truthound-dashboard 1.3.0__py3-none-any.whl → 1.4.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.
Files changed (169) hide show
  1. truthound_dashboard/api/alerts.py +258 -0
  2. truthound_dashboard/api/anomaly.py +1302 -0
  3. truthound_dashboard/api/cross_alerts.py +352 -0
  4. truthound_dashboard/api/deps.py +143 -0
  5. truthound_dashboard/api/drift_monitor.py +540 -0
  6. truthound_dashboard/api/lineage.py +1151 -0
  7. truthound_dashboard/api/maintenance.py +363 -0
  8. truthound_dashboard/api/middleware.py +373 -1
  9. truthound_dashboard/api/model_monitoring.py +805 -0
  10. truthound_dashboard/api/notifications_advanced.py +2452 -0
  11. truthound_dashboard/api/plugins.py +2096 -0
  12. truthound_dashboard/api/profile.py +211 -14
  13. truthound_dashboard/api/reports.py +853 -0
  14. truthound_dashboard/api/router.py +147 -0
  15. truthound_dashboard/api/rule_suggestions.py +310 -0
  16. truthound_dashboard/api/schema_evolution.py +231 -0
  17. truthound_dashboard/api/sources.py +47 -3
  18. truthound_dashboard/api/triggers.py +190 -0
  19. truthound_dashboard/api/validations.py +13 -0
  20. truthound_dashboard/api/validators.py +333 -4
  21. truthound_dashboard/api/versioning.py +309 -0
  22. truthound_dashboard/api/websocket.py +301 -0
  23. truthound_dashboard/core/__init__.py +27 -0
  24. truthound_dashboard/core/anomaly.py +1395 -0
  25. truthound_dashboard/core/anomaly_explainer.py +633 -0
  26. truthound_dashboard/core/cache.py +206 -0
  27. truthound_dashboard/core/cached_services.py +422 -0
  28. truthound_dashboard/core/charts.py +352 -0
  29. truthound_dashboard/core/connections.py +1069 -42
  30. truthound_dashboard/core/cross_alerts.py +837 -0
  31. truthound_dashboard/core/drift_monitor.py +1477 -0
  32. truthound_dashboard/core/drift_sampling.py +669 -0
  33. truthound_dashboard/core/i18n/__init__.py +42 -0
  34. truthound_dashboard/core/i18n/detector.py +173 -0
  35. truthound_dashboard/core/i18n/messages.py +564 -0
  36. truthound_dashboard/core/lineage.py +971 -0
  37. truthound_dashboard/core/maintenance.py +443 -5
  38. truthound_dashboard/core/model_monitoring.py +1043 -0
  39. truthound_dashboard/core/notifications/channels.py +1020 -1
  40. truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
  41. truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
  42. truthound_dashboard/core/notifications/deduplication/service.py +400 -0
  43. truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
  44. truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
  45. truthound_dashboard/core/notifications/dispatcher.py +43 -0
  46. truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
  47. truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
  48. truthound_dashboard/core/notifications/escalation/engine.py +429 -0
  49. truthound_dashboard/core/notifications/escalation/models.py +336 -0
  50. truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
  51. truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
  52. truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
  53. truthound_dashboard/core/notifications/events.py +49 -0
  54. truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
  55. truthound_dashboard/core/notifications/metrics/base.py +528 -0
  56. truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
  57. truthound_dashboard/core/notifications/routing/__init__.py +169 -0
  58. truthound_dashboard/core/notifications/routing/combinators.py +184 -0
  59. truthound_dashboard/core/notifications/routing/config.py +375 -0
  60. truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
  61. truthound_dashboard/core/notifications/routing/engine.py +382 -0
  62. truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
  63. truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
  64. truthound_dashboard/core/notifications/routing/rules.py +625 -0
  65. truthound_dashboard/core/notifications/routing/validator.py +678 -0
  66. truthound_dashboard/core/notifications/service.py +2 -0
  67. truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
  68. truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
  69. truthound_dashboard/core/notifications/throttling/builder.py +311 -0
  70. truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
  71. truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
  72. truthound_dashboard/core/openlineage.py +1028 -0
  73. truthound_dashboard/core/plugins/__init__.py +39 -0
  74. truthound_dashboard/core/plugins/docs/__init__.py +39 -0
  75. truthound_dashboard/core/plugins/docs/extractor.py +703 -0
  76. truthound_dashboard/core/plugins/docs/renderers.py +804 -0
  77. truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
  78. truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
  79. truthound_dashboard/core/plugins/hooks/manager.py +403 -0
  80. truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
  81. truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
  82. truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
  83. truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
  84. truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
  85. truthound_dashboard/core/plugins/loader.py +504 -0
  86. truthound_dashboard/core/plugins/registry.py +810 -0
  87. truthound_dashboard/core/plugins/reporter_executor.py +588 -0
  88. truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
  89. truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
  90. truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
  91. truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
  92. truthound_dashboard/core/plugins/sandbox.py +617 -0
  93. truthound_dashboard/core/plugins/security/__init__.py +68 -0
  94. truthound_dashboard/core/plugins/security/analyzer.py +535 -0
  95. truthound_dashboard/core/plugins/security/policies.py +311 -0
  96. truthound_dashboard/core/plugins/security/protocols.py +296 -0
  97. truthound_dashboard/core/plugins/security/signing.py +842 -0
  98. truthound_dashboard/core/plugins/security.py +446 -0
  99. truthound_dashboard/core/plugins/validator_executor.py +401 -0
  100. truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
  101. truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
  102. truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
  103. truthound_dashboard/core/plugins/versioning/semver.py +266 -0
  104. truthound_dashboard/core/profile_comparison.py +601 -0
  105. truthound_dashboard/core/report_history.py +570 -0
  106. truthound_dashboard/core/reporters/__init__.py +57 -0
  107. truthound_dashboard/core/reporters/base.py +296 -0
  108. truthound_dashboard/core/reporters/csv_reporter.py +155 -0
  109. truthound_dashboard/core/reporters/html_reporter.py +598 -0
  110. truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
  111. truthound_dashboard/core/reporters/i18n/base.py +494 -0
  112. truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
  113. truthound_dashboard/core/reporters/json_reporter.py +160 -0
  114. truthound_dashboard/core/reporters/junit_reporter.py +233 -0
  115. truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
  116. truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
  117. truthound_dashboard/core/reporters/registry.py +272 -0
  118. truthound_dashboard/core/rule_generator.py +2088 -0
  119. truthound_dashboard/core/scheduler.py +822 -12
  120. truthound_dashboard/core/schema_evolution.py +858 -0
  121. truthound_dashboard/core/services.py +152 -9
  122. truthound_dashboard/core/statistics.py +718 -0
  123. truthound_dashboard/core/streaming_anomaly.py +883 -0
  124. truthound_dashboard/core/triggers/__init__.py +45 -0
  125. truthound_dashboard/core/triggers/base.py +226 -0
  126. truthound_dashboard/core/triggers/evaluators.py +609 -0
  127. truthound_dashboard/core/triggers/factory.py +363 -0
  128. truthound_dashboard/core/unified_alerts.py +870 -0
  129. truthound_dashboard/core/validation_limits.py +509 -0
  130. truthound_dashboard/core/versioning.py +709 -0
  131. truthound_dashboard/core/websocket/__init__.py +59 -0
  132. truthound_dashboard/core/websocket/manager.py +512 -0
  133. truthound_dashboard/core/websocket/messages.py +130 -0
  134. truthound_dashboard/db/__init__.py +30 -0
  135. truthound_dashboard/db/models.py +3375 -3
  136. truthound_dashboard/main.py +22 -0
  137. truthound_dashboard/schemas/__init__.py +396 -1
  138. truthound_dashboard/schemas/anomaly.py +1258 -0
  139. truthound_dashboard/schemas/base.py +4 -0
  140. truthound_dashboard/schemas/cross_alerts.py +334 -0
  141. truthound_dashboard/schemas/drift_monitor.py +890 -0
  142. truthound_dashboard/schemas/lineage.py +428 -0
  143. truthound_dashboard/schemas/maintenance.py +154 -0
  144. truthound_dashboard/schemas/model_monitoring.py +374 -0
  145. truthound_dashboard/schemas/notifications_advanced.py +1363 -0
  146. truthound_dashboard/schemas/openlineage.py +704 -0
  147. truthound_dashboard/schemas/plugins.py +1293 -0
  148. truthound_dashboard/schemas/profile.py +420 -34
  149. truthound_dashboard/schemas/profile_comparison.py +242 -0
  150. truthound_dashboard/schemas/reports.py +285 -0
  151. truthound_dashboard/schemas/rule_suggestion.py +434 -0
  152. truthound_dashboard/schemas/schema_evolution.py +164 -0
  153. truthound_dashboard/schemas/source.py +117 -2
  154. truthound_dashboard/schemas/triggers.py +511 -0
  155. truthound_dashboard/schemas/unified_alerts.py +223 -0
  156. truthound_dashboard/schemas/validation.py +25 -1
  157. truthound_dashboard/schemas/validators/__init__.py +11 -0
  158. truthound_dashboard/schemas/validators/base.py +151 -0
  159. truthound_dashboard/schemas/versioning.py +152 -0
  160. truthound_dashboard/static/index.html +2 -2
  161. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/METADATA +142 -18
  162. truthound_dashboard-1.4.0.dist-info/RECORD +239 -0
  163. truthound_dashboard/static/assets/index-BCA8H1hO.js +0 -574
  164. truthound_dashboard/static/assets/index-BNsSQ2fN.css +0 -1
  165. truthound_dashboard/static/assets/unmerged_dictionaries-CsJWCRx9.js +0 -1
  166. truthound_dashboard-1.3.0.dist-info/RECORD +0 -110
  167. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/WHEEL +0 -0
  168. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/entry_points.txt +0 -0
  169. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,770 @@
1
+ """Sandbox Engine Implementations.
2
+
3
+ This module provides sandbox engines with different isolation levels:
4
+ - NoOpSandbox: No isolation (trusted plugins only)
5
+ - ProcessSandbox: Subprocess isolation with resource limits
6
+ - ContainerSandbox: Docker/Podman container isolation
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+ import json
13
+ import logging
14
+ import os
15
+ import signal
16
+ import subprocess
17
+ import sys
18
+ import tempfile
19
+ import time
20
+ import traceback
21
+ from abc import ABC, abstractmethod
22
+ from contextlib import contextmanager
23
+ from typing import Any
24
+
25
+ from .protocols import (
26
+ SandboxConfig,
27
+ SandboxError,
28
+ SandboxMemoryError,
29
+ SandboxResult,
30
+ SandboxSecurityError,
31
+ SandboxTimeoutError,
32
+ )
33
+ from .code_validator import (
34
+ CodeValidator,
35
+ RestrictedImporter,
36
+ create_safe_builtins,
37
+ )
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ class SandboxEngine(ABC):
43
+ """Abstract base class for sandbox engines."""
44
+
45
+ @property
46
+ @abstractmethod
47
+ def isolation_level(self) -> str:
48
+ """Get the isolation level."""
49
+ ...
50
+
51
+ @abstractmethod
52
+ def execute(
53
+ self,
54
+ code: str,
55
+ globals_dict: dict[str, Any] | None = None,
56
+ locals_dict: dict[str, Any] | None = None,
57
+ entry_point: str | None = None,
58
+ entry_args: dict[str, Any] | None = None,
59
+ ) -> SandboxResult:
60
+ """Execute code in the sandbox.
61
+
62
+ Args:
63
+ code: Python source code to execute.
64
+ globals_dict: Global variables to provide.
65
+ locals_dict: Local variables to provide.
66
+ entry_point: Function name to call after execution.
67
+ entry_args: Arguments to pass to entry point.
68
+
69
+ Returns:
70
+ SandboxResult with execution results.
71
+ """
72
+ ...
73
+
74
+ def validate_code(self, code: str) -> tuple[bool, list[str]]:
75
+ """Validate code before execution.
76
+
77
+ Args:
78
+ code: Python source code.
79
+
80
+ Returns:
81
+ Tuple of (is_valid, list of issues).
82
+ """
83
+ validator = CodeValidator()
84
+ return validator.validate(code)
85
+
86
+
87
+ class NoOpSandbox(SandboxEngine):
88
+ """No-isolation sandbox for trusted plugins."""
89
+
90
+ def __init__(self, config: SandboxConfig | None = None) -> None:
91
+ """Initialize the sandbox.
92
+
93
+ Args:
94
+ config: Sandbox configuration (mostly ignored).
95
+ """
96
+ self.config = config or SandboxConfig(enabled=False)
97
+
98
+ @property
99
+ def isolation_level(self) -> str:
100
+ """Get the isolation level."""
101
+ return "none"
102
+
103
+ @contextmanager
104
+ def _capture_output(self):
105
+ """Context manager to capture stdout and stderr."""
106
+ old_stdout = sys.stdout
107
+ old_stderr = sys.stderr
108
+ sys.stdout = io.StringIO()
109
+ sys.stderr = io.StringIO()
110
+ try:
111
+ yield sys.stdout, sys.stderr
112
+ finally:
113
+ sys.stdout = old_stdout
114
+ sys.stderr = old_stderr
115
+
116
+ def execute(
117
+ self,
118
+ code: str,
119
+ globals_dict: dict[str, Any] | None = None,
120
+ locals_dict: dict[str, Any] | None = None,
121
+ entry_point: str | None = None,
122
+ entry_args: dict[str, Any] | None = None,
123
+ ) -> SandboxResult:
124
+ """Execute code without isolation."""
125
+ start_time = time.perf_counter()
126
+
127
+ exec_globals = globals_dict.copy() if globals_dict else {}
128
+ exec_locals = locals_dict.copy() if locals_dict else {}
129
+
130
+ result = None
131
+ error = None
132
+ error_type = None
133
+ stdout_str = ""
134
+ stderr_str = ""
135
+
136
+ try:
137
+ with self._capture_output() as (stdout, stderr):
138
+ exec(code, exec_globals, exec_locals)
139
+
140
+ if entry_point and entry_point in exec_locals:
141
+ func = exec_locals[entry_point]
142
+ if callable(func):
143
+ result = func(**(entry_args or {}))
144
+ else:
145
+ error = f"Entry point '{entry_point}' is not callable"
146
+ error_type = "ValueError"
147
+ elif entry_point:
148
+ error = f"Entry point '{entry_point}' not found"
149
+ error_type = "KeyError"
150
+
151
+ stdout_str = stdout.getvalue()
152
+ stderr_str = stderr.getvalue()
153
+
154
+ except Exception as e:
155
+ error = f"{type(e).__name__}: {e}\n{traceback.format_exc()}"
156
+ error_type = type(e).__name__
157
+
158
+ execution_time = (time.perf_counter() - start_time) * 1000
159
+
160
+ return SandboxResult(
161
+ success=error is None,
162
+ result=result,
163
+ error=error,
164
+ error_type=error_type,
165
+ stdout=stdout_str,
166
+ stderr=stderr_str,
167
+ execution_time_ms=execution_time,
168
+ )
169
+
170
+
171
+ class ProcessSandbox(SandboxEngine):
172
+ """Process-based sandbox with resource limits."""
173
+
174
+ # Template for subprocess execution
175
+ RUNNER_TEMPLATE = '''
176
+ import sys
177
+ import json
178
+ import traceback
179
+
180
+ # Restore builtins for runner
181
+ import builtins
182
+ __builtins__ = builtins
183
+
184
+ def main():
185
+ # Read input
186
+ input_data = json.loads(sys.stdin.read())
187
+
188
+ code = input_data["code"]
189
+ globals_dict = input_data.get("globals", {})
190
+ locals_dict = input_data.get("locals", {})
191
+ entry_point = input_data.get("entry_point")
192
+ entry_args = input_data.get("entry_args", {})
193
+ safe_builtins = input_data.get("safe_builtins", [])
194
+ blocked_modules = input_data.get("blocked_modules", [])
195
+
196
+ # Create restricted environment
197
+ import ast
198
+
199
+ class RestrictedImporter:
200
+ SAFE_MODULES = {
201
+ "math", "statistics", "decimal", "fractions",
202
+ "random", "re", "json", "datetime",
203
+ "collections", "itertools", "functools",
204
+ "operator", "string", "textwrap", "unicodedata",
205
+ "typing", "dataclasses", "enum", "copy",
206
+ "numbers", "hashlib", "hmac", "base64",
207
+ "binascii", "io", "csv", "abc", "contextlib",
208
+ }
209
+
210
+ def __init__(self, blocked):
211
+ self.blocked = set(blocked)
212
+
213
+ def find_module(self, name, path=None):
214
+ base = name.split(".")[0]
215
+ if base in self.blocked:
216
+ return self
217
+ if base not in self.SAFE_MODULES:
218
+ return self
219
+ return None
220
+
221
+ def load_module(self, name):
222
+ raise ImportError(f"Import of '{name}' is not allowed")
223
+
224
+ # Install import restrictions
225
+ sys.meta_path.insert(0, RestrictedImporter(blocked_modules))
226
+
227
+ # Create safe builtins
228
+ safe_builtin_dict = {}
229
+ for name in safe_builtins:
230
+ if hasattr(builtins, name):
231
+ safe_builtin_dict[name] = getattr(builtins, name)
232
+
233
+ safe_builtin_dict["None"] = None
234
+ safe_builtin_dict["True"] = True
235
+ safe_builtin_dict["False"] = False
236
+ safe_builtin_dict["__name__"] = "__sandbox__"
237
+
238
+ # Add safe exceptions
239
+ for exc in ["Exception", "ValueError", "TypeError", "KeyError", "IndexError",
240
+ "AttributeError", "RuntimeError", "StopIteration", "ImportError"]:
241
+ if hasattr(builtins, exc):
242
+ safe_builtin_dict[exc] = getattr(builtins, exc)
243
+
244
+ # Prepare execution environment
245
+ exec_globals = globals_dict.copy()
246
+ exec_globals["__builtins__"] = safe_builtin_dict
247
+ exec_locals = locals_dict.copy()
248
+
249
+ result = None
250
+ error = None
251
+ error_type = None
252
+
253
+ try:
254
+ # Execute code
255
+ exec(code, exec_globals, exec_locals)
256
+
257
+ # Call entry point if specified
258
+ if entry_point:
259
+ if entry_point in exec_locals:
260
+ func = exec_locals[entry_point]
261
+ if callable(func):
262
+ result = func(**entry_args)
263
+ else:
264
+ error = f"Entry point '{entry_point}' is not callable"
265
+ error_type = "ValueError"
266
+ else:
267
+ error = f"Entry point '{entry_point}' not found"
268
+ error_type = "KeyError"
269
+
270
+ except Exception as e:
271
+ error = f"{type(e).__name__}: {e}"
272
+ error_type = type(e).__name__
273
+
274
+ # Output result
275
+ output = {
276
+ "success": error is None,
277
+ "result": result if is_json_serializable(result) else str(result),
278
+ "error": error,
279
+ "error_type": error_type,
280
+ }
281
+
282
+ print("__RESULT_START__")
283
+ print(json.dumps(output))
284
+ print("__RESULT_END__")
285
+
286
+ def is_json_serializable(obj):
287
+ try:
288
+ json.dumps(obj)
289
+ return True
290
+ except (TypeError, ValueError):
291
+ return False
292
+
293
+ if __name__ == "__main__":
294
+ main()
295
+ '''
296
+
297
+ def __init__(self, config: SandboxConfig | None = None) -> None:
298
+ """Initialize the sandbox.
299
+
300
+ Args:
301
+ config: Sandbox configuration.
302
+ """
303
+ self.config = config or SandboxConfig()
304
+
305
+ @property
306
+ def isolation_level(self) -> str:
307
+ """Get the isolation level."""
308
+ return "process"
309
+
310
+ def execute(
311
+ self,
312
+ code: str,
313
+ globals_dict: dict[str, Any] | None = None,
314
+ locals_dict: dict[str, Any] | None = None,
315
+ entry_point: str | None = None,
316
+ entry_args: dict[str, Any] | None = None,
317
+ ) -> SandboxResult:
318
+ """Execute code in a subprocess."""
319
+ start_time = time.perf_counter()
320
+
321
+ # Validate code first
322
+ is_valid, issues = self.validate_code(code)
323
+ if not is_valid:
324
+ return SandboxResult(
325
+ success=False,
326
+ error=f"Code validation failed: {'; '.join(issues)}",
327
+ error_type="SandboxSecurityError",
328
+ warnings=issues,
329
+ )
330
+
331
+ # Prepare input data
332
+ input_data = {
333
+ "code": code,
334
+ "globals": self._serialize_dict(globals_dict or {}),
335
+ "locals": self._serialize_dict(locals_dict or {}),
336
+ "entry_point": entry_point,
337
+ "entry_args": entry_args or {},
338
+ "safe_builtins": self.config.allowed_builtins,
339
+ "blocked_modules": self.config.blocked_modules,
340
+ }
341
+
342
+ # Write runner script to temp file
343
+ with tempfile.NamedTemporaryFile(
344
+ mode="w", suffix=".py", delete=False
345
+ ) as runner_file:
346
+ runner_file.write(self.RUNNER_TEMPLATE)
347
+ runner_path = runner_file.name
348
+
349
+ try:
350
+ # Build subprocess command
351
+ cmd = [sys.executable, "-u", runner_path]
352
+
353
+ # Start subprocess
354
+ process = subprocess.Popen(
355
+ cmd,
356
+ stdin=subprocess.PIPE,
357
+ stdout=subprocess.PIPE,
358
+ stderr=subprocess.PIPE,
359
+ text=True,
360
+ env=self._get_restricted_env(),
361
+ )
362
+
363
+ try:
364
+ # Send input and get output
365
+ stdout, stderr = process.communicate(
366
+ input=json.dumps(input_data),
367
+ timeout=self.config.wall_time_limit_sec,
368
+ )
369
+ except subprocess.TimeoutExpired:
370
+ process.kill()
371
+ process.wait()
372
+ return SandboxResult(
373
+ success=False,
374
+ error=f"Execution timed out after {self.config.wall_time_limit_sec}s",
375
+ error_type="SandboxTimeoutError",
376
+ exit_code=-9,
377
+ )
378
+
379
+ # Parse result
380
+ result = self._parse_output(stdout, stderr, process.returncode)
381
+ result.execution_time_ms = (time.perf_counter() - start_time) * 1000
382
+ result.exit_code = process.returncode
383
+ return result
384
+
385
+ finally:
386
+ # Clean up runner script
387
+ try:
388
+ os.unlink(runner_path)
389
+ except Exception:
390
+ pass
391
+
392
+ def _serialize_dict(self, d: dict[str, Any]) -> dict[str, Any]:
393
+ """Serialize dictionary for JSON transport."""
394
+ result = {}
395
+ for key, value in d.items():
396
+ try:
397
+ json.dumps(value)
398
+ result[key] = value
399
+ except (TypeError, ValueError):
400
+ # Skip non-serializable values
401
+ pass
402
+ return result
403
+
404
+ def _get_restricted_env(self) -> dict[str, str]:
405
+ """Get restricted environment variables."""
406
+ env = os.environ.copy()
407
+ # Remove potentially dangerous env vars
408
+ for key in ["LD_PRELOAD", "LD_LIBRARY_PATH", "PYTHONSTARTUP"]:
409
+ env.pop(key, None)
410
+ return env
411
+
412
+ def _parse_output(
413
+ self, stdout: str, stderr: str, exit_code: int
414
+ ) -> SandboxResult:
415
+ """Parse subprocess output."""
416
+ # Look for result markers
417
+ if "__RESULT_START__" in stdout and "__RESULT_END__" in stdout:
418
+ try:
419
+ start = stdout.index("__RESULT_START__") + len("__RESULT_START__")
420
+ end = stdout.index("__RESULT_END__")
421
+ result_json = stdout[start:end].strip()
422
+ result_data = json.loads(result_json)
423
+
424
+ # Extract stdout before markers
425
+ clean_stdout = stdout[:stdout.index("__RESULT_START__")].strip()
426
+
427
+ return SandboxResult(
428
+ success=result_data.get("success", False),
429
+ result=result_data.get("result"),
430
+ error=result_data.get("error"),
431
+ error_type=result_data.get("error_type"),
432
+ stdout=clean_stdout,
433
+ stderr=stderr,
434
+ )
435
+ except (json.JSONDecodeError, ValueError) as e:
436
+ return SandboxResult(
437
+ success=False,
438
+ error=f"Failed to parse result: {e}",
439
+ error_type="ParseError",
440
+ stdout=stdout,
441
+ stderr=stderr,
442
+ )
443
+
444
+ # No result markers found - execution error
445
+ if exit_code != 0:
446
+ return SandboxResult(
447
+ success=False,
448
+ error=f"Process exited with code {exit_code}",
449
+ error_type="ProcessError",
450
+ stdout=stdout,
451
+ stderr=stderr,
452
+ )
453
+
454
+ return SandboxResult(
455
+ success=True,
456
+ stdout=stdout,
457
+ stderr=stderr,
458
+ )
459
+
460
+
461
+ class ContainerSandbox(SandboxEngine):
462
+ """Container-based sandbox using Docker or Podman."""
463
+
464
+ def __init__(self, config: SandboxConfig | None = None) -> None:
465
+ """Initialize the sandbox.
466
+
467
+ Args:
468
+ config: Sandbox configuration.
469
+ """
470
+ self.config = config or SandboxConfig(isolation_level="container")
471
+ self._container_runtime = self._detect_runtime()
472
+
473
+ @property
474
+ def isolation_level(self) -> str:
475
+ """Get the isolation level."""
476
+ return "container"
477
+
478
+ def _detect_runtime(self) -> str | None:
479
+ """Detect available container runtime."""
480
+ for runtime in ["docker", "podman"]:
481
+ try:
482
+ result = subprocess.run(
483
+ [runtime, "--version"],
484
+ capture_output=True,
485
+ text=True,
486
+ timeout=5,
487
+ )
488
+ if result.returncode == 0:
489
+ logger.info(f"Using container runtime: {runtime}")
490
+ return runtime
491
+ except (subprocess.TimeoutExpired, FileNotFoundError):
492
+ continue
493
+ return None
494
+
495
+ def is_available(self) -> bool:
496
+ """Check if container runtime is available."""
497
+ return self._container_runtime is not None
498
+
499
+ def execute(
500
+ self,
501
+ code: str,
502
+ globals_dict: dict[str, Any] | None = None,
503
+ locals_dict: dict[str, Any] | None = None,
504
+ entry_point: str | None = None,
505
+ entry_args: dict[str, Any] | None = None,
506
+ ) -> SandboxResult:
507
+ """Execute code in a container."""
508
+ if not self._container_runtime:
509
+ # Fall back to process sandbox
510
+ logger.warning("Container runtime not available, using process sandbox")
511
+ return ProcessSandbox(self.config).execute(
512
+ code, globals_dict, locals_dict, entry_point, entry_args
513
+ )
514
+
515
+ start_time = time.perf_counter()
516
+
517
+ # Validate code first
518
+ is_valid, issues = self.validate_code(code)
519
+ if not is_valid:
520
+ return SandboxResult(
521
+ success=False,
522
+ error=f"Code validation failed: {'; '.join(issues)}",
523
+ error_type="SandboxSecurityError",
524
+ warnings=issues,
525
+ )
526
+
527
+ # Create temp directory for code
528
+ with tempfile.TemporaryDirectory() as tmpdir:
529
+ # Write code to file
530
+ code_path = os.path.join(tmpdir, "code.py")
531
+ with open(code_path, "w") as f:
532
+ f.write(self._wrap_code(code, entry_point, entry_args))
533
+
534
+ # Write input data
535
+ input_path = os.path.join(tmpdir, "input.json")
536
+ input_data = {
537
+ "globals": self._serialize_dict(globals_dict or {}),
538
+ "locals": self._serialize_dict(locals_dict or {}),
539
+ }
540
+ with open(input_path, "w") as f:
541
+ json.dump(input_data, f)
542
+
543
+ # Build container command
544
+ cmd = self._build_container_command(tmpdir)
545
+
546
+ try:
547
+ # Run container
548
+ process = subprocess.Popen(
549
+ cmd,
550
+ stdout=subprocess.PIPE,
551
+ stderr=subprocess.PIPE,
552
+ text=True,
553
+ )
554
+
555
+ try:
556
+ stdout, stderr = process.communicate(
557
+ timeout=self.config.wall_time_limit_sec
558
+ )
559
+ except subprocess.TimeoutExpired:
560
+ # Kill container
561
+ subprocess.run(
562
+ [self._container_runtime, "kill", f"sandbox_{os.getpid()}"],
563
+ capture_output=True,
564
+ )
565
+ process.kill()
566
+ process.wait()
567
+ return SandboxResult(
568
+ success=False,
569
+ error=f"Execution timed out after {self.config.wall_time_limit_sec}s",
570
+ error_type="SandboxTimeoutError",
571
+ exit_code=-9,
572
+ )
573
+
574
+ # Parse output
575
+ result = self._parse_container_output(stdout, stderr, process.returncode)
576
+ result.execution_time_ms = (time.perf_counter() - start_time) * 1000
577
+ result.exit_code = process.returncode
578
+ return result
579
+
580
+ except Exception as e:
581
+ return SandboxResult(
582
+ success=False,
583
+ error=f"Container execution failed: {e}",
584
+ error_type="ContainerError",
585
+ )
586
+
587
+ def _wrap_code(
588
+ self,
589
+ code: str,
590
+ entry_point: str | None,
591
+ entry_args: dict[str, Any] | None,
592
+ ) -> str:
593
+ """Wrap code for container execution."""
594
+ wrapper = f'''
595
+ import sys
596
+ import json
597
+
598
+ # Execute user code
599
+ {code}
600
+
601
+ # Call entry point if specified
602
+ if __name__ == "__main__":
603
+ result = None
604
+ error = None
605
+
606
+ try:
607
+ entry_point = {repr(entry_point)}
608
+ entry_args = {repr(entry_args or {})}
609
+
610
+ if entry_point:
611
+ func = locals().get(entry_point) or globals().get(entry_point)
612
+ if func and callable(func):
613
+ result = func(**entry_args)
614
+ elif func:
615
+ error = f"Entry point '{{entry_point}}' is not callable"
616
+ else:
617
+ error = f"Entry point '{{entry_point}}' not found"
618
+ except Exception as e:
619
+ error = f"{{type(e).__name__}}: {{e}}"
620
+
621
+ output = {{
622
+ "success": error is None,
623
+ "result": result,
624
+ "error": error,
625
+ }}
626
+ print("__RESULT__" + json.dumps(output))
627
+ '''
628
+ return wrapper
629
+
630
+ def _build_container_command(self, tmpdir: str) -> list[str]:
631
+ """Build container run command."""
632
+ cmd = [
633
+ self._container_runtime,
634
+ "run",
635
+ "--rm",
636
+ "--name", f"sandbox_{os.getpid()}",
637
+ # Resource limits
638
+ f"--memory={self.config.memory_limit_mb}m",
639
+ f"--cpus={self.config.max_processes}",
640
+ "--pids-limit", str(self.config.max_processes * 10),
641
+ # Security
642
+ "--read-only",
643
+ "--security-opt", "no-new-privileges",
644
+ "--cap-drop", "ALL",
645
+ ]
646
+
647
+ # Network
648
+ if not self.config.network_enabled:
649
+ cmd.extend(["--network", "none"])
650
+
651
+ # Mount code directory
652
+ cmd.extend(["-v", f"{tmpdir}:/sandbox:ro"])
653
+ cmd.extend(["-w", "/sandbox"])
654
+
655
+ # Image and command
656
+ cmd.append(self.config.container_image)
657
+ cmd.extend(["python", "/sandbox/code.py"])
658
+
659
+ return cmd
660
+
661
+ def _serialize_dict(self, d: dict[str, Any]) -> dict[str, Any]:
662
+ """Serialize dictionary for JSON transport."""
663
+ result = {}
664
+ for key, value in d.items():
665
+ try:
666
+ json.dumps(value)
667
+ result[key] = value
668
+ except (TypeError, ValueError):
669
+ pass
670
+ return result
671
+
672
+ def _parse_container_output(
673
+ self, stdout: str, stderr: str, exit_code: int
674
+ ) -> SandboxResult:
675
+ """Parse container output."""
676
+ # Look for result marker
677
+ if "__RESULT__" in stdout:
678
+ try:
679
+ idx = stdout.index("__RESULT__")
680
+ result_json = stdout[idx + len("__RESULT__"):].strip()
681
+ result_data = json.loads(result_json)
682
+
683
+ clean_stdout = stdout[:idx].strip()
684
+
685
+ return SandboxResult(
686
+ success=result_data.get("success", False),
687
+ result=result_data.get("result"),
688
+ error=result_data.get("error"),
689
+ stdout=clean_stdout,
690
+ stderr=stderr,
691
+ )
692
+ except (json.JSONDecodeError, ValueError) as e:
693
+ return SandboxResult(
694
+ success=False,
695
+ error=f"Failed to parse result: {e}",
696
+ stdout=stdout,
697
+ stderr=stderr,
698
+ )
699
+
700
+ if exit_code != 0:
701
+ return SandboxResult(
702
+ success=False,
703
+ error=f"Container exited with code {exit_code}",
704
+ stdout=stdout,
705
+ stderr=stderr,
706
+ )
707
+
708
+ return SandboxResult(
709
+ success=True,
710
+ stdout=stdout,
711
+ stderr=stderr,
712
+ )
713
+
714
+
715
+ def create_sandbox(config: SandboxConfig | None = None) -> SandboxEngine:
716
+ """Create a sandbox engine based on configuration.
717
+
718
+ Args:
719
+ config: Sandbox configuration.
720
+
721
+ Returns:
722
+ Appropriate SandboxEngine instance.
723
+ """
724
+ config = config or SandboxConfig()
725
+
726
+ if not config.enabled:
727
+ return NoOpSandbox(config)
728
+
729
+ if config.isolation_level == "container":
730
+ sandbox = ContainerSandbox(config)
731
+ if sandbox.is_available():
732
+ return sandbox
733
+ logger.warning("Container sandbox not available, falling back to process")
734
+ config.isolation_level = "process"
735
+
736
+ if config.isolation_level == "process":
737
+ return ProcessSandbox(config)
738
+
739
+ return NoOpSandbox(config)
740
+
741
+
742
+ def get_available_engines() -> list[dict[str, Any]]:
743
+ """Get list of available sandbox engines.
744
+
745
+ Returns:
746
+ List of engine information dictionaries.
747
+ """
748
+ engines = [
749
+ {
750
+ "name": "none",
751
+ "description": "No isolation (trusted plugins only)",
752
+ "available": True,
753
+ },
754
+ {
755
+ "name": "process",
756
+ "description": "Subprocess isolation with resource limits",
757
+ "available": True,
758
+ },
759
+ ]
760
+
761
+ # Check container availability
762
+ container_sandbox = ContainerSandbox()
763
+ engines.append({
764
+ "name": "container",
765
+ "description": "Docker/Podman container isolation",
766
+ "available": container_sandbox.is_available(),
767
+ "runtime": container_sandbox._container_runtime,
768
+ })
769
+
770
+ return engines