Skip to content

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.

MechanismEnforced?Use it forExample
CLAUDE.mdNo, advisoryConventions and context the model should know“We use pnpm, not npm.”
Permission rule (permissions.deny)Yes, static patternA fixed list of tools, commands, or pathsDeny Bash(curl *), deny Edit(.env)
Command hookYes, computedChecks that need logic, the file content, or the repository stateLint the edited file; block DROP TABLE in any form
Prompt or agent hookYes, judged by a modelChecks 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.

Claude Code v2.1.283 fires 33 hook events. The official hooks reference lists them all. These are the ones most repositories use:

EventFiresWhat exit code 2 does
SessionStartSession starts, resumes, clears, or compactsCannot block. Plain stdout on exit 0 becomes context for Claude
UserPromptSubmitYou submit a promptBlocks and erases the prompt
PreToolUseBefore any tool callBlocks the tool call; stderr goes to Claude as the reason
PermissionRequestA tool call needs a permission decisionNot honored. Deny through the JSON decision object instead
PostToolUseAfter a tool call succeedsCannot undo the call. Shows stderr to Claude, which then fixes it
PostToolBatchAfter a batch of parallel tool callsStops the loop before the next model call
Stop / SubagentStopClaude or a subagent finishes respondingKeeps it working
TaskCompletedA task is marked completePrevents the completion
PreCompactBefore context compactionBlocks compaction
ConfigChangeA settings file changes mid-sessionBlocks the change (not for managed policy settings)
NotificationClaude Code needs your attentionCannot 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.

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, and PostModelSwitch, 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 1 is not a gate. WorktreeCreate is 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.

LocationScopeShared with the team?
~/.claude/settings.jsonAll your projectsNo
.claude/settings.jsonThis projectYes, commit it
.claude/settings.local.jsonThis project, only youNo, gitignored
Managed policy settingsThe whole organizationYes, set by admins
Plugin hooks/hooks.jsonWhile the plugin is enabledYes, with the plugin
Skill or subagent frontmatterWhile the skill or subagent is activeYes, 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.

#!/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 nocasematch
rm_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 0

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

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 pipefail
command -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 ;;
esac
exit 0

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 pipefail
file=$(jq -r '.tool_input.file_path // empty')
[[ -f $file ]] || exit 0
bin=$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 ;;
esac
exit 0

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 pipefail
retrying=$(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; fi
tail_out=$(tail -n 40 <<<"$out")
if [[ $retrying == "true" ]]; then
jq -nc '{systemMessage: "Tests still fail after one retry. Stopping for a human."}'
exit 0
fi
jq -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.

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.

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 tests
set -u
export CLAUDE_PROJECT_DIR=$PWD
H=$CLAUDE_PROJECT_DIR/.claude/hooks # the same path the settings file uses
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
fail=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.sh
expect "rm -rf is denied" "$(decision block-destructive.sh "$(bash_call 'rm -rf dist')")" deny
expect "force push is denied" "$(decision block-destructive.sh "$(bash_call 'git push -f origin')")" deny
expect "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.sh
expect "migration edit blocked" "$(exit_code protect-paths.sh "$(edit_call "$PWD/db/migrations/007.sql")")" 2
expect "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 error
lint=$tmp/lint; mkdir -p "$lint/node_modules/.bin"; : >"$lint/app.ts"
stub "$lint/node_modules/.bin/prettier" 0
stub "$lint/node_modules/.bin/eslint" 1
expect "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` fails
repo=$tmp/repo; mkdir -p "$repo" "$tmp/bin"; git -C "$repo" init -q; : >"$repo/new.txt"
stub "$tmp/bin/npm" 1
stop() { (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"')" block
expect "retry hands over to a human" "$(stop true | jq -r 'has("systemMessage")')" true
exit $fail

Then verify in three layers, from cheapest to most realistic:

  1. Fixture tests in CI. Run .claude/hooks/test-hooks.sh in 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.

  2. One live smoke test per hook. Start claude --debug hooks and ask Claude to run rm -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.

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

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": true runs the command in the background and delivers its additionalContext or systemMessage to Claude on the next turn. It cannot block anything.
  • "asyncRewake": true also 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 -p and 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.