Use hooks as deterministic guardrails
Agent hooks are scripts that Claude Code, Codex and Cursor run at fixed points in the agent loop, whatever the model decides. A deterministic guardrail layers four of them: block edits to protected paths, block production commands, lint every edit, and refuse to stop while a secret or a failing test remains. Each hook needs tested fixtures.
This page is for the developer who owns one repository’s agent setup, and the tech lead who wants the same checks on every teammate’s machine. The situation it prevents: your CLAUDE.md says “never edit src/generated/” and “run the tests before you finish”, the agent follows both for a week, and then a long session edits a generated client, reports “all done”, and leaves a failing test and an API key in the diff. Instructions are advice the model can lose in a long context. A hook runs every time.
Scorecard question 12: How do you use build-time hooks as deterministic guardrails?
Maximum-score answer: Layered guardrails: blocking protected paths, auto-formatting, credential leak prevention, and policy verification.
What a layered hook setup gives you
Section titled “What a layered hook setup gives you”- Four hook scripts you can copy today:
protect-paths.sh,guard-commands.sh,post-edit-lint.shandstop-gate.sh - The registration for each in Claude Code and Codex, and the rule for Cursor until its adapter is proven
- A fixture command that proves each hook allows, denies and fails closed, so nobody has to read the script to trust it
- Three copy-paste prompts: design a hook, generate its fixtures, and audit what is already configured
- The failure modes that turn a hook off without anyone noticing, and how to recover from each
This page covers what one developer puts into hooks. How an organization reviews, distributes and rolls back shared hooks is in shared hooks governance. Hooks are one layer of the harness, next to permissions and sandboxes.
Which checks belong in a hook?
Section titled “Which checks belong in a hook?”A hook earns its place when the rule is mechanical, cheap to check and costly to forget. Put each risk at the earliest event that can still stop it:
| Risk | Hook and event | What the hook does | What still backs it up |
|---|---|---|---|
| The agent edits generated, vendored or secret files | protect-paths.sh on PreToolUse for file edits | Blocks the edit and says which source to change instead | A permission deny rule on the same paths |
| The agent runs a production or destructive command | guard-commands.sh on PreToolUse for the shell tool | Blocks the command and asks for a human to run it | Sandbox, no production credentials in the session, CI deploys |
| An edit breaks formatting or lint | post-edit-lint.sh on PostToolUse for file edits | Formats the file, then feeds lint errors back to the agent | The lint gate in CI |
| The diff contains a credential | stop-gate.sh on Stop | Refuses to let the turn end until the secret is gone | Secret scanning in CI and on the Git host |
| The agent says “done” with failing checks | stop-gate.sh on Stop | Runs typecheck and tests once and sends failures back | The same commands as required CI checks |
Keep repair and security apart. A formatter that crashes should let work continue, because a missed format is fixed by the next run. A path guard or a secret check that crashes should block, because a missed block is not fixed by anything.
How do hooks block in each tool?
Section titled “How do hooks block in each tool?”Every rule on this page rests on one contract: exit code 2 with a reason on stderr. The effect depends on the event. The table records what each CLI did when checked on 2026-09-26 against the Claude Code hooks reference (v2.1.283) and the Codex source at rust-v0.157.1.
| Event | Exit 2 in Claude Code | Exit 2 in Codex |
|---|---|---|
PreToolUse | Blocks the tool call; stderr goes to the agent | Blocks the tool call; stderr is the reason |
PostToolUse | Shows stderr to the agent; the tool already ran | Feeds stderr back to the agent; the tool already ran |
Stop | Prevents stopping; the agent continues with stderr as feedback | Continues the turn with stderr as the continuation prompt |
Exit 1, crash, missing script, timeout | Non-blocking: the action proceeds | Hook marked failed: the action proceeds |
The last row is the one that matters. No tool fails closed for you: a hook is closed only when its own code catches every failure it can detect and exits 2. Each script below does that. A timeout still fails open, so the real boundary for anything dangerous stays in permissions and sandboxes.
Build the four hooks
Section titled “Build the four hooks”The scripts assume a TypeScript repository with jq, gitleaks, Prettier and ESLint installed. Change the path patterns, commands and test scripts to match yours; keep the structure.
-
Protect paths before the edit. Claude Code passes the absolute path of every
EditandWritecall intool_input.file_path. Match a path segment, not the start of the string.#!/usr/bin/env bash# .claude/hooks/protect-paths.sh — PreToolUse · Edit|Write · blocks edits to protected filesset -uo pipefailblock() { printf 'protect-paths: %s\n' "$1" >&2; exit 2; }command -v jq >/dev/null 2>&1 || block "jq is missing, so the path cannot be checked."path=$(jq -er '.tool_input.file_path // empty' 2>/dev/null) || block "the hook input has no file_path."path=${path//\\//} # normalize Windows separatorscase "$path" in*/src/generated/*|*/vendor/*|*/.env|*/.env.*)block "$path is protected. Edit the source it is generated from, or ask a human." ;;esacexit 0 -
Block production commands before they run. Both Claude Code and Codex pass the shell command in
tool_input.commandfor theBashtool, so one script serves both.#!/usr/bin/env bash# .agent-hooks/guard-commands.sh — PreToolUse · Bash · Claude Code and Codexset -uo pipefailblock() { printf 'guard-commands: %s\n' "$1" >&2; exit 2; }command -v jq >/dev/null 2>&1 || block "jq is missing, so the command cannot be checked."cmd=$(jq -er '.tool_input.command // empty' 2>/dev/null) || block "the hook input has no command."if printf '%s' "$cmd" | grep -Eq \'(terraform|tofu)[[:space:]]+apply|kubectl[[:space:]].*prod|git[[:space:]]+push.*[[:space:]](--force|-f)([[:space:]]|$)|--remote([[:space:]]|$)'; thenblock "this command can change production. Write it in your summary for a human to run."fiexit 0A pattern sees one spelling.
sh -c '…', a variable prefix or a script file defeats it. Treat this hook as a typo-catcher that records intent, and keep the actual boundary in the sandbox and in the absence of production credentials. -
Lint every edit, and hand the errors back. On
PostToolUsethe edit has already happened, so exit2does not undo it: it puts the lint output in front of the agent, which fixes it on the next step.#!/usr/bin/env bash# .claude/hooks/post-edit-lint.sh — PostToolUse · Edit|Write · format, then lint the edited fileset -uo pipefailfile=$(jq -r '.tool_input.file_path // empty' 2>/dev/null) || exit 0case "$file" in *.ts|*.tsx|*.js|*.jsx) ;; *) exit 0 ;; esacbin="$CLAUDE_PROJECT_DIR/node_modules/.bin""$bin/prettier" --write "$file" >/dev/null 2>&1 || true # repair: fail openif ! out=$("$bin/eslint" --max-warnings=0 "$file" 2>&1); thenprintf 'ESLint failed on %s. Fix it before continuing:\n%s\n' "$file" "$out" | head -c 4000 >&2exit 2fiexit 0This hook is Claude Code only. Codex reports its file edits as the
apply_patchtool, whose input is a patch rather than one path, so in Codex the lint check runs over the changed files in the stop gate instead. -
Refuse to stop while secrets or failures remain. The
Stopevent fires when the agent is about to end its turn. Exit2sends it back to work with your message. Both tools passstop_hook_active: truewhen the agent is already continuing because of a stop hook; use it so a check the agent cannot fix does not loop. Claude Code also overrides a block after eight consecutive continuations (raise it withCLAUDE_CODE_STOP_HOOK_BLOCK_CAP; checked on 2.1.283). The gate scans only uncommitted changes, so a secret the agent commits during the turn escapes it: whenever the agent may commit, the gitleakspre-commithook in the ecosystem section below is required, not optional.#!/usr/bin/env bash# .agent-hooks/stop-gate.sh — Stop · Claude Code and Codex# Secrets always block. Typecheck and tests block once, then hand over to a human.set -uo pipefailblock() { printf 'stop-gate: %s\n' "$1" >&2; exit 2; }again=$(jq -r '.stop_hook_active // false' 2>/dev/null || echo false)root=$(git rev-parse --show-toplevel 2>/dev/null) || block "not inside a Git repository."cd "$root" || block "cannot enter $root."command -v gitleaks >/dev/null 2>&1 || block "gitleaks is missing, so the diff cannot be scanned."# Tracked changes plus new untracked files. gitleaks exits 1 on a leak or an error: both block.if ! { git diff HEAD 2>/dev/null; git ls-files -z --others --exclude-standard | xargs -0 -r cat; } \| gitleaks stdin --redact --no-banner >/dev/null 2>&1; thenblock "gitleaks found a credential in your changes. Remove it and read the value from the environment."fi[ "$again" = "true" ] && exit 0if ! out=$( { npm run --silent typecheck && npm run --silent lint && npm test --silent; } 2>&1 ); thenblock "typecheck, lint or tests fail. Fix them before you finish. Last output:$(printf '%s' "$out" | tail -n 40)"fiexit 0 -
Make the scripts executable with
chmod +x .claude/hooks/*.sh .agent-hooks/*.sh, and commit them. A script that is not executable is a hook that silently does nothing.
Register the hooks in each tool
Section titled “Register the hooks in each tool”The scripts are shared; the registration is not. Event names, matchers and file locations differ, so write one registration per tool against its own schema rather than copying one file between them.
Put project hooks in .claude/settings.json. ${CLAUDE_PROJECT_DIR} points at the project root where the session started, so the paths work from any subdirectory. Timeouts are in seconds.
{ "hooks": { "PreToolUse": [ { "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-paths.sh", "timeout": 10 }] }, { "matcher": "Bash", "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.agent-hooks/guard-commands.sh", "timeout": 10 }] } ], "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/post-edit-lint.sh", "timeout": 60 }] } ], "Stop": [ { "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.agent-hooks/stop-gate.sh", "timeout": 600 }] } ] }}Type /hooks in a session to see every configured hook and which settings file it came from. To watch hooks fire, start with claude --debug-file /tmp/hooks.log (it implies debug mode) and read the log; --debug hooks filters debug output to the hooks category (checked on 2.1.283).
Codex reads hooks.json from a config layer’s folder, such as .codex/ in a trusted project or ~/.codex/, or a [hooks] table in config.toml. The JSON shape matches Claude Code’s. Codex runs the command through a shell in the session’s working directory, so resolve the repository root yourself.
{ "hooks": { "PreToolUse": [ { "matcher": "apply_patch", "hooks": [{ "type": "command", "command": "\"$(git rev-parse --show-toplevel)/.agent-hooks/protect-paths-codex.sh\"", "timeout": 10 }] }, { "matcher": "Bash", "hooks": [{ "type": "command", "command": "\"$(git rev-parse --show-toplevel)/.agent-hooks/guard-commands.sh\"", "timeout": 10 }] } ], "Stop": [ { "hooks": [{ "type": "command", "command": "\"$(git rev-parse --show-toplevel)/.agent-hooks/stop-gate.sh\"", "timeout": 600 }] } ] }}Project hooks do not run until you trust them, and trust is tied to the hook’s definition. Open /hooks in a session to review and trust them, and again after anyone changes hooks.json. --dangerously-bypass-hook-trust skips that check (Codex 0.157.1); never pass it on a checkout you did not produce.
Codex edits files through apply_patch, which fires PreToolUse and PostToolUse with tool_name: "apply_patch" and passes the whole patch as tool_input.command (Codex 0.157.1). Codex matches Edit|Write to apply_patch but sends the patch in tool_input.command, not file_path. Do not register protect-paths.sh or post-edit-lint.sh in Codex unchanged: protect-paths.sh finds no file_path, exits 2 and blocks every edit, and post-edit-lint.sh silently lints nothing. Use this variant instead, which reads the file headers of the patch and applies the same case:
#!/usr/bin/env bash# .agent-hooks/protect-paths-codex.sh — Codex PreToolUse · apply_patch · blocks patches to protected filesset -uo pipefailblock() { printf 'protect-paths: %s\n' "$1" >&2; exit 2; }
command -v jq >/dev/null 2>&1 || block "jq is missing, so the patch cannot be checked."patch=$(jq -er '.tool_input.command // empty' 2>/dev/null) || block "the hook input has no patch in tool_input.command."paths=$(printf '%s\n' "$patch" | sed -nE 's/^\*\*\* (Add File|Update File|Delete File|Move to): (.*)$/\2/p')[ -n "$paths" ] || block "the patch names no files, so it cannot be checked."
while IFS= read -r path; do path="/${path//\\//}" # leading slash so relative paths match */dir/* case "$path" in */src/generated/*|*/vendor/*|*/.env|*/.env.*) block "$path is protected. Edit the source it is generated from, or ask a human." ;; esacdone <<< "$paths"exit 0Keep the sandbox’s writable roots as the backstop: a hook sees one tool, and a shell command that writes a file never reaches this one.
Cursor runs hooks as “spawned processes that communicate over stdio using JSON in both directions” that “can observe, block, or modify behavior” (Cursor hooks docs, checked 2026-08-28). Its event names, configuration file and failure behaviour could not be re-verified from the writing environment in September 2026, so this page does not state them.
What you can enforce for Cursor-authored changes today, without any Cursor-specific event name: make the checks that .agent-hooks/stop-gate.sh runs (typecheck, lint, tests, and gitleaks over the pull request’s commits, because CI has no uncommitted changes to scan) required CI checks on every pull request, and install the gitleaks pre-commit hook from the ecosystem section below on every machine that runs Cursor. Together they enforce the same invariants as the stop gate; add the protected-path rule to CI as a diff check on src/generated/ and vendor/. Only then port the scripts to Cursor through a thin adapter written from the current Cursor hooks docs, and keep that adapter advisory until the fixtures below pass through a real Cursor session.
Prove each hook without reading it
Section titled “Prove each hook without reading it”A hook you have not seen block is a hook you hope works. Feed each script the JSON the agent would send, and compare the exit code with what the contract says:
# Terminal, from the repository root. Expect: 0, 2, 0, 2, 2, 0, 0, 2, 2, 0.p=.claude/hooks/protect-paths.sh; g=.agent-hooks/guard-commands.sh; c=.agent-hooks/protect-paths-codex.shecho '{"tool_input":{"file_path":"/repo/src/app.ts"}}' | $p; echo $?echo '{"tool_input":{"file_path":"/repo/src/generated/api.ts"}}' | $p; echo $?echo '{"tool_input":{"file_path":"/repo/src/generator/api.ts"}}' | $p; echo $?echo 'not json' | $p; echo $?echo '{"tool_input":{"command":"terraform apply -auto-approve"}}' | $g; echo $?echo '{"tool_input":{"command":"terraform plan"}}' | $g; echo $?echo '{"tool_input":{"command":"cat docs/terraform-apply-notes.md"}}' | $g; echo $?echo '{"tool_input":{}}' | $g; echo $?echo '{"tool_input":{"command":"*** Begin Patch\n*** Update File: src/generated/api.ts\n*** End Patch"}}' | $c; echo $?echo '{"tool_input":{"command":"*** Begin Patch\n*** Update File: src/app.ts\n*** End Patch"}}' | $c; echo $?Then prove the registration, because a correct script behind a wrong matcher never runs. In a scratch branch, ask the agent to edit src/generated/api.ts and to run terraform apply, and confirm both are refused with your message. For the stop gate, write a synthetic token that your gitleaks rules detect (never a real one) into an uncommitted file on a throwaway branch, ask the agent to finish, and confirm it is sent back. The stop gate does not see secrets the agent has already committed. The pre-commit gitleaks hook and CI scanning cover that case.
Save the echo | script lines as a fixture script and run it in CI next to the hooks. That is the evidence a reviewer checks instead of the script: every path and command hook has an allow case, a deny case, a near miss that must be allowed, and a malformed-input case that exits 2. The lint hook and the stop gate depend on the repository state rather than on one JSON line, so a recorded session is their proof: ask the agent for an edit that breaks a lint rule and confirm the error is handed back, and plant a synthetic token as above.
Add ready-made hooks from the ecosystem
Section titled “Add ready-made hooks from the ecosystem”Two Anthropic plugins in the official Claude Code marketplace package hooks you would otherwise write yourself. Hooks cost no context tokens until they fire, which is why a plugin made only of hooks is cheap to keep installed.
| Plugin | What it adds | Install (Claude Code) |
|---|---|---|
hookify | Writes hook rules as Markdown files from a plain request, such as /hookify:hookify Block any rm -rf outside /tmp, with a warn or block action | claude plugin install hookify@claude-plugins-official |
security-guidance | Regex warnings on edits for dangerous patterns, a model review of the diff at Stop, and a reviewer on git commit and git push. Needs Claude Code 2.1.144 or later and Python 3.8+ | claude plugin install security-guidance@claude-plugins-official |
Anthropic’s plugin directory at claude.com/plugins showed 241,800 installs for security-guidance and 60,376 for hookify on 2026-09-26. Neither replaces your own path and secret hooks: hookify rules are patterns with the same bypass limits as guard-commands.sh, and the security-guidance review is a model judgement, not a deterministic check. Add gitleaks as a Git pre-commit hook as well. It is required whenever the agent may commit, because the stop gate scans only uncommitted changes and a committed secret never reaches it.
Who signs off on a hook, and how?
Section titled “Who signs off on a hook, and how?”Treat a hook change like code that runs with your credentials, because it does. The author writes the fixtures; a reviewer approves on evidence, not on reading every line:
- Every hook maps to a named risk in the table above, and its message says what to do instead.
- Allow, deny, near-miss and malformed-input fixtures pass in CI for every path and command hook, and each detectable failure exits
2. - The lint hook and the stop gate each have a recorded session showing them hand an error back.
- A recorded session shows each blocking hook refusing its target in every tool it is registered for.
- No hook writes secrets or full command text to stdout, stderr or logs.
- CI enforces the same invariants, so a disabled hook costs speed, not safety.
- Changes to
.claude/settings.jsonand.codex/hooks.jsongo through code-owner review, and Codex hooks are re-trusted after each change.
When hooks fail silently, and how to recover
Section titled “When hooks fail silently, and how to recover”The gate is off and nobody noticed. A mistyped path, a missing jq or an exit 1 produces a non-blocking error in both CLIs, and work continues. Recovery: run the fixture script, fix the path, and make every detectable failure exit 2. Watch the first session after any hook change for a hook error notice.
The stop gate loops. The tests fail for a reason the agent cannot fix, such as a missing service, and every stop is sent back. Recovery: check stop_hook_active as stop-gate.sh does, so checks block once and hand over to you. Keep only secrets blocking every time.
The formatter became a blocker. A PostToolUse hook that exits 2 on a Prettier crash stalls every edit. Recovery: repair steps fail open (|| true); only lint findings the agent can act on exit 2.
The hook leaks what it guards. A debug line prints the command or the matched secret into the transcript or a log. Recovery: print the rule and the file, never the value; use gitleaks --redact, and rotate any credential that reached a log.
The Codex hook stopped running after a pull. A teammate changed .codex/hooks.json, the definition no longer matches what you trusted, and the hook is skipped until you re-trust it. Recovery: open /hooks, review the change and trust it. For hooks that must always run, ask your platform team to ship them as managed hooks, as described in shared hooks governance.
CI ran a contributor’s hooks with your secrets. An agent job checked out a pull request whose .claude/settings.json added a hook, and the hook ran with the job’s tokens. Recovery: check out trusted scripts from the default branch, keep the contributor’s commit in a separate read-only directory, and run claude --bare --setting-sources "" --strict-mcp-config (Claude Code 2.1.283). Then follow the pattern in AI in CI/CD.
Where to go next with agent hooks
Section titled “Where to go next with agent hooks”Hooks sit inside the permission boundaries set in governance and autonomy, and the stop gate is the local half of the evidence the build and test stages ask for. For event-by-event recipes in one tool, read Claude Code hooks in practice. The next page in the harness section, agent skills, covers the procedures a hook cannot enforce. To lock test files the agent must not weaken, see protecting the oracle.