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.
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user