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.
- openbox_core/__init__.py +59 -0
- openbox_core/adapters/__init__.py +3 -0
- openbox_core/adapters/base.py +123 -0
- openbox_core/approvals.py +106 -0
- openbox_core/client.py +298 -0
- openbox_core/config.py +260 -0
- openbox_core/conformance/__init__.py +3 -0
- openbox_core/conformance/fake_core.py +169 -0
- openbox_core/conformance/hook_preflight.py +87 -0
- openbox_core/conformance/instrumentation.py +91 -0
- openbox_core/context.py +205 -0
- openbox_core/contracts/__init__.py +3 -0
- openbox_core/contracts/context.py +79 -0
- openbox_core/contracts/events.py +401 -0
- openbox_core/contracts/otel_spans.py +325 -0
- openbox_core/contracts/results.py +287 -0
- openbox_core/errors.py +287 -0
- openbox_core/gate.py +185 -0
- openbox_core/hooks/__init__.py +3 -0
- openbox_core/hooks/events.py +64 -0
- openbox_core/hooks/preflight.py +292 -0
- openbox_core/hooks/wrappers.py +105 -0
- openbox_core/identity.py +231 -0
- openbox_core/instrumentation/__init__.py +3 -0
- openbox_core/instrumentation/db.py +689 -0
- openbox_core/instrumentation/file.py +239 -0
- openbox_core/instrumentation/function.py +121 -0
- openbox_core/instrumentation/http.py +840 -0
- openbox_core/instrumentation/llm.py +3 -0
- openbox_core/instrumentation/manager.py +135 -0
- openbox_core/instrumentation/shared.py +27 -0
- openbox_core/otel/__init__.py +3 -0
- openbox_core/otel/propagation.py +45 -0
- openbox_core/otel/provider.py +35 -0
- openbox_core/otel/setup.py +36 -0
- openbox_core/otel/span_processor.py +62 -0
- openbox_core/otel/trace_context.py +71 -0
- openbox_core/py.typed +0 -0
- openbox_core/runtime.py +138 -0
- openbox_core/sdk_version.py +79 -0
- openbox_core/serialization.py +129 -0
- openbox_core/validation/__init__.py +3 -0
- openbox_core/validation/diagnostics.py +60 -0
- openbox_core/validation/event_rules.py +164 -0
- openbox_core/validation/registry.py +31 -0
- openbox_core/validation/span_normalization.py +107 -0
- openbox_core/wire/__init__.py +3 -0
- openbox_core/wire/core_span.py +130 -0
- openbox_core/wire/evaluate_payload.py +56 -0
- openbox_sdk_python-0.2.0.dist-info/METADATA +94 -0
- openbox_sdk_python-0.2.0.dist-info/RECORD +52 -0
- openbox_sdk_python-0.2.0.dist-info/WHEEL +4 -0
openbox_core/__init__.py
ADDED
|
@@ -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,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
|
+
)
|