Skip to content

Commit 295c2c8

Browse files
Add TypeScript AGT approvals
1 parent 9b2a1f4 commit 295c2c8

8 files changed

Lines changed: 116 additions & 7 deletions

File tree

CHANGELOG.md

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

33
## Unreleased
44

5+
- Add TypeScript action-bound AGT policy, approval-request, and terminal
6+
approval-resolution adapters with deterministic cross-language linkage.
7+
- Require every AGT approval binding field to be present before comparing it,
8+
preventing two absent values from being treated as a valid binding.
59
- Add TypeScript OPA, Cedar, and generic AGT policy-decision adapters plus an
610
AGT-compatible fail-closed batch sink.
711
- Add TypeScript usage/cost construction, coverage-labelled rollups, and

docs/adapters.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ Free-form error messages are rejected to prevent accidental content leakage.
2222
OPA, Cedar, the generic AGT policy mapper, and the fail-closed AGT batch sink are
2323
available in both Python and TypeScript. TypeScript accepts plain source records
2424
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
25+
approval adapters are also available in TypeScript using RFC 3339 UTC timestamp
26+
strings. Audit-action and data-label adapters are currently Python-only and
2627
remain explicit TypeScript parity work.
2728

2829
The optional AGT bridge implements the batch sink shape used by Agent OS without

packages/typescript/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +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 action-bound AGT approval, audit-action, and data-flow adapters,
14-
or TRACE finalizer. Generic OPA, Cedar, and AGT policy adapters are included. Those
13+
the Python SDK's AGT audit-action and data-flow adapters, or TRACE finalizer.
14+
Generic OPA, Cedar, AGT policy, and action-bound approval adapters are included. Those
1515
remain explicit parity work rather than implied compatibility.
1616

1717
Nanosecond timestamps are decimal strings on the JSON wire and `bigint` at the
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import {createHash} from "node:crypto";
2+
import {EventFactory} from "./factory.js";
3+
import type {NormalizedEvent} from "./types.js";
4+
5+
type Source = Record<string, unknown>;
6+
type Digest = {algorithm: string; value: string};
7+
const NAMESPACE = "ea5a1737-5417-4eaa-8bb0-4fc40e4cb837";
8+
const DIGEST = /^sha256:([0-9a-f]{64})$/;
9+
10+
export function agtPolicyDecisionRecord(factory: EventFactory, source: Source, options: {runId: string; agentId: string; actionType: string; resourceType: string; policyEngineVersion: string; bundleDigest: Digest; evaluationDurationNs?: number; enforcementMode?: string; traceId?: string; spanId?: string}): NormalizedEvent {
11+
if (source.verdict !== "require_approval") throw new Error("AGT PolicyDecisionRecord verdict must be require_approval");
12+
const sourceId = required(source.policy_decision_id, "policy_decision_id");
13+
const duration = options.evaluationDurationNs ?? 0;
14+
if (!Number.isSafeInteger(duration) || duration < 0) throw new Error("evaluationDurationNs must be a non-negative safe integer");
15+
return factory.build("policy.decision", {runId: options.runId, agentId: options.agentId, eventId: sourceEventId("policy", sourceId), timeUnixNano: timestampNs(source.decided_at, "decided_at"), ...context(options)}, {
16+
decision: "challenge", policy: {engine: "agt", engine_version: required(options.policyEngineVersion, "policyEngineVersion"), policy_id: required(source.policy_rule_id, "policy_rule_id"), bundle_digest: options.bundleDigest},
17+
action_type: options.actionType, resource_type: options.resourceType, enforcement_mode: options.enforcementMode ?? "enforce", evaluation_duration_ns: duration, reason_codes: ["agt.verdict:require_approval"],
18+
});
19+
}
20+
21+
export function agtApprovalRequest(factory: EventFactory, request: Source, policy: Source, options: {runId: string; traceId?: string; spanId?: string}): NormalizedEvent {
22+
verifyPairs(request, policy, [["policy_decision_id", "policy_decision_id"], ["action_digest", "action_digest"], ["policy_version", "policy_version"], ["approval_chain_id", "approval_chain_id"], ["approval_chain_version", "approval_chain_version"]], "request", "policy decision");
23+
const approvalId = required(request.approval_request_id, "approval_request_id");
24+
const policyId = required(request.policy_decision_id, "policy_decision_id");
25+
const requestedAt = timestampNs(request.requested_at, "requested_at");
26+
return factory.build("approval.requested", {runId: options.runId, agentId: required(request.agent_id, "agent_id"), eventId: sourceEventId("approval.requested", approvalId), timeUnixNano: requestedAt, ...context(options)}, {
27+
approval_id: approvalId, policy_event_id: sourceEventId("policy", policyId), chain_id: required(request.approval_chain_id, "approval_chain_id"), chain_version: required(request.approval_chain_version, "approval_chain_version"), action_digest: digest(request.action_digest, "action_digest"), actor_type: "policy", requested_at_unix_nano: requestedAt, expires_at_unix_nano: timestampNs(request.expires_at, "expires_at"), reason_codes: ["agt.approval:requested"],
28+
});
29+
}
30+
31+
export function agtApprovalResolution(factory: EventFactory, resolution: Source, request: Source, options: {runId: string; traceId?: string; spanId?: string}): NormalizedEvent {
32+
verifyPairs(resolution, request, [["approval_request_id", "approval_request_id"], ["action_digest", "action_digest"], ["policy_version", "policy_version"], ["approval_chain_version", "approval_chain_version"]], "resolution", "request");
33+
const eventType = new Map<unknown, string>([["allow", "approval.approved"], ["deny", "approval.rejected"], ["expired", "approval.expired"]]).get(resolution.outcome);
34+
if (!eventType) throw new Error(`unsupported AGT approval outcome: ${String(resolution.outcome)}`);
35+
const resolvedAt = timestampNs(resolution.resolved_at, "resolved_at");
36+
const requestedAt = timestampNs(request.requested_at, "requested_at");
37+
if (resolvedAt < requestedAt) throw new Error("AGT approval resolution cannot predate its request");
38+
const approvalId = required(resolution.approval_request_id, "approval_request_id");
39+
const resolutionId = required(resolution.approval_resolution_id, "approval_resolution_id");
40+
const finalDigest = resolution.final_entry_digest;
41+
return factory.build(eventType, {runId: options.runId, agentId: required(request.agent_id, "agent_id"), eventId: sourceEventId("approval.resolution", resolutionId), timeUnixNano: resolvedAt, ...context(options)}, {
42+
approval_id: approvalId, policy_event_id: sourceEventId("policy", required(request.policy_decision_id, "policy_decision_id")), chain_id: required(request.approval_chain_id, "approval_chain_id"), chain_version: required(request.approval_chain_version, "approval_chain_version"), resolution_id: resolutionId, action_digest: digest(resolution.action_digest, "action_digest"), actor_type: "system", requested_at_unix_nano: requestedAt, expires_at_unix_nano: timestampNs(request.expires_at, "expires_at"), reason_codes: [`agt.outcome:${String(resolution.outcome)}`], ...(finalDigest === undefined || finalDigest === null ? {} : {approval_evidence_digest: digest(finalDigest, "final_entry_digest")}),
43+
});
44+
}
45+
46+
function verifyPairs(left: Source, right: Source, pairs: Array<[string, string]>, leftName: string, rightName: string): void { for (const [leftField, rightField] of pairs) { const leftValue = required(left[leftField], leftField); const rightValue = required(right[rightField], rightField); if (leftValue !== rightValue) throw new Error(`AGT ${leftName} ${leftField} does not match ${rightName} ${rightField}`); } }
47+
function digest(value: unknown, field: string): Digest { const match = DIGEST.exec(required(value, field)); if (!match) throw new Error(`AGT ${field} must use sha256:<lowercase-hex>`); return {algorithm: "sha256", value: match[1]!}; }
48+
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; }
49+
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")})}; }
50+
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"); }
51+
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)}`; }

packages/typescript/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from "./adapters.js";
2+
export * from "./agt-approval.js";
23
export * from "./client.js";
34
export * from "./evidence.js";
45
export * from "./factory.js";
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import {agtApprovalRequest, agtApprovalResolution, agtPolicyDecisionRecord, EventFactory, SchemaValidator} from "../src/index.js";
4+
5+
const actionDigest = `sha256:${"a".repeat(64)}`; const bundle = {algorithm: "sha256", value: "b".repeat(64)};
6+
const factory = new EventFactory(SchemaValidator.bundled(), {name: "approval-tests", version: "1"}, () => 1n, () => "018f0f7d-7a13-7cc2-8000-000000000042");
7+
const policy = {action_digest: actionDigest, policy_rule_id: "high-risk-tools", policy_version: "7", approval_chain_id: "operators", approval_chain_version: "3", verdict: "require_approval", policy_decision_id: "pd_1", decided_at: "2026-08-18T12:00:00Z"};
8+
const request = {policy_decision_id: "pd_1", action_digest: actionDigest, agent_id: "agent-1", operation: "deploy", policy_version: "7", approval_chain_id: "operators", approval_chain_version: "3", expires_at: "2026-08-18T12:10:00Z", approval_request_id: "ar_1", requested_at: "2026-08-18T12:00:01Z"};
9+
const resolution = {approval_request_id: "ar_1", outcome: "allow", action_digest: actionDigest, policy_version: "7", approval_chain_version: "3", approval_resolution_id: "apr_1", resolved_at: "2026-08-18T12:02:00Z", final_entry_digest: `sha256:${"c".repeat(64)}`};
10+
11+
test("approval chain preserves Python-compatible policy, request, and resolution links", () => {
12+
const p = agtPolicyDecisionRecord(factory, policy, {runId: "run-1", agentId: "agent-1", actionType: "deploy", resourceType: "environment", policyEngineVersion: "1.0.0", bundleDigest: bundle});
13+
const q = agtApprovalRequest(factory, request, policy, {runId: "run-1"});
14+
const r = agtApprovalResolution(factory, resolution, request, {runId: "run-1"});
15+
assert.equal(p.event_id, "5e8f8100-3a82-54ab-89cb-fd43a08202b2"); assert.equal(q.event_id, "7781e4c1-4010-52d3-8aec-e0e89925b91e"); assert.equal(r.event_id, "900a5dda-3723-5cba-a3b2-c7281d05676c");
16+
assert.equal(q.policy_event_id, p.event_id); assert.equal(r.policy_event_id, p.event_id); assert.equal(r.approval_id, q.approval_id); assert.equal(r.event_type, "approval.approved");
17+
assert.equal((r.approval_evidence_digest as Record<string, unknown>).value, "c".repeat(64));
18+
});
19+
20+
test("request mapping rejects every policy binding mismatch", () => {
21+
for (const [field, value] of [["policy_decision_id", "other"], ["action_digest", `sha256:${"d".repeat(64)}`], ["policy_version", "8"], ["approval_chain_id", "other"], ["approval_chain_version", "4"]]) assert.throws(() => agtApprovalRequest(factory, {...request, [field]: value}, policy, {runId: "run-1"}), new RegExp(`${field} does not match`));
22+
assert.throws(() => agtApprovalRequest(factory, {...request, policy_version: undefined}, {...policy, policy_version: undefined}, {runId: "run-1"}), /policy_version must be a non-empty/);
23+
});
24+
25+
test("resolution mapping rejects request mismatch, chronology reversal, and malformed evidence", () => {
26+
for (const [field, value] of [["approval_request_id", "other"], ["action_digest", `sha256:${"d".repeat(64)}`], ["policy_version", "8"], ["approval_chain_version", "4"]]) assert.throws(() => agtApprovalResolution(factory, {...resolution, [field]: value}, request, {runId: "run-1"}), new RegExp(`${field} does not match`));
27+
assert.throws(() => agtApprovalResolution(factory, {...resolution, resolved_at: "2026-08-18T11:59:59Z"}, request, {runId: "run-1"}), /cannot predate/);
28+
assert.throws(() => agtApprovalResolution(factory, {...resolution, final_entry_digest: "not-a-digest"}, request, {runId: "run-1"}), /sha256/);
29+
});
30+
31+
test("only terminal outcomes map to terminal approval events", () => {
32+
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);
33+
assert.throws(() => agtApprovalResolution(factory, {...resolution, outcome: "vote"}, request, {runId: "run-1"}), /unsupported/);
34+
});

src/agentrust_telemetry/adapters/agt_approval.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,12 @@ def _verify_resolution_binding(resolution: Any, request: Any) -> None:
161161
("approval_chain_version", "approval_chain_version"),
162162
)
163163
for resolution_field, request_field in pairs:
164-
left = _enum_value(_field(resolution, resolution_field))
165-
right = _enum_value(_field(request, request_field))
164+
left = _required_string(
165+
_enum_value(_field(resolution, resolution_field)), resolution_field
166+
)
167+
right = _required_string(
168+
_enum_value(_field(request, request_field)), request_field
169+
)
166170
if left != right:
167171
raise ValueError(
168172
f"AGT resolution {resolution_field} does not match request {request_field}"
@@ -178,8 +182,12 @@ def _verify_request_binding(request: Any, policy_decision: Any) -> None:
178182
("approval_chain_version", "approval_chain_version"),
179183
)
180184
for request_field, policy_field in pairs:
181-
left = _enum_value(_field(request, request_field))
182-
right = _enum_value(_field(policy_decision, policy_field))
185+
left = _required_string(
186+
_enum_value(_field(request, request_field)), request_field
187+
)
188+
right = _required_string(
189+
_enum_value(_field(policy_decision, policy_field)), policy_field
190+
)
183191
if left != right:
184192
raise ValueError(
185193
f"AGT request {request_field} does not match policy decision {policy_field}"

tests/test_agt_approval.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,16 @@ def test_request_rejects_wrong_policy_action_and_chain(self):
166166
self.factory, wrong_action, self.request, run_id="run-1"
167167
)
168168

169+
def test_bindings_reject_fields_that_are_absent_on_both_records(self):
170+
request = replace(self.request, policy_version=None) # type: ignore[arg-type]
171+
policy = replace(self.policy, policy_version=None) # type: ignore[arg-type]
172+
with self.assertRaisesRegex(ValueError, "policy_version must be a non-empty"):
173+
agt_approval_request(self.factory, request, policy, run_id="run-1")
174+
175+
resolution = replace(self.resolution, policy_version=None) # type: ignore[arg-type]
176+
with self.assertRaisesRegex(ValueError, "policy_version must be a non-empty"):
177+
agt_approval_resolution(self.factory, resolution, request, run_id="run-1")
178+
169179
def test_resolution_rejects_pre_request_time_and_malformed_chain_digest(self):
170180
early = replace(self.resolution, resolved_at=NOW)
171181
with self.assertRaisesRegex(ValueError, "cannot predate"):

0 commit comments

Comments
 (0)