Unverified 提交 446c953b authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Add python permission hook (#2312)

#skip-bb <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Add a Python permission hook that restricts python/python3 to scripts inside the .claude directory. Blocks unsafe modes and command injection to reduce risk. - **New Features** - Enforces: allow scripts under .claude; deny scripts outside; deny -m, -c, and interactive; passthrough for non-python and --version/--help. - Robust parsing of env-var prefixes, flags, quoted/unquoted paths, relative/absolute paths, and symlinks; supports CLAUDE_PROJECT_DIR. - Registered the hook in PreToolUse and expanded allowed tools in settings (Bash(chmod:*), Bash(python3:*)). Added tests for allowed, blocked, passthrough, and security-bypass commands. <sup>Written for commit 798d1abf04cdedc5395603ce4e32b2b943be8941. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: 's avatarClaude <noreply@anthropic.com>
上级 ff360a09
差异被折叠。
# Commands that should be BLOCKED by the python-permission-hook
# Format: one command per line, lines starting with # are comments
# These are python commands that attempt to run scripts OUTSIDE the .claude directory
# =============================================================================
# SCRIPTS IN CURRENT DIRECTORY
# =============================================================================
python script.py
python3 script.py
python test.py
python3 test.py
python ./script.py
python3 ./script.py
# =============================================================================
# SCRIPTS IN OTHER DIRECTORIES
# =============================================================================
python src/script.py
python3 src/script.py
python src/test.py
python3 src/test.py
python lib/utils.py
python3 lib/utils.py
# =============================================================================
# ABSOLUTE PATHS OUTSIDE .claude
# =============================================================================
python /usr/local/bin/script.py
python3 /usr/local/bin/script.py
python /tmp/malicious.py
python3 /tmp/malicious.py
python /home/user/script.py
python3 /home/user/script.py
# =============================================================================
# PATH TRAVERSAL ATTEMPTS
# =============================================================================
python ../malicious.py
python3 ../malicious.py
python ../../escape.py
python3 ../../escape.py
python .claude/../escape.py
python3 .claude/../escape.py
# =============================================================================
# QUOTED PATHS OUTSIDE .claude
# =============================================================================
python "script.py"
python3 "script.py"
python 'script.py'
python3 'script.py'
python "/tmp/malicious.py"
python3 "/tmp/malicious.py"
# =============================================================================
# WITH FLAGS
# =============================================================================
python -u script.py
python3 -u script.py
python -B /tmp/malicious.py
python3 -B /tmp/malicious.py
# =============================================================================
# WITH ENVIRONMENT VARIABLES
# =============================================================================
FOO=bar python script.py
FOO=bar python3 script.py
DEBUG=1 python /tmp/test.py
DEBUG=1 python3 /tmp/test.py
# Commands that should be ALLOWED by the python-permission-hook
# Format: one command per line, lines starting with # are comments
# These are python commands that run scripts inside the .claude directory
# =============================================================================
# BASIC PYTHON COMMANDS
# =============================================================================
# Simple script execution
python .claude/script.py
python3 .claude/script.py
python .claude/test.py
python3 .claude/test.py
# Scripts in subdirectories
python .claude/hooks/test.py
python3 .claude/hooks/test.py
python .claude/hooks/tests/test.py
python3 .claude/hooks/tests/test.py
# =============================================================================
# QUOTED PATHS
# =============================================================================
python ".claude/script.py"
python3 ".claude/script.py"
python '.claude/script.py'
python3 '.claude/script.py'
python ".claude/hooks/test.py"
python3 ".claude/hooks/test.py"
# =============================================================================
# WITH FLAGS
# =============================================================================
python -u .claude/script.py
python3 -u .claude/script.py
python -B .claude/script.py
python3 -B .claude/script.py
python -u -B .claude/script.py
python3 -u -B .claude/script.py
# =============================================================================
# WITH ENVIRONMENT VARIABLES
# =============================================================================
FOO=bar python .claude/script.py
FOO=bar python3 .claude/script.py
DEBUG=1 python .claude/hooks/test.py
DEBUG=1 python3 .claude/hooks/test.py
FOO=bar BAZ=qux python .claude/script.py
PYTHONPATH=/some/path python .claude/script.py
# =============================================================================
# FULL PYTHON PATHS
# =============================================================================
/usr/bin/python .claude/script.py
/usr/bin/python3 .claude/script.py
/usr/local/bin/python .claude/script.py
/usr/local/bin/python3 .claude/script.py
# =============================================================================
# ENV PYTHON (various forms)
# =============================================================================
env python .claude/script.py
env python3 .claude/script.py
/usr/bin/env python .claude/script.py
/usr/bin/env python3 .claude/script.py
# =============================================================================
# VIRTUALENV / PYENV / CONDA PATHS
# =============================================================================
./venv/bin/python .claude/script.py
./venv/bin/python3 .claude/script.py
.venv/bin/python .claude/script.py
/home/user/.pyenv/shims/python .claude/script.py
/opt/conda/bin/python .claude/script.py
# =============================================================================
# FLAGS WITH ARGUMENTS (-W, -X)
# =============================================================================
python -W ignore .claude/script.py
python3 -W error .claude/script.py
python -X dev .claude/script.py
python3 -X utf8 .claude/script.py
python -W ignore -X dev .claude/script.py
# =============================================================================
# END-OF-OPTIONS DELIMITER (--)
# =============================================================================
python -- .claude/script.py
python3 -- .claude/script.py
python -u -- .claude/script.py
# Commands that should PASSTHROUGH (not be handled by python hook)
# These result in decision: 'none' - the hook doesn't make a decision
# Format: one command per line, lines starting with # are comments
# =============================================================================
# NON-PYTHON COMMANDS
# =============================================================================
# Basic shell commands
ls -la
cat file.txt
echo "hello"
npm install
git status
# Other interpreters
node script.js
ruby script.rb
perl script.pl
# =============================================================================
# PYTHON VERSION/HELP (passthrough to normal permission flow)
# =============================================================================
python --version
python3 --version
python -V
python3 -V
python --help
python3 --help
python -h
python3 -h
# Commands that should be BLOCKED due to security bypass attempts
# These test the new security features (shell injection, -m, -c, interactive mode)
# Format: one command per line, lines starting with # are comments
# =============================================================================
# SHELL INJECTION / COMMAND CHAINING
# =============================================================================
# Command separator
python .claude/script.py; malicious_command
python3 .claude/script.py; rm -rf /
# Logical AND
python .claude/script.py && malicious_command
python3 .claude/script.py && rm -rf /
# Logical OR
python .claude/script.py || malicious_command
python3 .claude/script.py || rm -rf /
# Pipe to another command
python .claude/script.py | cat
python3 .claude/script.py | sh
# Background + another command
python .claude/script.py & malicious_command
python3 .claude/script.py &rm -rf /
# Command substitution with $()
python .claude/script.py $(rm -rf /)
python3 .claude/script.py $(malicious_command)
# Command substitution with backticks
python .claude/script.py `rm -rf /`
python3 .claude/script.py `malicious_command`
# Process substitution
python <(echo "malicious code")
python3 <(cat /tmp/malicious.py)
# Input redirection (stdin)
python < /tmp/malicious.py
python3 < /tmp/script.py
python < script.py
python3 </tmp/malicious.py
# Newline command separation (encoded)
# Note: actual newlines in test file would break parsing
# =============================================================================
# MODULE EXECUTION (-m flag)
# =============================================================================
python -m http.server
python3 -m http.server
python -m pip install malicious_package
python3 -m pip install malicious_package
python -m runpy /tmp/malicious.py
python3 -m runpy script_outside_claude.py
python -m unittest discover
python3 -m pytest
# =============================================================================
# INLINE CODE EXECUTION (-c flag)
# =============================================================================
python -c "print('hello')"
python3 -c "print('hello')"
python -c 'import os; os.system("rm -rf /")'
python3 -c 'exec(open("/tmp/malicious.py").read())'
python -c "import subprocess; subprocess.call(['malicious'])"
python3 -c "__import__('os').system('whoami')"
# =============================================================================
# INTERACTIVE MODE (stdin redirection risk)
# =============================================================================
python
python3
# =============================================================================
# ENV PYTHON WITH DANGEROUS FLAGS
# =============================================================================
env python -m http.server
env python3 -c "print('hello')"
/usr/bin/env python -m pip install malicious
/usr/bin/env python3 -c "import os"
# =============================================================================
# HEREDOC / HERE-STRING ATTEMPTS
# =============================================================================
python <<EOF
python3 <<'EOF'
python <<<'print(1)'
python3 <<<"import os"
# =============================================================================
# COMBINED FLAGS BYPASS ATTEMPTS
# =============================================================================
# Combined -c flag (e.g., -Bc = -B + -c)
python -Bc "print('hello')"
python3 -Bc "import os"
python -uBc "malicious code"
python3 -OBc "__import__('os').system('whoami')"
# Combined -m flag (e.g., -um = -u + -m)
python -um http.server
python3 -Bm pip install malicious
python -uBm runpy /tmp/malicious.py
python3 -OBm pytest
#!/usr/bin/env python3
"""
Unit tests for python-permission-hook.py
This test loads commands from python_good_commands.txt and python_bad_commands.txt
and verifies that the hook correctly allows/denies them.
Run with: python .claude/hooks/tests/test_python_permission_hook.py
"""
import json
import subprocess
import sys
from pathlib import Path
def load_commands(filename: str) -> list[str]:
"""Load commands from a file, ignoring comments and empty lines."""
filepath = Path(__file__).parent / filename
commands = []
with open(filepath, "r") as f:
for line in f:
line = line.strip()
# Skip empty lines and comments
if line and not line.startswith("#"):
commands.append(line)
return commands
def run_hook(command: str) -> dict:
"""
Run the permission hook with the given command and return the result.
Returns a dict with:
- 'decision': 'allow', 'deny', or 'none' (no decision/passthrough)
- 'reason': the reason string if a decision was made
"""
hook_path = Path(__file__).parent.parent / "python-permission-hook.py"
input_data = json.dumps({
"tool_name": "Bash",
"tool_input": {
"command": command
}
})
result = subprocess.run(
[sys.executable, str(hook_path)],
input=input_data,
capture_output=True,
text=True
)
if result.stdout.strip():
try:
output = json.loads(result.stdout.strip())
hook_output = output.get("hookSpecificOutput", {})
return {
"decision": hook_output.get("permissionDecision", "none"),
"reason": hook_output.get("permissionDecisionReason", "")
}
except json.JSONDecodeError:
return {"decision": "none", "reason": f"Invalid JSON output: {result.stdout}"}
return {"decision": "none", "reason": "No output (passthrough)"}
def test_good_commands() -> tuple[int, int, list[str]]:
"""Test that good commands are allowed."""
commands = load_commands("python_good_commands.txt")
passed = 0
failed = 0
failures = []
for cmd in commands:
result = run_hook(cmd)
# Good commands should be 'allow'
if result["decision"] != "allow":
failed += 1
failures.append(f" FAIL (not allowed): {cmd}\n Decision: {result['decision']}, Reason: {result['reason']}")
else:
passed += 1
return passed, failed, failures
def test_bad_commands() -> tuple[int, int, list[str]]:
"""Test that bad commands are denied."""
commands = load_commands("python_bad_commands.txt")
passed = 0
failed = 0
failures = []
for cmd in commands:
result = run_hook(cmd)
# Bad commands should be 'deny'
if result["decision"] != "deny":
failed += 1
failures.append(f" FAIL (not blocked): {cmd}\n Decision: {result['decision']}, Reason: {result['reason']}")
else:
passed += 1
return passed, failed, failures
def test_passthrough_commands() -> tuple[int, int, list[str]]:
"""Test that commands that should be ignored result in a passthrough."""
commands = load_commands("python_passthrough_commands.txt")
passed = 0
failed = 0
failures = []
for cmd in commands:
result = run_hook(cmd)
# These commands should result in a passthrough ('none')
if result["decision"] != "none":
failed += 1
failures.append(f" FAIL (not passthrough): {cmd}\n Decision: {result['decision']}, Reason: {result['reason']}")
else:
passed += 1
return passed, failed, failures
def test_security_blocked_commands() -> tuple[int, int, list[str]]:
"""Test that security bypass attempts are denied."""
commands = load_commands("python_security_blocked_commands.txt")
passed = 0
failed = 0
failures = []
for cmd in commands:
result = run_hook(cmd)
# Security bypass attempts should be 'deny'
if result["decision"] != "deny":
failed += 1
failures.append(f" FAIL (not blocked): {cmd}\n Decision: {result['decision']}, Reason: {result['reason']}")
else:
passed += 1
return passed, failed, failures
def main():
print("=" * 60)
print("Testing python-permission-hook.py")
print("=" * 60)
print()
# Test good commands
print("Testing GOOD commands (should be allowed)...")
good_passed, good_failed, good_failures = test_good_commands()
print(f" Passed: {good_passed}, Failed: {good_failed}")
if good_failures:
print("\n Failures:")
for failure in good_failures:
print(failure)
print()
# Test bad commands
print("Testing BAD commands (should be blocked)...")
bad_passed, bad_failed, bad_failures = test_bad_commands()
print(f" Passed: {bad_passed}, Failed: {bad_failed}")
if bad_failures:
print("\n Failures:")
for failure in bad_failures:
print(failure)
print()
# Test passthrough commands
print("Testing PASSTHROUGH commands (should not be handled by hook)...")
pass_passed, pass_failed, pass_failures = test_passthrough_commands()
print(f" Passed: {pass_passed}, Failed: {pass_failed}")
if pass_failures:
print("\n Failures:")
for failure in pass_failures:
print(failure)
print()
# Test security blocked commands
print("Testing SECURITY BLOCKED commands (bypass attempts should be denied)...")
sec_passed, sec_failed, sec_failures = test_security_blocked_commands()
print(f" Passed: {sec_passed}, Failed: {sec_failed}")
if sec_failures:
print("\n Failures:")
for failure in sec_failures:
print(failure)
print()
# Summary
print("=" * 60)
total_passed = good_passed + bad_passed + pass_passed + sec_passed
total_failed = good_failed + bad_failed + pass_failed + sec_failed
print(f"TOTAL: {total_passed} passed, {total_failed} failed")
print("=" * 60)
if total_failed > 0:
sys.exit(1)
else:
print("\nAll tests passed!")
sys.exit(0)
if __name__ == "__main__":
main()
......@@ -111,7 +111,11 @@
"Bash(sort:*)",
"Bash(uniq:*)",
"Bash(cut:*)",
"Bash(diff:*)"
"Bash(diff:*)",
"Bash(chmod:*)",
"Bash(python3:*)"
]
},
"hooks": {
......@@ -123,6 +127,11 @@
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/gh-permission-hook.py",
"timeout": 5000
},
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/python-permission-hook.py",
"timeout": 5000
}
]
}
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论