Skip to content

Resolve the name a write lands on, not only the directory above it - #74

Open
Hotragn wants to merge 1 commit into
CopilotKit:mainfrom
Hotragn:resolve-the-name-a-write-lands-on
Open

Resolve the name a write lands on, not only the directory above it#74
Hotragn wants to merge 1 commit into
CopilotKit:mainfrom
Hotragn:resolve-the-name-a-write-lands-on

Conversation

@Hotragn

@Hotragn Hotragn commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

What this changes

resolvePath resolved the directory a write would land in and not the name it would land on
(agent-computer/src/workspace.ts:120):

const anchor = forWrite ? dirname(target) : target;

A read resolves target, so a link at notes.txt is followed and its destination checked. A write
resolves dirname(target), which proves where a new file would be created and proves nothing about
a name already there — and writeFile follows a link at the last component exactly as readFile
does. A link at notes.txt pointing at /outside/secret.txt has no .., is not absolute, and sits
directly in the workspace, so it passes all three layers and the bytes land outside.

The write side now resolves the last component too, by walking it with lstat/readlink rather than
realpath. realpath throws on a dangling link, so a check built on it reads "nothing is there",
takes the forWrite branch, and lets the write through the failure path while writeFile creates the
destination anyway. That is the harder half of the bug and the walk has to survive it. Hops are
bounded, so a cycle is refused with a WorkspacePathError rather than surfacing an ELOOP out of
the write.

Confining, not forbidding, the same as the read side: a link pointing back inside the workspace keeps
working. Refusing every link would pass the escape tests and quietly break a Bot that keeps
latest.csv pointing at the newest report.

Why it is worth doing when a shell can write outside anyway. It is not a fresh escape for a Bot
holding run_command — that Bot can write outside directly, and the link has to get there first,
which takes a shell. What it is, is a hole in what the gateway can still see. The gateway decides and
writes the row in another process, from the path as the Bot asked for it
(server/src/computer/gateway.ts:352describeFile at :700-714). So a CEL rule written against
file.path, file.name or file.extension is evaluated against notes.txt, a deny rule for
credentials/ or *.env does not fire, and the audit row names a file in the workspace that nothing
touched. A deployment that denies run_command and allows write_file is relying on exactly that.

Same argument #68 landed on for bash -lc: a permissive workspace is a decision a deployment can
make, and a trail that describes a different file from the one on disk is not.

Closes #73

Where it runs

  • New state that outlives a request? None. The walk is local to one resolvePath call and
    returns a string.
  • What happens on the second replica? Identical. Every replica resolves the path it was handed
    against its own filesystem, as before; nothing is shared or remembered.
  • Anything serialised? No. No writes to Postgres, no ordering requirement. The check-then-write
    against the filesystem is discussed under "What is not covered".
  • Anything fanned out to a browser? No.
  • New listener, port, or schedule? No.

Boundary and audit

  • No change to resolve → decide → audit → act. This is inside act, in the computer process, after
    the gateway has already decided and written the row.
  • No new refusal type. A refused write already surfaces as WorkspacePathError and is already
    recorded as a failed action; this makes five more shapes reach that path instead of succeeding
    silently. The cycle case changes an uncaught ELOOP into the same WorkspacePathError every other
    refusal uses.
  • Nothing new is trusted from the client. Strictly less is: the last component of the requested path
    used to be taken as final.

Changelog

Added under UnreleasedFixed:

  • A write could follow a symlink out of the Bot's workspace. The confinement resolved the
    directory a write would land in but not the name it would land on, so a link left at notes.txt
    pointing outside was followed by the write. Reads already refused the identical link. The gateway
    had decided and written the audit row against the path as asked for, so a rule written for
    credentials/ never saw the file that was written and the trail named a file nothing had touched.
    Links pointing back inside the workspace continue to work.

Proof

Six tests added to agent-computer/tests/workspace.test.ts, run under Linux (bun 1.4.0) because
symlink needs privilege on Windows.

They fail on main. upstream/main's workspace.ts with the new tests:

5 tests failed:
(fail) refuses to write THROUGH a symlinked FILE that points outside
(fail) refuses to append THROUGH a symlinked file that points outside
(fail) refuses to write through a DANGLING link that points outside
(fail) refuses a chain of links that ends up outside
(fail) refuses a cycle of links rather than following it forever

 27 pass, 5 fail, 50 expect() calls

The first of those, in full — the write is not refused, it succeeds:

error: expect(received).rejects.toThrow(expected)
Expected promise that rejects
Received promise that resolved: Promise { <resolved> }

and the cycle fails as ELOOP: too many symbolic links encountered thrown by writeFile, rather
than as a refusal.

The sixth test — a link pointing back inside still working — passes on main and after, which is
what says this confines rather than forbids.

With the change:

agent-computer/tests/workspace.test.ts   32 pass, 0 fail, 54 expect() calls
agent-computer (whole package)          104 pass, 0 fail, 206 expect() calls

biome format and biome lint clean on both changed files. tsc --noEmit clean in
agent-computer.

What is not covered

  • The check is not atomic with the write. A Bot that can replace notes.txt with a link between
    resolvePath returning and writeFile running still wins that race. The change narrows it rather
    than closing it: the resolved destination is what gets written, so the final component is no longer
    followed at write time. Closing it properly wants O_NOFOLLOW on the last component, which is not
    reachable through node:fs/promises writeFile. Worth its own issue if you want it.
  • A Bot with a shell is unaffected, by design. It can write outside directly. This is about what
    the policy and the trail can still describe, not about containing that Bot.
  • Hops are capped at 32 (Linux gives up at 40). A legitimate chain that long does not occur; the cap
    is there so a cycle terminates.

Merge notes

  • Touches CHANGELOG.md, as does Sign in for real, and decide who gets in #67. One added line at the top of UnreleasedFixed; a textual
    conflict at most, whichever lands first.
  • agent-computer/src/workspace.ts and its test are otherwise untouched by anything open. That file
    has not changed since the repository's first commit.
  • Nothing here overlaps feat: add optional Cua Driver computer backend #65, which is the other open change inside agent-computer: that one adds a
    browser backend and touches browser.ts, index.ts and the manifests, not the workspace guard.
  • The tests need a filesystem that permits symlink, so they are Linux-verified. On Windows they fail
    EPERM without Developer Mode — including the two symlink tests that were already there.

`resolvePath` resolved `dirname(target)` for a write and handed back the lexical
target, so a symlink at the last component was followed by `writeFile`. A read
through the identical link was already refused; the write side was the asymmetry.

A link at `notes.txt` pointing outside has no `..`, is not absolute, and sits
directly in the workspace, so it passed all three layers and the bytes landed
outside the volume.

The walk uses `lstat` and `readlink` rather than `realpath`, because `realpath`
throws on a dangling link while `writeFile` creates its destination regardless,
so that shape escaped through the failure path rather than the success one. Hops
are bounded, so a cycle is refused instead of surfacing an `ELOOP` from the write.

Confining rather than forbidding, as on the read side: a link pointing back
inside the workspace keeps working.

The escape is not the main cost, because a Bot holding `run_command` can write
outside directly. The cost is that the gateway decides and writes the audit row
in another process, from the path as it was asked for, so a rule written for
`credentials/` never sees the file that is written and the trail names a file
nothing touched.
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.

The workspace refuses a read through a symlink and allows a write through one

1 participant