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.
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 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.
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
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.
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. For mutually hostile tenants you want a separate one. 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 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.
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
# 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.
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. 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.
openat and return a sealed in-memory file.The kernel allows one notification listener per process, so extra interception runs inside the supervisor. See the handler API.
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.
io.sandlock.* keys.A pod-private PID space and network stack. Workloads that need those stay on runc.
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.