DEV Community

Owen
Owen

Posted on • Originally published at ofox.ai

Claude Code Safety Guide: Prevent Accidental File Deletion with Hooks, Permissions & Git Worktrees

Claude Code Safety Guide: Prevent Accidental File Deletion with Hooks, Permissions & Git Worktrees

Why This Guide Exists

This guide documents Claude Code's documented track record of deleting files unintentionally. The incidents represent recurring failure modes when an agent has shell access.

Notable incidents include:

  • October 21, 2025: Mike Wolak's home directory was wiped when Claude Code generated a destructive command with shell tilde expansion
  • February 26, 2026: Claude Code executed rm -rf against a Flutter project directory without authorization
  • April 24, 2026: A Cursor agent deleted an entire production database and backups in nine seconds
  • Multiple GitHub issues documenting file destruction during routine operations

Anthropic released sandboxing on October 20, 2025, but it remained opt-in. Every layer in this guide requires explicit configuration—the defaults provide insufficient protection.

Layer 1: Permission Deny Rules in settings.json

Permission deny rules are evaluated first and override allow rules. They cannot be loosened by command-line flags or prompts.

Recommended baseline for .claude/settings.json:

{
  "permissions": {
    "deny": [
      "Bash(rm:*)",
      "Bash(sudo:*)",
      "Bash(chmod 777:*)",
      "Bash(git push --force:*)",
      "Bash(git push -f:*)",
      "Bash(git reset --hard:*)",
      "Bash(git clean:*)",
      "Bash(dd:*)",
      "Bash(mkfs:*)",
      "Bash(* > /dev/sda*)",
      "Read(~/.ssh/**)",
      "Read(**/.env)",
      "Edit(**/.env)",
      "Edit(.git/**)"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Pattern Matching Details

Word-boundary semantics: Bash(rm:*) requires rm followed by a space or end-of-string, matching rm -rf . but not rmdir. The form Bash(rm*) without the boundary would match rmdir and similar commands.

Process wrappers get stripped: Claude Code strips timeout, time, nice, nohup, stdbuf, and bare xargs before matching rules. Environment runners like devbox run and docker exec are not stripped.

Limitations

Pattern-based blocking cannot reliably catch:

  • Variables: DIR=~ && rm -rf $DIR
  • Subshells: $(echo rm) -rf .
  • Compound chains where rm is not the first command
  • Custom scripts calling rm internally

Layer 2: A PreToolUse Hook That Inspects Every Command

A PreToolUse hook runs deterministic shell code on the full command string before execution. The model cannot override a blocking hook.

Create .claude/hooks/block-destructive.sh:

#!/bin/bash
# Read the full Bash invocation from stdin
CMD=$(jq -r '.tool_input.command')

# Patterns that should never run unattended
DANGEROUS='(^|[;&|`$(]| )(rm[[:space:]]+-[a-z]*[rRfF]|sudo[[:space:]]|chmod[[:space:]]+777|find[[:space:]].+-delete|find[[:space:]].+-exec[[:space:]]+rm)'

if echo "$CMD" | grep -Eq "$DANGEROUS"; then
  jq -n --arg cmd "$CMD" '{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: ("Blocked by safety hook: " + $cmd)
    }
  }'
  exit 0
fi

exit 0
Enter fullscreen mode Exit fullscreen mode

Make it executable: chmod +x .claude/hooks/block-destructive.sh

Wire it into .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-destructive.sh"
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Why Hooks Catch What Deny Rules Miss

  • Hooks see the literal command string, including subshells, pipes, and full find invocations
  • Exit code 0 with deny JSON returns control to Claude with the reason attached
  • Hooks fire regardless of permission mode, even in bypassPermissions mode

Layer 3: Git Worktrees So Mistakes Are Recoverable

A git worktree gives the agent its own checkout on its own branch, so destructive runs affect only the worktree, not your main work.

Manual setup:

# From your main checkout on the feature branch
git worktree add ../myproject-agent agent/refactor-auth
cd ../myproject-agent
claude
Enter fullscreen mode Exit fullscreen mode

If the agent deletes the entire working tree, your main copy remains intact. Clean up when done:

cd ../myproject
git worktree remove ../myproject-agent
git branch -D agent/refactor-auth  # optional
Enter fullscreen mode Exit fullscreen mode

For subagents, declare worktree isolation in the agent definition (e.g., .claude/agents/refactorer.md):

---
name: refactorer
description: "Performs large refactors in an isolated worktree"
tools: Read, Edit, Write, Bash
isolation: worktree
---

You are a refactoring specialist. Make incremental changes...
Enter fullscreen mode Exit fullscreen mode

Layer 4: Replace rm With trash

Aliasing rm to a recoverable deletion tool turns permanent loss into recovery from trash.

On macOS:

brew install trash
Enter fullscreen mode Exit fullscreen mode

Then in .claude/hooks/coerce-rm.sh:

#!/bin/bash
CMD=$(jq -r '.tool_input.command')

# If the command uses bare rm (not /bin/rm, not safe-rm), rewrite to trash
if echo "$CMD" | grep -Eq '(^|[;&|`$(]| )rm[[:space:]]+'; then
  NEW=$(echo "$CMD" | sed -E 's/(^|[;&|`$(]| )rm[[:space:]]+/\1trash /g')
  jq -n --arg cmd "$NEW" '{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "ask",
      permissionDecisionReason: ("Rewriting rm to trash. Approve to run: " + $cmd)
    }
  }'
  exit 0
fi
exit 0
Enter fullscreen mode Exit fullscreen mode

This hook returns permissionDecision: "ask" so users approve the rewritten command. Chain it after the block-destructive hook by registering both in the PreToolUse array.

Note: Putting alias rm='trash' in ~/.bashrc does not work for Claude Code, since the Bash tool spawns non-interactive shells. The hook approach is reliable.

Layer 5: Turn On the Sandbox

Anthropic's sandbox provides OS-level enforcement that prevents model confusion from bypassing protections. It restricts Bash and child processes to a defined filesystem and network boundary.

Enable it in .claude/settings.json:

{
  "sandbox": {
    "enabled": true,
    "filesystem": {
      "allowRead": ["."],
      "denyRead": ["~/.ssh", "~/.aws", "**/.env"],
      "allowWrite": ["~/.npm", "~/.cache"]
    },
    "network": {
      "allowedDomains": ["registry.npmjs.org", "api.github.com"]
    },
    "autoAllowBashIfSandboxed": true
  }
}
Enter fullscreen mode Exit fullscreen mode

With autoAllowBashIfSandboxed: true, sandboxed Bash runs without permission prompts because the OS boundary substitutes for per-command approval. Explicit deny rules still apply, and rm or rmdir against /, home directory, or critical system paths still triggers a prompt as a circuit breaker.

The sandbox survives prompt injection—if a model gets convinced by hidden text to wipe your home directory, the sandbox hard-blocks the syscall.

Bonus: Disable bypassPermissions in Managed Settings

For team administration, lock out bypassPermissions at the managed-settings level in /etc/claude-code/managed-settings.json:

{
  "permissions": {
    "disableBypassPermissionsMode": "disable",
    "disableAutoMode": "disable"
  },
  "allowManagedHooksOnly": true,
  "allowManagedPermissionRulesOnly": true
}
Enter fullscreen mode Exit fullscreen mode

allowManagedHooksOnly ensures only your security team's hooks are loaded—developers cannot turn off the block-destructive hook by editing .claude/settings.json.

The Recommended Setup

Layer everything. None is sufficient alone, and the cost of stacking them is one settings file and one shell script.

Layer What It Catches What It Misses
Deny rules Direct rm, sudo, force-push Compound commands, env runners, scripted deletions
PreToolUse hook Anything you can regex against Non-shell deletion (Edit tool overwriting a file)
Edit deny rules Writes to .env, .git, secrets Symlinks pointing out of allowed dirs
Worktrees Recoverable file destruction Damage to repos outside the worktree
trash hook Permanent file loss Files outside trash-aware paths
Sandbox OS-level filesystem and network boundary Anything inside allowed paths

Run it on a low-stakes project for a week and observe how often the hook fires. Most teams discover agents performed far more deletion than expected—they got lucky on the targets.

Every defense is opt-in, every default is loose, and every Claude Code horror story starts with someone trusting the model to remember a rule it was never enforced to follow.

Where to Go Next

For more on Claude Code's extensibility surface—hooks, subagents, and skills—read the complete guide to hooks, subagents, and skills. For provider setup and getting Claude Code talking to a custom endpoint, see the Claude Code configuration guide. For the underlying model and changes between Opus versions, see the Claude Opus API review. For comparisons between Claude Code, Codex CLI, Cursor, and DeepSeek TUI, the AI coding agents comparison covers the model layer underneath all of them.

If running Claude Code against a custom Anthropic-compatible endpoint, ofox.ai supports the full Anthropic protocol at https://api.ofox.ai/anthropic—including extended thinking and cache_control. The agent does not know the difference; your wallet might.


Originally published on ofox.ai/blog.

Top comments (0)