Landlock explained: unprivileged filesystem sandboxing in the Linux kernel, and where it stops
Landlock is a Linux Security Module that lets an ordinary, unprivileged process tell the kernel: from this moment on, the only parts of the filesystem I am allowed to touch are these, with these specific rights, and I would like you to enforce that on me and on every child I ever spawn, permanently. No root. No policy file. No conversation with a sysadmin. The process asks to be caged, the kernel obliges, and there is no key. It landed in mainline in 2021 and has been quietly growing capabilities in every kernel release since.
I'm Ajay; I built PandaStack. This post covers what Landlock actually is and why it's an odd creature among LSMs, the ruleset model in precise terms (including why rules are named by file descriptor rather than by path string, which is the whole robustness story), why ABI versioning is a real operational problem rather than a footnote, the things Landlock emphatically does not cover, how it compares to seccomp, namespaces, gVisor, and a microVM, and how to actually use it without spending an afternoon debugging a program that can no longer find its own libc. The honest conclusion up front: Landlock is excellent defense in depth inside a boundary, and a poor substitute for one.
Why Landlock is a strange LSM
Linux Security Modules are, historically, an administrator's tool. SELinux and AppArmor both work the same way at the level that matters: someone with root writes a system-wide policy describing what labelled subjects may do to labelled objects, that policy is loaded into the kernel, and every process on the machine is subject to it whether it knows or not. The policy is centralized, it is privileged, and it is emphatically not something your application can decide for itself at 3pm on a Tuesday because it's about to parse a file a stranger uploaded.
That model is a good fit for hardening a distribution and a bad fit for the situation most of us are actually in. If you're writing a program that has one risky subsystem — a parser, a plugin host, a template renderer, a build step, an LLM's freshly generated Python — you know far more about what that subsystem legitimately needs than any administrator ever will. But under the classic LSM model you can't act on that knowledge. You'd have to ship an AppArmor profile, get it installed with root, and hope the operator's distribution agrees with your ideas about paths.
Landlock inverts it. The policy is written by the program, applied to the program, and requires no privilege at all. It is stackable, meaning it composes with whatever SELinux or AppArmor policy is already in force rather than replacing it — the checks are additive, and a Landlock ruleset can only ever remove access, never grant it back. That last property is what makes an unprivileged interface safe to expose in the first place: there is no way to phrase a Landlock ruleset that gives you more than you started with.
- Unprivileged — any process can restrict itself. This is the design goal, not a side effect. It means library authors and application developers can sandbox risky code paths without asking anyone for anything.
- Self-imposed — you cannot Landlock somebody else's process. There is no landlock_restrict_that_guy(). A supervisor confines a child by restricting itself and then exec-ing the child, which is a meaningfully different shape from how SELinux thinks.
- Stackable and additive — it layers on top of existing LSMs and other Landlock domains. Restrictions accumulate; nesting a second ruleset can only narrow further.
- Irreversible and inherited — once applied, a process cannot un-apply it, and every fork and exec carries it. A sandboxed process cannot launch an unsandboxed helper as an escape hatch.
- Object-scoped, not syscall-scoped — Landlock does not care which syscall you used to reach a file. It cares which file. That is a different axis from seccomp entirely, which is why the two compose so well.
The model, precisely: rulesets, hierarchies, and one irreversible syscall
There are three syscalls and one prerequisite, and the flow is always the same. You create a ruleset that declares which access rights you intend to govern. You add rules granting a subset of those rights on specific filesystem hierarchies. Then you call the last syscall, which applies the ruleset to yourself and can never be undone.
The subtlety that trips people is the difference between handled rights and allowed rights. When you create the ruleset you pass a mask of rights you are choosing to handle — that is, rights the kernel should start enforcing for this domain. Anything you handle is denied everywhere by default. Anything you do not handle is simply not governed by Landlock at all and continues to work as before. So the ruleset creation is where you decide the scope of the cage, and the individual rules are where you punch specific holes in it. Handle a right and then forget to grant it anywhere and you have banned it outright, which is occasionally exactly what you want and more often the reason your program can no longer write to its log file.
Rules name hierarchies by file descriptor, not by path string
This is the part worth internalizing, because it is where Landlock differs from a decade of path-based sandboxing tools that were subtly broken. A Landlock rule does not say "allow reads under the string /work". It says "allow reads beneath the directory referred to by this open file descriptor". You open the directory, hand the kernel the descriptor, and the kernel pins the underlying filesystem object.
The consequences are exactly the ones you want. A rename cannot walk your rule somewhere else, because the rule was never attached to the name. A symlink planted mid-flight cannot redirect an allowed path outside the hierarchy, because resolution is checked against the pinned hierarchy rather than against a string you compared once. The classic time-of-check-to-time-of-use race — validate the path, attacker swaps it, use the path — has nothing to attack, because there is no check-then-use window; the enforcement happens inside path resolution itself, every single time.
There's a related right worth knowing about, usually spelled REFER, which governs linking and renaming files across hierarchy boundaries. It exists because moving a file between two hierarchies is a genuinely ambiguous operation under a hierarchy-scoped policy — the file would arrive somewhere with different rules attached. Landlock's answer is to forbid cross-hierarchy refer operations unless you explicitly handle and grant that right. If your program does an atomic write by creating a temp file in one directory and renaming it into another, this is the right that will bite you, and the error will look like a baffling EXDEV on a single filesystem.
no_new_privs, and the door that only closes
Before you can restrict yourself, you must set the no_new_privs bit via prctl (unless you hold CAP_SYS_ADMIN, which rather defeats the point). The reason is straightforward once you see it: without no_new_privs, a confined process could exec a setuid binary, gain privileges it didn't have, and potentially step outside the constraints it just accepted. no_new_privs pre-commits the process to never gaining privileges through exec, which is what makes an unprivileged self-restriction meaningful. It is itself irreversible and inherited, which is a theme.
After the final syscall, restrictions only accumulate. A confined process can create a further, narrower ruleset and apply that too — nesting domains is fine and useful — but it can never widen. Neither can its children, which is the property that makes the supervisor pattern work: a launcher sets up the cage, applies it to itself, then execs the untrusted binary, which is born inside the cage with no memory of a world outside it.
/* Landlock: a process sandboxes ITSELF. No root, no system-wide policy, no
* sysadmin. glibc ships no wrappers for these three syscalls, so we call them
* directly -- this is normal and expected, not a sign you're off the path. */
#define _GNU_SOURCE
#include <linux/landlock.h>
#include <sys/syscall.h>
#include <sys/prctl.h>
#include <fcntl.h>
#include <unistd.h>
static int ll_create(const struct landlock_ruleset_attr *attr,
size_t size, __u32 flags) {
return syscall(__NR_landlock_create_ruleset, attr, size, flags);
}
static int ll_add(int fd, enum landlock_rule_type type,
const void *attr, __u32 flags) {
return syscall(__NR_landlock_add_rule, fd, type, attr, flags);
}
static int ll_restrict(int fd, __u32 flags) {
return syscall(__NR_landlock_restrict_self, fd, flags);
}
int confine(void) {
/* STEP 1 -- ask the RUNNING kernel what it supports. Not optional: one
* binary meets many kernels, and asking for a right this kernel has never
* heard of fails the whole ruleset, not just that right. */
int abi = ll_create(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION);
if (abi < 0)
return -1; /* Landlock absent. Decide LOUDLY. Do not drift onward
* believing you are sandboxed when you are not. */
__u64 fs = LANDLOCK_ACCESS_FS_EXECUTE | LANDLOCK_ACCESS_FS_READ_FILE |
LANDLOCK_ACCESS_FS_READ_DIR | LANDLOCK_ACCESS_FS_WRITE_FILE |
LANDLOCK_ACCESS_FS_REMOVE_FILE | LANDLOCK_ACCESS_FS_REMOVE_DIR |
LANDLOCK_ACCESS_FS_MAKE_REG | LANDLOCK_ACCESS_FS_MAKE_DIR |
LANDLOCK_ACCESS_FS_MAKE_SYM | LANDLOCK_ACCESS_FS_REFER |
LANDLOCK_ACCESS_FS_TRUNCATE;
/* STEP 2 -- downgrade, don't die. Each ABI bump added rights; mask off the
* ones this kernel predates. Check the current kernel docs for which right
* arrived in which ABI rather than trusting a hardcoded table like this. */
if (abi < 2) fs &= ~LANDLOCK_ACCESS_FS_REFER;
if (abi < 3) fs &= ~LANDLOCK_ACCESS_FS_TRUNCATE;
/* Be honest with yourself: every line above is a line that says
* "get less sandboxing than you asked for, and continue anyway." */
/* Designated initializer zeroes every field your headers know about but
* this kernel does not -- newer struct fields must be zero on old kernels. */
struct landlock_ruleset_attr rs = { .handled_access_fs = fs };
int rsfd = ll_create(&rs, sizeof(rs), 0);
if (rsfd < 0) return -1;
/* STEP 3 -- rules name a HIERARCHY by open fd, NOT by path string. That is
* the robustness property: the kernel pins the object, so a later rename,
* symlink, or bind mount cannot walk the rule somewhere else. There is no
* check-then-use window for a TOCTOU race to squeeze into. */
struct landlock_path_beneath_attr ro = {
.allowed_access = LANDLOCK_ACCESS_FS_READ_FILE |
LANDLOCK_ACCESS_FS_READ_DIR |
LANDLOCK_ACCESS_FS_EXECUTE,
};
/* The interpreter, its libraries, and its stdlib. Forget these and your
* program dies at exec with a link error, not a permission error. */
const char *ro_paths[] = { "/usr", "/lib", "/lib64", "/etc", NULL };
for (int i = 0; ro_paths[i]; i++) {
ro.parent_fd = open(ro_paths[i], O_PATH | O_CLOEXEC);
if (ro.parent_fd < 0) continue; /* absent on this distro: fine */
ll_add(rsfd, LANDLOCK_RULE_PATH_BENEATH, &ro, 0);
close(ro.parent_fd);
}
/* The one place the workload may write. Note that read/write/create/delete
* are SEPARATE rights: granting writes does not grant deletes. */
struct landlock_path_beneath_attr rw = {
.allowed_access = ro.allowed_access |
LANDLOCK_ACCESS_FS_WRITE_FILE|
LANDLOCK_ACCESS_FS_MAKE_REG |
LANDLOCK_ACCESS_FS_MAKE_DIR |
LANDLOCK_ACCESS_FS_REMOVE_FILE,
};
const char *rw_paths[] = { "/work", "/tmp", NULL };
for (int i = 0; rw_paths[i]; i++) {
rw.parent_fd = open(rw_paths[i], O_PATH | O_CLOEXEC);
if (rw.parent_fd < 0) continue;
ll_add(rsfd, LANDLOCK_RULE_PATH_BENEATH, &rw, 0);
close(rw.parent_fd);
}
/* STEP 4 -- no_new_privs is REQUIRED. Without it a confined process could
* exec a setuid binary and acquire privileges it just promised not to have. */
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) return -1;
/* STEP 5 -- the door that only closes. Irreversible, inherited across every
* fork and exec from here on. There is no un-landlock. */
if (ll_restrict(rsfd, 0)) return -1;
close(rsfd);
/* Anything exec'd now is BORN inside the cage and cannot widen it. */
execl("/usr/bin/python3", "python3", "/work/task.py", (char *)NULL);
return -1;
}ABI versioning is an operational concern, not a footnote
Landlock ships as a numbered ABI, and that number has gone up repeatedly since it merged. The first version handled filesystem access rights. Later versions refined the filesystem model — adding governance over operations the original release simply didn't cover — and more recent versions extended Landlock beyond the filesystem entirely, adding restrictions on TCP bind and connect, and scoping for certain IPC mechanisms. I am deliberately not attaching kernel version numbers to specific features here, because that table changes and a blog post is the worst possible place to learn it. Read the current kernel documentation for Landlock; it maintains an explicit ABI-to-feature mapping and it is the only source that will still be right next year.
What matters architecturally is how you handle the variance. The kernel gives you a clean way to ask: create a ruleset with the version flag and no attributes, and you get back the supported ABI level as an integer. From there the correct pattern is best-effort — build the ruleset you want, mask off the rights this kernel doesn't know, and proceed. Hard-failing on an older kernel is usually worse than degrading, because it turns a partial defense into no deployment at all.
There's a second variance problem underneath the ABI one: Landlock can be compiled into a kernel and still not be active. Some distributions build the module but don't include it in the kernel's active LSM list, which means it has to be enabled at boot. The failure mode is quiet — your ruleset creation returns an error, your best-effort path shrugs, and your process runs unconfined. Probe at startup and treat absence as a configuration fact you report, not a condition you swallow.
# ---- 1. Is Landlock actually ENABLED here? Compiled in != active. --------
# The authoritative list of LSMs the running kernel has turned on:
cat /sys/kernel/security/lsm
# e.g. lockdown,capability,landlock,yama,apparmor
#
# If "landlock" is missing but the config says it exists, the distro built it
# and left it out of the active set -- it needs enabling on the kernel command
# line (lsm=...,landlock). This is the quiet failure: your program will happily
# report "sandbox applied" while enforcing nothing.
grep -E 'CONFIG_SECURITY_LANDLOCK' "/boot/config-$(uname -r)" || true
# ---- 2. Which ABI? Ask the kernel; never infer it from uname. ------------
# landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION) returns
# the supported ABI level as a positive integer.
cat > /tmp/llabi.c <<'EOF'
#define _GNU_SOURCE
#include <linux/landlock.h>
#include <sys/syscall.h>
#include <stdio.h>
#include <unistd.h>
int main(void) {
int v = syscall(__NR_landlock_create_ruleset, NULL, 0,
LANDLOCK_CREATE_RULESET_VERSION);
printf("landlock abi: %d\n", v); /* negative == unavailable */
return v < 0;
}
EOF
cc -o /tmp/llabi /tmp/llabi.c && /tmp/llabi
# ---- 3. Prove the sandbox denies what you THINK it denies. ---------------
# The kernel tree ships samples/landlock/sandboxer.c; build it and use it as a
# harness. LL_FS_RO / LL_FS_RW are colon-separated hierarchies.
#
# A sandbox you never tested negatively is a sandbox you are guessing about.
export LL_FS_RO="/usr:/lib:/lib64:/etc"
export LL_FS_RW="/work:/tmp"
./sandboxer /bin/sh -c '
# POSITIVE: the things the workload legitimately needs must still work.
echo hi > /work/probe && echo "write /work ....... allowed (correct)"
/usr/bin/python3 -c "pass" && echo "exec python3 ..... allowed (correct)"
# NEGATIVE: the whole point. Each of these MUST fail.
cat /root/.ssh/id_ed25519 2>/dev/null && echo "READ KEY .... LEAK" \
|| echo "read ssh key ..... denied (correct)"
echo x > /usr/local/bin/pwn 2>/dev/null && echo "WRITE /usr .. LEAK" \
|| echo "write /usr ....... denied (correct)"
ls /home 2>/dev/null >/dev/null && echo "LIST /home .. LEAK" \
|| echo "list /home ....... denied (correct)"
'
# ---- 4. The reality check the post is really about. ----------------------
# Landlock denied every filesystem probe above. It did NOT deny this:
./sandboxer /bin/sh -c 'uname -a; cat /proc/self/status | head -1'
# ...and it does nothing whatsoever about the syscall that carries the next
# kernel CVE. Different axis. See the comparison section.What Landlock does not cover
This is the section that earns the post, so let's be blunt. Landlock is a filesystem access control, plus — in newer ABIs — a narrow slice of network control. That is the entire scope. Everything in the following list is outside it, and every item is something people assume Landlock handles because "sandbox" is a word that means whatever the reader needs it to mean.
- It is not a syscall filter — Landlock governs which filesystem objects you can reach, not which syscalls you can make. A Landlocked process can still call every syscall in the table. The exotic ioctl path, the obscure socket family, the keyring call that carries next year's privilege-escalation CVE: all reachable, all unrestricted. That is seccomp's job and Landlock does not do it.
- It is not a resource limiter — there is no memory ceiling, no CPU quota, no process count, no fd limit, no I/O bandwidth. A confined process can fork-bomb, can allocate until the OOM killer picks a victim (and the OOM killer has never cared whose job was reasonable), and can spin a core forever, all while scrupulously obeying every filesystem rule you wrote. That is cgroups' job.
- It is not a namespace — Landlock changes what you may access, not what you can see. The process still observes the host's PID space, the host's network interfaces, the host's mount table, the host's uname. It does not give you a private view of anything; it only refuses some operations within the shared view.
- It is not a kernel boundary — and this is the one that matters. A fully Landlocked process is still a process running directly on your host kernel, sharing it with every other tenant on the machine. A memory-corruption bug reached through a syscall Landlock permits — which is all of them — compromises that kernel, and a compromised kernel does not consult LSM hooks on its way out.
- It does not restrict what you already opened — enforcement happens at path resolution. File descriptors opened before restrict_self remain fully usable afterward. This is deliberate and occasionally useful (pass a log fd in, then confine), and it is also a real footgun if you inherited descriptors you didn't audit.
- It does not govern the whole filesystem-adjacent surface — mount operations, for instance, are outside its remit, and various filesystem-adjacent behaviours are simply not modelled. Landlock does implement some non-filesystem protections, notably preventing a confined process from ptracing processes outside its own domain, but treat the exact boundaries as something to verify in current kernel docs rather than infer.
Landlock tells the kernel which rooms you may enter. It does not give you a different building, and it has no opinion at all about whether the building is on fire.
None of this is a criticism. Landlock does one job and does it unusually well, with a design that is robust in exactly the places path-based sandboxing traditionally wasn't. The criticism is reserved for the deployment where Landlock is the only layer and the workload is code that arrived over HTTP from a language model thirty seconds ago.
Landlock vs seccomp vs namespaces vs gVisor vs a microVM
The clean way to think about the layers is that each one restricts a different noun. seccomp restricts which syscalls a process may make. Landlock restricts which filesystem objects it may reach. Namespaces change what it can see. cgroups bound what it can consume. gVisor changes what is answering its syscalls. A microVM changes whose kernel it is talking to. These are orthogonal axes, which is precisely why they compose instead of competing — and why any argument of the form "you don't need X, you have Y" is usually comparing two different axes and declaring one the winner.
- What it restricts — Landlock: which filesystem hierarchies and which rights on them, plus limited TCP bind/connect in newer ABIs. seccomp-bpf: which syscall numbers (and, within limits, which scalar arguments) may be issued. User namespaces + chroot: what the process can see and which UIDs it maps to, not what it may call. AppArmor/SELinux: a centrally-authored policy over labelled subjects and objects, broader than Landlock but not self-applied. gVisor: nothing directly — it re-implements the syscall surface in userspace so the host kernel sees a much smaller one. MicroVM: nothing at the syscall level; it changes which kernel receives the syscalls at all.
- Privilege required to apply it — Landlock: none, which is the entire point; any process can confine itself. seccomp-bpf: none, given no_new_privs. User namespaces + chroot: no root needed for a user namespace on most modern distros, though some hardening configurations restrict unprivileged userns, and chroot alone does require privilege. AppArmor/SELinux: root, plus a policy installed into the system. gVisor: a runtime you deploy on the host. MicroVM: /dev/kvm access and a host you control.
- Granularity — Landlock: per-hierarchy, per-right, with read, write, execute, create, delete, and truncate as separate grants you must reason about individually. seccomp-bpf: per-syscall, with only shallow argument inspection (it cannot dereference pointers, so it cannot filter on a path string). User namespaces + chroot: coarse — a subtree, an ID map. AppArmor/SELinux: fine-grained but system-scoped and administratively owned. gVisor: whole-workload. MicroVM: whole-workload, with a device-level interface in place of a syscall-level one.
- Survives a kernel bug — Landlock: no. It is an LSM hook inside the kernel you are trying to protect; a successful kernel exploit is a compromise of the enforcer itself. seccomp-bpf: no, for the same reason, though it lowers the odds by making many vulnerable paths unreachable. User namespaces + chroot: no, and unprivileged user namespaces have historically been a source of escalation bugs in their own right. AppArmor/SELinux: no. gVisor: partially — the host kernel surface is much smaller, but the Sentry is a large piece of software with its own attack surface. MicroVM: this is the one designed for it; the guest talks to its own kernel, and a guest kernel compromise gets you a throwaway VM.
- Resource control — Landlock: none at all; a confined process can exhaust memory and CPU freely. seccomp-bpf: none, beyond blocking calls like fork if you're willing to break the workload. User namespaces + chroot: none by itself; this is cgroups' job. AppArmor/SELinux: none. gVisor: some, via its runtime and the cgroups underneath it. MicroVM: hard and structural — vCPU count and RAM are properties of the machine, and the guest's own OOM killer eats the guest's own processes.
- Blast radius when the workload is fully hostile — Landlock: the host kernel and every co-tenant on it, if the workload finds a kernel bug through any permitted syscall. seccomp-bpf: same, with fewer routes there. User namespaces + chroot: same. AppArmor/SELinux: same. gVisor: the Sentry process plus whatever host surface it retained. MicroVM: one guest, containing exactly one workload, which you were going to delete anyway.
- Typical use — Landlock: an application confining its own risky subsystem — a parser, a plugin, a build step, a renderer — with no cooperation from the operator. seccomp-bpf: hardening a daemon or a VMM, and the default profile every container runtime applies. User namespaces + chroot: the substrate of container runtimes. AppArmor/SELinux: distribution and enterprise hardening of the whole system. gVisor: a middle tier for multi-tenant workloads that want stronger-than-container without a VM. MicroVM: multi-tenant execution of code you have no reason to trust.
- How they compose — Landlock: excellent as an inner layer inside any of the others; it costs nothing and it is orthogonal to all of them. The productive stack for untrusted code is a microVM as the boundary, seccomp and cgroups hardening the host-side process, and Landlock plus seccomp inside the guest around the workload itself. Nobody in that list is a substitute for anybody else.
I've written the syscall-axis half of this argument in more depth in /blog/seccomp-explained, the userspace-kernel-reimplementation angle in /blog/gvisor-syscall-interception-explained, and the full stack-ranking in /blog/code-isolation-hierarchy. The short version: Landlock belongs in your stack. It does not belong at the bottom of it holding everything up.
Using it well: good targets, allow-list discipline, and the classic footgun
The workloads where Landlock genuinely shines share a shape: a component whose legitimate filesystem needs are small, known, and stable, sitting inside a larger program whose needs are not. A document or image parser that should only ever read one input file. A plugin loaded from a marketplace. A build step that should touch the source tree, a cache directory, and nothing resembling your CI credentials. A template renderer. Your own binary, confining itself after startup once it has opened everything it needs, so that a bug in request handling can't reach the private key it loaded at boot.
Reasoning about the allow-list is mostly discipline plus one insight: grant the narrowest hierarchy that works, and remember that rights are independent. Read, write, execute, create, delete, and truncate are separate grants. Granting write to a directory does not grant delete. Granting read does not grant directory listing — reading a file and enumerating a directory are different rights, and a program that can open a known path but not list its parent is a perfectly sensible thing to build on purpose. Start by handling everything, granting nothing, and then add grants until the workload passes its tests. Starting from the other direction — handling only the rights you're worried about — is how you end up with a ruleset that ignores the operation the attacker actually used.
The classic footgun: your process needs more than you think
Everyone writes their first ruleset granting read-write on the working directory, applies it, and watches the program die instantly with something incomprehensible. The reason is that a modern process depends on a startlingly wide set of files it never explicitly opens. The dynamic loader needs the interpreter and the shared libraries. A Python process needs its entire standard library tree, its site-packages, and the shared objects behind any C extension. Name resolution reads several files under /etc. TLS needs the CA bundle. Timezone-aware code reads the zoneinfo database. Locale-aware code reads locale archives. Temp file creation needs a writable temp directory, and "writable" here means the create right specifically, not merely the write right.
- Grant the runtime tree read plus execute — the interpreter, its libraries, its standard library. Forgetting execute on a library path produces a failure at load time that reads like corruption rather than policy.
- Grant /etc read, or at minimum the specific subtrees for resolver config, CA certificates, and locale — then be suspicious about what else lives there that you just made readable.
- Grant a private temp directory with create and delete, not just write — and prefer a per-job directory over the shared /tmp, since a hierarchy grant covers everything beneath it including whatever a neighbour left there.
- Watch out for atomic-write patterns — create-temp-then-rename across two hierarchies needs the refer right handled and granted, and without it you'll debug a cross-device error that makes no sense on one filesystem.
- Audit your inherited file descriptors — anything already open when you restrict yourself stays open and stays usable. That is a feature when you meant it and a hole when you didn't.
- Test the denials, not just the successes — a ruleset that lets your happy path run tells you nothing. Write the negative assertions and run them in CI, or you are shipping a belief rather than a control.
On tooling: the kernel tree ships a sample sandboxer that is a genuinely useful harness for experimenting, several language ecosystems have grown Landlock bindings, and a number of sandbox helpers and runtimes have added or are adding support. I'm deliberately not printing a list, because it would be wrong within a release cycle. Check the current state of whatever you're using rather than trusting a blog post's inventory — including this one's.
What layering looks like in practice
PandaStack runs every sandbox, managed Postgres database, and hosted app as its own KVM-backed Firecracker microVM, and the layers stack in a specific order for specific reasons. The boundary is the guest kernel: workload code executes against a kernel that belongs to that one guest, and its only route toward the host is a CPU-enforced VM exit into a small device-emulation surface. On the host side, the Firecracker VMM process is itself confined with a strict seccomp filter — so a guest that somehow escaped into the VMM lands in a process that can barely make a syscall — and it runs under a jailer that drops privileges and applies cgroups, namespaces, and a chroot. Networking is per-sandbox: each guest gets its own network namespace and TAP device from a pool of 16,384 pre-allocated /30 subnets per agent.
Landlock's place in that picture is inside the guest, around the workload, as the layer the boundary doesn't provide. The microVM stops the blast from reaching other tenants. It does nothing whatsoever to stop the model-written script from reading the API token you mounted at /run/secrets and posting it to a webhook, because from the guest kernel's perspective that is a completely legitimate file read by a completely legitimate process. Landlock is what makes that read impossible. Two layers, two different failure modes, neither redundant.
One caveat that is worth stating plainly rather than glossing: whether Landlock is available inside a guest is a property of the guest kernel you boot, and lean microVM kernels are often configured for boot speed and small size rather than for LSM coverage. This is exactly why the runtime probe belongs in your launcher rather than in your assumptions. If the probe comes back negative, you still have the boundary — you just don't have the inner layer, and you should know that rather than believe otherwise.
The economics are what make the boundary-first ordering practical. If a fresh VM cost thirty seconds, you'd pool them, share them across tenants, and reach for in-process confinement as the only affordable isolation — which is how a lot of teams end up asking Landlock to be a boundary. On PandaStack every create restores a baked Firecracker snapshot instead of cold-booting: roughly 49ms for the restore step, 179ms p50 end to end, around 203ms p99. The ~3s cold boot happens once, at bake time. At a fifth of a second, a private kernel per job is cheaper than most of the arguments for not having one.
from pandastack import Sandbox
# LAYER 1 (the boundary): the model's code gets its own KERNEL. Nothing it does
# -- including whatever it does to that kernel -- reaches another tenant.
# This is the layer Landlock structurally cannot provide.
def run_generated_code(job_id: str, source: str, dataset: bytes) -> str:
sbx = Sandbox.create(
template="code-interpreter",
ttl_seconds=900, # a wedged job is reclaimed, not nursed
metadata={"job": job_id, "kind": "llm-generated", "trust": "none"},
)
try:
sbx.filesystem.write("/work/task.py", source)
sbx.filesystem.write("/work/input.bin", dataset)
# LAYER 2 (inside the guest): confine the WORKLOAD PROCESS itself.
# The guest kernel doesn't care that /run/secrets is sensitive -- to it
# that's an ordinary file read by an ordinary process. Landlock is the
# layer that makes the read impossible in the first place.
#
# confine.c is the program from earlier in this post: it probes the ABI,
# builds a best-effort ruleset, sets no_new_privs, restricts itself,
# then execs python3. Anything python3 spawns inherits the cage.
out = sbx.exec(
"cd /work && ./confine "
"--ro /usr --ro /lib --ro /lib64 --ro /etc "
"--rw /work --rw /tmp "
"-- python3 task.py --input input.bin --out result.json",
timeout_seconds=600,
)
# A Landlock denial surfaces as an ordinary EACCES, which the workload
# may well catch and swallow. Do not infer "nothing was denied" from a
# zero exit code -- assert on the artifact, and audit stderr.
if out.exit_code != 0:
raise JobFailed(job_id, out.exit_code, out.stderr[-4000:])
return sbx.filesystem.read("/work/result.json")
finally:
# LAYER 3 (the cheapest one): the machine, its filesystem, its memory,
# and anything the code left behind all stop existing together.
sbx.kill()Where this leaves you
Landlock is three ideas. One: it's an unprivileged, stackable LSM that inverts the usual model — the program writes the policy, applies it to itself, and no administrator is involved, which is the only way application-level sandboxing ever gets adopted at scale. Two: the enforcement model is genuinely well built. Rules pin hierarchies by file descriptor rather than by string, so renames and symlinks and TOCTOU races have nothing to attack; rights are granular and independent; restrictions are irreversible and inherited so there is no escape hatch through a child process. Three: its scope is filesystem access plus, in newer ABIs, a slice of network control — and that scope is where the honest analysis has to start.
So use it, and use it early — inside your own binary, around your parsers and plugins and build steps, on kernels where it's available, with a runtime ABI probe and a downgrade path you've thought about rather than one that silently disarms you. Then be clear-eyed about the rest. Landlock does nothing about CPU or memory exhaustion. It does nothing about most syscalls. It does nothing about a kernel bug reached through a syscall it happily permits. A fully Landlocked process is still sharing your host kernel with every other tenant, and the thing that changes that is not a better ruleset.
For code a language model wrote thirty seconds ago and nobody has read, you want a different kernel — and then Landlock inside it, because the boundary that stops your tenants from reaching each other has no opinion about which files the workload reads inside its own guest. Two layers, two jobs. Reaching for either one alone is how you end up explaining to someone which of those jobs you thought was covered.
For the adjacent layers: the syscall axis is in /blog/seccomp-explained and its Firecracker-specific application in /blog/firecracker-seccomp-bpf-filter-explained, the privilege-dropping and cgroup layer under the VMM is in /blog/firecracker-jailer-explained, the network-visibility layer is in /blog/firecracker-network-namespace-isolation-explained, and the full ranking of every option from a chroot to a microVM is in /blog/code-isolation-hierarchy. If you're specifically here because you're about to execute model-written code, start with /blog/jailing-llm-generated-code.
Frequently asked questions
What is Landlock in Linux and how is it different from SELinux or AppArmor?
Landlock is a Linux Security Module that lets an ordinary unprivileged process restrict its own access to the filesystem — and, in newer ABI versions, to some network operations. The difference from SELinux and AppArmor is who writes the policy and who it applies to. Those are administrator tools: someone with root loads a system-wide policy that governs every process on the machine, whether the process knows or not. Landlock is the inverse. The program itself builds a ruleset describing the filesystem hierarchies it may touch and the rights it may exercise on them, then applies that ruleset to itself with no privilege required. It is stackable, so it composes with whatever SELinux or AppArmor policy is already loaded, and it is strictly subtractive — a Landlock ruleset can only remove access, never grant it. That's what makes an unprivileged interface safe to expose, and it's why application developers can sandbox a risky subsystem without asking an operator for anything.
Is Landlock enough to run untrusted or AI-generated code safely?
No, and the gap is larger than it first appears. Landlock controls which filesystem objects a process may reach. It is not a syscall filter, so a fully Landlocked process can still issue every syscall in the table, including the obscure ones where kernel privilege-escalation bugs tend to live. It is not a resource limiter, so the process can fork-bomb, exhaust memory until the OOM killer picks a victim, or spin a CPU forever while obeying every filesystem rule you wrote. It is not a namespace, so the process still sees the host's PIDs, interfaces, and mount table. Most importantly it is not a kernel boundary: it is an LSM hook inside the very kernel you're trying to protect, so a memory-corruption bug reached through a permitted syscall compromises the enforcer itself, and that kernel is shared with every other tenant on the machine. For untrusted code, use a boundary that gives the workload its own kernel — a KVM-backed microVM — and then apply Landlock inside it as a second layer.
Why does Landlock use file descriptors instead of path strings for its rules?
Because path strings are not stable references to filesystem objects, and every sandbox that treated them as if they were has had the same class of bug. If a rule says "allow reads under /work", something has to compare a path against that string, and between the comparison and the actual access an attacker can rename a directory, plant a symlink, or arrange a bind mount so the name now resolves somewhere else. That's the classic time-of-check-to-time-of-use race. Landlock sidesteps it entirely: you open the directory and pass the kernel the file descriptor, and the rule is attached to the pinned filesystem object rather than to a name. Enforcement then happens inside path resolution itself, on every access, rather than as a validation step you perform once and then trust. A later rename cannot move the rule, and a symlink cannot redirect an allowed path outside the granted hierarchy, because the name was never what the rule was about.
How should I handle Landlock ABI version differences across kernels?
Query the ABI at runtime and never infer it from the kernel version string. Calling landlock_create_ruleset with no attributes and the version flag returns the supported ABI level as a positive integer, or an error if Landlock is unavailable. From there, build the ruleset you actually want and mask off the individual access rights this kernel predates, because asking for an unknown right fails the entire ruleset rather than just that right. Two cautions. First, compiled in is not the same as enabled — some distributions build Landlock but leave it out of the kernel's active LSM list, so check /sys/kernel/security/lsm and treat absence as a reported configuration fact rather than something you silently swallow. Second, be honest that best-effort downgrade means running with less protection than your threat model assumed. Decide in advance which rights are optional hardening and which are load-bearing, and fail closed on the load-bearing set. Verify the current ABI-to-feature mapping in the kernel documentation, not in a blog post.
Why is my program broken after applying a Landlock ruleset?
Almost always because a modern process depends on far more files than the ones it explicitly opens, and your allow-list only covered the obvious ones. The dynamic loader needs the interpreter and every shared library, with the execute right, not just read. An interpreted runtime needs its whole standard library tree, its packages directory, and the shared objects behind any native extension. Name resolution, CA certificates, timezone data, and locale archives all live in places you probably didn't grant. Temp file creation needs the create right specifically, which is separate from the write right — read, write, execute, create, delete, and truncate are all independent grants, and granting one grants nothing else. The other frequent culprit is the atomic-write pattern: creating a temporary file in one hierarchy and renaming it into another requires the refer right to be both handled and granted, and without it you get a cross-device error that makes no sense on a single filesystem. Add grants until the tests pass, then write negative assertions so you know the ruleset still denies what you think it denies.
49ms p50 cold start. Fork, snapshot, and scale to zero.