The Cleanup Step Removed the Working Directory, Not Its Contents — Making Unattended Destruction Fail Closed
An unattended cleanup step deleted the working directory itself instead of what was inside it. Here is why the mkdir -p that followed was not a safety net, and how a defensive-looking default value ended up selecting the destructive branch, with the actual verification output.
I opened the logs one morning and a directory that should have been there was gone.
To be precise, what disappeared was not the contents of the cleanup target. It was the cleanup target itself. A maintenance step that runs once overnight — one line I had written — had taken the parent directory along with everything under it.
The cause was obvious within a minute. I had forgotten -mindepth 1 on a find. What took much longer was everything after that: why I had not noticed, and why the mkdir -p sitting right below it had not helped. Following those two questions turned up the same shape of hole in several other pieces of automation I run as an indie developer shipping my own apps.
The real question turned out to be a single one. When an unattended step lands in an "I don't know" state, which way does it fall? Mine all fell toward destruction.
What disappeared was the directory, not the files inside it
Here is the incident reproduced. The output below comes from an actual run on GNU bash 5.1.16 with GNU findutils 4.8.0.
The starting path you hand to find is itself the first match. -delete acts on that first match too. So -mindepth 1 is not an optional refinement. It is the flag that decides whether the starting point survives.
That part is preventable once you know it. The part that actually cost me time came next.
mkdir -p was not a safety net
My script had mkdir -p "$WORK" immediately after the cleanup. The intent was "even if something goes wrong, we recreate it." Here is what that actually buys you:
The directory comes back. The contents do not. And the awkward part is that everything downstream then proceeds normally, because everything downstream only checks that the directory exists. The existence test passes, writes succeed, the exit code is zero.
An incident happened, and nothing observed it as one. I found out the next morning by reading logs with my own eyes. The mkdir -p I had written as recovery was working as concealment.
The same shape shows up in other places. "Create it if missing." "Reinitialize it if corrupt." "Fall back to defaults if unreadable." All three wear the face of recovery, and all three erase the evidence before continuing.
Antigravity CLI 1.1.16 shipped a fix for exactly this pattern: it no longer overwrites an unparseable settings.json with defaults. Previously, once the file could not be parsed, the next save silently reset every setting. Now the save is refused, the file is preserved byte for byte, and the status line names the offending file.
Overwriting an unreadable config with defaults is the same construction as my mkdir -p. A branch written with recovery in mind became the path that destroys the most information. It was a well-timed reminder to go read my own 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
✦You will be able to find which of your unattended cleanup and initialization steps fall toward deletion when something is unknown, before an incident forces you to find out
✦You will be able to recognize, inside your own scripts, why filling a failed lookup with a default value quietly selects the destructive branch
✦You will be able to port a guard that keeps deletion inside an allowed root, including the symlink path that a naive prefix check lets through
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.
This reads as defensive. Errors are silenced with 2>/dev/null, and ${FREE_MB:-0} guards against an unset variable. I wrote both of those in the name of safety.
df ok: [3195]
df failed: [] len=0
value used in the test: [0]
When df fails the variable holds an empty string, and ${FREE:-0} substitutes an empty string just as readily as an unset one. So "free space unknown" is read as "free space is zero," and the cleanup branch is taken.
That is the counterintuitive part. I had treated :-0 as a safety measure. What it actually did was translate unknown into the worst possible value — and that worst value happened to map onto the most destructive branch.
Adding set -u does not close this path either. As long as :- is present, there is no unset-variable error to catch.
Treat "unknown" as a third state
The fix was small. Stop modeling this as a boolean and model it as yes / no / unknown.
# Return empty on failure. Never fall back to 0.free_mb() { local v v=$(df "$1" --output=avail -m 2>/dev/null | tail -1 | tr -d ' ') [[ "$v" =~ ^[0-9]+$ ]] || return 1 # not a number means failure printf '%s' "$v"}if V=$(free_mb /tmp); then if [ "$V" -lt 500 ]; then purge_under "$ALLOW_ROOT" "$CACHE_DIR" fielse echo "SKIP: cannot read free space, not running cleanup" >&2fi
Verify that the value is numeric; if you cannot, report failure. The caller then does nothing. Yes, disk pressure may build up unnoticed for one cycle. You will observe that on the next run. You will not observe the files that were deleted.
For anything running unattended, that asymmetry is the whole decision rule. What you skipped, you can still do later. What you deleted, you cannot. When in doubt, fall toward the recoverable side.
Keeping deletion inside an allowed root
Adding -mindepth 1 is still not enough. If the target path itself points somewhere unexpected, deleting "only the contents" causes exactly the same damage.
So I moved deletion into a single function that refuses to work outside an allowed root.
# purge_under: delete only the contents of target, only if target is under allow_rootpurge_under() { local allow_root="$1" target="$2" local ar tg # Resolve to real paths; if we cannot resolve, do nothing ar=$(cd "$allow_root" 2>/dev/null && pwd -P) || { echo "SKIP: cannot resolve allow_root" >&2; return 3; } tg=$(cd "$target" 2>/dev/null && pwd -P) || { echo "SKIP: cannot resolve target" >&2; return 3; } # Never operate on the allowed root itself [ "$tg" = "$ar" ] && { echo "SKIP: target is allow_root itself" >&2; return 3; } # Confirm containment using the resolved paths case "$tg/" in "$ar"/*) : ;; *) echo "SKIP: outside allow_root ($tg)" >&2; return 3 ;; esac find "$tg" -mindepth 1 -delete && echo "OK: cleared contents of $tg"}
Five inputs, actually run:
target passed in
Result
State of the protected file
$B/root/cache (normal)
OK: contents cleared
cache itself survives, 0 entries
$B/root (the allowed root)
SKIP
root survives
$B/outside (plainly outside)
SKIP
precious survives
$B/root/../outside (via ..)
SKIP
precious survives
$B/root/link (symlink pointing outside)
SKIP
precious survives
return 3 is a private code meaning "did not run." Keeping it distinct from 0 and 1 lets the caller notice that skips are piling up. Swallow the skip as success and you are back to silent anomalies.
A naive prefix check is fooled by a symlink
That pwd -P in the guard is doing real work. Compare a plain string prefix test against a resolved one:
naive check: allowed
real path: /tmp/probe3/outside
resolved check: rejected (outside)
The string $B/root/link genuinely begins with $B/root. The naive test passes. The real location is outside.
One clarification worth making: find -mindepth 1 -delete does not follow symlinks. It removes the link, not the target. I verified this — the precious file behind the link survived and only the link was removed.
So the hazard is not in find. It is in the containment check. Once a path containing a link is accepted as "inside," nothing after that point can save you. Do the check against resolved paths, always.
What the tool now handles, and what stays yours
Antigravity has been closing the same class of gap from its side:
Version
Change
What it means on your side
CLI 1.1.14
Paths outside the workspace are read-only by default; writes require approval based on the execution mode
The default route for an agent writing outside is narrower. What happens past an approval is unchanged
CLI 1.1.14
One malformed MCP server entry no longer prevents the others from loading
The "one bad entry silences everything" shape is gone; invalid entries are logged and skipped
CLI 1.1.14
Language server failures are logged and produce a non-zero exit
If you call the CLI from CI, failures previously counted as successes now surface
CLI 1.1.16
An unparseable settings.json is no longer overwritten with defaults; the save is refused and the file preserved byte for byte
A concrete example of removing the "unreadable, so fall back to defaults" path — a good template for auditing your own scripts
For taking inventory of what writes outside the workspace in the first place, I went through that separately in Count What Writes Outside Your Workspace Before You Upgrade to CLI 1.1.14. This article picks up after that point: what the destructive operation actually does once it has been approved.
It is easy to relax when the boundary tightens. Every incident described here happened entirely inside the workspace. The tool protects you from reaching outside. It does not protect you from deleting too much inside.
How to hand cleanup work to an agent
Since this happened, I include four things in any request that touches cleanup or initialization.
Confine deletion to one function, and forbid rm and find -delete anywhere else. Review then has a single place to look. Reading a diff now starts and usually ends with that function
Every value that can fail to load needs an explicit "could not load" path. Do not apply :- defaults to values used in a decision
Give skips their own exit code. Do not blend them into success
Do not place a recreate step directly after a delete step. If recreation is needed, make it a separate stage
The fourth is less about ordering than about intent. When cleanup and recovery share a function, each one hides the other's failure. Keep them apart and a failed cleanup leaves the directory missing, so the next stage fails honestly. An honest failure is a gift.
Here is the order I used to look for the same hole elsewhere, sorted by how much damage each can do.
#
What to look for
How to find it
1
Deletions that can take the starting point
find calls with -delete and no -mindepth
2
rm -rf with a variable in the path
Can you read off, right there, what it points to when the variable is empty?
3
Defaults applied to decision values
Any :- appearing inside an if
4
Recreation immediately after deletion
mkdir -p living in the same function as the delete
5
Containment checks on raw strings
Path comparisons not passed through pwd -P or realpath
Items 1 and 2 can be found mechanically. From item 3 onward you have to read with the original intent in mind. That is where I stalled longest, because every "default I added for safety" showed up as a candidate.
One thing to do next
Count the find calls with -delete in your repository, just once, and see how many are missing -mindepth. In my case the incident was not the only one left.
The hard part about unattended work is that its failures are quiet. Simply making breakage visible changes what the next morning looks like. I have not finished fixing all of mine, but the direction things fall is slowly changing.
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.