Claude Code のフックは timeout で素通りする——強制する仕組みに公式仕様が明記していた抜け道
はじめに
以前、AIコーディングアシスタントに「口約束の助言」ではなく「実行前に必ず止める関門」を作った記録を書いた。その関門の仕組みは、Claude Codeが提供する「フック」——特定の行動(ファイルの編集・コマンドの実行など)の前後に、こちらが用意した検査プログラムを必ず挟み込む機構——を使っている。
今回、そのフックに関する公式の一次資料を全文(3,409行)読み切った。目的は新しい機能を探すことではなく、自分が「これは強制層だから100%効く」と思っていた設計そのものを、公式の言葉で裏取りすることだった。結果、裏取りは半分成功し、半分は自分の思い込みを崩す形になった。
複数の関門は、同時に全部走る
まず、複数のフックを同じ場面に設定していた場合の挙動を確認した。
All matching hooks run in parallel.
条件に合うフックは、順番待ちではなく全部同時に走る。判定が割れた場合の優先順位も定義されていた。
When multiple PreToolUse hooks return different decisions, precedence is
deny>defer>ask>allow.
拒否が最も強く、次に保留、次に確認、最後に許可。複数の関門のうち1つでも「拒否」を出せば、他がどう判定していても止まる。ここまでは、以前設計した通りの前提が公式にも明記されていた。
「条件に合った時だけ動く」設定は、思ったより広く発火する
フックには「このコマンドの時だけ動く」という条件(if)を書ける。この条件の判定範囲を、公式は具体例つきで示していた。
| 設定した条件 | 実際のコマンド | 発火するか | 理由 |
|---|---|---|---|
Bash(git *) | npm test && git push | する | 複数コマンドをつないだ場合、それぞれが個別に判定される |
Bash(rm *) | echo $(rm -rf /) | する | $() やバッククォートの中に隠れたコマンドも判定対象になる |
「git コマンドの時だけ」と条件を絞ったつもりでも、&& でつないだ別のコマンドや、変数展開の中に隠れたコマンドまで判定の対象になる。条件を狭く書いたつもりが、実際にはこちらが想定していないコマンド文字列でも発火する、という方向のギャップだった。
一番の発見: 応答が遅いと、関門そのものが無かったことになる
ここからが本題である。フックには実行時間の上限(timeout)を設定できる。上限に達した時にどうなるかを、公式は次のように定めている。
A
command,http, ormcp_toolhook that reaches its timeout is canceled: Claude Code discards the hook’s output, and the hook renders no decision.A timed-out
command,http, ormcp_toolhook doesn’t block the tool call. The call continues through the normal permission flow, so don’t count on a stalled hook to act as a gate.
フックが制限時間内に応答できなかった場合、その判定は「拒否」ではなく「何も言っていない」扱いになり、行為はそのまま通る。 これは自分が「強制層」と呼んでいた設計にとって、根本的な前提の見直しを迫るものだった。強制層は「Claudeの判断に関係なく100%止める」ものだと考えていたが、公式の記述は「フックの応答が間に合わなければ、そのまま素通りする」と明記している。強制の仕組みそのものに、応答速度という別の変数が絡んでいた。
公式は代替の手段も同じ場所で明言している。
Because the
iffilter is best-effort, use the permission system rather than a hook to enforce a hard allow or deny.
条件判定つきのフックは「最善努力」の仕組みであって、確実な強制ではない。確実に止めたいなら、フックではなく権限システム側の拒否ルールを使うべきだ、という位置づけだった。
もう一つの静かな穴 — 打ち間違いが、通知なしで関門を消す
タイムアウトとは別の経路でも、同じ「気づかないまま素通りする」形の穴があった。
A hook that can’t start lands in the same non-blocking bucket. When the script path doesn’t exist or isn’t executable, the shell exits with a code like 127 and you see the same notice with the interpreter’s message … When you set up a policy hook, watch for this notice on its first run: a mistyped path in
settings.jsonleaves the gate silently disabled.
設定ファイルの中でフックのパス(プログラムの置き場所)を1文字でも打ち間違えると、エラーは出るが行為自体は止まらないまま実行され続ける。 打ち間違いに気づけるかどうかは「初回実行時にその通知を見ているか」だけにかかっている。
実測 — 自分の設定は、この穴に当たっているか
公式の記述を読んだ後、実際の設定を確認した。
[実測] 実行前に発火するフック(PreToolUse)の設定一覧を確認
対象: 実行前チェックの全ハンドラ
type: すべて "command"(プログラムを直接起動する形式)
timeout: すべて 20 秒 に設定
⇒ 応答に20秒以上かかるフックが1つでもあれば、
そのフックだけ「何も言っていない」扱いになり、他の判定に委ねられる
ここで効いてくるのが、この上限値の既定がいくつなのかだ。別の公式資料(開発キット側のフック定義)に、値を省略した場合の既定が明記されていた。
Timeout in seconds. When omitted, the per-event default applies: 600 for most events, 30 for
UserPromptSubmit
つまり何も指定しなければ600秒で、こちらはそれを20秒にしていた。素通りする穴が開く条件を、既定の30倍発火しやすい側に、自分で寄せていたことになる。しかもこれは事故ではなく、意図的な選択だった——フックの応答を待つあいだ、こちらの入力に対する返事は止まる。20秒という値は「待ち時間を縛る」ために選んだものだ。待ち時間と強制力を交換していたのに、交換した代償の側だけが、どこにも書かれていなかった。
実際に20秒に達したことがあるかどうかは、この時点では未確認のまま残した。設定を今すぐ変えることはせず、「強制層は100%ではなく、応答速度という別の変数を持つ」という事実だけを、まず確定させた。
発見の道のり
起点: 「実行前の関門(フック)は100%効く」という前提を、公式全文で裏取りする
│ (縦=読んだ順・横=確認した論点・入れ子=その内訳)
├─▶ 複数フックの同時実行と優先順位を確認
│ └─ ✅ 前提どおり(拒否が最優先) ────────────────┐
│ │ 前提は
├─▶ 条件判定(if)の発火範囲を確認 │ 半分だけ
│ └─ ⚠️ 想定より広く発火する │ 成立
│ (連結コマンド・変数展開の中まで判定対象) │
│ │
├─▶ 制限時間(timeout)に達した時の挙動を確認 ★前提が崩れる │
│ └─ ⛔ 「拒否」ではなく「無言のまま素通り」 │
│ ├─ 公式が名指しする代替: │
│ │ 権限システム側の拒否ルール │
│ └─ 自分の設定を実測 ── 全ハンドラ 20 秒 │
│ │ │
│ └─▶ では既定は? ── 別資料に 600 秒 │
│ │ │
│ └──(戻りエッジ)──▶ 「事故で開いた穴」ではなく
│ 「待ち時間と交換した穴」と読み直す
│
└─▶ もう1つの静かな穴 ― パスの打ち間違い
└─ エラーは出るが行為は止まらない(通知に気づけるかだけが頼り)
│ │
└───────────────┬───────────────────────────┘
▼
どちらの穴も「止まらなかったこと」が
こちらに通知されないまま作業が進む、という同じ形
強制層の設計を見直すということ
自作の関門が誤って自分自身の入力に反応する、という別種の不具合を実際に踏んだ話は、以前別の記事にまとめた。今回見つけたのはそれとは種類の違う話——個々のフックのロジックが正しくても、応答時間という設計の外側にある要因1つで、関門全体が無かったことになりうる、という強制層の骨格そのものの限界だった。
「必ず止める」と書いた設計書と、「タイムアウトすれば素通りする」という公式の実装は、両方とも正しい。ただし前者は理想であり、後者が実際の挙動だ。強制層を作った後も、その強制の仕組み自体が持つ限界を、実装した本人が把握しているかどうかで、関門の実効性は変わる。
Claude Code 用に作ったガードが、自分のコミットメッセージに反応して自爆した — 検査する側と検査される側の境界を、中身の性質まで見て引き直した記録
AIコーディングアシスタントに「やらせない」仕組みを作る — 助言から強制へ、そして関門に空いていた横穴
Claude Code の権限・サンドボックス・保護パス——公式仕様3本を読み切って、「AIに設定を書き換えさせない」仕組みの本当の関門を突き止めた記録
関門は、破られたときに音を立てない。20秒という数字を自分で書いた覚えはあっても、それが既定の30分の1で、何と引き換えだったかは書いていなかった——設定値の隣に「何を諦めてこの値にしたか」を1行残しておけば、全文を読み直すまで気づかない、ということにはならなかったはずだ。