PricingSign inDownload

Blog / claude code

Stop Claude Code from running rm -rf

A deny rule for rm -rf misses rm -r -f, rm -Rf and find -delete. What we tested in Claude Code 2.1.312, and the hook and sandbox setup that held.

On this page
  1. What a deny rule catches
  2. Add a hook that reads the whole command
  3. Turn on the sandbox for anything outside the project
  4. Git is the undo for everything inside the project
  5. What about Codex?
  6. The short version
  7. Try it with your team

Most people who let Claude Code run shell commands without asking add one line first: deny rm -rf. It feels like enough. We tested it in Claude Code 2.1.312 and it isn't: the same delete written three other ways went straight through.

This post shows what a deny rule catches, what slips past it, and a setup that stopped every recursive delete we tried: a deny rule, a short hook, the sandbox, and git as the real undo.

What a deny rule catches#

We made a scratch project with two folders, build and keep, and this .claude/settings.json:

json
{
  "permissions": {
    "allow": ["Bash"],
    "deny": ["Bash(rm -rf:*)", "Bash(rm -fr:*)"]
  }
}

"allow": ["Bash"] lets Claude run any command without asking, which is how many people work once prompts get tiring. Then we asked Claude to run one delete at a time, each in a fresh session. One thing to know before you test this yourself: Claude Code ignores a project's allow rules until you've accepted the folder's trust prompt once. Deny rules apply either way.

CommandResult
rm -rf buildRefused
/bin/rm -rf keepRefused
rm -r -f keepRan, folder gone
rm -Rf keepRan, folder gone
find . -name b -deleteRan, file gone

Claude Code is smart about the path: /bin/rm is matched as rm. But a deny rule is a prefix match on the command as written. Split the flags, change their case, or use another tool, and it no longer matches. You could add every spelling you can think of, but python3 -c "import shutil; shutil.rmtree('keep')" will always be one more.

Keep the deny rule anyway. It's one line, it catches the most common form, and it shows anyone reading your settings what you meant.

Add a hook that reads the whole command#

Claude Code runs a PreToolUse hook before every tool call and hands it the call as JSON. If the hook exits with code 2, the call is blocked and whatever the hook printed to stderr goes back to Claude as the reason. That gives you a place to look at the whole command with a real pattern instead of a prefix.

Save this as .claude/hooks/block-deletes.sh in your project:

bash
#!/bin/bash
# Reads the tool call as JSON on stdin; exit 2 blocks it and tells Claude why.
cmd=$(jq -r '.tool_input.command // empty')
if printf '%s' "$cmd" | grep -Eq '(^|[;&|[:space:]])(/bin/|/usr/bin/)?rm[[:space:]]+(-[a-zA-Z]*[rR]|--recursive)|find[[:space:]].*-delete|git[[:space:]]+clean[[:space:]]+-[a-zA-Z]*f'; then
  echo "Blocked: recursive deletes need a person. Ask the user to run it." >&2
  exit 2
fi
exit 0

Make it executable with chmod +x .claude/hooks/block-deletes.sh. It needs jq, which recent macOS versions include and most Linux systems can install from their package manager. Then register it next to the permissions:

json
{
  "permissions": {
    "allow": ["Bash"],
    "deny": ["Bash(rm -rf:*)", "Bash(rm -fr:*)"]
  },
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-deletes.sh" }
        ]
      }
    ]
  }
}

We ran the commands again:

CommandResult
rm -r -f keepRefused
rm -Rf keepRefused
find . -name b -deleteRefused
cd keep && rm -r .Refused
git clean -fdxRefused
rm keep/bRan (one file, not recursive)

When the hook blocked a command, Claude stopped and said so, quoting the reason: "The hook says recursive deletes need a person to run them, so if you still want this run, you'll need to execute it yourself." It didn't try another spelling to get around the block. The pattern deliberately lets a plain rm file through: agents delete single files all the time while they work, and blocking those makes the hook a nuisance you'll end up switching off.

A hook is still a pattern match. The shutil.rmtree line above goes past it, and so would a script the agent writes and then runs. That's why the next layer matters.

Turn on the sandbox for anything outside the project#

Claude Code's sandbox runs shell commands with the operating system limiting what they can touch. Writes are allowed inside the project and refused outside it. Add this to the same settings file:

json
"sandbox": {
  "enabled": true,
  "allowUnsandboxedCommands": false
}

allowUnsandboxedCommands: false removes the escape hatch where Claude retries a failing command outside the sandbox. With it on, we asked Claude to run shutil.rmtree on a folder in the home directory. Python got PermissionError: [Errno 1] Operation not permitted and the folder stayed. The same call on a folder inside the project worked, as it should: the sandbox protects everything outside your project, not the project itself.

So the layers split the work. The sandbox makes the worst case (your home folder, other repos, system files) impossible no matter how the command is written. The hook and the deny rule stop the common recursive deletes inside the project.

Git is the undo for everything inside the project#

Claude Code has /rewind, which restores files to an earlier point in the conversation. It's easy to assume that covers a bad delete. It mostly doesn't. The checkpointing docs say it plainly: "A file that only Bash commands changed isn't tracked and stays as it is." A file Claude never edited with its own editing tools and then removed with rm is not coming back through /rewind.

Git is what brings it back, if the work was committed:

bash
git status            # see what's gone
git restore .         # bring back every tracked file that was deleted

Untracked files (new files never added, anything in .gitignore such as .env) are not in git, so nothing restores them. Two habits cover most of it: commit before you hand the agent a long task, and keep the files that only live on your machine (keys, local config) backed up somewhere else, because no agent setting will ever bring those back.

What about Codex?#

Codex takes a different approach out of the box. Its documentation describes the default workspace-write sandbox as limiting writes to the project folder (plus temp folders), with commands that need more asking for approval first. That covers the "outside the project" case the way Claude Code's sandbox does. Inside the project, the same advice holds: commit before long tasks, and don't count on any agent to undo a delete for you.

The short version#

  • A deny rule on rm -rf stops exactly that spelling. Keep it, but don't trust it alone.
  • A PreToolUse hook that reads the whole command catches split flags, -Rf, find -delete and git clean -f.
  • The sandbox, with allowUnsandboxedCommands: false, makes deletes outside the project fail at the operating system level.
  • /rewind doesn't restore files that only shell commands touched. Git does, for what you committed.

If several people's agents work in the same repo, put these settings in the project's .claude/settings.json and commit them, so every teammate's Claude Code starts with the same guard rails. For the other half of the problem, two agents changing the same files, see how to stop agents editing the same files.