| """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) |
|
|