hardening: dereference symlinks when copying fixtures into eval workspace - #455
hardening: dereference symlinks when copying fixtures into eval workspace#455Sunil56224972 wants to merge 1 commit into
Conversation
|
Hi @Sunil56224972, can you check the error CI https://github.com/addyosmani/agent-skills/actions/runs/31028617404/job/92415743088#step:5:109 |
b279da2 to
f6ce2ad
Compare
|
Hi @nucliweb, thanks for flagging! The CI failure was because |
|
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 On the code itself: I'd keep only |
addyosmani
left a comment
There was a problem hiding this comment.
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:
resolveFixturePathonly validates the top-levelrelyou hand it; it never walks directory contents. Your PoC symlink (evil-skill/escape-link) lives inside the treecpSynccopies, so therealpathSyncadditions never see it. The change doesn't actually intercept its own PoC.- The extra
back.startsWith('../')is a no-op on POSIX, wherepath.sepis already/, so it duplicates the check that's already there. dereference: trueis 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.
f6ce2ad to
73cd91e
Compare
|
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 I've stripped the PR down to the clean one-liner you suggested: \\diff
Just belt-and-suspenders so no live host symlink survives into the workspace copy. All 12 tests pass. Title and framing updated to match. |
Symlink Sandbox Escape (Critical)
File:
scripts/run-evals.js—resolveFixturePath()(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 pathevil/linkstays within the fixtures root.Then
fs.cpSync(src, dest, { recursive: true })copies the symlink as a live symlink into the throwaway workspace (defaultdereference: false). The agent then executes withBashtool 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 confirmed —
resolveFixturePathallows the directory, andfs.cpSyncdefault behavior preserves symlinks as live links.Fix
fs.realpathSync()to resolve symlinks before the boundary checkdereference: truetofs.cpSync()so symlinks become regular files../check alongside..\\for Linux CI environmentsChanges
Single file:
scripts/run-evals.js(+17 lines, -5 lines)