Skip to content

Instantly share code, notes, and snippets.

@jalehman
Created December 16, 2025 16:53
Show Gist options
  • Select an option

  • Save jalehman/305cd87162dc79493b4ec4f817f15942 to your computer and use it in GitHub Desktop.

Select an option

Save jalehman/305cd87162dc79493b4ec4f817f15942 to your computer and use it in GitHub Desktop.
Block dangerous rm

In ~/.claude/settings.json:

{
  ...
  "hooks": {
    ...
    "PreToolUse": [
      {
        "hooks": [
          {
            "command": "/path/to/.claude/hooks/block-dangerous-rm.py",
            "type": "command"
          }
        ],
        "matcher": "Bash"
      }
    ],
    ...
  },
 ...
}

block-dangerous-rm.py

#!/usr/bin/env python3
"""
Claude Code pre-tool-use hook to block dangerous rm commands.

This hook intercepts Bash tool calls and blocks commands containing
dangerous rm patterns like `rm -rf` or `rm -fr`.

Exit codes:
  0 - Allow the command
  2 - Block the command (blocking error)
"""

import json
import re
import sys


# Absolutely blocked patterns - these are always dangerous
ALWAYS_BLOCKED_TARGETS = [
    "/",
    "/*",
    "~",
    "~/*",
    "$HOME",
    "$HOME/*",
    "/bin",
    "/usr",
    "/etc",
    "/var",
    "/tmp",
    "/home",
    "/root",
    "/System",
    "/Library",
    "/Applications",
    "..",
    "../*",
]

# Known-safe directory names (can be removed with rm -rf)
SAFE_DIRECTORY_NAMES = {
    "node_modules",
    "dist",
    "build",
    ".cache",
    "__pycache__",
    ".pytest_cache",
    "coverage",
    ".next",
    ".nuxt",
    ".output",
    ".venv",
    "venv",
    "env",
    ".tox",
    "target",  # Rust/Maven
    "vendor",  # Go/PHP (be careful)
    ".turbo",
    ".parcel-cache",
}


def extract_rm_targets(command: str) -> list[str]:
    """
    Extract target paths from an rm command.

    Handles: rm -rf path, rm -r -f path1 path2, etc.
    """
    # Match rm with various flag combinations followed by paths
    # This regex captures the flags and everything after
    rm_match = re.search(
        r"\brm\s+((?:-[rRfiv]+\s*)+)(.+?)(?:;|&&|\|\||$)",
        command
    )
    if not rm_match:
        return []

    flags = rm_match.group(1)
    paths_str = rm_match.group(2).strip()

    # Check if recursive flag is present
    has_recursive = bool(re.search(r"-[a-zA-Z]*[rR]", flags))
    has_force = bool(re.search(r"-[a-zA-Z]*f", flags))

    if not (has_recursive and has_force):
        return []  # Only care about rm -rf style commands

    # Split paths (simple split, doesn't handle quoted paths perfectly)
    paths = paths_str.split()
    return paths


def get_basename(path: str) -> str:
    """Get the final component of a path."""
    # Remove trailing slashes
    path = path.rstrip("/")
    # Handle relative paths like ./build or ../build
    if "/" in path:
        return path.split("/")[-1]
    return path


def is_dangerous_rm(command: str) -> tuple[bool, str]:
    """
    Check if a command contains dangerous rm patterns.

    Strategy:
    1. Extract targets from rm -rf commands
    2. Block if any target is in the always-blocked list
    3. Block if target is an absolute path to a system directory
    4. Allow if target is a known-safe directory name
    5. Allow relative paths that look like project directories

    Returns:
        Tuple of (is_dangerous, reason)
    """
    # Check if this is even an rm command
    if not re.search(r"\brm\b", command):
        return False, ""

    targets = extract_rm_targets(command)

    if not targets:
        return False, ""  # Not an rm -rf command

    for target in targets:
        # Normalize: expand ~ at start
        normalized = target
        if target.startswith("~/"):
            normalized = "$HOME" + target[1:]

        # Check against always-blocked targets
        for blocked in ALWAYS_BLOCKED_TARGETS:
            if normalized == blocked or normalized.startswith(blocked + "/"):
                return True, f"Blocked target: '{target}' matches blocked pattern '{blocked}'"

        # Block absolute paths to short system directories
        if target.startswith("/"):
            parts = [p for p in target.split("/") if p]
            if len(parts) <= 2:
                return True, f"Blocked: rm -rf on system path '{target}'"

        # Check if basename is a known-safe directory
        basename = get_basename(target)
        if basename in SAFE_DIRECTORY_NAMES:
            continue  # This target is safe

        # Block wildcards at dangerous locations
        if target in ("*", "./*"):
            return True, f"Blocked: rm -rf with wildcard '{target}'"

        # Block if target starts with absolute path and contains wildcard
        if target.startswith("/") and "*" in target:
            return True, f"Blocked: rm -rf with wildcard in absolute path '{target}'"

        # For relative paths not in safe list, block if it looks dangerous
        # (starts with .. or is just a bare wildcard)
        if target.startswith(".."):
            return True, f"Blocked: rm -rf going up directories '{target}'"

    # All targets passed checks
    return False, ""


def main():
    # Read JSON input from stdin
    try:
        input_data = json.load(sys.stdin)
    except json.JSONDecodeError as e:
        print(f"Error: Invalid JSON input: {e}", file=sys.stderr)
        sys.exit(1)

    # Get tool info
    tool_name = input_data.get("tool_name", "")
    tool_input = input_data.get("tool_input", {})
    command = tool_input.get("command", "")

    # Only check Bash commands
    if tool_name != "Bash":
        sys.exit(0)  # Allow non-Bash tools

    if not command:
        sys.exit(0)  # Allow empty commands (will fail naturally)

    # Check if the command is dangerous
    is_dangerous, reason = is_dangerous_rm(command)

    if is_dangerous:
        # Print reason to stderr (shown to Claude)
        print(f"BLOCKED: Dangerous rm command detected!", file=sys.stderr)
        print(f"Command: {command}", file=sys.stderr)
        print(f"Reason: {reason}", file=sys.stderr)
        print("", file=sys.stderr)
        print("If you need to delete files recursively, please:", file=sys.stderr)
        print("  1. Be more specific about the path", file=sys.stderr)
        print("  2. Use a safer alternative like 'trash' command", file=sys.stderr)
        print("  3. Ask the user to run the command manually", file=sys.stderr)
        sys.exit(2)  # Exit code 2 = blocking error

    # Command is safe, allow it
    sys.exit(0)


if __name__ == "__main__":
    main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment