Claude Code hooks実践
破壊的なgit操作を止めるhookを書いたら、+mainとgit -Cが素通りした。終了コードの意味と、hookを実測で直す手順。
PR:この記事には、当サイトが参加する(予定の)アフィリエイトプログラムの紹介枠(学習サービス)があります。内容と評価は、報酬の有無と関係なく決めています。
CLAUDE.md に書いたルールは「お願い」です。hook にすると「機構」になります。この記事では、破壊的な git 操作を止める PreToolUse hook を実際に書いて、穴を見つけて直すところまでを載せます。動かした環境は、編集部の検証環境(Linux、Claude Code 2.1.301、jq 1.7、git 2.47.3、2026-10-16)です。
hook のスクリプトは、公式ドキュメントにある入力 JSON の形を標準入力に流して試しました。Claude Code のセッションの中での発火までは確かめていません。
終了コードの意味を先に押さえる
公式リファレンスの要点です(出典:https://code.claude.com/docs/en/hooks 、2026-10-16確認)。
| 終了コード | PreToolUse での扱い |
|---|---|
| 0 | 成功。stdout の JSON があれば、その判定(allow/deny/ask/defer)に従う |
| 2 | ブロック。stderr が理由として Claude に渡る |
| それ以外(1 を含む) | ブロックしないエラー。ツールはそのまま実行される |
exit 1 では止まりません。 ポリシーとして止めたいなら、exit 2 か、permissionDecision を返します。hook がタイムアウトしたり、スクリプトのパスを打ち間違えて起動できなかったりしても、既定ではツールが実行されます。"onFailure": "block"(v2.1.295 以降)を付けると、失敗したときに止める側へ倒せます(同出典)。
最初の版は2つ素通りした
Bash の呼び出しを見て、破壊的な git 操作なら人に確認(ask)させる hook です。
#!/usr/bin/env bash
# .claude/hooks/git-guard.sh(PreToolUse、matcher: Bash)v1
cmd=$(jq -r '.tool_input.command // ""')
if printf '%s' "$cmd" | grep -Eq 'git +(push +(-f|--force)|reset +--hard|clean +-[a-z]*f|branch +-D)'; then
jq -n --arg c "$cmd" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"ask",permissionDecisionReason:("破壊的なgit操作: "+$c)}}'
fi
exit 0
入力を流して確かめました。
jq -nc --arg c "git push origin +main" '{tool_name:"Bash",tool_input:{command:$c}}' | ./git-guard.sh
git push --force と npm test && git reset --hard HEAD~1 は ask になりました。一方で、次の2つは判定なしで通りました。
git push origin +main:refspec の+も強制 push です。git -C /repo reset --hard:gitとresetのあいだにオプションが入ると、正規表現が一致しません。
直した版と結果
g='git( +-C +[^ ]+)*'
grep -Eq "$g +(push( +[^ ]+)* +(-f|--force[a-z-]*|\+[^ ]+)|reset +--hard|clean +-[a-z]*f|branch +-D)"
| 入力 | v2 の判定 |
|---|---|
git status | 判定なし |
git push origin main | 判定なし |
git push origin +main | ask |
git -C /repo reset --hard | ask |
git push --force-with-lease | ask |
git branch -d topic | 判定なし |
git branch -D topic | ask |
--force-with-lease も ask にしたのは意図したものです。比較的安全な操作ですが、人が一度見る価値はあります。
正規表現で網羅するのには限界があります。公式ドキュメントも、if フィールドの絞り込みは「ベストエフォート」なので、確実な許可・拒否には権限ルールを使うよう書いています(同出典)。hook と権限ルールの役割分担は「AIエージェントの権限設計」に書きました。
matcher の書き方で範囲が変わる
matcher は、英数字・_・-・空白・,・| だけなら完全一致、それ以外の文字を含むと正規表現として扱われます。Edit.* は NotebookEdit にも一致します。完全一致にしたいなら ^Edit$ と書きます(同出典)。
matcher には MCP のツール名(mcp__サーバー名__ツール名)も書けます。MCP サーバー側の作り方は「MCPサーバー入門と設計の5原則」に書きました。
判定はモデルに頼らない
編集部の hook は、判定に終了コード、git の実数、正規表現だけを使っています。こうしておいたので、モデルの世代を上げたときも、hook は1本も書き換えずに済みました。
逆に、モデルに判定させる hook は精度を測ってから残します。LLM に判定させる Stop hook をカテゴリごとに測ったら、3カテゴリのうち2つは「誤ブロックばかり」か「違反を1件も拾わない」状態でした。いまは1カテゴリだけに絞っています。
完了報告を Stop hook で検証する例は「AIの「完了しました」を信じない仕組み」にあります。
注入する hook は効果で残すか決める
プロンプトのたびに約450字を差し込む hook がありました。調べると、最初のプロンプトでは毎回「unknown」と判定していて、実質的に何も効いていませんでした。注入した文字数は、そのぶん文脈を使います。効いた実績がないものは外します。
毎回読ませる文書が膨らむ問題は「エージェントのメモリ設計」でも扱っています。
reject のあとは実物を見る
コミットメッセージの hook が reject したとき、lint-staged が退避した変更が元に戻らず、編集が git status にも出ずに消えたことがありました。しかも、一度では済みませんでした。いまは決めた手順で確かめています。
git status # reject のあと
git show --stat HEAD # commit のあと
再発したものだけ昇格させる
Stop hook がブロックした記録は jsonl に残しています。週に一度見返して、3回以上再発した失敗だけを hook か lint に昇格させます。1回に昇格させるのは最大3件までです。全部を機構にすると、hook そのものが保守の負担になるからです。
散文のルールをどう削ったかは「CLAUDE.mdは削るほど効く」に書きました。
まとめ
- 止めたいなら
exit 2かpermissionDecision。exit 1は止まらない。 - 起動の失敗やタイムアウトでも止めたいなら、
onFailure: "block"を付ける。 - hook は、入力の JSON を流して実測する。素通りする書き方は必ず見つかる。
- 判定は終了コードや git の実数で行い、モデルの判断に頼らない。