Landlock-Sharp

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:

  1. 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.
  2. Calls landlock_restrict_self(rulesetFd, flags), which the kernel records as a new "Landlock domain" attached to the current thread.
  3. Closes the ruleset file descriptor — it's no longer needed.
  4. Marks the Landlock instance as enforced so any further AddPathBeneathRule / AddPortRule calls 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 mmap of 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, after Enforce.

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.


Cross-reference

Referenced by

© 2026 Landlock-Sharp. All rights reserved.