agentrust-telemetry 0.1.0a3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentrust_telemetry/__init__.py +80 -0
- agentrust_telemetry/adapters/__init__.py +28 -0
- agentrust_telemetry/adapters/agt.py +207 -0
- agentrust_telemetry/adapters/agt_approval.py +235 -0
- agentrust_telemetry/adapters/agt_audit.py +177 -0
- agentrust_telemetry/adapters/agt_data.py +105 -0
- agentrust_telemetry/adapters/base.py +90 -0
- agentrust_telemetry/adapters/cedar.py +66 -0
- agentrust_telemetry/adapters/opa.py +102 -0
- agentrust_telemetry/client.py +113 -0
- agentrust_telemetry/context.py +30 -0
- agentrust_telemetry/data_flow.py +112 -0
- agentrust_telemetry/errors.py +26 -0
- agentrust_telemetry/evidence.py +204 -0
- agentrust_telemetry/otel.py +135 -0
- agentrust_telemetry/projection.py +49 -0
- agentrust_telemetry/propagation.py +102 -0
- agentrust_telemetry/py.typed +1 -0
- agentrust_telemetry/schemas/action.schema.json +39 -0
- agentrust_telemetry/schemas/approval.schema.json +27 -0
- agentrust_telemetry/schemas/common.schema.json +36 -0
- agentrust_telemetry/schemas/data-flow.schema.json +46 -0
- agentrust_telemetry/schemas/envelope.schema.json +21 -0
- agentrust_telemetry/schemas/evidence.schema.json +20 -0
- agentrust_telemetry/schemas/policy-decision.schema.json +33 -0
- agentrust_telemetry/schemas/usage.schema.json +80 -0
- agentrust_telemetry/trace_adapter.py +263 -0
- agentrust_telemetry/usage.py +190 -0
- agentrust_telemetry/validation.py +102 -0
- agentrust_telemetry-0.1.0a3.dist-info/METADATA +180 -0
- agentrust_telemetry-0.1.0a3.dist-info/RECORD +35 -0
- agentrust_telemetry-0.1.0a3.dist-info/WHEEL +5 -0
- agentrust_telemetry-0.1.0a3.dist-info/licenses/LICENSE +21 -0
- agentrust_telemetry-0.1.0a3.dist-info/licenses/NOTICE +4 -0
- agentrust_telemetry-0.1.0a3.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""AGT DataLabel and DataAccessDecision adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..data_flow import (
|
|
9
|
+
ClassificationResult,
|
|
10
|
+
DataEndpoint,
|
|
11
|
+
classified_data_flow,
|
|
12
|
+
)
|
|
13
|
+
from .base import EventFactory
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_LEVELS = {
|
|
17
|
+
0: "public",
|
|
18
|
+
1: "internal",
|
|
19
|
+
2: "confidential",
|
|
20
|
+
3: "restricted",
|
|
21
|
+
4: "top_secret",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def agt_data_classification(source: Any) -> ClassificationResult:
|
|
26
|
+
"""Map only AGT's ordered sensitivity tier; omit auxiliary label content."""
|
|
27
|
+
classification = _field(source, "classification")
|
|
28
|
+
raw = classification.value if isinstance(classification, Enum) else classification
|
|
29
|
+
if isinstance(raw, bool) or raw not in _LEVELS:
|
|
30
|
+
raise ValueError(f"unsupported AGT data classification: {raw!r}")
|
|
31
|
+
return ClassificationResult(
|
|
32
|
+
taxonomy="agt.data_classification.v1",
|
|
33
|
+
value=_LEVELS[raw],
|
|
34
|
+
producer="agt.data_label",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def agt_data_access_flow(
|
|
39
|
+
factory: EventFactory,
|
|
40
|
+
decision: Any,
|
|
41
|
+
*,
|
|
42
|
+
run_id: str,
|
|
43
|
+
direction: str,
|
|
44
|
+
source: DataEndpoint,
|
|
45
|
+
destination: DataEndpoint,
|
|
46
|
+
purpose: str,
|
|
47
|
+
content_digest: dict[str, str] | None = None,
|
|
48
|
+
size_bytes: int | None = None,
|
|
49
|
+
token_count: int | None = None,
|
|
50
|
+
media_type: str | None = None,
|
|
51
|
+
transformation: str | None = None,
|
|
52
|
+
**envelope: Any,
|
|
53
|
+
) -> dict[str, Any]:
|
|
54
|
+
"""Map an AGT DataAccessDecision without copying labels or free-form reason."""
|
|
55
|
+
allowed = _field(decision, "allowed")
|
|
56
|
+
if not isinstance(allowed, bool):
|
|
57
|
+
raise ValueError("AGT data access allowed must be a boolean")
|
|
58
|
+
result = agt_data_classification(_field(decision, "data_label"))
|
|
59
|
+
agent_id = _field(decision, "agent_id")
|
|
60
|
+
if not isinstance(agent_id, str) or not agent_id:
|
|
61
|
+
raise ValueError("AGT data access agent_id must be a non-empty string")
|
|
62
|
+
class FixedClassifier:
|
|
63
|
+
def classify(self, value: Any) -> ClassificationResult:
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
return classified_data_flow(
|
|
67
|
+
factory,
|
|
68
|
+
FixedClassifier(),
|
|
69
|
+
None,
|
|
70
|
+
run_id=run_id,
|
|
71
|
+
agent_id=agent_id,
|
|
72
|
+
time_unix_nano=_datetime_ns(_field(decision, "evaluated_at")),
|
|
73
|
+
direction=direction,
|
|
74
|
+
source=source,
|
|
75
|
+
destination=destination,
|
|
76
|
+
purpose=purpose,
|
|
77
|
+
policy_decision="allow" if allowed else "deny",
|
|
78
|
+
content_digest=content_digest,
|
|
79
|
+
size_bytes=size_bytes,
|
|
80
|
+
token_count=token_count,
|
|
81
|
+
media_type=media_type,
|
|
82
|
+
transformation=transformation,
|
|
83
|
+
**envelope,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _field(source: Any, name: str) -> Any:
|
|
88
|
+
if isinstance(source, dict):
|
|
89
|
+
return source.get(name)
|
|
90
|
+
return getattr(source, name, None)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _datetime_ns(value: Any) -> int:
|
|
94
|
+
from datetime import datetime, timezone
|
|
95
|
+
|
|
96
|
+
if not isinstance(value, datetime) or value.tzinfo is None:
|
|
97
|
+
raise ValueError("AGT data access evaluated_at must be a timezone-aware datetime")
|
|
98
|
+
utc = value.astimezone(timezone.utc)
|
|
99
|
+
epoch = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
|
100
|
+
delta = utc - epoch
|
|
101
|
+
return (
|
|
102
|
+
delta.days * 86_400_000_000_000
|
|
103
|
+
+ delta.seconds * 1_000_000_000
|
|
104
|
+
+ delta.microseconds * 1_000
|
|
105
|
+
)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Validated envelope construction shared by all adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
from ..validation import SchemaValidator
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EventFactory:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
validator: SchemaValidator,
|
|
17
|
+
*,
|
|
18
|
+
producer_name: str,
|
|
19
|
+
producer_version: str,
|
|
20
|
+
producer_instance_id: str | None = None,
|
|
21
|
+
clock_ns: Callable[[], int] = time.time_ns,
|
|
22
|
+
event_id_factory: Callable[[], uuid.UUID] = uuid.uuid4,
|
|
23
|
+
) -> None:
|
|
24
|
+
self._validator = validator
|
|
25
|
+
self._producer = {
|
|
26
|
+
"name": producer_name,
|
|
27
|
+
"version": producer_version,
|
|
28
|
+
**({"instance_id": producer_instance_id} if producer_instance_id else {}),
|
|
29
|
+
}
|
|
30
|
+
self._clock_ns = clock_ns
|
|
31
|
+
self._event_id_factory = event_id_factory
|
|
32
|
+
|
|
33
|
+
def build(
|
|
34
|
+
self,
|
|
35
|
+
event_type: str,
|
|
36
|
+
*,
|
|
37
|
+
run_id: str,
|
|
38
|
+
agent_id: str | None = None,
|
|
39
|
+
workflow_id: str | None = None,
|
|
40
|
+
parent_agent_id: str | None = None,
|
|
41
|
+
task_id: str | None = None,
|
|
42
|
+
trace_id: str | None = None,
|
|
43
|
+
span_id: str | None = None,
|
|
44
|
+
event_id: str | None = None,
|
|
45
|
+
time_unix_nano: int | str | None = None,
|
|
46
|
+
**payload: Any,
|
|
47
|
+
) -> dict[str, Any]:
|
|
48
|
+
reserved = {
|
|
49
|
+
"spec_version", "event_id", "event_type", "time_unix_nano", "run_id",
|
|
50
|
+
"producer", "agent_id", "workflow_id", "parent_agent_id", "task_id",
|
|
51
|
+
"trace_id", "span_id",
|
|
52
|
+
}
|
|
53
|
+
collision = sorted(reserved.intersection(payload))
|
|
54
|
+
if collision:
|
|
55
|
+
raise ValueError(f"payload cannot override envelope fields: {collision}")
|
|
56
|
+
timestamp = self._clock_ns() if time_unix_nano is None else time_unix_nano
|
|
57
|
+
event: dict[str, Any] = {
|
|
58
|
+
"spec_version": "0.1.0-alpha.3",
|
|
59
|
+
"event_id": event_id or str(self._event_id_factory()),
|
|
60
|
+
"event_type": event_type,
|
|
61
|
+
"time_unix_nano": _unix_nano(timestamp),
|
|
62
|
+
"run_id": run_id,
|
|
63
|
+
"producer": deepcopy(self._producer),
|
|
64
|
+
**{
|
|
65
|
+
key: _unix_nano(value) if key.endswith("_at_unix_nano") else value
|
|
66
|
+
for key, value in payload.items()
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
optional = {
|
|
70
|
+
"agent_id": agent_id,
|
|
71
|
+
"workflow_id": workflow_id,
|
|
72
|
+
"parent_agent_id": parent_agent_id,
|
|
73
|
+
"task_id": task_id,
|
|
74
|
+
"trace_id": trace_id,
|
|
75
|
+
"span_id": span_id,
|
|
76
|
+
}
|
|
77
|
+
event.update({key: value for key, value in optional.items() if value is not None})
|
|
78
|
+
self._validator.validate(event)
|
|
79
|
+
return event
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _unix_nano(value: Any) -> str:
|
|
83
|
+
if isinstance(value, bool) or not isinstance(value, (int, str)):
|
|
84
|
+
raise TypeError("Unix nanosecond timestamps must be integers or decimal strings")
|
|
85
|
+
text = str(value)
|
|
86
|
+
if not text.isascii() or not text.isdecimal() or (len(text) > 1 and text.startswith("0")):
|
|
87
|
+
raise ValueError("Unix nanosecond timestamps must be canonical non-negative decimals")
|
|
88
|
+
if len(text) > 20:
|
|
89
|
+
raise ValueError("Unix nanosecond timestamps cannot exceed 20 digits")
|
|
90
|
+
return text
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Cedar authorization-response adapter with explicit safe fields."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Iterable
|
|
7
|
+
|
|
8
|
+
from .base import EventFactory
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def cedar_policy_decision(
|
|
12
|
+
factory: EventFactory,
|
|
13
|
+
*,
|
|
14
|
+
run_id: str,
|
|
15
|
+
agent_id: str,
|
|
16
|
+
decision: str,
|
|
17
|
+
cedar_version: str,
|
|
18
|
+
bundle_digest: dict[str, str],
|
|
19
|
+
action_type: str,
|
|
20
|
+
resource_type: str,
|
|
21
|
+
evaluation_duration_ns: int,
|
|
22
|
+
determining_policy_ids: Iterable[str] = (),
|
|
23
|
+
error_codes: Iterable[str] = (),
|
|
24
|
+
enforcement_mode: str = "enforce",
|
|
25
|
+
input_digest: dict[str, str] | None = None,
|
|
26
|
+
**envelope: Any,
|
|
27
|
+
) -> dict[str, Any]:
|
|
28
|
+
normalized = decision.lower()
|
|
29
|
+
if normalized not in {"allow", "deny"}:
|
|
30
|
+
raise ValueError("Cedar decision must be Allow or Deny")
|
|
31
|
+
policy_ids = _identifiers(determining_policy_ids, "determining_policy_ids")
|
|
32
|
+
errors = _identifiers(error_codes, "error_codes")
|
|
33
|
+
if any(re.fullmatch(r"[A-Za-z0-9_.:-]{1,128}", value) is None for value in errors):
|
|
34
|
+
raise ValueError("Cedar error_codes must be identifiers, not error messages")
|
|
35
|
+
reason_codes = [*(f"cedar.policy:{value}" for value in policy_ids), *(f"cedar.error:{value}" for value in errors)]
|
|
36
|
+
if len(reason_codes) > 32:
|
|
37
|
+
raise ValueError("Cedar reasons and errors exceed the 32-code contract limit")
|
|
38
|
+
policy: dict[str, Any] = {
|
|
39
|
+
"engine": "cedar",
|
|
40
|
+
"engine_version": cedar_version,
|
|
41
|
+
"bundle_digest": bundle_digest,
|
|
42
|
+
**({"policy_id": policy_ids[0]} if len(policy_ids) == 1 else {}),
|
|
43
|
+
**({"input_digest": input_digest} if input_digest else {}),
|
|
44
|
+
}
|
|
45
|
+
return factory.build(
|
|
46
|
+
"policy.decision",
|
|
47
|
+
run_id=run_id,
|
|
48
|
+
agent_id=agent_id,
|
|
49
|
+
decision=normalized,
|
|
50
|
+
policy=policy,
|
|
51
|
+
action_type=action_type,
|
|
52
|
+
resource_type=resource_type,
|
|
53
|
+
enforcement_mode=enforcement_mode,
|
|
54
|
+
evaluation_duration_ns=evaluation_duration_ns,
|
|
55
|
+
reason_codes=reason_codes,
|
|
56
|
+
**envelope,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _identifiers(values: Iterable[str], field: str) -> list[str]:
|
|
61
|
+
if isinstance(values, (str, bytes)):
|
|
62
|
+
raise ValueError(f"Cedar {field} must be an iterable of strings, not a string")
|
|
63
|
+
normalized = sorted(set(values))
|
|
64
|
+
if any(not isinstance(value, str) or not value for value in normalized):
|
|
65
|
+
raise ValueError(f"Cedar {field} must contain non-empty strings")
|
|
66
|
+
return normalized
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Open Policy Agent decision-log adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
from .base import EventFactory
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
DecisionMapper = Callable[[Any], str]
|
|
14
|
+
_OPA_EVENT_NAMESPACE = uuid.UUID("881f86d8-7573-4d35-98cb-c00b934cc04f")
|
|
15
|
+
_TIMESTAMP = re.compile(
|
|
16
|
+
r"^(?P<date>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(?P<fraction>\d{1,9}))?Z$"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def opa_decision_log(
|
|
21
|
+
factory: EventFactory,
|
|
22
|
+
decision_log: dict[str, Any],
|
|
23
|
+
*,
|
|
24
|
+
run_id: str,
|
|
25
|
+
agent_id: str,
|
|
26
|
+
action_type: str,
|
|
27
|
+
resource_type: str,
|
|
28
|
+
bundle_digest: dict[str, str],
|
|
29
|
+
opa_version: str | None = None,
|
|
30
|
+
enforcement_mode: str = "enforce",
|
|
31
|
+
result_mapper: DecisionMapper | None = None,
|
|
32
|
+
) -> dict[str, Any]:
|
|
33
|
+
source_id = decision_log.get("decision_id")
|
|
34
|
+
if not isinstance(source_id, str) or not source_id:
|
|
35
|
+
raise ValueError("OPA decision log requires a non-empty decision_id")
|
|
36
|
+
mapper = result_mapper or _boolean_decision
|
|
37
|
+
decision = mapper(decision_log.get("result"))
|
|
38
|
+
if decision not in {"allow", "deny", "challenge", "not_applicable", "error"}:
|
|
39
|
+
raise ValueError(f"OPA result mapper returned unsupported decision: {decision!r}")
|
|
40
|
+
labels = decision_log.get("labels", {})
|
|
41
|
+
if not isinstance(labels, dict):
|
|
42
|
+
raise ValueError("OPA labels must be an object")
|
|
43
|
+
version = opa_version or labels.get("version")
|
|
44
|
+
if not isinstance(version, str) or not version:
|
|
45
|
+
raise ValueError("OPA version is required explicitly or in labels.version")
|
|
46
|
+
metrics = decision_log.get("metrics", {})
|
|
47
|
+
if not isinstance(metrics, dict):
|
|
48
|
+
raise ValueError("OPA metrics must be an object")
|
|
49
|
+
duration = metrics.get("timer_rego_query_eval_ns", 0)
|
|
50
|
+
if not isinstance(duration, int) or isinstance(duration, bool) or duration < 0:
|
|
51
|
+
raise ValueError("OPA timer_rego_query_eval_ns must be a non-negative integer")
|
|
52
|
+
path = decision_log.get("path")
|
|
53
|
+
policy: dict[str, Any] = {
|
|
54
|
+
"engine": "opa",
|
|
55
|
+
"engine_version": version,
|
|
56
|
+
"bundle_digest": bundle_digest,
|
|
57
|
+
**({"policy_id": path.lstrip("/")} if isinstance(path, str) and path else {}),
|
|
58
|
+
}
|
|
59
|
+
ids = decision_log.get("ids", [])
|
|
60
|
+
if not isinstance(ids, list) or any(not isinstance(value, str) or not value for value in ids):
|
|
61
|
+
raise ValueError("OPA ids must be an array of non-empty strings")
|
|
62
|
+
if len(ids) > 32:
|
|
63
|
+
raise ValueError("OPA ids exceed the 32-code contract limit")
|
|
64
|
+
return factory.build(
|
|
65
|
+
"policy.decision",
|
|
66
|
+
run_id=run_id,
|
|
67
|
+
agent_id=agent_id,
|
|
68
|
+
event_id=str(uuid.uuid5(_OPA_EVENT_NAMESPACE, source_id)),
|
|
69
|
+
time_unix_nano=_timestamp_ns(decision_log["timestamp"])
|
|
70
|
+
if "timestamp" in decision_log
|
|
71
|
+
else None,
|
|
72
|
+
trace_id=decision_log.get("trace_id"),
|
|
73
|
+
span_id=decision_log.get("span_id"),
|
|
74
|
+
decision=decision,
|
|
75
|
+
policy=policy,
|
|
76
|
+
action_type=action_type,
|
|
77
|
+
resource_type=resource_type,
|
|
78
|
+
enforcement_mode=enforcement_mode,
|
|
79
|
+
evaluation_duration_ns=duration,
|
|
80
|
+
reason_codes=[f"opa.rule:{value}" for value in ids],
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _boolean_decision(result: Any) -> str:
|
|
85
|
+
if result is True:
|
|
86
|
+
return "allow"
|
|
87
|
+
if result is False:
|
|
88
|
+
return "deny"
|
|
89
|
+
if result is None:
|
|
90
|
+
return "not_applicable"
|
|
91
|
+
raise ValueError("OPA non-boolean result requires an explicit result_mapper")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _timestamp_ns(value: Any) -> int:
|
|
95
|
+
if not isinstance(value, str):
|
|
96
|
+
raise ValueError("OPA timestamp must be an RFC 3339 UTC string")
|
|
97
|
+
match = _TIMESTAMP.fullmatch(value)
|
|
98
|
+
if match is None:
|
|
99
|
+
raise ValueError("OPA timestamp must use RFC 3339 UTC form ending in Z")
|
|
100
|
+
base = datetime.fromisoformat(match.group("date")).replace(tzinfo=timezone.utc)
|
|
101
|
+
fraction = (match.group("fraction") or "").ljust(9, "0")
|
|
102
|
+
return int(base.timestamp()) * 1_000_000_000 + int(fraction or "0")
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Validated, caller-owned telemetry emission."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Callable, Protocol
|
|
8
|
+
|
|
9
|
+
from .context import ContextIds, active_context_ids, current_span
|
|
10
|
+
from .errors import ContextMismatchError
|
|
11
|
+
from .projection import log_record, span_attributes
|
|
12
|
+
from .validation import SchemaValidator
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SpanLike(Protocol):
|
|
16
|
+
def add_event(self, name: str, attributes: dict[str, Any], timestamp: int) -> None: ...
|
|
17
|
+
def get_span_context(self) -> Any: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LogEmitter(Protocol):
|
|
21
|
+
def emit(self, record: dict[str, Any]) -> None: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EvidenceSink(Protocol):
|
|
25
|
+
def append(self, event: dict[str, Any]) -> Any: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MetricEmitter(Protocol):
|
|
29
|
+
def emit(self, event: dict[str, Any]) -> bool: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class EmitResult:
|
|
34
|
+
accepted: bool
|
|
35
|
+
span_event_emitted: bool
|
|
36
|
+
log_emitted: bool
|
|
37
|
+
context: ContextIds | None
|
|
38
|
+
projection_errors: tuple[str, ...] = ()
|
|
39
|
+
evidence_persisted: bool = False
|
|
40
|
+
metrics_emitted: bool = False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TelemetryClient:
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
validator: SchemaValidator,
|
|
47
|
+
*,
|
|
48
|
+
span_resolver: Callable[[], SpanLike | None] | None = None,
|
|
49
|
+
log_emitter: LogEmitter | None = None,
|
|
50
|
+
evidence_sink: EvidenceSink | None = None,
|
|
51
|
+
metric_emitter: MetricEmitter | None = None,
|
|
52
|
+
):
|
|
53
|
+
self._validator = validator
|
|
54
|
+
self._span_resolver = span_resolver
|
|
55
|
+
self._log_emitter = log_emitter
|
|
56
|
+
self._evidence_sink = evidence_sink
|
|
57
|
+
self._metric_emitter = metric_emitter
|
|
58
|
+
|
|
59
|
+
def emit(self, event: dict[str, Any]) -> EmitResult:
|
|
60
|
+
self._validator.validate(event)
|
|
61
|
+
resolver = self._span_resolver or current_span
|
|
62
|
+
span = resolver()
|
|
63
|
+
context = active_context_ids(lambda: span)
|
|
64
|
+
if context:
|
|
65
|
+
if event.get("trace_id") not in (None, context.trace_id):
|
|
66
|
+
raise ContextMismatchError("event trace_id disagrees with active span")
|
|
67
|
+
if event.get("span_id") not in (None, context.span_id):
|
|
68
|
+
raise ContextMismatchError("event span_id disagrees with active span")
|
|
69
|
+
|
|
70
|
+
evidence_persisted = False
|
|
71
|
+
if self._evidence_sink is not None:
|
|
72
|
+
# Evidence is accepted after correlation validation and before any
|
|
73
|
+
# best-effort operational projection.
|
|
74
|
+
self._evidence_sink.append(deepcopy(event))
|
|
75
|
+
evidence_persisted = True
|
|
76
|
+
|
|
77
|
+
errors: list[str] = []
|
|
78
|
+
span_emitted = False
|
|
79
|
+
if span is not None and context is not None:
|
|
80
|
+
try:
|
|
81
|
+
span.add_event(
|
|
82
|
+
event["event_type"],
|
|
83
|
+
attributes=span_attributes(event),
|
|
84
|
+
timestamp=int(event["time_unix_nano"]),
|
|
85
|
+
)
|
|
86
|
+
span_emitted = True
|
|
87
|
+
except Exception as exc: # exporter implementations are external
|
|
88
|
+
errors.append(f"span projection failed: {type(exc).__name__}: {exc}")
|
|
89
|
+
|
|
90
|
+
log_emitted = False
|
|
91
|
+
if self._log_emitter is not None:
|
|
92
|
+
try:
|
|
93
|
+
self._log_emitter.emit(log_record(event, context))
|
|
94
|
+
log_emitted = True
|
|
95
|
+
except Exception as exc: # exporter implementations are external
|
|
96
|
+
errors.append(f"log projection failed: {type(exc).__name__}: {exc}")
|
|
97
|
+
|
|
98
|
+
metrics_emitted = False
|
|
99
|
+
if self._metric_emitter is not None:
|
|
100
|
+
try:
|
|
101
|
+
metrics_emitted = self._metric_emitter.emit(deepcopy(event))
|
|
102
|
+
except Exception as exc:
|
|
103
|
+
errors.append(f"metric projection failed: {type(exc).__name__}: {exc}")
|
|
104
|
+
|
|
105
|
+
return EmitResult(
|
|
106
|
+
True,
|
|
107
|
+
span_emitted,
|
|
108
|
+
log_emitted,
|
|
109
|
+
context,
|
|
110
|
+
tuple(errors),
|
|
111
|
+
evidence_persisted,
|
|
112
|
+
metrics_emitted,
|
|
113
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Optional OpenTelemetry context integration without global configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ContextIds:
|
|
11
|
+
trace_id: str
|
|
12
|
+
span_id: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def current_span() -> Any | None:
|
|
16
|
+
try:
|
|
17
|
+
from opentelemetry import trace
|
|
18
|
+
except ImportError:
|
|
19
|
+
return None
|
|
20
|
+
return trace.get_current_span()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def active_context_ids(span_resolver: Callable[[], Any | None] = current_span) -> ContextIds | None:
|
|
24
|
+
span = span_resolver()
|
|
25
|
+
if span is None:
|
|
26
|
+
return None
|
|
27
|
+
context = span.get_span_context()
|
|
28
|
+
if not getattr(context, "is_valid", False):
|
|
29
|
+
return None
|
|
30
|
+
return ContextIds(trace_id=f"{context.trace_id:032x}", span_id=f"{context.span_id:016x}")
|