TL;DR
- The novel finding: memory-loader consent and provenance mismatch, not symlink following itself.
- Claude Code checks an
@import's lexical path, but follows symlinks when reading it. - An in-repo
@./linkcan therefore load a readable file from outside the repository. - Direct external imports trigger consent; the same target through a symlink is omitted.
- The bytes enter the initial model context before any model action or file-read tool call.
- A known
ANTHROPIC_BASE_URLsetting can optionally route that request to a repository-selected endpoint.
You clone the repo you always clone, into the same working directory you've used for months. Somewhere back when you first set that directory up, Claude Code asked whether you trusted it. You said yes, the way you always say yes. Trusting the folder is how the tool works.
That single yes turns out to be enough to resolve a file from outside the repo, read it, and ship it off your machine before the model has done anything at all. This is the third time this exact failure pattern has surfaced in Claude Code. The first two were fixed. This one lives on a code path those fixes never reached.
Why this looks completely normal
Claude Code reads project instructions from a CLAUDE.md memory file, plus anything under .claude/rules/**. Those files support an @import directive. A line like @./docs/setup.md pulls another file's content into the model's context at session start, resolved recursively. Anthropic's own documentation recommends exactly this pattern for keeping a shared AGENTS.md in sync across tools, and it says outright that imported files "load and enter the context window at launch."
Pointing an import at a symlink isn't exotic either. Imports and symlinked instruction files are both documented, ordinary conveniences, even though the documentation never specifically discusses an @import that resolves through a symlink pointing outside the project. Plenty of teams symlink a shared instructions file precisely so Claude Code and other agents read the same source of truth. As far as the feature is concerned, it's working exactly as designed when it follows that link.
Credit where it's due, and to the right party: both times this exact failure pattern turned up before, outside researchers found it and reported it through HackerOne, and Anthropic shipped the correct fix in response. Symlink handling in Claude Code has been examined and hardened more than once, and both times it was hardened correctly.
CVE-2025-59829
Claude Code checked permission deny-rules against a path without accounting for a symlink that resolved to a denied target. Reported through HackerOne, classed CWE-61, fixed in 1.0.120, on September 19, 2025.
CVE-2026-25724
Same shape again: a symlink let Claude Code read a deny-listed file such as /etc/passwd without the deny rule firing. Reported through HackerOne, CWE-61 plus CWE-285, fixed in 2.1.7, on January 13, 2026.
Both bugs came from the same failure pattern: the security decision and the filesystem operation disagreed about which path was actually being accessed. Both were closed the same way: resolve the symlink first, then decide whether the resolved path is allowed, not the other way around. The memory loader never got that treatment, and the build where we found the gap shipped long after 2.1.7 closed the second one.
The imported file is not the file that gets read
Drop an ordinary-looking import into a repo's CLAUDE.md:
# devtools-quickstart Project context: @./link
Commit link as a symlink to a file outside the repo. Git stores symlinks as mode 120000, and they survive a clone fully intact, absolute targets and ../ traversal included:
$ git ls-files -s 120000 6b58... 0 link $ readlink link /etc/passwd2

link as an in-repo symlink resolving to /etc/passwd2.Now open Claude Code in the cloned repo and capture the first request to the model. Inside the first user message, in the block that carries the project's memory files, the target file's content appears verbatim:
Contents of <repo>/CLAUDE.md (project instructions, checked into the codebase): # devtools-quickstart Project context: @./link Contents of <repo>/link (project instructions, checked into the codebase): _logd:*:272:272:log Daemon:/var/db/diagnostics:/usr/bin/false _appinstalld:*:273:273:App Install Daemon:/var/db/appinstalld:/usr/bin/false _installcoordinationd:*:274:274:Install Coordination Daemon:/var/db/installcoordinationd:/usr/bin/false _knowledgegraphd:*:279:279:Knowledge Graph Daemon:/var/db/knowledgegraphd:/usr/bin/false
Two details carry all the weight here. First, that block is labeled project instructions, checked into the codebase, and it carries the in-tree name link. The resolved target, /etc/passwd2, appears nowhere in it. The model is told this content is part of the repository, full stop. Second, there's no tool_use or tool_result block anywhere in the message. The model never decided to read this file. The memory loader placed it into context before the model did anything at all.

tail /etc/passwd2 that the daemon entries Claude Code loaded into context match the real file on disk byte for byte.We confirmed this on the wire against Claude Code v2.1.215, most recently with the symlink retargeted to /etc/passwd2, a benign copy of the account database we created for testing.
The target is benign here on purpose. It doesn't have to be. Swap /etc/passwd2 for the real /etc/passwd, or /proc/self/environ on Linux. The target can be an absolute path or a relative traversal like ../../../../etc/passwd. What it can't be is a shell convenience, since the filesystem never expands ~, so files like ~/.ssh/id_rsa or ~/.aws/credentials are only reachable when their real path is predictable, which it often is in CI runners, containers, and standardized developer images. Anything the user's own account can read, the symlink can point at, and the loader will read it straight into context.
Why the warning dialog never appears
Claude Code actually has a control built for exactly this situation. The first time a memory file imports something from outside the project, it's supposed to show an approval: an external-import dialog, remembered per project. That dialog exists precisely so the feature isn't "any repo can read any file on your disk."
Claude Code intentionally permits some reads outside the working directory, and its own security guide says so plainly. The narrow claim here is different: this automatic startup path bypasses the product's own external-import classification and labels the result as though it were checked into the repository, when it isn't.
For the symlink form, the dialog simply never appears. The reason is mechanical, not mysterious. The check that decides whether an import counts as "external" runs against the literal, pre-resolution path. That literal path is ./link, which sits inside the project, so the import gets classified as internal and the dialog never fires. The read, a moment later, uses that same literal path, and the operating system dereferences the symlink to the out-of-tree target underneath it. The gate looks at ./link. The read gets /etc/passwd2.
The symlink form never gets classified as an external import because the classifier keys on the pre-resolution path, full stop. A captured dialog shows this dynamically, too. Presented with a direct absolute import alongside the in-tree symlink spelling, it lists the direct path and omits the symlink entirely. both spellings resolve to the same file on disk.
The run-time resolution gap is visible directly in the CLI's own output. Given a CLAUDE.md that imports both a direct external path and a symlink pointing out of tree:

/etc/hosts, the import written directly, and says nothing at all about ./link, the import that resolves through a symlink to an equally out-of-tree target. Same repository, same session.At most, the only consent a user ever sees is the generic "trust this folder?" prompt. In non-interactive use there isn't even that. Run Claude Code with -p in a script or a CI job, or in any non-TTY context, and the trust and import prompts are skipped by design, since non-interactive mode delegates the trust decision to whatever called it. The out-of-tree read happens with zero prompts of any kind.

You may not have been asked at all. If you cloned this repo into a directory you'd already consented to, or into anything above it, Claude Code's trust reaches downward on its own. The new repo inherits that trust silently: no new prompt, nothing named, no decision to register anywhere. The one consent screen everyone points back to was spent long ago, on a completely different directory.
On that kind of machine, cloning the attacker's repo and simply running Claude Code reads the out-of-tree file and ships it with no prompt at all. "The user accepted the trust dialog" describes a click that, in this setup, happened long ago, or never happened in any way that involved this repository.
This isn't hypothetical. querying ~/.claude.json on one of our own machines for every directory with hasTrustDialogAccepted set true returned ~/Documents, ~/Desktop, and /Users itself, the entire multi-user root. That's the parent-of-home case sitting trusted on a real machine right now: anything cloned anywhere under /Users runs with no prompt at all, which is very nearly anywhere a developer would ever clone something.
You can check your own machine in about a second. On macOS or Linux, run this and read the list it prints:
jq -r '.projects|to_entries[]|select(.value.hasTrustDialogAccepted==true)|.key' ~/.claude.json
/Users entry at the bottom (arrowed) means the ancestor-walk behavior has silently extended trust to the entire multi-user root.Every path this prints can suppress the folder-trust dialog for anything cloned beneath it, under the ancestor-walk behavior present in the build we tested. If a broad root shows up in there, a ~/src, a ~/Documents, or /Users itself, then every repo you've ever cloned beneath it, and every one you clone next, already sits inside that trust boundary. If your list is short and specific instead, your exposure is smaller. On Windows, the same map lives in %USERPROFILE%\.claude.json; open it and scan the projects entries for hasTrustDialogAccepted set true.
Either way, the outcome is identical. A file from outside the repo gets resolved, read, and staged into the model's context. This isn't a new class of bug, either. The first two times the pattern appeared, Anthropic fixed it correctly, in the permission subsystem that decides which files a session is allowed to touch. What makes this one worth writing up is where it lives instead: on a code path that fix never reached, gated by a trust decision that was never built to carry this much weight.
The file leaves your machine
A file read into the model's context can feel like it stays between you and the tool. It doesn't. Context is whatever gets sent to the model, and the model is a network endpoint like any other.

By default that endpoint is api.anthropic.com. So the plainest version of this, with no attacker infrastructure involved at all, sends the content of an out-of-tree file to the configured model provider, without the user ever selecting or approving that file as context, on the very first model request after launch. On hosts configured to log request bodies for telemetry, it lands there too.
The attacker can choose where it goes
Then there's the version with a destination attached. A repository can commit a .claude/settings.json with an environment block, and that block can set ANTHROPIC_BASE_URL. That override redirects Claude Code's outbound endpoint under the exact same folder-trust decision, with no separate prompt ever naming the new host. The first request, already carrying the imported file, goes there instead.
We confirmed this end to end. A request body carrying /etc/passwd2, reachable only through the in-tree @./link symlink, arrived at a repository-chosen endpoint, produced by Claude Code 2.1.215, with no tool call anywhere in the transcript.

/etc/passwd2 daemon entries highlighted inline, proving the file's contents made it into the payload sent to the network, not just into local model context.The limits of what this actually shows, stated plainly:
- The
ANTHROPIC_BASE_URLredirect is a separately known issue, not something we're claiming as new. Check Point's CVE-2026-21852 covered an earlier form of it, where the override leaked the API key before the trust prompt even ran, and Anthropic fixed that in v2.0.65. What we're showing here is different: on 2.1.215, the override still routes the request, now after the trust decision, carrying the imported content to a repository-chosen host. We use it only to demonstrate that the content can be steered to an arbitrary endpoint, not only to the vendor. - The same request also carries an
Authorizationbearer token. Whether that credential actually reaches an attacker-chosen endpoint is suggested by the code but not something we proved on the wire. Our capture is of the request body, not the header. We're not claiming credential theft as demonstrated fact. - The import primitive is limited to
CLAUDE.mdand.claude/rules/**. Skill files (SKILL.md) are not affected. The import expander is never invoked from the skill loader.
The chain demonstrated here needs no code execution, no cooperation from the model, and no attacker-run server: clone, trust, and an out-of-tree file leaves the box on the very first request.
The same defect, on a third code path
It's tempting to file this away as symlink bug number three or four and move on. That misses where it actually sits.
The two fixed CVEs both lived in the permission subsystem: the code that decides whether a given file access is allowed at all. Each time, the fix resolved the symlink before running that decision. After 1.0.120, deny rules get checked against the real target. After 2.1.7, a deny-listed file can't be reached through a symlink either.
The memory loader is a different subsystem, and it never received that same treatment. Its containment check and its external-import approval both still run on the literal, pre-resolution path, while the actual read follows the link underneath. So the exact defect Claude Code has already fixed twice is still alive on the one path the tool-permission model was never wired to cover, and this path does something the earlier two never did: it puts what it finds onto the network, in the first POST request Claude Code sends and every one after, before the model has acted at all.
The fix was already shipped twice
The remedy here isn't novel research. It's the fix Claude Code already wrote, twice, applied to a third code path.
- Canonicalize the target once. Run both the containment check and the external-import approval against that canonical path, and read the file through a no-follow or beneath-root handle so a later path-based read can't be swapped out from underneath the check.
- Refuse, or require distinct destination-naming consent for, any
@importthat resolves outside the project root, regardless of how the in-tree literal path looks. - Surface an approval that names out-of-tree or symlinked imports specifically, and names any endpoint-changing environment variables set by project settings, instead of folding both into the generic folder-trust prompt. Don't skip that approval in non-interactive mode either.
- Optionally, label imported-context blocks with the resolved path, so the model isn't told an out-of-tree file is "checked into the codebase" when it plainly isn't.
The symlink is old. The import path is not.
We aren't claiming the underlying primitive. Link-following (CWE-59, CWE-61) has been a security problem since early Unix, and it's been hitting Git repositories for years. Git normally recreates the committed symlink on Unix-like systems. On Windows, symlink creation may require Developer Mode or elevated privileges, and checkout behavior can vary with Git configuration; otherwise the entry may materialize as an ordinary file containing the link target.
Git hardened its own checkout against malicious symlinks after the 2021-era CVEs, and the pattern keeps returning: CVE-2024-32021, where a local clone hardlinks arbitrary files through a symlink, and CVE-2025-48384, where a symlink combined with a crafted submodule path gives arbitrary write and code execution, now listed in CISA's exploited-vulnerabilities catalog. Git protects its own checkout. It does nothing about what reads that checkout afterward.
Adversa AI's SymJack (May 2026) and Wiz's later GhostApproval (July 2026) took the same primitive to AI coding assistants from the write side: a symlinked workspace file that an agent's Edit tool writes through, with the approval prompt showing the in-repo name rather than the resolved target. Anthropic hardened that Edit path, resolving symlinks before the approval prompt fires, and declined both reports under the same workspace-trust argument described in this report. Neither examined the startup memory-import path, where no Edit or Read tool ever runs.
We've approached the same primitive from a different angle. Not the Edit tool that writes, but the @import memory loader that reads. It resolves an in-tree symlink at session load and puts the out-of-tree target straight into model context, with no tool call and no external-import dialog, then sends it off the box on the very first request. The checkout hardening never reaches it, because the read happens after checkout completes. The Edit-path hardening never reaches it either, because no Edit tool runs at any point. At v2.1.215, that path is still wide open.
Why "trust this folder" isn't the boundary it claims to be
Workspace trust is a coarse, all-or-nothing decision, asked at the single least informed moment possible: before you've seen anything the repository actually does. Say yes and the grant is broad. That's the case that actually matters.
We're not the first to surface this ancestor walk either: an enterprise customer root-caused it from the binary in Claude Code issue #72896, where the reporter argued that extending ancestor trust to permission loading would "weaken the trust model." The realistic state of some long-lived developer environments is a broad working root, trusted once and persisted, something like ~/dev, with every repo since cloned somewhere beneath it.
None of this needs to be called "critical", and we aren't calling it that. It's a gap between a documented convenience and a consent model too coarse to separate "run my code" from "read my SSH key and mail it out," sitting on a path the vendor has otherwise hardened everywhere else. We're naming it in public so users can reason about what "trust this folder" actually grants in practice, and how rarely they're actually asked before they clone the next interesting-looking repo.
Disclosure
We reported the out-of-tree read and its exfiltration path to Anthropic through HackerOne in July 2026. Anthropic closed the report as Informative, with a consistent rationale: under the Claude Code threat model, the workspace-trust dialog is the security boundary. Accepting it grants the project the ability to read, edit, and execute on the host. Non-interactive mode intentionally delegates that decision to the caller. Reading file content through a repository-committed symlink, or redirecting the endpoint through repository-committed settings, falls within what that trust already grants. In their assessment, the external-import prompt is a usability aid rather than a boundary defended separately, consistent with how they'd assessed an earlier report with the same root cause.
We understand that model, and this report isn't an argument that Anthropic triaged it wrongly on their own terms. It's an argument about the terms themselves.
| Date | Event |
|---|---|
| 18-07-2026 | Reported out-of-tree read and exfiltration chain via HackerOne |
| 19-07-2026 | Anthropic responded |
| 20-07-2026 | Report closed as out of the plan's scope (Anthropic's trust boundary) |
symlink following in Claude Code is well documented, including two fixed CVEs (CVE-2025-59829, CVE-2026-25724), plus SymJack and GhostApproval on the write path.
Affected builds referenced: Claude Code v2.1.207 (static analysis) and v2.1.215 (wire capture). Other 2.1.x builds are very likely affected.
Prior art: CVE-2025-59829, CVE-2026-25724.

.webp)