| """FATHOM Python REPL sandbox. |
| |
| Security design (per STACK.md §7, CLAUDE.md §7, PITFALLS.md H3): |
| 1. RestrictedPython AST-level filter (compile_restricted) blocks attribute-access escapes |
| (__class__, __subclasses__, __globals__, traceback walks). |
| 2. Subprocess isolation with RLIMIT_AS=512MB, RLIMIT_CPU=5s, RLIMIT_FSIZE=0 (no file writes), |
| cwd=/tmp/episode-{uuid}, start_new_session=True, network denied via unshare -n or seccomp. |
| 3. Whitelisted builtins + whitelisted import hook. |
| 4. 30s wall-clock hard kill; on timeout, caller's globals_dict is NOT overwritten (D-03). |
| |
| Threat model: see Plan 00-02's <threat_model> block. |
| |
| Platform notes: |
| - `resource` module is POSIX-only. On Windows (dev laptops only) the rlimits are a no-op; |
| RestrictedPython + the import hook + the wall-clock timeout remain active. The venue |
| is a Linux A100 container, so the primary hardening path runs there. |
| - `os.setsid` / `start_new_session` is POSIX-only. Used when `os.name == "posix"`. |
| - `unshare -n` / seccomp are Linux-only stretch goals; the blocklist already removes the |
| `socket`/`urllib`/`http` surface at the import layer. |
| """ |
| from __future__ import annotations |
|
|
| import base64 |
| import json |
| import os |
| import pickle |
| import subprocess |
| import sys |
| import tempfile |
| import time |
| import uuid |
| from dataclasses import dataclass, field |
| from pathlib import Path |
| from typing import Any, Callable |
|
|
| from RestrictedPython import compile_restricted, safe_builtins as _rp_safe_builtins |
|
|
| |
| try: |
| import resource |
| _HAS_RESOURCE = True |
| except ImportError: |
| resource = None |
| _HAS_RESOURCE = False |
|
|
|
|
| |
| |
| |
|
|
| |
| ALLOWED_IMPORTS: frozenset[str] = frozenset({ |
| "re", "json", "math", "statistics", "collections", |
| "itertools", "string", "functools", "operator", |
| }) |
|
|
| |
| BLOCKED_IMPORTS: frozenset[str] = frozenset({ |
| "os", "sys", "subprocess", "socket", "urllib", "http", "requests", |
| "pathlib", "io", "ctypes", "threading", "multiprocessing", |
| "pickle", "marshal", "importlib", |
| }) |
|
|
| |
| _SAFE_BUILTIN_NAMES: tuple[str, ...] = ( |
| "len", "range", "enumerate", "zip", "map", "filter", |
| "sorted", "min", "max", "sum", "any", "all", "print", |
| "str", "int", "float", "bool", |
| "list", "dict", "tuple", "set", "frozenset", |
| "abs", "round", "divmod", "pow", |
| "repr", "reversed", "iter", "next", |
| "isinstance", "issubclass", |
| ) |
|
|
| |
| |
| |
| _FORBIDDEN_NAMES: frozenset[str] = frozenset({ |
| "open", "exec", "eval", "compile", "__import__", |
| "input", "breakpoint", "help", "exit", "quit", |
| "getattr", "setattr", "delattr", "hasattr", |
| "__builtins__", "globals", "locals", "vars", "dir", |
| }) |
|
|
|
|
| def _build_safe_builtins() -> dict[str, Any]: |
| """Build a minimal builtins dict. |
| |
| Starts from RestrictedPython.safe_builtins (pre-vetted) then narrows to our whitelist. |
| Whitelisted names missing from RP's ``safe_builtins`` (e.g. ``print``, ``list``, |
| ``dict``, ``set``, ``enumerate``, ``map``, ``filter``, ``min``, ``max``, ``sum``, |
| ``any``, ``all``, ``reversed``, ``iter``, ``next``) are fetched from Python's |
| real ``builtins`` module — they are safe and RP simply doesn't include them |
| by default. |
| |
| Returns a plain dict (NOT the builtins module) so user code cannot import |
| through it via ``__builtins__.open(...)`` — ``_FORBIDDEN_NAMES`` is the hard |
| barrier against the ``getattr(__builtins__, 'open')`` escape named in |
| STACK.md §7 adversarial test #5. |
| """ |
| import builtins as _py_builtins |
|
|
| b: dict[str, Any] = {} |
| |
| |
| for name in _SAFE_BUILTIN_NAMES: |
| if name in _rp_safe_builtins: |
| b[name] = _rp_safe_builtins[name] |
| elif hasattr(_py_builtins, name): |
| b[name] = getattr(_py_builtins, name) |
| |
| |
| b["True"] = True |
| b["False"] = False |
| b["None"] = None |
| |
| |
| |
| for k in _FORBIDDEN_NAMES: |
| b.pop(k, None) |
| return b |
|
|
|
|
| def _safe_import( |
| name: str, |
| globals: dict[str, Any] | None = None, |
| locals: dict[str, Any] | None = None, |
| fromlist: tuple[str, ...] = (), |
| level: int = 0, |
| ): |
| """Whitelisted ``__import__`` hook. |
| |
| Rejects anything not in :data:`ALLOWED_IMPORTS`. Relative imports (``level != 0``) |
| are always rejected. Delegates to :mod:`importlib` only for whitelisted roots — |
| `importlib` itself is never exposed to user code (it's imported locally here). |
| """ |
| if level != 0: |
| raise ImportError( |
| f"Relative imports not allowed in FATHOM REPL sandbox (requested level={level})" |
| ) |
| root = name.split(".")[0] |
| if root in BLOCKED_IMPORTS: |
| raise ImportError( |
| f"Import of {name!r} is explicitly blocked in FATHOM REPL sandbox" |
| ) |
| if root not in ALLOWED_IMPORTS: |
| raise ImportError( |
| f"Import of {name!r} not in whitelist {sorted(ALLOWED_IMPORTS)}" |
| ) |
| import importlib |
| return importlib.import_module(name) |
|
|
|
|
| |
| |
| |
|
|
|
|
| class ReplCompileError(Exception): |
| """RestrictedPython rejected the source (AST-level policy violation).""" |
|
|
|
|
| def _compile_user_code(code: str) -> Any: |
| """Run RestrictedPython's AST filter. |
| |
| Raises :class:`ReplCompileError` on policy violation, :class:`SyntaxError` |
| on malformed source. |
| |
| STACK §7 adversarial test coverage: |
| - ``().__class__.__base__.__subclasses__()`` → compile_restricted flags ``__class__`` access. |
| - ``import os`` → Python compile succeeds; ``_safe_import`` rejects at exec time. |
| - ``__builtins__['open']`` → compile_restricted flags subscript/attr on ``__builtins__``. |
| """ |
| compiled = compile_restricted(code, filename="<fathom_repl>", mode="exec") |
| if compiled is None: |
| raise ReplCompileError( |
| "RestrictedPython returned None (policy violation in submitted source)" |
| ) |
| return compiled |
|
|
|
|
| |
| |
| |
|
|
|
|
| @dataclass |
| class ReplResult: |
| """Return value of :func:`run_repl`. |
| |
| Attributes: |
| stdout: captured stdout from the child. |
| stderr: captured stderr from the child (or diagnostic from the parent). |
| exception: ``"ExcType: message"`` if user code raised, else ``None``. |
| globals_dict: updated globals after successful exec. **On timeout or pre-exec |
| parent-side failure, this equals the original input dict unchanged** |
| (D-03 — prior state preserved). |
| timed_out: ``True`` iff the 30s wall-clock kill fired. |
| wall_time_s: monotonic elapsed time from Popen to result assembly. |
| """ |
|
|
| stdout: str = "" |
| stderr: str = "" |
| exception: str | None = None |
| globals_dict: dict[str, Any] = field(default_factory=dict) |
| timed_out: bool = False |
| wall_time_s: float = 0.0 |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| _CHILD_RUNNER_TEMPLATE = r''' |
| import base64, json, pickle, sys, traceback, io, contextlib |
| |
| # Re-derive the sandbox inside the child. Importing anything beyond |
| # RestrictedPython + stdlib essentials would widen the attack surface. |
| from RestrictedPython import compile_restricted, safe_builtins as _rp_safe_builtins |
| |
| ALLOWED_IMPORTS = set(___ALLOWED___) |
| BLOCKED_IMPORTS = set(___BLOCKED___) |
| _SAFE_NAMES = tuple(___SAFE_NAMES___) |
| _FORBIDDEN = set(___FORBIDDEN___) |
| |
| |
| def _safe_import(name, globals=None, locals=None, fromlist=(), level=0): |
| if level != 0: |
| raise ImportError("Relative imports not allowed") |
| root = name.split(".")[0] |
| if root in BLOCKED_IMPORTS: |
| raise ImportError("Import of " + repr(name) + " is blocked in FATHOM REPL sandbox") |
| if root not in ALLOWED_IMPORTS: |
| raise ImportError("Import of " + repr(name) + " not in whitelist") |
| import importlib |
| return importlib.import_module(name) |
| |
| |
| def _stub_llm(prompt, chunk, depth=1): |
| """Deterministic regex-echo stub (Phase 0 only; Plan 04 replaces via RPC). |
| |
| Extracts the first keyword (>=3 alphanumeric chars) from `prompt`, returns |
| an 80-char window of `chunk` containing it, falling back to `chunk[:200]`. |
| """ |
| import re as _re |
| m = _re.search(r"\b\w{3,}\b", prompt) |
| kw = m.group(0) if m else "" |
| if kw and kw in chunk: |
| idx = chunk.index(kw) |
| return chunk[idx:idx + 80] |
| return chunk[:200] |
| |
| |
| def _build_builtins(): |
| import builtins as _py_builtins |
| b = {} |
| for name in _SAFE_NAMES: |
| if name in _rp_safe_builtins: |
| b[name] = _rp_safe_builtins[name] |
| elif hasattr(_py_builtins, name): |
| b[name] = getattr(_py_builtins, name) |
| b["True"] = True |
| b["False"] = False |
| b["None"] = None |
| for k in _FORBIDDEN: |
| b.pop(k, None) |
| # Install the whitelisted import hook so `import re` works but `import os` fails. |
| b["__import__"] = _safe_import |
| return b |
| |
| |
| def main(): |
| payload = json.loads(sys.stdin.read()) |
| code = payload["code"] |
| try: |
| user_globals = pickle.loads(base64.b64decode(payload["globals_b64"])) |
| if not isinstance(user_globals, dict): |
| user_globals = {} |
| except Exception as e: |
| sys.stdout.write(json.dumps({ |
| "stdout": "", |
| "stderr": "", |
| "exception": "globals unpickle failed: " + repr(e), |
| "globals_b64": payload["globals_b64"], |
| })) |
| return |
| |
| # Install safe builtins + llm stub into the user's namespace. |
| user_globals["__builtins__"] = _build_builtins() |
| user_globals.setdefault("llm", _stub_llm) |
| # RestrictedPython expects _getattr_ / _getitem_ helpers if attribute access |
| # is ever emitted by legitimate user code. Provide minimal safe versions. |
| user_globals.setdefault("_getattr_", getattr) |
| user_globals.setdefault("_getitem_", lambda o, k: o[k]) |
| user_globals.setdefault("_getiter_", iter) |
| user_globals.setdefault("_iter_unpack_sequence_", lambda it, count, _obj: tuple(it)) |
| # RestrictedPython rewrites `print(...)` to `_print = _print_(); _print._call_print(...)`. |
| # Our collector forwards to the real builtins.print so the surrounding |
| # redirect_stdout captures output into stdout_buf. |
| class _StdoutPrintCollector: |
| def __init__(self, _getattr_=None): |
| self._getattr_ = _getattr_ |
| def _call_print(self, *objects, **kwargs): |
| import builtins as _b |
| _b.print(*objects, **kwargs) |
| def __call__(self): |
| return "" |
| def write(self, text): |
| sys.stdout.write(text) |
| user_globals.setdefault("_print_", _StdoutPrintCollector) |
| |
| stdout_buf = io.StringIO() |
| stderr_buf = io.StringIO() |
| exception_str = None |
| try: |
| compiled = compile_restricted(code, filename="<fathom_repl_child>", mode="exec") |
| if compiled is None: |
| raise SyntaxError("RestrictedPython rejected source (compile returned None)") |
| with contextlib.redirect_stdout(stdout_buf), contextlib.redirect_stderr(stderr_buf): |
| exec(compiled, user_globals, user_globals) |
| except BaseException as e: |
| exception_str = type(e).__name__ + ": " + str(e) |
| |
| # Strip non-picklable injected values before returning globals to parent. |
| for k in ("__builtins__", "llm", "_getattr_", "_getitem_", "_getiter_", |
| "_iter_unpack_sequence_", "_print_", "_print"): |
| user_globals.pop(k, None) |
| |
| try: |
| updated_b64 = base64.b64encode(pickle.dumps(user_globals)).decode("ascii") |
| except Exception as e: |
| updated_b64 = payload["globals_b64"] # fall back to original on pickle failure |
| exception_str = (exception_str or "") + " | globals pickle failed: " + repr(e) |
| |
| sys.stdout.write(json.dumps({ |
| "stdout": stdout_buf.getvalue(), |
| "stderr": stderr_buf.getvalue(), |
| "exception": exception_str, |
| "globals_b64": updated_b64, |
| })) |
| |
| |
| if __name__ == "__main__": |
| main() |
| ''' |
|
|
|
|
| def _render_child_runner() -> str: |
| """Fill the child-runner template with our verbatim whitelist/blocklist data.""" |
| return (_CHILD_RUNNER_TEMPLATE |
| .replace("___ALLOWED___", json.dumps(sorted(ALLOWED_IMPORTS))) |
| .replace("___BLOCKED___", json.dumps(sorted(BLOCKED_IMPORTS))) |
| .replace("___SAFE_NAMES___", json.dumps(list(_SAFE_BUILTIN_NAMES))) |
| .replace("___FORBIDDEN___", json.dumps(sorted(_FORBIDDEN_NAMES)))) |
|
|
|
|
| def _set_rlimits_posix() -> None: |
| """``preexec_fn`` for POSIX subprocesses — enforces the STACK §7 rlimits. |
| |
| Limits applied (STACK.md §7 subprocess-level defenses): |
| * ``RLIMIT_AS`` = 512 MiB (virtual address space hard cap) |
| * ``RLIMIT_CPU`` = 5 s (CPU time; 30 s wall-clock is the outer bound) |
| * ``RLIMIT_FSIZE`` = 0 (no file writes even if ``open`` leaks) |
| * ``RLIMIT_NOFILE``= 64 (fd cap) |
| |
| Also detaches from the parent process group via :func:`os.setsid` so that |
| :func:`os.killpg` on timeout reaches the whole tree. |
| """ |
| if not _HAS_RESOURCE or resource is None: |
| return |
| _BYTES_512_MIB = 512 * 1024 * 1024 |
| resource.setrlimit(resource.RLIMIT_AS, (_BYTES_512_MIB, _BYTES_512_MIB)) |
| resource.setrlimit(resource.RLIMIT_CPU, (5, 5)) |
| resource.setrlimit(resource.RLIMIT_FSIZE, (0, 0)) |
| resource.setrlimit(resource.RLIMIT_NOFILE, (64, 64)) |
| |
| os.setsid() |
|
|
|
|
| def run_repl( |
| code: str, |
| globals_dict: dict[str, Any], |
| llm_callable: Callable[[str, str, int], str] | None = None, |
| timeout_s: float = 30.0, |
| episode_id: str | None = None, |
| ) -> ReplResult: |
| """Execute ``code`` in an isolated subprocess with stateful globals. |
| |
| Args: |
| code: Python source submitted by the model. |
| globals_dict: persistent globals from prior steps. May contain ``ctx`` and |
| any variables the model defined earlier. Must be JSON/pickle-safe — |
| only JSON-serialisable values survive the round-trip; functions and |
| open file handles do not. |
| llm_callable: accepted for forward-compat with Plan 04; ignored in Phase 0 |
| because the child uses an embedded deterministic regex-echo stub |
| (D-05). Plan 04 will inject the real Qwen-backed callable via an |
| RPC side channel. |
| timeout_s: wall-clock limit (default 30 s per STACK §7). |
| episode_id: used to build the ephemeral cwd ``<tmp>/episode-<id>``; |
| auto-generated if ``None``. |
| |
| Returns: |
| :class:`ReplResult`. On timeout, pre-exec pickle failure, or child |
| protocol error, ``globals_dict`` in the result equals the unchanged |
| input dict (D-03 — prior state preserved). |
| """ |
| eid = episode_id or uuid.uuid4().hex |
| |
| |
| ep_cwd = Path(tempfile.gettempdir()) / f"episode-{eid}" |
| ep_cwd.mkdir(parents=True, exist_ok=True) |
|
|
| started = time.monotonic() |
|
|
| |
| |
| |
| try: |
| globals_b64 = base64.b64encode(pickle.dumps(globals_dict)).decode("ascii") |
| except Exception as e: |
| return ReplResult( |
| stderr=f"Parent failed to pickle globals_dict: {e!r}", |
| exception=f"PickleError: {e!r}", |
| globals_dict=dict(globals_dict), |
| wall_time_s=time.monotonic() - started, |
| ) |
|
|
| payload = json.dumps({"code": code, "globals_b64": globals_b64}) |
| runner_src = _render_child_runner() |
|
|
| |
| |
| |
| is_posix = os.name == "posix" |
| preexec = _set_rlimits_posix if is_posix else None |
|
|
| |
| |
| |
| |
| |
| |
| import site as _site |
| _site_dirs = _site.getsitepackages() if hasattr(_site, "getsitepackages") else [] |
| _user_site = _site.getusersitepackages() if hasattr(_site, "getusersitepackages") else "" |
| _pythonpath_parts = [p for p in _site_dirs + ([_user_site] if _user_site else []) if p] |
| child_env = { |
| "PATH": os.environ.get("PATH", ""), |
| "PYTHONDONTWRITEBYTECODE": "1", |
| "PYTHONPATH": os.pathsep.join(_pythonpath_parts), |
| } |
| if os.name == "nt": |
| for _k in ("SystemRoot", "SystemDrive", "TEMP", "TMP", "WINDIR"): |
| if _k in os.environ: |
| child_env[_k] = os.environ[_k] |
|
|
| |
| |
| |
| args = [sys.executable, "-s", "-c", runner_src] |
|
|
| try: |
| proc = subprocess.Popen( |
| args, |
| stdin=subprocess.PIPE, |
| stdout=subprocess.PIPE, |
| stderr=subprocess.PIPE, |
| cwd=str(ep_cwd), |
| preexec_fn=preexec, |
| start_new_session=is_posix, |
| env=child_env, |
| ) |
| except Exception as e: |
| return ReplResult( |
| stderr=f"Popen failed: {e!r}", |
| exception=f"SpawnError: {e!r}", |
| globals_dict=dict(globals_dict), |
| wall_time_s=time.monotonic() - started, |
| ) |
|
|
| try: |
| stdout_b, stderr_b = proc.communicate( |
| input=payload.encode("utf-8"), |
| timeout=timeout_s, |
| ) |
| except subprocess.TimeoutExpired: |
| |
| try: |
| if is_posix: |
| import signal |
| os.killpg(os.getpgid(proc.pid), signal.SIGKILL) |
| else: |
| proc.kill() |
| except Exception: |
| try: |
| proc.kill() |
| except Exception: |
| pass |
| try: |
| proc.wait(timeout=2) |
| except Exception: |
| pass |
| _cleanup_ep_cwd(ep_cwd) |
| return ReplResult( |
| stderr="TimeoutError", |
| exception=f"TimeoutError: wall-clock exceeded {timeout_s}s", |
| globals_dict=dict(globals_dict), |
| timed_out=True, |
| wall_time_s=time.monotonic() - started, |
| ) |
|
|
| wall = time.monotonic() - started |
| stdout_raw = stdout_b.decode("utf-8", errors="replace") |
| stderr_raw = stderr_b.decode("utf-8", errors="replace") |
| _cleanup_ep_cwd(ep_cwd) |
|
|
| |
| try: |
| env = json.loads(stdout_raw) |
| except json.JSONDecodeError as e: |
| return ReplResult( |
| stdout="", |
| stderr=stderr_raw or stdout_raw, |
| exception=f"ChildProtocolError: {e!r}", |
| globals_dict=dict(globals_dict), |
| wall_time_s=wall, |
| ) |
|
|
| |
| try: |
| new_globals = pickle.loads(base64.b64decode(env["globals_b64"])) |
| if not isinstance(new_globals, dict): |
| new_globals = dict(globals_dict) |
| except Exception as e: |
| new_globals = dict(globals_dict) |
| env["exception"] = (env.get("exception") or "") + f" | globals unpickle failed: {e!r}" |
|
|
| return ReplResult( |
| stdout=env.get("stdout", ""), |
| stderr=env.get("stderr", "") or stderr_raw, |
| exception=env.get("exception"), |
| globals_dict=new_globals, |
| timed_out=False, |
| wall_time_s=wall, |
| ) |
|
|
|
|
| def _cleanup_ep_cwd(ep_cwd: Path) -> None: |
| """Best-effort ephemeral-cwd wipe. Never raises.""" |
| try: |
| for p in ep_cwd.iterdir(): |
| try: |
| if p.is_dir(): |
| import shutil |
| shutil.rmtree(p, ignore_errors=True) |
| else: |
| p.unlink() |
| except Exception: |
| pass |
| ep_cwd.rmdir() |
| except Exception: |
| pass |
|
|