Count What Writes Outside Your Workspace Before You Upgrade to CLI 1.1.14
Antigravity CLI 1.1.14 makes paths outside the workspace read-only by default. If your source assets and delivery targets live outside the repository, it is worth knowing what breaks before you upgrade. Here is a script that inventories your write targets, and the two ways it quietly undercounts them.
The source images for my wallpaper apps do not live in the app repository.
Originals sit in a cloud-synced folder, derived renditions are written somewhere else, and the delivery staging area is somewhere else again. Running six apps as an indie developer, keeping images out of the app repository was simply the least painful arrangement. The repository stays small, and several apps can share the same source set.
I have been handing the sorting and rendition work to agents with that layout in place. From the agent's point of view, both the reading and the writing happen outside the workspace.
That is where I stopped while reading the Antigravity CLI 1.1.14 notes. Access to paths outside the workspace is now read-only by default. Writing requires authorisation appropriate to the execution mode.
So in my layout, reading the source would still work, and writing the output might not. And the moment I would find out is the next time that job runs.
Noticing after the upgrade is noticing too late
The awkward part of a change like this is how quietly it breaks things.
If you are sitting at the editor, an approval prompt appears and you immediately understand. The trouble is with the overnight batch and the non-interactive runs invoked from CI. I wrote about protecting the turn boundary in non-interactive runs in Guarding the Turn Boundary in Non-Interactive Runs, and the assumption underneath that design was that permitted operations go through. When the default itself shifts, that assumption is the first thing to re-check.
So I settled on one piece of work to do before upgrading: count every place in my repository that writes outside the workspace.
Just count. Deciding what to fix comes afterwards. I chose that order because fixing while counting means forgetting the ones you could not fix.
The smallest script that finds write targets
The job is simple. Walk the scripts and config files in the repository, find write operations, and report the ones whose destination points outside the tree.
My first version was about this short.
#!/usr/bin/env python3"""Inventory writes that land outside the workspace (v1, literals only)."""import os, re, sysWRITE_PATTERNS = [ (re.compile(r'(?:^|\s)>>?\s*("?)([^\s"\'|;&]+)\1'), "shell redirect"), (re.compile(r'\b(?:cp|mv|rsync)\b[^\n]*?\s("?)(/[^\s"\']+|\.\.[^\s"\']*)\1\s*$'), "copy dest"), (re.compile(r'open\(\s*["\']([^"\']+)["\']\s*,\s*["\'][^"\']*[wax][^"\']*["\']'), "python open(w)"), (re.compile(r'--out(?:put)?[= ]\s*("?)([^\s"\']+)\1'), "cli --out"),]SKIP_DIRS = {".git", "node_modules", ".next", "__pycache__"}TEXT_EXT = {".sh", ".bash", ".py", ".mjs", ".js", ".ts", ".json", ".yml", ".yaml", ".toml"}def audit(root): root = os.path.abspath(root) for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] for name in filenames: if os.path.splitext(name)[1] not in TEXT_EXT: continue path = os.path.join(dirpath, name) lines = open(path, encoding="utf-8", errors="ignore").read().splitlines() for lineno, line in enumerate(lines, 1): for pattern, kind in WRITE_PATTERNS: m = pattern.search(line) if not m: continue raw = m.group(m.lastindex).strip('"\'') if raw.startswith(("$", "~", "%")): print(f"UNRESOLVED\t{os.path.relpath(path, root)}:{lineno}\t{kind}\t{raw}") continue target = raw if os.path.isabs(raw) else os.path.normpath(os.path.join(dirpath, raw)) if not (target + os.sep).startswith(root + os.sep): print(f"OUTSIDE\t{os.path.relpath(path, root)}:{lineno}\t{kind}\t{target}")if __name__ == "__main__": audit(sys.argv[1] if len(sys.argv) > 1 else ".")
Narrowing by extension in TEXT_EXT keeps the walk from opening images and binaries. Leaving node_modules out of SKIP_DIRS buries the output under build scripts from dependencies.
To test it I built a small fixture that mirrors my own layout: source input outside, derived output via a relative path, delivery staging outside, a log outside, and a cache under the user directory.
# scripts/derive.pyimport jsonCACHE = "/Users/shared/agent-cache/derive.json"def save(data): with open(CACHE, "w") as f: json.dump(data, f) with open("./local_report.txt", "w") as f: f.write("ok")
Those two files contain three writes that land outside the workspace: the rsync to delivery staging, the >> log append, and open(CACHE, "w").
Two of three. The open(CACHE, "w") vanished entirely.
✦
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
✦You will be able to mechanically inventory every place your project writes outside its workspace, before the upgrade rather than after
✦You will be able to avoid the failure mode where an automated job goes read-only and you find out from a missing file the next morning
✦You will be able to decide, target by target, whether to widen the workspace, authorise the write, or move the output somewhere it never needed to leave
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 ones you miss are the ones assigned to a variable
Of everything in this exercise, this is the finding I did not expect.
I had assumed that if anything slipped through, it would be some oddly written line. It was the opposite: the miss was the destination that had been neatly hoisted into a constant at the top of the file.
In hindsight it follows. You extract a path into a variable because it matters, because more than one place uses it, and because you might change it later. Nobody promotes a throwaway temp file to a constant. Which means a path held in a variable is far more likely to be a shared folder or a delivery target. A literals-only audit skips precisely the lines you most wanted to see.
That is not "good enough", it is "wrong where it counts", so it was worth fixing. I added a pass that collects literal assignments within the same file and expands them.
ASSIGN_SH = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_]*)=(?:"([^"]*)"|\'([^\']*)\'|(\S+))\s*$')ASSIGN_PY = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:"([^"]*)"|\'([^\']*)\')\s*$')VAR_REF = re.compile(r'\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?')def collect_vars(lines): """Collect literal assignments only. Expressions and calls are deliberately ignored.""" env = {} for line in lines: m = ASSIGN_SH.match(line) or ASSIGN_PY.match(line) if m: env[m.group(1)] = next(g for g in m.groups()[1:] if g is not None) return envdef expand(raw, env, seen=()): """Expand $VAR recursively. `seen` stops self-referential loops.""" def sub(m): name = m.group(1) if name in seen or name not in env: return m.group(0) return expand(env[name], env, seen + (name,)) return VAR_REF.sub(sub, raw)
At the call site, build env = collect_vars(lines) before walking the file. If a matched destination is a bare identifier, look it up in env; if it contains $VAR, run it through expand(). Exclude assignment lines from the walk itself — without that, OUT="../dist/derived" matched the cli --out pattern and got counted twice.
All three. The unresolved=0 matters just as much: while anything sits in that column, you have not finished counting. I treat "unresolved reaches zero" as the end of one pass.
Paths that come from the environment ($HOME, or variables injected by CI) have no assignment in the file, so expand() leaves the $ in place and they surface as UNRESOLVED. That is the correct behaviour. Silently passing something the machine cannot determine is the more dangerous option, so they stay flagged for a human to look at.
The same ../dist can be inside or outside
One more thing I did not see coming until I ran it.
Whether a relative destination is inside or outside depends on what you resolve it against. I resolved ../dist/derived two ways:
base = the file's location (scripts/) -> <root>/dist/derived INSIDE
base = cwd at run time (project root) -> <root>/../dist/derived OUTSIDE
One line, two answers, decided entirely by the base.
The unpleasant part is that the base you reach for naturally when writing the tool — the directory the file sits in — returns the reassuring answer. Seen from scripts/, ../dist lands back inside the repository. But build_assets.sh is actually invoked from the project root, so at run time it goes out.
An audit tool whose errors fall in the "misses things" direction is the worst kind. You upgrade with confidence and the job stops at 3am.
The fix is not hard. For shell scripts, pin the working directory at the top.
cd "$(dirname "$0")/.." || exit 1
With that line, the file-location base and the run-time base agree. Pinning the script beats teaching the audit tool to guess the working directory — that was my conclusion here. Making a guess more precise is a project that rarely ends.
Sorting the destinations into three buckets
Once the inventory exists, each destination gets a treatment. I use three buckets.
Bucket
What kind of destination
Treatment
When it does not fit
Bring it inside
Output specific to this project, not shared with anything else
Widen the workspace to include it, or move it into the repository
A shared area written to by several projects
Authorise the write
Sharing is the point and it cannot move — delivery staging, for example
Configure authorisation for the execution mode, scoped to explicit paths
Destinations decided at run time that cannot be enumerated in advance
Stop writing there
Logs, caches, intermediate files
Move the output inside the workspace and emit only finished artefacts outward
Another process is watching that location
Applying that to the fixture's three findings: delivery staging is bucket two, the log and the cache are bucket three. For the log and the cache I could not articulate any real reason they were outside. I had picked the user directory as a scratch location when I first wrote the script, and never revisited it.
Laid out that way, only one of the three writes actually needed to be outside. The other two dissolve through tidying the design rather than through permissions. Reducing external writes you never needed comes before adding authorisations.
The question of how tightly to scope permissions also comes up in Are You Actually Using Every Permission You Granted?. That one is about trimming granted permissions down from real usage; this one runs the other way, working out what you will need once the default tightens. Opposite directions, same prerequisite: without an inventory in hand, you cannot decide anything.
What to check after upgrading
With the inventory sorted, upgrade and verify. Having an order removes the hesitation.
While you are at the keyboard, run the single job that writes furthest outside. Watch whether it asks for approval or proceeds silently
If it asks, check which bucket that destination belongs to before you touch any settings
Trigger one non-interactive job manually and confirm it runs to completion
Once it does, restore the scheduled runs
Step three is tempting to skip. But passing interactively says nothing about passing non-interactively — default-mode handling and non-interactive handling are worth confirming as separate things.
I do not verify everything on every update. I did this time because the release notes used the words "by default". A change to a default reaches every place you never made explicit, and what you left implicit is exactly what you do not remember.
Where to start
Pick the repository where you have automated the most, and run version two of the script against it. The outside and unresolved counts alone give you a decent sense of the blast radius.
In my case, two of the three external writes were not permission problems at all. They were places I had never decided on properly, still running on a choice I made years ago and forgot. Until I counted, I had genuinely forgotten I was writing there.
If you run a similar layout, I hope this saves you ten minutes before your next upgrade. 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.