From 3d8953ab3c24eedff2285689d74eac4339221f7c Mon Sep 17 00:00:00 2001 From: Raghuvar Karthik Durga <177662415+drk-ram@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:14:45 -0500 Subject: [PATCH] fix(factory,agt-approval,agt-audit): preserve explicit empty event_id/instance_id for validation and reject pre-epoch timestamps in TS (#115) Signed-off-by: Raghuvar Karthik Durga <177662415+drk-ram@users.noreply.github.com> --- packages/typescript/src/agt-approval.ts | 2 +- packages/typescript/src/agt-audit.ts | 2 +- packages/typescript/src/factory.ts | 2 +- packages/typescript/test/agt-approval.test.ts | 1 + packages/typescript/test/agt-audit.test.ts | 3 +++ src/agentrust_telemetry/adapters/base.py | 4 +-- tests/test_adapters.py | 25 +++++++++++++++++++ 7 files changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/typescript/src/agt-approval.ts b/packages/typescript/src/agt-approval.ts index e3087c4..50bb299 100644 --- a/packages/typescript/src/agt-approval.ts +++ b/packages/typescript/src/agt-approval.ts @@ -47,5 +47,5 @@ function verifyPairs(left: Source, right: Source, pairs: Array<[string, string]> function digest(value: unknown, field: string): Digest { const match = DIGEST.exec(required(value, field)); if (!match) throw new Error(`AGT ${field} must use sha256:`); return {algorithm: "sha256", value: match[1]!}; } function required(value: unknown, field: string): string { if (typeof value !== "string" || !value) throw new Error(`AGT ${field} must be a non-empty string`); return value; } function context(value: {traceId?: string; spanId?: string}): {traceId?: string; spanId?: string} { return {...(value.traceId === undefined ? {} : {traceId: required(value.traceId, "traceId")}), ...(value.spanId === undefined ? {} : {spanId: required(value.spanId, "spanId")})}; } -function timestampNs(value: unknown, field: string): bigint { if (typeof value !== "string") throw new Error(`AGT ${field} must be an RFC 3339 UTC timestamp string`); const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?(?:Z|\+00:00)$/.exec(value); if (!match) throw new Error(`AGT ${field} must be an RFC 3339 UTC timestamp string`); const milliseconds = Date.parse(`${match[1]}Z`); if (match[1]!.startsWith("0000") || !Number.isFinite(milliseconds) || new Date(milliseconds).toISOString().slice(0, 19) !== match[1]) throw new Error(`AGT ${field} is not a valid timestamp`); return BigInt(milliseconds) * 1_000_000n + BigInt((match[2] ?? "").padEnd(9, "0") || "0"); } +function timestampNs(value: unknown, field: string): bigint { if (typeof value !== "string") throw new Error(`AGT ${field} must be an RFC 3339 UTC timestamp string`); const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?(?:Z|\+00:00)$/.exec(value); if (!match) throw new Error(`AGT ${field} must be an RFC 3339 UTC timestamp string`); const milliseconds = Date.parse(`${match[1]}Z`); if (match[1]!.startsWith("0000") || !Number.isFinite(milliseconds) || milliseconds < 0 || new Date(milliseconds).toISOString().slice(0, 19) !== match[1]) throw new Error(`AGT ${field} is not a valid timestamp`); return BigInt(milliseconds) * 1_000_000n + BigInt((match[2] ?? "").padEnd(9, "0") || "0"); } function sourceEventId(kind: string, id: string): string { const ns = Buffer.from(NAMESPACE.replaceAll("-", ""), "hex"); const bytes = createHash("sha1").update(ns).update(`${kind}:${id}`, "utf8").digest().subarray(0, 16); bytes[6] = (bytes[6]! & 0x0f) | 0x50; bytes[8] = (bytes[8]! & 0x3f) | 0x80; const hex = bytes.toString("hex"); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } diff --git a/packages/typescript/src/agt-audit.ts b/packages/typescript/src/agt-audit.ts index 561c222..b21d9ba 100644 --- a/packages/typescript/src/agt-audit.ts +++ b/packages/typescript/src/agt-audit.ts @@ -27,5 +27,5 @@ function actionOutcome(value: unknown): string { const result = new Map BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("AGT audit duration exceeds the safe integer range"); return Number(value); } function safeDuration(value: number, field: string): void { if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${field} must be a non-negative safe integer`); } function required(value: unknown, field: string): string { if (typeof value !== "string" || !value) throw new Error(`AGT audit ${field} must be a non-empty string`); return value; } -function timestampNs(value: unknown, field: string): bigint { if (typeof value !== "string") throw new Error(`AGT audit ${field} must be an RFC 3339 UTC timestamp`); const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?(?:Z|\+00:00)$/.exec(value); if (!match) throw new Error(`AGT audit ${field} must be an RFC 3339 UTC timestamp`); const milliseconds = Date.parse(`${match[1]}Z`); if (match[1]!.startsWith("0000") || !Number.isFinite(milliseconds) || new Date(milliseconds).toISOString().slice(0, 19) !== match[1]) throw new Error(`AGT audit ${field} is invalid`); return BigInt(milliseconds) * 1_000_000n + BigInt((match[2] ?? "").padEnd(9, "0") || "0"); } +function timestampNs(value: unknown, field: string): bigint { if (typeof value !== "string") throw new Error(`AGT audit ${field} must be an RFC 3339 UTC timestamp`); const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?(?:Z|\+00:00)$/.exec(value); if (!match) throw new Error(`AGT audit ${field} must be an RFC 3339 UTC timestamp`); const milliseconds = Date.parse(`${match[1]}Z`); if (match[1]!.startsWith("0000") || !Number.isFinite(milliseconds) || milliseconds < 0 || new Date(milliseconds).toISOString().slice(0, 19) !== match[1]) throw new Error(`AGT audit ${field} is invalid`); return BigInt(milliseconds) * 1_000_000n + BigInt((match[2] ?? "").padEnd(9, "0") || "0"); } function sourceEventId(kind: string, id: string): string { const ns = Buffer.from(NAMESPACE.replaceAll("-", ""), "hex"); const bytes = createHash("sha1").update(ns).update(`${kind}:${id}`, "utf8").digest().subarray(0, 16); bytes[6] = (bytes[6]! & 0x0f) | 0x50; bytes[8] = (bytes[8]! & 0x3f) | 0x80; const hex = bytes.toString("hex"); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } diff --git a/packages/typescript/src/factory.ts b/packages/typescript/src/factory.ts index f744325..924b4ef 100644 --- a/packages/typescript/src/factory.ts +++ b/packages/typescript/src/factory.ts @@ -18,7 +18,7 @@ export class EventFactory { readonly clockNs: () => bigint = () => BigInt(Date.now()) * 1_000_000n, readonly eventIdFactory: () => string = randomUUID, ) { - this.#producer = {name: producer.name, version: producer.version, ...(producer.instanceId ? {instance_id: producer.instanceId} : {})}; + this.#producer = {name: producer.name, version: producer.version, ...(producer.instanceId === undefined ? {} : {instance_id: producer.instanceId})}; } build(eventType: string, fields: EnvelopeFields, payload: Record): NormalizedEvent { diff --git a/packages/typescript/test/agt-approval.test.ts b/packages/typescript/test/agt-approval.test.ts index e917ca0..553f0bb 100644 --- a/packages/typescript/test/agt-approval.test.ts +++ b/packages/typescript/test/agt-approval.test.ts @@ -31,4 +31,5 @@ test("resolution mapping rejects request mismatch, chronology reversal, and malf test("only terminal outcomes map to terminal approval events", () => { for (const [outcome, expected] of [["allow", "approval.approved"], ["deny", "approval.rejected"], ["expired", "approval.expired"]]) assert.equal(agtApprovalResolution(factory, {...resolution, outcome}, request, {runId: "run-1"}).event_type, expected); assert.throws(() => agtApprovalResolution(factory, {...resolution, outcome: "vote"}, request, {runId: "run-1"}), /unsupported/); + assert.throws(() => agtPolicyDecisionRecord(factory, {...policy, decided_at: "1969-12-31T23:59:59Z"}, {runId: "run-1", agentId: "agent-1", actionType: "deploy", resourceType: "environment", policyEngineVersion: "1.0.0", bundleDigest: bundle}), /not a valid timestamp/); }); diff --git a/packages/typescript/test/agt-audit.test.ts b/packages/typescript/test/agt-audit.test.ts index ecf97e6..5b9633b 100644 --- a/packages/typescript/test/agt-audit.test.ts +++ b/packages/typescript/test/agt-audit.test.ts @@ -19,4 +19,7 @@ test("audit action rejects absent, reversed, and unsafe timing", () => { assert.throws(() => agtAuditAction(factory, source({event_type: "tool_invocation", issued_at: null}), {runId: "run-1", actionDigest: digest, actionKind: "tool", operation: "invoke"}), /requires durationNs/); assert.throws(() => agtAuditAction(factory, source({event_type: "tool_invocation", issued_at: "2026-08-18T12:00:01Z"}), {runId: "run-1", actionDigest: digest, actionKind: "tool", operation: "invoke"}), /cannot predate/); assert.throws(() => agtAuditAction(factory, source({event_type: "tool_invocation"}), {runId: "run-1", actionDigest: digest, actionKind: "tool", operation: "invoke", durationNs: Number.MAX_SAFE_INTEGER + 1}), /safe integer/); + assert.throws(() => agtAuditPolicyDecision(factory, source({timestamp: "1969-12-31T23:59:59Z"}), {runId: "run-1", policyEngineVersion: "5.0.0", bundleDigest: bundle, resourceType: "tool"}), /timestamp is invalid/); + const emptyInstFactory = new EventFactory(SchemaValidator.bundled(), {name: "audit-tests", version: "1", instanceId: ""}); + assert.throws(() => emptyInstFactory.build("usage.recorded", {runId: "run-1", agentId: "agent-1"}, {scope: "agent_run", operation: "build", input_tokens: 1})); }); diff --git a/src/agentrust_telemetry/adapters/base.py b/src/agentrust_telemetry/adapters/base.py index 4d406dd..0cffcaf 100644 --- a/src/agentrust_telemetry/adapters/base.py +++ b/src/agentrust_telemetry/adapters/base.py @@ -25,7 +25,7 @@ def __init__( self._producer = { "name": producer_name, "version": producer_version, - **({"instance_id": producer_instance_id} if producer_instance_id else {}), + **({"instance_id": producer_instance_id} if producer_instance_id is not None else {}), } self._clock_ns = clock_ns self._event_id_factory = event_id_factory @@ -56,7 +56,7 @@ def build( timestamp = self._clock_ns() if time_unix_nano is None else time_unix_nano event: dict[str, Any] = { "spec_version": "0.1.0-alpha.6", - "event_id": event_id or str(self._event_id_factory()), + "event_id": str(self._event_id_factory()) if event_id is None else event_id, "event_type": event_type, "time_unix_nano": _unix_nano(timestamp), "run_id": run_id, diff --git a/tests/test_adapters.py b/tests/test_adapters.py index e89791b..1929d5b 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -54,6 +54,31 @@ def test_factory_prevents_envelope_override(self): input_tokens=1, producer={"name": "spoofed", "version": "9"}, ) + with self.assertRaises(Exception): + self.factory.build( + "usage.recorded", + run_id="run-1", + agent_id="agent-1", + event_id="", + scope="agent_run", + operation="build", + input_tokens=1, + ) + empty_inst = EventFactory( + SchemaValidator(ROOT / "spec" / "schema"), + producer_name="adapter-tests", + producer_version="1.0.0", + producer_instance_id="", + ) + with self.assertRaises(Exception): + empty_inst.build( + "usage.recorded", + run_id="run-1", + agent_id="agent-1", + scope="agent_run", + operation="build", + input_tokens=1, + ) def test_factory_matches_cross_language_golden_event(self): parity_factory = EventFactory(