From d17f1511e04ff187f85d2a3cfbfba0ff8dab25a8 Mon Sep 17 00:00:00 2001 From: Raghuvar Karthik Durga <177662415+drk-ram@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:53:25 -0500 Subject: [PATCH] fix(evidence): reject boolean/non-integer max_events and enforce 64-char lowercase hex previousDigest in Python and TypeScript (#105) - Reject boolean or non-integer max_events in Python EvidenceAccumulator.__init__ and require string runId and safe-integer maxEvents in TypeScript EvidenceAccumulator. - Require previous_digest / previousDigest (when present) to match ^[0-9a-f]{64}$ in Python _entry_digest and TypeScript entryDigest. Closes #105 Signed-off-by: Raghuvar Karthik Durga <177662415+drk-ram@users.noreply.github.com> --- packages/typescript/src/evidence-digest.ts | 4 +++ packages/typescript/src/evidence.ts | 5 ++-- .../test/evidence-boundaries.test.ts | 27 +++++++++++++++++++ src/agentrust_telemetry/evidence.py | 11 ++++++-- tests/test_evidence.py | 11 ++++++++ 5 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 packages/typescript/test/evidence-boundaries.test.ts diff --git a/packages/typescript/src/evidence-digest.ts b/packages/typescript/src/evidence-digest.ts index be16338..c58b299 100644 --- a/packages/typescript/src/evidence-digest.ts +++ b/packages/typescript/src/evidence-digest.ts @@ -5,12 +5,16 @@ import type {NormalizedEvent} from "./types.js"; // Internal: shared by the accumulator that builds the chain and the TRACE // finalizer that re-checks it. Not re-exported from index.ts. const GENESIS_DIGEST = Buffer.alloc(32); +const HEX_32 = /^[0-9a-f]{64}$/; export function entryDigest(sequence: number, previousDigest: string | undefined, event: NormalizedEvent): string { const canonical = canonicalize(event); if (canonical === undefined) throw new TypeError("event has no canonical JSON representation"); const sequenceBytes = Buffer.alloc(8); sequenceBytes.writeBigUInt64BE(BigInt(sequence)); + if (previousDigest !== undefined && (typeof previousDigest !== "string" || !HEX_32.test(previousDigest))) { + throw new TypeError("previous digest must be 64 lowercase hex characters"); + } const previous = previousDigest ? Buffer.from(previousDigest, "hex") : GENESIS_DIGEST; if (previous.length !== 32) throw new TypeError("previous digest must be 32 bytes"); return createHash("sha256").update(previous).update(sequenceBytes).update(canonical, "utf8").digest("hex"); diff --git a/packages/typescript/src/evidence.ts b/packages/typescript/src/evidence.ts index bbb8161..2537fae 100644 --- a/packages/typescript/src/evidence.ts +++ b/packages/typescript/src/evidence.ts @@ -39,8 +39,9 @@ export class EvidenceAccumulator { readonly validator: SchemaValidator, readonly options: {durableAppend?: DurableAppend; maxEvents?: number} = {}, ) { - if (!runId) throw new EvidenceError("runId must be a non-empty string"); - if ((options.maxEvents ?? 10_000) < 1) throw new EvidenceError("maxEvents must be at least 1"); + if (typeof runId !== "string" || !runId) throw new EvidenceError("runId must be a non-empty string"); + const maxEvents = options.maxEvents ?? 10_000; + if (!Number.isSafeInteger(maxEvents) || maxEvents < 1) throw new EvidenceError("maxEvents must be at least 1"); } get mode(): "memory" | "callback" { return this.options.durableAppend ? "callback" : "memory"; } diff --git a/packages/typescript/test/evidence-boundaries.test.ts b/packages/typescript/test/evidence-boundaries.test.ts new file mode 100644 index 0000000..93b8338 --- /dev/null +++ b/packages/typescript/test/evidence-boundaries.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import {readFileSync} from "node:fs"; +import test from "node:test"; +import {EvidenceAccumulator, SchemaValidator} from "../src/index.js"; +import {entryDigest} from "../src/evidence-digest.js"; +import type {NormalizedEvent} from "../src/index.js"; + +const validator = SchemaValidator.bundled(); +const event = JSON.parse(readFileSync(new URL("../../../conformance/fixtures/valid/policy-decision.json", import.meta.url), "utf8")) as NormalizedEvent; + +test("evidence capacity accepts positive safe integers and rejects runtime type coercion", () => { + for (const maxEvents of [1, 10_000, Number.MAX_SAFE_INTEGER]) { + assert.doesNotThrow(() => new EvidenceAccumulator(event.run_id, validator, {maxEvents})); + } + for (const value of [true, false, "10", 0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + assert.throws(() => new EvidenceAccumulator(event.run_id, validator, {maxEvents: value as number}), /maxEvents/); + } +}); + +test("previous digest requires the exact lowercase representation", () => { + const valid = "ab".repeat(32); + assert.match(entryDigest(1, valid, event), /^[0-9a-f]{64}$/); + assert.match(entryDigest(0, undefined, event), /^[0-9a-f]{64}$/); + for (const value of ["", valid.toUpperCase(), valid + "ZZ", " " + valid, valid + "\n", "ab".repeat(31), null, 12]) { + assert.throws(() => entryDigest(1, value as string, event), /previous digest/); + } +}); diff --git a/src/agentrust_telemetry/evidence.py b/src/agentrust_telemetry/evidence.py index 01350da..74286c9 100644 --- a/src/agentrust_telemetry/evidence.py +++ b/src/agentrust_telemetry/evidence.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +import re from copy import deepcopy from dataclasses import dataclass from threading import Lock, get_ident @@ -17,6 +18,7 @@ Completeness = Literal["complete", "incomplete", "unknown"] CANONICALIZATION_PROFILE = "rfc8785-jcs-v1" _GENESIS_DIGEST = bytes(32) +_HEX_32_RE = re.compile(r"[0-9a-f]{64}") @dataclass(frozen=True) @@ -68,7 +70,7 @@ def __init__( ) -> None: if not isinstance(run_id, str) or not run_id: raise EvidenceError("run_id must be a non-empty string") - if max_events < 1: + if isinstance(max_events, bool) or not isinstance(max_events, int) or max_events < 1: raise EvidenceError("max_events must be at least 1") self._run_id = run_id self._validator = validator @@ -189,7 +191,12 @@ def _snapshot(self) -> EvidenceSnapshot: def _entry_digest(sequence: int, previous_digest: str | None, event: dict[str, Any]) -> str: canonical = rfc8785.dumps(event) - previous = bytes.fromhex(previous_digest) if previous_digest else _GENESIS_DIGEST + if previous_digest is None: + previous = _GENESIS_DIGEST + else: + if not isinstance(previous_digest, str) or not _HEX_32_RE.fullmatch(previous_digest): + raise ValueError("previous_digest must be 64 lowercase hex characters") + previous = bytes.fromhex(previous_digest) material = previous + sequence.to_bytes(8, "big") + canonical return hashlib.sha256(material).hexdigest() diff --git a/tests/test_evidence.py b/tests/test_evidence.py index 27f30be..2b5de2e 100644 --- a/tests/test_evidence.py +++ b/tests/test_evidence.py @@ -200,6 +200,17 @@ def durable_append(entry): accepted = clean.append(fixture("policy-decision.json")) self.assertEqual(accepted.sequence, 0) + def test_rejects_boolean_or_float_max_events_and_malformed_previous_digest(self): + from agentrust_telemetry.evidence import _entry_digest + + for bad_max in (True, False, 0, -1, 1.5, "10"): + with self.assertRaises(EvidenceError): + EvidenceAccumulator("run-1", self.validator, max_events=bad_max) # type: ignore[arg-type] + + for bad_prev in ("A" * 64, "a" * 64 + "ZZ", " " + "a" * 64): + with self.assertRaises(ValueError): + _entry_digest(1, bad_prev, fixture("policy-decision.json")) + def test_reentrant_seal_call_is_refused_not_deadlocked(self): accumulator = None