Claude Code can delete your files. The 3-layer setup I run: sandbox, hooks, git checkpoints
I let Claude Code run real commands against real repos and accounts every day. That's productive right up until one bad command lands. This month alone there were public reports of an agent wiping a user's drive, another
I let Claude Code run real commands against real repos and accounts every day. That's productive right up until one bad command lands. This month alone there were public reports of an agent wiping a user's drive, another deleting tens of thousands of files, and a steady stream of "it deleted my project" threads.
The fix isn't one tool. It's three cheap layers, each covering a different failure:
- Sandbox: the OS refuses writes outside your project.
- Hooks: Claude is stopped before a destructive command runs, or has to ask first.
- Checkpoints: when something gets through anyway, you can roll back in one command.
Here's how I set up each one.
Layer 1: turn on the sandbox
Claude Code ships a sandbox for its Bash tool. Run /sandbox inside a session. On macOS it uses Seatbelt; on Linux and WSL2 it uses bubblewrap. Writes are confined to the project at the OS level, which also covers child processes, so a Python script that calls shutil.rmtree can't escape either.
This is the strongest layer, so enable it first. It doesn't run on native Windows, though. And it can't tell an allowed but terrible write from a good one: git push --force to main, DROP TABLE users, or git reset --hard on two hours of uncommitted work are all within bounds.
That's what layer 2 is for.
Layer 2: a PreToolUse hook
Hooks are commands Claude Code runs at fixed points. A PreToolUse hook gets the pending tool call as JSON on stdin and can answer deny, ask, or nothing (allow). Claude sees your reason and adjusts.
The minimal version fits in one file:
#!/usr/bin/env python3
# .claude/hooks/guard.py
import json, os, re, sys
RULES = [
(r"\brm\s+-\w*r\w*\s+(/|~|\$HOME)(\s|$)", "deny", "recursive delete of / or home"),
(r"\bgit\s+push\b.*\s(--force(?!-with-lease)|-f)\b", "ask", "force-push rewrites remote history"),
(r"\bgit\s+reset\s+--hard\b", "ask", "discards uncommitted work"),
(r"\bdrop\s+(table|database)\b", "ask", "destroys data"),
(r"\brm\b.*\$\w+", "ask", "rm with a variable: if it's empty, the path becomes /"),
]
cmd = json.load(sys.stdin).get("tool_input", {}).get("command", "")
for pattern, decision, reason in RULES:
if re.search(pattern, cmd, re.IGNORECASE):
print(json.dumps({"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": decision,
"permissionDecisionReason": reason,
}}))
break
Wire it up in .claude/settings.json:
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard.py"}]
}]
}
}
Restart, run /hooks, and you'll see it listed. When it fires, Claude gets a message like PreToolUse:Bash hook error: rm targets a path outside the project, stops, and tells you.
Three things I learned the hard way:
-
Regex alone misses relative paths.
cd / && rm -rf usrcontains no/usr. Resolve eachrmtarget against the working directory, following anycdearlier in the same command, and compare it with the project root. -
Watch for empty variables.
rm -rf "$BUILD_DIR/"is fine untilBUILD_DIRis unset. Then it'srm -rf /. Make anyrmwith a variable target ask first. -
Don't block everyday work. If
rm -rf node_modulestriggers a prompt, you'll turn the hook off within a day. Allow deletes inside the project, and savedenyfor catastrophes andaskfor history-destroying git and SQL.
Add deny permission rules so Claude can't read secrets into its context in the first place:
"permissions": { "deny": ["Read(./.env)", "Read(./.env.*)", "Read(~/.ssh/**)", "Read(~/.aws/**)"] }
Layer 3: git checkpoints before every prompt
Sometimes something gets through anyway, so I snapshot the working tree on every prompt with a UserPromptSubmit hook. The trick is to do it without touching your branch, your staging area, or git stash:
tmp_index=$(mktemp)
cp .git/index "$tmp_index" 2>/dev/null
GIT_INDEX_FILE=$tmp_index git add -A # tracked + untracked, respects .gitignore
tree=$(GIT_INDEX_FILE=$tmp_index git write-tree)
commit=$(git commit-tree "$tree" -p HEAD -m checkpoint)
git update-ref "refs/checkpoints/$(date +%Y%m%d-%H%M%S)" "$commit"
rm "$tmp_index"
This creates a hidden commit that's invisible in git log, branches, and stashes. To restore everything, or a single file:
git for-each-ref refs/checkpoints/ # list
git restore --source refs/checkpoints/20261008-142530 -- .
Claude Code's own /rewind covers file edits it made through its edit tools, but not files changed by shell commands. Checkpoints cover both. They live inside .git, so the hook must also block deleting .git and the project root.
What this doesn't solve
Pattern hooks are a seatbelt, not a sandbox. Code written to a file and then executed can get past any regex. That's why layer 1 matters, along with real backups and running unattended agents in a container.
If you don't want to write it yourself
- Destructive Command Guard is a mature Rust hook with far more command patterns than you'll want to maintain.
- I packaged the setup above (bash guard, file-write guard, secret deny rules, checkpoints, a merge-safe installer, and a 36-case self-test that proves each rule without deleting anything) as Safety Rails. It's MIT-licensed, plain Python with no dependencies, and CI-tested on Linux and macOS.
What's the worst thing an agent has done in one of your repos? And which rule would have caught it?
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.