Skip to content

Commit f86cc93

Browse files
Add TypeScript policy adapters
1 parent 583f210 commit f86cc93

6 files changed

Lines changed: 139 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Add TypeScript OPA, Cedar, and generic AGT policy-decision adapters plus an
6+
AGT-compatible fail-closed batch sink.
57
- Add TypeScript usage/cost construction, coverage-labelled rollups, and
68
bounded-cardinality OpenTelemetry metric projection.
79
- Add a TypeScript evidence accumulator and adopt RFC 8785 JCS for reproducible

docs/adapters.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ IDs and caller-normalized diagnostic codes. Cedar evaluation errors are recorded
1919
without rewriting the final decision, matching Cedar's skip-on-error semantics.
2020
Free-form error messages are rejected to prevent accidental content leakage.
2121

22+
OPA, Cedar, the generic AGT policy mapper, and the fail-closed AGT batch sink are
23+
available in both Python and TypeScript. TypeScript accepts plain source records
24+
and has no runtime dependency on OPA, Cedar, or AGT packages. Action-bound AGT
25+
approval, audit-action, and data-label adapters are currently Python-only and
26+
remain explicit TypeScript parity work.
27+
2228
The optional AGT bridge implements the batch sink shape used by Agent OS without
2329
making AGT a core dependency. The generic constructor accepts a runtime's result
2430
sentinels and is the durable integration boundary. `from_agent_os` is a legacy

packages/typescript/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ It never installs an OTel provider, exporter, or global propagator.
1010
This reference surface includes a fail-closed evidence accumulator using the
1111
same RFC 8785 digest profile as Python, conservative usage/cost rollups, and a
1212
caller-owned bounded-cardinality OTel metric emitter. It does not yet include
13-
the Python SDK's source adapters or TRACE finalizer. Those
13+
the Python SDK's action-bound AGT approval, audit-action, and data-flow adapters,
14+
or TRACE finalizer. Generic OPA, Cedar, and AGT policy adapters are included. Those
1415
remain explicit parity work rather than implied compatibility.
1516

1617
Nanosecond timestamps are decimal strings on the JSON wire and `bigint` at the
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import {createHash} from "node:crypto";
2+
import {EventFactory, type EnvelopeFields} from "./factory.js";
3+
import type {NormalizedEvent} from "./types.js";
4+
5+
type Digest = {algorithm: string; value: string};
6+
type Decision = "allow" | "deny" | "challenge" | "not_applicable" | "error";
7+
const IDENTIFIER = /^[A-Za-z0-9_.:-]{1,128}$/;
8+
const OPA_NAMESPACE = "881f86d8-7573-4d35-98cb-c00b934cc04f";
9+
10+
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 {
11+
const sourceId = requiredString(source.decision_id, "OPA decision_id");
12+
const decision = (options.resultMapper ?? booleanDecision)(source.result);
13+
if (!["allow", "deny", "challenge", "not_applicable", "error"].includes(decision)) throw new Error(`OPA result mapper returned unsupported decision: ${decision}`);
14+
const labels = record(source.labels ?? {}, "OPA labels");
15+
const version = requiredString(options.opaVersion ?? labels.version, "OPA version");
16+
const metrics = record(source.metrics ?? {}, "OPA metrics");
17+
const duration = metrics.timer_rego_query_eval_ns ?? 0;
18+
if (!Number.isSafeInteger(duration) || (duration as number) < 0) throw new Error("OPA timer_rego_query_eval_ns must be a non-negative safe integer");
19+
const ids = source.ids ?? [];
20+
if (!Array.isArray(ids) || ids.some((value) => typeof value !== "string" || !value)) throw new Error("OPA ids must be an array of non-empty strings");
21+
if (ids.length > 32) throw new Error("OPA ids exceed the 32-code contract limit");
22+
const path = source.path;
23+
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)}, {
24+
decision, policy: {engine: "opa", engine_version: version, bundle_digest: options.bundleDigest, ...(typeof path === "string" && path ? {policy_id: path.replace(/^\/+/, "")} : {})},
25+
action_type: options.actionType, resource_type: options.resourceType, enforcement_mode: options.enforcementMode ?? "enforce", evaluation_duration_ns: duration,
26+
reason_codes: ids.map((value) => `opa.rule:${value}`),
27+
});
28+
}
29+
30+
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 {
31+
const decision = options.decision.toLowerCase();
32+
if (decision !== "allow" && decision !== "deny") throw new Error("Cedar decision must be Allow or Deny");
33+
const policies = identifiers(options.determiningPolicyIds ?? [], "determiningPolicyIds");
34+
const errors = identifiers(options.errorCodes ?? [], "errorCodes");
35+
if (errors.some((value) => !IDENTIFIER.test(value))) throw new Error("Cedar errorCodes must be identifiers, not error messages");
36+
const reasons = [...policies.map((value) => `cedar.policy:${value}`), ...errors.map((value) => `cedar.error:${value}`)];
37+
if (reasons.length > 32) throw new Error("Cedar reasons and errors exceed the 32-code contract limit");
38+
if (!Number.isSafeInteger(options.evaluationDurationNs) || options.evaluationDurationNs < 0) throw new Error("Cedar evaluationDurationNs must be a non-negative safe integer");
39+
return factory.build("policy.decision", {runId: options.runId, agentId: options.agentId, ...options.envelope}, {
40+
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} : {})},
41+
action_type: options.actionType, resource_type: options.resourceType, enforcement_mode: options.enforcementMode ?? "enforce", evaluation_duration_ns: options.evaluationDurationNs, reason_codes: reasons,
42+
});
43+
}
44+
45+
export function agtPolicyDecision(factory: EventFactory, source: Record<string, unknown>, options: {runId: string; policyEngineVersion: string; bundleDigest: Digest; resourceType?: string; enforcementMode?: string}): NormalizedEvent {
46+
const kind = source.kind;
47+
if (kind !== "policy_check" && kind !== "policy_violation") throw new Error(`AGT event kind is not a policy decision: ${String(kind)}`);
48+
const attributes = record(source.attributes ?? {}, "AGT attributes");
49+
const reasonCodes = attributes.reason_codes ?? [];
50+
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");
51+
const latency = source.latency_ms ?? 0;
52+
if (typeof latency !== "number" || !Number.isFinite(latency) || latency < 0) throw new Error("AGT latency_ms must be a finite non-negative number");
53+
const duration = Math.round(latency * 1_000_000);
54+
if (!Number.isSafeInteger(duration)) throw new Error("AGT latency_ms exceeds the safe nanosecond range");
55+
const policyName = source.policy_name;
56+
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)}, {
57+
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")})},
58+
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,
59+
});
60+
}
61+
62+
export class AgtGovernanceEventSink<Success, Failure> {
63+
constructor(readonly client: {emit(event: NormalizedEvent): {accepted: boolean; projectionErrors?: readonly unknown[]}}, readonly mapper: (source: unknown) => Iterable<NormalizedEvent>, readonly results: {success: Success; failure: Failure}) {}
64+
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; } }
65+
shutdown(_timeoutMs = 5_000): true { return true; }
66+
forceFlush(_timeoutMs = 30_000): true { return true; }
67+
}
68+
69+
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"); }
70+
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; }
71+
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>; }
72+
function requiredString(value: unknown, name: string): string { if (typeof value !== "string" || !value) throw new Error(`${name} must be a non-empty string`); return value; }
73+
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; }
74+
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")})}; }
75+
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"); }
76+
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)}`; }
77+
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)}`; }

packages/typescript/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from "./adapters.js";
12
export * from "./client.js";
23
export * from "./evidence.js";
34
export * from "./factory.js";
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import {AgtGovernanceEventSink, agtPolicyDecision, cedarPolicyDecision, EventFactory, opaDecisionLog, SchemaValidator} from "../src/index.js";
4+
import type {NormalizedEvent} from "../src/index.js";
5+
6+
const digest = {algorithm: "sha256", value: "a".repeat(64)};
7+
const factory = new EventFactory(SchemaValidator.bundled(), {name: "adapter-tests", version: "1"}, () => 1787079000000000000n, () => "018f0f7d-7a13-7cc2-8000-000000000042");
8+
9+
test("OPA boolean decision logs preserve safe facts and match Python identifiers", () => {
10+
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"};
11+
const event = opaDecisionLog(factory, source, {runId: "run-1", agentId: "agent-1", actionType: "agent.invoke", resourceType: "agent", bundleDigest: digest});
12+
assert.equal(event.event_id, "f340f74c-976a-5bad-a542-dbac78522ee3");
13+
assert.equal(event.time_unix_nano, "1787056496123456789"); assert.equal(event.decision, "allow"); assert.equal(event.evaluation_duration_ns, 4200);
14+
assert.equal(JSON.stringify(event).includes("must-not-copy"), false);
15+
assert.deepEqual(event.reason_codes, ["opa.rule:allow-agent"]);
16+
});
17+
18+
test("OPA rejects ambiguous results, malformed collections, and normalized invalid dates", () => {
19+
const options = {runId: "run-1", agentId: "agent-1", actionType: "agent.invoke", resourceType: "agent", bundleDigest: digest, opaVersion: "1.8.0"};
20+
assert.throws(() => opaDecisionLog(factory, {decision_id: "d", result: {allow: true}}, options), /explicit resultMapper/);
21+
assert.equal(opaDecisionLog(factory, {decision_id: "d", result: {allow: true}}, {...options, resultMapper: (value) => (value as {allow: boolean}).allow ? "allow" : "deny"}).decision, "allow");
22+
assert.throws(() => opaDecisionLog(factory, {decision_id: "d", result: true, metrics: []}, options), /metrics must be an object/);
23+
assert.throws(() => opaDecisionLog(factory, {decision_id: "d", result: true, timestamp: "2026-02-30T00:00:00Z"}, options), /not a valid timestamp/);
24+
assert.throws(() => opaDecisionLog(factory, {decision_id: "d", result: true, trace_id: 42}, options), /trace_id must be a non-empty string/);
25+
});
26+
27+
test("Cedar sorts stable codes, retains final decision, and rejects prose errors", () => {
28+
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"]});
29+
assert.equal(event.decision, "allow"); assert.equal((event.policy as Record<string, unknown>).policy_id, "permit-read");
30+
assert.deepEqual(event.reason_codes, ["cedar.policy:permit-read", "cedar.error:entity_attribute_missing"]);
31+
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/);
32+
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/);
33+
});
34+
35+
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"}}; }
36+
const mapAgt = (source: unknown): NormalizedEvent[] => [agtPolicyDecision(factory, source as Record<string, unknown>, {runId: "run-1", policyEngineVersion: "1.2.3", bundleDigest: digest})];
37+
38+
test("AGT policy mapping excludes free-form content and sink prevalidates a whole batch", () => {
39+
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);
40+
const serialized = JSON.stringify(event); for (const secret of ["secret customer", "/customer/42", "must not copy"]) assert.equal(serialized.includes(secret), false);
41+
const emitted: NormalizedEvent[] = []; const sink = new AgtGovernanceEventSink({emit: (item) => { emitted.push(item); return {accepted: true, projectionErrors: []}; }}, mapAgt, {success: "success", failure: "failure"});
42+
assert.equal(sink.emit([agtSource(), agtSource("unknown")]), "failure"); assert.deepEqual(emitted, []);
43+
assert.equal(sink.emit([agtSource()]), "success"); assert.equal(emitted.length, 1);
44+
});
45+
46+
test("AGT sink reports projection failure and identifiers cannot carry prose", () => {
47+
const sink = new AgtGovernanceEventSink({emit: () => ({accepted: true, projectionErrors: ["log failed"]})}, mapAgt, {success: 0, failure: 1});
48+
assert.equal(sink.emit([agtSource()]), 1);
49+
const source = agtSource(); source.attributes = {resource_type: "tool", reason_codes: ["customer secret denied"]};
50+
assert.throws(() => mapAgt(source), /unique identifiers/);
51+
});

0 commit comments

Comments
 (0)