From 849cf1040cb55d89de251cb90f11c59f23f907b9 Mon Sep 17 00:00:00 2001 From: Devon Date: Mon, 17 Aug 2026 15:56:07 +0000 Subject: [PATCH] fix(hooks): scope pre-tool-use enrichment to the current project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code's PreToolUse payload carries no `project` field — only session_id, cwd, tool_name and tool_input — but the hook read `data.project` and nothing else, so it always POSTed /agentmemory/enrich unscoped and mem::enrich searched the entire corpus. On a machine with several active projects that injects the wrong project's observations into this project's tool turns. Reproduced against a live 0.9.29 server: enriching src/functions/summarize.ts in the agentmemory repo returned daily-momentum-rebalancer content (lib/engine/main.ts, ReallocationDecisionSnapshot); the same request with a project scope returned agentmemory content and zero cross-project matches. pre-tool-use.ts was the only project-aware hook not importing _project.js — the other eleven already resolve via hookCwd(data) + resolveProject(cwd). Do the same here, keeping an explicit data.project as an override for hosts that do supply one. --- plugin/scripts/pre-tool-use.mjs | 36 +++++- src/hooks/pre-tool-use.ts | 14 ++- test/pre-tool-use-project-scope.test.ts | 149 ++++++++++++++++++++++++ 3 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 test/pre-tool-use-project-scope.test.ts diff --git a/plugin/scripts/pre-tool-use.mjs b/plugin/scripts/pre-tool-use.mjs index 97c65dc90..17d2717b5 100755 --- a/plugin/scripts/pre-tool-use.mjs +++ b/plugin/scripts/pre-tool-use.mjs @@ -1,4 +1,36 @@ #!/usr/bin/env node +import { execSync } from "node:child_process"; +import { basename } from "node:path"; +//#region src/hooks/_project.ts +function resolveProject(cwd) { + const explicit = process.env["AGENTMEMORY_PROJECT_NAME"]; + if (explicit && explicit.trim()) return explicit.trim(); + const dir = cwd && cwd.trim() ? cwd : process.cwd(); + try { + const top = execSync("git rev-parse --show-toplevel", { + cwd: dir, + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).toString().trim(); + if (top) return basename(top); + } catch {} + return basename(dir); +} +function hookCwd(data) { + if (!data || typeof data !== "object") return void 0; + if (typeof data.cwd === "string" && data.cwd.trim()) return data.cwd; + const roots = data.workspace_roots; + if (Array.isArray(roots)) { + for (const root of roots) if (typeof root === "string" && root.trim()) return root; + } + const projectDir = process.env["DEVIN_PROJECT_DIR"] || process.env["CLAUDE_PROJECT_DIR"]; + if (projectDir && projectDir.trim()) return projectDir; +} +//#endregion //#region src/hooks/pre-tool-use.ts function isSdkChildContext(payload) { if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; @@ -58,7 +90,7 @@ async function main() { } const rawSessionId = data.session_id || data.sessionId || data.conversation_id; const sessionId = typeof rawSessionId === "string" && rawSessionId.length > 0 ? rawSessionId : "unknown"; - const project = typeof data.project === "string" && data.project.trim().length > 0 ? data.project.trim() : void 0; + const project = (typeof data.project === "string" && data.project.trim().length > 0 ? data.project.trim() : void 0) ?? resolveProject(hookCwd(data) || process.cwd()); try { const res = await fetch(`${REST_URL}/agentmemory/enrich`, { method: "POST", @@ -68,7 +100,7 @@ async function main() { files, terms, toolName, - ...project !== void 0 && { project } + project }), signal: AbortSignal.timeout(2e3) }); diff --git a/src/hooks/pre-tool-use.ts b/src/hooks/pre-tool-use.ts index 0262fdea5..01c124211 100644 --- a/src/hooks/pre-tool-use.ts +++ b/src/hooks/pre-tool-use.ts @@ -1,5 +1,7 @@ #!/usr/bin/env node +import { resolveProject, hookCwd } from "./_project.js"; + function isSdkChildContext(payload: unknown): boolean { if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; if (!payload || typeof payload !== "object") return false; @@ -94,10 +96,18 @@ async function main() { typeof rawSessionId === "string" && rawSessionId.length > 0 ? rawSessionId : "unknown"; - const project = + // Claude Code's PreToolUse payload carries no `project` field — only + // session_id, cwd, tool_name and tool_input — so trusting data.project + // alone left every /enrich call unscoped, and mem::enrich then searched + // the whole corpus. On a machine with several active projects that + // injects another project's observations into this one's tool turns. + // Resolve from cwd like every other project-aware hook does, keeping an + // explicit data.project as an override for hosts that do supply one. + const explicitProject = typeof data.project === "string" && data.project.trim().length > 0 ? data.project.trim() : undefined; + const project = explicitProject ?? resolveProject(hookCwd(data) || process.cwd()); try { const res = await fetch(`${REST_URL}/agentmemory/enrich`, { @@ -108,7 +118,7 @@ async function main() { files, terms, toolName, - ...(project !== undefined && { project }), + project, }), signal: AbortSignal.timeout(2000), }); diff --git a/test/pre-tool-use-project-scope.test.ts b/test/pre-tool-use-project-scope.test.ts new file mode 100644 index 000000000..1b28ca759 --- /dev/null +++ b/test/pre-tool-use-project-scope.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { spawn } from "node:child_process"; +import { createServer, type Server } from "node:http"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Claude Code's PreToolUse payload has no `project` field — only session_id, +// cwd, tool_name and tool_input. The hook used to read data.project only, so +// every /agentmemory/enrich call went out unscoped and mem::enrich searched +// the whole corpus, injecting other projects' observations into this +// project's tool turns. It must resolve the project from cwd like every +// other project-aware hook. + +let server: Server; +let port: number; +let posts: Array<{ path: string; body: Record }> = []; + +const REPO_NAME = "amem-pretool-fixture"; + +function runHook( + payload: Record, + env: Record = {}, +): Promise { + return new Promise((resolve) => { + const child = spawn("node", ["plugin/scripts/pre-tool-use.mjs"], { + env: { + ...process.env, + AGENTMEMORY_URL: `http://127.0.0.1:${port}`, + AGENTMEMORY_INJECT_CONTEXT: "true", + // Keep the fixture's basename as the identity regardless of whether + // the developer running the suite has remote-identity mode on. + AGENTMEMORY_PROJECT_FROM_REMOTE: "0", + AGENTMEMORY_PROJECT_NAME: "", + ...env, + }, + }); + child.on("exit", (code) => resolve(code ?? 1)); + child.stdin.write(JSON.stringify(payload)); + child.stdin.end(); + }); +} + +describe("pre-tool-use enrich is project-scoped", () => { + let tmpRoot: string; + let repoDir: string; + let nestedDir: string; + + beforeAll(async () => { + tmpRoot = mkdtempSync(join(tmpdir(), "am-pretool-")); + repoDir = join(tmpRoot, REPO_NAME); + nestedDir = join(repoDir, "src", "deep"); + mkdirSync(nestedDir, { recursive: true }); + execFileSync("git", ["init", "--quiet"], { cwd: repoDir, stdio: "ignore" }); + + server = createServer((req, res) => { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + try { + posts.push({ path: req.url ?? "", body: JSON.parse(body || "{}") }); + } catch { + posts.push({ path: req.url ?? "", body: {} }); + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ context: "" })); + }); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + port = (server.address() as { port: number }).port; + }); + + afterAll(() => { + server.close(); + rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it("derives project from the payload cwd", async () => { + posts = []; + await runHook({ + session_id: "s1", + cwd: repoDir, + tool_name: "Read", + tool_input: { file_path: join(repoDir, "src", "thing.ts") }, + }); + + expect(posts.length).toBe(1); + expect(posts[0]!.path).toContain("/agentmemory/enrich"); + expect(posts[0]!.body.project).toBe(REPO_NAME); + }); + + it("resolves to the git toplevel from a nested cwd", async () => { + posts = []; + await runHook({ + session_id: "s2", + cwd: nestedDir, + tool_name: "Edit", + tool_input: { file_path: join(nestedDir, "x.ts") }, + }); + + expect(posts.length).toBe(1); + expect(posts[0]!.body.project).toBe(REPO_NAME); + }); + + it("never sends an unscoped enrich request", async () => { + posts = []; + await runHook({ + session_id: "s3", + cwd: repoDir, + tool_name: "Grep", + tool_input: { pattern: "handleError", path: repoDir }, + }); + + expect(posts.length).toBe(1); + const project = posts[0]!.body.project; + expect(project).toBeTruthy(); + expect(typeof project).toBe("string"); + }); + + it("an explicit payload project still wins (hosts that do supply one)", async () => { + posts = []; + await runHook({ + session_id: "s4", + cwd: repoDir, + project: "explicit-project", + tool_name: "Read", + tool_input: { file_path: join(repoDir, "y.ts") }, + }); + + expect(posts.length).toBe(1); + expect(posts[0]!.body.project).toBe("explicit-project"); + }); + + it("stays a no-op when AGENTMEMORY_INJECT_CONTEXT is not true", async () => { + posts = []; + await runHook( + { + session_id: "s5", + cwd: repoDir, + tool_name: "Read", + tool_input: { file_path: join(repoDir, "z.ts") }, + }, + { AGENTMEMORY_INJECT_CONTEXT: "false" }, + ); + + expect(posts.length).toBe(0); + }); +});