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
Use cases

Code You Did Not Write, Running on Your Machine

Every one of these has the same shape: something needs to execute, you cannot fully vouch for it, and wrapping it in a container is either too slow, too privileged, or not precise enough. These are the policies teams actually deploy.

AI Agents and Tool Execution

An agent that runs shell commands is arbitrary code execution with a model choosing the arguments. It needs a workspace and one or two endpoints, not a container's whole worldview.

Sandlock gives it exactly the paths and endpoints you name. The API key never enters its address space: the secret stays in the supervisor and is attached in the proxy after the ACL check has passed.

HTTP-level rules, not just host:443. One method, one path.
Zero-config HTTPS. An ephemeral CA, private key in memory, spliced into the trust bundles you name.
Credential stripped from the child, so a compromised agent cannot read its own key back.
GPU selection is a boundary. --gpu 0 makes the other device nodes unopenable.
Also available as

An MCP server exposing sandboxed shell, Python, and file tools, with a fresh sandbox per call.

Agent with one endpoint and no key
$ sandlock run \
    # the only endpoint that exists
    --http-allow "POST api.openai.com/v1/chat/completions" \
    --http-deny  "* */admin/*" \
    # trust the ephemeral CA, no install step
    --http-inject-ca /etc/ssl/certs/ca-certificates.crt \
    # the key stays in the supervisor
    --credential openai=env:OPENAI_API_KEY \
    --http-auth "POST api.openai.com/v1/* bearer openai" \
    # filesystem and resource envelope
    -r /usr -r /lib -r /etc -w /work \
    -m 1G -P 32 -t 600 \
    -- python3 agent.py

Untrusted CI Builds

A pull request from a fork is untrusted code with a build script attached. A disposable VM per job is slow and expensive; Docker on a shared runner hands that job a socket equivalent to root.

Sandlock runs the build as an ordinary user with the network closed, the toolchain read-only, and the source tree copy-on-write. Writes commit on success and vanish on failure, and --dry-run shows what a build would touch without letting any of it land.

No root, no daemon, no Docker socket on the runner.
About 5 ms of overhead, so confining each step is practical.
Reproducible builds. Freeze the clock, seed the PRNG, sort directory reads, disable ASLR.
Generated policies. sandlock learn watches a real build and writes the profile.
Nesting

A sandboxed job can run Sandlock itself, using --no-supervisor for the inner one.

Build a fork's PR, offline and reversible
# See what the build would change, change nothing
$ sandlock run --dry-run --workdir . \
    -w . -r /usr -r /lib -r /bin -r /etc \
    -- make build
A  build/out.o
M  Cargo.lock

# Run it for real: no network, capped, COW-committed
$ sandlock run --workdir . \
    -w . -r /usr -r /lib -r /bin -r /etc \
    -m 4G -P 64 -t 1800 \
    -- make -j4

# Deterministic: frozen clock, seeded randomness
$ sandlock run \
    --time-start "2000-01-01T00:00:00Z" \
    --random-seed 42 --deterministic-dirs \
    -- ./build.sh

Per-Request Code Execution

Notebook backends, interpreters, and autograders run one untrusted snippet per request, thousands of times a day. Container startup dominates the request, and a warm pool trades that latency for state leaking between callers.

At about 5 ms, a fresh sandbox per request is affordable and nothing carries over. Port virtualization gives each its own port space, so a hundred can bind 8080 without colliding.

Fresh sandbox per request, with no pool and no reuse.
Named sandboxes get a stable virtual hostname for a reverse proxy to route by.
Fleet introspection. sandlock ps and sandlock inspect show what is running and under which policy.
Virtualized /proc. Set the CPU count and memory a workload believes it has.
Know the boundary

Sandboxes on one host share a kernel. That stops a snippet reading another's files; it does not stop a kernel exploit. For mutually hostile tenants you want a separate kernel. See the security model.

Two servers, same port, no collision
$ sandlock run --name api.local --port-remap \
    --net-allow-bind 8080 \
    -r /usr -r /lib -r /etc -- python3 server.py &

$ sandlock run --name web.local --port-remap \
    --net-allow-bind 8080 \
    -r /usr -r /lib -r /etc -- python3 server.py &

$ sandlock ps
NAME          PID      UPTIME  CMD
api.local     12345        5m  python3 server.py
web.local     12346        3m  python3 server.py

$ sandlock inspect api.local --toml
$ sandlock kill web.local

Function as a Service

A function platform takes an event, runs someone else's code against it, returns a response, and releases the resources. The code is not yours, and the request should pay for nothing beyond the time it ran.

A policy per function answers the first. A 5 ms start answers the second: every invocation gets its own sandbox, so there is no pool to keep warm, nothing held between requests, and ten concurrent events are ten sandboxes.

Stateless by construction. A fresh sandbox carries nothing from the last invocation.
Policy per function. A resizer gets scratch space and no network; a notifier gets one endpoint and no data.
Bounded without a supervisor. Memory, processes, CPU share, and a timeout are part of the policy.
No warm pool, so idle functions cost nothing.
Beyond one host

The Sandbox HTTP API makes invocations remote calls, and the Sandbox Scheduler spreads them across a fleet.

Event in, response out, sandbox gone
from concurrent.futures import ThreadPoolExecutor
from sandlock import Sandbox, StdioMode

# What each deployed function is allowed to do. The resizer
# gets scratch space and no network; the notifier gets one
# endpoint and no data.
FUNCTIONS = {
    "resize": dict(
        fs_readable=["/usr", "/lib", "/etc", "/srv/fn/resize"],
        fs_writable=["/work"], max_memory="256M", max_processes=4,
    ),
    "notify": dict(
        fs_readable=["/usr", "/lib", "/etc", "/srv/fn/notify"],
        http_allow=["POST api.internal/v1/notify"], max_memory="128M",
    ),
}

def invoke(name: str, event: bytes) -> bytes:
    """One invocation: a sandbox of its own, torn down on return."""
    proc = Sandbox(**FUNCTIONS[name]).popen(
        ["python3", f"/srv/fn/{name}/handler.py"],
        stdin=StdioMode.PIPED, stdout=StdioMode.PIPED,
    )
    proc.stdin.write(event)
    proc.stdin.close()                  # EOF, so the handler runs
    response = proc.stdout.read()       # drain before wait
    if not proc.wait(timeout=30).success:
        raise RuntimeError(f"{name} failed")
    return response

# Concurrent events are concurrent sandboxes, one policy each.
with ThreadPoolExecutor() as pool:
    responses = list(pool.map(lambda e: invoke("resize", e), events))

Prompt Injection Defense

An agent reading a web page, a document, or a tool result is reading text an attacker may have written, and no amount of prompt engineering reliably stops a model being talked into something.

So stop trying, and make being convinced worthless. Split the agent so whatever reads untrusted text holds no capability, and whatever holds capability never reads untrusted text. Each stage keeps its own policy and data passes between them through kernel pipes.

The reader has nothing to give away. No network, no credentials, no data paths.
The actor never sees the text, only the structured result, so nothing in its input can carry an instruction.
Exfiltration fails at the syscall. "Send this to my server" hits a connect() the policy never allowed.
"Print your API key" returns nothing, because credential injection kept it out of the agent's memory.
What this does not do

It does not stop the model producing a hostile answer. What the policy bounds is what the agent can do, which is the part an attacker wants.

The reader is powerless, the actor is blind
from sandlock import Sandbox

# Reads the untrusted page and emits structured JSON.
# No network and no secrets, so an instruction buried in
# that page has nothing to reach for.
reader = Sandbox(
    fs_readable=["/usr", "/lib", "/bin", "/etc",
                 "/work/fetched"],
    max_memory="512M",
)

# Holds the one endpoint that may be called. It sees the
# reader's JSON, never the prose it came from.
actor = Sandbox(
    fs_readable=["/usr", "/lib", "/bin", "/etc"],
    http_allow=["POST api.internal/v1/tickets"],
)

result = (
    reader.cmd(["python3", "extract.py", "page.html"])
    | actor.cmd(["python3", "file_ticket.py"])
).run()

Compliance Audit and Virtual Filesystems

Some requirements no declarative policy can meet: a guaranteed record of every file the guest touched, or artifacts streamed to object storage as they are written rather than collected afterwards.

Handlers run your code inside the supervisor, on whichever syscalls you name, before the kernel acts. Because interception sits below the language runtime, an audit trail built this way cannot be routed around by ctypes or a raw syscall, and unlike eBPF tracing it needs no CAP_BPF.

Files with no host backing. Intercept openat and return a sealed in-memory file.
Slow work without stalling. A handler doing a network round trip defers to a worker.
Confinement is never weakened. Built-ins run first, and a handler on a blocklisted syscall is rejected before fork.
Rust, Python, or C, through the same model.
Why it lives here

The kernel allows one seccomp notification listener per process, so extra interception must run inside the same supervisor loop. That is what the handler API provides.

Python: deny by pattern, audit the rest
import sandlock
from sandlock.presets import (
    AuditPathsHandler, PathDenyHandler, COMMON_PATH_SYSCALLS,
)

audit = AuditPathsHandler(
    callback=lambda path, _ctx: log.info("open %s", path)
)
deny = PathDenyHandler(deny=["*/.ssh/*", "*/.aws/*"])

sb = sandlock.Sandbox(
    fs_readable=["/usr", "/lib", "/etc"],
    fs_writable=["/work"],
)
sb.run_with_handlers(
    cmd=["python3", "task.py"],
    handlers=[(s, deny) for s in COMMON_PATH_SYSCALLS]
           + [(s, audit) for s in COMMON_PATH_SYSCALLS],
)

More Pods per Node on Kubernetes

A container charges you before your workload runs an instruction: namespaces to construct, cgroups to wire, a privileged runtime to mediate it. That cost is per pod, paid on every start.

sandlock-oci implements the same OCI runtime interface, so containerd, CRI-O, and the kubelet drive it in place of runc with your images unchanged. What it produces is namespace-less and cgroup-less, confined by Landlock and seccomp, so a pod costs roughly what the process inside it costs.

About 5 ms to start, against roughly 200 ms for a container.
97% of bare-metal throughput on the Redis benchmark, where Docker held 63%.
No namespaces, no cgroups, no privileged daemon.
Policy rides on pod annotations. Network and HTTP rules the OCI spec cannot carry travel as io.sandlock.* keys.
Checkpoint and restore, so an idle pod can give its memory back.
What you give up

The parts of a container that are namespaces: a pod-private PID space and network stack. A workload that needs those should stay on runc.

Register the runtime, once
# containerd. pod_annotations is what forwards the
# io.sandlock.* keys through to the runtime.
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.sandlock]
  runtime_type   = "io.containerd.runc.v2"
  pod_annotations = ["io.sandlock.*"]
  [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.sandlock.options]
    BinaryName = "/usr/local/bin/sandlock-oci"

# ---
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: sandlock
handler: sandlock
Then policy travels in annotations
apiVersion: v1
kind: Pod
metadata:
  name: agent
  annotations:
    # ';' separates entries, because ',' is already
    # meaningful inside a network spec.
    io.sandlock.network.allow: "api.internal:443;10.0.0.0/8:5432"
    io.sandlock.http.allow: "POST api.internal/v1/*"
    io.sandlock.config.http_inject_ca: "/etc/ssl/certs/ca-certificates.crt"
spec:
  runtimeClassName: sandlock
  containers:
    - name: agent
      image: python:3.12-slim   # unchanged

Which One Is Yours?

If your situation is close to one of these but not quite it, we would like to hear about it. Running these at fleet scale is what the Sandbox HTTP API and Sandbox Scheduler are for.