| """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
|
|
|
|
|
| DEFAULT_TIMEOUT_S = 10.0
|
|
|
|
|
| DEFAULT_MEMORY_BYTES = 512 * 1024 * 1024
|
|
|
|
|
|
|
|
|
| DEFAULT_CPU_SECONDS = 15
|
|
|
|
|
|
|
|
|
| MAX_STDOUT_BYTES = 64 * 1024
|
|
|
|
|
|
|
|
|
| MAX_PAYLOAD_BYTES = 32 * 1024 * 1024
|
|
|
|
|
|
|
| MAX_SPEC_BYTES = 2 * 1024 * 1024
|
|
|
| MAX_SOURCE_BYTES = 128 * 1024
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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")
|
|
|
|
|
| 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")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|
|
|
|
|
|
|
|
|
|
|
|
|
| _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"], "<script view>", "exec"), _ns)
|
| if not _emitted:
|
| _out.update(ok=False, code="no_view",
|
| message="the script finished without calling emit(spec)")
|
| else:
|
| _out.update(ok=True, code="", message="", spec=_emitted[0])
|
| except _Refusal as _e:
|
| _out.update(ok=False, code="refused", message=str(_e)[:400])
|
| except MemoryError:
|
| _out.update(ok=False, code="memory",
|
| message="the script used more memory than a script view is allowed")
|
| except NameError as _e:
|
| _out.update(ok=False, code="refused_name",
|
| message=str(_e)[:200] + ". A script view may use only the names the sandbox "
|
| "provides")
|
| except BaseException as _e:
|
| _out.update(ok=False, code="error",
|
| message=type(_e).__name__ + ": " + str(_e)[:400])
|
|
|
| _out["stdout"] = "".join(_printed)
|
| _out["truncated"] = _spent[0] > _LIMIT
|
| # β THE PROBE: what this child actually had. The PARENT strips it unless it was asked for, so a
|
| # production run never carries it and a gate can still prove that no `core.*` module and no
|
| # secret-shaped environment key was ever inside this process.
|
| # β SNAPSHOTTED AFTER `exec`, and `os` is imported at the TOP so this list does not depend on
|
| # dict-literal evaluation order. The first draft called `__import__("os")` inside this very
|
| # expression, so whether `os` appeared depended on which value Python built first: a probe whose
|
| # contents move with an unrelated edit is a probe a gate cannot assert against.
|
| _out["probe"] = {"modules": sorted(_sys.modules), "env": sorted(_os.environ)}
|
| open(_sys.argv[2], "w", encoding="utf-8").write(_json.dumps(_out, default=str))
|
| '''
|
|
|
|
|
| def _child_env():
|
| """The child's WHOLE environment. An allow-list of two keys, and neither is a credential.
|
|
|
| β NOT `os.environ.copy()` MINUS SOMETHING. A subtractive environment ships every key nobody
|
| thought to name: `HF_TOKEN`, `ODOO_PASSWORD`, `ANTHROPIC_API_KEY` and whatever the next
|
| connector adds. The three names below are here because Python will not start on Windows
|
| without them; on Linux this returns `{}` and the child runs with no environment at all.
|
| """
|
| env = {}
|
| for name in ("SystemRoot", "SYSTEMROOT", "WINDIR"):
|
| if os.environ.get(name):
|
| env[name] = os.environ[name]
|
| return env
|
|
|
|
|
| def run(source, rows, fields, table_key, *, timeout_s=DEFAULT_TIMEOUT_S,
|
| memory_bytes=DEFAULT_MEMORY_BYTES, cpu_seconds=DEFAULT_CPU_SECONDS, probe=False):
|
| """Run ONE script over rows that are ALREADY scoped. Returns C3's envelope plus `caps`.
|
|
|
| {ok, code, message, spec, stdout, truncated, ms, caps: {wallClock, memory, cpu}}
|
|
|
| β THIS FUNCTION NEVER TOUCHES A STORE, AND THAT IS THE POINT: it takes rows. `run_view()`
|
| below is the door that fetches them through C1; keeping the two apart is what lets a gate
|
| drive the sandbox with no tenant, no runtime and no credential anywhere in the process.
|
|
|
| β `caps` IS PART OF THE ANSWER, NOT DEBUG OUTPUT. On Windows `resource` does not exist, so
|
| the memory and CPU limits are NOT applied and this says so. A caller that reports `ok:true`
|
| without reading `caps` is claiming an enforcement that did not happen (standing rule 1).
|
| """
|
| started = time.monotonic()
|
| refusal = check_source(source)
|
| if refusal is not None:
|
| return _refusal(refusal.code, refusal.message, started)
|
|
|
| payload = {"source": str(source or ""), "rows": rows, "fields": fields,
|
| "table": str(table_key or ""), "builtins": SANDBOX_BUILTIN_NAMES,
|
| "maxStdout": MAX_STDOUT_BYTES, "memoryBytes": int(memory_bytes),
|
| "cpuSeconds": int(cpu_seconds)}
|
| try:
|
| blob = json.dumps(payload, default=str)
|
| except (TypeError, ValueError) as exc:
|
| return _refusal("bad_rows", f"these rows cannot be handed to a script ({exc})", started)
|
| if len(blob.encode("utf-8", "replace")) > MAX_PAYLOAD_BYTES:
|
|
|
|
|
| return _refusal(
|
| "payload_too_large",
|
| f"this database's rows are larger than the {MAX_PAYLOAD_BYTES // (1024 * 1024)} MB a "
|
| f"script view can be handed at once. Narrow the view with a filter, or raise the "
|
| f"sandbox payload limit for this deployment", started)
|
|
|
| with tempfile.TemporaryDirectory(prefix="aios-script-") as work:
|
| pay_path = Path(work) / "payload.json"
|
| res_path = Path(work) / "result.json"
|
| pay_path.write_text(blob, encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| argv = [sys.executable, "-I", "-S", "-X", "utf8", "-", str(pay_path), str(res_path)]
|
| try:
|
| done = subprocess.run(
|
| argv, input=_RUNNER, text=True, encoding="utf-8", errors="replace",
|
| cwd=work, env=_child_env(),
|
| stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=timeout_s)
|
| except subprocess.TimeoutExpired:
|
| return _refusal("timeout",
|
| f"the script ran longer than {timeout_s:g} seconds and was stopped",
|
| started)
|
| except OSError as exc:
|
| return _refusal("no_sandbox",
|
| f"a script view could not be started on this deployment ({exc})",
|
| started)
|
|
|
| if not res_path.is_file():
|
|
|
|
|
|
|
| return _refusal(*_died(done), started)
|
| try:
|
| out = json.loads(res_path.read_text(encoding="utf-8"))
|
| except (OSError, ValueError) as exc:
|
| return _refusal("unreadable", f"the script's answer could not be read ({exc})",
|
| started)
|
|
|
| out["ms"] = int((time.monotonic() - started) * 1000)
|
| if out.get("ok"):
|
| spec_error = _check_spec(out.get("spec"))
|
| if spec_error:
|
| out.update(ok=False, code="bad_spec", message=spec_error, spec=None)
|
| if not probe:
|
| out.pop("probe", None)
|
| return out
|
|
|
|
|
| def _died(done):
|
| """`(code, message)` for a child that produced no answer."""
|
| rc = done.returncode
|
| tail = " ".join((done.stderr or "").split())[-300:]
|
| if rc in (-9, 137):
|
| return "memory", "the script was stopped for using too much memory"
|
| if rc in (-24, 152):
|
| return "timeout", "the script used more processor time than a script view is allowed"
|
| return "crashed", f"the script view engine stopped without an answer{': ' + tail if tail else ''}"
|
|
|
|
|
| def _refusal(code, message, started):
|
| return {"ok": False, "code": code, "message": message, "spec": None, "stdout": "",
|
| "truncated": False, "ms": int((time.monotonic() - started) * 1000),
|
| "caps": {"wallClock": True, "memory": False, "cpu": False}}
|
|
|
|
|
| def _check_spec(spec):
|
| """C3: a spec is a DESCRIPTION the client draws. Never HTML, never a script, never a URL.
|
|
|
| β THE CHECK IS ON THE KEYS, NOT ON THE STRING CONTENTS. Scanning values for `<script>` is a
|
| blacklist and would pass `<SCR` + `IPT>`; refusing a spec that carries an `html`, `script`,
|
| `src` or `onclick` key refuses the SHAPE that would let a renderer be talked into executing
|
| something. The vocabulary of legal `kind`s is the ROUTE's business (W36-T37) β this is the
|
| floor every caller gets whether or not the route above it remembers.
|
| """
|
| if not isinstance(spec, dict):
|
| return "the script emitted something that is not a view spec"
|
| try:
|
| blob = json.dumps(spec)
|
| except (TypeError, ValueError):
|
| return "the emitted view spec is not something the client can be sent"
|
| if len(blob.encode("utf-8", "replace")) > MAX_SPEC_BYTES:
|
| return (f"the emitted view spec is over {MAX_SPEC_BYTES // (1024 * 1024)} MB. A view spec "
|
| f"describes a picture; it is not where the rows go")
|
| banned = {"html", "innerhtml", "script", "src", "srcdoc", "href", "style", "onclick", "onload"}
|
| found = sorted(k for k in _keys_of(spec) if str(k).lower() in banned)
|
| if found:
|
| return (f"a view spec may not carry {', '.join(found)}. The client DRAWS a spec, so a "
|
| f"markup or URL key would be a script by another name")
|
| return None
|
|
|
|
|
| def _keys_of(value, depth=0):
|
| """Every key anywhere in a nested spec. Bounded, so a deep structure cannot spin this."""
|
| if depth > 12:
|
| return
|
| if isinstance(value, dict):
|
| for key, sub in value.items():
|
| yield key
|
| yield from _keys_of(sub, depth + 1)
|
| elif isinstance(value, (list, tuple)):
|
| for sub in value:
|
| yield from _keys_of(sub, depth + 1)
|
|
|
|
|
|
|
| def run_view(user, table_key, source, st=None, **kw):
|
| """Fetch through C1 under `user`'s scope, then run the script over what came back (R5).
|
|
|
| ββ THE FETCH HAPPENS IN THE PARENT AND ONLY ROWS CROSS INTO THE CHILD. That is wiring W1
|
| made structural: the child has no runtime to ask, no store handle to open and no credential
|
| to use, so "a script cannot read what its caller cannot read" is not a rule anybody has to
|
| keep β there is no second path for it to be broken through.
|
|
|
| β C1'S THREE EXCEPTIONS ARE ANSWERED, NEVER SWALLOWED. `UnknownTable`, `Denied` and
|
| `Unresolvable` mean three different things to a person; collapsing them into "no rows" is the
|
| silent-empty answer C1 was written to make impossible. `Unresolvable.as_limit()` is handed
|
| through in the words it was raised with β standing rule 1's second sentence, verbatim.
|
| """
|
| import core.perm_scope as perm_scope
|
|
|
| key = str(table_key or "")
|
| try:
|
| rows = perm_scope.scoped_table(user, key, st=st)
|
| fields = perm_scope.scoped_fields(user, key, st=st)
|
| except perm_scope.UnknownTable as exc:
|
| return _refusal("unknown_table", str(exc) or f"there is no database '{key}'",
|
| time.monotonic())
|
| except perm_scope.Denied as exc:
|
| return _refusal("denied", str(exc) or "this account may not read that database",
|
| time.monotonic())
|
| except perm_scope.Unresolvable as exc:
|
| out = _refusal("unresolvable", str(exc), time.monotonic())
|
| out["limit"] = exc.as_limit()
|
| return out
|
| return run(source, rows, fields, key, **kw)
|
|
|