openbox-langgraph-sdk-python 0.1.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_langgraph/__init__.py +130 -0
- openbox_langgraph/client.py +358 -0
- openbox_langgraph/config.py +264 -0
- openbox_langgraph/db_governance_hooks.py +897 -0
- openbox_langgraph/errors.py +114 -0
- openbox_langgraph/file_governance_hooks.py +413 -0
- openbox_langgraph/hitl.py +88 -0
- openbox_langgraph/hook_governance.py +397 -0
- openbox_langgraph/http_governance_hooks.py +695 -0
- openbox_langgraph/langgraph_handler.py +1616 -0
- openbox_langgraph/otel_setup.py +468 -0
- openbox_langgraph/span_processor.py +253 -0
- openbox_langgraph/tracing.py +352 -0
- openbox_langgraph/types.py +485 -0
- openbox_langgraph/verdict_handler.py +203 -0
- openbox_langgraph_sdk_python-0.1.0.dist-info/METADATA +492 -0
- openbox_langgraph_sdk_python-0.1.0.dist-info/RECORD +18 -0
- openbox_langgraph_sdk_python-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
# openbox/hook_governance.py
|
|
2
|
+
"""Hook-level governance evaluation for all operation types.
|
|
3
|
+
|
|
4
|
+
Sends per-operation governance evaluations to OpenBox Core during activity
|
|
5
|
+
execution. Used by OTel hooks to evaluate each operation (HTTP, file I/O,
|
|
6
|
+
database, traced functions) at two stages: 'started' and 'completed'.
|
|
7
|
+
|
|
8
|
+
Architecture:
|
|
9
|
+
1. Hook modules detect an operation and build a span_data dict
|
|
10
|
+
2. Hook calls evaluate_sync() or evaluate_async()
|
|
11
|
+
3. This module: looks up activity context, assembles payload, sends to API
|
|
12
|
+
4. If verdict is BLOCK/HALT → raises GovernanceBlockedError
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from .client import build_auth_headers
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from .span_processor import WorkflowSpanProcessor
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# Error policy constants
|
|
31
|
+
FAIL_OPEN = "fail_open"
|
|
32
|
+
FAIL_CLOSED = "fail_closed"
|
|
33
|
+
|
|
34
|
+
# Module-level config (set once by configure())
|
|
35
|
+
_api_url: str = ""
|
|
36
|
+
_api_key: str = ""
|
|
37
|
+
_api_timeout: float = 30.0
|
|
38
|
+
_on_api_error: str = FAIL_OPEN
|
|
39
|
+
_span_processor: WorkflowSpanProcessor | None = None
|
|
40
|
+
_cached_auth_headers: dict | None = None
|
|
41
|
+
|
|
42
|
+
# Persistent HTTP clients (lazy-init, thread-safe for requests)
|
|
43
|
+
_sync_client: httpx.Client | None = None
|
|
44
|
+
_async_client: httpx.AsyncClient | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def configure(
|
|
48
|
+
api_url: str,
|
|
49
|
+
api_key: str,
|
|
50
|
+
span_processor: WorkflowSpanProcessor,
|
|
51
|
+
*,
|
|
52
|
+
api_timeout: float = 30.0,
|
|
53
|
+
on_api_error: str = "fail_open",
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Set governance config. Called once by setup_opentelemetry_for_governance().
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
api_url: OpenBox Core API URL
|
|
59
|
+
api_key: API key for authentication
|
|
60
|
+
span_processor: WorkflowSpanProcessor for activity context lookup
|
|
61
|
+
api_timeout: Timeout for governance API calls (seconds)
|
|
62
|
+
on_api_error: Error policy — "fail_open" or "fail_closed"
|
|
63
|
+
"""
|
|
64
|
+
global _api_url, _api_key, _api_timeout, _on_api_error
|
|
65
|
+
global _span_processor, _sync_client, _async_client, _cached_auth_headers
|
|
66
|
+
_api_url = api_url.rstrip("/")
|
|
67
|
+
_api_key = api_key
|
|
68
|
+
_api_timeout = api_timeout
|
|
69
|
+
_on_api_error = on_api_error
|
|
70
|
+
_span_processor = span_processor
|
|
71
|
+
# Cache auth headers (immutable after configure)
|
|
72
|
+
_cached_auth_headers = build_auth_headers(api_key)
|
|
73
|
+
# Reset persistent clients so they pick up new timeout/config
|
|
74
|
+
_sync_client = None
|
|
75
|
+
_async_client = None
|
|
76
|
+
logger.info("Hook-level governance configured")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _get_sync_client() -> httpx.Client:
|
|
80
|
+
"""Get or create persistent sync HTTP client."""
|
|
81
|
+
global _sync_client
|
|
82
|
+
if _sync_client is None or _sync_client.is_closed:
|
|
83
|
+
_sync_client = httpx.Client(timeout=_api_timeout)
|
|
84
|
+
return _sync_client
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _get_async_client() -> httpx.AsyncClient:
|
|
88
|
+
"""Get or create persistent async HTTP client."""
|
|
89
|
+
global _async_client
|
|
90
|
+
if _async_client is None or _async_client.is_closed:
|
|
91
|
+
_async_client = httpx.AsyncClient(timeout=_api_timeout)
|
|
92
|
+
return _async_client
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def is_configured() -> bool:
|
|
96
|
+
"""Check if hook-level governance is active."""
|
|
97
|
+
return bool(_api_url and _span_processor is not None)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_span_processor() -> WorkflowSpanProcessor | None:
|
|
101
|
+
"""Return the configured span processor (or None)."""
|
|
102
|
+
return _span_processor
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def extract_span_context(span) -> tuple:
|
|
106
|
+
"""Extract (span_id_hex, trace_id_hex, parent_span_id_hex) from a span.
|
|
107
|
+
|
|
108
|
+
Handles NonRecordingSpan, MagicMock, and missing attributes safely.
|
|
109
|
+
Returns 16-char hex span_id, 32-char hex trace_id, and parent_span_id (or None).
|
|
110
|
+
"""
|
|
111
|
+
span_ctx = (
|
|
112
|
+
span.get_span_context()
|
|
113
|
+
if hasattr(span, "get_span_context")
|
|
114
|
+
else getattr(span, "context", None)
|
|
115
|
+
)
|
|
116
|
+
try:
|
|
117
|
+
span_id = (
|
|
118
|
+
format(span_ctx.span_id, "016x")
|
|
119
|
+
if span_ctx and isinstance(span_ctx.span_id, int)
|
|
120
|
+
else "0" * 16
|
|
121
|
+
)
|
|
122
|
+
except (AttributeError, TypeError):
|
|
123
|
+
span_id = "0" * 16
|
|
124
|
+
try:
|
|
125
|
+
trace_id = (
|
|
126
|
+
format(span_ctx.trace_id, "032x")
|
|
127
|
+
if span_ctx and isinstance(span_ctx.trace_id, int)
|
|
128
|
+
else "0" * 32
|
|
129
|
+
)
|
|
130
|
+
except (AttributeError, TypeError):
|
|
131
|
+
trace_id = "0" * 32
|
|
132
|
+
|
|
133
|
+
parent_span_id = None
|
|
134
|
+
parent = getattr(span, 'parent', None)
|
|
135
|
+
if parent and hasattr(parent, 'span_id') and isinstance(getattr(parent, 'span_id', None), int):
|
|
136
|
+
parent_span_id = format(parent.span_id, "016x")
|
|
137
|
+
|
|
138
|
+
return span_id, trace_id, parent_span_id
|
|
139
|
+
|
|
140
|
+
|
|
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)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _build_payload(
|
|
147
|
+
span: Any,
|
|
148
|
+
span_data: dict[str, Any] | None = None,
|
|
149
|
+
) -> dict[str, Any] | None:
|
|
150
|
+
"""Build governance evaluation payload from activity context + span data.
|
|
151
|
+
|
|
152
|
+
Returns None if no activity context found (not inside a governed activity).
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
span: OTel span for the current operation
|
|
156
|
+
span_data: Span data dict with hook_type, stage, and type-specific fields at root
|
|
157
|
+
"""
|
|
158
|
+
if _span_processor is None:
|
|
159
|
+
logger.debug("[GOV] _build_payload: span_processor is None — skipping")
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
# Look up activity context by trace_id via SpanProcessor
|
|
163
|
+
span_context = (
|
|
164
|
+
span.get_span_context()
|
|
165
|
+
if hasattr(span, "get_span_context")
|
|
166
|
+
else getattr(span, "context", None)
|
|
167
|
+
)
|
|
168
|
+
if not span_context or not hasattr(span_context, "trace_id"):
|
|
169
|
+
logger.debug("[GOV] _build_payload: no span context — skipping")
|
|
170
|
+
return None
|
|
171
|
+
trace_id = span_context.trace_id
|
|
172
|
+
activity_context = _span_processor.get_activity_context_by_trace(trace_id)
|
|
173
|
+
if activity_context is None:
|
|
174
|
+
logger.debug(
|
|
175
|
+
f"[GOV] _build_payload: no activity context for trace_id={trace_id} — skipping"
|
|
176
|
+
)
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
activity_id = activity_context.get("activity_id")
|
|
180
|
+
|
|
181
|
+
# Tag span_data with activity_id for server-side correlation
|
|
182
|
+
if span_data and activity_id and "activity_id" not in span_data:
|
|
183
|
+
span_data["activity_id"] = activity_id
|
|
184
|
+
|
|
185
|
+
# Assemble payload — send only the current span (server processes each individually)
|
|
186
|
+
payload = dict(activity_context)
|
|
187
|
+
payload["spans"] = [span_data] if span_data else []
|
|
188
|
+
payload["span_count"] = 1 if span_data else 0
|
|
189
|
+
payload["hook_trigger"] = True
|
|
190
|
+
from .types import rfc3339_now
|
|
191
|
+
payload["timestamp"] = rfc3339_now()
|
|
192
|
+
|
|
193
|
+
# Sanitize non-serializable objects (Temporal Payload objects from activity_context)
|
|
194
|
+
# Single pass: serialize with default=str and deserialize back to clean dict
|
|
195
|
+
try:
|
|
196
|
+
payload = json.loads(json.dumps(payload, default=str))
|
|
197
|
+
except (TypeError, ValueError):
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
return payload
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _resolve_activity_ids(span) -> tuple | None:
|
|
204
|
+
"""Resolve span → (workflow_id, activity_id) via trace_id lookup.
|
|
205
|
+
|
|
206
|
+
Returns (workflow_id, activity_id) tuple or None if resolution fails.
|
|
207
|
+
Handles NonRecordingSpan, MagicMock, and missing attributes safely.
|
|
208
|
+
"""
|
|
209
|
+
if _span_processor is None:
|
|
210
|
+
return None
|
|
211
|
+
span_context = (
|
|
212
|
+
span.get_span_context()
|
|
213
|
+
if hasattr(span, "get_span_context")
|
|
214
|
+
else getattr(span, "context", None)
|
|
215
|
+
)
|
|
216
|
+
if not span_context or not isinstance(getattr(span_context, "trace_id", None), int):
|
|
217
|
+
return None
|
|
218
|
+
activity_ctx = _span_processor.get_activity_context_by_trace(span_context.trace_id)
|
|
219
|
+
if not activity_ctx or not isinstance(activity_ctx, dict):
|
|
220
|
+
return None
|
|
221
|
+
return activity_ctx.get("workflow_id", ""), activity_ctx.get("activity_id", "")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _check_activity_abort(span) -> str | None:
|
|
225
|
+
"""Check if the activity owning this span has been aborted.
|
|
226
|
+
|
|
227
|
+
Returns abort reason if aborted, None otherwise.
|
|
228
|
+
"""
|
|
229
|
+
# Skip if span_processor lacks abort method (MagicMock/old processors)
|
|
230
|
+
if not hasattr(_span_processor, "get_activity_abort") or not callable(
|
|
231
|
+
getattr(_span_processor, "get_activity_abort", None)
|
|
232
|
+
):
|
|
233
|
+
return None
|
|
234
|
+
ids = _resolve_activity_ids(span)
|
|
235
|
+
if not ids:
|
|
236
|
+
return None
|
|
237
|
+
result = _span_processor.get_activity_abort(ids[0], ids[1])
|
|
238
|
+
# Ensure result is actually a string (not a MagicMock or other truthy object)
|
|
239
|
+
return result if isinstance(result, str) else None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _set_activity_abort(span, reason: str) -> None:
|
|
243
|
+
"""Set abort flag for the activity owning this span."""
|
|
244
|
+
ids = _resolve_activity_ids(span)
|
|
245
|
+
if not ids:
|
|
246
|
+
return
|
|
247
|
+
_span_processor.set_activity_abort(ids[0], ids[1], reason)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _handle_verdict(data: dict[str, Any], identifier: str, span: Any = None) -> None:
|
|
251
|
+
"""Check API response verdict and raise GovernanceBlockedError if blocked.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
data: Parsed JSON response from governance API
|
|
255
|
+
identifier: Resource identifier for error context (URL or file path)
|
|
256
|
+
span: OTel span (used to set abort flag on require_approval)
|
|
257
|
+
"""
|
|
258
|
+
from openbox_langgraph.errors import GovernanceBlockedError
|
|
259
|
+
|
|
260
|
+
from .types import Verdict
|
|
261
|
+
|
|
262
|
+
verdict = Verdict.from_string(data.get("verdict") or data.get("action", "continue"))
|
|
263
|
+
if verdict.should_stop():
|
|
264
|
+
if span:
|
|
265
|
+
reason = data.get("reason", "Blocked by governance")
|
|
266
|
+
ids = _resolve_activity_ids(span)
|
|
267
|
+
if ids:
|
|
268
|
+
_span_processor.set_activity_abort(ids[0], ids[1], reason)
|
|
269
|
+
if verdict == Verdict.HALT and hasattr(_span_processor, 'set_halt_requested'):
|
|
270
|
+
_span_processor.set_halt_requested(ids[0], ids[1], reason)
|
|
271
|
+
raise GovernanceBlockedError(
|
|
272
|
+
verdict.value, data.get("reason", "Blocked by governance"), identifier
|
|
273
|
+
)
|
|
274
|
+
if verdict.requires_approval():
|
|
275
|
+
if span:
|
|
276
|
+
_set_activity_abort(span, data.get("reason", "Approval required"))
|
|
277
|
+
raise GovernanceBlockedError(
|
|
278
|
+
verdict.value,
|
|
279
|
+
data.get("reason", "Approval required - blocked at hook level"),
|
|
280
|
+
identifier,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _send_and_handle(response: Any, identifier: str, span: Any = None) -> None:
|
|
285
|
+
"""Handle governance API response (shared between sync/async).
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
response: httpx Response object
|
|
289
|
+
identifier: Resource identifier for error context
|
|
290
|
+
span: OTel span (passed to _handle_verdict for abort flag)
|
|
291
|
+
"""
|
|
292
|
+
from openbox_langgraph.errors import GovernanceBlockedError
|
|
293
|
+
|
|
294
|
+
if response.status_code == 200:
|
|
295
|
+
_handle_verdict(response.json(), identifier, span=span)
|
|
296
|
+
elif response.status_code >= 400:
|
|
297
|
+
logger.warning(f"Hook governance API error: HTTP {response.status_code}")
|
|
298
|
+
if _on_api_error == FAIL_CLOSED:
|
|
299
|
+
raise GovernanceBlockedError(
|
|
300
|
+
"halt", f"Governance API error: HTTP {response.status_code}", identifier
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def evaluate_sync(
|
|
305
|
+
span: Any,
|
|
306
|
+
identifier: str,
|
|
307
|
+
span_data: dict[str, Any] | None = None,
|
|
308
|
+
) -> None:
|
|
309
|
+
"""Synchronous governance evaluation. Blocks until verdict is received.
|
|
310
|
+
|
|
311
|
+
Raises GovernanceBlockedError if verdict is BLOCK, HALT, or REQUIRE_APPROVAL.
|
|
312
|
+
Short-circuits immediately if the activity has been aborted by a prior hook.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
span: OTel span for the current operation
|
|
316
|
+
identifier: Resource identifier (URL or file path) for error context
|
|
317
|
+
span_data: Span data dict with hook_type and type-specific fields at root
|
|
318
|
+
"""
|
|
319
|
+
if not is_configured():
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
from openbox_langgraph.errors import GovernanceBlockedError
|
|
323
|
+
|
|
324
|
+
# Short-circuit if activity already aborted by a prior hook verdict
|
|
325
|
+
abort_reason = _check_activity_abort(span)
|
|
326
|
+
if abort_reason:
|
|
327
|
+
raise GovernanceBlockedError("require_approval", abort_reason, identifier)
|
|
328
|
+
|
|
329
|
+
payload = _build_payload(span, span_data)
|
|
330
|
+
if payload is None:
|
|
331
|
+
return
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
client = _get_sync_client()
|
|
335
|
+
response = client.post(
|
|
336
|
+
f"{_api_url}/api/v1/governance/evaluate",
|
|
337
|
+
json=payload,
|
|
338
|
+
headers=_auth_headers(),
|
|
339
|
+
)
|
|
340
|
+
_send_and_handle(response, identifier, span=span)
|
|
341
|
+
|
|
342
|
+
except GovernanceBlockedError:
|
|
343
|
+
raise
|
|
344
|
+
except Exception as e:
|
|
345
|
+
logger.warning(f"Hook governance evaluation failed: {e}")
|
|
346
|
+
if _on_api_error == FAIL_CLOSED:
|
|
347
|
+
raise GovernanceBlockedError(
|
|
348
|
+
"halt", f"Governance evaluation error: {e}", identifier
|
|
349
|
+
) from e
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
async def evaluate_async(
|
|
353
|
+
span: Any,
|
|
354
|
+
identifier: str,
|
|
355
|
+
span_data: dict[str, Any] | None = None,
|
|
356
|
+
) -> None:
|
|
357
|
+
"""Async governance evaluation. Awaits until verdict is received.
|
|
358
|
+
|
|
359
|
+
Raises GovernanceBlockedError if verdict is BLOCK, HALT, or REQUIRE_APPROVAL.
|
|
360
|
+
Short-circuits immediately if the activity has been aborted by a prior hook.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
span: OTel span for the current operation
|
|
364
|
+
identifier: Resource identifier (URL or file path) for error context
|
|
365
|
+
span_data: Span data dict with hook_type and type-specific fields at root
|
|
366
|
+
"""
|
|
367
|
+
if not is_configured():
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
from openbox_langgraph.errors import GovernanceBlockedError
|
|
371
|
+
|
|
372
|
+
# Short-circuit if activity already aborted by a prior hook verdict
|
|
373
|
+
abort_reason = _check_activity_abort(span)
|
|
374
|
+
if abort_reason:
|
|
375
|
+
raise GovernanceBlockedError("require_approval", abort_reason, identifier)
|
|
376
|
+
|
|
377
|
+
payload = _build_payload(span, span_data)
|
|
378
|
+
if payload is None:
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
client = _get_async_client()
|
|
383
|
+
response = await client.post(
|
|
384
|
+
f"{_api_url}/api/v1/governance/evaluate",
|
|
385
|
+
json=payload,
|
|
386
|
+
headers=_auth_headers(),
|
|
387
|
+
)
|
|
388
|
+
_send_and_handle(response, identifier, span=span)
|
|
389
|
+
|
|
390
|
+
except GovernanceBlockedError:
|
|
391
|
+
raise
|
|
392
|
+
except Exception as e:
|
|
393
|
+
logger.warning(f"Hook governance evaluation failed: {e}")
|
|
394
|
+
if _on_api_error == FAIL_CLOSED:
|
|
395
|
+
raise GovernanceBlockedError(
|
|
396
|
+
"halt", f"Governance evaluation error: {e}", identifier
|
|
397
|
+
) from e
|