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 clients 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.
Execution limits use wall time by default. CPU mode, Linux only, uses sampled thread CPU time.
The monty crate enables the monty-types/cpu-clock feature for native CPU reads.
ExecutionClock::is_available() checks whether this thread can read the selected clock.
Off Linux a CPU-mode ResourceTracker charges elapsed time instead, so check it before selecting CPU mode directly.
CPU-mode PoolConfig::subprocess pools independently measure child process CPU.
They require a finite PoolConfig::request_timeout as a wall deadline.
Remote deployments enable CPU enforcement in the server’s local subprocess pool; remote clients adopt its clock.
See resource limits for platform support, sampling, fallback, and snapshot compatibility.
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(())
}
Use Checkout::finish to reset a reusable session.
Use Checkout::terminate to discard a subprocess and wait for its exit, including after a cancelled turn.
Only Some(ExitStatus) confirms process exit. None does not confirm physical termination.
Dropping a checkout requests cleanup without waiting for that confirmation.
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.
With PoolConfig::stream_async_calls (on by default for remote pools, off by
default for subprocess pools) and a handler passed to
Checkout::set_streamed_call_handler, calls to async host functions that the
sandbox does not await at once do not suspend: a MontyObject::async_function value, or a name passed to
Checkout::set_async_functions for the feeds that follow.
The worker sends them mid-turn and keeps running, and the turn hands each to the handler in order with prints.
Start the call there, and settle it with resume_futures once ResolveFutures names it.
Each counts against max_suspensions: once that budget is spent, the next call suspends instead and the feed is
aborted there.
Without a handler, or after set_async_functions(None), calls suspend like any other call.
Calls whose results the host has not sent may hold at most a quarter of
PoolConfig::max_decode_bytes of arguments; past that the session ends with a
MemoryError.
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 client-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 worker, 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 workers — 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_workerreplaces a worker afterDEFAULT_MAX_CHECKOUTS_PER_WORKER(1000) checkouts;Nonenever recycles, and withPoolConfig::subprocessSome(1)gives every session a fresh process. Remote checkouts are single-use, so the setting does not apply to them. While a subprocess pool is open, recycled, crashed and discarded workers are replaced in the background untilmin_processesare live again, so checkouts usually find a warm worker.
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 workers over framed stdio.
These are the poolable workers: prewarmed, reused across checkouts, replaced on crash.
PoolConfig::remote reaches a remote worker over gRPC at an http:///https:// URL: one HTTP/2 connection per pool, one MontySession.Session stream per checkout.
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.
PoolConfig::max_decode_bytes can lower the default 1 GiB allocation budget for
each protocol frame from a subprocess or remote worker. Values above 1 GiB do not
raise the ceiling. The budget excludes wire buffers and host-language conversion.
Exhaustion discards the worker without replaying the request.
On the remote transport it also caps each message’s size, clamped between 64 KiB and the 256 MiB frame limit, so a larger message is refused before it is buffered.
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).
A loaded dump keeps the policy it was taken under; call MontyRepl::set_os_policy, or ReplProgress::set_os_policy
for a suspended dump, to apply your own, as pool workers do with the restoring checkout’s policy.
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));
typing.get_type_hints() of a host function or class suspends with
OsFunctionCall::GetTypeHints, naming the function (TypeHintsTarget::Function)
or the class’s id (TypeHintsTarget::Class).
The host’s answer is the call’s result; OsFunctionCall::no_handler_result() answers {} for a host without hints:
use monty::{MontyRun, RunProgress};
use monty_types::{CompileOptions, MontyObject, MontyType, OsFunctionCall, PrintWriter, ResourceTracker, TypeHintsTarget};
let code = "import typing\ntyping.get_type_hints(fetch)['url']";
let runner = MontyRun::new(code.to_owned(), "main.py", vec!["fetch".to_owned()], CompileOptions::default()).unwrap();
let fetch = MontyObject::function("fetch", None);
let progress = runner.start(vec![fetch], ResourceTracker::default(), PrintWriter::Stdout).unwrap();
let RunProgress::OsCall(call) = progress else { panic!("expected an OS call") };
let OsFunctionCall::GetTypeHints(TypeHintsTarget::Function(name)) = &call.function_call else { panic!() };
assert_eq!(name, "fetch");
let hints = MontyObject::dict([(MontyObject::string("url"), MontyObject::type_object(MontyType::Str))]);
let progress = call.resume(hints, PrintWriter::Stdout).unwrap();
assert_eq!(progress.into_complete().unwrap(), MontyObject::type_object(MontyType::Str));
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, whose
ImportArgs carry the dotted name and the module’s search path.
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.
A host with no such module may answer with the module’s .py file instead,
ResumeValue::ModuleFile (in-process,
ExtFunctionResult::ModuleFile), which the sandbox runs as the module.
ImportArgs::candidates lists the files to try, Checkout::read_from_mounts
reads one through the feed’s mounts without resuming, and find_module_file runs the whole search with a read
function the host supplies.
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.
PoolError::Disconnected retains optional remote details with the gRPC status that ended the stream (code and message, plus the server’s RemoteCause when it sent one), or the transport error.
The peer reason is unverified text, and a disconnect does not establish whether the request ran.
The display text includes that reason.
PoolError::Refused identifies a server refusal before a session exists.
Its details provide retryable() and retry_after (a Duration from Retry-After, capped at one hour), with no automatic retry.
PoolError::Connect retains the status or transport error for other connection failures.
PoolError::Configuration identifies invalid or incompatible settings, such as no finite request_timeout for a CPU-mode pool or server.