fix(mosaic): fail closed on unsafe brain cleanup and config

This commit is contained in:
2026-08-05 17:19:50 -05:00
parent 6afb3cb5b3
commit 656fa9ceb7
8 changed files with 202 additions and 63 deletions
@@ -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');
}
@@ -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');
}
@@ -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);
@@ -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<SecureConfigModule> {
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<void> => {
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<void> => {
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<void> => {
const secure = await loadSecureConfig();
const config = fixture();
chmodSync(config.directory, 0o777);
expect(() => secure.readBrainConfigSecure(config.file, config.root)).toThrow(
/config-ancestor-permissions-unsafe/,
);
});
});
@@ -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');
}
}
@@ -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<void> => {
it('publishes collision-safe append-only copies, retains the source explicitly, and never overwrites a finding', async (): Promise<void> => {
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<void> => {
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<void> => {
const sut = await loadSut('MB-REQ-07 failed-publish source preservation');
const root = tempRoot();
+12 -32
View File
@@ -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,
};
}
+4
View File
@@ -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);