Architecture
Sandlock is a policy applied to a process, not a runtime the process lives inside. This page covers what installs when, which component enforces which rule, and how to extend the supervisor.
Component layout
Sandlock is written in Rust. Everything, including the SDKs in other languages, drives the same core through the same policy object.
┌─────────────┐ ┌─────────────┐
│ Python SDK │ │ Go SDK │
│ (ctypes) │ │ (cgo) │
└──────┬──────┘ └──────┬──────┘
│ FFI │ FFI
└─────────┬──────────┘
▼
┌──────────────┐ ┌──────────────────────────────┐
│ sandlock CLI │───>│ libsandlock_ffi.so │
└──────────────┘ └───────────────┬──────────────┘
│
┌──────────────┐ │
│ sandlock-oci │────────────┐ │
│ (OCI runtime)│ │ │
└──────────────┘ ▼ ▼
┌──────────────────────────────┐
│ sandlock-core │
│ Landlock · seccomp · COW · │
│ pipeline · policy_fn · vDSO │
└──────────────────────────────┘
| Crate or package | Role |
|---|---|
sandlock-core | The library: Landlock, seccomp, the supervisor, COW, pipelines, dynamic policy |
sandlock-cli | The sandlock binary |
sandlock-oci | Namespace-less OCI runtime shim for containerd, CRI-O, and Kubernetes |
sandlock-ffi | C ABI shared library, libsandlock_ffi.so |
| Python SDK | ctypes bindings over the FFI library, plus the MCP server |
| Go SDK | cgo bindings over the FFI library |
The confinement sequence
Confinement is installed in the child after fork() and before exec(). By the time the workload's first instruction runs, every restriction is already in place, and the parent only begins supervising once the child confirms the filter is installed.
Parent Child │ fork() │ │──────────────────────────────────>│ │ ├─ 1. setpgid(0,0) │ ├─ 2. Optional: chdir(cwd) │ ├─ 3. NO_NEW_PRIVS │ ├─ 4. Landlock (fs + net + IPC) │ ├─ 5. seccomp filter (deny + notif) │ │ └─ send notif fd ──> Parent │ receive notif fd ├─ 6. Wait for "ready" signal │ start supervisor (tokio) ├─ 7. Close fds 3+ │ optional: vDSO patching └─ 8. exec(cmd) │ optional: policy_fn thread │ optional: CPU throttle task
Two steps deserve comment.
Step 3, NO_NEW_PRIVS, comes before the filter. Without it, a seccomp filter cannot be installed unprivileged at all, and a setuid binary reached later would gain privileges the policy never granted.
Step 7 closes every descriptor above stderr. A file descriptor inherited from the parent was opened before confinement existed, so the kernel will keep honouring it regardless of the Landlock ruleset. Closing them removes that escape hatch before the workload can use it.
What each layer enforces
Landlock, in the kernel
Landlock is Linux's unprivileged access-control LSM. Sandlock compiles the policy's filesystem, network, and IPC rules into a ruleset and applies it to the child. From that moment the kernel evaluates every access itself, and the ruleset can never be widened for the lifetime of the process tree.
- Filesystem. Read and write grants become recursive
PATH_BENEATHrules. Because the kernel resolves the path at access time, these rules are immune to the time-of-check/time-of-use races that defeat path-string filtering in userspace. - TCP. Connect and bind port allowlists, from ABI v4 onwards.
- IPC. Abstract UNIX socket scoping and signal scoping, from ABI v6, which stop a sandbox reaching sibling processes on the host.
Once installed, Landlock costs only the kernel's own check. This is what makes confinement cheap enough to leave enabled in production.
seccomp-bpf, in the kernel
A seccomp-bpf filter removes syscall families a confined workload has no legitimate use for. Sandlock's default blocklist is applied unconditionally and cannot be disabled; a policy may add denials on top, and may re-enable specific named groups such as System V IPC. Unknown group names are rejected rather than ignored, so a typo fails loudly instead of silently widening the sandbox.
The same filter installs the notification listener used by the supervisor, and is inherited by every descendant.
seccomp user notification, in the supervisor
Rules like "connect only to this IP", "stop at 512 MB resident", or "stage this write instead of performing it" cannot be expressed as a static kernel rule; they need a decision at the moment the syscall happens. Seccomp user notification hands those syscalls to an async supervisor built on tokio, running in the parent process outside the sandbox.
Which syscalls reach the supervisor
Only these, and only when the relevant policy feature is active. Everything else runs at full speed under the kernel filter.
| Syscall | Handler |
|---|---|
clone / fork / vfork | Process count enforcement |
mmap / munmap / brk / mremap | Memory limit tracking |
connect / sendto / sendmsg | IP allowlist, on-behalf execution, HTTP ACL redirect |
bind | On-behalf bind and port remapping |
openat | /proc virtualization, COW interception |
unlinkat / mkdirat / renameat2 | COW write interception |
execve / execveat | Policy callback hold, vDSO re-patching |
getrandom | Deterministic PRNG injection |
clock_nanosleep / timer_settime | Timer adjustment for frozen time |
getdents64 | PID filtering, COW directory merging |
getsockname | Port remap translation |
No-supervisor mode
--no-supervisor runs a sandbox with layers one and two only: Landlock plus a kernel-only deny filter, with no supervisor process at all. What you lose is everything the supervisor provides, namely IP allowlisting, resource limits, COW, chroot mediation, /proc virtualization, and custom handlers.
The mode exists because the kernel permits only one SECCOMP_FILTER_FLAG_NEW_LISTENER per task, so nesting one Sandlock inside another requires the inner one to go without.
$ sandlock run --no-supervisor \
-r /proc -r /usr -r /lib -r /lib64 -r /bin -r /etc -w /tmp -- \
sandlock run -r /usr -w /tmp -- untrusted-command
Network enforcement paths
Sandlock picks the cheapest path that can express your rules. This is not configurable; it falls out of the policy.
Direct path
Chosen when the policy consists of pure TCP port rules, with no concrete host, IP, or CIDR, and no HTTP ACL. Landlock enforces the port allowlist in the kernel and there is no per-syscall overhead. UDP and ICMP are never covered by Landlock and always use the other path when allowed at all.
On-behalf path
Chosen for any host, IP, or CIDR target, any UDP or ICMP rule, or any HTTP ACL rule, because the destination address has to be checked and Landlock cannot do that. Seccomp traps connect(), sendto(), sendmsg(), and sendmmsg(). The supervisor duplicates the child's descriptor, queries getsockopt(SOL_SOCKET, SO_PROTOCOL) to learn whether the socket is TCP, UDP, or ICMP, checks the destination against that protocol's resolved allowlist, and then performs the syscall itself. The HTTP proxy redirect, when configured, happens here too.
Duplicating the descriptor rather than trusting an address the child supplied is what makes this TOCTOU-safe: the supervisor acts on the socket the kernel actually gave it.
Copy-on-write
When workdir is set, Sandlock intercepts filesystem syscalls through seccomp notification and stages writes in an upper directory; reads resolve upper first, then lower. There is no mount namespace, no user namespace, and no root involved. The branch is committed on normal exit and aborted on error, both configurable.
See Filesystem and COW for branch actions, dry-run, and quota behaviour.
COW fork and map-reduce
Expensive initialization can be done once and then shared. An init callback runs in a template process; forking clones from it uses raw fork(2), so every clone shares the template's memory copy-on-write. A thousand clones take about 530 ms, roughly 1,900 forks per second.
Each clone's stdout is captured through its own pipe. The reduce step reads all of those pipes and feeds the combined output to a reducer's stdin, so the whole data flow is pipe-based with no temporary files. The mapper and the reducer are separate sandboxes with independent policies, which is what lets the mapper hold data access that the reducer does not. CLONE_ID=0..N-1 is set in each clone automatically.
Custom handlers
Downstream Rust crates can append their own seccomp-notification handlers to the supervisor's chain, registering for any syscall they care about through the Handler trait and Sandbox::run_with_handlers.
Two rules keep this from becoming an escape hatch:
- The built-in chain runs first. A user handler observes and can add restrictions, but cannot subvert a decision the built-ins already made.
- Registration is validated. Handlers on syscalls in the default blocklist, or in the policy's extra denials, are rejected at registration time rather than silently never firing.
This is the extension point that makes the sandbox programmable rather than merely configurable: a handler can deny a syscall, fabricate its return value, hand the guest a file that exists nowhere on disk, or defer slow work off the supervisor loop. See Extension Handlers for the full API, ordering semantics, injection, and the security boundary.
Host requirements
| Feature | Minimum kernel |
|---|---|
| seccomp user notification | 5.6 |
| Landlock filesystem rules | 5.13 |
| Landlock TCP port rules (ABI v4) | 6.7 |
| Landlock IPC scoping (ABI v6) | 6.12 |
Sandlock's default is strict: it enforces every protection the host supports and refuses to start when one it expects is unavailable. Waiving a protection is an explicit, per-protection decision, and the resolved posture is part of a sandbox's checkpoint. See Kernel Protections.