Compound Commands
Shell commands often combine multiple operations using pipes (|), logical operators (&&, ||), semicolons (;), and other constructs. runok splits these compound commands into individual sub-commands and evaluates each one separately.
Decomposition
Section titled “Decomposition”runok uses tree-sitter-bash to parse compound commands into an AST. The extract_commands() function (src/rules/command_parser/splitter/mod.rs) recursively walks the AST and extracts individual simple commands from:
- Pipelines:
cmd1 | cmd2 - Logical AND/OR:
cmd1 && cmd2,cmd1 || cmd2 - Command lists:
cmd1 ; cmd2 - Subshells:
(cmd1 && cmd2) - Redirected statements:
cmd1 > file - Variable assignments with commands:
VAR=value cmd1 - Negated commands:
! cmd1 - For loops:
for x in a b; do cmd1; done - Case statements:
case $x in a) cmd1;; esac - Function definitions:
f() { cmd1; }
Each extracted command is evaluated independently as if it were run alone.
Example
Section titled “Example”git add . && git commit -m "update" | catThis is decomposed into three commands:
git add .git commit -m "update"cat
Variable resolution
Section titled “Variable resolution”runok tracks shell variable assignments (X=value) within a single command string and resolves $X / ${X} references to their statically known value before rule evaluation, if the assignment is unconditional and its value contains no dynamic content.
rules: - deny: 'git push --force*' - allow: 'git push *'For the command F=--force; git push $F:
F=--forceis a static, unconditional assignment —Fis recorded as--force.git push $Fresolves togit push --forcebefore matching.- Final result: deny — the flag can no longer evade the rule by being smuggled through a variable.
What resolves
Section titled “What resolves”- A value made only of literal text, a single- or double-quoted string with no interpolation, or a concatenation of those (
X=1,X="git status",X='rm -rf'). - The resolved value substitutes for
$X/${X}wherever it’s referenced, including the command name position (X=rm; $X -rf /evaluates asrm -rf /). - An unquoted reference is split on whitespace like bash’s default
IFS(X="git status"; $Xevaluates as two tokens,gitandstatus); a quoted reference ("$X") is not split. - A subshell or command substitution (
(...),$(...)) forks a child shell, so a reassignment inside it never overwrites the value the parent scope resolves to afterward (X=--force; (X=--safe); git push $Xstill evaluates the outergit push $Xasgit push --force).
What does not resolve
Section titled “What does not resolve”The following are recorded as unresolvable and left as the literal $X / ${X} text:
- A dynamic value:
$(...),`...`, process substitution, or arithmetic expansion (X=$(cat f)). - A reassignment of a name that was previously dynamic in the same command string (
X=1; X=$(date); echo $X— the stale1is never reused). - An assignment inside a conditional or loop body (
if/elif/else/case/for/while/until), since it may run zero, one, or many times:if true; then X=rm; fi; $X /leaves$Xunresolved even though the branch always runs. - The right-hand side of
&&/||, since it only runs depending on the left side’s exit status:X=--force; false && X=--safe; git push $Xleaves$Xunresolved. - A
forloop’s own iteration variable, since its value changes every iteration (for i in a b; do echo $i; donenever resolves$i). - An array-element assignment (
arr[0]=x), and a bareexport/declare/unsetwith no value (X=1; export X; echo $Xleaves$Xunresolved —export Xalone doesn’t reassign it, but its current value isn’t statically known either). - An expansion with an operator, such as
${X:-default}or${X#pattern}. - Anything inside a function body at definition time (
f() { local X=1; }; echo $Xleaves the outer$Xunresolved) — the body’s variables are only resolved when the function is actually called, using the variables statically known at the call site.
Audit log
Section titled “Audit log”When variable resolution rewrites a command, the audit log’s command_evaluations entry records the resolved text in command (the value rule evaluation actually used) and the verbatim source in original_command, so both the decision and the original input remain inspectable. original_command is omitted when nothing was resolved.
{ "command": "git push --force", "original_command": "git push $F", "action": { "type": "deny", "detail": { "message": null, "fix_suggestion": null } }}See Audit Log JSON Schema for the full field reference.
Function call resolution
Section titled “Function call resolution”A shell function’s body (f() { ... }) is always extracted and evaluated unconditionally at definition time — see Decomposition above — regardless of whether the function is ever called. This is a safety backstop for cases runok cannot see statically, e.g. a persistent shell (like Claude Code’s) where a function defined in one tool call is invoked in a later one.
A call to that function, on the other hand, used to be matched as an ordinary, unknown command: f() { git push; }; f evaluated the call f against the configured rules, found no match, and fell through to defaults.action on every call — even when the body itself would always evaluate to allow.
runok now detects a call to a function defined earlier in the same command string, and evaluates the call by re-evaluating the body with the call’s own arguments bound to $1..$N / $@ / $* / $#, plus the script’s statically-resolved variables as of the call site (the same resolution described in Variable resolution above).
rules: - allow: 'git push' - deny: 'git push --force*'For the command f() { git push $1; }; f --force:
f’s body is extracted once, unconditionally, at definition time:git push $1($1stays literal — see What does not resolve above).- The call
f --forceis recognized as a call tof, defined earlier in the same command string. - The body is re-evaluated with
$1bound to--force:git push --force. - Final result: deny — the flag can no longer evade the rule by being smuggled through a function call’s own argument, the same way variable resolution closes the equivalent gap for a plain
$Xreference.
What calls resolve
Section titled “What calls resolve”- A call whose command name matches a function
name() { ... }defined earlier in the same command string (program order matters:f; f() { ... }does not resolvef, matching real bash’s “command not found”). - The call’s own arguments, bound to
$1..$Nin the body.$@and$*are both bound to every argument space-joined (matching how an unquoted$@/$*is word-split the same way any other multi-word value is — see What resolves above);$#is the argument count. - A call inside the resolved body to another function defined earlier in the script (
g() { git push; }; f() { g; }; fresolves bothfand the nestedg). - Redirects and the enclosing loop kind at the call site (
f() { git push; }; f > /pathattaches the redirect to the body’sgit push, and awhenclause referencingredirectssees it). - Multiple definitions of the same name (e.g. one per branch of an
if): every candidate body is evaluated, and the strictest result wins. - The function’s own body is still evaluated unconditionally at definition time, independent of any call — both evaluations are merged into the final result, so whichever is stricter always wins.
What calls do not resolve
Section titled “What calls do not resolve”- A call before its function is defined (
f; f() { git push; }leaves the firstfan unknown command). - Self-recursion or mutual recursion (
f() { f; }; f, orf() { g; }; g() { f; }; f): resolution stops the moment a function name reappears on the in-progress call stack, and falls back todefaults.actionfor that call instead of looping forever or erroring. - A call chain deeper than the shared wrapper recursion depth limit (10).
- A function defined in a separate tool call that this command string does not itself contain — only definitions appearing earlier in the same command string are visible. (The unconditional definition-time evaluation of the body is the safety backstop for exactly this case.)
- A body that fails to re-parse when re-evaluated with the call’s arguments substituted in — resolution is skipped for that candidate body, falling back to
defaults.actionthe same way an unresolved call does.
None of these ever produce an error: a call that cannot be resolved is simply evaluated as an ordinary, unknown command, the same as before this feature existed.
Strictest wins
Section titled “Strictest wins”After evaluating each sub-command, runok aggregates the results using the same Explicit Deny Wins logic:
The most restrictive action across all sub-commands becomes the final action.
The priority order is: deny > ask > allow.
Example
Section titled “Example”rules: - allow: 'git add *' - allow: 'git commit *' - deny: 'rm -rf *'For the command git add . && rm -rf /tmp:
git add .→allow(priority 0)rm -rf /tmp→deny(priority 2)- Final result: deny (strictest wins)
The entire compound command is blocked because one sub-command is denied.
Default action resolution
Section titled “Default action resolution”When a sub-command does not match any rule, its action is resolved immediately to the configured defaults.action (defaulting to ask if unconfigured). This ensures unmatched sub-commands participate in the aggregation at their effective restriction level.
defaults: action: ask
rules: - allow: 'git status'For the command git status && unknown-cmd:
git status→allow(priority 0)unknown-cmd→ no rule matched → resolved toask(priority 1)- Final result: ask (strictest wins)
Without this resolution, unmatched sub-commands would be silently ignored.
Sandbox policy aggregation
Section titled “Sandbox policy aggregation”When sub-commands have different sandbox presets, the sandbox policies are merged using the strictest intersection:
| Policy field | Merge strategy | Rationale |
|---|---|---|
fs.writable | Intersection | Only paths writable by all sub-commands are allowed |
fs.deny | Union | Paths denied by any sub-command are denied |
network.allow | AND | Network is blocked if any sub-command denies it |
Writable contradiction escalation
Section titled “Writable contradiction escalation”If the intersection of fs.writable paths is empty — meaning sub-commands require incompatible write access — this is treated as a contradiction. The action is escalated to ask (unless it is already deny), alerting the user to the conflict.
definitions: sandbox: project-a: fs: writable: ['/project-a'] project-b: fs: writable: ['/project-b']
rules: - allow: 'build-a *' sandbox: project-a - allow: 'build-b *' sandbox: project-bFor build-a release && build-b release:
build-a release→allowwith sandboxproject-a(writable:/project-a)build-b release→allowwith sandboxproject-b(writable:/project-b)- Writable intersection: empty (contradiction)
- Final result: ask (escalated from
allow)
Parse failure fallback
Section titled “Parse failure fallback”If tree-sitter-bash fails to parse the compound command, the entire input string is treated as a single command and evaluated directly. This ensures that unusual or non-standard shell syntax does not cause an outright error.
Related
Section titled “Related”- Priority Model — How action priorities work.
- Sandbox Overview — How sandbox policies are defined and applied.