468 lines
16 KiB
Python
Executable File
468 lines
16 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Unified PreToolUse Hook - Chains MCP Security + Auto-Approval
|
|
|
|
This module provides a single PreToolUse hook that chains two validators:
|
|
1. MCP Security Validator - Prevents CWE-22, CWE-78, SSRF for mcp__* tools
|
|
2. Auto-Approval Validator - Whitelist/blacklist logic for all tools
|
|
|
|
Architecture (Chain of Responsibility):
|
|
┌─────────────────────────────────────┐
|
|
│ on_pre_tool_use() (unified) │
|
|
└─────────────────────────────────────┘
|
|
│
|
|
├─ Step 1: MCP Security Check (mcp__* tools only)
|
|
│ → DENY if dangerous → exit
|
|
│ → PASS if safe → continue
|
|
│
|
|
└─ Step 2: Auto-Approval Check (all tools)
|
|
→ APPROVE if trusted
|
|
→ DENY if unknown/blacklisted
|
|
|
|
Benefits:
|
|
- No hook collision (single on_pre_tool_use function)
|
|
- Clear separation of concerns (each validator independent)
|
|
- Proper chaining (security first, then auto-approval)
|
|
- Configurable via environment variables
|
|
- Graceful degradation (errors default to manual approval)
|
|
|
|
Configuration:
|
|
- MCP_SECURITY_ENABLED (default: true) - Enable MCP security validation
|
|
- MCP_AUTO_APPROVE (default: false) - Enable auto-approval
|
|
- MCP_AUTO_APPROVE=everywhere|subagent_only|disabled
|
|
|
|
Usage:
|
|
# Hook is automatically invoked by Claude Code
|
|
# Returns {"approved": True/False, "reason": "..."}
|
|
|
|
Date: 2025-12-08
|
|
Issue: Hook collision between auto_approve_tool.py and mcp_security_enforcer.py
|
|
Agent: implementer
|
|
Phase: Refactoring (eliminate hook collision)
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Dict, Any, Optional
|
|
|
|
# Add lib directory to path for imports
|
|
LIB_DIR = Path(__file__).parent.parent / "lib"
|
|
sys.path.insert(0, str(LIB_DIR))
|
|
|
|
# Load .env file if available (for environment variable configuration)
|
|
def _load_env_file():
|
|
"""Load .env file from project root if it exists.
|
|
|
|
This enables configuration via .env files (MCP_AUTO_APPROVE, MCP_SECURITY_ENABLED, etc.)
|
|
without requiring python-dotenv as a dependency.
|
|
"""
|
|
# Try multiple locations for .env file
|
|
possible_env_files = [
|
|
Path(os.getenv("PROJECT_ROOT", os.getcwd())) / ".env", # Project root
|
|
Path.cwd() / ".env", # Current directory
|
|
Path.home() / ".env", # User home directory
|
|
]
|
|
|
|
for env_file in possible_env_files:
|
|
if env_file.exists():
|
|
try:
|
|
with open(env_file, 'r') as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
# Skip comments and empty lines
|
|
if not line or line.startswith('#'):
|
|
continue
|
|
# Parse KEY=VALUE format
|
|
if '=' in line:
|
|
key, value = line.split('=', 1)
|
|
key = key.strip()
|
|
value = value.strip().strip('"').strip("'") # Remove quotes
|
|
# Only set if not already in environment
|
|
if key not in os.environ:
|
|
os.environ[key] = value
|
|
return # Stop after first .env file found
|
|
except Exception:
|
|
pass # Silently skip unreadable .env files
|
|
|
|
# Load .env file at module import time
|
|
_load_env_file()
|
|
|
|
# Import validators (with graceful degradation)
|
|
try:
|
|
from mcp_permission_validator import MCPPermissionValidator, ValidationResult
|
|
MCP_SECURITY_AVAILABLE = True
|
|
except ImportError:
|
|
MCPPermissionValidator = None
|
|
ValidationResult = None
|
|
MCP_SECURITY_AVAILABLE = False
|
|
|
|
try:
|
|
from tool_validator import ToolValidator, load_policy
|
|
from tool_approval_audit import ToolApprovalAuditor
|
|
from auto_approval_consent import check_user_consent, get_auto_approval_mode
|
|
from user_state_manager import DEFAULT_STATE_FILE
|
|
AUTO_APPROVAL_AVAILABLE = True
|
|
except ImportError:
|
|
ToolValidator = None
|
|
ToolApprovalAuditor = None
|
|
check_user_consent = None
|
|
get_auto_approval_mode = None
|
|
AUTO_APPROVAL_AVAILABLE = False
|
|
|
|
|
|
# ============================================================================
|
|
# Configuration
|
|
# ============================================================================
|
|
|
|
def is_mcp_security_enabled() -> bool:
|
|
"""Check if MCP security validation is enabled.
|
|
|
|
Returns:
|
|
True if enabled (default), False if disabled
|
|
"""
|
|
enabled = os.getenv("MCP_SECURITY_ENABLED", "true").lower()
|
|
return enabled in ["true", "1", "yes", "on", "enable"]
|
|
|
|
|
|
# ============================================================================
|
|
# Validator 1: MCP Security (for mcp__* tools only)
|
|
# ============================================================================
|
|
|
|
def validate_mcp_security(
|
|
tool: str,
|
|
parameters: Dict[str, Any],
|
|
project_root: str
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""Validate MCP tool against security policy.
|
|
|
|
This validator only runs for mcp__* tools. Non-MCP tools return None
|
|
(pass through to next validator).
|
|
|
|
Args:
|
|
tool: Tool name (e.g., "mcp__filesystem__read")
|
|
parameters: Tool parameters
|
|
project_root: Project root directory
|
|
|
|
Returns:
|
|
{"approved": False, "reason": "..."} if denied
|
|
None if passed (continue to next validator)
|
|
"""
|
|
# Only validate MCP tools
|
|
if not tool.startswith("mcp__"):
|
|
return None # Pass through to next validator
|
|
|
|
# Check if MCP security is enabled
|
|
if not is_mcp_security_enabled():
|
|
return None # Security disabled, pass through
|
|
|
|
# Check if validator is available
|
|
if not MCP_SECURITY_AVAILABLE or MCPPermissionValidator is None:
|
|
return {
|
|
"approved": False,
|
|
"reason": "MCP security libraries not available (manual approval required)"
|
|
}
|
|
|
|
# Parse MCP tool format (mcp__category__operation)
|
|
parts = tool.split("__")
|
|
if len(parts) < 3:
|
|
return {
|
|
"approved": False,
|
|
"reason": f"Invalid MCP tool format: {tool} (expected mcp__category__operation)"
|
|
}
|
|
|
|
category = parts[1] # filesystem, shell, network, env
|
|
operation = parts[2] # read, write, execute, access
|
|
|
|
# Detect policy file
|
|
policy_file = Path(project_root) / ".mcp" / "security_policy.json"
|
|
policy_path = str(policy_file) if policy_file.exists() else None
|
|
|
|
# Create validator
|
|
validator = MCPPermissionValidator(policy_path=policy_path)
|
|
validator.project_root = project_root
|
|
|
|
# Route to appropriate validation method
|
|
result = None
|
|
|
|
if category == "filesystem" or category == "fs":
|
|
path = parameters.get("path")
|
|
if not path:
|
|
return {"approved": False, "reason": "Missing path parameter"}
|
|
|
|
if operation == "read":
|
|
result = validator.validate_fs_read(path)
|
|
elif operation == "write":
|
|
result = validator.validate_fs_write(path)
|
|
else:
|
|
return {"approved": False, "reason": f"Unknown filesystem operation: {operation}"}
|
|
|
|
elif category == "shell":
|
|
command = parameters.get("command")
|
|
if not command:
|
|
return {"approved": False, "reason": "Missing command parameter"}
|
|
result = validator.validate_shell(command)
|
|
|
|
elif category == "network":
|
|
url = parameters.get("url")
|
|
if not url:
|
|
return {"approved": False, "reason": "Missing url parameter"}
|
|
result = validator.validate_network(url)
|
|
|
|
elif category == "env":
|
|
var_name = parameters.get("name") or parameters.get("variable")
|
|
if not var_name:
|
|
return {"approved": False, "reason": "Missing variable name parameter"}
|
|
result = validator.validate_env(var_name)
|
|
|
|
else:
|
|
return {"approved": False, "reason": f"Unknown MCP category: {category}"}
|
|
|
|
# If validation failed, deny
|
|
if result and not result.approved:
|
|
return {"approved": False, "reason": result.reason}
|
|
|
|
# Validation passed, continue to next validator
|
|
return None
|
|
|
|
|
|
# ============================================================================
|
|
# Validator 2: Auto-Approval (for all tools)
|
|
# ============================================================================
|
|
|
|
def validate_auto_approval(
|
|
tool: str,
|
|
parameters: Dict[str, Any],
|
|
agent_name: Optional[str]
|
|
) -> Dict[str, Any]:
|
|
"""Validate tool call against auto-approval policy.
|
|
|
|
This validator runs for ALL tools (both MCP and non-MCP).
|
|
|
|
Args:
|
|
tool: Tool name
|
|
parameters: Tool parameters
|
|
agent_name: Agent name (from CLAUDE_AGENT_NAME env var)
|
|
|
|
Returns:
|
|
{"approved": True/False, "reason": "..."}
|
|
"""
|
|
# Check if auto-approval is available
|
|
if not AUTO_APPROVAL_AVAILABLE:
|
|
return {
|
|
"approved": False,
|
|
"reason": "Auto-approval libraries not available (manual approval required)"
|
|
}
|
|
|
|
# Import the auto-approval logic from shared library
|
|
# (This preserves all the existing logic without duplication)
|
|
try:
|
|
# Import from lib directory (already in sys.path from imports at top)
|
|
from auto_approval_engine import should_auto_approve
|
|
|
|
# Run auto-approval validation
|
|
approved, reason = should_auto_approve(tool, parameters, agent_name)
|
|
|
|
return {"approved": approved, "reason": reason}
|
|
|
|
except ImportError as e:
|
|
# Graceful degradation - library not available
|
|
return {
|
|
"approved": False,
|
|
"reason": f"Auto-approval engine not available: {e}"
|
|
}
|
|
except Exception as e:
|
|
# Graceful degradation - unexpected error
|
|
return {
|
|
"approved": False,
|
|
"reason": f"Auto-approval error (defaulting to manual): {e}"
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# Format Conversion Helper
|
|
# ============================================================================
|
|
|
|
def _convert_to_claude_format(approved: bool, reason: str) -> Dict[str, Any]:
|
|
"""Convert internal format to Claude Code's expected format.
|
|
|
|
Internal format: {"approved": bool, "reason": str}
|
|
Claude Code format: {
|
|
"hookSpecificOutput": {
|
|
"hookEventName": "PreToolUse",
|
|
"permissionDecision": "allow" | "deny" | "ask",
|
|
"permissionDecisionReason": str
|
|
}
|
|
}
|
|
|
|
Args:
|
|
approved: Whether to approve the tool call
|
|
reason: Human-readable explanation
|
|
|
|
Returns:
|
|
Dictionary in Claude Code's expected format
|
|
"""
|
|
return {
|
|
"hookSpecificOutput": {
|
|
"hookEventName": "PreToolUse",
|
|
"permissionDecision": "allow" if approved else "deny",
|
|
"permissionDecisionReason": reason
|
|
}
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# Unified Hook Entry Point
|
|
# ============================================================================
|
|
|
|
def on_pre_tool_use(tool: str, parameters: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Unified PreToolUse lifecycle hook (chains validators).
|
|
|
|
This hook chains two validators in order:
|
|
1. MCP Security (for mcp__* tools) - Prevents security vulnerabilities
|
|
2. Auto-Approval (for all tools) - Whitelist/blacklist logic
|
|
|
|
Args:
|
|
tool: Tool name (e.g., "Bash", "Read", "mcp__filesystem__read")
|
|
parameters: Tool parameters dictionary
|
|
|
|
Returns:
|
|
Dictionary with Claude Code's expected format:
|
|
{
|
|
"hookSpecificOutput": {
|
|
"hookEventName": "PreToolUse",
|
|
"permissionDecision": "allow" | "deny" | "ask",
|
|
"permissionDecisionReason": "explanation"
|
|
}
|
|
}
|
|
|
|
Error Handling:
|
|
- Graceful degradation: Any error results in manual approval
|
|
- Missing dependencies: Returns manual approval
|
|
"""
|
|
try:
|
|
# Get project root
|
|
project_root = os.getenv("PROJECT_ROOT", os.getcwd())
|
|
|
|
# Get agent name
|
|
agent_name = os.getenv("CLAUDE_AGENT_NAME", "").strip()
|
|
agent_name = agent_name if agent_name else None
|
|
|
|
# ========================================
|
|
# Step 1: MCP Security Validation
|
|
# ========================================
|
|
mcp_result = validate_mcp_security(tool, parameters, project_root)
|
|
|
|
# If MCP security denied, return immediately
|
|
if mcp_result is not None and not mcp_result.get("approved", False):
|
|
_log_denial(tool, parameters, agent_name, mcp_result["reason"], security_risk=True)
|
|
return _convert_to_claude_format(False, mcp_result["reason"])
|
|
|
|
# ========================================
|
|
# Step 2: Auto-Approval Validation
|
|
# ========================================
|
|
approval_result = validate_auto_approval(tool, parameters, agent_name)
|
|
|
|
# Log decision
|
|
if approval_result["approved"]:
|
|
_log_approval(tool, parameters, agent_name, approval_result["reason"])
|
|
else:
|
|
_log_denial(
|
|
tool, parameters, agent_name, approval_result["reason"],
|
|
security_risk="blacklist" in approval_result["reason"].lower()
|
|
)
|
|
|
|
return _convert_to_claude_format(
|
|
approval_result["approved"],
|
|
approval_result["reason"]
|
|
)
|
|
|
|
except Exception as e:
|
|
# Graceful degradation - deny on error
|
|
reason = f"Unified hook error (defaulting to manual): {e}"
|
|
_log_denial(tool, parameters, None, reason, security_risk=False)
|
|
|
|
return _convert_to_claude_format(False, reason)
|
|
|
|
|
|
# ============================================================================
|
|
# Logging Helpers
|
|
# ============================================================================
|
|
|
|
def _log_approval(
|
|
tool: str,
|
|
parameters: Dict[str, Any],
|
|
agent_name: Optional[str],
|
|
reason: str
|
|
) -> None:
|
|
"""Log approval decision."""
|
|
if not AUTO_APPROVAL_AVAILABLE or ToolApprovalAuditor is None:
|
|
return
|
|
|
|
try:
|
|
auditor = ToolApprovalAuditor()
|
|
auditor.log_approval(
|
|
agent_name=agent_name or "unknown",
|
|
tool=tool,
|
|
parameters=parameters,
|
|
reason=reason
|
|
)
|
|
except Exception:
|
|
pass # Silent failure
|
|
|
|
|
|
def _log_denial(
|
|
tool: str,
|
|
parameters: Dict[str, Any],
|
|
agent_name: Optional[str],
|
|
reason: str,
|
|
security_risk: bool
|
|
) -> None:
|
|
"""Log denial decision."""
|
|
if not AUTO_APPROVAL_AVAILABLE or ToolApprovalAuditor is None:
|
|
return
|
|
|
|
try:
|
|
auditor = ToolApprovalAuditor()
|
|
auditor.log_denial(
|
|
agent_name=agent_name or "unknown",
|
|
tool=tool,
|
|
parameters=parameters,
|
|
reason=reason,
|
|
security_risk=security_risk
|
|
)
|
|
except Exception:
|
|
pass # Silent failure
|
|
|
|
|
|
# ============================================================================
|
|
# Module Test
|
|
# ============================================================================
|
|
|
|
if __name__ == "__main__":
|
|
# Test cases
|
|
print("Testing unified hook...")
|
|
|
|
# Test 1: MCP security validation
|
|
result = on_pre_tool_use(
|
|
"mcp__filesystem__read",
|
|
{"path": "/etc/passwd"}
|
|
)
|
|
print(f"MCP read /etc/passwd: {result}")
|
|
|
|
# Test 2: Auto-approval for safe command
|
|
result = on_pre_tool_use(
|
|
"Bash",
|
|
{"command": "pytest tests/"}
|
|
)
|
|
print(f"Bash pytest: {result}")
|
|
|
|
# Test 3: Auto-approval for dangerous command
|
|
result = on_pre_tool_use(
|
|
"Bash",
|
|
{"command": "rm -rf /"}
|
|
)
|
|
print(f"Bash rm -rf: {result}")
|
|
|
|
print("Done!")
|