diff --git a/CHANGELOG.md b/CHANGELOG.md index 32bb94d..0372b71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/adapters.md b/docs/adapters.md index 0b257b9..db81312 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -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 diff --git a/packages/typescript/README.md b/packages/typescript/README.md index 9ab49fa..2484ef9 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -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 diff --git a/packages/typescript/src/adapters.ts b/packages/typescript/src/adapters.ts new file mode 100644 index 0000000..2937b3e --- /dev/null +++ b/packages/typescript/src/adapters.ts @@ -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, 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; errorCodes?: Iterable; enforcementMode?: string; inputDigest?: Digest; envelope?: Omit}): 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, 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 { + constructor(readonly client: {emit(event: NormalizedEvent): {accepted: boolean; projectionErrors?: readonly unknown[]}}, readonly mapper: (source: unknown) => Iterable, 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([["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 { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${name} must be an object`); return value as Record; } +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 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/src/index.ts b/packages/typescript/src/index.ts index 31ee9e0..9bdcdbd 100644 --- a/packages/typescript/src/index.ts +++ b/packages/typescript/src/index.ts @@ -1,3 +1,4 @@ +export * from "./adapters.js"; export * from "./client.js"; export * from "./evidence.js"; export * from "./factory.js"; diff --git a/packages/typescript/test/adapters.test.ts b/packages/typescript/test/adapters.test.ts new file mode 100644 index 0000000..85bfc59 --- /dev/null +++ b/packages/typescript/test/adapters.test.ts @@ -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).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 { 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, {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/); +});