diff --git a/README.md b/README.md index 55880ef..c91f479 100644 --- a/README.md +++ b/README.md @@ -358,6 +358,8 @@ script. With neither, it reports `no_gate` with exit code 2. An unconfigured gat | `proofloop report latest [--json]` | Summarize the latest gate receipt. | | `proofloop charts latest` | Write local JSON/SVG proof charts under `.proofloop/charts/`. | | `proofloop receipt verify --file ` | Verify app-produced proof receipts such as NodeAgent ingestion receipts. | +| `proofloop receipt envelope verify --file ` | Verify a `proofloop.receipt/v1` envelope, authority semantics, and local content hashes. | +| `proofloop receipt schema [--json]` | Locate or print the packaged `proofloop.receipt/v1` JSON Schema. | | `proofloop solo setup --source --agent both` | Install one canonical Solo skill for Codex and Claude Code and compose one Stop gate. | | `proofloop solo ingest --file --write-runner-plan` | Validate Solo evidence and optionally compile advisory tasks without executing them. | | `proofloop solo status\|resume\|gate` | Inspect or enforce the NodeProof-derived Solo interop state. | @@ -421,6 +423,27 @@ The verifier checks the receipt type/version, `ok: true`, document-pool to memor created document and memory-object counts, proof hashes/keys, zero source/chunk failures, and positive batch/concurrency config. Failed receipts exit 1, while malformed CLI usage exits 2. +### Canonical receipt envelope + +`proofloop.receipt/v1` is the general transport envelope for gate, Solo, hosted, UI-QA, evaluation, +runner, maturity, and app-specific receipts. It preserves each existing payload under a versioned, +content-hashed `payload` field while keeping the verdict authority separate: + +- Only deterministic gates or official scorers may produce an authoritative verdict. +- Model judges, human reviews, and imported pass claims remain advisory. +- Decisive checks must reference locally verifiable, content-hashed evidence. +- Inline payloads use sorted-key canonical JSON SHA-256; referenced files use raw-byte SHA-256. + +```bash +npx proofloop receipt schema +npx proofloop receipt schema --json +npx proofloop receipt envelope verify --file proof/receipt.json +``` + +See [`docs/receipt-envelope-v1.md`](docs/receipt-envelope-v1.md) for the public TypeScript API, +authority rules, and migration mapping for existing schemas. Existing receipt schemas and the +`receipt verify --kind nodeagent-ingestion` command remain supported. + ## Scope This package is the portable core: gate, refuse-fake-done hooks, expected-tool-use contracts, diff --git a/dist/cli.js b/dist/cli.js index 44b2c15..3932e4d 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -37,6 +37,7 @@ const proofloopHooks_1 = require("./proofloopHooks"); const proofloopCi_1 = require("./proofloopCi"); const proofloopToolUse_1 = require("./proofloopToolUse"); const receipts_1 = require("./receipts"); +const proofReceipt_1 = require("./proofReceipt"); const mcp_1 = require("./mcp"); const project_1 = require("./project"); const runner_1 = require("./runner"); @@ -113,6 +114,8 @@ function usage() { " report latest [--json] latest gate report", " charts latest write local JSON/SVG proof charts", " receipt verify --file verify app-produced proof receipts", + " receipt envelope verify --file verify a proofloop.receipt/v1 envelope", + " receipt schema [--json] locate or print the proofloop.receipt/v1 JSON Schema", " solo setup --source [--agent codex|claude-code|both] [--install-deps] [--verify]", " solo ingest|status|gate|resume validate and inspect Solo interop evidence", " solo attest --file --gate-receipt --out --key-id ", @@ -190,7 +193,7 @@ function runCli(argv) { case "charts": return runChartsCommand(positional[1], root); case "receipt": - return runReceiptCommand(positional[1], options, root); + return runReceiptCommand(positional[1], positional[2], options, root); case "solo": return runSoloCommand(positional[1], options, root); case "runner": @@ -743,9 +746,36 @@ function runChartsCommand(sub, root) { console.log(`proofloop charts: wrote ${result.svgPath}`); return 0; } -function runReceiptCommand(sub, options, root) { - if (sub !== "verify") { - console.error("proofloop receipt: expected `verify`."); +function runReceiptCommand(sub, action, options, root) { + if (sub === "schema") { + if (action !== undefined) { + console.error("proofloop receipt schema: unexpected positional argument."); + return 2; + } + if (options.json === true) + console.log(JSON.stringify((0, proofReceipt_1.readProofReceiptSchema)(), null, 2)); + else + console.log((0, proofReceipt_1.proofReceiptSchemaPath)()); + return 0; + } + if (sub === "envelope") { + if (action !== "verify") { + console.error("proofloop receipt envelope: expected `verify`."); + return 2; + } + const filePath = str(options.file); + if (!filePath) { + console.error("proofloop receipt envelope verify: --file is required."); + return 2; + } + return (0, proofReceipt_1.runProofReceiptEnvelopeVerify)({ + root, + filePath, + json: options.json === true, + }); + } + if (sub !== "verify" || action !== undefined) { + console.error("proofloop receipt: expected `verify`, `envelope verify`, or `schema`."); return 2; } const filePath = str(options.file); diff --git a/dist/index.d.ts b/dist/index.d.ts index 3cf35c3..41dd827 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -24,6 +24,7 @@ export * from "./maturity"; export * from "./productivity"; export * from "./contextReport"; export * from "./receipts"; +export * from "./proofReceipt"; export * from "./agentAdapters"; export * from "./agentLoop"; export * from "./codexRelaunch"; diff --git a/dist/index.js b/dist/index.js index f07992a..ce65d2b 100644 --- a/dist/index.js +++ b/dist/index.js @@ -41,6 +41,7 @@ __exportStar(require("./maturity"), exports); __exportStar(require("./productivity"), exports); __exportStar(require("./contextReport"), exports); __exportStar(require("./receipts"), exports); +__exportStar(require("./proofReceipt"), exports); __exportStar(require("./agentAdapters"), exports); __exportStar(require("./agentLoop"), exports); __exportStar(require("./codexRelaunch"), exports); diff --git a/dist/proofReceipt.d.ts b/dist/proofReceipt.d.ts new file mode 100644 index 0000000..246a011 --- /dev/null +++ b/dist/proofReceipt.d.ts @@ -0,0 +1,164 @@ +export declare const PROOFLOOP_RECEIPT_SCHEMA: "proofloop.receipt/v1"; +export declare const PROOFLOOP_RECEIPT_SCHEMA_VERSION: 1; +export declare const PROOFLOOP_RECEIPT_SCHEMA_FILE: "proofloop-receipt-v1.schema.json"; +export type ProofReceiptAuthority = "authoritative" | "advisory" | "informational"; +export type ProofReceiptStatus = "passed" | "failed" | "blocked" | "incomplete" | "error" | "unknown"; +export type ProofReceiptDecisionMethod = "deterministic_gate" | "official_scorer" | "model_judge" | "human_review" | "external_claim" | "none"; +export type ProofReceiptCheckStatus = "passed" | "failed" | "blocked" | "error" | "skipped" | "unknown"; +export type ProofReceiptCheckMethod = "deterministic" | "official_scorer" | "model_judge" | "human_review" | "external"; +export type ProofReceiptHashMethod = "raw-bytes-sha256" | "canonical-json-sha256" | "utf8-sha256"; +export interface ProofReceiptResource { + id: string; + kind: string; + description?: string; + path?: string; + uri?: string; + inline?: unknown; + sha256: string; + hashMethod: ProofReceiptHashMethod; + mediaType?: string; + visibility?: "private" | "team" | "public"; + redacted?: boolean; +} +export interface ProofReceiptCheck { + id: string; + status: ProofReceiptCheckStatus; + role: "decisive" | "advisory"; + method: ProofReceiptCheckMethod; + summary: string; + evidenceRefs: string[]; + durationMs?: number; + exitCode?: number; + score?: number; + threshold?: number; + scorer?: { + name: string; + version: string; + digest?: string; + }; +} +export interface ProofReceiptPayload { + schema: string; + version?: string | number; + mode: "inline" | "reference"; + data?: unknown; + ref?: string; + sha256: string; + hashMethod: "raw-bytes-sha256" | "canonical-json-sha256"; +} +export interface ProofReceiptEnvelope { + $schema?: string; + schema: typeof PROOFLOOP_RECEIPT_SCHEMA; + schemaVersion: typeof PROOFLOOP_RECEIPT_SCHEMA_VERSION; + receiptId: string; + kind: string; + createdAt: string; + producer: { + id: string; + version: string; + runtime?: string; + configHash?: string; + }; + subject: { + type: "repository" | "deployment" | "run" | "workflow" | "artifact" | "evaluation" | "application"; + id: string; + runId?: string; + artifactId?: string; + targetUrl?: string; + repository?: { + url?: string; + baseCommit?: string; + candidateCommit?: string; + branch?: string; + dirty?: boolean; + }; + }; + claim?: { + text: string; + boundary: "product_path" | "proxy" | "official" | "internal"; + tier?: "local_ready" | "team_ready" | "certification_ready"; + }; + verdict: { + status: ProofReceiptStatus; + authority: ProofReceiptAuthority; + decisionMethod: ProofReceiptDecisionMethod; + decisiveCheckIds: string[]; + summary: string; + }; + checks: ProofReceiptCheck[]; + evidence: ProofReceiptResource[]; + artifacts?: ProofReceiptResource[]; + payload: ProofReceiptPayload; + lineage?: { + parentReceiptIds?: string[]; + sourceReceiptIds?: string[]; + migration?: string; + }; + timing?: { + startedAt?: string; + completedAt?: string; + durationMs?: number; + phases?: Array<{ + id: string; + startedAt?: string; + completedAt?: string; + durationMs: number; + }>; + }; + budget?: { + maxUsd?: number; + spentUsd?: number; + maxRuntimeMs?: number; + maxModelCalls?: number; + modelCalls?: number; + }; + privacy?: { + visibility: "private" | "team" | "public"; + redacted: boolean; + containsPersonalData?: boolean; + externalEgress?: boolean; + }; + extensions?: Record; +} +export interface ProofReceiptIssue { + path: string; + code: string; + message: string; +} +export interface ProofReceiptValidation { + ok: boolean; + errors: ProofReceiptIssue[]; + warnings: ProofReceiptIssue[]; + envelope?: ProofReceiptEnvelope; +} +export interface ProofReceiptFileVerification extends ProofReceiptValidation { + receiptPath: string; +} +export declare function proofReceiptSchemaPath(): string; +export declare function readProofReceiptSchema(): unknown; +export declare function canonicalJson(value: unknown): string; +export declare function sha256Utf8(value: string): string; +export declare function sha256CanonicalJson(value: unknown): string; +export declare function createInlineProofReceiptPayload(schema: string, data: unknown, version?: string | number): ProofReceiptPayload; +export declare function createInlineProofReceiptResource(options: { + id: string; + kind: string; + inline: unknown; + description?: string; + mediaType?: string; + visibility?: "private" | "team" | "public"; + redacted?: boolean; +}): ProofReceiptResource; +export declare function validateProofReceiptEnvelope(value: unknown): ProofReceiptValidation; +export declare function verifyProofReceiptEnvelopeFile(options: { + root: string; + filePath: string; +}): ProofReceiptFileVerification; +export declare function formatProofReceiptVerification(result: ProofReceiptFileVerification): string; +export declare function runProofReceiptEnvelopeVerify(options: { + root: string; + filePath: string; + json?: boolean; + log?: (message: string) => void; + logError?: (message: string) => void; +}): number; diff --git a/dist/proofReceipt.js b/dist/proofReceipt.js new file mode 100644 index 0000000..31dbf5e --- /dev/null +++ b/dist/proofReceipt.js @@ -0,0 +1,576 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.PROOFLOOP_RECEIPT_SCHEMA_FILE = exports.PROOFLOOP_RECEIPT_SCHEMA_VERSION = exports.PROOFLOOP_RECEIPT_SCHEMA = void 0; +exports.proofReceiptSchemaPath = proofReceiptSchemaPath; +exports.readProofReceiptSchema = readProofReceiptSchema; +exports.canonicalJson = canonicalJson; +exports.sha256Utf8 = sha256Utf8; +exports.sha256CanonicalJson = sha256CanonicalJson; +exports.createInlineProofReceiptPayload = createInlineProofReceiptPayload; +exports.createInlineProofReceiptResource = createInlineProofReceiptResource; +exports.validateProofReceiptEnvelope = validateProofReceiptEnvelope; +exports.verifyProofReceiptEnvelopeFile = verifyProofReceiptEnvelopeFile; +exports.formatProofReceiptVerification = formatProofReceiptVerification; +exports.runProofReceiptEnvelopeVerify = runProofReceiptEnvelopeVerify; +const node_crypto_1 = require("node:crypto"); +const node_fs_1 = require("node:fs"); +const node_path_1 = require("node:path"); +exports.PROOFLOOP_RECEIPT_SCHEMA = "proofloop.receipt/v1"; +exports.PROOFLOOP_RECEIPT_SCHEMA_VERSION = 1; +exports.PROOFLOOP_RECEIPT_SCHEMA_FILE = "proofloop-receipt-v1.schema.json"; +const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/; +const KIND_PATTERN = /^[a-z][a-z0-9._/-]{0,127}$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const GIT_SHA_PATTERN = /^[a-f0-9]{40,64}$/; +const AUTHORITATIVE_METHODS = new Set(["deterministic_gate", "official_scorer"]); +const DECISIVE_CHECK_METHODS = new Set(["deterministic", "official_scorer"]); +const RECEIPT_KEYS = new Set([ + "$schema", + "schema", + "schemaVersion", + "receiptId", + "kind", + "createdAt", + "producer", + "subject", + "claim", + "verdict", + "checks", + "evidence", + "artifacts", + "payload", + "lineage", + "timing", + "budget", + "privacy", + "extensions", +]); +function proofReceiptSchemaPath() { + return (0, node_path_1.resolve)(__dirname, "..", "schemas", exports.PROOFLOOP_RECEIPT_SCHEMA_FILE); +} +function readProofReceiptSchema() { + return JSON.parse((0, node_fs_1.readFileSync)(proofReceiptSchemaPath(), "utf8")); +} +function canonicalJson(value) { + if (value === null) + return "null"; + if (typeof value === "string" || typeof value === "boolean") + return JSON.stringify(value); + if (typeof value === "number") { + if (!Number.isFinite(value)) + throw new Error("canonical JSON does not support non-finite numbers"); + return JSON.stringify(value); + } + if (Array.isArray(value)) + return `[${value.map((entry) => canonicalJson(entry)).join(",")}]`; + if (isRecord(value)) { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`) + .join(",")}}`; + } + throw new Error(`canonical JSON does not support ${typeof value}`); +} +function sha256Utf8(value) { + return (0, node_crypto_1.createHash)("sha256").update(value, "utf8").digest("hex"); +} +function sha256CanonicalJson(value) { + return sha256Utf8(canonicalJson(value)); +} +function createInlineProofReceiptPayload(schema, data, version) { + return { + schema, + ...(version !== undefined ? { version } : {}), + mode: "inline", + data, + sha256: sha256CanonicalJson(data), + hashMethod: "canonical-json-sha256", + }; +} +function createInlineProofReceiptResource(options) { + return { + id: options.id, + kind: options.kind, + ...(options.description !== undefined ? { description: options.description } : {}), + inline: options.inline, + sha256: sha256CanonicalJson(options.inline), + hashMethod: "canonical-json-sha256", + ...(options.mediaType !== undefined ? { mediaType: options.mediaType } : {}), + ...(options.visibility !== undefined ? { visibility: options.visibility } : {}), + ...(options.redacted !== undefined ? { redacted: options.redacted } : {}), + }; +} +function validateProofReceiptEnvelope(value) { + const errors = []; + const warnings = []; + const receipt = asRecord(value, "$", errors); + if (!receipt) + return { ok: false, errors, warnings }; + for (const key of Object.keys(receipt)) { + if (!RECEIPT_KEYS.has(key)) + issue(errors, `$.${key}`, "unknown_property", "unknown top-level property"); + } + expectLiteral(receipt.schema, exports.PROOFLOOP_RECEIPT_SCHEMA, "$.schema", errors); + expectLiteral(receipt.schemaVersion, exports.PROOFLOOP_RECEIPT_SCHEMA_VERSION, "$.schemaVersion", errors); + expectPattern(receipt.receiptId, ID_PATTERN, "$.receiptId", errors); + expectPattern(receipt.kind, KIND_PATTERN, "$.kind", errors); + expectDateTime(receipt.createdAt, "$.createdAt", errors); + const producer = asRecord(receipt.producer, "$.producer", errors); + if (producer) { + expectPattern(producer.id, ID_PATTERN, "$.producer.id", errors); + expectNonEmptyString(producer.version, "$.producer.version", errors); + if (producer.configHash !== undefined) + expectPattern(producer.configHash, SHA256_PATTERN, "$.producer.configHash", errors); + } + const subject = asRecord(receipt.subject, "$.subject", errors); + if (subject) { + expectEnum(subject.type, ["repository", "deployment", "run", "workflow", "artifact", "evaluation", "application"], "$.subject.type", errors); + expectPattern(subject.id, ID_PATTERN, "$.subject.id", errors); + if (subject.runId !== undefined) + expectPattern(subject.runId, ID_PATTERN, "$.subject.runId", errors); + if (subject.artifactId !== undefined) + expectPattern(subject.artifactId, ID_PATTERN, "$.subject.artifactId", errors); + if (subject.targetUrl !== undefined) + expectUri(subject.targetUrl, "$.subject.targetUrl", errors); + const repository = subject.repository === undefined ? undefined : asRecord(subject.repository, "$.subject.repository", errors); + if (repository) { + if (repository.baseCommit !== undefined) + expectPattern(repository.baseCommit, GIT_SHA_PATTERN, "$.subject.repository.baseCommit", errors); + if (repository.candidateCommit !== undefined) + expectPattern(repository.candidateCommit, GIT_SHA_PATTERN, "$.subject.repository.candidateCommit", errors); + } + } + const verdict = asRecord(receipt.verdict, "$.verdict", errors); + const status = verdict ? expectEnum(verdict.status, ["passed", "failed", "blocked", "incomplete", "error", "unknown"], "$.verdict.status", errors) : undefined; + const authority = verdict ? expectEnum(verdict.authority, ["authoritative", "advisory", "informational"], "$.verdict.authority", errors) : undefined; + const decisionMethod = verdict ? expectEnum(verdict.decisionMethod, ["deterministic_gate", "official_scorer", "model_judge", "human_review", "external_claim", "none"], "$.verdict.decisionMethod", errors) : undefined; + if (verdict) + expectNonEmptyString(verdict.summary, "$.verdict.summary", errors); + const decisiveCheckIds = verdict ? stringArray(verdict.decisiveCheckIds, "$.verdict.decisiveCheckIds", errors, true) : []; + const checkValues = arrayValue(receipt.checks, "$.checks", errors); + const checks = []; + const checkIds = new Set(); + for (let index = 0; index < checkValues.length; index += 1) { + const check = validateCheck(checkValues[index], index, errors); + if (!check) + continue; + if (checkIds.has(check.id)) + issue(errors, `$.checks[${index}].id`, "duplicate_id", `duplicate check id ${check.id}`); + checkIds.add(check.id); + checks.push(check); + } + const evidenceValues = arrayValue(receipt.evidence, "$.evidence", errors); + const evidence = []; + const evidenceIds = new Set(); + for (let index = 0; index < evidenceValues.length; index += 1) { + const resource = validateResource(evidenceValues[index], `$.evidence[${index}]`, errors); + if (!resource) + continue; + if (evidenceIds.has(resource.id)) + issue(errors, `$.evidence[${index}].id`, "duplicate_id", `duplicate evidence id ${resource.id}`); + evidenceIds.add(resource.id); + evidence.push(resource); + } + const artifactValues = receipt.artifacts === undefined ? [] : arrayValue(receipt.artifacts, "$.artifacts", errors); + const artifacts = []; + const artifactIds = new Set(); + for (let index = 0; index < artifactValues.length; index += 1) { + const resource = validateResource(artifactValues[index], `$.artifacts[${index}]`, errors); + if (!resource) + continue; + if (artifactIds.has(resource.id)) + issue(errors, `$.artifacts[${index}].id`, "duplicate_id", `duplicate artifact id ${resource.id}`); + artifactIds.add(resource.id); + artifacts.push(resource); + } + for (const check of checks) { + for (const evidenceRef of check.evidenceRefs) { + if (!evidenceIds.has(evidenceRef)) + issue(errors, `$.checks.${check.id}.evidenceRefs`, "missing_evidence", `unknown evidence ref ${evidenceRef}`); + } + } + validatePayload(receipt.payload, errors); + if (authority === "authoritative") { + if (!decisionMethod || !AUTHORITATIVE_METHODS.has(decisionMethod)) { + issue(errors, "$.verdict.decisionMethod", "authority_violation", "authoritative verdicts require a deterministic gate or official scorer"); + } + if (!status || status === "incomplete" || status === "unknown") { + issue(errors, "$.verdict.status", "authority_violation", "authoritative verdicts cannot be incomplete or unknown"); + } + if (decisiveCheckIds.length === 0) + issue(errors, "$.verdict.decisiveCheckIds", "missing_decisive_check", "authoritative verdicts require at least one decisive check"); + const decisiveIds = new Set(decisiveCheckIds); + const decisiveChecks = checks.filter((check) => decisiveIds.has(check.id)); + for (const id of decisiveCheckIds) { + if (!checkIds.has(id)) + issue(errors, "$.verdict.decisiveCheckIds", "missing_check", `unknown decisive check ${id}`); + } + for (const check of checks.filter((entry) => entry.role === "decisive")) { + if (!decisiveIds.has(check.id)) + issue(errors, `$.checks.${check.id}.role`, "unlisted_decisive_check", "decisive checks must be listed in verdict.decisiveCheckIds"); + } + for (const check of decisiveChecks) { + if (check.role !== "decisive") + issue(errors, `$.checks.${check.id}.role`, "authority_violation", "a decisiveCheckId must reference a decisive check"); + if (!DECISIVE_CHECK_METHODS.has(check.method)) + issue(errors, `$.checks.${check.id}.method`, "authority_violation", "model, human, and external checks cannot decide an authoritative verdict"); + if (check.evidenceRefs.length === 0) + issue(errors, `$.checks.${check.id}.evidenceRefs`, "missing_evidence", "decisive checks require locally verifiable evidence"); + for (const ref of check.evidenceRefs) { + const resource = evidence.find((entry) => entry.id === ref); + if (resource?.uri !== undefined) + issue(errors, `$.evidence.${ref}.uri`, "unverifiable_decisive_evidence", "URI-only evidence cannot decide an authoritative local verification"); + } + } + if (decisionMethod === "deterministic_gate" && decisiveChecks.some((check) => check.method !== "deterministic")) { + issue(errors, "$.verdict.decisionMethod", "method_mismatch", "deterministic_gate verdicts require every decisive check to be deterministic"); + } + if (decisionMethod === "official_scorer" && !decisiveChecks.some((check) => check.method === "official_scorer")) { + issue(errors, "$.verdict.decisionMethod", "method_mismatch", "official_scorer verdicts require an official scorer decisive check"); + } + if (status === "passed" && decisiveChecks.some((check) => check.status !== "passed")) { + issue(errors, "$.verdict.status", "verdict_mismatch", "an authoritative pass requires every decisive check to pass"); + } + if ((status === "failed" || status === "blocked" || status === "error") && !decisiveChecks.some((check) => check.status === status)) { + issue(errors, "$.verdict.status", "verdict_mismatch", `an authoritative ${status} verdict requires a decisive ${status} check`); + } + } + else if (authority !== undefined) { + if (decisiveCheckIds.length > 0) + issue(errors, "$.verdict.decisiveCheckIds", "authority_violation", "non-authoritative receipts cannot declare decisive checks"); + for (const check of checks) { + if (check.role === "decisive") + issue(errors, `$.checks.${check.id}.role`, "authority_violation", "non-authoritative receipts may contain advisory checks only"); + } + } + if (authority === "informational") { + if (status !== "incomplete" && status !== "unknown") + issue(errors, "$.verdict.status", "informational_verdict", "informational receipts must be incomplete or unknown"); + if (decisionMethod !== "none") + issue(errors, "$.verdict.decisionMethod", "informational_verdict", "informational receipts use decisionMethod none"); + } + const envelope = errors.length === 0 ? value : undefined; + return { ok: errors.length === 0, errors, warnings, ...(envelope ? { envelope } : {}) }; +} +function verifyProofReceiptEnvelopeFile(options) { + const receiptPath = (0, node_path_1.isAbsolute)(options.filePath) ? options.filePath : (0, node_path_1.resolve)(options.root, options.filePath); + if (!(0, node_fs_1.existsSync)(receiptPath)) { + return { + ok: false, + receiptPath, + errors: [{ path: "$", code: "receipt_missing", message: "receipt file does not exist" }], + warnings: [], + }; + } + let parsed; + try { + parsed = JSON.parse((0, node_fs_1.readFileSync)(receiptPath, "utf8")); + } + catch (error) { + return { + ok: false, + receiptPath, + errors: [{ path: "$", code: "receipt_json", message: error instanceof Error ? error.message : String(error) }], + warnings: [], + }; + } + const validation = validateProofReceiptEnvelope(parsed); + const errors = [...validation.errors]; + const warnings = [...validation.warnings]; + const envelope = validation.envelope; + if (envelope) { + const baseDir = (0, node_path_1.dirname)(receiptPath); + verifyPayloadIntegrity(envelope.payload, baseDir, errors); + for (const resource of [...envelope.evidence, ...(envelope.artifacts ?? [])]) { + verifyResourceIntegrity(resource, baseDir, errors); + } + } + return { + ok: errors.length === 0, + receiptPath, + errors, + warnings, + ...(envelope ? { envelope } : {}), + }; +} +function formatProofReceiptVerification(result) { + const lines = [ + `schema=${exports.PROOFLOOP_RECEIPT_SCHEMA}`, + `path=${result.receiptPath}`, + `status=${result.ok ? "passed" : "failed"}`, + ]; + if (result.envelope) { + lines.push(`receiptId=${result.envelope.receiptId}`); + lines.push(`kind=${result.envelope.kind}`); + lines.push(`authority=${result.envelope.verdict.authority}`); + lines.push(`verdict=${result.envelope.verdict.status}`); + } + lines.push("checks:"); + if (result.errors.length === 0) + lines.push("- PASS envelope and local integrity checks"); + for (const error of result.errors) + lines.push(`- FAIL ${error.path} ${error.code}: ${error.message}`); + for (const warning of result.warnings) + lines.push(`- WARN ${warning.path} ${warning.code}: ${warning.message}`); + return `${lines.join("\n")}\n`; +} +function runProofReceiptEnvelopeVerify(options) { + const result = verifyProofReceiptEnvelopeFile(options); + const log = options.log ?? console.log; + const logError = options.logError ?? console.error; + const output = options.json === true ? JSON.stringify(result, null, 2) : formatProofReceiptVerification(result); + if (result.ok) + log(output); + else + logError(output); + return result.ok ? 0 : 1; +} +function validateCheck(value, index, errors) { + const path = `$.checks[${index}]`; + const check = asRecord(value, path, errors); + if (!check) + return undefined; + const id = expectPattern(check.id, ID_PATTERN, `${path}.id`, errors); + const status = expectEnum(check.status, ["passed", "failed", "blocked", "error", "skipped", "unknown"], `${path}.status`, errors); + const role = expectEnum(check.role, ["decisive", "advisory"], `${path}.role`, errors); + const method = expectEnum(check.method, ["deterministic", "official_scorer", "model_judge", "human_review", "external"], `${path}.method`, errors); + const summary = expectNonEmptyString(check.summary, `${path}.summary`, errors); + const evidenceRefs = stringArray(check.evidenceRefs, `${path}.evidenceRefs`, errors, true); + if (check.durationMs !== undefined) + expectNonNegativeInteger(check.durationMs, `${path}.durationMs`, errors); + if (check.exitCode !== undefined && !Number.isInteger(check.exitCode)) + issue(errors, `${path}.exitCode`, "type", "expected an integer"); + if (check.score !== undefined && (typeof check.score !== "number" || !Number.isFinite(check.score))) + issue(errors, `${path}.score`, "type", "expected a finite number"); + if (check.threshold !== undefined && (typeof check.threshold !== "number" || !Number.isFinite(check.threshold))) + issue(errors, `${path}.threshold`, "type", "expected a finite number"); + const scorer = check.scorer === undefined ? undefined : asRecord(check.scorer, `${path}.scorer`, errors); + if (scorer) { + expectNonEmptyString(scorer.name, `${path}.scorer.name`, errors); + expectNonEmptyString(scorer.version, `${path}.scorer.version`, errors); + if (scorer.digest !== undefined) + expectPattern(scorer.digest, SHA256_PATTERN, `${path}.scorer.digest`, errors); + } + if (role === "decisive" && method && !DECISIVE_CHECK_METHODS.has(method)) + issue(errors, `${path}.method`, "authority_violation", "decisive checks must be deterministic or official scorers"); + if (role === "decisive" && evidenceRefs.length === 0) + issue(errors, `${path}.evidenceRefs`, "missing_evidence", "decisive checks require evidence"); + if (method === "official_scorer") { + if (!scorer) + issue(errors, `${path}.scorer`, "missing_scorer", "official scorer checks require scorer identity"); + else if (scorer.digest === undefined) + issue(errors, `${path}.scorer.digest`, "missing_scorer_digest", "official scorer checks require an immutable scorer digest"); + } + if (!id || !status || !role || !method || !summary) + return undefined; + return { + id, + status, + role, + method, + summary, + evidenceRefs, + ...(typeof check.durationMs === "number" ? { durationMs: check.durationMs } : {}), + ...(typeof check.exitCode === "number" ? { exitCode: check.exitCode } : {}), + ...(typeof check.score === "number" ? { score: check.score } : {}), + ...(typeof check.threshold === "number" ? { threshold: check.threshold } : {}), + ...(scorer ? { scorer: check.scorer } : {}), + }; +} +function validateResource(value, path, errors) { + const resource = asRecord(value, path, errors); + if (!resource) + return undefined; + const id = expectPattern(resource.id, ID_PATTERN, `${path}.id`, errors); + const kind = expectPattern(resource.kind, KIND_PATTERN, `${path}.kind`, errors); + const sha256 = expectPattern(resource.sha256, SHA256_PATTERN, `${path}.sha256`, errors); + const hashMethod = expectEnum(resource.hashMethod, ["raw-bytes-sha256", "canonical-json-sha256", "utf8-sha256"], `${path}.hashMethod`, errors); + const locators = [resource.path !== undefined, resource.uri !== undefined, Object.prototype.hasOwnProperty.call(resource, "inline")].filter(Boolean).length; + if (locators !== 1) + issue(errors, path, "resource_locator", "exactly one of path, uri, or inline is required"); + if (resource.path !== undefined && !safeRelativePath(resource.path)) + issue(errors, `${path}.path`, "relative_path", "expected a safe relative path without parent traversal"); + if (resource.uri !== undefined) + expectUri(resource.uri, `${path}.uri`, errors); + if (resource.path !== undefined || resource.uri !== undefined) { + if (hashMethod && hashMethod !== "raw-bytes-sha256") + issue(errors, `${path}.hashMethod`, "hash_method", "path and URI resources use raw-bytes-sha256"); + } + if (Object.prototype.hasOwnProperty.call(resource, "inline")) { + if (hashMethod === "canonical-json-sha256") { + try { + if (sha256 && sha256CanonicalJson(resource.inline) !== sha256) + issue(errors, `${path}.sha256`, "hash_mismatch", "inline canonical JSON hash does not match"); + } + catch (error) { + issue(errors, `${path}.inline`, "canonical_json", error instanceof Error ? error.message : String(error)); + } + } + else if (hashMethod === "utf8-sha256") { + if (typeof resource.inline !== "string") + issue(errors, `${path}.inline`, "type", "utf8-sha256 requires an inline string"); + else if (sha256 && sha256Utf8(resource.inline) !== sha256) + issue(errors, `${path}.sha256`, "hash_mismatch", "inline UTF-8 hash does not match"); + } + else if (hashMethod !== undefined) { + issue(errors, `${path}.hashMethod`, "hash_method", "inline resources use canonical-json-sha256 or utf8-sha256"); + } + } + if (!id || !kind || !sha256 || !hashMethod) + return undefined; + return value; +} +function validatePayload(value, errors) { + const payload = asRecord(value, "$.payload", errors); + if (!payload) + return undefined; + const schema = expectNonEmptyString(payload.schema, "$.payload.schema", errors); + const mode = expectEnum(payload.mode, ["inline", "reference"], "$.payload.mode", errors); + const sha256 = expectPattern(payload.sha256, SHA256_PATTERN, "$.payload.sha256", errors); + const hashMethod = expectEnum(payload.hashMethod, ["raw-bytes-sha256", "canonical-json-sha256"], "$.payload.hashMethod", errors); + const hasData = Object.prototype.hasOwnProperty.call(payload, "data"); + const hasRef = payload.ref !== undefined; + if (mode === "inline") { + if (!hasData || hasRef) + issue(errors, "$.payload", "payload_mode", "inline payload requires data and forbids ref"); + if (hashMethod !== "canonical-json-sha256") + issue(errors, "$.payload.hashMethod", "hash_method", "inline payloads use canonical-json-sha256"); + if (hasData && sha256) { + try { + if (sha256CanonicalJson(payload.data) !== sha256) + issue(errors, "$.payload.sha256", "hash_mismatch", "inline payload canonical JSON hash does not match"); + } + catch (error) { + issue(errors, "$.payload.data", "canonical_json", error instanceof Error ? error.message : String(error)); + } + } + } + else if (mode === "reference") { + if (!hasRef || hasData) + issue(errors, "$.payload", "payload_mode", "reference payload requires ref and forbids data"); + if (!safeRelativePath(payload.ref)) + issue(errors, "$.payload.ref", "relative_path", "expected a safe relative path without parent traversal"); + if (hashMethod !== "raw-bytes-sha256") + issue(errors, "$.payload.hashMethod", "hash_method", "reference payloads use raw-bytes-sha256"); + } + if (!schema || !mode || !sha256 || !hashMethod) + return undefined; + return value; +} +function verifyPayloadIntegrity(payload, baseDir, errors) { + if (payload.mode === "inline") + return; + if (!payload.ref || !safeRelativePath(payload.ref)) + return; + verifyRelativeFileHash(payload.ref, payload.sha256, baseDir, "$.payload.ref", errors); +} +function verifyResourceIntegrity(resource, baseDir, errors) { + if (!resource.path || !safeRelativePath(resource.path)) + return; + verifyRelativeFileHash(resource.path, resource.sha256, baseDir, `$.resources.${resource.id}.path`, errors); +} +function verifyRelativeFileHash(path, expectedHash, baseDir, issuePath, errors) { + const absolutePath = (0, node_path_1.resolve)(baseDir, path); + const escaped = (0, node_path_1.relative)(baseDir, absolutePath); + if (escaped === ".." || escaped.startsWith(`..${node_path_1.sep}`) || (0, node_path_1.isAbsolute)(escaped)) { + issue(errors, issuePath, "path_escape", "referenced file escapes the receipt directory"); + return; + } + if (!(0, node_fs_1.existsSync)(absolutePath)) { + issue(errors, issuePath, "referenced_file_missing", `referenced file does not exist: ${path}`); + return; + } + try { + const actual = (0, node_crypto_1.createHash)("sha256").update((0, node_fs_1.readFileSync)(absolutePath)).digest("hex"); + if (actual !== expectedHash) + issue(errors, issuePath, "hash_mismatch", `expected ${expectedHash}, received ${actual}`); + } + catch (error) { + issue(errors, issuePath, "referenced_file_unreadable", error instanceof Error ? error.message : String(error)); + } +} +function asRecord(value, path, errors) { + if (!isRecord(value)) { + issue(errors, path, "type", "expected an object"); + return undefined; + } + return value; +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function arrayValue(value, path, errors) { + if (!Array.isArray(value)) { + issue(errors, path, "type", "expected an array"); + return []; + } + return value; +} +function stringArray(value, path, errors, unique) { + const values = arrayValue(value, path, errors); + const strings = []; + for (let index = 0; index < values.length; index += 1) { + const item = expectPattern(values[index], ID_PATTERN, `${path}[${index}]`, errors); + if (item) + strings.push(item); + } + if (unique && new Set(strings).size !== strings.length) + issue(errors, path, "unique", "expected unique values"); + return strings; +} +function expectLiteral(value, expected, path, errors) { + if (value !== expected) { + issue(errors, path, "const", `expected ${String(expected)}`); + return undefined; + } + return expected; +} +function expectPattern(value, pattern, path, errors) { + if (typeof value !== "string" || !pattern.test(value)) { + issue(errors, path, "pattern", `expected string matching ${pattern.source}`); + return undefined; + } + return value; +} +function expectNonEmptyString(value, path, errors) { + if (typeof value !== "string" || value.length === 0) { + issue(errors, path, "type", "expected a non-empty string"); + return undefined; + } + return value; +} +function expectEnum(value, allowed, path, errors) { + if (typeof value !== "string" || !allowed.includes(value)) { + issue(errors, path, "enum", `expected one of ${allowed.join(", ")}`); + return undefined; + } + return value; +} +function expectDateTime(value, path, errors) { + if (typeof value !== "string" || Number.isNaN(Date.parse(value))) + issue(errors, path, "date_time", "expected an ISO-like date-time string"); +} +function expectUri(value, path, errors) { + if (typeof value !== "string") { + issue(errors, path, "uri", "expected a URI string"); + return; + } + try { + new URL(value); + } + catch { + issue(errors, path, "uri", "expected a valid URI"); + } +} +function expectNonNegativeInteger(value, path, errors) { + if (typeof value !== "number" || !Number.isInteger(value) || value < 0) + issue(errors, path, "type", "expected a non-negative integer"); +} +function safeRelativePath(value) { + if (typeof value !== "string" || value.length === 0 || (0, node_path_1.isAbsolute)(value) || /^[A-Za-z]:/.test(value)) + return false; + return !value.split(/[\\/]/).includes(".."); +} +function issue(target, path, code, message) { + target.push({ path, code, message }); +} diff --git a/docs/interoperability.md b/docs/interoperability.md index a976b28..0180683 100644 --- a/docs/interoperability.md +++ b/docs/interoperability.md @@ -2,6 +2,11 @@ Proof Loop stays the certification source of truth: deterministic gate receipts, tool-use logs, and runner receipts decide pass/fail. External orchestration and observability systems can mirror or launch work, but they do not replace Proof Loop receipts. +Cross-system evidence should use the [`proofloop.receipt/v1` envelope](receipt-envelope-v1.md). +Legacy schemas remain valid payloads inside the envelope; wrapping one never promotes its original +pass claim. Only a top-level deterministic gate or official scorer can issue an authoritative +verdict. + ## Solo Founder Agent Builder Solo Founder supplies the RALPH methodology and durable .solo/ work journal. NodeProof imports its evidence through the versioned proofloop-solo-interop-v1 envelope and derives the authoritative gate without accepting Solo's pass claim. diff --git a/docs/receipt-envelope-v1.md b/docs/receipt-envelope-v1.md new file mode 100644 index 0000000..467ac0b --- /dev/null +++ b/docs/receipt-envelope-v1.md @@ -0,0 +1,152 @@ +# `proofloop.receipt/v1` + +`proofloop.receipt/v1` is the canonical transport envelope for ProofLoop evidence. It wraps existing +gate, Solo, hosted, UI-QA, evaluation, runner, maturity, and app-specific receipts without changing +or deleting their schemas. + +The envelope separates three things that older receipts often mixed together: + +1. The original payload and its content hash. +2. Checks and evidence observed while verifying that payload. +3. The top-level verdict and exactly who is allowed to decide it. + +The JSON Schema ships at `schemas/proofloop-receipt-v1.schema.json`. The public TypeScript API is +exported from `proofloop` through `src/proofReceipt.ts`. + +## Authority invariant + +Wrapped payloads never transfer verdict authority implicitly. + +An authoritative envelope must satisfy all of these rules: + +- `verdict.decisionMethod` is `deterministic_gate` or `official_scorer`. +- `verdict.decisiveCheckIds` names at least one check. +- Every named check has `role: decisive`. +- Every decisive check uses `method: deterministic` or `official_scorer`. +- Every official-scorer check identifies the scorer by name, version, and immutable SHA-256 digest. +- Every decisive check names locally verifiable, content-hashed evidence. +- An authoritative `passed` verdict has no non-passing decisive check. +- An authoritative `failed`, `blocked`, or `error` verdict has a decisive check with the same state. + +Model judges, human reviews, and external claims remain useful evidence, but they are advisory. If a +human approval or signed upstream receipt is required for certification, a deterministic verifier +checks that approval or signature and records its own decisive result. + +The CLI fails closed on missing files, path traversal, payload or evidence hash mismatch, missing +decisive checks, and authority violations. + +## Commands + +```bash +# Locate the installed schema. +npx proofloop receipt schema + +# Print the schema JSON. +npx proofloop receipt schema --json + +# Verify structure, authority semantics, inline hashes, and referenced local bytes. +npx proofloop receipt envelope verify --file proof/receipt.json +npx proofloop receipt envelope verify --file proof/receipt.json --json +``` + +The existing app-specific command remains unchanged: + +```bash +npx proofloop receipt verify \ + --file docs/eval/nodeagent-ingestion-orchestrator.json \ + --kind nodeagent-ingestion +``` + +That verifier may become a decisive check in a new envelope; the app-specific payload does not need +to be rewritten. + +## Public API + +```ts +import { + createInlineProofReceiptPayload, + createInlineProofReceiptResource, + validateProofReceiptEnvelope, + verifyProofReceiptEnvelopeFile, + type ProofReceiptEnvelope, +} from "proofloop"; + +const legacyGate = { + schema: "proofloop-gate-v1", + status: "passed", + checks: [{ name: "tests", pass: true, exitCode: 0 }], +}; + +const commandEvidence = createInlineProofReceiptResource({ + id: "tests-output", + kind: "command-result", + inline: { command: "npm test", exitCode: 0 }, +}); + +const receipt: ProofReceiptEnvelope = { + schema: "proofloop.receipt/v1", + schemaVersion: 1, + receiptId: "receipt-tests-pass", + kind: "gate", + createdAt: new Date().toISOString(), + producer: { id: "proofloop", version: "0.3.0" }, + subject: { type: "repository", id: "my-repository" }, + verdict: { + status: "passed", + authority: "authoritative", + decisionMethod: "deterministic_gate", + decisiveCheckIds: ["tests"], + summary: "The configured test command exited successfully.", + }, + checks: [{ + id: "tests", + status: "passed", + role: "decisive", + method: "deterministic", + summary: "npm test exited 0.", + evidenceRefs: [commandEvidence.id], + exitCode: 0, + }], + evidence: [commandEvidence], + payload: createInlineProofReceiptPayload("proofloop-gate-v1", legacyGate, 1), +}; + +const result = validateProofReceiptEnvelope(receipt); +``` + +Inline JSON uses sorted-key canonical JSON before SHA-256 hashing. Referenced payloads and local +evidence use raw-byte SHA-256 and paths relative to the receipt file. This avoids ambiguous hashes +caused by whitespace or platform-specific absolute paths. + +## Migration mapping + +| Existing payload | Envelope kind | Initial authority | Decision method | Mapping rule | +|---|---|---|---|---| +| `proofloop-gate-v1` | `gate` | `authoritative` | `deterministic_gate` | Map configured command exit codes to decisive checks and hash their output or gate state. | +| `proofloop-solo-interop-v1` raw export | `solo-interop` | `advisory` | `external_claim` | Preserve `sourceVerdict.authority: advisory`; use no decisive checks. | +| NodeProof-derived Solo gate | `solo-gate` | `authoritative` | `deterministic_gate` | Wrap the NodeProof gate result, not the imported Solo pass claim. | +| `proofloop-hosted-run-v1`, bundle, or worker plan | `hosted-run-plan` | `informational` | `none` | A request, permission packet, queue item, or worker plan is not a completed proof run. | +| Hosted live worker receipt | `hosted-run` | `authoritative` only after verification | `deterministic_gate` | Use the success-contract checks and locally hashed screenshot, trace, scorecard, and output evidence. | +| `agentic-ui-qa-gate-v1` | `ui-qa` | `authoritative` for boolean gates | `deterministic_gate` | Only live-signal, open-P0, regression, and configured floor checks are decisive. Vision/model critique stays advisory. | +| BetterPR QA packet | `ui-handoff` | `informational` | `none` | The packet presents screenshots, video, and review links; reference a separate authoritative receipt. | +| Deterministic app eval | `evaluation` | `authoritative` | `deterministic_gate` | Map deterministic rubric checks to decisive checks. | +| Official upstream scorer | `evaluation` | `authoritative` | `official_scorer` | Record scorer name, version, digest, score, threshold, and immutable scorer output. | +| LLM-as-judge output | `evaluation` | `advisory` | `model_judge` | It may explain or prioritize findings but cannot decide an authoritative pass. | +| NodeAgent ingestion receipt | `app-receipt` | `authoritative` after verifier | `deterministic_gate` | Run the existing `nodeagent-ingestion` verifier and record that verifier result as the decisive check. | + +## Adoption rule + +Preserve every existing schema while consumers migrate. Emit the old payload exactly as before, +then either embed it under `payload.data` or reference the original file under `payload.ref`. + +Consumers should migrate in this order: + +1. Read both legacy payloads and `proofloop.receipt/v1`. +2. Emit the envelope alongside the existing receipt. +3. Add cross-repository conformance fixtures. +4. Switch transport and dashboards to the envelope. +5. Retire a legacy transport only after all consumers are proven compatible. + +The envelope is deliberately not a universal domain schema. Domain-specific data remains in the +versioned payload. ProofLoop owns transport integrity and verdict authority; domain tools and +official scorers retain ownership of their semantics. diff --git a/schemas/proofloop-receipt-v1.schema.json b/schemas/proofloop-receipt-v1.schema.json new file mode 100644 index 0000000..6bc28af --- /dev/null +++ b/schemas/proofloop-receipt-v1.schema.json @@ -0,0 +1,394 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://nodeproof.dev/schemas/proofloop-receipt-v1.schema.json", + "title": "ProofLoop Receipt Envelope v1", + "description": "Canonical transport envelope for ProofLoop evidence. Wrapped payloads never transfer verdict authority implicitly: only top-level deterministic gates or official scorers may produce an authoritative verdict.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "schemaVersion", + "receiptId", + "kind", + "createdAt", + "producer", + "subject", + "verdict", + "checks", + "evidence", + "payload" + ], + "properties": { + "$schema": { + "type": "string", + "format": "uri" + }, + "schema": { + "const": "proofloop.receipt/v1" + }, + "schemaVersion": { + "const": 1 + }, + "receiptId": { + "$ref": "#/$defs/id" + }, + "kind": { + "type": "string", + "pattern": "^[a-z][a-z0-9._/-]{0,127}$" + }, + "createdAt": { + "$ref": "#/$defs/dateTime" + }, + "producer": { + "type": "object", + "additionalProperties": false, + "required": ["id", "version"], + "properties": { + "id": { "$ref": "#/$defs/id" }, + "version": { "type": "string", "minLength": 1, "maxLength": 128 }, + "runtime": { "type": "string", "minLength": 1, "maxLength": 256 }, + "configHash": { "$ref": "#/$defs/sha256" } + } + }, + "subject": { + "type": "object", + "additionalProperties": false, + "required": ["type", "id"], + "properties": { + "type": { + "enum": ["repository", "deployment", "run", "workflow", "artifact", "evaluation", "application"] + }, + "id": { "$ref": "#/$defs/id" }, + "runId": { "$ref": "#/$defs/id" }, + "artifactId": { "$ref": "#/$defs/id" }, + "targetUrl": { "type": "string", "format": "uri" }, + "repository": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { "type": "string", "minLength": 1, "maxLength": 2048 }, + "baseCommit": { "$ref": "#/$defs/gitSha" }, + "candidateCommit": { "$ref": "#/$defs/gitSha" }, + "branch": { "type": "string", "minLength": 1, "maxLength": 512 }, + "dirty": { "type": "boolean" } + } + } + } + }, + "claim": { + "type": "object", + "additionalProperties": false, + "required": ["text", "boundary"], + "properties": { + "text": { "type": "string", "minLength": 1, "maxLength": 10000 }, + "boundary": { "enum": ["product_path", "proxy", "official", "internal"] }, + "tier": { "enum": ["local_ready", "team_ready", "certification_ready"] } + } + }, + "verdict": { + "type": "object", + "additionalProperties": false, + "required": ["status", "authority", "decisionMethod", "decisiveCheckIds", "summary"], + "properties": { + "status": { "enum": ["passed", "failed", "blocked", "incomplete", "error", "unknown"] }, + "authority": { "enum": ["authoritative", "advisory", "informational"] }, + "decisionMethod": { + "enum": ["deterministic_gate", "official_scorer", "model_judge", "human_review", "external_claim", "none"] + }, + "decisiveCheckIds": { + "type": "array", + "uniqueItems": true, + "items": { "$ref": "#/$defs/id" } + }, + "summary": { "type": "string", "minLength": 1, "maxLength": 10000 } + } + }, + "checks": { + "type": "array", + "maxItems": 10000, + "items": { "$ref": "#/$defs/check" } + }, + "evidence": { + "type": "array", + "maxItems": 10000, + "items": { "$ref": "#/$defs/resource" } + }, + "artifacts": { + "type": "array", + "maxItems": 10000, + "items": { "$ref": "#/$defs/resource" } + }, + "payload": { + "$ref": "#/$defs/payload" + }, + "lineage": { + "type": "object", + "additionalProperties": false, + "properties": { + "parentReceiptIds": { + "type": "array", + "uniqueItems": true, + "items": { "$ref": "#/$defs/id" } + }, + "sourceReceiptIds": { + "type": "array", + "uniqueItems": true, + "items": { "$ref": "#/$defs/id" } + }, + "migration": { "type": "string", "minLength": 1, "maxLength": 512 } + } + }, + "timing": { + "type": "object", + "additionalProperties": false, + "properties": { + "startedAt": { "$ref": "#/$defs/dateTime" }, + "completedAt": { "$ref": "#/$defs/dateTime" }, + "durationMs": { "type": "integer", "minimum": 0 }, + "phases": { + "type": "array", + "maxItems": 1000, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "durationMs"], + "properties": { + "id": { "$ref": "#/$defs/id" }, + "startedAt": { "$ref": "#/$defs/dateTime" }, + "completedAt": { "$ref": "#/$defs/dateTime" }, + "durationMs": { "type": "integer", "minimum": 0 } + } + } + } + } + }, + "budget": { + "type": "object", + "additionalProperties": false, + "properties": { + "maxUsd": { "type": "number", "minimum": 0 }, + "spentUsd": { "type": "number", "minimum": 0 }, + "maxRuntimeMs": { "type": "integer", "minimum": 0 }, + "maxModelCalls": { "type": "integer", "minimum": 0 }, + "modelCalls": { "type": "integer", "minimum": 0 } + } + }, + "privacy": { + "type": "object", + "additionalProperties": false, + "required": ["visibility", "redacted"], + "properties": { + "visibility": { "enum": ["private", "team", "public"] }, + "redacted": { "type": "boolean" }, + "containsPersonalData": { "type": "boolean" }, + "externalEgress": { "type": "boolean" } + } + }, + "extensions": { + "type": "object", + "additionalProperties": true + } + }, + "allOf": [ + { + "if": { + "properties": { + "verdict": { + "properties": { "authority": { "const": "authoritative" } }, + "required": ["authority"] + } + } + }, + "then": { + "properties": { + "verdict": { + "properties": { + "status": { "enum": ["passed", "failed", "blocked", "error"] }, + "decisionMethod": { "enum": ["deterministic_gate", "official_scorer"] }, + "decisiveCheckIds": { "minItems": 1 } + } + }, + "checks": { "minItems": 1 }, + "evidence": { "minItems": 1 } + } + }, + "else": { + "properties": { + "verdict": { + "properties": { "decisiveCheckIds": { "maxItems": 0 } } + }, + "checks": { + "items": { + "properties": { "role": { "const": "advisory" } } + } + } + } + } + }, + { + "if": { + "properties": { + "verdict": { + "properties": { "authority": { "const": "informational" } }, + "required": ["authority"] + } + } + }, + "then": { + "properties": { + "verdict": { + "properties": { + "status": { "enum": ["incomplete", "unknown"] }, + "decisionMethod": { "const": "none" } + } + } + } + } + } + ], + "$defs": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$" + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "gitSha": { + "type": "string", + "pattern": "^[a-f0-9]{40,64}$" + }, + "dateTime": { + "type": "string", + "format": "date-time" + }, + "relativePath": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^(?![A-Za-z]:)(?![/\\\\])(?!.*(?:^|[/\\\\])\\.\\.(?:[/\\\\]|$)).+$" + }, + "jsonValue": { + "anyOf": [ + { "type": "null" }, + { "type": "boolean" }, + { "type": "number" }, + { "type": "string" }, + { "type": "array", "items": { "$ref": "#/$defs/jsonValue" } }, + { "type": "object", "additionalProperties": { "$ref": "#/$defs/jsonValue" } } + ] + }, + "resource": { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "sha256", "hashMethod"], + "properties": { + "id": { "$ref": "#/$defs/id" }, + "kind": { "type": "string", "pattern": "^[a-z][a-z0-9._/-]{0,127}$" }, + "description": { "type": "string", "maxLength": 4000 }, + "path": { "$ref": "#/$defs/relativePath" }, + "uri": { "type": "string", "format": "uri" }, + "inline": { "$ref": "#/$defs/jsonValue" }, + "sha256": { "$ref": "#/$defs/sha256" }, + "hashMethod": { "enum": ["raw-bytes-sha256", "canonical-json-sha256", "utf8-sha256"] }, + "mediaType": { "type": "string", "minLength": 1, "maxLength": 256 }, + "visibility": { "enum": ["private", "team", "public"] }, + "redacted": { "type": "boolean" } + }, + "oneOf": [ + { "required": ["path"], "not": { "anyOf": [{ "required": ["uri"] }, { "required": ["inline"] }] } }, + { "required": ["uri"], "not": { "anyOf": [{ "required": ["path"] }, { "required": ["inline"] }] } }, + { "required": ["inline"], "not": { "anyOf": [{ "required": ["path"] }, { "required": ["uri"] }] } } + ] + }, + "check": { + "type": "object", + "additionalProperties": false, + "required": ["id", "status", "role", "method", "summary", "evidenceRefs"], + "properties": { + "id": { "$ref": "#/$defs/id" }, + "status": { "enum": ["passed", "failed", "blocked", "error", "skipped", "unknown"] }, + "role": { "enum": ["decisive", "advisory"] }, + "method": { "enum": ["deterministic", "official_scorer", "model_judge", "human_review", "external"] }, + "summary": { "type": "string", "minLength": 1, "maxLength": 10000 }, + "evidenceRefs": { + "type": "array", + "uniqueItems": true, + "items": { "$ref": "#/$defs/id" } + }, + "durationMs": { "type": "integer", "minimum": 0 }, + "exitCode": { "type": "integer" }, + "score": { "type": "number" }, + "threshold": { "type": "number" }, + "scorer": { + "type": "object", + "additionalProperties": false, + "required": ["name", "version"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 256 }, + "version": { "type": "string", "minLength": 1, "maxLength": 256 }, + "digest": { "$ref": "#/$defs/sha256" } + } + } + }, + "allOf": [ + { + "if": { "properties": { "role": { "const": "decisive" } }, "required": ["role"] }, + "then": { + "properties": { + "method": { "enum": ["deterministic", "official_scorer"] }, + "evidenceRefs": { "minItems": 1 } + } + } + }, + { + "if": { "properties": { "method": { "const": "official_scorer" } }, "required": ["method"] }, + "then": { + "required": ["scorer"], + "properties": { + "scorer": { "required": ["name", "version", "digest"] } + } + } + } + ] + }, + "payload": { + "type": "object", + "additionalProperties": false, + "required": ["schema", "mode", "sha256", "hashMethod"], + "properties": { + "schema": { "type": "string", "minLength": 1, "maxLength": 256 }, + "version": { + "anyOf": [ + { "type": "string", "minLength": 1, "maxLength": 128 }, + { "type": "integer", "minimum": 0 } + ] + }, + "mode": { "enum": ["inline", "reference"] }, + "data": { "$ref": "#/$defs/jsonValue" }, + "ref": { "$ref": "#/$defs/relativePath" }, + "sha256": { "$ref": "#/$defs/sha256" }, + "hashMethod": { "enum": ["raw-bytes-sha256", "canonical-json-sha256"] } + }, + "oneOf": [ + { + "properties": { + "mode": { "const": "inline" }, + "hashMethod": { "const": "canonical-json-sha256" } + }, + "required": ["data"], + "not": { "required": ["ref"] } + }, + { + "properties": { + "mode": { "const": "reference" }, + "hashMethod": { "const": "raw-bytes-sha256" } + }, + "required": ["ref"], + "not": { "required": ["data"] } + } + ] + } + } +} diff --git a/src/cli.ts b/src/cli.ts index 4a2b6b7..a168c2f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -38,6 +38,11 @@ import { import { installProofloopGithubCi } from "./proofloopCi"; import { runToolUseInit, runToolUseVerify } from "./proofloopToolUse"; import { runReceiptVerify, type ReceiptKind } from "./receipts"; +import { + proofReceiptSchemaPath, + readProofReceiptSchema, + runProofReceiptEnvelopeVerify, +} from "./proofReceipt"; import { startMcpServer } from "./mcp"; import { buildProofloopProjectManifest, @@ -158,6 +163,8 @@ function usage(): string { " report latest [--json] latest gate report", " charts latest write local JSON/SVG proof charts", " receipt verify --file verify app-produced proof receipts", + " receipt envelope verify --file verify a proofloop.receipt/v1 envelope", + " receipt schema [--json] locate or print the proofloop.receipt/v1 JSON Schema", " solo setup --source [--agent codex|claude-code|both] [--install-deps] [--verify]", " solo ingest|status|gate|resume validate and inspect Solo interop evidence", " solo attest --file --gate-receipt --out --key-id ", @@ -251,7 +258,7 @@ export function runCli(argv: string[]): number | Promise { return runChartsCommand(positional[1], root); case "receipt": - return runReceiptCommand(positional[1], options, root); + return runReceiptCommand(positional[1], positional[2], options, root); case "solo": return runSoloCommand(positional[1], options, root); @@ -814,9 +821,41 @@ function runChartsCommand(sub: string | undefined, root: string): number { return 0; } -function runReceiptCommand(sub: string | undefined, options: Record, root: string): number { - if (sub !== "verify") { - console.error("proofloop receipt: expected `verify`."); +function runReceiptCommand( + sub: string | undefined, + action: string | undefined, + options: Record, + root: string, +): number { + if (sub === "schema") { + if (action !== undefined) { + console.error("proofloop receipt schema: unexpected positional argument."); + return 2; + } + if (options.json === true) console.log(JSON.stringify(readProofReceiptSchema(), null, 2)); + else console.log(proofReceiptSchemaPath()); + return 0; + } + + if (sub === "envelope") { + if (action !== "verify") { + console.error("proofloop receipt envelope: expected `verify`."); + return 2; + } + const filePath = str(options.file); + if (!filePath) { + console.error("proofloop receipt envelope verify: --file is required."); + return 2; + } + return runProofReceiptEnvelopeVerify({ + root, + filePath, + json: options.json === true, + }); + } + + if (sub !== "verify" || action !== undefined) { + console.error("proofloop receipt: expected `verify`, `envelope verify`, or `schema`."); return 2; } diff --git a/src/index.ts b/src/index.ts index 3cf35c3..41dd827 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ export * from "./maturity"; export * from "./productivity"; export * from "./contextReport"; export * from "./receipts"; +export * from "./proofReceipt"; export * from "./agentAdapters"; export * from "./agentLoop"; export * from "./codexRelaunch"; diff --git a/src/proofReceipt.ts b/src/proofReceipt.ts new file mode 100644 index 0000000..5aff78e --- /dev/null +++ b/src/proofReceipt.ts @@ -0,0 +1,696 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; + +export const PROOFLOOP_RECEIPT_SCHEMA = "proofloop.receipt/v1" as const; +export const PROOFLOOP_RECEIPT_SCHEMA_VERSION = 1 as const; +export const PROOFLOOP_RECEIPT_SCHEMA_FILE = "proofloop-receipt-v1.schema.json" as const; + +export type ProofReceiptAuthority = "authoritative" | "advisory" | "informational"; +export type ProofReceiptStatus = "passed" | "failed" | "blocked" | "incomplete" | "error" | "unknown"; +export type ProofReceiptDecisionMethod = + | "deterministic_gate" + | "official_scorer" + | "model_judge" + | "human_review" + | "external_claim" + | "none"; +export type ProofReceiptCheckStatus = "passed" | "failed" | "blocked" | "error" | "skipped" | "unknown"; +export type ProofReceiptCheckMethod = "deterministic" | "official_scorer" | "model_judge" | "human_review" | "external"; +export type ProofReceiptHashMethod = "raw-bytes-sha256" | "canonical-json-sha256" | "utf8-sha256"; + +export interface ProofReceiptResource { + id: string; + kind: string; + description?: string; + path?: string; + uri?: string; + inline?: unknown; + sha256: string; + hashMethod: ProofReceiptHashMethod; + mediaType?: string; + visibility?: "private" | "team" | "public"; + redacted?: boolean; +} + +export interface ProofReceiptCheck { + id: string; + status: ProofReceiptCheckStatus; + role: "decisive" | "advisory"; + method: ProofReceiptCheckMethod; + summary: string; + evidenceRefs: string[]; + durationMs?: number; + exitCode?: number; + score?: number; + threshold?: number; + scorer?: { + name: string; + version: string; + digest?: string; + }; +} + +export interface ProofReceiptPayload { + schema: string; + version?: string | number; + mode: "inline" | "reference"; + data?: unknown; + ref?: string; + sha256: string; + hashMethod: "raw-bytes-sha256" | "canonical-json-sha256"; +} + +export interface ProofReceiptEnvelope { + $schema?: string; + schema: typeof PROOFLOOP_RECEIPT_SCHEMA; + schemaVersion: typeof PROOFLOOP_RECEIPT_SCHEMA_VERSION; + receiptId: string; + kind: string; + createdAt: string; + producer: { + id: string; + version: string; + runtime?: string; + configHash?: string; + }; + subject: { + type: "repository" | "deployment" | "run" | "workflow" | "artifact" | "evaluation" | "application"; + id: string; + runId?: string; + artifactId?: string; + targetUrl?: string; + repository?: { + url?: string; + baseCommit?: string; + candidateCommit?: string; + branch?: string; + dirty?: boolean; + }; + }; + claim?: { + text: string; + boundary: "product_path" | "proxy" | "official" | "internal"; + tier?: "local_ready" | "team_ready" | "certification_ready"; + }; + verdict: { + status: ProofReceiptStatus; + authority: ProofReceiptAuthority; + decisionMethod: ProofReceiptDecisionMethod; + decisiveCheckIds: string[]; + summary: string; + }; + checks: ProofReceiptCheck[]; + evidence: ProofReceiptResource[]; + artifacts?: ProofReceiptResource[]; + payload: ProofReceiptPayload; + lineage?: { + parentReceiptIds?: string[]; + sourceReceiptIds?: string[]; + migration?: string; + }; + timing?: { + startedAt?: string; + completedAt?: string; + durationMs?: number; + phases?: Array<{ + id: string; + startedAt?: string; + completedAt?: string; + durationMs: number; + }>; + }; + budget?: { + maxUsd?: number; + spentUsd?: number; + maxRuntimeMs?: number; + maxModelCalls?: number; + modelCalls?: number; + }; + privacy?: { + visibility: "private" | "team" | "public"; + redacted: boolean; + containsPersonalData?: boolean; + externalEgress?: boolean; + }; + extensions?: Record; +} + +export interface ProofReceiptIssue { + path: string; + code: string; + message: string; +} + +export interface ProofReceiptValidation { + ok: boolean; + errors: ProofReceiptIssue[]; + warnings: ProofReceiptIssue[]; + envelope?: ProofReceiptEnvelope; +} + +export interface ProofReceiptFileVerification extends ProofReceiptValidation { + receiptPath: string; +} + +type UnknownRecord = Record; + +const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/; +const KIND_PATTERN = /^[a-z][a-z0-9._/-]{0,127}$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const GIT_SHA_PATTERN = /^[a-f0-9]{40,64}$/; +const AUTHORITATIVE_METHODS = new Set(["deterministic_gate", "official_scorer"]); +const DECISIVE_CHECK_METHODS = new Set(["deterministic", "official_scorer"]); +const RECEIPT_KEYS = new Set([ + "$schema", + "schema", + "schemaVersion", + "receiptId", + "kind", + "createdAt", + "producer", + "subject", + "claim", + "verdict", + "checks", + "evidence", + "artifacts", + "payload", + "lineage", + "timing", + "budget", + "privacy", + "extensions", +]); + +export function proofReceiptSchemaPath(): string { + return resolve(__dirname, "..", "schemas", PROOFLOOP_RECEIPT_SCHEMA_FILE); +} + +export function readProofReceiptSchema(): unknown { + return JSON.parse(readFileSync(proofReceiptSchemaPath(), "utf8")); +} + +export function canonicalJson(value: unknown): string { + if (value === null) return "null"; + if (typeof value === "string" || typeof value === "boolean") return JSON.stringify(value); + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new Error("canonical JSON does not support non-finite numbers"); + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map((entry) => canonicalJson(entry)).join(",")}]`; + if (isRecord(value)) { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`) + .join(",")}}`; + } + throw new Error(`canonical JSON does not support ${typeof value}`); +} + +export function sha256Utf8(value: string): string { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +export function sha256CanonicalJson(value: unknown): string { + return sha256Utf8(canonicalJson(value)); +} + +export function createInlineProofReceiptPayload( + schema: string, + data: unknown, + version?: string | number, +): ProofReceiptPayload { + return { + schema, + ...(version !== undefined ? { version } : {}), + mode: "inline", + data, + sha256: sha256CanonicalJson(data), + hashMethod: "canonical-json-sha256", + }; +} + +export function createInlineProofReceiptResource(options: { + id: string; + kind: string; + inline: unknown; + description?: string; + mediaType?: string; + visibility?: "private" | "team" | "public"; + redacted?: boolean; +}): ProofReceiptResource { + return { + id: options.id, + kind: options.kind, + ...(options.description !== undefined ? { description: options.description } : {}), + inline: options.inline, + sha256: sha256CanonicalJson(options.inline), + hashMethod: "canonical-json-sha256", + ...(options.mediaType !== undefined ? { mediaType: options.mediaType } : {}), + ...(options.visibility !== undefined ? { visibility: options.visibility } : {}), + ...(options.redacted !== undefined ? { redacted: options.redacted } : {}), + }; +} + +export function validateProofReceiptEnvelope(value: unknown): ProofReceiptValidation { + const errors: ProofReceiptIssue[] = []; + const warnings: ProofReceiptIssue[] = []; + const receipt = asRecord(value, "$", errors); + if (!receipt) return { ok: false, errors, warnings }; + + for (const key of Object.keys(receipt)) { + if (!RECEIPT_KEYS.has(key)) issue(errors, `$.${key}`, "unknown_property", "unknown top-level property"); + } + + expectLiteral(receipt.schema, PROOFLOOP_RECEIPT_SCHEMA, "$.schema", errors); + expectLiteral(receipt.schemaVersion, PROOFLOOP_RECEIPT_SCHEMA_VERSION, "$.schemaVersion", errors); + expectPattern(receipt.receiptId, ID_PATTERN, "$.receiptId", errors); + expectPattern(receipt.kind, KIND_PATTERN, "$.kind", errors); + expectDateTime(receipt.createdAt, "$.createdAt", errors); + + const producer = asRecord(receipt.producer, "$.producer", errors); + if (producer) { + expectPattern(producer.id, ID_PATTERN, "$.producer.id", errors); + expectNonEmptyString(producer.version, "$.producer.version", errors); + if (producer.configHash !== undefined) expectPattern(producer.configHash, SHA256_PATTERN, "$.producer.configHash", errors); + } + + const subject = asRecord(receipt.subject, "$.subject", errors); + if (subject) { + expectEnum(subject.type, ["repository", "deployment", "run", "workflow", "artifact", "evaluation", "application"], "$.subject.type", errors); + expectPattern(subject.id, ID_PATTERN, "$.subject.id", errors); + if (subject.runId !== undefined) expectPattern(subject.runId, ID_PATTERN, "$.subject.runId", errors); + if (subject.artifactId !== undefined) expectPattern(subject.artifactId, ID_PATTERN, "$.subject.artifactId", errors); + if (subject.targetUrl !== undefined) expectUri(subject.targetUrl, "$.subject.targetUrl", errors); + const repository = subject.repository === undefined ? undefined : asRecord(subject.repository, "$.subject.repository", errors); + if (repository) { + if (repository.baseCommit !== undefined) expectPattern(repository.baseCommit, GIT_SHA_PATTERN, "$.subject.repository.baseCommit", errors); + if (repository.candidateCommit !== undefined) expectPattern(repository.candidateCommit, GIT_SHA_PATTERN, "$.subject.repository.candidateCommit", errors); + } + } + + const verdict = asRecord(receipt.verdict, "$.verdict", errors); + const status = verdict ? expectEnum(verdict.status, ["passed", "failed", "blocked", "incomplete", "error", "unknown"], "$.verdict.status", errors) : undefined; + const authority = verdict ? expectEnum(verdict.authority, ["authoritative", "advisory", "informational"], "$.verdict.authority", errors) : undefined; + const decisionMethod = verdict ? expectEnum(verdict.decisionMethod, ["deterministic_gate", "official_scorer", "model_judge", "human_review", "external_claim", "none"], "$.verdict.decisionMethod", errors) : undefined; + if (verdict) expectNonEmptyString(verdict.summary, "$.verdict.summary", errors); + const decisiveCheckIds = verdict ? stringArray(verdict.decisiveCheckIds, "$.verdict.decisiveCheckIds", errors, true) : []; + + const checkValues = arrayValue(receipt.checks, "$.checks", errors); + const checks: ProofReceiptCheck[] = []; + const checkIds = new Set(); + for (let index = 0; index < checkValues.length; index += 1) { + const check = validateCheck(checkValues[index], index, errors); + if (!check) continue; + if (checkIds.has(check.id)) issue(errors, `$.checks[${index}].id`, "duplicate_id", `duplicate check id ${check.id}`); + checkIds.add(check.id); + checks.push(check); + } + + const evidenceValues = arrayValue(receipt.evidence, "$.evidence", errors); + const evidence: ProofReceiptResource[] = []; + const evidenceIds = new Set(); + for (let index = 0; index < evidenceValues.length; index += 1) { + const resource = validateResource(evidenceValues[index], `$.evidence[${index}]`, errors); + if (!resource) continue; + if (evidenceIds.has(resource.id)) issue(errors, `$.evidence[${index}].id`, "duplicate_id", `duplicate evidence id ${resource.id}`); + evidenceIds.add(resource.id); + evidence.push(resource); + } + + const artifactValues = receipt.artifacts === undefined ? [] : arrayValue(receipt.artifacts, "$.artifacts", errors); + const artifacts: ProofReceiptResource[] = []; + const artifactIds = new Set(); + for (let index = 0; index < artifactValues.length; index += 1) { + const resource = validateResource(artifactValues[index], `$.artifacts[${index}]`, errors); + if (!resource) continue; + if (artifactIds.has(resource.id)) issue(errors, `$.artifacts[${index}].id`, "duplicate_id", `duplicate artifact id ${resource.id}`); + artifactIds.add(resource.id); + artifacts.push(resource); + } + + for (const check of checks) { + for (const evidenceRef of check.evidenceRefs) { + if (!evidenceIds.has(evidenceRef)) issue(errors, `$.checks.${check.id}.evidenceRefs`, "missing_evidence", `unknown evidence ref ${evidenceRef}`); + } + } + + validatePayload(receipt.payload, errors); + + if (authority === "authoritative") { + if (!decisionMethod || !AUTHORITATIVE_METHODS.has(decisionMethod)) { + issue(errors, "$.verdict.decisionMethod", "authority_violation", "authoritative verdicts require a deterministic gate or official scorer"); + } + if (!status || status === "incomplete" || status === "unknown") { + issue(errors, "$.verdict.status", "authority_violation", "authoritative verdicts cannot be incomplete or unknown"); + } + if (decisiveCheckIds.length === 0) issue(errors, "$.verdict.decisiveCheckIds", "missing_decisive_check", "authoritative verdicts require at least one decisive check"); + + const decisiveIds = new Set(decisiveCheckIds); + const decisiveChecks = checks.filter((check) => decisiveIds.has(check.id)); + for (const id of decisiveCheckIds) { + if (!checkIds.has(id)) issue(errors, "$.verdict.decisiveCheckIds", "missing_check", `unknown decisive check ${id}`); + } + for (const check of checks.filter((entry) => entry.role === "decisive")) { + if (!decisiveIds.has(check.id)) issue(errors, `$.checks.${check.id}.role`, "unlisted_decisive_check", "decisive checks must be listed in verdict.decisiveCheckIds"); + } + for (const check of decisiveChecks) { + if (check.role !== "decisive") issue(errors, `$.checks.${check.id}.role`, "authority_violation", "a decisiveCheckId must reference a decisive check"); + if (!DECISIVE_CHECK_METHODS.has(check.method)) issue(errors, `$.checks.${check.id}.method`, "authority_violation", "model, human, and external checks cannot decide an authoritative verdict"); + if (check.evidenceRefs.length === 0) issue(errors, `$.checks.${check.id}.evidenceRefs`, "missing_evidence", "decisive checks require locally verifiable evidence"); + for (const ref of check.evidenceRefs) { + const resource = evidence.find((entry) => entry.id === ref); + if (resource?.uri !== undefined) issue(errors, `$.evidence.${ref}.uri`, "unverifiable_decisive_evidence", "URI-only evidence cannot decide an authoritative local verification"); + } + } + if (decisionMethod === "deterministic_gate" && decisiveChecks.some((check) => check.method !== "deterministic")) { + issue(errors, "$.verdict.decisionMethod", "method_mismatch", "deterministic_gate verdicts require every decisive check to be deterministic"); + } + if (decisionMethod === "official_scorer" && !decisiveChecks.some((check) => check.method === "official_scorer")) { + issue(errors, "$.verdict.decisionMethod", "method_mismatch", "official_scorer verdicts require an official scorer decisive check"); + } + if (status === "passed" && decisiveChecks.some((check) => check.status !== "passed")) { + issue(errors, "$.verdict.status", "verdict_mismatch", "an authoritative pass requires every decisive check to pass"); + } + if ((status === "failed" || status === "blocked" || status === "error") && !decisiveChecks.some((check) => check.status === status)) { + issue(errors, "$.verdict.status", "verdict_mismatch", `an authoritative ${status} verdict requires a decisive ${status} check`); + } + } else if (authority !== undefined) { + if (decisiveCheckIds.length > 0) issue(errors, "$.verdict.decisiveCheckIds", "authority_violation", "non-authoritative receipts cannot declare decisive checks"); + for (const check of checks) { + if (check.role === "decisive") issue(errors, `$.checks.${check.id}.role`, "authority_violation", "non-authoritative receipts may contain advisory checks only"); + } + } + + if (authority === "informational") { + if (status !== "incomplete" && status !== "unknown") issue(errors, "$.verdict.status", "informational_verdict", "informational receipts must be incomplete or unknown"); + if (decisionMethod !== "none") issue(errors, "$.verdict.decisionMethod", "informational_verdict", "informational receipts use decisionMethod none"); + } + + const envelope = errors.length === 0 ? value as ProofReceiptEnvelope : undefined; + return { ok: errors.length === 0, errors, warnings, ...(envelope ? { envelope } : {}) }; +} + +export function verifyProofReceiptEnvelopeFile(options: { root: string; filePath: string }): ProofReceiptFileVerification { + const receiptPath = isAbsolute(options.filePath) ? options.filePath : resolve(options.root, options.filePath); + if (!existsSync(receiptPath)) { + return { + ok: false, + receiptPath, + errors: [{ path: "$", code: "receipt_missing", message: "receipt file does not exist" }], + warnings: [], + }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(receiptPath, "utf8")); + } catch (error) { + return { + ok: false, + receiptPath, + errors: [{ path: "$", code: "receipt_json", message: error instanceof Error ? error.message : String(error) }], + warnings: [], + }; + } + + const validation = validateProofReceiptEnvelope(parsed); + const errors = [...validation.errors]; + const warnings = [...validation.warnings]; + const envelope = validation.envelope; + if (envelope) { + const baseDir = dirname(receiptPath); + verifyPayloadIntegrity(envelope.payload, baseDir, errors); + for (const resource of [...envelope.evidence, ...(envelope.artifacts ?? [])]) { + verifyResourceIntegrity(resource, baseDir, errors); + } + } + + return { + ok: errors.length === 0, + receiptPath, + errors, + warnings, + ...(envelope ? { envelope } : {}), + }; +} + +export function formatProofReceiptVerification(result: ProofReceiptFileVerification): string { + const lines = [ + `schema=${PROOFLOOP_RECEIPT_SCHEMA}`, + `path=${result.receiptPath}`, + `status=${result.ok ? "passed" : "failed"}`, + ]; + if (result.envelope) { + lines.push(`receiptId=${result.envelope.receiptId}`); + lines.push(`kind=${result.envelope.kind}`); + lines.push(`authority=${result.envelope.verdict.authority}`); + lines.push(`verdict=${result.envelope.verdict.status}`); + } + lines.push("checks:"); + if (result.errors.length === 0) lines.push("- PASS envelope and local integrity checks"); + for (const error of result.errors) lines.push(`- FAIL ${error.path} ${error.code}: ${error.message}`); + for (const warning of result.warnings) lines.push(`- WARN ${warning.path} ${warning.code}: ${warning.message}`); + return `${lines.join("\n")}\n`; +} + +export function runProofReceiptEnvelopeVerify(options: { + root: string; + filePath: string; + json?: boolean; + log?: (message: string) => void; + logError?: (message: string) => void; +}): number { + const result = verifyProofReceiptEnvelopeFile(options); + const log = options.log ?? console.log; + const logError = options.logError ?? console.error; + const output = options.json === true ? JSON.stringify(result, null, 2) : formatProofReceiptVerification(result); + if (result.ok) log(output); + else logError(output); + return result.ok ? 0 : 1; +} + +function validateCheck(value: unknown, index: number, errors: ProofReceiptIssue[]): ProofReceiptCheck | undefined { + const path = `$.checks[${index}]`; + const check = asRecord(value, path, errors); + if (!check) return undefined; + const id = expectPattern(check.id, ID_PATTERN, `${path}.id`, errors); + const status = expectEnum(check.status, ["passed", "failed", "blocked", "error", "skipped", "unknown"], `${path}.status`, errors); + const role = expectEnum<"decisive" | "advisory">(check.role, ["decisive", "advisory"], `${path}.role`, errors); + const method = expectEnum(check.method, ["deterministic", "official_scorer", "model_judge", "human_review", "external"], `${path}.method`, errors); + const summary = expectNonEmptyString(check.summary, `${path}.summary`, errors); + const evidenceRefs = stringArray(check.evidenceRefs, `${path}.evidenceRefs`, errors, true); + if (check.durationMs !== undefined) expectNonNegativeInteger(check.durationMs, `${path}.durationMs`, errors); + if (check.exitCode !== undefined && !Number.isInteger(check.exitCode)) issue(errors, `${path}.exitCode`, "type", "expected an integer"); + if (check.score !== undefined && (typeof check.score !== "number" || !Number.isFinite(check.score))) issue(errors, `${path}.score`, "type", "expected a finite number"); + if (check.threshold !== undefined && (typeof check.threshold !== "number" || !Number.isFinite(check.threshold))) issue(errors, `${path}.threshold`, "type", "expected a finite number"); + const scorer = check.scorer === undefined ? undefined : asRecord(check.scorer, `${path}.scorer`, errors); + if (scorer) { + expectNonEmptyString(scorer.name, `${path}.scorer.name`, errors); + expectNonEmptyString(scorer.version, `${path}.scorer.version`, errors); + if (scorer.digest !== undefined) expectPattern(scorer.digest, SHA256_PATTERN, `${path}.scorer.digest`, errors); + } + if (role === "decisive" && method && !DECISIVE_CHECK_METHODS.has(method)) issue(errors, `${path}.method`, "authority_violation", "decisive checks must be deterministic or official scorers"); + if (role === "decisive" && evidenceRefs.length === 0) issue(errors, `${path}.evidenceRefs`, "missing_evidence", "decisive checks require evidence"); + if (method === "official_scorer") { + if (!scorer) issue(errors, `${path}.scorer`, "missing_scorer", "official scorer checks require scorer identity"); + else if (scorer.digest === undefined) issue(errors, `${path}.scorer.digest`, "missing_scorer_digest", "official scorer checks require an immutable scorer digest"); + } + if (!id || !status || !role || !method || !summary) return undefined; + return { + id, + status, + role, + method, + summary, + evidenceRefs, + ...(typeof check.durationMs === "number" ? { durationMs: check.durationMs } : {}), + ...(typeof check.exitCode === "number" ? { exitCode: check.exitCode } : {}), + ...(typeof check.score === "number" ? { score: check.score } : {}), + ...(typeof check.threshold === "number" ? { threshold: check.threshold } : {}), + ...(scorer ? { scorer: check.scorer as ProofReceiptCheck["scorer"] } : {}), + }; +} + +function validateResource(value: unknown, path: string, errors: ProofReceiptIssue[]): ProofReceiptResource | undefined { + const resource = asRecord(value, path, errors); + if (!resource) return undefined; + const id = expectPattern(resource.id, ID_PATTERN, `${path}.id`, errors); + const kind = expectPattern(resource.kind, KIND_PATTERN, `${path}.kind`, errors); + const sha256 = expectPattern(resource.sha256, SHA256_PATTERN, `${path}.sha256`, errors); + const hashMethod = expectEnum(resource.hashMethod, ["raw-bytes-sha256", "canonical-json-sha256", "utf8-sha256"], `${path}.hashMethod`, errors); + const locators = [resource.path !== undefined, resource.uri !== undefined, Object.prototype.hasOwnProperty.call(resource, "inline")].filter(Boolean).length; + if (locators !== 1) issue(errors, path, "resource_locator", "exactly one of path, uri, or inline is required"); + if (resource.path !== undefined && !safeRelativePath(resource.path)) issue(errors, `${path}.path`, "relative_path", "expected a safe relative path without parent traversal"); + if (resource.uri !== undefined) expectUri(resource.uri, `${path}.uri`, errors); + if (resource.path !== undefined || resource.uri !== undefined) { + if (hashMethod && hashMethod !== "raw-bytes-sha256") issue(errors, `${path}.hashMethod`, "hash_method", "path and URI resources use raw-bytes-sha256"); + } + if (Object.prototype.hasOwnProperty.call(resource, "inline")) { + if (hashMethod === "canonical-json-sha256") { + try { + if (sha256 && sha256CanonicalJson(resource.inline) !== sha256) issue(errors, `${path}.sha256`, "hash_mismatch", "inline canonical JSON hash does not match"); + } catch (error) { + issue(errors, `${path}.inline`, "canonical_json", error instanceof Error ? error.message : String(error)); + } + } else if (hashMethod === "utf8-sha256") { + if (typeof resource.inline !== "string") issue(errors, `${path}.inline`, "type", "utf8-sha256 requires an inline string"); + else if (sha256 && sha256Utf8(resource.inline) !== sha256) issue(errors, `${path}.sha256`, "hash_mismatch", "inline UTF-8 hash does not match"); + } else if (hashMethod !== undefined) { + issue(errors, `${path}.hashMethod`, "hash_method", "inline resources use canonical-json-sha256 or utf8-sha256"); + } + } + if (!id || !kind || !sha256 || !hashMethod) return undefined; + return value as ProofReceiptResource; +} + +function validatePayload(value: unknown, errors: ProofReceiptIssue[]): ProofReceiptPayload | undefined { + const payload = asRecord(value, "$.payload", errors); + if (!payload) return undefined; + const schema = expectNonEmptyString(payload.schema, "$.payload.schema", errors); + const mode = expectEnum<"inline" | "reference">(payload.mode, ["inline", "reference"], "$.payload.mode", errors); + const sha256 = expectPattern(payload.sha256, SHA256_PATTERN, "$.payload.sha256", errors); + const hashMethod = expectEnum<"raw-bytes-sha256" | "canonical-json-sha256">(payload.hashMethod, ["raw-bytes-sha256", "canonical-json-sha256"], "$.payload.hashMethod", errors); + const hasData = Object.prototype.hasOwnProperty.call(payload, "data"); + const hasRef = payload.ref !== undefined; + if (mode === "inline") { + if (!hasData || hasRef) issue(errors, "$.payload", "payload_mode", "inline payload requires data and forbids ref"); + if (hashMethod !== "canonical-json-sha256") issue(errors, "$.payload.hashMethod", "hash_method", "inline payloads use canonical-json-sha256"); + if (hasData && sha256) { + try { + if (sha256CanonicalJson(payload.data) !== sha256) issue(errors, "$.payload.sha256", "hash_mismatch", "inline payload canonical JSON hash does not match"); + } catch (error) { + issue(errors, "$.payload.data", "canonical_json", error instanceof Error ? error.message : String(error)); + } + } + } else if (mode === "reference") { + if (!hasRef || hasData) issue(errors, "$.payload", "payload_mode", "reference payload requires ref and forbids data"); + if (!safeRelativePath(payload.ref)) issue(errors, "$.payload.ref", "relative_path", "expected a safe relative path without parent traversal"); + if (hashMethod !== "raw-bytes-sha256") issue(errors, "$.payload.hashMethod", "hash_method", "reference payloads use raw-bytes-sha256"); + } + if (!schema || !mode || !sha256 || !hashMethod) return undefined; + return value as ProofReceiptPayload; +} + +function verifyPayloadIntegrity(payload: ProofReceiptPayload, baseDir: string, errors: ProofReceiptIssue[]): void { + if (payload.mode === "inline") return; + if (!payload.ref || !safeRelativePath(payload.ref)) return; + verifyRelativeFileHash(payload.ref, payload.sha256, baseDir, "$.payload.ref", errors); +} + +function verifyResourceIntegrity(resource: ProofReceiptResource, baseDir: string, errors: ProofReceiptIssue[]): void { + if (!resource.path || !safeRelativePath(resource.path)) return; + verifyRelativeFileHash(resource.path, resource.sha256, baseDir, `$.resources.${resource.id}.path`, errors); +} + +function verifyRelativeFileHash(path: string, expectedHash: string, baseDir: string, issuePath: string, errors: ProofReceiptIssue[]): void { + const absolutePath = resolve(baseDir, path); + const escaped = relative(baseDir, absolutePath); + if (escaped === ".." || escaped.startsWith(`..${sep}`) || isAbsolute(escaped)) { + issue(errors, issuePath, "path_escape", "referenced file escapes the receipt directory"); + return; + } + if (!existsSync(absolutePath)) { + issue(errors, issuePath, "referenced_file_missing", `referenced file does not exist: ${path}`); + return; + } + try { + const actual = createHash("sha256").update(readFileSync(absolutePath)).digest("hex"); + if (actual !== expectedHash) issue(errors, issuePath, "hash_mismatch", `expected ${expectedHash}, received ${actual}`); + } catch (error) { + issue(errors, issuePath, "referenced_file_unreadable", error instanceof Error ? error.message : String(error)); + } +} + +function asRecord(value: unknown, path: string, errors: ProofReceiptIssue[]): UnknownRecord | undefined { + if (!isRecord(value)) { + issue(errors, path, "type", "expected an object"); + return undefined; + } + return value; +} + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function arrayValue(value: unknown, path: string, errors: ProofReceiptIssue[]): unknown[] { + if (!Array.isArray(value)) { + issue(errors, path, "type", "expected an array"); + return []; + } + return value; +} + +function stringArray(value: unknown, path: string, errors: ProofReceiptIssue[], unique: boolean): string[] { + const values = arrayValue(value, path, errors); + const strings: string[] = []; + for (let index = 0; index < values.length; index += 1) { + const item = expectPattern(values[index], ID_PATTERN, `${path}[${index}]`, errors); + if (item) strings.push(item); + } + if (unique && new Set(strings).size !== strings.length) issue(errors, path, "unique", "expected unique values"); + return strings; +} + +function expectLiteral(value: unknown, expected: T, path: string, errors: ProofReceiptIssue[]): T | undefined { + if (value !== expected) { + issue(errors, path, "const", `expected ${String(expected)}`); + return undefined; + } + return expected; +} + +function expectPattern(value: unknown, pattern: RegExp, path: string, errors: ProofReceiptIssue[]): string | undefined { + if (typeof value !== "string" || !pattern.test(value)) { + issue(errors, path, "pattern", `expected string matching ${pattern.source}`); + return undefined; + } + return value; +} + +function expectNonEmptyString(value: unknown, path: string, errors: ProofReceiptIssue[]): string | undefined { + if (typeof value !== "string" || value.length === 0) { + issue(errors, path, "type", "expected a non-empty string"); + return undefined; + } + return value; +} + +function expectEnum(value: unknown, allowed: readonly T[], path: string, errors: ProofReceiptIssue[]): T | undefined { + if (typeof value !== "string" || !allowed.includes(value as T)) { + issue(errors, path, "enum", `expected one of ${allowed.join(", ")}`); + return undefined; + } + return value as T; +} + +function expectDateTime(value: unknown, path: string, errors: ProofReceiptIssue[]): void { + if (typeof value !== "string" || Number.isNaN(Date.parse(value))) issue(errors, path, "date_time", "expected an ISO-like date-time string"); +} + +function expectUri(value: unknown, path: string, errors: ProofReceiptIssue[]): void { + if (typeof value !== "string") { + issue(errors, path, "uri", "expected a URI string"); + return; + } + try { + new URL(value); + } catch { + issue(errors, path, "uri", "expected a valid URI"); + } +} + +function expectNonNegativeInteger(value: unknown, path: string, errors: ProofReceiptIssue[]): void { + if (typeof value !== "number" || !Number.isInteger(value) || value < 0) issue(errors, path, "type", "expected a non-negative integer"); +} + +function safeRelativePath(value: unknown): value is string { + if (typeof value !== "string" || value.length === 0 || isAbsolute(value) || /^[A-Za-z]:/.test(value)) return false; + return !value.split(/[\\/]/).includes(".."); +} + +function issue(target: ProofReceiptIssue[], path: string, code: string, message: string): void { + target.push({ path, code, message }); +} diff --git a/tests/fixtures/receipts/proofloop-receipt-v1/invalid-authoritative-model-judge.json b/tests/fixtures/receipts/proofloop-receipt-v1/invalid-authoritative-model-judge.json new file mode 100644 index 0000000..30dd587 --- /dev/null +++ b/tests/fixtures/receipts/proofloop-receipt-v1/invalid-authoritative-model-judge.json @@ -0,0 +1,53 @@ +{ + "schema": "proofloop.receipt/v1", + "schemaVersion": 1, + "receiptId": "receipt-invalid-model-authority", + "kind": "ui-qa", + "createdAt": "2026-07-20T00:05:00.000Z", + "producer": { + "id": "visual-judge", + "version": "1.0.0" + }, + "subject": { + "type": "deployment", + "id": "ui-preview" + }, + "verdict": { + "status": "passed", + "authority": "authoritative", + "decisionMethod": "model_judge", + "decisiveCheckIds": ["visual-judge"], + "summary": "This must fail because a model judge cannot be authoritative." + }, + "checks": [ + { + "id": "visual-judge", + "status": "passed", + "role": "decisive", + "method": "model_judge", + "summary": "A model said the page looks correct.", + "evidenceRefs": ["model-output"] + } + ], + "evidence": [ + { + "id": "model-output", + "kind": "model-judge-output", + "inline": { + "verdict": "pass" + }, + "sha256": "f5d6f98b22d346a32b0be68e2561b17913ff64e9b15a7c1e12f41b7c697ae1ac", + "hashMethod": "canonical-json-sha256" + } + ], + "payload": { + "schema": "visual-judge/v1", + "version": 1, + "mode": "inline", + "data": { + "verdict": "pass" + }, + "sha256": "f5d6f98b22d346a32b0be68e2561b17913ff64e9b15a7c1e12f41b7c697ae1ac", + "hashMethod": "canonical-json-sha256" + } +} diff --git a/tests/fixtures/receipts/proofloop-receipt-v1/invalid-official-scorer-without-digest.json b/tests/fixtures/receipts/proofloop-receipt-v1/invalid-official-scorer-without-digest.json new file mode 100644 index 0000000..30ed702 --- /dev/null +++ b/tests/fixtures/receipts/proofloop-receipt-v1/invalid-official-scorer-without-digest.json @@ -0,0 +1,63 @@ +{ + "schema": "proofloop.receipt/v1", + "schemaVersion": 1, + "receiptId": "receipt-official-eval-no-digest", + "kind": "evaluation", + "createdAt": "2026-07-20T00:05:00.000Z", + "producer": { + "id": "nodebench", + "version": "3.2.1" + }, + "subject": { + "type": "evaluation", + "id": "benchmark-case-1", + "runId": "eval-run-1" + }, + "verdict": { + "status": "passed", + "authority": "authoritative", + "decisionMethod": "official_scorer", + "decisiveCheckIds": ["official-score"], + "summary": "This claim is invalid because the official scorer is not content-addressed." + }, + "checks": [ + { + "id": "official-score", + "status": "passed", + "role": "decisive", + "method": "official_scorer", + "summary": "Official score met threshold, but scorer identity is mutable.", + "evidenceRefs": ["official-scorer-output"], + "score": 0.92, + "threshold": 0.9, + "scorer": { + "name": "official-example-scorer", + "version": "1.0.0" + } + } + ], + "evidence": [ + { + "id": "official-scorer-output", + "kind": "official-scorer-output", + "inline": { + "score": 0.92, + "threshold": 0.9 + }, + "sha256": "b3dadb88d0a2002345be3fbee379836eb0ee930266a39b6071f185b0ecd277d3", + "hashMethod": "canonical-json-sha256" + } + ], + "payload": { + "schema": "nodebench.eval-result/v1", + "version": 1, + "mode": "inline", + "data": { + "status": "passed", + "score": 0.92, + "threshold": 0.9 + }, + "sha256": "1b23892ed9c47f7815d3f270f2f8effbd374caf82e28e293366e10ee84a4f1f6", + "hashMethod": "canonical-json-sha256" + } +} diff --git a/tests/fixtures/receipts/proofloop-receipt-v1/invalid-pass-with-failed-check.json b/tests/fixtures/receipts/proofloop-receipt-v1/invalid-pass-with-failed-check.json new file mode 100644 index 0000000..3bdeb94 --- /dev/null +++ b/tests/fixtures/receipts/proofloop-receipt-v1/invalid-pass-with-failed-check.json @@ -0,0 +1,56 @@ +{ + "schema": "proofloop.receipt/v1", + "schemaVersion": 1, + "receiptId": "receipt-invalid-failed-check", + "kind": "gate", + "createdAt": "2026-07-20T00:06:00.000Z", + "producer": { + "id": "proofloop", + "version": "0.3.0" + }, + "subject": { + "type": "repository", + "id": "nodeproof" + }, + "verdict": { + "status": "passed", + "authority": "authoritative", + "decisionMethod": "deterministic_gate", + "decisiveCheckIds": ["unit-tests"], + "summary": "This must fail because the decisive check failed." + }, + "checks": [ + { + "id": "unit-tests", + "status": "failed", + "role": "decisive", + "method": "deterministic", + "summary": "npm test exited 1.", + "evidenceRefs": ["unit-tests-output"], + "exitCode": 1 + } + ], + "evidence": [ + { + "id": "unit-tests-output", + "kind": "command-result", + "inline": { + "command": "npm test", + "exitCode": 1 + }, + "sha256": "8ee927df2893d808a6726a83be8aee673eef0220c3dae83b203c49b0c58a1117", + "hashMethod": "canonical-json-sha256" + } + ], + "payload": { + "schema": "proofloop-gate-v1", + "version": 1, + "mode": "inline", + "data": { + "schema": "proofloop-gate-v1", + "status": "failed" + }, + "sha256": "25918622e120c5dccbda9d6957ab5dedb82b44a8dc121ccaeddaf4e728dbed86", + "hashMethod": "canonical-json-sha256" + } +} diff --git a/tests/fixtures/receipts/proofloop-receipt-v1/valid-gate.json b/tests/fixtures/receipts/proofloop-receipt-v1/valid-gate.json new file mode 100644 index 0000000..b048b2b --- /dev/null +++ b/tests/fixtures/receipts/proofloop-receipt-v1/valid-gate.json @@ -0,0 +1,98 @@ +{ + "$schema": "https://nodeproof.dev/schemas/proofloop-receipt-v1.schema.json", + "schema": "proofloop.receipt/v1", + "schemaVersion": 1, + "receiptId": "receipt-gate-pass", + "kind": "gate", + "createdAt": "2026-07-20T00:00:00.000Z", + "producer": { + "id": "proofloop", + "version": "0.3.0" + }, + "subject": { + "type": "repository", + "id": "nodeproof", + "repository": { + "url": "https://github.com/HomenShum/NodeProof", + "candidateCommit": "1111111111111111111111111111111111111111", + "branch": "main", + "dirty": false + } + }, + "claim": { + "text": "The configured repository gate passed.", + "boundary": "product_path", + "tier": "local_ready" + }, + "verdict": { + "status": "passed", + "authority": "authoritative", + "decisionMethod": "deterministic_gate", + "decisiveCheckIds": ["unit-tests"], + "summary": "The deterministic unit-test gate exited successfully." + }, + "checks": [ + { + "id": "unit-tests", + "status": "passed", + "role": "decisive", + "method": "deterministic", + "summary": "npm test exited 0.", + "evidenceRefs": ["unit-tests-output"], + "durationMs": 1234, + "exitCode": 0 + } + ], + "evidence": [ + { + "id": "unit-tests-output", + "kind": "command-result", + "inline": { + "command": "npm test", + "exitCode": 0, + "status": "passed" + }, + "sha256": "1083dc2133a47aed3d14ec88d94f11242c8b78d394f21feee464d511cf26b7ec", + "hashMethod": "canonical-json-sha256", + "mediaType": "application/json", + "visibility": "team", + "redacted": true + } + ], + "payload": { + "schema": "proofloop-gate-v1", + "version": 1, + "mode": "inline", + "data": { + "schema": "proofloop-gate-v1", + "status": "passed", + "checks": [ + { + "name": "unit-tests", + "command": "npm test", + "pass": true, + "exitCode": 0, + "ms": 1234 + } + ], + "source": "config-checks" + }, + "sha256": "6acf9b0872de320d3fbbf92b58c0fb77607e8644d9f58b432f429134efc8fc39", + "hashMethod": "canonical-json-sha256" + }, + "timing": { + "durationMs": 1234, + "phases": [ + { + "id": "unit-tests", + "durationMs": 1234 + } + ] + }, + "privacy": { + "visibility": "team", + "redacted": true, + "containsPersonalData": false, + "externalEgress": false + } +} diff --git a/tests/fixtures/receipts/proofloop-receipt-v1/valid-hosted-informational.json b/tests/fixtures/receipts/proofloop-receipt-v1/valid-hosted-informational.json new file mode 100644 index 0000000..5012445 --- /dev/null +++ b/tests/fixtures/receipts/proofloop-receipt-v1/valid-hosted-informational.json @@ -0,0 +1,43 @@ +{ + "schema": "proofloop.receipt/v1", + "schemaVersion": 1, + "receiptId": "receipt-hosted-plan", + "kind": "hosted-run-plan", + "createdAt": "2026-07-20T00:02:00.000Z", + "producer": { + "id": "proofloop-hosted", + "version": "0.3.0" + }, + "subject": { + "type": "deployment", + "id": "example-app", + "runId": "hosted-example-1", + "targetUrl": "https://example.com" + }, + "verdict": { + "status": "incomplete", + "authority": "informational", + "decisionMethod": "none", + "decisiveCheckIds": [], + "summary": "The hosted bundle is a plan; no worker verdict exists yet." + }, + "checks": [], + "evidence": [], + "payload": { + "schema": "proofloop-hosted-run-bundle-v1", + "version": 1, + "mode": "inline", + "data": { + "schema": "proofloop-hosted-run-bundle-v1", + "runId": "hosted-example-1", + "permission": { + "status": "pending" + }, + "runner": { + "mode": "external-managed-worker" + } + }, + "sha256": "636f432b495fdda597838a264afb4a168e2148da5fecb98061aa48c92a7c17d4", + "hashMethod": "canonical-json-sha256" + } +} diff --git a/tests/fixtures/receipts/proofloop-receipt-v1/valid-official-eval.json b/tests/fixtures/receipts/proofloop-receipt-v1/valid-official-eval.json new file mode 100644 index 0000000..b1cb7a8 --- /dev/null +++ b/tests/fixtures/receipts/proofloop-receipt-v1/valid-official-eval.json @@ -0,0 +1,72 @@ +{ + "schema": "proofloop.receipt/v1", + "schemaVersion": 1, + "receiptId": "receipt-official-eval", + "kind": "evaluation", + "createdAt": "2026-07-20T00:04:00.000Z", + "producer": { + "id": "nodebench", + "version": "3.2.1" + }, + "subject": { + "type": "evaluation", + "id": "benchmark-case-1", + "runId": "eval-run-1" + }, + "claim": { + "text": "The candidate passed the official scorer threshold.", + "boundary": "official", + "tier": "certification_ready" + }, + "verdict": { + "status": "passed", + "authority": "authoritative", + "decisionMethod": "official_scorer", + "decisiveCheckIds": ["official-score"], + "summary": "The official scorer returned 0.92 against a 0.90 threshold." + }, + "checks": [ + { + "id": "official-score", + "status": "passed", + "role": "decisive", + "method": "official_scorer", + "summary": "Official score met threshold.", + "evidenceRefs": ["official-scorer-output"], + "score": 0.92, + "threshold": 0.9, + "scorer": { + "name": "official-example-scorer", + "version": "1.0.0", + "digest": "2222222222222222222222222222222222222222222222222222222222222222" + } + } + ], + "evidence": [ + { + "id": "official-scorer-output", + "kind": "official-scorer-output", + "inline": { + "candidateProducedAt": "2026-07-20T00:03:00.000Z", + "evaluatorAccessedAt": "2026-07-20T00:03:30.000Z", + "score": 0.92, + "threshold": 0.9 + }, + "sha256": "32b5f2152a9915b602ecdac00afec3dcaf14c627a22d583b3e2caf1334dee4f9", + "hashMethod": "canonical-json-sha256" + } + ], + "payload": { + "schema": "nodebench.eval-result/v1", + "version": 1, + "mode": "inline", + "data": { + "status": "passed", + "score": 0.92, + "threshold": 0.9, + "scorer": "official-example-scorer@1.0.0" + }, + "sha256": "0525a00b771dce844af12768208ff3b3d7dc60ff6a7177374f50273f6cc2389e", + "hashMethod": "canonical-json-sha256" + } +} diff --git a/tests/fixtures/receipts/proofloop-receipt-v1/valid-solo-advisory.json b/tests/fixtures/receipts/proofloop-receipt-v1/valid-solo-advisory.json new file mode 100644 index 0000000..5a2c4ea --- /dev/null +++ b/tests/fixtures/receipts/proofloop-receipt-v1/valid-solo-advisory.json @@ -0,0 +1,49 @@ +{ + "schema": "proofloop.receipt/v1", + "schemaVersion": 1, + "receiptId": "receipt-solo-advisory", + "kind": "solo-interop", + "createdAt": "2026-07-20T00:01:00.000Z", + "producer": { + "id": "solo-founder-agent-builder", + "version": "0.1.0" + }, + "subject": { + "type": "run", + "id": "solo-run-1", + "runId": "solo-run-1" + }, + "claim": { + "text": "The Solo workflow reports local readiness.", + "boundary": "product_path", + "tier": "local_ready" + }, + "verdict": { + "status": "passed", + "authority": "advisory", + "decisionMethod": "external_claim", + "decisiveCheckIds": [], + "summary": "Imported Solo status remains advisory until NodeProof derives a gate." + }, + "checks": [], + "evidence": [], + "payload": { + "schema": "proofloop-solo-interop-v1", + "version": 1, + "mode": "inline", + "data": { + "schema": "proofloop-solo-interop-v1", + "sourceVerdict": { + "authority": "advisory", + "status": "advisory_pass" + }, + "programId": "program-1", + "goalId": "goal-1" + }, + "sha256": "89dad9c58705b086fbe709bfdc9122fa4dcaa8d047d941b9c2f37decd89272cf", + "hashMethod": "canonical-json-sha256" + }, + "lineage": { + "migration": "Wrap the original Solo envelope without promoting sourceVerdict authority." + } +} diff --git a/tests/fixtures/receipts/proofloop-receipt-v1/valid-ui-qa.json b/tests/fixtures/receipts/proofloop-receipt-v1/valid-ui-qa.json new file mode 100644 index 0000000..7aaea46 --- /dev/null +++ b/tests/fixtures/receipts/proofloop-receipt-v1/valid-ui-qa.json @@ -0,0 +1,81 @@ +{ + "schema": "proofloop.receipt/v1", + "schemaVersion": 1, + "receiptId": "receipt-ui-qa", + "kind": "ui-qa", + "createdAt": "2026-07-20T00:03:00.000Z", + "producer": { + "id": "agentic-ui-qa", + "version": "0.1.0" + }, + "subject": { + "type": "deployment", + "id": "ui-preview", + "targetUrl": "https://example.com/preview" + }, + "verdict": { + "status": "passed", + "authority": "authoritative", + "decisionMethod": "deterministic_gate", + "decisiveCheckIds": ["live-signal"], + "summary": "The deterministic live signal passed; the visual judge remains advisory." + }, + "checks": [ + { + "id": "live-signal", + "status": "passed", + "role": "decisive", + "method": "deterministic", + "summary": "Expected production DOM signals were present.", + "evidenceRefs": ["dom-observation"] + }, + { + "id": "visual-judge", + "status": "passed", + "role": "advisory", + "method": "model_judge", + "summary": "The visual judge reported no P0 or P1 finding.", + "evidenceRefs": ["visual-judge-output"] + } + ], + "evidence": [ + { + "id": "dom-observation", + "kind": "dom-assertion", + "inline": { + "selector": "[data-testid=run-status]", + "text": "completed", + "present": true + }, + "sha256": "8f9747033f3bf137959f88b559c7689af7386496d8579e9ca47bc6c7333fe742", + "hashMethod": "canonical-json-sha256" + }, + { + "id": "visual-judge-output", + "kind": "model-judge-output", + "inline": { + "model": "vision-reviewer", + "verdict": "publish", + "p0": 0, + "p1": 0 + }, + "sha256": "e413819b3c7b3cdbccd6fc47fb1c1eb04ef73f712e2b674256423a5c441df558", + "hashMethod": "canonical-json-sha256" + } + ], + "payload": { + "schema": "agentic-ui-qa-gate-v1", + "version": 1, + "mode": "inline", + "data": { + "schema": "agentic-ui-qa-gate-v1", + "status": "passed", + "blocks": [], + "prettify": { + "advisory": true + } + }, + "sha256": "d1e5e3d26647d16be770db418915c486ba049b359f33f38db4a7c108dc38f5e9", + "hashMethod": "canonical-json-sha256" + } +} diff --git a/tests/proofReceipt.test.ts b/tests/proofReceipt.test.ts new file mode 100644 index 0000000..7e666c4 --- /dev/null +++ b/tests/proofReceipt.test.ts @@ -0,0 +1,169 @@ +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { runCli } from "../src/cli"; +import { + PROOFLOOP_RECEIPT_SCHEMA, + canonicalJson, + createInlineProofReceiptPayload, + createInlineProofReceiptResource, + proofReceiptSchemaPath, + readProofReceiptSchema, + sha256CanonicalJson, + validateProofReceiptEnvelope, + verifyProofReceiptEnvelopeFile, + type ProofReceiptEnvelope, +} from "../src/proofReceipt"; + +const FIXTURE_ROOT = join(process.cwd(), "tests", "fixtures", "receipts", "proofloop-receipt-v1"); +const SCHEMA_DIGEST = "26b28b9453b31350261737671c48e5dc2adbc30da8886d7f7e74bd8cb52a1e36"; +const VALID_FIXTURES = [ + "valid-gate.json", + "valid-solo-advisory.json", + "valid-hosted-informational.json", + "valid-ui-qa.json", + "valid-official-eval.json", +]; +const tempRoots: string[] = []; + +afterEach(() => { + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function fixture(name: string): unknown { + return JSON.parse(readFileSync(join(FIXTURE_ROOT, name), "utf8")); +} + +function tempRoot(): string { + const root = mkdtempSync(join(tmpdir(), "proofloop-receipt-envelope-")); + tempRoots.push(root); + return root; +} + +describe("proofloop.receipt/v1", () => { + it("exports the packaged JSON Schema with the authority boundary", () => { + const schema = readProofReceiptSchema() as Record; + const text = JSON.stringify(schema); + + expect(proofReceiptSchemaPath()).toContain("schemas"); + expect(schema.$id).toBe("https://nodeproof.dev/schemas/proofloop-receipt-v1.schema.json"); + expect(createHash("sha256").update(JSON.stringify(schema)).digest("hex")).toBe(SCHEMA_DIGEST); + expect(text).toContain(PROOFLOOP_RECEIPT_SCHEMA); + expect(text).toContain("deterministic_gate"); + expect(text).toContain("official_scorer"); + expect(text).toContain("Wrapped payloads never transfer verdict authority implicitly"); + }); + + it.each(VALID_FIXTURES)("accepts conformance fixture %s", (name) => { + const result = validateProofReceiptEnvelope(fixture(name)); + expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]); + expect(result.ok).toBe(true); + }); + + it("rejects a model judge promoted to authoritative", () => { + const result = validateProofReceiptEnvelope(fixture("invalid-authoritative-model-judge.json")); + + expect(result.ok).toBe(false); + expect(result.errors.some((entry) => entry.code === "authority_violation")).toBe(true); + }); + + it("rejects an authoritative pass when a decisive check failed", () => { + const result = validateProofReceiptEnvelope(fixture("invalid-pass-with-failed-check.json")); + + expect(result.ok).toBe(false); + expect(result.errors).toContainEqual(expect.objectContaining({ code: "verdict_mismatch" })); + }); + + it("rejects an official scorer without immutable scorer identity", () => { + const result = validateProofReceiptEnvelope(fixture("invalid-official-scorer-without-digest.json")); + + expect(result.ok).toBe(false); + expect(result.errors).toContainEqual(expect.objectContaining({ code: "missing_scorer_digest" })); + }); + + it("hashes inline payloads and evidence with stable sorted-key canonical JSON", () => { + const left = { z: 1, a: { y: true, b: [2, 1] } }; + const right = { a: { b: [2, 1], y: true }, z: 1 }; + const payload = createInlineProofReceiptPayload("example.payload/v1", left, 1); + const evidence = createInlineProofReceiptResource({ id: "example-evidence", kind: "example", inline: right }); + + expect(canonicalJson(left)).toBe(canonicalJson(right)); + expect(payload.sha256).toBe(sha256CanonicalJson(right)); + expect(evidence.sha256).toBe(payload.sha256); + }); + + it("verifies referenced payload and evidence bytes and fails after tampering", () => { + const root = tempRoot(); + const payloadText = "{\n \"schema\": \"legacy-gate-v1\",\n \"status\": \"passed\"\n}\n"; + const evidenceText = "command=npm test\nexitCode=0\n"; + writeFileSync(join(root, "legacy-gate.json"), payloadText, "utf8"); + writeFileSync(join(root, "gate-output.txt"), evidenceText, "utf8"); + const hash = (text: string) => createHash("sha256").update(text, "utf8").digest("hex"); + const envelope: ProofReceiptEnvelope = { + schema: PROOFLOOP_RECEIPT_SCHEMA, + schemaVersion: 1, + receiptId: "receipt-reference", + kind: "gate", + createdAt: "2026-07-20T00:00:00.000Z", + producer: { id: "proofloop", version: "0.3.0" }, + subject: { type: "repository", id: "example-repository" }, + verdict: { + status: "passed", + authority: "authoritative", + decisionMethod: "deterministic_gate", + decisiveCheckIds: ["gate"], + summary: "The referenced deterministic gate passed.", + }, + checks: [{ + id: "gate", + status: "passed", + role: "decisive", + method: "deterministic", + summary: "The command exited 0.", + evidenceRefs: ["gate-output"], + exitCode: 0, + }], + evidence: [{ + id: "gate-output", + kind: "command-output", + path: "gate-output.txt", + sha256: hash(evidenceText), + hashMethod: "raw-bytes-sha256", + }], + payload: { + schema: "legacy-gate-v1", + version: 1, + mode: "reference", + ref: "legacy-gate.json", + sha256: hash(payloadText), + hashMethod: "raw-bytes-sha256", + }, + }; + const receiptPath = join(root, "receipt.json"); + writeFileSync(receiptPath, JSON.stringify(envelope, null, 2), "utf8"); + + expect(verifyProofReceiptEnvelopeFile({ root, filePath: receiptPath }).ok).toBe(true); + + writeFileSync(join(root, "gate-output.txt"), `${evidenceText}tampered=true\n`, "utf8"); + const tampered = verifyProofReceiptEnvelopeFile({ root, filePath: receiptPath }); + expect(tampered.ok).toBe(false); + expect(tampered.errors).toContainEqual(expect.objectContaining({ code: "hash_mismatch" })); + }); + + it("exposes schema discovery and envelope verification through the CLI", () => { + const messages: string[] = []; + const originalLog = console.log; + try { + console.log = (message?: unknown) => messages.push(String(message)); + expect(runCli(["receipt", "schema"])).toBe(0); + expect(runCli(["receipt", "envelope", "verify", "--file", join(FIXTURE_ROOT, "valid-gate.json"), "--json"])).toBe(0); + } finally { + console.log = originalLog; + } + + expect(messages.join("\n")).toContain("proofloop-receipt-v1.schema.json"); + expect(messages.join("\n")).toContain("\"ok\": true"); + }); +});