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.
@@ -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