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

claude-code / examples / hooks / fix_file_permissions_example.py
163 lines · 4.8 KBRaw
1#!/usr/bin/env python3
2"""
3Claude Code Hook: Fix File Permissions After Write
4===================================================
5This hook runs as a PostToolUse hook for the Write and Edit tools.
6It fixes file permissions to respect the system's umask setting.
7
8This addresses the issue where Claude Code's Write tool creates files with
9restrictive 0600 permissions, ignoring the user's umask setting.
10
11Read more about hooks here: https://docs.anthropic.com/en/docs/claude-code/hooks
12
13Configuration 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
31How 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
37For 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
43import json
44import os
45import stat
46import sys
47
48
49def 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
60def 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
76def 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
127def 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
162if __name__ == "__main__":
163 main()