diff --git a/examples/hooks/fix_file_permissions_example.py b/examples/hooks/fix_file_permissions_example.py new file mode 100755 index 00000000..791def97 --- /dev/null +++ b/examples/hooks/fix_file_permissions_example.py @@ -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() diff --git a/examples/hooks/test_fix_file_permissions.py b/examples/hooks/test_fix_file_permissions.py new file mode 100755 index 00000000..bea13a71 --- /dev/null +++ b/examples/hooks/test_fix_file_permissions.py @@ -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()