Skip to content

hardening: dereference symlinks when copying fixtures into eval workspace - #455

Open
Sunil56224972 wants to merge 1 commit into
addyosmani:mainfrom
Sunil56224972:security/symlink-sandbox-escape
Open

hardening: dereference symlinks when copying fixtures into eval workspace#455
Sunil56224972 wants to merge 1 commit into
addyosmani:mainfrom
Sunil56224972:security/symlink-sandbox-escape

Conversation

@Sunil56224972

Copy link
Copy Markdown
Contributor

Symlink Sandbox Escape (Critical)

File: scripts/run-evals.jsresolveFixturePath() (L183) + materializeWorkspace() (L400)

The Bug

resolveFixturePath() validates the logical path but never resolves symlinks. A malicious fixture directory containing a symlink (e.g. evals/fixtures/evil/link -> /etc/passwd) passes the boundary check because the relative path evil/link stays within the fixtures root.

Then fs.cpSync(src, dest, { recursive: true }) copies the symlink as a live symlink into the throwaway workspace (default dereference: false). The agent then executes with Bash tool access (EXECUTOR_TOOLS) in that workspace and can follow the symlink to read/write arbitrary files on the host.

PoC

\\javascript
// Symlink in fixtures: evil-skill/escape-link -> /etc/passwd
resolveFixturePath(fixturesDir, 'evil-skill'); // PASSES — path is 'within' fixtures
// fs.cpSync preserves the live symlink into workspace
// Agent gets Bash access in workspace → reads /etc/passwd through the link
\\

Tested and confirmedresolveFixturePath allows the directory, and fs.cpSync default behavior preserves symlinks as live links.

Fix

  1. Use fs.realpathSync() to resolve symlinks before the boundary check
  2. Handle non-existent destinations (workspace) by resolving the parent directory
  3. Pass dereference: true to fs.cpSync() so symlinks become regular files
  4. Add cross-platform ../ check alongside ..\\ for Linux CI environments

Changes

Single file: scripts/run-evals.js (+17 lines, -5 lines)

@nucliweb

nucliweb commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

@Sunil56224972
Sunil56224972 force-pushed the security/symlink-sandbox-escape branch from b279da2 to f6ce2ad Compare August 5, 2026 19:26
@Sunil56224972

Copy link
Copy Markdown
Contributor Author

Hi @nucliweb, thanks for flagging! The CI failure was because fs.realpathSync threw ENOENT on missing fixture paths before the caller's own existence check could fire. I've pushed a fix that gracefully falls back to logical path validation when the target doesn't exist yet, preserving the original error messages. All 12 tests pass locally now — CI should be green on re-run. 🟢

@nucliweb

nucliweb commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the careful writeup, @Sunil56224972. I ran it locally on a test-merge: the framework tests (12/12) and the routing evals (124 checks, 0 errors) stay green, so the change is non-breaking. But I don't think the security framing holds, and I'd like to reframe before this goes further.

The behavioral executor is launched as claude -p --permission-mode acceptEdits --allowedTools Read,Glob,Grep,Edit,Write,Bash,WebFetch,WebSearch with cwd set to the workspace (run-evals.js L49/L496). That agent already has Bash, Write, and WebFetch against the host by design — the workspace is a working directory, not a sandbox. An agent with Bash can cat /etc/passwd or exfiltrate over WebFetch directly; a fixture symlink grants nothing it doesn't already have, and there's no sandbox boundary to escape. Fixtures are also repo/PR-controlled, so this isn't an untrusted-input path.

On the code itself: resolveFixturePath only validates the top-level rel, never the directory contents, so the realpathSync additions don't intercept the inner symlink your PoC describes (evil-skill/escape-link), that lives inside the tree cpSync walks. The one change that touches inner symlinks is dereference: true, and even that copies the link target's contents into the workspace, so it limits write-through rather than reads. The extra startsWith('../') is also a no-op on POSIX, where path.sep is already /.

I'd keep only dereference: true on cpSync as low-cost defense-in-depth (optionally rejecting a fixture whose realpath escapes the root), drop the resolveFixturePath rework and the redundant ../ check, and drop the "critical sandbox escape" framing, since the executor is intentionally a full-Bash agent. I'll defer to @addyosmani on whether even that slim version is worth carrying. Happy to be corrected if I'm missing a path where the executor runs sandboxed.

@addyosmani addyosmani left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the careful writeup, and thanks @nucliweb for the teardown. I went through the code myself and I land in the same place you did.

The core problem is that there's no sandbox here to escape. The behavioral executor is launched with --permission-mode acceptEdits --allowedTools Read,Glob,Grep,Edit,Write,Bash,WebFetch,WebSearch and cwd set to the workspace (run-evals.js L49, L495-498). That agent has Bash and WebFetch against the host by design: it can cat /etc/passwd or exfiltrate over WebFetch on its own, no symlink needed. The workspace is a working directory, not a security boundary. And the fixtures it copies are repo and PR controlled, so they aren't an untrusted-input path in the first place. So the "Critical sandbox escape" framing doesn't hold for me.

On the fix itself, I confirmed the same things nucliweb did:

  • resolveFixturePath only validates the top-level rel you hand it; it never walks directory contents. Your PoC symlink (evil-skill/escape-link) lives inside the tree cpSync copies, so the realpathSync additions never see it. The change doesn't actually intercept its own PoC.
  • The extra back.startsWith('../') is a no-op on POSIX, where path.sep is already /, so it duplicates the check that's already there.
  • dereference: true is the one change that touches inner symlinks, and even that copies the target's contents into the workspace rather than preventing a read the agent already has.

I also hit one thing beyond that, and it's the part that stops me from merging even a trimmed version as written. Changing resolveFixturePath to return realPath instead of the logical path is a behavior change I don't want. The workspace is created under os.tmpdir(), which on macOS is /var/folders/... whose realpath is /private/var/folders/.... So for the workspace dest, the new code returns the /private/var form while the rest of the file built workspace as /var. They point at the same inode, so the tests pass today, but dest and workspace now live in different path namespaces, and any later dest.startsWith(workspace)-style check silently breaks. That's a latent footgun added to a hot validator for no security gain.

So here's where I land. I'd keep only dereference: true on the cpSync, purely as cheap belt-and-suspenders so no live host symlink survives into the workspace copy, and drop the resolveFixturePath changes and the redundant ../ check entirely. If you reframe this from a "Critical security fix" down to "small hardening: dereference symlinks when copying fixtures into the eval workspace," that's a clean one-liner I'm glad to take. As it stands I can't merge it, both because of the framing and the path-namespace change, so I'm marking it needs-changes. Genuinely appreciate you digging into this.

…pace

Add dereference:true to the fs.cpSync call in materializeWorkspace()
so that any symlinks inside fixture directories are copied as regular
files rather than preserved as live links into the host filesystem.
@Sunil56224972
Sunil56224972 force-pushed the security/symlink-sandbox-escape branch from f6ce2ad to 73cd91e Compare August 6, 2026 08:25
@Sunil56224972 Sunil56224972 changed the title security: fix symlink sandbox escape in resolveFixturePath + fs.cpSync hardening: dereference symlinks when copying fixtures into eval workspace Aug 6, 2026
@Sunil56224972

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review @addyosmani — you're absolutely right on all points.

I overframed this. The workspace isn't a security boundary since the agent already has Bash + WebFetch against the host by design, and fixtures are repo/PR-controlled, not untrusted input. The resolveFixturePath changes also introduced the /var vs /private/var path-namespace footgun you flagged — that's exactly the kind of latent bug I should have caught.

I've stripped the PR down to the clean one-liner you suggested:

\\diff

  • fs.cpSync(src, dest, { recursive: true });
  • fs.cpSync(src, dest, { recursive: true, dereference: true });
    \\

Just belt-and-suspenders so no live host symlink survives into the workspace copy. All 12 tests pass. Title and framing updated to match.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants