[BUG] extensibility.py follows symlinks in project-controlled guidance file, sending local file content to API

Resolved 💬 1 comment Opened Jun 1, 2026 by QiushiWu95 Closed Jul 5, 2026

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.py lines 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-guidance plugin cached (claude plugin marketplace update claude-plugins-official)
  • Python 3.9+

Automated PoC (no real API calls):

  1. Save the attached poc_extensibility_symlink.py
  2. Run: python3 poc_extensibility_symlink.py
  3. 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.md as 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 clone preserves symlinks by default, making this viable in a supply-chain scenario (malicious repo on GitHub).
  • The 8KB cap (GUIDANCE_MAX_BYTES at 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.

poc_extensibility_symlink.log
poc_extensibility_symlink.py

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗