Resource Limits
How much of the machine a sandbox may consume, and which devices it may reach. All of it without cgroups: the supervisor accounts for allocation as it happens, and device access is a Landlock boundary rather than an environment variable.
The fields
| Python | TOML | CLI | Default | Caps |
|---|---|---|---|---|
max_memory | memory | -m | none | Resident anonymous memory |
max_processes | processes | -P | 64 | Concurrent processes |
max_open_files | open_files | --max-open-files | none | Open descriptors |
max_cpu | cpu | -c | none | Percentage of one core |
max_disk | disk | --max-disk | none | COW upper-layer size |
cpu_cores | cpu_cores | --cpu-cores | none | Which cores may run it |
num_cpus | num_cpus | --num-cpus | none | How many cores it can see |
gpu_devices | gpu_devices | --gpu | none (denied) | Which GPUs it may open |
A wall-clock timeout is separate, and CLI-only: -t kills the sandbox after that many seconds. From the SDKs, pass timeout= to the run call.
$ sandlock run -m 512M -P 20 -t 30 -- ./compute.sh
Memory
There are no cgroups involved. The supervisor intercepts mmap, munmap, brk, and mremap, and tracks the sandbox's allocation against the cap as the calls happen.
Two consequences worth knowing:
Only anonymous mappings count. The accounting is for memory the sandbox actually claims, so a file-backed mapping of a large read-only file is not charged against the limit. This is usually what you want, and it is why a workload that maps a multi-gigabyte model file can still run under a modest cap.
The decisions are made on register arguments. Mapping length and brk addresses arrive in the notification itself rather than through a pointer into the guest's memory, so this accounting is not exposed to the re-read hazard that governs path handling. The one exception is clone3, whose flags live in a struct the supervisor reads from guest memory; that read feeds process accounting only, never a security decision, so a misread can throttle incorrectly but cannot bypass a kernel-enforced deny.
Accepts a string such as "512M" or "1G", or an integer byte count.
Processes
max_processes is a cap on concurrent processes, not on how many the sandbox may create over its lifetime. A build that spawns ten thousand short-lived compilers sequentially runs fine under a limit of 64; ten thousand at once does not.
Threads do not count. A fork-like call carrying CLONE_THREAD is creating a thread, not a process, and is not charged. A workload with a large thread pool is unaffected by this limit.
The default is 64. Setting it also enables the fork interception used by checkpoint freeze, so a policy that needs checkpointing gets it as a side effect of having a process limit at all.
Open files
This one has more edges than the others, and it is worth being precise about what it is.
max_open_files is enforced with RLIMIT_NOFILE, set in the child just before it execs. Both the soft and the hard limit are lowered, and descendants inherit the cap.
Treat this as a budget, not as confinement. Lowering the hard limit is one-way only for an unprivileged Sandlock. A sandbox launched by root, or with CAP_SYS_RESOURCE, can raise it back, because Sandlock does not drop capabilities.
It is an upper bound, never a grant. The value is clamped to both limits Sandlock itself inherited, so requesting more than the inherited soft limit gives the guest the inherited limit rather than the number you asked for. If a guest genuinely needs a bigger budget, raise the limit on Sandlock itself with prlimit or systemd's LimitNOFILE=.
It has to cover process startup. The limit must accommodate stdio, the dynamic loader's per-library descriptors, and under chroot the injected exec descriptor. Set it too low and the exec fails with exit code 127.
| When it fails | Plain exec | Under chroot |
|---|---|---|
| At startup | EMFILE, exit 127 | EIO, exit 127 |
| After startup, kernel-serviced open | EMFILE | |
| After startup, supervisor-mediated open | EACCES (chroot, COW, procfs virtualization) | |
The measured floor for a trivial command is about 4, for both plain exec and chroot. Programs linking more libraries need more.
CPU
Two independent knobs: how much CPU time the sandbox gets, and which cores it runs on.
Throttling
max_cpu is a percentage of a single core, from 1 to 100. It is applied to the entire process group by cycling SIGSTOP and SIGCONT from a supervisor task.
That mechanism is worth understanding before you rely on it. It is a duty cycle, not a scheduler reservation: the sandbox is repeatedly stopped and resumed to approximate the requested share. It is effective for holding a runaway workload down, and it is not the latency guarantee a cgroup CPU quota gives you. For hard scheduling guarantees, use cgroups on the Sandlock process itself.
Pinning
cpu_cores pins the sandbox to a set of cores through sched_setaffinity in the child. This is a real placement decision made by the kernel, not an approximation.
$ sandlock run -c 50 --cpu-cores 0,1 \
-r /usr -r /lib -- ./compute.sh
What the sandbox can see
num_cpus changes the visible CPU count in /proc/cpuinfo, renumbered 0..N-1. This matters more than it sounds: runtimes size their thread pools from what they see there, so a workload on a 128-core host will happily spawn 128 workers inside a sandbox pinned to two cores unless you tell it otherwise.
When max_memory is also set, /proc/meminfo is virtualized to match, so a runtime sizing its heap from available memory sees the sandbox's budget rather than the host's.
Pinning and visibility are separate. cpu_cores decides where the sandbox runs; num_cpus decides what it believes about the machine. Set both when a workload sizes itself from the CPU count.
Disk
max_disk caps the copy-on-write upper layer, so it only applies when workdir is set. Exceeding it surfaces to the workload as ENOSPC, the same error a full disk produces, which means programs handle it through the path they already have for that case.
See storage and quota for where the upper layer lives and how to move it off the source volume.
GPUs
GPU selection is an access-control decision, not a hint. gpu_devices adds Landlock rules for the chosen /dev/nvidia* and /dev/dri/* nodes, and sets CUDA_VISIBLE_DEVICES and ROCR_VISIBLE_DEVICES to match.
The environment variables are a convenience for the frameworks. The boundary is the Landlock rule: a sandbox given device 0 cannot open any other device node, whatever the workload does with its environment. This is the difference between hiding a GPU and denying it.
| Value | Effect |
|---|---|
None (the default) | GPU access denied entirely |
[] | Every GPU exposed |
[0, 2] | Only those devices openable |
From the CLI, --gpu all exposes every device and --gpu 0,2 selects indices.
# The NVIDIA userspace also needs the driver libraries readable, and
# it writes thread names under /proc/self/task.
$ sandlock run --gpu 0 \
-r /usr -r /lib -r /lib64 -r /etc -r /sys -r /proc \
-w /proc/self/task \
-- python3 train.py
The extra paths in that example are not incidental. The NVIDIA userspace stack loads driver libraries from the system directories and writes thread names under /proc/self/task, so a policy that grants only the device node will fail in ways that look like a driver problem rather than a policy one.
Tightening limits at runtime
Memory and process limits can be lowered while the sandbox runs, from a policy callback. A common shape is to give a program the headroom it needs to initialize, then reduce it once startup is done.
def on_event(event, ctx):
if event.syscall == "execve":
ctx.restrict_max_memory(256 * 1024 * 1024)
ctx.restrict_max_processes(8)
return 0
sandbox = Sandbox(
fs_readable=["/usr", "/lib"],
max_memory="2G",
policy_fn=on_event,
)
These only narrow. See Dynamic Policy for the full context API.
What these limits are not
Stating this plainly, because picking the wrong primitive is worse than picking none:
- Not scheduler guarantees. CPU throttling is a stop/resume duty cycle. It bounds consumption; it does not reserve capacity or bound latency.
- Not protection against the workload wedging itself. Limits protect the host. A sandbox can still spin, thrash, or deadlock inside its own budget.
- Not a defence against a privileged launcher. The open-files cap in particular can be raised back by a sandbox launched with the capability to do so.
If you need hard, scheduler-level guarantees, put the Sandlock process itself in a cgroup. The two compose: the cgroup bounds the whole thing from outside, and Sandlock's own limits partition what happens within it.