OCI Runtime and Kubernetes
sandlock-oci is a thin OCI-compatible front end over sandlock-core. Where runc builds a container from namespaces, cgroups, and pivot_root, it produces a namespace-less container confined by Landlock and seccomp, running unprivileged.
How it fits in
sandlock-oci parses an OCI bundle, translates config.json into a Sandlock policy, and drives the sandbox lifecycle on behalf of a higher-level runtime. Each call from that runtime maps to one subcommand. A long-lived supervisor daemon owns the sandbox between calls, and an in-sandbox sandlock-init acts as PID 1 so the workload and every exec'd process share one sandbox and one seccomp supervisor.
containerd / CRI-O / kubelet
│ (OCI runtime calls)
▼
sandlock-oci ──fork──> supervisor daemon
│ │
│ ▼
│ sandlock-init (confined PID 1 in the sandbox)
│ │ fork + execve
│ ▼
│ workload (+ any exec'd siblings)
▼
sandlock-core
Landlock · seccomp · seccomp-notify · COW
Lifecycle
| Command | Effect |
|---|---|
create <id> -b <bundle> | Spawn the supervisor, build the sandbox, park the child before execve, and save state |
start <id> | Release the parked child so it execve's the workload |
state <id> | Print state.json, reconciled against liveness |
kill <id> <signal> | Forward a signal to the workload, or to its group with -a |
delete <id> | Shut the supervisor down and remove the state directory |
exec <id> <cmd> | Run a sibling process in the same sandbox |
list | List all sandboxes managed by sandlock-oci |
checkpoint <id> | Snapshot a running sandbox to an image directory |
restore <id> | Recreate and resume a sandbox from a checkpoint image |
check | Report the host kernel's Landlock support |
The create then start split matches the OCI two-phase model. create forks the child and installs the full policy, meaning Landlock, seccomp-notify, resource limits, and the network ACL, with the child parked just before execve; start releases it. The supervisor reports the child PID back to the caller over a pipe, so the handshake involves no sleep and no race.
Driving it directly
$ sandlock-oci create mycontainer -b /path/to/bundle
$ sandlock-oci start mycontainer
$ sandlock-oci state mycontainer
$ sandlock-oci exec mycontainer sh -c 'echo hello'
$ sandlock-oci kill mycontainer SIGTERM
$ sandlock-oci delete mycontainer
To use it under a higher-level runtime, point that runtime at the sandlock-oci binary as its OCI runtime.
Spec translation
config.json is mapped to a Sandlock policy by intent rather than by replaying Linux container primitives. A memory limit becomes a memory limit; it does not become a cgroup.
| OCI field | Sandlock mapping |
|---|---|
root.path | chroot target for the sandbox |
root.readonly | Grants the rootfs read-only instead of read-write |
mounts (bind) | fs_mount, read-only or read-write from the options |
mounts (tmpfs) | Host-backed scratch directory, isolated and cleaned on delete |
mounts (proc) | Host /proc mounted read-only, virtualized by seccomp |
mounts (sysfs) | Host /sys mounted read-only |
process.cwd | Working directory |
process.env | Environment, started clean then populated from the spec |
process.user | Run-as uid and gid, with a user namespace only if needed |
linux.resources.memory.limit | max_memory |
linux.resources.pids.limit | max_processes |
linux.resources.cpu.quota / period | Sub-core max_cpu throttle |
linux.resources.cpu.cpus | CPU affinity via sched_setaffinity |
linux.namespaces | Ignored by design |
Supported spec versions are 1.0.x, 1.1.x, and 1.2.x. A bundle declaring any other version is rejected at create rather than silently mis-mapped, which is the difference between a clear failure and a container that quietly runs with less confinement than its author expected.
Mount types with no safe namespace-less equivalent, namely devpts, mqueue, cgroup, and cgroup2, are skipped. Port binding is remapped so in-container servers can bind() without colliding on host ports.
Sandlock policy in annotations
The OCI runtime specification has no field for an outbound allowlist or a transparent proxy, so the parts of a Sandlock policy it cannot express travel as annotations under the io.sandlock. prefix.
Entries are separated by ;, not ,. A comma is already meaningful inside a network spec, where host:80,443 is one rule covering two ports.
| Annotation | Maps to |
|---|---|
io.sandlock.network.allow | net_allow |
io.sandlock.network.deny | net_deny |
io.sandlock.network.allow_bind | net_allow_bind |
io.sandlock.network.deny_bind | net_deny_bind |
io.sandlock.network.port_remap | port_remap. On by default here, unlike everywhere else; see below. Set "false" to opt out. |
io.sandlock.http.allow | http_allow |
io.sandlock.http.deny | http_deny |
io.sandlock.http.ports | http_ports |
io.sandlock.config.http_ca | http_ca. A host-side file, resolved relative to the bundle. |
io.sandlock.config.http_key | http_key. Host-side, bundle-relative. |
io.sandlock.config.http_ca_out | http_ca_out. Host-side, bundle-relative. |
io.sandlock.config.http_inject_ca | http_inject_ca. These are sandbox-virtual paths, passed through as written and resolved through the chroot and mounts. |
Why port remapping defaults on
Container workloads legitimately run servers. Sandlock gates bind() default-deny, so without remapping an in-container server fails with EACCES and the container exits, which then hangs any readiness probe waiting on it. Remapped binds are emulated on the on-behalf path and succeed, taking the requested port when it is free and a fresh host port only on a conflict, so co-located sandboxes never collide.
On Kubernetes
Pod annotations only reach the runtime if the containerd handler is told to forward them. Without pod_annotations, the keys are silently dropped and the pod runs with no network policy at all.
[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:
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
runc compatibility
The CLI accepts the global and per-command flags containerd and CRI-O pass to runc, so it can be wired in as a drop-in replacement. Flags that do not apply to a namespace-less, cgroup-less runtime are accepted and ignored: --systemd-cgroup, --rootless, --no-pivot, --no-new-keyring, and --debug.
Fatal errors are appended to the --log file in text or JSON form, so the containerd shim surfaces the real failure reason rather than a generic one.
The supervisor daemon exits with the workload's status, re-raising the killing signal where applicable, so a reaping shim sees a wait-status that mirrors the workload exactly as it would from runc.
State
State lives under $XDG_RUNTIME_DIR/sandlock-oci for unprivileged users and /run/sandlock-oci for root, overridable with the global --root flag.
Requirements and limits
- Linux 6.12 or newer, for Landlock ABI v6. Run
sandlock-oci checkto confirm. - A built
sandlock-core; this crate depends on it directly. - The workload and all
exec'd processes share one sandbox and one seccomp supervisor, through the in-sandboxsandlock-initPID 1. execis non-TTY only.-tand--console-socketare accepted for runc compatibility but ignored, as there is no PTY support yet.
What you gain and what you give up. You gain kernel-enforced confinement without namespaces, cgroups, or privilege. You give up the parts of the container model that are namespaces: a container-private PID space and network stack. If your workload depends on those specifically, rather than on the isolation they happen to provide, runc remains the right runtime for it.