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
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/; ...'
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:?}/"
${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/'
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 (0)