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,59 @@
1
+ """OpenBox base SDK — governance core shared by every OpenBox framework SDK.
2
+
3
+ IMPORT SAFETY: this module exports only pure names (version + errors). It must
4
+ never eagerly import ``client``, ``identity``, ``gate``, ``runtime``, or any
5
+ module that pulls in httpx, cryptography, OTel instrumentation, logging,
6
+ wall-clock time, or random generation. Constrained framework paths (e.g. the
7
+ workflow sandbox) rely on ``import openbox_core`` staying side-effect free —
8
+ enforced by ``tests/test_import_safety.py``.
9
+
10
+ Heavy entry points are imported explicitly by non-sandbox code:
11
+
12
+ from openbox_core.client import EvaluationClient
13
+ from openbox_core.runtime import OpenBoxRuntime
14
+ """
15
+
16
+ from .errors import (
17
+ ApprovalExpiredError,
18
+ ApprovalRejectedError,
19
+ ApprovalTimeoutError,
20
+ ContractError,
21
+ GovernanceAPIError,
22
+ GovernanceBlockedError,
23
+ GovernanceHaltError,
24
+ GuardrailsValidationError,
25
+ OpenBoxAuthError,
26
+ OpenBoxConfigError,
27
+ OpenBoxError,
28
+ OpenBoxInsecureURLError,
29
+ OpenBoxNetworkError,
30
+ OpenBoxSigningError,
31
+ extract_governance_error,
32
+ )
33
+
34
+ # STATIC on purpose — never read via importlib.metadata. A metadata lookup
35
+ # OPENS A FILE; with file instrumentation active (frameworks patch
36
+ # builtins.open/io.open with governed wrappers) that read re-enters
37
+ # governance; eagerly it can deadlock package init as a circular import, and
38
+ # lazily it can recurse unboundedly when a per-request header builder resolves
39
+ # the version. Keep in sync with pyproject.toml on release.
40
+ __version__ = "0.2.0"
41
+
42
+ __all__ = [
43
+ "__version__",
44
+ "OpenBoxError",
45
+ "ContractError",
46
+ "OpenBoxConfigError",
47
+ "OpenBoxAuthError",
48
+ "OpenBoxNetworkError",
49
+ "OpenBoxInsecureURLError",
50
+ "OpenBoxSigningError",
51
+ "GovernanceBlockedError",
52
+ "GovernanceHaltError",
53
+ "GovernanceAPIError",
54
+ "GuardrailsValidationError",
55
+ "ApprovalExpiredError",
56
+ "ApprovalRejectedError",
57
+ "ApprovalTimeoutError",
58
+ "extract_governance_error",
59
+ ]
@@ -0,0 +1,3 @@
1
+ """Framework adapter protocol package."""
2
+
3
+ __all__: list[str] = []
@@ -0,0 +1,123 @@
1
+ """FrameworkAdapter protocol + the core default adapter.
2
+
3
+ The adapter is the ONE seam where governance verdicts become framework-native
4
+ effects. There is exactly one path: wrapper -> hook runtime -> adapter.
5
+ Framework SDKs override the callbacks; the default ``CoreAdapter`` raises the
6
+ core error types directly.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
12
+
13
+ from ..contracts.results import EvaluationResult
14
+ from ..errors import (
15
+ ApprovalExpiredError,
16
+ ApprovalRejectedError,
17
+ GovernanceBlockedError,
18
+ GovernanceHaltError,
19
+ )
20
+
21
+ if TYPE_CHECKING:
22
+ from typing import NoReturn
23
+
24
+ from ..approvals import ApprovalPoller
25
+ from ..contracts.context import ActivityContext
26
+
27
+ __all__ = ["FrameworkAdapter", "CoreAdapter"]
28
+
29
+
30
+ @runtime_checkable
31
+ class FrameworkAdapter(Protocol):
32
+ """Native-enforcement callbacks implemented by framework SDKs."""
33
+
34
+ name: str
35
+
36
+ async def handle_approval(self, result: EvaluationResult) -> None:
37
+ """Drive the framework's approval flow for REQUIRE_APPROVAL.
38
+
39
+ Return normally when approved; raise the framework's native rejection/
40
+ expiry error otherwise. Called BEFORE the real operation runs.
41
+
42
+ Adapters may ALSO define a plain-sync ``handle_approval_sync(result)``
43
+ (not part of the required protocol): when present, sync hook paths
44
+ delegate to it instead of driving the core inline poller. Frameworks
45
+ with retry-based HITL can raise their native pending error there.
46
+ """
47
+ ...
48
+
49
+ def raise_lifecycle_blocked(self, result: EvaluationResult) -> NoReturn:
50
+ """Produce the framework-native effect for a BLOCK/HALT lifecycle verdict."""
51
+ ...
52
+
53
+ def raise_hook_blocked(self, result: EvaluationResult) -> NoReturn:
54
+ """Produce the framework-native effect for a BLOCK/HALT started-hook
55
+ verdict. The real operation has NOT run."""
56
+ ...
57
+
58
+ def on_completed_hook_result(
59
+ self, result: EvaluationResult, context: ActivityContext | None = None
60
+ ) -> None:
61
+ """React to a completed-hook verdict. The operation ALREADY ran —
62
+ implementations may only affect FUTURE execution (e.g. mark the
63
+ activity/session blocked); they must never pretend to undo work.
64
+
65
+ ``context`` is the span-resolved ActivityContext (may be None). Frameworks
66
+ that must bridge a completed BLOCK/HALT to native effects on the correct
67
+ run/activity read the workflow/run/activity keys from it."""
68
+ ...
69
+
70
+
71
+ class CoreAdapter:
72
+ """Default adapter — raises core error types (framework-agnostic).
73
+
74
+ Args:
75
+ approval_poller: Optional ApprovalPoller enabling a real HITL wait.
76
+ Without one, REQUIRE_APPROVAL is fail-safe: rejected (the operation
77
+ does not run) rather than silently allowed.
78
+ """
79
+
80
+ name = "core"
81
+
82
+ def __init__(self, approval_poller: ApprovalPoller | None = None):
83
+ self._poller = approval_poller
84
+
85
+ async def handle_approval(self, result: EvaluationResult) -> None:
86
+ if self._poller is None or not result.approval_id:
87
+ raise ApprovalRejectedError(
88
+ "REQUIRE_APPROVAL verdict but no approval flow is configured — "
89
+ "failing safe (operation not run)"
90
+ )
91
+ approval = await self._poller.await_decision(
92
+ result.raw.get("workflow_id", ""),
93
+ result.raw.get("run_id", ""),
94
+ result.raw.get("activity_id", ""),
95
+ )
96
+ if approval.allow_shaped:
97
+ return
98
+ if approval.expired:
99
+ raise ApprovalExpiredError(approval.reason or "Approval window expired")
100
+ raise ApprovalRejectedError(approval.reason or "Approval rejected")
101
+
102
+ def raise_lifecycle_blocked(self, result: EvaluationResult) -> NoReturn:
103
+ self._raise_stop(result)
104
+
105
+ def raise_hook_blocked(self, result: EvaluationResult) -> NoReturn:
106
+ self._raise_stop(result)
107
+
108
+ def on_completed_hook_result(
109
+ self, result: EvaluationResult, context: ActivityContext | None = None
110
+ ) -> None:
111
+ # Completed telemetry never undoes the operation; the runtime records
112
+ # abort/halt flags for FUTURE execution — nothing to do here.
113
+ return None
114
+
115
+ @staticmethod
116
+ def _raise_stop(result: EvaluationResult) -> NoReturn:
117
+ from ..contracts.results import Verdict
118
+
119
+ if result.verdict is Verdict.HALT:
120
+ raise GovernanceHaltError(result.reason or "Halted by governance policy")
121
+ raise GovernanceBlockedError(
122
+ result.verdict, result.reason or "Blocked by governance policy"
123
+ )
@@ -0,0 +1,106 @@
1
+ """Approval polling orchestration on top of ``EvaluationClient.poll_approval``.
2
+
3
+ Owns the poll loop (interval/backoff), expiry handling, and timeout budget —
4
+ but imposes NO framework retry strategy: adapters that drive their own
5
+ approval UX call ``client.poll_approval`` directly and skip this module
6
+ entirely.
7
+
8
+ Terminal semantics:
9
+ - allow-shaped -> return the ApprovalResult (approved)
10
+ - blocking / expired -> return the ApprovalResult (caller inspects
11
+ ``is_blocking()``/``expired``; adapters translate to native errors)
12
+ - budget exhausted -> raise ApprovalTimeoutError (client-side condition)
13
+ - poll failure (None) -> still pending; keep polling
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import time
20
+
21
+ from .client import EvaluationClient
22
+ from .contracts.results import ApprovalResult
23
+ from .errors import ApprovalTimeoutError
24
+
25
+ __all__ = ["ApprovalPoller"]
26
+
27
+
28
+ class ApprovalPoller:
29
+ """Poll until an approval reaches a terminal state.
30
+
31
+ Args:
32
+ client: The EvaluationClient to poll through.
33
+ poll_interval_seconds: Base delay between polls.
34
+ max_wait_seconds: Total budget; ``None`` polls indefinitely.
35
+ backoff_multiplier: Interval growth per attempt (1.0 = constant).
36
+ max_interval_seconds: Interval ceiling when backing off.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ client: EvaluationClient,
42
+ *,
43
+ poll_interval_seconds: float = 5.0,
44
+ max_wait_seconds: float | None = None,
45
+ backoff_multiplier: float = 1.0,
46
+ max_interval_seconds: float = 60.0,
47
+ max_consecutive_failures: int = 60,
48
+ ):
49
+ self._client = client
50
+ self._interval = poll_interval_seconds
51
+ self._max_wait = max_wait_seconds
52
+ self._backoff = backoff_multiplier
53
+ self._max_interval = max_interval_seconds
54
+ # Genuine PENDING may legitimately wait forever (max_wait bounds it),
55
+ # but an UNREACHABLE Core must not hang the governed thread
56
+ # indefinitely: N consecutive poll failures raise ApprovalTimeoutError
57
+ # (fail-safe: the operation does not run).
58
+ self._max_consecutive_failures = max_consecutive_failures
59
+
60
+ def _next_interval(self, attempt: int) -> float:
61
+ return min(self._interval * (self._backoff**attempt), self._max_interval)
62
+
63
+ def _timed_out(self, started_at: float) -> bool:
64
+ return self._max_wait is not None and (time.monotonic() - started_at) >= self._max_wait
65
+
66
+ @staticmethod
67
+ def _is_terminal(result: ApprovalResult | None) -> bool:
68
+ return result is not None and not result.is_pending()
69
+
70
+ def wait_for_decision(
71
+ self, workflow_id: str, run_id: str, activity_id: str
72
+ ) -> ApprovalResult:
73
+ """Block until the approval is decided/expired, or the budget runs out."""
74
+ started_at = time.monotonic()
75
+ attempt = 0
76
+ consecutive_failures = 0
77
+ while True:
78
+ result = self._client.poll_approval(workflow_id, run_id, activity_id)
79
+ if self._is_terminal(result):
80
+ return result # type: ignore[return-value]
81
+ consecutive_failures = consecutive_failures + 1 if result is None else 0
82
+ if consecutive_failures >= self._max_consecutive_failures:
83
+ raise ApprovalTimeoutError()
84
+ if self._timed_out(started_at):
85
+ raise ApprovalTimeoutError(int(self._max_wait * 1000)) # type: ignore[arg-type]
86
+ time.sleep(self._next_interval(attempt))
87
+ attempt += 1
88
+
89
+ async def await_decision(
90
+ self, workflow_id: str, run_id: str, activity_id: str
91
+ ) -> ApprovalResult:
92
+ """Async :meth:`wait_for_decision`."""
93
+ started_at = time.monotonic()
94
+ attempt = 0
95
+ consecutive_failures = 0
96
+ while True:
97
+ result = await self._client.apoll_approval(workflow_id, run_id, activity_id)
98
+ if self._is_terminal(result):
99
+ return result # type: ignore[return-value]
100
+ consecutive_failures = consecutive_failures + 1 if result is None else 0
101
+ if consecutive_failures >= self._max_consecutive_failures:
102
+ raise ApprovalTimeoutError()
103
+ if self._timed_out(started_at):
104
+ raise ApprovalTimeoutError(int(self._max_wait * 1000)) # type: ignore[arg-type]
105
+ await asyncio.sleep(self._next_interval(attempt))
106
+ attempt += 1
openbox_core/client.py ADDED
@@ -0,0 +1,298 @@
1
+ """Sync + async EvaluationClient for OpenBox Core.
2
+
3
+ Endpoints:
4
+ POST /api/v1/governance/evaluate — lifecycle + hook evaluations
5
+ POST /api/v1/governance/approval — HITL approval polling
6
+ GET /api/v1/auth/validate — API key / signing validation
7
+
8
+ Transport rules:
9
+ - Signed requests send ``content=body_bytes`` — NEVER ``json=`` (client-side
10
+ re-serialization breaks Core's body-hash verification).
11
+ - ``httpx`` is imported lazily so this module never taints pure import paths.
12
+ - Fail modes apply to NETWORK errors only — contract violations raise before
13
+ any send (see gate.py) and are never converted to fail-open ALLOWs:
14
+ * fail_open (default): return allow-shaped ``EvaluationResult`` with
15
+ ``fallback_used=True`` (callers can tell it apart from a policy ALLOW).
16
+ * fail_closed: raise ``GovernanceAPIError`` (adapters map to native
17
+ halt/block behavior).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ from datetime import UTC, datetime
24
+ from typing import Any
25
+
26
+ from .contracts.results import ApprovalResult, EvaluationResult
27
+ from .errors import (
28
+ GovernanceAPIError,
29
+ OpenBoxAuthError,
30
+ OpenBoxNetworkError,
31
+ map_signing_error,
32
+ )
33
+ from .identity import AgentIdentity, prepare_signed_request
34
+ from .sdk_version import DEFAULT_SDK_ENGINE, DEFAULT_SDK_LANGUAGE
35
+
36
+ __all__ = [
37
+ "EVALUATE_PATH",
38
+ "APPROVAL_PATH",
39
+ "AUTH_VALIDATE_PATH",
40
+ "EvaluationClient",
41
+ "check_expiration",
42
+ ]
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ EVALUATE_PATH = "/api/v1/governance/evaluate"
47
+ APPROVAL_PATH = "/api/v1/governance/approval"
48
+ AUTH_VALIDATE_PATH = "/api/v1/auth/validate"
49
+
50
+
51
+ def check_expiration(data: dict) -> dict:
52
+ """Set ``expired=True`` if ``approval_expiration_time`` is past.
53
+
54
+ Modifies ``data`` in place and returns it. Handles ISO ``Z``, ISO offset,
55
+ and space-separated DB formats. Parse failures are logged, never raised.
56
+ """
57
+ expiration_time_str = data.get("approval_expiration_time")
58
+ if not expiration_time_str:
59
+ return data
60
+ try:
61
+ normalized = str(expiration_time_str).replace("Z", "+00:00").replace(" ", "T")
62
+ expiration_time = datetime.fromisoformat(normalized)
63
+ if expiration_time.tzinfo is None:
64
+ expiration_time = expiration_time.replace(tzinfo=UTC)
65
+ if datetime.now(UTC) > expiration_time:
66
+ data["expired"] = True
67
+ except (ValueError, TypeError) as e:
68
+ logger.warning(
69
+ f"Failed to parse approval_expiration_time '{expiration_time_str}': {e}"
70
+ )
71
+ return data
72
+
73
+
74
+ def _extract_reason_code(body: bytes | None) -> str | None:
75
+ """Machine reason code from Core's JSON error body, if present."""
76
+ import json
77
+
78
+ if not body:
79
+ return None
80
+ try:
81
+ data = json.loads(body.decode("utf-8", errors="replace"))
82
+ except Exception:
83
+ return None
84
+ if not isinstance(data, dict):
85
+ return None
86
+ code = data.get("reason_code") or data.get("code") or data.get("reason")
87
+ return code if isinstance(code, str) else None
88
+
89
+
90
+ class EvaluationClient:
91
+ """HTTP client for the OpenBox Core governance API (sync + async).
92
+
93
+ Holds persistent ``httpx.Client``/``httpx.AsyncClient`` instances created
94
+ lazily on first use; call :meth:`close`/:meth:`aclose` on shutdown.
95
+ """
96
+
97
+ def __init__(
98
+ self,
99
+ api_url: str,
100
+ api_key: str,
101
+ *,
102
+ timeout_seconds: float = 30.0,
103
+ on_api_error: str = "fail_open",
104
+ identity: AgentIdentity | None = None,
105
+ sdk_version: str | None = None,
106
+ sdk_engine: str = DEFAULT_SDK_ENGINE,
107
+ sdk_language: str = DEFAULT_SDK_LANGUAGE,
108
+ transport: Any = None,
109
+ async_transport: Any = None,
110
+ ):
111
+ """Args:
112
+ api_url: Core base URL (no trailing slash needed).
113
+ api_key: Bearer API key.
114
+ timeout_seconds: Per-request timeout.
115
+ on_api_error: "fail_open" (default) or "fail_closed".
116
+ identity: Loaded AgentIdentity for signed requests (None = unsigned).
117
+ sdk_version/sdk_engine/sdk_language: Values used to build
118
+ X-OpenBox-SDK-Version as openbox-{engine}-{language}-v{version}.
119
+ transport/async_transport: Optional httpx transports (tests inject
120
+ ``httpx.MockTransport`` here; production leaves them None).
121
+ """
122
+ if on_api_error not in ("fail_open", "fail_closed"):
123
+ raise ValueError(f"on_api_error must be 'fail_open' or 'fail_closed', got {on_api_error!r}")
124
+ self._api_url = api_url.rstrip("/")
125
+ self._api_key = api_key
126
+ self._timeout = timeout_seconds
127
+ self._on_api_error = on_api_error
128
+ self._identity = identity
129
+ self._sdk_version = sdk_version
130
+ self._sdk_engine = sdk_engine
131
+ self._sdk_language = sdk_language
132
+ self._transport = transport
133
+ self._async_transport = async_transport
134
+ self._sync_client: Any = None
135
+ self._async_client: Any = None
136
+
137
+ # ── Transport plumbing ────────────────────────────────────────────────
138
+
139
+ def _sync(self) -> Any:
140
+ if self._sync_client is None:
141
+ import httpx
142
+
143
+ self._sync_client = httpx.Client(timeout=self._timeout, transport=self._transport)
144
+ return self._sync_client
145
+
146
+ def _async(self) -> Any:
147
+ if self._async_client is None:
148
+ import httpx
149
+
150
+ self._async_client = httpx.AsyncClient(
151
+ timeout=self._timeout, transport=self._async_transport
152
+ )
153
+ return self._async_client
154
+
155
+ def close(self) -> None:
156
+ """Close the sync transport (idempotent)."""
157
+ if self._sync_client is not None:
158
+ self._sync_client.close()
159
+ self._sync_client = None
160
+
161
+ async def aclose(self) -> None:
162
+ """Close both transports (idempotent)."""
163
+ self.close()
164
+ if self._async_client is not None:
165
+ await self._async_client.aclose()
166
+ self._async_client = None
167
+
168
+ def _prepared(self, method: str, path: str, payload: dict | None) -> tuple[str, dict, bytes]:
169
+ headers, body = prepare_signed_request(
170
+ method,
171
+ path,
172
+ payload,
173
+ api_key=self._api_key,
174
+ identity=self._identity,
175
+ sdk_version=self._sdk_version,
176
+ sdk_engine=self._sdk_engine,
177
+ sdk_language=self._sdk_language,
178
+ )
179
+ return f"{self._api_url}{path}", headers, body
180
+
181
+ # ── Evaluate ──────────────────────────────────────────────────────────
182
+
183
+ def evaluate(self, payload: dict) -> EvaluationResult:
184
+ """POST a governance event; parse the verdict. Never raises on network
185
+ errors under fail_open — returns a ``fallback_used=True`` ALLOW."""
186
+ url, headers, body = self._prepared("POST", EVALUATE_PATH, payload)
187
+ try:
188
+ response = self._sync().post(url, content=body, headers=headers)
189
+ except Exception as e: # network layer
190
+ return self._network_failure(f"Governance API unreachable: {e}")
191
+ return self._parse_evaluate_response(response)
192
+
193
+ async def aevaluate(self, payload: dict) -> EvaluationResult:
194
+ """Async :meth:`evaluate`."""
195
+ url, headers, body = self._prepared("POST", EVALUATE_PATH, payload)
196
+ try:
197
+ response = await self._async().post(url, content=body, headers=headers)
198
+ except Exception as e:
199
+ return self._network_failure(f"Governance API unreachable: {e}")
200
+ return self._parse_evaluate_response(response)
201
+
202
+ def _parse_evaluate_response(self, response: Any) -> EvaluationResult:
203
+ if response.status_code >= 400:
204
+ return self._network_failure(f"Governance API error: HTTP {response.status_code}")
205
+ try:
206
+ data = response.json()
207
+ except Exception as e:
208
+ return self._network_failure(f"Governance API returned unparseable body: {e}")
209
+ result = EvaluationResult.from_dict(data)
210
+ if result.verdict.should_stop():
211
+ logger.info(f"Governance blocked: {result.reason} (policy: {result.policy_id})")
212
+ return result
213
+
214
+ def _network_failure(self, reason: str) -> EvaluationResult:
215
+ """Apply the on_api_error policy to a NETWORK failure."""
216
+ logger.warning(reason)
217
+ if self._on_api_error == "fail_closed":
218
+ raise GovernanceAPIError(reason)
219
+ return EvaluationResult.fallback_allow(reason)
220
+
221
+ # ── Approval polling ──────────────────────────────────────────────────
222
+
223
+ def poll_approval(self, workflow_id: str, run_id: str, activity_id: str) -> ApprovalResult | None:
224
+ """Poll HITL approval status once. Returns None on poll failure
225
+ (callers treat None as still-pending and retry)."""
226
+ payload = {"workflow_id": workflow_id, "run_id": run_id, "activity_id": activity_id}
227
+ url, headers, body = self._prepared("POST", APPROVAL_PATH, payload)
228
+ try:
229
+ response = self._sync().post(url, content=body, headers=headers)
230
+ except Exception as e:
231
+ logger.warning(f"Failed to poll approval status: {e}")
232
+ return None
233
+ return self._parse_approval_response(response)
234
+
235
+ async def apoll_approval(
236
+ self, workflow_id: str, run_id: str, activity_id: str
237
+ ) -> ApprovalResult | None:
238
+ """Async :meth:`poll_approval`."""
239
+ payload = {"workflow_id": workflow_id, "run_id": run_id, "activity_id": activity_id}
240
+ url, headers, body = self._prepared("POST", APPROVAL_PATH, payload)
241
+ try:
242
+ response = await self._async().post(url, content=body, headers=headers)
243
+ except Exception as e:
244
+ logger.warning(f"Failed to poll approval status: {e}")
245
+ return None
246
+ return self._parse_approval_response(response)
247
+
248
+ def _parse_approval_response(self, response: Any) -> ApprovalResult | None:
249
+ if response.status_code != 200:
250
+ logger.warning(f"Failed to get approval status: HTTP {response.status_code}")
251
+ return None
252
+ try:
253
+ data = response.json()
254
+ except Exception as e:
255
+ logger.warning(f"Failed to parse approval response: {e}")
256
+ return None
257
+ check_expiration(data)
258
+ return ApprovalResult.from_dict(data)
259
+
260
+ # ── Auth validation ───────────────────────────────────────────────────
261
+
262
+ def validate_api_key(self) -> bool:
263
+ """GET /api/v1/auth/validate (signed when identity is configured).
264
+
265
+ Returns True on success. Raises OpenBoxAuthError / OpenBoxSigningError
266
+ on 401/403, OpenBoxNetworkError on connectivity failure.
267
+ """
268
+ url, headers, _ = self._prepared("GET", AUTH_VALIDATE_PATH, None)
269
+ try:
270
+ response = self._sync().get(url, headers=headers)
271
+ except Exception as e:
272
+ raise OpenBoxNetworkError(f"Cannot reach OpenBox Core at {self._api_url}: {e}") from e
273
+ return self._parse_auth_response(response)
274
+
275
+ async def avalidate_api_key(self) -> bool:
276
+ """Async :meth:`validate_api_key`."""
277
+ url, headers, _ = self._prepared("GET", AUTH_VALIDATE_PATH, None)
278
+ try:
279
+ response = await self._async().get(url, headers=headers)
280
+ except Exception as e:
281
+ raise OpenBoxNetworkError(f"Cannot reach OpenBox Core at {self._api_url}: {e}") from e
282
+ return self._parse_auth_response(response)
283
+
284
+ def _parse_auth_response(self, response: Any) -> bool:
285
+ if response.status_code == 200:
286
+ return True
287
+ if response.status_code in (401, 403):
288
+ # When signing is enabled, surface Core's machine reason code as an
289
+ # actionable signing error (signature_invalid, nonce_replayed, ...).
290
+ reason_code = (
291
+ _extract_reason_code(response.content) if self._identity is not None else None
292
+ )
293
+ if reason_code:
294
+ raise map_signing_error(reason_code)
295
+ raise OpenBoxAuthError("Invalid API key. Check your API key at dashboard.openbox.ai")
296
+ raise OpenBoxNetworkError(
297
+ f"Cannot reach OpenBox Core at {self._api_url}: HTTP {response.status_code}"
298
+ )