Home
Why Sandlock
How It Works Use Cases Comparison Security Model
Docs
Documentation Home Getting Started CLI Reference Python SDK Sandbox Reference
Products
Overview Sandbox HTTP API Sandbox Scheduler
GitHub Schedule a Demo
Sandlock / Docs /Python SDK

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.

shell
$ cd python
$ pip install -e .

# with the MCP server
$ pip install -e '.[mcp]'

Running a command

python
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.

Full constructor
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

python
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

python
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

python
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.

python
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.

python
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.

python
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.

python
from sandlock import Sandbox, confine

confine(Sandbox(fs_readable=["/usr", "/lib"], fs_writable=["/tmp"]))

Port virtualization

python
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:

python
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:

python
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

python
from sandlock import parse_ports

parse_ports([80, "443", "8000-8005"])
# => [80, 443, 8000, 8001, 8002, 8003, 8004, 8005]
TypeFields
BranchActionCOMMIT, ABORT, KEEP
Changekind ("A", "M", "D"), path relative to workdir
DryRunResultsuccess, exit_code, stdout, stderr, changes, error

Testing

shell
$ cd python && pip install -e . && pytest tests/