Containment
A containment class bounds what a capsule's subprocesses — shell commands, plan shell steps and native tools — 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 the capsule's subprocesses:
the binaries declared under capabilities.shell.allow and the capsule's
native tools. 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, where the namespace probe builds the composed root (how the host is probed) | 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 for a session on that tier, and
--explain-scope --json carries it as enforcement_tier. It names the tier the session's
commands run on, which Declared class and host tier gives for every
declared class. The covers: line under it names the subprocess routes that mechanism confines,
and --explain-scope --json carries them as mechanism_covers:
mechanism_covers value |
Route |
|---|---|
shell |
A capabilities.shell.allow binary the model calls |
plan-shell-steps |
A shell step of a plan submitted through submit-plan |
native-tools |
A runtime: tool artifact that ships a native implementation |
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 paths outside the workdir are /dev/null (see
the fixed capsule device set), the
session's temporary folder and its home folder. An allowlisted CPython or Perl interpreter also gets
read-only grants on the directories it reports on its own import path (see
Derived interpreter runtimes), and a capsule that declares
capabilities.network.allow gets a read-only grant on the host's TLS trust store (see
The trust store for commands that reach the network). For any other path-based
interpreter, a capsule may name the host directories it needs 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.
The workdir grant does carry Refer and Truncate, so files move between its folders and the
capsule can truncate its own files, on a kernel whose Landlock ABI has them.
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 session's 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.
One network namespace per session. Every shell call and every capabilities.shell.allow
binary a session runs shares that session's one network namespace:
lois its only interface, and the egress proxy is its only way off the host.- A server one shell call starts on
127.0.0.1answers the session's later calls, whether it was backgrounded with&or demoted afterlifecycle.shell_grace_secs. - Nothing outside the session reaches it: not the host, not a second
mur runof the same capsule, not a delegated child or another formation member, each of which has a namespace of its own. - A plan's shell steps run in the namespace of the session that submitted the plan.
- When the session ends, every process it started is killed, backgrounded servers included, and the namespace goes with them.
Environment-only enforcement still sets HOME to the session's
home folder 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.
Native tools
A runtime: tool artifact whose packed murmur.yaml says implementation: native is a host
executable. It is spawned the way a capabilities.shell.allow command is: in the session's network
namespace, under the same seccomp filter with its capabilities dropped, and, on the Full and Sealed
tiers, inside the capsule's Landlock scope and composed root.
| Tier | A native tool gets |
|---|---|
| Sealed | The composed root, Landlock, seccomp and the session's network namespace |
| Full | Landlock, seccomp and the session's network namespace |
| Seccomp-only | Seccomp and the session's network namespace; no filesystem or exec scope |
| Environment-only | HOME set to the session's home folder, the credential strip and the per-process rlimits; no kernel confinement |
Its grants are the capsule's own: capabilities.filesystem, capabilities.network.allow,
capabilities.network.unix_sockets and capabilities.shell.allow apply to it exactly as they apply
to a shell command. The runtime adds the tool's store when its own entry
declares capabilities.state, and the files the kernel itself executes to start the tool, each
with read and execute and nothing else. It works those files out once, when it installs the tool,
from the artifact's own image:
- The tool's entrypoint,
tools/<name>/<name>in the session's runtime directory. - For a script, the absolute interpreter on its
#!line. On Sealed, that interpreter's directory is bound into the composed root when no fixed runtime path already carries it. - The ELF interpreter and shared-library closure of each, found at absolute paths and in the
system library directories. A library the tool finds through
$ORIGIN, or anywhere inside the workdir, gets no grant; one shipped beside the tool is read through the runtime directory's read-only grant.
Everything else the tool reads, writes, runs or connects to has to be granted by the capsule:
| The tool… | Needs |
|---|---|
Runs another program, such as git |
That program in capabilities.shell.allow |
Is a #!/usr/bin/env python3 script |
python3 in capabilities.shell.allow: the #! grant covers env only |
| Reads or writes a path outside the workdir | On Full and Sealed, keep its inputs and outputs in the workdir, the session's temporary folder, or the store capabilities.state names on its own entry. Nothing grants a write anywhere else; the read grants outside it are an allowlisted interpreter's derived runtime, the host's trust store when the capsule declares capabilities.network.allow, and a capabilities.shell.interpreter_runtime directory, which names a shell.allow binary and grants execute as well |
| Reaches a host | That host in capabilities.network.allow; the connection leaves through the egress proxy |
Reaches a server an earlier shell call started on 127.0.0.1 |
Nothing: the tool shares the session's loopback |
Opens an AF_UNIX socket |
capabilities.network.unix_sockets: true |
What each default artifact native tool needs is listed with the tools.
A capsule whose only subprocess route is a native tool still needs a network namespace, and is refused with
E-CAP-005 on a host that cannot create one. A sealed composed root
that cannot be built for a native tool ends the session with
E-RUN-014 (see Containment class), as it does for a shell command.
The entrypoint sits in the runtime directory, which the capsule's commands can read and cannot change on the Full and Sealed tiers: a command that overwrites, replaces or renames over it is refused, so the tool that runs is the tool that was installed, a pulled one included. On the Seccomp-only and Environment-only tiers nothing protects it; see What protects it.
A native tool's own config and store
Two keys on a native tool's own artifact entry reach that tool and the processes it starts, and nothing else:
| Entry key | The tool gets |
|---|---|
config: |
MURMUR_ARTIFACT_CONFIG: the block as compact JSON, on every tier |
capabilities.state |
MURMUR_STATE_DIR: the absolute path of its durable store, ~/.murmur/state/<store>/, created at launch |
An entry without the key gets no such variable. The runtime sets both names and takes neither from
the host: naming one in capabilities.shell.baseline_env or capabilities.env.allow passes
nothing through. Shell commands and other native tools have neither, and a WASM component gets
only its own config: and never MURMUR_STATE_DIR.
Who else can open the store depends on the tier:
| Tier | The store is reachable by |
|---|---|
| Sealed | The tool and the processes it starts. The store is bound into that tool's composed root at its own path, read and write, never execute. A store that cannot be bound ends the session with E-RUN-014 |
| Full | The tool and the processes it starts, read and write, never execute. Shell commands and other native tools are refused |
| Seccomp-only | Every subprocess of the capsule, by path. The tier has no filesystem scope; the tool is handed the path |
| Environment-only, macOS included | Every subprocess of the capsule, by path, as with any host file |
Every other sub-block of a native tool's capabilities: narrows nothing: the tool runs under the
capsule-wide grants above, and the entry prints W-SEC-008.
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
host can back: sealed
floor met: yes
mechanism: mountns+pivot_root+landlock+seccomp
covers: shell commands, plan shell steps, native tools
userns grant: profile_confining
landlock ABI: 3
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 murmur ships: the restriction is on and no mur-sealed profile confines mur |
Nothing is refused for this reading. The network namespace, the private /tmp and sealed are each decided by creating the namespaces, so a capsule is refused with E-CAP-005 or E-CAP-003 only when a creation fails; see Telling host conditions apart |
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.
Which Landlock ABI the ruleset is built at
On the Sealed and Full tiers the runtime builds the capsule's ruleset at the kernel's Landlock ABI, capped at v3. Two of the workdir's rights depend on it:
| Ruleset ABI | Kernel | Moving or hard-linking a file between two workdir folders | truncate(2) on a host file outside the workdir |
|---|---|---|---|
| 1 | Linux 5.13–5.18 | fails with EXDEV (Invalid cross-device link) |
not mediated: succeeds wherever the capsule's user may write |
| 2 | Linux 5.19–6.1 | permitted (Refer) |
not mediated |
| 3 | Linux 6.2 and later | permitted (Refer) |
refused (Truncate) |
At ABI 1, tools that fall back to copy-and-delete (mv, pip, git) still work, and uv and
other tools that rename atomically between folders fail. Nothing moves or links across the
workdir boundary at any ABI.
mur run --explain-scope prints the ABI on the landlock ABI: line below userns grant:, and
--explain-scope --json and session_start.effective_grants in
trace.jsonl carry it as landlock_abi. The
value is n/a, or null in JSON, when no Landlock domain is installed. mur doctor prints the
kernel's ABI and the ruleset ABI in its Landlock block, and says what this host loses below ABI 3:
Landlock
kernel ABI: 8
ruleset ABI: 3 (the kernel's, capped at 3)
Telling host conditions apart
Whether a capsule gets its network namespace, a mapped /tmp and sealed is decided by one
namespace probe, taken once per capsule run. It creates, in short-lived child processes, exactly
the namespaces a session needs, in the order a session needs them:
| Attempt | What the child does | Decides |
|---|---|---|
| Network | unshare(CLONE_NEWUSER \| CLONE_NEWNET), the identity uid_map/gid_map, lo up, the local default route, the namespace's sockets, the hand-off to the runtime |
E-CAP-005 for a capsule that can spawn a subprocess |
| Mount | Joins that network namespace as every command does, then unshare(CLONE_NEWNS), / made MS_REC\|MS_PRIVATE, a bind over /tmp |
Whether a literal /tmp is mapped by a mount namespace (temporary folder) |
| Sealed root | Joins that network namespace, then unshare(CLONE_NEWUSER \| CLONE_NEWNS) nested inside it, the identity maps, / made MS_REC\|MS_PRIVATE, a tmpfs, pivot_root(2), the old root detached |
The Sealed tier, and E-CAP-003 for a sealed capsule |
Each attempt is created, refused at a named call with its errno, or not attempted because the
network namespace it nests in could not be created. A refused network attempt therefore leaves
/tmp unmapped and the host at most on landlock+seccomp.
AppArmor's kernel.apparmor_restrict_unprivileged_userns and the profile confining mur are read
with the attempts and only word a refusal: the setting is never the reason for a refusal. A
refusal ends with the readings line, Host readings: kernel.apparmor_restrict_unprivileged_userns=<0|1>;
AppArmor profile confining mur: <profile>., and mur doctor prints one line per attempt in its
AppArmor / user namespaces block. Inside a container whose profile permits the namespaces, with
the setting reading 1:
AppArmor / user namespaces
this binary: /usr/local/bin/mur
profile attached: docker-default
Some other profile confines this binary. It is not the profile mur ships and grants nothing mur asked for; it may also be narrower than unconfined.
The grant below reads withheld, and the namespaces below were created regardless: the reading explains a refusal when an attempt fails, and caused none here.
userns grant: withheld
the restriction is on and no mur-sealed AppArmor profile is confining this binary, so nothing murmur ships grants it a user namespace
network namespace: created (a real probe created one; the readings above explain a refusal, they never cause one)
mount namespace: created (a private mount namespace inside the session's user namespace was created, so /tmp can be mapped)
sealed root: created (a nested user+mount namespace inside the session's was created and pivot_root(2) entered a composed root, so sealed can be backed)
A refused attempt reads refused — <call> failed with <ERRNO>, and an attempt after a refused
network namespace reads not attempted (no network namespace to create it in). The failed call,
its errno and the readings identify the host condition:
| Host condition | Network | Mount, sealed root | Readings line | Outcome and remedy |
|---|---|---|---|---|
Bare Ubuntu, setting 1, mur unconfined |
refused at writing uid_map/gid_map, EPERM |
not attempted | =1; … unconfined |
E-CAP-005; sealed refused with the mur-sealed profile sentence. Load the mur-sealed profile |
mur-sealed loaded |
created | created | =1; … mur-sealed |
Nothing refused |
| A container whose profile and seccomp policy permit the namespace | created | created | =1; … <container profile> |
Nothing refused |
A container profile that denies mount |
created | refused at making / MS_REC\|MS_PRIVATE |
=1; … <container profile> |
/tmp not mapped; sealed refused as mount(2) refused. Run the container with a profile that permits mount |
A container runtime that refuses unshare |
refused at unshare(CLONE_NEWUSER \| CLONE_NEWNET), EPERM |
not attempted | names the container profile | E-CAP-005; sealed refused as the namespace refused. Permit unshare(CLONE_NEWUSER) in the container's seccomp policy, or --cap-add SYS_ADMIN |
user.max_user_namespaces=0 |
refused at unshare(…), ENOSPC |
not attempted | any | E-CAP-005; sealed refused as unsupported by the kernel. Raise user.max_user_namespaces |
mur running inside a chroot |
refused at unshare(…), EPERM |
not attempted | any | E-CAP-005. The kernel refuses a user namespace to a chrooted process; run mur outside the chroot |
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. |
advisory is user-level trust. What a capsule's shell can write, including the compiled WASM
artifacts in ~/.murmur/compiled/ that every later launch
loads and the host facts in ~/.murmur/host_facts/ that every later launch grants
from, depends on the tier the host achieves:
| Tier | The shell can write | ~/.murmur/compiled/ and ~/.murmur/host_facts/ |
|---|---|---|
| Seccomp-only, Environment-only | Anything you can | Writable. A later scoped or sealed launch loads the compiled artifacts and grants the paths the host facts the shell wrote there name. The checksum stored beside each compiled artifact and the file stamps in each host facts entry detect damage and are no defence against code running as you |
| Full, Sealed | Its workdir only | Writable only when ~/.murmur lies inside the workdir. mur then neither reads nor writes either directory for that launch, but a later launch from another workdir uses what the shell wrote there |
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:
capabilities.containmentinmurmur.yaml(this field)containmentin.murmur/config.yaml, global or project scope (see Configuration files; note this key uses strongest-wins merging, not the usual project-wins rule)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. The achieved class is the class of the tier the session's commands run on.
mur run probes the kernel directly (never by trusting the manifest) for the strongest tier the
host can back, and the session runs on that tier capped by its declared floor:
- a Landlock-capable Linux 5.13+ host backs the Full tier,
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 — backs the Sealed tier,
sealed, which only a capsule declaringsealedruns on; - every other host (older Linux without Landlock, or macOS) backs
advisory.
Granting a scoped capsule access to host paths outside the workdir via
capabilities.shell.interpreter_runtime never changes the achieved class.
Declared class and host tier
Each cell gives the tier the session's commands run on, by its mechanism: name, and the class
achieved: reports:
| Declared | Sealed host | Full host | Seccomp-only host | Environment-only host |
|---|---|---|---|---|
advisory |
landlock+seccomp, scoped |
landlock+seccomp, scoped |
seccomp-only, advisory |
none, advisory |
scoped |
landlock+seccomp, scoped |
landlock+seccomp, scoped |
refused, E-CAP-003 |
refused, E-CAP-003 |
sealed |
mountns+pivot_root+landlock+seccomp, sealed |
refused, E-CAP-003 |
refused, E-CAP-003 |
refused, E-CAP-003 |
capabilities.filesystem.workdir_exec: true and a runtime directory inside the work folder each
cap the reported class at advisory without changing the tier. What the host could provide to a
capsule declaring sealed is reported separately, as host can back: under achieved: and
host_containment in --explain-scope --json and session_start.effective_grants.
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. mechanism: in --explain-scope still names the tier the
commands run on, and host can back: still names what the machine can do. 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.
So does a runtime directory inside the work folder. When ~/.murmur/sessions lies inside the
work folder the capsule's commands write, they can change the task, MURMUR.md, plans and installed
tools in its runtime directory, and the achieved class is capped at
advisory the same way. A scoped or sealed floor is refused with
E-CAP-003; below it the launch prints
W-SEC-033.
A weaker declaration is never silently upgraded. On a sealed-capable host, a capsule
declaring scoped (or advisory, or nothing) runs with scoped's mechanism — Landlock and seccomp
over the host filesystem, no composed root — and reports achieved: scoped and
mechanism: landlock+seccomp. Installing a composed root anyway would delete the host paths its
interpreter_runtime grants legitimately name, weakening the capsule rather than strengthening it.
host can back: sealed is the only line that names what the host could have provided.
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.
Provider tools
A capsule that declares a tool whose artifact carries a
provider_tool: block — a computer tool — must run sealed. The
requirement is decided against the effective declared floor, which it never raises:
| Effective declared floor | Result |
|---|---|
advisory or scoped |
Refused with E-CAP-003 before the session directory exists and before any driver call, naming every such tool and its kind, and the floor |
sealed, on a host that backs it |
Runs |
sealed, on a host that cannot back it |
The usual E-CAP-003 refusal, with a required by: line for each such tool |
A computer tool therefore runs on Linux only. A sealed capsule whose subprocess tree cannot get
its own network namespace is refused with E-CAP-005, which carries
the same required by: lines. A pull-artifact of such a tool into a session below sealed is
refused, with nothing installed.
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 and native tools 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 or native tool. 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: local/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>/reportsis 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 and native tools 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_files — null
when nothing is exported. See Resource plane.
Symlinks under an export root
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, the session's temporary folder and its home folder are the only writable paths 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 capsule declaring less than sealed on a sealed-capable host achieves scoped, so the scoped
rule applies to it.
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 and the session's temporary folder 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 is not a second exception. Where a command's /tmp is writable, it is the
session's temporary folder, which carries exactly the workdir's rights, so a binary
written to /tmp is no more runnable than one written to the workdir.
Every other device path is denied. The capsule's Landlock domain declares every right of the
ABI it is built at, 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 workdir, /tmp and the home folder are the only writable paths 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
session's home folder, the $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. The trust store alone reaches scoped
commands, for a capsule that declares capabilities.network.allow, through
the trust-store grant.
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 |
The session's temporary folder, tmp/ in the records directory, bound read-write |
lists |
| The work folder, at its own absolute host path | The capsule's work folder, read-write | lists |
| The runtime directory and the records directory, each at its own absolute host path | Bound read-only | lists |
The records directory's home/, at its own absolute host path |
The session's home folder, a verified read-write bind mounted after the records directory's read-only bind | lists |
/, /etc as a directory, and each path component above the work folder and the bound directories |
Scaffolding the runtime created so the mounts have somewhere to land | Permission denied |
The work folder, /tmp and the home folder are the only writable paths in the root.
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.
The session's temporary folder
Every session has a private temporary folder: tmp/ in its
records directory,
~/.murmur/records/<workdir root>/<session-id>/tmp.
| Property | Value |
|---|---|
| Created | At staging, mode 0700 |
| Written by | The session's shell commands, plan shell steps and native tools. The runtime writes nothing in it |
| Shared with | No other session. It is never inside the work folder |
| Landlock rights | Exactly the work folder's, so with capabilities.filesystem.workdir_exec: false nothing written to it can run |
| Size | Counted together with the work folder and the home folder against capabilities.resources.workdir_max_bytes; a breach ends the session with E-RUN-013 |
| Removed | With the session's records directory, by trace.retain or mur trace rm, including any read-only directory a command left in it |
The rest of the records directory stays read-only to commands: no record can be appended to, replaced, moved or hard-linked into the folder, and the folder itself cannot be renamed.
How a command reaches it
TMPDIR, TMP and TEMP are runtime-owned in every shell command, plan shell step and native
tool: no manifest key, capabilities.shell.baseline_env entry or host value changes them. All
three hold /tmp where a literal /tmp is mapped onto the folder, and the folder's absolute path
where it is not:
| Applied tier (classes) | Mount namespace usable | Literal /tmp |
TMPDIR, TMP, TEMP |
temp_dir.mapped_by |
|---|---|---|---|---|
mountns+pivot_root+landlock+seccomp (sealed) |
required by the tier | The composed root binds the folder at /tmp |
/tmp |
composed-root |
landlock+seccomp (scoped, and advisory on Linux 5.13+) |
yes | A private mount namespace per command binds the folder over /tmp |
/tmp |
mount-namespace |
landlock+seccomp |
no | Not mapped; Landlock refuses it | The folder's absolute path | null |
seccomp-only |
yes | A private mount namespace per command binds the folder over /tmp |
/tmp |
mount-namespace |
seccomp-only |
no | Not mapped; the host's own /tmp, with no filesystem containment on this tier |
The folder's absolute path | null |
none (macOS, any non-Linux host) |
— | Not mapped; the host's own /tmp |
The folder's absolute path | null |
Any tier but mountns+pivot_root+landlock+seccomp, with the workdir being /tmp |
— | Not mapped, since binding /tmp would hide the workdir |
The folder's absolute path | null |
Mount namespace usable is the namespace probe's mount attempt: inside the network namespace
it created, a child joined it the way every command does and built the private mount namespace a
command's mapping builds. It is decided by that attempt alone, never by the AppArmor setting; see
Telling host conditions apart. On a sealed-capable host, a
capsule declaring advisory or scoped runs on landlock+seccomp and maps /tmp through a mount
namespace.
On a mount-namespace tier each command enters a private mount namespace inside the session's
user namespace:
- The folder is bound over
/tmp. -
Every path the command needs that lies under the host's
/tmpis bound back at its own path, and nothing else of the host's/tmpis visible:Path Bound The work folder and the session's home folder Read-write The runtime directory, the records directory and every Landlock grant path Read-only A workdir or
HOMEunder/tmpkeeps working, and the empty directories leading to each bound path appear in the folder. 3. A mapping that cannot be built fails that command's spawn, and nothing runs:sandbox: could not map /tmp onto this session's temporary folder: <step> failed: <errno text>The command fails as an ordinary tool call; the session goes on. A link swapped onto the folder's path fails the step with
ESTALErather than redirecting the bind.
WASM artifacts and a transport: process harness are not capsule commands: their environment and
mounts do not change.
Reporting
mur run --explain-scope prints a Temporary folder block after the Records directory block, and
mur doctor prints the same block after its own Records directory block:
Temporary folder
path: /home/dev/.murmur/records/home/dev/proj/workdir/<session-id>/tmp
TMPDIR: /tmp
/tmp: mapped (mount-namespace)
Where /tmp is not mapped, the last line reads /tmp: not mapped — <reason>:
| Case | Reason |
|---|---|
| No kernel containment | this host has no kernel containment for capsule commands, so a literal /tmp path is the host's own /tmp; TMPDIR names the session's temporary folder |
seccomp-only without a mount namespace |
this host can give capsule commands neither a private mount namespace nor Landlock, so a literal /tmp path is the host's own /tmp; TMPDIR names the session's temporary folder |
landlock+seccomp without a mount namespace |
this host cannot give capsule commands a private mount namespace, so a literal /tmp path is refused; TMPDIR names the session's temporary folder |
The workdir is /tmp |
the workdir is /tmp itself, which mapping /tmp would hide; TMPDIR names the session's temporary folder |
--explain-scope --json and trace.jsonl's session_start.effective_grants carry the block as
temp_dir.
Known properties
| Property | Where | What it means |
|---|---|---|
/tmp and the workdir are different mounts |
A mapped tier | rename(2) between them fails with EXDEV, as on any host whose /tmp is a tmpfs |
The host's own /tmp is hidden |
A mapped tier | A Unix socket a host daemon keeps under /tmp is unreachable, which matters only with capabilities.network.unix_sockets: true |
TMPDIR is the folder's full path |
An unmapped tier | A program that creates a Unix socket beneath it can exceed the socket name limit: 104 bytes on macOS, 108 on Linux |
The folder lives under HOME |
Every tier | Its bytes land on HOME's filesystem |
A path the command needs lies under the host's /tmp |
A mapped tier | It is a read-only bind unless it is the workdir, so a write there fails with Read-only file system rather than Permission denied, and it carries the folder's read and execute rights |
The session's home folder
Every session has a private home folder: home/ in its
records directory,
~/.murmur/records/<workdir root>/<session-id>/home.
| Property | Value |
|---|---|
| Created | At staging, mode 0700 |
| Reached as | $HOME in every shell command, plan shell step and native tool, on every tier and every platform. No manifest key, capabilities.shell.baseline_env entry or host value changes it. Under sealed, also the home directory of the synthetic passwd entry |
| Written by | The session's shell commands, plan shell steps and native tools. The runtime writes nothing in it |
| Shared with | No other session. It is never inside the work folder |
| Landlock rights | A rule with exactly the work folder's rights on both Landlock tiers, mountns+pivot_root+landlock+seccomp and landlock+seccomp, so with capabilities.filesystem.workdir_exec: false nothing written to it can run |
| Composed root | A verified read-write bind at its own absolute path, mounted after the records directory's read-only bind. A link swapped onto its path fails the bind rather than redirecting it |
Mount-namespace /tmp mapping |
Bound back read-write at its own path when it lies under the host's /tmp |
| Size | Counted together with the work folder and the temporary folder against capabilities.resources.workdir_max_bytes; a breach ends the session with E-RUN-013 |
| Removed | With the session's records directory, by trace.retain or mur trace rm |
As with the temporary folder, no record can be moved or hard-linked into the home folder, and the folder itself cannot be renamed.
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 session's 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.
Network reach limits
- Only proxy-reachable ports work. The namespace binds a listener for each port the allowlist
implies, and connections are relayed by address. A destination on a port no allow entry names
has no listener and is refused by the kernel, so an allowlist of
https://api.example.comdoes not makeapi.example.com:22reachable. At most 16 distinct ports get a listener: an allowlist naming more is accepted, and the ports past that get none. The failure looks like a network fault rather than a policy decision. - IPv4 only, and a dual-stack name's real IPv6 address is still handed out. The namespace
installs no IPv6 route, so an IPv6 destination fails with
ENETUNREACHwhatever DNS said. The resolver answersAAAAwith no records only when the name has no IPv6 address upstream; a dual-stack name's realAAAArecords are returned unchanged. A client that tries IPv6 first pays a fallback-to-IPv4 delay but is granted nothing. A capsule whose allowlisted host is IPv6-only cannot reach it. - The proxy's ports are held for the whole session. Each listener the allowlist implies sits
on
0.0.0.0:<port>in the session's namespace from its first shell call to its end. That listener is the session's egress path: every outbound connection in the namespace lands on the local listener for its destination port. A server bound to one of those ports would receive the session's own outbound connections to allowlisted hosts on that port, so the ports are never released between commands. A server a capsule starts on one of them —443under anhttps://allowlist entry, for example — fails withEADDRINUSE. Pick a port no allow entry names. - A loopback server is reachable from its own session only. Later shell calls of the same
session reach it; the host, other sessions, delegated children and formation members get
Connection refused. - A runtime killed with
SIGKILLstill ends its session's processes. Each session's cgroup scope has a watcher process that kills everything in the scope as soon as the runtime is gone, however it died:SIGKILL, the OOM killer, or a crash. Processes outlive the runtime when the watcher is killed too (for example by aSIGKILLsent to every process of the user), and on macOS, which has no cgroups. Find those withpgrepand end them by hand. - The address→name binding has a lifetime. A connection is matched to a name through the answer the runtime's own resolver gave. The binding lasts five minutes, longer than the 60-second TTL the resolver puts on its answers. A client that caches an address far past its TTL and connects much later is checked against the addresses the allowlist resolved to at launch instead, as a literal address is.
Executable workdirs
capabilities.filesystem.workdir_exec decides one Landlock bit: whether the work folder's own
rule, and the rules on the session's temporary folder and
home folder, carry 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-scopereportsworkdir exec: trueand theadvisoryit forced;trace.jsonl'ssession_startcarriesworkdir_exec: true;W-SEC-011fires once at staging;- pairing it with
capabilities.containment: scoped(orsealed) 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.
Derived interpreter runtimes
An allowlisted interpreter reads its standard library from directories its binary does not name.
When mur stages a session on Linux, it asks each capabilities.shell.allow binary it recognises
as an interpreter where those directories are, and grants them. No manifest key turns this on or
names a path.
| Interpreter | Recognised by | Asked with |
|---|---|---|
| CPython | its binary lists the symbol Py_BytesMain |
-I -S -c and a program printing sys.path and site.getsitepackages() |
| Perl | its binary lists the symbol perl_parse |
-f -e and a program printing @INC |
The interpreter runs outside the sandbox, with an empty environment, / as its working directory
and a 5-second limit, before the capsule starts: when a staging or a mur run --explain-scope
finds no kept entry for it that still matches the host. A binary the capsule's
commands could have written is never run: one inside the workdir root, or inside
~/.murmur/records/<workdir root>/, which holds every session's
temporary folder. A binary named by a staged_runtime
grant is not asked, because its runtime is the staged tree. When the interpreter cannot answer,
mur prints one line naming the binary and the reason, keeps nothing, and the session starts with
the binary's usual grants; the next launch asks again.
What the interpreter reports becomes a grant:
| Reported path | scoped and sealed grant |
Composed-root bind on sealed |
|---|---|---|
| A directory on the import path | read and list, never execute | optional, when outside /usr, /bin, /sbin and /lib* |
| A zip archive on the import path | read, never execute | none |
A library an extension module directly inside one of those directories needs, such as libsqlite3.so.0 behind _sqlite3 |
read, never execute | its directory, on the same terms |
A reported path is dropped when it is relative, does not exist, is /, is the interpreter's own
directory or a directory above it, or lies inside one of those two capsule-writable trees.
Extension modules are scanned one directory deep. A third-party package that keeps its extension
modules in its own subdirectory, and needs a system library no derived directory holds, still
needs that library named in capabilities.shell.interpreter_runtime.
mur run --explain-scope lists the derived paths under derived interpreter runtime, and the
session_start event records them under
effective_grants.derived_interpreter_runtime:
derived interpreter runtime:
- /usr/bin/python3.12: /usr/lib/python3.12 (list_dir)
- /usr/bin/python3.12: /usr/lib/python3.12/lib-dynload (list_dir)
- /usr/bin/python3.12: /usr/lib/x86_64-linux-gnu/libsqlite3.so.0.8.6
On macOS nothing is derived and nothing is run.
Kept host facts
What mur works out for each capabilities.shell.allow binary is kept in one entry per binary
under ~/.murmur/host_facts/: whether the binary is a
recognised interpreter, the paths it reported, the files in each reported directory, and the
dependencies of every library its extension modules need. Beside each fact the entry records the
identity of the file or directory it was read from: device, inode, size, owner, mode, modification
time and change time. A staging that finds every recorded identity unchanged takes the entry, and
runs no interpreter, lists no reported directory and reads no extension module or library;
binaries are read only as far as their ELF headers. Otherwise it works the binary's facts out again,
runs the interpreter, and rewrites the entry.
mur install in a project writes the entries the project's first launch would
write, so that launch reports those binaries kept. An interpreter that does not answer gets no
entry, and the first launch works it out. mur install --no-precompile writes none.
session_start.host_facts in the session trace
says, for each binary, whether the launch kept its facts (kept) or worked them out (derived),
and why:
reason |
The launch worked the facts out because |
|---|---|
absent |
No entry exists for the binary |
changed |
The binary, its directory, the directory above it, a reported path, a file in a reported directory or a library the entry records has changed, or the launch reached a library the entry does not record. changed_path names the first such path |
unreadable |
The entry is not an owner-only regular file you own, does not parse, or was written by another mur version or with another interpreter table |
not-kept |
~/.murmur or ~/.murmur/host_facts/ lies inside a tree the capsule's commands can write, or the directory is a symlink, is not yours, or was wider than 0700. A wide directory is narrowed and emptied |
A file edited in place beside the interpreter, or in the directory above it, changes neither the
binary nor either directory, and goes unseen. Delete ~/.murmur/host_facts/ after such an edit;
mur writes it again.
Which reported paths a launch grants is decided again on every launch: paths inside the two capsule-writable trees are dropped and skipped in the library walk, so a launch with a kept entry grants exactly what working the facts out from scratch would grant.
The trust store for commands that reach the network
A command that opens a TLS connection reads the host's CA certificates to verify the server. When
mur stages a session on Linux, it grants the host's TLS trust store to the capsule's commands
when both of these hold:
capabilities.shell.allowis non-empty;capabilities.network.allowis non-empty.
A capsule missing either gets no trust-store grant. No manifest key turns this on or names a path.
mur checks this fixed list of locations:
| Path | Kind | Where it is the trust store |
|---|---|---|
/etc/ssl/certs/ca-certificates.crt |
file | Debian, Ubuntu, Arch, Gentoo |
/etc/pki/tls/certs/ca-bundle.crt |
file | Fedora, RHEL |
/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem |
file | RHEL 7+, Fedora, CentOS |
/etc/ssl/ca-bundle.pem |
file | openSUSE |
/etc/pki/tls/cacert.pem |
file | OpenELEC |
/etc/ssl/cert.pem |
file | Alpine |
/usr/lib/ssl/cert.pem |
file | Debian's OpenSSL default, a symlink |
/etc/ssl/certs |
directory | Debian, Ubuntu, Alpine, Arch |
/etc/pki/tls/certs |
directory | Fedora, RHEL |
/etc/pki/ca-trust/extracted/pem |
directory | RHEL 7+, Fedora |
/usr/lib/ssl/certs |
directory | Debian's OpenSSL default, a symlink |
/var/lib/ca-certificates/pem |
directory | openSUSE |
What the host has decides what is granted:
- Only locations that exist on the host, as the kind the list names, are granted. A missing or dangling location is skipped.
- Each is granted at its real path, with symlinks resolved.
murreads the entries directly inside each granted directory. A symlink there that resolves to a file outside every granted directory grants that file's directory too. On Debian and Ubuntu this is how/etc/ssl/certs/*.pemreach/usr/share/ca-certificates/mozilla.- That linked directory is not granted when it is
/, a directory containing a granted path, or inside a capsule-writable tree. A link such as/etc/ssl/certs/x -> ../openssl.cnftherefore grants nothing, and/etc/ssl,/etc/ssl/privateand/etc/ssl/openssl.cnfstay unreadable.
A file inside a granted directory is not listed again. Nothing inside the workdir root or
~/.murmur/records/<workdir root>/ is granted.
| Right | Granted |
|---|---|
ReadFile |
yes |
ReadDir |
on directories |
Execute |
no |
| every write right | no |
The grant applies on Full and Sealed. On Sealed it adds no composed-root bind: the
sealed /etc grant already carries /etc/ssl, /etc/pki and
/etc/ca-certificates, and a trust store the composed root does not carry, such as openSUSE's
/var/lib/ca-certificates, stays unreachable there. Native tools
inherit the grant with the rest of the session's.
SSL_CERT_FILE and SSL_CERT_DIR are not read: they are mur's own environment, which the
capsule's commands do not receive. A trust store outside the list needs a
capabilities.shell.interpreter_runtime directory.
mur run --explain-scope lists the granted paths under trust store, and the session_start
event records them under
effective_grants.trust_store:
trust store:
- /etc/ssl/certs (list_dir)
- /usr/share/ca-certificates/mozilla (list_dir)
A capsule that does not qualify prints trust store: <none>.
On macOS nothing is derived: commands' reads are not path-scoped there.
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 |
The restriction follows from the tier enforcement_tier reports: absent on
mountns+pivot_root+landlock+seccomp, enforced on landlock+seccomp, advisory below. A
composed root is built only for a session that asked for one — through
capabilities.containment: sealed or mur run --containment sealed — so on
a sealed-capable host a capsule that declares nothing reads achieved: scoped,
mechanism: landlock+seccomp and restriction: enforced, and its probe gets Permission denied
rather than No such file or directory. Read filesystem_boundary.restriction rather than
achieved_containment when workdir_exec is declared: it caps the class at advisory while
Landlock still enforces.
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 sets to the session's home folder, home/ in its records directory, before every
subprocess spawns, so the shell looked inside that folder 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 into its own home folder 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. Build it with mur and run it from the repository root:
cargo build --release -p murmur-cli -p escape-conformance
./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.
Each run stages its cases under a work root: the directory --work-root names, or a new
escape-conformance-work-<stamp> directory next to the record. A work root carries a
.escape-conformance-work-root marker file, and a marked work root is emptied before a run reuses
it, so every case is graded only on files its own run wrote. A non-empty directory without the
marker is refused with exit 1; clear it with the rm -rf command the refusal prints, or pass an
empty directory.
The run needs:
- a built
murbinary; python3;- a delegated cgroup v2 subtree — the harness wraps each capsule in
systemd-run --user --scope --property=Delegate=yesby default to get one.
It needs no registry access, model or API key: the harness carries its own process driver,
escape-conformance-driver, and installs it into each case's project with mur install.
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, a prerequisite is missing, or no probe could run | none |
3 |
a boundary case failed — a containment escape | written |
4 |
boundary clean, a resource ceiling did not hold — denial of service | written |
When no probe can run, the refusal names the check the preflight case failed — stage, launch,
harness, interpreter exec or probe file — and what to check first for it.
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 session's native subprocesses share one network namespace, separate from the host's and every other session's, 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, for shell commands and native tools, on both kernel tiers. Landlock cannot substitute for this: an inherited fd was opened before the ruleset existed. | Whenever the pre-exec window of the subprocess spawn 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 work folder under an allowlisted basename does not execute, because the work folder'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. Also the rest of the workdir's right set: files move and hard-link between its folders as real renames, nothing moves or links across its boundary, truncate(2) outside it is refused, uv venv and uv pip install succeed in it, and a ruleset built at ABI v1 fails those moves with EXDEV. |
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. |