diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fc8d2511..6e1832950 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -214,7 +214,8 @@ jobs: - uses: dtolnay/rust-toolchain@stable - - name: Host lifecycle and closure proofs + - name: Host lifecycle and closure proofs (Linux) + if: runner.os == 'Linux' run: | cargo test -p mc-host \ --test broca_protocol \ @@ -222,13 +223,23 @@ jobs: --test harness_closure \ --test protocol_vectors - - name: Native module adapter and CLI lifecycle + - name: Native module adapter and CLI lifecycle (Linux) + if: runner.os == 'Linux' run: | cargo test -p mc-module --bin ck-mc-host cargo test -p mc-module \ --test host_adapter \ --test lifecycle_cli + # The release ships darwin payloads and the GA evidence gate requires + # darwin target proofs, so the native lifecycle binary and its CLI + # contract need macOS proof alongside the Linux integration set. + - name: Native lifecycle binary and CLI contract (macOS) + if: runner.os == 'macOS' + run: | + cargo build -p mc-module --bin ck-mc-host + cargo test -p mc-module --test lifecycle_cli + check-plugin: name: Check (plugin) runs-on: ubuntu-latest diff --git a/crates/mc-module/src/bin/ck_mc_host/spawn.rs b/crates/mc-module/src/bin/ck_mc_host/spawn.rs index f1499382c..b00846bdd 100644 --- a/crates/mc-module/src/bin/ck_mc_host/spawn.rs +++ b/crates/mc-module/src/bin/ck_mc_host/spawn.rs @@ -94,18 +94,42 @@ pub fn spawn_detached( }; let mut pipe_fds = [0 as libc::c_int; 2]; + // Linux creates the pipe already close-on-exec. Darwin has no `pipe2`, so + // there the flag is applied in a second step below. + #[cfg(target_os = "linux")] // SAFETY: pipe2 writes exactly two descriptors into the array. cvt( unsafe { libc::pipe2(pipe_fds.as_mut_ptr(), libc::O_CLOEXEC) }, "envelope pipe creation failed", )?; - // SAFETY: the descriptors were just returned by pipe2 and are owned here. + #[cfg(target_os = "macos")] + // SAFETY: pipe writes exactly two descriptors into the array. + cvt( + unsafe { libc::pipe(pipe_fds.as_mut_ptr()) }, + "envelope pipe creation failed", + )?; + // SAFETY: the descriptors were just returned by pipe2/pipe and are owned here. let (pipe_r, pipe_w) = unsafe { ( OwnedFd::from_raw_fd(pipe_fds[0]), OwnedFd::from_raw_fd(pipe_fds[1]), ) }; + // Both ends are owned before the flag is set, so a failure here closes them + // instead of leaking a descriptor pair. Unlike `pipe2` this is not atomic + // with creation: a concurrent exec in another thread could inherit the ends + // in that window. The child below is the only exec this binary performs, it + // happens after this point, and it keeps the read end deliberately by + // dup2-ing it onto stdin (which clears close-on-exec) while closing every + // descriptor above 3. + #[cfg(target_os = "macos")] + for fd in [pipe_r.as_raw_fd(), pipe_w.as_raw_fd()] { + // SAFETY: fd is owned by pipe_r/pipe_w and open for this call. + cvt( + unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) }, + "envelope pipe cloexec failed", + )?; + } // Everything the child touches is prepared before fork: with tokio // worker threads alive, the child may only use async-signal-safe calls diff --git a/packages/plugin/src/features/magic-context/memory/embedding-synapse.test.ts b/packages/plugin/src/features/magic-context/memory/embedding-synapse.test.ts index 7c674861b..8ded1ec2a 100644 --- a/packages/plugin/src/features/magic-context/memory/embedding-synapse.test.ts +++ b/packages/plugin/src/features/magic-context/memory/embedding-synapse.test.ts @@ -1721,6 +1721,102 @@ describe("embedItemsDetailed", () => { } }); + it("reports a page cancelled when the abort lands inside its re-validation", async () => { + const db = ledgerDb(); + try { + const host = new DetailedHost(); + const controller = new AbortController(); + let demands = 0; + const provider = new SynapseEmbeddingProvider({ + connectionFile: "fixture", + projectRoot: "/repo", + session: "ses-1", + model: MODEL, + fingerprint: FP, + tableEpoch: 0, + dims: 3, + recommendedBatch: 2, + batchTimeoutMs: 5_000, + clientFactory: async () => host, + demandStart: async () => { + demands += 1; + // The abort lands while the managed demand is in flight, so + // `initialize` observes it on its own await and reports the + // plain `false` a rejected `raceSignal` is folded into. + controller.abort(); + await new Promise((resolve) => setTimeout(resolve, 0)); + return { + ok: true, + reason: "started", + storage: "ready", + authenticatedDaemonId: new Uint8Array([7, 7]), + }; + }, + }); + // Certify the lane before installing the managed origin: `initialize` + // is the only writer of the identity, and the pre-loop initialize + // must return from the already-certified state so the first page + // dispatches instead of demanding. + expect(await provider.initialize()).toBe(true); + const internals = provider as unknown as { + connectionOrigin: string; + compatibleDaemonId: Uint8Array | null; + initialized: boolean; + }; + internals.connectionOrigin = "managed-default"; + internals.compatibleDaemonId = new Uint8Array([7, 7]); + + // Reproduce the state a rotation on an earlier page installs: the + // lane is managed and no longer certified, which is precisely the + // precondition the per-page re-validation exists to answer. Doing it + // from the first page's own response keeps the second page's + // `signal.aborted` check ahead of the abort, so the abort can only + // be observed inside the re-validation itself. + host.resultPages = (_jobId, items) => { + if (items.some((item) => item.id === "memory:1")) internals.initialized = false; + return { + result: { + ...ENVELOPE, + done: true, + vectors: items.map((item) => ({ + id: item.id, + content_sha256: item.content_sha256, + vector: [1, 2, 3], + })), + }, + }; + }; + + const result = await provider.embedItemsDetailed( + detailedItems([ + { id: "memory:1", group: "g1" }, + { id: "memory:2", group: "g2" }, + ]), + detailedContext(db), + controller.signal, + ); + + // The first page completed before the identity was invalidated. + expect(result.receipts).toHaveLength(1); + expect(result.receipts[0].applicationGroup).toBe("g1"); + // Exactly one demand: the second page's re-validation. + expect(demands).toBe(1); + expect(result.failures).toHaveLength(1); + const g2 = result.failures[0]; + expect(g2.applicationGroup).toBe("g2"); + // This read `transport`/`retryable` before the signal was re-checked + // after initialization, which invites a retry of a request the caller + // withdrew and disagrees with the `cancelled` every later page reports. + expect(g2.code).toBe("cancelled"); + expect(g2.message).toBe("Synapse request aborted"); + expect(g2.disposition).toBe("retryable"); + // The cancelled page must never have reached the wire. + expect(host.batchCalls()).toHaveLength(1); + } finally { + closeQuietly(db); + } + }); + it("scopes an exhausted restart budget to its page and leaves sibling pages runnable", async () => { const db = ledgerDb(); try { diff --git a/packages/plugin/src/features/magic-context/memory/embedding-synapse.ts b/packages/plugin/src/features/magic-context/memory/embedding-synapse.ts index 03e2fe182..be444663d 100644 --- a/packages/plugin/src/features/magic-context/memory/embedding-synapse.ts +++ b/packages/plugin/src/features/magic-context/memory/embedding-synapse.ts @@ -895,6 +895,11 @@ export class SynapseEmbeddingProvider implements EmbeddingProvider { } for (let start = 0; start < items.length; ) { if (signal?.aborted || this.permanentFailure) break; + // A `module_restarted` failure on an earlier page invalidated the + // compatible daemon identity. Re-run the full initialization so + // the remaining pages only proceed against an incarnation that + // re-passed lifecycle compatibility validation. + if (!this.initialized && !(await this.initialize(signal))) break; const page = this.nextPage(items, start); start += page.length; try { @@ -1024,6 +1029,35 @@ export class SynapseEmbeddingProvider implements EmbeddingProvider { }); continue; } + // A `module_restarted` failure on an earlier page invalidated + // the compatible daemon identity; re-validate before this page + // so it never rides an unverified incarnation. + if (!this.initialized && !(await this.initialize(signal))) { + // `initialize` reports an abort raised during its own await + // as a plain `false`, so the signal is re-read here. Without + // it a caller-cancelled request records this one page as a + // retryable `transport` failure — inviting a retry of work + // the caller withdrew — while every later page correctly + // reports `cancelled` from the check above. + const aborted = signal?.aborted === true; + result.failures.push({ + applicationGroup, + items: manifest, + rowId: null, + code: this.permanentFailure + ? "artifact_invalid" + : aborted + ? "cancelled" + : "transport", + message: this.permanentFailure + ? "Synapse lane disabled after a permanent failure" + : aborted + ? "Synapse request aborted" + : "Synapse lane is unavailable", + disposition: this.permanentFailure ? "permanent" : "retryable", + }); + continue; + } try { result.receipts.push( await this.runDetailedPage(page, applicationGroup, context, signal), diff --git a/packages/plugin/src/shared/mc-host-lifecycle/compatibility.test.ts b/packages/plugin/src/shared/mc-host-lifecycle/compatibility.test.ts index d3de29c77..b42ee8e8f 100644 --- a/packages/plugin/src/shared/mc-host-lifecycle/compatibility.test.ts +++ b/packages/plugin/src/shared/mc-host-lifecycle/compatibility.test.ts @@ -137,4 +137,24 @@ describe("semver parsing", () => { expect(parseSemverTriple(bad)).toBeNull(); } }); + + test("leading zeroes are rejected rather than normalized", () => { + expect(parseSemverTriple("0.1.0")).toEqual([0, 1, 0]); + // Each of these would parse to an in-range triple under `\d+`, so the + // range gate would accept a non-canonical version. + for (const bad of ["00.1.0", "0.01.0", "0.1.00", "00.01.000", "01.2.3"]) { + expect(parseSemverTriple(bad)).toBeNull(); + } + }); + + test("a non-canonical daemon version fails the compatibility gate", () => { + // `00.01.000` normalizes to `[0, 1, 0]`, which is inside the supported + // half-open range, so only canonical-form rejection keeps this closed. + const verdict = evaluateDaemonCompatibility("mc-host/00.01.000"); + expect(verdict.ok).toBe(false); + if (!verdict.ok) { + expect(verdict.reason).toBe("incompatible_daemon"); + expect(verdict.detail).toBe("daemon version is not a canonical mc-host/X.Y.Z value"); + } + }); }); diff --git a/packages/plugin/src/shared/mc-host-lifecycle/compatibility.ts b/packages/plugin/src/shared/mc-host-lifecycle/compatibility.ts index e10d16cbb..f0b8f690d 100644 --- a/packages/plugin/src/shared/mc-host-lifecycle/compatibility.ts +++ b/packages/plugin/src/shared/mc-host-lifecycle/compatibility.ts @@ -24,8 +24,17 @@ export type CompatibilityVerdict = type SemverTriple = [number, number, number]; +/** + * Each part is a semver numeric identifier: a single `0`, or a non-zero digit + * followed by any digits. `\d+` would also admit leading zeroes, which + * `Number.parseInt` then silently normalizes — `00.01.000` would parse to + * `[0, 1, 0]` and pass the range gate, so a non-canonical peer version would + * satisfy a check whose verdict promises a canonical `X.Y.Z` value. + */ +const CANONICAL_SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; + export function parseSemverTriple(value: string): SemverTriple | null { - const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value); + const match = CANONICAL_SEMVER.exec(value); if (!match) return null; const triple: SemverTriple = [ Number.parseInt(match[1] as string, 10), @@ -182,19 +191,57 @@ export function evaluateEpochCompatibility(observed: ObservedEpochs): Compatibil return { ok: true }; } +export interface CompatibilityInput { + authenticatedDaemonVer: string; + catalog: CatalogEntry[]; + epochs: ObservedEpochs; +} + +/** + * The single ordered source of truth for the compatibility gate: stage id, + * the CLI check id it reports under, and its evaluator. `evaluateCompatibility`, + * the managed probe's `evaluatedThrough` labels, and the policy's emitted + * `compatibility.*` checks all derive from this list, so a stage added or + * reordered in one place cannot leave the probe sequence and the reported + * checks disagreeing. + */ +export const COMPATIBILITY_STAGES = [ + { + stage: "daemon", + checkId: "compatibility.daemon", + evaluate: (input: CompatibilityInput): CompatibilityVerdict => + evaluateDaemonCompatibility(input.authenticatedDaemonVer), + }, + { + stage: "modules", + checkId: "compatibility.modules", + evaluate: (input: CompatibilityInput): CompatibilityVerdict => + evaluateModuleCompatibility(input.catalog), + }, + { + stage: "epochs", + checkId: "compatibility.epochs", + evaluate: (input: CompatibilityInput): CompatibilityVerdict => + evaluateEpochCompatibility(input.epochs), + }, +] as const; + +export type CompatibilityStage = (typeof COMPATIBILITY_STAGES)[number]["stage"]; + +/** Position of `stage` in the ordered gate; the order is the array order. */ +export function compatibilityStageIndex(stage: CompatibilityStage): number { + return COMPATIBILITY_STAGES.findIndex((entry) => entry.stage === stage); +} + /** * The composed demand/status/doctor gate order: daemon range, then modules, * then epochs. First failure wins and is reported without any stop, replace, * or restart side effect (R17). */ -export function evaluateCompatibility(input: { - authenticatedDaemonVer: string; - catalog: CatalogEntry[]; - epochs: ObservedEpochs; -}): CompatibilityVerdict { - const daemon = evaluateDaemonCompatibility(input.authenticatedDaemonVer); - if (!daemon.ok) return daemon; - const modules = evaluateModuleCompatibility(input.catalog); - if (!modules.ok) return modules; - return evaluateEpochCompatibility(input.epochs); +export function evaluateCompatibility(input: CompatibilityInput): CompatibilityVerdict { + for (const stage of COMPATIBILITY_STAGES) { + const verdict = stage.evaluate(input); + if (!verdict.ok) return verdict; + } + return { ok: true }; } diff --git a/packages/plugin/src/shared/mc-host-lifecycle/managed-policy.ts b/packages/plugin/src/shared/mc-host-lifecycle/managed-policy.ts index 9041ac41c..954f293de 100644 --- a/packages/plugin/src/shared/mc-host-lifecycle/managed-policy.ts +++ b/packages/plugin/src/shared/mc-host-lifecycle/managed-policy.ts @@ -217,6 +217,8 @@ async function readCompatibilityProbe( if (signal?.aborted) throw signal.reason ?? new Error("compatibility probe aborted"); const components = asRecord(status.metrics.components); const magicContextMetrics = asRecord(asRecord(components?.["magic-context"])?.metrics); + // The probe only reports what it observed; the compatibility verdict is + // owned by exactly one place, `McHostLifecyclePolicy.applyCompatibility`. const snapshot = { authenticatedDaemonVersion: authenticated.daemonVer, authenticatedDaemonId: Uint8Array.from(authenticated.daemonId), @@ -239,7 +241,7 @@ async function probeManagedCompatibility( root: string, budgetMs: number, signal?: AbortSignal, -): Promise { +): Promise { const deadline = Date.now() + budgetMs; const client = await McHostClient.connect({ connectionFile: connectionFilePath(root), @@ -247,7 +249,7 @@ async function probeManagedCompatibility( requestTimeoutMs: Math.max(1, budgetMs), }); try { - return await readCompatibilitySnapshot(client, deadline, signal); + return await readCompatibilityProbe(client, deadline, signal); } finally { await client.closeAsync().catch(() => {}); } @@ -383,18 +385,62 @@ export function createManagedLifecyclePolicy( ? {} : { explicitExternalRoot: options.explicitExternalRoot }), }); + // The default compatibility probe's `host.status` reply already + // carries the storage state, so the demand path's storage probe can + // consume that observation instead of opening a second connection and + // re-issuing `host.status`. The observation is single-use and only a + // terminal state short-circuits; a `starting` observation still runs + // the polling probe so it can wait out startup within its own budget. + // + // The observation is tagged with the daemon incarnation whose + // `host.status` produced it and is only consumed by a demand that + // certified that same incarnation. Concurrent probes share this slot: + // `sharedCompatibility` dedupes per data root, so a real-root and a + // no-root key can be in flight together, and a non-`magic-context` + // demand writes an observation it never consumes. Untagged reuse would + // let a waiter read a state observed on a different request or daemon + // generation and publish module traffic against it. + let observedStorage: { + daemonId: Uint8Array; + state: "ready" | "starting" | "unavailable"; + } | null = null; + const defaultCompatibilityProbe = async ( + budgetMs: number, + signal?: AbortSignal, + ): Promise => { + const probe = await probeManagedCompatibility(root.root, budgetMs, signal); + observedStorage = + probe.status === null + ? null + : { + daemonId: Uint8Array.from(probe.snapshot.authenticatedDaemonId), + state: storageState(probe.status.metrics), + }; + return probe.snapshot; + }; + const defaultStorageProbe = ( + budgetMs: number, + expectedDaemonId?: Uint8Array, + ): Promise<"ready" | "starting" | "unavailable"> => { + const observed = observedStorage; + observedStorage = null; + if ( + expectedDaemonId !== undefined && + observed !== null && + sameDaemonId(observed.daemonId, expectedDaemonId) && + (observed.state === "ready" || observed.state === "unavailable") + ) { + return Promise.resolve(observed.state); + } + return probeManagedStorage(root.root, budgetMs, expectedDaemonId); + }; return new McHostLifecyclePolicy({ ...options, env, launchTarget: prepared, defaultStartupEnvelope: buildManagedCredentialEnvelope(env), - storageProbe: - options.storageProbe ?? - ((budgetMs, expectedDaemonId) => - probeManagedStorage(root.root, budgetMs, expectedDaemonId)), - compatibilityProbe: - options.compatibilityProbe ?? - ((budgetMs, signal) => probeManagedCompatibility(root.root, budgetMs, signal)), + storageProbe: options.storageProbe ?? defaultStorageProbe, + compatibilityProbe: options.compatibilityProbe ?? defaultCompatibilityProbe, readinessProbe: options.readinessProbe ?? ((budgetMs) => probeManagedReadiness(root.root, budgetMs)), diff --git a/packages/plugin/src/shared/mc-host-lifecycle/policy.ts b/packages/plugin/src/shared/mc-host-lifecycle/policy.ts index 80a071dc9..202294c85 100644 --- a/packages/plugin/src/shared/mc-host-lifecycle/policy.ts +++ b/packages/plugin/src/shared/mc-host-lifecycle/policy.ts @@ -18,11 +18,11 @@ import type { CatalogEntry } from "../mc-host-client"; import { checkPlatform, type LifecycleFailureReason, type PlatformReaders } from "./bootstrap"; import { + COMPATIBILITY_STAGES, + type CompatibilityStage, type CompatibilityVerdict, + compatibilityStageIndex, evaluateCompatibility, - evaluateDaemonCompatibility, - evaluateEpochCompatibility, - evaluateModuleCompatibility, type ObservedEpochs, } from "./compatibility"; import { @@ -55,7 +55,7 @@ export type LifecycleCommand = "start" | "stop" | "restart" | "status" | "doctor export type StorageReadiness = "ready" | "starting" | "unavailable"; -export type CompatibilityStage = "daemon" | "modules" | "epochs"; +export type { CompatibilityStage } from "./compatibility"; export interface CompatibilitySnapshot { authenticatedDaemonVersion: string; @@ -609,41 +609,21 @@ export class McHostLifecyclePolicy { result: DaemonResultV1, snapshot: CompatibilitySnapshot, ): { result: DaemonResultV1; verdict: CompatibilityVerdict } { - const verdict = evaluateCompatibility(compatibilityInput(snapshot)); - const stages: ReadonlyArray<{ - stage: CompatibilityStage; - id: "compatibility.daemon" | "compatibility.modules" | "compatibility.epochs"; - verdict: CompatibilityVerdict; - }> = [ - { - stage: "daemon", - id: "compatibility.daemon", - verdict: evaluateDaemonCompatibility(snapshot.authenticatedDaemonVersion), - }, - { - stage: "modules", - id: "compatibility.modules", - verdict: evaluateModuleCompatibility(snapshot.catalog), - }, - { - stage: "epochs", - id: "compatibility.epochs", - verdict: evaluateEpochCompatibility(snapshot.epochs), - }, - ]; - const stageOrder: Record = { - daemon: 0, - modules: 1, - epochs: 2, - }; - const evaluatedThrough = snapshot.evaluatedThrough ?? "epochs"; + const input = compatibilityInput(snapshot); + const verdict = evaluateCompatibility(input); + const evaluatedThroughIndex = compatibilityStageIndex( + snapshot.evaluatedThrough ?? "epochs", + ); const checksById = new Map(result.checks.map((check) => [check.id, check] as const)); - for (const stage of stages) { - if (stageOrder[stage.stage] > stageOrder[evaluatedThrough]) continue; - const reason = stage.verdict.ok ? "healthy" : stage.verdict.reason; - checksById.set(stage.id, { - id: stage.id, - status: stage.verdict.ok ? "pass" : "fail", + for (const [index, stage] of COMPATIBILITY_STAGES.entries()) { + // Only stages the probe actually reached are reported; a check for + // an unevaluated stage would assert an observation never made. + if (index > evaluatedThroughIndex) continue; + const stageVerdict = stage.evaluate(input); + const reason = stageVerdict.ok ? "healthy" : stageVerdict.reason; + checksById.set(stage.checkId, { + id: stage.checkId, + status: stageVerdict.ok ? "pass" : "fail", reason, remediation: remediationForReason(reason), }); diff --git a/scripts/build-mc-host-payload.ts b/scripts/build-mc-host-payload.ts index a4696dd24..c50b8101a 100644 --- a/scripts/build-mc-host-payload.ts +++ b/scripts/build-mc-host-payload.ts @@ -64,6 +64,7 @@ import { INPUT_KEYS, isPlaceholderSha256, OUTPUT_PATHS as U9_OUTPUT_PATHS, + qualificationEvidenceIdentityMismatch, requireQualificationEvidence, SOURCE_MANIFEST_PATH, } from "./qualify-mc-host-production-inputs"; @@ -259,17 +260,17 @@ export function loadReleaseContext(rootDir: string): ReleaseContext { let artifacts: Record | undefined; if (existsSync(evidencePath)) { const evidence = readJson(rootDir, U9_OUTPUT_PATHS.evidence); - const release = evidence.release as - | { id?: unknown; version?: unknown } - | undefined; - if ( - evidence.schema !== "magic-context.mc-host-release-qualification/v1" || - evidence.release_contract_sha256 !== u8Digest || - release?.id !== contract.release.id || - release?.version !== contract.release.version - ) { + // The identity rules are shared with `requireQualificationEvidence` + // (the U2/U6 consumption gate) so the two validators of this document + // cannot drift apart. + const identityMismatch = qualificationEvidenceIdentityMismatch( + evidence, + contract, + u8Digest, + ); + if (identityMismatch !== null) { fail( - `stale or unknown U9 qualification evidence at ${U9_OUTPUT_PATHS.evidence}`, + `stale or unknown U9 qualification evidence at ${U9_OUTPUT_PATHS.evidence}: ${identityMismatch}`, ); } if (lock.production_qualified !== evidence.production_qualified) { diff --git a/scripts/qualify-mc-host-production-inputs.ts b/scripts/qualify-mc-host-production-inputs.ts index 577cd0fec..703151e9e 100644 --- a/scripts/qualify-mc-host-production-inputs.ts +++ b/scripts/qualify-mc-host-production-inputs.ts @@ -2640,6 +2640,36 @@ export function generate( * mismatch), test-only, or non-production evidence. Returns the verified * evidence and artifact digests for embedding into build inputs. */ +/** + * Shared identity gate for one U9 qualification-evidence document: shape, + * schema, U8 release-contract digest, and release identity. Both the build's + * release-context loader and the production consumption gate call this, so + * what counts as "this release's evidence" cannot drift between the two + * validators. Returns the mismatch reason, or null when the document is this + * release's evidence. + */ +export function qualificationEvidenceIdentityMismatch( + evidence: unknown, + contract: { release: { id: string; version: string } }, + u8Digest: string, +): string | null { + if (evidence === null || typeof evidence !== "object" || Array.isArray(evidence)) { + return "malformed evidence document"; + } + const document = evidence as Record; + if (document.schema !== "magic-context.mc-host-release-qualification/v1") { + return "malformed or unknown schema"; + } + if (document.release_contract_sha256 !== u8Digest) { + return "stale U8 release-contract digest"; + } + const release = document.release as { id?: unknown; version?: unknown } | undefined; + if (release?.id !== contract.release.id || release?.version !== contract.release.version) { + return "release identity mismatch"; + } + return null; +} + export function requireQualificationEvidence(rootDir: string): { evidence: Record; u8Digest: string; @@ -2658,28 +2688,11 @@ export function requireQualificationEvidence(rootDir: string): { } catch { reject("malformed JSON"); } - if ( - evidence === null || - typeof evidence !== "object" || - Array.isArray(evidence) || - (evidence as { schema?: unknown }).schema !== - "magic-context.mc-host-release-qualification/v1" - ) { - reject("malformed or unknown schema"); - } const contract = buildContract(); const u8Digest = sha256Hex(canonicalJson(contract)); - if (evidence.release_contract_sha256 !== u8Digest) { - reject("stale U8 release-contract digest"); - } - const release = evidence.release as - | { id?: unknown; version?: unknown } - | undefined; - if ( - release?.id !== contract.release.id || - release?.version !== contract.release.version - ) { - reject("release identity mismatch"); + const identityMismatch = qualificationEvidenceIdentityMismatch(evidence, contract, u8Digest); + if (identityMismatch !== null) { + reject(identityMismatch); } if (evidence.test_only !== false) { reject("test-only evidence can never qualify a production build"); diff --git a/scripts/verify-mc-host-release-evidence.test.ts b/scripts/verify-mc-host-release-evidence.test.ts index 12e604c18..5bcca3223 100644 --- a/scripts/verify-mc-host-release-evidence.test.ts +++ b/scripts/verify-mc-host-release-evidence.test.ts @@ -1,11 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { buildContract, canonicalJson, sha256Hex } from "./generate-mc-host-release-manifest"; import { attestationMatchesWorkflowSource, buildInstalledReleaseEvidence, + QUALIFICATION_WORKFLOW_PATH, validateInstalledReleaseEvidence, validateInstalledReleaseEvidenceAgainstArtifacts, workflowRunApiPath, @@ -257,6 +258,11 @@ function installReleaseArtifacts( writeFileSync(join(root, relative), bytes); evidence[field] = sha256Hex(bytes); } + // Qualified verification requires the signer workflow the proofs cite to + // exist in the checkout under validation. + const workflowPath = join(root, QUALIFICATION_WORKFLOW_PATH); + mkdirSync(dirname(workflowPath), { recursive: true }); + writeFileSync(workflowPath, "name: qualification stub\n"); } describe("installed release evidence", () => { @@ -523,36 +529,50 @@ describe("installed release evidence", () => { ).toThrow(/must cite a test report under tmp\/mc-host-test-reports\//); }); - test("a test report must attest the target that cites it", () => { - const root = mkdtempSync(join(tmpdir(), "mc-host-installed-evidence-")); - const evidence = qualifiedEvidence(); - installReleaseArtifacts(root, evidence); - installProofArtifacts(root, evidence); - const targetProof = (evidence.proof_artifacts as { kind: string; path: string }[]).find( - (proof) => proof.kind === "target", - ); - if (targetProof === undefined) throw new Error("missing target proof"); - const report = JSON.parse( - readFileSync(join(root, targetProof.path), "utf8"), - ) as Record; - const reportPath = (report.observations as Record) - .test_report_path as string; - // Same path and a matching digest, but the report names another target. - const forged = `${canonicalJson({ - schema: "magic-context.mc-host-test-report/v1", - target: "some-other-target", - passed: true, - })}\n`; - writeFileSync(join(root, reportPath), forged); - rewriteProof(root, evidence, targetProof.path, (current) => { - const observations = current.observations as Record; - observations.test_report_sha256 = sha256Hex(forged); - }); + // Schema, target, and verdict share a single reject condition, so one case + // per clause keeps a regression in any one of them from riding on its + // neighbours still being enforced. + for (const mutation of ["failed", "wrong-schema", "wrong-target"] as const) { + test(`a test report must attest a passing run for its target (${mutation})`, () => { + const root = mkdtempSync(join(tmpdir(), "mc-host-installed-evidence-")); + const evidence = qualifiedEvidence(); + installReleaseArtifacts(root, evidence); + installProofArtifacts(root, evidence); + const targetProof = ( + evidence.proof_artifacts as { kind: string; path: string; subject: string }[] + ).find((proof) => proof.kind === "target"); + if (targetProof === undefined) throw new Error("missing target proof"); + const report = JSON.parse( + readFileSync(join(root, targetProof.path), "utf8"), + ) as Record; + const reportPath = (report.observations as Record) + .test_report_path as string; + // The citation path and digest stay consistent, so only the report's + // own content can carry the rejection. + const forged = `${canonicalJson({ + schema: + mutation === "wrong-schema" + ? "magic-context.mc-host-test-report/v0" + : "magic-context.mc-host-test-report/v1", + target: mutation === "wrong-target" ? "some-other-target" : targetProof.subject, + passed: mutation !== "failed", + })}\n`; + writeFileSync(join(root, reportPath), forged); + rewriteProof(root, evidence, targetProof.path, (current) => { + const observations = current.observations as Record; + observations.test_report_sha256 = sha256Hex(forged); + }); - expect(() => - validateInstalledReleaseEvidenceAgainstArtifacts(root, evidence, true, fullStubs()), - ).toThrow(/test report does not attest a passing/); - }); + expect(() => + validateInstalledReleaseEvidenceAgainstArtifacts( + root, + evidence, + true, + fullStubs(), + ), + ).toThrow(/test report does not attest a passing/); + }); + } test("one test report cannot satisfy two targets", () => { const root = mkdtempSync(join(tmpdir(), "mc-host-installed-evidence-")); @@ -860,6 +880,19 @@ describe("installed release evidence", () => { ); }); + test("a checkout without the qualification workflow cannot pass the GA gate", () => { + const root = mkdtempSync(join(tmpdir(), "mc-host-installed-evidence-")); + const evidence = qualifiedEvidence(); + installReleaseArtifacts(root, evidence); + installProofArtifacts(root, evidence); + // Every artifact and stub still verifies; only the workflow the evidence + // claims to have run under is absent from this checkout. + rmSync(join(root, QUALIFICATION_WORKFLOW_PATH)); + expect(() => + validateInstalledReleaseEvidenceAgainstArtifacts(root, evidence, true, fullStubs()), + ).toThrow(/qualification workflow .* does not exist/); + }); + test("qualification evidence cannot substitute for installed release evidence", () => { expect(() => validateInstalledReleaseEvidence( diff --git a/scripts/verify-mc-host-release-evidence.ts b/scripts/verify-mc-host-release-evidence.ts index 51870c87c..30ca827a0 100644 --- a/scripts/verify-mc-host-release-evidence.ts +++ b/scripts/verify-mc-host-release-evidence.ts @@ -6,7 +6,7 @@ */ import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; -import { readFileSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { @@ -22,7 +22,7 @@ const INPUT_LOCK_PATH = "release/mc-host-production-inputs.lock.json"; const PAYLOAD_INDEX_PATH = "release/mc-host-payload-index.json"; const STOP_PROVENANCE_PATH = "release/mc-host-n-minus-one-stop.json"; const SHA256_RE = /^[0-9a-f]{64}$/; -const QUALIFICATION_WORKFLOW_PATH = ".github/workflows/mc-host-release-qualification.yml"; +export const QUALIFICATION_WORKFLOW_PATH = ".github/workflows/mc-host-release-qualification.yml"; const EXPECTED_REPOSITORY = "ahrav/magic-context"; const TEST_REPORT_DIR = "tmp/mc-host-test-reports/"; const TEST_REPORT_SCHEMA = "magic-context.mc-host-test-report/v1"; @@ -824,6 +824,15 @@ export function validateInstalledReleaseEvidenceAgainstArtifacts( if (requireQualified && !/^[0-9a-f]{40}$/.test(expectedHeadSha)) { fail("cannot bind qualified evidence to the current release commit"); } + // Every qualified proof must cite this signer workflow, so a checkout + // that does not carry it can never have produced (or reproduce) the + // attested evidence. Failing here names the gap directly instead of + // surfacing it later as an opaque per-proof attestation mismatch. + if (requireQualified && !existsSync(join(rootDir, QUALIFICATION_WORKFLOW_PATH))) { + fail( + `qualification workflow ${QUALIFICATION_WORKFLOW_PATH} does not exist in this checkout`, + ); + } const expected = { production_inputs_sha256: sha256File(rootDir, INPUT_LOCK_PATH), qualification_sha256: sha256File(rootDir, QUALIFICATION_PATH),