[AI-1703] Scope vendor MCP exclusion to the execution cwd's ancestor chain - #445
Conversation
Design for making the borrowed-snapshot exclusion follow vendor discovery (the execution cwd's ancestor chain) rather than the repository root, and adding .github/mcp.json to the canonical path list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Derives the cwd prefix from git's own path bytes (rev-parse --show-prefix) rather than the filesystem, classifies through one byte-level matcher shared with the review-context extractor, corrects the reserved-index intersection to the destination index, adds a vendor discovery matrix and explicit caps. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Withdraws the unconditional case fold (a launch-refusal primitive), pins the rev-parse --show-prefix byte protocol, persists the git prefix across a refresh instead of re-deriving it, extends EnsureSeparateRoots to resolved paths, and corrects the whole-tree rejection rationale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Collapses the launch path and the classifier onto one git-derived prefix (the round-3 critical: two independent derivations could disagree and the appeal to a fail-closed side effect was unsound), narrows the non-ASCII prefix refusal to case-insensitive destinations, specifies resolving a not-yet-created snapshot root, and documents bind-mount/volume-alias aliasing as a trusted-configuration residual rather than claiming closure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the non-borrowed sync overloads with one that requires a source-side cwd (the old shape left no way to derive a prefix except the banned filesystem inference), pins where the prefix is captured versus where the plan is built relative to the destination case probe, persists the actual matched paths rather than canonical spellings so validation needs no second matcher, and corrects the non-ASCII test expectations to the pinned parse order. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The canonical vendor-config list is root-relative and every consumer matched it that way, but a borrowed snapshot can execute BELOW the repository root: CreateBorrowedSnapshotAsync takes the git root and the user's cwd independently, and returns the execution path as WorktreeInfo.Path. A review flow started from <repo>/src therefore left src/.codex/config.toml live in the tree the reviewer runs in. Codex layers .codex/config.toml from the root down to the cwd; Copilot and Claude Code walk from the cwd upward. Either way the reachable set is the ancestor chain, so that is what is now excluded. Also adds .github/mcp.json, which the list never had at all (it carried .github/copilot/mcp.json, a different path) and which was unprotected at the root of every borrowed snapshot regardless of scope, plus .copilot/mcp-config.json under the list's standing "wider than known readers" rationale. One prefix, from git, for both the classifier and the launch. It comes from `rev-parse --show-prefix`, not the filesystem: a .NET-derived prefix is not in the same pathname namespace as the paths ls-files reports (NFD/NFC on macOS, rooted results across Windows volumes), and deriving the launch cwd separately from the classified prefix lets the two disagree — at which point an unexcluded path materialises the alternate-spelling directory itself and the vendor launches somewhere nothing was excluded. One classifier, not two. Vendor paths no longer appear in the snapshot exclusion list at all; they are matched only through ClassifyReservedPath, which the review-context extractor also uses. The two had different case semantics (OrdinalIgnoreCase vs ASCII-only) — unobservable while the list was ASCII constants, and exactly how a path becomes contained but unreviewable. Other consequences: skip-worktree now intersects the DESTINATION index (the source index contains staged-but-uncommitted paths the destination clone does not, which would have failed update-index on a legitimate snapshot) and is batched on stdin; EnsureSeparateRoots compares resolved paths as well as lexical ones; the refresh path carries the prefix rather than re-deriving it; and the execution directory is created rather than required, since a cwd whose only content was vendor config now yields no directory at all. Notes: bind-mount and SUBST aliasing of WorktreeRoot remain a documented trusted-configuration residual. A non-ASCII cwd prefix is refused on case-insensitive volumes only, as a stated compatibility limitation. The pre-existing review-context capacity DoS is unchanged and tracked separately. Spec: docs/superpowers/specs/2026-08-04-ai1703-vendor-config-discovery-scope-design.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The NUL made git classify WorktreeManager.ReviewContext.cs as binary, so the file diffed as "Binary files differ" and was unreviewable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR Summary by QodoScope vendor MCP exclusion to execution cwd ancestor chain
AI Description
Diagram
High-Level Assessment
Files changed (10)
|
Code Review by Qodo
1. Invalid array length type
|
Four findings, all real: - The index policy marked only Exact matches skip-worktree while the manifest filter excludes Exact AND Descendant. The rationale was backwards: a repo can track `.mcp.json/child` (the config pathname as a directory) and each such child IS an index entry. Left unmarked it reads as a deletion, and an ordinary git operation could restore it into a live vendor-config tree. Now marks every non-Unrelated match. - ResolveDeepestExisting only tested LinkTarget on the deepest existing component, so with /alias -> /real and an ordinary /alias/existing, the containment check returned the lexical path and still missed a snapshot root reaching inside the source through the ancestor link. Now resolves every component of the existing prefix, with a bounded hop count for chains. - The git-derived prefix was not checked to belong to the named source repo. rev-parse reports whatever repository it DISCOVERS at the cwd, so a nested or foreign repository yielded a prefix in another namespace that was then matched against this source's ls-files output — the exact invariant the derivation exists to hold. --show-toplevel is now captured and required to resolve to the source root. - Both git helpers killed inline and threw, leaving the child unreaped and the pump tasks unobserved; the stdin helper also leaked a running child on a non-cancellation write fault. Cleanup moved into finally via a shared TerminateAndDrainAsync. Three regression tests added. Note on the first: on the BORROWED path a reserved path tracked as a directory is refused earlier by the pre-existing review-context guard, so the deletion case is unreachable there — that fail-closed behaviour is pinned, and the index fix is exercised through the sync path, which has no review context. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- IsAtOrUnder now normalises both operands to NFC. Case folding alone is not enough on a normalisation-insensitive volume: macOS treats composed and decomposed `café` as one directory while no StringComparison makes those strings equal, so a source spelled one way and a snapshot root spelled the other failed both the lexical and resolved checks and still landed inside the source. True filesystem identity stays out of reach (.NET exposes no portable device/inode pair) and remains in the documented trusted-configuration residual. - TerminateAndDrainAsync bounds every wait. It runs from a finally, so an unbounded WaitForExitAsync after a failed kill — or an unbounded pump await when a surviving descendant inherited the redirected pipe — would swallow the original timeout or overflow exception by never returning. Past a 5s budget the streams are abandoned, with a terminal continuation on each pump so an abandoned one cannot go unobserved. - The link-resolution hop loop now fails closed on exhaustion instead of continuing with a half-resolved path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- The NFC folding added last round was unconditional, which is correct on a normalisation-insensitive volume and WRONG on Linux, where composed and decomposed names are distinct directories — a valid layout would have been refused. Now probed on the source (the volume a nested snapshot root would have to be on), same shape as the existing case probe, falling back to folding when the probe cannot run. - TerminateAndDrainAsync falls back to a plain Kill() after a failed tree kill. Tree enumeration can fail where killing the process itself succeeds, and returning with a live owned child is a leak: disposing Process does not terminate it. The bounded waits stay, since neither kill form is guaranteed. - Both git helpers capture stderr INSIDE the protected block. Awaiting it after the finally re-awaited a pump the cleanup may have abandoned, waited out the remainder of the git timeout, and surfaced a raw task exception instead of the contextual timeout message. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 3 flagged that unconditional NFC folding over-refuses on a normalization-sensitive volume. The probe I added to make it conditional was worse than the problem: File.Exists reports false for access and I/O errors as well as absence, so a failed probe read as "normalization-sensitive" and silently reopened the containment bypass it exists to close — fail-open. It also wrote into the user's own checkout, which the source manifest reads as untracked content, and its cleanup deleted a second pathname it had never created. So the probe is gone and the fold is unconditional again, with the cost stated where the decision lives: the refusal needs an operator to have spelled the source and worktree root with different normalizations of the same name on such a volume, and it fails closed with a specific coded error. A clear error in a vanishingly rare configuration beats a bypass in a common one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Qodo finding: SyncFromSourceAsync ran rev-parse in a caller-supplied sourceCwd with no containment check of its own. The work-tree-top check inside ReadGitRelativeCwdAsync already refuses a foreign repository, so the security hole qodo describes is closed — but this overload still had no containment or existence check, so a bad cwd surfaced as a raw git failure instead of a coded error. Now mirrors CreateBorrowedSnapshotAsync. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The defect
WorktreeManager.WorkspaceMcpConfigPathsis root-relative, and every consumer matched it that way. But a borrowed snapshot can execute below the repository root —CreateBorrowedSnapshotAsynctakes the git root and the user's cwd independently, and returns the execution path asWorktreeInfo.Path. A review flow started from<repo>/srctherefore leftsrc/.codex/config.tomllive in the tree the reviewer runs in.Separately,
.github/mcp.jsonwas not on the list at all — it carried.github/copilot/mcp.json, a different path — so that one was unprotected at the root of every borrowed snapshot regardless of scope.This is a gap in already-merged containment (AI-1632, #427), not a follow-up nicety.
Vendor discovery
Re-checked against vendor documentation rather than inferred:
.mcp.json.mcp.json,.github/mcp.json.codex/config.toml.cursor/mcp.json,.gemini/settings.json,.kiro/settings/mcp.json,.vscode/mcp.jsonOpposite directions, same reachable set: the ancestor chain of the execution cwd. No supported vendor is documented to search downward or into a sibling — which is why a sibling is deliberately left alone, and why this repo's own committed
kcap/.mcp.jsonsurvives.The two structural changes
One prefix, from git, for both the classifier and the launch. It comes from
rev-parse --show-prefix, not the filesystem. A .NET-derived prefix is not in the same pathname namespace as the pathsls-filesreports — NFD/NFC on macOS, rooted results across Windows volumes — and deriving the launch cwd separately from the classified prefix lets the two disagree: an unexcluded path then materialises the alternate-spelling directory itself, and the vendor launches somewhere nothing was excluded.One classifier, not two. Vendor paths no longer appear in the snapshot exclusion list at all; they are matched only through
ClassifyReservedPath, which the review-context extractor also uses. The two matchers had different case semantics (OrdinalIgnoreCasevs ASCII-only) — unobservable while the list was ASCII constants, and exactly how a path becomes contained but unreviewable.Also in here
skip-worktreeintersects the destination index (the source index holds staged-but-uncommitted paths the destination clone does not — batching those would have failedupdate-indexon a legitimate snapshot), batched on stdin so a deep cwd cannot hitARG_MAXor be read as pathspec syntax.EnsureSeparateRootscompares resolved paths as well as lexical ones — aWorktreeRootsymlinked inside the source passed the string test, and Claude Code's upward walk would then reach the source's own root config.SyncFromSourceAsync's target-only overloads are replaced by one requiring a source cwd (no production callers).Testing
27 new tests, each with a positive control — including the case-sensitive
a/Asibling pair (an earlier design folded case unconditionally, which handed a hostile branch a launch-refusal primitive), the cross-volume alternate-prefix bypass, the staged-only addition,--show-prefixframing against real git, and containment-plus-reviewability moving together.Known residuals, stated not hidden
WorktreeRoot— trusted-configuration residual; it is daemon operator config, not branch content.Spec (5 rounds of codex spec review):
docs/superpowers/specs/2026-08-04-ai1703-vendor-config-discovery-scope-design.md🤖 Generated with Claude Code