From ecf375205f5b368cec19ee9a87551d16050363bb Mon Sep 17 00:00:00 2001 From: OpenCode Test Date: Wed, 7 Jan 2026 10:57:53 -0800 Subject: [PATCH] Implement guardrail hooks for dangerous operation prevention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PreToolUse hook intercepting Bash, Write, Edit - Block catastrophic commands (rm -rf /, mkfs, etc.) - Require confirmation for operations outside safe paths - Git-aware: operations in git repos are allowed - Session allowlist for user-confirmed operations - Audit logging to logs/guardrail.jsonl - Clear session allowlist on SessionEnd Config: state/guardrails.json Scripts: hooks/scripts/guardrail.py, guardrail-confirm.py 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- hooks/hooks.json | 12 ++ hooks/scripts/guardrail-confirm.py | 73 ++++++++ hooks/scripts/guardrail.py | 283 +++++++++++++++++++++++++++++ hooks/scripts/session-end.sh | 7 + state/component-registry.json | 11 ++ state/guardrails.json | 45 +++++ 6 files changed, 431 insertions(+) create mode 100755 hooks/scripts/guardrail-confirm.py create mode 100755 hooks/scripts/guardrail.py create mode 100644 state/guardrails.json diff --git a/hooks/hooks.json b/hooks/hooks.json index 0d79878..5653bc6 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -41,6 +41,18 @@ } ] } + ], + "PreToolUse": [ + { + "matcher": "Bash|Write|Edit", + "hooks": [ + { + "type": "command", + "command": "~/.claude/hooks/scripts/guardrail.py", + "timeout": 5 + } + ] + } ] } } diff --git a/hooks/scripts/guardrail-confirm.py b/hooks/scripts/guardrail-confirm.py new file mode 100755 index 0000000..e49dca6 --- /dev/null +++ b/hooks/scripts/guardrail-confirm.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +""" +Guardrail Confirm Helper + +Adds an operation to the session allowlist so it can proceed on retry. + +Usage: + python3 guardrail-confirm.py "" "" + +Example: + python3 guardrail-confirm.py "Bash" "rm -rf ~/Downloads/old-project" +""" + +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +HOME = Path.home() +STATE_DIR = HOME / ".claude" / "state" +SESSION_FILE = STATE_DIR / "guardrail-session.json" + + +def load_session(): + """Load current session allowlist.""" + 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 save_session(data: dict): + """Save session allowlist.""" + STATE_DIR.mkdir(parents=True, exist_ok=True) + with open(SESSION_FILE, "w") as f: + json.dump(data, f, indent=2) + + +def main(): + if len(sys.argv) != 3: + print("Usage: guardrail-confirm.py ") + print("Example: guardrail-confirm.py 'Bash' 'rm ~/Downloads/old'") + sys.exit(1) + + tool = sys.argv[1] + operation = sys.argv[2] + + # Load current session + session = load_session() + + # Check if already confirmed + for item in session.get("confirmed", []): + if item.get("tool") == tool and item.get("operation") == operation: + print(f"Already confirmed: {tool} - {operation[:50]}...") + sys.exit(0) + + # Add to allowlist + session["confirmed"].append({ + "tool": tool, + "operation": operation, + "ts": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + }) + + save_session(session) + print(f"Confirmed: {tool} - {operation[:50]}...") + print("You may now retry the operation.") + + +if __name__ == "__main__": + main() diff --git a/hooks/scripts/guardrail.py b/hooks/scripts/guardrail.py new file mode 100755 index 0000000..3934cc9 --- /dev/null +++ b/hooks/scripts/guardrail.py @@ -0,0 +1,283 @@ +#!/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() diff --git a/hooks/scripts/session-end.sh b/hooks/scripts/session-end.sh index 3278c32..6b7d6ba 100755 --- a/hooks/scripts/session-end.sh +++ b/hooks/scripts/session-end.sh @@ -28,6 +28,13 @@ REASON=$(echo "$INPUT" | python3 -c "import sys, json; print(json.load(sys.stdin log "SessionEnd triggered: session=$SESSION_ID reason=$REASON" +# Clear guardrail session allowlist (confirmations don't persist across sessions) +GUARDRAIL_SESSION="${HOME}/.claude/state/guardrail-session.json" +if [[ -f "$GUARDRAIL_SESSION" ]]; then + rm -f "$GUARDRAIL_SESSION" + log "Cleared guardrail session allowlist" +fi + # Validate required fields if [[ -z "$SESSION_ID" || -z "$TRANSCRIPT_PATH" ]]; then log "ERROR: Missing session_id or transcript_path" diff --git a/state/component-registry.json b/state/component-registry.json index 273cc8f..59fd874 100644 --- a/state/component-registry.json +++ b/state/component-registry.json @@ -132,6 +132,17 @@ "to-do", "pending" ] + }, + "guardrails": { + "description": "PreToolUse hook that prevents dangerous operations (rm -rf, system commands, etc.)", + "script": "~/.claude/hooks/scripts/guardrail.py", + "config": "~/.claude/state/guardrails.json", + "triggers": [ + "guardrail", + "safety", + "block dangerous", + "protect" + ] } }, "commands": { diff --git a/state/guardrails.json b/state/guardrails.json new file mode 100644 index 0000000..20cc9b7 --- /dev/null +++ b/state/guardrails.json @@ -0,0 +1,45 @@ +{ + "version": 1, + "safe_paths": [ + "~/.claude", + "~/projects" + ], + "blocked_paths": [ + "/etc", + "/usr", + "/var", + "/boot", + "/sys", + "/proc", + "~/.ssh", + "~/.gnupg", + "~/.aws" + ], + "rules": { + "bash": [ + {"pattern": "rm -rf /($|[^a-zA-Z])", "action": "block", "name": "rm_rf_root"}, + {"pattern": "rm -rf ~($|[^a-zA-Z])", "action": "block", "name": "rm_rf_home"}, + {"pattern": "rm -rf \\*", "action": "block", "name": "rm_rf_wildcard"}, + {"pattern": "chmod -R 777", "action": "block", "name": "chmod_777"}, + {"pattern": ":\\(\\)\\{ :\\|:& \\};:", "action": "block", "name": "fork_bomb"}, + {"pattern": "mkfs\\.", "action": "block", "name": "mkfs"}, + {"pattern": "dd .* of=/dev/", "action": "block", "name": "dd_device"}, + {"pattern": "> /dev/sd[a-z]", "action": "block", "name": "overwrite_device"}, + {"pattern": "shutdown", "action": "confirm", "name": "shutdown"}, + {"pattern": "reboot", "action": "confirm", "name": "reboot"}, + {"pattern": "systemctl (stop|disable|mask)", "action": "confirm", "name": "systemctl_destructive"}, + {"pattern": "rm ", "action": "confirm", "name": "rm_outside_safe", "outside_safe_paths": true}, + {"pattern": "kubectl delete", "action": "confirm", "name": "kubectl_delete"}, + {"pattern": "docker rm", "action": "confirm", "name": "docker_rm"}, + {"pattern": "docker system prune", "action": "confirm", "name": "docker_prune"} + ], + "write": [ + {"path_match": "blocked_paths", "action": "block", "name": "write_blocked_path"}, + {"path_match": "outside_safe_paths", "action": "confirm", "name": "write_outside_safe"} + ], + "edit": [ + {"path_match": "blocked_paths", "action": "block", "name": "edit_blocked_path"}, + {"path_match": "outside_safe_paths", "action": "confirm", "name": "edit_outside_safe"} + ] + } +}