mirror of
https://github.com/akitaonrails/ai-memory.git
synced 2026-10-02 03:24:46 +08:00
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:
co-authored by
Claude Sonnet 5.5
parent
9ae469141a
commit
d50cd442d2
@@ -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)
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user