Skip to content

FSStorageProvider constructed without rootDir in resolveSquadState() — state writes land in repo root instead of .squad/ #1555

Closed

Description

@tamirdresher

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

  1. Configure stateBackend: "worktree" (or "local") with teamRoot: "." in a repo that has a .squad/ directory.
  2. Start the squad_state MCP server against that repo.
  3. Call squad_state_write with key log/probe.md and any content.
  4. Observe the file is created at <repo-root>/log/probe.md, not <repo-root>/.squad/log/probe.md.
  5. 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_state MCP 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 predates resolveSquadState() 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

  1. tamirdresher commented on Jul 28, 2026

    @tamirdresher
    CollaboratorAuthor

    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).

  2. tamirdresher commented on Jul 29, 2026

    @tamirdresher
    CollaboratorAuthor

    Second, related defect found while verifying this fix

    While validating the FSStorageProvider rootDir fix, we hit a second, independent bug in the same area: squad_decide (and every squad_state_* tool — read, write, list, append, delete) started failing with:

    Path traversal blocked: [team-root]\decisions\inbox\<file>.md
    

    Root cause

    resolveSquadPaths() in squad-sdk/dist/resolution.js decides "local" vs "remote" team-root mode with:

    if (config && config.teamRoot) {
        // remote mode: teamDir = resolve(projectRoot, config.teamRoot)
    }

    config.teamRoot defaults 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:

    teamDir = resolve(projectRoot, ".") = projectRoot   // one directory above .squad/
    

    instead of teamDir === projectDir (.squad/). That wrong teamDir is passed into ToolRegistry as squadRoot, and every tool builds its absolute file path as path.join(this.squadRoot, ...) — so all of them end up one directory level outside .squad/. The storage provider's rootDir (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 rootDir at 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 real rootDir made 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)

    if (config && config.teamRoot && config.teamRoot !== '.') { ... }

    Verification

    • Before: squad_decide, squad_state_read, squad_state_list all failed with the traversal error above.
    • After: direct invocation of the same ToolRegistry handlers (bypassing a since-restarted MCP transport) confirmed teamDir === projectDir === .squad/, squad_decide writes into .squad/decisions/inbox/, and squad_state_read/squad_state_list resolve correctly against .squad/.
    • Restart required: like the original fix, this needs the state-mcp server process restarted — Node ES modules aren't hot-reloaded, so editing the file on disk alone does not affect an already-running server.
  3. added 5 commits that reference this issue on Aug 3, 2026
    b14a686
    cab66da
    091463c
    065d302
    555250e
  4. omercangumus commented on Aug 9, 2026

    @omercangumus
    Contributor

    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

  5. added a commit that references this issue on Aug 23, 2026
    0842655
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions