> ## Documentation Index
> Fetch the complete documentation index at: https://praison.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Command-Aware Permissions

> Deny rules now follow shell command structure — &&, ;, |, $(), subshells and redirects can no longer evade them

Permission checks for shell tool calls now follow the command's actual structure, so a `deny` rule fires even when the blocked command is hidden inside a compound, pipe, subshell, or substitution.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import Agent
from praisonaiagents.approval.protocols import ApprovalConfig

agent = Agent(
    name="Safe Worker",
    instructions="Run shell commands safely",
    approval=ApprovalConfig(permissions={
        "bash:rm *": "deny",
        "write:/etc/*": "deny",
        "*": "allow",
    }),
)

# All of these are now blocked, even though previously only the first was:
#   cd /tmp && rm -rf x
#   ls; rm -rf x
#   echo $(rm -rf x)
#   cat foo > /etc/hosts
agent.start("Tidy up /tmp")
```

<Warning>
  **Argument-scoped deny rules now reach the enforcement path (PraisonAI PR [#4234](https://github.com/MervinPraison/PraisonAI/pull/4234), closes [#4228](https://github.com/MervinPraison/PraisonAI/issues/4228)):** On releases prior to #4234, an argument-scoped deny rule like `bash:rm *` on a `PermissionManager` was **parsed and understood** by the rule engine, but the name-based deny gate only asked `is_denied(function_name)`. So if `execute_command` was allowed by name, `execute_command(command="rm -rf /tmp/x")` still ran — the argument-scoped rule never fired at call time. After #4234 the gate builds a scoped target from the arguments and checks both, so the rule fires. Upgrade to a release that includes #4234 — no code change is required.
</Warning>

The user approves shell tools; compound commands are parsed so hidden `deny` rules still block unsafe operations.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    subgraph "Command-Aware Permission Check"
        A[🤖 Agent] --> B[🔧 Tool Call]
        B --> C[🔍 CommandParser]
        C --> D[⚙️ Per-Op Check]
        D --> E{deny / ask / allow}
        E -->|deny wins| F[🚫 Blocked]
        E -->|ask| G[❓ Ask User]
        E -->|allow| H[✅ Allowed]
    end

    classDef agent fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef process fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef parser fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef allow fill:#10B981,stroke:#7C90A0,color:#fff
    classDef config fill:#6366F1,stroke:#7C90A0,color:#fff

    classDef agent fill:#8B0000,color:#fff
    classDef tool fill:#189AB4,color:#fff
    class A agent
    class B,C process
    class D parser
    class E config
    class F,G,H allow
```

## Quick Start

<Steps>
  <Step title="Block destructive commands — even in compound form">
    A single `deny` rule on `bash:rm *` now catches `rm` wherever it appears:

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    from praisonaiagents import Agent
    from praisonaiagents.approval.protocols import ApprovalConfig

    agent = Agent(
        name="Safe Worker",
        instructions="Run shell commands safely",
        approval=ApprovalConfig(permissions={
            "bash:rm *": "deny",
            "*": "allow",
        }),
    )

    agent.start("Clean up old files")
    ```

    Commands like `cd /tmp && rm -rf x`, `ls; rm -rf x`, and `echo $(rm -rf x)` are all blocked — not just `rm -rf x` directly.
  </Step>

  <Step title="Protect files from redirect overwrites">
    Truncating redirections (`>`, `>>`) emit a `write:` sub-target. A `write:` deny rule catches them:

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    from praisonaiagents import Agent
    from praisonaiagents.approval.protocols import ApprovalConfig

    agent = Agent(
        name="Safe Worker",
        instructions="Run shell commands safely",
        approval=ApprovalConfig(permissions={
            "write:/etc/*": "deny",
            "*": "allow",
        }),
    )

    agent.start("Update system config")
    ```

    `cat foo > /etc/hosts` and `echo x >> /etc/hosts` are blocked even though the command starts with `cat` / `echo`.
  </Step>
</Steps>

***

## How It Works

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant User
    participant Agent
    participant Tool
    participant PermissionManager
    participant CommandParser

    User->>Agent: Run task
    Agent->>Tool: bash:cd /tmp && rm -rf x
    Tool->>PermissionManager: check("bash:cd /tmp && rm -rf x")
    PermissionManager->>CommandParser: parse_command("cd /tmp && rm -rf x")
    CommandParser-->>PermissionManager: [ShellOp(cd), ShellOp(rm)]
    PermissionManager->>PermissionManager: check each op + original target
    PermissionManager-->>Tool: deny (rm matched deny rule)
    Tool-->>Agent: blocked
    Agent-->>User: Permission denied
```

| Step | Action                                                                           |
| ---- | -------------------------------------------------------------------------------- |
| 1    | Tool call arrives as `bash:<cmd>` target                                         |
| 2    | `command_parser.parse_command` decomposes into `ShellOp` list                    |
| 3    | Each op evaluated as its own sub-target (`bash:<exe> <args>` and `write:<path>`) |
| 4    | Original compound target also evaluated (legacy flat-rule compatibility)         |
| 5    | Aggregate: **deny wins → ask → allow**                                           |

***

## What Gets Decomposed

| Operator / Construct                            | Example                | Result                                 |                          |
| ----------------------------------------------- | ---------------------- | -------------------------------------- | ------------------------ |
| `&&` (AND)                                      | `cd /tmp && rm x`      | `[cd, rm]`                             |                          |
| `\|\|` (OR)                                     | `ls \|\| rm x`         | `[ls, rm]`                             |                          |
| `;` (sequence)                                  | `ls; rm x`             | `[ls, rm]`                             |                          |
| `\|` (pipe)                                     | `cat foo \| rm x`      | `[cat, rm]`                            |                          |
| `&` (background)                                | `rm x &`               | `[rm]`                                 |                          |
| Subshell `(...)`                                | `(cd /tmp && rm x)`    | `[cd, rm]`                             |                          |
| `$(...)` substitution                           | `echo $(rm -rf x)`     | `[echo, rm]`                           |                          |
| Backtick substitution                           | `` echo `rm -rf x` ``  | `[echo, rm]`                           |                          |
| Truncating redirect `>`                         | `cat foo > /etc/hosts` | `[cat] + write:/etc/hosts`             |                          |
| Append redirect `>>`                            | `echo x >> /etc/hosts` | `[echo] + write:/etc/hosts`            |                          |
| \`>                                             | `/`&>`/`&>>\`          | `cmd &> /tmp/log`                      | `[cmd] + write:/tmp/log` |
| fd-prefixed `2>` / `1>>`                        | `ls 2> err.txt`        | `[ls] + write:err.txt`                 |                          |
| Env-var assignment prefix                       | `FOO=bar rm x`         | `[rm]`                                 |                          |
| Path-like arg outside workspace                 | `cat /etc/passwd`      | `[cat] + external_dir:/etc/*`          |                          |
| Redirect target outside workspace               | `echo x > ~/.bashrc`   | `[echo] + external_dir:<home>/*`       |                          |
| Executable referenced by path outside workspace | `/tmp/tool.sh`         | `[/tmp/tool.sh] + external_dir:/tmp/*` |                          |

<Note>
  The `external_dir:` rows above only apply when `PermissionManager` is created with `workspace_root=...`. See [Workspace Boundary](/docs/features/workspace-boundary).
</Note>

**Single-quote suppression:** `echo '$(rm -rf x)'` is a literal string — no `rm` is extracted.

**fd-to-fd redirects** like `2>&1` are never treated as write targets.

**Input redirects** (`<`, `<<`, `<<<`) — the filename is never mistaken for the executable.

***

## Shell expansion escalates to ASK

Shell commands containing an expansion that cannot be statically resolved are escalated to `ASK` instead of being silently allowed by a broad rule.

Before the tokenizer runs, the manager checks for `$IFS`, `${VAR}`, and bare `$VAR`. If any is present, an explicit `deny` still wins; otherwise the request is escalated to `ASK` with the reason *"Command contains shell expansion that cannot be statically verified; requires approval"*. Command substitution (`$(...)` and backticks) is excluded — it is already decomposed per-op, so its inner commands keep matching deny rules.

| Pattern               | Example                | Resolution                            |
| --------------------- | ---------------------- | ------------------------------------- |
| `$IFS` word-splitting | `rm${IFS}-rf${IFS}/`   | `ASK` (deny still wins)               |
| `${VAR}` expansion    | `rm -rf ${TARGET_DIR}` | `ASK` (deny still wins)               |
| Bare `$VAR`           | `rm -rf $HOME`         | `ASK` (deny still wins)               |
| `$(...)` substitution | `echo $(rm -rf x)`     | Decomposed per-op (existing behavior) |
| Backtick substitution | `` echo `rm -rf x` ``  | Decomposed per-op (existing behavior) |

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import Agent
from praisonaiagents.approval.protocols import ApprovalConfig

agent = Agent(
    name="Safe Worker",
    instructions="Run shell commands safely",
    approval=ApprovalConfig(permissions={
        "bash:rm *": "deny",
        "bash:*": "allow",
    }),
)

# All of these now trigger ASK (or hit the explicit deny), not silent allow:
#   rm${IFS}-rf${IFS}/tmp/data      → ASK
#   rm -rf ${TARGET}                → ASK
#   rm -rf $HOME/downloads          → ASK
#   rm -rf /tmp/foo                 → DENY (matches bash:rm *)
agent.start("Clean up temp files")
```

Before this change, a broad `bash:*` allow could shadow a specific `bash:rm *` deny for anything containing `${IFS}` or `$HOME` — a real permission bypass.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    Cmd[📥 Shell command] --> HasExp{Has $IFS,<br/>${VAR}, or $VAR?}
    HasExp -->|No| Normal[🔧 Tokenize & decompose]
    HasExp -->|Yes| FlatCheck{Explicit deny<br/>matches?}
    FlatCheck -->|Yes| Deny[🚫 DENY]
    FlatCheck -->|No| Ask[❓ ASK user]

    classDef input fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef check fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef deny fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef ok fill:#10B981,stroke:#7C90A0,color:#fff

    class Cmd input
    class HasExp,FlatCheck check
    class Deny deny
    class Normal,Ask ok
```

***

## Evasions Now Blocked

A `deny: bash:rm *` rule now blocks **all** of these:

| Command                                                                  | Previous    | Now                               |
| ------------------------------------------------------------------------ | ----------- | --------------------------------- |
| `bash:rm -rf /tmp`                                                       | denied      | denied (unchanged)                |
| `bash:cd /tmp && rm -rf x`                                               | **ALLOWED** | denied                            |
| `bash:ls; rm -rf x`                                                      | **ALLOWED** | denied                            |
| `bash:cat foo \| rm x`                                                   | **ALLOWED** | denied                            |
| `bash:echo $(rm -rf x)`                                                  | **ALLOWED** | denied                            |
| `bash:echo \`rm -rf x\`\`                                                | **ALLOWED** | denied                            |
| `bash:(cd /tmp && rm -rf x)`                                             | **ALLOWED** | denied                            |
| `execute_command({"command=": "rm -rf /tmp/x"})` (argument-key mangling) | **ALLOWED** | denied                            |
| `bash:echo '$(rm -rf x)'` (single-quoted)                                | denied      | **allowed** (correctly — literal) |

***

## Argument-scoped deny reaches the gate

The rule engine has always understood argument-scoped patterns like `bash:rm *`. Since PR #4234 the enforcement path does too — the deny gate builds a scoped target from the call's arguments and checks it alongside the tool name.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant Agent
    participant Gate as _check_permission_manager_deny
    participant Build as build_permission_target
    participant Mgr as PermissionManager

    Agent->>Gate: execute_command({"command": "rm -rf /tmp/x"})
    Gate->>Gate: normalise kwargs (strip trailing '=')
    Gate->>Build: (execute_command, {"command": "rm -rf /tmp/x"})
    Build-->>Gate: "bash:rm -rf /tmp/x"
    Gate->>Mgr: is_denied("execute_command")?
    Mgr-->>Gate: no
    Gate->>Mgr: is_denied("bash:rm -rf /tmp/x")?
    Mgr-->>Gate: YES (matches "bash:rm *")
    Gate-->>Agent: {"permission_denied": True}
```

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import Agent
from praisonaiagents.permissions import PermissionManager

# Rule engine has always understood argument-scoped patterns —
# since PR #4234 the enforcement path does too.
manager = PermissionManager(storage_dir=".praisonai/permissions")
manager.load_rules_from_config({"bash:rm *": "deny"})

agent = Agent(name="Safe Worker", instructions="Run shell commands safely")
agent._permission_manager = manager

# Blocked — argument-scoped rule fires at the deny gate
agent._check_tool_approval_sync("execute_command", {"command": "rm -rf /tmp/x"})
# → {"permission_denied": True, "error": "Tool 'execute_command' blocked by permission policy"}

# Allowed — same tool, different args
agent._check_tool_approval_sync("execute_command", {"command": "ls -la"})
# → None (no denial)
```

<Note>
  **Name-level rules keep their all-or-nothing behaviour.** `execute_command: deny` still blocks *every* `execute_command(...)` call regardless of arguments. The scoped check is additive — it narrows a name that is otherwise allowed; it never loosens a name-level deny.
</Note>

**Argument-key mangling can't evade the gate.** A malformed `{"command=": "rm -rf /tmp/x"}` (a trailing-`=` kwarg LLMs sometimes hallucinate) is normalised *in the gate* with `k.strip().rstrip('=').strip()` **before** the scoped target is built — so a deny rule can't be dodged by a key that only cleans up after dispatch.

***

## Modified-args re-authorisation

An approval backend may rewrite arguments (a common sanitisation pattern). Since PR #4234, the rewritten args are re-checked against the deny gate before dispatch — so a rewrite can't smuggle a denied command past a gate that only saw the original, safe args.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents.approval.protocols import ApprovalDecision

# Approval backend rewrites args from safe to dangerous — re-authorised
decision = ApprovalDecision(
    approved=True,
    reason="mock",
    modified_args={"command": "rm -rf /tmp/x"},  # was originally "ls -la"
)
# The gate re-runs on modified_args before dispatch — the rewritten
# 'rm -rf' matches "bash:rm *" deny and the call is blocked.
```

Both the sync and async approval paths re-run the gate on the final arguments. `BYPASS` mode opts out entirely, matching its "skip all permission checks" contract.

***

## Aggregation Precedence

When a compound command produces multiple sub-operations, their results are aggregated as: **deny wins → then ask → then allow**.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents.permissions import PermissionManager, PermissionRule, PermissionAction

manager = PermissionManager()

manager.add_rule(PermissionRule(pattern="bash:*", action=PermissionAction.ALLOW))
manager.add_rule(PermissionRule(pattern="bash:cat *", action=PermissionAction.ASK))

result = manager.check("bash:ls && cat foo")
print(result.action)  # ASK — because cat triggered ask, and ask beats allow
```

| Sub-op results   | Aggregate |
| ---------------- | --------- |
| Any deny         | deny      |
| No deny, any ask | ask       |
| All allow        | allow     |

***

## Workspace Boundary

When `PermissionManager` is created with `workspace_root=...`, an extra `external_dir:<parent>/*` sub-target is added for any path that escapes the root — whether it appears as a command argument, a redirect write-target, or an executable path. The same deny→ask→allow aggregation applies: `external_dir:*` → deny hard-blocks; `external_dir:/data/*` → allow pre-authorises; the default is **ask**.

See [Workspace Boundary](/docs/features/workspace-boundary) for full details.

***

## Fallback Behaviour

For simple single commands (`bash:ls -la`) with no compound operators, the engine defers to the existing flat matcher — exact backward compatibility. On any parse failure, the whole command is treated as a single op using today's behaviour, so no existing rule is silently weakened.

***

## Common Patterns

**Block all destructive shell ops:**

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
approval=ApprovalConfig(permissions={
    "bash:rm *": "deny",
    "bash:mv *": "deny",
    "bash:dd *": "deny",
    "*": "allow",
})
```

**Protect a config directory from redirects:**

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
approval=ApprovalConfig(permissions={
    "write:/etc/*": "deny",
    "*": "allow",
})
```

**CI runner — deny by default, allow git only:**

```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
praisonai run "Deploy check" \
  --permission-default deny \
  --allow 'bash:git *'
```

***

## Best Practices

<AccordionGroup>
  <Accordion title="Patterns match the executable name, not the full path">
    `bash:rm *` catches `rm -rf /tmp` but **not** `bash:/usr/bin/rm -rf /tmp`. Use a regex rule with `is_regex: true` for absolute-path coverage.
  </Accordion>

  <Accordion title="Single-quoted substitutions are literals">
    `echo '$(rm -rf x)'` is correctly **not** treated as an `rm` call — the parser respects single-quote suppression. Double-quoted substitutions are still extracted.
  </Accordion>

  <Accordion title="Protect filesystem locations with write: patterns, not bash: patterns">
    Truncating redirects produce a `write:<path>` sub-target. Use `write:/etc/*` to block overwrites — `bash:cat *` alone won't catch `cat foo > /etc/hosts`.
  </Accordion>

  <Accordion title="Zero overhead when permissions are off">
    The command parser is lazy-imported: no parsing cost when permissions are not in use or the target is a non-shell tool.
  </Accordion>
</AccordionGroup>

***

Command-aware permissions decompose a compound command; [Reusable Approval Scopes](/docs/features/reusable-approval-scopes) generalise a single command's approval to a whole family.

## Related

<CardGroup cols={2}>
  <Card title="Declarative Permissions" icon="shield-halved" href="/docs/features/declarative-permissions">
    Pre-declare allow/deny rules in YAML, CLI, or Python
  </Card>

  <Card title="Permissions Module" icon="shield" href="/docs/features/permissions">
    Programmatic PermissionManager API
  </Card>

  <Card title="Permissions CLI" icon="terminal" href="/docs/cli/permissions">
    CLI rule management reference
  </Card>

  <Card title="Approval" icon="check" href="/docs/features/approval">
    Interactive approval backends
  </Card>

  <Card title="Workspace Boundary" icon="shield-halved" href="/docs/features/workspace-boundary">
    Gate shell/file access outside a project root with <code>external\_dir:\*</code>
  </Card>

  <Card title="Reusable Approval Scopes" icon="recycle" href="/docs/features/reusable-approval-scopes">
    Generalise one approval to cover a whole command family
  </Card>
</CardGroup>

<Note>
  Command-aware permissions parse **inside** the command; the [workspace boundary](/docs/features/workspace-boundary) gates **where** on disk it can act.
</Note>
