Security Model
Monty is designed to run code that a language model wrote and nobody reviewed. This page describes what that buys you and what it does not.
Monty is a language-level sandbox, not an OS-level one. There is no container, no seccomp filter and no VM. The isolation comes from the interpreter itself: sandboxed code cannot express an operation that touches the host, because the interpreter implements no such operation.
Concretely:
- There is no ambient authority. With no mounts and no host functions configured, the sandbox cannot read a file, read an environment variable, open a socket, or spawn a process. Not “it is blocked” — the capability does not exist in the bytecode VM.
- The interpreter performs no filesystem I/O at all. It suspends with a description of the operation it wants, and a
host component decides what to do about it.
All filesystem code lives in a separate crate (
monty-fs) that worker artifacts do not even link in some builds. - The dangerous modules are absent, not stubbed.
socket,subprocess,multiprocessing,threadingandctypesare not importable, and are also missing from the bundled typeshed, so type checking rejects code that uses them before it runs. eval,exec,compile,globals,localsand__import__do not exist.- No FFI, no C dependencies. Nothing in the sandbox can call into native code.
Everything the sandbox can reach outside itself goes through one of two mechanisms, and both are opt-in per feed.
Names the sandbox does not define are resolved against the external_lookup you supply.
A callable entry becomes a function the sandbox can call: execution suspends, your code runs on the host with your
process’s full authority, and execution resumes with the result.
See host functions.
Monty guarantees that the sandbox reaches nothing you did not hand it. It cannot guarantee that what you handed it is safe. A host function that takes a path and reads it, or takes a URL and fetches it, is an unconstrained filesystem or network primitive that you wrote. Validate arguments in the host function as you would validate any untrusted input.
Host directories are mounted into the sandbox at virtual paths, and only inside a mount can open() and pathlib do
anything.
A separate os= callback handles operations no mount covers.
See filesystem access.
Confinement is structural rather than checked:
- Each mount opens a
cap_std::fs::Dirdescriptor once, at mount time, and every operation runs relative to it —.., symlinks and directories swapped mid-operation cannot reach outside the mount, because no resolution step could leave it. ..and.are collapsed in the virtual namespace before anything touches the filesystem.- Symlinks with absolute targets are refused in read-only and read-write mounts, even when the target is inside the mount; overlay mounts refuse symlinks entirely.
- Null bytes in any path component are rejected.
- Paths handed back to the sandbox (from
Path.resolve(), for example) are virtual paths. A host path never leaks in.
/tmp, /etc, /proc, /dev, ~ and the host working directory are not reachable unless you mount them.
A Monty process can never be made fully crash-proof against memory errors — a stack-overflow abort or an allocator abort
takes down the process it happens in.
The Python package and the native @pydantic/monty binding therefore never run the interpreter in your process: every
session runs in a monty worker subprocess.
The WebAssembly build has no subprocess to use.
In a browser it runs off-thread in a Worker; under Node, which has no global Worker, @pydantic/monty/wasm runs
in-process outright.
See in-process execution.
When a worker dies, the pool observes the death, discards the worker, spawns a replacement, and the call raises
MontyCrashedError (PoolError::Crashed in Rust).
The session is lost; your process is not.
Two more properties of the worker boundary matter:
- Workers spawn with an empty environment (Windows keeps only
SystemRoot), so host secrets are never in a worker’s memory to begin with. - The parent treats every frame from a worker as untrusted input. A worker could in principle be compromised, so wire decoding validates everything, enforces depth and size budgets, and never panics on malformed data. A worker that violates the protocol is discarded.
From Rust, this is why monty-pool is the recommended entry point rather than the in-process
monty crate.
Untrusted code will try to allocate forever or loop forever. See resource limits for the full picture; the security-relevant parts:
max_memorybudgets the bytes a worker requests from its global allocator, not process RSS. Per-allocation overhead, fragmentation, and memory obtained without the allocator sit outside the count. Size the limit with headroom, and keep the worker-level backstop.max_duration_secscounts cumulative execution time, not wall clock. The clock is paused while the sandbox waits on a host function, so a slow host function does not consume the budget. It accumulates across feeds for the life of the session.- The in-sandbox time check only runs at interpreter checkpoints.
Two host-side backstops cover a wedged worker:
request_timeout(a per-turn deadline; a loop of quick host calls resets it) andduration_limit_grace(fires only if the session also setmax_duration_secs). Set bothrequest_timeoutandmax_duration_secsfor untrusted code. Every local pool (Monty,AsyncMonty, JavaScriptMonty.create(),PoolConfig::subprocess) defaultsrequest_timeoutto no deadline; onlyAsyncMontyWebsocketsets one, at 10 seconds. - After a memory or time limit fires, no guarantees are made about heap state or reference counts. Discard the
session rather than continuing to run code in it.
The pool does not do this for you, and the two limits do not even fail alike: a spent
max_duration_secsbudget is cumulative, so every later feed fails with the sameTimeoutError, while after amax_memorytrip a later feed may quietly succeed against a corrupted heap. - Compilation is not charged against the duration budget.
It has its own structural caps (AST nesting, bytecode operand sizes, comprehension nesting,
finallyexpansion), but a host accepting untrusted source should still isolate compilation — as the subprocess and WebAssembly runtimes do.
Host functions, the os= callback, and CallbackFile in the Python OSAccess helper all execute in the host process.
OSAccess backed by MemoryFile objects is fully sandboxed; OSAccess backed by CallbackFile is exactly as
sandboxed as the callback you wrote.
The Rust monty crate and the WebAssembly in-process degrade run the interpreter in the calling process.
The language-level sandbox still holds, but crash isolation does not: an abort in the sandbox is an abort in your
process.
In the browser, a real Worker restores isolation and gives the watchdog a hard kill via Worker.terminate(); where no
Worker exists, the same API degrades to in-process with no preemption.
AsyncMontyWebsocket (Python) and PoolConfig::websocket (Rust) dial a remote worker instead of spawning a local one.
A remote peer need not be a Monty sandbox at all. It may be real CPython with no sandbox, no resource limits and
full host access, relying on deployment isolation — a container or VM per session — rather than on the interpreter.
None of the guarantees on this page transfer across that boundary; they become properties of whatever is running on the
other end.
The worker binary is resolved from the explicit path you pass, then MONTY_BIN, then the bundled platform package, then
PATH.
When running untrusted code, pass the path explicitly rather than letting PATH decide which binary gets to be your
sandbox.
Snapshots are opaque bytes restored into a worker. Treat a snapshot from an untrusted source the way you would treat any untrusted serialized data: restore it into a worker you are willing to lose.
If you are reviewing or contributing to Monty, two files carry most of the weight:
crates/monty/src/heap.rs— the heap and reference counting.crates/monty-fs/src/mount_table.rs— the mount boundary: theDirdescriptor every filesystem operation runs against, withpath_security.rsbeside it holding the virtual-path policy.
Changes to any of them need careful security review.
The repository’s review-security skill
exists for exactly that.