The report said a developer asked an agent to clear a cache and ended up with the contents of a drive gone. That night I opened my own build-cleanup shell script instead of closing the tab.
As an indie developer, I clear build leftovers several times a week. One line in that script, if its variable ever came through empty, could have pointed at a root directory. The report was about an agent, but my stomach dropped over an old script of mine.
What I want to say first: narrowing how far a delete can reach matters more than deciding whether to switch approvals off. Below is a small Python checkpoint, and the line I now draw between what I hand over and what I don't.
What was reported, and what I won't claim
I only read the headline and summary of the coverage. A cache cleanup was requested, and more than intended was deleted. Whether the cause was a vague instruction, path resolution, or a permission setting, I can't tell from what is public.
So please read what follows not as a reproduction of that case, but as a precaution against the same kind of accident in your own setup. The kind is simple: the delete reached further than the person who asked imagined.
Narrow the reach first, polish the request second
My first instinct was to fix the prompt. I added "only the build folder of this project" and then more reminders. The results were poor, because a reminder only works if the reader honors it.
I do it the other way round now. I don't leave correctness to attention or polite wording. A machine checks the scope at the one point just before the delete runs. The checks are few:
- The target is not an empty string (this catches an unexpanded variable)
- Once resolved to a real path, the target sits inside the project
- The target is not the project root itself
- The target is not shallow, like a root or
/Users/name - Symbolic links are not followed
- Directories that contain
.gitare never removed
Every one of these looks obvious in hindsight. That is exactly why they slip when left to human eyes.
The checkpoint (dry-run by default)
Save this as safe_rm.py outside the project, for example in a bin folder under your home directory. Keeping it outside the project is a small detail that matters. If it lives where the agent can edit, the checkpoint itself can be rewritten.
#!/usr/bin/env python3
"""A checkpoint in front of delete commands. Dry-run by default; deletes only with --apply."""
import argparse
import os
import shutil
from pathlib import Path
MIN_DEPTH = 3 # anything shallower than /Users/name/project is refused outright
def check(target: str, root: Path) -> Path:
if not target.strip():
raise SystemExit("refused: empty target (a variable may not have expanded)")
p = Path(target).expanduser()
if p.is_symlink():
raise SystemExit(f"refused: symlinks are not followed: {p}")
real = p.resolve(strict=True)
if len(real.parts) <= MIN_DEPTH:
raise SystemExit(f"refused: path is too shallow: {real}")
if real == root or root not in real.parents:
raise SystemExit(f"refused: outside the project, or the root itself: {real}")
if (real / ".git").exists():
raise SystemExit(f"refused: directory contains .git: {real}")
return real
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("targets", nargs="*")
ap.add_argument("--root", default=os.getcwd())
ap.add_argument("--apply", action="store_true")
a = ap.parse_args()
root = Path(a.root).resolve(strict=True)
if not a.targets:
raise SystemExit("refused: no targets")
for t in a.targets:
real = check(t, root)
if real.is_dir():
size = sum(f.stat().st_size for f in real.rglob("*") if f.is_file())
else:
size = real.stat().st_size
print(f"{'delete' if a.apply else 'would delete'}: {real} ({size:,} bytes)")
if a.apply:
shutil.rmtree(real) if real.is_dir() else real.unlink()
if __name__ == "__main__":
main()Three reasons for the shape. Without --apply nothing is removed, so the first command an agent runs is always a preview. resolve(strict=True) refuses to wave through paths that don't exist. And the check uses root not in real.parents rather than a string prefix, because a prefix match confuses /proj with /proj-old.
What happened when I ran it
I built a small tree: a proj folder with build/cache and a symlink pointing outside, and one file outside proj that must survive.
| Target passed in | Result |
|---|---|
proj/build/cache (dry-run) | Preview only; nothing removed |
| Empty string | Refused (exit code 1) |
A directory outside proj | Refused |
| A symlink pointing outside | Refused; the outside file is untouched |
| The project root itself | Refused |
/tmp (shallow) | Refused |
A subdirectory containing .git | Refused |
proj/build/cache (--apply) | Deleted; the outside file remains |
Because refusals exit with code 1, they show up as failures when an agent runs the script. Nothing slips through quietly.
Which work gets approval-free, and which doesn't
A checkpoint doesn't replace deciding what to hand over. Here is where I split it.
| Task | Hand over without approval | Why |
|---|---|---|
| Regenerating build artifacts | Yes | If it vanishes, the same steps bring it back |
Deletes through safe_rm.py (dry-run) | Yes | It only prints a plan; nothing changes |
safe_rm.py --apply | After I look | I read the printed plan once myself |
| Deletes that include paths outside the project | No | I confirm recoverability first |
| Deletes under a synced folder (Dropbox and similar) | No | The deletion propagates to other machines |
I added the synced-folder row from experience. Something deleted inside Dropbox leaves you relying on version history unless you have a local backup. It is recoverable, but hunting for it in the middle of the night is something I would rather skip.
A second safety net, for work that skips the checkpoint
Even with deletes narrowed, I keep a way back. Before any large task I do just two things:
- Commit the working directory once in Git so the state, untracked files included, is pinned
- Keep anything that lives outside the project out of the agent's working directory in the first place
Both are short sentences, and that is why I can still do them on a tired day.
Approval-screen names and locations change between versions, so I won't describe them here. The idea of allow lists and deny lists is laid out in the official documentation; when you touch those settings, I'd keep it open beside you.
One line to start with
Open your cleanup script today and check what it would delete if a variable came through empty. That alone gave me one cold sweat.
People polish the request; a machine narrows the reach. I try to keep that split even on days when I'm worn out.