Repository navigation
Description
Summary
squad externalize names each project's external-state directory using only the basename of the project path (deriveProjectKey). Two different projects whose folders share a name therefore resolve to the same external directory, and externalize moves state in with no ownership/occupancy check — so the second project silently overwrites the first project's externalized state. There is no warning, no error, and no record of which project owns the directory, so the loss is invisible and unrecoverable after the fact.
Steps to reproduce
~/work/api→squad init(build up some team state) →squad externalize.~/clients/acme/api(a different project, same folder nameapi) →squad init→squad externalize.- The second
externalizewrites into the same…/squad/projects/api/and overwrites the first project'sdecisions.md, agent histories, and memory. No warning is shown.
Root cause (v0.11.0 / dev)
packages/squad-sdk/src/resolution.ts—deriveProjectKey()uses onlypath.basename(projectDir)(lowercased + sanitized), with anunknown-projectfallback. No parent path, no uniqueness.packages/squad-sdk/src/resolution.ts—resolveExternalStateDir(key)builds{globalDir}/projects/{key}and validates only emptiness +..; no ownership/collision guard.packages/squad-cli/src/cli/commands/externalize.ts(runExternalize) — derives the key and moves state into the resolved directory with only theKEEP_LOCALfilter; no occupancy/ownership check before overwriting.
Impact
- Silent, unrecoverable loss of a project's externalized team state whenever two project folders share a name (common: multiple repos named
api,web,service, …). - Wider than exact matches: names are lowercased and non-
[a-z0-9._-]characters are collapsed, so different names can merge; and any folder name made entirely of non-Latin characters (e.g. Chinese / Arabic / emoji) derives the singleunknown-projectkey — so all such projects share one bucket and overwrite each other. - Today's only mitigation is remembering to pass the optional
--key.
Proposed fix
The collision is created at a single site (runExternalize), so a guard there is sufficient; resolution.ts — and therefore every existing external directory — can stay byte-for-byte unchanged.
I've prepared a fix (draft PR to follow) that adds a small .owner.json ownership marker recording the owning project's path, and refuses when a different project targets an already-owned directory (pointing to the existing --key option). Pre-existing external dirs are adopted in place via the project's own config.json, so nothing is re-keyed or lost, and it catches every collision family (including the unknown-project bucket). If you'd rather not introduce a new on-disk file, a simpler variant that just refuses when the target dir is already non-empty and unproven is equally easy — happy to go that route instead.
Either way the behavior change is confined to the previously-broken path (silent overwrite → a clear refusal with a --key remedy), it is backward compatible, and it is a @bradygaster/squad-cli patch only (SDK untouched).
Reported with the help of Elad, a human-supervised autonomous coding assistant.
Activity
docs: clarify external state project keys (github#1483)
fix(cli): prevent external state key collisions
fix(cli): prevent external state key collisions (github#2283)