きっかけは、あるプロジェクトのエージェントが、本来触らせないはずのディレクトリにファイルを書き込んだことでした。原因を追うと、別のプロジェクトでは設定していた許可ツールの制限が、そのプロジェクトには入っていませんでした。同じ安全策を各プロジェクトにコピーして回るうちに、1箇所だけ古いまま取り残されていたのです。
個人開発で複数のアプリと複数のサイトを並行で動かしていると、Antigravity SDK を呼ぶコードがあちこちに散らばります。App Store と Google Play のリリース自動化、AdMob のレポート集計、記事の生成。どれもエージェントに任せていますが、そして散らばったコードは、必ず少しずつ食い違っていきます。片方を直して片方を忘れる。新しいモデルに移すとき、3箇所は直したが4箇所目を見落とす。こうした食い違いは、注意深さでは防げませんでした。
そこで、SDK を直接呼ぶのをやめ、薄いラッパーを1本だけ作って、すべてのプロジェクトをそこに通すようにしました。ここでは、そのラッパーにコスト上限・許可ツール・出力検証を集めた設計を共有します。
なぜ「薄い」ラッパーなのか
最初に注意したのは、ラッパーを厚くしすぎないことでした。SDK の機能を全部包み直すような分厚い抽象を作ると、SDK が更新されるたびにラッパーも追従が必要になり、かえって保守が増えます。
私が目指したのは、SDK の呼び出しはほぼそのまま通し、横断的に効かせたい安全策だけを差し込む薄い層です。具体的には、すべての呼び出しが必ず通る一点を作り、そこでコスト・ツール・出力をチェックする。SDK のАPIそのものは隠さず、危険なデフォルトだけを禁止する。この線引きが、長く使えるラッパーの条件でした。
ラッパーに集めたい横断的な安全策は、整理すると次の3つでした。
コスト上限です。上限なしの実行を構造的に禁止し、見積もりと実コストの二重で止めます。
許可ツールです。既定では読み取り系だけを許し、書き込みは明示許可がない限り通しません。
出力検証です。スキーマに通らない出力は採用せず、本番運用の下流に流しません。
この3つは、どのプロジェクトでも同じ形で効かせたい注意点です。だからこそ、各所にコピーするのではなく1箇所に集める価値がありました。
危険なデフォルトを型で禁止する
ラッパーの価値の半分は、実は型設計にあります。SDK を直接使うと、安全策の引数は「任意」です。任意ということは、忘れられるということです。ラッパーでは、忘れてはいけない引数を必須にしました。
import { createAgent, type AgentRunResult } from "@antigravity/sdk" ;
// 各呼び出しが必ず渡さねばならない安全策 — すべて必須にする
interface SafeRunOptions {
task : string ;
// コスト上限を任意にしない。上限なしの実行を型レベルで禁止する
maxCostUsd : number ;
// 許可ツールは明示必須。空配列なら「何も許可しない」を意味する
allowedTools : string [];
// 出力スキーマ検証は必須。検証なしの採用を許さない
validate : ( output : unknown ) => boolean ;
// モデルは別名でなく固定バージョンで受ける
model : `gemini-3.5-flash-${ string }` | `gemini-3.5-pro-${ string }` ;
}
export async function safeRun ( opts : SafeRunOptions ) : Promise < AgentRunResult > {
// ここがすべての呼び出しの一点。以降の章でガードを足していく
return runWithGuards (opts);
}
maxCostUsd を任意にしないのが効きました。SDK では上限を渡さなければ青天井で走ります。ラッパーの型でこれを必須にすると、上限を書き忘れたコードはそもそもコンパイルが通りません。安全策を「書いてあるか目で確認する」運用から、「書かなければビルドできない」仕組みに変えられます。
model をテンプレートリテラル型で固定バージョンに縛ったのも同じ発想です。gemini-3.5-flash という別名だけを渡すコードは型エラーになり、必ずバージョン付きで書くことになります。
fail-closed: 迷ったら止める
ガードの本体は runWithGuards に集めます。設計の原則は fail-closed です。判断に迷う状況では、通すのではなく止めます。
async function runWithGuards ( opts : SafeRunOptions ) : Promise < AgentRunResult > {
// 1. 許可ツールの正当性を先に検査する
const unknownTools = opts.allowedTools. filter ( t => ! KNOWN_SAFE_TOOLS . has (t));
if (unknownTools. length > 0 ) {
throw new GuardError ( `unknown tool requested: ${ unknownTools . join ( ", " ) }` );
}
// 2. コスト上限を実行前に見積もりと突き合わせる
const estimate = await estimateCost (opts.task, opts.model);
if (estimate.usd > opts.maxCostUsd) {
throw new GuardError (
`estimated $${ estimate . usd } exceeds cap $${ opts . maxCostUsd }` );
}
const agent = createAgent ({
model: opts.model,
tools: opts.allowedTools,
// SDK 側の実コスト上限も二重で設定する
hardCostCapUsd: opts.maxCostUsd,
});
const result = await agent. run (opts.task);
// 3. 出力検証。通らなければ採用しない(fail-closed)
if ( ! opts. validate (result.output)) {
throw new GuardError ( "output failed schema validation; refusing to adopt" );
}
return result;
}
ここで効くのは、コスト上限を二重にしていることです。実行前の見積もりで弾くだけでなく、SDK の hardCostCapUsd も同じ値で設定します。見積もりは外れることがあるので、実行中の実コストでも止まるようにしておく。片方だけでは、見積もりを超えて走り続ける事故を防げません。
出力検証で fail-closed にしたのも、痛い経験からです。以前は検証に通らない出力でも「とりあえず保存して後で直す」運用にしていましたが、その「後で」は来ませんでした。検証に通らない出力は採用せず例外にする、と決めてから、壊れた成果物が下流に流れる事故を回避できるようになりました。
コスト上限の既定は、私の運用では1回あたり$0.5に置いています。軽い記事生成やレポート集計はこの範囲に収まり、これを超える見積もりが出たら、まず何かがおかしいと疑う閾値として機能します。プロジェクトによっては重い処理もあるので、後述する上書きで個別に引き上げますが、既定は低く保つことを推奨します。低い既定は、暴走を最初に止めてくれる安全網になります。
ポリシーを1箇所で更新する
ラッパーに集約した最大の恩恵は、ポリシーの更新が1箇所で済むことでした。許可ツールの一覧、コスト上限のデフォルト、検証ルール。これらをラッパー内の定数とヘルパーに集めておくと、方針を変えるときに触る場所が1つになります。
// プロジェクト横断の既定ポリシー — ここだけを更新すれば全プロジェクトに効く
export const KNOWN_SAFE_TOOLS = new Set ([
"read_file" , "search_code" , "run_tests" ,
// "write_file" は既定では含めない。必要なプロジェクトだけ明示的に許可する
]);
export const DEFAULT_COST_CAP_USD = 0.5 ;
// よく使う検証を名前付きで提供する
export const validators = {
nonEmptyJson : ( o : unknown ) => typeof o === "object" && o !== null ,
hasRequiredKeys : ( keys : string []) => ( o : unknown ) =>
typeof o === "object" && o !== null && keys. every ( k => k in o),
};
たとえば、新しいモデルに移行するとき。以前は各プロジェクトの呼び出しを順に直していて、4箇所目を見落とす事故が起きました。今は、ラッパーの model 型の許容範囲を更新すれば、古いバージョンを渡しているプロジェクトはすべて型エラーで一斉に浮かび上がります。反映漏れが起きようがありません。
許可ツールから write_file を既定で外したのも意図的です。書き込みは最も危険な操作なので、必要なプロジェクトだけが明示的に許可する形にしました。冒頭で書いた「触らせないディレクトリに書き込んだ」事故は、この既定の見直しで構造的に防げるようになりました。
プロジェクト固有の上書きをどう許すか
一元化のジレンマは、プロジェクトごとの事情をどう扱うかです。すべてを共通化すると、特殊な要件を持つプロジェクトが窮屈になります。私は、既定は共通で持ちつつ、各プロジェクトが明示的に上書きできる余地を残しました。
// プロジェクト固有の設定。既定を継ぐが、明示すれば上書きできる
export function projectPolicy ( overrides : Partial <{
costCapUsd : number ;
extraTools : string [];
}> = {}) {
return {
costCapUsd: overrides.costCapUsd ?? DEFAULT_COST_CAP_USD ,
allowedTools: [ ... KNOWN_SAFE_TOOLS , ... (overrides.extraTools ?? [])],
};
}
ここで重要なのは、上書きが「黙って緩める」形にならないようにすることです。extraTools で危険なツールを足すと、そのコードは差分として明確に残ります。レビューのときに「このプロジェクトはなぜ書き込みを許可しているのか」が一目で分かる。緩和は禁止しないが、必ず目に見える形でしか緩和できない。この設計が、一元化と柔軟さの折り合いでした。
ラッパー自体をどう検証するか
安全策を1箇所に集めると、その1箇所が壊れたときの影響範囲も大きくなります。だからこそ、ラッパー自体には他のどのコードよりも手厚くテストを書きました。
特に大切にしたのは、ガードが「正しく止まること」のテストです。上限を超える見積もりで例外が出るか、未知のツールを渡したら弾かれるか、検証に通らない出力が採用されないか。通る側のテストよりも、止まる側のテストのほうが、安全策では重要でした。fail-closed の設計は、止まるべきときに本当に止まることを確かめて初めて意味を持ちます。
もうひとつ、ラッパーの変更は必ず全プロジェクトに波及するので、ポリシーの既定値を変えるときは、影響を受けるプロジェクトを事前に洗い出すようにしています。型エラーで一斉に浮かび上がる仕組みは便利ですが、それは「変更前に誰が影響を受けるか分かる」状態とセットでこそ安全に使えます。一元化の強さは、見通しの良さと表裏一体だと考えています。
どこから始めるか
すでに散らばっている呼び出しを一気に書き換えるのは大変です。私は、新規の呼び出しからラッパー経由に統一し、既存は触る機会があるたびに移していきました。一度に全部やろうとせず、SDK を直接 import している箇所を機械的に検出して、リストを少しずつ減らす進め方が現実的でした。
5つのプロジェクトに散っていた安全策を1本のラッパーに集めてから、ポリシー更新の反映漏れはゼロになりました。安全策は、各所に正しくコピーし続けることでは守れません。通る道を1本に絞り、そこに集めることで初めて、注意深さに頼らずに守れるようになります。
複数のプロジェクトで同じ安全策をコピーして消耗している方の、設計を見直すきっかけになれば幸いです。