|
#!/usr/bin/env python3 |
|
""" |
|
Security validator hook for Claude Code. |
|
Allows rm/rmdir only within a git repository and blocks dangerous deletion patterns. |
|
""" |
|
|
|
import glob |
|
import json |
|
import os |
|
from pathlib import Path |
|
import shlex |
|
import subprocess |
|
import sys |
|
from typing import List, Optional, Sequence, Tuple |
|
|
|
|
|
SHELL_SEPARATORS = {";", "|", "||", "&&"} |
|
|
|
|
|
def split_shell_segments(command: str) -> List[List[str]]: |
|
lexer = shlex.shlex(command, posix=True, punctuation_chars=";&|") |
|
lexer.whitespace_split = True |
|
lexer.commenters = "" |
|
|
|
segments: List[List[str]] = [] |
|
current: List[str] = [] |
|
|
|
for token in lexer: |
|
if token in SHELL_SEPARATORS: |
|
if current: |
|
segments.append(current) |
|
current = [] |
|
continue |
|
current.append(token) |
|
|
|
if current: |
|
segments.append(current) |
|
|
|
return segments |
|
|
|
|
|
def has_recursive_flag(args: Sequence[str]) -> bool: |
|
parsing_flags = True |
|
for arg in args: |
|
if parsing_flags and arg == "--": |
|
parsing_flags = False |
|
continue |
|
if parsing_flags and arg.startswith("-") and arg != "-": |
|
if "r" in arg or "R" in arg: |
|
return True |
|
return False |
|
|
|
|
|
def parse_rm_targets(args: Sequence[str]) -> List[str]: |
|
targets: List[str] = [] |
|
parsing_flags = True |
|
|
|
for arg in args: |
|
if parsing_flags and arg == "--": |
|
parsing_flags = False |
|
continue |
|
if parsing_flags and arg.startswith("-") and arg != "-": |
|
continue |
|
targets.append(arg) |
|
|
|
return targets |
|
|
|
|
|
def expand_targets(raw_target: str, cwd: Path) -> List[Path]: |
|
expanded = os.path.expanduser(raw_target) |
|
base = Path(expanded) |
|
if not base.is_absolute(): |
|
base = cwd / base |
|
|
|
if any(ch in raw_target for ch in "*?["): |
|
matches = [Path(p).resolve(strict=False) for p in glob.glob(str(base), recursive=True)] |
|
if matches: |
|
return matches |
|
|
|
return [base.resolve(strict=False)] |
|
|
|
|
|
def is_within(path: Path, parent: Path) -> bool: |
|
try: |
|
path.relative_to(parent) |
|
return True |
|
except ValueError: |
|
return False |
|
|
|
|
|
def nearest_existing_dir(path: Path) -> Path: |
|
candidate = path if path.is_dir() else path.parent |
|
if candidate.exists(): |
|
return candidate |
|
|
|
for parent in candidate.parents: |
|
if parent.exists(): |
|
return parent |
|
|
|
return Path.cwd() |
|
|
|
|
|
def git_root_for_path(path: Path) -> Optional[Path]: |
|
probe_dir = nearest_existing_dir(path) |
|
for candidate in [probe_dir, *probe_dir.parents]: |
|
result = subprocess.run( |
|
["git", "-C", str(candidate), "rev-parse", "--show-toplevel"], |
|
capture_output=True, |
|
text=True, |
|
check=False, |
|
) |
|
if result.returncode != 0: |
|
continue |
|
|
|
root = result.stdout.strip() |
|
if root: |
|
return Path(root).resolve(strict=False) |
|
|
|
return None |
|
|
|
|
|
def validate_rm_segment(tokens: Sequence[str], cwd: Path) -> Tuple[bool, str]: |
|
if not tokens: |
|
return True, "" |
|
|
|
exe = Path(tokens[0]).name |
|
if exe not in {"rm", "rmdir"}: |
|
return True, "" |
|
|
|
args = list(tokens[1:]) |
|
recursive = has_recursive_flag(args) |
|
targets = parse_rm_targets(args) |
|
|
|
if not targets: |
|
return False, "rm/rmdir requires explicit file or directory targets." |
|
|
|
for raw_target in targets: |
|
if raw_target in {"/", "~", "~root", ".", ".."}: |
|
return False, f"Deleting '{raw_target}' is blocked." |
|
|
|
for resolved_target in expand_targets(raw_target, cwd): |
|
git_root = git_root_for_path(resolved_target) |
|
if git_root is None: |
|
return False, ( |
|
f"Deletion blocked for '{raw_target}'. rm/rmdir is only allowed inside a git repository." |
|
) |
|
|
|
if not is_within(resolved_target, git_root): |
|
return False, f"Deletion blocked for '{raw_target}'. Target must be inside repo root {git_root}." |
|
|
|
if resolved_target == git_root: |
|
return False, f"Deletion of repository root is blocked: {git_root}" |
|
|
|
dot_git = (git_root / ".git").resolve(strict=False) |
|
if is_within(resolved_target, dot_git): |
|
return False, "Deletion inside .git is blocked." |
|
|
|
if recursive and resolved_target in {git_root, git_root.parent, Path.home(), Path("/")}: |
|
return False, f"Recursive deletion of high-risk path blocked: {resolved_target}" |
|
|
|
return True, "" |
|
|
|
|
|
def check_bash_command(command: str, cwd: Path) -> tuple[bool, str]: |
|
if "--no-preserve-root" in command: |
|
return False, "Use of --no-preserve-root is blocked." |
|
|
|
try: |
|
segments = split_shell_segments(command) |
|
except ValueError as exc: |
|
return False, f"Could not parse shell command safely: {exc}" |
|
|
|
for segment in segments: |
|
is_allowed, msg = validate_rm_segment(segment, cwd) |
|
if not is_allowed: |
|
return is_allowed, msg |
|
|
|
return True, "" |
|
|
|
|
|
def main() -> None: |
|
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) |
|
|
|
if input_data.get("tool_name", "") != "Bash": |
|
sys.exit(0) |
|
|
|
tool_input = input_data.get("tool_input", {}) |
|
command = tool_input.get("command", "") |
|
if not command: |
|
sys.exit(0) |
|
|
|
cwd_raw = tool_input.get("cwd") or os.getcwd() |
|
cwd = Path(cwd_raw).resolve(strict=False) |
|
|
|
is_allowed, error_msg = check_bash_command(command, cwd) |
|
if not is_allowed: |
|
print(f"SECURITY BLOCK: {error_msg}", file=sys.stderr) |
|
sys.exit(2) |
|
|
|
sys.exit(0) |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |