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 / plugins / security-guidance / hooks / symlink_deny_hook.py
137 lines · 3.9 KBRaw
1#!/usr/bin/env python3
2"""
3Symlink Deny Hook for Claude Code
4Security fix for CVE-2025-59829: Deny rules could be bypassed via symlinks.
5
6This hook resolves symlinks before checking file paths against deny patterns,
7preventing attackers from using symlinks to access restricted files.
8"""
9
10import json
11import os
12import re
13import sys
14from fnmatch import fnmatch
15
16
17# System directories that should be blocked by default
18# These match common deny rule patterns
19BLOCKED_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
34def 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
59def 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
95def 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
136if __name__ == "__main__":
137 main()