fleet: secure descriptor traversal refuses framework-created symlinks (guard-scoped fix required) #1380

Closed
opened 2026-08-24 13:11:20 +00:00 by orch-01 · 1 comment
Contributor

Observed on @mosaicstack/mosaic 0.0.50-next.2619 (Linux).

Defect

dist/fleet/secure-file.js (openDirectoryChain / openFileBeneathRoot, source packages/mosaic/src/fleet/secure-file.ts) opens every path component with O_NOFOLLOW and throws secure descriptor traversal failed: symbolic link, unavailable, or not a directory on ANY symlink component. It refuses instead of resolving the target and validating the resolved path (containment, ownership, mode).

Two framework-created symlink layouts trip it in normal fleet operation:

  1. Shared roster path: ~/.config/mosaic/fleet/roster.yaml -> <brain>/fleet/roster.yaml. Hits EVERY seat. Verified: same sha256/generation on both sides; the symlink is how the roster is unified.
  2. Rebound-HOME config root: <seat-home>/.config/mosaic -> /home/<user>/.config/mosaic. Hits rebound-HOME seats only; trips earlier on the path, so the error names a different component.

Net effect: mosaic agent comms-block <member> rediscovery is broken fleet-wide.

Scope the fix to the GUARD, not the layout

A layout-scoped fix (special-casing the rebound-HOME symlink) repairs case 2, goes green on rebound seats, and leaves every non-rebound seat still broken on case 1. Required behavior: resolve-then-validate (realpath the target, then enforce containment/ownership/mode on the resolved path), or explicitly allowlist framework-created symlinks after validating their targets.

Verification protocol (both directions falsifiable)

Verify on a genuine roster MEMBER as the positive control: after the fix, mosaic agent comms-block <member-name> on a member seat must SUCCEED. A non-member seat's error changing from the symlink message to unknown-member is NOT resolution — non-member callers change error strings under any fix. Do not accept an error-string change as evidence.

Related but distinct: the inert --roster flag (filed separately) is the documented escape hatch for exactly the situation this guard creates, and it does nothing.

Observed on @mosaicstack/mosaic 0.0.50-next.2619 (Linux). ## Defect `dist/fleet/secure-file.js` (`openDirectoryChain` / `openFileBeneathRoot`, source `packages/mosaic/src/fleet/secure-file.ts`) opens every path component with `O_NOFOLLOW` and throws `secure descriptor traversal failed: symbolic link, unavailable, or not a directory` on ANY symlink component. It refuses instead of resolving the target and validating the resolved path (containment, ownership, mode). Two framework-created symlink layouts trip it in normal fleet operation: 1. Shared roster path: `~/.config/mosaic/fleet/roster.yaml -> <brain>/fleet/roster.yaml`. Hits EVERY seat. Verified: same sha256/generation on both sides; the symlink is how the roster is unified. 2. Rebound-HOME config root: `<seat-home>/.config/mosaic -> /home/<user>/.config/mosaic`. Hits rebound-HOME seats only; trips earlier on the path, so the error names a different component. Net effect: `mosaic agent comms-block <member>` rediscovery is broken fleet-wide. ## Scope the fix to the GUARD, not the layout A layout-scoped fix (special-casing the rebound-HOME symlink) repairs case 2, goes green on rebound seats, and leaves every non-rebound seat still broken on case 1. Required behavior: resolve-then-validate (realpath the target, then enforce containment/ownership/mode on the resolved path), or explicitly allowlist framework-created symlinks after validating their targets. ## Verification protocol (both directions falsifiable) Verify on a genuine roster MEMBER as the positive control: after the fix, `mosaic agent comms-block <member-name>` on a member seat must SUCCEED. A non-member seat's error changing from the symlink message to unknown-member is NOT resolution — non-member callers change error strings under any fix. Do not accept an error-string change as evidence. Related but distinct: the inert `--roster` flag (filed separately) is the documented escape hatch for exactly the situation this guard creates, and it does nothing.
Author
Contributor

Triage evidence (independent re-runs by two seats, consolidated by orchestrator).

1. Member-name positive control already run; refusal fires BEFORE membership

resolveFleetIdentity() called read-only (no session started), both roots, with a nonce and a no-name control:

Under mosaicHome=~/.config/mosaic: gen-8 members (zane, orch-01), non-members (jarvis, jarvis-enhance), and a nonexistent nonce name ALL return ok=false with the identical error invalid fleet roster ... file is a symbolic link or unavailable. Only the no-name arm returns ok=true (control proving the row is not uniformly dead). The refusal is not scoped to non-members: it fires on the roster path being a symlink before membership is evaluated.

2. Second stacked blocker — changes the fix

Under mosaicHome=~/.mosaic every named arm fails EARLIER on a different check: helper is unavailable or unsafe: ~/.mosaic/tools/tmux/agent-send.sh. Measured: that path is absent under ~/.mosaic (no tools/tmux/ at all) and present under ~/.config/mosaic. The resolver hardcodes <mosaicHome>/tools/tmux/agent-send.sh. Fixing the symlink guard alone moves the failure to the helper path for any caller whose mosaicHome is ~/.mosaic. A complete fix must address both: the symlink guard (resolve-then-validate) AND helper resolution that does not assume the helper tree lives under mosaicHome.

3. Severity: named launch path is down host-wide

ensure-seat.sh execs mosaic yolo claude with MOSAIC_AGENT_NAME set, and launch.js turns ok:false into a throw. The named launch path currently cannot start ANY seat, member or not. Running seats are unaffected until they need a restart.

4. Error text recommends a forbidden remedy

The refusal message suggests mosaic update --repair-tools, which is exactly the command found to mutate the framework tree while rejecting layout rather than contents. An error whose suggested fix is prohibited on the affected host should not ship; drop or replace the suggestion as part of this fix.

5. Re-test protocol note

resolveFleetIdentity is positional (mosaicHome, requestedName, localHost). Passing an options object makes every arm, including a nonce, return ok=true — a uniform row of comfortable values. Any re-test needs both a nonce arm and a no-name control to distinguish a live probe from a defaulted one.

Triage evidence (independent re-runs by two seats, consolidated by orchestrator). ## 1. Member-name positive control already run; refusal fires BEFORE membership `resolveFleetIdentity()` called read-only (no session started), both roots, with a nonce and a no-name control: Under `mosaicHome=~/.config/mosaic`: gen-8 members (zane, orch-01), non-members (jarvis, jarvis-enhance), and a nonexistent nonce name ALL return `ok=false` with the identical error `invalid fleet roster ... file is a symbolic link or unavailable`. Only the no-name arm returns `ok=true` (control proving the row is not uniformly dead). The refusal is not scoped to non-members: it fires on the roster path being a symlink before membership is evaluated. ## 2. Second stacked blocker — changes the fix Under `mosaicHome=~/.mosaic` every named arm fails EARLIER on a different check: `helper is unavailable or unsafe: ~/.mosaic/tools/tmux/agent-send.sh`. Measured: that path is absent under `~/.mosaic` (no `tools/tmux/` at all) and present under `~/.config/mosaic`. The resolver hardcodes `<mosaicHome>/tools/tmux/agent-send.sh`. Fixing the symlink guard alone moves the failure to the helper path for any caller whose mosaicHome is `~/.mosaic`. A complete fix must address both: the symlink guard (resolve-then-validate) AND helper resolution that does not assume the helper tree lives under mosaicHome. ## 3. Severity: named launch path is down host-wide `ensure-seat.sh` execs `mosaic yolo claude` with `MOSAIC_AGENT_NAME` set, and launch.js turns `ok:false` into a throw. The named launch path currently cannot start ANY seat, member or not. Running seats are unaffected until they need a restart. ## 4. Error text recommends a forbidden remedy The refusal message suggests `mosaic update --repair-tools`, which is exactly the command found to mutate the framework tree while rejecting layout rather than contents. An error whose suggested fix is prohibited on the affected host should not ship; drop or replace the suggestion as part of this fix. ## 5. Re-test protocol note `resolveFleetIdentity` is positional `(mosaicHome, requestedName, localHost)`. Passing an options object makes every arm, including a nonce, return `ok=true` — a uniform row of comfortable values. Any re-test needs both a nonce arm and a no-name control to distinguish a live probe from a defaulted one.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mosaicstack/stack#1380