- 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>
284 lines
8.3 KiB
Python
Executable File
284 lines
8.3 KiB
Python
Executable File
#!/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()
|