Before Your Finger Learns the Approval Dialog: Folding Antigravity Permissions Into One Policy
Scattered approval dialogs, per-MCP allowlists, repeated re-auth. Built around Antigravity 2.2.1's unified permissions and OAuth keyring storage, here is how I fold every permission into a single policy and design away approval fatigue, with working code and measured numbers.
Let me be honest. For a stretch of time, I had trained my finger to hit Enter on Antigravity's approval dialogs without reading them.
As an indie developer, when I delegate more real work to agents, the number of times I am asked to approve something climbs fast. File writes, shell commands, external API calls, git pushes. Each one is a reasonable check on its own, but after a few dozen in a row, my hand started responding before my eyes did. One night I approved a command that was supposed to hit a test target, then noticed a moment too late that it carried a production environment variable. My hands went still.
Approvals stop protecting you when there are too many of them. They quietly become training in waving things through. This article is my own operational writeup on how to design away that fatigue instead of relying on willpower, built around the unified permission system introduced in Antigravity 2.2.1 and OAuth token storage in the OS keyring.
Why approvals collapse under fatigue
An approval dialog works only when it is rare and heavy. The instant its frequency rises, human attention runs the other way.
My earlier setup scattered permission logic across three places. One was the approval dialog that appeared for each command. One was the per-MCP-tool allowlist I maintained by hand. The third was the OAuth re-authentication I was asked for every time I switched sessions.
Each worked correctly in isolation. But to a developer, they all add up into a single number: how many times a day you are asked for permission. On a focused day, my combined approvals and authentications sometimes passed 40. At that count, the actual purpose, weighing what the operation does, is gone. The dialog becomes a formality.
The dangerous part of approval fatigue is that the accident is recorded as an approved operation. Nothing bypassed the guardrails; you opened the door yourself. Even with a strong Permission Boundary design for write access enforcing limits from the outside, if the final click is hollow, that boundary is a door left open.
The problem was not the contents of any single allowlist. It was that permission itself was scattered. That is where I needed to work.
What unified permissions changed
Antigravity 2.2.1 introduced a unified permission system that controls the scope of agent operations from one place. Alongside it, refreshed OAuth tokens are now saved automatically into the OS keyring, which lowers how often you are asked to re-authenticate.
From the angle of fatigue, these two are the front and back of the same problem. The first means permission definitions gather in one location. The second means an authentication you already granted is not needlessly questioned again. Together they cut both the scattering and the re-asking.
Aspect
Before consolidation
After (unified permissions)
Where permission lives
Split across command approvals, MCP allowlists, and re-auth
Gathered into one policy
Consistency of judgment
Same class of operation judged differently per place
Same class, same decision
Auth prompts
Re-auth on every session
Keyring storage suppresses re-auth
Auditability
Hard to trace what was allowed where
Policy and audit log seen at a glance
The point to hold onto is that unified permissions are only a container. How you classify things, which operations pass automatically, and where you stop your hand: that content is yours to design. From the next section on, I show the classification axes and the code I actually run.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A TypeScript design that classifies operations by reversibility and auth cost, then decides allow, prompt, or deny from one policy
✦A concrete migration path from scattered MCP allowlists, command approvals, and re-auth into unified permissions
✦Long-term operation with OAuth keyring storage and a weekly audit to stop the quiet return of allow-everything
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Never decide whether to approve based on an operation's name. The same "file write" carries wildly different weight for output to a temp directory versus overwriting a deploy config.
I look at operations along two axes. One is reversibility: can it be undone, is undoing costly, or is it impossible. The other is auth cost: does the operation use external credentials, and does failure lead to charges or public exposure.
I collapse those two axes into four tiers.
Tier
Meaning
Default handling
read
Reads that change no state
Auto-allow
reversible
Undoable local changes (temp files, local commits)
Auto-allow with log
remote
Changes with external effect (push, deploy, external API)
Ask for approval
irreversible
Cannot be undone; charges, publishing, deletion
Always stop
The value of this split is that passing read and reversible automatically narrows approvals down to genuinely heavy operations. When the total count of approvals drops, the weight of each one returns.
I turn the classification itself into code: a function that takes an operation and returns a tier.
// permission/classify.tsexport type Tier = "read" | "reversible" | "remote" | "irreversible";export interface Operation { kind: "fs" | "shell" | "git" | "http"; action: string; // e.g. "write", "delete", "push", "POST" target: string; // e.g. path, URL, remote name}// Operations that cannot be undone, or lead to charges/publishingfunction 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;}// Operations with external effectfunction 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;}// Locally undoable changesfunction isReversible(op: Operation): boolean { if (op.kind === "fs" && op.action === "write") return true; // temp or source writes 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";}
The key is deciding by property, not by name. A git push rises to irreversible when --force is present, while an ordinary push stays at remote. Your definition of danger differs from mine, so this classifier is exactly the part you grow by hand.
Fold scattered permissions into one policy
Once tiers are set, all that remains is declaring, in one policy, whether each tier returns auto-allow, approval, or denial. This becomes the single home for settings that used to live in three separate systems.
// permission/policy.tsimport { classify, type Operation, type Tier } from "./classify";export type Decision = "allow" | "prompt" | "deny";// Deny-leaning default. Loosen only the tiers you name (deny by default)const POLICY: Record<Tier, Decision> = { read: "allow", reversible: "allow", remote: "prompt", irreversible: "deny", // operations done only by hand are blocked from the start};// Explicit overrides. Reading this alone tells you what is treated speciallyconst OVERRIDES: Array<{ match: (op: Operation) => boolean; decision: Decision }> = [ // Deploys to a verification staging target are loosened to approval { match: (op) => op.kind === "shell" && /wrangler deploy --env staging/.test(op.target), decision: "prompt" }, // The live billing API is always denied regardless of tier { 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] };}
The aim of this shape is that the whole of your judgment fits on one screen. Per-tier defaults gather in POLICY, special cases in OVERRIDES. Back when I edited per-MCP allowlists individually, just understanding "what am I currently allowing where" took minutes. After consolidation, reading these two objects is enough.
The migration steps are as follows. I cleared mine in a single day, in this order.
Export existing approval logs and MCP allowlists to surface what you actually permit
Run each operation through classify and count how many fall into each tier
Move operations that land in read and reversible to auto-allow. Most approvals vanish here
Review only the remaining remote and irreversible operations, one by one, in OVERRIDES
Rewrite the unified permission settings so they match this policy's output
The thinking behind MCP least privilege itself belongs to MCP tool allowlist least-privilege design, but from the consolidation angle it helps to see it not as "deleting the allowlist" but as "folding it into the input side of one policy."
In my setup, this consolidation dropped the daily total of approvals and authentications from over 40 to around 12. All 12 that remain are remote or higher, meaning approvals genuinely worth reading. With the count down, the habit of weighing content came back.
Reduce auth prompts from the operation side
Separate from approvals, what wore me down was OAuth re-authentication. When you are asked to authenticate every time you switch sessions, that too becomes something you wave through.
In 2.2.1, refreshed tokens are saved automatically into the OS keyring. To reliably benefit, I also handle tokens through the keyring in my own tooling. Not placing credentials in a process environment variable as plain text is both a fatigue countermeasure and a basic defense for credentials.
// permission/token-store.tsimport { execFile } from "node:child_process";import { promisify } from "node:util";const run = promisify(execFile);// Using the macOS keychain as an example, defer tokens to the OS credential storeconst SERVICE = "antigravity-lab";export async function saveToken(account: string, token: string): Promise<void> { // Delete before adding to avoid duplicate entries on overwrite 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; // if unsaved, return null and let the caller start the auth flow }}
Deferring to the keyring narrows re-auth to only when a token has actually expired. In my case, this dropped auth prompts from a dozen-odd per week to a handful. With the count lower, when I am occasionally asked to authenticate, there is room to ask "why now."
On Linux secret-tool plays the same role, and on Windows the Credential Manager does. The design is simply moving the lifetime management of credentials from the app to the OS.
Stop the quiet return of allow-everything
Consolidation is not a one-time job. In operation, there will always come a moment when you want to add an exception to OVERRIDES for an annoying approval. Stack up those one-liners and the policy scatters again.
To prevent that quiet regrowth, I always drop decisions into an audit log and review it just once a week.
// permission/audit.tsimport { 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 pass to the caller's approval flow}
At the weekly review I look at a single ratio: the share of the log that is prompt plus deny. If it keeps falling, that is a sign you have added too many exceptions and drifted toward auto-allow.
# Tally the tier breakdown of the recent loggrep -o '"decision":"[a-z]*"' permission-audit.log | sort | uniq -c
When that ratio drops below 10 percent, I wipe OVERRIDES clean and rebuild from the tiers. The goal was never to reduce the number of approvals; it was to keep a state where I can put attention only on heavy operations. Precisely when the number looks too good, I suspect over-loosening.
For the approval flow itself and for handling repeating approval dialogs, reading Fixing repeating command approval dialogs alongside this lets you tighten both the UI side and the policy side.
Wrapping up
Approval fatigue is not weakness of will; it is a symptom of permission being scattered. As a next step, export one day of approval and auth logs, run them through classify, and count each tier. The moment you see how much of your approval budget read and reversible were eating, the payoff of consolidation is nearly certain.
I am still refining this operation myself. The wider the range you delegate to agents, the less permission design is something you build once and leave alone. I plan to keep tightening it while watching the numbers. I hope it helps with your own implementation, and thank you for reading.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.