import Image from 'next/image';
最初に正直に書きます。私は Antigravity のエージェントに本番データベースの UPDATE 権限を渡して、ユーザーテーブルの 1,200 行に意図しない値を書き込んでしまったことがあります。幸いトランザクションで囲んでいて即ロールバックできたのですが、あの「履歴を巻き戻す」という意思決定をした瞬間の冷や汗は、今でもはっきり覚えています。
この事故から学んだのは、ガードレールやサンドボックスだけでは足りないということでした。エージェントは「書き込んでいい場所」と「書き込んではいけない場所」を文脈で判断できません。許された範囲を Permission Boundary(権限境界) として外側から強制する仕組みが、本番運用には必ず必要です。
この記事は、その Permission Boundary をどう設計し、どう実装するかをまとめたものです。私が実際に Antigravity の Agent 実行環境 と組み合わせて運用している構成をベースにしています。
なぜサンドボックスだけでは不十分なのか
Antigravity のサンドボックス機能 は、ファイルシステムやネットワークの分離には有効です。しかし本番運用で起こる事故の多くは、サンドボックスの内側で起きます。
具体的には次のようなケースです。
エージェントが正しい認証情報で本番 DB に接続しているが、WHERE 句を間違えて全行更新してしまう
ステージング用の S3 バケットに書くつもりが、ハードコードされた本番バケット名を残してしまう
デプロイトリガーを叩く API キーが渡されており、テスト用と思って実行したら本番にデプロイされる
サンドボックスは「エージェントが触れるリソースの集合」を制御しますが、Permission Boundary は「触れたリソースに対して何をしていいか」を制御します。両者は補完関係にあり、片方だけでは本番事故を防ぎきれません。
私が好むのは「サンドボックスで触れる対象を絞り、Permission Boundary で操作の種類を絞る」という二段構えです。これは AWS の SCP と IAM Policy の関係と同じ考え方で、防御層が独立している点が肝心です。
4階層の Permission Boundary モデル
Permission Boundary を設計するときは、操作を 4 つの階層に分けて考えると整理しやすくなります。
Tier 0: Read-only(読み取り専用) — SELECT・GET・list 系。ログを残すだけで自由に許可
Tier 1: Sandbox Write(隔離環境への書き込み) — テスト DB・ステージング S3・PR ブランチへの push。自動承認可
Tier 2: Production Write(本番への書き込み) — 本番 DB の UPDATE、本番デプロイ、課金 API(Stripe など)。人間承認必須
Tier 3: Destructive(破壊的操作) — DROP TABLE、DELETE without WHERE、ロールバック、退会処理。人間承認 + 二要素確認必須
この分類を Antigravity のタスク定義ファイル(.antigravity/tasks/*.yaml)にメタデータとして埋め込み、ランタイムで強制します。エージェント側のプロンプトに「破壊的操作はしないでください」と書くだけでは絶対に守られないので、外側から強制することが肝心です。
// src/permission-boundary/tier.ts
// 各操作の Tier を定義する型
export type PermissionTier = 0 | 1 | 2 | 3 ;
export interface AgentOperation {
resource : string ; // 例: "db:users", "s3:bucket-prod-uploads"
action : string ; // 例: "select", "update", "delete", "deploy"
tier : PermissionTier ;
reason : string ; // エージェントが提示する根拠(監査ログ用)
}
// Tier 判定: 「リソース × 操作」のマトリクスを単一箇所で定義
export function classifyOperation ( resource : string , action : string ) : PermissionTier {
// Production resources
if (resource. startsWith ( 'db:prod:' ) || resource. startsWith ( 's3:bucket-prod-' )) {
if ([ 'drop' , 'truncate' , 'delete-bucket' ]. includes (action)) return 3 ;
if ([ 'update' , 'insert' , 'delete' , 'deploy' ]. includes (action)) return 2 ;
return 0 ;
}
// Staging
if (resource. startsWith ( 'db:staging:' ) || resource. startsWith ( 's3:bucket-staging-' )) {
return action === 'select' ? 0 : 1 ;
}
// 未知のリソースはデフォルトで Tier 3 にして deny by default
return 3 ;
}
ポイントは最後の「未知のリソースは Tier 3」というルールです。私はこれを feedback_deny_by_default.md として運用ルールに固定しています。新しいリソースを足したときに分類を忘れても、勝手に高権限で動かない設計にしておくのが安全です。
ドライラン・差分プレビュー・承認キューを一本化する
Tier 2 以上の操作を本番で実行する前に、必ず通すべき 3 つの関門があります。私は次のような単一インターフェースに集約しています。
// src/permission-boundary/gateway.ts
import { classifyOperation, AgentOperation } from './tier' ;
import { previewSqlDiff, previewS3Diff } from './diff' ;
import { enqueueApproval, waitForDecision } from './approval' ;
import { writeAuditLog } from './audit' ;
export interface ExecutionResult {
status : 'executed' | 'rejected' | 'expired' ;
affectedRows ?: number ;
diff ?: string ;
approver ?: string ;
}
export async function executeWithBoundary (
op : AgentOperation ,
executor : () => Promise <{ affectedRows : number }>,
) : Promise < ExecutionResult > {
const tier = classifyOperation (op.resource, op.action);
// Step 1: ドライラン — 実際には書き込まず、影響範囲だけを取得
const diff = await runDryRun (op);
if (diff.affectedRows > 10_000 ) {
// 10,000 行を超える更新は Tier に関わらずブロック
await writeAuditLog ({ ... op, status: 'blocked-large-impact' , diff });
throw new Error ( `Blocked: ${ diff . affectedRows } rows exceeds safety threshold` );
}
// Step 2: Tier 0/1 は即実行、Tier 2/3 は承認キューへ
if (tier <= 1 ) {
const result = await executor ();
await writeAuditLog ({ ... op, status: 'executed' , diff, ... result });
return { status: 'executed' , ... result, diff: diff.summary };
}
// Step 3: 承認キューへ投入
const ticket = await enqueueApproval ({
operation: op,
diff: diff.summary,
requiresTwoFactor: tier === 3 ,
expiresAt: Date. now () + 30 * 60_000 , // 30 分でタイムアウト
});
const decision = await waitForDecision (ticket.id);
if (decision.status !== 'approved' ) {
await writeAuditLog ({ ... op, status: decision.status, ticketId: ticket.id });
return { status: decision.status === 'expired' ? 'expired' : 'rejected' };
}
const result = await executor ();
await writeAuditLog ({ ... op, status: 'executed' , diff, ticketId: ticket.id, approver: decision.approver, ... result });
return { status: 'executed' , ... result, diff: diff.summary, approver: decision.approver };
}
async function runDryRun ( op : AgentOperation ) : Promise <{ affectedRows : number ; summary : string }> {
if (op.resource. startsWith ( 'db:' )) {
return previewSqlDiff (op);
}
if (op.resource. startsWith ( 's3:' )) {
return previewS3Diff (op);
}
throw new Error ( `No dry-run handler for resource: ${ op . resource }` );
}
この実装で重要なのは、すべての操作が executeWithBoundary を必ず通る、という不変条件を作ることです。エージェントが直接 SQL クライアントを呼べないように、ランタイム側で SQL コネクションをブロックし、executeWithBoundary 経由のクライアントだけを露出させます。
私はこれを「単一の門(single gate)」と呼んでいます。書き込みの経路がひとつに絞られていれば、監査・承認・上限チェックを一箇所に集約できます。逆に書き込み経路が分散していると、必ずどこかが抜け落ちます。
ドライランで何を見るのか
SQL の場合、私は次の 3 点を必ず確認しています。
影響行数 — EXPLAIN ではなく実際にトランザクションを開始し、ROW COUNT を取って即 ROLLBACK する
更新前の値 — SELECT ... FOR UPDATE で対象行のスナップショットを取り、Before/After の diff を生成する
インデックスとロック — EXPLAIN ANALYZE でフルスキャンが発生しないか確認する
# src/permission_boundary/preview.py
import psycopg2
from contextlib import contextmanager
@contextmanager
def dry_run_transaction (conn):
"""書き込みはせず、影響範囲だけを取得するためのコンテキスト."""
try :
with conn.cursor() as cur:
cur.execute( "BEGIN" )
yield cur
finally :
# 必ず ROLLBACK して書き込みを取り消す
conn.rollback()
def preview_update (conn, sql: str , params: tuple ) -> dict :
"""UPDATE 文のドライラン. 実際には書き込まない."""
with dry_run_transaction(conn) as cur:
# まず SELECT で Before のスナップショットを取る
select_sql = sql.replace( "UPDATE" , "SELECT * FROM" , 1 ).split( "SET" )[ 0 ]
cur.execute(select_sql, params)
before_rows = cur.fetchall()
# 実行して affected を取得(ROLLBACK されるので副作用なし)
cur.execute(sql, params)
affected = cur.rowcount
if affected > 10_000 :
raise ValueError ( f "Refusing dry-run: { affected } rows is too large for preview" )
return {
"affected_rows" : affected,
"before_sample" : before_rows[: 10 ], # 先頭 10 行だけ承認画面に見せる
"summary" : f " { affected } rows would be updated" ,
}
承認画面に「Before / After / 影響行数」を出すと、人間レビュアーが 30 秒で判断できます。これがないと、承認者は SQL を読んで頭の中で diff を作る必要があり、レビューが形骸化します。
RBAC × OPA で動的権限を評価する
Tier ベースの分類は静的ですが、実運用では「営業時間外は Tier 2 を承認できない」「特定プロジェクトのエージェントは Tier 1 までしか触れない」といった動的な条件を入れたくなります。私はこれを Open Policy Agent (OPA) で表現しています。
# policies/agent_boundary.rego
package agent.boundary
import future.keywords.if
import future.keywords.in
# デフォルトは拒否
default allow := false
# Tier 0/1 は常に許可
allow if {
input.operation.tier <= 1
}
# Tier 2 は承認者がいて、かつ営業時間内
allow if {
input.operation.tier == 2
input.approval.approver != ""
is_business_hours(input.now)
}
# Tier 3 は二要素承認 + 緊急チケット必須
allow if {
input.operation.tier == 3
input.approval.two_factor == true
input.approval.incident_ticket != ""
}
is_business_hours(ts) if {
hour := time.clock([ts, "Asia/Tokyo"])[0]
hour >= 9
hour < 21
}
# プロジェクトごとの上限を別途チェック
deny[msg] if {
input.agent.project == "experiment"
input.operation.tier >= 2
msg := "experimental agents cannot perform Tier 2+ operations"
}
この Rego を executeWithBoundary から呼ぶことで、コードを変えずにポリシーだけを更新できます。たとえば年末年始は is_business_hours を常に false にする、といった運用も Rego の差し替えだけで完了します。
OPA を採用する理由は「ポリシーをアプリケーションコードから切り離せる」ことに尽きます。事故が起きたあと、ポリシーだけを修正してデプロイすれば、コードレビューを最小化しつつ即座に対応できます。
監査ログとロールバックは必ずペアで設計する
書き込みを許す以上、巻き戻せる仕組みが必要です。私はすべての Tier 2/3 書き込みについて、次の 3 点を必ず記録しています。
操作前のスナップショット — UPDATE 前の対象行を JSON で保存(90 日保管)
逆操作 SQL — 巻き戻すための UPDATE ... SET ... WHERE id IN (...) を自動生成して保存
承認チェーン — エージェント → 承認者 → 実行者 のすべてのステップ
// src/permission-boundary/audit.ts
interface AuditEntry {
ts : string ;
operationId : string ;
agentId : string ;
resource : string ;
action : string ;
tier : PermissionTier ;
status : 'executed' | 'rejected' | 'expired' | 'blocked-large-impact' ;
diff : string ;
beforeSnapshot ?: Record < string , unknown >[];
reverseSql ?: string ;
approver ?: string ;
ticketId ?: string ;
affectedRows ?: number ;
}
export async function writeAuditLog ( entry : Partial < AuditEntry >) : Promise < void > {
// 監査ログは別 DB(書き込み専用ロール)に append-only で送る
await auditDb. query (
`INSERT INTO agent_audit_log
(ts, operation_id, agent_id, resource, action, tier, status, diff,
before_snapshot, reverse_sql, approver, ticket_id, affected_rows)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)` ,
[
new Date (). toISOString (),
entry.operationId ?? crypto. randomUUID (),
entry.agentId ?? 'unknown' ,
entry.resource,
entry.action,
entry.tier,
entry.status,
entry.diff ?? '' ,
JSON . stringify (entry.beforeSnapshot ?? []),
entry.reverseSql ?? '' ,
entry.approver ?? null ,
entry.ticketId ?? null ,
entry.affectedRows ?? null ,
],
);
}
// 逆操作 SQL を自動生成する
export function generateReverseSql ( beforeSnapshot : Record < string , unknown >[], table : string ) : string {
if (beforeSnapshot. length === 0 ) return '-- no rows affected' ;
const cases : string [] = [];
for ( const row of beforeSnapshot) {
const setClauses = Object. entries (row)
. filter (([ key ]) => key !== 'id' )
. map (([ key , value ]) => `${ key } = ${ JSON . stringify ( value ) }` )
. join ( ', ' );
cases. push ( `UPDATE ${ table } SET ${ setClauses } WHERE id = ${ JSON . stringify ( row . id ) };` );
}
return cases. join ( ' \n ' );
}
監査ログの DB は 書き込み専用ロール で運用し、エージェントからは絶対に読み書きできないようにします。事故時に「監査ログ自体を消された」という最悪のケースを防ぐためです。
よくある失敗パターン 3 つ
失敗 1: 「テスト用」のフラグでバイパスを作ってしまう
開発中に承認待ちが鬱陶しくなり、if (process.env.SKIP_BOUNDARY) return executor() のような分岐を入れてしまうケースです。これは必ず本番に漏れます。
私はこの教訓から、executeWithBoundary の入口でいきなり assert(process.env.NODE_ENV !== 'test' || allowedTestPaths.includes(op.resource)) を呼ぶようにしました。テスト時は明示的に許可されたリソースだけがバイパスでき、本番では絶対に通りません。
失敗 2: ドライランと本番実行で SQL が違う
承認画面では「100 行更新します」と表示されたのに、実行時には WHERE 句に追加条件が入って 5,000 行に膨らむ、というバグを経験しました。原因は、エージェントが承認後に SQL を再生成していたことでした。
対策は単純で、ドライラン時の SQL ハッシュを承認チケットに保存し、実行直前に再ハッシュして照合する ことです。
import { createHash } from 'crypto' ;
function sqlHash ( sql : string , params : unknown []) : string {
return createHash ( 'sha256' ). update (sql). update ( JSON . stringify (params)). digest ( 'hex' );
}
ハッシュが一致しなければ即拒否します。これだけで「承認後の改ざん」を技術的に不可能にできます。
失敗 3: 承認者が常に同じ人で、形骸化する
24 時間運用していると、承認者が一人に固定され、流れ作業で承認するようになります。これは事故の温床です。
私は「同じ承認者が連続で 5 回承認したら、6 回目は別の承認者を要求する」というポリシーを Rego に入れました。承認の質を担保するためのちょっとした摩擦は、運用上必須です。
deny[msg] if {
count([a | a := input.recent_approvals[_]; a.approver == input.approval.approver]) >= 5
msg := "different approver required after 5 consecutive approvals"
}
境界そのものをテストする
Permission Boundary は「事故を防ぐための最後の壁」ですから、その壁自体にひびが入っていないかを継続的に確かめる必要があります。私はここに、通常のアプリケーションコードよりも厳しいテストを課しています。
まず必ず書くのが、「すべての書き込み経路が executeWithBoundary を通っているか」を機械的に検証するテストです。人間の目視や grep に頼ると、必ずどこかで漏れます。
// tests/boundary-coverage.test.ts
import { readdirSync, readFileSync } from 'fs' ;
import { join } from 'path' ;
// SQL クライアントを直接呼んでいる箇所を静的に検出する
const FORBIDDEN_DIRECT_CALLS = [
/ \b pool \. query \( / ,
/ \b client \. query \( / ,
/ \b knex \( [ ^ )] * \)\. (insert | update | delete) \( / ,
];
function walkSource ( dir : string ) : string [] {
return readdirSync (dir, { withFileTypes: true }). flatMap (( e ) => {
const p = join (dir, e.name);
if (e. isDirectory ()) return walkSource (p);
return p. endsWith ( '.ts' ) ? [p] : [];
});
}
test ( 'no write path bypasses executeWithBoundary' , () => {
const violations : string [] = [];
for ( const file of walkSource ( 'src' )) {
if (file. includes ( 'permission-boundary/' )) continue ; // 境界の内側は除外
const code = readFileSync (file, 'utf8' );
for ( const pattern of FORBIDDEN_DIRECT_CALLS ) {
if (pattern. test (code)) violations. push ( `${ file }: ${ pattern }` );
}
}
expect (violations). toEqual ([]);
});
このテストを CI の必須ゲートに置くと、新しく DB クライアントを直接呼ぶコードが混入した瞬間にマージがブロックされます。私は一度、急いで足した集計バッチがこのテストに引っかかって助けられたことがあります。急いでいるときほど、境界を素通りするコードを書いてしまうものです。この落とし穴を回避する仕組みは、人間の注意力ではなく CI 側に持たせておくのが確実です。
次に、ポリシー(Rego)そのもののテストです。OPA は opa test でポリシーの単体テストを実行できます。
# policies/agent_boundary_test.rego
package agent.boundary
test_tier2_denied_outside_business_hours if {
not allow with input as {
"operation": {"tier": 2},
"approval": {"approver": "masaki"},
"now": 1735700400 # 深夜帯のタイムスタンプ
}
}
test_tier3_requires_two_factor if {
not allow with input as {
"operation": {"tier": 3},
"approval": {"two_factor": false, "incident_ticket": "INC-1"}
}
}
境界の挙動を「文章の仕様」ではなく「実行可能なテスト」で固定しておくと、ポリシーを変更したときに意図しない緩和が起きないかを CI が教えてくれます。私はポリシーを触るときが一番緊張するので、この安全網には何度も救われています。
エージェントが増えたときの境界運用
最初は 1 つのエージェントで始めても、運用が回り始めると、記事生成・監視・デプロイと、役割の異なるエージェントが増えていきます。私自身、個人開発で複数のサイトとアプリを一人で回している都合上、目的の違うエージェントを並行させる場面が日常的にあります。このとき Permission Boundary の設計を最初のまま放置すると、必ず破綻します。
破綻を防ぐために、私は 3 つの原則を守っています。
ひとつ目は、ポリシーを中央リポジトリに集約する ことです。エージェントごとに Rego を散らばらせると、どのエージェントがどこまで触れるのかを誰も把握できなくなります。私はポリシーを一箇所にまとめることを強く推奨します。エージェントの識別子(input.agent.id)で分岐させ、権限の全体像が一枚で見渡せる状態を保ちます。
# 中央ポリシーで、エージェントごとの上限 Tier を宣言的に管理する
max_tier := {
"content-writer": 1, # 記事生成エージェントは隔離環境のみ
"monitor": 0, # 監視は読み取り専用
"deployer": 2, # デプロイ担当だけが本番書き込み可
}
deny[msg] if {
limit := max_tier[input.agent.id]
input.operation.tier > limit
msg := sprintf("agent %q is capped at tier %d", [input.agent.id, limit])
}
ふたつ目は、エージェントごとに独立した予算とレート制限を持たせる ことです。あるエージェントが暴走しても、他のエージェントや本番全体を巻き込まないためです。私は「1 エージェントあたり 1 時間の Tier 2 操作は最大 3 回」といった上限を設けています。これはコスト管理だけでなく、無限ループで承認キューが溢れる事故の防波堤にもなります。
みっつ目は、信頼度を運用実績で更新する ことです。無事故で一定回数の Tier 1 操作を積んだエージェントには、ごく狭い範囲でだけ Tier 2 の自動承認を許す——という段階的な緩和を、監査ログの実績に基づいて判断します。ただし自動昇格には必ず「巻き戻せること」を条件に付けます。逆操作 SQL が生成できない操作は、実績に関わらず自動承認の対象から外します。
// 直近 30 日の監査ログから、そのエージェントの信頼度を算出する
async function trustScore ( agentId : string ) : Promise < number > {
const rows = await auditDb. query (
`SELECT status, COUNT(*) AS n FROM agent_audit_log
WHERE agent_id = $1 AND ts > now() - interval '30 days'
GROUP BY status` ,
[agentId],
);
const total = rows. reduce (( s , r ) => s + Number (r.n), 0 );
const incidents = rows
. filter (( r ) => r.status. startsWith ( 'blocked' ) || r.status === 'rejected' )
. reduce (( s , r ) => s + Number (r.n), 0 );
if (total < 50 ) return 0 ; // 実績が浅いうちは昇格させない
return 1 - incidents / total; // 0〜1 のスコア
}
大切なのは、信頼度スコアを「自動承認の範囲を広げる根拠」に使いつつ、破壊的操作(Tier 3)の人間承認だけは絶対に外さないことです。効率化は Tier 1〜2 の摩擦を減らす方向にとどめ、本当に危険な操作の前には必ず人間が立つ——この線引きを崩さないことが、エージェントが増えても事故を起こさない運用の核だと考えています。
本番投入前のチェックリスト
私が新しいエージェントを本番投入する前に必ず通すチェックリストを共有します。
すべての書き込み経路が executeWithBoundary を通っているか(grep で executor: を検索)
Tier 分類が明示されていない操作がゼロか(classifyOperation のデフォルト Tier 3 に落ちていないか)
ドライランが失敗するときの挙動が「実行しない」になっているか(fail-closed)
監査ログの DB が別ロール・別ネットワーク経路になっているか
承認画面に「Before / After / 影響行数 / 逆操作 SQL」がすべて表示されているか
ポリシー(Rego)の単体テストがあり、CI で実行されているか
ロールバック手順が runbook に文書化され、訓練を 1 回以上実施したか
このうち「訓練を 1 回以上実施」は特に重要です。Permission Boundary が機能している前提で運用していると、いざロールバックしようとした時に手順を誰も覚えていない、という事態になります。私は四半期に 1 度、ステージング環境で「Tier 3 操作 → 承認 → ロールバック」を通しで訓練しています。
権限境界の設計は、面倒に見えて、実は事故ったときのストレスを劇的に減らします。冒頭で書いた 1,200 行誤更新のあと、もし監査ログと逆操作 SQL がなかったら、私は今この記事を落ち着いて書けていなかったと思います。次の AI エージェント機能を本番に出す前に、まず executeWithBoundary のスケルトンだけでも書いてみてください。たぶん 1 日で書けて、その後ずっとあなたのプロダクトを守ってくれます。