File size: 14,696 Bytes
df72e5d effb18d df72e5d effb18d df72e5d effb18d df72e5d effb18d df72e5d effb18d df72e5d effb18d 503ebed effb18d df72e5d 503ebed df72e5d effb18d 503ebed effb18d 503ebed df72e5d effb18d 503ebed df72e5d effb18d df72e5d effb18d 503ebed effb18d 503ebed effb18d 503ebed effb18d 503ebed effb18d 503ebed effb18d 503ebed effb18d 503ebed df72e5d effb18d df72e5d 503ebed effb18d 503ebed effb18d df72e5d 503ebed df72e5d effb18d 503ebed effb18d 503ebed effb18d 503ebed effb18d 503ebed df72e5d 503ebed effb18d 503ebed df72e5d effb18d 503ebed df72e5d 503ebed effb18d 503ebed df72e5d effb18d 503ebed effb18d df72e5d effb18d df72e5d effb18d df72e5d effb18d df72e5d effb18d df72e5d effb18d df72e5d effb18d df72e5d effb18d df72e5d effb18d df72e5d effb18d df72e5d effb18d df72e5d 5644ab3 df72e5d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 | """
Python execution sandbox for safe, isolated code testing.
WHY A SUBPROCESS SANDBOX?
--------------------------
The agent generates code from untrusted LLM output β it could contain infinite
loops, memory bombs, file-system writes, or simply crash-inducing code. Running
it in the main process would either hang the agent or corrupt its state.
The sandbox runs generated code in a completely separate subprocess with:
- Process isolation: the agent process is never touched by the generated code
- rlimit resource limits: hard caps on memory (256 MB), CPU time (10 s),
and subprocess creation (0 child processes = no fork bombs)
- Timeout: kills the subprocess if it runs longer than 15 seconds
- Namespace isolation: the solution is loaded as a separate module; only
public names (no leading ``_``) are exported to the test namespace
SECURITY NOTE
-------------
This sandbox is appropriate for educational/demo use. Production deployments
should add nsjail, seccomp BPF, or Docker container isolation as Layer 4.
The current multi-layer setup stops common accidents but is not hardened
against a determined attacker.
HOW THE SANDBOX WORKS (STEP BY STEP)
--------------------------------------
1. Write solution_code to a temporary .py file (e.g. /tmp/sol_xyz.py)
2. Inject solution_path + test_code into the _SANDBOX_WRAPPER template
3. Write the rendered wrapper to a second temp file (e.g. /tmp/wrap_xyz.py)
4. Launch: ``python /tmp/wrap_xyz.py`` via asyncio.create_subprocess_exec
5. Apply rlimits via preexec_fn (runs in child after fork, before exec)
6. Wait for the subprocess with a timeout; kill if exceeded
7. Parse stdout/stderr for SANDBOX_RESULT: markers
8. Delete both temp files
9. Return ExecutionResult
OUTPUT PROTOCOL
---------------
The wrapper script writes tagged lines to stdout/stderr so we can parse the
outcome without relying on exit codes (which can be unreliable across platforms):
stdout: SANDBOX_RESULT:PASS
stderr: SANDBOX_RESULT:FAIL:<assertion message>
stderr: SANDBOX_RESULT:EXCEPTION:<ExceptionType>:<message>
"""
import asyncio
import os
import sys
import tempfile
import textwrap
from dataclasses import dataclass, field
@dataclass
class ExecutionResult:
"""Structured result from one sandbox execution.
All fields are populated whether the run passed or failed β callers should
check ``passed`` first, then read ``failed_assertions`` / ``exception_type``
for failure details.
"""
passed: bool # True only if SANDBOX_RESULT:PASS was found
stdout: str # full subprocess stdout
stderr: str # full subprocess stderr (includes SANDBOX_RESULT markers)
failed_assertions: list[str] = field(default_factory=list)
# Set when the code raised an unhandled exception before/during the test block
exception_type: str = ""
exception_message: str = ""
# Wall-clock time from subprocess launch to completion (or timeout)
elapsed_seconds: float = 0.0
# Populated by _parse_result only if output contains structured counts
passed_count: int = 0
total_count: int = 0
# ββ Sandbox wrapper script βββββββββββββββββββββββββββββββββββββββββββββββββββ
# This template is filled in with solution_path and indented_tests, then
# written to a temp file and executed as ``python <temp_file>``.
#
# WHY importlib?
# We could just exec() the solution code directly, but that would put it in
# the same namespace as the test code. Using importlib loads it as an isolated
# module so the test code only sees what the solution explicitly defines (public
# names only β nothing starting with _).
#
# The try/except structure in the wrapper captures both AssertionErrors (test
# failures) and other exceptions (solution crashes) as tagged stderr lines.
_SANDBOX_WRAPPER = textwrap.dedent("""\
import sys
import importlib.util
import traceback
# Load the solution file as a self-contained module (namespace isolation).
# The module's global namespace does not bleed into the test namespace.
_solution_spec = importlib.util.spec_from_file_location("_solution", {solution_path!r})
_solution_mod = importlib.util.module_from_spec(_solution_spec)
try:
_solution_spec.loader.exec_module(_solution_mod)
except Exception as _load_ex:
# Solution itself raised an exception on import β report it and exit.
print(
"SANDBOX_RESULT:EXCEPTION:" + type(_load_ex).__name__ + ":" + str(_load_ex),
file=sys.stderr,
)
traceback.print_exc(file=sys.stderr)
sys.exit(1)
# Export only public names from the solution module into the test namespace.
# Names starting with _ are private implementation details that tests should
# not depend on.
for _name in dir(_solution_mod):
if not _name.startswith("_"):
globals()[_name] = getattr(_solution_mod, _name)
# Run the test code. Each assert failure is caught individually so we report
# the specific assertion message. Non-assertion exceptions are also caught so
# the sandbox always exits cleanly (not via uncaught exception).
try:
{indented_tests}
print("SANDBOX_RESULT:PASS")
except AssertionError as _ae:
_msg = str(_ae) if str(_ae) else "AssertionError (no message)"
print("SANDBOX_RESULT:FAIL:" + _msg, file=sys.stderr)
traceback.print_exc(file=sys.stderr)
except Exception as _ex:
print("SANDBOX_RESULT:EXCEPTION:" + type(_ex).__name__ + ":" + str(_ex), file=sys.stderr)
traceback.print_exc(file=sys.stderr)
""")
# Default timeout in seconds before the subprocess is killed.
# 15s is generous for most algorithmic tasks but fast enough not to block
# the agent if the code has an infinite loop.
_DEFAULT_TIMEOUT = 15.0
# rlimits are only available on POSIX (Linux/macOS). Skip silently on Windows.
_RESOURCE_LIMITS_AVAILABLE = sys.platform != "win32"
def _apply_resource_limits() -> None:
"""Apply POSIX rlimits to the sandbox subprocess.
WHY preexec_fn?
This function is passed as ``preexec_fn`` to asyncio.create_subprocess_exec.
It runs in the child process AFTER fork() but BEFORE exec() β so the limits
are set on the exact process that will run our code, not the parent.
The three limits protect against:
- RLIMIT_AS: memory bomb (allocating huge arrays)
- RLIMIT_CPU: CPU-bound infinite loops (separate from wall-clock timeout)
- RLIMIT_NPROC: fork bombs (code that spawns many subprocesses)
Silently no-ops if resource module is unavailable (Windows, some HF Spaces configs).
"""
try:
import resource
# Hard limit: 256 MB virtual address space. The 3B model weights are NOT
# in this process β they're in the main agent process. This limit only
# applies to the tiny sandbox subprocess.
resource.setrlimit(resource.RLIMIT_AS, (256 * 1024 * 1024, 256 * 1024 * 1024))
# Hard limit: 10 seconds CPU time. Wall-clock timeout (asyncio.wait_for)
# handles the case where the process sleeps/blocks; RLIMIT_CPU handles
# pure CPU-bound infinite loops.
resource.setrlimit(resource.RLIMIT_CPU, (10, 10))
# Prevent the sandbox from spawning child processes (fork bomb prevention)
resource.setrlimit(resource.RLIMIT_NPROC, (0, 0))
except (ImportError, AttributeError, ValueError):
# resource module absent (Windows) or limit not supported β skip
pass
async def execute(
solution_code: str,
test_code: str,
timeout: float = _DEFAULT_TIMEOUT,
) -> ExecutionResult:
"""Execute solution_code + test_code in an isolated subprocess.
The solution and tests are written to separate temp files. The solution is
loaded as an isolated module so the test namespace is clean.
Args:
solution_code: The Python function/class to test.
test_code: Python assert statements to run against the solution.
timeout: Wall-clock seconds before the subprocess is killed.
Returns:
ExecutionResult β always returns (never raises), even on timeout or crash.
"""
import time
solution_tmp: str | None = None
wrapper_tmp: str | None = None
try:
# Write the solution to its own temp file. importlib loads it from disk,
# so it needs an actual file path. delete=False because we clean up manually
# in the finally block below.
with tempfile.NamedTemporaryFile(
mode="w", suffix=".py", delete=False, encoding="utf-8"
) as sol_f:
# Handle empty solution gracefully (e.g. first iteration generation failed)
sol_f.write(solution_code.strip() if solution_code.strip() else "# empty solution")
solution_tmp = sol_f.name
# Indent test code by 4 spaces so it fits inside the try block in the wrapper.
# A bare ``pass`` handles empty test suites (no assertions = trivially passes).
indented_tests = textwrap.indent(test_code.strip() if test_code.strip() else "pass", " ")
# Fill in the template and write to a second temp file
wrapper_script = _SANDBOX_WRAPPER.format(
solution_path=solution_tmp,
indented_tests=indented_tests,
)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".py", delete=False, encoding="utf-8"
) as wrap_f:
wrap_f.write(wrapper_script)
wrapper_tmp = wrap_f.name
# Use rlimits as preexec_fn on POSIX; None on Windows (no-op).
preexec = _apply_resource_limits if _RESOURCE_LIMITS_AVAILABLE else None
start = time.monotonic()
try:
# sys.executable ensures we use the same Python interpreter as the
# agent process (important for virtual environments on HF Spaces).
proc = await asyncio.create_subprocess_exec(
sys.executable,
wrapper_tmp,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
preexec_fn=preexec,
)
try:
stdout_bytes, stderr_bytes = await asyncio.wait_for(
proc.communicate(), timeout=timeout
)
except asyncio.TimeoutError:
# Kill the process and drain pipes before returning.
# Without the second communicate(), the subprocess may become
# a zombie that holds the pipe open.
proc.kill()
await proc.communicate()
return ExecutionResult(
passed=False,
stdout="",
stderr=f"EXECUTION TIMEOUT after {timeout}s",
exception_type="TimeoutError",
exception_message=f"Execution exceeded {timeout} second limit",
elapsed_seconds=timeout,
)
finally:
elapsed = time.monotonic() - start
finally:
# Always clean up temp files, even if subprocess raised
for path in [solution_tmp, wrapper_tmp]:
if path:
try:
os.unlink(path)
except OSError:
pass # file already deleted or never created
stdout = stdout_bytes.decode("utf-8", errors="replace")
stderr = stderr_bytes.decode("utf-8", errors="replace")
return _parse_result(stdout, stderr, elapsed)
def _parse_result(stdout: str, stderr: str, elapsed: float) -> ExecutionResult:
"""Parse SANDBOX_RESULT: markers from subprocess output into ExecutionResult.
The wrapper writes exactly one of these per run:
- ``SANDBOX_RESULT:PASS`` to stdout on success
- ``SANDBOX_RESULT:FAIL:<msg>`` to stderr on AssertionError
- ``SANDBOX_RESULT:EXCEPTION:<type>:<msg>`` to stderr on any other exception
"""
# Success: the PASS marker in stdout is definitive
if "SANDBOX_RESULT:PASS" in stdout:
return ExecutionResult(
passed=True,
stdout=stdout,
stderr=stderr,
elapsed_seconds=elapsed,
)
failed_assertions: list[str] = []
exception_type = ""
exception_message = ""
for line in stderr.splitlines():
if line.startswith("SANDBOX_RESULT:FAIL:"):
# Extract the assertion failure message (everything after the third colon)
failed_assertions.append(line.removeprefix("SANDBOX_RESULT:FAIL:"))
elif line.startswith("SANDBOX_RESULT:EXCEPTION:"):
# Format: SANDBOX_RESULT:EXCEPTION:<ExceptionType>:<message>
parts = line.removeprefix("SANDBOX_RESULT:EXCEPTION:").split(":", 1)
exception_type = parts[0] if parts else "UnknownException"
exception_message = parts[1] if len(parts) > 1 else ""
return ExecutionResult(
passed=False,
stdout=stdout,
stderr=stderr,
failed_assertions=failed_assertions,
exception_type=exception_type,
exception_message=exception_message,
elapsed_seconds=elapsed,
)
def format_failure_summary(result: ExecutionResult) -> str:
"""Produce a concise failure summary for the debugger agent's prompt.
Prioritizes specific assertion messages and exception info β more useful
to the LLM debugger than raw tracebacks. Tracebacks are included but
capped at 40 lines to avoid context overflow.
Args:
result: ExecutionResult from execute(). Must not be passed=True.
Returns:
Human-readable failure summary string.
"""
if result.passed:
return "All tests passed."
lines: list[str] = []
if result.exception_type:
# An unhandled exception before/during the test block β the most
# informative error type (tells the debugger exactly what broke).
lines.append(f"Exception: {result.exception_type}: {result.exception_message}")
if result.failed_assertions:
# Specific assertion messages β which test case failed and how.
lines.append("Failed assertions:")
for msg in result.failed_assertions:
lines.append(f" - {msg}")
if result.stderr:
# Include the traceback for line-number context, but cap at 40 lines.
# Strip our internal SANDBOX_RESULT: markers before including the traceback.
tb_lines = result.stderr.splitlines()
relevant = [ln for ln in tb_lines if not ln.startswith("SANDBOX_RESULT:")]
if relevant:
lines.append("Traceback (last 40 lines):")
lines.extend(relevant[-40:])
return "\n".join(lines)
|