Skip to content
Open
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
4 changes: 4 additions & 0 deletions packages/typescript/src/evidence-digest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
5 changes: 3 additions & 2 deletions packages/typescript/src/evidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"; }
Expand Down
27 changes: 27 additions & 0 deletions packages/typescript/test/evidence-boundaries.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
}
});
11 changes: 9 additions & 2 deletions src/agentrust_telemetry/evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down
11 changes: 11 additions & 0 deletions tests/test_evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down