diff --git a/packages/typescript/src/adapters.ts b/packages/typescript/src/adapters.ts index 2937b3e..5ec4630 100644 --- a/packages/typescript/src/adapters.ts +++ b/packages/typescript/src/adapters.ts @@ -72,6 +72,6 @@ function record(value: unknown, name: string): Record { if (typ function requiredString(value: unknown, name: string): string { if (typeof value !== "string" || !value) throw new Error(`${name} must be a non-empty string`); return value; } function identifiers(values: Iterable, name: string): string[] { if (typeof values === "string") throw new Error(`Cedar ${name} must be an iterable of strings, not a string`); const result = [...new Set(values)].sort(); if (result.some((value) => typeof value !== "string" || !value)) throw new Error(`Cedar ${name} must contain non-empty strings`); return result; } function optionalEnvelope(source: Record): Partial { return {...(source.trace_id === undefined ? {} : {traceId: requiredString(source.trace_id, "trace_id")}), ...(source.span_id === undefined ? {} : {spanId: requiredString(source.span_id, "span_id")})}; } -function utcTimestampNs(value: unknown, name: string, requireZ: boolean): bigint { if (typeof value !== "string") throw new Error(`${name} must be an RFC 3339 UTC string`); const suffix = requireZ ? "Z" : "(?:Z|\\+00:00)"; const match = new RegExp(`^(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2})(?:\\.(\\d{1,9}))?${suffix}$`).exec(value); if (!match) throw new Error(`${name} must use RFC 3339 UTC form`); 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(`${name} is not a valid timestamp`); return BigInt(milliseconds) * 1_000_000n + BigInt((match[2] ?? "").padEnd(9, "0") || "0"); } +function utcTimestampNs(value: unknown, name: string, requireZ: boolean): bigint { if (typeof value !== "string") throw new Error(`${name} must be an RFC 3339 UTC string`); const suffix = requireZ ? "Z" : "(?:Z|\\+00:00)"; const match = new RegExp(`^(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2})(?:\\.(\\d{1,9}))?${suffix}$`).exec(value); if (!match) throw new Error(`${name} must use RFC 3339 UTC form`); 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(`${name} is not a valid timestamp`); return BigInt(milliseconds) * 1_000_000n + BigInt((match[2] ?? "").padEnd(9, "0") || "0"); } function normalizeUuid(value: string): string { const hex = value.replaceAll("-", "").toLowerCase(); if (!/^[0-9a-f]{32}$/.test(hex)) throw new Error("AGT event_id must be a UUID"); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } function uuidV5(namespace: string, name: string): string { const ns = Buffer.from(namespace.replaceAll("-", ""), "hex"); const bytes = createHash("sha1").update(ns).update(name, "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/test/adapters.test.ts b/packages/typescript/test/adapters.test.ts index 85bfc59..aa8415c 100644 --- a/packages/typescript/test/adapters.test.ts +++ b/packages/typescript/test/adapters.test.ts @@ -48,4 +48,6 @@ test("AGT sink reports projection failure and identifiers cannot carry prose", ( assert.equal(sink.emit([agtSource()]), 1); const source = agtSource(); source.attributes = {resource_type: "tool", reason_codes: ["customer secret denied"]}; assert.throws(() => mapAgt(source), /unique identifiers/); + assert.throws(() => mapAgt({...agtSource(), occurred_at: "1969-12-31T23:59:59Z"}), /not a valid timestamp/); + assert.throws(() => mapAgt({...agtSource(), latency_ms: 9_007_199_255}), /safe nanosecond range/); }); diff --git a/src/agentrust_telemetry/adapters/agt.py b/src/agentrust_telemetry/adapters/agt.py index 48e2c8e..6b4005a 100644 --- a/src/agentrust_telemetry/adapters/agt.py +++ b/src/agentrust_telemetry/adapters/agt.py @@ -5,7 +5,7 @@ import math import re import uuid -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from enum import Enum from typing import Any, Callable, Iterable, Protocol, Sequence @@ -118,6 +118,9 @@ def agt_policy_decision( or not math.isfinite(latency_ms * 1_000_000) ): raise ValueError("AGT latency_ms must be a finite non-negative number") + duration_ns = round(latency_ms * 1_000_000) + if duration_ns > 9_007_199_254_740_991: + raise ValueError("AGT latency_ms exceeds the safe nanosecond range") policy: dict[str, Any] = { "engine": "agt", "engine_version": _required_string(policy_engine_version, "policy_engine_version"), @@ -139,7 +142,7 @@ def agt_policy_decision( action_type=action_type, resource_type=resolved_resource_type, enforcement_mode=enforcement_mode, - evaluation_duration_ns=round(latency_ms * 1_000_000), + evaluation_duration_ns=duration_ns, reason_codes=reason_codes, ) @@ -204,5 +207,9 @@ def _timestamp_ns(value: Any) -> int: if match is None: raise ValueError("AGT occurred_at must be an RFC 3339 UTC timestamp") base = datetime.fromisoformat(match.group("date")).replace(tzinfo=timezone.utc) + epoch = datetime(1970, 1, 1, tzinfo=timezone.utc) + if base < epoch: + raise ValueError("AGT occurred_at must not predate the Unix epoch") + seconds = (base - epoch) // timedelta(seconds=1) fraction = (match.group("fraction") or "").ljust(9, "0") - return int(base.timestamp()) * 1_000_000_000 + int(fraction or "0") + return seconds * 1_000_000_000 + int(fraction or "0") diff --git a/src/agentrust_telemetry/adapters/cedar.py b/src/agentrust_telemetry/adapters/cedar.py index 9134215..693b732 100644 --- a/src/agentrust_telemetry/adapters/cedar.py +++ b/src/agentrust_telemetry/adapters/cedar.py @@ -35,6 +35,11 @@ def cedar_policy_decision( reason_codes = [*(f"cedar.policy:{value}" for value in policy_ids), *(f"cedar.error:{value}" for value in errors)] if len(reason_codes) > 32: raise ValueError("Cedar reasons and errors exceed the 32-code contract limit") + if ( + type(evaluation_duration_ns) is not int + or not 0 <= evaluation_duration_ns <= 9_007_199_254_740_991 + ): + raise ValueError("Cedar evaluation_duration_ns must be a non-negative safe integer") policy: dict[str, Any] = { "engine": "cedar", "engine_version": cedar_version, diff --git a/src/agentrust_telemetry/adapters/opa.py b/src/agentrust_telemetry/adapters/opa.py index a4a3194..65a5b10 100644 --- a/src/agentrust_telemetry/adapters/opa.py +++ b/src/agentrust_telemetry/adapters/opa.py @@ -4,7 +4,7 @@ import re import uuid -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Any, Callable from .base import EventFactory @@ -53,7 +53,12 @@ def opa_decision_log( if not isinstance(metrics, dict): raise ValueError("OPA metrics must be an object") duration = metrics.get("timer_rego_query_eval_ns", 0) - if not isinstance(duration, int) or isinstance(duration, bool) or duration < 0: + if ( + not isinstance(duration, int) + or isinstance(duration, bool) + or duration < 0 + or duration > 9_007_199_254_740_991 + ): raise ValueError("OPA timer_rego_query_eval_ns must be a non-negative integer") path = decision_log.get("path") policy: dict[str, Any] = { @@ -104,5 +109,9 @@ def _timestamp_ns(value: Any) -> int: if match is None: raise ValueError("OPA timestamp must use RFC 3339 UTC form ending in Z") base = datetime.fromisoformat(match.group("date")).replace(tzinfo=timezone.utc) + epoch = datetime(1970, 1, 1, tzinfo=timezone.utc) + if base < epoch: + raise ValueError("OPA timestamp must not predate the Unix epoch") + seconds = (base - epoch) // timedelta(seconds=1) fraction = (match.group("fraction") or "").ljust(9, "0") - return int(base.timestamp()) * 1_000_000_000 + int(fraction or "0") + return seconds * 1_000_000_000 + int(fraction or "0") diff --git a/tests/test_adapters.py b/tests/test_adapters.py index e89791b..6517b42 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -330,6 +330,74 @@ def test_cedar_rejects_string_as_identifier_collection(self): determining_policy_ids="deny-secret", ) + def test_policy_adapters_enforce_safe_integer_durations_and_epoch_timestamps(self): + from agentrust_telemetry import agt_policy_decision + + for bad_dur in (True, False, -1, 1.5, 2**53): + with self.subTest(bad_dur=bad_dur): + with self.assertRaises(ValueError): + cedar_policy_decision( + self.factory, + run_id="run-1", + agent_id="agent-1", + decision="Allow", + cedar_version="4.11.2", + bundle_digest=DIGEST, + action_type="Document::read", + resource_type="Document", + evaluation_duration_ns=bad_dur, + ) + with self.assertRaises(ValueError): + self.opa_event( + { + "decision_id": "d-1", + "result": True, + "metrics": {"timer_rego_query_eval_ns": bad_dur}, + } + ) + with self.assertRaises(ValueError): + self.opa_event( + { + "decision_id": "d-1", + "result": True, + "timestamp": "1969-12-31T23:59:59Z", + } + ) + for bad_latency in (9_007_199_255.0,): + with self.assertRaises(ValueError): + agt_policy_decision( + self.factory, + { + "event_id": "018f0f7d7a137cc28000000000000042", + "occurred_at": "2026-08-18T12:34:56Z", + "kind": "policy_check", + "agent_id": "agent-1", + "action": "tool.invoke", + "decision": "allow", + "latency_ms": bad_latency, + "attributes": {"resource_type": "tool"}, + }, + run_id="run-1", + policy_engine_version="1.2.3", + bundle_digest=DIGEST, + ) + with self.assertRaises(ValueError): + agt_policy_decision( + self.factory, + { + "event_id": "018f0f7d7a137cc28000000000000042", + "occurred_at": "1969-12-31T23:59:59Z", + "kind": "policy_check", + "agent_id": "agent-1", + "action": "tool.invoke", + "decision": "allow", + "attributes": {"resource_type": "tool"}, + }, + run_id="run-1", + policy_engine_version="1.2.3", + bundle_digest=DIGEST, + ) + if __name__ == "__main__": unittest.main()