Claude Code PreToolUse Hook Examples (Copy-Paste)
Six PreToolUse hooks we piped dummy JSON through: block rm -rf /, secret files, force pushes and writes outside the project, ask on migrations, log commands.
- Six copy-paste PreToolUse hooks, each with its settings.json entry, script, and the exit code and stderr we got from dummy JSON (Claude Code 2.1.303, 2026-10-19).
- Only PreToolUse can stop a command before it runs; exit 2 blocks, while exit 1 or a missing script doesn't unless onFailure is block.
- When hooks disagree, deny beats defer, defer beats ask, and ask beats allow; defer only works in non-interactive -p runs.
These are six PreToolUse hooks you can paste into a project. Each one comes with its settings.json entry, the script, and the exit code and stderr we got by piping dummy JSON into it. We tested on macOS with Claude Code 2.1.303 and jq 1.7.1, on 2026-10-19. We ran the scripts directly with sample input, as the hooks guide suggests. We did not watch them fire inside a live Claude Code session. For why hooks beat prose rules and how to measure them, see Claude Code Hooks in Practice.
Which event to use
| Event | When it fires | What exit 2 does |
|---|---|---|
PreToolUse | After Claude builds the tool call, before it runs | Blocks the tool call |
PostToolUse | Right after a tool succeeds | Shows stderr to Claude; the tool already ran |
UserPromptSubmit | When a prompt is submitted, before Claude processes it | Blocks the prompt |
(source: https://code.claude.com/docs/en/hooks, checked 2026-10-19)
Only PreToolUse can stop a command before it happens. Every example below uses it.
Exit codes and permissionDecision
| What the hook does | Result on PreToolUse |
|---|---|
exit 0, no JSON | No decision; the normal permission flow applies |
exit 0 with permissionDecision JSON on stdout | The decision applies: allow, deny, ask or defer |
exit 2 | Blocks the call; stderr is the reason Claude sees |
| Any other exit code, including 1 | Non-blocking error; the tool runs |
exit 1 does not block. A missing or non-executable script doesn’t block either, unless you set "onFailure": "block" (Claude Code v2.1.295 or later). When several hooks disagree, deny wins over defer, defer over ask, and ask over allow. defer only works in non-interactive -p runs (source: https://code.claude.com/docs/en/hooks, checked 2026-10-19).
The input arrives on stdin. The fields these scripts read are tool_name and tool_input. For Read, Edit and Write, tool_input.file_path is always absolute, because Claude Code expands ~ and relative paths before the hook runs (same source).
The settings.json for all six
Save the scripts under .claude/hooks/, run chmod +x on them, and put this in .claude/settings.json. We checked the file with jq -e '.hooks.PreToolUse | length', which printed 3.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rm-root.sh" },
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-force-push.sh" },
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/log-bash.sh" }
]
},
{
"matcher": "Read|Edit|Write",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-secrets.sh" }
]
},
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/stay-in-project.sh" },
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/ask-migrations.sh" }
]
}
]
}
}
Bash and Edit|Write are exact matches. Matchers are case-sensitive, so bash matches nothing. Matching hooks run in parallel. The docs recommend wrapping $CLAUDE_PROJECT_DIR in double quotes in shell form (source: https://code.claude.com/docs/en/hooks, checked 2026-10-19).
Block rm -rf on / or home
#!/usr/bin/env bash
# .claude/hooks/block-rm-root.sh (matcher: Bash)
cmd=$(jq -r '.tool_input.command // ""')
# rm with an -r/-R flag whose target is /, /*, ~ or $HOME
re='(^|[;&|] *)rm( +-[a-zA-Z]+)* +-[a-zA-Z]*[rR][a-zA-Z]*( +-[a-zA-Z]+)* +(/\*?|~/?|\$HOME/?)( |;|&|$)'
if printf '%s' "$cmd" | grep -Eq "$re"; then
echo "Blocked: recursive rm on / or home" >&2
exit 2
fi
exit 0
tool_input.command | Exit | stderr |
|---|---|---|
rm -rf / | 2 | Blocked: recursive rm on / or home |
rm -r -f / | 2 | same |
npm run clean && rm -fr ~ | 2 | same |
rm -rf ./build | 0 | (none) |
rm -rf /tmp/cache | 0 | (none) |
A regex never covers every spelling. Treat this as a seatbelt, not a lock. For a hard rule, use a deny permission rule as well; the docs call hook if filtering best-effort for the same reason.
Block reads and edits of secret files
#!/usr/bin/env bash
# .claude/hooks/protect-secrets.sh (matcher: Read|Edit|Write)
path=$(jq -r '.tool_input.file_path // ""')
name=$(basename "$path")
case "$name" in
.env|.env.*|*.pem|*.key|id_rsa|id_ed25519)
[ "$name" = ".env.example" ] && exit 0
echo "Blocked: $name looks like a secret file" >&2
exit 2 ;;
esac
exit 0
$ echo '{"tool_name":"Read","tool_input":{"file_path":"/home/dev/my-app/.env"}}' | .claude/hooks/protect-secrets.sh
Blocked: .env looks like a secret file
exit=2
$ echo '{"tool_name":"Edit","tool_input":{"file_path":"/home/dev/my-app/.env.production"}}' | .claude/hooks/protect-secrets.sh
Blocked: .env.production looks like a secret file
exit=2
.env.example and src/app.ts both returned exit 0. One gap: files you add with @ in your prompt are inserted without a tool call, so no PreToolUse hook fires for them. A Read deny rule covers that case (source: https://code.claude.com/docs/en/hooks, checked 2026-10-19). More on this in Keep Secrets Away from Claude Code.
Block git push —force
#!/usr/bin/env bash
# .claude/hooks/no-force-push.sh (matcher: Bash)
cmd=$(jq -r '.tool_input.command // ""')
if printf '%s' "$cmd" | grep -Eq 'git +push\b.*( -f( |$)|--force( |$))'; then
echo "Blocked: force push. Use --force-with-lease and ask a human." >&2
exit 2
fi
exit 0
tool_input.command | Exit |
|---|---|
git push --force origin main | 2 |
git push -f | 2 |
git push --force-with-lease origin feature/login | 0 |
git push origin feature/login | 0 |
Block writes outside the project
#!/usr/bin/env bash
# .claude/hooks/stay-in-project.sh (matcher: Edit|Write)
path=$(jq -r '.tool_input.file_path // ""')
root="${CLAUDE_PROJECT_DIR:?}"
case "$path" in
"$root"/*) exit 0 ;;
esac
echo "Blocked: write outside the project: $(basename "$path")" >&2
exit 2
With CLAUDE_PROJECT_DIR=/home/dev/my-app:
tool_input.file_path | Exit | stderr |
|---|---|---|
/home/dev/my-app/src/login.ts | 0 | (none) |
/home/dev/.zshrc | 2 | Blocked: write outside the project: .zshrc |
/home/dev/my-app-old/src/a.ts | 2 | Blocked: write outside the project: a.ts |
The trailing / in "$root"/* matters. Without it, the sibling directory my-app-old would pass as “inside” my-app.
Ask before editing migrations
This one doesn’t block. It returns JSON with permissionDecision: "ask", so a human confirms the edit.
#!/usr/bin/env bash
# .claude/hooks/ask-migrations.sh (matcher: Edit|Write)
path=$(jq -r '.tool_input.file_path // ""')
case "$path" in
*/db/migrations/*)
jq -n --arg p "$path" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"ask",permissionDecisionReason:("Migration file: " + ($p|split("/")|last))}}' ;;
esac
exit 0
$ echo '{"tool_name":"Edit","tool_input":{"file_path":"/home/dev/my-app/db/migrations/20261019_add_users.sql"}}' | .claude/hooks/ask-migrations.sh
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
"permissionDecisionReason": "Migration file: 20261019_add_users.sql"
}
}
exit=0
For ask, the reason appears in the permission prompt. permissionDecision must sit inside hookSpecificOutput; at the top level it is ignored without an error (sources: https://code.claude.com/docs/en/hooks and https://code.claude.com/docs/en/hooks-guide, checked 2026-10-19). Build the JSON with jq rather than string concatenation so quotes are escaped.
Log every command and let it through
#!/usr/bin/env bash
# .claude/hooks/log-bash.sh (matcher: Bash)
log="${CLAUDE_PROJECT_DIR:?}/.claude/bash-commands.jsonl"
jq -c '{ts: (now|todate), cmd: .tool_input.command}' >> "$log"
exit 0
$ cat .claude/bash-commands.jsonl
{"ts":"2026-10-19T05:06:52Z","cmd":"npm test"}
{"ts":"2026-10-19T05:06:52Z","cmd":"git status"}
Both runs returned exit 0 with no stderr. Add .claude/bash-commands.jsonl to .gitignore.
Summary
- Use
PreToolUseto stop a tool call;PostToolUseruns after the tool has already acted. - Block with
exit 2and a reason on stderr.exit 1does not block. - Return
permissionDecision: "ask"insidehookSpecificOutputto hand the call to a human. - Pipe dummy JSON into each script and check the exit code before you trust it.
- Back regex hooks with permission rules; see Permission Design for AI Agents.