Claude Code hooks: guardrails, gates, and live context
Claude Code hooks are handlers (shell commands, HTTP endpoints, MCP tools, prompts, or agents) that Claude Code runs at fixed lifecycle events such as PreToolUse, PostToolUse, and Stop. Hooks run whether or not the model follows its instructions, so a PreToolUse hook can block a command and a Stop hook can refuse to let a turn end while tests fail.
This page is for developers and tech leads who own a repository’s .claude/settings.json. The situation it solves: your CLAUDE.md says “never edit migrations” and “run the tests before you finish”, and last week the agent did both anyway. A line in CLAUDE.md is advice the model weighs against everything else in its context. A hook is code that runs on every matching event.
What a production-ready hook setup gives you
Section titled “What a production-ready hook setup gives you”- A decision rule for when a check belongs in a hook, a permission rule, or
CLAUDE.md. - Five scripts: four gates that block destructive commands, protect paths, format and lint every edit, and refuse to stop on red tests, plus one that seeds each session with live context.
- The exit-code and JSON contract, including the two traps that silently disable a gate.
- A fixture test suite that proves each of the four gates works in CI, so nobody reviews hooks by reading them.
- Prompt hooks and background hooks for the checks a script cannot express, or that take too long to run inline.
When should a rule be a hook instead of a CLAUDE.md line?
Section titled “When should a rule be a hook instead of a CLAUDE.md line?”Put each rule in the weakest mechanism that still enforces it. Stronger mechanisms cost more latency and maintenance, and prompt hooks also cost tokens.
| Mechanism | Enforced? | Use it for | Example |
|---|---|---|---|
CLAUDE.md | No, advisory | Conventions and context the model should know | “We use pnpm, not npm.” |
Permission rule (permissions.deny) | Yes, static pattern | A fixed list of tools, commands, or paths | Deny Bash(curl *), deny Edit(.env) |
| Command hook | Yes, computed | Checks that need logic, the file content, or the repository state | Lint the edited file; block DROP TABLE in any form |
| Prompt or agent hook | Yes, judged by a model | Checks that need judgment, not pattern matching | “Does the final message cite the test command it ran?” |
Two consequences follow from that table. First, if a static rule can express the policy, use a permission rule: the official hooks reference calls a handler’s if filter “best-effort” and recommends the permission system for hard allow and deny. Second, hooks are the right place for anything that checks the result: the formatter, the linter, and the test suite. Those checks turn “the agent says it’s done” into evidence. For the permission side, see permissions and sandboxing.
Which Claude Code hook events can block?
Section titled “Which Claude Code hook events can block?”Claude Code v2.1.283 fires 33 hook events. The official hooks reference lists them all. These are the ones most repositories use:
| Event | Fires | What exit code 2 does |
|---|---|---|
SessionStart | Session starts, resumes, clears, or compacts | Cannot block. Plain stdout on exit 0 becomes context for Claude |
UserPromptSubmit | You submit a prompt | Blocks and erases the prompt |
PreToolUse | Before any tool call | Blocks the tool call; stderr goes to Claude as the reason |
PermissionRequest | A tool call needs a permission decision | Not honored. Deny through the JSON decision object instead |
PostToolUse | After a tool call succeeds | Cannot undo the call. Shows stderr to Claude, which then fixes it |
PostToolBatch | After a batch of parallel tool calls | Stops the loop before the next model call |
Stop / SubagentStop | Claude or a subagent finishes responding | Keeps it working |
TaskCompleted | A task is marked complete | Prevents the completion |
PreCompact | Before context compaction | Blocks compaction |
ConfigChange | A settings file changes mid-session | Blocks the change (not for managed policy settings) |
Notification | Claude Code needs your attention | Cannot block; use it for alerts |
The newer events (PreModelSwitch, FileChanged, WorktreeCreate, Elicitation, and others) solve narrower problems. Check the reference before you build on one.
How does a hook talk back to Claude Code?
Section titled “How does a hook talk back to Claude Code?”A command hook receives the event as JSON on stdin and answers through its exit code and stdout.
- Exit 0 means success. On most events stdout goes only to the debug log. On
SessionStart,UserPromptSubmit,UserPromptExpansion, andPostModelSwitch, plain-text stdout becomes context Claude can see. If stdout starts with{and ends with}, Claude Code parses it as JSON. - Exit 2 blocks on events that can block, and stderr becomes the reason. No JSON on stdout can override an exit-2 block.
- Any other exit code, including 1, does not block unless valid JSON on stdout makes the decision. Claude Code shows a “hook error” notice and the action proceeds. A policy hook that fails with
exit 1is not a gate.WorktreeCreateis the exception: any non-zero exit aborts worktree creation.
JSON gives finer control than an exit code. PreToolUse answers inside hookSpecificOutput with a permissionDecision of allow, deny, ask, or defer (defer works only in claude -p, for apps that pause and resume the session; interactive sessions ignore it). When several hooks disagree, the precedence is deny > defer > ask > allow. PostToolUse, Stop, and SubagentStop use a top-level {"decision": "block", "reason": "..."}. Any event that injects context uses hookSpecificOutput.additionalContext. A string over 10,000 characters is written to a file, and Claude gets the path plus a 2,000-character preview, so keep injected context short.
Where do hooks live, and how do matchers match?
Section titled “Where do hooks live, and how do matchers match?”Hooks merge across every location. None replaces another.
| Location | Scope | Shared with the team? |
|---|---|---|
~/.claude/settings.json | All your projects | No |
.claude/settings.json | This project | Yes, commit it |
.claude/settings.local.json | This project, only you | No, gitignored |
| Managed policy settings | The whole organization | Yes, set by admins |
Plugin hooks/hooks.json | While the plugin is enabled | Yes, with the plugin |
| Skill or subagent frontmatter | While the skill or subagent is active | Yes, in the file |
The matcher field is not always a regular expression. A matcher of letters, digits, _, -, spaces, commas, and | is an exact match: Bash matches only the Bash tool and Edit|Write matches exactly those two. Any other character makes it an unanchored JavaScript regular expression, so Edit.* also matches NotebookEdit. To match every tool of an MCP server, write mcp__github__.*. A bare mcp__github is an exact string and matches nothing.
To filter on arguments rather than tool names, add an if field to the handler in permission-rule syntax, such as "if": "Bash(git push *)" or "if": "Edit(*.sql)". It saves a process spawn on every other call.
Reference scripts through ${CLAUDE_PROJECT_DIR} so they resolve after Claude runs cd. With "args": [] set, the command runs in exec form without a shell, and the path needs no quoting.
Build the five hooks most repositories need
Section titled “Build the five hooks most repositories need”Save each script under .claude/hooks/, run chmod +x on it, and commit it. Every script except session-context.sh needs jq.
Block destructive shell commands
Section titled “Block destructive shell commands”#!/usr/bin/env bash# .claude/hooks/block-destructive.sh (PreToolUse, matcher "Bash")set -euo pipefail# Fail closed: without jq this gate cannot read the command, so block.command -v jq >/dev/null || { echo "jq is missing; install it before running Bash." >&2; exit 2; }cmd=$(jq -r '.tool_input.command // empty')
deny() { jq -nc --arg r "$1" '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $r}}' exit 0}
shopt -s nocasematchrm_rf='(^|[;&|[:space:]])rm[[:space:]]+-[a-z]*(rf|fr)'sql_drop='(drop|truncate)[[:space:]]+(table|database|schema)'force_push='git[[:space:]]+push.*(--force|[[:space:]]-f([[:space:]]|$))'
[[ $cmd =~ $rm_rf ]] && deny "rm -rf is blocked. Delete the specific files by path."[[ $cmd =~ $sql_drop ]] && deny "Destructive SQL is blocked. Write a migration and stop for review."[[ $cmd =~ $force_push ]] && deny "Force push is blocked. Push a new commit instead."exit 0The deny reason goes to Claude, so write it as the next instruction, not as an accusation. Claude reads “Delete the specific files by path” and does exactly that.
Treat this hook as a speed bump, not a boundary. rm -r -f, rm --recursive --force, and find . -delete all get past these patterns. Pattern matching catches the common spellings, not every one, so pair it with permissions.deny rules and the sandbox for anything that must never run.
Protect paths the agent must not edit
Section titled “Protect paths the agent must not edit”Migrations, secrets, and acceptance tests are the usual list. The acceptance tests matter most: an agent that can edit the test can make any change pass. See protecting the oracle for the full pattern.
#!/usr/bin/env bash# .claude/hooks/protect-paths.sh (PreToolUse, matcher "Edit|Write")set -euo pipefailcommand -v jq >/dev/null || { echo "jq is missing; install it before editing files." >&2; exit 2; }file=$(jq -r '.tool_input.file_path // empty')rel=${file#"$CLAUDE_PROJECT_DIR"/}
case "$rel" in db/migrations/*|.env|.env.*|package-lock.json|tests/acceptance/*) echo "Protected path: $rel. Describe the change you need in your reply; a human applies it." >&2 exit 2 ;;esacexit 0Format and lint every edit
Section titled “Format and lint every edit”A PostToolUse hook cannot undo an edit, but exit 2 puts the lint output in front of Claude, which fixes it on the next step.
#!/usr/bin/env bash# .claude/hooks/format-and-lint.sh (PostToolUse, matcher "Edit|Write")set -uo pipefailfile=$(jq -r '.tool_input.file_path // empty')[[ -f $file ]] || exit 0bin=$CLAUDE_PROJECT_DIR/node_modules/.bin
case "$file" in *.ts|*.tsx|*.js|*.jsx) "$bin/prettier" --write "$file" >/dev/null 2>&1 if ! out=$("$bin/eslint" --max-warnings=0 "$file" 2>&1); then echo "ESLint failed on $file. Fix these before moving on:" >&2 head -n 40 <<<"$out" >&2 exit 2 fi ;;esacexit 0Refuse to stop while tests fail
Section titled “Refuse to stop while tests fail”This is the hook that turns “done” into a claim backed by evidence. It runs only when the working tree is dirty, blocks the first stop on red tests, and hands over to a human if the retry also fails.
#!/usr/bin/env bash# .claude/hooks/tests-before-stop.sh (Stop)set -uo pipefailretrying=$(jq -r '.stop_hook_active')
# Nothing changed in the working tree: nothing to verify.[[ -z $(git status --porcelain) ]] && exit 0
if out=$(npm test --silent 2>&1); then exit 0; fitail_out=$(tail -n 40 <<<"$out")
if [[ $retrying == "true" ]]; then jq -nc '{systemMessage: "Tests still fail after one retry. Stopping for a human."}' exit 0fijq -nc --arg r "The test suite fails. Fix it before you finish. Last 40 lines:$tail_out" '{decision: "block", reason: $r}'stop_hook_active is true when Claude is already continuing because of a stop hook. Claude Code also caps stop hooks at eight consecutive continuations (raise it with CLAUDE_CODE_STOP_HOOK_BLOCK_CAP), but one retry followed by an escalation is cheaper and tells you sooner that something needs a human.
Seed every session with live context
Section titled “Seed every session with live context”SessionStart stdout becomes context. Use it for facts that change, and keep static conventions in CLAUDE.md.
#!/usr/bin/env bash# .claude/hooks/session-context.sh (SessionStart, matcher "startup|resume|compact")branch=$(git branch --show-current)echo "Current branch: $branch"echo "Uncommitted files: $(git status --porcelain | wc -l | tr -d ' ')"res=$(gh run list --branch "$branch" --limit 1 --json conclusion \ -q '.[0].conclusion' 2>/dev/null)echo "Last CI run on this branch: ${res:-unknown}"Including compact in the matcher re-injects the context after compaction, when the first copy has been summarized away. Write it as statements of fact. Text phrased as out-of-band system commands can trip Claude’s prompt-injection defenses.
Register all five in .claude/settings.json
Section titled “Register all five in .claude/settings.json”{ "hooks": { "SessionStart": [ { "matcher": "startup|resume|compact", "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/session-context.sh", "args": [] }] } ], "PreToolUse": [ { "matcher": "Bash", "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-destructive.sh", "args": [] }] }, { "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-paths.sh", "args": [] }] } ], "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format-and-lint.sh", "args": [], "timeout": 60 }] } ], "Stop": [ { "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/tests-before-stop.sh", "args": [], "timeout": 300 }] } ] }}Stop has no matcher support (checked against v2.1.283), so it has no matcher field. Open /hooks in a session to confirm that each hook loaded and to see which settings file it came from. The menu is read-only; change hooks by editing the JSON.
Prove each hook works without reading it
Section titled “Prove each hook works without reading it”A hook is code that guards other code, so it needs its own tests. Nobody should approve a hook change by reading a regular expression. Pipe recorded event JSON into each script and assert the outcome. The lint and Stop cases put stub eslint and npm binaries in front of the real ones, so the suite runs in seconds and tests the hook, not your linter. The rm -r -f case records a known gap on purpose: if you widen the regex, flip that case to deny.
#!/usr/bin/env bash# .claude/hooks/test-hooks.sh: run in CI beside the unit testsset -uexport CLAUDE_PROJECT_DIR=$PWDH=$CLAUDE_PROJECT_DIR/.claude/hooks # the same path the settings file usestmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXITfail=0
decision() { local out; out=$(printf '%s' "$2" | "$H/$1" 2>/dev/null) [[ -n $out ]] || out='{}' jq -r '.hookSpecificOutput.permissionDecision // "none"' <<<"$out"}exit_code() { printf '%s' "$2" | "$H/$1" >/dev/null 2>&1; echo $?; }expect() { [[ $2 == "$3" ]] || { echo "FAIL: $1 (got $2, want $3)"; fail=1; }; }stub() { printf '#!/bin/sh\necho stub output\nexit %s\n' "$2" >"$1"; chmod +x "$1"; }
bash_call() { jq -nc --arg c "$1" '{tool_name: "Bash", tool_input: {command: $c}}'; }edit_call() { jq -nc --arg f "$1" '{tool_name: "Edit", tool_input: {file_path: $f}}'; }
# PreToolUse: block-destructive.shexpect "rm -rf is denied" "$(decision block-destructive.sh "$(bash_call 'rm -rf dist')")" denyexpect "force push is denied" "$(decision block-destructive.sh "$(bash_call 'git push -f origin')")" denyexpect "npm test is allowed" "$(decision block-destructive.sh "$(bash_call 'npm test')")" none# Known gap, recorded on purpose: split flags slip past the pattern.expect "rm -r -f is NOT caught" "$(decision block-destructive.sh "$(bash_call 'rm -r -f dist')")" none
# PreToolUse: protect-paths.shexpect "migration edit blocked" "$(exit_code protect-paths.sh "$(edit_call "$PWD/db/migrations/007.sql")")" 2expect "source edit allowed" "$(exit_code protect-paths.sh "$(edit_call "$PWD/src/app.ts")")" 0
# PostToolUse: format-and-lint.sh against a stub ESLint that reports an errorlint=$tmp/lint; mkdir -p "$lint/node_modules/.bin"; : >"$lint/app.ts"stub "$lint/node_modules/.bin/prettier" 0stub "$lint/node_modules/.bin/eslint" 1expect "lint error exits 2" \ "$(CLAUDE_PROJECT_DIR=$lint exit_code format-and-lint.sh "$(edit_call "$lint/app.ts")")" 2
# Stop: tests-before-stop.sh in a dirty repository where `npm test` failsrepo=$tmp/repo; mkdir -p "$repo" "$tmp/bin"; git -C "$repo" init -q; : >"$repo/new.txt"stub "$tmp/bin/npm" 1stop() { (cd "$repo" && printf '{"stop_hook_active": %s}' "$1" | PATH=$tmp/bin:$PATH "$H/tests-before-stop.sh" 2>/dev/null); }expect "red tests block the stop" "$(stop false | jq -r '.decision // "none"')" blockexpect "retry hands over to a human" "$(stop true | jq -r 'has("systemMessage")')" true
exit $failThen verify in three layers, from cheapest to most realistic:
-
Fixture tests in CI. Run
.claude/hooks/test-hooks.shin the same job as your unit tests. A hook change without a new fixture fails review by default. The reviewer reads the fixture list, not the regex. -
One live smoke test per hook. Start
claude --debug hooksand ask Claude to runrm -rf dist. The deny reason should appear in the transcript. The debug log goes to~/.claude/debug/<session-id>.txt, not the terminal;--debug-file <path>writes it somewhere you choose. -
Watch the first real run. Look for a “hook error” notice the first time each hook fires. It is the only sign of a mistyped script path, as described under failure modes.
Who signs off: treat .claude/settings.json and .claude/hooks/ as production config. Put them under CODEOWNERS so a tech lead approves every change. For an organization-wide rollout, see shared hooks governance.
Use prompt and agent hooks when a script cannot decide
Section titled “Use prompt and agent hooks when a script cannot decide”A prompt hook ("type": "prompt") sends the event JSON and your prompt to a fast model, Haiku by default, which answers {"ok": true} or {"ok": false, "reason": "..."}. On Stop, ok: false feeds the reason back to Claude and the turn continues. This one catches “all done” messages that carry no evidence:
{ "hooks": { "Stop": [ { "hooks": [ { "type": "prompt", "prompt": "Here is the hook input: $ARGUMENTS. Read last_assistant_message. If it claims that tests pass, a bug is fixed or work is complete, it must name the command that was run and quote its result. If it does, respond {\"ok\": true}. If it does not, respond {\"ok\": false, \"reason\": \"Run the verification command and report its output before finishing.\"}. If no such claim is made, respond {\"ok\": true}.", "timeout": 30 } ] } ] }}If you combine it with tests-before-stop.sh, remember that stop_hook_active is true after either hook continues the turn; count retries in a temp file keyed on session_id if you need per-hook retries.
Every prompt hook is an extra model call on every matching event, so keep them on Stop and SubagentStop rather than on every tool call. An agent hook ("type": "agent") spawns a subagent that can use Read, Grep, and Glob for up to 50 turns before it decides. Agent hooks are experimental, so keep them out of the gates you rely on. The built-in /goal command is a session-scoped prompt Stop hook; see goal-directed runs when you want the same loop without writing config.
Run slow checks in the background
Section titled “Run slow checks in the background”A synchronous hook adds its runtime to every tool call it matches. For a type check or a slow test file, run it in the background instead:
"async": trueruns the command in the background and delivers itsadditionalContextorsystemMessageto Claude on the next turn. It cannot block anything."asyncRewake": truealso runs in the background, but on exit 2 it wakes Claude immediately with the hook’s stderr, even when the session is idle. It suits a background type check that must interrupt the agent when it fails.
In a claude -p run, Claude Code kills any async hook still running at teardown. For CI, keep the check synchronous or run it as a separate job step.
Do hooks run in CI and untrusted repositories?
Section titled “Do hooks run in CI and untrusted repositories?”Command hooks run with your full user permissions. Two rules follow from that:
- Interactive sessions hold back every settings-file hook until you accept the workspace trust dialog for the folder.
claude -pand Agent SDK sessions never show that dialog, yet they still run every hook in the repository’s settings files immediately (its allow rules stay off).
So never run claude -p over code you did not write while a secret is in the environment. That covers pull requests from forks, pull_request_target workflows, and repositories you are evaluating. Start those runs with --bare --setting-sources "" --strict-mcp-config; --settings '{"disableAllHooks": true}' alone is not enough, because the repository’s .mcp.json servers still start. Check out trusted scripts from the default branch, and keep the untrusted checkout in a directory the agent only reads. Claude Code in CI/CD and the agent threat model cover the full pattern. Organizations can set allowManagedHooksOnly in managed settings so that only admin-approved hooks run.
When Claude Code hooks break, and how to recover
Section titled “When Claude Code hooks break, and how to recover”The gate exits 1 and nothing is blocked. Only exit 2, or a JSON deny, blocks. Recovery: search your scripts for exit 1, and make set -e failures impossible on the deny path. A missing jq would exit 127 and let the call through, which is why the two PreToolUse scripts above check for jq first and exit 2 without it.
A mistyped path silently disables the gate. A mistyped script path fails to start: exit 127 in shell form, a spawn error in exec form. Claude Code treats either as a non-blocking error, shows a “hook error” notice, and the tool call proceeds. Recovery: use ${CLAUDE_PROJECT_DIR} with exec form, and make the fixture suite call scripts by the same path the settings file uses.
JSON output has no effect. Something printed before the JSON, often a shell profile or a noisy tool, so stdout no longer starts with {. Recovery: send everything except the final JSON to stderr or /dev/null, and check the debug log for “treating as plain text”.
A PreToolUse hook times out and the call runs anyway. A timed-out command hook renders no decision, so the call goes through the normal permission flow. Recovery: keep PreToolUse hooks fast and local, with no network calls, and back the critical ones with a permission deny rule.
Path protection has a Bash-shaped hole. Blocking Edit and Write on db/migrations/ does not stop sed -i or cat > through Bash. Recovery: add a matching permissions.deny rule, and run in the sandbox so writes outside allowed paths fail at the OS level.
The Stop hook loops or makes every turn slow. A full test suite on every stop can take minutes. Recovery: skip when the tree is clean, run only the tests related to changed files, and escalate to a human on the retry as the script above does.
Injected context goes stale on resume. When you resume with --continue or --resume, Claude Code replays text from mid-session hooks instead of re-running them. Recovery: put anything time-sensitive in a SessionStart hook, which runs again on resume.
You need to turn hooks off right now. Set "disableAllHooks": true in your settings, or start one run with claude --settings '{"disableAllHooks": true}'. Neither disables managed hooks unless the setting is made in managed settings.
Where to go next with Claude Code hooks
Section titled “Where to go next with Claude Code hooks”- Prerequisite: configure Claude Code covers the settings files and precedence that hooks build on.
- Next: scripting Claude Code runs these gates in headless and scheduled jobs.
- Custom subagents can carry their own hooks in frontmatter, scoped to the subagent’s run.
- Reading evidence instead of code explains why test, lint, and stop gates replace line-by-line review.
- Security audits with Claude Code extends the destructive-command list into a full review.