Enforcing the sandbox
Enforce() is where Landlock actually takes effect. It bakes the current ruleset into the kernel and binds it to the calling thread (and, transitively, every thread and process descended from it). Once it returns, the sandbox cannot be widened — only further narrowed by adding new rulesets on top.
This page covers the subtleties: what gets restricted, who inherits the restriction, and how to layer multiple rulesets. For the kernel-side semantics see landlock_restrict_self(2) and the Landlock kernel docs.
What Enforce() does
In order:
- Calls
prctl(PR_SET_NO_NEW_PRIVS, 1, ...)so the process can never gain privileges via a setuid binary. This is a hard prerequisite for Landlock and a sensible default for any sandboxed process — see prctl(2) —PR_SET_NO_NEW_PRIVS. - Calls
landlock_restrict_self(rulesetFd, flags), which the kernel records as a new "Landlock domain" attached to the current thread. - Closes the ruleset file descriptor — it's no longer needed.
- Marks the
Landlockinstance as enforced so any furtherAddPathBeneathRule/AddPortRulecalls throw.
The kernel maintains the domain as a property of the thread (and its descendants), not of the Landlock C# object — disposing or losing the C# reference does not undo the sandbox.
Thread vs. process scope
landlock_restrict_self binds the new domain to the calling thread. Other threads of the same process that were started before the call are unaffected.
var t1 = new Thread(() =>
{
Landlock.CreateRuleset(Landlock.FileSystem.CORE)
.AddPathBeneathRule("/tmp", Landlock.FileSystem.READ_FILE, Landlock.FileSystem.READ_DIR)
.Enforce();
File.ReadAllText("/etc/passwd"); // EACCES — this thread is sandboxed
});
var t2 = new Thread(() =>
{
File.ReadAllText("/etc/passwd"); // OK — t2 was started before t1 enforced
});
t2.Start();
t1.Start();
This matches the kernel API. To sandbox a whole process you have two choices:
- Enforce before any worker threads start. Apply Landlock at the very top of
Main, before the runtime kicks off the thread pool. - Enforce once per thread. Have every worker call
Enforce()itself. The rulesets layer, so doing this for many threads is fine — but you have to do it for every thread you create.
For sandboxing-the-whole-process pattern, see the worked example in Quick Start.
Enforce as early as possible
The earliest place in a .NET app is the top of Main, before any Task.Run, Thread.Start, async await, or Process.Start. New threads/processes started after the enforcement automatically inherit the sandbox.
Child threads and child processes inherit
Once a thread is sandboxed, every thread and every child process it spawns inherits the same Landlock domain. There is no opt-out. From the kernel's perspective the domain is part of the task's credentials and survives fork, clone, execve, and so on — see landlock_restrict_self(2) for the formal definition.
Landlock.CreateRuleset(Landlock.FileSystem.CORE)
.AddPathBeneathRule("/usr/bin", Landlock.FileSystem.READ_FILE, Landlock.FileSystem.EXECUTE)
.Enforce();
// The forked /usr/bin/grep is sandboxed too — it also only sees /usr/bin
using var p = Process.Start("/usr/bin/grep", "pattern");
This is what makes Landlock a useful primitive for running untrusted plugins — your code is the parent, the plugin is the child, and the kernel keeps the child inside the sandbox you set up.
Layering rulesets — the only way to "change" the sandbox
There is no way to widen a Landlock domain. The only legal modification is to layer an additional, more restrictive ruleset on top.
// Outer ruleset — allow read access under /usr and /etc
Landlock.CreateRuleset(Landlock.FileSystem.CORE)
.AddPathBeneathRule("/usr", Landlock.FileSystem.READ_FILE, Landlock.FileSystem.READ_DIR, Landlock.FileSystem.EXECUTE)
.AddPathBeneathRule("/etc", Landlock.FileSystem.READ_FILE, Landlock.FileSystem.READ_DIR)
.Enforce();
// Inner ruleset — drop /etc access before running the plugin
Landlock.CreateRuleset(Landlock.FileSystem.CORE)
.AddPathBeneathRule("/usr", Landlock.FileSystem.READ_FILE, Landlock.FileSystem.READ_DIR, Landlock.FileSystem.EXECUTE)
.Enforce();
// /etc is now blocked even though the outer ruleset allowed it
The effective rights are the intersection of every layered domain. A right granted by an outer ruleset can be removed by an inner one; a right denied by an outer ruleset cannot be re-added by an inner one.
Idempotence — calling Enforce() twice on the same instance
The C# binding tracks whether a given Landlock instance has been enforced. Calling Enforce() again on the same object is a no-op.
var sandbox = Landlock.CreateRuleset(Landlock.FileSystem.CORE)
.AddPathBeneathRule("/tmp", Landlock.FileSystem.READ_FILE, Landlock.FileSystem.READ_DIR);
sandbox.Enforce(); // applies the ruleset
sandbox.Enforce(); // no-op
To layer further restrictions, create a new ruleset and enforce that one — as in the example above.
What Enforce() does not do
- It doesn't fork a new process or sandboxed environment. Your existing process keeps running. Files and sockets opened before the call remain open and usable — Landlock only filters future syscalls.
- It doesn't close open file descriptors. A file the process had open at the time of enforcement can still be read or written; the sandbox only prevents new
open(2)calls. - It doesn't restrict
mmapof already-mapped regions. Mappings made before enforcement keep working. - It doesn't drop capabilities or change the UID. Combine with
setuid/setgid/prctl(PR_CAPBSET_DROP)if you need that, afterEnforce.
For the precise list, see landlock_restrict_self(2) — Description.
Pre-open everything you need
Because already-open file descriptors aren't filtered, a common pattern is to open all the files the process needs before enforcing, then drop the ability to open new ones:
// 1. Open files we'll need later
var configStream = File.OpenRead("/etc/myapp/config.json");
var logStream = File.AppendText("/var/log/myapp.log");
// 2. Drop the ability to open anything else
Landlock.CreateRuleset(Landlock.FileSystem.CORE)
.AddPathBeneathRule("/var/lib/myapp",
Landlock.FileSystem.READ_FILE,
Landlock.FileSystem.READ_DIR)
.Enforce();
// 3. The pre-opened streams still work, but /etc is now off-limits
// for any *new* open(2) call.
This is the same pattern OpenBSD's pledge/unveil users will recognise.