Block rm -rf with a Claude Code PreToolUse hook, and know its limits

Claude Code Published:

Two ways to deny a dangerous rm before it runs: exit code 2 and permissionDecision. Why a hook deny works even in bypassPermissions, and where string matching stops being enough.

Verified on Sep 22, 2026 These tools change quickly. Please also check the latest official documentation.
Contents
  1. Blocking with exit code 2
  2. Blocking with permissionDecision
  3. Cover file edits too
  4. Where string matching breaks
  5. Checking it works
  6. Summary

Some commands you never want running unreviewed, and rm -rf is the classic one. A PreToolUse hook lets a script inspect the call and refuse it before the tool runs.

What makes this hook worth using is its position in the chain: the documentation states that PreToolUse hooks fire before any permission-mode check, in every permission mode including dontAsk, and that a hook returning permissionDecision: "deny" blocks the tool even in bypassPermissions mode or with --dangerously-skip-permissions.

KEY POINT

What you will learn

  • Two ways to block: exit code 2 and permissionDecision
  • Why changing permission mode doesn't get around it, and what hooks still can't do
  • Where string matching breaks down, and what to pair it with

Blocking with exit code 2

The shortest form. Write a message to stderr and exit 2: the tool call is denied and that stderr is fed back to Claude.

#!/usr/bin/env bash
# .claude/hooks/block-rm-rf.sh
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

if echo "$CMD" | grep -Eq '(^|[;&|[:space:]])rm[[:space:]]+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)'; then
  echo "Blocked: recursive force-remove is not allowed here. Name the paths individually." >&2
  exit 2
fi

exit 0

On macOS and Linux the script has to be executable:

chmod +x .claude/hooks/block-rm-rf.sh

Register it in .claude/settings.json. As in the documentation's example, you can run a logging hook alongside the guardrail:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r .tool_input.command >> ~/.claude/bash.log"
          },
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rm-rf.sh"
          }
        ]
      }
    ]
  }
}

The documentation walks through what happens: both hooks execute in parallel, the logging hook exits 0 and reports no decision, and the guardrail exits 2 to deny the call. The deny takes precedence, so the command is blocked — and the log entry is still written, because the logging hook already ran.

Blocking with permissionDecision

Instead of an exit code, print JSON to stdout. This lets you pass a structured reason.

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Recursive deletes are blocked here. Run git clean -n first, then remove paths individually."
  }
}

PreToolUse accepts three values:

ValueEffect
allowSkip the interactive permission prompt. Deny and ask rules still apply
denyCancel the tool call and send the reason to Claude
askShow the permission prompt as normal

With deny, permissionDecisionReason is fed back to Claude, so writing why and what to do instead keeps the work moving rather than just stopping it.

allow cannot override a deny rule

The reverse does not hold. A hook returning allow does not bypass deny rules from settings, and it cannot suppress prompts for MCP tools marked requiresUserInteraction or for connector tools your organization set to ask. As the documentation puts it: hooks can tighten restrictions but not loosen them past what permission rules allow.

Cover file edits too

rm arrives through the Bash tool, but an Edit or Write that clobbers an important file is the same class of accident. The documentation shows a path-based guard:

#!/bin/bash
# protect-files.sh

INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

# Normalize Windows backslash separators so the patterns below match
FILE_PATH="${FILE_PATH//\\//}"

PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")

for pattern in "${PROTECTED_PATTERNS[@]}"; do
  if [[ "$FILE_PATH" == *"$pattern"* ]]; then
    echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
    exit 2
  fi
done

exit 0

Register that one with a matcher of Edit|Write. For the shape of the JSON these scripts read, see The JSON your Claude Code hooks receive on stdin; for matcher values, see Every value you can put in a Claude Code hook matcher.

Where string matching breaks

This is the part to be honest about. Matching the command text is easy to slip past.

Written asMatched by a naive rm -rf check
rm -rf buildYes
rm -r -f buildNo
rm --recursive --force buildNo
FLAGS="-rf"; rm $FLAGS buildNo
find . -deleteNot an rm at all

The script above handles -rf and -fr orderings, and that is still not exhaustive. A hook is an effective guardrail against mistakes; it is not a mechanism for stopping deliberate evasion.

For anything you need genuinely enforced, layer these underneath:

  • Permission deny rules: the documentation states that hook decisions don't bypass permission rules — Claude Code evaluates deny and ask rules regardless of what a PreToolUse hook returns. See Claude Code permissions in settings.json
  • The sandbox: OS-level restriction that also covers files a script opens indirectly

Worth knowing too: rm and rmdir targeting a critical path are never auto-approved in any mode, and no allow rule or PreToolUse hook allow approves them.

用語解説

PreToolUse: the event that fires immediately before a tool runs. Denying here means the tool call never happens.

Checking it works

After registering, run /hooks and confirm the hook appears under the right event. To test the script by itself, pipe sample JSON into it:

echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/x"}}' | ./.claude/hooks/block-rm-rf.sh; echo "exit=$?"

exit=2 is what you want to see.

Summary

  • PreToolUse hooks fire before permission-mode checks, so a deny holds even in bypassPermissions
  • Two ways to block: exit 2 for brevity, permissionDecision JSON to pass a reason
  • Multiple hooks on one event run in parallel, and a deny wins
  • A hook's allow cannot override a deny rule — hooks tighten, they don't loosen
  • String matching misses rm -r -f and variable expansion; use deny rules and the sandbox for real enforcement

FAQ

Does a PreToolUse hook still apply in bypassPermissions?
Yes. The documentation states that PreToolUse hooks fire before any permission-mode check, in every mode, and that a hook returning permissionDecision deny blocks the tool even in bypassPermissions or with --dangerously-skip-permissions.
Can a hook returning allow override a deny rule?
No. The documentation puts it as: hooks can tighten restrictions but not loosen them past what permission rules allow.
Should I use exit code 2 or permissionDecision?
Use the permissionDecision JSON when you want to tell Claude why, and exit code 2 when you want the shortest possible script. Both block the call.
Is matching on the string rm -rf enough?
No. It misses rm -r -f, long options, and variable expansion. Treat hooks as a guardrail against mistakes and rely on deny rules and the sandbox for real enforcement.

Primary sources

This article was drafted by AI from official documentation and reviewed by the site operator before publishing. Found a mistake? Let us know via the contact page.