Nobody Is There to Say Yes: Writing Unified Permissions as an Unattended Contract
The v2.2.1 unified permission system assumes a person is watching. On a 3 a.m. scheduled run, that assumption quietly breaks. Here is how I declared the policy up front as an allow / deny / queue contract so an unattended agent never stalls on a prompt no one can answer.
A scheduled run I had kicked off at 3 a.m. was still not finished by morning. I opened the log and it stopped on a single line: "Allow writing to this file?" There was no one there to answer.
The unified permission system that landed in v2.2.1 is a refreshingly plain mechanism for controlling what an agent is allowed to do. But that plainness rests on an assumption: that someone is sitting in front of the screen, ready to press allow or deny. The more you run agents overnight or before dawn as a solo developer, the more quietly that assumption slips away. Today I want to walk through the design I actually built to close that gap.
What happens when you carry an interactive mechanism into unattended hours
The default behavior of unified permissions is to ask when it meets an operation it has not seen before. In an interactive session that is correct. Returning judgment to a human is the foundation of safe design.
The trouble is that this same behavior runs unchanged during hours when there is no human to ask. When an agent running headless or on a schedule raises a prompt, no response comes back. It times out, or worse, it sits there holding the run open. In my case, a stalled process kept a lock and dragged the next scheduled start into the jam, chaining two nights of jobs together. The failure was in one place; the damage spread sideways.
Flipping everything to "allow" is the easy escape, but that throws away the reason you enabled unified permissions in the first place. Precisely because it is unattended, I want the permitted surface to stay narrow — and yet not stall. The only way to have both is to decide the answers before execution instead of asking during it.
The unattended contract: an allow / deny / queue trichotomy
The key was to stop treating the decision as a binary. Interactively, allow-or-deny is enough, because a person can think on the spot. Unattended, both ends hurt. Lean toward allow and dangerous operations slip through; lean toward deny and legitimate work is dropped.
So I added a third exit: queue.
Decision
Runtime behavior
Meaning
allow
Execute as-is
Reversible, contained operations you can call safe in advance
deny
Refuse, stop, and record
Things you never want touched unattended: destructive production changes, secret reads
queue
Hold execution for a morning decision
Neither clearly safe nor clearly dangerous — worth one human glance
An unattended contract is the promise that every operation that could arise is mapped, ahead of time, to one of these three. At runtime the agent consults the contract instead of asking. No prompt appears. So nothing stalls.
I set the axis of judgment on reversibility and blast radius, not on the name of a permission. Reads and changes inside the worktree are allow; side effects over the network or writes to shared resources are queue; destructive operations and secrets are deny. That line maps cleanly onto the operation categories unified permissions already enumerates.
✦
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 manifest that maps unified permissions to allow / deny / queue before the agent runs
✦An audit log of the prompts that would have appeared, plus a morning re-entry digest
✦Measured numbers from 30 nights of scheduled runs: deadlocks, queue volume, and what deny actually stopped
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.
The contract lives outside the code, as a declaration. Fold it into the agent's instructions and it gets carried along by context on every edit, loosening before you notice. I put it in its own file so every change is traceable through git history.
default: queue is the pivot. Drop unknown operations to deny and legitimate work vanishes silently. Pass them through as allow and danger leaks. Fall to hold and the operation never runs, yet the evidence remains for a human to pick up the next morning. It keeps the safety of deny-by-default without losing the work.
Keep each rule at a granularity where it can explain why it decides as it does. That pays off later. A rule your six-months-from-now self cannot read is the first sign of loosening.
Recording the prompts that would have appeared
The most painful thing about an unattended run is not knowing what the agent tried to do and was stopped from doing. Interactively it stays on screen; unattended, the prompt itself never appears. So I write the decision down myself, at the moment the contract is consulted.
# permission_gate.py — consult the contract before running; record decision and reasonimport json, time, fnmatch, sys, pathlib, yamlPOLICY = yaml.safe_load(pathlib.Path("unattended-permission.yaml").read_text())AUDIT = pathlib.Path("audit/permission-events.jsonl")def classify(action: str, **attrs) -> tuple[str, dict | None]: for rule in POLICY["rules"]: m = rule["match"] if m.get("action") != action: continue ok = True for key, pat in m.items(): if key == "action": continue val = attrs.get(key.replace("_glob", "").replace("_prefix", ""), "") if key.endswith("_glob") and not fnmatch.fnmatch(val, pat): ok = False if key.endswith("_prefix") and not any(val.startswith(p) for p in pat): ok = False if ok: return rule["decision"], rule return POLICY["default"], None # unclassified -> default (queue)def gate(action: str, **attrs) -> bool: decision, rule = classify(action, **attrs) AUDIT.parent.mkdir(exist_ok=True) with AUDIT.open("a") as f: f.write(json.dumps({ "ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "action": action, "attrs": attrs, "decision": decision, "matched": rule["match"] if rule else "default", }, ensure_ascii=False) + "\n") return decision == "allow" # queue / deny do not execute here
gate() returns just one thing: may this run right now. Queue and deny are identical in that neither executes. The difference is the decision value in the audit log and how you treat it in the morning. That separation keeps the downstream design simple.
I made the audit log one event per line as JSONL because appends are safe and the morning roll-up is a grep and a few lines of script. Logs for unattended work need two things: not to corrupt on write, and to be light on read.
The next morning, this is where you look
Queuing things up is meaningless if you never go and look. I fold the previous night's decisions into a single digest that I open first thing.
# digest.py — fold last night's audit log into one sheet, grouped by decisionimport json, collections, pathlibevents = [json.loads(l) for l in pathlib.Path("audit/permission-events.jsonl").read_text().splitlines()]by = collections.defaultdict(list)for e in events: by[e["decision"]].append(e)print(f"# Permission decisions last night — {len(events)} total")for d in ("queue", "deny", "allow"): items = by.get(d, []) print(f"\n## {d}: {len(items)}") for e in items[:20] if d != "allow" else []: print(f"- {e['ts']} {e['action']} {e['attrs']}")
It lists only queue and deny; allow is a count. Rereading everything an agent was permitted to do unattended is not realistic. What a human should see is the two things that matter: "held, waiting on your call" and "denied, the line the contract held." Once I could skim that in two or three minutes each morning, unattended operation finally felt sustainable.
For each queued item I decide whether to promote it into the contract or run it by hand once and be done. If the same operation lands in queue three nights running, that is the signal to move it to allow. The contract is not written once; it grows quietly out of the morning digest.
Thirty nights in, the numbers
This design is hard to trust on words alone, so here is what I measured. An indie developer app-maintenance job for one of my wallpaper apps — it touches AdMob remote config and asset sync — run under this contract for 30 consecutive nights.
Metric
Before the contract
After (30 nights)
Deadlocks from waiting on a prompt
7 / month
0
Decision events per night (median)
—
~140
Share that were allow
—
~88%
Operations that fell to queue (median/night)
—
4
Times deny actually stopped danger
—
3 in 30 nights
Morning digest review time
—
~2–3 min
The three that deny stopped were: one unintended git push, one rm -rf folded into a dependency swap, and one secret read. Interactively I would have reflexively refused all three. In the unattended hours, the contract made the same call for me. Small on paper — but if even one of those three had gone through, the nights spent recovering would have been far longer.
The queue median settling at four mattered too. If it were topping twenty every night, that would be a sign the contract is too vague, or that I am mixing in tasks that do not belong unattended at all. The volume of holds mirrors the resolution of your contract directly.
The order to introduce it, and the first line to draw
Trying to classify every operation at once will freeze you. I brought it in on an order.
Fix deny first, and narrowly: destructive production changes and secret reads. Those two, and only those, are never touched unattended. Close that first and you can fill in the rest slowly. Next make allow explicit, limited to what is reversible and contained — reads, worktree changes, running tests. Finally, leave the undecided alone. default: queue will catch it.
Put another way: fill in from the two ends you are sure of, and entrust the middle to hold. That was the quietest way I found to grow an unattended contract without letting it break.
Unified permissions gave us a place to state how far we allow. Make that statement writable not only as an interactive dialog during the hours a human is present, but as a contract for the hours no one is. That single step is what I most wanted to share today. If a nightly job of yours has ever stalled on a prompt, even once, I would gently suggest drawing just the deny line first. That is where I started, and it is how the mornings turned into a few quiet minutes. 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.