AGENTS.md@packages/sandbox · diff
git:20261004.ff9587b to git:20261005.0c64e87
14 added, 0 removed. Audit A to A.
# sandbox (Go)
## Purpose
`loom-exec`, Loom's kernel-enforcement helper: the Go binary the broker
spawns to run untrusted commands inside a real jail. It speaks the frozen
effect-plane framing protocol on stdio, builds the jail from a strict
`SandboxPolicyV1` decode, and reports honestly what the running kernel
actually enforced. WP-H's Linux phase 1 and macOS phase 2 are implemented;
the Windows phase 3 sandbox remains unbuilt and the binary refuses to serve
there rather than run with nothing enforcing the policy. This is the tree's
only Go module.
## Key Types
- `cmd/loom-exec` — the binary's roles, selected by the first
argument: no argument is **server mode** (read the base policy from fd 3,
then frames on stdio); `--exec` is **stage 2** (read the policy from
fd 3, apply the platform's in-process restrictions and rlimits, report on
fd 4, execve the target); `--self-test` runs the regression probes against
the live kernel. `--probe-socket`, `--probe-setsid`, and `--probe-fork`
are the internal network, session-escape, and process-limit witnesses.
`internal/selftest` also
carries `loom_hostile.erl`, the hostile-satellite tabletop's adversary,
which the self-test compiles with `erlc` and loads into a jailed node.
Server mode also takes `--allow-unenforced` (the explicit opt-out of the
unsupported-platform refusal) and `--cgroup-base DIR` (the delegated
cgroup v2 base, which `LOOM_CGROUP_BASE` also supplies); an unknown
server flag is a usage error, never a shrug, because a misspelled
`--cgroup-base` would silently drop the ceilings it was meant to grant.
- `internal/jail.RunGitIdentityPublication` implements the private
`--publish-git-identity POLICY_BASE64 WORKSPACE ENTRIES_JSON` mode from
[protocol 043](../../protocol-change/043-git-identity-publication.md).
It accepts only identity pairs and publishes the fixed tool-home file from
an original host-write grant, using no-follow directory walks and atomic
replacement. It creates no mounts or parent directories, so absent SQLite
side-file masks remain absent on the host. The existing policy decoder and
mount precedence enforce protected and read-only paths; only Linux and
macOS provide the descriptor implementation.
- `internal/policy.Policy` — `SandboxPolicyV1` decoded strictly and
totally, at wire version 2.
- `internal/policy.{Mount, MountAccess}` — the explicit-bind vocabulary of
`protocol-change/004`: a host path, `ro` or `rw`, and a `Required` bool
that decides whether an absent source refuses the execution or is
skipped. `required` is a bool here because the wire has bools; the
two-variant type on the Gleam side is a house rule about record fields
there, not a property of the format.
- `internal/framing` — the helper side of the wire: `u32_be length ++
msgpack(map)`, keys `v`, `id`, `kind`, `body`.
- `internal/server` — the frame loop: frames in on stdin, frames out on
stdout, one execution at a time.
- `internal/jail.{Request, Features, Report, StreamLimiter}` — one
execution's description, what the kernel actually offers, the
enforcement summary stage 2 sends back on fd 4, and the per-stream
output cap.
- `internal/jail.{SystemRoots, DarwinSystemRoots, PlanIsMinimal,
BaseViewName}` — the base view. The two lists are the read-only system
directories a jail is given before the policy says anything, each entry
commented with why it is there; `/home`, `/root`, `/Users` and
`/var/tmp` are deliberately absent, and the user's home directory
reaches a jail only through the workspace, the roots and the mounts.
`PlanIsMinimal` answers whether the policy left that base in place: a
`readable_roots` entry of `/` binds the host back over it and
reproduces the pre-020 view, which is what a harness that has not
dropped that entry still sends, and `BaseViewName` renders the answer
for the `base=` field of both platforms' audits.
- `internal/jail.{MountPlan, MountOp, MountClass}` — the mount precedence
model. `MountPlan` turns a policy into the ordered operations
`BwrapArgs` renders; `MountClass` says whether an op widens the jail's
view or subtracts from it, and settles which of two ops naming the same
path takes it. Pure, and the thing to read before changing any mount
behaviour. `PathKind` and `MaskSource` are its protected-path half,
`ClassMountReadOnly`/`ClassMountReadWrite` are the explicit mounts, and
`ClassRootTmpfs` is the empty tmpfs at `/` the minimal base view
starts from. Before planning a whole-host bind from a readable root,
writable root or scratch, `systemRootsFor` resolves the automatic system
directories through host symlinks. Otherwise `/bin -> usr/bin` is already
present when bubblewrap tries to mount onto `/bin`, and startup fails.
The resolved targets remain read-only, including under a writable `/`;
minimal-root plans retain the legacy destinations. `Start` audits and
renders the same prepared plan. `MountPlan` and `BwrapArgs` take the helper's own binary
path as their last argument: stage 2 is loom-exec re-executed inside
the jail, so a base view that does not carry it is a jail that cannot
start, and where the helper was installed is the helper's knowledge
rather than the sender's. That grant is emitted only when nothing else
covers the helper (`helperCovered`): the bind is a read-only mountpoint
over the file, so a helper inside the workspace could not be rebuilt or
replaced from inside the jail, which is what a daemon running from its
own checkout does every time it runs `go build -o bin/loom-exec`. A
helper under a `protected` entry is the case no grant can fix, and
`HelperUnderProtected` makes `Start` refuse it by name rather than
letting bwrap fail with an anonymous `execvp`. `BwrapArgs` also ends
every argv with `--remount-ro /`, after the whole plan and outside it:
bwrap's new root is a fresh tmpfs it never remounts, so without this a
region no grant covers is a writable, size-unbounded directory inside
the jail. It stays outside the plan because mountpoints have to be
creatable during setup and because `effective` and
`UnmountableProtected` read the plan as the setup-time state. The plan
keeps a `ClassRootTmpfs` entry at `/` carrying no argv, and that entry
is what the Linux audit reads to decide whether `base=` is `minimal` or
`host-view`.
- `internal/jail.MissingRequiredMounts` — the platform-independent refusal
for a `required` mount whose source is not on this host. Unlike
`MissingMountSources`, which diagnoses a bwrap argv that would otherwise
fail anonymously, this one is a property of the policy and holds on
Darwin too, where there is no bind mount at all.
- `internal/jail.PlatformSupport` (`Platform`, `PlatformFor`, `Refusal`) —
not a probe of the kernel but a fact about the *build*: whether Loom has
a jail for the OS it was compiled for. `PlatformFor` is pure and takes
the OS as an argument, which is the only way the macOS and Windows
answers can be tested at all.
- `internal/cgroup.{Detect, DetectBase, BaseEnvVar}` — the memory and pid
ceilings. `DetectBase` takes the base as an argument: a delegated,
process-empty cgroup v2 directory from the operator, or "" to fall back
to the helper's own cgroup (which works only in the true root cgroup).
It asks the kernel by `statfs(2)` whether the base is on a cgroup v2
filesystem, verifies the controllers are delegated and the base is
empty, checks that a process could actually be migrated into a child,
and reports a reason naming the delegation when it cannot. It does not
write: distributing `memory` and `pids` to the base's children is
`Setup`'s job, because detection answers a question and must not
reconfigure the operator's tree to do so.
`Sweep(base)` removes what killed helpers left behind (#702): a SIGKILLed
helper never runs `Cleanup`, so each kill leaves an empty
`exec-<id>-<pid>` directory, about 120 KB of kernel memory. `loom-exec`
calls it once in server mode, after detection accepted a base and before it
serves (`sweepStaleCgroups`; best effort, silent when nothing was removed).
A directory goes only if its name is exactly that shape, the pid in it (the
execution's first process, **not** the helper's) no longer exists, and
`cgroup.events` is not populated; the removal is `Cleanup`'s rmdir
depth-first, never a recursive delete. The pid test is what protects a live
helper: a cgroup `Setup` has just made is empty until `Enter`, so
`populated 0` alone cannot tell it from a leftover, but the execution's
process is already forked then and answers `kill(pid, 0)`. A reused pid reads
as alive and costs one directory until a later sweep. Tested against a fake
base on any host (`TestSweep*`), and against a real delegated cgroup where
`LOOM_CGROUP_BASE` provides one (`TestSweepRemovesARealCgroup`, skipped
elsewhere).
- `internal/jail.{CgroupSkip, CgroupSkipPrefix, CgroupCeilings}` — the
enforcement entry emitted when a policy asked for a ceiling and no
cgroup held it. `CgroupCeilings` names *which* of `memory.max` and
`pids.max` were asked for, so the skip cannot report a ceiling the
policy never wanted.
- `internal/jail.{MountReport, AuditMounts, MountSkipPrefix}` — the mount
layer's contribution to the enforcement report. `AuditMounts` replays a
`MountPlan` and counts the policy's own paths whose *effective* view is
the one asked for; see "The mount layer says what it achieved" below.
- `internal/jail.UnmountableProtected` — the pre-dispatch half of #60: it
replays a `MountPlan` the same way `AuditMounts` does and names every
`PathMissing` protected path whose parent the plan leaves read-only, so
`run.go` can refuse before bwrap ever runs instead of a bare `code=1`.
- `internal/jail.MissingMountSources` — the same treatment for #63: it
stats every source the plan binds read-write (a `writable_roots` entry,
a host-path `scratch`) and names the ones that are not on this host,
with the list each came from. `readable_roots` is absent by design; it
binds with `--ro-bind-try` and tolerates absence.
- `internal/jail.{SeatbeltPlan, SeatbeltPlanFor}`: the Darwin backend's
deny-default profile, path definitions, audit digest, and enforcement
tags. Model-influenced paths travel through `sandbox-exec -D`, never as
interpolated SBPL. Broad grants precede the protected logical/resolved
path denies, and the denies are the final rules in the profile. Two
roots the policy never names are granted on every plan: the user's
darwin temp and cache directories (`confstr(3)`, in
`seatbelt_userdirs_darwin.go`, via `/usr/bin/getconf` rather than cgo so
`CGO_ENABLED=0` still builds), because Apple's `git`/`make`/`clang`
shims write `xcrun`'s cache and clang's module cache there whatever
`TMPDIR` says — and the private per-execution scratch is made under
`/private/tmp`, not the helper's `TMPDIR`, precisely so that grant does
not make one execution's scratch writable by another.
`LOOM_SCRATCH_DIR` exposes that path when the effective environment
allowlist grants the name. The helper replaces any caller value; Linux
uses `/tmp` only when bubblewrap mounted it, and a configured host scratch
uses its policy path. Degraded Linux with no tmpfs omits the variable.
Explicit `TMPDIR` is preserved, so `${LOOM_SCRATCH_DIR:-$TMPDIR}` selects
scratch while retaining the session fallback. Private scratch lasts for
the execution or background job; configured host scratch is not removed.
- `internal/jail.{Stage2Skip, Stage2SkipPrefix, BwrapUnwitnessedSkip}` —
the entries for a stage 2 that never reported on fd 4, and for the
bwrap layer that consequently has no witness.
- `internal/jail.{ProcEntry, TermTargets}` — who the TERM rung is
addressed to, selected by descent from the supervisor rather than by
process group. `ProcEntry` is `{Pid, Ppid}`: the parent link is the
one relation a payload cannot rearrange. `payloadRoots` and
`termTargetsAreTrusted` beside it are the completeness check on that
selection: a scan that lost the payload's row is not allowed to pass
as a complete one.
- `internal/jail.processTracker`: Darwin's lifecycle backstop. It records
descendants it observes with their process birth time and rechecks that
identity immediately before signaling. This narrows PID reuse but cannot
make the check and `kill(2)` atomic. It complements rather than replaces
process-group signals for children that call `setsid(2)`. Each 20 ms sample
reads a fresh kernel process table. A parent-head map and one-based sibling
indices traverse that snapshot without copying process records or allocating
a child slice per host process. The ledger lock also owns reusable index and
frontier buffers; every capture clears the heads and overwrites active links
before traversal. Capacity follows the largest snapshot rather than being
allocated every tick. Reverse indexing preserves snapshot sibling order;
remembered identities remain in custody after reparenting. Tests pin fresh
edges after shrinking/reordered snapshots, allocation-free stable traversal,
concurrent captures and birth-checked delivery to a real sleeping process.
macOS has no PID
namespace, subreaper, or stable process handle, so a rapid daemonizing
double-fork can be reparented between samples. Every Darwin execution reports
`skip:darwin-process-lifecycle`; Seatbelt confinement remains inherited, but
`FullEnforcement` does not pretend sampled cleanup is a kernel guarantee.
- `internal/llock`, `internal/seccompf`, `internal/cgroup` — the three
in-process restriction layers. `seccompf` and `jail`'s `no_new_privs`
are split by build tag (`*_linux.go` / `*_other.go`) so the module
compiles and vets for `GOOS=darwin`; Darwin uses Seatbelt for filesystem
and network confinement rather than either Linux-only layer.
`internal/jail/stage2.go`'s `landlockView` is the one function that
turns a decoded policy into `llock.PolicyView`, and is where the
layering contract below is actually decided rather than just stated.
- `internal/selftest` — the probes and the ENFORCED/SKIPPED report.
## Relationships
- **Depends on**: the Go standard library plus the msgpack and syscall
support its own `go.mod` pins. It depends on no Loom package — the
protocol is the entire coupling.
- **Depended on by**: `broker` at runtime (`broker/exec` spawns and
supervises it), and `conformance`'s jailed e2e through the broker.
- **Counterpart**: `packages/broker` (`broker/framing`, `broker/policy`)
speaks the other end. Both are pinned against the golden frames under
`protocol/msgpack-fixtures/`.
## Traffic
- **Wire (stdio)** — the same frozen kinds the broker sends and receives:
`hello`, `exec_start`, `exec_stdin`, `exec_out`, `exec_exit`,
`cap_call`, `cap_result`, `cancel`, `shutdown`, `heartbeat`, `error`.
- **fd 3** — the base `SandboxPolicyV1`, required at spawn in both server
mode and stage 2. The broker delivers it as a mode-0600 file opened by a
shell wrapper, because Erlang ports cannot map arbitrary descriptors.
- **fd 4** — stage 2's enforcement `Report` back to the supervising
helper, sent just before execve: `Applied` entries are terse layer tags
(`landlock:abi=5`, `seccomp-net`, `seatbelt-net`, `rlimit-cpu`), `Skipped`
entries carry reasons. It is also the helper's **witness that the outer
platform jail reached stage 2**: bwrap/mount and Seatbelt profile claims
are published only when this report arrives, and its absence is a `skip:`,
never silence.
- **Environment** — `LOOM_CGROUP_BASE` names the delegated cgroup v2 base
when the operator supplies one that way; `--cgroup-base` overrides it.
Erlang ports cannot set a child's environment, so the broker uses the
argument (`broker/exec.SpawnConfig.helper_args`) and a systemd unit uses
the variable.
- **Process signals** — cancel is SIGTERM to the *payload and its
descendants*, SIGKILL to the pgroup after `KillGrace` (2 s), matching
the broker's own patience window exactly. The two rungs address
different sets on purpose; see "The TERM rung is addressed to the
payload" below and `jail/cancel.go`.
- **Commits / registers / actors**: none. The helper is stateless between
executions and persists nothing.
## Invariants
- **Policy decoding is strict, total, and fail-closed.** An unknown
version, a missing field, an unknown field, or a wrong type is an error —
never a guess, never a partial value, never a panic. The jail is only as
good as the reading of the bytes that describe it.
- **Malformed frames close the channel; unknown kinds do not.** A
malformed frame is an error result per §3.3 invariant 6; a well-formed
frame with an unrecognized kind gets an in-band `error` reply and the
channel survives (forward compatibility).
- **bwrap owns all namespace and mount work.** The Go runtime is
multithreaded from the first instruction, and unshare/fork namespace
assembly in a multithreaded process is the runc `nsexec.c` tar pit. The
helper only composes an argv — pure data, golden-tested — then stacks
in-process restrictions on itself before exec. If bwrap is missing we run
degraded and say so; we never build namespaces ourselves.
- **Restrict-then-exec is sound because the restrictions persist.**
Landlock domains and seccomp filters both survive execve and stack (a
child can only tighten), and seccomp installs with
`SECCOMP_FILTER_FLAG_TSYNC` so it binds every thread of the Go runtime,
not just the calling one. Installation requires `no_new_privs`, which
also stops the filter being used to confuse a setuid binary.
- **Network-off is enforced at socket creation.** seccomp cannot
dereference the sockaddr passed to `connect(2)`, but it can read the
integer domain argument of `socket(2)`/`socketpair(2)`. A process that
can never obtain an AF_INET/AF_INET6/AF_PACKET socket has nothing to
connect, bind, or sendto with — and the helper constructs the child's fd
table and environment, so no network fd can be smuggled in. AF_UNIX stays
usable and is confined by the filesystem layers; bwrap's network unshare
is the independent second layer.
- **Landlock has no deny rules, and the layering contract is: strictly
weaker than the mounts, never independent of them.** Landlock's grants
union — RWDirs, RWFiles, and RODirs only ever add access, and nothing
in the API can subtract from a region another rule already opened — so
a reader must not assume Landlock is a second, independent enforcement
point that would still hold if the mount plan were wrong. It is not: a
protected path *inside* a writable root cannot be carved out at the
Landlock layer, masking it is bwrap's job alone (tmpfs or ro-bind
shadowing), and without bwrap the carve-out is unenforceable —
reported in the enforcement summary, never hidden. The one place
Landlock *is* load-bearing on its own is degraded mode (no bwrap at
all), where it is briefly promoted to the only filesystem confinement
there is, and even then it can only restrict what the mount layer
would also have restricted, never narrow what a mount would have left
open. Concretely: `internal/llock.Rules` grants the same three policy
regions bwrap's own grant phase would bind — the readable roots, the
writable roots, and (for a host-path scratch only) the scratch — plus
the unconditional root-read every jail needs to exec anything. Its one
non-policy exception is an exact-file RWFiles grant for `/dev/null`, so
programs retain a null sink without widening the containing `/dev`
directory or granting device ioctls. It never sees `protected` at all;
that list is bwrap's alone.
`internal/jail/stage2.go`'s `landlockView` is where the policy becomes
that grant set, and a tmpfs scratch is granted there only when bwrap
actually mounted one (issue #59). Stage 2 cannot see the mount
namespace it was placed in, so `Start` states the fact: it passes
`jail.ScratchMountedFlag` on the stage-2 command line in the bwrap
branch and only for `scratch: tmpfs`, and `landlockView` grants
`ScratchMount` only when it arrives. Both halves matter. Without the
grant a Landlock host has a scratch directory the payload cannot write
to, measured as `touch /tmp/probe` failing in a jail reporting
`landlock:abi=1`; with an unconditional grant, degraded execution would
silently substitute the account's real `/tmp`. `scratch: "/"` is refused even earlier,
in `broker/policy.gleam`'s `validate` (`ScratchIsRoot`): a host-path
scratch of the literal root would otherwise reach `internal/llock` as
`RWDirs("/")`, and because grants union there is no way for any later
rule — including every protected-path mask, which Landlock cannot
express in the first place — to narrow it back down. Refusing it at
the policy boundary, before the policy ever reaches this package,
keeps `internal/jail/mounts.go`'s own rule intact: the mount layer
binds exactly what the policy says and narrows nothing on its own
initiative (4b4983d).
- **Linux uses cgroups, not rlimits, for memory and pids.** `RLIMIT_AS` is
per-process and trivially escaped by forking; `RLIMIT_NPROC` is per-user
(useless when the jail shares a uid, ignored for root). The construction
half is pure — policy in, `{path, contents}` pairs out — so the exact
writes are unit-tested even where no v2 delegation exists.
- **Darwin reports the weaker rlimit truth.** The platform exposes no
per-execution cgroup equivalent. A requested finite `RLIMIT_AS` is attempted
and currently returns `EINVAL`, so `mem_bytes` produces an explicit
`skip:rlimit-address-space` rather than aborting the otherwise confined run
or claiming a limit. `RLIMIT_NPROC` is per-user: it is installed only when
the account's current process count leaves a 16-process reserve below the
requested ceiling. When it does not, the run reports
`skip:rlimit-processes`; this avoids turning ordinary child spawns into
immediate `EAGAIN`. The sample and `setrlimit` cannot be atomic with
unrelated same-user forks, so the reserve narrows rather than eliminates
that race. Either skip fails `FullEnforcement`, while `BestEffort` retains
filesystem and network confinement and exposes the missing ceiling to its
caller.
- **The cgroup base is configuration, and its absence is reported, not
swallowed.** cgroup v2 forbids a cgroup from having both member
processes and controllers enabled for its children, so the helper's own
cgroup — which always contains the helper — can only serve as a base in
the true root cgroup. That is a fact about that choice of base, not
about cgroup v2: any delegated, process-empty cgroup distributes
controllers to its children, which is what `Delegate=yes` (and
`DelegateSubgroup=` on systemd v254+) produces. The operator names one
in `LOOM_CGROUP_BASE` or `--cgroup-base`. When there
is no usable base **and the policy asked for `mem_bytes` or `pids`**,
the per-exec `enforcement` list carries `skip:cgroup-v2: …`, naming
which of `memory.max` and `pids.max` was actually dropped, which fails
a `FullEnforcement` demand. Emitting nothing — which is what the helper
used to do — let a policy that demanded both ceilings pass strict
enforcement with neither in place, on every host that could not
delegate. A policy that asked for no such ceiling gets no such skip.
- **Only `statfs(2)` can tell a directory from a cgroup, so it is asked
first.** Reading `cgroup.controllers`, reading `cgroup.procs`, writing
`cgroup.subtree_control` and creating a child directory are all things
an ordinary directory does perfectly well. With nothing asking the
kernel, three text files under a typo'd `--cgroup-base` became a
"cgroup v2 base": `memory.max` and `pids.max` were written as ordinary
files, `Enter` wrote a pid into one and returned nil, a 32-way fork
burst under `pids: 8` ran to completion, and the frame reported
`cgroup-v2` **applied** (#52). `DetectBase` now checks
`CGROUP2_SUPER_MAGIC` before anything else, in a `_linux.go`/`_other.go`
pair so the module still vets for darwin. The interface-file reasoning
stays unit-testable against a directory shaped like a base, because
`usable` is reachable from a test while `DetectBase` is what production
calls.
- **Detection is a question; it does not reconfigure the operator's
tree.** Enabling `+memory +pids` in the base's `cgroup.subtree_control`
is a write into the operator's cgroups that a probe reading as
read-only has no business making, and it was never reverted. It moved
to `Setup`, which runs after the base is validated and an execution
actually needs a child; a failure there is an ordinary
`skip:cgroup-v2:`. `DetectBase` also refuses a base the helper lives
*outside* of, since cgroup v2's delegation containment rule needs write
access to the `cgroup.procs` of the common ancestor to migrate a
process in — advertising `cgroup-v2` in the hello frame for a base no
process could ever enter was the other half of #52. `Cleanup` rmdirs
child cgroups depth-first before the directory itself: a cgroup's
interface files are kernel-created and unremovable, so `RemoveAll` is
wrong and a bare `Remove` leaves an `exec-N-PID/` behind. It first
waits, bounded, for `cgroup.events` to report `populated 0`: under bwrap
the direct child is reaped while the process that built the namespaces
is still in the cgroup tearing them down, and an rmdir in that window
fails with EBUSY. Every exec cgroup leaked that way until the wait was
added, which on a signoff host meant 50,000 cgroups and several GB of
kernel memory. The wait is about 50 ms, so the server takes
`Exec.Settle`'s release after writing `exec_exit` rather than inside
`Wait`; each execution's `waitDone` also waits for the previous one's,
so `reapRunning` still joins every removal in flight.
- **The environment is constructed, never inherited.** A name absent from
`env_allow` is dropped even when the broker sent it, so the policy alone
is enough to audit what a jail could see. Output is sorted for
determinism. The helper replaces `LOOM_SCRATCH_DIR` with the actual
execution scratch path and rebuilds `PATH` rather than forwarding it:
`jail.BuildPath` (`internal/jail/env.go`) folds the broker's
requested `PATH` and the helper process's own inherited `PATH` — the
daemon's, which is where the operator's toolchain actually lives
(Homebrew's `/opt/homebrew/bin` on macOS, say) — with a fixed floor
(`/usr/local/bin`, `/usr/bin`, `/bin`), keeping only entries that are
absolute, name a directory that exists, and are not already present. A
directory list is not a secret, so dropping it bought nothing but an
unusable jail on the very filesystem view the policy already exposes
read-only. **The requested list leads the inherited one**, because it is
the only half that can name a bundled toolchain: an unpacked release
ships `erl` at `erts-<vsn>/bin/erl`, which the client discovers and
sends, and which is never on the daemon's PATH. That fold is safe
because the broker's environment is harness-authored, so a model still
cannot influence PATH resolution. Entries under anything the jail makes
writable are excluded from both lists: a directory the model can write
to, sitting on `PATH`, would let a jailed command shadow `rg` with a
script written a moment earlier. `pathExcludedRoots` computes that set
— the policy's writable roots, a host-backed scratch, `ScratchMount`
when the Linux scratch is a tmpfs (inside the jail `/tmp` is the
model's own tmpfs), and `DarwinUserDirectories()` on macOS (the
Seatbelt profile grants `file-write*` there) — and entries are
`filepath.Clean`ed before the containment test.
`run.go` builds this before calling `FilterEnv`, so a policy whose
`env_allow` never names `PATH` still sees none of it.
- **`output_bytes` is per stream, and truncation does not stop reading.**
After the cap the helper keeps draining and discards; stopping would
wedge the child on a full pipe, turning an output limit into an
accidental deadlock. The cap is crossed exactly once per stream.
- **One execution at a time per helper**; a second `exec_start` gets a
`busy` error. Concurrency lives in the broker's pool, which keeps "the
pgroup" in the cancel contract unambiguous.
- **Stdin never blocks the frame loop, and Cancel never waits on stdin.**
`exec_stdin` is handled on the helper's single frame-reading goroutine,
and a pipe write blocks once the kernel buffer (64 KiB) is full, which a
payload that never reads stdin guarantees. The write used to run inline
under `Exec.mu`, the same lock `Cancel` and the wall-clock deadline take,
so one such payload sent more than a pipe of stdin stopped the helper
reading `cancel`, `heartbeat` and `shutdown` and stopped its own deadline
from firing; the broker's only recourse was killing the helper after the
3 s grace, losing the native-exit witness and stranding a pool slot.
`Exec.WriteStdin` now only appends to `jail.stdinQueue`
(`internal/jail/stdin.go`): a FIFO with its own mutex, held for
bookkeeping and never across a write, drained by one writer goroutine per
execution, started by the first chunk. `Exec.mu` is not involved in
stdin at all. Pending bytes are bounded by `jail.StdinPendingMax`, 16 MiB,
equal to `framing.MaxFrameLen` (pinned by a test in `internal/server`,
since `jail` does not import `framing`); only a chunk that would exceed
it makes the frame loop wait, and that wait ends when the payload reads or
when the execution does, with `Cancel` and the deadline still able to
terminate the payload meanwhile. `exec_stdin` with `eof` closes the pipe
after every queued chunk has been written, so byte order is unchanged.
`Settle` calls `stdinQueue.abandon` after the child is reaped: it drops
the queue, closes the pipe (which interrupts a blocked write) and joins
the writer, before `execFreed` or `waitDone` can close, so "freed" and
"joined" below still hold with no writer goroutine outliving its
execution. Consequences visible to a caller: `WriteStdin` returns when the
chunk is accepted, not when the child read it, and a failed pipe write
(EPIPE from a child that closed stdin) is reported by the *next*
`exec_stdin` as the same `no_exec` error frame, not by the failing one.
No wire change.
- **Busy ends when the child is reaped, not when the exit frame has been
written.** `internal/server` keeps two signals for the running
execution and closes them either side of the terminal write:
`execFreed` closes as soon as `jail.Exec.Wait` returns and is what
`handleExecStart` consults, and `waitDone` closes after the `exec_exit`
frame is on the channel and is what `reapRunning` waits on before
native exit. One signal cannot serve both. The broker moves its own
state machine to `Idle` on reading `exec_exit` and may dispatch the
next `exec_start` at once, so a helper freeing itself only after the
write refused a strictly sequential caller whenever the two orders
raced — seen once on CI as `RefusedByHelper("busy", …)` on a `git
commit` that had already settled, and green on rerun. Freeing early
costs nothing: `Wait` joins the output pumps, so no further `exec_out`
can be emitted for the finished id, and the one frame still owed
carries that id rather than the next execution's. `framing.Conn`
serializes writes and emits a frame in a single `Write`, so it cannot
interleave with the next execution's bytes either. `reapRunning`
joining only the current execution stays sufficient for the same
reason: a later execution's exit frame cannot have been written until
the earlier one released the connection mutex.
- **Shutdown preserves the native exit witness.** After the hello exchange,
`shutdown` with an empty map body stops command dispatch, cancels the active
jail, and joins its existing `Wait` path before the helper exits with status
zero. The broker keeps stdin open and drains stdout until the port reports
native exit; there is no shutdown acknowledgement. A deadline, port closure,
or nonzero exit does not prove an orderly join. The join retains the existing
platform limits, including Darwin's sampled descendant cleanup, and does not
certify successful scratch-directory or cgroup removal.
- **The TERM rung is addressed to the payload; only the KILL rung takes
the pgroup.** Under bwrap the helper's direct child is a *supervisor*
which is also the group leader, with a second bwrap as the PID
namespace's init below it and the payload below that. The supervisor is
spawned `--die-with-parent`, so TERMing the group kills the supervisor,
whose death SIGKILLs the namespace init and every process in the
namespace with it. Measured: a payload with `trap "" TERM` and a
30-second loop died in 813 µs, by SIGKILL, having never been asked to
stop — `TERM → grace → KILL` was in practice just `KILL`. So the TERM
rung skips the scaffolding, KILL still goes to the group, and cleanup
keeps its unconditional group-wide sweep, so "no orphaned jails" is
unchanged.
- **The payload is found by descent, and both exclusions are
structural.** `jail.TermTargets` walks parent links from the supervisor
and takes everything at depth two or more: depth 0 is the supervisor,
depth 1 is bwrap's namespace init, and the rest is the payload and what
it spawned. It used to scan the supervisor's *process group* and spare
anything whose innermost `/proc/<pid>/status` `NSpid` was `1`. Both
were evadable with one unprivileged syscall (#53): `setsid(2)` took the
payload out of the group, the selection came back empty, and the caller
fell back to signalling the group — the collapse above, on a payload
nothing had asked to stop; and `unshare -U -p -f` made a payload look
like a namespace init, so it was skipped *by name*. Descent cannot be
left, and under `--unshare-pid` the kernel reparents orphans onto the
namespace's own init rather than host pid 1, so the walk enumerates the
whole jail. The two spared processes are now known by construction —
the pid the helper spawned, and that process's own children — so
nothing a payload does to itself puts it in the exempt set. A selection
that comes back empty still falls back to the whole group, because a
TERM silently not sent is worse than one sent too widely.
- **The selection is verified against the payload's own pid, because
non-empty is not the same as complete.** The walk is only as good as
the table it was handed, and one row lost to a transient `/proc` read
failure orphans everything below it: lose the depth-2 row and the whole
payload subtree drops out while any unrelated deeper descendant keeps
the answer non-empty. TERM then lands on the wrong subset, the ladder
waits out the full 2 s grace, and SIGKILL ends a payload whose TERM
handler was never asked to run — `Code:137 Signal:9 WallMs:2016` on a
green-looking cancel (#135). So `term` reads the supervisor's
grandchildren straight out of `/proc/<pid>/task/<pid>/children` — two
targeted reads, not a table walk — and a live root the selection does
not contain demotes it to the same whole-group fallback an empty
selection takes. The check only ever demotes on positive evidence: no
roots means "no opinion" and the selection stands, or a kernel without
`CONFIG_PROC_CHILDREN` would quietly reinstate the group-wide TERM #53
removed.
- **TERM is complete under bwrap and best-effort without it, and the
ladder's documentation says so.** The PID namespace is what makes the
descendant walk exhaustive. In degraded mode there is no namespace, the
group leader *is* the payload, and a payload that calls `setsid(2)`
leaves the group with nothing to put it back. That is one more thing a
missing bwrap costs; it is reported as degraded like the rest, and the
KILL rung's group sweep is what still bounds it.
- **A cancelled run says it was cancelled, because nothing else can.**
`Result.Cancelled` (and `exec_exit.cancelled`, protocol-change/006) is
set from the ladder's own state machine, which keeps the fact after the
process exits. The exit status cannot carry it in either direction: a
cancelled run whose payload had backgrounded its work reported `code=0
signal=0` — a clean success for a forcibly truncated execution, 3 runs
out of 3 — and `sh -c 'exit 143'` reports the TERM-killed payload's
`code=143` with no cancel involved at all. `TimedOut` separates an
explicit cancel from the wall clock, which climbs the same ladder.
- **A jailed exec reports `signal: 0` even when the payload was
signalled.** The helper waits on its direct child, which under bwrap is
the supervisor; the supervisor outlives the payload and relays a
signalled payload by *exiting* 128+signal rather than dying of it.
`code` is 143 for a TERM-killed payload jailed or unjailed; `signal` is
15 only unjailed. Callers must read `code`, never `signal`, for "how did
the payload end" — and `cancelled`, never `code`, for "was it allowed
to finish".
- **The base view is an allowlist, and a `readable_roots` entry of `/`
turns it back off.** protocol-change/020 replaced `--ro-bind / /` with
`--tmpfs /` plus a tolerant read-only bind of each entry in
`SystemRoots`, and replaced the Seatbelt profile's unconditional
`(allow file-read*)` with per-root subpath allows over
`DarwinSystemRoots` and the policy's own regions. What that buys is the
shape rather than any single path: `protected` is a denylist and covers
what the harness thought to name, while a base view that grants nothing
it was not asked for covers what nobody named — another workspace on
the account, another session's scratch, `~/.ssh`. The tie at `/` is how
an older harness keeps working: a readable root of `/` outranks the
tmpfs, rebuilds the whole-host view, and is reported as
`base=host-view` in the `mounts:` and `seatbelt-fs:` entries, where the
minimal base reports `base=minimal`. The counts alone cannot tell the
two apart, because they are identical for the same policy and the
difference between the views is everything the policy did not name.
Three consequences worth knowing before changing any of it: a mount
whose destination is under a read-only system bind needs no refusal,
because every operation binds a path onto itself and so the destination
exists whenever the source does; a missing protected path under the
root tmpfs *is* maskable, since bwrap can create a mount point in a
tmpfs, which is why `ClassRootTmpfs` counts as writable in the audit's
replay; and the execution now starts in the working directory the
request named, because an inherited one outside the view makes every
`getcwd(3)` in the payload fail.
- **Mount precedence is decided, not inherited from argv order.** bwrap
applies mount operations in argv order, so argv order *is* the
precedence between overlapping mounts. `jail.BwrapArgs` therefore does
not build that argv by concatenating the four policy path lists. It
resolves the policy into an ordered plan of `jail.MountOp`s — a region,
a `jail.MountClass` saying what the op does to it, and the argv
fragment — and orders the plan by two rules:
- **Grants first, masks last, and nothing after a mask.** A readable
root, a writable root and the scratch area *widen*; `--proc`, `--dev`
and the protected masks *subtract*. A grant after a mask undoes it
and fails **open**; a mask after a grant only narrows. So masks are
last unconditionally — including after the scratch mount, which is a
grant and used to be emitted dead last (#51).
- **Inside a phase, the most specific region wins.** Ops sort by path,
which puts a parent before every descendant of it, so the nested op
lands on top. A readable root inside a writable root comes out
read-only; a writable root under the scratch mount survives it.
- **The policy's explicit `mounts` come after even the masks.** That is
the one exception to the first rule, and `protocol-change/004` states
it as the property rather than as a detail: the cap socket has to be
visible inside the jail even where the scratch tmpfs would shadow it.
The shadow a mount wins over is the scratch tmpfs, and never a
protected mask: a mount overlapping a protected entry in either
direction is refused when the policy is decoded, by
`policy.checkMounts` here and by `broker/policy.validate` on the
composed policy, so no plan built here contains that pair.
**Nor does a mount ever win over a writable root.** A *read-only*
mount at or above a `writable_roots` entry would land on the writable
bind and leave the root read-only on Linux while Darwin's allow rules
keep it writable, and a mount of `/` would also bind the host's
`/proc`, `/dev` and `/tmp` back over the fresh ones (#37 again). Both
decoders refuse it, in the same words (`protocol-change/057`); a
read-only mount *under* a writable root and a read-write mount above
one stay valid, because both platforms honour them alike.
`required` reaches the argv as the absence of bwrap's `-try` suffix.
On Darwin the ordering is the other way round — a mount is an allow
rule over the subpath emitted *before* the trailing denies — because
Seatbelt takes the last matching rule and ADR-006 requires a protected
path to stay unreachable whatever else the profile says. The two
orderings enforce the same policy precisely because the overlap that
would tell them apart cannot reach either emitter.
- **A mount names one region, once, canonically.** The decoder refuses a
repeated mount path, a path ending in a slash, and a path with a `..`
segment. Neither side of the wire canonicalizes a mount path, so those
are three spellings of the same defect, and refusing them is why
neither emitter carries a tie-break and why the audit has no widened
mount to report.
Masks are deliberately exempt from the second rule against grants:
`protected` is the policy's only subtractive verb, so no grant at any
depth carves a hole in one. Where two entries name the *same* path the
higher `MountClass` takes it and the loser is not emitted at all —
writable beats readable, because `workspace_default` names the
workspace in both lists and means writable; the scratch tmpfs beats a
root at exactly `/tmp`, because dropping the scratch the policy asked
for is the worse of the two, and the tmpfs is the narrower.
`TestBwrapArgsNothingFollowsTheMasks` pins the first rule against the
plan rather than against a verb spelling, which matters because two
masks are spelled with bind verbs; the `TestJailed…` tests pin every
case in a real jail.
- **What that ordering cost before it was written down.** #37: a policy
naming `/` as a readable root — what a jailed build asking for the
toolchain sends — emitted a `--ro-bind / /` after `--proc`/`--dev` and
put the host's procfs and device tree back. A confinement gap (82 host
pids and the host's `/proc/1/cmdline` readable from inside a jail
reporting itself fully enforced, `/proc/self` resolving to the host
pid) and a hang at once: bwrap binds `MS_NODEV` unless asked for
`--dev-bind`, so the re-exposed `/dev` is a view in which no node can
be opened, and the BEAM `gleam build` spawns retries
`openat("/dev/null", O_WRONLY)` against EACCES forever. #51: the same
argv, at the other end — the scratch mount was emitted after the
masks, so `scratch: "/"` reproduced the whole of #37 with `--bind`
rather than `--ro-bind` (writable), and a protected directory under a
scratch path came back readable *and* writable, host file and all.
`TestFreshProcAndDevSurviveAReadableRootOfSlash` and
`TestJailedScratchOfRootDoesNotRestoreHostProcAndDev` pin the two ends
in a real jail.
- **The scratch mount and the writable roots, stated once.** A `tmpfs`
scratch mounts a fresh tmpfs at `jail.ScratchMount` (`/tmp`). It is a
grant, so it sorts with the other grants: a writable root *underneath*
it is emitted after it and survives (bwrap creates the mountpoint
inside the fresh tmpfs and binds the host directory there), and a
protected path underneath it is masked afterwards like any other. What
does *not* survive is a path under `/tmp` that the policy never named
— that is still replaced by empty scratch, which is why
`codemode/launch` refuses a cap socket there up front. Before #41 was
fixed the writable root did not survive either, and the write
evaporated silently.
- **A protected path is masked once, at its resolved target.** Two
bubblewrap facts, both measured, decide the shape of the mask loop. A
protected path nested inside another protected path must not get a
mask of its own: the ancestor's tmpfs is remounted read-only, so the
descendant's mountpoint cannot be created and bwrap refuses to start —
`protected: ["~/.ssh", "~/.ssh/id_rsa"]` killed every jail built from
it. And a mask lands on whatever its destination *resolves* to, so
`jail.PathKind` must describe the resolved target: a symlink to a
directory classified `PathFile` emits a file mask against a directory
and bwrap refuses; an absolute symlink is worse, since during setup its
target resolves inside bwrap's pivot root where it does not exist and
every mask form fails with ENOENT.
- **The helper speaks first.** The spec does not say who does; the helper
sends its hello so the broker learns features before committing work, and
requires the broker's hello before any other frame.
- **A wire change moves `framing.ExecProtocolVersion`.** It is 3, and the
`hello.proto` value on both sides; a `protocol-change` that adds,
removes, or makes-required a key on a frame this helper sends or
receives — or adds a kind to this channel — bumps it and
`broker/framing.exec_protocol_version` in the same commit (the addendum
to `protocol-change/006` is the ruling). `framing.EnvelopeVersion`, the
`v` key, is a separate number describing the container and stays at 1
while that shape does. A mismatch is refused at the handshake with both
numbers named, because "unsupported proto 2" alone is what issue #61
cost an hour to.
- **Degraded means degraded, out loud.** When bwrap, Landlock, or cgroups
are unavailable the helper enforces what it can and reports the truth in
`hello.features` and per-exec `enforcement`/`degraded`. The self-test
applies the same rule: a probe whose layer the environment cannot provide
prints SKIPPED with a reason and never fakes a pass; a probe whose layer
*is* available must enforce or the run exits nonzero. A green self-test
in a neutered container cannot be mistaken for a verified sandbox.
- **The mount layer says what it achieved, not that it ran.** `bwrap`
meant "bubblewrap was on PATH and we spawned it"; it never meant "the
policy's paths were narrowed as asked", which made every finding in the
mount-precedence family invisible to a full-enforcement demand (#54).
`jail.AuditMounts` replays the ordered `MountPlan` and emits
`mounts:ro=N,rw=M,mask=K,bind_ro=P,bind_rw=Q,scratch=…,base=…,plan=…`,
and the Darwin profile answers with the same fields inside
`seatbelt-fs:`. `base=` is `minimal` or `host-view`; see the base-view
invariant above.
`bind_ro` and `bind_rw` count the explicit `mounts` entries the plan
leaves at the access they asked for. The counts are of the
policy's own paths whose **effective** view — after the whole ordered
plan, taking the last operation that covers each path — is the one the
policy asked for, which is why they catch what a count of requested
operations cannot: that number is identical in a healthy plan and in
one whose mask a later bind of an ancestor undoes. Such a path drops
out of `mask=` and gets a `skip:mounts:` naming it and the operation
that re-exposed it. A writable root the plan leaves unwritable is
counted out of `rw=` without a skip — the fail-closed direction, and
the scratch tie at `/tmp` and a protected mask produce it legitimately
— with one exception: a writable root whose effective view is an
explicit read-only mount gets `skip:mounts: writable root … is
read-only`. Both decoders refuse that policy (`protocol-change/057`),
so reaching the audit means one of them was bypassed, and the broker
does not compare `rw=`, so without the skip a full-enforcement demand
read the jail as whole. A skip rather than a broker-side `rw=`
comparison, because the broker could tell this shortfall from the
legitimate ones only by re-deriving the plan. The `plan=` digest is a diffing aid and a golden-test
anchor, not a check: it detects *change*, and nobody holds the expected
value. The audit proves the plan, not its execution — for that, see the
witness rule below. Proving the resulting *view* would mean probing
from inside stage 2, and `faccessat` is unreliable for uid 0, which is
the common case inside a bwrap user namespace.
- **A layer that says nothing is not a layer that was applied.** The
per-exec report used to be trusted for what it *omitted*: no `skip:`
entry meant fully enforced. A stage 2 that died before writing fd 4
produced `enforcement: ["bwrap"]` — no skip anywhere — and satisfied a
`FullEnforcement` demand with the whole inner report missing (#54). So
the helper now claims `bwrap` and the `mounts:` audit **only when stage
2 reported**, that report being the one thing that could not have
arrived unless bubblewrap built the namespace and exec'd into it; and a
silent stage 2 emits `skip:stage2: …` and `skip:bwrap: …` instead of
nothing. The broker's half is `exec.required_layers`, which derives the
demanded set from the policy and refuses a report that never mentions
one of them.
- **A probe that can only fail must prove it can also succeed.** Three
denials in a row look identical to a module that never loaded, a node
that never booted, and a path that never existed — so the hostile-`.beam`
probe never reports containment on silence. The adversary announces that
it loaded and that it finished, it performs two effects the policy
*allows* (reading a bound-in file, writing inside the writable root) and
reports them, and the probe runs the identical module unjailed first: if
the escape does not succeed there, the probe skips rather than claiming a
jail held something the host never permitted anyway. The other end of
that pair lives in the tree too, as a test that runs the same adversary,
the same argv and the same jail with those three mechanisms *granted*
instead of withheld, and insists it reaches all three.
- **A protected path is masked at its inode, not at its name.** bwrap
resolves a mount's destination inside the pivot root it is building,
where a symlink's target does not exist yet, so masking the link's own
name fails outright — measured: `Can't mount tmpfs on …/dot-ssh: No
such file or directory`, exit 1, no jail at all, and for an *absolute*
symlink every mask form fails. `run.go` therefore rewrites
`Protected` through `filepath.EvalSymlinks` before building the plan,
and `statKinds` uses `os.Stat` rather than `os.Lstat` so the inode type
the mask form is chosen for is the target's. Masking the target covers
both names, since the link still resolves into the mask from inside. A
path that does not resolve keeps its own name: that is the
`PathMissing` case, where masking the name is the whole point.
- **A policy bwrap cannot realise fails in Loom's words, not bwrap's**
(issue #60). Three shapes made bubblewrap refuse to start with a bare
`code=1` and its own chatter on stderr, indistinguishable from the
payload's own command failing:
- A nested protected path — already handled by 4b4983d's plan model
(the redundant descendant is dropped before it ever gets a mask of
its own; see the mount-precedence bullets above).
- A `PathMissing` protected path whose parent the plan leaves
read-only. bwrap needs write access to create the mount point for a
path that does not exist yet, and refuses with `Can't mkdir parents
for PATH: Read-only file system` when it does not have it. `~/.ssh`
— a default protected path — hits this on any policy that does not
also grant write under `$HOME`, which is the ordinary case, not an
edge one. `internal/jail.UnmountableProtected` (mounts.go) replays
the mount plan the same way `AuditMounts` does and flags exactly
this shape; `run.go` calls it before building the argv at all and
refuses with a message naming the path, so the caller gets a
structured `error` frame instead of an opaque spawn failure.
- A nonexistent readable root. Unlike the other three path lists,
`readable_roots` names paths that may legitimately vary by host — an
optional toolchain root, a platform-specific system directory — so
it is bound with `--ro-bind-try` rather than refused up front,
matching the `Optional`/`IgnoreIfMissing()` treatment
`internal/llock.Rules` already gives the same list. The other three
lists (`writable_roots`, `protected`, and a host-path `scratch`) do
not tolerate absence the same way, and each has its own reason: see
"which path lists tolerate a missing path" above `readableRootOp` in
bwrap.go for the decision.
- A `writable_roots` entry or the host-path form of `scratch` that
does not exist. Both render as `--bind`, which requires the source,
so bwrap refuses with `Can't bind mount SRC: No such file or
directory` and exit 1. Refusing is the decision and not the defect —
a tool that believes it has write access it does not have is a
correctness hazard, and a silently narrower jail is the quiet
failure the design forbids — so `internal/jail.MissingMountSources`
(mounts.go) supplies the half that was missing, the diagnosis:
`run.go` stats both lists before the argv exists and refuses naming
the path, the list it came from, and the kernel's own reason (#63).
- **That rigour applies to every probe, not one.** Two probes asserted
only the *absence* of an effect — an untouched secret file, a prompt
`Wait` — and nothing having run satisfies both. With a `bwrap` on PATH
that is `#!/bin/sh\nexit 1`, eight probes said FAILED and those two said
ENFORCED (#54), and `.github/scripts/enforcement_report.sh` reads
exactly those per-probe verdicts. Both now carry a witness that the
payload executed: `probeProtected` performs an effect the policy
*allows* inside the same jail and requires its `ALLOWED-OK`, and
`probeOrphanReap` requires the shell to print the orphan's own pid,
which it can only do after forking it. `internal/selftest`'s own test
puts that shim on `PATH` and fails if any probe reports ENFORCED.
- **The probe's name says what it checks.** `probeProtected` was
"protected path write denied" and asserted only that a protected
*file*'s bytes were unchanged. Since #55 resolved `protected` to mean
the contents are gone from the jail's view whatever the inode type, it
is "protected path masked from reads and writes" and asserts that
neither a protected file's contents nor a protected directory's are
reachable from inside — the resolution pinned by a test rather than by
prose. Renaming a probe changes the probe set, which
`.github/scripts/enforcement_report.sh` fails on by design, so the
rename and the line in `.github/enforcement-expectations` move
together.
- **The state-root probe exercises the daemon's own layout, not a
synthetic path.** `probeDaemonStateRoot` builds the masks
`client/serve.protecting_state_root` chooses — `owner.token`,
`catalogue.db`, `daemon.lock` as files and `sessions/` as a directory —
with the state root *outside* every writable root, which is the
arrangement a real session gets and the one `probeProtected` does not
cover. It asserts on the secret bytes rather than on an error, because
the refusal has two shapes: on Linux a masked file reads as zero bytes
from the `/dev/null` bind and a masked directory is an empty read-only
tmpfs, while on Darwin all of them are denied outright. A probe
asserting EPERM would fail against a correct Linux jail. It is
`required` on both platforms, since masking is enforced on both. Both
of its runs name the state root as a readable root, which is what keeps
it a test of the mask: since the base view became minimal, a state root
the policy never names is absent whether or not it is masked, and a
control relying on the base view would report a broken probe on a
correct jail.
- **What the hostile-`.beam` probe claims, and what the base-view probe
claims beside it.** The hostile-`.beam` probe observes that an unvetted
`.beam` cannot write outside the writable roots (`erofs`), cannot see a
protected path, and cannot reach the network (`eperm`, from the seccomp
filter, behind an empty network namespace). It does not observe the
base view: its own `.beam` directory is a readable root in both of its
policies, because a module the jail cannot load reports a node that
never booted, which is a broken probe rather than containment. The
base view has its own probe, `host path outside the mount plan
unreadable`, and it is the only one here that measures the absence of a
grant rather than a subtraction: a host file that no part of the policy
names, a sibling of the workspace, is not readable from inside. Its
control reads the same file through a readable root first, so a jail
that refuses everything cannot pass it.
- **"Cannot see a protected path" means the contents, and it now holds
for a file too.** A protected *directory* — and a path that does not
exist yet, so a protected `~/.ssh` stays uncreatable — is shadowed by
an empty tmpfs remounted read-only: the directory is there and it is
empty, so its contents are `enoent`. A protected *file* used to be
bind-mounted onto itself read-only, which left it fully **readable**;
`protected: ["~/.aws/credentials"]`, the most obvious use of the
feature, handed the credentials to the jailed process and only stopped
it writing them back (#55). It is now shadowed by a read-only bind of
`jail.MaskSource` (`/dev/null`) instead — tmpfs cannot mount over a
non-directory, and bwrap binds `MS_NODEV`, so the masked path cannot
be opened at all: EACCES on read and on write, not `enoent`. Were nodev
ever absent the read would see an empty file and the write would go
nowhere near the host, so the mask is safe either way. The design's
`protected_paths` comment says "never writable"; the implementation is
deliberately stronger than that, and uniformly so, because a read is
what an adversary in the jail actually wants from a credential file.
- **A probe's own scratch directory must live outside the scratch mount.**
`os.MkdirTemp("")` lands under `/tmp` on a host with no `TMPDIR`, which
is most CI runners, and so does a helper binary built by
`internal/testbin`. A probe whose fixtures the jail's own scratch
covers reports a broken jail and the jailed Go tests fail for a reason
that has nothing to do with confinement; both now build outside it.
Since #41 an explicitly granted writable root under `/tmp` survives the
scratch mount, so the sharpest version of this hazard is gone — but a
probe directory that no policy entry names is still replaced by empty
scratch, and a fixture outside `/tmp` is the version that does not
depend on remembering which.
- **A missing platform is not a missing kernel feature, and is never
reported as one.** Everything else here probes the running kernel and
calls a gap environmental. A build with no jail for its OS has a gap in
*Loom*, so it takes a different path everywhere: `hello.features` gains
`platform-unsupported` and `Degraded()` is true; every per-exec
`enforcement` list leads with `skip:jail: …` naming what was not applied;
`--self-test` prints `RESULT: UNSUPPORTED PLATFORM` and exits nonzero
rather than repeating "skips are environmental, not passes"; and server
mode refuses to start without `--allow-unenforced`, because a BestEffort
caller would otherwise run model-influenced code with `network: off` in
the policy and nothing enforcing it. This is now the Windows/unknown-target
path; Darwin instead runs its live Seatbelt suite and advertises `seatbelt`.
+ - **A helper run from inside a jail cannot nest, and says so.** The kernel
+ refuses a second Seatbelt profile (`sandbox_apply: Operation not
+ permitted`, exit 71), so inside a Loom session `--self-test`, the
+ real-helper suites in `broker`, `tools` and `conformance`, `make e2e`,
+ `make e2e-codemode` and `docker-smoke` cannot run, and neither can an
+ `exec` of a setuid binary (`/bin/ps`) or a pty through `/dev/ptmx`. Writes
+ land only in the workspace, the user temp directories and the per-call
+ scratch; `/private/tmp` and `/var/tmp` are unwritable by design. The
+ self-test's `probeRootGate` creates its probe root once up front and, on
+ a permission refusal, prints `NOT RUN every probe` and `RESULT: NOT RUN
+ (not a pass)` with a nonzero exit instead of eleven failures. The Gleam
+ suites key their skip on `LOOM_SCRATCH_DIR` being set
+ (`broker/exec.jailed_session_skip_reason`). None of this widens a
+ policy; do not debug it, signoff and CI arbitrate.
## Deep Docs
- [protocol-change/014-helper-shutdown-witness.md](../../protocol-change/014-helper-shutdown-witness.md):
shutdown framing, native exit evidence, and uncertain cleanup.
- [docs/adr/006-macos-seatbelt-boundary.md](../../docs/adr/006-macos-seatbelt-boundary.md):
the Seatbelt boundary, Darwin's resource semantics, and the explicit
descendant-lifecycle limit.
- [docs/architecture/effects.md](../../docs/architecture/effects.md) —
the threat model and Rule Zero, the jail, enforced versus reported.
- [docs/spec-gaps.md](../../docs/spec-gaps.md) — "From WP-H (`sandbox`)":
policy source, `exec_start.limits`, output caps, hello ordering,
one-exec-at-a-time, network filtering, degraded mode, unknown input.
- [packages/broker/CLAUDE.md](../broker/CLAUDE.md) — the other end of the
wire.
- [Root CLAUDE.md](../../CLAUDE.md) — repo ground rules and the doc graph.
`make selftest` probes the current kernel; `make e2e` runs the jailed
acceptance against a freshly built helper.