Skip to content

Commit f3d1111

Browse files
Add strict AGT audit entry adapters (#15)
## What changed - map Agent Mesh AuditEntry policy events to normalized policy decisions - map tool/action audit entries to completed action events - require a caller-computed full action digest instead of misusing arguments_hash - derive duration only from valid issued/completed timestamps or an explicit duration - exclude free-form data and resource values - document consolidated-core import compatibility and legacy limitations ## Why AGT core retains the existing agentmesh import surfaces. Adopters that consume the audit log instead of GovernanceEventSink need a strict conversion path that does not overstate source integrity or action identity. ## Validation - 71 tests and 16 schema subtests pass - direct compatibility probe passes against the authoritative Agent Mesh AuditEntry model file - 8 bundled schemas match normative bytes - all conformance fixtures pass - source compilation and diff check pass - sdist and wheel build successfully - installed-wheel smoke test passes ## Probe note An initial source-tree package import failed before reaching AuditEntry because the monorepo bootstrap could not resolve the sibling hypervisor package; later chained commands masked that failure. The probe was rerun by directly loading the authoritative audit module and passed. ## Limits Agent Mesh audit hash v1.0 excludes later policy, timing, trace, argument-hash, approver, and environment fields, so verify_hash cannot attest those mapped facts. The minimal Agent OS AuditEntry lacks enough stable identity and correlation for automatic mapping. Signed-off-by: Imran Siddique <imran.siddique@opaque.co>
1 parent c746053 commit f3d1111

6 files changed

Lines changed: 325 additions & 1 deletion

File tree

‎ROADMAP.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
- Additional metric views and collector interoperability fixtures.
1414
- Additional W3C propagation carrier adapters beyond mutable string mappings.
1515
- Action lifecycle expansion if adopters need in-flight attempt telemetry.
16-
- AGT audit-entry adapters and framework integrations.
16+
- Framework integrations and future consolidated-core native event surfaces.
1717
- TypeScript SDK and mixed-language conformance.
1818

1919
Roadmap items are intentions, not shipped behavior or compatibility commitments.

‎docs/adapters.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,20 @@ version, and chronology against the supplied request. Individual
4545
The final chain-entry digest is retained as source-reported approval evidence;
4646
the adapter does not independently verify the chain.
4747

48+
Agent Mesh `AuditEntry` records, including those installed through
49+
`agent-governance-toolkit-core`, can be mapped as policy decisions or completed
50+
actions. Policy mapping requires a trusted bundle digest and explicit resource
51+
classification. Action mapping requires the caller's digest of the full governed
52+
action; `arguments_hash` is deliberately insufficient because it covers only
53+
arguments. Free-form `data` and `resource` are not copied.
54+
55+
Agent Mesh audit hash version 1.0 does not cover later-added policy decision,
56+
policy version, argument hash, approver, timing, trace, or environment fields.
57+
Calling the source object's `verify_hash()` therefore must not be represented as
58+
integrity proof for those fields. The minimal Agent OS `AuditEntry` has no stable
59+
source event ID, trace context, bundle identity, or action digest and is not
60+
mapped automatically.
61+
4862
Adapters report source facts; they do not evaluate policy or prove source
4963
authenticity. Callers remain responsible for trusted bundle digests and correct
5064
action/resource classification.

‎src/agentrust_telemetry/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
EventFactory,
66
agt_approval_request,
77
agt_approval_resolution,
8+
agt_audit_action,
9+
agt_audit_policy_decision,
810
agt_policy_decision,
911
agt_policy_decision_record,
1012
cedar_policy_decision,
@@ -51,6 +53,8 @@
5153
"active_context_ids",
5254
"agt_approval_request",
5355
"agt_approval_resolution",
56+
"agt_audit_action",
57+
"agt_audit_policy_decision",
5458
"agt_policy_decision",
5559
"agt_policy_decision_record",
5660
"cedar_policy_decision",

‎src/agentrust_telemetry/adapters/__init__.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
agt_approval_resolution,
88
agt_policy_decision_record,
99
)
10+
from .agt_audit import agt_audit_action, agt_audit_policy_decision
1011
from .cedar import cedar_policy_decision
1112
from .opa import opa_decision_log
1213

@@ -17,6 +18,8 @@
1718
"agt_policy_decision_record",
1819
"agt_approval_request",
1920
"agt_approval_resolution",
21+
"agt_audit_action",
22+
"agt_audit_policy_decision",
2023
"cedar_policy_decision",
2124
"opa_decision_log",
2225
]
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
"""Strict adapters for Agent Mesh audit entries shipped by AGT core."""
2+
3+
from __future__ import annotations
4+
5+
import uuid
6+
from datetime import datetime, timezone
7+
from typing import Any
8+
9+
from .base import EventFactory
10+
11+
12+
_AUDIT_NAMESPACE = uuid.UUID("398428fd-2730-498f-8ed8-6b4290668171")
13+
_POLICY_EVENTS = {"policy_evaluation", "policy_violation"}
14+
_ACTION_EVENTS = {"tool_invocation", "tool_blocked", "action"}
15+
16+
17+
def agt_audit_policy_decision(
18+
factory: EventFactory,
19+
source: Any,
20+
*,
21+
run_id: str,
22+
policy_engine_version: str,
23+
bundle_digest: dict[str, str],
24+
resource_type: str,
25+
evaluation_duration_ns: int = 0,
26+
enforcement_mode: str = "enforce",
27+
) -> dict[str, Any]:
28+
"""Map a policy-oriented Agent Mesh AuditEntry without copying its data."""
29+
event_type = _required_string(_field(source, "event_type"), "event_type")
30+
if event_type not in _POLICY_EVENTS:
31+
raise ValueError(f"AGT audit event is not a policy event: {event_type!r}")
32+
decision = _policy_decision(_field(source, "policy_decision"))
33+
entry_id = _required_string(_field(source, "entry_id"), "entry_id")
34+
policy: dict[str, Any] = {
35+
"engine": "agt",
36+
"engine_version": _required_string(policy_engine_version, "policy_engine_version"),
37+
"bundle_digest": bundle_digest,
38+
}
39+
matched_rule = _field(source, "matched_rule")
40+
if matched_rule is not None:
41+
policy["policy_id"] = _required_string(matched_rule, "matched_rule")
42+
return factory.build(
43+
"policy.decision",
44+
run_id=run_id,
45+
agent_id=_required_string(_field(source, "agent_did"), "agent_did"),
46+
event_id=_source_event_id("policy", entry_id),
47+
time_unix_nano=_datetime_ns(_field(source, "timestamp"), "timestamp"),
48+
trace_id=_optional_string(_field(source, "trace_id"), "trace_id"),
49+
decision=decision,
50+
policy=policy,
51+
action_type=_required_string(_field(source, "action"), "action"),
52+
resource_type=_required_string(resource_type, "resource_type"),
53+
enforcement_mode=enforcement_mode,
54+
evaluation_duration_ns=evaluation_duration_ns,
55+
reason_codes=[f"agt.audit:{event_type}"],
56+
)
57+
58+
59+
def agt_audit_action(
60+
factory: EventFactory,
61+
source: Any,
62+
*,
63+
run_id: str,
64+
action_digest: dict[str, str],
65+
action_kind: str,
66+
operation: str,
67+
duration_ns: int | None = None,
68+
) -> dict[str, Any]:
69+
"""Map an action audit row using a caller-computed full action digest."""
70+
event_type = _required_string(_field(source, "event_type"), "event_type")
71+
if event_type not in _ACTION_EVENTS:
72+
raise ValueError(f"AGT audit event is not an action event: {event_type!r}")
73+
entry_id = _required_string(_field(source, "entry_id"), "entry_id")
74+
outcome = "denied" if event_type == "tool_blocked" else _action_outcome(
75+
_field(source, "outcome")
76+
)
77+
resolved_duration = duration_ns
78+
if resolved_duration is None:
79+
resolved_duration = _audit_duration(source)
80+
target: dict[str, str] | None = None
81+
target_did = _field(source, "target_did")
82+
if target_did is not None:
83+
target = {"kind": "agent", "id": _required_string(target_did, "target_did")}
84+
return factory.build(
85+
"action.executed",
86+
run_id=run_id,
87+
agent_id=_required_string(_field(source, "agent_did"), "agent_did"),
88+
event_id=_source_event_id("action", entry_id),
89+
time_unix_nano=_datetime_ns(_field(source, "timestamp"), "timestamp"),
90+
trace_id=_optional_string(_field(source, "trace_id"), "trace_id"),
91+
action_id=entry_id,
92+
action_kind=action_kind,
93+
action_name=_required_string(_field(source, "action"), "action"),
94+
operation=_required_string(operation, "operation"),
95+
outcome=outcome,
96+
duration_ns=resolved_duration,
97+
action_digest=action_digest,
98+
**({"target": target} if target else {}),
99+
)
100+
101+
102+
def _policy_decision(value: Any) -> str:
103+
mapping = {
104+
"allow": "allow",
105+
"allowed": "allow",
106+
"deny": "deny",
107+
"denied": "deny",
108+
"require_approval": "challenge",
109+
"requires_approval": "challenge",
110+
"review": "challenge",
111+
"not_applicable": "not_applicable",
112+
"error": "error",
113+
}
114+
if value not in mapping:
115+
raise ValueError(f"unsupported AGT audit policy decision: {value!r}")
116+
return mapping[value]
117+
118+
119+
def _action_outcome(value: Any) -> str:
120+
mapping = {
121+
"success": "success",
122+
"failure": "error",
123+
"error": "error",
124+
"denied": "denied",
125+
"cancelled": "cancelled",
126+
"timeout": "timeout",
127+
}
128+
if value not in mapping:
129+
raise ValueError(f"unsupported AGT audit action outcome: {value!r}")
130+
return mapping[value]
131+
132+
133+
def _audit_duration(source: Any) -> int:
134+
issued = _field(source, "issued_at")
135+
completed = _field(source, "completed_at")
136+
if issued is None or completed is None:
137+
raise ValueError(
138+
"AGT action audit requires duration_ns or both issued_at and completed_at"
139+
)
140+
issued_ns = _datetime_ns(issued, "issued_at")
141+
completed_ns = _datetime_ns(completed, "completed_at")
142+
if completed_ns < issued_ns:
143+
raise ValueError("AGT completed_at cannot predate issued_at")
144+
return completed_ns - issued_ns
145+
146+
147+
def _source_event_id(kind: str, source_id: str) -> str:
148+
return str(uuid.uuid5(_AUDIT_NAMESPACE, f"{kind}:{source_id}"))
149+
150+
151+
def _field(source: Any, name: str) -> Any:
152+
if isinstance(source, dict):
153+
return source.get(name)
154+
return getattr(source, name, None)
155+
156+
157+
def _required_string(value: Any, field: str) -> str:
158+
if not isinstance(value, str) or not value:
159+
raise ValueError(f"AGT audit {field} must be a non-empty string")
160+
return value
161+
162+
163+
def _optional_string(value: Any, field: str) -> str | None:
164+
return None if value is None else _required_string(value, field)
165+
166+
167+
def _datetime_ns(value: Any, field: str) -> int:
168+
if not isinstance(value, datetime) or value.tzinfo is None:
169+
raise ValueError(f"AGT audit {field} must be a timezone-aware datetime")
170+
utc = value.astimezone(timezone.utc)
171+
epoch = datetime(1970, 1, 1, tzinfo=timezone.utc)
172+
delta = utc - epoch
173+
return (
174+
delta.days * 86_400_000_000_000
175+
+ delta.seconds * 1_000_000_000
176+
+ delta.microseconds * 1_000
177+
)

‎tests/test_agt_audit.py‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import sys
2+
import unittest
3+
from datetime import datetime, timedelta, timezone
4+
from pathlib import Path
5+
from types import SimpleNamespace
6+
7+
8+
ROOT = Path(__file__).resolve().parents[1]
9+
sys.path.insert(0, str(ROOT / "src"))
10+
11+
from agentrust_telemetry import ( # noqa: E402
12+
EventFactory,
13+
SchemaValidator,
14+
agt_audit_action,
15+
agt_audit_policy_decision,
16+
)
17+
18+
19+
NOW = datetime(2026, 8, 18, 12, 0, tzinfo=timezone.utc)
20+
DIGEST = {"algorithm": "sha256", "value": "a" * 64}
21+
BUNDLE = {"algorithm": "sha256", "value": "b" * 64}
22+
23+
24+
def audit_entry(**overrides):
25+
values = {
26+
"entry_id": "audit_123",
27+
"timestamp": NOW,
28+
"issued_at": NOW - timedelta(milliseconds=5),
29+
"completed_at": NOW,
30+
"event_type": "policy_evaluation",
31+
"agent_did": "did:agt:agent-1",
32+
"action": "tool.invoke",
33+
"arguments_hash": "c" * 64,
34+
"resource": "/customer/42",
35+
"target_did": None,
36+
"data": {"prompt": "must not cross"},
37+
"outcome": "success",
38+
"policy_decision": "require_approval",
39+
"matched_rule": "high-risk-tools",
40+
"policy_version": "7",
41+
"entry_hash": "d" * 64,
42+
"previous_hash": "e" * 64,
43+
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
44+
}
45+
values.update(overrides)
46+
return SimpleNamespace(**values)
47+
48+
49+
class AgtAuditAdapterTests(unittest.TestCase):
50+
@classmethod
51+
def setUpClass(cls):
52+
cls.factory = EventFactory(
53+
SchemaValidator(ROOT / "spec" / "schema"),
54+
producer_name="agt-audit-tests",
55+
producer_version="1.0.0",
56+
)
57+
58+
def test_policy_audit_maps_without_free_form_data_or_resource(self):
59+
event = agt_audit_policy_decision(
60+
self.factory,
61+
audit_entry(),
62+
run_id="run-1",
63+
policy_engine_version="5.0.0",
64+
bundle_digest=BUNDLE,
65+
resource_type="tool",
66+
)
67+
self.assertEqual(event["decision"], "challenge")
68+
self.assertEqual(event["policy"]["policy_id"], "high-risk-tools")
69+
serialized = repr(event)
70+
self.assertNotIn("/customer/42", serialized)
71+
self.assertNotIn("must not cross", serialized)
72+
self.assertNotIn("arguments_hash", serialized)
73+
self.assertNotIn("entry_hash", serialized)
74+
75+
def test_action_audit_requires_full_action_digest_and_computes_duration(self):
76+
source = audit_entry(event_type="tool_invocation", policy_decision=None)
77+
event = agt_audit_action(
78+
self.factory,
79+
source,
80+
run_id="run-1",
81+
action_digest=DIGEST,
82+
action_kind="tool",
83+
operation="invoke",
84+
)
85+
self.assertEqual(event["duration_ns"], 5_000_000)
86+
self.assertEqual(event["action_digest"], DIGEST)
87+
self.assertNotEqual(event["action_digest"]["value"], source.arguments_hash)
88+
89+
def test_blocked_action_is_denied_even_if_source_outcome_is_success(self):
90+
event = agt_audit_action(
91+
self.factory,
92+
audit_entry(event_type="tool_blocked", outcome="success"),
93+
run_id="run-1",
94+
action_digest=DIGEST,
95+
action_kind="tool",
96+
operation="invoke",
97+
)
98+
self.assertEqual(event["outcome"], "denied")
99+
100+
def test_action_rejects_missing_or_reversed_timing(self):
101+
with self.assertRaisesRegex(ValueError, "requires duration_ns"):
102+
agt_audit_action(
103+
self.factory,
104+
audit_entry(event_type="tool_invocation", issued_at=None),
105+
run_id="run-1",
106+
action_digest=DIGEST,
107+
action_kind="tool",
108+
operation="invoke",
109+
)
110+
with self.assertRaisesRegex(ValueError, "cannot predate"):
111+
agt_audit_action(
112+
self.factory,
113+
audit_entry(
114+
event_type="tool_invocation",
115+
issued_at=NOW,
116+
completed_at=NOW - timedelta(seconds=1),
117+
),
118+
run_id="run-1",
119+
action_digest=DIGEST,
120+
action_kind="tool",
121+
operation="invoke",
122+
)
123+
124+
125+
if __name__ == "__main__":
126+
unittest.main()

0 commit comments

Comments
 (0)