Repository navigation
fix(local-runtime): keep turn-diff capture reads inside the workspace - #234
Merged
Merged
Conversation
The turn-diff capture resolved a captured path lexically and then read it with fs.stat/fs.open, both of which follow symlinks. A path inside the workspace whose final component (or one of its parent directories) is a symlink pointing outside it is inside the workspace as a string and outside it as a file, so the capture put the content of a file outside the workspace into the turn-diff record, and from there into the database and into the WebUI review panel. The write side of the same hole already resolves the real path of the target and compares it with the real path of the workspace root (#232). The read side now does the same before any content is read: the target's real path is resolved, and a target that leaves the workspace is recorded by its metadata only, never by its content. The path itself is still recorded, so the turn is still visible in the review panel, and the snapshot is marked as having no content, which also keeps the rewind path from restoring it. A path that resolves outside the workspace in the first place is the capture layer's deliberate record of an out-of-workspace write, and keeps its existing behaviour; only the paths that merely look contained are re-checked here. Verified with a new test that drives the real capture service over the real SQLite store: before the fix the persisted record held the outside file's content and its unified diff, and after the fix it holds neither. The positive controls (a file inside the workspace, a symlink that stays inside the workspace, and a workspace reached through a symlinked root) still capture their content. The existing diff-mutation suites and the whole local-runtime suite pass (923 tests). Known boundary: the real path is resolved before the read, so a symlink swapped between the check and the read is still a race. Closing that needs a read that cannot follow a link (O_NOFOLLOW), which is a larger change.
modacker
added a commit
that referenced
this pull request
Oct 23, 2026
…de packages/webui (#240) The webui branch is a downstream client of MiniMax-AI/minimax-code. Changes to the upstream packages do not flow back upstream and turn into unreviewed divergence at the next upstream sync, so they do not belong here. This removes the upstream-package half of the work merged today: - packages/local-runtime/src/turns/file-changes.ts (#232, #234) - packages/agent-tools/** (#238) - packages/local-runtime-v2/src/service/workspace/operations/workspace-path.ts (#238) - packages/local-runtime-v2/src/application/conversation/conversation-application.ts and .../stream/session-stream-service.ts (#237) Everything under packages/webui stays: the queue-drain client half, the regenerated visual baselines, the browser test budgets and the CJK font gate. The inventories follow the files: eight removed paths leave release/public-source.json and six removed suites leave test/vitest-suites.json. Co-authored-by: probe <probe@local>
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 broke
packages/local-runtime/src/turns/file-changes.ts— the turn-diff capture layer — resolved the captured path lexically and then read it withfs.stat/fs.open. Both of those follow symlinks.A path inside the workspace whose final component is a symlink to a file outside it passes the lexical check — the string is inside the workspace — and only then resolves outside it. The capture therefore put the content of a file outside the workspace into the turn-diff record, and from there into the database and into the WebUI review panel.
The write side of the same hole was closed by #232. The read side was still lexical.
Why it failed
normalizeCapturePath/safeCapturedPathanswer the question "is this path inside the workspace as a string".snapshotCapturedFiletook that answer as the containment decision and read the file.With
link.txtinside the workspace symlinked tooutside/secret.txt, the persisted record held:What this changes
snapshotCapturedFilenow asks a second question before it reads anything: does the path really live inside the workspace?isCapturedPathInsideWorkspacecompares the real path of the target with the real path of the workspace root, reusing theresolveRealPath/isInsideDirectoryhelpers fix(local-runtime): contain turn-diff mutation targets by real path #232 added to this file. The two helpers stay private to the module: reading and writing are the same boundary and the same file.oversizedmarker this layer already reads as "no content was captured". The content is never read, never stored, never rendered.assertSnapshotCompleterejects anoversizedsnapshot, anddiff-rewind.tsturns that into askippedentry.Paths that resolve outside the workspace on purpose are unchanged:
normalizeCapturePathhands back the absolute path for those, and that is the capture layer's deliberate record of "a write landed outside the workspace". Those keep their existing behaviour.How it was checked
"content":"outside-secret-v1\n"and the unified diff of the outside file.diff-mutation-symlink-containment.test.ts(10) +diff-mutation-precheck.test.ts(11) + this file (6) → 27 passed.packages/local-runtime/test→ 38 files / 923 passed / 14 skipped.npx tsc -p tsconfig.standalone.json --noEmit→ exit 0.node scripts/source-inventory.mjs→Public source check passed: 5420 files(the manifest is regenerated by the generator, not hand-edited).Not addressed here
O_NOFOLLOW), which is a larger change than this refusal.realpathcannot tell a hardlink from a regular file: a hardlink inside the workspace that also points at a file outside it passes every path check, and writing to it changes the outside file. The path-based boundary does not cover it.externalmarker, not a defect fix.