# How to block rm -rf in Claude Code

A deny rule blocks rm -rf in Claude Code but also blocks the cleanups you want. A 17-line PreToolUse hook blocks only recursive deletes outside the project. Both configs, with the commands that get past them.

The built-in way is a deny rule in `.claude/settings.json`:

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

That refuses the usual spellings of a recursive `rm`, including `rm -rf node_modules`, which you probably want to allow. It misses `rm -Rf`, `/bin/rm -rf` and `bash -c 'rm -rf ...'`. The hook below is narrower: it blocks a recursive delete only when the target is outside the project.

## Where the deny rule goes

Rules go in `permissions.deny` in `.claude/settings.json` for the repo, or `~/.claude/settings.json` for every project. Deny is checked before ask and allow ([permissions docs](https://code.claude.com/docs/en/permissions)).

What the docs say a Bash rule does for you here:

- A deny rule matches past a leading variable assignment, so `FOO=bar rm -rf tmp/` is still caught.
- Wrappers such as `timeout`, `nice` and `nohup` are stripped before matching.
- Compound commands are split, so `cd build && rm -rf ../data` is checked part by part.

And what they say it does not do. A `Bash(rm *)` rule stops `rm -rf build/`. It does not stop `/bin/rm -rf build/` or `bash -c 'rm -rf build/'`. The docs call a deny rule cover for "the invocation Claude usually produces", not a boundary around the program.

To be asked instead of refused, move the rules to `permissions.ask`.

## A hook that checks the path

This hook lets `rm -rf node_modules` and `rm -rf ./dist` run and blocks a recursive delete whose target starts with `/`, `~`, `..` or a variable. Save it as `~/.claude/hooks/no-rm-outside.mjs`:

```js
#!/usr/bin/env node
let raw = "";
process.stdin.on("data", (d) => (raw += d));
process.stdin.on("end", () => {
  let cmd = "";
  try { cmd = String(JSON.parse(raw).tool_input?.command ?? ""); } catch {}
  for (const m of cmd.matchAll(/(?:^|[;&|(]\s*|\bsudo\s+)rm\s+([^;&|\n]*)/g)) {
    const words = m[1].trim().split(/\s+/);
    const recursive = words.some((w) => /^-[a-zA-Z]*[rR]/.test(w) || w === "--recursive");
    const targets = words.filter((w) => !w.startsWith("-")).map((w) => w.replace(/^["']/, ""));
    const outside = targets.some((t) => /^(\/|~|\$|\.\.)/.test(t) || t.includes("/../"));
    if (recursive && outside) {
      console.error("Blocked: recursive delete outside the project. Stop and ask the human.");
      process.exit(2);
    }
  }
});
```

Register it in `~/.claude/settings.json`:

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "node \"$HOME/.claude/hooks/no-rm-outside.mjs\"", "onFailure": "block" }
        ]
      }
    ]
  }
}
```

The script exits with code 2 to block, and the model reads the stderr line. `onFailure: "block"` makes a crashed or timed-out hook block as well, and needs Claude Code 2.1.295 or later ([hooks docs](https://code.claude.com/docs/en/hooks)).

## Deletes that get past the rule and the hook

Fourteen commands through the script:

```
BLOCKED  rm -rf ~/projects/old
BLOCKED  rm -rf /var/www/html
BLOCKED  cd build && rm -rf ../data
BLOCKED  rm -rf $BUILD_DIR
BLOCKED  sudo rm -r /opt/app
ran      rm -rf node_modules
ran      rm -rf ./dist
ran      rm notes.txt
ran      find ~/projects -name '*.log' -delete
ran      python3 -c "import shutil; shutil.rmtree('/srv/data')"
ran      bash -c 'rm -rf ~/projects/old'
ran      /bin/rm -rf ~/projects/old
ran      git clean -fdx
ran      rm -rf src
```

Five of the `ran` lines are deletes you would have wanted to see first. `find -delete` and `shutil.rmtree` never use `rm`. `bash -c` and `/bin/rm` hide it from a pattern that looks for `rm` at the start of a command. `git clean -fdx` deletes every untracked file with no `rm` in sight.

The last line is a different problem. `rm -rf src` is inside the project, so the hook lets it through. If the work in `src` is not committed, it is gone. No path check fixes that. Commit before you start a run. If a delete outside the project has to be impossible, use the sandbox.

## What ACP does with a delete

ACP's hook treats a recursive delete at two levels:

- **Always blocked:** a recursive delete of `/`, your home directory, or a system directory such as `/etc` or `/usr`. No approval reaches these.
- **Held for your OK:** a recursive delete of any other literal path outside the working directory, except temp directories such as `/tmp`. You get a prompt in the session and can say yes.

Its check also matches `/bin/rm` and reads the command inside `bash -c '...'`, the two forms the hook above missed. It does not catch `find -delete` or a Python `shutil.rmtree` either. A delete inside the project and a variable path such as `rm -rf $BUILD_DIR` are recorded in the log, not held. An `rm` rule of your own holds those.



## Frequently asked questions



## Related

- [How to block rm -rf in Claude Code, Codex and Cursor](/blog/stop-your-agent-from-running-rm-rf)
- [How to block rm -rf in Cursor with a beforeShellExecution hook](/blog/cursor-block-rm-rf-beforeshellexecution)
- [How to stop Codex from deleting your files](/blog/stop-codex-from-deleting-files)
- [Claude Code hooks reference](/blog/claude-code-hooks-reference)
