From fe2cf19461f70cbd8e435757346464e5ece4770f Mon Sep 17 00:00:00 2001 From: terra Date: Thu, 13 Aug 2026 13:55:38 -0500 Subject: [PATCH] fix(fleet): preserve managed link provenance AMD1213-B3: record Mosaic-owned links and refuse foreign or retargeted symlink mutations. Out-of-scope review follow-up: settings output/snapshot apply-time TOCTOU remains reported, not patched. --- .../src/commands/fleet-launch-command.spec.ts | 135 +++++++++- .../src/commands/fleet-launch-command.ts | 250 ++++++++++++++++-- 2 files changed, 350 insertions(+), 35 deletions(-) diff --git a/packages/mosaic/src/commands/fleet-launch-command.spec.ts b/packages/mosaic/src/commands/fleet-launch-command.spec.ts index 7cdbef4f..8a403faa 100644 --- a/packages/mosaic/src/commands/fleet-launch-command.spec.ts +++ b/packages/mosaic/src/commands/fleet-launch-command.spec.ts @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, readFileSync, + readlinkSync, rmSync, symlinkSync, writeFileSync, @@ -382,32 +383,138 @@ describe('A3 credential validation', () => { }); describe('managed plugin and skill links', () => { - it('installs listed entries and prunes only stale managed symlinks', () => { - const fx = fixture({ - schema: 1, - harness: 'claude', - plugins: ['keep'], - skills: ['mosaic-tools'], - }); - mkdirSync(join(fx.userHome, 'plugins', 'keep'), { recursive: true }); - mkdirSync(join(fx.userHome, 'plugins', 'old'), { recursive: true }); - mkdirSync(join(fx.userHome, 'skills', 'mosaic-tools'), { recursive: true }); + it('refuses an unrecorded foreign symlink without mutating it', () => { + const fx = fixture({ schema: 1, harness: 'claude', plugins: [] }); const pluginHome = join(fx.agentDir, '.claude', 'plugins'); + const foreign = join(fx.root, 'foreign-plugin'); mkdirSync(pluginHome, { recursive: true }); - symlinkSync(join(fx.userHome, 'plugins', 'old'), join(pluginHome, 'old'), 'dir'); + mkdirSync(foreign, { recursive: true }); + symlinkSync(foreign, join(pluginHome, 'foreign'), 'dir'); const plan = resolveFleetLaunchComposition('fred', { systemHome: fx.systemHome, userHome: fx.userHome, }); + expect(() => applyFleetLaunchComposition(plan)).toThrowError( + /unrecorded or retargeted symlink/, + ); + expect(readlinkSync(join(pluginHome, 'foreign'))).toBe(foreign); + }); + + it('prunes a recorded matching stale symlink', () => { + const fx = fixture({ schema: 1, harness: 'claude', plugins: ['old'] }); + mkdirSync(join(fx.userHome, 'plugins', 'old'), { recursive: true }); + const initial = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + applyFleetLaunchComposition(initial); + + writeFileSync( + join(fx.agentDir, 'profile.json'), + '{"schema":1,"harness":"claude","plugins":[]}\n', + ); + const plan = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + const pluginHome = join(fx.agentDir, '.claude', 'plugins'); expect(plan.prune).toEqual([join(pluginHome, 'old')]); applyFleetLaunchComposition(plan); expect(() => lstatSync(join(pluginHome, 'old'))).toThrow(); - expect(lstatSync(join(pluginHome, 'keep')).isSymbolicLink()).toBe(true); - expect(lstatSync(join(fx.agentDir, '.claude', 'skills', 'mosaic-tools')).isSymbolicLink()).toBe( - true, + }); + + it('refuses a recorded link retargeted after composition and leaves it intact', () => { + const fx = fixture({ schema: 1, harness: 'claude', plugins: ['old'] }); + const managedTarget = join(fx.userHome, 'plugins', 'old'); + const foreignTarget = join(fx.root, 'foreign-plugin'); + mkdirSync(managedTarget, { recursive: true }); + mkdirSync(foreignTarget, { recursive: true }); + const initial = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + applyFleetLaunchComposition(initial); + + writeFileSync( + join(fx.agentDir, 'profile.json'), + '{"schema":1,"harness":"claude","plugins":[]}\n', ); + const plan = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + const link = join(fx.agentDir, '.claude', 'plugins', 'old'); + rmSync(link); + symlinkSync(foreignTarget, link, 'dir'); + + expect(() => applyFleetLaunchComposition(plan)).toThrowError( + /unrecorded or retargeted symlink/, + ); + expect(readlinkSync(link)).toBe(foreignTarget); + }); + + it('refuses a symlinked manifest temporary path without modifying its target', () => { + const fx = fixture({ schema: 1, harness: 'claude', plugins: ['keep'] }); + const target = join(fx.userHome, 'plugins', 'keep'); + const sentinel = join(fx.root, 'sentinel.json'); + mkdirSync(target, { recursive: true }); + mkdirSync(join(fx.agentDir, '.claude'), { recursive: true }); + writeFileSync(sentinel, 'unchanged\n', { mode: 0o600 }); + symlinkSync(sentinel, join(fx.agentDir, '.claude', '.mosaic-managed-links.json.tmp'), 'file'); + const plan = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + + expect(() => applyFleetLaunchComposition(plan)).toThrowError(/cannot be created exclusively/); + expect(readFileSync(sentinel, 'utf8')).toBe('unchanged\n'); + }); + + it('adopts a pre-manifest credential link inside auth root before retargeting it', () => { + const fx = fixture({ schema: 1, harness: 'claude', bundle: 'next' }); + const previousBundle = join(fx.userHome, 'auth', 'claude', 'previous'); + const nextBundle = join(fx.userHome, 'auth', 'claude', 'next'); + const seatHome = join(fx.agentDir, '.claude'); + mkdirSync(previousBundle, { recursive: true }); + mkdirSync(nextBundle, { recursive: true }); + writeFileSync(join(previousBundle, '.credentials.json'), '{}\n', { mode: 0o600 }); + writeFileSync(join(nextBundle, '.credentials.json'), '{}\n', { mode: 0o600 }); + mkdirSync(seatHome, { recursive: true }); + symlinkSync( + join(previousBundle, '.credentials.json'), + join(seatHome, '.credentials.json'), + 'file', + ); + + const plan = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + applyFleetLaunchComposition(plan); + + expect(readlinkSync(join(seatHome, '.credentials.json'))).toBe( + join(nextBundle, '.credentials.json'), + ); + }); + + it('refuses an unrecorded mismatched credential symlink', () => { + const fx = fixture(); + const seatHome = join(fx.agentDir, '.claude'); + const foreignCredential = join(fx.root, 'foreign-credential.json'); + mkdirSync(seatHome, { recursive: true }); + writeFileSync(foreignCredential, '{}\n', { mode: 0o600 }); + symlinkSync(foreignCredential, join(seatHome, '.credentials.json'), 'file'); + const plan = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + + expect(() => applyFleetLaunchComposition(plan)).toThrowError( + /unrecorded or retargeted symlink/, + ); + expect(readFileSync(join(seatHome, '.credentials.json'), 'utf8')).toBe('{}\n'); }); it('tolerates harness metadata files in the install root and still refuses real directories', () => { diff --git a/packages/mosaic/src/commands/fleet-launch-command.ts b/packages/mosaic/src/commands/fleet-launch-command.ts index df3852e3..422767c5 100644 --- a/packages/mosaic/src/commands/fleet-launch-command.ts +++ b/packages/mosaic/src/commands/fleet-launch-command.ts @@ -1,13 +1,17 @@ import { + closeSync, lstatSync, mkdirSync, + openSync, readFileSync, readlinkSync, readdirSync, realpathSync, + renameSync, rmSync, symlinkSync, writeFileSync, + writeSync, type Stats, } from 'node:fs'; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; @@ -91,6 +95,16 @@ interface PlannedLink { readonly target: string; } +interface ManagedLinkManifest { + readonly links: Record; +} + +interface ManagedLinkState { + readonly path: string; + readonly links: Map; + readonly existed: boolean; +} + export interface FleetLaunchComposition { readonly name: string; readonly profilePath: string; @@ -113,6 +127,7 @@ export interface FleetLaunchComposition { readonly link: string; readonly target: string; }; + readonly managedLinks: ManagedLinkState; readonly installs: readonly PlannedLink[]; readonly prune: readonly string[]; readonly env: Readonly>; @@ -374,6 +389,7 @@ function resolveCredential( profile: FleetAgentLaunchProfile, userHome: string, seatHome: string, + managedLinks: ManagedLinkState, ): Pick { assertRealDirectory(userHome, 'user Mosaic root'); const realUserHome = realpathSync(userHome); @@ -425,6 +441,16 @@ function resolveCredential( const credentialLink = join(seatHome, CREDENTIAL_FILES[profile.harness]); const seatInfo = lstatIfPresent(credentialLink); + if (seatInfo?.isSymbolicLink() && !managedLinks.existed) { + const existingTarget = currentLinkTarget(credentialLink); + try { + assertContained(resolvedAuthRoot, realpathSync(existingTarget), 'credential migration'); + // A pre-manifest seat may adopt only a credential link inside the central auth root. + managedLinks.links.set(credentialLink, existingTarget); + } catch { + // Foreign links remain unrecorded and are refused by apply before any writes. + } + } if (seatInfo && !seatInfo.isSymbolicLink()) { throw new FleetLaunchError( 'FIRST_AUTH_REFUSAL', @@ -449,11 +475,16 @@ function resolveCredential( }; } +function currentLinkTarget(link: string): string { + return resolve(dirname(link), readlinkSync(link)); +} + function resolveManagedLinks( kind: PlannedLink['kind'], names: readonly string[], userHome: string, seatHome: string, + managedLinks: ManagedLinkState, ): { installs: PlannedLink[]; prune: string[] } { const plural = kind === 'plugin' ? 'plugins' : 'skills'; const storeRoot = join(userHome, plural); @@ -494,7 +525,6 @@ function resolveManagedLinks( if (installRootInfo?.isDirectory()) { for (const entry of readdirSync(installRoot, { withFileTypes: true })) { const path = join(installRoot, entry.name); - if (desired.has(entry.name)) continue; if (!entry.isSymbolicLink()) { // The harness writes its own metadata files (e.g. installed_plugins.json) // beside the managed links; only a real directory is an unmanaged entry @@ -507,12 +537,107 @@ function resolveManagedLinks( } continue; } - prune.push(path); + const currentTarget = currentLinkTarget(path); + const recordedTarget = managedLinks.links.get(path); + if (recordedTarget === undefined) { + if ( + !managedLinks.existed && + (() => { + try { + assertContained( + realpathSync(storeRoot), + realpathSync(currentTarget), + `${kind} migration`, + ); + return true; + } catch { + return false; + } + })() + ) { + // A pre-manifest seat may adopt only links to the central Mosaic store; foreign links refuse. + managedLinks.links.set(path, currentTarget); + } + } else if (recordedTarget !== currentTarget) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `managed ${kind} symlink target changed since composition: ${path}`, + ); + } + if (!desired.has(entry.name)) prune.push(path); } } return { installs, prune }; } +function readManagedLinkState(seatHome: string): ManagedLinkState { + const path = join(seatHome, '.mosaic-managed-links.json'); + const info = lstatIfPresent(path); + if (!info) return { path, links: new Map(), existed: false }; + if (!info.isFile() || info.isSymbolicLink()) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `managed link manifest must be a real, non-symlink JSON file: ${path}`, + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown; + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `managed link manifest is invalid JSON: ${detail}`, + ); + } + if (!isPlainObject(parsed) || !isPlainObject(parsed['links'])) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `managed link manifest has invalid shape: ${path}`, + ); + } + const links = new Map(); + for (const [link, target] of Object.entries(parsed['links'])) { + if (typeof target !== 'string' || !isAbsolute(link) || !isAbsolute(target)) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `managed link manifest has invalid entry: ${path}`, + ); + } + links.set(link, target); + } + return { path, links, existed: true }; +} + +interface PreparedManagedLinkManifest { + readonly path: string; + readonly descriptor: number; +} + +function prepareManagedLinkManifest(managedLinks: ManagedLinkState): PreparedManagedLinkManifest { + const path = `${managedLinks.path}.tmp`; + try { + return { path, descriptor: openSync(path, 'wx', 0o600) }; + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `managed link manifest temporary file cannot be created exclusively: ${detail}`, + ); + } +} + +function writeManagedLinkState( + managedLinks: ManagedLinkState, + prepared: PreparedManagedLinkManifest, +): void { + const entries = [...managedLinks.links.entries()].sort(([left], [right]) => + left.localeCompare(right), + ); + const manifest: ManagedLinkManifest = { links: Object.fromEntries(entries) }; + writeSync(prepared.descriptor, `${JSON.stringify(manifest, null, 2)}\n`); +} + function buildArgv( profile: FleetAgentLaunchProfile, seatHome: string, @@ -594,9 +719,22 @@ export function resolveFleetLaunchComposition( : readSettingsLayer('agent', overlayPath, false), ]; const merged = deepMergeSettings(...layers.map((layer) => layer.value)); - const credential = resolveCredential(profile, roots.userHome, seatHome); - const plugins = resolveManagedLinks('plugin', profile.plugins, roots.userHome, seatHome); - const skills = resolveManagedLinks('skill', profile.skills, roots.userHome, seatHome); + const managedLinks = readManagedLinkState(seatHome); + const credential = resolveCredential(profile, roots.userHome, seatHome, managedLinks); + const plugins = resolveManagedLinks( + 'plugin', + profile.plugins, + roots.userHome, + seatHome, + managedLinks, + ); + const skills = resolveManagedLinks( + 'skill', + profile.skills, + roots.userHome, + seatHome, + managedLinks, + ); const homeEnvName: Record = { claude: 'CLAUDE_CONFIG_DIR', pi: 'PI_CODING_AGENT_DIR', @@ -621,6 +759,7 @@ export function resolveFleetLaunchComposition( snapshot: settingsSnapshot, }, ...credential, + managedLinks, installs: [...plugins.installs, ...skills.installs], prune: [...plugins.prune, ...skills.prune], env, @@ -628,11 +767,20 @@ export function resolveFleetLaunchComposition( }; } -function ensureSymlink(link: string, target: string): void { +function ensureSymlink(link: string, target: string, managedLinks: ManagedLinkState): void { const info = lstatIfPresent(link); if (info?.isSymbolicLink()) { - const current = resolve(dirname(link), readlinkSync(link)); - if (current === target) return; + const current = currentLinkTarget(link); + if (current === target) { + managedLinks.links.set(link, target); + return; + } + if (managedLinks.links.get(link) !== current) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `unrecorded or retargeted symlink occupies managed path: ${link}`, + ); + } rmSync(link); } else if (info) { throw new FleetLaunchError( @@ -642,6 +790,30 @@ function ensureSymlink(link: string, target: string): void { } mkdirSync(dirname(link), { recursive: true }); symlinkSync(target, link, 'file'); + managedLinks.links.set(link, target); +} + +function assertManagedLinkMutationAllowed( + link: string, + target: string | undefined, + managedLinks: ManagedLinkState, +): void { + const info = lstatIfPresent(link); + if (!info) return; + if (!info.isSymbolicLink()) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `real object occupies managed symlink path ${link}; refusing to delete it.`, + ); + } + const current = currentLinkTarget(link); + if (target !== undefined && current === target) return; + if (managedLinks.links.get(link) !== current) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `unrecorded or retargeted symlink occupies managed path: ${link}`, + ); + } } function canonicalJson(value: unknown): unknown { @@ -657,21 +829,57 @@ function canonicalJson(value: unknown): unknown { /** Apply a previously resolved plan. No caller should apply a dry-run plan. */ export function applyFleetLaunchComposition(plan: FleetLaunchComposition): void { mkdirSync(plan.seatHome, { recursive: true }); - const settings = `${JSON.stringify(canonicalJson(plan.settings.merged), null, 2)}\n`; - writeFileSync(plan.settings.output, settings, { mode: 0o600 }); - writeFileSync(plan.settings.snapshot, settings, { mode: 0o600 }); - ensureSymlink(plan.credential.link, plan.credential.target); - for (const path of plan.prune) { - const info = lstatIfPresent(path); - if (info?.isSymbolicLink()) rmSync(path); - else if (info) { - throw new FleetLaunchError( - 'COMPOSITION_FAILED', - `real object replaced managed symlink before prune: ${path}`, - ); + const preparedManifest = prepareManagedLinkManifest(plan.managedLinks); + let committedManifest = false; + try { + assertManagedLinkMutationAllowed( + plan.credential.link, + plan.credential.target, + plan.managedLinks, + ); + for (const path of plan.prune) { + assertManagedLinkMutationAllowed(path, undefined, plan.managedLinks); + } + for (const install of plan.installs) { + assertManagedLinkMutationAllowed(install.link, install.target, plan.managedLinks); + } + + const settings = `${JSON.stringify(canonicalJson(plan.settings.merged), null, 2)}\n`; + writeFileSync(plan.settings.output, settings, { mode: 0o600 }); + writeFileSync(plan.settings.snapshot, settings, { mode: 0o600 }); + ensureSymlink(plan.credential.link, plan.credential.target, plan.managedLinks); + for (const path of plan.prune) { + const info = lstatIfPresent(path); + if (info?.isSymbolicLink()) { + const current = currentLinkTarget(path); + if (plan.managedLinks.links.get(path) !== current) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `unrecorded or retargeted symlink cannot be pruned: ${path}`, + ); + } + rmSync(path); + plan.managedLinks.links.delete(path); + } else if (info) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `real object replaced managed symlink before prune: ${path}`, + ); + } + } + for (const install of plan.installs) { + ensureSymlink(install.link, install.target, plan.managedLinks); + } + writeManagedLinkState(plan.managedLinks, preparedManifest); + closeSync(preparedManifest.descriptor); + renameSync(preparedManifest.path, plan.managedLinks.path); + committedManifest = true; + } finally { + if (!committedManifest) { + closeSync(preparedManifest.descriptor); + rmSync(preparedManifest.path, { force: true }); } } - for (const install of plan.installs) ensureSymlink(install.link, install.target); } /** Stable, auditable text representation used by --dry-run and snapshot tests. */