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

  1. The event happens. Example: the agent calls Read.
  2. Claude Code reads the hooks block in .claude/settings.json.
  3. Claude Code matches the tool name against the matcher regex.
  4. Claude Code starts the command. It sends the event payload as JSON on stdin.
  5. The script exits with a code.
Exit codeResult
0Allow. Claude Code parses stdout for JSON on events that accept it.
2Block. stderr returns to the agent as the reason.
any otherNon-blocking error. You see it. Execution continues.
Exit code 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

FileEventFunctionBlocks
pre_tool_use_secrets.pyPreToolUseRefuses a route to a credential. Refuses rm -rf.Yes
pre_tool_use_dependencies.pyPreToolUseRefuses an edit until the agent read the coupled files of that file.Yes
post_tool_use_log.pyPostToolUseAppends one JSONL line per tool call to logs/agent-actions.jsonl.No
session_start_context.pySessionStartInjects branch, uncommitted files, recent commits.No
stop_tests_must_pass.pyStopRuns TEST_COMMAND. Red tests block the stop.Yes
stop_notify.pyStopShows 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:

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

  1. Run mkdir -p .claude/hooks.
  2. Run cp skills/hooks/*.py .claude/hooks/.
  3. Copy skills/hooks/settings.json.example to .claude/settings.json. Merge the hooks block if the file exists.
  4. Set TEST_COMMAND in stop_tests_must_pass.py:25. The default is python -m pytest -q.
  5. Copy dependencies.example.json to .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

Hooks run real code with your credentials and no sandbox. Read every hook first.

Other handler types

Every hook here is a command hook. Four other types use the same settings.json shape:

TypeUse
promptSend the event to a fast model. Get allow or deny back. For judgments a regex cannot make.
agentSpawn a subagent with tools. Slow and expensive. Reserve it for a gate worth a minute.
httpPOST the event to a server. One policy for a whole organization.
mcp_toolCall a tool on a connected MCP server.