Landlock Rulesets and ABI Versions
The defining practical fact about Landlock is that its Application Binary Interface (ABI) is versioned with a single monotonically increasing integer, and every access right is gated on a minimum ABI level the running kernel must support. A binary compiled against the newest kernel headers will routinely run on an older kernel that lacks the newer rights — so a correct Landlock program must query the kernel’s supported ABI version at runtime (via
landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION)) and drop the access rights the kernel is too old to understand before enforcing. Skipping this step turns a forward-compatible sandbox into a crash: requesting an unknown right returnsEINVAL. In the v6.12 LTS kernel the constantLANDLOCK_ABI_VERSIONequals 6 — verified directly insecurity/landlock/syscalls.c, where the version-query path is literallyreturn LANDLOCK_ABI_VERSION;and the macro is#define LANDLOCK_ABI_VERSION 6.
Mental Model — A Feature Dial, Not a Compatibility Flag
The Landlock ABI version is best understood as a monotonic feature dial: ABI N is a strict superset of ABI N−1, each turn of the dial unlocking one or two new access rights or scopes. The number is not a breaking-change marker — there has never been a Landlock ABI break, and old programs keep working on new kernels unchanged. The version exists purely so a new program can discover, at runtime, how far the dial has been turned on this particular kernel and tailor its request accordingly. The contract is explicit and one-directional: the kernel will never silently make a sandbox stricter than the program asked for (that would be a security regression for the program), and the program must never request a right past the kernel’s dial position (that is an error).
This is enforced by a deliberate design choice the kernel documentation calls out: Landlock uses explicit bitflags (notably handled_access_fs) precisely so that “sandboxing [does not] become stricter with system updates” (landlock.rst). A program declares exactly which access classes it handles; rights that did not exist when the program was written are simply not in its bitmask, so a kernel upgrade can never retroactively start denying operations the program never accounted for.
flowchart TB Q["abi = landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION)"] Q --> CHK{"abi < 0?"} CHK -->|"ENOSYS / EOPNOTSUPP"| OFF["Landlock unavailable<br/>(too old, or disabled at boot)<br/>→ run unsandboxed or abort"] CHK -->|"abi ≥ 1"| SW["switch(abi): strip rights<br/>the kernel is too old to know"] SW --> S1["case 1: drop FS_REFER (needs ABI 2)"] S1 --> S2["case 2: drop FS_TRUNCATE (needs ABI 3)"] S2 --> S3["case 3: drop NET bind/connect (needs ABI 4)"] S3 --> S4["case 4: drop FS_IOCTL_DEV (needs ABI 5)"] S4 --> S5["case 5: drop SCOPE signal + abstract-unix (needs ABI 6)"] S5 --> ENF["enforce the trimmed ruleset"]
The canonical best-effort degradation ladder, mirroring the v6.12 sandboxer.c switch. What it shows: after one query call returns the kernel’s ABI level, a switch with deliberate fall-through peels off each right the kernel predates — a kernel reporting ABI 3, for example, falls through cases 3, 4, and 5, stripping network, ioctl-dev, and scope rights, keeping only what ABI 3 supports. The insight to take: the cases fall through from the kernel’s level downward through the newer features, so one number drives the whole trim, and a program never has to hard-code “if kernel ≥ 6.7” — it asks the ABI and the dial tells it everything.
The ABI Version Timeline — Verified Against Kernel Source
Each ABI level maps to the kernel release that introduced it. The table below was verified against the authoritative landlock(7) VERSIONS section and cross-checked by reading the actual UAPI header at each kernel tag (presence/absence of the introducing flag), because version-to-release mappings are exactly the kind of fact that secondary sources routinely get wrong.
| ABI | Kernel | What it added | Constants introduced |
|---|---|---|---|
| 1 | 5.13 | Filesystem access control (the original 13 FS rights) | LANDLOCK_ACCESS_FS_EXECUTE, WRITE_FILE, READ_FILE, READ_DIR, REMOVE_DIR, REMOVE_FILE, MAKE_CHAR, MAKE_DIR, MAKE_REG, MAKE_SOCK, MAKE_FIFO, MAKE_BLOCK, MAKE_SYM |
| 2 | 5.19 | File reparenting (rename/link across directories) | LANDLOCK_ACCESS_FS_REFER (bit 13) |
| 3 | 6.2 | File truncation control | LANDLOCK_ACCESS_FS_TRUNCATE (bit 14) |
| 4 | 6.7 | TCP bind/connect port restriction | LANDLOCK_ACCESS_NET_BIND_TCP, LANDLOCK_ACCESS_NET_CONNECT_TCP (+ LANDLOCK_RULE_NET_PORT) |
| 5 | 6.10 | ioctl(2) restriction on character/block devices | LANDLOCK_ACCESS_FS_IOCTL_DEV (bit 15) |
| 6 | 6.12 | Signal scoping + abstract-UNIX-socket scoping | LANDLOCK_SCOPE_SIGNAL, LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET (+ the scoped ruleset field) |
The release pinning of each row, and how each was verified during this task:
-
ABI 1 → Linux 5.13. Landlock merged in 5.13; the man-page VERSIONS table lists ABI 1 = 5.13, and the kernel doc states “Landlock was first introduced in Linux 5.13” (landlock.rst).
-
ABI 2 → Linux 5.19.
LANDLOCK_ACCESS_FS_REFER(bit 13) is absent from the v5.16 UAPI header (highest FS bit there isMAKE_SYM= bit 12) and present in the v5.19 header. Thelandlock(7)VERSIONS table agrees: ABI 2 = 5.19.Uncertain Linux Security MOC) state ABI 2 = 5.16. That appears to be wrong. Reason: I confirmed by source inspection that
LANDLOCK_ACCESS_FS_REFERis absent in v5.16 and present in v5.19, andlandlock(7)lists ABI 2 = 5.19 — so the correct mapping is ABI 2 = Linux 5.19, not 5.16. (Thereferpatches were authored in the 5.16 timeframe but did not land in a released kernel until 5.19.) To resolve: this is resolved against primary sources; the MOC's "5.16" should be corrected to "5.19". uncertainVerify: the dispatch brief (and the parent
-
ABI 3 → Linux 6.2.
LANDLOCK_ACCESS_FS_TRUNCATE(bit 14) is present in the v6.2 header; network rights are still absent there. Man page: ABI 3 = 6.2. -
ABI 4 → Linux 6.7.
LANDLOCK_ACCESS_NET_BIND_TCP/CONNECT_TCPare present in the v6.7 header, whileIOCTL_DEV(ABI 5) andSCOPE_SIGNAL(ABI 6) are still absent — pinning the network rights to ABI 4 / 6.7 exactly. Man page agrees. -
ABI 5 → Linux 6.10.
LANDLOCK_ACCESS_FS_IOCTL_DEV(bit 15) is absent in v6.7 and present by v6.12; the man-page table dates it to 6.10. -
ABI 6 → Linux 6.12. The two scope flags
LANDLOCK_SCOPE_SIGNALandLANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKETand thescopedruleset field are present in v6.12, and v6.12’sLANDLOCK_ABI_VERSIONis 6 — directly verified insyscalls.candlimits.h(whereLANDLOCK_LAST_SCOPEisLANDLOCK_SCOPE_SIGNAL).
Uncertain
Verify: ABI levels beyond 6 (the kernel.org master/7.x render lists ABI 7 logging flags, ABI 8
LANDLOCK_RESTRICT_SELF_TSYNCcross-thread enforcement, and ABI 9LANDLOCK_ACCESS_FS_RESOLVE_UNIX). Reason: these appear only in the livedocs.kernel.orgrender tracking a post-6.12 mainline, which the version-discipline rule for this vault excludes; they are not in v6.12. To resolve: re-pin against the relevant kernel tag (likely 6.16+ for ABI 7/8 and later for ABI 9) if/when the vault tracks a newer kernel. For the 6.12 / 6.18 LTS era this note targets, 6 is the ceiling on 6.12 (the 6.18 ceiling was not pinned during this task). uncertain
How the Version Query Works Mechanically
The runtime query reuses the landlock_create_ruleset syscall with a sentinel argument set. From the v6.12 syscalls.c: when flags == LANDLOCK_CREATE_RULESET_VERSION and attr == NULL and size == 0, the syscall short-circuits and returns the highest supported ABI version instead of creating a ruleset — the kernel documents this as “the returned value is the highest supported Landlock ABI version,” and the code path ends in return LANDLOCK_ABI_VERSION;. The returned value is a positive integer starting at 1 (landlock_create_ruleset(2)).
Three distinguishable outcomes a program must handle:
- A positive number (e.g.
6) — Landlock is present and enabled; this is the dial position. -ENOSYS(errno == ENOSYS) — the syscall itself does not exist; the kernel predates Landlock (older than 5.13) or was built withoutCONFIG_SECURITY_LANDLOCK. Treat as “Landlock unavailable.”-EOPNOTSUPP— Landlock is compiled in but disabled at boot time (not in the active LSM list). The man page documents this exact case: “Landlock is supported by the kernel but disabled at boot time” (landlock_create_ruleset(2)). The program can choose to fail closed (abort) or fail open (run unsandboxed), depending on its threat model.
LANDLOCK_CREATE_RULESET_VERSION itself is (1U << 0) in the v6.12 UAPI header.
The Best-Effort Pattern — The Canonical Code
“Best-effort” is the term the kernel docs use for the correct way to use Landlock across heterogeneous kernels: apply as much sandboxing as this kernel supports, and degrade gracefully on older ones. The rationale, verbatim: “Because we may not know which kernel version an application will be executed on, it is safer to follow a best-effort security approach” (landlock.rst).
The reference implementation is the kernel’s own samples/landlock/sandboxer.c. Here is its degradation switch, quoted verbatim from v6.12:
abi = landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION);
/* ... error handling on abi < 0 ... */
switch (abi) {
case 1:
/* Removes LANDLOCK_ACCESS_FS_REFER for ABI < 2 */
ruleset_attr.handled_access_fs &= ~LANDLOCK_ACCESS_FS_REFER;
__attribute__((fallthrough));
case 2:
/* Removes LANDLOCK_ACCESS_FS_TRUNCATE for ABI < 3 */
ruleset_attr.handled_access_fs &= ~LANDLOCK_ACCESS_FS_TRUNCATE;
__attribute__((fallthrough));
case 3:
/* Removes network support for ABI < 4 */
ruleset_attr.handled_access_net &=
~(LANDLOCK_ACCESS_NET_BIND_TCP |
LANDLOCK_ACCESS_NET_CONNECT_TCP);
__attribute__((fallthrough));
case 4:
/* Removes LANDLOCK_ACCESS_FS_IOCTL_DEV for ABI < 5 */
ruleset_attr.handled_access_fs &= ~LANDLOCK_ACCESS_FS_IOCTL_DEV;
__attribute__((fallthrough));
case 5:
/* Removes LANDLOCK_SCOPE_* for ABI < 6 */
ruleset_attr.scoped &= ~(LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET |
LANDLOCK_SCOPE_SIGNAL);
}The mechanism rewards careful reading. The program starts by populating ruleset_attr with every right it would like — using the newest headers it was compiled against. The switch then jumps to the case matching the kernel’s actual ABI and, via the deliberate fallthrough, peels off every right introduced after that level. A kernel reporting ABI 1 enters case 1 and falls through every case, stripping REFER, TRUNCATE, network, IOCTL_DEV, and scopes — leaving the original ABI-1 filesystem rights. A kernel reporting ABI 4 enters case 4, stripping only IOCTL_DEV and the scopes (it keeps network, truncate, refer). A kernel at ABI 6 matches no case and keeps everything. The note that “the cases fall through from the kernel’s level downward through the newer features” is the whole trick — one integer drives the entire downgrade, with no if (kernel >= 6.7) version arithmetic anywhere. After this switch, the trimmed ruleset_attr is safe to pass to landlock_create_ruleset() on that kernel without risking EINVAL.
Uncertain
Verify: the inline
case-comment text (e.g. “Removes LANDLOCK_ACCESS_FS_REFER for ABI < 2”) is reproduced from a summary of the v6.12 sample and may differ slightly from the exact upstream comment wording; the code (the&=masks and fall-through structure) is verified verbatim. Reason: the comments were not re-fetched character-for-character. To resolve: diff against the rawsamples/landlock/sandboxer.cat the v6.12 tag if exact comment text matters. uncertain
Rulesets, Layers, and the Stacking Limit
A ruleset is the kernel object behind the fd from landlock_create_ruleset (see Landlock for the create/add/enforce lifecycle). When landlock_restrict_self enforces a ruleset, it becomes one layer of the thread’s domain; calling restrict_self again adds another layer, and the effective policy is the intersection of all layers — each layer can only further restrict, never relax. Per landlock_restrict_self(2): “A thread can be restricted with multiple rulesets that are then composed together to form the thread’s Landlock domain,” updated so that “the constraints of each past and future composed rulesets will restrict the thread and its future children for their entire life.”
There is a hard cap on layers. In v6.12, security/landlock/limits.h defines #define LANDLOCK_MAX_NUM_LAYERS 16 — so a thread can stack at most 16 Landlock rulesets; a 17th restrict_self fails. The number of rules within a single ruleset is effectively unbounded (LANDLOCK_MAX_NUM_RULES = U32_MAX).
Uncertain
Verify: the layer-stack limit value. v6.12
limits.hsays 16 (LANDLOCK_MAX_NUM_LAYERS 16), and the v6.12 doc says “There is a limit of 16 layers of stacked rulesets.” But the currentlandlock_restrict_self(2)man page states the limit “is currently 64” (and reportsE2BIGon overflow). Reason: the limit was raised in a kernel after 6.12 and the man page tracks the newer value. To resolve: for 6.12 the authoritative value is 16; the 64 figure applies to a later kernel (re-pin for 6.18 against its ownlimits.hbefore quoting 16 there). uncertain
Failure Modes
EINVAL from create_ruleset on an old kernel. The signature failure of not doing best-effort: the program hard-codes a right (say LANDLOCK_ACCESS_FS_IOCTL_DEV) the running kernel’s ABI predates, and landlock_create_ruleset rejects the unknown bit with EINVAL. The fix is always the version-query + downgrade switch above — never assume the build kernel equals the run kernel.
Treating 0 as a valid version. The version query returns values starting at 1; there is no “ABI 0.” A returned 0 would be anomalous. The real “unavailable” signals are the negative ENOSYS/EOPNOTSUPP errnos, not a zero.
Forgetting that handled_access_net and scoped are separate fields. Stripping a network right means masking handled_access_net; stripping a scope means masking scoped; stripping a filesystem right means masking handled_access_fs. Mixing them up (e.g. trying to &= ~LANDLOCK_SCOPE_SIGNAL out of handled_access_fs) silently leaves the scope in the wrong field and either does nothing or trips EINVAL.
Over-trimming and silently weakening security. Best-effort degradation is a deliberate weakening on old kernels. A program with a strict threat model may prefer to fail closed — refuse to run if the kernel’s ABI is below some minimum — rather than run with a partially effective sandbox. Whether to fail open or closed is a policy decision the version query enables but does not make for you.
Production Notes
Real Landlock integrations all converge on this same query-then-trim shape. The rust-landlock crate (authored by Landlock’s own maintainer) builds the best-effort downgrade into its Compatibility/CompatLevel API so callers can declare “best-effort” or “strict” intent declaratively rather than hand-writing the switch; the Go go-landlock library exposes the same idea through versioned config presets (e.g. “v5” vs “v6” rule builders). systemd’s RestrictFileSystems= likewise probes the running kernel’s Landlock support before applying. The common engineering lesson: never compile-time-assume the runtime kernel’s ABI — the whole point of the versioned ABI is that one binary spans many kernels, and the runtime query is the contract that makes that safe.
See Also
- Landlock — the LSM itself: the three syscalls, the stacking model, the
no_new_privsprerequisite (read this first) - Landlock vs seccomp vs Namespaces — choosing among unprivileged-sandboxing primitives
- no_new_privs and Privilege Escalation Control — the mandatory prerequisite for unprivileged
restrict_self - The Linux Security Module Framework — the LSM hook infrastructure Landlock registers against
- Linux Security MOC — parent map (§F, Unprivileged Sandboxing); note: the MOC’s “ABI 2 = 5.16” should read 5.19 per the verification above