deny rule fires even when the blocked command is hidden inside a compound, pipe, subshell, or substitution.
deny rules still block unsafe operations.
Quick Start
1
Block destructive commands — even in compound form
A single Commands like
deny rule on bash:rm * now catches rm wherever it appears: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.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 andcmd 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. -EncodedCommandpayloads are decoded (base64 UTF-16LE) before gating, so a base64 blob cannot slip an external write past the gate.
Fail-closed behaviour
- Undecodable
-EncodedCommand→ the wrapper stays opaque (executable is preserved aspowershell), so a command-specific deny or allow onpowershell *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 toASK 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.
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
Adeny: 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 likebash: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.{"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.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
WhenPermissionManager 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:Best Practices
Standard bindir paths are reduced to the basename
Standard bindir paths are reduced to the basename
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.Wrappers no longer defeat a bash:rm * deny
Wrappers no longer defeat a bash:rm * deny
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.Single-quoted substitutions are literals
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.Protect filesystem locations with write: patterns, not bash: patterns
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.Zero overhead when permissions are off
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.
On Windows, prefer cmdlet names in your rules
On Windows, prefer cmdlet names in your rules
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.
Related
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.

