Getting Started with Rust
For running untrusted code, use monty-pool:
cargo add monty-pool monty-types tokio --features tokio/macros,tokio/rt-multi-thread,monty-types/tzdb
Workers are monty CLI binaries: build one with cargo build -p monty-runtime from the
Monty repository, or install it from PyPI as
pydantic-monty-runtime.
The in-process interpreter is the monty crate:
cargo add monty monty-types --features monty-types/tzdb
| Crate | What it is |
|---|---|
monty | The core interpreter: Python parser, bytecode VM, sandbox |
monty-types | Shared boundary types: values, exceptions, OS calls, limits |
monty-fs | Host-side filesystem mounts |
monty-runtime | The monty binary: REPL, file runner, subprocess worker |
monty-pool | Elastic pool of crash-isolated worker subprocesses |
monty-proto | The protobuf wire protocol between pool parents and workers |
monty-type-checking | Type checking, powered by ty |
monty-typeshed | Trimmed typeshed stubs for Monty’s stdlib subset |
Host-side crates depend on monty-types, not on monty, so the interpreter is not linked into your parent process
at all; monty-proto links it only with its worker feature, which the workers enable.
The Rust API pages document monty, monty-pool, monty-types, monty-fs, monty-proto and
monty-type-checking.
Custom OS handlers can use monty_types::normalize_virtual_path after validating the received path.
It shares the mounts’ lexical POSIX normalization; see filesystem callbacks
for validation order and access checks.
Build inputs with MontyObject constructors and inspect them with as_ref().
The builders on CallArgs and NamedValues
accept owned values; their iterators return ObjectRef views.
Representation APIs under monty_types::unstable
carry no API compatibility guarantee.
monty-poolruns the interpreter only inmontyworker subprocesses. Use this for untrusted code. It is the same engine the Python and JavaScript packages are built on.montyis the in-process interpreter. Use it when you control the code being run, or when subprocesses are impossible.
A Monty process can never be made fully crash-proof against memory errors — a stack-overflow abort or an allocator abort
takes the whole process down.
That is the entire reason monty-pool exists: the crash kills a worker, the pool notices and replaces it, and your
process is untouched.
use std::time::Duration;
use monty_pool::{Pool, PoolConfig, PoolError, ReplConfig, TurnEvent, on_print_sync};
#[tokio::main]
async fn main() -> Result<(), PoolError> {
let mut config = PoolConfig::subprocess("path/to/monty");
// no timeouts by default; set one before running untrusted code
config.request_timeout = Some(Duration::from_secs(30));
let pool = Pool::new(config).await?;
let mut session = pool.checkout(&ReplConfig::default()).await?;
let mut on_print = on_print_sync(|_stream, text| print!("{text}"));
// session state persists between feeds on the same checkout
session.feed("x = 21", vec![], vec![], false, &mut on_print).await?;
let event = session.feed("x * 2", vec![], vec![], false, &mut on_print).await?;
match event {
TurnEvent::Complete(value) => println!("result: {value:?}"), // Int(42)
// other events are suspensions (external function calls, OS calls,
// name lookups, futures) answered with `resume` / `resume_name_lookup`
// / `resume_futures` to continue the turn
other => println!("suspended: {other:?}"),
}
// return the worker to the pool for reuse by the next checkout
session.finish().await?;
Ok(())
}
Checkout::feed takes the code, inputs (host values bound as sandbox globals), per-feed filesystem mounts
(MountSpec), a skip_type_check flag and a print sink;
Checkout::feed_with_cwd also sets the sandbox’s
working directory, which otherwise defaults to the first mount.
It returns a TurnEvent:
TurnEvent | Meaning | Answer with |
|---|---|---|
Complete(value) | The snippet finished | nothing; feed again |
FunctionCall { object_id, .. } | The sandbox called a host function, or with object_id (the wrapper’s uuid) Some, a method on a host object or a host class’s construction (arriving as __call__) | Checkout::resume |
OsCall { .. } | The sandbox performed an OS operation | Checkout::resume_from_mounts or resume |
NameLookup { name, object_id } | The sandbox read an undefined name, or a lazy attribute of a host object when object_id is Some | Checkout::resume_name_lookup |
ResolveFutures { .. } | Every sandbox task is blocked on host futures | Checkout::resume_futures |
Every suspension variant also carries position, a SourceRange locating the suspending expression: the call, the
name, or the await the main task is blocked on.
A Checkout dropped without finish() kills its worker rather than returning it — mid-execution state cannot be
trusted back into the pool.
ReplConfig carries the per-session sandbox ResourceLimits and type-checking options.
A host module’s .pyi reaches the checker through the import_stubs OS call below.
Checkout::worker_id() identifies the worker within its pool, independent of PID reuse and transport;
it returns None after the worker is released or discarded.
Checkout::pid() is the subprocess-only OS diagnostic.
Checkout::dump and Checkout::restore snapshot and restore a session, including onto a different worker or machine.
Restore only unmodified snapshots whose provenance and integrity the caller has established;
see snapshot security.
- Crash isolation — a segfault, stack-overflow abort or allocator abort in the sandbox becomes
PoolError::Crashed; the pool discards the worker and spawns a replacement. - Hard timeouts — a parent-side deadline kills any worker whose turn exceeds
request_timeout(PoolError::Timeout), catching hangs the in-sandbox limits cannot see. With amax_feed_durationormax_turn_durationbudget the deadline also enforces that from outside the child, each plus its own grace (feed_duration_limit_grace,turn_duration_limit_grace).PoolConfig::subprocesssets neitherrequest_timeoutnorcheckout_timeoutby default; setrequest_timeoutyourself for untrusted code. - Suspension limits — the pool counts external calls, OS calls, name lookups and future-resolution turns against
ResourceLimits::max_suspensions. The first suspension over the limit ends the feed with an uncatchableRuntimeError. - Untrusted children — every frame from a possibly compromised worker is validated; wire decoding never panics, and a protocol violation discards the worker.
- Worker recycling —
max_checkouts_per_workerbounds the impact of a slow leak.
Runtime errors inside the sandbox (PoolError::Runtime) are not crashes: the worker and its session stay alive and
usable.
Memory and time limits return PoolError::Runtime with a MemoryError or TimeoutError, but
no guarantees hold about heap state afterwards.
max_feed_duration and max_turn_duration both restart, so a later feed runs against a heap you can no longer
trust.
Finish the checkout and take a fresh one.
max_suspensions also returns PoolError::Runtime, but leaves the session consistent.
Later feeds run until they suspend; the count remains spent.
Custom protobuf transports should decode protocol messages through
decode_frame or FrameReader.
Both manage the per-frame allocation budget automatically, including cleanup on errors or unwinding.
Raw prost::Message::decode calls fail if they attempt an allocation without a frame budget.
Protocol repeated fields and byte buffers use BudgetVec; convert standard vectors with .into() and recover them with .into_inner() without copying.
Reference containers use WireIndexes, WireNodePairs and WireNamedTuple, with domain NodeIds rather than raw protobuf integers.
Host construction and cloning do not use the decode budget.
See the monty-proto README
for the budget’s scope.
PoolConfig::subprocess spawns local monty subprocess children over framed stdio.
These are the poolable workers: prewarmed, reused across checkouts, replaced on crash.
PoolConfig::websocket dials a remote child over ws:///wss://.
Those workers are single-use, never prewarmed or returned to the pool, and isolation becomes the remote host’s
responsibility.
See the security model before using it.
MontyRun parses and compiles code once; run executes it with input values and returns the value of the final
expression as a MontyObject:
use monty::MontyRun;
use monty_types::{CompileOptions, MontyObject, PrintWriter, ResourceTracker};
let code = r#"
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
fib(x)
"#;
let mut runner = MontyRun::new(code.to_owned(), "fib.py", vec!["x".to_owned()], CompileOptions::default()).unwrap();
let result = runner.run(vec![MontyObject::int(10)], ResourceTracker::default(), PrintWriter::Stdout).unwrap();
assert_eq!(result, MontyObject::int(55));
CompileOptions also carries source_scan_threshold, the source length in bytes
above which a pre-parse nesting scan runs (4 KiB by default); see source nesting depth.
Errors come back as MontyException, with a traceback matching what CPython would produce.
PrintWriter controls where print() output goes: Stdout, Disabled, or collected — into a String, or into a
CollectedStreams buffer whose entries() label each run stdout or stderr.
use std::time::Duration;
use monty::MontyRun;
use monty_types::{CompileOptions, PrintWriter, ResourceLimits, ResourceTracker};
let limits = ResourceLimits {
max_memory: Some(10 * 1024 * 1024),
max_feed_duration: Some(Duration::from_millis(20)),
..ResourceLimits::default()
};
let mut runner = MontyRun::new("while True: pass".to_owned(), "spin.py", vec![], CompileOptions::default()).unwrap();
let err = runner.run(vec![], ResourceTracker::new(limits), PrintWriter::Stdout).unwrap_err();
assert!(err.to_string().contains("feed time limit exceeded"));
Both run and start answer clock calls from the system clock and seed unseeded generators from OS entropy.
Sleeps are capped at ten seconds per call.
run waits inline; start returns RunProgress::OsCall with SystemSleep or AsyncSystemSleep.
Wait for the requested delay and answer with None, or a future for asyncio.sleep():
use monty::MontyRun;
use monty_types::{CompileOptions, MontyObject, PrintWriter, ResourceTracker};
let code = "from datetime import date\ndate.today().year";
let mut runner = MontyRun::new(code.to_owned(), "today.py", vec![], CompileOptions::default()).unwrap();
let year = runner.run(vec![], ResourceTracker::default(), PrintWriter::Stdout).unwrap();
assert!(year.as_ref().as_int().is_some_and(|y| y >= 2026));
with_os_policy configures each operation.
DateTimeSource::Fixed freezes the clock, SandboxTimeZone::Fixed sets the local UTC offset, SandboxTimeZone::named takes an IANA zone name, and
RandomStart::Seed seeds random for reproducible runs.
named needs a tz database, which is a monty-types feature: tzdb reads the OS copy (TZDIR, /usr/share/zoneinfo)
and falls back to a bundled one, tzdb-bundled uses only the bundle.
Without either, every name is unknown.
SleepMode::Zero skips waits; SleepMode::System(max) sets their cap.
ProcessTime::Zero (the default) keeps time.process_time() at zero; ProcessTime::Elapsed reports the session’s
execution time.
CallHost delegates through RunProgress::OsCall under start, or raises NotImplementedError under run:
use monty::MontyRun;
use monty_types::{
OsPolicy, CompileOptions, DateTimeSource, MontyObject, PrintWriter, ProcessTime, RandomSeed, RandomStart,
ResourceTracker, SandboxTimeZone, SleepMode,
};
let calls = OsPolicy {
datetime: DateTimeSource::Fixed { unix_seconds: 1_700_000_000, microsecond: 0 },
timezone: SandboxTimeZone::Fixed { offset_seconds: 0, name: Some("UTC".to_owned()) },
sleep: SleepMode::Zero,
process_time: ProcessTime::Zero,
random_start: RandomStart::Seed(RandomSeed::Int(42.into())),
};
let code = "import random, time\nfrom datetime import date\ntime.sleep(3600)\n(date.today().year, random.random())";
let mut runner = MontyRun::new(code.to_owned(), "fixed.py", vec![], CompileOptions::default())
.unwrap()
.with_os_policy(calls);
let result = runner.run(vec![], ResourceTracker::default(), PrintWriter::Stdout).unwrap();
// CPython: random.seed(42); random.random()
assert_eq!(result, MontyObject::tuple([MontyObject::int(2023), MontyObject::float(0.6394267984578837)]));
Every pool session takes the same struct as ReplConfig::os_policy (see the clock).
os.urandom() is the one call with no in-process answer: under run it raises NotImplementedError, under start
it pauses for the host (see random).
MontyRun::start returns a RunProgress that pauses whenever the sandboxed code calls a function the host provides.
The host runs the real function and resumes with the result:
use monty::{MontyRun, RunProgress};
use monty_types::{CompileOptions, MontyObject, PrintWriter, ResourceTracker};
let code = "data = get_data(3)\ndata * 2";
let runner = MontyRun::new(code.to_owned(), "main.py", vec!["get_data".to_owned()], CompileOptions::default()).unwrap();
// pass the external function in as an input
let get_data = MontyObject::function("get_data".to_owned(), None);
let progress = runner.start(vec![get_data], ResourceTracker::default(), PrintWriter::Stdout).unwrap();
// execution pauses at the `get_data(3)` call
let RunProgress::FunctionCall(call) = progress else { panic!("expected a function call") };
assert_eq!(call.function_name, "get_data");
assert_eq!(call.args.arg(0).unwrap(), MontyObject::int(3));
// the host computes the result and resumes
let progress = call.resume(MontyObject::int(21), PrintWriter::Stdout).unwrap();
let RunProgress::Complete(result) = progress else { panic!("expected completion") };
assert_eq!(result, MontyObject::int(42));
Async host functions work the same way: FunctionCall::resume_pending continues with a pending future the sandboxed
code can await, and when every task is blocked the run yields RunProgress::ResolveFutures for the host to settle.
When FunctionCall::allow_eager_await is true the call is awaited immediately and no other task can run, so a host that already has the
result can pass it to FunctionCall::resume_eager and skip the ResolveFutures round trip.
Unknown imports suspend with OsFunctionCall::Import and the top-level
name.
The host’s answer becomes the module; dotted imports read subsequent components as attributes.
Hosts usually return a host-backed class instance containing the module’s attributes.
ResumeValue::NotFound and NotHandled raise ModuleNotFoundError;
in-process, OsCall::resume with
ExtFunctionResult::NotFound does the same, as does a run with no host.
A future answer raises RuntimeError at the import.
Before type checking, OsFunctionCall::ImportStubs requests imported host modules the session has not asked about.
Answer with a dict of ModuleStub names to .pyi sources,
using None for missing stubs, or NotHandled for no stubs.
The session retains these stubs in its dumps.
FunctionCall, OsCall, NameLookup and ResolveFutures expose abort, which raises a host-supplied
MontyException uncatchably at the suspension point and unwinds the run with a traceback.
A host driving the interpreter directly must count suspensions and call abort to enforce max_suspensions;
ResourceTracker stores that limit but does not enforce it.
The free function monty::dump serializes a session — idle between feeds (SessionRef::Idle) or suspended mid-run
(SessionRef::Suspended) — together with its script name and type-check state.
Dump::load restores it, in the same process or a different one.
Both this method and direct serde deserialization require unmodified bytes from a trusted, compatible Monty producer.
The caller must establish provenance and integrity; invalid snapshots have no correctness or availability guarantees.
See snapshot security.
use monty::{Dump, MontyRepl, Session, SessionRef, dump};
use monty_types::{CompileOptions, MontyObject, PrintWriter, ResourceTracker};
let mut repl = MontyRepl::new("repl.py", ResourceTracker::default(), CompileOptions::default());
repl.feed_run("x = 40", vec![], PrintWriter::Stdout).unwrap();
// dumping is read-only: the live session can keep feeding
let bytes = dump("repl.py", None, &[], SessionRef::Idle(&repl)).unwrap();
// later, restore and keep going
let Session::Idle(mut restored) = Dump::load(&bytes).unwrap().state else { panic!() };
let result = restored.feed_run("x + 2", vec![], PrintWriter::Stdout).unwrap();
assert_eq!(result, MontyObject::int(42));
MontyRepl— feed code snippet by snippet with state persisting between snippets.monty-fs— mount host directories into the sandbox at virtual paths, with path resolution hardened against escapes. See filesystem access.RunProgress::OsCallandRunProgress::NameLookup— the filesystem/osoperations and undefined-name reads the host intercepts.FunctionCall::object_idandNameLookup::object_ididentify the host receiver for routed calls and lookups, including class construction via__call__. Plain calls and lookups carryNone.