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

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つは判定なしで通りました。

直した版と結果

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 +mainask
git -C /repo reset --hardask
git push --force-with-leaseask
git branch -d topic判定なし
git branch -D topicask

--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は削るほど効く」に書きました。

まとめ