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
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")

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

Diagram

Command-Aware Permission Check

deny wins

ask

allow

🤖 Agent

🔧 Tool Call

🔍 CommandParser

⚙️ Per-Op Check

deny / ask / allow

🚫 Blocked

❓ Ask User

✅ Allowed

Quick Start

  1. Block destructive commands — even in compound form

    A single deny rule on bash:rm * now catches rm wherever it appears:

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

  2. Protect files from redirect overwrites

    Truncating redirections (>, >>) emit a write: sub-target. A write: deny rule catches them:

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


How It Works

Diagram
CommandParserPermissionManagerToolAgentUserCommandParserPermissionManagerToolAgentUserRun taskbash:cd /tmp && rm -rf xcheck(“bash:cd /tmp && rm -rf x”)parse_command(“cd /tmp && rm -rf x”)[ShellOp(cd), ShellOp(rm)]check each op + original targetdeny (rm matched deny rule)blockedPermission denied
StepAction
1Tool call arrives as bash:<cmd> target
2command_parser.parse_command decomposes into ShellOp list
3Each op evaluated as its own sub-target (bash:<exe> <args> and write:<path>)
4Original compound target also evaluated (legacy flat-rule compatibility)
5Aggregate: deny wins → ask → allow

What Gets Decomposed

Operator / ConstructExampleResult
&& (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]
$(...) substitutionecho $(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 prefixFOO=bar rm x[rm]
Path-like arg outside workspacecat /etc/passwd[cat] + external_dir:/etc/*
Redirect target outside workspaceecho x > ~/.bashrc[echo] + external_dir:<home>/*
Executable referenced by path outside workspace/tmp/tool.sh[/tmp/tool.sh] + external_dir:/tmp/*
POSIX wrapper prefix (sudo, env, command, nohup, nice, ionice, stdbuf, setsid, time, timeout, exec, busybox, xargs, doas)sudo rm -rf x[rm]
Wrapper with value flag (sudo -u, doas -u, nice -n, ionice -c/-n/-p, env -u/-C/-S)sudo -u root rm -rf x[rm] (value token for -u is skipped)
Wrapper with leading operand (timeout 5, nice -n 5, ionice -c 2)timeout 5s rm -rf x[rm] (duration operand with optional s/m/h/d for timeout)
Standard-bindir path prefix (/bin/, /sbin/, /usr/bin/, /usr/sbin/, /usr/local/bin/, /usr/local/sbin/, /opt/homebrew/bin/)/bin/rm -rf x[rm]
POSIX sh -c / bash -c / zsh -c / dash -c / ksh -c / ash -cbash -c 'rm -rf x'recurse into the quoted script, carrying its own inner redirects
External wrapper path (/tmp/sudo cmd, /tmp/bash -c '…')/tmp/sudo rm -rf x[/tmp/sudo] + [rm] — the external path stays as its own op so the workspace-boundary gate still prompts for running /tmp/sudo
Executable at a non-standard path (/tmp/tool, ../tool, /bin/../x)/tmp/toolkept intact — not reduced to a basename, so the workspace-boundary gate still fires

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.

python
from praisonaiagents import Agent
from praisonaiagents.approval.protocols import ApprovalConfig

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

# All of these are blocked by the single Remove-Item deny above:
#   powershell -Command "Remove-Item C:\tmp\x"
#   pwsh -c "Remove-Item .\out.txt"
#   powershell -NoProfile -Command "Remove-Item .\x"
#   powershell -Command "& { Remove-Item .\x }"
#   powershell -EncodedCommand <base64-of-'Remove-Item .\x'>
agent.start("Clean up temp files")
Diagram
graph LR
    Cmd[📥 Shell command] --> Detect{Executable is<br/>powershell / pwsh / cmd?}
    Detect -->|No| Posix[🐧 POSIX parse<br/>unchanged]
    Detect -->|Yes| Unwrap[🪟 Unwrap<br/>-Command / -c / /c]
    Unwrap --> Encoded{Is<br/>-EncodedCommand?}
    Encoded -->|Yes| Decode[🔓 Base64 UTF-16LE decode]
    Encoded -->|No| Strip[✂️ Strip scriptblock<br/>&amp; { … }]
    Decode --> Strip
    Strip --> Inner[🔧 Parse inner<br/>in matching dialect]
    Inner --> Gate[🛡️ Same deny→ask→allow<br/>as POSIX]

    classDef input fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef decide fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef posix fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef win fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef ok fill:#10B981,stroke:#7C90A0,color:#fff

    class Cmd input
    class Detect,Encoded decide
    class Posix posix
    class Unwrap,Decode,Strip,Inner win
    class Gate ok

What Windows dialects understand

Wrapper / constructExampleResult
powershell -Command "…"powershell -Command "Remove-Item C:\tmp\x"[Remove-Item] (dialect = powershell)
pwsh -c "…" (short flag)pwsh -c "New-Item .\out.txt"[New-Item]
cmd /c "…"cmd /c "del C:\tmp\x"[del] (dialect = cmd)
Option-prefixed PSpowershell -NoProfile -Command "Remove-Item C:\outside\x"[Remove-Item] (switches before -Command are skipped)
Option-prefixed cmdcmd /d /c "del C:\outside\x"[del]
PowerShell pipeline`powershell -Command “Get-Content a.txt \Set-Content b.txt”`[Get-Content, Set-Content]
Call-operator scriptblockpowershell -Command "& { Remove-Item .\x }"[Remove-Item] — { is not left as the executable
Bare call operatorpowershell -Command "& Remove-Item .\x"[Remove-Item]
-EncodedCommand (base64 UTF-16LE)powershell -EncodedCommand UgBlAG0Abw…decoded, then parsed inline
-e / -ec abbreviationspwsh -e UgBlAG0Abw…decoded, then parsed inline
Undecodable -EncodedCommandpowershell -EncodedCommand not!valid!base64fail-closed — stays a single opaque powershell op
powershell script.ps1 (no -Command)powershell script.ps1opaque single op — filename is not misparsed as a command flag
Drive-letter path argpowershell -Command "Remove-Item C:\tmp\x"C:\tmp\x is a path_arg (POSIX would not see it)
Relative Windows path argpowershell -Command "Set-Content .\out.txt hi".\out.txt is a path_arg
UNC / backslash tokens\\host\share\x, .\x, ..\xrecognised as Windows path shapes

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.

Workspace boundary on Windows

The workspace boundary fires on Windows paths the same way it fires on POSIX paths.

python
from praisonaiagents import Agent
from praisonaiagents.approval.protocols import ApprovalConfig

agent = Agent(
    name="Windows Worker",
    instructions="Only write inside the workspace",
    approval=ApprovalConfig(permissions={"bash:*": "allow"}),
)

# Inside workspace → allowed
#   powershell -Command "Set-Content .\note.txt hi"
#
# Outside workspace → prompts (external_dir:* boundary fires)
#   powershell -Command "Remove-Item C:\Users\Public\secret.txt"
#   powershell -NoProfile -Command "Remove-Item C:\outside\x"
#   powershell -EncodedCommand <base64-of-'Remove-Item C:\outside\x'>
agent.start("Save today's notes")
  • 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.

Diagram

POSIX only

Windows only

Both

What do you want to block?

POSIX only,
Windows only,
or both?

bash:rm *

bash:Remove-Item *

Two rules:
bash:rm *
bash:Remove-Item *

Path-based boundary
catches the rest automatically


POSIX wrappers and absolute paths

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

python
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",
    }),
)

# The single bash:rm * deny now catches every wrapper-prefixed form:
#   sudo rm -rf x
#   sudo -u root rm -rf x
#   env FOO=1 rm -rf x
#   /bin/rm -rf x
#   nice -n 5 rm -rf x
#   timeout 5s rm -rf x
#   xargs rm x
#   bash -c 'rm -rf x'
agent.start("Clean up temp files")
Diagram
_normalize_posix_argvPermissionManagerToolAgent_normalize_posix_argvPermissionManagerToolAgentbash:sudo rm -rf xcheck(“bash:sudo rm -rf x”)strip wrapper prefix[ShellOp(rm)]check inner rm opdeny (rm matched bash:rm *)blocked

What POSIX wrappers understand

Wrapper / constructExampleResult
Prefix wrappersudo rm -rf x[rm]
Wrapper with value flagsudo -u root rm -rf x[rm] (root skipped as -u‘s value)
env with assignmentsenv FOO=1 rm -rf x[rm] (leading VAR=val skipped)
Duration/priority operandtimeout 5s rm -rf x, nice -n 5 rm -rf x[rm] (numeric operand skipped)
-- ends option parsingsudo -u root -- rm -rf x[rm]
Standard-bindir path/usr/bin/rm -rf x[rm]
POSIX sh/bash/zsh/dash/ksh/ash -cbash -c 'rm -rf x'recurse into the quoted script
Nested wrapperssudo env nice rm -rf x[rm] (unwrapped left-to-right)

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.

PatternExampleResolution
$IFS word-splittingrm${IFS}-rf${IFS}/ASK (deny still wins)
${VAR} expansionrm -rf ${TARGET_DIR}ASK (deny still wins)
Bare $VARrm -rf $HOMEASK (deny still wins)
$(...) substitutionecho $(rm -rf x)Decomposed per-op (existing behavior)
Backtick substitution` echo rm -rf x `Decomposed per-op (existing behavior)
python
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.

Diagram
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

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

python
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",
    }),
)

# Unbalanced quote → ASK (explicit deny still wins if one matches):
#   echo 'x; rm -rf x      → ASK
agent.start("Print a status line")
Diagram

No

Yes

Yes

No

📥 Shell command

shlex.split
raises?

🔧 Tokenize & decompose

Explicit deny
matches?

🚫 DENY

❓ ASK user


Evasions Now Blocked

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

CommandPreviousNow
bash:rm -rf /tmpdenieddenied (unchanged)
bash:cd /tmp && rm -rf xALLOWEDdenied
bash:ls; rm -rf xALLOWEDdenied
`bash:cat foo \rm x`ALLOWEDdenied
bash:echo $(rm -rf x)ALLOWEDdenied
bash:echo \rm -rf x\“ALLOWEDdenied
bash:(cd /tmp && rm -rf x)ALLOWEDdenied
bash:/bin/rm -rf xALLOWEDdenied
bash:sudo rm -rf xALLOWEDdenied
bash:sudo -u root rm -rf xALLOWEDdenied
bash:env rm -rf xALLOWEDdenied
bash:env FOO=1 rm -rf xALLOWEDdenied
bash:command rm -rf xALLOWEDdenied
bash:nohup rm -rf xALLOWEDdenied
bash:nice rm -rf x / bash:nice -n 5 rm -rf xALLOWEDdenied
bash:ionice -c 2 rm -rf xALLOWEDdenied
bash:stdbuf rm -rf xALLOWEDdenied
bash:setsid rm -rf xALLOWEDdenied
bash:time rm -rf xALLOWEDdenied
bash:timeout 5 rm -rf x / bash:timeout 5s rm -rf xALLOWEDdenied
bash:exec rm -rf xALLOWEDdenied
bash:busybox rm -rf xALLOWEDdenied
bash:xargs rm xALLOWEDdenied
bash:doas rm -rf xALLOWEDdenied
bash:bash -c 'rm -rf x'ALLOWEDdenied
bash:sh -c 'rm -rf x'ALLOWEDdenied
bash:zsh -c 'cat foo > /etc/hosts' (with write:/etc/* deny)ALLOWEDdenied (inner redirect preserved)
bash:echo 'x; rm -rf x (unbalanced quote)ALLOWEDASK (new: parse error escalates; an explicit deny still wins)
execute_command({"command=": "rm -rf /tmp/x"}) (argument-key mangling)ALLOWEDdenied
bash:echo '$(rm -rf x)' (single-quoted)deniedallowed (correctly — literal)

Windows dialects — with a matching cmdlet deny like bash:Remove-Item (or bash:del / bash:New-Item *):

CommandPreviousNow
bash:powershell -Command "Remove-Item x"ALLOWEDdenied
bash:cmd /c "del x"ALLOWEDdenied
bash:pwsh -c "New-Item x"ALLOWEDdenied
bash:powershell -EncodedCommand … (decodes to Remove-Item x)ALLOWEDdenied
bash:powershell -Command "& { Remove-Item x }"ALLOWEDdenied

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.

Diagram
PermissionManagerbuild_permission_target_check_permission_manager_denyAgentPermissionManagerbuild_permission_target_check_permission_manager_denyAgentexecute_command({“command”: “rm -rf /tmp/x”})normalise kwargs (strip trailing ‘=’)(execute_command, {“command”: “rm -rf /tmp/x”})“bash:rm -rf /tmp/x”is_denied(“execute_command”)?nois_denied(“bash:rm -rf /tmp/x”)?YES (matches “bash:rm *”){“permission_denied”: True}
python
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)

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
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
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 resultsAggregate
Any denydeny
No deny, any askask
All allowallow

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:

python
approval=ApprovalConfig(permissions={
    "bash:rm *": "deny",
    "bash:mv *": "deny",
    "bash:dd *": "deny",
    "*": "allow",
})

Protect a config directory from redirects:

python
approval=ApprovalConfig(permissions={
    "write:/etc/*": "deny",
    "*": "allow",
})

CI runner — deny by default, allow git only:

bash
praisonai run "Deploy check" \
  --permission-default deny \
  --allow 'bash:git *'

Best Practices

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

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

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

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

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

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