Every surface so far has been guidance. A rule asks. A skill suggests. A subagent is told what to do and generally does it. All of them route through a model that is weighing your instruction against everything else in its context, and on a bad day it loses that argument.
A hook does not ask. It is a program Claude Code runs at a fixed moment in a session, such as before a tool call or when Claude tries to finish its turn. Those moments are the lifecycle events. Whether the model agrees with your hook is not a factor. It is also the surface where a small misunderstanding of a return value gets you a policy that silently does nothing.
Two things carry the answer back, and neither of them will be familiar if you have not written command-line programs before. The first is the exit code. Every program reports a number to whoever started it when it finishes, and by long convention zero means it succeeded and anything else means it failed. Claude Code reads that number off your hook.
The second is what the hook printed. A program writes to two separate output streams: stdout, short for standard output, where a normal print lands, and stderr, standard error, where error messages go. Claude Code treats them differently, so which stream you print to changes what happens.
Four facts to get right before you write anything
Exit 0 with no output is no decision. The docs are unambiguous: the tool call continues through the normal permission flow, which is the usual path where Claude Code checks your rules and asks you if it has to. Your hook can deny a call, but staying silent does not approve it. A hook that exits 0 has removed itself from the conversation, not voted yes.
Exit 1 does not block. Exit 2 blocks. Exit 1 is what most programs return when they fail, and Claude Code treats it as an error that does not stop anything, then proceeds with the action anyway. If your hook enforces a policy, exit 2 is the one that does it. WorktreeCreate is the single exception, where any non-zero exit aborts worktree creation. There is a caveat that matters more than it first appears: Claude Code reads JSON output fields from stdout on every exit code, not only 0. So exit 1 is not inert. A hook that prints a valid JSON decision and then exits 1 has still made that decision.
PermissionRequest ignores exit 2 entirely. On that event the exit code is not honoured and the permission flow proceeds unchanged. Denying from a PermissionRequest hook means emitting a decision object on stdout. There is no exit code that works there.
if is a performance filter, not a gate. It is evaluated only on the five tool events: PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, and PermissionDenied. On every other event, a hook with if set never runs at all. It holds exactly one permission rule, with no &&, no ||, and no list syntax. And when Claude Code cannot make sense of the Bash command well enough to compare it, the filter fails open, meaning it errs toward running your hook rather than skipping it, whatever the pattern says. Use it to avoid spawning a process on every unrelated tool call. Do not use it to enforce anything, because the docs tell you outright to reach for the permission system instead.
A hook that fails the wrong way looks exactly like a hook that is working.
The three levels of configuration
Hook config nests three deep, in this order:
- The event. One of 31 lifecycle events, from
SessionStartthroughSessionEnd. - A matcher group. The matcher is the pattern that decides which tools the group applies to. Omit it, or use
"*"or"", to match everything. - The handlers. The things that actually run. One or more, and all matching hooks run at the same time rather than one after another.
Identical handlers declared in more than one settings file collapse to a single run. A plugin’s or a skill’s copy of the same handler stays separate, so a plugin hook that duplicates a user hook runs twice.
The matcher is read one of two ways, and which one depends on the characters in it. Letters, digits, _, -, spaces, ,, and | and nothing else means an exact string, or a |-separated or ,-separated list of exact strings. Any other character makes it an unanchored JavaScript regular expression. A regular expression, or regex, is a pattern where some characters stand for other text instead of themselves: . means any single character and * means “any number of the thing before me”. Unanchored means the pattern matches if it is found anywhere in the name rather than having to account for the whole name.
That distinction bites on the tool names that come from Model Context Protocol (MCP) servers, which module 10 covers. mcp__memory contains only letters and underscores, so it is matched exactly against a tool literally named mcp__memory, which does not exist. mcp__memory__.* contains a dot, becomes a regex, and matches every tool from that server. FileChanged and StopFailure use a narrower exact set still: letters, digits, _, and | only.
There are five handler types: command, http, mcp_tool, prompt, and agent. Agent hooks are marked experimental. A command handler takes command plus optional args. Those arguments go straight to the program with no shell in between, which is what “exec form” means, so shell syntax such as a pipe or a glob arrives as plain text. It also takes async, asyncRewake, and shell for choosing bash or powershell.
Timeouts
| Handler type | Default timeout |
|---|---|
command, http, mcp_tool | 600s |
prompt | 30s |
agent | 60s |
Two events lower the command default: UserPromptSubmit drops it to 30 seconds, and MessageDisplay to 10. SessionEnd hooks share a 1.5-second budget between them, which is short enough that anything doing real work there needs async, the flag that lets a handler keep running in the background instead of holding the session up, or needs to live somewhere else entirely.
Two hooks that block, two different ways
Claude Code describes the event to the hook as JSON on stdin, standard input, which is the stream a program reads from when something feeds it text. The hook reads that, decides, and answers on stdout or with its exit code. That is the entire interface between the two.
These are the real files from the lab, not simplifications. Between them they cover both decision surfaces you will use.
.claude/hooks/block_secrets.py ships to your plugin Denies through JSON and exits 0 either way. The exit code carries no meaning here.
#!/usr/bin/env python3
"""PreToolUse (Read|Bash): deny access to secret files. JSON decision surface."""
import json, re, sys
SECRET = re.compile(r"(^|/)(\.env(\.[\w-]+)?|id_rsa|id_ed25519|.*\.pem|credentials\.json)$")
SECRET_IN_CMD = re.compile(r"(^|[\s/'\"])\.env(\.[\w-]+)?\b")
data = json.load(sys.stdin)
tool, inp = data.get("tool_name"), data.get("tool_input", {})
hit = None
if tool == "Read":
path = inp.get("file_path", "").replace("\\", "/") # Windows paths arrive with backslashes
if SECRET.search(path) and not path.endswith(".env.example"):
hit = path
elif tool == "Bash":
cmd = inp.get("command", "")
if SECRET_IN_CMD.search(cmd) and ".env.example" not in cmd:
hit = cmd
if hit:
print(json.dumps({"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": f"Secret file access blocked ({hit}). Use .env.example for variable names.",
}}))
sys.exit(0) # exit 0 either way; the JSON carries the decision. Silence != approve. Read more
The permissionDecision field takes deny, allow, or ask. Returning deny is the strongest thing a PreToolUse hook can say. Do not carry this shape over to PermissionRequest: that event ignores exit 2 and denies through a top-level decision object, so the nested PreToolUse shape is read as no decision there and your policy silently does not apply. Note that the Windows path normalisation is not decoration: tool_input arrives with backslashes on Windows and the regex is written for forward slashes.
.claude/hooks/block_destructive.py ships to your plugin Blocks with exit 2. What it writes to stderr is what Claude reads back.
#!/usr/bin/env python3
"""PreToolUse (Bash): exit-code surface. Exit 2 + stderr blocks; Claude sees stderr."""
import json, re, sys
DANGEROUS = [
(re.compile(r"\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\b"), "recursive force delete"),
(re.compile(r"\bgit\s+push\s+.*--force\b|\bgit\s+push\s+-f\b"), "force push"),
(re.compile(r"\bgit\s+reset\s+--hard\b"), "hard reset"),
(re.compile(r"\bDROP\s+(TABLE|DATABASE)\b", re.I), "SQL drop"),
]
cmd = json.load(sys.stdin).get("tool_input", {}).get("command", "")
for pattern, label in DANGEROUS:
if pattern.search(cmd):
print(f"Blocked ({label}): `{cmd}`. Explain what you need and ask the user.", file=sys.stderr)
sys.exit(2)
sys.exit(0) Read more
Write the stderr message for the model, not for a log file. It is the only explanation Claude gets about why the call failed, and a message that says what to do instead produces a useful next turn rather than a retry of the same command with a different flag.
.claude/hooks/tests_must_pass.py ships to your plugin A Stop hook. Sends Claude back to work when the suite is red, and guards against looping forever.
#!/usr/bin/env python3
"""Stop: run the test suite; block stopping until it passes. Guards against infinite loops."""
import json, os, subprocess, sys
data = json.load(sys.stdin)
if data.get("stop_hook_active"):
# We already sent Claude back once this turn. Let it stop and report, or you loop forever.
sys.exit(0)
test_cmd = os.environ.get("HOOK_TEST_CMD", "python3 -m pytest -q")
try:
r = subprocess.run(test_cmd, shell=True, capture_output=True, text=True, timeout=540)
except subprocess.TimeoutExpired:
print(json.dumps({"systemMessage": "Stop hook: test suite timed out; not blocking."}))
sys.exit(0)
if r.returncode == 0:
sys.exit(0)
tail = (r.stdout + r.stderr)[-3000:]
print(json.dumps({
"decision": "block",
"reason": f"Tests are failing. Fix them before finishing.\n```\n{tail}\n```",
})) Read more
The stop_hook_active check is the whole safety mechanism. Without it, blocking a stop causes Claude to continue, finish, hit the same hook, and block again. Claude Code overrides the hook and ends the turn after 8 consecutive blocks, so you will not loop indefinitely, but you will waste eight turns finding that out.
.claude/settings.json Event, then matcher group, then handlers. The two if filters are performance, not policy.
{
"hooks": {
"PreToolUse": [
{ "matcher": "Read|Bash",
"hooks": [ { "type": "command", "command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/block_secrets.py"], "timeout": 5 } ] },
{ "matcher": "Bash",
"hooks": [ { "type": "command", "if": "Bash(rm *)", "command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/block_destructive.py"], "timeout": 5 },
{ "type": "command", "if": "Bash(git *)", "command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/block_destructive.py"], "timeout": 5 } ] }
],
"PostToolUse": [
{ "hooks": [ { "type": "command", "command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/audit_log.py"], "async": true, "timeout": 10 } ] }
],
"Stop": [
{ "hooks": [ { "type": "command", "command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/tests_must_pass.py"], "timeout": 600 } ] }
]
}
} Read more
block_destructive.py is listed twice under one matcher because the if field holds exactly one permission rule and there is no or syntax. Two handlers in the same group run in parallel, so this costs nothing in wall-clock time. Both filters could be dropped entirely and the policy would be identical, only slower.
Notice that block_secrets.py exits 0 on the deny path. On the JSON surface the exit code is not carrying the decision, so making it 2 as well would be redundant at best and confusing to the next reader at worst. block_destructive.py does the opposite: no JSON at all, exit 2, and a stderr line written in language the model can act on.
Which events can block at all
Not every event has a veto. These do, on exit 2: PreToolUse, UserPromptSubmit, UserPromptExpansion, Stop, SubagentStop, TeammateIdle, TaskCreated, TaskCompleted, ConfigChange except for policy_settings, PostToolBatch, PreCompact, Elicitation, and ElicitationResult. WorktreeCreate blocks on any non-zero code.
The rest cannot block by exit code, whatever you return: PermissionRequest, PermissionDenied, StopFailure, PostToolUse, PostToolUseFailure, Notification, SubagentStart, SessionStart, Setup, SessionEnd, CwdChanged, DirectoryAdded, FileChanged, PostCompact, WorktreeRemove, InstructionsLoaded, and MessageDisplay.
PostToolUse being on the second list is the one people trip over. By the time it fires, the write already happened. If you want to prevent something, PreToolUse is where you do it.
Write block_destructive.py into .claude/hooks/ and wire it under PreToolUse with a Bash matcher, no if filter to start with. Ask Claude to clean up a scratch directory with rm -rf. It should be refused, and the refusal message you wrote should appear in Claude’s reasoning about what to do next.
Now add block_secrets.py alongside it and ask Claude to read your .env. Same outcome, different mechanism: nothing in stderr, exit 0, and a JSON permissionDecision doing the work.
Then measure the filter. Add "if": "Bash(rm *)" to the first handler and confirm a plain ls no longer spawns the process, while rm -rf still gets blocked. Finally, copy the whole hooks object into hooks/hooks.json in your plugin directory. The format is identical.
You write a hook that catches something dangerous, and you end it the way you end every other script:
if pattern.search(cmd):
print("Refusing to run that.", file=sys.stderr)
sys.exit(1)The command runs. Your hook fired, your regex matched, your message went to stderr, and Claude Code deleted the directory anyway. Exit 1 is a non-blocking error: Claude Code notes it and proceeds with the action. Only exit 2 blocks through the code alone.
What makes this hard to catch is that the hook is not broken in any visible way. There is no error, no warning, and your stderr message may well appear somewhere in the transcript, which reads as confirmation that the block worked. The only way to see the truth is to check whether the tool actually ran.
Change the 1 to a 2 and it blocks. Then read the caveat again, because the inverse case exists: if that script had printed a valid JSON decision on stdout before exiting 1, the decision would have applied, since Claude Code reads stdout JSON on every exit code. Exit 1 with JSON is a working hook. Exit 1 without JSON is a no-op that looks like a policy.
A hook is still not a security boundary, meaning it is not something that holds when somebody is working to get past it. It runs your code with your privileges at a moment Claude Code chose, and there are ways around it: a Bash command Claude Code cannot parse makes if fail open, a command can be spelled in ways your regex does not anticipate, and hooks can be disabled by configuration. Use hooks for policy that is deterministic, meaning the same input always produces the same decision with no model judgment in between. For a hard allow or deny, the permission system is the answer, and module 11 covers it.
Better than the earlier research suggested. Seven of ten surveyed harnesses run hooks the same way, as code whose answer does not depend on the model, and most of that shipped within the last year. Only Aider and Zed have nothing, and Aider’s nearest analogs are --lint-cmd and --test-cmd.
The concept transfers completely: lifecycle event, shell command, exit code 2 blocks. The config file does not, and the event names are gratuitously different in every tool. Cursor uses camelCase in .cursor/hooks.json and adds prompt-based hooks that a language model judges rather than code, which has no Claude Code equivalent. Gemini CLI keeps Claude’s matcher-array structure but renames every event to BeforeTool, AfterTool, BeforeModel and so on. Codex CLI’s event names are near-identical to Claude’s and it decides via JSON with continue: false. Windsurf has a distinct powershell key and genuine Windows support. Cline has no config file at all: you drop a bare executable named exactly after the event into a hooks directory, and it is macOS and Linux only.
Copilot CLI is the outlier that matters. As of changelog 1.0.6 its hook configuration files “work across VS Code, Claude Code, and the CLI without modification by accepting PascalCase event names alongside camelCase”, nested matcher structure included. The JSON you write in this module is the JSON it reads.
Check yourself
- Your
PreToolUsehook detects a policy violation, prints an explanation to stderr, and exits 0. What happens to the tool call? - You want a
PermissionRequesthook to deny something. What do you return? - A hook on
SessionStarthas"if": "Bash(git *)"set. When does it run?