Issue 00 · Autumn 2026日本語 · RSS
AgentKoji

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.

TL;DR

  • 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

EventWhen it firesWhat exit 2 does
PreToolUseAfter Claude builds the tool call, before it runsBlocks the tool call
PostToolUseRight after a tool succeedsShows stderr to Claude; the tool already ran
UserPromptSubmitWhen a prompt is submitted, before Claude processes itBlocks 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 doesResult on PreToolUse
exit 0, no JSONNo decision; the normal permission flow applies
exit 0 with permissionDecision JSON on stdoutThe decision applies: allow, deny, ask or defer
exit 2Blocks the call; stderr is the reason Claude sees
Any other exit code, including 1Non-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.commandExitstderr
rm -rf /2Blocked: recursive rm on / or home
rm -r -f /2same
npm run clean && rm -fr ~2same
rm -rf ./build0(none)
rm -rf /tmp/cache0(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.commandExit
git push --force origin main2
git push -f2
git push --force-with-lease origin feature/login0
git push origin feature/login0

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_pathExitstderr
/home/dev/my-app/src/login.ts0(none)
/home/dev/.zshrc2Blocked: write outside the project: .zshrc
/home/dev/my-app-old/src/a.ts2Blocked: 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