diff --git a/packages/pi-agent-journal/__tests__/evaluation-receipts.test.ts b/packages/pi-agent-journal/__tests__/evaluation-receipts.test.ts new file mode 100644 index 0000000..07c4d8f --- /dev/null +++ b/packages/pi-agent-journal/__tests__/evaluation-receipts.test.ts @@ -0,0 +1,152 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { + CANONICAL_JSON_LIMITS, + CanonicalJsonError, + canonicalJson, + canonicalSha256, +} from "../extensions/evaluation-receipts.js"; + +describe("evaluation receipt canonicalization", () => { + it("matches the RFC 8785 primitive and recursive sorting example", () => { + const value = { + numbers: [Number("333333333.33333329"), 1e30, 4.5, 2e-3, 1e-27], + string: '€$\u000f\nA\'B"\\\\"/', + literals: [null, true, false], + }; + + expect(canonicalJson(value)).toBe( + '{"literals":[null,true,false],"numbers":[333333333.3333333,1e+30,4.5,0.002,1e-27],"string":"€$\\u000f\\nA\'B\\"\\\\\\\\\\"/"}', + ); + }); + + it("sorts property names by raw UTF-16 code units while preserving array order", () => { + const value = { + "€": "Euro Sign", + "\r": "Carriage Return", + דּ: "Hebrew Letter Dalet With Dagesh", + "1": "One", + "😀": "Emoji: Grinning Face", + "\u0080": "Control", + ö: "Latin Small Letter O With Diaeresis", + nested: [ + { z: 1, a: 2 }, + { b: 3, a: 4 }, + ], + }; + + expect(canonicalJson(value)).toBe( + '{"\\r":"Carriage Return","1":"One","nested":[{"a":2,"z":1},{"a":4,"b":3}],"€":"Control","ö":"Latin Small Letter O With Diaeresis","€":"Euro Sign","😀":"Emoji: Grinning Face","דּ":"Hebrew Letter Dalet With Dagesh"}', + ); + }); + + it("preserves Unicode without normalization and emits stable UTF-8 SHA-256", () => { + const value = { decomposed: "e\u0301", composed: "é", negativeZero: -0 }; + const canonical = canonicalJson(value); + expect(canonical).toBe('{"composed":"é","decomposed":"é","negativeZero":0}'); + expect(canonicalSha256(value)).toEqual({ + canonical, + byteLength: Buffer.byteLength(canonical, "utf8"), + digest: createHash("sha256").update(Buffer.from(canonical, "utf8")).digest("hex"), + }); + }); + + it.each([ + ["NaN", Number.NaN], + ["positive infinity", Number.POSITIVE_INFINITY], + ["negative infinity", Number.NEGATIVE_INFINITY], + ["undefined", undefined], + ["bigint", 1n], + ["function", () => undefined], + ["symbol", Symbol("unsafe")], + ["date", new Date(0)], + ["lone high surrogate", "\ud800"], + ["lone low surrogate", "\udc00"], + ["lone surrogate property", { "\ud800": true }], + ])("rejects non-I-JSON input: %s", (_label, value) => { + expect(() => canonicalJson(value)).toThrow(CanonicalJsonError); + }); + + it("rejects cycles, sparse arrays, accessors, hidden fields, symbols, and custom prototypes", () => { + const cyclic: Record = {}; + cyclic.self = cyclic; + expect(() => canonicalJson(cyclic)).toThrow(/cycle/i); + + const sparse = [1, 2, 3]; + delete sparse[1]; + expect(() => canonicalJson(sparse)).toThrow(/sparse/i); + + const accessor = {}; + Object.defineProperty(accessor, "value", { enumerable: true, get: () => "unsafe" }); + expect(() => canonicalJson(accessor)).toThrow(/data property/i); + + const hidden = { visible: true }; + Object.defineProperty(hidden, "secret", { value: "unsafe", enumerable: false }); + expect(() => canonicalJson(hidden)).toThrow(/data property|non-JSON/i); + + const symbolic = { visible: true, [Symbol("secret")]: "unsafe" }; + expect(() => canonicalJson(symbolic)).toThrow(/non-JSON/i); + + const custom = Object.assign(Object.create({ inherited: "unsafe" }), { visible: true }); + expect(() => canonicalJson(custom)).toThrow(/plain JSON object/i); + }); + + it("accepts shared acyclic values and frozen JSON data", () => { + const shared = Object.freeze({ b: 2, a: 1 }); + expect(canonicalJson({ right: shared, left: shared })).toBe('{"left":{"a":1,"b":2},"right":{"a":1,"b":2}}'); + }); + + it("rejects proxies before executing traps", () => { + let traps = 0; + const handlers: ProxyHandler> = { + getPrototypeOf: () => { + traps += 1; + return Object.prototype; + }, + ownKeys: () => { + traps += 1; + return ["safe"]; + }, + getOwnPropertyDescriptor: () => { + traps += 1; + return { value: "forged", enumerable: true, writable: true, configurable: true }; + }, + }; + expect(() => canonicalJson(new Proxy({ safe: true }, handlers))).toThrow(/proxy/i); + expect(traps).toBe(0); + + let arrayTraps = 0; + const array = new Proxy([1, 2], { + getPrototypeOf: () => { + arrayTraps += 1; + return Array.prototype; + }, + }); + expect(() => canonicalJson(array)).toThrow(/proxy/i); + expect(arrayTraps).toBe(0); + }); + + it("fails closed on frozen depth, collection, node, string, and output limits", () => { + let deep: Record = {}; + for (let index = 0; index <= CANONICAL_JSON_LIMITS.maxDepth; index += 1) deep = { child: deep }; + expect(() => canonicalJson(deep)).toThrow(/depth limit/i); + + const longArray = Array.from({ length: CANONICAL_JSON_LIMITS.maxArrayLength + 1 }, () => null); + expect(() => canonicalJson(longArray)).toThrow(/array length limit/i); + + const wideObject = Object.fromEntries( + Array.from({ length: CANONICAL_JSON_LIMITS.maxObjectProperties + 1 }, (_, index) => [`k${index}`, null]), + ); + expect(() => canonicalJson(wideObject)).toThrow(/property limit/i); + + const childrenPerArray = Math.ceil(CANONICAL_JSON_LIMITS.maxNodes / CANONICAL_JSON_LIMITS.maxArrayLength); + const manyNodes = Array.from({ length: CANONICAL_JSON_LIMITS.maxArrayLength }, () => + Array.from({ length: childrenPerArray }, () => null), + ); + expect(() => canonicalJson(manyNodes)).toThrow(/node limit/i); + + expect(() => canonicalJson("x".repeat(CANONICAL_JSON_LIMITS.maxStringBytes + 1))).toThrow(/string byte limit/i); + const escaped = "\u0000".repeat(Math.floor(CANONICAL_JSON_LIMITS.maxOutputBytes / 6) + 1); + expect(() => canonicalJson(escaped)).toThrow(/output byte limit/i); + }); +}); diff --git a/packages/pi-agent-journal/extensions/evaluation-receipts.ts b/packages/pi-agent-journal/extensions/evaluation-receipts.ts new file mode 100644 index 0000000..c9f9469 --- /dev/null +++ b/packages/pi-agent-journal/extensions/evaluation-receipts.ts @@ -0,0 +1,185 @@ +import { createHash } from "node:crypto"; +import { isProxy } from "node:util/types"; + +export const CANONICAL_JSON_LIMITS = Object.freeze({ + maxDepth: 64, + maxNodes: 100_000, + maxArrayLength: 10_000, + maxObjectProperties: 10_000, + maxStringBytes: 256 * 1024, + maxOutputBytes: 1024 * 1024, +}); + +export class CanonicalJsonError extends Error { + constructor(message: string) { + super(message); + this.name = "CanonicalJsonError"; + } +} + +class CanonicalWriter { + readonly chunks: string[] = []; + byteLength = 0; + + append(value: string): void { + const nextBytes = Buffer.byteLength(value, "utf8"); + if (this.byteLength + nextBytes > CANONICAL_JSON_LIMITS.maxOutputBytes) { + throw new CanonicalJsonError("canonical JSON output byte limit exceeded"); + } + this.chunks.push(value); + this.byteLength += nextBytes; + } + + finish(): string { + return this.chunks.join(""); + } +} + +interface SerializationContext { + writer: CanonicalWriter; + stack: WeakSet; + nodes: number; +} + +function assertUnicode(value: string, field: string): void { + if (Buffer.byteLength(value, "utf8") > CANONICAL_JSON_LIMITS.maxStringBytes) { + throw new CanonicalJsonError(`${field} exceeds the string byte limit`); + } + for (let index = 0; index < value.length; index += 1) { + const unit = value.charCodeAt(index); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) { + throw new CanonicalJsonError(`${field} contains a lone high surrogate`); + } + index += 1; + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + throw new CanonicalJsonError(`${field} contains a lone low surrogate`); + } + } +} + +function serializeString(value: string, field: string, writer: CanonicalWriter): void { + assertUnicode(value, field); + writer.append(JSON.stringify(value)); +} + +function arrayDescriptors(value: unknown[], field: string): PropertyDescriptor[] { + if (value.length > CANONICAL_JSON_LIMITS.maxArrayLength) { + throw new CanonicalJsonError(`${field} exceeds the array length limit`); + } + if (Object.getPrototypeOf(value) !== Array.prototype) { + throw new CanonicalJsonError(`${field} must be a plain JSON array`); + } + const ownKeys = Reflect.ownKeys(value); + if (ownKeys.length !== value.length + 1 || ownKeys.some((key) => typeof key !== "string")) { + throw new CanonicalJsonError(`${field} contains non-JSON fields or sparse items`); + } + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length"); + if (!lengthDescriptor || !("value" in lengthDescriptor) || lengthDescriptor.value !== value.length) { + throw new CanonicalJsonError(`${field}.length must be an array data property`); + } + const descriptors: PropertyDescriptor[] = []; + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor || !("value" in descriptor) || descriptor.enumerable !== true) { + throw new CanonicalJsonError(`${field}[${index}] must be an enumerable data property`); + } + descriptors.push(descriptor); + } + return descriptors; +} + +function objectDescriptors(value: object, field: string): Array<[string, PropertyDescriptor]> { + if (Object.getPrototypeOf(value) !== Object.prototype) { + throw new CanonicalJsonError(`${field} must be a plain JSON object`); + } + const ownKeys = Reflect.ownKeys(value); + if (ownKeys.length > CANONICAL_JSON_LIMITS.maxObjectProperties) { + throw new CanonicalJsonError(`${field} exceeds the object property limit`); + } + if (ownKeys.some((key) => typeof key !== "string")) { + throw new CanonicalJsonError(`${field} contains non-JSON symbol fields`); + } + const entries = (ownKeys as string[]).map((key): [string, PropertyDescriptor] => { + assertUnicode(key, `${field} property name`); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor) || descriptor.enumerable !== true) { + throw new CanonicalJsonError(`${field}.${key} must be an enumerable data property`); + } + return [key, descriptor]; + }); + return entries.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)); +} + +function serialize(value: unknown, field: string, depth: number, context: SerializationContext): void { + if (depth > CANONICAL_JSON_LIMITS.maxDepth) throw new CanonicalJsonError(`${field} exceeds the depth limit`); + context.nodes += 1; + if (context.nodes > CANONICAL_JSON_LIMITS.maxNodes) + throw new CanonicalJsonError("canonical JSON node limit exceeded"); + + if (value === null) { + context.writer.append("null"); + return; + } + if (typeof value === "string") { + serializeString(value, field, context.writer); + return; + } + if (typeof value === "boolean") { + context.writer.append(value ? "true" : "false"); + return; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new CanonicalJsonError(`${field} contains a non-finite number`); + context.writer.append(JSON.stringify(value)); + return; + } + if (typeof value !== "object") { + throw new CanonicalJsonError(`${field} contains unsupported ${typeof value} data`); + } + if (isProxy(value)) throw new CanonicalJsonError(`${field} contains a Proxy`); + if (context.stack.has(value)) throw new CanonicalJsonError(`${field} contains a cycle`); + + context.stack.add(value); + try { + if (Array.isArray(value)) { + const descriptors = arrayDescriptors(value, field); + context.writer.append("["); + descriptors.forEach((descriptor, index) => { + if (index > 0) context.writer.append(","); + serialize(descriptor.value, `${field}[${index}]`, depth + 1, context); + }); + context.writer.append("]"); + return; + } + + const entries = objectDescriptors(value, field); + context.writer.append("{"); + entries.forEach(([key, descriptor], index) => { + if (index > 0) context.writer.append(","); + serializeString(key, `${field} property name`, context.writer); + context.writer.append(":"); + serialize(descriptor.value, `${field}.${key}`, depth + 1, context); + }); + context.writer.append("}"); + } finally { + context.stack.delete(value); + } +} + +export function canonicalJson(value: unknown): string { + const writer = new CanonicalWriter(); + serialize(value, "$", 0, { writer, stack: new WeakSet(), nodes: 0 }); + return writer.finish(); +} + +export function canonicalSha256(value: unknown): { canonical: string; byteLength: number; digest: string } { + const canonical = canonicalJson(value); + const bytes = Buffer.from(canonical, "utf8"); + return { + canonical, + byteLength: bytes.byteLength, + digest: createHash("sha256").update(bytes).digest("hex"), + }; +}