Skip to content

Resolve the last component of a write, not just the directory - #103

Closed
beardthelion wants to merge 1 commit into
CopilotKit:mainfrom
beardthelion:fix/workspace-symlink-at-target
Closed

Resolve the last component of a write, not just the directory#103
beardthelion wants to merge 1 commit into
CopilotKit:mainfrom
beardthelion:fix/workspace-symlink-at-target

Conversation

@beardthelion

Copy link
Copy Markdown
Contributor

Closes #100.

resolvePath resolves the directory a write lands in and returns the lexical target, so a symlink sitting at the last component is followed by writeFile and the bytes land wherever it points, as root. The module documents three confinement layers and names the symlink layer as the one people miss; on the write path it covers everything except the name being written to.

What it does

Resolves the last component too, when it is a link:

  • lstat the target. Only a symlink is treated specially, so an ordinary file or a name that does not exist yet takes the path it took before.
  • A link that resolves is checked against the root with the same assertInside as every other layer.
  • A dangling link is refused rather than resolved. It points at a file that does not exist, so there is nothing to compare, and writing through one creates the file it names.

Links pointing back inside still work, for writes as well as reads. The guard has to confine rather than forbid: refusing every link would pass every refusal test in the suite and break what links are legitimately for.

Append takes the same resolved path and is covered by the same check.

Verification

Four new cases in agent-computer/tests/workspace.test.ts, against real inodes in a temporary directory, because a symlink test with a fake filesystem tests the fake. Three fail before the change and pass after:

  • a write through a link at the file itself, asserting the outside file is untouched rather than only that the call refused
  • the same through append
  • a write through a dangling link, asserting no file is created outside

The fourth extends the existing "points back INSIDE still works" case to write as well as read, which is the assertion that would catch a fix that simply forbade links.

153 tests pass in agent-computer. bunx tsc --noEmit and bunx biome check clean on the changed files. Whole-tree bun test failure set is identical to main (the integration tests wanting a Postgres).

Note on the shared arrangement

Without COMPUTER_SUPERVISOR_URL every Bot shares one container and one /workspace, so this is also the path by which one Bot plants a link and another writes through it. That arrangement has other reasons not to be used for mutually untrusted Bots, and shell.ts says so already; this closes the file boundary either way.

Path confinement is described here as three layers, and the third one, resolving
symlinks, stops one component short on the write path. `resolvePath` resolves the
directory a write lands in and then returns the lexical target, so a link sitting where
the file goes is followed by `writeFile` and the bytes land wherever it points. A
workspace holding `notes.md -> /etc/crontab` takes a write to `notes.md` and puts it in
`/etc/crontab`, as root, with the request refused by nothing.

Nothing in this API creates a link, which is why it reads as covered. The links come
from everywhere else: a shell session, an archive a Bot unpacked, a volume left over
from an earlier deployment, and in a container shared between Bots, the other Bot.

A dangling link is refused rather than resolved. It points at a file that does not
exist yet, so there is nothing to compare against the root, and writing through one
creates the file it names, which is the same escape with an extra step.

Links pointing back inside still work, for writes as well as reads. The guard has to
confine rather than forbid; refusing every link would pass every refusal test here and
break what links are legitimately for.
@beardthelion

Copy link
Copy Markdown
Contributor Author

Duplicate of #74, which came first. Closing in favour of it: #74 also resolves a link pointing at a file that does not exist yet, which this one refuses outright, and it handles a workspace sitting behind a symlinked root.

@beardthelion
beardthelion deleted the fix/workspace-symlink-at-target branch August 21, 2026 20:47
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.

A write follows a symlink at the file itself, so the workspace boundary is one component short

1 participant