[BUG] extensibility.py follows symlinks in project-controlled guidance file, sending local file content to API
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
The security-guidance plugin's extensibility.py follows symlinks when reading .claude/claude-security-guidance.md from a project directory. Python's open() at line 109 transparently traverses symlinks without any path validation.
If a malicious repository commits .claude/claude-security-guidance.md as a symlink pointing to any local file readable by the user (e.g., ~/.ssh/id_rsa, ~/.aws/credentials), the plugin reads that file's content (up to 8KB), wraps it in <project-security-guidance> XML tags, and sends it to the Anthropic API as part of the LLM security review prompt.
This happens silently every time the plugin's review triggers (on Stop, PostToolUse for file edits, and commit/push events).
Affected code:
plugins/security-guidance/hooks/extensibility.pylines 107-109- Plugin version: 2.0.1
for label, path in _config_paths(cwd, GUIDANCE_BASENAME):
try:
with open(path, encoding="utf-8") as f: # follows symlinks without validation
txt = f.read().strip()
_config_paths() constructs the project path at line 98:
paths.append(("Project", os.path.join(cwd, ".claude", basename)))
No os.path.realpath() or containment check is performed before reading.
What Should Happen?
The plugin should resolve the path with os.path.realpath() before opening and verify that the resolved path remains within the expected directory boundary (project root for project paths, ~/.claude/ for user paths). If the resolved path escapes the boundary, the file should be skipped with a debug log.
Suggested fix:
for label, path in _config_paths(cwd, GUIDANCE_BASENAME):
resolved = os.path.realpath(path)
if cwd and label == "Project":
if not resolved.startswith(os.path.realpath(cwd) + os.sep):
debug_log(f"extensibility: skipping {path}: resolves outside project to {resolved}")
continue
try:
with open(resolved, encoding="utf-8") as f:
txt = f.read().strip()
The same fix should apply to _load_user_patterns() which has an identical issue with security-patterns.{yaml,json}.
Error Messages/Logs
No error messages are produced; the issue is silent. The symlink is followed successfully and the content is transmitted without any warning or log visible to the user.
Steps to Reproduce
Prerequisites:
- Claude Code with the
security-guidanceplugin cached (claude plugin marketplace update claude-plugins-official) - Python 3.9+
Automated PoC (no real API calls):
- Save the attached
poc_extensibility_symlink.py - Run:
python3 poc_extensibility_symlink.py - Observe all checks PASS and
VERDICT: VULNERABLE
The PoC:
- Creates a "sensitive" file outside a project directory
- Creates a project with
.claude/claude-security-guidance.mdas a symlink to that file - Starts a local HTTP capture server (no real API calls made)
- Imports the actual plugin modules from the cached plugin directory
- Triggers
extensibility.load_for_session()+llm.analyze_code_security() - Captures the HTTP POST body and confirms the secret marker is present
- Runs a negative control (project without symlink) to confirm the symlink is the causal mechanism
<details>
<summary>Expected PoC output</summary>
======================================================================
PoC: Symlink-following in security-guidance extensibility.py
======================================================================
[+] Created sensitive file outside project
[+] Created malicious project with symlink:
.claude/claude-security-guidance.md -> /tmp/.../sensitive_credentials.env
[+] Created benign project (negative control)
[+] Capture server on http://127.0.0.1:XXXXX
[+] Imported plugin modules
[*] POSITIVE test (project with symlink to outside file)...
[*] NEGATIVE test (project without symlink)...
======================================================================
RESULTS
======================================================================
[PASS] symlink_exists = True
[PASS] symlink_target_outside_project = True
[PASS] guidance_loaded_secret = True
[PASS] guidance_has_wrapper_tag = True
[PASS] POSITIVE_prompt_contains_secret = True
[PASS] NEGATIVE_prompt_contains_secret = False
[PASS] api_endpoint_hit = True
VERDICT: VULNERABLE
</details>
Manual reproduction:
# 1. Create a sensitive file
mkdir -p /tmp/bug003-demo
echo "SECRET_KEY=my_private_credential" > /tmp/bug003-demo/secret.env
# 2. Create a malicious project with symlink
mkdir -p /tmp/bug003-demo/evil-repo/.claude
ln -s /tmp/bug003-demo/secret.env /tmp/bug003-demo/evil-repo/.claude/claude-security-guidance.md
# 3. Verify symlink is followed
cat /tmp/bug003-demo/evil-repo/.claude/claude-security-guidance.md
# Output: SECRET_KEY=my_private_credential
# 4. Running Claude Code in evil-repo/ with security-guidance plugin enabled
# will cause secret.env content to appear in the API request body
Claude Model
Not sure / Multiple models
Is this a regression?
Yes, this worked in a previous version
Last Working Version
_No response_
Claude Code Version
2.1.123
Platform
AWS Bedrock
Operating System
Ubuntu/Debian Linux
Terminal/Shell
Other
Additional Information
git clonepreserves symlinks by default, making this viable in a supply-chain scenario (malicious repo on GitHub).- The 8KB cap (
GUIDANCE_MAX_BYTESat line 45) limits data per invocation but does not prevent the unauthorized read. - The basename is fixed (
claude-security-guidance.md) — this is not arbitrary../path traversal, but symlink-following on a project-controlled path. - Filing as a bug rather than to the HackerOne bounty program since symlink-based attacks are explicitly out of scope per the program rules. However, the underlying code defect (missing path validation) is a legitimate hardening fix.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗