Skip to content

[AI-1703] Scope vendor MCP exclusion to the execution cwd's ancestor chain - #445

Merged
realtonyyoung merged 12 commits into
mainfrom
tonyyoung/ai-1703-mcp-exclusion-scope
Aug 4, 2026
Merged

[AI-1703] Scope vendor MCP exclusion to the execution cwd's ancestor chain#445
realtonyyoung merged 12 commits into
mainfrom
tonyyoung/ai-1703-mcp-exclusion-scope

Conversation

@realtonyyoung

Copy link
Copy Markdown
Collaborator

The defect

WorktreeManager.WorkspaceMcpConfigPaths 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.

Separately, .github/mcp.json was 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:

Path Vendor Discovery
.mcp.json Claude Code upward through parents, merging; continues above the git root
.mcp.json, .github/mcp.json Copilot CLI cwd → repository root
.codex/config.toml Codex repository root → cwd, closest wins
.cursor/mcp.json, .gemini/settings.json, .kiro/settings/mcp.json, .vscode/mcp.json various workspace root (a subset of the chain)

Opposite 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.json survives.

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 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: 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 (OrdinalIgnoreCase vs ASCII-only) — unobservable while the list was ASCII constants, and exactly how a path becomes contained but unreviewable.

Also in here

  • skip-worktree intersects the destination index (the source index holds staged-but-uncommitted paths the destination clone does not — batching those would have failed update-index on a legitimate snapshot), batched on stdin so a deep cwd cannot hit ARG_MAX or be read as pathspec syntax.
  • EnsureSeparateRoots compares resolved paths as well as lexical ones — a WorktreeRoot symlinked inside the source passed the string test, and Claude Code's upward walk would then reach the source's own root config.
  • The refresh path carries the prefix rather than re-deriving it; SyncFromSourceAsync's target-only overloads are replaced by one requiring a source cwd (no production callers).
  • The execution directory is created rather than required to exist — widening the exclusion made a cwd whose only content is vendor config yield no directory at all, and throwing there would refuse launches for exactly the repositories this protects.

Testing

27 new tests, each with a positive control — including the case-sensitive a/A sibling 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-prefix framing against real git, and containment-plus-reviewability moving together.

Known residuals, stated not hidden

  • Bind-mount and SUBST aliasing of WorktreeRoot — trusted-configuration residual; it is daemon operator config, not branch content.
  • A non-ASCII cwd prefix is refused on case-insensitive volumes only, as a compatibility limitation rather than a security property.
  • The pre-existing review-context capacity DoS (one tracked ancestor config >256 KiB refuses every launch) is unchanged here and tracked separately.

Spec (5 rounds of codex spec review): docs/superpowers/specs/2026-08-04-ai1703-vendor-config-discovery-scope-design.md

🤖 Generated with Claude Code

realtonyyoung and others added 6 commits August 4, 2026 09:50
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>
@linear-code

linear-code Bot commented Aug 4, 2026

Copy link
Copy Markdown

AI-1703

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>
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Scope vendor MCP exclusion to execution cwd ancestor chain

🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Exclude vendor MCP configs across the execution cwd’s ancestor chain, not just repo root.
• Derive one git-native cwd prefix (rev-parse --show-prefix) for both exclusion and launch.
• Unify reserved-path matching and add coverage/tests for missing Copilot discovery paths.
Diagram

graph TD
AO["AgentOrchestrator"] --> WM["WorktreeManager (borrowed snapshot)"] --> GIT[("git: rev-parse/ls-files")]
GIT --> PLAN["SnapshotExclusionPlan"] --> BUILD["Build snapshot + review context"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Whole-tree vendor config exclusion
  • ➕ Covers nested launches into descendants not on the ancestor chain
  • ➕ Simpler reasoning: remove any vendor config anywhere in snapshot
  • ➖ Functional regression: would delete sibling configs no vendor can discover (e.g., committed kcap/.mcp.json)
  • ➖ Expands scope beyond stated threat model; increases disruption without clear benefit
2. Keep root-relative list, but also exclude cwd-relative variants
  • ➕ Smaller surface-area change than introducing a plan
  • ➕ Avoids some plan wiring through snapshot + review context
  • ➖ Reintroduces risk of drift between multiple matchers/lists (the PR’s core defect)
  • ➖ Still vulnerable to namespace mismatches (FS-derived vs git-derived spellings) and case semantics divergence
3. Unicode-aware case folding on case-insensitive destinations
  • ➕ Could allow non-ASCII prefixes on case-insensitive volumes without refusal
  • ➖ Adds a second matcher/normalization surface, undermining the ‘one classifier’ goal
  • ➖ Hard to guarantee equivalence across platforms/filesystems; increases complexity and risk

Recommendation: Proceed with the PR’s approach: a single git-derived cwd prefix and a single byte-level reserved-path classifier shared by snapshot filtering, skip-worktree targeting, and review-context extraction/validation. This directly closes the reported under-exclusion (.codex under sub-cwd) and the missing path gap (.github/mcp.json) while avoiding the multi-matcher drift and namespace mismatch issues that alternatives tend to reintroduce.

Files changed (10) +1452 / -68 · 1 not counted

Enhancement (1) +14 / -2
WorktreeManager.WorkspaceMcp.csClarify scope semantics and add missing Copilot discovery paths +14/-2

Clarify scope semantics and add missing Copilot discovery paths

• Documents that entries are directory-relative and that scope is determined by PlanSnapshotExclusions. Adds '.github/mcp.json' and '.copilot/mcp-config.json' to the canonical vendor config path list and clarifies '.vscode/mcp.json' rationale.

src/Capacitor.Cli.Daemon/Services/WorktreeManager.WorkspaceMcp.cs

Bug fix (3) +176 / -57
AgentOrchestrator.csPersist and pass GitRelativeCwd when refreshing borrowed snapshots +7/-1

Persist and pass GitRelativeCwd when refreshing borrowed snapshots

• Updates borrowed snapshot refresh to require and pass the creation-time GitRelativeCwd into SyncBorrowedSnapshotFromSourceAsync. Prevents re-deriving cwd scope from the target filesystem, which could diverge from the exclusion classifier’s spelling.

src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs

WorktreeManager.ReviewContext.csAlign review-context extraction/validation with exclusion plan; cap manifest size not counted

Align review-context extraction/validation with exclusion plan; cap manifest size

• Threads SnapshotExclusionPlan into review-context extraction so reserved-path matching uses the same expanded set and shared classifier semantics. Adds a serialized-manifest size ceiling with pre-allocation check, and validates entries against the exact matched reserved paths to avoid introducing a second matcher.

src/Capacitor.Cli.Daemon/Services/WorktreeManager.ReviewContext.cs

WorktreeManager.csUse git-relative cwd for launch + exclusion; remove static excluded list +169/-56

Use git-relative cwd for launch + exclusion; remove static excluded list

• Captures GitRelativeCwd at borrowed snapshot creation, uses it for execution path, and persists it on WorktreeInfo for refresh. Removes SnapshotExcludedPaths, builds a per-attempt SnapshotExclusionPlan, routes vendor exclusions through ClassifyReservedPath on raw bytes, batches skip-worktree updates based on destination index membership, strengthens EnsureSeparateRoots with resolved-path checks, and changes SyncFromSourceAsync to require a source-side cwd.

src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs

Refactor (1) +252 / -0
WorktreeManager.ExclusionPlan.csIntroduce SnapshotExclusionPlan and git-derived cwd prefix parsing +252/-0

Introduce SnapshotExclusionPlan and git-derived cwd prefix parsing

• Adds plan construction that expands WorkspaceMcpConfigPaths over the execution cwd’s ancestor chain with depth/byte caps. Implements bounded byte capture for 'git rev-parse --show-prefix', strict framing/UTF-8 parsing, and a stdin-batched git runner for skip-worktree updates.

src/Capacitor.Cli.Daemon/Services/WorktreeManager.ExclusionPlan.cs

Tests (4) +501 / -9
BorrowedSnapshotExclusionScopeTests.csAdd end-to-end tests for ancestor-chain scoping, parsing, caps, and refresh +475/-0

Add end-to-end tests for ancestor-chain scoping, parsing, caps, and refresh

• Creates real git fixtures to verify exclusion across root→cwd chain, sibling non-exclusion, and '.github/mcp.json' coverage. Adds tests for '--show-prefix' framing, non-ASCII rules, depth/aggregate-byte caps, destination-index skip-worktree behavior, review-context reachability, symlinked worktree-root rejection, and refresh reuse of persisted GitRelativeCwd.

test/Capacitor.Cli.Tests.Unit/BorrowedSnapshotExclusionScopeTests.cs

AcpHostedAgentRuntimeFactoryLiveTests.csUpdate SyncFromSourceAsync calls to supply source cwd +1/-1

Update SyncFromSourceAsync calls to supply source cwd

• Adjusts the live runtime factory test to use the new SyncFromSourceAsync signature that requires sourceRepoRoot and sourceCwd.

test/Capacitor.Cli.Tests.Unit/Services/AcpHostedAgentRuntimeFactoryLiveTests.cs

WorkspaceMcpNeutralizationTests.csUpdate path coverage assertions and plan-based exclusion expectations +23/-6

Update path coverage assertions and plan-based exclusion expectations

• Extends the expected vendor path list to include '.github/mcp.json' and '.copilot/mcp-config.json'. Replaces SnapshotExcludedPaths usage with PlanSnapshotExclusions at root and adds an explicit test that the new Copilot paths are present in WorkspaceMcpConfigPaths.

test/Capacitor.Cli.Tests.Unit/WorkspaceMcpNeutralizationTests.cs

WorktreeManagerTests.csUpdate unit tests for new SyncFromSourceAsync signature +2/-2

Update unit tests for new SyncFromSourceAsync signature

• Updates borrowed snapshot refresh-related tests to pass a sourceCwd argument instead of (now removed) target-side executionPath overloads.

test/Capacitor.Cli.Tests.Unit/WorktreeManagerTests.cs

Documentation (1) +509 / -0
2026-08-04-ai1703-vendor-config-discovery-scope-design.mdAdd design spec for git-derived cwd scoping and single-classifier exclusion +509/-0

Add design spec for git-derived cwd scoping and single-classifier exclusion

• Introduces a detailed threat model and vendor discovery matrix, explains the root-relative defect, and specifies a git byte-protocol for deriving cwd prefix. Defines the single-plan/single-classifier architecture, refresh semantics, bounds, and a comprehensive test plan (including new Copilot paths and symlink-root residuals).

docs/superpowers/specs/2026-08-04-ai1703-vendor-config-discovery-scope-design.md

@qodo-code-review

qodo-code-review Bot commented Aug 4, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Invalid array length type 🐞 Bug ≡ Correctness
Description
ReadManifestBytesAsync allocates with new byte[stream.Length] where FileStream.Length is a
long, which does not compile as an array length expression. This breaks the daemon build and
prevents review-context manifest validation from working.
Code

src/Capacitor.Cli.Daemon/Services/WorktreeManager.ReviewContext.cs[R62-66]

+        if (stream.Length > MaxReviewContextManifestBytes)
+            throw new InvalidOperationException(
+                "borrowed_snapshot_review_context_manifest_too_large");
+        var buffer = new byte[stream.Length];
+        await stream.ReadExactlyAsync(buffer, ct);
Evidence
The method checks stream.Length against a max, then uses it directly as an array length, which is
a compile-time type error because FileStream.Length is long.

src/Capacitor.Cli.Daemon/Services/WorktreeManager.ReviewContext.cs[57-67]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`ReadManifestBytesAsync` uses `new byte[stream.Length]` but `stream.Length` is `long`, which is not a valid array length type in C#.

### Issue Context
The method already enforces `stream.Length <= MaxReviewContextManifestBytes` (4 MiB), so converting to `int` is safe once made explicit.

### Fix Focus Areas
- src/Capacitor.Cli.Daemon/Services/WorktreeManager.ReviewContext.cs[57-67]

### Suggested change
After the size check, convert length explicitly:
- `var length = checked((int)stream.Length);`
- `var buffer = new byte[length];`
Then read exactly `length` bytes.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Verbose comments in SnapshotExclusionPlan ✗ Dismissed 📘 Rule violation ⚙ Maintainability
Description
New/updated code introduces large, narrative-style comment blocks that restate behavior and
historical context instead of keeping comments minimal and focusing on non-obvious rationale. This
increases maintenance cost and makes core logic harder to scan and review.
Code

src/Capacitor.Cli.Daemon/Services/WorktreeManager.ExclusionPlan.cs[R27-30]

+    /// <para><b>Why a plan rather than a static list.</b> Vendors resolve workspace MCP config along the
+    /// ancestor chain of their cwd — Codex layers <c>.codex/config.toml</c> from the repository root down
+    /// to the cwd, Copilot and Claude Code walk from the cwd upward — so a root-relative list is only
+    /// complete when the cwd IS the root. A borrowed snapshot may execute below the root, and a root-only
Evidence
PR Compliance ID 2 requires avoiding large verbose comments in favor of self-explanatory code. The
added multi-paragraph XML documentation and narrative inline comments (e.g., the long "Why a
plan"/"One list"/"Not a security boundary" sections) meet the checklist's failure criteria for
overly verbose commentary.

CLAUDE.md: Avoid verbose comments; prefer self-explanatory code
src/Capacitor.Cli.Daemon/Services/WorktreeManager.ExclusionPlan.cs[23-44]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Several new/updated files add very long explanatory comments (multi-paragraph XML docs and inline blocks). This violates the guideline to avoid verbose comments and prefer self-explanatory code with concise, additive commentary.

## Issue Context
This PR adds substantial narrative/history/threat-model explanation directly in code comments. That material is valuable, but should mostly live in design/spec docs, leaving code comments short and focused on the most non-obvious invariants.

## Fix Focus Areas
- src/Capacitor.Cli.Daemon/Services/WorktreeManager.ExclusionPlan.cs[23-44]
- src/Capacitor.Cli.Daemon/Services/WorktreeManager.ReviewContext.cs[41-50]
- src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs[888-901]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Unvalidated sourceCwd in sync ✓ Resolved 🐞 Bug ⛨ Security
Description
The new public SyncFromSourceAsync runs git rev-parse --show-prefix in the caller-provided
sourceCwd without verifying it is inside sourceRepoRoot (or even that it belongs to the same git
worktree). If sourceCwd is a valid git directory outside the intended repo (e.g., another repo),
the derived prefix/exclusion plan can be wrong and may leave vendor config unexcluded in the
generated target tree.
Code

src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs[R563-566]

    public async Task SyncFromSourceAsync(
-            string sourceRepoRoot, string targetWorktreePath,
-            string[] excludePaths, CancellationToken ct) {
-        await SyncFromSourceAsync(
-            sourceRepoRoot, targetWorktreePath, targetWorktreePath, excludePaths, ct);
-    }
-
-    public async Task SyncFromSourceAsync(
-            string sourceRepoRoot, string targetWorktreePath, string executionPath,
+            string sourceRepoRoot, string sourceCwd, string targetWorktreePath,
            string[] excludePaths, CancellationToken ct) {
+        var gitRelativeCwd = await ReadGitRelativeCwdAsync(Path.GetFullPath(sourceCwd), ct);
Evidence
SyncFromSourceAsync derives gitRelativeCwd from sourceCwd with no containment check, while
ReadGitRelativeCwdAsync runs git in whatever directory it is given. CreateBorrowedSnapshotAsync
shows the intended containment validation pattern that is missing from the sync API.

src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs[501-513]
src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs[563-570]
src/Capacitor.Cli.Daemon/Services/WorktreeManager.ExclusionPlan.cs[71-78]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`WorktreeManager.SyncFromSourceAsync(sourceRepoRoot, sourceCwd, ...)` derives `gitRelativeCwd` by running git in `sourceCwd`, but never checks that `sourceCwd` is contained by `sourceRepoRoot` or that git’s top-level directory matches `sourceRepoRoot`.

### Issue Context
`CreateBorrowedSnapshotAsync` already performs explicit containment/existence checks on `requestedCwd` before calling `ReadGitRelativeCwdAsync`; the sync path should enforce the same invariant.

### Fix Focus Areas
- src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs[501-513]
- src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs[563-570]
- src/Capacitor.Cli.Daemon/Services/WorktreeManager.ExclusionPlan.cs[71-78]

### Suggested change
In `SyncFromSourceAsync`:
1) `source = Path.GetFullPath(sourceRepoRoot)` and `cwd = Path.GetFullPath(sourceCwd)`.
2) Reject if `!Directory.Exists(cwd)`.
3) Reject if `Path.GetRelativePath(source, cwd)` is rooted or escapes via `..` (same logic as `CreateBorrowedSnapshotAsync`).
4) (Stronger) Run `git rev-parse --show-toplevel` in `cwd` and ensure it equals `sourceRepoRoot` under your existing path comparison rules; fail if it differs.
Then call `ReadGitRelativeCwdAsync(cwd, ct)`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. NUL in XML doc comment ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
WorktreeManager.ReviewContext.cs contains an embedded NUL control character in an XML doc comment
((<c>\0</c>) rendered as a literal NUL). This can cause source tooling to treat the file as binary
and may fail compilation/formatting in some environments.
Code

src/Capacitor.Cli.Daemon/Services/WorktreeManager.ReviewContext.cs[R45-47]

+    /// <para><b>Derived from the content cap, not chosen.</b> Each admitted byte can appear twice — once
+    /// base64-encoded (4/3) and once in <c>Text</c>, where JSON escaping of a control character costs six
+    /// bytes (<c>\u0000</c>). Worst case is therefore about <c>256 KiB × (4/3 + 6) ≈ 1.9 MiB</c> before
Evidence
The comment line visibly includes a literal control character inside <c>...</c>, indicating the
source file contains a NUL byte/character rather than an escaped sequence.

src/Capacitor.Cli.Daemon/Services/WorktreeManager.ReviewContext.cs[41-48]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
An XML doc comment includes a literal NUL character, which can break text-processing tools and may be rejected by some compilers/linters.

### Issue Context
The intent is to *describe* a NUL/control character expansion cost, not to embed the byte in source.

### Fix Focus Areas
- src/Capacitor.Cli.Daemon/Services/WorktreeManager.ReviewContext.cs[45-48]

### Suggested change
Replace the literal NUL with a safe textual representation, e.g.:
- `(<c>\\0</c>)` or `(<c>\\u0000</c>)` or `(<c>NUL</c>)`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread src/Capacitor.Cli.Daemon/Services/WorktreeManager.ExclusionPlan.cs
Comment thread src/Capacitor.Cli.Daemon/Services/WorktreeManager.cs Outdated
realtonyyoung and others added 5 commits August 4, 2026 11:04
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>
@realtonyyoung
realtonyyoung merged commit 897b4af into main Aug 4, 2026
6 checks passed
@realtonyyoung
realtonyyoung deleted the tonyyoung/ai-1703-mcp-exclusion-scope branch August 4, 2026 18:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant