The Linux envelope
On Linux every confined command runs inside a bwrap envelope: a private view of the filesystem and a private set of namespaces, built for that one command. This page says what the envelope mounts, what it shows the child, and how it copes with hosts that refuse parts of it. How a command enters it is confined-launch; how its denies hold is holding-a-deny.
The envelope is the child’s whole world
bwrap_command wraps each child in bwrap, and the envelope covers more than
the filesystem
(the-envelope-is-a-process-namespace).
- Namespaces. Every projection gets its own ipc, uts and cgroup namespaces.
Wherever the host can build one, it also gets a pid namespace with a fresh
/proc. A confined child then sees and can signal nothing of the host,/proc/selfis its own, andpsunder any grant shows only the envelope. - Working directory. The logical cwd goes in as
--chdir. A cwd the grant does not cover gets an empty0555tmpfs stand-in, laid before the binds, which hide it wherever they do cover it. So a grant narrower than the cwd still runs commands, as under Seatbelt, and a relative write there is refused. - The payload’s pid. bwrap never execs the payload in place: a monitor
clones a namespace init, which forks the payload. So a
Keptlaunch reads the payload’s pid back over--info-fd(InfoFd, a socketpair). No same-uid process can reopen a socketpair through/procto forge achild-pid. That pid is a session leader, by--new-session, and its group is the one every signal ladder targets. Nothing addresses the monitor. - Process groups.
Launch::spawnmakes an enveloped launchNewLeaderwhatever was asked, and returns the payload’s group.ForegroundDecisionnever hands an envelope the terminal. A pipeline collector addresses each confined stage’s envelope beside its own group (pipeline execution).
bwrap mounts what ral opened, never a name
Binds::open plans every bind a launch shows, opens each object once without
following a symlink, and lends a copy at Slot::Mount(i)
(the-envelope-mounts-by-handle).
bwrap_argv emits --ro-bind-fd or --bind-fd from the same vector. The binds
are: the read-only defaults, the read region (shown: every read scope of the
fs projection, run places and live support among them, written prefixes
included), the pins of bwrap and the trampoline, bound read-only at their own
names before the masks, and, over everything, the cgroup tree. The launched
image is not a bind of its own: the running-reads law puts it in the read
region, and the table admits it as it admits any file.
Every allowance is bound as the frozen real path the guard judged, never
re-resolved; the surface spelling is only the destination at which the frozen
object is bound.
- A path is its canonical object plus the spellings that reach it
(
render_objects)./binshows/usr/bin’s handle, except inside another bind, where the host’s own link already stands. - Redundant binds are dropped. A read-only bind inside another bind adds nothing. A writable bind is dropped only inside a writable one.
- A redirected allowance refuses the launch. Every bind object opens with
openat2(RESOLVE_NO_SYMLINKS), so a symlink put anywhere on a frozen allowance’s path refuses the launch, naming it, rather than following it. - There is no write freeze. No bind is laid read-only over an admitted directory a write covers; the protection it gave is a declared limit (authority-is-belnap-evidence).
- Missing objects are dropped, changed ones refused. An absent object drops its binds at the open, and so does a system default this user may not reach. A name that now reaches another object, or an open through a symlink, refuses the launch and says which.
- Every deny is masked at every name a bind shows it. A deny that would hide
/proc, which bwrap execs the trampoline through, is refused. - An unusable bwrap refuses in its own words. A bwrap that builds no
envelope refuses every confined launch, and one without
--ro-bind-fdis named with its version.
Pinned by sandbox::linux::tests::a_bind_is_opened_for_each_name_no_other_bind_shows,
::a_prefix_whose_component_became_a_symlink_since_render_is_refused,
::every_bind_is_a_handle_by_slot_in_layer_order,
::a_write_through_a_link_is_masked_at_both_its_names and, spawning,
::a_prefix_swapped_for_a_symlink_after_the_open_shows_what_was_opened.
The exec layer is lowered against the binds
Landlock rules attach to host objects opened before bwrap starts, and a walk
from a file inside the envelope meets only the inodes above it in the
envelope. So the bind plan, with its objects, is computed before the ruleset
and passed to landlock::build.
Inside a restricted envelope a bind’s root has the envelope’s own directories
above it, so a rule on a host directory above a bind root is off every walk
inside that bind (the payload’s REFER admission already worked around this for
the envelope’s root). enveloped once built the ruleset before bwrap_command
opened the binds; the deduped shown roots, with their objects, are now computed
first, and opening stays where it was.
- A directory admission
Dputs a rule onDitself where some bind root’s object lies at or aboveD, and a rule on every bind root whose object lies insideD: an alias (/aliasbound to/tools/subunderDir(/tools)) and a canonical sub-bind (a ceiling that reads only/tools/sub). Exclusions are subtracted along their spines from each root. Either way the walk from/tools/sub/progmeets/tools/suband the envelope’s own directories, never/tools; a nested grant that runsDir(/tools)under a ceiling that reads only/tools/subcannot widen reading, so/toolsis not shown. Anyis the directory/. Under an unrestricted fs it is a rule on/; under a restricted fs, a rule on every bind root remaining after the redundant-bind dedup. When every exclusion is a name veto, nothing is subtracted, so no exec ruleset is built; signal scoping stays.- Linker candidates are support, admitted unless excluded, not before the table; ral’s pinned inode is no longer admitted at all.
- Limits, declared: a program created during a launch on the spine of an
excluded path runs from the next launch; under
Anywith exclusions and a restricted fs the envelope’s private/tmpholds no host object, so a program built there does not run; an excluded path that is a hard link to an admitted file runs, since Landlock admits an inode.
Where the host refuses a mount, the envelope rebuilds it
Some hosts refuse bwrap a mount it needs. The envelope then builds the same result by hand, or states what it cannot build.
- A fresh devpts. A rootless container’s mount layer will not give bwrap
one, so
--devfails in setup and noRestrictedenvelope could launch.render_devlays out--dev’s shape by hand instead: a tmpfs, the device nodes bound in, thepts/ptmxsymlink, and a fresh/dev/shm. - A fresh procfs. The same runtimes mask
/proc, and the kernel then refuses the procfs a pid namespace needs.--unshare-pidis emitted only where the table can be hidden. HostEnvelopelearns both with one probe spawn each (sandbox/linux/host.rs).- What cannot be rebuilt is stated, not refused. Neither is a restriction a
grant names, so the launch runs, and the child sees what it would see
unconfined: the container’s own process table, the host’s
/dev/pts(two-enforcers). Running the container--privilegedlifts both. - A pty by hand is the host devpts’s.
/dev/ptmxopens where that devpts’sptmxmodeadmits: a container runtime’s own devpts (0666), not the kernel’s default nor a systemd host’s (000). The host’s/dev/ptmxnode cannot stand in: the kernel finds a node’s devpts at theptsbeside it in the same mount, and a bind of one file has none. - The render is a pure function of the probed
HostEnvelope. So the argv tests assert both shapes from literals, not from whichever host runs them. - Open: such hosts still refuse the read-only rebind of their own locked
/etc/hostsand/etc/resolv.conf, by descriptor as by name.
Pinned by sandbox::linux::tests::a_host_that_refuses_dev_gets_the_same_shape_by_hand
and ::the_by_hand_dev_serves_a_confined_body.
Kernel pseudo-filesystems describe the envelope
A pseudo-filesystem inside the envelope must describe the envelope. One that cannot is a host bind like any other, present only where something needs it.
/procis mounted fresh (--proc /proc) with the pid namespace./syshas no bwrap operation, and a bound/sysdescribes the host: itsclass/netlists the host’s interfaces whatever--unshare-netdid, and itsclass/dmiandbusname the machine. So the Linuxfloornarrows/sys, as it narrows/etc, to what sizes a program:devices/system/cpuandkernel/mm/transparent_hugepage. A grant that wants the rest reads/sysby name./sys/fs/cgroupwould be wrong, not just revealing. Under the cgroup namespace a child’s/proc/self/cgroupreads0::/, and a runtime that joins that to the host’s tree reads the root’s limits, which are none.cgroup_treere-roots the tree on ral’s own cgroup, the namespace’s root, which is what a fresh cgroup2 mount inside would show. It does so on both projections, over the projection’s binds. Where the host builds no cgroup namespace (HostEnvelope::private_cgroup), the host’s tree is the true one and is bound as it is.
Pinned by sandbox::linux::tests::sys_is_narrowed_to_what_sizes_a_program_and_the_cgroup_tree_is_the_payloads
and ::a_confined_program_reads_its_own_cgroup_and_none_of_the_hosts_interfaces.
The envelope binary is fixed at boot
The launcher is exactly as trustworthy as the file it is, so ral never picks
that file by name at spawn time. At spawn, the only environment in scope is the
payload’s, including any PATH override. Command::new("bwrap") would let a
within [env: [PATH: …]], or a planted binary earlier on PATH, run code before
any confinement.
sandbox::bootpins bwrap before any shell exists (linux::pin_envelope). It searches the absolute entries of thePATHral started with, as directories rather than a re-joined string (path::locate_in), and keeps the result as areexec::Pinned, the same fd pin ral uses to re-exec itself.- Every launch execs
/proc/self/fd/N. Neither aPATHoverride nor a replace-by-rename at the pinned path reaches it. - In-place rewriting of the pinned inode, the one change a pin by
descriptor cannot see, is closed on both sides:
- for a confined child, by the envelope.
Binds::openlends both pins, bwrap’s and ral’s, asOverbinds, each read-only by its own descriptor at the name its inode holds now (Pinned::names). They go after the projection’s binds,/wholesale underUnrestrictedincluded, and before the masks. A write getsEROFS, a hard linkEXDEV, and a rename or unlink of the mountpointEBUSY. This is the Linux twin of the write-deny macOS lays on ral’s own file in every profile (seatbelt-profile); - for ral’s own writes, by the
Guardedverdict (path-judgment).
- for a confined child, by the envelope.
- An unlinked pin gets no bind. A replace-by-rename took its name, so there is no name to lock.
- Still open: another same-uid process, and any later session, since a session cannot vet the enforcer it boots on. Where the pinned bwrap is writable by ral’s uid, the remedy is a root-owned bwrap (the distro package).
- A host with no bwrap is reported by
linux::envelopeat the first launch that needs one, throughRefusal::Unavailable.Launch::envelopenames the binary only for the wording of a spawn failure.
Pinned by sandbox::linux::tests::the_launcher_is_the_pinned_envelope_and_never_a_name,
::the_envelope_binary_is_read_only_inside_every_envelope,
::a_pinned_binary_inside_a_writable_prefix_is_locked and
guard::enforce::tests::a_write_onto_a_pinned_binary_is_guarded_before_any_grant_is_consulted.