fix(fleet): contain credential trust roots
AMD1213-B4: reject symlinked auth ancestry and group/world-readable credential artifacts before composition can write.
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
chmodSync,
|
||||||
lstatSync,
|
lstatSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
@@ -319,7 +320,41 @@ describe('A3 credential validation', () => {
|
|||||||
).toThrowError(/real, non-symlink credential file/);
|
).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":"[email protected]"}}\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 fx = fixture();
|
||||||
const plan = resolveFleetLaunchComposition('fred', {
|
const plan = resolveFleetLaunchComposition('fred', {
|
||||||
systemHome: fx.systemHome,
|
systemHome: fx.systemHome,
|
||||||
|
|||||||
@@ -375,8 +375,15 @@ function resolveCredential(
|
|||||||
userHome: string,
|
userHome: string,
|
||||||
seatHome: string,
|
seatHome: string,
|
||||||
): Pick<FleetLaunchComposition, 'bundle' | 'credential'> {
|
): Pick<FleetLaunchComposition, 'bundle' | 'credential'> {
|
||||||
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`);
|
assertRealDirectory(authRoot, `${profile.harness} auth root`);
|
||||||
|
const resolvedAuthRoot = realpathSync(authRoot);
|
||||||
|
assertContained(realUserHome, resolvedAuthRoot, `${profile.harness} auth root`);
|
||||||
|
|
||||||
const bundlePath = join(authRoot, profile.bundle);
|
const bundlePath = join(authRoot, profile.bundle);
|
||||||
const bundleInfo = lstatIfPresent(bundlePath);
|
const bundleInfo = lstatIfPresent(bundlePath);
|
||||||
if (!bundleInfo) {
|
if (!bundleInfo) {
|
||||||
@@ -395,7 +402,7 @@ function resolveCredential(
|
|||||||
const detail = error instanceof Error ? error.message : String(error);
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
throw new FleetLaunchError('COMPOSITION_FAILED', `credential bundle cannot resolve: ${detail}`);
|
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');
|
assertRealDirectory(resolvedBundleDir, 'resolved credential bundle');
|
||||||
|
|
||||||
const credentialTarget = join(resolvedBundleDir, CREDENTIAL_FILES[profile.harness]);
|
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}`,
|
`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);
|
const resolvedCredential = realpathSync(credentialTarget);
|
||||||
assertContained(realpathSync(authRoot), resolvedCredential, 'bundle credential');
|
assertContained(resolvedAuthRoot, resolvedCredential, 'bundle credential');
|
||||||
|
|
||||||
const credentialLink = join(seatHome, CREDENTIAL_FILES[profile.harness]);
|
const credentialLink = join(seatHome, CREDENTIAL_FILES[profile.harness]);
|
||||||
const seatInfo = lstatIfPresent(credentialLink);
|
const seatInfo = lstatIfPresent(credentialLink);
|
||||||
|
|||||||
Reference in New Issue
Block a user