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

Code You Did Not Write, Running on Your Machine

Each of these has the same shape: something must run, you cannot vouch for it, and a container is too slow, too privileged, or not precise enough.

AI Agents and Tool Execution

An agent running shell commands is arbitrary code execution with a model choosing the arguments. Give it the paths and endpoints you name and nothing else. The API key never enters its address space: it stays in the supervisor and is attached in the proxy after the ACL check.

HTTP-level rules, not just host:443. One method, one path.
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 fork's pull request is untrusted code with a build script attached. Sandlock runs it 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.

No root, no daemon, no Docker socket on the runner.
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

Notebooks, interpreters, and autograders run one untrusted snippet per request. At about 5 ms, a fresh sandbox per request is affordable, so there is no warm pool and nothing leaks between callers.

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. For mutually hostile tenants you want a separate one. 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 runs someone else's code against an event and should pay for nothing beyond the time it ran. A policy per function covers the first; a 5 ms start covers the second: every invocation gets its own sandbox.

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

# One policy per deployed function.
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

No prompt engineering reliably stops a model being talked into something. So make being convinced worthless: whatever reads untrusted text holds no capability, and whatever holds capability never reads untrusted text. Each stage keeps its own policy.

The reader has nothing to give away. No network, no credentials, no data paths.
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. No network, no secrets.
reader = Sandbox(
    fs_readable=["/usr", "/lib", "/bin", "/etc",
                 "/work/fetched"],
    max_memory="512M",
)

# Holds the one endpoint. Sees JSON, never the prose.
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

Handlers run your code inside the supervisor, on the syscalls you name, before the kernel acts. Interception sits below the language runtime, so an audit trail built this way cannot be routed around by ctypes or a raw syscall, and 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.
Why it lives here

The kernel allows one notification listener per process, so extra interception runs inside the supervisor. See the handler API.

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

sandlock-oci implements the OCI runtime interface, so containerd, CRI-O, and the kubelet drive it in place of runc with your images unchanged. The result is namespace-less and cgroup-less, so a pod costs roughly what the process inside it costs. Register the runtime on each node once; after that, policy travels on pod annotations.

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%.
Policy rides on pod annotations. Network and HTTP rules the OCI spec cannot carry travel as io.sandlock.* keys.
What you give up

A pod-private PID space and network stack. Workloads that need those stay on runc.

A RuntimeClass once, then policy per pod
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: sandlock
handler: sandlock          # registered with containerd or CRI-O
---
apiVersion: v1
kind: Pod
metadata:
  name: agent
  annotations:
    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?

Close to one of these but not quite? We would like to hear about it.