#!/usr/bin/env python3 """ Guardrail PreToolUse Hook Intercepts Bash, Write, and Edit tool calls to prevent dangerous operations. Returns JSON decision: {"decision": "allow"} or {"decision": "block", "reason": "..."} """ import json import os import re import sys from datetime import datetime, timezone from pathlib import Path # Paths HOME = Path.home() STATE_DIR = HOME / ".claude" / "state" LOGS_DIR = HOME / ".claude" / "logs" CONFIG_FILE = STATE_DIR / "guardrails.json" SESSION_FILE = STATE_DIR / "guardrail-session.json" LOG_FILE = LOGS_DIR / "guardrail.jsonl" def load_config(): """Load guardrails configuration.""" if not CONFIG_FILE.exists(): return None with open(CONFIG_FILE) as f: return json.load(f) def load_session_allowlist(): """Load session allowlist of confirmed operations.""" if not SESSION_FILE.exists(): return {"confirmed": []} try: with open(SESSION_FILE) as f: return json.load(f) except (json.JSONDecodeError, IOError): return {"confirmed": []} def is_in_allowlist(tool: str, operation: str) -> bool: """Check if operation was previously confirmed.""" allowlist = load_session_allowlist() for item in allowlist.get("confirmed", []): if item.get("tool") == tool and item.get("operation") == operation: return True return False def expand_path(path: str) -> Path: """Expand ~ and resolve path.""" return Path(os.path.expanduser(path)).resolve() def is_under_path(target: Path, parent: str) -> bool: """Check if target is under parent path.""" try: parent_path = expand_path(parent) # Handle glob patterns like ~/projects/* if "*" in parent: # For ~/projects/*, check if under ~/projects parent_path = expand_path(parent.replace("/*", "").replace("*", "")) return parent_path in target.parents or target == parent_path except (ValueError, OSError): return False def is_in_git_repo(path: Path) -> bool: """Check if path is inside a git repository.""" current = path if path.is_dir() else path.parent while current != current.parent: if (current / ".git").exists(): return True current = current.parent return False def classify_path(target_path: str, config: dict) -> str: """ Classify a path as 'blocked', 'safe', or 'outside'. Evaluation order: 1. Blocked paths -> 'blocked' 2. Safe paths -> 'safe' 3. Git repo -> 'safe' 4. Otherwise -> 'outside' """ try: target = expand_path(target_path) except (ValueError, OSError): return "outside" # Check blocked paths first for blocked in config.get("blocked_paths", []): if is_under_path(target, blocked): return "blocked" # Check safe paths for safe in config.get("safe_paths", []): if is_under_path(target, safe): return "safe" # Check if in git repo if is_in_git_repo(target): return "safe" return "outside" def extract_paths_from_command(command: str) -> list[str]: """Extract potential file paths from a bash command.""" paths = [] # Simple heuristic: look for things that look like paths # This catches ~/..., /..., and relative paths tokens = command.split() for token in tokens: # Skip flags if token.startswith("-"): continue # Skip common commands if token in ("rm", "mv", "cp", "chmod", "chown", "mkdir", "rmdir", "touch"): continue # Check if it looks like a path if "/" in token or token.startswith("~"): paths.append(token) return paths def check_bash_rules(command: str, config: dict) -> tuple[str, str | None, str]: """ Check bash command against rules. Returns: (action, rule_name, path_context) action: 'allow', 'block', or 'confirm' """ rules = config.get("rules", {}).get("bash", []) for rule in rules: pattern = rule.get("pattern", "") action = rule.get("action", "allow") name = rule.get("name", "unnamed") outside_safe_only = rule.get("outside_safe_paths", False) # Check if pattern matches if re.search(pattern, command): if outside_safe_only: # Only apply rule if operating outside safe paths paths = extract_paths_from_command(command) for path in paths: path_class = classify_path(path, config) if path_class in ("blocked", "outside"): return (action, name, path_class) # All paths are safe, allow continue else: # Rule applies regardless of path return (action, name, "n/a") return ("allow", None, "safe") def check_file_rules(file_path: str, tool: str, config: dict) -> tuple[str, str | None, str]: """ Check Write/Edit file path against rules. Returns: (action, rule_name, path_context) """ rules = config.get("rules", {}).get(tool.lower(), []) path_class = classify_path(file_path, config) for rule in rules: path_match = rule.get("path_match", "") action = rule.get("action", "allow") name = rule.get("name", "unnamed") if path_match == "blocked_paths" and path_class == "blocked": return (action, name, path_class) elif path_match == "outside_safe_paths" and path_class == "outside": return (action, name, path_class) return ("allow", None, path_class) def log_action(tool: str, operation: str, action: str, rule: str | None, path_context: str): """Log guardrail action to audit log.""" LOGS_DIR.mkdir(parents=True, exist_ok=True) entry = { "ts": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), "tool": tool, "operation": operation[:200], # Truncate long operations "action": action, "rule": rule or "none", "path_context": path_context } with open(LOG_FILE, "a") as f: f.write(json.dumps(entry) + "\n") def allow(): """Return allow decision.""" print(json.dumps({"decision": "allow"})) sys.exit(0) def block(reason: str): """Return block decision with reason.""" print(json.dumps({"decision": "block", "reason": reason})) sys.exit(0) def main(): # Read input from stdin try: input_data = json.load(sys.stdin) except json.JSONDecodeError: allow() # If we can't parse input, allow (fail open) tool_name = input_data.get("tool_name", "") tool_input = input_data.get("tool_input", {}) # Only check Bash, Write, Edit if tool_name not in ("Bash", "Write", "Edit"): allow() # Load config config = load_config() if not config: allow() # No config, allow everything # Determine operation string for allowlist check if tool_name == "Bash": operation = tool_input.get("command", "") else: operation = tool_input.get("file_path", "") # Check session allowlist first if is_in_allowlist(tool_name, operation): log_action(tool_name, operation, "confirmed_allow", "session_allowlist", "n/a") allow() # Check rules based on tool type if tool_name == "Bash": action, rule_name, path_context = check_bash_rules(operation, config) else: action, rule_name, path_context = check_file_rules(operation, tool_name, config) # Take action if action == "allow": allow() # Log blocked/confirm actions log_action(tool_name, operation, action if action == "block" else "confirm_required", rule_name, path_context) # Build block message if action == "block": reason = f"""GUARDRAIL BLOCKED: Operation not allowed. Tool: {tool_name} Operation: {operation} Rule: {rule_name} Path context: {path_context} This operation is blocked by guardrail policy and cannot proceed.""" else: # confirm confirm_cmd = f'python3 ~/.claude/hooks/scripts/guardrail-confirm.py "{tool_name}" "{operation}"' reason = f"""GUARDRAIL: User confirmation required. Tool: {tool_name} Operation: {operation} Rule: {rule_name} Path context: {path_context} To proceed after user confirms, run: {confirm_cmd} Then retry the original operation.""" block(reason) if __name__ == "__main__": main()