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:
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
hookskey, or a non-finite number (NaN,Infinity,-Infinity) anywhere in a JSON document. TOML spellsnanandinfnatively, 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
typeor an unrecognized one, or a handler missing a required field (commandfor command handlers,serverandtoolfor MCP tool handlers). - A field is the wrong type: a non-string
command, astatusMessagethat is neither a string nor null, or atimeoutthat is neither a number nor null. In aconfig.toml,timeoutandadditionalContextLimitmust 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
commandWindowsandcommand_windows. They are one field, and Codex refuses the document over the duplicate. - The combination is not supported: an
mcp_toolhandler onSessionEnd. 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 besidematcherandhookson an event group. - A field belonging to a different handler type, such as
commandWindowson an MCP tool handler. - A
promptoragenthandler: parsed, never run. Codex warns and skips it. - A
timeoutabove 3 seconds onSessionEndorInterrupt, 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
matcheron an event that does not filter on tool names. Codex accepts it and ignores it. - A
.codex/layer declaring hooks in bothhooks.jsonandconfig.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 setallow-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:
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:
How to fix¶
- Use one of the twelve event names Codex dispatches.
- Rewrite
promptandagenthandlers ascommandormcp_toolhandlers. - Give every command handler a
command, and every MCP tool handler bothserverandtool. Keep handler-specific fields with their type:commandWindows,additionalContextLimitandasyncbelong to command handlers,inputto 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 eachtimeoutandadditionalContextLimitas a non-negative whole number. - Pick one spelling of a Windows override per handler:
commandWindowsorcommand_windows, never both. - Drop
mcp_toolhandlers fromSessionEnd, which does not support them. - Keep
SessionEndandInterrupttimeouts under 3 seconds.
Codex ships events and handler fields faster than skillsaw releases. Rather than turning the rule off, name a newer one:
.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¶
| 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.