truthound-dashboard 1.3.1__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.
- truthound_dashboard/api/alerts.py +258 -0
- truthound_dashboard/api/anomaly.py +1302 -0
- truthound_dashboard/api/cross_alerts.py +352 -0
- truthound_dashboard/api/deps.py +143 -0
- truthound_dashboard/api/drift_monitor.py +540 -0
- truthound_dashboard/api/lineage.py +1151 -0
- truthound_dashboard/api/maintenance.py +363 -0
- truthound_dashboard/api/middleware.py +373 -1
- truthound_dashboard/api/model_monitoring.py +805 -0
- truthound_dashboard/api/notifications_advanced.py +2452 -0
- truthound_dashboard/api/plugins.py +2096 -0
- truthound_dashboard/api/profile.py +211 -14
- truthound_dashboard/api/reports.py +853 -0
- truthound_dashboard/api/router.py +147 -0
- truthound_dashboard/api/rule_suggestions.py +310 -0
- truthound_dashboard/api/schema_evolution.py +231 -0
- truthound_dashboard/api/sources.py +47 -3
- truthound_dashboard/api/triggers.py +190 -0
- truthound_dashboard/api/validations.py +13 -0
- truthound_dashboard/api/validators.py +333 -4
- truthound_dashboard/api/versioning.py +309 -0
- truthound_dashboard/api/websocket.py +301 -0
- truthound_dashboard/core/__init__.py +27 -0
- truthound_dashboard/core/anomaly.py +1395 -0
- truthound_dashboard/core/anomaly_explainer.py +633 -0
- truthound_dashboard/core/cache.py +206 -0
- truthound_dashboard/core/cached_services.py +422 -0
- truthound_dashboard/core/charts.py +352 -0
- truthound_dashboard/core/connections.py +1069 -42
- truthound_dashboard/core/cross_alerts.py +837 -0
- truthound_dashboard/core/drift_monitor.py +1477 -0
- truthound_dashboard/core/drift_sampling.py +669 -0
- truthound_dashboard/core/i18n/__init__.py +42 -0
- truthound_dashboard/core/i18n/detector.py +173 -0
- truthound_dashboard/core/i18n/messages.py +564 -0
- truthound_dashboard/core/lineage.py +971 -0
- truthound_dashboard/core/maintenance.py +443 -5
- truthound_dashboard/core/model_monitoring.py +1043 -0
- truthound_dashboard/core/notifications/channels.py +1020 -1
- truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
- truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
- truthound_dashboard/core/notifications/deduplication/service.py +400 -0
- truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
- truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
- truthound_dashboard/core/notifications/dispatcher.py +43 -0
- truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
- truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
- truthound_dashboard/core/notifications/escalation/engine.py +429 -0
- truthound_dashboard/core/notifications/escalation/models.py +336 -0
- truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
- truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
- truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
- truthound_dashboard/core/notifications/events.py +49 -0
- truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
- truthound_dashboard/core/notifications/metrics/base.py +528 -0
- truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
- truthound_dashboard/core/notifications/routing/__init__.py +169 -0
- truthound_dashboard/core/notifications/routing/combinators.py +184 -0
- truthound_dashboard/core/notifications/routing/config.py +375 -0
- truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
- truthound_dashboard/core/notifications/routing/engine.py +382 -0
- truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
- truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
- truthound_dashboard/core/notifications/routing/rules.py +625 -0
- truthound_dashboard/core/notifications/routing/validator.py +678 -0
- truthound_dashboard/core/notifications/service.py +2 -0
- truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
- truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
- truthound_dashboard/core/notifications/throttling/builder.py +311 -0
- truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
- truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
- truthound_dashboard/core/openlineage.py +1028 -0
- truthound_dashboard/core/plugins/__init__.py +39 -0
- truthound_dashboard/core/plugins/docs/__init__.py +39 -0
- truthound_dashboard/core/plugins/docs/extractor.py +703 -0
- truthound_dashboard/core/plugins/docs/renderers.py +804 -0
- truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
- truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
- truthound_dashboard/core/plugins/hooks/manager.py +403 -0
- truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
- truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
- truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
- truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
- truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
- truthound_dashboard/core/plugins/loader.py +504 -0
- truthound_dashboard/core/plugins/registry.py +810 -0
- truthound_dashboard/core/plugins/reporter_executor.py +588 -0
- truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
- truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
- truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
- truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
- truthound_dashboard/core/plugins/sandbox.py +617 -0
- truthound_dashboard/core/plugins/security/__init__.py +68 -0
- truthound_dashboard/core/plugins/security/analyzer.py +535 -0
- truthound_dashboard/core/plugins/security/policies.py +311 -0
- truthound_dashboard/core/plugins/security/protocols.py +296 -0
- truthound_dashboard/core/plugins/security/signing.py +842 -0
- truthound_dashboard/core/plugins/security.py +446 -0
- truthound_dashboard/core/plugins/validator_executor.py +401 -0
- truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
- truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
- truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
- truthound_dashboard/core/plugins/versioning/semver.py +266 -0
- truthound_dashboard/core/profile_comparison.py +601 -0
- truthound_dashboard/core/report_history.py +570 -0
- truthound_dashboard/core/reporters/__init__.py +57 -0
- truthound_dashboard/core/reporters/base.py +296 -0
- truthound_dashboard/core/reporters/csv_reporter.py +155 -0
- truthound_dashboard/core/reporters/html_reporter.py +598 -0
- truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
- truthound_dashboard/core/reporters/i18n/base.py +494 -0
- truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
- truthound_dashboard/core/reporters/json_reporter.py +160 -0
- truthound_dashboard/core/reporters/junit_reporter.py +233 -0
- truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
- truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
- truthound_dashboard/core/reporters/registry.py +272 -0
- truthound_dashboard/core/rule_generator.py +2088 -0
- truthound_dashboard/core/scheduler.py +822 -12
- truthound_dashboard/core/schema_evolution.py +858 -0
- truthound_dashboard/core/services.py +152 -9
- truthound_dashboard/core/statistics.py +718 -0
- truthound_dashboard/core/streaming_anomaly.py +883 -0
- truthound_dashboard/core/triggers/__init__.py +45 -0
- truthound_dashboard/core/triggers/base.py +226 -0
- truthound_dashboard/core/triggers/evaluators.py +609 -0
- truthound_dashboard/core/triggers/factory.py +363 -0
- truthound_dashboard/core/unified_alerts.py +870 -0
- truthound_dashboard/core/validation_limits.py +509 -0
- truthound_dashboard/core/versioning.py +709 -0
- truthound_dashboard/core/websocket/__init__.py +59 -0
- truthound_dashboard/core/websocket/manager.py +512 -0
- truthound_dashboard/core/websocket/messages.py +130 -0
- truthound_dashboard/db/__init__.py +30 -0
- truthound_dashboard/db/models.py +3375 -3
- truthound_dashboard/main.py +22 -0
- truthound_dashboard/schemas/__init__.py +396 -1
- truthound_dashboard/schemas/anomaly.py +1258 -0
- truthound_dashboard/schemas/base.py +4 -0
- truthound_dashboard/schemas/cross_alerts.py +334 -0
- truthound_dashboard/schemas/drift_monitor.py +890 -0
- truthound_dashboard/schemas/lineage.py +428 -0
- truthound_dashboard/schemas/maintenance.py +154 -0
- truthound_dashboard/schemas/model_monitoring.py +374 -0
- truthound_dashboard/schemas/notifications_advanced.py +1363 -0
- truthound_dashboard/schemas/openlineage.py +704 -0
- truthound_dashboard/schemas/plugins.py +1293 -0
- truthound_dashboard/schemas/profile.py +420 -34
- truthound_dashboard/schemas/profile_comparison.py +242 -0
- truthound_dashboard/schemas/reports.py +285 -0
- truthound_dashboard/schemas/rule_suggestion.py +434 -0
- truthound_dashboard/schemas/schema_evolution.py +164 -0
- truthound_dashboard/schemas/source.py +117 -2
- truthound_dashboard/schemas/triggers.py +511 -0
- truthound_dashboard/schemas/unified_alerts.py +223 -0
- truthound_dashboard/schemas/validation.py +25 -1
- truthound_dashboard/schemas/validators/__init__.py +11 -0
- truthound_dashboard/schemas/validators/base.py +151 -0
- truthound_dashboard/schemas/versioning.py +152 -0
- truthound_dashboard/static/index.html +2 -2
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/METADATA +142 -22
- truthound_dashboard-1.4.0.dist-info/RECORD +239 -0
- truthound_dashboard/static/assets/index-BZG20KuF.js +0 -586
- truthound_dashboard/static/assets/index-D_HyZ3pb.css +0 -1
- truthound_dashboard/static/assets/unmerged_dictionaries-CtpqQBm0.js +0 -1
- truthound_dashboard-1.3.1.dist-info/RECORD +0 -110
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/WHEEL +0 -0
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/entry_points.txt +0 -0
- {truthound_dashboard-1.3.1.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
|