PreToolUse hookの例集
ダミーのJSONで確かめたPreToolUse hookを6本載せます。rm -rf /や秘密ファイル、強制pushの止め方が分かります。
- そのまま貼れる PreToolUse hook を6本、settings.json、スクリプト、ダミーの JSON での exit code と stderr 付きでまとめました(Claude Code 2.1.303、2026-10-19)。
- 実行の前にコマンドを止められるのは PreToolUse だけです。exit 2 で止まり、exit 1 やスクリプトが見つからない場合は onFailure: block がなければ止まりません。
- 複数の hook の判断が分かれたら deny、defer、ask、allow の順に強く、defer が効くのは -p の非対話の実行だけです。
そのままプロジェクトに貼って使える PreToolUse hook を6本まとめました。どれにも settings.json の設定、スクリプト、ダミーの JSON を流したときの exit code と stderr を付けています。検証環境は macOS、Claude Code 2.1.303、jq 1.7.1 で、2026年10月19日に確かめました。スクリプトは、公式ガイドにあるとおりサンプルの入力をパイプして動かしました。実際の Claude Code のセッションの中で発火するところは見ていません。hook を使う理由や測り方はClaude Code hooks実践を読んでください。
どのイベントを使うか
| イベント | 発火するとき | exit 2 の効果 |
|---|---|---|
PreToolUse | Claude がツール呼び出しを組み立てたあと、実行する前 | ツール呼び出しを止める |
PostToolUse | ツールが成功した直後 | stderr を Claude に見せる。ツールはもう動いている |
UserPromptSubmit | プロンプトを送ったとき、Claude が処理する前 | プロンプトを止める |
出典:https://code.claude.com/docs/en/hooks 、2026-10-19確認
コマンドを実行の前に止められるのは PreToolUse だけです。この記事の例はすべて PreToolUse です。
exit code と permissionDecision
| hook の動き | PreToolUse での結果 |
|---|---|
exit 0、JSON なし | 判断なし。通常の権限の確認に進む |
exit 0、stdout に permissionDecision の JSON | その判断が効く。値は allow・deny・ask・defer |
exit 2 | 呼び出しを止める。stderr が理由として Claude に渡る |
| それ以外(1 を含む) | 止めないエラー扱い。ツールは動く |
exit 1 では止まりません。 スクリプトが見つからない、実行権限がない、という場合も止まりません。止めたいなら "onFailure": "block" を付けます(Claude Code v2.1.295 以降)。複数の hook の判断が分かれたときは、deny、defer、ask、allow の順に強くなります。defer が効くのは -p の非対話の実行だけです。
出典:https://code.claude.com/docs/en/hooks 、2026-10-19確認
入力は stdin に JSON で届きます。この記事のスクリプトが読むのは tool_name と tool_input です。Read・Edit・Write の tool_input.file_path は、hook が動く前に ~ や相対パスが展開されるので、必ず絶対パスになります(同じ出典)。
6本まとめた settings.json
スクリプトを .claude/hooks/ に置いて chmod +x し、.claude/settings.json に次を書きます。jq -e '.hooks.PreToolUse | length' で確かめると 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 や Edit|Write は完全一致です。matcher は大文字と小文字を区別するので、bash と書くとどれにも当たりません。当たった hook は並列に動きます。シェル形式で書くときは、$CLAUDE_PROJECT_DIR をダブルクォートで囲むよう公式に書かれています。
出典:https://code.claude.com/docs/en/hooks 、2026-10-19確認
/ やホームへの rm -rf を止める
#!/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 | 同上 |
npm run clean && rm -fr ~ | 2 | 同上 |
rm -rf ./build | 0 | なし |
rm -rf /tmp/cache | 0 | なし |
正規表現は、すべての書き方を拾えるわけではありません。この hook は安全ベルトとして使い、確実に止めたいものは permissions の deny にも書きます。公式も、hook の if での絞り込みは「ベストエフォート」だとしています。
秘密ファイルの読み書きを止める
#!/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 と src/app.ts は exit 0 でした。抜け道が1つあります。プロンプトに @ で付けたファイルはツール呼び出しなしで挿入されるので、PreToolUse hook は発火しません。これは Read の deny ルールで防げます(出典:https://code.claude.com/docs/en/hooks 、2026-10-19確認)。詳しくはClaude Codeに秘密を渡さないにあります。
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 |
プロジェクトの外への書き込みを止める
#!/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
CLAUDE_PROJECT_DIR=/home/dev/my-app で試した結果です。
tool_input.file_path | exit | stderr |
|---|---|---|
/home/dev/my-app/src/login.ts | 0 | なし |
/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 |
"$root"/* の末尾の / を省くと、隣のディレクトリの my-app-old まで「中」と判定されます。
マイグレーションの編集は人に聞く
この hook は止めません。permissionDecision: "ask" の JSON を返して、編集するかどうかを人に確かめさせます。
#!/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
ask の理由は、権限の確認画面に表示されます。permissionDecision は hookSpecificOutput の中に置きます。一番外に置くと、エラーも出ずに無視されます(出典:https://code.claude.com/docs/en/hooks 、https://code.claude.com/docs/en/hooks-guide 、2026-10-19確認)。JSON は文字列をつなげず jq で組み立てると、引用符が正しくエスケープされます。
コマンドを記録して通す
#!/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"}
2回とも exit 0 で、stderr は空でした。.claude/bash-commands.jsonl は .gitignore に入れます。
まとめ
- ツール呼び出しを止めるなら
PreToolUse。PostToolUseはツールが動いたあとに走る - 止めるときは
exit 2で、理由は stderr に書く。exit 1では止まらない - 人に判断させたいときは、
hookSpecificOutputの中でpermissionDecision: "ask"を返す - 使う前に、ダミーの JSON を流して exit code を確かめる
- 正規表現の hook は permissions のルールで補う。考え方はAIエージェントの権限設計にある