Compare commits

..

1 Commits

Author SHA1 Message Date
Claude
ff0fdc0676 fix(security): Resolve symlinks before checking deny rules (CVE-2025-59829)
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.
2026-01-08 20:08:08 +00:00
11 changed files with 276 additions and 82 deletions

View File

@@ -23,13 +23,13 @@ jobs:
uses: actions/checkout@v4
- name: Run Claude Code slash command
uses: anthropics/claude-code-base-action@v1
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: anthropics/claude-code-base-action@beta
with:
prompt: "/dedupe ${{ github.repository }}/issues/${{ github.event.issue.number || inputs.issue_number }}"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
claude_args: "--model claude-sonnet-4-5-20250929"
claude_env: |
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Log duplicate comment event to Statsig
if: always()

View File

@@ -95,14 +95,13 @@ jobs:
EOF
- name: Run Claude Code for Issue Triage
timeout-minutes: 5
uses: anthropics/claude-code-base-action@v1
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: anthropics/claude-code-base-action@beta
with:
prompt_file: /tmp/claude-prompts/triage-prompt.txt
allowed_tools: "Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues"
timeout_minutes: "5"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
claude_args: |
--model claude-sonnet-4-5-20250929
--mcp-config /tmp/mcp-config/mcp-servers.json
--allowedTools "Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues"
mcp_config: /tmp/mcp-config/mcp-servers.json
claude_args: "--model claude-sonnet-4-5-20250929"
claude_env: |
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -31,7 +31,7 @@ jobs:
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
uses: anthropics/claude-code-action@beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
claude_args: "--model claude-sonnet-4-5-20250929"

View File

@@ -109,13 +109,12 @@ jobs:
EOF
- name: Run Claude Code for Oncall Triage
timeout-minutes: 10
uses: anthropics/claude-code-base-action@v1
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: anthropics/claude-code-base-action@beta
with:
prompt_file: /tmp/claude-prompts/oncall-triage-prompt.txt
allowed_tools: "mcp__github__list_issues,mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue"
timeout_minutes: "10"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
claude_args: |
--mcp-config /tmp/mcp-config/mcp-servers.json
--allowedTools "mcp__github__list_issues,mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue"
mcp_config: /tmp/mcp-config/mcp-servers.json
claude_env: |
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,46 +1,5 @@
# Changelog
## 2.1.3
- Merged slash commands and skills, simplifying the mental model with no change in behavior
- Added release channel (`stable` or `latest`) toggle to `/config`
- Added detection and warnings for unreachable permission rules, with warnings in `/doctor` and after saving rules that include the source of each rule and actionable fix guidance
- Fixed plan files persisting across `/clear` commands, now ensuring a fresh plan file is used after clearing a conversation
- Fixed false skill duplicate detection on filesystems with large inodes (e.g., ExFAT) by using 64-bit precision for inode values
- Fixed mismatch between background task count in status bar and items shown in tasks dialog
- Fixed sub-agents using the wrong model during conversation compaction
- Fixed web search in sub-agents using incorrect model
- Fixed trust dialog acceptance when running from the home directory not enabling trust-requiring features like hooks during the session
- Improved terminal rendering stability by preventing uncontrolled writes from corrupting cursor state
- Improved slash command suggestion readability by truncating long descriptions to 2 lines
- Changed tool hook execution timeout from 60 seconds to 10 minutes
- [VSCode] Added clickable destination selector for permission requests, allowing you to choose where settings are saved (this project, all projects, shared with team, or session only)
## 2.1.2
- Added source path metadata to images dragged onto the terminal, helping Claude understand where images originated
- Added clickable hyperlinks for file paths in tool output in terminals that support OSC 8 (like iTerm)
- Added support for Windows Package Manager (winget) installations with automatic detection and update instructions
- Added Shift+Tab keyboard shortcut in plan mode to quickly select "auto-accept edits" option
- Added `FORCE_AUTOUPDATE_PLUGINS` environment variable to allow plugin autoupdate even when the main auto-updater is disabled
- Added `agent_type` to SessionStart hook input, populated if `--agent` is specified
- Fixed a command injection vulnerability in bash command processing where malformed input could execute arbitrary commands
- Fixed a memory leak where tree-sitter parse trees were not being freed, causing WASM memory to grow unbounded over long sessions
- Fixed binary files (images, PDFs, etc.) being accidentally included in memory when using `@include` directives in CLAUDE.md files
- Fixed updates incorrectly claiming another installation is in progress
- Fixed crash when socket files exist in watched directories (defense-in-depth for EOPNOTSUPP errors)
- Fixed remote session URL and teleport being broken when using `/tasks` command
- Fixed MCP tool names being exposed in analytics events by sanitizing user-specific server configurations
- Improved Option-as-Meta hint on macOS to show terminal-specific instructions for native CSIu terminals like iTerm2, Kitty, and WezTerm
- Improved error message when pasting images over SSH to suggest using `scp` instead of the unhelpful clipboard shortcut hint
- Improved permission explainer to not flag routine dev workflows (git fetch/rebase, npm install, tests, PRs) as medium risk
- Changed large bash command outputs to be saved to disk instead of truncated, allowing Claude to read the full content
- Changed large tool outputs to be persisted to disk instead of truncated, providing full output access via file references
- Changed `/plugins` installed tab to unify plugins and MCPs with scope-based grouping
- Deprecated Windows managed settings path `C:\ProgramData\ClaudeCode\managed-settings.json` - administrators should migrate to `C:\Program Files\ClaudeCode\managed-settings.json`
- [SDK] Changed minimum zod peer dependency to ^4.0.0
- [VSCode] Fixed usage display not updating after manual compact
## 2.1.0
- Added automatic skill hot-reload - skills created or modified in `~/.claude/skills` or `.claude/skills` are now immediately available without restarting the session

View File

@@ -5,10 +5,6 @@ description: Code review a pull request
Provide a code review for the given pull request.
**Agent assumptions (applies to all agents and subagents):**
- All tools are functional and will work without error. Do not test tools or make exploratory calls.
- Only call a tool if it is required to complete the task. Every tool call should have a clear purpose.
To do this, follow these steps precisely:
1. Launch a haiku agent to check if any of the following are true:
@@ -38,15 +34,15 @@ Note: Still review Claude generated PR's.
Agent 4: Opus bug agent (parallel subagent with agent 3)
Look for problems that exist in the introduced code. This could be security issues, incorrect logic, etc. Only look for issues that fall within the changed code.
**CRITICAL: We only want HIGH SIGNAL issues.** Flag issues where:
- The code will fail to compile or parse (syntax errors, type errors, missing imports, unresolved references)
- The code will definitely produce wrong results regardless of inputs (clear logic errors)
**CRITICAL: We only want HIGH SIGNAL issues.** This means:
- Objective bugs that will cause incorrect behavior at runtime
- Clear, unambiguous CLAUDE.md violations where you can quote the exact rule being broken
Do NOT flag:
- Code style or quality concerns
- Potential issues that depend on specific inputs or state
- Subjective suggestions or improvements
We do NOT want:
- Subjective concerns or "suggestions"
- Style preferences not explicitly required by CLAUDE.md
- Potential issues that "might" be problems
- Anything requiring interpretation or judgment calls
If you are not certain an issue is real, do not flag it. False positives erode trust and waste reviewer time.
@@ -61,10 +57,23 @@ Note: Still review Claude generated PR's.
If NO issues were found, post a summary comment using `gh pr comment` (if `--comment` argument is provided):
"No issues found. Checked for bugs and CLAUDE.md compliance."
8. Post inline comments for each issue using `mcp__github_inline_comment__create_inline_comment`. For each comment:
- Provide a brief description of the issue
- For small, self-contained fixes, include a committable suggestion block
- For larger fixes (6+ lines, structural changes, or changes spanning multiple locations), describe the issue and suggested fix without a suggestion block
8. Post inline comments for each issue using `mcp__github_inline_comment__create_inline_comment`:
- `path`: the file path
- `line` (and `startLine` for ranges): select the buggy lines so the user sees them
- `body`: Brief description of the issue (no "Bug:" prefix). For small fixes (up to 5 lines changed), include a committable suggestion:
```suggestion
corrected code here
```
**Suggestions must be COMPLETE.** If a fix requires additional changes elsewhere (e.g., renaming a variable requires updating all usages), do NOT use a suggestion block. The author should be able to click "Commit suggestion" and have a working fix - no followup work required.
For larger fixes (6+ lines, structural changes, or changes spanning multiple locations), do NOT use suggestion blocks. Instead:
1. Describe what the issue is
2. Explain the suggested fix at a high level
3. Include a copyable prompt for Claude Code that the user can use to fix the issue, formatted as:
```
Fix [file:line]: [brief description of issue and suggested fix]
```
**IMPORTANT: Only post ONE comment per unique issue. Do not post duplicate comments.**

View File

@@ -141,6 +141,39 @@ class RuleEngine:
patterns = matcher.split('|')
return tool_name in patterns
def _resolve_symlink_path(self, file_path: str) -> str:
"""Resolve symlinks in file path to get canonical path.
Security fix for CVE-2025-59829: Deny rules could be bypassed by creating
a symlink to a restricted file. This method resolves the symlink to its
target path so that deny rules are checked against the actual file.
Args:
file_path: The file path that may contain symlinks
Returns:
The canonical path with symlinks resolved, or original path if
resolution fails (e.g., file doesn't exist yet)
"""
import os
if not file_path:
return file_path
try:
# Expand user home directory first
expanded_path = os.path.expanduser(file_path)
# Use realpath to resolve all symlinks and get canonical path
# This handles nested symlinks and relative path components
resolved = os.path.realpath(expanded_path)
return resolved
except (OSError, ValueError):
# If resolution fails (e.g., permission denied, invalid path),
# return the original path to avoid blocking legitimate operations
return file_path
def _check_condition(self, condition: Condition, tool_name: str,
tool_input: Dict[str, Any], input_data: Dict[str, Any] = None) -> bool:
"""Check if a single condition matches.
@@ -196,6 +229,10 @@ class RuleEngine:
if field in tool_input:
value = tool_input[field]
if isinstance(value, str):
# Security fix: resolve symlinks for file_path fields to prevent bypass
# CVE-2025-59829: Deny rules could be bypassed via symlinks
if field == 'file_path':
value = self._resolve_symlink_path(value)
return value
return str(value)
@@ -241,11 +278,18 @@ class RuleEngine:
elif field == 'old_text' or field == 'old_string':
return tool_input.get('old_string', '')
elif field == 'file_path':
return tool_input.get('file_path', '')
# Security fix: resolve symlinks to prevent deny rule bypass
return self._resolve_symlink_path(tool_input.get('file_path', ''))
elif tool_name == 'Read':
# Security fix for CVE-2025-59829: Read tool symlink bypass
if field == 'file_path':
return self._resolve_symlink_path(tool_input.get('file_path', ''))
elif tool_name == 'MultiEdit':
if field == 'file_path':
return tool_input.get('file_path', '')
# Security fix: resolve symlinks to prevent deny rule bypass
return self._resolve_symlink_path(tool_input.get('file_path', ''))
elif field in ['new_text', 'content']:
# Concatenate all edits
edits = tool_input.get('edits', [])

View File

@@ -45,7 +45,9 @@ def main():
event = None
if tool_name == 'Bash':
event = 'bash'
elif tool_name in ['Edit', 'Write', 'MultiEdit']:
elif tool_name in ['Edit', 'Write', 'MultiEdit', 'Read']:
# Include Read tool in file events to check symlink bypass
# Security fix for CVE-2025-59829
event = 'file'
# Load rules

View File

@@ -1,7 +1,16 @@
{
"description": "Security reminder hook that warns about potential security issues when editing files",
"description": "Security hooks for file access validation and security pattern warnings",
"hooks": {
"PreToolUse": [
{
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/symlink_deny_hook.py"
}
],
"matcher": "Edit|Write|MultiEdit|Read"
},
{
"hooks": [
{
@@ -9,7 +18,7 @@
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/security_reminder_hook.py"
}
],
"matcher": "Edit|Write|MultiEdit"
"matcher": "Edit|Write|MultiEdit|Read"
}
]
}

View File

@@ -180,10 +180,46 @@ def save_state(session_id, shown_warnings):
pass # Fail silently if we can't save state
def resolve_symlink_path(file_path):
"""Resolve symlinks in file path to get canonical path.
Security fix for CVE-2025-59829: Deny rules could be bypassed by creating
a symlink to a restricted file. This method resolves the symlink to its
target path so that security patterns are checked against the actual file.
Args:
file_path: The file path that may contain symlinks
Returns:
The canonical path with symlinks resolved, or original path if
resolution fails (e.g., file doesn't exist yet)
"""
if not file_path:
return file_path
try:
# Expand user home directory first
expanded_path = os.path.expanduser(file_path)
# Use realpath to resolve all symlinks and get canonical path
# This handles nested symlinks and relative path components
resolved = os.path.realpath(expanded_path)
return resolved
except (OSError, ValueError):
# If resolution fails (e.g., permission denied, invalid path),
# return the original path to avoid blocking legitimate operations
return file_path
def check_patterns(file_path, content):
"""Check if file path or content matches any security patterns."""
# Security fix: resolve symlinks before checking patterns
# CVE-2025-59829: Security patterns could be bypassed via symlinks
resolved_path = resolve_symlink_path(file_path)
# Normalize path by removing leading slashes
normalized_path = file_path.lstrip("/")
normalized_path = resolved_path.lstrip("/")
for pattern in SECURITY_PATTERNS:
# Check path-based patterns
@@ -241,7 +277,7 @@ def main():
tool_input = input_data.get("tool_input", {})
# Check if this is a relevant tool
if tool_name not in ["Edit", "Write", "MultiEdit"]:
if tool_name not in ["Edit", "Write", "MultiEdit", "Read"]:
sys.exit(0) # Allow non-file tools to proceed
# Extract file path from tool_input

View File

@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""
Symlink Deny Hook for Claude Code
Security fix for CVE-2025-59829: Deny rules could be bypassed via symlinks.
This hook resolves symlinks before checking file paths against deny patterns,
preventing attackers from using symlinks to access restricted files.
"""
import json
import os
import re
import sys
from fnmatch import fnmatch
# System directories that should be blocked by default
# These match common deny rule patterns
BLOCKED_PATHS = [
"/etc/**",
"/etc/passwd",
"/etc/shadow",
"/etc/sudoers",
"/etc/ssh/**",
"/etc/ssl/**",
"/root/**",
"/var/log/**",
"/proc/**",
"/sys/**",
"/boot/**",
]
def resolve_symlink_path(file_path: str) -> str:
"""Resolve symlinks in file path to get canonical path.
Args:
file_path: The file path that may contain symlinks
Returns:
The canonical path with symlinks resolved, or original path if
resolution fails (e.g., file doesn't exist)
"""
if not file_path:
return file_path
try:
# Expand user home directory first
expanded_path = os.path.expanduser(file_path)
# Use realpath to resolve all symlinks and get canonical path
resolved = os.path.realpath(expanded_path)
return resolved
except (OSError, ValueError):
return file_path
def is_path_blocked(resolved_path: str, original_path: str) -> tuple:
"""Check if the resolved path matches any blocked patterns.
Only blocks if:
1. The path was a symlink (resolved != original)
2. The resolved path matches a blocked pattern
Args:
resolved_path: The canonical path after symlink resolution
original_path: The original path before resolution
Returns:
Tuple of (is_blocked: bool, reason: str)
"""
# Only apply symlink protection if path was actually a symlink
original_real = os.path.realpath(os.path.expanduser(original_path))
if original_real == resolved_path:
# Check if original was a symlink
expanded_original = os.path.expanduser(original_path)
if not os.path.islink(expanded_original):
# Not a symlink, allow normal deny rule checking to handle this
return False, ""
# Check if resolved path matches any blocked patterns
for pattern in BLOCKED_PATHS:
if pattern.endswith("/**"):
# Directory wildcard pattern
base_dir = pattern[:-3]
if resolved_path.startswith(base_dir + "/") or resolved_path == base_dir:
return True, f"Symlink bypass blocked: '{original_path}' resolves to '{resolved_path}' which matches blocked pattern '{pattern}'"
elif fnmatch(resolved_path, pattern):
return True, f"Symlink bypass blocked: '{original_path}' resolves to '{resolved_path}' which matches blocked pattern '{pattern}'"
return False, ""
def main():
"""Main hook function."""
try:
input_data = json.load(sys.stdin)
except json.JSONDecodeError:
sys.exit(0) # Allow on parse error
tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input", {})
# Only check file-related tools
if tool_name not in ["Read", "Edit", "Write", "MultiEdit"]:
sys.exit(0)
# Extract file path
file_path = tool_input.get("file_path", "")
if not file_path:
sys.exit(0)
# Resolve symlinks
resolved_path = resolve_symlink_path(file_path)
# Check if blocked
is_blocked, reason = is_path_blocked(resolved_path, file_path)
if is_blocked:
# Output denial response
response = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny"
},
"systemMessage": f"Security: {reason}"
}
print(json.dumps(response))
sys.exit(0)
# Allow the operation
sys.exit(0)
if __name__ == "__main__":
main()