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.
- codefix_env/__init__.py +80 -0
- codefix_env/cli.py +83 -0
- codefix_env/client.py +194 -0
- codefix_env/env.py +503 -0
- codefix_env/models.py +234 -0
- codefix_env/rewards.py +157 -0
- codefix_env/tasks/__init__.py +75 -0
- codefix_env/tasks/easy.py +244 -0
- codefix_env/tasks/hard.py +430 -0
- codefix_env/tasks/medium.py +342 -0
- codefix_env/utils/__init__.py +33 -0
- codefix_env/utils/metrics.py +165 -0
- codefix_env/utils/reward_model.py +79 -0
- codefix_env/utils/sandbox.py +392 -0
- codefix_env-0.2.1.dist-info/METADATA +663 -0
- codefix_env-0.2.1.dist-info/RECORD +20 -0
- codefix_env-0.2.1.dist-info/WHEEL +5 -0
- codefix_env-0.2.1.dist-info/entry_points.txt +2 -0
- codefix_env-0.2.1.dist-info/licenses/LICENSE +201 -0
- codefix_env-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -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
|