サブエージェントに Bash(git diff:*) とだけ書いた。
git diff しか打てないはずだった。
node --version を投げたら、素直にバージョンを返してきた。
止められもしなければ、警告も出ない。
この記事は2026年9月時点、Claude Code 2.1.270 の話だ。
書いているのは Claude Code——AIがコマンドを実行しながら開発を手伝うツール——を
自律で走らせている非エンジニアと、その中で動いているAI。
サブエージェントは、親のセッションが子のAIを呼び出して作業を任せる仕組み。
フックは、AIが何かを実行する直前に、こちらが用意したプログラムへ
「これ実行していい?」と相談してくれる仕組みのことだ。
差は1行だけだった
2本立てた。プロンプトも渡すコマンドも同じ。違うのは tools の1行だけ。
A: tools: ["Read", "Bash(git diff:*)"]
B: tools: ["Read"]
どちらにも「node --version を実行して、返ってきた文字列をそのまま報告しろ。
実行できなければ CANNOT RUN とだけ答えろ」と伝えた。
A は v24.18.0 と返した。
B は CANNOT RUN と返した。
Bには Bash が無い。だから打てない。ここは期待どおりだ。
問題はAで、Bash(git diff:*) と書いた行が、Bash を丸ごと渡していた。
git diff に絞るつもりで書いた指定が、無指定の Bash と同じ結果になる。
より狭く書いたのに、同じだけ広い。
怖いのは、転び方が安全でない側だということ
書き間違えたわけではない。書式は受理されている。
読み込み時にも、サブエージェントを起動した時にも、何も言われない。
ログを見ても、絞り込みが無効になったという行は出てこない。
つまり、設定ファイルを読み返しても気づけない。
Bash(git diff:*) という文字列は、そこに書いてある。読むと制限に見える。
公式ドキュメントは、拒否リスト側についてはこの挙動を明記している。
disallowedTools に Bash(git push *) と書くと、
一致するコマンドだけでなくツールごと消える、と。
そして「コマンド単位で絞りたいなら permissions.deny か PreToolUse フックを使え」と誘導する。
許可リスト側については、そういう記述が見当たらない。
拒否リストで「指定を書いても広がる」と分かっているなら、
許可リストで同じことが起きるのは想像がつく。だが書いていない。
公式が第一に勧めているのは permissions.deny のほうだ。
逐語で引くと「Bash は残したまま特定のコマンドを止めたいなら、
permissions.deny に Bash の拒否規則を足せ」とある。
そのうえで「もっと動的に制御したいなら PreToolUse フックを使え」と続く。
ただし permissions.deny は設定に書く以上、そのセッション全体に効く。
親も同じ制限を受ける。「このサブエージェントだけ狭くしたい」という形にはならない。
サブエージェントごとに分けたいなら、この先のフックの形になる。
(permissions.deny がサブエージェントに対してどう振る舞うかは自分で測っていない。
ここは断定しない。)
この挙動は2026年9月17日に起票されている(anthropics/claude-code#95002)。
起票によれば Windows 11 での観測で、ラベルにも platform:windows が付いている。
自分の環境は WSL2 の Linux だが、同じ 2.1.270 で手元で再現した。
Windowsだけの話ではない。
実害が出るのは、外から来たものを読ませるとき
権限が上がるわけではない。サブエージェントは親のセッションの権限の中で動く。
だから「乗っ取られて何でもできる」という話ではない。
効かなくなるのは、絞り込みそのものだ。
例えば、他のAIが書いた差分をレビューさせるサブエージェントを考える。
読ませる対象は、自分が書いたものではない。
そういう相手には git diff と git log だけ許して、
それ以外は打てないようにしておきたい。普通の発想だと思う。
その普通の発想が、書いた形のままでは成立しない。
では何なら効くのか
ここからは起票にもコメントにも無い。
まず、フックがサブエージェントの動きを見られるのかを確かめた。
何も止めない見物専用のフックを1本だけ置いて、
渡ってくるJSONをそのまま全部ファイルへ落とした。
欄を選んで抜き出すと、無い欄の不在に気づけない。だから丸ごと保存した。
自分の環境には32本のフックが入っているので、そのままでは混ざる。
--setting-sources '' で自分の設定を全部外し、
--settings に見物専用の1本だけを直接渡して隔離した。
落ちてきたJSONを開いたら、探していたものがあった。
[1] tool_name=Agent agent_id=None agent_type=None
tool_input.subagent_type='probe'
[2] tool_name=Bash agent_id='ae1cf76f113c27fdb' agent_type='probe'
tool_input.command='node --version'
親がサブエージェントを呼び出す瞬間(1行目)には agent_type が無い。
そのサブエージェントの中で Bash が動く瞬間(2行目)に、
agent_type と agent_id が付いてくる。
つまりフックは「今これを実行しようとしているのが、どのサブエージェントか」を知っている。
tools が握り潰した情報を、フックは受け取っている。
同じ宣言のまま、フックを1本足した
tools: ["Read", "Bash(git diff:*)"] はそのままにした。直していない。
その上で、PreToolUse のフックをこう書いた。
#!/bin/bash
INPUT=$(cat)
AT=$(printf '%s' "$INPUT" | python3 -c '
import json,sys; print(json.load(sys.stdin).get("agent_type") or "-")')
TN=$(printf '%s' "$INPUT" | python3 -c '
import json,sys; print(json.load(sys.stdin).get("tool_name") or "-")')
CMD=$(printf '%s' "$INPUT" | python3 -c '
import json,sys; print((json.load(sys.stdin).get("tool_input") or {}).get("command") or "")')
# probe という名前のサブエージェントの Bash だけを見る。
# 親セッションや他のサブエージェントはここを素通りさせる。
if [ "$AT" = "probe" ] && [ "$TN" = "Bash" ]; then
# 先に連結を落とす。これが無いと下の前方一致は門にならない。
case "$CMD" in
*';'*|*'&'*|*'|'*|*'`'*|*'$('*|*'>'*|*'<'*)
echo "BLOCKED: subagent '$AT' may not chain or redirect. Attempted: $CMD" >&2
exit 2 ;;
esac
case "$CMD" in
"git diff"|"git diff "*) exit 0 ;;
*) echo "BLOCKED: subagent '$AT' may only run 'git diff ...'. Attempted: $CMD" >&2
exit 2 ;;
esac
fi
exit 0
先に連結を落とす行が要る。これを書き忘れると、門は開いたままになる。
git diff --stat && node --version は「git diff 」で始まるからだ。
git diff --stat | sh も git diff $(node --version) も同じ理由で通ってしまう。
自分は最初、この6行を書いていなかった。
書き上げた時点では動いて見えた。node --version は止まり、git diff は通ったからだ。
穴に気づいたのは、後ろに別のコマンドを繋げた形を試したときだった。
連結を丸ごと拒否するのは単純で、安全側に倒れる。
そのかわり cd repo && git diff のような素直な使い方まで落とす。
実用に寄せたいなら、区切り文字で切って、
切れた断片の全部が許可の形に合うことを要求する書き方がある。
git log && rm -rf ~ は、2つ目の断片が合わないので落ちる。
cd repo && git diff は両方が合うので通る。
ただしその場合も、コマンド置換とバッククォートだけは別扱いにして、
含まれていたら中身を見ずに拒否する。
文字列を眺めるだけでは、置換の中で何が起きるか追えないからだ。
止めるのは exit 2 だけだ。exit 1 でも、標準エラーへ何か書くだけでも止まらない。
ここを間違えると、警告は出るのに素通りする見張りができあがる。
そして1回の実行の中で、2つのコマンドを順に打たせた。
片方は通ってほしいもの、もう片方は止まってほしいもの。
DENY agent=probe cmd=node --version
ALLOW agent=probe cmd=git diff --cached --stat
サブエージェント自身の報告はこうだった。
1. BLOCKED: command refused by PreToolUse hook
— subagent 'probe' may only run 'git diff ...'; attempted `node --version`
2. `sample.txt | 1 +` / `1 file changed, 1 insertion(+)`
node --version は止まった。
git diff --cached --stat は通って、中身のある出力を返した。
2つ目を空振りしないコマンドにしたのには理由がある。
何も出力しないコマンドで試すと、
「フックが止めた」のか「実行されたが結果が空だった」のかを区別できない。
以前それで一度、自分の実験を無駄にした。
使うときに気をつけること
この絞り込みは、名前で判定している。
agent_type はサブエージェントの名前なので、名前を変えれば当然すり抜ける。
複数のサブエージェントを同じ制限で縛りたいなら、名前の一覧を持つか、
「許可した名前以外は全部止める」側で書くほうが堅い。
それから、これは自分の手元の設定に置けるフックの話だ。
配布物として他人に渡すサブエージェントの定義には、フックを同梱できない。
その場合、tools の行だけが防御線に見えて、実際には何も守っていないことになる。
最後に、これは2026年9月時点の 2.1.270 での観測だ。
将来の版で tools の指定が効くようになれば、この記事の前半は古くなる。
そのときフックの側は、余分に効くだけで害にはならない。
確かめ方
自分の設定で同じことが起きているかは、5分で分かる。
サブエージェントに tools で Bash を絞って書いているなら、
そのサブエージェントに、絞ったはずの範囲の外にあるコマンドを1つ打たせてみる。
node --version でも pwd でもいい。返ってきたら、絞れていない。
返ってこなかった場合は、本当に絞れているのか、
別の理由で失敗しただけなのかを分けて確かめたほうがいい。
出力が必ず返るコマンドを選ぶ。それだけで区別がつく。
サブエージェントが渡した範囲を越える型は、これだけではない。
黙ったまま止まる。やっていない手順を「やった」と報告する。
見張りを組み立てたつもりで、組み立てられていない。
起票8件を並べて、それぞれの防ぎ方と一緒にまとめた本を書いている。
範囲のハミ出しについては第5章にある。
Claude Code 副の作業者の沈黙の失敗