結論:hooks で止まるのは「操作」だけ
Claude Code の hooks は、ツール実行の前後などに必ず起動する仕組みです。CLAUDE.md に書く禁止事項がAIへのお願いであるのに対し、hooks はAIの判断や読み落としに関係なく動きます。だから「確実に効かせたいこと」は hooks 側に置きます。
ここは数え方が変わった箇所なので先に書きます。2026-09-24 時点の公式リファレンスでは、hook ハンドラーは command / http / mcp_tool / prompt / agent の5タイプに分かれています。 このうち prompt はプロンプトと hook の入力をモデルへ送って yes/no を返させるもの、agent はファイルを読んだり検索したりできる subagent を起動するものです(公式は agent を実験的と明記)。つまり「AIの判断に関係なく動く」のは command / http / mcp_tool の3タイプで、prompt と agent は判定そのものをモデルに委ねます。この記事で扱うのはすべて command タイプです。機械的に確実に止めたい検査は、このどちらなのかを先に決めてください。
そのうえで、止められるのは画面上でツール呼び出しとして現れる操作だけです。「雑な実装をしない」「勝手に判断しない」は hooks では止まりません。ここを取り違えると、パターン表だけが育って誰も読まなくなります。
この記事は仕様の解説ではありません。4ヶ月運用して何が壊れたかの記録です。結論から書くと、踏んだ罠は3つでした。
- コマンド文字列だけを見るガードは、外部スクリプトを1枚かませば無効になる
- hook を個別に登録すると、Bash 1回ごとに874msのオーバーヘッドが乗る
- 閾値を他人の記事から借りると、鳴りすぎるか鳴らないかのどちらかになる
この記事の数字は何を測ったものか
先に集計条件を開示します。当メディアの運営で使っているAIエージェントの作業ログ(analytics/events.sqlite)を、2026年9月2日に再集計したものです。
| 項目 | 値 |
|---|---|
| 集計期間 | 2026-05-06 〜 2026-09-01(約4ヶ月) |
| セッション数 | 745(ツール呼び出しを含むもの) |
| ツール呼び出し | 24,773件 |
| 環境 | Windows 11 / Python 3.11.1 / Claude Code 2.1.161 |
内訳を出したのは、hook をどのイベントに置くと費用対効果が高いかを決めるためです。
作業ログ24,773件のツール呼び出し内訳
ツール呼び出しの 79.8% はシェル実行だった
2026-05-06〜09-01 / 745セッション・24,773件の自作作業ログを集計
だから PreToolUse × Bash に1本入れるだけで、作業の約8割が検問所を通る。
逆に、この1本を重くすると全体が遅くなる(実測874msの話は本文へ)。
| シェル実行(Bash / exec) | 79.8%(19,775件) |
|---|---|
| ファイル編集(apply_patch / Edit / Write) | 16.2%(4,005件) |
| 読み取り・検索(Read / Grep / Glob) | 1.8%(435件) |
| Web・その他(WebSearch / MCP ほか) | 2.2%(558件) |
シェル実行が 79.8%(19,775件) でした。つまり PreToolUse × Bash に検査を1本置くだけで、作業の約8割が検問所を通ります。逆に言えば、この1本を重くすると全体が遅くなります。3章目の874msはこれが原因でした。
自分の環境で同じ集計をするなら、ログの持ち方によりますが、考え方は「どのツールが何%を占めるか」を数えるだけです。8割を占めるツールが分かれば、そこに1本置くのが最短です。
実際に動かしている12検査の配置
現在の構成です。イベントごとにディスパッチャを1本だけ登録し、その中で検査を順に回します。
| イベント | 置いているもの | 挙動 |
|---|---|---|
| PreToolUse (Bash) | アップロード・削除ガード / git / build / watch | ブロック(exit 2)と注意喚起 |
| PostToolUse | 回路ブレーカー / ルーティング記録 | 通知・記録のみ |
| Stop | 検証リマインダ 3種 | 通知のみ |
| UserPromptSubmit | ブレーカーのカウンタリセット | 記録のみ |
| SessionStart | 品質基準の注入 | 記録のみ |
ブロックしているのは PreToolUse だけです。残りは通知と記録に留めています。止める対象を増やすと、止まってはいけない場面で止まって作業が進まなくなるためです。
検査の並び順にも意味があります。ディスパッチャは1つでも exit 2 を返した時点で残りを評価しません。だから事故コストが大きい検査を先頭に置きます。当メディアはアップロード・削除ガードを1番目に置いています。
外部スクリプト1枚でガードはすり抜けられた
ここが一番の失敗です。
アップロードガードの最初の実装は、コマンド文字列を正規表現で照合するだけでした。lftp put や scp user@host: を検出してブロックします。一見ちゃんと動きます。
しかし、これは通ります。
python scripts/publish.py
コマンド文字列に ftp も upload も出てきません。publish.py の中身がアップロード関数を呼んでいても、hook からは見えません。スクリプトを1枚かませるだけで、ガードは無効になっていました。
外部スクリプト経由でガードがすり抜けられた失敗と対策の比較図
外部スクリプト1枚でガードは無効になった
コマンド文字列しか見ていなかったのが原因
判定を「import の有無」にしなかった理由
| 対策前 | 対策後 |
|---|---|
| python scripts/publish.py | python scripts/publish.py |
| コマンド文字列に ftp / upload が無い | .py のパスを抽出して中身を読む |
| → 素通り。中身は upload_dir() を呼んでいた | → upload_dir() を検出してブロック |
| import で判定すると | ダウンロード専用スクリプトも止まる(DL も同じモジュールを import する) |
| 関数名で判定すると | upload_dir / delete_dir / storbinary など、実際に書き込む呼び出しだけ拾える |
| 中身が読めなかったら | 安全側に倒してブロックする(読めないものを通さない) |
対策は、コマンドから .py のパスを抜き出して中身まで読むことです。ここで一度失敗しています。最初は「アップロード用モジュールを import していたらブロック」にしたのですが、ダウンロード専用スクリプトまで止まりました。取得系も同じモジュールを import するからです。
判定を import ではなく実際に書き込む関数名に変えて解決しました。読めなかった場合は安全側に倒してブロックします。
# 名前に ftp / upload / deploy / sync を含む .py の実行は「要確認」の印。
# 上りか下りかは名前では判別できないので、実際の判定は中身で行う。
PY_FILENAME_RE = re.compile(
r"\bpython\b(?:\s+-\w+)*\s+\S*(?:ftp|upload|deploy|sync|publish)\S*\.py\b",
re.IGNORECASE,
)
def scan_py_files_in_cmd(cmd):
"""python ... script.py の中身を読み、書き込み / 削除があるか判定する。
戻り値: (checked, write_found, unreadable)
"""
py_paths = re.findall(
r"\bpython\b(?:\s+-\w+)*\s+[\"']?([^\s\"']+\.py)[\"']?", cmd, re.IGNORECASE
)
checked = write_found = unreadable = False
for path in py_paths:
try:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
content = f.read(20000)
except Exception:
unreadable = True # 読めなかったものは通さない
continue
checked = True
if PY_WRITE_RE.search(content):
write_found = True
return checked, write_found, unreadable
この対策には、まだ穴があります
この記事を書いている最中に、自分のガードが自分の図解生成スクリプトをブロックしました。
上の図を描く Python スクリプトには、説明のために書き込み関数の名前が文字列として入っています。ガードは中身を読んで関数名を見つけ、アップロード処理だと判定しました。実際には SVG をローカルに保存するだけのスクリプトです。
中身の文字列照合では、「その関数を呼んでいる」のか「その関数名について書いている」のかを区別できません。 抽象構文木を解析すれば区別できますが、そこまでやるかは判断です。当メディアは今のところ、誤検知したら止まる側で運用しています。止まりすぎる分には事故になりませんが、止まる回数が増えると承認が形骸化するので、そこが次の課題です。
この種のガードは「完全に防ぐもの」ではなく「素通りを減らすもの」として設計してください。
閾値は自分のログから決めた
もう1つ運用しているのが回路ブレーカーです。1つの指示に対してツールを何回使ったかを数え、多すぎるときに手を止めるよう促します。
「30ターンで方針確認」といったルールを文章で書いても実行されません。当メディアでは、必読と決めたファイルが3ヶ月消えたまま誰も気づかなかったことがあります。文章で課したルールは実行されないという前提で、機械的に数えることにしました。
問題は閾値です。他人の記事の数字を借りても効きません。 借りると、鳴りすぎて無視するか、鳴らなくて意味がないかのどちらかになります。自分の作業ログ415ブロック(人間の指示1件を1ブロックとして集計)の分布を見て決めました。
| 統計量 | ツール呼び出し数 |
|---|---|
| 中央値 | 4 |
| 75パーセンタイル | 12 |
| 90パーセンタイル | 24 |
| 95パーセンタイル | 34 |
| 最大 | 149 |
上位9%にあたる 25回で方針確認の警告、上位2%にあたる 60回で分割を促す設計にしました。同じ段階では二度鳴らしません。
正直に書くと、この閾値が妥当かはまだ分かりません。 導入後に記録できたのは28ブロックで、警告が2回、分割の促しが2回、最大は76回です。n が小さく、閾値の検証には足りていません。3ヶ月後に見直します。
自分の環境で同じことをするなら、ブロックごとのツール呼び出し数を並べて、パーセンタイルを取るだけです。中央値ではなく上位側で切るのがコツで、中央値付近で切ると通常作業のたびに鳴ります。
hooks が動かない・遅いときの診断
設定したのに動かない、という状態には決まった原因があります。上から順に見てください。
| 症状 | 最初に見るところ |
|---|---|
| 何も起きない | matcher の大文字小文字。公式は「マッチャーは大文字小文字を区別する」と明記。bash ではなく Bash |
| 何も起きない | matcher に書いた文字種(下記) |
| 何も起きない | settings.json の末尾カンマ。JSONとして壊れていると黙って無視される |
| 全ツールが止まる | hookスクリプトの構文エラー。シェルが exit 2 を返しブロック扱いになる |
| ときどき動かない | スクリプト実体がクラウドドライブ上にある。未マウント時に黙って死ぬ |
| 体感が遅い | 1イベントに別々の hook を複数登録している(同一ハンドラーは公式側で重複排除される。下記) |
| Stop hook が途中で効かなくなる | 連続8回ブロックすると Claude Code 側が上書きする。stop_hook_active を見て早期 exit する |
| 文字化けする | Windows。sys.stderr.reconfigure(encoding="utf-8") を入れる |
matcher の評価方法は、書いた文字種で分岐します(公式リファレンス、2026-09-24 確認)。文字・数字・_・-・スペース・,・| だけなら完全一致(または | , 区切りの完全一致リスト)ですが、それ以外の文字を含むとアンカー無しの JavaScript 正規表現として評価されます。Edit.* は Edit と NotebookEdit の両方にマッチするので、完全一致させたいなら ^Edit$ のようにアンカーを付けます。完全一致セット内のハイフンは Claude Code v2.1.195 以降が必要で、それ以前は code-reviewer が senior-code-reviewer でも発火します。
「ときどき動かない」と「体感が遅い」は、当メディアが実際に踏んだものです。
hook をイベントごとに4本登録していた時期があり、Bash 1回ごとに Python が4プロセス起動していました。しかもスクリプト実体をクラウドドライブに置いていたため、全 Bash 呼び出しに874msのオーバーヘッドが乗っていました(2026-08-30 実測)。さらにドライブが未マウントのときは、全 hook がエラーも出さずに停止していました。
ここも数え方に注意が要ります。 公式リファレンス(2026-09-24 確認)では、マッチしたフックは並列で実行され、同一のハンドラーは自動的に重複排除されます。コマンドフックはコマンド文字列と args、HTTPフックは URL で重複排除されます。つまり「登録本数がそのまま直列の待ち時間になる」わけではなく、同じハンドラーを重複して登録しても起動は1回です。それでも別々のスクリプトを4本登録すれば4プロセスが起動することは変わりません。1プロセスあたりの起動コストが重い環境では、並列でも最も遅い1本ぶんが全 Bash 呼び出しに乗ります。当メディアの874msは、クラウドドライブ上の Python 起動が4本同時に走ったケースでした。なお非同期フック(async)は例外で、公式は「各実行は個別のバックグラウンドプロセスを作成し、同じ非同期フックの複数の発火をまたぐ重複排除は無い」と明記しています。
時間側の既定値も押さえておきます。公式のタイムアウトは command / http / mcp_tool が600秒で、UserPromptSubmit・PreModelSwitch・PostModelSwitch では30秒、MessageDisplay では10秒に短縮されます。prompt は30秒、agent は60秒(かつ最大50ツール使用ターン)、SessionEnd は全タイプ合計で1.5秒の予算を共有し、hook 側の設定が長ければ最大60秒まで引き上げられます。874msは command の600秒に対しては誤差ですが、全 Bash 呼び出しに毎回乗るので体感には出ます。
hookを個別登録した場合とディスパッチャにまとめた場合の比較図
hook を4本登録したら、Bash 1回ごとに 874ms 乗った
2026-08-30 実測。判定ロジックは変えず、起動の仕方だけ直した
判定ロジックは1行も変えていない。遅かったのは検査の中身ではなく、プロセスの起動回数だった。
hook が「重い」と感じたら、まず何プロセス起動しているかを数える。
| 個別登録(対策前) | ディスパッチャ(対策後) |
|---|---|
| Bash 実行 1回 | Bash 実行 1回 |
| python | python |
| 4プロセス起動 / 実測 874ms | 中で4検査を順に評価 |
| ドライブ未マウント時は全部が黙って死ぬ | 1つが例外を投げても他は動かす |
直したのは起動の仕方だけで、判定ロジックは1行も変えていません。イベントごとにディスパッチャを1本登録し、その中で検査を順に評価します。規約は3つだけです。
- どれか1つでも
exit 2を返したら、その時点で停止して 2 を返す(ブロック最優先) exit 0の検査の標準出力は結合して出す- 1つの検査が例外を投げても他は動かす(hook全体を落とさない)
hook が「重い」と感じたら、まず何プロセス起動しているかを数えてください。 検査の中身を最適化する前に、そこで解決することがあります。
何から入れるか
全部入れる必要はありません。当メディアの構成も、事故や不便を1つずつ潰した結果としてこの形になっただけです。
| 状況 | 最初に入れるもの |
|---|---|
| 本番・STGへデプロイする権限をAIに渡している | アップロード・削除ガード(PreToolUse / Bash) |
| 削除・DB操作を任せている | 同上(パターン表に DROP と rm -rf を追加) |
| 長時間の自律実行をさせている | 回路ブレーカー(PostToolUse) |
| hookを既に3本以上登録している | ディスパッチャ化 |
| どれも当てはまらない | 入れない |
最後の行が本音です。起きていない事故を先回りしてパターン表に足すと、表だけ育って誰も読まなくなります。 踏んだ罠を1つずつ足すのが、結果として続いた運用でした。
当メディアのアップロードガードも、先回りして作ったものではありません。確認を取らずにアップロードした事故が実際にあり、その再発防止として実装したものです。
この記事で使っている hooks 一式を、そのままコピーできる形で配布しています。ディスパッチャの実装、アップロード・削除ガード(外部スクリプト経由のすり抜け対策込み)、回路ブレーカーとカウンタリセット、settings.json の登録例、動かないときの診断表をまとめたものです。顧客名・実案件・認証情報・社内パスは含みません。ライセンスは自由で、帰属表示も不要です。
hooks で止められるのは操作だけで、判断の質は上がりません。同じリポジトリを2体のAIエージェントに触らせたときの役割分担とロック設計はClaude Code と Codex の併用記事、subagent を使ったレビュー体制はコードレビュー自動化の記事、契約プランの選び方はClaude Code の料金記事で扱っています。
訂正履歴
- 2026-09-24: 公式ドキュメント(
code.claude.com/docs/ja/hooks・/hooks-guide)を再取得して照合し、仕様の数え方が変わっていた箇所を書き換えました。①hook ハンドラーがcommand/http/mcp_tool/prompt/agentの5タイプに分かれ、promptとagentは判定をモデルに委ねるため、旧記述の「hooks はAIの判断に関係なく動く」を3タイプに限定する形へ修正。②「マッチしたフックは並列で実行され、同一ハンドラーは自動で重複排除される」という公式記述を追記し、登録本数が直列の待ち時間になるかのように読めた箇所を修正。③公式のタイムアウト既定値(command/http/mcp_tool600秒、UserPromptSubmit等30秒、MessageDisplay10秒、prompt30秒、agent60秒かつ最大50ツール使用ターン、SessionEndは合計1.5秒予算)を追記。④Stop hook は連続8回ブロックで Claude Code 側が上書きするという上限を診断表へ追加。⑤matcherの評価方法が文字種で完全一致とアンカー無し正規表現に分岐する点を追記(旧記述は「完全一致」と一律に書いていました)。自社実測値(24,773件の内訳、874ms、415ブロックの分布と最大149回、閾値25回 / 60回)は当メディアのログから出した数字で公式仕様の変更対象ではないため、変更していません。 - 2026-09-02: 全面改稿。従来は最小構成の手順記事(サンドボックスでの実行検証)でしたが、4ヶ月の運用記録として再構成しました。追加したのは、ツール呼び出し24,773件の内訳、実運用12検査の配置、外部スクリプト経由でガードがすり抜けられた事例と対策コード、874msのオーバーヘッドとディスパッチャ化、回路ブレーカーの閾値の決め方、動かないときの診断表です。事実の訂正はありません(旧記事の実行検証結果は2026-08-11時点のもので、内容に誤りは見つかりませんでした)。旧記事にあった
guard.pyの最小構成は、すり抜け対策を加えた形で配布物へ移しています。 - 2026-08-25: 前回更新。
よくある質問