Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The restriction fallback

Some hosts cannot run the sandbox at all: unprivileged user namespaces are disabled, and with them every namespace facility the cage is built from. For those hosts the library offers a degraded mode, the restriction: it spawns the command directly on the host and confines it with the same Landlock filesystem rules, Landlock network rules, and seccomp filters the hardening layer provides, always under the no-new-privileges flag.

A restriction is a weaker boundary than a cage, and the difference is worth stating plainly. The command runs as the calling user, on the host filesystem (as far as the Landlock grants allow), with the host network and the host PID space visible. What a restriction delivers is filesystem-access and syscall confinement plus the library’s process model — the clean deterministic environment, output streaming, and the running handle — on hosts where nothing stronger is possible.

The restriction is part of the hardening feature and requires only Landlock or seccomp from the kernel, not user namespaces.

Configuring a restriction

Restriction mirrors the cage’s builder shape. Because there is no private rootfs, every path is a host path — including the grants that make the command runnable at all: with any Landlock grant configured, everything ungranted is denied, so the command’s own binary, its interpreter, and its libraries need read and execute grants alongside the data paths it works on.

use ferroday_cage::{FsAccess, Restriction};
fn main() -> ferroday_cage::Result<()> {
let status = Restriction::builder()
    .command("/usr/bin/sort")
    .args(["/work/input"])
    .landlock_fs(FsAccess::READ | FsAccess::EXECUTE, "/usr")
    .landlock_fs(FsAccess::READ | FsAccess::EXECUTE, "/lib")
    .landlock_fs(FsAccess::READ, "/etc")
    .landlock_fs(FsAccess::READ | FsAccess::WRITE, "/work")
    .build()?
    .run()?;
let _ = status;
Ok(())
}

landlock_fs, landlock_net, and seccomp behave exactly as they do on the cage builder, and at least one control must be configured: a restriction that restricts nothing is rejected at build time rather than launching a plain process that appears sandboxed.

landlock_net grants TCP bind and connect on a named port. Configuring any grant — filesystem or network — enrolls the command in Landlock, so a restriction whose only grant is a network one denies every TCP bind and connect it did not name, and the filesystem stays open. It governs TCP alone: UDP, raw sockets, and other socket families, AF_UNIX among them, are untouched, which is why the abstract-socket surfaces below survive it.

Network rights need Landlock ABI 4, where filesystem rights need only ABI 1. On an older Landlock kernel a filesystem grant narrows away best-effort, but a restriction whose only grant is a network one would then enforce nothing at all, so it is refused at build with ConfigError::RestrictionLandlockUnenforceable.

The command starts in / (or the current_dir) with a deterministic base environment plus the caller’s variables. The base is PATH alone — unlike the cage’s, it carries no HOME, because the command runs as the real calling user, for whom /root would be wrong; set HOME explicitly if the command needs one. Standard streams, the stdin disposition, output streaming through an observer, a pseudoterminal of its own (see A terminal of the sandbox’s own), and the Running handle — wait, deadline, terminate, kill, stop_with_caller — all work as they do for a cage.

The command starts with no capabilities. A restriction always drops to the empty capability set, and the always-set no-new-privileges flag prevents a set-user-ID or file-capabilities binary from acquiring any across the exec. This matters when the caller is privileged — a root process on a host with unprivileged user namespaces disabled is a plausible way to reach the restriction fallback — which would otherwise pass its full capability set to the command; when the caller is already unprivileged the drop is a no-op.

Because a restriction runs a host process with no namespace or root-swap isolation, some host-reaching surfaces remain, governed only by the Landlock and seccomp the caller configured: abstract-socket services such as D-Bus and the X server (a Landlock network grant governs TCP alone), same-user /proc under a seccomp-only restriction, and same-user ptrace under a Landlock-only restriction (the curated seccomp policy blocks it). A cage closes these three through its namespaces and swapped root.

The caller’s terminal is not among them, because it is not a namespace question: a controlling terminal is reached through the session, so a cage and a restriction answer it identically, by the rule described in The dispositions and the caller’s terminal. A command that inherits the caller’s standard input shares the caller’s terminal deliberately; one that does not runs in a session of its own and cannot reach it at all. Independently of the session, the curated seccomp policy denies ioctl for the TIOCSTI and TIOCLINUX requests, the two that write to a terminal’s input queue.

Falling back from a cage

A cage launch on a host without user namespaces fails with Error::UsernsUnavailable, naming the blocking configuration when the probe identifies it. A program that wants to degrade catches that error and runs its restriction:

use ferroday_cage::{Cage, Error, FsAccess, Restriction};
fn main() -> ferroday_cage::Result<()> {
let cage = Cage::builder().rootfs("/srv/rootfs").command("/usr/bin/job").build()?;
let status = match cage.run() {
    Err(Error::UsernsUnavailable { .. }) => {
        // The degraded path: host paths, weaker guarantees.
        Restriction::builder()
            .command("/usr/bin/job")
            .landlock_fs(FsAccess::READ | FsAccess::EXECUTE, "/usr")
            .landlock_fs(FsAccess::READ | FsAccess::WRITE, "/var/lib/job")
            .seccomp(ferroday_cage::SeccompPolicy::Curated)
            .build()?
            .run()?
    }
    outcome => outcome?,
};
let _ = status;
Ok(())
}

The fallback is a second, deliberate configuration, not a translation of the cage’s. A cage describes a private mounted view; a restriction describes the host. The paths differ, the trust model differs, and the decision to accept the weaker boundary belongs to the caller, which is why the library never degrades automatically.

Process model

A restriction launch takes the same shape as a cage without a PID namespace: a supervisor process watches the command from outside, and the same caveats apply. Descendants of the command can outlive it, a kill reaches only the command process itself, and when output is captured, a surviving descendant that holds the streams keeps an unbounded wait blocked — prefer a deadline wait for commands that may leave children behind. The Landlock ruleset, the seccomp filter, and no-new-privileges are all inherited by every process the command starts, so the confinement itself does extend to descendants.

Host support

Landlock enforcement follows the running kernel’s ABI, best-effort downward, exactly as in the hardening layer; a kernel with no Landlock LSM fails a Landlock-bearing restriction as a setup error. A seccomp-only restriction runs there. host::landlock_abi and host::seccomp_available report what the kernel offers.

On the command line

--restrict selects the restriction instead of the sandbox. The hardening grant flags apply with host paths, and the flags that configure namespace facilities are rejected:

$ fcage --restrict \
    --landlock-ro /usr --landlock-ro /lib --landlock-ro /etc \
    --landlock-rw /work \
    --seccomp curated \
    -- /usr/bin/sort /work/input

Alongside the grant flags, --setenv, --no-base-env, --chdir, --stdin, --rlimit, --timeout, --kill-after, and --stop-with-caller behave as they do for a sandbox. rlimit matters more here than in a cage: without namespaces, a resource limit is the only thing bounding what the command consumes.