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

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

This is decomposed into three commands:

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

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:

  1. F=--force is a static, unconditional assignment — F is recorded as --force.
  2. git push $F resolves to git push --force before matching.
  3. Final result: deny — the flag can no longer evade the rule by being smuggled through a variable.
  • 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 as rm -rf /).
  • An unquoted reference is split on whitespace like bash’s default IFS (X="git status"; $X evaluates as two tokens, git and status); 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 $X still evaluates the outer git push $X as git push --force).

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 stale 1 is 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 $X unresolved 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 $X leaves $X unresolved.
  • A for loop’s own iteration variable, since its value changes every iteration (for i in a b; do echo $i; done never resolves $i).
  • An array-element assignment (arr[0]=x), and a bare export/declare/unset with no value (X=1; export X; echo $X leaves $X unresolved — export X alone 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 $X leaves the outer $X unresolved) — the body’s variables are only resolved when the function is actually called, using the variables statically known at the call site.

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.

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:

  1. f’s body is extracted once, unconditionally, at definition time: git push $1 ($1 stays literal — see What does not resolve above).
  2. The call f --force is recognized as a call to f, defined earlier in the same command string.
  3. The body is re-evaluated with $1 bound to --force: git push --force.
  4. 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 $X reference.
  • A call whose command name matches a function name() { ... } defined earlier in the same command string (program order matters: f; f() { ... } does not resolve f, matching real bash’s “command not found”).
  • The call’s own arguments, bound to $1..$N in 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; }; f resolves both f and the nested g).
  • Redirects and the enclosing loop kind at the call site (f() { git push; }; f > /path attaches the redirect to the body’s git push, and a when clause referencing redirects sees 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.
  • A call before its function is defined (f; f() { git push; } leaves the first f an unknown command).
  • Self-recursion or mutual recursion (f() { f; }; f, or f() { g; }; g() { f; }; f): resolution stops the moment a function name reappears on the in-progress call stack, and falls back to defaults.action for 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.action the 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.

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 > pass > 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 /tmp → deny (priority 3)
  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 pass 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 status → allow (priority 0)
  2. unknown-cmd → no rule matched → resolved to ask (priority 2)
  3. Final result: ask (strictest wins)

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

The Claude Code hook applies a sandbox to a compound command sub-command by sub-command by default: for each sub-command that needs a sandbox, runok replaces that sub-command’s own text in the original input — its KEY=VALUE env-assignment prefix and its own redirects included — with RUNOK_HOOK_ORIGIN=<token> runok exec --sandbox <preset> -- '<sub-command>', where <sub-command> is the replaced text, shell-quoted, and returns the rewritten string via updatedInput. Only the operators that join sub-commands together (|, &&, ||, ;) are left as-is, handled by the outer shell exactly as they would be for an unsandboxed command.

rules:
- allow: 'wc *'
sandbox: readonly
- allow: 'cat *'

For the command cat notes.txt > out.json | wc -l:

  1. cat notes.txt > out.json matches a rule with no sandbox field.

  2. wc -l matches a rule with sandbox readonly.

  3. runok replaces only wc -l’s own text:

    cat notes.txt > out.json | RUNOK_HOOK_ORIGIN=<token> runok exec --sandbox readonly -- 'wc -l'
  4. cat notes.txt > out.json runs unsandboxed, since its own matched rule specified no sandbox — > out.json stays with the outer shell.

  5. The pipe also stays with the outer shell, connecting the two processes the same way it would for an unsandboxed pipeline.

A sub-command’s own redirect is replaced — and therefore sandboxed — along with it:

rules:
- allow: 'cat *'
sandbox: readonly
- allow: 'wc *'

For the command cat < secret.txt | wc -l, where readonly’s fs.read.deny covers secret.txt:

RUNOK_HOOK_ORIGIN=<token> runok exec --sandbox readonly -- 'cat < secret.txt' | wc -l

secret.txt is opened inside the sandbox, so readonly’s fs.read.deny blocks it — the same as it would for the equivalent non-compound cat < secret.txt.

The replaced range starts at the sub-command’s own KEY=VALUE env-assignment prefix, if it has one, and ends after its own redirects. FOO=1 node x therefore becomes RUNOK_HOOK_ORIGIN=<token> runok exec --sandbox readonly -- 'FOO=1 node x' — the env assignment is part of the quoted command, re-parsed and applied by the shell runok exec runs inside the sandbox.

When defaults.sandbox is set, it applies the same way it does to a non-compound command: a sub-command with no sandbox of its own still gets wrapped for the default preset.

Per-sub-command wrapping is what the Claude Code hook’s updatedInput rewrite uses. runok exec and runok check never use it: each evaluates one command string at a time and has no per-sub-command shell to hand a rewritten string back to, so they always apply a single, merged policy to the whole input instead — see Fallback: merged sandbox policy below.

runok exec and runok check always apply a single, merged sandbox policy to the whole input, built from every matched preset using the strictest intersection. The Claude Code hook normally avoids this by wrapping each sub-command on its own, but falls back to the same merged policy, wrapping the entire compound command in one sandbox, when a sub-command that needs a sandbox meets one of these conditions:

  • No byte range at all. The sub-command has no range of its own in the input to replace — the case after a parse failure, where the whole input is treated as one sub-command.
  • Byte range nested inside another sub-command’s range. Command substitution ($(...)) and process substitution (<(...)) extract an inner sub-command whose range sits inside the outer sub-command’s own range. Since the outer sub-command is wrapped too, wrapping the inner one as well would start a sandbox inside a sandbox.
  • Shell-state-changing builtin. A wrap runs the sub-command via runok exec, in a child process, so a builtin that mutates the calling shell’s own state — ., alias, cd, declare, eval, exec, export, local, popd, pushd, read, readonly, set, shift, shopt, source, trap, typeset, ulimit, umask, unalias, unset — would have its effect confined to that child process instead of the shell that runs the rest of the compound command: wrapping cd in cd build && make would run make in the original directory, not build.
  • Call to a shell function defined in the same input. deploy in deploy() { rsync ...; }; deploy resolves to a body that may need a sandbox, but the function exists only in the shell that read the definition — the child process runok exec spawns would not find it.

These conditions only trigger the fallback when the affected sub-command itself needs a sandbox — matches a rule with a sandbox field, or falls under defaults.sandbox with no rule-level override.

The nesting condition is one-directional. A substitution that needs no sandbox of its own never triggers the fallback, but it is still part of the text of the sub-command that encloses it, so it is wrapped — and sandboxed — along with it: in node fix.mjs > $(mktemp), with node * sandboxed and mktemp matching a rule that names no sandbox, mktemp runs under node’s preset. This matches what the merged fallback would do with the same command.

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

When every sub-command that specifies a sandbox resolves to the same preset (after deduplication), that single preset is applied directly instead of a merged policy — the same way a non-compound command’s preset is applied. This works with defaults.action: pass and does not force an ask prompt.

A merge across two or more distinct presets is represented as repeated --sandbox flags: the Claude Code hook writes updatedInput with one --sandbox <preset> flag per distinct preset name —

RUNOK_HOOK_ORIGIN=<token> runok exec --sandbox <preset-a> --sandbox <preset-b> -- '<compound command, re-quoted>'

— and runok exec resolves and merges the named presets itself, using the same intersection/union merge shown in the table above. This applies for allow, ask, and pass alike: a pass decision is no longer escalated to ask just because two or more distinct presets are involved, since the merged set can now always be carried through updatedInput.

If the intersection of fs.writable paths in the merged policy is empty — meaning sub-commands require incompatible write access — this is treated as a contradiction, and the action is escalated to ask (unless it is already deny). This escalation is unconditional: runok exec and runok check always evaluate against the merged policy, so it happens whenever the writable intersection is empty, independent of whether the Claude Code hook was also able to wrap every sub-command that needs one on its own.

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 the command build-a release && build-b release:

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

Both sub-commands have a contiguous byte range and neither is nested in the other, so the Claude Code hook can still wrap each of them on its own — but the merged policy it also evaluates against still has an empty writable intersection, so the action still escalates to ask.

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.