Skip to main content
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.
Argument-scoped deny rules now reach the enforcement path (PraisonAI PR #4234, closes #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.
The user approves shell tools; compound commands are parsed so hidden deny rules still block unsafe operations.

Quick Start

1

Block destructive commands — even in compound form

A single deny rule on bash:rm * now catches rm wherever it appears:
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.
2

Protect files from redirect overwrites

Truncating redirections (>, >>) emit a write: sub-target. A write: deny rule catches them:
cat foo > /etc/hosts and echo x >> /etc/hosts are blocked even though the command starts with cat / echo.

How It Works


What Gets Decomposed

The external_dir: rows above only apply when PermissionManager is created with workspace_root=.... See Workspace Boundary.
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.

Windows / PowerShell dialect

PowerShell and cmd wrappers are unwrapped so the inner cmdlet — not the opaque powershell -Command "…" — is what your rules see.

What Windows dialects understand

Recognised mutation vocabulary

Match is case-insensitive; a directory prefix on the executable is ignored. PowerShell (cmdlets + aliases): Remove-Item, New-Item, Move-Item, Copy-Item, Rename-Item, Set-Content, Add-Content, Clear-Content, Out-File, and aliases ri, rd, del, erase, ni, mi, move, cpi, copy, cp, rni, ren, sc, ac, rm, mv. cmd built-ins: del, erase, rd, rmdir, md, mkdir, move, copy, xcopy, ren, rename.
Get-Content, Get-ChildItem, and dir are not mutations — they follow the same read-vs-write distinction as POSIX.

Workspace boundary on Windows

The workspace boundary fires on Windows paths the same way it fires on POSIX paths.
  • The boundary check fires the same way it does for POSIX rm — Windows users no longer get a weaker default.
  • Option-prefixed forms (-NoProfile -Command …, cmd /d /c …) do not defeat the unwrap.
  • -EncodedCommand payloads are decoded (base64 UTF-16LE) before gating, so a base64 blob cannot slip an external write past the gate.
Anchor: Workspace Boundary.

Fail-closed behaviour

  • Undecodable -EncodedCommand → the wrapper stays opaque (executable is preserved as powershell), so a command-specific deny or allow on powershell * still applies; a broad wildcard does not silently authorise the payload.
  • powershell script.ps1 (no command flag) → stays opaque, filename is not treated as a command operand.
  • Any parse failure → whole-command fallback (existing POSIX behaviour, extended to Windows) — no rule is silently weakened.

Which rule should I write?

A rule name matches the executable name: POSIX pattern names catch POSIX executables; Windows cmdlet names catch cmdlets. The path-based workspace boundary is dialect-agnostic, so that half of the safety story needs no per-OS duplication.

POSIX wrappers and absolute paths

POSIX wrappers are unwrapped so a deny on the inner command still fires.

What POSIX wrappers understand

External paths escape the reduction. A wrapper invoked by an out-of-bindir path (/tmp/sudo cmd, /tmp/bash -c '…') keeps /tmp/sudo as its own op so the workspace-boundary gate still prompts for running it. See Workspace Boundary.

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.
Before this change, a broad bash:* allow could shadow a specific bash:rm * deny for anything containing ${IFS} or $HOME — a real permission bypass.

Unparseable commands escalate to ASK

A command whose quoting cannot be tokenized (shlex.split raises) is escalated to ASK so a mis-tokenized string can never slip past a specific deny. An unterminated quote like echo 'x; rm -rf x makes shlex.split raise, after which the fallback splitter mis-tokenizes the command and a specific deny can no longer be verified. An explicit DENY from the flat matcher still wins; otherwise the request is escalated to ASK with the reason “Command could not be parsed (unbalanced quotes?); requires approval”.

Evasions Now Blocked

A deny: bash:rm * rule now blocks all of these: Windows dialects — with a matching cmdlet deny like bash:Remove-Item * (or bash:del * / bash:New-Item *):

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.
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.
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.
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.

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 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:
Protect a config directory from redirects:
CI runner — deny by default, allow git only:

Best Practices

bash:rm * now catches /bin/rm, /usr/bin/rm, /usr/sbin/rm, /usr/local/bin/rm, /opt/homebrew/bin/rm. Executables at a non-standard path (/tmp/tool, ../tool) keep their path so the workspace-boundary gate still gates them — use a regex rule with is_regex: true only if you need to match those arbitrary paths too.
sudo rm, env rm, /bin/rm, nice rm, timeout 5 rm, xargs rm, bash -c 'rm', and the other POSIX wrappers all resolve to the inner command, so one bash:rm * deny covers them. Externally-pathed wrappers (/tmp/sudo cmd) still keep their path so the workspace-boundary gate prompts.
echo '$(rm -rf x)' is correctly not treated as an rm call — the parser respects single-quote suppression. Double-quoted substitutions are still extracted.
Truncating redirects produce a write:<path> sub-target. Use write:/etc/* to block overwrites — bash:cat * alone won’t catch cat foo > /etc/hosts.
The command parser is lazy-imported: no parsing cost when permissions are not in use or the target is a non-shell tool.
bash:Remove-Item * catches powershell -Command "Remove-Item …", pwsh -c "Remove-Item …", & { Remove-Item … } scriptblocks, and -EncodedCommand payloads that decode to Remove-Item. bash:rm * still catches POSIX rm; write both if your workflow targets both OSes.

Command-aware permissions decompose a compound command; Reusable Approval Scopes generalise a single command’s approval to a whole family.

Declarative Permissions

Pre-declare allow/deny rules in YAML, CLI, or Python

Permissions Module

Programmatic PermissionManager API

Permissions CLI

CLI rule management reference

Approval

Interactive approval backends

Workspace Boundary

Gate shell/file access outside a project root with external_dir:*

Reusable Approval Scopes

Generalise one approval to cover a whole command family
Command-aware permissions parse inside the command; the workspace boundary gates where on disk it can act.