What hooks are
A hook is an entry in settings.json that names an event, an optional matcher and a shell command. When the event happens, Claude Code runs the command, passes it a JSON description on standard input, and reads its exit code and output.
| Event | Runs | Useful for |
|---|---|---|
PreToolUse | Before a tool call | Blocking or approving commands and file access |
PostToolUse | After a tool call succeeds | Formatting, linting, logging |
UserPromptSubmit | When you send a prompt | Adding context, checking prompts |
Notification | When Claude needs your attention | Desktop notifications |
Stop | When Claude finishes responding | Running tests, a final check |
SessionStart | When a session starts or resumes | Loading context |
There are more, including SubagentStop, PreCompact and SessionEnd. Type /hooks in Claude Code to see the full list and what you have configured.
Where hooks live
~/.claude/settings.json: for you, in every project..claude/settings.json: for the project, committed and shared with your team..claude/settings.local.json: for the project, only on your machine.
A first PreToolUse hook
This entry runs a script before every Bash command. The matcher is the tool name: Bash, Read, Edit, Write, WebFetch and so on. Use Edit|Write for several, or * for all.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/guard.sh", "timeout": 15 }
]
}
]
}
}What the hook receives
{
"session_id": "…",
"transcript_path": "/Users/you/.claude/projects/…/….jsonl",
"cwd": "/Users/you/code/app",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "cat .env.local" }
}For Read, Edit and Write the path is in tool_input.file_path. For WebFetch the address is in tool_input.url.
How a hook blocks
- Exit code 0: the tool call goes ahead, subject to the normal permission rules.
- Exit code 2: the tool call is blocked, and whatever the hook wrote to standard error is shown to Claude so it can change course.
- Any other exit code: an error is shown to you and the tool call goes ahead. A crashing hook doesn't protect you.
For finer control, exit 0 and print JSON with a decision of allow, deny or ask:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
"permissionDecisionReason": "git reset --hard throws away uncommitted work"
}
}Example: block secret reads and dangerous commands
#!/bin/bash
# Needs jq: brew install jq
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command // empty')
deny() { echo "Blocked by guard.sh: $1" >&2; exit 2; }
# Secret files, but not templates such as .env.example
if echo "$cmd" | grep -Eq '(^|[ /"'"'"'])\.env(\.[A-Za-z0-9_-]+)?($|[ "'"'"'])' \
&& ! echo "$cmd" | grep -Eq '\.env\.(example|sample|template)'; then
deny "reads a .env file"
fi
# Environment dumps
echo "$cmd" | grep -Eq '(^|[;&|] *)(env|printenv|export -p)( |$)' && deny "prints environment variables"
# Destructive commands
echo "$cmd" | grep -Eq 'rm +-[a-zA-Z]*r[a-zA-Z]* +(~|/|\$HOME)( |/?$)' && deny "deletes your home folder"
echo "$cmd" | grep -Eq 'git +(reset +--hard|clean +-[a-z]*f)' && deny "throws away uncommitted work"
exit 0Make it executable with chmod +x ~/.claude/hooks/guard.sh, then restart Claude Code: hooks are read when a session starts.
Test and debug
echo '{"tool_name":"Bash","tool_input":{"command":"cat .env.local"}}' | ~/.claude/hooks/guard.sh
echo "exit code: $?"- Use absolute paths, or
$CLAUDE_PROJECT_DIRfor scripts kept in the project. - Run
claude --debugto see which hooks matched and what they returned. - Keep hooks fast. They run before every matching tool call.
- Remember that a hook runs with your full permissions. Read any hook before you install it.
Where regex hooks fall short
The script above stops cat .env. It doesn't stop python3 -c "import os; print(os.environ)", node -e, awk with ENVIRON, a source .env && echo $KEY, a file read through Read instead of Bash, or a secret file that isn't called .env. Covering those takes a matcher of *, per-tool logic and a much longer rule list, and it has to be repeated for every agent you use, each with its own hook format.
A ready-made hook
That longer rule list is what the Rollcage Guard is. Turning it on installs a PreToolUse hook on every tool that knows the secret files of each stack, catches environment dumps and one-line scripts in any language, checks network destinations, and blocks or asks about destructive commands. It installs the equivalent hooks for Codex CLI, Cursor and Gemini CLI, logs every block, and has a tester where you can type a command and see the decision.
Whoever writes it, a hook only sees tool calls. It can't see what a script does once it's running. Pair hooks with permission rules, the sandbox and a way to undo.
Questions
Do hooks run in YOLO mode?
Yes. Hooks run before tool calls in every permission mode, including when prompts are skipped with --dangerously-skip-permissions. That makes a blocking PreToolUse hook one of the few controls that still applies there.
Why isn't my hook running?
The usual causes: the session was started before you added it, the settings file has a JSON error, the matcher doesn't match the tool name exactly (it's case-sensitive), or the script isn't executable. Run claude --debug to see what was loaded.
What's the difference between a hook and a permission rule?
A permission rule matches a tool and a pattern. A hook is a program, so it can look inside the command, check several things at once and explain its decision to Claude. Use rules for simple cases and a hook when the rule would need logic.
Can a hook change what Claude does, not just block it?
Yes. The text a blocking hook writes is given to Claude as feedback, so a clear message such as 'use .env.example instead' usually gets you the right next step.