How to block rm -rf in Cursor with a beforeShellExecution hook
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 asworkspace_roots. - The output is
{"permission": "allow" | "deny" | "ask", "user_message": "...", "agent_message": "..."}.user_messageis shown to you andagent_messagegoes to the model. matcheris matched against the full command string."rm"also matchesnpm run format, so the script has to decide, not the matcher.failClosedis 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 -deletehas normin it. With"matcher": "rm"the hook does not even start. Drop the matcher and add a pattern forfind ... -deleteif you want it covered.bash -c 'rm -rf ...'hides thermbehind a quote. The pattern looks forrmat the start of a command.rm -rf srcis inside the project, so it runs. Ifsrcis 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/.
Related
- Cursor hooks reference
- Cursor’s control model
- How to block rm -rf in Claude Code
- How to block rm -rf in Claude Code, Codex and Cursor