From 70180d9477f417c714b254c5a691e3676cb8fe6e Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:21:18 -0700 Subject: [PATCH 1/2] feat(attest): pluggable attester seam with a deterministic sample implementation (#9211) --- .../src/calibration/attester.ts | 149 +++++++++++++++++ packages/loopover-engine/src/index.ts | 1 + .../loopover-engine/test/attester.test.ts | 41 +++++ test/unit/attester-engine.test.ts | 150 ++++++++++++++++++ 4 files changed, 341 insertions(+) create mode 100644 packages/loopover-engine/src/calibration/attester.ts create mode 100644 packages/loopover-engine/test/attester.test.ts create mode 100644 test/unit/attester-engine.test.ts diff --git a/packages/loopover-engine/src/calibration/attester.ts b/packages/loopover-engine/src/calibration/attester.ts new file mode 100644 index 0000000000..ae49548f8f --- /dev/null +++ b/packages/loopover-engine/src/calibration/attester.ts @@ -0,0 +1,149 @@ +// Attester seam (#9211) -- the pluggable boundary between "collect an attestation report" and "assemble the +// evidence envelope", so the attested-evaluation epic (#8534) can be built and CI-tested BEFORE any +// SEV-SNP-capable hardware exists. Confidential Containers ships a no-TEE sample-attester path for exactly +// this reason; mirroring it here means real hardware arrives as a CONFIG change (swap the Attester +// implementation) rather than as new code on the critical path. +// +// The split follows this module family's pure-core / thin-IO discipline (see threshold-backtest.ts vs +// services/threshold-backtest-run.ts): the interface, the deterministic sample implementation, and envelope +// assembly are pure and live here; a real SEV-SNP attester talks to /dev/sev-guest or a CoCo attestation +// agent and therefore lives in the I/O layer that supplies it. No IO, no randomness, no wall-clock reads -- +// the sample attester derives every byte it returns from its request, so a sample run is reproducible. +// +// SAFETY: a sample-attested envelope must never be presentable as real evidence. Two independent things stop +// that: the report carries {@link SAMPLE_ATTESTATION_MAGIC} as its leading plaintext bytes (so a verifier can +// name it precisely instead of reporting a confusing signature failure), AND a sample report has no AMD +// signature chain, so cryptographic verification fails regardless. The magic is the honest error message; the +// missing chain is the actual guarantee. + +import { createHash } from "node:crypto"; + +import { + buildAttestationReportData, + validateAttestationEnvelope, + type AttestationEnvelope, + type AttestationReportDataBinding, + type AttestationTeeTechnology, +} from "./attestation-envelope.js"; + +/** + * Leading plaintext bytes of every report {@link createSampleAttester} produces. Deliberately ASCII and + * deliberately not a valid SEV-SNP report prefix (a real report opens with a little-endian u32 version + * field, never this text), so detection is unambiguous in both directions. + */ +export const SAMPLE_ATTESTATION_MAGIC = "LOOPOVER-SAMPLE-ATTESTATION-v1"; + +/** What an attester is asked to bind and report over. `reportData` is {@link buildAttestationReportData}'s + * output (128 hex chars); `runtimeClass` labels the runtime image the workload ran as. */ +export type AttestationCollectionRequest = { + reportData: string; + runtimeClass: string; +}; + +/** What an attester returns -- the evidence-bearing fields only. The binding fields (`reportData`, `runId`) + * come from the caller, not the attester, so an attester can never restate what a run committed to. */ +export type AttestationCollection = { + teeTechnology: AttestationTeeTechnology; + measurement: string; + attestationReport: string; +}; + +/** + * The swappable boundary. `kind` is a stable identifier for the implementation ("sample", "sev-snp", ...) + * used in logs and failure detail; `collect` is async because every real implementation performs IO. + */ +export type Attester = { + kind: string; + collect(request: AttestationCollectionRequest): Promise; +}; + +export type SampleAttesterOptions = { + /** Which TEE the sample impersonates, so downstream shape handling is exercised for both. Default "sev-snp". */ + teeTechnology?: AttestationTeeTechnology; +}; + +/** + * A deterministic, hardware-free {@link Attester} for development and CI. Every field is derived from the + * request by SHA-256, so the same request always yields the same collection -- which is what lets the + * attested-run E2E assert exact bytes instead of merely "something was produced". + * + * NOT evidence of anything. See this module's SAFETY note. + */ +export function createSampleAttester(options: SampleAttesterOptions = {}): Attester { + const teeTechnology = options.teeTechnology ?? "sev-snp"; + return { + kind: "sample", + collect(request: AttestationCollectionRequest): Promise { + const digest = createHash("sha256") + .update(`${SAMPLE_ATTESTATION_MAGIC}:${request.reportData}:${request.runtimeClass}`) + .digest("hex"); + return Promise.resolve({ + teeTechnology, + // 64 hex chars -- inside the envelope's 32-128 measurement window for either technology. + measurement: createHash("sha256").update(`sample-measurement:${request.runtimeClass}`).digest("hex"), + attestationReport: Buffer.from(`${SAMPLE_ATTESTATION_MAGIC}:${digest}`, "utf8").toString("base64"), + }); + }, + }; +} + +/** + * True when `attestationReport` (base64) carries {@link SAMPLE_ATTESTATION_MAGIC}. Never throws: malformed + * base64, empty input and non-strings all return false, because a caller asking "is this a dev artifact?" + * about unparseable bytes wants `false`, not an exception -- the real verifier rejects those on their own + * merits (no signature chain) with its own error. + */ +export function isSampleAttestationReport(attestationReport: unknown): boolean { + if (typeof attestationReport !== "string" || attestationReport.length === 0) return false; + // Buffer.from(..., "base64") is lenient (it ignores invalid characters rather than throwing), so a decode + // that "succeeds" on garbage still can't produce the magic -- the prefix check below is what decides. + return Buffer.from(attestationReport, "base64").toString("utf8").startsWith(SAMPLE_ATTESTATION_MAGIC); +} + +export type AttestationAssemblyResult = + | { ok: true; envelope: AttestationEnvelope } + | { ok: false; errors: string[] }; + +/** + * Bind a run, collect evidence for it, and assemble a structurally-valid {@link AttestationEnvelope}. + * Computes `reportData` from `binding` itself rather than trusting a caller-supplied value, so the envelope's + * commitment and the bytes the attester signed over cannot drift apart. + * + * Returns `{ ok: false }` -- never throws -- when the attester's collection fails to assemble into a valid + * envelope, so a runner can record `attestation_failed` instead of crashing a completed evaluation. A + * throwing attester (real hardware absent, agent unreachable) is likewise converted into `{ ok: false }`: + * fail-closed is the runner's decision to record, not a stack trace to propagate. Binding-shape errors from + * {@link buildAttestationReportData} are the deliberate exception -- those are programmer errors in the + * caller's own inputs, and throwing loudly matches that helper's documented posture. + */ +export async function assembleAttestationEnvelope( + attester: Attester, + binding: AttestationReportDataBinding, + runtimeClass: string, +): Promise { + const reportData = buildAttestationReportData(binding); + + let collection: AttestationCollection; + try { + collection = await attester.collect({ reportData, runtimeClass }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + return { ok: false, errors: [`attester(${attester.kind}): collection failed: ${reason}`] }; + } + + const validation = validateAttestationEnvelope({ + schemaVersion: 1, + teeTechnology: collection.teeTechnology, + runtimeClass, + measurement: collection.measurement, + reportData, + runId: binding.runId, + attestationReport: collection.attestationReport, + // Honest default: evidence captured, nothing has checked it yet. The verifier CLI (#9212) is what + // promotes this to verified/failed -- assembly never self-certifies. + verification: { status: "unverified" }, + }); + + if (!validation.valid) return { ok: false, errors: validation.errors }; + return { ok: true, envelope: validation.envelope }; +} diff --git a/packages/loopover-engine/src/index.ts b/packages/loopover-engine/src/index.ts index f15740c963..9751101fdb 100644 --- a/packages/loopover-engine/src/index.ts +++ b/packages/loopover-engine/src/index.ts @@ -182,6 +182,7 @@ export * from "./calibration/backtest-threshold.js"; export * from "./calibration/provider-track-record.js"; export * from "./calibration/reliability-curve.js"; export * from "./calibration/attestation-envelope.js"; +export * from "./calibration/attester.js"; export { GOVERNOR_LEDGER_EVENT_TYPES, normalizeGovernorLedgerEvent, diff --git a/packages/loopover-engine/test/attester.test.ts b/packages/loopover-engine/test/attester.test.ts new file mode 100644 index 0000000000..b448ac8f56 --- /dev/null +++ b/packages/loopover-engine/test/attester.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + SAMPLE_ATTESTATION_MAGIC, + assembleAttestationEnvelope, + buildAttestationReportData, + createSampleAttester, + isSampleAttestationReport, + validateAttestationEnvelope, +} from "../dist/index.js"; + +const BINDING = { + corpusChecksum: "a1".repeat(32), // 64 hex + headSha: "b2".repeat(20), // 40 hex + baseSha: "c3".repeat(20), // 40 hex + runId: "d4".repeat(16), // 32 hex +}; +const RUNTIME_CLASS = "loopover-backtest-runner"; + +test("barrel: the public entrypoint re-exports the attester seam (#9211)", () => { + assert.equal(typeof createSampleAttester, "function"); + assert.equal(typeof assembleAttestationEnvelope, "function"); + assert.equal(typeof isSampleAttestationReport, "function"); + assert.equal(typeof SAMPLE_ATTESTATION_MAGIC, "string"); +}); + +test("the sample attester assembles an envelope the shipped validator accepts", async () => { + const result = await assembleAttestationEnvelope(createSampleAttester(), BINDING, RUNTIME_CLASS); + assert.ok(result.ok); + assert.equal(validateAttestationEnvelope(result.envelope).valid, true); + // The commitment is recomputed from the binding, never restated by the attester. + assert.equal(result.envelope.reportData, buildAttestationReportData(BINDING)); + assert.deepEqual(result.envelope.verification, { status: "unverified" }); +}); + +test("a sample-attested report stays identifiable as a dev artifact, not evidence", async () => { + const result = await assembleAttestationEnvelope(createSampleAttester(), BINDING, RUNTIME_CLASS); + assert.ok(result.ok); + assert.equal(isSampleAttestationReport(result.envelope.attestationReport), true); +}); diff --git a/test/unit/attester-engine.test.ts b/test/unit/attester-engine.test.ts new file mode 100644 index 0000000000..c4aa64c4d4 --- /dev/null +++ b/test/unit/attester-engine.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; + +// Direct src-path import (not the `@loopover/engine` barrel, which resolves to dist and is NOT in vitest's +// coverage.include): the engine's own node:test suite runs against dist and is invisible to Codecov, so this +// vitest mirror is what gives the module its codecov/patch coverage -- same seam as +// attestation-envelope-engine.test.ts. The companion packages/loopover-engine/test/attester.test.ts gates the +// engine workspace's own `npm run test` against the built barrel. +import { + SAMPLE_ATTESTATION_MAGIC, + assembleAttestationEnvelope, + createSampleAttester, + isSampleAttestationReport, + type Attester, +} from "../../packages/loopover-engine/src/calibration/attester.js"; +import { buildAttestationReportData } from "../../packages/loopover-engine/src/calibration/attestation-envelope.js"; + +const CORPUS_CHECKSUM = "a1".repeat(32); // 64 hex +const HEAD_SHA = "b2".repeat(20); // 40 hex +const BASE_SHA = "c3".repeat(20); // 40 hex +const RUN_ID = "d4".repeat(16); // 32 hex +const BINDING = { corpusChecksum: CORPUS_CHECKSUM, headSha: HEAD_SHA, baseSha: BASE_SHA, runId: RUN_ID }; +const RUNTIME_CLASS = "loopover-backtest-runner"; + +/** An attester that returns exactly what a test dictates -- the seam's whole point is that a runner can't + * tell implementations apart, so the tests exercise it the same way a runner would. */ +function fakeAttester(collection: Record, kind = "fake"): Attester { + return { kind, collect: () => Promise.resolve(collection as never) }; +} + +describe("createSampleAttester", () => { + it("defaults to sev-snp and derives every field deterministically from the request", async () => { + const attester = createSampleAttester(); + expect(attester.kind).toBe("sample"); + + const request = { reportData: "b".repeat(128), runtimeClass: RUNTIME_CLASS }; + const first = await attester.collect(request); + const second = await attester.collect(request); + + expect(first.teeTechnology).toBe("sev-snp"); + expect(first).toEqual(second); // deterministic: same request, byte-identical collection + expect(first.measurement).toMatch(/^[0-9a-f]{64}$/); + expect(isSampleAttestationReport(first.attestationReport)).toBe(true); + }); + + it("honors an explicit teeTechnology (the ?? fallback's other arm)", async () => { + const attester = createSampleAttester({ teeTechnology: "tdx" }); + const collection = await attester.collect({ reportData: "b".repeat(128), runtimeClass: RUNTIME_CLASS }); + expect(collection.teeTechnology).toBe("tdx"); + }); + + it("binds the report to the request: a different reportData yields a different report", async () => { + const attester = createSampleAttester(); + const a = await attester.collect({ reportData: "b".repeat(128), runtimeClass: RUNTIME_CLASS }); + const b = await attester.collect({ reportData: "c".repeat(128), runtimeClass: RUNTIME_CLASS }); + expect(a.attestationReport).not.toBe(b.attestationReport); + // ...while the measurement tracks the runtime class alone, not the payload. + expect(a.measurement).toBe(b.measurement); + }); +}); + +describe("isSampleAttestationReport", () => { + it("detects a sample report by its magic prefix", () => { + const report = Buffer.from(`${SAMPLE_ATTESTATION_MAGIC}:abc`, "utf8").toString("base64"); + expect(isSampleAttestationReport(report)).toBe(true); + }); + + it("returns false for base64 that decodes to something else", () => { + expect(isSampleAttestationReport(Buffer.from("a real report", "utf8").toString("base64"))).toBe(false); + }); + + it("returns false -- never throws -- for a non-string, and for an empty string", () => { + expect(isSampleAttestationReport(undefined)).toBe(false); + expect(isSampleAttestationReport(null)).toBe(false); + expect(isSampleAttestationReport(42)).toBe(false); + expect(isSampleAttestationReport("")).toBe(false); + }); +}); + +describe("assembleAttestationEnvelope", () => { + it("assembles a valid, unverified envelope and computes reportData from the binding itself", async () => { + const result = await assembleAttestationEnvelope(createSampleAttester(), BINDING, RUNTIME_CLASS); + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.envelope.reportData).toBe(buildAttestationReportData(BINDING)); + expect(result.envelope.runId).toBe(RUN_ID); + expect(result.envelope.runtimeClass).toBe(RUNTIME_CLASS); + expect(result.envelope.schemaVersion).toBe(1); + // Assembly never self-certifies -- promoting this is the verifier's job (#9212). + expect(result.envelope.verification).toEqual({ status: "unverified" }); + }); + + it("ignores any reportData an attester tries to restate: the binding is the only source", async () => { + // The fake echoes a bogus reportData in its collection; assembly must not read it back. + const attester = fakeAttester({ + teeTechnology: "sev-snp", + measurement: "a".repeat(64), + reportData: "f".repeat(128), + attestationReport: "QUJD", + }); + const result = await assembleAttestationEnvelope(attester, BINDING, RUNTIME_CLASS); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.envelope.reportData).toBe(buildAttestationReportData(BINDING)); + }); + + it("returns ok:false with the validator's errors when a collection is structurally invalid", async () => { + const attester = fakeAttester({ + teeTechnology: "sev-snp", + measurement: "not-hex", + attestationReport: "QUJD", + }); + const result = await assembleAttestationEnvelope(attester, BINDING, RUNTIME_CLASS); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors.some((error) => error.startsWith("measurement:"))).toBe(true); + }); + + it("converts a throwing attester into ok:false, naming the attester kind (Error arm)", async () => { + const attester: Attester = { + kind: "sev-snp", + collect: () => Promise.reject(new Error("/dev/sev-guest not present")), + }; + const result = await assembleAttestationEnvelope(attester, BINDING, RUNTIME_CLASS); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors).toEqual(["attester(sev-snp): collection failed: /dev/sev-guest not present"]); + }); + + it("converts a non-Error throw into ok:false too (the instanceof fallback arm)", async () => { + const attester: Attester = { kind: "flaky", collect: () => Promise.reject("agent timeout") }; + const result = await assembleAttestationEnvelope(attester, BINDING, RUNTIME_CLASS); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors).toEqual(["attester(flaky): collection failed: agent timeout"]); + }); + + it("throws on a malformed binding -- a caller-side programmer error, per buildAttestationReportData", async () => { + await expect( + assembleAttestationEnvelope(createSampleAttester(), { ...BINDING, headSha: "nope" }, RUNTIME_CLASS), + ).rejects.toThrow(/headSha must be 40 or 64 lowercase hex characters/); + }); + + it("a sample-attested envelope stays identifiable as a dev artifact after assembly", async () => { + const result = await assembleAttestationEnvelope(createSampleAttester(), BINDING, RUNTIME_CLASS); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(isSampleAttestationReport(result.envelope.attestationReport)).toBe(true); + }); +}); From 0773bd5ed556c0ece025cbaa6e17c4ec53590f28 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:29:12 -0700 Subject: [PATCH 2/2] feat(attest): attested backtest runner, SNP attester, and fail-closed classification (#9211) --- scripts/attested-backtest-run-core.ts | 100 ++++++++++++ scripts/attested-backtest-run.ts | 163 +++++++++++++++++++ scripts/snp-attester.ts | 100 ++++++++++++ test/unit/attested-backtest-run-core.test.ts | 147 +++++++++++++++++ test/unit/attested-backtest-run-e2e.test.ts | 119 ++++++++++++++ test/unit/snp-attester.test.ts | 122 ++++++++++++++ 6 files changed, 751 insertions(+) create mode 100644 scripts/attested-backtest-run-core.ts create mode 100644 scripts/attested-backtest-run.ts create mode 100644 scripts/snp-attester.ts create mode 100644 test/unit/attested-backtest-run-core.test.ts create mode 100644 test/unit/attested-backtest-run-e2e.test.ts create mode 100644 test/unit/snp-attester.test.ts diff --git a/scripts/attested-backtest-run-core.ts b/scripts/attested-backtest-run-core.ts new file mode 100644 index 0000000000..b7fefbc89a --- /dev/null +++ b/scripts/attested-backtest-run-core.ts @@ -0,0 +1,100 @@ +// Pure core for the attested backtest run (#9211, epic #8534). Decides WHETHER a run's attestation is +// acceptable and renders how it is persisted; the CLI (attested-backtest-run.ts) does the corpus read, the +// attester IO, and the D1 write. Mirrors backtest-logic-check-core.ts's pure-core / thin-IO split exactly, +// including its audit-event insert shape. +// +// The fail-closed rule lives here rather than in the CLI because it is the security-relevant decision in this +// feature: a runtime that CLAIMS a TEE must never be allowed to record a run as if attestation succeeded when +// it did not (#9211). Keeping it pure is what lets it be exhaustively tested without hardware. +import type { AttestationEnvelope } from "@loopover/engine"; + +import { sqlStringLiteral } from "./backtest-logic-check-core.js"; + +export const ATTESTED_BACKTEST_EVENT_TYPE = "calibration.attested_backtest_run"; + +/** What the runtime claims about itself, independent of what the attester actually returned. `none` is an + * ordinary un-attested dev run; `tee` asserts the workload believes it is inside a TEE runtime class. */ +export type RuntimeAttestationClaim = "none" | "tee"; + +export type AttestedRunOutcome = + /** Evidence captured and structurally valid. `attesterKind` is recorded so a sample-attested run is never + * mistaken for hardware evidence downstream. */ + | { status: "attested"; envelope: AttestationEnvelope; attesterKind: string } + /** The runtime claimed a TEE and evidence was NOT obtained -- the fail-closed case. */ + | { status: "attestation_failed"; reason: string; attesterKind: string } + /** No TEE was claimed and none was obtained: an ordinary unattested run, not a failure. */ + | { status: "unattested"; reason: string; attesterKind: string }; + +/** + * Decide a run's attestation outcome from what the runtime claimed and what the attester produced. + * + * The asymmetry is the point (#9211): under `claim: "tee"` a missing or invalid envelope is + * `attestation_failed` -- recorded, and non-zero-exiting at the CLI -- because a TEE runtime that cannot + * produce evidence is either misconfigured or lying, and silently degrading it to "unattested" is exactly the + * hole this epic exists to close. Under `claim: "none"` the same absence is simply `unattested`, so a + * developer running the pipeline on a laptop is not told their run failed. + * + * PURE. Never throws: an assembly failure arrives as `assembly.ok === false` and is classified, not raised. + */ +export function decideAttestedRunOutcome(input: { + claim: RuntimeAttestationClaim; + attesterKind: string; + assembly: { ok: true; envelope: AttestationEnvelope } | { ok: false; errors: string[] }; +}): AttestedRunOutcome { + if (input.assembly.ok) { + return { status: "attested", envelope: input.assembly.envelope, attesterKind: input.attesterKind }; + } + const reason = input.assembly.errors.join("; "); + if (input.claim === "tee") return { status: "attestation_failed", reason, attesterKind: input.attesterKind }; + return { status: "unattested", reason, attesterKind: input.attesterKind }; +} + +/** Process exit code for an outcome: only the fail-closed case is non-zero, so an ordinary unattested dev run + * never breaks a pipeline while a TEE runtime that lost its evidence always does. */ +export function attestedRunExitCode(outcome: AttestedRunOutcome): 0 | 1 { + return outcome.status === "attestation_failed" ? 1 : 0; +} + +/** + * Render the audit-event INSERT for one attested run. Mirrors buildLogicBacktestAuditInsertSql's column set, + * actor, and `outcome: "completed"` convention -- "completed" means "this run recorded successfully"; the + * attestation verdict itself lives in `detail` and `metadata.attestation.status`, exactly as the sibling + * backtest writers put their verdict in metadata rather than in the fixed outcome enum. + */ +export function buildAttestedRunAuditInsertSql(input: { + id: string; + targetKey: string; + ruleId: string; + outcome: AttestedRunOutcome; + corpusChecksum: string; + headSha: string; + baseSha: string; + runId: string; + createdAt: string; +}): string { + const metadataJson = JSON.stringify({ + attestation: { + status: input.outcome.status, + attesterKind: input.outcome.attesterKind, + ...(input.outcome.status === "attested" + ? { envelope: input.outcome.envelope } + : { reason: input.outcome.reason }), + }, + ruleId: input.ruleId, + corpusChecksum: input.corpusChecksum, + headSha: input.headSha, + baseSha: input.baseSha, + runId: input.runId, + }); + const values = [ + sqlStringLiteral(input.id), + sqlStringLiteral(ATTESTED_BACKTEST_EVENT_TYPE), + "'loopover'", + sqlStringLiteral(input.targetKey), + "'completed'", + sqlStringLiteral(`attested backtest for ${input.ruleId}: ${input.outcome.status} (${input.outcome.attesterKind})`), + sqlStringLiteral(metadataJson), + sqlStringLiteral(input.createdAt), + ].join(", "); + return `INSERT INTO audit_events (id, event_type, actor, target_key, outcome, detail, metadata_json, created_at) VALUES (${values})`; +} diff --git a/scripts/attested-backtest-run.ts b/scripts/attested-backtest-run.ts new file mode 100644 index 0000000000..8e31e5e2f6 --- /dev/null +++ b/scripts/attested-backtest-run.ts @@ -0,0 +1,163 @@ +#!/usr/bin/env node +// Attested backtest run (#9211, epic #8534) — replay a rule's corpus and capture attestation evidence +// binding WHICH evaluation ran, then persist the outcome. +// +// npx tsx scripts/attested-backtest-run.ts \ +// --rule-id linked_issue_scope_mismatch --corpus corpus.json \ +// --head-sha <40 hex> --base-sha <40 hex> --repo owner/repo --pr 42 \ +// [--attester sample|snp] [--runtime-claim none|tee] [--measurement ] \ +// [--runtime-class loopover-backtest-runner] [--persist --db loopover [--remote]] +// +// `--attester sample` (default) needs no hardware: it produces a deterministic, self-labeling dev artifact so +// this whole path is exercisable in CI today, with real SNP hardware arriving as `--attester snp` and nothing +// else changing. `--runtime-claim tee` is what makes a missing report FATAL (exit 1) instead of merely +// unattested — set it wherever the workload is supposed to be inside a TEE runtime class (#9213), so a +// misconfigured or lying runtime can never record a run as if attestation had succeeded. +// +// Exit codes: 0 = ran (attested or honestly unattested); 1 = FAIL-CLOSED (a TEE was claimed, evidence was +// not obtained); 2 = unusable input. +import { spawnSync } from "node:child_process"; +import { randomBytes, randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; + +import { assembleAttestationEnvelope, createSampleAttester, type Attester, type BacktestCase } from "@loopover/engine"; + +import { checksumCases } from "./backtest-corpus-export-core.js"; +import { + attestedRunExitCode, + buildAttestedRunAuditInsertSql, + decideAttestedRunOutcome, + type RuntimeAttestationClaim, +} from "./attested-backtest-run-core.js"; +import { createSnpAttester } from "./snp-attester.js"; + +type Args = { + ruleId: string; + corpus: string; + headSha: string; + baseSha: string; + repo: string; + pr: string; + attester: string; + runtimeClaim: RuntimeAttestationClaim; + runtimeClass: string; + measurement: string; + persist: boolean; + db: string; + remote: boolean; +}; + +function parseArgs(argv: readonly string[]): Args { + const get = (flag: string, fallback = ""): string => { + const index = argv.indexOf(flag); + return index >= 0 ? (argv[index + 1] ?? fallback) : fallback; + }; + const claim = get("--runtime-claim", "none"); + return { + ruleId: get("--rule-id"), + corpus: get("--corpus"), + headSha: get("--head-sha"), + baseSha: get("--base-sha"), + repo: get("--repo"), + pr: get("--pr"), + attester: get("--attester", "sample"), + runtimeClaim: claim === "tee" ? "tee" : "none", + runtimeClass: get("--runtime-class", "loopover-backtest-runner"), + // Supplied by the deployment that recorded it when the CoCo runtime class was stood up (#9213) — a guest + // cannot self-report a trustworthy measurement, and the verifier (#9212) checks it against the expected + // pinned image digest regardless. + measurement: get("--measurement"), + persist: argv.includes("--persist"), + db: get("--db", "loopover"), + remote: argv.includes("--remote"), + }; +} + +/** Resolve the configured attester. The whole point of the seam: this is the ONLY place the two differ. */ +export function resolveAttester(args: Pick): Attester { + if (args.attester === "snp") { + if (!/^[0-9a-f]{32,128}$/.test(args.measurement)) { + throw new Error("--attester snp requires --measurement (32-128 lowercase hex characters)"); + } + return createSnpAttester({ measurement: args.measurement, reportBin: process.env["SNP_REPORT_BIN"] ?? null }); + } + if (args.attester !== "sample") throw new Error(`unknown --attester: ${args.attester}`); + return createSampleAttester(); +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + for (const [flag, value] of [ + ["--rule-id", args.ruleId], + ["--corpus", args.corpus], + ["--head-sha", args.headSha], + ["--base-sha", args.baseSha], + ["--repo", args.repo], + ["--pr", args.pr], + ] as const) { + if (!value) { + process.stderr.write(`attested-backtest-run: ${flag} is required\n`); + process.exit(2); + } + } + + const manifest = JSON.parse(readFileSync(args.corpus, "utf8")) as { ruleId: string; checksum: string; cases: BacktestCase[] }; + if (manifest.ruleId !== args.ruleId) { + process.stderr.write(`corpus manifest is for rule ${manifest.ruleId}, not ${args.ruleId}\n`); + process.exit(2); + } + // Verify before attesting: an envelope binding a checksum the cases don't actually produce would be + // precisely-committed evidence of the wrong thing. + if (checksumCases(manifest.cases) !== manifest.checksum) { + process.stderr.write("corpus manifest checksum mismatch — re-export with backtest-corpus-export.ts\n"); + process.exit(2); + } + + const runId = randomBytes(16).toString("hex"); + const binding = { corpusChecksum: manifest.checksum, headSha: args.headSha, baseSha: args.baseSha, runId }; + + let attester: Attester; + try { + attester = resolveAttester(args); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(2); + return; + } + + const assembly = await assembleAttestationEnvelope(attester, binding, args.runtimeClass); + const outcome = decideAttestedRunOutcome({ claim: args.runtimeClaim, attesterKind: attester.kind, assembly }); + + if (args.persist) { + const sql = buildAttestedRunAuditInsertSql({ + id: randomUUID(), + targetKey: `${args.repo}#${args.pr}`, + ruleId: args.ruleId, + outcome, + corpusChecksum: manifest.checksum, + headSha: args.headSha, + baseSha: args.baseSha, + runId, + createdAt: new Date().toISOString(), + }); + const result = spawnSync("npx", ["wrangler", "d1", "execute", args.db, args.remote ? "--remote" : "--local", "--json", "--command", sql], { + encoding: "utf8", + maxBuffer: 256 * 1024 * 1024, + }); + if (result.status !== 0) { + process.stderr.write(`persist failed: ${(result.stderr ?? "").trim()}\n`); + process.exit(2); + } + } + + process.stdout.write(`${JSON.stringify({ status: outcome.status, attesterKind: outcome.attesterKind, runId, corpusChecksum: manifest.checksum }, null, 2)}\n`); + if (outcome.status === "attestation_failed") { + process.stderr.write(`FAIL-CLOSED: runtime claimed a TEE but produced no valid evidence: ${outcome.reason}\n`); + } + process.exit(attestedRunExitCode(outcome)); +} + +// Only run when invoked directly, so the exported helpers stay importable by tests. +if (process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop() ?? "")) { + await main(); +} diff --git a/scripts/snp-attester.ts b/scripts/snp-attester.ts new file mode 100644 index 0000000000..f29bc94a53 --- /dev/null +++ b/scripts/snp-attester.ts @@ -0,0 +1,100 @@ +// SEV-SNP attester (#9211) -- the IO-touching half of the attester seam, kept OUT of @loopover/engine so +// that package stays pure (no IO, no randomness, no clock). Pairs with createSampleAttester: same Attester +// interface, so switching a run from dev to real hardware is a config change, never a code change. +// +// Two collection paths, tried in order: +// 1. A Confidential Containers attestation agent over its local HTTP endpoint (the k3s/Kata/CoCo topology +// this epic targets -- see #9213). Preferred, because the agent owns the VCEK/cert plumbing. +// 2. The guest device (/dev/sev-guest) via a helper binary, for a bare CVM with no agent. +// +// Neither path is reachable without SNP-capable hardware, which is exactly why the sample attester exists: +// this file's failure mode on ordinary hardware is a clean throw that assembleAttestationEnvelope converts +// into `{ ok: false }`, which decideAttestedRunOutcome then classifies as attestation_failed under a TEE +// claim (fail-closed) -- never a silent degrade. +import { spawnSync } from "node:child_process"; + +import type { Attester, AttestationCollection, AttestationCollectionRequest } from "@loopover/engine"; + +/** Where a CoCo attestation agent listens inside the guest. Overridable for a non-default topology. */ +export const DEFAULT_ATTESTATION_AGENT_URL = "http://127.0.0.1:8006/aa/evidence"; + +export type SnpAttesterOptions = { + /** CoCo attestation-agent evidence endpoint. Set to null to skip the agent path entirely. */ + agentUrl?: string | null; + /** Helper binary for the direct-device path, invoked as ` ` and expected to print the + * base64 report on stdout. Set to null to skip the device path. */ + reportBin?: string | null; + /** Launch measurement, hex. Supplied by the deployment (recorded when the CoCo runtime class is stood up, + * #9213) rather than read here -- the guest cannot self-report a trustworthy measurement, and the verifier + * (#9212) checks it against the expected pinned image digest anyway. */ + measurement: string; + fetchImpl?: typeof fetch; + spawnImpl?: typeof spawnSync; +}; + +async function collectFromAgent(url: string, request: AttestationCollectionRequest, fetchImpl: typeof fetch): Promise { + const response = await fetchImpl(`${url}?runtime_data=${encodeURIComponent(request.reportData)}`, { + headers: { accept: "application/json" }, + }); + if (!response.ok) throw new Error(`attestation agent responded ${response.status}`); + const body = (await response.json()) as { evidence?: unknown }; + if (typeof body.evidence !== "string" || body.evidence.length === 0) { + throw new Error("attestation agent returned no evidence"); + } + return body.evidence; +} + +function collectFromDevice(bin: string, request: AttestationCollectionRequest, spawnImpl: typeof spawnSync): string { + const result = spawnImpl(bin, [request.reportData], { encoding: "utf8" }); + if (result.status !== 0) { + throw new Error(`report helper exited ${result.status ?? "null"}: ${(result.stderr ?? "").trim() || "no stderr"}`); + } + const report = (result.stdout ?? "").trim(); + if (report.length === 0) throw new Error("report helper produced no output"); + return report; +} + +/** + * A real SEV-SNP {@link Attester}. Throws (rather than returning a degraded collection) when no path yields a + * report: the caller's fail-closed classification is what decides whether that is fatal, and a half-formed + * "collection" here would defeat it. + */ +export function createSnpAttester(options: SnpAttesterOptions): Attester { + const agentUrl = options.agentUrl === undefined ? DEFAULT_ATTESTATION_AGENT_URL : options.agentUrl; + const reportBin = options.reportBin === undefined ? null : options.reportBin; + const fetchImpl = options.fetchImpl ?? globalThis.fetch; + const spawnImpl = options.spawnImpl ?? spawnSync; + + return { + kind: "sev-snp", + async collect(request: AttestationCollectionRequest): Promise { + const failures: string[] = []; + + if (agentUrl) { + try { + return { + teeTechnology: "sev-snp", + measurement: options.measurement, + attestationReport: await collectFromAgent(agentUrl, request, fetchImpl), + }; + } catch (error) { + failures.push(`agent: ${error instanceof Error ? error.message : String(error)}`); + } + } + + if (reportBin) { + try { + return { + teeTechnology: "sev-snp", + measurement: options.measurement, + attestationReport: collectFromDevice(reportBin, request, spawnImpl), + }; + } catch (error) { + failures.push(`device: ${error instanceof Error ? error.message : String(error)}`); + } + } + + throw new Error(failures.length > 0 ? failures.join("; ") : "no attestation path configured"); + }, + }; +} diff --git a/test/unit/attested-backtest-run-core.test.ts b/test/unit/attested-backtest-run-core.test.ts new file mode 100644 index 0000000000..4e71dc6683 --- /dev/null +++ b/test/unit/attested-backtest-run-core.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; + +import { + ATTESTED_BACKTEST_EVENT_TYPE, + attestedRunExitCode, + buildAttestedRunAuditInsertSql, + decideAttestedRunOutcome, + type AttestedRunOutcome, +} from "../../scripts/attested-backtest-run-core.js"; +import type { AttestationEnvelope } from "../../packages/loopover-engine/src/calibration/attestation-envelope.js"; + +const ENVELOPE: AttestationEnvelope = { + schemaVersion: 1, + teeTechnology: "sev-snp", + runtimeClass: "loopover-backtest-runner", + measurement: "a".repeat(64), + reportData: "b".repeat(128), + runId: "d4".repeat(16), + attestationReport: "QUJD", + verification: { status: "unverified" }, +}; + +const AUDIT_INPUT = { + id: "run-1", + targetKey: "acme/widgets#42", + ruleId: "linked_issue_scope_mismatch", + corpusChecksum: "a1".repeat(32), + headSha: "b2".repeat(20), + baseSha: "c3".repeat(20), + runId: "d4".repeat(16), + createdAt: "2026-07-27T00:00:00.000Z", +}; + +describe("decideAttestedRunOutcome", () => { + it("records a successful assembly as attested, carrying the attester kind forward", () => { + const outcome = decideAttestedRunOutcome({ + claim: "tee", + attesterKind: "sev-snp", + assembly: { ok: true, envelope: ENVELOPE }, + }); + expect(outcome).toEqual({ status: "attested", envelope: ENVELOPE, attesterKind: "sev-snp" }); + }); + + it("a sample-attested run stays labeled as such, so it can never pass for hardware evidence", () => { + const outcome = decideAttestedRunOutcome({ + claim: "none", + attesterKind: "sample", + assembly: { ok: true, envelope: ENVELOPE }, + }); + expect(outcome.status).toBe("attested"); + expect(outcome.attesterKind).toBe("sample"); + }); + + it("FAIL-CLOSED: a TEE-claiming runtime that produced no evidence is attestation_failed, not degraded", () => { + const outcome = decideAttestedRunOutcome({ + claim: "tee", + attesterKind: "sev-snp", + assembly: { ok: false, errors: ["attester(sev-snp): collection failed: /dev/sev-guest not present"] }, + }); + expect(outcome).toEqual({ + status: "attestation_failed", + reason: "attester(sev-snp): collection failed: /dev/sev-guest not present", + attesterKind: "sev-snp", + }); + }); + + it("the same absence under no TEE claim is an ordinary unattested run, not a failure", () => { + const outcome = decideAttestedRunOutcome({ + claim: "none", + attesterKind: "sample", + assembly: { ok: false, errors: ["measurement: expected 32-128 lowercase hex characters"] }, + }); + expect(outcome).toEqual({ + status: "unattested", + reason: "measurement: expected 32-128 lowercase hex characters", + attesterKind: "sample", + }); + }); + + it("joins multiple assembly errors into one reason string", () => { + const outcome = decideAttestedRunOutcome({ + claim: "tee", + attesterKind: "sev-snp", + assembly: { ok: false, errors: ["measurement: bad", "reportData: bad"] }, + }); + expect(outcome.status).toBe("attestation_failed"); + if (outcome.status !== "attestation_failed") return; + expect(outcome.reason).toBe("measurement: bad; reportData: bad"); + }); +}); + +describe("attestedRunExitCode", () => { + it("only the fail-closed case exits non-zero", () => { + const attested: AttestedRunOutcome = { status: "attested", envelope: ENVELOPE, attesterKind: "sample" }; + const unattested: AttestedRunOutcome = { status: "unattested", reason: "no tee", attesterKind: "sample" }; + const failed: AttestedRunOutcome = { status: "attestation_failed", reason: "no report", attesterKind: "sev-snp" }; + + expect(attestedRunExitCode(attested)).toBe(0); + expect(attestedRunExitCode(unattested)).toBe(0); + expect(attestedRunExitCode(failed)).toBe(1); + }); +}); + +describe("buildAttestedRunAuditInsertSql", () => { + it("persists the envelope under the attested status, with the shared audit-event column set", () => { + const sql = buildAttestedRunAuditInsertSql({ + ...AUDIT_INPUT, + outcome: { status: "attested", envelope: ENVELOPE, attesterKind: "sev-snp" }, + }); + + expect(sql).toContain("INSERT INTO audit_events (id, event_type, actor, target_key, outcome, detail, metadata_json, created_at)"); + expect(sql).toContain(ATTESTED_BACKTEST_EVENT_TYPE); + // The fixed outcome enum stays "completed" (the run recorded); the verdict lives in detail + metadata. + expect(sql).toContain("'completed'"); + expect(sql).toContain("attested backtest for linked_issue_scope_mismatch: attested (sev-snp)"); + + const metadata = JSON.parse(sql.match(/'(\{"attestation".*?\})'/)?.[1]?.replace(/''/g, "'") ?? "{}"); + expect(metadata.attestation.status).toBe("attested"); + expect(metadata.attestation.envelope).toEqual(ENVELOPE); + expect(metadata.attestation.reason).toBeUndefined(); + expect(metadata.corpusChecksum).toBe(AUDIT_INPUT.corpusChecksum); + expect(metadata.runId).toBe(AUDIT_INPUT.runId); + }); + + it("persists the reason -- and no envelope -- for a fail-closed run", () => { + const sql = buildAttestedRunAuditInsertSql({ + ...AUDIT_INPUT, + outcome: { status: "attestation_failed", reason: "no report", attesterKind: "sev-snp" }, + }); + + expect(sql).toContain("attested backtest for linked_issue_scope_mismatch: attestation_failed (sev-snp)"); + const metadata = JSON.parse(sql.match(/'(\{"attestation".*?\})'/)?.[1]?.replace(/''/g, "'") ?? "{}"); + expect(metadata.attestation.status).toBe("attestation_failed"); + expect(metadata.attestation.reason).toBe("no report"); + expect(metadata.attestation.envelope).toBeUndefined(); + }); + + it("escapes single quotes in the rendered literals rather than breaking the statement", () => { + const sql = buildAttestedRunAuditInsertSql({ + ...AUDIT_INPUT, + ruleId: "rule_with_'quote", + outcome: { status: "unattested", reason: "it's fine", attesterKind: "sample" }, + }); + expect(sql).toContain("rule_with_''quote"); + expect(sql).toContain("it''s fine"); + }); +}); diff --git a/test/unit/attested-backtest-run-e2e.test.ts b/test/unit/attested-backtest-run-e2e.test.ts new file mode 100644 index 0000000000..650c6775aa --- /dev/null +++ b/test/unit/attested-backtest-run-e2e.test.ts @@ -0,0 +1,119 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { buildBacktestCorpusManifest } from "../../scripts/backtest-corpus-export-core.js"; +import type { BacktestCase } from "../../packages/loopover-engine/src/index.js"; + +// End-to-end for the attested-run CLI (#9211), executed as a REAL subprocess against the sample attester -- +// which is the whole point of the seam: this exercises the complete path (corpus verify -> bind -> collect -> +// assemble -> classify -> exit code) on ordinary CI hardware, so the only thing real SNP metal changes is the +// `--attester snp` branch. Mirrors the repo's other real-subprocess CLI suites. + +const RULE_ID = "linked_issue_scope_mismatch"; +const HEAD_SHA = "b2".repeat(20); +const BASE_SHA = "c3".repeat(20); +const CASES: BacktestCase[] = [ + { targetKey: "acme/widgets#1", ruleId: RULE_ID, fired: true, overridden: false } as unknown as BacktestCase, +]; + +let dir: string; +let corpusPath: string; + +function run(args: readonly string[]): { status: number; stdout: string; stderr: string } { + try { + // tsx, not bare node: the repo's script family uses `.js` specifiers between sibling scripts (the + // NodeNext convention tsc enforces), which only tsx resolves back to their `.ts` sources -- the same + // runner .github/workflows/calibration-advisory.yml uses for the sibling backtest CLIs. + const stdout = execFileSync( + join(process.cwd(), "node_modules/.bin/tsx"), + [join(process.cwd(), "scripts/attested-backtest-run.ts"), ...args], + { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ); + return { status: 0, stdout, stderr: "" }; + } catch (error) { + const failure = error as { status?: number; stdout?: string; stderr?: string }; + return { status: failure.status ?? -1, stdout: failure.stdout ?? "", stderr: failure.stderr ?? "" }; + } +} + +const BASE_ARGS = ["--rule-id", RULE_ID, "--head-sha", HEAD_SHA, "--base-sha", BASE_SHA, "--repo", "acme/widgets", "--pr", "42"]; + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "attested-run-")); + corpusPath = join(dir, "corpus.json"); + writeFileSync(corpusPath, JSON.stringify(buildBacktestCorpusManifest(RULE_ID, CASES))); +}); + +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe("attested-backtest-run CLI (real subprocess)", () => { + it("runs the full attested path with the sample attester and exits 0", () => { + const result = run([...BASE_ARGS, "--corpus", corpusPath]); + expect(result.status).toBe(0); + + const report = JSON.parse(result.stdout); + expect(report.status).toBe("attested"); + expect(report.attesterKind).toBe("sample"); + expect(report.corpusChecksum).toMatch(/^[0-9a-f]{64}$/); + // A fresh freshness token per run is what stops a valid report being replayed for a different run. + expect(report.runId).toMatch(/^[0-9a-f]{32}$/); + }); + + it("mints a distinct runId on every invocation", () => { + const first = JSON.parse(run([...BASE_ARGS, "--corpus", corpusPath]).stdout); + const second = JSON.parse(run([...BASE_ARGS, "--corpus", corpusPath]).stdout); + expect(first.runId).not.toBe(second.runId); + }); + + it("FAIL-CLOSED: claiming a TEE with no reachable hardware exits 1 and says so", () => { + // No agent, no report helper, on ordinary CI hardware -- exactly the misconfigured-runtime case. + const result = run([...BASE_ARGS, "--corpus", corpusPath, "--attester", "snp", "--measurement", "a".repeat(64), "--runtime-claim", "tee"]); + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout).status).toBe("attestation_failed"); + expect(result.stderr).toContain("FAIL-CLOSED"); + }); + + it("the SAME failure without a TEE claim is honestly unattested, and exits 0", () => { + const result = run([...BASE_ARGS, "--corpus", corpusPath, "--attester", "snp", "--measurement", "a".repeat(64)]); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).status).toBe("unattested"); + }); + + it("rejects a corpus whose cases do not produce its recorded checksum", () => { + const tampered = join(dir, "tampered.json"); + const manifest = buildBacktestCorpusManifest(RULE_ID, CASES); + writeFileSync(tampered, JSON.stringify({ ...manifest, checksum: "0".repeat(64) })); + + const result = run([...BASE_ARGS, "--corpus", tampered]); + expect(result.status).toBe(2); + expect(result.stderr).toContain("checksum mismatch"); + }); + + it("rejects a corpus exported for a different rule, and a missing required flag", () => { + const otherRule = join(dir, "other.json"); + writeFileSync(otherRule, JSON.stringify(buildBacktestCorpusManifest("some_other_rule", CASES))); + const mismatched = run([...BASE_ARGS, "--corpus", otherRule]); + expect(mismatched.status).toBe(2); + expect(mismatched.stderr).toContain("is for rule some_other_rule"); + + const missing = run(["--rule-id", RULE_ID, "--corpus", corpusPath]); + expect(missing.status).toBe(2); + expect(missing.stderr).toContain("--head-sha is required"); + }); + + it("rejects --attester snp without a usable measurement, and an unknown attester name", () => { + const noMeasurement = run([...BASE_ARGS, "--corpus", corpusPath, "--attester", "snp"]); + expect(noMeasurement.status).toBe(2); + expect(noMeasurement.stderr).toContain("requires --measurement"); + + const unknown = run([...BASE_ARGS, "--corpus", corpusPath, "--attester", "nonsense"]); + expect(unknown.status).toBe(2); + expect(unknown.stderr).toContain("unknown --attester: nonsense"); + }); +}); diff --git a/test/unit/snp-attester.test.ts b/test/unit/snp-attester.test.ts new file mode 100644 index 0000000000..8322314980 --- /dev/null +++ b/test/unit/snp-attester.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from "vitest"; + +import { DEFAULT_ATTESTATION_AGENT_URL, createSnpAttester } from "../../scripts/snp-attester.js"; + +const REQUEST = { reportData: "b".repeat(128), runtimeClass: "loopover-backtest-runner" }; +const MEASUREMENT = "a".repeat(64); + +function jsonResponse(body: unknown, ok = true, status = 200): Response { + return { ok, status, json: () => Promise.resolve(body) } as unknown as Response; +} + +function spawnResult(over: Partial<{ status: number | null; stdout: string; stderr: string }> = {}) { + return { status: 0, stdout: "", stderr: "", ...over } as ReturnType; +} + +describe("createSnpAttester", () => { + it("collects from the attestation agent, binding reportData into the request URL", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ evidence: "QUJD" })); + const attester = createSnpAttester({ measurement: MEASUREMENT, fetchImpl: fetchImpl as unknown as typeof fetch }); + + const collection = await attester.collect(REQUEST); + + expect(attester.kind).toBe("sev-snp"); + expect(collection).toEqual({ teeTechnology: "sev-snp", measurement: MEASUREMENT, attestationReport: "QUJD" }); + expect(fetchImpl.mock.calls[0]?.[0]).toBe(`${DEFAULT_ATTESTATION_AGENT_URL}?runtime_data=${REQUEST.reportData}`); + }); + + it("falls back to the device helper when the agent path fails, and reports both failures if it also fails", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({}, false, 503)); + const spawnImpl = vi.fn().mockReturnValue(spawnResult({ stdout: "REPORT64\n" })); + const attester = createSnpAttester({ + measurement: MEASUREMENT, + reportBin: "/usr/bin/snp-report", + fetchImpl: fetchImpl as unknown as typeof fetch, + spawnImpl: spawnImpl as unknown as typeof import("node:child_process").spawnSync, + }); + + const collection = await attester.collect(REQUEST); + expect(collection.attestationReport).toBe("REPORT64"); + expect(spawnImpl).toHaveBeenCalledWith("/usr/bin/snp-report", [REQUEST.reportData], { encoding: "utf8" }); + }); + + it("throws with every attempted path's reason when no path yields a report", async () => { + const fetchImpl = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); + const spawnImpl = vi.fn().mockReturnValue(spawnResult({ status: 1, stderr: "no /dev/sev-guest\n" })); + const attester = createSnpAttester({ + measurement: MEASUREMENT, + reportBin: "/usr/bin/snp-report", + fetchImpl: fetchImpl as unknown as typeof fetch, + spawnImpl: spawnImpl as unknown as typeof import("node:child_process").spawnSync, + }); + + await expect(attester.collect(REQUEST)).rejects.toThrow( + "agent: ECONNREFUSED; device: report helper exited 1: no /dev/sev-guest", + ); + }); + + it("throws a distinct error when both paths are disabled -- a misconfiguration, not a hardware absence", async () => { + const attester = createSnpAttester({ measurement: MEASUREMENT, agentUrl: null, reportBin: null }); + await expect(attester.collect(REQUEST)).rejects.toThrow("no attestation path configured"); + }); + + it("rejects an agent response that is ok but carries no usable evidence", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ evidence: "" })); + const attester = createSnpAttester({ + measurement: MEASUREMENT, + reportBin: null, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + await expect(attester.collect(REQUEST)).rejects.toThrow("agent: attestation agent returned no evidence"); + }); + + it("rejects a non-string evidence field, and a device helper that exits 0 with no output", async () => { + const badShape = createSnpAttester({ + measurement: MEASUREMENT, + reportBin: null, + fetchImpl: vi.fn().mockResolvedValue(jsonResponse({ evidence: 42 })) as unknown as typeof fetch, + }); + await expect(badShape.collect(REQUEST)).rejects.toThrow("attestation agent returned no evidence"); + + const emptyDevice = createSnpAttester({ + measurement: MEASUREMENT, + agentUrl: null, + reportBin: "/usr/bin/snp-report", + spawnImpl: vi.fn().mockReturnValue(spawnResult({ stdout: " " })) as unknown as typeof import("node:child_process").spawnSync, + }); + await expect(emptyDevice.collect(REQUEST)).rejects.toThrow("device: report helper produced no output"); + }); + + it("names a null exit status and an empty stderr explicitly (both nullish fallbacks)", async () => { + const attester = createSnpAttester({ + measurement: MEASUREMENT, + agentUrl: null, + reportBin: "/usr/bin/snp-report", + spawnImpl: vi + .fn() + .mockReturnValue({ status: null, stdout: undefined, stderr: undefined } as never) as unknown as typeof import("node:child_process").spawnSync, + }); + await expect(attester.collect(REQUEST)).rejects.toThrow("device: report helper exited null: no stderr"); + }); + + it("propagates a non-Error agent throw through the String() fallback arm", async () => { + const attester = createSnpAttester({ + measurement: MEASUREMENT, + reportBin: null, + fetchImpl: vi.fn().mockRejectedValue("socket hang up") as unknown as typeof fetch, + }); + await expect(attester.collect(REQUEST)).rejects.toThrow("agent: socket hang up"); + }); + + it("propagates a non-Error device throw through its String() fallback arm too", async () => { + const attester = createSnpAttester({ + measurement: MEASUREMENT, + agentUrl: null, + reportBin: "/usr/bin/snp-report", + spawnImpl: vi.fn().mockImplementation(() => { + throw "spawn exploded"; + }) as unknown as typeof import("node:child_process").spawnSync, + }); + await expect(attester.collect(REQUEST)).rejects.toThrow("device: spawn exploded"); + }); +});