codefix-env 0.2.1__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,392 @@
1
+ """
2
+ Sandbox — Safe Python Code Execution
3
+ =====================================
4
+ Executes untrusted code in a restricted environment with:
5
+ - Timeout enforcement via threading
6
+ - Stdout/stderr capture
7
+ - Restricted builtins (no file I/O, no network, no subprocess)
8
+ - Memory-safe: runs in subprocess with resource limits on Linux
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ast
14
+ import builtins as _builtins_module
15
+ import io
16
+ import multiprocessing
17
+ import sys
18
+ import textwrap
19
+ import time
20
+ import traceback
21
+ from dataclasses import dataclass
22
+
23
+
24
+ @dataclass
25
+ class ExecutionResult:
26
+ """Result of a sandboxed code execution."""
27
+
28
+ stdout: str = ""
29
+ stderr: str = ""
30
+ exception: str = ""
31
+ passed: bool = False
32
+ runtime_ms: float = 0.0
33
+ timed_out: bool = False
34
+
35
+
36
+ # Builtins allowed in sandbox (no I/O, no network, no os)
37
+ _SAFE_BUILTIN_NAMES = [
38
+ # Core class/function support — REQUIRED for class and import keywords
39
+ "__build_class__",
40
+ "__name__",
41
+ "__package__",
42
+ "__spec__",
43
+ "__import__", # ✅ ADDED - Required for import statements to work
44
+ # Safe built-in functions
45
+ "abs",
46
+ "all",
47
+ "any",
48
+ "ascii",
49
+ "bin",
50
+ "bool",
51
+ "bytearray",
52
+ "bytes",
53
+ "callable",
54
+ "chr",
55
+ "complex",
56
+ "dict",
57
+ "dir",
58
+ "divmod",
59
+ "enumerate",
60
+ "filter",
61
+ "float",
62
+ "format",
63
+ "frozenset",
64
+ "getattr",
65
+ "globals",
66
+ "hasattr",
67
+ "hash",
68
+ "hex",
69
+ "id",
70
+ "int",
71
+ "isinstance",
72
+ "issubclass",
73
+ "iter",
74
+ "len",
75
+ "list",
76
+ "locals",
77
+ "map",
78
+ "max",
79
+ "min",
80
+ "next",
81
+ "object",
82
+ "oct",
83
+ "ord",
84
+ "pow",
85
+ "print",
86
+ "range",
87
+ "repr",
88
+ "reversed",
89
+ "round",
90
+ "set",
91
+ "setattr",
92
+ "slice",
93
+ "sorted",
94
+ "staticmethod",
95
+ "str",
96
+ "sum",
97
+ "super",
98
+ "tuple",
99
+ "type",
100
+ "vars",
101
+ "zip",
102
+ "True",
103
+ "False",
104
+ "None",
105
+ # All standard exceptions
106
+ "ArithmeticError",
107
+ "AssertionError",
108
+ "AttributeError",
109
+ "BaseException",
110
+ "BlockingIOError",
111
+ "BrokenPipeError",
112
+ "BufferError",
113
+ "BytesWarning",
114
+ "ChildProcessError",
115
+ "ConnectionAbortedError",
116
+ "ConnectionError",
117
+ "ConnectionRefusedError",
118
+ "ConnectionResetError",
119
+ "DeprecationWarning",
120
+ "EOFError",
121
+ "EnvironmentError",
122
+ "Exception",
123
+ "FileExistsError",
124
+ "FileNotFoundError",
125
+ "FloatingPointError",
126
+ "GeneratorExit",
127
+ "IOError",
128
+ "ImportError",
129
+ "ImportWarning",
130
+ "IndentationError",
131
+ "IndexError",
132
+ "InterruptedError",
133
+ "IsADirectoryError",
134
+ "KeyError",
135
+ "KeyboardInterrupt",
136
+ "LookupError",
137
+ "MemoryError",
138
+ "ModuleNotFoundError",
139
+ "NameError",
140
+ "NotADirectoryError",
141
+ "NotImplemented",
142
+ "NotImplementedError",
143
+ "OSError",
144
+ "OverflowError",
145
+ "PendingDeprecationWarning",
146
+ "PermissionError",
147
+ "ProcessLookupError",
148
+ "RecursionError",
149
+ "ReferenceError",
150
+ "ResourceWarning",
151
+ "RuntimeError",
152
+ "RuntimeWarning",
153
+ "StopAsyncIteration",
154
+ "StopIteration",
155
+ "SyntaxError",
156
+ "SyntaxWarning",
157
+ "SystemError",
158
+ "SystemExit",
159
+ "TabError",
160
+ "TimeoutError",
161
+ "TypeError",
162
+ "UnboundLocalError",
163
+ "UnicodeDecodeError",
164
+ "UnicodeEncodeError",
165
+ "UnicodeError",
166
+ "UnicodeTranslateError",
167
+ "UnicodeWarning",
168
+ "UserWarning",
169
+ "ValueError",
170
+ "Warning",
171
+ "ZeroDivisionError",
172
+ ]
173
+
174
+ _SAFE_BUILTINS = {
175
+ name: getattr(_builtins_module, name)
176
+ for name in _SAFE_BUILTIN_NAMES
177
+ if hasattr(_builtins_module, name)
178
+ }
179
+
180
+ # Safe stdlib modules allowed inside sandbox
181
+ _SAFE_MODULES = {
182
+ "math",
183
+ "cmath",
184
+ "decimal",
185
+ "fractions",
186
+ "random",
187
+ "statistics",
188
+ "itertools",
189
+ "functools",
190
+ "operator",
191
+ "collections",
192
+ "heapq",
193
+ "bisect",
194
+ "array",
195
+ "copy",
196
+ "re",
197
+ "string",
198
+ "textwrap",
199
+ "difflib",
200
+ "json",
201
+ "enum",
202
+ "dataclasses",
203
+ "typing",
204
+ "abc",
205
+ "io",
206
+ "time",
207
+ }
208
+
209
+
210
+ def _validate_ast(code: str) -> str | None:
211
+ """
212
+ Static analysis: reject code with dangerous AST nodes.
213
+ Returns error message if dangerous, None if safe.
214
+ """
215
+ try:
216
+ tree = ast.parse(code)
217
+ except SyntaxError as e:
218
+ return f"SyntaxError: {e}"
219
+
220
+ for node in ast.walk(tree):
221
+ # Block import of non-safe modules
222
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
223
+ if isinstance(node, ast.ImportFrom):
224
+ module = node.module or ""
225
+ else:
226
+ module = node.names[0].name if node.names else ""
227
+ root = module.split(".")[0]
228
+ if root not in _SAFE_MODULES:
229
+ return f"SecurityError: import of '{module}' is not allowed in sandbox"
230
+ # Block exec/eval/compile (but NOT __import__ - it's needed for imports)
231
+ if (
232
+ isinstance(node, ast.Call)
233
+ and isinstance(node.func, ast.Name)
234
+ and node.func.id in ("exec", "eval", "compile")
235
+ ):
236
+ return f"SecurityError: '{node.func.id}' is not allowed in sandbox"
237
+ return None
238
+
239
+
240
+ def _apply_resource_limits() -> None:
241
+ """
242
+ Cap CPU time, memory, and process count for the child process.
243
+
244
+ This closes a real gap that existed before: the previous sandbox relied
245
+ ONLY on (1) an AST allow-list and (2) a restricted __builtins__ dict.
246
+ Both operate at the Python level and are known to be bypassable via
247
+ attribute-chain tricks (e.g. reaching __globals__/__subclasses__ off an
248
+ allowed object to get back to unrestricted builtins). Neither stops a
249
+ fork-bomb or a memory-exhaustion payload. `resource.setrlimit` is a
250
+ kernel-enforced boundary that holds even if the Python-level sandbox is
251
+ bypassed entirely — defense in depth, not a replacement for it.
252
+
253
+ Linux/macOS only (no-op on Windows, where `resource` doesn't exist).
254
+ """
255
+ try:
256
+ import resource
257
+
258
+ resource.setrlimit(resource.RLIMIT_CPU, (5, 5)) # 5 CPU-seconds
259
+ resource.setrlimit(resource.RLIMIT_FSIZE, (1024 * 1024, 1024 * 1024)) # 1MB file writes
260
+
261
+ # RLIMIT_AS (virtual address space) is intentionally NOT set by
262
+ # default here. Tried it, broke it, documenting why rather than
263
+ # shipping something unreliable:
264
+ #
265
+ # This process tree imports torch (rewards.py's optional reward
266
+ # MLP) before this function ever runs, so by the time a sandboxed
267
+ # execution starts, the interpreter + torch + numpy already occupy
268
+ # a large chunk of virtual address space -- how much varies with
269
+ # torch version, allocator behavior, and platform. A cap sized for
270
+ # a "clean" process (256MB, then 1GB) left inconsistent headroom
271
+ # and intermittently broke thread creation for the
272
+ # multiprocessing.Queue feeder thread, surfacing as a confusing
273
+ # "can't start new thread" instead of a clear memory error --
274
+ # confirmed by reproducing it directly under load.
275
+ #
276
+ # If you need a hard memory ceiling for genuinely untrusted code,
277
+ # the reliable way to get it in THIS process layout is a cgroup
278
+ # memory limit on the whole worker (set at the container/process-
279
+ # group level, sized with headroom for torch), not RLIMIT_AS
280
+ # inside a process that already imported torch. Alternatively, run
281
+ # code execution in a separate lightweight process that does NOT
282
+ # import torch (decouple the reward-model process from the
283
+ # execution sandbox), which would make a tight RLIMIT_AS safe again.
284
+ #
285
+ # NOTE: RLIMIT_NPROC is also deliberately not set. It caps
286
+ # processes for the real UID *system-wide*, not per-process-tree,
287
+ # so it starves unrelated concurrent executions under load. Use a
288
+ # cgroup pids.max limit at the container level instead.
289
+ except (ImportError, ValueError, OSError):
290
+ # `resource` unavailable (Windows) or limits already tighter than requested.
291
+ pass
292
+
293
+
294
+ def _run_in_process(code: str, test_code: str, result_queue: multiprocessing.Queue) -> None:
295
+ """
296
+ Worker function executed in a child process.
297
+ Captures stdout/stderr and returns ExecutionResult via queue.
298
+ """
299
+ _apply_resource_limits()
300
+ stdout_capture = io.StringIO()
301
+ stderr_capture = io.StringIO()
302
+ old_stdout, old_stderr = sys.stdout, sys.stderr
303
+ sys.stdout = stdout_capture
304
+ sys.stderr = stderr_capture
305
+
306
+ # FIXED: Use _SAFE_BUILTINS which now includes __import__
307
+ ns: dict = {"__builtins__": _SAFE_BUILTINS}
308
+ passed = False
309
+ exception_str = ""
310
+ start = time.perf_counter()
311
+
312
+ try:
313
+ exec(compile(code, "<codefix>", "exec"), ns) # noqa: S102
314
+ exec(compile(test_code, "<test>", "exec"), ns) # noqa: S102
315
+ passed = True
316
+ except AssertionError as e:
317
+ exception_str = f"AssertionError: {e}"
318
+ except Exception: # noqa: BLE001
319
+ exception_str = traceback.format_exc()
320
+ finally:
321
+ runtime_ms = (time.perf_counter() - start) * 1000
322
+ sys.stdout = old_stdout
323
+ sys.stderr = old_stderr
324
+
325
+ result_queue.put(
326
+ ExecutionResult(
327
+ stdout=stdout_capture.getvalue(),
328
+ stderr=stderr_capture.getvalue(),
329
+ exception=exception_str,
330
+ passed=passed,
331
+ runtime_ms=runtime_ms,
332
+ timed_out=False,
333
+ )
334
+ )
335
+
336
+
337
+ def run_code(code: str, test_code: str = "", timeout_s: float = 5.0) -> ExecutionResult:
338
+ """
339
+ Execute `code` then `test_code` in a sandboxed subprocess.
340
+
341
+ Args:
342
+ code: The (potentially buggy) solution code.
343
+ test_code: Test assertion code (e.g. "assert add(1,2)==3").
344
+ timeout_s: Max wall-clock seconds before kill.
345
+
346
+ Returns:
347
+ ExecutionResult with pass/fail, stdout, stderr, runtime.
348
+ """
349
+ full_code = textwrap.dedent(code)
350
+ full_test = textwrap.dedent(test_code)
351
+
352
+ # Static check first (fast, no subprocess)
353
+ if err := _validate_ast(full_code):
354
+ return ExecutionResult(exception=err, passed=False)
355
+
356
+ # Run in child process for isolation
357
+ q: multiprocessing.Queue = multiprocessing.Queue()
358
+ proc = multiprocessing.Process(
359
+ target=_run_in_process,
360
+ args=(full_code, full_test, q),
361
+ daemon=True,
362
+ )
363
+ proc.start()
364
+ proc.join(timeout=timeout_s)
365
+
366
+ if proc.is_alive():
367
+ proc.terminate()
368
+ proc.join()
369
+ return ExecutionResult(timed_out=True, exception=f"TimeoutError: exceeded {timeout_s}s")
370
+
371
+ try:
372
+ result = q.get(timeout=0.5)
373
+ return result
374
+ except Exception:
375
+ return ExecutionResult(
376
+ exception="ExecutionError: process exited without result",
377
+ passed=False,
378
+ )
379
+
380
+
381
+ def run_all_tests(code: str, test_cases: list, timeout_s: float = 5.0) -> list[ExecutionResult]:
382
+ """
383
+ Run all test cases against the given code.
384
+ Each test is isolated (imports/state don't leak between tests).
385
+ """
386
+ results = []
387
+ for tc in test_cases:
388
+ res = run_code(
389
+ code, tc.code, timeout_s=tc.timeout_s if hasattr(tc, "timeout_s") else timeout_s
390
+ )
391
+ results.append(res)
392
+ return results