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.
host:443. One method, one path.--gpu 0 makes the other device nodes unopenable.An MCP server exposing sandboxed shell, Python, and file tools, with a fresh sandbox per call.
$ 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.
sandlock learn watches a real build and writes the profile.A sandboxed job can run Sandlock itself, using --no-supervisor for the inner one.
# 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.
sandlock ps and sandlock inspect show what is running and under which policy./proc. Set the CPU count and memory a workload believes it has.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.
$ 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.
The Sandbox HTTP API makes invocations remote calls, and the Sandbox Scheduler spreads them across a fleet.
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.
connect() the policy never allowed.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.
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.
openat and return a sealed in-memory file.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.
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.
io.sandlock.* keys.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.
# 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
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.