From 4fde3f622d3903973038a8c36f52b2256d9bb5e3 Mon Sep 17 00:00:00 2001 From: terra Date: Thu, 13 Aug 2026 14:38:25 -0500 Subject: [PATCH] fix(fleet): contain credential trust roots AMD1213-B4: reject symlinked auth ancestry and group/world-readable credential artifacts before composition can write. --- .../src/commands/fleet-launch-command.spec.ts | 37 ++++++++++++++++++- .../src/commands/fleet-launch-command.ts | 20 ++++++++-- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/packages/mosaic/src/commands/fleet-launch-command.spec.ts b/packages/mosaic/src/commands/fleet-launch-command.spec.ts index 42cce95d..7cdbef4f 100644 --- a/packages/mosaic/src/commands/fleet-launch-command.spec.ts +++ b/packages/mosaic/src/commands/fleet-launch-command.spec.ts @@ -1,4 +1,5 @@ import { + chmodSync, lstatSync, mkdirSync, mkdtempSync, @@ -319,7 +320,41 @@ describe('A3 credential validation', () => { ).toThrowError(/real, non-symlink credential file/); }); - it('accepts a real credential file contained in the harness auth root', () => { + it('refuses an auth ancestor symlink that relocates the credential trust root', () => { + const fx = fixture(); + rmSync(join(fx.userHome, 'auth'), { recursive: true, force: true }); + const outsideAuth = join(fx.root, 'outside-auth'); + const outsideBundle = join(outsideAuth, 'claude', 'fred_example.com'); + mkdirSync(outsideBundle, { recursive: true }); + writeFileSync(join(outsideBundle, '.credentials.json'), '{}\n', { mode: 0o600 }); + writeFileSync( + join(outsideBundle, 'account.json'), + '{"oauthAccount":{"emailAddress":"fred@example.com"}}\n', + ); + symlinkSync('fred_example.com', join(outsideAuth, 'claude', 'primary'), 'dir'); + symlinkSync(outsideAuth, join(fx.userHome, 'auth'), 'dir'); + + expect(() => + resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }), + ).toThrowError(/auth directory must be a real, non-symlink directory/); + }); + + it('refuses a group- or world-readable credential file', () => { + const fx = fixture(); + chmodSync(join(fx.namedBundleDir, '.credentials.json'), 0o644); + + expect(() => + resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }), + ).toThrowError(/credential file must not grant group or other permissions/); + }); + + it('accepts a real private credential file contained in the harness auth root', () => { const fx = fixture(); const plan = resolveFleetLaunchComposition('fred', { systemHome: fx.systemHome, diff --git a/packages/mosaic/src/commands/fleet-launch-command.ts b/packages/mosaic/src/commands/fleet-launch-command.ts index 61f58f5f..df3852e3 100644 --- a/packages/mosaic/src/commands/fleet-launch-command.ts +++ b/packages/mosaic/src/commands/fleet-launch-command.ts @@ -375,8 +375,15 @@ function resolveCredential( userHome: string, seatHome: string, ): Pick { - const authRoot = join(userHome, 'auth', profile.harness); + assertRealDirectory(userHome, 'user Mosaic root'); + const realUserHome = realpathSync(userHome); + const authDirectory = join(userHome, 'auth'); + assertRealDirectory(authDirectory, 'auth directory'); + const authRoot = join(authDirectory, profile.harness); assertRealDirectory(authRoot, `${profile.harness} auth root`); + const resolvedAuthRoot = realpathSync(authRoot); + assertContained(realUserHome, resolvedAuthRoot, `${profile.harness} auth root`); + const bundlePath = join(authRoot, profile.bundle); const bundleInfo = lstatIfPresent(bundlePath); if (!bundleInfo) { @@ -395,7 +402,7 @@ function resolveCredential( const detail = error instanceof Error ? error.message : String(error); throw new FleetLaunchError('COMPOSITION_FAILED', `credential bundle cannot resolve: ${detail}`); } - assertContained(realpathSync(authRoot), resolvedBundleDir, 'credential bundle'); + assertContained(resolvedAuthRoot, resolvedBundleDir, 'credential bundle'); assertRealDirectory(resolvedBundleDir, 'resolved credential bundle'); const credentialTarget = join(resolvedBundleDir, CREDENTIAL_FILES[profile.harness]); @@ -406,8 +413,15 @@ function resolveCredential( `bundle credential must be a real, non-symlink credential file: ${credentialTarget}`, ); } + if ((credentialInfo.mode & 0o077) !== 0) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `bundle credential file must not grant group or other permissions: ${credentialTarget}`, + ); + } + // Fleet launches and credential bundles share one operating-system user; ownership validation is deferred. const resolvedCredential = realpathSync(credentialTarget); - assertContained(realpathSync(authRoot), resolvedCredential, 'bundle credential'); + assertContained(resolvedAuthRoot, resolvedCredential, 'bundle credential'); const credentialLink = join(seatHome, CREDENTIAL_FILES[profile.harness]); const seatInfo = lstatIfPresent(credentialLink);