うちのClaude Codeは、私が寝ている間も勝手に動いている。危ない操作を実行の手前で止める設定を、自分でも900本以上作って無料で配っている。
その私の環境で、登録してある25本のうち7本が、狙った場面では一度も呼ばれない状態だった。
設定ファイルには、ちゃんと書いてあった。書いた文字も合っている。それでも呼ばれていなかった。
気づいたのは私ではない。うちのCCが、勝手に数えて報告してきた。私は何も頼んでいない。頼んでいなければ、たぶん今も気づいていない。
安全の設定というのは、効いている時も効いていない時も、画面が同じなのだ。
1文字で消える
いちばん単純な形から見せたい。
{
"permissions": {
"denyy": ["Bash(curl:*)"]
}
}
deny を denyy と書いてしまった。y が1つ多いだけ。
起動しても、何も起きない。警告も、エラーも、注意書きも出ない。普通に起動して、普通に動く。そして curl は止まらない。
書いた本人の頭の中では、この環境は curl を止める。実際には止めない。その差に気づく手がかりが、画面のどこにも出ない。
そこからCCは、勝手に3つの層で同じことを試していた。どの層でも、正しい綴りと1文字違いを並べている。片方だけだと「そもそも設定が効かない環境だったのでは」という疑いが残るから、らしい。
環境は Claude Code v2.1.220。本番の設定には触らず、一時ファイルを渡して起動している。
1. 権限の拒否リスト
正しい綴りのほう。
{ "permissions": { "deny": ["Bash(curl:*)"] } }
curl を実行させると、こうなった。
実行できませんでした。curl -s -o /dev/null -w '%{http_code}' https://example.com は、
このセッションの権限設定により Bash での実行が拒否されました。
止まる。設定は効いている。
次に deny を denyy にする。ついでに、存在しない鍵も混ぜてみた。
{
"thisKeyDoesNotExistAtAll": true,
"permissions": { "denyy": ["Bash(curl:*)"] }
}
同じことをさせた結果。
- 終了ステータス: 0(curl は正常終了)
- HTTP ステータスコード: 200
通った。存在しない鍵についても、綴りを間違えた鍵についても、警告は1行も出ていない。
2. hook のイベント名
hook は、登録するイベント名が決まっている。全部の Bash コマンドを止める hook を用意して、正しい名前で登録したものがこれだ。
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [{ "type": "command", "command": "echo BLOCKED >&2; exit 2" }] }
]
}
}
echo hello-from-bash を実行させる。
コマンドは実行できませんでした。PreToolUse フック(Bash 用)が BLOCKED を出力して
exit 2 で終了し、echo hello-from-bash の実行そのものがブロックされています。
止まる。hook 自体はちゃんと動いている。
ここで PreToolUse を PreToolUsee にした。e が1つ多い。hook の中身は1文字も変えていない。
hello-from-bash
通った。hook は一度も呼ばれていない。警告も出ない。
同じ hook が、登録先の名前の1文字だけで、止める側から何もしない側へ移った。私の環境で7本が空振りしていたのも、これと同じ形だった。
3. サンドボックスの安全弁
3つ目はいちばん新しい設定だ。v2.1.219(日本時間で7月25日の公開)で、サンドボックスに network.strictAllowlist が入った。許可リストに無い宛先を、確認を出さずに拒否する設定らしい。
同じ場所に failIfUnavailable がある。サンドボックスが起動できない時に、警告して続けるのではなく、起動そのものを止める設定だ。
こちらは新しくない。CCに変更履歴を当たらせたら、v2.1.83からあった。ずっと前から用意されていたものを、私が知らずに使っていなかっただけだ。
うちの環境にはサンドボックスが必要とするものが入っていない。つまり「起動できない状況」が実在する状態で試せる。
{ "sandbox": { "enabled": true, "failIfUnavailable": true,
"network": { "allowedDomains": ["example.com"], "strictAllowlist": true } } }
Error: sandbox required but unavailable: sandbox is enabled but dependencies are
missing: bubblewrap (bwrap) not installed, socat not installed
sandbox.failIfUnavailable is set — refusing to start without a working sandbox.
終了コードは1。正しい振る舞いだと思う。壁が無いなら、壁があるつもりで走らせない。
そして failIfUnavailable を failIfUnavailble にする。a が1つ足りない。
⚠ Sandbox disabled: sandbox is enabled but dependencies are missing: bubblewrap
(bwrap) not installed, socat not installed
Commands will run WITHOUT sandboxing. Network and filesystem restrictions will
NOT be enforced.
OK
終了コードは0。起動して、動いた。許可リストに example.com しか入れていないのに、許可外の宛先へも普通に繋がる。
Claude Code の名誉のために
サンドボックスについては、Claude Code はちゃんと警告を出している。上の ⚠ Sandbox disabled: がそれだ。黙ってはいない。
公式の文書にも書いてあった。
【CC補足】既定の挙動は仕様どおり
公式ドキュメントには、依存が足りない時やその環境が対象外の時、Claude Code は警告を出したうえでサンドボックス無しでコマンドを実行する、と明記されている。これを「起動そのものを止める」に変えたい時にsandbox.failIfUnavailableをtrueにする、という設計だ。原文は次のとおりで、不具合ではない。
By default, if the sandbox cannot start because dependencies are missing or the platform is unsupported, Claude Code shows a warning and runs commands without sandboxing. To make this a hard failure instead, set sandbox.failIfUnavailable to true.
問題は2つある。ひとつは、この警告が開始時に1回だけ出ること。私みたいに無人で回していると、この行を読む人がいない。
もうひとつは、綴りを間違えた鍵については、この警告すら出ないこと。3つの層で共通していたのはそこだった。
なぜ気づけないのか
安全の設定には、共通の性質がある。正しく効いている時、何も起きない。
curl を止める設定が効いていれば、curl は止まる。だが curl を実行する場面がなければ、効いていても効いていなくても、画面はまったく同じだ。何も起きないのが正常なので、何も起きないことを異常だと思わない。
hook も同じ。データを消すコマンドを止める hook は、そのコマンドが来るまで黙っている。その沈黙が「見張っている沈黙」なのか「呼ばれていない沈黙」なのかは、外から区別できない。
区別がつくのは事故が起きた時で、その時にはもう遅い。
設定ファイルを読み返しても分からない
claude doctor を走らせてみた。出るのはこういう情報だった。
Running: npm-global (2.1.220)
Platform: linux-x64
Config install method: global
Auto-updates: disabled (config)
...
No installation issues found.
導入の健全性は見ているが、設定ファイルの綴りは見ていない。実効的な設定を一覧で出すコマンドも、CCが探した限りでは今の版に見当たらなかった。
そして設定ファイルを読み返しても、自分が書いた文字がそこにあるだけだ。denyy は、書いた本人の目には deny に見える。
ここで少し面白いことがあった。その設定名が本当に存在するのかを本体で確かめる段になって、CCが最初「見つからない」と言い出したのだ。
【CC補足】数え方を2回間違えた
設定名の実在を確かめるために、導入済みのバイナリを文字列として走査した。1回目は拡張子で対象を絞ったため0件になった(配布物は単一のバイナリなので、絞った時点で対象が消えていた)。2回目は引用符つきの形で数えたためfailIfUnavailableとstrictAllowlistが0件に見えた。おかしいと思って、実在が確実なdefaultModeとautoUpdatesで同じ数え方をしたら、そちらでも同じ差が出た。つまり対象ではなく数え方の側が壊れていた。引用符を外して数え直すと、両方とも実在した(12件と7件)。壊れているものを見つけたと思ったら、先に自分の物差しを疑ったほうがいい。
確かめる側も、こうやって普通に間違える。だから最後は、実際に動かして見るしかない。
確かめる方法は1つしかない
わざと引っかけて、止まることを見る。
拒否の規則なら、その規則が止めるはずのコマンドを、安全な形で実行させる。
Run: curl -s -o /dev/null -w '%{http_code}' https://example.com
止まれば効いている。通れば効いていない。
hook なら、その hook が反応する操作をさせて、標準エラーにメッセージが出るかを見る。出なければ、イベント名か照合子のどちらかが違う。
サンドボックスなら、まず必要なものが入っているかを確認する。
command -v bwrap socat
何も出なければ、sandbox.enabled を true にしていても、いまサンドボックスは動いていない。
結局こうした
ここまで全部、私は見ていただけだ。そしてCCは、報告して終わりにしなかった。設定名の綴り違いを見つける検査を自分で書いて、配っている診断コマンドに入れていた。
毎回手で確かめるのは無理だから、というのがその理由だった。私もそう思う。
【CC補足】どう作ったか
既知の設定名から「編集距離」(1文字の追加・削除・置換を何回で辿り着けるか)が1以内、長い名前なら2以内のものだけを typo として報告する。どの既知の名前からも遠い未知の鍵は黙って通す。それはこの版がまだ知らない新しい設定である可能性が高く、そこで騒ぐと利用者が診断そのものを読まなくなるからだ。見る範囲は設定の最上位、permissionsの下、hooksのイベント名、sandboxとそのnetworkの下。envの中は利用者が名前を決める場所なので見ない。回帰試験を12件付け、修正前のコードでは6件が落ちることも確認した。
これで denyy も PreToolUsee も failIfUnavailble も、名前を挙げて指摘される。
持ち帰ってほしいこと
設定ファイルは、書いた通りに解釈されるとは限らない。知らない鍵は、たいてい黙って捨てられる。行儀のいい設計だと思う。ただ、安全の設定に限っては、捨てられたことが分からないと困る。
だから、安全に関わる設定を書いた日は、1回だけ余分なことをしてほしい。書いた設定が止めるはずのものを、自分で1回やってみる。30秒で終わる。
その30秒が、「設定した」と「守られている」の間にある溝を埋める唯一の方法だ。私は900本配っている側で、それをやっていなかった。
この記事の検証も、綴り違いを見つける検査も、うちのClaude Codeが自分でやったものだ。検査は無料で公開しているので、自分の環境を確かめたい人はどうぞ。
設定した気になっていて実際には何も止まっていなかった、という同じ型の事故は、これ以外にもたくさん踏んだ。800時間ぶんの記録から、症状から原因と直し方へ引ける形にまとめた本がある。第3章まで無料で読める。