fix(view): refuse worktree-cwd mutations when the route isn't ready, instead of silently editing the wrong checkout - #816
Conversation
…ot serve The 2026-09-19 wrong-checkout edit incident: a session whose cwd sits inside a linked worktree made an edit_file call with a prefixed path; the bytes landed in the MAIN checkout silently. Root cause chain: viewForSessionCWD returned (nil, nil) when the checkout's route could not serve (no dirty generation published yet), leaving the request on the base corpus with no rider; checkoutRootedPath then fell to the worktreeRootedPath existence heuristic, which leaves an exists-in-both file at the main checkout. viewForSessionCWD now checks route readiness before materializing: a mutative tool (per facades.mutatesSource on the authorized call marker) fails loudly with view_building — the same code an explicit worktree selector produces — instead of silently writing the base corpus. Read tools keep the soft base fallback but carry fallback_reason on the freshness rider so a degraded answer is visible. Regression tests: mutation refusal on unrouted checkout (no bytes move anywhere), read fallback rider fields. The binding happy path (route ready, bytes land in the worktree) is already pinned end-to-end by TestWorktreeMutationCoordinatorEndToEnd's cwd subtest.
Review pass (independent, larger model) found two P3s and one high-priority test gap in 8ac90591: - The route-not-ready base fallback rode a SelectorAuto rider, hiding which checkout the session actually bound. The rider now carries the resolved worktree selector and the primary base graph id, so a client can see both the intent and what actually answered. CheckoutID is unchanged; GraphID and RequestedView are the additions. - The read_file incident twin had no test. Added TestCWDBindingRouteNotReadyReadFileIsLabeled: repo-prefixed read from a worktree-anchored session with a retired route answers base with the labelled rider (requested_view=worktree:<id>, actual_view=base, fallback_reason=view_building, checkout_id and graph_id set). All session-cwd defence plus the full targeted regression (167s) pass.
… lanes Adds the 4 regression tests scoped in the 2026-09-20 handoff for the session-cwd route-not-ready mutation defence: - sole-repo topology (resolveFilePath's soleTrackedRepo branch, otherwise unreachable behind the two-repo fixture) still refuses loudly - batch_edit (handleAtomicBatchEdit) refuses like edit_file - write_file and edit_symbol refuse in parity with edit_file - a marker-less edit_file fails open to the second gate (refuseRoutedViewMutation) rather than bypassing both gates newViewStack is refactored into newViewStackWithRepos(t, includeOther) so the sole-repo fixture reuses the same generation/catalog/route setup instead of duplicating it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Code review flagged that the newViewStackWithRepos doc comment implied the sole-repo test exercises resolveFilePath's soleTrackedRepo branch directly. It doesn't: the route-not-ready refusal fires in the outer middleware before any handler reaches resolveFilePath. The test is still valid — it proves the refusal is topology-independent — just not for the reason the old comment stated. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…acade sourceMutatingFacades protects two facades, "edit" and "refactor" (facade_registry.go:166), but every existing route-not-ready regression test — old and new — only drove tools behind "edit" (edit_file, write_file, edit_symbol, batch_edit). The "refactor" facade's six legacy tools (apply_code_action, safe_delete_symbol, fix_all_in_file, inline_symbol, move_symbol, rename_symbol) go through the identical wrapToolHandler middleware and the same mutatesSource gate, but had zero coverage proving the route-not-ready refusal actually reaches them. Verified mechanically before writing the test: every refactor-facade handler is registered via s.addTool, which wraps it in the same s.wrapToolHandler(handler) chain as edit_file (server.go:3241-3247), and none of the six legacy names match checkoutControlOperationName or viewlessCatalogTool, so all six take the normal resolveRequestView path that produces the view_building refusal before the handler runs — same as the edit facade. All six now assert that refusal explicitly instead of relying on inference from the edit-facade tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…a worktree worktreeRootedPath short-circuited on the first os.Stat hit against the resolved (main) path and returned immediately, never checking whether a linked worktree also carried the file. This is the 2026-09-24 live incident: a session created a worktree, never cd'd into it, then issued an unrouted, unprefixed edit whose path existed in both checkouts — the edit landed in main instead of refusing or asking. Scope this narrowly to the two call sites where the checkout is inferred rather than caller-stated (resolveFilePath's sole-tracked-repo fallback, and anchorUnprefixedExisting): an explicit repo-prefixed path, or one recovered from graph metadata in resolveNodePath/resolveGraphPath, keeps the prior behavior — refusing there would break the existing main-checkout-prefix contract (TestEditFile_MainCheckoutPrefixStillEditsMainCheckout). Adds TestWorktreeRootedPath/refuses_a_file_that_exists_in_both_main_and_a_worktree to pin the new refusal. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
I had this fix deployed in my local server, but found the exact bug this PR targets reproduced live today in an unrelated repo — a separate, narrower gap in the same incident family, not a regression in what's already here. Root cause: Added in |
zzet
left a comment
There was a problem hiding this comment.
Request changes: the new ambiguity guard still falls back to the main checkout when more than one linked worktree contains the inferred target, preserving the wrong-checkout mutation this PR is intended to prevent.
| @@ -270,16 +307,26 @@ func worktreeRootedPath(abs, root string, mi multiRepoLookup) string { | |||
| continue | |||
| } | |||
| if match != "" && match != candidate { | |||
There was a problem hiding this comment.
[P1] Refuse ambiguity between multiple linked worktrees
When a second linked worktree contains the inferred file, this branch returns the original main-checkout path with no error before the new ambiguity check can run. An unrouted mutation can therefore still edit—or create—the main-checkout file when two worktrees contain the target. When refuseAmbiguous is true, return errPathAmbiguousCheckout on the second distinct match, and add tests for main + two worktrees and for two worktrees with no main file.
…orktrees worktreeRootedPath returned the originally-resolved (main-checkout) path as soon as a second linked worktree matched, before the main-vs-worktree ambiguity refusal could run. Called with refuseAmbiguous=true, the helper could therefore hand back a main-checkout path for a target carried by two worktrees — one that existed in main (main + two worktrees) or one that did not (two worktrees, no main copy, so a write would create it). Refuse with errPathAmbiguousCheckout on the second distinct worktree match when refuseAmbiguous is set. An explicit selection (refuseAmbiguous=false) keeps the resolved path, preserving the main-checkout-prefix contract. Tests: - TestWorktreeRootedPath_MultipleWorktrees pins the helper for main + two worktrees, two worktrees with no main copy, and the explicit-selection pass-through. - TestEditTools_UnprefixedPathAmbiguousAcrossTwoWorktrees drives edit_file and write_file end to end against a real MultiIndexer tracking main plus two worktrees, asserting the checkout-ambiguity refusal and that no checkout changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for catching this. You're right: the second-worktree branch returned Fix: with Tests:
Scope of the gap: writing the end-to-end test showed that in that topology the tool path was already refusing before this fix. Also worth noting:
Verified locally: |
Summary
Fixes a silent wrong-checkout write: when an MCP session's working directory is anchored inside a linked git worktree, and that worktree's view route can't yet serve (not fully published), a mutating tool call (
edit_file,write_file,edit_symbol,batch_edit, and therefactorfacade'srename_symbol/move_symbol/safe_delete_symbol/inline_symbol/apply_code_action/fix_all_in_file) previously degraded silently to the base corpus. For a file that exists in both the worktree and the main checkout, that meant the edit landed in the main checkout instead of the worktree — while the caller's context (cwd) said it was editing the worktree.viewForSessionCWD(internal/mcp/view_request.go) now refuses loudly (view_building) when the checkout is ready+automatic but its route can't yet serve and the request is a mutation (detected via theWithAuthorizedToolCallcontext marker +facades.mutatesSource). A read still degrades softly to base, but now carries a rider naming which checkout was actually wanted, so the degradation is visible rather than silent.refuseRoutedViewMutation) catches the case where no marker was present (so the first gate couldn't tell it was a mutation): it keys off the request's own tool name instead, and refuses an inexact/fallback view for any mutating tool — so even a marker-less call fails safely to aview_read_onlyrefusal rather than writing base.Test plan
TestCWDBindingRouteNotReadyMutationsRefuseLoudly/...ReadFileIsLabeled/...ReadsFallBackWithRider— pre-existing coverage foredit_fileand reads (unchanged behavior, retained as regression pins)TestCWDBindingRouteNotReadySoleRepoMutationRefusesLoudly— same refusal holds in a single-tracked-repo topology (bare/unprefixed paths), not just the two-repo fixtureTestCWDBindingRouteNotReadyBatchEditRefusesLoudly—batch_edit(a different code path thanedit_file, viahandleAtomicBatchEdit) refuses identicallyTestCWDBindingRouteNotReadyWriteAndEditSymbolRefuseLoudly—write_fileandedit_symbolparity withedit_fileTestCWDBindingRouteNotReadyRefactorFacadeRefusesLoudly— extends coverage to therefactorfacade's six tools (apply_code_action,safe_delete_symbol,fix_all_in_file,inline_symbol,move_symbol,rename_symbol), which share the mutation gate with theeditfacade but had no prior regression coverageTestCWDBindingRouteNotReadyMissingMarkerFailsOpenToReadOnly— a marker-less mutation still gets caught by the second gate (view_read_only), not a silent writego build ./...andgo vet ./...cleango test ./internal/mcp/...(full package, not just the new tests) — clean, twicego test ./...(repo-wide, excluding-raceper the knowninternal/mcp/store_sqlite/indexertimeout) — clean; one unrelated pre-existing flake ininternal/indexerunder full-parallel load (TestSpecLaunch_4_5_CrossWorkspaceHappyPath, times out only under full-suite parallelism, passes in isolation in <1s, zero overlap with this diff) — not touched by this changeedit_filethrough a fresh worktree-anchored session — the edit landed only in the worktree copy, main checkout untouched