Skip to content
Agentic Control Plane

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

David Crowe David Crowe · · 8 min read
cursor hooks permissions defense-in-depth
Share X HN LinkedIn

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

{
  "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).

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:

#!/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

How do I block a shell command in Cursor?

Add a beforeShellExecution entry to .cursor/hooks.json or ~/.cursor/hooks.json. The hook gets the full command as JSON on stdin and answers with permission allow, deny or ask. Exit code 2 is the same as deny.

What does a Cursor beforeShellExecution hook receive?

A JSON object with command (the full terminal command), cwd and sandbox, plus shared fields such as conversation_id, workspace_roots and hook_event_name.

What happens if my Cursor hook crashes?

By default the command runs. Cursor’s docs say crashes, timeouts and non-zero exit codes other than 2 fail open. Set failClosed to true on the hook to block instead. Invalid JSON output blocks the action either way.

Where do Cursor hook paths resolve from?

Project hooks run from the project root, so write .cursor/hooks/block-rm.mjs. User hooks run from ~/.cursor/.

Share X HN LinkedIn
Get the next data drop
What agents actually cost, new tool-surface captures, and the occasional incident post-mortem — sent when we publish something worth your inbox, not on a schedule. Unsubscribe anytime.
Share: Twitter LinkedIn
Related posts

← back to blog