diff --git a/README.md b/README.md index 55880ef..48e218b 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,7 @@ npx proofloop productivity --write --baseline-source benchmark # verified produc npx proofloop prompt # kickoff prompt to paste into your coding agent npx proofloop this-repo --goal "proofloop my latest updates" --write-runner-plan npx proofloop runner run --plan proofloop.runner.json --budget-usd 100 +npx proofloop program run --plan proofloop.program.json --budget-usd 25 npx proofloop gate # run checks -> .proofloop/gate-state.json ``` @@ -294,6 +295,98 @@ you want the CLI to execute the plan with append-only state, budget control, and npx proofloop this-repo --goal "proofloop my latest updates" --write-runner-plan --run --budget-usd 100 ``` +## Durable Program Supervisor (P0) + +`proofloop program` coordinates a small dependency-safe program by invoking the existing durable +runner once per arc. It is intentionally not a second shell runner. Each arc points to an immutable +`proofloop-runner-plan-v1` subplan, runs sequentially after its dependencies pass, and may require a +locally verified ProofLoop receipt. + +P0 admits only `read_only` and `proposal_only` arcs. Authority is a separate JSON file whose +canonical digest is pinned into durable program state. Any authority, program, or referenced runner +plan change blocks or fails the existing run rather than silently continuing. Explicit external +egress is rejected. Failed arcs are not automatically requeued; only an interrupted `running` arc +may recover through the existing runner's explicit stale-lock recovery path. + +```json +// authority.json +{ + "schema": "proofloop-program-authority-v1", + "authorityId": "overnight-read-propose-only", + "allowedArcModes": ["read_only", "proposal_only"], + "allowExternalEgress": false, + "maxBudgetUsd": 25, + "maxAttemptsPerArc": 1 +} +``` + +```json +// proofloop.program.json +{ + "schema": "proofloop-program-plan-v1", + "programId": "nodekit-ultra-v1", + "authorityPath": "authority.json", + "arcs": [ + { + "id": "baseline", + "mode": "read_only", + "runnerPlan": "plans/baseline.runner.json" + }, + { + "id": "proposal", + "mode": "proposal_only", + "runnerPlan": "plans/proposal.runner.json", + "dependsOn": ["baseline"], + "receipt": { "kind": "proofloop-envelope", "file": "proof/proposal-receipt.json" } + } + ] +} +``` + +```bash +npx proofloop program run --plan proofloop.program.json --budget-usd 25 +npx proofloop program resume --run-id latest +npx proofloop program status --run-id latest --json +npx proofloop program report --run-id latest +``` + +This is a local P0 safety boundary, not an OS sandbox. Runner subplans still require an execution +environment that independently enforces network, credential, browser, deployment, and publish +authority. + +### NodeKit compiled-proof binding + +NodeKit-generated `proof/release-proof.json` is not sufficient by itself to certify an application: +it must also bind to the exact candidate commit and the compiler's current resolved definition. + +```bash +npx proofloop program verify-nodekit \ + --file proof/release-proof.json \ + --candidate-commit "$(git rev-parse HEAD)" \ + --minimum-level local-ready +``` + +The verifier stays local and read-only. It fails closed when the candidate commit, compiled +`configHash`, raw `nodeagent.yaml` manifest digest, discovered source-file bytes, deterministic +demo/evaluation receipts, or required release receipts disagree. `--minimum-level release-ready` +also requires the live, browser, and deployment receipts NodeKit declares as release gates. + +An arc can use the same binding rather than a generic receipt: + +```json +{ + "kind": "nodekit-proof", + "file": "proof/release-proof.json", + "candidateCommit": "<40-or-64-char-lowercase-git-sha>", + "minimumLevel": "local-ready" +} +``` + +The binding validates the files NodeKit's compiler discovered. It deliberately does not claim to +cover source files omitted from that discovery contract; widening compiler discovery remains a +NodeKit compiler responsibility. It also verifies already-produced local receipts only: it never +deploys, invokes a provider, publishes, or promotes a candidate. + ## How The Stop Gate Decides - Default check-only mode reads `.proofloop/gate-state.json` with no subprocess or network call. @@ -358,6 +451,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. | @@ -365,6 +460,10 @@ script. With neither, it reports `no_gate` with exit code 2. An unconfigured gat | `proofloop runner resume --run-id latest --clear-stale-lock` | Resume a runner after a crash; stale `running` tasks are requeued after explicit stale-lock clearance. | | `proofloop runner status --run-id latest [--json]` | Inspect durable runner state and ledger paths. | | `proofloop runner report --run-id latest [--json]` | Print the runner honesty report with per-family/per-model pass rate and estimated cost/pass. | +| `proofloop program run --plan --budget-usd ` | Run a P0 read/proposal-only program as dependency-safe runner subplans under `.proofloop/programs/runs//`. | +| `proofloop program resume --run-id latest` | Resume queued arcs, or explicitly recover an interrupted running arc through the runner. Authority or referenced-plan changes fail closed; failed arcs are not requeued. | +| `proofloop program status\|report --run-id latest [--json]` | Inspect the pinned authority digest, program state, arc statuses, budget, and ledger. | +| `proofloop program verify-nodekit --file --candidate-commit ` | Locally bind a NodeKit proof receipt to the exact checked-out candidate and compiler discovery; no deploy or external action occurs. | | `proofloop mcp` | Start the optional read-only MCP server. | | `proofloop gate [--check]` | Run configured checks or `npm test`; exit 0 pass, 1 fail, 2 unusable. | | `proofloop hooks install\|uninstall\|status` | Install/remove/status Claude Code Stop, PreToolUse, and PostToolUse hooks. | @@ -421,6 +520,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..0d24596 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -37,9 +37,13 @@ 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"); +const program_1 = require("./program"); +const nodekitProof_1 = require("./nodekitProof"); +const easeProof_1 = require("./easeProof"); const targetPlan_1 = require("./targetPlan"); const hosted_1 = require("./hosted"); const maturity_1 = require("./maturity"); @@ -113,11 +117,15 @@ 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", + " ease verify --manifest [--out ] verify NodeKit EaseProof evidence integrity without inventing usability authority", " 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 ", " solo verify-attestation --file [--public-key-file ] [--key-id ]", " runner run|resume|status|report durable append-only task runner with budget and resume", + " program run|resume|status|report|verify-nodekit P0 program supervisor and local NodeKit proof binding", " hosted intake|validate|dashboard|run create or resume a hosted URL proof packet", " target [--url ] [--write-runner-plan] [--write-browser-smoke] recommend benchmark families and write target/context receipts", " maturity [--dense|--json|--write] [--target-level 5] judge agent-era codebase/app maturity and missing layers", @@ -190,11 +198,15 @@ 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 "ease": + return runEaseCommand(positional[1], options, root); case "solo": return runSoloCommand(positional[1], options, root); case "runner": return runRunnerCommand(positional[1], options, root); + case "program": + return runProgramCommand(positional[1], options, root); case "hosted": return runHostedCommand(positional[1], options, root); case "target": @@ -743,9 +755,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); @@ -767,6 +806,19 @@ function runReceiptCommand(sub, options, root) { json: options.json === true, }); } +function runEaseCommand(sub, options, root) { + if (sub !== "verify") { + console.error("proofloop ease: expected `verify`."); + return 2; + } + const manifestPath = str(options.manifest) ?? "proof/ease/latest/manifest.json"; + return (0, easeProof_1.runEaseProofVerify)({ + root, + manifestPath, + ...(str(options.out) !== undefined ? { outputPath: str(options.out) } : {}), + json: options.json === true, + }); +} async function runRunnerCommand(sub, options, root) { if (sub !== "run" && sub !== "resume" && sub !== "status" && sub !== "report") { console.error("proofloop runner: expected `run`, `resume`, `status`, or `report`."); @@ -786,6 +838,47 @@ async function runRunnerCommand(sub, options, root) { }); return result.exitCode; } +async function runProgramCommand(sub, options, root) { + if (sub === "verify-nodekit") { + const releaseProofPath = str(options.file); + const candidateCommit = str(options["candidate-commit"]); + if (!releaseProofPath || !candidateCommit) { + console.error("proofloop program verify-nodekit: requires --file and --candidate-commit ."); + return 2; + } + const minimumLevel = str(options["minimum-level"]); + if (minimumLevel !== undefined && minimumLevel !== "local-ready" && minimumLevel !== "release-ready") { + console.error("proofloop program verify-nodekit: --minimum-level must be local-ready or release-ready."); + return 2; + } + return (0, nodekitProof_1.runNodekitProofBindingVerify)({ + root, + releaseProofPath, + candidateCommit, + ...(minimumLevel !== undefined ? { minimumLevel: minimumLevel } : {}), + ...(str(options["compiled-definition"]) !== undefined ? { compiledDefinitionPath: str(options["compiled-definition"]) } : {}), + ...(str(options["config-hash-file"]) !== undefined ? { configHashPath: str(options["config-hash-file"]) } : {}), + ...(str(options.discovery) !== undefined ? { discoveryPath: str(options.discovery) } : {}), + json: options.json === true, + }); + } + if (sub !== "run" && sub !== "resume" && sub !== "status" && sub !== "report") { + console.error("proofloop program: expected `run`, `resume`, `status`, `report`, or `verify-nodekit`."); + return 2; + } + const result = await (0, program_1.runProofloopProgram)({ + root, + subcommand: sub, + ...(str(options.plan) !== undefined ? { planPath: str(options.plan) } : {}), + ...(str(options["run-id"]) !== undefined ? { runId: str(options["run-id"]) } : {}), + ...(num(options["budget-usd"]) !== undefined ? { budgetUsd: num(options["budget-usd"]) } : {}), + ...(num(options["max-arcs"]) !== undefined ? { maxArcs: num(options["max-arcs"]) } : {}), + ...(num(options["lock-ttl-ms"]) !== undefined ? { lockTtlMs: num(options["lock-ttl-ms"]) } : {}), + clearStaleLock: options["clear-stale-lock"] === true, + json: options.json === true, + }); + return result.exitCode; +} async function runTargetCommand(options, root) { const result = await (0, targetPlan_1.runProofloopTarget)({ root, diff --git a/dist/easeProof.d.ts b/dist/easeProof.d.ts new file mode 100644 index 0000000..f024ff3 --- /dev/null +++ b/dist/easeProof.d.ts @@ -0,0 +1,24 @@ +import { type ProofReceiptEnvelope } from "./proofReceipt"; +export interface EaseProofVerification { + ok: boolean; + easeCertified: boolean; + errors: string[]; + warnings: string[]; + manifestPath: string; + browserManifestPath?: string; + checkedScreenshots: number; + checkedReplayArtifacts: number; + envelope?: ProofReceiptEnvelope; + outputPath?: string; +} +export declare function verifyEaseProof(options: { + root: string; + manifestPath: string; + outputPath?: string; +}): EaseProofVerification; +export declare function runEaseProofVerify(options: { + root: string; + manifestPath: string; + outputPath?: string; + json?: boolean; +}): number; diff --git a/dist/easeProof.js b/dist/easeProof.js new file mode 100644 index 0000000..f786e0b --- /dev/null +++ b/dist/easeProof.js @@ -0,0 +1,215 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.verifyEaseProof = verifyEaseProof; +exports.runEaseProofVerify = runEaseProofVerify; +const node_crypto_1 = require("node:crypto"); +const node_fs_1 = require("node:fs"); +const node_path_1 = require("node:path"); +const proofReceipt_1 = require("./proofReceipt"); +function sha256(value) { + return (0, node_crypto_1.createHash)("sha256").update(value).digest("hex"); +} +function readJson(path, label, errors) { + if (!(0, node_fs_1.existsSync)(path)) { + errors.push(`${label} is missing: ${path}`); + return undefined; + } + try { + return JSON.parse((0, node_fs_1.readFileSync)(path, "utf8")); + } + catch (error) { + errors.push(`${label} is invalid JSON: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } +} +function verifyEmittedDigest(value, key, label, errors) { + const emitted = value[key]; + if (typeof emitted !== "string" || !/^[a-f0-9]{64}$/.test(emitted)) { + errors.push(`${label} ${key} is missing or invalid`); + return; + } + const covered = { ...value }; + delete covered[key]; + if (sha256(JSON.stringify(covered)) !== emitted) + errors.push(`${label} ${key} does not match content`); +} +function verifyEaseProof(options) { + const root = (0, node_path_1.resolve)(options.root); + const manifestPath = (0, node_path_1.isAbsolute)(options.manifestPath) ? options.manifestPath : (0, node_path_1.resolve)(root, options.manifestPath); + const evidenceRoot = (0, node_path_1.dirname)(manifestPath); + const errors = []; + const warnings = []; + const manifest = readJson(manifestPath, "EaseProof manifest", errors); + let browserManifest; + let checkedScreenshots = 0; + let checkedReplayArtifacts = 0; + if (manifest) { + if (manifest.schemaVersion !== "nodekit.ease-proof-run/v1") + errors.push("EaseProof manifest schemaVersion must be nodekit.ease-proof-run/v1"); + verifyEmittedDigest(manifest, "receiptDigest", "EaseProof manifest", errors); + if (!Array.isArray(manifest.base?.phases) || manifest.base.phases.length === 0) + errors.push("EaseProof phase timer ledger is missing"); + else + for (const phase of manifest.base.phases) { + if (!Number.isFinite(phase.durationMs) || phase.durationMs < 0) + errors.push(`EaseProof phase ${phase.name ?? "unknown"} has invalid durationMs`); + if (phase.exitCode !== undefined && phase.exitCode !== 0) + errors.push(`EaseProof phase ${phase.name ?? "unknown"} did not exit 0`); + } + const browserManifestPath = (0, node_path_1.resolve)(evidenceRoot, "browser", "screenshot-manifest.json"); + browserManifest = readJson(browserManifestPath, "browser screenshot manifest", errors); + if (browserManifest) { + verifyEmittedDigest(browserManifest, "manifestSha256", "browser screenshot manifest", errors); + if (manifest.base?.browserManifestDigest !== browserManifest.manifestSha256) + errors.push("factory and browser manifest digests do not match"); + const screenshots = Array.isArray(browserManifest.screenshots) ? browserManifest.screenshots : []; + if (screenshots.length === 0) + errors.push("browser screenshot manifest contains no screenshots"); + for (const screenshot of screenshots) { + const relativePath = screenshot.path; + if (typeof relativePath !== "string" || relativePath.includes("..") || (0, node_path_1.isAbsolute)(relativePath)) { + errors.push("screenshot path is unsafe"); + continue; + } + const pngPath = (0, node_path_1.resolve)(evidenceRoot, relativePath); + if (!(0, node_fs_1.existsSync)(pngPath)) { + errors.push(`screenshot is missing: ${relativePath}`); + continue; + } + checkedScreenshots += 1; + if (sha256((0, node_fs_1.readFileSync)(pngPath)) !== screenshot.pngSha256) + errors.push(`screenshot digest mismatch: ${relativePath}`); + if (screenshot.generatedCandidateCommit !== manifest.base?.candidateCommit) + errors.push(`screenshot candidate mismatch: ${relativePath}`); + if (screenshot.applicationHash !== manifest.base?.applicationHash || screenshot.configHash !== manifest.base?.configHash) + errors.push(`screenshot application identity mismatch: ${relativePath}`); + if (screenshot.nodekitSourceHash !== manifest.nodekitSourceHash) + errors.push(`screenshot NodeKit source mismatch: ${relativePath}`); + if (screenshot.consoleErrors !== 0 || screenshot.failedRequests !== 0 || screenshot.horizontalOverflowPx !== 0 || screenshot.mojibakeDetected !== false) { + errors.push(`screenshot browser checks failed: ${relativePath}`); + } + } + const replayArtifacts = Array.isArray(browserManifest.evidenceArtifacts) ? browserManifest.evidenceArtifacts : []; + const requiredReplayIds = new Set(["playwright-trace", "browser-video"]); + for (const artifact of replayArtifacts) { + const relativePath = artifact.path; + if (typeof relativePath !== "string" || relativePath.includes("..") || (0, node_path_1.isAbsolute)(relativePath)) { + errors.push("browser replay artifact path is unsafe"); + continue; + } + const artifactPath = (0, node_path_1.resolve)(evidenceRoot, relativePath); + if (!(0, node_fs_1.existsSync)(artifactPath)) { + errors.push(`browser replay artifact is missing: ${relativePath}`); + continue; + } + const bytes = (0, node_fs_1.readFileSync)(artifactPath); + checkedReplayArtifacts += 1; + if (sha256(bytes) !== artifact.sha256) + errors.push(`browser replay artifact digest mismatch: ${relativePath}`); + if (bytes.byteLength !== artifact.byteSize) + errors.push(`browser replay artifact size mismatch: ${relativePath}`); + requiredReplayIds.delete(String(artifact.id)); + } + for (const id of requiredReplayIds) + errors.push(`required browser replay artifact is missing: ${id}`); + const journeyAssertions = browserManifest.journeyAssertions; + for (const assertion of ["proposalVisible", "approvalApplied", "receiptVisible", "receiptSurvivedReload"]) { + if (journeyAssertions?.[assertion] !== true) + errors.push(`browser journey assertion failed: ${assertion}`); + } + if (!Number.isInteger(browserManifest.serverProcess?.pid) || typeof browserManifest.serverProcess?.command !== "string") { + errors.push("browser server process identity is missing"); + } + } + const candidateArchive = (0, node_path_1.resolve)(evidenceRoot, "candidate.tar.gz"); + if (!(0, node_fs_1.existsSync)(candidateArchive)) + errors.push("generated candidate archive is missing"); + const easeCertified = manifest.submissionReady === true + && Array.isArray(manifest.submissionBlockers) + && manifest.submissionBlockers.length === 0 + && browserManifest?.certified === true; + if (!easeCertified) + warnings.push("Evidence integrity may pass, but NodeKit Ease is not certified and submission remains blocked."); + const manifestBytes = (0, node_fs_1.readFileSync)(manifestPath); + const browserBytes = (0, node_fs_1.existsSync)(browserManifestPath) ? (0, node_fs_1.readFileSync)(browserManifestPath) : Buffer.from(""); + const archiveBytes = (0, node_fs_1.existsSync)(candidateArchive) ? (0, node_fs_1.readFileSync)(candidateArchive) : Buffer.from(""); + const receiptId = `ease-${String(manifest.runId ?? "unknown")}`; + const envelope = { + schema: proofReceipt_1.PROOFLOOP_RECEIPT_SCHEMA, + schemaVersion: 1, + receiptId, + kind: "nodekit-ease-integrity", + createdAt: new Date().toISOString(), + producer: { id: "proofloop", version: "0.3.0", configHash: manifest.nodekitSourceHash }, + subject: { + type: "run", + id: String(manifest.runId ?? "unknown"), + runId: String(manifest.runId ?? "unknown"), + repository: { candidateCommit: manifest.base?.candidateCommit, dirty: false }, + }, + claim: { + text: easeCertified + ? "The supplied NodeKit EaseProof evidence is internally bound and all submission gates are represented as passed." + : "The supplied NodeKit EaseProof evidence is internally bound; this receipt does not certify ease, human usability, deployment, or submission readiness.", + boundary: "proxy", + tier: easeCertified ? "certification_ready" : "local_ready", + }, + verdict: { + status: errors.length === 0 ? "passed" : "failed", + authority: "authoritative", + decisionMethod: "deterministic_gate", + decisiveCheckIds: ["ease-integrity"], + summary: errors.length === 0 ? "Local EaseProof hashes and identities verified." : "EaseProof integrity verification failed.", + }, + checks: [{ + id: "ease-integrity", + status: errors.length === 0 ? "passed" : "failed", + role: "decisive", + method: "deterministic", + summary: `${checkedScreenshots} screenshot(s), ${checkedReplayArtifacts} replay artifact(s), and the candidate/timer manifests were checked; Ease certification=${easeCertified}.`, + evidenceRefs: ["ease-manifest", "browser-manifest", "candidate-archive", "playwright-trace", "browser-video"], + }], + evidence: [ + { id: "ease-manifest", kind: "ease-manifest", path: (0, node_path_1.relative)(evidenceRoot, manifestPath).replaceAll("\\", "/") || "manifest.json", sha256: sha256(manifestBytes), hashMethod: "raw-bytes-sha256" }, + { id: "browser-manifest", kind: "screenshot-manifest", path: (0, node_path_1.relative)(evidenceRoot, browserManifestPath).replaceAll("\\", "/"), sha256: sha256(browserBytes), hashMethod: "raw-bytes-sha256" }, + { id: "candidate-archive", kind: "generated-candidate", path: (0, node_path_1.relative)(evidenceRoot, candidateArchive).replaceAll("\\", "/"), sha256: sha256(archiveBytes), hashMethod: "raw-bytes-sha256" }, + ...(browserManifest?.evidenceArtifacts ?? []).map((artifact) => ({ + id: String(artifact.id), + kind: String(artifact.id), + path: String(artifact.path), + sha256: String(artifact.sha256), + hashMethod: "raw-bytes-sha256", + })), + ], + payload: (0, proofReceipt_1.createInlineProofReceiptPayload)("nodekit.ease-verification/v1", { easeCertified, errors, warnings, checkedScreenshots, runId: manifest.runId }, 1), + timing: { startedAt: manifest.startedAt, completedAt: manifest.generatedAt, durationMs: manifest.durationMs }, + privacy: { visibility: "private", redacted: true, externalEgress: false }, + extensions: { easeCertified, submissionBlockers: manifest.submissionBlockers ?? [] }, + }; + const envelopeValidation = (0, proofReceipt_1.validateProofReceiptEnvelope)(envelope); + for (const issue of envelopeValidation.errors) + errors.push(`generated envelope ${issue.path}: ${issue.message}`); + let outputPath; + if (options.outputPath) { + outputPath = (0, node_path_1.isAbsolute)(options.outputPath) ? options.outputPath : (0, node_path_1.resolve)(root, options.outputPath); + (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(outputPath), { recursive: true }); + (0, node_fs_1.writeFileSync)(outputPath, `${JSON.stringify(envelope, null, 2)}\n`, "utf8"); + } + return { ok: errors.length === 0, easeCertified, errors, warnings, manifestPath, browserManifestPath, checkedScreenshots, checkedReplayArtifacts, envelope, ...(outputPath ? { outputPath } : {}) }; + } + return { ok: false, easeCertified: false, errors, warnings, manifestPath, checkedScreenshots, checkedReplayArtifacts }; +} +function runEaseProofVerify(options) { + const result = verifyEaseProof(options); + const rendered = options.json ? JSON.stringify(result, null, 2) : [ + `proofloop ease verify: ${result.ok ? "integrity-passed" : "failed"}`, + `easeCertified=${result.easeCertified}`, + `checkedScreenshots=${result.checkedScreenshots}`, + `checkedReplayArtifacts=${result.checkedReplayArtifacts}`, + ...result.errors.map((entry) => `FAIL ${entry}`), + ...result.warnings.map((entry) => `WARN ${entry}`), + ...(result.outputPath ? [`receipt=${result.outputPath}`] : []), + ].join("\n"); + (result.ok ? console.log : console.error)(rendered); + return result.ok ? 0 : 1; +} diff --git a/dist/index.d.ts b/dist/index.d.ts index 3cf35c3..85aaef6 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -17,6 +17,7 @@ export * from "./scaffoldConstants"; export * from "./project"; export * from "./mcp"; export * from "./runner"; +export * from "./program"; export * from "./layeredPlan"; export * from "./targetPlan"; export * from "./hosted"; @@ -24,6 +25,9 @@ export * from "./maturity"; export * from "./productivity"; export * from "./contextReport"; export * from "./receipts"; +export * from "./proofReceipt"; +export * from "./nodekitProof"; +export * from "./easeProof"; export * from "./agentAdapters"; export * from "./agentLoop"; export * from "./codexRelaunch"; diff --git a/dist/index.js b/dist/index.js index f07992a..dfc7520 100644 --- a/dist/index.js +++ b/dist/index.js @@ -34,6 +34,7 @@ __exportStar(require("./scaffoldConstants"), exports); __exportStar(require("./project"), exports); __exportStar(require("./mcp"), exports); __exportStar(require("./runner"), exports); +__exportStar(require("./program"), exports); __exportStar(require("./layeredPlan"), exports); __exportStar(require("./targetPlan"), exports); __exportStar(require("./hosted"), exports); @@ -41,6 +42,9 @@ __exportStar(require("./maturity"), exports); __exportStar(require("./productivity"), exports); __exportStar(require("./contextReport"), exports); __exportStar(require("./receipts"), exports); +__exportStar(require("./proofReceipt"), exports); +__exportStar(require("./nodekitProof"), exports); +__exportStar(require("./easeProof"), exports); __exportStar(require("./agentAdapters"), exports); __exportStar(require("./agentLoop"), exports); __exportStar(require("./codexRelaunch"), exports); diff --git a/dist/nodekitProof.d.ts b/dist/nodekitProof.d.ts new file mode 100644 index 0000000..9ba578d --- /dev/null +++ b/dist/nodekitProof.d.ts @@ -0,0 +1,60 @@ +/** + * NodeKit's generated applications currently emit `nodekit.proof-receipt/v1` + * as their local/release proof. That receipt is useful, but it does not carry + * the candidate commit or compiled application identity itself. This module + * binds the receipt to the checked-out candidate and the compiler outputs + * before a ProofLoop program may treat it as a passing arc. + * + * This is intentionally local-only. It does not deploy, invoke providers, or + * create a promotion claim. It verifies bytes already present in the project. + */ +export declare const NODEKIT_PROOF_RECEIPT_SCHEMA: "nodekit.proof-receipt/v1"; +export declare const NODEKIT_COMPILED_DEFINITION_SCHEMA: "nodeagent.resolved/v1"; +export declare const NODEKIT_DISCOVERY_SCHEMA: "nodeagent.discovery/v1"; +export type NodekitProofMinimumLevel = "local-ready" | "release-ready"; +export type VerifyNodekitProofBindingOptions = { + root: string; + releaseProofPath: string; + candidateCommit: string; + minimumLevel?: NodekitProofMinimumLevel; + compiledDefinitionPath?: string; + configHashPath?: string; + discoveryPath?: string; +}; +export type NodekitProofGateReceipt = { + id: string; + path: string; + sha256?: string; + ok: boolean; + errors: string[]; +}; +export type NodekitProofApplicationIdentity = { + configHash: string; + manifestDigest: string; + discoveryDigest: string; + fileCount: number; + candidateCommit: string; + observedCandidateCommit?: string; +}; +export type NodekitProofBindingVerification = { + schema: "proofloop-nodekit-proof-binding-v1"; + ok: boolean; + releaseProofPath: string; + candidateCommit: string; + minimumLevel: NodekitProofMinimumLevel; + errors: string[]; + gateReceipts: NodekitProofGateReceipt[]; + identity?: NodekitProofApplicationIdentity; +}; +/** + * Verify a generated NodeKit local/release proof against compiler outputs and + * the current Git candidate. The result is evidence only; callers decide how + * it contributes to a larger program verdict. + */ +export declare function verifyNodekitProofBinding(options: VerifyNodekitProofBindingOptions): NodekitProofBindingVerification; +export declare function formatNodekitProofBindingVerification(result: NodekitProofBindingVerification): string; +export declare function runNodekitProofBindingVerify(options: VerifyNodekitProofBindingOptions & { + json?: boolean; + log?: (message: string) => void; + logError?: (message: string) => void; +}): number; diff --git a/dist/nodekitProof.js b/dist/nodekitProof.js new file mode 100644 index 0000000..b5e61c7 --- /dev/null +++ b/dist/nodekitProof.js @@ -0,0 +1,511 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.NODEKIT_DISCOVERY_SCHEMA = exports.NODEKIT_COMPILED_DEFINITION_SCHEMA = exports.NODEKIT_PROOF_RECEIPT_SCHEMA = void 0; +exports.verifyNodekitProofBinding = verifyNodekitProofBinding; +exports.formatNodekitProofBindingVerification = formatNodekitProofBindingVerification; +exports.runNodekitProofBindingVerify = runNodekitProofBindingVerify; +const node_child_process_1 = require("node:child_process"); +const node_crypto_1 = require("node:crypto"); +const node_fs_1 = require("node:fs"); +const node_path_1 = require("node:path"); +/** + * NodeKit's generated applications currently emit `nodekit.proof-receipt/v1` + * as their local/release proof. That receipt is useful, but it does not carry + * the candidate commit or compiled application identity itself. This module + * binds the receipt to the checked-out candidate and the compiler outputs + * before a ProofLoop program may treat it as a passing arc. + * + * This is intentionally local-only. It does not deploy, invoke providers, or + * create a promotion claim. It verifies bytes already present in the project. + */ +exports.NODEKIT_PROOF_RECEIPT_SCHEMA = "nodekit.proof-receipt/v1"; +exports.NODEKIT_COMPILED_DEFINITION_SCHEMA = "nodeagent.resolved/v1"; +exports.NODEKIT_DISCOVERY_SCHEMA = "nodeagent.discovery/v1"; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const GIT_SHA_PATTERN = /^[a-f0-9]{40,64}$/; +const NODEKIT_SCHEMA_PATTERN = /^nodekit\.[a-z0-9][a-z0-9._-]*\/v\d+$/; +const DEFAULT_DEFINITION_PATH = ".nodeagent/resolved-definition.json"; +const DEFAULT_CONFIG_HASH_PATH = ".nodeagent/config-hash.txt"; +const DEFAULT_DISCOVERY_PATH = ".nodeagent/discovery.json"; +/** + * Verify a generated NodeKit local/release proof against compiler outputs and + * the current Git candidate. The result is evidence only; callers decide how + * it contributes to a larger program verdict. + */ +function verifyNodekitProofBinding(options) { + const root = (0, node_path_1.resolve)(options.root); + const minimumLevel = options.minimumLevel ?? "local-ready"; + const errors = []; + const gateReceipts = []; + const releaseProofPath = displayPath(options.releaseProofPath); + if (!GIT_SHA_PATTERN.test(options.candidateCommit)) { + errors.push("candidateCommit must be a lowercase 40-64 character Git SHA"); + } + if (minimumLevel !== "local-ready" && minimumLevel !== "release-ready") { + errors.push("minimumLevel must be local-ready or release-ready"); + } + const observedCandidateCommit = readCurrentCommit(root, errors); + if (observedCandidateCommit && observedCandidateCommit !== options.candidateCommit) { + errors.push(`candidate commit mismatch: expected ${options.candidateCommit}, observed ${observedCandidateCommit}`); + } + const releasePath = resolveRegularFile(root, options.releaseProofPath, "NodeKit release proof", errors); + const definitionPath = resolveRegularFile(root, options.compiledDefinitionPath ?? DEFAULT_DEFINITION_PATH, "NodeKit compiled definition", errors); + const configPath = resolveRegularFile(root, options.configHashPath ?? DEFAULT_CONFIG_HASH_PATH, "NodeKit config hash", errors); + const discoveryPath = resolveRegularFile(root, options.discoveryPath ?? DEFAULT_DISCOVERY_PATH, "NodeKit discovery", errors); + const compiled = definitionPath ? readCompiledDefinition(definitionPath, errors) : undefined; + const configHash = configPath ? readConfigHash(configPath, errors) : undefined; + const discovery = discoveryPath ? readDiscovery(discoveryPath, errors) : undefined; + const releaseProof = releasePath ? readJsonRecord(releasePath, "NodeKit release proof", errors) : undefined; + let identity; + if (compiled && configHash && discovery && discoveryPath) { + if (compiled.configHash !== configHash) { + errors.push("compiled definition configHash does not match .nodeagent/config-hash.txt"); + } + if (compiled.fileCount !== discovery.files.length) { + errors.push(`compiled definition fileCount ${compiled.fileCount} does not match discovery file count ${discovery.files.length}`); + } + verifyManifestDigest(root, compiled.manifestDigest, options.candidateCommit, errors); + verifyDiscoveryFiles(root, discovery.files, options.candidateCommit, errors); + identity = { + configHash: compiled.configHash, + manifestDigest: compiled.manifestDigest, + discoveryDigest: sha256((0, node_fs_1.readFileSync)(discoveryPath)), + fileCount: compiled.fileCount, + candidateCommit: options.candidateCommit, + ...(observedCandidateCommit ? { observedCandidateCommit } : {}), + }; + } + if (releaseProof && releasePath) { + verifyReleaseProof({ + root, + releasePath, + releaseProof, + minimumLevel, + compiledConfigHash: compiled?.configHash, + candidateCommit: options.candidateCommit, + errors, + gateReceipts, + }); + } + return { + schema: "proofloop-nodekit-proof-binding-v1", + ok: errors.length === 0 && gateReceipts.every((receipt) => receipt.ok), + releaseProofPath, + candidateCommit: options.candidateCommit, + minimumLevel, + errors, + gateReceipts, + ...(identity ? { identity } : {}), + }; +} +function formatNodekitProofBindingVerification(result) { + const lines = [ + `schema=${result.schema}`, + `status=${result.ok ? "passed" : "failed"}`, + `releaseProof=${result.releaseProofPath}`, + `candidateCommit=${result.candidateCommit}`, + `minimumLevel=${result.minimumLevel}`, + ]; + if (result.identity) { + lines.push(`configHash=${result.identity.configHash}`); + lines.push(`discoveryDigest=${result.identity.discoveryDigest}`); + lines.push(`fileCount=${result.identity.fileCount}`); + } + lines.push("gateReceipts:"); + for (const receipt of result.gateReceipts) { + lines.push(`- ${receipt.ok ? "PASS" : "FAIL"} ${receipt.id} ${receipt.path}${receipt.sha256 ? ` sha256=${receipt.sha256}` : ""}`); + for (const error of receipt.errors) + lines.push(` - ${error}`); + } + if (result.errors.length === 0) + lines.push("errors: none"); + else { + lines.push("errors:"); + for (const error of result.errors) + lines.push(`- ${error}`); + } + return `${lines.join("\n")}\n`; +} +function runNodekitProofBindingVerify(options) { + const result = verifyNodekitProofBinding(options); + const output = options.json === true + ? JSON.stringify(result, null, 2) + : formatNodekitProofBindingVerification(result); + const log = options.log ?? console.log; + const logError = options.logError ?? console.error; + if (result.ok) + log(output); + else + logError(output); + return result.ok ? 0 : 1; +} +function verifyReleaseProof(args) { + const { releaseProof, minimumLevel, errors } = args; + if (releaseProof.schemaVersion !== exports.NODEKIT_PROOF_RECEIPT_SCHEMA) { + errors.push(`NodeKit release proof schemaVersion must be ${exports.NODEKIT_PROOF_RECEIPT_SCHEMA}`); + } + if (releaseProof.passed !== true) + errors.push("NodeKit release proof must have passed=true"); + if (typeof releaseProof.configHash !== "string" || !SHA256_PATTERN.test(releaseProof.configHash)) { + errors.push("NodeKit release proof configHash must be a SHA-256 digest"); + } + else if (args.compiledConfigHash !== undefined && releaseProof.configHash !== args.compiledConfigHash) { + errors.push("NodeKit release proof configHash does not match the compiled NodeKit configHash"); + } + if (releaseProof.applicationHash !== undefined && releaseProof.applicationHash !== releaseProof.configHash) { + errors.push("NodeKit release proof applicationHash must match configHash when present"); + } + verifyReleaseReceiptVerification(releaseProof, args.compiledConfigHash, args.candidateCommit, errors); + const checks = asRecord(releaseProof.checks); + if (!checks) { + errors.push("NodeKit release proof checks must be an object"); + return; + } + for (const id of ["deterministicDemo", "deterministicEvaluation", "secretFree"]) { + if (checks[id] !== true) + errors.push(`NodeKit release proof checks.${id} must be true`); + } + const level = releaseProof.level; + const releaseReady = releaseProof.releaseReady; + if (level !== "local-ready" && level !== "release-ready") { + errors.push("NodeKit release proof level must be local-ready or release-ready"); + } + if (typeof releaseReady !== "boolean") { + errors.push("NodeKit release proof releaseReady must be boolean"); + } + else if ((level === "release-ready") !== releaseReady) { + errors.push("NodeKit release proof level and releaseReady disagree"); + } + if (minimumLevel === "release-ready" && level !== "release-ready") { + errors.push("NodeKit release proof does not meet required release-ready level"); + } + const proofDirectory = (0, node_path_1.dirname)(args.releasePath); + const demo = verifyJsonGate({ + root: args.root, + path: joinRelative(args.root, proofDirectory, "demo-receipt.json"), + id: "demo", + requireNodekitSchema: true, + configHash: args.compiledConfigHash, + candidateCommit: args.candidateCommit, + requirePassed: false, + }); + const evaluation = verifyJsonGate({ + root: args.root, + path: joinRelative(args.root, proofDirectory, "eval-receipt.json"), + id: "evaluation", + requireNodekitSchema: true, + requirePassed: true, + configHash: args.compiledConfigHash, + candidateCommit: args.candidateCommit, + }); + args.gateReceipts.push(demo, evaluation); + const optionalGateSpecs = [ + { id: "live", check: "livePi", filename: "pi-live-receipt.json", requireNodekitSchema: true, requirePassed: true, acceptedStatus: "pass" }, + { id: "browser", check: "browserQa", filename: "browser-proof.json", expectedSchema: "nodekit.browser-proof/v1", requirePassed: true }, + { id: "deployment", check: "deployment", filename: "deployment-receipt.json", requireNodekitSchema: true, requirePassed: true, acceptedStatus: "pass" }, + ]; + for (const spec of optionalGateSpecs) { + const mustVerify = minimumLevel === "release-ready" || checks[spec.check] === true; + if (!mustVerify) + continue; + const receipt = verifyJsonGate({ + root: args.root, + path: joinRelative(args.root, proofDirectory, spec.filename), + id: spec.id, + ...(spec.expectedSchema ? { expectedSchema: spec.expectedSchema } : {}), + ...(spec.requireNodekitSchema ? { requireNodekitSchema: true } : {}), + configHash: args.compiledConfigHash, + candidateCommit: args.candidateCommit, + requirePassed: spec.requirePassed === true, + ...(spec.acceptedStatus ? { acceptedStatus: spec.acceptedStatus } : {}), + }); + args.gateReceipts.push(receipt); + if (checks[spec.check] !== true) { + errors.push(`NodeKit release proof checks.${spec.check} must be true when ${spec.id} is required`); + } + } +} +function verifyJsonGate(args) { + const errors = []; + const file = resolveRegularFile(args.root, args.path, `${args.id} gate receipt`, errors); + const display = displayPath(args.path); + if (!file) + return { id: args.id, path: display, ok: false, errors }; + const value = readJsonRecord(file, `${args.id} gate receipt`, errors); + if (!value) + return { id: args.id, path: display, sha256: sha256((0, node_fs_1.readFileSync)(file)), ok: false, errors }; + if (args.expectedSchema && value.schemaVersion !== args.expectedSchema) { + errors.push(`${args.id} gate receipt schemaVersion must be ${args.expectedSchema}`); + } + if (args.requireNodekitSchema && (typeof value.schemaVersion !== "string" || !NODEKIT_SCHEMA_PATTERN.test(value.schemaVersion))) { + errors.push(`${args.id} gate receipt schemaVersion must be a NodeKit v1+ schema`); + } + if (args.configHash !== undefined && value.configHash !== args.configHash) { + errors.push(`${args.id} gate receipt configHash does not match the compiled NodeKit configHash`); + } + verifyGateIdentityAndDigest(value, args, errors); + if (args.requirePassed && value.passed !== true && value.status !== args.acceptedStatus) { + errors.push(`${args.id} gate receipt must have passed=true${args.acceptedStatus ? ` or status=${args.acceptedStatus}` : ""}`); + } + return { id: args.id, path: display, sha256: sha256((0, node_fs_1.readFileSync)(file)), ok: errors.length === 0, errors }; +} +function verifyReleaseReceiptVerification(releaseProof, configHash, candidateCommit, errors) { + const verification = asRecord(releaseProof.receiptVerification); + if (!verification) + return; + if (verification.passed !== true) + errors.push("NodeKit release proof receiptVerification must have passed=true when present"); + if (configHash !== undefined && verification.applicationHash !== undefined && verification.applicationHash !== configHash) { + errors.push("NodeKit release proof receiptVerification applicationHash does not match compiled NodeKit configHash"); + } + if (verification.candidateCommit !== undefined && verification.candidateCommit !== candidateCommit) { + errors.push("NodeKit release proof receiptVerification candidateCommit does not match the requested candidate"); + } +} +function verifyGateIdentityAndDigest(value, args, errors) { + if (args.configHash !== undefined && value.applicationHash !== undefined && value.applicationHash !== args.configHash) { + errors.push(`${args.id} gate receipt applicationHash does not match the compiled NodeKit configHash`); + } + const candidate = asRecord(value.candidate); + if (candidate && args.candidateCommit !== undefined) { + if (candidate.commit !== args.candidateCommit || candidate.dirty !== false) { + errors.push(`${args.id} gate receipt is not bound to the clean requested candidate commit`); + } + } + if (value.receiptDigest === undefined) + return; + if (typeof value.receiptDigest !== "string" || !SHA256_PATTERN.test(value.receiptDigest)) { + errors.push(`${args.id} gate receipt receiptDigest must be a SHA-256 digest when present`); + return; + } + const clone = { ...value }; + delete clone.receiptDigest; + if (sha256(JSON.stringify(clone)) !== value.receiptDigest) { + errors.push(`${args.id} gate receipt receiptDigest does not match content`); + } +} +function readCompiledDefinition(path, errors) { + const value = readJsonRecord(path, "NodeKit compiled definition", errors); + if (!value) + return undefined; + if (value.schemaVersion !== exports.NODEKIT_COMPILED_DEFINITION_SCHEMA) { + errors.push(`NodeKit compiled definition schemaVersion must be ${exports.NODEKIT_COMPILED_DEFINITION_SCHEMA}`); + } + if (typeof value.configHash !== "string" || !SHA256_PATTERN.test(value.configHash)) { + errors.push("NodeKit compiled definition configHash must be a SHA-256 digest"); + } + if (typeof value.manifestDigest !== "string" || !SHA256_PATTERN.test(value.manifestDigest)) { + errors.push("NodeKit compiled definition manifestDigest must be a SHA-256 digest"); + } + if (typeof value.fileCount !== "number" || !Number.isInteger(value.fileCount) || value.fileCount < 0) { + errors.push("NodeKit compiled definition fileCount must be a non-negative integer"); + } + if (typeof value.configHash !== "string" || typeof value.manifestDigest !== "string" || typeof value.fileCount !== "number") + return undefined; + return { + schemaVersion: value.schemaVersion, + configHash: value.configHash, + manifestDigest: value.manifestDigest, + fileCount: value.fileCount, + }; +} +function readConfigHash(path, errors) { + const value = (0, node_fs_1.readFileSync)(path, "utf8").trim(); + if (!SHA256_PATTERN.test(value)) { + errors.push("NodeKit config hash file must contain exactly one SHA-256 digest"); + return undefined; + } + return value; +} +function readDiscovery(path, errors) { + const value = readJsonRecord(path, "NodeKit discovery", errors); + if (!value) + return undefined; + if (value.schemaVersion !== exports.NODEKIT_DISCOVERY_SCHEMA) { + errors.push(`NodeKit discovery schemaVersion must be ${exports.NODEKIT_DISCOVERY_SCHEMA}`); + } + if (!Array.isArray(value.files)) { + errors.push("NodeKit discovery files must be an array"); + return undefined; + } + const seen = new Set(); + const files = []; + for (const [index, entry] of value.files.entries()) { + if (!asRecord(entry)) { + errors.push(`NodeKit discovery files[${index}] must be an object`); + continue; + } + const record = entry; + if (!safeRepoRelativePath(record.path)) { + errors.push(`NodeKit discovery files[${index}].path must be a safe repo-relative path`); + continue; + } + if (seen.has(record.path)) { + errors.push(`NodeKit discovery contains duplicate path ${record.path}`); + continue; + } + seen.add(record.path); + if (typeof record.digest !== "string" || !SHA256_PATTERN.test(record.digest)) { + errors.push(`NodeKit discovery files[${index}].digest must be a SHA-256 digest`); + continue; + } + if (typeof record.bytes !== "number" || !Number.isInteger(record.bytes) || record.bytes < 0) { + errors.push(`NodeKit discovery files[${index}].bytes must be a non-negative integer`); + continue; + } + files.push({ path: record.path, digest: record.digest, bytes: record.bytes }); + } + const sorted = [...files].sort((left, right) => left.path.localeCompare(right.path)); + if (!files.every((entry, index) => entry.path === sorted[index]?.path)) { + errors.push("NodeKit discovery files must be sorted by path"); + } + return { files }; +} +function verifyManifestDigest(root, expected, candidateCommit, errors) { + const manifestPath = resolveRegularFile(root, "nodeagent.yaml", "NodeKit manifest", errors); + if (!manifestPath) + return; + if (sha256((0, node_fs_1.readFileSync)(manifestPath)) !== expected) { + errors.push("nodeagent.yaml bytes do not match compiled definition manifestDigest"); + } + verifyCandidateFileBytes(root, candidateCommit, "nodeagent.yaml", "NodeKit manifest", errors); +} +function verifyDiscoveryFiles(root, files, candidateCommit, errors) { + for (const file of files) { + const path = resolveRegularFile(root, file.path, `NodeKit discovered file ${file.path}`, errors); + if (!path) + continue; + const bytes = (0, node_fs_1.readFileSync)(path); + if (bytes.byteLength !== file.bytes) { + errors.push(`NodeKit discovered file ${file.path} byte count changed`); + } + if (sha256(bytes) !== file.digest) { + errors.push(`NodeKit discovered file ${file.path} digest changed`); + } + verifyCandidateFileBytes(root, candidateCommit, file.path, `NodeKit discovered file ${file.path}`, errors); + } +} +function verifyCandidateFileBytes(root, candidateCommit, repoPath, label, errors) { + if (!GIT_SHA_PATTERN.test(candidateCommit)) + return; + try { + (0, node_child_process_1.execFileSync)("git", ["cat-file", "-e", `${candidateCommit}:${repoPath}`], { + cwd: root, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + } + catch { + errors.push(`${label} is not present in candidate commit ${candidateCommit}`); + return; + } + try { + // `git diff -- ` honors Git's clean/smudge filters. That + // keeps a normal CRLF checkout equivalent to its LF blob while our + // discovery digest separately binds the exact local bytes the compiler + // actually observed. + (0, node_child_process_1.execFileSync)("git", ["diff", "--quiet", candidateCommit, "--", repoPath], { + cwd: root, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + } + catch { + errors.push(`${label} bytes do not match candidate commit ${candidateCommit}`); + } +} +function readCurrentCommit(root, errors) { + try { + const commit = (0, node_child_process_1.execFileSync)("git", ["rev-parse", "HEAD"], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }).trim().toLowerCase(); + if (!GIT_SHA_PATTERN.test(commit)) { + errors.push("git rev-parse HEAD did not produce a valid Git SHA"); + return undefined; + } + return commit; + } + catch { + errors.push("candidate commit cannot be verified because git rev-parse HEAD failed"); + return undefined; + } +} +function resolveRegularFile(rootInput, pathInput, label, errors) { + if (!safeRepoRelativePath(pathInput)) { + errors.push(`${label} must be a safe repo-relative path`); + return undefined; + } + const root = realPathOrResolved(rootInput); + const candidate = (0, node_path_1.resolve)(rootInput, pathInput); + if (!(0, node_fs_1.existsSync)(candidate)) { + errors.push(`${label} is missing: ${pathInput}`); + return undefined; + } + try { + if ((0, node_fs_1.lstatSync)(candidate).isSymbolicLink()) { + errors.push(`${label} must not be a symbolic link: ${pathInput}`); + return undefined; + } + const real = realPathOrResolved(candidate); + const escaped = (0, node_path_1.relative)(root, real); + if (escaped === ".." || escaped.startsWith(`..${node_path_1.sep}`) || (0, node_path_1.isAbsolute)(escaped)) { + errors.push(`${label} escapes the repository root: ${pathInput}`); + return undefined; + } + if (!(0, node_fs_1.statSync)(real).isFile()) { + errors.push(`${label} is not a regular file: ${pathInput}`); + return undefined; + } + return real; + } + catch (error) { + errors.push(`${label} cannot be read: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } +} +function readJsonRecord(path, label, errors) { + try { + const value = JSON.parse((0, node_fs_1.readFileSync)(path, "utf8")); + if (!asRecord(value)) { + errors.push(`${label} must be a JSON object`); + return undefined; + } + return value; + } + catch (error) { + errors.push(`${label} must be valid JSON: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } +} +function joinRelative(root, directory, filename) { + const target = (0, node_path_1.resolve)(directory, filename); + const value = (0, node_path_1.relative)((0, node_path_1.resolve)(root), target).split(node_path_1.sep).join("/"); + return value || filename; +} +function displayPath(pathInput) { + if (!safeRepoRelativePath(pathInput)) + return pathInput; + return pathInput.split(/[\\/]/).join("/"); +} +function realPathOrResolved(pathInput) { + try { + return (0, node_path_1.resolve)((0, node_fs_1.realpathSync)(pathInput)); + } + catch { + return (0, node_path_1.resolve)(pathInput); + } +} +function safeRepoRelativePath(value) { + if (typeof value !== "string" || value.length === 0 || (0, node_path_1.isAbsolute)(value) || /^[A-Za-z]:/.test(value)) + return false; + return !value.split(/[\\/]/).some((segment) => segment === ".." || segment.length === 0 || segment.includes(":")); +} +function asRecord(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : undefined; +} +function sha256(value) { + return (0, node_crypto_1.createHash)("sha256").update(value).digest("hex"); +} diff --git a/dist/program.d.ts b/dist/program.d.ts new file mode 100644 index 0000000..e3625bc --- /dev/null +++ b/dist/program.d.ts @@ -0,0 +1,143 @@ +import { type NodekitProofMinimumLevel } from "./nodekitProof"; +/** + * P0 program supervisor. + * + * This is deliberately an orchestration layer over the existing durable runner: + * each arc points at one immutable runner plan. It does not accept arbitrary + * commands itself and it does not add a parallel task-execution engine. + */ +export declare const PROOFLOOP_PROGRAM_PLAN_SCHEMA: "proofloop-program-plan-v1"; +export declare const PROOFLOOP_PROGRAM_AUTHORITY_SCHEMA: "proofloop-program-authority-v1"; +export declare const PROOFLOOP_PROGRAM_STATE_SCHEMA: "proofloop-program-state-v1"; +export declare const PROOFLOOP_PROGRAM_EVENT_SCHEMA: "proofloop-program-event-v1"; +export type ProofloopProgramArcMode = "read_only" | "proposal_only"; +export type ProofloopProgramArcStatus = "queued" | "running" | "passed" | "failed" | "blocked_budget" | "blocked_authority"; +export type ProofloopProgramStatus = "queued" | "running" | "paused" | "certified" | "failed" | "failed_integrity" | "blocked_budget" | "blocked_authority"; +export type ProofloopEnvelopeReceiptHook = { + kind: "proofloop-envelope"; + file: string; +}; +export type ProofloopNodeagentIngestionReceiptHook = { + kind: "nodeagent-ingestion"; + file: string; + minDocuments?: number; + minMemoryObjects?: number; +}; +/** + * Binds NodeKit's generated proof receipt to the current compiled application + * identity and an exact Git candidate before a local-only program arc passes. + */ +export type ProofloopNodekitProofReceiptHook = { + kind: "nodekit-proof"; + file: string; + candidateCommit: string; + minimumLevel?: NodekitProofMinimumLevel; + compiledDefinition?: string; + configHashFile?: string; + discovery?: string; +}; +export type ProofloopProgramReceiptHook = ProofloopEnvelopeReceiptHook | ProofloopNodeagentIngestionReceiptHook | ProofloopNodekitProofReceiptHook; +export type ProofloopProgramArcPlan = { + id: string; + mode: ProofloopProgramArcMode; + runnerPlan: string; + dependsOn?: string[]; + receipt?: ProofloopProgramReceiptHook; + /** Must remain false in P0. The explicit field makes an attempted egress auditable and blockable. */ + externalEgress?: boolean; + maxAttempts?: number; +}; +export type ProofloopProgramPlan = { + schema: typeof PROOFLOOP_PROGRAM_PLAN_SCHEMA; + programId: string; + authorityPath: string; + arcs: ProofloopProgramArcPlan[]; +}; +export type ProofloopProgramAuthority = { + schema: typeof PROOFLOOP_PROGRAM_AUTHORITY_SCHEMA; + authorityId: string; + allowedArcModes: ProofloopProgramArcMode[]; + /** P0 is deliberately local-only. A true value is rejected rather than treated as consent. */ + allowExternalEgress: false; + maxBudgetUsd: number; + maxAttemptsPerArc: number; +}; +export type ProofloopProgramReceiptVerification = { + kind: ProofloopProgramReceiptHook["kind"]; + file: string; + ok: boolean; + errors: string[]; +}; +export type ProofloopProgramArcState = { + id: string; + mode: ProofloopProgramArcMode; + dependsOn: string[]; + runnerPlanPath: string; + runnerPlanDigest: string; + runnerRunId: string; + estimatedCostUsd: number; + /** + * Last durable runner-spend observation. This lets an interrupted arc + * recover without charging the program twice for tasks the runner already + * recorded before the supervisor was interrupted. + */ + runnerSpentEstimatedUsd?: number; + maxAttempts: number; + attempts: number; + status: ProofloopProgramArcStatus; + startedAt?: string; + completedAt?: string; + error?: string; + receipt?: ProofloopProgramReceiptVerification; +}; +export type ProofloopProgramState = { + schema: typeof PROOFLOOP_PROGRAM_STATE_SCHEMA; + programRunId: string; + programId: string; + planPath: string; + planDigest: string; + authorityPath: string; + authorityDigest: string; + budgetUsd: number; + spentEstimatedUsd: number; + status: ProofloopProgramStatus; + createdAt: string; + updatedAt: string; + arcStates: ProofloopProgramArcState[]; +}; +export type ProofloopProgramEvent = { + schema: typeof PROOFLOOP_PROGRAM_EVENT_SCHEMA; + programRunId: string; + at: string; + event: string; + arcId?: string; + data?: Record; +}; +export type ProofloopProgramResult = { + state: ProofloopProgramState; + runDir: string; + ledgerPath: string; + exitCode: number; +}; +export type ProofloopProgramOptions = { + root: string; + subcommand: "run" | "resume" | "status" | "report"; + planPath?: string; + runId?: string; + budgetUsd?: number; + maxArcs?: number; + lockTtlMs?: number; + clearStaleLock?: boolean; + json?: boolean; + log?: (message: string) => void; + logError?: (message: string) => void; +}; +/** Run or resume a dependency-ordered program. P0 only permits local read/proposal arcs. */ +export declare function runProofloopProgram(options: ProofloopProgramOptions): Promise; +export declare function readProofloopProgramPlan(rootInput: string, planPathInput: string): ProofloopProgramPlan; +export declare function readProofloopProgramAuthority(rootInput: string, authorityPathInput: string): ProofloopProgramAuthority; +export declare function programRunDir(rootInput: string, runId: string): string; +export declare function programStatePath(runDir: string): string; +export declare function programLedgerPath(runDir: string): string; +export declare function isProgramTerminal(status: ProofloopProgramStatus): boolean; +export declare function formatProofloopProgramStatus(state: ProofloopProgramState, runDir: string): string; diff --git a/dist/program.js b/dist/program.js new file mode 100644 index 0000000..d636818 --- /dev/null +++ b/dist/program.js @@ -0,0 +1,1030 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.PROOFLOOP_PROGRAM_EVENT_SCHEMA = exports.PROOFLOOP_PROGRAM_STATE_SCHEMA = exports.PROOFLOOP_PROGRAM_AUTHORITY_SCHEMA = exports.PROOFLOOP_PROGRAM_PLAN_SCHEMA = void 0; +exports.runProofloopProgram = runProofloopProgram; +exports.readProofloopProgramPlan = readProofloopProgramPlan; +exports.readProofloopProgramAuthority = readProofloopProgramAuthority; +exports.programRunDir = programRunDir; +exports.programStatePath = programStatePath; +exports.programLedgerPath = programLedgerPath; +exports.isProgramTerminal = isProgramTerminal; +exports.formatProofloopProgramStatus = formatProofloopProgramStatus; +const node_crypto_1 = require("node:crypto"); +const node_fs_1 = require("node:fs"); +const node_path_1 = require("node:path"); +const proofReceipt_1 = require("./proofReceipt"); +const nodekitProof_1 = require("./nodekitProof"); +const receipts_1 = require("./receipts"); +const runner_1 = require("./runner"); +/** + * P0 program supervisor. + * + * This is deliberately an orchestration layer over the existing durable runner: + * each arc points at one immutable runner plan. It does not accept arbitrary + * commands itself and it does not add a parallel task-execution engine. + */ +exports.PROOFLOOP_PROGRAM_PLAN_SCHEMA = "proofloop-program-plan-v1"; +exports.PROOFLOOP_PROGRAM_AUTHORITY_SCHEMA = "proofloop-program-authority-v1"; +exports.PROOFLOOP_PROGRAM_STATE_SCHEMA = "proofloop-program-state-v1"; +exports.PROOFLOOP_PROGRAM_EVENT_SCHEMA = "proofloop-program-event-v1"; +const PROGRAM_ROOT = ".proofloop/programs"; +const DEFAULT_LOCK_TTL_MS = 30 * 60_000; +// Program and arc identifiers become part of local durable run paths. Keep +// them portable across Windows and POSIX rather than accepting ':' or a path +// separator merely because it is convenient for a logical label. +const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/; +const PLAN_KEYS = new Set(["schema", "programId", "authorityPath", "arcs"]); +const ARC_KEYS = new Set(["id", "mode", "runnerPlan", "dependsOn", "receipt", "externalEgress", "maxAttempts"]); +const AUTHORITY_KEYS = new Set(["schema", "authorityId", "allowedArcModes", "allowExternalEgress", "maxBudgetUsd", "maxAttemptsPerArc"]); +const ENVELOPE_RECEIPT_KEYS = new Set(["kind", "file"]); +const NODEAGENT_RECEIPT_KEYS = new Set(["kind", "file", "minDocuments", "minMemoryObjects"]); +const NODEKIT_RECEIPT_KEYS = new Set(["kind", "file", "candidateCommit", "minimumLevel", "compiledDefinition", "configHashFile", "discovery"]); +/** Run or resume a dependency-ordered program. P0 only permits local read/proposal arcs. */ +async function runProofloopProgram(options) { + const root = (0, node_path_1.resolve)(options.root); + const log = options.log ?? console.log; + const logError = options.logError ?? console.error; + if (options.subcommand === "status") + return programStatus(options); + if (options.subcommand === "report") + return programReport(options); + let runId = options.runId; + // Keep the initial fallback inside the safe run-id grammar because failures + // must not turn a caller-supplied run id into a filesystem path. + let runDir = programRunDir(root, "unknown"); + let lock; + try { + if (options.subcommand === "resume") + runId = resolveProgramRunId(root, options.runId); + const existingState = options.subcommand === "resume" + ? readProgramState(programStatePath(programRunDir(root, runId))) + : undefined; + const planPath = resolveProgramPlanPath(options, existingState); + const compiled = compileProgram(root, planPath); + runId = options.subcommand === "resume" ? runId : options.runId ?? defaultProgramRunId(compiled.plan.programId); + if (!validId(runId)) + throw new Error("program run-id must contain only letters, numbers, '.', '_', or '-'"); + runDir = programRunDir(root, runId); + (0, node_fs_1.mkdirSync)(runDir, { recursive: true }); + lock = acquireProgramLock(runDir, options.lockTtlMs ?? DEFAULT_LOCK_TTL_MS, options.clearStaleLock === true); + const repaired = repairProgramLedgerTornTail(runDir); + if (repaired.repaired) + appendProgramEvent(runDir, { programRunId: runId, event: "ledger_torn_tail_repaired", data: repaired }); + let state = loadOrCreateProgramState(runDir, { + programRunId: runId, + compiled, + budgetUsd: initialBudget(compiled.authority, options.budgetUsd), + }); + writeLatestProgramRun(root, runId); + const resumeIntegrity = validateExistingProgramState(state, compiled, options.budgetUsd); + if (resumeIntegrity) { + state = terminalizeProgram(state, runDir, resumeIntegrity.status, resumeIntegrity.event, resumeIntegrity.message); + return emitProgramResult(state, runDir, options, log); + } + const policyViolations = validateProgramAuthority(compiled); + if (policyViolations.length > 0) { + state = terminalizeProgram(state, runDir, "blocked_authority", "authority_policy_blocked", policyViolations.join("; ")); + return emitProgramResult(state, runDir, options, log); + } + if (isProgramTerminal(state.status)) + return emitProgramResult(state, runDir, options, log); + appendProgramEvent(runDir, { + programRunId: runId, + event: "program_started", + data: { + subcommand: options.subcommand, + budgetUsd: state.budgetUsd, + maxArcs: options.maxArcs ?? null, + authorityDigest: state.authorityDigest, + }, + }); + const maxArcs = normalizeMaxArcs(options.maxArcs); + let executed = 0; + while (executed < maxArcs) { + const next = nextExecutableArc(state); + if (!next) + break; + const compiledArc = compiled.arcs.find((arc) => arc.plan.id === next.id); + if (!compiledArc) { + next.status = "failed"; + next.completedAt = nowIso(); + next.error = "Arc is missing from the compiled program."; + state = terminalizeProgram(state, runDir, "failed_integrity", "arc_missing_from_compiled_program", next.error, next.id); + break; + } + const recoveringInterruptedArc = next.status === "running"; + if (!recoveringInterruptedArc && next.attempts >= next.maxAttempts) { + next.status = "failed"; + next.completedAt = nowIso(); + next.error = `Arc exhausted its bounded attempt limit (${next.maxAttempts}) and will not be requeued automatically.`; + state = terminalizeProgram(state, runDir, "failed", "arc_attempt_limit_reached", next.error, next.id); + break; + } + if (!recoveringInterruptedArc && roundMoney(state.spentEstimatedUsd + next.estimatedCostUsd) > state.budgetUsd) { + next.status = "blocked_budget"; + next.completedAt = nowIso(); + next.error = `Program budget would be exceeded by arc estimate $${next.estimatedCostUsd.toFixed(4)}.`; + state = terminalizeProgram(state, runDir, "blocked_budget", "budget_kill_switch", next.error, next.id); + break; + } + if (recoveringInterruptedArc) { + appendProgramEvent(runDir, { + programRunId: runId, + event: "arc_recovery_requested", + arcId: next.id, + data: { + runnerRunId: next.runnerRunId, + attempt: next.attempts, + note: "Only interrupted running work may resume. Failed arcs remain terminal and are never automatically requeued.", + }, + }); + } + else { + state.status = "running"; + next.status = "running"; + next.attempts += 1; + next.startedAt = nowIso(); + state.updatedAt = next.startedAt; + writeProgramState(runDir, state); + appendProgramEvent(runDir, { + programRunId: runId, + event: "arc_started", + arcId: next.id, + data: { + mode: next.mode, + runnerPlanDigest: next.runnerPlanDigest, + estimatedCostUsd: next.estimatedCostUsd, + attempt: next.attempts, + maxAttempts: next.maxAttempts, + }, + }); + } + const runnerAlreadyExists = (0, node_fs_1.existsSync)((0, runner_1.runnerStatePath)((0, runner_1.runnerRunDir)(root, next.runnerRunId))); + const runner = await (0, runner_1.runProofloopRunner)({ + root, + subcommand: runnerAlreadyExists ? "resume" : "run", + ...(runnerAlreadyExists ? {} : { planPath: compiledArc.runnerPlanPath }), + runId: next.runnerRunId, + budgetUsd: roundMoney(state.budgetUsd - state.spentEstimatedUsd), + clearStaleLock: options.clearStaleLock === true, + log: () => { }, + logError: () => { }, + }); + const priorRunnerSpend = next.runnerSpentEstimatedUsd ?? 0; + const currentRunnerSpend = runner.state.spentEstimatedUsd; + if (!nonNegativeFiniteNumber(currentRunnerSpend) || currentRunnerSpend < priorRunnerSpend) { + next.status = "failed"; + next.completedAt = nowIso(); + next.error = "Runner spend observation is invalid or regressed; refusing to continue an unverifiable program run."; + state = terminalizeProgram(state, runDir, "failed_integrity", "runner_spend_integrity_failed", next.error, next.id, { + priorRunnerSpend, + currentRunnerSpend, + }); + break; + } + const runnerSpendDelta = roundMoney(currentRunnerSpend - priorRunnerSpend); + state.spentEstimatedUsd = roundMoney(state.spentEstimatedUsd + runnerSpendDelta); + next.runnerSpentEstimatedUsd = currentRunnerSpend; + next.completedAt = nowIso(); + state.updatedAt = next.completedAt; + writeProgramState(runDir, state); + if (runner.state.status !== "passed") { + next.status = runner.state.status === "blocked_budget" ? "blocked_budget" : "failed"; + next.error = `Runner ended ${runner.state.status}; runner run ${next.runnerRunId}.`; + const terminal = next.status === "blocked_budget" ? "blocked_budget" : "failed"; + state = terminalizeProgram(state, runDir, terminal, "arc_runner_failed", next.error, next.id, { + runnerStatus: runner.state.status, + runnerRunId: next.runnerRunId, + }); + break; + } + const receipt = verifyProgramReceipt(root, compiledArc.plan.receipt); + if (receipt) { + next.receipt = receipt; + appendProgramEvent(runDir, { + programRunId: runId, + event: receipt.ok ? "receipt_verified" : "receipt_verification_failed", + arcId: next.id, + data: { kind: receipt.kind, file: receipt.file, errors: receipt.errors }, + }); + if (!receipt.ok) { + next.status = "failed"; + next.error = `Required receipt verification failed: ${receipt.errors.join("; ")}`; + state = terminalizeProgram(state, runDir, "failed", "arc_receipt_failed", next.error, next.id); + break; + } + } + next.status = "passed"; + writeProgramState(runDir, state); + appendProgramEvent(runDir, { + programRunId: runId, + event: "arc_passed", + arcId: next.id, + data: { runnerRunId: next.runnerRunId, spentEstimatedUsd: state.spentEstimatedUsd }, + }); + executed += 1; + } + if (!isProgramTerminal(state.status)) { + if (state.arcStates.every((arc) => arc.status === "passed")) { + state = terminalizeProgram(state, runDir, "certified", "program_certified", "Every program arc and configured receipt hook passed."); + } + else if (state.arcStates.some((arc) => arc.status === "failed")) { + state = terminalizeProgram(state, runDir, "failed", "program_failed", "An arc failed and P0 does not automatically requeue failed work."); + } + else if (state.arcStates.some((arc) => arc.status === "blocked_budget")) { + state = terminalizeProgram(state, runDir, "blocked_budget", "program_budget_blocked", "A program arc is blocked by the approved budget."); + } + else if (executed >= maxArcs) { + state.status = "paused"; + state.updatedAt = nowIso(); + writeProgramState(runDir, state); + appendProgramEvent(runDir, { programRunId: runId, event: "program_paused", data: { maxArcs } }); + } + else { + state = terminalizeProgram(state, runDir, "failed_integrity", "no_dependency_safe_arc", "No dependency-safe queued arc remains; inspect the persisted program state."); + } + } + return emitProgramResult(state, runDir, options, log); + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + logError(`proofloop program: ${message}`); + return { + state: emptyProgramErrorState(runId ?? "unknown", message), + runDir, + ledgerPath: programLedgerPath(runDir), + exitCode: 2, + }; + } + finally { + lock?.release(); + } +} +function readProofloopProgramPlan(rootInput, planPathInput) { + const root = (0, node_path_1.resolve)(rootInput); + const planPath = resolveProgramRepoFile(root, planPathInput, "program plan", { allowAbsoluteInsideRoot: true }); + const raw = (0, node_fs_1.readFileSync)(planPath, "utf8").replace(/^\uFEFF/, ""); + let parsed; + try { + parsed = JSON.parse(raw); + } + catch (error) { + throw new Error(`program plan must be JSON: ${error instanceof Error ? error.message : String(error)}`); + } + if (!isRecord(parsed)) + throw new Error("program plan must be an object"); + rejectUnknownKeys(parsed, PLAN_KEYS, "program plan"); + if (parsed.schema !== exports.PROOFLOOP_PROGRAM_PLAN_SCHEMA) + throw new Error(`program plan schema must be ${exports.PROOFLOOP_PROGRAM_PLAN_SCHEMA}`); + if (!validId(parsed.programId)) + throw new Error("program plan programId is required"); + if (!safeRepoRelativePath(parsed.authorityPath)) + throw new Error("program plan authorityPath must be a safe repo-relative path"); + if (!Array.isArray(parsed.arcs) || parsed.arcs.length === 0) + throw new Error("program plan must include at least one arc"); + const ids = new Set(); + const arcs = parsed.arcs.map((value, index) => parseProgramArc(value, index, ids)); + validateArcGraph(arcs); + return { + schema: exports.PROOFLOOP_PROGRAM_PLAN_SCHEMA, + programId: parsed.programId, + authorityPath: parsed.authorityPath, + arcs, + }; +} +function readProofloopProgramAuthority(rootInput, authorityPathInput) { + const root = (0, node_path_1.resolve)(rootInput); + const authorityPath = resolveProgramRepoFile(root, authorityPathInput, "program authority"); + const raw = (0, node_fs_1.readFileSync)(authorityPath, "utf8").replace(/^\uFEFF/, ""); + let parsed; + try { + parsed = JSON.parse(raw); + } + catch (error) { + throw new Error(`program authority must be JSON: ${error instanceof Error ? error.message : String(error)}`); + } + if (!isRecord(parsed)) + throw new Error("program authority must be an object"); + rejectUnknownKeys(parsed, AUTHORITY_KEYS, "program authority"); + if (parsed.schema !== exports.PROOFLOOP_PROGRAM_AUTHORITY_SCHEMA) + throw new Error(`program authority schema must be ${exports.PROOFLOOP_PROGRAM_AUTHORITY_SCHEMA}`); + if (!validId(parsed.authorityId)) + throw new Error("program authority authorityId is required"); + if (!Array.isArray(parsed.allowedArcModes) || parsed.allowedArcModes.length === 0) + throw new Error("program authority allowedArcModes is required"); + const allowedArcModes = parsed.allowedArcModes.map((mode) => parseArcMode(mode, "program authority allowedArcModes")); + if (new Set(allowedArcModes).size !== allowedArcModes.length) + throw new Error("program authority allowedArcModes must be unique"); + if (parsed.allowExternalEgress !== false) + throw new Error("program authority allowExternalEgress must be false in P0"); + if (!nonNegativeFiniteNumber(parsed.maxBudgetUsd)) + throw new Error("program authority maxBudgetUsd must be a non-negative finite number"); + if (!positiveInteger(parsed.maxAttemptsPerArc)) + throw new Error("program authority maxAttemptsPerArc must be a positive integer"); + return { + schema: exports.PROOFLOOP_PROGRAM_AUTHORITY_SCHEMA, + authorityId: parsed.authorityId, + allowedArcModes, + allowExternalEgress: false, + maxBudgetUsd: parsed.maxBudgetUsd, + maxAttemptsPerArc: parsed.maxAttemptsPerArc, + }; +} +function programRunDir(rootInput, runId) { + if (!validId(runId)) + throw new Error("program run-id must contain only letters, numbers, '.', '_', or '-'"); + return (0, node_path_1.join)((0, node_path_1.resolve)(rootInput), PROGRAM_ROOT, "runs", runId); +} +function programStatePath(runDir) { + return (0, node_path_1.join)(runDir, "state.json"); +} +function programLedgerPath(runDir) { + return (0, node_path_1.join)(runDir, "ledger.jsonl"); +} +function isProgramTerminal(status) { + return status === "certified" || status === "failed" || status === "failed_integrity" || status === "blocked_budget" || status === "blocked_authority"; +} +function formatProofloopProgramStatus(state, runDir) { + const counts = state.arcStates.reduce((value, arc) => { + value[arc.status] += 1; + return value; + }, { queued: 0, running: 0, passed: 0, failed: 0, blocked_budget: 0, blocked_authority: 0 }); + return [ + `proofloop program: ${state.programRunId}`, + `program=${state.programId} status=${state.status}`, + `budget=$${state.budgetUsd.toFixed(4)} spent_est=$${state.spentEstimatedUsd.toFixed(4)}`, + `arcs passed=${counts.passed} queued=${counts.queued} running=${counts.running} failed=${counts.failed} blocked_budget=${counts.blocked_budget} blocked_authority=${counts.blocked_authority}`, + `authorityDigest=${state.authorityDigest}`, + `state=${programStatePath(runDir)}`, + `ledger=${programLedgerPath(runDir)}`, + ].join("\n"); +} +function compileProgram(root, planPathInput) { + const planPath = resolveProgramRepoFile(root, planPathInput, "program plan", { allowAbsoluteInsideRoot: true }); + const plan = readProofloopProgramPlan(root, planPath); + const authorityPath = resolveProgramRepoFile(root, plan.authorityPath, "program authority"); + const authority = readProofloopProgramAuthority(root, plan.authorityPath); + const arcsById = new Map(plan.arcs.map((arc) => [arc.id, arc])); + const orderedIds = stableTopologicalArcOrder(plan.arcs); + const arcs = orderedIds.map((id) => { + const arc = arcsById.get(id); + const runnerPlanPath = resolveProgramRepoFile(root, arc.runnerPlan, `runner plan for arc ${arc.id}`); + const runnerPlan = (0, runner_1.readRunnerPlan)(runnerPlanPath); + return { + plan: arc, + runnerPlanPath, + runnerPlan, + runnerPlanDigest: (0, proofReceipt_1.sha256CanonicalJson)(runnerPlan), + estimatedCostUsd: roundMoney(runnerPlan.tasks.reduce((sum, task) => sum + (task.estimatedCostUsd ?? 0), 0)), + }; + }); + const authorityDigest = (0, proofReceipt_1.sha256CanonicalJson)(authority); + const planDigest = (0, proofReceipt_1.sha256CanonicalJson)({ + plan, + arcs: arcs.map((arc) => ({ + id: arc.plan.id, + runnerPlan: arc.plan.runnerPlan, + runnerPlanDigest: arc.runnerPlanDigest, + estimatedCostUsd: arc.estimatedCostUsd, + })), + }); + return { plan, planPath, authorityPath, authority, authorityDigest, arcs, planDigest }; +} +function parseProgramArc(value, index, ids) { + if (!isRecord(value)) + throw new Error(`program arc ${index} must be an object`); + rejectUnknownKeys(value, ARC_KEYS, `program arc ${index}`); + if (!validId(value.id)) + throw new Error(`program arc ${index} id is required`); + if (ids.has(value.id)) + throw new Error(`duplicate program arc id: ${value.id}`); + ids.add(value.id); + const mode = parseArcMode(value.mode, `program arc ${value.id} mode`); + if (!safeRepoRelativePath(value.runnerPlan)) + throw new Error(`program arc ${value.id} runnerPlan must be a safe repo-relative path`); + const dependsOn = parseIdArray(value.dependsOn, `program arc ${value.id} dependsOn`); + if (new Set(dependsOn).size !== dependsOn.length) + throw new Error(`program arc ${value.id} dependsOn must be unique`); + if (dependsOn.includes(value.id)) + throw new Error(`program arc ${value.id} cannot depend on itself`); + if (value.externalEgress !== undefined && typeof value.externalEgress !== "boolean") + throw new Error(`program arc ${value.id} externalEgress must be boolean`); + const maxAttempts = value.maxAttempts === undefined ? undefined : requirePositiveInteger(value.maxAttempts, `program arc ${value.id} maxAttempts`); + const receipt = value.receipt === undefined ? undefined : parseReceiptHook(value.receipt, `program arc ${value.id} receipt`); + return { + id: value.id, + mode, + runnerPlan: value.runnerPlan, + ...(dependsOn.length > 0 ? { dependsOn } : {}), + ...(receipt ? { receipt } : {}), + ...(value.externalEgress === true ? { externalEgress: true } : {}), + ...(maxAttempts !== undefined ? { maxAttempts } : {}), + }; +} +function parseReceiptHook(value, label) { + if (!isRecord(value)) + throw new Error(`${label} must be an object`); + if (value.kind !== "proofloop-envelope" && value.kind !== "nodeagent-ingestion" && value.kind !== "nodekit-proof") { + throw new Error(`${label} kind must be proofloop-envelope, nodeagent-ingestion, or nodekit-proof`); + } + const allowedKeys = value.kind === "proofloop-envelope" + ? ENVELOPE_RECEIPT_KEYS + : value.kind === "nodeagent-ingestion" + ? NODEAGENT_RECEIPT_KEYS + : NODEKIT_RECEIPT_KEYS; + rejectUnknownKeys(value, allowedKeys, label); + if (!safeRepoRelativePath(value.file)) + throw new Error(`${label} file must be a safe repo-relative path`); + if (value.kind === "proofloop-envelope") { + return { kind: value.kind, file: value.file }; + } + if (value.kind === "nodekit-proof") { + if (!validGitCommit(value.candidateCommit)) { + throw new Error(`${label} candidateCommit must be a lowercase 40-64 character Git SHA`); + } + if (value.minimumLevel !== undefined && value.minimumLevel !== "local-ready" && value.minimumLevel !== "release-ready") { + throw new Error(`${label} minimumLevel must be local-ready or release-ready`); + } + const compiledDefinition = value.compiledDefinition; + const configHashFile = value.configHashFile; + const discovery = value.discovery; + for (const [field, path] of [ + ["compiledDefinition", compiledDefinition], + ["configHashFile", configHashFile], + ["discovery", discovery], + ]) { + if (path !== undefined && !safeRepoRelativePath(path)) + throw new Error(`${label} ${field} must be a safe repo-relative path`); + } + return { + kind: value.kind, + file: value.file, + candidateCommit: value.candidateCommit, + ...(value.minimumLevel !== undefined ? { minimumLevel: value.minimumLevel } : {}), + ...(typeof compiledDefinition === "string" ? { compiledDefinition } : {}), + ...(typeof configHashFile === "string" ? { configHashFile } : {}), + ...(typeof discovery === "string" ? { discovery } : {}), + }; + } + const minDocuments = value.minDocuments === undefined ? undefined : requireNonNegativeInteger(value.minDocuments, `${label} minDocuments`); + const minMemoryObjects = value.minMemoryObjects === undefined ? undefined : requireNonNegativeInteger(value.minMemoryObjects, `${label} minMemoryObjects`); + return { + kind: value.kind, + file: value.file, + ...(minDocuments !== undefined ? { minDocuments } : {}), + ...(minMemoryObjects !== undefined ? { minMemoryObjects } : {}), + }; +} +function validateArcGraph(arcs) { + const ids = new Set(arcs.map((arc) => arc.id)); + for (const arc of arcs) { + for (const dependency of arc.dependsOn ?? []) { + if (!ids.has(dependency)) + throw new Error(`program arc ${arc.id} depends on unknown arc ${dependency}`); + } + } + stableTopologicalArcOrder(arcs); +} +/** Stable Kahn ordering, intentionally matching the dependency semantics used by Solo handoff compilation. */ +function stableTopologicalArcOrder(arcs) { + const originalIndex = new Map(arcs.map((arc, index) => [arc.id, index])); + const indegree = new Map(arcs.map((arc) => [arc.id, arc.dependsOn?.length ?? 0])); + const dependents = new Map(); + for (const arc of arcs) { + for (const dependency of arc.dependsOn ?? []) { + const values = dependents.get(dependency) ?? []; + values.push(arc.id); + dependents.set(dependency, values); + } + } + const available = arcs.filter((arc) => indegree.get(arc.id) === 0).map((arc) => arc.id); + const ordered = []; + while (available.length > 0) { + available.sort((left, right) => (originalIndex.get(left) ?? 0) - (originalIndex.get(right) ?? 0)); + const id = available.shift(); + ordered.push(id); + for (const dependent of dependents.get(id) ?? []) { + const next = (indegree.get(dependent) ?? 0) - 1; + indegree.set(dependent, next); + if (next === 0) + available.push(dependent); + } + } + if (ordered.length !== arcs.length) { + const cyclic = arcs.filter((arc) => !ordered.includes(arc.id)).map((arc) => arc.id); + throw new Error(`program arc graph contains a cycle: ${cyclic.join(", ")}`); + } + return ordered; +} +function initialBudget(authority, requestedBudget) { + if (requestedBudget === undefined) + return authority.maxBudgetUsd; + if (!nonNegativeFiniteNumber(requestedBudget)) + throw new Error("program --budget-usd must be a non-negative finite number"); + if (requestedBudget > authority.maxBudgetUsd) + throw new Error("program --budget-usd cannot exceed the approved authority maxBudgetUsd"); + return requestedBudget; +} +function loadOrCreateProgramState(runDir, args) { + const statePath = programStatePath(runDir); + const existing = readProgramState(statePath); + if (existing) + return existing; + if ((0, node_fs_1.existsSync)(statePath)) { + throw new Error("program state is unreadable or corrupt; refusing to overwrite an existing run"); + } + const now = nowIso(); + const state = { + schema: exports.PROOFLOOP_PROGRAM_STATE_SCHEMA, + programRunId: args.programRunId, + programId: args.compiled.plan.programId, + planPath: args.compiled.planPath, + planDigest: args.compiled.planDigest, + authorityPath: args.compiled.authorityPath, + authorityDigest: args.compiled.authorityDigest, + budgetUsd: args.budgetUsd, + spentEstimatedUsd: 0, + status: "queued", + createdAt: now, + updatedAt: now, + arcStates: args.compiled.arcs.map((arc) => ({ + id: arc.plan.id, + mode: arc.plan.mode, + dependsOn: [...(arc.plan.dependsOn ?? [])], + runnerPlanPath: arc.runnerPlanPath, + runnerPlanDigest: arc.runnerPlanDigest, + runnerRunId: `${args.programRunId}-${arc.plan.id}`, + estimatedCostUsd: arc.estimatedCostUsd, + runnerSpentEstimatedUsd: 0, + maxAttempts: Math.min(arc.plan.maxAttempts ?? 1, args.compiled.authority.maxAttemptsPerArc), + attempts: 0, + status: "queued", + })), + }; + writeProgramState(runDir, state); + return state; +} +function validateExistingProgramState(state, compiled, requestedBudget) { + if (state.schema !== exports.PROOFLOOP_PROGRAM_STATE_SCHEMA) + return { status: "failed_integrity", event: "program_state_schema_mismatch", message: "Persisted program state has an unsupported schema." }; + if (!validId(state.programRunId)) + return { status: "failed_integrity", event: "program_state_run_id_invalid", message: "Persisted program state has an invalid run ID." }; + if (state.programId !== compiled.plan.programId) + return { status: "failed_integrity", event: "program_id_changed", message: "Program ID changed for an existing run." }; + if (state.planPath !== compiled.planPath || state.authorityPath !== compiled.authorityPath) { + return { status: "failed_integrity", event: "program_source_path_changed", message: "Persisted program source paths do not match the compiled program." }; + } + if (state.planDigest !== compiled.planDigest) + return { status: "failed_integrity", event: "program_plan_changed", message: "Program plan or referenced runner plan changed for an existing run." }; + if (state.authorityDigest !== compiled.authorityDigest) + return { status: "blocked_authority", event: "authority_digest_changed", message: "Authority changed after this run was created; a new approved program run is required." }; + if (!nonNegativeFiniteNumber(state.budgetUsd) || !nonNegativeFiniteNumber(state.spentEstimatedUsd) || state.spentEstimatedUsd > state.budgetUsd) { + return { status: "failed_integrity", event: "program_budget_state_invalid", message: "Persisted program budget state is invalid." }; + } + if (!isProofloopProgramStatus(state.status) || !Array.isArray(state.arcStates) || state.arcStates.length !== compiled.arcs.length) { + return { status: "failed_integrity", event: "program_state_shape_invalid", message: "Persisted program state does not match the compiled arc set." }; + } + for (let index = 0; index < compiled.arcs.length; index += 1) { + const persisted = state.arcStates[index]; + const expected = compiled.arcs[index]; + if (!persisted || persisted.id !== expected.plan.id || persisted.mode !== expected.plan.mode + || !sameStringArray(persisted.dependsOn, expected.plan.dependsOn ?? []) + || persisted.runnerPlanPath !== expected.runnerPlanPath + || persisted.runnerPlanDigest !== expected.runnerPlanDigest + || persisted.runnerRunId !== `${state.programRunId}-${expected.plan.id}` + || persisted.estimatedCostUsd !== expected.estimatedCostUsd + || persisted.maxAttempts !== Math.min(expected.plan.maxAttempts ?? 1, compiled.authority.maxAttemptsPerArc) + || !nonNegativeInteger(persisted.attempts) + || persisted.attempts > persisted.maxAttempts + || !isProofloopProgramArcStatus(persisted.status) + || (persisted.runnerSpentEstimatedUsd !== undefined && !nonNegativeFiniteNumber(persisted.runnerSpentEstimatedUsd))) { + return { status: "failed_integrity", event: "program_arc_state_invalid", message: `Persisted state for arc ${expected.plan.id} does not match the compiled program.` }; + } + } + if (requestedBudget !== undefined && requestedBudget !== state.budgetUsd) + return { status: "failed_integrity", event: "program_budget_changed", message: "Program budget is immutable after a run starts." }; + return undefined; +} +function validateProgramAuthority(compiled) { + const errors = []; + if (compiled.authority.allowExternalEgress !== false) + errors.push("P0 authority must prohibit external egress"); + for (const arc of compiled.arcs) { + if (!compiled.authority.allowedArcModes.includes(arc.plan.mode)) + errors.push(`arc ${arc.plan.id} mode ${arc.plan.mode} is not authorized`); + if (arc.plan.externalEgress === true) + errors.push(`arc ${arc.plan.id} declares external egress, which P0 prohibits`); + const requestedAttempts = arc.plan.maxAttempts ?? 1; + if (requestedAttempts > compiled.authority.maxAttemptsPerArc) { + errors.push(`arc ${arc.plan.id} maxAttempts ${requestedAttempts} exceeds authority maxAttemptsPerArc ${compiled.authority.maxAttemptsPerArc}`); + } + } + return errors; +} +function nextExecutableArc(state) { + const interrupted = state.arcStates.find((arc) => arc.status === "running"); + if (interrupted) + return interrupted; + const byId = new Map(state.arcStates.map((arc) => [arc.id, arc])); + return state.arcStates.find((arc) => arc.status === "queued" && arc.dependsOn.every((id) => byId.get(id)?.status === "passed")); +} +function verifyProgramReceipt(root, hook) { + if (!hook) + return undefined; + if (hook.kind === "proofloop-envelope") { + const result = (0, proofReceipt_1.verifyProofReceiptEnvelopeFile)({ root, filePath: hook.file }); + return { + kind: hook.kind, + file: hook.file, + ok: result.ok, + errors: result.errors.map((entry) => `${entry.code}: ${entry.message}`), + }; + } + if (hook.kind === "nodekit-proof") { + const result = (0, nodekitProof_1.verifyNodekitProofBinding)({ + root, + releaseProofPath: hook.file, + candidateCommit: hook.candidateCommit, + ...(hook.minimumLevel !== undefined ? { minimumLevel: hook.minimumLevel } : {}), + ...(hook.compiledDefinition !== undefined ? { compiledDefinitionPath: hook.compiledDefinition } : {}), + ...(hook.configHashFile !== undefined ? { configHashPath: hook.configHashFile } : {}), + ...(hook.discovery !== undefined ? { discoveryPath: hook.discovery } : {}), + }); + return { + kind: hook.kind, + file: hook.file, + ok: result.ok, + errors: [ + ...result.errors, + ...result.gateReceipts.flatMap((receipt) => receipt.errors.map((error) => `${receipt.id}: ${error}`)), + ], + }; + } + const result = (0, receipts_1.verifyReceiptFile)({ + root, + filePath: hook.file, + kind: "nodeagent-ingestion", + ...(hook.minDocuments !== undefined ? { minDocuments: hook.minDocuments } : {}), + ...(hook.minMemoryObjects !== undefined ? { minMemoryObjects: hook.minMemoryObjects } : {}), + }); + return { + kind: hook.kind, + file: hook.file, + ok: result.ok, + errors: result.checks.filter((entry) => !entry.ok).map((entry) => `${entry.name}: ${entry.detail}`), + }; +} +function validGitCommit(value) { + return typeof value === "string" && /^[a-f0-9]{40,64}$/.test(value); +} +function resolveProgramPlanPath(options, existing) { + if (options.subcommand === "resume") { + if (!existing) + throw new Error("cannot resume: missing program state"); + return existing.planPath; + } + if (!options.planPath) + throw new Error("program run requires --plan "); + return options.planPath; +} +function resolveProgramRunId(root, runId) { + const resolved = runId && runId !== "latest" + ? runId + : (() => { + const latestPath = (0, node_path_1.join)(root, PROGRAM_ROOT, "latest"); + if (!(0, node_fs_1.existsSync)(latestPath)) + throw new Error("no latest program run exists"); + return (0, node_fs_1.readFileSync)(latestPath, "utf8").trim(); + })(); + if (!validId(resolved)) + throw new Error("program run-id must contain only letters, numbers, '.', '_', or '-'"); + return resolved; +} +function programStatus(options) { + const root = (0, node_path_1.resolve)(options.root); + const log = options.log ?? console.log; + const logError = options.logError ?? console.error; + try { + const runId = resolveProgramRunId(root, options.runId); + const runDir = programRunDir(root, runId); + const state = readProgramState(programStatePath(runDir)); + if (!state) + throw new Error(`missing program state for ${runId}`); + if (options.json) + log(JSON.stringify(state, null, 2)); + else + log(formatProofloopProgramStatus(state, runDir)); + return { state, runDir, ledgerPath: programLedgerPath(runDir), exitCode: 0 }; + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + logError(`proofloop program: ${message}`); + const fallbackRunId = validId(options.runId) ? options.runId : "unknown"; + const runDir = programRunDir(root, fallbackRunId); + return { state: emptyProgramErrorState(fallbackRunId, message), runDir, ledgerPath: programLedgerPath(runDir), exitCode: 2 }; + } +} +function programReport(options) { + const result = programStatus({ ...options, subcommand: "status", log: () => { }, logError: () => { } }); + const log = options.log ?? console.log; + const logError = options.logError ?? console.error; + if (result.exitCode !== 0) { + logError(`proofloop program: unable to load report for ${options.runId ?? "latest"}`); + return result; + } + const report = { + schema: "proofloop-program-report-v1", + programRunId: result.state.programRunId, + programId: result.state.programId, + status: result.state.status, + authorityDigest: result.state.authorityDigest, + budgetUsd: result.state.budgetUsd, + spentEstimatedUsd: result.state.spentEstimatedUsd, + arcs: result.state.arcStates.map((arc) => ({ + id: arc.id, + mode: arc.mode, + status: arc.status, + attempts: arc.attempts, + maxAttempts: arc.maxAttempts, + receiptVerified: arc.receipt?.ok ?? null, + })), + statePath: programStatePath(result.runDir), + ledgerPath: result.ledgerPath, + }; + if (options.json) + log(JSON.stringify(report, null, 2)); + else + log(`${formatProofloopProgramStatus(result.state, result.runDir)}\nreport=${JSON.stringify(report.arcs)}`); + return result; +} +function emitProgramResult(state, runDir, options, log) { + if (options.json) + log(JSON.stringify(state, null, 2)); + else + log(formatProofloopProgramStatus(state, runDir)); + return { state, runDir, ledgerPath: programLedgerPath(runDir), exitCode: programExitCode(state.status) }; +} +function terminalizeProgram(state, runDir, status, event, message, arcId, data) { + state.status = status; + state.updatedAt = nowIso(); + writeProgramState(runDir, state); + appendProgramEvent(runDir, { + programRunId: state.programRunId, + event, + ...(arcId ? { arcId } : {}), + data: { message, ...(data ?? {}) }, + }); + return state; +} +function writeProgramState(runDir, state) { + atomicWriteJson(programStatePath(runDir), state); +} +function readProgramState(path) { + if (!(0, node_fs_1.existsSync)(path)) + return undefined; + try { + const value = JSON.parse((0, node_fs_1.readFileSync)(path, "utf8")); + return isRecord(value) ? value : undefined; + } + catch { + return undefined; + } +} +function appendProgramEvent(runDir, event) { + (0, node_fs_1.mkdirSync)(runDir, { recursive: true }); + const full = { schema: exports.PROOFLOOP_PROGRAM_EVENT_SCHEMA, at: nowIso(), ...event }; + (0, node_fs_1.appendFileSync)(programLedgerPath(runDir), `${JSON.stringify(full)}\n`, "utf8"); +} +function repairProgramLedgerTornTail(runDir) { + const ledgerPath = programLedgerPath(runDir); + if (!(0, node_fs_1.existsSync)(ledgerPath)) + return { repaired: false, previousBytes: 0, repairedBytes: 0 }; + const raw = (0, node_fs_1.readFileSync)(ledgerPath, "utf8"); + const previousBytes = Buffer.byteLength(raw); + if (raw.length === 0 || raw.endsWith("\n")) + return { repaired: false, previousBytes, repairedBytes: previousBytes }; + const lastNewline = raw.lastIndexOf("\n"); + const repaired = lastNewline >= 0 ? raw.slice(0, lastNewline + 1) : ""; + const repairedBytes = Buffer.byteLength(repaired); + (0, node_fs_1.truncateSync)(ledgerPath, repairedBytes); + return { repaired: true, previousBytes, repairedBytes }; +} +function acquireProgramLock(runDir, ttlMs, clearStaleLock) { + (0, node_fs_1.mkdirSync)(runDir, { recursive: true }); + const path = (0, node_path_1.join)(runDir, "program.lock"); + const token = (0, node_crypto_1.randomUUID)(); + try { + const fd = (0, node_fs_1.openSync)(path, "wx"); + (0, node_fs_1.writeFileSync)(fd, JSON.stringify({ token, pid: process.pid, createdAt: nowIso() })); + return programLockHandle(path, fd, token); + } + catch (error) { + const code = isRecord(error) && typeof error.code === "string" ? error.code : ""; + if (code !== "EEXIST") + throw error; + const ageMs = programLockAgeMs(path); + if (ageMs <= ttlMs) + throw new Error(`program lock is held at ${path}; ageMs=${ageMs}`); + if (!clearStaleLock) + throw new Error(`program lock is stale at ${path}; rerun with --clear-stale-lock to recover`); + (0, node_fs_1.rmSync)(path, { force: true }); + const fd = (0, node_fs_1.openSync)(path, "wx"); + (0, node_fs_1.writeFileSync)(fd, JSON.stringify({ token, pid: process.pid, createdAt: nowIso(), stoleStaleLock: true })); + return programLockHandle(path, fd, token); + } +} +function programLockHandle(path, fd, token) { + return { + release: () => { + try { + (0, node_fs_1.closeSync)(fd); + } + catch { + // Best effort; the token check below still prevents another process's lock from being removed. + } + try { + const raw = (0, node_fs_1.readFileSync)(path, "utf8"); + const parsed = JSON.parse(raw); + if (parsed.token === token) + (0, node_fs_1.unlinkSync)(path); + } + catch { + // A stale lock can be explicitly recovered by the next operator. + } + }, + }; +} +function programLockAgeMs(path) { + try { + return Math.max(0, Date.now() - (0, node_fs_1.statSync)(path).mtimeMs); + } + catch { + return Number.POSITIVE_INFINITY; + } +} +function writeLatestProgramRun(root, runId) { + const path = (0, node_path_1.join)(root, PROGRAM_ROOT, "latest"); + atomicWriteText(path, `${runId}\n`); +} +function atomicWriteJson(path, value) { + atomicWriteText(path, `${JSON.stringify(value, null, 2)}\n`); +} +function atomicWriteText(path, text) { + (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true }); + const temporary = `${path}.${process.pid}.${(0, node_crypto_1.randomUUID)()}.tmp`; + (0, node_fs_1.writeFileSync)(temporary, text, "utf8"); + try { + (0, node_fs_1.renameSync)(temporary, path); + } + catch (error) { + try { + if ((0, node_fs_1.existsSync)(path)) + (0, node_fs_1.unlinkSync)(path); + (0, node_fs_1.renameSync)(temporary, path); + } + catch { + throw error; + } + } +} +function emptyProgramErrorState(programRunId, message) { + const now = nowIso(); + return { + schema: exports.PROOFLOOP_PROGRAM_STATE_SCHEMA, + programRunId, + programId: "unknown", + planPath: "", + planDigest: "", + authorityPath: "", + authorityDigest: "", + budgetUsd: 0, + spentEstimatedUsd: 0, + status: "failed_integrity", + createdAt: now, + updatedAt: now, + arcStates: [{ + id: "program", + mode: "read_only", + dependsOn: [], + runnerPlanPath: "", + runnerPlanDigest: "", + runnerRunId: programRunId, + estimatedCostUsd: 0, + maxAttempts: 1, + attempts: 0, + status: "failed", + error: message, + }], + }; +} +function programExitCode(status) { + if (status === "certified") + return 0; + if (status === "paused" || status === "queued" || status === "running") + return 4; + if (status === "blocked_budget") + return 3; + if (status === "blocked_authority") + return 4; + return 1; +} +function normalizeMaxArcs(value) { + if (value === undefined) + return Number.POSITIVE_INFINITY; + if (!positiveInteger(value)) + throw new Error("program --max-arcs must be a positive integer"); + return value; +} +function defaultProgramRunId(programId) { + return `${programId}-${new Date().toISOString().replace(/[-:]/g, "").replace(/\..+$/, "Z")}`; +} +function resolveProgramRepoFile(root, pathInput, label, options = {}) { + if (typeof pathInput !== "string" || pathInput.length === 0) + throw new Error(`${label} path is required`); + if (!options.allowAbsoluteInsideRoot && !safeRepoRelativePath(pathInput)) + throw new Error(`${label} must be a safe repo-relative path`); + const rootReal = realPathOrResolved(root); + const candidate = (0, node_path_1.isAbsolute)(pathInput) ? (0, node_path_1.resolve)(pathInput) : (0, node_path_1.resolve)(root, pathInput); + if (!(0, node_fs_1.existsSync)(candidate)) + throw new Error(`${label} does not exist: ${pathInput}`); + const candidateReal = realPathOrResolved(candidate); + const escaped = (0, node_path_1.relative)(rootReal, candidateReal); + if (escaped === ".." || escaped.startsWith(`..${node_path_1.sep}`) || (0, node_path_1.isAbsolute)(escaped)) + throw new Error(`${label} escapes the repository root`); + if (!(0, node_fs_1.statSync)(candidateReal).isFile()) + throw new Error(`${label} is not a regular file: ${pathInput}`); + return candidateReal; +} +function realPathOrResolved(path) { + try { + return (0, node_path_1.resolve)((0, node_fs_1.realpathSync)(path)); + } + catch { + return (0, node_path_1.resolve)(path); + } +} +function safeRepoRelativePath(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 parseArcMode(value, label) { + if (value === "read_only" || value === "proposal_only") + return value; + throw new Error(`${label} must be read_only or proposal_only`); +} +function parseIdArray(value, label) { + if (value === undefined) + return []; + if (!Array.isArray(value) || !value.every(validId)) + throw new Error(`${label} must be an array of IDs`); + return value; +} +function validId(value) { + return typeof value === "string" && ID_PATTERN.test(value); +} +function nonNegativeInteger(value) { + return typeof value === "number" && Number.isInteger(value) && value >= 0; +} +function sameStringArray(left, right) { + return Array.isArray(left) && left.length === right.length && left.every((value, index) => value === right[index]); +} +function isProofloopProgramStatus(value) { + return value === "queued" || value === "running" || value === "paused" || value === "certified" + || value === "failed" || value === "failed_integrity" || value === "blocked_budget" || value === "blocked_authority"; +} +function isProofloopProgramArcStatus(value) { + return value === "queued" || value === "running" || value === "passed" || value === "failed" + || value === "blocked_budget" || value === "blocked_authority"; +} +function positiveInteger(value) { + return typeof value === "number" && Number.isInteger(value) && value > 0; +} +function requirePositiveInteger(value, label) { + if (!positiveInteger(value)) + throw new Error(`${label} must be a positive integer`); + return value; +} +function requireNonNegativeInteger(value, label) { + if (typeof value !== "number" || !Number.isInteger(value) || value < 0) + throw new Error(`${label} must be a non-negative integer`); + return value; +} +function nonNegativeFiniteNumber(value) { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} +function rejectUnknownKeys(value, allowed, label) { + for (const key of Object.keys(value)) { + if (!allowed.has(key)) + throw new Error(`${label} has unknown key \"${key}\"`); + } +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function nowIso() { + return new Date().toISOString(); +} +function roundMoney(value) { + return Math.round((value + Number.EPSILON) * 1_000_000) / 1_000_000; +} 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..92b664f 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, @@ -54,6 +59,9 @@ import { type ProofloopAgentTarget, } from "./project"; import { runProofloopRunner } from "./runner"; +import { runProofloopProgram } from "./program"; +import { runNodekitProofBindingVerify, type NodekitProofMinimumLevel } from "./nodekitProof"; +import { runEaseProofVerify } from "./easeProof"; import { runProofloopTarget } from "./targetPlan"; import { buildHostedRunBundle, @@ -158,11 +166,15 @@ 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", + " ease verify --manifest [--out ] verify NodeKit EaseProof evidence integrity without inventing usability authority", " 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 ", " solo verify-attestation --file [--public-key-file ] [--key-id ]", " runner run|resume|status|report durable append-only task runner with budget and resume", + " program run|resume|status|report|verify-nodekit P0 program supervisor and local NodeKit proof binding", " hosted intake|validate|dashboard|run create or resume a hosted URL proof packet", " target [--url ] [--write-runner-plan] [--write-browser-smoke] recommend benchmark families and write target/context receipts", " maturity [--dense|--json|--write] [--target-level 5] judge agent-era codebase/app maturity and missing layers", @@ -251,7 +263,10 @@ 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 "ease": + return runEaseCommand(positional[1], options, root); case "solo": return runSoloCommand(positional[1], options, root); @@ -259,6 +274,9 @@ export function runCli(argv: string[]): number | Promise { case "runner": return runRunnerCommand(positional[1], options, root); + case "program": + return runProgramCommand(positional[1], options, root); + case "hosted": return runHostedCommand(positional[1], options, root); @@ -814,9 +832,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; } @@ -842,6 +892,24 @@ function runReceiptCommand(sub: string | undefined, options: Record, + root: string, +): number { + if (sub !== "verify") { + console.error("proofloop ease: expected `verify`."); + return 2; + } + const manifestPath = str(options.manifest) ?? "proof/ease/latest/manifest.json"; + return runEaseProofVerify({ + root, + manifestPath, + ...(str(options.out) !== undefined ? { outputPath: str(options.out)! } : {}), + json: options.json === true, + }); +} + async function runRunnerCommand(sub: string | undefined, options: Record, root: string): Promise { if (sub !== "run" && sub !== "resume" && sub !== "status" && sub !== "report") { console.error("proofloop runner: expected `run`, `resume`, `status`, or `report`."); @@ -862,6 +930,48 @@ async function runRunnerCommand(sub: string | undefined, options: Record, root: string): Promise { + if (sub === "verify-nodekit") { + const releaseProofPath = str(options.file); + const candidateCommit = str(options["candidate-commit"]); + if (!releaseProofPath || !candidateCommit) { + console.error("proofloop program verify-nodekit: requires --file and --candidate-commit ."); + return 2; + } + const minimumLevel = str(options["minimum-level"]); + if (minimumLevel !== undefined && minimumLevel !== "local-ready" && minimumLevel !== "release-ready") { + console.error("proofloop program verify-nodekit: --minimum-level must be local-ready or release-ready."); + return 2; + } + return runNodekitProofBindingVerify({ + root, + releaseProofPath, + candidateCommit, + ...(minimumLevel !== undefined ? { minimumLevel: minimumLevel as NodekitProofMinimumLevel } : {}), + ...(str(options["compiled-definition"]) !== undefined ? { compiledDefinitionPath: str(options["compiled-definition"])! } : {}), + ...(str(options["config-hash-file"]) !== undefined ? { configHashPath: str(options["config-hash-file"])! } : {}), + ...(str(options.discovery) !== undefined ? { discoveryPath: str(options.discovery)! } : {}), + json: options.json === true, + }); + } + if (sub !== "run" && sub !== "resume" && sub !== "status" && sub !== "report") { + console.error("proofloop program: expected `run`, `resume`, `status`, `report`, or `verify-nodekit`."); + return 2; + } + const result = await runProofloopProgram({ + root, + subcommand: sub, + ...(str(options.plan) !== undefined ? { planPath: str(options.plan)! } : {}), + ...(str(options["run-id"]) !== undefined ? { runId: str(options["run-id"])! } : {}), + ...(num(options["budget-usd"]) !== undefined ? { budgetUsd: num(options["budget-usd"])! } : {}), + ...(num(options["max-arcs"]) !== undefined ? { maxArcs: num(options["max-arcs"])! } : {}), + ...(num(options["lock-ttl-ms"]) !== undefined ? { lockTtlMs: num(options["lock-ttl-ms"])! } : {}), + clearStaleLock: options["clear-stale-lock"] === true, + json: options.json === true, + }); + return result.exitCode; +} + async function runTargetCommand(options: Record, root: string): Promise { const result = await runProofloopTarget({ root, diff --git a/src/easeProof.ts b/src/easeProof.ts new file mode 100644 index 0000000..19363dd --- /dev/null +++ b/src/easeProof.ts @@ -0,0 +1,223 @@ +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; +import { + PROOFLOOP_RECEIPT_SCHEMA, + createInlineProofReceiptPayload, + type ProofReceiptEnvelope, + validateProofReceiptEnvelope, +} from "./proofReceipt"; + +type JsonRecord = Record; + +export interface EaseProofVerification { + ok: boolean; + easeCertified: boolean; + errors: string[]; + warnings: string[]; + manifestPath: string; + browserManifestPath?: string; + checkedScreenshots: number; + checkedReplayArtifacts: number; + envelope?: ProofReceiptEnvelope; + outputPath?: string; +} + +function sha256(value: string | Buffer): string { + return createHash("sha256").update(value).digest("hex"); +} + +function readJson(path: string, label: string, errors: string[]): JsonRecord | undefined { + if (!existsSync(path)) { + errors.push(`${label} is missing: ${path}`); + return undefined; + } + try { + return JSON.parse(readFileSync(path, "utf8")) as JsonRecord; + } catch (error) { + errors.push(`${label} is invalid JSON: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } +} + +function verifyEmittedDigest(value: JsonRecord, key: string, label: string, errors: string[]): void { + const emitted = value[key]; + if (typeof emitted !== "string" || !/^[a-f0-9]{64}$/.test(emitted)) { + errors.push(`${label} ${key} is missing or invalid`); + return; + } + const covered = { ...value }; + delete covered[key]; + if (sha256(JSON.stringify(covered)) !== emitted) errors.push(`${label} ${key} does not match content`); +} + +export function verifyEaseProof(options: { root: string; manifestPath: string; outputPath?: string }): EaseProofVerification { + const root = resolve(options.root); + const manifestPath = isAbsolute(options.manifestPath) ? options.manifestPath : resolve(root, options.manifestPath); + const evidenceRoot = dirname(manifestPath); + const errors: string[] = []; + const warnings: string[] = []; + const manifest = readJson(manifestPath, "EaseProof manifest", errors); + let browserManifest: JsonRecord | undefined; + let checkedScreenshots = 0; + let checkedReplayArtifacts = 0; + + if (manifest) { + if (manifest.schemaVersion !== "nodekit.ease-proof-run/v1") errors.push("EaseProof manifest schemaVersion must be nodekit.ease-proof-run/v1"); + verifyEmittedDigest(manifest, "receiptDigest", "EaseProof manifest", errors); + if (!Array.isArray(manifest.base?.phases) || manifest.base.phases.length === 0) errors.push("EaseProof phase timer ledger is missing"); + else for (const phase of manifest.base.phases) { + if (!Number.isFinite(phase.durationMs) || phase.durationMs < 0) errors.push(`EaseProof phase ${phase.name ?? "unknown"} has invalid durationMs`); + if (phase.exitCode !== undefined && phase.exitCode !== 0) errors.push(`EaseProof phase ${phase.name ?? "unknown"} did not exit 0`); + } + + const browserManifestPath = resolve(evidenceRoot, "browser", "screenshot-manifest.json"); + browserManifest = readJson(browserManifestPath, "browser screenshot manifest", errors); + if (browserManifest) { + verifyEmittedDigest(browserManifest, "manifestSha256", "browser screenshot manifest", errors); + if (manifest.base?.browserManifestDigest !== browserManifest.manifestSha256) errors.push("factory and browser manifest digests do not match"); + const screenshots = Array.isArray(browserManifest.screenshots) ? browserManifest.screenshots : []; + if (screenshots.length === 0) errors.push("browser screenshot manifest contains no screenshots"); + for (const screenshot of screenshots) { + const relativePath = screenshot.path; + if (typeof relativePath !== "string" || relativePath.includes("..") || isAbsolute(relativePath)) { + errors.push("screenshot path is unsafe"); + continue; + } + const pngPath = resolve(evidenceRoot, relativePath); + if (!existsSync(pngPath)) { + errors.push(`screenshot is missing: ${relativePath}`); + continue; + } + checkedScreenshots += 1; + if (sha256(readFileSync(pngPath)) !== screenshot.pngSha256) errors.push(`screenshot digest mismatch: ${relativePath}`); + if (screenshot.generatedCandidateCommit !== manifest.base?.candidateCommit) errors.push(`screenshot candidate mismatch: ${relativePath}`); + if (screenshot.applicationHash !== manifest.base?.applicationHash || screenshot.configHash !== manifest.base?.configHash) errors.push(`screenshot application identity mismatch: ${relativePath}`); + if (screenshot.nodekitSourceHash !== manifest.nodekitSourceHash) errors.push(`screenshot NodeKit source mismatch: ${relativePath}`); + if (screenshot.consoleErrors !== 0 || screenshot.failedRequests !== 0 || screenshot.horizontalOverflowPx !== 0 || screenshot.mojibakeDetected !== false) { + errors.push(`screenshot browser checks failed: ${relativePath}`); + } + } + + const replayArtifacts = Array.isArray(browserManifest.evidenceArtifacts) ? browserManifest.evidenceArtifacts : []; + const requiredReplayIds = new Set(["playwright-trace", "browser-video"]); + for (const artifact of replayArtifacts) { + const relativePath = artifact.path; + if (typeof relativePath !== "string" || relativePath.includes("..") || isAbsolute(relativePath)) { + errors.push("browser replay artifact path is unsafe"); + continue; + } + const artifactPath = resolve(evidenceRoot, relativePath); + if (!existsSync(artifactPath)) { + errors.push(`browser replay artifact is missing: ${relativePath}`); + continue; + } + const bytes = readFileSync(artifactPath); + checkedReplayArtifacts += 1; + if (sha256(bytes) !== artifact.sha256) errors.push(`browser replay artifact digest mismatch: ${relativePath}`); + if (bytes.byteLength !== artifact.byteSize) errors.push(`browser replay artifact size mismatch: ${relativePath}`); + requiredReplayIds.delete(String(artifact.id)); + } + for (const id of requiredReplayIds) errors.push(`required browser replay artifact is missing: ${id}`); + + const journeyAssertions = browserManifest.journeyAssertions; + for (const assertion of ["proposalVisible", "approvalApplied", "receiptVisible", "receiptSurvivedReload"]) { + if (journeyAssertions?.[assertion] !== true) errors.push(`browser journey assertion failed: ${assertion}`); + } + if (!Number.isInteger(browserManifest.serverProcess?.pid) || typeof browserManifest.serverProcess?.command !== "string") { + errors.push("browser server process identity is missing"); + } + } + + const candidateArchive = resolve(evidenceRoot, "candidate.tar.gz"); + if (!existsSync(candidateArchive)) errors.push("generated candidate archive is missing"); + + const easeCertified = manifest.submissionReady === true + && Array.isArray(manifest.submissionBlockers) + && manifest.submissionBlockers.length === 0 + && browserManifest?.certified === true; + if (!easeCertified) warnings.push("Evidence integrity may pass, but NodeKit Ease is not certified and submission remains blocked."); + + const manifestBytes = readFileSync(manifestPath); + const browserBytes = existsSync(browserManifestPath) ? readFileSync(browserManifestPath) : Buffer.from(""); + const archiveBytes = existsSync(candidateArchive) ? readFileSync(candidateArchive) : Buffer.from(""); + const receiptId = `ease-${String(manifest.runId ?? "unknown")}`; + const envelope: ProofReceiptEnvelope = { + schema: PROOFLOOP_RECEIPT_SCHEMA, + schemaVersion: 1, + receiptId, + kind: "nodekit-ease-integrity", + createdAt: new Date().toISOString(), + producer: { id: "proofloop", version: "0.3.0", configHash: manifest.nodekitSourceHash }, + subject: { + type: "run", + id: String(manifest.runId ?? "unknown"), + runId: String(manifest.runId ?? "unknown"), + repository: { candidateCommit: manifest.base?.candidateCommit, dirty: false }, + }, + claim: { + text: easeCertified + ? "The supplied NodeKit EaseProof evidence is internally bound and all submission gates are represented as passed." + : "The supplied NodeKit EaseProof evidence is internally bound; this receipt does not certify ease, human usability, deployment, or submission readiness.", + boundary: "proxy", + tier: easeCertified ? "certification_ready" : "local_ready", + }, + verdict: { + status: errors.length === 0 ? "passed" : "failed", + authority: "authoritative", + decisionMethod: "deterministic_gate", + decisiveCheckIds: ["ease-integrity"], + summary: errors.length === 0 ? "Local EaseProof hashes and identities verified." : "EaseProof integrity verification failed.", + }, + checks: [{ + id: "ease-integrity", + status: errors.length === 0 ? "passed" : "failed", + role: "decisive", + method: "deterministic", + summary: `${checkedScreenshots} screenshot(s), ${checkedReplayArtifacts} replay artifact(s), and the candidate/timer manifests were checked; Ease certification=${easeCertified}.`, + evidenceRefs: ["ease-manifest", "browser-manifest", "candidate-archive", "playwright-trace", "browser-video"], + }], + evidence: [ + { id: "ease-manifest", kind: "ease-manifest", path: relative(evidenceRoot, manifestPath).replaceAll("\\", "/") || "manifest.json", sha256: sha256(manifestBytes), hashMethod: "raw-bytes-sha256" }, + { id: "browser-manifest", kind: "screenshot-manifest", path: relative(evidenceRoot, browserManifestPath).replaceAll("\\", "/"), sha256: sha256(browserBytes), hashMethod: "raw-bytes-sha256" }, + { id: "candidate-archive", kind: "generated-candidate", path: relative(evidenceRoot, candidateArchive).replaceAll("\\", "/"), sha256: sha256(archiveBytes), hashMethod: "raw-bytes-sha256" }, + ...((browserManifest?.evidenceArtifacts ?? []) as JsonRecord[]).map((artifact) => ({ + id: String(artifact.id), + kind: String(artifact.id), + path: String(artifact.path), + sha256: String(artifact.sha256), + hashMethod: "raw-bytes-sha256" as const, + })), + ], + payload: createInlineProofReceiptPayload("nodekit.ease-verification/v1", { easeCertified, errors, warnings, checkedScreenshots, runId: manifest.runId }, 1), + timing: { startedAt: manifest.startedAt, completedAt: manifest.generatedAt, durationMs: manifest.durationMs }, + privacy: { visibility: "private", redacted: true, externalEgress: false }, + extensions: { easeCertified, submissionBlockers: manifest.submissionBlockers ?? [] }, + }; + const envelopeValidation = validateProofReceiptEnvelope(envelope); + for (const issue of envelopeValidation.errors) errors.push(`generated envelope ${issue.path}: ${issue.message}`); + let outputPath: string | undefined; + if (options.outputPath) { + outputPath = isAbsolute(options.outputPath) ? options.outputPath : resolve(root, options.outputPath); + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, `${JSON.stringify(envelope, null, 2)}\n`, "utf8"); + } + return { ok: errors.length === 0, easeCertified, errors, warnings, manifestPath, browserManifestPath, checkedScreenshots, checkedReplayArtifacts, envelope, ...(outputPath ? { outputPath } : {}) }; + } + return { ok: false, easeCertified: false, errors, warnings, manifestPath, checkedScreenshots, checkedReplayArtifacts }; +} + +export function runEaseProofVerify(options: { root: string; manifestPath: string; outputPath?: string; json?: boolean }): number { + const result = verifyEaseProof(options); + const rendered = options.json ? JSON.stringify(result, null, 2) : [ + `proofloop ease verify: ${result.ok ? "integrity-passed" : "failed"}`, + `easeCertified=${result.easeCertified}`, + `checkedScreenshots=${result.checkedScreenshots}`, + `checkedReplayArtifacts=${result.checkedReplayArtifacts}`, + ...result.errors.map((entry) => `FAIL ${entry}`), + ...result.warnings.map((entry) => `WARN ${entry}`), + ...(result.outputPath ? [`receipt=${result.outputPath}`] : []), + ].join("\n"); + (result.ok ? console.log : console.error)(rendered); + return result.ok ? 0 : 1; +} diff --git a/src/index.ts b/src/index.ts index 3cf35c3..85aaef6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ export * from "./scaffoldConstants"; export * from "./project"; export * from "./mcp"; export * from "./runner"; +export * from "./program"; export * from "./layeredPlan"; export * from "./targetPlan"; export * from "./hosted"; @@ -24,6 +25,9 @@ export * from "./maturity"; export * from "./productivity"; export * from "./contextReport"; export * from "./receipts"; +export * from "./proofReceipt"; +export * from "./nodekitProof"; +export * from "./easeProof"; export * from "./agentAdapters"; export * from "./agentLoop"; export * from "./codexRelaunch"; diff --git a/src/nodekitProof.ts b/src/nodekitProof.ts new file mode 100644 index 0000000..533713d --- /dev/null +++ b/src/nodekitProof.ts @@ -0,0 +1,611 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; + +/** + * NodeKit's generated applications currently emit `nodekit.proof-receipt/v1` + * as their local/release proof. That receipt is useful, but it does not carry + * the candidate commit or compiled application identity itself. This module + * binds the receipt to the checked-out candidate and the compiler outputs + * before a ProofLoop program may treat it as a passing arc. + * + * This is intentionally local-only. It does not deploy, invoke providers, or + * create a promotion claim. It verifies bytes already present in the project. + */ +export const NODEKIT_PROOF_RECEIPT_SCHEMA = "nodekit.proof-receipt/v1" as const; +export const NODEKIT_COMPILED_DEFINITION_SCHEMA = "nodeagent.resolved/v1" as const; +export const NODEKIT_DISCOVERY_SCHEMA = "nodeagent.discovery/v1" as const; + +export type NodekitProofMinimumLevel = "local-ready" | "release-ready"; + +export type VerifyNodekitProofBindingOptions = { + root: string; + releaseProofPath: string; + candidateCommit: string; + minimumLevel?: NodekitProofMinimumLevel; + compiledDefinitionPath?: string; + configHashPath?: string; + discoveryPath?: string; +}; + +export type NodekitProofGateReceipt = { + id: string; + path: string; + sha256?: string; + ok: boolean; + errors: string[]; +}; + +export type NodekitProofApplicationIdentity = { + configHash: string; + manifestDigest: string; + discoveryDigest: string; + fileCount: number; + candidateCommit: string; + observedCandidateCommit?: string; +}; + +export type NodekitProofBindingVerification = { + schema: "proofloop-nodekit-proof-binding-v1"; + ok: boolean; + releaseProofPath: string; + candidateCommit: string; + minimumLevel: NodekitProofMinimumLevel; + errors: string[]; + gateReceipts: NodekitProofGateReceipt[]; + identity?: NodekitProofApplicationIdentity; +}; + +type UnknownRecord = Record; + +type CompiledDefinition = { + configHash: string; + fileCount: number; + manifestDigest: string; + schemaVersion: string; +}; + +type DiscoveryFile = { + bytes: number; + digest: string; + path: string; +}; + +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const GIT_SHA_PATTERN = /^[a-f0-9]{40,64}$/; +const NODEKIT_SCHEMA_PATTERN = /^nodekit\.[a-z0-9][a-z0-9._-]*\/v\d+$/; +const DEFAULT_DEFINITION_PATH = ".nodeagent/resolved-definition.json"; +const DEFAULT_CONFIG_HASH_PATH = ".nodeagent/config-hash.txt"; +const DEFAULT_DISCOVERY_PATH = ".nodeagent/discovery.json"; + +/** + * Verify a generated NodeKit local/release proof against compiler outputs and + * the current Git candidate. The result is evidence only; callers decide how + * it contributes to a larger program verdict. + */ +export function verifyNodekitProofBinding(options: VerifyNodekitProofBindingOptions): NodekitProofBindingVerification { + const root = resolve(options.root); + const minimumLevel = options.minimumLevel ?? "local-ready"; + const errors: string[] = []; + const gateReceipts: NodekitProofGateReceipt[] = []; + const releaseProofPath = displayPath(options.releaseProofPath); + + if (!GIT_SHA_PATTERN.test(options.candidateCommit)) { + errors.push("candidateCommit must be a lowercase 40-64 character Git SHA"); + } + if (minimumLevel !== "local-ready" && minimumLevel !== "release-ready") { + errors.push("minimumLevel must be local-ready or release-ready"); + } + + const observedCandidateCommit = readCurrentCommit(root, errors); + if (observedCandidateCommit && observedCandidateCommit !== options.candidateCommit) { + errors.push(`candidate commit mismatch: expected ${options.candidateCommit}, observed ${observedCandidateCommit}`); + } + + const releasePath = resolveRegularFile(root, options.releaseProofPath, "NodeKit release proof", errors); + const definitionPath = resolveRegularFile(root, options.compiledDefinitionPath ?? DEFAULT_DEFINITION_PATH, "NodeKit compiled definition", errors); + const configPath = resolveRegularFile(root, options.configHashPath ?? DEFAULT_CONFIG_HASH_PATH, "NodeKit config hash", errors); + const discoveryPath = resolveRegularFile(root, options.discoveryPath ?? DEFAULT_DISCOVERY_PATH, "NodeKit discovery", errors); + + const compiled = definitionPath ? readCompiledDefinition(definitionPath, errors) : undefined; + const configHash = configPath ? readConfigHash(configPath, errors) : undefined; + const discovery = discoveryPath ? readDiscovery(discoveryPath, errors) : undefined; + const releaseProof = releasePath ? readJsonRecord(releasePath, "NodeKit release proof", errors) : undefined; + + let identity: NodekitProofApplicationIdentity | undefined; + if (compiled && configHash && discovery && discoveryPath) { + if (compiled.configHash !== configHash) { + errors.push("compiled definition configHash does not match .nodeagent/config-hash.txt"); + } + if (compiled.fileCount !== discovery.files.length) { + errors.push(`compiled definition fileCount ${compiled.fileCount} does not match discovery file count ${discovery.files.length}`); + } + verifyManifestDigest(root, compiled.manifestDigest, options.candidateCommit, errors); + verifyDiscoveryFiles(root, discovery.files, options.candidateCommit, errors); + identity = { + configHash: compiled.configHash, + manifestDigest: compiled.manifestDigest, + discoveryDigest: sha256(readFileSync(discoveryPath)), + fileCount: compiled.fileCount, + candidateCommit: options.candidateCommit, + ...(observedCandidateCommit ? { observedCandidateCommit } : {}), + }; + } + + if (releaseProof && releasePath) { + verifyReleaseProof({ + root, + releasePath, + releaseProof, + minimumLevel, + compiledConfigHash: compiled?.configHash, + candidateCommit: options.candidateCommit, + errors, + gateReceipts, + }); + } + + return { + schema: "proofloop-nodekit-proof-binding-v1", + ok: errors.length === 0 && gateReceipts.every((receipt) => receipt.ok), + releaseProofPath, + candidateCommit: options.candidateCommit, + minimumLevel, + errors, + gateReceipts, + ...(identity ? { identity } : {}), + }; +} + +export function formatNodekitProofBindingVerification(result: NodekitProofBindingVerification): string { + const lines = [ + `schema=${result.schema}`, + `status=${result.ok ? "passed" : "failed"}`, + `releaseProof=${result.releaseProofPath}`, + `candidateCommit=${result.candidateCommit}`, + `minimumLevel=${result.minimumLevel}`, + ]; + if (result.identity) { + lines.push(`configHash=${result.identity.configHash}`); + lines.push(`discoveryDigest=${result.identity.discoveryDigest}`); + lines.push(`fileCount=${result.identity.fileCount}`); + } + lines.push("gateReceipts:"); + for (const receipt of result.gateReceipts) { + lines.push(`- ${receipt.ok ? "PASS" : "FAIL"} ${receipt.id} ${receipt.path}${receipt.sha256 ? ` sha256=${receipt.sha256}` : ""}`); + for (const error of receipt.errors) lines.push(` - ${error}`); + } + if (result.errors.length === 0) lines.push("errors: none"); + else { + lines.push("errors:"); + for (const error of result.errors) lines.push(`- ${error}`); + } + return `${lines.join("\n")}\n`; +} + +export function runNodekitProofBindingVerify(options: VerifyNodekitProofBindingOptions & { + json?: boolean; + log?: (message: string) => void; + logError?: (message: string) => void; +}): number { + const result = verifyNodekitProofBinding(options); + const output = options.json === true + ? JSON.stringify(result, null, 2) + : formatNodekitProofBindingVerification(result); + const log = options.log ?? console.log; + const logError = options.logError ?? console.error; + if (result.ok) log(output); + else logError(output); + return result.ok ? 0 : 1; +} + +function verifyReleaseProof(args: { + root: string; + releasePath: string; + releaseProof: UnknownRecord; + minimumLevel: NodekitProofMinimumLevel; + compiledConfigHash?: string; + candidateCommit: string; + errors: string[]; + gateReceipts: NodekitProofGateReceipt[]; +}): void { + const { releaseProof, minimumLevel, errors } = args; + if (releaseProof.schemaVersion !== NODEKIT_PROOF_RECEIPT_SCHEMA) { + errors.push(`NodeKit release proof schemaVersion must be ${NODEKIT_PROOF_RECEIPT_SCHEMA}`); + } + if (releaseProof.passed !== true) errors.push("NodeKit release proof must have passed=true"); + if (typeof releaseProof.configHash !== "string" || !SHA256_PATTERN.test(releaseProof.configHash)) { + errors.push("NodeKit release proof configHash must be a SHA-256 digest"); + } else if (args.compiledConfigHash !== undefined && releaseProof.configHash !== args.compiledConfigHash) { + errors.push("NodeKit release proof configHash does not match the compiled NodeKit configHash"); + } + if (releaseProof.applicationHash !== undefined && releaseProof.applicationHash !== releaseProof.configHash) { + errors.push("NodeKit release proof applicationHash must match configHash when present"); + } + verifyReleaseReceiptVerification(releaseProof, args.compiledConfigHash, args.candidateCommit, errors); + const checks = asRecord(releaseProof.checks); + if (!checks) { + errors.push("NodeKit release proof checks must be an object"); + return; + } + for (const id of ["deterministicDemo", "deterministicEvaluation", "secretFree"]) { + if (checks[id] !== true) errors.push(`NodeKit release proof checks.${id} must be true`); + } + + const level = releaseProof.level; + const releaseReady = releaseProof.releaseReady; + if (level !== "local-ready" && level !== "release-ready") { + errors.push("NodeKit release proof level must be local-ready or release-ready"); + } + if (typeof releaseReady !== "boolean") { + errors.push("NodeKit release proof releaseReady must be boolean"); + } else if ((level === "release-ready") !== releaseReady) { + errors.push("NodeKit release proof level and releaseReady disagree"); + } + if (minimumLevel === "release-ready" && level !== "release-ready") { + errors.push("NodeKit release proof does not meet required release-ready level"); + } + + const proofDirectory = dirname(args.releasePath); + const demo = verifyJsonGate({ + root: args.root, + path: joinRelative(args.root, proofDirectory, "demo-receipt.json"), + id: "demo", + requireNodekitSchema: true, + configHash: args.compiledConfigHash, + candidateCommit: args.candidateCommit, + requirePassed: false, + }); + const evaluation = verifyJsonGate({ + root: args.root, + path: joinRelative(args.root, proofDirectory, "eval-receipt.json"), + id: "evaluation", + requireNodekitSchema: true, + requirePassed: true, + configHash: args.compiledConfigHash, + candidateCommit: args.candidateCommit, + }); + args.gateReceipts.push(demo, evaluation); + + const optionalGateSpecs: Array<{ + id: string; + check: string; + filename: string; + expectedSchema?: string; + requireNodekitSchema?: boolean; + requirePassed?: boolean; + acceptedStatus?: string; + }> = [ + { id: "live", check: "livePi", filename: "pi-live-receipt.json", requireNodekitSchema: true, requirePassed: true, acceptedStatus: "pass" }, + { id: "browser", check: "browserQa", filename: "browser-proof.json", expectedSchema: "nodekit.browser-proof/v1", requirePassed: true }, + { id: "deployment", check: "deployment", filename: "deployment-receipt.json", requireNodekitSchema: true, requirePassed: true, acceptedStatus: "pass" }, + ]; + for (const spec of optionalGateSpecs) { + const mustVerify = minimumLevel === "release-ready" || checks[spec.check] === true; + if (!mustVerify) continue; + const receipt = verifyJsonGate({ + root: args.root, + path: joinRelative(args.root, proofDirectory, spec.filename), + id: spec.id, + ...(spec.expectedSchema ? { expectedSchema: spec.expectedSchema } : {}), + ...(spec.requireNodekitSchema ? { requireNodekitSchema: true } : {}), + configHash: args.compiledConfigHash, + candidateCommit: args.candidateCommit, + requirePassed: spec.requirePassed === true, + ...(spec.acceptedStatus ? { acceptedStatus: spec.acceptedStatus } : {}), + }); + args.gateReceipts.push(receipt); + if (checks[spec.check] !== true) { + errors.push(`NodeKit release proof checks.${spec.check} must be true when ${spec.id} is required`); + } + } +} + +function verifyJsonGate(args: { + root: string; + path: string; + id: string; + expectedSchema?: string; + requireNodekitSchema?: boolean; + configHash?: string; + candidateCommit?: string; + requirePassed: boolean; + acceptedStatus?: string; +}): NodekitProofGateReceipt { + const errors: string[] = []; + const file = resolveRegularFile(args.root, args.path, `${args.id} gate receipt`, errors); + const display = displayPath(args.path); + if (!file) return { id: args.id, path: display, ok: false, errors }; + const value = readJsonRecord(file, `${args.id} gate receipt`, errors); + if (!value) return { id: args.id, path: display, sha256: sha256(readFileSync(file)), ok: false, errors }; + if (args.expectedSchema && value.schemaVersion !== args.expectedSchema) { + errors.push(`${args.id} gate receipt schemaVersion must be ${args.expectedSchema}`); + } + if (args.requireNodekitSchema && (typeof value.schemaVersion !== "string" || !NODEKIT_SCHEMA_PATTERN.test(value.schemaVersion))) { + errors.push(`${args.id} gate receipt schemaVersion must be a NodeKit v1+ schema`); + } + if (args.configHash !== undefined && value.configHash !== args.configHash) { + errors.push(`${args.id} gate receipt configHash does not match the compiled NodeKit configHash`); + } + verifyGateIdentityAndDigest(value, args, errors); + if (args.requirePassed && value.passed !== true && value.status !== args.acceptedStatus) { + errors.push(`${args.id} gate receipt must have passed=true${args.acceptedStatus ? ` or status=${args.acceptedStatus}` : ""}`); + } + return { id: args.id, path: display, sha256: sha256(readFileSync(file)), ok: errors.length === 0, errors }; +} + +function verifyReleaseReceiptVerification( + releaseProof: UnknownRecord, + configHash: string | undefined, + candidateCommit: string, + errors: string[], +): void { + const verification = asRecord(releaseProof.receiptVerification); + if (!verification) return; + if (verification.passed !== true) errors.push("NodeKit release proof receiptVerification must have passed=true when present"); + if (configHash !== undefined && verification.applicationHash !== undefined && verification.applicationHash !== configHash) { + errors.push("NodeKit release proof receiptVerification applicationHash does not match compiled NodeKit configHash"); + } + if (verification.candidateCommit !== undefined && verification.candidateCommit !== candidateCommit) { + errors.push("NodeKit release proof receiptVerification candidateCommit does not match the requested candidate"); + } +} + +function verifyGateIdentityAndDigest( + value: UnknownRecord, + args: { id: string; configHash?: string; candidateCommit?: string }, + errors: string[], +): void { + if (args.configHash !== undefined && value.applicationHash !== undefined && value.applicationHash !== args.configHash) { + errors.push(`${args.id} gate receipt applicationHash does not match the compiled NodeKit configHash`); + } + const candidate = asRecord(value.candidate); + if (candidate && args.candidateCommit !== undefined) { + if (candidate.commit !== args.candidateCommit || candidate.dirty !== false) { + errors.push(`${args.id} gate receipt is not bound to the clean requested candidate commit`); + } + } + if (value.receiptDigest === undefined) return; + if (typeof value.receiptDigest !== "string" || !SHA256_PATTERN.test(value.receiptDigest)) { + errors.push(`${args.id} gate receipt receiptDigest must be a SHA-256 digest when present`); + return; + } + const clone = { ...value }; + delete clone.receiptDigest; + if (sha256(JSON.stringify(clone)) !== value.receiptDigest) { + errors.push(`${args.id} gate receipt receiptDigest does not match content`); + } +} + +function readCompiledDefinition(path: string, errors: string[]): CompiledDefinition | undefined { + const value = readJsonRecord(path, "NodeKit compiled definition", errors); + if (!value) return undefined; + if (value.schemaVersion !== NODEKIT_COMPILED_DEFINITION_SCHEMA) { + errors.push(`NodeKit compiled definition schemaVersion must be ${NODEKIT_COMPILED_DEFINITION_SCHEMA}`); + } + if (typeof value.configHash !== "string" || !SHA256_PATTERN.test(value.configHash)) { + errors.push("NodeKit compiled definition configHash must be a SHA-256 digest"); + } + if (typeof value.manifestDigest !== "string" || !SHA256_PATTERN.test(value.manifestDigest)) { + errors.push("NodeKit compiled definition manifestDigest must be a SHA-256 digest"); + } + if (typeof value.fileCount !== "number" || !Number.isInteger(value.fileCount) || value.fileCount < 0) { + errors.push("NodeKit compiled definition fileCount must be a non-negative integer"); + } + if (typeof value.configHash !== "string" || typeof value.manifestDigest !== "string" || typeof value.fileCount !== "number") return undefined; + return { + schemaVersion: value.schemaVersion as string, + configHash: value.configHash, + manifestDigest: value.manifestDigest, + fileCount: value.fileCount, + }; +} + +function readConfigHash(path: string, errors: string[]): string | undefined { + const value = readFileSync(path, "utf8").trim(); + if (!SHA256_PATTERN.test(value)) { + errors.push("NodeKit config hash file must contain exactly one SHA-256 digest"); + return undefined; + } + return value; +} + +function readDiscovery(path: string, errors: string[]): { files: DiscoveryFile[] } | undefined { + const value = readJsonRecord(path, "NodeKit discovery", errors); + if (!value) return undefined; + if (value.schemaVersion !== NODEKIT_DISCOVERY_SCHEMA) { + errors.push(`NodeKit discovery schemaVersion must be ${NODEKIT_DISCOVERY_SCHEMA}`); + } + if (!Array.isArray(value.files)) { + errors.push("NodeKit discovery files must be an array"); + return undefined; + } + const seen = new Set(); + const files: DiscoveryFile[] = []; + for (const [index, entry] of value.files.entries()) { + if (!asRecord(entry)) { + errors.push(`NodeKit discovery files[${index}] must be an object`); + continue; + } + const record = entry as UnknownRecord; + if (!safeRepoRelativePath(record.path)) { + errors.push(`NodeKit discovery files[${index}].path must be a safe repo-relative path`); + continue; + } + if (seen.has(record.path)) { + errors.push(`NodeKit discovery contains duplicate path ${record.path}`); + continue; + } + seen.add(record.path); + if (typeof record.digest !== "string" || !SHA256_PATTERN.test(record.digest)) { + errors.push(`NodeKit discovery files[${index}].digest must be a SHA-256 digest`); + continue; + } + if (typeof record.bytes !== "number" || !Number.isInteger(record.bytes) || record.bytes < 0) { + errors.push(`NodeKit discovery files[${index}].bytes must be a non-negative integer`); + continue; + } + files.push({ path: record.path, digest: record.digest, bytes: record.bytes }); + } + const sorted = [...files].sort((left, right) => left.path.localeCompare(right.path)); + if (!files.every((entry, index) => entry.path === sorted[index]?.path)) { + errors.push("NodeKit discovery files must be sorted by path"); + } + return { files }; +} + +function verifyManifestDigest(root: string, expected: string, candidateCommit: string, errors: string[]): void { + const manifestPath = resolveRegularFile(root, "nodeagent.yaml", "NodeKit manifest", errors); + if (!manifestPath) return; + if (sha256(readFileSync(manifestPath)) !== expected) { + errors.push("nodeagent.yaml bytes do not match compiled definition manifestDigest"); + } + verifyCandidateFileBytes(root, candidateCommit, "nodeagent.yaml", "NodeKit manifest", errors); +} + +function verifyDiscoveryFiles(root: string, files: DiscoveryFile[], candidateCommit: string, errors: string[]): void { + for (const file of files) { + const path = resolveRegularFile(root, file.path, `NodeKit discovered file ${file.path}`, errors); + if (!path) continue; + const bytes = readFileSync(path); + if (bytes.byteLength !== file.bytes) { + errors.push(`NodeKit discovered file ${file.path} byte count changed`); + } + if (sha256(bytes) !== file.digest) { + errors.push(`NodeKit discovered file ${file.path} digest changed`); + } + verifyCandidateFileBytes(root, candidateCommit, file.path, `NodeKit discovered file ${file.path}`, errors); + } +} + +function verifyCandidateFileBytes( + root: string, + candidateCommit: string, + repoPath: string, + label: string, + errors: string[], +): void { + if (!GIT_SHA_PATTERN.test(candidateCommit)) return; + try { + execFileSync("git", ["cat-file", "-e", `${candidateCommit}:${repoPath}`], { + cwd: root, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + } catch { + errors.push(`${label} is not present in candidate commit ${candidateCommit}`); + return; + } + try { + // `git diff -- ` honors Git's clean/smudge filters. That + // keeps a normal CRLF checkout equivalent to its LF blob while our + // discovery digest separately binds the exact local bytes the compiler + // actually observed. + execFileSync("git", ["diff", "--quiet", candidateCommit, "--", repoPath], { + cwd: root, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + } catch { + errors.push(`${label} bytes do not match candidate commit ${candidateCommit}`); + } +} + +function readCurrentCommit(root: string, errors: string[]): string | undefined { + try { + const commit = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }).trim().toLowerCase(); + if (!GIT_SHA_PATTERN.test(commit)) { + errors.push("git rev-parse HEAD did not produce a valid Git SHA"); + return undefined; + } + return commit; + } catch { + errors.push("candidate commit cannot be verified because git rev-parse HEAD failed"); + return undefined; + } +} + +function resolveRegularFile(rootInput: string, pathInput: string, label: string, errors: string[]): string | undefined { + if (!safeRepoRelativePath(pathInput)) { + errors.push(`${label} must be a safe repo-relative path`); + return undefined; + } + const root = realPathOrResolved(rootInput); + const candidate = resolve(rootInput, pathInput); + if (!existsSync(candidate)) { + errors.push(`${label} is missing: ${pathInput}`); + return undefined; + } + try { + if (lstatSync(candidate).isSymbolicLink()) { + errors.push(`${label} must not be a symbolic link: ${pathInput}`); + return undefined; + } + const real = realPathOrResolved(candidate); + const escaped = relative(root, real); + if (escaped === ".." || escaped.startsWith(`..${sep}`) || isAbsolute(escaped)) { + errors.push(`${label} escapes the repository root: ${pathInput}`); + return undefined; + } + if (!statSync(real).isFile()) { + errors.push(`${label} is not a regular file: ${pathInput}`); + return undefined; + } + return real; + } catch (error) { + errors.push(`${label} cannot be read: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } +} + +function readJsonRecord(path: string, label: string, errors: string[]): UnknownRecord | undefined { + try { + const value = JSON.parse(readFileSync(path, "utf8")); + if (!asRecord(value)) { + errors.push(`${label} must be a JSON object`); + return undefined; + } + return value; + } catch (error) { + errors.push(`${label} must be valid JSON: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } +} + +function joinRelative(root: string, directory: string, filename: string): string { + const target = resolve(directory, filename); + const value = relative(resolve(root), target).split(sep).join("/"); + return value || filename; +} + +function displayPath(pathInput: string): string { + if (!safeRepoRelativePath(pathInput)) return pathInput; + return pathInput.split(/[\\/]/).join("/"); +} + +function realPathOrResolved(pathInput: string): string { + try { + return resolve(realpathSync(pathInput)); + } catch { + return resolve(pathInput); + } +} + +function safeRepoRelativePath(value: unknown): value is string { + if (typeof value !== "string" || value.length === 0 || isAbsolute(value) || /^[A-Za-z]:/.test(value)) return false; + return !value.split(/[\\/]/).some((segment) => segment === ".." || segment.length === 0 || segment.includes(":")); +} + +function asRecord(value: unknown): UnknownRecord | undefined { + return value && typeof value === "object" && !Array.isArray(value) ? value as UnknownRecord : undefined; +} + +function sha256(value: string | Buffer): string { + return createHash("sha256").update(value).digest("hex"); +} diff --git a/src/program.ts b/src/program.ts new file mode 100644 index 0000000..ecd7fb7 --- /dev/null +++ b/src/program.ts @@ -0,0 +1,1201 @@ +import { randomUUID } from "node:crypto"; +import { + appendFileSync, + closeSync, + existsSync, + mkdirSync, + openSync, + realpathSync, + readFileSync, + renameSync, + rmSync, + statSync, + truncateSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { sha256CanonicalJson, verifyProofReceiptEnvelopeFile } from "./proofReceipt"; +import { verifyNodekitProofBinding, type NodekitProofMinimumLevel } from "./nodekitProof"; +import { verifyReceiptFile } from "./receipts"; +import { + readRunnerPlan, + runProofloopRunner, + runnerRunDir, + runnerStatePath, + type ProofloopRunnerPlan, +} from "./runner"; + +/** + * P0 program supervisor. + * + * This is deliberately an orchestration layer over the existing durable runner: + * each arc points at one immutable runner plan. It does not accept arbitrary + * commands itself and it does not add a parallel task-execution engine. + */ +export const PROOFLOOP_PROGRAM_PLAN_SCHEMA = "proofloop-program-plan-v1" as const; +export const PROOFLOOP_PROGRAM_AUTHORITY_SCHEMA = "proofloop-program-authority-v1" as const; +export const PROOFLOOP_PROGRAM_STATE_SCHEMA = "proofloop-program-state-v1" as const; +export const PROOFLOOP_PROGRAM_EVENT_SCHEMA = "proofloop-program-event-v1" as const; + +export type ProofloopProgramArcMode = "read_only" | "proposal_only"; +export type ProofloopProgramArcStatus = "queued" | "running" | "passed" | "failed" | "blocked_budget" | "blocked_authority"; +export type ProofloopProgramStatus = + | "queued" + | "running" + | "paused" + | "certified" + | "failed" + | "failed_integrity" + | "blocked_budget" + | "blocked_authority"; + +export type ProofloopEnvelopeReceiptHook = { + kind: "proofloop-envelope"; + file: string; +}; + +export type ProofloopNodeagentIngestionReceiptHook = { + kind: "nodeagent-ingestion"; + file: string; + minDocuments?: number; + minMemoryObjects?: number; +}; + +/** + * Binds NodeKit's generated proof receipt to the current compiled application + * identity and an exact Git candidate before a local-only program arc passes. + */ +export type ProofloopNodekitProofReceiptHook = { + kind: "nodekit-proof"; + file: string; + candidateCommit: string; + minimumLevel?: NodekitProofMinimumLevel; + compiledDefinition?: string; + configHashFile?: string; + discovery?: string; +}; + +export type ProofloopProgramReceiptHook = + | ProofloopEnvelopeReceiptHook + | ProofloopNodeagentIngestionReceiptHook + | ProofloopNodekitProofReceiptHook; + +export type ProofloopProgramArcPlan = { + id: string; + mode: ProofloopProgramArcMode; + runnerPlan: string; + dependsOn?: string[]; + receipt?: ProofloopProgramReceiptHook; + /** Must remain false in P0. The explicit field makes an attempted egress auditable and blockable. */ + externalEgress?: boolean; + maxAttempts?: number; +}; + +export type ProofloopProgramPlan = { + schema: typeof PROOFLOOP_PROGRAM_PLAN_SCHEMA; + programId: string; + authorityPath: string; + arcs: ProofloopProgramArcPlan[]; +}; + +export type ProofloopProgramAuthority = { + schema: typeof PROOFLOOP_PROGRAM_AUTHORITY_SCHEMA; + authorityId: string; + allowedArcModes: ProofloopProgramArcMode[]; + /** P0 is deliberately local-only. A true value is rejected rather than treated as consent. */ + allowExternalEgress: false; + maxBudgetUsd: number; + maxAttemptsPerArc: number; +}; + +export type ProofloopProgramReceiptVerification = { + kind: ProofloopProgramReceiptHook["kind"]; + file: string; + ok: boolean; + errors: string[]; +}; + +export type ProofloopProgramArcState = { + id: string; + mode: ProofloopProgramArcMode; + dependsOn: string[]; + runnerPlanPath: string; + runnerPlanDigest: string; + runnerRunId: string; + estimatedCostUsd: number; + /** + * Last durable runner-spend observation. This lets an interrupted arc + * recover without charging the program twice for tasks the runner already + * recorded before the supervisor was interrupted. + */ + runnerSpentEstimatedUsd?: number; + maxAttempts: number; + attempts: number; + status: ProofloopProgramArcStatus; + startedAt?: string; + completedAt?: string; + error?: string; + receipt?: ProofloopProgramReceiptVerification; +}; + +export type ProofloopProgramState = { + schema: typeof PROOFLOOP_PROGRAM_STATE_SCHEMA; + programRunId: string; + programId: string; + planPath: string; + planDigest: string; + authorityPath: string; + authorityDigest: string; + budgetUsd: number; + spentEstimatedUsd: number; + status: ProofloopProgramStatus; + createdAt: string; + updatedAt: string; + arcStates: ProofloopProgramArcState[]; +}; + +export type ProofloopProgramEvent = { + schema: typeof PROOFLOOP_PROGRAM_EVENT_SCHEMA; + programRunId: string; + at: string; + event: string; + arcId?: string; + data?: Record; +}; + +export type ProofloopProgramResult = { + state: ProofloopProgramState; + runDir: string; + ledgerPath: string; + exitCode: number; +}; + +export type ProofloopProgramOptions = { + root: string; + subcommand: "run" | "resume" | "status" | "report"; + planPath?: string; + runId?: string; + budgetUsd?: number; + maxArcs?: number; + lockTtlMs?: number; + clearStaleLock?: boolean; + json?: boolean; + log?: (message: string) => void; + logError?: (message: string) => void; +}; + +type CompiledProgramArc = { + plan: ProofloopProgramArcPlan; + runnerPlanPath: string; + runnerPlan: ProofloopRunnerPlan; + runnerPlanDigest: string; + estimatedCostUsd: number; +}; + +type CompiledProgram = { + plan: ProofloopProgramPlan; + planPath: string; + authorityPath: string; + authority: ProofloopProgramAuthority; + authorityDigest: string; + arcs: CompiledProgramArc[]; + planDigest: string; +}; + +type ProgramLock = { + release: () => void; +}; + +const PROGRAM_ROOT = ".proofloop/programs"; +const DEFAULT_LOCK_TTL_MS = 30 * 60_000; +// Program and arc identifiers become part of local durable run paths. Keep +// them portable across Windows and POSIX rather than accepting ':' or a path +// separator merely because it is convenient for a logical label. +const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/; +const PLAN_KEYS = new Set(["schema", "programId", "authorityPath", "arcs"]); +const ARC_KEYS = new Set(["id", "mode", "runnerPlan", "dependsOn", "receipt", "externalEgress", "maxAttempts"]); +const AUTHORITY_KEYS = new Set(["schema", "authorityId", "allowedArcModes", "allowExternalEgress", "maxBudgetUsd", "maxAttemptsPerArc"]); +const ENVELOPE_RECEIPT_KEYS = new Set(["kind", "file"]); +const NODEAGENT_RECEIPT_KEYS = new Set(["kind", "file", "minDocuments", "minMemoryObjects"]); +const NODEKIT_RECEIPT_KEYS = new Set(["kind", "file", "candidateCommit", "minimumLevel", "compiledDefinition", "configHashFile", "discovery"]); + +/** Run or resume a dependency-ordered program. P0 only permits local read/proposal arcs. */ +export async function runProofloopProgram(options: ProofloopProgramOptions): Promise { + const root = resolve(options.root); + const log = options.log ?? console.log; + const logError = options.logError ?? console.error; + if (options.subcommand === "status") return programStatus(options); + if (options.subcommand === "report") return programReport(options); + + let runId = options.runId; + // Keep the initial fallback inside the safe run-id grammar because failures + // must not turn a caller-supplied run id into a filesystem path. + let runDir = programRunDir(root, "unknown"); + let lock: ProgramLock | undefined; + try { + if (options.subcommand === "resume") runId = resolveProgramRunId(root, options.runId); + const existingState = options.subcommand === "resume" + ? readProgramState(programStatePath(programRunDir(root, runId!))) + : undefined; + const planPath = resolveProgramPlanPath(options, existingState); + const compiled = compileProgram(root, planPath); + runId = options.subcommand === "resume" ? runId! : options.runId ?? defaultProgramRunId(compiled.plan.programId); + if (!validId(runId)) throw new Error("program run-id must contain only letters, numbers, '.', '_', or '-'"); + runDir = programRunDir(root, runId); + mkdirSync(runDir, { recursive: true }); + lock = acquireProgramLock(runDir, options.lockTtlMs ?? DEFAULT_LOCK_TTL_MS, options.clearStaleLock === true); + const repaired = repairProgramLedgerTornTail(runDir); + if (repaired.repaired) appendProgramEvent(runDir, { programRunId: runId, event: "ledger_torn_tail_repaired", data: repaired }); + + let state = loadOrCreateProgramState(runDir, { + programRunId: runId, + compiled, + budgetUsd: initialBudget(compiled.authority, options.budgetUsd), + }); + writeLatestProgramRun(root, runId); + + const resumeIntegrity = validateExistingProgramState(state, compiled, options.budgetUsd); + if (resumeIntegrity) { + state = terminalizeProgram(state, runDir, resumeIntegrity.status, resumeIntegrity.event, resumeIntegrity.message); + return emitProgramResult(state, runDir, options, log); + } + + const policyViolations = validateProgramAuthority(compiled); + if (policyViolations.length > 0) { + state = terminalizeProgram(state, runDir, "blocked_authority", "authority_policy_blocked", policyViolations.join("; ")); + return emitProgramResult(state, runDir, options, log); + } + + if (isProgramTerminal(state.status)) return emitProgramResult(state, runDir, options, log); + + appendProgramEvent(runDir, { + programRunId: runId, + event: "program_started", + data: { + subcommand: options.subcommand, + budgetUsd: state.budgetUsd, + maxArcs: options.maxArcs ?? null, + authorityDigest: state.authorityDigest, + }, + }); + + const maxArcs = normalizeMaxArcs(options.maxArcs); + let executed = 0; + while (executed < maxArcs) { + const next = nextExecutableArc(state); + if (!next) break; + const compiledArc = compiled.arcs.find((arc) => arc.plan.id === next.id); + if (!compiledArc) { + next.status = "failed"; + next.completedAt = nowIso(); + next.error = "Arc is missing from the compiled program."; + state = terminalizeProgram(state, runDir, "failed_integrity", "arc_missing_from_compiled_program", next.error, next.id); + break; + } + const recoveringInterruptedArc = next.status === "running"; + if (!recoveringInterruptedArc && next.attempts >= next.maxAttempts) { + next.status = "failed"; + next.completedAt = nowIso(); + next.error = `Arc exhausted its bounded attempt limit (${next.maxAttempts}) and will not be requeued automatically.`; + state = terminalizeProgram(state, runDir, "failed", "arc_attempt_limit_reached", next.error, next.id); + break; + } + if (!recoveringInterruptedArc && roundMoney(state.spentEstimatedUsd + next.estimatedCostUsd) > state.budgetUsd) { + next.status = "blocked_budget"; + next.completedAt = nowIso(); + next.error = `Program budget would be exceeded by arc estimate $${next.estimatedCostUsd.toFixed(4)}.`; + state = terminalizeProgram(state, runDir, "blocked_budget", "budget_kill_switch", next.error, next.id); + break; + } + + if (recoveringInterruptedArc) { + appendProgramEvent(runDir, { + programRunId: runId, + event: "arc_recovery_requested", + arcId: next.id, + data: { + runnerRunId: next.runnerRunId, + attempt: next.attempts, + note: "Only interrupted running work may resume. Failed arcs remain terminal and are never automatically requeued.", + }, + }); + } else { + state.status = "running"; + next.status = "running"; + next.attempts += 1; + next.startedAt = nowIso(); + state.updatedAt = next.startedAt; + writeProgramState(runDir, state); + appendProgramEvent(runDir, { + programRunId: runId, + event: "arc_started", + arcId: next.id, + data: { + mode: next.mode, + runnerPlanDigest: next.runnerPlanDigest, + estimatedCostUsd: next.estimatedCostUsd, + attempt: next.attempts, + maxAttempts: next.maxAttempts, + }, + }); + } + + const runnerAlreadyExists = existsSync(runnerStatePath(runnerRunDir(root, next.runnerRunId))); + const runner = await runProofloopRunner({ + root, + subcommand: runnerAlreadyExists ? "resume" : "run", + ...(runnerAlreadyExists ? {} : { planPath: compiledArc.runnerPlanPath }), + runId: next.runnerRunId, + budgetUsd: roundMoney(state.budgetUsd - state.spentEstimatedUsd), + clearStaleLock: options.clearStaleLock === true, + log: () => {}, + logError: () => {}, + }); + const priorRunnerSpend = next.runnerSpentEstimatedUsd ?? 0; + const currentRunnerSpend = runner.state.spentEstimatedUsd; + if (!nonNegativeFiniteNumber(currentRunnerSpend) || currentRunnerSpend < priorRunnerSpend) { + next.status = "failed"; + next.completedAt = nowIso(); + next.error = "Runner spend observation is invalid or regressed; refusing to continue an unverifiable program run."; + state = terminalizeProgram(state, runDir, "failed_integrity", "runner_spend_integrity_failed", next.error, next.id, { + priorRunnerSpend, + currentRunnerSpend, + }); + break; + } + const runnerSpendDelta = roundMoney(currentRunnerSpend - priorRunnerSpend); + state.spentEstimatedUsd = roundMoney(state.spentEstimatedUsd + runnerSpendDelta); + next.runnerSpentEstimatedUsd = currentRunnerSpend; + next.completedAt = nowIso(); + state.updatedAt = next.completedAt; + writeProgramState(runDir, state); + + if (runner.state.status !== "passed") { + next.status = runner.state.status === "blocked_budget" ? "blocked_budget" : "failed"; + next.error = `Runner ended ${runner.state.status}; runner run ${next.runnerRunId}.`; + const terminal = next.status === "blocked_budget" ? "blocked_budget" : "failed"; + state = terminalizeProgram(state, runDir, terminal, "arc_runner_failed", next.error, next.id, { + runnerStatus: runner.state.status, + runnerRunId: next.runnerRunId, + }); + break; + } + + const receipt = verifyProgramReceipt(root, compiledArc.plan.receipt); + if (receipt) { + next.receipt = receipt; + appendProgramEvent(runDir, { + programRunId: runId, + event: receipt.ok ? "receipt_verified" : "receipt_verification_failed", + arcId: next.id, + data: { kind: receipt.kind, file: receipt.file, errors: receipt.errors }, + }); + if (!receipt.ok) { + next.status = "failed"; + next.error = `Required receipt verification failed: ${receipt.errors.join("; ")}`; + state = terminalizeProgram(state, runDir, "failed", "arc_receipt_failed", next.error, next.id); + break; + } + } + + next.status = "passed"; + writeProgramState(runDir, state); + appendProgramEvent(runDir, { + programRunId: runId, + event: "arc_passed", + arcId: next.id, + data: { runnerRunId: next.runnerRunId, spentEstimatedUsd: state.spentEstimatedUsd }, + }); + executed += 1; + } + + if (!isProgramTerminal(state.status)) { + if (state.arcStates.every((arc) => arc.status === "passed")) { + state = terminalizeProgram(state, runDir, "certified", "program_certified", "Every program arc and configured receipt hook passed."); + } else if (state.arcStates.some((arc) => arc.status === "failed")) { + state = terminalizeProgram(state, runDir, "failed", "program_failed", "An arc failed and P0 does not automatically requeue failed work."); + } else if (state.arcStates.some((arc) => arc.status === "blocked_budget")) { + state = terminalizeProgram(state, runDir, "blocked_budget", "program_budget_blocked", "A program arc is blocked by the approved budget."); + } else if (executed >= maxArcs) { + state.status = "paused"; + state.updatedAt = nowIso(); + writeProgramState(runDir, state); + appendProgramEvent(runDir, { programRunId: runId, event: "program_paused", data: { maxArcs } }); + } else { + state = terminalizeProgram(state, runDir, "failed_integrity", "no_dependency_safe_arc", "No dependency-safe queued arc remains; inspect the persisted program state."); + } + } + return emitProgramResult(state, runDir, options, log); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logError(`proofloop program: ${message}`); + return { + state: emptyProgramErrorState(runId ?? "unknown", message), + runDir, + ledgerPath: programLedgerPath(runDir), + exitCode: 2, + }; + } finally { + lock?.release(); + } +} + +export function readProofloopProgramPlan(rootInput: string, planPathInput: string): ProofloopProgramPlan { + const root = resolve(rootInput); + const planPath = resolveProgramRepoFile(root, planPathInput, "program plan", { allowAbsoluteInsideRoot: true }); + const raw = readFileSync(planPath, "utf8").replace(/^\uFEFF/, ""); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error(`program plan must be JSON: ${error instanceof Error ? error.message : String(error)}`); + } + if (!isRecord(parsed)) throw new Error("program plan must be an object"); + rejectUnknownKeys(parsed, PLAN_KEYS, "program plan"); + if (parsed.schema !== PROOFLOOP_PROGRAM_PLAN_SCHEMA) throw new Error(`program plan schema must be ${PROOFLOOP_PROGRAM_PLAN_SCHEMA}`); + if (!validId(parsed.programId)) throw new Error("program plan programId is required"); + if (!safeRepoRelativePath(parsed.authorityPath)) throw new Error("program plan authorityPath must be a safe repo-relative path"); + if (!Array.isArray(parsed.arcs) || parsed.arcs.length === 0) throw new Error("program plan must include at least one arc"); + const ids = new Set(); + const arcs = parsed.arcs.map((value, index) => parseProgramArc(value, index, ids)); + validateArcGraph(arcs); + return { + schema: PROOFLOOP_PROGRAM_PLAN_SCHEMA, + programId: parsed.programId, + authorityPath: parsed.authorityPath, + arcs, + }; +} + +export function readProofloopProgramAuthority(rootInput: string, authorityPathInput: string): ProofloopProgramAuthority { + const root = resolve(rootInput); + const authorityPath = resolveProgramRepoFile(root, authorityPathInput, "program authority"); + const raw = readFileSync(authorityPath, "utf8").replace(/^\uFEFF/, ""); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error(`program authority must be JSON: ${error instanceof Error ? error.message : String(error)}`); + } + if (!isRecord(parsed)) throw new Error("program authority must be an object"); + rejectUnknownKeys(parsed, AUTHORITY_KEYS, "program authority"); + if (parsed.schema !== PROOFLOOP_PROGRAM_AUTHORITY_SCHEMA) throw new Error(`program authority schema must be ${PROOFLOOP_PROGRAM_AUTHORITY_SCHEMA}`); + if (!validId(parsed.authorityId)) throw new Error("program authority authorityId is required"); + if (!Array.isArray(parsed.allowedArcModes) || parsed.allowedArcModes.length === 0) throw new Error("program authority allowedArcModes is required"); + const allowedArcModes = parsed.allowedArcModes.map((mode) => parseArcMode(mode, "program authority allowedArcModes")); + if (new Set(allowedArcModes).size !== allowedArcModes.length) throw new Error("program authority allowedArcModes must be unique"); + if (parsed.allowExternalEgress !== false) throw new Error("program authority allowExternalEgress must be false in P0"); + if (!nonNegativeFiniteNumber(parsed.maxBudgetUsd)) throw new Error("program authority maxBudgetUsd must be a non-negative finite number"); + if (!positiveInteger(parsed.maxAttemptsPerArc)) throw new Error("program authority maxAttemptsPerArc must be a positive integer"); + return { + schema: PROOFLOOP_PROGRAM_AUTHORITY_SCHEMA, + authorityId: parsed.authorityId, + allowedArcModes, + allowExternalEgress: false, + maxBudgetUsd: parsed.maxBudgetUsd, + maxAttemptsPerArc: parsed.maxAttemptsPerArc, + }; +} + +export function programRunDir(rootInput: string, runId: string): string { + if (!validId(runId)) throw new Error("program run-id must contain only letters, numbers, '.', '_', or '-'"); + return join(resolve(rootInput), PROGRAM_ROOT, "runs", runId); +} + +export function programStatePath(runDir: string): string { + return join(runDir, "state.json"); +} + +export function programLedgerPath(runDir: string): string { + return join(runDir, "ledger.jsonl"); +} + +export function isProgramTerminal(status: ProofloopProgramStatus): boolean { + return status === "certified" || status === "failed" || status === "failed_integrity" || status === "blocked_budget" || status === "blocked_authority"; +} + +export function formatProofloopProgramStatus(state: ProofloopProgramState, runDir: string): string { + const counts = state.arcStates.reduce>((value, arc) => { + value[arc.status] += 1; + return value; + }, { queued: 0, running: 0, passed: 0, failed: 0, blocked_budget: 0, blocked_authority: 0 }); + return [ + `proofloop program: ${state.programRunId}`, + `program=${state.programId} status=${state.status}`, + `budget=$${state.budgetUsd.toFixed(4)} spent_est=$${state.spentEstimatedUsd.toFixed(4)}`, + `arcs passed=${counts.passed} queued=${counts.queued} running=${counts.running} failed=${counts.failed} blocked_budget=${counts.blocked_budget} blocked_authority=${counts.blocked_authority}`, + `authorityDigest=${state.authorityDigest}`, + `state=${programStatePath(runDir)}`, + `ledger=${programLedgerPath(runDir)}`, + ].join("\n"); +} + +function compileProgram(root: string, planPathInput: string): CompiledProgram { + const planPath = resolveProgramRepoFile(root, planPathInput, "program plan", { allowAbsoluteInsideRoot: true }); + const plan = readProofloopProgramPlan(root, planPath); + const authorityPath = resolveProgramRepoFile(root, plan.authorityPath, "program authority"); + const authority = readProofloopProgramAuthority(root, plan.authorityPath); + const arcsById = new Map(plan.arcs.map((arc) => [arc.id, arc])); + const orderedIds = stableTopologicalArcOrder(plan.arcs); + const arcs = orderedIds.map((id) => { + const arc = arcsById.get(id)!; + const runnerPlanPath = resolveProgramRepoFile(root, arc.runnerPlan, `runner plan for arc ${arc.id}`); + const runnerPlan = readRunnerPlan(runnerPlanPath); + return { + plan: arc, + runnerPlanPath, + runnerPlan, + runnerPlanDigest: sha256CanonicalJson(runnerPlan), + estimatedCostUsd: roundMoney(runnerPlan.tasks.reduce((sum, task) => sum + (task.estimatedCostUsd ?? 0), 0)), + }; + }); + const authorityDigest = sha256CanonicalJson(authority); + const planDigest = sha256CanonicalJson({ + plan, + arcs: arcs.map((arc) => ({ + id: arc.plan.id, + runnerPlan: arc.plan.runnerPlan, + runnerPlanDigest: arc.runnerPlanDigest, + estimatedCostUsd: arc.estimatedCostUsd, + })), + }); + return { plan, planPath, authorityPath, authority, authorityDigest, arcs, planDigest }; +} + +function parseProgramArc(value: unknown, index: number, ids: Set): ProofloopProgramArcPlan { + if (!isRecord(value)) throw new Error(`program arc ${index} must be an object`); + rejectUnknownKeys(value, ARC_KEYS, `program arc ${index}`); + if (!validId(value.id)) throw new Error(`program arc ${index} id is required`); + if (ids.has(value.id)) throw new Error(`duplicate program arc id: ${value.id}`); + ids.add(value.id); + const mode = parseArcMode(value.mode, `program arc ${value.id} mode`); + if (!safeRepoRelativePath(value.runnerPlan)) throw new Error(`program arc ${value.id} runnerPlan must be a safe repo-relative path`); + const dependsOn = parseIdArray(value.dependsOn, `program arc ${value.id} dependsOn`); + if (new Set(dependsOn).size !== dependsOn.length) throw new Error(`program arc ${value.id} dependsOn must be unique`); + if (dependsOn.includes(value.id)) throw new Error(`program arc ${value.id} cannot depend on itself`); + if (value.externalEgress !== undefined && typeof value.externalEgress !== "boolean") throw new Error(`program arc ${value.id} externalEgress must be boolean`); + const maxAttempts = value.maxAttempts === undefined ? undefined : requirePositiveInteger(value.maxAttempts, `program arc ${value.id} maxAttempts`); + const receipt = value.receipt === undefined ? undefined : parseReceiptHook(value.receipt, `program arc ${value.id} receipt`); + return { + id: value.id, + mode, + runnerPlan: value.runnerPlan, + ...(dependsOn.length > 0 ? { dependsOn } : {}), + ...(receipt ? { receipt } : {}), + ...(value.externalEgress === true ? { externalEgress: true } : {}), + ...(maxAttempts !== undefined ? { maxAttempts } : {}), + }; +} + +function parseReceiptHook(value: unknown, label: string): ProofloopProgramReceiptHook { + if (!isRecord(value)) throw new Error(`${label} must be an object`); + if (value.kind !== "proofloop-envelope" && value.kind !== "nodeagent-ingestion" && value.kind !== "nodekit-proof") { + throw new Error(`${label} kind must be proofloop-envelope, nodeagent-ingestion, or nodekit-proof`); + } + const allowedKeys = value.kind === "proofloop-envelope" + ? ENVELOPE_RECEIPT_KEYS + : value.kind === "nodeagent-ingestion" + ? NODEAGENT_RECEIPT_KEYS + : NODEKIT_RECEIPT_KEYS; + rejectUnknownKeys(value, allowedKeys, label); + if (!safeRepoRelativePath(value.file)) throw new Error(`${label} file must be a safe repo-relative path`); + + if (value.kind === "proofloop-envelope") { + return { kind: value.kind, file: value.file }; + } + + if (value.kind === "nodekit-proof") { + if (!validGitCommit(value.candidateCommit)) { + throw new Error(`${label} candidateCommit must be a lowercase 40-64 character Git SHA`); + } + if (value.minimumLevel !== undefined && value.minimumLevel !== "local-ready" && value.minimumLevel !== "release-ready") { + throw new Error(`${label} minimumLevel must be local-ready or release-ready`); + } + const compiledDefinition = value.compiledDefinition; + const configHashFile = value.configHashFile; + const discovery = value.discovery; + for (const [field, path] of [ + ["compiledDefinition", compiledDefinition], + ["configHashFile", configHashFile], + ["discovery", discovery], + ] as const) { + if (path !== undefined && !safeRepoRelativePath(path)) throw new Error(`${label} ${field} must be a safe repo-relative path`); + } + return { + kind: value.kind, + file: value.file, + candidateCommit: value.candidateCommit, + ...(value.minimumLevel !== undefined ? { minimumLevel: value.minimumLevel } : {}), + ...(typeof compiledDefinition === "string" ? { compiledDefinition } : {}), + ...(typeof configHashFile === "string" ? { configHashFile } : {}), + ...(typeof discovery === "string" ? { discovery } : {}), + }; + } + + const minDocuments = value.minDocuments === undefined ? undefined : requireNonNegativeInteger(value.minDocuments, `${label} minDocuments`); + const minMemoryObjects = value.minMemoryObjects === undefined ? undefined : requireNonNegativeInteger(value.minMemoryObjects, `${label} minMemoryObjects`); + return { + kind: value.kind, + file: value.file, + ...(minDocuments !== undefined ? { minDocuments } : {}), + ...(minMemoryObjects !== undefined ? { minMemoryObjects } : {}), + }; +} + +function validateArcGraph(arcs: ProofloopProgramArcPlan[]): void { + const ids = new Set(arcs.map((arc) => arc.id)); + for (const arc of arcs) { + for (const dependency of arc.dependsOn ?? []) { + if (!ids.has(dependency)) throw new Error(`program arc ${arc.id} depends on unknown arc ${dependency}`); + } + } + stableTopologicalArcOrder(arcs); +} + +/** Stable Kahn ordering, intentionally matching the dependency semantics used by Solo handoff compilation. */ +function stableTopologicalArcOrder(arcs: ProofloopProgramArcPlan[]): string[] { + const originalIndex = new Map(arcs.map((arc, index) => [arc.id, index])); + const indegree = new Map(arcs.map((arc) => [arc.id, arc.dependsOn?.length ?? 0])); + const dependents = new Map(); + for (const arc of arcs) { + for (const dependency of arc.dependsOn ?? []) { + const values = dependents.get(dependency) ?? []; + values.push(arc.id); + dependents.set(dependency, values); + } + } + const available = arcs.filter((arc) => indegree.get(arc.id) === 0).map((arc) => arc.id); + const ordered: string[] = []; + while (available.length > 0) { + available.sort((left, right) => (originalIndex.get(left) ?? 0) - (originalIndex.get(right) ?? 0)); + const id = available.shift()!; + ordered.push(id); + for (const dependent of dependents.get(id) ?? []) { + const next = (indegree.get(dependent) ?? 0) - 1; + indegree.set(dependent, next); + if (next === 0) available.push(dependent); + } + } + if (ordered.length !== arcs.length) { + const cyclic = arcs.filter((arc) => !ordered.includes(arc.id)).map((arc) => arc.id); + throw new Error(`program arc graph contains a cycle: ${cyclic.join(", ")}`); + } + return ordered; +} + +function initialBudget(authority: ProofloopProgramAuthority, requestedBudget: number | undefined): number { + if (requestedBudget === undefined) return authority.maxBudgetUsd; + if (!nonNegativeFiniteNumber(requestedBudget)) throw new Error("program --budget-usd must be a non-negative finite number"); + if (requestedBudget > authority.maxBudgetUsd) throw new Error("program --budget-usd cannot exceed the approved authority maxBudgetUsd"); + return requestedBudget; +} + +function loadOrCreateProgramState( + runDir: string, + args: { programRunId: string; compiled: CompiledProgram; budgetUsd: number }, +): ProofloopProgramState { + const statePath = programStatePath(runDir); + const existing = readProgramState(statePath); + if (existing) return existing; + if (existsSync(statePath)) { + throw new Error("program state is unreadable or corrupt; refusing to overwrite an existing run"); + } + const now = nowIso(); + const state: ProofloopProgramState = { + schema: PROOFLOOP_PROGRAM_STATE_SCHEMA, + programRunId: args.programRunId, + programId: args.compiled.plan.programId, + planPath: args.compiled.planPath, + planDigest: args.compiled.planDigest, + authorityPath: args.compiled.authorityPath, + authorityDigest: args.compiled.authorityDigest, + budgetUsd: args.budgetUsd, + spentEstimatedUsd: 0, + status: "queued", + createdAt: now, + updatedAt: now, + arcStates: args.compiled.arcs.map((arc) => ({ + id: arc.plan.id, + mode: arc.plan.mode, + dependsOn: [...(arc.plan.dependsOn ?? [])], + runnerPlanPath: arc.runnerPlanPath, + runnerPlanDigest: arc.runnerPlanDigest, + runnerRunId: `${args.programRunId}-${arc.plan.id}`, + estimatedCostUsd: arc.estimatedCostUsd, + runnerSpentEstimatedUsd: 0, + maxAttempts: Math.min(arc.plan.maxAttempts ?? 1, args.compiled.authority.maxAttemptsPerArc), + attempts: 0, + status: "queued", + })), + }; + writeProgramState(runDir, state); + return state; +} + +function validateExistingProgramState( + state: ProofloopProgramState, + compiled: CompiledProgram, + requestedBudget: number | undefined, +): { status: "failed_integrity" | "blocked_authority"; event: string; message: string } | undefined { + if (state.schema !== PROOFLOOP_PROGRAM_STATE_SCHEMA) return { status: "failed_integrity", event: "program_state_schema_mismatch", message: "Persisted program state has an unsupported schema." }; + if (!validId(state.programRunId)) return { status: "failed_integrity", event: "program_state_run_id_invalid", message: "Persisted program state has an invalid run ID." }; + if (state.programId !== compiled.plan.programId) return { status: "failed_integrity", event: "program_id_changed", message: "Program ID changed for an existing run." }; + if (state.planPath !== compiled.planPath || state.authorityPath !== compiled.authorityPath) { + return { status: "failed_integrity", event: "program_source_path_changed", message: "Persisted program source paths do not match the compiled program." }; + } + if (state.planDigest !== compiled.planDigest) return { status: "failed_integrity", event: "program_plan_changed", message: "Program plan or referenced runner plan changed for an existing run." }; + if (state.authorityDigest !== compiled.authorityDigest) return { status: "blocked_authority", event: "authority_digest_changed", message: "Authority changed after this run was created; a new approved program run is required." }; + if (!nonNegativeFiniteNumber(state.budgetUsd) || !nonNegativeFiniteNumber(state.spentEstimatedUsd) || state.spentEstimatedUsd > state.budgetUsd) { + return { status: "failed_integrity", event: "program_budget_state_invalid", message: "Persisted program budget state is invalid." }; + } + if (!isProofloopProgramStatus(state.status) || !Array.isArray(state.arcStates) || state.arcStates.length !== compiled.arcs.length) { + return { status: "failed_integrity", event: "program_state_shape_invalid", message: "Persisted program state does not match the compiled arc set." }; + } + for (let index = 0; index < compiled.arcs.length; index += 1) { + const persisted = state.arcStates[index]; + const expected = compiled.arcs[index]; + if (!persisted || persisted.id !== expected.plan.id || persisted.mode !== expected.plan.mode + || !sameStringArray(persisted.dependsOn, expected.plan.dependsOn ?? []) + || persisted.runnerPlanPath !== expected.runnerPlanPath + || persisted.runnerPlanDigest !== expected.runnerPlanDigest + || persisted.runnerRunId !== `${state.programRunId}-${expected.plan.id}` + || persisted.estimatedCostUsd !== expected.estimatedCostUsd + || persisted.maxAttempts !== Math.min(expected.plan.maxAttempts ?? 1, compiled.authority.maxAttemptsPerArc) + || !nonNegativeInteger(persisted.attempts) + || persisted.attempts > persisted.maxAttempts + || !isProofloopProgramArcStatus(persisted.status) + || (persisted.runnerSpentEstimatedUsd !== undefined && !nonNegativeFiniteNumber(persisted.runnerSpentEstimatedUsd))) { + return { status: "failed_integrity", event: "program_arc_state_invalid", message: `Persisted state for arc ${expected.plan.id} does not match the compiled program.` }; + } + } + if (requestedBudget !== undefined && requestedBudget !== state.budgetUsd) return { status: "failed_integrity", event: "program_budget_changed", message: "Program budget is immutable after a run starts." }; + return undefined; +} + +function validateProgramAuthority(compiled: CompiledProgram): string[] { + const errors: string[] = []; + if (compiled.authority.allowExternalEgress !== false) errors.push("P0 authority must prohibit external egress"); + for (const arc of compiled.arcs) { + if (!compiled.authority.allowedArcModes.includes(arc.plan.mode)) errors.push(`arc ${arc.plan.id} mode ${arc.plan.mode} is not authorized`); + if (arc.plan.externalEgress === true) errors.push(`arc ${arc.plan.id} declares external egress, which P0 prohibits`); + const requestedAttempts = arc.plan.maxAttempts ?? 1; + if (requestedAttempts > compiled.authority.maxAttemptsPerArc) { + errors.push(`arc ${arc.plan.id} maxAttempts ${requestedAttempts} exceeds authority maxAttemptsPerArc ${compiled.authority.maxAttemptsPerArc}`); + } + } + return errors; +} + +function nextExecutableArc(state: ProofloopProgramState): ProofloopProgramArcState | undefined { + const interrupted = state.arcStates.find((arc) => arc.status === "running"); + if (interrupted) return interrupted; + const byId = new Map(state.arcStates.map((arc) => [arc.id, arc])); + return state.arcStates.find((arc) => arc.status === "queued" && arc.dependsOn.every((id) => byId.get(id)?.status === "passed")); +} + +function verifyProgramReceipt(root: string, hook: ProofloopProgramReceiptHook | undefined): ProofloopProgramReceiptVerification | undefined { + if (!hook) return undefined; + if (hook.kind === "proofloop-envelope") { + const result = verifyProofReceiptEnvelopeFile({ root, filePath: hook.file }); + return { + kind: hook.kind, + file: hook.file, + ok: result.ok, + errors: result.errors.map((entry) => `${entry.code}: ${entry.message}`), + }; + } + if (hook.kind === "nodekit-proof") { + const result = verifyNodekitProofBinding({ + root, + releaseProofPath: hook.file, + candidateCommit: hook.candidateCommit, + ...(hook.minimumLevel !== undefined ? { minimumLevel: hook.minimumLevel } : {}), + ...(hook.compiledDefinition !== undefined ? { compiledDefinitionPath: hook.compiledDefinition } : {}), + ...(hook.configHashFile !== undefined ? { configHashPath: hook.configHashFile } : {}), + ...(hook.discovery !== undefined ? { discoveryPath: hook.discovery } : {}), + }); + return { + kind: hook.kind, + file: hook.file, + ok: result.ok, + errors: [ + ...result.errors, + ...result.gateReceipts.flatMap((receipt) => receipt.errors.map((error) => `${receipt.id}: ${error}`)), + ], + }; + } + const result = verifyReceiptFile({ + root, + filePath: hook.file, + kind: "nodeagent-ingestion", + ...(hook.minDocuments !== undefined ? { minDocuments: hook.minDocuments } : {}), + ...(hook.minMemoryObjects !== undefined ? { minMemoryObjects: hook.minMemoryObjects } : {}), + }); + return { + kind: hook.kind, + file: hook.file, + ok: result.ok, + errors: result.checks.filter((entry) => !entry.ok).map((entry) => `${entry.name}: ${entry.detail}`), + }; +} + +function validGitCommit(value: unknown): value is string { + return typeof value === "string" && /^[a-f0-9]{40,64}$/.test(value); +} + +function resolveProgramPlanPath(options: ProofloopProgramOptions, existing: ProofloopProgramState | undefined): string { + if (options.subcommand === "resume") { + if (!existing) throw new Error("cannot resume: missing program state"); + return existing.planPath; + } + if (!options.planPath) throw new Error("program run requires --plan "); + return options.planPath; +} + +function resolveProgramRunId(root: string, runId: string | undefined): string { + const resolved = runId && runId !== "latest" + ? runId + : (() => { + const latestPath = join(root, PROGRAM_ROOT, "latest"); + if (!existsSync(latestPath)) throw new Error("no latest program run exists"); + return readFileSync(latestPath, "utf8").trim(); + })(); + if (!validId(resolved)) throw new Error("program run-id must contain only letters, numbers, '.', '_', or '-'"); + return resolved; +} + +function programStatus(options: ProofloopProgramOptions): ProofloopProgramResult { + const root = resolve(options.root); + const log = options.log ?? console.log; + const logError = options.logError ?? console.error; + try { + const runId = resolveProgramRunId(root, options.runId); + const runDir = programRunDir(root, runId); + const state = readProgramState(programStatePath(runDir)); + if (!state) throw new Error(`missing program state for ${runId}`); + if (options.json) log(JSON.stringify(state, null, 2)); + else log(formatProofloopProgramStatus(state, runDir)); + return { state, runDir, ledgerPath: programLedgerPath(runDir), exitCode: 0 }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logError(`proofloop program: ${message}`); + const fallbackRunId = validId(options.runId) ? options.runId : "unknown"; + const runDir = programRunDir(root, fallbackRunId); + return { state: emptyProgramErrorState(fallbackRunId, message), runDir, ledgerPath: programLedgerPath(runDir), exitCode: 2 }; + } +} + +function programReport(options: ProofloopProgramOptions): ProofloopProgramResult { + const result = programStatus({ ...options, subcommand: "status", log: () => {}, logError: () => {} }); + const log = options.log ?? console.log; + const logError = options.logError ?? console.error; + if (result.exitCode !== 0) { + logError(`proofloop program: unable to load report for ${options.runId ?? "latest"}`); + return result; + } + const report = { + schema: "proofloop-program-report-v1", + programRunId: result.state.programRunId, + programId: result.state.programId, + status: result.state.status, + authorityDigest: result.state.authorityDigest, + budgetUsd: result.state.budgetUsd, + spentEstimatedUsd: result.state.spentEstimatedUsd, + arcs: result.state.arcStates.map((arc) => ({ + id: arc.id, + mode: arc.mode, + status: arc.status, + attempts: arc.attempts, + maxAttempts: arc.maxAttempts, + receiptVerified: arc.receipt?.ok ?? null, + })), + statePath: programStatePath(result.runDir), + ledgerPath: result.ledgerPath, + }; + if (options.json) log(JSON.stringify(report, null, 2)); + else log(`${formatProofloopProgramStatus(result.state, result.runDir)}\nreport=${JSON.stringify(report.arcs)}`); + return result; +} + +function emitProgramResult(state: ProofloopProgramState, runDir: string, options: ProofloopProgramOptions, log: (message: string) => void): ProofloopProgramResult { + if (options.json) log(JSON.stringify(state, null, 2)); + else log(formatProofloopProgramStatus(state, runDir)); + return { state, runDir, ledgerPath: programLedgerPath(runDir), exitCode: programExitCode(state.status) }; +} + +function terminalizeProgram( + state: ProofloopProgramState, + runDir: string, + status: Extract, + event: string, + message: string, + arcId?: string, + data?: Record, +): ProofloopProgramState { + state.status = status; + state.updatedAt = nowIso(); + writeProgramState(runDir, state); + appendProgramEvent(runDir, { + programRunId: state.programRunId, + event, + ...(arcId ? { arcId } : {}), + data: { message, ...(data ?? {}) }, + }); + return state; +} + +function writeProgramState(runDir: string, state: ProofloopProgramState): void { + atomicWriteJson(programStatePath(runDir), state); +} + +function readProgramState(path: string): ProofloopProgramState | undefined { + if (!existsSync(path)) return undefined; + try { + const value = JSON.parse(readFileSync(path, "utf8")); + return isRecord(value) ? value as ProofloopProgramState : undefined; + } catch { + return undefined; + } +} + +function appendProgramEvent(runDir: string, event: Omit): void { + mkdirSync(runDir, { recursive: true }); + const full: ProofloopProgramEvent = { schema: PROOFLOOP_PROGRAM_EVENT_SCHEMA, at: nowIso(), ...event }; + appendFileSync(programLedgerPath(runDir), `${JSON.stringify(full)}\n`, "utf8"); +} + +function repairProgramLedgerTornTail(runDir: string): { repaired: boolean; previousBytes: number; repairedBytes: number } { + const ledgerPath = programLedgerPath(runDir); + if (!existsSync(ledgerPath)) return { repaired: false, previousBytes: 0, repairedBytes: 0 }; + const raw = readFileSync(ledgerPath, "utf8"); + const previousBytes = Buffer.byteLength(raw); + if (raw.length === 0 || raw.endsWith("\n")) return { repaired: false, previousBytes, repairedBytes: previousBytes }; + const lastNewline = raw.lastIndexOf("\n"); + const repaired = lastNewline >= 0 ? raw.slice(0, lastNewline + 1) : ""; + const repairedBytes = Buffer.byteLength(repaired); + truncateSync(ledgerPath, repairedBytes); + return { repaired: true, previousBytes, repairedBytes }; +} + +function acquireProgramLock(runDir: string, ttlMs: number, clearStaleLock: boolean): ProgramLock { + mkdirSync(runDir, { recursive: true }); + const path = join(runDir, "program.lock"); + const token = randomUUID(); + try { + const fd = openSync(path, "wx"); + writeFileSync(fd, JSON.stringify({ token, pid: process.pid, createdAt: nowIso() })); + return programLockHandle(path, fd, token); + } catch (error) { + const code = isRecord(error) && typeof error.code === "string" ? error.code : ""; + if (code !== "EEXIST") throw error; + const ageMs = programLockAgeMs(path); + if (ageMs <= ttlMs) throw new Error(`program lock is held at ${path}; ageMs=${ageMs}`); + if (!clearStaleLock) throw new Error(`program lock is stale at ${path}; rerun with --clear-stale-lock to recover`); + rmSync(path, { force: true }); + const fd = openSync(path, "wx"); + writeFileSync(fd, JSON.stringify({ token, pid: process.pid, createdAt: nowIso(), stoleStaleLock: true })); + return programLockHandle(path, fd, token); + } +} + +function programLockHandle(path: string, fd: number, token: string): ProgramLock { + return { + release: () => { + try { + closeSync(fd); + } catch { + // Best effort; the token check below still prevents another process's lock from being removed. + } + try { + const raw = readFileSync(path, "utf8"); + const parsed = JSON.parse(raw) as { token?: unknown }; + if (parsed.token === token) unlinkSync(path); + } catch { + // A stale lock can be explicitly recovered by the next operator. + } + }, + }; +} + +function programLockAgeMs(path: string): number { + try { + return Math.max(0, Date.now() - statSync(path).mtimeMs); + } catch { + return Number.POSITIVE_INFINITY; + } +} + +function writeLatestProgramRun(root: string, runId: string): void { + const path = join(root, PROGRAM_ROOT, "latest"); + atomicWriteText(path, `${runId}\n`); +} + +function atomicWriteJson(path: string, value: unknown): void { + atomicWriteText(path, `${JSON.stringify(value, null, 2)}\n`); +} + +function atomicWriteText(path: string, text: string): void { + mkdirSync(dirname(path), { recursive: true }); + const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`; + writeFileSync(temporary, text, "utf8"); + try { + renameSync(temporary, path); + } catch (error) { + try { + if (existsSync(path)) unlinkSync(path); + renameSync(temporary, path); + } catch { + throw error; + } + } +} + +function emptyProgramErrorState(programRunId: string, message: string): ProofloopProgramState { + const now = nowIso(); + return { + schema: PROOFLOOP_PROGRAM_STATE_SCHEMA, + programRunId, + programId: "unknown", + planPath: "", + planDigest: "", + authorityPath: "", + authorityDigest: "", + budgetUsd: 0, + spentEstimatedUsd: 0, + status: "failed_integrity", + createdAt: now, + updatedAt: now, + arcStates: [{ + id: "program", + mode: "read_only", + dependsOn: [], + runnerPlanPath: "", + runnerPlanDigest: "", + runnerRunId: programRunId, + estimatedCostUsd: 0, + maxAttempts: 1, + attempts: 0, + status: "failed", + error: message, + }], + }; +} + +function programExitCode(status: ProofloopProgramStatus): number { + if (status === "certified") return 0; + if (status === "paused" || status === "queued" || status === "running") return 4; + if (status === "blocked_budget") return 3; + if (status === "blocked_authority") return 4; + return 1; +} + +function normalizeMaxArcs(value: number | undefined): number { + if (value === undefined) return Number.POSITIVE_INFINITY; + if (!positiveInteger(value)) throw new Error("program --max-arcs must be a positive integer"); + return value; +} + +function defaultProgramRunId(programId: string): string { + return `${programId}-${new Date().toISOString().replace(/[-:]/g, "").replace(/\..+$/, "Z")}`; +} + +function resolveProgramRepoFile( + root: string, + pathInput: string, + label: string, + options: { allowAbsoluteInsideRoot?: boolean } = {}, +): string { + if (typeof pathInput !== "string" || pathInput.length === 0) throw new Error(`${label} path is required`); + if (!options.allowAbsoluteInsideRoot && !safeRepoRelativePath(pathInput)) throw new Error(`${label} must be a safe repo-relative path`); + const rootReal = realPathOrResolved(root); + const candidate = isAbsolute(pathInput) ? resolve(pathInput) : resolve(root, pathInput); + if (!existsSync(candidate)) throw new Error(`${label} does not exist: ${pathInput}`); + const candidateReal = realPathOrResolved(candidate); + const escaped = relative(rootReal, candidateReal); + if (escaped === ".." || escaped.startsWith(`..${sep}`) || isAbsolute(escaped)) throw new Error(`${label} escapes the repository root`); + if (!statSync(candidateReal).isFile()) throw new Error(`${label} is not a regular file: ${pathInput}`); + return candidateReal; +} + +function realPathOrResolved(path: string): string { + try { + return resolve(realpathSync(path)); + } catch { + return resolve(path); + } +} + +function safeRepoRelativePath(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 parseArcMode(value: unknown, label: string): ProofloopProgramArcMode { + if (value === "read_only" || value === "proposal_only") return value; + throw new Error(`${label} must be read_only or proposal_only`); +} + +function parseIdArray(value: unknown, label: string): string[] { + if (value === undefined) return []; + if (!Array.isArray(value) || !value.every(validId)) throw new Error(`${label} must be an array of IDs`); + return value; +} + +function validId(value: unknown): value is string { + return typeof value === "string" && ID_PATTERN.test(value); +} + +function nonNegativeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 0; +} + +function sameStringArray(left: unknown, right: string[]): boolean { + return Array.isArray(left) && left.length === right.length && left.every((value, index) => value === right[index]); +} + +function isProofloopProgramStatus(value: unknown): value is ProofloopProgramStatus { + return value === "queued" || value === "running" || value === "paused" || value === "certified" + || value === "failed" || value === "failed_integrity" || value === "blocked_budget" || value === "blocked_authority"; +} + +function isProofloopProgramArcStatus(value: unknown): value is ProofloopProgramArcStatus { + return value === "queued" || value === "running" || value === "passed" || value === "failed" + || value === "blocked_budget" || value === "blocked_authority"; +} + +function positiveInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value > 0; +} + +function requirePositiveInteger(value: unknown, label: string): number { + if (!positiveInteger(value)) throw new Error(`${label} must be a positive integer`); + return value; +} + +function requireNonNegativeInteger(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isInteger(value) || value < 0) throw new Error(`${label} must be a non-negative integer`); + return value; +} + +function nonNegativeFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +function rejectUnknownKeys(value: Record, allowed: Set, label: string): void { + for (const key of Object.keys(value)) { + if (!allowed.has(key)) throw new Error(`${label} has unknown key \"${key}\"`); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function roundMoney(value: number): number { + return Math.round((value + Number.EPSILON) * 1_000_000) / 1_000_000; +} 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/easeProof.test.ts b/tests/easeProof.test.ts new file mode 100644 index 0000000..d4d341b --- /dev/null +++ b/tests/easeProof.test.ts @@ -0,0 +1,99 @@ +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { verifyEaseProof } from "../src/easeProof"; +import { verifyProofReceiptEnvelopeFile } from "../src/proofReceipt"; + +const roots: string[] = []; +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))); +const sha256 = (value: string | Buffer) => createHash("sha256").update(value).digest("hex"); +const writeJson = (path: string, value: unknown) => { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); }; + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "proofloop-ease-")); + roots.push(root); + const evidence = join(root, "proof", "ease", "latest"); + const png = Buffer.from("png-evidence"); + const candidate = Buffer.from("candidate-archive"); + const trace = Buffer.from("playwright-trace"); + const video = Buffer.from("browser-video"); + const commit = "a".repeat(40); + const hash = "b".repeat(64); + const screenshotPath = join(evidence, "browser", "screenshots", "arrival.png"); + mkdirSync(dirname(screenshotPath), { recursive: true }); + writeFileSync(screenshotPath, png); + writeFileSync(join(evidence, "candidate.tar.gz"), candidate); + writeFileSync(join(evidence, "browser", "playwright-trace.zip"), trace); + writeFileSync(join(evidence, "browser", "journey.webm"), video); + const browser: Record = { + schemaVersion: "nodekit.browser-certification/v1", + certified: false, + missingStates: ["fresh_human"], + serverProcess: { command: "node apps/web/server.mjs", pid: 1234 }, + journeyAssertions: { proposalVisible: true, approvalApplied: true, receiptVisible: true, receiptSurvivedReload: true }, + evidenceArtifacts: [ + { id: "playwright-trace", path: "browser/playwright-trace.zip", sha256: sha256(trace), byteSize: trace.byteLength }, + { id: "browser-video", path: "browser/journey.webm", sha256: sha256(video), byteSize: video.byteLength }, + ], + screenshots: [{ + path: "browser/screenshots/arrival.png", + pngSha256: sha256(png), + generatedCandidateCommit: commit, + applicationHash: hash, + configHash: hash, + nodekitSourceHash: hash, + consoleErrors: 0, + failedRequests: 0, + horizontalOverflowPx: 0, + mojibakeDetected: false, + }], + }; + browser.manifestSha256 = sha256(JSON.stringify(browser)); + writeJson(join(evidence, "browser", "screenshot-manifest.json"), browser); + const manifest: Record = { + schemaVersion: "nodekit.ease-proof-run/v1", + runId: "ease_test", + startedAt: "2026-07-21T00:00:00.000Z", + generatedAt: "2026-07-21T00:00:01.000Z", + durationMs: 1000, + nodekitSourceHash: hash, + base: { applicationHash: hash, configHash: hash, candidateCommit: commit, browserManifestDigest: browser.manifestSha256, phases: [{ name: "scaffold", durationMs: 10, exitCode: 0 }] }, + submissionReady: false, + submissionBlockers: ["freshHumanUsability"], + }; + manifest.receiptDigest = sha256(JSON.stringify(manifest)); + writeJson(join(evidence, "manifest.json"), manifest); + return { evidence, root, screenshotPath }; +} + +describe("NodeKit EaseProof verifier", () => { + it("verifies local evidence integrity while keeping ease certification blocked", () => { + const { root } = fixture(); + const output = join(root, "proof", "ease", "latest", "proofloop-receipt.json"); + const result = verifyEaseProof({ root, manifestPath: "proof/ease/latest/manifest.json", outputPath: output }); + expect(result.errors).toEqual([]); + expect(result.ok).toBe(true); + expect(result.easeCertified).toBe(false); + expect(result.checkedReplayArtifacts).toBe(2); + expect(result.warnings).toContain("Evidence integrity may pass, but NodeKit Ease is not certified and submission remains blocked."); + expect(verifyProofReceiptEnvelopeFile({ root, filePath: output }).ok).toBe(true); + }); + + it("fails after screenshot bytes are changed", () => { + const { root, screenshotPath } = fixture(); + writeFileSync(screenshotPath, "tampered"); + const result = verifyEaseProof({ root, manifestPath: "proof/ease/latest/manifest.json" }); + expect(result.ok).toBe(false); + expect(result.errors).toContain("screenshot digest mismatch: browser/screenshots/arrival.png"); + }); + + it("fails after replay evidence bytes are changed", () => { + const { root, evidence } = fixture(); + writeFileSync(join(evidence, "browser", "journey.webm"), "tampered"); + const result = verifyEaseProof({ root, manifestPath: "proof/ease/latest/manifest.json" }); + expect(result.ok).toBe(false); + expect(result.errors).toContain("browser replay artifact digest mismatch: browser/journey.webm"); + }); +}); 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/nodekitProof.test.ts b/tests/nodekitProof.test.ts new file mode 100644 index 0000000..7098212 --- /dev/null +++ b/tests/nodekitProof.test.ts @@ -0,0 +1,318 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { runCli } from "../src/cli"; +import { verifyNodekitProofBinding } from "../src/nodekitProof"; +import { runProofloopProgram, type ProofloopProgramAuthority, type ProofloopProgramPlan } from "../src/program"; +import type { ProofloopRunnerPlan } from "../src/runner"; + +const tempRoots: string[] = []; + +afterEach(() => { + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function tempRoot(): string { + const root = mkdtempSync(join(tmpdir(), "proofloop-nodekit-proof-")); + tempRoots.push(root); + return root; +} + +function sha256(value: string | Buffer): string { + return createHash("sha256").update(value).digest("hex"); +} + +function writeJson(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function withReceiptDigest>(receipt: T): T & { receiptDigest: string } { + return { ...receipt, receiptDigest: sha256(JSON.stringify(receipt)) }; +} + +function git(root: string, args: string[]): string { + return execFileSync("git", args, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }).trim(); +} + +type NodekitFixture = { + candidateCommit: string; + configHash: string; +}; + +function writeNodekitFixture(root: string): NodekitFixture { + const manifest = "apiVersion: nodeagent.dev/v1\nkind: AgentApplication\nmetadata:\n name: test-agent\n"; + const toolSource = "export const check = () => 'ok';\n"; + mkdirSync(join(root, "agent", "tools"), { recursive: true }); + writeFileSync(join(root, "nodeagent.yaml"), manifest, "utf8"); + writeFileSync(join(root, "agent", "tools", "check.mjs"), toolSource, "utf8"); + + git(root, ["init"]); + git(root, ["config", "user.email", "proofloop@example.test"]); + git(root, ["config", "user.name", "ProofLoop Test"]); + git(root, ["add", "nodeagent.yaml", "agent/tools/check.mjs"]); + git(root, ["commit", "-m", "initial NodeKit candidate"]); + const candidateCommit = git(root, ["rev-parse", "HEAD"]); + + const configHash = sha256("resolved-test-configuration"); + const discoveredBytes = Buffer.from(toolSource, "utf8"); + writeJson(join(root, ".nodeagent", "resolved-definition.json"), { + schemaVersion: "nodeagent.resolved/v1", + configHash, + fileCount: 1, + manifestDigest: sha256(manifest), + }); + mkdirSync(join(root, ".nodeagent"), { recursive: true }); + writeFileSync(join(root, ".nodeagent", "config-hash.txt"), `${configHash}\n`, "utf8"); + writeJson(join(root, ".nodeagent", "discovery.json"), { + schemaVersion: "nodeagent.discovery/v1", + files: [{ + path: "agent/tools/check.mjs", + bytes: discoveredBytes.byteLength, + digest: sha256(discoveredBytes), + }], + }); + writeJson(join(root, "proof", "demo-receipt.json"), withReceiptDigest({ + schemaVersion: "nodekit.smb-lending-receipt/v1", + configHash, + applicationHash: configHash, + candidate: { commit: candidateCommit, dirty: false }, + })); + writeJson(join(root, "proof", "eval-receipt.json"), withReceiptDigest({ + schemaVersion: "nodekit.smb-lending-eval-receipt/v1", + passed: true, + configHash, + applicationHash: configHash, + candidate: { commit: candidateCommit, dirty: false }, + })); + writeJson(join(root, "proof", "release-proof.json"), { + schemaVersion: "nodekit.proof-receipt/v1", + configHash, + applicationHash: configHash, + generatedAt: "2026-07-20T00:00:00.000Z", + level: "local-ready", + passed: true, + releaseReady: false, + checks: { + deterministicDemo: true, + deterministicEvaluation: true, + secretFree: true, + livePi: null, + browserQa: null, + deployment: null, + }, + missingReleaseGates: ["live model", "browser", "deployment"], + receiptVerification: { + schemaVersion: "nodekit.local-receipt-verification/v1", + passed: true, + applicationHash: configHash, + candidateCommit, + }, + }); + return { candidateCommit, configHash }; +} + +function writeProgramAuthority(root: string): void { + const authority: ProofloopProgramAuthority = { + schema: "proofloop-program-authority-v1", + authorityId: "nodekit-proof-authority", + allowedArcModes: ["read_only", "proposal_only"], + allowExternalEgress: false, + maxBudgetUsd: 1, + maxAttemptsPerArc: 1, + }; + writeJson(join(root, "authority.json"), authority); +} + +function writeNoopRunnerPlan(root: string): string { + const plan: ProofloopRunnerPlan = { + schema: "proofloop-runner-plan-v1", + tasks: [{ + id: "local-proof", + command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("process.exit(0)")}`, + estimatedCostUsd: 0, + }], + }; + writeJson(join(root, "plans", "noop.json"), plan); + return "plans/noop.json"; +} + +describe("NodeKit proof binding", () => { + it("binds a local-ready NodeKit proof to its compiled identity and current candidate commit", () => { + const root = tempRoot(); + const fixture = writeNodekitFixture(root); + + const result = verifyNodekitProofBinding({ + root, + releaseProofPath: "proof/release-proof.json", + candidateCommit: fixture.candidateCommit, + }); + + expect(result.ok).toBe(true); + expect(result.identity?.configHash).toBe(fixture.configHash); + expect(result.identity?.candidateCommit).toBe(fixture.candidateCommit); + expect(result.gateReceipts.map((receipt) => [receipt.id, receipt.ok])).toEqual([["demo", true], ["evaluation", true]]); + }); + + it("fails closed when the candidate commit, discovered source bytes, or required gate receipt does not match", () => { + const root = tempRoot(); + const fixture = writeNodekitFixture(root); + writeFileSync(join(root, "agent", "tools", "check.mjs"), "export const check = () => 'changed';\n", "utf8"); + rmSync(join(root, "proof", "eval-receipt.json")); + + const result = verifyNodekitProofBinding({ + root, + releaseProofPath: "proof/release-proof.json", + candidateCommit: "0".repeat(40), + }); + + expect(result.ok).toBe(false); + expect(result.errors).toContain(`candidate commit mismatch: expected ${"0".repeat(40)}, observed ${fixture.candidateCommit}`); + expect(result.errors).toContain("NodeKit discovered file agent/tools/check.mjs digest changed"); + expect(result.gateReceipts.find((receipt) => receipt.id === "evaluation")?.ok).toBe(false); + }); + + it("rejects regenerated discovery when its source bytes are not part of the candidate commit", () => { + const root = tempRoot(); + const fixture = writeNodekitFixture(root); + const regeneratedSource = "export const check = () => 'regenerated-but-uncommitted';\n"; + const regeneratedBytes = Buffer.from(regeneratedSource, "utf8"); + writeFileSync(join(root, "agent", "tools", "check.mjs"), regeneratedSource, "utf8"); + writeJson(join(root, ".nodeagent", "discovery.json"), { + schemaVersion: "nodeagent.discovery/v1", + files: [{ + path: "agent/tools/check.mjs", + bytes: regeneratedBytes.byteLength, + digest: sha256(regeneratedBytes), + }], + }); + + const result = verifyNodekitProofBinding({ + root, + releaseProofPath: "proof/release-proof.json", + candidateCommit: fixture.candidateCommit, + }); + + expect(result.ok).toBe(false); + expect(result.errors).toContain(`NodeKit discovered file agent/tools/check.mjs bytes do not match candidate commit ${fixture.candidateCommit}`); + }); + + it("rejects a gate receipt whose configHash belongs to a different compiled application", () => { + const root = tempRoot(); + const fixture = writeNodekitFixture(root); + const evalPath = join(root, "proof", "eval-receipt.json"); + const evalReceipt = JSON.parse(readFileSync(evalPath, "utf8")) as Record; + evalReceipt.configHash = "f".repeat(64); + writeJson(evalPath, evalReceipt); + + const result = verifyNodekitProofBinding({ + root, + releaseProofPath: "proof/release-proof.json", + candidateCommit: fixture.candidateCommit, + }); + + expect(result.ok).toBe(false); + expect(result.gateReceipts.find((receipt) => receipt.id === "evaluation")?.errors).toContain( + "evaluation gate receipt configHash does not match the compiled NodeKit configHash", + ); + }); + + it("rejects an emitted receiptDigest after any covered receipt content changes", () => { + const root = tempRoot(); + const fixture = writeNodekitFixture(root); + const demoPath = join(root, "proof", "demo-receipt.json"); + const demoReceipt = JSON.parse(readFileSync(demoPath, "utf8")) as Record; + demoReceipt.extraAssertion = "tampered after the digest was emitted"; + writeJson(demoPath, demoReceipt); + + const result = verifyNodekitProofBinding({ + root, + releaseProofPath: "proof/release-proof.json", + candidateCommit: fixture.candidateCommit, + }); + + expect(result.ok).toBe(false); + expect(result.gateReceipts.find((receipt) => receipt.id === "demo")?.errors).toContain( + "demo gate receipt receiptDigest does not match content", + ); + }); + + it("requires the three release-only receipts when release-ready proof is requested", () => { + const root = tempRoot(); + const fixture = writeNodekitFixture(root); + + const result = verifyNodekitProofBinding({ + root, + releaseProofPath: "proof/release-proof.json", + candidateCommit: fixture.candidateCommit, + minimumLevel: "release-ready", + }); + + expect(result.ok).toBe(false); + expect(result.errors).toContain("NodeKit release proof does not meet required release-ready level"); + expect(result.gateReceipts.map((receipt) => [receipt.id, receipt.ok])).toEqual([ + ["demo", true], + ["evaluation", true], + ["live", false], + ["browser", false], + ["deployment", false], + ]); + }); + + it("is available through both the CLI and a local-only P0 program receipt hook", async () => { + const root = tempRoot(); + const fixture = writeNodekitFixture(root); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + try { + expect(await runCli([ + "--dir", root, + "program", "verify-nodekit", + "--file", "proof/release-proof.json", + "--candidate-commit", fixture.candidateCommit, + "--json", + ])).toBe(0); + + writeProgramAuthority(root); + const runnerPlan = writeNoopRunnerPlan(root); + const program: ProofloopProgramPlan = { + schema: "proofloop-program-plan-v1", + programId: "nodekit-binding-program", + authorityPath: "authority.json", + arcs: [{ + id: "verify-nodekit", + mode: "read_only", + runnerPlan, + receipt: { + kind: "nodekit-proof", + file: "proof/release-proof.json", + candidateCommit: fixture.candidateCommit, + }, + }], + }; + writeJson(join(root, "program.json"), program); + const run = await runProofloopProgram({ + root, + subcommand: "run", + planPath: "program.json", + runId: "nodekit-binding", + log: () => undefined, + logError: () => undefined, + }); + expect(run.exitCode).toBe(0); + expect(run.state.status).toBe("certified"); + expect(run.state.arcStates[0]?.receipt).toMatchObject({ kind: "nodekit-proof", ok: true }); + } finally { + log.mockRestore(); + error.mockRestore(); + } + }); +}); diff --git a/tests/program.test.ts b/tests/program.test.ts new file mode 100644 index 0000000..58fe2b5 --- /dev/null +++ b/tests/program.test.ts @@ -0,0 +1,261 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { runCli } from "../src/cli"; +import { + programLedgerPath, + programRunDir, + programStatePath, + runProofloopProgram, + type ProofloopProgramAuthority, + type ProofloopProgramPlan, + type ProofloopProgramState, +} from "../src/program"; +import { + PROOFLOOP_RECEIPT_SCHEMA, + createInlineProofReceiptPayload, + createInlineProofReceiptResource, + type ProofReceiptEnvelope, +} from "../src/proofReceipt"; +import type { ProofloopRunnerPlan } from "../src/runner"; + +const tempRoots: string[] = []; + +afterEach(() => { + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function tempRoot(): string { + const root = mkdtempSync(join(tmpdir(), "proofloop-program-")); + tempRoots.push(root); + return root; +} + +function nodeCommand(source: string): string { + return `${JSON.stringify(process.execPath)} -e ${JSON.stringify(source)}`; +} + +function appendMarkerCommand(value: string, exitCode = 0): string { + return nodeCommand([ + "const fs=require('node:fs');", + "fs.appendFileSync(process.env.MARKER,process.env.VALUE+String.fromCharCode(10));", + `process.exit(${exitCode});`, + ].join("")); +} + +function writeRunnerPlan(root: string, id: string, marker: string, value: string, estimatedCostUsd: number, exitCode = 0): string { + const relativePath = join("plans", `${id}.runner.json`); + const absolutePath = join(root, relativePath); + mkdirSync(join(root, "plans"), { recursive: true }); + const plan: ProofloopRunnerPlan = { + schema: "proofloop-runner-plan-v1", + tasks: [{ + id: `${id}.task`, + command: appendMarkerCommand(value, exitCode), + env: { MARKER: marker, VALUE: value }, + estimatedCostUsd, + }], + }; + writeFileSync(absolutePath, `${JSON.stringify(plan, null, 2)}\n`, "utf8"); + return relativePath.replace(/\\/g, "/"); +} + +function writeAuthority(root: string, overrides: Partial = {}): void { + const authority: ProofloopProgramAuthority = { + schema: "proofloop-program-authority-v1", + authorityId: "overnight-authority", + allowedArcModes: ["read_only", "proposal_only"], + allowExternalEgress: false, + maxBudgetUsd: 10, + maxAttemptsPerArc: 1, + ...overrides, + }; + writeFileSync(join(root, "authority.json"), `${JSON.stringify(authority, null, 2)}\n`, "utf8"); +} + +function writeProgram(root: string, arcs: ProofloopProgramPlan["arcs"]): string { + const plan: ProofloopProgramPlan = { + schema: "proofloop-program-plan-v1", + programId: "overnight-program", + authorityPath: "authority.json", + arcs, + }; + const path = join(root, "program.json"); + writeFileSync(path, `${JSON.stringify(plan, null, 2)}\n`, "utf8"); + return "program.json"; +} + +function readState(root: string, runId: string): ProofloopProgramState { + return JSON.parse(readFileSync(programStatePath(programRunDir(root, runId)), "utf8")) as ProofloopProgramState; +} + +function writePassingEnvelope(root: string, relativePath = "receipt.json"): string { + const evidence = createInlineProofReceiptResource({ id: "gate-evidence", kind: "test-evidence", inline: { gate: "passed" } }); + const envelope: ProofReceiptEnvelope = { + schema: PROOFLOOP_RECEIPT_SCHEMA, + schemaVersion: 1, + receiptId: "program-gate-receipt", + kind: "program-gate", + createdAt: "2026-07-20T00:00:00.000Z", + producer: { id: "proofloop", version: "0.3.0" }, + subject: { type: "workflow", id: "program-test" }, + verdict: { + status: "passed", + authority: "authoritative", + decisionMethod: "deterministic_gate", + decisiveCheckIds: ["gate"], + summary: "The deterministic program gate passed.", + }, + checks: [{ + id: "gate", + status: "passed", + role: "decisive", + method: "deterministic", + summary: "The test command exited zero.", + evidenceRefs: [evidence.id], + exitCode: 0, + }], + evidence: [evidence], + payload: createInlineProofReceiptPayload("program-test-payload/v1", { status: "passed" }, 1), + }; + writeFileSync(join(root, relativePath), `${JSON.stringify(envelope, null, 2)}\n`, "utf8"); + return relativePath; +} + +describe("proofloop program", () => { + it("runs arcs in stable dependency order, verifies a receipt, and resumes only queued work", async () => { + const root = tempRoot(); + const marker = join(root, "order.txt"); + writeAuthority(root); + const firstPlan = writeRunnerPlan(root, "first", marker, "first", 0.1); + const secondPlan = writeRunnerPlan(root, "second", marker, "second", 0.1); + const receipt = writePassingEnvelope(root); + const programPath = writeProgram(root, [ + { id: "second", mode: "proposal_only", runnerPlan: secondPlan, dependsOn: ["first"], receipt: { kind: "proofloop-envelope", file: receipt } }, + { id: "first", mode: "read_only", runnerPlan: firstPlan }, + ]); + + const paused = await runProofloopProgram({ root, subcommand: "run", planPath: programPath, runId: "ordered", maxArcs: 1, log: () => {}, logError: () => {} }); + expect(paused.exitCode).toBe(4); + expect(paused.state.status).toBe("paused"); + expect(readFileSync(marker, "utf8")).toBe("first\n"); + expect(paused.state.arcStates.map((arc) => [arc.id, arc.status])).toEqual([["first", "passed"], ["second", "queued"]]); + + const resumed = await runProofloopProgram({ root, subcommand: "resume", runId: "ordered", log: () => {}, logError: () => {} }); + expect(resumed.exitCode).toBe(0); + expect(resumed.state.status).toBe("certified"); + expect(readFileSync(marker, "utf8")).toBe("first\nsecond\n"); + expect(resumed.state.arcStates.find((arc) => arc.id === "second")?.receipt?.ok).toBe(true); + expect(readFileSync(programLedgerPath(programRunDir(root, "ordered")), "utf8")).toContain("receipt_verified"); + }); + + it("binds the authority digest and blocks a changed authority before executing queued arcs", async () => { + const root = tempRoot(); + const marker = join(root, "authority.txt"); + writeAuthority(root); + const firstPlan = writeRunnerPlan(root, "first", marker, "first", 0.1); + const secondPlan = writeRunnerPlan(root, "second", marker, "second", 0.1); + const programPath = writeProgram(root, [ + { id: "first", mode: "read_only", runnerPlan: firstPlan }, + { id: "second", mode: "proposal_only", runnerPlan: secondPlan, dependsOn: ["first"] }, + ]); + + await runProofloopProgram({ root, subcommand: "run", planPath: programPath, runId: "authority", maxArcs: 1, log: () => {}, logError: () => {} }); + writeAuthority(root, { maxBudgetUsd: 9 }); + const blocked = await runProofloopProgram({ root, subcommand: "resume", runId: "authority", log: () => {}, logError: () => {} }); + + expect(blocked.exitCode).toBe(4); + expect(blocked.state.status).toBe("blocked_authority"); + expect(readFileSync(marker, "utf8")).toBe("first\n"); + expect(readFileSync(programLedgerPath(programRunDir(root, "authority")), "utf8")).toContain("authority_digest_changed"); + }); + + it("blocks declared external egress before the runner can execute", async () => { + const root = tempRoot(); + const marker = join(root, "egress.txt"); + writeAuthority(root); + const runnerPlan = writeRunnerPlan(root, "external", marker, "should-not-run", 0); + const programPath = writeProgram(root, [{ + id: "external", + mode: "read_only", + runnerPlan, + externalEgress: true, + }]); + + const blocked = await runProofloopProgram({ root, subcommand: "run", planPath: programPath, runId: "egress", log: () => {}, logError: () => {} }); + + expect(blocked.exitCode).toBe(4); + expect(blocked.state.status).toBe("blocked_authority"); + expect(existsSync(marker)).toBe(false); + }); + + it("rejects non-portable durable identifiers before dispatch", async () => { + const root = tempRoot(); + const marker = join(root, "portable-id.txt"); + writeAuthority(root); + const runnerPlan = writeRunnerPlan(root, "portable", marker, "should-not-run", 0); + const programPath = writeProgram(root, [{ id: "bad:arc", mode: "read_only", runnerPlan }]); + + const rejected = await runProofloopProgram({ root, subcommand: "run", planPath: programPath, runId: "portable", log: () => {}, logError: () => {} }); + + expect(rejected.exitCode).toBe(2); + expect(rejected.state.status).toBe("failed_integrity"); + expect(existsSync(marker)).toBe(false); + }); + + it("enforces the program budget across sequential arcs", async () => { + const root = tempRoot(); + const marker = join(root, "budget.txt"); + writeAuthority(root, { maxBudgetUsd: 0.4 }); + const firstPlan = writeRunnerPlan(root, "first", marker, "first", 0.3); + const secondPlan = writeRunnerPlan(root, "second", marker, "second", 0.3); + const programPath = writeProgram(root, [ + { id: "first", mode: "read_only", runnerPlan: firstPlan }, + { id: "second", mode: "proposal_only", runnerPlan: secondPlan, dependsOn: ["first"] }, + ]); + + const result = await runProofloopProgram({ root, subcommand: "run", planPath: programPath, runId: "budget", log: () => {}, logError: () => {} }); + + expect(result.exitCode).toBe(3); + expect(result.state.status).toBe("blocked_budget"); + expect(readFileSync(marker, "utf8")).toBe("first\n"); + expect(result.state.arcStates.map((arc) => [arc.id, arc.status])).toEqual([["first", "passed"], ["second", "blocked_budget"]]); + }); + + it("does not automatically requeue a failed arc on resume", async () => { + const root = tempRoot(); + const marker = join(root, "failed.txt"); + writeAuthority(root); + const runnerPlan = writeRunnerPlan(root, "failing", marker, "attempt", 0, 1); + const programPath = writeProgram(root, [{ id: "failing", mode: "read_only", runnerPlan }]); + + const failed = await runProofloopProgram({ root, subcommand: "run", planPath: programPath, runId: "failed", log: () => {}, logError: () => {} }); + expect(failed.exitCode).toBe(1); + expect(failed.state.status).toBe("failed"); + expect(readState(root, "failed").arcStates[0]?.attempts).toBe(1); + + const resumed = await runProofloopProgram({ root, subcommand: "resume", runId: "failed", log: () => {}, logError: () => {} }); + expect(resumed.exitCode).toBe(1); + expect(readState(root, "failed").arcStates[0]?.attempts).toBe(1); + expect(readFileSync(marker, "utf8")).toBe("attempt\n"); + }); + + it("exposes the program supervisor through the public CLI", async () => { + const root = tempRoot(); + const marker = join(root, "cli.txt"); + writeAuthority(root); + const runnerPlan = writeRunnerPlan(root, "cli", marker, "cli", 0); + const programPath = writeProgram(root, [{ id: "cli", mode: "read_only", runnerPlan }]); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + try { + const code = await runCli(["--dir", root, "program", "run", "--plan", programPath, "--run-id", "cli"]); + expect(code).toBe(0); + expect(readFileSync(marker, "utf8")).toBe("cli\n"); + } finally { + log.mockRestore(); + error.mockRestore(); + } + }); +}); 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"); + }); +});