fix(mosaic): harden brain migration snapshots

This commit is contained in:
2026-08-05 17:19:50 -05:00
parent 2451c2f21a
commit fcfc1b08f9
2 changed files with 195 additions and 23 deletions
@@ -331,7 +331,23 @@ describe('R6 — brain layout refuses secret material', (): void => {
).toBe(true); ).toBe(true);
} }
const rules = readFileSync(join(root, '.gitignore'), 'utf8').trim().split('\n'); const rules = readFileSync(join(root, '.gitignore'), 'utf8').trim().split('\n');
expect(rules).toEqual(['*.token', '*.key', '*.pem', '.env', 'credentials.json']); expect(rules).toEqual(
expect.arrayContaining([
'*.token',
'*.key',
'*.pem',
'.env',
'credentials.json',
'*.TOKEN',
'*.KEY',
'*.PEM',
'.env.*',
'credentials.*',
'secrets.*',
'id_rsa',
'id_ed25519',
]),
);
}); });
it('refuses a symlinked layout directory without writing a tracked placeholder outside the brain', async (): Promise<void> => { it('refuses a symlinked layout directory without writing a tracked placeholder outside the brain', async (): Promise<void> => {
@@ -626,7 +642,20 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
const laneRoot = join(sourceRoot, 'lanes', 'lane-a'); const laneRoot = join(sourceRoot, 'lanes', 'lane-a');
const brainRoot = join(root, 'brain'); const brainRoot = join(root, 'brain');
mkdirSync(laneRoot, { recursive: true }); mkdirSync(laneRoot, { recursive: true });
for (const name of ['access.token', 'private.key', 'client.pem', '.env', 'credentials.json']) { const secretNames = [
'access.token',
'private.key',
'client.pem',
'CLIENT.PEM',
'.env',
'.env.local',
'.env.production',
'credentials.json',
'credentials.yaml',
'id_rsa',
'secrets.txt',
];
for (const name of secretNames) {
writeFileSync(join(laneRoot, name), 'DO-NOT-MIGRATE\n'); writeFileSync(join(laneRoot, name), 'DO-NOT-MIGRATE\n');
} }
@@ -637,7 +666,7 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
expect(plan.status).toBe('ready'); expect(plan.status).toBe('ready');
expect(plan.candidates).toHaveLength(0); expect(plan.candidates).toHaveLength(0);
expect(plan.reported).toHaveLength(5); expect(plan.reported).toHaveLength(secretNames.length);
expect(plan.reported.every((entry) => /secret/i.test(entry.reason))).toBe(true); expect(plan.reported.every((entry) => /secret/i.test(entry.reason))).toBe(true);
expect(existsSync(brainRoot)).toBe(false); expect(existsSync(brainRoot)).toBe(false);
}); });
@@ -686,6 +715,45 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
expect(publishedPaths).toContain(candidate.archive); expect(publishedPaths).toContain(candidate.archive);
}); });
it.each(['modified', 'replaced'] as const)(
'retains and reports a source that is %s while its published snapshot is in flight',
async (change): Promise<void> => {
const sut = await loadSut('MB-REQ-07 source identity before cleanup');
const root = tempRoot();
const sourceRoot = join(root, 'local-memory');
const brainRoot = join(root, 'brain');
mkdirSync(join(sourceRoot, 'lanes', 'lane-a'), { recursive: true });
const source = join(sourceRoot, 'lanes', 'lane-a', 'finding.md');
writeFileSync(source, 'published snapshot\n');
const plan = sut.discoverBrainMigration(
{ sourceRoot, brainRoot, seat: 'seat-a', lane: 'lane-a', laneActive: true },
activeLaneOwner,
);
const result = sut.migrateBrainState(
plan,
(): MigrationPublishEvidence => {
if (change === 'replaced') rmSync(source);
writeFileSync(source, 'new concurrent state\n');
return { commit: 'a'.repeat(40), remoteHead: 'a'.repeat(40), reachable: true };
},
brainRoot,
);
expect(result.status).toBe('reported');
expect(readFileSync(source, 'utf8')).toBe('new concurrent state\n');
expect(readFileSync(plan.candidates[0]!.destination, 'utf8')).toBe('published snapshot\n');
expect(result.reported).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: source,
reason: expect.stringMatching(/changed|identity/i),
}),
]),
);
},
);
it('refuses nested symlink destinations without copying a migration outside the brain', async (): Promise<void> => { it('refuses nested symlink destinations without copying a migration outside the brain', async (): Promise<void> => {
const sut = await loadSut('MB-REQ-07 migration destination no-follow'); const sut = await loadSut('MB-REQ-07 migration destination no-follow');
const root = tempRoot(); const root = tempRoot();
+124 -20
View File
@@ -1,9 +1,9 @@
import { import {
closeSync, closeSync,
constants as fsConstants, constants as fsConstants,
copyFileSync,
existsSync, existsSync,
fsyncSync, fsyncSync,
fstatSync,
lstatSync, lstatSync,
linkSync, linkSync,
mkdirSync, mkdirSync,
@@ -22,7 +22,28 @@ import { parseCredentialEstateRegistry } from '../credentials/estate-registry.js
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
const COMMIT = /^[0-9a-f]{40}$/; const COMMIT = /^[0-9a-f]{40}$/;
const GITIGNORE_RULES = ['*.token', '*.key', '*.pem', '.env', 'credentials.json'] as const; const GITIGNORE_RULES = [
'*.token',
'*.key',
'*.pem',
'.env',
'credentials.json',
'*.TOKEN',
'*.KEY',
'*.PEM',
'*.p12',
'*.pfx',
'*.jks',
'*.keystore',
'.env.*',
'credentials.*',
'secret.*',
'secrets.*',
'id_rsa',
'id_dsa',
'id_ecdsa',
'id_ed25519',
] as const;
const BRAIN_DIRECTORIES = ['agents', 'lanes', 'board', 'specs', 'methods', 'archives'] as const; const BRAIN_DIRECTORIES = ['agents', 'lanes', 'board', 'specs', 'methods', 'archives'] as const;
const TERMINAL_EXITS = { const TERMINAL_EXITS = {
ok: 0, ok: 0,
@@ -108,6 +129,11 @@ export interface MigrationCandidate {
readonly destination: string; readonly destination: string;
readonly archive: string; readonly archive: string;
readonly kind: 'lane' | 'seat'; readonly kind: 'lane' | 'seat';
readonly sourceIdentity: {
readonly dev: number | bigint;
readonly ino: number | bigint;
readonly digest: string;
};
} }
export interface MigrationReport { export interface MigrationReport {
@@ -536,11 +562,42 @@ function ownerIsValid(
return true; return true;
} }
function migrationDigest(sourceRoot: string, path: string): string { interface StableSourceSnapshot {
const stat = lstatSync(path); readonly content: Buffer;
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('migration-source-not-regular'); readonly dev: number | bigint;
const key = relative(sourceRoot, path).split(sep).join('/'); readonly ino: number | bigint;
return createHash('sha256').update(key).update('\0').update(readFileSync(path)).digest('hex'); readonly digest: string;
}
function stableSourceSnapshot(path: string): StableSourceSnapshot {
const descriptor = openSync(
path,
fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW,
);
try {
const before = fstatSync(descriptor);
if (!before.isFile()) throw new Error('migration-source-not-regular');
const content = readFileSync(descriptor);
const after = fstatSync(descriptor);
if (
!after.isFile() ||
before.dev !== after.dev ||
before.ino !== after.ino ||
before.size !== after.size ||
before.mtimeMs !== after.mtimeMs ||
content.byteLength !== after.size
) {
throw new Error('migration-source-changed-during-read');
}
return {
content,
dev: after.dev,
ino: after.ino,
digest: createHash('sha256').update(content).digest('hex'),
};
} finally {
closeSync(descriptor);
}
} }
function migrationCandidate( function migrationCandidate(
@@ -551,7 +608,14 @@ function migrationCandidate(
seat: string, seat: string,
lane: string, lane: string,
): MigrationCandidate { ): MigrationCandidate {
const digest = migrationDigest(sourceRoot, path).slice(0, 16); const source = stableSourceSnapshot(path);
const key = relative(sourceRoot, path).split(sep).join('/');
const digest = createHash('sha256')
.update(key)
.update('\0')
.update(source.content)
.digest('hex')
.slice(0, 16);
const name = `${digest}-${basename(path)}`; const name = `${digest}-${basename(path)}`;
const destination = const destination =
kind === 'lane' kind === 'lane'
@@ -562,17 +626,25 @@ function migrationCandidate(
destination, destination,
archive: join(brainRoot, 'archives', 'imports', kind, name), archive: join(brainRoot, 'archives', 'imports', kind, name),
kind, kind,
sourceIdentity: { dev: source.dev, ino: source.ino, digest: source.digest },
}; };
} }
function secretShapedPath(path: string): boolean { function secretShapedPath(path: string): boolean {
const name = basename(path); const name = basename(path).toLowerCase();
return ( return (
name === '.env' || name === '.env' ||
name.startsWith('.env.') ||
name === 'credentials.json' || name === 'credentials.json' ||
name.endsWith('.token') || name.startsWith('credentials.') ||
name.endsWith('.key') || name === 'secret' ||
name.endsWith('.pem') name.startsWith('secret.') ||
name === 'secrets' ||
name.startsWith('secrets.') ||
['id_rsa', 'id_dsa', 'id_ecdsa', 'id_ed25519'].includes(name) ||
['.token', '.key', '.pem', '.p12', '.pfx', '.jks', '.keystore'].some((suffix): boolean =>
name.endsWith(suffix),
)
); );
} }
@@ -695,7 +767,22 @@ function assertSafeDestinationAncestors(root: string, destination: string): void
} }
} }
function copyVerified(source: string, destination: string, brainRoot: string): boolean { function sourceSnapshotMatches(
snapshot: StableSourceSnapshot,
identity: MigrationCandidate['sourceIdentity'],
): boolean {
return (
snapshot.dev === identity.dev &&
snapshot.ino === identity.ino &&
snapshot.digest === identity.digest
);
}
function copyVerified(
source: StableSourceSnapshot,
destination: string,
brainRoot: string,
): boolean {
assertSafeDestinationAncestors(brainRoot, destination); assertSafeDestinationAncestors(brainRoot, destination);
mkdirSync(dirname(destination), { recursive: true }); mkdirSync(dirname(destination), { recursive: true });
assertSafeDestinationAncestors(brainRoot, destination); assertSafeDestinationAncestors(brainRoot, destination);
@@ -704,22 +791,20 @@ function copyVerified(source: string, destination: string, brainRoot: string): b
if (!status.isFile() || status.isSymbolicLink()) { if (!status.isFile() || status.isSymbolicLink()) {
throw new Error('append-only-destination-unsafe'); throw new Error('append-only-destination-unsafe');
} }
const sourceDigest = createHash('sha256').update(readFileSync(source)).digest('hex');
const destinationDigest = createHash('sha256').update(readFileSync(destination)).digest('hex'); const destinationDigest = createHash('sha256').update(readFileSync(destination)).digest('hex');
if (sourceDigest !== destinationDigest) throw new Error('append-only-collision'); if (source.digest !== destinationDigest) throw new Error('append-only-collision');
return false; return false;
} }
const temporary = `${destination}.tmp-${process.pid}-${randomUUID()}`; const temporary = `${destination}.tmp-${process.pid}-${randomUUID()}`;
try { try {
copyFileSync(source, temporary, fsConstants.COPYFILE_EXCL); writeFileSync(temporary, source.content, { mode: 0o600, flag: 'wx' });
const temporaryStatus = lstatSync(temporary); const temporaryStatus = lstatSync(temporary);
if (!temporaryStatus.isFile() || temporaryStatus.isSymbolicLink()) { if (!temporaryStatus.isFile() || temporaryStatus.isSymbolicLink()) {
throw new Error('migration-copy-target-unsafe'); throw new Error('migration-copy-target-unsafe');
} }
syncFile(temporary); syncFile(temporary);
const sourceDigest = createHash('sha256').update(readFileSync(source)).digest('hex');
const copiedDigest = createHash('sha256').update(readFileSync(temporary)).digest('hex'); const copiedDigest = createHash('sha256').update(readFileSync(temporary)).digest('hex');
if (sourceDigest !== copiedDigest) throw new Error('migration-copy-verification-failed'); if (source.digest !== copiedDigest) throw new Error('migration-copy-verification-failed');
assertSafeDestinationAncestors(brainRoot, destination); assertSafeDestinationAncestors(brainRoot, destination);
linkSync(temporary, destination); linkSync(temporary, destination);
syncFile(destination); syncFile(destination);
@@ -755,10 +840,14 @@ export function migrateBrainState(
) { ) {
throw new Error('migration-destination-escaped-brain'); throw new Error('migration-destination-escaped-brain');
} }
if (copyVerified(candidate.source, candidate.destination, brainRoot)) { const source = stableSourceSnapshot(candidate.source);
if (!sourceSnapshotMatches(source, candidate.sourceIdentity)) {
throw new Error('migration-source-changed-before-copy');
}
if (copyVerified(source, candidate.destination, brainRoot)) {
created.push(candidate.destination); created.push(candidate.destination);
} }
if (copyVerified(candidate.source, candidate.archive, brainRoot)) { if (copyVerified(source, candidate.archive, brainRoot)) {
created.push(candidate.archive); created.push(candidate.archive);
} }
published.push(candidate.destination, candidate.archive); published.push(candidate.destination, candidate.archive);
@@ -797,6 +886,21 @@ export function migrateBrainState(
const removalReports: MigrationReport[] = []; const removalReports: MigrationReport[] = [];
for (const candidate of plan.candidates) { for (const candidate of plan.candidates) {
try { try {
const current = stableSourceSnapshot(candidate.source);
const beforeUnlink = lstatSync(candidate.source);
if (
!sourceSnapshotMatches(current, candidate.sourceIdentity) ||
!beforeUnlink.isFile() ||
beforeUnlink.isSymbolicLink() ||
beforeUnlink.dev !== current.dev ||
beforeUnlink.ino !== current.ino
) {
removalReports.push({
path: candidate.source,
reason: 'Source identity or content changed after publication; retained and reported.',
});
continue;
}
unlinkSync(candidate.source); unlinkSync(candidate.source);
migrated.push(candidate); migrated.push(candidate);
} catch { } catch {