Repository navigation
Description
Summary
With stateBackend: "local" (which also covers the "worktree" backend), the squad_state MCP server writes all mutable state (decisions log, agent histories, memory index/audit, orchestration logs, etc.) to the repository root instead of <repo>/.squad/. This silently creates a full "shadow" state tree that diverges from the authoritative .squad/ copy that agents and humans actually read with ordinary file tools.
Root cause
packages/squad-sdk/src/resolution.js (compiled to dist/resolution.js), function resolveSquadState():
const stateStorage = backend.name === 'local'
? new FSStorageProvider() // BUG: no rootDir
: new StateBackendStorageAdapter(backend, paths.projectDir);For the local/worktree backend, FSStorageProvider is constructed with no root directory argument. All relative keys used by the state tools ("decisions.md", "agents/<name>/history.md", "log/<file>.md", "memory/audit.jsonl", ...) then resolve against the MCP server process's current working directory (the repository root) rather than against paths.projectDir (the real .squad/ directory).
Note that paths.projectDir is already computed and correctly passed on the other branch of the same ternary (StateBackendStorageAdapter(backend, paths.projectDir)), which strongly suggests this was an oversight rather than intentional behavior. Because zero constructor arguments are passed regardless of user configuration, there is no configuration-level workaround — this must be fixed in the SDK itself.
There is also a module-level instance a few lines above with the same unrooted construction (const storage = new FSStorageProvider();), which may be worth auditing for the same class of bug if it's used anywhere state keys are resolved relative to a project directory.
Suggested fix
const stateStorage = backend.name === 'local'
? new FSStorageProvider(paths.projectDir) // pass rootDir so relative keys resolve under .squad/
: new StateBackendStorageAdapter(backend, paths.projectDir);Reproduction
- Configure
stateBackend: "worktree"(or"local") withteamRoot: "."in a repo that has a.squad/directory. - Start the
squad_stateMCP server against that repo. - Call
squad_state_writewith keylog/probe.mdand any content. - Observe the file is created at
<repo-root>/log/probe.md, not<repo-root>/.squad/log/probe.md. - As an immediate tell,
squad_state_list()returns the repository's top-level listing (.git,.github,.squad, ...) instead of the contents of.squad/.
Impact
Agents read governance state (e.g. .squad/decisions.md, .squad/agents/*/history.md) with ordinary file tools, while the MCP state server reads and writes a parallel shadow copy at the repo root. The two records diverge silently — neither side has any signal that the other exists. In our case this ran undetected for roughly 11 days and caused a real operational failure: an agent correctly refused to act on a governance decision because that decision, written via the state tool, was invisible in the file the agent actually reads.
Approximate drift found in one affected repo before reconciliation:
- ~95 shadow-only history entries for a single agent
- 22 shadow-only log files, plus several more with diverging content
- 23 shadow-only orchestration-log entries
- memory audit log: 4 → 15 entries after merging
- decisions archive: 210 → 732 lines after merging
The shadow paths were also git-tracked and committed by automated processes (a scheduled "watch" job), so the pollution entered version control history, not just the working tree.
Detection
To check if another repo/instance is affected, look for a shadow tree at the repository root alongside .squad/: directories/files such as agents/, log/, orchestration-log/, memory/, decisions.md appearing at the repo root are the tell. squad_state_health will report FSStorageProvider as the active backend, and squad_state_list() returning .git/.github/other repo-root entries (instead of .squad/ contents) confirms it live.
Workaround until fixed
Patching the installed node_modules/@bradygaster/squad-sdk/dist/resolution.js copy in place works, but:
- It is overwritten on the next
npm install/squad upgrade— it is not a durable fix. - A restart of the
squad_stateMCP server process is required for the patch to take effect — the fix on disk does not retroactively affect an already-running Node process holding the old module in memory.
Environment
@bradygaster/squad-cli/@bradygaster/squad-sdk:0.11.0- Node.js:
v22.15.0 - OS: Windows
- Note: a stale npx-cache build resolving to
@bradygaster/squad-sdk@0.9.4(pulled in via squad-cli's wildcard"@bradygaster/squad-sdk": "*"dependency range) was also found during investigation. That older build predatesresolveSquadState()entirely and appears to be an unused, non-functional cache entry — flagged for awareness, not confirmed as a live risk, but the wildcard dependency range is presumably how such stale/mismatched builds can persist in npx caches at all.
Activity
tamirdresher commentedon Jul 28, 2026
Cross-referenced from internal tracking issue: https://github.com/tamirdresher_microsoft/tamresearch1/issues/5083 (private repo, not publicly accessible — filed there for our own reconciliation history).
tamirdresher commentedon Jul 29, 2026
Second, related defect found while verifying this fix
While validating the
FSStorageProviderrootDir fix, we hit a second, independent bug in the same area:squad_decide(and everysquad_state_*tool — read, write, list, append, delete) started failing with:Root cause
resolveSquadPaths()insquad-sdk/dist/resolution.jsdecides "local" vs "remote" team-root mode with:config.teamRootdefaults to the sentinel string"."(meaning "no separate team dir, use.squad/itself"). That default is a truthy string, so the check always takes the "remote" branch, computing:instead of
teamDir === projectDir(.squad/). That wrongteamDiris passed intoToolRegistryassquadRoot, and every tool builds its absolute file path aspath.join(this.squadRoot, ...)— so all of them end up one directory level outside.squad/. The storage provider'srootDir(correctly.squad/, per this issue's fix) then rejects those paths as traversal, and the LLM-facing error sanitizer (which replaces the real root with the placeholder[team-root]before returning the message) faithfully reports the already-wrong path.[team-root]is not a broken template — it's the sanitizer working correctly on bad input.Regression or latent bug?
Latent bug, exposed by this issue's fix — not a regression from it. Before the fix, the storage provider had no
rootDirat all, so its traversal guard short-circuited (if (!this.rootDir) return filePath;) and silently allowed writes to the wrong (one-level-too-high) location instead of throwing. Adding a realrootDirmade the guard active, which correctly caught a pre-existing path-construction bug that had previously been failing silently — quite possibly the real mechanism behind the original shadow-state-tree symptom this issue was filed for.Fix (same style/location as the original)
Verification
squad_decide,squad_state_read,squad_state_listall failed with the traversal error above.ToolRegistryhandlers (bypassing a since-restarted MCP transport) confirmedteamDir === projectDir === .squad/,squad_decidewrites into.squad/decisions/inbox/, andsquad_state_read/squad_state_listresolve correctly against.squad/.state-mcpserver process restarted — Node ES modules aren't hot-reloaded, so editing the file on disk alone does not affect an already-running server.docs(telemetry-revision): remove fabricated runtime contracts; accura…
docs: production operations — observability, state backend selection,…
docs(gnc-revision): remove fabricated metrics/spans, fix #1555 workar…
docs: production security hardening — threat model, prompt injection …
docs(gnc-revision): remove fabricated metrics/spans, fix #1555 workar…
omercangumus commentedon Aug 9, 2026
picking this up. confirmed the core defect still live on dev: resolveSquadState() in resolution.ts constructs FSStorageProvider() with no rootDir for the local backend, so the traversal guard short-circuits and every state write just trusts whatever path got built upstream instead of validating against .squad/. fix is passing paths.teamDir through, matching what ToolRegistry already gets in state-mcp.ts. PR shortly
fix(sdk): wire FSStorageProvider rootDir in resolveSquadState, fix te…