Skip to content

codex-hooks-valid

Codex hooks files must use Codex's hook events, handler types, and fields

Severity error (auto)
Autofix -
Since v0.20.0
Repo Types codex-marketplace, codex-plugin, codex-project
Category OpenAI Codex

Why

OpenAI Codex runs lifecycle hooks from two files in a project's .codex/ layer — hooks.json and the [hooks] tables of config.toml — and from installed plugins: hooks/hooks.json, a custom path the manifest names, or a payload written inline in .codex-plugin/plugin.json.

The TOML tables carry the same vocabulary as the JSON file: [[hooks.<Event>]] is one {matcher?, hooks: [...]} entry and [[hooks.<Event>.hooks]] one handler, so every check below applies to either file. A Windows override may be spelled commandWindows or command_windows in either — Codex declares the second as an alias of the first, so a handler writing both is a duplicate field and costs the whole document. [hooks.state] is the one table the TOML file has that the JSON one does not: Codex writes per-hook enablement and trust there, ignores a project layer's copy, and it is not an event.

The JSON file's root accepts only description and hooks. Unknown root fields, including $schema, make Codex refuse the file. description must be a string or null. Group and handler metadata have different rules: Codex ignores unknown fields there, so skillsaw reports them as warnings and extra-fields can allow intentional metadata.

An omitted root hooks field defaults to an empty event map; an omitted matcher-group hooks field defaults to an empty handler list. Both are accepted, unlike explicit null or a value of the wrong type. Empty event arrays do not count as a second active source for the both-files advisory.

In JSON, matcher: null means unset. Command handlers also accept null for commandWindows (or command_windows), statusMessage, timeout, and additionalContextLimit; MCP tool handlers accept it for statusMessage and timeout. These are optional fields in Codex's released configuration deserializer. A default does not imply nullability: command async must still be a boolean, and MCP input must still be an object. Null does not make a field valid on another handler type or resolve a Windows alias conflict. MCP input also cannot contain null inside an object or array: Codex converts these arguments to TOML for trust hashing and refuses values TOML cannot represent. Omit unset entries instead; empty objects and arrays are accepted. Unsigned integers from 2^63 through 2^64 - 1 also cannot be represented for trust hashing; encode such identifiers as strings.

A shape defect in config.toml is worse than the same defect in hooks.json. The refusals were measured against codex-cli 0.153.2: a TOML syntax error, an event whose value is not an array of tables, a handler with no type, a handler with a type Codex has no variant for, a command handler with no command, a timeout or additionalContextLimit that is not a non-negative whole number, and both spellings of a Windows override on one handler. Each makes codex exit 1 and start no session in that project, for everyone who clones it. The identical mistake in hooks.json is a warning that costs that one file's hooks and nothing else. Both files are checked against the same vocabulary at the same severities. The messages differ in the noun each syntax uses for a table or an array, and config.toml gets one check hooks.json does not: timeout and additionalContextLimit must be non-negative whole numbers there.

.codex/config.toml is read from every directory between the repository root and the one a session starts in, so a committed services/billing/.codex/config.toml is live configuration for anyone working in that subtree. Every one in the repository is checked. Nothing above the repository root is read, and ~/.codex/config.toml is not repository content.

Project-layer hooks run only once the developer's own config trusts the project (projects."<path>".trust_level = "trusted"). That gate lives on their machine, not in the repository, so the file is checked as it will behave once trusted.

Codex merges the two layers when a directory carries both, prints one startup warning naming both paths, and runs every handler in each. That is a tidiness finding at INFO — a reader editing hooks.json will not see the config.toml copy also firing, and Codex's own advice is to prefer a single representation per layer. A layer that splits its hooks deliberately can say so:

rules:
  codex-hooks-valid:
    allow-both-files: true

Codex adopted Claude Code's nested shape — {hooks: {Event: [{matcher?, hooks: [{type, ...}]}]}} — and kept its own vocabulary. It dispatches twelve lifecycle events, runs command and mcp_tool handlers, and parses prompt and agent handlers without ever running them. A file copied from a Claude plugin therefore loads without complaint and does less than it says, with nothing on the console to explain it.

The commands are a separate concern: hooks-dangerous scans them for risky execution patterns and hooks-prohibited checks them against an explicit allowlist.

A plugin shipping both .claude-plugin/ and .codex-plugin/ manifests has its shared hooks/hooks.json validated by claude-hooks-valid, so one file gets one set of results. Dedicated Codex files and inline manifest hooks are checked here.

These checks were part of hooks-json-valid before 0.20.0 split them by host. The legacy name resolves to claude-hooks-valid for configuration and suppression comments.

A baseline written under hooks-json-valid keeps suppressing a finding from this rule only where the message is the same. A hooks file is JSON, which carries no line numbers, so the baseline fingerprint hashes the rule name, the file path, and the message text — and 0.20.0 rewrote most of these messages. Three file-level verdicts kept their wording and carry over:

  • Invalid JSON: <parser error>
  • hooks.json must be a JSON object
  • 'hooks' must be a JSON object

The per-event and per-handler shape messages were all re-worded, so a finding of that kind returns after the upgrade even with the old baseline in place. Re-record with skillsaw baseline once you have reviewed what came back.

Severity

A finding's severity is how much of the file the defect costs.

Errors — Codex loads nothing, or a handler cannot run. In a config.toml the measured refusals cost the whole CLI rather than the file.

  • The document is refused: invalid JSON or TOML, a non-object root, a missing or non-object hooks key, or a non-finite number (NaN, Infinity, -Infinity) anywhere in a JSON document. TOML spells nan and inf natively, so those reach the field checks instead.
  • The entry or handler is unusable: an event whose value is not an array, a malformed matcher group, a handler with no type or an unrecognized one, or a handler missing a required field (command for command handlers, server and tool for MCP tool handlers).
  • A field is the wrong type: a non-string command, a statusMessage that is neither a string nor null, or a timeout that is neither a number nor null. In a config.toml, timeout and additionalContextLimit must also be non-negative whole numbers. Both files deserialize them as unsigned integers; the JSON path keeps the looser check deliberately, so an upgrade does not surface a finding on a file that already worked.
  • One field written twice: a handler carrying both commandWindows and command_windows. They are one field, and Codex refuses the document over the duplicate.
  • The combination is not supported: an mcp_tool handler on SessionEnd. Codex warns and skips this one entry rather than refusing the file.

Warnings — the file loads and something in it does not fire. Codex says nothing at all about an unknown name — of an event, a handler field or an event-group key — under any flag: --strict-config never descends into [hooks]. It does name the file for a prompt or agent handler and for an mcp_tool handler on SessionEnd.

  • An event name Codex does not dispatch. The rest of the file still loads.
  • A key no handler type takes — a misspelled commandWindows, for instance, which is dropped on every platform — or a key beside matcher and hooks on an event group.
  • A field belonging to a different handler type, such as commandWindows on an MCP tool handler.
  • A prompt or agent handler: parsed, never run. Codex warns and skips it.
  • A timeout above 3 seconds on SessionEnd or Interrupt, which Codex clamps for these quick-exit events.

Info — the file loads and does what it says, and something is worth a look.

  • A non-null matcher on an event that does not filter on tool names. Codex accepts it and ignores it.
  • A .codex/ layer declaring hooks in both hooks.json and config.toml. Both load and every handler runs; Codex names both paths on startup and asks for a single representation per layer. Keep the hooks in one of them, or set allow-both-files.

Upgrading

hooks-json-valid reported no unknown-key finding before 0.20.0 split it by host. This one applies to every Codex hooks file, not only config.toml: a repository's .codex/hooks.json, a Codex-only plugin's hooks/hooks.json, a file a manifest names in hooks, and hooks written inline in .codex-plugin/plugin.json all get it. A repository that has carried a misspelled handler key since before the upgrade will see a new warning where it saw none. Fix the spelling, or accept it:

rules:
  codex-hooks-valid:
    extra-fields:
      - somethingNew

A repository whose only Codex marker is .codex/config.toml is now reported as codex-project rather than agents-md. Nothing is removed from the rule set by that, but CI keyed on the reported repository type will see the new name.

Examples

Bad — an event Codex does not dispatch, and a prompt handler it parses and skips:

{
  "hooks": {
    "PostToolUseFailure": [
      { "hooks": [{ "type": "command", "command": "./scripts/report.sh" }] }
    ],
    "SessionStart": [
      { "hooks": [{ "type": "prompt", "prompt": "Summarise the repo" }] }
    ]
  }
}

Good — a command hook filtered by matcher, and an MCP tool hook:

{
  "description": "Repository policy hooks",
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/audit-shell.sh",
            "timeout": 10,
            "statusMessage": "Auditing shell command"
          }
        ]
      }
    ],
    "SessionStart": [
      {
        "hooks": [
          { "type": "mcp_tool", "server": "policy", "tool": "load_rules" }
        ]
      }
    ]
  }
}

Good — the same project layer written in .codex/config.toml. The event key takes an array of tables, and timeout a whole number of seconds:

[[hooks.PreToolUse]]
matcher = "Bash"

[[hooks.PreToolUse.hooks]]
type = "command"
command = "./scripts/audit-shell.sh"
commandWindows = "powershell -File .\\scripts\\audit-shell.ps1"
timeout = 10

Bad — the same tables written as a plain table rather than an array of them. This is one of the measured refusals: codex exits 1 and starts no session in the project:

[hooks.PreToolUse]
matcher = "Bash"

How to fix

  • Use one of the twelve event names Codex dispatches.
  • Rewrite prompt and agent handlers as command or mcp_tool handlers.
  • Give every command handler a command, and every MCP tool handler both server and tool. Keep handler-specific fields with their type: commandWindows, additionalContextLimit and async belong to command handlers, input to MCP tools. Spell them exactly — an unrecognized handler key, and an unrecognized key on an event group, are both dropped without a word.
  • In config.toml, write each event as an array of tables ([[hooks.<Event>]]), and each timeout and additionalContextLimit as a non-negative whole number.
  • Pick one spelling of a Windows override per handler: commandWindows or command_windows, never both.
  • Drop mcp_tool handlers from SessionEnd, which does not support them.
  • Keep SessionEnd and Interrupt timeouts under 3 seconds.

Codex ships events and handler fields faster than skillsaw releases. Rather than turning the rule off, name a newer one:

rules:
  codex-hooks-valid:
    extra-events:
      - SomethingNew
    extra-fields:
      - somethingNew

.codex/config.toml also declares a project's MCP servers, in [mcp_servers.<name>] tables. Those are read by mcp-prohibited and mcp-valid-json, not here — Codex diagnoses a malformed server table itself, naming the server and the field and exiting 1, so no rule restates it.

Configuration

rules:
  codex-hooks-valid:
    enabled: auto  # true | false | auto
    severity: error
Parameter Description Default
extra-events Additional hook event names to accept, for events newer than this skillsaw release []
extra-fields Additional hook handler field names to accept, for fields newer than this skillsaw release []
allow-both-files Accept a .codex/ directory that declares hooks in both hooks.json and config.toml false

Run skillsaw explain codex-hooks-valid to see this documentation and the rule's effective configuration in your terminal.