spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

Add PostToolUse hook example for fixing file permissionsBrowse files
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.
Claude authored on Dec 16, 2025 parent c27c6f4 · commit ea2608cf852e
2 changed files+441 -0compared with c27c6f4
Aexamples/hooks/fix_file_permissions_example.py
Aexamples/hooks/test_fix_file_permissions.py
@@ -1,0 +1,163 @@
1+#!/usr/bin/env python3
2+"""
3+Claude Code Hook: Fix File Permissions After Write
4+===================================================
5+This hook runs as a PostToolUse hook for the Write and Edit tools.
6+It fixes file permissions to respect the system's umask setting.
7+
8+This addresses the issue where Claude Code's Write tool creates files with
9+restrictive 0600 permissions, ignoring the user's umask setting.
10+
11+Read more about hooks here: https://docs.anthropic.com/en/docs/claude-code/hooks
12+
13+Configuration example for ~/.claude/settings.json or .claude/settings.local.json:
14+
15+{
16+ "hooks": {
17+ "PostToolUse": [
18+ {
19+ "matcher": "Write|Edit",
20+ "hooks": [
21+ {
22+ "type": "command",
23+ "command": "python3 /path/to/claude-code/examples/hooks/fix_file_permissions_example.py"
24+ }
25+ ]
26+ }
27+ ]
28+ }
29+}
30+
31+How it works:
32+- After Write or Edit tool completes, this hook runs
33+- Gets the file path from the tool input
34+- Calculates the correct permissions based on the current umask
35+- Applies the umask-respecting permissions to the file
36+
37+For example:
38+- With umask 022: files become 0644 (rw-r--r--)
39+- With umask 002: files become 0664 (rw-rw-r--)
40+- With umask 077: files remain 0600 (rw-------)
41+"""
42+
43+import json
44+import os
45+import stat
46+import sys
47+
48+
49+def get_umask() -> int:
50+ """Get the current umask value.
51+
52+ We temporarily set umask to get the current value, then restore it.
53+ This is the standard way to read umask in Python.
54+ """
55+ current_umask = os.umask(0)
56+ os.umask(current_umask)
57+ return current_umask
58+
59+
60+def calculate_file_permissions(umask_value: int) -> int:
61+ """Calculate file permissions based on umask.
62+
63+ Standard Unix behavior: new files start with 0666 base permissions,
64+ then umask is applied to remove bits.
65+
66+ Args:
67+ umask_value: The current umask value (e.g., 0o022)
68+
69+ Returns:
70+ The file permissions after applying umask (e.g., 0o644)
71+ """
72+ base_permissions = 0o666 # rw-rw-rw-
73+ return base_permissions & ~umask_value
74+
75+
76+def fix_file_permissions(file_path: str) -> dict:
77+ """Fix permissions for a file to respect umask.
78+
79+ Args:
80+ file_path: Path to the file to fix
81+
82+ Returns:
83+ Dict with status information
84+ """
85+ if not file_path:
86+ return {"status": "skipped", "reason": "no file path provided"}
87+
88+ if not os.path.exists(file_path):
89+ return {"status": "skipped", "reason": "file does not exist"}
90+
91+ if not os.path.isfile(file_path):
92+ return {"status": "skipped", "reason": "path is not a file"}
93+
94+ try:
95+ # Get current permissions
96+ current_mode = stat.S_IMODE(os.stat(file_path).st_mode)
97+
98+ # Calculate expected permissions based on umask
99+ umask_value = get_umask()
100+ expected_mode = calculate_file_permissions(umask_value)
101+
102+ # Only change if current permissions are more restrictive than expected
103+ # This handles the case where Write tool sets 0600 instead of umask-based perms
104+ if current_mode == 0o600 and expected_mode != 0o600:
105+ os.chmod(file_path, expected_mode)
106+ return {
107+ "status": "fixed",
108+ "file": file_path,
109+ "old_mode": oct(current_mode),
110+ "new_mode": oct(expected_mode),
111+ "umask": oct(umask_value),
112+ }
113+ else:
114+ return {
115+ "status": "unchanged",
116+ "file": file_path,
117+ "current_mode": oct(current_mode),
118+ "expected_mode": oct(expected_mode),
119+ }
120+
121+ except PermissionError as e:
122+ return {"status": "error", "reason": f"permission denied: {e}"}
123+ except OSError as e:
124+ return {"status": "error", "reason": f"OS error: {e}"}
125+
126+
127+def main():
128+ """Main entry point for the PostToolUse hook."""
129+ try:
130+ input_data = json.load(sys.stdin)
131+ except json.JSONDecodeError as e:
132+ # Exit 0 - don't block on invalid input, just log to stderr
133+ print(f"Warning: Invalid JSON input: {e}", file=sys.stderr)
134+ sys.exit(0)
135+
136+ tool_name = input_data.get("tool_name", "")
137+
138+ # Only process Write and Edit tools
139+ if tool_name not in ("Write", "Edit"):
140+ sys.exit(0)
141+
142+ tool_input = input_data.get("tool_input", {})
143+ file_path = tool_input.get("file_path", "")
144+
145+ if not file_path:
146+ sys.exit(0)
147+
148+ result = fix_file_permissions(file_path)
149+
150+ # Output result as JSON for logging/debugging
151+ # This will appear in the transcript when running with --debug
152+ if result.get("status") == "fixed":
153+ output = {
154+ "systemMessage": f"Fixed file permissions for {file_path}: {result['old_mode']} -> {result['new_mode']} (umask: {result['umask']})"
155+ }
156+ print(json.dumps(output))
157+
158+ # Always exit 0 - this is a PostToolUse hook, we don't want to block
159+ sys.exit(0)
160+
161+
162+if __name__ == "__main__":
163+ main()
@@ -1,0 +1,278 @@
1+#!/usr/bin/env python3
2+"""
3+Tests for the fix_file_permissions_example.py hook.
4+
5+Run these tests with:
6+ python3 examples/hooks/test_fix_file_permissions.py
7+
8+Or with pytest:
9+ pytest examples/hooks/test_fix_file_permissions.py -v
10+"""
11+
12+import json
13+import os
14+import stat
15+import subprocess
16+import sys
17+import tempfile
18+import unittest
19+from pathlib import Path
20+
21+# Path to the hook script
22+HOOK_SCRIPT = Path(__file__).parent / "fix_file_permissions_example.py"
23+
24+
25+class TestFixFilePermissionsHook(unittest.TestCase):
26+ """Test cases for the file permissions fix hook."""
27+
28+ def setUp(self):
29+ """Set up test fixtures."""
30+ self.temp_dir = tempfile.mkdtemp()
31+ self.test_file = os.path.join(self.temp_dir, "test_file.txt")
32+
33+ def tearDown(self):
34+ """Clean up test files."""
35+ if os.path.exists(self.test_file):
36+ os.remove(self.test_file)
37+ if os.path.exists(self.temp_dir):
38+ os.rmdir(self.temp_dir)
39+
40+ def run_hook(self, tool_name: str, file_path: str) -> subprocess.CompletedProcess:
41+ """Run the hook script with given input."""
42+ input_data = {
43+ "tool_name": tool_name,
44+ "tool_input": {"file_path": file_path},
45+ "session_id": "test-session",
46+ "cwd": os.getcwd(),
47+ }
48+
49+ result = subprocess.run(
50+ [sys.executable, str(HOOK_SCRIPT)],
51+ input=json.dumps(input_data),
52+ capture_output=True,
53+ text=True,
54+ )
55+ return result
56+
57+ def create_file_with_permissions(self, path: str, mode: int) -> None:
58+ """Create a test file with specific permissions."""
59+ with open(path, "w") as f:
60+ f.write("test content")
61+ os.chmod(path, mode)
62+
63+ def get_file_permissions(self, path: str) -> int:
64+ """Get the permission bits of a file."""
65+ return stat.S_IMODE(os.stat(path).st_mode)
66+
67+ def test_fixes_restrictive_permissions_with_umask_022(self):
68+ """Test that 0600 permissions are fixed to 0644 with umask 022."""
69+ # Save and set umask
70+ old_umask = os.umask(0o022)
71+ try:
72+ # Create file with restrictive permissions (simulating Write tool bug)
73+ self.create_file_with_permissions(self.test_file, 0o600)
74+ self.assertEqual(self.get_file_permissions(self.test_file), 0o600)
75+
76+ # Run the hook
77+ result = self.run_hook("Write", self.test_file)
78+ self.assertEqual(result.returncode, 0)
79+
80+ # Check permissions were fixed
81+ expected_mode = 0o644 # 0666 & ~0022
82+ self.assertEqual(
83+ self.get_file_permissions(self.test_file),
84+ expected_mode,
85+ f"Expected {oct(expected_mode)}, got {oct(self.get_file_permissions(self.test_file))}",
86+ )
87+ finally:
88+ os.umask(old_umask)
89+
90+ def test_fixes_restrictive_permissions_with_umask_002(self):
91+ """Test that 0600 permissions are fixed to 0664 with umask 002."""
92+ # Save and set umask
93+ old_umask = os.umask(0o002)
94+ try:
95+ # Create file with restrictive permissions
96+ self.create_file_with_permissions(self.test_file, 0o600)
97+ self.assertEqual(self.get_file_permissions(self.test_file), 0o600)
98+
99+ # Run the hook
100+ result = self.run_hook("Write", self.test_file)
101+ self.assertEqual(result.returncode, 0)
102+
103+ # Check permissions were fixed
104+ expected_mode = 0o664 # 0666 & ~0002
105+ self.assertEqual(
106+ self.get_file_permissions(self.test_file),
107+ expected_mode,
108+ f"Expected {oct(expected_mode)}, got {oct(self.get_file_permissions(self.test_file))}",
109+ )
110+ finally:
111+ os.umask(old_umask)
112+
113+ def test_preserves_permissions_matching_umask(self):
114+ """Test that permissions already matching umask are not changed."""
115+ old_umask = os.umask(0o022)
116+ try:
117+ # Create file with correct permissions already
118+ self.create_file_with_permissions(self.test_file, 0o644)
119+
120+ # Run the hook
121+ result = self.run_hook("Write", self.test_file)
122+ self.assertEqual(result.returncode, 0)
123+
124+ # Permissions should be unchanged
125+ self.assertEqual(self.get_file_permissions(self.test_file), 0o644)
126+ finally:
127+ os.umask(old_umask)
128+
129+ def test_respects_umask_077(self):
130+ """Test that umask 077 results in 0600 (no change needed)."""
131+ old_umask = os.umask(0o077)
132+ try:
133+ # Create file with 0600 permissions
134+ self.create_file_with_permissions(self.test_file, 0o600)
135+
136+ # Run the hook
137+ result = self.run_hook("Write", self.test_file)
138+ self.assertEqual(result.returncode, 0)
139+
140+ # With umask 077, 0600 is correct - should remain unchanged
141+ self.assertEqual(self.get_file_permissions(self.test_file), 0o600)
142+ finally:
143+ os.umask(old_umask)
144+
145+ def test_handles_edit_tool(self):
146+ """Test that the hook also works for the Edit tool."""
147+ old_umask = os.umask(0o022)
148+ try:
149+ self.create_file_with_permissions(self.test_file, 0o600)
150+
151+ result = self.run_hook("Edit", self.test_file)
152+ self.assertEqual(result.returncode, 0)
153+
154+ self.assertEqual(self.get_file_permissions(self.test_file), 0o644)
155+ finally:
156+ os.umask(old_umask)
157+
158+ def test_ignores_other_tools(self):
159+ """Test that the hook ignores non-Write/Edit tools."""
160+ old_umask = os.umask(0o022)
161+ try:
162+ self.create_file_with_permissions(self.test_file, 0o600)
163+
164+ result = self.run_hook("Read", self.test_file)
165+ self.assertEqual(result.returncode, 0)
166+
167+ # Permissions should be unchanged for Read tool
168+ self.assertEqual(self.get_file_permissions(self.test_file), 0o600)
169+ finally:
170+ os.umask(old_umask)
171+
172+ def test_handles_nonexistent_file(self):
173+ """Test that the hook handles non-existent files gracefully."""
174+ result = self.run_hook("Write", "/nonexistent/path/file.txt")
175+ self.assertEqual(result.returncode, 0)
176+
177+ def test_handles_empty_file_path(self):
178+ """Test that the hook handles empty file path gracefully."""
179+ input_data = {
180+ "tool_name": "Write",
181+ "tool_input": {},
182+ "session_id": "test-session",
183+ }
184+
185+ result = subprocess.run(
186+ [sys.executable, str(HOOK_SCRIPT)],
187+ input=json.dumps(input_data),
188+ capture_output=True,
189+ text=True,
190+ )
191+ self.assertEqual(result.returncode, 0)
192+
193+ def test_handles_invalid_json(self):
194+ """Test that the hook handles invalid JSON input gracefully."""
195+ result = subprocess.run(
196+ [sys.executable, str(HOOK_SCRIPT)],
197+ input="not valid json",
198+ capture_output=True,
199+ text=True,
200+ )
201+ # Should exit 0 even with invalid input (don't block the workflow)
202+ self.assertEqual(result.returncode, 0)
203+ self.assertIn("Invalid JSON", result.stderr)
204+
205+ def test_handles_directory_path(self):
206+ """Test that the hook ignores directory paths."""
207+ result = self.run_hook("Write", self.temp_dir)
208+ self.assertEqual(result.returncode, 0)
209+
210+ def test_outputs_system_message_on_fix(self):
211+ """Test that the hook outputs a systemMessage when fixing permissions."""
212+ old_umask = os.umask(0o022)
213+ try:
214+ self.create_file_with_permissions(self.test_file, 0o600)
215+
216+ result = self.run_hook("Write", self.test_file)
217+ self.assertEqual(result.returncode, 0)
218+
219+ # Check that stdout contains the systemMessage JSON
220+ if result.stdout.strip():
221+ output = json.loads(result.stdout)
222+ self.assertIn("systemMessage", output)
223+ self.assertIn("Fixed file permissions", output["systemMessage"])
224+ finally:
225+ os.umask(old_umask)
226+
227+
228+class TestCalculateFilePermissions(unittest.TestCase):
229+ """Test the calculate_file_permissions function directly."""
230+
231+ def test_umask_022(self):
232+ """Test permission calculation with umask 022."""
233+ # Import the function from the hook script
234+ import importlib.util
235+
236+ spec = importlib.util.spec_from_file_location("hook", HOOK_SCRIPT)
237+ hook = importlib.util.module_from_spec(spec)
238+ spec.loader.exec_module(hook)
239+
240+ result = hook.calculate_file_permissions(0o022)
241+ self.assertEqual(result, 0o644)
242+
243+ def test_umask_002(self):
244+ """Test permission calculation with umask 002."""
245+ import importlib.util
246+
247+ spec = importlib.util.spec_from_file_location("hook", HOOK_SCRIPT)
248+ hook = importlib.util.module_from_spec(spec)
249+ spec.loader.exec_module(hook)
250+
251+ result = hook.calculate_file_permissions(0o002)
252+ self.assertEqual(result, 0o664)
253+
254+ def test_umask_077(self):
255+ """Test permission calculation with umask 077."""
256+ import importlib.util
257+
258+ spec = importlib.util.spec_from_file_location("hook", HOOK_SCRIPT)
259+ hook = importlib.util.module_from_spec(spec)
260+ spec.loader.exec_module(hook)
261+
262+ result = hook.calculate_file_permissions(0o077)
263+ self.assertEqual(result, 0o600)
264+
265+ def test_umask_000(self):
266+ """Test permission calculation with umask 000."""
267+ import importlib.util
268+
269+ spec = importlib.util.spec_from_file_location("hook", HOOK_SCRIPT)
270+ hook = importlib.util.module_from_spec(spec)
271+ spec.loader.exec_module(hook)
272+
273+ result = hook.calculate_file_permissions(0o000)
274+ self.assertEqual(result, 0o666)
275+
276+
277+if __name__ == "__main__":
278+ unittest.main()