From fcfc1b08f9dc0431a64821328f850a0273caed54 Mon Sep 17 00:00:00 2001 From: be-coder-07 Date: Wed, 5 Aug 2026 13:30:13 -0500 Subject: [PATCH] fix(mosaic): harden brain migration snapshots --- .../mosaic/src/commands/brain-store.spec.ts | 74 ++++++++- packages/mosaic/src/commands/brain-store.ts | 144 +++++++++++++++--- 2 files changed, 195 insertions(+), 23 deletions(-) diff --git a/packages/mosaic/src/commands/brain-store.spec.ts b/packages/mosaic/src/commands/brain-store.spec.ts index df0800e4..abd488be 100644 --- a/packages/mosaic/src/commands/brain-store.spec.ts +++ b/packages/mosaic/src/commands/brain-store.spec.ts @@ -331,7 +331,23 @@ describe('R6 — brain layout refuses secret material', (): void => { ).toBe(true); } 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 => { @@ -626,7 +642,20 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v const laneRoot = join(sourceRoot, 'lanes', 'lane-a'); const brainRoot = join(root, 'brain'); 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'); } @@ -637,7 +666,7 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v expect(plan.status).toBe('ready'); 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(existsSync(brainRoot)).toBe(false); }); @@ -686,6 +715,45 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v 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 => { + 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 => { const sut = await loadSut('MB-REQ-07 migration destination no-follow'); const root = tempRoot(); diff --git a/packages/mosaic/src/commands/brain-store.ts b/packages/mosaic/src/commands/brain-store.ts index 0da2e559..875cc3c7 100644 --- a/packages/mosaic/src/commands/brain-store.ts +++ b/packages/mosaic/src/commands/brain-store.ts @@ -1,9 +1,9 @@ import { closeSync, constants as fsConstants, - copyFileSync, existsSync, fsyncSync, + fstatSync, lstatSync, linkSync, mkdirSync, @@ -22,7 +22,28 @@ import { parseCredentialEstateRegistry } from '../credentials/estate-registry.js const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; 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 TERMINAL_EXITS = { ok: 0, @@ -108,6 +129,11 @@ export interface MigrationCandidate { readonly destination: string; readonly archive: string; readonly kind: 'lane' | 'seat'; + readonly sourceIdentity: { + readonly dev: number | bigint; + readonly ino: number | bigint; + readonly digest: string; + }; } export interface MigrationReport { @@ -536,11 +562,42 @@ function ownerIsValid( return true; } -function migrationDigest(sourceRoot: string, path: string): string { - const stat = lstatSync(path); - if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('migration-source-not-regular'); - const key = relative(sourceRoot, path).split(sep).join('/'); - return createHash('sha256').update(key).update('\0').update(readFileSync(path)).digest('hex'); +interface StableSourceSnapshot { + readonly content: Buffer; + readonly dev: number | bigint; + readonly ino: number | bigint; + 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( @@ -551,7 +608,14 @@ function migrationCandidate( seat: string, lane: string, ): 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 destination = kind === 'lane' @@ -562,17 +626,25 @@ function migrationCandidate( destination, archive: join(brainRoot, 'archives', 'imports', kind, name), kind, + sourceIdentity: { dev: source.dev, ino: source.ino, digest: source.digest }, }; } function secretShapedPath(path: string): boolean { - const name = basename(path); + const name = basename(path).toLowerCase(); return ( name === '.env' || + name.startsWith('.env.') || name === 'credentials.json' || - name.endsWith('.token') || - name.endsWith('.key') || - name.endsWith('.pem') + name.startsWith('credentials.') || + name === 'secret' || + 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); mkdirSync(dirname(destination), { recursive: true }); assertSafeDestinationAncestors(brainRoot, destination); @@ -704,22 +791,20 @@ function copyVerified(source: string, destination: string, brainRoot: string): b if (!status.isFile() || status.isSymbolicLink()) { 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'); - if (sourceDigest !== destinationDigest) throw new Error('append-only-collision'); + if (source.digest !== destinationDigest) throw new Error('append-only-collision'); return false; } const temporary = `${destination}.tmp-${process.pid}-${randomUUID()}`; try { - copyFileSync(source, temporary, fsConstants.COPYFILE_EXCL); + writeFileSync(temporary, source.content, { mode: 0o600, flag: 'wx' }); const temporaryStatus = lstatSync(temporary); if (!temporaryStatus.isFile() || temporaryStatus.isSymbolicLink()) { throw new Error('migration-copy-target-unsafe'); } syncFile(temporary); - const sourceDigest = createHash('sha256').update(readFileSync(source)).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); linkSync(temporary, destination); syncFile(destination); @@ -755,10 +840,14 @@ export function migrateBrainState( ) { 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); } - if (copyVerified(candidate.source, candidate.archive, brainRoot)) { + if (copyVerified(source, candidate.archive, brainRoot)) { created.push(candidate.archive); } published.push(candidate.destination, candidate.archive); @@ -797,6 +886,21 @@ export function migrateBrainState( const removalReports: MigrationReport[] = []; for (const candidate of plan.candidates) { 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); migrated.push(candidate); } catch {