Skip to content

Next (unreleased)

This page tracks changes that will be included in the next release. It is updated as pull requests are merged.

runok previously matched shell variables ($X, ${X}) as literal, unresolved tokens. This meant a deny rule written against a flag’s literal text could be bypassed by smuggling the flag through a variable:

rules:
- deny: 'git push --force*'

F=--force; git push $F used to slip past this rule (git push $F never contains the literal text --force). runok now tracks static, single-value variable assignments within a command string and resolves $X / ${X} to their value before rule evaluation, so F=--force; git push $F is now correctly denied. The same resolution also lets an allow rule match a command hidden behind a variable, e.g. X="git status"; $X now evaluates as git status instead of falling through to an unknown-command default.

Only assignments that are unconditional and statically known are resolved — a dynamic value ($(...), backticks, process/arithmetic substitution), a reassignment inside a conditional or loop body, an array-element assignment, or anything referenced inside a function body at definition time falls back to the pre-existing verbatim-token behavior. See Rule Evaluation — Variable resolution for the full list and examples.

When a command is rewritten this way, the audit log records both the resolved text (command) and the verbatim source (original_command). See Audit Log JSON Schema — original_command for details.

What changes for existing rules? A rule that matches literal $X text (e.g. allow: 'echo $X', intending to match the variable reference itself rather than its value) now sees the resolved value instead, when that value is statically known. This is unlikely in practice — patterns are normally written against a command’s real arguments, not its unexpanded source — but if a rule specifically depended on $X staying unresolved, it should be rewritten against the values the variable can actually take, since those are now what reaches rule evaluation.

Function call resolution closes a deny-bypass gap for shell functions (#475)

Section titled “Function call resolution closes a deny-bypass gap for shell functions (#475)”

A shell function’s body was always evaluated unconditionally at definition time, as a safety backstop, but a call to that function (f() { git push; }; f) was matched as an ordinary, unknown command — resolving to defaults.action (usually ask) on every call, and never seeing the arguments the call itself passed in:

rules:
- allow: 'git push'
- deny: 'git push --force*'

f() { git push $1; }; f --force used to slip past the deny rule: definition-time evaluation only ever sees the literal, unexpanded git push $1. runok now detects a call to a function defined earlier in the same command string, and evaluates the call by substituting the call’s own arguments for $1..$N / $@ / $* / $# in the body, then evaluating the body itself — so f --force above is now correctly denied. A call to a fully allow-able function (f() { git push; }; f) now resolves to allow too, instead of ask on every call.

The function’s body is still evaluated unconditionally at definition time regardless of whether it is ever called — this remains the safety backstop for cases where the function was defined in a previous tool invocation that runok cannot see. See Rule Evaluation — Function call resolution for the full list of what resolves and what does not.

What changes for existing rules? A call to a function whose body always evaluates to allow now resolves to allow on every call, instead of falling through to defaults.action (commonly ask) as it did before. If a rule (or the absence of one) relied on every function call landing on ask for a human to review regardless of what the function’s body does, that call now goes straight to allow instead. This only affects functions defined earlier in the same command string — a function defined in a previous, separate tool invocation is unaffected, since only its unconditional definition-time evaluation (unchanged) can see it.

Breaking: ? in a flag’s value position now means “optional value” (#471)

Section titled “Breaking: ? in a flag’s value position now means “optional value” (#471)”

Some flags accept a value but also work without one (e.g. git’s --abbrev[=<n>], -n[<n>]). Writing ? in the value position now matches zero or one token, unlike * which requires exactly one:

- allow: 'git branch --abbrev ?'

--abbrev and --abbrev=8 both match. Like real optional-argument flags (GNU getopt_long convention), a space-separated following token is never consumed as the value — git branch --abbrev 8 actually creates a branch named 8 in real git, so runok does not treat 8 as --abbrev’s value either. ? is also supported as the value pattern in <flag:name> group definitions.

What changes for existing rules?

A pattern that wrote a bare ? directly after a flag used to match the literal string ? as that flag’s value. It now means “optional value” instead.

What should I do?

If you have a rule that intentionally relied on ? being a literal flag value, escape it: replace ? with \? (this works the same as \* for a literal *). runok migrate does this automatically across every pattern-syntax field.

See Matching Behavior — Optional Flag Values for details.

Breaking: unmatched commands now resolve to pass by default, not ask (#502)

Section titled “Breaking: unmatched commands now resolve to pass by default, not ask (#502)”

defaults.action’s fallback when left unset has changed from ask to pass (see below). This closes the gap the previous PR only opened: runok’s allowlist model returning ask for every unmatched Bash command was overriding Claude Code’s own auto-mode classifier, since a hook-level ask/deny/allow decision short-circuits that flow before it ever runs. Most runok.yml configs never set defaults.action explicitly, so this had been quietly defeating auto mode for anyone using the default settings.

defaults:
action: ask # no longer implied; write this explicitly to keep the old behavior

What changes for existing configs? A runok.yml without a defaults.action key now defers unmatched commands to Claude Code’s normal permission flow instead of always prompting.

What should I do? To keep the previous allowlist-style behavior (every unmatched command prompts), set defaults.action: ask explicitly. No other change is required to restore the old behavior. See defaults.action for details.

Since pass is now the common outcome for a command that matches no rule, the top-level tests section also accepts pass as an expected decision (- pass: 'some command'), for asserting that a command falls through to defaults.action/defaults.sandbox rather than being caught by an explicit rule. See runok test for details.

runok hook --agent codex integrates with Codex CLI’s hook protocol (#517)

Section titled “runok hook --agent codex integrates with Codex CLI’s hook protocol (#517)”

runok hook now accepts --agent codex alongside the existing --agent claude-code, dispatching Codex CLI’s PreToolUse and PermissionRequest hook events. The mapping mirrors --agent claude-code for deny/allow, with one Codex-specific constraint: Codex rejects updatedInput unless it is paired with an explicit permissionDecision: "allow", so a pass decision never emits updatedInput under --agent codex, even when defaults.sandbox is configured — the sandbox wrap only reaches Codex through an explicit allow. PermissionRequest has no updatedInput support at all, so it always reports a plain { behavior: "allow" } or { behavior: "deny", message }, ignoring sandbox presets entirely.

ask is handled differently by the two events, since Codex’s PreToolUse hook has no way to open an approval prompt mid-call: it always reports permissionDecision: "deny", with the reason text explaining that human judgment is needed (not a hard rejection) and instructing the model to stop, report which command needs approval and why, and let the delegator or the user decide, rather than retrying — this session has no way to re-run the call with elevated permission. PermissionRequest is unchanged — an ask decision writes nothing, deferring to Codex’s own approval UI. See runok hook for the full decision mapping.

Following up on runok hook --agent codex (#517), the runok init --scope user wizard now also detects a Codex config directory ($CODEX_HOME, or ~/.codex when that variable is unset) and registers runok hook --agent codex for both the PreToolUse and PermissionRequest events in <codex_home>/hooks.json:

{
"hooks": {
"PreToolUse": [
{
"matcher": "^Bash$",
"hooks": [{ "type": "command", "command": "runok hook --agent codex" }]
}
],
"PermissionRequest": [
{
"matcher": "^Bash$",
"hooks": [{ "type": "command", "command": "runok hook --agent codex" }]
}
]
}
}

Unlike Claude Code’s plain-string matchers, Codex’s hooks.json matchers are regexes, hence "^Bash$". The step is skipped entirely when no Codex config directory exists, and re-running runok init won’t duplicate an already-registered entry. As with the existing Claude Code integration, the wizard shows a diff and asks for confirmation before writing — previously this required hand-editing ~/.codex/hooks.json. You may need to approve the newly-registered hook in Codex before it takes effect. See runok init for details.

defaults.action: pass defers to Claude Code’s own permission flow (#496)

Section titled “defaults.action: pass defers to Claude Code’s own permission flow (#496)”

An unmatched command previously always resolved to a concrete decision (allow, deny, or ask, per defaults.action), even when ask meant forcing a permission prompt that Claude Code’s own permission flow might have resolved without one. The new pass value for defaults.action writes nothing to the PreToolUse hook’s stdout and exits 0 instead, which Claude Code interprets as “no decision to report” — the tool call falls through to its own permission flow rather than being short-circuited into ask:

defaults:
action: pass

It can be combined with defaults.sandbox: the hook response omits permissionDecision entirely but still includes updatedInput with the command rewritten to run under the sandbox preset, so Claude Code’s own permission flow decides against the original command while the sandbox still applies to whatever actually runs. runok exec has no underlying permission flow to defer to, so it falls back to the same behavior as ask; runok check reports pass as its own distinct decision value instead. pass decisions are still recorded in the audit log, so runok audit’s rule-authoring workflow keeps working for unmatched commands. See defaults.action for details.

Breaking: since a passing test case’s output could otherwise read as PASS: cmd => pass, runok test now labels a passing test case ok instead of PASS (FAIL is unchanged). Update any script or CI step that greps runok test output for the literal PASS: prefix.

runok check --verbose / runok exec --verbose / runok hook --verbose now render a tree instead of [verbose]-prefixed log lines (#486)

Section titled “runok check --verbose / runok exec --verbose / runok hook --verbose now render a tree instead of [verbose]-prefixed log lines (#486)”

Verbose output is a colorized, indented tree: each sub-command of a compound command gets its own numbered block listing every matched rule alongside the resolved action, and a footer states the overall result and which sub-command decided it.

▶ Evaluating: git add . && curl -s https://install.example.com | sh
Compound command (3 sub-commands)
[1] git add .
├─ allow 'git *' (tokens: add, .)
└─ result: allow
[2] curl -s https://install.example.com
└─ result: ask (no rules matched)
[3] sh
└─ result: ask (no rules matched)
────────────────────────────────────────────
Result: ASK (blocked by [2] curl -s https://install.example.com)
ask

Colors (green/yellow/red for allow/ask/deny) are applied automatically on a TTY and omitted otherwise (piped output, log files). See Debugging for more examples.

Experimental: require_command_in_path denies commands not found in PATH (#485)

Section titled “Experimental: require_command_in_path denies commands not found in PATH (#485)”

An AI coding agent’s most common failure mode is trying to run a typo’d or hallucinated command (tarraform for terraform) — previously, runok would allow it through and the command would only fail once the shell actually tried to run it. The new experimental.require_command_in_path check catches this earlier: when no rule matches and argv[0] cannot be resolved via PATH, it denies (or asks) instead of falling through to defaults.action.

runok.yml
experimental:
require_command_in_path:
enabled: true
action: deny

It is off by default and, like everything under experimental, may change in a future minor release. It also skips itself in several situations to avoid a false deny — an argument still hidden behind an unresolved shell variable, a function defined earlier in the same command string, source/./eval anywhere in the input, and any argv[0] containing a /. See Experimental Features — require_command_in_path for the full behavior and when ignore is the right escape hatch.

The audit log records when require_command_in_path decided the outcome (#487)

Section titled “The audit log records when require_command_in_path decided the outcome (#487)”

experimental.require_command_in_path denies or asks by falling through the same code path as an unmatched command resolved via defaults.action, so an audit entry with empty matched_rules used to be ambiguous between the two. Each command_evaluations entry now carries require_command_in_path, the unresolved command name, whenever this check (rather than a matched rule or defaults.action) decided that branch’s action:

{
"command": "tarraform version",
"action": { "type": "deny", "detail": { "message": "..." } },
"argv": ["tarraform", "version"],
"require_command_in_path": "tarraform"
}

The field is omitted whenever the check did not decide the branch’s action, so existing runok audit --json consumers are unaffected. See Audit Log JSON Schema — require_command_in_path for details.

runok migrate rewrites the legacy Claude Code hook command, which now prints a deprecation warning (#480)

Section titled “runok migrate rewrites the legacy Claude Code hook command, which now prints a deprecation warning (#480)”

Following up on runok hook (#476), runok migrate now rewrites an existing .claude/settings.json registration of the deprecated runok check --input-format claude-code-hook command to runok hook --agent claude-code, for both the PreToolUse and PostToolUse events:

// Before
{ "type": "command", "command": "runok check --input-format claude-code-hook" }
// After
{ "type": "command", "command": "runok hook --agent claude-code" }

Unlike the config-chain migrations above, this one targets .claude/settings.json directly at the user scope (~/.claude/settings.json) and the project scope (.claude/settings.json in the current directory), matching the locations runok init sets up. Running runok check --input-format claude-code-hook now also prints a deprecation warning to stderr on every invocation, pointing at runok hook and runok migrate; stdout is untouched, since it carries the hook response Claude Code parses. See runok migrate — Claude Code legacy hook command for details.

runok migrate escapes bare ? left over from the optional-value marker change (#479)

Section titled “runok migrate escapes bare ? left over from the optional-value marker change (#479)”

Following up on the breaking change above, runok migrate now rewrites a bare ? in every pattern-syntax field (rules[].{allow,deny,ask}, definitions.wrappers, definitions.flag_groups, definitions.aliases, and definitions.vars entries with type: pattern) to the escaped form \?, so a config written before v0.4.0 keeps matching the literal ? it relied on:

# Before
rules:
- allow: 'git branch --abbrev ?'
# After
rules:
- allow: 'git branch --abbrev \?'

Comments, formatting, and quoting style outside the rewritten fields are preserved. rules[].tests are left untouched, since inline test commands are not patterns. See runok migrate — Bare ? in pattern strings for details.

runok hook: a dedicated command for agent hook integrations (#476)

Section titled “runok hook: a dedicated command for agent hook integrations (#476)”

Claude Code integration used to register runok check --input-format claude-code-hook for both the PreToolUse and PostToolUse events. This was misleading: check is documented as a read-only evaluation command, but in PostToolUse mode it does no evaluation at all — it only writes an ask_resolution record to the audit log.

runok hook replaces it as the dedicated agent-integration endpoint. It takes a required --agent flag identifying which agent’s hook protocol to speak (currently only claude-code) rather than defaulting silently to Claude Code, since the flag determines the response shape as well as the input parsing, and hook is meant to grow additional agent integrations over time:

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "runok hook --agent claude-code" }
]
}
],
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "runok hook --agent claude-code" }
]
}
]
}
}

It dispatches on the input’s hook_event_name the same way the old hook mode did (PreToolUse evaluates and responds, PostToolUse records ask approvals), plus one addition: any other event — including hook events a future Claude Code release might add — is silently ignored (exit 0, no output), so runok hook --agent claude-code keeps working without requiring a runok upgrade first.

runok init now registers runok hook --agent claude-code for new setups, and rewrites an existing runok check --input-format claude-code-hook entry to runok hook --agent claude-code in place when re-run. runok check --input-format claude-code-hook still works for backward compatibility but is deprecated — see runok hook for the full reference.

runok audit --recheck re-evaluates entries against the current config (#474)

Section titled “runok audit --recheck re-evaluates entries against the current config (#474)”

An audit entry’s recorded action and matched_rules are a snapshot from when the entry was decided — if rules changed since then, the log alone can’t tell you how a command would evaluate today. --recheck re-evaluates each displayed entry’s command against the config currently in effect (loaded from the entry’s own metadata.cwd) and annotates the output with the result. It’s an annotation, not a filter: it never changes which entries are shown, and composes with --action, --since, --dir, and the other filters.

Terminal window
runok audit --action ask --recheck --json \
| jq 'select(.recheck.action.type == "ask")'

Text mode gains a NOW column next to ACTION; JSON mode gains a recheck object per entry, distinguishing an ask resolved by an explicit rule from one resolved purely via defaults.action fallback. Every ask decision entry in --json output also now carries an approved boolean — the same ask_resolution join used for the ask ✓ marker in text mode.

See runok audit — --recheck and Audit Log JSON Schema — Recheck Object for details.

The audit log used to record only that runok answered ask for a command — not whether the user then approved it in Claude Code’s permission dialog. Registering runok as an opt-in PostToolUse hook (offered by runok init --scope user, or added manually to settings.json) closes that gap: approving an ask now appends a self-contained ask_resolution record correlated with the original ask entry via tool_use_id.

runok audit marks approved asks as ask ✓, and runok audit --json emits the ask_resolution records alongside decision entries. Denials cannot be recorded — Claude Code fires no hook after a denied dialog — so an unmarked ask means “denied or not yet decided”.

{
"kind": "ask_resolution",
"outcome": "approved",
"tool_use_id": "toolu_01AbCdEfGh",
"command": "terraform apply",
"executed_command": "runok exec --sandbox restricted -- 'terraform apply'"
}

See Claude Code Integration -> Track ask approvals and Audit Log JSON Schema -> Ask Resolution Record for details.

The fs namespace now exposes fs.home and fs.cwd as values, alongside the existing fs.exists() / fs.is_file() / fs.is_dir() functions. fs.cwd is read directly from the OS, so unlike env.PWD it cannot go stale or be left unset by a shell that does not export PWD. fs.home is null when the home directory cannot be determined, rather than folding that into an empty-string prefix that would silently match everything.

rules:
# Allow `make` only under a chosen directory tree in the user's home.
- allow: 'make *'
when: "fs.cwd.startsWith(fs.home + '/projects/')"

See When Clauses -> Filesystem for details.

when clauses can now call glob_matches(pattern, value) and read the raw contents of definitions.paths / definitions.vars through the new definitions context variable. Combined, these let a deny/when guard and an allow/<var:name> pattern share a single declared list of type: pattern glob values, instead of duplicating the list:

definitions:
vars:
safe-rm-paths:
type: pattern
values:
- '**/node_modules'
- 'node_modules'
- '**/dist'
- 'dist'
- '/tmp/*'
rules:
- deny: 'rm -rf *'
when: '!args.all(a, definitions.vars["safe-rm-paths"].exists(p, glob_matches(p, a)))'
- allow: 'rm -rf <var:safe-rm-paths>'

definitions.paths exposes the same data as the existing paths context variable; definitions.vars is new and lists every declared value for each definitions.vars entry, unlike vars, which only holds the single value a matched <var:name> placeholder captured. See When Clauses -> definitions and When Clauses -> Glob matching for details.

runok init no longer overwrites an existing runok.yml with migrated Claude Code rules (#522)

Section titled “runok init no longer overwrites an existing runok.yml with migrated Claude Code rules (#522)”

runok init used to silently overwrite an existing hand-written runok.yml (e.g. one built with extends) in two cases: accepting the “Migrate Claude Code Bash permissions to runok rules?” prompt replaced it with the migrated rules, and a separate “runok.yml already exists. Overwrite?” prompt could be bypassed since -y auto-answers every prompt “yes” — even though -y is documented as only skipping confirmations, not permitting destructive overwrites. runok init now never touches an existing runok.yml: the migration offer isn’t shown once one already exists at the target scope, the overwrite prompt has been removed entirely, and init only ever populates a fresh, not-yet-existing runok.yml.

runok init no longer replaces an existing runok.yml with boilerplate (#468)

Section titled “runok init no longer replaces an existing runok.yml with boilerplate (#468)”

Applying Claude Code integration changes in runok init used to rewrite an existing runok.yml with the boilerplate template when no permission migration happened. The wizard now only rewrites an existing config when a migration was accepted; re-running init just to register hooks leaves the file untouched.

A bare assignment redirect prefix with no command name no longer fails to parse (#491)

Section titled “A bare assignment redirect prefix with no command name no longer fails to parse (#491)”

A simple command consisting of only variable assignment(s) and redirect(s) — no command name — is valid POSIX shell (bash -n and zsh -n both accept it), but the underlying parser couldn’t parse it, so runok check rejected it outright:

Terminal window
$ printf '%s' 'TS=foo 2>/dev/null' | runok check
runok: stdin parse error: failed to parse stdin as shell input

TS=foo 2>/dev/null; echo hi now correctly extracts to a single command (echo hi), with the assignment persisted so a later $TS in the same command string resolves it — TS is never looked up as a command name in PATH.

Section titled “defaults.action: pass combined with defaults.sandbox now actually runs the command (TODO(pr-link))”

For an unmatched command, the PreToolUse hook rewrites updatedInput to RUNOK_HOOK_ORIGIN=<token> runok exec --sandbox <preset> -- <command> so the sandbox still applies once Claude Code’s own permission flow lets the command through. runok exec re-evaluates that command against the same rules, and an unmatched command also resolves to Action::Pass there — which exec (having no permission flow of its own to defer to) treated the same as ask, denying with exit code 3. The combination documented in defaults.action: pass + defaults.sandbox (#502) never actually ran the command.

runok exec now runs the command under the resolved sandbox when the invocation came from the hook’s own wrapper, identified by an internal RUNOK_HOOK_ORIGIN=<token> environment variable the wrapper sets — not merely by the presence of --sandbox, so typing runok exec --sandbox <preset> -- <command> directly still denies an unmatched command exactly as before. The token changes on every hook invocation, so the marker can’t just be copy-pasted from a doc or a previous run. See defaults.action for details.

exec never verifies the token’s value, only that the env var was set — it is not a cryptographic proof that a given invocation actually came from the hook, and anyone who can run runok exec at all can set it. This is a deliberate, accepted trade-off, not an oversight: it only relaxes Action::Pass (a command that matched no rule). A command that matches a deny or ask rule is still rejected by exec’s own re-evaluation regardless of the marker. Spoofing it can therefore only turn “the caller’s own permission flow decides, unsandboxed” into “the caller’s own permission flow decides, sandboxed” — a strictly narrower outcome, not a privilege escalation.

A compound command matching a single sandboxed preset no longer loses its sandbox or forces an ask prompt (#506)

Section titled “A compound command matching a single sandboxed preset no longer loses its sandbox or forces an ask prompt (#506)”

A compound command (|, &&, ;, loops) whose sandbox policy came from more than one sub-command was always represented internally as a merged policy, even when every sub-command that specified a sandbox actually named the same preset. Two consequences followed: a pass resolution (one sub-command matched a sandboxed allow rule, another was unmatched) was escalated to ask on every invocation, since a pass response has no way to carry a merged policy; an allow resolution applied no sandbox at all, since allow’s updatedInput rewriting only understood a named preset, not a merged policy — so the sandbox was silently dropped while the command still ran.

A compound command now collapses to the underlying named preset (applied the same way a non-compound command’s preset is) whenever every sandboxed sub-command resolves to the same preset after deduplication, fixing both cases. A command that mixes two or more genuinely different presets still merges them and still escalates a pass resolution to ask, since that case has no single preset to fall back to. See Compound Commands — Sandbox policy aggregation for details.

A compound command wraps each sub-command in its own sandbox instead of merging them into one (#516, #518)

Section titled “A compound command wraps each sub-command in its own sandbox instead of merging them into one (#516, #518)”

A compound command (|, &&, ||, ;, loops) whose sandbox policy came from more than one sub-command used to be wrapped as a whole: all matched presets were merged into one policy, and runok exec --sandbox <preset> -- '<compound command, re-quoted>' ran the entire compound inside it. A sub-command that matched an allow rule with no sandbox field still ran inside a neighboring sub-command’s preset, tightening restrictions the matched rule never asked for.

Each sub-command that needs a sandbox is now replaced with its own RUNOK_HOOK_ORIGIN=<token> runok exec --sandbox <preset> -- '<sub-command>', where <sub-command> is that sub-command’s own text — including its own redirects — shell-quoted as a single argument. Only the operators joining sub-commands (|, &&, ||, ;) stay in the outer shell. For example, with wc * sandboxed under a readonly preset and cat * left unsandboxed:

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

cat notes.txt > out.json keeps running unsandboxed, exactly as its own matched rule specified, and > out.json is opened by that same unsandboxed process. Because the replacement covers the sandboxed sub-command’s own text in full, a redirect belonging to that sub-command is carried inside its sandbox instead of being left to the outer shell. This is the Claude Code hook’s updatedInput rewrite specifically — runok exec and runok check each evaluate one command string at a time and have no per-sub-command shell to hand a rewritten string back to, so they keep applying a single, merged policy to the whole input.

runok falls back to that merge-and-wrap behavior when a sub-command has no byte range of its own in the input to replace, when its range sits inside another sub-command’s range ($(...), <(...)), or when a sub-command is a shell builtin that changes shell state (cd, export, source, eval, and similar) or a call to a shell function defined in the same input: replacing either would run it in a child process, so cd build && make would run make back in the original directory instead of build, and a function defined by the command itself would not exist at all.

That fallback merges every distinct preset matched across the compound’s sub-commands using Strictest Wins. runok exec --sandbox accepts the flag repeatably, and runok exec resolves and merges the named presets itself; the hook writes updatedInput as one --sandbox <preset> flag per distinct preset, e.g. RUNOK_HOOK_ORIGIN=<token> runok exec --sandbox web-only --sandbox api-only -- '<compound command, re-quoted>'. This applies for allow, ask, and pass alike — a pass decision is never escalated to ask just to avoid dropping the sandbox, since the merged set can always be carried through updatedInput. The writable-contradiction escalation (an empty intersection of writable roots forcing ask) is unrelated and unchanged.

See Compound Commands — Sandbox policy aggregation for the full mechanics.