openbox-langgraph-sdk-python 0.1.2__py3-none-any.whl → 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.
@@ -32,11 +32,19 @@ from openbox_langgraph.errors import (
32
32
  GovernanceHaltError,
33
33
  GuardrailsValidationError,
34
34
  OpenBoxAuthError,
35
+ OpenBoxConfigError,
35
36
  OpenBoxError,
36
37
  OpenBoxInsecureURLError,
37
38
  OpenBoxNetworkError,
38
39
  )
39
40
  from openbox_langgraph.hitl import poll_until_decision
41
+ from openbox_langgraph.identity import (
42
+ AgentIdentityConfig,
43
+ build_agent_identity_canonical_request,
44
+ create_agent_identity_headers,
45
+ parse_optional_agent_identity_config,
46
+ validate_agent_identity_config,
47
+ )
40
48
  from openbox_langgraph.langgraph_handler import (
41
49
  OpenBoxLangGraphHandler,
42
50
  OpenBoxLangGraphHandlerOptions,
@@ -79,6 +87,7 @@ from openbox_langgraph.verdict_handler import (
79
87
 
80
88
  __all__ = [
81
89
  "DEFAULT_HITL_CONFIG",
90
+ "AgentIdentityConfig",
82
91
  "ApprovalExpiredError",
83
92
  "ApprovalRejectedError",
84
93
  "ApprovalResponse",
@@ -95,6 +104,7 @@ __all__ = [
95
104
  "LangChainGovernanceEvent",
96
105
  "LangGraphStreamEvent",
97
106
  "OpenBoxAuthError",
107
+ "OpenBoxConfigError",
98
108
  "OpenBoxError",
99
109
  "OpenBoxInsecureURLError",
100
110
  "OpenBoxLangGraphHandler",
@@ -105,7 +115,9 @@ __all__ = [
105
115
  "WorkflowEventType",
106
116
  "WorkflowSpanBuffer",
107
117
  "WorkflowSpanProcessor",
118
+ "build_agent_identity_canonical_request",
108
119
  "build_auth_headers",
120
+ "create_agent_identity_headers",
109
121
  "create_openbox_graph_handler",
110
122
  "create_span",
111
123
  "enforce_verdict",
@@ -117,12 +129,14 @@ __all__ = [
117
129
  "merge_config",
118
130
  "parse_approval_response",
119
131
  "parse_governance_response",
132
+ "parse_optional_agent_identity_config",
120
133
  "poll_until_decision",
121
134
  "rfc3339_now",
122
135
  "safe_serialize",
123
136
  "setup_opentelemetry_for_governance",
124
137
  "to_server_event_type",
125
138
  "traced",
139
+ "validate_agent_identity_config",
126
140
  "verdict_from_string",
127
141
  "verdict_priority",
128
142
  "verdict_requires_approval",
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import os
6
7
  from dataclasses import dataclass
7
8
  from datetime import UTC
@@ -9,7 +10,12 @@ from typing import Any
9
10
 
10
11
  import httpx
11
12
 
12
- from openbox_langgraph.errors import OpenBoxNetworkError
13
+ from openbox_langgraph.errors import OpenBoxConfigError, OpenBoxNetworkError
14
+ from openbox_langgraph.identity import (
15
+ AgentIdentityConfig,
16
+ create_agent_identity_headers,
17
+ parse_optional_agent_identity_config,
18
+ )
13
19
  from openbox_langgraph.types import (
14
20
  ApprovalResponse,
15
21
  GovernanceVerdictResponse,
@@ -19,20 +25,41 @@ from openbox_langgraph.types import (
19
25
  to_server_event_type,
20
26
  )
21
27
 
22
- _SDK_VERSION = "0.1.0"
28
+ _SDK_VERSION = "0.2.0"
23
29
 
24
30
 
25
- def build_auth_headers(api_key: str) -> dict[str, str]:
31
+ def build_auth_headers(
32
+ api_key: str,
33
+ *,
34
+ method: str | None = None,
35
+ pathname: str | None = None,
36
+ body: bytes | str | None = None,
37
+ agent_identity: AgentIdentityConfig | None = None,
38
+ ) -> dict[str, str]:
26
39
  """Build standard auth headers for governance API calls.
27
40
 
28
41
  Single source of truth — used by GovernanceClient and hook_governance.
29
42
  """
30
- return {
43
+ headers = {
31
44
  "Authorization": f"Bearer {api_key}",
32
45
  "Content-Type": "application/json",
33
46
  "User-Agent": f"OpenBox-LangGraph-SDK/{_SDK_VERSION}",
34
47
  "X-OpenBox-SDK-Version": _SDK_VERSION,
35
48
  }
49
+ if agent_identity:
50
+ if method is None or pathname is None:
51
+ msg = "method and pathname are required when signing OpenBox requests."
52
+ raise OpenBoxConfigError(msg)
53
+ headers.update(
54
+ create_agent_identity_headers(
55
+ did=agent_identity.did,
56
+ private_key=agent_identity.private_key,
57
+ method=method,
58
+ pathname=pathname,
59
+ body=body,
60
+ )
61
+ )
62
+ return headers
36
63
 
37
64
 
38
65
  @dataclass
@@ -58,6 +85,8 @@ class GovernanceClient:
58
85
  api_key: str,
59
86
  timeout: float = 30.0, # seconds
60
87
  on_api_error: str = "fail_open",
88
+ agent_did: str | None = None,
89
+ agent_private_key: str | None = None,
61
90
  ) -> None:
62
91
  self._api_url = api_url.rstrip("/")
63
92
  self._api_key = api_key
@@ -65,7 +94,10 @@ class GovernanceClient:
65
94
  self._on_api_error = on_api_error
66
95
  self._client: httpx.AsyncClient | None = None
67
96
  self._sync_client: httpx.Client | None = None
68
- self._cached_headers = build_auth_headers(api_key)
97
+ self._agent_identity = parse_optional_agent_identity_config(
98
+ did=agent_did,
99
+ private_key=agent_private_key,
100
+ )
69
101
  # Deduplication: prevent sending the same (activity_id, event_type) twice
70
102
  # within the same workflow run. Keyed by (workflow_id, run_id) so it resets
71
103
  # automatically on each new ainvoke() call.
@@ -110,7 +142,11 @@ class GovernanceClient:
110
142
  client = self._get_client()
111
143
  response = await client.get(
112
144
  f"{self._api_url}/api/v1/auth/validate",
113
- headers=self._headers(),
145
+ headers=self._headers(
146
+ method="GET",
147
+ pathname="/api/v1/auth/validate",
148
+ body=b"",
149
+ ),
114
150
  )
115
151
  if response.status_code in (401, 403):
116
152
  msg = "Invalid API key. Check your API key at dashboard.openbox.ai"
@@ -176,16 +212,22 @@ class GovernanceClient:
176
212
 
177
213
  if os.environ.get("OPENBOX_DEBUG") == "1":
178
214
  import json
215
+
179
216
  print(
180
217
  f"[OpenBox Debug] governance request: {json.dumps(payload, indent=2, default=str)}"
181
218
  )
182
219
 
183
220
  try:
184
221
  client = self._get_client()
222
+ body = _json_body(payload)
185
223
  response = await client.post(
186
224
  f"{self._api_url}/api/v1/governance/evaluate",
187
- headers=self._headers(),
188
- json=payload,
225
+ headers=self._headers(
226
+ method="POST",
227
+ pathname="/api/v1/governance/evaluate",
228
+ body=body,
229
+ ),
230
+ content=body,
189
231
  )
190
232
 
191
233
  if not response.is_success:
@@ -226,6 +268,7 @@ class GovernanceClient:
226
268
 
227
269
  if os.environ.get("OPENBOX_DEBUG") == "1":
228
270
  import json
271
+
229
272
  print(
230
273
  "[OpenBox Debug] sync governance request:"
231
274
  f" {json.dumps(payload, indent=2, default=str)}"
@@ -233,10 +276,15 @@ class GovernanceClient:
233
276
 
234
277
  try:
235
278
  client = self._get_sync_client()
279
+ body = _json_body(payload)
236
280
  response = client.post(
237
281
  f"{self._api_url}/api/v1/governance/evaluate",
238
- headers=self._headers(),
239
- json=payload,
282
+ headers=self._headers(
283
+ method="POST",
284
+ pathname="/api/v1/governance/evaluate",
285
+ body=body,
286
+ ),
287
+ content=body,
240
288
  )
241
289
 
242
290
  if not response.is_success:
@@ -256,9 +304,7 @@ class GovernanceClient:
256
304
  raise OpenBoxNetworkError(msg) from e
257
305
  return None
258
306
 
259
- async def poll_approval(
260
- self, params: ApprovalPollParams
261
- ) -> ApprovalResponse | None:
307
+ async def poll_approval(self, params: ApprovalPollParams) -> ApprovalResponse | None:
262
308
  """Poll for HITL approval status.
263
309
 
264
310
  Returns `None` on network failure so the caller can retry.
@@ -268,14 +314,21 @@ class GovernanceClient:
268
314
  """
269
315
  try:
270
316
  client = self._get_client()
271
- response = await client.post(
272
- f"{self._api_url}/api/v1/governance/approval",
273
- headers=self._headers(),
274
- json={
317
+ body = _json_body(
318
+ {
275
319
  "workflow_id": params.workflow_id,
276
320
  "run_id": params.run_id,
277
321
  "activity_id": params.activity_id,
278
- },
322
+ }
323
+ )
324
+ response = await client.post(
325
+ f"{self._api_url}/api/v1/governance/approval",
326
+ headers=self._headers(
327
+ method="POST",
328
+ pathname="/api/v1/governance/approval",
329
+ body=body,
330
+ ),
331
+ content=body,
279
332
  )
280
333
 
281
334
  if not response.is_success:
@@ -287,6 +340,7 @@ class GovernanceClient:
287
340
  # SDK-side expiration check
288
341
  if parsed.approval_expiration_time and not parsed.expired:
289
342
  from datetime import datetime
343
+
290
344
  expiry = datetime.fromisoformat(
291
345
  parsed.approval_expiration_time.replace("Z", "+00:00")
292
346
  )
@@ -298,9 +352,7 @@ class GovernanceClient:
298
352
  except Exception:
299
353
  return None
300
354
 
301
- async def evaluate_raw(
302
- self, payload: dict[str, Any]
303
- ) -> dict[str, Any] | None:
355
+ async def evaluate_raw(self, payload: dict[str, Any]) -> dict[str, Any] | None:
304
356
  """Send a pre-built payload to the governance evaluate endpoint.
305
357
 
306
358
  Used by hook-level governance where the payload is fully assembled
@@ -311,16 +363,22 @@ class GovernanceClient:
311
363
  """
312
364
  if os.environ.get("OPENBOX_DEBUG") == "1":
313
365
  import json
366
+
314
367
  print(
315
368
  f"[OpenBox Debug] span hook request: {json.dumps(payload, indent=2, default=str)}"
316
369
  )
317
370
 
318
371
  try:
319
372
  client = self._get_client()
373
+ body = _json_body(payload)
320
374
  response = await client.post(
321
375
  f"{self._api_url}/api/v1/governance/evaluate",
322
- headers=self._headers(),
323
- json=payload,
376
+ headers=self._headers(
377
+ method="POST",
378
+ pathname="/api/v1/governance/evaluate",
379
+ body=body,
380
+ ),
381
+ content=body,
324
382
  )
325
383
 
326
384
  if not response.is_success:
@@ -354,5 +412,15 @@ class GovernanceClient:
354
412
  # Private helpers
355
413
  # ─────────────────────────────────────────────────────────────
356
414
 
357
- def _headers(self) -> dict[str, str]:
358
- return self._cached_headers
415
+ def _headers(self, *, method: str, pathname: str, body: bytes | str | None) -> dict[str, str]:
416
+ return build_auth_headers(
417
+ self._api_key,
418
+ method=method,
419
+ pathname=pathname,
420
+ body=body,
421
+ agent_identity=self._agent_identity,
422
+ )
423
+
424
+
425
+ def _json_body(payload: dict[str, Any]) -> bytes:
426
+ return json.dumps(payload, separators=(",", ":"), default=str).encode("utf-8")
@@ -7,22 +7,31 @@ from __future__ import annotations
7
7
 
8
8
  import re
9
9
  from dataclasses import dataclass, field
10
- from typing import Any
10
+ from typing import TYPE_CHECKING, Any
11
11
 
12
12
  from openbox_langgraph.errors import (
13
13
  OpenBoxAuthError,
14
+ OpenBoxConfigError,
14
15
  OpenBoxInsecureURLError,
15
16
  OpenBoxNetworkError,
16
17
  )
18
+ from openbox_langgraph.identity import (
19
+ AgentIdentityConfig,
20
+ parse_optional_agent_identity_config,
21
+ )
17
22
  from openbox_langgraph.types import DEFAULT_HITL_CONFIG, HITLConfig
18
23
 
24
+ if TYPE_CHECKING:
25
+ from logging import Logger
26
+
19
27
  # API key format pattern (obx_live_... or obx_test_...)
20
28
  _API_KEY_PATTERN = re.compile(r"^obx_(live|test)_[a-zA-Z0-9_]+$")
21
29
 
22
30
 
23
- def _get_logger():
31
+ def _get_logger() -> Logger:
24
32
  """Lazy logger import."""
25
33
  import logging
34
+
26
35
  return logging.getLogger(__name__)
27
36
 
28
37
 
@@ -30,6 +39,7 @@ def _get_logger():
30
39
  # API key / URL validation
31
40
  # ═══════════════════════════════════════════════════════════════════
32
41
 
42
+
33
43
  def validate_api_key_format(api_key: str) -> bool:
34
44
  """Return True if the API key matches the expected `obx_live_*` / `obx_test_*` format."""
35
45
  return bool(_API_KEY_PATTERN.match(api_key))
@@ -53,6 +63,7 @@ def validate_url_security(api_url: str) -> None:
53
63
  # GovernanceConfig
54
64
  # ═══════════════════════════════════════════════════════════════════
55
65
 
66
+
56
67
  @dataclass
57
68
  class GovernanceConfig:
58
69
  """Full resolved governance configuration for the handler."""
@@ -143,16 +154,27 @@ def merge_config(partial: dict[str, Any] | None = None) -> GovernanceConfig:
143
154
  # Global Config Singleton
144
155
  # ═══════════════════════════════════════════════════════════════════
145
156
 
157
+
146
158
  @dataclass
147
159
  class _GlobalConfigState:
148
160
  api_url: str = ""
149
161
  api_key: str = ""
150
162
  governance_timeout: float = 30.0 # seconds
151
-
152
- def configure(self, api_url: str, api_key: str, governance_timeout: float = 30.0) -> None:
163
+ agent_did: str | None = None
164
+ agent_private_key: str | None = None
165
+
166
+ def configure(
167
+ self,
168
+ api_url: str,
169
+ api_key: str,
170
+ governance_timeout: float = 30.0,
171
+ agent_identity: AgentIdentityConfig | None = None,
172
+ ) -> None:
153
173
  self.api_url = api_url.rstrip("/")
154
174
  self.api_key = api_key
155
175
  self.governance_timeout = governance_timeout
176
+ self.agent_did = agent_identity.did if agent_identity else None
177
+ self.agent_private_key = agent_identity.private_key if agent_identity else None
156
178
 
157
179
  def __repr__(self) -> str:
158
180
  if self.api_key and len(self.api_key) > 8:
@@ -164,7 +186,8 @@ class _GlobalConfigState:
164
186
  return (
165
187
  f"_GlobalConfigState(api_url={self.api_url!r}, "
166
188
  f"api_key={masked!r}, "
167
- f"governance_timeout={self.governance_timeout})"
189
+ f"governance_timeout={self.governance_timeout}, "
190
+ f"agent_did={self.agent_did!r})"
168
191
  )
169
192
 
170
193
  def is_configured(self) -> bool:
@@ -183,8 +206,12 @@ def get_global_config() -> _GlobalConfigState:
183
206
  # Server-side API key validation (sync, using urllib — no httpx at module level)
184
207
  # ═══════════════════════════════════════════════════════════════════
185
208
 
209
+
186
210
  def _validate_api_key_with_server(
187
- api_url: str, api_key: str, timeout: float
211
+ api_url: str,
212
+ api_key: str,
213
+ timeout: float,
214
+ agent_identity: AgentIdentityConfig | None = None,
188
215
  ) -> None:
189
216
  """Validate API key by calling /api/v1/auth/validate endpoint (synchronous).
190
217
 
@@ -194,14 +221,18 @@ def _validate_api_key_with_server(
194
221
  from urllib.error import HTTPError, URLError # lazy
195
222
  from urllib.request import Request, urlopen # lazy
196
223
 
224
+ from openbox_langgraph.client import build_auth_headers # lazy
225
+
197
226
  try:
198
227
  req = Request(
199
228
  f"{api_url}/api/v1/auth/validate",
200
- headers={
201
- "Authorization": f"Bearer {api_key}",
202
- "Content-Type": "application/json",
203
- "User-Agent": "OpenBox-LangGraph-SDK/0.1.0",
204
- },
229
+ headers=build_auth_headers(
230
+ api_key,
231
+ method="GET",
232
+ pathname="/api/v1/auth/validate",
233
+ body=b"",
234
+ agent_identity=agent_identity,
235
+ ),
205
236
  method="GET",
206
237
  )
207
238
  with urlopen(req, timeout=timeout) as response:
@@ -230,11 +261,14 @@ def _validate_api_key_with_server(
230
261
  # initialize() — synchronous, matching openbox-temporal-sdk-python
231
262
  # ═══════════════════════════════════════════════════════════════════
232
263
 
264
+
233
265
  def initialize(
234
266
  api_url: str,
235
267
  api_key: str,
236
268
  governance_timeout: float = 30.0,
237
269
  validate: bool = True,
270
+ agent_did: str | None = None,
271
+ agent_private_key: str | None = None,
238
272
  ) -> None:
239
273
  """Initialize the OpenBox LangGraph SDK with credentials.
240
274
 
@@ -246,7 +280,12 @@ def initialize(
246
280
  api_key: API key in `obx_live_*` or `obx_test_*` format.
247
281
  governance_timeout: HTTP timeout in **seconds** for governance calls (default 30.0).
248
282
  validate: If True, validates the API key against the server on startup.
283
+ agent_did: Optional OpenBox agent DID. Falls back to `OPENBOX_AGENT_DID`.
284
+ agent_private_key: Optional raw Ed25519 private key seed. Falls back to
285
+ `OPENBOX_AGENT_PRIVATE_KEY`.
249
286
  """
287
+ import os
288
+
250
289
  validate_url_security(api_url)
251
290
 
252
291
  if not validate_api_key_format(api_key):
@@ -259,9 +298,27 @@ def initialize(
259
298
  )
260
299
  raise OpenBoxAuthError(msg)
261
300
 
262
- _global_config.configure(api_url.rstrip("/"), api_key, governance_timeout)
301
+ try:
302
+ agent_identity = parse_optional_agent_identity_config(
303
+ did=agent_did or os.environ.get("OPENBOX_AGENT_DID"),
304
+ private_key=agent_private_key or os.environ.get("OPENBOX_AGENT_PRIVATE_KEY"),
305
+ )
306
+ except OpenBoxConfigError:
307
+ raise
263
308
 
264
309
  if validate:
265
- _validate_api_key_with_server(api_url.rstrip("/"), api_key, governance_timeout)
310
+ _validate_api_key_with_server(
311
+ api_url.rstrip("/"),
312
+ api_key,
313
+ governance_timeout,
314
+ agent_identity,
315
+ )
316
+
317
+ _global_config.configure(
318
+ api_url.rstrip("/"),
319
+ api_key,
320
+ governance_timeout,
321
+ agent_identity,
322
+ )
266
323
 
267
324
  _get_logger().info(f"OpenBox LangGraph SDK initialized with API URL: {api_url}")
@@ -11,6 +11,10 @@ class OpenBoxAuthError(OpenBoxError):
11
11
  """Raised when the API key is invalid or unauthorized."""
12
12
 
13
13
 
14
+ class OpenBoxConfigError(OpenBoxError):
15
+ """Raised when SDK configuration is invalid."""
16
+
17
+
14
18
  class OpenBoxNetworkError(OpenBoxError):
15
19
  """Raised when the OpenBox Core API is unreachable or returns an error."""
16
20
 
@@ -21,6 +21,7 @@ from typing import TYPE_CHECKING, Any
21
21
  import httpx
22
22
 
23
23
  from .client import build_auth_headers
24
+ from .identity import AgentIdentityConfig, parse_optional_agent_identity_config
24
25
 
25
26
  if TYPE_CHECKING:
26
27
  from .span_processor import WorkflowSpanProcessor
@@ -37,7 +38,7 @@ _api_key: str = ""
37
38
  _api_timeout: float = 30.0
38
39
  _on_api_error: str = FAIL_OPEN
39
40
  _span_processor: WorkflowSpanProcessor | None = None
40
- _cached_auth_headers: dict | None = None
41
+ _agent_identity: AgentIdentityConfig | None = None
41
42
 
42
43
  # Persistent HTTP clients (lazy-init, thread-safe for requests)
43
44
  _sync_client: httpx.Client | None = None
@@ -51,6 +52,8 @@ def configure(
51
52
  *,
52
53
  api_timeout: float = 30.0,
53
54
  on_api_error: str = "fail_open",
55
+ agent_did: str | None = None,
56
+ agent_private_key: str | None = None,
54
57
  ) -> None:
55
58
  """Set governance config. Called once by setup_opentelemetry_for_governance().
56
59
 
@@ -60,16 +63,20 @@ def configure(
60
63
  span_processor: WorkflowSpanProcessor for activity context lookup
61
64
  api_timeout: Timeout for governance API calls (seconds)
62
65
  on_api_error: Error policy — "fail_open" or "fail_closed"
66
+ agent_did: Optional OpenBox agent DID for AIP request signing
67
+ agent_private_key: Optional OpenBox agent private key for AIP request signing
63
68
  """
64
69
  global _api_url, _api_key, _api_timeout, _on_api_error
65
- global _span_processor, _sync_client, _async_client, _cached_auth_headers
70
+ global _span_processor, _sync_client, _async_client, _agent_identity
66
71
  _api_url = api_url.rstrip("/")
67
72
  _api_key = api_key
68
73
  _api_timeout = api_timeout
69
74
  _on_api_error = on_api_error
70
75
  _span_processor = span_processor
71
- # Cache auth headers (immutable after configure)
72
- _cached_auth_headers = build_auth_headers(api_key)
76
+ _agent_identity = parse_optional_agent_identity_config(
77
+ did=agent_did,
78
+ private_key=agent_private_key,
79
+ )
73
80
  # Reset persistent clients so they pick up new timeout/config
74
81
  _sync_client = None
75
82
  _async_client = None
@@ -138,9 +145,15 @@ def extract_span_context(span) -> tuple:
138
145
  return span_id, trace_id, parent_span_id
139
146
 
140
147
 
141
- def _auth_headers() -> dict:
142
- """Return cached auth headers (built once in configure())."""
143
- return _cached_auth_headers or build_auth_headers(_api_key)
148
+ def _auth_headers(*, method: str, pathname: str, body: bytes | str | None) -> dict[str, str]:
149
+ """Build auth headers for the exact outbound governance request."""
150
+ return build_auth_headers(
151
+ _api_key,
152
+ method=method,
153
+ pathname=pathname,
154
+ body=body,
155
+ agent_identity=_agent_identity,
156
+ )
144
157
 
145
158
 
146
159
  def _build_payload(
@@ -332,10 +345,15 @@ def evaluate_sync(
332
345
 
333
346
  try:
334
347
  client = _get_sync_client()
348
+ body = _json_body(payload)
335
349
  response = client.post(
336
350
  f"{_api_url}/api/v1/governance/evaluate",
337
- json=payload,
338
- headers=_auth_headers(),
351
+ content=body,
352
+ headers=_auth_headers(
353
+ method="POST",
354
+ pathname="/api/v1/governance/evaluate",
355
+ body=body,
356
+ ),
339
357
  )
340
358
  _send_and_handle(response, identifier, span=span)
341
359
 
@@ -380,10 +398,15 @@ async def evaluate_async(
380
398
 
381
399
  try:
382
400
  client = _get_async_client()
401
+ body = _json_body(payload)
383
402
  response = await client.post(
384
403
  f"{_api_url}/api/v1/governance/evaluate",
385
- json=payload,
386
- headers=_auth_headers(),
404
+ content=body,
405
+ headers=_auth_headers(
406
+ method="POST",
407
+ pathname="/api/v1/governance/evaluate",
408
+ body=body,
409
+ ),
387
410
  )
388
411
  _send_and_handle(response, identifier, span=span)
389
412
 
@@ -395,3 +418,7 @@ async def evaluate_async(
395
418
  raise GovernanceBlockedError(
396
419
  "halt", f"Governance evaluation error: {e}", identifier
397
420
  ) from e
421
+
422
+
423
+ def _json_body(payload: dict[str, Any]) -> bytes:
424
+ return json.dumps(payload, separators=(",", ":"), default=str).encode("utf-8")
@@ -0,0 +1,160 @@
1
+ """OpenBox AIP agent identity signing helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import re
8
+ import uuid
9
+ from dataclasses import dataclass
10
+ from datetime import UTC, datetime
11
+
12
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
13
+
14
+ from openbox_langgraph.errors import OpenBoxConfigError
15
+
16
+ OPENBOX_AGENT_DID_HEADER = "X-OpenBox-Agent-DID"
17
+ OPENBOX_AGENT_TIMESTAMP_HEADER = "X-OpenBox-Agent-Timestamp"
18
+ OPENBOX_AGENT_NONCE_HEADER = "X-OpenBox-Agent-Nonce"
19
+ OPENBOX_BODY_SHA256_HEADER = "X-OpenBox-Body-SHA256"
20
+ OPENBOX_AGENT_SIGNATURE_HEADER = "X-OpenBox-Agent-Signature"
21
+
22
+ _DID_PATTERN = re.compile(
23
+ r"^did:aip:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
24
+ re.IGNORECASE,
25
+ )
26
+ _ED25519_SEED_BYTE_LENGTH = 32
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class AgentIdentityConfig:
31
+ """Validated AIP identity configuration for one OpenBox agent."""
32
+
33
+ did: str
34
+ private_key: str
35
+
36
+
37
+ AgentIdentityHeaders = dict[str, str]
38
+
39
+
40
+ def build_agent_identity_canonical_request(
41
+ *,
42
+ body_sha256: str,
43
+ method: str,
44
+ nonce: str,
45
+ pathname: str,
46
+ timestamp: str,
47
+ ) -> str:
48
+ """Return Core's newline-joined canonical request string."""
49
+ return "\n".join([method.upper(), pathname, timestamp, nonce, body_sha256])
50
+
51
+
52
+ def validate_agent_identity_config(*, did: str, private_key: str) -> AgentIdentityConfig:
53
+ """Validate and normalize AIP DID/private-key configuration."""
54
+ normalized_did = did.strip()
55
+ normalized_private_key = private_key.strip()
56
+
57
+ if not _DID_PATTERN.match(normalized_did):
58
+ msg = "Invalid OpenBox agent DID. Expected format 'did:aip:<uuid>'."
59
+ raise OpenBoxConfigError(msg)
60
+
61
+ private_key_seed = _decode_private_key_seed(normalized_private_key)
62
+ return AgentIdentityConfig(
63
+ did=normalized_did,
64
+ private_key=base64.b64encode(private_key_seed).decode("ascii"),
65
+ )
66
+
67
+
68
+ def parse_optional_agent_identity_config(
69
+ *,
70
+ did: str | None,
71
+ private_key: str | None,
72
+ ) -> AgentIdentityConfig | None:
73
+ """Parse optional DID config and fail fast when only one value is present."""
74
+ normalized_did = _normalize_optional_string(did)
75
+ normalized_private_key = _normalize_optional_string(private_key)
76
+
77
+ if normalized_did is None and normalized_private_key is None:
78
+ return None
79
+
80
+ if normalized_did is None or normalized_private_key is None:
81
+ msg = (
82
+ "Both OPENBOX_AGENT_DID and OPENBOX_AGENT_PRIVATE_KEY are required "
83
+ "when enabling OpenBox agent identity signing."
84
+ )
85
+ raise OpenBoxConfigError(msg)
86
+
87
+ return validate_agent_identity_config(did=normalized_did, private_key=normalized_private_key)
88
+
89
+
90
+ def create_agent_identity_headers(
91
+ *,
92
+ did: str,
93
+ private_key: str,
94
+ method: str,
95
+ pathname: str,
96
+ body: bytes | str | None = None,
97
+ timestamp: str | None = None,
98
+ nonce: str | None = None,
99
+ ) -> AgentIdentityHeaders:
100
+ """Create signed AIP headers for an exact outbound request body."""
101
+ identity = validate_agent_identity_config(did=did, private_key=private_key)
102
+ body_bytes = _body_to_bytes(body)
103
+ body_sha256 = hashlib.sha256(body_bytes).hexdigest()
104
+ timestamp_value = timestamp or _rfc3339_now()
105
+ nonce_value = nonce or str(uuid.uuid4())
106
+ canonical = build_agent_identity_canonical_request(
107
+ method=method,
108
+ pathname=pathname,
109
+ timestamp=timestamp_value,
110
+ nonce=nonce_value,
111
+ body_sha256=body_sha256,
112
+ )
113
+ signing_key = Ed25519PrivateKey.from_private_bytes(
114
+ _decode_private_key_seed(identity.private_key)
115
+ )
116
+ signature = signing_key.sign(canonical.encode("utf-8"))
117
+
118
+ return {
119
+ OPENBOX_AGENT_DID_HEADER: identity.did,
120
+ OPENBOX_AGENT_TIMESTAMP_HEADER: timestamp_value,
121
+ OPENBOX_AGENT_NONCE_HEADER: nonce_value,
122
+ OPENBOX_BODY_SHA256_HEADER: body_sha256,
123
+ OPENBOX_AGENT_SIGNATURE_HEADER: base64.b64encode(signature).decode("ascii"),
124
+ }
125
+
126
+
127
+ def _decode_private_key_seed(private_key: str) -> bytes:
128
+ try:
129
+ decoded = base64.b64decode(private_key, validate=True)
130
+ except ValueError as exc:
131
+ msg = "Invalid OpenBox agent private key. Expected a base64 raw 32-byte Ed25519 seed."
132
+ raise OpenBoxConfigError(msg) from exc
133
+
134
+ if (
135
+ len(decoded) != _ED25519_SEED_BYTE_LENGTH
136
+ or base64.b64encode(decoded).decode("ascii") != private_key
137
+ ):
138
+ msg = "Invalid OpenBox agent private key. Expected a base64 raw 32-byte Ed25519 seed."
139
+ raise OpenBoxConfigError(msg)
140
+
141
+ return decoded
142
+
143
+
144
+ def _body_to_bytes(body: bytes | str | None) -> bytes:
145
+ if body is None:
146
+ return b""
147
+ if isinstance(body, bytes):
148
+ return body
149
+ return body.encode("utf-8")
150
+
151
+
152
+ def _normalize_optional_string(value: str | None) -> str | None:
153
+ if value is None:
154
+ return None
155
+ stripped = value.strip()
156
+ return stripped or None
157
+
158
+
159
+ def _rfc3339_now() -> str:
160
+ return datetime.now(tz=UTC).isoformat(timespec="microseconds").replace("+00:00", "Z")
@@ -413,6 +413,8 @@ class OpenBoxLangGraphHandler:
413
413
  api_key=gc.api_key,
414
414
  timeout=gc.governance_timeout, # seconds
415
415
  on_api_error=self._config.on_api_error,
416
+ agent_did=gc.agent_did,
417
+ agent_private_key=gc.agent_private_key,
416
418
  )
417
419
 
418
420
  # Setup OTel HTTP governance hooks (required)
@@ -429,6 +431,8 @@ class OpenBoxLangGraphHandler:
429
431
  api_timeout=gc.governance_timeout,
430
432
  on_api_error=self._config.on_api_error,
431
433
  sqlalchemy_engine=opts.sqlalchemy_engine,
434
+ agent_did=gc.agent_did,
435
+ agent_private_key=gc.agent_private_key,
432
436
  )
433
437
  _logger.debug("[OpenBox] OTel HTTP governance hooks enabled")
434
438
  else:
@@ -1451,6 +1455,8 @@ def create_openbox_graph_handler(
1451
1455
  validate: bool = True,
1452
1456
  enable_telemetry: bool = True,
1453
1457
  sqlalchemy_engine: Any = None,
1458
+ agent_did: str | None = None,
1459
+ agent_private_key: str | None = None,
1454
1460
  **handler_kwargs: Any,
1455
1461
  ) -> OpenBoxLangGraphHandler:
1456
1462
  """Create a fully configured `OpenBoxLangGraphHandler` wrapping a compiled LangGraph graph.
@@ -1467,6 +1473,9 @@ def create_openbox_graph_handler(
1467
1473
  enable_telemetry: Reserved for future HTTP-span telemetry patching.
1468
1474
  sqlalchemy_engine: Optional SQLAlchemy Engine instance to instrument for DB
1469
1475
  governance. Required when the engine is created before the handler.
1476
+ agent_did: Optional OpenBox agent DID. Falls back to `OPENBOX_AGENT_DID`.
1477
+ agent_private_key: Optional raw Ed25519 private key seed. Falls back to
1478
+ `OPENBOX_AGENT_PRIVATE_KEY`.
1470
1479
  **handler_kwargs: Additional keyword arguments forwarded to
1471
1480
  `OpenBoxLangGraphHandlerOptions`.
1472
1481
 
@@ -1488,6 +1497,8 @@ def create_openbox_graph_handler(
1488
1497
  api_key=api_key,
1489
1498
  governance_timeout=governance_timeout,
1490
1499
  validate=validate,
1500
+ agent_did=agent_did,
1501
+ agent_private_key=agent_private_key,
1491
1502
  )
1492
1503
 
1493
1504
  options = OpenBoxLangGraphHandlerOptions(
@@ -67,6 +67,8 @@ def setup_opentelemetry_for_governance(
67
67
  sqlalchemy_engine: Any | None = None,
68
68
  api_timeout: float = 30.0,
69
69
  on_api_error: str = "fail_open",
70
+ agent_did: str | None = None,
71
+ agent_private_key: str | None = None,
70
72
  ) -> None:
71
73
  """
72
74
  Setup OpenTelemetry instrumentors with body capture hooks.
@@ -88,6 +90,8 @@ def setup_opentelemetry_for_governance(
88
90
  when the engine is created before instrumentation runs (e.g.,
89
91
  at module import time). If not provided, only future engines
90
92
  created via create_engine() will be instrumented.
93
+ agent_did: Optional OpenBox agent DID for AIP request signing.
94
+ agent_private_key: Optional OpenBox agent private key for AIP request signing.
91
95
  """
92
96
  global _span_processor, _ignored_url_prefixes
93
97
  _span_processor = span_processor
@@ -99,8 +103,13 @@ def setup_opentelemetry_for_governance(
99
103
 
100
104
  # Configure governance modules
101
105
  _hook_gov.configure(
102
- api_url, api_key, span_processor,
103
- api_timeout=api_timeout, on_api_error=on_api_error,
106
+ api_url,
107
+ api_key,
108
+ span_processor,
109
+ api_timeout=api_timeout,
110
+ on_api_error=on_api_error,
111
+ agent_did=agent_did,
112
+ agent_private_key=agent_private_key,
104
113
  )
105
114
  _db_gov.configure(span_processor)
106
115
 
@@ -1,11 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openbox-langgraph-sdk-python
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: OpenBox governance and observability SDK for LangGraph
5
5
  License: MIT
6
+ License-File: LICENSE
6
7
  Requires-Python: >=3.11
8
+ Requires-Dist: cryptography>=42.0.0
7
9
  Requires-Dist: httpx>=0.27.0
8
- Requires-Dist: langchain-core>=1.2.22
10
+ Requires-Dist: langchain-core>=1.3.3
9
11
  Requires-Dist: langgraph>=0.2.0
10
12
  Requires-Dist: opentelemetry-api>=1.20.0
11
13
  Requires-Dist: opentelemetry-instrumentation-asyncpg>=0.41b0
@@ -24,6 +26,7 @@ Requires-Dist: opentelemetry-sdk>=1.20.0
24
26
  Provides-Extra: dev
25
27
  Requires-Dist: mypy>=1.10.0; extra == 'dev'
26
28
  Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
29
+ Requires-Dist: pytest-cov>=5.0.0; extra == 'dev'
27
30
  Requires-Dist: pytest>=8.0.0; extra == 'dev'
28
31
  Requires-Dist: ruff>=0.6.0; extra == 'dev'
29
32
  Provides-Extra: otel
@@ -128,15 +131,19 @@ uv add openbox-langgraph-sdk-python
128
131
 
129
132
  ## Quickstart
130
133
 
131
- ### 1. Get your API key
134
+ ### 1. Get your agent credentials
132
135
 
133
- Sign in to [dashboard.openbox.ai](https://dashboard.openbox.ai), create an agent called `"MyAgent"`, and copy your API key (`obx_live_...` or `obx_test_...`).
136
+ Sign in to [dashboard.openbox.ai](https://dashboard.openbox.ai), create an agent called `"MyAgent"`, and copy the agent API key plus its DID credentials.
137
+
138
+ New OpenBox agents have DID signing enabled by default. Keep the private key secret and load it from your environment.
134
139
 
135
140
  ### 2. Set environment variables
136
141
 
137
142
  ```bash
138
143
  export OPENBOX_URL="https://core.openbox.ai"
139
144
  export OPENBOX_API_KEY="obx_live_..."
145
+ export OPENBOX_AGENT_DID="did:aip:..."
146
+ export OPENBOX_AGENT_PRIVATE_KEY="..."
140
147
  ```
141
148
 
142
149
  ### 3. Wrap your graph
@@ -195,6 +202,8 @@ See `test-agent/README.md` for setup and run instructions.
195
202
  | `graph` | `CompiledGraph` | **required** | Your compiled LangGraph graph |
196
203
  | `api_url` | `str` | **required** | Base URL of your OpenBox Core instance |
197
204
  | `api_key` | `str` | **required** | API key (`obx_live_*` or `obx_test_*`) |
205
+ | `agent_did` | `str` | `OPENBOX_AGENT_DID` | Agent DID used to sign governance requests |
206
+ | `agent_private_key` | `str` | `OPENBOX_AGENT_PRIVATE_KEY` | Base64 raw Ed25519 private key seed for the agent DID |
198
207
  | `agent_name` | `str` | `None` | Agent name as configured in the dashboard |
199
208
  | `validate` | `bool` | `True` | Validate API key against server on startup |
200
209
  | `on_api_error` | `str` | `"fail_open"` | `"fail_open"` (allow on error) or `"fail_closed"` (block on error) |
@@ -0,0 +1,20 @@
1
+ openbox_langgraph/__init__.py,sha256=PCVxIw5vkxH8e8QoTO7xSZtzzT5cLm2hiccVv9dESqA,4048
2
+ openbox_langgraph/client.py,sha256=18LfPcAZqpEjes0Vca-yymQqTL4ZWcviliE2pmHLmjk,15602
3
+ openbox_langgraph/config.py,sha256=e6aeRJYbgwMZMYW0miOftl-OcSHhDfEZI7SqXAziE-0,13106
4
+ openbox_langgraph/db_governance_hooks.py,sha256=9Cj3Afsr3WUl1HYqOC8Q1P8OWMOR0lSOZhO5OxrpOl8,40120
5
+ openbox_langgraph/errors.py,sha256=2FLK2_h-ObNZ-r3jPZJQR0fU5WMBoEE_0FPOxL5BLlA,3806
6
+ openbox_langgraph/file_governance_hooks.py,sha256=ddPZEF8yu8iq48rfU3OhI867P6G_I9Kt_ajfPo_6izc,16100
7
+ openbox_langgraph/hitl.py,sha256=p8Xyxp9vvIBciumghM2RGJ53deIIwbufLRmw-PPLteU,2751
8
+ openbox_langgraph/hook_governance.py,sha256=BsXrOGMPIFtkoYukbI7qL0hMohgTMJwk7unnztmOIug,14913
9
+ openbox_langgraph/http_governance_hooks.py,sha256=DbY1p_ZipUxJqR6jrc9FjLVA4RkYRVx6p-Nrlf0hzt8,28170
10
+ openbox_langgraph/identity.py,sha256=PI0BngywO5sCDSw9rw0N12QBWEKcRoB7QBkmig4-nqA,5028
11
+ openbox_langgraph/langgraph_handler.py,sha256=UP9aXufLx_CtswEaOpmyva6NRtECeYn3KO5g2H4SvKQ,72722
12
+ openbox_langgraph/otel_setup.py,sha256=EVEGcfkobo2_qkec9lyyyNDIzJjIv1k8QuX6cL6aXN8,17637
13
+ openbox_langgraph/span_processor.py,sha256=tY3OXXIq9b47vjmA1oZy2Uu7N1Y5gSaxBOI1RgZK09Q,13289
14
+ openbox_langgraph/tracing.py,sha256=818uke3pRRRFLuO5Ca-Tj3p-LA2QCVjRiI41gQcs5yU,12590
15
+ openbox_langgraph/types.py,sha256=EUozIU3pCimjXItwKLaZ-NLLpHGAD06SgBRxgK1rHHg,18591
16
+ openbox_langgraph/verdict_handler.py,sha256=YmZjBxzOa2RBbkF13xDCPUAGzz7m1hwdMErwOVDl2WM,8323
17
+ openbox_langgraph_sdk_python-0.2.0.dist-info/METADATA,sha256=4QbHIlf-h2TEb3xF_DOauHdmnkKTLwYUsvShRMdcrJE,21481
18
+ openbox_langgraph_sdk_python-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
19
+ openbox_langgraph_sdk_python-0.2.0.dist-info/licenses/LICENSE,sha256=bPyy7QFDMnA5r6asaR3iD9rQvpjz5cUFk1ouHCuBYIY,1071
20
+ openbox_langgraph_sdk_python-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OPENBOX AI LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,18 +0,0 @@
1
- openbox_langgraph/__init__.py,sha256=_hLVtJi-ERJ_GQJImJOMsSzu8RKse52kA-Lsk-hskgs,3581
2
- openbox_langgraph/client.py,sha256=YpTvZEruBuhYPHbOlt9Hq1YsB_b3Ak9cwuTXYdVr2_M,13356
3
- openbox_langgraph/config.py,sha256=xWMqcntzY6yveP2roqsC2gdkc66n9F8c903ATt415f4,11643
4
- openbox_langgraph/db_governance_hooks.py,sha256=9Cj3Afsr3WUl1HYqOC8Q1P8OWMOR0lSOZhO5OxrpOl8,40120
5
- openbox_langgraph/errors.py,sha256=ZEE6gUBf6KSEUqwOU887ocMoJuqX9Jt3U12u93BevQk,3712
6
- openbox_langgraph/file_governance_hooks.py,sha256=ddPZEF8yu8iq48rfU3OhI867P6G_I9Kt_ajfPo_6izc,16100
7
- openbox_langgraph/hitl.py,sha256=p8Xyxp9vvIBciumghM2RGJ53deIIwbufLRmw-PPLteU,2751
8
- openbox_langgraph/hook_governance.py,sha256=ZDAcyjTBQnFkwG6fv6XOAwiMtwXRQAQCTgTWiD6np40,13946
9
- openbox_langgraph/http_governance_hooks.py,sha256=DbY1p_ZipUxJqR6jrc9FjLVA4RkYRVx6p-Nrlf0hzt8,28170
10
- openbox_langgraph/langgraph_handler.py,sha256=BD61U9F2ESLzSTDJ-wXLS5tNFjvoaeSLHqoj2ABYfQQ,72177
11
- openbox_langgraph/otel_setup.py,sha256=ti9hQ8pWVj8gQk8E4WMw9gDPqGUlGSzoJLoav38EJno,17305
12
- openbox_langgraph/span_processor.py,sha256=tY3OXXIq9b47vjmA1oZy2Uu7N1Y5gSaxBOI1RgZK09Q,13289
13
- openbox_langgraph/tracing.py,sha256=818uke3pRRRFLuO5Ca-Tj3p-LA2QCVjRiI41gQcs5yU,12590
14
- openbox_langgraph/types.py,sha256=EUozIU3pCimjXItwKLaZ-NLLpHGAD06SgBRxgK1rHHg,18591
15
- openbox_langgraph/verdict_handler.py,sha256=YmZjBxzOa2RBbkF13xDCPUAGzz7m1hwdMErwOVDl2WM,8323
16
- openbox_langgraph_sdk_python-0.1.2.dist-info/METADATA,sha256=V3opgqntXab1KFXSYKwjc-0uEUpq4uDNcVYXzMkzeD4,20963
17
- openbox_langgraph_sdk_python-0.1.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
18
- openbox_langgraph_sdk_python-0.1.2.dist-info/RECORD,,