From 2451c2f21a5f55b715cb015ad2b01b6c70460639 Mon Sep 17 00:00:00 2001 From: be-coder-07 Date: Wed, 5 Aug 2026 13:24:55 -0500 Subject: [PATCH] feat(mosaic): provision per-estate durable brain --- docs/PRD.md | 6 +- .../1051-mosaic-brain-installer.md | 36 +- packages/mosaic/src/cli.ts | 5 + .../src/commands/brain-doctor-check.spec.ts | 272 +++++ .../mosaic/src/commands/brain-doctor-check.ts | 161 +++ .../src/commands/brain-owner-resolver.spec.ts | 304 ++++++ .../src/commands/brain-owner-resolver.ts | 255 +++++ .../commands/brain-provision-command.spec.ts | 122 +++ .../src/commands/brain-provision-command.ts | 139 +++ .../src/commands/brain-provision.spec.ts | 458 +++++++++ .../mosaic/src/commands/brain-provision.ts | 247 +++++ .../src/commands/brain-store-runtime.spec.ts | 715 +++++++++++++ .../src/commands/brain-store-runtime.ts | 393 +++++++ .../mosaic/src/commands/brain-store.spec.ts | 405 ++++++-- packages/mosaic/src/commands/brain-store.ts | 955 ++++++++++++++++++ packages/mosaic/src/commands/launch.ts | 31 +- 16 files changed, 4423 insertions(+), 81 deletions(-) create mode 100644 packages/mosaic/src/commands/brain-doctor-check.spec.ts create mode 100644 packages/mosaic/src/commands/brain-doctor-check.ts create mode 100644 packages/mosaic/src/commands/brain-owner-resolver.spec.ts create mode 100644 packages/mosaic/src/commands/brain-owner-resolver.ts create mode 100644 packages/mosaic/src/commands/brain-provision-command.spec.ts create mode 100644 packages/mosaic/src/commands/brain-provision-command.ts create mode 100644 packages/mosaic/src/commands/brain-provision.spec.ts create mode 100644 packages/mosaic/src/commands/brain-provision.ts create mode 100644 packages/mosaic/src/commands/brain-store-runtime.spec.ts create mode 100644 packages/mosaic/src/commands/brain-store-runtime.ts create mode 100644 packages/mosaic/src/commands/brain-store.ts diff --git a/docs/PRD.md b/docs/PRD.md index 8fb7c2cf..94cb6ace 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -104,18 +104,18 @@ In scope now: estate derivation, secret exclusion, non-destructive migration, do ### Acceptance criteria -1. `AC-MB-01`: Contract tests observe RED before implementation and then distinguish `ok/0`, `refused/10`, `error/20`, and `indeterminate/30`, preserving stable reason codes including `identity-not-found`, `credential-rejected`, and `provider-unavailable`. +1. `AC-MB-01`: Contract tests observe RED before implementation and then distinguish `ok/0`, `refused/10`, `error/20`, and `indeterminate/30`, preserving v1.5 diagnoses including refused `provider-identity-mismatch`/`credential-rejected` and indeterminate `identity-not-measured`/`provider-unavailable`. A scope-forbidden `/user` result with confirmed in-scope repository capability is never represented as a dead credential. `identity-not-found` remains reserved for a future visibility-authorized inventory operation and is not an expected `validate` result. 2. `AC-MB-02`: Estate resolution uses the configured target git host and one registry; unknown, mismatched, and host-machine-derived inputs fail closed. 3. `AC-MB-03`: A clean fixture contains the required layout and exact secret exclusions, and seeded secret-shaped files remain ignored without their values appearing in output. 4. `AC-MB-04`: Migration moves lane-durable and seat-state content into collision-safe archive/ledger paths, preserves source on any incomplete move, never overwrites an existing finding, never deletes by age/size, and reports unresolved items explicitly. 5. `AC-MB-05`: Doctor detects all four R8 defect classes; `--fix` repairs eligible classes through the approved P7/broker seam and leaves unresolved credential-dependent states visible. 6. `AC-MB-06`: Git-axis and API-axis refusal must both be authoritative `refused` outcomes with matching stable reason codes; any disagreement yields `indeterminate`. 7. `AC-MB-07`: Independent code review and security review pass at the exact head, and HOMELAB Woodpecker instance `mosaic` is terminal green before integration. -8. `AC-MB-08`: Integration into `next` is reported only as **believed-fixed, pending validation AND pending promotion to `main`**; issue #1051 remains open for #1037 promotion and W-jarvis validation. +8. `AC-MB-08`: After reviewed merge to `main`, report only **believed-fixed, pending jarvis validation**; issue #1051 remains open until W-jarvis validates the installed result. ### Constraints and risks -- MC-CRED-01 contract v1.3 is the caller boundary; no independent credential/token lookup is permitted. +- MC-CRED-01 contract v1.5 is the caller boundary; no independent credential/token lookup is permitted. Identity is established from governed mint-time binding and provider evidence when measurable, never a credential filename. Runtime validation does not widen a least-privilege token merely to make `/user` observable. - C1 owns installer phase sequencing. This slice consumes P5/P7 ordering without renumbering or duplicating the phase machine. - Lane content is findings, so last-writer-wins is data loss. Append-only names and explicit collision handling are mandatory. - A created-but-empty brain beside unbacked local doctrine fails the objective; migration is a primary acceptance gate. diff --git a/docs/scratchpads/1051-mosaic-brain-installer.md b/docs/scratchpads/1051-mosaic-brain-installer.md index 9b1885b1..bdf7daba 100644 --- a/docs/scratchpads/1051-mosaic-brain-installer.md +++ b/docs/scratchpads/1051-mosaic-brain-installer.md @@ -10,9 +10,9 @@ Codify estate-derived, repository-backed `~/.mosaic` support with secret exclusi - Provider issue: HOMELAB `git.mosaicstack.dev`, `GET /api/v1/repos/mosaicstack/stack/issues/1051`, `application/json;charset=utf-8`. - Issue requirements: R1–R8 read directly on 2026-08-05. -- MC-CRED caller contract: v1.3, SHA-256 `8cfa4853d2b0b0e8cc9e792fa8411310e16d7704c06e0af9d9a57155131d8086` at intake. +- MC-CRED caller contract: v1.5, SHA-256 `4cecba3386b37431d4a075205c6dfe43555c7673922fed61b84f43cac1a6ae92` at the 2026-08-05 re-derivation. Earlier moving bindings were v1.5 `710d22d61a93a4b9c70fc55506a023a675a110417fa7a6e72dc051c0d9fe8237`, v1.4 `27f20158561ae8292f3bfc926b5e97f398de93db6a1cf65fcc215d08811d39af`/`d12ad4595b7aef078e392988a07ab5cb00244440775c9c733dc825746d7ac67b`, and v1.3 `8cfa4853d2b0b0e8cc9e792fa8411310e16d7704c06e0af9d9a57155131d8086`. - Fleet doctrine: SHA-256 `026b43322e0551ef15b646a9f30d3a6aef58c662a810b732be2a03b1ecf7d36e` at intake. -- Base: HOMELAB provider `next` = `4df478cdd150fdf8d52ea109f02ade5d85017acd`; `main` = `5916aeefd6ed12bcac086c6834c7f6c4ae38e1bc`; provider branch objects matched fetched refs and `main` is reachable from `next`. +- Intake base was HOMELAB provider `next` = `4df478cdd150fdf8d52ea109f02ade5d85017acd`; `main` = `5916aeefd6ed12bcac086c6834c7f6c4ae38e1bc`. On 2026-08-05 `mos-claude` ruled that L0 trunk-based gate 15 requires all three lanes to retarget to `main`; `next` remains a non-merging integration branch. Never weaken or patch `pr-merge.sh`. ## Scope @@ -33,6 +33,15 @@ Codify estate-derived, repository-backed `~/.mosaic` support with secret exclusi - No phase renumbering; C1 owns the phase machine and provides the P5→P7 seam. - No age/size reaping or deletion. +## Owner authority ruling and resolver seam + +- Binding addendum: `/home/hermes/agent-work/tl-mosaic/CHARTER-MB-BRAIN-01-ADDENDUM.md`; re-read after compaction. +- HOMELAB durable lane-archive owner and user-namespace brain owner are the human provider account selected by local estate policy (operator ruling: `jason.woltje`) with a required GLPI queue as the standing remediation process. The brain target is therefore `/mosaic-brain` on the estate host, not `/mosaic-brain`. Framework source remains operator-agnostic: the actual login and queue are local policy, not hardcoded open-source context. +- Provider lookup is anonymous because the ruled owner is public. It requires exact allowlisted login plus a same-invocation public known-good control, private 404 control, and generated absent 404 control. It sends no Authorization header and never widens token scope. +- Provider `active` is deliberately ignored: non-admin reads return false for demonstrably active accounts. Resolvability + exact login + public visibility are the gate. +- Private and absent principals both return anonymous 404. The fail-closed reason is `owner-not-resolvable`, never owner-not-found. +- Caller `owner` strings and `validated=true` are ignored. Migration consumes only an injected source-of-truth resolver result. Owner grammar is NFKC-stable, ASCII allowlisted, exact-policy matched, and mission-seat class is excluded. + ## Plan 1. Pre-register acceptance tests and observe each requirement RED for its own missing behavior. @@ -42,7 +51,7 @@ Codify estate-derived, repository-backed `~/.mosaic` support with secret exclusi 5. Integrate the helper into C1's P7 seam and `mosaic doctor` after C1 lands/rebase. 6. Run focused, package, installer, lint, typecheck, format, and situational security tests. 7. Run independent code and security reviews in parallel; remediate and re-review. -8. Push after HOMELAB queue guard, open PR to `next`, and wait for merge order C1 → MC-CRED → MB-BRAIN. +8. Push after HOMELAB queue guard, open the reviewed PR to `main`, and preserve merge order C1 → MC-CRED → MB-BRAIN. Do not modify the merge guard; `next` is non-merging integration only. 9. Re-take CI measurement at the rebased exact head; do not rework code solely because base evidence moved. ## Acceptance interpretation registered before results @@ -51,7 +60,7 @@ Codify estate-derived, repository-backed `~/.mosaic` support with secret exclusi - `refused/10`: complete authoritative denial only. - `error/20`: local contract/control failure; never reinterpret as denial. - `indeterminate/30`: incomplete/disagreeing evidence; fail closed, never resolve permissively. -- Both Git and API axes must return authoritative `refused` with the same stable reason code for R5. Any axis disagreement is `indeterminate`. +- Both Git and API axes must return authoritative `refused` with the same stable reason code for R5. Any axis disagreement is `indeterminate`. A provider `/user` login mismatch is first-class `provider-identity-mismatch`; credential filenames never establish principal identity. - Migration success requires the durable object to contain the moved item and no overwrite; incomplete moves retain the source and are reported. - Secret exclusion is tested through both exact ignore rules and seeded secret-shaped controls; output is scanned without printing secret values. @@ -61,7 +70,7 @@ No explicit token ceiling was supplied. Working cap: 55K tokens for implementati ## Risks -- C1 and MC-CRED branches have not merged into `next`; integration edits must wait for their exact interfaces or be confined to stable contract seams. +- C1 and MC-CRED branches have not merged into `main`; integration edits must wait for their exact interfaces or be confined to stable contract seams. - A broker runtime test before MC-CRED lands would either fail for an irrelevant reason or pressure a hand-rolled workaround; contract fixtures are allowed, live capability claims are not. - Migration can lose data through overwrite, cross-device move failure, or partial copy. Implementation must stage, verify resulting bytes, and retain/report source on incomplete transfer. - `~/.mosaic` is a git repo, while current working state may live under multiple local roots; detection must be explicit and cannot treat age/size as ownership. @@ -70,15 +79,22 @@ No explicit token ceiling was supplied. Working cap: 55K tokens for implementati - [x] Charter receipt accepted by `tl-mosaic`. - [x] Issue #1051 R1–R8 read directly from provider. -- [x] Contract re-derived at v1.3. +- [x] Contract re-derived through v1.3, moving v1.4, and v1.5 before R5 integration. v1.5 separates in-scope repository capability from `/user` identity measurement: 401 is `credential-rejected`/refused, 403/404 may become `identity-not-measured` only after in-scope capability succeeds, and 200 login mismatch is refused. `identity-not-found` is not reachable from `validate`. - [x] C1 P5→P7 seam receipt read; no brain implementation is in C1. -- [ ] RED acceptance set committed. -- [ ] Implementation green. +- [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: 76/76 (store 38, runtime 12, owner resolver 14, provision 4, 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 now reports secret-shaped files without copying them; symlinked `.gitignore`, layout directories, and nested migration destinations fail closed. +- [x] Multi-host push-on-write retries non-fast-forward races via fetch/rebase rather than LWW, and unknown publication reachability retains both sources and local copies. +- [ ] 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. - [ ] Independent security review. - [ ] HOMELAB CI terminal green at exact head. -- [ ] Integrated to `next` after C1 and MC-CRED. +- [ ] Reviewed PR retargeted to `main` after C1 and MC-CRED; `next` remains non-merging integration only. ## Completion language -Only: **believed-fixed, pending validation AND pending promotion to `main`**. Issue #1051 remains open; #1037 is the promotion vehicle and W-jarvis is the external validator. +After reviewed merge to `main`, only: **believed-fixed, pending jarvis validation**. Issue #1051 remains open until W-jarvis validates the installed result. diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index 958cb3d0..13a844b0 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -23,6 +23,7 @@ import { registerSkillCommand } from './commands/skill.js'; import { registerLaunchCommands } from './commands/launch.js'; import { registerLeaseCapabilityProbe } from './commands/lease-activation-probe.js'; import { registerInstallOrderingGuardCommand } from './commands/install-ordering-guard.js'; +import { registerBrainProvisionCommand } from './commands/brain-provision-command.js'; import { registerAuthCommand } from './commands/auth.js'; import { registerFederationCommand } from './commands/federation.js'; import { registerGatewayCommand } from './commands/gateway.js'; @@ -85,6 +86,10 @@ registerLeaseCapabilityProbe(program); registerInstallOrderingGuardCommand(program); +// ─── durable brain P7 provisioner (hidden; #1051) ─────────────────────── + +registerBrainProvisionCommand(program); + // ─── login ────────────────────────────────────────────────────────────── program diff --git a/packages/mosaic/src/commands/brain-doctor-check.spec.ts b/packages/mosaic/src/commands/brain-doctor-check.spec.ts new file mode 100644 index 00000000..af9a2d2e --- /dev/null +++ b/packages/mosaic/src/commands/brain-doctor-check.spec.ts @@ -0,0 +1,272 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +interface CommandRequest { + readonly program: 'git' | 'mosaic'; + readonly args: readonly string[]; + readonly env: Readonly>; +} + +interface CommandResult { + readonly status: number; + readonly stdout: string; + readonly stderr: string; +} + +interface InstalledDoctorResult { + readonly status: 'ok' | 'warn' | 'error'; + readonly findings: readonly { + readonly code: string; + readonly reasonCode: string | null; + }[]; + readonly lines: readonly string[]; +} + +interface BrainDoctorModule { + runInstalledBrainDoctorCheck( + options: { + readonly mosaicHome: string; + readonly home: string; + readonly identity?: string; + readonly fix: boolean; + }, + run: (request: CommandRequest) => CommandResult, + ): InstalledDoctorResult; +} + +const MODULE_PATH = './brain-doctor-check.js'; +const roots: string[] = []; + +async function loadDoctor(requirement: string): Promise { + try { + return (await import(MODULE_PATH)) as BrainDoctorModule; + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`${requirement}: installed brain doctor check is absent (${detail})`); + } +} + +function tempRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'mosaic-brain-doctor-')); + roots.push(root); + return root; +} + +function installConfig(root: string): { readonly home: string; readonly mosaicHome: string } { + const home = join(root, 'home'); + const mosaicHome = join(home, '.config', 'mosaic'); + mkdirSync(join(mosaicHome, 'cred'), { recursive: true }); + mkdirSync(join(mosaicHome, 'brain'), { recursive: true }); + writeFileSync( + join(mosaicHome, 'cred', 'estates.json'), + JSON.stringify({ + version: 1, + estates: [ + { + name: 'homelab', + readOnlyControlIdentity: 'read-control', + hosts: [ + { + host: 'git.example.invalid', + provider: 'gitea', + apiBaseUrl: 'https://git.example.invalid', + tokenPrefix: 'gitea-example', + }, + ], + }, + ], + }), + { mode: 0o600 }, + ); + writeFileSync( + join(mosaicHome, 'brain', 'owners.json'), + JSON.stringify({ + version: 1, + estates: [ + { + estate: 'homelab', + laneArchiveOwners: [{ kind: 'provider-user', login: 'durable-owner' }], + standingProcess: { kind: 'glpi-queue', queue: 'mosaic-brain-remediation' }, + controls: { publicIdentity: 'public-control', privateIdentity: 'private-control' }, + }, + ], + }), + { mode: 0o600 }, + ); + writeFileSync( + join(mosaicHome, '.install-manifest.json'), + JSON.stringify({ + version: 2, + status: 'committed', + sourceRepo: 'https://git.example.invalid/example/stack.git', + }), + { mode: 0o600 }, + ); + return { home, mosaicHome }; +} + +function validateResult(outcome: 'ok' | 'refused' | 'indeterminate', reasonCode: string): string { + const exitCode = outcome === 'ok' ? 0 : outcome === 'refused' ? 10 : 30; + return JSON.stringify({ + schemaVersion: 1, + operation: 'validate', + outcome, + exitCode, + retryable: false, + subject: { + identity: 'seat-a', + estate: 'homelab', + host: 'git.example.invalid', + repo: 'durable-owner/mosaic-brain', + }, + mutation: 'none', + reason: { code: reasonCode, message: 'non-secret' }, + evidence: { + providerIdentity: + outcome === 'ok' + ? { + login: 'seat-a', + endpoint: 'GET /api/v1/user', + contentType: 'application/json', + } + : null, + repositoryPermission: + outcome === 'ok' + ? { + requested: 'write', + effective: 'write', + endpoint: 'GET /api/v1/repos/durable-owner/mosaic-brain', + contentType: 'application/json', + } + : null, + writeDifferential: + outcome === 'ok' + ? { + state: 'can-write', + credentialBinding: 'same-resolution', + transportPrincipal: 'seat-a', + authenticatedReceivePack: 'advertised', + readOnlyControl: { + identity: 'read-control', + providerPermission: 'read', + receivePack: 'refused', + }, + unauthenticatedReceivePack: 'refused', + artifactCreated: false, + proves: 'non-secret evidence', + doesNotProve: 'branch update acceptance', + } + : null, + }, + audit: { journalId: 'opaque', state: 'sealed' }, + }); +} + +afterEach((): void => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe('installed mosaic doctor brain checks', (): void => { + it('derives the target from the committed install manifest and surfaces a missing clone plus refusal', async (): Promise => { + const doctor = await loadDoctor('MB-REQ-08 installed doctor missing clone'); + const config = installConfig(tempRoot()); + const requests: CommandRequest[] = []; + + const result = doctor.runInstalledBrainDoctorCheck( + { ...config, identity: 'seat-a', fix: false }, + (request): CommandResult => { + requests.push(request); + return { + status: 10, + stdout: validateResult('refused', 'no-token-for-identity'), + stderr: 'refused reason=no-token-for-identity', + }; + }, + ); + + expect(result.status).toBe('warn'); + expect(result.findings.map((finding) => finding.code)).toEqual( + expect.arrayContaining(['brain-clone-missing', 'brain-write-access-refused']), + ); + expect(result.lines.join('\n')).toMatch(/brain-clone-missing/); + expect(result.lines.join('\n')).toMatch(/no-token-for-identity/); + expect(requests[0]?.args).toContain('durable-owner/mosaic-brain'); + }); + + it('fails closed without an explicit identity and performs no command', async (): Promise => { + const doctor = await loadDoctor('MB-REQ-08 explicit identity'); + const config = installConfig(tempRoot()); + let calls = 0; + + const result = doctor.runInstalledBrainDoctorCheck( + { ...config, fix: false }, + (): CommandResult => { + calls += 1; + return { status: 0, stdout: '', stderr: '' }; + }, + ); + + expect(result).toMatchObject({ + status: 'error', + findings: [{ code: 'brain-identity-required', reasonCode: 'identity-required' }], + }); + expect(calls).toBe(0); + }); + + it('treats identity-not-measured as an error, not no-write refusal and not a repairable grant case', async (): Promise => { + const doctor = await loadDoctor('MB-REQ-08 identity measurement axis'); + const config = installConfig(tempRoot()); + const requests: CommandRequest[] = []; + + const result = doctor.runInstalledBrainDoctorCheck( + { ...config, identity: 'seat-a', fix: true }, + (request): CommandResult => { + requests.push(request); + return { + status: 30, + stdout: validateResult('indeterminate', 'identity-not-measured'), + stderr: 'identity not measured', + }; + }, + ); + + expect(result.status).toBe('error'); + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'brain-write-access-indeterminate', + reasonCode: 'identity-not-measured', + }), + ]), + ); + expect(requests.some((request) => request.args.includes('grant'))).toBe(false); + }); + + it('is wired into the top-level mosaic doctor path before the shell audit runs', (): void => { + const launch = readFileSync(join(process.cwd(), 'src', 'commands', 'launch.ts'), 'utf8'); + + expect(launch).toContain('runInstalledBrainDoctorCheck'); + expect(launch).toContain('defaultInstalledBrainDoctorOptions'); + expect(launch).toContain('systemCommandRunner'); + expect(launch).toMatch(/brainCheckFailed[\s\S]*runDoctorScriptAndExit/); + }); + + it('reports a missing or unsafe registry/manifest as configuration error rather than defaulting estate', async (): Promise => { + const doctor = await loadDoctor('MB-REQ-02 missing mapping fail-closed'); + const root = tempRoot(); + const home = join(root, 'home'); + const mosaicHome = join(home, '.config', 'mosaic'); + mkdirSync(mosaicHome, { recursive: true }); + + const result = doctor.runInstalledBrainDoctorCheck( + { home, mosaicHome, identity: 'seat-a', fix: false }, + (): CommandResult => ({ status: 0, stdout: '', stderr: '' }), + ); + + expect(result.status).toBe('error'); + expect(result.findings[0]?.code).toMatch(/brain-(estate-registry|install-manifest)-/); + expect(result.lines.join('\n')).not.toMatch(/homelab|usc/); + }); +}); diff --git a/packages/mosaic/src/commands/brain-doctor-check.ts b/packages/mosaic/src/commands/brain-doctor-check.ts new file mode 100644 index 00000000..028dd3aa --- /dev/null +++ b/packages/mosaic/src/commands/brain-doctor-check.ts @@ -0,0 +1,161 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { z } from 'zod'; +import { readRegularFileSecure } from '../fleet/secure-file.js'; +import { resolveBrainOwnerPolicy } from './brain-owner-resolver.js'; +import { deriveBrainTarget } from './brain-store.js'; +import { + collectBrainDoctorReport, + repairBrainDoctor, + type CommandRunner, + 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({ + version: z.literal(2), + status: z.literal('committed'), + sourceRepo: z.string().min(1), + }) + .passthrough(); + +export interface InstalledDoctorFinding { + readonly code: string; + readonly reasonCode: string | null; +} + +export interface InstalledDoctorResult { + readonly status: 'ok' | 'warn' | 'error'; + readonly findings: readonly InstalledDoctorFinding[]; + readonly lines: readonly string[]; +} + +function configurationError(code: string, reasonCode = code): InstalledDoctorResult { + return { + status: 'error', + findings: [{ code, reasonCode }], + lines: [`[mosaic-doctor] [ERROR] ${code}`], + }; +} + +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 => ({ + code: finding.code, + reasonCode: finding.reasonCode, + }), + ); + const hard = findings.some( + (finding): boolean => + finding.code.endsWith('-error') || + finding.code.endsWith('-indeterminate') || + finding.code === 'brain-not-git-repository', + ); + const status: InstalledDoctorResult['status'] = + findings.length === 0 ? 'ok' : hard ? 'error' : 'warn'; + const severity = status === 'error' ? 'ERROR' : status === 'warn' ? 'WARN' : 'OK'; + const lines = + findings.length === 0 + ? ['[mosaic-doctor] [OK] mosaic-brain ready'] + : findings.map( + (finding): string => + `[mosaic-doctor] [${severity}] ${finding.code}${ + finding.reasonCode === null ? '' : ` reason=${finding.reasonCode}` + }`, + ); + return { status, findings, lines }; +} + +export function runInstalledBrainDoctorCheck( + options: { + readonly mosaicHome: string; + readonly home: string; + readonly identity?: string; + readonly fix: boolean; + }, + run: CommandRunner, +): InstalledDoctorResult { + if (options.identity === undefined || !IDENTITY.test(options.identity)) { + return configurationError('brain-identity-required', 'identity-required'); + } + + const registryPath = join(options.mosaicHome, 'cred', 'estates.json'); + const manifestPath = join(options.mosaicHome, '.install-manifest.json'); + const ownerPolicyPath = join(options.mosaicHome, 'brain', 'owners.json'); + let registrySource: string; + try { + registrySource = readUtf8(registryPath, options.mosaicHome); + } catch { + return configurationError('brain-estate-registry-unavailable'); + } + let manifestSource: string; + try { + manifestSource = readUtf8(manifestPath, options.mosaicHome); + } catch { + return configurationError('brain-install-manifest-unavailable'); + } + let manifestRaw: unknown; + try { + manifestRaw = JSON.parse(manifestSource); + } catch { + return configurationError('brain-install-manifest-invalid'); + } + const manifest = manifestSchema.safeParse(manifestRaw); + if (!manifest.success) return configurationError('brain-install-manifest-invalid'); + let ownerPolicySource: string; + try { + ownerPolicySource = readUtf8(ownerPolicyPath, options.mosaicHome); + } catch { + return configurationError('brain-owner-policy-unavailable'); + } + let preliminaryTarget: ReturnType; + try { + preliminaryTarget = deriveBrainTarget(registrySource, manifest.data.sourceRepo, 'policy-probe'); + } catch { + return configurationError('brain-estate-registry-invalid'); + } + const ownerPolicy = resolveBrainOwnerPolicy(ownerPolicySource, preliminaryTarget.estate); + if (ownerPolicy === undefined) return configurationError('brain-owner-policy-invalid'); + + const input = { + registrySource, + targetGitUrl: manifest.data.sourceRepo, + brainNamespace: ownerPolicy.brainNamespace, + identity: options.identity, + root: join(options.home, '.mosaic'), + }; + try { + return renderReport( + options.fix ? repairBrainDoctor(input, run) : collectBrainDoctorReport(input, run), + ); + } catch { + return configurationError('brain-estate-registry-invalid'); + } +} + +export function defaultInstalledBrainDoctorOptions(fix: boolean): { + readonly mosaicHome: string; + readonly home: string; + readonly identity?: string; + readonly fix: boolean; +} { + const home = homedir(); + const identity = process.env['MOSAIC_GIT_IDENTITY']; + return { + mosaicHome: process.env['MOSAIC_HOME'] ?? join(home, '.config', 'mosaic'), + home, + ...(identity === undefined ? {} : { identity }), + fix, + }; +} diff --git a/packages/mosaic/src/commands/brain-owner-resolver.spec.ts b/packages/mosaic/src/commands/brain-owner-resolver.spec.ts new file mode 100644 index 00000000..89a9ee22 --- /dev/null +++ b/packages/mosaic/src/commands/brain-owner-resolver.spec.ts @@ -0,0 +1,304 @@ +import { describe, expect, it } from 'vitest'; + +/** + * Red-first owner-authority resolver contract for #1051. + * + * Fixtures are operator-agnostic. The HOMELAB owner name belongs in the local + * estate policy, never in framework source. Anonymous lookup is intentional: + * the ruled owner class is PUBLIC and least-privilege seats may lack read:user. + */ + +interface MigrationOwnerResolution { + readonly verdict: 'resolved' | 'refused' | 'not-measured'; + readonly reasonCode: string; + readonly principal: { + readonly name: string; + readonly kind: 'durable-human'; + } | null; + readonly authority: { + readonly system: 'gitea'; + readonly endpoint: string; + readonly contentType: 'application/json'; + } | null; +} + +type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise; + +interface OwnerResolverModule { + resolveProviderDurableOwner( + input: { + readonly estateRegistrySource: string; + readonly ownerPolicySource: string; + readonly host: string; + readonly requestedOwner: string; + }, + dependencies: { + readonly fetch: FetchLike; + readonly absentControlName: () => string; + }, + ): Promise; +} + +const MODULE_PATH = './brain-owner-resolver.js'; + +async function loadResolver(requirement: string): Promise { + try { + return (await import(MODULE_PATH)) as OwnerResolverModule; + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`${requirement}: brain owner resolver is absent (${detail})`); + } +} + +function estateRegistry(): string { + return JSON.stringify({ + version: 1, + estates: [ + { + name: 'homelab', + readOnlyControlIdentity: 'read-control', + hosts: [ + { + host: 'git.example.invalid', + provider: 'gitea', + apiBaseUrl: 'https://git.example.invalid', + tokenPrefix: 'gitea-example', + }, + ], + }, + ], + }); +} + +function ownerPolicy(): string { + return JSON.stringify({ + version: 1, + estates: [ + { + estate: 'homelab', + laneArchiveOwners: [{ kind: 'provider-user', login: 'durable-owner' }], + standingProcess: { kind: 'glpi-queue', queue: 'mosaic-brain-remediation' }, + controls: { + publicIdentity: 'public-control', + privateIdentity: 'private-control', + }, + }, + ], + }); +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }); +} + +function publicUser(login: string, active = false): Response { + return jsonResponse(200, { + id: 42, + login, + visibility: 'public', + active, + }); +} + +function identityFromUrl(input: string | URL | Request): string { + const value = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + return decodeURIComponent(new URL(value).pathname.split('/').at(-1) ?? ''); +} + +function controlledFetch( + overrides: Readonly> = {}, + calls: Array<{ identity: string; authorization: string | null }> = [], +): FetchLike { + return async (input: string | URL | Request, init?: RequestInit): Promise => { + const identity = identityFromUrl(input); + const headers = new Headers(init?.headers); + calls.push({ identity, authorization: headers.get('authorization') }); + const override = overrides[identity]; + if (override !== undefined) return override.clone(); + if (identity === 'public-control') return publicUser('public-control'); + if (identity === 'private-control' || identity === 'generated-absent-control') { + return jsonResponse(404, { message: 'not found' }); + } + if (identity === 'durable-owner') return publicUser('durable-owner', false); + return jsonResponse(404, { message: 'not found' }); + }; +} + +describe('provider-backed durable owner resolver', (): void => { + it('resolves an allowlisted PUBLIC owner by exact login with public/private/absent controls and ignores active=false', async (): Promise => { + const resolver = await loadResolver('MB-REQ-09 provider owner resolution'); + const calls: Array<{ identity: string; authorization: string | null }> = []; + + const result = await resolver.resolveProviderDurableOwner( + { + estateRegistrySource: estateRegistry(), + ownerPolicySource: ownerPolicy(), + host: 'git.example.invalid', + requestedOwner: 'user:durable-owner', + }, + { + fetch: controlledFetch({}, calls), + absentControlName: (): string => 'generated-absent-control', + }, + ); + + expect(result).toEqual({ + verdict: 'resolved', + reasonCode: 'owner-verified', + principal: { name: 'user:durable-owner', kind: 'durable-human' }, + authority: { + system: 'gitea', + endpoint: 'GET /api/v1/users/durable-owner', + contentType: 'application/json', + }, + }); + expect(calls.map((call) => call.identity)).toEqual([ + 'public-control', + 'private-control', + 'generated-absent-control', + 'durable-owner', + ]); + expect(calls.every((call) => call.authorization === null)).toBe(true); + }); + + it('requires the GLPI standing remediation queue in the local estate policy', async (): Promise => { + const resolver = await loadResolver('MB-REQ-09 standing process policy'); + const raw = JSON.parse(ownerPolicy()) as { estates: Array> }; + delete raw.estates[0]?.['standingProcess']; + let fetchCalls = 0; + + const result = await resolver.resolveProviderDurableOwner( + { + estateRegistrySource: estateRegistry(), + ownerPolicySource: JSON.stringify(raw), + host: 'git.example.invalid', + requestedOwner: 'user:durable-owner', + }, + { + fetch: async (): Promise => { + fetchCalls += 1; + return publicUser('durable-owner'); + }, + absentControlName: (): string => 'generated-absent-control', + }, + ); + + expect(result).toMatchObject({ verdict: 'refused', reasonCode: 'owner-policy-invalid' }); + expect(fetchCalls).toBe(0); + }); + + it('rejects a provider-valid but unlisted principal before provider lookup', async (): Promise => { + const resolver = await loadResolver('MB-REQ-09 provider-valid unlisted owner refusal'); + const calls: Array<{ identity: string; authorization: string | null }> = []; + + const result = await resolver.resolveProviderDurableOwner( + { + estateRegistrySource: estateRegistry(), + ownerPolicySource: ownerPolicy(), + host: 'git.example.invalid', + requestedOwner: 'user:other-public-user', + }, + { + fetch: controlledFetch({ 'other-public-user': publicUser('other-public-user') }, calls), + absentControlName: (): string => 'generated-absent-control', + }, + ); + + expect(result).toMatchObject({ verdict: 'refused', reasonCode: 'owner-not-allowlisted' }); + expect(calls).toHaveLength(0); + }); + + it.each([ + ['user:durable–owner', 'owner-name-invalid'], + [' user:durable-owner ', 'owner-name-invalid'], + ['user:durable.owner', 'owner-not-allowlisted'], + ['user:durable owner', 'owner-name-invalid'], + ['user:durable-owner', 'owner-name-invalid'], + ['user:be-coder-07@mission-seat', 'owner-name-invalid'], + ] as const)( + 'rejects non-canonical, unlisted, or transient-seat presentation %s before lookup', + async (name, reasonCode): Promise => { + const resolver = await loadResolver('MB-REQ-09 owner allowlist grammar'); + let fetchCalls = 0; + + const result = await resolver.resolveProviderDurableOwner( + { + estateRegistrySource: estateRegistry(), + ownerPolicySource: ownerPolicy(), + host: 'git.example.invalid', + requestedOwner: name, + }, + { + fetch: async (): Promise => { + fetchCalls += 1; + return publicUser('durable-owner'); + }, + absentControlName: (): string => 'generated-absent-control', + }, + ); + + expect(result).toMatchObject({ verdict: 'refused', reasonCode }); + expect(fetchCalls).toBe(0); + }, + ); + + it('fails closed as not-resolvable rather than claiming a private-or-absent owner does not exist', async (): Promise => { + const resolver = await loadResolver('MB-REQ-09 private/absent ambiguity'); + + const result = await resolver.resolveProviderDurableOwner( + { + estateRegistrySource: estateRegistry(), + ownerPolicySource: ownerPolicy(), + host: 'git.example.invalid', + requestedOwner: 'user:durable-owner', + }, + { + fetch: controlledFetch({ 'durable-owner': jsonResponse(404, { message: 'hidden' }) }), + absentControlName: (): string => 'generated-absent-control', + }, + ); + + expect(result).toMatchObject({ + verdict: 'not-measured', + reasonCode: 'owner-not-resolvable', + principal: null, + }); + expect(JSON.stringify(result)).not.toMatch(/owner-not-found|does-not-exist/); + }); + + it.each([ + ['public control hidden', { 'public-control': jsonResponse(404, {}) }], + ['public control login mismatch', { 'public-control': publicUser('other') }], + ['private control unexpectedly public', { 'private-control': publicUser('private-control') }], + [ + 'generated absent control unexpectedly resolves', + { 'generated-absent-control': publicUser('generated-absent-control') }, + ], + ] as const)( + 'makes the whole result not-measured when %s', + async (_caseName, overrides): Promise => { + const resolver = await loadResolver('MB-REQ-09 owner resolver controls'); + + const result = await resolver.resolveProviderDurableOwner( + { + estateRegistrySource: estateRegistry(), + ownerPolicySource: ownerPolicy(), + host: 'git.example.invalid', + requestedOwner: 'user:durable-owner', + }, + { + fetch: controlledFetch(overrides), + absentControlName: (): string => 'generated-absent-control', + }, + ); + + expect(result).toMatchObject({ + verdict: 'not-measured', + reasonCode: 'owner-control-invalid', + }); + }, + ); +}); diff --git a/packages/mosaic/src/commands/brain-owner-resolver.ts b/packages/mosaic/src/commands/brain-owner-resolver.ts new file mode 100644 index 00000000..5c881853 --- /dev/null +++ b/packages/mosaic/src/commands/brain-owner-resolver.ts @@ -0,0 +1,255 @@ +import { z } from 'zod'; +import { parseCredentialEstateRegistry } from '../credentials/estate-registry.js'; +import type { MigrationOwnerResolution } from './brain-store.js'; + +const MAX_BODY_BYTES = 256 * 1024; +const LOGIN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/; +const REQUESTED_OWNER = /^user:(.+)$/; + +const ownerPolicySchema = z + .object({ + version: z.literal(1), + estates: z + .array( + z + .object({ + estate: z.string().min(1), + laneArchiveOwners: z + .array( + z + .object({ + kind: z.literal('provider-user'), + login: z.string().min(1), + }) + .strict(), + ) + .min(1), + standingProcess: z + .object({ + kind: z.literal('glpi-queue'), + queue: z.string().regex(/^[a-z0-9][a-z0-9-]*$/), + }) + .strict(), + controls: z + .object({ + publicIdentity: z.string().min(1), + privateIdentity: z.string().min(1), + }) + .strict(), + }) + .strict(), + ) + .min(1), + }) + .strict(); + +const providerUserSchema = z + .object({ + id: z.number().int(), + login: z.string().min(1), + visibility: z.literal('public'), + }) + .passthrough(); + +export type OwnerFetch = (input: string | URL | Request, init?: RequestInit) => Promise; + +function unresolved(reasonCode: string): MigrationOwnerResolution { + return { + verdict: 'not-measured', + reasonCode, + principal: null, + authority: null, + }; +} + +function refused(reasonCode: string): MigrationOwnerResolution { + return { + verdict: 'refused', + reasonCode, + principal: null, + authority: null, + }; +} + +function exactCanonicalLogin(value: string): boolean { + return value.normalize('NFKC') === value && LOGIN.test(value); +} + +async function boundedJson(response: Response): Promise { + const contentType = response.headers.get('content-type') ?? ''; + if (!contentType.toLowerCase().startsWith('application/json')) { + throw new Error('owner-unexpected-content-type'); + } + const declared = response.headers.get('content-length'); + if (declared !== null) { + const size = Number.parseInt(declared, 10); + if (Number.isFinite(size) && size > MAX_BODY_BYTES) { + throw new Error('owner-unexpected-provider-shape'); + } + } + const body = new Uint8Array(await response.arrayBuffer()); + if (body.byteLength > MAX_BODY_BYTES) throw new Error('owner-unexpected-provider-shape'); + try { + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body)); + } catch { + throw new Error('owner-unexpected-provider-shape'); + } +} + +async function readPublicIdentity( + origin: string, + identity: string, + fetchImpl: OwnerFetch, +): Promise<{ readonly status: number; readonly user: unknown }> { + let response: Response; + try { + response = await fetchImpl(`${origin}/api/v1/users/${encodeURIComponent(identity)}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'User-Agent': 'mosaic-brain-owner/1', + }, + }); + } catch { + throw new Error('owner-provider-unavailable'); + } + return { status: response.status, user: await boundedJson(response) }; +} + +function publicIdentityMatches(value: unknown, identity: string): boolean { + const parsed = providerUserSchema.safeParse(value); + return parsed.success && parsed.data.login === identity; +} + +export interface BrainOwnerPolicyBinding { + readonly brainNamespace: string; + readonly publicControl: string; + readonly privateControl: string; + readonly standingQueue: string; +} + +export function resolveBrainOwnerPolicy( + ownerPolicySource: string, + estate: string, +): BrainOwnerPolicyBinding | undefined { + let rawPolicy: unknown; + try { + rawPolicy = JSON.parse(ownerPolicySource); + } catch { + return undefined; + } + const policy = ownerPolicySchema.safeParse(rawPolicy); + if (!policy.success) return undefined; + const estatePolicies = policy.data.estates.filter( + (candidate): boolean => candidate.estate === estate, + ); + if (estatePolicies.length !== 1) return undefined; + const estatePolicy = estatePolicies[0]; + if (estatePolicy === undefined || estatePolicy.laneArchiveOwners.length !== 1) return undefined; + const brainNamespace = estatePolicy.laneArchiveOwners[0]?.login; + if (brainNamespace === undefined || !exactCanonicalLogin(brainNamespace)) return undefined; + return { + brainNamespace, + publicControl: estatePolicy.controls.publicIdentity, + privateControl: estatePolicy.controls.privateIdentity, + standingQueue: estatePolicy.standingProcess.queue, + }; +} + +export function parseRequestedOwner(requestedOwner: string): string | null { + if (requestedOwner.normalize('NFKC') !== requestedOwner) return null; + const match = REQUESTED_OWNER.exec(requestedOwner); + const login = match?.[1]; + if (login === undefined || !exactCanonicalLogin(login)) return null; + return login; +} + +export async function resolveProviderDurableOwner( + input: { + readonly estateRegistrySource: string; + readonly ownerPolicySource: string; + readonly host: string; + readonly requestedOwner: string; + }, + dependencies: { + readonly fetch: OwnerFetch; + readonly absentControlName: () => string; + }, +): Promise { + const requestedLogin = parseRequestedOwner(input.requestedOwner); + if (requestedLogin === null) return refused('owner-name-invalid'); + + const target = parseCredentialEstateRegistry(input.estateRegistrySource).resolveByHost( + input.host, + ); + if (target === undefined) return refused('estate-host-unmapped'); + + const policy = resolveBrainOwnerPolicy(input.ownerPolicySource, target.estate); + if (policy === undefined) return refused('owner-policy-invalid'); + if (policy.brainNamespace !== requestedLogin) return refused('owner-not-allowlisted'); + + const publicControl = policy.publicControl; + const privateControl = policy.privateControl; + const absentControl = dependencies.absentControlName(); + if ( + !exactCanonicalLogin(publicControl) || + !exactCanonicalLogin(privateControl) || + !exactCanonicalLogin(absentControl) || + new Set([publicControl, privateControl, absentControl, requestedLogin]).size !== 4 + ) { + return refused('owner-policy-invalid'); + } + + try { + const publicResult = await readPublicIdentity( + target.host.apiBaseUrl, + publicControl, + dependencies.fetch, + ); + if (publicResult.status !== 200 || !publicIdentityMatches(publicResult.user, publicControl)) { + return unresolved('owner-control-invalid'); + } + + const privateResult = await readPublicIdentity( + target.host.apiBaseUrl, + privateControl, + dependencies.fetch, + ); + if (privateResult.status !== 404) return unresolved('owner-control-invalid'); + + const absentResult = await readPublicIdentity( + target.host.apiBaseUrl, + absentControl, + dependencies.fetch, + ); + if (absentResult.status !== 404) return unresolved('owner-control-invalid'); + + const ownerResult = await readPublicIdentity( + target.host.apiBaseUrl, + requestedLogin, + dependencies.fetch, + ); + if (ownerResult.status === 401 || ownerResult.status === 403 || ownerResult.status === 404) { + return unresolved('owner-not-resolvable'); + } + if (ownerResult.status !== 200) return unresolved('owner-provider-unavailable'); + if (!publicIdentityMatches(ownerResult.user, requestedLogin)) { + return unresolved('owner-provider-identity-mismatch'); + } + return { + verdict: 'resolved', + reasonCode: 'owner-verified', + principal: { name: `user:${requestedLogin}`, kind: 'durable-human' }, + authority: { + system: 'gitea', + endpoint: `GET /api/v1/users/${requestedLogin}`, + contentType: 'application/json', + }, + }; + } catch (error: unknown) { + const reason = error instanceof Error ? error.message : 'owner-provider-unavailable'; + if (reason === 'owner-unexpected-content-type') return unresolved(reason); + if (reason === 'owner-unexpected-provider-shape') return unresolved(reason); + return unresolved('owner-provider-unavailable'); + } +} diff --git a/packages/mosaic/src/commands/brain-provision-command.spec.ts b/packages/mosaic/src/commands/brain-provision-command.spec.ts new file mode 100644 index 00000000..c76925c0 --- /dev/null +++ b/packages/mosaic/src/commands/brain-provision-command.spec.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { Command } from 'commander'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +interface BrainProvisionCommandModule { + readonly BRAIN_PROVISION_COMMAND: string; + registerBrainProvisionCommand(program: Command): void; + executeBrainProvisionCommand( + options: { + readonly mosaicHome: string; + readonly home: string; + readonly identity: string; + readonly refusalIdentity: string; + readonly targetUrl: string; + readonly owner: string; + readonly lane: string; + readonly sourceRoot?: string; + readonly brainRoot?: string; + readonly ownerPolicy?: string; + readonly registry?: string; + }, + dependencies: { + readonly run: () => never; + readonly fetch: typeof fetch; + readonly absentControlName: () => string; + }, + ): Promise<{ + readonly status: 'provisioned' | 'blocked' | 'failed'; + readonly reasonCode: string; + }>; +} + +const MODULE_PATH = './brain-provision-command.js'; +const roots: string[] = []; + +async function loadCommand(requirement: string): Promise { + try { + return (await import(MODULE_PATH)) as BrainProvisionCommandModule; + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`${requirement}: brain provision command is absent (${detail})`); + } +} + +function tempRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'mosaic-brain-command-')); + roots.push(root); + return root; +} + +afterEach((): void => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe('internal P7 brain provision command', (): void => { + it('registers only explicit non-secret contract inputs and no credential/token lookup switches', async (): Promise => { + const module = await loadCommand('MB-REQ-03 broker-only provision command'); + const program = new Command(); + module.registerBrainProvisionCommand(program); + const command = program.commands.find( + (candidate) => candidate.name() === module.BRAIN_PROVISION_COMMAND, + ); + + expect(command).toBeDefined(); + const flags = command?.options.map((option) => option.flags) ?? []; + expect(flags.join(' ')).toContain('--identity'); + expect(flags.join(' ')).toContain('--target-url'); + expect(flags.join(' ')).toContain('--refusal-identity'); + expect(flags.join(' ')).toContain('--owner-policy'); + expect(flags.join(' ')).toContain('--owner'); + expect(flags.join(' ')).toContain('--lane'); + expect(flags.join(' ')).not.toMatch(/token|password|authorization|grant-authority/i); + }); + + it('is registered by the shipped CLI', async (): Promise => { + const module = await loadCommand('MB-REQ-10 shipped P7 command'); + const cli = readFileSync(join(process.cwd(), 'src', 'cli.ts'), 'utf8'); + + expect(cli).toContain('registerBrainProvisionCommand'); + expect(cli).toContain(`registerBrainProvisionCommand(program)`); + expect(module.BRAIN_PROVISION_COMMAND).toBe('__brain-provision'); + }); + + it('fails closed before commands when the local owner policy is absent', async (): Promise => { + const module = await loadCommand('MB-REQ-09 owner policy required'); + const root = tempRoot(); + const home = join(root, 'home'); + const mosaicHome = join(home, '.config', 'mosaic'); + mkdirSync(join(mosaicHome, 'cred'), { recursive: true }); + writeFileSync( + join(mosaicHome, 'cred', 'estates.json'), + JSON.stringify({ version: 1, estates: [] }), + { mode: 0o600 }, + ); + let commands = 0; + + const result = await module.executeBrainProvisionCommand( + { + mosaicHome, + home, + identity: 'seat-a', + refusalIdentity: 'outside-seat', + targetUrl: 'https://git.example.invalid/example/stack.git', + owner: 'user:durable-owner', + lane: 'lane-a', + }, + { + run: (): never => { + commands += 1; + throw new Error('must not run'); + }, + fetch, + absentControlName: (): string => 'generated-absent-control', + }, + ); + + expect(result).toMatchObject({ status: 'failed', reasonCode: 'owner-policy-unavailable' }); + expect(commands).toBe(0); + }); +}); diff --git a/packages/mosaic/src/commands/brain-provision-command.ts b/packages/mosaic/src/commands/brain-provision-command.ts new file mode 100644 index 00000000..ebdeab19 --- /dev/null +++ b/packages/mosaic/src/commands/brain-provision-command.ts @@ -0,0 +1,139 @@ +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 { 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 { + readonly mosaicHome: string; + readonly home: string; + readonly identity: string; + readonly refusalIdentity: string; + readonly targetUrl: string; + readonly owner: string; + readonly lane: string; + readonly sourceRoot?: string; + readonly brainRoot?: string; + readonly ownerPolicy?: string; + readonly registry?: string; +} + +interface BrainProvisionCommandDependencies { + readonly run: CommandRunner; + readonly fetch: OwnerFetch; + readonly absentControlName: () => string; +} + +function configFailure(reasonCode: string): ProvisionResult { + return { + status: 'failed', + reasonCode, + findings: [{ code: `brain-${reasonCode}`, reasonCode }], + owner: null, + migration: null, + }; +} + +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, +): Promise { + const registry = options.registry ?? join(options.mosaicHome, 'cred', 'estates.json'); + const ownerPolicy = options.ownerPolicy ?? join(options.mosaicHome, 'brain', 'owners.json'); + let estateRegistrySource: string; + try { + estateRegistrySource = readConfig(registry, options.mosaicHome); + } catch { + return configFailure('estate-registry-unavailable'); + } + let ownerPolicySource: string; + try { + ownerPolicySource = readConfig(ownerPolicy, options.mosaicHome); + } catch { + return configFailure('owner-policy-unavailable'); + } + + try { + return await provisionBrain( + { + estateRegistrySource, + ownerPolicySource, + targetGitUrl: options.targetUrl, + requestedOwner: options.owner, + identity: options.identity, + refusalIdentity: options.refusalIdentity, + root: options.brainRoot ?? join(options.home, '.mosaic'), + sourceRoot: options.sourceRoot ?? join(options.mosaicHome, 'memory'), + seat: options.identity, + lane: options.lane, + laneActive: false, + }, + dependencies, + ); + } catch { + return configFailure('brain-provision-exception'); + } +} + +export function registerBrainProvisionCommand(program: Command): void { + program + .command(BRAIN_PROVISION_COMMAND, { hidden: true }) + .description('Internal installer P7 durable-brain provisioner') + .requiredOption('--identity ', 'explicit fleet identity') + .requiredOption('--target-url ', 'configured target git URL') + .requiredOption('--refusal-identity ', 'explicit out-of-estate negative control') + .requiredOption('--owner ', 'policy-bound durable owner candidate') + .requiredOption('--lane ', 'source lane to migrate') + .option('--mosaic-home ', 'installed Mosaic home') + .option('--home ', 'principal home') + .option('--source-root ', 'legacy memory root') + .option('--brain-root ', 'per-estate brain checkout root') + .option('--owner-policy ', 'durable-owner allowlist policy') + .option('--registry ', 'estate registry path') + .action(async (raw: Record): Promise => { + const home = raw['home'] ?? homedir(); + const mosaicHome = + raw['mosaicHome'] ?? process.env['MOSAIC_HOME'] ?? join(home, '.config', 'mosaic'); + const result = await executeBrainProvisionCommand( + { + mosaicHome, + home, + identity: raw['identity']!, + targetUrl: raw['targetUrl']!, + refusalIdentity: raw['refusalIdentity']!, + owner: raw['owner']!, + lane: raw['lane']!, + ...(raw['sourceRoot'] === undefined ? {} : { sourceRoot: raw['sourceRoot'] }), + ...(raw['brainRoot'] === undefined ? {} : { brainRoot: raw['brainRoot'] }), + ...(raw['ownerPolicy'] === undefined ? {} : { ownerPolicy: raw['ownerPolicy'] }), + ...(raw['registry'] === undefined ? {} : { registry: raw['registry'] }), + }, + { + run: systemCommandRunner, + fetch, + absentControlName: (): string => `mosaic-absent-${randomUUID()}`, + }, + ); + process.stdout.write( + `${JSON.stringify({ + status: result.status, + reasonCode: result.reasonCode, + findings: result.findings, + owner: result.owner, + migration: result.migration, + })}\n`, + ); + if (result.status !== 'provisioned') process.exitCode = result.status === 'blocked' ? 30 : 20; + }); +} diff --git a/packages/mosaic/src/commands/brain-provision.spec.ts b/packages/mosaic/src/commands/brain-provision.spec.ts new file mode 100644 index 00000000..d15f32d5 --- /dev/null +++ b/packages/mosaic/src/commands/brain-provision.spec.ts @@ -0,0 +1,458 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +interface CommandRequest { + readonly program: 'git' | 'mosaic'; + readonly args: readonly string[]; + readonly env: Readonly>; +} + +interface CommandResult { + readonly status: number; + readonly stdout: string; + readonly stderr: string; +} + +type CommandRunner = (request: CommandRequest) => CommandResult; +type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise; + +interface ProvisionResult { + readonly status: 'provisioned' | 'blocked' | 'failed'; + readonly reasonCode: string; + readonly findings: readonly { code: string; reasonCode: string | null }[]; + readonly owner: { + readonly verdict: 'resolved' | 'refused' | 'not-measured'; + readonly reasonCode: string; + } | null; + readonly migration: { + readonly status: 'migrated' | 'reported' | 'failed'; + readonly reported: readonly { path: string; reason: string }[]; + } | null; +} + +interface ProvisionModule { + provisionBrain( + input: { + readonly estateRegistrySource: string; + readonly ownerPolicySource: string; + readonly targetGitUrl: string; + readonly requestedOwner: string; + readonly identity: string; + readonly refusalIdentity: string; + readonly root: string; + readonly sourceRoot: string; + readonly seat: string; + readonly lane: string; + readonly laneActive: boolean; + }, + dependencies: { + readonly run: CommandRunner; + readonly fetch: FetchLike; + readonly absentControlName: () => string; + }, + ): Promise; +} + +const MODULE_PATH = './brain-provision.js'; +const roots: string[] = []; + +async function loadProvisioner(requirement: string): Promise { + try { + return (await import(MODULE_PATH)) as ProvisionModule; + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`${requirement}: brain provisioner is absent (${detail})`); + } +} + +function tempRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'mosaic-brain-provision-')); + roots.push(root); + return root; +} + +function estateRegistry(): string { + return JSON.stringify({ + version: 1, + estates: [ + { + name: 'homelab', + readOnlyControlIdentity: 'read-control', + hosts: [ + { + host: 'git.example.invalid', + provider: 'gitea', + apiBaseUrl: 'https://git.example.invalid', + tokenPrefix: 'gitea-example', + }, + ], + }, + ], + }); +} + +function ownerPolicy(): string { + return JSON.stringify({ + version: 1, + estates: [ + { + estate: 'homelab', + laneArchiveOwners: [{ kind: 'provider-user', login: 'durable-owner' }], + standingProcess: { kind: 'glpi-queue', queue: 'mosaic-brain-remediation' }, + controls: { publicIdentity: 'public-control', privateIdentity: 'private-control' }, + }, + ], + }); +} + +function validateResult( + outcome: 'ok' | 'refused' | 'indeterminate', + reasonCode: string, + identity = 'seat-a', +): string { + const exitCode = outcome === 'ok' ? 0 : outcome === 'refused' ? 10 : 30; + return JSON.stringify({ + schemaVersion: 1, + operation: 'validate', + outcome, + exitCode, + retryable: false, + subject: { + identity, + estate: 'homelab', + host: 'git.example.invalid', + repo: 'durable-owner/mosaic-brain', + }, + mutation: 'none', + reason: { code: reasonCode, message: 'non-secret' }, + evidence: { + providerIdentity: + outcome === 'ok' + ? { + login: identity, + endpoint: 'GET /api/v1/user', + contentType: 'application/json', + } + : null, + repositoryPermission: + outcome === 'ok' + ? { + requested: 'write', + effective: 'write', + endpoint: 'GET /api/v1/repos/durable-owner/mosaic-brain', + contentType: 'application/json', + } + : null, + writeDifferential: + outcome === 'ok' + ? { + state: 'can-write', + credentialBinding: 'same-resolution', + transportPrincipal: identity, + authenticatedReceivePack: 'advertised', + readOnlyControl: { + identity: 'read-control', + providerPermission: 'read', + receivePack: 'refused', + }, + unauthenticatedReceivePack: 'refused', + artifactCreated: false, + proves: 'non-secret evidence', + doesNotProve: 'branch update acceptance', + } + : null, + }, + audit: { journalId: 'opaque', state: 'sealed' }, + }); +} + +function publicUser(login: string): Response { + return new Response(JSON.stringify({ id: 1, login, visibility: 'public', active: false }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +function ownerFetch(ownerStatus = 200): FetchLike { + return async (input): Promise => { + const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const identity = decodeURIComponent(new URL(raw).pathname.split('/').at(-1) ?? ''); + if (identity === 'public-control') return publicUser(identity); + if (identity === 'private-control' || identity === 'generated-absent-control') { + return new Response(JSON.stringify({ message: 'hidden or absent' }), { + status: 404, + headers: { 'content-type': 'application/json' }, + }); + } + if (identity === 'durable-owner' && ownerStatus === 200) return publicUser(identity); + return new Response(JSON.stringify({ message: 'hidden or absent' }), { + status: ownerStatus, + headers: { 'content-type': 'application/json' }, + }); + }; +} + +function baseInput(root: string): { + readonly estateRegistrySource: string; + readonly ownerPolicySource: string; + readonly targetGitUrl: string; + readonly requestedOwner: string; + readonly identity: string; + readonly refusalIdentity: string; + readonly root: string; + readonly sourceRoot: string; + readonly seat: string; + readonly lane: string; + readonly laneActive: boolean; +} { + return { + estateRegistrySource: estateRegistry(), + ownerPolicySource: ownerPolicy(), + targetGitUrl: 'https://git.example.invalid/example/stack.git', + requestedOwner: 'user:durable-owner', + identity: 'seat-a', + refusalIdentity: 'outside-seat', + root: join(root, 'brain'), + sourceRoot: join(root, 'local-memory'), + seat: 'seat-a', + lane: 'lane-a', + laneActive: false, + }; +} + +afterEach((): void => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe('P7 brain provisioning orchestration', (): void => { + it('requires the P5 write-capability postcondition and never grants or clones on refusal', async (): Promise => { + const provisioner = await loadProvisioner('MB-REQ-10 P5 before P7'); + const root = tempRoot(); + const requests: CommandRequest[] = []; + + const result = await provisioner.provisionBrain(baseInput(root), { + run: (request): CommandResult => { + requests.push(request); + return { + status: 10, + stdout: validateResult('refused', 'no-token-for-identity'), + stderr: 'refused reason=no-token-for-identity', + }; + }, + fetch: ownerFetch(), + absentControlName: (): string => 'generated-absent-control', + }); + + expect(result).toMatchObject({ + status: 'blocked', + reasonCode: 'credential-postcondition-failed', + }); + expect(requests).toHaveLength(1); + expect(requests[0]?.program).toBe('mosaic'); + expect(requests[0]?.args.slice(0, 3)).toEqual(['cred', 'validate', 'seat-a']); + expect(requests.some((request) => request.args.includes('grant'))).toBe(false); + expect(requests.some((request) => request.args.includes('clone'))).toBe(false); + }); + + it('blocks before clone when the out-of-estate Git and API axes disagree', async (): Promise => { + const provisioner = await loadProvisioner('MB-REQ-05 P7 refusal control gate'); + const root = tempRoot(); + const requests: CommandRequest[] = []; + + const result = await provisioner.provisionBrain(baseInput(root), { + run: (request): CommandResult => { + requests.push(request); + if (request.program === 'mosaic' && request.args[2] === 'outside-seat') { + return { + status: 10, + stdout: validateResult('refused', 'no-token-for-identity', 'outside-seat'), + stderr: 'refused reason=no-token-for-identity', + }; + } + if (request.program === 'mosaic') { + return { status: 0, stdout: validateResult('ok', 'validation-verified'), stderr: '' }; + } + if (request.args.includes('ls-remote')) { + return { status: 0, stdout: 'refs are visible', stderr: '' }; + } + return { status: 99, stdout: '', stderr: 'unexpected command' }; + }, + fetch: ownerFetch(), + absentControlName: (): string => 'generated-absent-control', + }); + + expect(result).toMatchObject({ + status: 'blocked', + reasonCode: 'refusal-control-failed', + }); + expect(requests.some((request) => request.args.includes('clone'))).toBe(false); + expect(requests.some((request) => request.args.includes('grant'))).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(); + const input = baseInput(root); + mkdirSync(join(input.sourceRoot, 'lanes', 'lane-a'), { recursive: true }); + const source = join(input.sourceRoot, 'lanes', 'lane-a', 'finding.md'); + writeFileSync(source, 'durable finding\n'); + const requests: CommandRequest[] = []; + let commitOrdinal = 0; + const runner: CommandRunner = (request): CommandResult => { + requests.push(request); + if (request.program === 'mosaic') { + if (request.args[2] === 'outside-seat') { + return { + status: 10, + stdout: validateResult('refused', 'no-token-for-identity', 'outside-seat'), + stderr: 'refused reason=no-token-for-identity', + }; + } + return { 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 (request.args[0] === 'clone') { + mkdirSync(join(input.root, '.git'), { recursive: true }); + return { status: 0, stdout: '', stderr: '' }; + } + 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: '', stderr: '' }; + } + if (command.includes('rev-parse HEAD')) { + commitOrdinal += 1; + return { + status: 0, + stdout: `${commitOrdinal === 1 ? 'a' : 'c'.repeat(1)}`.repeat(40) + '\n', + stderr: '', + }; + } + if (command.includes('rev-parse origin/main')) { + const value = commitOrdinal === 1 ? 'b' : 'd'; + return { status: 0, stdout: `${value.repeat(40)}\n`, stderr: '' }; + } + return { status: 0, stdout: '', stderr: '' }; + }; + + const result = await provisioner.provisionBrain(input, { + run: runner, + fetch: ownerFetch(), + absentControlName: (): string => 'generated-absent-control', + }); + + expect(result).toMatchObject({ + status: 'provisioned', + reasonCode: 'brain-provisioned', + owner: { verdict: 'resolved', reasonCode: 'owner-verified' }, + migration: { status: 'migrated' }, + }); + expect(existsSync(source)).toBe(false); + const imported = result.migration?.reported ?? []; + expect(imported).toEqual([]); + const laneImports = join(input.root, 'lanes', 'lane-a', 'findings', 'imports'); + const archiveImports = join(input.root, 'archives', 'imports', 'lane'); + expect(existsSync(laneImports)).toBe(true); + expect(existsSync(archiveImports)).toBe(true); + expect( + requests.filter((request) => request.program === 'git' && request.args.includes('push')), + ).toHaveLength(2); + expect(requests.some((request) => request.args.includes('grant'))).toBe(false); + }); + + it('keeps every source and reports the owner ambiguity when the public owner cannot be resolved', async (): Promise => { + const provisioner = await loadProvisioner('MB-REQ-07 owner-blocked detection/reporting'); + const root = tempRoot(); + const input = baseInput(root); + mkdirSync(input.root, { recursive: true }); + mkdirSync(join(input.root, '.git'), { recursive: true }); + mkdirSync(join(input.sourceRoot, 'lanes', 'lane-a'), { recursive: true }); + const source = join(input.sourceRoot, 'lanes', 'lane-a', 'finding.md'); + writeFileSync(source, 'retain me\n'); + let commitOrdinal = 0; + + const result = await provisioner.provisionBrain(input, { + run: (request): CommandResult => { + if (request.program === 'mosaic') { + if (request.args[2] === 'outside-seat') { + return { + status: 10, + stdout: validateResult('refused', 'no-token-for-identity', 'outside-seat'), + stderr: 'refused reason=no-token-for-identity', + }; + } + return { 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: '', stderr: '' }; + } + if (command.includes('rev-parse HEAD')) { + commitOrdinal += 1; + return { status: 0, stdout: `${'a'.repeat(40)}\n`, stderr: '' }; + } + if (command.includes('rev-parse origin/main')) { + return { status: 0, stdout: `${'b'.repeat(40)}\n`, stderr: '' }; + } + return { status: 0, stdout: '', stderr: '' }; + }, + fetch: ownerFetch(404), + absentControlName: (): string => 'generated-absent-control', + }); + + expect(result).toMatchObject({ + status: 'blocked', + reasonCode: 'owner-not-resolvable', + owner: { verdict: 'not-measured', reasonCode: 'owner-not-resolvable' }, + migration: { status: 'reported' }, + }); + expect(readFileSync(source, 'utf8')).toBe('retain me\n'); + expect(result.migration?.reported).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: source, reason: expect.stringMatching(/owner/i) }), + ]), + ); + expect(JSON.stringify(result)).not.toMatch(/owner-not-found|does-not-exist/); + expect(commitOrdinal).toBe(0); + }); +}); diff --git a/packages/mosaic/src/commands/brain-provision.ts b/packages/mosaic/src/commands/brain-provision.ts new file mode 100644 index 00000000..9d00adad --- /dev/null +++ b/packages/mosaic/src/commands/brain-provision.ts @@ -0,0 +1,247 @@ +import { lstatSync } from 'node:fs'; +import { parseRequestedOwner, resolveProviderDurableOwner } from './brain-owner-resolver.js'; +import { + createBrainSkeleton, + deriveBrainTarget, + discoverBrainMigration, + migrateBrainState, + type MigrationResult, + type MigrationOwnerResolution, +} from './brain-store.js'; +import { + collectBrainDoctorReport, + collectBrainRefusalControl, + publishBrainPaths, + type CommandRequest, + type CommandResult, + type CommandRunner, +} from './brain-store-runtime.js'; +import type { OwnerFetch } from './brain-owner-resolver.js'; + +export interface ProvisionResult { + readonly status: 'provisioned' | 'blocked' | 'failed'; + readonly reasonCode: string; + readonly findings: readonly { + readonly code: string; + readonly reasonCode: string | null; + }[]; + readonly owner: Pick | null; + readonly migration: Pick | null; +} + +function commandEnv(identity: string): Readonly> { + return { MOSAIC_GIT_IDENTITY: identity, GIT_TERMINAL_PROMPT: '0' }; +} + +function findingView( + findings: readonly { readonly code: string; readonly reasonCode: string | null }[], +): readonly { readonly code: string; readonly reasonCode: string | null }[] { + return findings.map((finding): { readonly code: string; readonly reasonCode: string | null } => ({ + code: finding.code, + reasonCode: finding.reasonCode, + })); +} + +function blocked( + reasonCode: string, + findings: readonly { readonly code: string; readonly reasonCode: string | null }[], + owner: MigrationOwnerResolution | null = null, + migration: MigrationResult | null = null, +): ProvisionResult { + return { + status: 'blocked', + reasonCode, + findings: findingView(findings), + owner: owner === null ? null : { verdict: owner.verdict, reasonCode: owner.reasonCode }, + migration: + migration === null ? null : { status: migration.status, reported: migration.reported }, + }; +} + +function failed( + reasonCode: string, + findings: readonly { readonly code: string; readonly reasonCode: string | null }[], + owner: MigrationOwnerResolution | null = null, + migration: MigrationResult | null = null, +): ProvisionResult { + return { + ...blocked(reasonCode, findings, owner, migration), + status: 'failed', + }; +} + +export async function provisionBrain( + input: { + readonly estateRegistrySource: string; + readonly ownerPolicySource: string; + readonly targetGitUrl: string; + readonly requestedOwner: string; + readonly identity: string; + readonly refusalIdentity: string; + readonly root: string; + readonly sourceRoot: string; + readonly seat: string; + readonly lane: string; + readonly laneActive: boolean; + }, + dependencies: { + readonly run: CommandRunner; + readonly fetch: OwnerFetch; + readonly absentControlName: () => string; + }, +): Promise { + const brainNamespace = parseRequestedOwner(input.requestedOwner); + if (brainNamespace === null) return blocked('owner-name-invalid', []); + const target = deriveBrainTarget(input.estateRegistrySource, input.targetGitUrl, brainNamespace); + const doctorInput = { + registrySource: input.estateRegistrySource, + targetGitUrl: input.targetGitUrl, + brainNamespace, + identity: input.identity, + root: input.root, + }; + let report = collectBrainDoctorReport(doctorInput, dependencies.run); + if (report.access.outcome !== 'ok') { + return blocked('credential-postcondition-failed', report.findings); + } + + const refusalControl = collectBrainRefusalControl( + { + registrySource: input.estateRegistrySource, + targetGitUrl: input.targetGitUrl, + brainNamespace, + refusalIdentity: input.refusalIdentity, + }, + dependencies.run, + ); + if (!refusalControl.observed) { + return blocked('refusal-control-failed', [ + ...report.findings, + { + code: 'brain-refusal-control-indeterminate', + reasonCode: refusalControl.reasonCode, + }, + ]); + } + + const owner = await resolveProviderDurableOwner( + { + estateRegistrySource: input.estateRegistrySource, + ownerPolicySource: input.ownerPolicySource, + host: target.host, + requestedOwner: input.requestedOwner, + }, + { + fetch: dependencies.fetch, + absentControlName: dependencies.absentControlName, + }, + ); + if (owner.verdict !== 'resolved') { + const plan = discoverBrainMigration( + { + sourceRoot: input.sourceRoot, + brainRoot: input.root, + seat: input.seat, + lane: input.lane, + laneActive: input.laneActive, + }, + (): MigrationOwnerResolution => owner, + ); + const migration = migrateBrainState( + plan, + (): never => { + throw new Error('blocked owner cannot publish'); + }, + input.root, + ); + return blocked(owner.reasonCode, report.findings, owner, migration); + } + + if (report.findings.some((finding): boolean => finding.code === 'brain-clone-missing')) { + const clone: CommandRequest = { + program: 'git', + args: ['clone', '--branch', 'main', '--single-branch', target.cloneUrl, input.root], + env: commandEnv(input.identity), + }; + const cloneResult: CommandResult = dependencies.run(clone); + if (cloneResult.status !== 0) return failed('brain-clone-failed', report.findings); + report = collectBrainDoctorReport(doctorInput, dependencies.run); + } + + const blockingCloneFindings = report.findings.filter( + (finding): boolean => + finding.code === 'brain-clone-missing' || + finding.code === 'brain-not-git-repository' || + finding.code === 'brain-remote-mismatch' || + finding.code === 'brain-branch-mismatch' || + finding.code.startsWith('brain-write-access-'), + ); + if (blockingCloneFindings.length > 0) { + return blocked('brain-postcondition-failed', report.findings); + } + + let skeletonPaths: readonly string[]; + try { + const skeleton = createBrainSkeleton(input.root); + skeletonPaths = skeleton.created.filter((path: string): boolean => lstatSync(path).isFile()); + } catch { + return failed('brain-skeleton-failed', report.findings); + } + if (skeletonPaths.length > 0) { + try { + const evidence = publishBrainPaths( + { + root: input.root, + identity: input.identity, + paths: skeletonPaths, + message: 'chore: seed durable brain layout', + }, + dependencies.run, + ); + if (!evidence.reachable) return failed('brain-skeleton-not-reachable', report.findings); + } catch { + return failed('brain-skeleton-publish-failed', report.findings); + } + } + + const plan = discoverBrainMigration( + { + sourceRoot: input.sourceRoot, + brainRoot: input.root, + seat: input.seat, + lane: input.lane, + laneActive: input.laneActive, + }, + (): MigrationOwnerResolution => owner, + ); + const migration = migrateBrainState( + plan, + (brainRoot: string, paths: readonly string[]) => + publishBrainPaths( + { + root: brainRoot, + identity: input.identity, + paths, + message: `migrate: archive ${input.lane} working memory`, + }, + dependencies.run, + ), + input.root, + ); + + if (migration.status === 'failed') { + return failed('brain-migration-publish-failed', report.findings, owner, migration); + } + + report = collectBrainDoctorReport(doctorInput, dependencies.run); + if (report.findings.length > 0) { + return blocked('brain-final-postcondition-failed', report.findings, owner, migration); + } + return { + status: 'provisioned', + reasonCode: 'brain-provisioned', + findings: [], + owner: { verdict: owner.verdict, reasonCode: owner.reasonCode }, + migration: { status: migration.status, reported: migration.reported }, + }; +} diff --git a/packages/mosaic/src/commands/brain-store-runtime.spec.ts b/packages/mosaic/src/commands/brain-store-runtime.spec.ts new file mode 100644 index 00000000..c628007d --- /dev/null +++ b/packages/mosaic/src/commands/brain-store-runtime.spec.ts @@ -0,0 +1,715 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +/** + * Red-first integration seam for #1051. + * + * Live broker grants and remote writes are deliberately not exercised here. + * The injected runner records exact commands and returns contract objects; the + * production path must call `mosaic cred`, never resolve a token itself. + */ + +interface CommandRequest { + readonly program: 'git' | 'mosaic'; + readonly args: readonly string[]; + readonly cwd?: string; + readonly env: Readonly>; +} + +interface CommandResult { + readonly status: number; + readonly stdout: string; + readonly stderr: string; +} + +type CommandRunner = (request: CommandRequest) => CommandResult; + +interface DoctorRuntimeReport { + readonly findings: readonly { code: string; repairable: boolean; reasonCode: string | null }[]; + readonly access: { + readonly outcome: 'ok' | 'refused' | 'error' | 'indeterminate'; + readonly exitCode: 0 | 10 | 20 | 30; + readonly reasonCode: string; + }; + readonly refusalControl: { + readonly observed: boolean; + readonly reasonCode: string | null; + }; +} + +interface PublishEvidence { + readonly commit: string; + readonly remoteHead: string; + readonly reachable: boolean; +} + +interface BrainRuntimeModule { + collectBrainDoctorReport( + input: { + readonly registrySource: string; + readonly targetGitUrl: string; + readonly brainNamespace: string; + readonly identity: string; + readonly root: string; + }, + run: CommandRunner, + ): DoctorRuntimeReport; + repairBrainDoctor( + input: { + readonly registrySource: string; + readonly targetGitUrl: string; + readonly brainNamespace: string; + readonly identity: string; + readonly root: string; + }, + run: CommandRunner, + ): DoctorRuntimeReport; + collectBrainRefusalControl( + input: { + readonly registrySource: string; + readonly targetGitUrl: string; + readonly brainNamespace: string; + readonly refusalIdentity: string; + }, + run: CommandRunner, + ): { + readonly observed: boolean; + readonly reasonCode: string | null; + readonly gitReasonCode: string; + readonly apiReasonCode: string; + }; + publishBrainPaths( + input: { + readonly root: string; + readonly identity: string; + readonly paths: readonly string[]; + readonly message: string; + }, + run: CommandRunner, + ): PublishEvidence; +} + +const MODULE_PATH = './brain-store-runtime.js'; +const roots: string[] = []; + +async function loadRuntime(requirement: string): Promise { + try { + return (await import(MODULE_PATH)) as BrainRuntimeModule; + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`${requirement}: brain-store runtime is absent (${detail})`); + } +} + +function tempRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'mosaic-brain-runtime-')); + roots.push(root); + return root; +} + +function registry(): string { + return JSON.stringify({ + version: 1, + estates: [ + { + name: 'homelab', + readOnlyControlIdentity: 'read-control', + hosts: [ + { + host: 'git.mosaicstack.dev', + provider: 'gitea', + apiBaseUrl: 'https://git.mosaicstack.dev', + tokenPrefix: 'gitea-mosaicstack', + }, + ], + }, + ], + }); +} + +function validateResult( + outcome: 'ok' | 'refused' | 'error' | 'indeterminate', + reasonCode: string, +): string { + const exits = { ok: 0, refused: 10, error: 20, indeterminate: 30 } as const; + return JSON.stringify({ + schemaVersion: 1, + operation: 'validate', + outcome, + exitCode: exits[outcome], + retryable: false, + subject: { + identity: 'synthetic-no-token', + estate: 'homelab', + host: 'git.mosaicstack.dev', + repo: 'mosaicstack/mosaic-brain', + }, + mutation: 'none', + reason: { code: reasonCode, message: 'non-secret' }, + evidence: { + providerIdentity: + outcome === 'ok' + ? { + login: 'synthetic-no-token', + endpoint: 'GET /api/v1/user', + contentType: 'application/json', + } + : null, + repositoryPermission: + outcome === 'ok' + ? { + requested: 'write', + effective: 'write', + endpoint: 'GET /api/v1/repos/mosaicstack/mosaic-brain', + contentType: 'application/json', + } + : null, + writeDifferential: + outcome === 'ok' + ? { + state: 'can-write', + credentialBinding: 'same-resolution', + transportPrincipal: 'synthetic-no-token', + authenticatedReceivePack: 'advertised', + readOnlyControl: { + identity: 'read-control', + providerPermission: 'read', + receivePack: 'refused', + }, + unauthenticatedReceivePack: 'refused', + artifactCreated: false, + proves: 'non-secret evidence', + doesNotProve: 'branch update acceptance', + } + : null, + }, + audit: { journalId: 'opaque', state: 'sealed' }, + }); +} + +function requestHasSecretShape(request: CommandRequest): boolean { + return JSON.stringify(request).match(/authorization|password|\.token|token-dir/i) !== null; +} + +afterEach((): void => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe('doctor runtime observation', (): void => { + it('runs a synthetic no-token positive control through mosaic cred even when the clone is missing', async (): Promise => { + const runtime = await loadRuntime('MB-REQ-05 synthetic refusal control'); + const root = join(tempRoot(), 'missing-brain'); + const requests: CommandRequest[] = []; + const runner: CommandRunner = (request): CommandResult => { + requests.push(request); + return { + status: 10, + stdout: validateResult('refused', 'no-token-for-identity'), + stderr: 'refused reason=no-token-for-identity', + }; + }; + + const report = runtime.collectBrainDoctorReport( + { + registrySource: registry(), + targetGitUrl: 'https://git.mosaicstack.dev/mosaicstack/stack.git', + brainNamespace: 'mosaicstack', + identity: 'synthetic-no-token', + root, + }, + runner, + ); + + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ + program: 'mosaic', + args: [ + 'cred', + 'validate', + 'synthetic-no-token', + '--estate', + 'homelab', + '--host', + 'git.mosaicstack.dev', + '--repo', + 'mosaicstack/mosaic-brain', + '--require', + 'write', + '--json', + ], + }); + expect(requests.some(requestHasSecretShape)).toBe(false); + expect(report.refusalControl).toEqual({ + observed: true, + reasonCode: 'no-token-for-identity', + }); + expect(report.findings.map((finding) => finding.code)).toEqual( + expect.arrayContaining(['brain-clone-missing', 'brain-write-access-refused']), + ); + }); + + it('treats process/object terminal-class disagreement as indeterminate', async (): Promise => { + const runtime = await loadRuntime('MB-REQ-08 process/object disagreement'); + const root = join(tempRoot(), 'missing-brain'); + + const report = runtime.collectBrainDoctorReport( + { + registrySource: registry(), + targetGitUrl: 'https://git.mosaicstack.dev/mosaicstack/stack.git', + brainNamespace: 'mosaicstack', + identity: 'synthetic-no-token', + root, + }, + (): CommandResult => ({ + status: 0, + stdout: validateResult('refused', 'no-token-for-identity'), + stderr: '', + }), + ); + + expect(report.access).toMatchObject({ + outcome: 'indeterminate', + exitCode: 30, + reasonCode: 'unexpected-provider-shape', + }); + expect(report.refusalControl.observed).toBe(false); + }); + + it('rejects a self-consistent broker object for a different declared subject', async (): Promise => { + const runtime = await loadRuntime('MB-REQ-04 caller subject binding'); + const root = join(tempRoot(), 'missing-brain'); + + const report = runtime.collectBrainDoctorReport( + { + registrySource: registry(), + targetGitUrl: 'https://git.mosaicstack.dev/mosaicstack/stack.git', + brainNamespace: 'mosaicstack', + identity: 'seat-a', + root, + }, + (): CommandResult => ({ + status: 0, + stdout: validateResult('ok', 'validation-verified'), + stderr: '', + }), + ); + + expect(report.access).toMatchObject({ + outcome: 'indeterminate', + exitCode: 30, + reasonCode: 'unexpected-provider-shape', + }); + }); + + it('reads wrong remote and dirty state from git while preserving credential identity binding', async (): Promise => { + const runtime = await loadRuntime('MB-REQ-08 real git observation seam'); + const root = join(tempRoot(), 'brain'); + mkdirSync(join(root, '.git'), { recursive: true }); + const requests: CommandRequest[] = []; + const runner: CommandRunner = (request): CommandResult => { + requests.push(request); + if (request.program === 'mosaic') { + const source = validateResult('ok', 'validation-verified').replaceAll( + 'synthetic-no-token', + 'seat-a', + ); + return { status: 0, stdout: source, stderr: '' }; + } + const command = request.args.join(' '); + 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.uscllc.com/usc/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: '?? uncommitted.md\n', stderr: '' }; + } + return { status: 99, stdout: '', stderr: 'unexpected command' }; + }; + + const report = runtime.collectBrainDoctorReport( + { + registrySource: registry(), + targetGitUrl: 'https://git.mosaicstack.dev/mosaicstack/stack.git', + brainNamespace: 'mosaicstack', + identity: 'seat-a', + root, + }, + runner, + ); + + expect(report.findings.map((finding) => finding.code)).toEqual( + expect.arrayContaining(['brain-remote-mismatch', 'brain-uncommitted-state']), + ); + expect(requests.filter((request) => request.program === 'git')).toHaveLength(4); + for (const request of requests) { + expect(request.env['MOSAIC_GIT_IDENTITY']).toBe('seat-a'); + expect(request.env['GIT_TERMINAL_PROMPT']).toBe('0'); + } + }); + + it('repairs write refusal through mosaic cred, revalidates, then clones and verifies the resulting object', async (): Promise => { + const runtime = await loadRuntime('MB-REQ-08 broker-only doctor repair'); + const root = join(tempRoot(), 'brain'); + const requests: CommandRequest[] = []; + let validationCount = 0; + const runner: CommandRunner = (request): CommandResult => { + requests.push(request); + if (request.program === 'mosaic' && request.args[1] === 'validate') { + validationCount += 1; + if (validationCount === 1) { + return { + status: 10, + stdout: validateResult('refused', 'no-token-for-identity').replaceAll( + 'synthetic-no-token', + 'seat-a', + ), + stderr: 'refused reason=no-token-for-identity', + }; + } + const source = validateResult('ok', 'validation-verified').replaceAll( + 'synthetic-no-token', + 'seat-a', + ); + return { status: 0, stdout: source, stderr: '' }; + } + if (request.program === 'mosaic' && request.args[1] === 'grant') { + return { status: 0, stdout: '{"outcome":"ok"}\n', stderr: '' }; + } + if (request.program === 'git' && request.args[0] === 'clone') { + mkdirSync(join(root, '.git'), { recursive: true }); + return { status: 0, stdout: '', stderr: '' }; + } + const command = request.args.join(' '); + 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.mosaicstack.dev/mosaicstack/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: '', stderr: '' }; + } + return { status: 99, stdout: '', stderr: 'unexpected command' }; + }; + + const report = runtime.repairBrainDoctor( + { + registrySource: registry(), + targetGitUrl: 'https://git.mosaicstack.dev/mosaicstack/stack.git', + brainNamespace: 'mosaicstack', + identity: 'seat-a', + root, + }, + runner, + ); + + expect(report.findings).toEqual([]); + const sequence = requests.map( + (request) => `${request.program}:${request.args[1] ?? request.args[0]}`, + ); + expect(sequence.slice(0, 4)).toEqual([ + 'mosaic:validate', + 'mosaic:grant', + 'mosaic:validate', + 'git:--branch', + ]); + expect(requests.some(requestHasSecretShape)).toBe(false); + }); + + it('does not clone when broker revalidation remains refused after a grant attempt', async (): Promise => { + const runtime = await loadRuntime('MB-REQ-08 failed grant remains visible'); + const root = join(tempRoot(), 'brain'); + const requests: CommandRequest[] = []; + + const report = runtime.repairBrainDoctor( + { + registrySource: registry(), + targetGitUrl: 'https://git.mosaicstack.dev/mosaicstack/stack.git', + brainNamespace: 'mosaicstack', + identity: 'synthetic-no-token', + root, + }, + (request): CommandResult => { + requests.push(request); + if (request.program === 'mosaic' && request.args[1] === 'grant') { + return { status: 10, stdout: '{"outcome":"refused"}\n', stderr: 'refused' }; + } + return { + status: 10, + stdout: validateResult('refused', 'no-token-for-identity'), + stderr: 'refused reason=no-token-for-identity', + }; + }, + ); + + expect(report.findings.map((finding) => finding.code)).toEqual( + expect.arrayContaining(['brain-clone-missing', 'brain-write-access-refused']), + ); + expect(requests.some((request) => request.program === 'git')).toBe(false); + }); +}); + +describe('R5 production refusal control', (): void => { + it('requires matching refusal on both Git transport and broker API axes', async (): Promise => { + const runtime = await loadRuntime('MB-REQ-05 production both-axis refusal'); + const requests: CommandRequest[] = []; + + const result = runtime.collectBrainRefusalControl( + { + registrySource: registry(), + targetGitUrl: 'https://git.mosaicstack.dev/mosaicstack/stack.git', + brainNamespace: 'mosaicstack', + refusalIdentity: 'synthetic-no-token', + }, + (request): CommandResult => { + requests.push(request); + if (request.program === 'mosaic') { + return { + status: 10, + stdout: validateResult('refused', 'no-token-for-identity'), + stderr: 'refused reason=no-token-for-identity', + }; + } + return { + status: 128, + stdout: '', + stderr: 'credential helper refused reason=no-token-for-identity', + }; + }, + ); + + expect(result).toEqual({ + observed: true, + reasonCode: 'no-token-for-identity', + gitReasonCode: 'no-token-for-identity', + apiReasonCode: 'no-token-for-identity', + }); + expect(requests.map((request) => request.program)).toEqual(['mosaic', 'git']); + expect(requests[1]?.args).toEqual([ + 'ls-remote', + 'https://git.mosaicstack.dev/mosaicstack/mosaic-brain.git', + 'HEAD', + ]); + expect( + requests.every((request) => request.env['MOSAIC_GIT_IDENTITY'] === 'synthetic-no-token'), + ).toBe(true); + expect(requests.some(requestHasSecretShape)).toBe(false); + }); + + it('fails when the API refuses but Git transport accepts the out-of-estate identity', async (): Promise => { + const runtime = await loadRuntime('MB-REQ-05 production axis disagreement'); + + const result = runtime.collectBrainRefusalControl( + { + registrySource: registry(), + targetGitUrl: 'https://git.mosaicstack.dev/mosaicstack/stack.git', + brainNamespace: 'mosaicstack', + refusalIdentity: 'synthetic-no-token', + }, + (request): CommandResult => + request.program === 'mosaic' + ? { + status: 10, + stdout: validateResult('refused', 'no-token-for-identity'), + stderr: 'refused reason=no-token-for-identity', + } + : { status: 0, stdout: 'refs are visible', stderr: '' }, + ); + + expect(result).toMatchObject({ + observed: false, + reasonCode: 'permission-evidence-disagrees', + gitReasonCode: 'transport-accepted', + apiReasonCode: 'no-token-for-identity', + }); + }); +}); + +describe('push-on-write publication', (): void => { + it('commits with command-scoped identity, pushes immediately, and proves reachability from origin/main', async (): Promise => { + const runtime = await loadRuntime('MB-REQ-07 publish-on-write reachability'); + const root = tempRoot(); + const requests: CommandRequest[] = []; + const commit = 'a'.repeat(40); + const remoteHead = 'b'.repeat(40); + const runner: CommandRunner = (request): CommandResult => { + requests.push(request); + const command = request.args.join(' '); + if (command.includes('rev-parse HEAD')) + return { status: 0, stdout: `${commit}\n`, stderr: '' }; + if (command.includes('rev-parse origin/main')) { + return { status: 0, stdout: `${remoteHead}\n`, stderr: '' }; + } + return { status: 0, stdout: '', stderr: '' }; + }; + + const evidence = runtime.publishBrainPaths( + { + root, + identity: 'seat-a', + paths: [join(root, '.gitignore'), join(root, 'lanes', 'lane-a', '.gitkeep')], + message: 'migrate lane-a state', + }, + runner, + ); + + expect(evidence).toEqual({ commit, remoteHead, reachable: true }); + 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 .* -c user\.name=seat-a -c user\.email=seat-a@fleet\.mosaicstack\.dev commit/, + ), + expect.stringMatching(/git -C .* push origin HEAD:main/), + expect.stringMatching(/git -C .* fetch origin main/), + expect.stringMatching(/git -C .* merge-base --is-ancestor/), + ]), + ); + const pushIndex = rendered.findIndex((command) => command.includes(' push origin HEAD:main')); + const fetchIndex = rendered.findIndex((command) => command.includes(' fetch origin main')); + expect(pushIndex).toBeGreaterThan(-1); + expect(fetchIndex).toBeGreaterThan(pushIndex); + expect(rendered.join('\n')).not.toMatch(/timer|cron|interval/); + expect(requests.some(requestHasSecretShape)).toBe(false); + }); + + it('rebases and retries a rejected concurrent append-only push instead of choosing last-writer-wins', async (): Promise => { + const runtime = await loadRuntime('MB-REQ-07 append-only multi-host conflict'); + const root = tempRoot(); + const requests: CommandRequest[] = []; + const firstCommit = 'a'.repeat(40); + const rebasedCommit = 'c'.repeat(40); + const remoteHead = 'd'.repeat(40); + let pushes = 0; + let rebased = false; + + const evidence = runtime.publishBrainPaths( + { + root, + identity: 'seat-a', + paths: [join(root, 'lanes', 'lane-a', 'findings', 'host-a.md')], + message: 'append host-a finding', + }, + (request): CommandResult => { + requests.push(request); + const command = request.args.join(' '); + if (command.includes('push origin HEAD:main')) { + pushes += 1; + return pushes === 1 + ? { status: 1, stdout: '', stderr: 'non-fast-forward' } + : { status: 0, stdout: '', stderr: '' }; + } + if (command.includes('rebase origin/main')) { + rebased = true; + return { status: 0, stdout: '', stderr: '' }; + } + if (command.includes('rev-parse HEAD')) { + return { + status: 0, + stdout: `${rebased ? rebasedCommit : firstCommit}\n`, + stderr: '', + }; + } + if (command.includes('rev-parse origin/main')) { + return { status: 0, stdout: `${remoteHead}\n`, stderr: '' }; + } + return { status: 0, stdout: '', stderr: '' }; + }, + ); + + const rendered = requests.map((request) => request.args.join(' ')); + const firstPush = rendered.findIndex((command) => command.includes('push origin HEAD:main')); + const rebase = rendered.findIndex((command) => command.includes('rebase origin/main')); + const secondPush = rendered + .map((command): boolean => command.includes('push origin HEAD:main')) + .lastIndexOf(true); + expect(firstPush).toBeGreaterThan(-1); + expect(rebase).toBeGreaterThan(firstPush); + expect(secondPush).toBeGreaterThan(rebase); + expect(pushes).toBe(2); + expect(evidence).toEqual({ commit: rebasedCommit, remoteHead, reachable: true }); + }); + + it('re-proves reachability without inventing a commit when content was already published', async (): Promise => { + const runtime = await loadRuntime('MB-REQ-07 idempotent publication read-back'); + const root = tempRoot(); + const commit = 'e'.repeat(40); + const requests: CommandRequest[] = []; + + const evidence = runtime.publishBrainPaths( + { + root, + identity: 'seat-a', + paths: [join(root, 'lanes', 'lane-a', 'already-present.md')], + message: 'append existing finding', + }, + (request): CommandResult => { + requests.push(request); + const command = request.args.join(' '); + if (command.includes(' commit ')) { + return { status: 1, stdout: '', stderr: 'nothing to commit' }; + } + if (command.includes('diff --cached --quiet')) { + return { status: 0, stdout: '', stderr: '' }; + } + if (command.includes('rev-parse HEAD') || command.includes('rev-parse origin/main')) { + return { status: 0, stdout: `${commit}\n`, stderr: '' }; + } + return { status: 0, stdout: '', stderr: '' }; + }, + ); + + expect(evidence).toEqual({ commit, remoteHead: commit, reachable: true }); + expect(requests.some((request) => request.args.includes('push'))).toBe(true); + }); + + it('does not manufacture reachability when merge-base rejects the new commit', async (): Promise => { + const runtime = await loadRuntime('MB-REQ-07 publication reachability negative control'); + const root = tempRoot(); + const commit = 'a'.repeat(40); + const remoteHead = 'b'.repeat(40); + + const evidence = runtime.publishBrainPaths( + { + root, + identity: 'seat-a', + paths: [join(root, '.gitignore')], + message: 'seed brain', + }, + (request): CommandResult => { + const command = request.args.join(' '); + if (command.includes('rev-parse HEAD')) { + return { status: 0, stdout: `${commit}\n`, stderr: '' }; + } + if (command.includes('rev-parse origin/main')) { + return { status: 0, stdout: `${remoteHead}\n`, stderr: '' }; + } + if (command.includes('merge-base --is-ancestor')) { + return { status: 1, stdout: '', stderr: '' }; + } + return { status: 0, stdout: '', stderr: '' }; + }, + ); + + expect(evidence).toEqual({ commit, remoteHead, reachable: false }); + }); +}); diff --git a/packages/mosaic/src/commands/brain-store-runtime.ts b/packages/mosaic/src/commands/brain-store-runtime.ts new file mode 100644 index 00000000..81036249 --- /dev/null +++ b/packages/mosaic/src/commands/brain-store-runtime.ts @@ -0,0 +1,393 @@ +import { existsSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { isAbsolute, relative, resolve, sep } from 'node:path'; +import { + assessCredentialResult, + deriveBrainTarget, + evaluateBrainDoctor, + planBrainDoctorFix, + type BrainDoctorFinding, + type BrainDoctorObservation, + type CredentialAssessment, +} from './brain-store.js'; + +const COMMIT = /^[0-9a-f]{40}$/; + +export interface CommandRequest { + readonly program: 'git' | 'mosaic'; + readonly args: readonly string[]; + readonly cwd?: string; + readonly env: Readonly>; +} + +export interface CommandResult { + readonly status: number; + readonly stdout: string; + readonly stderr: string; +} + +export type CommandRunner = (request: CommandRequest) => CommandResult; + +export const systemCommandRunner: CommandRunner = (request: CommandRequest): CommandResult => { + const result = spawnSync(request.program, request.args, { + cwd: request.cwd, + env: { ...process.env, ...request.env }, + encoding: 'utf8', + maxBuffer: 1024 * 1024, + }); + return { + status: result.status ?? 127, + stdout: result.stdout ?? '', + stderr: result.stderr ?? result.error?.message ?? '', + }; +}; + +export interface DoctorRuntimeReport { + readonly findings: readonly BrainDoctorFinding[]; + readonly access: CredentialAssessment; + readonly refusalControl: { + readonly observed: boolean; + readonly reasonCode: string | null; + }; +} + +export interface BrainRefusalControlResult { + readonly observed: boolean; + readonly reasonCode: string | null; + readonly gitReasonCode: string; + readonly apiReasonCode: string; +} + +export interface PublishEvidence { + readonly commit: string; + readonly remoteHead: string; + readonly reachable: boolean; +} + +function commandEnv(identity: string): Readonly> { + return { + MOSAIC_GIT_IDENTITY: identity, + GIT_TERMINAL_PROMPT: '0', + }; +} + +function integrationFailure(): CredentialAssessment { + return { + outcome: 'indeterminate', + exitCode: 30, + reasonCode: 'unexpected-provider-shape', + diagnostic: 'indeterminate: unexpected-provider-shape', + }; +} + +function runGit(run: CommandRunner, identity: string, args: readonly string[]): CommandResult { + return run({ program: 'git', args, env: commandEnv(identity) }); +} + +export function collectBrainDoctorReport( + input: { + readonly registrySource: string; + readonly targetGitUrl: string; + readonly brainNamespace: string; + readonly identity: string; + readonly root: string; + }, + run: CommandRunner, +): DoctorRuntimeReport { + const target = deriveBrainTarget(input.registrySource, input.targetGitUrl, input.brainNamespace); + const validation = run({ + program: 'mosaic', + args: [ + 'cred', + 'validate', + input.identity, + '--estate', + target.estate, + '--host', + target.host, + '--repo', + target.repo, + '--require', + 'write', + '--json', + ], + env: commandEnv(input.identity), + }); + let access = assessCredentialResult(validation.stdout, { + identity: input.identity, + estate: target.estate, + host: target.host, + repo: target.repo, + }); + if (validation.status !== access.exitCode) access = integrationFailure(); + + const rootExists = existsSync(input.root); + let gitRepository = false; + let remote: string | null = null; + let branch: string | null = null; + let dirty: boolean | null = null; + if (rootExists) { + const repository = runGit(run, input.identity, [ + '-C', + input.root, + 'rev-parse', + '--is-inside-work-tree', + ]); + gitRepository = repository.status === 0 && repository.stdout.trim() === 'true'; + if (gitRepository) { + const remoteResult = runGit(run, input.identity, [ + '-C', + input.root, + 'remote', + 'get-url', + 'origin', + ]); + const branchResult = runGit(run, input.identity, [ + '-C', + input.root, + 'branch', + '--show-current', + ]); + const statusResult = runGit(run, input.identity, ['-C', input.root, 'status', '--porcelain']); + if (remoteResult.status === 0) remote = remoteResult.stdout.trim(); + if (branchResult.status === 0) branch = branchResult.stdout.trim(); + if (statusResult.status === 0) dirty = statusResult.stdout.trim().length > 0; + } + } + + const observation: BrainDoctorObservation = { + rootExists, + gitRepository, + remote, + branch, + dirty, + access, + }; + const refusalMarker = `refused reason=${access.reasonCode}`; + const refusalObserved = + validation.status === 10 && + access.outcome === 'refused' && + access.reasonCode === 'no-token-for-identity' && + validation.stderr.includes(refusalMarker); + return { + findings: evaluateBrainDoctor(observation, target.cloneUrl), + access, + refusalControl: { + observed: refusalObserved, + reasonCode: refusalObserved ? access.reasonCode : null, + }, + }; +} + +export function collectBrainRefusalControl( + input: { + readonly registrySource: string; + readonly targetGitUrl: string; + readonly brainNamespace: string; + readonly refusalIdentity: string; + }, + run: CommandRunner, +): BrainRefusalControlResult { + const target = deriveBrainTarget(input.registrySource, input.targetGitUrl, input.brainNamespace); + if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(input.refusalIdentity)) { + return { + observed: false, + reasonCode: 'permission-evidence-disagrees', + gitReasonCode: 'invalid-control-identity', + apiReasonCode: 'invalid-control-identity', + }; + } + const apiResult = run({ + program: 'mosaic', + args: [ + 'cred', + 'validate', + input.refusalIdentity, + '--estate', + target.estate, + '--host', + target.host, + '--repo', + target.repo, + '--require', + 'write', + '--json', + ], + env: commandEnv(input.refusalIdentity), + }); + let api = assessCredentialResult(apiResult.stdout, { + identity: input.refusalIdentity, + estate: target.estate, + host: target.host, + repo: target.repo, + }); + if (apiResult.status !== api.exitCode) api = integrationFailure(); + + const gitResult = runGit(run, input.refusalIdentity, ['ls-remote', target.cloneUrl, 'HEAD']); + const marker = /(?:^|\s)reason=([a-z0-9-]+)(?:\s|$)/.exec(gitResult.stderr)?.[1]; + const stableRefusals = new Set([ + 'identity-required', + 'estate-required', + 'estate-host-mismatch', + 'cross-estate-resolution', + 'no-token-for-identity', + 'tea-login-missing', + 'tea-login-host-mismatch', + 'provider-identity-mismatch', + 'credential-rejected', + 'permission-denied', + 'organization-membership-required', + 'team-membership-required', + ]); + const gitReasonCode = + gitResult.status === 0 + ? 'transport-accepted' + : marker !== undefined && stableRefusals.has(marker) && gitResult.stdout.length === 0 + ? marker + : 'transport-indeterminate'; + const observed = + api.outcome === 'refused' && + gitReasonCode !== 'transport-accepted' && + gitReasonCode !== 'transport-indeterminate' && + gitReasonCode === api.reasonCode; + return { + observed, + reasonCode: observed ? api.reasonCode : 'permission-evidence-disagrees', + gitReasonCode, + apiReasonCode: api.reasonCode, + }; +} + +export function repairBrainDoctor( + input: { + readonly registrySource: string; + readonly targetGitUrl: string; + readonly brainNamespace: string; + readonly identity: string; + readonly root: string; + }, + run: CommandRunner, +): DoctorRuntimeReport { + const target = deriveBrainTarget(input.registrySource, input.targetGitUrl, input.brainNamespace); + let report = collectBrainDoctorReport(input, run); + const actions = planBrainDoctorFix({ + findings: report.findings, + target, + identity: input.identity, + root: input.root, + }); + for (const action of actions) { + if (action.program === 'mosaic') { + run({ program: 'mosaic', args: action.args, env: commandEnv(input.identity) }); + report = collectBrainDoctorReport(input, run); + if (report.access.outcome !== 'ok') return report; + continue; + } + const result = runGit(run, input.identity, action.args); + if (result.status !== 0) return collectBrainDoctorReport(input, run); + } + return collectBrainDoctorReport(input, run); +} + +function requireSuccess(result: CommandResult, operation: string): void { + if (result.status !== 0) throw new Error(`${operation}-failed`); +} + +function containedRelative(root: string, path: string): string { + if (isAbsolute(path) === false) throw new Error('brain-publish-path-must-be-absolute'); + const absoluteRoot = resolve(root); + const absolutePath = resolve(path); + if (absolutePath === absoluteRoot || !absolutePath.startsWith(`${absoluteRoot}${sep}`)) { + throw new Error('brain-publish-path-escaped-root'); + } + return relative(absoluteRoot, absolutePath).split(sep).join('/'); +} + +export function publishBrainPaths( + input: { + readonly root: string; + readonly identity: string; + readonly paths: readonly string[]; + readonly message: string; + }, + run: CommandRunner, +): PublishEvidence { + if (input.paths.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)); + + requireSuccess( + runGit(run, input.identity, ['-C', input.root, 'add', '--', ...paths]), + 'brain-git-add', + ); + const commitAttempt = runGit(run, input.identity, [ + '-C', + input.root, + '-c', + `user.name=${input.identity}`, + '-c', + `user.email=${input.identity}@fleet.mosaicstack.dev`, + 'commit', + '-m', + input.message, + ]); + if (commitAttempt.status !== 0) { + const stagedDifference = runGit(run, input.identity, [ + '-C', + input.root, + 'diff', + '--cached', + '--quiet', + '--exit-code', + ]); + if (stagedDifference.status !== 0) throw new Error('brain-git-commit-failed'); + } + const readHead = (): string => { + const result = runGit(run, input.identity, ['-C', input.root, 'rev-parse', 'HEAD']); + requireSuccess(result, 'brain-git-read-commit'); + const value = result.stdout.trim(); + if (!COMMIT.test(value)) throw new Error('brain-git-commit-shape-invalid'); + return value; + }; + let commit = readHead(); + let pushed = false; + for (let attempt = 0; attempt < 3; attempt += 1) { + const push = runGit(run, input.identity, ['-C', input.root, 'push', 'origin', 'HEAD:main']); + if (push.status === 0) { + pushed = true; + break; + } + const concurrentUpdate = /non-fast-forward|fetch first|\[rejected\]/i.test(push.stderr); + if (!concurrentUpdate || attempt === 2) throw new Error('brain-git-push-failed'); + requireSuccess( + runGit(run, input.identity, ['-C', input.root, 'fetch', 'origin', 'main']), + 'brain-git-fetch-concurrent', + ); + requireSuccess( + runGit(run, input.identity, ['-C', input.root, 'rebase', 'origin/main']), + 'brain-git-rebase-concurrent', + ); + commit = readHead(); + } + if (!pushed) throw new Error('brain-git-push-failed'); + requireSuccess( + runGit(run, input.identity, ['-C', input.root, 'fetch', 'origin', 'main']), + 'brain-git-fetch-readback', + ); + const reachableResult = runGit(run, input.identity, [ + '-C', + input.root, + 'merge-base', + '--is-ancestor', + commit, + 'origin/main', + ]); + if (reachableResult.status !== 0 && reachableResult.status !== 1) { + throw new Error('brain-git-reachability-check-failed'); + } + const remoteResult = runGit(run, input.identity, ['-C', input.root, 'rev-parse', 'origin/main']); + requireSuccess(remoteResult, 'brain-git-read-remote-head'); + const remoteHead = remoteResult.stdout.trim(); + if (!COMMIT.test(remoteHead)) throw new Error('brain-git-remote-head-shape-invalid'); + return { commit, remoteHead, reachable: reachableResult.status === 0 }; +} diff --git a/packages/mosaic/src/commands/brain-store.spec.ts b/packages/mosaic/src/commands/brain-store.spec.ts index ab7961ad..df0800e4 100644 --- a/packages/mosaic/src/commands/brain-store.spec.ts +++ b/packages/mosaic/src/commands/brain-store.spec.ts @@ -1,5 +1,14 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -7,7 +16,7 @@ import { join } from 'node:path'; * Red-first contract checks for stack #1051 / MB-BRAIN-01. * * These checks are committed before the implementation. They bind to the - * MC-CRED v1.3 terminal classes and stable reason codes, not to the currently + * MC-CRED v1.5 terminal classes and stable reason codes, not to the currently * deployed resolver behavior. Live grant and read/write round-trip tests remain * gated on MC-CRED-01; these fixtures contain no credential values. */ @@ -32,10 +41,23 @@ interface ResolverParityAssessment extends CredentialAssessment { readonly apiReasonCode: string; } -interface MigrationOwner { - readonly name: string; - readonly kind: 'active-lane' | 'durable-team' | 'durable-human' | 'durable-queue'; - readonly validated: boolean; +interface MigrationOwnerResolution { + readonly verdict: 'resolved' | 'refused' | 'not-measured'; + readonly reasonCode: string; + readonly principal: { + readonly name: string; + readonly kind: + | 'active-lane' + | 'durable-team' + | 'durable-human' + | 'durable-queue' + | 'mission-seat'; + } | null; + readonly authority: { + readonly system: 'gitea' | 'glpi' | 'mosaic-mission-state'; + readonly endpoint: string; + readonly contentType: 'application/json'; + } | null; } interface MigrationCandidate { @@ -49,7 +71,7 @@ interface MigrationPlan { readonly status: 'ready' | 'blocked'; readonly candidates: readonly MigrationCandidate[]; readonly reported: readonly { path: string; reason: string }[]; - readonly owner: MigrationOwner | null; + readonly owner: MigrationOwnerResolution['principal']; } interface MigrationPublishEvidence { @@ -93,18 +115,24 @@ interface BrainWritePolicy { } interface BrainStoreModule { - deriveBrainTarget(registrySource: string, targetGitUrl: string): BrainTarget; + deriveBrainTarget( + registrySource: string, + targetGitUrl: string, + brainNamespace: string, + ): BrainTarget; createBrainSkeleton(root: string): { readonly created: readonly string[] }; assessCredentialResult(source: string): CredentialAssessment; assessResolverParity(gitSource: string, apiSource: string): ResolverParityAssessment; - discoverBrainMigration(input: { - readonly sourceRoot: string; - readonly brainRoot: string; - readonly seat: string; - readonly lane: string; - readonly laneActive: boolean; - readonly owner?: MigrationOwner; - }): MigrationPlan; + discoverBrainMigration( + input: { + readonly sourceRoot: string; + readonly brainRoot: string; + readonly seat: string; + readonly lane: string; + readonly laneActive: boolean; + }, + resolveOwner?: (lane: string) => MigrationOwnerResolution, + ): MigrationPlan; migrateBrainState( plan: MigrationPlan, publish: (brainRoot: string, paths: readonly string[]) => MigrationPublishEvidence, @@ -182,6 +210,11 @@ function credentialResult( outcome: CredentialAssessment['outcome'], reasonCode: string, message = 'non-secret diagnostic', + providerLogin: string | null = outcome === 'ok' + ? 'external-seat' + : reasonCode === 'provider-identity-mismatch' + ? 'Mos' + : null, ): string { const exits = { ok: 0, refused: 10, error: 20, indeterminate: 30 } as const; return JSON.stringify({ @@ -199,9 +232,41 @@ function credentialResult( mutation: 'none', reason: { code: reasonCode, message }, evidence: { - providerIdentity: null, - repositoryPermission: null, - writeDifferential: null, + providerIdentity: + providerLogin === null + ? null + : { + login: providerLogin, + endpoint: 'GET /api/v1/user', + contentType: 'application/json', + }, + repositoryPermission: + outcome === 'ok' + ? { + requested: 'write', + effective: 'write', + endpoint: 'GET /api/v1/repos/mosaicstack/mosaic-brain', + contentType: 'application/json', + } + : null, + writeDifferential: + outcome === 'ok' + ? { + state: 'can-write', + credentialBinding: 'same-resolution', + transportPrincipal: 'external-seat', + authenticatedReceivePack: 'advertised', + readOnlyControl: { + identity: 'homelab-read-control', + providerPermission: 'read', + receivePack: 'refused', + }, + unauthenticatedReceivePack: 'refused', + artifactCreated: false, + proves: 'non-secret evidence', + doesNotProve: 'branch update acceptance', + } + : null, }, audit: { journalId: 'opaque', state: 'sealed' }, }); @@ -218,13 +283,17 @@ describe('R2/Q1 — estate and brain discovery have one authority', (): void => const sut = await loadSut('MB-REQ-02 target-host estate derivation'); expect( - sut.deriveBrainTarget(registry(), 'https://git.mosaicstack.dev/mosaicstack/stack.git'), + sut.deriveBrainTarget( + registry(), + 'https://git.mosaicstack.dev/mosaicstack/stack.git', + 'durable-owner', + ), ).toEqual({ estate: 'homelab', host: 'git.mosaicstack.dev', - owner: 'mosaicstack', - repo: 'mosaicstack/mosaic-brain', - cloneUrl: 'https://git.mosaicstack.dev/mosaicstack/mosaic-brain.git', + owner: 'durable-owner', + repo: 'durable-owner/mosaic-brain', + cloneUrl: 'https://git.mosaicstack.dev/durable-owner/mosaic-brain.git', }); }); @@ -234,7 +303,11 @@ describe('R2/Q1 — estate and brain discovery have one authority', (): void => process.env['MOSAIC_ESTATE'] = 'homelab'; try { expect(() => - sut.deriveBrainTarget(registry(), 'https://unmapped.example.invalid/acme/stack.git'), + sut.deriveBrainTarget( + registry(), + 'https://unmapped.example.invalid/acme/stack.git', + 'durable-owner', + ), ).toThrow(/estate-host-unmapped/); } finally { if (previous === undefined) delete process.env['MOSAIC_ESTATE']; @@ -252,11 +325,41 @@ describe('R6 — brain layout refuses secret material', (): void => { for (const directory of ['agents', 'lanes', 'board', 'specs', 'methods', 'archives']) { expect(existsSync(join(root, directory)), directory).toBe(true); + expect( + existsSync(join(root, directory, '.gitkeep')), + `${directory} tracked placeholder`, + ).toBe(true); } const rules = readFileSync(join(root, '.gitignore'), 'utf8').trim().split('\n'); expect(rules).toEqual(['*.token', '*.key', '*.pem', '.env', 'credentials.json']); }); + 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(); + const brain = join(root, 'brain'); + const outside = join(root, 'outside'); + mkdirSync(brain); + mkdirSync(outside); + symlinkSync(outside, join(brain, 'agents')); + + expect(() => sut.createBrainSkeleton(brain)).toThrow(/brain-layout-directory-unsafe/); + expect(existsSync(join(outside, '.gitkeep'))).toBe(false); + }); + + it('refuses a symlinked gitignore instead of reading and tracking its external target', async (): Promise => { + const sut = await loadSut('MB-REQ-06 no-follow gitignore'); + const root = tempRoot(); + const brain = join(root, 'brain'); + const outside = join(root, 'outside-secret'); + mkdirSync(brain); + writeFileSync(outside, 'DO-NOT-TRACK\n'); + symlinkSync(outside, join(brain, '.gitignore')); + + expect(() => sut.createBrainSkeleton(brain)).toThrow(/brain-layout-ignore-unsafe/); + expect(readFileSync(outside, 'utf8')).toBe('DO-NOT-TRACK\n'); + }); + it('never relays broker reason messages that may contain secret-bearing text', async (): Promise => { const sut = await loadSut('MB-REQ-06 secret-free error path'); const secretMarker = 'DO-NOT-EMIT-CREDENTIAL-MARKER'; @@ -270,14 +373,16 @@ describe('R6 — brain layout refuses secret material', (): void => { }); }); -describe('credential caller contract v1.3 terminal classes', (): void => { +describe('credential caller contract v1.5 terminal classes', (): void => { it.each([ - ['ok', 0, 'grant-verified'], + ['ok', 0, 'validation-verified'], ['refused', 10, 'no-token-for-identity'], + ['refused', 10, 'provider-identity-mismatch'], ['error', 20, 'estate-registry-invalid'], ['indeterminate', 30, 'provider-unavailable'], - ['indeterminate', 30, 'identity-not-found'], - ['indeterminate', 30, 'credential-rejected'], + ['indeterminate', 30, 'identity-not-visible'], + ['indeterminate', 30, 'identity-not-measured'], + ['refused', 10, 'credential-rejected'], ] as const)( 'preserves %s/%i and stable reason %s without parsing prose', async (outcome, exitCode, reasonCode): Promise => { @@ -293,7 +398,7 @@ describe('credential caller contract v1.3 terminal classes', (): void => { it('makes a missing or inconsistent decision field indeterminate rather than success or refusal', async (): Promise => { const sut = await loadSut('AC-MB-01 malformed broker result fail-closed'); - const malformed = JSON.parse(credentialResult('ok', 'grant-verified')) as Record< + const malformed = JSON.parse(credentialResult('ok', 'validation-verified')) as Record< string, unknown >; @@ -305,6 +410,66 @@ describe('credential caller contract v1.3 terminal classes', (): void => { expect(result.exitCode).toBe(30); expect(result.reasonCode).toBe('unexpected-provider-shape'); }); + + it('makes an apparent write ok indeterminate when the side-effect-free differential is absent', async (): Promise => { + const sut = await loadSut('AC-MB-01 required write differential'); + const raw = JSON.parse(credentialResult('ok', 'validation-verified')) as { + evidence: { writeDifferential: unknown }; + }; + raw.evidence.writeDifferential = null; + + const result = sut.assessCredentialResult(JSON.stringify(raw)); + + expect(result).toMatchObject({ + outcome: 'indeterminate', + exitCode: 30, + reasonCode: 'readback-missing', + }); + }); + + it('rejects the superseded indeterminate/credential-rejected pairing from before the v1.4 correction', async (): Promise => { + const sut = await loadSut('AC-MB-01 credential-rejected stable class'); + + const legacy = JSON.parse(credentialResult('indeterminate', 'provider-unavailable')) as Record< + string, + unknown + >; + legacy['reason'] = { + code: 'credential-rejected', + message: 'superseded classification', + }; + const result = sut.assessCredentialResult(JSON.stringify(legacy)); + + expect(result.outcome).toBe('indeterminate'); + expect(result.exitCode).toBe(30); + expect(result.reasonCode).toBe('unexpected-provider-shape'); + }); + + it('preserves scope-limited identity as not-measured rather than a dead credential refusal', async (): Promise => { + const sut = await loadSut('AC-MB-01 scope-limited identity is not dead credential'); + + const result = sut.assessCredentialResult( + credentialResult('indeterminate', 'identity-not-measured'), + ); + + expect(result).toMatchObject({ + outcome: 'indeterminate', + exitCode: 30, + reasonCode: 'identity-not-measured', + }); + }); + + it('refuses an apparent ok whose provider /user read-back names a different principal', async (): Promise => { + const sut = await loadSut('AC-MB-01 provider identity MISMATCH is first-class'); + + const result = sut.assessCredentialResult( + credentialResult('ok', 'validation-verified', 'looks successful', 'Mos'), + ); + + expect(result.outcome).toBe('refused'); + expect(result.exitCode).toBe(10); + expect(result.reasonCode).toBe('provider-identity-mismatch'); + }); }); describe('R5 — out-of-estate refusal must agree on both resolver axes', (): void => { @@ -324,7 +489,7 @@ describe('R5 — out-of-estate refusal must agree on both resolver axes', (): vo it.each([ [ credentialResult('refused', 'no-token-for-identity'), - credentialResult('ok', 'grant-verified'), + credentialResult('ok', 'validation-verified'), ], [ credentialResult('refused', 'no-token-for-identity'), @@ -346,8 +511,19 @@ describe('R5 — out-of-estate refusal must agree on both resolver axes', (): vo }); describe('R7 — migration is non-destructive, append-only, and explicit', (): void => { - it('detects canonical lane and current-seat state while explicitly reporting unsupported local state', async (): Promise => { - const sut = await loadSut('MB-REQ-07 migration detection/reporting'); + const activeLaneOwner = (): MigrationOwnerResolution => ({ + verdict: 'resolved', + reasonCode: 'active-lane-owner-verified', + principal: { name: 'lane:lane-a', kind: 'active-lane' }, + authority: { + system: 'mosaic-mission-state', + endpoint: 'file:///var/lib/mosaic/missions/lane-a.json', + contentType: 'application/json', + }, + }); + + it('uses an injected authoritative owner resolver for unit mechanics without claiming live owner validation', async (): Promise => { + const sut = await loadSut('MB-REQ-07 injected owner-resolver seam'); const root = tempRoot(); const sourceRoot = join(root, 'local-memory'); const brainRoot = join(root, 'brain'); @@ -357,16 +533,13 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v writeFileSync(join(sourceRoot, 'agents', 'seat-a', 'STATE.md'), 'seat state\n'); writeFileSync(join(sourceRoot, 'orphan-state.md'), 'must be reported\n'); - const plan = sut.discoverBrainMigration({ - sourceRoot, - brainRoot, - seat: 'seat-a', - lane: 'lane-a', - laneActive: true, - owner: { name: 'lane-a', kind: 'active-lane', validated: true }, - }); + const plan = sut.discoverBrainMigration( + { sourceRoot, brainRoot, seat: 'seat-a', lane: 'lane-a', laneActive: true }, + activeLaneOwner, + ); expect(plan.status).toBe('ready'); + expect(plan.owner).toEqual({ name: 'lane:lane-a', kind: 'active-lane' }); expect(plan.candidates.map((candidate) => candidate.kind).sort()).toEqual(['lane', 'seat']); expect(plan.reported).toEqual( expect.arrayContaining([ @@ -375,20 +548,22 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v ); }); - it('leaves the migration gate blocking when no validated owner exists', async (): Promise => { - const sut = await loadSut('MB-REQ-09 named durable owner gate'); + it('ignores a caller-asserted owner string/validated flag and leaves the gate blocking', async (): Promise => { + const sut = await loadSut('MB-REQ-09 caller owner assertion cannot satisfy gate'); const root = tempRoot(); const sourceRoot = join(root, 'local-memory'); mkdirSync(join(sourceRoot, 'lanes', 'closed-lane'), { recursive: true }); writeFileSync(join(sourceRoot, 'lanes', 'closed-lane', 'finding.md'), 'finding\n'); - - const plan = sut.discoverBrainMigration({ + const callerAssertion = { sourceRoot, brainRoot: join(root, 'brain'), seat: 'seat-a', lane: 'closed-lane', laneActive: false, - }); + owner: { name: 'some-string', kind: 'durable-team', validated: true }, + }; + + const plan = sut.discoverBrainMigration(callerAssertion); expect(plan.status).toBe('blocked'); expect(plan.candidates).toHaveLength(0); @@ -399,6 +574,74 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v ); }); + it.each([ + ['mission seat', { name: 'user:be-coder-07', kind: 'mission-seat' }], + ['unicode dash', { name: 'team:platform–maintainers', kind: 'durable-team' }], + ['padded', { name: ' team:platform-maintainers ', kind: 'durable-team' }], + ['dot presentation', { name: 'team:platform.maintainers', kind: 'durable-team' }], + ['space presentation', { name: 'team:platform maintainers', kind: 'durable-team' }], + ] as const)( + 'rejects %s owner evidence through the injected resolver allowlist', + async (_caseName, principal): Promise => { + const sut = await loadSut('MB-REQ-09 durable-owner allowlist'); + const root = tempRoot(); + const sourceRoot = join(root, 'local-memory'); + mkdirSync(join(sourceRoot, 'lanes', 'closed-lane'), { recursive: true }); + writeFileSync(join(sourceRoot, 'lanes', 'closed-lane', 'finding.md'), 'finding\n'); + + let resolverCalls = 0; + const plan = sut.discoverBrainMigration( + { + sourceRoot, + brainRoot: join(root, 'brain'), + seat: 'seat-a', + lane: 'closed-lane', + laneActive: false, + }, + (): MigrationOwnerResolution => { + resolverCalls += 1; + return { + verdict: 'resolved', + reasonCode: 'owner-verified', + principal, + authority: { + system: 'gitea', + endpoint: 'GET /api/v1/teams/1', + contentType: 'application/json', + }, + }; + }, + ); + + expect(resolverCalls).toBe(1); + expect(plan.status).toBe('blocked'); + expect(plan.candidates).toHaveLength(0); + }, + ); + + it('reports secret-shaped legacy files without ever copying them into the brain', async (): Promise => { + const sut = await loadSut('MB-REQ-06 migration secret exclusion'); + const root = tempRoot(); + const sourceRoot = join(root, 'local-memory'); + 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']) { + writeFileSync(join(laneRoot, name), 'DO-NOT-MIGRATE\n'); + } + + const plan = sut.discoverBrainMigration( + { sourceRoot, brainRoot, seat: 'seat-a', lane: 'lane-a', laneActive: true }, + activeLaneOwner, + ); + + expect(plan.status).toBe('ready'); + expect(plan.candidates).toHaveLength(0); + expect(plan.reported).toHaveLength(5); + expect(plan.reported.every((entry) => /secret/i.test(entry.reason))).toBe(true); + expect(existsSync(brainRoot)).toBe(false); + }); + it('publishes collision-safe append-only copies before archiving sources and never overwrites a finding', async (): Promise => { const sut = await loadSut('MB-REQ-07 append-only publish-before-archive migration'); const root = tempRoot(); @@ -406,14 +649,10 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v const brainRoot = join(root, 'brain'); mkdirSync(join(sourceRoot, 'lanes', 'lane-a'), { recursive: true }); writeFileSync(join(sourceRoot, 'lanes', 'lane-a', 'finding.md'), 'new finding\n'); - const plan = sut.discoverBrainMigration({ - sourceRoot, - brainRoot, - seat: 'seat-a', - lane: 'lane-a', - laneActive: true, - owner: { name: 'lane-a', kind: 'active-lane', validated: true }, - }); + const plan = sut.discoverBrainMigration( + { sourceRoot, brainRoot, seat: 'seat-a', lane: 'lane-a', laneActive: true }, + activeLaneOwner, + ); const candidate = plan.candidates[0]; expect(candidate).toBeDefined(); if (candidate === undefined) return; @@ -447,6 +686,39 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v expect(publishedPaths).toContain(candidate.archive); }); + it('refuses nested symlink destinations without copying a migration outside the brain', async (): Promise => { + const sut = await loadSut('MB-REQ-07 migration destination no-follow'); + const root = tempRoot(); + const sourceRoot = join(root, 'local-memory'); + const brainRoot = join(root, 'brain'); + const outside = join(root, 'outside'); + mkdirSync(join(sourceRoot, 'lanes', 'lane-a'), { recursive: true }); + mkdirSync(join(brainRoot, 'lanes'), { recursive: true }); + mkdirSync(outside); + const source = join(sourceRoot, 'lanes', 'lane-a', 'finding.md'); + writeFileSync(source, 'lane state\n'); + symlinkSync(outside, join(brainRoot, 'lanes', 'lane-a')); + const plan = sut.discoverBrainMigration( + { sourceRoot, brainRoot, seat: 'seat-a', lane: 'lane-a', laneActive: true }, + activeLaneOwner, + ); + let publishCalls = 0; + + const result = sut.migrateBrainState( + plan, + (): MigrationPublishEvidence => { + publishCalls += 1; + return { commit: 'a'.repeat(40), remoteHead: 'a'.repeat(40), reachable: true }; + }, + brainRoot, + ); + + expect(result.status).toBe('failed'); + expect(readFileSync(source, 'utf8')).toBe('lane state\n'); + expect(readdirSync(outside)).toEqual([]); + expect(publishCalls).toBe(0); + }); + 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(); @@ -455,14 +727,10 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v mkdirSync(join(sourceRoot, 'agents', 'seat-a'), { recursive: true }); const source = join(sourceRoot, 'agents', 'seat-a', 'STATE.md'); writeFileSync(source, 'seat state\n'); - const plan = sut.discoverBrainMigration({ - sourceRoot, - brainRoot, - seat: 'seat-a', - lane: 'lane-a', - laneActive: true, - owner: { name: 'lane-a', kind: 'active-lane', validated: true }, - }); + const plan = sut.discoverBrainMigration( + { sourceRoot, brainRoot, seat: 'seat-a', lane: 'lane-a', laneActive: true }, + activeLaneOwner, + ); const result = sut.migrateBrainState( plan, @@ -476,6 +744,9 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v expect(result.status).toBe('failed'); expect(readFileSync(source, 'utf8')).toBe('seat state\n'); + expect(plan.candidates[0]).toBeDefined(); + expect(existsSync(plan.candidates[0]!.destination)).toBe(true); + expect(existsSync(plan.candidates[0]!.archive)).toBe(true); expect(result.reported).toEqual( expect.arrayContaining([ expect.objectContaining({ reason: expect.stringMatching(/reachab/i) }), @@ -496,11 +767,13 @@ describe('R8 — doctor diagnoses defects and fixes only through approved seams' remote: null, branch: null, dirty: null, - access: null, + access: sut.assessCredentialResult(credentialResult('refused', 'no-token-for-identity')), }, expected, ); - expect(missing.map((finding) => finding.code)).toContain('brain-clone-missing'); + expect(missing.map((finding) => finding.code)).toEqual( + expect.arrayContaining(['brain-clone-missing', 'brain-write-access-refused']), + ); const defects = sut.evaluateBrainDoctor( { @@ -529,6 +802,7 @@ describe('R8 — doctor diagnoses defects and fixes only through approved seams' const target = sut.deriveBrainTarget( registry(), 'https://git.mosaicstack.dev/mosaicstack/stack.git', + 'durable-owner', ); const findings: BrainDoctorFinding[] = [ { code: 'brain-clone-missing', repairable: true, reasonCode: null }, @@ -545,7 +819,9 @@ describe('R8 — doctor diagnoses defects and fixes only through approved seams' }); const rendered = JSON.stringify(actions); - expect(actions.map((action) => action.program)).toEqual(['git', 'git', 'mosaic']); + expect(actions.map((action) => action.program)).toEqual(['mosaic', 'git', 'git']); + expect(actions[0]?.findingCode).toBe('brain-write-access-refused'); + expect(actions[1]?.findingCode).toBe('brain-clone-missing'); expect(rendered).toContain('cred'); expect(rendered).toContain('grant'); expect(rendered).toContain('--estate'); @@ -559,6 +835,7 @@ describe('R8 — doctor diagnoses defects and fixes only through approved seams' const target = sut.deriveBrainTarget( registry(), 'https://git.mosaicstack.dev/mosaicstack/stack.git', + 'durable-owner', ); for (const finding of [ diff --git a/packages/mosaic/src/commands/brain-store.ts b/packages/mosaic/src/commands/brain-store.ts new file mode 100644 index 00000000..0da2e559 --- /dev/null +++ b/packages/mosaic/src/commands/brain-store.ts @@ -0,0 +1,955 @@ +import { + closeSync, + constants as fsConstants, + copyFileSync, + existsSync, + fsyncSync, + lstatSync, + linkSync, + mkdirSync, + openSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { createHash, randomUUID } from 'node:crypto'; +import { z } from 'zod'; +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 BRAIN_DIRECTORIES = ['agents', 'lanes', 'board', 'specs', 'methods', 'archives'] as const; +const TERMINAL_EXITS = { + ok: 0, + refused: 10, + error: 20, + indeterminate: 30, +} as const; +const STABLE_REASON_CLASSES: Readonly> = { + 'identity-required': 'refused', + 'estate-required': 'refused', + 'estate-host-mismatch': 'refused', + 'cross-estate-resolution': 'refused', + 'no-token-for-identity': 'refused', + 'tea-login-missing': 'refused', + 'tea-login-host-mismatch': 'refused', + 'provider-identity-mismatch': 'refused', + 'credential-rejected': 'refused', + 'permission-denied': 'refused', + 'organization-membership-required': 'refused', + 'team-membership-required': 'refused', + 'invalid-input': 'error', + 'estate-registry-invalid': 'error', + 'insecure-credential-source': 'error', + 'journal-unavailable': 'error', + 'internal-invariant': 'error', + 'provider-unavailable': 'indeterminate', + 'identity-not-visible': 'indeterminate', + 'identity-not-measured': 'indeterminate', + 'identity-not-found': 'indeterminate', + 'unexpected-content-type': 'indeterminate', + 'unexpected-provider-shape': 'indeterminate', + 'scope-not-evaluable': 'indeterminate', + 'permission-evidence-disagrees': 'indeterminate', + 'transport-principal-mismatch': 'indeterminate', + 'read-only-control-invalid': 'indeterminate', + 'readback-missing': 'indeterminate', + 'mutation-state-unknown': 'indeterminate', +}; + +export interface BrainTarget { + readonly estate: string; + readonly host: string; + readonly owner: string; + readonly repo: string; + readonly cloneUrl: string; +} + +export type CredentialOutcome = keyof typeof TERMINAL_EXITS; + +export interface CredentialAssessment { + readonly outcome: CredentialOutcome; + readonly exitCode: 0 | 10 | 20 | 30; + readonly reasonCode: string; + readonly diagnostic: string; +} + +export interface ResolverParityAssessment extends CredentialAssessment { + readonly gitReasonCode: string; + readonly apiReasonCode: string; +} + +export interface MigrationOwnerResolution { + readonly verdict: 'resolved' | 'refused' | 'not-measured'; + readonly reasonCode: string; + readonly principal: { + readonly name: string; + readonly kind: + | 'active-lane' + | 'durable-team' + | 'durable-human' + | 'durable-queue' + | 'mission-seat'; + } | null; + readonly authority: { + readonly system: 'gitea' | 'glpi' | 'mosaic-mission-state'; + readonly endpoint: string; + readonly contentType: 'application/json'; + } | null; +} + +export interface MigrationCandidate { + readonly source: string; + readonly destination: string; + readonly archive: string; + readonly kind: 'lane' | 'seat'; +} + +export interface MigrationReport { + readonly path: string; + readonly reason: string; +} + +export interface MigrationPlan { + readonly status: 'ready' | 'blocked'; + readonly candidates: readonly MigrationCandidate[]; + readonly reported: readonly MigrationReport[]; + readonly owner: MigrationOwnerResolution['principal']; +} + +export interface MigrationPublishEvidence { + readonly commit: string; + readonly remoteHead: string; + readonly reachable: boolean; +} + +export interface MigrationResult { + readonly status: 'migrated' | 'reported' | 'failed'; + readonly migrated: readonly MigrationCandidate[]; + readonly reported: readonly MigrationReport[]; + readonly publish: MigrationPublishEvidence | null; +} + +export interface BrainDoctorObservation { + readonly rootExists: boolean; + readonly gitRepository: boolean; + readonly remote: string | null; + readonly branch: string | null; + readonly dirty: boolean | null; + readonly access: CredentialAssessment | null; +} + +export interface BrainDoctorFinding { + readonly code: string; + readonly repairable: boolean; + readonly reasonCode: string | null; +} + +export interface BrainDoctorAction { + readonly program: 'git' | 'mosaic'; + readonly args: readonly string[]; + readonly findingCode: string; +} + +export interface BrainWritePolicy { + readonly allowed: boolean; + readonly mode: 'append-only' | 'single-writer' | 'seat-writer' | 'refused'; + readonly reason: string; +} + +interface ParsedGitTarget { + readonly host: string; + readonly owner: string; +} + +const providerIdentitySchema = z + .object({ + login: z.string().min(1), + endpoint: z.literal('GET /api/v1/user'), + contentType: z.string().min(1), + }) + .passthrough(); + +const repositoryPermissionSchema = z + .object({ + requested: z.literal('write'), + effective: z.enum(['write', 'admin']), + endpoint: z.string().min(1), + contentType: z.string().min(1), + }) + .passthrough(); + +const writeDifferentialSchema = z + .object({ + state: z.literal('can-write'), + credentialBinding: z.literal('same-resolution'), + transportPrincipal: z.string().min(1), + authenticatedReceivePack: z.literal('advertised'), + readOnlyControl: z + .object({ + identity: z.string().min(1), + providerPermission: z.literal('read'), + receivePack: z.literal('refused'), + }) + .passthrough(), + unauthenticatedReceivePack: z.literal('refused'), + artifactCreated: z.literal(false), + proves: z.string().min(1), + doesNotProve: z.string().min(1), + }) + .passthrough(); + +const credentialResultSchema = z + .object({ + schemaVersion: z.literal(1), + operation: z.literal('validate'), + outcome: z.enum(['ok', 'refused', 'error', 'indeterminate']), + exitCode: z.number().int(), + subject: z + .object({ + identity: z.string().min(1), + estate: z.string().min(1), + host: z.string().min(1), + repo: z.string().min(1), + }) + .strict(), + mutation: z.enum(['none', 'not-started', 'applied', 'unknown']), + reason: z + .object({ + code: z.string().min(1), + message: z.string(), + }) + .passthrough(), + evidence: z + .object({ + providerIdentity: providerIdentitySchema.nullable(), + repositoryPermission: repositoryPermissionSchema.nullable(), + writeDifferential: writeDifferentialSchema.nullable(), + }) + .passthrough(), + audit: z + .object({ + journalId: z.string().nullable(), + state: z.enum(['not-started', 'open', 'sealed']), + }) + .passthrough(), + }) + .passthrough(); + +function safeName(value: string, label: string): string { + if (!SAFE_NAME.test(value)) { + throw new Error(`invalid-${label}`); + } + return value; +} + +function parseGitTarget(targetGitUrl: string): ParsedGitTarget { + let host = ''; + let pathname = ''; + + if (/^[^@\s]+@[^:\s]+:.+$/.test(targetGitUrl)) { + const separator = targetGitUrl.indexOf(':'); + const authority = targetGitUrl.slice(0, separator); + host = authority.slice(authority.lastIndexOf('@') + 1); + pathname = targetGitUrl.slice(separator + 1); + } else { + let parsed: URL; + try { + parsed = new URL(targetGitUrl); + } catch { + throw new Error('target-git-url-invalid'); + } + if (!['https:', 'ssh:'].includes(parsed.protocol) || parsed.password !== '') { + throw new Error('target-git-url-invalid'); + } + if (parsed.protocol === 'https:' && parsed.username !== '') { + throw new Error('target-git-url-contains-credential'); + } + host = parsed.hostname; + pathname = parsed.pathname; + } + + const parts = pathname + .replace(/^\/+/, '') + .replace(/\.git$/, '') + .split('/') + .filter((part: string): boolean => part.length > 0); + if (host.length === 0 || parts.length !== 2) { + throw new Error('target-git-url-invalid'); + } + return { host: host.toLowerCase(), owner: safeName(parts[0] ?? '', 'repository-owner') }; +} + +export function deriveBrainTarget( + registrySource: string, + targetGitUrl: string, + brainNamespace: string, +): BrainTarget { + const target = parseGitTarget(targetGitUrl); + const resolved = parseCredentialEstateRegistry(registrySource).resolveByHost(target.host); + if (resolved === undefined) { + throw new Error(`estate-host-unmapped: ${target.host}`); + } + const owner = safeName(brainNamespace, 'brain-namespace'); + const repo = `${owner}/mosaic-brain`; + return { + estate: resolved.estate, + host: target.host, + owner, + repo, + cloneUrl: `${resolved.host.apiBaseUrl}/${repo}.git`, + }; +} + +function syncFile(path: string): void { + const descriptor = openSync(path, 'r'); + try { + fsyncSync(descriptor); + } finally { + closeSync(descriptor); + } +} + +export function createBrainSkeleton(root: string): { readonly created: readonly string[] } { + const created: string[] = []; + if (existsSync(root)) { + const rootStatus = lstatSync(root); + if (!rootStatus.isDirectory() || rootStatus.isSymbolicLink()) { + throw new Error('brain-layout-root-unsafe'); + } + } + for (const directory of BRAIN_DIRECTORIES) { + const path = join(root, directory); + if (existsSync(path)) { + const status = lstatSync(path); + if (!status.isDirectory() || status.isSymbolicLink()) { + throw new Error('brain-layout-directory-unsafe'); + } + } + } + + mkdirSync(root, { recursive: true }); + for (const directory of BRAIN_DIRECTORIES) { + const path = join(root, directory); + if (!existsSync(path)) { + mkdirSync(path, { recursive: true }); + created.push(path); + } + const placeholder = join(path, '.gitkeep'); + if (!existsSync(placeholder)) { + writeFileSync(placeholder, '', { encoding: 'utf8', mode: 0o644, flag: 'wx' }); + syncFile(placeholder); + created.push(placeholder); + } else if (!lstatSync(placeholder).isFile() || lstatSync(placeholder).isSymbolicLink()) { + throw new Error('brain-layout-placeholder-unsafe'); + } + } + + const ignorePath = join(root, '.gitignore'); + if (existsSync(ignorePath)) { + const ignoreStatus = lstatSync(ignorePath); + if (!ignoreStatus.isFile() || ignoreStatus.isSymbolicLink()) { + 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 temporary = `${ignorePath}.tmp-${process.pid}-${randomUUID()}`; + writeFileSync(temporary, content, { encoding: 'utf8', mode: 0o644, flag: 'wx' }); + syncFile(temporary); + renameSync(temporary, ignorePath); + created.push(ignorePath); + } + return { created }; +} + +function indeterminate(reasonCode: string): CredentialAssessment { + return { + outcome: 'indeterminate', + exitCode: 30, + reasonCode, + diagnostic: `indeterminate: ${reasonCode}`, + }; +} + +export function assessCredentialResult( + source: string, + expectedSubject?: { + readonly identity: string; + readonly estate: string; + readonly host: string; + readonly repo: string; + }, +): CredentialAssessment { + let raw: unknown; + try { + raw = JSON.parse(source); + } catch { + return indeterminate('unexpected-provider-shape'); + } + const parsed = credentialResultSchema.safeParse(raw); + if (!parsed.success) return indeterminate('unexpected-provider-shape'); + + if ( + expectedSubject !== undefined && + (parsed.data.subject.identity !== expectedSubject.identity || + parsed.data.subject.estate !== expectedSubject.estate || + parsed.data.subject.host !== expectedSubject.host || + parsed.data.subject.repo !== expectedSubject.repo) + ) { + return indeterminate('unexpected-provider-shape'); + } + + const expectedExit = TERMINAL_EXITS[parsed.data.outcome]; + if (parsed.data.exitCode !== expectedExit) { + return indeterminate('unexpected-provider-shape'); + } + + const stableClass = STABLE_REASON_CLASSES[parsed.data.reason.code]; + if (stableClass !== undefined && stableClass !== parsed.data.outcome) { + return indeterminate('unexpected-provider-shape'); + } + + const identity = parsed.data.evidence.providerIdentity; + if (identity !== null) { + if (!identity.contentType.toLowerCase().startsWith('application/json')) { + return indeterminate('unexpected-content-type'); + } + if (identity.login !== parsed.data.subject.identity) { + return { + outcome: 'refused', + exitCode: 10, + reasonCode: 'provider-identity-mismatch', + diagnostic: 'refused: provider-identity-mismatch', + }; + } + } + if (parsed.data.outcome === 'ok') { + const permission = parsed.data.evidence.repositoryPermission; + const differential = parsed.data.evidence.writeDifferential; + if ( + identity === null || + permission === null || + differential === null || + !permission.contentType.toLowerCase().startsWith('application/json') || + differential.transportPrincipal !== parsed.data.subject.identity || + parsed.data.mutation !== 'none' || + parsed.data.audit.state !== 'sealed' || + parsed.data.audit.journalId === null + ) { + return indeterminate('readback-missing'); + } + } + if ( + parsed.data.reason.code === 'provider-identity-mismatch' && + (identity === null || identity.login === parsed.data.subject.identity) + ) { + return indeterminate('unexpected-provider-shape'); + } + + return { + outcome: parsed.data.outcome, + exitCode: expectedExit, + reasonCode: parsed.data.reason.code, + diagnostic: `${parsed.data.outcome}: ${parsed.data.reason.code}`, + }; +} + +export function assessResolverParity( + gitSource: string, + apiSource: string, +): ResolverParityAssessment { + const git = assessCredentialResult(gitSource); + const api = assessCredentialResult(apiSource); + if (git.outcome === 'refused' && api.outcome === 'refused' && git.reasonCode === api.reasonCode) { + return { + ...git, + gitReasonCode: git.reasonCode, + apiReasonCode: api.reasonCode, + }; + } + return { + ...indeterminate('permission-evidence-disagrees'), + gitReasonCode: git.reasonCode, + apiReasonCode: api.reasonCode, + }; +} + +function filesBelow(root: string): string[] { + if (!existsSync(root)) return []; + const result: string[] = []; + const walk = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) walk(path); + else if (entry.isFile()) result.push(path); + else result.push(path); + } + }; + walk(root); + return result.sort((left: string, right: string): number => left.localeCompare(right)); +} + +function ownerIsValid( + resolution: MigrationOwnerResolution, + lane: string, + laneActive: boolean, +): boolean { + if ( + resolution.verdict !== 'resolved' || + resolution.principal === null || + resolution.authority === null || + resolution.authority.endpoint.length === 0 || + resolution.authority.contentType !== 'application/json' + ) { + return false; + } + + const principal = resolution.principal; + const normalized = principal.name.normalize('NFKC'); + if (normalized !== principal.name) return false; + const grammars: Readonly> = { + 'active-lane': /^lane:[a-z0-9][a-z0-9-]*$/, + 'durable-team': /^team:[a-z0-9][a-z0-9-]*$/, + 'durable-human': /^user:[a-z0-9][a-z0-9-]*$/, + 'durable-queue': /^queue:[a-z0-9][a-z0-9-]*$/, + }; + const grammar = grammars[principal.kind]; + if (grammar === undefined || !grammar.test(normalized)) return false; + if (principal.kind === 'active-lane') { + return laneActive && principal.name === `lane:${lane}`; + } + 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'); +} + +function migrationCandidate( + sourceRoot: string, + brainRoot: string, + path: string, + kind: 'lane' | 'seat', + seat: string, + lane: string, +): MigrationCandidate { + const digest = migrationDigest(sourceRoot, path).slice(0, 16); + const name = `${digest}-${basename(path)}`; + const destination = + kind === 'lane' + ? join(brainRoot, 'lanes', lane, 'findings', 'imports', name) + : join(brainRoot, 'agents', seat, 'state', 'imports', name); + return { + source: path, + destination, + archive: join(brainRoot, 'archives', 'imports', kind, name), + kind, + }; +} + +function secretShapedPath(path: string): boolean { + const name = basename(path); + return ( + name === '.env' || + name === 'credentials.json' || + name.endsWith('.token') || + name.endsWith('.key') || + name.endsWith('.pem') + ); +} + +function reportAll(paths: readonly string[], reason: string): MigrationReport[] { + return paths.map((path: string): MigrationReport => ({ path, reason })); +} + +export function discoverBrainMigration( + input: { + readonly sourceRoot: string; + readonly brainRoot: string; + readonly seat: string; + readonly lane: string; + readonly laneActive: boolean; + }, + resolveOwner?: (lane: string) => MigrationOwnerResolution, +): MigrationPlan { + const seat = safeName(input.seat, 'seat'); + const lane = safeName(input.lane, 'lane'); + const laneRoot = join(input.sourceRoot, 'lanes', lane); + const seatRoot = join(input.sourceRoot, 'agents', seat); + const laneFiles = filesBelow(laneRoot); + const seatFiles = filesBelow(seatRoot); + const selected = new Set([...laneFiles, ...seatFiles]); + const all = filesBelow(input.sourceRoot); + const unsupported = all.filter((path: string): boolean => !selected.has(path)); + + let ownerResolution: MigrationOwnerResolution = { + verdict: 'not-measured', + reasonCode: 'owner-resolver-unavailable', + principal: null, + authority: null, + }; + if (resolveOwner !== undefined) { + try { + ownerResolution = resolveOwner(lane); + } catch { + ownerResolution = { + verdict: 'not-measured', + reasonCode: 'owner-resolver-failed', + principal: null, + authority: null, + }; + } + } + if (!ownerIsValid(ownerResolution, lane, input.laneActive)) { + return { + status: 'blocked', + candidates: [], + reported: reportAll( + all, + 'Migration requires a source-of-truth-resolved named durable owner; caller assertions are not evidence.', + ), + owner: null, + }; + } + + const candidates: MigrationCandidate[] = []; + const reported: MigrationReport[] = reportAll( + unsupported, + 'Ownership or supported migration shape was not established; retained and reported.', + ); + for (const path of laneFiles) { + if (secretShapedPath(path)) { + reported.push({ + path, + reason: 'Secret-shaped state is forbidden in the brain; retained and reported.', + }); + continue; + } + try { + candidates.push( + migrationCandidate(input.sourceRoot, input.brainRoot, path, 'lane', seat, lane), + ); + } catch { + reported.push({ path, reason: 'Lane state was not a regular file; retained and reported.' }); + } + } + for (const path of seatFiles) { + if (secretShapedPath(path)) { + reported.push({ + path, + reason: 'Secret-shaped state is forbidden in the brain; retained and reported.', + }); + continue; + } + try { + candidates.push( + migrationCandidate(input.sourceRoot, input.brainRoot, path, 'seat', seat, lane), + ); + } catch { + reported.push({ path, reason: 'Seat state was not a regular file; retained and reported.' }); + } + } + return { status: 'ready', candidates, reported, owner: ownerResolution.principal }; +} + +function isContained(root: string, path: string): boolean { + const absoluteRoot = resolve(root); + const absolutePath = resolve(path); + return absolutePath === absoluteRoot || absolutePath.startsWith(`${absoluteRoot}${sep}`); +} + +function assertSafeDestinationAncestors(root: string, destination: string): void { + if (!isContained(root, destination)) throw new Error('migration-destination-escaped-brain'); + if (!existsSync(root)) mkdirSync(root, { recursive: true }); + const rootStatus = lstatSync(root); + if (!rootStatus.isDirectory() || rootStatus.isSymbolicLink()) { + throw new Error('migration-destination-ancestor-unsafe'); + } + const parts = relative(root, dirname(destination)).split(sep).filter(Boolean); + let cursor = root; + for (const part of parts) { + cursor = join(cursor, part); + if (!existsSync(cursor)) continue; + const status = lstatSync(cursor); + if (!status.isDirectory() || status.isSymbolicLink()) { + throw new Error('migration-destination-ancestor-unsafe'); + } + } +} + +function copyVerified(source: string, destination: string, brainRoot: string): boolean { + assertSafeDestinationAncestors(brainRoot, destination); + mkdirSync(dirname(destination), { recursive: true }); + assertSafeDestinationAncestors(brainRoot, destination); + if (existsSync(destination)) { + const status = lstatSync(destination); + 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'); + return false; + } + const temporary = `${destination}.tmp-${process.pid}-${randomUUID()}`; + try { + copyFileSync(source, temporary, fsConstants.COPYFILE_EXCL); + 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'); + assertSafeDestinationAncestors(brainRoot, destination); + linkSync(temporary, destination); + syncFile(destination); + return true; + } finally { + rmSync(temporary, { force: true }); + } +} + +export function migrateBrainState( + plan: MigrationPlan, + publish: (brainRoot: string, paths: readonly string[]) => MigrationPublishEvidence, + brainRoot: string, +): MigrationResult { + if (plan.status !== 'ready' || plan.candidates.length === 0) { + return { + status: 'reported', + migrated: [], + reported: plan.reported, + publish: null, + }; + } + + const created: string[] = []; + const published: string[] = []; + let publicationAttempted = false; + let evidence: MigrationPublishEvidence; + try { + for (const candidate of plan.candidates) { + if ( + !isContained(brainRoot, candidate.destination) || + !isContained(brainRoot, candidate.archive) + ) { + throw new Error('migration-destination-escaped-brain'); + } + if (copyVerified(candidate.source, candidate.destination, brainRoot)) { + created.push(candidate.destination); + } + if (copyVerified(candidate.source, candidate.archive, brainRoot)) { + created.push(candidate.archive); + } + published.push(candidate.destination, candidate.archive); + } + + publicationAttempted = true; + evidence = publish(brainRoot, published); + if (!COMMIT.test(evidence.commit) || !COMMIT.test(evidence.remoteHead) || !evidence.reachable) { + throw new Error('remote reachability was not established'); + } + } catch (error: unknown) { + // Once publication is attempted its remote mutation state may be unknown. + // Keep the local copies so the checkout does not silently diverge from a + // commit that may already be reachable; sources always remain intact. + if (!publicationAttempted) { + for (const path of created.reverse()) rmSync(path, { force: true }); + } + const detail = error instanceof Error ? error.message : 'migration failed'; + return { + status: 'failed', + migrated: [], + reported: [ + ...plan.reported, + ...plan.candidates.map( + (candidate: MigrationCandidate): MigrationReport => ({ + path: candidate.source, + reason: `Migration retained source: ${detail}`, + }), + ), + ], + publish: null, + }; + } + + const migrated: MigrationCandidate[] = []; + const removalReports: MigrationReport[] = []; + for (const candidate of plan.candidates) { + try { + unlinkSync(candidate.source); + migrated.push(candidate); + } catch { + removalReports.push({ + path: candidate.source, + reason: + 'Published migration is reachable but source cleanup failed; retained and reported.', + }); + } + } + return { + status: removalReports.length === 0 ? 'migrated' : 'reported', + migrated, + reported: [...plan.reported, ...removalReports], + publish: evidence, + }; +} + +function accessFinding(access: CredentialAssessment | null): BrainDoctorFinding | null { + if (access === null) { + return { + code: 'brain-write-access-indeterminate', + repairable: false, + reasonCode: 'readback-missing', + }; + } + if (access.outcome === 'ok') return null; + return { + code: `brain-write-access-${access.outcome}`, + repairable: + access.outcome === 'refused' && + ['permission-denied', 'no-token-for-identity'].includes(access.reasonCode), + reasonCode: access.reasonCode, + }; +} + +export function evaluateBrainDoctor( + observation: BrainDoctorObservation, + expectedRemote: string, +): readonly BrainDoctorFinding[] { + const access = accessFinding(observation.access); + if (!observation.rootExists) { + return [ + { code: 'brain-clone-missing', repairable: true, reasonCode: null }, + ...(access === null ? [] : [access]), + ]; + } + const findings: BrainDoctorFinding[] = []; + if (!observation.gitRepository) { + findings.push({ code: 'brain-not-git-repository', repairable: true, reasonCode: null }); + return findings; + } + if (observation.remote !== expectedRemote) { + findings.push({ code: 'brain-remote-mismatch', repairable: true, reasonCode: null }); + } + if (observation.branch !== 'main') { + findings.push({ code: 'brain-branch-mismatch', repairable: false, reasonCode: null }); + } + if (observation.dirty === true) { + findings.push({ code: 'brain-uncommitted-state', repairable: false, reasonCode: null }); + } + if (access !== null) findings.push(access); + return findings; +} + +export function planBrainDoctorFix(input: { + readonly findings: readonly BrainDoctorFinding[]; + readonly target: BrainTarget; + readonly identity: string; + readonly root: string; +}): readonly BrainDoctorAction[] { + safeName(input.identity, 'identity'); + const actions: BrainDoctorAction[] = []; + const priority: Readonly> = { + 'brain-write-access-refused': 0, + 'brain-clone-missing': 1, + 'brain-remote-mismatch': 2, + }; + const ordered = [...input.findings].sort( + (left: BrainDoctorFinding, right: BrainDoctorFinding): number => + (priority[left.code] ?? 99) - (priority[right.code] ?? 99), + ); + for (const finding of ordered) { + if (!finding.repairable) continue; + if (finding.code === 'brain-clone-missing') { + actions.push({ + program: 'git', + args: ['clone', '--branch', 'main', '--single-branch', input.target.cloneUrl, input.root], + findingCode: finding.code, + }); + } else if (finding.code === 'brain-remote-mismatch') { + actions.push({ + program: 'git', + args: ['-C', input.root, 'remote', 'set-url', 'origin', input.target.cloneUrl], + findingCode: finding.code, + }); + } else if (finding.code === 'brain-write-access-refused') { + actions.push({ + program: 'mosaic', + args: [ + 'cred', + 'grant', + input.identity, + '--estate', + input.target.estate, + '--host', + input.target.host, + '--repo', + input.target.repo, + '--permission', + 'write', + '--json', + ], + findingCode: finding.code, + }); + } + } + return actions; +} + +function safeRelativePath(path: string): string[] | null { + if (isAbsolute(path) || path.includes('\\')) return null; + const parts = path.split('/').filter((part: string): boolean => part.length > 0); + if (parts.length === 0 || parts.some((part: string): boolean => part === '.' || part === '..')) { + return null; + } + return parts; +} + +export function classifyBrainWrite(input: { + readonly path: string; + readonly actor: string; + readonly seat: string; + readonly boardWriter?: string; +}): BrainWritePolicy { + const parts = safeRelativePath(input.path); + if (parts === null || !SAFE_NAME.test(input.actor) || !SAFE_NAME.test(input.seat)) { + return { allowed: false, mode: 'refused', reason: 'invalid-write-subject' }; + } + if (parts[0] === 'lanes' && parts.length >= 3) { + return { allowed: true, mode: 'append-only', reason: 'lane-content-is-findings' }; + } + if (parts[0] === 'board') { + if (input.boardWriter !== undefined && input.actor === input.boardWriter) { + return { allowed: true, mode: 'single-writer', reason: 'named-board-writer' }; + } + return { allowed: false, mode: 'refused', reason: 'board-writer-mismatch' }; + } + if (parts[0] === 'agents' && parts.length >= 3) { + if (parts[1] === input.seat && input.actor === input.seat) { + return { allowed: true, mode: 'seat-writer', reason: 'seat-owned-state' }; + } + return { allowed: false, mode: 'refused', reason: 'seat-writer-mismatch' }; + } + return { allowed: false, mode: 'refused', reason: 'unsupported-write-path' }; +} diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index 3a0dd998..6572bead 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -29,6 +29,11 @@ import { readPersonaContractBlock } from '../fleet/persona-contract.js'; import { canonicalizeRoleClass } from './fleet-personas.js'; import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js'; import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js'; +import { + defaultInstalledBrainDoctorOptions, + runInstalledBrainDoctorCheck, +} from './brain-doctor-check.js'; +import { systemCommandRunner } from './brain-store-runtime.js'; const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic'); const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024; @@ -1257,8 +1262,11 @@ export function registerLaunchCommands(program: Command): void { }); } - // `doctor` — the framework drift audit (bash script) PLUS the #869 - // Point-1 C5 lease-enforcement activation check (TS, reusing C1's + // `doctor` — the framework drift audit (bash script), the #869 + // Point-1 C5 lease-enforcement activation check, and the #1051 per-estate + // durable brain check. Both TS checks run before the bash audit and can + // force a non-zero result for hard/indeterminate failures. + // The lease check reuses C1's // `leaseEnforcementActivatable()` and C3's `checkBrokerSupervisorHealth()`). // Kept out of the generic `directCommands` loop above because this check // must run and report BEFORE the bash script's own exit, and must be able @@ -1267,14 +1275,29 @@ export function registerLaunchCommands(program: Command): void { // undiagnosed (see lease-doctor-check.ts docstring). program .command('doctor') - .description('Health audit — detect drift, missing files, and #869 lease-activation gaps') + .description('Health audit — detect drift, lease gaps, and per-estate brain defects') .allowUnknownOption(true) .allowExcessArguments(true) .action(async (_opts: unknown, cmd: Command) => { checkMosaicHome(); const leaseCheck = await runLeaseEnforcementDoctorCheck(); const leaseCheckFailed = printLeaseDoctorCheck(leaseCheck); - runDoctorScriptAndExit(fwScript('mosaic-doctor'), cmd.args, leaseCheckFailed); + const fix = cmd.args.includes('--fix'); + const brainCheck = runInstalledBrainDoctorCheck( + defaultInstalledBrainDoctorOptions(fix), + systemCommandRunner, + ); + for (const line of brainCheck.lines) { + (brainCheck.status === 'ok' ? console.log : console.error)(line); + } + const brainCheckFailed = + brainCheck.status === 'error' || + (brainCheck.status === 'warn' && cmd.args.includes('--fail-on-warn')); + runDoctorScriptAndExit( + fwScript('mosaic-doctor'), + cmd.args, + leaseCheckFailed || brainCheckFailed, + ); }); }