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"
|
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
|
# Validate required fields
|
||||||
if [[ -z "$SESSION_ID" || -z "$TRANSCRIPT_PATH" ]]; then
|
if [[ -z "$SESSION_ID" || -z "$TRANSCRIPT_PATH" ]]; then
|
||||||
log "ERROR: Missing session_id or transcript_path"
|
log "ERROR: Missing session_id or transcript_path"
|
||||||
|
|||||||
@@ -132,6 +132,17 @@
|
|||||||
"to-do",
|
"to-do",
|
||||||
"pending"
|
"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": {
|
"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