Claude Code のフックは timeout で素通りする——強制する仕組みに公式仕様が明記していた抜け道

AI Claude Code Operations Security 検証

はじめに

以前、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, or mcp_tool hook that reaches its timeout is canceled: Claude Code discards the hook’s output, and the hook renders no decision.

A timed-out command, http, or mcp_tool hook 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 if filter 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.json leaves 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行残しておけば、全文を読み直すまで気づかない、ということにはならなかったはずだ。

気軽にメッセージください

仕事の依頼、案件紹介、ご感想・ご質問なんでもお待ちしております。 高い志をもった同士の皆様と繋がることを切に願っております。 これからも人生を掛けたチャレンジを続けていきます。 何卒よろしくお願い致します。