創刊準備号 · 2026年秋English · RSS
AgentKoji

PreToolUse hookの例集

ダミーのJSONで確かめたPreToolUse hookを6本載せます。rm -rf /や秘密ファイル、強制pushの止め方が分かります。

要点(TL;DR)

  • そのまま貼れる 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 の効果
PreToolUseClaude がツール呼び出しを組み立てたあと、実行する前ツール呼び出しを止める
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.commandexitstderr
rm -rf /2Blocked: recursive rm on / or home
rm -r -f /2同上
npm run clean && rm -fr ~2同上
rm -rf ./build0なし
rm -rf /tmp/cache0なし

正規表現は、すべての書き方を拾えるわけではありません。この 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.commandexit
git push --force origin main2
git push -f2
git push --force-with-lease origin feature/login0
git push origin feature/login0

プロジェクトの外への書き込みを止める

#!/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_pathexitstderr
/home/dev/my-app/src/login.ts0なし
/home/dev/.zshrc2Blocked: write outside the project: .zshrc
/home/dev/my-app-old/src/a.ts2Blocked: 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 に入れます。

まとめ

関連記事