Skip to content

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.

  • Four hook scripts you can copy today: protect-paths.sh, guard-commands.sh, post-edit-lint.sh and stop-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.

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:

RiskHook and eventWhat the hook doesWhat still backs it up
The agent edits generated, vendored or secret filesprotect-paths.sh on PreToolUse for file editsBlocks the edit and says which source to change insteadA permission deny rule on the same paths
The agent runs a production or destructive commandguard-commands.sh on PreToolUse for the shell toolBlocks the command and asks for a human to run itSandbox, no production credentials in the session, CI deploys
An edit breaks formatting or lintpost-edit-lint.sh on PostToolUse for file editsFormats the file, then feeds lint errors back to the agentThe lint gate in CI
The diff contains a credentialstop-gate.sh on StopRefuses to let the turn end until the secret is goneSecret scanning in CI and on the Git host
The agent says “done” with failing checksstop-gate.sh on StopRuns typecheck and tests once and sends failures backThe 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.

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.

EventExit 2 in Claude CodeExit 2 in Codex
PreToolUseBlocks the tool call; stderr goes to the agentBlocks the tool call; stderr is the reason
PostToolUseShows stderr to the agent; the tool already ranFeeds stderr back to the agent; the tool already ran
StopPrevents stopping; the agent continues with stderr as feedbackContinues the turn with stderr as the continuation prompt
Exit 1, crash, missing script, timeoutNon-blocking: the action proceedsHook 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.

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.

  1. Protect paths before the edit. Claude Code passes the absolute path of every Edit and Write call in tool_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 files
    set -uo pipefail
    block() { 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 separators
    case "$path" in
    */src/generated/*|*/vendor/*|*/.env|*/.env.*)
    block "$path is protected. Edit the source it is generated from, or ask a human." ;;
    esac
    exit 0
  2. Block production commands before they run. Both Claude Code and Codex pass the shell command in tool_input.command for the Bash tool, so one script serves both.

    #!/usr/bin/env bash
    # .agent-hooks/guard-commands.sh — PreToolUse · Bash · Claude Code and Codex
    set -uo pipefail
    block() { 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:]]|$)'; then
    block "this command can change production. Write it in your summary for a human to run."
    fi
    exit 0

    A 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.

  3. Lint every edit, and hand the errors back. On PostToolUse the edit has already happened, so exit 2 does 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 file
    set -uo pipefail
    file=$(jq -r '.tool_input.file_path // empty' 2>/dev/null) || exit 0
    case "$file" in *.ts|*.tsx|*.js|*.jsx) ;; *) exit 0 ;; esac
    bin="$CLAUDE_PROJECT_DIR/node_modules/.bin"
    "$bin/prettier" --write "$file" >/dev/null 2>&1 || true # repair: fail open
    if ! out=$("$bin/eslint" --max-warnings=0 "$file" 2>&1); then
    printf 'ESLint failed on %s. Fix it before continuing:\n%s\n' "$file" "$out" | head -c 4000 >&2
    exit 2
    fi
    exit 0

    This hook is Claude Code only. Codex reports its file edits as the apply_patch tool, 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.

  4. Refuse to stop while secrets or failures remain. The Stop event fires when the agent is about to end its turn. Exit 2 sends it back to work with your message. Both tools pass stop_hook_active: true when 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 with CLAUDE_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 gitleaks pre-commit hook 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 pipefail
    block() { 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; then
    block "gitleaks found a credential in your changes. Remove it and read the value from the environment."
    fi
    [ "$again" = "true" ] && exit 0
    if ! out=$( { npm run --silent typecheck && npm run --silent lint && npm test --silent; } 2>&1 ); then
    block "typecheck, lint or tests fail. Fix them before you finish. Last output:
    $(printf '%s' "$out" | tail -n 40)"
    fi
    exit 0
  5. 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.

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).

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 window
# 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.sh
echo '{"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.

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.

PluginWhat it addsInstall (Claude Code)
hookifyWrites hook rules as Markdown files from a plain request, such as /hookify:hookify Block any rm -rf outside /tmp, with a warn or block actionclaude plugin install hookify@claude-plugins-official
security-guidanceRegex 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.

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.json and .codex/hooks.json go 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.

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.