Python SDK
ctypes bindings over the C ABI. A Sandbox is a plain dataclass describing policy, with no runtime state, so it can be built once and reused across calls.
Install
The package builds the Rust FFI library during installation, so a Rust toolchain must be present. Python 3.8 or later is required.
$ cd python
$ pip install -e .
# with the MCP server
$ pip install -e '.[mcp]'
Running a command
from sandlock import Sandbox
sandbox = Sandbox(
fs_writable=["/tmp/sandbox"],
fs_readable=["/usr", "/lib", "/etc"],
max_memory="256M",
max_processes=10,
clean_env=True,
)
result = sandbox.run(["python3", "-c", "print('hello')"], timeout=30)
assert result.success
assert b"hello" in result.stdout
The result carries success, exit_code, stdout, stderr, and error. Output is bytes, not text, because a sandboxed workload's output is not guaranteed to be valid UTF-8.
The policy object
Every field is optional, and omitting one means "no restriction" beyond Sandlock's default seccomp blocklist, which is always applied. The grouping below matches the TOML profile sections.
from sandlock import Sandbox, BranchAction
sandbox = Sandbox(
# [config]
http_ca=None, http_key=None,
fs_storage=None, workdir=None,
# [determinism]
random_seed=None, time_start=None,
deterministic_dirs=False, no_randomize_memory=False,
# [program]. exec and args are arguments to .run() / .cmd()
env={}, cwd=None, uid=None, gid=None,
clean_env=False, no_coredump=False, no_huge_pages=False,
no_supervisor=False,
# [filesystem]
fs_readable=(), fs_writable=(), fs_denied=(),
chroot=None, fs_mount={},
on_exit=BranchAction.COMMIT, on_error=BranchAction.ABORT,
# [network]
net_allow_bind=(), net_allow=(), port_remap=False,
# [http]
http_ports=(), http_allow=(), http_deny=(),
# [syscalls]
extra_allow_syscalls=(), extra_deny_syscalls=(),
# [limits]
max_memory=None, max_processes=64, max_open_files=None,
max_cpu=None, max_disk=None,
gpu_devices=None, cpu_cores=None, num_cpus=None,
# runtime kwargs, not serialized as policy
name=None, policy_fn=None, init_fn=None, work_fn=None,
)
The sandbox reference documents every field's type, default, and behaviour, including where a Python name differs from its TOML key.
HTTP ACL
agent = Sandbox(
fs_readable=["/usr", "/lib", "/etc"],
http_allow=["POST api.openai.com/v1/chat/completions"],
http_deny=["* */admin/*"],
)
result = agent.run(["python3", "agent.py"])
chroot and mounts
chrooted = Sandbox(
chroot="/opt/rootfs",
fs_mount={"/work": "/tmp/sandbox-1/work"}, # maps /work inside chroot
fs_readable=["/usr", "/bin", "/lib", "/etc"],
cwd="/work",
)
result = chrooted.run(["python3", "task.py"])
The Python mapping cannot express a read-only mount and rejects :ro entries with PolicyError. Load such a profile through the CLI with --profile-file, or use the C ABI's sandlock_sandbox_builder_fs_mount_ro.
Dry-run
sandbox = Sandbox(
fs_writable=["."],
workdir=".",
fs_readable=["/usr", "/lib", "/bin", "/etc"],
)
result = sandbox.dry_run(["make", "build"])
for c in result.changes:
print(f"{c.kind} {c.path}") # A=added, M=modified, D=deleted
Pipelines
Chain sandboxed stages with the | operator. Each stage has its own independent policy, and data flows between them through kernel pipes, so a downstream stage sees the bytes without gaining the upstream stage's access.
from sandlock import Sandbox
trusted = Sandbox(fs_readable=["/usr", "/lib", "/bin", "/etc", "/opt/data"])
restricted = Sandbox(fs_readable=["/usr", "/lib", "/bin", "/etc"])
# reader can access the data, processor cannot
result = (
trusted.cmd(["cat", "/opt/data/secret.csv"])
| restricted.cmd(["tr", "a-z", "A-Z"])
).run()
assert b"SECRET" in result.stdout
The XOA pattern
eXecute-Only Agents: a planner with no data access generates code, and an executor with data access but no network runs it. Neither stage ever holds both capabilities.
planner = Sandbox(fs_readable=["/usr", "/lib", "/bin", "/etc"])
executor = Sandbox(fs_readable=["/usr", "/lib", "/bin", "/etc", "/data"])
result = (
planner.cmd(["python3", "-c", "print('cat /data/input.txt')"])
| executor.cmd(["sh"])
).run()
COW fork and map-reduce
Initialize expensive state once, then fork clones that share the template's memory copy-on-write. A thousand clones take about 530 ms, roughly 1,900 forks per second, and each clone inherits the full confinement with CLONE_ID set automatically.
Each clone's stdout is captured through its own pipe, and reduce() reads all of those pipes and feeds the combined output to a reducer's stdin. The mapper and reducer are separate sandboxes with different policies, which is what lets the mapper hold data access the reducer does not.
from sandlock import Sandbox
def init():
global model, data
model = load_model() # 2 GB, loaded once
data = preprocess_dataset()
def work(clone_id):
shard = data[clone_id::4]
print(sum(shard)) # stdout → per-clone pipe
# Map: fork 4 clones from the template
mapper = Sandbox(
fs_readable=["/usr", "/lib", "/bin", "/etc", "/data"],
init_fn=init,
work_fn=work,
)
clones = mapper.fork(4)
# Reduce: pipe clone outputs into a reducer with no data access
reducer = Sandbox(fs_readable=["/usr", "/lib", "/bin", "/etc"])
result = reducer.reduce(
["python3", "-c", "import sys; print(sum(int(l) for l in sys.stdin))"],
clones,
)
print(result.stdout)
Confining the current process
confine() applies Landlock filesystem rules to the running process rather than spawning a child. It is irreversible: there is no way to widen the ruleset afterwards, by design.
from sandlock import Sandbox, confine
confine(Sandbox(fs_readable=["/usr", "/lib"], fs_writable=["/tmp"]))
Port virtualization
sb = Sandbox(
port_remap=True,
net_allow_bind=[8080],
fs_readable=["/usr", "/lib", "/etc"],
name="api.local",
)
# sb.ports() returns {virtual_port: real_port} while running
Dynamic policy and handlers
Two extension points, each with its own page. Dynamic Policy covers the policy_fn callback, which returns allow, deny, or audit for a fixed set of syscalls. Extension Handlers covers the Handler API, which registers on arbitrary syscalls and can synthesize return values, inject file content, and defer slow work. In brief:
def on_event(event, ctx):
if event.syscall == "execve" and event.argv_contains("curl"):
return True # deny with EPERM
if event.syscall == "execve":
ctx.restrict_network([]) # lock down after startup
return 0 # allow
sandbox = Sandbox(fs_readable=["/usr", "/lib"], policy_fn=on_event)
And a handler, registered on whichever syscalls you name:
from sandlock.presets import PathDenyHandler, COMMON_PATH_SYSCALLS
deny = PathDenyHandler(deny=["*/.ssh/*", "*/.aws/*"])
sandbox.run_with_handlers(cmd, [(s, deny) for s in COMMON_PATH_SYSCALLS])
Helpers and types
from sandlock import parse_ports
parse_ports([80, "443", "8000-8005"])
# => [80, 443, 8000, 8001, 8002, 8003, 8004, 8005]
| Type | Fields |
|---|---|
BranchAction | COMMIT, ABORT, KEEP |
Change | kind ("A", "M", "D"), path relative to workdir |
DryRunResult | success, exit_code, stdout, stderr, changes, error |
Testing
$ cd python && pip install -e . && pytest tests/