はじめに
AI コーディングエージェントに aws s3 ls を叩かせたい、という場面は珍しくありません。ところがその瞬間、こちらは二択を迫られます。
-
渡す:
AWS_SECRET_ACCESS_KEYを環境変数のまま子プロセスに継承させる。動くけれど、エージェントが読んだログ・生成したファイル・送信したリクエストのどこに鍵が転がるか、こちらは制御できない - 渡さない: 環境変数を落とす。安全だが、AWS を触るタスクは全部できなくなる
筆者はこの二択がずっと気持ち悪く、結局「AWS 関連は手で叩く」に落ち着いていました。ところが Claude Code のサンドボックス設定を読み直したところ、この二択の外側に 第三の選択肢(mask)があることに気づきました。
要点はサンドボックスの中のプロセスには偽の鍵(sentinel)を見せておき、外に出ていくリクエストを中継するプロキシが、許可したホスト宛のときだけ本物に差し替える。AWS の場合は差し替えるだけでは足りないので、プロキシが SigV4 署名を計算し直す。
本記事はこの仕組みを一次情報(公式ドキュメント)から読み解き、設定の判断軸とハマりどころを整理したものです。
背景: サンドボックスは2層で、デフォルトでは鍵を守らない
Claude Code のサンドボックスは Bash ツールとその子プロセスに対して OS レベルの境界を張ります。実装は macOS が Seatbelt、Linux / WSL2 が bubblewrap です。ここで押さえるべきなのは、境界が独立した2層でできている点です。
- ファイルシステム分離: どのパスを読めるか/書けるか
- ネットワーク分離: どのドメインに出られるか(サンドボックス外のプロキシが判定)
そして公式ドキュメントは、この2層の片方だけでは意味がないとはっきり書いています。ネットワーク分離がなければ、侵害されたエージェントは SSH 鍵を持ち出せる。ファイルシステム分離がなければ、~/.bashrc や $PATH 上の実行ファイルを書き換えて次回の実行で権限を広げられる、という理屈です。
ここで最初の落とし穴です。読み取りのデフォルトは「マシン全体が読める」(一部の拒否ディレクトリを除く)。つまり ~/.aws/credentials も ~/.ssh/ も、何も設定しなければサンドボックス内から読めます。ドキュメントも「組み込みのクレデンシャル拒否リストは存在しない」と明記しています。
環境変数も同様で、サンドボックス内の Bash コマンドは親プロセスの環境をそのまま継承します。ターミナルで export AWS_SECRET_ACCESS_KEY=... している人は、その時点で鍵をエージェントに渡しています。
つまり「サンドボックスに入れたから安全」ではなく、鍵については明示的に設定して初めて守られる、というのが出発点です。
deny と mask — 動かなくなるか、動いたまま守るか
クレデンシャルの保護は sandbox.credentials ブロックで宣言します(Claude Code v2.1.187 以降)。ファイルと環境変数のそれぞれにモードを指定する形です。
deny: 単純に落とす
{
"sandbox": {
"enabled": true,
"credentials": {
"files": [
{ "path": "~/.aws/credentials", "mode": "deny" },
{ "path": "~/.ssh", "mode": "deny" }
],
"envVars": [
{ "name": "GITHUB_TOKEN", "mode": "deny" },
{ "name": "NPM_TOKEN", "mode": "deny" }
]
}
}
}
ファイルは読み取り拒否、環境変数はコマンド実行前に unset されます。安全ですが、当然ながら gh も npm publish も動かなくなります。冒頭の「渡さない」がこれです。
deny には運用上ありがたい性質があります。どのスコープからでも追加できるが、どのスコープからも取り消せない。deny は必ず権限を狭める方向にしか働かないため、マージ時に「誰かが緩めた」が起きません。
mask: sentinel を見せて、出口で差し替える
mask は環境変数が v2.1.199 以降、ファイルが v2.1.221 以降で使えます。動きはこうです。
- サンドボックス内のコマンドには、セッションごとの sentinel 値(偽の値)が見える
- リクエストがサンドボックスの外に出るとき、プロキシが sentinel を本物に差し替える
- 差し替え先のホストは
injectHostsで絞る(省略時はallowedDomains全体が対象)
{
"sandbox": {
"enabled": true,
"network": {
"tlsTerminate": {},
"allowedDomains": ["*.github.com", "registry.npmjs.org"]
},
"credentials": {
"envVars": [
{ "name": "GH_TOKEN", "mode": "mask", "injectHosts": ["api.github.com"] },
{ "name": "NPM_TOKEN", "mode": "mask" }
]
}
}
}
コマンド自身も、それが吐くログも、本物の値を一度も保持しません。それでいて api.github.com 宛のリクエストは正しく認証されます。「動いたまま守る」がこれです。
なお injectHosts の各エントリは network.allowedDomains に含まれている必要があります。ネットワーク許可リストが上位の境界で、injectHosts はその内側をさらに絞るもの、という関係です。
tlsTerminate が必須になる理由
mask はリクエストの中身(ヘッダとボディ)を書き換える処理なので、プロキシが中身を見られなければ成立しません。ところが Claude Code の組み込みプロキシは、デフォルトでは TLS を終端しません。許可判定はクライアントが申告したホスト名だけで行い、中身は覗かない設計です。
そこで network.tlsTerminate を設定してプロキシ自身に TLS を終端させます。ドキュメントでは実験的機能とされています(v2.1.199 以降)。
重要なのはフェイルセーフの向きです。tlsTerminate を忘れた場合の挙動は**「漏れる」ではなく「認証が失敗する」**になります。コマンドが見るのは sentinel のままで、その sentinel がそのままサーバに届いて弾かれるだけで、秘密は露出しません。この設定ミスは起動時に報告されます。倒れる方向が正しく設計されている、と読めます。
設定を書ける場所が絞られている
mask は「プロキシに本物のクレデンシャルを指定ホストへ送る権限を与える」宣言なので、リポジトリの .claude/settings.json からは効きません。ユーザー設定・managed settings・--settings CLI フラグでのみ有効です。network.tlsTerminate と credentials.allowPlaintextInject も同様です。
クローンしてきたリポジトリの設定ファイルが「自分のトークンを勝手なホストへ注入させる」ことができない、という設計です。同じ変数がどこかのスコープで deny されていれば deny が勝ちます。
AWS だけ特別扱いになる理由
ここからが本題です。GitHub や npm のトークンは「ヘッダに載っている値をそのまま差し替える」で済みます。ところが AWS は済みません。
SigV4 は、シークレットキーそのものをリクエストに載せません。シークレットキーから導出した鍵で、リクエストの内容(メソッド、パス、ヘッダ、ボディのハッシュ、日時)に対する HMAC 署名を計算し、その署名を Authorization ヘッダに載せます。
つまりサンドボックス内で sentinel を使って署名を計算した時点で、その署名は「偽の鍵から導出された、正しくない署名」です。プロキシが後から文字列置換しても直りません。署名し直す以外にないわけです。
プロキシは access key ID の sentinel を手がかりに「これは SigV4 リクエストだ」と検出し、値を実物に置き換えたうえで署名を計算し直します。ここから、設定上のルールが素直に導けます。
AWS_ACCESS_KEY_ID と AWS_SECRET_ACCESS_KEY はセットで mask する。
シークレットだけを mask すると、検出の手がかりである access key ID の sentinel が出てこないため、プロキシは SigV4 リクエストだと気づけません。結果として偽の署名のまま AWS に届いて失敗します。このケースは起動時に警告が出ます。一方で access key ID だけを mask した場合は警告が出ないとドキュメントは明記しています。警告の有無が非対称なので、設定を書くときは必ず2つを並べて書きます。
もうひとつ、AWS 特有の注意点があります。再署名が行われるのは access key ID 側のエントリに書いた injectHosts のホストです。シークレット側に injectHosts を書いても再署名先は変わらないので、ホストの絞り込みは access key ID のエントリに書きます。
慣例的な AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN の3つは、値を丸ごと mask していれば自動で1つのクレデンシャルとして紐付けられます。独自の変数名を使っている場合は credentials.awsPairs で明示的にグルーピングします(v2.1.224 以降)。
{
"sandbox": {
"credentials": {
"envVars": [
{
"name": "MY_KEY_ID",
"mode": "mask",
"injectHosts": ["s3.ap-northeast-1.amazonaws.com"]
},
{ "name": "MY_SECRET_KEY", "mode": "mask" },
{ "name": "MY_SESSION_TOKEN", "mode": "mask" }
],
"awsPairs": [
{
"accessKeyIdVar": "MY_KEY_ID",
"secretAccessKeyVar": "MY_SECRET_KEY",
"sessionTokenVar": "MY_SESSION_TOKEN"
}
]
}
}
}
awsPairs はあくまで「mask 済みの envVars エントリ同士を紐付ける」設定なので、envVars 側で3つを丸ごと mask してあることが前提です。awsPairs だけを書いても効きません。sessionTokenVar を指定した場合は、再署名したリクエストに実トークンが x-amz-security-token として載ります。
再署名できない3つの形態
署名を計算し直す方式には原理的な限界があります。ドキュメントは再署名できない形態を3つ挙げています。
| リクエスト形態 |
sigv4 キー |
再署名できない理由 |
|---|---|---|
| aws-chunked ストリーミングアップロード | streaming |
チャンクごとの署名が seed 署名から連鎖するため、再署名にはボディの書き換えが要る |
| 署名済み URL(presigned URL) | presigned |
署名が URL 自体に埋まっており、Authorization ヘッダが存在しない |
| SigV4A(非対称署名) | sigv4a |
再計算できる共有鍵の HMAC が存在しない |
デフォルトでは、これらは壊れた署名を送るのではなくプロキシエラーで落ちます。落ちるのは mask したペアの sentinel で署名されたリクエストだけで、mask していないクレデンシャルで署名されたリクエストは影響を受けません。credentials.sigv4 で形態ごとに passthrough を指定すると、プロキシエラーの代わりに AWS 側の拒否レスポンスがそのままツールに返るようになります(v2.1.224 以降)。本番は既定のまま(プロキシエラー)にしておき、原因を切り分けたいときだけ passthrough に倒す、という使い分けが無難だと思います。
なお x-amz-date ヘッダが欠けているなど、検出はできたが再署名に必要な情報が足りないリクエストも同様にプロキシエラーになります。
ファイルの mask と extract
ファイルにも mask が使えますが、プラットフォームで挙動が違います。
- Linux / WSL2: 秘密の部分を placeholder に置き換えた sentinel コピーを読ませ、egress でプロキシが実値に差し替える
-
macOS: sentinel コピーは作られず、そのファイルは読めなくなる(実質
denyと同じ)
つまり macOS では「ファイルに入った鍵で認証しつつ守る」はできません。~/.aws/credentials を使う運用なら、macOS では環境変数方式に寄せるか、deny で割り切るかの判断が要ります。なお macOS のこの読み取りブロックは、deny エントリと違ってファイルシステム分離を切っても外れません。
もうひとつ気をつけたいのが、mask が安全にマスクできないときは deny にフォールバックする点です。ドキュメントはその条件を4つ挙げています。
- ディレクトリのパス(
~/.awsのようにディレクトリごと指定した場合) - glob パターン
- 8 MiB を超えるファイル
- UTF-8 テキストでないファイル
mask は1ファイル単位なので、クレデンシャルファイルは1つずつ列挙します。ディレクトリを守りたいなら、最初から deny として書いた方が意図が明確です。
構造化ファイルには extract で「どこが秘密か」を正規表現のグループ1で教えます。これがないとファイル全体が1つの sentinel に置き換わり、設定をパースするツールが壊れます。
{
"path": "~/.config/gh/hosts.yml",
"mode": "mask",
"extract": "oauth_token:\\s*(\\S+)",
"injectHosts": ["api.github.com"]
}
extract が何にもマッチしなかったときの挙動は onExtractNoMatch で決めます。既定は warn(警告して素通し = マスクされていない本物が読める)なので、「秘密は存在するはずだがパターンが外れるかもしれない」ケースでは deny か error に倒すべきです。既定値が緩い方向なので、ここは意識して上書きする箇所だと思います。
ただし macOS では、ファイルシステム分離が有効なかぎり mask エントリはパターンを走らせる前に deny として適用されます。つまり extract も onExtractNoMatch も、macOS で効くのは分離を切っているときだけです。
判断軸の整理
「どの鍵をどう扱うか」を決めるときの軸を、ここまでの内容から表にしておきます。
| 状況 | 選ぶモード | 理由 |
|---|---|---|
| エージェントがそのサービスを触る必要がない | deny |
最も単純で、どのスコープからも取り消せない |
| 触る必要があり、値がヘッダやボディにそのまま載る(GitHub / npm 等) |
mask + tlsTerminate + injectHosts
|
コマンド側は本物を一度も持たない |
| 触る必要があり、AWS SigV4 |
mask を access key ID とシークレットのペアで |
プロキシが検出して再署名する |
| 秘密がファイルにあり、macOS 中心のチーム |
deny(または環境変数方式へ移行) |
macOS では file mask が実質 deny になる |
| ストリーミングアップロードや presigned URL が主用途 |
mask は不向き |
再署名できない形態にあたる |
そのうえで、ネットワーク側は「許可ドメインをどれだけ狭くできるか」が実質的な防御力になります。strictAllowlist(v2.1.219 以降)を true にすると、許可リスト外のホストはプロンプトを出さずに拒否されます。これも mask と同じくリポジトリの .claude/settings.json からは効かず、ユーザー設定・managed settings・--settings でのみ有効です。プロンプトが出る運用は、結局のところ「面倒になった人が Yes を押す」導線なので、無人実行に寄せるほど strict 側に倒す価値が上がります。
まとめ
- Claude Code のサンドボックスは ファイルシステム分離とネットワーク分離の2層で、片方だけでは境界にならない
- デフォルトではクレデンシャルは守られない。読み取りはマシン全体が許可され、環境変数は親から継承される
-
deny(落とす)とmask(sentinel を見せて出口で差し替える)の2モードがあり、maskは「動いたまま守る」ための道具 -
maskはプロキシが中身を書き換えるのでtlsTerminateが必須。忘れても漏れはせず、認証が失敗する方向に倒れる - AWS は SigV4 のため差し替えだけでは足りず、プロキシが再署名する。検出の手がかりが access key ID の sentinel なので、access key ID とシークレットは必ずセットで mask する
- 再署名できない形態(streaming / presigned / SigV4A)があり、既定ではプロキシエラーで落ちる
- 組織展開では boolean はロックできるが配列はマージされる。
excludedCommandsにはロックがない
「エージェントに鍵を渡すか渡さないか」という二択で止まっていたところに、鍵の保持場所をエージェントの外(プロキシ)に置くという第三の解があった、というのが筆者にとっての一番の収穫でした。これは Claude Code に限った話ではなく、エージェントに外部サービスを触らせる仕組み全般に効く設計だと思います。クレデンシャルを持つのはエージェントではなく、エージェントの外に立つ中継点である、という置き方です。
次は実際に tlsTerminate を有効にして、aws sts get-caller-identity が sentinel のまま通るかどうかを自分の環境で確かめてみるつもりです。
参考
- Configure the sandboxed Bash tool — Claude Code Docs — 本記事の設定・挙動・バージョン要件はすべてこのページに基づく
- Sandbox environments — Claude Code Docs — 組み込みサンドボックスと dev container / VM など他の分離手段の比較
-
Settings — Claude Code Docs —
sandbox配下の設定キー一覧 - anthropic-experimental/sandbox-runtime — 同じ OS プリミティブを単体パッケージ化したもの
- containers/bubblewrap — Linux / WSL2 でファイルシステム分離を担う非特権サンドボックスツール
- Domain fronting — Wikipedia — TLS を検査しない許可リストが回避されうる仕組み