mirror of
https://github.com/anthropics/claude-code.git
synced 2026-05-15 05:42:44 +00:00
This adds a workaround for issue #12172 where the Write tool ignores umask and creates files with hardcoded 0600 permissions. The hook: - Runs after Write/Edit tool operations - Detects files with restrictive 0600 permissions - Applies umask-respecting permissions (e.g., 0644 with umask 022) - Includes comprehensive test suite with 15 test cases Users can configure this hook in their settings to fix file permissions until the underlying issue in the Write tool is resolved.
279 lines
9.7 KiB
Python
Executable File
279 lines
9.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Tests for the fix_file_permissions_example.py hook.
|
|
|
|
Run these tests with:
|
|
python3 examples/hooks/test_fix_file_permissions.py
|
|
|
|
Or with pytest:
|
|
pytest examples/hooks/test_fix_file_permissions.py -v
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
# Path to the hook script
|
|
HOOK_SCRIPT = Path(__file__).parent / "fix_file_permissions_example.py"
|
|
|
|
|
|
class TestFixFilePermissionsHook(unittest.TestCase):
|
|
"""Test cases for the file permissions fix hook."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
self.temp_dir = tempfile.mkdtemp()
|
|
self.test_file = os.path.join(self.temp_dir, "test_file.txt")
|
|
|
|
def tearDown(self):
|
|
"""Clean up test files."""
|
|
if os.path.exists(self.test_file):
|
|
os.remove(self.test_file)
|
|
if os.path.exists(self.temp_dir):
|
|
os.rmdir(self.temp_dir)
|
|
|
|
def run_hook(self, tool_name: str, file_path: str) -> subprocess.CompletedProcess:
|
|
"""Run the hook script with given input."""
|
|
input_data = {
|
|
"tool_name": tool_name,
|
|
"tool_input": {"file_path": file_path},
|
|
"session_id": "test-session",
|
|
"cwd": os.getcwd(),
|
|
}
|
|
|
|
result = subprocess.run(
|
|
[sys.executable, str(HOOK_SCRIPT)],
|
|
input=json.dumps(input_data),
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return result
|
|
|
|
def create_file_with_permissions(self, path: str, mode: int) -> None:
|
|
"""Create a test file with specific permissions."""
|
|
with open(path, "w") as f:
|
|
f.write("test content")
|
|
os.chmod(path, mode)
|
|
|
|
def get_file_permissions(self, path: str) -> int:
|
|
"""Get the permission bits of a file."""
|
|
return stat.S_IMODE(os.stat(path).st_mode)
|
|
|
|
def test_fixes_restrictive_permissions_with_umask_022(self):
|
|
"""Test that 0600 permissions are fixed to 0644 with umask 022."""
|
|
# Save and set umask
|
|
old_umask = os.umask(0o022)
|
|
try:
|
|
# Create file with restrictive permissions (simulating Write tool bug)
|
|
self.create_file_with_permissions(self.test_file, 0o600)
|
|
self.assertEqual(self.get_file_permissions(self.test_file), 0o600)
|
|
|
|
# Run the hook
|
|
result = self.run_hook("Write", self.test_file)
|
|
self.assertEqual(result.returncode, 0)
|
|
|
|
# Check permissions were fixed
|
|
expected_mode = 0o644 # 0666 & ~0022
|
|
self.assertEqual(
|
|
self.get_file_permissions(self.test_file),
|
|
expected_mode,
|
|
f"Expected {oct(expected_mode)}, got {oct(self.get_file_permissions(self.test_file))}",
|
|
)
|
|
finally:
|
|
os.umask(old_umask)
|
|
|
|
def test_fixes_restrictive_permissions_with_umask_002(self):
|
|
"""Test that 0600 permissions are fixed to 0664 with umask 002."""
|
|
# Save and set umask
|
|
old_umask = os.umask(0o002)
|
|
try:
|
|
# Create file with restrictive permissions
|
|
self.create_file_with_permissions(self.test_file, 0o600)
|
|
self.assertEqual(self.get_file_permissions(self.test_file), 0o600)
|
|
|
|
# Run the hook
|
|
result = self.run_hook("Write", self.test_file)
|
|
self.assertEqual(result.returncode, 0)
|
|
|
|
# Check permissions were fixed
|
|
expected_mode = 0o664 # 0666 & ~0002
|
|
self.assertEqual(
|
|
self.get_file_permissions(self.test_file),
|
|
expected_mode,
|
|
f"Expected {oct(expected_mode)}, got {oct(self.get_file_permissions(self.test_file))}",
|
|
)
|
|
finally:
|
|
os.umask(old_umask)
|
|
|
|
def test_preserves_permissions_matching_umask(self):
|
|
"""Test that permissions already matching umask are not changed."""
|
|
old_umask = os.umask(0o022)
|
|
try:
|
|
# Create file with correct permissions already
|
|
self.create_file_with_permissions(self.test_file, 0o644)
|
|
|
|
# Run the hook
|
|
result = self.run_hook("Write", self.test_file)
|
|
self.assertEqual(result.returncode, 0)
|
|
|
|
# Permissions should be unchanged
|
|
self.assertEqual(self.get_file_permissions(self.test_file), 0o644)
|
|
finally:
|
|
os.umask(old_umask)
|
|
|
|
def test_respects_umask_077(self):
|
|
"""Test that umask 077 results in 0600 (no change needed)."""
|
|
old_umask = os.umask(0o077)
|
|
try:
|
|
# Create file with 0600 permissions
|
|
self.create_file_with_permissions(self.test_file, 0o600)
|
|
|
|
# Run the hook
|
|
result = self.run_hook("Write", self.test_file)
|
|
self.assertEqual(result.returncode, 0)
|
|
|
|
# With umask 077, 0600 is correct - should remain unchanged
|
|
self.assertEqual(self.get_file_permissions(self.test_file), 0o600)
|
|
finally:
|
|
os.umask(old_umask)
|
|
|
|
def test_handles_edit_tool(self):
|
|
"""Test that the hook also works for the Edit tool."""
|
|
old_umask = os.umask(0o022)
|
|
try:
|
|
self.create_file_with_permissions(self.test_file, 0o600)
|
|
|
|
result = self.run_hook("Edit", self.test_file)
|
|
self.assertEqual(result.returncode, 0)
|
|
|
|
self.assertEqual(self.get_file_permissions(self.test_file), 0o644)
|
|
finally:
|
|
os.umask(old_umask)
|
|
|
|
def test_ignores_other_tools(self):
|
|
"""Test that the hook ignores non-Write/Edit tools."""
|
|
old_umask = os.umask(0o022)
|
|
try:
|
|
self.create_file_with_permissions(self.test_file, 0o600)
|
|
|
|
result = self.run_hook("Read", self.test_file)
|
|
self.assertEqual(result.returncode, 0)
|
|
|
|
# Permissions should be unchanged for Read tool
|
|
self.assertEqual(self.get_file_permissions(self.test_file), 0o600)
|
|
finally:
|
|
os.umask(old_umask)
|
|
|
|
def test_handles_nonexistent_file(self):
|
|
"""Test that the hook handles non-existent files gracefully."""
|
|
result = self.run_hook("Write", "/nonexistent/path/file.txt")
|
|
self.assertEqual(result.returncode, 0)
|
|
|
|
def test_handles_empty_file_path(self):
|
|
"""Test that the hook handles empty file path gracefully."""
|
|
input_data = {
|
|
"tool_name": "Write",
|
|
"tool_input": {},
|
|
"session_id": "test-session",
|
|
}
|
|
|
|
result = subprocess.run(
|
|
[sys.executable, str(HOOK_SCRIPT)],
|
|
input=json.dumps(input_data),
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
self.assertEqual(result.returncode, 0)
|
|
|
|
def test_handles_invalid_json(self):
|
|
"""Test that the hook handles invalid JSON input gracefully."""
|
|
result = subprocess.run(
|
|
[sys.executable, str(HOOK_SCRIPT)],
|
|
input="not valid json",
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
# Should exit 0 even with invalid input (don't block the workflow)
|
|
self.assertEqual(result.returncode, 0)
|
|
self.assertIn("Invalid JSON", result.stderr)
|
|
|
|
def test_handles_directory_path(self):
|
|
"""Test that the hook ignores directory paths."""
|
|
result = self.run_hook("Write", self.temp_dir)
|
|
self.assertEqual(result.returncode, 0)
|
|
|
|
def test_outputs_system_message_on_fix(self):
|
|
"""Test that the hook outputs a systemMessage when fixing permissions."""
|
|
old_umask = os.umask(0o022)
|
|
try:
|
|
self.create_file_with_permissions(self.test_file, 0o600)
|
|
|
|
result = self.run_hook("Write", self.test_file)
|
|
self.assertEqual(result.returncode, 0)
|
|
|
|
# Check that stdout contains the systemMessage JSON
|
|
if result.stdout.strip():
|
|
output = json.loads(result.stdout)
|
|
self.assertIn("systemMessage", output)
|
|
self.assertIn("Fixed file permissions", output["systemMessage"])
|
|
finally:
|
|
os.umask(old_umask)
|
|
|
|
|
|
class TestCalculateFilePermissions(unittest.TestCase):
|
|
"""Test the calculate_file_permissions function directly."""
|
|
|
|
def test_umask_022(self):
|
|
"""Test permission calculation with umask 022."""
|
|
# Import the function from the hook script
|
|
import importlib.util
|
|
|
|
spec = importlib.util.spec_from_file_location("hook", HOOK_SCRIPT)
|
|
hook = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(hook)
|
|
|
|
result = hook.calculate_file_permissions(0o022)
|
|
self.assertEqual(result, 0o644)
|
|
|
|
def test_umask_002(self):
|
|
"""Test permission calculation with umask 002."""
|
|
import importlib.util
|
|
|
|
spec = importlib.util.spec_from_file_location("hook", HOOK_SCRIPT)
|
|
hook = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(hook)
|
|
|
|
result = hook.calculate_file_permissions(0o002)
|
|
self.assertEqual(result, 0o664)
|
|
|
|
def test_umask_077(self):
|
|
"""Test permission calculation with umask 077."""
|
|
import importlib.util
|
|
|
|
spec = importlib.util.spec_from_file_location("hook", HOOK_SCRIPT)
|
|
hook = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(hook)
|
|
|
|
result = hook.calculate_file_permissions(0o077)
|
|
self.assertEqual(result, 0o600)
|
|
|
|
def test_umask_000(self):
|
|
"""Test permission calculation with umask 000."""
|
|
import importlib.util
|
|
|
|
spec = importlib.util.spec_from_file_location("hook", HOOK_SCRIPT)
|
|
hook = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(hook)
|
|
|
|
result = hook.calculate_file_permissions(0o000)
|
|
self.assertEqual(result, 0o666)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|