Skip to content

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:

  • lo is its only interface, and the egress proxy is its only way off the host.
  • A server one shell call starts on 127.0.0.1 answers the session's later calls, whether it was backgrounded with & or demoted after lifecycle.shell_grace_secs.
  • Nothing outside the session reaches it: not the host, not a second mur run of 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:

  1. The tool's entrypoint, tools/<name>/<name> in the session's runtime directory.
  2. 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.
  3. 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:

  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. 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 declaring sealed runs 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>/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 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.

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:

  1. The folder is bound over /tmp.
  2. Every path the command needs that lies under the host's /tmp is bound back at its own path, and nothing else of the host's /tmp is 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 HOME under /tmp keeps 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 ESTALE rather 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.com does not make api.example.com:22 reachable. 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 ENETUNREACH whatever DNS said. The resolver answers AAAA with no records only when the name has no IPv6 address upstream; a dual-stack name's real AAAA records 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 — 443 under an https:// allowlist entry, for example — fails with EADDRINUSE. 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 SIGKILL still 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 a SIGKILL sent to every process of the user), and on macOS, which has no cgroups. Find those with pgrep and 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-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.


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:

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:

  1. Only locations that exist on the host, as the kind the list names, are granted. A missing or dangling location is skipped.
  2. Each is granted at its real path, with symlinks resolved.
  3. mur reads 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/*.pem reach /usr/share/ca-certificates/mozilla.
  4. 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.cnf therefore grants nothing, and /etc/ssl, /etc/ssl/private and /etc/ssl/openssl.cnf stay 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 mur binary;
  • python3;
  • a delegated cgroup v2 subtree — the harness wraps each capsule in systemd-run --user --scope --property=Delegate=yes by 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.