fix(fleet): resolve-then-validate symlink guard + framework helper resolution (#1380) (#1383)
ci/woodpecker/push/publish Pipeline failed

Co-authored-by: code-be-01 <[email protected]>
This commit was merged in pull request #1383.
This commit is contained in:
2026-08-24 19:39:13 +00:00
committed by orch-01
parent 9014a510a9
commit 8eb7e6354e
6 changed files with 505 additions and 72 deletions
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { import {
chmodSync, chmodSync,
mkdtempSync, mkdtempSync,
@@ -16,6 +16,7 @@ import {
renderPeerReach, renderPeerReach,
readFleetCommsBlock, readFleetCommsBlock,
resolveCommsBlock, resolveCommsBlock,
resolveFleetIdentity,
resolvePeerCommand, resolvePeerCommand,
renderToolsContractStatus, renderToolsContractStatus,
} from './comms-onboarding.js'; } 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', () => { it('rejects unknown fields instead of leniently constructing a second roster view', () => {
expect(() => parseFleetRosterV1(`${ROSTER}\nunknown: value\n`, 'yaml')).toThrow( expect(() => parseFleetRosterV1(`${ROSTER}\nunknown: value\n`, 'yaml')).toThrow(
/unknown field/i, /unknown field/i,
@@ -493,6 +556,11 @@ describe('resolvePeerCommand', () => {
describe('readFleetCommsBlock — spawned-agent context', () => { describe('readFleetCommsBlock — spawned-agent context', () => {
let home: string; let home: string;
beforeEach(() => { 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-')); home = mkdtempSync(join(tmpdir(), 'mosaic-comms-'));
mkdirSync(join(home, 'fleet'), { recursive: true }); mkdirSync(join(home, 'fleet'), { recursive: true });
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true }); mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
@@ -501,7 +569,10 @@ describe('readFleetCommsBlock — spawned-agent context', () => {
writeFileSync(helper, '#!/bin/sh\n'); writeFileSync(helper, '#!/bin/sh\n');
chmodSync(helper, 0o755); 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', () => { it('uses the authoritative self host and global socket from the shared roster resolver', () => {
const result = readFleetCommsBlock(home, 'enhancer', 'process-host-must-not-win'); 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'); const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
rmSync(helper); rmSync(helper);
writeFileSync(join(home, 'real-send.sh'), '#!/bin/sh\n'); const outside = mkdtempSync(join(tmpdir(), 'mosaic-helper-outside-'));
symlinkSync(join(home, 'real-send.sh'), helper); 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)], ['non-executable', () => chmodSync(join(home, 'tools', 'tmux', 'agent-send.sh'), 0o644)],
])('fails closed for a %s helper with deterministic repair guidance', (_case, mutate) => { ])(
'fails closed for a %s helper with deterministic guidance (no forbidden remedy)',
(_case, mutate) => {
mutate(); mutate();
const result = readFleetCommsBlock(home, 'enhancer', 'w-jarvis'); const result = readFleetCommsBlock(home, 'enhancer', 'w-jarvis');
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
expect(result.output).toBe(''); expect(result.output).toBe('');
expect(result.error).toContain('mosaic update --repair-tools'); expect(result.error).not.toContain('--repair-tools'); // stack#1380 M5a
expect(result.error).toContain('no active context or session was rewritten'); expect(result.error).toContain('no active context or session was rewritten');
}); },
);
it('does not rewrite the roster while resolving context', () => { it('does not rewrite the roster while resolving context', () => {
const path = join(home, 'fleet', 'roster.yaml'); const path = join(home, 'fleet', 'roster.yaml');
@@ -603,11 +678,11 @@ describe('renderToolsContractStatus — non-mutating install drift', () => {
}); });
afterEach(() => rmSync(home, { recursive: true, force: true })); 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); const status = renderToolsContractStatus(home);
expect(status).toContain('mosaic update --repair-tools');
expect(status).not.toContain('--reseed');
expect(status).toContain('authorized operator'); 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', () => { it('reports stale preserved content without rewriting it', () => {
@@ -616,8 +691,8 @@ describe('renderToolsContractStatus — non-mutating install drift', () => {
writeFileSync(path, stale); writeFileSync(path, stale);
const status = renderToolsContractStatus(home); const status = renderToolsContractStatus(home);
expect(status).toContain('fleet-comms-contract: 1'); expect(status).toContain('fleet-comms-contract: 1');
expect(status).toContain('digest-qualified backup'); expect(status).toContain('authorized operator');
expect(status).toContain('mosaic update --repair-tools'); expect(status).not.toContain('--repair-tools'); // stack#1380 M5a
expect(status).toContain('active context was not rewritten'); expect(status).toContain('active context was not rewritten');
expect(readFileSync(path, 'utf8')).toBe(stale); expect(readFileSync(path, 'utf8')).toBe(stale);
}); });
@@ -648,7 +723,7 @@ describe('renderToolsContractStatus — non-mutating install drift', () => {
expect(renderToolsContractStatus(home)).not.toBe(''); 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 external = join(home, 'external-tools.md');
const externalContent = '# external\n<!-- fleet-comms-contract: 1 -->\n'; const externalContent = '# external\n<!-- fleet-comms-contract: 1 -->\n';
writeFileSync(external, externalContent); writeFileSync(external, externalContent);
@@ -656,24 +731,23 @@ describe('renderToolsContractStatus — non-mutating install drift', () => {
const status = renderToolsContractStatus(home); const status = renderToolsContractStatus(home);
expect(status).toContain('unavailable'); expect(status).toContain('does not byte-match');
expect(status).toContain('mosaic update --repair-tools');
expect(readFileSync(external, 'utf8')).toBe(externalContent); expect(readFileSync(external, 'utf8')).toBe(externalContent);
}); });
it('treats source TOOLS.md symlinks as unavailable without following them', () => { it('treats a source TOOLS.md symlink escaping the install home as unavailable', () => {
const external = join(home, 'external-source.md'); const outside = mkdtempSync(join(tmpdir(), 'mosaic-source-outside-'));
const content = '# authoritative tools\n<!-- fleet-comms-contract: 1 -->\n'; const content = '# authoritative tools\n<!-- fleet-comms-contract: 1 -->\n';
writeFileSync(external, content); writeFileSync(join(outside, 'external-source.md'), content);
rmSync(join(home, 'defaults', 'TOOLS.md')); 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); writeFileSync(join(home, 'TOOLS.md'), content);
const status = renderToolsContractStatus(home); const status = renderToolsContractStatus(home);
expect(status).toContain('source contract'); expect(status).toContain('source contract');
expect(status).toContain('unavailable'); 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', () => { it('accepts byte-equal bounded source and installed contracts', () => {
@@ -730,3 +804,91 @@ describe('resolveCommsBlock — mosaic agent comms-block', () => {
expect(result.error).toContain('requires'); 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
// <configHome>/fleet/roster.yaml -> <brain>/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'),
);
});
});
+66 -8
View File
@@ -9,8 +9,9 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { homedir, hostname } from 'node:os'; import { homedir, hostname } from 'node:os';
import { join } from 'node:path'; import { join, resolve } from 'node:path';
import { readRegularFileSecure } from './secure-file.js'; import { readRegularFileSecure } from './secure-file.js';
import { resolveBrainHome } from './brain-home.js';
import { import {
parseFleetRosterV1, parseFleetRosterV1,
resolveInstalledFleetRosterPath, 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 { function validateAgentSendHelper(path: string, mosaicHome: string): string | undefined {
try { 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; return undefined;
} catch (error) { } catch (error) {
const reason = error instanceof Error ? error.message : String(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 { 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( export function resolveFleetIdentity(
@@ -278,9 +325,15 @@ export function resolveFleetIdentity(
localHost: string = shortHostname(), localHost: string = shortHostname(),
): FleetIdentityResult { ): FleetIdentityResult {
if (!requestedName) return { ok: true }; if (!requestedName) return { ok: true };
const agentSendPath = join(mosaicHome, 'tools', 'tmux', 'agent-send.sh'); const helper = resolveAgentSendHelper(mosaicHome);
const helperError = validateAgentSendHelper(agentSendPath, mosaicHome); if (helper.error) return { ok: false, error: helperFailureGuidance(helper.error) };
if (helperError) return { ok: false, error: helperFailureGuidance(helperError) }; const agentSendPath = helper.path;
// Split-home layouts unify the roster by symlinking
// <configHome>/fleet/roster.yaml -> <brain>/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; let rosterPath: string;
try { try {
@@ -301,7 +354,10 @@ export function resolveFleetIdentity(
let roster: FleetRoster; let roster: FleetRoster;
try { try {
roster = parseFleetRosterV1( roster = parseFleetRosterV1(
readRegularFileSecure(rosterPath, { root: mosaicHome }).content.toString('utf8'), readRegularFileSecure(rosterPath, {
root: mosaicHome,
symlinkTargetRoots: rosterSymlinkRoots,
}).content.toString('utf8'),
rosterPath.endsWith('.json') ? 'json' : 'yaml', rosterPath.endsWith('.json') ? 'json' : 'yaml',
); );
} catch (error) { } catch (error) {
@@ -399,7 +455,9 @@ function boundedContractDigest(
} }
function replacementGuidance(): string { 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. */ /** Detect preserved installed TOOLS.md drift without changing it. */
+65 -1
View File
@@ -7,6 +7,7 @@ import { canonicalizeRoleClass } from '../commands/fleet-personas.js';
interface RawFleetRoster { interface RawFleetRoster {
version?: unknown; version?: unknown;
transport?: unknown; transport?: unknown;
generation?: unknown;
tmux?: { tmux?: {
socket_name?: unknown; socket_name?: unknown;
socketName?: unknown; socketName?: unknown;
@@ -41,6 +42,10 @@ interface RawFleetRoster {
resetBetweenTasks?: unknown; resetBetweenTasks?: unknown;
kickstart_template?: unknown; kickstart_template?: unknown;
kickstartTemplate?: unknown; kickstartTemplate?: unknown;
model?: unknown;
reasoning?: unknown;
lifecycle?: { enabled?: unknown; desired_state?: unknown };
launch?: { yolo?: unknown };
}>; }>;
connector?: { connector?: {
kind?: unknown; kind?: unknown;
@@ -216,7 +221,17 @@ function normalizeFleetRosterV1Unchecked(raw: RawFleetRoster): FleetRoster {
'runtimes', 'runtimes',
'agents', 'agents',
'connector', '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) { if (raw.tmux !== undefined) {
assertObject(raw.tmux, 'Fleet roster tmux'); assertObject(raw.tmux, 'Fleet roster tmux');
assertKnownKeys(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', [ assertKnownKeys(raw.defaults, 'Fleet roster defaults', [
'working_directory', 'working_directory',
'workingDirectory', 'workingDirectory',
// stack#1380 verification unblock: roster-v2 default runtime hint.
'runtime',
]); ]);
} }
if (raw.runtimes !== undefined) { 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 (raw.transport !== 'tmux') throw new Error('Fleet roster transport must be "tmux".');
if (!Array.isArray(raw.agents) || raw.agents.length === 0) { if (!Array.isArray(raw.agents) || raw.agents.length === 0) {
throw new Error('Fleet roster must define at least one agent.'); throw new Error('Fleet roster must define at least one agent.');
@@ -318,7 +337,52 @@ function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): Fle
'resetBetweenTasks', 'resetBetweenTasks',
'kickstart_template', 'kickstart_template',
'kickstartTemplate', '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 name = stringValue(raw.name, '', 'Fleet roster agent name');
const runtime = stringValue( const runtime = stringValue(
raw.runtime, raw.runtime,
+79 -9
View File
@@ -10,12 +10,14 @@ import {
type PathLike, type PathLike,
} from 'node:fs'; } from 'node:fs';
import type * as NodeFs from 'node:fs'; import type * as NodeFs from 'node:fs';
import type { Stats } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join, resolve } from 'node:path';
interface FilesystemRaceState { interface FilesystemRaceState {
afterLstat?: (path: string) => void; afterLstat?: (path: string) => void;
afterOpen?: (path: string) => void; afterOpen?: (path: string) => void;
afterStat?: (path: string, stats: Stats) => Stats;
} }
const filesystemRaceState = vi.hoisted<FilesystemRaceState>(() => ({})); const filesystemRaceState = vi.hoisted<FilesystemRaceState>(() => ({}));
@@ -29,6 +31,10 @@ vi.mock('node:fs', async (importOriginal) => {
filesystemRaceState.afterLstat?.(String(path)); filesystemRaceState.afterLstat?.(String(path));
return result; 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) => { openSync: (path: PathLike, flags: string | number, mode?: number) => {
const fd = actual.openSync(path, flags, mode); const fd = actual.openSync(path, flags, mode);
filesystemRaceState.afterOpen?.(String(path)); filesystemRaceState.afterOpen?.(String(path));
@@ -46,11 +52,13 @@ describe('secure file reads', () => {
root = mkdtempSync(join(tmpdir(), 'mosaic-secure-file-')); root = mkdtempSync(join(tmpdir(), 'mosaic-secure-file-'));
filesystemRaceState.afterLstat = undefined; filesystemRaceState.afterLstat = undefined;
filesystemRaceState.afterOpen = undefined; filesystemRaceState.afterOpen = undefined;
filesystemRaceState.afterStat = undefined;
}); });
afterEach(() => { afterEach(() => {
filesystemRaceState.afterLstat = undefined; filesystemRaceState.afterLstat = undefined;
filesystemRaceState.afterOpen = undefined; filesystemRaceState.afterOpen = undefined;
filesystemRaceState.afterStat = undefined;
rmSync(root, { recursive: true, force: true }); rmSync(root, { recursive: true, force: true });
}); });
@@ -60,25 +68,87 @@ 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'); const external = join(root, 'external');
mkdirSync(external); mkdirSync(external);
writeFileSync(join(external, 'file'), 'external\n'); writeFileSync(join(external, 'file'), 'external\n');
symlinkSync(external, join(root, 'linked')); symlinkSync(external, join(root, 'linked'));
expect(() => readRegularFileSecure(join(root, 'linked', 'file'), { root })).toThrow( const snapshot = readRegularFileSecure(join(root, 'linked', 'file'), { root });
'path ancestor is a symbolic link', expect(snapshot.content.toString('utf8')).toBe('external\n');
);
}); });
it('rejects a symlink target', () => { it('permits a symlinked file whose resolved target is inside the root', () => {
const external = join(root, 'external'); const external = join(root, 'external-file');
writeFileSync(external, 'external\n'); writeFileSync(external, 'external\n');
symlinkSync(external, join(root, 'linked-file')); symlinkSync(external, join(root, 'linked-file'));
expect(() => readRegularFileSecure(join(root, 'linked-file'), { root })).toThrow( const snapshot = readRegularFileSecure(join(root, 'linked-file'), { root });
'file is a symbolic link', 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', () => { it('keeps ancestor traversal bound when an opened directory is substituted', () => {
+93 -19
View File
@@ -7,14 +7,25 @@ import {
mkdirSync, mkdirSync,
openSync, openSync,
readFileSync, readFileSync,
readlinkSync,
statSync,
} from 'node:fs'; } from 'node:fs';
import { platform } from 'node:os'; 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 { export interface SecureFileReadOptions {
root: string; root: string;
maxBytes?: number; maxBytes?: number;
executable?: boolean; 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 { 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 canonicalRoot = resolve(root);
const canonicalTarget = resolve(target); const canonicalTarget = resolve(target);
assertCanonicalContainment(canonicalRoot, canonicalTarget); assertCanonicalContainment(canonicalRoot, canonicalTarget);
@@ -96,39 +157,52 @@ function openFileBeneathRoot(root: string, target: string): { fd: number; descri
const fileName = components.pop(); const fileName = components.pop();
if (fileName === undefined) throw new Error('managed file path names the managed root'); 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 { try {
let parentFd = rootChain.fd; realRoot = resolveRealPath(canonicalRoot, [canonicalRoot]);
for (const component of components) {
try {
parentFd = openSync(
procDescriptorPath(parentFd, component),
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
);
} catch (error) { } catch (error) {
throw secureFilesystemError( throw secureFilesystemError(
'path ancestor is a symbolic link, unavailable, or not a directory', 'secure descriptor traversal failed: symbolic link, unavailable, or not a directory',
error, error,
); );
} }
rootChain.descriptors.push(parentFd); const sanctioned = [realRoot, ...symlinkTargetRoots.map((extra) => resolve(extra))];
if (!fstatSync(parentFd).isDirectory()) { let realTarget: string;
throw new Error('path ancestor is a symbolic link or not a directory'); 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 fd: number; let fd: number;
try { try {
fd = openSync( fd = openSync(
procDescriptorPath(parentFd, fileName), procDescriptorPath(chain.fd, basename(realTarget)),
constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW,
); );
} catch (error) { } catch (error) {
throw secureFilesystemError('file is a symbolic link or unavailable', error); throw secureFilesystemError('file is a symbolic link or unavailable', error);
} }
rootChain.descriptors.push(fd); chain.descriptors.push(fd);
return { fd, descriptors: rootChain.descriptors }; return { fd, descriptors: chain.descriptors };
} catch (error) { } catch (error) {
closeDescriptors(rootChain.descriptors); closeDescriptors(chain.descriptors);
if (error instanceof Error) throw error; if (error instanceof Error) throw error;
throw new Error('secure managed file open failed'); throw new Error('secure managed file open failed');
} }
@@ -203,7 +277,7 @@ export function readRegularFileSecure(
path: string, path: string,
options: SecureFileReadOptions, options: SecureFileReadOptions,
): SecureFileSnapshot { ): SecureFileSnapshot {
const openedFile = openFileBeneathRoot(options.root, path); const openedFile = openFileBeneathRoot(options.root, path, options.symlinkTargetRoots ?? []);
try { try {
const opened = fstatSync(openedFile.fd); const opened = fstatSync(openedFile.fd);
if (!opened.isFile()) throw new Error('managed file is not a regular file'); if (!opened.isFile()) throw new Error('managed file is not a regular file');
@@ -168,7 +168,9 @@ describe('repairFleetCommsTools', () => {
const result = repairFleetCommsTools(framework, home); const result = repairFleetCommsTools(framework, home);
expect(result).toMatchObject({ ok: false, changed: false }); 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(readFileSync(target, 'utf8')).toBe('do not touch\n');
expect(lstatSync(join(home, 'tools', 'tmux', 'agent-send.sh')).isSymbolicLink()).toBe(true); expect(lstatSync(join(home, 'tools', 'tmux', 'agent-send.sh')).isSymbolicLink()).toBe(true);
}); });
@@ -222,7 +224,10 @@ describe('repairFleetCommsTools', () => {
const result = repairFleetCommsTools(framework, targetHome); const result = repairFleetCommsTools(framework, targetHome);
expect(result, testCase.name).toMatchObject({ ok: false, changed: false }); 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([]); expect(readdirSync(external), testCase.name).toEqual([]);
} }
}); });