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

fix(security): Resolve symlinks before checking deny rules (CVE-2025-59829)Browse files
This commit fixes a security vulnerability where deny rules could be
bypassed by creating symbolic links to restricted files.

Changes:
- Add symlink resolution in rule_engine.py _extract_field method
- Add symlink resolution in security_reminder_hook.py check_patterns
- Create new symlink_deny_hook.py for blocking symlinks to system paths
- Include Read tool in file event handlers for deny rule checking
- Update hooks.json to apply security hooks to Read tool

The vulnerability allowed attackers to bypass deny rules like
Read(/etc/passwd) by creating a symlink (e.g., ln -s /etc/passwd test.txt)
and then reading the symlink instead of the restricted file directly.

The fix uses os.path.realpath() to resolve all symlinks to their canonical
paths before checking against deny patterns, ensuring that deny rules are
enforced regardless of whether the path is accessed directly or via symlink.
Claude authored on Jan 8, 2026 parent f34e253 · commit ff0fdc06760d
5 changed files+235 -7compared with f34e253
Mplugins/hookify/core/rule_engine.py
Mplugins/hookify/hooks/pretooluse.py
Mplugins/security-guidance/hooks/hooks.json
Mplugins/security-guidance/hooks/security_reminder_hook.py
Aplugins/security-guidance/hooks/symlink_deny_hook.py
@@ -141,6 +141,39 @@
141141 patterns = matcher.split('|')
142142 return tool_name in patterns
143143
144+ def _resolve_symlink_path(self, file_path: str) -> str:
145+ """Resolve symlinks in file path to get canonical path.
146+
147+ Security fix for CVE-2025-59829: Deny rules could be bypassed by creating
148+ a symlink to a restricted file. This method resolves the symlink to its
149+ target path so that deny rules are checked against the actual file.
150+
151+ Args:
152+ file_path: The file path that may contain symlinks
153+
154+ Returns:
155+ The canonical path with symlinks resolved, or original path if
156+ resolution fails (e.g., file doesn't exist yet)
157+ """
158+ import os
159+
160+ if not file_path:
161+ return file_path
162+
163+ try:
164+ # Expand user home directory first
165+ expanded_path = os.path.expanduser(file_path)
166+
167+ # Use realpath to resolve all symlinks and get canonical path
168+ # This handles nested symlinks and relative path components
169+ resolved = os.path.realpath(expanded_path)
170+
171+ return resolved
172+ except (OSError, ValueError):
173+ # If resolution fails (e.g., permission denied, invalid path),
174+ # return the original path to avoid blocking legitimate operations
175+ return file_path
176+
144177 def _check_condition(self, condition: Condition, tool_name: str,
145178 tool_input: Dict[str, Any], input_data: Dict[str, Any] = None) -> bool:
146179 """Check if a single condition matches.
@@ -196,6 +229,10 @@
196229 if field in tool_input:
197230 value = tool_input[field]
198231 if isinstance(value, str):
232+ # Security fix: resolve symlinks for file_path fields to prevent bypass
233+ # CVE-2025-59829: Deny rules could be bypassed via symlinks
234+ if field == 'file_path':
235+ value = self._resolve_symlink_path(value)
199236 return value
200237 return str(value)
201238
@@ -241,11 +278,18 @@
241278 elif field == 'old_text' or field == 'old_string':
242279 return tool_input.get('old_string', '')
243280 elif field == 'file_path':
244- return tool_input.get('file_path', '')
281+ # Security fix: resolve symlinks to prevent deny rule bypass
282+ return self._resolve_symlink_path(tool_input.get('file_path', ''))
245283
284+ elif tool_name == 'Read':
285+ # Security fix for CVE-2025-59829: Read tool symlink bypass
286+ if field == 'file_path':
287+ return self._resolve_symlink_path(tool_input.get('file_path', ''))
288+
246289 elif tool_name == 'MultiEdit':
247290 if field == 'file_path':
248- return tool_input.get('file_path', '')
291+ # Security fix: resolve symlinks to prevent deny rule bypass
292+ return self._resolve_symlink_path(tool_input.get('file_path', ''))
249293 elif field in ['new_text', 'content']:
250294 # Concatenate all edits
251295 edits = tool_input.get('edits', [])
@@ -45,7 +45,9 @@
4545 event = None
4646 if tool_name == 'Bash':
4747 event = 'bash'
48- elif tool_name in ['Edit', 'Write', 'MultiEdit']:
48+ elif tool_name in ['Edit', 'Write', 'MultiEdit', 'Read']:
49+ # Include Read tool in file events to check symlink bypass
50+ # Security fix for CVE-2025-59829
4951 event = 'file'
5052
5153 # Load rules
@@ -1,15 +1,24 @@
11 {
2- "description": "Security reminder hook that warns about potential security issues when editing files",
2+ "description": "Security hooks for file access validation and security pattern warnings",
33 "hooks": {
44 "PreToolUse": [
55 {
66 "hooks": [
77 {
88 "type": "command",
9+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/symlink_deny_hook.py"
10+ }
11+ ],
12+ "matcher": "Edit|Write|MultiEdit|Read"
13+ },
14+ {
15+ "hooks": [
16+ {
17+ "type": "command",
918 "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/security_reminder_hook.py"
1019 }
1120 ],
12- "matcher": "Edit|Write|MultiEdit"
21+ "matcher": "Edit|Write|MultiEdit|Read"
1322 }
1423 ]
1524 }
@@ -180,10 +180,46 @@
180180 pass # Fail silently if we can't save state
181181
182182
183+def resolve_symlink_path(file_path):
184+ """Resolve symlinks in file path to get canonical path.
185+
186+ Security fix for CVE-2025-59829: Deny rules could be bypassed by creating
187+ a symlink to a restricted file. This method resolves the symlink to its
188+ target path so that security patterns are checked against the actual file.
189+
190+ Args:
191+ file_path: The file path that may contain symlinks
192+
193+ Returns:
194+ The canonical path with symlinks resolved, or original path if
195+ resolution fails (e.g., file doesn't exist yet)
196+ """
197+ if not file_path:
198+ return file_path
199+
200+ try:
201+ # Expand user home directory first
202+ expanded_path = os.path.expanduser(file_path)
203+
204+ # Use realpath to resolve all symlinks and get canonical path
205+ # This handles nested symlinks and relative path components
206+ resolved = os.path.realpath(expanded_path)
207+
208+ return resolved
209+ except (OSError, ValueError):
210+ # If resolution fails (e.g., permission denied, invalid path),
211+ # return the original path to avoid blocking legitimate operations
212+ return file_path
213+
214+
183215 def check_patterns(file_path, content):
184216 """Check if file path or content matches any security patterns."""
217+ # Security fix: resolve symlinks before checking patterns
218+ # CVE-2025-59829: Security patterns could be bypassed via symlinks
219+ resolved_path = resolve_symlink_path(file_path)
220+
185221 # Normalize path by removing leading slashes
186- normalized_path = file_path.lstrip("/")
222+ normalized_path = resolved_path.lstrip("/")
187223
188224 for pattern in SECURITY_PATTERNS:
189225 # Check path-based patterns
@@ -241,7 +277,7 @@
241277 tool_input = input_data.get("tool_input", {})
242278
243279 # Check if this is a relevant tool
244- if tool_name not in ["Edit", "Write", "MultiEdit"]:
280+ if tool_name not in ["Edit", "Write", "MultiEdit", "Read"]:
245281 sys.exit(0) # Allow non-file tools to proceed
246282
247283 # Extract file path from tool_input
@@ -1,0 +1,137 @@
1+#!/usr/bin/env python3
2+"""
3+Symlink Deny Hook for Claude Code
4+Security fix for CVE-2025-59829: Deny rules could be bypassed via symlinks.
5+
6+This hook resolves symlinks before checking file paths against deny patterns,
7+preventing attackers from using symlinks to access restricted files.
8+"""
9+
10+import json
11+import os
12+import re
13+import sys
14+from fnmatch import fnmatch
15+
16+
17+# System directories that should be blocked by default
18+# These match common deny rule patterns
19+BLOCKED_PATHS = [
20+ "/etc/**",
21+ "/etc/passwd",
22+ "/etc/shadow",
23+ "/etc/sudoers",
24+ "/etc/ssh/**",
25+ "/etc/ssl/**",
26+ "/root/**",
27+ "/var/log/**",
28+ "/proc/**",
29+ "/sys/**",
30+ "/boot/**",
31+]
32+
33+
34+def resolve_symlink_path(file_path: str) -> str:
35+ """Resolve symlinks in file path to get canonical path.
36+
37+ Args:
38+ file_path: The file path that may contain symlinks
39+
40+ Returns:
41+ The canonical path with symlinks resolved, or original path if
42+ resolution fails (e.g., file doesn't exist)
43+ """
44+ if not file_path:
45+ return file_path
46+
47+ try:
48+ # Expand user home directory first
49+ expanded_path = os.path.expanduser(file_path)
50+
51+ # Use realpath to resolve all symlinks and get canonical path
52+ resolved = os.path.realpath(expanded_path)
53+
54+ return resolved
55+ except (OSError, ValueError):
56+ return file_path
57+
58+
59+def is_path_blocked(resolved_path: str, original_path: str) -> tuple:
60+ """Check if the resolved path matches any blocked patterns.
61+
62+ Only blocks if:
63+ 1. The path was a symlink (resolved != original)
64+ 2. The resolved path matches a blocked pattern
65+
66+ Args:
67+ resolved_path: The canonical path after symlink resolution
68+ original_path: The original path before resolution
69+
70+ Returns:
71+ Tuple of (is_blocked: bool, reason: str)
72+ """
73+ # Only apply symlink protection if path was actually a symlink
74+ original_real = os.path.realpath(os.path.expanduser(original_path))
75+ if original_real == resolved_path:
76+ # Check if original was a symlink
77+ expanded_original = os.path.expanduser(original_path)
78+ if not os.path.islink(expanded_original):
79+ # Not a symlink, allow normal deny rule checking to handle this
80+ return False, ""
81+
82+ # Check if resolved path matches any blocked patterns
83+ for pattern in BLOCKED_PATHS:
84+ if pattern.endswith("/**"):
85+ # Directory wildcard pattern
86+ base_dir = pattern[:-3]
87+ if resolved_path.startswith(base_dir + "/") or resolved_path == base_dir:
88+ return True, f"Symlink bypass blocked: '{original_path}' resolves to '{resolved_path}' which matches blocked pattern '{pattern}'"
89+ elif fnmatch(resolved_path, pattern):
90+ return True, f"Symlink bypass blocked: '{original_path}' resolves to '{resolved_path}' which matches blocked pattern '{pattern}'"
91+
92+ return False, ""
93+
94+
95+def main():
96+ """Main hook function."""
97+ try:
98+ input_data = json.load(sys.stdin)
99+ except json.JSONDecodeError:
100+ sys.exit(0) # Allow on parse error
101+
102+ tool_name = input_data.get("tool_name", "")
103+ tool_input = input_data.get("tool_input", {})
104+
105+ # Only check file-related tools
106+ if tool_name not in ["Read", "Edit", "Write", "MultiEdit"]:
107+ sys.exit(0)
108+
109+ # Extract file path
110+ file_path = tool_input.get("file_path", "")
111+ if not file_path:
112+ sys.exit(0)
113+
114+ # Resolve symlinks
115+ resolved_path = resolve_symlink_path(file_path)
116+
117+ # Check if blocked
118+ is_blocked, reason = is_path_blocked(resolved_path, file_path)
119+
120+ if is_blocked:
121+ # Output denial response
122+ response = {
123+ "hookSpecificOutput": {
124+ "hookEventName": "PreToolUse",
125+ "permissionDecision": "deny"
126+ },
127+ "systemMessage": f"Security: {reason}"
128+ }
129+ print(json.dumps(response))
130+ sys.exit(0)
131+
132+ # Allow the operation
133+ sys.exit(0)
134+
135+
136+if __name__ == "__main__":
137+ main()