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
| 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() |