Skip to content

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.

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.

git add . && git commit -m "update" | cat

This is decomposed into three commands:

  1. git add .
  2. git commit -m "update"
  3. cat

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.

rules:
- allow: 'git add *'
- allow: 'git commit *'
- deny: 'rm -rf *'

For the command git add . && rm -rf /tmp:

  1. git add .allow (priority 0)
  2. rm -rf /tmpdeny (priority 2)
  3. Final result: deny (strictest wins)

The entire compound command is blocked because one sub-command is denied.

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:

  1. git statusallow (priority 0)
  2. unknown-cmd → no rule matched → resolved to ask (priority 1)
  3. Final result: ask (strictest wins)

Without this resolution, unmatched sub-commands would be silently ignored.

When sub-commands have different sandbox presets, the sandbox policies are merged using the strictest intersection:

Policy fieldMerge strategyRationale
fs.writableIntersectionOnly paths writable by all sub-commands are allowed
fs.denyUnionPaths denied by any sub-command are denied
network.allowANDNetwork is blocked if any sub-command denies it

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

For build-a release && build-b release:

  1. build-a releaseallow with sandbox project-a (writable: /project-a)
  2. build-b releaseallow with sandbox project-b (writable: /project-b)
  3. Writable intersection: empty (contradiction)
  4. Final result: ask (escalated from allow)

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.