Skip to content

Security: pydantic/monty

docs/security.md

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.

The sandbox has been through two completed rounds of the Hack Monty bounty program, and a third is under way. If you find a way out of it, please open an issue or claim the bounty.

What "secure" means here

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.

!!! note

If you want Monty combined with OS-level isolation, see [Full Monty](server.md), the commercial version of Monty.

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 wall clock and OS entropy are the exceptions: every session reads the clock by default and seeds random from entropy; see the clock and entropy.
  • 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. socket, subprocess, multiprocessing, threading and ctypes are not importable, and are also missing from the bundled typeshed, so type checking rejects code that uses them before it runs.
  • No FFI, no C dependencies. Nothing in the sandbox can call into native code.

The three host-access mechanisms

Everything the sandbox can reach outside itself goes through one of three mechanisms, and all are opt-in per feed.

Host functions

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.

=== "Python"

```python
from pydantic_monty import Monty


def get_price(sku: str) -> float:
    return {'A1': 3.5, 'B2': 12.0}[sku]


with Monty() as pool:
    with pool.checkout() as session:
        result = session.feed_run(
            "get_price('B2') * 2", external_lookup={'get_price': get_price}
        )
        print(result)
        #> 24.0
```

=== "TypeScript"

```ts
import { Monty } from '@pydantic/monty'

function getPrice(sku: string): number {
  return { A1: 3.5, B2: 12.0 }[sku]!
}

await using pool = await Monty.create()
await using session = await pool.checkout()
const result = await session.feedRun("get_price('B2') * 2", { externalLookup: { get_price: getPrice } })
console.log(result) // 24
```

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 objects and classes

[ClassInstance][pydantic_monty.ClassInstance] and [ClassType][pydantic_monty.ClassType] wrappers put a host object, or a host class, in front of the sandbox. Every method call, lazy attribute read and init=True construction the wrapper allows runs your code on the host, with the same authority as a host function. eager_attrs, lazy_attrs and allowed_methods are name allow-lists that default to nothing, and init is a boolean gate that defaults to False; 'all' still skips underscore-prefixed names, and for allowed_methods it exposes only the functions the class defines. Nothing is wrapped for you: a method that returns another object fails conversion unless a convert_value hook wraps it with a policy you chose.

=== "Python"

```python
from dataclasses import dataclass

from pydantic_monty import ClassInstance, Monty


@dataclass
class Account:
    owner: str
    balance: float

    def withdraw(self, amount: float) -> float:
        self.balance -= amount
        return self.balance

    def close(self) -> None: ...


account = Account(owner='ada', balance=100.0)
# the sandbox sees `owner` and `balance`, may call `withdraw`, and cannot call `close`
wrapper = ClassInstance(
    account, eager_attrs={'owner', 'balance'}, allowed_methods={'withdraw'}
)

with Monty() as pool:
    with pool.checkout() as session:
        print(session.feed_run('account.withdraw(30)', inputs={'account': wrapper}))
        #> 70.0
```

=== "TypeScript"

```ts
import { ClassInstance, Monty } from '@pydantic/monty'

class Account {
  constructor(
    public owner: string,
    public balance: number,
  ) {}
  withdraw(amount: number): number {
    this.balance -= amount
    return this.balance
  }
  close(): void {}
}

const account = new Account('ada', 100)
// the sandbox sees `owner` and `balance`, may call `withdraw`, and cannot call `close`
const wrapper = new ClassInstance(account, { eagerAttrs: ['owner', 'balance'], allowedMethods: ['withdraw'] })

await using pool = await Monty.create()
await using session = await pool.checkout()
console.log(await session.feedRun('account.withdraw(30)', { inputs: { account: wrapper } })) // 70
```

See host objects.

Mounts and the os callback

Host directories are mounted into the sandbox at virtual paths, and only inside a mount can open() and pathlib do anything.

=== "Python"

```python
import tempfile
from pathlib import Path

from pydantic_monty import Monty, MountDir

with tempfile.TemporaryDirectory() as tmp:
    Path(tmp, 'notes.txt').write_text('mounted from the host')
    with MountDir(host_path=tmp, virtual_path='/data', mode='read-only') as mount:
        with Monty() as pool:
            with pool.checkout() as session:
                print(session.feed_run("open('/data/notes.txt').read()", mount=mount))
                #> mounted from the host
```

=== "TypeScript"

```ts
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

import { Monty } from '@pydantic/monty'
import { MountDir } from '@pydantic/monty/node'

const tmp = mkdtempSync(join(tmpdir(), 'monty-'))
writeFileSync(join(tmp, 'notes.txt'), 'mounted from the host')
{
  using mount = new MountDir({ hostPath: tmp, virtualPath: '/data', mode: 'read-only' })
  await using pool = await Monty.create()
  await using session = await pool.checkout()
  console.log(await session.feedRun("open('/data/notes.txt').read()", { mount })) // mounted from the host
}
rmSync(tmp, { recursive: true })
```

A separate os= callback handles operations no mount covers: the remaining pathlib operations, os.getenv, os.environ, os.urandom(), and clock and sleep calls configured with 'call_host' (see the clock and waiting). [AbstractOS][pydantic_monty.AbstractOS] is the typed form of that callback; [OSAccess][pydantic_monty.OSAccess] implements it over in-memory files and an environ mapping you supply, and overriding one of its methods replaces one operation. JavaScript has only the callback form, so the TypeScript tab answers the same two operations by hand. The session freezes the clock:

=== "Python"

```python
from datetime import datetime

from pydantic_monty import MemoryFile, Monty, OSAccess

fs = OSAccess(
    [MemoryFile('/config.json', content='{"stage": "test"}')], environ={'STAGE': 'test'}
)
code = """
import json, os
from datetime import datetime
from pathlib import Path
f'{os.getenv("STAGE")} {json.loads(Path("/config.json").read_text())["stage"]} {datetime.now():%H:%M}'
"""

with Monty() as pool:
    with pool.checkout(os_policy={'datetime': datetime(2026, 1, 1, 9, 30)}) as session:
        print(session.feed_run(code, os=fs))
        #> test test 09:30
```

=== "TypeScript"

```ts
import { Monty, NOT_HANDLED } from '@pydantic/monty'

const files = new Map([['/config.json', '{"stage": "test"}']])
const environ: Record<string, string> = { STAGE: 'test' }

function fs(functionName: string, args: unknown[]) {
  if (functionName === 'Path.read_text') return files.get(args[0] as string) ?? NOT_HANDLED
  if (functionName === 'os.getenv') return environ[args[0] as string] ?? null
  return NOT_HANDLED
}

const code = `
import json, os
from datetime import datetime
from pathlib import Path
f'{os.getenv("STAGE")} {json.loads(Path("/config.json").read_text())["stage"]} {datetime.now():%H:%M}'
`

await using pool = await Monty.create()
await using session = await pool.checkout({ osPolicy: { datetime: new Date('2026-01-01T09:30:00Z') } })
console.log(await session.feedRun(code, { os: fs })) // test test 09:30
```

See filesystem access.

Confinement is structural rather than checked:

  • Each mount opens a cap_std::fs::Dir descriptor 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. The sandbox's own working directory is a virtual path, so a relative path is resolved inside the sandbox and reaches a mount as an absolute virtual path.

The clock

date.today(), datetime.now() and the time module's wall clocks (time(), monotonic(), perf_counter() and the conversion functions called without a time) are the only calls that read a clock. All sessions default to the system clock read in UTC. The session's os_policy ([OSPolicy][pydantic_monty.OSPolicy] on [Monty.checkout][pydantic_monty.Monty.checkout] in Python, osPolicy on checkout() in JavaScript, OsPolicy in Rust) configures the instant (datetime) and local zone (timezone) separately:

  • datetime='call_host' delegates the clock calls to your os= handler; unanswered calls raise. Every time module clock arrives as the one OS function time.time, with the asking function's name ('time.monotonic', 'time.localtime', ...) as its argument, so a handler can tell them apart.
  • A fixed instant (datetime.datetime, Date or DateTimeSource::Fixed) freezes the clock.
  • timezone sets the zone that naive datetime.now() and date.today() use and that astimezone(), strftime('%Z') and the time.timezone / time.tzname constants report: 'utc', an IANA name such as 'Europe/London' resolved inside the worker from its tz database, or a fixed offset and name.

A fixed zone uses {'offset_seconds': ..., 'name': ...} in Python, { offsetSeconds, name } in JavaScript, or SandboxTimeZone::Fixed in Rust; a named one is SandboxTimeZone::named(...) in Rust.

Wall-clock time is a weak capability, but it is one: it is what makes elapsed time measurable from inside the sandbox. A fixed instant removes it, and pins time.monotonic() and time.perf_counter() too, since they read the same clock. time.process_time() is governed separately by process_time, which defaults to 'zero'; 'elapsed' deliberately hands elapsed execution time back to the sandbox. Under datetime='call_host' it learns whatever your handler answers; the default [OSAccess][pydantic_monty.OSAccess] handler answers with the host's real clock and zone. See datetime.

Entropy

os.urandom() is the only call that reads entropy from the host. Through the pool the request reaches your os= handler like any other OS call; with no handler it raises RuntimeError. Python's default AbstractOS.urandom() raises MemoryError before allocating when a request exceeds max_urandom_bytes, 1 MiB by default; OSAccess(max_urandom_bytes=...) sets the cap. A custom handler allocates in the host process, outside the worker's memory limit, so it must apply its own cap.

The random module calls the handler only under random_start='call_host'. By default it uses worker OS entropy. For reproducible runs, configure a seed: {'seed': ...} in Python, { seed } in JavaScript, or RandomStart::Seed in Rust. An explicit random.seed(42) overrides this policy. See random.

Waiting

Default system sleeps bypass your os= handler. The sandbox caps each delay at sleep_system_max, ten seconds by default (--max-sleep in the CLI). The pool waits on the calling thread under Monty, or with a timer under AsyncMonty and JavaScript, including browsers. All pools answer asyncio.sleep() with futures, so gathered sleeps overlap and other sandbox tasks can run meanwhile. Manual suspension drivers receive capped system.sleep or system.async_sleep calls. 'call_host' handlers instead receive time.sleep or asyncio.sleep.

A wait costs nothing against the duration limits, which measure execution time and stop while the sandbox is suspended. Each nonzero system sleep costs a suspension, plus another if an async future is awaited later. max_suspensions therefore bounds sleeping loops. The pool also enforces max_total_sleep_secs, charging capped delays before waiting and raising an uncatchable TimeoutError if a sleep would exceed the total, independently of the worker's checks. See resource limits.

With sleep='call_host', your os= handler receives uncapped delays, uncharged to max_total_sleep_secs. The handler decides how long to wait; unanswered calls raise. With sleep='zero', both calls return immediately. [OSAccess][pydantic_monty.OSAccess] caps every wait at its max_sleep, ten seconds unless you say otherwise.

Under [AsyncMonty][pydantic_monty.AsyncMonty] and in JavaScript a 'call_host' handler may be async. Its answer to asyncio.sleep() then runs alongside the sandbox's other tasks, so gathered sleeps overlap; its answer to any other call is awaited before that session resumes, holding up nothing else. The handler's is_async argument says which pool is calling, and [AbstractOS.async_sleep()][pydantic_monty.AbstractOS.async_sleep] uses it to do this by default.

=== "Python"

```python
import time
from typing import Any

from pydantic_monty import NOT_HANDLED, Monty


def host_os(*, name: str, args: tuple[Any, ...], **_future_kwargs: Any) -> Any:
    if name == 'time.sleep':
        time.sleep(min(args[0], 0.05))  # never wait longer than 50ms
        return None
    return NOT_HANDLED


with Monty() as pool:
    with pool.checkout(os_policy={'sleep': 'call_host'}) as session:
        print(session.feed_run('import time\ntime.sleep(30)\n"awake"', os=host_os))
        #> awake
```

=== "TypeScript"

```ts
import { Monty, NOT_HANDLED } from '@pydantic/monty'

async function hostOs(name: string, args: unknown[]) {
  if (name !== 'time.sleep') return NOT_HANDLED
  const seconds = Math.min(args[0] as number, 0.05) // never wait longer than 50ms
  await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
  return null
}

await using pool = await Monty.create()
await using session = await pool.checkout({ osPolicy: { sleep: 'call_host' } })
console.log(await session.feedRun('import time\ntime.sleep(30)\n"awake"', { os: hostOs })) // awake
```

asyncio.sleep arrives the same way, with the delay as its only argument. Its return value is ignored: the sandbox itself produces the result argument of asyncio.sleep() from the await.

Crash isolation

Monty runs in a subprocess, so an unexpected memory error or panic in the interpreter cannot kill the main process; the same design makes it easy to run many Monty interpreters in parallel. The Python package and the native @pydantic/monty binding 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][pydantic_monty.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. Wire decoding validates values and enforces allocation budgets before growing decoded buffers. Generated repeated fields, including empty traceback entries and print segments, share the value decoder's budget. A worker that violates the protocol is discarded. See the protocol allocation budget for what the per-frame budget counts and excludes.

From Rust, this is why monty-pool is the recommended entry point rather than the in-process monty crate.

Resource exhaustion

Untrusted code will try to allocate forever or loop forever. See resource limits for the full picture; the security-relevant parts:

  • max_memory budgets 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_feed_duration_secs counts 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. max_turn_duration_secs bounds the same clock over one host round trip. Neither accumulates across feeds — there is no session-lifetime budget, so bounding what a session costs in total is the host's job.
  • The in-sandbox time check only runs at interpreter checkpoints. Host-side backstops cover a wedged worker: request_timeout (a per-turn deadline; a loop of quick host calls resets it), and one grace per duration limit — feed_duration_limit_grace and turn_duration_limit_grace — each firing only if the session also set the limit it backs. Set request_timeout and at least one duration limit for untrusted code. max_turn_duration_secs does not close the gap named above: its clock also restarts at each host answer, so a loop of quick host calls resets it just as it resets request_timeout. max_feed_duration_secs does bound such a loop, because its clock runs for the whole feed; max_suspensions bounds the number of round trips. Across feeds neither applies — every in-sandbox budget restarts at the next feed, so a session fed repeatedly is bounded only by what the host counts and ends itself. Every local pool ([Monty][pydantic_monty.Monty], [AsyncMonty][pydantic_monty.AsyncMonty], JavaScript Monty.create(), PoolConfig::subprocess) defaults request_timeout to no deadline; only [AsyncMontyWebsocket][pydantic_monty.AsyncMontyWebsocket] sets 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 neither limit stops you: the duration budgets restart at the next feed, and after a max_memory trip a later feed may quietly succeed against a corrupted heap.
  • Compilation of the fed source is not charged against the duration budgets. It has its own structural caps (AST nesting, bytecode operand sizes, comprehension nesting, finally expansion), but a host accepting untrusted source should still isolate compilation — as the subprocess and WebAssembly runtimes do. eval() and exec() compile inside the VM under the same caps, charged against the budget, and their code runs under the limits and host boundary of the code that called them.
  • max_suspensions bounds suspension events per checkout. A snippet can otherwise retry a rejected host call while the duration budgets are paused. Each allowed ClassType(init=True) construction adds an instance-store entry outside max_memory. The pool aborts the first suspension over the limit with an uncatchable RuntimeError.

Where the guarantees weaken

Your own callbacks

Host functions, the methods, lazy attributes and constructors exposed through [ClassInstance][pydantic_monty.ClassInstance]/[ClassType][pydantic_monty.ClassType], the os= callback, and [CallbackFile][pydantic_monty.CallbackFile] in the Python [OSAccess][pydantic_monty.OSAccess] helper all execute in the host process. OSAccess backed by [MemoryFile][pydantic_monty.MemoryFile] objects is fully sandboxed; OSAccess backed by CallbackFile is exactly as sandboxed as the callback you wrote.

In-process execution

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.

Remote workers

[AsyncMontyWebsocket][pydantic_monty.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.

Pin the worker binary

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.

Deserializing snapshots

Snapshots must be unmodified output from a trusted, compatible Monty producer. The caller must establish their provenance and integrity before restoring them; Monty does not authenticate snapshots. Use trusted storage or verify a MAC/signature before loading bytes received through an untrusted channel. A checksum supplied alongside untrusted bytes is not authentication.

Invalid snapshots have no correctness or availability guarantees: loading or using them may return incorrect results, panic, terminate the process, or fail to terminate. Successful decoding does not establish that a snapshot is valid. Worker isolation does not replace verification: restored state carries resource limits and can request host callbacks. These rules also apply to direct serde deserialization in Rust. Genuine snapshots produced while running untrusted Python remain supported; the trust requirement concerns the producer and serialized bytes, not the Python source.

The parts that are most security-critical

If you are reviewing or contributing to Monty, two areas carry most of the weight:

  • crates/monty/src/heap/ — the heap arena, free list and reference counting.
  • crates/monty-fs/src/mount_table.rs — the mount boundary: the Dir descriptor every filesystem operation runs against, with path_security.rs beside 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.

There aren't any published security advisories