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