Kernel Protections
Sandlock refuses to start when a protection it expects is unavailable, which is the right default and the wrong one for a fleet with mixed kernels. Two builder methods relax it, one protection at a time, and never silently.
Landlock did not arrive complete. Each release added access rights, and each is gated behind an ABI version the host kernel either provides or does not. Sandlock names six of them, and by default enforces all six.
If the host is too old for one, the sandbox fails to build with an error naming the protection and the kernel's actual ABI. That is deliberate: the alternative is a sandbox that quietly confines less than the policy says, which is the failure mode you learn about from an incident rather than from an error.
The six protections
| Protection | CLI name | ABI | Kernel | What it stops |
|---|---|---|---|---|
FsRefer |
fs-refer |
v2 | 5.19 | Linking or renaming a file across directories |
FsTruncate |
fs-truncate |
v3 | 6.2 | Truncating a file through truncate, ftruncate, creat, or O_TRUNC |
NetTcp |
net-tcp |
v4 | 6.7 | TCP bind and connect outside the port allowlist |
FsIoctlDev |
fs-ioctl-dev |
v5 | 6.10 | ioctl on character and block devices |
SignalScope |
signal-scope |
v6 | 6.12 | Signalling a process outside the sandbox |
AbstractUnixSocketScope |
abstract-unix-socket-scope |
v6 | 6.12 | Connecting to an abstract UNIX socket outside the sandbox |
This is why the site says Linux 6.12: it is the first kernel that provides all six. Everything below that needs at least one waiver.
Check the host first
sandlock check reports the running kernel's Landlock ABI and, for each protection, whether this host can provide it. Run it before you deploy rather than after a sandbox refuses to build.
$ sandlock check
Three states
Every protection sits in one of three states. Unnamed protections are Strict, so a policy that says nothing about protections behaves exactly as it did before this API existed.
| State | On a kernel that supports it | On a kernel that does not |
|---|---|---|
Strictthe default |
Enforced | The sandbox fails to build, naming the protection and the host's ABI |
Degradableallow_degraded |
Enforced | Skipped, and reported as degraded |
Disableddisable |
Not enforced | Not enforced |
The two waivers answer different questions. allow_degraded is about the fleet: enforce this wherever you can, and do not fail on the hosts that cannot. disable is about the workload: this program legitimately needs the capability the protection blocks, so do not enforce it anywhere.
Reaching for disable when you meant allow_degraded gives up the protection on every host, including the modern ones. It is the more expensive mistake of the two.
Setting them
Both waivers are last-wins per protection: a later call for the same protection supersedes the earlier one, so the two lists cannot contradict each other.
use sandlock_core::{Protection, Sandbox};
// Build on kernels below 6.12, where the v6 scopes do not exist.
// On a kernel that has them, they are still enforced.
let sb = Sandbox::builder()
.fs_read("/usr").fs_read("/lib")
.fs_write("/tmp")
.allow_degraded(Protection::SignalScope)
.allow_degraded(Protection::AbstractUnixSocketScope)
.build()?;
from sandlock import Sandbox, Protection
sandbox = Sandbox(
fs_readable=["/usr", "/lib"],
fs_writable=["/tmp"],
allow_degraded=[
Protection.SIGNAL_SCOPE,
Protection.ABSTRACT_UNIX_SOCKET_SCOPE,
],
)
sb := &sandlock.Sandbox{
FSReadable: []string{"/usr", "/lib"},
FSWritable: []string{"/tmp"},
AllowDegraded: []sandlock.Protection{
sandlock.ProtectionSignalScope,
sandlock.ProtectionAbstractUnixSocketScope,
},
}
# Both flags are repeatable.
$ sandlock run \
--allow-degraded signal-scope \
--allow-degraded abstract-unix-socket-scope \
-r /usr -r /lib -w /tmp -- ./task.sh
# A workload that genuinely must signal a process outside the sandbox.
$ sandlock run --disable signal-scope \
-r /usr -r /lib -w /tmp -- ./supervisor.sh
Reading the result back
A waiver is never silent. Sandbox::active_protections() returns each protection's resolved status, so a deployment can assert its posture rather than assume it.
| Status | Means |
|---|---|
Active | Enforced. The policy asked for it and the host provides it. |
Degraded | Waived by allow_degraded and skipped, because this host is too old. |
Disabled | Waived by disable, regardless of the host. |
Unavailable | Strict and unavailable. This is the state that failed the build. |
The distinction between Degraded and Disabled is worth surfacing in your own monitoring. The first tells you a host is behind and will start enforcing again when it is upgraded. The second tells you a decision was made, and will keep applying after every upgrade until someone revisits it.
FsRefer cannot be disabled
disable(Protection::FsRefer) is rejected at build time with an Invalid error, and the reason is counterintuitive enough to be worth stating.
Landlock denies cross-directory rename and link by default in every ruleset, even when REFER is not handled. Controlled renaming within writable areas works precisely because Sandlock handles REFER and grants it on writable paths, which is what the strict and degradable states do. Un-handling it cannot loosen anything; it only removes the mechanism that makes rename work at all.
So disable here would do the opposite of what the name promises. Sandlock refuses rather than accept a call whose effect contradicts its meaning. If you wanted REFER enforced only where the kernel supports it, that is allow_degraded.
Waivers travel with a checkpoint
The resolved posture is part of a sandbox's checkpoint. A restored sandbox comes back with the protections it was built with, not the ones the new host happens to offer.
That matters for a scheduler that moves sandboxes between machines: restoring onto a newer kernel does not quietly start enforcing something the workload was written against, and restoring onto an older one does not quietly stop enforcing something the policy required.
Choosing what to waive
Every waiver is confinement you no longer have, so the useful question is what each one costs.
| Waiving | What the workload regains |
|---|---|
SignalScope | The ability to signal processes outside the sandbox, including ones belonging to other users' sessions on the same host. |
AbstractUnixSocketScope | The ability to connect to abstract UNIX sockets outside the sandbox, which is how a good deal of desktop and system IPC is reached. |
FsIoctlDev | Device ioctl on any device node the filesystem rules already expose. |
NetTcp | Kernel-level TCP port enforcement. Destination checks on the on-behalf path still apply, so this is narrower than it sounds, but the cheap path is gone. |
FsTruncate | Truncating files it can otherwise open, which turns a read grant into a way to destroy content. |
A waiver is a threat-model change, not a compatibility flag. The two v6 scopes are the usual ones to degrade, because 6.12 is recent and they are the difference between running on it and not. The rest are worth an explicit decision: if you find yourself degrading FsTruncate to support a 6.0 host, the honest summary is that this fleet does not yet have the kernel Sandlock is built for.