"""Run a candidate routine in a separate process, and say plainly what that buys. The checker used to import the file you gave it straight into its own process. That meant any generated code you checked ran with your account's privileges, your files, your network and your credentials -- while the course was telling you to treat generated output as untrusted. This course cannot teach verification and then execute unverified code in the most trusting way available. So the candidate now runs in a child process which: * is started with no inherited environment beyond a minimal PATH and PYTHONPATH, so tokens and keys in your shell environment are not handed to it; * is killed, with its whole process group, after a timeout (default 10s); * has CPU time, address space, file size and subprocess count capped through ``resource`` limits where the platform provides them (POSIX); * is started in its own session, so a runaway child cannot linger attached to your terminal; * returns only data -- a table of values as strings -- so nothing it constructs is unpickled or executed back in the checker. **What that is, and is not.** It is a blast radius reduction and a way to turn hangs and runaway memory into named diagnoses instead of a frozen terminal. It is *not* a security boundary. The child still runs as you, can still read files you can read, and on a platform without ``resource`` limits (Windows) only the timeout applies. Nothing here stops deliberate exfiltration. If you are checking code you have not read, from a source you have no reason to trust, run the whole lab inside a container or a virtual machine with no network and no access to your home directory -- or use the table route, which needs no execution at all. The threat model is stated in ``README.md`` under "Running generated code". Standard library only. """ from __future__ import annotations import json import os import subprocess import sys from dataclasses import dataclass, field from fractions import Fraction from pathlib import Path LAB_DIR = Path(__file__).resolve().parent RUNNER = LAB_DIR / "_sandbox_runner.py" #: Wall-clock seconds before the candidate is killed. Generous for an exact #: Bernoulli routine over the checked range; short enough that an accidental #: infinite loop is a diagnosis rather than a hung terminal. DEFAULT_TIMEOUT = 10.0 #: CPU seconds, address space and output size the child may use. These are #: applied where the platform supports them; see the module docstring. CPU_SECONDS = 10 ADDRESS_SPACE_BYTES = 1_024 * 1_024 * 1_024 # 1 GiB MAX_OUTPUT_BYTES = 1_000_000 #: RLIMIT_NPROC counts every process of the user, not only the child's: on a #: busy desktop this already stops the child from starting any process. MAX_PROCESSES = 64 @dataclass class SandboxResult: """What the child process managed to produce, and how it ended.""" ok: bool = False #: n -> the value the routine returned, as ``repr`` and as a Fraction when #: it could be read as one exactly: a Fraction or int as itself, a finite #: float or Decimal as ``Fraction(value)``, the rational it actually stores. values: dict[int, str] = field(default_factory=dict) fractions: dict[int, Fraction] = field(default_factory=dict) #: n -> the exception text, for inputs where the routine raised. raised: dict[int, str] = field(default_factory=dict) #: n -> the type name of what was returned. types: dict[int, str] = field(default_factory=dict) names: list[str] = field(default_factory=list) failure: str = "" failure_kind: str = "" detail: str = "" def _limits() -> None: # pragma: no cover - exercised in the child process """Apply resource limits inside the child, before the candidate is imported.""" os.setsid() try: import resource except ImportError: # platforms without POSIX resource limits return for what, limit in ( (resource.RLIMIT_CPU, CPU_SECONDS), (resource.RLIMIT_AS, ADDRESS_SPACE_BYTES), (resource.RLIMIT_FSIZE, MAX_OUTPUT_BYTES), ): try: resource.setrlimit(what, (limit, limit)) except (ValueError, OSError): continue try: # no forking: one runaway process is enough resource.setrlimit(resource.RLIMIT_NPROC, (MAX_PROCESSES, MAX_PROCESSES)) except (ValueError, OSError, AttributeError): pass def _kill_group(child: subprocess.Popen) -> None: """Kill the child's whole process group (POSIX), or the child alone.""" try: if os.name == "posix": import signal os.killpg(child.pid, signal.SIGKILL) else: child.kill() except (ProcessLookupError, PermissionError, OSError): pass def run_candidate( path: str | Path, max_n: int, timeout: float = DEFAULT_TIMEOUT ) -> SandboxResult: """Import ``path`` in a child process and collect ``bernoulli(0..max_n)``. Returns a :class:`SandboxResult`; it never raises for anything the candidate does, because "it hung", "it exhausted memory" and "it raised on import" are all findings the checker needs to report rather than crashes. """ given = Path(path) result = SandboxResult() if not given.exists(): result.failure_kind = "no-file" result.failure = f"no such file: {given}" return result # Absolute, because the child runs with the candidate's own directory as its # working directory -- so a file that reads a data file beside itself behaves # as it would if you ran it yourself. path = given.resolve() command = [sys.executable, "-I", "-S", str(RUNNER), str(path), str(max_n)] environment = { "PATH": os.environ.get("PATH", ""), "PYTHONIOENCODING": "utf-8", # Nothing else: the child does not need, and should not receive, the # tokens and credentials that live in a working shell's environment. } try: child = subprocess.Popen( command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=environment, cwd=str(path.parent), preexec_fn=_limits if os.name == "posix" else None, # noqa: PLW1509 ) except OSError as exc: result.failure_kind = "sandbox-error" result.failure = f"The candidate process could not be started: {exc}" return result try: stdout, stderr = child.communicate(timeout=timeout) completed = subprocess.CompletedProcess(command, child.returncode, stdout, stderr) except subprocess.TimeoutExpired: _kill_group(child) try: # a descendant that left the group may still hold the pipes child.communicate(timeout=5) except subprocess.TimeoutExpired: pass result.failure_kind = "timeout" result.failure = ( f"The routine did not finish within {timeout:.0f} seconds and was stopped." ) result.detail = ( "An exact Bernoulli routine over this range finishes in well under a\n" "second. A timeout usually means an unbounded loop or unbounded\n" "recursion -- which is a defect the checker cannot diagnose further,\n" "because it never saw a value." ) return result finally: # Whatever the candidate started and left behind goes with it: the # child leads its own session, so its group is everything it spawned. _kill_group(child) if not completed.stdout.strip(): # A negative return code means the child was killed by a signal, which # here means it hit a limit: CPU time, address space or file size. That # is the same finding as the wall-clock timeout, and a learner needs to # be told which one rather than "no output". killed = completed.returncode is not None and completed.returncode < 0 result.failure_kind = "timeout" if killed else "no-output" result.failure = ( "The routine was stopped after exceeding a resource limit " f"({CPU_SECONDS}s of CPU time, or {ADDRESS_SPACE_BYTES // (1024 ** 2)} MiB " "of memory)." if killed else "The candidate process produced no result." ) result.detail = ( (completed.stderr or "").strip()[:2000] or "An exact Bernoulli routine over this range finishes well inside\n" "those limits. Hitting them usually means an unbounded loop, runaway\n" "recursion, or a routine that allocates without bound." ) return result try: payload = json.loads(completed.stdout.splitlines()[-1]) except (ValueError, IndexError): result.failure_kind = "no-output" result.failure = "The candidate process produced unreadable output." result.detail = (completed.stdout or "")[-2000:] return result result.names = payload.get("names", []) if not payload.get("ok"): result.failure_kind = payload.get("kind", "import-error") result.failure = payload.get("error", "unknown failure") result.detail = payload.get("detail", "") return result for key, entry in payload.get("results", {}).items(): n = int(key) if "error" in entry: result.raised[n] = entry["error"] continue result.values[n] = entry["repr"] result.types[n] = entry["type"] if entry.get("fraction") is not None: numerator, denominator = entry["fraction"] result.fractions[n] = Fraction(numerator, denominator) result.ok = True return result