以前、sandbox.credentials の denyRead で秘密の読み取り面を絞ったとき 、ひとつだけ引っかかりが残りました。
「読ませない」は強い制御です。ただ、外部 API を叩く必要のあるジョブには、結局どこかで本物のトークンを渡すしかない。deny で絞れるのは「そのジョブに不要な秘密」までで、「そのジョブが使う秘密」は守れないままでした。
2026年8月5日の changelog で、Linux と WSL 向けにサンドボックス資格情報の mode: "mask" が入ったのを見て、手が止まりました。説明にはこうあります。サンドボックス内のコマンドはセンチネル値のコピーを読み、実際の値はサンドボックスプロキシが送信時に差し替える。
読ませないのではなく、偽物を読ませる。そして出口で本物にすり替える。
この一文の設計がどこまで守ってくれるのかは、仕様の文面だけでは判断できません。差し替えという中核の仕組みを最小構成で作り直し、境界を自分の手で押して確かめました。
「読ませない」から「偽物を読ませる」へ
deny と mask は、守る対象が違います。
deny は露出面の制御です。~/.aws/credentials や不要な環境変数など、そのジョブに関係のない秘密をサンドボックスから見えなくします。ファイルシステムの分離を書き込み面から絞ったとき と同じ発想で、触れる範囲そのものを狭める。
mask が守るのは、その先です。ジョブが正当に使う秘密であっても、サンドボックス内のプロセスには実値を見せない。プロセスが読むのは SBX_SENTINEL_... のような無害な文字列で、外向きの通信がプロキシを通過する瞬間にだけ実値へ差し替わります。
この設計の含意は小さくありません。サンドボックス内でプロンプトインジェクションや不正なコードが動いても、盗めるのはセンチネルだけ。センチネルは外の世界では何の権限も持ちません。
ただし、です。「送信時にバイト列を差し替える」という仕組みには、原理的に守れないケースがあるはずです。それを確かめるために、最小のプロキシを書きました。
仕組みを最小構成で作り直す
検証環境は Linux サンドボックス上の Python 3.10.12 です。実際の実装には TLS の扱いなど複雑な層が絡みますが、ここでは「ワイヤ上のセンチネルを実値に置き換える」という中核だけを切り出し、平文経路で再現します。
まず、素朴に書くとこうなります。受け取ったチャンクごとに置換して転送する形です。
# naive 版: recv したチャンク単位でセンチネルを置換する
def handle (client_sock):
up = socket.create_connection(( UPSTREAM_HOST , UPSTREAM_PORT ))
client_sock.settimeout( 0.5 )
try :
while True :
chunk = client_sock.recv( 8192 )
if not chunk:
break
# チャンク内に閉じたセンチネルしか置換できない点に注意
up.sendall(chunk.replace( SENTINEL , REAL_VALUE ))
except socket.timeout:
pass
一見動きます。ところがこの書き方には、ストリーム処理の古典的な穴があります。センチネルが 2 つの recv チャンクにまたがった瞬間、どちらのチャンクにも完全なセンチネルが存在しないため、置換がすり抜けるのです。
対策は、センチネル長 - 1 バイトのテールを常に手元に残す rolling buffer です。
# rolling buffer 版: センチネルがチャンク境界で割れても取りこぼさない
S = SENTINEL # bytes
keep = len (S) - 1 # 境界をまたぐ可能性がある最大長
def handle (client_sock):
up = socket.create_connection(( UPSTREAM_HOST , UPSTREAM_PORT ))
client_sock.settimeout( 0.5 )
buf = b ""
try :
while True :
chunk = client_sock.recv( 8192 )
if not chunk:
break
buf += chunk
buf = buf.replace(S, REAL_VALUE )
if len (buf) > keep:
# テール keep バイトは次のチャンクと結合してから判定する
up.sendall(buf[: - keep])
buf = buf[ - keep:]
except socket.timeout:
pass
if buf:
# 接続終了時に残ったテールを最終置換して送り切る
up.sendall(buf.replace(S, REAL_VALUE ))
エラー処理を省いた骨格ですが、keep の持ち越しと終了時のフラッシュが要点です。ここを落とすと、末尾にセンチネルが来たリクエストだけ差し替わらない、という再現しづらい不具合になります。
実測1: 境界で割れたセンチネルは素朴な置換をすり抜ける
recv のチャンクをあえて 8 バイト刻みにして、19 バイトのセンチネルが必ず境界をまたぐ状況を作りました。Bearer ヘッダにセンチネルを載せ、両方のプロキシを通した結果です。
実装 実値が上流に届いたか センチネルが素通りしたか
naive 版(チャンク単位置換) 届かない 素通りした
rolling buffer 版 届いた 素通りなし
naive 版では、上流サーバーの受信ログにセンチネルがそのまま残っていました。私自身、もし自前でこの種のプロキシを書く機会があれば、この一点だけで自作を思いとどまると感じています。ストリーム置換は見た目より一段難しい。既製の実装が rolling buffer 相当の処理を内蔵していることには、それだけの理由があります。
実測2: 変換された秘密は差し替えられない
ここからが、事前の予想と違った部分です。
差し替えは「ワイヤ上のバイト列にセンチネルの原文が現れる」ことを前提にしています。では、送信前に秘密が変換される認証方式ではどうなるか。
まず Basic 認証。user:パスワード を base64 でエンコードしてヘッダに載せる方式です。センチネルをパスワード位置に入れて rolling buffer 版プロキシを通しました。
上流が受信した Authorization ヘッダを復号した結果:
user:SBX_SENTINEL_a7f3e9
実値への差し替え: 行われず(センチネルが base64 の内側に残存)
プロキシはワイヤ上のバイト列を見ますが、そこにあるのは base64 変換後の文字列です。センチネルの原文はどこにも現れないため、置換のしようがありません。上流には「センチネルをパスワードだと信じた認証情報」が届き、当然 401 で弾かれます。
次に HMAC 署名。リクエストへ署名する方式(AWS SigV4 など)を想定し、センチネルを鍵にした署名と実値を鍵にした署名を比べました。
HMAC-SHA256("payload") の先頭16桁:
センチネル鍵: c22efffa06d956f2
実値の鍵: 37c6b2d919ceadfd
当然ながら別物です。署名はサンドボックス内で計算されるため、センチネル鍵で作られた署名が外へ出ていきます。プロキシが差し替えられるのは署名の材料ではなく完成品の通過点だけなので、検証側では必ず不一致になります。
つまり、マスキングを有効にすれば秘密がすべて守られる、という読み方は正確ではありませんでした。守れるのは「秘密が原文のままワイヤに現れる認証方式」だけ。変換や署名を挟む方式では、そもそも成立しない。ドキュメントの一文からここまで読み取るのは、私には難しかったと思います。
実測3: ボディに載ったセンチネルは、長さを狂わせる
ここまでの検証は、秘密がヘッダに載る前提で進めていました。ところが自分のジョブを見返すと、リクエストボディの JSON にキーを含める API がひとつ混じっています。ヘッダと同じ扱いでよいのか、確かめておくべきでした。
差し替えとは、センチネルを実値に置き換える操作です。両者の長さは、まず一致しません。そしてボディの長さは Content-Length ヘッダが先に宣言しています。
置換した瞬間、宣言された長さと実際に流れるバイト数がずれる。
19 バイトのセンチネルを含む JSON ボディを用意し、実値の長さを変えて上流の挙動を観察しました。
実値の長さ Content-Length の宣言 実際に届いたボディ 上流での結末
32 バイト(センチネルより長い) 73 86 バイト 先頭 73 バイトで切られ JSONDecodeError
10 バイト(センチネルより短い) 52 43 バイト 残り 9 バイトを待って 3.003 秒ブロック
長い側の壊れ方は派手です。ボディが宣言の位置で切られ、上流の JSON パーサが即座に落ちます。原因は見えにくくても、失敗したこと自体はすぐ分かります。
厄介なのは短い側でした。上流は宣言された 52 バイトが揃うまで待ち続け、届かない 9 バイトのためにタイムアウトまで固まります。エラーではなく、遅延として現れる。リトライを挟むジョブなら、「たまに遅い」という症状に化けてしまいます。
プロキシ側で Content-Length を差分ぶん補正すれば、どちらも解消します。
import re
def swap_with_length_fix (head: bytes , body: bytes ) -> tuple[ bytes , bytes ]:
"""センチネルの出現回数ぶん、宣言長を実値との差で補正してから置換する"""
delta = body.count( SENTINEL ) * ( len ( REAL_VALUE ) - len ( SENTINEL ))
if delta:
head = re.sub(
rb " (?i) Content-Length: * (\d + ) " ,
lambda m: b "Content-Length: " + str ( int (m.group( 1 )) + delta).encode(),
head, count = 1 ,
)
return head, body.replace( SENTINEL , REAL_VALUE )
補正を入れた経路では、宣言 86 バイト・実測 86 バイトで JSON が正常にパースされました。既製の実装はこの処理を当然のように内蔵しているはずですが、「センチネルと実値の長さは違う」という当たり前の事実がボディ経路でだけ牙を剥く、という構図は覚えておく価値があります。
チャンク分割転送(Transfer-Encoding: chunked)を使う経路も、各チャンクの長さ宣言が同じ性質を持ちます。長さの宣言と実体が別々に存在する場所は、すべて疑ってよさそうです。
どの認証方式なら任せられるか
実測を踏まえて、手元のジョブを分類した結果がこの表です。
認証方式 秘密のワイヤ上の姿 mask との相性
Bearer トークン(Authorization ヘッダ直載せ) 原文のまま 良い
X-API-Key 等のカスタムヘッダ 原文のまま 良い
Basic 認証 base64 変換後 成立しない
HMAC 署名型(SigV4 等) 署名に変換済み 成立しない
秘密鍵で署名する JWT 署名に変換済み 成立しない
個人開発で回しているスケジュール実行パイプラインでは、私の場合、GitHub への push に使う PAT と Anthropic API のキーが主な秘密です。どちらも Bearer 型のヘッダ直載せなので、mask と好相性という判断になりました。一方、署名型の API を叩くジョブをいつか組むなら、署名処理をサンドボックスの外へ出して回避するか、短命の資格情報へ寄せる設計 と組み合わせることになりそうです。
オーバーヘッドの実測と、導入前の5段階
差し替えの走査コストも測りました。1KB ボディのリクエストを 200 回、直結とプロキシ経由で比べています(ローカルループバック・平文経路での参考値です)。
経路 中央値 p95
直結 0.612 ms 0.848 ms
プロキシ経由(置換あり) 1.277 ms 1.547 ms
中央値で +0.665ms、直結比でおよそ2.1倍です。実際のネットワーク往復が数十〜数百 ms である以上、通常の API 呼び出しでは誤差の範囲に収まります。高頻度の小さなリクエストを内側ループで大量に打つ構成でだけ、累積を意識すれば十分という感触です。
導入前の確認は、この順で進めるのをお勧めします。
ジョブが叩く外部 API の認証方式を列挙する 。ヘッダ直載せか、変換・署名を挟むかで二分する
原文のまま載る方式の秘密だけを mask の対象にする 。Bearer 型はここに入る
変換・署名型の秘密は mask に入れず、従来どおり deny・unset の側で扱う 。署名が必要ならサンドボックスの外で行う
秘密がボディに載る API を別枠にする 。ヘッダ直載せと違い、長さ宣言の整合という論点が上乗せされる(実測3)
切り替え後は上流の 401 率と p95 レイテンシを併せて見張る 。分類ミスは認証エラーとして、長さのずれは遅延として表面化する
5 にレイテンシを加えたのは、実測3 で自分の前提がひとつ崩れたからです。書き始めた時点では「mask の分類ミスは静かに壊れず、必ず 401 で表面化する」と考えていました。ヘッダ経路ではそのとおりです。ただしボディ経路で実値が短い場合だけは、エラーではなく待ち時間として現れます。401 だけを見張っていると取りこぼす失敗が、ひとつ残っていました。
境界を測ってから任せる
deny で露出面を絞り、filesystem の分離で書き込み面を絞り、そして mask で「使う秘密」さえプロセスに見せない。サンドボックスの守りが層になってきた実感があります。
ただ、新しい層を重ねる前に境界を測る手間は省けません。今回なら「差し替えはワイヤ上の原文にしか効かない」という一点を知らずに署名型の API へ適用していたら、原因の見えにくい認証エラーに時間を溶かしていたはずです。長さの宣言がずれる方は、もっと静かでした。
まずは自分のジョブが使う秘密と認証方式の一覧を作るところから始めていただくのが良いと思います。私はこの整理をして初めて、mask に任せられる範囲が具体的に見えました。表が埋まれば、判断はほとんど終わっています。
検証に使ったコードはそのまま流用いただけます。どこかの切り分けの時間を少しでも短くできれば幸いです。