Chapter 2 — When your agent deletes your files

Files are more forgiving than a database. If a file is committed to git, a deletion is an inconvenience, not a loss. But there is one shape of file deletion that is every bit as final as DROP TABLE: a recursive force-delete pointed at the wrong directory. rm -rf does not ask, does not move things to a trash, and does not care whether the path was ./build or ~. This chapter is about keeping that one command from ever pointing somewhere fatal, and giving everything else a recycle bin.

2.1 What actually happens

The failure mode comes in two flavors.

The first is the catastrophic target: rm -rf aimed, through a bad variable expansion or a misread path, at /, at ~, at $HOME, or up through .. into a parent tree. An unset variable is the classic trigger — rm -rf "$DIR/build" becomes rm -rf /build when $DIR is empty, or rm -rf "$HOME/$PROJECT" wipes your home directory when $PROJECT is empty. An agent assembling a cleanup command from context is exactly the kind of actor that produces an empty variable and runs the result without flinching.

The second is scope creep on a delete that was supposed to be small. In one documented case, a user asked the agent to clear a specific set of telemetry rows; it deleted those and then an unrelated set of 12,547 rows nobody had mentioned. The same incident report describes the agent deleting an entire Docker database volume during what was meant to be a migration fix (anthropics/claude-code#46779). “Delete this one thing” became “delete more than you asked.”

2.2 Why an agent does this

An agent cleaning up after itself is helpful right up until the path is wrong. “Let me remove the old build artifacts,” “let me clear the cache,” “let me reset the working directory” — each is a reasonable intention that compiles down to a recursive delete. The model is not reckless; it simply does not have the human reflex of pausing when a delete path looks suspiciously short, or when a variable might be empty. It executes the command it composed, and rm -rf executes back, immediately and silently.

2.3 The block — stop the fatal target, recycle the rest

Two layers. First, a hard block on the catastrophic targets — a recursive delete that names /, a top-level directory, ~, $HOME, or a parent-directory traversal is refused, whether or not the target is quoted. (What it cannot judge is a target the shell computes; see the limit at the end of this chapter.) Merge it into ~/.claude/settings.json — add the entry to the existing hooks.PreToolUse array rather than pasting over the file, and check the count before and after, as §1.4 sets out:

 1 {
 2   "hooks": {
 3     "PreToolUse": [
 4       {
 5         "matcher": "Bash",
 6         "hooks": [
 7           {
 8             "type": "command",
 9             "command": "bash -c 'INPUT=$(cat); command -v jq >/dev/null 2>&1 || { echo \"GUARD INACTIVE: jq not found; this hook is not protecting you\" >&2; exit 1; }; CMD=$(echo \"$INPUT\" | jq -r \".tool_input.command // empty\"); [ -z \"$CMD\" ] && exit 0; if echo \"$CMD\" | grep -qE \"\\\\brm\\\\s+([^;|&]*\\\\s)?['\"'\"'\\\"]?(/+\\\\*?|/+[^/ '\"'\"'\\\"]+(/+\\\\*?)?|~(/+\\\\*?)?|~/+[^/ '\"'\"'\\\"]+(/+\\\\*?)?|\\\\$\\\\{?HOME\\\\}?(/+\\\\*?)?|\\\\$\\\\{?HOME\\\\}?/+[^/ '\"'\"'\\\"]+(/+\\\\*?)?|/+(home|Users)/+[^/ '\"'\"'\\\\\\\"]+(/+\\\\*?)?|/+(home|Users)/+[^/ '\"'\"'\\\\\\\"]+/+[^/ '\"'\"'\\\"]+(/+\\\\*?)?)['\"'\"'\\\"]?\\\\s*(['\"'\"'\\\"]|$|\\\\s)\"; then echo \"BLOCKED: rm targeting /, your home, or a directory directly beneath either\" >&2; exit 2; fi; if echo \"$CMD\" | grep -qE \"\\\\brm\\\\s+[^;|&]*(^|[^.])\\\\.\\\\.(/|\\\\s|$)\"; then echo \"BLOCKED: rm with parent-directory traversal\" >&2; exit 2; fi; exit 0'"
10           }
11         ]
12       }
13     ]
14   }
15 }

Before you paste it: this guard needs jq. Run command -v jq first. Without it the hook exits 1 and prints GUARD INACTIVE, and because Claude Code treats every exit code other than 2 as “carry on”, the command still runs. An inactive guard and a working guard look the same from the outside. §5.3 explains why that exit code was chosen.

The rule has one shape, and it is worth stating exactly, because both the over-blocking and the under-blocking live in the details: an rm is refused when its target is /, your home, or anything one level beneath either — quoted or not, ~ or $HOME, long flags or short, with or without a trailing slash. So rm -rf /, rm -rf "/", rm -rf ~, rm -rf ~/Documents, rm -rf ~/Documents/, rm -rf $HOME/projects, rm -rf /home/you, rm -rf /build, rm -rf // and rm --recursive --force / are all refused, and so is a parent-directory traversal like rm -rf ../../anything.

The target may be anywhere in the command, not only first. rm -rf dist ~ is refused on the ~, and rm -rf node_modules /home/you on the absolute home — an earlier edition read only the argument directly after the flags, so both of those went through while the guard watched. Spelling your home as an absolute path counts: /home/you and /Users/you are treated exactly like ~.

Note what that sentence does not say: it does not say recursive. The rule reads the target, not the flags, so rm ~/notes.txt — one file, no -r — is refused too. That is deliberate; a single file directly in your home is usually a dotfile you did not mean to delete. It is also the most common false stop you will meet, so it belongs here rather than in a footnote.

Two levels down, it passes: rm -rf ~/.cache/puppeteer and rm -rf /tmp/mycache are two segments and go through, as do deeper ones like rm -rf ~/projects/app/node_modules and relative paths like rm -rf ./build. That boundary is a deliberate trade and the first edition of this guard got it wrong in both directions — first by missing quoted home paths entirely, then by refusing every path under $HOME, which would have made a developer disable the hook inside a day. A guard you turn off protects nothing, and Chapter 5 is about exactly that. Every case above was verified by piping it through the hook and reading the exit code, in both directions.

The quoted forms are worth calling out, because the previous edition of this guard let all of them through. rm -rf "$HOME" — your home directory, in the most ordinary quoting a shell script would use — returned exit code 0. The pattern expected an unquoted target and nothing told it otherwise. That is the failure mode this book is about, found in this book’s own hook, so it goes in the book rather than into a quiet patch.

The second layer turns ordinary deletes into recoverable ones. cc-safe-setup ships file-recycle-bin.sh, which copies a target into .claude/recycle-bin/ before the rm runs, so a normal delete can be undone with a plain cp. Two limits worth knowing before you count on it: it only fires when the command starts with rm — so cd src && rm old.txt is not saved, which is the compound shape 87% of real commands take — and it only copies regular files, so rm -rf ./olddir saves nothing. It also ships rm-safety-net.sh (blocks recursive deletes against critical paths and traversal, the maintained version of the hook above) and bulk-file-delete-guard.sh (catches a single rm that would remove a large number of files at once). All three are examples, not part of the default install — add each by name with --install-example <name> after running:

1 npx github:yurukusa/cc-safe-setup

The recycle bin is the difference between “I deleted the wrong file” being a thirty-second cp and being a recovery operation.

2.4 If it already ran — the recovery order

1. The recycle bin

If you had file-recycle-bin.sh installed before the delete, your file is sitting in .claude/recycle-bin/. Restore it:

1 cp .claude/recycle-bin/<file> <original-path>

2. Git

If the file was committed, it is in the repository regardless of the delete. Recover the last committed version:

1 git checkout HEAD -- path/to/file

If it was deleted and the deletion was committed, find the commit before the deletion and restore from there:

1 git log --oneline -- path/to/file     # find the last commit that had it
2 git checkout <commit> -- path/to/file

3. The filesystem’s own safety nets

If the project lived on a filesystem with snapshots (APFS local snapshots on macOS, ZFS/Btrfs snapshots, a Time Machine backup, or a synced folder like Dropbox/OneDrive with version history), check there before giving up. These are outside git and outside the agent, so a delete inside the repo does not touch them.

The honest truth

A file that was deleted by rm, was never committed to git, had no recycle-bin hook, and lived on a plain filesystem with no snapshots, is gone. Undelete tools that scan raw disk blocks occasionally work, but only if you stop writing to the disk immediately, and they are unreliable for the small text files most projects are made of. The recycle bin and a committed git history are cheap to set up beforehand and decisive afterward; raw-block recovery is neither.

2.5 The one-paragraph version

Install the block so that a recursive delete naming /, a top-level directory, ~ or $HOME — quoted or not — is refused. Then add the recycle-bin hook so every other delete is a cp away from undone, and commit often, so git is a second net under the first.

One thing the block cannot do, and you should know it before you rely on it. The empty-variable accident — rm -rf "$DIR/build" becoming rm -rf /build when $DIR is unset — happens after the hook has answered. A PreToolUse hook is handed the command as text, before the shell expands anything; at that moment the target is a variable name, not a path. I fired exactly that command at the block to be sure, and it passes. What closes that gap is set -u in your own scripts so an unset variable is an error, the recycle bin in §2.3, and the commit underneath. The block catches the written catastrophic target; the layers underneath catch the computed one.