Repository navigation
fix(local-runtime): contain turn-diff mutation targets by real path - #232
Merged
Merged
Conversation
The turn-diff revert/reapply resolved each target path lexically: the resolved path was compared to the workspace root as a string. A path inside the workspace whose final component is a symlink pointing outside it is inside the workspace as a string and outside it as a file, and the write follows the link — so a revert could write into a file outside the workspace. Resolve the target's real path and compare it with the workspace root's real path. The lexical check stays as the cheap fast reject, so every existing refusal (an absolute path, `.git/`, `.harness-def/`, the root itself) is unchanged; the real-path check adds the symlink escape on top of it. The resolver follows the tool path guard (`agent-tools/src/desktop/path-guard.ts`): realpath the target, and when it does not exist yet, resolve its nearest existing ancestor and re-attach the missing segments. A dangling symlink is followed by hand — realpath fails on it the way it fails on a missing file, but a write through it creates the file it points at. The "Known boundary (lexical containment only)" note in the code is removed: the boundary it described is closed.
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.
Change
The turn-diff revert/reapply could write into a file outside the workspace.
resolveMutationTargetPath(packages/local-runtime/src/turns/file-changes.ts) is the containment check for the turn-diff mutation — the undo/revert and reapply write. It is not the check on the tool's own write, and not the capture.Call chain:
routeLocalTurnDiffMutationApi(packages/local-runtime/src/turns/diff-api.ts:157) →mutateLocalTurnDiff→applyTurnDiffMutation(diff-api.ts:174) →applyLocalTurnDiffSnapshotMutation(file-changes.ts:389) →resolveMutationTargetPath→fs.writeFile/fs.rm(file-changes.ts:431).rewindRecordFiles(packages/local-runtime/src/turns/diff-capability.ts:127) calls the same mutation with the fork's target workspace.PR #220 made that mutation all-or-nothing and added this check, and left the remaining boundary written down in the code:
Lexical containment is not containment. A path inside the workspace whose final component is a symlink pointing outside it is inside the workspace as a string and outside it as a file — and
fs.writeFilefollows the link. Reproduced on the base commit with the test in this PR: the file outside the workspace heldoutside-v1before the revert andoutside-v0after it, so the revert wrote through the in-workspace symlink. The same held forreapply, for a symlinked directory inside the workspace, for a relative link, and for a dangling link — where the write creates the file it points at, outside the workspace.The fix
packages/local-runtime/src/turns/file-changes.ts..climb, a sibling directory whose name starts with the workspace name, and everythingnormalizeCapturePathfilters (.git/,.harness-def/, the root itself).packages/agent-tools/src/desktop/path-guard.ts):realpaththe target; when the target does not exist yet — a revert recreates a deleted file — resolve its nearest existing ancestor and re-attach the missing segments. A dangling symlink is followed by hand, becauserealpathfails on it the way it fails on a missing file while a write through it creates the file it points at.path.relative, like the tool path guard — case-insensitive on Windows, where the filesystem is.Both sides are compared as real paths, so a workspace that is itself reached through a symlink still contains its own files, and a link that leaves it does not.
No existing refusal was weakened: the 11 pre-existing precheck tests are unchanged and still pass. What is added on top is the symlink escape. The "Known boundary (lexical containment only)" note is removed — the boundary it described is closed. What remains is stated instead: the real path is resolved before the write, so a symlink swapped between the check and the write is still a race.
Validation
webui@63b9739e, headbd9bb67c, macOS arm64, Node v25.9.0):packages/local-runtime/test/unit/diff-mutation-symlink-containment.test.ts— 8 failed / 2 passed before the fix, 10 passed after. The two that pass in both runs are the legitimate cases that must keep working (a symlink pointing at another file inside the workspace, a workspace reached through a symlinked root).packages/local-runtime/test/unit/diff-mutation-precheck.test.ts— 11 passed (no existing refusal weakened).packages/webui/test/unit/diff-mutation-error.test.tsx— 22 passed.node scripts/run-vitest-suite.mjs capability— 234 files, 5804 passed, 17 skipped, 0 failed.node scripts/run-vitest-suite.mjs policy— 142 passed.node scripts/run-vitest-suite.mjs webui— 3253 passed; 4–5 files are flaky on this machine and fail the same way on the base commit without this change (webui-design-tokens,webui-role-check,webui-host-event-generation,webui-workspace-archive,agent-transcript). The turn-diff webui test passes.tsc -p tsconfig.standalone.json— clean.node scripts/source-inventory.mjs— passed, 5419 files.realpathper mutation target plus one for the workspace root, resolved once per run; the lexical fast reject still runs first, so an out-of-workspace path costs no syscall.Publication and contribution checks
release/public-source.json; new tests are declared intest/vitest-suites.jsonwhere applicable.Maintainer handoff
Publication scope or license changes (if any): none.
Shared-source port: not needed.
Still open — for a decision, not fixed here
The capture layer shares the same lexical assumption, on the read side:
normalizeCapturePath/safeCapturedPath/snapshotCapturedFile(file-changes.ts) snapshot a target through a symlink, so a link inside the workspace to a file outside it puts the outside file's content into the turn-diff snapshot, the stored record and the review panel. Closing that is a different decision — the capture layer deliberately records out-of-workspace writes as absolute paths, and this change does not touch that contract.isInside/isPathInsideworkspace checks elsewhere in the repo have the same shape (e.g.packages/local-runtime/src/permissions/checkers.ts,packages/local-runtime/src/observability/diagnostic-bundle.ts,packages/shared/src/local-runtime-paths.ts,packages/local-runtime-v2/src/service/workspace/operations/workspace-path.ts). Each is a separate boundary with its own blast radius, and none of them is the turn-diff mutation.