Claude Code の PreToolUse フックを安全装置として使っている人向けの話。危険なコマンドを止めるフックを書いて、単体テストも通っている、という状態を前提にする。手元で測った結果なので、環境の条件は最後にまとめて書く。
「フックの判定は正しいのに、止まらない」
今夜、Claude Code の起票を1本読んで手が止まった。#84701、2026年8月7日に立って、いまもコメントが0件のまま残っているやつだ。
書いてある事故がきつい。無人で走らせていた背景のサブエージェントに、ホームディレクトリへの rm -rf を約5分半実行された。実際のプロジェクトのデータが消えた(後で GitHub から復旧した、と書かれている)。
その人は再発防止として PreToolUse のフックを固めた。rm -rf・git reset --hard・find -delete・chmod -R 777 あたりを、サブエージェント由来のときに強制的に拒否する。サブエージェントかどうかは、フックが受け取る入力の transcript_path に /subagents/ が含まれるかで判定した。
翌日、本人が本物のサブエージェントを出して find -delete と chmod -R 777 を撃った。どちらも止まらなかった。
しかもこの人は丁寧で、サブエージェントの報告を信じていない。ファイルが実際に消えたこと、パーミッションが実際に777になったことを、自分で確かめている。一方で、同じコマンドをフックに直接流す単体テストでは、正しく拒否が返っている。
読み方はひとつしかないように見える。フックの判定は正しい。実行する側が強制していない。
自分も最初はそう読んだ。
入力を丸ごと落とす
それで自分の環境で測ることにした。ここでひとつだけ、いつもと違うことをしている。
フックの入力を確かめるとき、たいてい jq で見たい欄だけ抜き出す。それだと無い欄の不在に気づけないので、丸ごと保存した。
# hook.py — 受け取った入力をそのまま1行ずつ落とすだけ
import sys, json
d = json.load(sys.stdin)
open("/tmp/payloads.jsonl", "a").write(json.dumps(d, ensure_ascii=False) + "\n")
{"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [
{"type": "command", "command": "python3 /tmp/hook.py"}]}]}}
このフックは何も判定しない。終了コードは0なので操作は普通に通る。この状態で、上位エージェント本人に Bash を1回、サブエージェントに Bash を1回撃たせた。落ちた2行を並べると、こうなった。
上位 : cwd, effort, hook_event_name, permission_mode, prompt_id,
session_id, tool_input, tool_name, tool_use_id, transcript_path
サブエージェント : 上と同じ10個 + agent_id + agent_type
値も見る。
上位 session_id=6a0302f1-… transcript_path=…/-tmp-hp-proj/6a0302f1-….jsonl agent_id なし
サブエージェント session_id=6a0302f1-… transcript_path=…/-tmp-hp-proj/6a0302f1-….jsonl agent_id=acca37433b29c5d00
session_id も transcript_path も、親と1バイトも違わない。 増えているのは agent_id と agent_type の2つだけだった。
意地が悪いのはここから
サブエージェント自身の記録は、ディスク上にはちゃんと在る。この検証の間にも作られていた。
~/.claude/projects/<プロジェクト>/<session_id>/subagents/agent-<agent_id>.jsonl
だから、ファイルを探しに行った人は正しく結論する。「サブエージェントの記録は /subagents/ の下にある」。事実だ。
だが、その道はフックには渡らない。
渡るのは SubagentStop の入力のほうで、しかも transcript_path ではなく agent_transcript_path という別の名前の欄に入っている。ここも実測した。
SubagentStop の入力の欄:
agent_id, agent_transcript_path, agent_type, background_tasks, cwd, effort,
hook_event_name, last_assistant_message, permission_mode, prompt_id,
session_crons, session_id, stop_hook_active, transcript_path
agent_transcript_path = …/<session_id>/subagents/agent-ad5….jsonl ← 子自身の記録
transcript_path = …/<session_id>.jsonl ← 親の記録
名前が違う別の欄なので、PreToolUse で transcript_path をいくら見ても出てこない。そして SubagentStop は終わった後に呼ばれるので、道具の呼び出しを止める用途には使えない。
ここは「直るのを待つ話」ではない
このずれについては、2026年8月17日に開発元が起票 #76333 で答えている。要旨は3つで、transcript_path はどのフックでも常に親の記録を指すのが意図した挙動であること、子の記録は agent_transcript_path という別の欄で渡すこと、そしてその欄が付くのは今のところ SubagentStop だけであること。子の中で走るツールの出来事にもこの欄を足すことは検討中、とも書かれている。
つまりこれは不具合の報告を待つ話ではなく、こちら側の書き方を変える話だ。
そして同じ回答に、今日から使える回避策がある。transcript_path から .jsonl を外して、/subagents/agent-<agent_id>.jsonl を足す。
伝聞のまま書きたくないので、手元の記録で組み立て直した。
渡された transcript_path : …/-tmp-hp-proj/6a0302f1-….jsonl
agent_id : acca37433b29c5d00
組み立てた道 : …/-tmp-hp-proj/6a0302f1-…/subagents/agent-acca37433b29c5d00.jsonl
そのファイル : 実在した
4条件の検証で出た3体ぶんにも同じ手順を当てて、3件とも実在するファイルの場所と一致した。合わせて4件中4件。子自身の記録を読みたいフックは、欄が渡されるのを待たずに、自分で組み立てられる。
つまり transcript_path に /subagents/ が含まれるかでサブエージェントを見分けるフックは、構造上いつも「サブエージェントではない」と答える。拒否の枝に一度も入らない。フックが拒否を返していないのだから、本体には強制すべきものが届いていない。外から見ると「強制されていない」と区別がつかない。
(#84701 のフックの実物は見ていない。判定の方法は報告者本人の記述から取っている。)
なぜ単体テストで捕まらないのか
ここが今回いちばん効いた部分だと思っている。
フックのテストはたいてい、こう書く。
echo '{"tool_input":{"command":"find /tmp/x -delete"},"transcript_path":"/…/subagents/agent-abc.jsonl"}' \
| bash guardian.sh
echo $? # 2 が出る。緑
通る。当然だ。拒否の条件が「サブエージェント由来のとき」である以上、単体テストで拒否が返ったのなら、流した JSON には /subagents/ を含む道が入っていたことになる。テストの入力を書いた人と、判定条件を書いた人が同じだと、この2つは同じ想像から出てくる。
テストが検査しているのは「条件式が意図どおりに動くか」であって、「その条件が本番で真になることがあるか」ではない。後者はテストの外側にある。
「強制されない」のほうも測った
原因の説明が付いたので、強制の側も測り直した。親と子で拒否がちゃんと効くこと自体は前に測って書いたので、今回足したのは背景実行と Explore の2つになる。
判定は副作用でやる。サブエージェントの自己申告を信じないためだ(#84701 の報告者が正しかった点でもある)。撃たせるコマンドを touch にして、ファイルが残っていたら拒否を突破して実行された、とする。
4つの場所から同じことをさせた。
1 上位エージェント本人 → 止まった 証拠ファイルなし
2 サブエージェント(通常) → 止まった 証拠ファイルなし
3 サブエージェント(run_in_background:true)→ 止まった 証拠ファイルなし
4 サブエージェント(Explore) → 止まった 証拠ファイルなし
証拠ディレクトリは空のままだった。4件とも tool_result は is_error: true で、拒否の理由の文字列がそのまま返っている。背景実行が本当に有効だったかは、モデルの言葉ではなく Agent の呼び出しの入力(run_in_background)を読んで確かめた。
少なくとも自分が測った版と環境では、「背景実行だから」も「Explore だから」も、拒否が外れる理由になっていない。
同じずれは、反対向きにも刺さる
もう1本、同じ根の起票がある。#76333 は、この transcript_path のずれそのものを報告している。
そちらのフックは「必要な道具が今回のターンで呼ばれたか」を記録から読んで判定する門だった。サブエージェントの中で走ると親の記録を読みに行き、子の道具呼び出しはそこに無いので、「前提を満たしていない」と結論して編集を片っ端から拒否した。
同じ1つの欄が、フックの条件の向き次第で拒否しすぎにも許しすぎにもなる。前者は作業が止まるだけだ。後者は、止めたつもりの破壊的なコマンドがそのまま走る。#84701 の人がフックを固めることにしたのは、その前にホームディレクトリを失っているからだった。
この2本を結びつけた記述は、まだどちらのスレッドにも無い。ただし片方(#76333)には開発元が答えている。放置されているのは #84701 のほうだ。
1分でできる確認
自分のフックが該当するかは、これで分かる。さっきの読み捨てフックを登録して、サブエージェントに何か1つ撃たせたあと、欄の一覧を見る。
jq -r 'keys | join(", ")' /tmp/payloads.jsonl | sort -u
# 上位側 : cwd, effort, hook_event_name, permission_mode, prompt_id, session_id, ...
# サブ側 : 同じ並びに agent_id, agent_type が増えた行が出る
行が2種類出れば成功。増えている欄が、サブエージェントを見分ける手掛かりになる。1種類しか出ないなら、サブエージェントの呼び出しがそもそもこのフックに届いていない。その場合は登録先の名前のほうを疑う(claude doctor で Unknown hook event が出ていないか)。
見分けに使える確実な手掛かりは、この版では agent_id と agent_type の有無だけだった。ついでに言うと、session_id が親と同じということは、セッション単位の錠前や排他もサブエージェント全員で共有される。並列に出した N 体が全部同じ鍵を持って全部通る。これは #76726 が Windows 11 で報告している内容と一致した。
ついでに言うと、これは自分にも刺さった。セッションを鍵にして道具の呼び出し回数を数えるフックを使っていたのだが、session_id が親子で共有される以上、そのカウンターは親と全サブエージェントの合算になる。暴走した1体を名指しで止められないし、その子が枠を焼き切ると、まだ1回も呼んでいない親のほうが止まる。両方とも手元で再現した。鍵を agent_id ごとにも分けて直してある。
自分も同じ日に、同じ形で外している
正直に書いておく。
今夜この件を調べ始めたきっかけは、5か月前に自分が別の起票(#34692)へ書いた「Can confirm」の撤回だった。「サブエージェントでは親のフックが発火しない」という報告に、一度も再現していないのに同意を書いていた。測ったら逆だった——ここまでは前に書いた。今夜の続きがある。
その撤回文で自分が消したのは、こういう趣旨の一文だ。「誰も、拒否が子の中でコマンドを実際に止めるかを確かめていない」。スレッドの中の報告が「呼ばれたか」しか見ていない、というほうは事実なので残した。消したのは、スレッドの外まで広げた否定のほうだ。
投稿してから7分後、追跡をちゃんと検索したら #84701 が出てきた。まさに強制を測っていて、しかも独立に副作用まで確かめている報告だ。自分の「誰も」は、読んでいない集合について書いた否定だった。
同意にコストがかからないのと同じで、否定にもコストがかからない。どちらも件数としては数えられてしまう。訂正はコメントを編集して、消した文と理由をそのまま残す形で入れた。
この記事の内容も、同じ穴を持ち得る。だから測った条件を書く。
測った条件と、書けないこと
- Claude Code 2.1.246、Linux(WSL2)、
claude -pの非対話実行 -
CLAUDE_CONFIG_DIRを一時ディレクトリへ向け、本番の~/.claudeは関与させていない - サブエージェントは
general-purposeとExplore、run_in_backgroundは真偽の両方
この家系には、説明が付いていない報告が2本ある。macOS・2.1.198 での断続的な素通り(#73380。Explore のサブエージェントが拒否対象のファイルを1回だけ読めて、再試験では正しく止まった)と、Explore のサブエージェントで cd X && cmd の複合コマンドがフックを素通りしたという報告(#78970)だ。こちらが撃たせたのは touch の単体で、複合コマンドの形は測っていない。版と OS と対話・非対話の差も分離できていない。
なので書けるのは2つだけだ。「サブエージェントでは拒否が効かない」は一般には成り立たない、ということ。そして、効かないように見える原因のひとつは本体ではなくフックの見分け方の側にある、ということ。
手で作った入力で通ったことは、本番の入力で通ることの証明にならない。 判定に使う欄は、本番の入力を1回落として実物で確かめる。1分で済む。
フックそのものは cc-safe-setup に置いてある(無料)。