mirror of
https://github.com/akitaonrails/ai-memory.git
synced 2026-10-02 03:24:46 +08:00
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:
co-authored by
Claude Opus 5.5
parent
e76a9fcc0f
commit
d8dfdc94a9
@@ -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
@@ -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"
|
||||
);
|
||||
return;
|
||||
};
|
||||
if !strip_types.status.success() {
|
||||
eprintln!(
|
||||
"skipping Node-required runtime evidence: node lacks --experimental-strip-types"
|
||||
);
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
strip_types.is_ok_and(|output| output.status.success()),
|
||||
"Node runtime evidence needs node with --experimental-strip-types (Node >= 22.6)"
|
||||
);
|
||||
|
||||
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");
|
||||
|
||||
@@ -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);
|
||||
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"),
|
||||
);
|
||||
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::Drop,
|
||||
"command: {command}"
|
||||
serde_json::to_value(protocol.disposition()).unwrap(),
|
||||
vector["disposition"],
|
||||
"vector: {vector}"
|
||||
);
|
||||
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");
|
||||
assert_eq!(
|
||||
decision.protocol().disposition(),
|
||||
CaptureDisposition::Keep,
|
||||
"command: {command}"
|
||||
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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user