Unverified 提交 68f4de78 authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Use Claude Sonnet to decide permission requests (#2319)

## Summary - Fix PermissionRequest hook to use correct hookSpecificOutput JSON wrapper format - Previously the hook output {behavior: allow} but Claude Code expects {hookSpecificOutput: {hookEventName: PermissionRequest, decision: {behavior: allow}}} - Clean up code: remove debug logging, organize imports ## Test plan - Run a command that triggers permission request (e.g., rm -rf somedir) - Verify the hook auto-approves GREEN operations without showing the permission dialog - Test with DEBUG_PERMISSION_HOOK=1 to see hook execution logs Generated with Claude Code <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Uses Claude Sonnet to analyze permission requests and fixes the PermissionRequest hook output format so decisions are applied correctly. GREEN auto-approves, YELLOW passes through, RED auto-denies without showing the dialog. - **Bug Fixes** - Use the correct wrapper: {hookSpecificOutput: {hookEventName: "PermissionRequest", decision: {behavior: "allow"|"deny"}}}. - Improve JSON extraction; remove debug logs and tidy imports. - **New Features** - Add permission-request-hook.py to analyze requests with Claude CLI (sonnet) and auto-approve/deny using permission-policy.md. - Add tests for hook behavior, response schema, and policy coverage. - Enable the hook in .claude/settings.json for all tools. <sup>Written for commit bcdcd4eeda5e28d4cde37247fae8c150c1e9ba1b. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces an AI-driven PermissionRequest hook that evaluates tool actions against a new security policy and auto-approves/denies accordingly, plus tests and settings wiring. > > - **Add** `permission-request-hook.py` to call Claude (model `sonnet`) with `permission-policy.md`, parse JSON robustly, and emit `hookSpecificOutput` for `allow`/`deny` (GREEN auto-approve, RED auto-deny, YELLOW passthrough) > - **Add** comprehensive `permission-policy.md` covering Bash, GitHub, and file operations with GREEN/YELLOW/RED criteria > - **Add** tests in `tests/test_permission_request_hook.py` for hook passthrough behavior, response format, CLI absence, and policy coverage > - **Configure** `.claude/settings.json` to register the new PermissionRequest hook for all tools with a 30s timeout > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit bcdcd4eeda5e28d4cde37247fae8c150c1e9ba1b. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com>
上级 f8304602
差异被折叠。
#!/usr/bin/env python3
"""
AI-Powered Permission Request Hook
This is a PermissionRequest hook that runs when the user would see a permission dialog.
It uses Claude to analyze the request and determine whether to auto-approve or auto-deny.
Safety levels:
- GREEN: Safe operation, auto-approve (user won't see dialog)
- YELLOW: Uncertain, pass to normal flow (user sees dialog and decides)
- RED: Dangerous operation, auto-deny (user won't see dialog, request blocked)
The hook is designed to catch requests that slip through explicit rule-based hooks.
It provides an additional layer of security through semantic understanding.
Usage:
Receives JSON on stdin with tool_name and tool_input
Outputs hookSpecificOutput JSON for allow/deny, or nothing for passthrough
"""
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Optional
def load_policy_guidelines() -> str:
"""Load policy guidelines from the markdown file."""
policy_path = Path(__file__).parent / "permission-policy.md"
try:
return policy_path.read_text()
except FileNotFoundError:
return ""
def analyze_with_claude(tool_name: str, tool_input: dict) -> Optional[dict]:
"""
Use Claude Code CLI to analyze the tool request and determine safety level.
Returns None if analysis fails or is unavailable.
"""
claude_path = shutil.which("claude")
if not claude_path:
home = Path.home()
default_path = home / ".claude" / "local" / "claude"
if default_path.exists():
claude_path = str(default_path)
else:
return None
if tool_name == "Bash":
command = tool_input.get("command", "")
request_description = f"Bash command: {command}"
elif tool_name in ("Edit", "Write"):
file_path = tool_input.get("file_path", "")
request_description = f"{tool_name} to file: {file_path}"
else:
request_description = f"{tool_name}: {json.dumps(tool_input)}"
cwd = os.getcwd()
project_dir = os.environ.get("CLAUDE_PROJECT_DIR", cwd)
policy = load_policy_guidelines()
if not policy:
return None
prompt = f"""{policy}
## Current Request
Working directory: {cwd}
Project directory: {project_dir}
Request to analyze:
{request_description}
Analyze this request and provide your safety assessment. Respond with ONLY a JSON object, no other text."""
try:
result = subprocess.run(
[
claude_path,
"--print",
"--output-format", "text",
"--model", "sonnet",
prompt
],
capture_output=True,
text=True,
timeout=25,
cwd=project_dir,
)
if result.returncode != 0:
return None
response_text = result.stdout.strip()
try:
parsed = json.loads(response_text)
if "score" in parsed and parsed["score"] in ("GREEN", "YELLOW", "RED"):
return parsed
except json.JSONDecodeError:
# Extract JSON from markdown code fences if present
# Use a more robust approach that handles braces in string values
# by finding all potential JSON objects and trying to parse each
start_indices = [i for i, c in enumerate(response_text) if c == '{']
for start in start_indices:
# Find matching closing brace by counting brace depth
depth = 0
in_string = False
escape_next = False
for i, c in enumerate(response_text[start:], start):
if escape_next:
escape_next = False
continue
if c == '\\' and in_string:
escape_next = True
continue
if c == '"' and not escape_next:
in_string = not in_string
continue
if not in_string:
if c == '{':
depth += 1
elif c == '}':
depth -= 1
if depth == 0:
candidate = response_text[start:i + 1]
try:
parsed = json.loads(candidate)
if "score" in parsed and parsed["score"] in ("GREEN", "YELLOW", "RED"):
return parsed
except json.JSONDecodeError:
pass
break
return None
except subprocess.SubprocessError:
return None
def make_allow_decision() -> dict:
"""Auto-approve the permission request."""
return {
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {"behavior": "allow"}
}
}
def make_deny_decision(reason: str) -> dict:
"""Auto-deny the permission request."""
return {
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "deny",
"message": f"[AI-RED] {reason}"
}
}
}
def main():
try:
input_data = json.load(sys.stdin)
except json.JSONDecodeError:
sys.exit(0)
tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input")
if not isinstance(tool_input, dict):
sys.exit(0)
result = analyze_with_claude(tool_name, tool_input)
if result is None:
sys.exit(0)
score = result.get("score")
reason = result.get("reason", "No reason provided")
if score == "GREEN":
print(json.dumps(make_allow_decision()))
elif score == "RED":
print(json.dumps(make_deny_decision(reason)))
# YELLOW: no output, fall through to normal permission flow
sys.exit(0)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Tests for the AI-powered PermissionRequest hook.
This is a PermissionRequest hook (not PreToolUse) that runs when the user
would see a permission dialog. It can auto-approve or auto-deny.
Response format: { "hookSpecificOutput": { "hookEventName": "PermissionRequest", "decision": { "behavior": "allow" | "deny" } } }
"""
import json
import subprocess
import sys
from pathlib import Path
import pytest
# Get the hook path
HOOK_PATH = Path(__file__).parent.parent / "permission-request-hook.py"
def run_hook(tool_name: str, tool_input: dict) -> tuple[int, str]:
"""Run the hook with the given input and return (returncode, stdout)."""
input_data = json.dumps({"tool_name": tool_name, "tool_input": tool_input})
result = subprocess.run(
[sys.executable, str(HOOK_PATH)],
input=input_data,
capture_output=True,
text=True,
)
return result.returncode, result.stdout
def parse_response(stdout: str) -> dict | None:
"""Parse the hook response, return None if empty/invalid."""
if not stdout.strip():
return None
try:
return json.loads(stdout)
except json.JSONDecodeError:
return None
class TestHookBasics:
"""Test basic hook behavior without AI."""
def test_invalid_json_passthrough(self):
"""Invalid JSON should pass through (exit 0, no output)."""
result = subprocess.run(
[sys.executable, str(HOOK_PATH)],
input="not valid json",
capture_output=True,
text=True,
)
assert result.returncode == 0
assert result.stdout == ""
def test_non_dict_tool_input_passthrough(self):
"""Non-dict tool_input should pass through."""
returncode, stdout = run_hook("Bash", "not a dict") # type: ignore
assert returncode == 0
assert stdout == ""
def test_all_tools_analyzed(self):
"""All tools should be analyzed (matcher is *)."""
# Without claude CLI available, all tools pass through
# but the hook should attempt to analyze them
returncode, _stdout = run_hook("Read", {"file_path": "/etc/passwd"})
assert returncode == 0
# Passes through because claude CLI analysis returns None
def test_bash_commands_analyzed(self):
"""Bash commands should be analyzed including gh and python."""
returncode, _stdout = run_hook("Bash", {"command": "gh pr view 123"})
assert returncode == 0
# All commands go through AI analysis now
class TestNoCLI:
"""Test behavior when claude CLI is not available."""
def test_no_claude_cli_passthrough(self, monkeypatch):
"""Without claude CLI in PATH, should pass through (user decides)."""
# Remove claude from PATH by setting empty PATH
monkeypatch.setenv("PATH", "/nonexistent")
returncode, stdout = run_hook("Bash", {"command": "ls -la"})
assert returncode == 0
# Without claude CLI, passes through to normal permission flow
assert parse_response(stdout) is None # No decision = user decides
class TestResponseFormat:
"""Test that responses follow PermissionRequest format."""
def test_response_format_documented(self):
"""Response format should be documented in the hook."""
hook_content = HOOK_PATH.read_text()
assert '"behavior"' in hook_content
assert '"allow"' in hook_content
assert '"deny"' in hook_content
class TestPolicyGuidelines:
"""Test that policy guidelines cover expected cases."""
# Policy is now in a separate markdown file
POLICY_PATH = HOOK_PATH.parent / "permission-policy.md"
def test_policy_file_exists(self):
"""Policy file should exist."""
assert self.POLICY_PATH.exists()
def test_policy_has_green_section(self):
"""Policy should have GREEN section."""
policy_content = self.POLICY_PATH.read_text()
assert "### GREEN" in policy_content
assert "Safe - Auto-approve" in policy_content
def test_policy_has_yellow_section(self):
"""Policy should have YELLOW section."""
policy_content = self.POLICY_PATH.read_text()
assert "### YELLOW" in policy_content
assert "Uncertain - User decides" in policy_content
def test_policy_has_red_section(self):
"""Policy should have RED section."""
policy_content = self.POLICY_PATH.read_text()
assert "### RED" in policy_content
assert "Dangerous - Block" in policy_content
def test_policy_covers_rm_rf(self):
"""Policy should mention rm -rf as dangerous."""
policy_content = self.POLICY_PATH.read_text()
assert "rm -rf" in policy_content
def test_policy_covers_git_force_push(self):
"""Policy should mention git push --force as dangerous."""
policy_content = self.POLICY_PATH.read_text()
assert "git push --force" in policy_content
def test_policy_covers_shell_patterns(self):
"""Policy should mention shell patterns requiring inspection."""
policy_content = self.POLICY_PATH.read_text()
assert "Shell patterns requiring inspection" in policy_content
assert "Command chaining" in policy_content
def test_policy_covers_curl_pipe_sh(self):
"""Policy should mention curl | sh as dangerous."""
policy_content = self.POLICY_PATH.read_text()
assert "curl | sh" in policy_content or "curl | bash" in policy_content
def test_policy_covers_safe_commands(self):
"""Policy should list safe commands."""
policy_content = self.POLICY_PATH.read_text()
assert "ls" in policy_content
assert "cat" in policy_content
assert "grep" in policy_content
assert "git status" in policy_content
if __name__ == "__main__":
pytest.main([__file__, "-v"])
...@@ -135,6 +135,18 @@ ...@@ -135,6 +135,18 @@
} }
] ]
} }
],
"PermissionRequest": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/permission-request-hook.py",
"timeout": 30000
}
]
}
] ]
} }
} }
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论