TradingAgents/.claude/hooks/unified_pre_tool.py

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()