A Monty sandbox has no filesystem.
With nothing configured, open() and every pathlib.Path method that reaches the filesystem raise PermissionError
for every path, because nothing is exposed for them to reach — including the existence checks CPython answers without
raising.
/tmp, /etc, /proc, /dev, ~ and the host working directory are not reachable.
You give the sandbox a filesystem in one of two ways: mounts, which map real host directories to virtual paths, or
an os callback, which answers filesystem operations in host code.
A [MountDir][pydantic_monty.MountDir] maps a host directory to a virtual path inside the sandbox.
Mounts are per-feed, and all arguments are keyword-only:
=== "Python"
```python
import tempfile
from pathlib import Path
from pydantic_monty import Monty, MountDir
with tempfile.TemporaryDirectory() as tmp:
Path(tmp, 'greeting.txt').write_text('hello from the host')
code = "from pathlib import Path\nPath('/data/greeting.txt').read_text()"
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(code, mount=mount))
#> hello 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, 'greeting.txt'), 'hello from the host')
const code = "from pathlib import Path\nPath('/data/greeting.txt').read_text()"
{
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(code, { mount })) // hello from the host
}
rmSync(tmp, { recursive: true })
```
Pass a list to mount= for several at once.
The mounts of one feed must have distinct virtual paths and cover disjoint host directories; a feed that mounts a
directory and one inside it, the same directory twice, or two directories at one virtual path fails before it starts
with a MontyRuntimeError wrapping a ValueError, since the stricter mount's mode could otherwise be bypassed through
the other mount's paths.
See limitations/filesystem.md.
In JavaScript MountDir comes from the @pydantic/monty/node subpath and using closes it at the end of scope; the
WebAssembly build rejects mounts outright, because a browser has no host filesystem.
The sandbox has a virtual working directory.
A session's first feed sets it to the first mount's virtual path, or / when nothing is mounted, and it then persists
across the session's feeds like the globals do.
os.getcwd() and Path.cwd() report it.
Relative paths in open(), os and pathlib calls are joined onto it.
Mounts receive these paths without collapsing . or .., so they can validate every component.
__file__ is the script name's final component placed under it.
os.chdir() moves it, and the change carries over to later feeds.
Pass cwd= to switch to another absolute virtual path before a feed.
Custom os callbacks in Python and JavaScript receive normalized absolute paths, with . and .. collapsed.
For example, os.listdir() passes /data when the cwd is /data, and ../secret becomes /secret.
Both rename arguments are normalized.
An empty string path never reaches a callback or mount: os.stat('') and the like raise FileNotFoundError in the interpreter, and os.path.exists('') returns False.
Path('') is Path('.'), as in CPython, so Path('').exists() checks the working directory.
Check access rules against complete path components: a string prefix check can confuse /data with /database.
Use MountTable for host filesystem access: normalization and prefix checks alone do not confine symlinks or prevent races.
=== "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('hello')
code = "import os\n(os.getcwd(), __file__, open('notes.txt').read())"
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(code, mount=mount))
#> ('/data', '/data/main.py', 'hello')
cwd_code = 'import os\nos.getcwd()'
print(session.feed_run(cwd_code, mount=mount, cwd='/data/sub'))
#> /data/sub
```
=== "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'), 'hello')
const code = "import os\n(os.getcwd(), __file__, open('notes.txt').read())"
{
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(code, { mount })) // [ '/data', '/data/main.py', 'hello' ]
console.log(await session.feedRun('import os\nos.getcwd()', { mount, cwd: '/data/sub' })) // /data/sub
}
rmSync(tmp, { recursive: true })
```
The directory is not checked against the mounts, and os.chdir() needs a mount (or os callback) to confirm its
target exists.
The divergences are in limitations/os.md.
import helpers runs the package helpers/__init__.py, or else helpers.py, from the working directory when no
host module of that name exists, so a module file in a mount imports
directly.
The host reads each candidate itself, as a Path.read_text call to the os callback first and through the mounts when
the callback declines it, so the whole search costs one round trip; each module that imports successfully runs once per
session.
This is the reverse of open(), where mounts go first: the callback can serve a module without mounting a directory,
and shadow a mounted file before it runs as code.
To refuse one, raise FileNotFoundError, NotADirectoryError, IsADirectoryError or PermissionError, which skips
that file even when a mount holds it; any other error fails the import.
Only a name external_modules lacks is searched for: a factory exception is the import's error, and no file is read:
=== "Python"
```python
import tempfile
from pathlib import Path
from pydantic_monty import Monty, MountDir
with tempfile.TemporaryDirectory() as tmp:
Path(tmp, 'helpers.py').write_text('def double(x):\n return x * 2\n')
code = 'import helpers\nhelpers.double(21)'
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(code, mount=mount))
#> 42
```
=== "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, 'helpers.py'), 'def double(x):\n return x * 2\n')
const code = 'import helpers\nhelpers.double(21)'
{
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(code, { mount })) // 42
}
rmSync(tmp, { recursive: true })
```
The divergences, including the search path and the module cache, are in
limitations/modules.md.
| Mode | Reads | Writes |
|---|---|---|
'read-only' |
from the host directory | raise PermissionError |
'read-write' |
from the host directory | written through to the host |
'overlay' (default) |
fall through to the host | captured in memory, discarded when the feed ends |
'overlay' is the default: writes are kept in memory and discarded when the feed ends, and sandboxed code still reads
back its own writes.
Each feed starts with a fresh overlay.
!!! warning "'read-write' writes files from untrusted code to your real filesystem"
Those files are untrusted input; do not execute them.
Importing counts as executing, and the import can be indirect: with a directory on `sys.path` mounted, sandboxed
code can write `json.py`, or any module not yet imported, and the host's next `import` runs it — including imports
`pydantic_monty` makes itself.
Use `'read-write'` only with a directory that contains no code or config and is not on `sys.path` or any other
execution path.
| Argument | Default | Meaning |
|---|---|---|
host_path |
required | Real host directory; canonicalized at construction, raises if missing or not a directory |
virtual_path |
required | Absolute POSIX-style path prefix inside the sandbox, whatever the host OS |
mode |
'overlay' |
One of the three above |
write_bytes_limit |
None |
Cap on bytes written through the mount per feed; exceeding it raises OSError |
memory_usage_limit |
DEFAULT_MEMORY_USAGE_LIMIT |
Byte budget for overlay data and transient results; exceeding it raises MemoryError |
Validation happens at construction, not at feed time — a bad virtual_path raises immediately.
Mounts are serviced by the host, including for remote workers; the worker never receives host paths.
Closing a mount prevents new feeds from using it, but an in-flight feed keeps its own reference.
Each directory mkdir creates, parents included, counts as DIRECTORY_WRITE_BYTES
(4,096 bytes) against write_bytes_limit.
The names are keyword-only on purpose: mount tools disagree on whether host or virtual comes first (docker -v versus
nginx's alias), so requiring names removes the ambiguity.
Confinement is structural, not a check.
Each mount opens a directory descriptor (cap_std::fs::Dir) once, at mount time, and every operation runs relative to
that descriptor, so .., symlinks and directories swapped mid-operation cannot reach outside it.
.. and . are collapsed in the virtual namespace before anything touches the filesystem, null bytes are rejected, and
paths handed back to the sandbox (from Path.resolve(), for example) are virtual paths — a host path never leaks in.
See the security model.
The cost of the structural boundary is symlink support: read-only and read-write mounts refuse a symlink with an absolute target even when it points back inside the mount, and overlay mounts refuse symlinks entirely. Relative symlinks that stay inside the mount are followed in the non-overlay modes.
- Virtual paths are always POSIX, on every host OS.
Path('C:/Users/foo')is a literal POSIX path, andrepris alwaysPosixPath(...). - No live file descriptors.
open()keeps no OS handle between calls; each read or write is a separate one-shot host operation. This is what makes mid-execution snapshots safe. It also means a file's whole content is loaded into the sandbox on its first read,seek()or iteration step; limitations/open.md lists the iterator consumers that cannot issue that load. - Only regular files can be read, written or opened.
FIFOs, sockets and device nodes raise
PermissionError, because mount I/O must never block on sandbox-reachable input. Existence checks andstat()still work on them.
The full list is in limitations/filesystem.md
and limitations/open.md.
Mount I/O has no timeout: it runs on the host between protocol turns, outside request_timeout and the sandbox
duration limits, whose clock is paused while the host works.
A stalled NFS or FUSE volume can block a feed indefinitely.
The pool uses blocking threads for filesystem operations, so a stalled call does not block other sessions' timers.
Cancelling the feed does not cancel an in-flight filesystem operation. It keeps its blocking thread until it returns, and a read-write mount's write, rename or delete can finish after cancellation was observed. Use host storage whose availability and side effects are acceptable for the workload.
Operations no mount covers fall through to the os= handler, an [OsHandler][pydantic_monty.OsHandler].
Two kinds of call never reach this handler: host module calls
(import and import_stubs) are answered from the checkout's external_modules, and typing.get_type_hints of a
host function or class is answered by that function or class.
An import of a module the checkout does not provide reaches it first, before the mounts, as Path.read_text of
<cwd>/name/__init__.py and <cwd>/name.py (see importing modules): return the source of a
file you serve, and answer one you do not with NOT_HANDLED, so the mounts are read next.
A FileNotFoundError (or PermissionError, NotADirectoryError, IsADirectoryError) skips that file without
consulting the mounts, so a handler that raises it for every path it does not serve hides every mounted module;
any other error fails the import.
It is called with keyword arguments, name, args, kwargs and is_async, and its return value is handed back to the sandbox.
Absorb the arguments you do not use with **_future_kwargs, so a later version can pass more:
=== "Python"
```python
from pydantic_monty import NOT_HANDLED, Monty
def handle_os(*, name, args, **_future_kwargs):
if name == 'os.getenv' and args[0] == 'STAGE':
return 'production'
return NOT_HANDLED
with Monty() as pool:
with pool.checkout() as session:
print(session.feed_run("import os\nos.getenv('STAGE')", os=handle_os))
#> production
```
=== "TypeScript"
```ts
import { Monty, NOT_HANDLED } from '@pydantic/monty'
function handleOs(functionName: string, args: unknown[]) {
if (functionName === 'os.getenv' && args[0] === 'STAGE') return 'production'
return NOT_HANDLED
}
await using pool = await Monty.create()
await using session = await pool.checkout()
console.log(await session.feedRun("import os\nos.getenv('STAGE')", { os: handleOs })) // production
```
Returning the [NOT_HANDLED][pydantic_monty.NOT_HANDLED] sentinel declines the call, and the sandbox raises whatever it would have raised with no
handler at all.
The operations that can arrive are a fixed set: Path.exists, Path.is_file, Path.is_dir, Path.is_symlink, open,
Path.read_text, Path.read_bytes, Path.write_text, Path.write_bytes, Path.append_text, Path.append_bytes,
Path.mkdir, Path.unlink, Path.rmdir, Path.iterdir, Path.scan, Path.stat, Path.rename, Path.resolve,
Path.absolute, os.getenv, os.environ and os.urandom.
Path.scan answers os.scandir, os.walk, Path.walk, Path.glob and Path.rglob with one call for a whole subtree.
Its keyword arguments are max_depth (None for the whole tree), follow_symlinks, and a glob's pattern parts,
case_sensitive and recurse_symlinks.
The reply is a list of (path, is_dir, is_file, is_symlink) tuples, one for the scanned directory itself ('') and
one per entry below it, with paths relative to the scanned directory.
The pattern is only a hint: a handler may return entries it cannot match, because the sandbox filters the reply itself.
A glob's handler may instead reply {'matches': [path, ...]}: the matches already selected, relative to the scanned directory, which the sandbox uses as they are.
A directory matched before the pattern's last part keeps a trailing / there.
Mounts reply that way unless the matches outnumber the entries they read.
[AbstractOS.path_scan()][pydantic_monty.AbstractOS.path_scan] does this by default from path_iterdir() and the
predicates, returning [ScanEntry][pydantic_monty.ScanEntry] tuples.
When the root is neither an existing path nor a symlink, it calls path_stat() to distinguish a missing path
from an invalid parent directory.
A handler that declines Path.scan makes glob() return nothing and walk() report a PermissionError, since both
treat a directory they cannot read as CPython does.
With os_policy configured to call the host, the handler also receives clock and sleep calls
(see the clock).
Unseeded random generators and random.seed() call os.urandom only under random_start='call_host'
(see random).
os callbacks run in your process with your process's authority.
Everything in designing a safe tool surface applies.
Python's AbstractOS.urandom() raises MemoryError before allocating when a request exceeds max_urandom_bytes,
1 MiB by default.
OSAccess(max_urandom_bytes=...) sets the cap; see os limitations.
Within a feed_run, an OS call is offered to the feed's mounts first, then to the os= handler, then falls back to the
sandbox's own no-handler error.
feed_start is different: it surfaces every OS call as a snapshot instead of answering it.
snapshot.resume_auto() applies the same mounts-then-os order, and snapshot.resume_not_handled() applies the
no-handler default explicitly.
Plain resume(...) bypasses both mounts and the handler, using only the supplied answer.
pydantic_monty ships a ready-made [AbstractOS][pydantic_monty.AbstractOS] implementation for when you want a filesystem with no host directory
behind it at all.
JavaScript has no equivalent class, so the TypeScript tab answers the same operations from a Map in an os callback:
=== "Python"
```python
from pydantic_monty import MemoryFile, Monty, OSAccess
fs = OSAccess(
[
MemoryFile('/data/report.csv', content='name,total\nada,42\n'),
],
environ={'STAGE': 'test'},
)
code = "from pathlib import Path\nPath('/data/report.csv').read_text().count(chr(10))"
with Monty() as pool:
with pool.checkout() as session:
print(session.feed_run(code, os=fs))
#> 2
```
=== "TypeScript"
```ts
import { Monty, NOT_HANDLED } from '@pydantic/monty'
const files = new Map([['/data/report.csv', 'name,total\nada,42\n']])
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 = "from pathlib import Path\nPath('/data/report.csv').read_text().count(chr(10))"
await using pool = await Monty.create()
await using session = await pool.checkout()
console.log(await session.feedRun(code, { os: fs })) // 2
```
For JavaScript open callbacks, return new MontyFileHandle(path, mode, { position: 0 }) from @pydantic/monty.
The marker canonicalizes mode and exposes path, mode, position, binary, readable and writable.
Paths are virtual POSIX paths; position defaults to zero.
Returning a handle answers only open; reads and writes are separate callbacks, never operations on a live host descriptor.
Text appends through OSAccess preserve the file's storage type: str stays str, and bytes stays bytes.
For bytes-backed files, only the appended text is UTF-8 encoded; existing bytes are not decoded.
For CallbackFile, a text append calls the read callback first.
The write callback then receives the full content (existing plus appended) in the stored type.
[OSAccess][pydantic_monty.OSAccess] backed by [MemoryFile][pydantic_monty.MemoryFile] objects is fully sandboxed: content lives in host memory, path traversal cannot escape
to real files, and os.getenv sees only the environ mapping you passed.
Because that content lives in host memory, OSAccess caps all MemoryFile content, initial files included, at
max_total_file_bytes (50 MB by default).
A write past it raises OSError in the sandbox, and None removes the cap; see
write limits.
[CallbackFile][pydantic_monty.CallbackFile] read and write callbacks run in the host and can reach real resources.
That is the point of it, but it means an OSAccess containing a CallbackFile is exactly as sandboxed as the callback
you wrote.
For anything more specific, subclass OSAccess and override the methods you want to change, or implement every
abstract method of AbstractOS yourself; the optional hooks (path_open, the append methods, date_today,
datetime_now, time, sleep, async_sleep) report [NOT_HANDLED][pydantic_monty.NOT_HANDLED] to Monty if you make
them raise NotImplementedError.
time receives the name of the time function that asked ('time.time', 'time.monotonic', 'time.localtime',
...) so one hook can serve every clock or give each its own answer.
Rust hosts hold a MountTable from monty-fs and service suspensions with
MountTable::handle_os_call; monty-pool does this for you when you pass MountSpecs to Checkout::feed.