Hooks — how they work
Reference for skills/hooks/. A hook is a script. The harness runs the script on a lifecycle event. The model does not choose to run it. That is why a hook can guarantee a behavior, and a rule cannot.
The mechanism
- The event happens. Example: the agent calls
Read. - Claude Code reads the
hooksblock in.claude/settings.json. - Claude Code matches the tool name against the
matcherregex. - Claude Code starts the command. It sends the event payload as JSON on stdin.
- The script exits with a code.
| Exit code | Result |
|---|---|
0 | Allow. Claude Code parses stdout for JSON on events that accept it. |
2 | Block. stderr returns to the agent as the reason. |
| any other | Non-blocking error. You see it. Execution continues. |
1 does not block. This is the trap named in skills/hooks/README.md.Only some events block: PreToolUse, UserPromptSubmit, Stop, SubagentStop, PreCompact, PostToolBatch. PostToolUse cannot block, because the tool already ran.
The rule is short: pre = gate, post = log.
The six hooks in this folder
| File | Event | Function | Blocks |
|---|---|---|---|
pre_tool_use_secrets.py | PreToolUse | Refuses a route to a credential. Refuses rm -rf. | Yes |
pre_tool_use_dependencies.py | PreToolUse | Refuses an edit until the agent read the coupled files of that file. | Yes |
post_tool_use_log.py | PostToolUse | Appends one JSONL line per tool call to logs/agent-actions.jsonl. | No |
session_start_context.py | SessionStart | Injects branch, uncommitted files, recent commits. | No |
stop_tests_must_pass.py | Stop | Runs TEST_COMMAND. Red tests block the stop. | Yes |
stop_notify.py | Stop | Shows a desktop notification. | No |
All six fail open. Any unexpected error exits 0. A bug in a hook cannot stop your session.
Detail per hook
pre_tool_use_secrets.py
The hook blocks three routes:
- The file route. The regex
\.env\b|\.pem$|\.key$|id_rsa|id_ed25519|\.ssh/|\.aws/credentials|\.netrc|credentials\.jsonmatches the path..env.examplestays allowed. - The environment route. The patterns match
printenv, a bareenv,echo $API_KEY,os.environ,process.env,ENV[. - The shell route.
_normalize()removes quote characters first. A split-quote spelling of a filename cannot pass.
Known gap: the agent can write a script that reads the environment, then run the script. The path check does not see the content.
pre_tool_use_dependencies.py
The hook reads .claude/hooks/dependencies.json. Each key is an fnmatch glob for the file under edit. Each value is a list of files the agent must read first. The hook stores the read files per session in a SHA1-named JSON file under the OS temp directory, in claude-hook-deps/. Set MODE = "context" at line 37 to inject the file contents instead of blocking. Without the config file the hook allows everything.
post_tool_use_log.py
The hook takes an exclusive lock on a separate .lock file before each append. Parallel tool calls otherwise lose lines. The measured loss was 4 lines in 128 calls.
stop_tests_must_pass.py
The hook reads stop_hook_active from the payload at line 189. It exits 0 when that flag is true. This stops the block loop. Claude Code caps the loop at 8 consecutive blocks.
Install
- Run
mkdir -p .claude/hooks. - Run
cp skills/hooks/*.py .claude/hooks/. - Copy
skills/hooks/settings.json.exampleto.claude/settings.json. Merge thehooksblock if the file exists. - Set
TEST_COMMANDinstop_tests_must_pass.py:25. The default ispython -m pytest -q. - Copy
dependencies.example.jsonto.claude/hooks/dependencies.json. Write your own couplings.
Test examples
Run each command in Git Bash. Check the exit code.
1. Secrets hook — must BLOCK, exit 2
echo '{"session_id":"t","cwd":".","tool_name":"Read","tool_input":{"file_path":".env"}}' \
| uv run .claude/hooks/pre_tool_use_secrets.py; echo "exit=$?"
2. Secrets hook — must ALLOW, exit 0
echo '{"session_id":"t","cwd":".","tool_name":"Read","tool_input":{"file_path":"README.md"}}' \
| uv run .claude/hooks/pre_tool_use_secrets.py; echo "exit=$?"
3. Template file — must ALLOW, exit 0
echo '{"session_id":"t","cwd":".","tool_name":"Read","tool_input":{"file_path":".env.example"}}' \
| uv run .claude/hooks/pre_tool_use_secrets.py; echo "exit=$?"
4. Environment dump — must BLOCK, exit 2
echo '{"session_id":"t","cwd":".","tool_name":"Bash","tool_input":{"command":"printenv | grep KEY"}}' \
| uv run .claude/hooks/pre_tool_use_secrets.py; echo "exit=$?"
5. Split-quote bypass attempt — must BLOCK, exit 2
echo '{"session_id":"t","cwd":".","tool_name":"Bash","tool_input":{"command":"cat \".en\"\"v\""}}' \
| uv run .claude/hooks/pre_tool_use_secrets.py; echo "exit=$?"
6. Recursive delete — must BLOCK, exit 2
echo '{"session_id":"t","cwd":".","tool_name":"Bash","tool_input":{"command":"rm -rf build/"}}' \
| uv run .claude/hooks/pre_tool_use_secrets.py; echo "exit=$?"
7. Dependencies hook — must BLOCK, exit 2
Create the config first.
mkdir -p .claude/hooks
cat > .claude/hooks/dependencies.json <<'CFG'
{"globs": {"app/api/routes/*.py": ["app/api/schemas.py"]}}
CFG
echo '{"session_id":"d1","cwd":".","tool_name":"Edit","tool_input":{"file_path":"app/api/routes/users.py"}}' \
| uv run .claude/hooks/pre_tool_use_dependencies.py; echo "exit=$?"
8. Dependencies hook — must ALLOW after the read, exit 0
Use the same session_id.
echo '{"session_id":"d1","cwd":".","tool_name":"Read","tool_input":{"file_path":"app/api/schemas.py"}}' \
| uv run .claude/hooks/pre_tool_use_dependencies.py; echo "exit=$?"
echo '{"session_id":"d1","cwd":".","tool_name":"Edit","tool_input":{"file_path":"app/api/routes/users.py"}}' \
| uv run .claude/hooks/pre_tool_use_dependencies.py; echo "exit=$?"
9. Log hook — must write one line, exit 0
echo '{"session_id":"t","cwd":".","tool_name":"Read","tool_input":{"file_path":"README.md"}}' \
| uv run .claude/hooks/post_tool_use_log.py; echo "exit=$?"
cat logs/agent-actions.jsonl
10. Session context — must print JSON with hookSpecificOutput
echo '{"session_id":"t","cwd":".","source":"startup"}' \
| uv run .claude/hooks/session_start_context.py; echo "exit=$?"
11. Stop hook loop guard — must ALLOW, exit 0
echo '{"session_id":"t","cwd":".","stop_hook_active":true}' \
| uv run .claude/hooks/stop_tests_must_pass.py; echo "exit=$?"
12. Stop hook, both directions
echo '{"session_id":"t","cwd":".","stop_hook_active":false}' \
| uv run .claude/hooks/stop_tests_must_pass.py; echo "exit=$?"
Expect exit 0 with a green suite. Break one test. Repeat the command. Expect exit 2. Exit 2 in both states means your test command does not resolve. Read the venv note in skills/hooks/README.md.
Warnings before you install
- An
@filemention in your prompt attaches the file without a tool call. NoPreToolUsehook fires. The guard covers what the agent reaches for, not what you hand it. additionalContextmust sit insidehookSpecificOutput. At the top level Claude Code ignores it and prints no warning.- Hooks run in a non-interactive shell. Your
~/.bashrcdoes not load. A tool that reachesPATHthrough your shell profile fails inside a hook. - Debug with
claude --debug. SetCLAUDE_CODE_DEBUG_LOG_LEVEL=verboseto see matcher counts.
Other handler types
Every hook here is a command hook. Four other types use the same settings.json shape:
| Type | Use |
|---|---|
prompt | Send the event to a fast model. Get allow or deny back. For judgments a regex cannot make. |
agent | Spawn a subagent with tools. Slow and expensive. Reserve it for a gate worth a minute. |
http | POST the event to a server. One policy for a whole organization. |
mcp_tool | Call a tool on a connected MCP server. |