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,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
|