From e91c8c06a5d49ac3443c02bafd53ec6259be5114 Mon Sep 17 00:00:00 2001 From: be-coder-07 Date: Wed, 5 Aug 2026 23:32:23 -0500 Subject: [PATCH] fix(#1051): track canonical estate registry --- .../1051-mosaic-brain-installer.md | 12 +- .../credentials/credential-provider.dto.ts | 52 ++++++ .../src/credentials/credential-result.dto.ts | 84 +++++++++ .../src/credentials/estate-registry.dto.ts | 15 ++ .../src/credentials/estate-registry.spec.ts | 100 +++++++++++ .../mosaic/src/credentials/estate-registry.ts | 167 ++++++++++++++++++ 6 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 packages/mosaic/src/credentials/credential-provider.dto.ts create mode 100644 packages/mosaic/src/credentials/credential-result.dto.ts create mode 100644 packages/mosaic/src/credentials/estate-registry.dto.ts create mode 100644 packages/mosaic/src/credentials/estate-registry.spec.ts create mode 100644 packages/mosaic/src/credentials/estate-registry.ts diff --git a/docs/scratchpads/1051-mosaic-brain-installer.md b/docs/scratchpads/1051-mosaic-brain-installer.md index 09d86a34..5483e207 100644 --- a/docs/scratchpads/1051-mosaic-brain-installer.md +++ b/docs/scratchpads/1051-mosaic-brain-installer.md @@ -84,7 +84,7 @@ No explicit token ceiling was supplied. Working cap: 55K tokens for implementati - [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: 97/97 (secure config 4, store 45, runtime 19, owner resolver 16, provision 5, provision command 3, installed doctor 5). -- [x] MC-CRED added the required canonical reverse registry seam `ParsedCredentialEstateRegistry.resolveByHost()` at dependency head `6ca8758f`; current local copies are temporary until dependency integration and the 32-line permissive shim has been removed. +- [x] MC-CRED added the required canonical reverse registry seam `ParsedCredentialEstateRegistry.resolveByHost()`; the 32-line permissive shim was removed. After exact-head CI proved the cross-PR source dependency was absent, the provider-fetched canonical registry implementation, DTO dependencies, and registry tests were tracked byte-for-byte on this branch so a fresh checkout validates the real seam rather than a stub. A later rebase onto merged MC-CRED should recognize those identical files as upstream. - [x] Identity gotcha measured: inline `MOSAIC_GIT_IDENTITY=be-coder-07` controls credential resolution but does not override `user.name`/`user.email` inherited from the linked worktree common-dir config (`coder-mos1`). The first local P7 RED commit was immediately amended before push with command-scoped `GIT_AUTHOR_*` + `GIT_COMMITTER_*`; resulting author and committer both read back as `be-coder-07`. Every subsequent authoring command must carry both identity sets and be verified. - [x] R6 migration reports filename- or content-secret-shaped files without copying them; arbitrary legacy content requires an approved scanner bound to the exact source snapshot, and production currently retains/reports when no approved scanner is configured. `.gitignore` is canonical allowlisted content only: an existing noncanonical regular file fails closed and is never merged into publication. Symlinked `.gitignore`, layout directories, and nested migration destinations fail closed; a dirty checkout blocks provisioning before skeleton publication. The brain root is principal-owned mode `0700` before clone and after clone, all memory-bearing layout directories are mode `0700` even under umask `0022`, and doctor reports owner-accessible roots as hard unsafe findings. - [x] Provider owner lookup uses manual redirect handling, a five-second abort signal, strict JSON content type/shape, and an incrementally enforced 256 KiB response ceiling. @@ -100,6 +100,16 @@ No explicit token ceiling was supplied. Working cap: 55K tokens for implementati - [ ] HOMELAB CI terminal green at exact head. - [ ] Reviewed PR retargeted to `main` after C1 and MC-CRED; `next` remains non-merging integration only. +## Exact-head CI dependency remediation + +- Pipeline `#2222` at `a50b5a6b` ran the sole pull-request-eligible workflow (`ci`, 1/3 defined workflows) and failed `typecheck` with two `TS2307` errors before lint, format, or tests could execute. +- History establishes that the imports are intentional: commit `2451c2f` introduced both consumers, while the contemporaneous registry-seam report explicitly called the local 32-line implementation a disposable scaffold and required MC-CRED's canonical parser. This was a deliberate cross-PR dependency, not a wrong import or forgotten shim add. +- RED-first root typecheck reproduced the two missing-module errors. The fix tracks the provider-fetched MC-CRED registry, its two DTO dependencies, its unit test, and the result DTO dependency. Three DTO files remain byte-identical to `fbff4ffa`; author review found that the canonical parser accepted a trailing-slash origin which MB-BRAIN consumers concatenate into double-slash URLs, so the parser and test are intentionally hardened here pending propagation to MC-CRED. +- R7 removed the tracked registry module and root typecheck returned RED with three missing-module errors (the two production consumers plus the registry unit test); restoring the same SHA-256 returned typecheck to 45/45 tasks. +- With the suppressing typecheck failure removed, lint ran 25/25 tasks and formatting passed. Full tests actually ran: 1,607/1,614 passed; the seven failures are the four pre-registered P7 integration tests intentionally held for C1/MC-CRED integration plus the three previously disclosed ambient update-banner CLI smoke failures. No test was weakened. Build ran 25/25 tasks. +- Author review's trailing-slash finding was reproduced RED (`https://git.example.invalid/` accepted), then fixed by requiring the configured source to equal `URL.origin`; the control reran GREEN. Security review reported risk `none` with zero findings; final code re-review remains required after remediation. +- This is a second instance of the `#1068` suppression class: an early integrity failure prevented every downstream stage carrying behavioral evidence from running while the workflow's aggregate failure looked like a completed check. Workflow reordering remains `#1068` scope and is not changed here. + ## Completion language 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/credentials/credential-provider.dto.ts b/packages/mosaic/src/credentials/credential-provider.dto.ts new file mode 100644 index 00000000..a7807965 --- /dev/null +++ b/packages/mosaic/src/credentials/credential-provider.dto.ts @@ -0,0 +1,52 @@ +import type { + ProviderIdentityEvidenceDto, + ReceivePackEvidenceDto, + RepositoryPermission, + RepositoryPermissionEvidenceDto, +} from './credential-result.dto.js'; + +export interface ResolvedCredential { + readonly identity: string; + readonly estate: string; + readonly host: string; + readonly resolutionId: string; + readonly secret: Uint8Array; +} + +export interface CredentialResolver { + resolve(identity: string, estate: string, host: string): Promise; +} + +export interface GiteaCredentialProvider { + readIdentity(resolved: ResolvedCredential): Promise; + readRepositoryPermission( + resolved: ResolvedCredential, + repo: string, + ): Promise; + probeReceivePack( + resolved: ResolvedCredential | undefined, + repo: string, + ): Promise; +} + +export interface CredentialEstateRegistry { + matches(estate: string, host: string): boolean; +} + +export interface CredentialValidationDependencies { + readonly resolver: CredentialResolver; + readonly provider: GiteaCredentialProvider; + readonly estateRegistry: CredentialEstateRegistry; +} + +export interface GiteaReadValidationRequestDto { + readonly identity: string; + readonly estate: string; + readonly host: string; + readonly repo: string; + readonly requiredPermission?: RepositoryPermission; +} + +export interface GiteaWriteValidationRequestDto extends GiteaReadValidationRequestDto { + readonly readOnlyControlIdentity: string; +} diff --git a/packages/mosaic/src/credentials/credential-result.dto.ts b/packages/mosaic/src/credentials/credential-result.dto.ts new file mode 100644 index 00000000..c9765a5c --- /dev/null +++ b/packages/mosaic/src/credentials/credential-result.dto.ts @@ -0,0 +1,84 @@ +export type CredentialOutcome = 'ok' | 'refused' | 'error' | 'indeterminate'; +export type CredentialMutationState = 'none' | 'not-started' | 'applied' | 'unknown'; +export type RepositoryPermission = 'none' | 'read' | 'write' | 'admin'; +export type ReceivePackState = 'advertised' | 'refused'; + +export interface CredentialReasonDto { + readonly code: string; + readonly message: string; +} + +export interface CredentialSubjectDto { + readonly identity: string; + readonly estate: string; + readonly host: string; + readonly repo: string; +} + +export interface ProviderIdentityEvidenceDto { + readonly login: string; + readonly endpoint: string; + readonly contentType: string; +} + +export interface RepositoryPermissionEvidenceDto { + readonly effective: RepositoryPermission; + readonly endpoint: string; + readonly contentType: string; +} + +export interface ReceivePackEvidenceDto { + readonly state: ReceivePackState; + readonly principal: string | null; + readonly resolutionId: string | null; + readonly contentType: string; +} + +export interface ReadOnlyControlEvidenceDto { + readonly identity: string; + readonly providerPermission: RepositoryPermission; + readonly receivePack: ReceivePackState; +} + +export interface WriteDifferentialEvidenceDto { + readonly state: 'can-write'; + readonly credentialBinding: 'same-resolution'; + readonly transportPrincipal: string; + readonly authenticatedReceivePack: 'advertised'; + readonly readOnlyControl: ReadOnlyControlEvidenceDto; + readonly unauthenticatedReceivePack: 'refused'; + readonly artifactCreated: false; + readonly proves: string; + readonly doesNotProve: string; +} + +export interface TokenCapabilitiesEvidenceDto { + readonly state: 'measured' | 'not-measured'; + readonly scopes: readonly string[]; + readonly source: 'provider-token-object' | 'runtime-not-authorized'; +} + +export interface CredentialValidationEvidenceDto { + readonly providerIdentity: ProviderIdentityEvidenceDto | null; + readonly tokenCapabilities: TokenCapabilitiesEvidenceDto; + readonly repositoryPermission: RepositoryPermissionEvidenceDto | null; + readonly writeDifferential: WriteDifferentialEvidenceDto | null; +} + +export interface CredentialAuditResultDto { + readonly journalId: string | null; + readonly state: 'not-started' | 'open' | 'sealed'; +} + +export interface CredentialValidationResultDto { + readonly schemaVersion: 1; + readonly operation: 'validate' | 'whoami'; + readonly outcome: CredentialOutcome; + readonly exitCode: 0 | 10 | 20 | 30; + readonly retryable: boolean; + readonly subject: CredentialSubjectDto; + readonly mutation: CredentialMutationState; + readonly reason: CredentialReasonDto; + readonly evidence: CredentialValidationEvidenceDto; + readonly audit: CredentialAuditResultDto; +} diff --git a/packages/mosaic/src/credentials/estate-registry.dto.ts b/packages/mosaic/src/credentials/estate-registry.dto.ts new file mode 100644 index 00000000..80e808a6 --- /dev/null +++ b/packages/mosaic/src/credentials/estate-registry.dto.ts @@ -0,0 +1,15 @@ +export type CredentialProviderKind = 'gitea'; + +export interface CredentialHostConfigDto { + readonly host: string; + readonly provider: CredentialProviderKind; + readonly apiBaseUrl: string; + readonly tokenPrefix: string; +} + +export interface CredentialEstateConfigDto { + readonly name: string; + readonly readOnlyControlIdentity?: string; + readonly inventoryAuthorityIdentity?: string; + readonly hosts: readonly CredentialHostConfigDto[]; +} diff --git a/packages/mosaic/src/credentials/estate-registry.spec.ts b/packages/mosaic/src/credentials/estate-registry.spec.ts new file mode 100644 index 00000000..d90995c3 --- /dev/null +++ b/packages/mosaic/src/credentials/estate-registry.spec.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { parseCredentialEstateRegistry } from './estate-registry.js'; + +const validRegistry = 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', + }, + ], + }, + ], +}); + +describe('credential estate registry', (): void => { + it('requires an exact declared estate-host pair', (): void => { + const registry = parseCredentialEstateRegistry(validRegistry); + + expect(registry.matches('homelab', 'git.example.invalid')).toBe(true); + expect(registry.matches('usc', 'git.example.invalid')).toBe(false); + expect(registry.matches('homelab', 'other.example.invalid')).toBe(false); + expect(registry.resolveByHost('git.example.invalid')).toMatchObject({ + estate: 'homelab', + host: { host: 'git.example.invalid', provider: 'gitea' }, + }); + expect(registry.resolveByHost('other.example.invalid')).toBeUndefined(); + }); + + it('rejects a provider URL whose host differs from the declared host', (): void => { + const source = validRegistry.replace( + 'https://git.example.invalid', + 'https://other.example.invalid', + ); + + expect(() => parseCredentialEstateRegistry(source)).toThrow(/api-host-mismatch/); + }); + + it('rejects one host assigned to multiple estates', (): void => { + const source = JSON.stringify({ + version: 1, + estates: [ + { + name: 'homelab', + hosts: [ + { + host: 'git.example.invalid', + provider: 'gitea', + apiBaseUrl: 'https://git.example.invalid', + tokenPrefix: 'gitea-example', + }, + ], + }, + { + name: 'other', + hosts: [ + { + host: 'git.example.invalid', + provider: 'gitea', + apiBaseUrl: 'https://git.example.invalid', + tokenPrefix: 'gitea-other', + }, + ], + }, + ], + }); + + expect(() => parseCredentialEstateRegistry(source)).toThrow(/duplicate-host/); + }); + + it('rejects URLs with userinfo, path, query, fragment, trailing slash, or non-HTTPS scheme', (): void => { + for (const apiBaseUrl of [ + 'http://git.example.invalid', + 'https://user@git.example.invalid', + 'https://git.example.invalid/', + 'https://git.example.invalid/api', + 'https://git.example.invalid?x=1', + 'https://git.example.invalid#x', + ]) { + const source = validRegistry.replace('https://git.example.invalid', apiBaseUrl); + expect(() => parseCredentialEstateRegistry(source), apiBaseUrl).toThrow(/invalid-api-url/); + } + }); + + it('requires a configured read-only control for write validation', (): void => { + const registry = parseCredentialEstateRegistry(validRegistry); + const withoutControl = parseCredentialEstateRegistry( + validRegistry.replace('"readOnlyControlIdentity":"read-control",', ''), + ); + + expect(registry.readOnlyControl('homelab')).toBe('read-control'); + expect(() => withoutControl.readOnlyControl('homelab')).toThrow(/read-only-control-missing/); + }); +}); diff --git a/packages/mosaic/src/credentials/estate-registry.ts b/packages/mosaic/src/credentials/estate-registry.ts new file mode 100644 index 00000000..45a0286b --- /dev/null +++ b/packages/mosaic/src/credentials/estate-registry.ts @@ -0,0 +1,167 @@ +import { z } from 'zod'; +import type { CredentialEstateRegistry } from './credential-provider.dto.js'; +import type { CredentialEstateConfigDto, CredentialHostConfigDto } from './estate-registry.dto.js'; + +const NAME = /^[a-z0-9][a-z0-9-]*$/; +const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; +const HOST = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; + +const hostSchema = z + .object({ + host: z.string().regex(HOST), + provider: z.literal('gitea'), + apiBaseUrl: z.string(), + tokenPrefix: z.string().regex(NAME), + }) + .strict(); + +const estateSchema = z + .object({ + name: z.string().regex(NAME), + readOnlyControlIdentity: z.string().regex(IDENTITY).optional(), + inventoryAuthorityIdentity: z.string().regex(IDENTITY).optional(), + hosts: z.array(hostSchema).min(1), + }) + .strict(); + +const registrySchema = z + .object({ + version: z.literal(1), + estates: z.array(estateSchema).min(1), + }) + .strict(); + +export class CredentialEstateRegistryError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(`Credential estate registry rejected: code=${code} ${message}`); + this.name = 'CredentialEstateRegistryError'; + } +} + +function validateApiUrl(host: CredentialHostConfigDto): void { + let url: URL; + try { + url = new URL(host.apiBaseUrl); + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + throw new CredentialEstateRegistryError('invalid-api-url', detail); + } + if ( + url.protocol !== 'https:' || + url.username !== '' || + url.password !== '' || + url.pathname !== '/' || + host.apiBaseUrl !== url.origin || + url.search !== '' || + url.hash !== '' + ) { + throw new CredentialEstateRegistryError( + 'invalid-api-url', + 'provider API URL must be an HTTPS origin without userinfo, path, query, or fragment', + ); + } + if (url.hostname !== host.host) { + throw new CredentialEstateRegistryError( + 'api-host-mismatch', + 'provider API URL hostname does not equal the declared host', + ); + } +} + +export class ParsedCredentialEstateRegistry implements CredentialEstateRegistry { + private readonly estates: ReadonlyMap; + + constructor(estates: readonly CredentialEstateConfigDto[]) { + this.estates = new Map( + estates.map( + (estate: CredentialEstateConfigDto): readonly [string, CredentialEstateConfigDto] => [ + estate.name, + estate, + ], + ), + ); + } + + matches(estate: string, host: string): boolean { + return this.resolve(estate, host) !== undefined; + } + + resolve(estate: string, host: string): CredentialHostConfigDto | undefined { + return this.estates + .get(estate) + ?.hosts.find((candidate: CredentialHostConfigDto): boolean => candidate.host === host); + } + + resolveByHost( + host: string, + ): { readonly estate: string; readonly host: CredentialHostConfigDto } | undefined { + for (const [estate, config] of this.estates) { + const match = config.hosts.find( + (candidate: CredentialHostConfigDto): boolean => candidate.host === host, + ); + if (match !== undefined) return { estate, host: match }; + } + return undefined; + } + + inventoryAuthority(estate: string): string { + const identity = this.estates.get(estate)?.inventoryAuthorityIdentity; + if (identity === undefined) { + throw new CredentialEstateRegistryError( + 'inventory-authority-missing', + `estate ${estate} has no delegated inventory authority identity`, + ); + } + return identity; + } + + readOnlyControl(estate: string): string { + const identity = this.estates.get(estate)?.readOnlyControlIdentity; + if (identity === undefined) { + throw new CredentialEstateRegistryError( + 'read-only-control-missing', + `estate ${estate} has no provider-confirmed read-only control identity`, + ); + } + return identity; + } +} + +export function parseCredentialEstateRegistry(source: string): ParsedCredentialEstateRegistry { + let raw: unknown; + try { + raw = JSON.parse(source); + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + throw new CredentialEstateRegistryError('invalid-json', detail); + } + + const parsed = registrySchema.safeParse(raw); + if (!parsed.success) { + throw new CredentialEstateRegistryError( + 'invalid-schema', + parsed.error.issues[0]?.message ?? 'invalid', + ); + } + + const estateNames = new Set(); + const hostNames = new Set(); + for (const estate of parsed.data.estates) { + if (estateNames.has(estate.name)) { + throw new CredentialEstateRegistryError('duplicate-estate', estate.name); + } + estateNames.add(estate.name); + for (const host of estate.hosts) { + validateApiUrl(host); + if (hostNames.has(host.host)) { + throw new CredentialEstateRegistryError('duplicate-host', host.host); + } + hostNames.add(host.host); + } + } + + return new ParsedCredentialEstateRegistry(parsed.data.estates); +}