Claude Code hooks: examples that block, with tests
Claude Code hooks are commands that run at fixed points of a session: before a tool runs, after it runs, when a prompt is submitted, when the session stops. They are how you turn “the agent should not do this” into something that is actually checked. This article shows Claude Code hooks examples that block what must be blocked, how to test them, and the mistakes behind most “hook error” searches.
Everything below follows the official hooks reference and the permissions documentation. We run Claude Code with hooks every day for our own studio; one of ours is a Stop hook that warns when a session’s token use passes a limit.
Where Claude Code hooks are configured
Hooks live in settings files:
| File | Applies to | Shared |
|---|---|---|
~/.claude/settings.json |
all your projects | no |
.claude/settings.json |
one project | yes, can be committed |
.claude/settings.local.json |
one project | no |
Entries from these files are merged, not replaced. Plugins, skills and subagents can bring their own hooks too.
A hook entry has three levels: the event, a matcher group, and the handlers:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "python3 ${CLAUDE_PROJECT_DIR}/.claude/hooks/block_rm.py" }
]
}
]
}
}
CLAUDE_PROJECT_DIR is the project root where the session started. On Windows, call Python the way your system expects.
A PreToolUse hook that blocks a risky command
A PreToolUse hook gets the planned tool call as JSON on standard input: tool_name, tool_input and a few session fields. For Bash, tool_input.command is the command text.
#!/usr/bin/env python3
import json, re, sys
data = json.load(sys.stdin)
cmd = data.get("tool_input", {}).get("command", "")
if re.search(r"\brm\s+-\w*(rf|fr)\w*\b", cmd):
print("Blocked: recursive force delete. Ask the user to run it.", file=sys.stderr)
sys.exit(2)
sys.exit(0)
The rule that matters: exit code 2 blocks the call, and stderr becomes the reason Claude sees. Exit 0 lets it through. Any other code is a non-blocking error: the action goes ahead and you only get a notice.
A text pattern catches the usual form of a command, not every form: this one stops rm -rf and rm -fr, but rm -r -f with separate flags gets through. That is why each rule needs tests, and why permission rules and the sandbox exist next to hooks.
If you prefer structured output, a hook can exit 0 and print JSON with hookSpecificOutput, where hookEventName is PreToolUse and permissionDecision is allow, deny, ask or defer, with a permissionDecisionReason.
The PreToolUse matcher, without surprises
The matcher filters on the tool name:
Bash, or a list likeEdit|Write— exact names.- Anything with other characters is a regular expression, unanchored:
^Notebook,mcp__memory__.*. - MCP tools are named
mcp__<server>__<tool>. A matcher ofmcp__memoryalone matches nothing; you needmcp__memory__.*. *, an empty string, or no matcher means every tool.
There is also an if field that uses permission-rule syntax, such as Bash(git *). The documentation calls it best-effort: for a hard allow or deny, use the permission system.
Test every hook: one call blocked, one call allowed
A hook you never tested is a hook you hope works. The cheapest test feeds it the same JSON Claude Code would send and checks the exit code:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf build"}}' | python3 .claude/hooks/block_rm.py; echo "exit $?" # expect 2
echo '{"tool_name":"Bash","tool_input":{"command":"ls build"}}' | python3 .claude/hooks/block_rm.py; echo "exit $?" # expect 0
Our rule for every hook rule: one action that must be blocked and one that must pass, both in the test file. The second test catches the other common failure: a pattern so broad it blocks normal work.
Why a PreToolUse hook error does not stop anything
The usual causes, all from the reference:
- Exit code 1 instead of 2. It shows an error notice and the tool still runs.
- Timeout. A PreToolUse command hook that times out does not block; the call continues through the normal permission flow. Command hooks default to 600 seconds.
- Matcher that never matches, like
mcp__memorywithout.*. - A failing script. By default a failing hook does not block. Recent versions support
"onFailure": "block"(v2.1.295 or later) to block when a command hook fails, times out or returns invalid output.
Hooks, permission rules and skills: which does what
- Permission rules (
allow,ask,denyin settings.json) are checked in that order: deny, then ask, then allow. A deny rule is applied even when a PreToolUse hook returnsallow. - A blocking hook (exit 2) stops the call before permission rules are evaluated, so it also wins over an allow rule.
- Skills are instructions and resources the agent loads when needed. They guide; they do not enforce.
For confidential folders, start with deny rules such as Read(./secrets/**). Know their limit: they cover the built-in file tools and commands like cat, head and tail, but not grep -r run from the folder, or a script that opens files itself. A PreToolUse hook can inspect the full command text for those cases, and for enforcement at the operating-system level the documentation points to the sandbox.
When to hand it over
If your agent setup grew fast and you are not sure which rules actually hold, we map the agents, write the hook rules with tests and show the test run, not just the config. You can see how our own system is put together on the work page.
Questions
Why does my PreToolUse hook not block anything?
Most often it exits with code 1. Only exit code 2 blocks the tool call; any other non-zero code is a non-blocking error and the action goes ahead. A hook that times out does not block either.
Do I need hooks if I already have permission deny rules?
For simple rules, deny rules come first: they are checked even when a hook returns allow. Hooks are for logic a pattern cannot express, such as inspecting the whole command text before it runs.
Do Read deny rules stop cat and grep in Bash?
They cover the built-in file tools and file commands Claude Code recognises in Bash, such as cat, head and tail. They do not cover a command that reads files without naming them, like grep -r from the folder, or a script that opens files itself. For that, the documentation points to the sandbox.
Where do Claude Code hooks live?
In settings files: ~/.claude/settings.json for all your projects, .claude/settings.json for one project and your team, or .claude/settings.local.json for you only. Entries from several files are merged.