diff --git a/docs/scratchpads/1051-mosaic-brain-installer.md b/docs/scratchpads/1051-mosaic-brain-installer.md index fee5a78e..ec11172d 100644 --- a/docs/scratchpads/1051-mosaic-brain-installer.md +++ b/docs/scratchpads/1051-mosaic-brain-installer.md @@ -89,7 +89,7 @@ No explicit token ceiling was supplied. Working cap: 55K tokens for implementati - [x] R6 migration reports filename- or content-secret-shaped files without copying them; arbitrary legacy content requires an approved scanner bound to the exact source snapshot, and production currently retains/reports when no approved scanner is configured. Symlinked `.gitignore`, layout directories, and nested migration destinations fail closed. - [x] Provider owner lookup uses manual redirect handling, a five-second abort signal, strict JSON content type/shape, and an incrementally enforced 256 KiB response ceiling. - [x] Automatic source deletion is parked per the shared-Git-identity governance ruling; remotely reachable snapshots still leave and report every source. -- [x] Multi-host push-on-write uses an isolated temporary Git index, verifies the exact changed-path allowlist plus both author/committer trailers before push, retries non-fast-forward races via identity-scoped fetch/rebase rather than LWW, and retains both sources and local copies when publication reachability is unknown. A real-repository abuse test proves unrelated pre-staged secret-shaped content remains staged but never enters the published commit. +- [x] Multi-host push-on-write uses an isolated temporary Git index populated from approved in-memory blobs rather than pathname re-reads, verifies each committed blob ID, the exact changed-path allowlist, and both author/committer trailers before push, retries non-fast-forward races via identity-scoped fetch/rebase rather than LWW, and retains both sources and local copies when publication reachability is unknown. A real-repository abuse test proves destination-path substitution cannot change committed bytes and unrelated pre-staged secret-shaped content remains staged but never enters the published commit. - [ ] Installer shell P7 invocation after C1 + MC-CRED integration; production command is registered but the C1 shell has not yet called it. - [ ] Implementation green on merged dependency base. - [ ] Independent code review. diff --git a/packages/mosaic/src/commands/brain-provision.spec.ts b/packages/mosaic/src/commands/brain-provision.spec.ts index 1c545c8e..93377cfb 100644 --- a/packages/mosaic/src/commands/brain-provision.spec.ts +++ b/packages/mosaic/src/commands/brain-provision.spec.ts @@ -326,11 +326,21 @@ describe('P7 brain provisioning orchestration', (): void => { mkdirSync(join(input.root, '.git'), { recursive: true }); return { status: 0, stdout: '', stderr: '' }; } - if (command.includes(' add -- ')) { - const separator = request.args.indexOf('--'); - approvedPaths = separator === -1 ? [] : [...request.args.slice(separator + 1)]; + if (command.includes('read-tree')) { + approvedPaths = []; return { status: 0, stdout: '', stderr: '' }; } + if (command.includes('hash-object')) { + return { status: 0, stdout: `${'f'.repeat(40)}\n`, stderr: '' }; + } + if (command.includes('update-index')) { + const path = request.args.at(-1); + if (path !== undefined) approvedPaths.push(path); + return { status: 0, stdout: '', stderr: '' }; + } + if (command.includes('rev-parse') && request.args.at(-1)?.includes(':')) { + return { status: 0, stdout: `${'f'.repeat(40)}\n`, stderr: '' }; + } if (command.includes('diff --cached --quiet')) { return { status: 1, stdout: '', stderr: '' }; } diff --git a/packages/mosaic/src/commands/brain-provision.ts b/packages/mosaic/src/commands/brain-provision.ts index 04c921a6..7fed8ff3 100644 --- a/packages/mosaic/src/commands/brain-provision.ts +++ b/packages/mosaic/src/commands/brain-provision.ts @@ -1,4 +1,3 @@ -import { lstatSync } from 'node:fs'; import { parseRequestedOwner, resolveProviderDurableOwner } from './brain-owner-resolver.js'; import { createBrainSkeleton, @@ -7,6 +6,7 @@ import { migrateBrainState, type MigrationResult, type MigrationOwnerResolution, + type MigrationPublishEntry, } from './brain-store.js'; import { collectBrainDoctorReport, @@ -181,20 +181,20 @@ export async function provisionBrain( return blocked('brain-postcondition-failed', report.findings); } - let skeletonPaths: readonly string[]; + let skeletonEntries: readonly MigrationPublishEntry[]; try { const skeleton = createBrainSkeleton(input.root); - skeletonPaths = skeleton.created.filter((path: string): boolean => lstatSync(path).isFile()); + skeletonEntries = skeleton.publicationEntries; } catch { return failed('brain-skeleton-failed', report.findings); } - if (skeletonPaths.length > 0) { + if (skeletonEntries.length > 0) { try { const evidence = publishBrainPaths( { root: input.root, identity: input.identity, - paths: skeletonPaths, + entries: skeletonEntries, message: 'chore: seed durable brain layout', }, dependencies.run, @@ -218,12 +218,12 @@ export async function provisionBrain( ); const migration = migrateBrainState( plan, - (brainRoot: string, paths: readonly string[]) => + (brainRoot: string, entries: readonly MigrationPublishEntry[]) => publishBrainPaths( { root: brainRoot, identity: input.identity, - paths, + entries, message: `migrate: archive ${input.lane} working memory`, }, dependencies.run, diff --git a/packages/mosaic/src/commands/brain-store-runtime.spec.ts b/packages/mosaic/src/commands/brain-store-runtime.spec.ts index a3578449..6c7a6c5e 100644 --- a/packages/mosaic/src/commands/brain-store-runtime.spec.ts +++ b/packages/mosaic/src/commands/brain-store-runtime.spec.ts @@ -17,6 +17,7 @@ interface CommandRequest { readonly args: readonly string[]; readonly cwd?: string; readonly env: Readonly>; + readonly stdin?: Uint8Array; } interface CommandResult { @@ -86,7 +87,7 @@ interface BrainRuntimeModule { input: { readonly root: string; readonly identity: string; - readonly paths: readonly string[]; + readonly entries: readonly { readonly path: string; readonly content: Uint8Array }[]; readonly message: string; }, run: CommandRunner, @@ -546,7 +547,7 @@ describe('R5 production refusal control', (): void => { }); describe('push-on-write publication', (): void => { - it('never commits or pushes an unrelated pre-staged secret-shaped file', async (): Promise => { + it('publishes approved in-memory blobs despite path substitution and excludes unrelated staged secrets', async (): Promise => { const runtime = await loadRuntime('MB-REQ-06 isolated publication index'); const root = tempRoot(); const remote = join(root, 'remote.git'); @@ -571,13 +572,13 @@ describe('push-on-write publication', (): void => { writeFileSync(join(checkout, '.env'), 'TEST_ONLY_SECRET=fixture-value\n'); execFileSync('git', ['-C', checkout, 'add', '-f', '.env']); const approved = join(checkout, 'finding.md'); - writeFileSync(approved, 'approved finding\n'); + writeFileSync(approved, 'TEST_ONLY_SECRET=path-substitution\n'); const evidence = runtime.publishBrainPaths( { root: checkout, identity: 'seat-a', - paths: [approved], + entries: [{ path: approved, content: new TextEncoder().encode('approved finding\n') }], message: 'append approved finding', }, runtime.systemCommandRunner, @@ -599,7 +600,14 @@ describe('push-on-write publication', (): void => { '--cached', '--name-only', ]).toString(); + const committedContent = execFileSync('git', [ + '-C', + checkout, + 'show', + `${evidence.commit}:finding.md`, + ]).toString(); expect(committed.trim().split('\n')).toEqual(['finding.md']); + expect(committedContent).toBe('approved finding\n'); expect(staged.trim().split('\n')).toContain('.env'); expect(evidence.reachable).toBe(true); }); @@ -613,6 +621,12 @@ describe('push-on-write publication', (): void => { const runner: CommandRunner = (request): CommandResult => { requests.push(request); const command = request.args.join(' '); + if (command.includes('hash-object')) { + return { status: 0, stdout: `${'f'.repeat(40)}\n`, stderr: '' }; + } + if (command.includes('rev-parse') && request.args.at(-1)?.includes(':')) { + return { status: 0, stdout: `${'f'.repeat(40)}\n`, stderr: '' }; + } if (command.includes('rev-parse HEAD')) return { status: 0, stdout: `${commit}\n`, stderr: '' }; if (command.includes('rev-parse origin/main')) { @@ -638,7 +652,10 @@ describe('push-on-write publication', (): void => { { root, identity: 'seat-a', - paths: [join(root, '.gitignore'), join(root, 'lanes', 'lane-a', '.gitkeep')], + entries: [ + { path: join(root, '.gitignore'), content: new TextEncoder().encode('*.token\n') }, + { path: join(root, 'lanes', 'lane-a', '.gitkeep'), content: new Uint8Array() }, + ], message: 'migrate lane-a state', }, runner, @@ -648,7 +665,11 @@ describe('push-on-write publication', (): void => { const rendered = requests.map((request) => `${request.program} ${request.args.join(' ')}`); expect(rendered).toEqual( expect.arrayContaining([ - expect.stringMatching(/git -C .* add -- \.gitignore lanes\/lane-a\/\.gitkeep/), + expect.stringMatching(/git -C .* hash-object -w --stdin/), + expect.stringMatching(/git -C .* update-index --add --cacheinfo 100644 .* \.gitignore/), + expect.stringMatching( + /git -C .* update-index --add --cacheinfo 100644 .* lanes\/lane-a\/\.gitkeep/, + ), expect.stringMatching( /git -C .* -c user\.name=seat-a -c user\.email=seat-a@fleet\.mosaicstack\.dev commit/, ), @@ -679,12 +700,23 @@ describe('push-on-write publication', (): void => { { root, identity: 'seat-a', - paths: [join(root, 'lanes', 'lane-a', 'findings', 'host-a.md')], + entries: [ + { + path: join(root, 'lanes', 'lane-a', 'findings', 'host-a.md'), + content: new TextEncoder().encode('finding\n'), + }, + ], message: 'append host-a finding', }, (request): CommandResult => { requests.push(request); const command = request.args.join(' '); + if (command.includes('hash-object')) { + return { status: 0, stdout: `${'f'.repeat(40)}\n`, stderr: '' }; + } + if (command.includes('rev-parse') && request.args.at(-1)?.includes(':')) { + return { status: 0, stdout: `${'f'.repeat(40)}\n`, stderr: '' }; + } if (command.includes('push origin HEAD:main')) { pushes += 1; return pushes === 1 @@ -749,12 +781,20 @@ describe('push-on-write publication', (): void => { { root, identity: 'seat-a', - paths: [join(root, 'lanes', 'lane-a', 'already-present.md')], + entries: [ + { + path: join(root, 'lanes', 'lane-a', 'already-present.md'), + content: new TextEncoder().encode('existing\n'), + }, + ], message: 'append existing finding', }, (request): CommandResult => { requests.push(request); const command = request.args.join(' '); + if (command.includes('hash-object')) { + return { status: 0, stdout: `${'f'.repeat(40)}\n`, stderr: '' }; + } if (command.includes(' commit ')) { return { status: 1, stdout: '', stderr: 'nothing to commit' }; } @@ -782,11 +822,19 @@ describe('push-on-write publication', (): void => { { root, identity: 'seat-a', - paths: [join(root, '.gitignore')], + entries: [ + { path: join(root, '.gitignore'), content: new TextEncoder().encode('*.token\n') }, + ], message: 'seed brain', }, (request): CommandResult => { const command = request.args.join(' '); + if (command.includes('hash-object')) { + return { status: 0, stdout: `${'f'.repeat(40)}\n`, stderr: '' }; + } + if (command.includes('rev-parse') && request.args.at(-1)?.includes(':')) { + return { status: 0, stdout: `${'f'.repeat(40)}\n`, stderr: '' }; + } if (command.includes('rev-parse HEAD')) { return { status: 0, stdout: `${commit}\n`, stderr: '' }; } diff --git a/packages/mosaic/src/commands/brain-store-runtime.ts b/packages/mosaic/src/commands/brain-store-runtime.ts index 3f679521..f8113aa8 100644 --- a/packages/mosaic/src/commands/brain-store-runtime.ts +++ b/packages/mosaic/src/commands/brain-store-runtime.ts @@ -13,12 +13,15 @@ import { } from './brain-store.js'; const COMMIT = /^[0-9a-f]{40}$/; +const GIT_OBJECT = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; +const MAX_PUBLISH_ENTRY_BYTES = 1024 * 1024; export interface CommandRequest { readonly program: 'git' | 'mosaic'; readonly args: readonly string[]; readonly cwd?: string; readonly env: Readonly>; + readonly stdin?: Uint8Array; } export interface CommandResult { @@ -35,6 +38,7 @@ export const systemCommandRunner: CommandRunner = (request: CommandRequest): Com env: { ...process.env, ...request.env }, encoding: 'utf8', maxBuffer: 1024 * 1024, + input: request.stdin, }); return { status: result.status ?? 127, @@ -90,8 +94,9 @@ function runGitWithEnv( identity: string, args: readonly string[], env: Readonly>, + stdin?: Uint8Array, ): CommandResult { - return run({ program: 'git', args, env: { ...commandEnv(identity), ...env } }); + return run({ program: 'git', args, env: { ...commandEnv(identity), ...env }, stdin }); } export function collectBrainDoctorReport( @@ -317,14 +322,27 @@ export function publishBrainPaths( input: { readonly root: string; readonly identity: string; - readonly paths: readonly string[]; + readonly entries: readonly { + readonly path: string; + readonly content: Uint8Array; + }[]; readonly message: string; }, run: CommandRunner, ): PublishEvidence { - if (input.paths.length === 0) throw new Error('brain-publish-paths-empty'); + if (input.entries.length === 0) throw new Error('brain-publish-paths-empty'); if (input.message.trim().length === 0) throw new Error('brain-publish-message-empty'); - const paths = input.paths.map((path: string): string => containedRelative(input.root, path)); + const entries = input.entries.map((entry) => { + if (entry.content.byteLength > MAX_PUBLISH_ENTRY_BYTES) { + throw new Error('brain-publish-entry-too-large'); + } + return { + path: containedRelative(input.root, entry.path), + content: Uint8Array.from(entry.content), + }; + }); + const paths = entries.map((entry): string => entry.path); + if (new Set(paths).size !== paths.length) throw new Error('brain-publish-path-duplicate'); const readHead = (): string => { const result = runGit(run, input.identity, ['-C', input.root, 'rev-parse', 'HEAD']); @@ -354,6 +372,19 @@ export function publishBrainPaths( throw new Error('brain-git-commit-identity-mismatch'); } }; + const expectedObjects = new Map(); + const verifyCommitObjects = (commit: string): void => { + for (const [path, expected] of expectedObjects) { + const result = runGit(run, input.identity, [ + '-C', + input.root, + 'rev-parse', + `${commit}:${path}`, + ]); + requireSuccess(result, 'brain-git-read-commit-object'); + if (result.stdout.trim() !== expected) throw new Error('brain-git-commit-content-mismatch'); + } + }; const verifyCommitPaths = (commit: string): void => { const changed = runGit(run, input.identity, [ '-C', @@ -384,10 +415,37 @@ export function publishBrainPaths( runGitWithEnv(run, input.identity, ['-C', input.root, 'read-tree', base], isolatedEnv), 'brain-git-isolated-index-init', ); - requireSuccess( - runGitWithEnv(run, input.identity, ['-C', input.root, 'add', '--', ...paths], isolatedEnv), - 'brain-git-add', - ); + for (const entry of entries) { + const object = runGitWithEnv( + run, + input.identity, + ['-C', input.root, 'hash-object', '-w', '--stdin'], + isolatedEnv, + entry.content, + ); + requireSuccess(object, 'brain-git-write-approved-object'); + const objectId = object.stdout.trim(); + if (!GIT_OBJECT.test(objectId)) throw new Error('brain-git-object-shape-invalid'); + expectedObjects.set(entry.path, objectId); + requireSuccess( + runGitWithEnv( + run, + input.identity, + [ + '-C', + input.root, + 'update-index', + '--add', + '--cacheinfo', + '100644', + objectId, + entry.path, + ], + isolatedEnv, + ), + 'brain-git-stage-approved-object', + ); + } const difference = runGitWithEnv( run, input.identity, @@ -416,6 +474,7 @@ export function publishBrainPaths( ); commit = readHead(); verifyCommitPaths(commit); + verifyCommitObjects(commit); verifyCommitIdentity(commit); createdCommit = true; } else if (difference.status !== 0) { @@ -453,6 +512,7 @@ export function publishBrainPaths( ); commit = readHead(); verifyCommitPaths(commit); + verifyCommitObjects(commit); verifyCommitIdentity(commit); } if (!pushed) throw new Error('brain-git-push-failed'); diff --git a/packages/mosaic/src/commands/brain-store.spec.ts b/packages/mosaic/src/commands/brain-store.spec.ts index eb0312d2..dfaa1bba 100644 --- a/packages/mosaic/src/commands/brain-store.spec.ts +++ b/packages/mosaic/src/commands/brain-store.spec.ts @@ -137,7 +137,10 @@ interface BrainStoreModule { ): MigrationPlan; migrateBrainState( plan: MigrationPlan, - publish: (brainRoot: string, paths: readonly string[]) => MigrationPublishEvidence, + publish: ( + brainRoot: string, + entries: readonly { readonly path: string; readonly content: Uint8Array }[], + ) => MigrationPublishEvidence, brainRoot: string, hooks?: { readonly beforeDestinationWrite?: (destination: string) => void; @@ -758,8 +761,8 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v const result = sut.migrateBrainState( plan, - (_publishedRoot, paths): MigrationPublishEvidence => { - publishedPaths = paths; + (_publishedRoot, entries): MigrationPublishEvidence => { + publishedPaths = entries.map((entry): string => entry.path); return { commit: 'a'.repeat(40), remoteHead: 'a'.repeat(40), reachable: true }; }, brainRoot, diff --git a/packages/mosaic/src/commands/brain-store.ts b/packages/mosaic/src/commands/brain-store.ts index 9f7e3f7f..a314d96e 100644 --- a/packages/mosaic/src/commands/brain-store.ts +++ b/packages/mosaic/src/commands/brain-store.ts @@ -150,6 +150,11 @@ export interface MigrationPlan { readonly owner: MigrationOwnerResolution['principal']; } +export interface MigrationPublishEntry { + readonly path: string; + readonly content: Uint8Array; +} + export interface MigrationPublishEvidence { readonly commit: string; readonly remoteHead: string; @@ -348,8 +353,12 @@ function syncFile(path: string): void { } } -export function createBrainSkeleton(root: string): { readonly created: readonly string[] } { +export function createBrainSkeleton(root: string): { + readonly created: readonly string[]; + readonly publicationEntries: readonly MigrationPublishEntry[]; +} { const created: string[] = []; + const publicationEntries: MigrationPublishEntry[] = []; if (existsSync(root)) { const rootStatus = lstatSync(root); if (!rootStatus.isDirectory() || rootStatus.isSymbolicLink()) { @@ -378,6 +387,7 @@ export function createBrainSkeleton(root: string): { readonly created: readonly writeFileSync(placeholder, '', { encoding: 'utf8', mode: 0o644, flag: 'wx' }); syncFile(placeholder); created.push(placeholder); + publicationEntries.push({ path: placeholder, content: new Uint8Array() }); } else if (!lstatSync(placeholder).isFile() || lstatSync(placeholder).isSymbolicLink()) { throw new Error('brain-layout-placeholder-unsafe'); } @@ -406,8 +416,9 @@ export function createBrainSkeleton(root: string): { readonly created: readonly syncFile(temporary); renameSync(temporary, ignorePath); created.push(ignorePath); + publicationEntries.push({ path: ignorePath, content: new TextEncoder().encode(content) }); } - return { created }; + return { created, publicationEntries }; } function indeterminate(reasonCode: string): CredentialAssessment { @@ -945,7 +956,10 @@ function copyVerified( export function migrateBrainState( plan: MigrationPlan, - publish: (brainRoot: string, paths: readonly string[]) => MigrationPublishEvidence, + publish: ( + brainRoot: string, + entries: readonly MigrationPublishEntry[], + ) => MigrationPublishEvidence, brainRoot: string, hooks: MigrationHooks = {}, ): MigrationResult { @@ -959,7 +973,7 @@ export function migrateBrainState( } const created: string[] = []; - const published: string[] = []; + const published: MigrationPublishEntry[] = []; let publicationAttempted = false; let evidence: MigrationPublishEvidence; try { @@ -980,7 +994,10 @@ export function migrateBrainState( if (copyVerified(source, candidate.archive, brainRoot, hooks)) { created.push(candidate.archive); } - published.push(candidate.destination, candidate.archive); + published.push( + { path: candidate.destination, content: Uint8Array.from(source.content) }, + { path: candidate.archive, content: Uint8Array.from(source.content) }, + ); } publicationAttempted = true;