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
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""HookRuntime — the SINGLE decision point for hook verdicts.
|
|
2
|
+
|
|
3
|
+
One path only: wrapper -> hook runtime -> adapter. Wrappers never interpret
|
|
4
|
+
verdicts; this runtime validates via the gate and delegates every native
|
|
5
|
+
effect to the FrameworkAdapter:
|
|
6
|
+
|
|
7
|
+
- started BLOCK/HALT -> mark abort (+halt flag) -> ``adapter.raise_hook_blocked``
|
|
8
|
+
- started REQUIRE_APPROVAL -> approval flow; rejected/unavailable -> blocked
|
|
9
|
+
- completed verdicts -> ``adapter.on_completed_hook_result`` + abort/halt
|
|
10
|
+
flags for FUTURE execution (the operation already ran; never undone)
|
|
11
|
+
- prior abort -> fail fast without another network call
|
|
12
|
+
- no bound context -> skip silently (not an error)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
from typing import Any, NoReturn
|
|
20
|
+
|
|
21
|
+
from ..approvals import ApprovalPoller
|
|
22
|
+
from ..contracts.events import EventEnvelope
|
|
23
|
+
from ..contracts.otel_spans import HookType, Stage
|
|
24
|
+
from ..contracts.results import EvaluationResult, Verdict
|
|
25
|
+
from ..errors import ContractError, GovernanceAPIError, GovernanceBlockedError
|
|
26
|
+
from ..hooks.events import build_hook_event, resolve_context
|
|
27
|
+
from ..runtime import OpenBoxRuntime
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
__all__ = ["HookRuntime"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class HookRuntime:
|
|
35
|
+
"""Drives preflight/completed hook evaluation for one OpenBoxRuntime."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, runtime: OpenBoxRuntime):
|
|
38
|
+
self._runtime = runtime
|
|
39
|
+
self._store = runtime.context_store
|
|
40
|
+
self._gate = runtime.gate
|
|
41
|
+
self._adapter = runtime.adapter
|
|
42
|
+
# Decide ONCE whether the adapter's completed callback takes ``context``,
|
|
43
|
+
# by inspecting its signature — so a genuine TypeError raised inside the
|
|
44
|
+
# callback body is never mistaken for an arity mismatch and swallowed.
|
|
45
|
+
self._completed_accepts_context = self._adapter_accepts_context()
|
|
46
|
+
hitl = runtime.config.hitl
|
|
47
|
+
self._sync_poller: ApprovalPoller | None = None
|
|
48
|
+
if hitl.enabled:
|
|
49
|
+
self._sync_poller = ApprovalPoller(
|
|
50
|
+
runtime.client,
|
|
51
|
+
poll_interval_seconds=hitl.poll_interval_ms / 1000.0,
|
|
52
|
+
max_wait_seconds=(hitl.max_wait_ms / 1000.0) if hitl.max_wait_ms else None,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# ── Preflight (started stage) ─────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
def preflight(
|
|
58
|
+
self,
|
|
59
|
+
span: Any,
|
|
60
|
+
*,
|
|
61
|
+
hook_type: HookType,
|
|
62
|
+
identifier: str = "",
|
|
63
|
+
fields: Mapping[str, Any] | None = None,
|
|
64
|
+
) -> bool:
|
|
65
|
+
"""Evaluate BEFORE the real operation. True ⇒ proceed.
|
|
66
|
+
|
|
67
|
+
Blocking outcomes never return — the adapter raises. Skipped hooks
|
|
68
|
+
(disabled preflight / no context) return True.
|
|
69
|
+
"""
|
|
70
|
+
event = self._pre_gate(span, hook_type, fields)
|
|
71
|
+
if event is None:
|
|
72
|
+
return True
|
|
73
|
+
try:
|
|
74
|
+
result = self._gate.preflight(event)
|
|
75
|
+
except (ContractError, GovernanceAPIError) as e:
|
|
76
|
+
self._fail_closed_started(e, span)
|
|
77
|
+
return self._decide_started(result, span, sync=True)
|
|
78
|
+
|
|
79
|
+
async def apreflight(
|
|
80
|
+
self,
|
|
81
|
+
span: Any,
|
|
82
|
+
*,
|
|
83
|
+
hook_type: HookType,
|
|
84
|
+
identifier: str = "",
|
|
85
|
+
fields: Mapping[str, Any] | None = None,
|
|
86
|
+
) -> bool:
|
|
87
|
+
"""Async :meth:`preflight` — approval delegates to the adapter."""
|
|
88
|
+
event = self._pre_gate(span, hook_type, fields)
|
|
89
|
+
if event is None:
|
|
90
|
+
return True
|
|
91
|
+
try:
|
|
92
|
+
result = await self._gate.apreflight(event)
|
|
93
|
+
except (ContractError, GovernanceAPIError) as e:
|
|
94
|
+
self._fail_closed_started(e, span)
|
|
95
|
+
return await self._adecide_started(result, span)
|
|
96
|
+
|
|
97
|
+
def _pre_gate(
|
|
98
|
+
self, span: Any, hook_type: HookType, fields: Mapping[str, Any] | None
|
|
99
|
+
) -> EventEnvelope | None:
|
|
100
|
+
if not self._runtime.config.instrumentation.preflight_enabled:
|
|
101
|
+
return None
|
|
102
|
+
ctx = resolve_context(self._store, span)
|
|
103
|
+
if ctx is not None:
|
|
104
|
+
# Abort short-circuit: a prior hook already stopped this activity.
|
|
105
|
+
if self._store.is_activity_aborted(ctx.workflow_id, ctx.activity_id):
|
|
106
|
+
self._adapter.raise_hook_blocked(
|
|
107
|
+
EvaluationResult(
|
|
108
|
+
verdict=Verdict.BLOCK,
|
|
109
|
+
reason="Activity aborted by a prior hook verdict",
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
return build_hook_event(
|
|
113
|
+
self._store, span, stage=Stage.STARTED, hook_type=hook_type, fields=fields
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def _fail_closed_started(self, error: Exception, span: Any) -> NoReturn:
|
|
117
|
+
"""Map a started-hook evaluation failure to a framework-native HALT.
|
|
118
|
+
|
|
119
|
+
Reached only when the failure must stop the operation: the client
|
|
120
|
+
raises ``GovernanceAPIError`` solely under ``on_api_error=fail_closed``
|
|
121
|
+
(fail-open returns an allow-shaped fallback instead), and started-hook
|
|
122
|
+
``ContractError``s always fail closed — a payload we cannot express to
|
|
123
|
+
Core must not let the operation run ungoverned. Raising the raw error
|
|
124
|
+
would leave frameworks treating it as a generic (often retryable)
|
|
125
|
+
failure; routing a HALT-shaped result through the adapter preserves
|
|
126
|
+
the non-retryable halt semantics.
|
|
127
|
+
"""
|
|
128
|
+
halt = EvaluationResult(
|
|
129
|
+
verdict=Verdict.HALT,
|
|
130
|
+
reason=f"Governance evaluation failed closed: {error}",
|
|
131
|
+
fallback_used=True,
|
|
132
|
+
raw={"fail_closed_error": str(error), "error_type": type(error).__name__},
|
|
133
|
+
)
|
|
134
|
+
self._mark_stopped(halt, span)
|
|
135
|
+
self._adapter.raise_hook_blocked(halt) # NoReturn by contract
|
|
136
|
+
raise GovernanceBlockedError(
|
|
137
|
+
halt.verdict, halt.reason or "Blocked (adapter returned)"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def _decide_started(self, result: EvaluationResult, span: Any, *, sync: bool) -> bool:
|
|
141
|
+
verdict = result.verdict
|
|
142
|
+
if verdict.should_stop():
|
|
143
|
+
self._mark_stopped(result, span)
|
|
144
|
+
self._adapter.raise_hook_blocked(result) # NoReturn by contract
|
|
145
|
+
# Defense in depth: a misbehaving adapter that RETURNS from its
|
|
146
|
+
# NoReturn callback must not fall through to run the operation.
|
|
147
|
+
raise GovernanceBlockedError(
|
|
148
|
+
result.verdict, result.reason or "Blocked (adapter returned)"
|
|
149
|
+
)
|
|
150
|
+
if verdict.requires_approval():
|
|
151
|
+
return self._sync_approval(result, span)
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
async def _adecide_started(self, result: EvaluationResult, span: Any) -> bool:
|
|
155
|
+
verdict = result.verdict
|
|
156
|
+
if verdict.should_stop():
|
|
157
|
+
self._mark_stopped(result, span)
|
|
158
|
+
self._adapter.raise_hook_blocked(result) # NoReturn by contract
|
|
159
|
+
# Defense in depth: a misbehaving adapter that RETURNS from its
|
|
160
|
+
# NoReturn callback must not fall through to run the operation.
|
|
161
|
+
raise GovernanceBlockedError(
|
|
162
|
+
result.verdict, result.reason or "Blocked (adapter returned)"
|
|
163
|
+
)
|
|
164
|
+
if verdict.requires_approval():
|
|
165
|
+
# Adapter drives its native approval flow; returning ⇒ approved.
|
|
166
|
+
await self._adapter.handle_approval(result)
|
|
167
|
+
return True
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
def _sync_approval(self, result: EvaluationResult, span: Any) -> bool:
|
|
171
|
+
"""Sync approval: adapter-native flow first, core poller fallback.
|
|
172
|
+
|
|
173
|
+
An adapter exposing ``handle_approval_sync`` owns the flow. Returning
|
|
174
|
+
normally means approved. Without that seam, drive the core poller;
|
|
175
|
+
no poller / no approval_id ⇒ fail safe: blocked (the operation must
|
|
176
|
+
not run on an unresolved approval).
|
|
177
|
+
"""
|
|
178
|
+
ctx = resolve_context(self._store, span)
|
|
179
|
+
adapter_sync = getattr(self._adapter, "handle_approval_sync", None)
|
|
180
|
+
if adapter_sync is not None:
|
|
181
|
+
# Pass the span-resolved context: ambient ContextVar lookup can
|
|
182
|
+
# miss in user-spawned threads, and the adapter needs the context
|
|
183
|
+
# for skip-HITL decisions and framework buffer correlation.
|
|
184
|
+
adapter_sync(result, context=ctx)
|
|
185
|
+
return True
|
|
186
|
+
if self._sync_poller is None or not result.approval_id or ctx is None:
|
|
187
|
+
self._mark_stopped(result, span)
|
|
188
|
+
self._adapter.raise_hook_blocked(result) # NoReturn by contract
|
|
189
|
+
# Defense in depth: a misbehaving adapter that RETURNS from its
|
|
190
|
+
# NoReturn callback must not fall through to run the operation.
|
|
191
|
+
raise GovernanceBlockedError(
|
|
192
|
+
result.verdict, result.reason or "Blocked (adapter returned)"
|
|
193
|
+
)
|
|
194
|
+
approval = self._sync_poller.wait_for_decision(
|
|
195
|
+
ctx.workflow_id or "", ctx.run_id or "", ctx.activity_id or ""
|
|
196
|
+
)
|
|
197
|
+
if approval.allow_shaped:
|
|
198
|
+
return True
|
|
199
|
+
self._mark_stopped(result, span)
|
|
200
|
+
self._adapter.raise_hook_blocked(result) # NoReturn by contract
|
|
201
|
+
raise GovernanceBlockedError(
|
|
202
|
+
result.verdict, result.reason or "Blocked (adapter returned)"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def _mark_stopped(self, result: EvaluationResult, span: Any) -> None:
|
|
206
|
+
ctx = resolve_context(self._store, span)
|
|
207
|
+
if ctx is not None:
|
|
208
|
+
self._store.mark_activity_aborted(ctx.workflow_id, ctx.activity_id)
|
|
209
|
+
if result.verdict is Verdict.HALT:
|
|
210
|
+
# Expose the halt request; the framework adapter decides how to
|
|
211
|
+
# stop future work.
|
|
212
|
+
self._store.request_halt()
|
|
213
|
+
|
|
214
|
+
# ── Completed (telemetry stage) ───────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
def completed(
|
|
217
|
+
self,
|
|
218
|
+
span: Any,
|
|
219
|
+
*,
|
|
220
|
+
hook_type: HookType,
|
|
221
|
+
fields: Mapping[str, Any] | None = None,
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Evaluate AFTER the operation ran. Never raises to the caller and
|
|
224
|
+
never undoes the operation — stop verdicts only mark FUTURE execution
|
|
225
|
+
blocked (abort/halt flags + adapter callback)."""
|
|
226
|
+
if not self._runtime.config.instrumentation.completed_telemetry_enabled:
|
|
227
|
+
return
|
|
228
|
+
event = build_hook_event(
|
|
229
|
+
self._store, span, stage=Stage.COMPLETED, hook_type=hook_type, fields=fields
|
|
230
|
+
)
|
|
231
|
+
if event is None:
|
|
232
|
+
return
|
|
233
|
+
try:
|
|
234
|
+
result = self._gate.completed(event)
|
|
235
|
+
except Exception:
|
|
236
|
+
logger.warning("completed-hook telemetry failed", exc_info=True)
|
|
237
|
+
return
|
|
238
|
+
self._after_completed(result, span)
|
|
239
|
+
|
|
240
|
+
async def acompleted(
|
|
241
|
+
self,
|
|
242
|
+
span: Any,
|
|
243
|
+
*,
|
|
244
|
+
hook_type: HookType,
|
|
245
|
+
fields: Mapping[str, Any] | None = None,
|
|
246
|
+
) -> None:
|
|
247
|
+
"""Async :meth:`completed`."""
|
|
248
|
+
if not self._runtime.config.instrumentation.completed_telemetry_enabled:
|
|
249
|
+
return
|
|
250
|
+
event = build_hook_event(
|
|
251
|
+
self._store, span, stage=Stage.COMPLETED, hook_type=hook_type, fields=fields
|
|
252
|
+
)
|
|
253
|
+
if event is None:
|
|
254
|
+
return
|
|
255
|
+
try:
|
|
256
|
+
result = await self._gate.acompleted(event)
|
|
257
|
+
except Exception:
|
|
258
|
+
logger.warning("completed-hook telemetry failed", exc_info=True)
|
|
259
|
+
return
|
|
260
|
+
self._after_completed(result, span)
|
|
261
|
+
|
|
262
|
+
def _adapter_accepts_context(self) -> bool:
|
|
263
|
+
"""True when the adapter's ``on_completed_hook_result`` accepts a
|
|
264
|
+
``context`` argument (checked once, by signature — not by catching a
|
|
265
|
+
TypeError from the call, which would mask real errors)."""
|
|
266
|
+
import inspect
|
|
267
|
+
|
|
268
|
+
callback = getattr(self._adapter, "on_completed_hook_result", None)
|
|
269
|
+
if callback is None:
|
|
270
|
+
return False
|
|
271
|
+
try:
|
|
272
|
+
params = inspect.signature(callback).parameters
|
|
273
|
+
except (TypeError, ValueError):
|
|
274
|
+
return False
|
|
275
|
+
# Accepts context via an explicit param or **kwargs.
|
|
276
|
+
return "context" in params or any(
|
|
277
|
+
p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def _after_completed(self, result: EvaluationResult, span: Any) -> None:
|
|
281
|
+
if result.verdict.should_stop():
|
|
282
|
+
self._mark_stopped(result, span) # future execution only
|
|
283
|
+
# Hand the span-resolved context so adapters can bridge a completed
|
|
284
|
+
# BLOCK/HALT to native effects on the correct run/activity.
|
|
285
|
+
ctx = resolve_context(self._store, span)
|
|
286
|
+
try:
|
|
287
|
+
if self._completed_accepts_context:
|
|
288
|
+
self._adapter.on_completed_hook_result(result, context=ctx)
|
|
289
|
+
else:
|
|
290
|
+
self._adapter.on_completed_hook_result(result)
|
|
291
|
+
except Exception:
|
|
292
|
+
logger.warning("adapter.on_completed_hook_result failed", exc_info=True)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Shared around-operation driver: preflight -> real op -> completed.
|
|
2
|
+
|
|
3
|
+
Wrappers never interpret verdicts — a blocked preflight raises out of the
|
|
4
|
+
hook runtime/adapter before the real operation is invoked. Completed
|
|
5
|
+
telemetry always fires (success AND failure paths) with duration + error.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ..contracts.otel_spans import HookType
|
|
15
|
+
from .preflight import HookRuntime
|
|
16
|
+
|
|
17
|
+
__all__ = ["run_governed_sync", "run_governed_async"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _completed_fields(
|
|
21
|
+
base: Mapping[str, Any] | None,
|
|
22
|
+
extra: Mapping[str, Any] | None,
|
|
23
|
+
duration_ms: float,
|
|
24
|
+
error: str | None,
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
fields = dict(base or {})
|
|
27
|
+
fields.update(extra or {})
|
|
28
|
+
fields.setdefault("duration_ns", int(duration_ms * 1_000_000))
|
|
29
|
+
if error is not None:
|
|
30
|
+
fields["error"] = error
|
|
31
|
+
return fields
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run_governed_sync(
|
|
35
|
+
hook_runtime: HookRuntime,
|
|
36
|
+
operation: Callable[..., Any],
|
|
37
|
+
args: tuple,
|
|
38
|
+
kwargs: dict,
|
|
39
|
+
*,
|
|
40
|
+
span: Any,
|
|
41
|
+
hook_type: HookType,
|
|
42
|
+
identifier: str = "",
|
|
43
|
+
started_fields: Mapping[str, Any] | None = None,
|
|
44
|
+
completed_fields: Callable[[Any], Mapping[str, Any] | None] | None = None,
|
|
45
|
+
) -> Any:
|
|
46
|
+
"""preflight -> operation -> completed (sync)."""
|
|
47
|
+
hook_runtime.preflight(
|
|
48
|
+
span, hook_type=hook_type, identifier=identifier, fields=started_fields
|
|
49
|
+
)
|
|
50
|
+
start = time.perf_counter()
|
|
51
|
+
try:
|
|
52
|
+
result = operation(*args, **kwargs)
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
55
|
+
hook_runtime.completed(
|
|
56
|
+
span,
|
|
57
|
+
hook_type=hook_type,
|
|
58
|
+
fields=_completed_fields(started_fields, None, duration_ms, str(exc)),
|
|
59
|
+
)
|
|
60
|
+
raise
|
|
61
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
62
|
+
extra = completed_fields(result) if completed_fields else None
|
|
63
|
+
hook_runtime.completed(
|
|
64
|
+
span,
|
|
65
|
+
hook_type=hook_type,
|
|
66
|
+
fields=_completed_fields(started_fields, extra, duration_ms, None),
|
|
67
|
+
)
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def run_governed_async(
|
|
72
|
+
hook_runtime: HookRuntime,
|
|
73
|
+
operation: Callable[..., Any],
|
|
74
|
+
args: tuple,
|
|
75
|
+
kwargs: dict,
|
|
76
|
+
*,
|
|
77
|
+
span: Any,
|
|
78
|
+
hook_type: HookType,
|
|
79
|
+
identifier: str = "",
|
|
80
|
+
started_fields: Mapping[str, Any] | None = None,
|
|
81
|
+
completed_fields: Callable[[Any], Mapping[str, Any] | None] | None = None,
|
|
82
|
+
) -> Any:
|
|
83
|
+
"""preflight -> operation -> completed (async)."""
|
|
84
|
+
await hook_runtime.apreflight(
|
|
85
|
+
span, hook_type=hook_type, identifier=identifier, fields=started_fields
|
|
86
|
+
)
|
|
87
|
+
start = time.perf_counter()
|
|
88
|
+
try:
|
|
89
|
+
result = await operation(*args, **kwargs)
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
92
|
+
await hook_runtime.acompleted(
|
|
93
|
+
span,
|
|
94
|
+
hook_type=hook_type,
|
|
95
|
+
fields=_completed_fields(started_fields, None, duration_ms, str(exc)),
|
|
96
|
+
)
|
|
97
|
+
raise
|
|
98
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
99
|
+
extra = completed_fields(result) if completed_fields else None
|
|
100
|
+
await hook_runtime.acompleted(
|
|
101
|
+
span,
|
|
102
|
+
hook_type=hook_type,
|
|
103
|
+
fields=_completed_fields(started_fields, extra, duration_ms, None),
|
|
104
|
+
)
|
|
105
|
+
return result
|
openbox_core/identity.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""AgentIdentity — AIP DID validation and Ed25519 request signing.
|
|
2
|
+
|
|
3
|
+
Implements the Core signed-request contract. The canonical string (must match
|
|
4
|
+
Core ``agent.go:93``)::
|
|
5
|
+
|
|
6
|
+
UPPER(METHOD)\nPATH\nTIMESTAMP\nNONCE\nBODY_SHA256_HEX
|
|
7
|
+
|
|
8
|
+
Contract invariants:
|
|
9
|
+
|
|
10
|
+
- The signing TIMESTAMP is ``datetime.now(timezone.utc).isoformat()`` —
|
|
11
|
+
it KEEPS ``+00:00`` and never uses ``Z``. The event-payload timestamp is a
|
|
12
|
+
different field with a different format.
|
|
13
|
+
- NONCE is ``secrets.token_urlsafe(24)``.
|
|
14
|
+
- Signature is standard padded base64 of the Ed25519 signature over the
|
|
15
|
+
canonical string, ASCII-decoded.
|
|
16
|
+
- PATH includes the ``/api/v1`` prefix; no host, no query.
|
|
17
|
+
- Body bytes are produced ONCE by ``serialization.serialize_body`` and sent
|
|
18
|
+
verbatim via ``content=body_bytes`` — NEVER ``json=``.
|
|
19
|
+
|
|
20
|
+
SANDBOX SAFETY: ``cryptography`` is imported lazily inside functions. This
|
|
21
|
+
module must never be imported from constrained framework paths; signing
|
|
22
|
+
happens only in client/runtime code.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import base64
|
|
28
|
+
import hashlib
|
|
29
|
+
import secrets
|
|
30
|
+
import uuid
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from datetime import UTC, datetime
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from .errors import OpenBoxConfigError
|
|
36
|
+
from .sdk_version import (
|
|
37
|
+
DEFAULT_SDK_ENGINE,
|
|
38
|
+
DEFAULT_SDK_LANGUAGE,
|
|
39
|
+
build_sdk_identifier,
|
|
40
|
+
)
|
|
41
|
+
from .serialization import serialize_body
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"AGENT_DID_PREFIX",
|
|
45
|
+
"EMPTY_BODY_SHA256",
|
|
46
|
+
"HEADER_DID",
|
|
47
|
+
"HEADER_TIMESTAMP",
|
|
48
|
+
"HEADER_NONCE",
|
|
49
|
+
"HEADER_SIGNATURE",
|
|
50
|
+
"HEADER_BODY_SHA256",
|
|
51
|
+
"validate_agent_did",
|
|
52
|
+
"load_ed25519_seed",
|
|
53
|
+
"AgentIdentity",
|
|
54
|
+
"build_canonical_string",
|
|
55
|
+
"build_auth_headers",
|
|
56
|
+
"prepare_signed_request",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
# Agent DID prefix; the suffix must be a parseable UUID (validated via
|
|
60
|
+
# uuid.UUID, matching Core's UUID parser rather than a loose regex).
|
|
61
|
+
AGENT_DID_PREFIX = "did:aip:"
|
|
62
|
+
|
|
63
|
+
# SHA-256 of empty bytes — body hash for GET / empty-body requests.
|
|
64
|
+
EMPTY_BODY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
65
|
+
|
|
66
|
+
# AIP signed-request header names (Core agent.go:26-30).
|
|
67
|
+
HEADER_DID = "X-OpenBox-Agent-DID"
|
|
68
|
+
HEADER_TIMESTAMP = "X-OpenBox-Agent-Timestamp"
|
|
69
|
+
HEADER_NONCE = "X-OpenBox-Agent-Nonce"
|
|
70
|
+
HEADER_SIGNATURE = "X-OpenBox-Agent-Signature"
|
|
71
|
+
HEADER_BODY_SHA256 = "X-OpenBox-Body-SHA256"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def validate_agent_did(agent_did: str) -> None:
|
|
75
|
+
"""Validate agent DID format (``did:aip:<uuid>``).
|
|
76
|
+
|
|
77
|
+
Parses the suffix with uuid.UUID so malformed UUID layouts fail locally at
|
|
78
|
+
init (matching Core's parser) rather than slipping through to a Core 4xx.
|
|
79
|
+
|
|
80
|
+
Raises OpenBoxConfigError on mismatch.
|
|
81
|
+
"""
|
|
82
|
+
if not isinstance(agent_did, str) or not agent_did.startswith(AGENT_DID_PREFIX):
|
|
83
|
+
raise OpenBoxConfigError(
|
|
84
|
+
f"Invalid agent DID format. Expected 'did:aip:<uuid>', "
|
|
85
|
+
f"got: '{str(agent_did)[:24]}...' (showing first 24 chars)"
|
|
86
|
+
)
|
|
87
|
+
suffix = agent_did[len(AGENT_DID_PREFIX):]
|
|
88
|
+
try:
|
|
89
|
+
uuid.UUID(suffix)
|
|
90
|
+
except (ValueError, AttributeError):
|
|
91
|
+
raise OpenBoxConfigError(
|
|
92
|
+
f"Invalid agent DID: '{agent_did[:24]}...' — the part after "
|
|
93
|
+
f"'{AGENT_DID_PREFIX}' is not a valid UUID."
|
|
94
|
+
) from None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load_ed25519_seed(agent_private_key: str) -> Any:
|
|
98
|
+
"""Decode a base64 raw 32-byte Ed25519 seed and load a private key object.
|
|
99
|
+
|
|
100
|
+
The provisioned key is a raw 32-byte seed (base64), NOT PKCS8. Returns a
|
|
101
|
+
cryptography Ed25519PrivateKey. Never echoes key bytes in error messages —
|
|
102
|
+
the seed is non-repudiation material.
|
|
103
|
+
|
|
104
|
+
Raises OpenBoxConfigError on any failure (bad base64, wrong length, load error).
|
|
105
|
+
"""
|
|
106
|
+
# cryptography imported lazily — keeps it off any eager import path.
|
|
107
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
seed = base64.b64decode(agent_private_key, validate=True)
|
|
111
|
+
except Exception:
|
|
112
|
+
raise OpenBoxConfigError(
|
|
113
|
+
"Invalid agent private key: not valid base64 (key bytes not shown)."
|
|
114
|
+
) from None
|
|
115
|
+
|
|
116
|
+
if len(seed) != 32:
|
|
117
|
+
raise OpenBoxConfigError(
|
|
118
|
+
f"Invalid agent private key: expected a 32-byte Ed25519 seed, "
|
|
119
|
+
f"got {len(seed)} bytes (key bytes not shown)."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
return Ed25519PrivateKey.from_private_bytes(seed)
|
|
124
|
+
except Exception:
|
|
125
|
+
raise OpenBoxConfigError(
|
|
126
|
+
"Invalid agent private key: could not load Ed25519 key (key bytes not shown)."
|
|
127
|
+
) from None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class AgentIdentity:
|
|
132
|
+
"""A validated agent DID plus its loaded Ed25519 signer.
|
|
133
|
+
|
|
134
|
+
Stores the loaded key OBJECT — never raw seed bytes/strings after init.
|
|
135
|
+
Construct via :meth:`from_private_key` for validation.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
agent_did: str
|
|
139
|
+
signer: Any = field(repr=False) # Ed25519PrivateKey; excluded from repr
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def from_private_key(cls, agent_did: str, agent_private_key: str) -> AgentIdentity:
|
|
143
|
+
"""Validate the DID, decode + load the seed, return a ready identity."""
|
|
144
|
+
validate_agent_did(agent_did)
|
|
145
|
+
return cls(agent_did=agent_did, signer=load_ed25519_seed(agent_private_key))
|
|
146
|
+
|
|
147
|
+
def sign(self, canonical: str) -> str:
|
|
148
|
+
"""Sign a canonical string; return standard padded base64 (ASCII)."""
|
|
149
|
+
return base64.b64encode(self.signer.sign(canonical.encode("utf-8"))).decode("ascii")
|
|
150
|
+
|
|
151
|
+
def __repr__(self) -> str: # never leak key material
|
|
152
|
+
return f"AgentIdentity(agent_did={self.agent_did!r}, signer=<loaded>)"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def build_canonical_string(
|
|
156
|
+
method: str, path: str, timestamp: str, nonce: str, body_sha256: str
|
|
157
|
+
) -> str:
|
|
158
|
+
"""The exact canonical string Core verifies (``agent.go:93``)."""
|
|
159
|
+
return "\n".join([method.upper(), path, timestamp, nonce, body_sha256])
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def build_auth_headers(
|
|
163
|
+
api_key: str,
|
|
164
|
+
sdk_version: str | None = None,
|
|
165
|
+
*,
|
|
166
|
+
sdk_engine: str = DEFAULT_SDK_ENGINE,
|
|
167
|
+
sdk_language: str = DEFAULT_SDK_LANGUAGE,
|
|
168
|
+
) -> dict[str, str]:
|
|
169
|
+
"""Standard bearer auth headers for governance API calls."""
|
|
170
|
+
sdk_identifier = build_sdk_identifier(
|
|
171
|
+
engine=sdk_engine,
|
|
172
|
+
language=sdk_language,
|
|
173
|
+
version=sdk_version,
|
|
174
|
+
)
|
|
175
|
+
return {
|
|
176
|
+
"Authorization": f"Bearer {api_key}",
|
|
177
|
+
"User-Agent": f"OpenBox-SDK/{sdk_identifier}",
|
|
178
|
+
"X-OpenBox-SDK-Version": sdk_identifier,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def prepare_signed_request(
|
|
183
|
+
method: str,
|
|
184
|
+
path: str,
|
|
185
|
+
payload: dict | None,
|
|
186
|
+
*,
|
|
187
|
+
api_key: str,
|
|
188
|
+
identity: AgentIdentity | None,
|
|
189
|
+
sdk_version: str | None = None,
|
|
190
|
+
sdk_engine: str = DEFAULT_SDK_ENGINE,
|
|
191
|
+
sdk_language: str = DEFAULT_SDK_LANGUAGE,
|
|
192
|
+
_timestamp: str | None = None,
|
|
193
|
+
_nonce: str | None = None,
|
|
194
|
+
) -> tuple[dict[str, str], bytes]:
|
|
195
|
+
"""Build request headers + exact body bytes — the single source of truth.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
method: HTTP method (case-insensitive; upper-cased into the canonical string).
|
|
199
|
+
path: URL path only, no host/query — INCLUDES the ``/api/v1`` prefix.
|
|
200
|
+
payload: JSON-serializable body, or ``None`` for empty-body (GET) requests.
|
|
201
|
+
api_key: Bearer API key for the base auth headers.
|
|
202
|
+
identity: Loaded AgentIdentity, or ``None`` for unsigned mode.
|
|
203
|
+
_timestamp/_nonce: Deterministic injection points for golden-fixture
|
|
204
|
+
tests ONLY. Production callers must not pass them — a reused nonce
|
|
205
|
+
is rejected by Core (nonce_replayed).
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
``(headers, body_bytes)``. Callers MUST send ``content=body_bytes`` —
|
|
209
|
+
never ``json=`` — so the transmitted bytes match the hashed bytes.
|
|
210
|
+
"""
|
|
211
|
+
body_bytes = serialize_body(payload)
|
|
212
|
+
headers = build_auth_headers(
|
|
213
|
+
api_key,
|
|
214
|
+
sdk_version,
|
|
215
|
+
sdk_engine=sdk_engine,
|
|
216
|
+
sdk_language=sdk_language,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
if identity is not None:
|
|
220
|
+
body_sha256 = hashlib.sha256(body_bytes).hexdigest()
|
|
221
|
+
# Signing timestamp KEEPS +00:00 (never Z) — Core verifies these bytes.
|
|
222
|
+
timestamp = _timestamp if _timestamp is not None else datetime.now(UTC).isoformat()
|
|
223
|
+
nonce = _nonce if _nonce is not None else secrets.token_urlsafe(24)
|
|
224
|
+
canonical = build_canonical_string(method, path, timestamp, nonce, body_sha256)
|
|
225
|
+
headers[HEADER_DID] = identity.agent_did
|
|
226
|
+
headers[HEADER_TIMESTAMP] = timestamp
|
|
227
|
+
headers[HEADER_NONCE] = nonce
|
|
228
|
+
headers[HEADER_SIGNATURE] = identity.sign(canonical)
|
|
229
|
+
headers[HEADER_BODY_SHA256] = body_sha256
|
|
230
|
+
|
|
231
|
+
return headers, body_bytes
|