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:
@@ -41,6 +41,18 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash|Write|Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.claude/hooks/scripts/guardrail.py",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
73
hooks/scripts/guardrail-confirm.py
Executable file
73
hooks/scripts/guardrail-confirm.py
Executable file
@@ -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 "<tool>" "<operation>"
|
||||
|
||||
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 <tool> <operation>")
|
||||
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()
|
||||
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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
45
state/guardrails.json
Normal file
45
state/guardrails.json
Normal file
@@ -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"}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user