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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion docs/trace-finalization.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
8 changes: 4 additions & 4 deletions packages/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions packages/typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
50 changes: 50 additions & 0 deletions packages/typescript/src/trace-finalizer.ts
Original file line number Diff line number Diff line change
@@ -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<Signed extends Record<string, unknown> = Record<string, unknown>> {
readonly profileV02: string;
signRecord(record: Record<string, unknown>, 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<Signed extends Record<string, unknown>>(snapshot: EvidenceSnapshot, config: TraceConfiguration, options: {signingKey: unknown; codec: TraceCodec<Signed>}): 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<string, unknown> = {
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<string, unknown>).bundle_digest as Record<string, unknown> | 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<string, unknown>); 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<string, unknown> | 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); }
49 changes: 49 additions & 0 deletions packages/typescript/test/trace-finalizer.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> { profileV02 = "tag:github.com,2026:agentrust-trace/v0.2"; calls: string[] = []; failVerify = false; signRecord(record: Record<string, unknown>, key: unknown) { this.calls.push("sign"); assert.equal(key, "private"); return {...record, signature: "signed"}; } validateRecord(record: Record<string, unknown>) { 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<string, unknown>, 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<string, unknown>).hash, (record.tool_transcript as Record<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).bundle_digest as Record<string, unknown>).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"]);
});
11 changes: 3 additions & 8 deletions src/agentrust_telemetry/trace_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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),
Expand Down
4 changes: 4 additions & 0 deletions tests/test_trace_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading