From 5040ceeab6e720ec97021d45907a6b2c1ef1604b Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 5 Jul 2026 15:49:17 +0200 Subject: [PATCH] Support Codex pre-tool-use targets --- plugin/hooks/hooks.codex.json | 2 +- plugin/scripts/pre-tool-use.mjs | 147 ++++++++++++++++++++++++------- src/hooks/pre-tool-use.ts | 132 ++++++++++++++++++++++----- test/codex-connect-hooks.test.ts | 4 +- test/codex-plugin.test.ts | 12 ++- test/context-injection.test.ts | 136 ++++++++++++++++++++++++++++ 6 files changed, 373 insertions(+), 60 deletions(-) diff --git a/plugin/hooks/hooks.codex.json b/plugin/hooks/hooks.codex.json index d2c3a3b6d..3fb435426 100644 --- a/plugin/hooks/hooks.codex.json +++ b/plugin/hooks/hooks.codex.json @@ -24,7 +24,7 @@ ], "PreToolUse": [ { - "matcher": "Edit|Write|Read|Glob|Grep", + "matcher": "Edit|Write|Read|Glob|Grep|apply_patch|exec_command|shell_command|Bash", "hooks": [ { "type": "command", diff --git a/plugin/scripts/pre-tool-use.mjs b/plugin/scripts/pre-tool-use.mjs index d70c166ed..e8d02ec2c 100755 --- a/plugin/scripts/pre-tool-use.mjs +++ b/plugin/scripts/pre-tool-use.mjs @@ -13,6 +13,113 @@ function authHeaders() { if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; return h; } +function unique(values) { + return [...new Set(values.filter((value) => value.trim().length > 0))]; +} +function extractCodexPatchFiles(patch) { + const files = []; + for (const line of patch.split("\n")) { + const match = line.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/); + if (match) files.push(match[1].trim()); + } + return unique(files); +} +function extractCodexCommandTarget(command) { + const trimmed = command.trim(); + if (!trimmed || /[;&|`$<>]/.test(trimmed)) return void 0; + const tokens = (trimmed.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []).map((part) => part.replace(/^["']|["']$/g, "")); + const commandName = tokens[0]?.split("/").pop(); + if (!commandName) return void 0; + if (commandName === "rg") { + const positional = tokens.slice(1).filter((token) => !token.startsWith("-")); + if (positional.length === 0) return void 0; + const [pattern, ...paths] = positional; + return { + files: unique(paths), + terms: pattern ? [pattern] : [], + toolName: "grep" + }; + } + if ([ + "sed", + "cat", + "head", + "tail", + "nl" + ].includes(commandName)) { + const paths = tokens.slice(1).filter((token) => !token.startsWith("-") && /[./]/.test(token)); + if (paths.length === 0) return void 0; + return { + files: unique(paths), + terms: [], + toolName: "read" + }; + } + if (["ls", "find"].includes(commandName)) { + const paths = tokens.slice(1).filter((token) => !token.startsWith("-")); + if (paths.length === 0) return void 0; + return { + files: unique(paths), + terms: [], + toolName: "glob" + }; + } +} +function enrichTargetForTool(toolName, toolInput) { + const normalizedToolName = toolName.toLowerCase(); + if ([ + "edit", + "write", + "create", + "read", + "view", + "glob", + "grep" + ].includes(normalizedToolName)) { + const files = []; + const fileKeys = normalizedToolName === "grep" ? ["path", "file"] : [ + "file_path", + "path", + "file", + "pattern" + ]; + for (const key of fileKeys) { + const val = toolInput[key]; + if (typeof val === "string" && val.length > 0) files.push(val); + } + if (files.length === 0) return void 0; + const terms = []; + if (normalizedToolName === "grep" || normalizedToolName === "glob") { + const pattern = toolInput["pattern"]; + if (typeof pattern === "string" && pattern.length > 0) terms.push(pattern); + } + return { + files: unique(files), + terms, + toolName + }; + } + if (normalizedToolName === "apply_patch") { + const patch = toolInput["patch"] ?? toolInput["input"] ?? toolInput["command"]; + if (typeof patch !== "string") return void 0; + const files = extractCodexPatchFiles(patch); + if (files.length === 0) return void 0; + return { + files, + terms: [], + toolName: "edit" + }; + } + if ([ + "exec_command", + "shell_command", + "bash" + ].includes(normalizedToolName)) { + const command = toolInput["cmd"] ?? toolInput["command"]; + if (typeof command !== "string") return void 0; + return extractCodexCommandTarget(command); + } +} async function main() { if (!INJECT_CONTEXT) return; let input = ""; @@ -26,35 +133,9 @@ async function main() { if (isSdkChildContext(data)) return; const toolName = typeof data.tool_name === "string" ? data.tool_name : typeof data.toolName === "string" ? data.toolName : void 0; if (!toolName) return; - const normalizedToolName = toolName.toLowerCase(); - if (![ - "edit", - "write", - "create", - "read", - "view", - "glob", - "grep" - ].includes(normalizedToolName)) return; const rawToolInput = data.tool_input ?? data.toolArgs; - const toolInput = typeof rawToolInput === "object" && rawToolInput !== null && !Array.isArray(rawToolInput) ? rawToolInput : {}; - const files = []; - const fileKeys = normalizedToolName === "grep" ? ["path", "file"] : [ - "file_path", - "path", - "file", - "pattern" - ]; - for (const key of fileKeys) { - const val = toolInput[key]; - if (typeof val === "string" && val.length > 0) files.push(val); - } - if (files.length === 0) return; - const terms = []; - if (normalizedToolName === "grep" || normalizedToolName === "glob") { - const pattern = toolInput["pattern"]; - if (typeof pattern === "string" && pattern.length > 0) terms.push(pattern); - } + const target = enrichTargetForTool(toolName, typeof rawToolInput === "object" && rawToolInput !== null && !Array.isArray(rawToolInput) ? rawToolInput : {}); + if (!target) return; const rawSessionId = data.session_id || data.sessionId; 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; @@ -64,9 +145,9 @@ async function main() { headers: authHeaders(), body: JSON.stringify({ sessionId, - files, - terms, - toolName, + files: target.files, + terms: target.terms, + toolName: target.toolName, ...project !== void 0 && { project } }), signal: AbortSignal.timeout(2e3) @@ -78,7 +159,7 @@ async function main() { } catch {} } main(); - //#endregion -export { }; +export {}; + //# sourceMappingURL=pre-tool-use.mjs.map \ No newline at end of file diff --git a/src/hooks/pre-tool-use.ts b/src/hooks/pre-tool-use.ts index 277d7799b..931c82099 100644 --- a/src/hooks/pre-tool-use.ts +++ b/src/hooks/pre-tool-use.ts @@ -31,6 +31,109 @@ function authHeaders(): Record { return h; } +type EnrichTarget = { + files: string[]; + terms: string[]; + toolName: string; +}; + +function unique(values: string[]): string[] { + return [...new Set(values.filter((value) => value.trim().length > 0))]; +} + +function extractCodexPatchFiles(patch: string): string[] { + const files: string[] = []; + for (const line of patch.split("\n")) { + const match = line.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/); + if (match) files.push(match[1].trim()); + } + return unique(files); +} + +function extractCodexCommandTarget(command: string): EnrichTarget | undefined { + const trimmed = command.trim(); + if (!trimmed || /[;&|`$<>]/.test(trimmed)) return undefined; + + const parts = trimmed.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []; + const tokens = parts.map((part) => part.replace(/^["']|["']$/g, "")); + const commandName = tokens[0]?.split("/").pop(); + if (!commandName) return undefined; + + if (commandName === "rg") { + const positional = tokens.slice(1).filter((token) => !token.startsWith("-")); + if (positional.length === 0) return undefined; + const [pattern, ...paths] = positional; + return { + files: unique(paths), + terms: pattern ? [pattern] : [], + toolName: "grep", + }; + } + + if (["sed", "cat", "head", "tail", "nl"].includes(commandName)) { + const paths = tokens + .slice(1) + .filter((token) => !token.startsWith("-") && /[./]/.test(token)); + if (paths.length === 0) return undefined; + return { files: unique(paths), terms: [], toolName: "read" }; + } + + if (["ls", "find"].includes(commandName)) { + const paths = tokens.slice(1).filter((token) => !token.startsWith("-")); + if (paths.length === 0) return undefined; + return { files: unique(paths), terms: [], toolName: "glob" }; + } + + return undefined; +} + +function enrichTargetForTool( + toolName: string, + toolInput: Record, +): EnrichTarget | undefined { + const normalizedToolName = toolName.toLowerCase(); + const fileTools = ["edit", "write", "create", "read", "view", "glob", "grep"]; + + if (fileTools.includes(normalizedToolName)) { + const files: string[] = []; + const fileKeys = + normalizedToolName === "grep" + ? ["path", "file"] + : ["file_path", "path", "file", "pattern"]; + for (const key of fileKeys) { + const val = toolInput[key]; + if (typeof val === "string" && val.length > 0) files.push(val); + } + if (files.length === 0) return undefined; + + const terms: string[] = []; + if (normalizedToolName === "grep" || normalizedToolName === "glob") { + const pattern = toolInput["pattern"]; + if (typeof pattern === "string" && pattern.length > 0) { + terms.push(pattern); + } + } + + return { files: unique(files), terms, toolName }; + } + + if (normalizedToolName === "apply_patch") { + const patch = toolInput["patch"] ?? toolInput["input"] ?? toolInput["command"]; + if (typeof patch !== "string") return undefined; + const files = extractCodexPatchFiles(patch); + if (files.length === 0) return undefined; + return { files, terms: [], toolName: "edit" }; + } + + if (["exec_command", "shell_command", "bash"].includes(normalizedToolName)) { + const command = toolInput["cmd"] ?? toolInput["command"]; + if (typeof command !== "string") return undefined; + return extractCodexCommandTarget(command); + } + + return undefined; +} + async function main() { // Default off: exit immediately so we don't even open stdin. This keeps // Claude Code's tool-call hot path as cheap as possible. @@ -58,10 +161,6 @@ async function main() { : undefined; if (!toolName) return; - const normalizedToolName = toolName.toLowerCase(); - const fileTools = ["edit", "write", "create", "read", "view", "glob", "grep"]; - if (!fileTools.includes(normalizedToolName)) return; - const rawToolInput = data.tool_input ?? data.toolArgs; const toolInput = typeof rawToolInput === "object" && @@ -69,24 +168,9 @@ async function main() { !Array.isArray(rawToolInput) ? (rawToolInput as Record) : {}; - const files: string[] = []; - const fileKeys = - normalizedToolName === "grep" - ? ["path", "file"] - : ["file_path", "path", "file", "pattern"]; - for (const key of fileKeys) { - const val = toolInput[key]; - if (typeof val === "string" && val.length > 0) files.push(val); - } - if (files.length === 0) return; - const terms: string[] = []; - if (normalizedToolName === "grep" || normalizedToolName === "glob") { - const pattern = toolInput["pattern"]; - if (typeof pattern === "string" && pattern.length > 0) { - terms.push(pattern); - } - } + const target = enrichTargetForTool(toolName, toolInput); + if (!target) return; const rawSessionId = data.session_id || data.sessionId; const sessionId = @@ -104,9 +188,9 @@ async function main() { headers: authHeaders(), body: JSON.stringify({ sessionId, - files, - terms, - toolName, + files: target.files, + terms: target.terms, + toolName: target.toolName, ...(project !== undefined && { project }), }), signal: AbortSignal.timeout(2000), diff --git a/test/codex-connect-hooks.test.ts b/test/codex-connect-hooks.test.ts index 75accbee7..9e5c5f866 100644 --- a/test/codex-connect-hooks.test.ts +++ b/test/codex-connect-hooks.test.ts @@ -35,7 +35,9 @@ describe("buildMergedHooks", () => { const preToolUse = merged.hooks["PreToolUse"]; expect(preToolUse).toBeDefined(); expect(preToolUse!.length).toBeGreaterThan(0); - expect(preToolUse![0].matcher).toBe("Edit|Write|Read|Glob|Grep"); + expect(preToolUse![0].matcher).toBe( + "Edit|Write|Read|Glob|Grep|apply_patch|exec_command|shell_command|Bash", + ); }); it("includes all six expected lifecycle events", () => { diff --git a/test/codex-plugin.test.ts b/test/codex-plugin.test.ts index 6b7111977..7959b226a 100644 --- a/test/codex-plugin.test.ts +++ b/test/codex-plugin.test.ts @@ -10,7 +10,7 @@ function readJson(path: string): T { } type HookHandler = { type: string; command: string }; -type HookEntry = { hooks: HookHandler[] }; +type HookEntry = { matcher?: string; hooks: HookHandler[] }; function hookCommands(path: string): string[] { const manifest = readJson<{ hooks: Record }>(path); @@ -117,6 +117,16 @@ describe("Codex plugin manifest (developers.openai.com/codex/plugins)", () => { expect(events).toContain("Stop"); }); + it("routes Codex-native file tool names to PreToolUse", () => { + const hooks = readJson<{ hooks: Record }>( + join(pluginRoot, "hooks/hooks.codex.json"), + ); + const matcher = hooks.hooks.PreToolUse?.[0]?.matcher; + expect(matcher).toBeDefined(); + expect(matcher).toContain("apply_patch"); + expect(matcher).toContain("exec_command"); + }); + it("hook command scripts referenced in hooks.codex.json exist on disk", () => { const hooks = readJson<{ hooks: Record }>( join(pluginRoot, "hooks/hooks.codex.json"), diff --git a/test/context-injection.test.ts b/test/context-injection.test.ts index d76896388..e33d5552e 100644 --- a/test/context-injection.test.ts +++ b/test/context-injection.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { spawn } from "node:child_process"; +import { createServer } from "node:http"; import { join } from "node:path"; const HOOKS_DIR = join(import.meta.dirname, "..", "plugin", "scripts"); @@ -54,6 +55,49 @@ function runHook( }); } +async function runHookWithServer( + scriptName: string, + stdin: string, + env: Record, +): Promise<{ + stdout: string; + exitCode: number | null; + requests: Array<{ path: string; body: Record }>; +}> { + const requests: Array<{ path: string; body: Record }> = []; + const server = createServer((req, res) => { + let raw = ""; + req.on("data", (chunk) => { + raw += chunk; + }); + req.on("end", () => { + requests.push({ + path: req.url ?? "", + body: raw ? (JSON.parse(raw) as Record) : {}, + }); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ context: "remembered context" })); + }); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("test server did not bind to a TCP port"); + } + + try { + const result = await runHook(scriptName, stdin, { + ...env, + AGENTMEMORY_URL: `http://127.0.0.1:${address.port}`, + }); + return { stdout: result.stdout, exitCode: result.exitCode, requests }; + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +} + describe("pre-tool-use hook — context injection gate (#143)", () => { it("writes nothing to stdout when AGENTMEMORY_INJECT_CONTEXT is unset (default)", async () => { const payload = JSON.stringify({ @@ -108,6 +152,98 @@ describe("pre-tool-use hook — context injection gate (#143)", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toBe(""); }); + + it("enriches Codex apply_patch events by parsing patch file headers", async () => { + const payload = JSON.stringify({ + session_id: "ses_codex", + tool_name: "apply_patch", + tool_input: { + command: [ + "*** Begin Patch", + "*** Update File: src/hooks/pre-tool-use.ts", + "@@", + "-old", + "+new", + "*** Add File: test/codex-hook.test.ts", + "+it('works', () => {})", + "*** End Patch", + ].join("\n"), + }, + project: "agentmemory", + }); + + const result = await runHookWithServer("pre-tool-use.mjs", payload, { + AGENTMEMORY_INJECT_CONTEXT: "true", + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("remembered context"); + expect(result.requests).toHaveLength(1); + expect(result.requests[0]).toMatchObject({ + path: "/agentmemory/enrich", + body: { + sessionId: "ses_codex", + files: ["src/hooks/pre-tool-use.ts", "test/codex-hook.test.ts"], + terms: [], + toolName: "edit", + project: "agentmemory", + }, + }); + }); + + it("ignores Codex apply_patch events without concrete patch file headers", async () => { + const payload = JSON.stringify({ + session_id: "ses_codex", + tool_name: "apply_patch", + tool_input: { patch: "*** Begin Patch\n*** End Patch" }, + }); + + const result = await runHook("pre-tool-use.mjs", payload, { + AGENTMEMORY_INJECT_CONTEXT: "true", + AGENTMEMORY_URL: "http://127.0.0.1:1", + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(""); + }); + + it("enriches conservative Codex exec_command grep events", async () => { + const payload = JSON.stringify({ + session_id: "ses_codex", + tool_name: "exec_command", + tool_input: { cmd: "rg pre-tool-use src test" }, + }); + + const result = await runHookWithServer("pre-tool-use.mjs", payload, { + AGENTMEMORY_INJECT_CONTEXT: "true", + }); + + expect(result.stdout).toBe("remembered context"); + expect(result.requests[0]).toMatchObject({ + path: "/agentmemory/enrich", + body: { + sessionId: "ses_codex", + files: ["src", "test"], + terms: ["pre-tool-use"], + toolName: "grep", + }, + }); + }); + + it("does not parse compound Codex shell commands", async () => { + const payload = JSON.stringify({ + session_id: "ses_codex", + tool_name: "exec_command", + tool_input: { cmd: "sed -n '1,40p' src/hooks/pre-tool-use.ts | cat" }, + }); + + const result = await runHookWithServer("pre-tool-use.mjs", payload, { + AGENTMEMORY_INJECT_CONTEXT: "true", + }); + + expect(result.stdout).toBe(""); + expect(result.requests).toHaveLength(0); + }); }); describe("session-start hook — context injection gate (#143)", () => {