prompture 0.0.50.dev1__py3-none-any.whl → 0.0.51.dev1__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.
- prompture/__init__.py +62 -0
- prompture/_version.py +2 -2
- prompture/agent.py +61 -2
- prompture/agent_types.py +98 -0
- prompture/analysis/__init__.py +19 -0
- prompture/analysis/analyzer.py +142 -0
- prompture/analysis/ast_visitors.py +302 -0
- prompture/analysis/risk_scoring.py +219 -0
- prompture/history.py +299 -0
- prompture/sandbox/__init__.py +31 -0
- prompture/sandbox/exceptions.py +54 -0
- prompture/sandbox/resource_limits.py +128 -0
- prompture/sandbox/restrictions.py +292 -0
- prompture/sandbox/sandbox.py +406 -0
- {prompture-0.0.50.dev1.dist-info → prompture-0.0.51.dev1.dist-info}/METADATA +1 -1
- {prompture-0.0.50.dev1.dist-info → prompture-0.0.51.dev1.dist-info}/RECORD +20 -10
- {prompture-0.0.50.dev1.dist-info → prompture-0.0.51.dev1.dist-info}/WHEEL +0 -0
- {prompture-0.0.50.dev1.dist-info → prompture-0.0.51.dev1.dist-info}/entry_points.txt +0 -0
- {prompture-0.0.50.dev1.dist-info → prompture-0.0.51.dev1.dist-info}/licenses/LICENSE +0 -0
- {prompture-0.0.50.dev1.dist-info → prompture-0.0.51.dev1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Import and path restriction logic for the sandbox.
|
|
2
|
+
|
|
3
|
+
Defines configurable restrictions for imports and filesystem access.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
# Always blocked imports - these are never allowed regardless of configuration
|
|
12
|
+
# These modules can be used to escape the sandbox or cause serious harm
|
|
13
|
+
ALWAYS_BLOCKED_IMPORTS = frozenset(
|
|
14
|
+
{
|
|
15
|
+
# Low-level system access
|
|
16
|
+
"ctypes",
|
|
17
|
+
"ctypes.util",
|
|
18
|
+
"_ctypes",
|
|
19
|
+
# Process/threading that could escape sandbox
|
|
20
|
+
"multiprocessing",
|
|
21
|
+
"_multiprocessing",
|
|
22
|
+
"threading",
|
|
23
|
+
"_thread",
|
|
24
|
+
"concurrent",
|
|
25
|
+
"concurrent.futures",
|
|
26
|
+
# Memory/garbage collection manipulation
|
|
27
|
+
"gc",
|
|
28
|
+
# System internals
|
|
29
|
+
"sys",
|
|
30
|
+
"_sys",
|
|
31
|
+
"builtins",
|
|
32
|
+
"_builtins",
|
|
33
|
+
# Import system manipulation
|
|
34
|
+
"importlib",
|
|
35
|
+
"importlib.util",
|
|
36
|
+
"importlib.abc",
|
|
37
|
+
"importlib.machinery",
|
|
38
|
+
"importlib.resources",
|
|
39
|
+
"pkgutil",
|
|
40
|
+
"runpy",
|
|
41
|
+
# Code introspection/manipulation
|
|
42
|
+
"code",
|
|
43
|
+
"codeop",
|
|
44
|
+
"ast", # Could be used to construct code
|
|
45
|
+
"dis",
|
|
46
|
+
"inspect",
|
|
47
|
+
"traceback",
|
|
48
|
+
"linecache",
|
|
49
|
+
"tokenize",
|
|
50
|
+
"token",
|
|
51
|
+
"symbol",
|
|
52
|
+
"parser",
|
|
53
|
+
# Debugging
|
|
54
|
+
"pdb",
|
|
55
|
+
"bdb",
|
|
56
|
+
"trace",
|
|
57
|
+
"faulthandler",
|
|
58
|
+
# Serialization that can execute code
|
|
59
|
+
"pickle",
|
|
60
|
+
"cPickle",
|
|
61
|
+
"_pickle",
|
|
62
|
+
"shelve",
|
|
63
|
+
"marshal",
|
|
64
|
+
"pickletools",
|
|
65
|
+
# OS/subprocess access
|
|
66
|
+
"os",
|
|
67
|
+
"posix",
|
|
68
|
+
"nt",
|
|
69
|
+
"posixpath",
|
|
70
|
+
"ntpath",
|
|
71
|
+
"_posixsubprocess",
|
|
72
|
+
"subprocess",
|
|
73
|
+
"shutil",
|
|
74
|
+
"pathlib", # Can access filesystem
|
|
75
|
+
"glob",
|
|
76
|
+
"fnmatch",
|
|
77
|
+
# Signals and process control
|
|
78
|
+
"signal",
|
|
79
|
+
"pty",
|
|
80
|
+
"tty",
|
|
81
|
+
"termios",
|
|
82
|
+
# Resource control
|
|
83
|
+
"resource",
|
|
84
|
+
# Windows-specific
|
|
85
|
+
"msilib",
|
|
86
|
+
"winreg",
|
|
87
|
+
"_winapi",
|
|
88
|
+
"msvcrt",
|
|
89
|
+
# Platform info leakage
|
|
90
|
+
"platform",
|
|
91
|
+
"sysconfig",
|
|
92
|
+
# Sockets/networking
|
|
93
|
+
"socket",
|
|
94
|
+
"_socket",
|
|
95
|
+
"ssl",
|
|
96
|
+
"_ssl",
|
|
97
|
+
"select",
|
|
98
|
+
"selectors",
|
|
99
|
+
"asyncio",
|
|
100
|
+
# File-related
|
|
101
|
+
"io",
|
|
102
|
+
"_io",
|
|
103
|
+
"tempfile",
|
|
104
|
+
# Weak references (can leak objects)
|
|
105
|
+
"weakref",
|
|
106
|
+
# Exit/termination
|
|
107
|
+
"atexit",
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class ImportRestrictions:
|
|
114
|
+
"""Configuration for import restrictions in the sandbox.
|
|
115
|
+
|
|
116
|
+
Attributes:
|
|
117
|
+
allowed: Set of explicitly allowed module names. If non-empty,
|
|
118
|
+
only these modules can be imported (whitelist mode).
|
|
119
|
+
blocked: Set of explicitly blocked module names. Used in
|
|
120
|
+
blacklist mode when allowed is empty.
|
|
121
|
+
block_all: If True, block all imports except builtins.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
allowed: set[str] = field(default_factory=set)
|
|
125
|
+
blocked: set[str] = field(default_factory=set)
|
|
126
|
+
block_all: bool = False
|
|
127
|
+
|
|
128
|
+
def is_allowed(self, module_name: str) -> tuple[bool, str | None]:
|
|
129
|
+
"""Check if a module import is allowed.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
module_name: The module name to check.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Tuple of (is_allowed, reason_if_blocked).
|
|
136
|
+
"""
|
|
137
|
+
# Get the top-level module
|
|
138
|
+
top_level = module_name.split(".")[0]
|
|
139
|
+
|
|
140
|
+
# Always blocked takes precedence
|
|
141
|
+
if module_name in ALWAYS_BLOCKED_IMPORTS or top_level in ALWAYS_BLOCKED_IMPORTS:
|
|
142
|
+
return False, "Module is on the always-blocked list for security"
|
|
143
|
+
|
|
144
|
+
# Check block_all mode
|
|
145
|
+
if self.block_all:
|
|
146
|
+
return False, "All imports are blocked in this sandbox"
|
|
147
|
+
|
|
148
|
+
# Whitelist mode (allowed is non-empty)
|
|
149
|
+
if self.allowed:
|
|
150
|
+
if top_level in self.allowed or module_name in self.allowed:
|
|
151
|
+
return True, None
|
|
152
|
+
return False, "Module is not on the allowed list"
|
|
153
|
+
|
|
154
|
+
# Blacklist mode
|
|
155
|
+
if top_level in self.blocked or module_name in self.blocked:
|
|
156
|
+
return False, "Module is explicitly blocked"
|
|
157
|
+
|
|
158
|
+
return True, None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# Safe imports that are typically allowed for data processing
|
|
162
|
+
SAFE_IMPORTS = frozenset(
|
|
163
|
+
{
|
|
164
|
+
"json",
|
|
165
|
+
"re",
|
|
166
|
+
"math",
|
|
167
|
+
"statistics",
|
|
168
|
+
"decimal",
|
|
169
|
+
"fractions",
|
|
170
|
+
"random",
|
|
171
|
+
"collections",
|
|
172
|
+
"itertools",
|
|
173
|
+
"functools",
|
|
174
|
+
"operator",
|
|
175
|
+
"string",
|
|
176
|
+
"textwrap",
|
|
177
|
+
"unicodedata",
|
|
178
|
+
"difflib",
|
|
179
|
+
"typing",
|
|
180
|
+
"dataclasses",
|
|
181
|
+
"enum",
|
|
182
|
+
"numbers",
|
|
183
|
+
"datetime",
|
|
184
|
+
"calendar",
|
|
185
|
+
"time",
|
|
186
|
+
"copy",
|
|
187
|
+
"pprint",
|
|
188
|
+
"reprlib",
|
|
189
|
+
"types",
|
|
190
|
+
"abc",
|
|
191
|
+
"contextlib",
|
|
192
|
+
"heapq",
|
|
193
|
+
"bisect",
|
|
194
|
+
"array",
|
|
195
|
+
"hashlib",
|
|
196
|
+
"hmac",
|
|
197
|
+
"secrets",
|
|
198
|
+
"base64",
|
|
199
|
+
"binascii",
|
|
200
|
+
"struct",
|
|
201
|
+
"codecs",
|
|
202
|
+
"html",
|
|
203
|
+
"html.parser",
|
|
204
|
+
"html.entities",
|
|
205
|
+
"urllib.parse",
|
|
206
|
+
"zlib",
|
|
207
|
+
"gzip",
|
|
208
|
+
"bz2",
|
|
209
|
+
"lzma",
|
|
210
|
+
"csv",
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def get_safe_imports() -> set[str]:
|
|
216
|
+
"""Return a copy of the safe imports set.
|
|
217
|
+
|
|
218
|
+
These are modules that are generally safe to allow in a sandbox
|
|
219
|
+
for data processing tasks.
|
|
220
|
+
"""
|
|
221
|
+
return set(SAFE_IMPORTS)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass
|
|
225
|
+
class PathRestrictions:
|
|
226
|
+
"""Configuration for filesystem path restrictions in the sandbox.
|
|
227
|
+
|
|
228
|
+
Attributes:
|
|
229
|
+
allowed_read: Set of directory paths allowed for reading.
|
|
230
|
+
allowed_write: Set of directory paths allowed for writing.
|
|
231
|
+
allow_cwd: Whether to allow read/write in the current working directory.
|
|
232
|
+
working_directory: Optional working directory override.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
allowed_read: set[Path] = field(default_factory=set)
|
|
236
|
+
allowed_write: set[Path] = field(default_factory=set)
|
|
237
|
+
allow_cwd: bool = False
|
|
238
|
+
working_directory: Path | None = None
|
|
239
|
+
|
|
240
|
+
def _resolve_path(self, path: str | Path) -> Path:
|
|
241
|
+
"""Resolve a path relative to the working directory."""
|
|
242
|
+
p = Path(path)
|
|
243
|
+
if not p.is_absolute():
|
|
244
|
+
base = self.working_directory or Path.cwd()
|
|
245
|
+
p = base / p
|
|
246
|
+
return p.resolve()
|
|
247
|
+
|
|
248
|
+
def can_read(self, path: str | Path) -> bool:
|
|
249
|
+
"""Check if a path can be read."""
|
|
250
|
+
resolved = self._resolve_path(path)
|
|
251
|
+
|
|
252
|
+
# Check if it's under any allowed read directory
|
|
253
|
+
for allowed in self.allowed_read:
|
|
254
|
+
try:
|
|
255
|
+
resolved.relative_to(allowed.resolve())
|
|
256
|
+
return True
|
|
257
|
+
except ValueError:
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
# Check CWD allowance
|
|
261
|
+
if self.allow_cwd:
|
|
262
|
+
cwd = self.working_directory or Path.cwd()
|
|
263
|
+
try:
|
|
264
|
+
resolved.relative_to(cwd.resolve())
|
|
265
|
+
return True
|
|
266
|
+
except ValueError:
|
|
267
|
+
pass
|
|
268
|
+
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
def can_write(self, path: str | Path) -> bool:
|
|
272
|
+
"""Check if a path can be written."""
|
|
273
|
+
resolved = self._resolve_path(path)
|
|
274
|
+
|
|
275
|
+
# Check if it's under any allowed write directory
|
|
276
|
+
for allowed in self.allowed_write:
|
|
277
|
+
try:
|
|
278
|
+
resolved.relative_to(allowed.resolve())
|
|
279
|
+
return True
|
|
280
|
+
except ValueError:
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
# Check CWD allowance
|
|
284
|
+
if self.allow_cwd:
|
|
285
|
+
cwd = self.working_directory or Path.cwd()
|
|
286
|
+
try:
|
|
287
|
+
resolved.relative_to(cwd.resolve())
|
|
288
|
+
return True
|
|
289
|
+
except ValueError:
|
|
290
|
+
pass
|
|
291
|
+
|
|
292
|
+
return False
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""Main PythonSandbox class for safe code execution.
|
|
2
|
+
|
|
3
|
+
Provides a configurable sandbox environment for executing Python code
|
|
4
|
+
with import restrictions, path restrictions, and resource limits.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import builtins
|
|
10
|
+
import io
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from prompture.analysis import analyze_python
|
|
17
|
+
|
|
18
|
+
from .exceptions import ImportViolationError, PathViolationError, SandboxError, SandboxTimeoutError
|
|
19
|
+
from .resource_limits import ResourceContext, ResourceLimits
|
|
20
|
+
from .restrictions import ImportRestrictions, PathRestrictions, get_safe_imports
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class SandboxResult:
|
|
25
|
+
"""Result of sandbox code execution.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
success: Whether execution completed without errors.
|
|
29
|
+
output: Captured stdout from execution.
|
|
30
|
+
error: Error message if execution failed.
|
|
31
|
+
exception: The exception object if one was raised.
|
|
32
|
+
return_value: The return value of the executed code (if any).
|
|
33
|
+
locals: Dictionary of local variables after execution.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
success: bool
|
|
37
|
+
output: str = ""
|
|
38
|
+
error: str | None = None
|
|
39
|
+
exception: BaseException | None = None
|
|
40
|
+
return_value: Any = None
|
|
41
|
+
locals: dict[str, Any] = field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RestrictedImporter:
|
|
45
|
+
"""Custom import handler that enforces import restrictions."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, restrictions: ImportRestrictions, original_import: Any) -> None:
|
|
48
|
+
self.restrictions = restrictions
|
|
49
|
+
self.original_import = original_import
|
|
50
|
+
|
|
51
|
+
def __call__(
|
|
52
|
+
self,
|
|
53
|
+
name: str,
|
|
54
|
+
globals: dict | None = None,
|
|
55
|
+
locals: dict | None = None,
|
|
56
|
+
fromlist: tuple = (),
|
|
57
|
+
level: int = 0,
|
|
58
|
+
) -> Any:
|
|
59
|
+
"""Check import restrictions before allowing the import."""
|
|
60
|
+
allowed, reason = self.restrictions.is_allowed(name)
|
|
61
|
+
if not allowed:
|
|
62
|
+
raise ImportViolationError(name, reason)
|
|
63
|
+
|
|
64
|
+
# Also check fromlist items
|
|
65
|
+
if fromlist:
|
|
66
|
+
for item in fromlist:
|
|
67
|
+
full_name = f"{name}.{item}"
|
|
68
|
+
allowed, reason = self.restrictions.is_allowed(full_name)
|
|
69
|
+
if not allowed:
|
|
70
|
+
raise ImportViolationError(full_name, reason)
|
|
71
|
+
|
|
72
|
+
return self.original_import(name, globals, locals, fromlist, level)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class RestrictedOpen:
|
|
76
|
+
"""Custom open handler that enforces path restrictions."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, path_restrictions: PathRestrictions) -> None:
|
|
79
|
+
self.path_restrictions = path_restrictions
|
|
80
|
+
|
|
81
|
+
def __call__(
|
|
82
|
+
self,
|
|
83
|
+
file: str | Path,
|
|
84
|
+
mode: str = "r",
|
|
85
|
+
*args: Any,
|
|
86
|
+
**kwargs: Any,
|
|
87
|
+
) -> io.IOBase:
|
|
88
|
+
"""Check path restrictions before opening a file."""
|
|
89
|
+
is_write = any(c in mode for c in "wax+")
|
|
90
|
+
|
|
91
|
+
if is_write:
|
|
92
|
+
if not self.path_restrictions.can_write(file):
|
|
93
|
+
raise PathViolationError(str(file), "write")
|
|
94
|
+
else:
|
|
95
|
+
if not self.path_restrictions.can_read(file):
|
|
96
|
+
raise PathViolationError(str(file), "read")
|
|
97
|
+
|
|
98
|
+
return builtins.open(file, mode, *args, **kwargs)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class PythonSandbox:
|
|
102
|
+
"""Sandbox for safe Python code execution.
|
|
103
|
+
|
|
104
|
+
Provides a restricted execution environment with:
|
|
105
|
+
- Import whitelist/blacklist with always-blocked security list
|
|
106
|
+
- Filesystem path restrictions
|
|
107
|
+
- Timeout handling
|
|
108
|
+
- Optional memory limits (Unix only)
|
|
109
|
+
- Captured stdout/stderr
|
|
110
|
+
|
|
111
|
+
Example::
|
|
112
|
+
|
|
113
|
+
from prompture.sandbox import PythonSandbox
|
|
114
|
+
|
|
115
|
+
sandbox = PythonSandbox(
|
|
116
|
+
allowed_imports=["json", "math"],
|
|
117
|
+
timeout_seconds=5,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
result = sandbox.execute('''
|
|
121
|
+
import json
|
|
122
|
+
data = {"x": 1, "y": 2}
|
|
123
|
+
print(json.dumps(data))
|
|
124
|
+
''')
|
|
125
|
+
|
|
126
|
+
if result.success:
|
|
127
|
+
print(result.output) # '{"x": 1, "y": 2}'
|
|
128
|
+
else:
|
|
129
|
+
print(f"Error: {result.error}")
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
allowed_imports: List of module names to allow (whitelist mode).
|
|
133
|
+
If empty, uses blocked_imports as a blacklist.
|
|
134
|
+
blocked_imports: List of module names to block (blacklist mode).
|
|
135
|
+
Only used if allowed_imports is empty.
|
|
136
|
+
timeout_seconds: Maximum execution time in seconds.
|
|
137
|
+
max_memory_bytes: Maximum memory usage (Unix only).
|
|
138
|
+
allowed_read_paths: List of directory paths allowed for reading.
|
|
139
|
+
allowed_write_paths: List of directory paths allowed for writing.
|
|
140
|
+
allow_cwd: Whether to allow file access in the current working directory.
|
|
141
|
+
working_directory: Optional working directory for the sandbox.
|
|
142
|
+
use_safe_imports: If True and allowed_imports is empty, use the
|
|
143
|
+
predefined SAFE_IMPORTS set as the allowed list.
|
|
144
|
+
validate_before_exec: If True, run code analysis before execution.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
allowed_imports: list[str] | None = None,
|
|
150
|
+
blocked_imports: list[str] | None = None,
|
|
151
|
+
timeout_seconds: float = 30.0,
|
|
152
|
+
max_memory_bytes: int | None = None,
|
|
153
|
+
allowed_read_paths: list[str | Path] | None = None,
|
|
154
|
+
allowed_write_paths: list[str | Path] | None = None,
|
|
155
|
+
allow_cwd: bool = False,
|
|
156
|
+
working_directory: str | Path | None = None,
|
|
157
|
+
use_safe_imports: bool = True,
|
|
158
|
+
validate_before_exec: bool = True,
|
|
159
|
+
) -> None:
|
|
160
|
+
# Set up import restrictions
|
|
161
|
+
if allowed_imports is not None:
|
|
162
|
+
allowed_set = set(allowed_imports)
|
|
163
|
+
elif use_safe_imports:
|
|
164
|
+
allowed_set = get_safe_imports()
|
|
165
|
+
else:
|
|
166
|
+
allowed_set = set()
|
|
167
|
+
|
|
168
|
+
blocked_set = set(blocked_imports) if blocked_imports else set()
|
|
169
|
+
|
|
170
|
+
self.import_restrictions = ImportRestrictions(
|
|
171
|
+
allowed=allowed_set,
|
|
172
|
+
blocked=blocked_set,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Set up path restrictions
|
|
176
|
+
read_paths = {Path(p) for p in (allowed_read_paths or [])}
|
|
177
|
+
write_paths = {Path(p) for p in (allowed_write_paths or [])}
|
|
178
|
+
work_dir = Path(working_directory) if working_directory else None
|
|
179
|
+
|
|
180
|
+
self.path_restrictions = PathRestrictions(
|
|
181
|
+
allowed_read=read_paths,
|
|
182
|
+
allowed_write=write_paths,
|
|
183
|
+
allow_cwd=allow_cwd,
|
|
184
|
+
working_directory=work_dir,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Set up resource limits
|
|
188
|
+
self.resource_limits = ResourceLimits(
|
|
189
|
+
timeout_seconds=timeout_seconds,
|
|
190
|
+
max_memory_bytes=max_memory_bytes,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
self.validate_before_exec = validate_before_exec
|
|
194
|
+
|
|
195
|
+
def execute(self, code: str, globals_dict: dict[str, Any] | None = None) -> SandboxResult:
|
|
196
|
+
"""Execute Python code in the sandbox.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
code: Python source code to execute.
|
|
200
|
+
globals_dict: Optional dictionary of global variables to make
|
|
201
|
+
available during execution. Defaults to a minimal safe set.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
SandboxResult with execution outcome.
|
|
205
|
+
"""
|
|
206
|
+
# Validate code first if enabled
|
|
207
|
+
if self.validate_before_exec:
|
|
208
|
+
analysis = analyze_python(code)
|
|
209
|
+
if not analysis.syntax_valid:
|
|
210
|
+
return SandboxResult(
|
|
211
|
+
success=False,
|
|
212
|
+
error=f"Syntax error: {analysis.syntax_error}",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Set up restricted builtins
|
|
216
|
+
safe_builtins = self._get_safe_builtins()
|
|
217
|
+
|
|
218
|
+
# Set up globals
|
|
219
|
+
exec_globals: dict[str, Any] = {"__builtins__": safe_builtins}
|
|
220
|
+
if globals_dict:
|
|
221
|
+
exec_globals.update(globals_dict)
|
|
222
|
+
|
|
223
|
+
exec_locals: dict[str, Any] = {}
|
|
224
|
+
|
|
225
|
+
# Capture stdout
|
|
226
|
+
old_stdout = sys.stdout
|
|
227
|
+
old_stderr = sys.stderr
|
|
228
|
+
captured_stdout = io.StringIO()
|
|
229
|
+
captured_stderr = io.StringIO()
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
sys.stdout = captured_stdout
|
|
233
|
+
sys.stderr = captured_stderr
|
|
234
|
+
|
|
235
|
+
# Execute with resource limits
|
|
236
|
+
with ResourceContext(self.resource_limits):
|
|
237
|
+
exec(compile(code, "<sandbox>", "exec"), exec_globals, exec_locals)
|
|
238
|
+
|
|
239
|
+
# Success
|
|
240
|
+
output = captured_stdout.getvalue()
|
|
241
|
+
if len(output) > self.resource_limits.max_output_bytes:
|
|
242
|
+
output = output[: self.resource_limits.max_output_bytes] + "\n... [output truncated]"
|
|
243
|
+
|
|
244
|
+
return SandboxResult(
|
|
245
|
+
success=True,
|
|
246
|
+
output=output,
|
|
247
|
+
locals={k: v for k, v in exec_locals.items() if not k.startswith("_")},
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
except ImportViolationError as e:
|
|
251
|
+
return SandboxResult(
|
|
252
|
+
success=False,
|
|
253
|
+
output=captured_stdout.getvalue(),
|
|
254
|
+
error=str(e),
|
|
255
|
+
exception=e,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
except PathViolationError as e:
|
|
259
|
+
return SandboxResult(
|
|
260
|
+
success=False,
|
|
261
|
+
output=captured_stdout.getvalue(),
|
|
262
|
+
error=str(e),
|
|
263
|
+
exception=e,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
except SandboxTimeoutError as e:
|
|
267
|
+
return SandboxResult(
|
|
268
|
+
success=False,
|
|
269
|
+
output=captured_stdout.getvalue(),
|
|
270
|
+
error=str(e),
|
|
271
|
+
exception=e,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
except SandboxError as e:
|
|
275
|
+
return SandboxResult(
|
|
276
|
+
success=False,
|
|
277
|
+
output=captured_stdout.getvalue(),
|
|
278
|
+
error=str(e),
|
|
279
|
+
exception=e,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
except Exception as e:
|
|
283
|
+
return SandboxResult(
|
|
284
|
+
success=False,
|
|
285
|
+
output=captured_stdout.getvalue(),
|
|
286
|
+
error=f"{type(e).__name__}: {e}",
|
|
287
|
+
exception=e,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
finally:
|
|
291
|
+
sys.stdout = old_stdout
|
|
292
|
+
sys.stderr = old_stderr
|
|
293
|
+
|
|
294
|
+
def _get_safe_builtins(self) -> dict[str, Any]:
|
|
295
|
+
"""Return a dictionary of safe builtins for the sandbox."""
|
|
296
|
+
# Start with a minimal set of safe builtins
|
|
297
|
+
safe = {
|
|
298
|
+
# Types
|
|
299
|
+
"bool": bool,
|
|
300
|
+
"int": int,
|
|
301
|
+
"float": float,
|
|
302
|
+
"str": str,
|
|
303
|
+
"bytes": bytes,
|
|
304
|
+
"bytearray": bytearray,
|
|
305
|
+
"list": list,
|
|
306
|
+
"tuple": tuple,
|
|
307
|
+
"dict": dict,
|
|
308
|
+
"set": set,
|
|
309
|
+
"frozenset": frozenset,
|
|
310
|
+
"type": type,
|
|
311
|
+
"object": object,
|
|
312
|
+
# Functions
|
|
313
|
+
"abs": abs,
|
|
314
|
+
"all": all,
|
|
315
|
+
"any": any,
|
|
316
|
+
"bin": bin,
|
|
317
|
+
"chr": chr,
|
|
318
|
+
"divmod": divmod,
|
|
319
|
+
"enumerate": enumerate,
|
|
320
|
+
"filter": filter,
|
|
321
|
+
"format": format,
|
|
322
|
+
"hash": hash,
|
|
323
|
+
"hex": hex,
|
|
324
|
+
"id": id,
|
|
325
|
+
"isinstance": isinstance,
|
|
326
|
+
"issubclass": issubclass,
|
|
327
|
+
"iter": iter,
|
|
328
|
+
"len": len,
|
|
329
|
+
"map": map,
|
|
330
|
+
"max": max,
|
|
331
|
+
"min": min,
|
|
332
|
+
"next": next,
|
|
333
|
+
"oct": oct,
|
|
334
|
+
"ord": ord,
|
|
335
|
+
"pow": pow,
|
|
336
|
+
"print": print,
|
|
337
|
+
"range": range,
|
|
338
|
+
"repr": repr,
|
|
339
|
+
"reversed": reversed,
|
|
340
|
+
"round": round,
|
|
341
|
+
"slice": slice,
|
|
342
|
+
"sorted": sorted,
|
|
343
|
+
"sum": sum,
|
|
344
|
+
"zip": zip,
|
|
345
|
+
# Constants
|
|
346
|
+
"True": True,
|
|
347
|
+
"False": False,
|
|
348
|
+
"None": None,
|
|
349
|
+
# Exceptions (subset)
|
|
350
|
+
"Exception": Exception,
|
|
351
|
+
"BaseException": BaseException,
|
|
352
|
+
"ValueError": ValueError,
|
|
353
|
+
"TypeError": TypeError,
|
|
354
|
+
"KeyError": KeyError,
|
|
355
|
+
"IndexError": IndexError,
|
|
356
|
+
"AttributeError": AttributeError,
|
|
357
|
+
"RuntimeError": RuntimeError,
|
|
358
|
+
"StopIteration": StopIteration,
|
|
359
|
+
"ZeroDivisionError": ZeroDivisionError,
|
|
360
|
+
"AssertionError": AssertionError,
|
|
361
|
+
"NotImplementedError": NotImplementedError,
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
# Add restricted import
|
|
365
|
+
safe["__import__"] = RestrictedImporter(self.import_restrictions, builtins.__import__)
|
|
366
|
+
|
|
367
|
+
# Add restricted open if path restrictions are configured
|
|
368
|
+
if (
|
|
369
|
+
self.path_restrictions.allowed_read
|
|
370
|
+
or self.path_restrictions.allowed_write
|
|
371
|
+
or self.path_restrictions.allow_cwd
|
|
372
|
+
):
|
|
373
|
+
safe["open"] = RestrictedOpen(self.path_restrictions)
|
|
374
|
+
|
|
375
|
+
return safe
|
|
376
|
+
|
|
377
|
+
def read_file(self, path: str | Path) -> str:
|
|
378
|
+
"""Convenience method to read a file through the sandbox.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
path: Path to the file to read.
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
File contents as a string.
|
|
385
|
+
|
|
386
|
+
Raises:
|
|
387
|
+
PathViolationError: If the path is not allowed for reading.
|
|
388
|
+
FileNotFoundError: If the file does not exist.
|
|
389
|
+
"""
|
|
390
|
+
if not self.path_restrictions.can_read(path):
|
|
391
|
+
raise PathViolationError(str(path), "read")
|
|
392
|
+
return Path(path).read_text()
|
|
393
|
+
|
|
394
|
+
def write_file(self, path: str | Path, content: str) -> None:
|
|
395
|
+
"""Convenience method to write a file through the sandbox.
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
path: Path to the file to write.
|
|
399
|
+
content: Content to write to the file.
|
|
400
|
+
|
|
401
|
+
Raises:
|
|
402
|
+
PathViolationError: If the path is not allowed for writing.
|
|
403
|
+
"""
|
|
404
|
+
if not self.path_restrictions.can_write(path):
|
|
405
|
+
raise PathViolationError(str(path), "write")
|
|
406
|
+
Path(path).write_text(content)
|