From a784058241af997157257bee65eb1f2fe8bb61d8 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Wed, 19 Aug 2026 10:44:41 -0700 Subject: [PATCH] Add TypeScript TRACE finalization --- CHANGELOG.md | 2 + docs/trace-finalization.md | 10 +++- packages/typescript/README.md | 8 +-- packages/typescript/src/index.ts | 1 + packages/typescript/src/trace-finalizer.ts | 50 +++++++++++++++++++ .../typescript/test/trace-finalizer.test.ts | 49 ++++++++++++++++++ src/agentrust_telemetry/trace_adapter.py | 11 ++-- tests/test_trace_adapter.py | 4 ++ 8 files changed, 122 insertions(+), 13 deletions(-) create mode 100644 packages/typescript/src/trace-finalizer.ts create mode 100644 packages/typescript/test/trace-finalizer.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f97a6fd..2a3ac35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Add TypeScript TRACE finalization through a caller-supplied official codec and + standardize tool-transcript hashing on RFC 8785 JCS across both SDKs. - Add TypeScript metadata-only data-flow primitives plus AGT data-access, audit-policy, and completed-action adapters. - Enforce the documented no-URL metadata identifier boundary in both SDKs. diff --git a/docs/trace-finalization.md b/docs/trace-finalization.md index 68ea04d..de2777d 100644 --- a/docs/trace-finalization.md +++ b/docs/trace-finalization.md @@ -1,6 +1,8 @@ # TRACE finalization -Status: experimental; requires Python 3.11+ and `agentrust-telemetry[trace]`. +Status: experimental. Python requires 3.11+ and `agentrust-telemetry[trace]`. +TypeScript requires a caller-supplied official TRACE codec because no official +AgentTrust TRACE Node package is currently published. `finalize_trace` maps a sealed, explicitly complete evidence snapshot into the official TRACE v0.2 model, signs it with a caller-supplied key, validates it, and @@ -22,3 +24,9 @@ When no action evidence is present, the optional transcript remains absent. The signed record remains subject to TRACE's documented trust-anchor, freshness, revocation, transparency, and software-only limitations. + +The TypeScript codec boundary must provide the v0.2 profile identifier, signing, +structural validation, public-key derivation, and signature verification. The +finalizer invokes all four steps and fails closed. It does not substitute a local +shape check for official TRACE validation. Tool transcript bytes use RFC 8785 JCS +in both SDKs. diff --git a/packages/typescript/README.md b/packages/typescript/README.md index bee85c6..4142d05 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -9,10 +9,10 @@ It never installs an OTel provider, exporter, or global propagator. This reference surface includes a fail-closed evidence accumulator using the same RFC 8785 digest profile as Python, conservative usage/cost rollups, and a -caller-owned bounded-cardinality OTel metric emitter. It does not yet include -the Python SDK's TRACE finalizer. Generic OPA, Cedar, AGT policy, approval, -audit-action, and data-flow adapters are included. TRACE conversion remains -explicit parity work rather than implied compatibility. +caller-owned bounded-cardinality OTel metric emitter. Generic OPA, Cedar, AGT +policy, approval, audit-action, and data-flow adapters are included. TRACE +finalization is available through a caller-supplied official codec because no +official AgentTrust TRACE package currently exists for Node. Nanosecond timestamps are decimal strings on the JSON wire and `bigint` at the TypeScript construction boundary, avoiding IEEE-754 precision loss. diff --git a/packages/typescript/src/index.ts b/packages/typescript/src/index.ts index 6cd2a1a..23c2d08 100644 --- a/packages/typescript/src/index.ts +++ b/packages/typescript/src/index.ts @@ -9,5 +9,6 @@ export * from "./metrics.js"; export * from "./projection.js"; export * from "./propagation.js"; export type * from "./types.js"; +export * from "./trace-finalizer.js"; export * from "./validation.js"; export * from "./usage.js"; diff --git a/packages/typescript/src/trace-finalizer.ts b/packages/typescript/src/trace-finalizer.ts new file mode 100644 index 0000000..34f64f1 --- /dev/null +++ b/packages/typescript/src/trace-finalizer.ts @@ -0,0 +1,50 @@ +import {createHash} from "node:crypto"; +import canonicalize from "canonicalize"; +import type {EvidenceSnapshot} from "./evidence.js"; +import type {NormalizedEvent} from "./types.js"; + +export type TraceOriginKind = "self" | "third-party-control-plane" | "log-import"; +export interface TraceConfiguration {subject: string; modelProvider: string; modelId: string; buildDigest: string; originKind: TraceOriginKind; originProducer: string; appraisalVerifier: string; classificationTaxonomy: string; classificationOrder: readonly string[]; modelVersion?: string; buildSlsaLevel?: number; buildBuilder?: string; buildProvenanceUri?: string; transparency?: string} +export interface TraceCodec = Record> { + readonly profileV02: string; + signRecord(record: Record, signingKey: unknown): Signed; + validateRecord(record: Signed): void; + publicKey(signingKey: unknown): unknown; + verifyRecord(record: Signed, verificationKey: unknown, options: {maxAgeSeconds: number | null}): void; +} +export class TraceFinalizationError extends Error {} + +export function finalizeTrace>(snapshot: EvidenceSnapshot, config: TraceConfiguration, options: {signingKey: unknown; codec: TraceCodec}): Signed { + requireFinalizable(snapshot, config, options); + const events = snapshot.entries.map((entry) => entry.event); + const issuedAt = latestSeconds(events); + const [bundleHash, enforcementMode] = policyBinding(events); + const transcript = toolTranscript(snapshot); + const record: Record = { + eat_profile: options.codec.profileV02, iat: issuedAt, subject: config.subject, + model: {provider: config.modelProvider, model_id: config.modelId, ...(config.modelVersion ? {version: config.modelVersion} : {})}, + runtime: {platform: "software-only", measurement: `sha256:${snapshot.chainDigest}`}, + policy: {bundle_hash: bundleHash, enforcement_mode: enforcementMode}, data_class: highestDataClass(events, config), ...(transcript ? {tool_transcript: transcript} : {}), + origin: {kind: config.originKind, producer: config.originProducer}, + build_provenance: {slsa_level: config.buildSlsaLevel ?? 0, digest: config.buildDigest, ...(config.buildBuilder ? {builder: config.buildBuilder} : {}), ...(config.buildProvenanceUri ? {provenance_uri: config.buildProvenanceUri} : {})}, + appraisal: {status: appraisal(events), verifier: config.appraisalVerifier, timestamp: issuedAt}, ...(config.transparency ? {transparency: config.transparency} : {}), + }; + try { const signed = options.codec.signRecord(record, options.signingKey); options.codec.validateRecord(signed); const verificationKey = options.codec.publicKey(options.signingKey); options.codec.verifyRecord(signed, verificationKey, {maxAgeSeconds: null}); return signed; } + catch (error) { throw new TraceFinalizationError(`official TRACE signing or validation failed: ${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}`, {cause: error}); } +} + +function requireFinalizable(snapshot: EvidenceSnapshot, config: TraceConfiguration, options: {signingKey: unknown; codec: TraceCodec}): void { + if (!snapshot.sealed) throw new TraceFinalizationError("evidence snapshot must be sealed"); + if (snapshot.completeness !== "complete") throw new TraceFinalizationError("TRACE finalization requires completeness='complete'"); + if (!snapshot.entries.length || !snapshot.chainDigest) throw new TraceFinalizationError("TRACE finalization requires non-empty chained evidence"); + if (options.signingKey === undefined || options.signingKey === null) throw new TraceFinalizationError("a caller-supplied signing key is required"); + if (!options.codec || !options.codec.profileV02 || ![options.codec.signRecord, options.codec.validateRecord, options.codec.publicKey, options.codec.verifyRecord].every((value) => typeof value === "function")) throw new TraceFinalizationError("a caller-supplied official TRACE codec is required"); + const required = {subject: config.subject, modelProvider: config.modelProvider, modelId: config.modelId, buildDigest: config.buildDigest, originProducer: config.originProducer, appraisalVerifier: config.appraisalVerifier}; + const missing = Object.entries(required).filter(([, value]) => !value).map(([name]) => name).sort(); if (missing.length) throw new TraceFinalizationError(`trusted TRACE configuration is missing: ${missing.join(", ")}`); + if (new Set(config.classificationOrder).size !== config.classificationOrder.length) throw new TraceFinalizationError("classificationOrder contains duplicates"); +} +function policyBinding(events: readonly NormalizedEvent[]): [string, string] { const decisions = events.filter((event) => event.event_type === "policy.decision"); if (!decisions.length) throw new TraceFinalizationError("no policy decision evidence is present"); const bindings = decisions.map((event) => (event.policy as Record).bundle_digest as Record | undefined); if (bindings.some((value) => !value)) throw new TraceFinalizationError("every policy decision must carry bundle_digest"); const algorithms = new Set(bindings.map((value) => String(value!.algorithm))); const unsupported = [...algorithms].filter((value) => !new Set(["sha256", "sha384"]).has(value)).sort(); if (unsupported.length) throw new TraceFinalizationError(`TRACE does not support observed policy digest algorithms: ${unsupported.join(", ")}`); const bundles = new Set(bindings.map((value) => `${String(value!.algorithm)}:${String(value!.value)}`)); if (bundles.size !== 1) throw new TraceFinalizationError("conflicting policy bundle digests are present"); const modes = new Set(decisions.map((event) => String(event.enforcement_mode))); if (modes.size !== 1) throw new TraceFinalizationError("conflicting policy enforcement modes are present"); return [[...bundles][0]!, new Map([["enforce", "enforce"], ["monitor", "advisory"], ["disabled", "declared"]]).get([...modes][0]!)!]; } +function highestDataClass(events: readonly NormalizedEvent[], config: TraceConfiguration): string { const flows = events.filter((event) => event.event_type === "data_flow.observed"); if (!flows.length) throw new TraceFinalizationError("no classified data-flow evidence is present"); const classifications = flows.map((event) => event.classification as Record); if (classifications.some((value) => value.taxonomy !== config.classificationTaxonomy)) throw new TraceFinalizationError("data-flow taxonomy conflicts with TRACE configuration"); const rank = new Map(config.classificationOrder.map((value, index) => [value, index])); const values = classifications.map((value) => String(value.value)); const unknown = [...new Set(values.filter((value) => !rank.has(value)))].sort(); if (unknown.length) throw new TraceFinalizationError(`unranked data classifications are present: ${unknown.join(", ")}`); return values.reduce((highest, value) => rank.get(value)! > rank.get(highest)! ? value : highest); } +function appraisal(events: readonly NormalizedEvent[]): string { const decisions = new Map(events.filter((event) => event.event_type === "policy.decision").map((event) => [event.event_id, String(event.decision)])); const approvals = events.filter((event) => event.event_type.startsWith("approval.")); const types = new Set(approvals.map((event) => event.event_type)); if ([...decisions.values()].some((value) => value === "deny" || value === "error") || ["approval.rejected", "approval.expired", "approval.execution_failed"].some((value) => types.has(value))) return "contraindicated"; const approved = new Set(approvals.filter((event) => event.event_type === "approval.approved" && typeof event.policy_event_id === "string").map((event) => String(event.policy_event_id))); if ([...decisions].some(([id, value]) => value === "challenge" && !approved.has(id)) || types.has("approval.cancelled")) return "warning"; return decisions.size || approvals.length ? "affirming" : "none"; } +function toolTranscript(snapshot: EvidenceSnapshot): Record | undefined { const actions = snapshot.entries.filter((entry) => entry.event.event_type === "action.executed").map((entry) => ({sequence: entry.sequence, event: entry.event})); if (!actions.length) return undefined; const canonical = canonicalize(actions); if (canonical === undefined) throw new TraceFinalizationError("tool transcript cannot be canonicalized"); return {hash: `sha256:${createHash("sha256").update(canonical, "utf8").digest("hex")}`, call_count: actions.length}; } +function latestSeconds(events: readonly NormalizedEvent[]): number { const latest = events.reduce((value, event) => { const current = BigInt(event.time_unix_nano); return current > value ? current : value; }, 0n) / 1_000_000_000n; if (latest > BigInt(Number.MAX_SAFE_INTEGER)) throw new TraceFinalizationError("TRACE issuance time exceeds the JavaScript safe-integer range"); return Number(latest); } diff --git a/packages/typescript/test/trace-finalizer.test.ts b/packages/typescript/test/trace-finalizer.test.ts new file mode 100644 index 0000000..d8d275b --- /dev/null +++ b/packages/typescript/test/trace-finalizer.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import {readFileSync} from "node:fs"; +import test from "node:test"; +import {fileURLToPath} from "node:url"; +import {EvidenceAccumulator, finalizeTrace, SchemaValidator, TraceFinalizationError} from "../src/index.js"; +import type {NormalizedEvent, TraceCodec, TraceConfiguration} from "../src/index.js"; + +const root = fileURLToPath(new URL("../../../", import.meta.url)); +const fixture = (name: string): NormalizedEvent => JSON.parse(readFileSync(`${root}/conformance/fixtures/valid/${name}`, "utf8")) as NormalizedEvent; +const validator = SchemaValidator.bundled(); +const config: TraceConfiguration = {subject: "spiffe://example.test/agent/workflow", modelProvider: "example", modelId: "example-model", modelVersion: "2026-08", buildDigest: `sha256:${"b".repeat(64)}`, buildSlsaLevel: 1, originKind: "self", originProducer: "example-runtime", appraisalVerifier: "https://example.test/verifier", classificationTaxonomy: "example.enterprise.v1", classificationOrder: ["public", "internal", "confidential", "restricted"]}; +class RecordingCodec implements TraceCodec> { profileV02 = "tag:github.com,2026:agentrust-trace/v0.2"; calls: string[] = []; failVerify = false; signRecord(record: Record, key: unknown) { this.calls.push("sign"); assert.equal(key, "private"); return {...record, signature: "signed"}; } validateRecord(record: Record) { this.calls.push("validate"); assert.equal(record.signature, "signed"); } publicKey(key: unknown) { this.calls.push("publicKey"); assert.equal(key, "private"); return "public"; } verifyRecord(record: Record, key: unknown, options: {maxAgeSeconds: number | null}) { this.calls.push("verify"); assert.equal(record.signature, "signed"); assert.equal(key, "public"); assert.equal(options.maxAgeSeconds, null); if (this.failVerify) throw new Error("bad signature"); } } +function snapshot(extra: NormalizedEvent[] = [], completeness: "complete" | "incomplete" = "complete") { const accumulator = new EvidenceAccumulator("run-governed-sdlc-001", validator); accumulator.append(fixture("policy-decision.json")); accumulator.append(fixture("data-flow.json")); extra.forEach((event) => accumulator.append(event)); return accumulator.seal(completeness); } + +test("finalizer derives claims then signs, validates, and self-verifies", () => { + const codec = new RecordingCodec(); const evidence = snapshot(); const record = finalizeTrace(evidence, config, {signingKey: "private", codec}); + assert.deepEqual(codec.calls, ["sign", "validate", "publicKey", "verify"]); + assert.deepEqual(record.runtime, {platform: "software-only", measurement: `sha256:${evidence.chainDigest}`}); + assert.deepEqual(record.policy, {bundle_hash: `sha256:${"a".repeat(64)}`, enforcement_mode: "enforce"}); + assert.equal(record.data_class, "confidential"); assert.deepEqual(record.appraisal, {status: "contraindicated", verifier: "https://example.test/verifier", timestamp: 1787079600}); assert.equal(record.tool_transcript, undefined); +}); +test("tool transcript uses the shared RFC 8785 sequence/event digest", () => { + const record = finalizeTrace(snapshot([fixture("action.json")]), config, {signingKey: "private", codec: new RecordingCodec()}); + assert.deepEqual(record.tool_transcript, {hash: "sha256:c697a0cb7991d61a4b4a7454de3be3a24046cbffb62f5fc14784ce52ccafe497", call_count: 1}); + const changed = fixture("action.json"); changed.outcome = "error"; changed.error_type = "remote_error"; + assert.notEqual((finalizeTrace(snapshot([changed]), config, {signingKey: "private", codec: new RecordingCodec()}).tool_transcript as Record).hash, (record.tool_transcript as Record).hash); +}); +test("linked approval resolves a challenge while an unrelated approval does not", () => { + const policy = fixture("policy-decision.json"); policy.decision = "challenge"; + const approval = fixture("approval.json"); approval.event_type = "approval.approved"; approval.policy_event_id = policy.event_id; + const make = (item: NormalizedEvent) => { const accumulator = new EvidenceAccumulator("run-governed-sdlc-001", validator); accumulator.append(policy); accumulator.append(item); accumulator.append(fixture("data-flow.json")); return finalizeTrace(accumulator.seal("complete"), config, {signingKey: "private", codec: new RecordingCodec()}); }; + assert.equal((make(approval).appraisal as Record).status, "affirming"); + approval.policy_event_id = "018f0f7d-7a13-7cc2-8000-000000000099"; approval.event_id = "018f0f7d-7a13-7cc2-8000-000000000098"; + assert.equal((make(approval).appraisal as Record).status, "warning"); +}); +test("finalization refuses incomplete evidence, conflicts, unranked data, and missing trust inputs", () => { + const open = new EvidenceAccumulator("run-governed-sdlc-001", validator); open.append(fixture("policy-decision.json")); + assert.throws(() => finalizeTrace(open.snapshot(), config, {signingKey: "private", codec: new RecordingCodec()}), /sealed/); + assert.throws(() => finalizeTrace(snapshot([], "incomplete"), config, {signingKey: "private", codec: new RecordingCodec()}), /completeness/); + assert.throws(() => finalizeTrace(snapshot(), config, {signingKey: null, codec: new RecordingCodec()}), /signing key/); + assert.throws(() => finalizeTrace(snapshot(), {...config, classificationOrder: ["public", "internal"]}, {signingKey: "private", codec: new RecordingCodec()}), /unranked/); + const conflicting = fixture("policy-decision.json"); conflicting.event_id = "018f0f7d-7a13-7cc2-8000-000000000099"; ((conflicting.policy as Record).bundle_digest as Record).value = "f".repeat(64); + assert.throws(() => finalizeTrace(snapshot([conflicting]), config, {signingKey: "private", codec: new RecordingCodec()}), /conflicting policy bundle/); +}); +test("codec validation or self-verification failure is fail-closed", () => { + const codec = new RecordingCodec(); codec.failVerify = true; + assert.throws(() => finalizeTrace(snapshot(), config, {signingKey: "private", codec}), TraceFinalizationError); + assert.deepEqual(codec.calls, ["sign", "validate", "publicKey", "verify"]); +}); diff --git a/src/agentrust_telemetry/trace_adapter.py b/src/agentrust_telemetry/trace_adapter.py index 9e1a0d9..9f45c4c 100644 --- a/src/agentrust_telemetry/trace_adapter.py +++ b/src/agentrust_telemetry/trace_adapter.py @@ -3,10 +3,11 @@ from __future__ import annotations import hashlib -import json from dataclasses import dataclass from typing import Any, Literal +import rfc8785 + from .errors import TraceFinalizationError from .evidence import EvidenceSnapshot @@ -211,13 +212,7 @@ def _tool_transcript(snapshot: EvidenceSnapshot) -> dict[str, Any] | None: ] if not actions: return None - canonical = json.dumps( - actions, - sort_keys=True, - separators=(",", ":"), - ensure_ascii=False, - allow_nan=False, - ).encode("utf-8") + canonical = rfc8785.dumps(actions) return { "hash": "sha256:" + hashlib.sha256(canonical).hexdigest(), "call_count": len(actions), diff --git a/tests/test_trace_adapter.py b/tests/test_trace_adapter.py index 9eadc75..0f7c876 100644 --- a/tests/test_trace_adapter.py +++ b/tests/test_trace_adapter.py @@ -72,6 +72,10 @@ def test_action_events_create_truthful_tool_transcript(self): record = finalize_trace(snapshot, self.config, signing_key=self.key) self.assertEqual(record["tool_transcript"]["call_count"], 1) self.assertRegex(record["tool_transcript"]["hash"], r"^sha256:[0-9a-f]{64}$") + self.assertEqual( + record["tool_transcript"]["hash"], + "sha256:c697a0cb7991d61a4b4a7454de3be3a24046cbffb62f5fc14784ce52ccafe497", + ) changed = fixture("action.json") changed["outcome"] = "error"