以前、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 の持ち越しと終了時のフラッシュが要点です。ここを落とすと、末尾にセンチネルが来たリクエストだけ差し替わらない、という再現しづらい不具合になります。