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.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.
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
Patterns match the executable name, not the full path
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.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.

