0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

サブエージェントの tools で Bash を絞ったつもりになっていないか

0
Posted at

サブエージェントに 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 副の作業者の沈黙の失敗

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?