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,114 @@
1
+ """OpenBox LangGraph SDK — Custom exception classes."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class OpenBoxError(Exception):
7
+ """Base class for all OpenBox SDK errors."""
8
+
9
+
10
+ class OpenBoxAuthError(OpenBoxError):
11
+ """Raised when the API key is invalid or unauthorized."""
12
+
13
+
14
+ class OpenBoxNetworkError(OpenBoxError):
15
+ """Raised when the OpenBox Core API is unreachable or returns an error."""
16
+
17
+
18
+ class OpenBoxInsecureURLError(OpenBoxError):
19
+ """Raised when an insecure HTTP URL is used for a non-localhost endpoint."""
20
+
21
+
22
+ class GovernanceBlockedError(OpenBoxError):
23
+ """Raised when governance returns a BLOCK or HALT verdict.
24
+
25
+ Supports two calling conventions:
26
+
27
+ Hook-level (3 positional args):
28
+ GovernanceBlockedError(verdict_str, reason, identifier)
29
+ e.g. GovernanceBlockedError("block", "Blocked by policy", "https://api.example.com")
30
+
31
+ SDK-level (keyword args):
32
+ GovernanceBlockedError(reason, policy_id=..., risk_score=...)
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ verdict_or_reason: str,
38
+ reason_or_policy_id: str | None = None,
39
+ identifier_or_risk_score: str | float | None = None,
40
+ policy_id: str | None = None,
41
+ risk_score: float | None = None,
42
+ ) -> None:
43
+ from openbox_langgraph.types import Verdict # lazy to avoid circular
44
+
45
+ _HOOK_VERDICTS = ("block", "halt", "require_approval", "stop")
46
+ is_hook_call = verdict_or_reason in _HOOK_VERDICTS
47
+
48
+ if is_hook_call:
49
+ # GovernanceBlockedError(verdict, reason, identifier)
50
+ self.verdict = Verdict.from_string(verdict_or_reason).value
51
+ reason = reason_or_policy_id or verdict_or_reason
52
+ self.identifier = (
53
+ str(identifier_or_risk_score) if isinstance(identifier_or_risk_score, str) else ""
54
+ )
55
+ self.policy_id = policy_id
56
+ self.risk_score = risk_score
57
+ super().__init__(reason)
58
+ else:
59
+ # GovernanceBlockedError(reason, policy_id?, risk_score?)
60
+ self.verdict = "block"
61
+ self.identifier = ""
62
+ self.policy_id = (
63
+ reason_or_policy_id if isinstance(reason_or_policy_id, str) else policy_id
64
+ )
65
+ self.risk_score = (
66
+ identifier_or_risk_score
67
+ if isinstance(identifier_or_risk_score, (int, float))
68
+ else risk_score
69
+ )
70
+ super().__init__(verdict_or_reason)
71
+
72
+
73
+ class GovernanceHaltError(OpenBoxError):
74
+ """Raised when governance returns a HALT verdict (workflow-level stop)."""
75
+
76
+ verdict = "halt"
77
+
78
+ def __init__(
79
+ self,
80
+ reason: str,
81
+ *,
82
+ identifier: str = "",
83
+ policy_id: str | None = None,
84
+ risk_score: float | None = None,
85
+ ) -> None:
86
+ super().__init__(reason)
87
+ self.identifier = identifier
88
+ self.policy_id = policy_id
89
+ self.risk_score = risk_score
90
+
91
+
92
+ class GuardrailsValidationError(OpenBoxError):
93
+ """Raised when guardrails validation fails."""
94
+
95
+ def __init__(self, reasons: list[str]) -> None:
96
+ msg = f"Guardrails validation failed: {'; '.join(reasons)}"
97
+ super().__init__(msg)
98
+ self.reasons = reasons
99
+
100
+
101
+ class ApprovalExpiredError(OpenBoxError):
102
+ """Raised when the HITL approval window has expired."""
103
+
104
+
105
+ class ApprovalRejectedError(OpenBoxError):
106
+ """Raised when the HITL approval is explicitly rejected."""
107
+
108
+
109
+ class ApprovalTimeoutError(OpenBoxError):
110
+ """Raised when HITL polling exceeds max_wait_ms."""
111
+
112
+ def __init__(self, max_wait_ms: int | float) -> None:
113
+ super().__init__(f"HITL approval timed out after {max_wait_ms}ms")
114
+ self.max_wait_ms = max_wait_ms
@@ -0,0 +1,413 @@
1
+ # openbox/file_governance_hooks.py
2
+ """File I/O governance hooks — instruments builtins.open() and os.fdopen().
3
+
4
+ Wraps file objects with TracedFile to create OTel spans and send
5
+ hook-level governance evaluations for every file operation (open,
6
+ read, write, readline, readlines, writelines, close).
7
+
8
+ - "started" evaluations can block file access before it happens
9
+ - "completed" evaluations report what happened (informational)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import os
16
+ import time as _time
17
+
18
+ from . import hook_governance as _hook_gov
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def _build_file_span_data(
24
+ span,
25
+ file_path: str,
26
+ file_mode: str,
27
+ operation: str,
28
+ stage: str,
29
+ error: str | None = None,
30
+ duration_ms: float | None = None,
31
+ data: str | None = None,
32
+ bytes_read: int | None = None,
33
+ bytes_written: int | None = None,
34
+ lines_count: int | None = None,
35
+ operations: list | None = None,
36
+ ) -> dict:
37
+ """Build span data dict for a file operation (used by governance hooks).
38
+
39
+ attributes: OTel-original only. All custom data at root level.
40
+ """
41
+
42
+ span_id_hex, trace_id_hex, parent_span_id = _hook_gov.extract_span_context(span)
43
+ raw_attrs = getattr(span, 'attributes', None)
44
+ attrs = dict(raw_attrs) if raw_attrs else {}
45
+
46
+ span_name = getattr(span, 'name', None) or f"file.{operation}"
47
+ now_ns = _time.time_ns()
48
+ duration_ns = int(duration_ms * 1_000_000) if duration_ms else None
49
+ end_time = now_ns if stage == "completed" else None
50
+ start_time = (now_ns - duration_ns) if duration_ns else now_ns
51
+
52
+ result = {
53
+ "span_id": span_id_hex,
54
+ "trace_id": trace_id_hex,
55
+ "parent_span_id": parent_span_id,
56
+ "name": span_name,
57
+ "kind": "INTERNAL",
58
+ "stage": stage,
59
+ "start_time": start_time,
60
+ "end_time": end_time,
61
+ "duration_ns": duration_ns,
62
+ "attributes": attrs,
63
+ "status": {"code": "ERROR" if error else "UNSET", "description": error},
64
+ "events": [],
65
+ # Hook type identification
66
+ "hook_type": "file_operation",
67
+ # File-specific root fields
68
+ "file_path": file_path,
69
+ "file_mode": file_mode,
70
+ "file_operation": operation,
71
+ "error": error,
72
+ }
73
+
74
+ # Only include optional fields if they have values
75
+ if data is not None:
76
+ result["data"] = data
77
+ if bytes_read is not None:
78
+ result["bytes_read"] = bytes_read
79
+ if bytes_written is not None:
80
+ result["bytes_written"] = bytes_written
81
+ if lines_count is not None:
82
+ result["lines_count"] = lines_count
83
+ if operations is not None:
84
+ result["operations"] = operations
85
+
86
+ return result
87
+
88
+
89
+ def setup_file_io_instrumentation() -> bool:
90
+ """Setup file I/O instrumentation by patching built-in open().
91
+
92
+ File operations will be captured as spans with:
93
+ - file.path: File path
94
+ - file.mode: Open mode (r, w, a, etc.)
95
+ - file.operation: read, write, etc.
96
+ - file.bytes: Number of bytes read/written
97
+
98
+ Returns:
99
+ True if instrumentation was successful
100
+ """
101
+ import builtins
102
+
103
+ from opentelemetry import trace
104
+
105
+ # Check if already instrumented
106
+ if hasattr(builtins, '_openbox_original_open'):
107
+ logger.debug("File I/O already instrumented")
108
+ return True
109
+
110
+ _original_open = builtins.open
111
+ builtins._openbox_original_open = _original_open # Store for uninstrumentation
112
+ _tracer = trace.get_tracer("openbox.file_io")
113
+
114
+ # Paths to skip (noisy system files)
115
+ _skip_patterns = ('/dev/', '/proc/', '/sys/', '__pycache__', '.pyc', '.pyo', '.so', '.dylib')
116
+
117
+ class TracedFile:
118
+ """Wrapper around file object to trace read/write operations.
119
+
120
+ Also sends hook-level governance evaluations:
121
+ - "started" on open (can block file access)
122
+ - "completed" on close (reports summary of operations performed)
123
+ """
124
+
125
+ def __init__(self, file_obj, file_path: str, mode: str, parent_span):
126
+ self._file = file_obj
127
+ self._file_path = file_path
128
+ self._mode = mode
129
+ self._parent_span = parent_span
130
+ self._bytes_read = 0
131
+ self._bytes_written = 0
132
+ self._operations: list = [] # Track operations for governance payload
133
+
134
+ def _evaluate_governance(self, operation: str, stage: str, span=None, **extra):
135
+ """Send governance evaluation for a file operation stage.
136
+
137
+ Args:
138
+ operation: File operation name (read, write, close, etc.)
139
+ stage: Governance stage (started, completed)
140
+ span: OTel span for this operation. Falls back to parent span.
141
+ **extra: Additional trigger fields (data, bytes_read, etc.)
142
+ """
143
+ if not _hook_gov.is_configured():
144
+ return
145
+ from openbox_langgraph.errors import GovernanceBlockedError
146
+ active_span = span or self._parent_span
147
+ try:
148
+ span_data = _build_file_span_data(
149
+ active_span, self._file_path, self._mode, operation, stage,
150
+ **extra,
151
+ )
152
+ _hook_gov.evaluate_sync(
153
+ active_span,
154
+ identifier=self._file_path,
155
+ span_data=span_data,
156
+ )
157
+ except GovernanceBlockedError:
158
+ raise
159
+ except Exception:
160
+ pass # fail_open handled inside evaluate_sync
161
+
162
+ def read(self, size=-1):
163
+ with _tracer.start_as_current_span("file.read") as span:
164
+ span.set_attribute("file.path", self._file_path)
165
+ span.set_attribute("file.operation", "read")
166
+ self._evaluate_governance("read", "started", span=span)
167
+
168
+ data = self._file.read(size)
169
+ bytes_count = len(data) if isinstance(data, (str, bytes)) else 0
170
+ self._bytes_read += bytes_count
171
+ self._operations.append("read")
172
+ span.set_attribute("file.bytes", bytes_count)
173
+
174
+ self._evaluate_governance(
175
+ "read", "completed", span=span, data=data, bytes_read=bytes_count
176
+ )
177
+ return data
178
+
179
+ def readline(self):
180
+ with _tracer.start_as_current_span("file.readline") as span:
181
+ span.set_attribute("file.path", self._file_path)
182
+ span.set_attribute("file.operation", "readline")
183
+ self._evaluate_governance("readline", "started", span=span)
184
+
185
+ data = self._file.readline()
186
+ bytes_count = len(data) if isinstance(data, (str, bytes)) else 0
187
+ self._bytes_read += bytes_count
188
+ self._operations.append("readline")
189
+ span.set_attribute("file.bytes", bytes_count)
190
+
191
+ self._evaluate_governance(
192
+ "readline", "completed", span=span, data=data, bytes_read=bytes_count
193
+ )
194
+ return data
195
+
196
+ def readlines(self):
197
+ with _tracer.start_as_current_span("file.readlines") as span:
198
+ span.set_attribute("file.path", self._file_path)
199
+ span.set_attribute("file.operation", "readlines")
200
+ self._evaluate_governance("readlines", "started", span=span)
201
+
202
+ data = self._file.readlines()
203
+ bytes_count = sum(len(line) for line in data) if data else 0
204
+ self._bytes_read += bytes_count
205
+ self._operations.append("readlines")
206
+ span.set_attribute("file.bytes", bytes_count)
207
+ span.set_attribute("file.lines", len(data) if data else 0)
208
+
209
+ self._evaluate_governance(
210
+ "readlines", "completed", span=span,
211
+ data=data, bytes_read=bytes_count,
212
+ lines_count=len(data) if data else 0,
213
+ )
214
+ return data
215
+
216
+ def write(self, data):
217
+ with _tracer.start_as_current_span("file.write") as span:
218
+ span.set_attribute("file.path", self._file_path)
219
+ span.set_attribute("file.operation", "write")
220
+ self._evaluate_governance("write", "started", span=span)
221
+
222
+ bytes_count = len(data) if isinstance(data, (str, bytes)) else 0
223
+ span.set_attribute("file.bytes", bytes_count)
224
+ self._bytes_written += bytes_count
225
+ self._operations.append("write")
226
+ result = self._file.write(data)
227
+
228
+ self._evaluate_governance(
229
+ "write", "completed", span=span, data=data, bytes_written=bytes_count
230
+ )
231
+ return result
232
+
233
+ def writelines(self, lines):
234
+ with _tracer.start_as_current_span("file.writelines") as span:
235
+ span.set_attribute("file.path", self._file_path)
236
+ span.set_attribute("file.operation", "writelines")
237
+ self._evaluate_governance("writelines", "started", span=span)
238
+
239
+ bytes_count = sum(len(line) for line in lines) if lines else 0
240
+ span.set_attribute("file.bytes", bytes_count)
241
+ span.set_attribute("file.lines", len(lines) if lines else 0)
242
+ self._bytes_written += bytes_count
243
+ self._operations.append("writelines")
244
+ result = self._file.writelines(lines)
245
+
246
+ self._evaluate_governance(
247
+ "writelines", "completed", span=span,
248
+ data=lines, bytes_written=bytes_count,
249
+ lines_count=len(lines) if lines else 0,
250
+ )
251
+ return result
252
+
253
+ def close(self):
254
+ # Governance "completed" — reports what happened during file lifecycle
255
+ # Use try/finally to ensure file handle and span are always cleaned up
256
+ from openbox_langgraph.errors import GovernanceBlockedError
257
+ gov_error = None
258
+ try:
259
+ self._evaluate_governance(
260
+ "close", "completed", span=self._parent_span,
261
+ bytes_read=self._bytes_read,
262
+ bytes_written=self._bytes_written,
263
+ operations=self._operations,
264
+ )
265
+ except GovernanceBlockedError as e:
266
+ gov_error = e
267
+ finally:
268
+ if self._parent_span:
269
+ self._parent_span.set_attribute("file.total_bytes_read", self._bytes_read)
270
+ self._parent_span.set_attribute("file.total_bytes_written", self._bytes_written)
271
+ self._parent_span.end()
272
+ self._file.close()
273
+ if gov_error:
274
+ raise gov_error
275
+
276
+ def __enter__(self):
277
+ return self
278
+
279
+ def __exit__(self, exc_type, exc_val, exc_tb):
280
+ # Don't mask the original exception if close() also raises
281
+ try:
282
+ self.close()
283
+ except Exception:
284
+ if exc_type is None:
285
+ raise
286
+ return False
287
+
288
+ def __iter__(self):
289
+ return iter(self._file)
290
+
291
+ def __next__(self):
292
+ return next(self._file)
293
+
294
+ def __getattr__(self, name):
295
+ return getattr(self._file, name)
296
+
297
+ def traced_open(file, mode='r', *args, **kwargs):
298
+ file_str = str(file)
299
+
300
+ # Skip system/noisy paths
301
+ if any(p in file_str for p in _skip_patterns):
302
+ return _original_open(file, mode, *args, **kwargs)
303
+
304
+ span = _tracer.start_span("file.open")
305
+ span.set_attribute("file.path", file_str)
306
+ span.set_attribute("file.mode", mode)
307
+
308
+ # Governance "started" — can block file access before it happens
309
+ if _hook_gov.is_configured():
310
+ from openbox_langgraph.errors import GovernanceBlockedError
311
+ try:
312
+ open_span_data = _build_file_span_data(
313
+ span, file_str, mode, "open", "started",
314
+ )
315
+ _hook_gov.evaluate_sync(
316
+ span,
317
+ identifier=file_str,
318
+ span_data=open_span_data,
319
+ )
320
+ except GovernanceBlockedError:
321
+ span.set_attribute("error", True)
322
+ span.set_attribute("governance.blocked", True)
323
+ span.end()
324
+ raise
325
+ except Exception:
326
+ pass # Non-governance errors are swallowed (fail_open handled inside evaluate_sync)
327
+
328
+ try:
329
+ file_obj = _original_open(file, mode, *args, **kwargs)
330
+ return TracedFile(file_obj, file_str, mode, span)
331
+ except Exception as e:
332
+ span.set_attribute("error", True)
333
+ span.set_attribute("error.type", type(e).__name__)
334
+ span.set_attribute("error.message", str(e))
335
+ span.end()
336
+ raise
337
+
338
+ builtins.open = traced_open
339
+
340
+ # Also patch os.fdopen — some frameworks (e.g. DeepAgents) use
341
+ # os.open() + os.fdopen() for symlink-safe I/O with O_NOFOLLOW,
342
+ # bypassing builtins.open entirely.
343
+ _original_fdopen = os.fdopen
344
+ os._openbox_original_fdopen = _original_fdopen # Store for uninstrumentation
345
+
346
+ def traced_fdopen(fd, mode='r', *args, **kwargs):
347
+ # Resolve path from fd when possible
348
+ try:
349
+ file_str = os.readlink(f"/proc/self/fd/{fd}")
350
+ except (OSError, ValueError):
351
+ try:
352
+ # macOS: use fcntl F_GETPATH
353
+ import fcntl
354
+ buf = b'\x00' * 1024
355
+ file_str = fcntl.fcntl(fd, fcntl.F_GETPATH, buf).split(b'\x00', 1)[0].decode()
356
+ except Exception:
357
+ file_str = f"<fd:{fd}>"
358
+
359
+ if any(p in file_str for p in _skip_patterns):
360
+ return _original_fdopen(fd, mode, *args, **kwargs)
361
+
362
+ span = _tracer.start_span("file.open")
363
+ span.set_attribute("file.path", file_str)
364
+ span.set_attribute("file.mode", mode)
365
+
366
+ if _hook_gov.is_configured():
367
+ from openbox_langgraph.errors import GovernanceBlockedError
368
+ try:
369
+ open_span_data = _build_file_span_data(
370
+ span, file_str, mode, "open", "started",
371
+ )
372
+ _hook_gov.evaluate_sync(
373
+ span,
374
+ identifier=file_str,
375
+ span_data=open_span_data,
376
+ )
377
+ except GovernanceBlockedError:
378
+ span.set_attribute("error", True)
379
+ span.set_attribute("governance.blocked", True)
380
+ span.end()
381
+ raise
382
+ except Exception:
383
+ pass
384
+
385
+ try:
386
+ file_obj = _original_fdopen(fd, mode, *args, **kwargs)
387
+ return TracedFile(file_obj, file_str, mode, span)
388
+ except Exception as e:
389
+ span.set_attribute("error", True)
390
+ span.set_attribute("error.type", type(e).__name__)
391
+ span.set_attribute("error.message", str(e))
392
+ span.end()
393
+ raise
394
+
395
+ os.fdopen = traced_fdopen
396
+ logger.info("Instrumented: file I/O (builtins.open + os.fdopen)")
397
+ return True
398
+
399
+
400
+ def uninstrument_file_io() -> None:
401
+ """Restore original open() and os.fdopen() functions."""
402
+ import builtins
403
+ restored = []
404
+ if hasattr(builtins, '_openbox_original_open'):
405
+ builtins.open = builtins._openbox_original_open
406
+ delattr(builtins, '_openbox_original_open')
407
+ restored.append("builtins.open")
408
+ if hasattr(os, '_openbox_original_fdopen'):
409
+ os.fdopen = os._openbox_original_fdopen
410
+ delattr(os, '_openbox_original_fdopen')
411
+ restored.append("os.fdopen")
412
+ if restored:
413
+ logger.info("Uninstrumented: file I/O (%s)", ", ".join(restored))
@@ -0,0 +1,88 @@
1
+ """OpenBox LangGraph SDK — Human-in-the-Loop (HITL) approval polling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import dataclass
7
+
8
+ from openbox_langgraph.client import ApprovalPollParams, GovernanceClient
9
+ from openbox_langgraph.errors import (
10
+ ApprovalExpiredError,
11
+ ApprovalRejectedError,
12
+ GovernanceBlockedError,
13
+ )
14
+ from openbox_langgraph.types import HITLConfig, Verdict, verdict_should_stop
15
+
16
+
17
+ @dataclass
18
+ class HITLPollParams:
19
+ """Parameters for the HITL polling loop."""
20
+
21
+ workflow_id: str
22
+ run_id: str
23
+ activity_id: str
24
+ activity_type: str
25
+
26
+
27
+ async def poll_until_decision(
28
+ client: GovernanceClient,
29
+ params: HITLPollParams,
30
+ config: HITLConfig,
31
+ ) -> None:
32
+ """Block until governance approves or rejects.
33
+
34
+ Polls OpenBox Core indefinitely until a terminal verdict is received.
35
+ The server controls approval expiration — the SDK does not impose a deadline.
36
+
37
+ Resolves (returns None) when approval is granted (ALLOW verdict).
38
+ Raises on rejection, expiry, or HALT/BLOCK verdict.
39
+
40
+ Args:
41
+ client: The governance HTTP client.
42
+ params: Identifiers for the pending approval.
43
+ config: HITL polling configuration (poll_interval_ms).
44
+
45
+ Raises:
46
+ ApprovalExpiredError: When the approval window has expired (server-side).
47
+ ApprovalRejectedError: When approval is explicitly rejected.
48
+ GovernanceBlockedError: When verdict is BLOCK.
49
+ """
50
+ while True:
51
+ await asyncio.sleep(config.poll_interval_ms / 1000.0)
52
+
53
+ response = await client.poll_approval(
54
+ ApprovalPollParams(
55
+ workflow_id=params.workflow_id,
56
+ run_id=params.run_id,
57
+ activity_id=params.activity_id,
58
+ )
59
+ )
60
+
61
+ if response is None:
62
+ # API unreachable — keep polling (fail-open for HITL)
63
+ continue
64
+
65
+ if response.expired:
66
+ msg = (
67
+ f"Approval expired for {params.activity_type} "
68
+ f"(activity_id={params.activity_id})"
69
+ )
70
+ raise ApprovalExpiredError(msg)
71
+
72
+ verdict = response.verdict
73
+ reason = response.reason
74
+
75
+ if verdict == Verdict.ALLOW:
76
+ return
77
+
78
+ # HALT or any stop verdict → rejected
79
+ if verdict_should_stop(verdict):
80
+ msg = reason or f"Approval rejected for {params.activity_type}"
81
+ raise ApprovalRejectedError(msg)
82
+
83
+ # BLOCK specifically
84
+ if verdict == Verdict.BLOCK:
85
+ msg = reason or f"Approval rejected for {params.activity_type}"
86
+ raise GovernanceBlockedError("block", msg, params.activity_id)
87
+
88
+ # Still pending (REQUIRE_APPROVAL / CONSTRAIN) — keep polling