prompture 0.0.50.dev1__py3-none-any.whl → 0.0.51__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: prompture
3
- Version: 0.0.50.dev1
3
+ Version: 0.0.51
4
4
  Summary: Ask LLMs to return structured JSON and run cross-model tests. API-first.
5
5
  Author-email: Juan Denis <juan@vene.co>
6
6
  License-Expression: MIT