Skip to content

fix(sdk): keep a linked team's state on the team repo's git backend - #2351

Open
joslat wants to merge 3 commits into
github:devfrom
joslat:joslat/2320-linked-team-git-backend
Open

joslat wants to merge 3 commits into
github:devfrom
joslat:joslat/2320-linked-team-git-backend

Conversation

@joslat

@joslat joslat commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

What

A linked project on the orphan or two-layer state backend can use squad_state again. The git backend runs in the team repo, and the storage adapter is rooted at the team's .squad. The team repo's git root has to be the team repo itself: if the team root isn't in a git repository, or only sits inside another one, resolution fails with a message that says so instead of writing state somewhere else.

Why

Closes #2320

resolveSquadState ran the git backend in the project repo and rooted StateBackendStorageAdapter at the project's .squad, while state-mcp roots every tool at the team's .squad. Every memory.*, squad_state_write and squad_state_read call therefore failed with "path is outside squadDir". As Tamir confirmed on the issue, the team repo owns .squad/, so its state belongs on the team repo's squad-state branch.

How

  • resolution.ts, resolveSquadState.
    • In remote (linked) mode with a configured orphan or two-layer backend, the state backend runs in the team repo, picked by a new linkedTeamRepoRoot():

      • The team repo is teamRoot when it holds .squad/ (what squad link writes), its parent when teamRoot names the .squad/ dir, or teamRoot when the squad files sit at its root.
      • Its git rev-parse --show-toplevel must equal the team repo, not just contain it. A linked worktree's root counts.
      • Both sides are compared canonically, using the file's existing canonicalDir() (realpath) and samePath(). So .., absolute paths, symlinks or junctions, 8.3 names and drive-letter or case differences don't change the answer.
    • It fails closed, with a message naming the team root and the project's config.json, when:

      • the team root doesn't exist;
      • the team's .squad is a link out of the team repo;
      • the team root isn't in a git repository;
      • the team root's git root is some other, enclosing repository ("… is not the root of a git repository: it is inside the repository at …. Squad will not write the team's state into another repository.").

      Before this PR, every state call failed instead. In the first version of this PR, a team dir with no repo of its own resolved to the enclosing repo.

    • A teamRoot that is itself a link to a team repo of its own is accepted. That repo's root is exactly what the link points at, so there's no other repository involved.

    • The adapter is rooted at paths.teamSquadDir, the same root the local backend (FSStorageProvider) and every tool already use. Outside linked mode teamSquadDir equals projectDir, so nothing changes there.

    • Only the backend moves. repoRoot in the returned context stays the project's (worktree-aware) repository, because callers use it for project paths: the Claude hook's reviewer-lockout check (hook.ts) and the backend MCP harnesses' agent turns (backend-mcp.ts). The first version of this PR changed repoRoot too, which would have broken both; Copilot's review caught the hook.

    • On Windows, a teamRoot that names the squad dir with different casing (C:\team\.SQUAD) is recognised as the squad dir, so the team repo is its parent.

  • state-backend.ts. The config parsing in resolveStateBackend moves into configuredStateBackend(), so resolveSquadState can check the type before creating the backend. It's not exported from the package index, and resolveStateBackend behaves the same.

What stays the same:

  • Linked projects on the local backend: same behaviour.
  • Projects that aren't linked: same behaviour on every backend.
  • The backend type still comes from the project's config.json. I left that alone on purpose; there's a related question about it below.

There's no "trusted link record" escape hatch: Squad has no such record today, so anything that doesn't resolve to the team repo's own root is refused. If you want one later, it can go on top of this.

Related question, not changed here: squad link writes only teamRoot and projectKey to the project's config.json, never stateBackend. If the team repo itself runs on orphan (set in the team's own config.json), a project linked with squad link therefore resolves to local and writes straight into the team repo's working tree instead of its squad-state branch. Should a linked project take the team's stateBackend when its own config doesn't set one? I can open a separate issue for that.

Testing

New tests in test/cli/state-mcp.test.ts, through a real createStateMcpSession from the project dir:

  • linked project with orphan and with two-layer:
    • squad_state_write, squad_state_read and memory.write succeed;
    • the team repo gets refs/heads/squad-state (for orphan, the written key is on it);
    • the project repo gets no squad-state branch and no notes;
    • the team's working tree stays clean;
  • linked project with orphan whose team root isn't in a git repository: resolveSquadState throws the message above. This one runs under the system temp dir, since anywhere inside the checkout git rev-parse finds the Squad repo itself.

New test/linked-team-state-boundary.test.ts (20 tests, all under the system temp dir, for the boundary from the review):

  • accepted:
    • relative, absolute and .. teamRoot values that land on the team repo;
    • a teamRoot that names the team's .squad dir;
    • a team repo that is a linked git worktree;
    • a teamRoot symlink or junction to a team repo of its own;
    • on Windows, a teamRoot spelled with a lowercase drive letter and an uppercased path, both for the team repo and for its .squad dir (spelled .SQUAD).
  • repoRoot stays the project's repository while the backend runs in the team repo (orphan and two-layer).
  • refused, with no squad-state branch or notes created in any repo:
    • a non-git team dir inside another repository, reached by a relative, an absolute and a ..-traversal teamRoot, on orphan and on two-layer;
    • a teamRoot symlink or junction to such a dir;
    • a team .squad that is a symlink or junction into another repository;
    • a team root outside any repository;
    • a missing team root.
  • the adapter still refuses to delete, empty or overwrite the team's state root (deleteSync, deleteDirSync, writeSync on teamSquadDir).

New test/cli/hook-linked-project.test.ts: the real squad hook claude PreToolUse command in a linked project on orphan and on two-layer still warns a locked-out member who edits a project file covered by a lockout.

Results:

  • With the source change reverted, and the SDK dist rebuilt from it (the CLI imports the SDK from dist), all 3 state-mcp tests fail. With the change, they pass.
  • Boundary tests:
    • Against the first version of this PR (0ef7eb3), the 7 refusal tests fail: it resolved to the enclosing repo, as you found.
    • The missing-root test fails there only on the message, since the first version already refused that case.
    • With this change, all 17 pass.
    • The 5 tests added for the Copilot review (the hook on both backends, repoRoot on both backends, .SQUAD) fail against 24e6789 with the SDK dist rebuilt from it, and pass with the fix.
  • Boundary, hook, state-mcp, state-backend (+ root guard, handshake), resolution, memory-store-resolution, backend, claude-hooks and claude-v2-review-fixes: 361 passed, 7 skipped, 3 failed. The 3 failures are Windows-only problems in the Claude hook suites, which call runClaudeHook directly and never go through resolution: creating a symlink without permission (EPERM), a folder name containing < and >, and a spawned child process. Separately, state-backend-create-if-absent fails 3 tests the same way as before. The only failures are 3 in state-backend-create-if-absent, which spawns a child with a c:\ path as an ESM URL (ERR_UNSUPPORTED_ESM_URL_SCHEME), a Windows-only problem in that test. It doesn't go through resolution.
  • npm run lint: clean. ESLint has no errors, and the only warnings on the new lines are n/no-sync, which applies to the whole file: resolution is synchronous. markdownlint and cspell are clean on the docs and changeset.

⚠️ Quick Check

  • If SDK/CLI source files changed: changeset added (.changeset/linked-team-git-backend.md, sdk patch)

PR Readiness Checklist

Branch & Commit

  • Branch created from dev (not main)
  • Branch is up to date with dev (675c9e9, the new github/squad home)
  • Verified diff contains only intended changes (7 files)
  • PR is not in draft mode
  • Commit history is clean

Build & Test

  • npm run lint passes
  • Relevant tests pass
  • No file overlap with any open PR

Docs

  • features/state-backends.md: a short "Linked Projects" note (state stays with the team repo, whose Git root must be the team repo itself, and the error otherwise)

Breaking Changes

None for working setups. A linked project on a git backend whose team root isn't the root of its own git repository now gets a clear error when it starts. Before, it either failed on every state call or, if the team dir sat inside another repository, used that repository.

Waivers

None.

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

🟠 Impact Analysis — PR #2351

Risk tier: 🟠 HIGH

📊 Summary

Metric Count
Files changed 8
Files added 3
Files modified 5
Files deleted 0
Modules touched 5

🎯 Risk Factors

  • 8 files changed (6-20 → MEDIUM)
  • 5 modules touched (5-8 → HIGH)

📦 Modules Affected

docs (1 file)
  • docs/src/content/docs/features/state-backends.md
root (1 file)
  • .changeset/linked-team-git-backend.md
scripts (1 file)
  • scripts/release/test-reconciliation.mjs
squad-sdk (2 files)
  • packages/squad-sdk/src/resolution.ts
  • packages/squad-sdk/src/state-backend.ts
tests (3 files)
  • test/cli/hook-linked-project.test.ts
  • test/cli/state-mcp.test.ts
  • test/linked-team-state-boundary.test.ts

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

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

🛫 PR Readiness Check

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

PR Scope: 📦🔧 Mixed (product + infrastructure)

⚠️ 3 item(s) to address before review

Status Check Details
❌ Single commit 3 commits — consider squashing before review
✅ 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 0 active Copilot thread(s) resolved (2 outdated skipped)
❌ CI passing 9 check(s) still running

Files Changed (8 files, +494 −10)

File +/−
.changeset/linked-team-git-backend.md +5 −0
docs/src/content/docs/features/state-backends.md +2 −0
packages/squad-sdk/src/resolution.ts +93 −5
packages/squad-sdk/src/state-backend.ts +16 −3
scripts/release/test-reconciliation.mjs +1 −0
test/cli/hook-linked-project.test.ts +87 −0
test/cli/state-mcp.test.ts +67 −2
test/linked-team-state-boundary.test.ts +223 −0

Total: +494 −10


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

@tamirdresher

Copy link
Copy Markdown
Collaborator

Thanks Jose, this is the right fix direction and the storage adapter part is solid (roots at teamSquadDir, normalizes Windows separators and case, rejects outside paths and dot segments, keeps the root delete guard). One boundary issue we need closed before merging, found in our security pass on 0ef7eb3:

In packages/squad-sdk/src/resolution.ts around line 975, gitTopLevel(paths.teamSquadDir) ?? gitTopLevel(paths.teamDir) accepts any git repository that merely contains the configured team directory. It does not prove that the top level it found is the linked team's own repo. Since teamRoot comes straight from .squad/config.json and can be an absolute path, contain .., or point through a symlink, a team directory with no .git of its own that sits under some other repository resolves successfully and the orphan or two layer backend then writes state refs into that outer repo. We reproduced it: a project linked to a non git team dir nested under another repo returned resolved true, backend orphan, with the outer ancestor selected. The current test only places the non git team under the temp dir where there is no ancestor repo, so it does not catch this.

What we need:

  1. Canonicalize the configured team target and the selected git top level (realpath, Windows case and drive handling).
  2. Require the git top level to equal the intended team repository root (worktrees included), not just contain it.
  3. Fail closed on absolute, traversal or symlink configs that resolve to a different repo boundary, unless an explicit trusted link record names that exact canonical root.
  4. Regression tests: non git team nested inside another repo, .. traversal, absolute path, symlink or junction escape, Windows drive and case normalization, and the root delete rejection still holding.

Not a question of intent, just a boundary we have to hold since this path decides where state gets written. Happy to review again as soon as it is pushed.

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

👋 Friendly nudge — this PR has had no activity for 7 days.

What needs attention:

  • 👀 No approving reviews yet. Request a review from a teammate.
  • ⬇️ 19 commits behind dev. Rebase to pick up latest changes.

If this PR is abandoned, please close it. If it's blocked on something external, leave a comment so the team knows.
This is an automated check that runs on weekdays. It won't nudge the same PR more than once per week.

@joslat
joslat force-pushed the joslat/2320-linked-team-git-backend branch from 0ef7eb3 to 24e6789 Compare November 10, 2026 18:50
Copilot AI auto review requested due to automatic review settings November 10, 2026 18:50
@joslat

joslat commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks a lot for the careful security pass, Tamir, and sorry this sat for a while. I missed your review in the middle of the repo move. Good catch: containing the team dir is clearly not the same as being the team repo.

Pushed 24e6789, rebased on the new dev (675c9e9). It covers all four points:

  1. Canonicalized. The team target and git's top level both go through the existing canonicalDir() (realpath) and samePath(), so .., absolute paths, symlinks or junctions, 8.3 names and drive-letter or case differences don't change the result.

  2. Equality, not containment. The top level must equal the team repo: teamRoot when it holds .squad/, its parent when teamRoot names .squad/, or teamRoot when the squad files are at its root. A linked worktree's root counts.

  3. Fail closed. A non-git team dir inside another repo, a teamRoot link to one, a team .squad linked out of the team repo, a team root outside any repo, and a missing team root all throw. The message names the team root and the project's config.json. I didn't add a "trusted link record" exception, because Squad has no such record today, so anything that isn't the team repo's own root is refused. A teamRoot that is a link to a team repo with its own root is still accepted, since no other repository is involved there.

  4. Regression tests. New test/linked-team-state-boundary.test.ts, 17 tests:

    • your nested case, reached through a relative, an absolute and a .. path, on both orphan and two-layer;
    • a symlink or junction escape, both for teamRoot and for the team's .squad;
    • Windows drive letter and case;
    • a worktree;
    • the root-delete rejection still holding through the resolved storage.

    The refusal tests also check that no squad-state branch or notes were created in any repo. Against 0ef7eb3, the 7 refusal tests fail (it picked the outer repo, as you saw), and with the fix all 17 pass.

repoRoot keeps git's own spelling of the path, like the unlinked case. Details are in the updated description. Happy to adjust anything!

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

Linked-project hooks can skip reviewer lockout checks, while Windows path casing can cause valid team repositories to be rejected and prevent state tools from starting.

2 open issues
What changed in this PR

Fixes #2320 by keeping linked-team state on the team repository’s Git backend and aligning storage paths with the team’s .squad directory.

Changes:

  • Routes linked orphan and two-layer backends to the team repository.
  • Adds repository-boundary validation and regression coverage for linked state operations.
  • Documents linked-project behavior and adds an SDK patch changeset.
File Description
test/​linked-team-state-boundary.test.ts Tests repository boundaries, worktrees, links, and path casing.
test/​cli/​state-mcp.test.ts Tests linked-team state tools and missing-repository errors.
packages/​squad-sdk/​src/​state-backend.ts Extracts backend configuration selection into a helper.
packages/​squad-sdk/​src/​resolution.ts Resolves the team’s Git repository and roots storage at its squad directory.
docs/​src/​content/​docs/​features/​state-backends.md Documents linked-project storage and repository requirements.
.changeset/​linked-team-git-backend.md Records the SDK patch and its behavior changes.

Review effort: Balanced (auto)

Comment thread packages/squad-sdk/src/resolution.ts Resolved
Comment thread packages/squad-sdk/src/resolution.ts Resolved
In a linked project on the orphan or two-layer backend, resolveSquadState
ran the git backend in the project repo and rooted the storage adapter at
the project's .squad, while every squad_state tool builds paths under the
team's .squad. Each memory.*, squad_state_write and squad_state_read call
failed with "path is outside squadDir".

The team repo owns the state:

- in linked mode with a git backend, the backend runs in the team repo.
  Its git top level must be exactly the team repo (a worktree root
  counts), compared after resolving links, drive letters and case. A team
  dir that only sits inside another repository, through "..", an absolute
  teamRoot or a symlink/junction, and a team .squad linked out of the
  team repo are refused, as is a team root outside any git repository;
- only the backend moves: the context's repoRoot stays the project's
  repository, which the Claude hooks and the backend MCP harnesses use
  for project paths and agent turns;
- the adapter is rooted at the team's .squad, the root the local backend
  and the tools already use (unchanged outside linked mode);
- the config parsing in resolveStateBackend moves to
  configuredStateBackend() so the type can be checked first.

Closes github#2320
@joslat
joslat force-pushed the joslat/2320-linked-team-git-backend branch from 24e6789 to aa2e033 Compare November 10, 2026 19:04
… reconciliation

The two drive-letter/case tests run only on Windows (it.runIf win32), so
Linux release evidence reported them as unexplained skips. Following the
repo's [platform: win32] convention, they get a title prefix and a
reviewed env-gated rule with runsOn: 'win32', which also makes them
required, passing executions in Windows evidence.
…ring them in the Windows release job

The Windows release job runs a fixed list of test files, so a runsOn rule
made these two tests required there and reported them missing. Use the
same plain env-gated rule as the other it.runIf(win32) tests
(mcp-spec-init) and leave the release workflow untouched.
@joslat

joslat commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

Hi @tamirdresher, a short update since my reply above: Copilot's review found two more things, both fixed and resolved.

  • repoRoot in the resolved context stays the project's repo, and only the state backend uses the team repo. The new Claude hook (lockout check) and createMcpBackend (agent turns) both read repoRoot as the project root, so changing it would have broken them. There's a hook-level regression test for it now.
  • A teamRoot like C:\team\.SQUAD is now recognised on Windows.

The two Windows-only casing tests are registered as env-gated in the release reconciliation, the same way the mcp-spec-init tests are, and CI is fully green on 219f988. Ready for your re-review whenever you have a moment. Thanks!

This branch has not been deployed

No deployments
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_state fails on every call in a linked team with the orphan or two-layer backend

3 participants