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()