interpretthis 0.3.0

Sandboxed Python AST interpreter for untrusted and LLM-generated code
Documentation
# Threat Model — interpretthis

## Why this document

`interpretthis` executes LLM-generated Python in production pipelines. The threat model is *"what an adversarial or prompt-injected LLM might emit"* — not a generic Python sandbox. The crate's job is structured-output generation under tool injection, not arbitrary code execution.

This file enumerates concrete attacks and points to the code that blocks them so a reader can verify the security posture without reverse-engineering the implementation. For vulnerability disclosure, see [`.github/SECURITY.md`](./.github/SECURITY.md).

## What's blocked at parse / eval time

Every restriction sits in one of four categories. Audit posture differs per category: a parser-rejected construct can't even be modelled in the value graph, while an allowlist-gated construct admits the syntax but stops execution at a vetted boundary. When a new language feature is added, identifying the category up-front is what determines whether the existing blocklists still cover it.

### 1. Parser-rejected

Constructs the parser refuses outright; they never reach the evaluator.

Currently: **none above the rustpython-parser baseline**. We use stock `rustpython-parser`, so all Python 3 syntax parses. AST nodes the evaluator doesn't handle reach a catch-all branch (next category), not a parser error. This is deliberate: every restriction is checked in our code, so adding a parser-side check is fragile (a `rustpython-parser` upgrade can silently lift it) compared to checking in the evaluator where we own the boundary.

### 2. Evaluator-rejected with a clear error

AST nodes that parse, but the evaluator refuses to execute. Most return `InterpreterError::Security` or `InterpreterError::Runtime`; matrix multiplication is the lone `InterpreterError::TypeError`. Prefer `(see CONFORMANCE.md#<anchor>)` on user-facing rejections where an anchor exists.

| Construct | Source location |
|---|---|
| `await` (no coroutine machinery) | `src/eval/mod.rs::eval_expr` `Expr::Await` arm |
| `async def`, `async for`, `async with` | `src/eval/mod.rs::eval_stmt` catch-all |
| `class` keyword arguments (e.g. `metaclass=`) | `src/eval/classes.rs::eval_class_def` keyword-args check |
| Computed class bases (e.g. `class Foo(f()):`) | `src/eval/classes.rs::eval_class_def` base-expr check — only bare-name bases accepted |
| `from module import *` | `src/eval/modules/mod.rs::eval_import_from` star-import branch |
| Relative imports (`from .module`) | `src/eval/modules/mod.rs::eval_import_from` |
| Dotted imports (`import a.b`) | `src/eval/modules/mod.rs::eval_import` |
| Augmented assignment to a slice target | `src/eval/statements.rs::eval_aug_assign` |
| Matrix multiplication (`@`) | `src/eval/operations.rs::apply_binop` MatMult arm |
| Complex `del` attribute / subscript target | `src/eval/delete.rs::delete_target` |

Supported (not in this rejection table): multi-level classes + C3 MRO + `super()`, class decorators (`@property` / `@staticmethod` / `@classmethod` / `@dataclass`), `match` class patterns, eager `yield` / `yield from`, and user-class `with` / `__enter__` / `__exit__` (`src/eval/control_flow.rs::eval_with`). Generator *iterator* protocol (`next` / `send` / `throw` / `close`) remains partial — see [`CONFORMANCE.md`](./CONFORMANCE.md).

### 3. Allowlist-gated

The construct is supported in principle, but execution checks against a vetted whitelist.

- **Imports** — `src/eval/modules/mod.rs::MODULES` is the registry of every shippable stdlib module (including `copy`). The registry IS the allowlist; `is_known_module` reads from it. Any other module raises `ModuleNotFoundError`.
- **Bare-name resolution** — `DANGEROUS_NAMES` at `src/security/names.rs` rejects `eval`, `exec`, `compile`, `getattr`, `setattr`, `delattr`, `__import__`, `globals`, `locals`, `vars`, `dir`, `open`, `file`, `os`, `sys`, `subprocess`, `shutil` even though the parser accepts them as identifiers. Checked in `src/security/validator.rs`.
- **Attribute access** — `BLOCKED_ATTRIBUTES` at `src/security/names.rs` rejects `__class__`, `__bases__`, `__subclasses__`, `__mro__`, `__globals__`, `__code__`, `__closure__`, `__dict__`, `__builtins__`, `__spec__`, `__loader__`. Single-underscore names (`obj._field`) are allowed; only the explicit dunder list is gated.

### 4. Dynamically validated

Runtime caps that don't refuse a construct but bound its impact. Catalogued in the next section.

## What's bounded by resource limits (not blocked outright)

These are intentional DoS controls — the operations themselves are legal in the language subset, but the runtime cuts them off before they hurt.

- **Memory** — `max_memory_bytes`, default 128 MiB (accounted state size).
- **Total operation count** — `max_operations`, default 10 M.
- **While-loop iterations** — `max_while_iterations`, default 100 K.
- **Recursion depth** — `max_recursion_depth`, default 1000 (matches CPython). Enforced at `src/state.rs::enter_call`.
- **Stdout** — `max_stdout_bytes`, default 64 KiB.
- **Wall-clock** — optional `max_execution_time`; checked cooperatively every 100 ops (does not pre-empt a blocked tool future).
- **Collection / string multiply caps** — fixed ceilings on list/string repetition size (`MAX_COLLECTION_SIZE` / `MAX_STRING_SIZE` in `src/eval/operations.rs`), independent of the memory budget.
- **Integer overflow** — arithmetic uses `checked_*` ops where applicable; overflow surfaces as a typed error, not a panic or wrap. Very large integer exponents may take a float fast-path rather than counting ops.

Defaults live in `src/config.rs`; `InterpreterConfig` lets callers tighten or loosen each independently.

## What's relied on (assumptions, not enforced)

- **The categorization in "What's blocked" is the security boundary.** Every new language feature must be classified (parser-rejected / evaluator-rejected / allowlist-gated / dynamically-validated) before it ships and the corresponding blocklists re-audited. Adding a feature without confirming the blocklists still cover the new escape surface is the bug class this section exists to prevent.
- **Tool implementations are trusted code.** Tool errors surface as `InterpreterError::Tool` and are *not* catchable by user-code `try`/`except`. Adding a tool with side effects implicitly extends the trust boundary. Hosts that inject tools own that boundary.
- **Integrity of resumable state is a host responsibility.** This crate serializes interpreter variables with a `STATE_FORMAT_VERSION` prefix and rejects version-mismatched blobs via `InterpreterError::StateFormatSuperseded`. It does **not** sign or encrypt blobs — hosts that persist state across untrusted boundaries should wrap exports (e.g. HMAC-SHA256 + compression) before storage. A tampered or superseded blob must fail closed at restore, not silently mis-deserialize.

## Status notes

`DANGEROUS_NAMES` and `BLOCKED_ATTRIBUTES` remain the security spine for every shipped language feature (classes, decorators, match, generators, `with`, stdlib modules). New surface must re-audit those lists before merge.

Open parity work (not security blockers by themselves) is tracked in [`STATUS.md`](./STATUS.md) / [`CONFORMANCE.md`](./CONFORMANCE.md): user-class dunder slot dispatch, generator iterator methods, full exception MRO, and selected stdlib follow-ups (`strptime`, richer `functools`, …). `contextlib` is not shipped; user-defined context managers via `with` are.

## Cross-reference: `CONFORMANCE.md`

This document is the security-side view of what is rejected at parse / eval time and why each rejection exists in attacker-model terms. The user-side catalogue of what the interpreter does or does not support — across both security and parity dimensions, with stable section anchors that every "not supported" error string in `src/` points at — lives in `CONFORMANCE.md` next to this file. Distinction: this doc explains *why* things are blocked from a security standpoint; `CONFORMANCE.md` is the user-facing catalogue of *what* the interpreter does or does not support. A user reading an `InterpreterError` message follows the `(see CONFORMANCE.md#...)` pointer in the error string to the relevant anchor; a security reviewer auditing the sandbox boundary reads this file.

## Concrete attack → mitigation

| Attack pattern                                              | Where it fails                                                  |
|-------------------------------------------------------------|-----------------------------------------------------------------|
| `__import__('os').system('rm -rf /')`                       | `__import__` in `DANGEROUS_NAMES` (`security/names.rs`)         |
| `().__class__`                                              | `__class__` in `BLOCKED_ATTRIBUTES` (`security/names.rs`)       |
| `().__class__.__bases__[0].__subclasses__()`                | `__class__` and `__bases__` both blocked                        |
| `eval("...")` / `exec("...")` / `compile(...)`              | `eval` / `exec` / `compile` in `DANGEROUS_NAMES`                |
| `getattr(obj, '__globals__')`                               | `getattr` in `DANGEROUS_NAMES`                                  |
| `setattr(obj, 'x', 1)` / `delattr(obj, 'x')`                | `setattr` / `delattr` in `DANGEROUS_NAMES`                      |
| `import os` / `from os import path`                         | `os` not in the `MODULES` registry (`eval/modules/mod.rs`)      |
| `from . import sibling`                                     | relative-import gate in `eval/modules/mod.rs::eval_import_from` |
| `from collections import *`                                 | star-import gate in `eval/modules/mod.rs::eval_import_from`     |
| `class Evil(metaclass=Meta): ...`                           | class-keyword-args gate in `eval/classes.rs::eval_class_def`    |
| `input(...)`                                                | builtin blocked (`eval/functions/builtins.rs`)                  |
| `[1] * 10**9` (huge list repeat)                            | `MAX_COLLECTION_SIZE` in `eval/operations.rs`                   |
| `while True: pass`                                          | `max_while_iterations`                                          |
| `def f(): return f()` (infinite recursion)                  | `max_recursion_depth`                                           |
| Tight Python loop without tools                             | `max_operations` (and optional cooperative wall-clock)          |
| Oversized state import                                      | `MAX_IMPORT_SIZE` in `serialize.rs`                             |
| State-blob from an earlier wire format                      | `STATE_FORMAT_VERSION` mismatch → `StateFormatSuperseded`       |
| State-blob tampering across resume boundaries               | Host responsibility (this crate does not sign blobs)            |

Rows that were previously in this table — `class Evil(SomethingTrusted)`, `@dataclass`, `case SomeClass(x, y)` — are no longer blocked, because the constructs themselves are supported as of Tracks B1/B2/B4. Their security comes from the orthogonal blocklists: any class body that tries to reach `__class__` / `__bases__` / `__subclasses__` is still rejected (`BLOCKED_ATTRIBUTES`); any decorator that tries to call `eval` is still rejected (`DANGEROUS_NAMES`); any `match` arm cannot dereference a blocked dunder. The user-facing construct is fine; the escape primitives are still gated.

## Out of scope

- **Side-channel attacks** (timing, cache-residency, power). The runtime does not promise constant-time behavior for arbitrary user code.
- **Slow but well-formed code.** Tool calls may take arbitrary time; the interpreter does not impose a wall-clock budget on awaited tool work.
- **Adversarial / harmful content in tool outputs.** If a tool returns a string, that string is text — applying policy to it is the upstream LLM/policy layer's job, not the interpreter's.
- **Third-party Python ecosystem CVEs.** The interpreter does not run CPython; it walks `rustpython-parser` AST nodes through its own evaluator, so CPython-specific vulnerabilities don't apply.