diff --git a/docs/scratchpads/1051-mosaic-brain-installer.md b/docs/scratchpads/1051-mosaic-brain-installer.md index 91ce6e5a..d6c92df1 100644 --- a/docs/scratchpads/1051-mosaic-brain-installer.md +++ b/docs/scratchpads/1051-mosaic-brain-installer.md @@ -83,10 +83,10 @@ No explicit token ceiling was supplied. Working cap: 55K tokens for implementati - [x] C1 P5→P7 seam receipt read; no brain implementation is in C1. - [x] RED acceptance set committed at `cf11c6c86abae073d8b02b4014cd5447ba67f12a`; author and committer read back as `be-coder-07` and branch reachability was independently verified by `tl-mosaic`. - [x] Moving-contract REDs observed independently for v1.4 mismatch, R8 prerequisite ordering, owner resolver seam/allowlist, tracked skeleton/no-follow behavior, runtime observation/publication, and provider owner resolution. -- [x] Focused implementation includes secure migration, v1.5 write-differential/subject binding, production Git+API refusal parity, provider-backed durable owner resolution that ignores non-admin `active`, required GLPI standing-process policy, P7 provision orchestration, an internal installer command, and installed `mosaic doctor` wiring. Latest focused result: 93/93 (secure config 4, store 43, runtime 18, owner resolver 16, provision 4, provision command 3, installed doctor 5). +- [x] Focused implementation includes secure migration, v1.5 write-differential/subject binding, production Git+API refusal parity, provider-backed durable owner resolution that ignores non-admin `active`, required GLPI standing-process policy, P7 provision orchestration, an internal installer command, and installed `mosaic doctor` wiring. Latest focused result: 95/95 (secure config 4, store 44, runtime 18, owner resolver 16, provision 5, provision command 3, installed doctor 5). - [x] MC-CRED added the required canonical reverse registry seam `ParsedCredentialEstateRegistry.resolveByHost()` at dependency head `6ca8758f`; current local copies are temporary until dependency integration and the 32-line permissive shim has been removed. - [x] Identity gotcha measured: inline `MOSAIC_GIT_IDENTITY=be-coder-07` controls credential resolution but does not override `user.name`/`user.email` inherited from the linked worktree common-dir config (`coder-mos1`). The first local P7 RED commit was immediately amended before push with command-scoped `GIT_AUTHOR_*` + `GIT_COMMITTER_*`; resulting author and committer both read back as `be-coder-07`. Every subsequent authoring command must carry both identity sets and be verified. -- [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] 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. `.gitignore` is canonical allowlisted content only: an existing noncanonical regular file fails closed and is never merged into publication. Symlinked `.gitignore`, layout directories, and nested migration destinations fail closed; a dirty checkout blocks provisioning before skeleton publication. - [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] Security-critical owner policy/registry reads have direct controls for principal UID ownership, file/ancestor permissions, and descriptor-safe regular-file reads. - [x] Automatic source deletion is parked per the shared-Git-identity governance ruling; remotely reachable snapshots still leave and report every source. diff --git a/packages/mosaic/src/commands/brain-provision.spec.ts b/packages/mosaic/src/commands/brain-provision.spec.ts index 93377cfb..a99dd930 100644 --- a/packages/mosaic/src/commands/brain-provision.spec.ts +++ b/packages/mosaic/src/commands/brain-provision.spec.ts @@ -292,6 +292,63 @@ describe('P7 brain provisioning orchestration', (): void => { expect(requests.some((request) => request.args.includes('grant'))).toBe(false); }); + it('blocks before skeleton publication when the existing checkout is dirty', async (): Promise => { + const provisioner = await loadProvisioner('MB-REQ-06 dirty checkout publication gate'); + const root = tempRoot(); + const input = baseInput(root); + mkdirSync(join(input.root, '.git'), { recursive: true }); + const requests: CommandRequest[] = []; + + const result = await provisioner.provisionBrain(input, { + run: (request): CommandResult => { + requests.push(request); + if (request.program === 'mosaic') { + return request.args[2] === 'outside-seat' + ? { + status: 10, + stdout: validateResult('refused', 'no-token-for-identity', 'outside-seat'), + stderr: 'refused reason=no-token-for-identity', + } + : { status: 0, stdout: validateResult('ok', 'validation-verified'), stderr: '' }; + } + const command = request.args.join(' '); + if (command.includes('ls-remote')) { + return { + status: 128, + stdout: '', + stderr: 'credential helper refused reason=no-token-for-identity', + }; + } + if (command.includes('rev-parse --is-inside-work-tree')) { + return { status: 0, stdout: 'true\n', stderr: '' }; + } + if (command.includes('remote get-url origin')) { + return { + status: 0, + stdout: 'https://git.example.invalid/durable-owner/mosaic-brain.git\n', + stderr: '', + }; + } + if (command.includes('branch --show-current')) { + return { status: 0, stdout: 'main\n', stderr: '' }; + } + if (command.includes('status --porcelain')) { + return { status: 0, stdout: '?? .gitignore\n', stderr: '' }; + } + return { status: 99, stdout: '', stderr: 'unexpected publication command' }; + }, + fetch: ownerFetch(), + absentControlName: (): string => 'generated-absent-control', + }); + + expect(result).toMatchObject({ + status: 'blocked', + reasonCode: 'brain-postcondition-failed', + }); + expect(requests.some((request) => request.args.includes('commit'))).toBe(false); + expect(requests.some((request) => request.args.includes('push'))).toBe(false); + }); + it('clones, seeds, resolves owner, migrates, pushes on each write, and archives source only after reachability', async (): Promise => { const provisioner = await loadProvisioner('MB-REQ-07 complete migration transaction'); const root = tempRoot(); diff --git a/packages/mosaic/src/commands/brain-provision.ts b/packages/mosaic/src/commands/brain-provision.ts index 7fed8ff3..8889a153 100644 --- a/packages/mosaic/src/commands/brain-provision.ts +++ b/packages/mosaic/src/commands/brain-provision.ts @@ -175,6 +175,8 @@ export async function provisionBrain( finding.code === 'brain-not-git-repository' || finding.code === 'brain-remote-mismatch' || finding.code === 'brain-branch-mismatch' || + finding.code === 'brain-uncommitted-state' || + finding.code === 'brain-git-state-indeterminate' || finding.code.startsWith('brain-write-access-'), ); if (blockingCloneFindings.length > 0) { diff --git a/packages/mosaic/src/commands/brain-store.spec.ts b/packages/mosaic/src/commands/brain-store.spec.ts index 2d8a81d3..9590f403 100644 --- a/packages/mosaic/src/commands/brain-store.spec.ts +++ b/packages/mosaic/src/commands/brain-store.spec.ts @@ -359,6 +359,16 @@ describe('R6 — brain layout refuses secret material', (): void => { ); }); + it('refuses existing noncanonical gitignore content instead of publishing it', async (): Promise => { + const sut = await loadSut('MB-REQ-06 unapproved gitignore content'); + const root = tempRoot(); + const ignore = join(root, '.gitignore'); + writeFileSync(ignore, '# token=fixture-value\n*.token\n'); + + expect(() => sut.createBrainSkeleton(root)).toThrow(/brain-layout-ignore-content-unsafe/); + expect(readFileSync(ignore, 'utf8')).toBe('# token=fixture-value\n*.token\n'); + }); + it('refuses a symlinked layout directory without writing a tracked placeholder outside the brain', async (): Promise => { const sut = await loadSut('MB-REQ-06 no-follow brain layout'); const root = tempRoot(); diff --git a/packages/mosaic/src/commands/brain-store.ts b/packages/mosaic/src/commands/brain-store.ts index 77599016..72389625 100644 --- a/packages/mosaic/src/commands/brain-store.ts +++ b/packages/mosaic/src/commands/brain-store.ts @@ -10,7 +10,6 @@ import { openSync, readdirSync, readFileSync, - renameSync, rmSync, unlinkSync, writeFileSync, @@ -400,21 +399,22 @@ export function createBrainSkeleton(root: string): { throw new Error('brain-layout-ignore-unsafe'); } } - const existing = existsSync(ignorePath) - ? readFileSync(ignorePath, 'utf8') - .split(/\r?\n/) - .filter((line: string): boolean => line.length > 0) - : []; - const merged = [...existing]; - for (const rule of GITIGNORE_RULES) { - if (!merged.includes(rule)) merged.push(rule); - } - const content = `${merged.join('\n')}\n`; - if (!existsSync(ignorePath) || readFileSync(ignorePath, 'utf8') !== content) { + const content = `${GITIGNORE_RULES.join('\n')}\n`; + if (existsSync(ignorePath)) { + const snapshot = stableSourceSnapshot(ignorePath); + if (!snapshot.content.equals(Buffer.from(content, 'utf8'))) { + throw new Error('brain-layout-ignore-content-unsafe'); + } + } else { const temporary = `${ignorePath}.tmp-${process.pid}-${randomUUID()}`; - writeFileSync(temporary, content, { encoding: 'utf8', mode: 0o644, flag: 'wx' }); - syncFile(temporary); - renameSync(temporary, ignorePath); + try { + writeFileSync(temporary, content, { encoding: 'utf8', mode: 0o644, flag: 'wx' }); + syncFile(temporary); + linkSync(temporary, ignorePath); + syncFile(ignorePath); + } finally { + rmSync(temporary, { force: true }); + } created.push(ignorePath); publicationEntries.push({ path: ignorePath, content: new TextEncoder().encode(content) }); }