From 8eb7e6354e16ae9379fb8a14dca2d7b2e344d1e0 Mon Sep 17 00:00:00 2001 From: code-be-01 Date: Mon, 24 Aug 2026 19:39:13 +0000 Subject: [PATCH] fix(fleet): resolve-then-validate symlink guard + framework helper resolution (#1380) (#1383) Co-authored-by: code-be-01 --- .../mosaic/src/fleet/comms-onboarding.spec.ts | 214 +++++++++++++++--- packages/mosaic/src/fleet/comms-onboarding.ts | 74 +++++- packages/mosaic/src/fleet/fleet-roster-v1.ts | 66 +++++- packages/mosaic/src/fleet/secure-file.spec.ts | 88 ++++++- packages/mosaic/src/fleet/secure-file.ts | 126 ++++++++--- .../src/runtime/update-checker.reseed.spec.ts | 9 +- 6 files changed, 505 insertions(+), 72 deletions(-) diff --git a/packages/mosaic/src/fleet/comms-onboarding.spec.ts b/packages/mosaic/src/fleet/comms-onboarding.spec.ts index 41bb9c97..c89a1faa 100644 --- a/packages/mosaic/src/fleet/comms-onboarding.spec.ts +++ b/packages/mosaic/src/fleet/comms-onboarding.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { chmodSync, mkdtempSync, @@ -16,6 +16,7 @@ import { renderPeerReach, readFleetCommsBlock, resolveCommsBlock, + resolveFleetIdentity, resolvePeerCommand, renderToolsContractStatus, } from './comms-onboarding.js'; @@ -61,6 +62,68 @@ describe('shared fleet roster v1 resolver', () => { }); }); + // stack#1380 verification unblock: the fleet's own roster-v2 tooling writes + // the v1 body plus a generation fence and seat lifecycle/launch envelopes. + // The parser tolerates exactly that envelope (validated, opaque to comms). + const V2_ROSTER = [ + 'version: 2', + 'generation: 8', + 'transport: tmux', + 'tmux:', + ' socket_name: mosaic-fleet', + 'defaults:', + ' working_directory: ~/.mosaic', + ' runtime: claude', + 'agents:', + ' - name: orch-01', + ' runtime: claude', + ' class: orchestrator', + ' model: opus', + ' reasoning: high', + ' lifecycle:', + ' enabled: true', + ' desired_state: running', + ' launch:', + ' yolo: false', + '', + ].join('\n'); + + it('accepts the roster-v2 envelope (generation + lifecycle/launch) on the v1 body', () => { + const resolved = parseFleetRosterV1(V2_ROSTER, 'yaml'); + expect(resolved.tmux.socketName).toBe('mosaic-fleet'); + expect(resolved.agents[0]?.name).toBe('orch-01'); + }); + + it('rejects a non-integer generation', () => { + expect(() => + parseFleetRosterV1(V2_ROSTER.replace('generation: 8', 'generation: eight'), 'yaml'), + ).toThrow(/generation must be a non-negative integer/); + }); + + it('rejects an invalid lifecycle desired_state', () => { + expect(() => + parseFleetRosterV1( + V2_ROSTER.replace('desired_state: running', 'desired_state: paused'), + 'yaml', + ), + ).toThrow(/desired_state must be running\|stopped/); + }); + + it('rejects unknown fields inside the lifecycle envelope', () => { + expect(() => + parseFleetRosterV1( + V2_ROSTER.replace(' enabled: true', ' enabled: true\n surprise: 1'), + 'yaml', + ), + ).toThrow(/lifecycle has unknown field/); + }); + + it('rejects a non-boolean launch.yolo', () => { + expect(() => + parseFleetRosterV1(V2_ROSTER.replace('yolo: false', 'yolo: sometimes'), 'yaml'), + ).toThrow(/launch\.yolo must be a boolean/); + }); + it('rejects unknown fields instead of leniently constructing a second roster view', () => { expect(() => parseFleetRosterV1(`${ROSTER}\nunknown: value\n`, 'yaml')).toThrow( /unknown field/i, @@ -493,6 +556,11 @@ describe('resolvePeerCommand', () => { describe('readFleetCommsBlock — spawned-agent context', () => { let home: string; beforeEach(() => { + // Hermetic helper fallback (stack#1380): the resolver probes + // $HOME/.config/mosaic when mosaicHome itself carries no helper — point + // HOME at a sandbox parent so tests never see the real host install. + vi.stubEnv('HOME', mkdtempSync(join(tmpdir(), 'mosaic-homeless-'))); + vi.stubEnv('MOSAIC_HOME', ''); home = mkdtempSync(join(tmpdir(), 'mosaic-comms-')); mkdirSync(join(home, 'fleet'), { recursive: true }); mkdirSync(join(home, 'tools', 'tmux'), { recursive: true }); @@ -501,7 +569,10 @@ describe('readFleetCommsBlock — spawned-agent context', () => { writeFileSync(helper, '#!/bin/sh\n'); chmodSync(helper, 0o755); }); - afterEach(() => rmSync(home, { recursive: true, force: true })); + afterEach(() => { + vi.unstubAllEnvs(); + rmSync(home, { recursive: true, force: true }); + }); it('uses the authoritative self host and global socket from the shared roster resolver', () => { const result = readFleetCommsBlock(home, 'enhancer', 'process-host-must-not-win'); @@ -564,23 +635,27 @@ describe('readFleetCommsBlock — spawned-agent context', () => { }, ], [ - 'symlink', + 'symlink escaping the install home', () => { const helper = join(home, 'tools', 'tmux', 'agent-send.sh'); rmSync(helper); - writeFileSync(join(home, 'real-send.sh'), '#!/bin/sh\n'); - symlinkSync(join(home, 'real-send.sh'), helper); + const outside = mkdtempSync(join(tmpdir(), 'mosaic-helper-outside-')); + writeFileSync(join(outside, 'real-send.sh'), '#!/bin/sh\n', { mode: 0o755 }); + symlinkSync(join(outside, 'real-send.sh'), helper); }, ], ['non-executable', () => chmodSync(join(home, 'tools', 'tmux', 'agent-send.sh'), 0o644)], - ])('fails closed for a %s helper with deterministic repair guidance', (_case, mutate) => { - mutate(); - const result = readFleetCommsBlock(home, 'enhancer', 'w-jarvis'); - expect(result.ok).toBe(false); - expect(result.output).toBe(''); - expect(result.error).toContain('mosaic update --repair-tools'); - expect(result.error).toContain('no active context or session was rewritten'); - }); + ])( + 'fails closed for a %s helper with deterministic guidance (no forbidden remedy)', + (_case, mutate) => { + mutate(); + const result = readFleetCommsBlock(home, 'enhancer', 'w-jarvis'); + expect(result.ok).toBe(false); + expect(result.output).toBe(''); + expect(result.error).not.toContain('--repair-tools'); // stack#1380 M5a + expect(result.error).toContain('no active context or session was rewritten'); + }, + ); it('does not rewrite the roster while resolving context', () => { const path = join(home, 'fleet', 'roster.yaml'); @@ -603,11 +678,11 @@ describe('renderToolsContractStatus — non-mutating install drift', () => { }); afterEach(() => rmSync(home, { recursive: true, force: true })); - it('uses the supported repair command when installed TOOLS.md is missing', () => { + it('names operator-verified recovery instead of a forbidden remedy when installed TOOLS.md is missing', () => { const status = renderToolsContractStatus(home); - expect(status).toContain('mosaic update --repair-tools'); - expect(status).not.toContain('--reseed'); expect(status).toContain('authorized operator'); + expect(status).not.toContain('--repair-tools'); // stack#1380 M5a + expect(status).not.toContain('--reseed'); }); it('reports stale preserved content without rewriting it', () => { @@ -616,8 +691,8 @@ describe('renderToolsContractStatus — non-mutating install drift', () => { writeFileSync(path, stale); const status = renderToolsContractStatus(home); expect(status).toContain('fleet-comms-contract: 1'); - expect(status).toContain('digest-qualified backup'); - expect(status).toContain('mosaic update --repair-tools'); + expect(status).toContain('authorized operator'); + expect(status).not.toContain('--repair-tools'); // stack#1380 M5a expect(status).toContain('active context was not rewritten'); expect(readFileSync(path, 'utf8')).toBe(stale); }); @@ -648,7 +723,7 @@ describe('renderToolsContractStatus — non-mutating install drift', () => { expect(renderToolsContractStatus(home)).not.toBe(''); }); - it('treats installed TOOLS.md symlinks as stale without following or rewriting them', () => { + it('reads an installed TOOLS.md symlink whose validated target diverges (stack#1380 resolve-then-validate)', () => { const external = join(home, 'external-tools.md'); const externalContent = '# external\n\n'; writeFileSync(external, externalContent); @@ -656,24 +731,23 @@ describe('renderToolsContractStatus — non-mutating install drift', () => { const status = renderToolsContractStatus(home); - expect(status).toContain('unavailable'); - expect(status).toContain('mosaic update --repair-tools'); + expect(status).toContain('does not byte-match'); expect(readFileSync(external, 'utf8')).toBe(externalContent); }); - it('treats source TOOLS.md symlinks as unavailable without following them', () => { - const external = join(home, 'external-source.md'); + it('treats a source TOOLS.md symlink escaping the install home as unavailable', () => { + const outside = mkdtempSync(join(tmpdir(), 'mosaic-source-outside-')); const content = '# authoritative tools\n\n'; - writeFileSync(external, content); + writeFileSync(join(outside, 'external-source.md'), content); rmSync(join(home, 'defaults', 'TOOLS.md')); - symlinkSync(external, join(home, 'defaults', 'TOOLS.md')); + symlinkSync(join(outside, 'external-source.md'), join(home, 'defaults', 'TOOLS.md')); writeFileSync(join(home, 'TOOLS.md'), content); const status = renderToolsContractStatus(home); expect(status).toContain('source contract'); expect(status).toContain('unavailable'); - expect(readFileSync(external, 'utf8')).toBe(content); + expect(readFileSync(join(outside, 'external-source.md'), 'utf8')).toBe(content); }); it('accepts byte-equal bounded source and installed contracts', () => { @@ -730,3 +804,91 @@ describe('resolveCommsBlock — mosaic agent comms-block', () => { expect(result.error).toContain('requires'); }); }); + +describe('resolveFleetIdentity — stack#1380 split-home layouts', () => { + // Brain-shaped mosaicHome (fleet state, NO tools/tmux) + framework config + // home carrying the helper, roster unified by the framework-created symlink + // /fleet/roster.yaml -> /fleet/roster.yaml. This is the + // host layout that was down; all probes are POSITIONAL per the #1380 + // verification protocol (an object arg proves nothing — M5b). HOME is + // stubbed so the config-default fallback stays inside the sandbox. + let brain: string; + let configHome: string; + + beforeEach(() => { + const parent = mkdtempSync(join(tmpdir(), 'mosaic-i1380-parent-')); + brain = join(parent, 'brain'); + // Framework home at the stubbed DEFAULT location so the fallback derives + // exactly as in production ($HOME/.config/mosaic), not by coincidence. + configHome = join(parent, 'home', '.config', 'mosaic'); + vi.stubEnv('HOME', join(parent, 'home')); + vi.stubEnv('MOSAIC_HOME', ''); + mkdirSync(join(brain, 'fleet'), { recursive: true }); + writeFileSync(join(brain, 'fleet', 'roster.yaml'), ROSTER, { mode: 0o600 }); + mkdirSync(join(configHome, 'fleet'), { recursive: true }); + symlinkSync(join(brain, 'fleet', 'roster.yaml'), join(configHome, 'fleet', 'roster.yaml')); + mkdirSync(join(configHome, 'tools', 'tmux'), { recursive: true }); + writeFileSync(join(configHome, 'tools', 'tmux', 'agent-send.sh'), '#!/bin/sh\n', { + mode: 0o755, + }); + process.env['MOSAIC_BRAIN_HOME'] = brain; + }); + + afterEach(() => { + delete process.env['MOSAIC_BRAIN_HOME']; + vi.unstubAllEnvs(); + rmSync(join(brain, '..'), { recursive: true, force: true }); + }); + + it('resolves a member through the roster symlink under the config home', () => { + const result = resolveFleetIdentity(configHome, 'orchestrator', 'w-jarvis'); + expect(result.ok).toBe(true); + expect(result.identity?.member.name).toBe('orchestrator'); + expect(result.identity?.agentSendPath).toBe(join(configHome, 'tools', 'tmux', 'agent-send.sh')); + }); + + it('resolves a member when mosaicHome is the brain (helper found under the framework home)', () => { + const result = resolveFleetIdentity(brain, 'enhancer', 'w-jarvis'); + expect(result.ok).toBe(true); + expect(result.identity?.member.name).toBe('enhancer'); + expect(result.identity?.agentSendPath).toBe(join(configHome, 'tools', 'tmux', 'agent-send.sh')); + }); + + it('no-name control stays a quiet no-op', () => { + expect(resolveFleetIdentity(configHome, undefined, 'w-jarvis')).toEqual({ ok: true }); + expect(resolveFleetIdentity(brain, undefined, 'w-jarvis')).toEqual({ ok: true }); + }); + + it('a nonce name fails naming membership, not the symlink or the helper', () => { + const result = resolveFleetIdentity(configHome, 'nonce-' + Date.now(), 'w-jarvis'); + expect(result.ok).toBe(false); + expect(result.error).not.toContain('symbolic link'); + expect(result.error).not.toContain('helper'); + expect(result.error).toContain('nonce-'); + }); + + it('a non-member failure names membership, not the symlink (protocol control)', () => { + const result = resolveFleetIdentity(configHome, 'jarvis', 'w-jarvis'); + expect(result.ok).toBe(false); + expect(result.error).not.toContain('symbolic link'); + expect(result.error).not.toContain('helper is unavailable'); + expect(result.error).toContain('orchestrator'); // known-member listing + }); + + it('names every searched framework home when the helper is missing everywhere', () => { + rmSync(join(configHome, 'tools'), { recursive: true, force: true }); + const result = resolveFleetIdentity(brain, 'orchestrator', 'w-jarvis'); + expect(result.ok).toBe(false); + expect(result.error).toContain('agent-send.sh'); + expect(result.error).toContain(join(brain, 'tools', 'tmux', 'agent-send.sh')); + expect(result.error).not.toContain('--repair-tools'); + }); + + it('readFleetCommsBlock composes the full contract on the split-home layout', () => { + const result = readFleetCommsBlock(configHome, 'orchestrator', 'w-jarvis'); + expect(result.ok).toBe(true); + expect(result.output).toContain( + 'Helper: `' + join(configHome, 'tools', 'tmux', 'agent-send.sh'), + ); + }); +}); diff --git a/packages/mosaic/src/fleet/comms-onboarding.ts b/packages/mosaic/src/fleet/comms-onboarding.ts index 80a53bd6..6e5481af 100644 --- a/packages/mosaic/src/fleet/comms-onboarding.ts +++ b/packages/mosaic/src/fleet/comms-onboarding.ts @@ -9,8 +9,9 @@ import { createHash } from 'node:crypto'; import { existsSync } from 'node:fs'; import { homedir, hostname } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { readRegularFileSecure } from './secure-file.js'; +import { resolveBrainHome } from './brain-home.js'; import { parseFleetRosterV1, resolveInstalledFleetRosterPath, @@ -260,7 +261,13 @@ context and have an authorized operator relaunch only this exact roster member w function validateAgentSendHelper(path: string, mosaicHome: string): string | undefined { try { - readRegularFileSecure(path, { root: mosaicHome, executable: true }); + readRegularFileSecure(path, { + root: mosaicHome, + executable: true, + // The helper tree may live under the framework config home while this + // caller's mosaicHome is the brain; both are framework-owned roots. + symlinkTargetRoots: [resolveBrainHome(mosaicHome)], + }); return undefined; } catch (error) { const reason = error instanceof Error ? error.message : String(error); @@ -268,8 +275,48 @@ function validateAgentSendHelper(path: string, mosaicHome: string): string | und } } +/** + * Framework install homes probed for tools/tmux/agent-send.sh (stack#1380 M2). + * The helper ships with the FRAMEWORK install, which on split-home layouts is + * the config home — not the brain (~/.mosaic carries fleet state, no tools). + */ +function frameworkHelperHomes(mosaicHome: string): string[] { + const homes = [resolve(mosaicHome)]; + const envHome = process.env['MOSAIC_HOME']; + if (envHome && envHome.trim() !== '' && resolve(envHome) !== resolve(mosaicHome)) { + homes.push(resolve(envHome)); + } + const configDefault = join(homedir(), '.config', 'mosaic'); + if (resolve(configDefault) !== resolve(mosaicHome)) homes.push(configDefault); + return homes; +} + +function resolveAgentSendHelper(mosaicHome: string): { path: string; error?: string } { + const homes = frameworkHelperHomes(mosaicHome); + for (const home of homes) { + const helper = join(home, 'tools', 'tmux', 'agent-send.sh'); + if (!existsSync(helper)) continue; + const error = validateAgentSendHelper(helper, home); + if (!error) return { path: helper }; + // Present but unsafe: surface that verdict instead of silently probing on. + return { path: helper, error }; + } + return { + path: join(resolve(mosaicHome), 'tools', 'tmux', 'agent-send.sh'), + error: + `fleet helper agent-send.sh was not found under any framework install home ` + + `(${homes.map((h) => join(h, 'tools', 'tmux', 'agent-send.sh')).join('; ')}). ` + + `Verify the framework install for this host (the helper ships with the framework ` + + `config home; the brain home carries fleet state, not tools) and have an authorized ` + + `operator restore it if missing.`, + }; +} + function helperFailureGuidance(reason: string): string { - return `${reason}. Run \`mosaic update --repair-tools\` to restore the supported current-version helper and TOOLS contract, then retry exact-member composition; no active context or session was rewritten.`; + // stack#1380 M5a: `mosaic update --repair-tools` is a forbidden remedy on the + // affected estate (and wrong for a layout/missing-helper failure). Name the + // actual recovery shape instead. + return `${reason}. Verify the framework install provides tools/tmux/agent-send.sh under the framework config home and that the roster resolves (split-home layouts symlink the roster into the brain); contact the operator if it persists; no active context or session was rewritten.`; } export function resolveFleetIdentity( @@ -278,9 +325,15 @@ export function resolveFleetIdentity( localHost: string = shortHostname(), ): FleetIdentityResult { if (!requestedName) return { ok: true }; - const agentSendPath = join(mosaicHome, 'tools', 'tmux', 'agent-send.sh'); - const helperError = validateAgentSendHelper(agentSendPath, mosaicHome); - if (helperError) return { ok: false, error: helperFailureGuidance(helperError) }; + const helper = resolveAgentSendHelper(mosaicHome); + if (helper.error) return { ok: false, error: helperFailureGuidance(helper.error) }; + const agentSendPath = helper.path; + + // Split-home layouts unify the roster by symlinking + // /fleet/roster.yaml -> /fleet/roster.yaml. The secure + // read resolves that framework-created symlink when the brain is a + // sanctioned target root (stack#1380 M1). + const rosterSymlinkRoots = [resolveBrainHome(mosaicHome)]; let rosterPath: string; try { @@ -301,7 +354,10 @@ export function resolveFleetIdentity( let roster: FleetRoster; try { roster = parseFleetRosterV1( - readRegularFileSecure(rosterPath, { root: mosaicHome }).content.toString('utf8'), + readRegularFileSecure(rosterPath, { + root: mosaicHome, + symlinkTargetRoots: rosterSymlinkRoots, + }).content.toString('utf8'), rosterPath.endsWith('.json') ? 'json' : 'yaml', ); } catch (error) { @@ -399,7 +455,9 @@ function boundedContractDigest( } function replacementGuidance(): string { - return `Run \`mosaic update --repair-tools\` to make a digest-qualified backup and restore the supported current-version TOOLS contract, then have an authorized operator explicitly relaunch the exact roster member. The active context was not rewritten.`; + // stack#1380 M5a: never recommend the forbidden --repair-tools remedy from + // error text; name the operator-verified recovery shape instead. + return `Verify the installed TOOLS contract against the framework source with an authorized operator (the installed file must byte-match the supported current version) and have the operator explicitly relaunch the exact roster member. The active context was not rewritten.`; } /** Detect preserved installed TOOLS.md drift without changing it. */ diff --git a/packages/mosaic/src/fleet/fleet-roster-v1.ts b/packages/mosaic/src/fleet/fleet-roster-v1.ts index a07eb2af..3c942ff1 100644 --- a/packages/mosaic/src/fleet/fleet-roster-v1.ts +++ b/packages/mosaic/src/fleet/fleet-roster-v1.ts @@ -7,6 +7,7 @@ import { canonicalizeRoleClass } from '../commands/fleet-personas.js'; interface RawFleetRoster { version?: unknown; transport?: unknown; + generation?: unknown; tmux?: { socket_name?: unknown; socketName?: unknown; @@ -41,6 +42,10 @@ interface RawFleetRoster { resetBetweenTasks?: unknown; kickstart_template?: unknown; kickstartTemplate?: unknown; + model?: unknown; + reasoning?: unknown; + lifecycle?: { enabled?: unknown; desired_state?: unknown }; + launch?: { yolo?: unknown }; }>; connector?: { kind?: unknown; @@ -216,7 +221,17 @@ function normalizeFleetRosterV1Unchecked(raw: RawFleetRoster): FleetRoster { 'runtimes', 'agents', 'connector', + // stack#1380 verification unblock: the fleet's own roster-v2 mutation + // tooling writes a `generation` fence on the same v1 body. Tolerated here + // as an opaque non-negative integer; comms semantics are unchanged. + 'generation', ]); + if ( + raw.generation !== undefined && + (typeof raw.generation !== 'number' || !Number.isInteger(raw.generation) || raw.generation < 0) + ) { + throw new Error('Fleet roster generation must be a non-negative integer.'); + } if (raw.tmux !== undefined) { assertObject(raw.tmux, 'Fleet roster tmux'); assertKnownKeys(raw.tmux, 'Fleet roster tmux', [ @@ -231,6 +246,8 @@ function normalizeFleetRosterV1Unchecked(raw: RawFleetRoster): FleetRoster { assertKnownKeys(raw.defaults, 'Fleet roster defaults', [ 'working_directory', 'workingDirectory', + // stack#1380 verification unblock: roster-v2 default runtime hint. + 'runtime', ]); } if (raw.runtimes !== undefined) { @@ -243,7 +260,9 @@ function normalizeFleetRosterV1Unchecked(raw: RawFleetRoster): FleetRoster { ]); } } - if (raw.version !== 1) throw new Error('Fleet roster version must be 1.'); + if (raw.version !== 1 && raw.version !== 2) { + throw new Error('Fleet roster version must be 1 or 2.'); + } if (raw.transport !== 'tmux') throw new Error('Fleet roster transport must be "tmux".'); if (!Array.isArray(raw.agents) || raw.agents.length === 0) { throw new Error('Fleet roster must define at least one agent.'); @@ -318,7 +337,52 @@ function normalizeAgent(raw: NonNullable[number]): Fle 'resetBetweenTasks', 'kickstart_template', 'kickstartTemplate', + // stack#1380 verification unblock: roster-v2 envelope fields written by + // the fleet's own mutation tooling. Validated, then opaque to comms. + 'model', + 'reasoning', + 'lifecycle', + 'launch', ]); + if (raw.model !== undefined && typeof raw.model !== 'string') { + throw new Error('Fleet roster agent model must be a string.'); + } + if (raw.reasoning !== undefined && typeof raw.reasoning !== 'string') { + throw new Error('Fleet roster agent reasoning must be a string.'); + } + const lifecycle = raw.lifecycle as { enabled?: unknown; desired_state?: unknown } | undefined; + if (lifecycle !== undefined) { + if (typeof lifecycle !== 'object' || lifecycle === null) { + throw new Error('Fleet roster agent lifecycle must be an object.'); + } + const lifecycleKeys = Object.keys(lifecycle); + if (!lifecycleKeys.every((key) => key === 'enabled' || key === 'desired_state')) { + throw new Error('Fleet roster agent lifecycle has unknown field(s).'); + } + if (lifecycle.enabled !== undefined && typeof lifecycle.enabled !== 'boolean') { + throw new Error('Fleet roster agent lifecycle.enabled must be a boolean.'); + } + if ( + lifecycle.desired_state !== undefined && + (typeof lifecycle.desired_state !== 'string' || + !['running', 'stopped'].includes(lifecycle.desired_state)) + ) { + throw new Error('Fleet roster agent lifecycle.desired_state must be running|stopped.'); + } + } + const launch = raw.launch as { yolo?: unknown } | undefined; + if (launch !== undefined) { + if (typeof launch !== 'object' || launch === null) { + throw new Error('Fleet roster agent launch must be an object.'); + } + const launchKeys = Object.keys(launch); + if (!launchKeys.every((key) => key === 'yolo')) { + throw new Error('Fleet roster agent launch has unknown field(s).'); + } + if (launch.yolo !== undefined && typeof launch.yolo !== 'boolean') { + throw new Error('Fleet roster agent launch.yolo must be a boolean.'); + } + } const name = stringValue(raw.name, '', 'Fleet roster agent name'); const runtime = stringValue( raw.runtime, diff --git a/packages/mosaic/src/fleet/secure-file.spec.ts b/packages/mosaic/src/fleet/secure-file.spec.ts index 7d97384f..8c275ff2 100644 --- a/packages/mosaic/src/fleet/secure-file.spec.ts +++ b/packages/mosaic/src/fleet/secure-file.spec.ts @@ -10,12 +10,14 @@ import { type PathLike, } from 'node:fs'; import type * as NodeFs from 'node:fs'; +import type { Stats } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; interface FilesystemRaceState { afterLstat?: (path: string) => void; afterOpen?: (path: string) => void; + afterStat?: (path: string, stats: Stats) => Stats; } const filesystemRaceState = vi.hoisted(() => ({})); @@ -29,6 +31,10 @@ vi.mock('node:fs', async (importOriginal) => { filesystemRaceState.afterLstat?.(String(path)); return result; }, + statSync: (path: PathLike) => { + const result = actual.statSync(path); + return filesystemRaceState.afterStat?.(String(path), result) ?? result; + }, openSync: (path: PathLike, flags: string | number, mode?: number) => { const fd = actual.openSync(path, flags, mode); filesystemRaceState.afterOpen?.(String(path)); @@ -46,11 +52,13 @@ describe('secure file reads', () => { root = mkdtempSync(join(tmpdir(), 'mosaic-secure-file-')); filesystemRaceState.afterLstat = undefined; filesystemRaceState.afterOpen = undefined; + filesystemRaceState.afterStat = undefined; }); afterEach(() => { filesystemRaceState.afterLstat = undefined; filesystemRaceState.afterOpen = undefined; + filesystemRaceState.afterStat = undefined; rmSync(root, { recursive: true, force: true }); }); @@ -60,27 +68,89 @@ describe('secure file reads', () => { ); }); - it('rejects a symlink in a file ancestor', () => { + // stack#1380: the guard resolves symlinks and validates the resolved target + // instead of refusing any symlink component. + it('permits a symlink ancestor whose resolved target is inside the root', () => { const external = join(root, 'external'); mkdirSync(external); writeFileSync(join(external, 'file'), 'external\n'); symlinkSync(external, join(root, 'linked')); - expect(() => readRegularFileSecure(join(root, 'linked', 'file'), { root })).toThrow( - 'path ancestor is a symbolic link', - ); + const snapshot = readRegularFileSecure(join(root, 'linked', 'file'), { root }); + expect(snapshot.content.toString('utf8')).toBe('external\n'); }); - it('rejects a symlink target', () => { - const external = join(root, 'external'); + it('permits a symlinked file whose resolved target is inside the root', () => { + const external = join(root, 'external-file'); writeFileSync(external, 'external\n'); symlinkSync(external, join(root, 'linked-file')); - expect(() => readRegularFileSecure(join(root, 'linked-file'), { root })).toThrow( - 'file is a symbolic link', + const snapshot = readRegularFileSecure(join(root, 'linked-file'), { root }); + expect(snapshot.content.toString('utf8')).toBe('external\n'); + }); + + it('permits a symlink resolving into an additional sanctioned root (split-home roster shape)', () => { + const brain = `${root}-brain`; + mkdirSync(join(brain, 'fleet'), { recursive: true }); + writeFileSync(join(brain, 'fleet', 'roster.yaml'), 'roster\n', { mode: 0o600 }); + mkdirSync(join(root, 'fleet')); + symlinkSync(join(brain, 'fleet', 'roster.yaml'), join(root, 'fleet', 'roster.yaml')); + + const snapshot = readRegularFileSecure(join(root, 'fleet', 'roster.yaml'), { + root, + symlinkTargetRoots: [brain], + }); + expect(snapshot.content.toString('utf8')).toBe('roster\n'); + }); + + it('refuses a symlink whose resolved target escapes every sanctioned root', () => { + const outside = mkdtempSync(join(tmpdir(), 'mosaic-secure-outside-')); + try { + mkdirSync(join(root, 'fleet'), { recursive: true }); + writeFileSync(join(outside, 'roster.yaml'), 'escaped\n', { mode: 0o600 }); + symlinkSync(join(outside, 'roster.yaml'), join(root, 'fleet', 'roster.yaml')); + + expect(() => readRegularFileSecure(join(root, 'fleet', 'roster.yaml'), { root })).toThrow( + /symlink target escapes managed roots/, + ); + } finally { + rmSync(outside, { recursive: true, force: true }); + } + }); + + it('refuses a group-writable symlink target', () => { + const loose = join(root, 'loose'); + mkdirSync(loose); + chmodSync(loose, 0o770); // group-writable bit survives umask via explicit chmod + writeFileSync(join(loose, 'file'), 'loose\n'); + symlinkSync(loose, join(root, 'linked-loose')); + + expect(() => readRegularFileSecure(join(root, 'linked-loose', 'file'), { root })).toThrow( + /group- or world-writable/, ); }); + it('refuses a symlink target owned by another user', () => { + const external = join(root, 'foreign'); + mkdirSync(external); + writeFileSync(join(external, 'file'), 'foreign\n'); + symlinkSync(external, join(root, 'linked-foreign')); + + filesystemRaceState.afterStat = (path, stats): Stats => { + if (resolve(path) === resolve(external)) { + return { ...stats, uid: stats.uid + 4242 } as Stats; + } + return stats; + }; + try { + expect(() => readRegularFileSecure(join(root, 'linked-foreign', 'file'), { root })).toThrow( + /not owned by the current user/, + ); + } finally { + filesystemRaceState.afterStat = undefined; + } + }); + it('keeps ancestor traversal bound when an opened directory is substituted', () => { const tools = join(root, 'tools'); const displacedTools = join(root, 'tools.displaced'); diff --git a/packages/mosaic/src/fleet/secure-file.ts b/packages/mosaic/src/fleet/secure-file.ts index 82f27201..3178ff3e 100644 --- a/packages/mosaic/src/fleet/secure-file.ts +++ b/packages/mosaic/src/fleet/secure-file.ts @@ -7,14 +7,25 @@ import { mkdirSync, openSync, readFileSync, + readlinkSync, + statSync, } from 'node:fs'; import { platform } from 'node:os'; -import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; +import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path'; export interface SecureFileReadOptions { root: string; maxBytes?: number; executable?: boolean; + /** + * Additional roots a symlink component may resolve into (stack#1380). + * Default: only the managed root itself. Every symlink hop is validated — + * containment under the root or one of these roots, current-user ownership, + * no group/world-writable mode — and refusal stays the default for anything + * else. Callers that operate the split-home layout pass the brain home so + * the framework-created roster symlink resolves. + */ + symlinkTargetRoots?: string[]; } export interface SecureFileSnapshot { @@ -88,7 +99,57 @@ function openDirectoryChain(absoluteDirectory: string): { fd: number; descriptor } } -function openFileBeneathRoot(root: string, target: string): { fd: number; descriptors: number[] } { +function containedUnder(root: string, target: string): boolean { + const rel = relative(resolve(root), resolve(target)); + return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel) && rel !== ''; +} + +const MAX_SYMLINK_HOPS = 40; + +/** + * Resolve every symlink on `lexical` component-wise, validating each hop + * (stack#1380 resolve-then-validate): the hop target must stay under one of + * the sanctioned roots, must be owned by the current user (or root), and must + * not be group- or world-writable. Returns a symlink-free absolute path. + */ +function resolveRealPath(lexical: string, sanctionedRoots: string[]): string { + const hopTargets: string[] = []; + let current: string = sep; + for (const piece of resolve(lexical).split(sep).filter(Boolean)) { + current = resolve(current, piece); + for (let hops = 0; lstatSync(current).isSymbolicLink(); ) { + if (++hops > MAX_SYMLINK_HOPS) { + throw new Error(`symlink chain exceeds ${MAX_SYMLINK_HOPS} hops: ${lexical}`); + } + const linkTarget = readlinkSync(current); + const absolute = resolve(dirname(current), linkTarget); + if (!sanctionedRoots.some((root) => containedUnder(root, absolute))) { + throw new Error( + `symlink target escapes managed roots [${sanctionedRoots.join(', ')}]: ${absolute}`, + ); + } + hopTargets.push(absolute); + current = absolute; + } + } + const uid = typeof process.getuid === 'function' ? process.getuid() : 0; + for (const hop of hopTargets) { + const stat = statSync(hop); + if (stat.uid !== uid && stat.uid !== 0) { + throw new Error(`symlink target is not owned by the current user: ${hop}`); + } + if (stat.mode & 0o022) { + throw new Error(`symlink target is group- or world-writable: ${hop}`); + } + } + return current; +} + +function openFileBeneathRoot( + root: string, + target: string, + symlinkTargetRoots: string[] = [], +): { fd: number; descriptors: number[] } { const canonicalRoot = resolve(root); const canonicalTarget = resolve(target); assertCanonicalContainment(canonicalRoot, canonicalTarget); @@ -96,39 +157,52 @@ function openFileBeneathRoot(root: string, target: string): { fd: number; descri const fileName = components.pop(); if (fileName === undefined) throw new Error('managed file path names the managed root'); - const rootChain = openDirectoryChain(canonicalRoot); + // stack#1380: resolve-then-validate. The lexical path must name the managed + // root (above); symlink components are then resolved hop-by-hop under the + // sanctioned roots (validated per hop), and the descriptor traversal walks + // the symlink-free real path — keeping the O_NOFOLLOW chain as the race + // guard for anything substituted after resolution. + let realRoot: string; + try { + realRoot = resolveRealPath(canonicalRoot, [canonicalRoot]); + } catch (error) { + throw secureFilesystemError( + 'secure descriptor traversal failed: symbolic link, unavailable, or not a directory', + error, + ); + } + const sanctioned = [realRoot, ...symlinkTargetRoots.map((extra) => resolve(extra))]; + let realTarget: string; + try { + realTarget = resolveRealPath(canonicalTarget, sanctioned); + } catch (error) { + if (error instanceof Error && !('code' in error)) throw error; + throw secureFilesystemError( + 'secure descriptor traversal failed: symbolic link, unavailable, or not a directory', + error, + ); + } + if (!sanctioned.some((sr) => containedUnder(sr, realTarget) || resolve(sr) === realTarget)) { + throw new Error( + `resolved path escapes managed roots [${sanctioned.join(', ')}]: ${realTarget}`, + ); + } + + const chain = openDirectoryChain(dirname(realTarget)); try { - let parentFd = rootChain.fd; - for (const component of components) { - try { - parentFd = openSync( - procDescriptorPath(parentFd, component), - constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, - ); - } catch (error) { - throw secureFilesystemError( - 'path ancestor is a symbolic link, unavailable, or not a directory', - error, - ); - } - rootChain.descriptors.push(parentFd); - if (!fstatSync(parentFd).isDirectory()) { - throw new Error('path ancestor is a symbolic link or not a directory'); - } - } let fd: number; try { fd = openSync( - procDescriptorPath(parentFd, fileName), + procDescriptorPath(chain.fd, basename(realTarget)), constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW, ); } catch (error) { throw secureFilesystemError('file is a symbolic link or unavailable', error); } - rootChain.descriptors.push(fd); - return { fd, descriptors: rootChain.descriptors }; + chain.descriptors.push(fd); + return { fd, descriptors: chain.descriptors }; } catch (error) { - closeDescriptors(rootChain.descriptors); + closeDescriptors(chain.descriptors); if (error instanceof Error) throw error; throw new Error('secure managed file open failed'); } @@ -203,7 +277,7 @@ export function readRegularFileSecure( path: string, options: SecureFileReadOptions, ): SecureFileSnapshot { - const openedFile = openFileBeneathRoot(options.root, path); + const openedFile = openFileBeneathRoot(options.root, path, options.symlinkTargetRoots ?? []); try { const opened = fstatSync(openedFile.fd); if (!opened.isFile()) throw new Error('managed file is not a regular file'); diff --git a/packages/mosaic/src/runtime/update-checker.reseed.spec.ts b/packages/mosaic/src/runtime/update-checker.reseed.spec.ts index 83fe1bb9..1021a669 100644 --- a/packages/mosaic/src/runtime/update-checker.reseed.spec.ts +++ b/packages/mosaic/src/runtime/update-checker.reseed.spec.ts @@ -168,7 +168,9 @@ describe('repairFleetCommsTools', () => { const result = repairFleetCommsTools(framework, home); expect(result).toMatchObject({ ok: false, changed: false }); - expect(result.reason).toContain('symbolic link'); + // stack#1380: resolve-then-validate — an escaping symlink is still + // refused, with the new escape diagnostic. + expect(result.reason).toContain('symlink target escapes managed roots'); expect(readFileSync(target, 'utf8')).toBe('do not touch\n'); expect(lstatSync(join(home, 'tools', 'tmux', 'agent-send.sh')).isSymbolicLink()).toBe(true); }); @@ -222,7 +224,10 @@ describe('repairFleetCommsTools', () => { const result = repairFleetCommsTools(framework, targetHome); expect(result, testCase.name).toMatchObject({ ok: false, changed: false }); - expect(result.reason, testCase.name).toContain('symbolic link'); + // stack#1380: escaping ancestor symlinks stay refused. The home case is + // caught by the managed-root guard ('is a symbolic link'); deeper + // components by resolve-then-validate ('symlink target escapes'). + expect(result.reason, testCase.name).toMatch(/symbolic link|symlink target escapes/); expect(readdirSync(external), testCase.name).toEqual([]); } });