Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions backlog/tasks/task-4 - Support-Codex-CLI.md
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -22,8 +23,8 @@ Register PreToolUse, PostToolUse and SessionStart in .codex/hooks.json (same sha

## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #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
<!-- AC:END -->
1 change: 1 addition & 0 deletions src/agents/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
50 changes: 50 additions & 0 deletions src/agents/codex.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).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) };
},
};
5 changes: 3 additions & 2 deletions src/agents/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
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";

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);
Expand All @@ -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 };
4 changes: 4 additions & 0 deletions src/agents/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
8 changes: 7 additions & 1 deletion src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
);
Expand All @@ -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");
Expand Down
1 change: 1 addition & 0 deletions src/commands/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
10 changes: 8 additions & 2 deletions src/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string, unknown>)?.new_string ?? ""));
return [];
}).join("\n");

if (mentionsState(path)) return `tool call writes to ${STATE_DIR}/, canaryhook's record`;
if (!mentionsSettings(path)) return null;
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Commands

Options
--json Machine-readable output
--agents <list> init: agents to wire, comma-separated (claude, cursor); default is
--agents <list> 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
Expand Down
85 changes: 85 additions & 0 deletions test/codex.test.js
Original file line number Diff line number Diff line change
@@ -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), {});
});
Loading