119 lines
3.5 KiB
Python
Executable File
119 lines
3.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
PreToolUse Hook - Simple Standalone Script for Claude Code
|
|
|
|
Reads tool call from stdin, validates it, outputs decision to stdout.
|
|
|
|
Input (stdin):
|
|
{
|
|
"tool_name": "Bash",
|
|
"tool_input": {"command": "pytest tests/"}
|
|
}
|
|
|
|
Output (stdout):
|
|
{
|
|
"hookSpecificOutput": {
|
|
"hookEventName": "PreToolUse",
|
|
"permissionDecision": "allow", # or "deny"
|
|
"permissionDecisionReason": "reason"
|
|
}
|
|
}
|
|
|
|
Exit code: 0 (always - let Claude Code process the decision)
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
import os
|
|
from pathlib import Path
|
|
|
|
# Add lib directory to path
|
|
LIB_DIR = Path(__file__).parent.parent / "lib"
|
|
sys.path.insert(0, str(LIB_DIR))
|
|
|
|
# Load .env file if available
|
|
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
|
|
|
|
load_env()
|
|
|
|
def main():
|
|
"""Main entry point."""
|
|
try:
|
|
# Read input from stdin
|
|
input_data = json.load(sys.stdin)
|
|
|
|
# Extract tool info
|
|
tool_name = input_data.get("tool_name", "")
|
|
tool_input = input_data.get("tool_input", {})
|
|
|
|
# Get agent name from environment
|
|
agent_name = os.getenv("CLAUDE_AGENT_NAME", "").strip() or None
|
|
|
|
# Import and run validation
|
|
try:
|
|
from auto_approval_engine import should_auto_approve
|
|
|
|
approved, reason = should_auto_approve(tool_name, tool_input, agent_name)
|
|
|
|
# Determine three-state decision:
|
|
# 1. approved=True → "allow" (auto-approve)
|
|
# 2. blacklisted/security_risk → "deny" (block entirely)
|
|
# 3. not whitelisted → "ask" (fall back to user)
|
|
if approved:
|
|
permission_decision = "allow"
|
|
elif "blacklist" in reason.lower() or "injection" in reason.lower() or "security" in reason.lower() or "circuit breaker" in reason.lower():
|
|
permission_decision = "deny"
|
|
else:
|
|
# Not whitelisted but not dangerous - ask user
|
|
permission_decision = "ask"
|
|
|
|
except Exception as e:
|
|
# Graceful degradation - ask user on error (don't block)
|
|
permission_decision = "ask"
|
|
reason = f"Auto-approval error: {e}"
|
|
|
|
# Output decision
|
|
decision = {
|
|
"hookSpecificOutput": {
|
|
"hookEventName": "PreToolUse",
|
|
"permissionDecision": permission_decision,
|
|
"permissionDecisionReason": reason
|
|
}
|
|
}
|
|
|
|
print(json.dumps(decision))
|
|
|
|
except Exception as e:
|
|
# Error - ask user (don't block on hook errors)
|
|
decision = {
|
|
"hookSpecificOutput": {
|
|
"hookEventName": "PreToolUse",
|
|
"permissionDecision": "ask",
|
|
"permissionDecisionReason": f"Hook error: {e}"
|
|
}
|
|
}
|
|
print(json.dumps(decision))
|
|
|
|
# Always exit 0 - let Claude Code process the decision
|
|
sys.exit(0)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|