From 58f859bf22f90d57327342fc13b2b272eee25e3a Mon Sep 17 00:00:00 2001 From: Sebastian Otaegui Date: Mon, 13 Jul 2026 12:38:19 -0300 Subject: [PATCH 1/2] feat(agent-journal): canonicalize evaluation receipts Add strict RFC 8785 JSON canonicalization and UTF-8 SHA-256 receipts for future provenance graphs, rejecting non-I-JSON and exotic mutable input. RED: npx vitest run packages/pi-agent-journal/__tests__/evaluation-receipts.test.ts Failure: the canonical receipt module was absent. --- .../__tests__/evaluation-receipts.test.ts | 93 ++++++++++++++ .../extensions/evaluation-receipts.ts | 114 ++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 packages/pi-agent-journal/__tests__/evaluation-receipts.test.ts create mode 100644 packages/pi-agent-journal/extensions/evaluation-receipts.ts 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..f9cbff8 --- /dev/null +++ b/packages/pi-agent-journal/__tests__/evaluation-receipts.test.ts @@ -0,0 +1,93 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { 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}}'); + }); +}); 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..ee52671 --- /dev/null +++ b/packages/pi-agent-journal/extensions/evaluation-receipts.ts @@ -0,0 +1,114 @@ +import { createHash } from "node:crypto"; + +export class CanonicalJsonError extends Error { + constructor(message: string) { + super(message); + this.name = "CanonicalJsonError"; + } +} + +function assertUnicode(value: string, field: string): void { + 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): string { + assertUnicode(value, field); + return JSON.stringify(value); +} + +function arrayDescriptors(value: unknown[], field: string): PropertyDescriptor[] { + if (Object.getPrototypeOf(value) !== Array.prototype) { + throw new CanonicalJsonError(`${field} must be a plain JSON array`); + } + const ownKeys = Reflect.ownKeys(value); + const expected = [...Array.from({ length: value.length }, (_, index) => String(index)), "length"].sort(); + if ( + ownKeys.some((key) => typeof key !== "string") || + JSON.stringify((ownKeys as string[]).sort()) !== JSON.stringify(expected) + ) { + throw new CanonicalJsonError(`${field} contains non-JSON fields or sparse items`); + } + 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.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, stack: WeakSet): string { + if (value === null) return "null"; + if (typeof value === "string") return serializeString(value, field); + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new CanonicalJsonError(`${field} contains a non-finite number`); + return JSON.stringify(value); + } + if (typeof value !== "object") { + throw new CanonicalJsonError(`${field} contains unsupported ${typeof value} data`); + } + if (stack.has(value)) throw new CanonicalJsonError(`${field} contains a cycle`); + stack.add(value); + try { + if (Array.isArray(value)) { + const descriptors = arrayDescriptors(value, field); + return `[${descriptors.map((descriptor, index) => serialize(descriptor.value, `${field}[${index}]`, stack)).join(",")}]`; + } + const entries = objectDescriptors(value, field); + return `{${entries + .map( + ([key, descriptor]) => + `${serializeString(key, `${field} property name`)}:${serialize(descriptor.value, `${field}.${key}`, stack)}`, + ) + .join(",")}}`; + } finally { + stack.delete(value); + } +} + +export function canonicalJson(value: unknown): string { + return serialize(value, "$", new WeakSet()); +} + +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"), + }; +} From 1dc4582e003e7ca28a85ea364ce089e8e27579c5 Mon Sep 17 00:00:00 2001 From: Sebastian Otaegui Date: Mon, 13 Jul 2026 12:45:13 -0300 Subject: [PATCH 2/2] fix(agent-journal): bound canonical receipt inputs Reject proxies before traps execute and freeze depth, node, collection, string, and output ceilings so receipt generation fails closed under adversarial inputs without unbounded allocation or stack overflow. RED: npx vitest run packages/pi-agent-journal/__tests__/evaluation-receipts.test.ts Failure: object/array proxies were accepted and resource limits were absent. --- .../__tests__/evaluation-receipts.test.ts | 61 ++++++++- .../extensions/evaluation-receipts.ts | 117 ++++++++++++++---- 2 files changed, 154 insertions(+), 24 deletions(-) diff --git a/packages/pi-agent-journal/__tests__/evaluation-receipts.test.ts b/packages/pi-agent-journal/__tests__/evaluation-receipts.test.ts index f9cbff8..07c4d8f 100644 --- a/packages/pi-agent-journal/__tests__/evaluation-receipts.test.ts +++ b/packages/pi-agent-journal/__tests__/evaluation-receipts.test.ts @@ -1,6 +1,11 @@ import { createHash } from "node:crypto"; import { describe, expect, it } from "vitest"; -import { CanonicalJsonError, canonicalJson, canonicalSha256 } from "../extensions/evaluation-receipts.js"; +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", () => { @@ -90,4 +95,58 @@ describe("evaluation receipt canonicalization", () => { 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 index ee52671..c9f9469 100644 --- a/packages/pi-agent-journal/extensions/evaluation-receipts.ts +++ b/packages/pi-agent-journal/extensions/evaluation-receipts.ts @@ -1,4 +1,14 @@ 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) { @@ -7,7 +17,34 @@ export class CanonicalJsonError extends Error { } } +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) { @@ -22,23 +59,26 @@ function assertUnicode(value: string, field: string): void { } } -function serializeString(value: string, field: string): string { +function serializeString(value: string, field: string, writer: CanonicalWriter): void { assertUnicode(value, field); - return JSON.stringify(value); + 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); - const expected = [...Array.from({ length: value.length }, (_, index) => String(index)), "length"].sort(); - if ( - ownKeys.some((key) => typeof key !== "string") || - JSON.stringify((ownKeys as string[]).sort()) !== JSON.stringify(expected) - ) { + 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)); @@ -55,6 +95,9 @@ function objectDescriptors(value: object, field: string): Array<[string, Propert 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`); } @@ -69,38 +112,66 @@ function objectDescriptors(value: object, field: string): Array<[string, Propert return entries.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)); } -function serialize(value: unknown, field: string, stack: WeakSet): string { - if (value === null) return "null"; - if (typeof value === "string") return serializeString(value, field); - if (typeof value === "boolean") return value ? "true" : "false"; +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`); - return JSON.stringify(value); + context.writer.append(JSON.stringify(value)); + return; } if (typeof value !== "object") { throw new CanonicalJsonError(`${field} contains unsupported ${typeof value} data`); } - if (stack.has(value)) throw new CanonicalJsonError(`${field} contains a cycle`); - stack.add(value); + 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); - return `[${descriptors.map((descriptor, index) => serialize(descriptor.value, `${field}[${index}]`, stack)).join(",")}]`; + 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); - return `{${entries - .map( - ([key, descriptor]) => - `${serializeString(key, `${field} property name`)}:${serialize(descriptor.value, `${field}.${key}`, stack)}`, - ) - .join(",")}}`; + 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 { - stack.delete(value); + context.stack.delete(value); } } export function canonicalJson(value: unknown): string { - return serialize(value, "$", new WeakSet()); + 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 } {