Skip to content

fix(core): resolve symlinks before the external_directory boundary check - #52224

Closed
wulart wants to merge 1 commit into
anomalyco:devfrom
wulart:fix/symlink-external-directory-bypass
Closed

wulart wants to merge 1 commit into
anomalyco:devfrom
wulart:fix/symlink-external-directory-bypass

Conversation

@wulart

@wulart wulart commented Sep 30, 2026

Copy link
Copy Markdown

Issue for this PR

Closes #40160

Type of change

  • Bug fix

What does this PR do?

external_directory is the only path-level boundary in the default permission config, and FileAccess.resolve decides it with lexical containment (path.resolve + FSUtil.contains) while the file operations that follow go through the filesystem, which follows symlinks. A symlink inside the working directory pointing outside passes the check silently — no prompt, and the tool lands on the external file (#40160).

Repro on stock v2 (v2.0.19 / current v2 tip):

mkdir -p /tmp/outside && echo secret > /tmp/outside/notes.txt
ln -s /tmp/outside ./vendor    # inside the working directory
# ask the agent to read vendor/notes.txt

Verified by driving FileAccess.authorizeRead plus the real reader on pristine v2: externalDirectory is undefined, only a read assertion fires, and the outside contents are returned. Same for the explore agent (shell denied — the escape needs no shell), and for a symlink planted by git clone (git object mode 120000), so a cloned repo is enough to plant the vector.

The fix resolves symlinks in resolve before the containment check, with a three-level fallback (real path → parent's real path + basename for prospective targets → lexical path), and compares against the resolved working directory and worktree so a working directory reached through a symlink still counts as internal. Every realPath failure falls back (ENOTDIR, ELOOP, EACCES included), so the boundary check never crashes or fails open. Since all file tools and the shell cwd/cd gate resolve through this one function, the change covers read/write/edit/patch/glob/grep and the shell directory gate in one place.

Internal targets keep their lexical path and resource; external authorization is keyed on the resolved location, so one approval covers other symlink aliases to the same place — the consent workflow described in #30788 (ln -s ~/Obsidian/... ./notes works after one approval). The prompt now shows the real directory, which also addresses the misleading-path half of #40160.

The existing tests that asserted lexical authorization for in-location symlinks to external files are flipped to expect the external_directory prompt; in-location-to-in-location symlinks keep their lexical target.

How did you verify your code works?

  • Reproduced before / gated after on pristine v2 via FileAccess.authorizeRead + the real reader (file link, directory link, git-clone-dropped link, explore agent; direct outside read as control).
  • bun test in packages/core: 5552 passing; the failing set is identical to the unpatched baseline on this machine (pre-existing bash-timing/watcher environment flakes).
  • New symlink cases: prospective target below an external symlink, existing external target behind a link, outside link pointing inside stays internal, symlinked working directory stays internal, deep lexical fallback.
  • bun run typecheck clean. A same-shape fix has been running in production in an educational deployment since September, where external_directory is the workspace isolation boundary.

Screenshots / recordings

Not a UI change.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

FileAccess.resolve decides the external_directory boundary with lexical
containment while the file operations that follow go through the
filesystem, which follows symlinks. An in-directory link pointing outside
therefore passes the check silently and the tool lands outside the
working directory with no permission prompt (anomalyco#40160).

Resolve symlinks before the containment check with a three-level
fallback (real path, parent real path + basename for prospective
targets, lexical path), and compare against the resolved working
directory and worktree so directories reached through symlinks stay
internal. Internal targets keep their lexical path and resource;
external authorization is keyed on the resolved location so approvals
persist across symlink aliases. Covers read/write/edit/patch/glob/grep
and the shell cwd/cd gate, which all resolve through this function.

Every realPath failure falls back (ENOTDIR, ELOOP, EACCES included), so
the boundary check never fails open or crashes.

Flips the tests that asserted lexical authorization for in-location
symlinks to external files, and adds symlink coverage: prospective
target below an external symlink, existing external target behind a
link, outside link pointing inside, symlinked working directory, and
deep lexical fallback.

Co-Authored-By: Claude Code <[email protected]>
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for your contribution!

This PR doesn't have a linked issue. All PRs must reference an existing issue.

Please:

  1. Open an issue describing the bug/feature (if one doesn't exist)
  2. Add Fixes #<number> or Closes #<number> to this PR description

See CONTRIBUTING.md for details.

@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

@github-actions

Copy link
Copy Markdown
Contributor

Automated PR Cleanup

Thank you for contributing to opencode.

Due to the high volume of PRs from users and AI agents, we periodically close older PRs using automated criteria so maintainers can focus review time on the most active and community-supported contributions.

This PR was closed because it matched the following cleanup criteria:

  • The PR was created more than 1 month ago
  • The PR had fewer than 2 positive reactions
  • Positive reactions are counted as thumbs-up, heart, celebration, or rocket reactions on the PR

PRs created within the last month are not affected by this cleanup.

If you believe this PR was closed incorrectly, or if you are still actively working on it, please leave a comment explaining why it should be reopened. A maintainer can review and reopen it if appropriate.

Thanks again for taking the time to contribute.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Symlink inside the project bypasses external_directory, and the permission prompt shows the wrong path

1 participant