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 OPA, Cedar, and generic AGT policy-decision adapters plus an
AGT-compatible fail-closed batch sink.
- Add TypeScript usage/cost construction, coverage-labelled rollups, and
bounded-cardinality OpenTelemetry metric projection.
- Add a TypeScript evidence accumulator and adopt RFC 8785 JCS for reproducible
Expand Down
6 changes: 6 additions & 0 deletions docs/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ IDs and caller-normalized diagnostic codes. Cedar evaluation errors are recorded
without rewriting the final decision, matching Cedar's skip-on-error semantics.
Free-form error messages are rejected to prevent accidental content leakage.

OPA, Cedar, the generic AGT policy mapper, and the fail-closed AGT batch sink are
available in both Python and TypeScript. TypeScript accepts plain source records
and has no runtime dependency on OPA, Cedar, or AGT packages. Action-bound AGT
approval, audit-action, and data-label adapters are currently Python-only and
remain explicit TypeScript parity work.

The optional AGT bridge implements the batch sink shape used by Agent OS without
making AGT a core dependency. The generic constructor accepts a runtime's result
sentinels and is the durable integration boundary. `from_agent_os` is a legacy
Expand Down
3 changes: 2 additions & 1 deletion packages/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ 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 source adapters or TRACE finalizer. Those
the Python SDK's action-bound AGT approval, audit-action, and data-flow adapters,
or TRACE finalizer. Generic OPA, Cedar, and AGT policy adapters are included. Those
remain explicit parity work rather than implied compatibility.

Nanosecond timestamps are decimal strings on the JSON wire and `bigint` at the
Expand Down
77 changes: 77 additions & 0 deletions packages/typescript/src/adapters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {createHash} from "node:crypto";
import {EventFactory, type EnvelopeFields} from "./factory.js";
import type {NormalizedEvent} from "./types.js";

type Digest = {algorithm: string; value: string};
type Decision = "allow" | "deny" | "challenge" | "not_applicable" | "error";
const IDENTIFIER = /^[A-Za-z0-9_.:-]{1,128}$/;
const OPA_NAMESPACE = "881f86d8-7573-4d35-98cb-c00b934cc04f";

export function opaDecisionLog(factory: EventFactory, source: Record<string, unknown>, options: {runId: string; agentId: string; actionType: string; resourceType: string; bundleDigest: Digest; opaVersion?: string; enforcementMode?: string; resultMapper?: (value: unknown) => Decision}): NormalizedEvent {
const sourceId = requiredString(source.decision_id, "OPA decision_id");
const decision = (options.resultMapper ?? booleanDecision)(source.result);
if (!["allow", "deny", "challenge", "not_applicable", "error"].includes(decision)) throw new Error(`OPA result mapper returned unsupported decision: ${decision}`);
const labels = record(source.labels ?? {}, "OPA labels");
const version = requiredString(options.opaVersion ?? labels.version, "OPA version");
const metrics = record(source.metrics ?? {}, "OPA metrics");
const duration = metrics.timer_rego_query_eval_ns ?? 0;
if (!Number.isSafeInteger(duration) || (duration as number) < 0) throw new Error("OPA timer_rego_query_eval_ns must be a non-negative safe integer");
const ids = source.ids ?? [];
if (!Array.isArray(ids) || ids.some((value) => typeof value !== "string" || !value)) throw new Error("OPA ids must be an array of non-empty strings");
if (ids.length > 32) throw new Error("OPA ids exceed the 32-code contract limit");
const path = source.path;
return factory.build("policy.decision", {runId: options.runId, agentId: options.agentId, eventId: uuidV5(OPA_NAMESPACE, sourceId), ...(source.timestamp === undefined ? {} : {timeUnixNano: utcTimestampNs(source.timestamp, "OPA timestamp", true)}), ...optionalEnvelope(source)}, {
decision, policy: {engine: "opa", engine_version: version, bundle_digest: options.bundleDigest, ...(typeof path === "string" && path ? {policy_id: path.replace(/^\/+/, "")} : {})},
action_type: options.actionType, resource_type: options.resourceType, enforcement_mode: options.enforcementMode ?? "enforce", evaluation_duration_ns: duration,
reason_codes: ids.map((value) => `opa.rule:${value}`),
});
}

export function cedarPolicyDecision(factory: EventFactory, options: {runId: string; agentId: string; decision: string; cedarVersion: string; bundleDigest: Digest; actionType: string; resourceType: string; evaluationDurationNs: number; determiningPolicyIds?: Iterable<string>; errorCodes?: Iterable<string>; enforcementMode?: string; inputDigest?: Digest; envelope?: Omit<EnvelopeFields, "runId" | "agentId">}): NormalizedEvent {
const decision = options.decision.toLowerCase();
if (decision !== "allow" && decision !== "deny") throw new Error("Cedar decision must be Allow or Deny");
const policies = identifiers(options.determiningPolicyIds ?? [], "determiningPolicyIds");
const errors = identifiers(options.errorCodes ?? [], "errorCodes");
if (errors.some((value) => !IDENTIFIER.test(value))) throw new Error("Cedar errorCodes must be identifiers, not error messages");
const reasons = [...policies.map((value) => `cedar.policy:${value}`), ...errors.map((value) => `cedar.error:${value}`)];
if (reasons.length > 32) throw new Error("Cedar reasons and errors exceed the 32-code contract limit");
if (!Number.isSafeInteger(options.evaluationDurationNs) || options.evaluationDurationNs < 0) throw new Error("Cedar evaluationDurationNs must be a non-negative safe integer");
return factory.build("policy.decision", {runId: options.runId, agentId: options.agentId, ...options.envelope}, {
decision, policy: {engine: "cedar", engine_version: options.cedarVersion, bundle_digest: options.bundleDigest, ...(policies.length === 1 ? {policy_id: policies[0]} : {}), ...(options.inputDigest ? {input_digest: options.inputDigest} : {})},
action_type: options.actionType, resource_type: options.resourceType, enforcement_mode: options.enforcementMode ?? "enforce", evaluation_duration_ns: options.evaluationDurationNs, reason_codes: reasons,
});
}

export function agtPolicyDecision(factory: EventFactory, source: Record<string, unknown>, options: {runId: string; policyEngineVersion: string; bundleDigest: Digest; resourceType?: string; enforcementMode?: string}): NormalizedEvent {
const kind = source.kind;
if (kind !== "policy_check" && kind !== "policy_violation") throw new Error(`AGT event kind is not a policy decision: ${String(kind)}`);
const attributes = record(source.attributes ?? {}, "AGT attributes");
const reasonCodes = attributes.reason_codes ?? [];
if (!Array.isArray(reasonCodes) || reasonCodes.length > 32 || reasonCodes.some((value) => typeof value !== "string" || !IDENTIFIER.test(value)) || new Set(reasonCodes).size !== reasonCodes.length) throw new Error("AGT reason_codes must contain at most 32 unique identifiers");
const latency = source.latency_ms ?? 0;
if (typeof latency !== "number" || !Number.isFinite(latency) || latency < 0) throw new Error("AGT latency_ms must be a finite non-negative number");
const duration = Math.round(latency * 1_000_000);
if (!Number.isSafeInteger(duration)) throw new Error("AGT latency_ms exceeds the safe nanosecond range");
const policyName = source.policy_name;
return factory.build("policy.decision", {runId: options.runId, agentId: requiredString(source.agent_id, "AGT agent_id"), eventId: normalizeUuid(requiredString(source.event_id, "AGT event_id")), timeUnixNano: utcTimestampNs(source.occurred_at, "AGT occurred_at", false), ...optionalEnvelope(source)}, {
decision: agtDecision(source.decision), policy: {engine: "agt", engine_version: requiredString(options.policyEngineVersion, "AGT policyEngineVersion"), bundle_digest: options.bundleDigest, ...(policyName === undefined ? {} : {policy_id: requiredString(policyName, "AGT policy_name")})},
action_type: requiredString(source.action, "AGT action"), resource_type: requiredString(options.resourceType ?? attributes.resource_type, "AGT resource_type"), enforcement_mode: options.enforcementMode ?? "enforce", evaluation_duration_ns: duration, reason_codes: reasonCodes,
});
}

export class AgtGovernanceEventSink<Success, Failure> {
constructor(readonly client: {emit(event: NormalizedEvent): {accepted: boolean; projectionErrors?: readonly unknown[]}}, readonly mapper: (source: unknown) => Iterable<NormalizedEvent>, readonly results: {success: Success; failure: Failure}) {}
emit(sources: readonly unknown[]): Success | Failure { try { const events = sources.flatMap((source) => [...this.mapper(source)]); for (const event of events) { const result = this.client.emit(event); if (!result.accepted || (result.projectionErrors?.length ?? 0) > 0) return this.results.failure; } return this.results.success; } catch { return this.results.failure; } }
shutdown(_timeoutMs = 5_000): true { return true; }
forceFlush(_timeoutMs = 30_000): true { return true; }
}

function booleanDecision(value: unknown): Decision { if (value === true) return "allow"; if (value === false) return "deny"; if (value === null || value === undefined) return "not_applicable"; throw new Error("OPA non-boolean result requires an explicit resultMapper"); }
function agtDecision(value: unknown): Decision { const mapped = new Map<unknown, Decision>([["allow", "allow"], ["allowed", "allow"], ["deny", "deny"], ["denied", "deny"], ["block", "deny"], ["blocked", "deny"], ["require_approval", "challenge"], ["requires_approval", "challenge"], ["review", "challenge"]]).get(value); if (!mapped) throw new Error(`unsupported AGT policy decision: ${String(value)}`); return mapped; }
function record(value: unknown, name: string): Record<string, unknown> { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${name} must be an object`); return value as Record<string, unknown>; }
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<string>, 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<string, unknown>): Partial<EnvelopeFields> { 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 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)}`; }
1 change: 1 addition & 0 deletions packages/typescript/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./adapters.js";
export * from "./client.js";
export * from "./evidence.js";
export * from "./factory.js";
Expand Down
51 changes: 51 additions & 0 deletions packages/typescript/test/adapters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import test from "node:test";
import {AgtGovernanceEventSink, agtPolicyDecision, cedarPolicyDecision, EventFactory, opaDecisionLog, SchemaValidator} from "../src/index.js";
import type {NormalizedEvent} from "../src/index.js";

const digest = {algorithm: "sha256", value: "a".repeat(64)};
const factory = new EventFactory(SchemaValidator.bundled(), {name: "adapter-tests", version: "1"}, () => 1787079000000000000n, () => "018f0f7d-7a13-7cc2-8000-000000000042");

test("OPA boolean decision logs preserve safe facts and match Python identifiers", () => {
const source = {decision_id: "decision-123", labels: {version: "1.8.0"}, path: "/agents/allow", input: {secret: "must-not-copy"}, result: true, timestamp: "2026-08-18T12:34:56.123456789Z", metrics: {timer_rego_query_eval_ns: 4200}, ids: ["allow-agent"], trace_id: "4bf92f3577b34da6a3ce929d0e0e4736", span_id: "00f067aa0ba902b7"};
const event = opaDecisionLog(factory, source, {runId: "run-1", agentId: "agent-1", actionType: "agent.invoke", resourceType: "agent", bundleDigest: digest});
assert.equal(event.event_id, "f340f74c-976a-5bad-a542-dbac78522ee3");
assert.equal(event.time_unix_nano, "1787056496123456789"); assert.equal(event.decision, "allow"); assert.equal(event.evaluation_duration_ns, 4200);
assert.equal(JSON.stringify(event).includes("must-not-copy"), false);
assert.deepEqual(event.reason_codes, ["opa.rule:allow-agent"]);
});

test("OPA rejects ambiguous results, malformed collections, and normalized invalid dates", () => {
const options = {runId: "run-1", agentId: "agent-1", actionType: "agent.invoke", resourceType: "agent", bundleDigest: digest, opaVersion: "1.8.0"};
assert.throws(() => opaDecisionLog(factory, {decision_id: "d", result: {allow: true}}, options), /explicit resultMapper/);
assert.equal(opaDecisionLog(factory, {decision_id: "d", result: {allow: true}}, {...options, resultMapper: (value) => (value as {allow: boolean}).allow ? "allow" : "deny"}).decision, "allow");
assert.throws(() => opaDecisionLog(factory, {decision_id: "d", result: true, metrics: []}, options), /metrics must be an object/);
assert.throws(() => opaDecisionLog(factory, {decision_id: "d", result: true, timestamp: "2026-02-30T00:00:00Z"}, options), /not a valid timestamp/);
assert.throws(() => opaDecisionLog(factory, {decision_id: "d", result: true, trace_id: 42}, options), /trace_id must be a non-empty string/);
});

test("Cedar sorts stable codes, retains final decision, and rejects prose errors", () => {
const event = cedarPolicyDecision(factory, {runId: "run-1", agentId: "agent-1", decision: "Allow", cedarVersion: "4.11.2", bundleDigest: digest, actionType: "Document::read", resourceType: "Document", evaluationDurationNs: 900, determiningPolicyIds: ["permit-read", "permit-read"], errorCodes: ["entity_attribute_missing"]});
assert.equal(event.decision, "allow"); assert.equal((event.policy as Record<string, unknown>).policy_id, "permit-read");
assert.deepEqual(event.reason_codes, ["cedar.policy:permit-read", "cedar.error:entity_attribute_missing"]);
assert.throws(() => cedarPolicyDecision(factory, {runId: "run-1", agentId: "agent-1", decision: "Deny", cedarVersion: "4", bundleDigest: digest, actionType: "read", resourceType: "doc", evaluationDurationNs: 1, errorCodes: ["failed to read secret /customer/42"]}), /not error messages/);
assert.throws(() => cedarPolicyDecision(factory, {runId: "run-1", agentId: "agent-1", decision: "Deny", cedarVersion: "4", bundleDigest: digest, actionType: "read", resourceType: "doc", evaluationDurationNs: 1, determiningPolicyIds: "deny-secret"}), /not a string/);
});

function agtSource(decision = "require_approval"): Record<string, unknown> { return {event_id: "018f0f7d7a137cc28000000000000042", occurred_at: "2026-08-18T12:34:56.123456789+00:00", kind: "policy_check", agent_id: "agent-1", action: "tool.invoke", decision, reason: "secret customer text", resource: "/customer/42", policy_name: "tool-policy", latency_ms: 1.25, attributes: {resource_type: "tool", reason_codes: ["approval.required"], prompt: "must not copy"}}; }
const mapAgt = (source: unknown): NormalizedEvent[] => [agtPolicyDecision(factory, source as Record<string, unknown>, {runId: "run-1", policyEngineVersion: "1.2.3", bundleDigest: digest})];

test("AGT policy mapping excludes free-form content and sink prevalidates a whole batch", () => {
const event = mapAgt(agtSource())[0]!; assert.equal(event.decision, "challenge"); assert.equal(event.event_id, "018f0f7d-7a13-7cc2-8000-000000000042"); assert.equal(event.time_unix_nano, "1787056496123456789"); assert.equal(event.evaluation_duration_ns, 1_250_000);
const serialized = JSON.stringify(event); for (const secret of ["secret customer", "/customer/42", "must not copy"]) assert.equal(serialized.includes(secret), false);
const emitted: NormalizedEvent[] = []; const sink = new AgtGovernanceEventSink({emit: (item) => { emitted.push(item); return {accepted: true, projectionErrors: []}; }}, mapAgt, {success: "success", failure: "failure"});
assert.equal(sink.emit([agtSource(), agtSource("unknown")]), "failure"); assert.deepEqual(emitted, []);
assert.equal(sink.emit([agtSource()]), "success"); assert.equal(emitted.length, 1);
});

test("AGT sink reports projection failure and identifiers cannot carry prose", () => {
const sink = new AgtGovernanceEventSink({emit: () => ({accepted: true, projectionErrors: ["log failed"]})}, mapAgt, {success: 0, failure: 1});
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/);
});
Loading