fix(fleet): resolve-then-validate symlink guard + framework helper resolution (#1380) (#1383)
ci/woodpecker/push/publish Pipeline failed
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:
@@ -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<!-- fleet-comms-contract: 1 -->\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<!-- fleet-comms-contract: 1 -->\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
|
||||
// <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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
// <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;
|
||||
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. */
|
||||
|
||||
@@ -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<RawFleetRoster['agents']>[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,
|
||||
|
||||
@@ -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<FilesystemRaceState>(() => ({}));
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user