Skip to content
Open
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
13 changes: 6 additions & 7 deletions .codex/hooks.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
{
"version": 1,
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "bun bin/failproofai.mjs --hook SessionStart --cli codex",
"timeout": 60000,
"timeout": 60,
"__failproofai_hook__": true
}
]
Expand All @@ -19,7 +18,7 @@
{
"type": "command",
"command": "bun bin/failproofai.mjs --hook PreToolUse --cli codex",
"timeout": 60000,
"timeout": 60,
"__failproofai_hook__": true
}
]
Expand All @@ -31,7 +30,7 @@
{
"type": "command",
"command": "bun bin/failproofai.mjs --hook PermissionRequest --cli codex",
"timeout": 60000,
"timeout": 60,
"__failproofai_hook__": true
}
]
Expand All @@ -43,7 +42,7 @@
{
"type": "command",
"command": "bun bin/failproofai.mjs --hook PostToolUse --cli codex",
"timeout": 60000,
"timeout": 60,
"__failproofai_hook__": true
}
]
Expand All @@ -55,7 +54,7 @@
{
"type": "command",
"command": "bun bin/failproofai.mjs --hook UserPromptSubmit --cli codex",
"timeout": 60000,
"timeout": 60,
"__failproofai_hook__": true
}
]
Expand All @@ -67,7 +66,7 @@
{
"type": "command",
"command": "bun bin/failproofai.mjs --hook Stop --cli codex",
"timeout": 60000,
"timeout": 60,
"__failproofai_hook__": true
}
]
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## 0.0.12-beta.0 — 2026-07-03

### Fixes
- Stop writing an invalid top-level `version` field into OpenAI Codex `hooks.json`. Codex's `HooksFile` config struct is `#[serde(deny_unknown_fields)]`, so the `version: 1` marker made Codex v0.142+ refuse to start every session with ``unknown field `version`, expected `hooks` ``. failproofai now writes only `hooks`, strips any leftover `version` on the next install/uninstall so previously-broken configs self-heal, and corrects the hook `timeout` from `60000` to `60` — Codex reads `timeout` in seconds (`timeout_sec`), so the old value meant ~16.7h instead of 60s (#473).

## 0.0.11 — 2026-06-26

### Breaking
Expand Down
15 changes: 15 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@ cwd (where `codex` was launched), so we use a relative `bun bin/failproofai.mjs`
path. If Codex ever changes that behavior and the hook fails to find the binary,
switch to an absolute path.

**Schema (verified against Codex CLI v0.142.5 / `codex-rs/config/src/hook_config.rs`):**
Codex's top-level `HooksFile` struct is `#[serde(deny_unknown_fields)]` and allows
**only** `hooks` (plus an optional `description`), so the config must **not** carry a
top-level `version` field — an earlier build wrote `version: 1` (an unverified
assumption), which made Codex v0.142+ refuse to start every session with
``unknown field `version`, expected `hooks` ``. The `codex` integration now writes
only `hooks` and strips any leftover `version` on the next install/uninstall so a
previously-broken config self-heals. Two more schema notes: `timeout` is in **seconds**
(Codex's `timeout_sec`, default 600), not the milliseconds Claude/Cursor use — hook
entries use `timeout: 60`. And the `__failproofai_hook__` marker is safe to keep:
Codex's `HookHandlerConfig::Command` is an internally-tagged enum (`#[serde(tag =
"type")]`) with **no** `deny_unknown_fields`, so the extra key is tolerated, not
rejected. **Do not** add a `version` field to `.codex/hooks.json` (unlike Copilot and
Cursor, whose own schemas legitimately require one).

For production users (outside this repo), the recommended Codex install is:
```bash
failproofai policies --install --cli codex --scope project
Expand Down
16 changes: 14 additions & 2 deletions __tests__/e2e/hooks/codex-integration.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,15 @@ describe("E2E: Codex integration — install/uninstall", () => {
const hooksPath = resolve(env.cwd, ".codex", "hooks.json");
expect(existsSync(hooksPath)).toBe(true);
const settings = JSON.parse(readFileSync(hooksPath, "utf-8")) as Record<string, unknown>;
expect(settings.version).toBe(1);
const hooks = settings.hooks as Record<string, unknown[]>;
// Codex's HooksFile is #[serde(deny_unknown_fields)] — the file must NOT
// carry a top-level `version` (it would make Codex refuse to start with
// "unknown field `version`, expected `hooks`"). Fresh install → only `hooks`.
expect(settings.version).toBeUndefined();
expect(Object.keys(settings)).toEqual(["hooks"]);
const hooks = settings.hooks as Record<
string,
Array<{ hooks: Array<Record<string, unknown>> }>
>;
// Codex stores under PascalCase keys
expect(hooks.PreToolUse).toBeDefined();
expect(hooks.PostToolUse).toBeDefined();
Expand All @@ -188,6 +195,11 @@ describe("E2E: Codex integration — install/uninstall", () => {
expect(hooks.UserPromptSubmit).toBeDefined();
// Snake-case keys should not be present
expect(hooks.pre_tool_use).toBeUndefined();
// Each entry matches Codex's HookHandlerConfig::Command shape: type
// "command" + `timeout` in SECONDS (60, not 60_000 ms).
const entry = hooks.PreToolUse[0].hooks[0];
expect(entry.type).toBe("command");
expect(entry.timeout).toBe(60);
} finally {
env.cleanup();
}
Expand Down
36 changes: 32 additions & 4 deletions __tests__/hooks/integrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@ describe("OpenAI Codex integration", () => {
expect(entry.command).toContain("--cli codex");
expect(entry.command).toContain("--hook pre_tool_use");
expect(entry[FAILPROOFAI_HOOK_MARKER]).toBe(true);
// Codex reads `timeout` in SECONDS (its `timeout_sec` field), not the
// milliseconds Claude uses — 60_000 would be ~16.7h.
expect(entry.timeout).toBe(60);
});

it("project scope uses npx -y failproofai", () => {
Expand All @@ -220,16 +223,41 @@ describe("OpenAI Codex integration", () => {
// Snake-case keys must NOT be present (Codex stores under Pascal)
expect(hooks[snake]).toBeUndefined();
}
// Settings file carries version: 1
expect(settings.version).toBe(1);
// Codex's HooksFile is #[serde(deny_unknown_fields)] — a top-level `version`
// makes Codex refuse to start, so we must NOT write one.
expect(settings.version).toBeUndefined();
});

it("readSettings backfills version: 1 on existing files without it", () => {
it("readSettings does not inject a top-level version", () => {
const settingsPath = resolve(tempDir, ".codex", "hooks.json");
mkdirSync(resolve(tempDir, ".codex"), { recursive: true });
writeFileSync(settingsPath, JSON.stringify({ hooks: {} }));
const read = codex.readSettings(settingsPath);
expect(read.version).toBe(1);
expect(read.version).toBeUndefined();
});

it("writeHookEntries strips a legacy top-level version (migration)", () => {
// A hooks.json written by an older failproofai build carried version: 1,
// which Codex v0.142+ rejects. Re-installing must remove it, not just stop
// adding it, so a previously-broken file self-heals.
const settings: Record<string, unknown> = { version: 1, hooks: {} };
codex.writeHookEntries(settings, "/usr/bin/failproofai", "user");
expect(settings.version).toBeUndefined();
expect(settings.hooks).toBeDefined();
});

it("removeHooksFromFile strips a legacy top-level version (migration)", () => {
const settingsPath = resolve(tempDir, ".codex", "hooks.json");
mkdirSync(resolve(tempDir, ".codex"), { recursive: true });
const settings: Record<string, unknown> = {};
codex.writeHookEntries(settings, "/usr/bin/failproofai", "project");
(settings as { version?: number }).version = 1; // simulate old build's field
codex.writeSettings(settingsPath, settings);

codex.removeHooksFromFile(settingsPath);
const after = JSON.parse(readFileSync(settingsPath, "utf-8")) as Record<string, unknown>;
expect(after.version).toBeUndefined();
expect(after.hooks).toBeUndefined();
});

it("re-running writeHookEntries is idempotent", () => {
Expand Down
37 changes: 26 additions & 11 deletions src/hooks/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,16 +226,20 @@ export const claudeCode: Integration = {

// ── OpenAI Codex integration ────────────────────────────────────────────────
//
// Codex's hook protocol is Claude-compatible by design (see the parity matrix
// in plans/great-in-failproofai-i-vectorized-treasure.md). The only material
// Codex's hook protocol is Claude-compatible by design. The only material
// differences are:
// • Settings paths: ~/.codex/hooks.json (user) and <cwd>/.codex/hooks.json (project)
// • Stdin event names arrive snake_case (pre_tool_use); we canonicalize to PascalCase before policy lookup
// • No "local" scope
// • Settings file carries a top-level "version": 1 marker
// • Schema: the top-level object allows ONLY `hooks` (plus an optional
// `description`). Codex's `HooksFile` struct is #[serde(deny_unknown_fields)],
// so a stray `version` key makes Codex refuse to start the session with
// "unknown field `version`, expected `hooks`". We must not write one — and we
// strip any left over by older builds on the next install/uninstall.
// • `timeout` is in SECONDS (Codex's `timeout_sec`, default 600), not the
// milliseconds Claude/Cursor use.

interface CodexSettingsFile {
version?: number;
hooks?: Record<string, ClaudeHookMatcher[]>;
[key: string]: unknown;
}
Expand All @@ -261,9 +265,7 @@ export const codex: Integration = {
},

readSettings(settingsPath) {
const raw = readJsonFile(settingsPath);
if (raw.version === undefined) raw.version = 1;
return raw;
return readJsonFile(settingsPath);
},

writeSettings(settingsPath, settings) {
Expand All @@ -281,7 +283,9 @@ export const codex: Integration = {
return {
type: "command",
command,
timeout: 60_000,
// Codex reads `timeout` in SECONDS (its `timeout_sec` field, default 600),
// not the milliseconds Claude uses. 60_000 would mean ~16.7h, not 60s.
timeout: 60,
[FAILPROOFAI_HOOK_MARKER]: true,
};
},
Expand All @@ -290,7 +294,10 @@ export const codex: Integration = {

writeHookEntries(settings, binaryPath, scope) {
const s = settings as CodexSettingsFile;
if (s.version === undefined) s.version = 1;
// Migration: older builds wrote a top-level `version: 1` that Codex now
// rejects (deny_unknown_fields). Strip it so re-installing self-heals a
// previously broken hooks.json.
delete (s as Record<string, unknown>).version;
if (!s.hooks) s.hooks = {};

for (const eventType of CODEX_HOOK_EVENT_TYPES) {
Expand All @@ -315,7 +322,14 @@ export const codex: Integration = {

removeHooksFromFile(settingsPath) {
const settings = this.readSettings(settingsPath) as CodexSettingsFile;
if (!settings.hooks) return 0;
// Migration: drop any legacy top-level `version` (Codex rejects it) so an
// uninstall also leaves a parseable file behind.
const hadVersion = (settings as Record<string, unknown>).version !== undefined;
delete (settings as Record<string, unknown>).version;
if (!settings.hooks) {
if (hadVersion) this.writeSettings(settingsPath, settings as Record<string, unknown>);
return 0;
}

let removed = 0;
for (const eventType of Object.keys(settings.hooks)) {
Expand Down Expand Up @@ -374,7 +388,8 @@ export const codex: Integration = {
// Hook entries differ from Claude/Codex: each entry uses OS-keyed `bash` and
// `powershell` command fields and a `timeoutSec` (seconds) instead of Claude's
// single `command` field with `timeout` (milliseconds). Top-level wrapper is
// `{ "version": 1, "hooks": {...} }`, mirroring Codex.
// `{ "version": 1, "hooks": {...} }` (Copilot's own VS Code-compatible schema;
// unlike Codex, whose HooksFile rejects a top-level `version`).

interface CopilotHookEntry {
type: "command";
Expand Down
4 changes: 2 additions & 2 deletions src/hooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export const CODEX_TOOL_MAP: Record<string, string> = {
// Settings paths:
// user → ~/.copilot/hooks/failproofai.json
// project → <cwd>/.github/hooks/failproofai.json (also where the cloud agent reads)
// Settings file carries `version: 1` like Codex's hooks.json.
// Settings file carries a top-level `version: 1` (Copilot's own VS Code-compatible schema).

export const COPILOT_HOOK_SCOPES = ["user", "project"] as const;
export type CopilotHookScope = (typeof COPILOT_HOOK_SCOPES)[number];
Expand Down Expand Up @@ -159,7 +159,7 @@ export const COPILOT_TOOL_MAP: Record<string, string> = {
// Settings paths:
// user → ~/.cursor/hooks.json
// project → <cwd>/.cursor/hooks.json
// Settings file carries `version: 1` like Codex/Copilot.
// Settings file carries a top-level `version: 1` (Cursor's own hooks.json schema).

export const CURSOR_HOOK_SCOPES = ["user", "project"] as const;
export type CursorHookScope = (typeof CURSOR_HOOK_SCOPES)[number];
Expand Down