# How to block rm -rf in Cursor with a beforeShellExecution hook

A Cursor beforeShellExecution hook reads each terminal command before it runs and can deny it. A working hooks.json and a 22-line script that blocks recursive deletes outside the project, with what gets past it.

Put this in `.cursor/hooks.json` at the root of the project:

```json
{
  "version": 1,
  "hooks": {
    "beforeShellExecution": [
      { "command": "node .cursor/hooks/block-rm.mjs", "matcher": "rm", "failClosed": true }
    ]
  }
}
```

Cursor runs the script before each terminal command whose text matches `rm`, passes the command as JSON on stdin, and blocks it when the script answers `"permission": "deny"`. The script further down blocks a recursive delete outside the project and lets `rm -rf node_modules` run.

## What Cursor passes the hook

Cursor reads hooks from `.cursor/hooks.json` in the project and `~/.cursor/hooks.json` for your user. The file needs `"version": 1` and a `hooks` object keyed by event ([hooks docs](https://cursor.com/docs/agent/hooks)).

For `beforeShellExecution`:

- The input is `command` (the full terminal command), `cwd`, `sandbox`, and shared fields such as `workspace_roots`.
- The output is `{"permission": "allow" | "deny" | "ask", "user_message": "...", "agent_message": "..."}`. `user_message` is shown to you and `agent_message` goes to the model.
- `matcher` is matched against the full command string. `"rm"` also matches `npm run format`, so the script has to decide, not the matcher.
- `failClosed` is off by default. A hook that crashes, times out or exits with a code other than 0 or 2 lets the command run unless you set it.
- Project hooks run from the project root. User hooks run from `~/.cursor/`.

## The script

Save this as `.cursor/hooks/block-rm.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).command ?? ""); } catch {}
  let verdict = { permission: "allow" };
  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) {
      verdict = {
        permission: "deny",
        user_message: "Blocked a recursive delete outside the project.",
        agent_message: "Recursive delete outside the project is blocked. Stop and ask the human.",
      };
    }
  }
  console.log(JSON.stringify(verdict));
});
```

It always prints JSON, on the allow path too. With `failClosed` on, a hook that prints nothing counts as a failure and blocks the command. Cursor also blocks when the output is not valid JSON for the event.

To be asked instead of refused, change `"deny"` to `"ask"`. Cursor lists `ask` as a valid answer for `beforeShellExecution`. That is not true of every Cursor hook: on `preToolUse` the docs say `ask` is not enforced.

## What it still misses

Six commands, and what the script answered:

```
BLOCKED  rm -rf ~/projects/old
BLOCKED  rm -rf /var/www/html
ran      rm -rf node_modules
ran      rm -rf src
ran      find ~/projects -delete
ran      bash -c 'rm -rf ~/projects/old'
```

Each of the last three got through for a different reason:

- `find ~/projects -delete` has no `rm` in it. With `"matcher": "rm"` the hook does not even start. Drop the matcher and add a pattern for `find ... -delete` if you want it covered.
- `bash -c 'rm -rf ...'` hides the `rm` behind a quote. The pattern looks for `rm` at the start of a command.
- `rm -rf src` is inside the project, so it runs. If `src` is not committed, it is gone. Commit before a long agent run.

A shell hook also only sees shell commands. Anything the agent does through its own file tools does not reach `beforeShellExecution`.

Cursor's docs do not say how hooks behave when auto-run is on. Check it in your version: turn auto-run on, ask the agent to run `rm -rf ~/hook-test-does-not-exist`, and see whether the deny message appears.

## What ACP adds

ACP installs a hook into Cursor and keeps it current. A recursive delete of `/`, your home directory or a system directory is always blocked. A recursive delete of any other literal path outside the working directory, temp directories such as `/tmp` excepted, is blocked until you approve it on the console or from the email, and then you ask the agent to retry. Its check also matches `/bin/rm` and reads the command inside `bash -c '...'`.

It does not catch `find -delete` either, and a delete inside the project is recorded, not held. Every tool call the hook sees goes into one log, next to the calls from any other agent you run on the same machine.



## Frequently asked questions



## Related

- [Cursor hooks reference](/blog/cursor-hooks-reference)
- [Cursor's control model](/controls/cursor)
- [How to block rm -rf in Claude Code](/blog/block-rm-rf-in-claude-code)
- [How to block rm -rf in Claude Code, Codex and Cursor](/blog/stop-your-agent-from-running-rm-rf)
