From 9abb9fb43a22f0854501702861710020c3f9ebc8 Mon Sep 17 00:00:00 2001 From: raj-khan Date: Sat, 24 Oct 2026 13:52:22 +0800 Subject: [PATCH] Add Codex CLI support init wires PreToolUse, PostToolUse and SessionStart in .codex/hooks.json and prints the /hooks trust step Codex needs. Argv-array commands are inspected as one line, and hooks = false in .codex/config.toml trips tamper. --- backlog/tasks/task-4 - Support-Codex-CLI.md | 11 +-- src/agents/claude.ts | 1 + src/agents/codex.ts | 50 ++++++++++++ src/agents/index.ts | 5 +- src/agents/types.ts | 4 + src/commands/init.ts | 8 +- src/commands/status.ts | 1 + src/hook.ts | 10 ++- src/index.ts | 2 +- test/codex.test.js | 85 +++++++++++++++++++++ 10 files changed, 166 insertions(+), 11 deletions(-) create mode 100644 src/agents/codex.ts create mode 100644 test/codex.test.js diff --git a/backlog/tasks/task-4 - Support-Codex-CLI.md b/backlog/tasks/task-4 - Support-Codex-CLI.md index 476062f..50cf968 100644 --- a/backlog/tasks/task-4 - Support-Codex-CLI.md +++ b/backlog/tasks/task-4 - Support-Codex-CLI.md @@ -1,9 +1,10 @@ --- id: TASK-4 title: Support Codex CLI -status: To Do +status: Done assignee: [] created_date: '2026-10-24 05:41' +updated_date: '2026-10-24 05:52' labels: - multi-agent dependencies: @@ -22,8 +23,8 @@ Register PreToolUse, PostToolUse and SessionStart in .codex/hooks.json (same sha ## Acceptance Criteria -- [ ] #1 A decoy read through Codex PreToolUse is denied and recorded -- [ ] #2 An array command is inspected like a string -- [ ] #3 init prints the trust step -- [ ] #4 Disabling hooks in .codex/config.toml trips tamper +- [x] #1 A decoy read through Codex PreToolUse is denied and recorded +- [x] #2 An array command is inspected like a string +- [x] #3 init prints the trust step +- [x] #4 Disabling hooks in .codex/config.toml trips tamper diff --git a/src/agents/claude.ts b/src/agents/claude.ts index 9f99e14..d2eee1c 100644 --- a/src/agents/claude.ts +++ b/src/agents/claude.ts @@ -15,6 +15,7 @@ export const claude: Agent = { label: "Claude Code", configPath: SETTINGS_PATH, alsoProtect: [".claude/settings.local.json"], + disables: /disableAllHooks[\\"']*\s*:\s*true/, hookCommand: HOOK_COMMAND, sessionCommand: SESSION_COMMAND, // Claude Code is always wired by default, whether or not .claude/ exists yet. diff --git a/src/agents/codex.ts b/src/agents/codex.ts new file mode 100644 index 0000000..4014f49 --- /dev/null +++ b/src/agents/codex.ts @@ -0,0 +1,50 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { registeredSpecs, registerSpecs, unregisterSpecs, type HookSpec } from "../settings.js"; +import { claude } from "./claude.js"; +import type { Agent } from "./types.js"; + +const CONFIG = ".codex/hooks.json"; +const HOOK = "canaryhook hook --agent codex"; +const SESSION = "canaryhook session-start --agent codex"; + +/** Codex reads Claude Code's hooks.json shape; a group without a matcher covers every tool. */ +const SPECS: HookSpec[] = [ + { event: "PreToolUse", command: HOOK }, + { event: "PostToolUse", command: HOOK }, + { event: "SessionStart", command: SESSION }, +]; + +/** Codex may hand a shell command over as an argv array; inspect it as one line. */ +function flatten(input: unknown): unknown { + if (!input || typeof input !== "object") return input; + const command = (input as Record).command; + return Array.isArray(command) ? { ...input, command: command.map(String).join(" ") } : input; +} + +/** Codex's payloads and answers follow Claude Code's, so only the wiring differs. */ +export const codex: Agent = { + ...claude, + name: "codex", + label: "Codex CLI", + configPath: CONFIG, + alsoProtect: [".codex/config.toml"], + disables: /\b(?:codex_)?hooks\s*=\s*false/, + setupNote: + "Codex runs project hooks only once you trust them: run /hooks in Codex and approve canaryhook's.", + hookCommand: HOOK, + sessionCommand: SESSION, + detect: (root) => existsSync(join(root, ".codex")), + + wiring(root) { + const [pre, post, session] = registeredSpecs(root, CONFIG, SPECS); + return { pre, post, session }; + }, + register: (root) => registerSpecs(root, CONFIG, SPECS), + unregister: (root) => (existsSync(join(root, CONFIG)) ? unregisterSpecs(root, CONFIG, SPECS) : false), + + parse(payload) { + const call = claude.parse(payload); + return call && { ...call, input: flatten(call.input) }; + }, +}; diff --git a/src/agents/index.ts b/src/agents/index.ts index 3f196a0..44c9281 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -1,4 +1,5 @@ import { claude } from "./claude.js"; +import { codex } from "./codex.js"; import { cursor } from "./cursor.js"; import type { State } from "../state.js"; import type { Agent } from "./types.js"; @@ -6,7 +7,7 @@ import type { Agent } from "./types.js"; export type { Agent, ToolCall, Wiring } from "./types.js"; /** Every agent canaryhook can wire, in the order they are reported. */ -export const AGENTS: Agent[] = [claude, cursor]; +export const AGENTS: Agent[] = [claude, cursor, codex]; export function agentNamed(name: string): Agent | undefined { return AGENTS.find((agent) => agent.name === name); @@ -28,4 +29,4 @@ export function wiredAgents(state: State): Agent[] { return (state.agents ?? [claude.name]).flatMap((name) => agentNamed(name) ?? []); } -export { claude, cursor }; +export { claude, codex, cursor }; diff --git a/src/agents/types.ts b/src/agents/types.ts index db3645a..73999b6 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -32,6 +32,10 @@ export interface Agent { configPath: string; /** Other config files that can switch the hooks off without holding them. */ alsoProtect?: string[]; + /** A config setting that turns every hook off, canaryhook's included. */ + disables?: RegExp; + /** A step the user has to take before the hooks run, printed by init and status. */ + setupNote?: string; hookCommand: string; sessionCommand: string; /** Whether the project already uses this agent, so init wires it by default. */ diff --git a/src/commands/init.ts b/src/commands/init.ts index ab2d8b6..623c461 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -100,7 +100,12 @@ export function init( planted: decoys.map((d) => d.path), hookRegistered: added.some(Boolean), settings: claude.configPath, - agents: agents.map((agent, i) => ({ name: agent.name, config: agent.configPath, registered: added[i] })), + agents: agents.map((agent, i) => ({ + name: agent.name, + config: agent.configPath, + registered: added[i], + setupNote: agent.setupNote ?? null, + })), gitignoreUpdated: ignored, })}\n`, ); @@ -116,6 +121,7 @@ export function init( ? `Registered the hooks for ${agent.label} in ${agent.configPath}.\n` : `The hooks for ${agent.label} were already registered in ${agent.configPath}.\n`, ); + if (agent.setupNote) process.stdout.write(` ${agent.setupNote}\n`); }); if (ignored) process.stdout.write("Added the decoys and .canaryhook/ to .gitignore.\n"); if (options.commit) process.stdout.write("Left .gitignore alone, as --commit was passed.\n"); diff --git a/src/commands/status.ts b/src/commands/status.ts index 71d8622..e29190b 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -65,6 +65,7 @@ export function status(root: string, options: { json: boolean }): number { process.stdout.write(` Hook ${mark(a.pre)}\n`); process.stdout.write(` Session hook ${mark(a.session)}\n`); process.stdout.write(` Output hook ${mark(a.post)}\n`); + if (a.agent.setupNote) process.stdout.write(` Note ${a.agent.setupNote}\n`); } process.stdout.write("Decoys\n"); for (const decoy of decoys) { diff --git a/src/hook.ts b/src/hook.ts index a3237d4..89cbfa5 100644 --- a/src/hook.ts +++ b/src/hook.ts @@ -183,7 +183,7 @@ const WRITE_FIELDS = ["content", "new_string", "edits", "new_source"]; const mentionsState = (text: string) => mentionsPath(text, STATE_DIR); const mentionsSettings = (text: string) => SETTINGS_FILES.some((file) => mentionsPath(text, file)); -const disablesHooks = (text: string) => /disableAllHooks[\\"']*\s*:\s*true/.test(text); +const disablesHooks = (text: string) => AGENTS.some((agent) => agent.disables?.test(text)); /** * A call that would blind canaryhook: erase or rewrite its record, unregister @@ -202,7 +202,13 @@ export function tampers(toolInput: unknown): string | null { | string | undefined; if (path === undefined || !WRITE_FIELDS.some((field) => field in fields)) return null; - const written = JSON.stringify(WRITE_FIELDS.map((field) => fields[field] ?? null)); + // Raw text, not JSON: escaping would hide a setting at the start of a line. + const written = WRITE_FIELDS.flatMap((field) => { + const value = fields[field]; + if (typeof value === "string") return [value]; + if (Array.isArray(value)) return value.map((edit) => String((edit as Record)?.new_string ?? "")); + return []; + }).join("\n"); if (mentionsState(path)) return `tool call writes to ${STATE_DIR}/, canaryhook's record`; if (!mentionsSettings(path)) return null; diff --git a/src/index.ts b/src/index.ts index ab6cf32..29e0fe1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,7 +28,7 @@ Commands Options --json Machine-readable output - --agents init: agents to wire, comma-separated (claude, cursor); default is + --agents init: agents to wire, comma-separated (claude, cursor, codex); default is Claude Code plus any agent the project already uses --commit init: track the decoys in git instead of ignoring them --force init: replant over an existing setup diff --git a/test/codex.test.js b/test/codex.test.js new file mode 100644 index 0000000..11d2b42 --- /dev/null +++ b/test/codex.test.js @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { inspect } from "../dist/hook.js"; + +const CLI = fileURLToPath(new URL("../dist/index.js", import.meta.url)); + +function run(args, cwd, input) { + return spawnSync(process.execPath, [CLI, ...args], { cwd, input, encoding: "utf8" }); +} + +function project() { + const root = realpathSync(mkdtempSync(join(tmpdir(), "canaryhook-"))); + assert.equal(run(["init", "--agents", "codex"], root).status, 0); + return root; +} + +const config = (root) => JSON.parse(readFileSync(join(root, ".codex", "hooks.json"), "utf8")); + +function codexCall(root, event, fields) { + return JSON.stringify({ session_id: "s1", hook_event_name: event, cwd: root, model: "gpt", ...fields }); +} + +test("init registers the Codex hooks in .codex/hooks.json and prints the trust step", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "canaryhook-"))); + const result = run(["init", "--agents", "codex"], root); + assert.match(result.stdout, /run \/hooks in Codex/); + const { hooks } = config(root); + assert.equal(hooks.PreToolUse[0].hooks[0].command, "canaryhook hook --agent codex"); + assert.equal("matcher" in hooks.PreToolUse[0], false); + assert.equal(hooks.PostToolUse[0].hooks[0].command, "canaryhook hook --agent codex"); + assert.equal(hooks.SessionStart[0].hooks[0].command, "canaryhook session-start --agent codex"); + assert.equal(existsSync(join(root, ".claude", "settings.json")), false); +}); + +test("init wires Codex by default when the project has .codex/", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "canaryhook-"))); + mkdirSync(join(root, ".codex")); + run(["init"], root); + assert.equal(config(root).hooks.PreToolUse.length, 1); +}); + +test("denies a decoy read through Codex PreToolUse and records it", () => { + const root = project(); + const out = JSON.parse(run(["hook", "--agent", "codex"], root, codexCall(root, "PreToolUse", { tool_name: "Bash", tool_input: { command: "cat .env.backup" } })).stdout); + assert.equal(out.hookSpecificOutput.permissionDecision, "deny"); + assert.equal(run(["verify"], root).status, 1); +}); + +test("inspects an argv-array command like a string", () => { + const root = project(); + const call = codexCall(root, "PreToolUse", { tool_name: "Bash", tool_input: { command: ["bash", "-lc", "cat .env*"] } }); + assert.equal(JSON.parse(run(["hook", "--agent", "codex"], root, call).stdout).hookSpecificOutput.permissionDecision, "deny"); + const clean = codexCall(root, "PreToolUse", { tool_name: "Bash", tool_input: { command: ["ls", "src"] } }); + assert.equal(run(["hook", "--agent", "codex"], root, clean).stdout, ""); +}); + +test("PostToolUse blocks a result holding a canary", () => { + const root = project(); + const { decoys } = JSON.parse(readFileSync(join(root, ".canaryhook", "canaries.json"), "utf8")); + const call = codexCall(root, "PostToolUse", { tool_name: "Bash", tool_input: { command: "grep -r AKIA ." }, tool_response: `x ${decoys[0].token}` }); + assert.equal(JSON.parse(run(["hook", "--agent", "codex"], root, call).stdout).decision, "block"); +}); + +test("disabling hooks in .codex/config.toml trips tamper", () => { + for (const toolInput of [ + { file_path: ".codex/config.toml", content: "[features]\nhooks = false\n" }, + { command: `printf '[features]\\nhooks = false\\n' >> .codex/config.toml` }, + { command: "sed -i 's/hooks = true/hooks = false/' .codex/config.toml" }, + { file_path: ".codex/hooks.json", content: "{}" }, + ]) { + assert.equal(inspect([], toolInput)?.reason, "tamper", JSON.stringify(toolInput)); + } + assert.equal(inspect([], { file_path: ".codex/config.toml", content: 'model = "gpt-5"\n' }), null); +}); + +test("clean removes the Codex hooks", () => { + const root = project(); + run(["clean"], root); + assert.deepEqual(config(root), {}); +});