From 7f299b105e590cfdc7fd9bf45453f7ab093b5280 Mon Sep 17 00:00:00 2001 From: be-coder-07 Date: Wed, 5 Aug 2026 13:42:45 -0500 Subject: [PATCH] fix(mosaic): fail closed on unsafe brain cleanup and config --- .../mosaic/src/commands/brain-doctor-check.ts | 18 ++---- .../src/commands/brain-provision-command.ts | 12 +--- .../src/commands/brain-provision.spec.ts | 10 ++- .../src/commands/brain-secure-config.spec.ts | 63 +++++++++++++++++++ .../src/commands/brain-secure-config.ts | 52 +++++++++++++++ .../mosaic/src/commands/brain-store.spec.ts | 62 ++++++++++++++++-- packages/mosaic/src/commands/brain-store.ts | 44 ++++--------- packages/mosaic/src/fleet/secure-file.ts | 4 ++ 8 files changed, 202 insertions(+), 63 deletions(-) create mode 100644 packages/mosaic/src/commands/brain-secure-config.spec.ts create mode 100644 packages/mosaic/src/commands/brain-secure-config.ts diff --git a/packages/mosaic/src/commands/brain-doctor-check.ts b/packages/mosaic/src/commands/brain-doctor-check.ts index 028dd3aa..c05501a8 100644 --- a/packages/mosaic/src/commands/brain-doctor-check.ts +++ b/packages/mosaic/src/commands/brain-doctor-check.ts @@ -1,7 +1,7 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { z } from 'zod'; -import { readRegularFileSecure } from '../fleet/secure-file.js'; +import { readBrainConfigSecure } from './brain-secure-config.js'; import { resolveBrainOwnerPolicy } from './brain-owner-resolver.js'; import { deriveBrainTarget } from './brain-store.js'; import { @@ -11,7 +11,6 @@ import { type DoctorRuntimeReport, } from './brain-store-runtime.js'; -const MAX_CONFIG_BYTES = 256 * 1024; const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; const manifestSchema = z .object({ @@ -40,15 +39,6 @@ function configurationError(code: string, reasonCode = code): InstalledDoctorRes }; } -function readUtf8(path: string, root: string): string { - const snapshot = readRegularFileSecure(path, { root, maxBytes: MAX_CONFIG_BYTES }); - try { - return new TextDecoder('utf-8', { fatal: true }).decode(snapshot.content); - } catch { - throw new Error('config-not-utf8'); - } -} - function renderReport(report: DoctorRuntimeReport): InstalledDoctorResult { const findings = report.findings.map( (finding): InstalledDoctorFinding => ({ @@ -95,13 +85,13 @@ export function runInstalledBrainDoctorCheck( const ownerPolicyPath = join(options.mosaicHome, 'brain', 'owners.json'); let registrySource: string; try { - registrySource = readUtf8(registryPath, options.mosaicHome); + registrySource = readBrainConfigSecure(registryPath, options.mosaicHome); } catch { return configurationError('brain-estate-registry-unavailable'); } let manifestSource: string; try { - manifestSource = readUtf8(manifestPath, options.mosaicHome); + manifestSource = readBrainConfigSecure(manifestPath, options.mosaicHome); } catch { return configurationError('brain-install-manifest-unavailable'); } @@ -115,7 +105,7 @@ export function runInstalledBrainDoctorCheck( if (!manifest.success) return configurationError('brain-install-manifest-invalid'); let ownerPolicySource: string; try { - ownerPolicySource = readUtf8(ownerPolicyPath, options.mosaicHome); + ownerPolicySource = readBrainConfigSecure(ownerPolicyPath, options.mosaicHome); } catch { return configurationError('brain-owner-policy-unavailable'); } diff --git a/packages/mosaic/src/commands/brain-provision-command.ts b/packages/mosaic/src/commands/brain-provision-command.ts index ebdeab19..950c88ba 100644 --- a/packages/mosaic/src/commands/brain-provision-command.ts +++ b/packages/mosaic/src/commands/brain-provision-command.ts @@ -2,12 +2,11 @@ import { randomUUID } from 'node:crypto'; import { homedir } from 'node:os'; import { join } from 'node:path'; import type { Command } from 'commander'; -import { readRegularFileSecure } from '../fleet/secure-file.js'; +import { readBrainConfigSecure } from './brain-secure-config.js'; import { provisionBrain, type ProvisionResult } from './brain-provision.js'; import { systemCommandRunner, type CommandRunner } from './brain-store-runtime.js'; import type { OwnerFetch } from './brain-owner-resolver.js'; -const MAX_POLICY_BYTES = 256 * 1024; export const BRAIN_PROVISION_COMMAND = '__brain-provision'; interface BrainProvisionCommandOptions { @@ -40,11 +39,6 @@ function configFailure(reasonCode: string): ProvisionResult { }; } -function readConfig(path: string, root: string): string { - const content = readRegularFileSecure(path, { root, maxBytes: MAX_POLICY_BYTES }).content; - return new TextDecoder('utf-8', { fatal: true }).decode(content); -} - export async function executeBrainProvisionCommand( options: BrainProvisionCommandOptions, dependencies: BrainProvisionCommandDependencies, @@ -53,13 +47,13 @@ export async function executeBrainProvisionCommand( const ownerPolicy = options.ownerPolicy ?? join(options.mosaicHome, 'brain', 'owners.json'); let estateRegistrySource: string; try { - estateRegistrySource = readConfig(registry, options.mosaicHome); + estateRegistrySource = readBrainConfigSecure(registry, options.mosaicHome); } catch { return configFailure('estate-registry-unavailable'); } let ownerPolicySource: string; try { - ownerPolicySource = readConfig(ownerPolicy, options.mosaicHome); + ownerPolicySource = readBrainConfigSecure(ownerPolicy, options.mosaicHome); } catch { return configFailure('owner-policy-unavailable'); } diff --git a/packages/mosaic/src/commands/brain-provision.spec.ts b/packages/mosaic/src/commands/brain-provision.spec.ts index d15f32d5..b200fee2 100644 --- a/packages/mosaic/src/commands/brain-provision.spec.ts +++ b/packages/mosaic/src/commands/brain-provision.spec.ts @@ -365,11 +365,15 @@ describe('P7 brain provisioning orchestration', (): void => { status: 'provisioned', reasonCode: 'brain-provisioned', owner: { verdict: 'resolved', reasonCode: 'owner-verified' }, - migration: { status: 'migrated' }, + migration: { status: 'reported' }, }); - expect(existsSync(source)).toBe(false); + expect(existsSync(source)).toBe(true); const imported = result.migration?.reported ?? []; - expect(imported).toEqual([]); + expect(imported).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: source, reason: expect.stringMatching(/retained/i) }), + ]), + ); const laneImports = join(input.root, 'lanes', 'lane-a', 'findings', 'imports'); const archiveImports = join(input.root, 'archives', 'imports', 'lane'); expect(existsSync(laneImports)).toBe(true); diff --git a/packages/mosaic/src/commands/brain-secure-config.spec.ts b/packages/mosaic/src/commands/brain-secure-config.spec.ts new file mode 100644 index 00000000..1a52d73a --- /dev/null +++ b/packages/mosaic/src/commands/brain-secure-config.spec.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +interface SecureConfigModule { + readBrainConfigSecure(path: string, root: string): string; +} + +const roots: string[] = []; + +async function loadSecureConfig(): Promise { + try { + return (await import('./brain-secure-config.js')) as SecureConfigModule; + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`MB-REQ-06 secure brain config reader is absent (${detail})`); + } +} + +function fixture(): { readonly root: string; readonly directory: string; readonly file: string } { + const outer = mkdtempSync(join(tmpdir(), 'mosaic-brain-secure-config-')); + roots.push(outer); + const root = join(outer, 'mosaic'); + const directory = join(root, 'brain'); + const file = join(directory, 'owners.json'); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + writeFileSync(file, '{"version":1}\n', { mode: 0o600 }); + return { root, directory, file }; +} + +afterEach((): void => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe('security-critical brain configuration reads', (): void => { + it('reads a principal-owned non-writable regular file through the secure descriptor path', async (): Promise => { + const secure = await loadSecureConfig(); + const config = fixture(); + + expect(secure.readBrainConfigSecure(config.file, config.root)).toBe('{"version":1}\n'); + }); + + it('rejects a group/world-writable policy file', async (): Promise => { + const secure = await loadSecureConfig(); + const config = fixture(); + chmodSync(config.file, 0o666); + + expect(() => secure.readBrainConfigSecure(config.file, config.root)).toThrow( + /config-file-permissions-unsafe/, + ); + }); + + it('rejects a group/world-writable managed ancestor', async (): Promise => { + const secure = await loadSecureConfig(); + const config = fixture(); + chmodSync(config.directory, 0o777); + + expect(() => secure.readBrainConfigSecure(config.file, config.root)).toThrow( + /config-ancestor-permissions-unsafe/, + ); + }); +}); diff --git a/packages/mosaic/src/commands/brain-secure-config.ts b/packages/mosaic/src/commands/brain-secure-config.ts new file mode 100644 index 00000000..3d96bad7 --- /dev/null +++ b/packages/mosaic/src/commands/brain-secure-config.ts @@ -0,0 +1,52 @@ +import { lstatSync } from 'node:fs'; +import { dirname, relative, resolve, sep } from 'node:path'; +import { assertCanonicalContainment, readRegularFileSecure } from '../fleet/secure-file.js'; + +const MAX_CONFIG_BYTES = 256 * 1024; +const GROUP_OR_OTHER_WRITE = 0o022; + +function currentUid(): number { + if (typeof process.getuid !== 'function') { + throw new Error('config-owner-check-unsupported'); + } + return process.getuid(); +} + +function assertOwnedNonWritableDirectory(path: string, uid: number): void { + const status = lstatSync(path); + if (!status.isDirectory() || status.isSymbolicLink() || status.uid !== uid) { + throw new Error('config-ancestor-owner-unsafe'); + } + if ((status.mode & GROUP_OR_OTHER_WRITE) !== 0) { + throw new Error('config-ancestor-permissions-unsafe'); + } +} + +export function readBrainConfigSecure(path: string, root: string): string { + const canonicalRoot = resolve(root); + const canonicalPath = resolve(path); + assertCanonicalContainment(canonicalRoot, canonicalPath); + const uid = currentUid(); + assertOwnedNonWritableDirectory(canonicalRoot, uid); + let cursor = canonicalRoot; + for (const component of relative(canonicalRoot, dirname(canonicalPath)) + .split(sep) + .filter(Boolean)) { + cursor = resolve(cursor, component); + assertOwnedNonWritableDirectory(cursor, uid); + } + + const snapshot = readRegularFileSecure(canonicalPath, { + root: canonicalRoot, + maxBytes: MAX_CONFIG_BYTES, + }); + if (snapshot.uid !== uid) throw new Error('config-file-owner-unsafe'); + if ((snapshot.mode & GROUP_OR_OTHER_WRITE) !== 0) { + throw new Error('config-file-permissions-unsafe'); + } + try { + return new TextDecoder('utf-8', { fatal: true }).decode(snapshot.content); + } catch { + throw new Error('config-file-not-utf8'); + } +} diff --git a/packages/mosaic/src/commands/brain-store.spec.ts b/packages/mosaic/src/commands/brain-store.spec.ts index 345af6fd..e7c2db17 100644 --- a/packages/mosaic/src/commands/brain-store.spec.ts +++ b/packages/mosaic/src/commands/brain-store.spec.ts @@ -138,7 +138,10 @@ interface BrainStoreModule { plan: MigrationPlan, publish: (brainRoot: string, paths: readonly string[]) => MigrationPublishEvidence, brainRoot: string, - hooks?: { readonly beforeDestinationWrite?: (destination: string) => void }, + hooks?: { + readonly beforeDestinationWrite?: (destination: string) => void; + readonly beforeSourceCleanup?: (source: string) => void; + }, ): MigrationResult; evaluateBrainDoctor( observation: BrainDoctorObservation, @@ -683,7 +686,7 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v expect(existsSync(brainRoot)).toBe(false); }); - it('publishes collision-safe append-only copies before archiving sources and never overwrites a finding', async (): Promise => { + it('publishes collision-safe append-only copies, retains the source explicitly, and never overwrites a finding', async (): Promise => { const sut = await loadSut('MB-REQ-07 append-only publish-before-archive migration'); const root = tempRoot(); const sourceRoot = join(root, 'local-memory'); @@ -718,11 +721,19 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v brainRoot, ); - expect(result.status).toBe('migrated'); + expect(result.status).toBe('reported'); expect(readFileSync(preexisting, 'utf8')).toBe('older independent finding\n'); expect(readFileSync(candidate.destination, 'utf8')).toBe('new finding\n'); expect(readFileSync(candidate.archive, 'utf8')).toBe('new finding\n'); - expect(existsSync(candidate.source)).toBe(false); + expect(existsSync(candidate.source)).toBe(true); + expect(result.reported).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: candidate.source, + reason: expect.stringMatching(/retained/i), + }), + ]), + ); expect(publishedPaths).toContain(candidate.destination); expect(publishedPaths).toContain(candidate.archive); }); @@ -759,7 +770,7 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v expect.arrayContaining([ expect.objectContaining({ path: source, - reason: expect.stringMatching(/changed|identity/i), + reason: expect.stringMatching(/retained|cleanup/i), }), ]), ); @@ -840,6 +851,47 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v expect(publishCalls).toBe(0); }); + it('never path-unlinks a source when identity-aware unlink is unavailable', async (): Promise => { + const sut = await loadSut('MB-REQ-07 no path-based source unlink'); + 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, + ); + let cleanupHookCalled = false; + + const result = sut.migrateBrainState( + plan, + (): MigrationPublishEvidence => ({ + commit: 'a'.repeat(40), + remoteHead: 'a'.repeat(40), + reachable: true, + }), + brainRoot, + { + beforeSourceCleanup: (): void => { + cleanupHookCalled = true; + rmSync(source); + writeFileSync(source, 'replacement must survive\n'); + }, + }, + ); + + expect(cleanupHookCalled).toBe(true); + expect(result.status).toBe('reported'); + expect(readFileSync(source, 'utf8')).toBe('replacement must survive\n'); + expect(result.reported).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: source, reason: expect.stringMatching(/retained/i) }), + ]), + ); + }); + it('retains every source and reports failure when remote reachability is not established', async (): Promise => { const sut = await loadSut('MB-REQ-07 failed-publish source preservation'); const root = tempRoot(); diff --git a/packages/mosaic/src/commands/brain-store.ts b/packages/mosaic/src/commands/brain-store.ts index 174622d8..f4f9012b 100644 --- a/packages/mosaic/src/commands/brain-store.ts +++ b/packages/mosaic/src/commands/brain-store.ts @@ -157,6 +157,7 @@ export interface MigrationPublishEvidence { export interface MigrationHooks { readonly beforeDestinationWrite?: (destination: string) => void; + readonly beforeSourceCleanup?: (source: string) => void; } export interface MigrationResult { @@ -962,39 +963,18 @@ export function migrateBrainState( }; } - const migrated: MigrationCandidate[] = []; - 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 { - removalReports.push({ - path: candidate.source, - reason: - 'Published migration is reachable but source cleanup failed; retained and reported.', - }); - } - } + const retentionReports = plan.candidates.map((candidate: MigrationCandidate): MigrationReport => { + hooks.beforeSourceCleanup?.(candidate.source); + return { + path: candidate.source, + reason: + 'Published snapshot is remotely reachable; automatic path-based cleanup is unsafe, so Mosaic retained and reported the source.', + }; + }); return { - status: removalReports.length === 0 ? 'migrated' : 'reported', - migrated, - reported: [...plan.reported, ...removalReports], + status: 'reported', + migrated: [], + reported: [...plan.reported, ...retentionReports], publish: evidence, }; } diff --git a/packages/mosaic/src/fleet/secure-file.ts b/packages/mosaic/src/fleet/secure-file.ts index 82f27201..e9042fb9 100644 --- a/packages/mosaic/src/fleet/secure-file.ts +++ b/packages/mosaic/src/fleet/secure-file.ts @@ -22,6 +22,8 @@ export interface SecureFileSnapshot { mode: number; dev: number | bigint; ino: number | bigint; + uid: number; + gid: number; } function sameIdentity( @@ -235,6 +237,8 @@ export function readRegularFileSecure( mode: Number(opened.mode), dev: opened.dev, ino: opened.ino, + uid: opened.uid, + gid: opened.gid, }; } finally { closeDescriptors(openedFile.descriptors);