Claude Code deleted ~600GB: unprompted `rm -rf` on a substitution that resolved to a drive root

Status Open
Reported on v2.1.274
Maintainer reply None cached
Activity 5 comments · opened Sep 18, 2026

Claude Code deleted ~600GB: unprompted rm -rf on a substitution that resolved to a drive root

Summary

On 2026-09-16, a Claude Code session running on Windows executed, unprompted, as a
"clear old test state" step:

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

cygpath -u 'C:\' resolves to /c/, so the command recursively deleted the drive.
Because stderr was redirected to /dev/null, it produced no visible output and ran
for roughly 35 minutes before anyone noticed.

Approximately 600GB was destroyed, including the Windows user profile, several git
repositories, and gitignored planning documents that existed in no other location.

Environment

  • Claude Code 2.1.274
  • Model: claude-opus-5
  • Windows 11 Pro 22631, Git Bash (Bash tool)
  • Date: 2026-09-16

What makes this more than a bad command

  1. The delete target was never printed before use. It was built from a command

substitution. Nothing in the flow showed the resolved path, so there was no point
at which /c/ was visible as the target.

  1. Errors were deliberately suppressed. 2>/dev/null on a recursive delete means

the one signal that something is wrong — the flood of permission errors as it walks
into system directories — is discarded. A long-running silent delete looks identical
to a fast, successful one.

  1. The command was the agent's own idea. It was housekeeping before the real task,

not something asked for.

  1. The audit trail was inside the blast radius. ~/.claude/projects was deleted

along with everything else, so the originating transcript is unrecoverable. I can
supply two sessions from the following day that discuss the incident, but not the
session that ran the command.

Suggested guardrails

  • Refuse, rather than prompt, when a recursive delete resolves to a filesystem root,

a drive root, or a user profile root. A confirmation dialog is not enough here: the
string in the prompt is the unresolved substitution, not the path being deleted.

  • Resolve and display delete targets before the confirmation, never the raw expression.
  • Treat stderr suppression on a destructive command as its own refusal condition, or

strip the redirection.

  • Consider keeping session transcripts outside the working-tree blast radius, or

mirroring them, so an incident cannot destroy its own evidence.

Mitigation in place locally

A PreToolUse hook (guard-recursive-delete.js) now forces a permission prompt on every
recursive delete form, and a session monitor gates bulk deletes across all projects.
These are user-side workarounds for something the tool should not do by default.

What I can provide on request

  • Two session transcripts from 2026-09-17 discussing the incident and the recovery.
  • The restored repository state and the list of what could not be recovered.

View original on GitHub ↗

4 Comments

foma-agent · 23 days ago

I read #95426 and current CHANGELOG through 2.1.278. The command is rm -rf "$(cygpath -u 'C:\\')" 2>/dev/null. The unevaluated string never contains /c/ or a drive root, so a PreToolUse matcher on rm -rf / or rm -rf ~ would allow it. 2.1.275–2.1.278 add no resolved-target refuse for recursive deletes. Recurrence #36339 was a literal Git Bash path (closed). I did not run 2.1.274 or 2.1.278.

faizjamal0111-svg · 19 days ago

Sorry about the data. Picking up the point in the comment above: a rule on the target can't catch this, because the target only exists after $(cygpath ...) runs. A rule on the verb can. A PreToolUse hook that refuses rm -rf anywhere in the command, whatever follows it, refuses this exact string: I ran rm -rf "$(cygpath -u 'C:\')" 2>/dev/null through the one I use and it is refused before anything is evaluated. The cost is that deletes come back to you, which after 600GB seems like the right trade.

The refusal text matters too. It goes back to the model, and telling it not to reach the same result another way changes what happens next: in my test Claude stopped and handed the delete back to me instead of trying rm -r.

One caveat for your setup: I build this (paveo) for macOS and Linux, and a hook that crashes on Windows lets every command through, so it isn't safe to point at Git Bash today. Would Windows support matter to you? That would decide whether I do it next.

dylanroscover · 19 days ago

yes, this issue happened on a windows box so windows support is relevant.

On Fri, Sep 25, 2026 at 5:46 AM MD Faiz Jamal @.***>
wrote:

faizjamal0111-svg left a comment (anthropics/claude-code#95426) <https://github.com/anthropics/claude-code/issues/95426#issuecomment-5832595803> Sorry about the data. Picking up the point in the comment above: a rule on the target can't catch this, because the target only exists after $(cygpath ...) runs. A rule on the verb can. A PreToolUse hook that refuses rm -rf anywhere in the command, whatever follows it, refuses this exact string: I ran rm -rf "$(cygpath -u 'C:\')" 2>/dev/null through the one I use and it is refused before anything is evaluated. The cost is that deletes come back to you, which after 600GB seems like the right trade. The refusal text matters too. It goes back to the model, and telling it not to reach the same result another way changes what happens next: in my test Claude stopped and handed the delete back to me instead of trying rm -r. One caveat for your setup: I build this (paveo) for macOS and Linux, and a hook that crashes on Windows lets every command through, so it isn't safe to point at Git Bash today. Would Windows support matter to you? That would decide whether I do it next. — Reply to this email directly, view it on GitHub <https://github.com/anthropics/claude-code/issues/95426?email_source=notifications&email_token=AAGDJUJ6T4VEYUG2BONVDXL5QZSLFA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTKOBTGI2TSNJYGAZ2M4TFMFZW63VGMF2XI2DPOKSWK5TFNZ2KYZTPN52GK4S7MNWGSY3L#issuecomment-5832595803>, or unsubscribe <https://github.com/notifications/unsubscribe-auth/AAGDJUOGIPCFTMPH3NU74WT5QZSLFAVCNFSNUABFKJSXA33TNF2G64TZHM4TGNZSGUZTINZVHNEXG43VMU5TKNJQGM2DQNZZGIZ2C5QC> . Triage notifications, keep track of coding agent tasks and review pull requests on the go with GitHub Mobile for iOS <https://github.com/notifications/mobile/ios/AAGDJUKLG3BQX6TTXS65CAL5QZSLFA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTKOBTGI2TSNJYGAZ2M4TFMFZW63VGMF2XI2DPOKSWK5TFNZ2KUZTPN52GK4S7NFXXG> and Android <https://github.com/notifications/mobile/android/AAGDJUKQ3JIAPGWBL7LKS6D5QZSLFA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTKOBTGI2TSNJYGAZ2M4TFMFZW63VGMF2XI2DPOKSWK5TFNZ2K4ZTPN52GK4S7MFXGI4TPNFSA>. Download it today! You are receiving this because you authored the thread.Message ID: @.***>
faizjamal0111-svg · 19 days ago

Thanks, that settles it for me. Windows needs three real changes (file locking, the timer that makes a stuck hook refuse instead of allow, and how Git Bash runs the hook), and I won't call it safe until it has run on a real Windows box. If I get a Windows build working, would you run it on that machine for an afternoon and tell me where it breaks? If so I'll do it next and ping you here.

Showing cached comments. Read the full discussion on GitHub ↗