先日、「拒否ルールを全部消しても、Claude Code は eval を止めた。守っていたのは別の仕組みだった」という記事を読んで、正直ゾッとした。
私は個人でAIエージェント基盤を作り、業務の一部を無人で回している。安全装置の根拠をpermissions.denyだけに置いていたら、それは「気休め」でしかなかったかもしれない、ということだからだ。
自分でも同じ検証をやり直せれば良かったのだが、今回は時間の都合で再現実験はできていない。しかも元記事の試行数・条件別の内訳は、記事内の説明だけでは私自身が正確に再構成できなかった(総試行数の式と、条件ごとの内訳の記述が一致しない箇所があるように見えた)。そこで本稿では元記事の細かい数値やコマンドごとの条件分岐を転記・要約することはせず、**「元記事が報告した中心的な観測」**だけを出発点にする。
元記事が報告した中心的な観測
私が本稿の起点として使うのは、元記事のこの一点だけだ。
-
secret.txtへのRead/Edit/Writeを拒否するdenyルールを設定ファイルから削除しても、eval・env -Cを使った書き込みはClaude Code 2.1.268/2.1.271/2.1.273の3バージョンで一貫してブロックされた - 元記事はこの「消したはずのdenyルールとは別の何かが止めていた」という観測を、「Bashの静的解析層」という独自の仮説として説明している(この命名・分類は元記事独自のものであり、Claude Code側が公式にそう呼んでいるわけではない)
これ以外の細部(試行回数の内訳、--dangerously-skip-permissions使用時の各パターンの挙動、time -pの扱い等)は、元記事の一次データを直接見ていない状態で数値まで再現して語ると誤転記のリスクが高いと判断し、本稿では扱わない。気になる方は元記事を直接読んでほしい。
公式ドキュメントで確認できた3つの層
私が運用しているPreToolUseフック群の設計根拠を見直すために、hooksのリファレンスとpermissionsのリファレンス、headless実行のドキュメントを読み直した。確認できた範囲では、Claude Codeのコマンド実行制御は少なくとも3層に分かれている。
層1: Bashルールのマッチングは「解析できないものを許可しない」設計
permissionsドキュメントには、ルール評価の順序についてこう書かれている。
Rules are evaluated in order: deny, then ask, then allow. The first match in that order determines the outcome, and rule specificity doesn't change the order.
そして重要なのがこの一文だ。
When
&&or||has nothing after it, such as innpm test &&, Claude Code treats the command as unparseable and doesn't split it into subcommands for allow-rule matching, so a rule such asBash(npm *)doesn't approve it.
さらにwatch・setsid・ionice・flockのような「exec wrapper」系コマンドについても、同様の注記がある。
Exec wrappers such as
watch,setsid,ionice, andflockcan't be auto-approved by a prefix rule likeBash(watch *), so in Manual mode they always prompt.
ここで公式文書から確実に言えるのは、①&&/||で終わる不完全な複合コマンドはallowルールでの自動承認対象にならないこと、②watch・setsid・ionice・flockのような一部のexec wrapperはManualモードで常にpromptに回されること、の2点だけだ。実際に読み直した限り、evalやenv -Cという具体的なコマンド名を「解析できないため確認に回る」と直接分類した記述は見当たらなかった。したがって「evalやenv -Cも同じ理屈でaskに回っている」というのは、元記事の観測結果から私が立てた仮説であり、公式文書で直接裏付けられた分類ではない——という前提を置いた上で、以下の層2・層3を読んでほしい。
なおtimeについては、permissionsドキュメントに「Bashルールの照合前にtimeout・time・nice・nohup・stdbufなどの固定wrapperを取り除いてから照合する」という明記がある。つまり現行仕様ではtime自体が「解析できないコマンド」なのではなく、透過的に取り除かれて内側のコマンド(例: cp)として照合されるwrapperだ。ここで重要なのは、内側のcpに対応するallowルールが無ければ、timeを剥いだ後のcpはManualモードでは確認(ask)対象になり、承認できる相手がいない-p実行では層2の既定挙動でそのまま拒否される、という点だ。つまりtimeのブロックも「timeだから」ではなく、timeが剥かれた後の中身のコマンドに対する通常の権限判定(層1・層2)の結果として説明できる可能性が高く、「timeは一般に確認に回るコマンドだ」と一般化することはできない。
層2: claude -pは「聞ける相手がいなければ拒否」がドキュメント化された既定挙動
これが個人的に一番の発見だった。元記事の検証はclaude -p(非対話モード)で行われている。headlessドキュメントの「Turn off permission prompts in unattended runs」の節にこうある。
With the flag, your run doesn't consult the host or wait on it. Anything that would prompt is denied unless a
PermissionRequesthook allows it [...] In a-prun with no host, these requests are denied either way [...]
つまり、確認(ask)が必要な操作は、承認できる相手(対話ユーザーやSDKのcanUseToolコールバック)がいない-p実行では、フラグの有無に関わらずデフォルトで拒否される。denyルールを消しても、「解析できず確認に回るコマンド」がもし本当に存在するなら、この既定挙動によって結果的にブロックされる(前節で書いた通り、eval・env -Cをこの分類に入れるのは私の仮説で、timeは現行仕様上ここに入れるべきではない)。私の見立てでは、この既定拒否こそが元記事の再現性(3バージョンで1つも違わなかった)を説明する一番シンプルな理由だ。
層3: Hooksのexit code 2は、権限ルールより先に走り、どんな決定も覆せない
私が実際に使っているのはこの層だ。hooksドキュメントより。
Exit code 2 means a blocking error. [...] even a JSON
permissionDecisionof"allow"can't override it.
そしてpermissionsドキュメント側にはこうある。
PreToolUse hooks run before the permission prompt, for every tool [...] Hook decisions don't bypass permission rules. Claude Code evaluates deny and ask rules regardless of what a PreToolUse hook returns [...] This preserves the deny-first precedence [...]
(例外としてEndConversationはPreToolUse/PostToolUseそのものを発火しない、とドキュメントに明記されている。以下は対象になる各ツール呼び出しについての話だ。)
フックは権限ルールより先に走り、exit 2で拒否を返せばそれは絶対に覆らない(allowで上書きできない)。一方で、フックが黙ってexit 0を返した場合は、通常の権限フロー(deny→ask→allow)にそのまま進む。つまりフックと権限ルールは「どちらか」ではなく、フックが先、権限ルールが後の直列構成になっている。
自分の運用に当てはめてみる
私はこの1年、個人のAIエージェント基盤にPreToolUseフックを何枚か重ねて無人稼働させてきた。代表的なのは次の3枚だ。
-
killスイッチ: 特定のフラグファイルが存在する間は、
Write/Edit/NotebookEdit/Bashをまとめて問答無用でexit 2ブロック(Bashは内容を見ておらず、git statusのような読み取り主体の呼び出しも一緒に止まる。「書き込み系だけ」を選別しているわけではない) - 禁止ゾーンガード: 特定ディレクトリへの書き込みを、そのファイルパスをコマンド文字列から正規表現で検出してブロック
- 文脈量監視: セッションのトークン消費が閾値を超えたら状態変更系ツールをブロック(現状はドライランでログのみ)
killスイッチの実装はこんな形にしている(実際のスクリプトを簡略化したもの)。
#!/bin/bash
# PreToolUseフック: フラグファイルが存在する間、Write/Edit/NotebookEdit/Bashを丸ごと止める
FLAG_FILE="$HOME/.myagent/PAUSED"
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name // empty')
if [[ -f "$FLAG_FILE" ]]; then
case "$tool_name" in
Write|Edit|NotebookEdit|Bash)
echo "PAUSED中のためブロックしました" >&2
exit 2
;;
"")
# matcherがWrite/Edit/NotebookEdit/Bashに限定されているので、この分岐に来るのは
# それら4ツールの呼び出しでjqがtool_nameを取得できなかった場合だけ。
# 素通りさせる(exit 0)とその呼び出し自体が通ってしまうので、fail-closedで止める
echo "PAUSED中: tool_name未取得のためfail-closedでブロックしました" >&2
exit 2
;;
esac
fi
exit 0
これをsettings.json側でこう登録する。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|NotebookEdit|Bash",
"hooks": [
{
"type": "command",
"command": "$HOME/.myagent/hooks/kill-switch.sh"
}
]
}
]
}
}
正直に言うと、このフック群を最初に組んだ時点では「deny rulesとhooksの優先順位」を厳密に理解していなかった。とりあえずexit code 2を使えば止まる、という体感だけで組んでいた。今回ドキュメントを読み直して、なぜそれで良かったのか(exit 2はallow rulesより強い、という仕様上の裏付け)がようやく言語化できた。
このフック以前は、別のRailsアプリで似た設計判断をしたことがある。全コントローラでログインチェックを手動で呼んでいたところ、追加漏れが3件見つかったので、ApplicationControllerのbefore_actionに一本化し、除外したいアクションだけskip_before_actionで明示する形に変えた。個別に「チェックを足す」よりも「デフォルトで適用し、例外だけ明示的に外す」方が事故に強い、という学びだった。Punditで認可を実装した別案件でも同じ構造で、if文でのチェックを都度書く方式からauthorize呼び出し+after_action :verify_authorizedで呼び忘れを検出する仕組みに変えたことで、認可チェック漏れがゼロになった。Claude Codeの「確認できる相手がいない-p実行では、askへ回るはずの操作が既定で拒否される」という既定挙動も、根っこは同じ「デフォルト拒否・明示的に許可」の思想だと思う。
インフラ側でも同じパターンを踏んだことがある。Webサーバーの公開ディレクトリに.envが置かれていてURLから直接読めてしまう事故があった際は、ドキュメントルートを絞り込み、隠しファイルへのアクセスを全て拒否するルールを追加して塞いだ。ファイアウォールでも、未設定のまま不要なポートが外部に公開されていたのをiptablesで「必要なポートだけ開ける」方式に変えたことがある。どちらも「許可リストの外は自動的に閉じる」という設計であり、今回調べたClaude Codeの3層構造とも重なる。
よくある誤解と対処
| 誤解 | 実態(ドキュメント根拠) | 対処 |
|---|---|---|
permissions.denyを書けば安全 |
裸のBashと、それと同等のBash(*)はいずれもBashツール自体をClaudeのコンテキストから完全に除去する。一方、Bash(rm *)のように対象コマンドを限定したdeny/askルールはBashツールを残したまま、Claudeが書いたコマンド文字列(複合コマンドを分解したサブコマンド部分を含む)とのテキストマッチングで呼び出し時に拒否する仕組みで、OSレベルで実行後のファイルアクセス先を追跡するものではない。いずれの形でも、文字列として一致しない間接的な経路(元記事のeval・env -Cはその一例と考えられる)までは拒否できない |
「特定パスへの直接アクセスを拒否する」ことと「安全」を同一視しない。任意コマンド実行を許すBash権限がある限り、パス指定denyは間接アクセスの防波堤にはならない |
| フックでallowを返せば強制的に通せる |
exit 2のhard denyはJSONのpermissionDecision: "allow"でも上書きできない |
判断しない(通常の権限フローに委ねる)場合は出力なしのexit 0。通常ならpromptになる呼び出しを明示的に許可したい場合はJSONでpermissionDecision: "allow"を返す(いずれもdeny/askルールには勝てない) |
--dangerously-skip-permissionsはテスト用に一時的に使っても安全 |
公式仕様は「permission promptsを省略する。ただし、どのモードも自動承認しない一部の操作は例外」としており、解析・ルール評価そのものを丸ごと無効化するとは説明していない | 無人稼働ジョブでは原則使わない。使う場合も「確認プロンプトを省略する」以上の効果を期待せず、コンテナ/VM等の隔離環境で実害を防ぐ |
| Hooksを1枚入れれば安全設計は完了 | フック・権限ルール・(-p固有の)既定拒否挙動は別々の層で、それぞれ異なる失敗モードに強い。ただし同じ失敗を必ず別の層が拾ってくれる保証はない(例: 正規表現ベースの禁止ゾーンフックが漏らした、任意プロセス経由のファイルアクセスは、パスdenyやask挙動でも捕捉できないことがある) |
各層が実際にどの失敗モードを補完するかを役割分担として明文化する。任意プロセスのファイルアクセス制御そのものが必要な場合はOSレベルのサンドボックス等、別レイヤーで担保する |
まとめ
元記事の実測は再現できていないので、元記事が名付けた「Bashの静的解析層」という仮説的な結論そのものを否定はできない。ただ、少なくともclaude -pという実行条件については、「確認(ask)が必要な操作は、確認できる相手がいなければ既定で拒否される」という、ドキュメントに明記された挙動を土台にすれば、(もしeval・env -Cが本当にaskへ回る分類だと仮定するなら)同じ現象の相当部分を説明できる仮説が立つ、というのが今回の到達点だ。仮説の土台になっている「eval・env -Cがaskへ回る」という部分自体は、私の観測からの推測であり、ドキュメントで直接確認できたわけではない、という限界は残る。
今回いちばん効いたのは、フック単体の仕様を覚え直したことではなく、「ルール・フック・実行モードは別の層で、それぞれ別の理由で"効く"」という構造を先に押さえてから自分のHooks構成を見返したことだった。層の役割を取り違えたまま重ねると、どれか1枚が思ったように動かなかったときに「なぜ安全だったのか」を誰も説明できなくなる。ルールを消しても何かが止まったなら、まずは「なぜ止まったか」を一次情報で追いかける価値がある。
今回はclaude -p実行時の挙動に絞って調べました。対話セッション(プロンプト表示あり)でのaskルールの挙動や、サンドボックス機能との重なりは別記事で扱う予定です。Claude Code や業務自動化まわりの実装で試したことを不定期に書いているので、続きが気になる方は フォロー をどうぞ。