Back to blog
NO.
031
DATE
Updated 2026-09-27
READ
~22 min
KIND
Guide
STATUS
Reviewed

TAGS: AI collaboration Architecture

Hooks for Coding Agents: From Rule to Gate

Turning one soft rule into a verifiable gate: where the criteria come from, which lifecycle point, what it can see, and four layers of verification.

The usual outcome of installing a hook is not installing it wrong. It is installing one that does nothing.

It throws no error and raises no alarm. It just quietly lets things through at the moment it was supposed to stop them. The rule looks insured; the fuse is blown. This post is about turning one soft rule in a docs file into a deterministic gate that is verifiable, portable, and maintainable — and about proving it is alive. Every Claude Code behavior here was tested on my machine. The Codex parts were checked against the official documentation only, never run.

One soft rule, three layers of enforcement

The first question is not how to write it. It is which layer the rule belongs to. A beautifully built gate at the wrong layer only makes people believe they are insured, so this step comes before any regex.

CLAUDE.md / AGENTS.md        intent, principles, soft constraints
    ↓
lifecycle hook               immediate agent-side feedback and behavioral guardrail
    ↓
Git hook / tests / CI        actual repository-level enforcement

The middle layer is the subject here, and its position is one sentence: a hook is a guardrail, not the repository's security boundary. Both vendors say so themselves.

Claude Code's hooks documentation states that the if filter is best-effort, fails open when it cannot parse a command, and advises you to "use the permission system rather than a hook to enforce a hard allow or deny." Codex's hooks documentation is blunter: "Some specialized tool paths can opt out of the default hook path. Treat tool hooks as a useful guardrail, not a complete enforcement boundary."

So a hard requirement like "every commit must carry its documentation" ultimately lands in a Git hook or in CI. The hook's value is elsewhere: it speaks at the moment the agent can still fix it, rather than when CI turns red and the context has moved on.

What should a hook cover, then? The class of deterministic judgments where forgetting once wastes the work: run the formatter before committing, update the docs when the runtime changed, never touch .env. Hard constraints get a deterministic command hook. For subjective quality checks that need judgment, Claude Code now offers prompt and agent handlers (agent is officially marked experimental) — but a model's judgment cannot carry a hard boundary. It can remind. It cannot be the lock.

Where the criteria come from

One precondition gets skipped easily, and it determines whether this hook still exists in six months: the criteria come from rules the repository already wrote down, not from what the hook's author invented on the spot.

The first hook I installed in this blog's repository covers "code changed but the docs did not." Its criteria look like this:

const WORK = /^(src\/|scripts\/|public\/|外部\/|\.github\/|package\.json|.*\.config\.(json|mjs|ts)$|wrangler.*\.toml)/;
const DOC  = /^(docs\/|AGENTS\.md|CONTEXT\.md|README\.md)/;

Not one entry in those two lines is a new rule. 外部/ (the repository's evidence-screenshot directory) is covered in AGENTS.md: "Commit a screenshot in the same change that references it." docs/roadmap.md states that every change, decision, and reversal goes into it. Every other entry points back at a specific sentence.

Criteria with no provenance eventually get treated as noise and routed around — usually by the person who wrote them. A gate that cannot explain its basis gets an exemption the first time it blocks someone, and then the exemption stays open.

Picking the moment: four candidates on the lifecycle

With the rule settled, the next question is where on the lifecycle the gate drops. The four candidates in this repository, and the decision:

After every file write (PostToolUse) — rejected. A real working session triggers it forty or fifty times. More fundamentally, at the moment of a single edit, what the documentation should say is usually not settled yet. In one batch I was consolidating font weights and only discovered 18 missed inline styles by the third file; being prompted to document each edit would have meant writing it three times and reversing it twice.

Before a commit (PreToolUse matching git commit) — the main gate. A commit is the unit of persistence, it fires once per unit of work, and there is still time to add the docs and try again.

Before a push (PreToolUse matching git push) — the second gate. A push is the moment something becomes a public fact. It sees something different from the commit gate; the next section explains why.

Session end (SessionEnd) — rejected. Nothing can be fixed by then.

There is also a reminder on Stop. It is not a zero-cost notification: Claude's Stop feedback, whether via block or additionalContext, continues the conversation and costs the model another round. So it only speaks once at least four files have changed — a task spans many turns, and it is not worth interrupting every one.

What that moment can actually see

This is the step most easily got wrong after choosing the moment, and the only structural limitation in this hook — not sloppy code, but something that moment physically cannot see.

The intuitive implementation checks git diff --cached to see what is about to be committed. That road is closed.

PreToolUse fires before the whole command runs, and git add -A && git commit is one command. At trigger time nothing has been staged, --cached returns empty, and the hook lets everything through forever.

Switching to git status --porcelain over the whole working tree fixes that — and opens a new hole. If the docs happen to be dirty in the working tree while this commit stages only git add src/foo.js, the hook sees "documentation changed" and allows it, and the commit that actually lands contains no docs at all.

Two sentences that must stay separate:

  • The commit gate reads the working tree. It is not equivalent to Git's own pre-commit hook.
  • The push gate reads the outgoing commit range (@{u}..HEAD by default) — what the batch about to become public actually touched.

@{u}..HEAD is only an approximation of the one case "push the current branch to its upstream"; do not treat it as the definition of the push range. An explicit remote/refspec, a push of a different branch, or a push of multiple refs describes a different push. The native pre-push hook receives the actual local/remote ref and object pairs, which is more precise than this approximation.

The push gate closes the selective-staging hole, and that is the real reason the two gates are not redundant. But it has its own limit: it is aggregate. One documentation commit plus four code commits passes as a whole. Even with a perfectly chosen range, a range-level gate cannot prove that "every single commit carries its documentation" — only a per-commit Git hook or CI can.

Both limitations are written up as self-test cases, tagged "boundary." The point of such cases is to keep the limitation visible, not to make it turn green.

Writing the decision as a deterministic policy

The decision logic is a pure function taking (mode, command, paths), with everything that touches git left outside. The value of a pure function is that the self-test can prove the logic without depending on the current state of the working tree — which is the crux of layer 1 below.

Two trade-offs:

Fail-open versus fail-closed is a choice you have to state. This gate is fail-open: the whole body sits in a try, catch exits 0, and it allows rather than errors when upstream cannot be parsed. The reasoning is that it is a guardrail, not a security boundary — a hook that blocks commits because git hiccuped is worse than no hook, and the thing it guards will not destroy the repository if it slips. But fail-open is not the same as silently looking successful: such a pass should explicitly report "skipped" and keep that failure observable, rather than looking identical to a normal pass.

For a destructive-command gate like "never delete recursively" the choice inverts — but you have to move the layer too, not just the fail direction. That kind of command executes the instant it is typed locally; by the time a Git hook or CI runs at commit or push, the local delete has already happened. It belongs on native permissions and sandbox boundaries, blocking before the command runs (Claude's and Codex's PreToolUse deny, and their respective sandboxes); the lifecycle hook only supplies feedback. Note too that a local Git hook can be bypassed with --no-verify; to actually gate a merge, use server-side required checks.

It judges text, not semantics. A regex reading a shell command is always reading text. So match only in command position: at the start of the string, or after &&, ||, ;, |, or a newline, allowing leading environment assignments.

function invocation(command, verb) {
  const pattern = new RegExp(String.raw`^\s*((?:\w+=\S*\s+)*)git\s+${verb}(?![\w-])(.*)$`);
  for (const segment of String(command).split(/[;&|]+|\n/)) {
    const match = pattern.exec(segment);
    if (match) return { env: match[1] ?? "", args: match[2] ?? "" };
  }
  return null;
}

It returns not a boolean but this invocation's own environment prefix and this invocation's own arguments. What that distinction blocks shows up in the escape-hatch section below.

False negatives and false positives cost differently: a miss fails to stop one commit, while a false positive gets the whole hook switched off. So this leans toward missing. The regex started out as /\bgit\s+commit\b/, and it blocked a Python command because a string list inside that Python happened to contain those two words. What it blocked was an article about itself.

Postscript, 2026-09-03. Writing this English version reproduced that failure one level up. The heredoc creating this file contained the escape-hatch examples above at the start of a line, so the gate — reading text, as designed — saw a real commit in command position and blocked the file write. The tightened v1.1.0 regex behaved exactly as specified; what it cannot know is that the line was prose. This is the "best effort" label doing its job, and the reason the boundary cases are in the test table rather than papered over. The fix was to write the file through an editor tool the gate does not cover, not to weaken the gate.

Two hosts, one policy core

The event names and the JSON shapes are close enough that the policy core can be shared. But handler types, matcher behavior, path resolution, the trust model, and some event semantics all differ, so host configuration must be written and verified separately. The table reflects implementation status as follows: the Claude Code column was tested on 2026-08-11 (2.1.227), and the Codex column was verified against the official docs on 2026-09-27; both are sourced from both vendors' docs. This is version-sensitive; defer to the current official documentation.

Claude CodeCodex
Config filesettings.json, three scopeshooks.json or config.toml, user and repo level
Multiple sourcesMergedMerged; higher-priority layers do not override lower ones
Event countDozens, still growingA dozen-plus, and moving
Handler typescommand · http · mcp_tool · prompt · agent (agent experimental)command and mcp_tool actually run; prompt and agent are parsed then skipped
Tool event pre-filtermatcher + if (permission-rule syntax)matcher only, a regex, matching tool names
PreToolUse decisionsallow · deny · ask · deferallow · deny (ask parses but is unsupported)
Stop outputdecision:"block" prevents stopping, additionalContext is non-error feedback — both continue the conversationdecision:"block" also keeps it going (reason becomes a new continuation prompt)
Path resolution${CLAUDE_PROJECT_DIR} + exec formSession cwd; docs recommend resolving from the git root
Per-hook-definition trustNone (there is first-time codebase trust)Yes, by definition hash

A few of these deserve expanding.

Claude's if now understands compound commands

This contradicts what a lot of hook tutorials do. if uses permission-rule syntax, and the official docs include a Bash matching table stating that compound commands are checked per subcommand and that leading environment assignments are stripped before matching — their own example matches a git push inside a two-command chain.

So there is no need to "start a script process for every Bash call and then look for the git verb inside the script." The division of labor becomes:

{
  "type": "command",
  "command": "node",
  "args": ["${CLAUDE_PROJECT_DIR}/scripts/hook-doc-freshness.mjs", "commit"],
  "if": "Bash(git commit*)",
  "timeout": 10
}

if does cheap pre-filtering; the script does the business decision and its own defensive checks. A stronger native parser does not mean the second line of defense can go. The same table says that patterns more specific than a command name still run when they meet $(), backticks, or $VAR, and that it fails open when the whole command cannot be parsed. Testing locally, an unrelated command that merely contained $(wc -l < $S) started the hook anyway — and agent-written commands are full of $(). So if saves fewer processes than it looks, and command-position matching inside the script stays.

The ${CLAUDE_PROJECT_DIR} part is another correction: a relative node scripts/... depends on the working directory at trigger time, and one cd from the agent removes it. The presence of args makes it exec form — Claude Code spawns directly without a shell and passes arguments verbatim.

One more: if is only evaluated on tool events, so a hook that carries it on Stop never runs.

The Stop return shape does not transfer between hosts

First, correct a direction that is easy to get wrong: both hosts' Stop block semantics are the same — both prevent the agent from stopping and continue the conversation, not the opposite of each other. What actually differs is the available output shapes.

Claude Code's Stop has two outputs. decision: "block" with a reason prevents it from stopping; the other is additionalContext:

{
  "hookSpecificOutput": {
    "hookEventName": "Stop",
    "additionalContext": "Docs lag: 4 files changed, docs/ untouched."
  }
}

The key is not to treat additionalContext as a zero-cost notification — the docs define it as "non-error feedback that continues the conversation," meaning it also continues the conversation and costs the model another round. It just does not error; it is labeled Stop hook feedback in the transcript, and the model can see it and act. Another easy trap: systemMessage is officially "Warning message shown to the user" — it is the channel to a human, not feedback to the model. If your goal is to get the agent to write the docs, returning only systemMessage accomplishes nothing. Return both: one for the person, one for the model.

Either form must read stop_hook_active from the input and stay silent when it is true. Claude Code caps continuation at 8 rounds as a backstop, but relying on the cap means burning 8 rounds.

Codex points the same direction as Claude: its Stop also uses {"decision":"block","reason":"..."} to prevent stopping and keep Codex going, except it turns reason directly into a new continuation prompt (equivalent to a new user message). Both are "block = do not stop, continue"; the difference is shape: Claude additionally offers additionalContext, a non-error but still continuation feedback channel, and Codex's Stop has no equivalent shape. So you cannot port Claude's return body across verbatim; implement and test each against its own schema.

That is why this Codex adapter does not wire up Stop, with the reason recorded in its README.

Codex configuration and paths

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "^Bash$",
        "hooks": [
          {
            "type": "command",
            "command": "node \"$(git rev-parse --show-toplevel)/scripts/hook-doc-freshness.mjs\" commit",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

Codex command hooks run with the session cwd as their working directory, and Codex can start from a subdirectory, so the docs recommend resolving repo-local hooks from the git root — the form above is the official example. Codex also has no if, so every Bash call starts a process, which makes the script's early return matter more here.

The good news: the PreToolUse deny shape happens to transfer. Codex also accepts permissionDecision: "deny" and permissionDecisionReason inside hookSpecificOutput. The bad news: ask parses on Codex but is unsupported — the hook run is recorded as failed and the tool call proceeds. Port Claude's four-value decision across verbatim and what you get is a silently dead gate.

Four layers of verification

Each layer catches a different class of problem. Drop one and a whole class walks through.

Layer 1: is the logic right? Synthesize the input the hook would receive and feed it straight to the decision function. What matters is not running the command but arranging the input into a state that should be blocked beforehand. Test on a clean repository and any hook "passes," because it was supposed to allow. A hook that always allows looks exactly like a perfectly correct hook on a clean repository.

This layer compresses to one command:

node scripts/hook-doc-freshness.mjs --self-test

It prints PASS / FAIL per case across 44 cases, exits 0 when all pass, and prints the total on the last line. It is wired into npm run check. Its value is not only convenience: someone who does not write code can tell whether this hook is alive or dead without reading a regex — just look for FAIL.

Layer 2: is the configuration itself valid? jq -e '.hooks.PreToolUse[] | .hooks[] | .command' .claude/settings.json, counting only if it exits 0 and prints the command. This layer catches broken JSON — a syntax error in a settings file silently disables everything in that file, not just the hook. But it only proves the JSON parses and that key exists; it is neither full schema validation nor proof that the host actually registered the hook — the latter is layer 3's job, so do not mistake this layer's green light for it.

Layer 3: did the host actually register it? The first two layers can pass while the host never loaded it. Both vendors provide a direct check: Claude Code's /hooks is a read-only browser listing configured hooks per event with each handler's full definition; Codex's /hooks additionally reviews, trusts, and disables — and on Codex, untrusted means uninstalled, which it warns about at startup.

To see how the host actually executed it, Claude Code offers claude --debug-file <path>, or claude --debug followed by reading the session debug file: which hooks matched, exit codes, and full stdout and stderr are all there (--debug does not print to the terminal).

Layer 4: does it block when it should and allow when it should not? The first three layers prove it fires; layer 4 proves it fires correctly. Construct the failing state, actually run the action, and see whether it is blocked. Then construct the normal state and confirm it passes. Constructing only the failing state verifies a gate that blocks everyone — and that kind of gate eventually gets an escape hatch that then stays open forever.

Layers 3 and 4 can be merged into something cheaper: temporarily prepend a sentinel to the command (echo fired >> /tmp/hook-check.txt), run a command that should not trigger, then one that should, and compare the file. That is how the if change was verified: echo hello started no process, while echo build && git commit --dry-run -m probe started one and was blocked. Remove the sentinel afterwards.

Adversarial cases: write the bypasses into the tests

All-normal-inputs-pass only proves it works on normal inputs. What belongs in the tests is every bypass you can think of.

Version 1.0.0's self-test had 21 cases, all passing. On review I found three reproducible bypasses in it — none of them logic errors, all of them scope that was too wide:

Inputv1.0.0Why it is wrong
echo SKIP_DOC_CHECK=1 && git commit -m xallowedSubstring search over the whole command; a previous command printing the switch counted
git add src/foo.js && git commit --amend --no-editallowedExempted on sight of --amend
git push --tags origin mainallowedExempted on sight of --tags

The last two are settled by git's own documentation. git commit --amend will "replace the tip of the current branch by creating a new commit. The recorded tree is prepared as usual" — it can carry entirely new code, not just a reworded message. git push --tags pushes tags "in addition to refspecs explicitly listed on the command line," so branches on the same command line go too. Both exemptions were named after an intention rather than after what the command actually does.

The fix was not more regex. It was narrowing scope to "this invocation's own environment prefix" and "this invocation's own arguments." Every case in the table is now labeled:

  • Guarantee: a semantic guarantee by design. If it breaks, it is a bug.
  • Best effort: a textual heuristic. Constructible inputs can get around it.
  • Boundary: structurally undecidable. It exists as a case to keep the limitation visible.

The two cases tagged boundary are command git commit and git -C . commit, both misses. The first stays unfixed because prefix forms have no end (env, builtin, an absolute path…). The second stays unfixed because -C may point at a different repository, in which case this working tree is not evidence about what is being committed — blocking would cost more than missing.

A few other cases that are easy to omit: non-ASCII paths, renames, an entire untracked directory, unparseable upstream, and "only code staged while the working tree's docs are dirty." The last one was genuinely constructed in a temporary repository, and it asserts that the commit gate allows — not that it does not.

And one thing you cannot get from reading the code: without -z, git status --porcelain escapes paths containing non-ASCII bytes into C-style octal, so a regex anchored on 外部/ never matches — and that is exactly the directory holding evidence screenshots that the repository explicitly requires to be committed alongside the change. In other words, the hook always allowed in the directory where it mattered most. With -z the path is emitted verbatim, at the cost of consuming the rename source field yourself (-z mode reverses the order: destination first, source second). Add -uall too, or an entire untracked directory collapses to dir/.

The escape hatch is itself a policy

An escape hatch is mandatory. But "which changes need no documentation" cannot be decided by category name: pure formatting usually does not, yet "dependency bump" and "rollback" are not inherently doc-free — an upgrade may change configuration, support boundaries, or operational behavior, and a rollback the same. The exemption must bind to the actual impact of the change, not to which category name it carries or whether there is a blanket switch. And the escape hatch needs defining and testing as strictly as the gate itself, or the hole you closed reopens through it.

Two constraints:

One: its form is limited by what the host can see. This repository commits by piping the message in on stdin, so the commit message never appears in the command string the hook can read. The switch therefore has to be an environment-variable prefix on that same invocation, and cannot be a marker inside the commit message.

Two: its scope binds to the target invocation, not to any substring of the whole Bash input. Neither of these forms is exempt now, and each has a case guarding it:

echo SKIP_DOC_CHECK=1 && git commit -m x    # switch sits in a different command
git commit -m "SKIP_DOC_CHECK=1"            # switch sits in the commit message

The parsing rules in the README must match the implementation. An escape hatch documented more broadly than it is implemented has written the bypass into the manual.

Trust models: both have one, at different granularity

Project-level configuration travels with a clone onto your machine. .claude/settings.json can be committed, and so can .codex/hooks.json. A hook with a SessionStart command in an unfamiliar repository would execute the moment the project is opened, if there were no trust gate.

Both vendors have project-level trust: Claude Code has trust verification for a first-time codebase and for new MCP servers (disabled under non-interactive -p), and Codex loads project-level hooks only after the .codex/ layer is trusted.

The difference is granularity. Codex additionally requires that an unmanaged command hook be reviewed and trusted as that exact hook definition; trust is recorded against the definition's current hash, any edit sends it back to review, and /hooks can trust or disable them individually. Its bypass flag exists for automation whose source was verified outside Codex, and the word "dangerously" in that flag's name is deliberate.

So "Codex has a trust mechanism and Claude does not" is inaccurate. The accurate version: both have project-level trust, and Codex additionally does per-definition hash review.

Take it and use it

The whole thing is packaged in the resource library: Doc freshness gate v1.1.1, CC BY 4.0. It contains the script, adapter configurations for both Claude Code and Codex, 44 self-test cases, a CHANGELOG, and a prompt you can paste to an AI agent to have it install the gate itself.

If you want a general reminder rather than a strict commit gate, the library also has a doc-review reminder: UserPromptSubmit records a baseline, Stop reminds at most once and asks the agent for a one-line docs assessment, with no git interception. It was written by ChatGPT and has only passed its bundled tests, not a run inside a client.

Two steps in that prompt carry the weight. One asks the agent to report what it set WORK and DOC to, and which sentence in which repository file justifies each entry — provenance is the only checkable thing, and it is the only way to see whether the agent invented rules of its own. The other requires it to construct both the "should block" and "should not block" states and actually run each once.

References

Version-sensitive facts link their official source inline. This is the full set. The documentation changes; this post will not follow it.

Boundaries

  • Platform behavior verified: Claude Code on 2026-08-11 (2.1.227), and the Codex column against the official docs on 2026-09-27. Event lists, field names, and handler support are all moving. Defer to the current official documentation.
  • Tested on Claude Code: exec form and ${CLAUDE_PROJECT_DIR} work; the if filter matches the commit verb inside a two-command chain, starts no process for an unrelated echo, and fails open on commands containing $() or $VAR; the block reason is fed back to the agent in full; both escape-hatch bypass forms are indeed blocked.
  • Codex not tested. Codex is not installed on this machine. The Codex column and every Codex conclusion above come from the official documentation, not from a run. Where they differ, the official docs win.
  • The methodology comes from one practice in one repository, not from an industry standard. The repository is Node + Astro + git; layers 1, 3, and 4 transfer to other stacks, while layer 2 is tied to the specific configuration format.
  • This gate checks whether a doc was touched, not whether anyone assessed the change's impact on the docs. Fixing an unrelated typo fools it; a purely internal refactor may need no doc change at all. The honest practice is to update the docs a change affects and give a specific "no update needed" reason for the rest — a policy stance a regex cannot judge. It defends against forgetting entirely, not against doing it lazily.
Comments →

CC BY-NC-SA 4.0

Comments

Comments are powered by GitHub Discussions. Sign in with GitHub to comment. Open the matching Discussion