Pydantic Monty pauses a running snippet mid-call and resumes it in a different process
Version 0.0.19 starts in microseconds and blocks the filesystem and network by default, but it still can't run a match statement or import itertools.
exec() gives an LLM's code the same access as the process running it
Agent frameworks increasingly let a model write a short Python script to do the actual work, rather than approve one tool call at a time. Pydantic's own case for the pattern is direct: "LLMs work faster, cheaper and more reliably when they write code instead of making sequential tool calls."1 Whatever the model writes is still model output. Nobody reviews it before it runs, and a model that can be prompted into calling the right function can be prompted into calling the wrong one.
Python's own exec() enforces nothing about what that
code is allowed to touch. It runs the string in the calling
process, with the calling process's own permissions.
llm_generated_code = """
import os
print(open("secret.env").read().strip())
"""
exec(llm_generated_code)
# DB_PASSWORD=correct-horse-battery-staple
exec(): the
secret file in the working directory prints in plain text.
No permission check ran, and the process's filesystem access became the model's filesystem access. Guards that patch this from inside the same interpreter, stripping builtins or scanning the syntax tree for banned names, still run the untrusted code in the interpreter that also holds the host's secrets. Closing that gap took a second interpreter, in a second process, that was never given the operation in the first place.
Pydantic built that interpreter in Rust and calls it Monty,
distributed as pydantic-monty for exactly this job:
running untrusted, model-written Python.2
The package on PyPI is at version 0.0.19, released July 24, 2026,
MIT-licensed, and needs Python 3.10 or later on the host.3
Pydantic's own figures put the compiled interpreter at roughly 4.5
megabytes, with about 5 megabytes of memory overhead per
worker.1 Feed it the
identical ten lines:
from pydantic_monty import Monty, MontyRuntimeError
with Monty() as pool:
with pool.checkout() as session:
try:
session.feed_run(llm_generated_code)
except MontyRuntimeError as e:
print(e.display("type-msg"))
# PermissionError: Permission denied: 'secret.env'
feed_run instead of exec().1
open() is not missing here. The error is a
PermissionError, the kind Python raises when a
capability check fails, not the NameError or
ModuleNotFoundError it would raise if the call did
not exist. Monty's default is zero access: "no filesystem, no
network, no environment variables, strict resource
limits."1 The
interpreter is enforcing that default here, not stumbling over an
operation it never built.
A container is the wrong shape for a call this small
The obvious way to isolate that code is a container: a scratch filesystem and network namespace nothing else can see, thrown away when the call ends. It is also not built for a call this small. Pydantic's own comparison table puts Monty's execution at 4.5 microseconds without type checking and 4.8 milliseconds with it turned on, against a Docker container's cold start at roughly 195 milliseconds and a Pyodide WebAssembly runtime at roughly 2,800 milliseconds.1 The project's own README separately clocks a fourth approach, a WASI/Wasmer sandbox reached over a subprocess call, at 66 milliseconds.2 One independent write-up states the container case for agent workloads plainly: Docker, it argues, is "too slow for how AI agents actually work."4
| Approach | Startup latency |
|---|---|
| Monty | 4.5 µs (no type check) |
| Monty | 4.8 ms (type-checked) |
| WASI/Wasmer | 66 ms |
| Docker | 195 ms |
| Pyodide (WASM) | 2,800 ms |
The gap survives Monty's own safety cost. Type checking each snippet before running it adds three orders of magnitude over the untyped path, and the 4.8-millisecond, type-checked figure, the one that describes an actual call, is still roughly 40 times faster than Docker's 195.1
The comparison is almost entirely startup and dispatch overhead, not raw execution speed. Once a snippet is running, Monty's own documentation describes runtime performance as similar to CPython, generally "between 5x faster and 5x slower."2 The numbers above measure how fast the sandbox opens the door, not how fast it runs once you are inside.
Every host function is granted by name, not inherited
Blocking everything by default only works if there is a way back in
for calls a snippet legitimately needs. Monty's route is
external_lookup: a plain dict of host callables,
passed once per call, naming the only functions the sandbox can
resolve for anything it does not already know.2
Pydantic designs Monty around that same default: everything closed
until a name is granted.1
code = """
config = get_config("api_base_url")
try:
open("secret.env").read()
leaked = True
except Exception:
leaked = False
(config, leaked)
"""
result = session.feed_run(
code,
external_lookup={"get_config": lambda key: "https://api.internal/v1"},
)
# ('https://api.internal/v1', False)
The host decided what get_config returns, and Monty
never had to know the function existed until that call was handed
to it. The file read two lines later fails the same way it did in
Fig. 2: not because open() is unresolved, but because
filesystem access is still zero by default, and Monty checks that
default on every call regardless of what has been
granted.1 A function
name missing from external_lookup entirely fails a
different way, with a plain NameError, though nothing
in this snippet takes that path.
A paused call serializes to a few hundred bytes and resumes in another process
Grant a function through feed_run and Monty calls it
synchronously, blocking until the host answers. A second entry
point, feed_start, does something a subprocess or
container sandbox has no equivalent for. Instead of driving
straight to completion, it can stop the instant a snippet reaches
an external call and hand back a snapshot describing exactly where
execution paused.12
The host answers it whenever it is ready.
I ran this against the installed 0.0.19 package myself, against a
loop that calls an unresolved get_weather function
twice:
code = """
total = 0
for city in ["Paris", "Tokyo"]:
total += get_weather(city)
total
"""
with Monty() as pool:
with pool.checkout() as session:
snap = session.feed_start(code)
while not isinstance(snap, MontyComplete):
if isinstance(snap, FunctionSnapshot):
blob = snap.dump() # 549 bytes
break
snap = snap.resume_auto()
# A second, unrelated worker picks the call up.
with pool.checkout() as session2:
restored = session2.load_snapshot(blob)
fake_temps = {"Paris": 18, "Tokyo": 27}
while not isinstance(restored, MontyComplete):
city = restored.args[0]
restored = restored.resume({"return_value": fake_temps[city]})
print(restored.output)
# 45
The pause happened inside one worker process.
snap.dump() returned 549 bytes: opaque state, no live
file descriptor, nothing tying it to the process that produced it.
I loaded those bytes into a second, freshly checked-out worker
Monty treats as unrelated to the first, and answered the paused
call with a plain dict. The loop picked up exactly where it
stopped, ran its second iteration, and returned the total.
Pydantic's own figure for a snapshot's size is "single-digit
kilobytes."1 This one,
at two iterations, landed under one.
Nothing about a container or a bare subprocess offers the same trade. The process holding a paused call has to stay resident, tying up a worker, until an answer arrives. Monty's snapshot lets that worker go back into the pool the moment a call blocks, and the eventual answer can land on whatever worker Monty checks out next.
One code-writing call replaces a loop of tool calls
The payoff shows up across a full agent turn, not one call. Mike
Chambers built a weather agent that writes a short script instead
of calling tools one at a time, using
asyncio.gather() to run two external lookups at once
instead of two sequential round trips. His own framing of the
savings: "One round-trip instead of five. Or ten. Or
twenty."5 Fewer round
trips to the model means less context spent re-describing the
conversation each time.
The restricted language pushes toward this shape on its own.
Simon Willison, testing an earlier build, noticed that an
unsupported construct does not fail silently: the sandbox returns
a specific, typed error the model can read and retry against, the
way it would handle any other exception.6
A killed container returns nothing about why a call never came
back; a NotImplementedError naming the missing
construct is something an agent loop can act on directly.
match statements, generators, and most of the standard library still fail
Running each disputed construct against the installed 0.0.19 package settles most of the maturity question directly.
class Point: # works
def __init__(self, x, y): self.x, self.y = x, y
with open_cm() as v: ... # works (context managers)
match x: # NotImplementedError:
case 1: ... # pattern matching not yet supported
def gen(): # NotImplementedError:
yield 1 # yield expressions not yet supported
import itertools # ModuleNotFoundError: no module 'itertools'
import functools # ModuleNotFoundError: no module 'functools'
feed_run
against the installed package; comments are the actual results.
A class definition and a with block
backed by __enter__/__exit__ both execute
cleanly. As of this release, the README and the
hosted documentation, dated the same day, still listed class
definitions as "support should come soon." The version 0.0.19
release notes themselves list "User-defined classes implementation"
as new in this build.7
The documentation had not caught up to the changelog. Running the
interpreter directly settles which one was current.
A match statement and a generator function still fail
outright, each rejected with an explicit
NotImplementedError naming the missing construct, and
two of the standard library's most ordinary modules,
itertools and functools, are not
importable at all. Third-party packages stay out of the sandbox
entirely: installing one switches Monty to a separate,
embedded-CPython worker mode, a different trade against the
isolation the rest of this piece describes.2
An independent test against an earlier build, version 0.0.3, found roughly the same shape of gaps: no class definitions, no match statements, no context managers, no generators, most of the standard library absent.8 Two of those five are resolved as of 0.0.19. Three are not.
The release log moves fast enough to trust; the missing stdlib is real enough to plan around
Trusting a v0.0.x sandbox with untrusted code is a fair thing to
ask before adopting it. The release cadence argues for patience
paying off. Version 0.0.18, on May 29, 2026, added context
managers, a limited open() builtin, and
garbage-collection improvements. Version 0.0.19, on July 24, added
user-defined classes, a WebSocket transport, and a
feed_start API with resume_auto() for
driving snapshots automatically: two substantial releases inside
three months.7 The
open issue tracker names specific, ordinary problems rather than
staying quiet: async incompatibilities, hangs on CPU-heavy
operations, missing stdlib modules, configuration validation bugs,
error messages that do not yet match CPython's.2
That reads as a project still actively finding its own edges, not
one that has stopped moving.
The same release that added classes also added WebSocket support, meaning the worker pool this piece has been running locally can run behind a network call instead, the same role a remote code-execution service fills, without leaving Monty's own process model.7
Monty earns a place in an agent's code-mode step: short, host-mediated snippets calling granted functions, with no generators or much of the standard library required. Match statements, generators, and most of the standard library still block a script outright, by name, the moment the parser or the import system meets them.7 That list is shrinking release to release, most recently classes and context managers, so it is worth rechecking before ruling Monty out for a workload it cannot handle today.
Sources
- Pydantic · Introducing Pydantic Monty
- pydantic/monty · README and project documentation
- PyPI · pydantic-monty 0.0.19
- Suleiman Tawil · Why AI Agents Are Ditching Docker for Interpreter-Level Sandboxing
- Mike Chambers · Pydantic Monty: a safe way to run LLM-generated code
- Simon Willison · Running Pydantic's Monty Rust sandboxed Python subset in WebAssembly
- pydantic/monty · Release history
- Tim Hopper · Pydantic Monty: A Secure Python Interpreter for AI Agents