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,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
@@ -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
@@ -0,0 +1,3 @@
1
+ """Generic operation instrumentation (HTTP/DB/file/function/LLM)."""
2
+
3
+ __all__: list[str] = []