import Image from 'next/image';
正直に打ち明けます。私はある時期、Antigravity の承認ダイアログに対して「内容を読まずにEnterを押す」癖がついていました。
個人開発でエージェントに実作業を任せる量が増えると、一日のうちに承認を求められる回数が跳ね上がります。ファイル書き込み、コマンド実行、外部APIの呼び出し、Gitのpush。ひとつひとつは正しい確認なのですが、数十回続くと指のほうが先に反応してしまう。ある夜、テスト用のはずのコマンドに本番の環境変数が渡っているのを、承認を押した後で気づきました。手が止まりました。
承認は、多すぎると守りにならない。むしろ「読まずに通す」訓練になってしまう。この記事は、その承認疲れを気合いではなく設計で断つための、私自身の運用のまとめです。Antigravity 2.2.1 で入った統一パーミッション体系と、OAuthトークンのキーリング保存を軸にしています。
なぜ承認は「疲れ」で崩れるのか
承認ダイアログが機能するのは、それが「稀」で「重い」ときだけです。頻度が上がった瞬間、人間の注意は逆方向に働きます。
以前の私の環境は、許可の仕組みが三つの場所に散らばっていました。ひとつはコマンド実行のたびに出る承認ダイアログ。ひとつはMCPツールごとに手で書いた許可リスト。そしてもうひとつが、セッションをまたぐたびに求められるOAuthの再認証です。
それぞれは独立して正しく動いていました。ただ、開発者の体感としては「許可を求められる回数」という一本の数字に合算されます。私の手元では、集中して作業した日の承認・認証の合計が40回を超えることもありました。この回数になると、内容の吟味という本来の目的は失われ、ダイアログは通過儀礼に変わります。
承認疲れの怖いところは、事故が「承認された操作」として記録に残る点です。ガードレールをすり抜けたのではなく、こちらが自分の手で通してしまう。書き込み権限の Permission Boundary 設計で外側から強制する仕組みを整えても、最後のワンクリックが形骸化していれば、その境界は開いた扉と変わりません。
問題は個々の許可リストの中身ではなく、許可が散在していること。ここに手を入れる必要がありました。
統一パーミッションが変えた前提
Antigravity 2.2.1 では、エージェントの操作範囲を一元的に制御する統一パーミッション体系が導入されました。あわせて、更新されたOAuthトークンをOSのキーリングへ自動保存する仕組みが入り、再認証を求められる頻度が下がっています。
この二つは、承認疲れという観点では同じ問題の表と裏です。前者は「許可の定義が一箇所に集まる」こと、後者は「一度許可した認証を無駄に問い直さない」こと。散らばりと蒸し返しの両方を減らします。
| 観点 | 集約前 | 集約後(統一パーミッション) |
| 許可の定義場所 | コマンド承認・MCP許可リスト・再認証の3系統に分散 | 1枚のポリシーに集約 |
| 判断の一貫性 | 同種の操作でも場所ごとに基準がぶれる | 同じ分類には同じ判断 |
| 認証プロンプト | セッションごとに再認証 | キーリング保存で再認証を抑制 |
| 監査 | どこで何を許したか追いにくい | ポリシーと監査ログで一望 |
ここで大切なのは、統一パーミッションはあくまで「入れ物」だという点です。何をどう分類し、どの操作を自動で通し、どこで手を止めるのか。その中身は自分で設計する必要があります。次の節から、私が実際に使っている分類軸とコードを示します。
操作を「可逆性」と「認証コスト」で分類する
承認するか否かを、操作の名前で決めてはいけません。同じ「ファイル書き込み」でも、一時ディレクトリへの出力と、デプロイ設定の上書きでは重みがまるで違うからです。
私は操作を二つの軸で見ています。ひとつは可逆性。取り消せるか、取り消しにコストがかかるか、取り消せないか。もうひとつは認証コスト。その操作が外部の資格情報を使い、失敗が課金や公開につながるか。
この二軸を、四つの区分に落とします。
| 区分 | 意味 | 既定の扱い |
| read | 状態を変えない読み取り | 自動許可 |
| reversible | 取り消せる局所的な変更(一時ファイル・ローカルコミット) | 自動許可+ログ |
| remote | 外部へ影響が出る変更(push・デプロイ・外部API) | 承認を求める |
| irreversible | 取り消せない・課金・公開・削除 | 常に手を止める |
この分類のポイントは、read と reversible を自動で通すことで、承認を「本当に重い操作」だけに絞れることです。承認の総数が減れば、一回一回の重みが戻ります。
分類そのものをコードにします。操作を受け取り、区分を返す関数です。
// permission/classify.ts
export type Tier = "read" | "reversible" | "remote" | "irreversible";
export interface Operation {
kind: "fs" | "shell" | "git" | "http";
action: string; // 例: "write", "delete", "push", "POST"
target: string; // 例: パス・URL・リモート名
}
// 取り消せない、または課金・公開につながる操作の判定
function isIrreversible(op: Operation): boolean {
if (op.kind === "fs" && op.action === "delete") return true;
if (op.kind === "git" && op.action === "push" && /--force/.test(op.target)) return true;
if (op.kind === "http" && op.action === "POST" && /\/(billing|charge|publish)/.test(op.target)) return true;
return false;
}
// 外部へ影響が出る操作の判定
function isRemote(op: Operation): boolean {
if (op.kind === "git" && op.action === "push") return true;
if (op.kind === "http" && op.action !== "GET") return true;
if (op.kind === "shell" && /\b(deploy|wrangler|gcloud)\b/.test(op.target)) return true;
return false;
}
// ローカルで取り消せる変更の判定
function isReversible(op: Operation): boolean {
if (op.kind === "fs" && op.action === "write") return true; // 一時・ソースへの書き込み
if (op.kind === "git" && ["add", "commit"].includes(op.action)) return true;
return false;
}
export function classify(op: Operation): Tier {
if (isIrreversible(op)) return "irreversible";
if (isRemote(op)) return "remote";
if (isReversible(op)) return "reversible";
return "read";
}
分類を名前ではなく性質で決めているのが要点です。git push でも --force が付けば irreversible に上がり、通常のpushは remote に留まります。読者の環境ごとに危険の定義は違うので、この判定関数こそ手で育てる場所です。
散らばった許可を1枚のポリシーへ集約する
区分が決まれば、あとは区分ごとに「自動許可・承認・拒否」のどれを返すかを一枚のポリシーで宣言します。ここが、以前は三系統に散っていた設定の集約先になります。
// permission/policy.ts
import { classify, type Operation, type Tier } from "./classify";
export type Decision = "allow" | "prompt" | "deny";
// 既定は拒否寄り。明示した区分だけを緩める(deny by default)
const POLICY: Record<Tier, Decision> = {
read: "allow",
reversible: "allow",
remote: "prompt",
irreversible: "deny", // 手動でしか行わない操作は最初から遮断
};
// 例外の上書き。ここだけを見れば「何を特別扱いしているか」が分かる
const OVERRIDES: Array<{ match: (op: Operation) => boolean; decision: Decision }> = [
// 検証用のステージングへのデプロイは承認に緩める
{ match: (op) => op.kind === "shell" && /wrangler deploy --env staging/.test(op.target), decision: "prompt" },
// 本番の課金APIは区分に関わらず常に拒否
{ match: (op) => op.kind === "http" && /\/billing\/live/.test(op.target), decision: "deny" },
];
export function decide(op: Operation): { tier: Tier; decision: Decision } {
const tier = classify(op);
for (const rule of OVERRIDES) {
if (rule.match(op)) return { tier, decision: rule.decision };
}
return { tier, decision: POLICY[tier] };
}
この形にした狙いは、判断の全体像を一画面で追えることです。区分ごとの既定はPOLICYに、特別扱いはOVERRIDESに集まります。MCPツールごとの許可リストを個別に書き換えていた頃は、「今どこで何を許しているか」を把握するだけで数分かかりました。集約後は、この二つのオブジェクトを読めば済みます。
移行の手順は次のとおりです。私はこの順で一日かけて片付けました。
- 既存の承認ログとMCP許可リストを書き出し、実際に許可している操作を洗い出す
- 各操作を classify に通し、区分ごとの件数を数える
- read と reversible に落ちた操作は自動許可へ。ここで承認の大半が消える
- remote と irreversible に残った操作だけをOVERRIDESで個別に見直す
- 統一パーミッションの設定を、このポリシーの出力と一致するよう書き換える
MCPの最小権限そのものの考え方はMCPツール許可リストの最小権限設計に譲りますが、集約の観点では「許可リストを消す」のではなく「一枚のポリシーの入力側に畳み込む」と捉えると整理しやすいです。
私の環境では、この集約で一日あたりの承認・認証の合計が40回超から12回前後に下がりました。残った12回はすべて remote 以上、つまり本当に読むべき承認です。数が減ったことで、内容を吟味する習慣が戻ってきました。
認証プロンプトを運用から減らす
承認とは別に、私を疲れさせていたのがOAuthの再認証でした。セッションを切り替えるたびに認証を求められると、それ自体が「読まずに通す」対象になります。
2.2.1 では更新されたトークンがOSのキーリングへ自動保存されるようになりました。この恩恵を確実に受けるために、自作のツール側でもトークンをキーリング経由で扱うようにしています。プロセスの環境変数へ平文で置かないことは、承認疲れ対策であると同時に、資格情報の基本的な守りでもあります。
// permission/token-store.ts
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
// macOS のキーチェーンを例に、トークンをOSの資格情報ストアへ委ねる
const SERVICE = "antigravity-lab";
export async function saveToken(account: string, token: string): Promise<void> {
// 既存を消してから追加(上書き時の重複登録を避ける)
await run("security", ["delete-generic-password", "-s", SERVICE, "-a", account]).catch(() => {});
await run("security", ["add-generic-password", "-s", SERVICE, "-a", account, "-w", token]);
}
export async function loadToken(account: string): Promise<string | null> {
try {
const { stdout } = await run("security", ["find-generic-password", "-s", SERVICE, "-a", account, "-w"]);
return stdout.trim();
} catch {
return null; // 未保存なら null を返し、呼び出し側で認証フローへ
}
}
キーリングに委ねることで、再認証はトークンが実際に失効したときだけに絞られます。私の場合、これで認証プロンプトが週に十数回から数回まで減りました。回数が減った分、たまに認証を求められたときは「なぜ今か」を考える余裕が生まれます。
Linux では secret-tool、Windows では資格情報マネージャーが同じ役割を担います。要は、資格情報の寿命管理をアプリではなくOSへ移すという設計です。
「とりあえず全許可」の再増殖を止める
集約は一度やって終わりではありません。運用していると、面倒な承認に対してOVERRIDESへ例外を足したくなる瞬間が必ず来ます。その一行が積み重なると、ポリシーはまた散らかり始めます。
私は再増殖を防ぐために、決定を必ず監査ログへ落とし、週に一度だけ見返す運用にしています。
// permission/audit.ts
import { appendFile } from "node:fs/promises";
import { decide } from "./policy";
import type { Operation } from "./classify";
export async function guarded(op: Operation): Promise<Operation> {
const { tier, decision } = decide(op);
const line = JSON.stringify({
ts: new Date().toISOString(),
kind: op.kind, action: op.action, target: op.target,
tier, decision,
});
await appendFile("permission-audit.log", line + "\n");
if (decision === "deny") throw new Error(`denied: ${op.kind}:${op.action} ${op.target}`);
return op; // prompt/allow は呼び出し側の承認フローへ渡す
}
週次のレビューで見るのは一つの比率だけです。ログ全体のうち prompt と deny が占める割合。これが下がり続けていたら、例外を足しすぎて自動許可に寄っているサインです。
# 直近ログの区分内訳を集計する
grep -o '"decision":"[a-z]*"' permission-audit.log | sort | uniq -c
私はこの比率が10%を切ったら、OVERRIDESを一度まっさらにして区分から見直すようにしています。承認の数を減らすことが目的ではなく、「重い操作にだけ意識を向けられる状態」を保つことが目的だからです。数字が良くなりすぎたときこそ、緩めすぎを疑います。
承認フローそのものの実装や、繰り返し出る承認ダイアログへの対処は、繰り返される承認ダイアログの修正も合わせて読むと、UI側とポリシー側の両面から詰められます。
まとめ
承認疲れは意志の弱さではなく、許可が散らばっていることの症状です。次の一歩として、まずは一日分の承認・認証ログを書き出し、classify に通して区分ごとの件数を数えてみてください。read と reversible がどれだけ承認枠を食っていたかが見えた瞬間に、集約の効果はほぼ確定します。
私自身、まだこの運用を磨いている途中です。エージェントに任せる範囲が広がるほど、許可の設計は一度作って放置できるものではなくなります。数字を見ながら締め直していく作業を、これからも続けていくつもりです。実装の参考になれば幸いです。お読みいただきありがとうございました。