"""core/script_sandbox.py — WAVE 36 (R5 / R10, contract C1): running a tenant's OWN Python. Owner item 6: *"Add code script as an interface (database View) so a user can build whatever they want through the Agent chat interface."* Item 8: *"We need to really guardrail the reach of this script. So let's really grill this down."* R10 ruled it SERVER-SIDE PYTHON after the trade was stated, so this file is the guardrail, and one engine serves both items. ════════════════════════════════════════════════════════════════════════════════════════════════ ⛔⛔ THE ONE PARAGRAPH TO READ BEFORE CHANGING ANYTHING HERE. In-process CPython cannot deliver two of this ticket's clauses. An AST allow-list plus a curated namespace stops import, file, network and environment access — but it **cannot cap memory and cannot interrupt a runaway loop**, because a `while True:` in the same interpreter is not a slow request, it is the tenant's ONE FastAPI process gone. So the script runs in a **SUBPROCESS**: `resource.setrlimit` for address space and CPU, a hard wall-clock kill from the parent, and the allow-list inside. Neither half is sufficient; both are load-bearing. ⭐ AND THE SUBPROCESS RECEIVES **ROWS, NEVER A STORE**. The parent calls C1's `scoped_table` under the CALLING user's record and serialises the result; the child imports nothing from this repo and holds no credential, no runtime and no store handle. Wiring W1 ("the sandbox has no second store path") is then true by CONSTRUCTION rather than by discipline, and it is checkable: the child reports its own `sys.modules`, and no `core.*` name may appear in it. ⛔ NEVER A BLACKLIST. Every rule below is an ALLOW-LIST — a set of node types, a set of attribute names, a dict of builtins. A blacklist of dangerous spellings is bypassable by construction, and the bypass is usually one string method away (`"{0.__class__}".format(x)` performs its attribute lookup inside `format`, so there is no `ast.Attribute` node to refuse). ════════════════════════════════════════════════════════════════════════════════════════════════ The two layers, and they refuse DIFFERENT things on purpose: 1. `check_source()` — a pure function over source text. Refuses a construct the language offers and this sandbox does not: `import`, `class`, `with`, `async`, `yield`, `global`, and every attribute name outside `ALLOWED_ATTRS`. 2. `SANDBOX_BUILTINS` — the names that resolve at all. `__import__`, `open`, `eval`, `exec`, `compile`, `getattr`, `globals`, `vars` and `type` are simply absent, so a source that gets past layer 1 still finds nothing to call. ⚠ THAT DUPLICATION IS DELIBERATE AND IT CHANGES HOW THE GATE MUST BE WRITTEN. `import os` is refused twice, so a negative control that drops ONE layer sees the other refuse and reports green — the shape that already cost this wave one missed control in `routes_agent_harness`. So each layer is tested AT ITS OWN BOUNDARY: `check_source()` is called directly on source strings, and `run()` is driven end to end. An NC drops one entry from one frozenset and the matching boundary goes red. """ import ast import json import os import subprocess import sys import tempfile import time from pathlib import Path #: Wall clock, enforced by the PARENT with a kill. The one cap that works on every platform. DEFAULT_TIMEOUT_S = 10.0 #: Address space for the child (`RLIMIT_AS`). POSIX only — see `run()`'s `caps` report. DEFAULT_MEMORY_BYTES = 512 * 1024 * 1024 #: CPU seconds for the child (`RLIMIT_CPU`). POSIX only. Deliberately above the wall clock: the #: wall-clock kill is the primary control and this is the backstop for a child that stops being #: reachable. A CPU limit BELOW the timeout would make every slow script look like a CPU refusal. DEFAULT_CPU_SECONDS = 15 #: What the script may print, in bytes. `print` is a curated builtin writing to a capped buffer, #: and the child's real stdout goes to DEVNULL — so a script cannot fill a pipe, and anything #: that escaped far enough to write to fd 1 has nowhere for it to land. MAX_STDOUT_BYTES = 64 * 1024 #: The serialised ROW payload handed to the child. ⛔ A REFUSAL, NEVER A TRUNCATION (standing rule #: 1): a short answer from a data tool is a wrong answer that looks right. Over this, `run()` #: returns a named limit carrying its cause and a recommendation. MAX_PAYLOAD_BYTES = 32 * 1024 * 1024 #: The emitted spec. A render spec is a description of a picture; one larger than this is data #: pretending to be a description. MAX_SPEC_BYTES = 2 * 1024 * 1024 MAX_SOURCE_BYTES = 128 * 1024 # ══════════════════════════════════════════════════════ LAYER 1 — the AST allow-list ═══════════ #: Every `ast` node class a script may contain. ⛔ THE ABSENCES ARE THE POLICY: `Import` / #: `ImportFrom` (no module reaches the script), `ClassDef` (a class body is a namespace with its #: own scoping rules and buys a data script nothing), `With` (a context manager is `__enter__` #: by another spelling), `Global` / `Nonlocal` (rebinding the sandbox's own names), and every #: `Async*` / `Await` / `Yield` form (this engine is synchronous; a coroutine that is never #: awaited is a silent no-op that looks like a working script). ALLOWED_NODES = frozenset(""" Module Expr Assign AugAssign AnnAssign NamedExpr Return Pass Break Continue Delete Assert Raise If For While Try TryStar ExceptHandler FunctionDef Lambda arguments arg keyword BoolOp BinOp UnaryOp IfExp Dict Set List Tuple Starred Subscript Slice Compare Call Attribute Name Constant JoinedStr FormattedValue ListComp SetComp DictComp GeneratorExp comprehension Load Store Del And Or Not Invert UAdd USub Add Sub Mult Div FloorDiv Mod Pow LShift RShift BitOr BitXor BitAnd MatMult Eq NotEq Lt LtE Gt GtE Is IsNot In NotIn """.split()) #: Every attribute name a script may READ or CALL. ⛔⛔ THIS IS THE LOAD-BEARING SET, and it is #: an allow-list of NAMES rather than a refusal of dunders, because the interesting escapes are #: ordinary-looking: `f.__globals__` on any function reaches the runner's own module namespace, #: `e.__traceback__.tb_frame.f_globals` reaches it from an exception handler, and `().__class__` #: reaches `object.__subclasses__`. None of those names is here, and neither is any name this #: sandbox has not been asked for. #: ⚠ `format` IS ABSENT DELIBERATELY. `"{0.__class__}".format(x)` performs the attribute lookup #: INSIDE `str.format`, where no `ast.Attribute` node exists for layer 1 to see. f-strings are #: fine — `f"{x.__class__}"` compiles to a real `Attribute` node and is refused. ALLOWED_ATTRS = frozenset(""" append extend insert pop remove clear sort reverse copy count index keys values items get setdefault update add discard union intersection difference issubset issuperset join split rsplit splitlines strip lstrip rstrip lower upper title capitalize casefold replace startswith endswith find rfind zfill ljust rjust center partition removeprefix removesuffix isdigit isalpha isalnum isspace isupper islower isnumeric real imag numerator denominator """.split()) class Refused(Exception): """A named refusal: `code` for a caller to branch on, `message` for a person to read.""" def __init__(self, code, message): self.code, self.message = code, message super().__init__(f"{code}: {message}") def _attr_ok(name): """An attribute name passes only if it is on the list AND is not private. ⚠ THE SECOND TEST IS NOT A BLACKLIST — it narrows an allow-list that already excludes every private name. It is here so that adding a name to `ALLOWED_ATTRS` cannot open a dunder by accident, which is the one edit a future reader is most likely to make in a hurry. """ return name in ALLOWED_ATTRS and not name.startswith("_") def check_source(source): """LAYER 1. Return a `Refused` for source this sandbox will not run, or `None`. ⭐ PURE, AND THAT IS WHAT MAKES IT TESTABLE AT ITS OWN BOUNDARY. It reads no file, spawns no process and touches no store, so a gate can hand it a hundred hostile strings for free and an NC can drop one entry from one frozenset and watch exactly this function change its answer. """ text = str(source or "") if len(text.encode("utf-8", "replace")) > MAX_SOURCE_BYTES: return Refused("source_too_long", f"a script view is at most {MAX_SOURCE_BYTES // 1024} KB of source") try: tree = ast.parse(text) except SyntaxError as exc: return Refused("syntax", f"line {exc.lineno or 0}: {exc.msg}") for node in ast.walk(tree): kind = type(node).__name__ if kind not in ALLOWED_NODES: return Refused("refused_construct", f"line {getattr(node, 'lineno', 0)}: this sandbox does not run " f"{_english(kind)}") if isinstance(node, ast.Attribute) and not _attr_ok(node.attr): return Refused("refused_attribute", f"line {getattr(node, 'lineno', 0)}: the attribute " f"'{node.attr}' is not available inside a script view") # ⛔ A NAME may not be private either. `_` prefixed names are the runner's own, and a # script that could bind one could shadow the machinery it runs on top of. if isinstance(node, ast.Name) and node.id.startswith("_"): return Refused("reserved_name", f"line {getattr(node, 'lineno', 0)}: names starting with an " f"underscore are reserved by the sandbox") if isinstance(node, (ast.FunctionDef, ast.arg, ast.ExceptHandler)) and str( getattr(node, "name", None) or getattr(node, "arg", "") or "").startswith("_"): return Refused("reserved_name", f"line {getattr(node, 'lineno', 0)}: names starting with an " f"underscore are reserved by the sandbox") if isinstance(node, ast.keyword) and str(node.arg or "").startswith("_"): return Refused("reserved_name", f"line {getattr(node, 'lineno', 0)}: keyword arguments starting with " f"an underscore are reserved by the sandbox") return None _ENGLISH = { "Import": "an import", "ImportFrom": "an import", "ClassDef": "a class definition", "With": "a with block", "AsyncWith": "a with block", "AsyncFor": "an async loop", "AsyncFunctionDef": "an async function", "Await": "await", "Yield": "yield", "YieldFrom": "yield from", "Global": "a global statement", "Nonlocal": "a nonlocal statement", "Match": "a match statement", } def _english(kind): return _ENGLISH.get(kind, f"a {kind} expression") # ══════════════════════════════════════════ LAYER 2 — the namespace, and the child program ═════ #: The builtins a script may reach, BY NAME. Everything else is a `NameError` in the child. #: ⛔ THE ABSENCES, again, are the policy: `__import__` `open` `eval` `exec` `compile` `input` #: `getattr` `setattr` `delattr` `globals` `locals` `vars` `dir` `type` `super` `object` `help` #: `exit` `breakpoint` `memoryview` `id`. Several are harmless on their own; each one is a step #: on a published escape, and none has ever been asked for by a script that shapes rows. #: ⚠ THE EXCEPTION CLASSES ARE HERE BECAUSE `try:` IS, and a `try` block whose `except` clause #: cannot name what it catches is a construct that reads as supported and is not. They are safe #: for the same reason everything else is: `Exception.__subclasses__` needs an attribute this #: sandbox does not allow, so a class object in the namespace is a leaf, not a doorway. SANDBOX_BUILTIN_NAMES = ( "abs all any bool bytes callable chr dict divmod enumerate filter float frozenset hash hex " "int isinstance issubclass iter len list map max min next oct ord pow range repr reversed " "round set slice sorted str sum tuple zip True False None " "Exception ValueError TypeError KeyError IndexError ZeroDivisionError ArithmeticError " "AttributeError StopIteration OverflowError" ).split() #: The literal program the child runs. It is TEXT rather than a module because the child must #: import nothing from this repo: a module would be found on `sys.path` and would drag `core` #: with it, which is exactly the second store path W1 forbids. #: ⚠ Every name in here is underscore-prefixed and layer 1 refuses a script from binding one, so #: the runner's own machinery cannot be shadowed by the source it executes. _RUNNER = r''' import json as _json, os as _os, sys as _sys _pay = _json.loads(open(_sys.argv[1], "r", encoding="utf-8").read()) _out = {"ok": False, "code": "not_run", "message": "the script did not run", "stdout": "", "spec": None, "caps": {"wallClock": True, "memory": False, "cpu": False}} # ── the caps this platform can actually apply, reported either way (standing rule 1) ────────── try: import resource as _res _mem = int(_pay["memoryBytes"]) _res.setrlimit(_res.RLIMIT_AS, (_mem, _mem)) _out["caps"]["memory"] = True _cpu = int(_pay["cpuSeconds"]) _res.setrlimit(_res.RLIMIT_CPU, (_cpu, _cpu)) _out["caps"]["cpu"] = True except Exception: # `resource` is POSIX only. The wall-clock kill in the parent still applies, and `caps` says # which of the three held, never a silent partial. pass _printed = [] _spent = [0] _LIMIT = int(_pay["maxStdout"]) def _print(*_a, **_k): _text = (_k.get("sep") or " ").join(str(_x) for _x in _a) + (_k.get("end") or "\n") _room = _LIMIT - _spent[0] if _room > 0: _printed.append(_text[:_room]) _spent[0] += len(_text) class _Refusal(Exception): """The SANDBOX refusing, as distinct from the SCRIPT failing. Without its own class these arrive as `ValueError`, indistinguishable from a `ValueError` the script raised itself, and the answer then says "refused" about an ordinary bug in the tenant's own code. Two different facts, two different codes. """ _emitted = [] def _emit(_spec): if not isinstance(_spec, dict): raise _Refusal("emit() takes a view spec, which is a dictionary") if _emitted: raise _Refusal("emit() was already called; a script view emits exactly one view") _emitted.append(_spec) _rows = _pay["rows"] _fields = _pay["fields"] _bound = _pay["table"] def _scoped_table(_table=None): if _table is not None and str(_table) != _bound: raise _Refusal( "this script view is bound to the database '" + _bound + "' and asked for '" + str(_table) + "'. A script view reads its own database only") return [dict(_r) for _r in _rows] def _scoped_fields(): return [dict(_f) for _f in _fields] _ns = {"__builtins__": {_n: __builtins__[_n] if isinstance(__builtins__, dict) else getattr(__builtins__, _n) for _n in _pay["builtins"]}} _ns["__builtins__"]["print"] = _print _ns["print"] = _print _ns["emit"] = _emit _ns["scoped_table"] = _scoped_table _ns["scoped_fields"] = _scoped_fields _ns["table"] = _bound try: exec(compile(_pay["source"], "