Resolve the name a write lands on, not only the directory above it - #74
Open
Hotragn wants to merge 1 commit into
Open
Resolve the name a write lands on, not only the directory above it#74Hotragn wants to merge 1 commit into
Hotragn wants to merge 1 commit into
Conversation
`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.
Hotragn
requested review from
MikeRyanDev,
davidmckayv,
guidovizoso and
tylerslaton
as code owners
August 21, 2026 05:26
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
resolvePathresolved the directory a write would land in and not the name it would land on(
agent-computer/src/workspace.ts:120):A read resolves
target, so a link atnotes.txtis followed and its destination checked. A writeresolves
dirname(target), which proves where a new file would be created and proves nothing abouta name already there — and
writeFilefollows a link at the last component exactly asreadFiledoes. A link at
notes.txtpointing at/outside/secret.txthas no.., is not absolute, and sitsdirectly 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/readlinkrather thanrealpath.realpaththrows on a dangling link, so a check built on it reads "nothing is there",takes the
forWritebranch, and lets the write through the failure path whilewriteFilecreates thedestination 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
WorkspacePathErrorrather than surfacing anELOOPout ofthe 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.csvpointing 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:352→describeFileat:700-714). So a CEL rule written againstfile.path,file.nameorfile.extensionis evaluated againstnotes.txt, a deny rule forcredentials/or*.envdoes not fire, and the audit row names a file in the workspace that nothingtouched. A deployment that denies
run_commandand allowswrite_fileis relying on exactly that.Same argument #68 landed on for
bash -lc: a permissive workspace is a decision a deployment canmake, and a trail that describes a different file from the one on disk is not.
Closes #73
Where it runs
resolvePathcall andreturns a string.
against its own filesystem, as before; nothing is shared or remembered.
against the filesystem is discussed under "What is not covered".
Boundary and audit
act, in the computer process, afterthe gateway has already decided and written the row.
WorkspacePathErrorand is alreadyrecorded as a failed action; this makes five more shapes reach that path instead of succeeding
silently. The cycle case changes an uncaught
ELOOPinto the sameWorkspacePathErrorevery otherrefusal uses.
used to be taken as final.
Changelog
Added under
Unreleased→Fixed:directory a write would land in but not the name it would land on, so a link left at
notes.txtpointing 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) becausesymlinkneeds privilege on Windows.They fail on
main.upstream/main'sworkspace.tswith the new tests:The first of those, in full — the write is not refused, it succeeds:
and the cycle fails as
ELOOP: too many symbolic links encounteredthrown bywriteFile, ratherthan as a refusal.
The sixth test — a link pointing back inside still working — passes on
mainand after, which iswhat says this confines rather than forbids.
With the change:
biome formatandbiome lintclean on both changed files.tsc --noEmitclean inagent-computer.What is not covered
notes.txtwith a link betweenresolvePathreturning andwriteFilerunning still wins that race. The change narrows it ratherthan 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_NOFOLLOWon the last component, which is notreachable through
node:fs/promiseswriteFile. Worth its own issue if you want it.the policy and the trail can still describe, not about containing that Bot.
is there so a cycle terminates.
Merge notes
CHANGELOG.md, as does Sign in for real, and decide who gets in #67. One added line at the top ofUnreleased→Fixed; a textualconflict at most, whichever lands first.
agent-computer/src/workspace.tsand its test are otherwise untouched by anything open. That filehas not changed since the repository's first commit.
agent-computer: that one adds abrowser backend and touches
browser.ts,index.tsand the manifests, not the workspace guard.symlink, so they are Linux-verified. On Windows they failEPERMwithout Developer Mode — including the two symlink tests that were already there.