openbox-sdk-python 0.2.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 (52) hide show
  1. openbox_core/__init__.py +59 -0
  2. openbox_core/adapters/__init__.py +3 -0
  3. openbox_core/adapters/base.py +123 -0
  4. openbox_core/approvals.py +106 -0
  5. openbox_core/client.py +298 -0
  6. openbox_core/config.py +260 -0
  7. openbox_core/conformance/__init__.py +3 -0
  8. openbox_core/conformance/fake_core.py +169 -0
  9. openbox_core/conformance/hook_preflight.py +87 -0
  10. openbox_core/conformance/instrumentation.py +91 -0
  11. openbox_core/context.py +205 -0
  12. openbox_core/contracts/__init__.py +3 -0
  13. openbox_core/contracts/context.py +79 -0
  14. openbox_core/contracts/events.py +401 -0
  15. openbox_core/contracts/otel_spans.py +325 -0
  16. openbox_core/contracts/results.py +287 -0
  17. openbox_core/errors.py +287 -0
  18. openbox_core/gate.py +185 -0
  19. openbox_core/hooks/__init__.py +3 -0
  20. openbox_core/hooks/events.py +64 -0
  21. openbox_core/hooks/preflight.py +292 -0
  22. openbox_core/hooks/wrappers.py +105 -0
  23. openbox_core/identity.py +231 -0
  24. openbox_core/instrumentation/__init__.py +3 -0
  25. openbox_core/instrumentation/db.py +689 -0
  26. openbox_core/instrumentation/file.py +239 -0
  27. openbox_core/instrumentation/function.py +121 -0
  28. openbox_core/instrumentation/http.py +840 -0
  29. openbox_core/instrumentation/llm.py +3 -0
  30. openbox_core/instrumentation/manager.py +135 -0
  31. openbox_core/instrumentation/shared.py +27 -0
  32. openbox_core/otel/__init__.py +3 -0
  33. openbox_core/otel/propagation.py +45 -0
  34. openbox_core/otel/provider.py +35 -0
  35. openbox_core/otel/setup.py +36 -0
  36. openbox_core/otel/span_processor.py +62 -0
  37. openbox_core/otel/trace_context.py +71 -0
  38. openbox_core/py.typed +0 -0
  39. openbox_core/runtime.py +138 -0
  40. openbox_core/sdk_version.py +79 -0
  41. openbox_core/serialization.py +129 -0
  42. openbox_core/validation/__init__.py +3 -0
  43. openbox_core/validation/diagnostics.py +60 -0
  44. openbox_core/validation/event_rules.py +164 -0
  45. openbox_core/validation/registry.py +31 -0
  46. openbox_core/validation/span_normalization.py +107 -0
  47. openbox_core/wire/__init__.py +3 -0
  48. openbox_core/wire/core_span.py +130 -0
  49. openbox_core/wire/evaluate_payload.py +56 -0
  50. openbox_sdk_python-0.2.0.dist-info/METADATA +94 -0
  51. openbox_sdk_python-0.2.0.dist-info/RECORD +52 -0
  52. openbox_sdk_python-0.2.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,325 @@
1
+ """Span contracts — Stage, HookType, and flat OpenBox hook spans.
2
+
3
+ OTel spans are the INTERNAL source of truth (there are no fixed
4
+ HttpSpan/DbSpan/... dataclasses), but ``from_otel_span`` returns the flat Core
5
+ ``SpanData`` shape immediately. There is no SDK-visible
6
+ ``{"otel": ..., "openbox": ...}`` span envelope.
7
+
8
+ Pure module: span access is DUCK-TYPED (plain getattr) so importing this never
9
+ pulls in opentelemetry — the import-safety harness holds.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Mapping
15
+ from enum import Enum
16
+ from typing import Any
17
+
18
+ from ..otel.trace_context import format_span_id, format_trace_id
19
+
20
+ __all__ = [
21
+ "Stage",
22
+ "HookType",
23
+ "serialize_readable_span",
24
+ "from_otel_span",
25
+ "OpenBoxSpanAdapter",
26
+ ]
27
+
28
+
29
+ class Stage(str, Enum):
30
+ """Hook evaluation stage."""
31
+
32
+ STARTED = "started"
33
+ COMPLETED = "completed"
34
+
35
+
36
+ class HookType(str, Enum):
37
+ """Operation category of a hook span (Core root field ``hook_type``)."""
38
+
39
+ HTTP_REQUEST = "http_request"
40
+ DB_QUERY = "db_query"
41
+ FILE_OPERATION = "file_operation"
42
+ FUNCTION_CALL = "function_call"
43
+ LLM_CALL = "llm_call" # reserved; disabled until provider hooks are implemented
44
+
45
+
46
+ def _span_context_of(span: Any) -> Any:
47
+ if hasattr(span, "get_span_context"):
48
+ try:
49
+ return span.get_span_context()
50
+ except Exception:
51
+ return None
52
+ return getattr(span, "context", None)
53
+
54
+
55
+ def _enum_name(value: Any) -> str | None:
56
+ """'SpanKind.CLIENT' -> 'CLIENT'; plain strings pass through."""
57
+ if value is None:
58
+ return None
59
+ name = getattr(value, "name", None)
60
+ if isinstance(name, str):
61
+ return name
62
+ text = str(value)
63
+ return text.split(".")[-1] if text else None
64
+
65
+
66
+ def _attributes_of(obj: Any) -> dict[str, Any]:
67
+ attributes = getattr(obj, "attributes", None)
68
+ if attributes is None:
69
+ return {}
70
+ try:
71
+ return dict(attributes)
72
+ except Exception:
73
+ return {}
74
+
75
+
76
+ # Best-effort semantic attribute mapping: wire field -> OTel keys (new, legacy).
77
+ _SEMANTIC_ATTR_MAP: dict[str, tuple[str, ...]] = {
78
+ "http_url": ("url.full", "http.url"),
79
+ "http_method": ("http.request.method", "http.method"),
80
+ "http_status_code": ("http.response.status_code", "http.status_code"),
81
+ "db_system": ("db.system.name", "db.system"),
82
+ "db_name": ("db.namespace", "db.name"),
83
+ "db_operation": ("db.operation.name", "db.operation"),
84
+ "db_statement": ("db.query.text", "db.statement"),
85
+ "server_address": ("server.address", "net.peer.name"),
86
+ "server_port": ("server.port", "net.peer.port"),
87
+ "file_path": ("file.path",),
88
+ "file_mode": ("file.mode",),
89
+ "file_operation": ("file.operation",),
90
+ "function": ("code.function.name", "code.function"),
91
+ "module": ("code.namespace", "code.module"),
92
+ }
93
+
94
+ _DEFAULT_KIND_BY_HOOK: dict[str, str] = {
95
+ "http_request": "CLIENT",
96
+ "db_query": "CLIENT",
97
+ "file_operation": "INTERNAL",
98
+ "function_call": "INTERNAL",
99
+ "llm_call": "CLIENT",
100
+ }
101
+
102
+ # Family-specific root fields that must exist, null-valued when unavailable, so
103
+ # hook spans have the same flat key contract in memory and on the wire.
104
+ _ROOT_FIELDS_BY_HOOK_TYPE: dict[str, tuple[str, ...]] = {
105
+ "http_request": (
106
+ "http_method",
107
+ "http_url",
108
+ "http_status_code",
109
+ "request_headers",
110
+ "response_headers",
111
+ "request_body",
112
+ "response_body",
113
+ ),
114
+ "db_query": (
115
+ "db_system",
116
+ "db_name",
117
+ "db_operation",
118
+ "db_statement",
119
+ "server_address",
120
+ "server_port",
121
+ "rowcount",
122
+ ),
123
+ "file_operation": (
124
+ "file_path",
125
+ "file_mode",
126
+ "file_operation",
127
+ "bytes_read",
128
+ "bytes_written",
129
+ ),
130
+ "function_call": ("function", "module", "args", "result"),
131
+ }
132
+
133
+
134
+ def _attr(attributes: Mapping[str, Any], keys: tuple[str, ...]) -> Any:
135
+ for key in keys:
136
+ value = attributes.get(key)
137
+ if value is not None:
138
+ return value
139
+ return None
140
+
141
+
142
+ def serialize_readable_span(span: Any) -> dict[str, Any]:
143
+ """Preserve the OTel surface the SDK exposes, via duck-typed access.
144
+
145
+ Ids stay RAW INTEGERS here (internal representation; the wire projection
146
+ formats hex strings). Missing pieces degrade to None/empty — a span is
147
+ never rejected during serialization.
148
+ """
149
+ span_context = _span_context_of(span)
150
+ context_part = None
151
+ if span_context is not None:
152
+ span_id = getattr(span_context, "span_id", None)
153
+ trace_id = getattr(span_context, "trace_id", None)
154
+ context_part = {
155
+ "span_id": span_id if isinstance(span_id, int) else None,
156
+ "trace_id": trace_id if isinstance(trace_id, int) else None,
157
+ }
158
+
159
+ parent = getattr(span, "parent", None)
160
+ parent_part = None
161
+ if parent is not None:
162
+ parent_span_id = getattr(parent, "span_id", None)
163
+ if isinstance(parent_span_id, int):
164
+ parent_part = {"span_id": parent_span_id}
165
+
166
+ status = getattr(span, "status", None)
167
+ status_part = None
168
+ if status is not None:
169
+ status_part = {
170
+ "code": _enum_name(getattr(status, "status_code", None)) or "UNSET",
171
+ "description": getattr(status, "description", None),
172
+ }
173
+
174
+ events = []
175
+ for event in getattr(span, "events", None) or ():
176
+ events.append(
177
+ {
178
+ "name": getattr(event, "name", None),
179
+ "timestamp": getattr(event, "timestamp", None),
180
+ "attributes": _attributes_of(event),
181
+ }
182
+ )
183
+
184
+ links = []
185
+ for link in getattr(span, "links", None) or ():
186
+ link_context = getattr(link, "context", None)
187
+ links.append(
188
+ {
189
+ "context": {
190
+ "span_id": getattr(link_context, "span_id", None),
191
+ "trace_id": getattr(link_context, "trace_id", None),
192
+ },
193
+ "attributes": _attributes_of(link),
194
+ }
195
+ )
196
+
197
+ resource = getattr(span, "resource", None)
198
+ resource_part = _attributes_of(resource) if resource is not None else None
199
+
200
+ scope = getattr(span, "instrumentation_scope", None)
201
+ scope_part = None
202
+ if scope is not None:
203
+ scope_part = {
204
+ "name": getattr(scope, "name", None),
205
+ "version": getattr(scope, "version", None),
206
+ }
207
+
208
+ start_time = getattr(span, "start_time", None)
209
+ end_time = getattr(span, "end_time", None)
210
+ return {
211
+ "context": context_part,
212
+ "parent": parent_part,
213
+ "name": getattr(span, "name", None),
214
+ "kind": _enum_name(getattr(span, "kind", None)),
215
+ "start_time": start_time if isinstance(start_time, int) else None,
216
+ "end_time": end_time if isinstance(end_time, int) else None,
217
+ "attributes": _attributes_of(span),
218
+ "events": events,
219
+ "links": links,
220
+ "status": status_part,
221
+ "resource": resource_part,
222
+ "instrumentation_scope": scope_part,
223
+ }
224
+
225
+
226
+ def from_otel_span(
227
+ span: Any,
228
+ *,
229
+ stage: Stage | str,
230
+ hook_type: HookType | str | None = None,
231
+ activity_context: Any = None,
232
+ fields: Mapping[str, Any] | None = None,
233
+ ) -> dict[str, Any]:
234
+ """Build one flat Core ``SpanData`` dict from an OTel span.
235
+
236
+ Args:
237
+ span: OTel span (Span/ReadableSpan/NonRecordingSpan — duck-typed).
238
+ stage: started/completed.
239
+ hook_type: Operation category (None for non-hook telemetry spans).
240
+ activity_context: Accepted for backward-compatible call sites; context is
241
+ stored on the surrounding event, not inside the span.
242
+ fields: Wrapper-supplied Core root fields (request_body, rowcount,
243
+ args, ...) merged at the span root. These carry data OTel attributes
244
+ don't (bodies, results).
245
+ """
246
+ _ = activity_context
247
+ stage_value = stage.value if isinstance(stage, Stage) else str(stage)
248
+ hook_value = hook_type.value if isinstance(hook_type, HookType) else hook_type
249
+ otel = serialize_readable_span(span)
250
+ context = otel.get("context") or {}
251
+ parent = otel.get("parent") or {}
252
+ span_id_int = context.get("span_id")
253
+ trace_id_int = context.get("trace_id")
254
+ parent_id_int = parent.get("span_id")
255
+ attributes = dict(otel.get("attributes") or {})
256
+
257
+ start_time = otel.get("start_time")
258
+ end_time = otel.get("end_time")
259
+ if stage_value == Stage.STARTED.value:
260
+ end_time = None
261
+ duration_ns = None
262
+ else:
263
+ duration_ns = (
264
+ end_time - start_time
265
+ if isinstance(end_time, int) and isinstance(start_time, int)
266
+ else None
267
+ )
268
+
269
+ wire: dict[str, Any] = {
270
+ "span_id": format_span_id(span_id_int) if isinstance(span_id_int, int) else "0" * 16,
271
+ "trace_id": format_trace_id(trace_id_int) if isinstance(trace_id_int, int) else "0" * 32,
272
+ "parent_span_id": format_span_id(parent_id_int) if isinstance(parent_id_int, int) else None,
273
+ "name": otel.get("name") or (hook_value or "span"),
274
+ "kind": otel.get("kind") or _DEFAULT_KIND_BY_HOOK.get(hook_value or "", "INTERNAL"),
275
+ "stage": stage_value,
276
+ "start_time": start_time,
277
+ "end_time": end_time,
278
+ "duration_ns": duration_ns,
279
+ "attributes": attributes,
280
+ "status": otel.get("status") or {"code": "UNSET", "description": None},
281
+ "events": otel.get("events") or [],
282
+ "error": None,
283
+ }
284
+ if hook_value:
285
+ wire["hook_type"] = hook_value
286
+
287
+ for wire_field, attr_keys in _SEMANTIC_ATTR_MAP.items():
288
+ value = _attr(attributes, attr_keys)
289
+ if value is not None:
290
+ wire[wire_field] = value
291
+
292
+ for field_name, value in dict(fields or {}).items():
293
+ if value is not None:
294
+ wire[field_name] = value
295
+
296
+ for field_name in _ROOT_FIELDS_BY_HOOK_TYPE.get(hook_value or "", ()):
297
+ wire.setdefault(field_name, None)
298
+
299
+ if stage_value != Stage.STARTED.value and wire.get("end_time") is None:
300
+ measured = wire.get("duration_ns")
301
+ if isinstance(start_time, int) and isinstance(measured, int):
302
+ wire["end_time"] = start_time + measured
303
+
304
+ return wire
305
+
306
+
307
+ class OpenBoxSpanAdapter:
308
+ """Adapter object form of :func:`from_otel_span` (protocol-compatible)."""
309
+
310
+ @staticmethod
311
+ def from_otel_span(
312
+ span: Any,
313
+ *,
314
+ stage: Stage | str,
315
+ hook_type: HookType | str | None = None,
316
+ activity_context: Any = None,
317
+ fields: Mapping[str, Any] | None = None,
318
+ ) -> dict[str, Any]:
319
+ return from_otel_span(
320
+ span,
321
+ stage=stage,
322
+ hook_type=hook_type,
323
+ activity_context=activity_context,
324
+ fields=fields,
325
+ )
@@ -0,0 +1,287 @@
1
+ """Result contracts — Verdict, GuardrailsResult, EvaluationResult, ApprovalResult.
2
+
3
+ Pure, import-safe module: no network, crypto, OTel, logging, wall-clock, or
4
+ random. Strict dataclass constructors AND loose ``from_dict()`` parsers are
5
+ both public so callers can work with typed values or raw backend dicts.
6
+
7
+ Parsing preserves ``raw`` so nothing the backend sent is ever lost, and stays
8
+ tolerant of unknown keys (field-shape drift from Core must not crash SDKs).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from enum import Enum
15
+ from typing import Any
16
+
17
+ __all__ = [
18
+ "Verdict",
19
+ "GuardrailsResult",
20
+ "EvaluationResult",
21
+ "ApprovalResult",
22
+ ]
23
+
24
+
25
+ class Verdict(str, Enum):
26
+ """5-tier graduated response. Priority: HALT > BLOCK > REQUIRE_APPROVAL > CONSTRAIN > ALLOW."""
27
+
28
+ ALLOW = "allow"
29
+ CONSTRAIN = "constrain"
30
+ REQUIRE_APPROVAL = "require_approval"
31
+ BLOCK = "block"
32
+ HALT = "halt"
33
+
34
+ @classmethod
35
+ def from_string(cls, value: str | None) -> Verdict:
36
+ """Parse with v1.0 compat: 'continue'→ALLOW, 'stop'→HALT, 'require-approval'→REQUIRE_APPROVAL."""
37
+ if value is None:
38
+ return cls.ALLOW
39
+ normalized = value.lower().replace("-", "_")
40
+ if normalized == "continue":
41
+ return cls.ALLOW
42
+ if normalized == "stop":
43
+ return cls.HALT
44
+ if normalized in ("require_approval", "request_approval"):
45
+ return cls.REQUIRE_APPROVAL
46
+ try:
47
+ return cls(normalized)
48
+ except ValueError:
49
+ return cls.ALLOW
50
+
51
+ @property
52
+ def priority(self) -> int:
53
+ """Priority for aggregation: HALT=5, BLOCK=4, REQUIRE_APPROVAL=3, CONSTRAIN=2, ALLOW=1."""
54
+ return {
55
+ Verdict.ALLOW: 1,
56
+ Verdict.CONSTRAIN: 2,
57
+ Verdict.REQUIRE_APPROVAL: 3,
58
+ Verdict.BLOCK: 4,
59
+ Verdict.HALT: 5,
60
+ }[self]
61
+
62
+ @classmethod
63
+ def highest_priority(cls, verdicts: list[Verdict]) -> Verdict:
64
+ """Get highest priority verdict from list. Returns ALLOW if empty."""
65
+ return max(verdicts, key=lambda v: v.priority) if verdicts else cls.ALLOW
66
+
67
+ def should_stop(self) -> bool:
68
+ """True if BLOCK or HALT."""
69
+ return self in (Verdict.BLOCK, Verdict.HALT)
70
+
71
+ def requires_approval(self) -> bool:
72
+ """True if REQUIRE_APPROVAL."""
73
+ return self == Verdict.REQUIRE_APPROVAL
74
+
75
+
76
+ @dataclass
77
+ class GuardrailsResult:
78
+ """Guardrails check result from the governance API.
79
+
80
+ Contains redacted input/output that should replace the original activity
81
+ data, plus validation results that can block execution.
82
+ """
83
+
84
+ redacted_input: Any = None # Redacted activity_input/activity_output (JSON-decoded)
85
+ input_type: str = "" # "activity_input" or "activity_output"
86
+ raw_logs: dict[str, Any] | None = None # Raw logs from guardrails evaluation
87
+ validation_passed: bool = True # If False, execution should be stopped
88
+ reasons: list[dict[str, str]] = field(default_factory=list) # [{type, field, reason}]
89
+
90
+ @classmethod
91
+ def from_dict(cls, data: dict[str, Any]) -> GuardrailsResult:
92
+ return cls(
93
+ redacted_input=data.get("redacted_input"),
94
+ input_type=data.get("input_type", ""),
95
+ raw_logs=data.get("raw_logs"),
96
+ validation_passed=data.get("validation_passed", True),
97
+ reasons=data.get("reasons") or [],
98
+ )
99
+
100
+ def get_reason_strings(self) -> list[str]:
101
+ """Extract just the 'reason' field from each reason object."""
102
+ return [r.get("reason", "") for r in self.reasons if r.get("reason")]
103
+
104
+
105
+ @dataclass
106
+ class EvaluationResult:
107
+ """Response from a governance evaluation.
108
+
109
+ ``guardrails`` and ``guardrails_result`` are the SAME object —
110
+ ``guardrails_result`` is a read-only compatibility alias; there is no way
111
+ for the two to diverge.
112
+
113
+ Optional transport/diagnostic fields default to falsy values so strict
114
+ construction stays terse.
115
+ """
116
+
117
+ verdict: Verdict
118
+ reason: str | None = None
119
+ policy_id: str | None = None
120
+ risk_score: float = 0.0
121
+ metadata: dict[str, Any] | None = None
122
+ governance_event_id: str | None = None
123
+ guardrails: GuardrailsResult | None = None
124
+ approval_id: str | None = None
125
+ approval_expiration_time: str | None = None
126
+ trust_tier: str | None = None
127
+ alignment_score: float | None = None
128
+ behavioral_violations: list[str] | None = None
129
+ constraints: list[dict[str, Any]] | None = None
130
+ fallback_used: bool = False # True when fail-open produced this result
131
+ diagnostics: list[Any] = field(default_factory=list)
132
+ raw: dict[str, Any] = field(default_factory=dict)
133
+
134
+ @property
135
+ def guardrails_result(self) -> GuardrailsResult | None:
136
+ """Alias of ``guardrails`` (same object)."""
137
+ return self.guardrails
138
+
139
+ @property
140
+ def action(self) -> str:
141
+ """Backward compat: return the v1.0 action string derived from verdict."""
142
+ if self.verdict == Verdict.ALLOW:
143
+ return "continue"
144
+ if self.verdict == Verdict.HALT:
145
+ return "stop"
146
+ if self.verdict == Verdict.REQUIRE_APPROVAL:
147
+ return "require-approval"
148
+ return self.verdict.value
149
+
150
+ @classmethod
151
+ def from_dict(cls, data: dict[str, Any]) -> EvaluationResult:
152
+ """Parse a governance response dict (v1.0 and v1.1 compatible).
153
+
154
+ Verdict-first with v1.0 ``action`` fallback — the approval
155
+ *action-precedence* change applies to :class:`ApprovalResult` only.
156
+ Unknown keys are preserved in ``raw``, never an error.
157
+ """
158
+ guardrails = None
159
+ if data.get("guardrails_result"):
160
+ guardrails = GuardrailsResult.from_dict(data["guardrails_result"])
161
+ elif data.get("guardrails"):
162
+ guardrails = GuardrailsResult.from_dict(data["guardrails"])
163
+
164
+ verdict = Verdict.from_string(data.get("verdict") or data.get("action", "continue"))
165
+
166
+ return cls(
167
+ verdict=verdict,
168
+ reason=data.get("reason"),
169
+ policy_id=data.get("policy_id"),
170
+ risk_score=data.get("risk_score", 0.0),
171
+ metadata=data.get("metadata"),
172
+ governance_event_id=data.get("governance_event_id"),
173
+ guardrails=guardrails,
174
+ approval_id=data.get("approval_id"),
175
+ approval_expiration_time=data.get("approval_expiration_time"),
176
+ trust_tier=data.get("trust_tier"),
177
+ alignment_score=data.get("alignment_score"),
178
+ behavioral_violations=data.get("behavioral_violations"),
179
+ constraints=data.get("constraints"),
180
+ fallback_used=bool(data.get("fallback_used", False)),
181
+ diagnostics=data.get("diagnostics") or [],
182
+ raw=dict(data),
183
+ )
184
+
185
+ @classmethod
186
+ def fallback_allow(cls, reason: str) -> EvaluationResult:
187
+ """Allow-shaped result for fail-open network-error paths.
188
+
189
+ ``fallback_used=True`` marks it as a fallback so callers can tell a
190
+ policy ALLOW from an unreachable-Core ALLOW. A network error must never
191
+ silently flip BLOCK→ALLOW without this marker.
192
+ """
193
+ return cls(verdict=Verdict.ALLOW, reason=reason, fallback_used=True)
194
+
195
+
196
+ @dataclass
197
+ class ApprovalResult:
198
+ """Normalized HITL approval-poll response.
199
+
200
+ Decision-source precedence: **``action`` wins over ``verdict``** when both
201
+ are present.
202
+
203
+ When NEITHER field is present, ``verdict`` is ``None`` (pending-unknown) —
204
+ never auto-ALLOW. Expired approvals block unless the backend explicitly
205
+ returned an allow-shaped verdict/action.
206
+ """
207
+
208
+ verdict: Verdict | None = None
209
+ action: str | None = None
210
+ reason: str | None = None
211
+ approval_id: str | None = None
212
+ approval_expiration_time: str | None = None
213
+ expired: bool = False
214
+ raw: dict[str, Any] = field(default_factory=dict)
215
+
216
+ # Known decision vocabulary for approvals (current values + accepted aliases).
217
+ # Anything OUTSIDE this set parses to None (pending) — the evaluate-path
218
+ # leniency of Verdict.from_string (unknown -> ALLOW) is too loose at the
219
+ # human-approval trust boundary and is not used here.
220
+ _DECISION_VOCABULARY = frozenset(
221
+ {
222
+ "allow",
223
+ "constrain",
224
+ "require_approval",
225
+ "request_approval",
226
+ "block",
227
+ "halt",
228
+ "continue",
229
+ "stop",
230
+ }
231
+ )
232
+
233
+ @staticmethod
234
+ def _parse_decision(value: Any) -> Verdict | None:
235
+ """Strict, fail-safe decision parsing: empty/unknown -> None (pending)."""
236
+ if not isinstance(value, str) or not value.strip():
237
+ return None
238
+ normalized = value.strip().lower().replace("-", "_")
239
+ if normalized not in ApprovalResult._DECISION_VOCABULARY:
240
+ return None
241
+ return Verdict.from_string(normalized)
242
+
243
+ @classmethod
244
+ def from_dict(cls, data: dict[str, Any]) -> ApprovalResult:
245
+ action = data.get("action")
246
+ # Empty/whitespace action is absent; it must not shadow ``verdict``.
247
+ if not isinstance(action, str) or not action.strip():
248
+ action = None
249
+ verdict_source = action if action is not None else data.get("verdict")
250
+ verdict = cls._parse_decision(verdict_source)
251
+ return cls(
252
+ verdict=verdict,
253
+ action=action,
254
+ reason=data.get("reason"),
255
+ # Normalize ``id`` → ``approval_id``.
256
+ approval_id=data.get("approval_id") or data.get("id"),
257
+ approval_expiration_time=data.get("approval_expiration_time"),
258
+ expired=bool(data.get("expired", False)),
259
+ raw=dict(data),
260
+ )
261
+
262
+ @property
263
+ def allow_shaped(self) -> bool:
264
+ """True when the backend explicitly returned an allow verdict/action."""
265
+ return self.verdict == Verdict.ALLOW
266
+
267
+ def is_blocking(self) -> bool:
268
+ """True when this response must stop the operation.
269
+
270
+ Expired approvals are blocking unless explicitly allow-shaped; an
271
+ explicit BLOCK/HALT verdict is blocking regardless of expiry.
272
+ """
273
+ if self.expired:
274
+ return not self.allow_shaped
275
+ return self.verdict is not None and self.verdict.should_stop()
276
+
277
+ def is_pending(self) -> bool:
278
+ """True while the approval decision is still outstanding.
279
+
280
+ Absent verdict/action (``None``) is pending — never auto-ALLOW.
281
+ REQUIRE_APPROVAL and CONSTRAIN keep polling.
282
+ """
283
+ if self.expired:
284
+ return False
285
+ if self.verdict is None:
286
+ return True
287
+ return self.verdict in (Verdict.REQUIRE_APPROVAL, Verdict.CONSTRAIN)