ヘッドレスの Claude Code を何本も動かして仕事を分担させる技術検証で、人の承認を待つ関門を PreToolUse フックで作ろうとしたとき、最初に測ったのは、フックの中で承認が来るまで待つ作りで足りるかでした。ところが、フックが timeout を超えると、止めるはずのツールがそのまま実行されました。関門が黙って開いてしまう(fail-open)ので、承認の仕組みには使えません。

結論

  • timeout を超えたフックは取り消され、hook_response の outcome が cancelled、stdout が空になりました。コマンドはそのまま実行され、permission_denials も空でした。
  • timeout の内に返せば、長く待ってから返しても判定は効きました(600 秒の設定で、45 秒待ってから返した deny が効いた)。
  • 承認のように、いつ返せるか分からないものはフックの中で待たないことにしました。フックはすぐに defer を返してプロセスを一度終わらせ、承認のあとに --resume で続けます。

試した環境

  • Mac、Claude Code 2.1.170
  • claude -p --output-format stream-json --verbose --include-hook-events --settings <フックの設定> --setting-sources '' で起こし、Bash の呼び出しに PreToolUse フックを掛けました。

試した仕組みは、エージェントが節目で承認の申請を出し、人が画面から承認か差し戻しをする作りでした。

測ったこと

フックの設定の timeout(秒)と、フックの中で寝かせる時間を変えて、2通りを測りました。

フックの timeoutフックが寝た時間起きたこと
3 秒20 秒フックは取り消され(cancelled)、コマンドはそのまま実行された
600 秒45 秒フックは最後まで走り、deny が効いた

timeout を超えたときの流れを図にすると、次のようになります。フックが判定を返す前に取り消されると、ツールはそのまま実行されます。

timeout を超えたフックは取り消され、ツールはそのまま実行される

3 秒の回の hook_response は、outcome: "cancelled"、stdout: "" でした。フックの出力は何も使われず、Bash のコマンドは実行されています。

なぜ困るか

承認の関門は、止めるために置くものです。それが timeout を超えると、エラーにもならずに開いてしまいます。

しかも、result の permission_denials は空のままでした。result だけを見ていると拒否された形跡は残らず、ふつうに許可されて実行された回と見分けが付きません。フックのイベントまで見ていなければ、関門が素通りになったことに気づけません。

timeout の値を大きくすれば済むように見えます。しかし人の承認がいつ下りるかは決まっていないので、どんな値にしても、それを超えて待てば同じことが起きます。600 秒の設定で 45 秒の待ちが通ったのは、timeout の内に返せたからです。加えて私たちは、承認を待つあいだプロセスを残さないことを条件にしていました。フックの中で待つとプロセスを生かしておくことになるので、この条件にも合いませんでした。

窓口で待たずに申請を差し入れて立ち去り、ベンチで休むロボットと、離れた部屋で申請を手に取る人

どうしたか

フックの中では待たず、まだ承認されていなければ、すぐに defer を返すようにしました。

{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"defer","permissionDecisionReason":"承認の申請を出しました。人の承認を待ちます。"}}

フックが defer を返すと、プロセスは exit 0 で終わり、result の stop_reason が tool_deferred になります。is_error は false なので、こちらでは見分けられません。承認が下りたら、--resume で同じセッションを再開します。フックがもう一度呼ばれるので、そこで allow を返すと、保留していたツールが実行されます。承認はプロセスの外で待ち、フック自体はいつもすぐに返すので、timeout には当たりません。

どう確かめるか

起こすときに --include-hook-events を付けると、stream-json にフックのイベント(hook_started・hook_response)が流れます。hook_response の outcome が cancelled になっていないか、stdout が空になっていないかを見れば、フックが取り消されたことが分かります。result の permission_denials だけでは分かりません。

確かめていないこと

  • 測ったのは上の2通りだけです。timeout の値ごとの振る舞いの違いや、どこで分かれるかは詰めていません。
  • 2.1.170 より後の版でも同じかどうか。

おわりに

フックが timeout を超えると、ツールは止まらずに通りました。承認のように待ち時間の決まらないものはフックの中で待たず、defer でプロセスを一度終わらせる、というのが私たちの結論でした。

※ この記事に書いた Claude Code の振る舞いは、執筆時点で私たちが測ったものです。いまは変わっている可能性があります。