DEV Community

Myeongseok Kang
Myeongseok Kang

Posted on

An empty variable turned rm -rf and rsync --delete into "delete /". Coding agents did it three times this fall

Three reports on the Claude Code issue tracker this autumn describe the same accident from different angles. In each one an agent built a destructive command around a value it never printed, the value came out empty or pointed at a drive root, and the command did exactly what it said.

Three incidents

#95426, Windows with Git Bash. As a "clear old test state" step the agent ran:

rm -rf "$(cygpath -u 'C:\')" 2>/dev/null
Enter fullscreen mode Exit fullscreen mode

cygpath -u 'C:\' prints /c/, the whole drive. stderr went to /dev/null, so nothing appeared on screen while it ran for about 35 minutes. The reporter lost roughly 600 GB, including the Windows user profile and several git repositories.

#109735, Windows with WSL, opened this week. A subagent wanted a Linux copy of a project and ran:

wsl -d Ubuntu-24.04 -- bash -lc 'set -u; R=~/some-dir; mkdir -p $R; rsync -a --delete /mnt/d/<project>/ $R/; ...'
Enter fullscreen mode Exit fullscreen mode

The assignment is inside the single-quoted script, which looks safe, and set -u is there too. But the command line went through wsl.exe -- and was interpreted by a shell once more before the inner bash started, and in that shell R was not set. $R/ became /, the command that ran was rsync -a --delete /mnt/d/<project>/ /, and rsync deleted what the user could delete under /, including files on the Windows data drive mounted at /mnt/d. Files deleted through WSL skip the Recycle Bin.

#107274, macOS: an rm -rf in the harness removed a repository's .git, and with it more than twelve months of history that had not been pushed anywhere.

Why the usual advice did not save them

Quoting does not help when the variable is empty: rm -rf "$D"/ is still rm -rf /. set -u only fires in the shell that sees the unset variable, and in #109735 that was not the shell that expanded it. --preserve-root protects / itself but not /*, /c/ or /mnt/d/.

What does help is making the empty case fail before the command runs:

rm -rf "${D:?}/"           # exits with "D: parameter null or not set"
rsync -a --delete src/ "${R:?}/"
Enter fullscreen mode Exit fullscreen mode

${D:?} makes the shell stop instead of substituting an empty string, in every shell layer it is expanded in. Printing the target first (echo "$D"), or resolving it into a variable and checking it is under the project, catches the cygpath case, where the value is not empty but wrong. For .git, the safe default is to move it aside (mv .git .git.bak) rather than delete it.

Agents rarely do any of this unprompted, and a memory file that says "never rm -rf a variable" is a suggestion, not a check.

A hook that checks the command text first

I maintain claude-bulkhead, a set of PreToolUse hooks for Claude Code (MIT, no dependencies; the same guards also load in Codex, Gemini CLI, Copilot CLI, Cursor and opencode). Its basics guard already refused rm -rf ~ and rm -rf /. Version 0.4.0 adds rules for the three patterns above:

rm-empty-var blocks a recursive delete whose path is a variable or a command substitution followed by / (rm -rf $D/, rm -rf "$BUILD_DIR"/*, rm -rf $(cygpath -u "$X")/), a path that is only a quoted substitution (rm -rf "$(cygpath -u 'C:\')", the exact #95426 command), and rsync --delete whose destination is $R/ or a bare $R. It lets the command through when the same shell set the variable earlier to something that cannot be empty, such as D=$(mktemp -d) && rm -rf $D/, or when the command already uses ${D:?}. An assignment inside wsl ... -- bash -lc '...' does not count, because the outer shell expands the variable first, which is the #109735 trap; the exact command from that report is blocked.

rm-git-dir blocks rm -rf .git, rm -rf path/.git and find . -name .git -exec rm -rf {} + when that .git is an existing folder, and points to git worktree remove or moving it aside instead.

rm-unquoted-var blocks an unquoted rm -rf $D when the value set on the same line contains a space or a wildcard, since bash splits it into several paths. zsh does not split it, which is part of what went wrong in #107271.

The same release also covers #109627, from this week: on macOS an agent ran pkill -f "gcloud.py compute" -u <user> --signal 0 as an existence check. BSD pkill has no --signal option, so --signal and 0 became two more patterns, 0 matched almost every process, and every other Claude Code session got SIGTERM. kill-guard now reads pkill arguments the way the platform's pkill does and blocks long options and extra patterns on macOS, pointing to pgrep -f for a check that only lists.

Each block tells the agent why and gives the safe form, usually "${VAR:?}". The command is not run, so you can check any line without installing anything:

npx -y claude-bulkhead explain -- 'rsync -a --delete src/ $R/'
Enter fullscreen mode Exit fullscreen mode

The hooks read the command text. A delete inside a script the agent wrote and then runs is not seen, which is also why #107271, an unquoted rm -rf $D inside a helper script, is out of reach. That one needs the script itself to use "${D:?}".

Top comments (5)

Collapse
 
chao_yan_0eca708448360655 profile image
Henry Yan •

The three incidents share one shape: the destructive path was computed, not literal, and the usual quoting / set -u / --preserve-root advice failed in the shell that actually expanded the value. ${D:?} is the right fix in the command itself.

I would not treat a hook that only reads the command text as the whole gate, and the article already flags why: an agent can write the same rm into a script and then run the script, and the hook never sees the line. In a coding-agent loop I'd want that path covered too: either the script is itself checked before it runs, or a newly written file isn't executable in the same turn that created it without going through the same policy. A memory file that says "don't rm -rf a variable" is, as you say, a suggestion.

Collapse
 
piekwerk profile image
Piekwerk •

The #109735 trap is not WSL specific. Every extra shell layer re-expands the string, ssh and docker exec have the same shape. What I settled on is piping the script over stdin instead of nesting quoted strings, so the inner command is just bash -s reading a quoted heredoc. No middle layer expands anything, so set -u fires in the shell that actually runs the code. For rsync --delete I have the agent do one -n pass first and read the deletion list, that check survives any number of shell layers. After #95426 I also stopped accepting 2>/dev/null on destructive commands, that 35 minute silence was the last chance to notice.

Collapse
 
namkyu profile image
NamKyu Eum •

The -n pass is the one I trust too. My agent once reported "deleted 2 files" and 3 were gone. It had counted with git ls-files and skipped an untracked one. When yours reads the list back, do you see the raw lines or its summary?

Collapse
 
piekwerk profile image
Piekwerk •

Raw lines. I make it paste the deleting lines from the -n output verbatim, run with -v so every deletion gets its own line, and the count I trust is the one I do myself on that paste. Its summary number does not get a vote. Your ls-files miss is exactly the case this catches, because rsync scans the live tree itself, so the untracked file shows up in the raw lines even though no git index knows it exists. The gap left is that I read a paste, not the stream, so a truncated quote would still slip past me. That has not happened yet, but I would not call the setup airtight.

Collapse
 
fgrandon profile image
Francisco Grandón •

The WSL example (#109735) where an outer shell expands $R/ before the inner bash -lc even sees set -u is a brutal failure mode, since --preserve-root ignores /c/ and /mnt/d/. Moving that check into a PreToolUse hook with rm-empty-var beats relying on memory-file prompts. When an agent writes a helper script to bypass the direct command text, do you pair the hook with a filesystem sandbox or inspect newly written scripts too?