Skip to content

fix(sdk): refuse consult when info/exclude resolves outside the project - #1850

Merged
bradygaster merged 1 commit into
devfrom
squad/1826-consult-refuses-linked-worktree
Aug 24, 2026
Merged

fix(sdk): refuse consult when info/exclude resolves outside the project#1850
bradygaster merged 1 commit into
devfrom
squad/1826-consult-refuses-linked-worktree

Conversation

@bradygaster

Copy link
Copy Markdown
Owner

Closes #1826

Summary

setupConsultMode hides .squad/ by appending to git's info/exclude, but resolved that path with git rev-parse --git-path info/exclude — which answers for whichever repository encloses the directory, not one rooted at it. The "already squadified -> refuse" guard checked the worktree-local .squad/. The check was local; the write was global.

Two ways the write escapes:

  • from a linked worktree, info/exclude resolves to the main checkout;
  • from a directory that is not itself a repo root, it resolves to an ancestor repository.

Either way .squad/ is hidden in checkouts the caller never named. Since info/exclude is untracked and per-clone, no repo-side change can repair it — which is why .squad/e2e/ was invisible despite being absent from .gitignore, and why git add -f was needed for #1819-#1821.

The route was not unprovable — this test suite was doing it

#1826 recorded the worktree route as "unproven and now unprovable." It turns out a different route was live in the repo, and it is reproducible on demand.

The fixture built a fabricated .git/ directory:

mkdirSync(join(PROJECT_ROOT, '.git', 'info'), { recursive: true });

That satisfies the "is there a .git here" check but is not a valid repository, so git rev-parse walked out of the fixture and answered for the enclosing squad checkout. Every run of these tests appended the consult block to the developer's own .git/info/exclude.

Measured directly on my clone:

BEFORE: 240        # pristine git template
npx vitest run test/sdk/consult.test.ts -t "setupConsultMode"
Tests  11 passed | 41 skipped (52)
AFTER:  313        # +73 B consult block

240 + 73 = 313 — byte-identical to the forensic math in #1826, which measured the live poisoned file at exactly 313 B. CI never surfaced it because CI clones are discarded, precisely as the issue predicted.

After this change, the same measurement holds steady across all five consult-adjacent suites (122 tests):

BEFORE: 241
AFTER:  241

One of the issue's two suggested fixes is not available

#1826 offered: "refuse ... or write to a worktree-local exclude."

The second option does not work. Git keeps no per-worktree info/exclude — a file placed at .git/worktrees/<id>/info/exclude is simply never read. Verified directly:

git-dir:        C:/src/squad/.git/worktrees/bradygaster-shiny-chainsaw
git-common-dir: C:/src/squad/.git
--git-path:     C:/src/squad/.git/info/exclude     # <- the shared one

# planted ZZZ-probe-local.txt in the worktree-local info/exclude:
check-ignore result: ''                            # <- not honored

Taking that option would have produced a quieter bug: consult mode silently failing to hide .squad/, leaving it exposed to accidental commits. Refusing is the only safe behaviour, so that is what this implements.

Change

  • isExcludeOwnedBy(projectRoot) (new export) — verifies the git common dir is contained by projectRoot. One check covers all three cases: normal repo passes; linked worktree fails; nested-in-ancestor fails.
  • setupConsultMode refuses before any mutation, with a message naming the cause and directing the caller to the main checkout.
  • resolveGitExcludePath docstring corrected. It claimed to handle "worktrees/submodules" — the inverse of what it does. That claim is the proximate cause of this defect and is now documented as a hazard with an explicit pointer to the containment check.
  • Fixture is hermeticgit init instead of a fabricated .git/.
  • Three regression tests: exclude is written inside the project; refuse from a linked worktree; refuse from a fabricated-.git directory (the exact old-fixture shape).

Validation

npx vitest run test/sdk/consult.test.ts test/cli/consult.test.ts \
  test/cross-package-exports.test.ts test/state/archival.test.ts \
  test/cli/nap-archival-safety.test.ts

Test Files  5 passed (5)
     Tests  122 passed (122)

npm run build passes. Changeset included (patch, squad-sdk).

Follow-ups, not in this PR

  • .squad/identity/prd-consult-mode.md:229 advises "Do not hard-code resolve(cwd, '.git/info/exclude'). In git worktrees..." and prescribes git rev-parse --git-path info/exclude. That guidance is what produced this bug. It needs correcting, but it is identity/PRD material — flagging for Scribe rather than editing here.
  • squad doctor check — suggestion (2) in squad consult writes .squad/ to the shared info/exclude, silently hiding state in main and all sibling worktrees #1826, to detect and repair clones already poisoned. This PR stops new poisonings; it cannot heal existing ones. Worth its own issue.
  • Anyone who has run this suite has a poisoned clone. Repair is removing the # Squad consult mode (local only) block from .git/info/exclude.

Routing note: #1826 names EECOM for consult.ts with CAPCOM/FIDO for the doctor check. Handled here directly because it gates the E2E re-run this morning — EECOM should review.

setupConsultMode hides .squad/ by appending to git's info/exclude, but
resolved that path with `git rev-parse --git-path info/exclude`, which
answers for whichever repository ENCLOSES the directory. The guard that
refuses an already-squadified project checked the worktree-local .squad/,
so the two disagreed about scope: the check was local, the write global.

Two ways that write escapes:

  - from a linked worktree, info/exclude resolves to the MAIN checkout;
  - from a directory that is not itself a repo root, it resolves to an
    ANCESTOR repository.

Either way .squad/ is hidden in checkouts the caller never named, and
info/exclude is untracked and per-clone, so nothing in the repo can undo
it. That is why .squad/e2e/ was invisible despite being absent from
.gitignore, and why `git add -f` was needed for #1819-#1821.

This test suite was itself a live instance of the second case. The
fixture built a fabricated `.git/` directory, which is not a valid
repository, so git walked out of the fixture and every run appended the
consult block to the developer's own checkout. Byte math matches the
forensics in #1826 exactly: 240 B template + 73 B block = the 313 B
observed there. CI never surfaced it because CI clones are discarded.
The fixture now runs `git init`, and the exclude file stays put.

Writing to a worktree-local exclude is not an available alternative:
git keeps no per-worktree info/exclude, and a file placed at
.git/worktrees/<id>/info/exclude is never read. Verified directly. So
refusing is the only safe behaviour, and setupConsultMode now checks
containment via a new isExcludeOwnedBy() export and points the caller at
the main checkout.

Closes #1826

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 20afe6d2-444e-414e-8a39-e67ab67ca6df
Copilot AI lite review requested due to automatic review settings August 24, 2026 17:32
@github-actions

Copy link
Copy Markdown
Contributor

🟡 Impact Analysis — PR #1850

Risk tier: 🟡 MEDIUM

📊 Summary

Metric Count
Files changed 3
Files added 1
Files modified 2
Files deleted 0
Modules touched 3

🎯 Risk Factors

  • 3 files changed (≤5 → LOW)
  • 3 modules touched (2-4 → MEDIUM)

📦 Modules Affected

root (1 file)
  • .changeset/lucky-moons-shake.md
squad-sdk (1 file)
  • packages/squad-sdk/src/sharing/consult.ts
tests (1 file)
  • test/sdk/consult.test.ts

This report is generated automatically for every PR. See #733 for details.

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

🛫 PR Readiness Check

ℹ️ This comment updates on each push. Last checked: commit 6392978

PR Scope: 📦🔧 Mixed (product + infrastructure)

⚠️ 2 item(s) to address before review

Status Check Details
Single commit 1 commit — clean history
Not in draft Ready for review
Branch up to date Up to date with dev
Copilot review No Copilot review yet — it may still be processing
Changeset present Changeset file found
Scope clean No .squad/ or docs/proposals/ files
No merge conflicts No merge conflicts
Copilot threads resolved 2 unresolved Copilot thread(s) — fix and resolve before merging
CI passing All checks passing

Files Changed (3 files, +138 −5)

File +/−
.changeset/lucky-moons-shake.md +19 −0
packages/squad-sdk/src/sharing/consult.ts +55 −2
test/sdk/consult.test.ts +64 −3

Total: +138 −5


This check runs automatically on every push. Fix any ❌ items and push again.
See CONTRIBUTING.md and PR Requirements for details.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new containment logic (and its matching test assertion) uses startsWith('..'), which can misclassify valid in-project paths whose first segment begins with .. (e.g., ..foo) and should be tightened to detect only actual parent traversal (../ or ..\\).

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR fixes a consult-mode safety bug where setupConsultMode() could append .squad/ to a different checkout’s shared info/exclude (e.g., when invoked from a linked worktree or when git rev-parse resolves an enclosing repository), by refusing to proceed unless the resolved git common dir is contained by projectRoot.

Changes:

  • Added isExcludeOwnedBy(projectRoot) and a refusal guard in setupConsultMode() before mutating info/exclude.
  • Updated resolveGitExcludePath() documentation to explicitly warn about git rev-parse scoping behavior.
  • Made the setupConsultMode test fixture hermetic (git init) and added regression tests for the linked-worktree and fabricated-.git cases; added a changeset for the SDK patch.
File summaries
File Description
packages/squad-sdk/src/sharing/consult.ts Adds containment check (isExcludeOwnedBy) and refuses consult mode when info/exclude would resolve outside the project; updates docstring.
test/sdk/consult.test.ts Switches fixture to git init to avoid leaking into the enclosing repo; adds regression tests for refusal scenarios.
.changeset/lucky-moons-shake.md Documents the behavior change and ships it as a patch release for @bradygaster/squad-sdk.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +371 to +372
const rel = path.relative(path.resolve(projectRoot), commonDir);
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
Comment thread test/sdk/consult.test.ts
Comment on lines +601 to +606
const rel = relative(PROJECT_ROOT, result.gitExclude);
expect(
rel.startsWith('..') || isAbsolute(rel),
`consult wrote to ${result.gitExclude}, outside ${PROJECT_ROOT}. ` +
`That is a different repository's exclude file.`,
).toBe(false);
@bradygaster
bradygaster merged commit 9426b21 into dev Aug 24, 2026
17 checks passed
@bradygaster
bradygaster deleted the squad/1826-consult-refuses-linked-worktree branch August 24, 2026 18:14
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.

squad consult writes .squad/ to the shared info/exclude, silently hiding state in main and all sibling worktrees

2 participants