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
152 changes: 152 additions & 0 deletions packages/pi-agent-journal/__tests__/evaluation-receipts.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};
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<Record<string, unknown>> = {
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<string, unknown> = {};
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);
});
});
185 changes: 185 additions & 0 deletions packages/pi-agent-journal/extensions/evaluation-receipts.ts
Original file line number Diff line number Diff line change
@@ -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<object>;
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<object>(), 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"),
};
}