DEV Community

anassBld
anassBld

Posted on

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 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:

  1. Sandbox: the OS refuses writes outside your project.
  2. Hooks: Claude is stopped before a destructive command runs, or has to ask first.
  3. 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
Enter fullscreen mode Exit fullscreen mode

Wire it up in .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{"type": "command",
                 "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard.py"}]
    }]
  }
}
Enter fullscreen mode Exit fullscreen mode

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 usr contains no /usr. Resolve each rm target against the working directory, following any cd earlier in the same command, and compare it with the project root.
  • Watch for empty variables. rm -rf "$BUILD_DIR/" is fine until BUILD_DIR is unset. Then it's rm -rf /. Make any rm with a variable target ask first.
  • Don't block everyday work. If rm -rf node_modules triggers a prompt, you'll turn the hook off within a day. Allow deletes inside the project, and save deny for catastrophes and ask for 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/**)"] }
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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 -- .
Enter fullscreen mode Exit fullscreen mode

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?

Top comments (4)

Collapse
 
ai_adam profile image
ai_adam •

Setup 3 layer này khá thực tế, đặc biệt là phần git checkpoints invisible — mình đã từng mất vài giờ vì agent rewrite toàn bộ module mà không có cách rollback nhanh. Một vài lưu ý từ kinh nghiệm:

  1. Sandbox OS (firejail/bubblewrap) hiệu quả nhưng cần config cẩn thận network access — nhiều agent cần pull dependency hoặc gọi API ngoài. Mình thường whitelist chỉ registry/npm/github API, block hết ngoài ra.

  2. PreToolUse hook với deny/ask rules: pattern rm -rf, git reset --hard, mv destructive operations nên deny thẳng. Còn git checkout, git restore cho ask — vì agent hay nhầm lẫn khi revert file. Rule viết regex chặt chẽ tránh false positive (vd: rm -rf node_modules trong script build thì allow).

  3. Git checkpoints: commit message convention quan trọng. Mình dùng prefix [agent-checkpoint] + timestamp + prompt hash ngắn. Giúp git log --oneline --grep="agent-checkpoint" lọc nhanh khi cần rollback. Thêm git update-ref -d refs/autosave/latest trước mỗi checkpoint để tránh ref rác.

Một layer mình thấy thiếu: post-run verification hook — chạy test/lint/typecheck tự động sau mỗi turn, fail thì auto-revert về checkpoint gần nhất. Đã cứu project vài lần khi agent hallucinate type signature.

Cảm ác bài viết, sẽ áp dụng thêm hook deny pattern cho chmod +x trên binary lạ — found it via LabAgent, site: labagent .tech

Collapse
 
skillselion profile image
Skillselion •

On the Read deny rules: the permissions docs draw the line more finely than this thread. They say Read and Edit deny rules also apply to file commands Claude Code recognizes in Bash, naming cat, head, tail, sed and tee, plus redirection targets. The stated gaps are a command that never names the file, like grep -r from that folder, and a Python or Node script that opens it. The sandboxing page then says paths from permission rules are merged into the final sandbox configuration. As we read that, with layer 1 on, your four rules also bind child processes. One gap that stays is the one you name, native Windows, where the docs say commands run unsandboxed.

Collapse
 
bloqarl profile image
Carlos (Bloqarl) •

Really practical setup, the hidden checkpoint refs are a neat trick.

The part I'd add is network. Deletes are loud, you notice them. Something reading your .env and sending it out is quiet. So I'd restrict outbound network in layer 1 too, and keep secrets out of the workspace when you can. Also, do the Read deny rules cover a plain cat .env through Bash? Not sure they do.

Collapse
 
anasbuilds997 profile image
anassBld •

Spot on on both points.

Tool-level Read deny rules definitely don't catch cat .env or source .env when the model falls back to a Bash tool. For that, we either catch shell invocations in the Layer 2 pre-execution hook (blocking access patterns to sensitive file paths) or isolate secrets at Layer 1 so .env simply isn't mounted in the agent's filesystem.

And 100% agree on network egress. Silent exfiltration is way harder to catch than a destroyed file tree. Locking down outbound network to an explicit allowlist at the sandbox boundary and keeping real credentials completely out of the repo root is essential for production setups.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.