I was scanning a diff over morning coffee when I stopped. A task I had left running overnight was supposed to touch nothing but the repository, yet it had written a config file one directory up. Nothing was harmed. But the fact that it could write there lingered far longer than whatever it had written.
Antigravity CLI 1.1.3 (July 16) fixes a bug where always-proceed mode was mistakenly auto-approving file writes outside the workspace. My little morning surprise had fallen straight through that hole. Even with the fix in place, I would rather not hand the whole decision to the CLI — I want my own thin layer that confirms where a write is allowed to land. This article shares the guard I built for exactly that.
What "writing outside" really means
When a write path slips outside its bounds, the cause is rarely dramatic. Here are the three I actually hit.
| Entry point | What happens | Why it's easy to miss |
|---|---|---|
| Relative path vs. cwd drift | ../config/app.json resolves from a different base than you expected | Correct locally, wrong only during unattended runs |
Stray .. | A generated path picks up .. and steps past the boundary | It reads as a perfectly natural string |
| Symbolic links | A link inside the workspace points to a real target outside it | You can't tell from the path text alone |
They share one lesson: never trust the surface-level path right before you write. A prefix string match lets both .. and symlinks slip past. What you actually need is to resolve where the path really points, and only then decide whether it sits inside the boundary.
A small guard that vets the write path
There are only two jobs. One, canonicalize the target down to its real location. Two, decide whether that location is contained under the workspace root. In Node.js it comes out compact.
// write-guard.mjs
import { realpathSync } from "node:fs";
import { resolve, dirname, relative, isAbsolute } from "node:path";
// The workspace's real path (resolved once at startup)
const ROOT = realpathSync(process.env.AGY_WORKSPACE ?? process.cwd());
// Resolve a path even if it doesn't exist yet, by walking up to
// the nearest ancestor that does exist.
function realpathNearest(p) {
let current = resolve(p);
for (;;) {
try {
const realParent = realpathSync(dirname(current));
return resolve(realParent, current.slice(dirname(current).length + 1));
} catch (e) {
if (e.code !== "ENOENT") throw e;
const parent = dirname(current);
if (parent === current) return current; // reached the filesystem root
current = parent;
}
}
}
export function assertInsideWorkspace(target) {
const real = realpathNearest(target);
const rel = relative(ROOT, real);
const escapes = rel === "" || rel.startsWith("..") || isAbsolute(rel);
if (escapes) {
throw new Error(
`Write target is outside the workspace: ${target}\n resolved: ${real}\n root: ${ROOT}`
);
}
return real;
}The crux is realpathNearest. A file you're about to create doesn't exist yet, so you can't resolve it directly. So you walk up to the nearest existing ancestor, resolve that, and rebuild the path from there. Any symlink along the way is then evaluated against its real target, and .. is caught by checking whether relative returns a string starting with ...
At the write site, you add a single line.
import { writeFileSync } from "node:fs";
import { assertInsideWorkspace } from "./write-guard.mjs";
function safeWrite(target, data) {
const real = assertInsideWorkspace(target); // throws if outside
writeFileSync(real, data);
}Trying to slip past it
I judge a guard less by what it lets through and more by what it refuses. I turned the table below into a test and fixed things until it was green.
| Input | Expected | Intent |
|---|---|---|
notes/today.md | allow | the happy path |
./out/build/log.txt | allow | a relative path inside |
../secrets.json | block | stepping one level up |
/etc/hosts | block | an absolute path outward |
out/../../x | block | going in, then back out |
| under a symlink that points outside | block | the real target is beyond the boundary |
The test itself stays short.
// write-guard.test.mjs
import assert from "node:assert";
import { symlinkSync, mkdirSync } from "node:fs";
import { assertInsideWorkspace } from "./write-guard.mjs";
const pass = ["notes/today.md", "./out/build/log.txt"];
const block = ["../secrets.json", "/etc/hosts", "out/../../x"];
for (const p of pass) assert.doesNotThrow(() => assertInsideWorkspace(p), p);
for (const p of block) assert.throws(() => assertInsideWorkspace(p), p);
// symlink escape
mkdirSync("out", { recursive: true });
symlinkSync("/tmp", "out/escape"); // a link pointing outside
assert.throws(() => assertInsideWorkspace("out/escape/pwn"), "symlink escape");
console.log("Everything allowed and blocked exactly as expected");Whether you block out/../../x is the line that separates this from a string-match guard. A prefix check would call it "inside because it starts with out/," but canonicalization reveals the real target is outside.
Doubling up on the CLI side
On top of the in-app layer, when I run the CLI unattended I like to keep an outline in the allow-rule names too. In 1.1.3, operations that need confirmation are soft-denied, and the CLI prints the allow-rule name you'd need to permit them to stderr. Naming your write rules so their scope is legible makes them far easier to audit later.
# From an unattended run's log, pull only where a write permission took effect
agy -p "$TASK" 2>&1 | grep -iE "allow.*(write|fs)" | sort -uGlance at those allow-rule names and, once each morning, check that no rule wider than you intended has quietly taken hold. Even that small habit makes creeping permissions easier to notice.
Where to put it in practice
I've found this guard leaks the least when it lives inside the function that performs the write — right before the actual writeFileSync call. Paths get assembled in many places, but the exit where you finally write almost always funnels down to one spot. Vet at the exit, and no matter how a path was built along the way, it can't slip outside.
When it does block, don't swallow it — log it. I record three things: the resolved real path, the root I measured against, and the raw input. Seeing at a glance where it started, what it was, and where it tried to go makes diagnosis quick.
If you want to shape the broader blast radius itself, Build for the Day the Agent Breaks Something: Keeping Blast Radius Small is a good companion. For the full picture of handing overnight work to an agent with more peace of mind, I wrote up Letting a Background Agent Work Overnight Without Regretting It by Morning — Guardrails for Unattended Runs. And for building CLI allow rules up from least privilege rather than down from full access, reading Turning Silent Auto-Approvals into Allow Rules, One Soft-Deny at a Time alongside this gives the permission story more depth.
Wrapping up
What I fear most in an unattended run isn't failure — it's quiet success in the wrong place. Vetting the write path takes just two small moves: canonicalize, then check containment. As a next step, I'd suggest tracing where your own task's "write exits" gather. Once you hold the exit, this guard does its job from a single spot.
As an indie developer automating my own tasks, I keep redrawing what I delegate every time I re-read a permission default. If this brings a little more calm to someone else's morning after a night of automated work, I'll be glad. Thank you for reading.