TL;DR
- エージェントの権限設定は「何をさせないか」をコマンドの綴りで書きがちです。このリポジトリの
deny19 件は全部それでした(Bash コマンド名 10 件・読み取りパス 9 件、社内データ)。影響範囲で書かれたルールは 0 件です。 - 綴りで禁じると、同じ効果に届く別の綴りが残ります。
Bash(rm:*)を deny しながらBash(find:*)を allow していて、find . -deleteは同じ削除に到達します。 - もっと悪い形も出ました。危険コマンドを止めるはずの PreToolUse フックが、本番の payload 形状では 1 件も止めていませんでした。
{"tool_input":{"command":"rm -rf ..."}}を渡すとexit 0を返します(社内データ)。トップレベルにcommandを置いた形ならexit 2で止まるので、テストは通っていました。 - 原因は「危険が無かった」と「読めなかった」を同じ exit 0 に潰していたことです。フックは
commandを取り出せないと素通りしていました。保険のはずのsedフォールバックは正規表現が二重エスケープで壊れていて、6654740以降は一度も抽出に成功していません(出力は空文字)。壊したのは機能変更ではなくdocs:の整形コミットでした。 - 直したのは 3 点です。読み取れない入力はブロックする、payload の形状を決め打ちしない、
git push --forceを語単位で検出する(--force-with-leaseは通す)。修正は PR #467 として main に入っています。 - ただしフックでは被害半径は絞れません。
find -deleteを deny に足しても、次はxargs rm、perl -e unlinkと続きます。このリポジトリで測った範囲では、半径を絞れそうな層はツール層・ファイルシステム・CI トークンという外側にしかありませんでした。 - このリポジトリの実測では、エージェント定義 24 本すべてが
Bashを持ち、ツール層で読み取り専用のものは 0 本でした。第 7 節のスクリプトで、このうちフックとpermissionsの検査だけを手元のリポジトリに対して実行できます(tools:と CI のpermissions:は本文のコマンドを手で叩いてください)。
はじめに:禁止リストは「何を禁じたか」しか記録しない
AI エージェントに自律的な作業をさせたい、という話はだいたい権限設定から始まります。「危ないコマンドは禁止しておこう」「秘密ファイルは読ませないようにしよう」。そうやって deny のリストが伸びていきます。
このやり方には、書いている最中は見えない前提があります。禁止リストは「何を禁じたか」は記録しますが、「何が起きうるか」は記録しない、という前提です。
rm を禁じたとき、記録されるのは「rm を禁じた」という事実だけです。「ファイルが消えないようにした」ではありません。この 2 つがずれていることは、find . -delete を 1 行書けば分かります。
この記事は一般論ではなく、このリポジトリで実際に何を測り、何が壊れていて、何を直したかの記録です。組織としてどう権限を設計すべきかという提言はしません。基準となるコミットは 5832132(2026-09-08)で、数値はすべてその時点のものです。
なお、権限設定と実際の挙動がずれる問題全般は AIガバナンスが効かない4類型と検証手順 で、ガードスクリプトが発火しない壊れ方は 効いていないガードを見つける7つの型 で扱っています。本記事はそのうち 「権限そのものの書き方」に絞り、被害半径という単位で書き直すとどうなるかを実物で見ます。
1. deny 19 件を数えて、何で書かれているかを分類する
まず、このリポジトリの権限設定を数えます。
jq -r '.permissions | "allow=\(.allow|length) deny=\(.deny|length) ask=\(.ask|length)"' \
.claude/settings.json
# => allow=72 deny=19 ask=0
ask が 0 件なのは意図的です。確認プロンプトが多すぎると作業が進まないので、判断を止める代わりに許可と拒否をはっきりさせる方針を採っています。
その 19 件の内訳を見ます。
jq -r '.permissions.deny[]' .claude/settings.json
| 分類 | 件数 | 中身 |
|---|---|---|
| Bash のコマンド名 | 10 | curl / wget / rm -rf / sudo / pkill / kill / rm / git push origin main(2 形)/ git push -f |
| Read のファイルパス | 9 | .env / .env.* / secrets/** / *credential* / *secret* / *.pem / *.key / node_modules/** / pnpm-lock.yaml |
19 件のうち 19 件が「対象の名前」で書かれています。「この操作が失敗したとき、どこまで壊れるか」で書かれたルールは 1 件もありません。
これは責める話ではなく、設定ファイルの表現力の話です。.claude/settings.json の Bash(<prefix>:*) は先頭からの前方一致しか書けないので、そもそも「影響範囲」を表現する語彙がありません。書ける形が名前しかないなら、書かれるものも名前になります。
名前で禁じると、同じ効果の別名が残る
rm は deny されています。では削除できないかというと、そうではありません。
| 到達したい効果 | 綴り | このリポジトリの判定 |
|---|---|---|
| ファイルを消す | rm -rf ./x | deny(Bash(rm:*)) |
| ファイルを消す | find ./x -delete | allow(Bash(find:*) が明示的に許可) |
| ディレクトリを消す | git worktree remove --force ./x | allow(Bash(git worktree:*)) |
同じ「消える」に対して、判定が逆になっています。しかも find は明示的な allow なので、確認すら挟まりません。
これは「find を deny に足せば直る」話ではありません。足しても次に xargs rm、perl -e unlink、python3 -c と続きます。綴りの集合は無限に開いていて、閉じることができません。
2. 止めていたつもりのフックが、本番形状では 1 件も止めていなかった
権限設定の外側に、もう 1 枚ガードがあります。.claude/hooks/safety.sh という PreToolUse フックで、Bash ツールの呼び出し前に走り、危険なコマンドの文字列に一致したらブロックします。
これが本番では何も止めていませんでした。
Claude Code の PreToolUse フックは、標準入力に JSON を受け取ります。公式ドキュメントの入力例のとおり、ツールの引数は tool_input の下にネストされています。Bash なら tool_input.command です。
修正前のフックに、その形で危険コマンドを渡すとこうなります。
# 修正前のフックを取り出す。現在の .claude/hooks/safety.sh は修正後なので、
# そのまま叩くと両方 2 になる。
# 書き込むのは自分で作った一時ディレクトリだけにする(固定パスに置かない)。
WORK="$(mktemp -d)"
git show 5832132^:.claude/hooks/safety.sh > "$WORK/safety_before.sh"
# 破壊的コマンドは実行していない。フックに文字列を渡して終了コードを見るだけ。
printf '{"tool_input":{"command":"rm -rf ./sandbox"}}' | bash "$WORK/safety_before.sh"
echo $?
# => 0 ← 素通り(本番はこの形で渡してくる)
printf '{"command":"rm -rf ./sandbox"}' | bash "$WORK/safety_before.sh"
echo $?
# => 2 ← こちらは止まる
rm -rf "$WORK"
本番が使う形だけ通り、本番が使わない形だけ止まっていました。
修正前後を並べます。フックに JSON を渡して終了コードと stderr を読んだ結果です(社内データ)。
| コマンド | 到達する効果 | #467 前 | #467 後 |
|---|---|---|---|
rm -rf ./sandbox | 削除 | 通す | 止める(危険検出) |
rm -r -f ./sandbox | 削除(フラグ分割) | 通す | 止める(危険検出) |
rm -rf ./sandbox | 削除(空白 2 個) | 通す | 止める(危険検出) |
find ./sandbox -delete | 削除(allow 済みの別経路) | 通す | 通す |
git push --force origin main | 履歴の巻き戻し | 通す | 止める(危険検出) |
git push origin main --force | 履歴の巻き戻し(引数順) | 通す | 止める(危険検出) |
git push --force-with-lease origin feat/x | 安全側の上書き | 通す | 通す |
ls -la | 無害 | 通す | 通す |
find ./sandbox -delete が修正後も「通す」のは、直し忘れではありません。第 6 節で書くとおり、そこはフックの担当ではないと判断しました。
3. exit 0 が意味していたのは「安全」ではなく「読めなかった」
なぜ気づけなかったのか。フックの終了コードを見ます。
修正前のフックは、command を取り出せなかったときにこう書いていました。
if [ -z "$command" ]; then
exit 0
fi
exit 0 は Claude Code にとって「このコマンドは実行してよい」という意味です。つまりこのフックは、「危険が見つからなかった」と「そもそも検査できなかった」を同じ返事にしていました。
tool_input にネストされた payload では command が空になるので、毎回この行を通って exit 0 していたわけです。ログ上は正常です。ブロックが 0 件なのは「危ないことをしていない」ように見えます。
保険はありました。node で JSON を解析できないときに備えて、sed で "command" を拾うフォールバックがあります。これも死んでいました。
# 修正前のフォールバックが実際にやっていたこと
printf '{"tool_input":{"command":"rm -rf ./sandbox"}}' |
sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p'
# => (空。1 件も抽出できていない)
シングルクォートの中の \\( は、sed には「バックスラッシュ 1 文字、そのあとに丸括弧」として渡ります。BRE のグループ化 \( ではありません。入力に literal な \( が現れることはないので、この形になって以降は 1 件も抽出できません。
いつ壊れたのかを git で追うと、話はもう少し悪くなります。
git log --oneline --follow -- .claude/hooks/safety.sh | tail -3
# 5832132 fix(guard): safety hook が検査不能を「問題なし」として素通りしていた問題を修正 (#467)
# 6654740 docs: PR#47のレビュー指摘に基づき情報を整理・修正
# 1579260 feat: エージェント設定ファイル(Antigravity Kit)の移行と最適化
git show 1579260:.claude/hooks/safety.sh | grep -n 'sed -n'
# 15: ... sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' ← 正しい
git show 6654740:.claude/hooks/safety.sh | grep -n 'sed -n'
# 15: ... sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' ← 二重エスケープ
導入時(1579260、2026-02-03)の正規表現は正しく動いていました。実際、その版に本番形状の payload を渡すと exit 2 でブロックします。壊れたのは 2 日後の 6654740(2026-02-05)で、コミットメッセージは docs: PR#47のレビュー指摘に基づき情報を整理・修正 です。保険を殺したのは機能変更ではなく、整形のつもりのコミットでした(社内データ)。
2 つの経路が、別々の理由で表面化を防いでいました。トップレベルに command がある形では node が値を取れるので、フォールバックは実行されません。本番の nested な形では node が空を返すのでフォールバックまで進みますが、そこで空文字が返ります。どちらの経路でも「壊れている」という信号が出ないわけです。壊れた保険は、壊れていることを誰にも知らせません。
これは 効いていないガードを見つける7つの型 で書いた「存在するが発火しないガード」と同じ型で、今回は発火しないことが exit 0 という合格の顔で表示されていた分だけ悪質でした。
直したこと
修正(PR #467)は 3 点です。
- 読み取れない入力はブロックする。payload が空 / JSON が壊れている /
commandキーがどこにも無い、のいずれもexit 2にしました。「検査できなかった」を合格に丸めません。 - payload の形状を決め打ちしない。
commandキーを持つ文字列を深さ優先で探し、tool_input.commandでもトップレベルでも、それより深いネストでも読めるようにしました。環境変数CLAUDE_BASH_COMMAND/CLAUDE_TOOL_INPUTと標準入力の 3 経路も維持しています。 - ブロック理由を出力で分ける。危険検出は
Blocked by safety hook: ...、検査不能は検査不能を stderr に出します。
3 番目は自分で踏んだ罠から入れました。この修正の初版は、fail-closed にするのと同時に読み取り範囲をトップレベルだけに狭めてしまい、ls -la すら exit 2 になる状態でした。レビューで止められています。
このとき分かったのは、読み取り範囲を狭めたまま fail-closed にすると、正常系まで「検査不能」に分類され、それが全ブロックとして出てくるということでした。しかも終了コードだけを見ていると、その全ブロックが「正しくブロックできた」に見えます。ブロック理由を分けたのはそのためです。
4. 能力では書けない要件がある
もうひとつ、同じ型の穴が見つかりました。force push です。
deny に入っていたのは Bash(git push -f:*) だけで、長い綴りの --force がありません。そして allow には Bash(git push:*) が広く入っています。
--force が抜けていたのは意図的でした。Bash(git push --force:*) を deny に入れると、前方一致なので --force-with-lease まで巻き込みます。git-push のドキュメントにあるとおり --force-with-lease は他人の commit を消さない安全側の操作なので、それを潰したくなかったわけです。
安全な長い綴りを通すために、危険な長い綴りも通していたという状態です。第 1 節の rm と find とまったく同じ構造です。
しかも Bash(git push origin main) は引数順に依存します。git push --force origin main は文字列として一致しません。同じ形の穴は、私たちが運用している別のリポジトリでも独立に見つかりました(非公開リポジトリの記録なので詳細は示せません、社内データ)。
ここで重要なのは、この要件は permissions では原理的に書けないという点です。
| 書きたい判定 | 前方一致で書けるか |
|---|---|
--force を止め、--force-with-lease は通す | 書けない(前方一致が後者まで巻き込む) |
git push origin main --force のような引数順 | 書けない(prefix が途中で切れる) |
そこで、この判定だけはフック側で引数を語単位に解析するようにしました。列挙ではありません。
# 語に分割し、git push のスコープ内でだけフラグを見る
for word in "${words[@]}"; do
if [ "$in_push" -eq 1 ]; then
case "$word" in
--force-with-lease | --force-with-lease=* | --force-if-includes) ;; # 通す
--force) return 0 ;; # 止める
--*) ;;
-*f*) return 0 ;; # -f / -uf も止める
esac
fi
# 省略部分は、区切り文字(; & | 括弧)でスコープを畳む処理と、
# git / push の並びを見て in_push を立てる処理。
done
引数順に依存せず、git push origin main --force も pnpm test && git push --force origin main も検出します。pnpm render:article-images x --force のような push 以外の --force は誤爆しません。
.claude/settings.json 側には何も足していません。Bash(git push --force:*) を足すと、そもそもの問題(前方一致が --force-with-lease を巻き込む)を再現するだけだからです。
5. 影響範囲を絞れる層はどこにあるか
ここまでで、文字列マッチでは被害半径を絞れないことが分かりました。では、どの層なら絞れるのか。このリポジトリの各層を測ります。
ツール層:読み取り専用のエージェントは 0 本だった
エージェント定義には tools: 行があり、そのエージェントが使えるツールを列挙できます。ここは綴りではなく能力の集合なので、原理的には半径を絞れる層です。
ls .agents/agents/*.md | wc -l # => 25(うち 1 件は README)
grep -l '^tools:.*Bash' .agents/agents/*.md | wc -l # => 24
grep -lE '^tools:.*(Write|Edit)' .agents/agents/*.md | wc -l # => 22
# grep は 0 件マッチで exit 1 を返す。set -e 下で無言終了しないよう保護する。
grep -h '^tools:' .agents/agents/*.md | grep -vcE '(Bash|Write|Edit)' || true # => 0
# 上の 3 行は tools が 1 行に並ぶフロー記法(tools: Read, Bash)を前提にしている。
# ブロック記法(tools: の次行から - Bash)だと ^tools:.*Bash が当たらず、
# 読み取り専用エージェントを過大報告する。前提が成り立つことを先に確かめる。
grep -c '^tools:[[:space:]]*$' .agents/agents/*.md | grep -v ':0$' || true # => 出力なし(全件フロー記法)
24 本すべてが Bash を持ち、22 本が Write か Edit を持ち、ツール層で読み取り専用のものは 0 本でした(社内データ)。この数え方は上のとおり tools: がフロー記法で書かれていることに依存しています。ブロック記法のリポジトリでは安全側ではなく危険側に外れる(読み取り専用を多く見積もる)ので、最後の 1 行で前提を確認してから読んでください。
このリポジトリには「Checker は成果物を直接修正しない」という契約が散文で書かれています。しかし article-reviewer の tools: には Write が入っています。契約は文章で、権限はツール層にあり、両者は繋がっていません。同じ問題は Issue Triage Agentを安全に作る役割分離 でも実測しています。
ファイルシステム層:worktree はブランチの隔離であって FS の隔離ではない
並列作業では git worktree を使っています。「エージェントごとに worktree を分ければ隔離できる」という理解でしたが、測ると違いました。
git rev-parse --show-toplevel
# => .../notionnext-blog/.claude/worktrees/article-blast-radius
git rev-parse --git-common-dir
# => .../notionnext-blog/.git ← 親と共有
test -f ../../../package.json && echo reachable
# => reachable ← 親の作業ツリーに届く
cat ../../../.git/HEAD
# => ref: refs/heads/main ← 親のブランチも読める
worktree は .claude/worktrees/ の下、つまり親の作業ツリーの内側にあります。.git は共有で、相対パスを 3 つ上がれば親のファイルに届きます。
分離されているのはブランチであって、ファイルシステムではありません。 実際、worktree を指定して委譲したのに親のチェックアウトを切り替えてしまった事故が起きています。ここから先、別マウント・読み取り専用バインド・コンテナといった OS 側の仕組みまで降りないと半径は縮まらない、というのがこのリポジトリで出した結論です。ただしどれもまだ実施していません(第 8 節)。Kubernetes を非 root ユーザーで動かす公式ドキュメントが扱う user namespace は、まさにこの層です。エージェントのサンドボックスとして rootless コンテナを使うのは、「何をさせないか」ではなく「どこまで届くか」を設定する行為にあたります。
拡張の実行を隔離する具体例は WASM Component Modelで「安全な拡張」を作り、AIワーカーの変更を隔離する で扱っています。
CI 層:ここだけは半径が宣言されていた
意外だったのが CI です。
ls .github/workflows/*.yml | wc -l # => 6
grep -rl '^permissions:' .github/workflows/ | wc -l # => 4
6 本中 4 本が top-level の permissions: を宣言していました。検証系(content-ci / article-risk-gate-check)は contents: read、記事を作る系(blog-factory-planner / blog-factory-executor)は contents: write と pull-requests: write です。
GitHub の公式ドキュメントが推奨する形です。**ここでは「何を実行するか」ではなく「トークンがどこまで届くか」で書かれています。**残る 2 本(readme-sync-check / spec-sync-check)は未宣言でリポジトリ既定を継承します。
同じリポジトリの中で、CI 層だけが影響範囲で書かれ、エージェント層は綴りで書かれていた、というのがこの記事の出発点です。
層ごとの整理
| 層 | 書ける単位 | このリポジトリの状態 |
|---|---|---|
| permissions | コマンド名の前方一致 | deny 19 件すべて名前。同効果の別名が残る |
| PreToolUse フック | コマンド文字列 | 修正後は嘘をつかなくなったが、半径は絞れない |
ツール層(tools:) | 能力の集合 | 24/24 が Bash、読み取り専用 0 本 |
| ファイルシステム | 到達可能な範囲 | worktree はブランチのみ隔離。FS は共有 |
| CI トークン | 権限スコープ | 6 本中 4 本が宣言済み |
上 2 つは綴りの層で、下 3 つが半径の層です。このリポジトリは、綴りの層に力を入れて、半径の層をほとんど設定していませんでした。
被害範囲を区画に切って閉じ込めるという考え方自体は新しくありません。AWS の Well-Architected は bulkhead アーキテクチャで影響範囲を限定することを設計原則として挙げていますし、NIST SP 800-207 のゼロトラストも、境界の内側を信頼しないという同じ発想です。OWASP の LLM Top 10 は LLM06 Excessive Agency で、過剰な機能・権限・自律性を分けて扱うことを求めています。足りていなかったのは考え方ではなく、それを自分のリポジトリで測る手順のほうでした。
権限の割り当てそのものの整理は 権限マトリクスとガードレール設計、止まったときに誰へ上げるかは 人間へのエスカレーション設計、実行時・流出まで含めた 4 層の整理は AIコーディングセキュリティ設計 にあります。本記事はそれらの下にある「どの層で何を書けるか」の話です。
6. なぜ find -delete を deny に足さなかったか
修正後も find ./sandbox -delete は通ります。足せば 1 行で済むのに、足しませんでした。
理由は 2 つあります。
1 つ目は、このリポジトリでは閉じられなかったからです。find を足せば xargs rm、perl -e unlink、python3 -c "shutil.rmtree(...)"、bash -c "..." と続きます。列挙側は 1 行ずつ増やせますが、回避側は手元に入っている言語処理系の数だけ選択肢がありました。追いつく見込みが立たなかったので、追うのをやめました。
2 つ目は、足すと「対策した」ことになってしまうからです。deny に find が並んでいれば、次に見た人は削除経路が塞がれていると読みます。実際には塞がっていません。このリポジトリでは、穴が開いていると分かっている状態のほうを選びました。
代わりにやったのは、この境界を検証で固定することです。「find -delete は止まらない」ことをテストに書き、フックの冒頭コメントにも「ここを増築して安心しないこと」と残しました。以下は tests/test_guard_self_check.py からの抜粋で、docstring は要約しています。
def test_documents_that_enumeration_does_not_bound_blast_radius(self):
"""同じ削除効果に到達する別経路はフックでは止まらない。
これは不具合ではなく、文字列マッチの原理的な限界。
列挙を増やしてこのテストを緑にしようとしないこと。
"""
proc = self._run(self._payload("find ./sandbox -delete"))
self.assertEqual(proc.returncode, 0, proc.stderr)
self.assertIn("blast radius", SAFETY_HOOK.read_text(encoding="utf-8"))
穴を塞げないなら、せめて穴の位置を動かないように留める、という判断です。
7. 手元のリポジトリで同じ検査をする
ここまでの検査をまとめたスクリプトです。**対象リポジトリに対しては読み取りしかしません。**フックに JSON 文字列を渡して、終了コードと stderr を読むだけです(後片付けとして、自分が mktemp -d で作った一時ディレクトリだけを削除します)。
終了コードは 3 つに分けています。0 = 検査して指摘なし、1 = 指摘あり、2 = そもそも検査できなかった(設定ミス)。検査対象が 0 件のときに 0 を返さないのが要点です。0 件になる経路は 4 つあります(フックが無い / jq が無い / settings.json が JSON として読めない / permissions の allow と deny がどちらも 0 件)。最後の 1 つは、最も危険な状態が最も静かに通る経路なので、明示的に「未検査」に倒しています。
必要なものは bash 3.2 以上だけです(macOS 標準の bash でも動作を確認しました)。jq があればスクリプト内の検査 4(第 1 節でやった deny と allow の突き合わせ)も走り、無ければ「未検査」として exit 2 になります。
#!/usr/bin/env bash
# blast-radius-audit.sh <repo>
#
# 対象リポジトリに対しては読み取りしかしない。フックに JSON を渡し、
# 終了コードと stderr だけを読む。書き込むのは自分で作った一時ディレクトリだけ。
#
# 終了コード: 0 = 検査して指摘なし / 1 = 指摘あり / 2 = 検査できなかった(設定ミス)
# 必要なもの: bash 3.2 以上。jq があれば permissions も検査する(無ければ「未検査」で 2)。
set -uo pipefail
REPO="${1:-.}"
HOOK="$REPO/.claude/hooks/safety.sh"
SETTINGS="$REPO/.claude/settings.json"
WORK="$(mktemp -d)" || { echo "一時ディレクトリを作れませんでした。" >&2; exit 2; }
trap 'rm -rf "$WORK"' EXIT
findings=0
unchecked=0
note() { printf ' [指摘] %s\n' "$1"; findings=$((findings + 1)); }
skip() { printf ' [未検査] %s\n' "$1" >&2; unchecked=$((unchecked + 1)); }
# 検査対象が無いことを「合格」と同じ顔で返さない
if [ ! -f "$HOOK" ]; then
echo "検査対象: 0 件($HOOK が無い)" >&2
echo "これは『合格』ではありません。フックのパスを引数で指定してください。" >&2
exit 2
fi
# コマンド文字列を JSON 文字列へ埋める(単一行のコマンドを前提)
json_escape() {
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/ /\\t/g'
}
json_nested() { printf '{"tool_input":{"command":"%s"}}' "$(json_escape "$1")"; }
json_flat() { printf '{"command":"%s"}' "$(json_escape "$1")"; }
# exit 2 を「危険検出」と「検査不能」に分類する。
# これを分けないと、正常系が全ブロックされている状態を「正しく止まっている」と誤読する。
probe() {
local rc
printf '%s' "$1" | bash "$HOOK" >/dev/null 2>"$WORK/err"
rc=$?
if [ "$rc" -eq 0 ]; then echo "通す"
elif grep -qi 'blocked\|検出\|danger' "$WORK/err" 2>/dev/null; then echo "止める(危険検出)"
else echo "止める(検査不能)"; fi
}
echo "== 1. 本番形状(tool_input.command)で危険コマンドを止めるか =="
for danger in 'rm -rf ./sandbox' 'git reset --hard HEAD~1'; do
nested="$(probe "$(json_nested "$danger")")"
flat="$(probe "$(json_flat "$danger")")"
printf ' %-24s nested=%-16s top_level=%s\n' "$danger" "$nested" "$flat"
# 「非対称(本番形状だけ素通り)」と「両方素通り」を分けて数える。
# 片方だけを指摘条件にすると、どちらの形状でも止めていない最悪の状態が
# 指摘 0 件・exit 0 になる(=この記事が批判している丸め方そのもの)。
if [ "$nested" = "通す" ] && [ "$flat" = "通す" ]; then
note "どちらの payload 形状でも素通りする(危険コマンドを 1 件も止めていない): $danger"
elif [ "$nested" = "通す" ]; then
note "本番形状だけ素通りする(payload の読み取りがトップレベル限定): $danger"
fi
done
echo "== 2. 検査できなかった入力を素通りさせないか =="
for payload in '{"tool_input":{"description":"no command"}}' '{not json' ''; do
result="$(probe "$payload")"
printf ' %-38s %s\n' "${payload:-(空 payload)}" "$result"
[ "$result" = "通す" ] && note "検査不能を素通りさせている(fail-open): ${payload:-(空 payload)}"
done
echo "== 3. 正常系を過剰にブロックしないか =="
for benign in 'ls -la' 'git status' 'git push origin feat/x'; do
for shape in nested flat; do
result="$(probe "$(json_${shape} "$benign")")"
[ "$result" != "通す" ] && note "正常なコマンドをブロックしている($shape): $benign → $result"
done
done
echo "== 4. 禁じた効果に別経路が残っていないか =="
if [ ! -f "$SETTINGS" ]; then
skip "$SETTINGS が無いため permissions を検査していません"
elif ! command -v jq >/dev/null 2>&1; then
skip "jq が無いため permissions を検査していません"
elif ! jq empty "$SETTINGS" >/dev/null 2>&1; then
skip "$SETTINGS が JSON として読めないため permissions を検査していません"
else
deny="$(jq -r '.permissions.deny[]? // empty' "$SETTINGS")"
allow="$(jq -r '.permissions.allow[]? // empty' "$SETTINGS")"
printf ' allow=%s deny=%s ask=%s\n' \
"$(printf '%s\n' "$allow" | grep -c . || true)" \
"$(printf '%s\n' "$deny" | grep -c . || true)" \
"$(jq -r '.permissions.ask | length // 0' "$SETTINGS")"
# 4 つ目の 0 件経路。permissions が空なら突き合わせる対象が無い。
# allow=0 deny=0 を「指摘なし」と同じ顔で返さない。
if [ -z "$deny" ] && [ -z "$allow" ]; then
skip "permissions の allow / deny が 0 件のため突き合わせる対象がありません: ${SETTINGS}"
else
if printf '%s\n' "$deny" | grep -q 'Bash(rm' && printf '%s\n' "$allow" | grep -q 'Bash(find'; then
note "rm を deny しつつ find を allow している(find -delete が同じ削除に到達する)"
fi
# マッチ 0 件で無言終了しないよう || true で保護する
broken="$(printf '%s\n' "$deny" | grep -E 'Bash\([^)]+:\*.+\)' || true)"
if [ -n "$broken" ]; then
while IFS= read -r rule; do
note "途中に :* を挟む deny は前方一致にならず機能しません: $rule"
done <<<"$broken"
fi
fi
fi
echo
if [ "$unchecked" -gt 0 ]; then
echo "未検査 $unchecked 件。合格ではありません(指摘 $findings 件)。"
exit 2
fi
if [ "$findings" -gt 0 ]; then
echo "指摘 $findings 件。"
exit 1
fi
echo "指摘なし。ただし見たのは PreToolUse フックと permissions だけです。"
exit 0
このリポジトリの 5832132 時点に対して実行すると、こうなります。
== 1. 本番形状(tool_input.command)で危険コマンドを止めるか ==
rm -rf ./sandbox nested=止める(危険検出) top_level=止める(危険検出)
git reset --hard HEAD~1 nested=止める(危険検出) top_level=止める(危険検出)
== 2. 検査できなかった入力を素通りさせないか ==
{"tool_input":{"description":"no command"}} 止める(検査不能)
{not json 止める(検査不能)
(空 payload) 止める(検査不能)
== 3. 正常系を過剰にブロックしないか ==
== 4. 禁じた効果に別経路が残っていないか ==
allow=72 deny=19 ask=0
[指摘] rm を deny しつつ find を allow している(find -delete が同じ削除に到達する)
指摘 1 件。
# exit 1
修正前のフックを置いたディレクトリに対して実行すると 指摘 6 件・exit 1、フックが存在しないディレクトリに対しては exit 2、settings.json が無い(または壊れている)ディレクトリに対しては 未検査 1 件で exit 2 になります。この 4 通りが区別できることは実際に確認しました。
8. この設計で解けないこと
正直に書いておきます。
- フックは半径を絞りません。 第 6 節のとおり、
find -deleteは通ります。フックが直したのは「嘘をつかないこと」だけです。 - 1 リポジトリの記録です。 deny 19 件・エージェント 24 本という数字はこのリポジトリの値で、一般法則ではありません。持ち帰れるのは数字ではなく、第 7 節の測り方のほうです。
- 半径の層はまだ設定していません。 読み取り専用の
tools:を持つエージェントは 0 本のままですし、worktree を FS ごと隔離してもいません。この記事は「測って、綴りの層の嘘を直した」ところまでです。 askを増やす方向は採っていません。 確認プロンプトを増やせば止まりますが、それは半径を絞ったのではなく作業を止めただけです。今回の修正で確認が増えた箇所はありません。- フックには既知の偽陽性があります。 引用符の中の危険文字列にも反応します。引用の中身を除外すると
bash -c "..."を素通りさせる偽陰性が生まれるので、偽陽性のほうを残しました。ガードとしては見逃しのほうが悪い、という判断です。
まとめ
このリポジトリで測った結果は次のとおりです(5832132 時点、社内データ)。
deny19 件は全部コマンド名とファイルパスで書かれていて、影響範囲で書かれたものは 0 件rmを deny しながらfindを allow していて、同じ削除に別経路が残っている- 危険コマンドを止めるはずのフックは、本番の payload 形状では 1 件も止めていなかった
- その原因は「危険が無かった」と「読めなかった」を同じ
exit 0に潰していたこと - エージェント 24 本すべてが
Bash持ちで、ツール層で読み取り専用は 0 本 - worktree はブランチの隔離であって FS の隔離ではない
- 6 本の CI ワークフローのうち 4 本だけが、影響範囲(トークン権限)で書かれていた
綴りで禁じる限り、禁止リストは伸び続けて、それでも閉じません。このリポジトリでは、綴りの層には「嘘をつかないこと」だけを期待し、半径そのものはツール層・ファイルシステム・CI トークンで設計する、という置き方にしました。
まだ半径の層は設定していません。次に測るのはそこです。
FAQ
Blast Radius(被害半径)とは何ですか
ある操作が失敗したときに、影響が届く範囲のことです。信頼性設計では、障害を区画に閉じ込めて全体に波及させない考え方として使われます(AWS Well-Architected の bulkhead など)。AI エージェントの権限設計に持ち込むと、「何を実行させないか」ではなく「実行できてしまったとき、どこまで壊れるか」を設計単位にする、という意味になります。
危険なコマンドを deny リストに列挙するのは無意味ですか
無意味ではありませんが、それだけでは閉じませんでした。このリポジトリでは rm を deny しつつ find を allow していて、find . -delete が同じ削除に到達しています。列挙で減らせたのは「うっかり打つ」経路までで、範囲そのものは変わりませんでした。範囲のほうは、ツール層・ファイルシステム・トークン権限で書くしかない、というのがこのリポジトリでの整理です。
PreToolUse フックが何も止めていないことは、どうすれば分かりますか
本番と同じ payload 形状でフックを叩き、終了コードを見ます。Claude Code の PreToolUse は {"tool_input":{"command":"..."}} の形で渡します。この形で危険コマンドを渡して exit 0 が返るなら、そのフックは本番で機能していません。第 7 節のスクリプトがこの検査を含んでいます。
fail-open を fail-closed に変えるとき、気をつけることはありますか
入力の読み取り範囲を同時に狭めないことです。狭めると正常なコマンドまで「検査不能」に分類され、それが全ブロックとして現れます。このリポジトリでは、修正の初版で実際にそうなりました。あわせて、ブロック理由を「危険検出」と「検査不能」で出力に分けておくと、終了コードだけを見て誤読することを防げます。
git push --force を deny に入れられないのはなぜですか
前方一致だからです。Bash(git push --force:*) を deny に入れると --force-with-lease まで巻き込みます。後者は他人の commit を消さない安全側の操作なので、潰したくありません。このリポジトリでは permissions には足さず、フック側で引数を語単位に解析して両者を区別しました。
References
- Claude Code — Hooks(PreToolUse の JSON 入力と exit code 2 の意味)
- git-push Documentation(
--forceと--force-with-lease) - AWS Well-Architected — REL10-BP03 Use bulkhead architectures to limit scope of impact
- NIST SP 800-207 Zero Trust Architecture
- OWASP LLM06:2025 Excessive Agency
- Kubernetes — Running Kubernetes Node Components as a Non-root User
- GitHub Docs — Controlling permissions for GITHUB_TOKEN
