Up to here, every surface has been guidance. A rule asks. A skill suggests. A subagent gets told what to do and usually listens. All of those paths run through a model that weighs your instruction against everything else in its context, and on a bad day, your instruction loses.
A hook does not ask. Claude Code runs it at a fixed moment in a session: before a tool call, when Claude tries to finish its turn, and a dozen other points you will meet as you wire more of them. Those moments are the lifecycle events. Whether the model agrees with your hook is irrelevant. This is also the surface where one wrong return value gives you a policy that silently does nothing, and you will not get a warning.
Your hook sends its answer back two ways, and neither will feel familiar if you have never written a command-line program. The first is the exit code. When a program finishes, it reports a number to whoever started it. By long convention, zero means success and anything else means failure. Claude Code reads that number off your hook.
The second is what the hook printed. Programs write to two separate streams: stdout (standard output), where a normal print lands, and stderr (standard error), where error messages go. Claude Code treats those streams differently, so the stream you choose changes what happens next.
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, the usual path where Claude Code checks your rules and asks you when it must. Your hook can deny a call, but staying silent does not approve it. Exit 0 means your hook stepped out of the conversation; it did not vote yes.
Exit 1 does not block. Exit 2 blocks. Exit 1 is what most programs return when they fail. 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 code that actually blocks. WorktreeCreate is the single exception: any non-zero exit aborts worktree creation. One caveat, and this trips people, Claude Code reads JSON output fields from stdout on every exit code, not only 0. 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. To deny from a PermissionRequest hook, emit a decision object on stdout. No exit code works there.
if is a performance filter, not a gate. It runs 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, no &&, no ||, no list syntax. When Claude Code cannot parse the Bash command well enough to compare it, the filter fails open: it errs toward running your hook rather than skipping it, whatever the pattern says. Use if to avoid spawning a process on every unrelated tool call. Do not use it to enforce anything; 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. That is why we get these four facts straight before writing a line of code.
The three levels of configuration
Now we need the shape of the config. 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 programs that actually run. One or more, and all matching hooks in a group run in parallel, not 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 tool names 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, that 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, 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
Here is the whole interface. Claude Code describes the event to your hook as JSON on stdin (standard input), the stream a program reads when something feeds it text. The hook reads that, decides, and answers on stdout or with its exit code. That is it.
These are the real files from the lab, not toy examples. Together they cover both decision surfaces you will actually 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 the split. 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.
The same hooks in four languages
The lab keeps every hook in Python, Bash, PowerShell, and Node. run_tests.sh runs all four against the same stdin fixtures. The tabs below are read from those files at build time, so the code you see here is the code the suite passed. Pick a language once and every tab group on the site follows.
#!/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. #!/usr/bin/env bash
# PreToolUse (Read|Bash): deny access to secret files. JSON decision surface.
set -euo pipefail
input=$(cat)
tool=$(jq -r '.tool_name' <<<"$input")
hit=""
case "$tool" in
Read)
path=$(jq -r '.tool_input.file_path // ""' <<<"$input"); path="${path//\\//}" # normalize Windows separators
if [[ "$path" =~ (^|/)(\.env(\.[A-Za-z0-9_-]+)?|id_rsa|id_ed25519|.*\.pem|credentials\.json)$ && "$path" != *.env.example ]]; then hit="$path"; fi ;;
Bash)
cmd=$(jq -r '.tool_input.command // ""' <<<"$input")
if [[ "$cmd" =~ (^|[[:space:]/\'\"])\.env(\.[A-Za-z0-9_-]+)?([^A-Za-z0-9_]|$) && "$cmd" != *".env.example"* ]]; then hit="$cmd"; fi ;;
esac
if [[ -n "$hit" ]]; then
jq -nc --arg r "Secret file access blocked ($hit). Use .env.example for variable names." \
'{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'
fi
exit 0 # silence != approve; the normal permission flow still applies # PreToolUse (Read|Bash): deny access to secret files. JSON decision surface.
$in = [Console]::In.ReadToEnd() | ConvertFrom-Json
$hit = $null
switch ($in.tool_name) {
'Read' {
$p = ([string]$in.tool_input.file_path) -replace '\\','/' # normalize Windows separators
if ($p -match '(^|/)(\.env(\.[\w-]+)?|id_rsa|id_ed25519|.*\.pem|credentials\.json)$' -and $p -notlike '*.env.example') { $hit = $p }
}
'Bash' {
$c = [string]$in.tool_input.command
if ($c -match '(^|[\s/''"])\.env(\.[\w-]+)?\b' -and $c -notlike '*.env.example*') { $hit = $c }
}
}
if ($hit) {
@{ hookSpecificOutput = @{ hookEventName = 'PreToolUse'; permissionDecision = 'deny'
permissionDecisionReason = "Secret file access blocked ($hit). Use .env.example for variable names." }
} | ConvertTo-Json -Depth 4 -Compress
}
exit 0 # silence != approve #!/usr/bin/env node
/**
* PreToolUse (Read|Bash): deny access to secret files on the JSON decision surface.
* It always exits 0; a denial is carried by JSON, while silence means no decision.
*/
const secret = /(^|\/)(\.env(\.[\w-]+)?|id_rsa|id_ed25519|.*\.pem|credentials\.json)$/;
const secretInCommand = /(^|[\s/'"])\.env(\.[\w-]+)?\b/;
const data = JSON.parse(await readStdin());
const tool = data.tool_name;
const input = data.tool_input ?? {};
let hit = null;
if (tool === "Read") {
// Claude can send Windows paths even when the matching rules use forward slashes.
const filePath = (input.file_path ?? "").replaceAll("\\", "/");
if (secret.test(filePath) && !filePath.endsWith(".env.example")) hit = filePath;
} else if (tool === "Bash") {
const command = input.command ?? "";
if (secretInCommand.test(command) && !command.includes(".env.example")) hit = command;
}
if (hit) {
console.log(JSON.stringify({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: `Secret file access blocked (${hit}). Use .env.example for variable names.`,
},
}));
}
async function readStdin() {
let input = "";
for await (const chunk of process.stdin) input += chunk;
return input;
} #!/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) #!/usr/bin/env bash
# PreToolUse (Bash): exit-code surface. Exit 2 + stderr blocks; Claude sees stderr.
set -euo pipefail
cmd=$(jq -r '.tool_input.command // ""')
block() { echo "Blocked ($1): \`$cmd\`. Explain what you need and ask the user." >&2; exit 2; }
[[ "$cmd" =~ (^|[[:space:];&|])rm[[:space:]]+-[A-Za-z]*(rf|fr) ]] && block "recursive force delete"
[[ "$cmd" =~ git[[:space:]]+push.*(--force|[[:space:]]-f)([[:space:]]|$) ]] && block "force push"
[[ "$cmd" =~ git[[:space:]]+reset[[:space:]]+--hard ]] && block "hard reset"
[[ "$cmd" =~ [Dd][Rr][Oo][Pp][[:space:]]+([Tt][Aa][Bb][Ll][Ee]|[Dd][Aa][Tt][Aa][Bb][Aa][Ss][Ee]) ]] && block "SQL drop"
exit 0 # PreToolUse (Bash|PowerShell): exit-code surface. Exit 2 + stderr blocks; Claude sees stderr.
$in = [Console]::In.ReadToEnd() | ConvertFrom-Json
$cmd = [string]$in.tool_input.command
$rules = @(
@{ re = '(^|[\s;&|])rm\s+-[a-zA-Z]*(rf|fr)\b'; label = 'recursive force delete' },
@{ re = 'Remove-Item\b.*-Recurse\b.*-Force|Remove-Item\b.*-Force\b.*-Recurse'; label = 'recursive force delete' },
@{ re = 'git\s+push\b.*(--force|\s-f)(\s|$)'; label = 'force push' },
@{ re = 'git\s+reset\s+--hard'; label = 'hard reset' },
@{ re = '(?i)DROP\s+(TABLE|DATABASE)'; label = 'SQL drop' }
)
foreach ($r in $rules) {
if ($cmd -match $r.re) {
[Console]::Error.WriteLine("Blocked ($($r.label)): ``$cmd``. Explain what you need and ask the user.")
exit 2
}
}
exit 0 #!/usr/bin/env node
/**
* PreToolUse (Bash): block known destructive commands on the exit-code surface.
* A match writes the explanation to stderr and exits 2; safe commands stay silent and exit 0.
*/
const dangerous = [
[/\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\b/, "recursive force delete"],
[/\bgit\s+push\s+.*--force\b|\bgit\s+push\s+-f\b/, "force push"],
[/\bgit\s+reset\s+--hard\b/, "hard reset"],
[/\bDROP\s+(TABLE|DATABASE)\b/i, "SQL drop"],
];
const data = JSON.parse(await readStdin());
const command = data.tool_input?.command ?? "";
for (const [pattern, label] of dangerous) {
if (pattern.test(command)) {
process.stderr.write(`Blocked (${label}): \`${command}\`. Explain what you need and ask the user.\n`);
process.exitCode = 2;
break;
}
}
async function readStdin() {
let input = "";
for await (const chunk of process.stdin) input += chunk;
return input;
} #!/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```",
})) #!/usr/bin/env bash
# Stop: run the test suite; block stopping until it passes. Guards against infinite loops.
set -uo pipefail
input=$(cat)
if [[ "$(jq -r '.stop_hook_active' <<<"$input")" == "true" ]]; then exit 0; fi # already sent back once
test_cmd="${HOOK_TEST_CMD:-python3 -m pytest -q}"
out=$(timeout 540 bash -c "$test_cmd" 2>&1); rc=$?
if [[ $rc -eq 124 ]]; then jq -nc '{systemMessage:"Stop hook: test suite timed out; not blocking."}'; exit 0; fi
[[ $rc -eq 0 ]] && exit 0
tail=$(printf '%s' "$out" | tail -c 3000)
jq -nc --arg t "$tail" '{decision:"block", reason:("Tests are failing. Fix them before finishing.\n```\n"+$t+"\n```")}' # Stop: run the test suite; block stopping until it passes. Guards against infinite loops.
$in = [Console]::In.ReadToEnd() | ConvertFrom-Json
if ($in.stop_hook_active) { exit 0 } # already sent Claude back once this turn
$testCmd = if ($env:HOOK_TEST_CMD) { $env:HOOK_TEST_CMD } else { 'python3 -m pytest -q' }
$job = Start-Job -ScriptBlock { param($c) Invoke-Expression "$c 2>&1" | Out-String; $LASTEXITCODE } -ArgumentList $testCmd
if (-not (Wait-Job $job -Timeout 540)) {
Stop-Job $job; @{ systemMessage = 'Stop hook: test suite timed out; not blocking.' } | ConvertTo-Json -Compress; exit 0
}
$result = Receive-Job $job; $rc = $result[-1]; $out = ($result[0..($result.Count-2)] -join '')
if ($rc -eq 0) { exit 0 }
$tail = if ($out.Length -gt 3000) { $out.Substring($out.Length - 3000) } else { $out }
@{ decision = 'block'; reason = "Tests are failing. Fix them before finishing.`n``````n$tail`n``````" } | ConvertTo-Json -Compress #!/usr/bin/env node
/**
* Stop: run the test suite and block stopping until it passes.
* The re-entrancy guard permits a second stop attempt so a failing suite cannot create a loop.
*/
import { spawnSync } from "node:child_process";
const data = JSON.parse(await readStdin());
if (!pythonTruthy(data.stop_hook_active)) {
const testCommand = process.env.HOOK_TEST_CMD ?? "python3 -m pytest -q";
const result = spawnSync(testCommand, {
shell: true,
encoding: "utf8",
timeout: 540_000,
windowsHide: true,
});
if (result.error?.code === "ETIMEDOUT") {
console.log(JSON.stringify({
systemMessage: "Stop hook: test suite timed out; not blocking.",
}));
} else if (result.status !== 0) {
// Array.from counts Unicode code points, matching Python string slicing.
const tail = Array.from(`${result.stdout ?? ""}${result.stderr ?? ""}`)
.slice(-3000)
.join("");
console.log(JSON.stringify({
decision: "block",
reason: `Tests are failing. Fix them before finishing.\n\`\`\`\n${tail}\n\`\`\``,
}));
}
}
function pythonTruthy(value) {
// Hook input is normally boolean, but preserve Python semantics for any valid JSON value.
if (Array.isArray(value)) return value.length > 0;
if (value !== null && typeof value === "object") return Object.keys(value).length > 0;
return Boolean(value);
}
async function readStdin() {
let input = "";
for await (const chunk of process.stdin) input += chunk;
return input;
} The Bash tab depends on jq, the same way the upstream reference does. The PowerShell tab targets pwsh 7 and reads $env:CLAUDE_PROJECT_DIR, never the bare variable.
Which events can block at all
You cannot block on every event. These do, on exit 2: PreToolUse, UserPromptSubmit, UserPromptExpansion, Stop, SubagentStop, TeammateIdle, TaskCreated, TaskCompleted, ConfigChange except for policy_settings, PostToolBatch, PreCompact, Elicitation, ElicitationResult, and, from v2.1.251, PreModelSwitch. 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, MessageDisplay, and PostModelSwitch.
Each name above is its own page. Every one states what arrives on stdin, what each exit code does on that event, and which JSON fields it honours; the full list groups them the same way this section does.
PostToolUse 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.
Start with block_destructive.py in .claude/hooks/, wired under PreToolUse with a Bash matcher and no if filter yet. Ask Claude to clean up a scratch directory with rm -rf. It should be refused, and the refusal message you wrote should show up in Claude’s reasoning about what to do next.
Next 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. When that works, 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 anyway. Your hook fired, your regex matched, your message went to stderr, and Claude Code deleted the directory. 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 like 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.
One more thing before we close. A hook is still not a security boundary, 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: 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, code whose answer does not depend on the model, and most of that shipped within the last year. Only Aider and Zed have nothing; 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. camelCase and PascalCase are the two capitalisation styles for a compound name, preToolUse against PreToolUse. Cursor also 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. That is the transfer story in one sentence.
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?