Repository navigation
feat(tools): route the remaining write tools through the guard (U7, #1375) - #1918
Open
easonLiangWorldedtech wants to merge 120 commits into
Open
easonLiangWorldedtech wants to merge 120 commits into
easonLiangWorldedtech wants to merge 120 commits into
Conversation
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.
This PR routes the remaining write tools -
apply_diff,write_to_file,edit,edit_file,search_replace- through the same guard, so after this unit no tool path can clobber a file the model never read: an unearned write fails with the re-read remediation instead of overwriting. A write outside every workspace root is no longer an outright rejection: the tool layer classifies against all workspace folders, asks the user, and forwards the approval to the guard, which re-checks containment against the approved roots before publishing.It is the seventh of nine PRs for epic #1375 (chain tracked on #1991).
Related GitHub Issue
Tracking: issue 2022 (this unit's tracking issue; deliberately not a closing reference - the maintainer closes it when the series lands).
Refs: #1991 (chain tracking), #1375 (epic).
Description
What this PR changes:
apply_diff,write_to_file,edit,edit_fileandsearch_replaceall save throughDiffViewProvider.saveDirectly()/guardedWrite()with an explicit write kind, so a targeted edit is never treated as a full-file replacement.fs.statpair and observe the file only when both tokens match, so the publish is authorized by the exact bytes the tool computed its hunks from; a stat failure leaves the file unobserved and never fails the read.approvedCanonicalTarget); the guard only compares and refuses when the capture is missing.Why the diff looks bigger than the change: the chain is non-cumulative - this branch is based on
mainand carries its own copy of the earlier units' content, which the GitHub diff shows as context. This unit's own delta is 539 a+d / 54 changed executable lines.Review focus: the tool classes named above,
src/core/tools/guardedWrite.ts(approval binding), and the specs named in the Test Procedure.Test Procedure
From the repository root, with dependencies installed:
pnpm --dir src test -- core/tools/__tests__/guardedWrite.spec.ts core/tools/__tests__/applyPatchTool.execute.spec.ts core/tools/__tests__/applyPatchTool.partial.spec.ts core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts core/tools/__tests__/writeToFileTool.spec.ts core/tools/__tests__/editTool.spec.ts core/tools/__tests__/searchAndReplaceTool.spec.tsExpected: 138 passed, 5 skipped. Then
pnpm --dir src test -- integrations/editor/__tests__/DiffViewProvider.spec.ts utils/__tests__/safeWriteJson.test.ts utils/__tests__/safeWriteJson.lockKey.spec.ts- 166 passed, 4 skipped.Environment: Windows 11, Node 20, unit layer only.
Pre-Submission Checklist
.changesetor CHANGELOG changes (AGENTS.md).src/eslint-suppressions.jsonunchanged - no suppression count increased.--max-warnings=0) rather than relying on suppressions.Visual Snapshots
Not applicable - no rendered surface; the approval prompt is the existing one.
Videos (interaction / animation only)
Not applicable.
Documentation Updates
No user-facing documentation change: the guard is internal behaviour of the write tools, and the model-facing remediation text already existed in earlier units. No
.changesetand no CHANGELOG edit (AGENTS.md).Additional Notes
7f90fc299, 1402 at the previous head) because the unlanded predecessors inflate it; the row clears on rebase after the predecessors merge.Verification at this head
Verification at this head (7f90fc2)
applyPatchTool.execute.spec.ts27 passed (27);DiffViewProvider.spec.ts158 passed (158);safeWriteText.spec.ts68 passed (68) - also 68 passed (68) in a worker whoseprocess.platformreportslinux;safeWriteJson.test.ts27 passed / 4 skipped;safeWriteJson.stagingDirectory.spec.ts7 passed (7).srclane measured at52628eb86: 12859 passed | 45 failed | 41 skipped (the follow-up commit adds one passing case to that total). The same two files fail on the pre-change baseline run of this same worktree -__tests__/dist_assets.spec.ts(40, needs the bundle the local lane skips) andservices/rules/__tests__/rules.spec.ts(5, symlink cases on Windows) - so nothing that passed before fails now. Honesty note: local vitest lanes skip thepretestbundling step, so bundle-dependent specs are CI-only.--prune-suppressions --max-warnings=0clean on all seven files;src/eslint-suppressions.jsonleft byte-identical to the merge commit, because the prune only reformatted it and it was restored from the index. Against the merge base the total is 3301 -> 3299: one reduction (core/tools/__tests__/readFileTool.spec.ts), and none of the files touched this round carries an entry.pnpm check-types(roottsc -p tsconfig.json --noEmitplus 11 turbo tasks) exit 0 on a cold cache: 11 cache miss, 0 cached. The pre-push hook re-ran it before this head was pushed.split-1833/u7: 262 reparse points under the node_modules roots - 225 pnpm-store links into this worktree, 37 workspace links, 0 links pointing into the main checkout;packages/types/distand the turbo bin present.7f90fc299, read from the job log (run 41970071166, job 127056022960): the gate derivesBASE_SHAasHEAD_SHA^1of the PR merge commit and fails withMutation gate failed: extension has 1468 changed executable lines (limit 500)- the same chain budget-cap failure as at the previous head, which reported 1402, so chain paperwork rather than a new defect. The gate's own logic subtests passed in that run and Stryker never started, so there is noreports/mutation/artifact and no directive was added.compile,e2e-mock,extension-host-visual,webview-visualand bothplatform-unit-testlanes were still running when this line was written; CodeRabbit's own check is neutral (advisory).Updated this round
verifyTargetre-resolves before the publish): registered confinement primitive - see record 6106959976 on feat(tools): wire guarded writes into the diff-view save paths (S4b, #1375) #1408. The carried copy already re-checks the target under the lock immediately before the publish; the full directory-identity pinning is U5's (feat(tools): guarded write core under the shared lock (U5, #1375) #1914) content and merges before this unit in the declared order.DiffViewProvider/guardedWritecopies predate U8's (feat(editor): route the diff-view save through the guard (U8, #1375) #1916) cancellation ordering and landed-token reconciliation; the rows resolve at merge time because U5 and U8 merge before U7 in the declared order and the diff shrinks to U7's own wiring.Get in Touch
Reviewer and maintainer questions are welcome as comments on this PR or on #1991.
Stryker directive removal (equivalence notes)
Per AGENTS.md the two Stryker suppression comments in the guarded save
path were removed instead of kept; their equivalence claims:
"update"->""): an empty kinddispatches exactly like
"update"inside guardedWrite - only"edit"and
"create"branch distinctly - and the default-parameter line isnot part of this unit's changed-line selector, so the mutant is
equivalent / out of scope.
GuardRejectedErroris only thrown by guardedWrite, which is reachedonly after the
!saveTaskcheck above has already thrown. Wheneverthe operand is evaluated
saveTaskis provably non-null, so droppingthe operand cannot change which branch is taken.
mutation-diff remains advisory for stacked units; maintainers decide any
narrow gate-config exclusion.
Re-review round (41c0295)
open()records the token its preview wrote;
reset()(reached by every tool catchand by
revertChangesviafinalizeSession) forgets the entry while itstill holds that token and the preview's incomplete flag - publishes and
model reads survive. Trigger-sequence test + two survival tests; three
negative controls, each exactly one red.
^Stale version -- the file changed) + deduplicated repeated assertion.Ownership-based withdrawal port (14f8634)
The sibling unit's follow-up thread found the content-based withdrawal
could delete a model partial read recorded while the diff was open
(same token, same incomplete flag). The copy this unit owns carried the
identical flaw and is fixed identically:
open()keeps the exact entryobject its
observe()wrote;reset()forgets only while the registrystill stores that object. Regression test ported; NCs each exactly one
red. 153/153 green.
Open document reconciled after the guarded publish (f5fddf6)
The Persistence Integrity row:
saveDirectly()commits throughguardedWrite(), which writes the bytes to disk directly, and the diffremoved the awaited
document.save()that used to follow the publish -leaving an already-open target document holding its pre-publish buffer.
A dirty buffer there is a live hazard: VS Code's ordinary save path
would republish those stale bytes over what the guard committed.
saveDirectlynow reconciles the open document before returning -reverting reloads the published bytes and clears the dirty flag - so no
open buffer is left whose text differs from the publish; reverting a
document that already matches the disk is a no-op, and the memory-only
path still never saves the buffer over the publish. Regression test: an
already-open dirty target document is reverted (not saved) by the time
saveDirectlyresolves; negative control: removing the reconcilereddens exactly that test. 154/154 green (240/240 across the guarded
write and tool suites) at this head.
Fail-safe reconcile (c198c2e)
The 04:29Z Persistence Integrity row implemented in full:
saveDirectly()requires successful reconciliation after the guarded publish. IfrevertDocument()returns false or throws, the tab is closed and the document reopened from disk; if the reopened buffer is still dirty the write fails instead of reporting success, and the diagnostics/open path does not run while the target is dirty. Test: revert command rejects and buffer stays dirty ->saveDirectly()rejects, tab closed and reopened from disk (a later save reads the published bytes), stale buffer's own save never runs. Two negative controls (bare revert; tolerate dirty reopen) each redden exactly that test. 155/155 green.The Security Boundaries TOCTOU row remains the registered check-then-resolve family (lands once at the publish-primitive owner; precedent record 6106959976, issue #1408).
Staging-directory drift guard fix (64dc74c)
platform-unit-test (ubuntu-latest)was red at 40ac71f on exactly one case:core/task/__tests__/Task.spec.ts > 'finalizePartialToolAsk still updates the webview when a later save stage fails'(expected "updateClineMessage" to be called with arguments, Number of calls: 0). Measured with a probe on every realpath call in that window that printed the path it received and its outcome, plusfs.statSyncof the same path at the same instant through the never-mocked fs: not one realpath returned ENOENT for the directory - the guard's first walk and both lock-key canonicalizations resolved it, four times, to inode 22517998136858990, and the only ENOENTs were onui_messages.jsonitself, the target file that genuinely is not there. The guard's ownawait fs.stat(approvedCanonical)reported ENOENT for a directory the unmocked fs reported as that same inode, becauseTask.spec.ts:144mocksfs/promiseswithstat: vi.fn().mockRejectedValue({ code: "ENOENT" })for every path, and it rejected before the second walk, so the drift comparison never happened. The guard was failing on a question it invented, and the only reason our own lane never caught it is that the suite driving this write path mocks stat to fail for every path.The guard now acts on a single signal: the re-resolution of the approved directory succeeds and lands on a different directory. A probe that cannot answer is not an answer - ENOENT because a component is mid-replacement or because the caller's fs reports the path as missing, EACCES, EPERM, ELOOP - so the write proceeds; the existence probe is gone, and a directory that really is deleted now fails at the staging write itself, still fail-closed with nothing created outside the directory the write was approved for. Confinement keeps the opposite rule on purpose: there the guard has to prove containment, and a probe that cannot answer cannot prove anything. Controls: reverting the fix reddens exactly the two new cannot-answer cases - one of them a unit-scale twin of the CI failure that also asserts the fixed path makes zero
fs.statcalls - and reddens the CI case inTask.spec.tsand only it (1 failed | 198 passed); restoring it is green (5 files, 306 passed | 4 skipped) and byte-identical by SHA256. Cross-checked against a scratch merge of this branch's head with upstream/main 6bbc7a0: the CI case is red there without this commit and green with it.Known property, deliberately not fixed in this PR: if the approved directory is replaced before the guard's first reading, both readings agree and no layer rejects the write - measured byte-for-byte identical before and after this change (the payload publishes at wherever the approved pathname now points, with no staged file left in either spelling and only the advisory lock directory travelling with the renamed directory). The consequence is that the file can be published into a directory the writer's advisory lock does not name. Closing that means
safeWriteTextbinding the publish destination to the directory its own advisory lock names, which is a design change in the publish path that #1916'sguardedWritelayer builds on, so it is registered as a follow-up unit rather than folded into a green-lane fix. It is recorded bysafeWriteJson.stagingDirectory.spec.ts > 'follows the approved pathname when the directory is replaced before the guard reads it', which assertsreadings === 3so a change in the canonicalization order fails loudly.Review round r112 fixes (52628eb)
Head
52628eb86. The merge base is nowmainate943530c8(merged in002b34fa4), so the GitHub diff at this head is 33 files / 12474 insertions / 349 deletions against that base. This unit's own delta is 539 a+d / 54 changed executable lines; the mutation gate charges the whole branch delta againstmain(it derivesBASE_SHAasHEAD_SHA^1of the PR merge commit), so the chain copies this branch carries sit inside its number, and refreshing ontomaindid not shrink it.Five live threads, each fixed and answered in-thread with its control numbers:
src/core/tools/ApplyPatchTool.ts:116(Major,rt4475729779):executerecords the exact registry entry behind every observation it creates and forgets it only while the registry still holds that entry; every publish site drops its recorded entry first, so a published file is never withdrawn. Reverted to bloba36944e59b6b: 4 failed | 22 passed; at this head 26 passed (26).src/integrations/editor/DiffViewProvider.ts:765(Minor,rt4475729791): 765, 962 and 989 now close only this provider diff view viacloseOwnDiffView(absolutePath).closeAllDiffViews()stays as the path-unknown fallback insidereset()(1703) and a test pins the fallback itself. Reverted to blob8afc716b0946: 8 failed | 150 passed; at this head 158 passed (158).src/services/file-safety/safeWriteText.ts:358(Minor,rt4475668838):dirPathis created with the plain recursivefs.mkdirbefore_stagingDir(dirPath), so the private0o700applies to the staging directory alone and the two writers agree (safeWriteJson.ts:219already did this). Reverted to blobcb24e0e3172c: 1 failed | 67 passed on the creation-order assertion; at this head 68 passed (68). Windows cannot expose the mode difference itself (measured 666 / 666 in both orders), so the test asserts creation order plus themodeoption each call carries.src/utils/__tests__/safeWriteJson.test.ts:646(Minor,rt4475729798):vi.doUnmockreplaces the hoistedvi.unmock, the comment says what actually undoes the capture, and a new case asserts a later import resolves the realproper-lockfile. Rewritten back tovi.unmock: 1 failed | 26 passed | 4 skipped; at this head 27 passed | 4 skipped.src/services/file-safety/__tests__/safeWriteText.spec.ts:692(Trivial,rt4475668810): the platform skip is gone, and a second case of the same class in the same file lost its skip too; the file runs 68/68 on Windows and 68/68 in a worker reportinglinux.Chain overlap, recorded in-thread: the sites this unit shares with #1916 are the
ApplyPatchTool.readFilepartial observation and its publish sites, plus theDiffViewProviderteardown; the follow-up that binds a publish destination to the locked directory stays on #1916.Gates for this round: husky hooks on for both commit and push (lint-staged eslint + prettier, pre-push
check-types), never--no-verify; push used--force-with-leasepinned to the freshly read fork head64dc74cab, read back as52628eb86. No.changeset, no CHANGELOG edit, no suppression increase, no Stryker directive.Follow-up inside round r112 (
7f90fc299)CodeRabbit verification reply on
rt4475729779found one non-publishing exit the first cut left open: a move publishes the destination, then a failedfs.unlinkof the source is logged and tolerated, so the source stays on disk with its original bytes while its tracking entry had already been dropped - the tool-owned partial observation survived as authority for a lateredit. The source entry now leaves the tracking map only when that unlink succeeded (sourceRemovedgates it); the destination entry still leaves unconditionally, because the destination really did publish.Control: the new case
move: withdraws the source observation when its unlink fails after the destination publishedfails at52628eb86(1 failed | 26 passed (27),expected true to be falseon the leftover registry entry, and a follow-upguardedWrite(..., "update")to the source rode on it instead of refusing) and passes at7f90fc299(27 passed (27)). Reverting only the implementation to blobb78a7ad6ad55reddens exactly that case. Fixed blobs at this head: implementation74a22bf7644f, specff52ead16c31. GitHub exposes no reopen mutation, so the answer was posted as a reply inside the already-resolved thread.Charged diff at this head against the merge base
e943530c8: 33 files, 12510 insertions / 350 deletions. Suppressions remain byte-identical to the merge commit (--prune-suppressionsonly reformats it, and it is restored from the index each round).Closes: #2426 (this unit's tracking issue, U7 of the #1375 split, filed 2026-11-11; the note carries this unit's head, its measured price with the base named and the gate state, and the plan post-dates this pull.)