358 lines
12 KiB
Python
Executable File
358 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Unified PreToolUse Hook - Consolidated Permission & Security Validation
|
|
|
|
This hook consolidates three PreToolUse validators into a single dispatcher:
|
|
1. MCP Security Validator (pre_tool_use.py) - Path traversal, injection, SSRF protection
|
|
2. Agent Authorization (enforce_implementation_workflow.py) - Pipeline agent detection
|
|
3. Batch Permission Approver (batch_permission_approver.py) - Permission batching
|
|
|
|
Decision Logic:
|
|
- If ANY validator returns "deny" → output "deny" (block operation)
|
|
- If ALL validators return "allow" → output "allow" (approve operation)
|
|
- Otherwise → output "ask" (prompt user)
|
|
|
|
Environment Variables:
|
|
- PRE_TOOL_MCP_SECURITY: Enable/disable MCP security (default: true)
|
|
- PRE_TOOL_AGENT_AUTH: Enable/disable agent authorization (default: true)
|
|
- PRE_TOOL_BATCH_PERMISSION: Enable/disable batch permission (default: false)
|
|
- MCP_AUTO_APPROVE: Enable/disable auto-approval (default: false)
|
|
|
|
Input (stdin):
|
|
{
|
|
"tool_name": "Bash",
|
|
"tool_input": {"command": "pytest tests/"}
|
|
}
|
|
|
|
Output (stdout):
|
|
{
|
|
"hookSpecificOutput": {
|
|
"hookEventName": "PreToolUse",
|
|
"permissionDecision": "allow|deny|ask",
|
|
"permissionDecisionReason": "Combined validator reasons"
|
|
}
|
|
}
|
|
|
|
Exit code: 0 (always - let Claude Code process the decision)
|
|
|
|
Date: 2025-12-15
|
|
Issue: GitHub #142 (Unified PreToolUse Hook)
|
|
Agent: implementer
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Dict, Tuple, List
|
|
|
|
|
|
def find_lib_directory(hook_path: Path) -> Path | None:
|
|
"""
|
|
Find lib directory dynamically (Issue #113).
|
|
|
|
Checks multiple locations in order:
|
|
1. Development: plugins/autonomous-dev/lib (relative to hook)
|
|
2. Local install: ~/.claude/lib
|
|
3. Marketplace: ~/.claude/plugins/autonomous-dev/lib
|
|
|
|
Args:
|
|
hook_path: Path to this hook script
|
|
|
|
Returns:
|
|
Path to lib directory if found, None otherwise (graceful failure)
|
|
"""
|
|
# Try development location first
|
|
dev_lib = hook_path.parent.parent / "lib"
|
|
if dev_lib.exists() and dev_lib.is_dir():
|
|
return dev_lib
|
|
|
|
# Try local install
|
|
home = Path.home()
|
|
local_lib = home / ".claude" / "lib"
|
|
if local_lib.exists() and local_lib.is_dir():
|
|
return local_lib
|
|
|
|
# Try marketplace location
|
|
marketplace_lib = home / ".claude" / "plugins" / "autonomous-dev" / "lib"
|
|
if marketplace_lib.exists() and marketplace_lib.is_dir():
|
|
return marketplace_lib
|
|
|
|
return None
|
|
|
|
|
|
# Add lib directory to path dynamically
|
|
LIB_DIR = find_lib_directory(Path(__file__))
|
|
if LIB_DIR:
|
|
sys.path.insert(0, str(LIB_DIR))
|
|
|
|
|
|
def load_env():
|
|
"""Load .env file from project root if it exists."""
|
|
env_file = Path(os.getcwd()) / ".env"
|
|
if env_file.exists():
|
|
try:
|
|
with open(env_file, 'r') as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line or line.startswith('#'):
|
|
continue
|
|
if '=' in line:
|
|
key, value = line.split('=', 1)
|
|
key = key.strip()
|
|
value = value.strip().strip('"').strip("'")
|
|
if key not in os.environ:
|
|
os.environ[key] = value
|
|
except Exception:
|
|
pass # Silently skip
|
|
|
|
|
|
# Agents authorized for code changes (pipeline agents)
|
|
# Issue #147: Consolidated to only active agents that write code/tests/docs
|
|
PIPELINE_AGENTS = [
|
|
'implementer',
|
|
'test-master',
|
|
'doc-master',
|
|
]
|
|
|
|
|
|
def validate_mcp_security(tool_name: str, tool_input: Dict) -> Tuple[str, str]:
|
|
"""
|
|
Validate MCP security (path traversal, injection, SSRF).
|
|
|
|
Args:
|
|
tool_name: Name of the tool being called
|
|
tool_input: Tool input parameters
|
|
|
|
Returns:
|
|
Tuple of (decision, reason)
|
|
- decision: "allow", "deny", or "ask"
|
|
- reason: Human-readable reason for decision
|
|
"""
|
|
# Check if MCP security is enabled
|
|
enabled = os.getenv("PRE_TOOL_MCP_SECURITY", "true").lower() == "true"
|
|
if not enabled:
|
|
return ("allow", "MCP security disabled")
|
|
|
|
try:
|
|
# Try to import MCP security validator
|
|
try:
|
|
from mcp_security_validator import validate_mcp_operation
|
|
|
|
# Validate the operation
|
|
is_safe, reason = validate_mcp_operation(tool_name, tool_input)
|
|
|
|
if not is_safe:
|
|
# Security risk detected
|
|
return ("deny", f"MCP Security: {reason}")
|
|
else:
|
|
return ("allow", f"MCP Security: {reason}")
|
|
|
|
except ImportError:
|
|
# MCP security validator not available - check auto-approval
|
|
auto_approve_enabled = os.getenv("MCP_AUTO_APPROVE", "false").lower()
|
|
|
|
if auto_approve_enabled == "false":
|
|
# Auto-approval disabled, no MCP security - ask user
|
|
return ("ask", "MCP security validator unavailable, auto-approval disabled")
|
|
|
|
# Auto-approval enabled - try to use it
|
|
try:
|
|
from auto_approval_engine import should_auto_approve
|
|
|
|
agent_name = os.getenv("CLAUDE_AGENT_NAME", "main")
|
|
approved, reason = should_auto_approve(tool_name, tool_input, agent_name)
|
|
|
|
if approved:
|
|
return ("allow", f"Auto-approved: {reason}")
|
|
elif "blacklist" in reason.lower() or "injection" in reason.lower() or "security" in reason.lower() or "circuit breaker" in reason.lower():
|
|
return ("deny", f"Blacklisted: {reason}")
|
|
else:
|
|
return ("ask", f"Not whitelisted: {reason}")
|
|
|
|
except ImportError:
|
|
# Neither validator available - ask user (safe default)
|
|
return ("ask", "MCP security validators unavailable")
|
|
|
|
except Exception as e:
|
|
# Error in validation - ask user (don't block on errors)
|
|
return ("ask", f"MCP security error: {e}")
|
|
|
|
|
|
def validate_agent_authorization(tool_name: str, tool_input: Dict) -> Tuple[str, str]:
|
|
"""
|
|
Validate agent authorization for code changes.
|
|
|
|
Args:
|
|
tool_name: Name of the tool being called
|
|
tool_input: Tool input parameters
|
|
|
|
Returns:
|
|
Tuple of (decision, reason)
|
|
- decision: "allow", "deny", or "ask"
|
|
- reason: Human-readable reason for decision
|
|
"""
|
|
# Check if agent authorization is enabled
|
|
enabled = os.getenv("PRE_TOOL_AGENT_AUTH", "true").lower() == "true"
|
|
if not enabled:
|
|
return ("allow", "Agent authorization disabled")
|
|
|
|
# Check if running inside a pipeline agent
|
|
agent_name = os.getenv("CLAUDE_AGENT_NAME", "").strip().lower()
|
|
if agent_name in PIPELINE_AGENTS:
|
|
return ("allow", f"Pipeline agent '{agent_name}' authorized")
|
|
|
|
# Issue #141: Intent detection removed
|
|
# All changes allowed - rely on persuasion, convenience, and skills
|
|
return ("allow", f"Tool '{tool_name}' allowed (intent detection removed per Issue #141)")
|
|
|
|
|
|
def validate_batch_permission(tool_name: str, tool_input: Dict) -> Tuple[str, str]:
|
|
"""
|
|
Validate batch permission for auto-approval.
|
|
|
|
Args:
|
|
tool_name: Name of the tool being called
|
|
tool_input: Tool input parameters
|
|
|
|
Returns:
|
|
Tuple of (decision, reason)
|
|
- decision: "allow", "deny", or "ask"
|
|
- reason: Human-readable reason for decision
|
|
"""
|
|
# Check if batch permission is enabled
|
|
enabled = os.getenv("PRE_TOOL_BATCH_PERMISSION", "false").lower() == "true"
|
|
if not enabled:
|
|
return ("allow", "Batch permission disabled")
|
|
|
|
try:
|
|
# Try to import permission classifier
|
|
try:
|
|
from permission_classifier import PermissionClassifier, PermissionLevel
|
|
|
|
# Classify operation
|
|
classifier = PermissionClassifier()
|
|
level = classifier.classify(tool_name, tool_input)
|
|
|
|
if level == PermissionLevel.SAFE:
|
|
return ("allow", f"Batch permission: SAFE operation auto-approved")
|
|
elif level == PermissionLevel.BOUNDARY:
|
|
return ("allow", f"Batch permission: BOUNDARY operation allowed")
|
|
else: # PermissionLevel.SENSITIVE
|
|
return ("ask", f"Batch permission: SENSITIVE operation requires user approval")
|
|
|
|
except ImportError:
|
|
# Permission classifier not available - allow (don't block)
|
|
return ("allow", "Batch permission classifier unavailable")
|
|
|
|
except Exception as e:
|
|
# Error in validation - allow (don't block on errors)
|
|
return ("allow", f"Batch permission error: {e}")
|
|
|
|
|
|
def combine_decisions(validators_results: List[Tuple[str, str, str]]) -> Tuple[str, str]:
|
|
"""
|
|
Combine multiple validator decisions into single decision.
|
|
|
|
Decision Logic:
|
|
- If ANY validator returns "deny" → "deny" (block operation)
|
|
- If ALL validators return "allow" → "allow" (approve operation)
|
|
- Otherwise → "ask" (prompt user)
|
|
|
|
Args:
|
|
validators_results: List of (validator_name, decision, reason) tuples
|
|
|
|
Returns:
|
|
Tuple of (final_decision, combined_reason)
|
|
"""
|
|
decisions = []
|
|
reasons = []
|
|
|
|
for validator_name, decision, reason in validators_results:
|
|
decisions.append(decision)
|
|
reasons.append(f"[{validator_name}] {reason}")
|
|
|
|
# If ANY deny → deny
|
|
if "deny" in decisions:
|
|
deny_reasons = [r for v, d, r in validators_results if d == "deny"]
|
|
return ("deny", "; ".join(deny_reasons))
|
|
|
|
# If ALL allow → allow
|
|
if all(d == "allow" for d in decisions):
|
|
return ("allow", "; ".join(reasons))
|
|
|
|
# Otherwise → ask
|
|
ask_reasons = [r for v, d, r in validators_results if d == "ask"]
|
|
if ask_reasons:
|
|
return ("ask", "; ".join(ask_reasons))
|
|
else:
|
|
return ("ask", "; ".join(reasons))
|
|
|
|
|
|
def output_decision(decision: str, reason: str):
|
|
"""Output the hook decision in required format."""
|
|
output = {
|
|
"hookSpecificOutput": {
|
|
"hookEventName": "PreToolUse",
|
|
"permissionDecision": decision,
|
|
"permissionDecisionReason": reason
|
|
}
|
|
}
|
|
print(json.dumps(output))
|
|
|
|
|
|
def main():
|
|
"""Main entry point - dispatch to all validators and combine decisions."""
|
|
try:
|
|
# Load environment variables
|
|
load_env()
|
|
|
|
# Read input from stdin
|
|
try:
|
|
input_data = json.load(sys.stdin)
|
|
except json.JSONDecodeError as e:
|
|
# Invalid JSON - ask user (don't block on invalid input)
|
|
output_decision("ask", f"Invalid input JSON: {e}")
|
|
sys.exit(0)
|
|
|
|
# Extract tool information
|
|
tool_name = input_data.get("tool_name", "")
|
|
tool_input = input_data.get("tool_input", {})
|
|
|
|
if not tool_name:
|
|
# No tool name - ask user
|
|
output_decision("ask", "No tool name provided")
|
|
sys.exit(0)
|
|
|
|
# Run all validators in sequence
|
|
validators_results = []
|
|
|
|
# 1. MCP Security Validator
|
|
decision, reason = validate_mcp_security(tool_name, tool_input)
|
|
validators_results.append(("MCP Security", decision, reason))
|
|
|
|
# 2. Agent Authorization
|
|
decision, reason = validate_agent_authorization(tool_name, tool_input)
|
|
validators_results.append(("Agent Auth", decision, reason))
|
|
|
|
# 3. Batch Permission Approver
|
|
decision, reason = validate_batch_permission(tool_name, tool_input)
|
|
validators_results.append(("Batch Permission", decision, reason))
|
|
|
|
# Combine all decisions
|
|
final_decision, combined_reason = combine_decisions(validators_results)
|
|
|
|
# Output final decision
|
|
output_decision(final_decision, combined_reason)
|
|
|
|
except Exception as e:
|
|
# Error in hook - ask user (don't block on hook errors)
|
|
output_decision("ask", f"Hook error: {e}")
|
|
|
|
# Always exit 0 - let Claude Code process the decision
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|