Status: specification for small-python on PyPI. It replaces the in-process
CPython extension (small_python._core), which this design retires.
- Run untrusted code, including code written by a language model, from CPython. Nothing the engine does, including a memory-safety bug triggered on purpose, may crash, hang, or take over the CPython process.
- Give engine code exactly the host functions, modules, and directories the embedder grants, and nothing else.
- Keep the embedding API of the retired extension where the design allows, so that existing code keeps working.
- Ship one wheel per platform that works on every supported CPython.
Not goals: sharing Python objects with engine code, running engine code concurrently in one runtime, or protecting the engine from its host.
Each Runtime starts one spy-worker process (protocol.md) and speaks the
worker protocol over its stdin and stdout. The package is pure Python plus the
worker executable; it links no C into CPython.
CPython process spy-worker process
+----------------------------+ pipe +------------------------+
| small_python.Runtime | ---requests--> | SPYContext |
| host functions, loader, | <--requests--- | engine, std modules, |
| output, Object proxies | | handle table |
+----------------------------+ +------------------------+
Package layout:
small_python/
__init__.py Runtime, Object, Error, WorkerError, Timeout, versions
_wire.py value codec and message framing (protocol.md §3–§4)
_runtime.py the parent side: turns, handles, host callbacks
_runtime.pyi types
spy-worker the worker executable for this platform (.exe on Windows)
py.typed
The wheel is tagged py3-none-<platform>: it holds no extension module, so
one wheel serves CPython 3.10 and later. Wheels are built for manylinux and
musllinux (x86_64, aarch64), macOS (x86_64, arm64), and Windows (x86_64). An
sdist builds the worker with the system C compiler, or on Windows with
MinGW-w64 GCC, since MSVC lacks C11 complex numbers. Windows hosts have no
open() in the engine (manual.md: files need a POSIX host).
from small_python import Runtime, Object, Error, WorkerError, TimeoutRuntime(*, std=True, step_limit=0, memory_limit=0, timeout=None,
output=None, loader=None, message_limit=64 << 20)Starting a runtime starts its worker and waits for its INIT reply. If the
worker cannot start, the constructor raises WorkerError.
| Argument | Meaning |
|---|---|
std |
Register the standard modules (sys and gc). |
step_limit |
Evaluation steps per top-level eval or exec; 0 is unlimited. Exceeding it raises RuntimeError in the engine. |
memory_limit |
Bytes the engine may hold; 0 is unlimited. Exceeding it raises MemoryError in the engine. |
timeout |
Seconds a single operation may spend running in the worker, or None. Exceeding it terminates the worker and raises Timeout. |
output |
Callable receiving printed text; None writes to sys.stdout. |
loader |
Callable mapping a module name to source str, or None when there is none. |
message_limit |
Largest message in bytes, either direction. |
Negative limits, a non-positive timeout, or a non-callable output or
loader raise ValueError or TypeError before the worker starts.
Running code:
eval(source, filename="<string>")returns the value of the last expression statement, orNone.exec(source, filename="<string>")returnsNone.call(function, /, *args, **kwargs)calls a global name (str), anObject, or a Python callable (which is first passed into the engine as a native function), and returns the converted result.
Globals:
rt[name]reads a global, falling back to builtins;KeyErrorwhen absent.rt[name] = valuesets a global;TypeErrorwhenvaluedoes not convert.del rt[name]deletes a global;KeyErrorwhen absent.
Extending:
define(name, function)binds a callable as the globalname; returnsfunction.function(func=None, *, name=None)is the decorator form.register_module(name, members=None, source=None):import namemakes a module whose attributes aremembersand then runssourcein it.mount(directory, path=None, *, read_only=False): letopen()reachdirectoryat engine pathpath(default: its resolved host path). The directory is resolved in the parent;Errorif it does not exist.
Lifetime:
collect()runs the cycle collector and returns the number freed.close()ends the worker and drops every host reference. Idempotent.closedisTrueafterclose()or once the worker is lost.- The runtime is a context manager that closes on exit, and
__del__closes it as a last resort.
Values cross by copy (protocol.md §4):
| Python | Engine | Notes |
|---|---|---|
None, bool, int, float, complex |
same | any size int |
str, bytes, bytearray |
same | copied |
list, tuple, dict, set, frozenset |
same | copied; nesting over 200 levels raises RecursionError (an Error of that type from the engine) |
| callable | native function | keeps the callable until the runtime closes |
Object |
the engine value | from another runtime: ValueError |
| anything else | — | TypeError |
Python subclasses of these types convert as their base type (an IntEnum
member arrives as an int, an OrderedDict as a dict). From the engine,
only values whose type is exactly one of the first three rows come back by
content (protocol.md §4); everything else, subclasses included, is an
Object.
A proxy for an engine value held by the worker. It supports calling,
attribute get and set, item get and set, len, in, iteration, next,
bool, hash, the six comparisons, repr, str, and dir; each is one
OP (protocol.md §7.3), and a call is one CALL. Protocol failures raise what Python expects:
AttributeError, KeyError (with the key), IndexError, TypeError, and
StopIteration at the end of iteration; other engine exceptions raise
Error. Comparing with a value that does not convert is unequal for == and
raises TypeError for ordering. Objects of different runtimes are unequal.
Deleting attributes or items raises TypeError.
When an Object is collected its handle is queued and released with the next
message to the worker. Once its runtime is closed, every operation but repr
raises; once the runtime is lost, WorkerError.
Error(Exception) reports an engine exception that escaped. Attributes:
type, message, exception (an Object, or None), filename, lineno;
str(e) is "type: message". If it came from a host function's exception
that engine code did not handle, that Python exception is its __cause__; a
BaseException that is not an Exception (KeyboardInterrupt, SystemExit)
is re-raised itself instead.
WorkerError(RuntimeError) reports a lost worker (protocol.md §8). Its
message names the cause; returncode is the worker's exit status (negative
for a signal) or None when it was terminated by the parent.
Timeout(WorkerError, TimeoutError) reports an expired timeout.
Misuse raises ordinary exceptions: RuntimeError for a closed runtime, for
eval/exec/close inside a callback of the same runtime, and for use from
another thread while the runtime is busy.
A host function receives converted positional and keyword arguments and
returns a value that converts. While it runs, the worker waits; the host
function may call call, rt[...], define, and Object operations on the
same runtime (nested requests), but not eval, exec, or close.
An exception from a host function becomes an engine exception
(protocol.md §7.4): the nearest engine builtin class by name in the
exception's method resolution order, with the same args when they convert,
else its message; otherwise RuntimeError. An escaping Error from the same
runtime re-raises the original engine exception.
The loader follows the same rules. An exception from output is reported
with sys.unraisablehook, and the engine's print raises OSError. The loader receives the engine's
probe names: for import pkg.mod it is asked for pkg.mod.__init__ (a
package), then pkg.mod; returning source for pkg.mod.__namespace__ makes a
namespace package. The parent answers the __init__ and __namespace__
probes with None unless the loader returns source for them, so a plain
dict.get loader works.
A runtime belongs to one thread at a time: the thread that started its
current top-level operation, until that operation returns. A call from any
other thread meanwhile raises RuntimeError. Separate runtimes are
independent and may be used from different threads at once. Releasing
collected objects is safe from any thread.
The worker runs in its own process group, so Ctrl-C reaches only CPython. A
KeyboardInterrupt that arrives while CPython waits for the worker terminates
the worker, marks the runtime lost, and propagates. A KeyboardInterrupt
inside a host function is a host exception (§3.4).
The process boundary is the isolation boundary. Inside it, the engine
confines code to its grants (docs/manual.md); outside it, the parent:
- starts the worker as in
protocol.md§9: empty environment, working directory/, no extra descriptors, its own process group, death with the parent; - bounds every read by
message_limit, validates every message before acting on it, and terminates the worker on any violation; - bounds time per operation with
timeout; - never evaluates, unpickles, or imports anything the worker sends.
A worker that has been taken over by an exploit would run with the user's
operating-system privileges. Containing that is the job of an OS sandbox,
which the worker applies to itself, without privileges, immediately before it
first runs engine code (its first EVAL, EXEC, CALL, or OP; see
protocol.md §9). Mounts are fixed from then on: a later mount raises
Error of type PermissionError.
| Platform | Layer |
|---|---|
| Linux | PR_SET_NO_NEW_PRIVS |
| Linux | Landlock (kernel 5.13 and later): files only beneath the mounted directories, read-only where mounted so; no TCP (kernel 6.7 and later) |
| Linux x86_64, aarch64 | seccomp: only the system calls the engine makes (I/O on open descriptors, memory, clocks, signals to itself, exit, and file calls when directories are mounted); any other fails with EPERM; a foreign architecture's calls kill the worker |
| macOS | sandbox_init profile: deny by default; allow metadata reads, and reading (and writing, unless read-only) beneath the mounted directories |
A layer the kernel lacks is skipped; one the kernel refuses ends the worker. Other platforms, Windows among them, have no OS layer; for hostile code there, also run the parent in a container or VM.
Unchanged: Runtime methods and arguments, conversion, Object operations,
Error attributes and causes, callback rules.
Changed:
- Engine code runs in another process; a crash raises
WorkerErrorinstead of killing CPython. - New arguments
memory_limit,timeout,message_limit; new exceptionsWorkerErrorandTimeout. - Engine subclasses of the convertible types (a
dictsubclass defined in engine code) arrive asObjectinstead of being copied. register_moduleconvertsmemberswhen called, so an unconvertible member raisesTypeErrorthere rather than at import.- Engine values nested too deeply to convert raise
Errorof typeRecursionErrorrather than Python'sRecursionError. - Overlapping use from two threads is always detected.
- Each operation is a message round trip and each runtime a process. On an
Apple M-series Mac with
-O2, a round trip (eval("1")) takes about 15 µs and starting and closing a runtime about 4 ms. SPY_VERSIONis read from the bundled worker (spy-worker --version) on first access.
- The in-process extension. Fastest, and what this replaces: any engine bug is a CPython crash.
forkwithoutexec. Cheaper to start, but forking a threaded CPython is unsafe, and the child would inherit every secret of the parent's memory.- WebAssembly in CPython (as the JavaScript package does). Strong isolation without an OS sandbox, but it needs a WebAssembly runtime as a dependency and runs the engine several times slower. It remains the candidate for platforms without a usable OS sandbox.
- A pool of reusable workers. Faster runtime creation, but a reused process can carry state from earlier untrusted code. A future pool must only hold workers that have never run code.
The suite runs against the built worker and must cover, besides the existing API tests:
- a worker killed by
SIGSEGV,SIGABRT, andSIGKILLmid-operation raisesWorkerErrorwith the status, and the runtime stays lost; - a parent fed malformed messages by a fake worker (unknown kind or tag,
truncated or trailing bytes, a length out of range, a count beyond the
message, deep nesting, invalid UTF-8, non-canonical
I, an unknown function id, a parent-only tag, a malformed error, an ended stream) terminates it and raisesWorkerErrornaming the violation, without hanging or raising anything else; a silent fake worker times out; timeoutonwhile True: pass, and its exclusion of host-function time;memory_limitraising a catchableMemoryErrorand the runtime working afterwards;- nested callbacks several levels deep in both directions;
- handle release: creating and dropping many objects does not grow the worker's table;
KeyboardInterruptwhile waiting terminates the worker;- the worker exits when the parent is killed;
- the worker is sandboxed once code has run (
NoNewPrivsandSeccompin/procon Linux,sandbox_checkon macOS), andmountis refused afterwards.