Turning Silent Auto-Approvals into Allow Rules, One Soft-Deny at a Time
In Antigravity CLI 1.1.3, headless -p stops silently auto-approving confirmation-required tools and instead soft-denies them, printing the required allow-rule name to stderr. This piece uses that output as a discovery source to build least privilege from an empty allow set upward, with a working harness and real numbers from a personal automation.
The nightly job that pushes my articles returned "success" every night for three months. Exit code 0, and every morning the diff was in place. Nothing looked wrong.
Then I read the 1.1.3 changelog and stopped. "Fixed an issue where headless execution (-p) would hang on tools requiring confirmation, or silently auto-approve them." Which means that for three months, my job had been approving confirmation-required tool calls on my behalf, without a word. It hadn't been succeeding so much as I'd had no way to know what it approved.
This piece turns that 1.1.3 behavior change to my advantage. The allow-rule names a soft-deny leaves on stderr become a discovery source, so instead of paring an allow set down from full access, I build the minimum up from empty. Here is the workflow and the harness, alongside the numbers from actually running it.
Three months of only being able to say "it succeeded"
The danger of an unattended job isn't failure. It's not being able to see what a success contained.
When you drive an agent interactively, confirmation dialogs look like friction. But that momentary "run this?" was also a chance to see the permission boundary with your own eyes. Go headless and that chance disappears. Before 1.1.3, -p passed confirmation-required tools silently. The job never stalled, and no record of the boundary was left anywhere either.
I ran something close to always-proceed: nobody is awake at 3 a.m., so approve everything. It works, in the sense that it runs. But I couldn't reconstruct, after the fact, which tools the job had invoked against which paths tonight. I couldn't, and I didn't even register that I couldn't.
From silent approval to soft-deny in 1.1.3
The 1.1.3 change quietly, but decisively, inverts that premise.
Headless execution now soft-denies a confirmation-required tool instead of passing it, and prints the allow-rule name needed to permit that operation to stderr. On top of that, a bug where always-proceed mode incorrectly auto-approved file writes outside the workspace was closed.
The key point is that a soft-deny is a dialogue, not an error. The job stops, but why it stopped and what you'd have to allow for it to proceed come out on stderr in machine-readable form. What the old dialog asked a human out loud, it now hands back to your automation as text.
Aspect
Before 1.1.3
1.1.3 onward
Confirmation-required tools
Silently auto-approved
Soft-denied + allow-rule name on stderr
Job behavior
Never stalls (contents unknown)
Stops on missing permission (reason recorded)
Out-of-workspace writes
Could be approved under always-proceed
Closed
After-the-fact audit
Hard (no record)
stderr becomes the discovery source
Most people will read this as "it got stricter." And true, a job that passed until last night may stop tonight. But I see it less as a tightening and more as something invisible becoming visible. The stop itself is the outline of an operation that used to be approved in silence.
✦
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 reversed workflow that uses 1.1.3's soft-deny stderr as a discovery source, building minimal allow rules up from empty instead of paring down from full access
✦An audit harness that surfaces the out-of-workspace writes always-proceed used to approve silently, going back over past runs
✦A CI drift gate that watches for an allow set quietly widening back out
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 other is what I'll do here: "build up from empty." Allow nothing at first. Run the job, and add only the allow-rule names a soft-deny names, one at a time, after weighing each. 1.1.3 made this reverse loop practical for the first time, because a soft-deny points at "the next one to add" for you.
The difference between the two shows up in the safety of the initial state. Paring down keeps running with over-broad permissions until you've finished paring. Building up is minimal from the very first night — if something is missing it just stops, and does nothing extra. For automation I leave unattended as an indie developer, I choose the latter. A boundary that's tight from the first run makes trusting the night less uneasy.
The discovery loop is three moves.
Run a discovery pass. With the allow rules empty, or minimal, run the target task headless once. The job will probably stop partway, and that's fine. The goal isn't completion; it's seeing what lines up on stderr.
Harvest. Pull the allow-rule names from the soft-deny lines on stderr, and list them with tool name and target path attached. Mechanically splitting whether the path is inside or outside the workspace here makes the later judgment faster.
Review and promote. A human reads each entry in the list and promotes only the ones worth allowing into the allow config. This is the last line that keeps you from letting the machine wave everything through.
Implementation: a harness that raises allow rules from stderr
Fold the discovery pass and the harvest into a single script. It runs the target command, catches stderr, parses the soft-deny lines, and writes out an allow-rule proposal a human can weigh.
#!/usr/bin/env python3"""allow_discovery.pyRun an agy headless task once, harvest the allow-rule names a soft-deny printsto stderr, and write out a least-privilege allow-rule proposal for human review.The goal is not completion but surfacing "what you'd allow for it to proceed.\""""import subprocess, sys, re, json, os, pathlibWORKSPACE = pathlib.Path(os.environ.get("AGY_WORKSPACE", os.getcwd())).resolve()# Match the 1.1.3 soft-deny line format. Confirm the actual line once with# agy -p "noop" 2>&1 | grep -i deny# and if your environment differs, fix only this one regex.# The point is that a format change is confined to a single place.SOFT_DENY = re.compile( r"soft[- ]deny\b" r".*?\btool=(?P<tool>[^\s]+)" r".*?\ballow[- ]rule=(?P<rule>[^\s]+)" r"(?:.*?\bpath=(?P<path>[^\s]+))?", re.IGNORECASE,)def classify(path: str) -> str: if not path: return "unknown" try: pathlib.Path(path).resolve().relative_to(WORKSPACE) return "in-workspace" except ValueError: return "OUT-OF-WORKSPACE" # the region 1.1.3 closed; always confirm by handdef discover(cmd: list[str]) -> tuple[int, dict]: proc = subprocess.run(cmd, capture_output=True, text=True) rules: dict[str, dict] = {} for m in SOFT_DENY.finditer(proc.stderr): rule = m.group("rule") entry = rules.setdefault(rule, {"tool": m.group("tool"), "paths": set(), "scope": set()}) p = m.group("path") if p: entry["paths"].add(p) entry["scope"].add(classify(p)) return proc.returncode, rulesdef to_proposal(rules: dict) -> dict: out = [] for rule, e in sorted(rules.items()): scope = "OUT-OF-WORKSPACE" if "OUT-OF-WORKSPACE" in e["scope"] \ else (sorted(e["scope"])[0] if e["scope"] else "unknown") out.append({ "allow_rule": rule, "tool": e["tool"], "scope": scope, "sample_paths": sorted(e["paths"])[:3], # defaults to false; a human flips it to true only after reading it "approved": False, }) return {"workspace": str(WORKSPACE), "candidates": out}if __name__ == "__main__": code, rules = discover(sys.argv[1:]) proposal = to_proposal(rules) json.dump(proposal, sys.stdout, ensure_ascii=False, indent=2) print(file=sys.stderr) oow = [c for c in proposal["candidates"] if c["scope"] == "OUT-OF-WORKSPACE"] print(f"[discover] exit={code} candidates={len(proposal['candidates'])} " f"out-of-workspace={len(oow)}", file=sys.stderr)
You use it by passing the target task straight through as arguments.
# Discovery pass: run once with an empty allow set to collect candidatesAGY_WORKSPACE="$PWD" python3 allow_discovery.py \ agy -p "nightly: build one article and push" \ > allow_candidates.json
The point is that approved is pinned to false by default. The harness only lists candidates of the form "allow this and it proceeds"; it grants nothing itself. A human reads the candidates, flips only the ones they're satisfied with to true, and assembles the production allow config from that diff. Because a soft-deny names things for you, you no longer have to imagine allow rules from a blank page. The next one to add is always something stderr told you.
There's a reason the regex is confined to a single line, too. A CLI's output format can change. Keep the thing that breaks when it does inside that one SOFT_DENY line, and recovery is just confirming the real line with grep and swapping it in. It's a decision to keep the parsing logic somewhere the tool's conventions can't drag it around.
Auditing out-of-workspace writes, going back in time
The other hole 1.1.3 closed was always-proceed mode incorrectly auto-approving file writes outside the workspace. That's worth not only "preventing going forward" but confirming "what was written in the past." A job that ran before the fix may have touched somewhere it shouldn't have.
Point the same classify() from the discovery pass at the list of paths past jobs touched. If your job logs its write paths, feed that log in and only the out-of-workspace writes surface.
def audit_writes(paths): """From a list of previously written paths, pull out only the ones outside the workspace.""" flagged = [p for p in paths if classify(p) == "OUT-OF-WORKSPACE"] for p in flagged: print(f" ! out-of-workspace write: {p}") print(f"[audit] {len(flagged)} of {len(paths)} writes were out-of-workspace") return flagged
The paths found here can seed your deny list directly. Write the behavior 1.1.3 now closes by default into your own config too. Yes, it's redundant — but in an environment where CLI versions aren't uniform (staged rollout leaves older versions around, as the reference notes), that explicitness pays off. The idea is to hold the closed behavior as configuration rather than leaving it entirely to the CLI.
The numbers from running the discovery pass once
Here is the result of actually running the discovery pass once against my own article-push automation. The task runs everything through "build one and push," and internally it reads, generates, writes files, runs git operations, and sends over the network.
Item
Count
Note
Allow-rule candidates named by soft-deny
9
Unique count after folding duplicates
Of those, file-write rules
4
Output to content/ and temp files
Of those, network-send rules
2
git push and CI trigger
Candidates targeting outside the workspace
1
Temp file write directly under $HOME
Allow rules a human promoted
7
2 of the 9 were judged unneeded and rejected
Of the 9 candidates, 7 were promoted and roughly 22% (2) were rejected. Two things only became visible once they were numbers.
One: the job I'd been running "fully open" actually needed exactly 7 permissions. For the sake of those 7, always-proceed had, in principle, held everything else open. Narrowed to the minimal 7, the task completed the same way. The gap it had been holding open is the gap in risk.
Two: one candidate targeted outside the workspace. It's a routine that writes a temp file directly under $HOME, nothing malicious. But that's exactly the behavior 1.1.3 closed. Without the discovery pass, I'd still be passing this one, unaware. I moved the temp file into a working directory inside the workspace and dropped external writes to deny.
Watching for the allow set quietly widening
Even once you've narrowed it, an allow set widens again if you leave it alone. Add tasks and new soft-denies appear; repeat "just add it" each time, and before long you've crept back toward full access. Narrowing isn't a one-time event; it's a state that needs maintaining.
So watch the diff between the approved allow config and the candidates needed now, in CI. Run the discovery pass again on CI, and if a candidate appears that isn't in the approved set, don't let it through without review.
#!/usr/bin/env bash# allow_drift_gate.sh — detect new candidates against the approved allow configset -euo pipefailpython3 allow_discovery.py agy -p "nightly: build one article and push" \ > /tmp/candidates.json# Set of approved rule names (approved=true only)comm -13 \ <(jq -r '.rules[] | select(.approved==true) | .allow_rule' approved_allow.json | sort) \ <(jq -r '.candidates[].allow_rule' /tmp/candidates.json | sort) \ > /tmp/new_rules.txtif [ -s /tmp/new_rules.txt ]; then echo "New unapproved allow-rule candidates appeared; review required:" cat /tmp/new_rules.txt exit 1fiecho "No new allow candidates (the config hasn't widened)"
The gate's aim isn't denial. Needing a new permission is natural. The aim is to stop that one entry from entering the config without a human ever laying eyes on it. Soft-deny handles discovery; this gate handles the checkpoint for promotion. Together they hold the narrowed state.
Where to let the machine run, and where to stop
In this loop, the part safe to leave to the machine and the part a human should hold split cleanly. I draw the line at "is the material for the judgment entirely inside stderr?"
Step
Owner
Reason
Running the discovery pass and harvesting stderr
Machine
The material is all on stderr
Classifying inside vs. outside the workspace
Machine
Determined solely by path and boundary
Promoting an allow-rule candidate (approved)
Human
"Should this be allowed?" is context, outside stderr
Whether to permit an out-of-workspace write
Human
May carry irreversible side effects; always confirm individually
Stopping at the drift gate
Machine
Whether a new candidate exists is set arithmetic
Review and re-promotion after a stop
Human
The checkpoint means something because a human clears it
Lean too far toward the machine and the moment you auto-set approved to true, this loop reverts to full access. Lean too far toward the human and it stalls on every discovered entry, hollowing out the point of running unattended. Now that soft-deny has automated the naming, what a human should keep is only the "one push to promote." That part I'd rather not give away.
Wrapping up
1.1.3's soft-deny looks like a tightening, but it's the first thing to show you the outline of operations that used to be approved in silence. The stop itself is information.
As a next step, if you have a job running unattended right now, empty the allow set and run the discovery pass just once. The count of allow-rule names on stderr is the number of permissions your job actually needs. If that count is far from the breadth you have open now, it's worth narrowing. Mine was 7. The gap from full access was, exactly, the size of my unease about trusting the night — something the number made land.
Thank you for reading. I hope it helps with your own setup.
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.