Implement guardrail hooks for dangerous operation prevention
- 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 <noreply@anthropic.com>
This commit is contained in:
283
hooks/scripts/guardrail.py
Executable file
283
hooks/scripts/guardrail.py
Executable file
@@ -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()
|
||||
Reference in New Issue
Block a user