Skip to content

Containment

A containment class bounds what a capsule's shell subprocesses reach on the host, through kernel mechanisms the host has to provide. It does not bound what a WASM artifact does inside the workdir — What bounds a WASM artifact names the grant that does. This page covers the enforcement tier each host resolves to, the containment class a capsule can require of it, the fixed grants a contained capsule receives, and how each claim is checked.


Kernel enforcement is Linux-only, and that is permanent

Landlock, seccomp and cgroups are Linux kernel primitives with no equivalent elsewhere, so every containment claim a kernel backs requires Linux with kernel ≥5.13. macOS and Windows sit permanently on the Environment-only tier. Subprocess enforcement tiers is the single statement of what each platform gets.

Subprocess enforcement tiers

W-SEC-001, W-SEC-002, W-SEC-003 and W-SEC-005 all stem from one mechanism: at capsule launch the runtime probes the host and resolves an enforcement tier for shell subprocesses declared under capabilities.shell.allow. The probe is a live capability test — a Landlock ruleset really constructed, a namespace really created in a forked child — never a kernel version string.

The reading is taken once per capsule run. A process that runs several capsules in turn — mur eval, which runs one per dataset case — reads the host again for each, so a host you fix between cases takes effect without restarting.

The probe sets the ceiling. A capsule then runs on the strongest tier that is both within that ceiling and no stronger than its declared containment class: the Sealed tier is applied only to a capsule that declares capabilities.containment: sealed, and every other capsule on a sealed-capable host runs on Full.

Tier Host mechanism: Filesystem Exec Network
Sealed Linux, kernel ≥5.13, with a usable unprivileged user + mount namespace mountns+pivot_root+landlock+seccomp kernel-enforced, inside a composed root kernel-enforced kernel-enforced
Full Linux, kernel ≥5.13 (Landlock available) landlock+seccomp kernel-enforced kernel-enforced kernel-enforced
Seccomp-only Linux, kernel <5.13 (no Landlock) seccomp-only not enforced not enforced¹ kernel-enforced
Environment-only macOS, Windows, any non-Linux host none not enforced not enforced not enforced

¹ Exec is a Landlock right, so a host without Landlock has no kernel-level exec mediation at all. Treat capabilities.shell.allow as advisory on a host below kernel 5.13.

The mechanism: column is what mur run --explain-scope prints, and it always reports what the host can back — never what the session installed.

Where the user namespace comes from

The Sealed tier and the capsule network namespace both need an unprivileged user namespace, and on an AppArmor host something has to permit one. mur run --explain-scope prints which permission is in effect on the line below mechanism:, and --explain-scope --json carries the same value as userns_grant:

Containment
  declared:  sealed
  achieved:  sealed
  floor met: yes
  mechanism: mountns+pivot_root+landlock+seccomp
  userns grant: profile_confining
userns grant: What permits the namespace Scope of the permission
apparmor_absent AppArmor is not enabled on this host nothing was ever restricted; no profile is needed
profile_confining the shipped mur-sealed AppArmor profile is confining this binary mur alone — the configuration murmur ships
restriction_disabled_host_wide kernel.apparmor_restrict_unprivileged_userns is 0 every program on the machine; reported as W-SEC-013
withheld nothing sealed is refused with E-CAP-003, and a capsule that spawns a subprocess with E-CAP-005

The line is n/a off Linux, where AppArmor does not exist. The same value is written to session_start.userns_grant in trace.jsonl, so a finished session's record distinguishes a sealed result obtained through the shipped profile from one obtained on a host whose unprivileged-userns hardening was switched off.

A checkout build runs as ./target/debug/mur or ./target/release/mur, which no shipped profile attaches to. sudo scripts/install-dev-apparmor.sh generates and loads a profile for exactly those two paths, so building from source needs no host-wide sysctl.

What the Full tier grants. The Landlock scope grants the capsule workdir a near-full access set and a narrow, derived read+execute grant for exactly the shell.allow binaries, their ELF interpreter (dynamic loader), and the transitive closure of their shared libraries — so an allowlisted program can exec and dynamic-link /usr/bin/bash and its libraries while no directory is granted wholesale and the only writable path outside the workdir is /dev/null (see the fixed capsule device set). A capsule may additionally name specific host directories a path-based interpreter needs (its stdlib) via capabilities.shell.interpreter_runtime, which grants exactly the directories named, each with an explicit per-directory list_dir flag (see W-SEC-009). The workdir grant is not the full Landlock right-set: character-device (MakeChar), block-device (MakeBlock) and unix-socket (MakeSock) creation are withheld, so a capsule cannot create a raw disk device node inside its own workdir and read the host filesystem through it — and, unless the manifest declares capabilities.filesystem.workdir_exec: true, the workdir grant also withholds Execute, so nothing the capsule writes into its own workdir can be run under any name. That withholding is the exec column above: capabilities.shell.allow is enforced by granting Execute on exactly the allowlisted binaries' own paths and nowhere the capsule can write.

What kernel-enforced filesystem scope covers, and what it does not. Landlock mediates the operations that touch a file — open, read, write, execve — not path resolution. A stat, access or readlink on a path the capsule was never granted still succeeds and still reports that file's metadata; only opening its contents is refused. The boundary is on reading and writing, not on learning that a path exists.

What every Linux tier grants. Independently of Landlock, seccomp refuses socket(AF_UNIX, ...) outright unless the manifest declares capabilities.network.unix_sockets: true, and always refuses AF_NETLINK/AF_PACKET, so a capsule cannot reach a host daemon socket such as /var/run/docker.sock (see W-SEC-005). Also independently of Landlock, the forked shell child drops its entire capability bounding set, clears its permitted/effective/inheritable sets, and sets no_new_privs before execve, so a root-operated mur run does not hand the subprocess CAP_MKNOD (or CAP_DAC_OVERRIDE, or anything else) in the first place.

Filesystem and exec scoping both use Landlock; network scoping uses the capsule's own network namespace plus an egress proxy; socket-family denial uses seccomp argument matching. Underneath all of them, the seccomp filter's default action is itself a deny — see Default-deny syscall allowlist — so a syscall named by none of the mechanisms above is refused outright rather than falling through to an implicit allow.

Environment-only enforcement still gives you a synthetic HOME and strips credential-shaped environment variables before the subprocess spawns (see Lock down a capsule's capabilities), but nothing prevents the subprocess from reading files outside the workdir, executing an unlisted binary, or connecting to a host outside capabilities.network.allow.

These are kernel behaviours, so the only check that means anything is a run on a real host. The hand-run procedures that do that are listed under Verification.


Containment class

A containment class is a floor requirement — "don't launch me unless the host can actually enforce at least this much" — as opposed to a capability grant like network.allow or shell.allow, which describe what is allowed once a session is running. Three classes exist, weakest to strongest:

Class Meaning
advisory No kernel-level enforcement required. Every host satisfies this, including macOS and older Linux.
scoped Landlock filesystem mediation + seccomp syscall filtering over the host filesystem. Requires Linux 5.13+ with a usable Landlock ABI.
sealed Mount-namespace isolation onto a composed root, with Landlock and seccomp still applied inside it. Everything outside that root is absent, not merely denied. Requires Linux 5.13+ with a usable Landlock ABI and unprivileged user namespaces the process can mount inside: inside a container that needs --cap-add SYS_ADMIN, and on a host where AppArmor's restrict_unprivileged_userns is active the mur-sealed profile shipped with mur must be loaded.

sealed's one documented exception: /proc

A private procfs needs a privilege an unprivileged user namespace does not have, so on most hosts a sealed root carries the host's /proc instead. Host process metadata is visible under /proc inside a sealed capsule, as it is under scoped; opens through it are still refused. Every other axis of the root — /etc, /dev, block devices, sockets, other users' homes — is absent, not merely denied. Where the runtime does hold that privilege, a private masked /proc is used and this exception does not apply.

Declaring a floor. Three independent sources can each declare a minimum class, and they combine by taking the strongest requested — never the weakest:

  1. capabilities.containment in murmur.yaml (this field)
  2. containment in .murmur/config.yaml, global or project scope (see Configuration files; note this key uses strongest-wins merging, not the usual project-wins rule)
  3. mur run --containment <advisory|scoped|sealed> on the command line

Any source left undeclared contributes nothing; if all three are undeclared, the effective floor is advisory. A CLI flag or workspace default can only raise the floor a manifest already set — never lower it.

Achieved class. mur run derives the class the host can actually provide by probing the kernel directly (never by trusting the manifest). The probe is a conjunction, and every element has to hold for the next class up: a Landlock-capable Linux 5.13+ host achieves scoped; a host that also completes the whole composed-root construction — verified by really doing it in a forked child, not by reading a version string — achieves sealed; every other host (older Linux without Landlock, or macOS) achieves advisory. Granting a scoped capsule access to host paths outside the workdir via capabilities.shell.interpreter_runtime never changes the achieved class.

One manifest property does lower it, and only lower it. capabilities.filesystem.workdir_exec: true caps the achieved class at advisory on every host, including a sealed-capable one. The host probe still reports what the machine can do, and mechanism: in --explain-scope still names the full tier. It is the capsule giving up the claim scoped makes: with an executable workdir, shell.allow is no longer something the kernel can hold the capsule to. Nothing in a manifest can ever raise an achieved class. See Executable workdirs.

A weaker declaration is never silently upgraded. On a sealed-capable host, a capsule declaring scoped still runs with scoped's mechanism — Landlock and seccomp over the host filesystem, no composed root. Installing one anyway would delete the host paths its interpreter_runtime grants legitimately name, weakening the capsule rather than strengthening it. The achieved class reported in the trace still says what the host can back; the mechanism installed follows what the capsule asked for.

What the sealed probe performs. The probe child rehearses the composed root's construction end to end, in its own throwaway namespace, and reports the stage that failed:

Stage A refusal reports
unshare(CLONE_NEWUSER \| CLONE_NEWNS) The namespace could not be created — the container case: no CAP_SYS_ADMIN, or a seccomp filter blocking the syscall
Identity uid_map/gid_map writes The namespace exists and cannot be owned — an id-mapping policy problem, not a missing capability
mount(MS_REC \| MS_PRIVATE) on /, then a tmpfs over a root base candidate mount(2) inside the namespace was refused — what a confinement that permits userns_create and then denies CAP_SYS_ADMIN looks like
The parking directory, chdir, pivot_root(2), chdir("/"), umount2(MNT_DETACH) pivot_root(2) was refused. mount(2) working while the pivot does not is a policy that grants one and not the other: an AppArmor profile missing its pivot_root, rule, or a container runtime whose seccomp allowlist omits the syscall even where CAP_SYS_ADMIN is granted

The rehearsal runs in the child's own namespace with mount propagation made private first, so nothing it does reaches the host's mount table.

The last row is why the rehearsal goes all the way through the pivot: a host that mounts and refuses pivot_root(2) is refused at launch, rather than at the first subprocess with E-RUN-014 once an inference call has already been paid for.

Refusal. A host whose achieved class is weaker than the effective declared floor refuses the launch with E-CAP-003, before any registry pull, artifact compile, or workdir creation. The refusal names the specific missing mechanism — the AppArmor profile, a container's absent CAP_SYS_ADMIN, a refused pivot_root(2), a kernel without user namespaces — and the command that fixes it.

A manifest that never declares capabilities.containment is never gated by this check — the effective floor resolves to advisory, which every host satisfies.


What bounds a WASM artifact

A containment class and a WASM artifact's own grants answer different questions:

Question What answers it
Can this capsule reach my host? capabilities.containment — the class the capsule's shell subprocesses run under
Can this artifact reach outside its directory? capabilities.filesystem.scope on that artifact's own entry

Under sealed, everything outside the composed root is absent to a shell subprocess. Inside the workdir, an artifact's reach is exactly what its own grants say, and the containment class leaves that unchanged: an artifact declaring no capabilities.filesystem.scope works out of the whole workdir under every class. Narrow one artifact by naming the subtree it needs on its entry:

artifacts:
  - name: report-writer
    version: 0.1.0
    runtime: tool
    capabilities:
      filesystem:
        scope: reports
      network:
        allow: []

That entry is the whole of what report-writer reaches:

  • <accessible workdir>/reports is the artifact's current directory, created if missing, and it reaches nothing above it. There is no wider filesystem behind the scope to fall back to.
  • The grant is read from your own manifest entry, never from the artifact's bundled murmur.yaml, so an artifact cannot scope itself up.
  • Outbound HTTP is allowlist-gated, and an artifact's own declaration only ever subtracts from the capsule ceiling — here to zero, so every request is refused. An artifact holds no raw sockets, and has no way to run a command.

Containment and disclosure

A containment class and an export answer different questions, and only one of them is about the capsule:

Bounds Declared by Effect on the achieved class
Containment (capabilities.containment) What the capsule's shell subprocesses reach outward — which host paths, hosts and binaries they can touch Manifest, workspace config or --containment, strongest wins It is the achieved class
Disclosure (exports.files) What an operator reaches inward — which files an external process may read out of the workdir The manifest's top-level exports: block None

Declaring exports.files gives the agent no capability whatsoever: the runtime serves the files itself, off the host path it already holds for the workdir, without involving the agent. mur run --explain-scope prints the declared export in its Resource plane section, and --explain-scope --json carries it as exports_filesnull when nothing is exported. See Resource plane.

What a symlink under exports.files.root means depends on the class the session achieved, which every response reports in x-murmur-containment:

Achieved class Rule Why
scoped Refuse any symlink on the resolved path with symlink_refused, and omit symlinked entries from a listing Host-path grants are possible and the filesystem's shape stays visible, so a symlink under the export root could target a granted host path
sealed Follow, and serve only when the fully-resolved target is still beneath the export root The workdir is the only writable path and there is no outside to name, so everything under the root is capsule-authored
advisory Same rule as sealed A convention on top of a convention; every response carries advisory so a reader knows what it is trusting

A symlink whose target leaves the root is refused at every class: symlink_refused under scoped, outside_root under the other two.


The fixed capsule device set

On the Full tier a capsule gets exactly three device rules, fixed at compile time, with no manifest key that adds a device or removes one:

Device Access Why
/dev/null read and write The one deliberate exception to "nothing outside the workdir is writable". Ordinary tooling opens it for both reading and writing — a shell 2>/dev/null redirect, a language runtime's null-device constant — and a read-only grant fails those, as an unexplained crash rather than as a policy denial.
/dev/zero read only Zero-fill reads and older allocators' mapping fallbacks. Nothing needs to write it.
/dev/urandom read only Not for getrandom(2) — that is a syscall and needs no filesystem grant — but because OpenSSL and older glibc paths still open() the device outright.

/tmp under sealed is not a second exception. Inside a composed root /tmp is writable, but it is the workdir under another name: the runtime binds a directory inside the session workdir there, counted by the same capabilities.resources.workdir_max_bytes guard and discarded with the session. It carries exactly the workdir's rights, so a binary written to /tmp is no more runnable than one written to the workdir. scoped composes no root and binds nothing at /tmp, where it stays denied.

Every other device path is denied. The capsule's Landlock domain declares the full ABI v1 right-set for itself, so a path with no matching rule is refused rather than merely un-granted. /dev/random, /dev/full, /dev/tty, /dev/console, /dev/mem and every raw block device stay denied, and a fourth device can appear only by editing the fixed set.

On sealed, a different mechanism answers the same question. A capsule that declares the sealed class gets a private /dev tmpfs carrying the OCI default device set, so the kernel-visible device namespace is the boundary rather than a per-path grant. It holds six nodes — null, zero, full and tty readable and writable, random and urandom read-only — plus the OCI symlinks fd, stdin, stdout, stderr and ptmx. /dev/shm is deliberately absent: it is writable, and the session workdir is the only writable path in a composed root. Landlock keeps running inside the composed root, so the sealed /dev carries Landlock rules of its own — without them its device nodes would be present and unopenable. The two device sets are independent: a scoped declaration on a sealed-capable host keeps the three-rule list above.

The fixed sealed-tier runtime-tree grant

On sealed only. A composed root bind-mounts a fixed list of host runtime directories read-only — /usr, /bin, /sbin, /lib, /lib32, /lib64 and /libx32 — and Landlock installs inside that root as defence in depth. Each of those entries gets one Landlock rule, fixed at compile time, with no manifest key that adds a path or removes one. Unlike W-SEC-009's interpreter_runtime grants, nothing here is author-declared: it fires no warning, appears in no --explain-scope section, and is a property of the tier.

Right Granted Why
ReadFile yes Open a file in the tree by name.
ReadDir yes A path-based runtime walks its search path; without the ability to list it, it cannot find its own standard library.
Execute no Granting it across /usr, /bin and /sbin would make every binary the host ships runnable inside a sealed session, reducing capabilities.shell.allow to documentation. Loading a shared library is gated by ReadFile, so extension modules still load.
every write right no The bind is read-only.

scoped gets none of this. Without a composed root, Landlock applies straight over the real host filesystem, where /usr is the host's own — so the grant is emitted on the sealed tier only and is empty everywhere else.

The fixed sealed-tier /etc grant

A composed root does not carry the host's /etc. It carries a fixed allowlist of sixteen entries, bind-mounted read-only and each silently skipped when the host does not have it: the loader's cache and config (/etc/ld.so.cache, /etc/ld.so.conf, /etc/ld.so.conf.d), the alternatives database (/etc/alternatives), the TLS trust store (/etc/ssl, /etc/pki, /etc/ca-certificates, /etc/ca-certificates.conf), name resolution (/etc/resolv.conf, /etc/hosts, /etc/nsswitch.conf), the timezone (/etc/localtime, /etc/timezone), the terminal database (/etc/terminfo) and the account databases (/etc/passwd, /etc/group). Everything else under /etc/etc/shadow, /etc/sudoers, /etc/ssh, cloud-init credentials — is absent, and /etc itself cannot be listed.

Each entry gets one Landlock rule, fixed at compile time, with no manifest key that adds a path or removes one. Like the runtime-tree grant it is a property of the tier: it fires no warning and appears in no --explain-scope section.

Right Granted Why
ReadFile yes, on all sixteen Reading the file the composed root already mounted.
ReadDir on the six directory entries only — /etc/ssl, /etc/pki, /etc/ca-certificates, /etc/ld.so.conf.d, /etc/alternatives, /etc/terminfo TLS trust-store lookup and terminal-database lookup enumerate their directories. The other ten are files or symlinks, where listing has no meaning.
Execute no /etc/alternatives is a directory of symlinks into /usr/bin. Granting Execute here would be a second, undeclared route around capabilities.shell.allow.
every write right no The binds are read-only. A writable /etc/resolv.conf inside a capsule would be a name-resolution hijack of the capsule's own egress.

The account databases are synthetic. Fourteen of the sixteen entries are the host's own file, bind-mounted read-only. /etc/passwd and /etc/group are not: both are world-readable on every distribution, so binding the host's would hand a sealed capsule the machine's full account list. The composed root carries a synthetic pair instead — an entry for root, and one for the uid the capsule's subprocesses run as, under the account name capsule, whose home directory is the synthetic $HOME the subprocess environment already sets. Username, group and ~ lookups all resolve and agree with $HOME; no host account name appears inside the capsule.

scoped gets none of this, for the reason the runtime-tree grant gives: without a composed root these rules would apply to the host's own /etc — its real trust store, its real resolv.conf, its real account databases.

One operational consequence. Each of these entries holds a file descriptor open while the capsule starts, under whatever capabilities.resources.max_open_files the manifest declared. A sealed capsule allowing an interpreter and a shell needs roughly seventy descriptors to launch, and below that it is refused at startup rather than silently weakened. A manifest with a very tight max_open_files may need to raise it.

What the composed root contains

A capsule that declares capabilities.containment: sealed on a host that reaches the Sealed tier runs inside a fresh tmpfs the runtime populates and then pivot_roots onto. It holds exactly what the sections above put there, and every other path is absent:

Path What it is ls
/usr, /bin, /sbin, /lib, /lib32, /lib64, /libx32 The runtime-tree grant, bind-mounted read-only from the host lists
The sixteen /etc allowlist entries Bind-mounted read-only, the account databases synthetic lists, on the six directory entries
/dev A private tmpfs carrying the OCI device set Permission denied
/proc Masked with hidepid where the kernel allows it, bound from the host where it does not Permission denied
/tmp A directory inside the session workdir, bound here so it stays inside the workdir's size budget lists
The workdir, at its own absolute host path The capsule's accessible workdir, read-write, and the only writable path in the root lists
/, /etc as a directory, and each path component above the workdir Scaffolding the runtime created so the mounts below have somewhere to land Permission denied

cd and stat succeed where ls fails, and that is the design. With a workdir at /home/you/project, the composed root carries /home and /home/you as scaffolding holding nothing but the path down to that workdir:

$ cd /home && stat -c '%F' /home
directory
$ ls /home
ls: cannot open directory '/home': Permission denied

The reason is where the Landlock rules come from. A rule is attached to an inode, and the runtime opens each rule's file descriptor in the parent process, against a real host path, before the fork that builds the root. A bind-mounted subtree carries its host inode through the pivot_root, so its rule follows it — which is why ls /usr lists. The root tmpfs and the directories created inside it after the fork are new inodes that did not exist when those descriptors were opened, so no rule was ever taken on them and the capsule's own default-deny Landlock domain applies. That is also why ls / is refused, and why /dev and /proc hold nodes the capsule can open inside directories it cannot enumerate.

Enumerating the root is refused; opening a path inside it is not. Read a file under /usr or /etc directly rather than listing the directory that holds it.

Default-deny syscall allowlist

Every mechanism above governs a specific syscall (socket, mknod) or a specific resource (the workdir). Underneath them, the seccomp filter's default action is deny (EPERM), modelled on the OCI/Docker default seccomp profile: only a fixed, named allowlist of syscalls is permitted, and a syscall outside it is refused before any argument is read. Applies on every Linux tier identically.

execve/execveat are ordinary allowed syscalls in that list — capabilities.shell.allow is enforced by the Landlock Execute right instead (see W-SEC-011 and the tier table) — as are connect/sendto, whose capabilities.network.allow enforcement lives in the capsule's own network namespace and egress proxy. socket stays governed by the per-domain rules described above.

A container in front of mur run can hide what this layer does and does not do. A container supplies containment the runtime does not — a masked or minimal /dev, its own default syscall filter, dropped capabilities, its own mount and network namespaces. A capsule that looks well contained inside one may be relying on the container for part of that, and the same capsule on bare metal is contained only by what this page describes. Test the posture you intend to ship, on the kind of host you intend to ship it on.

Diagnosability. A syscall refused by the default action returns EPERM to the caller. The filter also turns on kernel audit logging for every non-allow action, so a denial reaches the kernel log with the syscall number, pid and process name — provided the host is configured to log seccomp errno actions, which mur does not control. Enforcement does not depend on that; only its legibility does.

Compatibility. The allowlist is reconciled against containerd's default profile so that a workload already proven to run under a container's seccomp profile keeps working under mur run's equivalent. Do not assume the syscall surface a shell workload needs is exactly the one that profile permits: if a workload dies on an unexpected EPERM, the audit trail names the syscall. Widening the allowlist is a change to the runtime, not something a manifest can do.

What to do: prefer specific binary declarations over bash, keep network.allow and filesystem.scope minimal, and use the data/action phase-separation pattern for capsules that ingest untrusted content. Do not run mur run as root if you can avoid it. On the Seccomp-only tier (W-SEC-002) this filter still applies, but the filesystem and exec gaps described there apply alongside it.


Capsule name resolution

Every name a capsule looks up is resolved by the runtime process itself, not by the C library. The capsule's namespace has one resolver — the runtime's, on UDP 53 — and the runtime answers it from an in-process DNS client of its own. A name outside capabilities.network.allow never gets resolved at all: it is answered REFUSED before any lookup starts.

One lookup for an allowlisted name has three outcomes, and each carries a different rcode.

Outcome What it means What the capsule sees
Resolved The name has addresses NOERROR with the A/AAAA records for the family asked about
Does not exist An upstream answered authoritatively that there is no such name NXDOMAIN (rcode 3)
Did not answer The deadline elapsed, no nameserver was reachable, or every one failed the query SERVFAIL (rcode 2)

SERVFAIL is what a resolver client reads as "ask again": getaddrinfo inside the capsule reports it as EAI_AGAIN, "Temporary failure in name resolution". NXDOMAIN is the claim that the name is gone, which a client has no reason to retry — so a slow or unreachable upstream must not produce it. One name's whole budget, across every search suffix tried and every nameserver asked, is five seconds; a name that exceeds it is SERVFAIL.

What the runtime reads. /etc/resolv.conf supplies the nameservers, the search list and ndots, applied as resolv.conf(5) states them. /etc/hosts supplies static names and is consulted first, so a name pinned there is answered without a query leaving the host. Both files are read once, when the first lookup in a process happens, and are not re-read when they change: a host that rewrites either mid-run needs mur restarted for the change to take effect.

What the runtime does not read. The runtime is a DNS client with a hosts file. Everything glibc's name-service switch layers on top of that is absent, and none of it is configurable:

Not honoured Consequence
/etc/nsswitch.conf ordering The order is always the hosts file, then DNS, whatever the file says
mDNS (.local names, mdns4_minimal) A .local name resolves only if an ordinary nameserver answers for it
myhostname The host's own name resolves only from /etc/hosts or DNS
nis, and systemd-resolved's resolve NSS module A name that exists only in one of these does not resolve

systemd-resolved's stub listener is unaffected: on a host whose /etc/resolv.conf names 127.0.0.53, the runtime speaks ordinary DNS to it like any other nameserver. It is the NSS module that is bypassed, not the daemon.


Executable workdirs

capabilities.filesystem.workdir_exec decides one Landlock bit: whether the session workdir's own rule carries the Execute right.

The default (false, and what every manifest that omits the key gets). The workdir is readable and writable but not executable. Each binary named in capabilities.shell.allow gets its own narrow read+execute grant at its real host path — the binary, its ELF interpreter, and its DT_NEEDED shared-library closure — so allowlisted programs run normally from /usr/bin and friends. Nothing the capsule produces runs. The decision is made by the kernel, on the path it resolved itself, so there is no name to spoof and no window to race:

$ # inside a capsule with `shell.allow: [bash]` and workdir_exec absent
$ cp /usr/bin/nc ./bash && ./bash -l
bash: ./bash: Permission denied

The cost, stated plainly. A binary the capsule legitimately compiled cannot run either:

$ gcc -o ./hello hello.c && ./hello
bash: ./hello: Permission denied

workdir_exec: true. The workdir keeps Execute, and compile-and-run works. In exchange, capabilities.shell.allow stops being an enforceable property of the capsule: anything written into the workdir runs regardless of what the allowlist says. The runtime does not pretend otherwise —

  • the capsule's achieved containment class is advisory, on every host, including a Landlock-capable one;
  • mur run --explain-scope reports workdir exec: true and the advisory it forced;
  • trace.jsonl's session_start carries workdir_exec: true;
  • W-SEC-011 fires once at staging;
  • pairing it with capabilities.containment: scoped (or sealed) refuses the launch.
capabilities:
  filesystem:
    workdir_exec: true           # compile-and-run; shell.allow is advisory inside the workdir
  shell:
    allow:
      - bash
      - gcc

The refusal, when a manifest asks for both, names the manifest rather than the host — because no host can satisfy it. See E-CAP-003.

Where it has no effect. The bit is a Landlock right, so a host with no usable Landlock ABI (Linux < 5.13) and every non-Linux host ignore it entirely — on those hosts nothing mediates exec either way, which is exactly why neither can reach scoped. W-SEC-001 and W-SEC-002 already say so.


Staged runtime

capabilities.shell.staged_runtime and capabilities.shell.interpreter_runtime solve the same problem — a path-based interpreter cannot run from its binary alone, because its stdlib lives outside the workdir at a path the DT_NEEDED closure cannot discover — and they solve it in opposite directions.

interpreter_runtime staged_runtime
Direction widens the capsule's Landlock scope outwards to host paths bind-mounts the runtime tree inwards into the capsule's own root
Floor required any (works at scoped) sealed only
Host paths reachable from inside yes, the granted directories no
Coupled to one host's layout yes — fires W-SEC-009 no, and pin makes the coupling checkable
Granularity specific directories, each with an explicit list_dir the whole named tree, read-only

Because the second makes the first unnecessary for any binary that uses it, declaring both for the same binary is rejected at parse time. A composed root does not contain the host directories an interpreter_runtime grant names, so the grant would describe paths that do not exist inside the capsule.

capabilities:
  containment: sealed              # required
  shell:
    allow:
      - python3
    staged_runtime:
      - binary: python3
        source_path: /opt/testbed/conda/envs/django__django
        pin: conda-4.10.3/python-3.9.19/testbed-2024-05-01

A staged_runtime grant requires an effective sealed floor, and declaring it below one is refused at launch with E-CAP-004.

pin is for humans, not for the runtime. Nothing parses it, matches it, or verifies it against the tree at source_path. It exists so that "the same interpreter build ran on both hosts" is a claim someone can check by comparing two manifests, rather than one assumed from two directories having the same name. mur run --explain-scope prints every declared grant with its pin, on every host and whatever the floor:

  staged runtime:
    - python3: /opt/testbed/conda/envs/django__django (pin: conda-4.10.3/python-3.9.19/testbed-2024-05-01)

--explain-scope is a diagnostic, so it reports the grant even where mur run would refuse — which is exactly the case an operator is inspecting.

Under a declared sealed floor, a capabilities.shell.allow grant that cannot function inside the composed root is decided at staging rather than deep into a run — see E-CAP-006 and W-SEC-012.

Testing containment honestly

Probe with an absolute path to a real file that exists outside the workdir, and read the file rather than checking that it exists:

$ cat /home/you/.ssh/id_rsa
cat: /home/you/.ssh/id_rsa: Permission denied

mur run --explain-scope --json names the answer to expect, as filesystem_boundary.restriction:

filesystem_boundary.restriction What the probe above prints
advisory The file's contents. No kernel mechanism mediates the filesystem
enforced Permission denied — Landlock refuses the open, and stat on the same path still succeeds
absent No such file or directory — the path is not in the composed root

Read filesystem_boundary.restriction rather than achieved_containment. The achieved class is what this host can back; the restriction is the mechanism this session installs, and the two part company on a sealed-capable host running a capsule that declared less than sealed. A composed root is built only for a session that asked for one — through capabilities.containment: sealed or mur run --containment sealed — so a capsule that declares nothing reads achieved: sealed and restriction: enforced, and its probe gets Permission denied rather than No such file or directory.

The ~ form returns a false pass. cat ~/.ssh/id_rsa answers No such file or directory on every tier, including a host that denies nothing. The tilde resolves through HOME, which the runtime rewrites to <workdir>/.capsule-home before every subprocess spawns, so the shell looked inside the workdir and the host path was never opened. The answer reports where the path landed, not what was denied. This holds on macOS too, which reaches advisory — the same absolute path there reads the file.

mur run --explain-scope says the same thing under Not protected here on every session that composes no root, and --explain-scope --json carries it as filesystem_boundary.not_protected.

The rewrite is unconditional, so a capsule that writes to ~ writes inside its own workdir on every tier and every platform. See Lock down a capsule for the environment the subprocess starts with.

Verification — how the containment claims are checked

Landlock scoping, seccomp filtering, pivot_root onto a composed root, cgroup v2 resource ceilings and file-descriptor hygiene across exec are all claims about what a kernel does, so each one is checked against a real Linux host. Two things do the checking, and they cover different ground.

Escape-conformance harness Manual procedures
Form One command, graded against a declared containment class Written scenarios with exact commands and expected output
Covers Filesystem escape, /proc re-open paths, inherited descriptors, device-node creation, the exec allowlist, network egress, unix sockets, the dangerous-syscall table, resource exhaustion Launch-time refusals, the composed root observed from inside a live capsule, the fixed device set, shell-binary reachability, staged runtimes, the child's capability sets
Where the result lives A dated record file the run writes A dated entry in the procedure itself

The rest of the test suite asserts the decision logic: which enforcement tier a probe resolves to, which containment class a tier achieves, that a zero limit value is rejected.

The escape-conformance harness

crates/capsule-runtime/escape-conformance drives a real capsule through a registry of escape probes and grades each verdict against what the declared class promises. It is its own workspace root, so build and run it from its own directory:

cd crates/capsule-runtime/escape-conformance
cargo build --release
./target/release/escape-conformance --class sealed

--class takes advisory, scoped or sealed and is what the run is graded against. --list-cases prints the registry with each case's expectation per class, and --help lists the remaining options.

The run needs a built mur binary, python3, and a delegated cgroup v2 subtree — the harness wraps each capsule in systemd-run --user --scope --property=Delegate=yes by default to get one. It runs on bare metal: a host that cannot back the class under test, and a host that looks like a container, are both refused before the first case runs, and a refused run writes no record.

The exit code separates a refusal from a failure, and an escape from a ceiling that gave way:

exit meaning record
0 every asserted case matched its expected verdict written
1 usage error, or the harness itself could not proceed none
2 refused — this host cannot back the class, or a prerequisite is missing none
3 a boundary case failed — a containment escape written
4 boundary clean, a resource ceiling did not hold — denial of service written

The manual procedures

Each procedure lives in the murmur repository on the main branch; the name links to it.

Procedure What it covers When it is run
Network namespace + egress proxy That a capsule's native subprocess tree runs inside its own network namespace, and that the only way out is a proxy in the runtime process applying capabilities.network.allow. Includes the E-CAP-005 refusal on a host that cannot provide a namespace. Whenever the namespace setup, the egress proxy, or the allow-list enforcement path changes.
Sealed containment The sealed class end to end: the E-CAP-003 refusal when the AppArmor profile is absent, the composed root observed from inside a live capsule's shell tool (paths outside it return ENOENT, not EACCES), "containment_achieved":"sealed" in trace.jsonl, and the refusal to run at a weaker class inside a plain container. Release gate for the sealed class. Re-run whenever the composed-root construction, the host probe, or the tier→class mapping changes.
Staged runtime bind mount That a capabilities.shell.staged_runtime grant lands its pinned tree read-only inside the composed root at the same absolute path it has on the host, that a missing source_path refuses the session with E-RUN-014 before any shell command runs, and that the same pin on two hosts yields the same interpreter with no interpreter_runtime grant anywhere. Whenever the staging bind, the grant's Landlock rights, or the composed-root plan changes.
Resource limits The three mechanisms bounding the native subprocess tree: setrlimit(2) per-process ceilings, the cgroup v2 scope (fork bomb, memory hog, CPU, I/O), and the periodic workdir-size check. Ten scenarios, including the E-RUN-012 fail-closed launch refusal and the macOS gap behind W-SEC-010. On a Linux host with systemd user cgroup delegation configured, whenever capabilities.resources enforcement or the cgroup delegation path changes.
Subprocess fd hygiene The negative property that a descriptor open in the runtime process at spawn time is not visible inside the spawned child, across both spawn paths (shell tool and native tool), on both kernel tiers. Landlock cannot substitute for this: an inherited fd was opened before the ruleset existed. Whenever either spawn path's pre-exec window changes.
Workdir Execute rights and declared workdir_exec That capabilities.shell.allow is complete: with the default capabilities.filesystem.workdir_exec: false, a binary planted in the session workdir under an allowlisted basename does not execute, because the workdir's Landlock rule carries no Execute right. Also the declared opt-in's whole visible surface — the binary runs, the achieved class drops to advisory, --explain-scope and trace.jsonl say so, and containment: scoped alongside it refuses with E-CAP-003. Whenever the workdir grant's right set, the exec-grant derivation, or the tier→class mapping changes.
Shell-binary reachability under sealed That a capabilities.shell.allow grant which cannot actually function inside a composed root fails at launch rather than deep in a run: the E-CAP-006 refusal for an interpreted entrypoint whose package tree nothing declared reaches, the W-SEC-012 warning for a compiler driver whose cc1/as/ld helpers have no Execute grant, and the two negative controls — a system /usr interpreter that needs no grant, and a declared interpreter_runtime that makes a real compile succeed. Whenever the reachability checks, the fixed sealed runtime tree's right set, or the known-driver registry changes.
Workdir device-node escape That a capsule cannot create a character- or block-device node inside its own workdir and read the raw host filesystem through it, via the Landlock workdir grant withholding those rights and the shell child's capability drop. Also that FIFO and ordinary file creation still work, and that the child is left non-dumpable. Whenever the workdir grant's right set, the child's capability drop, or the pre-exec hardening sequence changes.
Unmediated AF_UNIX sockets That a capsule cannot open a unix-domain socket by default and so cannot reach a host daemon socket such as /var/run/docker.sock; that AF_NETLINK and AF_PACKET are refused with and without the opt-in; that capabilities.network.unix_sockets: true really does hand the family back; and whether real workloads survive the default deny. Whenever the socket(2)-domain rule, the unix_sockets opt-in, or the set of denied address families changes.
The fixed capsule device set That /dev/null is readable and writable, /dev/zero and /dev/urandom readable but not writable, every other device refused, and /dev itself not enumerable — plus that a host missing one of the three degrades rather than failing the launch, and whether three devices are enough for real workloads. Whenever the fixed device set, its per-device rights, or the missing-device fallback changes.