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.
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.
Quick Start
- Block destructive commands — even in compound form
A single
denyrule onbash:rm *now catchesrmwherever 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, andecho $(rm -rf x)are all blocked — not justrm -rf xdirectly. - Protect files from redirect overwrites
Truncating redirections (
>,>>) emit awrite:sub-target. Awrite: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/hostsandecho x >> /etc/hostsare blocked even though the command starts withcat/echo.
How It Works
| 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/* | ||||
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 -c | bash -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/tool | kept 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.
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")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/>& { … }]
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 okWhat Windows dialects understand
| Wrapper / construct | Example | Result | |
|---|---|---|---|
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 PS | powershell -NoProfile -Command "Remove-Item C:\outside\x" | [Remove-Item] (switches before -Command are skipped) | |
| Option-prefixed cmd | cmd /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 scriptblock | powershell -Command "& { Remove-Item .\x }" | [Remove-Item] — { is not left as the executable | |
| Bare call operator | powershell -Command "& Remove-Item .\x" | [Remove-Item] | |
-EncodedCommand (base64 UTF-16LE) | powershell -EncodedCommand UgBlAG0Abw… | decoded, then parsed inline | |
-e / -ec abbreviations | pwsh -e UgBlAG0Abw… | decoded, then parsed inline | |
Undecodable -EncodedCommand | powershell -EncodedCommand not!valid!base64 | fail-closed — stays a single opaque powershell op | |
powershell script.ps1 (no -Command) | powershell script.ps1 | opaque single op — filename is not misparsed as a command flag | |
| Drive-letter path arg | powershell -Command "Remove-Item C:\tmp\x" | C:\tmp\x is a path_arg (POSIX would not see it) | |
| Relative Windows path arg | powershell -Command "Set-Content .\out.txt hi" | .\out.txt is a path_arg | |
| UNC / backslash tokens | \\host\share\x, .\x, ..\x | recognised 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.
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. -EncodedCommandpayloads 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 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.
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")What POSIX wrappers understand
| Wrapper / construct | Example | Result |
|---|---|---|
| Prefix wrapper | sudo rm -rf x | [rm] |
| Wrapper with value flag | sudo -u root rm -rf x | [rm] (root skipped as -u‘s value) |
env with assignments | env FOO=1 rm -rf x | [rm] (leading VAR=val skipped) |
| Duration/priority operand | timeout 5s rm -rf x, nice -n 5 rm -rf x | [rm] (numeric operand skipped) |
-- ends option parsing | sudo -u root -- rm -rf x | [rm] |
| Standard-bindir path | /usr/bin/rm -rf x | [rm] |
POSIX sh/bash/zsh/dash/ksh/ash -c | bash -c 'rm -rf x' | recurse into the quoted script |
| Nested wrappers | sudo 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.
| 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) |
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.
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 okUnparseable 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”.
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")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 | |
bash:/bin/rm -rf x | ALLOWED | denied | |
bash:sudo rm -rf x | ALLOWED | denied | |
bash:sudo -u root rm -rf x | ALLOWED | denied | |
bash:env rm -rf x | ALLOWED | denied | |
bash:env FOO=1 rm -rf x | ALLOWED | denied | |
bash:command rm -rf x | ALLOWED | denied | |
bash:nohup rm -rf x | ALLOWED | denied | |
bash:nice rm -rf x / bash:nice -n 5 rm -rf x | ALLOWED | denied | |
bash:ionice -c 2 rm -rf x | ALLOWED | denied | |
bash:stdbuf rm -rf x | ALLOWED | denied | |
bash:setsid rm -rf x | ALLOWED | denied | |
bash:time rm -rf x | ALLOWED | denied | |
bash:timeout 5 rm -rf x / bash:timeout 5s rm -rf x | ALLOWED | denied | |
bash:exec rm -rf x | ALLOWED | denied | |
bash:busybox rm -rf x | ALLOWED | denied | |
bash:xargs rm x | ALLOWED | denied | |
bash:doas rm -rf x | ALLOWED | denied | |
bash:bash -c 'rm -rf x' | ALLOWED | denied | |
bash:sh -c 'rm -rf x' | ALLOWED | denied | |
bash:zsh -c 'cat foo > /etc/hosts' (with write:/etc/* deny) | ALLOWED | denied (inner redirect preserved) | |
bash:echo 'x; rm -rf x (unbalanced quote) | ALLOWED | ASK (new: parse error escalates; an explicit deny still wins) | |
execute_command({"command=": "rm -rf /tmp/x"}) (argument-key mangling) | ALLOWED | denied | |
bash:echo '$(rm -rf x)' (single-quoted) | denied | allowed (correctly — literal) |
Windows dialects — with a matching cmdlet deny like bash:Remove-Item (or bash:del / bash:New-Item *):
| Command | Previous | Now |
|---|---|---|
bash:powershell -Command "Remove-Item x" | ALLOWED | denied |
bash:cmd /c "del x" | ALLOWED | denied |
bash:pwsh -c "New-Item x" | ALLOWED | denied |
bash:powershell -EncodedCommand … (decodes to Remove-Item x) | ALLOWED | denied |
bash:powershell -Command "& { Remove-Item x }" | ALLOWED | denied |
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.
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.
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.
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 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:
approval=ApprovalConfig(permissions={
"bash:rm *": "deny",
"bash:mv *": "deny",
"bash:dd *": "deny",
"*": "allow",
})Protect a config directory from redirects:
approval=ApprovalConfig(permissions={
"write:/etc/*": "deny",
"*": "allow",
})CI runner — deny by default, allow git only:
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
Pre-declare allow/deny rules in YAML, CLI, or Python
Permissions ModuleProgrammatic PermissionManager API
Permissions CLICLI rule management reference
ApprovalInteractive approval backends
Workspace BoundaryGate shell/file access outside a project root with <code>external_dir:*</code>
Reusable Approval ScopesGeneralise one approval to cover a whole command family