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);
}
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> => {
@@ -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<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> => {
const sut = await loadSut('MB-REQ-07 migration destination no-follow');
const root = tempRoot();
+124 -20
View File
@@ -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 {