perf(hooks): split shell commands into words only when a policy needs them

Review follow-up for #973. `extract` runs for every tool event on the native
hook and the server, but shell commands were tokenized inside it, so users
with no `[capture]` policy paid for words they never read. `shell_command` now
only validates and borrows the command, and the words are split in
`match_command`'s caller under an active policy. The generated TypeScript
integrations do the same. Behavior is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Peterson Salme
2026-09-28 21:55:28 -03:00
co-authored by Claude Sonnet 5.5
parent 9ae469141a
commit d50cd442d2
2 changed files with 90 additions and 34 deletions
@@ -277,14 +277,16 @@ function captureGlobReaches(glob: string, prefix: string, insensitive: boolean,
// (`bash -lc "<script>"`); joining elements would re-split paths with spaces.
// A long element that splits is a script, not a path: keeping it whole would
// charge it, quadratically, against every pattern.
function captureShellCommand(args: Record<string, unknown> | undefined): string[] | undefined { if (!args || typeof args !== "object" || Array.isArray(args)) return undefined; const value = "command" in args ? args.command : args.cmd; if (typeof value === "string") return captureShellWords(value); if (Array.isArray(value) && value.every((x) => typeof x === "string")) return (value as string[]).flatMap((item) => { const tokens = captureShellWords(item); return tokens.length === 1 && tokens[0] === item || [...item].length > 256 ? tokens : [item, ...tokens]; }); return undefined; }
function captureShellCommand(args: Record<string, unknown> | undefined): string | string[] | undefined { if (!args || typeof args !== "object" || Array.isArray(args)) return undefined; const value = "command" in args ? args.command : args.cmd; if (typeof value === "string") return value; if (Array.isArray(value) && value.every((x) => typeof x === "string")) return value as string[]; return undefined; }
// Split only when a policy is active: this runs for every tool event.
function captureShellWordList(command: string | string[]): string[] { if (typeof command === "string") return captureShellWords(command); return command.flatMap((item) => { const tokens = captureShellWords(item); return tokens.length === 1 && tokens[0] === item || [...item].length > 256 ? tokens : [item, ...tokens]; }); }
function captureShellWords(command: string): string[] { const special = (c: string) => /\s/.test(c) || "|&;<>()".includes(c); const chars = [...command]; const words: string[] = []; let word = ""; let inWord = false; let quote = ""; for (let i = 0; i < chars.length; i++) { const c = chars[i]; const next = chars[i + 1]; if (quote) { if (c === quote) quote = ""; else if (quote === '"' && c === "\\" && (next === '"' || next === "\\")) { word += next; i++; } else word += c; } else if (c === "'" || c === '"') { quote = c; inWord = true; } else if (c === "\\" && next !== undefined && (special(next) || next === "'" || next === '"' || next === "\\")) { word += next; i++; inWord = true; } else if (special(c)) { if (inWord) words.push(word); word = ""; inWord = false; } else { word += c; inWord = true; } } if (inWord) words.push(word); return words; }
function captureShellArguments(word: string): string[] { const out = word.startsWith("-") ? [] : [word]; const eq = word.indexOf("="); if (eq >= 0) out.push(word.slice(eq + 1)); return out.filter((argument) => argument.trim() !== ""); }
// Lexical only, like the native hook: nothing is expanded or executed, so
// variables, command substitution, and `cd` state are not followed. A tool's
// own `workdir` replaces the event cwd for relative arguments.
function captureMatchCommand(words: string[], config: CaptureConfig, workdir?: string): boolean | undefined { const budget = { work: 0 }; const home = homedir(); const base = workdir === undefined ? config.base : /^(?:\/|\\\\|[A-Za-z]:[\\/])/.test(workdir) ? workdir : config.base && captureJoin(config.base, workdir); for (const word of words) for (const argument of captureShellArguments(word)) { const expanded = argument.startsWith("~/") ? captureJoin(home, argument.slice(2)) : argument; if ([...expanded].length > CAPTURE_MAX_PATH_CHARS) continue; const absolute = /^(?:\/|\\\\|[A-Za-z]:[\\/])/.test(expanded); if (!absolute && !base) continue; const candidate = captureNormalize(absolute ? expanded : captureJoin(base, expanded)); if (!candidate) continue; const glob = /[*?]/.test(candidate.path); for (const pattern of config.patterns) { if (candidate.windows !== pattern.windows) continue; const under = captureStartsWith(candidate.path, pattern.prefix, pattern.windows); if (!under && !glob) continue; if (under) { const directory = pattern.directory ? captureGlob(pattern.directory, candidate.path, pattern.windows, budget) : false; if (directory !== false) return directory; const match = captureGlob(pattern.path, candidate.path, pattern.windows, budget); if (match !== false) return match; } if (glob) { const reaches = captureGlobReaches(candidate.path, pattern.prefix, pattern.windows, budget); if (reaches !== false) return reaches; } } } return false; }
function captureTool(payload: Record<string, unknown>): { family: CaptureProtocol["tool_family"]; paths?: string[]; extraction: CaptureProtocol["extraction_state"]; callID?: string; command?: string[]; shell?: boolean; workdir?: string } { const name = typeof payload.tool === "string" ? payload.tool.toLowerCase() : ""; const args = payload.args as Record<string, unknown> | undefined; const call = ["tool_use_id","toolUseId","tool_call_id","toolCallId","call_id","callId","callID"].map((k) => payload[k]).find((v): v is string => typeof v === "string" && /^[A-Za-z0-9_.-]{1,128}$/.test(v)); if (["search","grep","glob","find","list","ls","list_files","read_dir"].includes(name)) return { family: "search-list", extraction: "not-applicable", callID: call }; if (["bash","shell","shell_command","exec","execute","run_command","web_search","terminal","execute_bash","execute_cmd"].includes(name)) return { family: "non-file", extraction: "extracted", callID: call, command: captureShellCommand(args), shell: name !== "web_search", workdir: typeof args?.workdir === "string" && args.workdir.trim() ? args.workdir : undefined }; if (!["read","write","edit","apply_patch","notebookedit","notebook_edit","create_file","delete_file","rename_file","move_file","multi_edit","multiedit","replace","replace_all"].includes(name)) return { family: "unknown", extraction: "extracted", callID: call }; const direct = (o: any): string[] | undefined => { if (!o || typeof o !== "object") return undefined; const r: string[] = []; for (const k of ["file_path","filePath","path","absolute_path","AbsolutePath","notebook_path"]) if (k in o) { if (typeof o[k] !== "string") return undefined; r.push(o[k]); } if ("paths" in o) { if (!Array.isArray(o.paths) || o.paths.some((x: unknown) => typeof x !== "string")) return undefined; r.push(...o.paths); } return r.length && r.length <= CAPTURE_MAX_CANDIDATES ? r : undefined; }; let paths = direct(args); if (["multi_edit","multiedit","replace_all"].includes(name)) { const entries = args?.edits ?? args?.replacements; if (!Array.isArray(entries) || !entries.length || entries.length > CAPTURE_MAX_CANDIDATES) paths = undefined; else { paths = paths ?? []; for (const entry of entries) { const more = direct(entry); if (!more || paths.length + more.length > CAPTURE_MAX_CANDIDATES) { paths = undefined; break; } paths.push(...more); } } } if (!paths || paths.some((p) => !p.trim() || [...p].length > CAPTURE_MAX_PATH_CHARS)) return { family: "file", extraction: "missing-or-malformed", callID: call }; return { family: "file", paths, extraction: "extracted", callID: call }; }
function captureMatchCommand(command: string | string[], config: CaptureConfig, workdir?: string): boolean | undefined { const budget = { work: 0 }; const home = homedir(); const base = workdir === undefined ? config.base : /^(?:\/|\\\\|[A-Za-z]:[\\/])/.test(workdir) ? workdir : config.base && captureJoin(config.base, workdir); for (const word of captureShellWordList(command)) for (const argument of captureShellArguments(word)) { const expanded = argument.startsWith("~/") ? captureJoin(home, argument.slice(2)) : argument; if ([...expanded].length > CAPTURE_MAX_PATH_CHARS) continue; const absolute = /^(?:\/|\\\\|[A-Za-z]:[\\/])/.test(expanded); if (!absolute && !base) continue; const candidate = captureNormalize(absolute ? expanded : captureJoin(base, expanded)); if (!candidate) continue; const glob = /[*?]/.test(candidate.path); for (const pattern of config.patterns) { if (candidate.windows !== pattern.windows) continue; const under = captureStartsWith(candidate.path, pattern.prefix, pattern.windows); if (!under && !glob) continue; if (under) { const directory = pattern.directory ? captureGlob(pattern.directory, candidate.path, pattern.windows, budget) : false; if (directory !== false) return directory; const match = captureGlob(pattern.path, candidate.path, pattern.windows, budget); if (match !== false) return match; } if (glob) { const reaches = captureGlobReaches(candidate.path, pattern.prefix, pattern.windows, budget); if (reaches !== false) return reaches; } } } return false; }
function captureTool(payload: Record<string, unknown>): { family: CaptureProtocol["tool_family"]; paths?: string[]; extraction: CaptureProtocol["extraction_state"]; callID?: string; command?: string | string[]; shell?: boolean; workdir?: string } { const name = typeof payload.tool === "string" ? payload.tool.toLowerCase() : ""; const args = payload.args as Record<string, unknown> | undefined; const call = ["tool_use_id","toolUseId","tool_call_id","toolCallId","call_id","callId","callID"].map((k) => payload[k]).find((v): v is string => typeof v === "string" && /^[A-Za-z0-9_.-]{1,128}$/.test(v)); if (["search","grep","glob","find","list","ls","list_files","read_dir"].includes(name)) return { family: "search-list", extraction: "not-applicable", callID: call }; if (["bash","shell","shell_command","exec","execute","run_command","web_search","terminal","execute_bash","execute_cmd"].includes(name)) return { family: "non-file", extraction: "extracted", callID: call, command: captureShellCommand(args), shell: name !== "web_search", workdir: typeof args?.workdir === "string" && args.workdir.trim() ? args.workdir : undefined }; if (!["read","write","edit","apply_patch","notebookedit","notebook_edit","create_file","delete_file","rename_file","move_file","multi_edit","multiedit","replace","replace_all"].includes(name)) return { family: "unknown", extraction: "extracted", callID: call }; const direct = (o: any): string[] | undefined => { if (!o || typeof o !== "object") return undefined; const r: string[] = []; for (const k of ["file_path","filePath","path","absolute_path","AbsolutePath","notebook_path"]) if (k in o) { if (typeof o[k] !== "string") return undefined; r.push(o[k]); } if ("paths" in o) { if (!Array.isArray(o.paths) || o.paths.some((x: unknown) => typeof x !== "string")) return undefined; r.push(...o.paths); } return r.length && r.length <= CAPTURE_MAX_CANDIDATES ? r : undefined; }; let paths = direct(args); if (["multi_edit","multiedit","replace_all"].includes(name)) { const entries = args?.edits ?? args?.replacements; if (!Array.isArray(entries) || !entries.length || entries.length > CAPTURE_MAX_CANDIDATES) paths = undefined; else { paths = paths ?? []; for (const entry of entries) { const more = direct(entry); if (!more || paths.length + more.length > CAPTURE_MAX_CANDIDATES) { paths = undefined; break; } paths.push(...more); } } } if (!paths || paths.some((p) => !p.trim() || [...p].length > CAPTURE_MAX_PATH_CHARS)) return { family: "file", extraction: "missing-or-malformed", callID: call }; return { family: "file", paths, extraction: "extracted", callID: call }; }
function capturePolicy(payload: Record<string, unknown>, cwd: string | undefined): { disposition: CaptureDisposition; protocol?: CaptureProtocol; payload: Record<string, unknown> } { const markerPresent = !!findMarker(cwd); if (CAPTURE_MODE === "allowlist" && !markerPresent) return { disposition: "drop", payload }; const config = captureConfig(cwd); const tool = captureTool(payload); let disposition: CaptureDisposition = "keep"; if (config.state === "invalid" && (tool.family === "file" || tool.shell)) disposition = "metadata-only"; else if (config.state === "active" && tool.family === "search-list") disposition = "drop"; else if (config.state === "active" && tool.family === "file") { if (!tool.paths) disposition = "metadata-only"; else { const candidates = tool.paths.map((p) => captureNormalize(/^(?:\/|\\\\|[A-Za-z]:[\\/])/.test(p) ? p : captureJoin(config.base, p))); if (candidates.some((p) => !p)) disposition = "metadata-only"; else { const budget = { work: 0 }; captureMatch: for (const candidate of candidates as { path: string; windows: boolean }[]) for (const pattern of config.patterns) { if (candidate.windows !== pattern.windows) continue; if (pattern.directory && captureGlob(pattern.directory, candidate.path, pattern.windows, budget)) { disposition = "drop"; break captureMatch; } const match = captureGlob(pattern.path, candidate.path, pattern.windows, budget); if (match === undefined) { disposition = "metadata-only"; break; } if (match) { disposition = "drop"; break captureMatch; } } } } } else if (config.state === "active" && tool.family === "non-file" && tool.command !== undefined && captureMatchCommand(tool.command, config, tool.workdir) !== false) disposition = "drop"; if (config.state === "inactive") return { disposition, payload }; const protocol: CaptureProtocol = { version: CAPTURE_POLICY_V1, disposition, policy_state: config.state, tool_family: tool.family, path_count: tool.paths?.length ?? 0, extraction_state: tool.extraction }; if (disposition === "metadata-only") { const session = payload.sessionID ?? payload.sessionId ?? payload.session_id; const routing = typeof payload.cwd === "string" ? payload.cwd : cwd; return { disposition, protocol, payload: { ...(typeof session === "string" ? { session_id: session } : {}), ...(typeof routing === "string" ? { cwd: routing } : {}), tool_family: tool.family, tool_name: tool.family, ...(tool.callID ? { tool_call_id: tool.callID } : {}), _ai_memory_capture: protocol } }; } if (disposition === "keep") return { disposition, protocol, payload: { ...payload, _ai_memory_capture: protocol } }; return { disposition, protocol, payload }; }
"##;
TEMPLATE.replace("__AI_MEMORY_CAPTURE_MODE__", capture_mode)
+85 -31
View File
@@ -522,14 +522,14 @@ impl CapturePolicy {
// still name an ignored file whose content lands in the
// output (`cat docs/adr/*.md`). An exhausted match budget
// fails closed like an unprovable file candidate.
ToolFamily::NonFile => match extracted.command.as_deref().map(|words| {
ToolFamily::NonFile => match extracted.command.as_ref().map(|command| {
let base = match extracted.workdir.as_deref() {
Some(dir) if is_absolute(dir) => dir.to_owned(),
// `join` would turn an unusable cwd into `/dir`.
Some(dir) if is_absolute(cwd) => join(cwd, dir),
_ => cwd.to_owned(),
};
self.match_command(words, &base)
self.match_command(&command.words(), &base)
}) {
Some(Ok(true) | Err(())) => (CaptureDisposition::Drop, extracted.state),
Some(Ok(false)) | None => (CaptureDisposition::Keep, extracted.state),
@@ -661,11 +661,11 @@ pub fn metadata_only_body(
}
#[derive(Default)]
struct Extracted {
struct Extracted<'a> {
family: ToolFamily,
paths: Option<Vec<String>>,
/// A shell tool's command, already split into words.
command: Option<Vec<String>>,
/// A shell tool's command, not yet split into words.
command: Option<ShellCommand<'a>>,
/// A command-running tool, whether or not its command could be read.
/// `web_search` is non-file but runs nothing.
shell: bool,
@@ -674,7 +674,7 @@ struct Extracted {
state: ExtractionState,
}
fn extract(agent: AgentKind, raw: &Value) -> Extracted {
fn extract(agent: AgentKind, raw: &Value) -> Extracted<'_> {
let Some(object) = raw.as_object() else {
return Extracted::default();
};
@@ -893,34 +893,51 @@ fn direct_paths(object: &Map<String, Value>) -> Option<Vec<String>> {
(!paths.is_empty()).then_some(paths)
}
/// The words of a shell tool's command. An argument vector (Codex exec) is
/// already split: each element is one word as given (`private notes/x.md`),
/// and is also tokenized on its own so a `bash -lc "<script>"` element is read
/// like any script. Joining the elements instead would re-split paths with
/// spaces and let one element's stray quote swallow the rest.
fn shell_command(args: &Value) -> Option<Vec<String>> {
match args.get("command").or_else(|| args.get("cmd"))? {
Value::String(command) => Some(shell_words(command)),
Value::Array(items) => {
let items = items
.iter()
.map(Value::as_str)
.collect::<Option<Vec<_>>>()?;
let mut words = Vec::new();
for item in items {
let tokens = shell_words(item);
// Whitespace in a long element means a script, not a path;
// its tokens are checked below. Keeping the blob whole would
// charge it, quadratically, against every pattern.
if (tokens.len() != 1 || tokens[0] != item)
&& item.chars().count() <= MAX_ARGV_PATH_CHARS
{
words.push(item.to_owned());
/// A shell tool's command as the tool gave it. Reading it only validates its
/// shape; the words are split on demand by [`ShellCommand::words`], because
/// `extract` runs for every tool event and most never need them.
enum ShellCommand<'a> {
Line(&'a str),
Argv(Vec<&'a str>),
}
impl ShellCommand<'_> {
/// The words of the command. An argument vector (Codex exec) is already
/// split: each element is one word as given (`private notes/x.md`), and is
/// also tokenized on its own so a `bash -lc "<script>"` element is read
/// like any script. Joining the elements instead would re-split paths with
/// spaces and let one element's stray quote swallow the rest.
fn words(&self) -> Vec<String> {
match self {
Self::Line(command) => shell_words(command),
Self::Argv(items) => {
let mut words = Vec::new();
for item in items {
let tokens = shell_words(item);
// Whitespace in a long element means a script, not a path;
// its tokens are checked below. Keeping the blob whole
// would charge it, quadratically, against every pattern.
if (tokens.len() != 1 || tokens[0] != *item)
&& item.chars().count() <= MAX_ARGV_PATH_CHARS
{
words.push((*item).to_owned());
}
words.extend(tokens);
}
words.extend(tokens);
words
}
Some(words)
}
}
}
fn shell_command(args: &Value) -> Option<ShellCommand<'_>> {
match args.get("command").or_else(|| args.get("cmd"))? {
Value::String(command) => Some(ShellCommand::Line(command)),
Value::Array(items) => items
.iter()
.map(Value::as_str)
.collect::<Option<Vec<_>>>()
.map(ShellCommand::Argv),
_ => None,
}
}
@@ -2074,4 +2091,41 @@ mod tests {
"innocuous script silently dropped by the match budget"
);
}
// Reading a shell call's command must not tokenize it: `extract` runs for
// every tool event, including the common case with no `[capture]` policy,
// where the words are never used. Splitting is a separate, explicit step.
#[test]
fn shell_command_borrows_the_command_and_splits_only_on_request() {
let line = json!({"command": "cat 'private notes/x.md' | head"});
let Some(ShellCommand::Line(borrowed)) = shell_command(&line) else {
panic!("a string command is kept as a line");
};
assert!(std::ptr::eq(borrowed, line["command"].as_str().unwrap()));
assert_eq!(
ShellCommand::Line(borrowed).words(),
["cat", "private notes/x.md", "head"]
);
// An argv element stays whole (a path with spaces) and is also split
// on its own; a long one is a script and is only split.
let argv = json!({"cmd": ["cat", "private notes/x.md"]});
assert_eq!(
shell_command(&argv).unwrap().words(),
["cat", "private notes/x.md", "private", "notes/x.md"]
);
let script = format!("echo {}; ls", "x ".repeat(MAX_ARGV_PATH_CHARS));
let argv = json!({"command": ["bash", "-lc", script]});
let words = shell_command(&argv).unwrap().words();
assert!(!words.contains(&script), "a long script is not one path");
assert_eq!(words.last().map(String::as_str), Some("ls"));
// Unreadable commands stay unreadable.
for args in [
json!({"command": 7}),
json!({"command": ["cat", 7]}),
json!({"cmd_line": "cat x"}),
] {
assert!(shell_command(&args).is_none(), "{args}");
}
}
}