fix(hooks): close review gaps in shell-command capture matching

Review of #961 found the ported TypeScript matcher claimed more than it
enforced. Both matchers now:

- recognize OpenClaw's and Devin's `exec` shell tool, and the TS list
  gains the native aliases (`shell_command`, `terminal`, `execute_bash`,
  `execute_cmd`);
- resolve relative arguments from a shell tool's `workdir` (OpenCode
  `bash`, OpenClaw `exec`, Codex `shell`) instead of the event cwd;
- treat `dir/**` as covering `dir` itself when `dir` holds a glob, as the
  TS already did; Rust charges that match to its budget.

TS `captureStartsWith` walks only the prefix instead of spreading both
strings per pattern and argument, and `captureGlob` reuses
`captureCharEq`.

The shell drop/keep tables move into a `shell` section of the shared
capture-policy fixture that both `shell_fixture_vectors` and the Node
runtime evidence execute, with tool-alias, workdir, glob-directory and
Windows vectors. The Linux CI test job installs Node 24 and runs the
Node evidence with `--ignored`, which now fails instead of skipping when
Node cannot strip types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Peterson Salme
2026-09-28 14:36:39 -03:00
co-authored by Claude Opus 5.5
parent e76a9fcc0f
commit d8dfdc94a9
7 changed files with 192 additions and 144 deletions
+13
View File
@@ -75,6 +75,19 @@ jobs:
# block that fails to compile (e.g. an indented shell example rustdoc
# reads as Rust) shipped unnoticed. Run them explicitly.
- run: cargo test --workspace --doc
# The capture policy every generated OpenCode/OMP/Pi/OpenClaw plugin
# embeds is TypeScript; only Node can execute it against the shared
# capture-policy fixture. The test is ignored locally, where Node is not
# a build requirement.
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
if: matrix.os == 'ubuntu-latest'
with:
node-version: "24"
- name: Generated capture-policy TypeScript runtime evidence
if: matrix.os == 'ubuntu-latest'
# `--workspace` reuses the test binaries built above instead of
# re-resolving features for one package.
run: cargo test --workspace --lib -- --ignored --exact commands::render_shared::tests::generated_capture_policy_v1_node_runtime_evidence
- name: Vendored tailwind.css is current
if: matrix.os == 'ubuntu-latest'
run: git diff --exit-code -- crates/ai-memory-web/static/tailwind.css
+6 -1
View File
@@ -20,7 +20,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The generated OpenCode, OMP, Pi and OpenClaw integrations now apply the same
lexical shell-command `ignore_paths` matching as the native hook, so a `bash`
call such as `cat docs/adr/0001.md` is dropped there too instead of being
captured. Refresh or reinstall generated plugins to pick it up. (#948)
captured. Both matchers now also recognize OpenClaw's and Devin's `exec` shell
tool, resolve relative arguments from a shell tool's `workdir` (OpenCode
`bash`, OpenClaw `exec`, Codex `shell`) instead of the event cwd, and treat
`dir/**` as covering `dir` itself when `dir` holds a glob (`docs/a?r/**`), as
the generated plugins already did. Refresh or reinstall generated plugins to
pick it up. (#948)
- Grok Build CLI tool observations are no longer stored with an empty body.
Grok posts Claude Code's snake_case tool fields (`tool_name` / `tool_input` /
`tool_use_id`), but it was missing from both `closed_tool_agent` and the
@@ -268,19 +268,20 @@ function captureConfig(cwd: string | undefined): CaptureConfig {
return { state: "invalid", patterns: [], base: candidateBase };
}
}
function captureGlob(pattern: string, candidate: string, insensitive: boolean, budget: { work: number }): boolean | undefined { const p = [...pattern]; const c = [...candidate]; const eq = (a: string, b: string) => insensitive && a.charCodeAt(0) < 128 && b.charCodeAt(0) < 128 ? a.toLowerCase() === b.toLowerCase() : a === b; const previous = new Array<boolean>(p.length + 1).fill(false); previous[0] = true; for (let j = 1; j <= p.length; j++) previous[j] = p[j - 1] === "*" && p[j] !== "*" && previous[j - 1]; for (const ch of c) { const current = new Array<boolean>(p.length + 1).fill(false); for (let j = 1; j <= p.length; j++) { if (++budget.work > CAPTURE_MAX_WORK) return undefined; const x = p[j - 1]; current[j] = x === "*" && p[j] === "*" ? false : x === "*" && j >= 2 && p[j - 2] === "*" ? current[j - 2] || previous[j] : x === "*" ? current[j - 1] || (ch !== "/" && previous[j]) : x === "?" ? ch !== "/" && previous[j - 1] : eq(x, ch) && previous[j - 1]; } for (let j = 0; j <= p.length; j++) previous[j] = current[j]; } return previous[p.length]; }
function captureGlob(pattern: string, candidate: string, insensitive: boolean, budget: { work: number }): boolean | undefined { const p = [...pattern]; const c = [...candidate]; const previous = new Array<boolean>(p.length + 1).fill(false); previous[0] = true; for (let j = 1; j <= p.length; j++) previous[j] = p[j - 1] === "*" && p[j] !== "*" && previous[j - 1]; for (const ch of c) { const current = new Array<boolean>(p.length + 1).fill(false); for (let j = 1; j <= p.length; j++) { if (++budget.work > CAPTURE_MAX_WORK) return undefined; const x = p[j - 1]; current[j] = x === "*" && p[j] === "*" ? false : x === "*" && j >= 2 && p[j - 2] === "*" ? current[j - 2] || previous[j] : x === "*" ? current[j - 1] || (ch !== "/" && previous[j]) : x === "?" ? ch !== "/" && previous[j - 1] : captureCharEq(x, ch, insensitive) && previous[j - 1]; } for (let j = 0; j <= p.length; j++) previous[j] = current[j]; } return previous[p.length]; }
function captureCharEq(a: string, b: string, insensitive: boolean): boolean { return insensitive && a.charCodeAt(0) < 128 && b.charCodeAt(0) < 128 ? a.toLowerCase() === b.toLowerCase() : a === b; }
function captureLiteralPrefix(path: string): string { const glob = path.search(/[*?]/); if (glob < 0) return path; const slash = path.lastIndexOf("/", glob); if (slash < 0) return ""; const head = path.slice(0, slash + 1); const trimmed = head.slice(0, -1); return trimmed && !trimmed.endsWith(":") ? trimmed : head; }
function captureStartsWith(path: string, prefix: string, insensitive: boolean): boolean { const p = [...path]; const q = [...prefix]; return q.length <= p.length && q.every((x, i) => captureCharEq(x, p[i], insensitive)); }
function captureStartsWith(path: string, prefix: string, insensitive: boolean): boolean { const p = path[Symbol.iterator](); for (const x of prefix) { const c = p.next(); if (c.done || !captureCharEq(x, c.value, insensitive)) return false; } return true; }
function captureGlobReaches(glob: string, prefix: string, insensitive: boolean, budget: { work: number }): boolean | undefined { const target = prefix.replace(/\/+$/, ""); const depth = target.split("/").filter(Boolean).length; if (!depth) return true; let seen = 0; let offset = 0; for (const part of glob.split("/")) { offset += part.length; if (part && ++seen === depth) return captureGlob(glob.slice(0, offset), target, insensitive, budget); offset++; } return false; }
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 value; if (Array.isArray(value) && value.every((x) => typeof x === "string")) return value.join(" "); return undefined; }
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.
function captureMatchCommand(command: string, config: CaptureConfig): boolean | undefined { const budget = { work: 0 }; const home = homedir(); for (const word of captureShellWords(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 && !config.base) continue; const candidate = captureNormalize(absolute ? expanded : captureJoin(config.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 } { 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","execute","run_command","web_search"].includes(name)) return { family: "non-file", extraction: "extracted", callID: call, command: captureShellCommand(args) }; 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") 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) !== 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 }; }
// variables, command substitution, and `cd` state are not followed. A tool's
// own `workdir` replaces the event cwd for relative arguments.
function captureMatchCommand(command: 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 captureShellWords(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; 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), 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") 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)
}
@@ -2067,27 +2068,21 @@ mod tests {
assert_eq!(h, "Bearer abc123");
}
/// Manual Node-required runtime evidence for the exact TypeScript emitted by
/// Node-required runtime evidence for the exact TypeScript emitted by
/// `ts_capture_policy_v1`. This deliberately executes the emitted source,
/// rather than maintaining a JavaScript copy in the test suite.
/// rather than maintaining a JavaScript copy in the test suite. Ignored
/// because Node is not a build requirement; the Linux CI test job installs
/// Node and runs it explicitly, so a missing Node fails instead of skipping.
#[test]
#[ignore = "manual Node-required generated TypeScript runtime evidence"]
#[ignore = "needs Node >= 22.6; CI runs it with --ignored"]
fn generated_capture_policy_v1_node_runtime_evidence() {
let strip_types = Command::new("node")
.args(["--experimental-strip-types", "--version"])
.output();
let Ok(strip_types) = strip_types else {
eprintln!(
"skipping Node-required runtime evidence: node lacks --experimental-strip-types"
assert!(
strip_types.is_ok_and(|output| output.status.success()),
"Node runtime evidence needs node with --experimental-strip-types (Node >= 22.6)"
);
return;
};
if !strip_types.status.success() {
eprintln!(
"skipping Node-required runtime evidence: node lacks --experimental-strip-types"
);
return;
}
let temp = tempfile::tempdir().unwrap();
let module = temp.path().join("capture-policy-runtime-evidence.ts");
@@ -2136,35 +2131,16 @@ for (const vector of fixture.normalization) {{
expectDecision({{ tool: "edit", args: {{ path: vector.candidate }} }}, cwd, vector.match ? "drop" : "keep", "file", "extracted", 1, "fixture-normalization");
}}
expectDecision({{ tool: "edit", args: {{ path: "private/item" }} }}, marker('[capture]\nignore_paths = ["private/**"]\n'), "drop", "file", "extracted", 1, "marker-relative");
// Shell parity with the native hook's lexical command matching (#948).
const shellCwd = marker('[capture]\nignore_paths = ["docs/adr/**", "~/notes/**"]\n');
const shellSub = join(shellCwd, "sub");
mkdirSync(shellSub, {{ recursive: true }});
markerFixtures.set(shellSub, join(shellCwd, ".ai-memory.toml"));
// Shell parity with the native hook's lexical command matching (#948): the
// same shared vectors `capture_policy.rs` `shell_fixture_vectors` runs.
const shellRoot = marker(`[capture]\nignore_paths = ${{JSON.stringify(fixture.shell.ignore_paths)}}\n`);
for (const vector of fixture.shell.vectors) {{
const cwd = vector.cwd ? join(shellRoot, vector.cwd) : shellRoot;
markerFixtures.set(cwd, join(shellRoot, ".ai-memory.toml"));
const payload = JSON.parse(JSON.stringify(vector.payload ?? {{ tool: "bash", args: {{ command: vector.command }} }}).replaceAll("{{root}}", shellRoot));
expectDecision(payload, cwd, vector.disposition, "non-file", "extracted", 0, `shell-${{vector.disposition}}: ${{JSON.stringify(vector)}}`);
}}
const bash = (command: string) => ({{ tool: "bash", args: {{ command }} }});
for (const [command, cwd] of [
["cat docs/adr/0001.md", shellCwd],
["cat ./docs/adr/0001.md", shellCwd],
[`cat ${{shellCwd}}/docs/adr/0001.md`, shellCwd],
["cat ../docs/adr/0001.md", shellSub],
["cat docs/adr/*.md", shellCwd],
["cat docs/*/0001.md", shellCwd],
["ls docs/*", shellCwd],
["cat 'docs/adr/a b.md'", shellCwd],
['cat "docs/adr/a b.md"', shellCwd],
[String.raw`cat docs/adr/a\ b.md`, shellCwd],
["head -n5 docs/adr/x.md | wc -l", shellCwd],
["grep --file=docs/adr/x.md pattern", shellCwd],
["FILE=docs/adr/x.md sh -c 'cat $FILE'", shellCwd],
[`cd /tmp && cat ${{shellCwd}}/docs/adr/x.md;echo`, shellCwd],
["wc -l <docs/adr/x.md", shellCwd],
["cat ~/notes/today.md", shellCwd],
["cat docs/adr", shellCwd],
]) expectDecision(bash(command), cwd, "drop", "non-file", "extracted", 0, `shell-drop: ${{command}}`);
for (const command of ["cat src/main.rs", "cat docs/adrx.md", "cat docs/adr-notes/x.md", "ls docs", "cat *.md", "git add .", "cargo test -p ai-memory-hooks", "echo 'docs/adr is ignored'x", "cat notes/today.md", ""]) expectDecision(bash(command), shellCwd, "keep", "non-file", "extracted", 0, `shell-keep: ${{command}}`);
expectDecision({{ tool: "shell", args: {{ command: ["bash", "-lc", "cat docs/adr/x.md"] }} }}, shellCwd, "drop", "non-file", "extracted", 0, "shell-argv");
expectDecision({{ tool: "run_command", args: {{ cmd: "cat docs/adr/x.md" }} }}, shellCwd, "drop", "non-file", "extracted", 0, "shell-cmd-key");
expectDecision({{ tool: "bash", args: {{ command: 7 }} }}, shellCwd, "keep", "non-file", "extracted", 0, "shell-non-string-command");
check(capturePolicy(bash("cat docs/adr/x.md"), "/no-marker").disposition === "keep", "shell-inactive-keep");
expectDecision(bash("cat docs/adr/x.md"), marker('[capture'), "keep", "non-file", "extracted", 0, "shell-invalid-keep");
expectDecision(bash(`cat ${{Array(8).fill("docs/x".repeat(400)).join(" ")}}`), marker(`[capture]\nignore_paths = ["${{"docs/**/?".repeat(100)}}"]\n`), "drop", "non-file", "extracted", 0, "shell-budget-fails-closed");
+84 -86
View File
@@ -392,6 +392,17 @@ struct CompiledPattern {
/// Whole segments before the first glob; a matching path must start here.
literal_prefix: String,
}
impl CompiledPattern {
/// Upper bound on `glob_match` steps for one candidate, directory glob included.
fn match_cost(&self, candidate: &str) -> usize {
let pattern = self.path.chars().count()
+ self
.directory_base
.as_deref()
.map_or(0, |base| base.chars().count());
pattern.saturating_mul(candidate.chars().count())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Flavor {
Posix,
@@ -505,11 +516,15 @@ 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(|command| self.match_command(command, cwd))
{
ToolFamily::NonFile => match extracted.command.as_deref().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(command, &base)
}) {
Some(Ok(true) | Err(())) => (CaptureDisposition::Drop, extracted.state),
Some(Ok(false)) | None => (CaptureDisposition::Keep, extracted.state),
},
@@ -545,13 +560,7 @@ impl CapturePolicy {
.filter(|pattern| pattern.flavor == candidate.flavor)
{
work = work
.checked_add(
pattern
.path
.chars()
.count()
.saturating_mul(candidate.path.chars().count()),
)
.checked_add(pattern.match_cost(&candidate.path))
.ok_or(())?;
if work > MAX_MATCH_WORK {
return Err(());
@@ -598,13 +607,7 @@ impl CapturePolicy {
continue;
}
work = work
.checked_add(
pattern
.path
.chars()
.count()
.saturating_mul(candidate.path.chars().count()),
)
.checked_add(pattern.match_cost(&candidate.path))
.ok_or(())?;
if work > MAX_MATCH_WORK {
return Err(());
@@ -656,6 +659,7 @@ struct Extracted {
family: ToolFamily,
paths: Option<Vec<String>>,
command: Option<String>,
workdir: Option<String>,
call_id: Option<String>,
state: ExtractionState,
}
@@ -714,6 +718,13 @@ fn extract(agent: AgentKind, raw: &Value) -> Extracted {
let command = (family == ToolFamily::NonFile)
.then(|| args.and_then(shell_command))
.flatten();
// OpenCode `bash`, OpenClaw `exec` and Codex `shell` run in `workdir`
// when given, so relative arguments resolve from there.
let workdir = command
.as_ref()
.and_then(|_| args?.get("workdir")?.as_str())
.filter(|dir| !dir.trim().is_empty())
.map(str::to_owned);
let state = if family == ToolFamily::File && paths.is_none() {
ExtractionState::MissingOrMalformed
} else {
@@ -740,6 +751,7 @@ fn extract(agent: AgentKind, raw: &Value) -> Extracted {
family,
paths,
command,
workdir,
call_id,
state,
}
@@ -771,7 +783,7 @@ fn family(name: &str) -> ToolFamily {
"read_file" | "write_file" | "edit_file" | "patch" => ToolFamily::File,
"search" | "grep" | "glob" | "find" | "list" | "ls" | "list_files" | "read_dir"
| "list_dir" | "grep_search" | "search_files" => ToolFamily::SearchList,
"bash" | "shell" | "shell_command" | "execute" | "run_command" | "web_search"
"bash" | "shell" | "shell_command" | "exec" | "execute" | "run_command" | "web_search"
| "terminal" | "execute_bash" | "execute_cmd" => ToolFamily::NonFile,
_ => ToolFamily::Unknown,
}
@@ -1107,13 +1119,13 @@ fn glob_match(
directory_base: Option<&str>,
insensitive: bool,
) -> bool {
let pattern: Vec<char> = pattern.chars().collect();
let candidate: Vec<char> = candidate.chars().collect();
if directory_base
.is_some_and(|base| equal_chars(&base.chars().collect::<Vec<_>>(), &candidate, insensitive))
{
// `dir/**` also names `dir` itself, and `dir` may hold globs
// (`docs/a?r/**`), exactly as in the generated TypeScript.
if directory_base.is_some_and(|base| glob_match(base, candidate, None, insensitive)) {
return true;
}
let pattern: Vec<char> = pattern.chars().collect();
let candidate: Vec<char> = candidate.chars().collect();
let mut previous = vec![false; pattern.len() + 1];
previous[0] = true;
for index in 1..=pattern.len() {
@@ -1185,13 +1197,6 @@ fn glob_reaches(glob: &str, prefix: &str, insensitive: bool) -> bool {
}
false
}
fn equal_chars(left: &[char], right: &[char], insensitive: bool) -> bool {
left.len() == right.len()
&& left
.iter()
.zip(right)
.all(|(&a, &b)| char_equal(a, b, insensitive))
}
fn char_equal(left: char, right: char, insensitive: bool) -> bool {
if insensitive && left.is_ascii() && right.is_ascii() {
left.eq_ignore_ascii_case(&right)
@@ -1744,65 +1749,49 @@ mod tests {
json!({"tool_name": "Bash", "tool_input": {"command": command}, "tool_use_id": "call-1"})
}
/// The drop/keep tables live in the shared fixture so the generated
/// TypeScript matcher runs the very same vectors (#961).
#[test]
fn shell_commands_reading_ignored_paths_are_dropped() {
let policy = shell_policy();
for (command, cwd) in [
("cat docs/adr/0001.md", "/repo"),
("cat ./docs/adr/0001.md", "/repo"),
("cat /repo/docs/adr/0001.md", "/repo"),
("cat ../docs/adr/0001.md", "/repo/sub"),
("cat docs/adr/*.md", "/repo"),
("cat docs/*/0001.md", "/repo"),
("ls docs/*", "/repo"),
("cat 'docs/adr/a b.md'", "/repo"),
("cat \"docs/adr/a b.md\"", "/repo"),
(r"cat docs/adr/a\ b.md", "/repo"),
("head -n5 docs/adr/x.md | wc -l", "/repo"),
("grep --file=docs/adr/x.md pattern", "/repo"),
("FILE=docs/adr/x.md sh -c 'cat $FILE'", "/repo"),
("cd /tmp && cat /repo/docs/adr/x.md;echo", "/repo"),
("wc -l <docs/adr/x.md", "/repo"),
("cat ~/notes/today.md", "/repo"),
("cat docs/adr", "/repo"),
] {
let decision = policy.inspect(AgentKind::ClaudeCode, &bash(command), cwd);
assert_eq!(
decision.protocol().disposition(),
CaptureDisposition::Drop,
"command: {command}"
fn shell_fixture_vectors() {
let fixture: Value =
serde_json::from_str(include_str!("../tests/fixtures/capture-policy.json")).unwrap();
let shell = &fixture["shell"];
let ignore_paths = shell["ignore_paths"]
.as_array()
.unwrap()
.iter()
.map(|pattern| pattern.as_str().unwrap().to_owned())
.collect();
let policy = CapturePolicy::resolve(
CaptureSource::Parsed(&CaptureConfig { ignore_paths }),
"/repo",
Some("/home/me"),
);
assert_eq!(decision.protocol().tool_family(), ToolFamily::NonFile);
}
}
#[test]
fn shell_commands_not_touching_ignored_paths_are_kept() {
let policy = shell_policy();
for command in [
"cat src/main.rs",
"cat docs/adrx.md",
"cat docs/adr-notes/x.md",
"ls docs",
"cat *.md",
"git add .",
"cargo test -p ai-memory-hooks",
"echo 'docs/adr is ignored'x",
"cat notes/today.md",
"",
] {
let decision = policy.inspect(AgentKind::ClaudeCode, &bash(command), "/repo");
for vector in shell["vectors"].as_array().unwrap() {
let payload = vector
.get("payload")
.cloned()
.unwrap_or_else(|| json!({"tool": "bash", "args": {"command": vector["command"]}}));
let payload: Value =
serde_json::from_str(&payload.to_string().replace("{root}", "/repo")).unwrap();
let cwd = vector["cwd"]
.as_str()
.map_or_else(|| "/repo".to_owned(), |sub| format!("/repo/{sub}"));
let decision = policy.inspect(AgentKind::OpenCode, &payload, &cwd);
let protocol = decision.protocol();
assert_eq!(
decision.protocol().disposition(),
CaptureDisposition::Keep,
"command: {command}"
serde_json::to_value(protocol.disposition()).unwrap(),
vector["disposition"],
"vector: {vector}"
);
assert_eq!(
protocol.tool_family(),
ToolFamily::NonFile,
"vector: {vector}"
);
// Unchanged protocol fields keep old and new servers agreeing.
assert_eq!(decision.protocol().path_count(), 0);
assert_eq!(
decision.protocol().extraction_state(),
ExtractionState::Extracted
);
assert_eq!(protocol.path_count(), 0);
assert_eq!(protocol.extraction_state(), ExtractionState::Extracted);
}
}
@@ -1831,6 +1820,14 @@ mod tests {
AgentKind::OpenCode,
json!({"tool": "bash", "args": {"command": "cat docs/adr/x.md"}}),
),
(
AgentKind::OpenClaw,
json!({"tool": "exec", "args": {"command": "cat 0001.md", "workdir": "docs/adr"}}),
),
(
AgentKind::Devin,
json!({"tool_name": "exec", "tool_input": {"command": "cat docs/adr/x.md"}}),
),
] {
let decision = policy.inspect(agent, &raw, "/repo");
if decision.protocol().tool_family() == ToolFamily::NonFile {
@@ -1842,6 +1839,7 @@ mod tests {
} else {
// `exec_command` is not a recognized shell tool name; it keeps
// today's unknown-tool behavior rather than guessing.
assert_eq!(raw["tool_name"], "exec_command", "raw: {raw}");
assert_eq!(decision.protocol().tool_family(), ToolFamily::Unknown);
}
}
+58 -3
View File
@@ -9,7 +9,9 @@
{"pattern":"C:\\Secret\\**","candidate":"c:/SECRET","cwd":"C:/","match":true},
{"pattern":"\\\\server\\share\\x","candidate":"//SERVER/SHARE/X","cwd":"C:/","match":true},
{"pattern":"unicode/?.txt","candidate":"unicode/é.txt","cwd":"/repo","match":true},
{"pattern":"foo..bar","candidate":"foo..bar","cwd":"/repo","match":true}
{"pattern":"foo..bar","candidate":"foo..bar","cwd":"/repo","match":true},
{"pattern":"docs/a?r/**","candidate":"docs/adr","cwd":"/repo","match":true},
{"pattern":"docs/a?r/**","candidate":"docs/adrx","cwd":"/repo","match":false}
],
"decisions": [
{"agent":"claude-code","payload":{"tool_name":"Edit","tool_input":{"file_path":"secret/a"}},"disposition":"drop","tool_family":"file","extraction_state":"extracted","path_count":1},
@@ -32,9 +34,9 @@
{"agent":"open-code","payload":{"tool":"bash","args":{"command":"cat secret/a | head"}},"disposition":"drop","tool_family":"non-file","extraction_state":"extracted","path_count":0},
{"agent":"omp","payload":{"tool":"bash","args":{"command":"cat secret/*"}},"disposition":"drop","tool_family":"non-file","extraction_state":"extracted","path_count":0},
{"agent":"pi","payload":{"tool":"bash","args":{"command":"cat 'secret/a b'"}},"disposition":"drop","tool_family":"non-file","extraction_state":"extracted","path_count":0},
{"agent":"openclaw","payload":{"tool":"bash","args":{"command":"wc -l <secret/a"}},"disposition":"drop","tool_family":"non-file","extraction_state":"extracted","path_count":0},
{"agent":"openclaw","payload":{"tool":"exec","args":{"command":"wc -l <secret/a"}},"disposition":"drop","tool_family":"non-file","extraction_state":"extracted","path_count":0},
{"agent":"open-code","payload":{"tool":"bash","args":{"command":"cat public/a *.md"}},"disposition":"keep","tool_family":"non-file","extraction_state":"extracted","path_count":0},
{"agent":"openclaw","payload":{"tool":"bash","args":{"command":"git diff"}},"disposition":"keep","tool_family":"non-file","extraction_state":"extracted","path_count":0},
{"agent":"openclaw","payload":{"tool":"exec","args":{"command":"git diff"}},"disposition":"keep","tool_family":"non-file","extraction_state":"extracted","path_count":0},
{"agent":"codex","payload":{"tool_name":"Edit","tool_input":null},"disposition":"metadata-only","tool_family":"file","extraction_state":"missing-or-malformed","path_count":0},
{"agent":"codex","payload":{"tool_name":"Edit","tool_input":{"path":" "}},"disposition":"metadata-only","tool_family":"file","extraction_state":"missing-or-malformed","path_count":0},
{"agent":"codex","payload":{"tool_name":"MultiEdit","tool_input":{"edits":[{"path":"public"},{"path":"secret/a"}]}},"disposition":"drop","tool_family":"file","extraction_state":"extracted","path_count":2},
@@ -43,5 +45,58 @@
{"agent":"codex","payload":{"tool_name":"FutureTool","tool_input":{"path":"secret/a"}},"disposition":"keep","tool_family":"unknown","extraction_state":"extracted","path_count":0},
{"agent":"codex","payload":{"tool_name":"Edit","tool_input":{"nested":{"path":"secret/a"}}},"disposition":"metadata-only","tool_family":"file","extraction_state":"missing-or-malformed","path_count":0}
],
"shell": {
"note": "Shell-tool vectors run by both matchers. `{root}` is the marker directory, `cwd` is relative to it, and `command` is shorthand for a `bash` payload.",
"ignore_paths": ["docs/adr/**","~/notes/**","docs/n?tes/**","C:/Secret/**"],
"vectors": [
{"command":"cat docs/adr/0001.md","disposition":"drop"},
{"command":"cat ./docs/adr/0001.md","disposition":"drop"},
{"command":"cat {root}/docs/adr/0001.md","disposition":"drop"},
{"command":"cat ../docs/adr/0001.md","cwd":"sub","disposition":"drop"},
{"command":"cat docs/adr/*.md","disposition":"drop"},
{"command":"cat docs/*/0001.md","disposition":"drop"},
{"command":"ls docs/*","disposition":"drop"},
{"command":"cat 'docs/adr/a b.md'","disposition":"drop"},
{"command":"cat \"docs/adr/a b.md\"","disposition":"drop"},
{"command":"cat docs/adr/a\\ b.md","disposition":"drop"},
{"command":"head -n5 docs/adr/x.md | wc -l","disposition":"drop"},
{"command":"grep --file=docs/adr/x.md pattern","disposition":"drop"},
{"command":"FILE=docs/adr/x.md sh -c 'cat $FILE'","disposition":"drop"},
{"command":"cd /tmp && cat {root}/docs/adr/x.md;echo","disposition":"drop"},
{"command":"wc -l <docs/adr/x.md","disposition":"drop"},
{"command":"cat ~/notes/today.md","disposition":"drop"},
{"command":"cat docs/adr","disposition":"drop"},
{"command":"ls docs/notes","disposition":"drop"},
{"command":"cat c:/SECRET/x.md","disposition":"drop"},
{"command":"cat C:\\SECRET\\x.md","disposition":"drop"},
{"command":"ls c:/sec*","disposition":"drop"},
{"command":"cat src/main.rs","disposition":"keep"},
{"command":"cat docs/adrx.md","disposition":"keep"},
{"command":"cat docs/adr-notes/x.md","disposition":"keep"},
{"command":"ls docs","disposition":"keep"},
{"command":"cat *.md","disposition":"keep"},
{"command":"git add .","disposition":"keep"},
{"command":"cargo test -p ai-memory-hooks","disposition":"keep"},
{"command":"echo 'docs/adr is ignored'x","disposition":"keep"},
{"command":"cat notes/today.md","disposition":"keep"},
{"command":"","disposition":"keep"},
{"command":"cat C:/Public/x.md","disposition":"keep"},
{"command":"ls C:/Pub*","disposition":"keep"},
{"payload":{"tool":"shell","args":{"command":["bash","-lc","cat docs/adr/x.md"]}},"disposition":"drop"},
{"payload":{"tool":"run_command","args":{"cmd":"cat docs/adr/x.md"}},"disposition":"drop"},
{"payload":{"tool":"bash","args":{"command":7}},"disposition":"keep"},
{"payload":{"tool":"exec","args":{"command":"cat docs/adr/x.md"}},"disposition":"drop"},
{"payload":{"tool":"shell_command","args":{"command":"cat docs/adr/x.md"}},"disposition":"drop"},
{"payload":{"tool":"terminal","args":{"command":"cat docs/adr/x.md"}},"disposition":"drop"},
{"payload":{"tool":"execute_bash","args":{"command":"cat docs/adr/x.md"}},"disposition":"drop"},
{"payload":{"tool":"execute_cmd","args":{"command":"cat docs/adr/x.md"}},"disposition":"drop"},
{"payload":{"tool":"bash","args":{"command":"cat 0001.md","workdir":"docs/adr"}},"disposition":"drop"},
{"payload":{"tool":"exec","args":{"command":"cat 0001.md","workdir":"{root}/docs/adr"}},"disposition":"drop"},
{"payload":{"tool":"bash","args":{"command":"cat ../adr/0001.md","workdir":"docs/x"}},"disposition":"drop"},
{"payload":{"tool":"bash","args":{"command":"cat docs/adr/0001.md","workdir":" "}},"disposition":"drop"},
{"payload":{"tool":"bash","args":{"command":"cat docs/adr/0001.md","workdir":"sub"}},"disposition":"keep"},
{"payload":{"tool":"bash","args":{"command":"cat 0001.md","workdir":7}},"disposition":"keep"}
]
},
"protocol": {"accept":{"version":1,"disposition":"drop","policy_state":"active","tool_family":"file","path_count":1,"extraction_state":"extracted"},"reject":{"version":1,"disposition":"drop","policy_state":"active","tool_family":"file","path_count":1,"extraction_state":"extracted","paths":["SENTINEL"]}}
}
+6 -5
View File
@@ -235,11 +235,12 @@ That form contains only bounded routing/tool/decision metadata, never paths,
patterns, arguments, output, errors, titles, or nested payload. Unknown tools
retain current behavior.
Recognized shell tools (`Bash`, `shell`, `execute_bash`, `terminal`, …) have no
path field, so the command line is split into words lexically, the way a POSIX
shell quotes and separates them, without expanding or running anything. Each
argument that is not a flag, plus the value of a `--flag=value` or
`NAME=value` word, is resolved from the event's `cwd` like a file-tool path. If
Recognized shell tools (`Bash`, `shell`, `exec`, `execute_bash`, `terminal`, …)
have no path field, so the command line is split into words lexically, the way
a POSIX shell quotes and separates them, without expanding or running anything.
Each argument that is not a flag, plus the value of a `--flag=value` or
`NAME=value` word, is resolved like a file-tool path: from the tool's own
`workdir` argument when it has one, otherwise from the event's `cwd`. If
one matches a pattern, the whole event is **dropped**, exactly like a matching
file read. An argument containing `*` or `?` also matches when its glob can
reach a pattern's directory: `cat docs/*/0001.md` is dropped under
+1 -1
View File
@@ -39,7 +39,7 @@ boundary not yet built.
| 10 | Destructive-op live-process refusal + confirm flags (invariant #9) | `ai-memory-cli/src/commands/process_guard.rs` `sibling_processes` + confirm flags in `reset`/`restore`/`reindex`/`uninstall --purge-data`/`purge_project` | `admin_purge.rs` confirm→400; `removal.rs` — injected live-sibling makes each destructive command bail before touching the data dir | STRONG |
| 10b | Session purge is scope+owner-bound (no cross-session/project over-delete) | `ai-memory-store/src/ops.rs` `purge_session` — selection scoped to `(workspace_id, project_id)` and keyed on this session's own `summary_page_id` **or** `path='sessions/<sid>.md'` + `json_extract(frontmatter_json,'$.session_id')=<sid>` (frontmatter owner, not the recursive latest-chain); `in_scope==0 → NotFound` fail-closed; whole op in one transaction | `ops.rs` `purge_session_leaves_a_sibling_session_in_the_same_project_intact`, `…refuses_a_session_from_another_project_and_deletes_nothing`, `…refuses_a_session_from_another_workspace`, `…does_not_delete_an_identically_pathed_page_in_another_project`, `…removes_older_summary_versions_without_deleting_prior_manual_page` (#862) | STRONG |
| 11a | Hook backpressure (202/429) + bounded fan-out (invariant #5) | `ai-memory-hooks/src/router.rs` semaphore→429, 202 immediately, `MAX_HOOK_BATCH_ITEMS`, bounded LRU limiter | `router.rs` `handle_hook_returns_429_when_ingest_saturated`, `ingest_rate_limiter_is_bounded` | STRONG |
| 11b | Capture exclusions drop before storage | `ai-memory-hooks` `capture_policy.rs` `inspect`→`Drop` (before semaphore/spawn), including shell commands whose arguments name an ignored path (`match_command`); generated OpenCode/OMP/Pi/OpenClaw integrations mirror it in `render_shared.rs` `ts_capture_policy_v1` (`captureMatchCommand`) | `capture_policy.rs` per-agent `…honors_exclusions` tests, `shell_commands_reading_ignored_paths_are_dropped` (+ `…not_touching…are_kept` control), `fixture_vectors` (shared `capture-policy.json`, incl. TS-adapter `bash` drop/keep vectors); `router.rs` `capture_protocol_shell_decisions_survive_server_reinspection`; `hook.rs` `shell_command_reading_an_ignored_path_is_dropped_before_spool`; `render_shared.rs` `generated_capture_policy_v1_node_runtime_evidence` (manual, Node-required: same fixture + shell drop/keep tables against the emitted TypeScript) | STRONG |
| 11b | Capture exclusions drop before storage | `ai-memory-hooks` `capture_policy.rs` `inspect`→`Drop` (before semaphore/spawn), including shell commands whose arguments name an ignored path (`match_command`); generated OpenCode/OMP/Pi/OpenClaw integrations mirror it in `render_shared.rs` `ts_capture_policy_v1` (`captureMatchCommand`) | `capture_policy.rs` per-agent `…honors_exclusions` tests, `shell_fixture_vectors` (shared `capture-policy.json` `shell` drop/keep vectors: tool aliases incl. OpenClaw/Devin `exec`, `workdir`, glob directories, Windows paths), `shell_tool_shapes_of_every_adapter_honor_exclusions`, `fixture_vectors` (incl. TS-adapter `bash`/`exec` vectors); `router.rs` `capture_protocol_shell_decisions_survive_server_reinspection`; `hook.rs` `shell_command_reading_an_ignored_path_is_dropped_before_spool`; `render_shared.rs` `generated_capture_policy_v1_node_runtime_evidence` (runs the same fixture sections against the emitted TypeScript; `#[ignore]` locally, run with `--ignored` under Node 24 by the Linux CI test job) | STRONG |
| 11c | Capture hook ≤200ms budget (invariant #5) | `hooks/_lib.sh` capture path `curl --max-time 0.2` (context-fetch 1.0s and background drain 2.0s are separate, larger-budget paths) | none (shell-script timeout; hard to unit-test) — watch on any capture-path change | WATCH |
| 12 | Network/auth posture | `config.rs` loopback `DEFAULT_BIND`; `serve.rs` `validate_http_exposure`, `require_allowed_host`; `auth.rs` `require_bearer` | `serve.rs` host-guard (missing→400 / forged→403), non-loopback-requires-token; `auth.rs` wrong-token 401 | STRONG |
| 13 | Managed-run transcript attribution (concurrent launches in one checkout, invariant #16) | `ai-memory-store/src/workstream.rs` `link_native_session` stamps `native_session_linked_at` on its own run only, both `finish` updates drop the stamp when the session changes, `run_status` reports it; `ai-memory-cli/src/commands/run.rs` `resolve_native_session_after_run` takes a linked session only when `ai-memory-workstream` `native_session_in_checkout` holds it for this checkout (OpenCode by recorded directory), and never falls back to a link it set aside | `multi_session.rs` `a_session_linked_by_one_managed_run_is_not_another_runs`; `run.rs` `a_session_linked_during_the_run_wins_over_discovery` (concurrent newer session, another checkout's link refused, no fallback to it, unlinked control); `transcript.rs` `native_session_in_checkout_checks_the_opencode_directory`; `store/src/lib.rs` `managed_run_status_reports_a_link_made_during_the_run` | PARTIAL: a run whose child links nothing still falls back to discovering the newest session in the checkout |