mirror of
https://github.com/anthropics/claude-code.git
synced 2026-01-30 04:02:03 +00:00
Compare commits
2 Commits
73eddfd640
...
claude/sla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34994ca273 | ||
|
|
ea2608cf85 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
.DS_Store
|
||||
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
163
examples/hooks/fix_file_permissions_example.py
Executable file
163
examples/hooks/fix_file_permissions_example.py
Executable file
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Claude Code Hook: Fix File Permissions After Write
|
||||
===================================================
|
||||
This hook runs as a PostToolUse hook for the Write and Edit tools.
|
||||
It fixes file permissions to respect the system's umask setting.
|
||||
|
||||
This addresses the issue where Claude Code's Write tool creates files with
|
||||
restrictive 0600 permissions, ignoring the user's umask setting.
|
||||
|
||||
Read more about hooks here: https://docs.anthropic.com/en/docs/claude-code/hooks
|
||||
|
||||
Configuration example for ~/.claude/settings.json or .claude/settings.local.json:
|
||||
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 /path/to/claude-code/examples/hooks/fix_file_permissions_example.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
How it works:
|
||||
- After Write or Edit tool completes, this hook runs
|
||||
- Gets the file path from the tool input
|
||||
- Calculates the correct permissions based on the current umask
|
||||
- Applies the umask-respecting permissions to the file
|
||||
|
||||
For example:
|
||||
- With umask 022: files become 0644 (rw-r--r--)
|
||||
- With umask 002: files become 0664 (rw-rw-r--)
|
||||
- With umask 077: files remain 0600 (rw-------)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
|
||||
|
||||
def get_umask() -> int:
|
||||
"""Get the current umask value.
|
||||
|
||||
We temporarily set umask to get the current value, then restore it.
|
||||
This is the standard way to read umask in Python.
|
||||
"""
|
||||
current_umask = os.umask(0)
|
||||
os.umask(current_umask)
|
||||
return current_umask
|
||||
|
||||
|
||||
def calculate_file_permissions(umask_value: int) -> int:
|
||||
"""Calculate file permissions based on umask.
|
||||
|
||||
Standard Unix behavior: new files start with 0666 base permissions,
|
||||
then umask is applied to remove bits.
|
||||
|
||||
Args:
|
||||
umask_value: The current umask value (e.g., 0o022)
|
||||
|
||||
Returns:
|
||||
The file permissions after applying umask (e.g., 0o644)
|
||||
"""
|
||||
base_permissions = 0o666 # rw-rw-rw-
|
||||
return base_permissions & ~umask_value
|
||||
|
||||
|
||||
def fix_file_permissions(file_path: str) -> dict:
|
||||
"""Fix permissions for a file to respect umask.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file to fix
|
||||
|
||||
Returns:
|
||||
Dict with status information
|
||||
"""
|
||||
if not file_path:
|
||||
return {"status": "skipped", "reason": "no file path provided"}
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
return {"status": "skipped", "reason": "file does not exist"}
|
||||
|
||||
if not os.path.isfile(file_path):
|
||||
return {"status": "skipped", "reason": "path is not a file"}
|
||||
|
||||
try:
|
||||
# Get current permissions
|
||||
current_mode = stat.S_IMODE(os.stat(file_path).st_mode)
|
||||
|
||||
# Calculate expected permissions based on umask
|
||||
umask_value = get_umask()
|
||||
expected_mode = calculate_file_permissions(umask_value)
|
||||
|
||||
# Only change if current permissions are more restrictive than expected
|
||||
# This handles the case where Write tool sets 0600 instead of umask-based perms
|
||||
if current_mode == 0o600 and expected_mode != 0o600:
|
||||
os.chmod(file_path, expected_mode)
|
||||
return {
|
||||
"status": "fixed",
|
||||
"file": file_path,
|
||||
"old_mode": oct(current_mode),
|
||||
"new_mode": oct(expected_mode),
|
||||
"umask": oct(umask_value),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "unchanged",
|
||||
"file": file_path,
|
||||
"current_mode": oct(current_mode),
|
||||
"expected_mode": oct(expected_mode),
|
||||
}
|
||||
|
||||
except PermissionError as e:
|
||||
return {"status": "error", "reason": f"permission denied: {e}"}
|
||||
except OSError as e:
|
||||
return {"status": "error", "reason": f"OS error: {e}"}
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for the PostToolUse hook."""
|
||||
try:
|
||||
input_data = json.load(sys.stdin)
|
||||
except json.JSONDecodeError as e:
|
||||
# Exit 0 - don't block on invalid input, just log to stderr
|
||||
print(f"Warning: Invalid JSON input: {e}", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
|
||||
tool_name = input_data.get("tool_name", "")
|
||||
|
||||
# Only process Write and Edit tools
|
||||
if tool_name not in ("Write", "Edit"):
|
||||
sys.exit(0)
|
||||
|
||||
tool_input = input_data.get("tool_input", {})
|
||||
file_path = tool_input.get("file_path", "")
|
||||
|
||||
if not file_path:
|
||||
sys.exit(0)
|
||||
|
||||
result = fix_file_permissions(file_path)
|
||||
|
||||
# Output result as JSON for logging/debugging
|
||||
# This will appear in the transcript when running with --debug
|
||||
if result.get("status") == "fixed":
|
||||
output = {
|
||||
"systemMessage": f"Fixed file permissions for {file_path}: {result['old_mode']} -> {result['new_mode']} (umask: {result['umask']})"
|
||||
}
|
||||
print(json.dumps(output))
|
||||
|
||||
# Always exit 0 - this is a PostToolUse hook, we don't want to block
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
278
examples/hooks/test_fix_file_permissions.py
Executable file
278
examples/hooks/test_fix_file_permissions.py
Executable file
@@ -0,0 +1,278 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user