Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"test": "vitest run",
"typecheck": "tsc --noEmit",
"lint": "biome check .",
"format": "biome format --write ."
"format": "biome format --write .",
"verify-receipt": "node --import ./scripts/register-ts-loader.mjs src/verify/cli.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "1.30.0",
Expand Down
5 changes: 5 additions & 0 deletions scripts/register-ts-loader.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Node 24 deprecates --loader; this shim registers the same resolve hook via
// --import, which is the supported replacement and emits no warning.
import { register } from "node:module";

register("./ts-loader.mjs", import.meta.url);
18 changes: 18 additions & 0 deletions scripts/ts-loader.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Node 24 loader hook: resolves .js imports to .ts files so TypeScript ESM
// sources can run without a build step. Used only by the verify-receipt CLI.
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";

export function resolve(specifier, context, nextResolve) {
// Only remap relative .js imports.
if (specifier.startsWith(".") && specifier.endsWith(".js")) {
const tsSpecifier = `${specifier.slice(0, -3)}.ts`;
if (context.parentURL) {
const resolved = new URL(tsSpecifier, context.parentURL);
if (existsSync(fileURLToPath(resolved))) {
return nextResolve(resolved.href, context);
}
}
}
return nextResolve(specifier, context);
}
74 changes: 74 additions & 0 deletions src/verify/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env node
// verify-receipt: evidence command for Need-to-Know release receipts.
//
// Usage:
// verify-receipt <receipt-file.json>
// cat receipt.json | verify-receipt
//
// Exit codes:
// 0 — PASS: receipt is internally consistent and all checks passed
// 1 — FAIL: one or more checks failed (outcome printed to stdout)
// 2 — ERROR: could not read input (I/O error)
//
// This is an evidence command, not a receipt service or product API.
// It performs deterministic internal-consistency checks only:
// - receipt fields are present and non-empty
// - contract hash matches the recomputed candidate hash
// - output hash matches the recomputed candidate output hash
// - candidate passes the release contract policy
// - canary values are absent from the released rows
// - when events are present: every gated tool call is requested AND granted
// by the user (user.tool_approval status "allow") before it executes, no
// duplicate approval requests, canary absent from the serialized stream
//
// It does NOT authenticate origin, verify external delivery, or guarantee that
// the persisted-event witness and the local candidate are from the same session.

import { readFileSync } from "node:fs";

import { verifyReceipt } from "./verify.js";

function readInput(args: string[]): string {
const [filePath] = args;
if (filePath !== undefined && filePath !== "-") {
return readFileSync(filePath, "utf8");
}
// Read from stdin synchronously — file descriptor 0, not /dev/stdin,
// which does not exist on Windows.
return readFileSync(0, "utf8");
}

function main(args: string[]): void {
let raw: string;
try {
raw = readInput(args);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`verify-receipt: could not read input: ${message}\n`);
process.exit(2);
}

let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
process.stderr.write("verify-receipt: input is not valid JSON\n");
process.exit(1);
}

const result = verifyReceipt(parsed);

if (result.outcome === "pass") {
process.stdout.write(
`verify-receipt: PASS receipt=${result.receiptId} query=${result.queryId}\n`,
);
process.exit(0);
} else {
process.stdout.write(
`verify-receipt: FAIL outcome=${result.outcome} detail=${result.detail}\n`,
);
process.exit(1);
}
}

main(process.argv.slice(2));
61 changes: 61 additions & 0 deletions src/verify/receipt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { Sha256Hex } from "../contract/canonical.js";

// A VerifiableReceipt bundles the release receipt together with the candidate
// that produced it. The verifier recomputes both hashes from the candidate and
// checks them against the receipt — internal consistency only. It does not
// authenticate origin or prevent a party from fabricating both fields together.
export type VerifiableReceipt = {
readonly receipt: {
// Display field only: nothing candidate-side can corroborate a receiptId,
// so the verifier reports it back but never treats it as verified.
readonly receiptId: string;
readonly queryId: string;
readonly contractHash: Sha256Hex;
readonly outputHash: Sha256Hex;
readonly datasetVersion: string;
readonly policyVersion: string;
};
readonly candidate: unknown;
// Optional: serialized TrueForge session events for the turn that produced
// this receipt. When present the verifier additionally checks approval-before-
// release ordering and canary absence in the event stream.
readonly events?: unknown;
};

// The verifier never throws — every path returns a typed verdict.
export type VerifyOutcome =
| "pass"
| "receipt_malformed"
| "candidate_malformed"
| "contract_hash_mismatch"
| "output_hash_mismatch"
| "candidate_denied"
| "receipt_metadata_mismatch"
| "canary_in_rows"
| "events_malformed"
| "approval_missing"
| "user_approval_missing"
| "release_before_approval"
| "canary_in_events"
| "duplicate_approval_event";

export type VerifyResult =
| { readonly outcome: "pass"; readonly receiptId: string; readonly queryId: string }
| { readonly outcome: Exclude<VerifyOutcome, "pass">; readonly detail: string };

// TrueForge event type strings relevant to approval-ordering checks.
export const TF_EVENT_TOOL_APPROVAL_REQUIRED = "tool.approval_required";
export const TF_EVENT_USER_TOOL_APPROVAL = "user.tool_approval";
export const TF_EVENT_TOOL_RESPONSE = "tool.response";

// The receipt record keys — exactly these, no extras, no missing.
export const RECEIPT_KEYS = Object.freeze([
"receiptId",
"queryId",
"contractHash",
"outputHash",
"datasetVersion",
"policyVersion",
] as const);

export const VERIFIABLE_RECEIPT_KEYS = Object.freeze(["receipt", "candidate"] as const);
Loading