Your CLAUDE.md says “Never force-push to shared branches.” Claude obeyed it for three weeks. Then, deep in a long refactor with a rejected rebase, it ran git push --force anyway.
Claude Code hooks exist for exactly this moment. They move rules out of the model’s attention and into the harness, where they run whether or not the model remembers them.
Instructions are suggestions. Hooks are law.
We build Claude Code hooks into a three-layer guardrail set you can commit to any repo. PreToolUse hooks block dangerous commands before they run. PostToolUse hooks verify every edit after it lands, and SessionStart hooks load state deterministically.
What you’ll learn
- Decide which rules belong in CLAUDE.md and which belong in hooks
- Apply the exit-code and JSON decision model correctly, including the silent exit-1 trap
- Block dangerous Bash commands and protected-file edits before they execute
- Feed formatter and type-check failures back into Claude’s loop after every edit
- Assemble all three layers into one committed
.claude/settings.json, then test and debug it
Prerequisites
- You use Claude Code daily and already have
.claude/settings.jsonfiles in your projects - A recent Claude Code version (the per-handler
iffilter used below arrived in v2.1.85) jqinstalled, plus comfort reading small Bash scripts- Familiarity with permission rules and modes (allow, ask, deny,
bypassPermissions)
Instructions are suggestions
Anthropic’s own docs draw the line for you. Hooks give you “deterministic control: certain actions always happen rather than relying on the LLM to choose to run them.”
Their steering post is blunter: when something absolutely must not happen, “an instruction is the wrong tool.” Long sessions, ambiguity, or a prompt injection in a task file can all make the model drop a prompted rule.
The distinction is architectural, not stylistic. A CLAUDE.md rule competes for attention with everything else in context. A hook lives in the harness and fires on every matched event, at zero attention cost.
As the steering post puts it: the model choosing to run a formatter is different from the formatter running automatically.
CLAUDE.md: 'Never force-push to shared branches.' Enforced by model attention.
PreToolUse hook: guard-bash.sh exits 2 on force pushes. Enforced by the harness.
CLAUDE.md still owns the “what and why” of your project — see CLAUDE.md as a contract, not a wiki for what belongs there. Hooks own the “must.” Every hand-typed correction you send after a skipped formatter or a broken type check is part of the correction tax; hooks refund it.
The enforcement model behind Claude Code hooks
Every command hook receives event JSON on stdin and answers through exit codes, stdout, and stderr. Three exit codes matter:
- Exit 0 — no objection. The action proceeds. For PreToolUse this does not approve the call; the normal permission flow still applies. For SessionStart and UserPromptSubmit, stdout gets injected into Claude’s context.
- Exit 2 — block. stderr is fed back to Claude as feedback so it can correct course. Nothing you print as JSON can override an exit-2 block.
- Any other exit code, including 1 — non-blocking error. The action proceeds and the transcript shows a hook error notice. (The exception: if stdout carries a valid JSON decision, Claude Code ignores the exit code and the JSON decides.)
Exit 1 does not block. It is the conventional Unix failure code, so it is the one developers reach for — and the hook then “fails” silently while the dangerous action proceeds.
Guardrail scripts must exit 2 to block. Reserve exit 1 for internal errors you want surfaced without blocking, such as JSON parse failures.
For richer control than a bare exit 2, exit 0 and print structured JSON to stdout. PreToolUse hooks use hookSpecificOutput.permissionDecision (allow, deny, or ask) with a permissionDecisionReason. A fourth value, defer, only applies in non-interactive -p runs, where it pauses the session at the tool call so an Agent SDK wrapper can collect input and resume.
PostToolUse and Stop hooks use a top-level decision: "block" with a reason. Pick one mechanism per hook: exit 2 with stderr, or exit 0 with JSON. Claude Code reads JSON on any exit code, so mixing them only makes it harder to tell which message Claude will see.
Here is the fact that makes hooks law rather than convention. The hooks guide states that PreToolUse hooks fire before any permission-mode check, and that a hook returning permissionDecision: "deny" blocks the tool even in bypassPermissions mode or with --dangerously-skip-permissions.
The asymmetry is deliberate — a hook’s allow skips the interactive prompt, but deny and ask rules from settings are still evaluated regardless of what the hook returns. Hooks can tighten policy; they cannot loosen it past your deny rules.
Claude Code hooks are also nearly free in context terms. The configuration lives outside the main context window, unlike CLAUDE.md prose that costs tokens on every turn — a point that matters when you are budgeting context deliberately.
Block dangerous commands with PreToolUse
Layer 1 stops actions before they happen. PreToolUse input arrives as JSON with tool_name and tool_input — for Bash that means tool_input.command, for Edit and Write it means tool_input.file_path.
This guard blocks force pushes and a handful of other destructive patterns:
#!/bin/bash
# PreToolUse guard for Bash. Exit 2 blocks; stderr goes to Claude.
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
BLOCKED_PATTERNS=(
# ([^-]|$) so the safe --force-with-lease is NOT matched
'git push[^|]*--force([^-]|$)'
'git push[^|]*-f\b'
# broad on purpose: blocks rm -rf on any absolute path
'rm -rf /'
'chmod 777'
'drop table'
)
for pattern in "${BLOCKED_PATTERNS[@]}"; do
if echo "$COMMAND" | grep -qiE "$pattern"; then
echo "Blocked by guard-bash hook: matched '$pattern'." \
"Use a safe alternative (e.g. git push --force-with-lease" \
"after review)." >&2
exit 2
fi
done
exit 0
The stderr message does double duty. It blocks the call, and it teaches Claude the sanctioned alternative in the same turn.
- 01
Create the script
Save the guard as
.claude/hooks/guard-bash.shin your repo. Keep hook scripts in the repo so they version with the policy they enforce. - 02
Make it executable
Run
chmod +x .claude/hooks/guard-bash.sh. A non-executable hook fails with a non-blocking error — which means it silently stops guarding. - 03
Register it in settings
Add the PreToolUse entry to
.claude/settings.json(shown below). The file watcher normally picks up direct settings edits mid-session; if the hook has not appeared after a few seconds, restart the session to force a reload. - 04
Verify it fires
Open the
/hooksmenu to confirm registration, then test the script standalone by piping sample JSON into it (see the testing section below).
Registration in the committed project settings:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-bash.sh",
"timeout": 5
}
]
}
]
}
}
Set a short timeout. PreToolUse validators sit on the critical path of every matched tool call, so a hung guard stalls the whole session.
In a session, the sequence is simple. Claude proposes git push --force, the hook exits 2, and the call never runs. Claude receives the stderr text as the reason, including the pointer to --force-with-lease, and can retry with the safe form in the same turn.
The same rule in CLAUDE.md depends on the model remembering it under pressure. Here the harness enforces it, and the feedback redirects Claude to the safe path without a human in the loop.
Protect files the same way
The identical pattern guards Edit and Write. Block changes to .env files, lockfiles, and — critically — .claude/ itself, so the model cannot edit the hook config that constrains it:
#!/bin/bash
# PreToolUse guard for Edit|Write. Blocks edits to protected paths.
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
case "$FILE" in
*.env|*.env.*|*/.claude/*|*package-lock.json)
echo "Blocked by protect-paths hook: $FILE is protected." \
"Ask a human to change it." >&2
exit 2
;;
esac
exit 0
Two advanced notes. First, hooks can rewrite arguments before the tool runs via hookSpecificOutput.updatedInput. If multiple hooks return updatedInput for the same call, the last one to finish wins non-deterministically — keep one rewriter per tool.
Second, the if field filters handlers with permission-rule syntax like "if": "Bash(git *)". Treat it as an optimization that saves process spawns, not as the check itself: the docs call it best-effort, and when Claude Code can’t work out which commands a Bash input runs, it runs your hook regardless of the pattern. Your script must still test the command itself.
Verify every edit with PostToolUse
Layer 2 runs after a tool call succeeds. PostToolUse cannot undo the action, but it can do two things instructions cannot. It runs cleanup unconditionally, and it pushes failures back into Claude’s loop via decision: "block" with a reason Claude must act on.
This is the tightest verification loop available — per edit, not per task.
The formatter hook is a one-liner adapted from the official docs, matched on Edit|Write (it appears in the full config below). Edit|Write fires for every file type, so we add Prettier’s --ignore-unknown flag to skip files it has no parser for instead of erroring on them. Pair it with a type check that blocks on failure:
#!/bin/bash
# PostToolUse on Edit|Write. Runs tsc; on failure, blocks with the
# errors so Claude fixes them before building on broken code.
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
case "$FILE" in
*.ts|*.tsx) ;;
*) exit 0 ;;
esac
ERRORS=$(cd "$CLAUDE_PROJECT_DIR" && npx tsc --noEmit 2>&1 | head -30)
if [ -n "$ERRORS" ]; then
jq -n --arg reason "tsc failed after editing $FILE:
$ERRORS" '{decision: "block", reason: $reason}'
fi
exit 0
Note the mechanism: the script exits 0 and emits JSON. For PostToolUse, decision: "block" in stdout is what feeds the reason back to Claude. Exit 2 with stderr also works; pick one per hook.
The edit has already landed when this runs, so nothing is undone. What changes is the next step: the tsc errors arrive as the hook’s reason, and Claude fixes them before it moves on instead of stacking new edits on a broken build.
Every edit becomes a checked edit. Claude never “forgets” to format, and type errors surface before the next change builds on top of them.
Claude can also create and modify files through Bash — sed, cat >, git apply — and none of those trigger an Edit|Write matcher. If a hook must see every file change, add a Stop hook that sweeps the working tree once per turn with git status --porcelain, or match Bash as well. Without this, your verification layer is a decoration, not a guardrail.
Mind the cost. Full-project tsc --noEmit on every edit is slow on large repos. Scope it with -p to the affected package — scoping Claude Code in monorepos covers the pattern.
Or use a cached runner like claude-code-typescript-hooks. It caches config discovery by hash, runs warm checks in milliseconds, and exits 2 with a full error report on failure.
Load state with SessionStart
Layer 3 makes context arrive deterministically. SessionStart hooks fire when a session begins or resumes, with matchers startup, resume, clear, and compact. On exit 0, whatever the command prints to stdout is injected into Claude’s context — not a file the model optionally reads, but actual model input.
#!/bin/bash
# SessionStart: inject live repo state. stdout on exit 0 = context.
cd "$CLAUDE_PROJECT_DIR" || exit 0
echo "## Repo state ($(date -u +%Y-%m-%dT%H:%MZ))"
echo "Branch: $(git branch --show-current)"
echo "Recent commits:"
git log --oneline -5
echo "Uncommitted changes:"
git status --short | head -10
echo "Failing CI on this branch, if any: check before claiming done."
Static conventions still belong in CLAUDE.md — the docs say so directly. SessionStart earns its keep for dynamic state: the current branch, recent commits, sprint context, environment variables.
For env vars specifically, a SessionStart hook can write export statements to $CLAUDE_ENV_FILE, which Claude Code runs before every Bash command. The documented direnv pattern runs direnv export bash > "$CLAUDE_ENV_FILE" from both a SessionStart hook and a CwdChanged hook, so the variables follow Claude when it changes directory.
The compact matcher is the sleeper feature. Register your context script on startup|compact and your conventions get re-injected immediately after every auto-compact — exactly when the summarizer is most likely to have dropped them. This is the deterministic half of surviving auto-compact; the other half is shaping what compaction keeps.
Assemble the committed guardrail set
The three layers belong in one committed .claude/settings.json next to a .claude/hooks/ directory, so every clone of the repo gets the same law:
.claude/
├── settings.json
└── hooks/
├── guard-bash.sh
├── protect-paths.sh
├── typecheck.sh
└── load-context.sh
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-bash.sh",
"timeout": 5
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-paths.sh",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write --ignore-unknown"
},
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/typecheck.sh",
"timeout": 60
}
]
}
],
"SessionStart": [
{
"matcher": "startup|compact",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/load-context.sh"
}
]
}
]
}
}
Three mechanics to know when reading this file:
- Matchers. Plain names and lists separated by
|or,(Edit|Write,Edit, Write) are exact matches. Anything with other regex characters is an unanchored, case-sensitive JS regex (mcp__github__.*). Empty, omitted, or*matches everything. - Parallel execution. All matching hooks run in parallel, and one hook’s deny does not stop its siblings — their side effects still happen. For PreToolUse the most restrictive decision wins:
deny>defer>ask>allow. - Exec form. If quoting bugs bite you, add
"args": []to spawn the executable directly with no shell tokenization. It pairs well with${CLAUDE_PROJECT_DIR}placeholders.
Pick the right scope
| Scope | File | Use for |
|---|---|---|
| User | ~/.claude/settings.json | Personal habits across all projects |
| Project | .claude/settings.json | Team guardrails, committed to git |
| Local | .claude/settings.local.json | Personal overrides, gitignored |
| Managed | Org policy settings | Rules users must not remove |
Guardrails the team relies on belong in the committed project file. Anything you would be uncomfortable letting a teammate delete in a PR belongs in managed settings instead.
Test hooks standalone, then debug in session
Every command hook is just a program reading JSON from stdin. Test it without Claude Code in the loop — this output is from running the guard script above directly:
$ echo '{"tool_input":{"command":"git push --force origin main"}}' \
| .claude/hooks/guard-bash.sh; echo "exit: $?"
Blocked by guard-bash hook: matched 'git push[^|]*--force([^-]|$)'.
Use a safe alternative (e.g. git push --force-with-lease after
review).
exit: 2
$ echo '{"tool_input":{"command":"git status"}}' \
| .claude/hooks/guard-bash.sh; echo "exit: $?"
exit: 0An exit code of 2 on the dangerous input and 0 on the safe one is the whole contract. In session, use the /hooks menu to browse registered hooks and Ctrl+O to open the transcript view and check each hook’s outcome. Run claude --debug-file /tmp/claude.log when you need full exit codes, stdout, and stderr per invocation.
Where the law has loopholes
Claude Code hooks are law within a session, but we would rather name the boundaries than oversell them.
Hook config reloads live. The file watcher picks up settings edits mid-session, usually within seconds — anything that can write the file can change the law. The ConfigChange event fires when an external process modifies a config file, so you can log those changes or block unauthorized ones.
Hooks run unsandboxed with your full user permissions. A hook is arbitrary code executing on every matched event. Review hook commands before registering them, especially in cloned repos and plugins.
The settings file is itself writable. The model can in principle edit hook config through Edit or Write, which is why protect-paths.sh above blocks .claude/ paths. Permission deny rules on those paths add a second layer.
Orgs can go further: managed settings plus allowManagedHooksOnly block user, project, local, and plugin hooks in favor of centrally managed ones.
PostToolUse cannot undo. It is a verification lane, not a rollback mechanism. Anything irreversible must be caught by Layer 1.
Judgment calls are not command-hook territory. For “should Claude stop yet?” decisions, the docs offer type: "prompt" hooks (a single Haiku call returning a verdict) and experimental type: "agent" hooks. Both put an LLM back in the loop — suggestions with better plumbing, not law.
That distinction is the whole thesis. Relatedly, Stop hooks have a loop cap: after Stop hooks have continued the turn eight times in a row, Claude Code overrides the next block and ends the turn (CLAUDE_CODE_STOP_HOOK_BLOCK_CAP raises it). Don’t lean on the cap. Stop scripts should check stop_hook_active in their input and exit 0 when the condition they wait on can’t resolve.
One more boundary worth knowing: in headless -p mode there is no human to answer prompts, so enforcement rests on deny rules and PreToolUse hooks. And --bare skips project hooks entirely, so don’t combine it with a setup that relies on them. Get this right before you put Claude Code in CI.
Across a team, the failure mode is rarely a missing hook. It is several engineers with their own private guard-bash.sh variants in user settings, one of them exiting 1, and no shared answer to which commands are off-limits. Standardising means one committed guardrail set per repo, the non-negotiable rules promoted to managed settings, and a standalone test for every script. That rollout is the core of our Enable work.
Next steps
- Choosing the right extension point — when a hook is the wrong tool and a skill, permission rule, or subagent fits better.
- Headless Claude Code in CI and cron — carry these guardrails into
-pmode, where PreToolUse is the only gate left. - Reviewing AI diffs without rubber-stamping — hooks verify mechanics; you still own the judgment on what merges.
- External: the official hooks reference and hooks guide document every Claude Code hooks event and JSON field we build on here.