Repository navigation
fix(webui): withhold the body of a diff entry outside the workspace - #244
Merged
Merged
Conversation
modacker
force-pushed
the
fix/diff-workspace-boundary
branch
from
October 23, 2026 11:43
0777100 to
bf70d86
Compare
A turn's diff can name a file outside the workspace. The runtime's capture layer records an out-of-workspace write as an absolute path, so the entry reaches the client with that file's content attached — and the client rendered it: the transcript's diff card, the workspace turn-review panel and the workspace review page each put the patch text on screen. The fix that stopped the collection at the source was made upstream in `packages/local-runtime/src/turns/file-changes.ts` and reverted in PR #240, because it changed a package this fork may not change. The boundary is therefore drawn on the client side, which is the last point before the content reaches a screen. A diff's file entries are now projected through that boundary before they reach a surface. An entry whose path resolves outside the workspace keeps its name, its status and its line counts, and loses its body: the diff text, the patch hunks, the file's content. The surface says what is withheld instead of showing it. The entry itself stays in the list — "which file was touched" is the part a person can still act on. The comparison is lexical, on posix-style strings, the same normalization the message file-reference reader uses. An absolute path is compared with the root segment by segment, so a sibling directory that shares the root's name is outside; a relative path is refused when it climbs out with `..`. Two limits are stated rather than papered over, and ADR 0024 and the tests say so out loud: * A symlink inside the workspace that points outside it stays contained here. The entry's path is a relative one and the browser has no filesystem to resolve it against; telling the two apart needs `realpath` on the runtime side. * An unknown workspace root is not a boundary. The entries are shown as they are, because a check that hid everything it could not place would hide ordinary content too. The upstream half of this is still open: nothing here stops the runtime from sending an out-of-workspace file's patch to the browser, only from the browser showing it. The client stops the content from being displayed; only the upstream change stops it from being collected.
modacker
force-pushed
the
fix/diff-workspace-boundary
branch
from
October 23, 2026 13:34
bf70d86 to
a2f4294
Compare
…it reads The case reads three things that are written by someone else — the runtime's active turn, the client's captured request frames, and the runtime's event stream — and it read all three once, at a moment that is not the moment the write lands. The turn before the cancellation step is over when its answer is on screen, but the runtime retires the turn a moment later, and the case sent the next message into that gap. A session whose turn has not been retired does not start a turn: the message is queued instead, so it never reaches the model and the case waits for an event that will not come. The case now waits for the turn to be retired, the way it already does before its other sends. The plan-entry request was read out of the frame capture once, right after the answer that answers it had rendered. The capture is now polled, the way the persisted history next to it is already polled. The runtime's event watch was torn down the moment the turn went inactive — which is exactly when the runtime reports the failure — so an event still in flight when the watch was aborted was lost, and the comparison that follows had nothing on the runtime side to compare the client's event against. The watch now stays open until both sides carry the error. The turn retries the failing model call, so the runtime reports the failure more than once and the first report is not necessarily the one the client saw; the pairing now looks at every error each side reported, and the two views still have to name the same turn. How it was verified: the case was run repeatedly on the same machine. Before the change it failed on the cancellation gate in about two runs out of five, and the model server never received the message at all — the runtime never started a turn for it. The same case fails the same way on the trunk without this branch, so the flake is not this branch's; the change removes the races the case was reading through and leaves the assertions as they were.
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.
fix(webui): withhold the body of a diff entry outside the workspace
The vulnerability
A turn's diff lists the files a turn touched, and one of those files can be outside the
workspace. The runtime's capture layer records an out-of-workspace write as an absolute path, so
the entry reaches the client with that file's content attached — the diff text, the patch hunks,
the file's lines.
The client rendered it. Three surfaces put a diff body on screen:
features/transcript/ui/DiffCard.tsx) — its own review panel,features/workspace/ui/WorkspacePanels.tsx),features/review/ui/ReviewSettingsPage.public.tsx).So a turn that touched a file outside the workspace showed that file's content in the browser.
Why this is fixed in the WebUI layer
The fix that stopped the collection at the source was made upstream in
packages/local-runtime/src/turns/file-changes.ts(commitb462add2) and reverted in PR #240,because it changed a package this fork may not change. The upstream fix was not wrong; it was in
the wrong layer for this repository, whose boundary is
packages/webuialone.The boundary is therefore drawn on the client side, which is the last point before the content
reaches a screen. This is a mitigation with a stated scope, not a replacement for the upstream
fix — see the limits below.
The implementation
A new projection module,
packages/webui/src/client/projection/common/diff-workspace-boundary.ts,owns the decision:
isWebuiDiffPathWithinWorkspace(workspaceDir, filePath)— the lexical comparison.projectWebuiDiffFile(file, workspaceDir)/projectWebuiDiffFiles(files, workspaceDir)— theprojection. An entry outside the workspace keeps its name, status and added/deleted line
counts, and loses its body (
diffandpatch). The entry is not dropped, and the countsare not changed.
WEBUI_DIFF_OUTSIDE_WORKSPACE_NOTICE— what the surface says instead: 「该文件在工作区之外,内容未展示。」
The boundary is applied by the projection, not by the components: a surface renders the entries
it is given, and an entry for a file outside the workspace arrives without a body — there is
nothing for a renderer to leak even if it forgets to ask. The verdict travels with the entry
(
withinWorkspace), so the surface that renders the body and the projection that withholds itcannot disagree.
The comparison is lexical, on posix-style strings, the same normalization the message
file-reference reader uses (
features/transcript/projection/message-file-reference.ts), extended toresolve
..and to compare absolute paths segment by segment:(
../outside.txt,src/../../outside.txt);whose name starts with the root's name (
/work/project-backupagainst the root/work/project)is outside — a string prefix would have called it inside;
Applied at all three surfaces that show a diff body, plus the two places the entries travel:
packages/webui/src/client/features/transcript/ui/DiffCard.tsxpackages/webui/src/client/features/workspace/ui/WorkspacePanels.tsxpackages/webui/src/client/features/review/ui/ReviewSettingsPage.public.tsxpackages/webui/src/client/features/transcript/ui/MessageItem.tsxThe workspace review page also does not ask the runtime for the patch of a file outside the
workspace: the content is not going to be shown, so it does not have to travel.
The decision is recorded in ADR 0024
and its residual limits in
packages/webui/docs/known-gaps.md§10.What the tests cover
packages/webui/test/unit/diff-workspace-boundary.test.tsx(16 cases) asserts the boundary atboth levels, because either alone would pass with the other broken:
The projection — the body is blocked in the data:
..→ the body is removed;The surfaces — the body is not rendered:
outside — the notice is shown instead, and the file stays in the list with its counts;
keeps the file's name and counts either way.
A separate assertion pins the known limit rather than leaving it to be discovered: a symlink
inside the workspace that points outside it is not detectable — the path is a relative one and
stays contained.
The frozen path-metadata oracle
(
packages/webui/test/gates/fixtures/path-metadata-f00.json) records the new module'sclassification, and
release/public-source.jsonandtest/vitest-suites.jsonare regenerated (nothand-edited) for the new files.
Capability limits — stated, not papered over
outside it resolves outside, and the browser has no filesystem to see that: the entry's path is a
relative one, so it stays contained and its body is shown. Telling the two apart needs
realpathon every target, with careful handling of files that do not exist yet — which isruntime-side work. This is the one case the fix does not cover.
entries are shown as they are. A check that hid everything it could not place would hide ordinary
content as well, and "the diff shows nothing" is a silent, confusing failure. The check may
therefore say "inside" only when it can compare.
out-of-workspace file's patch to the browser; it stops the browser from showing it. The client
stops the content from being displayed; only the upstream change (reverted in PR revert(upstream-packages): keep the webui branch's local changes inside packages/webui #240) stops it
from being collected.
getSessionDiffis not consumed by any surface today. When one is, it goes through the sameprojection.
Scope
Only
packages/webui/is touched, plus the two generated manifests the repository requires(
release/public-source.json,test/vitest-suites.json). No upstream package is changed.Gates run
pnpm --filter @mavis/webui typecheck:server/typecheck:client/typecheck:test— pass.node scripts/run-vitest-suite.mjs webui— 209 files pass, 3297 tests pass.One pre-existing failure remains:
packages/webui/test/integration/agent-transcript.test.ts("Timed out waiting for held cancellation response"). It fails identically on the base commit
with this change stashed — it needs a live model provider.
node scripts/check-webui-dependency.mjs— pass (0 violations, 0 cycles, 0 role violations).node scripts/build-webui.mjsthennode scripts/check-webui-boundary.mjs— pass.node scripts/source-inventory.mjs --writethennode scripts/source-inventory.mjs— pass.pnpm test:webui-browser— not run: no usable Chromium on this machine. CI runs it.Gates run
pnpm --filter @mavis/webui typecheck:server/typecheck:client/typecheck:test— pass.node scripts/run-vitest-suite.mjs webui— 209 files pass, 3297 tests pass.node scripts/check-webui-dependency.mjs— pass (0 violations, 0 cycles, 0 role violations).node scripts/build-webui.mjsthennode scripts/check-webui-boundary.mjs— pass.node scripts/source-inventory.mjs --writethennode scripts/source-inventory.mjs— pass.pnpm test:webui-browser— not run: no usable Chromium on this machine. CI runs it.A third commit: the transcript case was racing three writes it reads
The first CI run of this pull request turned
agent-transcript.test.tsred on macOS. That fileis not touched by this pull request, and the failure is not this pull request's: with this
branch's changes stashed, the same case fails the same way on the base commit.
What the case was doing wrong is worth fixing here, because the fix is in this repository's
package and the case is this repository's own gate:
retires the turn a moment later, and the case sent the next message into that gap — a session
whose turn has not been retired queues the message instead of starting a turn, so it never
reaches the model;
answers it had rendered;
when the runtime reports the failure, so an event still in flight was lost and the comparison
that follows had nothing on the runtime side to compare the client's event against.
All three now wait for the write they read, and the assertions themselves are unchanged. The
turn id agreement is still checked, against every error each side reported, because the turn
retries the failing model call and the runtime therefore reports the failure more than once.
CI on this pull request
The checks on this pull request are the ones on its current head — see the checks tab. The
transcript case runs on all three platforms; the change above removes the races it was reading
through rather than loosening any of its expectations, and no timeout a case owns was raised.