A practical guide to designing Permission Boundaries that let AI agents safely touch production databases, deploys, and billing APIs — with dry-runs, approval queues, and audit logs.
I'll start with a confession. I once gave an Antigravity agent UPDATE access to a production database, and it managed to overwrite 1,200 user records with the wrong values. The transaction wrapper saved me — I rolled back within thirty seconds — but the cold sweat I felt while making the "rewind history" decision is something I won't forget.
What I learned was that guardrails and sandboxes are not enough. An agent cannot reliably tell, from context alone, where it is allowed to write and where it is not. The set of allowed write operations has to be enforced from the outside as a Permission Boundary.
This article describes how I design and implement that boundary. The patterns below are running in production behind my own Antigravity agent execution environment, and they have caught real mistakes since the day I added them.
Why a Sandbox Alone Is Not Enough
The Antigravity sandbox feature is excellent at isolating filesystems and network access. But the kinds of incidents that hurt the most in production happen inside the sandbox.
Concretely:
The agent connects to production with valid credentials, but mis-writes the WHERE clause and updates every row in the table.
The agent intends to write to a staging S3 bucket, but a hard-coded production bucket name slips through.
The agent has a deploy API key for testing, runs what it thinks is a dry deploy, and ships untested code to production.
A sandbox controls which resources the agent can touch. A Permission Boundary controls what it is allowed to do with the resources it touched. They are complementary, and neither alone is enough.
The mental model I prefer is "the sandbox shrinks the universe of reachable resources, and the boundary shrinks the universe of allowed actions on those resources." This is structurally the same as the relationship between AWS SCPs and IAM policies — two independent layers, both enforcing limits.
In practice I think of it as defense in depth, but with a specific structure: the sandbox enforces capability (what tools and endpoints exist at all), while the boundary enforces intent (what operations on those endpoints are allowed in the current context). Failing one layer should never automatically grant access through the other.
A Four-Tier Permission Boundary Model
The cleanest way I've found to organize permissions is to bucket every operation into one of four tiers.
Tier 0 — Read-only. SELECT, GET, list. Log it and allow it.
Tier 2 — Production write. Production DB UPDATE, production deploy, billing API. Human approval required.
Tier 3 — Destructive. DROP TABLE, DELETE without WHERE, rollback, account closure. Human approval plus a second factor required.
The classification lives as metadata on each operation, and it is enforced at runtime. Writing "please don't do destructive things" in the agent prompt does not work — the boundary has to be enforced from outside the agent.
// src/permission-boundary/tier.ts// Type definitions for permission tiersexport type PermissionTier = 0 | 1 | 2 | 3;export interface AgentOperation { resource: string; // e.g. "db:users", "s3:bucket-prod-uploads" action: string; // e.g. "select", "update", "delete", "deploy" tier: PermissionTier; reason: string; // The agent's stated reason — kept for auditing}// Single source of truth for the resource × action matrixexport 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; } // Unknown resources default to Tier 3 — deny by default return 3;}
The most important line is the last one: unknown resources default to Tier 3. If you forget to classify a new resource you've added, the system fails closed instead of granting elevated privileges. I've made this rule a permanent operational principle, and it has saved me more than once when a junior engineer added a new bucket and forgot to update the classifier.
A subtle but useful side effect of this design is that the classifyOperation function becomes a kind of executable inventory. If you want to audit "what production resources can my agents touch?", you read this one file. There's no need to crawl through configuration scattered across IAM, secrets managers, and deployment scripts.
✦
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 four-tier classification for agent write operations, enforced from outside the agent as a Permission Boundary
✦Working code for reversible production writes: dry-runs, SQL-hash verification, reverse-operation SQL, and append-only audit logs
✦How to test the boundary itself in CI and keep it from breaking as your agent fleet grows — central policy, budgets, and trust scoring
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.
Dry-Run, Diff Preview, and Approval Queue — All Through One Gate
Tier 2+ operations have to pass three gates before they touch production. I run all of them through a single function so they can never be skipped accidentally.
The non-negotiable invariant is that every write goes through executeWithBoundary. The runtime should not even hand the agent a raw SQL client — only the boundary-aware client. Once you have a single gate, you have one place to add audit, approval, and impact limits. Once you have multiple write paths, one of them will eventually skip a check.
I think of this as the "single gate" pattern. Everything you want to enforce — rate limits, blast-radius caps, two-person rules — becomes trivial once writes funnel through one function.
What to Inspect During Dry-Run
For SQL operations, the dry-run should always tell you three things.
Affected row count. Open a transaction, run the statement, capture ROW COUNT, then ROLLBACK immediately.
Before-image of the rows.SELECT ... FOR UPDATE against the targets and serialize the snapshot for the approval UI.
Index and lock behaviour.EXPLAIN ANALYZE to make sure you're not about to full-scan a billion-row table.
# src/permission_boundary/preview.pyimport psycopg2from contextlib import contextmanager@contextmanagerdef dry_run_transaction(conn): """Run statements but always rollback — for impact estimation only.""" try: with conn.cursor() as cur: cur.execute("BEGIN") yield cur finally: # Always rollback to discard any side effects conn.rollback()def preview_update(conn, sql: str, params: tuple) -> dict: """Dry-run an UPDATE; never persists.""" with dry_run_transaction(conn) as cur: # First, capture a Before snapshot select_sql = sql.replace("UPDATE", "SELECT * FROM", 1).split("SET")[0] cur.execute(select_sql, params) before_rows = cur.fetchall() # Now run the UPDATE — rollback handler will undo it 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], # Show approver only the first ten "summary": f"{affected} rows would be updated", }
A good approval UI shows Before / After / row count. With those three pieces a reviewer can decide in thirty seconds. Without them, the reviewer has to read SQL and mentally simulate the diff, and approval becomes rubber-stamping.
For S3 and other object stores the dry-run shape is different but the principle is identical. List the keys that would be affected, sample the existing objects, and surface a count. I generally cap object dry-run inspection at fifty samples; beyond that the approval UI gets unreadable and reviewers stop paying attention.
// Sketch of the equivalent dry-run for S3 batch operationsexport async function previewS3Diff(op: AgentOperation) { const keys = await listKeysMatching(op.resource, op.params.prefix); if (keys.length > 10_000) { throw new Error(`Refusing preview: ${keys.length} keys exceeds safety threshold`); } const sample = keys.slice(0, 50); const beforeMetadata = await Promise.all(sample.map((k) => headObject(op.resource, k))); return { affectedRows: keys.length, summary: `${keys.length} objects would be ${op.action}d (sample of ${sample.length} shown)`, sample: beforeMetadata, };}
The detail that matters most here is Refusing preview rather than Returning best effort. If you can't preview accurately, refuse — silent best-effort is exactly the failure mode that lets agents write things humans never agreed to.
Dynamic Policies with RBAC and OPA
Tier classification is static. In real operations you also want dynamic conditions: "no Tier 2 outside business hours," "experimental agents are capped at Tier 1," "emergency overrides require an incident ticket." I express these in Open Policy Agent (OPA) Rego files.
# policies/agent_boundary.regopackage agent.boundaryimport future.keywords.ifimport future.keywords.in# Default denydefault allow := false# Tier 0/1 always allowedallow if { input.operation.tier <= 1}# Tier 2 needs an approver and business hoursallow if { input.operation.tier == 2 input.approval.approver != "" is_business_hours(input.now)}# Tier 3 needs two-factor approval and an incident ticketallow 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}# Project-level capdeny[msg] if { input.agent.project == "experiment" input.operation.tier >= 2 msg := "experimental agents cannot perform Tier 2+ operations"}
Calling this Rego from executeWithBoundary lets you change policy without changing code. After an incident, you patch the policy and ship — no full code review cycle, no agent retraining.
The reason I reach for OPA specifically is that it cleanly separates policy from application code. When something goes wrong, you almost always want to tighten the policy faster than you can ship application changes. OPA also gives you a uniform place to express things like quiet hours, holiday freezes, and project-specific carve-outs without scattering conditional logic through your codebase.
If you don't already use OPA, you can prototype the same logic with a small TypeScript policy module and migrate later. The structure of the rules — default deny, explicit allow, and explicit deny[msg] — is the part that matters most.
Audit Logs and Reverse Operations Must Ship Together
If you let agents write, you have to be able to undo their writes. For every Tier 2/3 write, I capture three things:
Before snapshot — the rows as they existed before the change, stored as JSON for 90 days.
Reverse SQL — an auto-generated UPDATE ... SET ... WHERE id IN (...) that would restore the previous state.
Approval chain — agent → approver → executor, every hop logged.
// src/permission-boundary/audit.tsinterface 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> { // Audit log writes go to a separate DB with a write-only role 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, ], );}// Auto-generate the reverse SQL from a Before snapshotexport 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');}
The audit DB itself runs on a write-only role that the agent can never read or modify. This is to defeat the worst-case scenario where an attacker compromises the agent and tries to erase the audit trail. I also send a duplicate of every audit row to a long-term object store with object lock enabled, so even an admin compromise cannot rewrite history within the retention window.
One thing worth emphasizing: the reverse SQL is not a substitute for backups. It's a fast-path recovery for routine mistakes. Catastrophic failures still need point-in-time restore from your standard backup story. The reverse SQL exists so that the typical "the agent updated 200 rows wrong" case takes a minute to fix instead of an hour.
Three Failure Modes I've Personally Hit
1. The "Test Mode" Bypass That Leaks to Production
While iterating, you get tired of waiting for approvals and add if (process.env.SKIP_BOUNDARY) return executor(). That flag will eventually leak to production.
The fix is to invert the control: at the entry of executeWithBoundary, hard-assert that bypasses are only allowed for an explicit allowlist of test resources. In production, the allowlist is empty, so nothing can sneak through.
const TEST_ALLOWLIST = process.env.NODE_ENV === 'test' ? new Set(['db:test:scratch', 's3:bucket-test-fixtures']) : new Set<string>();if (!TEST_ALLOWLIST.has(op.resource) && process.env.SKIP_BOUNDARY) { throw new Error('SKIP_BOUNDARY is not honoured outside the test allowlist');}
The shape matters: the bypass exists, but only when the resource is on a list that is provably empty in production builds. This is much safer than relying on environment variables alone.
2. The SQL Mutates Between Dry-Run and Execute
I once approved an UPDATE that the dry-run reported as 100 rows; the actual execution hit 5,000 because the agent regenerated the SQL after approval and added a stray join. The fix is to hash the dry-run SQL into the approval ticket and compare again at execute time.
If the hash doesn't match, refuse to execute. This makes "approval-then-tamper" technically impossible, and it costs you a single line of code at the gate.
3. The Same Approver Stamps Every Ticket
In a 24/7 operation, a single approver tends to become the default and starts rubber-stamping. I added a Rego rule that enforces a different approver after five consecutive approvals.
deny[msg] if { count([a | a := input.recent_approvals[_]; a.approver == input.approval.approver]) >= 5 msg := "different approver required after 5 consecutive approvals"}
A small amount of operational friction is the price you pay for non-rubber-stamp approvals. If your team is small enough that this rule is hard to satisfy, the answer is not to relax the rule — it's to question whether you should be running Tier 2 operations on a thin staffing model in the first place.
Testing the Boundary Itself
One mistake I made early on was treating Permission Boundary as production code that needs no tests because "it's the policy layer." That logic gets wrong fast.
I now keep a dedicated test suite that exercises the gate directly:
A static fixture of resource × action pairs, asserting the expected tier each time.
A property-based test that random-generates (resource, action) strings and asserts Tier 3 for anything not explicitly classified.
Integration tests that submit a synthetic Tier 2 operation, verify it lands in the approval queue, and confirm execution requires a real approver record.
A "tamper test" that approves a ticket and then mutates the operation payload before execute — the test passes only if execution refuses.
Together this suite takes about a second to run on CI and acts as a tripwire whenever someone refactors the gate. The single most expensive bug I've shipped to production was a refactor that accidentally bypassed the dry-run for one code path. A unit test would have caught it in seconds.
Operating the Boundary as Your Agent Fleet Grows
As an indie developer running several sites and apps solo, I hit this stage quickly: the boundary I described above works for a single agent calling a single backend, but rarely stays that simple. As soon as you have multiple agents — perhaps a research agent, a deploy agent, and a customer-data agent — you discover that the policy story has to be richer than "tier × approver." Each agent needs its own identity, its own role, and its own blast-radius cap.
I solve this with a small registry that ships alongside the codebase:
The executeWithBoundary function consults the registry on every call and refuses to execute if op.tier > agent.maxTier. This makes "least privilege per agent" a structural property of the system rather than a cultural norm. New agents are added by writing a registry entry, and the security review for that entry is a small, reviewable diff rather than a re-audit of the whole codebase.
The rate limit is intentional. Even within their permitted tier, agents should not be allowed to fire off hundreds of writes per minute — that pattern is almost always a runaway loop and rarely a legitimate workload. I run a sliding-window counter keyed by agent.id and return a polite throttle error when an agent exceeds it. The gate code becomes:
const agent = AGENT_REGISTRY[op.agentId];if (!agent) throw new Error(`Unknown agent: ${op.agentId}`);if (op.tier > agent.maxTier) { throw new Error(`Agent ${agent.id} is capped at Tier ${agent.maxTier} but tried Tier ${op.tier}`);}if (await rateLimitExceeded(agent)) { throw new Error(`Agent ${agent.id} exceeded ${agent.rateLimitPerHour}/h`);}
The combination of registry, rate limit, and per-agent cap means a single misbehaving agent cannot exhaust the approval queue or stage a quiet privilege escalation. Even if its prompt is hijacked, it remains structurally bounded.
Designing for the Multi-Tenant Case
If your agents touch tenant-scoped data — most SaaS products will, sooner or later — there is one more invariant worth adding. Every operation must include a tenantId and the boundary must verify that the resource being touched belongs to that tenant.
export interface TenantScopedOperation extends AgentOperation { tenantId: string;}async function assertTenantOwnership(op: TenantScopedOperation) { const owner = await lookupResourceTenant(op.resource); if (owner !== op.tenantId) { throw new Error( `Cross-tenant access denied: agent acting for ${op.tenantId} but resource owned by ${owner}`, ); }}
This check exists alongside the tier classification, not in place of it. The reason is that a "Tier 1 staging write" still must not touch another tenant's staging data. The two dimensions — what kind of operation and whose data — should never collapse into a single check.
The most expensive bug I have read about in this area was an agent that legitimately had write access to a billing record but, due to an ID mix-up, applied a refund to the wrong tenant's invoice. The tier check passed cleanly. Only an explicit tenant-ownership check would have caught it. I now treat that ownership assertion as part of the boundary itself, and I include it in the audit log so cross-tenant attempts are visible even when blocked.
Wiring the Boundary into the Agent Loop
The final piece is making the boundary impossible for the agent to forget. The Antigravity agent loop generally exposes a tool catalog to the model. Make sure that catalog only contains boundary-aware tools — never raw clients.
Notice that the agent never receives a function called "rawSql" or "execAny." The blast radius is bounded by the tool catalog itself, not by the agent's discipline. This is the same idea as principle of least privilege in operating systems, applied one level up.
A useful side-effect: when reviewing what an agent can do in a given environment, you read a single file (the tool catalog) plus a single registry entry (the agent's tier cap). That is your security boundary made human-legible — no need to read prompts, no need to model the agent's reasoning, no need to trust documentation.
Pre-Production Checklist
Before any new agent ships to production, I run through this list:
Every write path goes through executeWithBoundary — verified with grep for direct DB or S3 client usage.
No operation is unclassified — classifyOperation never falls through to the default Tier 3.
Dry-run failures fail closed: the operation does not execute when impact preview is unavailable.
The audit DB is on a separate role and ideally a separate network path.
The approval UI shows Before / After / row count / reverse SQL — all four.
Rego policies have unit tests, and CI runs them on every change.
A rollback runbook exists, and you've drilled it at least once in staging.
That last item — the drill — matters more than people think. Once Permission Boundary is in place, you start to feel safe. The day you actually need to roll back, you don't want to be reading the runbook for the first time. I drill the full Tier 3 → approval → rollback path in staging once a quarter.
Permission Boundary design looks like overhead until the first time it stops you from blasting your production database. The day after I had to recover those 1,200 rows, I would have written this code in two hours and shipped it. Don't wait for the incident — sketch out an executeWithBoundary skeleton this afternoon. It will be the cheapest insurance you've ever bought, and the structure will keep paying off every time you add a new agent.
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.