openwright-core 0.6.0__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.
Files changed (57) hide show
  1. openwright/__init__.py +20 -0
  2. openwright/_ed25519_pure.py +98 -0
  3. openwright/adapters/__init__.py +37 -0
  4. openwright/adapters/a2a.py +71 -0
  5. openwright/adapters/base.py +24 -0
  6. openwright/adapters/langfuse.py +56 -0
  7. openwright/adapters/otel_genai.py +180 -0
  8. openwright/adapters/policy.py +63 -0
  9. openwright/adapters/receipt.py +198 -0
  10. openwright/adapters/sarif_in.py +68 -0
  11. openwright/anchor.py +134 -0
  12. openwright/authz.py +68 -0
  13. openwright/browser_verifier.py +197 -0
  14. openwright/canonical.py +119 -0
  15. openwright/checkpoint_store.py +140 -0
  16. openwright/cli.py +445 -0
  17. openwright/connectors/__init__.py +236 -0
  18. openwright/connectors/builtin.py +88 -0
  19. openwright/crosswalk.py +372 -0
  20. openwright/crosswalk_loader.py +53 -0
  21. openwright/crosswalks/CHANGELOG.md +47 -0
  22. openwright/crosswalks/__init__.py +7 -0
  23. openwright/crosswalks/eu_ai_act.yaml +294 -0
  24. openwright/crosswalks/eu_ai_act_v1.yaml +107 -0
  25. openwright/crosswalks/gdpr.yaml +304 -0
  26. openwright/crosswalks/iso_42001.yaml +209 -0
  27. openwright/crosswalks/nist_ai_rmf.yaml +204 -0
  28. openwright/crosswalks/soc2.yaml +246 -0
  29. openwright/dashboard.py +100 -0
  30. openwright/demo.py +270 -0
  31. openwright/events.py +210 -0
  32. openwright/identity.py +61 -0
  33. openwright/ingest/__init__.py +13 -0
  34. openwright/ingest/durable.py +144 -0
  35. openwright/ingest/fanout.py +38 -0
  36. openwright/ingest/grpc_server.py +44 -0
  37. openwright/ingest/http_server.py +163 -0
  38. openwright/ingest/otlp_common.py +69 -0
  39. openwright/ingest/pipeline.py +307 -0
  40. openwright/ledger.py +479 -0
  41. openwright/merkle.py +262 -0
  42. openwright/report.py +388 -0
  43. openwright/sbom.py +42 -0
  44. openwright/scheduler.py +97 -0
  45. openwright/sdk.py +297 -0
  46. openwright/signing.py +343 -0
  47. openwright/spec.py +109 -0
  48. openwright/vault.py +55 -0
  49. openwright/verify.py +392 -0
  50. openwright/web_demo.py +275 -0
  51. openwright/witness.py +90 -0
  52. openwright/witness_service.py +136 -0
  53. openwright_core-0.6.0.dist-info/METADATA +174 -0
  54. openwright_core-0.6.0.dist-info/RECORD +57 -0
  55. openwright_core-0.6.0.dist-info/WHEEL +4 -0
  56. openwright_core-0.6.0.dist-info/entry_points.txt +3 -0
  57. openwright_core-0.6.0.dist-info/licenses/LICENSE +52 -0
openwright/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """OpenWright — the Agent Evidence Layer.
2
+
3
+ OpenWright turns the runtime behavior of AI agents into signed, tamper-evident,
4
+ control-mapped audit evidence: telemetry/SDK signals → a canonical
5
+ ``ComplianceEvent`` → declarative regulatory crosswalks → an append-only Merkle
6
+ log → signed reports → independent verification.
7
+
8
+ Boundary (non-negotiable, see FR-RPT-07 / NFR-COMP-01): OpenWright produces
9
+ *evidence that controls were exercised*. It does NOT assert or imply legal
10
+ compliance, certification, or fitness. Those are judgments reserved for
11
+ qualified auditors and counsel.
12
+ """
13
+
14
+ __version__ = "0.6.0"
15
+
16
+ # Schema version for the canonical event model. Bumped independently of the
17
+ # package version; readers MUST tolerate unknown future fields (DR-04).
18
+ COMPLIANCE_EVENT_SCHEMA_VERSION = "1.0.0"
19
+
20
+ __all__ = ["__version__", "COMPLIANCE_EVENT_SCHEMA_VERSION"]
@@ -0,0 +1,98 @@
1
+ """Pure-Python Ed25519 signature *verification* (RFC 8032).
2
+
3
+ Used as a fallback by the verifier when the ``cryptography`` package is not
4
+ available — e.g. when running the verifier inside a stock WASM Python (Pyodide)
5
+ in the browser (FR-VER-04 [P2]). With this fallback the verifier needs **zero
6
+ third-party dependencies**, only the standard library.
7
+
8
+ This implements verification only (never signing); it follows the cofactorless
9
+ group-equation check ``[S]B == R + [k]A`` from RFC 8032 §5.1.7, which accepts all
10
+ signatures produced by a compliant signer (e.g. ``cryptography``). It is not
11
+ constant-time, which is fine — verification uses only public data.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+
18
+ _P = 2**255 - 19
19
+ _L = 2**252 + 27742317777372353535851937790883648493
20
+
21
+
22
+ def _inv(x: int) -> int:
23
+ return pow(x, _P - 2, _P)
24
+
25
+
26
+ _D = (-121665 * _inv(121666)) % _P
27
+ _I = pow(2, (_P - 1) // 4, _P)
28
+
29
+
30
+ def _xrecover(y: int) -> int:
31
+ xx = (y * y - 1) * _inv(_D * y * y + 1)
32
+ x = pow(xx, (_P + 3) // 8, _P)
33
+ if (x * x - xx) % _P != 0:
34
+ x = (x * _I) % _P
35
+ if x % 2 != 0:
36
+ x = _P - x
37
+ return x
38
+
39
+
40
+ _BY = (4 * _inv(5)) % _P
41
+ _BX = _xrecover(_BY) % _P
42
+ _B = (_BX, _BY)
43
+
44
+
45
+ def _edwards_add(p1, p2):
46
+ x1, y1 = p1
47
+ x2, y2 = p2
48
+ denom = _inv(1 + _D * x1 * x2 * y1 * y2)
49
+ x3 = (x1 * y2 + x2 * y1) * denom % _P
50
+ denom2 = _inv(1 - _D * x1 * x2 * y1 * y2)
51
+ y3 = (y1 * y2 + x1 * x2) * denom2 % _P
52
+ return (x3, y3)
53
+
54
+
55
+ def _scalarmult(point, e: int):
56
+ result = (0, 1) # neutral element
57
+ addend = point
58
+ while e > 0:
59
+ if e & 1:
60
+ result = _edwards_add(result, addend)
61
+ addend = _edwards_add(addend, addend)
62
+ e >>= 1
63
+ return result
64
+
65
+
66
+ def _is_on_curve(point) -> bool:
67
+ x, y = point
68
+ return (-x * x + y * y - 1 - _D * x * x * y * y) % _P == 0
69
+
70
+
71
+ def _decodepoint(s: bytes):
72
+ val = int.from_bytes(s, "little")
73
+ y = val & ((1 << 255) - 1)
74
+ x = _xrecover(y)
75
+ if (x & 1) != ((val >> 255) & 1):
76
+ x = _P - x
77
+ point = (x, y)
78
+ if not _is_on_curve(point):
79
+ raise ValueError("point not on curve")
80
+ return point
81
+
82
+
83
+ def verify(public_key: bytes, signature: bytes, message: bytes) -> bool:
84
+ """Return True iff ``signature`` is a valid Ed25519 signature of ``message``."""
85
+ if len(signature) != 64 or len(public_key) != 32:
86
+ return False
87
+ try:
88
+ r_point = _decodepoint(signature[:32])
89
+ a_point = _decodepoint(public_key)
90
+ except (ValueError, Exception): # noqa: BLE001
91
+ return False
92
+ s = int.from_bytes(signature[32:], "little")
93
+ if s >= _L:
94
+ return False
95
+ h = int.from_bytes(hashlib.sha512(signature[:32] + public_key + message).digest(), "little") % _L
96
+ sb = _scalarmult(_B, s)
97
+ ha = _scalarmult(a_point, h)
98
+ return sb == _edwards_add(r_point, ha)
@@ -0,0 +1,37 @@
1
+ """Source-format adapters (FR-NRM-03).
2
+
3
+ Each adapter isolates one upstream convention so that a change there (e.g. the
4
+ OTel GenAI conventions leaving experimental status — C-01) requires editing only
5
+ that adapter, never the core or the canonical event schema. Adapters are
6
+ deliberately proto-agnostic: they consume plain Python dicts, so the ingest
7
+ layer owns OTLP/protobuf decoding.
8
+ """
9
+
10
+ from .base import SpanData
11
+ from .otel_genai import span_to_event
12
+ from .a2a import reconstruct_provenance
13
+ from .langfuse import apply_langfuse_precedence, has_langfuse_attributes
14
+ from .sarif_in import sarif_to_events
15
+ from .policy import policy_decisions_to_events
16
+ from .receipt import (
17
+ SignedActionReceiptSource,
18
+ ReceiptSource,
19
+ ReceiptVerificationError,
20
+ receipt_to_event,
21
+ sign_receipt,
22
+ )
23
+
24
+ __all__ = [
25
+ "SpanData",
26
+ "span_to_event",
27
+ "reconstruct_provenance",
28
+ "apply_langfuse_precedence",
29
+ "has_langfuse_attributes",
30
+ "sarif_to_events",
31
+ "policy_decisions_to_events",
32
+ "SignedActionReceiptSource",
33
+ "ReceiptSource",
34
+ "ReceiptVerificationError",
35
+ "receipt_to_event",
36
+ "sign_receipt",
37
+ ]
@@ -0,0 +1,71 @@
1
+ """A2A task-provenance reconstruction (FR-ING-05).
2
+
3
+ Verified against the A2A spec (v0.3.0): a ``Task`` has ``id`` and ``contextId``
4
+ but NO parent-task field; cross-task lineage is expressed softly via
5
+ ``Message.referenceTaskIds`` and grouping via ``contextId``. OpenWright therefore
6
+ *reconstructs* a parent/root chain from those signals — the ``parent_task_id``
7
+ and ``root_task_id`` on a :class:`Provenance` are OpenWright-derived, not native
8
+ A2A fields. Absent A2A, provenance degrades to single-event records (A-02).
9
+
10
+ All A2A-origin data is treated as untrusted input (FR-ING-10): IDs are used only
11
+ as opaque correlation keys, never interpreted or executed.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Dict, List, Optional
17
+
18
+ from ..events import Provenance
19
+
20
+
21
+ def reconstruct_provenance(
22
+ task_id: Optional[str],
23
+ context_id: Optional[str] = None,
24
+ reference_task_ids: Optional[List[str]] = None,
25
+ known: Optional[Dict[str, Provenance]] = None,
26
+ ) -> Provenance:
27
+ """Reconstruct parent/root for ``task_id`` from A2A signals.
28
+
29
+ ``known`` maps already-seen task IDs to their reconstructed provenance, used
30
+ to walk the root chain. ``reference_task_ids`` (from the triggering Message)
31
+ yields the parent; the root is the parent's root, or this task if it has none.
32
+ """
33
+ known = known or {}
34
+ parent_task_id = reference_task_ids[0] if reference_task_ids else None
35
+
36
+ if parent_task_id and parent_task_id in known and known[parent_task_id].root_task_id:
37
+ root_task_id = known[parent_task_id].root_task_id
38
+ elif parent_task_id:
39
+ root_task_id = parent_task_id # parent unseen; best-effort root = parent
40
+ else:
41
+ root_task_id = task_id # no parent → this task is its own root
42
+
43
+ return Provenance(
44
+ task_id=task_id,
45
+ context_id=context_id,
46
+ parent_task_id=parent_task_id,
47
+ root_task_id=root_task_id,
48
+ )
49
+
50
+
51
+ # Attribute keys some instrumentations use to carry A2A identifiers on spans.
52
+ A2A_TASK_ID = "a2a.task.id"
53
+ A2A_CONTEXT_ID = "a2a.context.id"
54
+ A2A_REFERENCE_TASK_IDS = "a2a.reference_task_ids"
55
+
56
+
57
+ def provenance_from_attributes(
58
+ attrs: Dict[str, object], known: Optional[Dict[str, Provenance]] = None
59
+ ) -> Optional[Provenance]:
60
+ """Reconstruct provenance from A2A identifiers carried as span attributes."""
61
+ task_id = attrs.get(A2A_TASK_ID)
62
+ if not task_id:
63
+ return None
64
+ refs = attrs.get(A2A_REFERENCE_TASK_IDS)
65
+ ref_list = list(refs) if isinstance(refs, (list, tuple)) else ([refs] if refs else None)
66
+ return reconstruct_provenance(
67
+ str(task_id),
68
+ context_id=str(attrs[A2A_CONTEXT_ID]) if attrs.get(A2A_CONTEXT_ID) else None,
69
+ reference_task_ids=[str(r) for r in ref_list] if ref_list else None,
70
+ known=known,
71
+ )
@@ -0,0 +1,24 @@
1
+ """Shared adapter input types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict, Optional
7
+
8
+
9
+ @dataclass
10
+ class SpanData:
11
+ """A telemetry span reduced to plain Python (no protobuf types).
12
+
13
+ The ingest layer fills this from OTLP; adapters consume it. Keeping adapters
14
+ free of protobuf imports is what makes them swappable (FR-NRM-03).
15
+ """
16
+
17
+ name: str
18
+ attributes: Dict[str, Any] = field(default_factory=dict)
19
+ resource: Dict[str, Any] = field(default_factory=dict)
20
+ trace_id: Optional[str] = None
21
+ span_id: Optional[str] = None
22
+ parent_span_id: Optional[str] = None
23
+ start_time_unix_nano: Optional[int] = None
24
+ status: Optional[str] = None
@@ -0,0 +1,56 @@
1
+ """Langfuse ``langfuse.*`` attribute precedence (FR-ING-06).
2
+
3
+ Langfuse documents that its ``langfuse.*`` namespace "always take[s] precedence
4
+ over the generic OpenTelemetry conventions" (langfuse.com/integrations/native/
5
+ opentelemetry, verified 2026-05). This adapter overlays those values onto an
6
+ event already normalized from generic ``gen_ai.*`` attributes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Dict
12
+
13
+ from ..canonical import hash_payload
14
+ from ..events import ComplianceEvent, IORef, ModelInfo
15
+
16
+ LF_SESSION_ID = "langfuse.session.id"
17
+ LF_USER_ID = "langfuse.user.id"
18
+ LF_ENVIRONMENT = "langfuse.environment"
19
+ LF_OBS_MODEL = "langfuse.observation.model.name"
20
+ LF_OBS_INPUT = "langfuse.observation.input"
21
+ LF_OBS_OUTPUT = "langfuse.observation.output"
22
+ LF_OBS_LEVEL = "langfuse.observation.level"
23
+ LF_OBS_TYPE = "langfuse.observation.type"
24
+ LF_TRACE_NAME = "langfuse.trace.name"
25
+
26
+
27
+ def has_langfuse_attributes(attrs: Dict[str, Any]) -> bool:
28
+ return any(k.startswith("langfuse.") for k in attrs)
29
+
30
+
31
+ def apply_langfuse_precedence(event: ComplianceEvent, attrs: Dict[str, Any]) -> ComplianceEvent:
32
+ """Mutate ``event`` so langfuse.* values win over generic OTel values."""
33
+ if not has_langfuse_attributes(attrs):
34
+ return event
35
+
36
+ # Session id groups related observations → use as context if present.
37
+ if attrs.get(LF_SESSION_ID):
38
+ event.provenance.context_id = str(attrs[LF_SESSION_ID])
39
+
40
+ if attrs.get(LF_OBS_MODEL):
41
+ event.model = event.model or ModelInfo()
42
+ event.model.request_model = str(attrs[LF_OBS_MODEL])
43
+
44
+ if attrs.get(LF_OBS_INPUT) is not None or attrs.get(LF_OBS_OUTPUT) is not None:
45
+ event.io = event.io or IORef()
46
+ if attrs.get(LF_OBS_INPUT) is not None:
47
+ event.io.input_ref = hash_payload(attrs[LF_OBS_INPUT])
48
+ if attrs.get(LF_OBS_OUTPUT) is not None:
49
+ event.io.output_ref = hash_payload(attrs[LF_OBS_OUTPUT])
50
+
51
+ for key, label in ((LF_USER_ID, "user_id"), (LF_ENVIRONMENT, "environment"), (LF_OBS_LEVEL, "level"), (LF_OBS_TYPE, "observation_type"), (LF_TRACE_NAME, "trace_name")):
52
+ if attrs.get(key) is not None:
53
+ event.attributes[f"langfuse.{label}"] = str(attrs[key])
54
+
55
+ event.attributes["langfuse_precedence_applied"] = True
56
+ return event
@@ -0,0 +1,180 @@
1
+ """OpenTelemetry GenAI semantic-convention adapter (FR-ING-03, FR-NRM-01).
2
+
3
+ All attribute names live in this module so a future rename is a one-line change
4
+ (FR-NRM-03; the conventions are still Experimental/"Development" — C-01). Both
5
+ the current names and their deprecated predecessors are accepted, since
6
+ instrumentations that have not opted into ``gen_ai_latest_experimental`` still
7
+ emit the old names (verified against opentelemetry.io, 2026-05). When both are
8
+ present the current name wins.
9
+
10
+ There is NO ``gen_ai.usage.cost`` attribute in the conventions; cost is derived
11
+ downstream from token counts and an operator-supplied pricing table, or omitted.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ from ..canonical import hash_payload
19
+ from ..events import ComplianceEvent, EventKind, IORef, ModelInfo, ToolInfo
20
+ from .base import SpanData
21
+
22
+ # -- attribute names (single source of truth) ---------------------------------
23
+ A_OPERATION = "gen_ai.operation.name"
24
+ A_PROVIDER = "gen_ai.provider.name"
25
+ A_PROVIDER_LEGACY = "gen_ai.system"
26
+ A_REQUEST_MODEL = "gen_ai.request.model"
27
+ A_RESPONSE_MODEL = "gen_ai.response.model"
28
+ A_INPUT_TOKENS = "gen_ai.usage.input_tokens"
29
+ A_INPUT_TOKENS_LEGACY = "gen_ai.usage.prompt_tokens"
30
+ A_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
31
+ A_OUTPUT_TOKENS_LEGACY = "gen_ai.usage.completion_tokens"
32
+ A_FINISH_REASONS = "gen_ai.response.finish_reasons"
33
+ A_TOOL_NAME = "gen_ai.tool.name"
34
+ A_TOOL_CALL_ID = "gen_ai.tool.call.id"
35
+ A_TOOL_TYPE = "gen_ai.tool.type"
36
+ A_CONVERSATION_ID = "gen_ai.conversation.id"
37
+ A_AGENT_NAME = "gen_ai.agent.name"
38
+ # Deprecated/removed content attributes — hashed if present, never stored raw.
39
+ A_PROMPT_LEGACY = "gen_ai.prompt"
40
+ A_COMPLETION_LEGACY = "gen_ai.completion"
41
+
42
+ _OPERATION_TO_KIND = {
43
+ "chat": EventKind.LLM_CALL,
44
+ "generate_content": EventKind.LLM_CALL,
45
+ "text_completion": EventKind.LLM_CALL,
46
+ "embeddings": EventKind.LLM_CALL,
47
+ "execute_tool": EventKind.TOOL_CALL,
48
+ "invoke_agent": EventKind.AGENT_DECISION,
49
+ "create_agent": EventKind.AGENT_DECISION,
50
+ "invoke_workflow": EventKind.AGENT_DECISION,
51
+ }
52
+
53
+
54
+ def _first(attrs: Dict[str, Any], *keys: str) -> Optional[Any]:
55
+ for k in keys:
56
+ if k in attrs and attrs[k] is not None:
57
+ return attrs[k]
58
+ return None
59
+
60
+
61
+ def _coerce_int(value: Any) -> Optional[int]:
62
+ if value is None:
63
+ return None
64
+ try:
65
+ return int(value)
66
+ except (TypeError, ValueError):
67
+ return None
68
+
69
+
70
+ def _coerce_str_list(value: Any) -> Optional[List[str]]:
71
+ if value is None:
72
+ return None
73
+ if isinstance(value, (list, tuple)):
74
+ return [str(v) for v in value]
75
+ return [str(value)] # tolerate a scalar emitted instead of a 1-element array
76
+
77
+
78
+ def detected_semconv(attrs: Dict[str, Any]) -> str:
79
+ """Best-effort tag of which convention generation a span follows."""
80
+ if A_PROVIDER in attrs or A_INPUT_TOKENS in attrs:
81
+ return "gen_ai/latest"
82
+ if A_PROVIDER_LEGACY in attrs or A_INPUT_TOKENS_LEGACY in attrs:
83
+ return "gen_ai/legacy"
84
+ return "gen_ai/unknown"
85
+
86
+
87
+ def span_to_event(
88
+ span: SpanData,
89
+ *,
90
+ timestamp: str,
91
+ agent_id: Optional[str] = None,
92
+ pricing: Optional[Dict[str, Any]] = None,
93
+ ) -> ComplianceEvent:
94
+ """Normalize one GenAI span into a canonical :class:`ComplianceEvent`."""
95
+ attrs = span.attributes or {}
96
+ operation = attrs.get(A_OPERATION)
97
+ kind = _OPERATION_TO_KIND.get(operation, EventKind.GENERIC)
98
+
99
+ resolved_agent = (
100
+ agent_id
101
+ or attrs.get(A_AGENT_NAME)
102
+ or span.resource.get("service.name")
103
+ or "unknown-agent"
104
+ )
105
+
106
+ input_tokens = _coerce_int(_first(attrs, A_INPUT_TOKENS, A_INPUT_TOKENS_LEGACY))
107
+ output_tokens = _coerce_int(_first(attrs, A_OUTPUT_TOKENS, A_OUTPUT_TOKENS_LEGACY))
108
+ total_tokens = (input_tokens or 0) + (output_tokens or 0) if (input_tokens or output_tokens) else None
109
+
110
+ # Legacy raw content, if present, is hashed — never stored inline (FR-LED-06).
111
+ prompt = attrs.get(A_PROMPT_LEGACY)
112
+ completion = attrs.get(A_COMPLETION_LEGACY)
113
+
114
+ cost = None
115
+ if pricing and (input_tokens is not None or output_tokens is not None):
116
+ cost = _compute_cost(pricing, _first(attrs, A_REQUEST_MODEL, A_RESPONSE_MODEL), input_tokens, output_tokens)
117
+
118
+ io = IORef(
119
+ input_ref=hash_payload(prompt) if prompt is not None else None,
120
+ output_ref=hash_payload(completion) if completion is not None else None,
121
+ input_tokens=input_tokens,
122
+ output_tokens=output_tokens,
123
+ total_tokens=total_tokens,
124
+ cost=cost,
125
+ cost_currency="USD" if cost else None,
126
+ )
127
+ model = ModelInfo(
128
+ provider=_first(attrs, A_PROVIDER, A_PROVIDER_LEGACY),
129
+ request_model=attrs.get(A_REQUEST_MODEL),
130
+ response_model=attrs.get(A_RESPONSE_MODEL),
131
+ )
132
+ tool = None
133
+ if attrs.get(A_TOOL_NAME) or attrs.get(A_TOOL_CALL_ID):
134
+ tool = ToolInfo(name=attrs.get(A_TOOL_NAME), call_id=attrs.get(A_TOOL_CALL_ID))
135
+
136
+ extra: Dict[str, Any] = {}
137
+ if operation:
138
+ extra["operation"] = operation
139
+ finish = _coerce_str_list(attrs.get(A_FINISH_REASONS))
140
+ if finish:
141
+ extra["finish_reasons"] = finish
142
+ if attrs.get(A_TOOL_TYPE):
143
+ extra["tool_type"] = attrs[A_TOOL_TYPE]
144
+ extra["semconv"] = detected_semconv(attrs)
145
+
146
+ conversation_id = attrs.get(A_CONVERSATION_ID)
147
+
148
+ return ComplianceEvent(
149
+ timestamp=timestamp,
150
+ kind=kind,
151
+ actor={"agent_id": resolved_agent},
152
+ provenance={"context_id": conversation_id} if conversation_id else {},
153
+ io=io if any(v is not None for v in io.model_dump().values()) else None,
154
+ model=model if any(model.model_dump().values()) else None,
155
+ tool=tool,
156
+ attributes=extra,
157
+ source={
158
+ "format": "otel-genai",
159
+ "span_id": span.span_id,
160
+ "trace_id": span.trace_id,
161
+ },
162
+ )
163
+
164
+
165
+ def _compute_cost(
166
+ pricing: Dict[str, Any], model: Optional[str], in_tok: Optional[int], out_tok: Optional[int]
167
+ ) -> Optional[str]:
168
+ """Derive a cost as a decimal string (no floats in the event — FR-NRM-04)."""
169
+ from decimal import Decimal
170
+
171
+ rates = pricing.get(model) if model else None
172
+ rates = rates or pricing.get("default")
173
+ if not rates:
174
+ return None
175
+ cost = Decimal(0)
176
+ if in_tok:
177
+ cost += Decimal(str(rates.get("input_per_1k", 0))) * Decimal(in_tok) / Decimal(1000)
178
+ if out_tok:
179
+ cost += Decimal(str(rates.get("output_per_1k", 0))) * Decimal(out_tok) / Decimal(1000)
180
+ return f"{cost:.6f}"
@@ -0,0 +1,63 @@
1
+ """Policy-engine decision ingest (FR-ING-09): OPA and Cedar → policy.decision events.
2
+
3
+ Consumes decision logs from Open Policy Agent (OPA) and Amazon Cedar and records
4
+ them as ``policy_decision`` events. We consume the engines' output; we do not
5
+ re-implement policy evaluation. All input is treated as untrusted (FR-ING-10):
6
+ parsing is defensive and malformed entries are skipped, not fatal.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from ..events import ComplianceEvent, EventKind
14
+
15
+
16
+ def _opa_entry(entry: Dict[str, Any]) -> Dict[str, Any]:
17
+ result = entry.get("result")
18
+ allowed = result.get("allow") if isinstance(result, dict) else result
19
+ return {
20
+ "engine": "opa",
21
+ "decision_id": entry.get("decision_id"),
22
+ "policy": entry.get("path"),
23
+ "decision": "allow" if allowed else "deny",
24
+ "result": result if not isinstance(result, dict) else None,
25
+ }
26
+
27
+
28
+ def _cedar_entry(entry: Dict[str, Any]) -> Dict[str, Any]:
29
+ decision = entry.get("decision")
30
+ diagnostics = entry.get("diagnostics", {}) if isinstance(entry.get("diagnostics"), dict) else {}
31
+ return {
32
+ "engine": "cedar",
33
+ "decision": str(decision).lower() if decision else None,
34
+ "determining_policies": diagnostics.get("reason"),
35
+ "errors": diagnostics.get("errors"),
36
+ }
37
+
38
+
39
+ def policy_decisions_to_events(
40
+ entries: List[Dict[str, Any]],
41
+ *,
42
+ agent_id: str,
43
+ timestamp: str,
44
+ engine: str = "opa",
45
+ task_id: Optional[str] = None,
46
+ ) -> List[ComplianceEvent]:
47
+ parse = _opa_entry if engine == "opa" else _cedar_entry
48
+ events: List[ComplianceEvent] = []
49
+ for entry in entries:
50
+ if not isinstance(entry, dict):
51
+ continue
52
+ attrs = {k: v for k, v in parse(entry).items() if v is not None}
53
+ events.append(
54
+ ComplianceEvent(
55
+ timestamp=entry.get("timestamp") or timestamp,
56
+ kind=EventKind.POLICY_DECISION,
57
+ actor={"agent_id": agent_id},
58
+ provenance={"task_id": task_id} if task_id else {},
59
+ attributes=attrs,
60
+ source={"format": f"policy-{engine}"},
61
+ )
62
+ )
63
+ return events