Skip to content

fix(storage): accept paths under a symlinked root in its original spelling - #2492

Merged
bradygaster merged 1 commit into
devfrom
squad/symlink-team-root
Nov 13, 2026
Merged

bradygaster merged 1 commit into
devfrom
squad/symlink-team-root

Conversation

@bradygaster

Copy link
Copy Markdown
Collaborator

Found in the live scenario pass for #2487 (results).

Problem

FSStorageProvider stores its confinement root resolved through symlinks, for example /private/tmp/x on macOS. Callers pass absolute paths in the spelling they were given, such as /tmp/x/team.md. The first check compares the two as text, so every such path was refused with "Path traversal blocked: [team-root]/team.md".

So squad_state couldn't read or write a team folder reached through a symlink: macOS /tmp, or a team root that is itself a link.

Fix

A path under the root's original spelling is re-expressed under the resolved root before any check runs. Both spellings name the same folder, so this doesn't widen what is reachable. The existing checks still run on the result: the textual containment check, then the symlink check through realpath, in both the async and sync paths.

Tests

test/fs-storage-symlinked-root.test.ts (new):

  • Reads and writes through the link's spelling: async, sync, relative paths, and the root itself.
  • Still refused:
    • ../ out of the root, in either spelling;
    • a sibling folder whose name merely starts with the root's name;
    • a symlink inside the root that points outside it.
  • Two of the new tests fail on dev and pass with the fix. The third shows escapes are still refused.

Full suite on macOS: local-only failures drop from 26 to 7. The fix clears 19, including both squad-20 bridge tests and the memory and upstream tests, all of which hit this bug through /tmp. The remaining failures are in other areas: export/import, upstream source hardening, the profile guard and the gh-aw staging tests. Two of them are known timing flakes.

🤖 Generated with Claude Code

…lling

FSStorageProvider stores its root resolved (/private/tmp/x) but callers
pass absolute paths in the spelling they were given (/tmp/x/team.md), so
the lexical containment check refused them as "Path traversal blocked".
That broke squad_state for team roots reached through a symlink (found in
the #2487 scenario pass) and caused 19 local-only test failures on macOS.
Paths under the original spelling are now re-expressed under the resolved
root before the existing checks, which still refuse real escapes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI dynamic review requested due to automatic review settings November 13, 2026 09:05
@github-actions

Copy link
Copy Markdown
Contributor

🟡 Impact Analysis — PR #2492

Risk tier: 🟡 MEDIUM

📊 Summary

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

🎯 Risk Factors

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

📦 Modules Affected

root (2 files)
  • .changeset/symlinked-team-root.md
  • CHANGELOG.md
squad-sdk (1 file)
  • packages/squad-sdk/src/storage/fs-storage-provider.ts
tests (1 file)
  • test/fs-storage-symlinked-root.test.ts

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

@github-actions

github-actions Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

🛫 PR Readiness Check

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

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 No Copilot review threads
❌ CI passing 2 check(s) still running

Files Changed (4 files, +506 −422)

File +/−
.changeset/symlinked-team-root.md +6 −0
CHANGELOG.md +1 −0
packages/squad-sdk/src/storage/fs-storage-provider.ts +441 −422
test/fs-storage-symlinked-root.test.ts +58 −0

Total: +506 −422


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

@bradygaster
bradygaster enabled auto-merge (squash) November 13, 2026 09:11
@bradygaster
bradygaster merged commit 907565c into dev Nov 13, 2026
32 checks passed

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

Path rewriting can misroute reads and writes on case-sensitive macOS volumes, and the regression test fails on Windows without symlink privileges.

2 open issues
What changed in this PR

Fixes FSStorageProvider path handling so squad_state can access team folders through their original symlinked paths, including macOS /tmp.

Changes:

  • Preserve the original root spelling and rebase paths before existing confinement checks.
  • Add regression tests for linked-root access and rejected escapes.
  • Document the fix and request SDK and CLI patch releases.
File Description
test/​fs-storage-symlinked-root.test.ts Tests linked-root access and traversal protection.
packages/​squad-sdk/​src/​storage/​fs-storage-provider.ts Rebases original-spelling paths for async and sync operations.
CHANGELOG.md Describes the user-visible storage fix.
.changeset/​symlinked-team-root.md Requests SDK and CLI patch releases.

Review effort: Max (dynamic)

Comment on lines +38 to +40
if (!lex || !root || this.pathEquals(lex, root)) return resolved;
if (this.pathEquals(resolved, lex)) return root;
if (this.pathStartsWith(resolved, lex + sep)) return root + resolved.slice(lex.length);
Comment on lines +53 to +56
fs.symlinkSync(path.join(base, 'secret.txt'), path.join(real, 'escape.md'));
const p = new FSStorageProvider(link);
await expect(p.read(path.join(link, 'escape.md'))).rejects.toThrow(/Symlink traversal blocked/);
expect(() => p.readSync(path.join(link, 'escape.md'))).toThrow(/Symlink traversal blocked/);
bradygaster added a commit that referenced this pull request Nov 13, 2026
…ts (#2494)

- squad doctor: the bridge reports its team dir resolved (/private/var/...)
  while doctor expected the unresolved spelling (/var/...), so the check
  failed on macOS temp and symlinked folders. Compare real paths.
- Tests that assumed os.tmpdir() is already resolved, or that macOS ignores
  XDG_CONFIG_HOME, now hold on macOS: upstream-source-hardening,
  profile-fs-guard, export-import.

Local full suite on macOS: 26 failures before #2492, now 0 besides timing
flakes that pass when re-run alone.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants