AIエージェントに書かせた自動化スクリプトが、ローカルの対話セッションでは何度動かしても問題なく動くはずなのに、非対話実行のsandbox環境に載せた瞬間だけ黙って止まる。エラーメッセージを読んでも、コードのロジックはどこも間違っていない。悪いのはコードではなく、コードが実行されている「場所」の方だった——sandbox化されたAIコーディングエージェントを使ったことがあるなら、この落差に一度は付き合ったことがあるはずだ(正直、原因を突き止めるまでの時間がまるまる無駄になった気分になる)。
原因はだいたい2つに集約される。ファイルシステムへの書き込み制限(sandboxのallowWrite/denyOnly)と、ネットワーク送信先の制限(sandboxのallowedHosts)だ。Claude CodeやCodex系のようなAIコーディングエージェントのsandboxモードは、この2つを軸に「何をしていいか」を絞り込む設計になっている。この記事は、動かしてから都度デバッグするのではなく、スクリプトやワークフローを書き始める前に、この2軸でどこに制約がぶつかりそうかを見積もるためのチェックリストとして整理する。
この記事で学べること
- Claude Code / Codex系のsandboxモード(filesystem書き込み制限・network送信先制限)が、実際の開発ワークフローのどこにぶつかるか
- npm install・process substitution・一時ファイル書き込みを例に、書き込み制限を設計段階で見積もる考え方
- 未許可ホストへの通信を例に、ネットワーク制限を設計段階で見積もる考え方
- 見積もった制約をCLAUDE.md / settings.jsonにどう落とし込むか
前提条件
- Claude Code(Pro $20/月、Max $100〜200/月)やCodex CLIのような、OSレベルでコマンド実行を隔離するsandbox機構を使っている、または導入を検討している
- 承認なしの非対話実行(CI、スケジュールタスク、無人での連続タスク実行など)でAIエージェントを動かす場面がある
- シェルスクリプトやNode.jsのビルドツールなど、ファイル書き込みや外部通信を伴う自動化をAIエージェントに書かせている、または自分で書いている
なぜ「動かしてから気づく」だと手戻りが大きいのか
sandboxの制約は、たいてい「動かないこと」で気づく。問題は、気づくタイミングが遅いほど手戻りが大きくなることだ。スクリプトを書き終えて、レビューも通して、いざ本番の非対話実行に載せた段階でEPERMやネットワーク拒否に当たると、原因の切り分けからやり直しになる。
このリポジトリでも、書き込み・通信まわりの制約が「動いている途中で不意に発覚する」パターンは何度か踏んでいる。以前hookを入れたら開発が止まった時の切り分け方を書いたときも、AIで作った社内ツールの認証・権限の穴を書いたときも、共通していたのは「動いている=安全に設計できている」ではないという教訓だった。sandboxの書き込み・通信制限も同じで、今日動いたスクリプトが、許可リストの範囲外に一歩踏み出した瞬間に止まる可能性は常に残っている。
この記事が扱うのは、その手前の「設計段階で見積もる」部分だ。sandboxをセキュリティの多層防御として設計する話はネット送信制限と秘匿マスクの考え方に、npm installで実際に踏んだエラーの切り分け方はsandbox環境でnpm installが失敗する3つの原因に譲る。この記事はそのどちらでもなく、「そもそも書く前に何を見積もっておけば、その2本を読まずに済んだか」を一般化する。
軸1: ファイルシステムへの書き込みをどう見積もるか
sandboxのfilesystem制限は、多くの場合「許可リスト配下だけ書き込みを許す」か「特定パスだけ拒否する」のどちらかの形を取る。
{
"sandbox": {
"filesystem": {
"allowWrite": ["~/ghq/github.com/<org>/**", "$TMPDIR"],
"denyOnly": ["**/.env", "**/.ssh/**", "**/.aws/**"]
}
}
}
allowWrite型は「デフォルト全拒否、許可リストだけ書き込み可能」、denyOnly型は「デフォルト書き込み可能、特定パスだけ拒否」という逆方向の設計思想になる。自分のプロジェクトが今どちらの型か、即答できるだろうか? 同じ「書き込み制限」という言葉でも中身は正反対なので、ここを取り違えたまま見積もると、後工程がまるごと逆向きに転がる(許可されているはずの場所で拒否される、みたいなやつだ)。
設計段階で見落としやすい書き込み先は、だいたい次の3パターンに分かれる。
| 書き込み先 | 見落としがちな理由 | 設計段階でやること |
|---|---|---|
一時ファイル($TMPDIR配下) | 呼び出しごとに変数解決のタイミングがずれることがある | 解決済みの絶対パスを固定するか、${TMPDIR:-/tmp}のようにフォールバック付きで参照する |
パッケージマネージャのキャッシュ(~/.npm等) | プロジェクトのworktree外にある、グローバル領域だと意識しづらい | キャッシュ参照先を書き込み許可済みのディレクトリへ明示的に切り替える |
| worktree・プロジェクトルート外への出力 | ログやレポートを「とりあえずどこかに」書こうとして許可リスト外を踏む | 出力先ディレクトリを許可リスト配下に固定してからスクリプトを書く |
このリポジトリの内部スクリプトでも、一時ファイルを扱う箇所は素の/tmpを直接書かず、mktemp "${TMPDIR:-/tmp}/myapp-XXXXXX"のようにフォールバック付きで解決する形に倒してある。$TMPDIRが未設定の環境でも壊れず、sandbox環境で$TMPDIRが別のディレクトリを指していてもそこに追従する。「一時ファイルはどこかに書ければいい」ではなく、「一時ファイルはどこに書くと決めるか」を先に固定しておく設計判断だ(ここをサボると、あとから/tmp直書きの残骸を延々grepする羽目になる)。
もう1つ見落としやすいのが、シェルのprocess substitution(diff <(コマンド1) <(コマンド2)のような書き方)だ。これは内部的に/dev/fd/*のような仮想ファイルディスクリプタを経由する。sandbox機構がコマンド実行のたびにプロセスを新規に隔離する方式だと、この仮想ファイルディスクリプタへのアクセスが塞がれ、<(...)を使った比較コマンドがそのまま失敗することがある(ローカルでは何百回動かしても再現しないタイプの不具合なので、たちが悪い)。設計段階でこの手の比較処理を書くなら、「一度実体のある一時ファイルに書き出してから、通常のファイルパスで比較する」形に倒しておくと、sandboxの有無に関わらず動く実装になる。
軸2: ネットワーク送信先をどう見積もるか
networkの制限は、多くの場合「宛先ドメインのallowlist」という形を取る。
{
"sandbox": {
"network": {
"allowedDomains": ["registry.npmjs.org", "pypi.org", "api.example-service.com"]
}
}
}
このリストに入っていないドメインへの通信は、初回に確認が入るか、無人実行では黙って拒否されるかのどちらかになる。ここで見積もりを誤りやすいのは、「自分が呼び出しているAPIのドメインさえ入れておけばいい」と考えてしまうことだ。
実際、社内のレポーティング処理を非対話実行のsandbox環境に載せる設計をしたとき、外部APIを叩く手段としてSDKクライアントライブラリではなく素のREST呼び出し(HTTP)に倒す、という判断を採った記録が残っている。SDKライブラリは内部で独自のトランスポート層やソケット接続を張ることがあり、そのライブラリが実際に何のホスト・何のプロトコルで通信しているかがドキュメントだけでは分からないケースがあった(正直、ソースコードまで読みに行かないと分からないことも珍しくない)。allowlistに載せるべきホストが特定できないSDKを使うより、通信内容が予測できるREST呼び出しに寄せたほうが、sandboxのネットワーク許可リストとの相性がよかった、ということだ。
これを一般化すると、外部サービスとの連携を設計する時点で確認しておくべきなのは次の3点になる。
- 本体のAPIドメインだけでなく、そのSDK・CLIが裏で叩く付帯的なエンドポイント(認証トークンの取得先、テレメトリ送信先など)
- パッケージインストール時にpostinstallスクリプトが叩く別ドメイン(レジストリ本体とは別のCDNやミラーを叩くパッケージがある)
- SDKの内部トランスポートがallowlistと相性の悪い通信方式(独自ソケット、gRPC等)を使っていないか。怪しければ素のHTTPクライアントに倒す選択肢を検討する
npm installでこのパターンが実際にどう症状として出るか、切り分け方まで深掘りしたい場合はsandbox環境でnpm installが失敗する3つの原因を参照してほしい。この記事ではnpm installを1つの具体例として扱うにとどめる。
軸3: 破壊的操作をどう止めるか
書き込み・通信の制限と並んで見積もっておきたいのが、「絶対に止めたい操作」の扱い方だ。permissions.denyのようなコマンド文字列のパターンマッチだけに頼ると、オプションの挟み方1つで判定そのものが起動しないケースがある。先頭が固定されたパターンは、先頭が変わっただけで一致しなくなるというのは、実測で複数の抜け道が見つかった話として禁止したはずのgit pushが通る理由にまとめてある。
破壊的操作を本当に止めたいなら、コマンド文字列のパターンマッチだけでなく、実行前に介入するhookを組み合わせるのが確実だ。パターンマッチは「一致すれば強力」だが「一致するかどうか」自体が脆い——禁止のはずが、オプションの挟み方ひとつで普通に通っていた、なんてことも起こる。この前提を設計段階で織り込んでおく。
非対話実行中にこの手のブロックへ実際に引っかかると、git reset --hardのような一発リセットの逃げ道も封じられる。そうなったときに非破壊コマンドの組み合わせで復旧する手順はgit stash popでコンフリクトした時の非破壊リカバリで扱っている。
設計チェックリスト: CLAUDE.md / settings.jsonへの落とし込み方
3つの軸を見積もったら、プロジェクトのCLAUDE.mdやsettings.jsonに「毎回同じ調査をしなくて済む形」で記録しておく。チェックリストにするとこうなる。
- 書き込み先を先に列挙する: 一時ファイル・キャッシュ・ログ出力・生成物、それぞれの書き込み先パスを箇条書きにし、
allowWrite/denyOnlyのどちらの型かに合わせて許可リストと突き合わせる - 通信先を先に列挙する: 呼び出すAPI本体だけでなく、SDKが裏で叩く付帯エンドポイントまで含めて洗い出す。分からなければ、まず許可なしで一度動かして「何が拒否されたか」を観察するのも有効な調査手段になる
- 一時ファイルはフォールバック付きで参照する:
$TMPDIRを直接展開せず、${TMPDIR:-/tmp}のような形で固定し、解決タイミングのズレを設計時点で潰す(ここをサボると、実行環境が変わるたびに同じ原因を掘り返すことになる) - process substitution等、sandbox依存の構文を避ける:
<(...)のような仮想ファイルディスクリプタに依存する構文は、実体のある一時ファイル経由の処理に置き換える - 破壊的操作はhookで止める:
permissions.denyのパターンマッチだけに頼らず、本当に止めたい操作は実行前に介入するhookで二重に固める - 見積もった前提をCLAUDE.mdに残す: 「このプロジェクトのsandboxはallowWrite型で、書き込み許可済みなのは
<repo>/**と$TMPDIRのみ」のように、次に触る人(人間でもエージェントでも)が同じ調査をやり直さずに済む形で書いておく
動作確認: 自分のプロジェクトで見積もりを検証する
自分のプロジェクトのsandbox設定が、上のチェックリストと噛み合っているかは、次のコマンドで確認できる。
# 1. $TMPDIRが実際に何を指しているか確認する
echo "$TMPDIR"
# 2. TMPDIR配下に書き込めるか、実際にファイルを作って試す
touch "${TMPDIR:-/tmp}/sandbox-check-$$" && echo "書き込みOK" && rm "${TMPDIR:-/tmp}/sandbox-check-$$"
# 3. process substitutionが使える環境か確認する
diff <(echo a) <(echo a) && echo "process substitution OK"
# 4. 許可リストにないはずのホストへの通信が実際に拒否されるか確認する
# (許可リストに入っていないドメインで試す。到達してしまったら許可リストの見積もりが甘い)
curl -sS --max-time 3 https://example-not-in-allowlist.test 2>&1 | head -3
手順2・3は、自分が使っているsandbox機構の設定ファイル(プロジェクト直下のsettings系ファイルにあるfilesystem/network項目)に書いてある内容と、実際の挙動が一致しているかを確認する意味も兼ねている。
手順3で失敗する場合は、process substitutionに依存したスクリプトが眠っていないか確認しておく価値がある。手順4は「拒否されるはずなのに普通に繋がった」が一番気まずい結果で、そうなったら許可リストの見積もりそのものを見直すサインだ。
注意点・Tips
- allowWrite型とdenyOnly型は逆方向の設計思想: どちらの型かを勘違いしたまま見積もると、許可されているはずの場所で拒否されたり、拒否されるはずの場所に書き込めてしまったりする(この2つを混同したまま設計を進めるのが一番厄介)。まず自分のプロジェクトがどちらかを確認する
- SDKクライアントは中身が見えにくい: 通信先ドメインが公式ドキュメントに明記されていないSDKを使う場合は、素のHTTP呼び出しに倒すか、一度許可なしで動かして拒否ログから逆算する
- パターンマッチのガードは「一致すれば強い、一致するかは別問題」: 破壊的操作を止めたいなら、コマンド文字列の完全一致に依存しすぎず、hookのような実行前介入も併用する
まとめ
sandboxの制約は、ファイルシステムの書き込み(allowWrite/denyOnly)とネットワークの送信先(allowedHosts)という2つの軸でだいたい説明がつく。npm install・process substitution・一時ファイル書き込みは、そのどちらかの軸にぶつかった具体例にすぎない。動かしてから都度デバッグするのは、原因の切り分けに毎回同じ時間を溶かすということでもある。書き込み先・通信先・破壊的操作の3点を書く前に洗い出し、CLAUDE.mdやsettings.jsonに前提として残しておけば、同じ調査を繰り返さずに済む。AIエージェントに複数のワークフローを並行して任せる比重が増えている場合、こうしたsandbox設計の見積もりから相談したいならAI実装支援も選択肢の一つになる。



