安全のために hook を仕込んだら、今度は普通のコマンドまでいちいち確認を求められる。ls を打つだけで「実行していいですか?」と聞かれ、あるいはセッションがいつまでも終わってくれません。「守る」ための仕組みが「進まない」原因に化ける——防御を固めた直後に、いちばん食らいやすい返り討ちです。
前の記事(エージェントの本番実行で起こるセキュリティ事故を Claude Code の hooks で防ぐ)では、PreToolUse / PostToolUse / deny rules / sandbox の4層で守りを固める設計を書きました。今回はその続きで、守りを入れた後にハマる「動かない・止まった」を切り分ける話をします。
扱うのは3つです。PreToolUse の ask が暴発する条件、実行後フックの副作用で遅延・無限ループが起きる仕組み、そして deny rules と hook の ask を混同するとどうハマるか。いずれも実装の挙動ベースで書きます。
この記事で扱うこと
- PreToolUse の
ask判定がなぜ暴発するのか、どう切り分けるのか - 実行後に走るフック(PostToolUse / Stop)の副作用で遅延・ループが起きる仕組み
- deny rules(機械フィルタ)と hook の ask(意思確認)の責務を混同したときの失敗パターン
前提として、hook 設定と各スクリプトは前の記事の4層構成を踏襲しています。ここでは「入れた後に何が起きるか」に絞ります。
切り分けの入り口はいつも同じで、「どの症状か」を決めれば、原因の層と最初の一手が自動的に絞れます。
症状1: PreToolUse の ask が暴発する
一番多いのが「安全なコマンドまで確認を求められる」パターンです。フックが賢く危険を見抜いた結果かと思いきや、実際はもっと素っ気ないものです。原因はほぼ一つに集約されます。PreToolUse の判定はコマンド文字列に対する正規表現マッチでしかありません。フックは stdin で {tool_name, tool_input:{command}} の JSON を受け取り、command の文字列にパターンが当たれば permissionDecision: "ask" を返します。エージェントの意図も、いま本番に触ろうとしているかどうかも、フックは知りません。ただ文字列だけを見ています。
だから暴発は「パターンが広すぎる」ときに起きます。たとえば本番 credential を検知するフックを考えてみます。素朴に PROD という文字列を拾うと、echo $PRODUCER も ls products/ も git log --grep=production も全部 ask になります(PRODUCER は本番でもなんでもない、ただの変数名なのに)。これが暴発です。
これを避けている実装は、境界条件を厳密にしています。環境変数なら PROD の直後に _ を必須にする($PROD_API_KEY は当てるが $PRODUCER は外す)。ファイルなら .env.production を単語境界付きで拾い、git log --grep=production の中の production は「ファイル名ではない」として外す。.env.staging や .env.test も当てない。つまり「当てるべきものだけ当て、似て非なるものを外す」ための条件が、正規表現に全部書いてあります。逆に言えば、この境界が甘いフックは必ず暴発します。
切り分けかた: フックを単体で再生できる
ありがたいことに、PreToolUse フックは「JSON を受け取って JSON を返す」だけの純粋な部品です。エージェントを動かさなくても、疑わしいコマンドを流し込んで判定だけ確認できます。
# フックが「なぜ ask を返すのか」を単体で再現する
echo '{"tool_name":"Bash","tool_input":{"command":"ls products/"}}' \
| bash ~/.claude/hooks/pretool-bash-credential-guard.sh
返ってくる JSON の permissionDecision を見れば、ask なのか、何も返さず素通し(pass-through)なのかが分かります。暴発を疑ったら、まず実際のコマンド文字列そのままを JSON に入れて流します。フックのテストも中身はこれと同じで、当てるべき入力(positive)と外すべき入力(negative / false-positive)を並べて、期待どおりの判定が返るかを assert しているだけです。手元で再生できる以上、暴発は「勘」ではなく「入力を1件流す」で切り分けられます。
もう一つの暴発源が「判定不能で ask に倒す」設計です。たとえば push 先ブランチを見るフックは、ブランチが取得できない(git リポジトリ外・detached HEAD)と、危険だからではなく判断できないから ask を返します。これは正しい安全側フェイルですが、暴発に見えます。切り分けのポイントは「危険を検知した ask」なのか「情報不足の ask」なのかを、返ってきた permissionDecisionReason の文言で読み分けることです。
症状2: 実行後フックの副作用で遅延・ループする
次にハマるのが「なんか遅い」「セッションが終わらない」です。フックってそんなに重いの、実際どうなの?と思うかもしれませんが、遅さの出どころははっきりしています。ここは PostToolUse と Stop、二種類の実行後フックが絡みます。
PostToolUse は全ツール呼び出しに乗る
出力から秘匿情報をマスクするようなフックは、matcher を空(=全ツール対象)にして PostToolUse に置くことが多いです。これは設計として正しい一方で、ツールを1回叩くたびにフックのプロセスが起動することを意味します。マスク処理が perl の一発起動、リソース監視が python の一発起動……と積むと、軽い作業でも毎回その分だけ待たされます(一発は0.数秒でも、叩く回数だけ積もる)。体感の遅延はここから来ることがあります。まず「どのフックが全ツールに乗っているか」を settings.json の matcher で確認するのが切り分けの入り口です。
もう一つ、PostToolUse には見えにくい副作用があります。フックが出力を書き換えて返す(updatedToolOutput)と、エージェントは元の出力を一切見なくなります。マスクが効きすぎて正規の出力まで潰していると、「コマンドは成功しているのに結果が読めない」という不可解な状態になります(正直、これがいちばん原因を見失う)。だから汎用フィルタは危ういわけです。実装側も、対象を特定フォーマット(既知の鍵 prefix や環境変数形式)に絞ることで、この「debug しづらさ」を避けています。切り分けるなら、疑わしいフックを一時的に外して同じコマンドを叩き、出力が変わるかを確かめます。
副作用は「増え続けるログ」の形でも出ます。デバッグ用の環境変数を立てたまま運用すると、追記オンリーのログが上限なく肥大していく、という類です。これは動作を止めはしませんが、じわじわディスクとレイテンシを食います。切り分けの過程でデバッグフラグを立てたら、終わったら必ず戻します。
Stop フックの無限ループ
「セッションが終わらない」の正体はたいてい Stop フックです。Stop フックが exit 2 を返すと、Claude は停止をキャンセルして作業を継続します。これは「未コミットの差分が残ったまま終わろうとしたら継続させる」といったガードに使う正しい挙動ですが、裏を返すと危険もあります。ブロック条件が永遠に解消しないと、止まろうとする→継続させられる→また止まろうとする、のループになりえます(本人は親切のつもりで、いつまでも働き続ける)。
だからこの種のガードは、暴走しないための逃げ道を最初から持っています。無効化用の環境変数(escape hatch)を用意し、main / dev のような保護ブランチや、差分ゼロ、判定不能(detached HEAD)といった「継続を促す意味がない」状況ではブロックせず素通しします。切り分けの手順は明快です。まず escape hatch の環境変数で一度止め、次に「なぜブロックが解消しないのか」(消し忘れた差分など)を潰します。ループそのものを叩くのではなく、ブロック条件を満たしてやるのが筋です。
症状3: deny rules と hook の ask を混同している
三つ目は設計の話で、いちばん再発しやすいところです。deny rules と hook の ask は、責務がまったく違います。ここを混ぜると「固すぎて進まない」か「緩すぎて暴発する」のどちらかに倒れます。
deny rules(settings.json)は機械フィルタです。 コマンドのパターンに完全一致で当てて、静的に拒否します。文脈もエージェントの意図も見ません。逃げ道もありません。git push --force* や rm -rf のような「文脈を問わず、実行された時点で事故」というものを、確定的に潰すのに向いています。
hook の ask は意思確認です。 フックは stdin の JSON からブランチや対象を読んで、文脈に応じて allow / deny / ask を返せます。ask はユーザーに逃げ道を残します。「保護ブランチへの push は deny、feature ブランチは allow、判定できなければ ask」のように、状況によって答えが変わる判断はこちら側の仕事です。
混同はこう起きます。文脈依存の判断を deny rules に押し込むと、逃げ道がないので正規の操作まで巻き込んで固まります(たとえば push を丸ごと deny すると feature ブランチの作業まで死ぬ)。逆に、常に当たるだけの機械的パターンを hook の ask に載せると、意思確認が意味を失って毎回プロンプトが出ます——症状1の暴発そのものです。
切り分けの指針はシンプルです。完全一致で機械的に潰せるもの(force push・ファイルシステム破壊・root 操作)は deny rules。文脈を読んで答えが変わるもの(このブランチはOKか、この参照は本番か)は hook の ask。 どちらの層に置くべきかを取り違えているだけで、症状は「固い」か「暴発」のどちらかに必ず現れます。前の記事の4層モデルで言えば、層2(deny)と層1(PreToolUse ask)の役割分担を守ることが、そのまま予防になります。
切り分けチェックリスト
| 症状 | 切り分けと対処 |
|---|---|
| 確認プロンプトが多すぎる | PreToolUse フックに疑わしいコマンド文字列を JSON で流し、ask を返す条件を単体で再現する。境界(_ サフィックス・単語境界)が甘くないか、あるいは「情報不足の ask」ではないかを permissionDecisionReason で読む |
| 動作が遅い | settings.json で matcher が空(全ツール対象)の PostToolUse フックを洗い出す。疑わしいものを一時的に外して体感差を測る |
| 結果が読めない・おかしい | 出力を書き換えるフック(updatedToolOutput)を疑い、外して出力が戻るか確認する。デバッグフラグは使い終わったら戻す |
| セッションが終わらない | Stop フックの escape hatch でまず止め、ブロック条件(消し忘れた差分など)を解消する |
| 固すぎる・暴発する | その判断は「完全一致で機械的に潰せる」のか「文脈で答えが変わる」のか。前者は deny rules、後者は hook の ask に置き直す |
まとめ
hook を入れて開発が止まったとき、原因は「hook が悪い」ではなく「層の使い方」に寄っています。PreToolUse の ask は文字列マッチなので、境界が甘いと暴発します。実行後フックは全ツールに乗るほど遅くなり、出力を書き換えるほど debug しづらくなります。Stop フックは継続を強制できるぶん、ループの芽を持ちます。そして deny と ask は責務が違います。
救いは、どのフックも「入力を渡して出力を見る」単体の部品だということです。エージェント全体を動かして勘で探る必要はありません(そもそも勘で当たるなら苦労しない)。疑わしい入力を1件流し、返ってくる判定を読む。切り分けは、いつもそこから始められます。



