diff --git a/.cargo/config.toml.example b/.cargo/config.toml.example
index 70621fc..148c066 100644
--- a/.cargo/config.toml.example
+++ b/.cargo/config.toml.example
@@ -1,16 +1,32 @@
-# Copy to .cargo/config.toml to build the ABI V9 / RESOLVE_UNIX path:
-# cp .cargo/config.toml.example .cargo/config.toml
+# NOT NEEDED FOR A NORMAL BUILD as of v0.2.
+#
+# The V9 landlock crate is wired in by a committed `[patch.crates-io]` section in
+# Cargo.toml pointing at `third_party/rust-landlock`, whose sources are VENDORED
+# (committed to this repo directly — not a git submodule), so a fresh clone
+# builds with no init step and no override:
+#
# cargo build --features resolve-unix
-# `paths` must point at a landlock checkout that adds ABI V8/V9 +
-# AccessFs::ResolveUnix — upstream rust-landlock PR #135
-# (https://github.com/landlock-lsm/rust-landlock/pull/135). Its head branch lives
-# in the author's fork, so fetch it by PR number rather than by branch name:
+#
+# (`--features resolve-unix` is mandatory since the V9 floor — a build without it
+# stops at the compile_error! in src/landlock.rs. See D-4b.)
+#
+# NOTE: the Landlock ABI is a property of the RUNNING KERNEL —
+# LANDLOCK_CREATE_RULESET_VERSION reports what the kernel implements. A V9-capable
+# crate on a 6.15 kernel still detects V7, and oring refuses at the floor gate.
+# Running needs an actual >= 7.1 kernel (or a VM booting one, as CI does), not
+# just this crate.
+#
+# --- The only remaining use: point at a DIFFERENT landlock checkout ------------
+# Useful when developing against the fork itself, or testing upstream
+# rust-landlock#135 (https://github.com/landlock-lsm/rust-landlock/pull/135)
+# before it is vendored into third_party/. Copy to .cargo/config.toml and adjust.
+#
+# A `paths` override SHADOWS the [patch.crates-io] in Cargo.toml and makes cargo
+# emit a "recommended to use the [patch] feature" warning — that warning is
+# expected here and means the override is in effect.
+#
# git clone https://github.com/landlock-lsm/rust-landlock ../rust-landlock
# git -C ../rust-landlock fetch origin pull/135/head:pr135
# git -C ../rust-landlock checkout pr135
-# (verified: oring builds and its full suite passes with --features resolve-unix
-# against this checkout on a real V9 kernel.) Without this override the
-# `resolve-unix` feature will not compile against the published crate, so
-# `cargo install oring --features resolve-unix` needs a local #135 checkout until
-# upstream ships V9 in a release.
-paths = ["../rust-landlock"]
+#
+# paths = ["../rust-landlock"]
diff --git a/.githooks/commit-msg b/.githooks/commit-msg
new file mode 100755
index 0000000..80f202d
--- /dev/null
+++ b/.githooks/commit-msg
@@ -0,0 +1,5 @@
+#!/bin/sh
+# SPDX-License-Identifier: BSD-3-Clause
+# Installed by `make hooks`, never automatically — a repository that edits the
+# developer's git configuration on build is a repository nobody should trust.
+exec "$(git rev-parse --show-toplevel)/scripts/check-commit-msg.sh" "$@"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1beb7e1..4030aa0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,194 +1,93 @@
# SPDX-License-Identifier: BSD-3-Clause
-# Makes the gates SECURITY.md/DESIGN describe REAL: fmt, clippy, cargo-deny,
-# static-musl build + static-link assertion, and the test suite. Actions are
-# pinned by SHA. GitHub's ubuntu-24.04 runner is below oring's V7/6.15 floor, so
-# on it the confinement tests SKIP-and-count (require_or_skip gates on
-# ABI>=FLOOR_ABI) and lint-build instead asserts the real fail-closed REFUSE.
-# The confinement suite is WITNESSED on a real >=floor kernel by the
-# `confinement-vng` job below: it boots a >=6.15 guest under virtme-ng/KVM (KVM
-# is available on the free runner via a udev rule) and runs the suite with
-# ORING_REQUIRE_KERNEL=1 so a skip is a HARD failure.
+#
+# CI is the make targets. Nothing here does anything a maintainer cannot run
+# with one command, and nothing here is the authority on whether oring confines.
+#
+# WHAT THIS CANNOT DO, stated first because it is the whole shape of the file.
+# oring's floor is Landlock ABI V10 / kernel 7.2. No hosted GitHub runner is at
+# that floor — 7.2 is not released — so a hosted runner cannot witness a single
+# containment cell. `make check` sets ORING_REQUIRE_KERNEL=1 precisely so a
+# machine that cannot witness containment REFUSES to report that it did, which
+# means running `make check` here would fail on every push, for a reason that has
+# nothing to do with the commit.
+#
+# So this workflow runs the half a sub-floor runner can genuinely check: format,
+# lints, dependency policy, the static build, and the seccomp SMT gate — which is
+# pure computation over the emitted cBPF and needs no Landlock at all.
+#
+# CONTAINMENT IS WITNESSED BY `make check` ON A >=7.2 HOST, and nowhere else.
+# On the maintainer's box that is 34 suites, 0 failed, plus a matrix of 34 checks
+# with all 21 denial cells attributed against a bare control. There is no CI job
+# that reproduces it and this file does not pretend otherwise.
+#
+# A previous version of this file defined three virtme-ng/KVM jobs that built a
+# 7.2-rc4 kernel and ran the suite under it. They were never executed — its own
+# header said so — and a job that has never run is not evidence, it is an
+# intention with YAML around it. Deleted rather than left staged.
name: ci
+
on:
push:
pull_request:
- workflow_dispatch: # manual trigger — also runs the staged confinement-v9 job
+ workflow_dispatch:
-# Supersede an in-flight run on the same ref (the confinement-vng kernel build
-# is long; don't stack two).
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
- lint-build:
+ check:
runs-on: ubuntu-24.04
steps:
+ # Ring 1 is a rev-pinned git dependency on the maintainer's fork (V10),
+ # required to compile at all since the V10 floor (D-4b). Unlike the vendored
+ # copy it replaced, this needs the network at build time — `cargo` fetches
+ # the pinned revision.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- run: rustup show && rustup target add x86_64-unknown-linux-musl
+ - run: sudo apt-get update && sudo apt-get install -y z3
+ - run: cargo install cargo-deny cargo-auditable --locked
+
- name: fmt
- run: cargo fmt --check
- # NOT `-D warnings`: that would escalate the intentional pedantic=warn
- # lints to errors. The Cargo [lints] `clippy all=deny` already makes the
- # hard set a build failure; pedantic stays advisory.
- # `--features bpf-dump` (NOT --all-features): lint the analysis-only
- # bpf-dump code (seccomp::dump_groups, the bpf-dump bin, tests/seccomp_smt.rs)
- # too, but do NOT enable `resolve-unix` — that feature needs the out-of-tree
- # V9 landlock fork (ABI::V8/V9), which the published crate lacks, so it
- # cannot compile in CI (see .cargo/config.toml.example, #135).
+ run: make fmt
- name: clippy
- run: cargo clippy --all-targets --features bpf-dump --locked
- # One install of the security tooling from crates.io (--locked). cargo-deny
- # comes from crates.io, NOT its marketplace action, whose bundled binary
- # was too old to parse the current RustSec DB (CVSS 4.0). cargo-auditable
- # embeds the dependency SBOM in the build below; cargo-audit scans it.
- - name: install security tooling
- run: cargo install cargo-deny cargo-auditable cargo-audit --locked
- - name: cargo-deny (advisories/licenses/bans/sources)
- run: cargo deny check
- - name: static-musl build (with embedded SBOM)
- run: cargo auditable build --release --locked --target x86_64-unknown-linux-musl
+ run: make clippy
+ # bans + licenses + sources only. `advisories` fetches the live RustSec
+ # database, so it would turn this red on a day nobody touched the
+ # repository; it lives in `make advisories` and `make check-full`.
+ - name: deny
+ run: make deny
+ - name: static-musl build with an embedded SBOM
+ run: make build
env:
# Pin the embedded git SHA to a declared input so the same commit
- # yields the same bytes in CI and in a .git-absent tarball build.
+ # yields the same bytes here and in a .git-absent tarball build.
ORING_GIT_SHA: ${{ github.sha }}
- # rustc links musl targets as static-pie, so file(1) reports "static-pie
- # linked", NOT "statically linked" — the literal-string form of this
- # assertion failed on a real build. Accept both static spellings; the
- # thing being excluded is "dynamically linked".
+ # rustc links musl as static-pie, so file(1) says "static-pie linked", NOT
+ # "statically linked" — the literal-string form of this assertion failed on
+ # a real build. What is being excluded is "dynamically linked".
- name: static-link assertion
run: |
file target/x86_64-unknown-linux-musl/release/oring | tee /dev/stderr \
| grep -qE "static-pie linked|statically linked"
- # End-to-end smoke of the SHIPPED musl binary (distinct from `cargo test`,
- # which builds debug binaries). The hosted runner's kernel varies around
- # the V7 floor: on a >=V7 runner oring seals and execs the target (exit 0);
- # on a sub-floor runner it refuses (exit 120). Both are healthy fail-closed
- # outcomes; anything else (crash/partial seal) is a real failure.
- - name: shipped-binary seal smoke test
+ # Pure computation over the emitted cBPF — the H2 truncation guard
+ # (CVE-2019-7303's class) and the socket() protocol wall. No kernel needed,
+ # so this is real evidence even here.
+ - name: seccomp SMT gate
+ run: make smt
+
+ # The one containment fact a SUB-FLOOR runner can establish: that oring
+ # refuses rather than under-confines. Exit 120 is the fail-closed path and
+ # is what this runner should produce; exit 0 would mean the runner fleet
+ # reached 7.2 and this assertion needs revisiting.
+ - name: fail-closed refusal below the floor
run: |
set +e
target/x86_64-unknown-linux-musl/release/oring --profile balanced --workdir /tmp -- /bin/true
rc=$?; echo "exit=$rc"
case "$rc" in
- 0) echo "PASS: sealed and ran the target (runner is >=V7)" ;;
- 120) echo "PASS: refused below the V7 floor (fail-closed)" ;;
+ 120) echo "PASS: refused below the V10 floor (fail-closed)" ;;
+ 0) echo "FAIL: sealed on a runner expected to be below the floor —"
+ echo " the fleet moved; re-read this job and make check's kernel gate"; exit 1 ;;
*) echo "FAIL: unexpected exit $rc"; exit 1 ;;
esac
- # Audit the SHIPPED binary's embedded SBOM against RustSec (complements the
- # source-level cargo-deny advisories check above).
- - name: audit the shipped binary (RustSec)
- run: cargo audit bin target/x86_64-unknown-linux-musl/release/oring
- # Unit tests run everywhere; confinement integration tests skip-and-count
- # on this below-floor runner (require_or_skip). Not passing ORING_REQUIRE_KERNEL.
- - name: tests
- run: cargo test --locked
- # Ring-2 truncation-invariance gate — the H2 regression guard (§5). Pure
- # computation over the emitted cBPF, so it needs a solver but NOT a Landlock
- # kernel, and runs fully on this runner (~2s). ORING_REQUIRE_SMT=1 makes a
- # missing z3 fatal rather than a silent skip.
- - name: install z3
- run: sudo apt-get update && sudo apt-get install -y z3
- - name: seccomp SMT gate
- run: cargo test --locked --features bpf-dump --test seccomp_smt
- env:
- ORING_REQUIRE_SMT: "1"
-
- # Witness containment on a real >=V7 kernel — the "proven/gated/witnessed" leg.
- # The hosted ubuntu-24.04 runner kernel is now itself >=6.15 (V7), so boot IT
- # directly under vng/KVM (~30s) instead of compiling a kernel. Clean guest,
- # ORING_REQUIRE_KERNEL=1 so a skip is a HARD FAILURE. The V9/RESOLVE_UNIX path
- # needs a 7.1 kernel — see the staged confinement-v9 job below.
- confinement-vng:
- runs-on: ubuntu-24.04
- timeout-minutes: 30
- steps:
- - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- - run: rustup show
- - name: Enable KVM on the free runner
- run: |
- echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
- | sudo tee /etc/udev/rules.d/99-kvm4all.rules
- sudo udevadm control --reload-rules
- sudo udevadm trigger --name-match=kvm
- ls -la /dev/kvm
- - name: Install virtme-ng
- run: |
- sudo apt-get update -qq
- sudo apt-get install -y qemu-system-x86 >/dev/null
- pipx install virtme-ng
- vng --version
- # Pre-build the test binaries on the host (glibc); the guest shares the
- # host FS (hostfs), so the in-guest `cargo test` just executes them.
- - name: Build test binaries (host)
- run: cargo test --no-run --locked 2>&1 | tail -3
- - name: Confinement suite under vng/KVM (runner kernel; skip => fail)
- run: |
- set -o pipefail
- KREL="$(uname -r)"
- sudo chmod +r "/boot/vmlinuz-$KREL" "/boot/config-$KREL" 2>/dev/null || true
- # Boot the runner's own kernel (~30s) — no build. Propagate HOME +
- # ~/.cargo/bin so host cargo/rustup (shared via hostfs) resolve.
- run_in_vng() { timeout 600 vng --run "/boot/vmlinuz-$KREL" -- env HOME="$HOME" PATH="$HOME/.cargo/bin:$PATH" "$@"; }
- echo "== guest Landlock ABI (proves >=V7) =="
- run_in_vng sh -c 'echo "uname=$(uname -r)"; echo "lsm=$(cat /sys/kernel/security/lsm)"; python3 -c "import ctypes; print(\"GUEST_LANDLOCK_ABI=\"+str(ctypes.CDLL(None).syscall(444,0,0,1)))"' 2>&1 \
- | grep -E 'uname=|lsm=|GUEST_LANDLOCK_ABI=' | tee guest.info
- GABI="$(sed -n 's/.*GUEST_LANDLOCK_ABI=\([0-9]*\).*/\1/p' guest.info | tail -1)"
- [ "${GABI:-0}" -ge 7 ] || { echo "FAIL: guest Landlock ABI ${GABI:-unknown} < V7 floor"; exit 1; }
- echo "== confinement suite, ORING_REQUIRE_KERNEL=1 =="
- run_in_vng ORING_REQUIRE_KERNEL=1 cargo test --locked 2>&1 | tee vng.log | tail -100
- grep -qE 'test result: ok' vng.log || { echo "FAIL: suite not ok"; exit 1; }
- - name: Live jail suite under vng/KVM (runner kernel)
- run: |
- set -o pipefail
- KREL="$(uname -r)"
- timeout 600 vng --run "/boot/vmlinuz-$KREL" -- env HOME="$HOME" PATH="$HOME/.cargo/bin:$PATH" \
- ORING_REQUIRE_KERNEL=1 ORING_LIVE=1 \
- cargo test --locked --test containment_live 2>&1 | tee live.log | tail -60
- grep -qE 'test result: ok' live.log || { echo "FAIL: live suite not ok"; exit 1; }
-
- # STAGED (on-demand: workflow_dispatch only). Builds a mainline 7.1 kernel
- # (Landlock ABI V9) and runs the suite on it — the highest ABI, exercising the
- # scope/abstract-socket cells at V9. The pathname-AF_UNIX cells still skip
- # without `--features resolve-unix` + the out-of-tree fork (the published crate
- # can't build V9), which is why this is not per-push: ~20min kernel build.
- confinement-v9:
- if: github.event_name == 'workflow_dispatch'
- runs-on: ubuntu-24.04
- timeout-minutes: 75
- steps:
- - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- - run: rustup show
- - name: Enable KVM on the free runner
- run: |
- echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
- | sudo tee /etc/udev/rules.d/99-kvm4all.rules
- sudo udevadm control --reload-rules
- sudo udevadm trigger --name-match=kvm
- ls -la /dev/kvm
- - name: Install virtme-ng + kernel build deps
- run: |
- sudo apt-get update -qq
- sudo apt-get install -y qemu-system-x86 build-essential flex bison bc \
- libelf-dev libssl-dev dwarves cpio >/dev/null
- pipx install virtme-ng
- vng --version
- - name: Build test binaries (host)
- run: cargo test --no-run --locked 2>&1 | tail -3
- - name: Build the V9 kernel (mainline v7.1)
- run: |
- set -o pipefail
- git clone --depth 1 -b v7.1 https://github.com/torvalds/linux kern
- printf 'CONFIG_SECURITY=y\nCONFIG_SECURITY_LANDLOCK=y\nCONFIG_LSM="landlock,yama,bpf"\nCONFIG_NAMESPACES=y\nCONFIG_USER_NS=y\nCONFIG_NET_NS=y\nCONFIG_UTS_NS=y\nCONFIG_IPC_NS=y\nCONFIG_PID_NS=y\n' > landlock.cfg
- ( cd kern && timeout 3000 vng --build --config ../landlock.cfg --verbose 2>&1 | tail -20 )
- - name: Confinement suite on V9 (skip => fail)
- run: |
- set -o pipefail
- run_in_vng() { ( cd kern && timeout 600 vng -- env HOME="$HOME" PATH="$HOME/.cargo/bin:$PATH" "$@" ); }
- echo "== guest Landlock ABI (expect V9) =="
- run_in_vng sh -c 'echo "uname=$(uname -r)"; echo "lsm=$(cat /sys/kernel/security/lsm)"; python3 -c "import ctypes; print(\"GUEST_LANDLOCK_ABI=\"+str(ctypes.CDLL(None).syscall(444,0,0,1)))"' 2>&1 \
- | grep -E 'uname=|lsm=|GUEST_LANDLOCK_ABI=' | tee guest.info
- GABI="$(sed -n 's/.*GUEST_LANDLOCK_ABI=\([0-9]*\).*/\1/p' guest.info | tail -1)"
- [ "${GABI:-0}" -ge 9 ] || { echo "FAIL: guest Landlock ABI ${GABI:-unknown} < V9"; exit 1; }
- run_in_vng ORING_REQUIRE_KERNEL=1 cargo test --locked 2>&1 | tee vng.log | tail -100
- grep -qE 'test result: ok' vng.log || { echo "FAIL: suite not ok"; exit 1; }
diff --git a/.gitignore b/.gitignore
index ef4a2c4..ef66ebb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,4 +3,8 @@ target/
*.sha256
*.swp
.cargo/config.toml
-docs-internal/
+# docs-internal/ REMOVED 2026-07-29 and deliberately NOT ignored any more.
+# It held 75 review documents / 22,008 lines — untracked, inside the product repo,
+# grading claims that ship. Corrections there were unrecoverable. It is now evidence
+# in the maintainer's research corpus, tracked and gated. If a `docs-internal/`
+# reappears here, it should show up in `git status` and be questioned, not hidden.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4762a26..3a8a34c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,198 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+Nothing yet.
+
+## [0.2.0] - 2026-08-02
+
+### Security
+
+- **A decoyed `$HOME` could silence the `shared_cache_write=` disclosure** (T25).
+ The field read `$HOME` alone, so `HOME=/tmp/decoy` — or `HOME` unset — reported
+ `none` over a policy granting write on the real `~/.cargo/registry`. It now
+ takes the union of the passwd home and `$HOME`. Confinement was never affected;
+ this was the operator's view of it. `docs/RESIDUALS.md`.
+
+- **Caller bytes reached the terminal unescaped at 13 of ~14 diagnostic sites**
+ (T26). A forged `SEALED` line was injectable through argv alone —
+ `ESC[2K\r` erases the real `REFUSE` and prints a plausible seal line.
+ `crate::diag::safe` now escapes at the `OringError::Display` chokepoint and at
+ the warning sites. The seal line remains forgeable by the **child**, which
+ shares stderr; what closed is forgery by the caller.
+
+- **`$TMPDIR` is no longer read from the environment.** A caller-supplied value
+ was pushed into a writable tier, and `check_profile_grants` did not catch a
+ path one component below `$HOME`: `TMPDIR=$HOME/.ssh --profile balanced` sealed
+ and the child got `O_WRONLY` on `authorized_keys`. Use `--tmpdir` or
+ `--exec-tmp`.
+
+- **Tier overlap is refused.** Landlock ORs an ancestor's rule with a
+ descendant's, so `read_exec ∪ write_noexec = write_exec` — a tier neither
+ declaration names. `--workdir /tmp` under `balanced` upgraded all of shared
+ `/tmp` to write+exec while `grants=` reported success.
+
+- **`socket()`/`socketpair()` are constrained by `type`, not just family and
+ protocol.** `SOCK_SEQPACKET` reached SCTP's slot. `socketpair` was worse: it
+ carried only the family gate, and `sock_create()` calls `request_module()`
+ before checking `->socketpair`, so a sealed child could make modprobe load
+ `sctp.ko` on its behalf.
+
+- **`--workdir` refuses a symlink**, matching `--exec-tmp`. The old
+ `metadata()` check followed links, so a link to a `$PATH` directory granted a
+ writable tier over it while `--explain` printed only the link.
+
+### Added
+
+- **No `CLONE_NEW*` combination is allowed by the emitted filter** — proved over
+ all argument values by z3 (`tests/seccomp_smt.rs`), per flag and in
+ combination, with a control asserting `CLONE_NEWTIME` on `clone` *is* allowed
+ so the query cannot pass vacuously. Only `CLONE_NEWUSER` is attributable at run
+ time — unprivileged the others return `EPERM` bare — so a dropped bit or a
+ combined mask would have left the whole suite green.
+- **`setns` has a cell** (T14): `EBADF` bare, `EPERM` sealed. It is the one
+ namespace-family syscall whose denial is directly attributable without a user
+ namespace.
+
+- **`NO_NEW_PRIVS` is measured** (`tests/nnp.rs`, T15) — bare `0/0`, sealed
+ `1/2`, one variable. A headline guarantee that had no test. The seal line's
+ `nnp=` literal is checked against the kernel for the same run.
+
+- **The FD scrub is pinned at both ends** (`tests/fd_scrub.rs`, T10), at the
+ boundary: fd 2 is the highest preserved, fd 3 the lowest scrubbed, because an
+ off-by-one in `close_range_cloexec(3)` is the mutation the code can suffer and
+ cells at 0 and 7 stay green through it.
+
+- **Three documented controls are demonstrated** (`tests/control_efficacy.rs`,
+ `tests/containment_matrix.rs`, CON-7): the workdir-review recipe,
+ `oring --explain`, and `--proc self`. Each carries a vacuity arm.
+ `tests/design_residuals.rs` now fails when a residual names a control and cites
+ no fixture.
+
+- **The containment matrix attributes its own cells** (T21). Every denial cell
+ runs bare first; one whose bare arm satisfies the sealed expectation is
+ reported `UNATTRIBUTABLE`, not `PASS`, and must be declared. **21 of 21 are
+ attributed** and the declared list is empty.
+
+- **No shipped profile grants access to a host credential store** (T16). An
+ ∀-over-profiles invariant over `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.netrc`,
+ `~/.kube`, `~/.docker/config.json`, `~/.config/gh` and the shell histories,
+ across all four tiers, ancestry both ways.
+
+- **`egress_udp` / `ingress_udp`** — Landlock UDP rights at ABI V10. Grant port
+ `0` in `ingress_udp` to permit kernel autobind, or a non-empty `egress_udp` is
+ inert; oring warns on that shape. Unset means unrestricted, not denied.
+
+- **`unhandled=` on the seal line**, plus a warning when a class is only
+ partially restricted. Derived by set difference, so a right oring does not yet
+ plumb reports itself. Not suppressible by `ORING_QUIET`.
+
+- **`errata=` on the seal line** — which declared Landlock fixes the running
+ kernel carries. A **lower bound**, not a certificate: behaviour changes have
+ shipped with no erratum. `?` when the kernel does not answer, which stays
+ distinguishable from `0x0`.
+
+- **`--tmpdir
`** — the `write_noexec` sibling of `--exec-tmp`, and the
+ replacement for reading `$TMPDIR`. oring sets the child's `TMPDIR` to what it
+ granted, or unsets it.
+
+### Changed
+
+- **BREAKING — hard Landlock floor raised from V7 (kernel 6.15) to V10 (kernel
+ 7.2).** oring refuses on any kernel below 7.2, with no degraded path. V10 is in
+ the floor for the UDP rights: below it Landlock cannot restrict UDP at all, so
+ DNS egress could only be disclosed, never gated. V9's `RESOLVE_UNIX` is in it
+ because Landlock has no hook on `connect()` to a pathname AF_UNIX socket below
+ V9, so no filesystem tier can gate `docker.sock` or ssh-agent. `docs/DESIGN.md`
+ §4, D-4b.
+
+- **BREAKING — UDP is restricted by default in both built-in profiles.**
+ `balanced` grants outbound `:53` only (with `ingress_udp: [0]`); `strict`
+ grants none, so **names do not resolve under `strict`**. Both now report
+ `unhandled=-` and pass `--require-full-coverage`. Not a closure of the DNS
+ residual: Landlock net is port-only, so `:53` still reaches any address.
+
+- **BREAKING — no built-in profile grants write on a shared package cache.**
+ `~/.cargo/{registry,git,.package-cache}`, `~/go/pkg`, `~/.npm` and `~/.cache`
+ are no longer writable. Cargo verifies a tarball once at download and never
+ again, so a payload planted in a cached `build.rs` executes at the operator's
+ uid in an unrelated project. **What breaks:** fetching an uncached dependency —
+ loudly, naming the crate. Pre-warm outside the cage, or grant one deliberately
+ with `--policy`.
+
+- **BREAKING — `--features resolve-unix` is required**, not optional; a build
+ without it stops at a `compile_error!`. `cargo install oring` still does not
+ build, now because the floor needs V10, which no release carries.
+
+- **Ring 1 is no longer vendored.** `third_party/rust-landlock` is deleted;
+ `[patch.crates-io]` points at `eslerm/rust-landlock` pinned by **revision**, so
+ the input cannot move under a build. Verified against the deleted tree: one
+ file differs, by eleven lines, all doc comments. RustSec still cannot scan it —
+ `[advisories]` is keyed to registry packages — and `deny.toml` allows the
+ source explicitly. **Cost:** a fresh clone now needs the network.
+
+- **History rewritten before this release.** Every commit predating `v0.2.0` has
+ a new SHA: messages carrying absolute home directories and machine names were
+ rewritten, because a commit message publishes and cannot be unpublished. Only
+ messages changed — tree hashes and commit counts were verified identical before
+ the backups were deleted. `make hooks` installs a guard; `make check-history`
+ sweeps every ref.
+
+### Removed
+
+- The `RESOLVE_UNIX` opt-in posture and its per-seal residual warning — the
+ condition it reported is now a refusal. `warn_on_sockets` is retained as a
+ tripwire against a future floor lowering.
+
+- The pathname-AF_UNIX residual and the `unix-connect-*` cells' build gating:
+ those are now unconditional proof cells.
+
+### Fixed
+
+- **The x32 guard's test coverage could not see the guard break.** The guard is
+ one BPF instruction; mutating it killed x32 `getpid`, allowed every other x32
+ syscall, and left the suite green — every cell probed one syscall number. Fixed
+ at the model level: `x32_guard_matches_its_spec_over_a_dense_table` asserts the
+ emitted program equals its specification over 12,354 points, killing all 89
+ enumerated mutants. Adding process cells at more numbers cannot close the class.
+ `docs/VERIFICATION.md`.
+
+- **The `NO_NEW_PRIVS` invariant was described as double and has three legs, one
+ dead.** `seccompiler` sets `PR_SET_NO_NEW_PRIVS` itself before installing the
+ filter, so the documented `EACCES`-without-NNP leg can never fire through
+ oring, and `preexec::set_no_new_privs()` is defence-in-depth rather than the
+ sole mechanism. Recorded so it is not deleted on the strength of a green run.
+
+- **git was unusable in-cage for anyone whose config lives in
+ `$XDG_CONFIG_HOME`.** `~/.config/git/{config,ignore,attributes}` are now
+ granted `read_only`. No new exposure — `~/.gitconfig` was already granted and
+ it is the same content. It failed *fatally* rather than degrading because
+ Landlock hooks the open and not the metadata check, a combination DAC cannot
+ produce; that class is a residual and is not closeable here.
+
+- **`--profile balanced` could not resolve DNS on a systemd host.**
+ `/etc/resolv.conf` is a symlink into `/run` and Landlock rules the resolved
+ inode, so the `/etc` grant never reached it — and glibc then fell back to
+ `127.0.0.1:53`, silently resolving through a resolver the operator never chose.
+ Fixed by granting the literal path.
+
+- **The abi-ahead warning could be switched off by improving the crate.** It
+ keyed on whether the crate could *name* a right rather than on whether anything
+ was unrestricted, so bumping to V10 silenced it while UDP stayed open. Now
+ derived from the handled set.
+
+- **Peer `/proc//environ` is walled for an out-of-domain peer** (ATTACKS row
+ 12) — and **not** for a sibling: `domain_scope_le()` is ancestor-or-equal, so
+ two children of one seal read each other's `environ`. The denier consults no
+ handled right and predates the 5.13 merge, so no floor bought it. Measured, not
+ assumed. `maps`/`mem` have no cell yet; peer metadata remains a residual.
+
+- Scratch-directory validation is split from creation, so `--explain` rejects a
+ plan the real run would reject instead of previewing it as sound.
+
+[rust-landlock#135]: https://github.com/landlock-lsm/rust-landlock/pull/135
+
## [0.1.0] - 2026-07-24
### Added
@@ -43,3 +235,4 @@ target is never executed), `unsafe` isolated to one audited module, and panic-fr
on the production path. BSD-3-Clause.
[0.1.0]: https://github.com/hexproofdev/oring/releases/tag/v0.1.0
+[0.2.0]: https://github.com/hexproofdev/oring/releases/tag/v0.2.0
diff --git a/CLAUDE.md b/CLAUDE.md
index af3ce5f..257ed20 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -6,8 +6,32 @@ denylist to *itself*, sets `NO_NEW_PRIVS`, scrubs FDs and signal state, then
`execve`s the target — which inherits every restriction irrevocably and can
never widen it. x86_64 Linux only, enforced by a `compile_error!` (the seccomp
arch guard is x86_64-specific). Static-musl, no root, no daemon.
-Read `docs/DESIGN.md` before changing anything: it carries the threat model, the
-two rings, the seal order, and the residuals oring cannot close. v1.
+v1.
+
+## What to read before changing something
+
+`docs/DESIGN.md` used to carry all of this and this line used to say "read it
+before changing anything" — which meant loading ~34k tokens to change one line.
+It was split on 2026-08-01 by what the reader came to do. **Section numbers did
+not change**, so every `DESIGN §n` citation still resolves, and
+`docs/DESIGN.md` carries the table that says which file holds which section.
+
+- **Always:** `docs/DESIGN.md` — architecture, the `§n` index, deployment,
+ packaging, status. Small enough to keep in context.
+- **Touching the seal, either ring, or the pre-exec order:**
+ `docs/INTERNALS.md` (§4 Landlock, §5 seccomp, §6 the seal order). **§6 first.**
+- **Touching policy fields, profiles, or the CLI:** `docs/REFERENCE.md`
+ (§7 schema, §8 flags).
+- **Making or changing a claim about what oring protects against:**
+ `docs/RESIDUALS.md` (§2 — the threat model, the guarantees, every residual with
+ its loss and disclosure tag, the non-goals). Gated by
+ `tests/design_residuals.rs`.
+- **Wondering why something is the way it is:** `docs/RATIONALE.md` (§14, `D-n`).
+ Meet the argument, not the absence.
+- **Correcting a published claim:** the current state goes in the document, the
+ account of what was wrong goes in `docs/CORRECTIONS.md` as a dated `C-n`, and
+ the document cites it as `[corrections: C-n]`. Do not re-litigate in place —
+ that is what made §2 a third of the design document.
## The discipline
@@ -41,30 +65,70 @@ two rings, the seal order, and the residuals oring cannot close. v1.
`expect`, `panic`, `unreachable`, `todo`, `indexing_slicing`, `string_slice`,
and `arithmetic_side_effects`. No panicking sites on the seal path; tests may
unwrap freely.
-- Hard kernel floor: Landlock **V7 / 6.15**. Below it oring refuses rather than
- run a partial cage — there is no degraded path. ABI V9 (`RESOLVE_UNIX`, which
- walls `connect()` to a pathname AF_UNIX socket) is *opt-in* hardening behind
- `--features resolve-unix` + an out-of-tree V9 fork (rust-landlock#135); without
- it that one residual is unmediated and reported loudly at seal.
+- Hard kernel floor: Landlock **V10 / 7.2** (`FLOOR_ABI`, `src/landlock.rs`).
+ Below it oring refuses rather than run a partial cage — there is no degraded
+ path. Two rights are why the floor sits where it does. `RESOLVE_UNIX` (V9) walls
+ `connect()` to a pathname AF_UNIX socket (docker.sock / ssh-agent): Landlock has
+ **no hook on that connect below V9**, so beneath it no FS tier can gate the class
+ — withholding a `/tmp` grant only obscures the socket [D-4b]. The UDP net rights
+ (V10) are what let the profiles restrict DNS instead of disclosing it as
+ unhandled. Through v0.1 `RESOLVE_UNIX` was an opt-in feature with a loud residual
+ warning; that was the degraded-path-with-a-warning pattern [D-4] rejects
+ everywhere else, so v0.2 folded it into the floor and the V10 raise followed the
+ same argument. **The ABI is a kernel property** — a V10-capable crate on a 6.15
+ kernel still detects V7 and refuses. Price, stated plainly: every pre-7.2 kernel
+ is refused, and until upstream ships these rights in a release the crate must
+ come from the out-of-tree fork, so `cargo install oring` does not build.
- The child's environment is an allowlist, built fresh — never scrubbed
(`BASE_ENV_KEEP` in `src/main.rs`). Credentials are withheld by construction,
not by enumerating what to drop.
-- Dependencies stay minimal and musl-clean: `landlock` (crates.io, tops at V7),
- `seccompiler` (Firecracker's pure-Rust BPF emitter), `libc`, `serde`. The
- checked-in build is a clean registry dependency that RustSec / `cargo deny`
- cover; the shipped binary embeds an auditable SBOM (`cargo audit bin oring`).
+- Dependencies stay minimal and musl-clean: `landlock`, `seccompiler`
+ (Firecracker's pure-Rust BPF emitter), `libc`, `serde`. The clean-registry-
+ dependency guarantee (RustSec / `cargo deny` coverage) is **suspended for
+ `landlock` only**, and knowingly: the floor needs the **V10 UDP rights**, which
+ no published release names, so `landlock` comes from the out-of-tree fork until
+ upstream releases them — at which point bump the version, delete the
+ `resolve-unix` feature and its `compile_error!`, and the guarantee is restored
+ with no behaviour change. **`AccessFs::ResolveUnix` (V9) is no longer part of
+ that reason:** upstream released it in v0.4.7, which is the release vendored in
+ the pinned fork. This line said "the published crate (V8 as of 2026-07) cannot
+ name" both rights until 2026-08-01; it was true when written, and citing V9 as
+ a cause of the suspension is now wrong in a way that would license deleting the
+ fork the day someone re-checked it. The shipped binary still embeds an
+ auditable SBOM (`cargo audit bin oring`).
+
+ Same reason, harder consequence: **oring cannot be published to crates.io** as
+ it stands. crates.io rejects git dependencies, and cargo drops
+ `[patch.crates-io]` on publish, so the packaged tree compiles against the
+ registry `landlock` and fails — measured 2026-08-01, `cargo package` stops with
+ 5 errors on the absent `AccessNet::{BindUdp,ConnectSendUdp}`. "`cargo install
+ oring` does not build" is the weaker half of this and was the only half stated.
## Standing rejections
Each was argued down in `docs/DESIGN.md` — meet the argument, not the absence.
Sub-floor degraded ABI paths (`--allow-net-degrade`, the scope/audit skips): gone
-— below V7 oring refuses with one clear message [D-4]. `deny_extra`/`allow_extra`
+— below the floor oring refuses with one clear message [D-4, D-4b]. `deny_extra`/`allow_extra`
config fields: cut from v1, because validating syscall names would drag a
name→number arch table onto the sole config-parse path [D-19]. A bundled
credential broker: wrong shape — a daemon-free, unprivileged, self-sealing binary
cannot hold a key on a child's behalf; that needs root, a persistent process, or
a netns. oring does blast-radius reduction, not credential brokering. Sealing a
-multithreaded process: refused — `restrict_self`/seccomp are per-thread and v1
-carries no TSYNC. Config fuzzing: deferred — the policy parser sits on a
-parent/config trust boundary, not an attacker-controlled path, and `panic=abort`
-makes any parse bug fail closed.
+multithreaded process: refused **as a choice, not a constraint** — both rings can
+now do it (`LANDLOCK_RESTRICT_SELF_TSYNC` at ABI V10; `seccompiler`'s
+`apply_filter_all_threads`, present in the pinned 0.5), so the old "they are
+per-thread and v1 carries no TSYNC" argument is dead. What stands is that a
+single-threaded sealer is simpler, the CLI has no threads, and the
+`/proc/self/task` guard fails closed for the embedder path. The open question is
+whether TSYNC sealing would be *safe*: Landlock's flag overrides sibling threads'
+configuration irrespective of previously established domains, which is stronger
+than anything oring does elsewhere. Config fuzzing: deferred on **cost**, and the
+label that used to license it is retracted — this line read "not an
+attacker-controlled path" until 2026-07-30, which `docs/DESIGN.md` §12 corrected on
+2026-07-29 and this second copy did not follow: `ORING_POLICY` is ambient, `main.rs`
+refuses `--profile` beside it *precisely because* an untrusted earlier step can set it,
+and policy-derived bytes reached 13 of ~14 diagnostic sites unsanitised until
+2026-08-02, when `crate::diag::safe` and the `OringError::Display` chokepoint
+closed it (T26). `panic=abort`
+still makes a parse bug fail closed — on `[profile.release]` only; debug and test
+builds unwind. Trust boundaries have one home: `THREAT_MODEL.md` §1.
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..0fc9892
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,136 @@
+
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, caste, color, religion, or sexual
+identity and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes,
+ and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the overall
+ community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or advances of
+ any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email address,
+ without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official email address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the maintainer at **security@hexproof.dev**. All complaints will be
+reviewed and investigated promptly and fairly.
+
+
+
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+**What to expect.** oring is maintained by one person. A report is acknowledged
+within 7 days. Where a report concerns the maintainer, say so and it will be
+referred to a neutral third party before any decision is taken — a
+single-maintainer project cannot investigate itself, and pretending otherwise
+would be worse than saying so here.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series of
+actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. Violating
+these terms may lead to a temporary or permanent ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. Violating these
+terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within the
+community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.1, available at
+https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
+
+Community Impact Guidelines were inspired by
+[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
+
+For answers to common questions about this code of conduct, see the FAQ at
+https://www.contributor-covenant.org/faq. Translations are available at
+https://www.contributor-covenant.org/translations.
+
+[homepage]: https://www.contributor-covenant.org
+[Mozilla CoC]: https://github.com/mozilla/inclusion
diff --git a/CONSTRAINTS.md b/CONSTRAINTS.md
new file mode 100644
index 0000000..4bc98db
--- /dev/null
+++ b/CONSTRAINTS.md
@@ -0,0 +1,265 @@
+
+# CONSTRAINTS — what oring must enforce, and what enforces it
+
+**Living document, gated in-tree.** `cargo test --test constraint_register` checks every
+row mechanically, so it runs in `make check` with no workflow change. It also
+runs in CI, which checks the half a sub-floor runner can — this register is
+prose and needs no kernel. `docs/DESIGN.md` §2 derives the loss structure; this is what you read
+to find out which of it is actually held up by an artifact.
+
+**This file names defects that are not fixed.** That is the point of it — a register
+whose `gaps` fields are empty is a register nobody is using. Read `status` and `gaps`
+before you read any guarantee elsewhere in this repository.
+
+## Read this before trusting any row
+
+**Ids are `CON-n`, and that is not oring's `D-n`.** `docs/DESIGN.md` carries a decision
+table of 28 `D-n` rows; those are design decisions, this is a constraint register, and
+the two namespaces are unrelated. They were `C1`–`C4` for three hours on 2026-07-28
+until that collided with an attack taxonomy's `cat-C1`–`cat-C6` in the maintainer's
+research corpus, which is the whole reason the prefix is long and boring.
+
+**Why a constraint register and not a risk register.** A risk register needs a loss
+magnitude and a probability. oring has neither: no loss here is quantified in units
+anyone cares about, and a *design* defect has no failure rate — the `--proc none`
+symlink case fires 100% of the time given a symlink. Registers of that shape also grow
+monotonically and never retire a row. A constraint is a testable statement with an
+artifact or an admitted gap, so this one converges: rows close, and the retirement rule
+at the bottom requires that they be deleted when they do.
+
+## The two columns that are usually one
+
+**ENFORCED BY** and **OBSERVABLE VIA** answer different questions, and conflating them
+is a mistake this project has paid for more than once. A constraint can be *enforced
+and invisible* (a withheld path still reaches the child on fd 0, with a seal line
+byte-identical to the run that denied it), or *reported and false* (`shared_cache_write=none`
+printed by a function that could not know). *Enforced* is a property of the system's
+behaviour; *observable* is a property of what the operator can see. **A controller can
+only act on what it observes**, so a row with an enforcement artifact and no instrument
+is not finished.
+
+## Status vocabulary — a closed set, checked
+
+| status | meaning |
+|---|---|
+| `enforced` | an artifact enforces it **and fails when the mechanism is removed** |
+| `enforced-unpinned` | the system enforces it, but nothing fails if that changes |
+| `unenforced` | no artifact; the constraint is a statement of intent |
+| `unenforced-by-decision` | deliberately not enforced; the row records the decision |
+
+`enforced` is a claim about mutation, not about a green suite. Where a row claims it,
+`last-verified` says what was mutated and what turned red.
+
+## Derivation — the gate checks BOTH directions against this list
+
+The first version of this register walked only the loss level, and its gate could not
+see that: the gate enumerated rows and asked whether each named a loss, so **a loss
+with no constraint was invisible by construction** and the check returned clean. It now
+reads this section too, and fails if any entry below is named by no row.
+
+### Losses
+- `BREACH` — the confined child obtains a capability the policy did not grant
+- `LAUNDER` — a bounded hazard is made broader or longer-lived
+- `DIVERGE` — the confined tool produces a wrong result the operator relies on
+
+### Hazards
+- `HZ-REACHABLE` — an ungranted capability is reachable
+- `HZ-DIVERGENCE` — confined and unconfined behaviour differ under one configuration
+- `HZ-INSTRUCTED` — the operator is instructed into the hazard
+
+### Feedback defects (multipliers, not ranks — `MISREPORT` is worse than `OPAQUE`)
+- `MISREPORT` — oring's output asserts a state a same-run measurement contradicts
+- `OPAQUE` — a failure names neither the withheld resource nor the sandbox
+
+A feedback defect multiplies a loss rather than ranking beside one: it is what stops the
+operator from noticing the loss happened. It is listed here because the gate resolves
+loss names in `docs/DESIGN.md` §2 residual tags against this section, and residuals do
+name them.
+
+---
+
+## CON-1 — Every capability the child obtains is named by the policy, or is named on the seal line as unrestricted
+
+- **derives-from:** `BREACH`
+- **controller:** oring
+- **enforced-by:** `tests/containment_matrix.rs`, `tests/deny_fs.rs`, `tests/deny_net.rs`, `tests/deny_syscall.rs`, `tests/deny_strict.rs`, `tests/protocol_wall.rs`, `src/seccomp.rs` (`x32_guard_matches_its_spec_over_a_dense_table` — 12,354 points, kills 89 of 89 enumerated mutants), `src/landlock.rs` (`tier_invariants`)
+- **observable-via:** `oring --denials `, and the `SEALED` line's `grants=` / `unhandled=` — but note both instruments are **inside CON-5's scope**, so their integrity is that row's problem and not this one's
+- **status:** `enforced`
+- **last-verified:** 2026-07-29 — 140 pass under `cargo test --locked --features resolve-unix` (27 suites, 0 failed, 2 ignored), plus 3 in `seccomp_smt` under `bpf-dump` with z3 5.0.0. Identical count with and without `ORING_REQUIRE_KERNEL=1` on this host (7.2.0-rc4, at the V10 floor), so **nothing skipped here — which is exactly why this number is a weak instrument on a sub-floor host.** Note also that **6 of the 140 are the two doc gates added alongside this register and exercise none of oring's behaviour**: a total that mixes confinement cells with document checks is a worse signal than either alone, and it went up by 6 on a day no confinement code changed
+- **gaps:** **Missing — invocation-conditional coverage.** `tests/containment_matrix.rs` early-returns unless `ORING_REQUIRE_KERNEL=1`; measured, a bare `cargo test` on a sub-floor host reports green in 0.00s **having run 0 of 31 cells**. `make check` sets the variable; CI cannot, because no hosted runner is at the floor.
+
+ **Missing — composition.** One pair is exercised (`tcp-wall-both-rings`, with a reachability control), and it is **not proven to catch anything the per-mechanism cells miss**: dropping `SYS_sendto` fails this cell *and* `msg_fastopen_is_blocked`, and no mutant isolating the conjunction was found. All three known composition instances are CON-1 violations no per-mechanism cell can reach.
+
+ **Not evidence — `tests/deny_x32.rs`.** Its own header forbids citing it as proof: a measured mutant leaked six x32 syscalls while every process cell stayed green. The model-level check is what covers that class.
+
+ **Closed since this row was written**, listed because the row previously carried the argument and a reader should not re-open it: the in-domain `--proc self` pair (T19), the FD scrub at both ends (T10), `NO_NEW_PRIVS` (T15), and per-cell attribution against a bare control (T21). The closure narratives are in `CHANGELOG.md`.
+
+- **would-change:** whether the first tagged release ships
+
+## CON-2 — No run leaves the system with a broader or longer-lived exposure than it found
+
+- **derives-from:** `LAUNDER`
+- **controller:** oring
+- **enforced-by:** `src/profile.rs` (`no_profile_grants_write_on_a_shared_package_cache`), `src/policy.rs` (`every_named_shared_cache_is_detected_individually`), `tests/self_protection.rs`
+- **observable-via:** the `SEALED` line's `shared_cache_write=` — **unreliable, see gaps**
+- **status:** `enforced`
+- **last-verified:** 2026-07-28 — mutation-measured, which is the only evidence that supports this status: adding `write_noexec.push(h.join(".cargo/registry"))` back into `balanced()` turns `no_profile_grants_write_on_a_shared_package_cache` **red**. **Re-measured 2026-07-29 and it still turns red**, but the recipe changed and is now TWO lines in TWO places: `write_noexec` is no longer bound `mut` (that keyword was residue from the grant removal and had been leaving a warning in the build), so the mutation is (1) restore the `mut`, and (2) push the grant **inside the `if let Some(h) = home()` block**, not at the binding — `h` is not in scope there. Stated because the first attempt put it at the binding, **did not compile, and produced no failing test** — and a mutation that fails to build looks exactly like one the suite caught if you only check for a red run
+- **gaps:** **This row read `enforced-unpinned` for a day, on the written claim that "nothing fails if a grant is added back". That claim was false when written** — the pinning test had landed the day before. Wrong-when-written, not stale, and recorded here because that is the distinction this project applies to everyone else's claims. What survives: the tiers that test does *not* cover are still unpinned, and there is no property asserting that the shipped profiles leave `~/.ssh` out of **every** tier — a convenience grant added to `balanced()` would keep the whole suite green while falsifying examples in `docs/DESIGN.md` §2. **The disclosure half is CON-5's:** `shared_cache_write=` reports `none` in cases where the function could not know, and a decoyed `HOME` defeats it outright.
+- **would-change:** whether the shared-cache decision is closed in the artifact rather than in the design
+
+## CON-3 — The confined tool's effect equals its unconfined effect under the same configuration, or the difference is reported
+
+- **derives-from:** `DIVERGE`
+- **controller:** oring
+- **enforced-by:** *(gap — the constraint is still unenforced, but the COUNTEREXAMPLE is now pinned)* `tests/residual_boundary.rs` (`access_says_readable_where_open_is_denied`) measures the canonical `DIVERGE` instance with three arms and one variable each: unconfined both calls succeed; confined with the path withheld **`access(R_OK)` reports it readable while `open()` is denied**; confined with the path granted, neither diverges. `tests/signal_dispositions.rs` carries a genuine bare arm but is **disposition-valued, not errno-valued**; `tests/agent_survival.rs` is one-sided. `tests/agent_survival.rs` (`the_confined_result_equals_the_unconfined_result`) is the **positive arm**: identical inputs, `git write-tree` confined under `balanced` and bare, and the content-addressed hashes must match. `write-tree` is the observable precisely because it folds in only names, modes and blob contents — a commit hash carries author and two timestamps and would differ for reasons unrelated to confinement.
+- **observable-via:** *(gap)* no `env=` token on the `SEALED` line, and no environment section in `--explain`
+- **status:** `unenforced`
+- **last-verified:** 2026-07-29 — measured, not read. The divergence: unconfined `0`, confined-withheld `10` (access OK / open DENIED), confined-granted `0`. Cause verified against **this kernel's own** `security/landlock/fs.c`: `file_open` registered, `inode_permission` **0**, `inode_getattr` **0**, so there is no hook an ABI bump could add a right to. Also `command grep` over `tests/*.rs`: the two bare arms in-tree (`protocol_wall::run_payload_bare`, `socket_bare`) both return `i32`
+- **gaps:** **Not closeable at this layer.** oring cannot know which paths a tool will pre-flight, so it cannot report a divergence per run. Making a path read as *absent* is a mount-namespace capability; Landlock's only vocabulary is deny.
+
+ **The status vocabulary has no word for this row.** There IS an artifact, but it pins the **counterexample**, not the constraint: `unenforced` says *no artifact*, which is false, and `enforced` would also be false. Kept at `unenforced` because the constraint does not hold, rather than inventing a fifth status for one row.
+
+ **Both arms exist and they disagree, which is the honest state.** The positive arm passes over a bounded domain — one tool, one operation, one profile — while the pinned counterexample is real and unreported. What the pin buys is that the divergence can no longer change *silently*: if a kernel closes it, the test fails and the residual must be rewritten.
+
+- **would-change:** whether oring may claim effect-equivalence at all
+
+## CON-5 — Every difference between the confinement the policy names and the confinement actually installed is refused before `execve`, or disclosed on stderr in a form neither the child nor `ORING_QUIET` can suppress
+
+- **derives-from:** `MISREPORT`
+- **controller:** oring
+- **enforced-by:** `tests/udp_wall.rs` (`quiet_cannot_suppress_the_unhandled_disclosure` — `ORING_QUIET` arrives from the environment, i.e. from the party the threat model calls the adversary, and it suppresses the status line but **not** the under-coverage report), `src/landlock.rs` (`below_floor_boundary_is_fail_closed`), `src/denials.rs` (`requires_the_audit_transport`), `tests/residual_boundary.rs` (`the_under_coverage_disclosure_is_discardable_by_the_caller` — pins the LIMITATION, not the guarantee), `tests/udp_wall.rs` (`require_full_coverage_refuses_when_a_right_is_unhandled`, `require_full_coverage_seals_when_every_right_is_handled`, `the_coverage_refusal_survives_stderr_being_discarded` — the **refused-before-`execve`** limb, reached by the opt-in `--require-full-coverage`)
+- **observable-via:** *(gap — structural, not an omission)* **every instrument oring ships is inside this constraint's own scope.** Any observer that could falsify it must be a second, out-of-band measurement of child state.
+- **status:** `enforced-unpinned`
+- **last-verified:** 2026-07-29 — source read. The `SEALED` line has **13** substituted fields (`proc=` added this day), and **two** now carry an assertion: `unhandled=`, which compares the printed token to the *request*, and `proc=`, which is **derived from the installed tiers** specifically so it can disagree with `--proc` — measured, a `--policy` granting `/proc/cpuinfo` with no flag reports `custom:1`. `nnp=1` remains a literal
+- **gaps:** **Missing — the disclosure channel is suppressible by the caller.** The statement was narrowed on 2026-07-29 because it was false as written: it claimed a channel the run cannot suppress. Measured on `balanced` at V10 — with fd 2 piped the `unhandled=` line is present; with `2>/dev/null` **zero** disclosure lines appear and the seal proceeds. `ORING_QUIET` is refused; a shell redirect is not.
+
+ **Not closeable — there is no second channel.** The write to `/dev/null` succeeds, so no error check inside oring can detect it. `src/denials.rs` is a *consumer* of kernel-emitted records; a producer needs `CAP_AUDIT_WRITE` an unprivileged tool lacks; and `/dev/log` would be grantable only by the policy under audit, because the disclosure is emitted after the seal.
+
+ **Mitigated, opt-in.** `--require-full-coverage` turns any unhandled right into refusal code 120 before `execve` — an exit code is the one signal a redirect cannot remove. It is not the default, so the gap stands for every default run.
+
+- **would-change:** whether `--check` may be documented as a CI gate; whether a disclose-rather-than-refuse decision may rest on a disclosure that is false as an audit record
+
+## CON-6 — Every grant in force was authored explicitly, and no ambient source widens or overrides an authored one, or the run refuses to seal
+
+- **derives-from:** `HZ-REACHABLE`
+- **controller:** oring
+- **enforced-by:** `tests/self_protection.rs`, `tests/policy.rs`, `tests/policy_props.rs`, `tests/tmpdir_not_from_env.rs`, `tests/exec_tmp.rs`
+- **observable-via:** `oring --explain`, and refusal codes 120–122
+- **status:** `enforced`
+- **last-verified:** 2026-07-29 — source read across 41 test functions, **none of which enforced a register row before this one existed**
+- **gaps:** **The rule is authored vs ambient, not trusted vs hostile.** Authored: `--policy`, `--profile`, `--tmpdir`, `--exec-tmp`. Ambient: `ORING_POLICY`, `$TMPDIR`, `$HOME`. `docs/DESIGN.md` asserted the caller was *trusted* in two places while three shipped guards refused ambient widening — the register stated the rule before the design document did.
+
+ **Any future ambient read is a candidate violation by construction**, which is what makes this row worth more than its test list. Two measured escalation instances: a `~/.cargo/bin` `$PATH` over-grant, and `TMPDIR=$HOME/.ssh --profile balanced` sealing with write on `authorized_keys`.
+
+ **The prediction it made has since been confirmed and closed.** This field named `writable_shared_caches` reading `$HOME` as a candidate; a decoyed `HOME` did defeat the `shared_cache_write=` disclosure, and it was fixed on 2026-08-02 by taking the union of the passwd home and `$HOME` (T25, `tests/decoy_home.rs`). The row is retained because the *class* is open, not the instance.
+
+- **would-change:** whether any profile may derive a tier from caller-supplied state
+
+## CON-7 — Every control oring's documentation names for a residual is demonstrated by a fixture that detects that residual and leaves the payload unfired
+
+- **derives-from:** `HZ-INSTRUCTED`
+- **controller:** the operator, whose process model oring's documentation supplies — **the documentation is the control action, not the controller**
+- **enforced-by:** *(partial — 3 of 6)* `tests/control_efficacy.rs` demonstrates two: the **workdir-review recipe** against the `core.fsmonitor` poisoning (the retracted `git` review fires the payload and never names it; the documented recipe detects it and leaves it unfired), and **`oring --explain`** against the `access()`/`open()` divergence (names the granted tier, not the withheld path, and executes nothing — checked against a positive control that runs the same command without `--explain`). `tests/containment_matrix.rs` demonstrates the third, **`--proc self`**, via the matched `proc-indomain-env-full` / `-self` pair. Every one carries its own vacuity arm. Red-tested: three mutants for the recipe, three for `--explain`, all confirmed red. **Three controls remain undemonstrated, and two of those cannot be demonstrated here.**
+- **observable-via:** greppable, and now gated — `tests/design_residuals.rs::every_named_control_cites_a_fixture_or_is_a_declared_exemption` fails when a residual names a `**Control.**` and cites no fixture unless it is a declared exemption, and **also** fails on a stale exemption, so the list can only shrink. Measured 2026-08-02: 6 residuals name a control, **5 cite a fixture**, 1 is a declared exemption
+- **status:** `unenforced`
+- **last-verified:** 2026-07-28 — measured: `git status` and `git diff` return **empty output at exit 0** while `core.fsmonitor` fires the planted payload **four times during the review itself**, at the operator's uid, outside the cage
+- **gaps:** **Still `unenforced`, and the word is exact: the constraint says EVERY named control, and 3 of 6 are demonstrated.** What changed on 2026-08-01 is that the gap is counted and cannot grow silently.
+
+ **Two of the remaining three are not demonstrable in this tree at all** — a host firewall and a container runtime are companion controls outside oring — so the end state is four fixtures plus two stated exemptions, not six fixtures. Deciding that is `would-change` on this row.
+
+ **The citation gate is the weaker half and knows it.** Before the first fixture landed, 2 of 6 residuals cited a `tests/` path and **both citations were of the residual rather than of the control** — one pins a *limitation*, which is the opposite of demonstrating a mitigation. A field that repeats the request cannot disagree with it, the same weakness this register records about `unhandled=` at CON-5.
+
+ **This row exists because the same sentence was retracted twice in two days** — first "run the first build inside the cage", then "review with `git`", which *is* the execution. A control that fires the hazard it detects is worse than none, because it is performed with confidence. Both accounts are in `docs/CORRECTIONS.md` (C-10, C-11).
+
+- **would-change:** whether `docs/DESIGN.md` §2 may name a control that cites no fixture
+
+## CON-8 — The confined tool still completes its legitimate work under the shipped profiles
+
+- **derives-from:** `HZ-DIVERGENCE`
+- **controller:** oring
+- **enforced-by:** `tests/agent_survival.rs` (a real `cargo build` and a real `git init` + commit under the shipped profile with a harness-owned `$HOME`), plus roughly 14 still-allowed control cells across `tests/deny_syscall.rs`, `tests/protocol_wall.rs`, `tests/deny_strict.rs`, and — added 2026-07-29 — `name_resolution_survives_under_balanced` and `the_profiles_udp_posture_holds_at_the_socket_layer`
+- **observable-via:** *(gap — partial)* the child's exit status, which **collides with oring's refusal codes 120–122**, so that signal alone cannot distinguish a confined tool that failed from a run oring refused
+- **status:** `enforced`
+- **last-verified:** 2026-07-29 — re-verified against the UDP tightening (see gaps): 171 passed, 0 failed, `make check` rc=0, and the two new cells each shown to go red under mutation. The same caveat as CON-1 applies about what a suite total mixes together
+- **gaps:** **Missing — no tightening is gated against this row.** Every tightening of a profile is a candidate violation, and nothing runs the survival suite as a precondition for one.
+
+ **This row is the surviving half of a deleted pair.** `CON-4` asserted a run does not deny service to the *host*; it was retired on 2026-08 as out of scope, because host resource exhaustion is the runtime's job. This half asserts the run still works for the *child*, and nothing else protects the confined tool from oring's own tightening.
+
+ **The fixture has been blind once, measured.** When UDP became a profile default, every existing `agent_survival.rs` cell built its own policy and so could not see it — the suite stayed green through a change that broke name resolution under `strict`. Fixed by adding cells that exercise the shipped profiles; recorded because the same shape will recur with the next default.
+
+- **would-change:** whether any tightening ships
+
+## CON-9 — On a substrate that cannot enforce the policy, oring refuses rather than sealing partially
+
+- **derives-from:** `OPAQUE`
+- **controller:** oring
+- **enforced-by:** `src/landlock.rs` — `below_floor_boundary_is_fail_closed` pins the boundary including `abi=0` and `abi<0`; `floor_and_abi_maps` asserts `FLOOR_ABI == 10` as a literal and pins the `resolve_unix_active` pairing, so no kernel oring agrees to seal on can run with the AF_UNIX class unmediated — at V10 that wall is guaranteed with a margin rather than by equality
+- **observable-via:** refusal code 120, and the `abi=` / `errata=` seal fields
+- **status:** `enforced`
+- **last-verified:** 2026-07-29 — source read; `RulesetStatus::NotEnforced` and `RulesetStatus::PartiallyEnforced` are both hard refusals
+- **gaps:** This is the constraint that makes the V10 floor a **product decision** rather than a build flag, and it is the one a widen-reach pressure would trade first — which is the argument for naming it before that pressure arrives rather than after. `errata=` is a **lower bound, not a certificate**.
+- **would-change:** whether the V10 floor may be lowered for reach
+
+---
+
+## The adversary inside the machinery
+
+`docs/DESIGN.md` §2 carries a second residual subsection — *what could be wrong with
+ORING ITSELF* — and **no row above covers its class.** oring is the child: there is no
+process boundary between sealer and sealed, the sealing library is vendored out-of-tree
+and unaudited, seccompiler's lowering is not re-proven here, and `panic = "abort"` is
+`[profile.release]` only, so "a parse bug fails closed" does not hold for a debug or
+test build.
+
+**A constraint for that class arguably cannot have an artifact from inside the seal**,
+which is the same structural problem CON-5's `observable-via` states: any check oring
+runs on itself is inside its own scope. It is recorded here as a named absence rather
+than given a row that no artifact could ever fill.
+
+## The number worth tracking
+
+**Fraction enforced: 5 of 8.**
+
+**It was 5 of 9 an hour earlier and no work landed.** Retiring `CON-4` moved the
+denominator and not the numerator — the same property this section already warned about
+in the other direction, now demonstrated on itself. Read as a burn-down it would show
+progress that did not happen. It describes the register, not the tool.
+
+The numerator counts `enforced` **only** — CON-1, CON-2, CON-6, CON-8, CON-9. Counting
+`enforced-unpinned` as well would make it 6 of 8, because CON-5 is enforced by a
+mechanism nothing pins. Both readings are defensible and they differ, so **the gate
+recomputes the number from the `status` fields and fails if this line disagrees with
+it.** A hand-maintained count is exactly the kind of row this project has already
+watched drift four times.
+
+**It moved from "1 of 4" without a line of oring changing**, and that is a property of
+the metric worth stating rather than hiding: **it moves when work lands and when scope
+changes.** Three of the five were already true and unnamed; one was misclassified by a
+claim that was false when written. A burn-down read off this number across a scope
+change would report progress that did not happen.
+
+The **rate** at which rows move toward `enforced` needs a second dated measurement and
+is **absent rather than fabricated**.
+
+## Retirement rule
+
+**A row that would change no decision is deleted, not archived.** Every other document
+in this repository grows monotonically; this one is required not to.
+
+**It has now been applied once, which is the only evidence that it is real.** `CON-4`
+("a run does not deny service to the host") was **deleted on 2026-07-29** — not
+archived, and not left behind as documentation. It derived from a loss called `EXHAUST`,
+it had no enforcer and no instrument, and `docs/DESIGN.md` non-goal 6 already gave up
+cgroups and rlimits deliberately on the grounds that each *"needs a persistent or
+privileged component oring by definition is not"*. **0 of 113 attack rows carried any
+resource-exhaustion evidence.** It changed no decision, so it went.
+
+`EXHAUST` went with it, necessarily: the gate checks the derivation in **both**
+directions, so a loss no row claims fails the build. **Measured rather than asserted** —
+deleting the row while keeping the loss reports `derivation entries: 9, claimed by a
+row: 8, orphaned: 1` and fails with *"Loss \"EXHAUST\" is named by no constraint"*. The
+rule could not be half-applied, and that is the strongest argument for checking the
+reverse direction at all: keeping the loss would have been the natural half-measure, and
+it is now impossible.
+
+Three losses remain: `BREACH`, `LAUNDER`, `DIVERGE`.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..8dd7a45
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,96 @@
+
+# Contributing to oring
+
+## The gate
+
+```sh
+make doctor # prerequisites, and what is missing
+make check # fmt + clippy + deny + test + the seccomp SMT gate
+```
+
+**`make check` is the gate, and it is the authority.** CI runs make targets too,
+but it runs only the ones a sub-floor runner can honestly check — format, lints,
+dependency policy, the static build, the SMT gate, and the fail-closed refusal.
+No hosted runner is at oring's Landlock V10 / kernel 7.2 floor, so **CI witnesses
+no containment at all**. If you want to know whether the cage holds, run
+`make check` on a >=7.2 host; there is no substitute and CI is not one.
+
+**`make check` fails on a host below the kernel floor**, deliberately. The
+containment tests need Landlock ABI V10 (kernel 7.2), and a machine that cannot
+witness containment must not report that it did. Use `make test` for the
+skip-tolerant variant while developing; the gate is the thing that has to pass.
+
+Two slower targets, neither on the per-commit path:
+
+- `make check-full` — the gate with every `ORING_REQUIRE_*` skip made fatal,
+ plus the live-database advisory checks.
+- `make verify` — the Kani proofs (`x32_guard_total`, `abi_from_version_clamp`).
+ Minutes, and the only gate those two proofs have.
+
+## What a reviewer checks
+
+Read [`docs/DESIGN.md`](docs/DESIGN.md) for the map, then the file that covers
+what you are touching — [`CLAUDE.md`](CLAUDE.md) routes by subject. Beyond the
+gate, a review asks:
+
+- **Does it stay fail-closed?** Every step in `pipeline::seal()` is HARD (`?`).
+ Any error before the seal means the target is never executed. No branch skips
+ a ring; none execs while under-confined.
+- **Is the seal order intact?** NNP before seccomp. Landlock before seccomp. FD
+ scrub after Landlock. A wrong order is a confinement bypass, not a style nit —
+ `docs/INTERNALS.md` §6 says why each step precedes the next.
+- **Is there a runnable check?** A new guarantee needs one: a test, a proof, an
+ SMT gate, or a witnessed matrix cell. A property stated only in prose is an
+ open question — say so and open it rather than asserting it.
+- **Does the mutation test fail?** For anything claiming `enforced` in
+ `CONSTRAINTS.md`, remove the mechanism and confirm something turns red. A
+ green suite is not evidence that a gate can fail.
+- **Is `unsafe` still confined to `src/sys.rs`?** It is denied crate-wide. Every
+ raw op sits in its own `unsafe {}` block with a `SAFETY:` note.
+- **Is the production path panic-free?** `unwrap`, `expect`, `panic`,
+ `indexing_slicing`, `arithmetic_side_effects` and friends are denied outside
+ `cfg(test)`. Tests may unwrap freely.
+- **Is the rationale recorded where the code is?** When you reject an approach,
+ write down the rejection. Cuts that lose their context get re-proposed.
+
+## Documentation changes with behaviour changes
+
+A document changes in the same commit as the behaviour it describes. A document
+maintained separately is maintained late, and this project has paid for that
+more than once.
+
+Two rules that are enforced rather than asked for:
+
+- **Correcting a published claim?** The current state goes in the document; the
+ account of what was wrong goes in [`docs/CORRECTIONS.md`](docs/CORRECTIONS.md)
+ as a dated `C-n`, cited from the document as `[corrections: C-n]`. Do not
+ re-litigate in place.
+- **Counted claims are gated.** `tests/citation_handles.rs` and
+ `tests/design_residuals.rs` check numbers in the documents against the tree. If
+ you add a residual or a `BUILD-QUEUE.md T` handle, the count in the document
+ moves with it or the gate fails.
+
+## Commits
+
+- One concern per commit. The subject says what changed and, where it is not
+ obvious, why — `git log --oneline` is a review surface.
+- **No host-specific detail in a commit message.** No absolute home directories,
+ no machine names, no operator layout. Commit messages publish and cannot be
+ unpublished; paste measurement transcripts with `$HOME` substituted.
+- Say what you measured. "Measured 2026-08-01 on 7.2.0-rc4: …" is worth more
+ than a paragraph of reasoning, and it is what makes the claim checkable later.
+
+## Reporting a vulnerability
+
+Not here. [`SECURITY.md`](SECURITY.md) is the route, and a security issue should
+not arrive as a public pull request.
+
+## Licence of contributions
+
+oring is BSD-3-Clause. **By submitting a contribution you agree that it is
+licensed under BSD-3-Clause**, the same terms as the project — inbound equals
+outbound. There is no CLA and no copyright assignment; you keep your copyright.
+
+If you cannot make that grant for a piece of work — because an employer owns it,
+or because it derives from differently-licensed code — say so in the pull request
+before it is reviewed rather than after.
diff --git a/Cargo.lock b/Cargo.lock
index 5d4025b..7c6cd6e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -137,9 +137,8 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "landlock"
-version = "0.4.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "635839550ae8b90d9fd2571460a6645dc0aec070225956ca7a2831ed31d2795d"
+version = "0.4.7"
+source = "git+https://github.com/eslerm/rust-landlock?rev=d7bb0c1fac6b58f06be7718fe3fd0be8631356a1#d7bb0c1fac6b58f06be7718fe3fd0be8631356a1"
dependencies = [
"enumflags2",
"libc",
@@ -181,7 +180,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "oring"
-version = "0.1.0"
+version = "0.2.0"
dependencies = [
"insta",
"landlock",
diff --git a/Cargo.toml b/Cargo.toml
index 8d32135..896ea55 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "oring"
-version = "0.1.0"
+version = "0.2.0"
edition = "2021"
rust-version = "1.80"
description = "Unprivileged, self-applied, pre-exec confinement wrapper: Landlock + seccomp, then execve"
@@ -10,10 +10,19 @@ readme = "README.md"
authors = ["Mark Esler "]
keywords = ["landlock", "seccomp", "sandbox", "isolation", "rootless"]
categories = ["command-line-utilities", "os::linux-apis"]
-# The published crate builds against the registry `landlock` (V7). The vendored
-# fork + CI/dev scaffolding are not part of that build, so keep them out of the
-# .crate (docs-internal/ and spike/ are gitignored and already excluded).
-exclude = ["third_party/", ".github/"]
+# The published crate builds against the registry `landlock`. CI/dev scaffolding
+# is not part of that build, so keep it out of the .crate. (`third_party/` was
+# here until 2026-08-02, when ring 1 became a rev-pinned git dependency and the
+# vendored copy was deleted.) NOTE 2026-07-29: `docs-internal/` no longer exists here — its 75 review
+# documents were evidence, not product, and moved to the maintainer's research corpus
+# where they are tracked and gated. They were gitignored in this tree, which meant
+# 22,008 lines graded shipped claims from outside version control.
+# CLAUDE.md is agent/contributor instructions, not part of the document set a
+# stranger is expected to read, and it reached the published .crate until
+# 2026-08-01 (measured with `cargo package --list`). It carries no host paths,
+# so this is placement rather than disclosure — but a working file does not
+# belong in the artifact a consumer receives.
+exclude = [".github/", "bench/", "CLAUDE.md"]
[[bin]]
name = "oring"
@@ -31,24 +40,51 @@ name = "bpf-dump"
path = "src/bin/bpf_dump.rs"
required-features = ["bpf-dump"]
+# Analysis-only: the cost harness behind `bench/run.sh` (see `bench/README.md`).
+# Off by default, so a shipped build is unaffected. It lives in the package
+# rather than beside the scratch fixtures it replaced for ONE reason: `make
+# check` runs `cargo clippy --all-targets`, so this compiles on the everyday
+# gate and cannot rot as the code it measures moves. It calls
+# `seccomp::dump_groups` directly, so what it times is what `apply` installs.
+[[bin]]
+name = "bench"
+path = "src/bin/bench.rs"
+required-features = ["bench"]
+
[features]
bpf-dump = []
+# `bench` implies `bpf-dump`: the ring-2 measurements need the emitted programs,
+# and a `bench` build without them would compile to subcommands that refuse.
+bench = ["bpf-dump"]
# `resolve-unix` compiles in the ABI V9 / RESOLVE_UNIX path — the Landlock right
# that walls `connect()` to a pathname AF_UNIX socket (docker.sock / ssh-agent).
-# It requires a `landlock` crate that names ABI V8/V9 + `AccessFs::ResolveUnix`,
-# which the published crate does NOT yet have (it tops out at V7). That binding
-# comes from upstream rust-landlock PR #135 (landlock-lsm/rust-landlock#135) or
-# an equivalent V9 branch; until it is in a crates.io release, enabling this
-# feature needs an out-of-tree path override (see `.cargo/config.toml.example`)
-# and so `cargo install oring --features resolve-unix` will NOT build from
-# crates.io. WITHOUT this feature (the default, public build) oring runs at its
-# V7 floor and pathname-AF_UNIX connect is an UNMEDIATED, loudly-reported
-# residual. When upstream ships V9, bump the dep and this feature can default-on.
+#
+# As of v0.2 this feature is REQUIRED, not optional: a build without it fails
+# fast via `compile_error!` in src/landlock.rs rather than shipping a binary that
+# refuses at every seal. It stays expressed as a feature only so that failure is
+# one legible message instead of a pile of unresolved `ABI::V9` paths.
+#
+# WHAT CHANGED 2026-07-29, because the old text here was measurably false. It
+# said the feature "requires a `landlock` crate that names ABI V8/V9 +
+# AccessFs::ResolveUnix, which the published crate does NOT yet have (it tops out
+# at V7)". Upstream **released** that in v0.4.7 (rust-landlock#135), so the
+# published crate does have it, and the stated reason for the whole arrangement no
+# longer holds.
+#
+# CONSEQUENCE, stated plainly and still true, but for a DIFFERENT reason:
+# `cargo install oring` does not build against the registry alone, and ring 1 is
+# not a clean registry dep that RustSec / `cargo deny` fully cover. That is now
+# the price of the **V10** floor, not of RESOLVE_UNIX — the vendored tree exists
+# for the UDP rights (see `[patch.crates-io]`), which no release carries.
+#
+# OPEN, and deliberately not decided here: with V9 published, this feature no
+# longer gates anything a registry build lacks, so it is a candidate for deletion
+# in favour of gating on V10 or nothing at all. That is a packaging decision.
resolve-unix = []
# docs.rs must build with default features ONLY. `resolve-unix` needs an
-# out-of-tree landlock (rust-landlock#135) and cannot compile against the
-# published crate, so it must never be enabled on docs.rs. docs.rs already
+# out-of-tree landlock (the pinned `abi-v10` fork) and cannot compile against
+# the published crate, so it must never be enabled on docs.rs. docs.rs already
# defaults to default-features; pinning it here keeps a later `all-features`
# from silently breaking the hosted docs build.
[package.metadata.docs.rs]
@@ -56,12 +92,18 @@ features = []
all-features = false
[dependencies]
-# Ring 1 — pure Rust, no C, musl-clean. Published crates.io crate: reaches
-# Landlock ABI V7 (FS tiers + Refer/Truncate + TCP net + IoctlDev + IPC scope +
-# audit) — the full set oring's V7 floor needs. The V9 RESOLVE_UNIX right is an
-# opt-in built via the `resolve-unix` feature + a local fork override, so the
-# checked-in repo has a clean registry dependency that RustSec / `cargo deny`
-# actually cover.
+# Ring 1 — pure Rust, no C, musl-clean. The published crates.io crate reaches
+# Landlock **ABI V9** as of v0.4.7 (FS tiers + Refer/Truncate + TCP net +
+# IoctlDev + IPC scope + audit + RESOLVE_UNIX). oring's floor is **V10**, whose
+# UDP rights no release carries, so this requirement is satisfied by the pinned
+# fork in `[patch.crates-io]`, NOT by the registry copy. The version
+# string stays here so the switch back is a one-line bump the day upstream
+# releases V10. Until then this is knowingly not a pure registry dependency.
+#
+# The caret requirement admits the fork's 0.4.7; it is left at 0.4.5 rather than
+# bumped because the version here only has to ADMIT what the patch resolves. This comment previously said the registry crate
+# "reaches ABI V7" and that the floor was V9 — both were true when written and
+# neither is now.
landlock = "0.4.5"
# Ring 2 — Firecracker's pure-Rust BPF emitter; MaskedEq; no libseccomp.so link.
seccompiler = "0.5"
@@ -99,6 +141,55 @@ pedantic = { level = "warn", priority = -1 }
[build-dependencies]
# build.rs captures the git SHA for the seal attestation line (§11/§12).
+# Ring 1 comes from the maintainer's fork, pinned by REVISION.
+#
+# `rev`, not `branch`, and that is the whole point of the line. A branch is
+# mutable: `cargo update` follows it wherever it moves, so the crate that
+# installs the cage could change under a build that never asked it to. A rev
+# names an immutable object. `Cargo.lock` pins the resolved commit either way
+# for `--locked`, but the input identity should be fixed at the source, not only
+# in the lockfile.
+#
+# WHAT THIS REV IS, verified 2026-08-02 against the tree it replaces: upstream
+# v0.4.7 (`62fec0f`, released, on crates.io) plus exactly three commits — uapi
+# updated to Linux 7.2, ABI V10 handling, and a sandboxer sync. Diffed file by
+# file against the sources previously vendored here: ONE file differs,
+# `src/net.rs`, by ELEVEN LINES, all of them `///` doc comments. Ring 1's
+# behaviour is byte-for-byte what the suite was green against before the switch.
+#
+# WHY NOT VENDORED ANY MORE. It is the maintainer's own fork and does not belong
+# copied inside this repository. The cost, stated because the vendoring comment
+# used to give it as the reason to vendor: a fresh clone now needs the network to
+# build, where committed sources needed nothing.
+#
+# WHAT THIS DOES NOT BUY, so nobody switches back expecting it: RustSec coverage
+# is NOT restored. `[advisories]` is keyed to registry packages, so a git source
+# is no more scannable than the path patch was — see `deny.toml`, which had to
+# ALLOW this source for `cargo deny check sources` to pass at all. And
+# `cargo publish` is still refused: crates.io rejects git dependencies too.
+#
+# WHEN UPSTREAM SHIPS V10: delete this section, bump the `landlock` version
+# above, drop the `allow-git` entry in `deny.toml`, and registry-dependency
+# coverage is restored with no behaviour change.
+[patch.crates-io]
+landlock = { git = "https://github.com/eslerm/rust-landlock", rev = "d7bb0c1fac6b58f06be7718fe3fd0be8631356a1" }
+
+# Release strips symbols, so a flame graph of a release binary is hex addresses
+# and any conclusion drawn from one is unfounded. This profile is the fixture
+# that makes oring's own cost attributable: same optimisation settings as
+# release, symbols kept. It changes nothing about the shipped artifact — it is a
+# separate profile, selected with `cargo build --profile profiling`.
+#
+# `debug = 1` (line tables) rather than 2: enough for `perf`/`samply` to resolve
+# frames, without the size. Frame pointers are NOT set here because they are a
+# rustflag, not a profile key — pass `-C force-frame-pointers=yes` when a
+# frame-pointer walker truncates the stacks, and measure that cost rather than
+# assuming the usual "<1%".
+[profile.profiling]
+inherits = "release"
+debug = 1
+strip = false
+
[profile.release]
opt-level = "z"
lto = true
diff --git a/Makefile b/Makefile
index 434d573..b2fb376 100644
--- a/Makefile
+++ b/Makefile
@@ -1,69 +1,458 @@
# SPDX-License-Identifier: BSD-3-Clause
# oring — local gate runner: how oring is built, tested, and proven.
#
-# make same as `make check`
-# make doctor check prerequisites (rustc + the musl target)
-# make check the gate: fmt + clippy + test (what CI runs)
+# make same as `make help` (a bare `make` must not launch a gate)
+# make doctor check prerequisites and name what is missing
+# make check the gate: fmt + clippy + deny + test + the seccomp SMT gate.
+# THE authority on whether the cage holds — CI cannot be, because
+# no hosted runner is at the V10/7.2 floor.
+# make check-full same gate, plus the live-database advisory checks, with
+# EVERY ORING_REQUIRE_* skip made fatal
# make build release static-musl binary with an embedded auditable SBOM
# make test the test suite (the ring tests need a Landlock kernel)
# make verify Kani symbolic proofs (the x32 seccomp guard, the ABI clamp)
-# make audit scan the shipped binary's SBOM against RustSec
+# make fmt / clippy / deny the ordinary verbs, flags in one place
+# make advisories / audit LIVE RustSec database — not in `check`
+# make hooks install the tracked git hooks (never automatic)
+# make check-history sweep every ref for host detail in commit messages
# make clean remove build artifacts
#
# Tooling, installed once as needed:
# rustup target add x86_64-unknown-linux-musl # build
+# cargo install cargo-deny --locked # check
# cargo install cargo-auditable cargo-audit --locked # build, audit
# cargo install --locked kani-verifier && cargo kani setup # verify
-# z3 (system) # the seccomp SMT gate in `make test`
+# z3 (system) # the seccomp SMT gate in `make check`
CARGO := cargo
TARGET := x86_64-unknown-linux-musl
BIN := target/$(TARGET)/release/oring
-.PHONY: all help doctor check build test verify audit clean
+# `resolve-unix` is REQUIRED, not optional: the floor is Landlock V10 and
+# src/landlock.rs `compile_error!`s without it. It stays a non-default feature
+# only so docs.rs can still build against the published landlock crate (V9 as of
+# v0.4.7, which carries RESOLVE_UNIX but NOT the V10 UDP rights the floor needs)
+# — which means every compiling invocation here must pass it explicitly. The V10
+# crate itself arrives via `[patch.crates-io]` — a rev-pinned git dependency on
+# the maintainer's fork, no longer vendored in-tree.
+FEATURES := resolve-unix
+
+# The feature set for any cargo invocation that builds EVERY target in the
+# package — `clippy --all-targets` and `cargo kani` both do. `bpf-dump` and
+# `bench` are `required-features` binaries, and cargo does not skip them: it
+# exits 101 with "target `` … requires the features: ``" BEFORE
+# doing any work.
+#
+# THIS IS ONE VARIABLE BECAUSE THE SAME DEFECT LANDED TWICE. `make verify`
+# passed `$(FEATURES),bpf-dump` inline; adding the `bench` binary broke it, and
+# it stayed broken — exit 2, both Kani proofs re-checked by NOTHING — while
+# `make check` stayed green, because clippy's copy of the list had been updated
+# and verify's had not. Kani appears in no CI workflow, so that target is the
+# only gate the x32 guard and the ABI clamp have, and the x32 guard is the
+# invariant a whole-suite green run demonstrably cannot see (CHANGELOG, "the
+# x32 guard's test coverage could not see the guard break"). A new
+# required-features binary must be added HERE, once, not at each call site.
+ALL_TARGET_FEATURES := $(FEATURES),bpf-dump,bench
+
+# Where the suite's full output lands, so the gate can COUNT what it did instead
+# of trusting a summary line. Under target/, so `make clean` takes it.
+SUITELOG := target/oring-suite.log
+SMTLOG := target/oring-smt.log
+
+.PHONY: all help doctor check check-full suite-audit build test verify \
+ fmt clippy deny smt advisories audit hooks check-history clean
+
+# Every capability CI exercises has a target here, and CI runs the target rather
+# than the command. CI runs only the subset a SUB-FLOOR runner can honestly
+# check — fmt, clippy, deny, build, smt, and the fail-closed refusal — because
+# `check` sets ORING_REQUIRE_KERNEL=1 and would fail there on every push. Until 2026-08-01 `.github/workflows/ci.yml` invoked cargo
+# directly at nine call sites and `make ...` at none, which had two costs: a
+# contributor who ran the one target the README names could pass locally and
+# fail CI on `cargo deny`, which `make check` did not run at all; and the
+# feature list existed twice, so consolidating it into ALL_TARGET_FEATURES fixed
+# one copy and left CI's. A capability the project exercises and does not expose
+# as a target is where the README, CI and the maintainer's habits drift apart.
+
+# Extra ORING_REQUIRE_* settings for the suite line in `check`. Empty by
+# default; `check-full` sets it. See the check-full comment for why this is a
+# separate target and not simply added to `check`.
+REQUIRE_EXTRA :=
all: check
help:
@echo "oring — make "
- @echo " make doctor check prerequisites (rustc + the musl target)"
- @echo " make check the gate: fmt + clippy + test"
+ @echo " make doctor check prerequisites (rustc, musl target, cargo-deny, z3)"
+ @echo " make check the gate: fmt + clippy + deny + test + seccomp SMT gate"
+ @echo " make check-full same, plus the live-database advisory checks, and"
+ @echo " every ORING_REQUIRE_* skip made fatal"
+ @echo " make suite-audit LOG= what a suite log shows RAN, vs what it claims"
@echo " make build release static-musl binary with an embedded auditable SBOM"
@echo " make test the test suite (the ring tests need a Landlock kernel)"
@echo " make verify Kani symbolic proofs (x32 guard, ABI clamp)"
- @echo " make audit scan the shipped binary's SBOM against RustSec"
+ @echo " make fmt rustfmt --check"
+ @echo " make clippy clippy over every target"
+ @echo " make deny cargo-deny: bans + licenses + sources (reproducible)"
+ @echo " make smt the seccomp SMT gate alone (no Landlock kernel needed)"
+ @echo " make advisories cargo-deny advisories — FETCHES the live RustSec DB"
+ @echo " make audit scan the shipped binary's SBOM against RustSec (live DB)"
+ @echo " make hooks install the tracked git hooks (commit-msg guard)"
+ @echo " make check-history sweep every ref for host detail in commit messages"
@echo " make clean remove build artifacts"
+# `check`'s prerequisites, all of them. cargo-deny and z3 are here because
+# `check` now fails without them rather than skipping — a gate that quietly
+# drops a step on a machine missing a tool is the clean-looking zero this
+# project's whole gate discipline exists to prevent.
doctor:
@command -v cargo >/dev/null 2>&1 && echo "✓ cargo $$(cargo --version | cut -d' ' -f2)" || { echo "✗ cargo not found — https://rustup.rs"; exit 1; }
@rustup target list --installed 2>/dev/null | grep -qx $(TARGET) && echo "✓ musl target present" || echo "… missing musl target — run: rustup target add $(TARGET)"
+ @command -v cargo-deny >/dev/null 2>&1 && echo "✓ cargo-deny $$(cargo deny --version 2>/dev/null | cut -d' ' -f2)" || echo "… missing cargo-deny (make check needs it) — cargo install cargo-deny --locked"
+ @command -v z3 >/dev/null 2>&1 && echo "✓ z3 $$(z3 --version 2>/dev/null | cut -d' ' -f3)" || echo "… missing z3 (the seccomp SMT gate needs it) — install from your distro"
+ @command -v cargo-audit >/dev/null 2>&1 && echo "✓ cargo-audit (make audit)" || echo "… missing cargo-audit (make audit only) — cargo install cargo-audit --locked"
+ @command -v cargo-kani >/dev/null 2>&1 && echo "✓ kani (make verify)" || echo "… missing kani (make verify only) — cargo install --locked kani-verifier && cargo kani setup"
@echo "✓ ready"
-# The everyday gate — the same fmt/clippy/test CI runs. clippy uses --features
-# bpf-dump (the analysis-only code), NOT --all-features: resolve-unix needs the
-# out-of-tree V9 fork and cannot compile against the published crate.
+# The ordinary verbs, wrapped so the flags live in one place and CI can call the
+# target instead of copying the command.
+fmt:
+ $(CARGO) fmt --check
+
+clippy:
+ $(CARGO) clippy --all-targets --features $(ALL_TARGET_FEATURES) --locked
+
+# THE REPRODUCIBLE SUBSET, and the subset name is the whole point.
+#
+# `cargo deny check` with no arguments runs FOUR checks, and one of them —
+# `advisories` — fetches the live RustSec database. That makes a bare invocation
+# change verdict on a day nobody touched the repository, so it is not a
+# per-commit gate however it is described. This target names its three
+# reproducible subsets explicitly: they read a committed policy against a
+# committed lockfile and answer the same way at the same commit forever.
+deny:
+ $(CARGO) deny check bans licenses sources
+
+# The ring-2 truncation-invariance gate on its own. `check` runs this as a
+# separate step because tests/seccomp_smt.rs is `#![cfg(feature = "bpf-dump")]`
+# and compiles to NOTHING without it — a clean-looking zero. Exposed as a target
+# so a machine that cannot witness containment can still run the half that is
+# pure computation over the emitted cBPF and needs no Landlock kernel.
+smt:
+ ORING_REQUIRE_SMT=1 $(CARGO) test --features $(FEATURES),bpf-dump --locked \
+ --test seccomp_smt -- --nocapture --test-threads=1
+
+# The live-database half, deliberately separate and deliberately NOT in `check`.
+# Belongs in `check-full` or a scheduled job. A red result here means the world
+# changed, not that the commit did.
+advisories:
+ $(CARGO) deny check advisories
+
+# Install the tracked hooks. NEVER automatic: a repository that edits the
+# developer's git configuration as a side effect of building is a repository
+# nobody should trust. Provide the target; let the human run it.
+hooks:
+ git config core.hooksPath .githooks
+ @echo "✓ hooks installed (core.hooksPath = .githooks)"
+ @echo " commit-msg: refuses host-specific detail in a commit message"
+ @echo " to remove: git config --unset core.hooksPath"
+
+# Sweep EVERY ref for host-specific detail in commit messages.
+#
+# The hook guards one message as it is written; this guards the history that
+# already exists, and the two are not the same check. A message already
+# committed is invisible to the hook forever. Not part of `check`: it is a
+# pre-push question, and on a large history it is slow.
+#
+# Counts and reports the FRAME — refs scanned and commits examined — because a
+# zero over an unstated span is the defect this project's gates exist to catch.
+# SPAN: --branches --tags, which is what a clone receives — NOT --all.
+#
+# The first version printed a frame of "refs/heads refs/tags" and then scanned
+# `git rev-list --all`, which also walks refs/backup/ and refs/original/. Run
+# straight after a history rewrite it reported 32 offending commits that existed
+# only in the backup refs the rewrite had just created, so a correct rewrite
+# looked like a failed one. A count over a span you did not state is the defect
+# this target exists to catch, committed in the target itself.
+#
+# refs/backup/ and refs/original/ are deliberately out of span: `git clone` takes
+# refs/heads and refs/tags and nothing else, so an object reachable only from a
+# backup ref does not publish. Delete those refs when you no longer want the
+# undo, not to make this pass.
+check-history:
+ @refs=$$(git for-each-ref --format='%(refname)' refs/heads refs/tags | wc -l); \
+ commits=$$(git rev-list --branches --tags | wc -l); \
+ echo " scanning $$commits commit(s) across $$refs ref(s) [refs/heads + refs/tags]"; \
+ bad=0; \
+ for c in $$(git rev-list --branches --tags); do \
+ if git log -1 --format='%s%n%b' $$c | grep -qE '/home/[a-z_][a-z0-9_-]{2,}|/Users/[a-z_][a-z0-9_-]{2,}' \
+ || git log -1 --format='%s%n%b' $$c | grep -qwE 'xtal|xtra'; then \
+ echo " ✗ $$(git log -1 --format='%h %s' $$c)"; bad=$$((bad+1)); fi; \
+ done; \
+ if [ $$bad -eq 0 ]; then echo " ✓ no host-specific detail in any commit message"; else \
+ echo " $$bad commit message(s) carry host-specific detail."; \
+ echo " These PUBLISH. Reword before pushing; after a push there is no remedy."; \
+ exit 1; fi
+
+# The everyday gate — the same fmt/clippy/test CI runs. clippy adds bpf-dump and
+# bench (the analysis-only code) on top of the mandatory feature set, NOT
+# --all-features, so the set stays explicit as features are added.
+#
+# `bench` is here for anti-rot and nothing else: `src/bin/bench.rs` measures what
+# `seccomp::apply` installs and what `pipeline::seal` costs, so it goes stale the
+# first time either moves. --all-targets compiles it on every gate run. It is
+# never RUN here — `bench/run.sh` takes minutes and needs a quiet box, which is
+# the opposite of what a pre-commit gate should be.
check:
@echo "== FMT =="
$(CARGO) fmt --check
@echo "== CLIPPY =="
- $(CARGO) clippy --all-targets --features bpf-dump --locked
+ $(CARGO) clippy --all-targets --features $(ALL_TARGET_FEATURES) --locked
+# `deny` and not `advisories`: the three subsets here are reproducible from the
+# commit, so they can gate it. The advisory check fetches a live database and is
+# in `check-full`. Splitting them is what makes a red `check` mean the commit is
+# wrong rather than that RustSec published something overnight.
+ @echo "== DENY (bans + licenses + sources) =="
+ $(MAKE) --no-print-directory deny
@echo "== TEST =="
- $(CARGO) test --locked
- @echo "== oring: gate passed (fmt + clippy + test) =="
+# ORING_REQUIRE_KERNEL turns a SKIP into a hard failure. Without it this gate
+# reported a green suite it had not run: `containment_matrix` printed
+# "1 passed" having executed 0 of its 28 cells, because a skipped test and a
+# passing test are indistinguishable in cargo's summary — "ok. 0 passed" reads
+# green and the SKIP line goes to stderr, which cargo discards without
+# --nocapture. The entire containment surface sat behind that.
+#
+# CONSEQUENCE, chosen deliberately: `make check` now FAILS on a host below the
+# V9 floor instead of passing vacuously. That is D-4's own posture — refuse
+# rather than run a partial cage — applied to the gate: a machine that cannot
+# witness containment must not report that it did. `make test` is unchanged and
+# still skips, for anyone who wants the old behaviour.
+#
+# ORING_REQUIRE_AUDIT, ORING_REQUIRE_UDP and ORING_REQUIRE_NET are NOT set here — see `check-full`.
+# --nocapture is LOAD-BEARING, not verbosity. Every skip in this tree announces
+# itself with `eprintln!("SKIP: …")` on stderr, and cargo DISCARDS stderr without
+# it — so the evidence that a cell did not run is thrown away by default, while
+# the cell still counts as PASSED. Logged rather than piped so cargo's own exit
+# status is preserved without depending on `pipefail`.
+#
+# On success show cargo's own summary lines only; on failure show EVERYTHING.
+# --nocapture is needed for the SKIP lines to exist at all, but it also unleashes
+# every test's stderr, and a gate nobody can read is a gate nobody runs. The full
+# log is always on disk at $(SUITELOG).
+#
+# NO COMMENT MAY SIT INSIDE THE CONTINUED RECIPE BELOW, and the four lines above
+# used to. Measured on GNU Make 4.4.1: a `#` line between two backslash-continued
+# recipe lines ENDS the recipe line, so what follows runs in a SECOND shell —
+# where `rc` is unset. `[ $$rc -ne 0 ]` then errored ("unary operator expected"),
+# and an errored test takes the ELSE branch, so the failure arm was dead: a red
+# suite printed the same summary grep as a green one, never the log, and
+# `exit $$rc` exited on the LAST command's status rather than cargo's. Red-tested
+# with `false` in cargo's place: broken form printed the success arm, hoisted form
+# printed the failure arm and exited 1. What kept `make check` honest meanwhile was
+# `suite-audit` counting `FAILED` lines — a second instrument covering for a dead
+# first one, which is exactly the arrangement that hides a defect for a year.
+ @mkdir -p target
+ @ORING_REQUIRE_KERNEL=1 $(REQUIRE_EXTRA) $(CARGO) test --features $(FEATURES) --locked -- --nocapture --test-threads=1 > $(SUITELOG) 2>&1; rc=$$?; \
+ if [ $$rc -ne 0 ]; then cat $(SUITELOG); else grep -E '^(test result:|error)' $(SUITELOG) || true; fi; \
+ $(MAKE) --no-print-directory suite-audit LOG=$(SUITELOG) || rc=$$?; \
+ exit $$rc
+# The SMT gate is a SEPARATE step, mirroring CI exactly, because
+# tests/seccomp_smt.rs is `#![cfg(feature = "bpf-dump")]`: under the line above
+# it compiles to NOTHING and cargo prints "ok. 0 passed" — a clean-looking zero.
+# Until 2026-07-26 this gate therefore never ran locally, while `make check`
+# printed "gate passed"; the H2 truncation guard (CVE-2019-7303's class) is what
+# it proves, and it is the regression guard for the socket() protocol wall.
+# ORING_REQUIRE_SMT=1 makes a missing z3 fatal rather than a silent skip.
+# Kept as its own invocation, not a blanket `--features …,bpf-dump` on the line
+# above, so the main suite still runs against the SHIPPED feature set.
+#
+# On success show cargo's own summary lines only; on failure show EVERYTHING.
+# --nocapture is needed for the SKIP lines to exist at all, but it also unleashes
+# every test's stderr, and a gate nobody can read is a gate nobody runs. The full
+# log is always on disk at $(SMTLOG).
+#
+# Comments stay ABOVE the recipe line here for the reason spelled out at the
+# suite step: inside a backslash continuation they split it into a second shell
+# and lose `rc`. This copy was the visible one — it echoed the `if` line and the
+# `[: -ne: unary operator expected` into every gate run — but both copies were
+# broken, and the suite one was worse for being silent.
+ @echo "== SECCOMP SMT GATE =="
+ @ORING_REQUIRE_SMT=1 $(CARGO) test --features $(FEATURES),bpf-dump --locked --test seccomp_smt -- --nocapture --test-threads=1 > $(SMTLOG) 2>&1; rc=$$?; \
+ if [ $$rc -ne 0 ]; then cat $(SMTLOG); else grep -E '^(test result:|error)' $(SMTLOG) || true; fi; \
+ $(MAKE) --no-print-directory suite-audit LOG=$(SMTLOG) || rc=$$?; \
+ exit $$rc
+# THE BANNER IS CONDITIONAL, and that is the point of this whole target. It used
+# to print "gate passed" unconditionally — over a run in which, measured on this
+# host, THREE cells did not execute (two live-suite cells needing ORING_LIVE, and
+# the SCTP cell on a kernel with no sctp.ko). A gate may legitimately be unable to
+# witness something; it may not report an unqualified pass over it.
+# UNANCHORED and counted by OCCURRENCE, not by line. Anchoring on `^SKIP:` undercounted
+# — measured, 2 against 3 — because `--nocapture` lets parallel test threads interleave
+# writes MID-LINE, so a skip announcement can land behind another test's output and lose
+# column 1. That is a clean-looking TWO produced by the very gate written to catch
+# clean-looking numbers. Both causes are addressed: the gate runs `--test-threads=1` so
+# output cannot interleave, and the count no longer depends on that having worked.
+#
+# `wc -l` over matches, NOT `grep -c | paste | bc`. The first version was
+# the latter with a `|| echo 0` fallback, whose failure mode is REPORTING ZERO SKIPS
+# — i.e. printing the unqualified banner — on any host without bc. A construct that
+# fabricates a clean answer when its tooling is missing is the defect this gate exists
+# to catch, so it is gone rather than guarded.
+ @skips=$$(grep -hoE 'SKIP:' $(SUITELOG) $(SMTLOG) 2>/dev/null | wc -l); \
+ if [ "$$skips" -eq 0 ]; then \
+ echo "== oring: gate passed (fmt + clippy + deny + test + smt) — every cell witnessed == [$(SUITELOG)]"; \
+ else \
+ echo "== oring: gate passed (fmt + clippy + deny + test + smt), BUT $$skips cell(s) were NOT WITNESSED on this host =="; \
+ echo " this run is not a full witness; see the NOT WITNESSED list above, or \`make check-full\` to make the ORING_REQUIRE_* classes fatal"; \
+ fi
+
+# `check` with all five ORING_REQUIRE_* variables SET on the suite line.
+#
+# WHAT THIS DOES NOT DO: it does not fix the defect. `make check` still passes
+# while the audit-attribution and UDP suites skip, and that is the actual
+# finding — ORING_REQUIRE_AUDIT, ORING_REQUIRE_UDP and ORING_REQUIRE_NET are set by no OTHER make
+# target and no CI workflow, so a green gate does not mean those suites ran.
+# The indistinguishable-pass is worse than the usual clean-looking zero: the
+# skip paths return EARLY, so each cell counts as PASSED. Measured under a
+# planted mutant, `udp_wall` reports `ok. 9 passed; 0 failed` having executed
+# zero cells. Not a clean-looking zero — a clean-looking nine.
+#
+# WHY THIS RECURSES INSTEAD OF DEPENDING ON `check` — it is a bug fix, not style.
+# The first version was `check-full: check` plus a target-specific
+# `REQUIRE_EXTRA`. Target-specific variables do propagate to prerequisites, but
+# make builds `check` ONCE PER INVOCATION, so the variable context that wins is
+# decided by GOAL ORDER: `make check check-full` ran the suite with NO extra
+# vars and then printed the strict banner over it, rc=0 — a false green, in the
+# target whose whole purpose is to prevent one. Silent and order-dependent
+# (`make check-full check` was correct), which is the worst shape it could have.
+# Passing REQUIRE_EXTRA on a sub-make command line makes it a command-line
+# variable, the highest-precedence kind, so no goal ordering or outer override
+# can strip it. The banner then cannot print over a run that did not set them.
+#
+# WHY THE FIX IS NOT SIMPLY ADDING THEM TO `check` — an operator-visible choice,
+# escalated rather than made here:
+# ORING_REQUIRE_KERNEL set `make check`'s bar AT oring's product floor (V9):
+# a host that cannot witness containment must not report that it did.
+# ORING_REQUIRE_UDP would set it ABOVE that floor — the UDP cells need V10
+# (kernel 7.2), so `make check` would start FAILING on 7.1, the minimum kernel
+# oring itself supports and ships for. ORING_REQUIRE_AUDIT is a different
+# question again: it needs a readable `_TRANSPORT=audit` journal, which is
+# host configuration rather than kernel version, so it would fail in any
+# container without journald+auditd.
+# Two reasonable answers, different observable behaviour for an operator. Filed
+# as T7 in the build queue for a decision; this target exists so the strict run
+# is one reproducible command in the meantime, rather than four env vars typed
+# by hand and reported on trust.
+# The banner says SET, not "all skips are fatal", and the distinction is real:
+# ORING_REQUIRE_SMT makes only the z3-absent path fatal (tests/seccomp_smt.rs
+# :326). An inconclusive z3 verdict still warns and `continue`s (:284-291), so
+# a pair z3 never decided is counted as proved. Claiming "every skip fatal"
+# would overstate exactly the axis the tree's own claims ledger already grades F.
+check-full:
+ $(MAKE) check REQUIRE_EXTRA='ORING_REQUIRE_AUDIT=1 ORING_REQUIRE_UDP=1 ORING_REQUIRE_NET=1'
+# The live-database half runs HERE and never in `check`. Both targets fetch from
+# RustSec, so both can change verdict without a commit; that is fine in the slow
+# gate and is exactly what disqualifies them from the per-commit one.
+ @echo "== ADVISORIES (live RustSec database) =="
+ $(MAKE) --no-print-directory advisories
+ @echo "== oring: gate passed with ORING_REQUIRE_{KERNEL,SMT,AUDIT,UDP} all SET =="
+# Even THIS run is not a full witness, and the NOT WITNESSED list above now proves it
+# rather than leaving it to this comment. Only 9 of the 20 skip sites in the tree are
+# reachable by any ORING_REQUIRE_* variable; the other 11 (no cat(1), no cargo, no git,
+# HOME unset, no journald cursor, userns unavailable, SCTP absent, ORING_LIVE unset)
+# have no flag at all. Measured on the development host: `check-full` still reports 3
+# unwitnessed cells. "All four SET" was never the same claim as "everything ran".
+ @echo " and see the NOT WITNESSED list above: setting all five does NOT make every skip fatal"
+
+# suite-audit — say what a run DID, not what it claims. `make suite-audit LOG=`
+#
+# Standalone on purpose: that is how this check is itself red-tested, by feeding it
+# a synthesized log. A gate never seen red is not known to work.
+#
+# THREE QUESTIONS, none of which cargo's summary line answers.
+#
+# 1. DID EVERY SUITE REPORT? Cargo prints one `Running` line per test binary plus
+# one `Doc-tests` line, so FED = those, REPORTED = `test result:` lines, and the
+# two must be equal. SELF-CALIBRATING — no hardcoded total to go stale. On this
+# tree it is 26 + 1 = 27 on both sides.
+#
+# MEASURED 2026-07-29, and it corrects the causal story this comment first told.
+# A binary that FAILS normally still prints `test result: FAILED`, so fed and
+# reported stay EQUAL (27/27) and question 2 is what catches it. What this count
+# catches is a binary that produces NO SUMMARY AT ALL — `std::process::abort()`
+# measured 27 fed against 26 reported. That is the crash / SIGSEGV / killed-guest
+# case, and no amount of reading `test result:` lines can see it, because the line
+# does not exist. Two questions, two distinct failure shapes; neither subsumes the
+# other.
+#
+# 2. DID ANYTHING FAIL? This is the one that answers the CI defect.
+# `.github/workflows/ci.yml` gates three jobs on `grep -qE 'test result: ok'`,
+# and a run with one failing binary contains BOTH `ok` lines and a `FAILED` line
+# — measured — so the grep matches a failed run. Counting FAILED is the fix, and
+# it is order-independent, unlike anything that depends on which binary failed
+# first. Those three sites are staged for repair but UNEXERCISED: there are no
+# runners here and `vng` is not installed, so whether the guest's exit status
+# propagates out remains unestablished.
+#
+# 3. WHAT DID NOT RUN? The one that had no instrument. `require_or_skip` returns
+# EARLY, so a skipped cell counts as PASSED and is indistinguishable from a real
+# one in cargo's summary. MEASURED on this tree, 2026-07-29: with the UDP
+# requirement raised above this host's ABI, `udp_wall` prints
+# `ok. 9 passed; 0 failed` having executed ONE of nine cells — a clean-looking
+# NINE, not a clean-looking zero. The 8 SKIP lines that would show it go to
+# stderr, which cargo discards without --nocapture. Hence --nocapture in `check`.
+#
+# A SKIP IS NOT MADE FATAL HERE, deliberately. Making it fatal is the escalated
+# decision recorded in the check-full comment: ORING_REQUIRE_UDP needs V10, so it
+# would fail `make check` on 7.1 — the minimum kernel oring ships for. What is
+# forbidden is an UNQUALIFIED PASS over a run that witnessed less than everything.
+# The 11-of-20 skip sites that NO ORING_REQUIRE_* variable can reach (no cat(1), no
+# cargo, no git, HOME unset, no journald cursor, userns unavailable, SCTP absent)
+# are counted here too, and no flag would have covered them.
+#
+# Exit: 0 audited clean · 1 a suite failed or never reported · 2 nothing examined.
+LOG ?= $(SUITELOG)
+suite-audit:
+ @test -f "$(LOG)" || { \
+ echo " ✗ suite-audit: no log at $(LOG). Exit 2 — nothing was examined, and that"; \
+ echo " is not the same as nothing being wrong."; exit 2; }
+ @fed=$$(grep -cE '^ *(Running|Doc-tests) ' "$(LOG)" || true); \
+ rep=$$(grep -cE '^test result:' "$(LOG)" || true); \
+ fail=$$(grep -cE '^test result: FAILED' "$(LOG)" || true); \
+ skip=$$(grep -oE 'SKIP:' "$(LOG)" | wc -l); \
+ echo " suites fed: $$fed reported: $$rep FAILED: $$fail cells skipped: $$skip [$(LOG)]"; \
+ rc=0; \
+ if [ "$$fed" -eq 0 ]; then \
+ echo " ✗ no test binary ran at all — a zero here is not a pass"; rc=1; fi; \
+ if [ "$$fed" -ne "$$rep" ]; then \
+ echo " ✗ $$((fed - rep)) suite(s) started and never reported a result line. A binary"; \
+ echo " that aborts, segfaults or is killed prints no summary at all, so no amount of"; \
+ echo " reading 'test result:' lines can see it — only counting can."; rc=1; fi; \
+ if [ "$$fail" -ne 0 ]; then \
+ echo " ✗ $$fail suite(s) reported FAILED"; rc=1; fi; \
+ if [ "$$skip" -gt 0 ]; then \
+ echo " ⚠ NOT WITNESSED — these cells did not run, and each counted as PASSED:"; \
+ sed -n 's/.*\(SKIP: .*\)/\1/p' "$(LOG)" | sort | uniq -c | sed 's/^/ /'; fi; \
+ exit $$rc
# Release build with an embedded, auditable dependency SBOM: the static binary
# carries its full dependency tree, so `make audit` (or any consumer) can scan the
# SHIPPED artifact against RustSec, not just Cargo.lock.
build:
- $(CARGO) auditable build --release --locked --target $(TARGET)
+ $(CARGO) auditable build --release --locked --features $(FEATURES) --target $(TARGET)
test:
- $(CARGO) test --locked
+ $(CARGO) test --features $(FEATURES) --locked
# Formal proof — opt-in (Kani, minutes). See docs/VERIFICATION.md.
+#
+# $(ALL_TARGET_FEATURES), not $(FEATURES): the proofs use neither `bpf-dump` nor
+# `bench`, but `cargo kani` builds every target in the package and both are
+# `required-features` binaries, so omitting either aborts the run at 101 before a
+# single proof executes. The full account, and why this list lives in one
+# variable rather than at each call site, is at its definition above.
verify:
- $(CARGO) kani --harness x32_guard_total
- $(CARGO) kani --harness abi_from_version_clamp
+ $(CARGO) kani --features $(ALL_TARGET_FEATURES) --harness x32_guard_total
+ $(CARGO) kani --features $(ALL_TARGET_FEATURES) --harness abi_from_version_clamp
# Scan the shipped binary's embedded SBOM against the RustSec advisory database.
audit:
diff --git a/README.md b/README.md
index 8d04f82..8fd2c60 100644
--- a/README.md
+++ b/README.md
@@ -6,83 +6,231 @@ Run an untrusted process inside a kernel-enforced cage — no root, no daemon, o
static binary.
`oring` restricts *itself*, then hands control to your command. It applies Landlock
-(filesystem tiers, per-direction TCP, IPC scope), installs a seccomp-bpf denylist,
-sets `NO_NEW_PRIVS`, builds a clean environment, and `execve()`s the target. The
-child inherits every restriction irrevocably and can never widen it — so a
-compromise is contained by the kernel, not by trust.
+(filesystem tiers, per-direction TCP and UDP, IPC scope), installs a
+seccomp-bpf denylist, sets `NO_NEW_PRIVS`, builds a clean environment, and
+`execve()`s the target. The child inherits every restriction irrevocably and can
+never widen it — so a compromise is contained by the kernel, not by trust.
```sh
oring --profile balanced --workdir /work/repo -- cargo test # build/test; TCP :443 to any host, not a firewall
-oring --profile strict --workdir /work/scratch -- ./sample # untrusted binary; no TCP egress (UDP/DNS open), W^X
+oring --profile strict --workdir /work/scratch -- ./sample # untrusted binary; no egress at all (no DNS), W^X
oring --policy /etc/oring/rig.json -- ./fuzzer # explicit JSON policy
oring --profile balanced --workdir /work/repo --explain # preview the plan, run nothing
```
-oring is honest about what it is not: not an orchestrator, a network-policy engine,
-or a VM boundary, and it does not author host MAC — those stack underneath it. The
-residuals it *cannot* close are written down plainly in
-[`docs/DESIGN.md`](docs/DESIGN.md) §2, not glossed over.
+It is not an orchestrator, a network-policy engine, or a VM boundary, and it does not
+author host MAC. Those stack underneath it. The residuals it cannot close are enumerated
+in [`docs/DESIGN.md`](docs/DESIGN.md) §2, each with the loss it belongs to and whether
+anything reports it at run time.
## Profiles
-Two baked-in profiles, tested against the shipping binary on a real kernel.
-`balanced` is for a coding agent that builds code — toolchain caches granted,
-workdir `write_exec`, `:443` egress to any IP (port-only, not a domain/IP firewall).
-`strict` is for an untrusted binary — nothing
-granted, workdir `write_noexec` (W^X), deny-all TCP egress (UDP/DNS still open),
-JIT blocked. Both block
-reading outside their tiers (SSH keys, shell history), writing system paths, the
-privilege-escalation syscalls, and abstract AF_UNIX `connect()`. Anything in between
-is a small JSON `--policy` (schema in `docs/DESIGN.md` §7).
-
-**Wrapping an agent:** `balanced` grants the toolchain caches but deliberately not
-an agent's own state directory (`~/.claude`, `~/.config`), and the child's environment
-is an allowlist (secrets withheld unless you `--env-keep NAME` them). So `-- claude`
-won't start as-is; decide the credential trade on purpose — and know the honest limits.
-Grant `~/.claude` so the agent can run and the API key inside it is readable by the
-(possibly prompt-injected) agent: confinement buys blast-radius reduction, not credential
-protection. Two residuals oring cannot close for you: **egress** is port-only to any IP
-(DNS/UDP open), so a host firewall or filtering proxy is a *required* companion control,
-not optional; and the agent has full write over its **workdir** — `.git/hooks`, `Makefile`,
-`build.rs`, `package.json` scripts all run on the next `git`/build/test you run *outside*
-the cage, so treat a touched workdir as untrusted and run the first build inside oring.
-Full story in `docs/DESIGN.md` §2/§9.
+Two baked-in profiles, tested against a build of this tree on a Landlock kernel.
+`balanced` is for a coding agent that builds code — toolchain caches **readable, never
+writable** (`~/.cache` is not granted at all, see below), workdir `write_exec`,
+`:443` egress to any IP (port-only, not a domain/IP firewall).
+`strict` is for an untrusted binary: **no toolchain caches and no `$HOME` grants at
+all**, workdir `write_noexec` (W^X), deny-all TCP egress, JIT blocked. It still grants the
+system `read_exec` set (`/usr/bin`, `/bin`, `/lib*`, `/usr/libexec`), `/etc` and
+`/usr/share` read-only, `/proc/self`, and `/tmp` write-no-exec, because something has to
+be executable or nothing runs. The contrast with `balanced` is the `$HOME` grants, not the
+system set.
+
+Both profiles block reading outside their tiers (SSH keys, shell history), writing system
+paths, the privilege-escalation syscalls, and abstract AF_UNIX `connect()`. Both refuse
+`socket()` in `AF_INET`/`AF_INET6` for anything ring 1 cannot mediate (ICMP, MPTCP, SCTP,
+`SOCK_SEQPACKET`, `SOCK_RAW`), so in-cage `ping` does not work.
+Anything in between is a small JSON `--policy` (schema in `docs/DESIGN.md` §7).
+
+**UDP is restricted under both profiles.** `balanced` grants outbound **:53 only**, for
+name resolution and nothing else. `strict` grants no UDP at all, so under `strict` names
+do not resolve. That is deliberate: it already denies every TCP egress, so
+resolving a name it cannot connect to buys nothing.
+
+Restricting UDP takes two grants, not one: a sending socket acquires its source
+port by kernel autobind, which Landlock checks against a synthetic **port 0**, so
+`egress_udp` without `0` in `ingress_udp` sends nothing at all. oring warns on that
+pair, and for the built-in profiles it is a build-time invariant.
+
+A run still tells you which rights it left open — the seal line carries
+`unhandled=`, and a policy restricting *part* of the network warns rather than
+letting you read `egress_tcp` as "no egress". Under both profiles that field now
+reads `-`, and `--require-full-coverage` turns any gap into exit 120 instead of a
+warning.
+
+**Wrapping an agent.** `balanced` grants the toolchain caches but not an agent's own
+state directory (`~/.claude`, `~/.config`), and the child's environment is an allowlist,
+so secrets are withheld unless you `--env-keep NAME` them. `-- claude` will not start
+as-is. Grant `~/.claude` and the agent runs, and the API key inside it is readable by a
+possibly prompt-injected agent: confinement buys blast-radius reduction, not credential
+protection. Decide that trade deliberately.
+
+Two residuals oring cannot close for you:
+
+- **Egress is port-only, to any IP.** Landlock keys its network rules on the port and
+ never on the address, so a host firewall or filtering proxy is a required companion
+ control rather than an optional one. That applies to the `balanced` DNS grant too,
+ which reaches :53 at any address.
+- **The agent has full write over its workdir.** `.git/hooks`, `Makefile`, `build.rs` and
+ `package.json` scripts all run on the next `git`, build or test you run *outside* the
+ cage, so treat a touched workdir as untrusted input.
+
+**Toolchain caches are readable, never writable.** `~/.cargo/registry`, `~/.cargo/git`
+and `~/.npm` stay readable (`~/go/pkg` via the `~/go` rule); nothing shipped grants write
+on them, and a unit test asserts the absence of all six. `~/.cache` is not granted at
+all: it is an XDG namespace third parties join after a policy is written, and it holds
+`~/.cache/huggingface/token`. Granting a cache back in a hand-written `--policy` reopens
+the class, because a later unconfined build reads what the confined child wrote there.
+
+What that costs. A warm offline `cargo build` inside the cage succeeds; fetching a
+dependency that is not already cached fails loudly, naming the crate. `go build` fails
+under `balanced` with an error naming GOROOT rather than the policy, because `GOCACHE`
+defaults to `$XDG_CACHE_HOME/go-build` and must be **writable**, which the cargo
+demotion's read-only compromise cannot supply. The fix needs only shipped flags, since
+the workdir is already `write_exec`:
+
+```sh
+GOCACHE=/work/repo/.gocache oring --profile balanced --workdir /work/repo \
+ --env-keep GOCACHE -- go build ./...
+```
+
+Measured: rc=0. Restoring write on `~/.cache/go-build` instead is not an option, because
+Go's build cache validates by size rather than by hash on the path serving compiled
+objects and cached executables. Expect `cargo miri`, `dune`, `ccache`/`sccache` and
+anything else caching under `~/.cache` to need an explicit `--policy` grant; pre-warm
+outside the cage, or grant a specific path. **The `~/.cache` decision rests on a security
+argument and has not been measured for usability** (`DECISIONS.md` D1d — a handle in the
+maintainer's decision log, which is not in this repository and resolves to nothing you can
+open; the reasoning it points at is in `docs/DESIGN.md` §2/§9, which does ship). Anything
+under `~/.cache` other than the paths named here is unmeasured.
## How it works
-- **Ring 1 — Landlock:** four filesystem tiers, per-direction TCP allowlists, IPC
- scoping. Hard floor kernel **6.15 / ABI V7** — below it oring refuses rather than
- run a partial cage. The V9 `RESOLVE_UNIX` right (walls `connect()` to a pathname
- AF_UNIX socket like `docker.sock` or ssh-agent) is optional hardening behind
- `--features resolve-unix`; without it that residual is reported loudly at seal.
+- **Ring 1 — Landlock:** four filesystem tiers, per-direction TCP **and UDP**
+ allowlists, IPC scoping. Hard floor kernel **7.2 / ABI V10** — below it oring
+ refuses rather than run a partial cage. Two rights are in the floor. `RESOLVE_UNIX`
+ (V9) walls `connect()` to a pathname AF_UNIX socket like `docker.sock` or
+ ssh-agent: Landlock has no hook on that connect below V9, so beneath it no
+ filesystem tier can gate it. The UDP rights (V10) are what let the profiles
+ restrict DNS instead of disclosing it as unhandled. That is a steep floor — it
+ admits 7.2 and nothing earlier — and it is deliberate; see Build.
- **Ring 2 — seccomp:** a denylist removing the known escape launchpads (io_uring,
userfaultfd, bpf, ptrace, the mount and namespace family), with a mandatory
- x32-ABI guard so the filter can't be re-entered through the compat ABI.
+ x32-ABI guard so the filter can't be re-entered through the compat ABI. It also
+ carries the axes ring 1 cannot see: `socket()`/`socketpair()` are constrained by
+ family, **type** and **protocol**, because the kernel picks the transport from
+ any of the three and Landlock's TCP rights apply only to real TCP.
+
+Two things a run tells you about *itself*, because "enforced" is not the same as
+"enforced completely":
+
+- **`unhandled=`** on the seal line names every restrictable right this run left
+ open, derived by set difference against the kernel rights the linked crate can
+ name — so a right oring does not yet plumb reports itself rather than waiting to
+ be noticed. It is not suppressible by `ORING_QUIET`.
+- **Refusal on a policy that does not describe its own cage.** Landlock ORs an
+ ancestor's rule with a descendant's, so `read_exec` over `write_noexec` silently
+ becomes `write_exec` — a tier neither line names. oring refuses that rather than
+ sealing a cage wider than the policy reads.
When a denial leaves you with a bare `EACCES`, `--explain` shows the effective
policy and `--denials ` reconstructs what the kernel blocked.
+**Reach for `--denials` when a tool fails in a way that sounds nothing like a
+sandbox.** A denied path is still *visible* under Landlock — `stat()` and
+`access()` keep answering from ordinary file permissions, and only the `open()`
+is refused. That combination cannot happen without a sandbox, so a tool that
+checks `access()` first and then opens is told the file is readable and then
+fails anyway, landing in an error path meant for disk corruption. Measured:
+without a grant on `~/.config/git/config`, **every** git command fails with
+`fatal: unknown error occurred while reading the configuration files` — which
+names neither the file nor oring. `--denials ` names it directly:
+
+```console
+$ oring --denials 2970284
+denied:
+ fs.read_file /home/u/.config/git/config (x3)
+```
+
+The pid is on the `SEALED` line. The built-in profiles grant cargo's and git's config
+paths for exactly this reason. They do not grant `~/.npmrc`, `~/.config/pip/pip.conf`, or
+Go's `~/.config/go/env`; `balanced` grants `~/go` and `~/go/bin`, which are Go's module
+and binary directories rather than its config. No profile can enumerate every tool, so
+when something behaves oddly under oring and not outside it, `--denials` is the first
+thing to run. Go also has a larger breakage under the profiles: see `GOCACHE` above.
+
+Not every tool aborts. Measured on the same setup, `npm` reads an unreachable `~/.npmrc`,
+silently ignores it, and returns the default value. It does the same under ordinary file
+permissions, so that one is npm's behaviour rather than oring's.
+
+**Scratch directories are named, not inherited.** oring does not read `$TMPDIR`
+from the environment — a caller-supplied value used to widen a profile's writable
+tiers. Use `--tmpdir ` (no-exec) or `--exec-tmp ` (write+exec, for
+toolchains that link then run); oring sets the child's `TMPDIR` to whatever it
+actually granted.
+
## Build
-Linux **≥ 6.15** with Landlock enabled (`grep landlock /sys/kernel/security/lsm`),
-x86_64.
+Linux **≥ 7.2** with Landlock enabled (`grep landlock /sys/kernel/security/lsm`),
+x86_64. The floor is ABI V10; below it oring refuses at seal rather than running a
+partial cage.
```sh
git clone https://github.com/hexproofdev/oring && cd oring
make build # static-musl binary with an embedded auditable SBOM
-make check # the gate: fmt + clippy + test (make help lists the rest)
+make check # the gate: fmt + clippy + deny + test + smt (make help lists the rest)
```
-oring depends on the published [`landlock`](https://crates.io/crates/landlock)
-crate — nothing vendored. The optional V9 `resolve-unix` hardening builds against an
-out-of-tree fork (upstream
-[`rust-landlock#135`](https://github.com/landlock-lsm/rust-landlock/pull/135));
-see [`.cargo/config.toml.example`](.cargo/config.toml.example).
+Two costs of that floor, up front rather than discovered:
+
+- **Kernel.** The Landlock ABI is a property of the *running kernel* — no
+ userspace package raises it. On anything below 7.2 oring refuses at seal, by
+ design. **7.2 is not released** — it is at rc4 as of 2026-08-02 — so today
+ that means a self-built mainline kernel or a VM booting one.
+ **Containment is witnessed by `make check` on a >=7.2 host and nowhere else.**
+ No hosted runner is at the floor, so CI checks format, lints, dependency
+ policy, the static build, the SMT gate and the fail-closed refusal — and
+ witnesses no containment cell at all. Do not read a green CI badge as evidence
+ that the cage holds.
+- **Dependency.** The V10 UDP rights (`BindUdp` / `ConnectSendUdp`) are in **no
+ published [`landlock`](https://crates.io/crates/landlock) release**, so the
+ build uses the maintainer's fork, pinned by revision — upstream v0.4.7 plus
+ the unreleased `abi-v10` work (`eslerm/rust-landlock@d7bb0c1`). It is a `rev`
+ rather than a branch so the input cannot move under a build. `AccessFs::ResolveUnix` is *not* what the fork is
+ for: upstream released it, with ABI V9, in
+ [v0.4.7](https://github.com/landlock-lsm/rust-landlock/pull/135), and that is
+ the release vendored here.
+
+ Three consequences, and the first is the one a packager needs:
+
+ - **oring cannot be published to crates.io as it stands.** The `.crate`
+ drops `[patch.crates-io]` on publish (and rejects git dependencies), so
+ the packaged tree compiles against the registry `landlock` and fails —
+ measured 2026-08-01, `cargo package` stops with 5 errors on the absent
+ `AccessNet` variants. Install from this repository.
+ - `cargo install oring` does **not** build, for the same reason.
+ - `landlock` is not the clean registry dependency RustSec / `cargo deny` fully
+ cover; the shipped binary still carries an auditable SBOM (`make audit`).
+
+ When upstream releases V10 all three close together: bump the version, delete
+ the `[patch.crates-io]` block and the `allow-git` entry in `deny.toml`, no
+ behaviour change.
+
+ **Done 2026-08-02.** The patch moved from an in-tree copy to the fork it
+ already tracked. The pinned input is now a commit SHA in `Cargo.lock` rather
+ than an unchecksummed path, at the cost of the offline clone — a git
+ dependency needs the network at build time, where committed sources needed
+ nothing.
+
+ The vendored fork covers the V10 *access rights* only. ABI 10 also adds
+ `LANDLOCK_ADD_RULE_QUIET` and the `quiet_access_*` ruleset-attr fields, which
+ would need regenerated uapi bindings and struct-size handling against older
+ kernels; those are deliberately not implemented, and oring does not claim them.
## Alternatives
-For a batteries-included agent sandbox, reach for one of these — they do things
-oring deliberately doesn't:
+For a batteries-included agent sandbox, use one of these. They do things oring
+deliberately does not:
- [sandbox-runtime](https://github.com/anthropic-experimental/sandbox-runtime)
(Anthropic) — cross-platform (macOS/Linux/Windows), domain-level network egress
@@ -92,19 +240,25 @@ oring deliberately doesn't:
credential-brokering auth proxy and per-API egress. Needs a container runtime
and a privileged sidecar.
-oring is the minimal end of that spectrum: one unprivileged static binary, no
-runtime, no daemon, no user namespaces (Landlock, not bubblewrap), and a
-confinement small enough to formally verify. It runs where those can't —
-distroless images, userns-disabled kernels, a bare CI step — and works as a
-defense-in-depth layer *inside* them.
+oring is the minimal end of that spectrum: one unprivileged static binary, no runtime,
+no daemon, no user namespaces (Landlock, not bubblewrap), and a construction small enough
+to formally verify. The x32 guard and the ABI clamp are Kani-proven and the emitted filter
+is SMT-checked for truncation invariance. Read that precisely: what is proven is that
+oring **builds the filter it intends**. Whether the cage holds is the kernel's to enforce
+and is explicitly not verified; [`docs/VERIFICATION.md`](docs/VERIFICATION.md) lists what
+is out of scope as prominently as what is in it. oring runs where the tools above cannot,
+including distroless images, userns-disabled kernels and a bare CI step, and works as a
+defence-in-depth layer inside them.
## Status
-`v0.1.0`. Design and threat model: [`docs/DESIGN.md`](docs/DESIGN.md). How it's
+`v0.2.0`. Design, residuals and non-goals: [`docs/DESIGN.md`](docs/DESIGN.md).
+Is a behaviour an oring bug: [`THREAT_MODEL.md`](THREAT_MODEL.md). What oring
+claims to enforce, and what enforces it: [`CONSTRAINTS.md`](CONSTRAINTS.md). How it's
proven: [`docs/VERIFICATION.md`](docs/VERIFICATION.md). Attack catalogue:
[`docs/ATTACKS.md`](docs/ATTACKS.md). Release history:
[`CHANGELOG.md`](CHANGELOG.md). Contributors and agents start with
-[`CLAUDE.md`](CLAUDE.md). Found a way past the cage? See
-[`SECURITY.md`](SECURITY.md) — that's exactly what we want to hear.
+[`CLAUDE.md`](CLAUDE.md). Found a way past the cage? [`SECURITY.md`](SECURITY.md) is the
+route, and a report is worth sending even if you are unsure.
BSD-3-Clause — see [`LICENSE`](LICENSE).
diff --git a/SECURITY.md b/SECURITY.md
index 21ea2a3..77aea71 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -4,20 +4,73 @@
## Reporting a Vulnerability
-Found a way past the cage? Report it privately via GitHub — the repository's
+Report a suspected vulnerability privately via GitHub — the repository's
**Security** tab → **Report a vulnerability** (this drafts a private advisory).
Email **security@hexproof.dev** works too. Please include a description, a
reproducer or proof-of-concept, and the affected version or commit.
-You can expect acknowledgment within a few days, a fix for valid issues, and credit
-if you'd like it. Please give us time to patch before disclosing publicly.
+## What to expect
+
+- **Acknowledgement within 3 business days.** If you have not heard back by
+ then, assume the message did not arrive and send it again.
+- **Disclosure deadline: 90 days** from the report, or when a fix is available,
+ whichever comes first. If a fix will take longer, we will say so before day 90
+ and agree an extension with you rather than let the date pass in silence.
+- **Credit if you want it**, in the advisory and in `CHANGELOG.md`. Say how you
+ would like to be named, or say you would rather not be.
+- **The advisory identifier**: we request it and it publishes through GitHub's
+ advisory database. If you have already requested one, say so and we will use
+ yours.
+
+## Safe harbour
+
+We will not pursue or support legal action against anyone who reports a
+vulnerability in good faith under this policy — meaning you stayed within your
+own systems or a test environment you control, did not access, modify or exfil
+data that is not yours, did not degrade a service anyone else relies on, and gave
+us the time above before publishing.
+
+If you are unsure whether something is in scope, ask first. A question costs
+nothing and this paragraph is meant to be relied on.
+
+## Supported versions
+
+The most recent tagged release only. oring is a single static binary with no
+runtime and no update channel, so a fix ships as a new tag and there is no
+backport branch. If you need a fix against an older tag for a reason we have not
+thought of, say so in the report.
## Scope
-A *bypass of the cage* — escaping the Landlock or seccomp confinement oring applies
-to itself — is a vulnerability. A **documented residual is not**: the pathname-socket
-reach without `--features resolve-unix`, UDP/DNS egress, peer `/proc/` reads
-(`environ`/`maps` under `--proc full` — use `--proc self` or a runtime PID-ns),
-"not a VM boundary", and the workdir-poisoning class are all by design and enumerated
-in the threat model — see [`docs/DESIGN.md`](docs/DESIGN.md) §2. Those are the model working
-as intended, not defects.
+[`THREAT_MODEL.md`](THREAT_MODEL.md) decides whether a behaviour is an oring security bug:
+which party acted, which level it sits at, and what makes it a bypass rather than a grant
+or a documented residual. It is the authority, and this file does not summarise it.
+[`docs/DESIGN.md`](docs/DESIGN.md) §2 enumerates what oring does *not* stop, and
+[`CONSTRAINTS.md`](CONSTRAINTS.md) what it claims to enforce.
+
+**If you are unsure, send it anyway.** We would rather reject a report than miss one.
+
+
diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md
new file mode 100644
index 0000000..3999d90
--- /dev/null
+++ b/THREAT_MODEL.md
@@ -0,0 +1,260 @@
+
+# THREAT MODEL — is this an oring bug, and how bad is it?
+
+This file classifies a behaviour. It enumerates no residuals: `docs/DESIGN.md` §2 holds
+those with the loss each belongs to, and `CONSTRAINTS.md` holds what oring claims to
+enforce and what enforces it. **With a behaviour in hand, start at §4.**
+
+`tests/threat_model.rs` checks that every id written in backticks below resolves in
+`CONSTRAINTS.md`, that this file carries none of the shapes that hold evidence in the
+other two, and that `SECURITY.md` and `README.md` still link it. It does not check that
+any rule here is correct; §10 says what this file does not establish.
+
+Modelled on Mickaël Salaün's *"landlock: Document the threat model"* (2026-07-07,
+`Documentation/security/landlock.rst`). Three points of departure are marked
+**DIVERGES FROM LANDLOCK** below.
+
+---
+
+## 1. The parties
+
+Four. §4 starts by asking which one acted, because the answer changes the
+classification.
+
+- **The child, after the seal.** `restrict_self` + `execve` have both happened, every
+ ring is in force and irrevocable. Assume it holds arbitrary native code at uid 1000
+ (prompt-injection → shellcode is the motivating path). **This is the adversary**, and
+ containment breach is the primary loss. Nothing in the level structure below demotes
+ that; if a taxonomy reading and the adversary reading ever conflict, the adversary wins
+ and the taxonomy is wrong.
+- **The caller, before the seal.** Trusted to author a policy; not trusted to widen one
+ ambiently. Authored sources are `--policy`, `--profile`, `--tmpdir`,
+ `--exec-tmp`. Ambient sources are `ORING_POLICY`, `$TMPDIR`, `$HOME`. Where an ambient
+ source would override or widen an authored one, oring refuses rather than picking a
+ winner. This is `CON-6`, and it is a **narrower** trust grant than "the caller is
+ trusted": three shipped guards refuse ambient widening rather than resolve it.
+- **The operator, after the run.** Holds a workdir the child could write, and executes it
+ later at their own uid, outside the cage. Their process model is supplied by oring's
+ documentation, which makes **the documentation a control action and the operator the
+ controller** — the relation `CON-7` names.
+- **oring itself.** There is no process boundary between sealer and sealed: `execve`
+ preserves the process, so oring *is* the child. Every claim below assumes oring's own
+ machinery is intact, and `docs/DESIGN.md` §2's *what could be wrong with ORING ITSELF*
+ residuals are where that assumption is examined rather than asserted.
+
+## 2. What oring guarantees
+
+One sentence, and it is `CON-1`: **every capability the child obtains is named by the
+policy, or is named on the seal line as unrestricted.** One measured case sits on the
+boundary of that sentence and is not settled: a path the policy withholds, handed to the
+child by the caller on fd 0. §6 states it, says why it is open, and says to report it.
+
+Two consequences worth stating because reports turn on them:
+
+- **A grant is not a residual.** If `--explain` prints the tier, the access is the
+ policy's decision, not oring's defect. The workdir being writable under `balanced` is a
+ grant.
+- **An unhandled right is disclosed on stderr, not silently dropped.** `unhandled=` on
+ the seal line names what the run left unrestricted, and `--require-full-coverage`
+ converts it into refusal code 120 before `execve`. That flag is opt-in; without it, a
+ caller who redirects stderr removes the disclosure. `CON-5` records this as a live
+ limitation with the measurement behind it.
+
+## 3. Three levels, and why they are not one ranking
+
+`CONSTRAINTS.md` defines these ids; this file only cites them.
+
+- **Losses** (`BREACH`, `LAUNDER`, `DIVERGE`) — what the operator actually suffers.
+ Ordering them is **not a technical output**: in STAMP terms losses are
+ stakeholder-defined, so the ranking belongs to whoever bears them and is recorded in
+ the maintainer's decision log, not derived here.
+- **Hazards** (`HZ-REACHABLE`, `HZ-DIVERGENCE`, `HZ-INSTRUCTED`) — system *states* that
+ lead to a loss. "Behaves differently confined than unconfined" is a state, not a loss.
+- **Feedback defects** (`MISREPORT`, `OPAQUE`) — defects in what oring *reports*. These
+ are **multipliers, not ranks**: they act on the operator's ability to detect and
+ correct every loss above them, so the question to ask is never "is `MISREPORT` worse
+ than `BREACH`" but "which losses does this one stop anybody noticing".
+
+**Items from different levels cannot be linearly ordered**, and retracted inferences in
+this project's history were exactly that error, among them choosing between *two
+mitigations of one loss* using a loss rank. If a report or a review compares two items,
+check they are on the same level first.
+
+## 4. Is it an oring security bug? Check in order
+
+1. Which party performed the action? If the caller handed the child a capability —
+ an fd redirected onto stdin, a policy granting the path — see §6 first: that is
+ ordinarily *not* a bypass, and one measured case sits on that boundary unruled.
+2. Was the capability named by the policy, or named on the seal line as unrestricted?
+ If yes, it is a grant or a disclosed gap. Not a security bug; §8 says which
+ non-security reports are still worth sending. `--explain` and `unhandled=` are how you
+ check.
+3. Does a named residual in `docs/DESIGN.md` §2 already cover it? If yes, it is a
+ documented residual and not a security bug — **but check the residual's own scope
+ before closing the report.** A residual that is *wider than the sentence recording it*
+ is a bug in the record, and this tree has a measured instance. `docs/ATTACKS.md`
+ recorded delivery over an inherited descriptor in its peer-procfs form, and the
+ measurement found the same delivery working for **any** withheld path under both
+ shipped profiles. The record was narrower than the mechanism. What matters is whether
+ the mechanism, rather than the example, is the one written down.
+4. Did the child obtain a capability the policy did not grant? That is `BREACH`, and
+ it is a security bug. Report it.
+5. Does an instrument assert a state a same-run measurement contradicts? A seal field
+ that reads clean while the thing it names is false is `MISREPORT`, and **it is a
+ security bug even though nothing escaped.** A forged or misleading audit record is
+ worse than no record: missing feedback stops a controller, corrupted feedback steers
+ it, and a forged seal line has been injected through argv alone with no policy file
+ involved (`docs/DESIGN.md` §2).
+6. Did the run leave the system with a broader or longer-lived exposure than it found?
+ That is `LAUNDER`, and it is in scope **with nobody attacking**. See §7.
+7. Did oring deny something the policy granted? That is over-enforcement — a
+ *compatibility* bug (`CON-8`), not a security bug. Still report it; the fix relaxes
+ rather than tightens, and the class matters because every tightening oring ships is a
+ candidate violation of it.
+
+If none of the above fires, the behaviour is out of scope, and §8 says which kind of out
+of scope.
+
+## 5. Under-enforcement, over-enforcement, and why oring is not best-effort
+
+- **Enforcing less than the policy names is under-enforcement: a security bug.** The fix
+ may make a deployed policy stricter, but only by finally enforcing what it already
+ requested.
+- **Enforcing more than the policy names is over-enforcement: a compatibility bug
+ (`CON-8`).**
+
+**DIVERGES FROM LANDLOCK — oring is not best-effort.** Landlock's model is explicitly
+best-effort: it enforces what the running kernel supports rather than refusing to sandbox
+at all, because a program and its kernel are built independently. oring inverts that at
+the substrate boundary. Below the Landlock ABI floor it **refuses to seal** rather than
+installing a partial cage (`CON-9`), and a `RulesetStatus` of `NotEnforced` or
+`PartiallyEnforced` is a hard refusal. So *"oring ran and enforced less than the policy
+named because the kernel could not"* is not a limitation to be tolerated here — it is
+either a refusal you should have seen, or a bug. The one place the best-effort pattern
+survives is `unhandled=`: a right the running kernel cannot handle is disclosed and the
+run proceeds, and that is exactly the degraded-path-with-a-warning shape this project
+rejects elsewhere. It is recorded as an open product question in `CON-5`, not defended.
+
+## 6. Resources the caller hands in — and the one case that is still open
+
+**Landlock's rule, quoted because oring inherits most of it:** *"receiving or inheriting
+a file descriptor (or similar) is governed by the capability model, not Landlock … a
+possible confused deputy, not a bypass."* A domain restricts operations initiated under
+it, not capabilities the process already holds. oring `execve`s a fresh image and passes
+no sockets, so what crosses the seal is what the **caller** put there.
+
+**DIVERGES FROM LANDLOCK, and this is the open question.** Landlock's caller is an
+unsandboxed process that willingly passes a resource in. oring's caller is the party
+`CON-6` says may author a policy but may **not** widen one ambiently — the tree ships
+three guards that refuse rather than resolve that ambiguity. Importing the confused-deputy
+clause wholesale would license, by construction, exactly the widening those guards refuse.
+
+The measured instance that decides it: **any path the policy withholds is deliverable to
+the confined child on fd 0**, under both shipped profiles, with a seal line byte-identical
+to the run that denied it (`docs/DESIGN.md` §2, the *resource the domain did not create*
+residual). Under *"the child obtains a capability the policy did not
+grant"* that is `BREACH`; under *"the child escaped"* it is not. **The definition is
+ambiguous, the ambiguity is load-bearing, and it is the maintainer's to rule on** — this
+file states the question rather than settling it by quoting a document written for a
+different system. Open and unruled as of 2026-07-30. Until it is ruled: **report it, and
+it will be triaged, not rejected.**
+
+What is *not* open: fds 0–2 are deliberately preserved (a child without stdio is broken)
+and fd 3 and above are scrubbed. That is a design decision with a stated reason, not an
+oversight, though no fixture covers the scrub at either end (`CON-1`).
+
+## 7. Blast radius
+
+Two dimensions, and one exception that is oring's alone.
+
+- **Within a run.** A defect in a domain-wide mechanism — the seal order, the ABI floor,
+ credential handling — can drop every restriction the run installed. A missing rule for
+ one right affects only policies that named that right and leaves the rest intact.
+- **Across runs.** A policy is self-imposed per invocation, not system-wide. A defect
+ weakens only the runs that requested the affected restriction. There is no persistent
+ component, no daemon, and nothing survives the child's exit.
+- **DIVERGES FROM LANDLOCK — the exception, and it is the reason `LAUNDER` exists.**
+ Landlock's mechanism only *adds* restrictions on top of existing access controls, so an
+ enforcement bug can at most undo Landlock's own. oring is a mechanism plus a set of
+ profiles plus documented operator procedure, and the last two can leave the machine
+ worse than not running oring at all. Both are measured: a profile that granted write on
+ a shared package cache turned a bounded single-project hazard into a persistent
+ cross-project one, and a documented review control fired the payload it existed to
+ detect, four times, at the operator's uid, outside the cage. **A run that is worse than
+ no run is a first-class loss here**, and it needs no attacker. `CON-2` carries it; the
+ enforced half is the profiles, not the procedure.
+
+## 8. What is NOT an oring security bug
+
+Out of scope as a *security* bug. Several are still worth reporting, and each entry says
+where it belongs.
+
+- **A documented residual, reported as itself.** `docs/DESIGN.md` §2 enumerates what
+ oring does not stop, with the loss each belongs to and whether any instrument reports
+ it. Read it before reporting. A residual *wider than its record* is a different report
+ and is in scope (§4.3).
+- **Anything oring's non-goals surrender.** It is not a VM boundary, not a kernel-0-day
+ defence beyond surface reduction, not a network policy engine (domain/IP egress is a
+ required companion control at the host layer), does not author host MAC, does not mask
+ procfs/sysfs, and ships no supervisor, broker, cgroup or rlimit machinery — each needs a
+ persistent or privileged component oring by definition is not. `docs/DESIGN.md` §2 holds
+ the list; this is a pointer, not a second copy.
+- **Denial of service.** A compromised child can fork-bomb, fill the workdir, or exhaust
+ the host. Surrendered by the non-goal above, deliberately, not overlooked.
+- **Information disclosure about the policy.** Which errno a denial returns, whether a
+ path exists, how long an operation took — these can reveal policy shape. Not a security
+ bug, except where the denial's *report* is false, which is `MISREPORT` (§4.5).
+- **What a retained privilege buys outside oring's coverage.** oring is unprivileged and
+ self-applied; dropping privileges is the caller's job, and `NO_NEW_PRIVS` prevents
+ regaining them, not holding them.
+- **The behaviour of a service the child is allowed to reach.** If the policy grants a
+ socket, what the service on the other end does with the child's request is that
+ service's authorization problem. A more-privileged service acting on a confined
+ client's request without checking is a confused deputy in the service.
+- **A tool breaking under confinement because it pre-flights with `access()`.** Landlock
+ hooks the open, not the metadata check, so a denied path stays visible and a tool that
+ trusts the two to agree walks into a branch it never tests. This is structural — no ABI
+ bump changes it — and it is a diagnosability and compatibility residual, tracked as
+ `CON-3`. It is not a containment hole. It is worth reporting: which paths the profiles
+ should grant, to cut how often it is hit, is still open.
+
+## 9. Sandboxing is layered
+
+oring is the access-control layer of a sandbox, not the sandbox. Three companion controls
+are required, not optional, and their absence is not an oring bug:
+
+- **Host-layer egress control** — Landlock's network rights are port-only, so
+ domain/IP-level egress needs host nftables default-deny or a filtering proxy.
+- **Runtime masking and PID namespacing** — masking `/proc` and `/sys`, and hiding peer
+ PIDs, are the container runtime's job. oring's scope ring is defence-in-depth for the
+ signalling and abstract-socket half only; for peer `/proc` reads the oring-side control
+ is `--proc self`, which narrows the residual at a fork-compatibility cost and is not a
+ replacement either (`docs/DESIGN.md` §2).
+- **Operator procedure after the run** — a workdir an agent touched is untrusted input.
+ **Do not use `git` to inspect it**: repo-local `.git/config` is read on every `git`
+ command and `core.fsmonitor`, `core.pager`, `core.sshCommand`, `alias.*` and
+ `diff.*.textconv` are each an execution trigger, so the review *is* the execution.
+ What survives measurement is inspecting with tools that do not interpret the tree as a
+ repository, or re-cloning into a throwaway checkout. `docs/DESIGN.md` §2 carries the
+ measurement and the two retractions that produced this sentence.
+
+## 10. What this file does not establish
+
+- **It cannot tell you a classification is correct.** It fixes the *procedure*; whether a
+ given behaviour was filed under the right loss is adversarial review's job and stays
+ with people.
+- **It is a control action with no measured efficacy.** oring's documentation sets the
+ operator's process model (§1), and the gap is `CON-7`'s: **no fixture in this tree runs
+ a documented control against the residual it is named for.** That row, not this file,
+ carries its status. This file is another control action under it and inherits the gap.
+- **The `BREACH` boundary is open** (§6). It is the one definitional question a reporter
+ can hit today with no rule to apply.
+- **`abi=` and `errata=` are not behaviour certificates.** `errata=` is a lower bound: it
+ enumerates only errata the kernel declares, and two kernels agreeing on both may still
+ differ in behaviour.
+
+## 11. Reporting
+
+Route and expectations are in [`SECURITY.md`](SECURITY.md). **If §4 leaves you unsure,
+send it anyway:** rejecting a report is cheaper than missing one, and the ambiguity in §6
+is real.
diff --git a/bench/README.md b/bench/README.md
new file mode 100644
index 0000000..91bfedf
--- /dev/null
+++ b/bench/README.md
@@ -0,0 +1,135 @@
+# bench/ — what oring costs, measured
+
+oring shipped v1 with **one** cost claim in the whole tree (`docs/DESIGN.md` §5,
+the errno-partition bullet) and no instrument behind it. This directory is the
+instrument.
+
+The measuring lives in `src/bin/bench.rs` — in the package, not beside it, so
+`make check`'s `cargo clippy --all-targets` compiles it and it cannot rot as the
+code it measures moves. It calls `seccomp::dump_groups` directly, so what it
+times is what `seccomp::apply` installs. `bench/run.sh` supplies what a single
+measurement cannot: the pinning, the rounds, the controls, and the frame.
+
+```sh
+bench/run.sh # everything, with controls, ~3 minutes
+bench/run.sh --cpu 6 --rounds 3 # defaults
+cargo run --release --features resolve-unix,bench --bin bench -- help
+```
+
+| question | subcommand | term it isolates |
+|---|---|---|
+| how long does one `oring … -- cmd` add? | `exec N EXPECTED cmd…` | fixed per-invocation cost |
+| what does installing ring 2 cost? | `install` | seccomp filter install (BPF JIT) |
+| ring 2 per syscall — and per *filter*? | `filters K N` | seccomp entry vs. filter count |
+| what does ring 1 cost per `open()`? | `opens PATH N` | Landlock path mediation |
+| what are the per-path seal costs made of? | `paths DIR N` | `canonicalize`/`metadata`/`open` |
+| how far is the emitted cBPF walked? | `sim` | filter path length, no kernel needed |
+
+## The discipline these harnesses enforce, and why
+
+**Check the exit status of the thing you timed.** The first draft of this sweep
+reported `--profile strict` as *faster than the bare command* — that invocation
+exited 121 (`--workdir must be absolute`) with stderr on `/dev/null`, so it never
+sealed and never exec'd. A clean-looking fast number over a command that did not
+run. `bench exec` takes the expected status and prints **no timing at all** if any
+run disagrees; a number beside a warning gets quoted without the warning.
+
+The same shape caught a second one: section 5 first failed with `spawn /bin/true:
+Permission denied`, because the harness redirects its children to `/dev/null` and
+the policy did not grant it. The C fixture this replaced had silently degraded
+instead — it checked `open("/dev/null")` for failure and skipped the redirect,
+so it measured a *different* thing and reported a number for it.
+
+**Interleave the control, and print the warm-up you discarded.** This box is
+shared. Every section runs its control on the same pinned CPU in the same round,
+and rounds are printed individually rather than averaged. Round 1 reads high
+(measured: `/bin/true` at 175 µs in round 1 against 126 µs in rounds 2-3, a 39%
+frequency ramp). If rounds 2..N disagree with each other, the box was busy and
+the run is not quotable.
+
+**A simulation is not a profile.** `bench sim` interprets the emitted cBPF and
+reports how far an allowed syscall walks. That is a model of the filter,
+corroborated by `install`/`filters` but not a substitute: 215 instructions and
++4.4 ns do not reconcile arithmetically, and the reason is what the `filters`
+sweep measures rather than what `sim` asserts.
+
+## What it measured, 2026-07-30
+
+Host `7.2.0-rc4-oring`, 32 cores, `bpf_jit_enable=1`, load 0.3-0.5, `taskset -c 6`,
+3 rounds, round 1 discarded as warm-up. **glibc host release build — not the
+shipped static-musl artifact**; the process-start term differs there, the
+kernel-side terms do not.
+
+Per invocation — `/bin/true` costs 127 µs; under oring it costs 758 µs (p50):
+
+| term | cost | scales with |
+|---|---|---|
+| oring's own process start | +73 µs (200 vs 127) | binary size |
+| policy parse + validate + ABI probe | +38 µs (`--check` 238) | policy size |
+| **ring 2: 4 seccomp filter installs** | **227 µs** | filter count × length |
+| ring 1 + the rest of the seal | ~90 µs at 8 paths | path count, at 4.8 µs/path |
+| the second `execve` any wrapper implies | +203 µs (`env` control) | fixed |
+| **total added** | **+631 µs** | |
+
+The install is 72% of the ~317 µs the seal itself costs (758 − 238 − 203), and
+36% of everything oring adds. Note the shape *within* it: `enosys` (100 insns)
+costs 61 µs while `eopnotsupp` (105 insns) costs 22 — the first install carries a
+one-time ~40 µs, and after that cost tracks program length (`eperm`, 457 insns,
+120 µs).
+
+Steady state, inside the cage:
+
+| term | cost | scales with |
+|---|---|---|
+| per syscall | +4.4 ns (33.7 → 38.1 ns, +13%) | **seccomp entry, not filters** |
+| per `open()`, warm cache | +0.175 µs (0.461 → 0.636, +38%) | rule count, but barely — see below |
+| per `fork`+`execve` inside the cage | +1-2 µs on 126 (≈1%) | — filters and domain are inherited |
+
+The filter-count sweep is the load-bearing one (rounds 2-3):
+
+```
+filters=0 33.8 / 33.6 ns filters=2 37.6 / 38.3
+filters=1 38.2 / 38.4 ns filters=3 39.1 / 38.4
+ filters=4 39.3 / 38.7
+```
+
+Effectively all of it is the cost of *entering* the seccomp path: the step is at
+k=1 and filters 2-4 add ~0.5 ns between them, inside the round-to-round spread —
+even though an allowed syscall walks ~215 cBPF instructions across the stack.
+**The errno partition costs nothing measurable at runtime and 227 µs at install**
+— the opposite of what §5's cost sentence implied.
+
+Ring 1's per-`open` cost does depend on rule count, slightly and reproducibly:
+0.634/0.642/0.634 µs at 8 rules against 0.647/0.645/0.646 at 43 — about +0.012 µs
+for 35 extra rules (~0.3 ns/rule), i.e. **7% of the mediation cost across a 5×
+larger ruleset**. Flat enough to plan around, not flat enough to state as flat.
+
+Two consequences worth carrying:
+
+- **The fixed per-invocation cost dominates; amortisation is the only lever.** A
+ subprocess tree of any size under one seal pays the 631 µs once. Wrapping a
+ per-item sweep item-by-item multiplies it by the item count; wrapping the sweep
+ pays it once.
+- **`strict` is not slower than `balanced`.** 864 instructions against 809, 9
+ more per allowed-syscall miss, no measurable runtime difference.
+
+## What is still unattributed
+
+Of the 4.8 µs per granted path, ~2.0 µs is accounted for: `metadata` 0.22 µs +
+`PathFd`'s `O_PATH` open (≤0.46 µs — `bench paths` prices `File::open`, which is
+an upper bound) + the ≥3 `canonicalize` passes every declared path takes at
+0.44 µs each. Those passes are in `apply_proc_mode`, `check_tier_overlap`,
+`proc_exposure`, and 5× per *write* path in `writable_shared_caches`, where the
+canonicalise sits inside the `SHARED_CACHES` loop.
+
+The remaining ~60% needs a profile of oring itself, which is what
+`[profile.profiling]` exists for:
+
+```sh
+cargo build --profile profiling --features resolve-unix
+perf record -F 997 -g --
+```
+
+Check the root frame's sample count against rate × duration before reading
+anything off it. A 758 µs process sampled at 997 Hz needs thousands of iterations
+or the profile is idle and every conclusion from it is void.
diff --git a/bench/run.sh b/bench/run.sh
new file mode 100755
index 0000000..58d5c33
--- /dev/null
+++ b/bench/run.sh
@@ -0,0 +1,194 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: BSD-3-Clause
+#
+# run.sh — what oring costs, with the control beside every number.
+#
+# bench/run.sh [--cpu N] [--iters N] [--rounds N] [--keep]
+#
+# The measuring is all in `src/bin/bench.rs`; this supplies what a single
+# measurement cannot: the pinning, the rounds, the controls, and the FRAME —
+# host, kernel, pinned CPU, iteration count, path count, filter count, printed
+# before any number. A number without its frame is not a measurement, and this
+# tree has paid for that lesson more than once.
+#
+# Every timed invocation has its exit status checked: `bench exec` takes the
+# expected status and refuses to print a time if any run disagrees. The first
+# draft of this sweep reported a REFUSED oring invocation as "faster than the
+# bare command"; that is the failure mode this script is shaped around.
+#
+# Rounds are printed, never averaged away. Round 1 is frequency warm-up on most
+# hosts and reads ~40% high. If rounds 2..N disagree with each other the box is
+# too busy and the run is not quotable — say so rather than picking one.
+set -euo pipefail
+
+CPU=6
+ITERS=400
+ROUNDS=3
+KEEP=0
+SYSN=3000000 # syscalls per `bench syscalls` / `bench filters` loop
+FSN=200000 # opens per `bench opens` loop
+PATHN=200000 # iterations per `bench paths` primitive
+EXTRA=35 # extra granted paths in the second policy (isolates the per-path term)
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --cpu) CPU="$2"; shift 2 ;;
+ --iters) ITERS="$2"; shift 2 ;;
+ --rounds) ROUNDS="$2"; shift 2 ;;
+ --keep) KEEP=1; shift ;;
+ -h|--help) sed -n '4,21p' "$0"; exit 0 ;;
+ *) echo "run.sh: unknown argument $1" >&2; exit 2 ;;
+ esac
+done
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+
+# Pinning is not decoration: this box is shared and an unpinned loop migrates
+# across cores mid-run. If taskset is absent we still run, but the frame says so
+# — conditions left unstated are worse than no number.
+if command -v taskset >/dev/null 2>&1; then
+ PIN=(taskset -c "$CPU"); PINNED="taskset -c $CPU"
+else
+ PIN=(); PINNED="NOT PINNED (taskset absent) — treat every number as noisy"
+fi
+
+TMP="$(mktemp -d "${TMPDIR:-/tmp}/oring-bench.XXXXXX")"
+cleanup() { [ "$KEEP" = 1 ] || rm -rf "$TMP"; }
+trap cleanup EXIT
+mkdir -p "$TMP/data"
+# Inherited TMPDIR makes oring print an INFO line into every measurement's
+# output. Drop it AFTER mktemp has used it: the note is correct and belongs in
+# real runs, it just is not part of what is being measured here.
+unset TMPDIR
+
+echo "== building =================================================================="
+cargo build --release --features resolve-unix --locked >/dev/null
+cargo build --release --features resolve-unix,bench --bin bench --locked >/dev/null
+ORING="$ROOT/target/release/oring"
+BENCH="$ROOT/target/release/bench"
+head -c 4096 /dev/urandom > "$TMP/data/f"
+
+# Two policies differing ONLY in path count, so the per-path term is isolated
+# rather than inferred. Nothing is writable, so the policy files cannot land
+# under a write tier and oring's self-protection guard stays quiet. All four net
+# fields are set, so `unhandled=` is `-` and no coverage warning muddies stderr.
+BIN_DIR="$(dirname "$BENCH")"
+net='"egress_tcp":[],"ingress_tcp":[],"egress_udp":[],"ingress_udp":[],"scope_ipc":true'
+ro7="\"/etc\",\"/proc\",\"$TMP/data\""
+# /dev/null is granted because the harness redirects its children there, and a
+# policy that cannot open it fails the exec measurement rather than slowing it —
+# which is precisely the shape section 5 caught the first time this ran.
+wr='"/dev/null"'
+rx="\"/usr\",\"/lib64\",\"/lib\",\"$BIN_DIR\""
+printf '{"version":1,"landlock":{"read_exec":[%s],"read_only":[%s],"write_noexec":[%s],%s},"seccomp":{"profile":"balanced"}}\n' \
+ "$rx" "$ro7" "$wr" "$net" > "$TMP/pol7.json"
+
+roN="$ro7"
+for i in $(seq 1 "$EXTRA"); do
+ d="$TMP/paths/$i"; mkdir -p "$d"; roN="$roN,\"$d\""
+done
+printf '{"version":1,"landlock":{"read_exec":[%s],"read_only":[%s],"write_noexec":[%s],%s},"seccomp":{"profile":"balanced"}}\n' \
+ "$rx" "$roN" "$wr" "$net" > "$TMP/polN.json"
+N7=8; NN=$((N7 + EXTRA))
+
+export ORING_QUIET=1
+
+cat </dev/null || echo '?')
+ iterations exec: $ITERS syscall: $SYSN open: $FSN path: $PATHN
+ policies pol7 = $N7 paths, polN = $NN paths (identical but for path count)
+ rounds $ROUNDS (round 1 is frequency warm-up on most hosts — printed, not dropped)
+EOF
+
+echo
+echo "== 1. per invocation: what one \`oring -- cmd\` adds =========================="
+echo " controls: the bare command; \`env cmd\` (a second execve, no confinement);"
+echo " oring --version (start+exit, no policy); oring --check (parse+validate, no seal)"
+for r in $(seq 1 "$ROUNDS"); do
+ echo " -- round $r"
+ printf ' %-30s ' 'bare /bin/true'
+ "${PIN[@]}" "$BENCH" exec "$ITERS" 0 /bin/true
+ printf ' %-30s ' 'env /bin/true (2 execs)'
+ "${PIN[@]}" "$BENCH" exec "$ITERS" 0 /usr/bin/env /bin/true
+ printf ' %-30s ' 'oring --version'
+ "${PIN[@]}" "$BENCH" exec "$ITERS" 0 "$ORING" --version
+ printf ' %-30s ' 'oring --check (no seal)'
+ "${PIN[@]}" "$BENCH" exec "$ITERS" 0 "$ORING" --policy "$TMP/pol7.json" --check -- /bin/true
+ printf ' %-30s ' "oring seal, $N7 paths"
+ "${PIN[@]}" "$BENCH" exec "$ITERS" 0 "$ORING" --policy "$TMP/pol7.json" -- /bin/true
+ printf ' %-30s ' "oring seal, $NN paths"
+ "${PIN[@]}" "$BENCH" exec "$ITERS" 0 "$ORING" --policy "$TMP/polN.json" -- /bin/true
+done
+
+echo
+echo "== 2. ring 2: installing the errno-partitioned filters ======================"
+echo " the term docs/DESIGN.md §5 did not name, and the one that dominates"
+for r in $(seq 1 "$ROUNDS"); do
+ echo " -- round $r"
+ "${PIN[@]}" "$BENCH" install
+done
+
+echo
+echo "== 3. ring 2: ns/syscall as a function of FILTER COUNT ======================"
+echo " k=0 is the control. If the cost were the linear partition scan this would"
+echo " rise with k; if it is seccomp entry it steps once at k=1 and then flattens."
+for r in $(seq 1 "$ROUNDS"); do
+ echo " -- round $r"
+ for k in 0 1 2 3 4; do printf ' '; "${PIN[@]}" "$BENCH" filters "$k" "$SYSN"; done
+done
+
+echo
+echo "== 3b. the same term under a REAL seal (control for 3) ======================"
+for r in $(seq 1 "$ROUNDS"); do
+ printf ' -- round %s unsealed: ' "$r"; "${PIN[@]}" "$BENCH" syscalls "$SYSN"
+ printf ' sealed: '
+ "${PIN[@]}" "$ORING" --policy "$TMP/pol7.json" -- "$BENCH" syscalls "$SYSN"
+done
+
+echo
+echo "== 4. ring 1: us/open+close, and whether it scales with RULE COUNT =========="
+for r in $(seq 1 "$ROUNDS"); do
+ printf ' -- round %s unsealed : ' "$r"; "${PIN[@]}" "$BENCH" opens "$TMP/data/f" "$FSN"
+ printf ' sealed %-2s : ' "$N7"
+ "${PIN[@]}" "$ORING" --policy "$TMP/pol7.json" -- "$BENCH" opens "$TMP/data/f" "$FSN"
+ printf ' sealed %-2s : ' "$NN"
+ "${PIN[@]}" "$ORING" --policy "$TMP/polN.json" -- "$BENCH" opens "$TMP/data/f" "$FSN"
+done
+
+echo
+echo "== 5. fork+exec from INSIDE the cage vs outside =============================="
+echo " filters and the Landlock domain are inherited, so this measures whether a"
+echo " subprocess tree RE-PAYS the seal. If these differ, section 1's cost is"
+echo " per-process; if they do not, it is per-oring-invocation and amortisable."
+for r in $(seq 1 "$ROUNDS"); do
+ printf ' -- round %s outside: ' "$r"; "${PIN[@]}" "$BENCH" exec "$ITERS" 0 /bin/true
+ printf ' inside: '
+ "${PIN[@]}" "$ORING" --policy "$TMP/pol7.json" -- "$BENCH" exec "$ITERS" 0 /bin/true
+done
+
+echo
+echo "== 6. primitives underneath the per-path seal cost =========================="
+for r in $(seq 1 "$ROUNDS"); do
+ echo " -- round $r"
+ "${PIN[@]}" "$BENCH" paths "$TMP/data" "$PATHN"
+done
+
+echo
+echo "== 7. emitted cBPF: how far an allowed syscall walks (model, not a timing) =="
+echo " -- balanced"
+"$BENCH" sim | sed 's/^/ /'
+echo " -- strict"
+"$BENCH" sim --strict | sed -n '1,7p' | sed 's/^/ /'
+
+echo
+echo "== done. Rounds are printed individually on purpose: if 2..$ROUNDS disagree, the"
+echo " box was busy and this run is not quotable. Numbers of record: bench/README.md."
diff --git a/deny.toml b/deny.toml
index cabcbd9..cc34afb 100644
--- a/deny.toml
+++ b/deny.toml
@@ -32,12 +32,24 @@ confidence-threshold = 0.8
# by [advisories] + the [sources] checksum lock.
multiple-versions = "warn"
wildcards = "warn"
-# `landlock` is the published crates.io crate (Ring 1). It is a normal registry
-# dependency with a `source` + `checksum` in Cargo.lock, so `[advisories]`
-# (RustSec) and `[sources]` cover it like any other crate — no special handling.
-# The ABI V9 / RESOLVE_UNIX hardening is an opt-in `resolve-unix` feature built
-# against an out-of-tree fork override (see .cargo/config.toml.example); that
-# override is a developer/CI concern and never part of the checked-in graph.
+# `landlock` (Ring 1) is NOT a clean registry dependency, and this file cannot
+# pretend otherwise. The **V10** floor needs the UDP net rights, which no
+# published release carries, so `Cargo.toml` has a committed `[patch.crates-io]`
+# pointing at the maintainer's fork, pinned by revision. `Cargo.lock` therefore
+# carries a real `source` for it — better than the path patch this replaced,
+# which had no `source` and no `checksum` at all — but `[advisories]` (RustSec)
+# is keyed to registry packages and still covers NOTHING here. The audit surface
+# for ring 1 is reading the pinned revision, plus `cargo audit bin` against the
+# shipped SBOM. This is the stated
+# price of the floor [D-4b]; when upstream releases **V10**, drop the patch and
+# this paragraph reverts to "covered like any other crate" with no behaviour
+# change.
+#
+# Corrected 2026-07-29: this said the floor needed `AccessFs::ResolveUnix` which
+# "the published crate (V7) cannot name", and that the trigger was upstream
+# releasing V9. Upstream released V9 in v0.4.7, so acting on the old trigger
+# would drop the patch while the floor still requires V10 — see the longer note
+# in `Cargo.toml`.
deny = []
skip = []
skip-tree = []
@@ -57,4 +69,15 @@ executables = "deny"
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
-allow-git = []
+# DONE 2026-08-02, and it is a real widening rather than a formality. Ring 1 now
+# arrives as a GIT dependency pinned by rev (`Cargo.toml`, `[patch.crates-io]`),
+# so `cargo deny check sources` — which is in `make check` — needs this URL
+# allowed or the gate fails on every run.
+#
+# What is admitted: a source `[advisories]` still cannot scan. What is gained
+# over the path patch it replaces: `Cargo.lock` pins a commit SHA, where a path
+# patch carried no `source` and no `checksum` at all. So this is better
+# IDENTIFICATION of an input that remains unscanned — not scanning. The audit
+# surface for ring 1 is still reading the fork, plus `cargo audit bin` against
+# the shipped SBOM.
+allow-git = ["https://github.com/eslerm/rust-landlock"]
diff --git a/docs/ATTACKS.md b/docs/ATTACKS.md
index 3cff8f8..4dbf03d 100644
--- a/docs/ATTACKS.md
+++ b/docs/ATTACKS.md
@@ -27,13 +27,16 @@ Run: `cargo test` (needs a Landlock kernel; see §11d for the CI skip contract).
| 3e | `pipe2(O_NOTIFICATION_PIPE)` to construct a watch_queue — the LPE-launchpad class oring already deletes via keyctl/userfaultfd/io_uring | `EPERM` | `deny_syscall::pipe2_notification_pipe_is_blocked` |
| 3f | (control) plain `pipe2(O_CLOEXEC)`, which every shell needs | allowed | `deny_syscall::plain_pipe2_still_works` |
| 3g | `mount_setattr` / `quotactl_fd` / `open_tree_attr` — the variants left open beside blocked `mount`/`quotactl`/`open_tree`. `mount_setattr` can clear `MS_NOEXEC`, a W^X concern for `strict` | `EPERM` | `deny_syscall::mount_and_quota_variants_are_blocked` |
+| 3h | **Egress via `IPPROTO_MPTCP`** — `sk_is_tcp()` is false for MPTCP (erratum 1 narrowed the TCP rights to real TCP *on purpose*), so ring 1's `ConnectTcp` never sees it. **Measured bypass before this rule:** under `--profile strict` (`egress_tcp: []`, "deny-all TCP egress") `IPPROTO_TCP` got `EACCES` while `socket(AF_INET, SOCK_STREAM, IPPROTO_MPTCP)` connected to a plain-TCP listener and delivered 24 bytes. Ring 2 now allows, inside `AF_INET`/`AF_INET6`, only the protocols ring 1 can mediate | `EAFNOSUPPORT` | `protocol_wall::mptcp_socket_is_refused_under_strict` |
| 4 | (control) `connect()` to an allowed port | not Landlock-blocked | `deny_net::connect_to_listed_port_not_eacces` |
| 5 | `ptrace` a peer to steal memory/inject | seccomp `EPERM` | `deny_syscall::ptrace_is_eperm` |
| 6 | `keyctl` keyring LPE surface | seccomp `ENOSYS` | `deny_syscall::keyctl_is_enosys` |
| 7 | `io_uring_setup` async-IO LPE surface | seccomp `ENOSYS` | `deny_syscall::io_uring_is_enosys` |
| 8 | `unshare(CLONE_NEWUSER)` **alone** (userns LPE launchpad) | seccomp `EPERM` | `deny_syscall::unshare_newuser_alone_is_eperm` |
-| 9 | Re-enter a blocked syscall via the **x32 ABI** (`nr | 0x40000000`) | seccomp `SIGSYS` kill | `deny_x32::x32_call_is_killed` |
+| 9 | Re-enter a blocked syscall via the **x32 ABI** (`nr \| 0x40000000`) | seccomp `SIGSYS` kill | `seccomp::fv_tests::x32_guard_matches_its_spec_over_a_dense_table` (the mutation-backed one — 12,354 points, model-level) + `deny_x32::{x32_call_is_killed, x32_call_is_killed_at_the_bottom_of_the_numbering, x32_bare_is_not_signalled}` (the kernel anchor) + Kani `x32_guard_total` (total). **Do not cite `x32_call_is_killed` alone** — measured 2026-07-28, it *survives* the mutant that guts the guard |
| 10 | `connect()` to an **abstract** AF_UNIX socket | IPC scope (V6, at floor) → `EPERM` | `containment_matrix` `unix-abstract-{balanced,strict}` |
+| 11 | `connect()` to a **pathname** AF_UNIX socket (`docker.sock`, `/tmp/ssh-*/agent.*`) — and `sendmsg(2)` to one with an explicit recipient address, which the same right covers | Landlock `RESOLVE_UNIX` (ABI V9; **below oring's floor**, so guaranteed at every seal) → `EACCES` | `containment_matrix` `unix-connect-{balanced,strict}` (errno) + `denial_attribution` `unix_connect_denial_names_resolve_unix` and `addressed_sendmsg_denial_names_resolve_unix` — **both halves covered.** The latter two assert the *mechanism*, not the placement: they read the kernel's own `blockers=fs.resolve_unix` back through `oring --denials`, so a denial caused by an ungranted path cannot pass for the V9 wall |
+| 12 | Read an **out-of-domain** process's `/proc//environ` under `--proc full` — one **not** sealed by this run (corrected 2026-07-27: this said *"steal a sibling's credentials"*, and a sibling is the one relationship where the wall does NOT hold — `domain_scope_le()` is ancestor-or-equal, so two children of ONE seal read each other; measured `--proc full` readable. The `--proc self` denial this cited alongside it was removed 2026-07-28: it is real but is an **FS-tier** denial of an ungranted `/proc/` path, not the domain relation, so it was never the matched half of that measurement — see the row-12 note) | `EACCES` (the hook returns `EPERM`; `mm_access()` maps it) from `hook_ptrace_access_check` — **not** bought by the Landlock floor at any version; the rule predates 5.13; see note for what actually decides it | `containment_matrix` `proc-peer-env-full` |
Row 8 is the load-bearing per-flag assertion: a single combined `CLONE_NEW*`
mask would only match all-bits-set, so `NEWUSER` by itself is tested explicitly.
@@ -54,21 +57,53 @@ bpf, …) is bypassable on any `CONFIG_X86_X32_ABI=y` kernel.
These are **not** oring bugs; they are the honest residuals from `docs/DESIGN.md`
§2. Listed here so the boundary is explicit, not discovered.
-- **[ ] Peer procfs read** — `/proc//{environ,maps,mem}` under `--proc full`.
- These are VFS `read()`s gated only by `ptrace_may_access`, and **Landlock does
- not reliably wall them at the V7 floor** (measured NotBlocked on kernel 6.15,
- cell `proc-peer-env-full`); a newer kernel's `ptrace_access_check` hook was seen
- to deny them on 7.1.3, but that's a version bonus, not a floor guarantee.
- Control: `--proc self`, or a runtime PID-namespace + `maskedPaths`.
-- **[ ] Any-IP egress on an allowed port** (443 → exfil) and **DNS/UDP** — Landlock
- net is port-only. Control: host nftables default-deny + a filtering proxy.
-- **[ ] `connect()` to a *pathname* AF_UNIX socket** (`docker.sock`, `ssh-agent`) —
- blocked **only** when built `--features resolve-unix` on a Landlock V9 (≥7.1)
- kernel (Landlock `RESOLVE_UNIX` → `EACCES`); the `containment_matrix
- unix-connect-*` proof cells run only in that build and auto-skip otherwise. In
- the **default shipping build it is UNMEDIATED** (DESIGN §2). Control: don't
- grant a tier holding a privileged socket, build the V9 wall, or wall it
- host-side. (The *abstract*-socket variant IS blocked at the V7 floor — row 10.)
+- **[ ] `uprobe`/`uretprobe` are UNFILTERABLE by ring 2** — `seccomp_uprobe_exception()`
+ (`kernel/seccomp.c:744`, 7.2-rc4) returns true *before* the cBPF emulator runs, pinning
+ `__NR_uretprobe` (335) and `__NR_uprobe` (336) to always-allow in every filter's action
+ cache. No `RET_ERRNO`, no `RET_KILL_*`, no `RET_LOG`, and **no audit record**;
+ `SECCOMP_MODE_STRICT` allows them too.
+ **Costs oring nothing today** — neither number is in any errno group (85 syscalls
+ checked against the compiled filter), and the exception is range- and arch-guarded, so
+ the x32 guard still KILLs `0x40000000|336` (measured under seal: SIGSYS). **Not reachable
+ by an unprivileged confined child:** both syscalls return ENXIO/SIGILL without a
+ kernel-installed `[uprobes-trampoline]` VMA, which is unforgeable from userspace
+ (`vma_is_special_mapping` compares against a kernel `__ro_after_init` struct), carries
+ `VM_DONTCOPY`, and whose `.mremap` returns `-EPERM`; installing one needs `CAP_SYS_ADMIN`
+ on the uprobe PMU (`kernel/events/core.c:11631` — a bare `capable()`, *not*
+ `perfmon_capable()`, so `perf_event_paranoid` is irrelevant) or root-only tracefs, and
+ step 11's `execve` discards any `mm` that had one. Listed because it is a **permanent**
+ limit on what ring 2 can promise: a future `paranoid` profile cannot deny these, and a
+ bug in `sys_uprobe`/`sys_uretprobe` (a path that has already needed shadow-stack and SLS
+ fixes) has no ring-2 mitigation.
+- **[ ] Peer procfs METADATA** — `/proc//{cmdline,stat}` under `--proc full`
+ is world-readable and `ptrace_may_access`-independent, so no floor closes it
+ (cell `proc-peer-cmd-full`). Control: `--proc self`, or a runtime PID-namespace
+ + `maskedPaths`. Peer `environ` is a separate case and is walled for an
+ out-of-domain peer — mechanism, scope and the two privilege/fd caveats are in
+ `docs/RESIDUALS.md`; how the account here was corrected is C-13/C-14/C-18 in
+ `docs/CORRECTIONS.md`.
+- **[ ] Any-IP egress on an allowed port** (443 → exfil) — Landlock net is
+ **port-only, structurally**: the rule key is an exact-match scalar holding the
+ port, and the address is consulted only to reject a family mismatch, so no ABI
+ bump changes this without a new key type. Control: host nftables default-deny +
+ a filtering proxy. This one does not close.
+- **[ ] DNS/UDP** — *narrowed twice, still not closed under `balanced`.* Landlock
+ gained UDP rights at ABI V10 and oring plumbs them (`egress_udp`/`ingress_udp`).
+ **Since 2026-07-29 both profiles set them** — `strict` deny-all (closed there),
+ `balanced` outbound **:53 only** — which retired the previous text here: *"they are
+ opt-in and the built-in profiles leave them unset, since a V10-only default would
+ refuse to seal on every kernel below 7.2."* The floor is V10, so that reason is
+ gone. **Why this row stays open:** the grant is still **port-only**, so `balanced`
+ permits :53 to *any address*, which is the DNS-tunnel channel itself — an
+ attacker-controlled resolver is reachable. What closed is UDP on every *other*
+ port. Control: the same host nftables + filtering proxy as the row above, or
+ `strict`, or a `--policy` with `egress_udp: []`.
+- **[x] `connect()` to a *pathname* AF_UNIX socket** — **promoted out of this list
+ in v0.2**: it is row 11 above, proven unconditionally, because the floor moved to
+ V9/7.1 (D-4b). It could never be closed here — Landlock has no hook on that
+ connect below V9, so no FS tier gated it and withholding `/tmp` only obscured
+ the socket. Kept as a struck entry because the *shape* of the fix matters: the
+ only control was the floor.
- **[ ] In-tree write-then-run** in the `write_exec` workdir — accepted for the
`balanced` build-agent profile (D-8). The `strict` profile is the answer when
this matters: it pairs a `write_noexec` workdir with a `mprotect(PROT_EXEC)`
diff --git a/docs/CITATIONS.md b/docs/CITATIONS.md
new file mode 100644
index 0000000..1068bc8
--- /dev/null
+++ b/docs/CITATIONS.md
@@ -0,0 +1,281 @@
+# CITATIONS.md — what oring's claims rest on
+
+oring's docs assert a lot and cite almost nothing. Before this file, `docs/` and
+`README.md` carried five external links between them, all of them repositories.
+This maps **oring's own claims to the primary sources that support or contradict
+them**, so a reader can check the reasoning instead of trusting it.
+
+Three rules, borrowed from the corpus this was assembled from:
+
+- **[LOAD-BEARING]** — a design decision currently rests on this. If the source
+ is wrong, oring is wrong.
+- **[BOUNDS]** — establishes what is or is not achievable at this layer. Shapes
+ scope, does not dictate implementation.
+- **[CONTEXT]** — worth having read; no current decision depends on it.
+
+**Sources that cut against oring are listed, marked, and not softened.** A
+citations file containing only supporting work would be a bibliography of our
+own opinions. Where a source contradicts a claim in `DESIGN.md`, that is stated
+here and the doc is left alone — reconciling the two is a separate, deliberate
+edit.
+
+Undated URLs were reachable 2026-07-26. Mailing-list threads are cited by
+Message-ID, which is stable; fetch them with
+`curl https://lore.kernel.org///t.mbox.gz` (lore's HTML views are
+bot-walled, its mirror paths are not).
+
+---
+
+## 1. The two-ring architecture — Landlock for what it mediates, seccomp for what it does not
+
+**[LOAD-BEARING]** for `DESIGN.md` §3 and §5. oring's central structural claim
+has been asserted without a source. It has one, from Landlock's author:
+
+> The currently non-restrictable syscall families are not blocked by Landlock
+> and will be allowed to do what they do if all enabled access-control layers
+> allow such access … Making Landlock aware of all access types is a lot of
+> effort and will take time … **In the meantime, using seccomp to filter the
+> unhandled syscalls is a good approach, even if there are drawbacks.**
+
+Mickaël Salaün, 2022-08-31, answering Jeff Xu (Chromium) in *"current landlock
+limitations and what it means for sandboxer"*, `landlock@lists.linux.dev`.
+The same message establishes that **`chroot(2)` cannot bypass Landlock** —
+escaping a chroot is unrelated to Landlock's access control (relevant to
+`ATTACKS.md`).
+
+Referenced there: Günther Noack, *"Pledge on Linux"*,
+https://blog.gnoack.org/post/pledge-on-linux/ — the drawbacks of the seccomp
+half, from a Landlock maintainer. **[BOUNDS]**
+
+## 2. Landlock's threat model
+
+**[LOAD-BEARING]** for `DESIGN.md` §2. Until 2026 Landlock's threat model was
+scattered across list threads; it is now a first-party document.
+
+*"[PATCH v1] landlock: Document the threat model"*, Mickaël Salaün, 2026-07-07,
+`linux-security-module`. It defines the guarantees (unprivileged
+self-sandboxing, per-domain confinement) and names the boundary cases by
+category. Two bear directly on oring:
+
+- **Resources obtained from outside the sandbox** — *"receiving or inheriting a
+ file descriptor (or similar) is governed by the capability model, not
+ Landlock … a possible confused deputy, **not a bypass**"*. So a socket
+ connected before `restrict_self()`, or arriving via `SCM_RIGHTS`, is an
+ uncapped channel **by design**. §2 should list this as a residual; it is not a
+ defect and not something a future ABI closes.
+- **Same-process thread interactions** — *"a per-thread domain does not protect
+ the process; a whole-process guarantee requires confining all its threads with
+ the same domain"*. This is the basis for §10's single-threaded seal point.
+
+**This document has not yet been mapped against §2 line by line.** It is the
+highest-value outstanding read for this project.
+
+## 3. Landlock ABI — what each version can and cannot express
+
+**[LOAD-BEARING]** for `DESIGN.md` §4 — the **V10 / kernel 7.2 floor**, raised from V9
+on 2026-07-29 so the UDP net rights below are guaranteed rather than disclosed as
+unhandled (D-4b, as amended) — and for the abi-ahead warning in `DESIGN.md` §13, which
+`DESIGN.md` records as an open consistency question against D-4b rather than a settled
+decision, and which carries no decision number.
+
+- `include/uapi/linux/landlock.h`, mainline v7.2-rc4 — `RESOLVE_UNIX` is
+ `1ULL<<16`, documented as covering `connect(2)` **and `sendmsg(2)` with an
+ explicit recipient address**, domain-scoped, returning `EACCES`, *"available
+ since the ninth version of the Landlock ABI"*.
+- ABI 10 (`security/landlock/syscalls.c:172`) adds
+ `LANDLOCK_ACCESS_NET_BIND_UDP` (`1ULL<<2`),
+ `LANDLOCK_ACCESS_NET_CONNECT_SEND_UDP` (`1ULL<<3`) and
+ `LANDLOCK_ADD_RULE_QUIET`.
+ **Corrected 2026-07-26 — two things previously listed here are not ABI-10
+ additions.** `LANDLOCK_RESTRICT_SELF_TSYNC` is **ABI 8**:
+ `Documentation/userspace-api/landlock.rst:753` — *"Starting with the Landlock
+ ABI version 8 … using the `LANDLOCK_RESTRICT_SELF_TSYNC` flag"* — and oring's
+ own `LANDLOCK-V9-SCOPE.md` already said V8 — a review record that lived in this tree's
+ gitignored `docs-internal/` until 2026-07-29 and is now in the maintainer's corpus. It was measured on a
+ V10 kernel, which is not the same claim. `LANDLOCK_CREATE_RULESET_ERRATA` is
+ the version-independent errata probe; the errata it reports are declared at
+ ABI 1, 4 and 6.
+- **Network rules are port-only, structurally.** The ruleset is a red-black tree
+ on an exact-match scalar key (`security/landlock/ruleset.c`,
+ `landlock_find_rule()`), and `net.c` puts the *port* in that key; the address
+ is consulted only to reject a family mismatch. **[BOUNDS]** — no ABI bump
+ changes this without a new key type. It is why §2's egress residual is
+ structural rather than temporary.
+- **Non-TCP/UDP traffic is unmediated by ring 1 at every current ABI.** All three
+ net hooks `return 0` — *allow* — for a socket that is neither TCP nor UDP. So
+ even with a UDP grant, non-TCP/UDP IP protocols and raw sockets are not
+ mediated by **ring 1**.
+
+ **They do not all reach that `return 0` the same way, and this bullet said they
+ did until 2026-07-27.** `hook_socket_bind` and `hook_socket_connect` dispatch
+ `if TCP … else if UDP … else return 0`. `hook_socket_sendmsg` does not: it
+ opens with a `(msg_flags & MSG_FASTOPEN) && address && sk_is_tcp` branch, then
+ falls into a bare `if (sk_is_udp) … else return 0` with no TCP arm at all
+ (`security/landlock/net.c:346-375`, read from the 7.2.0-rc4 source of the
+ running kernel, 2026-07-27). The conclusion is unaffected — every path still
+ allows what is neither TCP nor UDP — but the uniform-dispatch phrasing was not
+ a description of the third hook. The same wording was in `DESIGN.md` §2 and is
+ corrected there too.
+
+ **Corrected 2026-07-26/27 — this bullet previously overstated on three counts,
+ and each correction is load-bearing:**
+ 1. It named **DCCP and UDP-Lite** as live channels. Both are **removed from
+ Linux** (`net/dccp` and `net/ipv4/udplite.c` are absent from 7.2-rc4).
+ Naming them was plausibility, not verification.
+ 2. It said **ICMP stays open**. That residual is **CLOSED by ring 2** as of
+ 2026-07-26: `socket()` in `AF_INET`/`AF_INET6` is restricted to the
+ protocols ring 1 can mediate, in **both** profiles, so `IPPROTO_ICMP` gets
+ `EAFNOSUPPORT` — and in-cage `ping` no longer works. "Unmediated by ring 1"
+ is not "reachable by the child"; this bullet conflated them.
+ 3. It said **"no ABI version retires it"**. That absolute is contradicted by
+ §5 of this same file: Ivanov's `LANDLOCK_ACCESS_SOCKET_CREATE` is a live,
+ reviewed RFC that names exactly this class. Say *"no net-hook ABI bump
+ retires it; closing it needs a new access-right family, which is proposed
+ and unmerged"* — which is a statement about the current tree, not about all
+ future Linux.
+
+ Note also that SCTP **is** live on a stock distro kernel: a measurement to the
+ contrary was taken on this box's `localmodconfig`-trimmed kernel, which has no
+ `sctp.ko`. Protocol *availability* is a property of the kernel build.
+
+ §2's residual is accurate and current about V10, but it was *named* "DNS/UDP
+ exfil" (and `README.md` said "UDP/DNS still open" until 2026-07-27), which names the part V10
+ closes and omits the rest. **Not a contradiction — an under-inclusive label.**
+ Restating it as *non-TCP/UDP IP protocols, of which UDP is the part the V10
+ grant closes* keeps the next V-bump from reading as having closed the residual.
+
+## 4. Why Landlock has no address matching — deferred, with a stated test
+
+**[BOUNDS]** for §2's egress residual and §9's host-firewall companion control.
+CIDR was proposed in the original PoC as `landlock_net_cidr4_attr` (address +
+prefix, citing RFC 4632) and rejected by Salaün, 2021-12-17, in *"[RFC PATCH
+0/2] Landlock network PoC implementation"*, `linux-security-module`:
+
+> IP addresses (and subnets) should not be part of a rule, **at least for now** …
+> tied either to the system architecture …, the local network or Internet, hence
+> **moving targets not controlled by application developers**. Moreover … more
+> complex to check and handle subnets, which are most of the time tied to the
+> **Netfilter infrastructure, not suitable for Landlock because of its
+> unprivileged nature** … protocols such as TCP and their associated ports are
+> normalized and are tied to an application semantic.
+
+So the port-only design is a considered position with a reopening condition, not
+an omission. Anything oring says about future address filtering should engage
+these three criteria.
+
+## 5. In-flight upstream work that would close a published residual
+
+**[CONTEXT]**, becoming **[LOAD-BEARING]** if merged. Mikhail Ivanov, *"Socket
+type control for Landlock"* → *"landlock: Support socket access-control"*,
+`linux-security-module`: RFC v1 2024-04, v2 2024-05, v3 2024-11, v4 with review
+activity 2026-05-22; 19 patches, selftests covering SCTP peeloff, `accept(2)`
+and packet protocol alias. Companion RFC: *"landlock: Fix non-TCP sockets
+restriction"*.
+
+This is the mechanism that would close the residual in §3 of this file. It bears on
+oring's abi-ahead behaviour — warn above the crate ceiling rather than refuse — which
+carries **no decision number**: `DESIGN.md` §13 records it as *"an open consistency
+question against D-4b, not a settled design"*. Whatever settles it should distinguish a
+right oring never claimed from one that closes a **published** residual.
+
+## 6. Ring 2 — the denylist decisions
+
+- **CVE-2019-7303** (snapd, reported by Jann Horn): a seccomp rule compiled to a
+ 64-bit compare on an argument the kernel truncates to 32 bits, so
+ `ioctl(fd, TIOCSTI | (1UL<<32), …)` passed the filter. **[LOAD-BEARING]** — the
+ defect class `tests/seccomp_smt.rs` exists to exclude, and the reason the SMT
+ gate proves *truncation-invariance* rather than a property of the raw
+ `seccomp_data`. Note the hazard is specific to **denylists with argument
+ matching**; an allowlist fails closed here, which is a real argument against
+ oring's shape and is met rather than ignored.
+- **CVE-2018-15746** (QEMU): `-sandbox on` applied the filter to the calling
+ thread only — `Seccomp: 2` and `Seccomp: 0` in the same process. Fixed with
+ `SCMP_FLTATR_CTL_TSYNC`. **[LOAD-BEARING]** for §10: it *strengthens* the
+ single-threaded seal point rather than arguing for TSYNC, because sealing
+ pre-`execve` is structurally immune to the class.
+- **CVE-2024-21626** (runc, Aleksa Sarai): a leaked file descriptor as an escape
+ vector. **[LOAD-BEARING]** — oring's `close_range` FD scrub exists for this.
+- `seccomp(2)` man page and *"Seccomp filters for multi-threaded programs"*,
+ LWN, https://lwn.net/Articles/600250/ — seccomp's `TSYNC` **refuses** to sync a
+ thread whose filter is not an ancestor of the caller's. **[CONTEXT]**, and a
+ useful contrast: Landlock's `TSYNC` overrides instead.
+
+## 7. Sources that cut against oring
+
+Listed because a citations file that omits them is advocacy.
+
+- **Pailoor et al., *"Automated Policy Synthesis for System Call Sandboxing"*,
+ OOPSLA/PACMPL 2020.** Synthesised syscall **allowlists** prevent >95% of known
+ LPEs and beat hand-written policies. **[BOUNDS]** — it removes the *fallback*
+ argument for a hand-maintained denylist. oring's denylist has to be justified
+ on its own terms (no profile authoring burden, no per-application policy), not
+ as "an allowlist is impractical".
+- **Landlock's compatibility ladder is the majority position.** clampdown, codex
+ and Syd all implement best-effort/per-version handling; oring's hard floor is a
+ minority of one. Salaün's own guidance (SSTIC 2024) is that an application
+ *should not* sandbox itself if it risks breaking a legitimate use case — though
+ he scopes that to embedded, developer-shipped sandboxing and concedes a
+ minimum-requirement floor is legitimate for security software. **[BOUNDS]** for
+ D-4.
+- **Syd** (Ali Polatel), https://gitlab.exherbo.org/sydbox/sydbox — the closest
+ prior art: Rust, seccomp user-notification plus Landlock, ABI V1–V9, ~379k
+ lines. Larger, more portable, better CI, on the ladder, and it *fuzzes*
+ `canonicalize` while oring fuzzes nothing. **[CONTEXT]** — the two projects
+ made opposite bets: dynamic testing over a surface oring reasons about
+ statically, versus static proof over a surface Syd does not model.
+
+## 8. A published escape against Landlock itself
+
+**[CONTEXT]**, and it belongs in `ATTACKS.md` rather than being absent:
+**CVE-2024-42318**, known as *"Landlock Houdini"*, with an LTP reproducer —
+`landlock07`, Andrea Cervesato (SUSE), forwarded to `landlock@lists.linux.dev`
+2024-08-20. §2 already says oring is not a kernel-0-day defence; this is the one
+published escape against the mechanism it sits on, and a reader is entitled to
+know it exists.
+
+## 9. Agent containment — the premise §2 assumes
+
+**[BOUNDS]**. §2 asserts a prompt-injection threat model without argument.
+
+- Trail of Bits, *"Prompt injection to RCE in AI agents"*, 2025-10-22,
+ https://blog.trailofbits.com/2025/10/22/prompt-injection-to-rce-in-ai-agents/ —
+ argument injection defeats command **allowlists** in three production agents;
+ their conclusion is *"the most effective defense available today is
+ sandboxing"*, naming Landlock on Linux by name, and *"local applications
+ deserve the same protection"*. The endorsement is of the approach, not of any
+ implementation.
+- **CVE-2025-54135** (Cursor, "CurXecute") and **CVE-2025-53773** (Copilot in VS
+ Code): injected content directs the agent to rewrite its own permission
+ configuration, after which actions run without confirmation. **[LOAD-BEARING]**
+ for §7's `check_self_protection` — two shipped products, one root cause, and
+ oring's seal makes that root cause unreachable.
+
+ **Corrected 2026-07-27: this cited `CVE-2025-43663`, which is the wrong ID** —
+ reserved/unpublished and unrelated. CurXecute is **CVE-2025-54135**: Aim Labs,
+ CVSS 8.6, coordinated disclosure to Cursor 2025-07-07, public 2025-08-01, fixed
+ in Cursor 1.3 (2025-07-29). Verified against the CVE record and multiple
+ independent write-ups, not taken from the finding that reported it.
+
+ **And the mechanism as written is softer than reality, which strengthens the
+ argument rather than weakening it.** The suggested `~/.cursor/mcp.json` edit
+ **lands on disk and executes before the user accepts or rejects it** — the
+ write *is* the execution. So the root cause is not "the agent talked the user
+ into approving something"; it is that a config governing execution was writable
+ by the thing it governs. That is precisely what `check_self_protection` refuses,
+ and it is why the refusal is not merely defence-in-depth.
+- Salaün named this class in **SSTIC 2024** §2.5 a year earlier: a sandboxed
+ process tampering with *"its own or others' persistent data (e.g.
+ configuration file, cache) and then alter behavior of next runs (e.g. disable
+ sandboxing)"*, and says the kernel cannot prevent it because it does not know
+ which data is sensitive. That is the public citation for a userspace
+ complement being **required**, not optional.
+
+## 10. Where to check any of this
+
+- Landlock user list: `git clone --mirror https://lore.kernel.org/landlock/0`
+ — 740 KB, complete.
+- Development list: `git clone --mirror
+ https://lore.kernel.org/linux-security-module/0` — 217 MB, ~11 s.
+- public-inbox stores one message per commit with the raw mail in a blob named
+ `m` and the Subject as the commit message, so `git log --all --format='%s'`
+ indexes an entire list in one command.
diff --git a/docs/CORRECTIONS.md b/docs/CORRECTIONS.md
new file mode 100644
index 0000000..63cc434
--- /dev/null
+++ b/docs/CORRECTIONS.md
@@ -0,0 +1,356 @@
+
+# CORRECTIONS — what this project published and later found wrong
+
+Every entry is a claim oring's own documentation made, and the measurement that
+refuted it. Newest first. Ids are stable: `docs/RESIDUALS.md` and
+`docs/DESIGN.md` cite them as `[corrections: C-n]`, and a citation is how a
+reader gets from the current wording to the reason it is worded that way.
+
+**Why this file exists.** Until 2026-08-01 each of these lived inline, in the
+sentence it corrected — so §2 carried the current position and the argument
+against two earlier ones in the same paragraph, and a reader looking up what
+oring does today paid for a debate that had already ended. The corrections are
+kept because a project that silently rewrites its claims cannot be audited, and
+because several of these were *dangerous* wordings rather than merely wrong ones
+(C-11, C-12). They are kept **here** because the residual's job is to state what
+is true now.
+
+**What belongs in an entry.** What the text said, what measurement refuted it,
+and what it says now. Not the general lesson — a lesson with no measurement
+under it is an aphorism, and this file is not for those.
+
+**This file is not a changelog.** `CHANGELOG.md` records what the *software*
+did. This records what the *documentation asserted* and got wrong, which is a
+different failure with a different reader: someone deciding how much to trust
+the rest of the page.
+
+---
+
+## C-20 — 2026-07 · `docs/DESIGN.md` §5 · "family >= 17 blocks PACKET"
+
+**Said:** the `socket()` family gate stopped `AF_INET SOCK_PACKET` because the
+rule blocks families at or above 17.
+
+**Refuted by:** `__sock_create` rewrites `AF_INET SOCK_PACKET` to `PF_PACKET`
+*before* the family-keyed module request, so the family comparison never sees
+17. What actually stopped it was `CAP_NET_RAW` — a **deployment** property oring
+does not control and must not claim.
+
+**Now:** the type axis is gated explicitly, and the guarantee is stated on the
+mechanism that holds it rather than on one that does not.
+
+## C-19 — 2026-07-28 · `docs/INTERNALS.md` §5 · the btrfs 0x94 justification
+
+**Said:** oring does not block the btrfs `0x94` ioctl magic because "its only
+unprivileged member is the benign `FICLONE`/`FICLONERANGE`" and "every
+destructive member is CAP_SYS_ADMIN-gated".
+
+**Refuted by:** reading `fs/btrfs/ioctl.c` in the running 7.2.0-rc4 tree. 63
+ioctls are defined in the magic and only 35 `capable(CAP_SYS_ADMIN)` checks exist
+in the whole file. `SNAP_CREATE`/`SNAP_CREATE_V2` gate on
+`inode_owner_or_capable()` — the owner qualifies, no capability involved — and
+`SUBVOL_CREATE`, `SUBVOL_SETFLAGS`, `DEFRAG`, `FS_INFO`, `SPACE_INFO`, `SYNC` and
+`INO_LOOKUP` are reachable unprivileged.
+
+**Now:** the conclusion is unchanged and the argument is different. Every one of
+these acts on a path the child already holds an fd to, and Landlock gates the
+`open()`, so the reach is bounded by the granted tiers rather than by the
+capability set. Not measured — no btrfs is mounted on the development host — so
+it is a source read.
+
+## C-18 — 2026-07-26 · `docs/ATTACKS.md` row 12 · three drafts, two withdrawn
+
+**Said:** successive drafts of the peer-`environ` note attributed the denial to
+the Landlock floor, then described the rule as "may not read a non-descendant's",
+then explained a contradictory 6.15 measurement as the reader "must have read
+something inside its own domain".
+
+**Refuted by:** the floor buys none of it — a ruleset handling only
+`LANDLOCK_ACCESS_FS_EXECUTE` (ABI V1) with **zero rules** takes peer `environ`
+from readable to `EACCES`. "Non-descendant" is false in its second half:
+`domain_scope_le()` is ancestor-**or-equal**, so siblings under one seal read
+each other. And the 6.15 result is **unexplained** — no artefact of the run
+survives, `Sentinel::spawn()` is byte-identical across the commits either side,
+so a placement error is not the explanation. The domain-relation story was
+asserted with more confidence than the evidence carried and is withdrawn.
+
+**Now:** privilege is a demonstrated mechanism that reproduces *both* historical
+results on one kernel — the same binary against the same harness-spawned
+sentinel gives `Deny(EACCES)` as an ordinary user and `NotBlocked` as root. That
+is recorded in `docs/RESIDUALS.md` as a live caveat rather than as history, since
+`sudo oring` loses the wall today. See also C-13, C-14.
+
+## C-17 — 2026-07-29 · `docs/RESIDUALS.md`, peer procfs · instrument, and two symlink defeats
+
+**Said:** the peer-procfs residual was disclosed as `documented-only` with
+`--proc self` as the offered control, and the seal line carried no field
+distinguishing the modes — `grants=` read identically whichever applied.
+
+**Refuted by:** two independent defeats of the mode guard, both measured with
+`__probe proc-cmdline 1`. (a) The guard compared paths **lexically** while
+`add_tier` opens with `PathFd::new` and follows symlinks, so a policy naming a
+symlink to `/proc` kept its grant: `/proc` named directly gave `EACCES` under
+both `--proc self` and `--proc none`; a symlink to it gave **0, READABLE**, under
+both. (b) The guard filtered only `read_only`, so a **direct** `/proc` grant in
+`read_exec` or `write_noexec` survived `--proc none` outright, with no symlink
+involved.
+
+**Now:** every tier is canonicalised through `policy::canonical_or_literal`,
+pinned by `policy::tests::proc_mode_treats_a_symlink_exactly_like_its_target_in_every_tier`
+over 4 tiers × 3 modes × 2 spellings. The seal line carries `proc=` —
+`none` / `self` / `full` / `custom:` — **derived from the installed tiers,
+never echoed from the flag**. Measured: a `--policy` granting `/proc/cpuinfo`
+with no `--proc` flag reports `proc=custom:1`, which a flag-echo could not show.
+
+## C-16 — 2026-07-28 · peer procfs · "a pattern no host MAC can produce"
+
+**Said:** the readable-sibling / denied-peer differential was a signature only
+Landlock could produce.
+
+**Refuted by:** the universal is false. A peer that has called
+`PR_SET_DUMPABLE 0` has a root-owned `/proc/`, so **pure DAC with no MAC and
+no oring produces the same differential** — measured: non-dumpable peer denied,
+ordinary peer readable, same reader, same uid, same security context. The
+original argument also controlled the *reader's* context, which is constant by
+construction, when the variable that matters is the *targets'*.
+
+**Now:** the claim is dropped. What establishes that the denier consults no
+handled access right is a single-variable test and is stronger: with **no oring
+at all**, a process calling `landlock_restrict_self()` handling **only**
+`LANDLOCK_ACCESS_FS_MAKE_SYM` — with **zero rules added** — flips a pre-existing
+peer's `environ` from readable to `EACCES` while its own still reads. Entering
+any domain does it.
+
+## C-15 — 2026-07-28 · peer procfs · two mechanisms presented as one controlled pair
+
+**Said:** "`--proc full` readable / `--proc self` denied", published as the two
+arms of one experiment varying the domain relation.
+
+**Refuted by:** the `--proc self` denial is the **Landlock FS tier**, not the
+domain relation. `/proc/` is not a granted path, so the open fails
+before `ptrace_may_access` is consulted and the domain relation — which still
+*permits* the read — never gets a say. Proof, one variable: `cmdline`, `stat` and
+`comm` are **not** ptrace-gated (readable bare and under `--proc full`) and are
+**also** denied under `--proc self`; only a path-layer denial does that.
+
+**Now:** stated as two mechanisms. The operator conclusion is unchanged —
+`--proc self` does buy sibling-`environ` privacy — but it buys it by making the
+path unreachable, which is why it also costs fork compatibility (`DESIGN.md` §8).
+
+## C-14 — 2026-07-27 · peer procfs · "non-descendant" was backwards
+
+**Said:** peer `environ` is closed for a **non-descendant** process.
+
+**Refuted by:** `domain_scope_le()` is ancestor-**or-equal**, and `fork(2)`
+creates no new domain, so every process under one oring seal shares one domain
+and reads every other's `environ`. A sibling *is* a non-descendant and *is*
+readable. Measured 2026-07-27 on 7.2.0-rc4, one seal, true siblings.
+
+**Now:** the axis is **in-domain vs out-of-domain**, not descent. Confining an
+agent does not isolate its subprocesses' environments from each other.
+
+## C-13 — 2026-07-26 · peer procfs · three claims about the floor, all wrong
+
+**Said:** Landlock "does not reliably wall them at the V7 floor" (measured
+NotBlocked on 6.15); the 7.1.3 denial was "a version-dependent bonus, not a floor
+guarantee"; and the V9 floor closed it.
+
+**Refuted by:** the denier consults **no handled access right** — it compares
+domain hierarchies via `domain_scope_le()` — and the rule predates the 5.13
+merge, so the floor bought none of it. The outcome turns on where the *target*
+sits: a landlocked reader may read a target in its own domain or a sub-domain and
+may not read one outside it. Measured, one kernel, one seal, one variable: a peer
+forked *before* the seal is denied, a child forked *after* it is readable. The
+6.15 result was therefore never evidence of a kernel change.
+
+**Now:** stated as a domain-relation property, floor-independent.
+
+## C-12 — 2026-07-26 · protocol reach · mediation confused with reachability
+
+**Said:** the non-TCP/UDP channels Landlock declines to mediate were listed as
+things a confined child could reach, naming ICMP, DCCP and UDP-Lite.
+
+**Refuted by:** what Landlock declines to mediate and what an unprivileged child
+can actually reach are different questions. ICMP *was* reachable and measured
+sending under a valid `strict` seal — now **closed by ring 2**: `socket()` in
+`AF_INET`/`AF_INET6` is restricted to the protocols ring 1 can mediate, so
+`IPPROTO_ICMP` gets `EAFNOSUPPORT` and in-cage `ping` does not work. `AF_PACKET`
+and raw sockets need `CAP_NET_RAW`. **DCCP and UDP-Lite no longer exist in
+Linux** (`net/dccp` and `net/ipv4/udplite.c` are absent from 7.2-rc4), so naming
+them as channels was wrong.
+
+**Now:** SCTP is named as the live case, and a prior measurement to the contrary
+is recorded as having been taken on a `localmodconfig` kernel with no `sctp.ko` —
+protocol *availability* is a property of the kernel build, not of Landlock.
+
+## C-11 — 2026-07-28 · workdir poisoning · the review control fired the payload
+
+**Said:** treat a touched workdir as untrusted and review it — with `git status`
+and `git diff`.
+
+**Refuted by:** measured, reproduced twice independently. A child writes
+`.git/fsm.sh` and sets `core.fsmonitor`, both within what a workdir grant
+permits. The operator's review returns **empty output at exit 0 from both
+commands** — `.git/` is untracked, so nothing surfaces — while the payload
+**fires four times during the review itself**, at the operator's uid, outside the
+cage, because `core.fsmonitor` executes on index refresh.
+
+**Now:** inspect with tools that do not interpret the tree as a repository —
+`command grep -nE 'fsmonitor|pager|sshCommand|textconv|hooksPath|askpass|^\s*\[alias\]' .git/config`
+and `find .git/hooks -type f ! -name '*.sample'`, which detected the same
+poisoning with **zero** firings — or re-clone into a throwaway checkout. There is
+no safer `git` invocation: `core.pager`, `core.sshCommand`, `alias.*` and
+`diff.*.textconv` are separate triggers on separate commands, and repo-local
+`.git/config` is read on every one of them.
+
+## C-10 — 2026-07-28 · workdir poisoning · "run the first build inside the cage"
+
+**Said:** run the first build or test after an agent session inside the cage
+rather than outside it.
+
+**Refuted by:** under both built-in profiles as they then stood, that build ran
+the agent's `build.rs` with write access to `~/.cargo/registry`, `~/.cargo/git`,
+`~/.npm` and `~/.cache` — laundering a bounded single-project hazard into a
+persistent cross-project one.
+
+**Now:** run it outside, in a throwaway checkout, or not at all. The advice was
+retracted as actively harmful; the cache grants it depended on were themselves
+removed (C-9a below).
+
+## C-9 — 2026-07-29 · UDP defaults · the reason for leaving them unset had expired
+
+**Said:** the UDP rights are opt-in and the built-in profiles leave them unset,
+"because a V10-only default would refuse to seal on every kernel below 7.2, so
+under the profiles this residual is still LIVE".
+
+**Refuted by:** true only while the floor was V9. The floor moved to V10, which
+refuses those kernels regardless, so the stated reason no longer existed.
+
+**Now:** `balanced` grants outbound **:53 only** (with `ingress_udp: [0]` for
+autobind); `strict` grants no UDP at all. Both report `unhandled=-`. Measured
+2026-07-29 on 7.2.0-rc4 via bash `/dev/udp` with an unsealed control. **Not a
+closure:** Landlock net is port-only, so `balanced` still permits :53 to any
+address, which is the DNS-tunnel channel itself.
+
+## C-9a — 2026-07-28 · shared package cache · "both profiles"
+
+**Said:** both built-in profiles granted `write_noexec` on the shared package
+caches.
+
+**Refuted by:** `strict` never did.
+
+**Now:** the grants are removed from `balanced` too; the caches remain readable.
+The upstream defect is unchanged and is not oring's to fix — cargo verifies a
+tarball once at download and never verifies anything on disk again, and a payload
+appended to a `build.rs` under `~/.cargo/registry/src` with `.cargo-ok` untouched
+executes at the operator's uid in an unrelated project.
+
+## C-8 — 2026-07-27 · net hooks · one dispatch shape claimed for three hooks
+
+**Said:** all three Landlock net hooks reach their `return 0` by the same
+`if TCP … else if UDP … else return 0` dispatch.
+
+**Refuted by:** `hook_socket_sendmsg` (`security/landlock/net.c:346-375`) opens
+with a `(msg_flags & MSG_FASTOPEN) && address && sk_is_tcp` branch and then falls
+into a bare `if (sk_is_udp) … else return 0` carrying **no TCP arm**.
+
+**Now:** the conclusion is unchanged — anything that is not TCP or UDP is
+unmediated at every ABI — and the dispatch is described per hook.
+
+## C-7 — 2026-07-27 · net hooks · a design position described as a limitation
+
+**Said:** Landlock's silence on non-TCP/UDP sockets was a hook-signature
+limitation.
+
+**Refuted by:** the hooks are registered and they run —
+`LSM_HOOK_INIT(socket_sendmsg, hook_socket_sendmsg)` (`net.c:380`, 7.2-rc4) — and
+the body *chooses* to return 0 for anything `sk_is_tcp()`/`sk_is_udp()` rejects.
+Landlock can see it and declines to act.
+
+**Now:** stated as a deliberate design position. No net-hook ABI bump retires it;
+closing it needs a new access-right family, which Ivanov's *"Support socket
+access-control"* RFC proposes and which is unmerged — so "no ABI closes it" is a
+claim about today's tree, not about all future Linux.
+
+## C-6 — 2026-07-2x · pre-seal descriptors · "outside the seal's reach"
+
+**Said:** a descriptor held before `restrict_self()` is outside the seal's reach.
+
+**Refuted by:** an addressed `sendto(2)` on that very fd **is** denied. What
+survives is the address-less *path* — `hook_socket_sendmsg` only checks when
+`msg->msg_name != NULL`, and the autobind check passes because the socket is
+already bound.
+
+**Now:** the residual is "a connected socket keeps its established peer", not
+"pre-seal descriptors are unmediated". Descriptors arriving over `SCM_RIGHTS`
+share the distinction and have **not** been separately measured.
+
+## C-5 — 2026-07-27 · FD scrub · an unqualified guarantee
+
+**Said:** "Inherited leaked FDs" — unqualified, in the irrevocable-guarantees
+list.
+
+**Refuted by:** fds 0–2 are deliberately preserved and are **not** scrubbed, so
+an fd the caller redirects onto stdin/stdout/stderr crosses the seal intact.
+Measured 2026-07-27, one variable: an inherited fd 0 is readable inside the cage,
+an inherited fd 7 is gone.
+
+**Now:** qualified as **fd 3 and above**. It is the caller's own redirect rather
+than a child-initiated escape — Landlock's threat model calls that a confused
+deputy, not a bypass — but the guarantee as written was broader than the
+mechanism.
+
+## C-4 — 2026-07-27 · namespace rules · "7 rules each"
+
+**Said:** 7 arg-mask rules each on `clone` and `unshare`.
+
+**Refuted by:** it is 7 on `clone` and **8** on `unshare`, and the asymmetry is
+deliberate: `unshare` carries an extra `CLONE_NEWTIME` (0x80) rule and `clone`
+must not, because in legacy `clone(2)` the low byte is `CSIGNAL` and a 0x80 rule
+there would false-match an exit signal.
+
+**Now:** both counts stated, with the reason. Saying "each" hid a deliberate
+asymmetry behind a symmetry that was not in the subject matter.
+
+## C-3 — 2026-07-29 · attacker model · the caller's role
+
+**Said:** wording that implied the caller is trusted.
+
+**Refuted by:** three shipped guards say otherwise. `--profile` beside
+`ORING_POLICY` is a hard refusal; `$TMPDIR` is no longer read at all, after it
+was measured granting write on `~/.ssh/authorized_keys`; and
+`check_profile_grants` refuses a profile whose tier would contain `$HOME`.
+
+**Now:** the caller is trusted to **author** a policy explicitly; the caller's
+**ambient state is not trusted to widen or override what was authored**. Where an
+ambient source would override an authored one, oring refuses rather than picking a
+winner. This is `CONSTRAINTS.md` **CON-6**, which stated the rule before this
+document did.
+
+## C-2 — 2026-07-29 · constraint register · a pointer into a private corpus
+
+**Said:** the loss/hazard/constraint structure lived in a file in the
+maintainer's private research corpus.
+
+**Refuted by:** a release could then ship with a constraint register its readers
+could not see, and gates that never ran against it.
+
+**Now:** `CONSTRAINTS.md` is in this repository and gated in-tree by
+`tests/constraint_register.rs` and `tests/design_residuals.rs`, so it runs on
+every `cargo test` and in `make check`.
+
+## C-1 — 2026-07-29 · §2 scope · an adversary-scoped section holding non-adversary losses
+
+**Said:** the section was titled *Threat model & non-goals* and was scoped
+entirely to what an attacker can do.
+
+**Refuted by:** it was measured holding two items with **no adversary in them** —
+the `access()`/`open()` divergence, filed as "not a containment hole", and the
+workdir-poisoning advice, retracted twice in two days. Those are losses that
+occur with nobody attacking, and an adversary-scoped section has no slot for
+them, so each was mis-ranked until a measurement forced it.
+
+**Now:** the loss structure (`BREACH` / `LAUNDER` / `DIVERGE`) is what residuals
+are tagged against. **What did not change:** the compromised agent is still the
+primary adversary and containment breach the primary loss. If the two readings
+ever conflict, the adversary wins and the taxonomy is wrong.
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 67d38dc..988accf 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -2,7 +2,52 @@
# oring — Design Document v1.0
-Design and threat model. Built on rust-landlock 0.4.5 and seccompiler 0.5.x; the FS-tier and seccomp designs are derived from first principles (kernel Landlock/seccomp semantics and known LPE-primitive classes), see §4/§5. Resolved decisions are in the Decision Log (§14).
+The architecture, the deployment and packaging story, and the map to everything
+else. Built on rust-landlock 0.4.5 and seccompiler 0.5.x; the FS-tier and seccomp
+designs are derived from first principles (kernel Landlock/seccomp semantics and
+known LPE-primitive classes), see §4/§5.
+
+## Where each section lives
+
+**Split on 2026-08-01 by what the reader came to do. Section numbers did not
+change** — a `DESIGN §n` citation resolves through this table, and there are
+enough of them in `src/`, `tests/` and the other documents that renumbering would
+have cost a great deal and bought a reader nothing.
+
+| § | Subject | File |
+|---|---|---|
+| 1 | Overview | here |
+| **2** | **Threat model, what oring stops, every residual, non-goals** | [`RESIDUALS.md`](RESIDUALS.md) |
+| 3 | Architecture — rings and host layering | here |
+| 4 | Landlock design (ring 1) | [`INTERNALS.md`](INTERNALS.md) |
+| 5 | Seccomp design (ring 2) | [`INTERNALS.md`](INTERNALS.md) |
+| 6 | Pre-exec sequence — **the seal order** | [`INTERNALS.md`](INTERNALS.md) |
+| 7 | Policy schema + profile | [`REFERENCE.md`](REFERENCE.md) |
+| 8 | CLI / UX | [`REFERENCE.md`](REFERENCE.md) |
+| 9 | Deployment — container entrypoint + standalone | here |
+| 10 | Rust crate structure + deps | here |
+| 11 | Testing strategy | here |
+| 12 | Build / packaging (static musl) | here |
+| 13 | Implementation status | here |
+| 14 | Decision Log (`D-n`) | [`RATIONALE.md`](RATIONALE.md) |
+
+Not numbered, and not part of the `§n` space:
+[`CORRECTIONS.md`](CORRECTIONS.md) (what this project published and later found
+wrong, cited as `[corrections: C-n]`), [`ATTACKS.md`](ATTACKS.md),
+[`VERIFICATION.md`](VERIFICATION.md), [`CITATIONS.md`](CITATIONS.md), and
+`CONSTRAINTS.md` (`CON-n`) in the repository root.
+
+**If you are changing confinement code, §6 is the section to read first.** The
+seal order is load-bearing: a wrong order is a confinement bypass, not a style
+nit.
+
+### Two id namespaces, and one of them does not resolve here
+
+**`D-1` … `D-32` are this document's own decisions** and are defined in the Decision Log (§14). **`D1`, `D3`, `D9` — no hyphen — are not.** They belong to the maintainer's decision log, which is not part of this repository. Two namespaces separated by a hyphen is a bad design and it is named here rather than left for a reader to trip over; the constraint register (`CONSTRAINTS.md`) uses the deliberately unmistakable `CON-n` for the same reason.
+
+**`BUILD-QUEUE.md T` is a work-item handle, not a link.** That queue is the maintainer's and is not in this repository, so **a T-number here resolves to nothing you can open** — it is a stable identifier for a known gap, and the gap itself is always described in the surrounding sentence. **16 such citations exist, and they are not confined to the prose: 3 in `docs/`, 7 in `src/`, 6 in `tests/`** — all of which ship in the crate, so a reader who greps the source meets the convention there too. (This sentence read "Nine such citations exist (eight here, one in `VERIFICATION.md`)" until 2026-08-01, which was the count for `docs/` alone presented as the count for the tree, and understated it by twelve. **Those four numbers are gated** by `tests/citation_handles.rs`, which counts the tree and fails on drift.) Read them as "this is tracked", never as "see the ticket". `CONSTRAINTS.md` deliberately carries **no** T-numbers and states its gaps in prose instead.
+
+**The same applies to any `deep_research/…` path. 4 citations in `docs/`, 0 in `src/`, and 0 in `tests/`** name notes in the maintainer's research corpus, which is not published. This clause said "three comments in `src/`" until 2026-08-01; `grep -r deep_research src/` returns **zero**, and had done since those comments moved to `tests/`. Gated by `tests/citation_handles.rs` along with the T-handle counts above, and `src/` is asserted at 0 rather than left unstated, because that is the number that rotted. In both places they attribute evidence rather than offer a link, and **the claim they support is always stated where it is cited**, so nothing here depends on reading them. They are listed as a known wart, not defended — a shipping crate that cites unreachable paths is a defect, and the fix is to inline whatever a reader actually needs.
---
@@ -18,44 +63,21 @@ Not an orchestrator. Not a MAC authoring tool. Not a network policy engine. Not
---
-## 2. Threat model & non-goals
-
-**Attacker:** a fully-compromised agent (prompt-injection → arbitrary native code) running as uid 1000 *after* `restrict_self`+`execve`. All rings already in effect and irrevocable. The attacker is the CHILD; policy is chosen by the trusted PARENT before the first exec.
-
-### What oring stops (kernel-enforced, irrevocable)
-- FS access outside policy tiers — host creds (`~/.ssh`, `~/.aws`), *other* projects' `.git/config`, host config. **Strongest guarantee (Landlock V1–V3, hard floor).** Read this narrowly: it is `.git/config` of repos **outside** the granted tiers. The **workdir's own** `.git/` is inside `write_exec` and is fully writable by the child — see the in-tree residual in §2.
-- W^X: no exec from writable scratch (`write_noexec` tiers).
-- Privilege gain via SUID/setcap/file-caps (`NO_NEW_PRIVS`) — neutralizes the CVE-2023-0386 OverlayFS-SUID payoff for the confined child (its mount/userns half is separately closed by the seccomp `mount`/`unshare(CLONE_NEWUSER)` denials, §5).
-- Namespace creation — per-flag arg-masks on `clone`/`unshare` `CLONE_NEW*` (7 rules each), `clone3`→ENOSYS, `mount`/`setns`/`pivot_root` blocked.
-- LPE launchpads: io_uring, eBPF, ptrace, userfaultfd, keyctl, perf_event_open, memfd_create, module ops, `open_by_handle_at`, Dirty-Pipe (`splice`/`vmsplice`), StackRot (`MAP_GROWSDOWN`), TTY-injection ioctls, and **x32-ABI re-entry of all of the above** (§5).
-- **AF_UNIX MSG_OOB UAF (CVE-2025-38236)** — arg-masked on send/recv `MSG_OOB` + `SIOCATMARK` (fix, was wrongly deferred).
-- Inherited leaked FDs (FD-scrub via `close_range`).
-
-### Also stopped — guaranteed at the V7 floor (these ABIs sit below it)
-- **Connect/bind on non-allowed TCP ports** — Landlock net (ABI V4 / kernel 6.7), always present at the floor. Port-only, and dependent on the ring-2 `MSG_FASTOPEN` block for the wall to hold (§4/§5).
-- **Signalling / abstract-socket peers outside domain** — Landlock scope (ABI V6 / kernel 6.12), always present at the floor → `EPERM`. In the container path this is also provided by the runtime's netns/pidns; oring's scope ring is defense-in-depth there.
-
-### Residual — what a compromised agent CAN still do (honest)
-- Full control inside granted FS tiers (the workdir is `write_exec` under `balanced` — an in-tree write-then-run payload is possible; documented, not fixed).
-- **Poisoning the workdir so the OPERATOR executes it later, unconfined.** The child can write `.git/hooks/*` and `.git/config` (`core.fsmonitor`, `core.pager`, `core.sshCommand`, `alias.*`, `diff.*.textconv` all run commands), and equally `Makefile`, `build.rs`, `package.json` scripts, `conftest.py`. Each fires on the next `git`/build/test the operator runs **outside** the cage, at their uid. This is not closeable at this layer and is **not** the same class as the `~/.cargo/bin` `$PATH` over-grant that was fixed on: that was write access the agent never needed, whereas this is the tree the agent exists to edit. Landlock has no deny rule — access is the union of every rule on the path — so a subpath cannot be carved out of a granted tier; withholding `.git/` would mean never granting the repo root, which blocks creating any new top-level file. **The control is review-before-execute:** treat a workdir an agent has touched as untrusted input, and run the first build/test after an agent session inside the cage, not outside it.
-- Outbound TCP to **any IP** on an allowed port (443 → any HTTPS endpoint → exfil). Landlock net is port-only, not domain/IP.
-- **DNS/UDP exfil** — Landlock has no UDP control through V7.
-- Read its own env → steal any credential injected into the ring. **Narrowed:** the child's env is now an allowlist by default (`BASE_ENV_KEEP` — POSIX/locale basics only), so a credential reaches the child only when the operator names it with `--env-keep` or opts out wholesale with `--env-inherit`. The residual is now "steal any credential the operator explicitly admitted," which is a decision rather than an accident.
-- **Reach the operator's `ssh-agent` — unless built with the V9 wall.** `AccessFs::ResolveUnix` walls pathname-socket `connect()`, but only at ABI 9 and only when compiled `--features resolve-unix`. Without that (the default build, or any kernel < 7.1), there is no right gating it: both profiles grant `/tmp` (`src/profile.rs:44`, `:183`), OpenSSH's socket sits at `/tmp/ssh-XXXXXX/agent.NNNN`, and a confined child could sign as the operator. The env allowlist removes the trivial path (`SSH_AUTH_SOCK` is not passed) but not the hole — `/tmp` carries `ReadDir`, so the socket is findable by scan. Mitigation is not available at the Landlock layer (no deny rule; a granted `/tmp` cannot have a subpath carved out), so oring reports it loudly at seal and `warn_on_sockets` warns (a two-level scan that DOES reach the ssh-agent depth). Build `--features resolve-unix` on kernel 7.1+ to make it structurally absent.
-- **Peer procfs read**: `/proc//{environ,maps,mem}` — a compromised child can read a peer uid-1000 process's environment/memory. These are VFS `read()`s gated only by `ptrace_may_access`, not by a blocked syscall, and **Landlock does not reliably wall them at the V7 floor** (measured NotBlocked on kernel 6.15, containment-matrix cell `proc-peer-env-full`). A newer kernel's `ptrace_access_check` hook was observed to deny them on 7.1.3, but that is a version-dependent bonus, **not a floor guarantee** — so the honest posture is that this is a residual. Metadata (`cmdline`/`stat`) is world-readable regardless. Controls: `--proc self`, or runtime PID-ns + `maskedPaths` (§9).
-- **Connect to a pathname AF_UNIX socket — walled only with the V9 build.** With `--features resolve-unix` on kernel ≥ 7.1, Landlock **`RESOLVE_UNIX`** gates `connect()` to a filesystem-path socket (handled + granted in no tier → `EACCES`); abstract sockets are covered by scope (`EPERM`) at the V7 floor regardless. Without the feature (or below 7.1), pathname-socket connect is **unmediated** — the docker.sock/podman.sock/journald and ssh-agent classes are reachable. `warn_on_sockets` flags a granted tier holding a live socket (two-level scan), and oring prints the residual at seal; neither is a control, so the operator's real defense is: don't grant a tier holding a privileged socket, or build the V9 wall.
-- Unfilterable kernel bug classes: `futex` UAF, the AF_UNIX **SCM_RIGHTS GC race** (genuinely no arg to mask — distinct from the now-filtered MSG_OOB path), Dirty-COW-via-`/proc/self/mem`. Required by threaded workloads.
-- Read unmasked `/proc`/`/sys` unless the runtime masked them.
-
-### Non-goals
-1. **Not a VM/hypervisor boundary.** Shared host kernel. Kernel-isolation-critical workloads (Xen fuzzing rig) need a VM underneath.
-2. **Not a kernel-0-day defense** beyond surface reduction.
-3. **Not a network policy engine.** Domain/IP egress = host nftables default-deny or a filtering proxy — **required companion control** (§9), stated loudly.
-4. **Does not author/load host MAC.** SELinux/AppArmor stay the host's job and stack underneath (intersection).
-5. **Does not mask procfs/sysfs.** Runtime `maskedPaths`/mount-view job. (Pathname unix-socket connect IS now mediated at V9 via `RESOLVE_UNIX` — see §4.)
-6. **No supervisor / seccomp-notify / OCI-hook validation / egress firewall / credential broker / cgroups / rlimits.** Each needs a persistent or privileged component oring by definition isn't.
+## 2. Threat model, losses, and non-goals
+
+**Moved to [`RESIDUALS.md`](RESIDUALS.md) on 2026-08-01.** The section number is
+unchanged and every `DESIGN §2` citation still resolves. They are spread across
+`src/`, `tests/` and the other documents here, and renumbering them would have
+bought a reader nothing.
+
+It holds the attacker model, the kernel-enforced guarantees, every residual with
+its loss and disclosure tag, and the non-goals. It is the longest thing here and
+the one to read before relying on oring for anything, which is why it is now a
+document a reader can open on its own rather than a third of this one.
+
+Correction history for those residuals is in
+[`CORRECTIONS.md`](CORRECTIONS.md), cited from each entry as `[corrections: C-n]`.
----
## 3. Architecture — rings and host layering
@@ -84,328 +106,29 @@ Consequences: oring is **additive and safe when the runtime already confines** (
## 4. Landlock design (ring 1)
-Single ruleset, single `restrict_self`.
-
-### FS access tiers — DERIVED from the kernel right vocabulary, not enumerated
-
-| Tier | dir set | use |
-|------|---------|-----|
-| `read_only` | `ReadFile\|ReadDir` (+`Refer`) | config, read-only mounts |
-| `read_exec` | `read_only\|Execute` (+`Refer`) | `/usr`,`/lib`,`/usr/lib`,`/usr/libexec`, interpreters |
-| `write_noexec` | `read_only\|WriteFile\|MakeReg\|MakeDir\|RemoveFile\|RemoveDir\|MakeSock\|MakeFifo\|MakeSym\|Truncate\|IoctlDev` (+`Refer`) | `/tmp`, scratch — W^X |
-| `write_exec` | `write_noexec\|Execute` | the workdir |
-
-**FS decisions:**
-- **Build unions explicitly; do NOT use `AccessFs::from_write(abi)`** — that helper includes `MakeChar\|MakeBlock`. Device-node rights appear in no tier; combined with `handle_access(AccessFs::from_all(abi))` for FS they are handled-but-never-granted → globally denied (defense-in-depth with seccomp `mknod`/`mknodat`→ERRNO).
-- **Dir-vs-file split** (`RightScope` in `landlock.rs`): the kernel rejects dir-only rights (`ReadDir`,`Make*`,`Remove*`,`Refer`) on a non-dir FD (`EINVAL`). Each `AccessFs` right is tagged `Both` or `DirOnly`; a file mask keeps only `Both` rights. Route each path by `metadata().is_dir()` (follows symlinks, matching `PathFd`). Missing paths → dir bucket.
-- **Manual `IgnoreIfMissing`**: `PathFd::new` → `Err` on ENOENT → skip; any other error (EACCES) → **fail closed**. Because a silently-skipped path yields a downstream EACCES that looks like an oring bug, emit a startup diagnostic listing every skipped path (§8, §11).
-- **Refer** in dir sets only. Safe on `read_only`: it is only the inert endpoint of a rename; unlinking the source still needs `RemoveFile`/`RemoveDir`. No write leaks.
-- **List real usrmerge targets, not symlinks.** Do not rely on `/lib`→`/usr/lib`; list `/usr/lib` and `/usr/libexec` directly (node bundle / libexec live there).
-
-### FS ABI strategy — hard floor V7, `RESOLVE_UNIX` (V9) opt-in
-Query `ABI::new_current()`. **Hard floor V7 / kernel 6.15** (`FLOOR_ABI = 7`, `src/landlock.rs`), hardcoded, refuse below it. V7 is the coherent-cage minimum: FS core + `Refer`/`Truncate` (V1–V3), TCP net (V4), `IoctlDev` (V5), IPC scope (V6), and audit logging (V7) are all *guaranteed* there, so there are **no degraded/backcompat branches** — running a partial cage on an old kernel is worse than refusing (D-4). Below V7 oring exits with one clear message; there is no `--allow-net-degrade`, no scope-skip-below-V6, no audit-skip-below-V7. Mask the FS core to detected ABI (`from_all(abi)`) so a healthy kernel returns `FullyEnforced`.
-
-One right beyond the floor, **`RESOLVE_UNIX` (V9 / kernel 7.1)**, walls `connect()` to a pathname AF_UNIX socket (docker.sock / ssh-agent). It is **additive hardening applied best-effort**: active only when built `--features resolve-unix` AND the kernel is ≥ V9 (`resolve_unix_active`), and granted in **no** tier so an out-of-domain pathname connect returns `EACCES`. When inactive, pathname-socket connect is a residual (§2), reported loudly at seal and by the `warn_on_sockets` lint. The degradation is fail-safe — the missing right only ever removes a *restriction*, never widens a grant, and its absence is never silent. `abi_from_version` clamps to the crate ceiling (V7 without the feature, V9 with it); `abi_from_version_clamp` (Kani, `make verify`) proves that clamp never over-claims. A hard V9 floor was rejected: it would make oring refuse on nearly every machine (7.1 is recent) and force a vendored fork, where the graceful design keeps the checked-in dependency the published crate (§10).
-
-### Net — per-direction, ABI-gated, no silent downgrade
-rust-landlock chooses per-direction via `handle_access` (go-landlock's connect+bind coupling trap does not apply — one ruleset).
-- `egress_tcp`/`ingress_tcp` each modeled `Option>`. `None` ⇒ don't handle that direction (unrestricted). `Some(ports)` ⇒ handle + one `NetPort::new(port, access)` per port. `Some(vec![])` ⇒ handled, zero rules ⇒ **explicit deny-all**.
-- **Do NOT mask net to detected ABI.** For any requested net restriction (connect or bind, including the empty-vec deny-all), set `CompatLevel::HardRequirement` before `handle_access` — never let deny-all silently become allow-all, never launder a dropped net ring into `FullyEnforced`. TCP net is V4 and always present at the V7 floor, so this never degrades; there is no opt-in escape.
-- **Landlock net gates only the `connect(2)`/`bind(2)` hook**, and only by *port* — not IP, not protocol. Two consequences the operator must know: an allowed port reaches *any* IP (port-only, §2), and `sendto(…, MSG_FASTOPEN, …)` performs an implicit connect inside the send path that never reaches `security_socket_connect`. The TCP port wall therefore holds only because **ring 2** blocks `MSG_FASTOPEN` (§5); Landlock alone does not close it. Domain/IP egress is a required host companion control (§9).
-
-### Scope (IPC)
-`scope(Scope::from_all(abi))` ⇒ `AbstractUnixSocket|Signal`, always present at the V7 floor (scope is V6). An out-of-domain abstract-socket connect → `EPERM`, and signalling peers outside the domain is blocked. In the container path this containment also comes from netns/pidns; oring's scope ring is defense-in-depth there.
-
-### V7 audit logging — wire it
-After `create()` and before `restrict_self()`, best-effort (gated ABI≥V7): `log_new_exec(true)` (LOG_NEW_EXEC_ON — log the confined child's denials) + `log_same_exec(false)` (suppress oring's own pre-exec noise). Single call covers FS+net+scope (one ruleset). Records surface to the HOST auditd/dmesg only — the confined uid-1000 child cannot and should not read them. Zero cost where the host has no audit path.
-
-### Status gate — reframed (§14 D-3)
-Enforcement is guaranteed by **ABI-masking of the FS core** (`FullyEnforced` by construction on any non-broken V3+ kernel), not by the status check. The status gate is a **tripwire**, not the security-meaningful control:
-- `NotEnforced` → **fatal**, actionable: "Landlock present but NotEnforced; kernel likely booted with `lsm=` disabling it or `CONFIG_SECURITY_LANDLOCK` off — refusing."
-- `PartiallyEnforced` → **fatal** tripwire for a broken rust-landlock/kernel invariant (unreachable under FS-only masking); message prints detected ABI + the handled-access set oring itself built.
-- `no_new_privs == false` → **fatal** (internal bug — oring set it in step 4).
-
-Additionally emit a **per-ring effective report** (fs=enforced, net=enforced|refused, scope=on|dropped(needs V6, host Vn)) so oring never prints green when a requested ring was dropped.
-
-### Rust sketch
-```rust
-let abi = ABI::new_current();
-if abi < ABI::V7 { refuse(120, "Landlock ABI < V7 (need kernel 6.15+); detected {abi:?}"); }
-
-// FS core: masked to detected ABI (>= V7 by the floor above).
-let mut rs = Ruleset::default()
- .set_compatibility(CompatLevel::BestEffort)
- .handle_access(AccessFs::from_all(abi))?;
-
-// Net: HARD requirement — TCP (V4) is always present at the V7 floor.
-if policy.net_requested() {
- rs = rs.set_compatibility(CompatLevel::HardRequirement);
- if let Some(_) = policy.egress_tcp { rs = rs.handle_access(AccessNet::ConnectTcp)?; }
- if let Some(_) = policy.ingress_tcp { rs = rs.handle_access(AccessNet::BindTcp)?; }
- rs = rs.set_compatibility(CompatLevel::BestEffort);
-}
-if policy.scope_ipc { rs = rs.scope(Scope::from_all(abi))?; } // scope (V6) present at floor
-
-let created = rs.create()?;
-created.set_log_new_exec(true).set_log_same_exec(false); // best-effort V7
-// ... add_rules(fs tiers, net ports) ...
-let status = created.restrict_self()?;
-gate(status); // tripwire + per-ring report
-```
-
----
+**Moved to [`INTERNALS.md`](INTERNALS.md) on 2026-08-01**, with §5 and §6. Section
+number unchanged; every `DESIGN §4` citation still resolves.
## 5. Seccomp design (ring 2)
-**Denylist, decisively** — `default = Allow` with targeted ERRNO blocks. oring wraps arbitrary dev toolchains (cargo/npm/pip/go/node/git/gcc/clang/python); an allowlist has to track the moving union of every runtime's syscalls — one miss = `SIGSYS` mid-build. This is why Docker's own default is a denylist. Job: delete the well-known LPE launchpads. A denylist can't stop a 0-day in an *allowed* syscall — Landlock is the containment backstop.
-
-**Errno, never blanket-kill** (per-syscall action, chosen by what the denial should look like to the caller):
-- `ENOSYS(38)` where a userspace fallback exists: `io_uring*`, `userfaultfd`, `clone3`, `splice`/`tee`/`vmsplice`, `memfd_create`, `execveat`, `pidfd_open`, `keyctl`/`add_key`/`request_key`.
-- `EPERM(1)` where there's no fallback: `mount`/`ptrace`/`bpf`/`kexec`/`reboot`/etc.
-- `EAFNOSUPPORT(97)` for the `socket` family filter.
-- `EOPNOTSUPP(95)` for the MSG_OOB / SIOCATMARK filters.
-- `ENOTTY(25)` for the file-ioctl defense-in-depth set (deferred to `strict`/v2 — not in shipped `balanced`).
-
-`KillProcess` reserved for a future `paranoid` profile (turns capability probes into crashed builds). `Notify`/user-notif out of scope (reintroduces a supervisor).
-
-### x32 ABI guard — MANDATORY v1 prepend (fixes the central false claim)
-seccompiler's auto arch-prologue compares only `seccomp_data.arch == AUDIT_ARCH_X86_64`. It kills i386/int-0x80 (arch differs) but **does NOT close x32**: x32 runs in long mode with arch == AUDIT_ARCH_X86_64, distinguished by `__X32_SYSCALL_BIT (0x40000000)` OR'd into `seccomp_data.nr`. An x32-numbered `ptrace`/`bpf`/`io_uring`/`mount`/`unshare` passes the arch guard, matches no number-keyed deny rule, and falls through to `default=Allow` — reopening every blocked launchpad on any `CONFIG_X86_X32_ABI=y` kernel (stock Ubuntu/Debian generic). seccompiler's exact-`u32`-nr rule model cannot express a masked-bit test.
-
-**Fix:** install a hand-assembled 7-instruction cBPF guard as a **separate `SET_MODE_FILTER` call installed FIRST** (filters stack; a KILL in any filter wins):
-```
-LD seccomp_data.arch
-JEQ AUDIT_ARCH_X86_64 -> next ; else RET KILL_PROCESS (kills i386 + foreign arch)
-LD seccomp_data.nr
-JSET 0x40000000 -> RET KILL_PROCESS (any x32-bit call)
-RET ALLOW ; defer to the real denylist
-```
-Whole-ABI kill is safe (a cap-drop=ALL uid-1000 dev toolchain never issues x32). This is a small audited splice, not a hand-rolled filter — the insta rule-snapshot (§11) makes it reviewable. The x32 re-entry (`nr | 0x40000000`) shares AUDIT_ARCH_X86_64, so a number-keyed deny is bypassable without it; the guard KILLs foreign/compat arches outright.
-
-### v1 `balanced` block set
-The denylist is the known kernel escape surface, selected from first principles: each entry is an LPE launchpad, a kernel-attack-surface syscall, a named-CVE mitigation, or device-node creation — a functional set a `cap-drop=ALL`+NNP uid-1000 dev toolchain never legitimately needs. Grouped by errno (§ mechanism below); every row carries its rationale. This table is the representative core; the authoritative, exhaustive set is the compiled-in `&[SyscallRule]` in `src/seccomp.rs` (snapshot-tested, §11) — read it, not this table, when reasoning about coverage.
-
-| Syscall(s) | Match | Action | Rationale |
-|---|---|---|---|
-| `io_uring_setup/enter/register` | all | ENOSYS | async-IO LPE surface; runtimes fall back |
-| `userfaultfd` | all | ENOSYS | UAF-exploit primitive |
-| `memfd_create` | all | ENOSYS | fileless-exec staging |
-| `clone3` | all | ENOSYS | struct args unreachable to cBPF → glibc/Go fall back to `clone` |
-| `execveat` | all | ENOSYS | fexecve/memfd exec vector (`execve` stays ALLOWED) |
-| `pidfd_open` | all | ENOSYS | pidfd LPE surface |
-| `splice`/`tee`/`vmsplice` | all | ENOSYS | Dirty-Pipe (CVE-2022-0847) |
-| `keyctl`/`add_key`/`request_key` | all | ENOSYS | keyring LPE |
-| `bpf` | all | EPERM | eBPF LPE surface |
-| `perf_event_open` | all | EPERM | perf LPE surface |
-| `ptrace`/`process_vm_readv`/`process_vm_writev`/`kcmp`/`process_madvise`/`pidfd_getfd` | all | EPERM | cross-process (does NOT cover /proc/pid/mem — §2) |
-| `mount`/`umount2`/`open_tree`/`move_mount`/`fsopen`/`fsconfig`/`fsmount`/`fspick`/`pivot_root`/`chroot`/`setns` | all | EPERM | mount/ns manipulation |
-| `mknod`/`mknodat` | all | EPERM | device-node creation (belt w/ Landlock) |
-| `unshare` | 7 × MaskedEq, one per `CLONE_NEW*` bit | EPERM | **per-flag** (§ below) |
-| `clone` | 7 × MaskedEq, one per `CLONE_NEW*` bit | EPERM | **per-flag**; blanket block would break threads |
-| `kexec_load`/`kexec_file_load`/`init_module`/`finit_module`/`delete_module` | all | EPERM | kernel image/module |
-| `reboot`/`swapon`/`swapoff`/`acct`/`quotactl`/`vhangup`/`syslog` | all | EPERM | admin ops |
-| `ioperm`/`iopl`/`pciconfig_*` | all | EPERM | port IO |
-| `settimeofday`/`clock_settime`/`adjtimex`/`clock_adjtime`/`sethostname`/`setdomainname` | all | EPERM | global mutation |
-| `fanotify_init`/`fanotify_mark` | all | EPERM | fs-wide notify |
-| `move_pages`/`migrate_pages`/`modify_ldt` | all | EPERM | niche LPE surface |
-| `seccomp` | all | EPERM | self-defense |
-| `socket`/`socketpair` | `Ge`(family, 17) | EAFNOSUPPORT | block PACKET/TIPC/ALG/VSOCK; allow UNIX/INET/INET6/NETLINK (`socketpair` walks the same family gate) |
-| `send`/`sendto`/`recv`/`recvfrom` | MaskedEq(arg3 & MSG_OOB) | EOPNOTSUPP | **CVE-2025-38236 AF_UNIX MSG_OOB UAF** |
-| `sendmsg`/`recvmsg` | MaskedEq(arg2 & MSG_OOB) | EOPNOTSUPP | same — flags is arg2 for the msg variants |
-| `sendmmsg`/`recvmmsg`/`recvmmsg_time64` | MaskedEq(arg3 & MSG_OOB) | EOPNOTSUPP | same — flags is arg3 for the mmsg variants |
-| `ioctl` | Eq(arg1 == SIOCATMARK 0x8905) | EOPNOTSUPP | MSG_OOB pair |
-| `sendto`/`sendmmsg` | MaskedEq(flags & MSG_FASTOPEN) | EOPNOTSUPP | implicit-connect egress that never reaches Landlock's `connect()` hook — the only reason the TCP port wall holds (ATTACKS §3a) |
-| `open_by_handle_at`/`name_to_handle_at` | all | EPERM | fd-from-handle reopen that sidesteps path-based Landlock |
-| `ioctl` | Eq(arg1 ∈ {TIOCSTI, TIOCLINUX, TIOCSETD}) | EPERM | TTY injection |
-| `ioctl` | Eq(arg1 ∈ file-ioctl set below) | ENOTTY | file-fd defense-in-depth — **deferred to `strict`/v2**, not in shipped `balanced` |
-| `mmap`/`mmap2` | MaskedEq(arg3 & MAP_GROWSDOWN) | EPERM | StackRot (CVE-2023-3269) |
-| `prctl` | MaskedEq(arg0 == PR_SET_DUMPABLE & arg1 > 0), (arg0 == PR_SET_PTRACER) | EPERM | re-dumpable / ptracer |
-
-**File-ioctl defense-in-depth set — deferred to `strict`/v2, NOT shipped in `balanced`** (exact-cmd `Eq`→ENOTTY, same mechanism as TIOCSTI; encoding a wrong ioctl number breaks benign ioctls, so it waits for the tighter profile): `FS_IOC_ENABLE_VERITY` (irreversible in-place immutability), `FS_IOC_FIEMAP` (physical-layout leak), `FS_IOC_ADD_ENCRYPTION_KEY`/`FS_IOC_REMOVE_ENCRYPTION_KEY`, `XFS_IOC_FSBULKSTAT`/`XFS_IOC_BULKSTAT`/`XFS_IOC_FSGEOMETRY` (cross-tier inode metadata on ext4/xfs), `EXT4_IOC_ALLOC_DA_BLKS`. These are metadata-leak / own-tier DoS, **not** confinement escape — they tighten the already-conceded writable-tier residual. They matter mainly on a bare-metal host with real ext4/xfs; largely moot on a container tmpfs/overlay. Tools fall back cleanly on ENOTTY (verified: `cp`/`rsync` do).
-
-**Deliberately NOT in v1:**
-- `personality` — **cut from `balanced`.** With `mprotect` unrestricted (mandatory for JIT/GC runtimes), blocking `READ_IMPLIES_EXEC` buys no W^X margin an attacker with code-exec can't already get via `mprotect(PROT_EXEC)`, and `ADDR_NO_RANDOMIZE` self-disable is negligible post-compromise; blanket-blocking in `balanced` only risks breaking `setarch`/ASAN-re-exec/emulators. **`strict` DOES block `personality(READ_IMPLIES_EXEC)`** — there `mprotect`/`pkey_mprotect(PROT_EXEC)` are filtered for true W^X (§7), so `personality(RIE)` is a real escape route that arg-filtering must also close (`seccomp.rs`).
-- **Block-device / loop / dm ioctl wall** (`BLK*`, loop 0x4c, dm 0xfd) — dead under cap-drop=ALL + Landlock device-node denial + no block-dev nodes. → `strict` later.
-- **btrfs 0x94 magic block** — its only unprivileged member is the benign `FICLONE`/`FICLONERANGE` (reflink copy); every destructive member is CAP_SYS_ADMIN-gated. An order-dependent "ALLOW FICLONE first, then ERRNO the 0x94 magic" carve-out is **impossible** with cumulative seccomp filters (ERRNO outranks ALLOW; seccompiler binds one action per filter). So oring does NOT block 0x94 at all — no protection lost. **Invariant: oring only ever uses whole-syscall or whole-magic blocks with no benign-exception; any in-mask carve-out is a libseccomp-only capability oring does not carry.**
-
-### `clone`/`unshare` — per-flag, not one combined mask
-cBPF/seccompiler cannot do "any bit in mask set" in one compare (no masked-not-equal op). A single `MaskedEq(mask=OR-of-all-NEW*, value=OR-of-all)` matches only when ALL bits are set — trivially bypassed by `unshare(CLONE_NEWUSER)` alone (the userns-LPE launchpad). Emit **7 rules per syscall**, each `MaskedEq(mask==value==single bit)`: NEWNS 0x20000, NEWCGROUP 0x2000000, NEWUTS 0x4000000, NEWIPC 0x8000000, NEWUSER 0x10000000, NEWPID 0x20000000, NEWNET 0x40000000. The security-load-bearing bit is `CLONE_NEWUSER` (the unprivileged-userns LPE launchpad); the other six namespace flags are cheap defence-in-depth on the same syscalls.
-
-### Mechanism, action, arch
-- **seccompiler 0.5.x** (Firecracker, pure Rust — `libc` bindings + optional serde, no `-sys`, no C link). Only option that keeps the musl-static entrypoint AND gives `SeccompCmpOp::MaskedEq`. Reject libseccomp-rs (links `libseccomp.so`). Reject hand-rolled cBPF except the audited x32 guard splice.
-- **Per-syscall errno mixing = N cumulative sub-filters, one per DISTINCT errno.** Not "two." Partition the deny set so each blocked syscall matches in exactly one sub-filter (`mismatch_action=Allow`, `match_action=Errno(e)`). v1 `balanced` = **4 sub-filters** (EPERM, ENOSYS, EAFNOSUPPORT, EOPNOTSUPP) + the x32 KILL guard installed first (the file-ioctl `ENOTTY` set is deferred to `strict`/v2, above). Filters stack; ERRNO outranks ALLOW; no ties. Cost is an N-filter stack of linear partition scans + N−1 arch prologues, not a blanket "double" — quantify against actual partition sizes. The errno partition is a property of each syscall's denial semantics (pretend-absent → ENOSYS, denied → EPERM/EACCES, unsupported family → EAFNOSUPPORT), so the filter count follows the profile automatically.
-- **Arch: x86_64 only + kill-on-mismatch + the x32 guard.** aarch64 is a compile-time target only, added when a native arm64 Linux host becomes a security target (landlock 0.4.5 + seccompiler 0.5 both support it).
-- **Representation:** blocklist as a compiled-in Rust `&[SyscallRule]` — no JSON-at-runtime on the security-critical path, type-checked, uses `libc::SYS_*` constants (no syscall-name table).
-
----
+**Moved to [`INTERNALS.md`](INTERNALS.md) on 2026-08-01**, with §4 and §6. Section
+number unchanged; every `DESIGN §5` citation still resolves.
## 6. Pre-exec sequence (definitive order — uniform fail-closed)
-`pipeline::seal()` runs this exact sequence; `main` then execs. **Every step is HARD (`?`)** — there is no SOFT category in v1 (both former "soft" steps are effectively infallible for a valid unprivileged caller; promoting them makes "every step fail-closed" literally true). `panic="abort"` ⇒ no partial-seal-then-exec. Note `panic="abort"` is set on the **release** profile only (§12); debug/test builds are `panic="unwind"`. The seal is structurally safe under either: `seal() -> Result`, its `execve` is the last statement, so a panic anywhere unwinds *past* (never reaching) the exec — a future dev must not add a `catch_unwind` around the seal assuming abort semantics hold in every profile.
-
-```
- 1. parse argv split at "--"; require a target after it (else exit 2)
- 2. load policy --policy | ORING_POLICY env | --profile balanced (none → refuse, exit 120)
- 3. resolve target PATH lookup → absolute STRING; fstat: reject non-regular/non-exec;
- assert target under a read_exec/write_exec rule (not found → exit 122)
- 4. NO_NEW_PRIVS prctl(PR_SET_NO_NEW_PRIVS,1) (HARD: before seccomp)
- 5. DUMPABLE=0 prctl(PR_SET_DUMPABLE,0) (HARD; oring-window only)
- 6. LANDLOCK ring 1; core hard-fail (HARD: before seccomp)
- 7. signal-mask reset sigprocmask(SIG_SETMASK, empty) (HARD)
- 8. FD scrub close_range(3, ~0, CLOSE_RANGE_CLOEXEC)
- 9. build argv/env strip ORING_POLICY (pure memory)
-10. SECCOMP x32 guard filter, then N errno sub-filters (LAST restriction)
-11. attest + execve emit seal line to stderr, then execve resolved path, clean env
-```
-
-**Rationale:**
-- **Resolve target (3) before Landlock (6).** Resolve to an **absolute string** first → clean `exit(122)` on missing binary, rather than a post-seal `EACCES` that looks like a malfunction. `fstat` once to reject non-regular/non-executable and enforce the read_exec-coverage check (§8), then close that fd. **No O_PATH fd is held** — TOCTOU closure would require `fexecve`/`execveat`, which oring deliberately blocks (memfd fileless-exec; cBPF can't safely arg-filter it), and the race has no attacker in either deployment (read-only rootfs in-container; single-threaded, no child, no local writer standalone).
-- **NNP (4) before seccomp (10).** Unprivileged `SET_MODE_FILTER` returns EACCES without NNP. Set it explicitly to decouple from Landlock ordering.
-- **`DUMPABLE=0` (5)** — one syscall; protects oring's own pre-exec window only (blocks same-uid `/proc/self/mem`/ptrace-attach while policy sits in memory; suppresses a policy-leaking core dump). **NOT inherited across execve** — the kernel resets the child to dumpable. Child-side ptrace/mem protection comes from the seccomp `ptrace`/`process_vm_*` denials, NOT this step (and does NOT cover peer `/proc//mem` — §2).
-- **Landlock (6) before seccomp (10)** so `landlock_*` setup syscalls aren't blocked.
-- **Signal-mask reset (7)** — execve inherits the mask unchanged; a parent with signals blocked silently hands the child a bad mask. Correctness fix a Go-managed sealer doesn't hit.
-- **Disposition normalization (11a)** — execve resets *caught* handlers but **preserves `SIG_IGN`**, a separate inheritance channel from the mask. Rust installs `SIGPIPE=SIG_IGN` at startup, so an un-normalized child sees `EPIPE` where it should die (`producer | head -1` hangs; `$PIPESTATUS` reads 0, not 141). Every ignored signal except `SIGKILL`/`SIGSTOP` is restored to `SIG_DFL` immediately before execve. Placement is after the seal-line write for ordering hygiene only — **not**, as first documented, because oring's ignored `SIGPIPE` protects that write: `eprintln!` panics on failure and `panic=abort` converts that to an abort, so a closed stderr prevents the exec under either ordering. POSIX hygiene, not a confinement control. Known regression: this also clears a *deliberately* inherited `SIG_IGN`, so `nohup oring -- cmd` no longer hands the child an ignored `SIGHUP`.
-- **FD scrub (8) = `close_range(3, RawFd::MAX, CLOSE_RANGE_CLOEXEC)`** — **not** the `/proc/self/fd` walk (that walk needs `READ_DIR` on a `/proc` rule the lean profiles don't grant → EACCES → the pipeline would brick every invocation; granting `/proc` to fix it reintroduces the peer-procfs residual §2 disclaims). `close_range` needs no path, is indifferent to Landlock ordering, preserves CLOEXEC-not-close semantics, and (critically) does NOT close the exec path since we hold no target fd. Base `close_range` is 5.9+, `CLOSE_RANGE_CLOEXEC` 5.11+ — both well below the V7/6.15 floor. **No async anywhere** (tokio would spawn the epoll/eventfd/pipe FDs this defeats). Sync-only, hard architectural constraint.
-- **Env-build (9)** — the child's env is an **allowlist** (`BASE_ENV_KEEP`, §8), built fresh, not an inheritance minus `ORING_POLICY`. The old claim that "env is not a security boundary here" was wrong in one specific way: the child being sandboxed does not stop it *reading* a credential it was handed, and a credential in the env is exactly the exfil half SECURITY.md owns. `LD_PRELOAD` into the *child* is not a bypass; `LD_*` into *oring* would be catastrophic but oring is static-musl with no dynamic linker → `LD_*` ignored (a security reason for the musl constraint).
-- **Attest + Seccomp + execve (10–11).** **Cross-ring contract: the filter MUST allow `execve`, `exit_group`, and `write`** (for the seal line), or oring bricks itself. `execve` allowed; only `execveat`→ENOSYS.
-
----
+**Moved to [`INTERNALS.md`](INTERNALS.md) on 2026-08-01**, with §4 and §5. Section
+number unchanged; every `DESIGN §6` citation still resolves. **The order is
+load-bearing** — a wrong order is a confinement bypass, not a style nit.
## 7. Policy schema + profile
-The policy is `version` + six FS/net fields (`read_only`, `read_exec`, `write_noexec`, `write_exec`, `egress_tcp`, `ingress_tcp`) + a minimal `seccomp` selector.
-
-```json
-{
- "version": 1,
- "landlock": {
- "read_only": ["/etc","/usr/share"],
- "read_exec": ["/usr/bin","/lib","/lib64","/usr/lib","/usr/libexec"],
- "write_noexec": ["/run","/var/log","/tmp"],
- "write_exec": ["/work/repo"],
- "egress_tcp": [443],
- "ingress_tcp": [],
- "scope_ipc": true
- },
- "seccomp": { "profile": "balanced" }
-}
-```
-
-```rust
-#[derive(Deserialize)] #[serde(deny_unknown_fields)]
-struct Policy { #[serde(default="one")] version: u32,
- #[serde(default)] landlock: Landlock,
- #[serde(default)] seccomp: Seccomp }
-
-#[derive(Deserialize, Default)] #[serde(deny_unknown_fields)]
-struct Landlock {
- #[serde(default)] read_only: Vec, #[serde(default)] read_exec: Vec,
- #[serde(default)] write_noexec: Vec, #[serde(default)] write_exec: Vec,
- #[serde(default)] egress_tcp: Option>,// None=unrestricted, Some([])=deny-all
- #[serde(default)] ingress_tcp: Option>,
- #[serde(default="yes")] scope_ipc: bool }
-
-#[derive(Deserialize, Default)] #[serde(deny_unknown_fields)]
-struct Seccomp { #[serde(default)] profile: SeccompProfile } // v1: only Balanced
-```
-
-`deny_extra`/`allow_extra` are **cut from v1** — no v1 consumer, and validating names would drag a syscall-name→number arch table onto the (sole) config-parse path. Deferred to §13-later.
-
-**Validation (parse-time, before any restriction):** `version==1`; `deny_unknown_fields` (a typo'd `read_execc` errors, never silently grants nothing); absolute paths only; port ≤65535 (serde-free); **target binary must be under some `read_exec` rule** (else specific error, not a cryptic `execve: EACCES`); **error on a missing `--workdir`** (typo-confines-to-nothing footgun); warn on missing system dirs; loud warn on `egress_tcp` containing 53 (UDP DNS not covered) and on any granted tier containing/ancestoring a live socket inode (`docker.sock`/`podman.sock`/containerd/`/run` — §2 escape). Every message prefixed `oring:`, stderr, non-zero exit.
-
-**Self-protection:** `Policy::check_self_protection` (run after `validate`, once every tier is final) hard-refuses when **oring's own binary** or the **policy file** resolves under any write tier — the `--policy ./oring.json --workdir .` shape, where the child could rewrite the policy governing its own next run. An unresolvable `current_exe()` WARNs rather than refuses (an unreadable `/proc/self/exe` is an environment defect, not a policy input).
-
-**Deny-by-default — three states:** no policy resolves → refuse (exit 120); policy grants nothing → refuse with the target-not-covered message; policy with grants → apply. Landlock < V7 floor → refuse (§4); seccomp load failure → refuse.
-
-### Profile: `balanced` (the only v1 profile)
-Compiled-in constant; auto-includes standard read-exec system dirs so the common case is "name your workdir." Agent edits a repo + HTTPS:
-- read_exec: `/usr/bin /usr/local/bin /bin /lib /lib64 /usr/lib /usr/libexec /opt`
-- read_only: `/etc /usr/share` + `/proc` (read-only, so `git`/`node`/`python` can read `/proc/self/*`; peer *secret* reads stay walled by Landlock's ptrace hook, §2)
-- write_exec: workdir (build agent compiles+runs in-tree — the in-tree W^X residual is documented in §2)
-- write_noexec: `/tmp /var/log /var/cache /var/tmp` + the standard character devices
-- HOME toolchain caches: `~/.rustup` read_exec; `~/.gitconfig` read_only; `~/.npm ~/.cache` write_noexec; and the split below
-- `egress_tcp:[443]`; `ingress_tcp:[]` (**deny-all TCP bind** — a fixed-port dev server or test listener, e.g. `python -m http.server 8000`, gets `EACCES`; widen with a `--policy` naming the port); `scope_ipc:true`; seccomp `balanced`.
-
-**`~/.cargo` and `~/go` are split, and the split is load-bearing.** `bin/` under each is on the
-operator's `$PATH`, so granting write there is an escape that needs no kernel bug: the child drops a
-binary, the operator runs it later *unconfined* at their own uid. The tiers are therefore
-
-| path | tier | why |
-|---|---|---|
-| `~/.cargo`, `~/go` | read_only | cargo reads `config.toml`/`env`; these are **parents of `bin/`** so they must not carry write |
-| `~/.cargo/bin`, `~/go/bin` | read_exec | run the shims; never writable |
-| `~/.cargo/registry`, `~/.cargo/git`, `~/.cargo/.package-cache`, `~/go/pkg` | write_noexec | where the toolchains actually write |
-
-The shape is forced by Landlock's combination rule: *"One policy layer grants access to a file path
-if at least one of its rules **encountered on the path** grants the access"*
-(`Documentation/userspace-api/landlock.rst`). Rules **union along the path walk** — a narrower rule
-does not override a wider one. A `write` grant on `~/.cargo` would therefore confer write on
-`~/.cargo/bin` regardless of any rule placed there, so the write grants go on sibling subtrees and
-never on a parent of `bin/`. Nothing executes from these caches (cargo builds scripts and proc-macros
-into `target/`; `go build` links into `$TMPDIR`), so no-exec costs nothing.
-
-*Compat, stated plainly:* `cargo install` and `go install` do not work inside the cage — they write
-`bin/`. `~/.cargo/.package-cache` is cargo's top-level lock **file**, granted as a file rule so it
-does not widen `~/.cargo`; on a `$HOME` that has never run cargo it does not exist yet, is skipped
-like any missing path, and cargo cannot create it under a read-only `~/.cargo`. Run cargo once
-unconfined first, or grant it via `--policy`.
-
-`strict` **ships alongside `balanced`** (`by_name` resolves both; `src/profile.rs`): untrusted-binary posture — nothing granted, workdir `write_noexec` for true W^X, deny-all egress, and a seccomp-blocked `pkey_mprotect(PROT_EXEC)` so the W^X wall can't be reopened. `--profile` accepts `balanced` (default) or `strict`. A `dev` (interactive, wide) profile is still deferred until a wired consumer exists; anything between or beyond the two profiles is expressed via `--policy`.
-
-**`--workdir `** injects one path into `balanced`'s `write_exec`. oring asserts the resolved target binary is already under a `read_exec`/`write_exec` tier and **refuses (exit 122)** if it is not — it does NOT auto-grant the target. No mount-scraping, no `$PATH`/`$HOME` globbing (predictable > clever).
-
----
+**Moved to [`REFERENCE.md`](REFERENCE.md) on 2026-08-01**, with §8. Section number
+unchanged; every `DESIGN §7` citation still resolves.
## 8. CLI / UX
-```
-oring [OPTIONS] -- [args...]
-
- --policy policy JSON file (wins over env)
- --profile baked profile: balanced (default) | strict
- --workdir inject DIR into the profile's writable workdir tier
- (write_exec for balanced; write_noexec for strict)
- --exec-tmp create DIR (0700), grant write_exec, point child's TMPDIR at it,
- for tools that write-then-exec in scratch. /tmp stays no-exec.
- --proc /proc grant: full (default) | self | none (self closes peer-procfs)
- --env-clear pass NO env to the child except --env-keep vars
- --env-keep allowlist one more env var (repeatable). Child gets POSIX basics by
- default; credentials (SSH_AUTH_SOCK/*_TOKEN/*_API_KEY/AWS_*) must be named
- --env-inherit pass the FULL parent env (legacy default; unsafe — child reads every credential)
- --require-all-grants refuse to seal if any intended path could not be granted (CI)
- --denials report what Landlock DENIED to a sealed run, from the kernel's V7 audit
- --since journal window for --denials (default: -1 hour)
- --explain print the effective plan and exit (no execve)
- --check validate policy+target+ABI floor (no execve)
- -v, --verbose emit the effective plan to stderr before execve
- -- everything after = target argv (required)
-
-Policy also via ORING_POLICY (JSON env). ORING_QUIET=1 suppresses the seal line.
-
-DELIVERY PRECEDENCE (highest wins; refuse if none): --policy > ORING_POLICY > --profile.
- (SANDBOX_POLICY and ORING_PROFILE are NOT accepted — no consumer; keeps one parser shape.)
- --profile supplies baked defaults; --policy/ORING_POLICY whole-replace it (no merge).
-
-EXIT: 120 confinement refused (no policy / Landlock < V7 floor / seccomp load failed)
- 121 policy parse/validation error
- 122 target not found
- 159 seccomp KILL (x32/arch guard) = 128+SIGSYS
- * child's execve-preserved status
-```
-
-Exit codes are deliberately **out of the 125/126/127 shell+docker band and the 128+N signal band** so a fail-closed security event is never confused with a benign child `sh -c` exec failure. Every refusal emits one machine-parseable stderr line `oring: REFUSE reason= (code=120)`; a successful seal emits (§6 step 11, §11) a positive seal line (§12).
-
-**Diagnosis path (self-applied cages give bare errnos with no attribution):**
-- `--explain` — pre-exec string dump: resolved FS tiers + path lists, connect/bind port sets, IPC scope state, detected ABI vs floor and which rings actually engaged, seccomp profile + one-line block summary, target read_exec coverage. No deps, no execve.
-- `-v`/`ORING_VERBOSE=1` — emit that same one-block summary to stderr right before execve, so a failed build's log carries policy context inline.
-- Documented errno→cause table in `--help`/README: EACCES on a path = Landlock FS; EACCES on `connect()` = port not in `egress_tcp` / net ABI absent; ENOSYS on io_uring/memfd/clone3 = oring seccomp; EPERM on ptrace/mount/bpf = oring seccomp.
-- **Ceiling stated plainly:** in-band per-denial attribution is impossible without a supervisor (seccomp-notify is out of scope; ptrace/strace are blocked by oring's own filter). `--explain` + verbose-startup + the errno table are the intended in-band workflow; `--denials ` reads the host-side V7 audit records after the fact (operator-side, not readable by the confined child).
-
-### Scratch, procfs, and env flags — security notes
-- **`--exec-tmp `** grants one operator-named `write_exec` scratch dir and wires the child's `TMPDIR` to it, for tools that write-then-`execve` in temp (Miri's sysroot build, `cc`/`ld`, `-sys`/bindgen build scripts, `go test`/`go run`) which `/tmp` (`write_noexec`) denies. It is created at **0700** — a world-writable scratch would let another local uid plant a binary the confined child then runs. A **pre-existing** path is re-validated, not trusted: oring `lstat`s it and refuses a symlink (else `canonicalize` would grant the link's *target* `write_exec` — a widening) or a group/other-writable directory. The grant is folded in **before** `check_self_protection`, so pointing `--exec-tmp` at the oring binary or policy still refuses. `/tmp` itself stays no-exec — this opens exactly one dir. Residual: a TOCTOU swap by an attacker who controls a writable *parent* — name the scratch under sticky `/tmp` or an operator-owned dir. Pinned by `tests/exec_tmp.rs`.
-- **`--proc `** = `full` (default) | `self` | `none`. `self` grants own-pid procfs plus a curated global allowlist, closing even the peer-*metadata* leak (§2); `none` grants no `/proc`.
-- **Env allowlist** — the child's env is built fresh from `BASE_ENV_KEEP` (POSIX/locale basics) plus any `--env-keep NAME`; credentials are withheld by construction, not by enumerating what to drop. `--env-clear` passes only what is named; `--env-inherit` restores the legacy pass-everything default and is documented as unsafe (a compromised child reads every credential in the shell). The rule is the §5 rubric applied to a second namespace: *enumerate what gets in, never what to drop.* Pinned by `tests/deny_env.rs`.
-
----
+**Moved to [`REFERENCE.md`](REFERENCE.md) on 2026-08-01**, with §7. Section number
+unchanged; every `DESIGN §8` citation still resolves.
## 9. Deployment — container entrypoint + standalone
@@ -424,10 +147,10 @@ execve-only and does not reap.
### Companion controls (REQUIRED — oring is only honest with these)
- **Egress:** host nftables default-deny + a credential proxy/broker. oring cannot do domain/IP egress or hide the API key from the process that legitimately holds it (§2). Shipping oring without a paired egress control gives a false sense of containment for exfil.
- **Peer procfs:** runtime PID namespace + `maskedPaths` on `/proc//{mem,environ,maps,kcore}` (default docker/podman provide both). Without a PID ns (standalone), broad `/proc` read exposes every uid-1000 peer's secrets.
-- **Privileged sockets:** at **V9** ring 1 now blocks `connect()` to a pathname unix socket (`RESOLVE_UNIX`, `EACCES`) and to abstract sockets (scope, `EPERM`) — see §4. Bind-mounting `docker.sock`/`podman.sock`/containerd is nonetheless discouraged (defense-in-depth; and it is still unmediated on a V7/V8 kernel). oring's extra contribution is the policy-apply-time socket lint (§7), now ABI-gated to fire only where the connect is actually unmediated.
+- **Privileged sockets:** ring 1 blocks `connect()` to a pathname unix socket (`RESOLVE_UNIX`, `EACCES`) and to abstract sockets (scope, `EPERM`) on every kernel oring runs on — V10 is the floor and `RESOLVE_UNIX` (V9) sits below it, so the V7/V8 window where this was unmediated is now refused outright rather than warned about (§4, D-4b). Bind-mounting `docker.sock`/`podman.sock`/containerd is still discouraged as defense-in-depth: the wall is a Landlock right, and a right is only as good as the ruleset that carries it. The policy-apply-time socket lint (§7) is retained but inert at the floor — it is the tripwire that would start firing again if the floor were ever lowered.
### Standalone (bare metal)
-Same static binary: `oring --policy /etc/oring/rig.json -- ./fuzzer ...`. All rings unprivileged, no root/MAC/pre-installed profile. Hard floor well below a modern host (e.g. Alpine on 6.18). **No IPC/signal boundary** here (shared host netns+pidns, kernel likely <6.12): a compromised child can `SIGKILL` same-uid host processes (incl. a supervising orchestrator) and reach host abstract sockets (systemd/D-Bus). Companion control: launch the target inside a PID+net namespace (`unshare`/`bwrap`/`systemd-run`) or on ≥6.12. Do NOT grant `/run` (or grant only specific non-socket subpaths). Keep `/tmp`/scratch `write_noexec` so the default profile stays W^X-clean where no `noexec` mount sits above oring.
+Same static binary: `oring --policy /etc/oring/rig.json -- ./fuzzer ...`. All rings unprivileged, no root/MAC/pre-installed profile. **The V10 / kernel 7.2 floor binds here too, and it is the main siting constraint** — LTS distro kernels do not meet it, so a bare-metal rig needs 7.2+ or oring refuses at seal (exit 120, §8). IPC scope (V6) sits below that floor and is always installed, so a child cannot signal, or reach abstract sockets belonging to, processes outside its domain — that is ring 1, not the host. What standalone lacks is a PID and network namespace: peer *metadata* under `/proc` stays readable (§2) and nothing bounds egress by address. Companion control: launch the target inside a PID+net namespace (`unshare`/`bwrap`/`systemd-run`) or on ≥6.12. Do NOT grant `/run` (or grant only specific non-socket subpaths). Keep `/tmp`/scratch `write_noexec` so the default profile stays W^X-clean where no `noexec` mount sits above oring.
---
@@ -449,7 +172,9 @@ tests/ deny_fs deny_net deny_syscall deny_x32 deny_env deny_strict exec_tmp
```toml
[dependencies]
landlock = "0.4.5" # pure Rust, no C, musl-clean (published crate; tops at V7)
-seccompiler = "0.5" # pure-Rust BPF emitter; MaskedEq; no TSYNC (single-threaded)
+seccompiler = "0.5" # pure-Rust BPF emitter; MaskedEq. Exposes TSYNC via
+ # apply_filter_all_threads(); oring uses the per-thread
+ # apply_filter() by choice (§10 single-threaded note)
libc = "0.2" # raw syscalls (prctl/close_range/execve/sigprocmask/seccomp) + SYS_*/flag consts
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -481,50 +206,158 @@ opt-level="z" lto=true codegen-units=1 strip=true panic="abort" # fail-clo
- `deny_fs` → EACCES(13) outside tiers + control write in `write_exec` temp.
- `deny_net` → Landlock EACCES on bad port (ECONNREFUSED on allowed port = pass).
- `deny_syscall` → EPERM ptrace, ENOSYS keyctl, **`unshare(CLONE_NEWUSER)` alone → EPERM** (authoritative per-flag assertion, not just all-bits-set), EOPNOTSUPP on MSG_OOB send.
-- `deny_x32` → issue `ptrace` via inline `syscall` with `rax = 0x40000000|__NR_ptrace`, assert SIGSYS/kill; gate on detecting `CONFIG_X86_X32_ABI`, log LOUD if x32 present and blocked (never silently Allowed).
+- `deny_x32` → issue an x32-numbered syscall (`rax = 0x40000000|nr`) and expect
+ SIGSYS. The **process-level** cells cannot close this class on their own: the
+ dominant leak family is a monotone threshold that always spares the numbers a
+ cell names, measured leaking six x32 syscalls while every cell stayed green.
+ What covers it is the model-level check over 12,354 points plus the Kani proof;
+ the file's own header forbids citing it as proof. See `VERIFICATION.md`.
-**d. CI + LOUD skips (never silent-green):** the GitHub hosted `ubuntu-24.04` runner boots kernel ~6.8 — *below* oring's V7 floor — so the ring self-exec probes cannot run on the bare runner. They run inside a **virtme-ng / KVM guest** booting a ≥6.15 kernel (matrix at ABI V7/V8/V9, KVM available on the free runner via a udev rule). Runtime-probe `ABI::new_current()`; below the V7 floor → `eprintln!("SKIP: …")` **and count the skip**. Gate hard runs behind `ORING_REQUIRE_KERNEL=1` so a runner that should have the kernel can't silently skip green. Unit + rule-table snapshot + the z3 SMT gate + Kani proofs run on the bare runner; macOS dev: integration cfg-gated off, unit+snapshot compile.
+**d. CI + LOUD skips (never silent-green):** no hosted GitHub runner is at oring's floor, so the ring self-exec probes never run on a bare runner. They run inside a **virtme-ng / KVM guest** booting a ≥7.1 kernel (KVM available on the free runner via a udev rule). The old V7/V8/V9 ABI matrix collapses to V9: sub-floor ABIs are now refusal cases, not degraded-run cases, so what they exercise is the floor gate itself — worth one cheap cell each asserting exit 120, not a full confinement leg. Runtime-probe `ABI::new_current()`; below the V9 floor → `eprintln!("SKIP: …")` **and count the skip**. Gate hard runs behind `ORING_REQUIRE_KERNEL=1` so a runner that should have the kernel can't silently skip green. Unit + rule-table snapshot + the z3 SMT gate + Kani proofs run on the bare runner; macOS dev: integration cfg-gated off, unit+snapshot compile.
-**e. Drift alarm (roadmap, non-blocking):** track the kernel's evolving LPE-primitive surface (new syscalls in the RustSec/kernel-hardening advisory stream) against oring's `&[SyscallRule]` and a checked-in rationale for each intentional omission, so a genuinely new escape primitive forces a human decision instead of silent lag.
+**e. Drift alarm (not implemented):** track the kernel's evolving LPE-primitive surface (new syscalls in the RustSec/kernel-hardening advisory stream) against oring's `&[SyscallRule]` and a checked-in rationale for each intentional omission, so a genuinely new escape primitive forces a human decision instead of silent lag.
-**Fuzzing (deferred to §13-later):** the policy parser sits on a parent/config trust boundary, NOT an attacker-controlled path (the attacker is the post-execve child; nested re-wrapping only tightens). `panic="abort"` + refuse-on-error makes any parse bug fail-closed. So cargo-fuzz on `Policy::parse` is not v1 scope. `cargo-deny`/`cargo-audit` stay in CI (tiny dep tree by design).
+**Fuzzing (not in the current implementation):** the policy parser sits on a parent/config trust boundary. **CORRECTED 2026-07-29: the clause that followed — "NOT an attacker-controlled path" — is false, and it was the only place in the tree asserting those bytes are trusted.** `ORING_POLICY` is an environment variable, `src/main.rs` refuses `--profile` alongside it *precisely because* an untrusted earlier step can set it, and policy-derived strings reach 13 of ~14 diagnostic print sites unsanitised — a forged `SEALED` line was injected through one of them (`BUILD-QUEUE.md` T26). Two premises of the fuzzing decision also need restating: `panic="abort"` is set on `[profile.release]` **only**, so "fails closed" is release-only and debug/test builds unwind. **The decision (no cargo-fuzz in v1) may still stand on cost grounds; the trust label that licensed it does not** (the attacker for the parsed bytes is whoever set the variable, not only the post-execve child; nested re-wrapping only tightens). `panic="abort"` + refuse-on-error makes any parse bug fail-closed. So cargo-fuzz on `Policy::parse` is not v1 scope. `cargo-deny`/`cargo-audit` stay in CI (tiny dep tree by design).
---
## 12. Build / packaging (static musl)
- **`x86_64-unknown-linux-musl`, fully static, no interpreter.** Non-negotiable: (1) oring is first-exec — a dynamic oring fails on distroless/scratch/Chainguard-static; (2) single relocatable artifact drops into any image or bare host; (3) `LD_*` ignored → can't subvert oring pre-seal (security, not just packaging).
-- Deps are pure-Rust/rustix-linux_raw → no C toolchain. `cargo build --release --target x86_64-unknown-linux-musl`; from macOS use `cross`. CI gates on `ldd`→"not a dynamic executable" + `file`→"statically linked". Sub-1MB expected.
+- Deps are pure-Rust/rustix-linux_raw → no C toolchain. `cargo build --release --features resolve-unix --target x86_64-unknown-linux-musl` — the feature is required, not optional (`src/landlock.rs` `compile_error!`s without it, and `[features]` declares no default; `make build` supplies it); from macOS use `cross`. CI gates on `ldd`→"not a dynamic executable" + `file`→"statically linked". Sub-1MB expected.
- **Distribution:** one static artifact + `.sha256`. A container consumer bundles it via multi-stage `COPY` (arch-aware so a native arm64 build can't get a bricked x86_64 entrypoint). The seccomp profile is inside the binary; standalone gets `/etc/oring/*.json` per host.
- Reproducibility: committed `Cargo.lock` + `--locked`, pinned `rust-toolchain.toml`, `SOURCE_DATE_EPOCH`, `--remap-path-prefix`, `--build-id=none`; `build.rs`-captured git SHA feeds the seal line; two-host diff in CI. SLSA/cosign later.
-**Seal line (v1) — a status REPORT, not an attestation:** the last action before `execve` (step 11), after `restrict_self` + seccomp apply, emit one stderr line: `oring + sealed ring1_fs=… abi=V net=on|off scope=… audit=… nnp=1`. Sourced from the actual `RestrictionStatus`, `ABI::new_current()`, and which `handle_access` calls fired. It is an **unsigned, forgeable `eprintln!`** — the sealed child inherits stderr and `write` is deliberately allowed, so the child can print an identical line. Treat it as an informational marker, **not** a trust boundary or proof of provenance; a downstream consumer must not treat the token as authoritative. Default ON (untrusted-agent model); suppress via `ORING_QUIET=1`. (The earlier `ring1=degraded`/`net=refused` sketch is stale — those states are unreachable at the V7 hard floor.) Real signed provenance (SLSA/cosign) is roadmap (§13).
+**Seal line (v1) — a status REPORT, not an attestation:** the last action before `execve` (step 11), after `restrict_self` + seccomp apply, emit one stderr line: `oring: SEALED + ring1_fs=… grants=/ abi=V errata=0x net=tcp-ports|tcp+udp-ports|udp-ports|off scope=… audit=… unhandled=|- ring2=balanced|strict nnp=1 pid=`. Sourced from the actual `RestrictionStatus`, `ABI::new_current()`, the errata probe, and which `handle_access` calls fired. **`unhandled=`** names the restrictable rights this run left OPEN (`net.ConnectSendUdp`, …) or `-` for none — derived by set difference against the crate's `from_all(abi)`, never declared. `abi=` reports the interface version and `net=`/`scope=` report what was *asked for*; none of them could say what was left *unrestricted*, which is the question an auditor reading a seal line after the fact is actually asking. **`errata=`** reports which **declared** Landlock errata the running kernel carries (`?` if it did not answer): the ABI integer describes the *interface*, the errata bitmask describes which published *fixes* are present, and the two can disagree because vendor kernels are assembled by backporting. **It is a LOWER BOUND on behavioural difference, not a certificate that the interface behaves as documented** — a distinction this document overstated until 2026-07-27. The mask enumerates only errata the kernel *declares*, and 7.2-rc4 declares exactly three (1 at ABI 4, 2 at ABI 6, 3 at ABI 1) while carrying at least one behaviour change that has none: `MSG_FASTOPEN`'s implicit connect is mediated at `security/landlock/net.c:354`, and no erratum covers it. So `errata=0x7` means "carries the three declared fixes", and two kernels agreeing on `(abi, errata)` may still differ in behaviour. A floor-level erratum reported absent also raises a per-erratum WARN — it warns rather than refuses, because every such erratum predates the floor and no host has been shown that meets V10 without them (same asymmetry as the abi-ahead path). See `docs/CITATIONS.md` §3. It is an **unsigned, forgeable `eprintln!`** — the sealed child inherits stderr and `write` is deliberately allowed, so the child can print an identical line. Treat it as an informational marker, **not** a trust boundary or proof of provenance; a downstream consumer must not treat the token as authoritative. Default ON (untrusted-agent model); suppress via `ORING_QUIET=1`. (The earlier `ring1=degraded`/`net=refused` sketch is stale — those states are unreachable at the V9 hard floor.) Real signed provenance (SLSA/cosign) is not implemented.
---
-## 13. LEAN phased roadmap
-
-### v1-MVP — the standalone binary + core rings
-- [ ] Ring 1 Landlock: 4 FS tiers + dir/file split + manual IgnoreIfMissing + `handle_access(from_all)` device-node denial; **hard V7/6.15 floor** (hardcoded, no `--abi-floor`); FS masked to detected ABI; per-direction TCP with **HardRequirement** (V4 present at floor); scope on (V6 at floor); **V7 audit logging wired**; **`RESOLVE_UNIX` (V9) opt-in hardening**; status gate as tripwire + per-ring effective report.
-- [ ] Ring 2 seccomp: one embedded `balanced` denylist via seccompiler; **x32 KILL guard prepend**; kill-on-arch-mismatch; **per-flag clone/unshare CLONE_NEW\* (7+7)**; **MSG_OOB/SIOCATMARK block**; **MSG_FASTOPEN implicit-connect block**; **`open_by_handle_at`/`name_to_handle_at` block**; **personality cut**; errno mixing via **N (=4) cumulative sub-filters** (file-ioctl `ENOTTY` set deferred to `strict`/v2); compiled `&[SyscallRule]`, no JSON/name-table.
-- [ ] Pre-exec: NNP-first, DUMPABLE=0, signal-mask reset, **`close_range` FD-scrub** (not /proc walk), env-scrub, **absolute-string target resolve (no O_PATH fd)**, execve. Uniform HARD fail-closed (no SOFT category).
-- [ ] Policy envelope (version+landlock+seccomp) via `--policy`/`ORING_POLICY`; **one profile `balanced`**; `--workdir`; full validation incl. socket-in-tier lint.
-- [ ] UX: `--explain`, `--check`, `-v`/`ORING_VERBOSE`, errno→cause table; **exit codes 120/121/122** (out of shell/docker band); machine-parseable REFUSE + SEALED stderr tokens.
-- [ ] Static musl x86_64 + `.sha256` + build.rs SHA; **seal line**.
-- [ ] container-consumer wiring (reference): arch-pinned entrypoint; a reaper as PID 1; policy in container coordinates via `ORING_POLICY`; read_exec covering the image toolchain; a CI smoke test that the sealed entrypoint runs the workload.
-- [ ] Tests: unit + rule-table snapshot + self-exec integration (fs/net/syscall/**x32**) + LOUD-skip CI.
-
-### v2 — when a wired consumer appears
-- [ ] `strict` profile: full FS-ioctl magic wall (block-device/loop/dm class; whole-magic MaskedEq, NO in-mask carve-outs — libseccomp-only capability oring does not carry) + stricter FS/net.
-- [ ] `dev` profile (interactive/trusted).
-- [ ] Seccomp drift-alarm CI leg (§11e).
-- [ ] LATER-scope Landlock hardening: scope procfs to own-pid subtree (`/proc/` + curated global allowlist) to close peer-procfs read within oring's own ring (brittle — tools reading other pids break; hence not v1).
-
-### Later — justify before building
-- [ ] `seccomp.allow_extra` hole-punch (the one field that can weaken security) + additive floor guard; `deny_extra` (numeric nr only, or lift seccompiler-bin's arch table — never hand-roll).
-- [ ] `paranoid` allowlist profile — only after the agent syscall set is measured.
-- [ ] aarch64-unknown-linux-musl target + multi-arch OCI manifest — when a native arm64 Linux host is a real security target.
-- [ ] `--abi-floor` override (purely additive, safe to defer); TOML front-end; `--dry-run` full dump; `--argv0`/`--env-clear`/`--env-keep`; auto-derive Landlock from `/proc/self/mountinfo`; write_exec-on-noexec-mount validator; over-broad-grant lint; cargo-fuzz on the config parser; SLSA provenance.
+## 13. Implementation status
+
+What the shipped binary IS. Forward-looking work is deliberately not in this
+document: a public design doc that promises future work ages badly and invites
+planning against things that may never ship.
+
+**Current: v0.2 (unreleased) — hard Landlock floor V10 / kernel 7.2.** Released
+history is in `CHANGELOG.md`; the reasoning behind each choice is the Decision
+Log (§14).
+
+### Implemented — the standalone binary + core rings
+
+- **Ring 1 Landlock:** 4 FS tiers + dir/file split + manual IgnoreIfMissing +
+ `handle_access(from_all)` device-node denial; **hard V10/7.2 floor** (hardcoded,
+ no `--abi-floor`; was V7/6.15 through v0.1 — D-4b); FS masked to detected ABI;
+ per-direction TCP with **HardRequirement** (V4 present at floor); scope on (V6
+ at floor); V7 audit logging wired; `RESOLVE_UNIX` (V9) floor-guaranteed; status
+ gate as tripwire + per-ring effective report.
+- **Ring 2 seccomp:** embedded `balanced` and `strict` denylists via seccompiler;
+ **x32 KILL guard prepend**; kill-on-arch-mismatch; per-flag `clone`/`unshare`
+ `CLONE_NEW*` (7+7); MSG_OOB/SIOCATMARK block; MSG_FASTOPEN implicit-connect
+ block; `open_by_handle_at`/`name_to_handle_at` block; personality cut; errno
+ mixing via N (=4) cumulative sub-filters; compiled `&[SyscallRule]`, no
+ JSON/name-table.
+- **Pre-exec:** NNP-first, `DUMPABLE=0`, signal-mask reset, `close_range` FD-scrub
+ (not a /proc walk), env allowlist, absolute-string target resolve (no `O_PATH`
+ fd), `execve`. Uniform HARD fail-closed — no SOFT category.
+- **Policy:** envelope (version+landlock+seccomp) via `--policy`/`ORING_POLICY`;
+ profiles `balanced` and `strict`; `--workdir`; `--proc`; `--exec-tmp`; full
+ validation including the socket-in-tier lint.
+- **UX:** `--explain`, `--check`, `--denials`, `-v`/`ORING_VERBOSE`, errno→cause
+ table; exit codes 120/121/122 (outside the shell/docker band); machine-parseable
+ REFUSE + SEALED stderr tokens.
+- **Build:** static musl x86_64 + `.sha256` + build.rs SHA; seal line; embedded
+ auditable SBOM.
+
+### Verified — how each guarantee is held (§11)
+
+- **Proven** (Kani, `make verify`): `x32_guard_total`, `abi_from_version_clamp`.
+- **Gated** (z3 SMT over the emitted cBPF): `tests/seccomp_smt.rs` —
+ truncation-invariance for both profiles.
+- **Propertied** (proptest): the policy layer.
+- **Witnessed** on a real Landlock kernel: the containment matrix and the
+ self-exec probe suites, `ORING_REQUIRE_KERNEL=1` so a skip is a hard failure.
+ Last full run, re-derived 2026-07-29: **140 tests / 24 integration binaries / 27
+ suites green** with `ORING_REQUIRE_KERNEL=1`, on kernel 7.2.0-rc4 / ABI V10, plus 3
+ SMT proofs under `bpf-dump` (z3 5.0.0). **This line read "81 tests / 18 binaries green
+ on kernel 7.1.4 / ABI V9" until then** — a claim that says *last* and was not, stale by
+ 59 tests and a kernel minor. Two caveats it needs: the host is **at or above** the floor,
+ so `require_or_skip` skips nothing here and the total is identical without
+ `ORING_REQUIRE_KERNEL=1` — the variable's value shows up on a sub-floor host, not this
+ one; and **6 of the 140 are document gates** that exercise none of oring's behaviour.
+- **Catalogued:** every blocked attack maps to a runnable test in
+ `docs/ATTACKS.md`; residuals are enumerated in §2 rather than discovered.
+
+### Known gaps in the current implementation
+
+Stated here because §2 residuals are about what oring *cannot* close, while these
+are things it has not closed *yet*:
+
+- Peer `/proc//{maps,mem}` have no containment-matrix cell. They share the
+ `ptrace_may_access` gate with `environ` (which IS walled and witnessed — by
+ `hook_ptrace_access_check`, which consults no handled right, so **not** by what
+ the V9 floor buys; the rule predates 5.13; what decides it (domain ancestry-or-equality, and privilege via CAP_PERFMON) is in the ATTACKS row-12 note),
+ and are expected to behave the same — but that is an expectation,
+ not a proof, and the claim in §2 is deliberately not widened to cover them.
+- A kernel whose ABI exceeds the linked crate's ceiling leaves rights oring cannot
+ name unhandled; oring warns (abi-ahead) and runs. Whether that should instead be
+ a refusal is an open consistency question against D-4b, not a settled design.
+- **Coverage is disclosed in two halves, because one predicate cannot express
+ it.** `abi > crate ceiling` detects rights the crate cannot NAME; it says nothing
+ about rights the crate names and oring does not HANDLE. Those coincided until the
+ crate learned V10 with no UDP field in the schema — at which point raising the
+ ceiling switched the abi-ahead warning off **without restricting one extra byte**
+ (measured 2026-07-27, one constant changed). So the named half (`unhandled=`,
+ plus a WARN on a partially-restricted class) is a set difference against the
+ crate's own `from_all(abi)`, never a hand-maintained table. Neither half is
+ complete alone: the derived half is bounded above by the crate ceiling, which is
+ exactly where the abi-ahead half starts.
+- **The `socket()` wall now keys on family, type AND protocol.** `type` selects the
+ transport independently of `protocol` — `inet_create` dispatches on `inetsw[sock->type]`,
+ and with `protocol == 0` the first registrant for that type wins — so a rule reading only
+ arg 0 and arg 2 was **stating a guarantee it did not have**. Measured: `socket(AF_INET,
+ SOCK_SEQPACKET, 0)` reached SCTP's slot, and `socketpair(AF_INET, SOCK_SEQPACKET,
+ IPPROTO_SCTP)` ran `sock_create()` — including `request_module("net-pf-2-proto-132")` —
+ because `__sys_socketpair` creates before testing whether the family implements
+ `->socketpair`, so a sealed child could make modprobe (a usermode helper OUTSIDE the
+ sandbox) load `sctp.ko` on its behalf. Both are closed. The type enumeration is
+ **complete, not sampled**: `net/socket.c:1742` rejects any bit outside `SOCK_TYPE_MASK |
+ SOCK_CLOEXEC | SOCK_NONBLOCK` with EINVAL before dispatch. `AF_INET SOCK_PACKET` is a separate case and was described wrongly here until
+ 2026-07 [corrections: C-20].
+- **Tier overlap is refused, because the tier lattice is not closed under Landlock's
+ union.** Landlock has no deny rule and no unmount: rules on an ancestor and a descendant
+ are OR'd and the wider grant wins, so `read_exec ∪ write_noexec = write_exec` — a tier
+ **neither declaration names**. `--workdir /tmp` under `balanced` (which has `/tmp` in
+ `write_noexec`) therefore upgraded all of `/tmp` to write+exec and a script written there
+ executed, while `grants=` reported full success. `Policy::check_tier_overlap` computes
+ each path's EFFECTIVE tier as the union over every declaration covering it and requires
+ it to equal the declared tier, except that `read_only` may effectively be `read_exec`
+ (inert: nothing writable gains exec). oring cannot make the restrictive rule win, so it errors; upstream discussed the
+ same "permission additivity" problem in 2024 (`docs/CITATIONS.md`). Residual: paths are compared after canonicalization, so symlinks are seen but
+ **bind mounts and hard links are not**.
+- **`$TMPDIR` is no longer read from the environment.** It used to be pushed into
+ `write_noexec`, letting a caller-supplied variable widen a built-in profile — and the
+ `$HOME` guard added to catch that tests `home.starts_with(tier)`, so narrowing by one
+ component walked past it (`TMPDIR=$HOME/.ssh` yielded `O_WRONLY` on `authorized_keys`).
+ No predicate on the path fixes it: `~/.ssh` is an existing, caller-owned, 0700 directory,
+ exactly the shape a good scratch dir has. The scratch path now comes from `--tmpdir` /
+ `--exec-tmp`, and oring SETS the child's `TMPDIR` to what it actually granted — or unsets
+ it when it granted none, rather than naming a directory the child cannot write.
+
+- **Matrix attribution.** A `Deny` cell only attributes its denial to oring if
+ the same operation is permitted unsealed; where DAC or a host MAC already
+ forbids it, the cell passes on the other layer's authority. **Computed on every
+ run since 2026-08-02** — every denial cell runs bare first and an
+ indistinguishable pair is reported `UNATTRIBUTABLE`, not `PASS`. 21 of 21 are
+ attributed. What is still not established is *which* layer produced an
+ attributed denial; see `tests/containment_matrix.rs` and `VERIFICATION.md`.
+
+ **The 07-24 exception (`shmat-exec`) was never real** — that row has asserted
+ `EPERM` since the initial release while the note recorded `EACCES` bare, which
+ is distinguishable. The first write-up of the 07-28 re-run reported this as an
+ exception that had *moved over time*, and got the cell count and the
+ succeed-bare count wrong as well. All three are corrected in `VERIFICATION.md`,
+ which carries the reasoning; the per-cell table is in
+ `deep_research/notes/2026-07-28-t21-control-pass-vetted.md`.
### Explicitly NOT oring, ever
cgroups/rlimits; MAC authoring; user-supplied seccomp JSON/BPF from untrusted input; seccomp-notify supervisor; egress firewall; credential broker; procfs masking; OCI-spec validation; any fork/supervise loop.
@@ -533,34 +366,5 @@ cgroups/rlimits; MAC authoring; user-supplied seccomp JSON/BPF from untrusted in
## 14. Decision Log
-| # | Decision | Chosen | Rationale |
-|---|----------|--------|-----------|
-| D-1 | Profile names / count | **One profile `balanced`** + `--policy` escape hatch; flag kept, only value | Only wired consumer is `--profile balanced`; strict/dev drag in the fragile full ioctl wall + a `dev` root footgun; dissolves OPEN #1/#9, collapses #5/#8 [10] |
-| D-2 | Policy format | **JSON only** (env + file) | One serde front-end; TOML later if standalone authoring demands [OPEN #2] |
-| D-3 | `PartiallyEnforced` gate | **Fatal, but demoted to tripwire**; enforcement guaranteed by FS ABI-masking; actionable messages | Under FS masking `FullyEnforced` is tautological, so the gate never fires on real downgrade; its real jobs are NotEnforced + no_new_privs==false [38, OPEN #3] |
-| D-4 | Old-kernel floor | **Hardcode floor at V7/6.15, refuse below; remove `--abi-floor` + all sub-floor degrade** | Every deployment target runs ≥6.15, so the full FS/net/scope/audit set is guaranteed at the floor; a degrade path is a whole second untested posture — refusing below the floor is the fail-closed answer (supersedes the earlier V3/6.2 floor) |
-| D-5 | Seccomp v1 content | **Trimmed `balanced`** + small file-ioctl defense-in-depth set; block-device wall + btrfs 0x94 → strict/dropped | Block-device/dm ioctls dead under cap-drop=ALL; btrfs 0x94's only unprivileged member is benign FICLONE and its carve-out is inexpressible in cumulative filters [19, 29, 35, OPEN #5] |
-| D-6 | Errno mixing | **N cumulative sub-filters, one per distinct errno** (v1 = 4) | balanced needs EPERM+ENOSYS+EAFNOSUPPORT+EOPNOTSUPP — one per distinct denial semantics; the ENOTTY file-ioctl set is deferred to `strict`/v2 [30, OPEN #6] |
-| D-7 | `SANDBOX_POLICY` compat | **Excluded from v1** | No consumer needs it; a second flat shape widens the sole parser surface; dissolves the deny_unknown_fields drift risk [25, 32] |
-| D-8 | `--workdir` tier | **`write_exec`** for balanced; in-tree W^X residual documented | balanced's real consumer is a build agent that compiles+runs in-tree [10d, OPEN #8] |
-| D-9 | x32 ABI | **Mandatory 7-instruction cBPF KILL guard prepend** | Arch guard closes only i386; x32 shares AUDIT_ARCH_X86_64 and reopens every denied syscall via nr\|0x40000000 — the guard KILLs it [1, 7, 21] |
-| D-10 | MSG_OOB | **Blocked in v1** (send/recv flags + SIOCATMARK → EOPNOTSUPP) | CVE-2025-38236 AF_UNIX UAF is filterable via MaskedEq already committed; deferring it was a strict regression vs the ported reference [2] |
-| D-11 | FD scrub | **`close_range(3,MAX,CLOSE_RANGE_CLOEXEC)`** | The `/proc/self/fd` walk needs a `/proc` Landlock grant the lean profiles lack → bricks every run; granting /proc reintroduces peer-procfs residual [3] |
-| D-12 | clone/unshare NEW* | **7 per-flag MaskedEq rules each** | A single combined mask matches only all-bits-set; `unshare(CLONE_NEWUSER)` alone (userns LPE) would slip through [15] |
-| D-13 | personality | **Cut from `balanced`; blocked in `strict`** | in balanced mprotect stays open so an RIE-block buys nothing; in strict (true W^X — mprotect/pkey_mprotect(PROT_EXEC) filtered) personality(RIE) is a real escape and is blocked [20] |
-| D-14 | Landlock net ABI | **HardRequirement; no silent masking of net** (TCP V4 always present at the V7 floor) | Masking would launder a dropped port-restriction into FullyEnforced; empty-vec deny-all must not become allow-all [5, 8, 9] |
-| D-15 | Landlock scope ABI | **Best-effort, warn-if- ORING_POLICY > --profile** | SANDBOX_POLICY + ORING_PROFILE have no consumer; fewer parser shapes/branches [25] |
-| D-19 | deny_extra/allow_extra | **Cut from v1** | No consumer; would drag a syscall-name→number arch table onto the config-parse path [22] |
-| D-20 | Fuzz + parser trust label | **Demote cargo-fuzz to later; relabel policy.rs "config edge"** | Policy is parent-supplied, pre-attacker; nested wrapping only tightens; panic=abort fails closed [24] |
-| D-21 | Exit codes | **120/121/122** (out of 125-127 + 128+N bands) + REFUSE/SEALED stderr tokens | 126/127 collide with docker + the child `sh -c`; a fail-closed security event must be distinguishable [28, 36] |
-| D-27 | Rule-table snapshot | **Snapshot logical `&[SyscallRule]`, not sock_filter[]** + one BPF canary | Raw-BPF churns on seccompiler bumps → blind-accept; the logical table is the reviewable artifact [34] |
-| D-28 | Self-verification | **v1 seal line (status report) + `--check` predictor** | Core is already fail-closed; the gap is a positive audit trail + preflight, not fail-open detection [37, 40] |
-| D-29 | SOFT pipeline steps | **Eliminated; all steps HARD** | Both former soft steps are effectively infallible; uniform fail-closed removes a self-contradictory contract [39] |
-| D-30 | Peer procfs / AF_UNIX socket | **procfs = documented residual + companion controls; abstract AF_UNIX walled by scope; pathname AF_UNIX walled only with `--features resolve-unix` (V9)** | VFS-read of /proc/pid/{mem,environ,maps} unmediated (runtime PID-ns/maskedPaths are the real controls); abstract by scope (`EPERM`) at the V7 floor; pathname by `RESOLVE_UNIX` (V9) opt-in, else a loudly-reported residual + `warn_on_sockets` [4, 6] |
-| D-31 | Credential exfil | **Documented required companion control (nftables + broker); do NOT auto-refuse on secret-env** | oring can't do domain/IP egress or hide a key from its holder; refusing on env-heuristics is brittle [OPEN #10] |
-| D-32 | Seccomp drift | **Non-blocking CI drift-alarm vs a divergence-allowlist; reject superset gate / codegen** | blocking every unneeded syscall is not a goal (deliberate scalpel: escape primitives only); codegen breaks hermetic static build [31] |
-
----
\ No newline at end of file
+**Moved to [`RATIONALE.md`](RATIONALE.md) on 2026-08-01.** The numbering is
+unchanged and every `D-n` citation still resolves.
diff --git a/docs/INTERNALS.md b/docs/INTERNALS.md
new file mode 100644
index 0000000..b2a62dd
--- /dev/null
+++ b/docs/INTERNALS.md
@@ -0,0 +1,223 @@
+
+# INTERNALS — how the two rings are built, and the order they are applied in
+
+**This is `DESIGN.md` §4, §5 and §6.** The section numbers are unchanged and every
+`DESIGN §4` / `§5` / `§6` citation still resolves; `DESIGN.md` carries the index.
+
+For the reader changing oring's confinement code. **§6 is the one to read first
+and the one not to change casually: the seal order is load-bearing, and a wrong
+order is a confinement bypass rather than a style nit.**
+
+- **§4 — Landlock (ring 1).** FS tiers derived from the kernel right vocabulary,
+ the hard ABI floor, per-direction net, IPC scope.
+- **§5 — seccomp (ring 2).** The denylist, the mandatory x32 guard, and the
+ argument masks — including the axes ring 1 cannot see.
+- **§6 — the pre-exec sequence.** The definitive order, uniformly fail-closed.
+
+What each ring does *not* stop is [`RESIDUALS.md`](RESIDUALS.md); why the shape
+was chosen is `DESIGN.md` §14.
+
+## 4. Landlock design (ring 1)
+
+Single ruleset, single `restrict_self`.
+
+### FS access tiers — DERIVED from the kernel right vocabulary, not enumerated
+
+| Tier | dir set | use |
+|------|---------|-----|
+| `read_only` | `ReadFile\|ReadDir` (+`Refer`) | config, read-only mounts |
+| `read_exec` | `read_only\|Execute` (+`Refer`) | `/usr`,`/lib`,`/usr/lib`,`/usr/libexec`, interpreters |
+| `write_noexec` | `read_only\|WriteFile\|MakeReg\|MakeDir\|RemoveFile\|RemoveDir\|MakeSock\|MakeFifo\|MakeSym\|Truncate\|IoctlDev` (+`Refer`) | `/tmp`, scratch — W^X |
+| `write_exec` | `write_noexec\|Execute` | the workdir |
+
+**FS decisions:**
+- **Build unions explicitly; do NOT use `AccessFs::from_write(abi)`** — that helper includes `MakeChar\|MakeBlock`. Device-node rights appear in no tier; combined with `handle_access(AccessFs::from_all(abi))` for FS they are handled-but-never-granted → globally denied (defense-in-depth with seccomp `mknod`/`mknodat`→ERRNO).
+- **Dir-vs-file split** (`RightScope` in `landlock.rs`): the kernel rejects dir-only rights (`ReadDir`,`Make*`,`Remove*`,`Refer`) on a non-dir FD (`EINVAL`). Each `AccessFs` right is tagged `Both` or `DirOnly`; a file mask keeps only `Both` rights. Route each path by `metadata().is_dir()` (follows symlinks, matching `PathFd`). Missing paths → dir bucket.
+- **Manual `IgnoreIfMissing`**: `PathFd::new` → `Err` on ENOENT → skip; any other error (EACCES) → **fail closed**. Because a silently-skipped path yields a downstream EACCES that looks like an oring bug, emit a startup diagnostic listing every skipped path (§8, §11).
+- **Refer** in dir sets only. Safe on `read_only`: it is only the inert endpoint of a rename; unlinking the source still needs `RemoveFile`/`RemoveDir`. No write leaks. **That argument assumes Landlock erratum 3.** Without it, rename/link across *disconnected* directories (a directory moved outside the scope of a bind mount) can widen access rights past `LANDLOCK_ACCESS_FS_REFER` — the kernel documents it as potential privilege escalation. Erratum 3 is declared at **ABI 1**, so it is orthogonal to the ABI integer: a kernel can report V9 or V10 and not carry it, which is exactly why the errata bitmask exists. oring therefore probes it, reports it on the seal line (`errata=`), and WARNs per missing floor-level erratum (§12). It does **not** refuse — every such erratum predates the floor and no host has been shown that meets V10 without them.
+- **List real usrmerge targets, not symlinks.** Do not rely on `/lib`→`/usr/lib`; list `/usr/lib` and `/usr/libexec` directly (node bundle / libexec live there).
+
+### FS ABI strategy — hard floor V10
+Query `ABI::new_current()`. **Hard floor V10 / kernel 7.2** (`FLOOR_ABI = 10`, `src/landlock.rs`), hardcoded, refuse below it — raised from V9 on 2026-07-29 so the UDP net rights are guaranteed rather than disclosed as unhandled. V10 is the coherent-cage minimum: FS core + `Refer`/`Truncate` (V1–V3), TCP net (V4), `IoctlDev` (V5), IPC scope (V6), audit logging (V7), `RESOLVE_UNIX` (V9) and the **UDP net rights** (V10) are all *guaranteed* there, so there are **no degraded/backcompat branches** — running a partial cage on an old kernel is worse than refusing (D-4). Below V10 oring exits with one clear message; there is no `--allow-net-degrade`, no scope-skip-below-V6, no audit-skip-below-V7, and no run-without-the-pathname-socket-wall. Mask the FS core to detected ABI (`from_all(abi)`) so a healthy kernel returns `FullyEnforced`.
+
+The V9 clause is what moved in v0.2 (D-4b). It is a *floor* rather than opt-in hardening for a structural reason: `RESOLVE_UNIX` is the only mechanism at this layer that mediates `connect()` to a pathname AF_UNIX socket. Below V9 that hook does not exist, so the class is ungated no matter how the tiers are drawn — which made the old "opt-in feature + loud residual warning" exactly the degraded-path-with-a-warning shape D-4 rejects everywhere else. Note the ABI is a property of the **running kernel**: a V9-capable crate on a 6.15 kernel still detects V7 and refuses.
+
+**`RESOLVE_UNIX` (V9 / kernel 7.1)** walls **pathname AF_UNIX socket resolution** (docker.sock / ssh-agent) — per the UAPI header, that is `connect(2)` **and `sendmsg(2)` with an explicit recipient address**, so the wall is wider than the `connect()`-only framing used elsewhere in this document. It is **at the floor**, not beyond it: handled and granted in **no** tier, so an out-of-domain pathname resolution returns `EACCES` on every kernel oring agrees to run on. `abi_from_version` clamps to the crate ceiling; `abi_from_version_clamp` (Kani, `make verify`) proves that clamp never over-claims. **The ceiling number is deliberately not restated here** — it lives once, as `CEILING` in `src/landlock.rs`, with `CRATE_MAX_ABI` derived from it. This sentence said "(V9)" until 2026-07-27, months after the ceiling moved to V10, which is exactly the defect restating it creates.
+
+Two properties of this right are easy to get wrong, and both are stated in `include/uapi/linux/landlock.h`. First, it is **domain-scoped despite being an `AccessFs` right**: it applies only to sockets created *outside* the new Landlock domain, and "newly created UNIX servers within the same Landlock domain continue to be accessible." So handling it and granting it in no tier costs no in-sandbox IPC — a confined child can still create and connect to its own sockets. Second, the kernel implements it via a **dedicated LSM hook** (`unix_find`, called from `unix_find_bsd()`), which is *why* no arrangement of FS tiers could substitute below V9: there was no hook on that path at all, not merely no right.
+
+*Superseded (v0.1):* this right was previously additive hardening — active only when built `--features resolve-unix` on a ≥V9 kernel, with pathname-socket connect a §2 residual otherwise, reported at seal and by the `warn_on_sockets` lint. A hard V9 floor was rejected then on the grounds that it would make oring refuse on nearly every machine and force an out-of-tree crate. Both consequences were real and are now accepted deliberately (D-4b, and `§0` of the maintainer's roadmap — a handle to an unpublished file, on the same footing as the `BUILD-QUEUE.md` T-numbers, and **not** a section of this document): the rejected reasoning weighed reach against containment, and reach is not the goal. What actually settled it is that the "fail-safe degradation" argument was wrong in one specific way — a missing *restriction* is exactly the thing the child benefits from, and no FS tier could substitute, because Landlock has no hook on that connect below V9.
+
+### Net — per-direction, ABI-gated, no silent downgrade
+rust-landlock chooses per-direction via `handle_access` (go-landlock's connect+bind coupling trap does not apply — one ruleset).
+- `egress_tcp`/`ingress_tcp` each modeled `Option>`. `None` ⇒ don't handle that direction (unrestricted). `Some(ports)` ⇒ handle + one `NetPort::new(port, access)` per port. `Some(vec![])` ⇒ handled, zero rules ⇒ **explicit deny-all**.
+- **Do NOT mask net to detected ABI.** For any requested net restriction (connect or bind, including the empty-vec deny-all), set `CompatLevel::HardRequirement` before `handle_access` — never let deny-all silently become allow-all, never launder a dropped net ring into `FullyEnforced`. TCP net is V4 and always present at the V10 floor, so this never degrades; there is no opt-in escape.
+- **Landlock net gates only the `connect(2)`/`bind(2)` hook**, and only by *port* — not IP, not protocol. Two consequences the operator must know: an allowed port reaches *any* IP (port-only, §2), and `sendto(…, MSG_FASTOPEN, …)` performs an implicit connect inside the send path that never reaches `security_socket_connect`. The TCP port wall therefore holds only because **ring 2** blocks `MSG_FASTOPEN` (§5); Landlock alone does not close it. Domain/IP egress is a required host companion control (§9).
+
+### Scope (IPC)
+`scope(Scope::from_all(abi))` ⇒ `AbstractUnixSocket|Signal`, always present at the floor (scope is V6). An out-of-domain abstract-socket connect → `EPERM`, and signalling peers outside the domain is blocked. In the container path this containment also comes from netns/pidns; oring's scope ring is defense-in-depth there.
+
+### V7 audit logging — wire it
+After `create()` and before `restrict_self()`, best-effort (gated ABI≥V7): `log_new_exec(true)` (LOG_NEW_EXEC_ON — log the confined child's denials) + `log_same_exec(false)` (suppress oring's own pre-exec noise). Single call covers FS+net+scope (one ruleset). Records surface to the HOST auditd/dmesg only — the confined uid-1000 child cannot and should not read them. Zero cost where the host has no audit path.
+
+### Status gate — reframed (§14 D-3)
+Enforcement is guaranteed by **ABI-masking of the FS core** (`FullyEnforced` by construction on any non-broken V3+ kernel), not by the status check. The status gate is a **tripwire**, not the security-meaningful control:
+- `NotEnforced` → **fatal**, actionable: "Landlock present but NotEnforced; kernel likely booted with `lsm=` disabling it or `CONFIG_SECURITY_LANDLOCK` off — refusing."
+- `PartiallyEnforced` → **fatal** tripwire for a broken rust-landlock/kernel invariant (unreachable under FS-only masking); message prints detected ABI + the handled-access set oring itself built.
+- `no_new_privs == false` → **fatal** (internal bug — oring set it in step 4).
+
+Additionally emit a **per-ring effective report** (fs=enforced, net=enforced|refused, scope=on|dropped(needs V6, host Vn)) so oring never prints green when a requested ring was dropped.
+
+### Rust sketch
+```rust
+let abi = ABI::new_current();
+if abi < ABI::V10 { refuse(120, "Landlock ABI < V10 (need kernel 7.2+); detected {abi:?}"); }
+
+// FS core: masked to detected ABI (>= V10 by the floor above).
+let mut rs = Ruleset::default()
+ .set_compatibility(CompatLevel::BestEffort)
+ .handle_access(AccessFs::from_all(abi))?;
+
+// Net: HARD requirement — TCP (V4) is always present at the floor.
+if policy.net_requested() {
+ rs = rs.set_compatibility(CompatLevel::HardRequirement);
+ if let Some(_) = policy.egress_tcp { rs = rs.handle_access(AccessNet::ConnectTcp)?; }
+ if let Some(_) = policy.ingress_tcp { rs = rs.handle_access(AccessNet::BindTcp)?; }
+ rs = rs.set_compatibility(CompatLevel::BestEffort);
+}
+if policy.scope_ipc { rs = rs.scope(Scope::from_all(abi))?; } // scope (V6) present at floor
+
+let created = rs.create()?;
+created.set_log_new_exec(true).set_log_same_exec(false); // best-effort V7
+// ... add_rules(fs tiers, net ports) ...
+let status = created.restrict_self()?;
+gate(status); // tripwire + per-ring report
+```
+
+---
+
+## 5. Seccomp design (ring 2)
+
+**Denylist, decisively** — `default = Allow` with targeted ERRNO blocks. oring wraps arbitrary dev toolchains (cargo/npm/pip/go/node/git/gcc/clang/python); an allowlist has to track the moving union of every runtime's syscalls — one miss = `SIGSYS` mid-build. This is why Docker's own default is a denylist. Job: delete the well-known LPE launchpads. A denylist can't stop a 0-day in an *allowed* syscall — Landlock is the containment backstop.
+
+**Errno, never blanket-kill** (per-syscall action, chosen by what the denial should look like to the caller):
+- `ENOSYS(38)` where a userspace fallback exists: `io_uring*`, `userfaultfd`, `clone3`, `splice`/`tee`/`vmsplice`, `memfd_create`, `execveat`, `pidfd_open`, `keyctl`/`add_key`/`request_key`.
+- `EPERM(1)` where there's no fallback: `mount`/`ptrace`/`bpf`/`kexec`/`reboot`/etc.
+- `EAFNOSUPPORT(97)` for the `socket` family filter.
+- `EOPNOTSUPP(95)` for the MSG_OOB / SIOCATMARK filters.
+- `ENOTTY(25)` for the file-ioctl defense-in-depth set (deferred to `strict`/v2 — not in shipped `balanced`).
+
+`KillProcess` reserved for a future `paranoid` profile (turns capability probes into crashed builds). `Notify`/user-notif out of scope (reintroduces a supervisor).
+
+### x32 ABI guard — MANDATORY v1 prepend (fixes the central false claim)
+seccompiler's auto arch-prologue compares only `seccomp_data.arch == AUDIT_ARCH_X86_64`. It kills i386/int-0x80 (arch differs) but **does NOT close x32**: x32 runs in long mode with arch == AUDIT_ARCH_X86_64, distinguished by `__X32_SYSCALL_BIT (0x40000000)` OR'd into `seccomp_data.nr`. An x32-numbered `ptrace`/`bpf`/`io_uring`/`mount`/`unshare` passes the arch guard, matches no number-keyed deny rule, and falls through to `default=Allow` — reopening every blocked launchpad on any `CONFIG_X86_X32_ABI=y` kernel (stock Ubuntu/Debian generic). seccompiler's exact-`u32`-nr rule model cannot express a masked-bit test.
+
+**Fix:** install a hand-assembled 7-instruction cBPF guard as a **separate `SET_MODE_FILTER` call installed FIRST** (filters stack; a KILL in any filter wins):
+```
+LD seccomp_data.arch
+JEQ AUDIT_ARCH_X86_64 -> next ; else RET KILL_PROCESS (kills i386 + foreign arch)
+LD seccomp_data.nr
+JSET 0x40000000 -> RET KILL_PROCESS (any x32-bit call)
+RET ALLOW ; defer to the real denylist
+```
+Whole-ABI kill is safe (a cap-drop=ALL uid-1000 dev toolchain never issues x32). This is a small audited splice, not a hand-rolled filter — the insta rule-snapshot (§11) makes it reviewable. The x32 re-entry (`nr | 0x40000000`) shares AUDIT_ARCH_X86_64, so a number-keyed deny is bypassable without it; the guard KILLs foreign/compat arches outright.
+
+### v1 `balanced` block set
+The denylist is the known kernel escape surface, selected from first principles: each entry is an LPE launchpad, a kernel-attack-surface syscall, a named-CVE mitigation, or device-node creation — a functional set a `cap-drop=ALL`+NNP uid-1000 dev toolchain never legitimately needs. Grouped by errno (§ mechanism below); every row carries its rationale. This table is the representative core; the authoritative, exhaustive set is the compiled-in `&[SyscallRule]` in `src/seccomp.rs` (snapshot-tested, §11) — read it, not this table, when reasoning about coverage.
+
+| Syscall(s) | Match | Action | Rationale |
+|---|---|---|---|
+| `io_uring_setup/enter/register` | all | ENOSYS | async-IO LPE surface; runtimes fall back |
+| `userfaultfd` | all | ENOSYS | UAF-exploit primitive |
+| `memfd_create` | all | ENOSYS | fileless-exec staging |
+| `clone3` | all | ENOSYS | struct args unreachable to cBPF → glibc/Go fall back to `clone` |
+| `execveat` | all | ENOSYS | fexecve/memfd exec vector (`execve` stays ALLOWED) |
+| `pidfd_open` | all | ENOSYS | pidfd LPE surface |
+| `splice`/`tee`/`vmsplice` | all | ENOSYS | Dirty-Pipe (CVE-2022-0847) |
+| `keyctl`/`add_key`/`request_key` | all | ENOSYS | keyring LPE |
+| `bpf` | all | EPERM | eBPF LPE surface |
+| `perf_event_open` | all | EPERM | perf LPE surface |
+| `ptrace`/`process_vm_readv`/`process_vm_writev`/`kcmp`/`process_madvise`/`pidfd_getfd` | all | EPERM | cross-process (does NOT cover /proc/pid/mem — §2) |
+| `mount`/`umount2`/`open_tree`/`move_mount`/`fsopen`/`fsconfig`/`fsmount`/`fspick`/`pivot_root`/`chroot`/`setns` | all | EPERM | mount/ns manipulation |
+| `mknod`/`mknodat` | all | EPERM | device-node creation (belt w/ Landlock) |
+| `unshare` | **8** × MaskedEq — one per `CLONE_NEW*` bit, **plus `CLONE_NEWTIME`** | EPERM | **per-flag**, see below |
+| `clone` | **7** × MaskedEq, one per `CLONE_NEW*` bit | EPERM | **per-flag**; blanket block would break threads. **No `CLONE_NEWTIME` rule** — in legacy `clone(2)` the low byte is `CSIGNAL`, so a 0x80 rule would false-match an exit signal (`src/seccomp.rs`, at the `unshare_rules` push) |
+| `kexec_load`/`kexec_file_load`/`init_module`/`finit_module`/`delete_module` | all | EPERM | kernel image/module |
+| `reboot`/`swapon`/`swapoff`/`acct`/`quotactl`/`vhangup`/`syslog` | all | EPERM | admin ops |
+| `ioperm`/`iopl`/`pciconfig_*` | all | EPERM | port IO |
+| `settimeofday`/`clock_settime`/`adjtimex`/`clock_adjtime`/`sethostname`/`setdomainname` | all | EPERM | global mutation |
+| `fanotify_init`/`fanotify_mark` | all | EPERM | fs-wide notify |
+| `move_pages`/`migrate_pages`/`modify_ldt` | all | EPERM | niche LPE surface |
+| `seccomp` | all | EPERM | self-defense |
+| `socket`/`socketpair` | `Ge`(family, 17) | EAFNOSUPPORT | block PACKET/TIPC/ALG/VSOCK; allow UNIX/INET/INET6/NETLINK (`socketpair` walks the same family gate) |
+| `send`/`sendto`/`recv`/`recvfrom` | MaskedEq(arg3 & MSG_OOB) | EOPNOTSUPP | **CVE-2025-38236 AF_UNIX MSG_OOB UAF** |
+| `sendmsg`/`recvmsg` | MaskedEq(arg2 & MSG_OOB) | EOPNOTSUPP | same — flags is arg2 for the msg variants |
+| `sendmmsg`/`recvmmsg`/`recvmmsg_time64` | MaskedEq(arg3 & MSG_OOB) | EOPNOTSUPP | same — flags is arg3 for the mmsg variants |
+| `ioctl` | Eq(arg1 == SIOCATMARK 0x8905) | EOPNOTSUPP | MSG_OOB pair |
+| `sendto`/`sendmmsg` | MaskedEq(flags & MSG_FASTOPEN) | EOPNOTSUPP | implicit-connect egress that never reaches Landlock's `connect()` hook — the only reason the TCP port wall holds (ATTACKS §3a) |
+| `open_by_handle_at`/`name_to_handle_at` | all | EPERM | fd-from-handle reopen that sidesteps path-based Landlock |
+| `ioctl` | Eq(arg1 ∈ {TIOCSTI, TIOCLINUX, TIOCSETD}) | EPERM | TTY injection |
+| `ioctl` | Eq(arg1 ∈ file-ioctl set below) | ENOTTY | file-fd defense-in-depth — **deferred to `strict`/v2**, not in shipped `balanced` |
+| `mmap`/`mmap2` | MaskedEq(arg3 & MAP_GROWSDOWN) | EPERM | StackRot (CVE-2023-3269) |
+| `prctl` | MaskedEq(arg0 == PR_SET_DUMPABLE & arg1 > 0), (arg0 == PR_SET_PTRACER) | EPERM | re-dumpable / ptracer |
+
+**File-ioctl defense-in-depth set — deferred to `strict`/v2, NOT shipped in `balanced`** (exact-cmd `Eq`→ENOTTY, same mechanism as TIOCSTI; encoding a wrong ioctl number breaks benign ioctls, so it waits for the tighter profile): `FS_IOC_ENABLE_VERITY` (irreversible in-place immutability), `FS_IOC_FIEMAP` (physical-layout leak), `FS_IOC_ADD_ENCRYPTION_KEY`/`FS_IOC_REMOVE_ENCRYPTION_KEY`, `XFS_IOC_FSBULKSTAT`/`XFS_IOC_BULKSTAT`/`XFS_IOC_FSGEOMETRY` (cross-tier inode metadata on ext4/xfs), `EXT4_IOC_ALLOC_DA_BLKS`. These are metadata-leak / own-tier DoS, **not** confinement escape — they tighten the already-conceded writable-tier residual. They matter mainly on a bare-metal host with real ext4/xfs; largely moot on a container tmpfs/overlay. Tools fall back cleanly on ENOTTY (verified: `cp`/`rsync` do).
+
+**Deliberately NOT in v1:**
+- `personality` — **cut from `balanced`.** With `mprotect` unrestricted (mandatory for JIT/GC runtimes), blocking `READ_IMPLIES_EXEC` buys no W^X margin an attacker with code-exec can't already get via `mprotect(PROT_EXEC)`, and `ADDR_NO_RANDOMIZE` self-disable is negligible post-compromise; blanket-blocking in `balanced` only risks breaking `setarch`/ASAN-re-exec/emulators. **`strict` DOES block `personality(READ_IMPLIES_EXEC)`** — there `mprotect`/`pkey_mprotect(PROT_EXEC)` are filtered for true W^X (§7), so `personality(RIE)` is a real escape route that arg-filtering must also close (`seccomp.rs`).
+- **Block-device / loop / dm ioctl wall** (`BLK*`, loop 0x4c, dm 0xfd) — dead under cap-drop=ALL + Landlock device-node denial + no block-dev nodes. → `strict` later.
+- **btrfs 0x94 magic block** — an order-dependent "ALLOW FICLONE first, then ERRNO the 0x94 magic" carve-out is **impossible** with cumulative seccomp filters (ERRNO outranks ALLOW; seccompiler binds one action per filter). So oring does NOT block 0x94 at all — though not for the reason first given [corrections: C-19]. Read against `fs/btrfs/ioctl.c` (7.2.0-rc4): **63 ioctls in the magic, 35
+ `capable(CAP_SYS_ADMIN)` checks in the whole file** — `SNAP_CREATE` gates on
+ `inode_owner_or_capable()`, and `SNAP_DESTROY` with `user_subvol_rm_allowed`
+ deletes a non-empty subvolume `rmdir(2)` would refuse. **Why the conclusion holds:** every one of these acts on a path the child already holds an fd to, and Landlock gates the `open()`, so the reach is bounded by the granted tiers — not by the capability set. Note the bound is the *tier*, not Landlock ioctl mediation: `LANDLOCK_ACCESS_FS_IOCTL_DEV` covers **device** files only, so ioctls on regular files and directories are unmediated at every ABI. **Not measured — this box has no btrfs mounted**, so it is a source read of the running kernel's tree, and `user_subvol_rm_allowed` is not a default. Whether `strict` should wall the magic outright is a real trade (it would break `FICLONE` for `cp`/`rsync`) and is filed as a decision, not assumed here. **Invariant: oring only ever uses whole-syscall or whole-magic blocks with no benign-exception; any in-mask carve-out is a libseccomp-only capability oring does not carry.**
+
+### `clone`/`unshare` — per-flag, not one combined mask
+cBPF/seccompiler cannot do "any bit in mask set" in one compare (no masked-not-equal op). A single `MaskedEq(mask=OR-of-all-NEW*, value=OR-of-all)` matches only when ALL bits are set — trivially bypassed by `unshare(CLONE_NEWUSER)` alone (the userns-LPE launchpad). Emit one rule per bit, each `MaskedEq(mask==value==single bit)`: NEWNS 0x20000, NEWCGROUP 0x2000000, NEWUTS 0x4000000, NEWIPC 0x8000000, NEWUSER 0x10000000, NEWPID 0x20000000, NEWNET 0x40000000 — **7 on `clone`, 8 on `unshare`**, the extra being `CLONE_NEWTIME` (0x80), which `clone` must not carry because its low byte is `CSIGNAL` and a 0x80 rule there would false-match an exit signal [corrections: C-4]. The security-load-bearing bit is `CLONE_NEWUSER` (the unprivileged-userns LPE launchpad); the other six namespace flags are cheap defence-in-depth on the same syscalls.
+
+### Mechanism, action, arch
+- **seccompiler 0.5.x** (Firecracker, pure Rust — `libc` bindings + optional serde, no `-sys`, no C link). Only option that keeps the musl-static entrypoint AND gives `SeccompCmpOp::MaskedEq`. Reject libseccomp-rs (links `libseccomp.so`). Reject hand-rolled cBPF except the audited x32 guard splice.
+- **Per-syscall errno mixing = N cumulative sub-filters, one per DISTINCT errno.** Not "two." Partition the deny set so each blocked syscall matches in exactly one sub-filter (`mismatch_action=Allow`, `match_action=Errno(e)`). v1 `balanced` = **4 sub-filters** (EPERM, ENOSYS, EAFNOSUPPORT, EOPNOTSUPP) + the x32 KILL guard installed first (the file-ioctl `ENOTTY` set is deferred to `strict`/v2, above). Filters stack; ERRNO outranks ALLOW; no ties.
+
+ **Cost, QUANTIFIED 2026-07-30 — and the sentence this replaces pointed at the wrong term.** It read: "Cost is an N-filter stack of linear partition scans + N−1 arch prologues, not a blanket 'double' — quantify against actual partition sizes." The mechanics were right and the magnitude was not, in the direction that matters. Measured with `bench/run.sh` on 7.2.0-rc4 (glibc host build, pinned, 3 rounds):
+
+ - **At runtime the partition costs nothing measurable.** A filter-count sweep (`bench filters k`, k=0..4, everything else held) steps once at k=1 — +4.4 ns/syscall, the cost of *entering* seccomp at all — and filters 2-4 add ~0.5 ns between them, inside the round-to-round spread. This holds even though an allowed syscall walks ~215 cBPF instructions across the stack (`bench sim`: 809 instructions total, mean 207 executed, max 265), because the kernel JITs each program and the scan is not where the time goes. So the linear scan is real, and it is not a cost.
+ - **At INSTALL it is the dominant term, and the old sentence did not name it.** Four `seccomp(SET_MODE_FILTER)` calls cost **227 µs** — 72% of the ~317 µs the seal itself takes and 36% of the ~631 µs oring adds to an invocation. Cost tracks program length after a one-time ~40 µs on the first install (`enosys` 100 insns / 61 µs, `eopnotsupp` 105 / 22, `eperm` 457 / 120).
+ - **Consequence for the design, not just the docs:** adding a fifth errno group is ~free for the child and costs the operator another ~25-120 µs per invocation depending on its size. And because filters are inherited across `fork`/`execve` (measured: fork+exec inside the cage is within 1% of outside), that cost is paid **once per oring invocation**, never per process in the child's tree — so the lever on it is amortisation, not partition shape.
+
+ The errno partition is a property of each syscall's denial semantics (pretend-absent → ENOSYS, denied → EPERM/EACCES, unsupported family → EAFNOSUPPORT), so the filter count follows the profile automatically.
+- **Arch: x86_64 only + kill-on-mismatch + the x32 guard.** aarch64 is a compile-time target only, added when a native arm64 Linux host becomes a security target (landlock 0.4.5 + seccompiler 0.5 both support it).
+- **Representation:** blocklist as a compiled-in Rust `&[SyscallRule]` — no JSON-at-runtime on the security-critical path, type-checked, uses `libc::SYS_*` constants (no syscall-name table).
+
+---
+
+## 6. Pre-exec sequence (definitive order — uniform fail-closed)
+
+`pipeline::seal()` runs this exact sequence; `main` then execs. **Every step is HARD (`?`)** — there is no SOFT category in v1 (both former "soft" steps are effectively infallible for a valid unprivileged caller; promoting them makes "every step fail-closed" literally true). `panic="abort"` ⇒ no partial-seal-then-exec. Note `panic="abort"` is set on the **release** profile only (§12); debug/test builds are `panic="unwind"`. The seal is structurally safe under either: `seal() -> Result`, its `execve` is the last statement, so a panic anywhere unwinds *past* (never reaching) the exec — a future dev must not add a `catch_unwind` around the seal assuming abort semantics hold in every profile.
+
+```
+ 1. parse argv split at "--"; require a target after it (else exit 2)
+ 2. load policy --policy | ORING_POLICY env | --profile balanced (none → refuse, exit 120)
+ 3. resolve target PATH lookup → absolute STRING; fstat: reject non-regular/non-exec;
+ assert target under a read_exec/write_exec rule (not found → exit 122)
+ 4. NO_NEW_PRIVS prctl(PR_SET_NO_NEW_PRIVS,1) (HARD: before seccomp)
+ 5. DUMPABLE=0 prctl(PR_SET_DUMPABLE,0) (HARD; oring-window only)
+ 6. LANDLOCK ring 1; core hard-fail (HARD: before seccomp)
+ 7. signal-mask reset sigprocmask(SIG_SETMASK, empty) (HARD)
+ 8. FD scrub close_range(3, ~0, CLOSE_RANGE_CLOEXEC)
+ 9. build argv/env strip ORING_POLICY (pure memory)
+10. SECCOMP x32 guard filter, then N errno sub-filters (LAST restriction)
+11. attest + execve emit seal line to stderr, then execve resolved path, clean env
+```
+
+**Rationale:**
+- **Resolve target (3) before Landlock (6).** Resolve to an **absolute string** first → clean `exit(122)` on missing binary, rather than a post-seal `EACCES` that looks like a malfunction. `fstat` once to reject non-regular/non-executable and enforce the read_exec-coverage check (§8), then close that fd. **No O_PATH fd is held** — TOCTOU closure would require `fexecve`/`execveat`, which oring deliberately blocks (memfd fileless-exec; cBPF can't safely arg-filter it), and the race has no attacker in either deployment (read-only rootfs in-container; single-threaded, no child, no local writer standalone).
+- **NNP (4) before seccomp (10).** Unprivileged `SET_MODE_FILTER` returns EACCES without NNP. Set it explicitly to decouple from Landlock ordering.
+- **`DUMPABLE=0` (5)** — one syscall; protects oring's own pre-exec window only (blocks same-uid `/proc/self/mem`/ptrace-attach while policy sits in memory; suppresses a policy-leaking core dump). **NOT inherited across execve** — the kernel resets the child to dumpable. Child-side ptrace/mem protection comes from the seccomp `ptrace`/`process_vm_*` denials, NOT this step (and does NOT cover peer `/proc//mem` — §2).
+- **Landlock (6) before seccomp (10)** so `landlock_*` setup syscalls aren't blocked.
+- **Signal-mask reset (7)** — execve inherits the mask unchanged; a parent with signals blocked silently hands the child a bad mask. Correctness fix a Go-managed sealer doesn't hit.
+- **Disposition normalization (11a)** — execve resets *caught* handlers but **preserves `SIG_IGN`**, a separate inheritance channel from the mask. Rust installs `SIGPIPE=SIG_IGN` at startup, so an un-normalized child sees `EPIPE` where it should die (`producer | head -1` hangs; `$PIPESTATUS` reads 0, not 141). Every ignored signal except `SIGKILL`/`SIGSTOP` is restored to `SIG_DFL` immediately before execve. Placement is after the seal-line write for ordering hygiene only — **not**, as first documented, because oring's ignored `SIGPIPE` protects that write: `eprintln!` panics on failure and `panic=abort` converts that to an abort, so a closed stderr prevents the exec under either ordering. POSIX hygiene, not a confinement control. Known regression: this also clears a *deliberately* inherited `SIG_IGN`, so `nohup oring -- cmd` no longer hands the child an ignored `SIGHUP`.
+- **FD scrub (8) = `close_range(3, RawFd::MAX, CLOSE_RANGE_CLOEXEC)`** — **not** the `/proc/self/fd` walk (that walk needs `READ_DIR` on a `/proc` rule the lean profiles don't grant → EACCES → the pipeline would brick every invocation; granting `/proc` to fix it reintroduces the peer-procfs residual §2 disclaims). `close_range` needs no path, is indifferent to Landlock ordering, preserves CLOEXEC-not-close semantics, and (critically) does NOT close the exec path since we hold no target fd. Base `close_range` is 5.9+, `CLOSE_RANGE_CLOEXEC` 5.11+ — both well below the V10/7.2 floor. **No async anywhere** (tokio would spawn the epoll/eventfd/pipe FDs this defeats). Sync-only, hard architectural constraint.
+- **Env-build (9)** — the child's env is an **allowlist** (`BASE_ENV_KEEP`, §8), built fresh, not an inheritance minus `ORING_POLICY`. The old claim that "env is not a security boundary here" was wrong in one specific way: the child being sandboxed does not stop it *reading* a credential it was handed, and a credential in the env is exactly the exfil half SECURITY.md owns. `LD_PRELOAD` into the *child* is not a bypass; `LD_*` into *oring* would be catastrophic but oring is static-musl with no dynamic linker → `LD_*` ignored (a security reason for the musl constraint).
+- **Attest + Seccomp + execve (10–11).** **Cross-ring contract: the filter MUST allow `execve`, `exit_group`, and `write`** (for the seal line), or oring bricks itself. `execve` allowed; only `execveat`→ENOSYS.
+
+---
diff --git a/docs/RATIONALE.md b/docs/RATIONALE.md
new file mode 100644
index 0000000..c15397d
--- /dev/null
+++ b/docs/RATIONALE.md
@@ -0,0 +1,49 @@
+
+# RATIONALE — the decision log
+
+**This is `DESIGN.md` §14.** The numbering is unchanged and every `D-n` citation
+still resolves; `DESIGN.md` carries the index.
+
+`D-1` … `D-32` are this project's decisions. **`D1`, `D3`, `D9` — no hyphen — are
+not**: they belong to the maintainer's decision log, which is not in this
+repository. Two namespaces separated by a hyphen is a bad design and it is named
+here rather than left for a reader to trip over.
+
+Each row records a decision *and the argument that was rejected*. That is the
+point of the file: a cut that loses its context gets re-proposed. Meet the
+argument, not the absence.
+
+## 14. Decision Log
+
+| # | Decision | Chosen | Rationale |
+|---|----------|--------|-----------|
+| D-1 | Profile names / count | **One profile `balanced`** + `--policy` escape hatch; flag kept, only value | Only wired consumer is `--profile balanced`; strict/dev drag in the fragile full ioctl wall + a `dev` root footgun; dissolves OPEN #1/#9, collapses #5/#8 [10] |
+| D-2 | Policy format | **JSON only** (env + file) | One serde front-end; TOML later if standalone authoring demands [OPEN #2] |
+| D-3 | `PartiallyEnforced` gate | **Fatal, but demoted to tripwire**; enforcement guaranteed by FS ABI-masking; actionable messages | Under FS masking `FullyEnforced` is tautological, so the gate never fires on real downgrade; its real jobs are NotEnforced + no_new_privs==false [38, OPEN #3] |
+| D-4 | Old-kernel floor | **Hardcode the floor, refuse below; remove `--abi-floor` + all sub-floor degrade** | A degrade path is a whole second untested posture — refusing below the floor is the fail-closed answer (supersedes the earlier V3/6.2 and V7/6.15 floors; the current number is D-4b) |
+| D-4b | Floor raised to **V9/7.1** (v0.2) | **Fold `RESOLVE_UNIX` into the floor; delete the `resolve-unix` opt-in posture and its RESIDUAL warning** | Landlock has **no hook on pathname-AF_UNIX `connect()` below V9**, so that class (docker.sock, ssh-agent) is ungated beneath the floor no matter how tiers are drawn — it was never closable at the FS layer. Withholding the `/tmp` grant only obscured the socket. *(The clause that used to follow — "and even that was defeated by the peer-procfs residual handing over `SSH_AUTH_SOCK` from a peer `environ` under the default `--proc full`" — is **retracted 2026-07-26**: a landlocked reader cannot read an **out-of-domain** peer's `environ` on any kernel carrying the ptrace rule, which predates 5.13, so peer procfs was never that route. *(That retraction said "non-descendant" until 2026-07-27, which is false — siblings share a domain and do read each other. The retraction's conclusion is unaffected: the `SSH_AUTH_SOCK`-holding peer is the operator's own process, outside the seal's domain, so it is denied either way.)* `SSH_AUTH_SOCK` did reach the child, by ordinary **environment inheritance** — a real path, since narrowed by the `BASE_ENV_KEEP` allowlist (§2). **D-4b's conclusion is unaffected:** obscurity-is-not-mediation stands alone and suffices.)* An opt-in wall plus a loud warning is precisely the degraded-path-with-a-warning shape D-4 rejects. **Costs, accepted knowingly:** (1) every pre-7.1 kernel is refused, including the 7.0.13/V8 test host — the ABI is a kernel property, so no userspace package closes that gap; (2) until upstream ships V9 (rust-landlock#135), `landlock` comes from an out-of-tree crate, so `cargo install oring` does not build and the clean-registry-dependency guarantee is suspended for that one dep. Both revert to a one-line version bump on upstream release, with no behaviour change. Rejected alternatives: a `--require-unix-wall` flag (default-off reproduces the degraded posture; default-on is the floor with extra steps), and dropping the `/tmp` grant (obscurity, not mediation — see above) |
+| D-5 | Seccomp v1 content | **Trimmed `balanced`** + small file-ioctl defense-in-depth set; block-device wall + btrfs 0x94 → strict/dropped | Block-device/dm ioctls dead under cap-drop=ALL; btrfs 0x94's only unprivileged member is benign FICLONE and its carve-out is inexpressible in cumulative filters [19, 29, 35, OPEN #5] |
+| D-6 | Errno mixing | **N cumulative sub-filters, one per distinct errno** (v1 = 4) | balanced needs EPERM+ENOSYS+EAFNOSUPPORT+EOPNOTSUPP — one per distinct denial semantics; the ENOTTY file-ioctl set is deferred to `strict`/v2 [30, OPEN #6] |
+| D-7 | `SANDBOX_POLICY` compat | **Excluded from v1** | No consumer needs it; a second flat shape widens the sole parser surface; dissolves the deny_unknown_fields drift risk [25, 32] |
+| D-8 | `--workdir` tier | **`write_exec`** for balanced; in-tree W^X residual documented | balanced's real consumer is a build agent that compiles+runs in-tree [10d, OPEN #8] |
+| D-9 | x32 ABI | **Mandatory 7-instruction cBPF KILL guard prepend** | Arch guard closes only i386; x32 shares AUDIT_ARCH_X86_64 and reopens every denied syscall via nr\|0x40000000 — the guard KILLs it [1, 7, 21] |
+| D-10 | MSG_OOB | **Blocked in v1** (send/recv flags + SIOCATMARK → EOPNOTSUPP) | CVE-2025-38236 AF_UNIX UAF is filterable via MaskedEq already committed; deferring it was a strict regression vs the ported reference [2] |
+| D-11 | FD scrub | **`close_range(3,MAX,CLOSE_RANGE_CLOEXEC)`** | The `/proc/self/fd` walk needs a `/proc` Landlock grant the lean profiles lack → bricks every run; granting /proc reintroduces peer-procfs residual [3] |
+| D-12 | clone/unshare NEW* | **7 per-flag MaskedEq rules each** | A single combined mask matches only all-bits-set; `unshare(CLONE_NEWUSER)` alone (userns LPE) would slip through [15] |
+| D-13 | personality | **Cut from `balanced`; blocked in `strict`** | in balanced mprotect stays open so an RIE-block buys nothing; in strict (true W^X — mprotect/pkey_mprotect(PROT_EXEC) filtered) personality(RIE) is a real escape and is blocked [20] |
+| D-14 | Landlock net ABI | **HardRequirement; no silent masking of net** (TCP V4 always present at the floor) | Masking would launder a dropped port-restriction into FullyEnforced; empty-vec deny-all must not become allow-all [5, 8, 9] |
+| D-15 | Landlock scope ABI | **Best-effort, warn-if- ORING_POLICY > --profile** | SANDBOX_POLICY + ORING_PROFILE have no consumer; fewer parser shapes/branches [25] |
+| D-19 | deny_extra/allow_extra | **Cut from v1** | No consumer; would drag a syscall-name→number arch table onto the config-parse path [22] |
+| D-20 | Fuzz + parser trust label | **Demote cargo-fuzz to later; relabel policy.rs "config edge"** | Policy is parent-supplied, pre-attacker; nested wrapping only tightens; panic=abort fails closed [24] |
+| D-21 | Exit codes | **120/121/122** (out of 125-127 + 128+N bands) + REFUSE/SEALED stderr tokens | 126/127 collide with docker + the child `sh -c`; a fail-closed security event must be distinguishable [28, 36] |
+| D-27 | Rule-table snapshot | **Snapshot logical `&[SyscallRule]`, not sock_filter[]** + one BPF canary | Raw-BPF churns on seccompiler bumps → blind-accept; the logical table is the reviewable artifact [34] |
+| D-28 | Self-verification | **v1 seal line (status report) + `--check` predictor** | Core is already fail-closed; the gap is a positive audit trail + preflight, not fail-open detection [37, 40] |
+| D-29 | SOFT pipeline steps | **Eliminated; all steps HARD** | Both former soft steps are effectively infallible; uniform fail-closed removes a self-contradictory contract [39] |
+| D-30 | Peer procfs / AF_UNIX socket | **procfs: `environ` walled by `hook_ptrace_access_check`, which consults NO handled right — so not bought by the V9 rights (corrected 2026-07-26); not bought by the floor; decided by domain ancestry-or-equality AND by privilege (CAP_PERFMON bypasses it, Linux 6.15+) — see ATTACKS row 12. Metadata still residual; abstract AF_UNIX walled by scope; pathname AF_UNIX walled by `RESOLVE_UNIX` at the V9 floor** | VFS-read of /proc/pid/environ was unmediated at V7 and returns `EACCES` at 7.1.4/V9 — SELinux ruled out as denier (bare read succeeds, `deny_ptrace` off, peer-specific), so this did NOT close with the floor (corrected 2026-07-26): the denier consults no handled right and predates 5.13 — see the ATTACKS row-12 note; `maps`/`mem` share the gate but have no cell — claim not widened. `cmdline`/`stat` stay world-readable (runtime PID-ns/maskedPaths remain the control). Abstract by scope (`EPERM`); pathname by `RESOLVE_UNIX`, floor-guaranteed rather than opt-in [4, 6, D-4b] |
+| D-31 | Credential exfil | **Documented required companion control (nftables + broker); do NOT auto-refuse on secret-env** | oring can't do domain/IP egress or hide a key from its holder; refusing on env-heuristics is brittle [OPEN #10] |
+| D-32 | Seccomp drift | **Non-blocking CI drift-alarm vs a divergence-allowlist; reject superset gate / codegen** | blocking every unneeded syscall is not a goal (deliberate scalpel: escape primitives only); codegen breaks hermetic static build [31] |
+
+---
diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md
new file mode 100644
index 0000000..c9002ca
--- /dev/null
+++ b/docs/REFERENCE.md
@@ -0,0 +1,183 @@
+
+# REFERENCE — policy schema and command-line interface
+
+**This is `DESIGN.md` §7 and §8.** The section numbers are unchanged and every
+`DESIGN §7` / `§8` citation still resolves; `DESIGN.md` carries the index.
+
+Look-up material: the JSON policy schema with every field, and every flag with
+what it does. Written to be read in fragments rather than end to end — nothing
+here argues a position, and where a field has a rationale it points at
+`DESIGN.md` §14 rather than restating it.
+
+The tier vocabulary these fields name is defined in `DESIGN.md` §4. **Landlock
+unions rights along the path walk** — access is granted if at least one rule on
+the path allows it, and there is no deny rule — so a subpath can never be carved
+out of a grant. Read every tier below that way.
+
+## 7. Policy schema + profile
+
+The policy is `version` + eight FS/net fields (`read_only`, `read_exec`, `write_noexec`, `write_exec`, `egress_tcp`, `ingress_tcp`, `egress_udp`, `ingress_udp`) + a minimal `seccomp` selector.
+
+```json
+{
+ "version": 1,
+ "landlock": {
+ "read_only": ["/etc","/usr/share"],
+ "read_exec": ["/usr/bin","/lib","/lib64","/usr/lib","/usr/libexec"],
+ "write_noexec": ["/tmp","/var/tmp"],
+ "write_exec": ["/work/repo"],
+ "egress_tcp": [443],
+ "ingress_tcp": [],
+ "egress_udp": [53],
+ "ingress_udp": [0],
+ "scope_ipc": true
+ },
+ "seccomp": { "profile": "balanced" }
+}
+```
+
+```rust
+#[derive(Deserialize)] #[serde(deny_unknown_fields)]
+struct Policy { #[serde(default="one")] version: u32,
+ #[serde(default)] landlock: Landlock,
+ #[serde(default)] seccomp: Seccomp }
+
+#[derive(Deserialize, Default)] #[serde(deny_unknown_fields)]
+struct Landlock {
+ #[serde(default)] read_only: Vec, #[serde(default)] read_exec: Vec,
+ #[serde(default)] write_noexec: Vec, #[serde(default)] write_exec: Vec,
+ #[serde(default)] egress_tcp: Option>,// None=unrestricted, Some([])=deny-all
+ #[serde(default)] ingress_tcp: Option>,
+ #[serde(default)] egress_udp: Option>, // V10+ only; REFUSED below V10
+ #[serde(default)] ingress_udp: Option>,// grant port 0 to permit autobind
+ #[serde(default="yes")] scope_ipc: bool }
+
+#[derive(Deserialize, Default)] #[serde(deny_unknown_fields)]
+struct Seccomp { #[serde(default)] profile: SeccompProfile } // v1: only Balanced
+```
+
+`deny_extra`/`allow_extra` are **cut from v1** — no v1 consumer, and validating names would drag a syscall-name→number arch table onto the (sole) config-parse path. Not in the current implementation; tracked outside this document.
+
+**Validation (parse-time, before any restriction):** `version==1`; `deny_unknown_fields` (a typo'd `read_execc` errors, never silently grants nothing); absolute paths only; port ≤65535 (serde-free); **target binary must be under some `read_exec` rule** (else specific error, not a cryptic `execve: EACCES`); **error on a missing `--workdir`** (typo-confines-to-nothing footgun); warn on missing system dirs; loud warn on `egress_tcp` containing 53 **while `egress_udp` is unset** (DNS/UDP not covered; the warn is conditional now that the field exists, since an unconditional one trains operators to ignore it), a loud warn when a non-empty `egress_udp` sits beside an `ingress_udp` lacking port 0 (kernel autobind of the source port is then denied and the egress grant is inert — invisible in `unhandled=`, because both rights ARE handled), and on any granted tier containing/ancestoring a live socket inode (`docker.sock`/`podman.sock`/containerd/`/run` — §2 escape). Every message prefixed `oring:`, stderr, non-zero exit.
+
+**Self-protection:** `Policy::check_self_protection` (run after `validate`, once every tier is final) hard-refuses when **oring's own binary** or the **policy file** resolves under any write tier — the `--policy ./oring.json --workdir .` shape, where the child could rewrite the policy governing its own next run. An unresolvable `current_exe()` WARNs rather than refuses (an unreadable `/proc/self/exe` is an environment defect, not a policy input).
+
+**Profile self-protection:** `Policy::check_profile_grants` refuses when a BUILT-IN profile would grant write over a tier containing `$HOME`. It applies only to profiles — an operator who writes `$HOME` into a `--policy` file means it. Note the guard is `home.starts_with(tier)`, i.e. it catches a tier that CONTAINS `$HOME` and not one *underneath* it; the escalation that motivated it (`TMPDIR=$HOME/.ssh`) is now closed at the source instead, by not reading `$TMPDIR` at all.
+
+**Tier overlap:** `Policy::check_tier_overlap` (run after every injection — `--workdir`, `--exec-tmp`, `--tmpdir`, `--proc`) refuses a policy whose declared tiers do not describe the cage. Landlock ORs an ancestor's rule with a descendant's and the wider grant wins, so the four-tier lattice is **not closed under that union**: `read_exec ∪ write_noexec = write_exec`, a tier neither line names. For each declared `(path, tier)` the EFFECTIVE tier is the union over every declaration covering it; gaining **write** refuses, gaining **exec while writable** refuses, gaining exec while not writable warns (inert — nothing writable gained exec). One sentence: *the effective tier must equal the declared tier, except that `read_only` may effectively be `read_exec`.* Residual: paths are compared after canonicalization, so symlinks are seen but bind mounts and hard links are not.
+
+**Scratch directories:** `$TMPDIR` is **not** read from the environment. It used to be pushed into `write_noexec`, which let a caller-supplied variable widen a built-in profile's tiers — and in this threat model the caller is the adversary. The path now comes from `--tmpdir` (write_noexec) or `--exec-tmp` (write_exec), both validated by `lstat` (no symlink, must be a directory, no group/other write), and oring SETS the child's `TMPDIR` to whatever it granted — or unsets it when it granted none, rather than naming a directory the child cannot write.
+
+**Deny-by-default — three states:** no policy resolves → refuse (exit 120); policy grants nothing → refuse with the target-not-covered message; policy with grants → apply. Landlock < V10 floor → refuse (§4); seccomp load failure → refuse.
+
+### Profile: `balanced` (the only v1 profile)
+Compiled-in constant; auto-includes standard read-exec system dirs so the common case is "name your workdir." Agent edits a repo + HTTPS:
+- read_exec: `/usr/bin /usr/local/bin /bin /lib /lib64 /usr/lib /usr/libexec /opt`
+- read_only: `/etc /usr/share` + `/proc` (read-only, so `git`/`node`/`python` can read `/proc/self/*`; peer *secret* reads stay walled by Landlock's ptrace hook, §2)
+- write_exec: workdir (build agent compiles+runs in-tree — the in-tree W^X residual is documented in §2)
+- write_noexec: `/tmp /var/tmp` + the standard character devices. `/var/log` and `/var/cache` are deliberately NOT granted: no build writes there, and they are shared system sinks (the log destination; packages a later root process installs). DAC refuses the write on a normal host anyway, which is what made the grant look free — it was dead weight that became a widening wherever a subdirectory is group- or world-writable.
+- HOME toolchain caches: `~/.rustup` read_exec; `~/.gitconfig` read_only; **no write grant on any shared package cache since 2026-07-28** (`~/.cargo/registry`, `~/.cargo/git`, `~/go/pkg`, `~/.npm`, `~/.cache` — see §2 and the `no_profile_grants_write_on_a_shared_package_cache` test); and the split below
+- `egress_tcp:[443]`; `ingress_tcp:[]` (**deny-all TCP bind** — a fixed-port dev server or test listener, e.g. `python -m http.server 8000`, gets `EACCES`; widen with a `--policy` naming the port); `scope_ipc:true`; seccomp `balanced`.
+
+**`~/.cargo` and `~/go` are split, and the split is load-bearing.** `bin/` under each is on the
+operator's `$PATH`, so granting write there is an escape that needs no kernel bug: the child drops a
+binary, the operator runs it later *unconfined* at their own uid. The tiers are therefore
+
+| path | tier | why |
+|---|---|---|
+| `~/.cargo/config.toml`, `~/.cargo/config`, `~/.cargo/env` | read_only | the config cargo actually reads, granted **by name** — see the credential note below |
+| `~/go` | read_only | **parent of `bin/`** so it must not carry write |
+| `~/.cargo/bin`, `~/go/bin` | read_exec | run the shims; never writable |
+| `~/.cargo/registry`, `~/.cargo/git`, `~/.npm` | **read_only** | build inputs. **Write removed 2026-07-28** (D1) — these are shared across projects and cargo re-verifies nothing on disk, so a write here reaches every future build on the machine. `~/go/pkg` inherits read from the `~/go` rule. `~/.cargo/.package-cache` is granted nothing: measured, cargo tolerates an unwritable lock for a warm build. |
+
+The shape is forced by Landlock's combination rule: *"One policy layer grants access to a file path
+if at least one of its rules **encountered on the path** grants the access"*
+(`Documentation/userspace-api/landlock.rst`). Rules **union along the path walk** — a narrower rule
+does not override a wider one. A `write` grant on `~/.cargo` would therefore confer write on
+`~/.cargo/bin` regardless of any rule placed there, so the write grants go on sibling subtrees and
+never on a parent of `bin/`. Nothing executes from these caches (cargo builds scripts and proc-macros
+into `target/`; `go build` links into `$TMPDIR`), so no-exec costs nothing.
+
+**The same union rule applies to READS, and `~/.cargo` used to get this wrong.** A `read_only` grant
+on the `~/.cargo` directory unions down onto `~/.cargo/credentials.toml` — the crates.io token, in
+plaintext — and onto any `[registries.*] token` in `config.toml`. That is a live credential readable
+by the child under the reference profile with one unprivileged read, no kernel bug and no cooperating
+peer. Note precisely what it broke: §8's "withheld by construction" is scoped to the ENV axis and
+was true there — the defect was that the same principle had never been carried to the FILESYSTEM
+axis, where a directory grant hands over the token the env allowlist was busy withholding. The fix is the read-side
+mirror of the write-side split: grant `config.toml`, `config` and `env` **as named files**, never the
+directory. A build or test never needs the token; only `cargo publish` and authenticated registry
+operations do, and those are out of scope for a cage. `profile::tests::cargo_credentials_are_never_readable`
+pins it.
+
+*Compat, stated plainly:* `cargo install` and `go install` do not work inside the cage — they write
+`bin/`. `~/.cargo/.package-cache` is cargo's top-level lock **file**, granted as a file rule so it
+does not widen `~/.cargo`; on a `$HOME` that has never run cargo it does not exist yet, is skipped
+like any missing path, and cargo cannot create it — `~/.cargo` itself carries no rule at all, so
+there is no `MakeReg` on it. Run cargo once unconfined first, or grant it via `--policy`.
+
+`strict` **ships alongside `balanced`** (`by_name` resolves both; `src/profile.rs`): untrusted-binary posture — nothing granted, workdir `write_noexec` for true W^X, deny-all egress, and a seccomp-blocked `pkey_mprotect(PROT_EXEC)` so the W^X wall can't be reopened. `--profile` accepts `balanced` (default) or `strict`. A `dev` (interactive, wide) profile is still deferred until a wired consumer exists; anything between or beyond the two profiles is expressed via `--policy`.
+
+**`--workdir `** injects one path into `balanced`'s `write_exec`. oring asserts the resolved target binary is already under a `read_exec`/`write_exec` tier and **refuses (exit 122)** if it is not — it does NOT auto-grant the target. No mount-scraping, no `$PATH`/`$HOME` globbing (predictable > clever).
+
+---
+
+## 8. CLI / UX
+
+```
+oring [OPTIONS] -- [args...]
+
+ --policy policy JSON file (wins over env)
+ --profile baked profile: balanced (default) | strict
+ --workdir inject DIR into the profile's writable workdir tier
+ (write_exec for balanced; write_noexec for strict)
+ --exec-tmp create DIR (0700), grant write_exec, point child's TMPDIR at it,
+ for tools that write-then-exec in scratch. /tmp stays no-exec.
+ --proc /proc grant: full (default) | self | none (self closes peer-procfs)
+ --env-clear pass NO env to the child except --env-keep vars
+ --env-keep allowlist one more env var (repeatable). Child gets POSIX basics by
+ default; credentials (SSH_AUTH_SOCK/*_TOKEN/*_API_KEY/AWS_*) must be named
+ --env-inherit pass the FULL parent env (legacy default; unsafe — child reads every credential)
+ --require-all-grants refuse to seal if any intended path could not be granted (CI)
+ --require-full-coverage refuse to seal if any restrictable RIGHT was left unhandled
+ (i.e. `unhandled=` would not be `-`). Opt-in. Both shipped
+ profiles PASS it as of 2026-07-29 (they set the UDP rights);
+ a hand-authored `--policy` is what can leave one unhandled
+ --denials report what Landlock DENIED to a sealed run, from the kernel's V7 audit
+ --since journal window for --denials (default: -1 hour)
+ --explain print the effective plan and exit (no execve)
+ --check validate policy+target+ABI floor (no execve)
+ -v, --verbose emit the effective plan to stderr before execve
+ -- everything after = target argv (required)
+
+Policy also via ORING_POLICY (JSON env). ORING_QUIET=1 suppresses the seal line.
+
+DELIVERY PRECEDENCE (highest wins; refuse if none): --policy > ORING_POLICY > --profile.
+ (SANDBOX_POLICY and ORING_PROFILE are NOT accepted — no consumer; keeps one parser shape.)
+ --profile supplies baked defaults; --policy/ORING_POLICY whole-replace it (no merge).
+
+EXIT: 120 confinement refused (no policy / Landlock < V10 floor / seccomp load failed)
+ 121 policy parse/validation error
+ 122 target not found
+ 159 seccomp KILL (x32/arch guard) = 128+SIGSYS
+ * child's execve-preserved status
+```
+
+Exit codes are deliberately **out of the 125/126/127 shell+docker band and the 128+N signal band** so a fail-closed security event is never confused with a benign child `sh -c` exec failure. Every refusal emits one machine-parseable stderr line `oring: REFUSE reason= (code=120)`; a successful seal emits (§6 step 11, §11) a positive seal line (§12).
+
+**Diagnosis path (self-applied cages give bare errnos with no attribution):**
+- `--explain` — pre-exec string dump: resolved FS tiers + path lists, connect/bind port sets, IPC scope state, detected ABI vs floor and which rings actually engaged, seccomp profile + one-line block summary, target read_exec coverage. No deps, no execve.
+- `-v`/`ORING_VERBOSE=1` — emit that same one-block summary to stderr right before execve, so a failed build's log carries policy context inline.
+- Documented errno→cause table in `--help`/README: EACCES on a path = Landlock FS; EACCES on `connect()` = port not in `egress_tcp` / net ABI absent; ENOSYS on io_uring/memfd/clone3 = oring seccomp; EPERM on ptrace/mount/bpf = oring seccomp.
+- **Ceiling stated plainly:** in-band per-denial attribution is impossible without a supervisor (seccomp-notify is out of scope; ptrace/strace are blocked by oring's own filter). `--explain` + verbose-startup + the errno table are the intended in-band workflow; `--denials ` reads the host-side V7 audit records after the fact (operator-side, not readable by the confined child).
+
+### Scratch, procfs, and env flags — security notes
+- **`--exec-tmp `** grants one operator-named `write_exec` scratch dir and wires the child's `TMPDIR` to it, for tools that write-then-`execve` in temp (Miri's sysroot build, `cc`/`ld`, `-sys`/bindgen build scripts, `go test`/`go run`) which `/tmp` (`write_noexec`) denies. It is created at **0700** — a world-writable scratch would let another local uid plant a binary the confined child then runs. A **pre-existing** path is re-validated, not trusted: oring `lstat`s it and refuses a symlink (else `canonicalize` would grant the link's *target* `write_exec` — a widening) or a group/other-writable directory. The grant is folded in **before** `check_self_protection`, so pointing `--exec-tmp` at the oring binary or policy still refuses. `/tmp` itself stays no-exec — this opens exactly one dir. Residual: a TOCTOU swap by an attacker who controls a writable *parent* — name the scratch under sticky `/tmp` or an operator-owned dir. Pinned by `tests/exec_tmp.rs`.
+- **`--proc `** = `full` (default) | `self` | `none`. `self` grants **the sealing pid's** procfs plus a curated global allowlist, closing even the peer-*metadata* leak (§2); `none` grants no `/proc`.
+
+ **"own pid" means the SEALING pid, and that is a sharper limit than it sounds.** `PROC_SELF_READONLY` lists the literal path `/proc/self`, and tier setup opens it with `PathFd::new`, which follows the symlink — so the rule lands on `/proc/`. `execve` preserves that pid and `fork(2)` does not, so a forked child's `/proc/self` resolves to a directory that was never granted. **Measured 2026-07-27** (bare: parent+child OK; `--proc full`: parent+child OK; `--proc self` and `strict`: parent OK, forked child `EACCES`). The blast radius is wider than "forked children": in `sh -c 'head /proc/self/status'`, `head` is itself a fork+exec, so **every external command** the target spawns loses access to its own procfs — only the single exec'd target keeps it.
+
+ This is a Landlock **expressiveness limit**, not a defect: rules are path-based and `/proc/` is per-pid, so "the caller's own procfs" cannot be expressed. It is documented here because the cost is asymmetric with the benefit — peer `environ`, the credential-bearing read, is **already walled at the V9 floor in every `--proc` mode** (§2), so `self` buys peer *metadata* privacy (`cmdline`/`stat`) and pays for it with fork compatibility across the whole child process tree. An operator hitting `EACCES` on `/proc/self/maps` from a subprocess has no other way to connect it to this flag. **CORRECTED 2026-07-27: this paragraph said peer `environ` is already walled at the V9 floor in EVERY `--proc` mode, so `self` bought only metadata privacy. That is false in the case that matters.** Landlock's `domain_scope_le()` is ancestor-**or-equal**, so two children of one seal share a domain and read each other's `environ` freely. Measured, one seal, true siblings: `--proc full` → sibling `environ` **readable**; `--proc self` → **denied**. The V9 floor walls only **non-descendant, out-of-domain** peers. So `self` buys credential privacy *between the child's own subprocesses* — the normal shape for an agent spawning tools — and pays for it with fork compatibility across the tree. **Corrected again 2026-07-28: the two halves of that pair are not one experiment.** The `--proc self` denial is the **same path-layer effect this paragraph opens with** — `/proc/` was never granted — and not the domain relation, which still permits the read. `cmdline`/`stat`/`comm`, which no ptrace hook gates, are denied under `--proc self` too, which only a path denial explains. The operator advice stands unchanged and the *cost* stated above is now visibly the same cost: `self` buys sibling-`environ` privacy **by** making sibling procfs unreachable, which is precisely why it breaks forked children's `/proc/self`. One mechanism, one price — not two findings.
+- **Env allowlist** — the child's env is built fresh from `BASE_ENV_KEEP` (POSIX/locale basics) plus any `--env-keep NAME`; credentials are withheld by construction, not by enumerating what to drop. `--env-clear` passes only what is named; `--env-inherit` restores the legacy pass-everything default and is documented as unsafe (a compromised child reads every credential in the shell). The rule is the §5 rubric applied to a second namespace: *enumerate what gets in, never what to drop.* Pinned by `tests/deny_env.rs`. **This claim is scoped to the environment.** The filesystem axis is a separate obligation with its own history — see the credential note in §7; withholding `ANTHROPIC_API_KEY` from the env buys nothing if a tier grant makes `~/.cargo/credentials.toml` readable.
+
+---
diff --git a/docs/RESIDUALS.md b/docs/RESIDUALS.md
new file mode 100644
index 0000000..bbc621e
--- /dev/null
+++ b/docs/RESIDUALS.md
@@ -0,0 +1,700 @@
+
+# RESIDUALS — threat model, what oring stops, and what it does not
+
+**This is `DESIGN.md` §2.** The number is unchanged and every existing
+`DESIGN §2` citation still resolves; `docs/DESIGN.md` carries the index.
+
+Read this before relying on oring for anything. It states the adversary, the
+guarantees that are kernel-enforced and irrevocable, and — at greater length,
+because this is the part that decides whether oring fits your problem — every
+capability a compromised child retains anyway.
+
+**Gated in-tree.** `tests/design_residuals.rs` checks every residual below
+against `CONSTRAINTS.md`, in both directions: a residual naming a loss that does
+not exist fails, and a loss no residual claims fails too. It runs on every
+`cargo test` and in `make check`. **`make check` on a >=7.2 host is the only
+place containment is witnessed** — no hosted runner is at oring's floor, so CI
+checks format, lints, dependency policy, the static build and the SMT gate, and
+nothing else.
+
+**Correction history is not in this file.** Where a residual's wording was
+published wrong and later refuted by measurement, the entry carries
+`[corrections: C-n]` and the account lives in
+[`CORRECTIONS.md`](CORRECTIONS.md). Several of these were dangerous wordings
+rather than merely wrong ones, so they are kept — but a reader looking up what
+oring does today should not have to read a debate that has ended.
+
+## How to read a residual
+
+Each carries a tag: the **loss** it belongs to, and whether anything reports it
+at run time.
+
+| disclosure | meaning |
+|---|---|
+| `stated` | documented, and an instrument reports it during a run |
+| `documented-only` | written down; nothing in a run reports it |
+| `silent` | neither |
+
+**A residual whose disclosure is `silent` is one an operator cannot discover from
+a run**, which is a different and worse thing than one they can look up. The
+losses (`BREACH`, `LAUNDER`, `DIVERGE`) and hazards are defined in
+[`CONSTRAINTS.md`](../CONSTRAINTS.md), which also carries the current enforced
+fraction.
+
+## Attacker
+
+A fully-compromised agent — prompt-injection to arbitrary native code — running
+as uid 1000 *after* `restrict_self` + `execve`, with all rings already in effect
+and irrevocable. **The attacker is the child.**
+
+**The caller's role is narrower than "trusted"** [corrections: C-3]. The caller
+is trusted to **author** a policy explicitly; the caller's **ambient state is not
+trusted to widen or override what was authored**. Explicit sources (`--policy`,
+`--profile`, `--tmpdir`, `--exec-tmp`) are authored. Ambient sources
+(`ORING_POLICY`, `$TMPDIR`, `$HOME`) are not, and where an ambient source would
+override or widen an authored one, **oring refuses rather than picking a
+winner**: `--profile` beside `ORING_POLICY` is a hard refusal (`src/main.rs`);
+`$TMPDIR` is no longer read at all, after it was measured granting write on
+`~/.ssh/authorized_keys`; `check_profile_grants` refuses a profile whose tier
+would contain `$HOME`. This is `CONSTRAINTS.md` **CON-6**, which stated the rule
+before this document did. It also predicted a measured defect rather than merely
+describing one: `$HOME` is ambient, `writable_shared_caches` read it and nothing
+else, and a decoyed `HOME` made the seal line report
+`shared_cache_write=none` over a policy granting write on the real
+`~/.cargo/registry`.
+
+**Fixed 2026-08-02.** That function now takes the union of the **passwd**
+home — which is not ambient — and `$HOME`, so a decoy can only ever *add* a
+place to look and can no longer remove the true one. `$HOME` is kept as an
+additional root deliberately: a relocated home, a container with no passwd
+entry, and a harness pointing `HOME` at a fixture are all real. Pinned by
+`tests/decoy_home.rs`, which decoys `HOME`, unsets it, and carries both a
+negative control (a policy granting no cache still reports `none`) and a
+positive one (an honest `HOME` reports the grant), so "still reported" cannot
+pass over a decoy that never took effect. Reverting to `$HOME` alone turns it
+red. **The confinement was never affected** — this was `MISREPORT`, the
+operator's view of the cage rather than the cage.
+
+## What oring stops — kernel-enforced, irrevocable
+
+- **FS access outside policy tiers** — host creds (`~/.ssh`, `~/.aws`), *other*
+ projects' `.git/config`, host config. **Strongest guarantee (Landlock V1–V3,
+ hard floor).** Read it narrowly: it is `.git/config` of repos **outside** the
+ granted tiers. The **workdir's own** `.git/` is inside `write_exec` and is
+ fully writable by the child — see *Poisoning the workdir* below.
+ **Measured** 2026-07-27 against the shipped `balanced` profile, with controls
+ in the same run: `~/.ssh/{id_ed25519,authorized_keys,known_hosts}`, `~/.bashrc`
+ and two out-of-tree `.git/config` files all read bare and return `EACCES`
+ sealed, while `~/.rustup/settings.toml` and `/etc/hostname` — deliberately
+ granted — stay readable.
+ **The mechanism and the named paths are now tethered separately.**
+ `read_outside_tiers_is_eacces` builds its own policy, so it proves only that
+ Landlock denies an out-of-tier read — it says nothing about the shipped
+ profiles. What closes the other half is
+ `profile::tests::no_profile_grants_any_access_to_a_credential_store`: an
+ ∀-over-profiles invariant asserting that no tier of either baked profile
+ reaches `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.netrc`, `~/.kube`,
+ `~/.docker/config.json`, `~/.config/gh`, or the shell histories. It checks all four tiers and tests ancestry both ways, so a grant on `~/.ssh`
+ and one on `~/.ssh/known_hosts` are each caught.
+- **W^X** — no exec from writable scratch (`write_noexec` tiers).
+- **Privilege gain via SUID/setcap/file-caps** (`NO_NEW_PRIVS`) — neutralises the
+ CVE-2023-0386 OverlayFS-SUID payoff for the confined child; its mount/userns
+ half is separately closed by the seccomp `mount`/`unshare(CLONE_NEWUSER)`
+ denials (`DESIGN.md` §5). **Measured** 2026-07-27: `NoNewPrivs` `0`→`1` and
+ `Seccomp` `0`→`2` in the child's `/proc/self/status`. **`NO_NEW_PRIVS` appears
+ zero times in `tests/`** — fixture is T15.
+- **Namespace creation** — per-flag arg-masks on `clone`/`unshare` `CLONE_NEW*`,
+ `clone3`→`ENOSYS`, `mount`/`setns`/`pivot_root` blocked. **7 rules on `clone`,
+ 8 on `unshare`** [corrections: C-4]; the extra one is `CLONE_NEWTIME` (0x80),
+ which `clone` must not carry because its low byte is `CSIGNAL` and a 0x80 rule
+ there would false-match an exit signal.
+ **Coverage, measured** 2026-07-27: only `CLONE_NEWUSER` has a cell, and that is
+ not arbitrary — it is the **only** flag whose denial is attributable to oring
+ without extra machinery, since unprivileged the other seven already return
+ `EPERM` bare. Inside a plain user namespace all eight succeed bare and all
+ eight then seal `EPERM`, so the userns is what makes them testable;
+ `tests/common/mod.rs::run_probe_jailed` already builds one. `setns` is
+ attributable directly (`EBADF` bare → `EPERM` sealed) and has **no cell**.
+ `mount` and `pivot_root` are `EPERM` at every level and are **not** attributable
+ by errno at all — a permanent limit, not a missing fixture.
+ **`setns` now has a cell** (`deny_syscall.rs`): `EBADF` bare, `EPERM` sealed.
+ **And the flags are covered where runtime testing cannot reach them** —
+ `tests/seccomp_smt.rs::no_clone_new_flag_combination_is_allowed` proves over
+ *all* argument values that no `CLONE_NEW*` combination is allowed by the
+ emitted filter, per flag and in combination, with a control asserting
+ `CLONE_NEWTIME` on `clone` *is* allowed so the query cannot pass vacuously.
+- **LPE launchpads** — io_uring, eBPF, ptrace, userfaultfd, keyctl,
+ perf_event_open, memfd_create, module ops, `open_by_handle_at`, Dirty-Pipe
+ (`splice`/`vmsplice`), StackRot (`MAP_GROWSDOWN`), TTY-injection ioctls, and
+ **x32-ABI re-entry of all of the above** (`DESIGN.md` §5).
+ **All thirteen rules are emitted; twelve are measured denying.** Measured
+ bare-vs-sealed, one variable, 2026-07-27: `MAP_GROWSDOWN` OK→`EPERM`,
+ `memfd_create` OK→`ENOSYS`, `splice`/`vmsplice` OK→`ENOSYS`, `TIOCSTI`
+ `ENOTTY`→`EPERM`, `userfaultfd` `EPERM`→`ENOSYS`, `perf_event_open`
+ `EACCES`→`EPERM`, `bpf` `EINVAL`→`EPERM`, `open_by_handle_at` `EFAULT`→`EPERM`.
+ The two **arg-masked** rules — the class where an off-by-one denies nothing and
+ reads green — have controls proving the masks are narrow: plain `mmap` and
+ `TIOCGWINSZ` on the same fd are unaffected by the seal.
+ **`module ops` is the one name measurement cannot attribute:** `init_module` is
+ `EPERM` bare (no `CAP_SYS_MODULE`) and `EPERM` sealed, so the two causes are
+ indistinguishable — the rule is real, the *evidence* for it is not. Fixture is
+ `BUILD-QUEUE.md` T13.
+- **AF_UNIX MSG_OOB UAF (CVE-2025-38236)** — arg-masked on send/recv `MSG_OOB` +
+ `SIOCATMARK`.
+- **Inherited leaked FDs at fd 3 and above** — FD-scrub via
+ `close_range(3.., CLOSE_RANGE_CLOEXEC)`, `src/preexec.rs`. **fds 0–2 are
+ deliberately preserved and are NOT scrubbed** [corrections: C-5] — a child
+ without stdio is broken — so an fd the caller redirects onto stdin/stdout/stderr
+ crosses the seal intact. **Measured** 2026-07-27, one variable: an inherited
+ fd 0 is readable inside the cage, an inherited fd 7 is gone.
+ **Both ends are now pinned** by `tests/fd_scrub.rs`, which plants a readable
+ descriptor with `dup2` (clearing `FD_CLOEXEC`, so it survives `execve` the way
+ a caller's leak does) and asserts it readable bare, `EBADF` sealed, and fd 0
+ readable in both. The bare arm is the load-bearing one: without it, "fd 7 is
+ gone" is what a harness that plants nothing reports. Red-tested both ways —
+ removing `preexec::scrub_fds()` and disabling the plant each turn it red.
+
+### Also stopped — guaranteed at the V10 floor
+
+- **Connect/bind on non-allowed TCP ports** — Landlock net (ABI V4 / kernel 6.7).
+ Port-only, and dependent on the ring-2 `MSG_FASTOPEN` block for the wall to
+ hold (`DESIGN.md` §4/§5).
+- **Signalling and abstract-socket peers outside the domain** — Landlock scope
+ (ABI V6 / kernel 6.12) → `EPERM`. In the container path the runtime's
+ netns/pidns also provides this; oring's scope ring is defence-in-depth there.
+
+---
+
+## Residual — full control inside granted FS tiers
+
+**Claim.** The workdir is `write_exec` under `balanced`, so an in-tree
+write-then-run payload is possible.
+
+**Mechanism.** This is the grant, not a gap: the policy named it.
+
+**[loss: NONE — the policy named it · disclosure: stated (`--explain` prints the tiers)]**
+
+## Residual — poisoning the workdir so the operator executes it later, unconfined
+
+**Claim.** The child can write `.git/hooks/*` and `.git/config`
+(`core.fsmonitor`, `core.pager`, `core.sshCommand`, `alias.*`, `diff.*.textconv`
+all run commands), and equally `Makefile`, `build.rs`, `package.json` scripts,
+`conftest.py`. Each fires on the next `git`, build or test the operator runs
+**outside** the cage, at their uid.
+
+**Mechanism.** Not closeable at this layer. Landlock has no deny rule — access is
+the union of every rule on the path — so a subpath cannot be carved out of a
+granted tier. Withholding `.git/` would mean never granting the repo root, which
+blocks creating any new top-level file. This is **not** the same class as the
+`~/.cargo/bin` `$PATH` over-grant that was fixed: that was write access the agent
+never needed, whereas this is the tree the agent exists to edit.
+
+**Control.** Inspect the tree with tools that do not interpret it as a
+repository:
+`command grep -nE 'fsmonitor|pager|sshCommand|textconv|hooksPath|askpass|^\s*\[alias\]' .git/config`
+and `find .git/hooks -type f ! -name '*.sample'`. Or re-clone into a throwaway
+checkout. **Do not review with `git`** and do not run the first build inside the
+cage — both were published as controls here and both were measured making things
+worse [corrections: C-11, C-10].
+
+**This control is demonstrated**, which for a control on this page is the
+exception rather than the rule (CON-7).
+`tests/control_efficacy.rs::the_documented_workdir_review_detects_the_poisoning_without_firing_it`
+plants the `core.fsmonitor` poisoning and runs both arms in one test: the
+retracted `git` review **fires the payload twice and never names it**, and the
+recipe above **detects it and leaves the payload unfired**. The retracted arm is
+the vacuity control — without it, a passing result would be indistinguishable
+from an inert payload. Red-tested three ways: blinding the recipe, making the
+payload inert, and drifting the recipe in this document away from the fixture
+each turn it red.
+
+**Not established.** No instrument reports this at run time. The fixture covers
+the seven `.git/config` keys and non-sample `.git/hooks` files — **not**
+`Makefile`, `build.rs`, `package.json` scripts or `conftest.py`, which are named
+above and are ordinary tracked files, so the answer there is review-the-diff and
+no fixture demonstrates it.
+
+**[loss: LAUNDER + hazard HZ-INSTRUCTED · disclosure: documented-only — no instrument reports it, and a named control was measured FIRING the payload (T30)]**
+
+## Residual — poisoning a shared package cache
+
+**Claim.** ~~Every future project on the machine executes it.~~ **Closed
+2026-07-28 for the built-in profiles** by removing the write grants
+(`DECISIONS.md` D1); the caches remain readable. Kept because the *class* is
+closed only for those profiles — a `--policy` that grants a cache reopens it —
+and because the upstream defect is unchanged.
+
+**Mechanism.** **cargo verifies a tarball once at download and never verifies
+anything on disk again.** `.cargo-ok` is a version tag with no hash, the
+`Source::verify` hook exists and `RegistrySource` does not implement it, and a
+cache-hit on a `.crate` checks only that the file is non-empty.
+
+**Measured** 2026-07-28, end to end: a payload appended to a `build.rs` under
+`~/.cargo/registry/src`, with `.cargo-ok` untouched, executes at the operator's
+uid on an ordinary `cargo build` — or on `cargo check` alone, which is what an
+editor runs — **in a completely unrelated project**. Cross-project, persistent
+across sessions, and it leaves nothing in either repo's git history. The trigger
+needs a cold `target/` for that crate, which is the normal case for a *different*
+project. W^X buys nothing: the chain plants **source** that a later toolchain
+compiles.
+
+**Not established.** The upstream defect — cargo holds a sha256 and declines to
+check it — is not oring's to fix. [corrections: C-9a]
+
+## Residual — outbound TCP to any IP on an allowed port
+
+**Claim.** 443 reaches any HTTPS endpoint, so an allowed port is an exfil
+channel.
+
+**Mechanism.** Landlock net is port-only, never address-keyed.
+
+**Control.** Host firewall or filtering proxy — a **required** companion control
+(`DESIGN.md` §9), not an optional one.
+
+**[loss: BREACH by intent — the policy granted the port · disclosure: stated (`net=` and `egress_tcp`)]**
+
+## Residual — DNS and UDP exfil
+
+**Claim.** Narrowed 2026-07-29 by making UDP a profile default, and deliberately
+**not** called closed. `strict` grants no UDP at all, so for that profile it *is*
+closed. `balanced` grants outbound **:53 only** — but Landlock net is port-only,
+so **:53 at any address is exactly the DNS-tunnel channel**, reachable via an
+attacker-controlled resolver. The residual survives under `balanced` in its
+most-used form, narrowed from every UDP port to one.
+
+**Mechanism.** ABI V10 ships `LANDLOCK_ACCESS_NET_BIND_UDP` and
+`LANDLOCK_ACCESS_NET_CONNECT_SEND_UDP` (mainline 7.2), the latter explicitly
+covering "send datagrams to the given remote port ignoring any destination
+pre-set on a socket" — the UDP analogue of the `MSG_FASTOPEN` implicit-connect
+gap ring 2 closes.
+
+**Measured** 2026-07-29 on 7.2.0-rc4 via bash `/dev/udp` under each profile with
+an unsealed control (both ports reachable bare): `balanced` permits :53 and denies
+:9, `strict` denies both, `getent hosts` resolves under `balanced` (exit 0) and
+fails under `strict` (exit 2). Pinned by
+`profile::tests::the_shipped_udp_posture_is_dns_only_or_deny_all` plus two
+∀-over-profiles invariants: no net right left `None`, and a UDP egress grant
+implies port 0 for autobind.
+
+**Control.** The `DESIGN.md` §9 companion control is what closes it.
+Do not read "UDP is restricted" as "DNS exfil is closed". [corrections: C-9]
+
+**[loss: BREACH by intent — the profile granted :53 · disclosure: stated (`net=` names the UDP posture and `unhandled=` reports any right left open)]**
+
+## Residual — anything that is not TCP or UDP is unmediated, at every ABI
+
+**Claim.** `egress_tcp: []` and `egress_udp: []` together still do not mean "no
+network". Read `egress_tcp: []` as "no TCP", never as "no egress".
+
+**Mechanism.** All three Landlock net hooks `return 0` — *allow* — for a socket
+that is neither TCP nor UDP (`security/landlock/net.c`). This is a **deliberate
+design position, not a hook-signature limitation** [corrections: C-7]: the hooks
+are registered and they run, and the body chooses to return 0 for anything
+`sk_is_tcp()`/`sk_is_udp()` rejects. The three do not reach it by one dispatch
+shape [corrections: C-8]. No net-hook ABI bump retires this; closing it needs a
+new access-right family, which Ivanov's *"Support socket access-control"* RFC
+proposes and which is unmerged — so "no ABI closes it" is a claim about today's
+tree, not about all future Linux.
+
+**What is actually reachable** is a different question from what Landlock
+declines to mediate [corrections: C-12]. ICMP is **closed by ring 2**: `socket()`
+in `AF_INET`/`AF_INET6` is restricted to the protocols ring 1 can mediate, in
+both profiles, so `IPPROTO_ICMP` gets `EAFNOSUPPORT` and **in-cage `ping` does
+not work**. `AF_PACKET` and raw sockets need `CAP_NET_RAW`. DCCP and UDP-Lite no
+longer exist in Linux. **SCTP is live on a stock distro kernel** — a measurement
+to the contrary was taken on a `localmodconfig` kernel with no `sctp.ko`, a
+standing reminder that protocol *availability* is a property of the kernel build,
+not of Landlock.
+
+**[loss: BREACH by intent · disclosure: stated — `unhandled=` names it and `ORING_QUIET` cannot suppress that (pinned by `tests/udp_wall.rs`)]**
+
+## Residual — the child reads its own environment
+
+**Claim.** Any credential injected into the ring is readable by the child.
+
+**Mechanism.** Narrowed: the child's env is an allowlist by default
+(`BASE_ENV_KEEP` — POSIX/locale basics only), so a credential reaches the child
+only when the operator names it with `--env-keep` or opts out wholesale with
+`--env-inherit`. What remains is "steal any credential the operator explicitly
+admitted", which is a decision rather than an accident.
+
+**[loss: NONE — an explicitly admitted credential is a decision · disclosure: documented-only, and the ENV side is silent: 38 of 46 vars dropped, one narrated (T27)]**
+
+## Residual — reach the operator's ssh-agent
+
+**Claim.** ~~A compromised child reaches `SSH_AUTH_SOCK`.~~ **Closed in v0.2 by
+the V9 floor** [D-4b], as a special case of pathname-AF_UNIX connect below.
+
+**Mechanism, kept because it is the instructive part.** It was not closable at
+the FS-tier layer at all. Both profiles grant `/tmp` and OpenSSH's socket sits at
+`/tmp/ssh-XXXXXX/agent.NNNN`, yet no arrangement of tiers ever gated it —
+Landlock has no deny rule and a granted `/tmp` cannot have a subpath carved out,
+so withholding the grant would have obscured the socket (a `mkdtemp` name), not
+denied it. The env allowlist removes the trivial path but never closed the hole.
+`warn_on_sockets` is retained as the tripwire that would start warning again if
+the floor were lowered.
+
+## Residual — connect to a pathname AF_UNIX socket
+
+**Claim.** ~~docker.sock, podman.sock, journald.~~ **Closed in v0.2 by the V9
+floor** [D-4b]. `RESOLVE_UNIX` gates `connect()` to a filesystem-path socket
+(handled, granted in no tier → `EACCES`); abstract sockets are covered by scope
+(`EPERM`) at the floor regardless.
+
+**Mechanism, kept because it is the instructive part.** Landlock has **no hook on
+AF_UNIX `connect()` below V9**, so no arrangement of tiers gated it — and the
+obscurity that withholding `/tmp` bought was itself defeated by the peer-procfs
+residual below, which hands over `SSH_AUTH_SOCK` verbatim from a peer's `environ`
+under the default `--proc full`. **Two residuals composed to defeat the
+mitigation between them**; raising the floor was the only real fix.
+
+## Residual — peer procfs read
+
+**Claim.** A compromised child can read a peer uid-1000 process's
+`/proc//{environ,maps,mem}`.
+
+**Mechanism.** These are VFS `read()`s gated only by `ptrace_may_access`, not by
+a blocked syscall, and Landlock walls them through `hook_ptrace_access_check`.
+The denier consults **no handled access right** — it compares domain hierarchies
+via `domain_scope_le()`, which is ancestor-**or-equal** — and the rule predates
+the 5.13 merge, so the floor buys none of it [corrections: C-13]. The axis is
+**in-domain vs out-of-domain, not descent** [corrections: C-14]: `fork(2)`
+creates no new domain, so every process under one oring seal shares one domain
+and reads every other's `environ`.
+
+**Measured.** Out-of-domain peer `environ` returns `EACCES` (Fedora 44, kernel
+7.1.4, ABI V9); SELinux was ruled out rather than presumed — the same read
+succeeds bare under an identical context, `deny_ptrace` is off, and the denial is
+peer-specific. Siblings under one seal: `environ` is **readable** under
+`--proc full` (7.2.0-rc4, 2026-07-27). Under `--proc self` the sibling read is
+denied, but by the **FS tier**, not the domain relation [corrections: C-15] —
+`cmdline`, `stat` and `comm` are not ptrace-gated and are also denied there,
+which only a path-layer denial can do.
+
+**Control.** `--proc self`, or runtime PID-ns + `maskedPaths` (`DESIGN.md` §9).
+`--proc self` buys sibling-`environ` privacy by making the path unreachable,
+which is why it also costs the fork compatibility documented in `DESIGN.md` §8.
+
+**`--proc self` is demonstrated** (CON-7) by the matched pair
+`proc-indomain-env-full` / `proc-indomain-env-self` in
+`tests/containment_matrix.rs`: one seal, two processes, a planted
+`ORING_DECOY` in the sibling's environment. Under `--proc full` the read
+**succeeds** — that arm is the vacuity control, and it is the residual itself —
+and under `--proc self` it returns `EACCES`. The runtime PID-ns control is
+**not** demonstrated here and cannot be: it needs a container runtime.
+
+**The wall is lost under privilege, and oring does not check for it.**
+`may_access_mm()` (`kernel/fork.c`) returns true on
+`(mode & PTRACE_MODE_READ) && perfmon_capable()` **after** `ptrace_may_access`
+fails, so a reader holding `CAP_PERFMON` or `CAP_SYS_ADMIN` in the initial
+userns reads a non-descendant's `environ` and `maps` regardless of any Landlock
+domain. `mem` still denies — it uses `PTRACE_MODE_ATTACH`. **oring performs no
+euid or capability check, so `sudo oring …` silently loses this wall.** The
+bypass arrived in Linux 6.15 (`0c555a3c1bc9`, "procfs: allow read-only remote mm
+access under CAP_PERFMON").
+
+**The check is open-time only.** A descriptor opened before `restrict_self`
+keeps reading afterwards while a fresh open of the same path returns `EACCES`.
+`scrub_fds` closes that for the child, but fds 0–2 are exempt, so
+`oring … < /proc//environ` survives — the same shape as the pre-seal
+descriptor residual below.
+
+**Not established.** `environ` is what was witnessed. `maps` and `mem` share the
+`ptrace_may_access` gate and are expected to follow but have **no cell yet** — do
+not widen the claim without one. Peer metadata (`cmdline`/`stat`) is
+world-readable regardless of the floor and remains readable under `--proc full`.
+The seal line's `proc=` field reports the tiers oring **asked** Landlock for, and
+`add_tier` skips a path that does not exist, so a granted-but-absent `/proc` path
+still counts.
+
+**[loss: BREACH · disclosure: documented-only — `--proc self` is the offered control; the instrument half closed 2026-07-29 when the seal line gained `proc=`, derived from the installed tiers rather than echoed from the flag (corrections: C-17)]**
+
+## Residual — unfilterable kernel bug classes
+
+**Claim.** `futex` UAF, the AF_UNIX **SCM_RIGHTS GC race** (genuinely no argument
+to mask — distinct from the now-filtered MSG_OOB path), and
+Dirty-COW-via-`/proc/self/mem`.
+
+**Mechanism.** Required by threaded workloads; there is no argument to filter on.
+
+**[loss: BREACH · disclosure: documented-only — nothing in a run reports it; this is non-goal 2 restated]**
+
+## Residual — unmasked /proc and /sys
+
+**Claim.** Readable unless the runtime masked them.
+
+**Mechanism.** Masking is a mount-view job and belongs to the runtime.
+
+**[loss: BREACH by intent — the runtime is the controller · disclosure: documented-only; this is non-goal 5 restated and carries no measurement]**
+
+## Residual — a resource the domain did not create
+
+**Claim.** A Landlock domain restricts *operations initiated under it*, not
+capabilities the process already holds. A UDP socket `connect()`ed **before**
+`restrict_self()` keeps sending, via address-less `send(2)`, after a zero-rule
+seal, while a freshly created socket in the same process is denied `EACCES`.
+
+**Mechanism.** Narrower than it first looks [corrections: C-6]: an addressed
+`sendto(2)` on that very fd **is** denied. What survives is the address-less
+*path* — `hook_socket_sendmsg` only checks when `msg->msg_name != NULL`, and the
+autobind check passes because the socket is already bound. So the residual is "a
+connected socket keeps its established peer", not "pre-seal descriptors are
+unmediated". Upstream states this as design: *"receiving or inheriting a file
+descriptor (or similar) is governed by the capability model, not Landlock … a
+possible confused deputy, **not a bypass**"* (`landlock: Document the threat
+model`, 2026-07-07). Not closeable by an ABI bump; covered, if at all, by the
+`DESIGN.md` §9 host layer.
+
+**Measured** 2026-07-28, one variable (the redirect), both shipped profiles: a
+fresh `open()` of a withheld path is denied while the same path delivered on
+**fd 0** reads in full, with a seal line **byte-identical** between the two runs.
+Attributed to the scrub by fd 3 returning `Bad file descriptor` on the same run,
+and reproduced on a planted sentinel outside every granted tier as well as on
+peer procfs — so the mechanism is **any withheld path**, not a procfs special
+case.
+
+**Not established.** Descriptors arriving over `SCM_RIGHTS` are subject to the
+same distinction and have **not** been separately measured. oring `execve`s a
+fresh image and passes no sockets, so the exposure is what the **caller** hands
+in.
+
+**[loss: BREACH · disclosure: **silent** — `--denials` is structurally blind here, because the open happens outside the domain and the kernel is never asked, so no denial exists to report (T10)]**
+
+## Residual — a denied path stays visible, and `access()` pre-flights abort
+
+**Claim.** Landlock hooks the open; it does not hook the metadata permission
+check. Measured on one path, one binary, one variable (the seal):
+`access(R_OK)` → `True`, `stat()` → succeeds, `exists` → `True`,
+`open()` → `EACCES`. **That combination cannot occur under DAC**, where
+`chmod 000` fails both together, so a program that checks `access()` and then
+opens is walked into a branch reachable on a normal system only through a race or
+corruption.
+
+**Mechanism.** Structural, not a version artifact: `security/landlock/fs.c`
+registers `file_open` and the `path_*`/`sb_*` families and **neither
+`inode_permission` nor `inode_getattr`**, which is what `access`/`stat` resolve
+through.
+
+**Measured on git**, two denial layers × two DAC modes, identical Landlock denial
+in both sealed rows: `sealed`+mode 644 → **rc=128, fatal**; `sealed`+mode 000 →
+rc=0, warning only. The only variable is whether `access()` agreed. Under the
+divergence `~/.config/git/config` is fatal with *"unknown error occurred while
+reading the configuration files"* — **naming neither the file nor the sandbox** —
+and `~/.config/git/ignore` is fatal naming the file; `attributes` degrades
+cleanly. Three failure modes are known (abort, silent config loss, correct
+handling) and **only git is measured as aborting**: `npm` with an unreachable
+`~/.npmrc` returns rc=0 and the default value, and does the same under
+`chmod 000`, so that is npm's behaviour rather than a sandbox effect.
+
+**Control.** `oring --explain`. Nothing in the failure suggests it, which is the
+residual.
+
+**Demonstrated** (CON-7) by
+`tests/control_efficacy.rs::explain_discriminates_the_withheld_path_and_runs_nothing`,
+in three arms: the divergence is live under the policy used (`access()` readable,
+`open()` denied); `--explain` **names the granted tier and not the withheld
+path**, so an operator can see which side of the boundary theirs fell on; and it
+**does not execute the target** — checked against a positive control that runs
+the same command without `--explain` and confirms it does create the sentinel,
+so "ran nothing" cannot pass over a command that could never have run.
+
+**Not established.** Not closeable at this layer: making a path read as *absent*
+is a mount-namespace capability (measured — the same file withheld by a tmpfs
+mount in a user namespace gives git rc=0), and Landlock's only vocabulary is
+deny. Which config paths the built-in profiles should grant to reduce how often
+this is hit is open (`BUILD-QUEUE.md` T23). Full measurement:
+`deep_research/notes/2026-07-28-landlock-access-open-divergence.md`.
+
+**[loss: OPAQUE (and DIVERGE for the redirection fix) · disclosure: documented-only — the operator's route back is `oring --explain` and nothing in the failure suggests it]**
+
+---
+
+## What could be wrong with oring itself
+
+Every residual above assumes the seal works and asks what a child can still
+reach. This section asks the other question: **what if the adversary is already
+inside oring's own machinery?** Each is a dependency of correctness that oring
+neither enforces nor checks, and the pattern is why they were invisible — a
+residual list scoped to *what the child can do* has no slot for *the wall might
+not be where this document says it is*.
+
+## Residual — oring IS the child; there is no process boundary
+
+**Claim.** Sealer and sealed are one process.
+
+**Measured** 2026-07-29: the seal line reports `pid=511037` and the child prints
+`$$ = 511037`.
+
+**Mechanism.** `execve` preserves the process, so everything oring holds at seal
+time is inherited by definition — fds (0–2 deliberately not scrubbed), the
+environment, signal dispositions, cwd, session and process group. The address
+space is replaced, so memory is not inherited; **nothing else is protected by a
+boundary, because there is no boundary.** This is also why the seal line is
+forgeable by the child: same stderr, same process.
+
+**[loss: BREACH · disclosure: documented-only — `pid=` is printed but no field says what that identity implies]**
+
+## Residual — the sealing library is vendored out-of-tree and unaudited
+
+**Claim.** `Cargo.toml` carries
+`[patch.crates-io] landlock = { git = "…/eslerm/rust-landlock", rev = "d7bb0c1…" }`, so the
+crate that installs ring 1 is not the published one, `cargo install oring` does
+not build, and the clean-registry-dependency guarantee is **suspended for exactly
+the dependency that does the confining** (D-4b). 58 packages resolve in
+`Cargo.lock`.
+
+**[loss: BREACH · disclosure: **silent** — nothing in a run reports which landlock implementation sealed it]**
+
+## Residual — seccompiler's lowering is not re-proven here
+
+**Claim.** oring verifies its own hand-assembled cBPF and takes the rest of
+ring 2's compilation on faith — it is Firecracker's TCB, imported. A lowering bug
+produces a filter that installs cleanly and denies less than the rules say.
+[`VERIFICATION.md`](VERIFICATION.md) states the boundary.
+
+**[loss: BREACH · disclosure: **silent**]**
+
+## Residual — `nnp=1` is a literal, not a measurement
+
+**Claim.** The only seal-line field not derived from run state.
+
+**Mechanism — three legs, and one of them is dead.** The field was described here
+as true by a *double* invariant: `set_no_new_privs()?` aborts the pipeline before
+the line is reached, and unprivileged `SECCOMP_SET_MODE_FILTER` returns `EACCES`
+without NNP. **Measured 2026-08-02, the second leg is unreachable:**
+`seccompiler::apply_filter_with_flags` calls `prctl(PR_SET_NO_NEW_PRIVS, 1, …)`
+itself immediately before the `seccomp()` syscall
+(`seccompiler-0.5.0/src/lib.rs:344`), and `SeccompProfile` has two variants with
+no no-filter option — so every seal sets NNP twice and that `EACCES` path can
+never fire through oring. oring's own call is **defence-in-depth**, not the sole
+mechanism.
+
+**Measured.** `tests/nnp.rs` — bare `NoNewPrivs 0 / Seccomp 0`, sealed
+`NoNewPrivs 1 / Seccomp 2`, one variable. It also checks the literal against the
+kernel for the same run, which is what makes the field falsifiable by a run at
+all; flipping it to `nnp=0` turns the suite red.
+
+**Not established.** That privilege escalation is prevented — only that the bit
+the guarantee rests on is set and that oring's report of it is not lying. And
+**no test can pin `preexec::set_no_new_privs()` itself**, for the reason above:
+removing it leaves the child at `NoNewPrivs: 1` regardless. Do not read a green
+run as evidence that line is load-bearing, and do not delete it on that basis
+(T15).
+
+**[loss: BREACH · disclosure: **silent** — the field asserts a property it does not measure]**
+
+## Residual — `errata=` is a lower bound, not a certificate
+
+**Claim.** Two kernels agreeing on `(abi, errata)` may still differ in behaviour.
+The mask enumerates only errata the kernel *declares*, and 7.2-rc4 carries at
+least one behaviour change no erratum covers.
+
+**[loss: BREACH · disclosure: stated — `DESIGN.md` §12 says so, and the field is printed]**
+
+## Residual — diagnostics render caller-supplied bytes raw
+
+**Claim.** ~~13 of ~14 print sites rendered caller bytes unsanitised.~~
+**Closed 2026-08-02.** Only `denials.rs` escaped them, and it is the only one
+whose input comes from the post-`execve` child — every other site took bytes
+from the *caller*, which the threat model does not treat as trusted. A forged
+`SEALED` line was injected **through argv alone** under `--profile balanced`,
+with no policy file at all, and ANSI escapes passed through unchanged.
+
+**Mechanism.** `crate::diag::safe` is now the single implementation, applied at
+two levels. `OringError`'s `Display` is a chokepoint covering every refusal
+message and every future one by construction, rather than depending on each
+`OringError::policy(format!(...))` author to remember. The warning and info
+sites that interpolate caller data — skipped policy paths, a granted tier
+holding a socket, tier overlap, `--exec-tmp`, and `$TMPDIR` — call it directly.
+Escaping happens at render, not at construction, so messages stay matchable by
+tests and only the terminal sees the escaped form.
+
+**Measured.** `tests/diag_injection.rs` drives `ESC[2K\r` plus a plausible
+`SEALED` line through argv and through `$TMPDIR`, and asserts no raw `ESC` or
+`CR` reaches stderr — with a guard that the payload appears *escaped* rather
+than merely absent, so a cell cannot pass over output that never contained the
+argument. Reverting either sanitiser reopens its own path and turns exactly that
+test red.
+
+**Not established.** Escaping control characters does not make the output
+unspoofable: a path that is long, right-to-left, or a homoglyph of another path
+is still misleading and is not addressed. Nor does it constrain the **child**,
+which shares stderr after the seal and can print anything — that residual is
+documented above and unchanged. What closed is forgery by the *caller*, before
+the child exists.
+
+**[loss: MISREPORT · disclosure: **silent** — the forged line is content-indistinguishable from the real one]**
+
+## Residual — `panic = "abort"` is `[profile.release]` only
+
+**Claim.** "A parse bug fails closed" holds for the shipped binary and not for
+debug or test builds, which unwind. Stated because `DESIGN.md` §12's fuzzing
+decision rests on it.
+
+**[loss: BREACH · disclosure: documented-only]**
+
+## Residual — the under-coverage disclosure is discardable by the invoker
+
+**Claim.** `unhandled=` is the only seal-line field reporting what the run left
+**unrestricted**, and it is written with `let _ = writeln!(stderr, …)`.
+
+**Measured** 2026-07-29 on the shipped `balanced` profile at V10, which leaves
+`net.BindUdp,net.ConnectSendUdp` unhandled: with fd 2 piped the line is present;
+with `2>/dev/null` **zero** disclosure lines appear, the seal proceeds and the
+child exits 0. `ORING_QUIET` is refused (`tests/udp_wall.rs`) while a shell
+redirect is unmediated — the same channel, two suppression routes, one guarded
+and one not. **The write to `/dev/null` succeeds**, so no error check inside
+oring can detect the case.
+
+**Mechanism.** Structural at this layer, established rather than assumed: the
+audit transport `src/denials.rs` uses is a *consumer* of kernel-emitted Landlock
+records (types 1423/1424) read via `journalctl`. oring has no producer side,
+`AUDIT_USER_MSG` needs `CAP_AUDIT_WRITE` that an unprivileged tool lacks, and
+`/dev/log` would be a new channel opened at step 11 when oring is **already
+sealed** (step 6) — grantable only by the very policy under audit. oring
+additionally blocks `SYS_syslog`. Pinned by
+`tests/residual_boundary.rs::the_under_coverage_disclosure_is_discardable_by_the_caller`.
+
+**Control.** `--require-full-coverage`, an opt-in that turns any unhandled right
+into refusal code 120 before `execve` — an exit code is the one signal a redirect
+cannot remove. Measured: it refuses `balanced` at V10, seals a policy naming both
+UDP directions (`unhandled=-`), and still returns 120 with stderr discarded. It
+is not the default because that would refuse every default run today, **so the
+residual stands for any run without the flag**, which is every default run.
+
+**Not established.** The product question is open and is not decided here: `D-4`
+rejects the degraded-path-with-a-warning pattern everywhere else, and
+`unhandled=` is that pattern surviving in the one place it is load-bearing for
+auditing.
+
+**[loss: MISREPORT · disclosure: documented-only — this is the only place it is written down, and no instrument reports that a disclosure was discarded]**
+
+---
+
+**What follows from having this list at all.** **Seven of the eight** residuals
+in this section are `silent` or `documented-only`: **an operator running oring
+cannot discover from the run that any of them is the case.** That is the honest
+cost of exec-and-vanish — there is no surviving component to attest to its own
+integrity — and it is `open-questions.md` Q10 arriving from a third direction.
+(`open-questions.md Q` is a work-item handle on the same footing as the
+`BUILD-QUEUE.md` T-numbers: the file is the maintainer's and is not in this
+repository, so the question is stated here or it is not available to you.)
+
+---
+
+## Non-goals
+
+1. **Not a VM/hypervisor boundary.** Shared host kernel. Kernel-isolation-critical
+ workloads (a Xen fuzzing rig) need a VM underneath.
+2. **Not a kernel-0-day defence** beyond surface reduction.
+3. **Not a network policy engine.** Domain/IP egress is host nftables
+ default-deny or a filtering proxy — a **required companion control**
+ (`DESIGN.md` §9), stated loudly.
+4. **Does not author or load host MAC.** SELinux/AppArmor stay the host's job and
+ stack underneath (intersection).
+5. **Does not mask procfs/sysfs.** That is a runtime `maskedPaths` / mount-view
+ job. Pathname unix-socket connect *is* mediated at V9 via `RESOLVE_UNIX`
+ (`DESIGN.md` §4).
+6. **No supervisor, seccomp-notify, OCI-hook validation, egress firewall,
+ credential broker, cgroups or rlimits.** Each needs a persistent or privileged
+ component oring by definition is not.
diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md
index 474e6ae..061ca68 100644
--- a/docs/VERIFICATION.md
+++ b/docs/VERIFICATION.md
@@ -11,6 +11,8 @@ oring **builds the filter/ruleset it intends**, exhaustively, where the
| Property | Artifact | Tool | Scope |
|----------|----------|------|-------|
| **x32 guard is TOTAL** — `eval == KILL_PROCESS iff arch != AUDIT_ARCH_X86_64 \|\| (nr & X32_SYSCALL_BIT)`, else `ALLOW` | `seccomp::x32_guard_total` | **Kani** (bounded model check on the real Rust) | proven over **all 2⁶⁴** `(arch, nr)`; matrix samples 1 cell |
+| **ABI clamp never over-claims** — `abi_from_version` never names an ABI the linked crate cannot handle, clamps at/above the ceiling *to* the ceiling (never to a lower/empty set), and maps each in-range version to exactly its own ABI | `landlock::abi_from_version_clamp` | **Kani** | proven over **every `i32`**; the unit test spot-checks 10 values |
+| **Filter is truncation-invariant** — the emitted cBPF cannot change verdict on a truncated/short read of the `seccomp_data` high bits (the H2 regression class) | `tests/seccomp_smt.rs` | **z3 SMT** over the emitted program | both shipped profiles + a positive control that must detect an injected high-bit dependency |
| tier masks: `write_noexec` never grants `Execute`; no tier grants device-node creation; `wx == wn \| Execute` lattice | `landlock::tests::tier_invariants` | `#[test]` (finite `AccessFs` flag domain) | exhaustive by construction (const-eval, not FV) |
The x32 guard is the one genuinely proof-shaped object in the tree: a frozen,
@@ -20,13 +22,38 @@ sampled test reliably catches.
## Running the proofs
-Opt-in — **not** in CI (Kani is minutes/harness):
+Kani is opt-in — **not** in CI, so `make verify` is its only gate. The SMT gate
+*is* in CI (`make smt`) and needs only `z3`, no Landlock kernel.
+
+**"minutes per harness" was wrong and it is the reason this stayed unfixed.**
+Measured 2026-07-28 on 7.2.0-rc4: `x32_guard_total` **0.09–0.11 s** of solver
+time, `abi_from_version_clamp` **0.016–0.024 s**; `make verify` end to end
+**2.1 s warm, 4.4–4.6 s cold**, and **4.9 s pinned to 4 CPUs** — a
+runner-shaped control showing the figure is not core-count sensitive. The
+estimate overstated the cost by roughly two orders of magnitude, and every
+downstream document inherited it (a conformance rubric carried "~5–10 min"
+and "1 d" for the same work, both unmeasured).
+
+The real cost is the **install**, not the run: Kani vendors its own pinned
+nightly — demonstrated to coexist with this repo's `rust-toolchain.toml`, since
+`make verify` runs green here — and `~/.kani` is ~483 MB. The harnesses need no
+musl target, no `z3`, no Landlock kernel and no KVM, and they build with the
+feature set `lint-build`'s clippy step already compiles. So the shape is: cache
+`~/.kani`, `cargo install --locked kani-verifier@`, run `make verify` in
+`lint-build` — **not** in `confinement-v10`, whose cost is a kernel build the
+proofs do not need. Pin the version: an unpinned bump changes the vendored
+nightly and can change what `#[kani::unwind(9)]` covers.
```sh
-make verify # cargo kani --harness x32_guard_total
-cargo test --lib # the tier_invariants + interpreter corner checks (fast, in the normal suite)
+make verify # cargo kani: x32_guard_total AND abi_from_version_clamp
+cargo test --lib # tier_invariants + interpreter corner checks (fast, normal suite)
+ORING_REQUIRE_SMT=1 cargo test --features bpf-dump --test seccomp_smt # z3 gate
```
+`ORING_REQUIRE_SMT=1` makes a missing `z3` fatal rather than a silent skip — the
+same never-silent-green contract `ORING_REQUIRE_KERNEL=1` provides for the
+kernel-enforced suites.
+
## Explicitly NOT verified (out of scope by construction)
- **"The cage holds."** The Landlock LSM and the kernel seccomp BPF VM are the
@@ -38,3 +65,156 @@ cargo test --lib # the tier_invariants + interpreter corner checks (fast, in
`SeccompFilter → BpfProgram` compilation is Firecracker's TCB, not re-proven.
- **W^X as a whole / pipeline ordering.** Completeness assertions over the kernel
syscall surface and integration properties — tests, not theorems.
+- **Which layer denied — for the MATRIX.** `tests/containment_matrix.rs` asserts an
+ expected errno per cell; it does not establish that *oring* produced the denial.
+ On a host with a second MAC enforcing (SELinux/AppArmor), or where DAC already
+ forbids the operation, a deny cell can pass on the other layer's authority. A
+ manual bare-vs-confined control pass (2026-07-24) found every deny cell
+ attributable except `shmat-exec`, which denies `EACCES` unconfined on that host —
+ but the matrix harness does not perform that control itself, and the last full
+ run was on a SELinux-enforcing host. Treat a green matrix there as necessary,
+ not sufficient. See the limitation note at the top of that file.
+
+ **Control pass re-run 2026-07-28 on 7.2.0-rc4, SELinux Enforcing.** All **20**
+ cells that assert a denial (19 `Expect::Deny` + the one `Expect::Kill`) — **21 of 30 as of 2026-07-29**, after two in-domain PROC cells landed (T19); the pass below covered the 20 that existed then were
+ probed bare by running the harness's own payload — `oring __probe `,
+ debug build — with no wrapper, so the seal is the only variable. Result:
+ **14 succeed outright unconfined** (the strongest form of attribution),
+ **5 return a distinct errno**, and **exactly one returns the errno the cell
+ asserts**.
+
+ **That one is `fs-write-readonly`** — it writes `/etc/oring-matrix`, which DAC
+ already forbids uid 1000, so it is `EACCES` bare and `EACCES` sealed. It
+ establishes nothing about oring. **Demonstrated, not inferred:** under a mutant
+ that grants every handled FS right on `/` (ring 1's FS layer stops enforcing
+ entirely), this cell still reports `PASS` while `wx-noexec-tier` correctly goes
+ `FAIL`. **Fixed and pinned (2026-08-02).** The cell no longer targets `/etc`: it writes
+ into a harness-owned `read_only` grant that DAC permits, so the bare arm
+ succeeds and the denial is attributable, with a companion read cell proving the
+ grant is live. **The fixture also exists now:** every denial cell runs bare
+ before it runs sealed, and a cell whose bare arm satisfies the sealed
+ expectation is reported `UNATTRIBUTABLE` and must be declared, with the
+ declaration checked in both directions. What that closes is the *rot*: the
+ attribution was a hand-check on two dates, and a cell added after the last one
+ went uncovered. What it does **not** close is which layer produced an attributed
+ denial — bare-succeeds shows the environment does not supply the denial by
+ itself, not that ring 1 rather than ring 2 produced it. Per-cell table:
+ `deep_research/notes/2026-07-28-t21-control-pass-vetted.md`.
+
+ **CORRECTED 2026-07-28 — the first write-up of this re-run was wrong three
+ ways, and the corrections matter more than the result.**
+
+ 1. It said "all 19 deny cells (20 at the time; **21 of 30** since the T19 in-domain cells)". There are **20**; `19` is the count of the
+ `Expect::Deny` enum variant, and the same paragraph listed the `Kill` cell
+ among those probed. The count was taken from the type, not from the
+ property "asserts a denial".
+ 2. It said ten cells succeed unconfined and "the rest return a distinct
+ errno". Measured: **14**. The four IPC cells (`unix-connect{,-abstract}`
+ × 2 profiles) succeed bare; they do not return a distinct errno. The
+ likeliest cause is an unfaithful first control — a bare `unix-connect`
+ against an unbound path fails `ENOENT`, which reads as "distinct errno" and
+ **overstates** how much the cell proves. It cannot be diagnosed now,
+ because that pass retained no data.
+ 3. It said "the exception has moved" and drew the lesson that a dated claim
+ ages in its *content*. **That mechanism is fabricated.** The `shmat-strict`
+ row has asserted `EPERM` since `9d1c272` (initial release) and has never
+ changed, and the 07-24 note itself records `EACCES` bare — which is
+ *distinguishable*, by the predicate stated in that same note. Nothing aged.
+ The 07-24 conclusion did not follow from its own evidence when it was
+ written, and today's re-run reproduces its **observation** exactly. A
+ re-run that disagrees with the record must separate "the world changed"
+ from "the earlier inference was wrong"; this one assumed the first and
+ published the flattering story.
+
+ What survives is the narrower lesson: an exception recorded as a **name**
+ (`shmat-exec`) rather than as a **property** — *a cell whose asserted errno
+ equals the errno the operation already returns unconfined* — cannot be
+ re-checked mechanically, so neither the original error nor its restatement was
+ catchable by reading. Attribution is a per-run computation, and until the
+ harness performs it (G19) any prose here is a snapshot that no gate defends.
+
+ `tests/protocol_wall.rs` **does** perform it, and that is why it is a separate
+ file rather than more matrix rows. `assert_oring_refused` runs each triple BARE
+ first and skips loudly when the kernel refuses it anyway — without which the SCTP
+ cell passed on a `localmodconfig` kernel with no `sctp.ko`, satisfied entirely by
+ the kernel's own `EPROTONOSUPPORT`. Its complement,
+ `assert_oring_changed_the_errno`, covers the cases where the kernel also refuses
+ but with a *distinguishable* errno, and fails loudly if the bare errno is already
+ `EAFNOSUPPORT` — because then the two causes are indistinguishable and the cell
+ would establish nothing.
+
+## The claim table is mutation-audited (2026-07-27)
+
+Every row of `docs/ATTACKS.md` was audited by removing the **enforcement
+mechanism** it depends on and recording which tests die — eight mutations
+covering all twenty rows.
+
+**The breakdown, stated rather than summed to a single number.** All 20 cited
+tests exist; there are no phantom citations. Of the 20 rows: **16 die to a
+mutation of their own mechanism** (15 at the 07-27 audit, plus row 9 as of
+2026-07-28); **3 are `(control)` allow-assertions with no mutation run against
+them** (`3d`, `3f`, `4`); **1 is not mutable from oring at all** (row 12).
+
+*This replaces "19 of 20 rows are backed by a test that provably fails when its
+mechanism is removed", which never reconciled with the table above it.* That
+count reached 19 only by treating row 12 — which the very next bullet calls
+*measured, not mutation-backed* — and three controls that had no mutation run
+as backed. The sentence was self-inconsistent before row 9 was fixed, so the
+repair is not to increment it.
+
+- **Row 9 (x32 re-entry) was the failure. Fixed 2026-07-28, and the first fix
+ was not enough.** The mutant is `JSET` on `X32_SYSCALL_BIT` swapped for `JEQ`
+ on `X32_SYSCALL_BIT | 39`, allowing every x32 syscall except `getpid`; it left
+ the whole suite green and `make check` at rc=0, SMT included — not one cell went red.
+ - `22f6cca` added a second process cell at a second number. An adversarial
+ mutation sweep then measured that cell's marginal detection power as **1
+ mutant out of 89** — the one it was written against — and found two that beat
+ it: `i4.k := 0xC0000000` survived the **entire** gate, and
+ `i4 := JGT X32_SYSCALL_BIT|38` leaked x32 `read`, `write`, `open`, `mmap`,
+ `mprotect` and `ioctl` **with both cells green**, because both probed numbers
+ sit above the threshold. Probing more numbers cannot close this: the dominant
+ leak family is monotone and always spares the numbers a cell names.
+ - `e53cd67` closes it at the model instead —
+ `x32_guard_matches_its_spec_over_a_dense_table` asserts the emitted program
+ equals its specification over 12,354 points, killing all 89 enumerated
+ mutants and all 79 leaky survivors in microseconds with no kernel. It runs
+ where the process cells take `require_or_skip` and count green.
+ - **A note on the SMT gate, corrected here because the error propagated.** The
+ claim "SMT included" is true of the *gate* and misleading about *coverage*:
+ `tests/seccomp_smt.rs` consumes `dump_groups()`, which compiles the four
+ errno groups only. **The hand-assembled x32 guard is not in the SMT model at
+ any number** — it is out of scope, not narrowly scoped.
+- **Row 12 is not mutable from oring.** Its denier is
+ `hook_ptrace_access_check`, which consults no handled access right. The row is
+ *measured*, not *mutation-backed*; a kernel change to `domain_scope_le()`
+ would retire the guarantee with no signal in this tree.
+- **`containment_matrix` is one test for 30 cells**, so a mutation that kills it
+ does not say which cell caught it. Coverage is proven; attribution inside the
+ matrix is not.
+
+Run any of these with `--no-fail-fast`. Without it `cargo test` stops after the
+first failing binary, and a mutation that kills six tests reports one.
+
+Full method and per-row table:
+`deep_research/notes/2026-07-27-guarantee-audit.md`.
+
+## Practice: a green test is not evidence until it can go red
+
+Every refusal-shaped check added since 2026-07-26 is **mutation-verified** — the
+check is stubbed out and the suite re-run, and the change is only accepted if
+exactly the intended cells go red while the clean/warn cells stay green.
+
+This is not ceremony. It has caught real defects in the tests themselves:
+
+- `inet_socketpair_is_refused_under_strict` stayed GREEN with the type rule
+ removed, because every cell in it used `IPPROTO_SCTP` — already blocked by the
+ *protocol* rule, so the test never exercised the axis it was written for.
+ Protocol-0 cells were added and it now fails as intended.
+- `partial_net_restriction_is_disclosed_by_name` was re-run against the ORIGINAL
+ `abi > CRATE_MAX_ABI` predicate to confirm it catches the historical bug rather
+ than a paraphrase of it.
+
+The same discipline applies to skips: `ORING_REQUIRE_KERNEL=1`,
+`ORING_REQUIRE_SMT=1` and `ORING_REQUIRE_UDP=1` each turn a would-be skip into a
+hard failure, because `ok. 0 passed` reads green and `SKIP` goes to stderr, which
+cargo discards.
diff --git a/scripts/check-commit-msg.sh b/scripts/check-commit-msg.sh
new file mode 100755
index 0000000..80cdf61
--- /dev/null
+++ b/scripts/check-commit-msg.sh
@@ -0,0 +1,57 @@
+#!/bin/sh
+# SPDX-License-Identifier: BSD-3-Clause
+#
+# Refuse a commit message carrying host-specific detail.
+#
+# WHY THIS EXISTS, measured 2026-08-01: a commit on the release branch carried
+# the maintainer's real home directory on four lines of its body, pasted in with
+# a measurement transcript. It was caught by a review, one commit before the
+# branch would have been pushed. Commit messages publish and cannot be
+# unpublished — a host rewrites nothing, and a later reword leaves the original
+# object fetchable by SHA — so this is a class of defect with no remedy after
+# the fact, only prevention before it.
+#
+# Scope, stated because a guard's frame is the thing to check: this reads ONE
+# message, the one being written. It says nothing about messages already in the
+# history. `make check-history` is the sweep over every ref.
+#
+# Usage: check-commit-msg.sh (git passes the message path)
+# Exit: 0 clean · 1 host detail found · 2 could not read the message
+
+set -eu
+
+msg="${1:-}"
+[ -n "$msg" ] && [ -r "$msg" ] || {
+ echo "check-commit-msg: no readable message at '${msg}'. Exit 2 — nothing was" >&2
+ echo " examined, and that is not the same as nothing being wrong." >&2
+ exit 2
+}
+
+# Strip comment lines: git's template explains itself in them and they never ship.
+body=$(grep -v '^#' "$msg" || true)
+
+# Absolute home directories of real users. `/home/u` and `/home/x` are the
+# synthetic paths this project's documentation uses in examples, so they are
+# allowed by being too short to be a real login here — the pattern requires 3+.
+hits=$(printf '%s\n' "$body" | grep -nE '/home/[a-z_][a-z0-9_-]{2,}|/Users/[a-z_][a-z0-9_-]{2,}' || true)
+
+# Machine names that identify the maintainer's hosts. Word-bounded: an
+# unbounded 'xtra' matches 'extra', which cost a review a false positive over
+# twelve files before it was caught.
+hits="${hits}$(printf '%s\n' "$body" | grep -nwE 'xtal|xtra' || true)"
+
+if [ -n "$hits" ]; then
+ cat >&2 < …` — see `bench help`. Driven by `bench/run.sh`,
+//! which supplies the pinning, the rounds, and the controls; every subcommand
+//! here measures exactly one term and prints exactly one line.
+//!
+//! # Why this is Rust and in-tree rather than a scratch C/python fixture
+//!
+//! Not taste. `make check` runs `cargo clippy --all-targets`, so a binary in the
+//! package is COMPILED by the everyday gate and cannot silently rot as the code
+//! it measures moves — which a scratch harness does the first time an errno
+//! group changes shape. It also calls [`oring::seccomp::dump_groups`] directly,
+//! so the programs timed here are the programs `seccomp::apply` installs, with
+//! no JSON round-trip and no generated C to regenerate. `tests/seccomp_smt.rs`
+//! made the same call for the same reason.
+//!
+//! Everything here is safe Rust: filters are installed through `seccompiler`'s
+//! own `apply_filter`, `NO_NEW_PRIVS` through `oring::preexec`. `unsafe_code` is
+//! denied package-wide and this binary needs no exception.
+//!
+//! # What it deliberately does NOT do
+//!
+//! No averaging across rounds, no "best of" hidden inside a subcommand, no
+//! warm-up it does not announce. Each invocation reports one number plus the
+//! frame that number is only meaningful inside (n, path, filter count). The
+//! interleaving and the min-of-rounds live in the driver where they are visible.
+
+// Pedantic exceptions, argued rather than silenced wholesale:
+// - the casts are iteration counts and elapsed nanoseconds moving between
+// integer and float. At the magnitudes here (n <= 3e6, us <= 1e6) f64 is
+// exact, and a benchmark that refused to divide would be a worse instrument.
+// - `unnecessary_wraps`: every subcommand returns `Result<(), String>` so
+// `main`'s dispatch is uniform and a future failure mode does not change the
+// signature. Two of them cannot currently fail; that is not worth an
+// asymmetry in the table above.
+#![allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
+#![allow(clippy::cast_sign_loss, clippy::unnecessary_wraps)]
+
+use std::cmp::Ordering;
+use std::hint::black_box;
+use std::process::{Command, ExitCode, Stdio};
+use std::time::Instant;
+
+#[cfg(feature = "bpf-dump")]
+use seccompiler::{apply_filter, sock_filter, BpfProgram};
+
+const USAGE: &str = "\
+bench — what oring costs, measured (analysis only; see bench/run.sh)
+
+ bench syscalls ns per syscall (getpid), for the ring-2 steady state
+ bench opens us per open+close, for the ring-1 steady state
+ bench paths the primitives the per-path seal cost is built from
+ bench exec us per fork+exec, with the child's exit status CHECKED
+ bench install [--strict] us to install each errno filter (the per-invocation term)
+ bench filters [--strict] install the first k filters, then the syscall loop
+ bench sim [--strict] how far an allowed syscall walks the emitted cBPF
+
+Run the whole sweep, with controls and pinning: bench/run.sh";
+
+fn main() -> ExitCode {
+ let argv: Vec = std::env::args().collect();
+ let sub = argv.get(1).map_or("help", String::as_str);
+ let rest: Vec<&str> = argv.iter().skip(2).map(String::as_str).collect();
+ let strict = rest.contains(&"--strict");
+
+ let r = match sub {
+ "syscalls" => arg_u64(&rest, 0).and_then(syscalls),
+ "opens" => match (rest.first(), arg_u64(&rest, 1)) {
+ (Some(p), Ok(n)) => opens(p, n),
+ _ => Err("usage: bench opens ".to_string()),
+ },
+ "paths" => match (rest.first(), arg_u64(&rest, 1)) {
+ (Some(d), Ok(n)) => paths(d, n),
+ _ => Err("usage: bench paths ".to_string()),
+ },
+ "exec" => exec_bench(&rest),
+ "install" => install(strict),
+ "filters" => match (arg_u64(&rest, 0), arg_u64(&rest, 1)) {
+ (Ok(k), Ok(n)) => filters(k as usize, n, strict),
+ _ => Err("usage: bench filters [--strict]".to_string()),
+ },
+ "sim" => sim(strict),
+ "help" | "-h" | "--help" => {
+ println!("{USAGE}");
+ return ExitCode::SUCCESS;
+ }
+ other => Err(format!("unknown subcommand `{other}`\n\n{USAGE}")),
+ };
+
+ match r {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(e) => {
+ eprintln!("bench: {e}");
+ ExitCode::from(2)
+ }
+ }
+}
+
+fn arg_u64(rest: &[&str], i: usize) -> Result {
+ rest.get(i)
+ .ok_or_else(|| "missing numeric argument".to_string())?
+ .parse()
+ .map_err(|e| format!("bad numeric argument: {e}"))
+}
+
+// --- steady state ----------------------------------------------------------
+
+/// ns per syscall. `getpid` on purpose: it is the cheapest real syscall, so any
+/// seccomp cost shows up as the largest possible FRACTION — measuring against an
+/// expensive syscall would flatter the result. glibc has not cached `getpid`
+/// since 2.25 and musl never did, so `process::id()` is one real syscall.
+///
+/// The warm-up loop is announced rather than hidden: first-touch faults and the
+/// CPU's frequency ramp belong outside the measured window, and a reader has to
+/// be able to see that choice.
+fn syscalls(n: u64) -> Result<(), String> {
+ let mut acc: u32 = 0;
+ for _ in 0..200_000u64 {
+ acc ^= std::process::id();
+ }
+ black_box(acc);
+ let t = Instant::now();
+ for _ in 0..n {
+ acc ^= std::process::id();
+ }
+ let el = t.elapsed();
+ black_box(acc);
+ println!(
+ "{:.2} ns/syscall (getpid, n={n})",
+ el.as_secs_f64() * 1e9 / n as f64
+ );
+ Ok(())
+}
+
+/// us per `open`+`close`. Landlock mediates the path WALK, so its cost lands
+/// here and not on the syscall entry ring 2 taxes — measured, this term is ~30x
+/// the per-syscall one.
+///
+/// Bounded claim, and the bound matters: warm dentry cache, one path shape, one
+/// filesystem. Run it under seals with different RULE COUNTS to test whether the
+/// cost scales with the ruleset.
+fn opens(path: &str, n: u64) -> Result<(), String> {
+ for _ in 0..2_000u64 {
+ drop(std::fs::File::open(path));
+ }
+ let t = Instant::now();
+ for _ in 0..n {
+ // A failure here is the interesting case under a seal: the policy did
+ // not grant the path, so there is nothing to time. Refuse to print a
+ // number for a loop that measured EACCES.
+ let f = std::fs::File::open(path).map_err(|e| format!("open {path}: {e}"))?;
+ drop(black_box(f));
+ }
+ let el = t.elapsed();
+ println!(
+ "{:.3} us/open+close ({path}, n={n})",
+ el.as_secs_f64() * 1e6 / n as f64
+ );
+ Ok(())
+}
+
+/// The primitives the seal's per-path cost is built from.
+///
+/// `add_tier` pays one `metadata` and one `PathFd::new` per declared path, and
+/// the pre-seal guards each pay a `canonicalize`: every declared path is
+/// canonicalised in `apply_proc_mode`, again in `check_tier_overlap`, again in
+/// `proc_exposure`, and 5x per WRITE path in `writable_shared_caches` (the
+/// canonicalise sits inside the `SHARED_CACHES` loop). This prices those calls
+/// so the per-path term can be attributed rather than guessed at.
+///
+/// `File::open` stands in for `PathFd`'s `O_PATH` open, which std cannot express.
+/// `O_PATH` is the cheaper of the two, so this row is an UPPER bound on that
+/// component — stated because an upper bound used as an estimate is how a cost
+/// model drifts.
+fn paths(dir: &str, n: u64) -> Result<(), String> {
+ let one = |label: &str, f: &dyn Fn() -> bool| {
+ for _ in 0..5_000u64 {
+ black_box(f());
+ }
+ let t = Instant::now();
+ for _ in 0..n {
+ black_box(f());
+ }
+ println!(
+ " {label:<20} {:.3} us",
+ t.elapsed().as_secs_f64() * 1e6 / n as f64
+ );
+ };
+ one("canonicalize", &|| std::fs::canonicalize(dir).is_ok());
+ one("metadata", &|| std::fs::metadata(dir).is_ok());
+ one("File::open+drop", &|| std::fs::File::open(dir).is_ok());
+ println!(" (n={n} each, {dir}; File::open is an UPPER bound on PathFd's O_PATH open)");
+ Ok(())
+}
+
+// --- per invocation --------------------------------------------------------
+
+/// us per fork+exec, over `N` runs, **with the child's exit status checked
+/// against `expected`**.
+///
+/// The check is the point of this harness, not a courtesy. The first draft of
+/// this sweep reported `oring --profile strict` as FASTER than the bare command
+/// — that invocation exited 121 (`--workdir must be absolute`) with stderr on
+/// /dev/null, so it never sealed and never exec'd. A clean-looking fast number
+/// over a command that did not run. Any disagreement voids the whole run: the
+/// timing line is not printed at all, because a number printed beside a warning
+/// gets quoted without it.
+fn exec_bench(rest: &[&str]) -> Result<(), String> {
+ let n = arg_u64(rest, 0)?;
+ let want: i32 = rest
+ .get(1)
+ .ok_or_else(|| "usage: bench exec ".to_string())?
+ .parse()
+ .map_err(|e| format!("bad expected status: {e}"))?;
+ let cmd = rest.get(2..).unwrap_or(&[]);
+ let (prog, args) = cmd
+ .split_first()
+ .ok_or_else(|| "usage: bench exec ".to_string())?;
+
+ let mut t = Vec::with_capacity(n as usize);
+ let mut bad = 0u64;
+ let mut first_bad = None;
+ for _ in 0..n {
+ let start = Instant::now();
+ let st = Command::new(prog)
+ .args(args)
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .status()
+ .map_err(|e| format!("spawn {prog}: {e}"))?;
+ t.push(start.elapsed().as_secs_f64() * 1e6);
+ let got = st.code().unwrap_or(-1);
+ if got != want {
+ bad = bad.saturating_add(1);
+ first_bad.get_or_insert(got);
+ }
+ }
+ if bad > 0 {
+ return Err(format!(
+ "VOID — {bad} of {n} runs exited {:?}, expected {want}. `{prog}` did not do what \
+ this measurement assumed, so the timing is not reported.",
+ first_bad.unwrap_or(-1)
+ ));
+ }
+ t.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
+ let at = |q: f64| {
+ t.get(((t.len() as f64) * q) as usize)
+ .copied()
+ .unwrap_or(0.0)
+ };
+ println!(
+ "min={:.0} p50={:.0} p90={:.0} us (n={n}, all exited {want})",
+ t.first().copied().unwrap_or(0.0),
+ at(0.5),
+ at(0.9)
+ );
+ Ok(())
+}
+
+/// Convert one dumped group to the form `apply_filter` installs.
+#[cfg(feature = "bpf-dump")]
+fn to_program(insns: &[(u16, u8, u8, u32)]) -> BpfProgram {
+ insns
+ .iter()
+ .map(|&(code, jt, jf, k)| sock_filter { code, jt, jf, k })
+ .collect()
+}
+
+/// Time each `seccomp(SET_MODE_FILTER)` install.
+///
+/// This is the term `docs/DESIGN.md` §5 did not name and the one that dominates:
+/// measured 2026-07-30, ~316 us for the four `balanced` groups, against ~628 us
+/// of total added per-invocation cost. The process is sealed when this returns —
+/// it is a one-shot measurement by construction, which is also why the install
+/// order here is the install order in `seccomp::apply` (the EPERM group blocks
+/// `seccomp` itself and must go last).
+#[cfg(feature = "bpf-dump")]
+fn install(strict: bool) -> Result<(), String> {
+ oring::preexec::set_no_new_privs().map_err(|e| format!("NO_NEW_PRIVS: {e}"))?;
+ let groups = oring::seccomp::dump_groups(strict).map_err(|e| format!("dump_groups: {e}"))?;
+ let mut total = 0f64;
+ for g in &groups {
+ let prog = to_program(&g.insns);
+ let t = Instant::now();
+ apply_filter(&prog).map_err(|e| format!("install {}: {e}", g.name))?;
+ let us = t.elapsed().as_secs_f64() * 1e6;
+ println!(
+ " install {:<14} {:>4} insns {us:>8.1} us",
+ g.name,
+ g.insns.len()
+ );
+ total += us;
+ }
+ println!(
+ " {:<22} {:>10} {total:>8.1} us ({} filters, {} insns)",
+ "TOTAL",
+ "",
+ groups.len(),
+ groups.iter().map(|g| g.insns.len()).sum::()
+ );
+ Ok(())
+}
+
+/// Install the first `k` filters, then run the syscall loop.
+///
+/// The sweep k=0..4 is what separates "seccomp ENTRY costs 5 ns" from "each
+/// filter costs 5 ns". Measured, it is entirely the former: the step is at k=1
+/// and filters 2..4 are free, so the errno partition costs nothing at runtime
+/// even though an allowed syscall walks ~215 instructions across the stack.
+/// Nothing but `k` varies between arms, which is why this exists instead of
+/// comparing seals.
+#[cfg(feature = "bpf-dump")]
+fn filters(k: usize, n: u64, strict: bool) -> Result<(), String> {
+ let groups = oring::seccomp::dump_groups(strict).map_err(|e| format!("dump_groups: {e}"))?;
+ if k > groups.len() {
+ return Err(format!("k must be 0..={}", groups.len()));
+ }
+ if k > 0 {
+ oring::preexec::set_no_new_privs().map_err(|e| format!("NO_NEW_PRIVS: {e}"))?;
+ }
+ for g in groups.iter().take(k) {
+ apply_filter(&to_program(&g.insns)).map_err(|e| format!("install {}: {e}", g.name))?;
+ }
+ print!("filters={k} ");
+ syscalls(n)
+}
+
+// --- the emitted filter, without a kernel ----------------------------------
+
+/// How far an allowed syscall walks the emitted cBPF.
+///
+/// A MODEL of the filter, not a timing: it needs no kernel and no root and
+/// answers "how long is the scan" exactly, but 215 instructions and the +5 ns
+/// `filters` measures do not reconcile arithmetically — the kernel JITs the
+/// program and the real cost turns out to be seccomp entry. Use this to reason
+/// about filter SHAPE and `filters` to reason about time.
+///
+/// The miss path is what matters: a syscall oring does not block must fail every
+/// comparison, so for allowed syscalls the common case is the worst case of a
+/// linear scan and it grows with the denylist.
+///
+/// Args are all zero, which is right for the miss path (no argument predicate
+/// can match) and wrong for reading a hit's depth. The boundary is printed with
+/// the result for that reason. Relative to `seccomp::eval_cbpf`, which covers
+/// the 7-instruction x32 guard for the Kani proof, this interprets the wider
+/// subset seccompiler emits.
+#[cfg(feature = "bpf-dump")]
+// A spread a real child actually makes, so the table is not just an aggregate.
+const HOT: &[(&str, u32)] = &[
+ ("read", 0),
+ ("write", 1),
+ ("close", 3),
+ ("mmap", 9),
+ ("mprotect", 10),
+ ("ioctl", 16),
+ ("clone", 56),
+ ("execve", 59),
+ ("futex", 202),
+ ("getdents64", 217),
+ ("clock_gettime", 228),
+ ("openat", 257),
+ ("newfstatat", 262),
+];
+
+#[cfg(feature = "bpf-dump")]
+fn sim(strict: bool) -> Result<(), String> {
+ const SWEEP: u32 = 470; // x86_64 syscall table fits under this — printed as the frame.
+ let groups = oring::seccomp::dump_groups(strict).map_err(|e| format!("dump_groups: {e}"))?;
+
+ println!(
+ "boundary: profile={}, {} filters, syscall nrs 0..{}, all six args = 0, arch = x86_64",
+ if strict { "strict" } else { "balanced" },
+ groups.len(),
+ SWEEP - 1
+ );
+ let mut total = 0usize;
+ for g in &groups {
+ println!(
+ " {:<14} errno={:<3} syscalls={:>3} program={:>4} insns",
+ g.name,
+ g.errno,
+ g.nrs.len(),
+ g.insns.len()
+ );
+ total += g.insns.len();
+ }
+ println!(
+ " {:<14} {:>28} program={total:>4} insns across the stack",
+ "TOTAL", ""
+ );
+
+ let mut per = Vec::with_capacity(SWEEP as usize);
+ for nr in 0..SWEEP {
+ let mut sum = 0usize;
+ for g in &groups {
+ sum += eval(&g.insns, nr)?;
+ }
+ per.push(sum);
+ }
+ let sum: usize = per.iter().sum();
+ println!(
+ "\nexecuted per syscall across all {} filters: min {} mean {:.1} max {}",
+ groups.len(),
+ per.iter().min().copied().unwrap_or(0),
+ sum as f64 / per.len() as f64,
+ per.iter().max().copied().unwrap_or(0)
+ );
+
+ println!("\n syscall nr executed (per filter)");
+ for &(label, nr) in HOT {
+ let each: Result, String> = groups.iter().map(|g| eval(&g.insns, nr)).collect();
+ let each = each?;
+ let parts: Vec = each.iter().map(ToString::to_string).collect();
+ println!(
+ " {label:<16} {nr:<4} {:>8} ({})",
+ each.iter().sum::(),
+ parts.join("+")
+ );
+ }
+ Ok(())
+}
+
+/// Interpret one cBPF program for syscall `nr` with zero args; returns the
+/// number of instructions executed. Total by construction: any opcode
+/// seccompiler does not emit is an error, never a silent count.
+#[cfg(feature = "bpf-dump")]
+fn eval(insns: &[(u16, u8, u8, u32)], nr: u32) -> Result {
+ const AUDIT_ARCH_X86_64: u32 = 0xc000_003e;
+ let mut mem = [0u32; 16];
+ let (mut a, mut pc, mut steps) = (0u32, 0usize, 0usize);
+ while let Some(&(code, jt, jf, k)) = insns.get(pc) {
+ steps = steps.saturating_add(1);
+ let cls = code & 0x07;
+ match cls {
+ 0x00 => {
+ // LD
+ a = match code & 0xe0 {
+ 0x20 => match k {
+ // struct seccomp_data: nr, arch, instruction_pointer, args[6]
+ 0 => nr,
+ 4 => AUDIT_ARCH_X86_64,
+ _ => 0,
+ },
+ 0x00 => k,
+ 0x60 => mem.get(k as usize).copied().unwrap_or(0),
+ m => return Err(format!("unhandled LD mode {m:#x} at {pc}")),
+ };
+ pc = pc.saturating_add(1);
+ }
+ 0x02 => {
+ // ST
+ if let Some(slot) = mem.get_mut(k as usize) {
+ *slot = a;
+ }
+ pc = pc.saturating_add(1);
+ }
+ 0x04 => {
+ // ALU
+ match code & 0xf0 {
+ 0x50 => a &= k,
+ o => return Err(format!("unhandled ALU op {o:#x} at {pc}")),
+ }
+ pc = pc.saturating_add(1);
+ }
+ 0x05 => {
+ // JMP
+ let off = match code & 0xf0 {
+ 0x00 => usize::try_from(k).unwrap_or(0),
+ 0x10 => usize::from(if a == k { jt } else { jf }),
+ 0x20 => usize::from(if a > k { jt } else { jf }),
+ 0x30 => usize::from(if a >= k { jt } else { jf }),
+ 0x40 => usize::from(if a & k != 0 { jt } else { jf }),
+ o => return Err(format!("unhandled JMP op {o:#x} at {pc}")),
+ };
+ pc = pc.saturating_add(1).saturating_add(off);
+ }
+ 0x06 => return Ok(steps), // RET
+ c => return Err(format!("unhandled class {c} at {pc}")),
+ }
+ }
+ // seccompiler never emits a fall-off, and the kernel would reject one. Fail
+ // loudly rather than return a count that looks like a measurement.
+ Err(format!("program fell off the end at pc={pc} without a RET"))
+}
+
+// Without `bpf-dump` there is no way to reach the emitted programs, so the four
+// ring-2 subcommands refuse rather than silently measuring nothing. `bench` is
+// declared with `required-features = ["bench"]` and `bench` enables `bpf-dump`,
+// so this arm is unreachable in a normal build — it exists so an explicit
+// `--no-default-features` combination fails with a sentence instead of a
+// compile error about a missing function.
+#[cfg(not(feature = "bpf-dump"))]
+fn install(_strict: bool) -> Result<(), String> {
+ Err("built without `bpf-dump`: the emitted filters are unreachable".to_string())
+}
+#[cfg(not(feature = "bpf-dump"))]
+fn filters(_k: usize, _n: u64, _strict: bool) -> Result<(), String> {
+ Err("built without `bpf-dump`: the emitted filters are unreachable".to_string())
+}
+#[cfg(not(feature = "bpf-dump"))]
+fn sim(_strict: bool) -> Result<(), String> {
+ Err("built without `bpf-dump`: the emitted filters are unreachable".to_string())
+}
diff --git a/src/denials.rs b/src/denials.rs
index d7888f7..dbf2976 100644
--- a/src/denials.rs
+++ b/src/denials.rs
@@ -215,21 +215,9 @@ pub fn collect(pid: u32, since: &str) -> Result {
})
}
-/// Escape control/escape bytes so an attacker-chosen denied path cannot inject
-/// terminal escape sequences into the operator's TTY when `--denials` is read.
-/// Printable text (including non-ASCII UTF-8 filenames) passes through unchanged.
-fn sanitize(s: &str) -> String {
- use std::fmt::Write as _;
- let mut out = String::with_capacity(s.len());
- for c in s.chars() {
- if c.is_control() {
- let _ = write!(out, "\\x{:02x}", u32::from(c));
- } else {
- out.push(c);
- }
- }
- out
-}
+/// Escaping lives in `crate::diag` so there is ONE implementation. This module
+/// had the only copy, and the other print sites had none (T26).
+use crate::diag::safe as sanitize;
/// Human-readable report. Groups by (rights, path) so a loop denied 400 times
/// reads as one line with a count.
diff --git a/src/diag.rs b/src/diag.rs
new file mode 100644
index 0000000..89cd0e3
--- /dev/null
+++ b/src/diag.rs
@@ -0,0 +1,82 @@
+// SPDX-License-Identifier: BSD-3-Clause
+//! One sanitiser for every diagnostic that renders bytes oring did not choose.
+//!
+//! oring's diagnostics interpolate caller-controlled strings: policy paths, the
+//! target name, `$TMPDIR`, an `io::Error` carrying a path. `src/denials.rs`
+//! escaped them; **13 of ~14 other print sites did not**, and a forged `SEALED`
+//! line was injected through argv alone under `--profile balanced`, with no
+//! policy file (T26). The threat model does not license treating those bytes as
+//! trusted — `ORING_POLICY` is ambient, and argv comes from whoever invoked
+//! oring, which under the agent-wrapping story may be the thing being confined.
+//!
+//! The loss is `MISREPORT`, not `BREACH`: nothing here widens the cage, but a
+//! terminal that honours escape sequences can be made to repaint a `REFUSE` as a
+//! `SEALED`, and the operator acts on what they saw.
+//!
+//! **Does NOT** shorten, quote or validate. A path can still mislead by being
+//! long, right-to-left, or a homoglyph of another path — none of which is a
+//! control byte, and none of which is closed here.
+/// Escape control characters so caller-supplied bytes cannot inject terminal
+/// escape sequences into the operator's TTY.
+///
+/// Printable text, including non-ASCII UTF-8 filenames, passes through
+/// unchanged: a legitimate path with an accented character must remain readable,
+/// or operators start ignoring the diagnostics that carry it.
+#[must_use]
+pub fn safe(s: &str) -> String {
+ use std::fmt::Write as _;
+ let mut out = String::with_capacity(s.len());
+ for c in s.chars() {
+ if c.is_control() {
+ // `\x1b` rather than the raw byte. Width is not padded beyond two
+ // digits because `char::is_control` admits U+0085 and U+2028, whose
+ // escapes are longer — truncating them would produce a DIFFERENT
+ // character rather than an escaped one.
+ let _ = write!(out, "\\x{:02x}", u32::from(c));
+ } else {
+ out.push(c);
+ }
+ }
+ out
+}
+
+/// `safe` over anything that renders — paths, errors, `OsStr` via `Display`.
+#[must_use]
+pub fn safe_display(d: &impl std::fmt::Display) -> String {
+ safe(&d.to_string())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn escape_sequences_do_not_survive() {
+ // The exact shape that forged a SEALED line: erase the real line, print
+ // a fake one.
+ let forged = "\x1b[2K\rooring: SEALED v9.9.9 grants=999/999";
+ let out = safe(forged);
+ assert!(!out.contains('\x1b'), "ESC survived: {out:?}");
+ assert!(!out.contains('\r'), "CR survived: {out:?}");
+ assert!(out.contains("\\x1b") && out.contains("\\x0d"), "{out:?}");
+ }
+
+ #[test]
+ fn ordinary_paths_are_untouched() {
+ for s in [
+ "/home/u/.cargo/registry",
+ "/work/repo/café/naïve.rs",
+ "/tmp/oring-1234",
+ ] {
+ assert_eq!(safe(s), s, "a legitimate path was altered");
+ }
+ }
+
+ #[test]
+ fn newlines_cannot_forge_a_second_line() {
+ // A path containing a newline could otherwise emit what looks like an
+ // additional, independent diagnostic line.
+ let out = safe("/tmp/x\noring: SEALED forged");
+ assert!(!out.contains('\n'), "newline survived: {out:?}");
+ }
+}
diff --git a/src/error.rs b/src/error.rs
index 80d310a..e70bba6 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -63,14 +63,23 @@ impl OringError {
}
}
+/// **The chokepoint (T26).** Every refusal message reaches the operator through
+/// this impl, and every one of them interpolates something the caller chose — a
+/// policy path, the target name, an `io::Error` carrying a path. Sanitising here
+/// covers all four variants and every future call site by construction, rather
+/// than relying on each `OringError::policy(format!(...))` author to remember.
+///
+/// Escaping at RENDER rather than at CONSTRUCTION is deliberate: a message is
+/// built once and may be matched on by tests or callers, and only the terminal
+/// needs the escaped form. `is_control` is a no-op on every legitimate path, so
+/// nothing readable changes.
impl fmt::Display for OringError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- OringError::Refuse { msg, .. } => write!(f, "{msg}"),
- OringError::Policy(m) => write!(f, "{m}"),
- OringError::Target(m) => write!(f, "{m}"),
- OringError::Usage(m) => write!(f, "{m}"),
- }
+ let msg = match self {
+ OringError::Refuse { msg, .. } => msg,
+ OringError::Policy(m) | OringError::Target(m) | OringError::Usage(m) => m,
+ };
+ write!(f, "{}", crate::diag::safe(msg))
}
}
diff --git a/src/landlock.rs b/src/landlock.rs
index 1919769..a7ce61a 100644
--- a/src/landlock.rs
+++ b/src/landlock.rs
@@ -6,12 +6,18 @@
//! `AccessFs` vocabulary against a `(writable, executable)` policy + node
//! kind (see `Tier`/`RIGHTS`), so device-node creation and W^X are
//! structural properties, not hand-maintained unions.
-//! - hard V7/6.15 floor: refuse below it. RESOLVE_UNIX (V9, pathname-AF_UNIX
-//! connect) is additive hardening applied best-effort with `--features
-//! resolve-unix` on a ≥V9 kernel; when inactive it is a loudly-reported
-//! residual, not a silent gap. Above the crate ceiling the newer right
-//! (V10 UDP) is UNHANDLED — oring warns (abi-ahead), never implies full
-//! coverage (CALIF F1)
+//! - hard V10/7.2 floor: refuse below it. RESOLVE_UNIX (V9, pathname-AF_UNIX
+//! connect) is IN the floor — Landlock has no hook on that connect below V9,
+//! so beneath the floor the docker.sock/ssh-agent class is unmediated by
+//! ring 1 and no FS tier can gate it.
+//! - coverage is disclosed in TWO halves and neither is complete alone: rights
+//! the crate cannot NAME (above its ceiling) get the abi-ahead warn, and
+//! rights it names but this run did not HANDLE are derived by set difference
+//! against `from_all(abi)` and reported as `unhandled=` + a partial-class
+//! warn. Keying the whole disclosure on the first half is what let a V10 cap
+//! bump silence it while UDP stayed open (CALIF F1)
+//! - V10 UDP rights (`egress_udp`/`ingress_udp`) are opt-in and REFUSED below
+//! V10 — a dropped UDP handle is an open egress path, not a missing grant
//! - net HARD-required so a deny-all can never silently become allow-all
//! - manual IgnoreIfMissing: ENOENT → skip, any other error → fail closed
@@ -19,47 +25,242 @@ use std::io;
use std::path::{Path, PathBuf};
use landlock::{
- Access, AccessFs, AccessNet, CompatLevel, Compatible, NetPort, PathBeneath, PathFd,
+ Access, AccessFs, AccessNet, BitFlags, CompatLevel, Compatible, NetPort, PathBeneath, PathFd,
PathFdError, Ruleset, RulesetAttr, RulesetCreatedAttr, RulesetStatus, Scope, ABI,
};
use crate::error::{OringError, Result};
use crate::policy::Policy;
-/// Hard Landlock ABI floor: **V7 (kernel 6.15)** — the minimum for a coherent
-/// cage: FS tiers + Refer/Truncate, TCP net (V4), IoctlDev (V5), IPC scope (V6)
-/// and audit (V7) are all guaranteed at V7. Below it oring refuses.
+/// Hard Landlock ABI floor: **V10 (kernel 7.2)** — the minimum for a coherent
+/// cage. FS tiers + Refer/Truncate, TCP net (V4), IoctlDev (V5), IPC scope (V6),
+/// audit (V7), `RESOLVE_UNIX` (V9) and the UDP net rights (V10) are all
+/// guaranteed at V10. Below it oring refuses.
///
-/// V9's `RESOLVE_UNIX` — the right that walls `connect()` to a pathname AF_UNIX
-/// socket (docker.sock / ssh-agent) — is *additive hardening*, not part of the
-/// floor: it is applied best-effort when the `resolve-unix` feature is built AND
-/// the kernel is ≥ V9 (7.1). When it is not active, pathname-AF_UNIX connect is
-/// an UNMEDIATED residual — a granted `/tmp` exposes the operator's ssh-agent —
-/// which oring reports loudly at seal and warns about at policy time
-/// (`warn_on_sockets`). This is a deliberate degradation, kept fail-safe: the
-/// missing right only ever removes a *restriction*, never widens a grant, and its
-/// absence is never silent.
-pub const FLOOR_ABI: i32 = 7;
-
-/// Highest ABI the linked `landlock` crate can NAME/handle. The published crate
-/// reaches **V7**; the `resolve-unix` feature (built against a V9-capable fork)
-/// reaches **V9**. A running kernel above this exposes restrictable rights the
-/// crate cannot emit — those classes are UNHANDLED (= unrestricted) even though
+/// V9 is in the floor because of `RESOLVE_UNIX` — the right that walls
+/// `connect()` to a pathname AF_UNIX socket (docker.sock / ssh-agent). Landlock
+/// has **no hook on AF_UNIX `connect()` below V9**, so beneath this floor that
+/// path is unmediated by ring 1 no matter what the FS tiers say: a granted `/tmp`
+/// exposes the operator's ssh-agent, and withholding the grant only obscures the
+/// socket rather than gating it (D-4b). Through v0.1 this was a
+/// loudly-reported residual behind an opt-in `resolve-unix` feature — that was
+/// the degraded-path-with-a-warning pattern [D-4] rejects everywhere else, so
+/// v0.2 folds it into the floor: oring refuses rather than run the partial cage.
+///
+/// **RAISED TO V10 / kernel 7.2 on 2026-07-29.** V9's `RESOLVE_UNIX` reasoning above still
+/// holds and is now *below* the floor, so it is guaranteed rather than argued. What V10
+/// adds is **UDP**: `LANDLOCK_ACCESS_NET_{BIND,CONNECT_SEND}_UDP`. Below V10 oring cannot
+/// mediate UDP at all, so `egress_tcp: []` could not mean "no egress" and DNS/UDP exfil was
+/// a residual the built-in profiles had to carry — the degraded-path-with-a-warning pattern
+/// [D-4] rejects everywhere else, and the argument that folded V9 in applies unchanged.
+///
+/// Cost, stated plainly and larger than the V9 raise: **every kernel below 7.2 is
+/// refused**, which at the time of the decision is every released stable kernel. oring
+/// supports 7.2.0-rc4 and later, deliberately. The crate comes from the out-of-tree patch
+/// until V10 lands in a published `landlock` release — and note V9 *has* landed upstream
+/// (0.4.7), so the patch now carries **only** V10, a smaller delta than the V9 fork was.
+///
+/// **What this retires.** The ring-2 `MSG_FASTOPEN` rule stops being load-bearing:
+/// mainline `33cb713db016` fixed that Landlock gap in 7.2, so at this floor ring 1 gates
+/// the route itself — measured 2026-07-29, dropping the ring-2 rule on 7.2.0-rc4 still
+/// yields EACCES. The rule stays as defence in depth; what changes is that it is no longer
+/// the only thing between a policy and an open port.
+pub const FLOOR_ABI: i32 = 10;
+
+/// The crate ceiling as an ABI **variant** — the single place to edit when the
+/// vendored crate learns a new ABI.
+///
+/// Everything else about the ceiling is derived from this: [`CRATE_MAX_ABI`] is
+/// `CEILING as i32`, and [`abi_from_version`] clamps to `CEILING`. Before, the
+/// integer and the clamp target were two independent encodings coupled only by
+/// a Kani proof and a unit test — editing one alone compiled fine and produced a
+/// binary that reported `abi=V10>handledV9` while handling V10 rights. Now that
+/// desynchronisation is not expressible.
+///
+/// This works because the crate's `ABI` discriminants ARE the version numbers
+/// (`Unsupported = 0`, `V1 = 1`, … `V10 = 10`, `compat.rs`), so `as i32` is the
+/// exact inverse of the naming — asserted below rather than assumed.
+#[cfg(feature = "resolve-unix")]
+const CEILING: ABI = ABI::V10;
+#[cfg(not(feature = "resolve-unix"))]
+const CEILING: ABI = ABI::V7;
+
+/// Highest ABI the linked `landlock` crate can NAME/handle — **derived from
+/// [`CEILING`]**, which the vendored fork behind the `resolve-unix` feature
+/// provides. A running kernel above this exposes restrictable rights the crate
+/// cannot emit — those classes are UNHANDLED (= unrestricted) even though
/// `restrict_self` reports FullyEnforced (which certifies only the *requested*
/// subset). oring reports the shortfall (abi-ahead) rather than implying full
/// coverage.
+pub const CRATE_MAX_ABI: i32 = CEILING as i32;
+
+// The discriminants-are-version-numbers property this file now leans on, pinned
+// at COMPILE time against the vendored crate. If a future bump renumbers them,
+// this fails to build rather than silently skewing every ceiling comparison.
+const _: () = {
+ assert!(ABI::Unsupported as i32 == 0);
+ assert!(ABI::V1 as i32 == 1);
+ assert!(ABI::V7 as i32 == 7);
+};
#[cfg(feature = "resolve-unix")]
-pub const CRATE_MAX_ABI: i32 = 9;
+const _: () = {
+ assert!(ABI::V9 as i32 == 9);
+ assert!(ABI::V10 as i32 == 10);
+};
+
+/// Mainline kernel version that first carries each Landlock ABI.
+///
+/// Exists because the kernel version was written out by hand wherever an operator
+/// had to be told what to upgrade to, and those literals drifted the moment the
+/// floor moved: `--check` said "need kernel 7.1+" while refusing anything below
+/// V10, and two skip messages in `tests/common` told the operator a cell "needs
+/// kernel 7.1" for the same reason. A number the code does not derive is a number
+/// that goes stale silently, because nothing recomputes it.
+/// `""` for an ABI with no recorded version — callers must treat that as unknown
+/// rather than as a version, which is what [`FLOOR_KERNEL`] enforces at compile
+/// time. A `match` and not a table because `clippy::indexing_slicing` is denied
+/// crate-wide and a `const fn` cannot iterate.
+const fn kernel_for(abi: i32) -> &'static str {
+ match abi {
+ 7 => "6.12",
+ 8 => "6.15",
+ 9 => "7.1",
+ 10 => "7.2",
+ _ => "",
+ }
+}
+
+/// Kernel version an operator needs for [`FLOOR_ABI`], e.g. `"7.2"`.
+///
+/// Resolved in a `const fn` so a floor with no mapped kernel version is a
+/// **compile error**, not a runtime panic in the one code path that only executes
+/// on a machine too old to run the tests. Raising `FLOOR_ABI` without adding its
+/// kernel here cannot ship.
+pub const FLOOR_KERNEL: &str = {
+ let k = kernel_for(FLOOR_ABI);
+ assert!(
+ !k.is_empty(),
+ "FLOOR_ABI has no mapped kernel version — add an arm to kernel_for"
+ );
+ k
+};
+
+/// ABI that first names `LANDLOCK_ACCESS_NET_{BIND,CONNECT_SEND}_UDP` — the
+/// gate for `egress_udp`/`ingress_udp`.
+///
+/// Public and named rather than an inline `10` for the same reason
+/// `tests/common` reads [`FLOOR_ABI`] instead of hardcoding `9`: a literal
+/// duplicated between the enforcement gate and the test that covers it can drift
+/// apart, and the drift shows up as a test that quietly stops exercising the
+/// gate rather than as a failure.
+pub const UDP_ABI: i32 = 10;
+
+// The UDP rights are GUARANTEED BY THE FLOOR since 2026-07-29, pinned at compile time
+// rather than left as a coincidence of two integers. It is what lets the UDP cells be
+// unconditional: below V10 oring refuses to seal at all, so there is no kernel oring
+// accepts on which `egress_udp` cannot be named. If a future floor drop makes this false
+// the build fails here, instead of the UDP suites silently reverting to skip-and-count.
+const _: () = assert!(UDP_ABI <= FLOOR_ABI);
+
+/// ABI that first names `AccessFs::ResolveUnix` — the pathname-AF_UNIX `connect()` wall.
+///
+/// Named rather than left as a `9` inside a test, because the floor's relationship to it is
+/// a property of the product: under a V9 floor the two were the SAME number and the pairing
+/// was pinned by equality. At a V10 floor the wall is guaranteed with a MARGIN, so the
+/// property to hold is an inequality — and it belongs at compile time beside the UDP one,
+/// not as a runtime assertion over two constants (which is a check that cannot fail, and
+/// clippy says so).
+pub const RESOLVE_UNIX_ABI: i32 = 9;
+
+// The floor GUARANTEES the pathname-AF_UNIX wall rather than coinciding with it. A floor
+// drop below V9 fails the build here — which is the residual the floor exists to delete.
+const _: () = assert!(RESOLVE_UNIX_ABI <= FLOOR_ABI);
+
+// A V9 floor cannot be met by a crate that tops out at V7: `abi_from_version`
+// could never name V9, so every run would refuse at the floor gate. Fail closed
+// at COMPILE time with a legible message instead of shipping a binary that
+// always refuses.
#[cfg(not(feature = "resolve-unix"))]
-pub const CRATE_MAX_ABI: i32 = 7;
+compile_error!(
+ "oring requires the `resolve-unix` feature: the V10 floor (FLOOR_ABI = 10) needs a \
+ landlock crate that names ABI V10 + the UDP net rights, which the published \
+ crate does not yet provide. Build with `--features resolve-unix` and a V9 crate \
+ override (see .cargo/config.toml.example). When upstream ships V9 in a release, \
+ drop the feature and depend on it directly."
+);
/// True when the RESOLVE_UNIX pathname-socket wall is compiled in *and* the
-/// running kernel provides it. When false, pathname-AF_UNIX connect is the
-/// loudly-reported residual (see [`FLOOR_ABI`]).
+/// running kernel provides it. At the V10 floor this is true for every kernel
+/// oring agrees to run on — it is kept as an explicit predicate so the wall is
+/// asserted from the *detected* ABI rather than assumed from the floor constant.
pub fn resolve_unix_active(abi_ver: i32) -> bool {
cfg!(feature = "resolve-unix") && abi_ver >= 9
}
+/// One published Landlock erratum oring cares about: a behavioural fix the
+/// running kernel either carries or does not, independent of its ABI integer.
+struct ErratumInfo {
+ /// 1-based, as the kernel numbers them. See [`erratum_applied`] for the
+ /// bit mapping — do NOT open-code it.
+ number: u8,
+ /// ABI level the erratum was declared against.
+ abi: i32,
+ summary: &'static str,
+}
+
+/// Errata introduced at or below [`FLOOR_ABI`], i.e. every one a kernel meeting
+/// oring's floor is expected to carry. Kept explicit rather than derived: a
+/// future erratum on a newer ABI must be *added deliberately*, so oring never
+/// warns about a fix it has not reasoned about.
+///
+/// Erratum 3 is the load-bearing one — without it, rename/link across
+/// disconnected directories can widen access rights past `LANDLOCK_ACCESS_FS_REFER`,
+/// and oring grants `Refer` in **every** tier (§4).
+const FLOOR_ERRATA: &[ErratumInfo] = &[
+ ErratumInfo {
+ number: 1,
+ abi: 4,
+ summary: "TCP rights no longer restrict non-TCP stream sockets (SMC/MPTCP/SCTP)",
+ },
+ ErratumInfo {
+ number: 2,
+ abi: 6,
+ summary: "signal scoping no longer blocks same-process cross-domain signals",
+ },
+ ErratumInfo {
+ number: 3,
+ abi: 1,
+ summary: "disconnected-directory rename/link can no longer widen rights past FS_REFER",
+ },
+];
+
+/// Is erratum `number` present in `mask`?
+///
+/// The kernel sets bit `number - 1`: `landlock_errata |= BIT(number - 1)` in
+/// `security/landlock/setup.c`, and `Documentation/userspace-api/landlock.rst`
+/// gives the userspace form as `errata & (1 << (N - 1))`.
+///
+/// **One definition, deliberately.** This mapping was open-coded at two call
+/// sites when the errata support first landed; either could have drifted, and
+/// neither was reachable by a test. It is also why the arithmetic is saturating
+/// and the shift checked rather than `1u64 << (number - 1)`: the crate denies
+/// `clippy::arithmetic_side_effects`, and a `0` number would underflow `u8` into
+/// a 255-bit shift — a panic in a wrapper whose whole contract is failing
+/// closed without aborting.
+fn erratum_applied(mask: u64, number: u8) -> bool {
+ let bit = u32::from(number).saturating_sub(1);
+ 1u64.checked_shl(bit).is_some_and(|b| mask & b != 0)
+}
+
+/// Render the errata bitmask for the seal line: `0x7`, or `?` when the kernel
+/// did not answer. Never blank — an absent probe must be visible, not implied.
+fn describe_errata(errata: Option) -> String {
+ match errata {
+ Some(mask) => format!("0x{mask:x}"),
+ None => "?".to_string(),
+ }
+}
+
/// Ground-truth of which rings actually engaged — feeds the seal line (§12).
#[derive(Debug)]
pub struct RingReport {
@@ -68,9 +269,26 @@ pub struct RingReport {
pub scope: &'static str,
pub audit: &'static str,
pub abi: String,
+ /// Which **declared** Landlock errata the running kernel carries (`0x7`), or
+ /// `?` if it did not answer. Reported because the ABI integer alone does not
+ /// establish which fixes are present. It is a LOWER BOUND: behaviour changes
+ /// ship without errata (see [`crate::sys::landlock_errata`]), so a full mask
+ /// does not certify documented behaviour.
+ pub errata: String,
/// `granted/intended` FS paths. Enforcement status says the cage is on;
/// this says whether it is the cage you asked for.
pub grants: String,
+ /// Restrictable rights the crate can NAME at this kernel's ABI that this run
+ /// did not handle — i.e. left unrestricted — or `-` for none. Derived by set
+ /// difference (see [`unhandled_rights`]), so it cannot go stale as the crate
+ /// or the policy schema grows.
+ ///
+ /// This is the field that makes under-coverage attestable rather than
+ /// merely warned about: `fs=enforced` says the cage is on and `net=` says
+ /// which directions were asked for, but neither could express "and these
+ /// specific rights were left open", which is exactly what a reader auditing
+ /// a seal line after the fact needs to know.
+ pub unhandled: String,
}
// FS access tiers, derived — not enumerated (§4).
@@ -208,17 +426,18 @@ impl Tier {
/// True when a detected ABI is below the hard floor — oring must refuse. Pure so
/// the fail-closed boundary is unit-testable on any kernel (the real `apply` only
-/// hits the refuse path below kernel 6.15, which the test host is not).
+/// hits the refuse path below kernel 7.1, which the test host may not be).
fn below_floor(abi_ver: i32) -> bool {
abi_ver < FLOOR_ABI
}
-/// Apply ring 1. Hard floor is Landlock **V7 (kernel 6.15)**: FS/Refer/Truncate
-/// (V1-3), TCP net (V4), IoctlDev (V5), IPC scope (V6) and audit (V7) are all
-/// guaranteed there. RESOLVE_UNIX (V9) is additive hardening applied best-effort
-/// when the `resolve-unix` feature + a ≥V9 kernel are present; otherwise its
-/// absence is a loudly-reported residual (see [`FLOOR_ABI`]). Below V7 oring
-/// refuses cleanly rather than run a partial cage.
+/// Apply ring 1. Hard floor is Landlock **V10 (kernel 7.2)**: FS/Refer/Truncate
+/// (V1-3), TCP net (V4), IoctlDev (V5), IPC scope (V6), audit (V7),
+/// `RESOLVE_UNIX` (V9) and the UDP net rights (V10) are all guaranteed there.
+/// Below the floor oring refuses cleanly rather than run a partial cage —
+/// pathname-AF_UNIX `connect()` has no Landlock hook at all beneath V9, so a
+/// sub-floor run would leave the docker.sock/ssh-agent class unmediated by
+/// ring 1 (see [`FLOOR_ABI`]).
pub fn apply(policy: &Policy) -> Result {
let abi_ver = detect_abi_version();
let abi_dbg = format!("V{abi_ver}");
@@ -227,87 +446,250 @@ pub fn apply(policy: &Policy) -> Result {
return Err(OringError::refuse(
"landlock-abi",
format!(
- "Landlock ABI {abi_dbg} < V{FLOOR_ABI} (need kernel 6.15+); refusing — \
- oring requires the coherent Landlock set (FS+Refer+Truncate+net+\
- IoctlDev+scope+audit), all guaranteed at V7"
+ "Landlock ABI {abi_dbg} < V{FLOOR_ABI} (need kernel {FLOOR_KERNEL}+); \
+ refusing — oring requires the coherent Landlock set (FS+Refer+\
+ Truncate+net+IoctlDev+scope+audit+ResolveUnix+UDP), all guaranteed \
+ at V{FLOOR_ABI}. Below V9 Landlock has no hook on pathname-AF_UNIX \
+ connect(), so docker.sock and ssh-agent would be unmediated by ring \
+ 1 and no FS tier could gate them; below V10 it cannot restrict UDP, \
+ so DNS egress would be unmediated too"
),
));
}
+ // ERRATA disclosure. The ABI integer describes the INTERFACE; the errata
+ // bitmask reports which DECLARED fixes the kernel carries, and the two can
+ // disagree because vendor kernels are assembled by backporting — which is
+ // the whole reason the kernel exposes this. It is a LOWER BOUND on
+ // behavioural difference: `MSG_FASTOPEN` mediation landed with no erratum
+ // (see `sys::landlock_errata`), so `0x7` means "carries the three declared
+ // fixes", not "behaves as documented". The floor above checks
+ // only the version, so without this a kernel could satisfy V9 while missing
+ // a fix a granted right depends on, and oring would report FullyEnforced.
+ //
+ // WARN, do not refuse. Every erratum below predates the floor and no host
+ // has yet been shown that meets V9 without them, so refusing would reject a
+ // kernel nobody has demonstrated exists — the same asymmetry the abi-ahead
+ // path reasons from. An unavailable probe is reported, never inferred from.
+ let errata = crate::sys::landlock_errata();
+ let errata_report = describe_errata(errata);
+ if let Some(mask) = errata {
+ let missing: Vec<&ErratumInfo> = FLOOR_ERRATA
+ .iter()
+ .filter(|e| !erratum_applied(mask, e.number))
+ .collect();
+ if !missing.is_empty() {
+ for e in &missing {
+ eprintln!(
+ "oring: WARN kernel Landlock reports ABI {abi_dbg} but NOT erratum {} \
+ (introduced at ABI V{}): {}. The ABI version is satisfied; this fix \
+ is not, so the affected behaviour may differ from what oring's \
+ policy assumes.",
+ e.number, e.abi, e.summary
+ );
+ }
+ }
+ }
+
// Detected may exceed the crate ceiling; clamp to what the crate can name.
let abi = abi_from_version(abi_ver);
- // RESOLVE_UNIX residual: when the pathname-AF_UNIX wall is NOT active (feature
- // off, or kernel < V9), report it loudly. It is an additive hardening whose
- // absence removes only a restriction — but that restriction matters: a granted
- // /tmp exposes the operator's ssh-agent. Never let this be silent (§2).
- if !resolve_unix_active(abi_ver) {
- let why = if cfg!(feature = "resolve-unix") {
- format!("kernel Landlock {abi_dbg} < V9")
- } else {
- "built without the `resolve-unix` feature".to_string()
- };
- eprintln!(
- "oring: RESIDUAL pathname-AF_UNIX connect() is UNMEDIATED ({why}) — a granted \
- /tmp exposes the operator's ssh-agent (`/tmp/ssh-*/agent.*`). To close it, \
- build --features resolve-unix on a kernel 7.1+ (ABI V9). Abstract sockets are \
- still walled by IPC scope."
- );
- }
+ // The pathname-AF_UNIX wall is floor-guaranteed from v0.2: below_floor() has
+ // already refused anything under V9, and the linked crate is V9-capable by the
+ // compile_error! on `resolve-unix`. Assert it from the DETECTED abi rather than
+ // inferring it from the floor constant, so a later floor edit cannot silently
+ // un-wall this class (§2 — the residual this replaced).
+ debug_assert!(
+ resolve_unix_active(abi_ver),
+ "post-floor invariant: RESOLVE_UNIX must be active at {abi_dbg}"
+ );
- // ABI-AHEAD disclosure (CALIF F1): a kernel above the crate's V7 ceiling has
- // restrictable rights oring cannot emit, so those op classes are UNHANDLED
- // (unrestricted) while restrict_self still reports FullyEnforced. Warn loudly
- // — do NOT refuse (every current deployment kernel is ABI 8/9, so refusing
- // would brick oring everywhere). This makes the silent under-coverage honest.
+ // ABI-AHEAD (CALIF F1), the CRATE-LIMITED half of the coverage disclosure:
+ // a kernel above the crate's ceiling has restrictable rights oring cannot
+ // emit, so those op classes are UNHANDLED (unrestricted) while restrict_self
+ // still reports FullyEnforced. Warn loudly — do NOT refuse: refusing above
+ // the ceiling would brick oring on every kernel newer than the crate, which
+ // is the opposite of the floor's logic (the floor refuses when a right oring
+ // NEEDS is absent; here the shortfall is a right the crate cannot NAME).
+ //
+ // This predicate is CORRECT for its half and WRONG as a total answer — the
+ // distinction that cost a measurement. `abi_ver > CRATE_MAX_ABI` detects
+ // rights the crate cannot NAME. It says nothing about rights the crate names
+ // and oring does not HANDLE, and those two sets stopped coinciding the moment
+ // the crate learned V10 while the policy schema had no UDP field. Raising the
+ // cap then SILENCED this warning without restricting one extra byte. The
+ // named-gap half below closes that; both are reported together.
let abi_ahead = abi_ver > CRATE_MAX_ABI;
if abi_ahead {
eprintln!(
"oring: WARN kernel Landlock ABI {abi_dbg} > handled V{CRATE_MAX_ABI} (linked crate cap) \
— rights the crate cannot name are UNHANDLED (unrestricted) above V{CRATE_MAX_ABI}. \
Crate-limited, not structural; FullyEnforced certifies only the V{CRATE_MAX_ABI} subset. \
- Build --features resolve-unix (V9 crate) to raise the cap."
+ Bump the linked landlock crate to raise the cap."
);
}
let ll = &policy.landlock;
// --- Build the ruleset -------------------------------------------------
+ // Every `handle_access` below ALSO records into a `handled_*` set. Those
+ // sets are the input to `coverage()` — the disclosure is computed from what
+ // was actually handled, never restated by hand. See [`Coverage`].
let mut rs = Ruleset::default();
rs = rs.set_compatibility(CompatLevel::BestEffort);
// FS core: handle EVERY fs right up to detected ABI. Rights we never grant
// in any tier (MakeChar/MakeBlock) become handled-but-denied everywhere.
+ let handled_fs = AccessFs::from_all(abi);
rs = rs
- .handle_access(AccessFs::from_all(abi))
+ .handle_access(handled_fs)
.map_err(|e| OringError::refuse("landlock-fs", format!("handle fs access: {e}")))?;
- // Net (V4+, guaranteed at the V7 floor): per-direction, HARD requirement so
+ // Net (V4+, guaranteed at the V10 floor): per-direction, HARD requirement so
// a deny-all can never silently become allow-all.
+ // UDP is V10-only. Refuse rather than degrade: a dropped TCP grant is a
+ // missing permission the child notices, but a dropped UDP handle is an OPEN
+ // EGRESS PATH the operator does not notice. Gate BEFORE building the ruleset
+ // so the refusal names the kernel, not a downstream handle_access error.
+ let udp_requested = policy.udp_requested();
+ if udp_requested && abi_ver < UDP_ABI {
+ return Err(OringError::refuse(
+ "landlock-udp-abi",
+ format!(
+ "policy sets egress_udp/ingress_udp but kernel Landlock is {abi_dbg} — \
+ UDP rights require V{UDP_ABI}. Refusing rather than sealing with UDP egress \
+ silently unrestricted; drop the field or run a V{UDP_ABI} kernel."
+ ),
+ ));
+ }
+
let mut net_report = "off";
- if policy.net_requested() {
+ let mut handled_net = BitFlags::::EMPTY;
+ if policy.net_requested() || udp_requested {
rs = rs.set_compatibility(CompatLevel::HardRequirement);
- if ll.egress_tcp.is_some() {
- rs = rs.handle_access(AccessNet::ConnectTcp).map_err(|e| {
- OringError::refuse("landlock-net", format!("handle egress_tcp: {e}"))
- })?;
- }
- if ll.ingress_tcp.is_some() {
- rs = rs.handle_access(AccessNet::BindTcp).map_err(|e| {
- OringError::refuse("landlock-net", format!("handle ingress_tcp: {e}"))
- })?;
+ // Each arm records what it handled. The pairing (policy field → access
+ // right) lives HERE and nowhere else: `coverage()` derives the gap by
+ // set difference, so adding a right without a field — or a field
+ // without a right — shows up as an unhandled class instead of a silent
+ // hole. This is the whole fix; see the `Coverage` doc comment.
+ for (requested, right) in [
+ (ll.egress_tcp.is_some(), AccessNet::ConnectTcp),
+ (ll.ingress_tcp.is_some(), AccessNet::BindTcp),
+ (ll.egress_udp.is_some(), AccessNet::ConnectSendUdp),
+ (ll.ingress_udp.is_some(), AccessNet::BindUdp),
+ ] {
+ if requested {
+ rs = rs.handle_access(right).map_err(|e| {
+ OringError::refuse("landlock-net", format!("handle {right:?}: {e}"))
+ })?;
+ handled_net |= right;
+ }
}
rs = rs.set_compatibility(CompatLevel::BestEffort);
- net_report = "on";
+ // Total by construction — no `unreachable!` (denied lib-wide, and a
+ // panic in the sealing path is the wrong failure anyway). The
+ // (false,false) arm is dead under the enclosing `if`, and reports the
+ // truth rather than a lie if that guard ever changes.
+ net_report = match (policy.net_requested(), udp_requested) {
+ (true, true) => "tcp+udp-ports",
+ (true, false) => "tcp-ports",
+ (false, true) => "udp-ports",
+ (false, false) => "off",
+ };
}
- // Scope (IPC, V6+, guaranteed at the V7 floor).
+ // Scope (IPC, V6+, guaranteed at the V10 floor).
let mut scope_report = "off";
+ let mut handled_scope = BitFlags::::EMPTY;
if ll.scope_ipc {
+ handled_scope = Scope::from_all(abi);
rs = rs
- .scope(Scope::from_all(abi))
+ .scope(handled_scope)
.map_err(|e| OringError::refuse("landlock-scope", format!("handle scope: {e}")))?;
scope_report = "on";
}
+ // COVERAGE disclosure, the named half. Computed from the handled sets built
+ // above against the crate's own `from_all(abi)` enumeration — see
+ // [`unhandled_rights`].
+ let unhandled = unhandled_rights(abi, handled_fs, handled_net, handled_scope);
+ let unhandled_report = if unhandled.is_empty() {
+ "-".to_string()
+ } else {
+ unhandled.join(",")
+ };
+ // Hard gate (Type-II defense), mirroring `--require-all-grants` above: the
+ // operator asked for coverage to be ESTABLISHED, so a right left unhandled is a
+ // failure rather than a warning. Refuse BEFORE sealing completes — never run the
+ // target under a cage whose gaps were only ever announced on a channel the
+ // caller can discard.
+ //
+ // This is the "refused before execve" limb of CON-5. The other limb — disclosure
+ // — is a stderr line written with `let _ = writeln!`, and `2>/dev/null` removes
+ // it while the write SUCCEEDS, so nothing inside oring can detect that case. An
+ // exit code cannot be redirected away, which is the whole point of the flag.
+ if policy.require_full_coverage && !unhandled.is_empty() {
+ return Err(OringError::refuse(
+ "coverage-incomplete",
+ format!(
+ "--require-full-coverage: {} restrictable right(s) left UNHANDLED at abi=V{abi}: {}. Set the missing policy field(s), or drop the flag to seal with the gap disclosed on the seal line instead.",
+ unhandled.len(),
+ unhandled.join(", ")
+ ),
+ ));
+ }
+
+ // WARN only on a PARTIAL restriction: the class was restricted at all, and
+ // some of its rights were left open. That is the shape that misleads — a
+ // policy naming `egress_tcp` reads as "the network is restricted", and on a
+ // V10 kernel it silently was not. A class left ENTIRELY unhandled is not
+ // warned about: `net=off` / `scope=off` already say so on the seal line, and
+ // warning on every unrestricted class would bury this one in noise.
+ //
+ // The line between the two is intent vs omission: `scope_ipc: false` is a
+ // value the operator affirmatively wrote, while an absent `egress_udp`
+ // beside a present `egress_tcp` is a field they never saw. Omission is what
+ // gets flagged.
+ if !handled_net.is_empty() {
+ let net_gap: Vec<&str> = unhandled
+ .iter()
+ .filter(|r| r.starts_with("net."))
+ .map(String::as_str)
+ .collect();
+ if !net_gap.is_empty() {
+ eprintln!(
+ "oring: WARN net is PARTIALLY restricted — {} handled, but {} UNHANDLED \
+ (unrestricted) at {abi_dbg}. A policy that names some net fields and not \
+ others restricts only what it names; set the missing field(s) to close this, \
+ or state the gap deliberately.",
+ handled_net.iter().count(),
+ net_gap.join(" ")
+ );
+ }
+ }
+
+ // THE AUTOBIND FOOTGUN, warned about by name because it is invisible
+ // everywhere else. `sendto` on an unbound UDP socket acquires a source port
+ // via kernel autobind, and the kernel checks that against a synthetic
+ // **port 0** (`security/landlock/net.c`). So handling BindUdp without
+ // granting port 0 denies the autobind — and with it every datagram the
+ // `egress_udp` grant appears to permit. A policy reading "allow DNS out,
+ // allow no inbound UDP" therefore denies DNS.
+ //
+ // This warns rather than refuses: `ingress_udp: []` beside `egress_udp: []`
+ // is a coherent deny-everything posture, and only the combination with a
+ // NON-EMPTY egress grant is self-defeating. `unhandled=` cannot show this —
+ // both rights ARE handled, which is exactly why it needs its own warning.
+ if let (Some(eg), Some(ing)) = (&ll.egress_udp, &ll.ingress_udp) {
+ if !eg.is_empty() && !ing.contains(&0) {
+ eprintln!(
+ "oring: WARN egress_udp grants {} port(s) but ingress_udp does not include \
+ port 0 — kernel autobind of the source port will be DENIED, so those \
+ datagrams cannot be sent and the egress grant is inert. Add 0 to \
+ ingress_udp to permit ephemeral autobind.",
+ eg.len()
+ );
+ }
+ }
+
let mut created = rs
.create()
.map_err(|e| OringError::refuse("landlock-create", format!("create ruleset: {e}")))?;
@@ -358,7 +740,7 @@ pub fn apply(policy: &Policy) -> Result {
"--require-all-grants: {} of {intended} intended path(s) could not be granted \
(missing/unopenable): {}",
list.len(),
- list.join(", ")
+ crate::diag::safe(&list.join(", "))
),
));
}
@@ -367,7 +749,7 @@ pub fn apply(policy: &Policy) -> Result {
"oring: WARN skipped {} path(s) named by your policy but missing/unopenable — \
check for typos; the child gets NO access to them: {}",
list.len(),
- list.join(", ")
+ crate::diag::safe(&list.join(", "))
);
} else {
eprintln!(
@@ -378,8 +760,11 @@ pub fn apply(policy: &Policy) -> Result {
}
}
- // Net port rules (only when a net direction was handled).
- if net_report == "on" {
+ // Net port rules (only when a net direction was handled). Keyed on
+ // net_report != "off" rather than a string equality against one variant —
+ // the old `== "tcp-ports"` form would have silently dropped every rule the
+ // moment a second variant existed.
+ if net_report != "off" {
if let Some(ports) = &ll.egress_tcp {
for &p in ports {
created = created
@@ -398,6 +783,24 @@ pub fn apply(policy: &Policy) -> Result {
})?;
}
}
+ if let Some(ports) = &ll.egress_udp {
+ for &p in ports {
+ created = created
+ .add_rule(NetPort::new(p, AccessNet::ConnectSendUdp))
+ .map_err(|e| {
+ OringError::refuse("landlock-net", format!("udp connect rule :{p}: {e}"))
+ })?;
+ }
+ }
+ if let Some(ports) = &ll.ingress_udp {
+ for &p in ports {
+ created = created
+ .add_rule(NetPort::new(p, AccessNet::BindUdp))
+ .map_err(|e| {
+ OringError::refuse("landlock-net", format!("udp bind rule :{p}: {e}"))
+ })?;
+ }
+ }
}
// Enforce.
@@ -443,9 +846,11 @@ pub fn apply(policy: &Policy) -> Result {
scope: scope_report,
audit,
abi: abi_report,
+ errata: errata_report,
// Completeness, always attested: "granted/intended". 14/14 means the
// sandbox is what you asked for; 10/14 means it is silently narrower.
grants: format!("{}/{intended}", intended.saturating_sub(skipped.len())),
+ unhandled: unhandled_report,
})
}
@@ -548,6 +953,70 @@ pub fn detected_abi() -> i32 {
detect_abi_version()
}
+/// Detected errata for `--explain`: the bitmask, then each floor-level erratum
+/// as present or MISSING. Spelled out rather than left as hex, because the
+/// point of showing it is that a reader can act on it.
+pub fn detected_errata_string() -> String {
+ let errata = crate::sys::landlock_errata();
+ let Some(mask) = errata else {
+ return "? (kernel did not answer the errata probe)".to_string();
+ };
+ let detail: Vec = FLOOR_ERRATA
+ .iter()
+ .map(|e| {
+ let state = if erratum_applied(mask, e.number) {
+ "ok"
+ } else {
+ "MISSING"
+ };
+ format!("{}:{state}", e.number)
+ })
+ .collect();
+ format!("0x{mask:x} ({})", detail.join(" "))
+}
+
+/// Which restrictable rights this run left UNHANDLED — **derived, not declared**.
+///
+/// For each access class, the crate's own `from_all(abi)` enumerates every right
+/// it can name at the clamped ABI; the `handled_*` sets record what `apply()`
+/// actually passed to `handle_access`/`scope`. The difference is the gap, named
+/// right by right (`net.ConnectSendUdp`), in a form an operator can act on and a
+/// test can assert against.
+///
+/// **Why derived is the whole point.** The disclosure this replaced asked "is the
+/// kernel newer than the crate?" as a proxy for "is anything unrestricted?". The
+/// proxy held only while oring plumbed every right the crate could name, and it
+/// broke silently the first time that stopped being true — bumping the crate to
+/// V10 switched the warning OFF while UDP stayed wide open, because the *proxy*
+/// improved rather than the *coverage*. A set difference against `from_all`
+/// cannot drift that way: a right the crate learns and oring does not plumb
+/// appears here on the next run, with no table to update and nothing to
+/// remember. The maintenance burden is zero by construction, which is the only
+/// kind that survives.
+///
+/// **Bounded above by the crate ceiling, deliberately.** Rights the crate cannot
+/// name are invisible to `from_all` and so cannot appear here — that residual is
+/// the abi-ahead warning's job, and the two halves are reported together
+/// precisely because neither is complete alone.
+fn unhandled_rights(
+ abi: ABI,
+ handled_fs: BitFlags,
+ handled_net: BitFlags,
+ handled_scope: BitFlags,
+) -> Vec {
+ let mut out = Vec::new();
+ for r in (AccessFs::from_all(abi) & !handled_fs).iter() {
+ out.push(format!("fs.{r:?}"));
+ }
+ for r in (AccessNet::from_all(abi) & !handled_net).iter() {
+ out.push(format!("net.{r:?}"));
+ }
+ for r in (Scope::from_all(abi) & !handled_scope).iter() {
+ out.push(format!("scope.{r:?}"));
+ }
+ out
+}
+
/// Query the kernel's Landlock ABI version (rust-landlock 0.4.5 exposes no
/// public "current ABI" query, so we probe via `sys`). Returns 0 if unavailable.
fn detect_abi_version() -> i32 {
@@ -555,10 +1024,17 @@ fn detect_abi_version() -> i32 {
}
/// Map a detected ABI integer to the crate's `ABI` enum, CLAMPED to the crate's
-/// max ([`CRATE_MAX_ABI`]): V7 by default, V9 with the `resolve-unix` feature. A
-/// newer kernel exposes rights the crate can't name — those are left unhandled;
-/// `apply()` warns loudly (abi-ahead) rather than pretending the clamp is
-/// lossless.
+/// max ([`CRATE_MAX_ABI`], derived from [`CEILING`] — deliberately not restated
+/// as a number here: this comment still read "= V9" after the ceiling moved to
+/// V10, which is the whole reason the two encodings were collapsed into one). A
+/// newer kernel exposes rights the crate can't
+/// name — those are left unhandled; `apply()` warns loudly (abi-ahead) rather
+/// than pretending the clamp is lossless.
+///
+/// The `resolve-unix` cfgs below are retained (rather than unconditional) purely
+/// so a build without the feature fails with the single legible `compile_error!`
+/// at the top of this module instead of a pile of unresolved `ABI::V8`/`V9`
+/// paths. They go away when upstream ships V9 in a published release.
fn abi_from_version(v: i32) -> ABI {
match v {
1 => ABI::V1,
@@ -568,42 +1044,38 @@ fn abi_from_version(v: i32) -> ABI {
5 => ABI::V5,
6 => ABI::V6,
7 => ABI::V7,
- // V8/V9 are named only by a V9-capable crate; without the feature the
- // published crate stops at V7, so everything above clamps there.
+ // V8/V9 are named only by a V9-capable crate — which the V9 floor makes
+ // mandatory (see the compile_error! above). The non-feature arm is
+ // unreachable in a build that compiles at all; it is kept so this match
+ // stays total for every cfg combination.
#[cfg(feature = "resolve-unix")]
8 => ABI::V8,
#[cfg(feature = "resolve-unix")]
9 => ABI::V9,
- #[cfg(feature = "resolve-unix")]
- _ => ABI::V9,
- #[cfg(not(feature = "resolve-unix"))]
- _ => ABI::V7,
+ // At and above the ceiling, clamp TO the ceiling. Spelled as `CEILING`
+ // rather than a repeated variant so the constant and the clamp target
+ // cannot drift apart — that pair was previously coupled only by a proof.
+ _ => CEILING,
}
}
/// Integer rank of an `ABI` variant — the inverse of the V-number. Only used by
/// the `abi_from_version` proof/tests to reason about clamp bounds.
+///
+/// This was a hand-written match, which made it a THIRD independent encoding of
+/// the same fact alongside [`CEILING`] and [`CRATE_MAX_ABI`]. The crate's
+/// discriminants already ARE the V-numbers (pinned by the `const _` assertions
+/// near [`CEILING`]), so the cast is the inverse by construction and cannot
+/// drift.
+///
+/// It also strictly improves the `#[non_exhaustive]` case. The old `_ =>
+/// i32::MAX` was a sentinel chosen to FAIL the over-claim assertion if a future
+/// variant ever appeared. A cast does better: an unknown variant yields its
+/// TRUE rank, which is above the ceiling and so fails the same assertion — for
+/// the real reason, and with a number a reader can act on.
#[cfg(any(test, kani))]
fn abi_rank(abi: ABI) -> i32 {
- match abi {
- ABI::Unsupported => 0,
- ABI::V1 => 1,
- ABI::V2 => 2,
- ABI::V3 => 3,
- ABI::V4 => 4,
- ABI::V5 => 5,
- ABI::V6 => 6,
- ABI::V7 => 7,
- #[cfg(feature = "resolve-unix")]
- ABI::V8 => 8,
- #[cfg(feature = "resolve-unix")]
- ABI::V9 => 9,
- // `ABI` is #[non_exhaustive]. `abi_from_version` never constructs a
- // variant beyond CRATE_MAX_ABI, so this is unreachable — but map it to a
- // rank that FAILS the over-claim assertion, so if that ever changed the
- // proof/test would catch it rather than silently pass.
- _ => i32::MAX,
- }
+ abi as i32
}
/// Kani proof `abi_from_version_clamp` (run via `make verify`): for EVERY `i32`,
@@ -634,6 +1106,84 @@ fn abi_from_version_clamp() {
mod tests {
use super::*;
+ /// An unanswered errata probe must render as `?`, never as blank or `0x0`.
+ /// `0x0` is a real answer meaning "this kernel carries none of them" and
+ /// must stay distinguishable from "the kernel did not tell us" — collapsing
+ /// the two would report absent evidence as a clean result.
+ #[test]
+ fn errata_absent_is_distinguishable_from_none_applied() {
+ assert_eq!(describe_errata(None), "?");
+ assert_eq!(describe_errata(Some(0)), "0x0");
+ assert_eq!(describe_errata(Some(7)), "0x7");
+ }
+
+ /// The bit mapping, pinned against the kernel's own: `BIT(number - 1)`
+ /// (`security/landlock/setup.c`). This is the assertion that was missing
+ /// when errata support landed — the mapping was open-coded at two call
+ /// sites and an off-by-one at either would have produced a false WARN on
+ /// every conforming kernel while both then-existing tests still passed.
+ #[test]
+ fn erratum_bit_mapping_matches_the_kernel() {
+ // Erratum N occupies bit N-1, so erratum 1 is the LOW bit. An
+ // off-by-one in either direction breaks exactly these four.
+ assert!(erratum_applied(0b001, 1), "erratum 1 must read bit 0");
+ assert!(!erratum_applied(0b010, 1), "erratum 1 must NOT read bit 1");
+ assert!(erratum_applied(0b100, 3), "erratum 3 must read bit 2");
+ assert!(
+ !erratum_applied(0b011, 3),
+ "erratum 3 must NOT read bit 0/1"
+ );
+
+ // The live shape: this kernel family reports 0x7 = all three present.
+ for e in FLOOR_ERRATA {
+ assert!(erratum_applied(0b111, e.number), "0x7 means all applied");
+ assert!(!erratum_applied(0, e.number), "0x0 means none applied");
+ }
+
+ // Total on degenerate input: a 0 number must not underflow into a
+ // 255-bit shift, and a number past the mask width must not panic.
+ assert!(!erratum_applied(u64::MAX, 0) || erratum_applied(u64::MAX, 1));
+ assert!(!erratum_applied(u64::MAX, 200));
+ }
+
+ /// Golden pin of the table's contents against the kernel headers it
+ /// transcribes (`security/landlock/errata/abi-{1,4,6}.h`).
+ ///
+ /// **This is a REGRESSION guard, not a verification** — it cannot detect a
+ /// wrong transcription, only a later drift from whatever was transcribed,
+ /// because the kernel headers are not available at test time. It exists
+ /// because the weaker invariant below is satisfied by a table with two
+ /// erratum/ABI pairs swapped, which would make the WARN text name the wrong
+ /// ABI while every other test still passed. Update it deliberately when the
+ /// kernel adds an erratum; do not "fix" it to match an edit.
+ #[test]
+ fn floor_errata_table_matches_the_kernel_headers() {
+ let got: Vec<(u8, i32)> = FLOOR_ERRATA.iter().map(|e| (e.number, e.abi)).collect();
+ assert_eq!(
+ got,
+ vec![(1, 4), (2, 6), (3, 1)],
+ "FLOOR_ERRATA drifted from the kernel: erratum 1 is declared at ABI 4 \
+ (errata/abi-4.h), erratum 2 at ABI 6 (abi-6.h), erratum 3 at ABI 1 (abi-1.h)"
+ );
+ }
+
+ /// Every erratum oring warns about must have been introduced at or below
+ /// the floor. Warning about an erratum from a newer ABI would fire on every
+ /// conforming kernel — noise that trains operators to ignore the warning.
+ #[test]
+ fn floor_errata_are_at_or_below_the_floor() {
+ for e in FLOOR_ERRATA {
+ assert!(
+ e.abi <= FLOOR_ABI,
+ "erratum {} is declared at ABI V{} which is above FLOOR_ABI V{FLOOR_ABI}; \
+ a kernel meeting the floor is not expected to carry it",
+ e.number,
+ e.abi
+ );
+ assert!(e.number >= 1, "erratum numbers are 1-based (bit number-1)");
+ }
+ }
+
#[test]
fn tier_invariants() {
// The security-load-bearing properties, asserted against the COMPUTED
@@ -717,32 +1267,120 @@ mod tests {
assert!(below_floor(-1), "an error probe (abi<0) must refuse");
}
+ /// Every ABI the crate can be at floor has a kernel version, and the mapping is
+ /// monotone. Guards the message an operator acts on: `--check` and the suite's
+ /// skip lines both tell them which kernel to get, and both used to carry `7.1`
+ /// as a literal — still saying it after the floor moved to V10.
+ ///
+ /// The monotone check is the half that would otherwise rot unnoticed: a wrong
+ /// version at a *single* entry still prints something plausible, so the property
+ /// worth asserting is that later ABIs never map to earlier kernels.
+ #[test]
+ fn floor_kernel_is_known() {
+ assert!(
+ !FLOOR_KERNEL.is_empty(),
+ "FLOOR_ABI must map to a kernel version"
+ );
+ assert_eq!(FLOOR_KERNEL, "7.2", "V10 first shipped in mainline 7.2");
+
+ // Every known ABI parses as major.minor and the sequence rises with the ABI.
+ // Swept over a range WIDER than the mapped set, so an arm added out of order
+ // — or a gap in the middle — is visible rather than skipped.
+ let mut prev = (0u32, 0u32);
+ let mut prev_abi = 0i32;
+ let mut seen = 0;
+ for abi in 1..=FLOOR_ABI + 4 {
+ let ver = kernel_for(abi);
+ if ver.is_empty() {
+ assert!(
+ abi != FLOOR_ABI,
+ "the floor itself must have a kernel version"
+ );
+ continue;
+ }
+ seen += 1;
+ let (maj, min) = ver
+ .split_once('.')
+ .map(|(a, b)| (a.parse::().unwrap(), b.parse::().unwrap()))
+ .unwrap_or_else(|| panic!("kernel_for({abi}) = {ver:?} is not major.minor"));
+ assert!(
+ (maj, min) >= prev,
+ "ABI V{abi} maps to kernel {ver}, EARLIER than V{prev_abi}'s {}.{} — \
+ a later right cannot appear in an earlier kernel",
+ prev.0,
+ prev.1
+ );
+ prev = (maj, min);
+ prev_abi = abi;
+ }
+ // Frame: the span swept and how many arms it found, so "all monotone" is
+ // checkable against a count rather than asserted over an empty sweep.
+ assert!(
+ seen >= 4,
+ "swept ABI 1..={} and found only {seen} mapped version(s) — \
+ the monotonicity check above was nearly vacuous",
+ FLOOR_ABI + 4
+ );
+ assert!(
+ kernel_for(FLOOR_ABI + 4).is_empty(),
+ "an unreleased ABI must map to \"\" (unknown), never to a guess"
+ );
+ }
+
#[test]
fn floor_and_abi_maps() {
- // Floor is V7 (the coherent-cage minimum). A kernel above the crate's
- // handled ceiling clamps DOWN to that ceiling, never to a lower/empty set.
- assert_eq!(FLOOR_ABI, 7);
+ // Floor is V10 since 2026-07-29 — the coherent-cage minimum, which now includes
+ // the UDP net rights as well as RESOLVE_UNIX. A kernel above the crate's handled
+ // ceiling clamps DOWN to that ceiling, never to a lower/empty set.
+ //
+ // These two are asserted as LITERALS on purpose, not against the constants they
+ // pin. Deriving them (`assert_eq!(FLOOR_ABI, FLOOR_ABI)`) is the shape that cannot
+ // fail, and this test is the tripwire for an accidental floor change — so a
+ // deliberate one has to come here and say so.
+ assert_eq!(FLOOR_ABI, 10);
+ assert_eq!(CRATE_MAX_ABI, 10);
+ // At this floor the two coincide, which is worth stating because it makes the
+ // abi-ahead reporting path unreachable on a conforming kernel: there is no ABI a
+ // supported kernel can expose that the crate cannot name. It becomes reachable
+ // again the moment the kernel gains V11, and `unhandled=`/`errata=` exist for
+ // exactly that.
+ assert_eq!(FLOOR_ABI, CRATE_MAX_ABI);
assert!(matches!(abi_from_version(7), ABI::V7));
+ assert!(matches!(abi_from_version(8), ABI::V8));
+ assert!(matches!(abi_from_version(9), ABI::V9));
+ assert!(matches!(abi_from_version(10), ABI::V10));
+ assert!(matches!(abi_from_version(11), ABI::V10));
- // Without the feature the crate stops at V7, so everything above clamps
- // to V7 and resolve-unix is never active.
- #[cfg(not(feature = "resolve-unix"))]
- {
- assert_eq!(CRATE_MAX_ABI, 7);
- assert!(matches!(abi_from_version(9), ABI::V7));
- assert!(!resolve_unix_active(9));
- }
- // With the feature the crate names V8/V9; V10+ clamps to V9, and the
- // pathname-socket wall is active on a V9 kernel.
- #[cfg(feature = "resolve-unix")]
- {
- assert_eq!(CRATE_MAX_ABI, 9);
- assert!(matches!(abi_from_version(8), ABI::V8));
- assert!(matches!(abi_from_version(9), ABI::V9));
- assert!(matches!(abi_from_version(10), ABI::V9));
- assert!(resolve_unix_active(9));
- assert!(!resolve_unix_active(8));
- }
+ // The load-bearing pairing: the pathname-AF_UNIX wall is active at and
+ // above the floor, and inactive everywhere the floor refuses. If these two
+ // ever disagree, a sub-floor kernel could run with the class unmediated —
+ // which is the residual the V9 floor exists to delete (§2).
+ assert!(resolve_unix_active(FLOOR_ABI));
+ assert!(resolve_unix_active(FLOOR_ABI + 1));
+
+ // **The pairing changed shape when the floor rose, and this is the interesting
+ // part.** Under a V9 floor the wall's ABI and the floor were the SAME number, so
+ // `!resolve_unix_active(FLOOR_ABI - 1)` held and pinned them together. At a V10
+ // floor `resolve_unix_active(9)` is TRUE while 9 is BELOW the floor — the wall is
+ // now guaranteed with a margin rather than exactly at the boundary, and asserting
+ // the old clause would fail for a reason that is not a defect.
+ //
+ // So the property is restated as what it always meant: the wall is active
+ // everywhere the floor ADMITS, and the floor sits at or above the ABI that
+ // introduces it. A floor drop below 9 would break the second clause and fail here.
+ // `FLOOR_ABI >= RESOLVE_UNIX_ABI` is asserted at COMPILE time beside the constant,
+ // not here: over two `const`s it is a check that cannot fail at run time.
+ assert!(resolve_unix_active(RESOLVE_UNIX_ABI));
+ assert!(!resolve_unix_active(RESOLVE_UNIX_ABI - 1));
+ assert!(
+ below_floor(9),
+ "V9 has RESOLVE_UNIX but LACKS the UDP net rights, so at a V10 floor it must be \
+ refused — not run with UDP unmediated, which is the residual this floor deletes"
+ );
+ assert!(
+ below_floor(8),
+ "V8 lacks RESOLVE_UNIX as well, so it must be refused, not warned about"
+ );
// The clamp property `make verify` proves exhaustively over all i32,
// spot-checked here so normal CI catches a dropped/mis-mapped arm without
diff --git a/src/lib.rs b/src/lib.rs
index 3bbcadd..43b893e 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -39,6 +39,7 @@
#[cfg(all(target_os = "linux", not(target_arch = "x86_64")))]
compile_error!("oring v1 supports only linux x86_64 (x32/seccomp arch guard); aarch64 is roadmap");
+pub mod diag;
pub mod error;
pub mod policy;
// Post-hoc denial report (§8). Linux-only: reads Landlock V7 audit records.
diff --git a/src/main.rs b/src/main.rs
index 9edbea4..7b14d7d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -73,8 +73,26 @@ OPTIONS:
scratch (Miri's sysroot build, cc/ld, -sys/bindgen build
scripts, `go test`/`go run`). /tmp itself stays no-exec.
Bounded and operator-named — wipe it between runs.
+ --tmpdir create DIR (0700), grant it write_noexec, and point the
+ child's TMPDIR at it. oring NO LONGER reads $TMPDIR from
+ the environment: the caller is the adversary, and a
+ caller-supplied path silently widened a profile's tiers
+ (measured: TMPDIR=$HOME/.ssh yielded write on
+ authorized_keys). Name the scratch dir here instead.
--proc /proc grant: full (default) | self | none
- (self = own pid + curated globals; closes peer-procfs)
+ (self = THE SEALING PID's procfs + curated globals;
+ closes peer-procfs METADATA. The grant resolves
+ /proc/self ONCE, at seal time, so it names one pid:
+ execve keeps it, fork does not. Every external command
+ the child spawns therefore gets EACCES on its OWN
+ /proc/self/{maps,exe,status}. It DOES buy peer-environ
+ privacy between the child's own subprocesses: Landlock
+ scopes ancestor-OR-EQUAL, so siblings under one seal
+ read each other's environ under --proc full and cannot
+ under --proc self (measured). What walls an OUT-OF-domain
+ peer is that domain scoping itself, not the ABI floor —
+ it consults no handled right and predates Landlock's
+ merge, so no floor bought it. See DESIGN.md §2.)
--env-clear pass NO environment to the child except --env-keep vars
(withhold API keys etc. from a compromised child)
--env-keep allowlist one more env var to pass through (repeatable).
@@ -89,6 +107,15 @@ OPTIONS:
--require-all-grants refuse to seal if ANY intended path could not be
granted (missing/unopenable). For CI: turns a silent
under-grant into a hard failure
+ --require-full-coverage
+ refuse to seal if ANY restrictable right was left
+ UNHANDLED (i.e. `unhandled=` would not be `-`).
+ Opt-in. Both shipped profiles PASS it as of
+ 2026-07-29; it is a hand-authored `--policy` that can
+ leave a right unhandled. Use it when coverage must be
+ ESTABLISHED, not reported — the `unhandled=`
+ disclosure is a stderr line the caller can discard,
+ an exit code is not.
--denials report what Landlock DENIED to a sealed run (§8).
Turns a mystery EACCES into `fs.read_dir /home/u`.
Takes the pid from the SEALED line; execve preserves
@@ -101,10 +128,12 @@ OPTIONS:
-h, --help this help
--version version + git sha
-Policy also via ORING_POLICY (JSON env). ORING_QUIET=1 suppresses the seal line.
+Policy also via ORING_POLICY (JSON env). ORING_QUIET=1 suppresses the seal line
+(under-coverage warnings still print — they are not suppressible).
Delivery precedence (highest wins; refuse if none): --policy > ORING_POLICY > --profile.
-EXIT: 120 confinement refused 121 policy error 122 target not found
+EXIT: 2 usage error (unknown flag, bad --profile name — oring's own, no child ran)
+ 120 confinement refused 121 policy error 122 target not found
159 seccomp KILL (x32/arch guard) * child's execve-preserved status";
#[derive(Default)]
@@ -113,10 +142,12 @@ struct Args {
profile: Option,
workdir: Option,
exec_tmp: Option,
+ tmpdir: Option,
proc_mode: Option,
env_clear: bool,
env_inherit: bool,
require_all_grants: bool,
+ require_full_coverage: bool,
env_keep: Vec,
denials_pid: Option,
since: Option,
@@ -158,12 +189,14 @@ fn parse_args(argv: Vec) -> Result {
"--env-clear" => a.env_clear = true,
"--env-inherit" => a.env_inherit = true,
"--require-all-grants" => a.require_all_grants = true,
+ "--require-full-coverage" => a.require_full_coverage = true,
"--env-keep" => a.env_keep.push(need_val(&mut it, "--env-keep")?),
"-v" | "--verbose" => a.verbose = true,
"--policy" => a.policy_file = Some(need_val(&mut it, "--policy")?),
"--profile" => a.profile = Some(need_val(&mut it, "--profile")?),
"--workdir" => a.workdir = Some(need_val(&mut it, "--workdir")?),
"--exec-tmp" => a.exec_tmp = Some(need_val(&mut it, "--exec-tmp")?),
+ "--tmpdir" => a.tmpdir = Some(need_val(&mut it, "--tmpdir")?),
"--proc" => a.proc_mode = Some(parse_proc(&need_val(&mut it, "--proc")?)?),
other => {
return Err(OringError::usage(format!(
@@ -221,31 +254,55 @@ fn parse_proc(s: &str) -> Result {
/// controls a WRITABLE PARENT dir can still TOCTOU-swap the entry between this
/// check and the grant; naming the scratch under a sticky dir (`/tmp`) or one
/// the operator owns closes that.
-fn create_exec_tmp(p: &Path) -> Result<()> {
+/// `materialize` splits the SIDE EFFECT from the PREDICATE. `--explain` must not
+/// create a directory, but it must still report a scratch path that would be
+/// refused — otherwise the preview shows a plan the real run rejects, which is
+/// the opposite of what a preview is for. So creation is gated; validation of an
+/// entry that already exists is not.
+fn create_scratch_dir(flag: &str, p: &Path, materialize: bool) -> Result<()> {
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
- match std::fs::DirBuilder::new().mode(0o700).create(p) {
- Ok(()) => Ok(()),
- Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
+ if materialize {
+ match std::fs::DirBuilder::new().mode(0o700).create(p) {
+ Ok(()) => return Ok(()),
+ Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
+ Err(e) => {
+ return Err(OringError::usage(format!(
+ "{flag}: cannot create {}: {e}",
+ p.display()
+ )))
+ }
+ }
+ }
+ {
+ {
// lstat, not stat: we must judge the entry itself, not its target.
- let md = std::fs::symlink_metadata(p).map_err(|e| {
- OringError::usage(format!("--exec-tmp: cannot stat {}: {e}", p.display()))
- })?;
+ // Absent on a preview is fine — the real run will create it.
+ let md = match std::fs::symlink_metadata(p) {
+ Ok(md) => md,
+ Err(_) if !materialize => return Ok(()),
+ Err(e) => {
+ return Err(OringError::usage(format!(
+ "{flag}: cannot stat {}: {e}",
+ p.display()
+ )))
+ }
+ };
if md.file_type().is_symlink() {
return Err(OringError::usage(format!(
- "--exec-tmp is a symlink: {} — refusing (it would grant its \
- target write_exec); name a real directory",
+ "{flag} is a symlink: {} — refusing (it would grant its target a \
+ writable tier); name a real directory",
p.display()
)));
}
if !md.is_dir() {
return Err(OringError::usage(format!(
- "--exec-tmp exists but is not a directory: {}",
+ "{flag} exists but is not a directory: {}",
p.display()
)));
}
if md.permissions().mode() & 0o022 != 0 {
return Err(OringError::usage(format!(
- "--exec-tmp is group/other-writable ({:#o}): {} — refusing \
+ "{flag} is group/other-writable ({:#o}): {} — refusing \
(another uid could plant an executable the child then runs); \
`chmod 700` it",
md.permissions().mode() & 0o777,
@@ -254,10 +311,6 @@ fn create_exec_tmp(p: &Path) -> Result<()> {
}
Ok(())
}
- Err(e) => Err(OringError::usage(format!(
- "--exec-tmp: cannot create {}: {e}",
- p.display()
- ))),
}
}
@@ -285,6 +338,7 @@ fn load_policy(a: &Args, materialize: bool) -> Result {
// best-effort defaults. Drives WARN-vs-INFO on a missing granted path.
policy.user_authored = !from_profile;
policy.require_all_grants = a.require_all_grants;
+ policy.require_full_coverage = a.require_full_coverage;
// Precedence footgun (CALIF): a --policy file or ORING_POLICY env WHOLE-
// REPLACES an explicit --profile (no per-field merge). An env var silently
@@ -319,6 +373,24 @@ fn load_policy(a: &Args, materialize: bool) -> Result {
// Landlock cannot grant a path that does not exist, so a typo would seal
// the child with NO workspace and surface later as a confusing EACCES.
// Fail fast and loudly instead (follows symlinks, matching PathFd).
+ //
+ // But judge the ENTRY before its target. `--exec-tmp` already refuses a
+ // symlink for exactly this reason and `--workdir` did not, which was an
+ // asymmetry with teeth: `metadata()` follows links, so
+ // `--workdir /tmp/link` where `link -> /usr/local/bin` granted
+ // `write_exec` on `/usr/local/bin` — a $PATH directory — while
+ // `--explain` printed only `write_exec: ["/tmp/link"]`, hiding the real
+ // target from the operator reviewing the cage they just asked for.
+ // lstat first, same test and same reasoning as `create_exec_tmp`.
+ let lmd = std::fs::symlink_metadata(&p)
+ .map_err(|e| OringError::policy(format!("--workdir: cannot stat {dir}: {e}")))?;
+ if lmd.file_type().is_symlink() {
+ return Err(OringError::policy(format!(
+ "--workdir is a symlink: {dir} — refusing (it would grant its \
+ target a writable tier, and --explain would show only the link); \
+ name the real directory"
+ )));
+ }
match std::fs::metadata(&p) {
Ok(m) if m.is_dir() => {}
Ok(_) => {
@@ -359,9 +431,7 @@ fn load_policy(a: &Args, materialize: bool) -> Result {
"--exec-tmp must be absolute: {dir}"
)));
}
- if materialize {
- create_exec_tmp(&p)?;
- }
+ create_scratch_dir("--exec-tmp", &p, materialize)?;
// Grant the canonical path when it exists (matches how add_tier opens
// it); fall back to the literal path for a preview where it may not.
let granted = std::fs::canonicalize(&p).unwrap_or(p);
@@ -374,16 +444,44 @@ fn load_policy(a: &Args, materialize: bool) -> Result {
"oring: WARN --exec-tmp grants {} write_exec (write+execute), \
reopening a W^X hole under the `{name}` profile whose purpose is \
true W^X — only that scratch dir is affected",
- granted.display()
+ oring::diag::safe_display(&granted.display())
);
}
policy.add_workdir(granted); // write_exec tier
}
+ // --tmpdir: the write_noexec sibling of --exec-tmp, and the replacement for
+ // reading $TMPDIR out of the environment (see the long note in profile.rs).
+ // Same validation, because the shapes it rejects are the same ones: a
+ // symlink would grant its target, a non-directory would grant a FILE a
+ // writable tier (`TMPDIR=~/.bashrc` yielded WriteFile+Truncate on the
+ // operator's shell rc — a deferred-execution primitive invisible to both
+ // the $HOME guard and the tier-overlap check), and a group/other-writable
+ // dir lets another uid stage content.
+ if let Some(dir) = &a.tmpdir {
+ let p = PathBuf::from(dir);
+ if !p.is_absolute() {
+ return Err(OringError::usage(format!(
+ "--tmpdir must be absolute: {dir}"
+ )));
+ }
+ create_scratch_dir("--tmpdir", &p, materialize)?;
+ let granted = std::fs::canonicalize(&p).unwrap_or(p);
+ policy.add_workdir_noexec(granted);
+ }
+
policy.validate()?;
// After every tier is final (--workdir/--proc/--exec-tmp folded in), so the
// check sees the tiers the kernel will actually get.
policy.check_self_protection(a.policy_file.as_deref().map(Path::new))?;
+ // Same point in the pipeline, opposite direction: the caller must not have
+ // widened a built-in profile out from under us (e.g. $TMPDIR=$HOME).
+ policy.check_profile_grants(std::env::var_os("HOME").map(PathBuf::from).as_deref())?;
+ // AFTER every injection (--workdir, --exec-tmp, $TMPDIR, --proc), because
+ // the overlap that matters is in the tier set actually about to be sealed —
+ // `--workdir /tmp` creates one that neither the profile nor the flag has on
+ // its own.
+ policy.check_tier_overlap()?;
Ok(policy)
}
@@ -395,6 +493,11 @@ fn explain(policy: &Policy, a: &Args) {
"detected Landlock ABI: {}",
oring::landlock::detected_abi_string()
);
+ #[cfg(target_os = "linux")]
+ println!(
+ "detected errata: {}",
+ oring::landlock::detected_errata_string()
+ );
println!("read_exec: {:?}", ll.read_exec);
println!("read_only: {:?}", ll.read_only);
println!("write_noexec: {:?}", ll.write_noexec);
@@ -461,8 +564,9 @@ fn run() -> Result<()> {
return Err(OringError::refuse(
"landlock-abi",
format!(
- "Landlock ABI V{abi} < V{} (need kernel 6.15+); seal would refuse",
- oring::landlock::FLOOR_ABI
+ "Landlock ABI V{abi} < V{} (need kernel {}+); seal would refuse",
+ oring::landlock::FLOOR_ABI,
+ oring::landlock::FLOOR_KERNEL
),
));
}
@@ -502,11 +606,68 @@ fn run() -> Result<()> {
// whichever keep mode admits it. `extra_keep` then force-admits TMPDIR even
// under --env-clear, where it is otherwise dropped — without it the scratch
// dir would be granted but unused.
+ // oring OWNS the child's TMPDIR. It is always set here — never inherited —
+ // to whatever scratch was actually granted: --exec-tmp, else --tmpdir, else
+ // /tmp (write_noexec in both built-in profiles by construction).
+ //
+ // Setting it is not optional once the grant stopped following it. Dropping
+ // the grant while still PASSING the caller's value is a Type-II failure: the
+ // child gets a TMPDIR it cannot write, and links fail with a mystery EACCES
+ // that trains the operator to loosen policy until it works. That failure
+ // already existed on the --policy path, where $TMPDIR was never consulted —
+ // measured: `TMPDIR=/scratch oring --policy p.json -- sh -c 'echo hi >
+ // "$TMPDIR/probe"'` gives "Permission denied". Owning the variable fixes
+ // both paths at once.
let mut extra_keep: Vec = Vec::new();
- if let Some(dir) = &a.exec_tmp {
- let resolved = std::fs::canonicalize(dir).unwrap_or_else(|_| PathBuf::from(dir));
- std::env::set_var("TMPDIR", &resolved);
- extra_keep.push("TMPDIR".to_string());
+ // Resolve the scratch dir from the COMMAND LINE, never the environment.
+ // Falling back to /tmp only when the final policy actually grants it: the
+ // built-in profiles put /tmp in write_noexec by construction, but a
+ // user-authored --policy need not, and asserting TMPDIR=/tmp against a
+ // policy that did not grant it would recreate the Type-II failure this
+ // change exists to remove — just pointed somewhere else.
+ let scratch: Option = match a.exec_tmp.as_ref().or(a.tmpdir.as_ref()) {
+ Some(d) => Some(std::fs::canonicalize(d).unwrap_or_else(|_| PathBuf::from(d))),
+ None => {
+ let tmp = Path::new("/tmp");
+ let granted = policy
+ .landlock
+ .write_noexec
+ .iter()
+ .chain(policy.landlock.write_exec.iter())
+ // Canonicalise the granted path, same reason as the three guards in
+ // policy.rs: `add_tier` grants the resolved inode. Conservative direction
+ // here — a symlink to /tmp would make this read "not granted" and hand the
+ // child no TMPDIR — but the guards must agree with the kernel, not merely
+ // err safely, or the next one to be written copies the lexical shape.
+ .any(|p| tmp.starts_with(oring::policy::canonical_or_literal(p)));
+ granted.then(|| PathBuf::from("/tmp"))
+ }
+ };
+ if let Some(inherited) = std::env::var_os("TMPDIR") {
+ if scratch.as_deref() != Some(Path::new(&inherited)) {
+ eprintln!(
+ "oring: INFO ignoring inherited TMPDIR={} — oring no longer takes the \
+ scratch path from the environment (a caller-supplied value silently \
+ widened profile tiers; measured, TMPDIR=$HOME/.ssh granted write on \
+ authorized_keys). Name it with --tmpdir or --exec-tmp instead.",
+ oring::diag::safe_display(&Path::new(&inherited).display())
+ );
+ }
+ }
+ match &scratch {
+ Some(s) => {
+ // Single-threaded and pre-seal, so set_var is sound here; the child
+ // env is BUILT from oring's own vars_os filtered by the keep-set, so
+ // this is how the new value reaches it. `extra_keep` force-admits it
+ // even under --env-clear, where it would otherwise be dropped.
+ std::env::set_var("TMPDIR", s);
+ extra_keep.push("TMPDIR".to_string());
+ }
+ // No granted scratch: hand the child NO TMPDIR rather than one it cannot
+ // write. POSIX tools then use their own /tmp default, which is an honest
+ // "unset" instead of a grant oring cannot back. Removed explicitly
+ // because TMPDIR is in BASE_ENV_KEEP and would otherwise pass through.
+ None => std::env::remove_var("TMPDIR"),
}
// Allowlist by default. The child's env is BUILT, never scrubbed (sys::exec
@@ -593,6 +754,73 @@ mod probe {
let arg = args.get(1).map(String::as_str).unwrap_or("");
match op {
"fs-read" => exit(errno_of(std::fs::read(arg).map(|_| ()))),
+ // The DIVERGENCE probe (CON-3). Reports `access(R_OK)` and `open()` for the
+ // SAME path in the SAME process, so the two can be compared with one variable.
+ //
+ // Landlock cannot mediate `access(2)`: verified against this kernel's own
+ // `security/landlock/fs.c`, which registers `file_open` and the `path_*`/`sb_*`
+ // hooks and registers **neither** `inode_permission` nor `inode_getattr`. So a
+ // withheld path stays VISIBLE to a pre-flight check while the open that follows
+ // is denied — a tool that trusts `access()` takes the wrong branch and produces
+ // a wrong result with nobody attacking. Structural: no ABI bump changes it.
+ //
+ // Exit encodes both answers so one run yields the pair:
+ // 0 access OK, open OK — no divergence
+ // 10 access OK, open DENIED — THE DIVERGENCE
+ // 20 access DENIED, open OK — inverted (would be surprising)
+ // 30 access DENIED, open DENIED — consistent refusal
+ // IN-DOMAIN peer read (BUILD-QUEUE T19). Two SIBLINGS under ONE seal: this
+ // process spawns B (holding a decoy secret in its environ), then spawns A to
+ // read B's `/proc//environ`, and exits with A's errno.
+ //
+ // Siblings, not parent->child, and that distinction is the whole point:
+ // `ptrace_may_access` permits an ANCESTOR to read a descendant regardless of
+ // Landlock, so a parent reading its own child would pass for reasons that have
+ // nothing to do with the domain relation. Landlock scopes ancestor-or-equal, so
+ // two children of one seal share a domain and the ptrace hook permits them —
+ // which is exactly the case `SECURITY.md` publishes advice about.
+ //
+ // TWO DIFFERENT MECHANISMS, which SECURITY.md itself had to separate on
+ // 2026-07-28 after presenting them as one controlled pair:
+ // --proc full -> READABLE: the domain relation permits it (0)
+ // --proc self -> DENIED at the Landlock FS TIER, because the sibling's
+ // /proc/ is not a granted path, BEFORE
+ // ptrace_may_access is consulted at all (EACCES)
+ "proc-indomain-env" => {
+ let me = std::env::current_exe()
+ .unwrap_or_else(|_| std::path::PathBuf::from("/proc/self/exe"));
+ let mut b = match std::process::Command::new("/bin/sleep")
+ .arg("5")
+ .env("ORING_DECOY", "sibling-secret")
+ .spawn()
+ {
+ Ok(c) => c,
+ Err(e) => exit(240 + (e.raw_os_error().unwrap_or(0) % 10)),
+ };
+ let target = format!("/proc/{}/environ", b.id());
+ let a = std::process::Command::new(&me)
+ .args(["__probe", "fs-read", &target])
+ .status();
+ let _ = b.kill();
+ let _ = b.wait();
+ exit(match a {
+ Ok(st) => st.code().unwrap_or(239),
+ Err(_) => 238,
+ });
+ }
+ "fs-access-vs-open" => {
+ let c = std::ffi::CString::new(arg).unwrap_or_default();
+ // SAFETY: `c` is a NUL-terminated C string that outlives the call; R_OK is
+ // a plain flag. `access` only reads the path.
+ let acc_ok = unsafe { libc::access(c.as_ptr(), libc::R_OK) } == 0;
+ let open_ok = std::fs::File::open(arg).is_ok();
+ exit(match (acc_ok, open_ok) {
+ (true, true) => 0,
+ (true, false) => 10,
+ (false, true) => 20,
+ (false, false) => 30,
+ });
+ }
"fs-readdir" => exit(errno_of(std::fs::read_dir(arg).map(|it| {
it.count();
}))),
@@ -613,6 +841,15 @@ mod probe {
"sc-keyctl" => exit(raw_errno(unsafe {
libc::syscall(libc::SYS_keyctl, 0, 0, 0, 0, 0)
})),
+ // T14. setns is the one namespace-family syscall whose denial is
+ // DIRECTLY attributable without a userns: unprivileged it returns
+ // EBADF bare (fd -1 is invalid, checked before any permission), and
+ // EPERM sealed (ring 2 refuses the call outright). The CLONE_NEW*
+ // flags cannot do this — unprivileged they are EPERM bare too, so a
+ // sealed EPERM attributes nothing; mount/pivot_root likewise.
+ "sc-setns" => exit(raw_errno(unsafe {
+ libc::syscall(libc::SYS_setns, -1i32, 0i32)
+ })),
"sc-unshare-newuser" => exit(raw_errno(unsafe {
libc::syscall(libc::SYS_unshare, 0x1000_0000)
})),
@@ -774,8 +1011,9 @@ mod probe {
"unix-connect" => {
// Pathname AF_UNIX connect. Walled by Landlock RESOLVE_UNIX at
// ABI>=9 (handled via from_all, granted in no tier): connect to
- // an out-of-domain pathname socket -> EACCES. Below V9 the right
- // doesn't exist and this is the §2 unmediated residual.
+ // an out-of-domain pathname socket -> EACCES. V9 is below the floor as
+ // of v0.2, so this is a proof cell on every kernel oring runs on
+ // — no longer a build-gated cell that auto-skips (§2, D-4b).
let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0) };
if fd < 0 {
exit(os_errno());
@@ -825,6 +1063,50 @@ mod probe {
let r = unsafe { libc::connect(fd, (&a as *const libc::sockaddr_un).cast(), len) };
exit(if r < 0 { os_errno() } else { 0 });
}
+ // T15. `NO_NEW_PRIVS` is a headline guarantee and the seal line's
+ // `nnp=1` is a LITERAL — the one field there not derived from run
+ // state. It is true by a double invariant (`set_no_new_privs()?`
+ // aborts before the line is reached, and unprivileged
+ // `SECCOMP_SET_MODE_FILTER` returns EACCES without NNP) rather than
+ // by observation, so it could be falsified by a refactor and never
+ // by a run. This probe is what lets a run falsify it: it reports the
+ // kernel's own view from `/proc/self/status`, which `execve`
+ // preserves, so the value read here is the sealed child's.
+ //
+ // Encoding: NoNewPrivs * 10 + Seccomp. Bare = 0, sealed = 12
+ // (NNP 1, seccomp mode 2 = filter). 200+ = the field could not be
+ // read or parsed, which must not be mistaken for a bare 0.
+ "nnp-status" => {
+ let Ok(s) = std::fs::read_to_string("/proc/self/status") else {
+ exit(200);
+ };
+ let field = |k: &str| -> Option {
+ s.lines()
+ .find_map(|l| l.strip_prefix(k))
+ .and_then(|v| v.trim().parse::().ok())
+ };
+ match (field("NoNewPrivs:"), field("Seccomp:")) {
+ (Some(n), Some(sc)) => exit(n * 10 + sc),
+ (None, _) => exit(201),
+ (_, None) => exit(202),
+ }
+ }
+ // T10. Read one byte from a raw fd number and report whether it was
+ // there. The FD scrub is `close_range(3.., CLOSE_RANGE_CLOEXEC)`,
+ // which SETS CLOEXEC rather than closing — the fds go away at the
+ // `execve` that follows, so the child is the only place the effect
+ // is observable. 0 = the fd was open and readable, otherwise errno
+ // (EBADF = 9 is the scrubbed case).
+ "fd-read" => {
+ let n: libc::c_int = arg.parse().unwrap_or(-1);
+ let mut buf = [0u8; 1];
+ // SAFETY: `buf` is a valid writable single byte for the length
+ // passed. `read(2)` on a closed or invalid descriptor returns
+ // -1/EBADF rather than touching the buffer, which is precisely
+ // the case under test.
+ let rc = unsafe { libc::read(n, buf.as_mut_ptr().cast(), 1) };
+ exit(raw_errno(rc as libc::c_long));
+ }
"proc-read" => {
// Peer SECRET read: /proc//environ. Goes through
// ptrace_may_access → Landlock's ptrace hook blocks it for peers
@@ -933,10 +1215,38 @@ mod probe {
exit(0);
}
"x32-call" => {
- // An x32-numbered getpid — the guard must SIGSYS-kill us before
+ // An x32-numbered syscall — the guard must SIGSYS-kill us before
// the kernel dispatches, regardless of whether x32 is even
// supported. If we survive to exit, the guard FAILED.
- unsafe { libc::syscall((0x4000_0000i64) | libc::SYS_getpid) };
+ //
+ // `arg` names the 64-bit number to OR the x32 bit into; empty =
+ // `getpid`, the number every cell used until 2026-07-28. Taking a
+ // SECOND number is the whole point of `BUILD-QUEUE.md` T1: a guard
+ // mutated from `JSET X32_SYSCALL_BIT` to `JEQ X32_SYSCALL_BIT|39`
+ // kills x32 `getpid` and allows every other x32 syscall, and the
+ // full suite stayed green because nothing probed a second number.
+ let nr: i64 = if arg.is_empty() {
+ libc::SYS_getpid
+ } else {
+ match arg.parse() {
+ Ok(n) => n,
+ // LOUD on a bad number. Falling back to getpid would make a
+ // typo'd cell pass against a getpid-only mutant — the cell
+ // would be green for the wrong reason, which is the exact
+ // defect T1 exists to remove.
+ Err(_) => exit(249),
+ }
+ };
+ // EXPLICIT ZERO ARGUMENTS. `libc::syscall` is variadic: calling it
+ // with no argument list leaves the syscall's argument registers
+ // holding whatever happened to be there. That was harmless while
+ // this probe was hardcoded to `getpid`, which takes none — but the
+ // number is now caller-supplied, so a BYPASSED guard would execute
+ // an arbitrary syscall with garbage arguments. Zeros make the
+ // bypass observable without making it destructive: `read(0, NULL,
+ // 0)` returns immediately, and every other low number degenerates
+ // the same way.
+ unsafe { libc::syscall((0x4000_0000i64) | nr, 0, 0, 0, 0, 0, 0) };
exit(0); // reaching here = NOT blocked (test fails)
}
_ => {
diff --git a/src/pipeline.rs b/src/pipeline.rs
index 5602f3a..b7daac8 100644
--- a/src/pipeline.rs
+++ b/src/pipeline.rs
@@ -81,6 +81,54 @@ pub fn seal(
seccomp::apply(policy.seccomp.profile)?;
// 11. report (ground truth from the actual restriction) then execve.
+ //
+ // UNDER-COVERAGE IS NOT SUPPRESSIBLE. `ORING_QUIET=1` silences the seal
+ // line, which is an informational *status report*; it must not silence a
+ // *disclosure* that rights were left unrestricted. The two compose into a
+ // blind spot otherwise, and it is reachable by the party oring treats as the
+ // adversary: `quiet` comes from an ENVIRONMENT VARIABLE (`main.rs`), so a
+ // wrapper script or parent process can set it without the policy author's
+ // involvement — and a class left entirely unhandled deliberately does not
+ // raise the partial-class WARN in `landlock::apply`, on the reasoning that
+ // `net=off` on the seal line already says so. Under quiet there is no seal
+ // line to say it. Measured: `ORING_QUIET=1` with a policy naming no net
+ // fields produced EMPTY stderr while all four net rights were open.
+ // D1b: does this run leave a shared cross-project cache writable? Computed
+ // here rather than in `check_profile_grants`, which returns early for
+ // `user_authored` policies — and a hand-written `--policy` is now the only
+ // remaining route into the class, since the built-in profiles stopped
+ // granting these on 2026-07-28.
+ let writable_caches = policy.writable_shared_caches();
+ let shared_cache_write = if writable_caches.is_empty() {
+ "none".to_string()
+ } else {
+ writable_caches.join(",")
+ };
+ // A DISCLOSURE, not a verdict: it reports which named caches are writable and
+ // says nothing about whether the run is safe. Fires for the named list only,
+ // so it stays rare enough to mean something. Not suppressible by
+ // ORING_QUIET, for the same reason `unhandled=` is not: a quiet flag may
+ // silence a status report, never the fact that a wall is absent.
+ if !writable_caches.is_empty() {
+ use std::io::Write as _;
+ let _ = writeln!(
+ std::io::stderr(),
+ "oring: WARN shared package cache WRITABLE by the child: {} — a later \
+ UNCONFINED build in an UNRELATED project reads these, and neither cargo \
+ nor go re-verifies what it finds there. Grant it deliberately or not at \
+ all (the maintainer's decision log, D1).",
+ shared_cache_write
+ );
+ }
+ if quiet && ring1.unhandled != "-" {
+ use std::io::Write as _;
+ let _ = writeln!(
+ std::io::stderr(),
+ "oring: WARN sealed with rights UNRESTRICTED: {} (seal line suppressed by \
+ ORING_QUIET; under-coverage is reported regardless)",
+ ring1.unhandled
+ );
+ }
if !quiet {
use std::io::Write as _;
// Non-fatal by construction: a closed/unwritable stderr must NOT abort
@@ -91,14 +139,36 @@ pub fn seal(
// this run; execve PRESERVES the pid, so this IS the child's pid.
let _ = writeln!(
std::io::stderr(),
- "oring: SEALED {} ring1_fs={} grants={} abi={} net={} scope={} audit={} nnp=1 pid={}",
+ "oring: SEALED {} ring1_fs={} grants={} abi={} errata={} net={} scope={} audit={} unhandled={} shared_cache_write={} proc={} ring2={} nnp=1 pid={}",
version_tag(),
ring1.fs,
ring1.grants,
ring1.abi,
+ ring1.errata,
ring1.net,
ring1.scope,
ring1.audit,
+ // Which restrictable rights this run left OPEN. `abi=` reports the
+ // interface version and `net=`/`scope=` report what was asked for;
+ // none of them could say what was left unrestricted, which is the
+ // question an auditor reading a seal line after the fact is asking.
+ ring1.unhandled,
+ shared_cache_write,
+ // Which /proc exposure the run actually installed. There was NO field for
+ // this: `--proc self|none` is the control `--help` and DESIGN §2 advertise,
+ // and `grants=` was the operator's only feedback on whether it applied —
+ // it reads identical either way. Two independent routes defeated the mode
+ // guard (BUILD-QUEUE T24) and no instrument would have shown either.
+ // DERIVED from the tiers, never from the flag, so it can disagree with
+ // `--proc` and say so.
+ policy.proc_exposure(),
+ // Ring 2 had NO field here at all: every other token reports ring 1
+ // or process state, so a reader could not tell which seccomp profile
+ // sealed — and after the socket() protocol allowlist landed, ring 2
+ // carries a load-bearing egress property (non-TCP/UDP refused in
+ // AF_INET/AF_INET6) that nothing in this line mentioned. `net=` was
+ // identical before and after that fix, which is what prompted this.
+ policy.seccomp.profile.as_str(),
std::process::id(),
);
}
diff --git a/src/policy.rs b/src/policy.rs
index 42a86b5..a8b7e9e 100644
--- a/src/policy.rs
+++ b/src/policy.rs
@@ -40,6 +40,43 @@ pub struct Policy {
/// works. For CI/automation, where a silent degradation is invisible.
#[serde(skip)]
pub require_all_grants: bool,
+
+ /// Set by `--require-full-coverage` (not a JSON field). Refuse to seal if ANY
+ /// restrictable right was left UNHANDLED — i.e. if `unhandled=` would be
+ /// anything but `-`.
+ ///
+ /// **Why this exists as a flag rather than the default.** `unhandled=` is a
+ /// degraded path with a warning, which `D-4` rejects everywhere else in this
+ /// tool — the V9 floor was folded in for exactly that reason.
+ ///
+ /// The original reason it stayed opt-in is GONE, and this note is here so the
+ /// dead reason is not re-cited. It used to be that making it fatal would refuse
+ /// every default run, because the shipped profiles left
+ /// `net.BindUdp,net.ConnectSendUdp` unhandled — itself only because a V10-only
+ /// default would not seal below 7.2. `FLOOR_ABI` is 10 and the profiles now
+ /// handle both, so **measured 2026-07-29, `--require-full-coverage` exits 0 on
+ /// both `balanced` and `strict`** (`the_shipped_udp_posture_is_dns_only_or_deny_all`
+ /// pins the posture that makes it so).
+ ///
+ /// What still keeps it opt-in is a DIFFERENT argument, and a weaker one: a
+ /// hand-authored `--policy` that omits a field currently warns, and defaulting
+ /// this would turn every such policy into an exit 120. That is a compatibility
+ /// call on user-authored input, not a statement about oring's own defaults, and
+ /// it is open rather than settled.
+ ///
+ /// **What it buys, and it is not "a louder warning".** The disclosure is a
+ /// stderr line written with `let _ = writeln!`, so oring's INVOKER can discard
+ /// it with `2>/dev/null` — measured, and the write to `/dev/null` SUCCEEDS, so
+ /// no error check inside oring can detect the case. There is no second channel:
+ /// the audit transport `denials.rs` reads is a *consumer* of kernel-emitted
+ /// Landlock records, a producer needs `CAP_AUDIT_WRITE` an unprivileged tool
+ /// lacks, and `/dev/log` would be grantable only by the very policy under audit
+ /// because the disclosure is emitted after the seal. So an operator who needs
+ /// coverage to be *established* rather than *reported* cannot get it from the
+ /// disclosure at any redirection. This flag converts it into an **exit code**,
+ /// which no redirect can suppress.
+ #[serde(skip)]
+ pub require_full_coverage: bool,
}
#[derive(Debug, Default, Deserialize)]
@@ -58,6 +95,22 @@ pub struct Landlock {
pub egress_tcp: Option>,
#[serde(default)]
pub ingress_tcp: Option>,
+ /// UDP directions, Landlock **V10+**. Same None/Some([]) convention as the
+ /// TCP pair. Requesting either on a sub-V10 kernel is REFUSED, not degraded:
+ /// silently dropping a requested net right is the Type-II failure the floor
+ /// exists to prevent (§4 D-4), and here the drop would be an open egress
+ /// path rather than a missing grant.
+ ///
+ /// `ConnectSendUdp` covers `connect(2)` AND `sendto(2)` to an explicit
+ /// remote port. `BindUdp` covers `bind(2)`. Note the autobind trap: an
+ /// unbound socket that sends acquires a source port via autobind, so
+ /// handling `BindUdp` without granting it denies ordinary egress — measured
+ /// 2026-07-26, and the reason egress-only policies must handle
+ /// `ConnectSendUdp` alone.
+ #[serde(default)]
+ pub egress_udp: Option>,
+ #[serde(default)]
+ pub ingress_udp: Option>,
#[serde(default = "default_true")]
pub scope_ipc: bool,
}
@@ -79,6 +132,18 @@ pub enum SeccompProfile {
Strict,
}
+impl SeccompProfile {
+ /// Name for the seal line's `ring2=` field. There is deliberately no
+ /// `off` variant — ring 2 is mandatory, which is what stops the socket()
+ /// protocol wall being defeated by asking for no seccomp at all.
+ pub fn as_str(self) -> &'static str {
+ match self {
+ SeccompProfile::Balanced => "balanced",
+ SeccompProfile::Strict => "strict",
+ }
+ }
+}
+
/// How much of `/proc` a profile grants (read). Peer-procfs is a §2 residual;
/// `SelfOnly` closes it for standalone use at the cost of tools that read other
/// pids' /proc. `None` is the tightest.
@@ -108,10 +173,119 @@ pub const PROC_SELF_READONLY: &[&str] = &[
/// Canonicalize `p`, or fall back to the literal path. Fail-open: an
/// un-canonicalizable path (missing, permission) is compared as-written rather
/// than dropped — enforcement is Landlock's, this is only a pre-seal predicate.
-fn canonical_or_literal(p: &Path) -> PathBuf {
+/// Resolve a path for COMPARISON, falling back to the literal when it does not exist.
+///
+/// Public because the bin is a separate crate and `main.rs` has a guard on the same
+/// inputs. **Every guard that predicts what `add_tier` will do must go through this**:
+/// `add_tier` opens with `PathFd::new`, which follows symlinks, so a rule lands on the
+/// TARGET inode and a lexical comparison silently disagrees with the kernel. Four guards
+/// compared lexically until 2026-07-29 (BUILD-QUEUE T24); one of them was a measured
+/// BREACH.
+pub fn canonical_or_literal(p: &Path) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}
+/// Shared, cross-project package caches — the named vector list from D1b.
+///
+/// NAMED, not a heuristic, and that is deliberate. A blanket warning on every
+/// `--policy` trains operators to ignore it (the `ORING_QUIET` / denial-noise
+/// failure) and implies the operator erred when they may have chosen on purpose.
+/// These five are the paths a LATER UNCONFINED toolchain reads, so a write here
+/// reaches every future project on the machine — measured: a payload under
+/// `~/.cargo/registry/src` executes at the operator's uid on an ordinary
+/// `cargo build`, in an unrelated project.
+const SHARED_CACHES: &[&[&str]] = &[
+ &[".cargo", "registry"],
+ &[".cargo", "git"],
+ &[".npm"],
+ &["go", "pkg"],
+ &[".cache"],
+];
+
+impl Policy {
+ /// Named shared caches this policy leaves WRITABLE by the child, as
+ /// `~`-relative labels. Empty when none — the shipped profiles' state since
+ /// 2026-07-28.
+ ///
+ /// Checks BOTH directions, because either makes a shared cache writable:
+ /// a write tier at-or-above the cache (Landlock unions along the path walk,
+ /// so an ancestor grant confers write on everything beneath), and a write
+ /// tier NESTED INSIDE it (`--policy` granting `~/.cargo/registry/src`).
+ ///
+ /// Deliberately NOT implemented in `check_profile_grants`: that returns
+ /// `Ok(())` immediately for `user_authored` policies, which are now the only
+ /// remaining route into this hazard.
+ pub fn writable_shared_caches(&self) -> Vec {
+ // EVERY plausible home, not just `$HOME` — this is a DISCLOSURE, and a
+ // disclosure that an ambient variable can silence is not one.
+ //
+ // T25: `$HOME` is ambient, and `RESIDUALS.md` names it alongside
+ // `ORING_POLICY` and `$TMPDIR` as a source the caller's own environment
+ // is not trusted to control. This function read it and nothing else, so
+ // `HOME=/tmp/decoy` (or `HOME` unset) made the cache paths resolve
+ // somewhere no tier could match, and the seal line reported
+ // `shared_cache_write=none` over a policy granting write on the real
+ // `~/.cargo/registry`. The confinement was never affected; the operator's
+ // view of it was, which is the MISREPORT class and worse than an opaque
+ // field because it reads as a positive result.
+ //
+ // The passwd entry is the non-ambient answer and is checked first.
+ // `$HOME` is retained as an ADDITIONAL root rather than replaced: a
+ // legitimately relocated home, a container without a passwd entry, or a
+ // test harness pointing HOME at a fixture are all real, and taking the
+ // union means a decoy can only ever ADD a place to look. It cannot
+ // remove the true one, which is the property that was missing.
+ let mut roots: Vec = Vec::new();
+ for cand in [crate::sys::passwd_home(), crate::profile::home()]
+ .into_iter()
+ .flatten()
+ {
+ let cand = canonical_or_literal(&cand);
+ if !roots.contains(&cand) {
+ roots.push(cand);
+ }
+ }
+ if roots.is_empty() {
+ return Vec::new();
+ }
+ let writes: Vec<&PathBuf> = self
+ .landlock
+ .write_noexec
+ .iter()
+ .chain(self.landlock.write_exec.iter())
+ .collect();
+ let mut hit = Vec::new();
+ for parts in SHARED_CACHES {
+ let caches: Vec = roots
+ .iter()
+ .map(|r| {
+ let mut cache = r.clone();
+ for c in *parts {
+ cache.push(c);
+ }
+ cache
+ })
+ .collect();
+ if caches.iter().any(|cache| {
+ writes
+ .iter()
+ // Canonicalise the GRANTED path for the same reason `apply_proc_mode`
+ // does: a symlink to a shared cache is granted on its target inode, so a
+ // lexical compare reports `shared_cache_write=none` when the write is
+ // real. That is the MISREPORT direction of T24 rather than the breach
+ // direction, and it defeats the disclosure rather than the confinement.
+ .any(|w| {
+ let cw = canonical_or_literal(w);
+ cache.starts_with(&cw) || cw.starts_with(cache)
+ })
+ }) {
+ hit.push(format!("~/{}", parts.join("/")));
+ }
+ }
+ hit
+ }
+}
+
impl Policy {
/// Parse from a JSON string (file or env). deny_unknown_fields ⇒ a typo'd
/// key errors rather than silently granting nothing.
@@ -125,6 +299,12 @@ impl Policy {
self.landlock.egress_tcp.is_some() || self.landlock.ingress_tcp.is_some()
}
+ /// True if the policy asks for ANY UDP restriction (incl. deny-all).
+ /// Drives the hard V10 gate in ring 1 — see [`Landlock::egress_udp`].
+ pub fn udp_requested(&self) -> bool {
+ self.landlock.egress_udp.is_some() || self.landlock.ingress_udp.is_some()
+ }
+
/// Inject a workdir into the write_exec tier (from --workdir). §7.
pub fn add_workdir(&mut self, dir: PathBuf) {
self.landlock.write_exec.push(dir);
@@ -137,8 +317,79 @@ impl Policy {
/// Rewrite the /proc grant in read_only per `--proc`. Drops every existing
/// `/proc*` read_only entry, then re-adds per mode.
+ /// What `/proc` exposure the policy actually carries, for the seal line's `proc=`.
+ ///
+ /// **DERIVED from the tiers, not from the flag.** `CONSTRAINTS.md` CON-5 records that
+ /// `unhandled=` is weak precisely because it compares a printed token to the *request*
+ /// rather than to an outcome; repeating that here would add a field that agrees with
+ /// `--proc` by construction and could never disagree with it. This reads the tiers as
+ /// they stand after `apply_proc_mode`, so a `--policy` that grants `/proc` by another
+ /// name is reported as what it is.
+ ///
+ /// **What it does NOT establish, stated rather than implied:** these are the tiers
+ /// oring *asked* Landlock for. `add_tier` skips a path that does not exist
+ /// (IgnoreIfMissing), so a granted-but-absent `/proc` path would still be counted
+ /// here. Closing that gap needs the installed ruleset, not the policy — the same
+ /// structural limit CON-5's `observable-via` already names for every instrument oring
+ /// ships.
+ pub fn proc_exposure(&self) -> String {
+ let under_proc: Vec = self
+ .all_tiers()
+ .into_iter()
+ .flat_map(|(_t, paths)| paths.to_vec())
+ .map(|p| canonical_or_literal(&p))
+ .filter(|p| p.starts_with("/proc"))
+ .collect();
+ if under_proc.is_empty() {
+ return "none".to_string();
+ }
+ // The whole subtree, however it was spelled.
+ if under_proc.iter().any(|p| p == Path::new("/proc")) {
+ return "full".to_string();
+ }
+ // Resolve the curated list TOO. `/proc/self` is itself a symlink — it resolves to
+ // `/proc/` — so comparing resolved grants against the literal constant can
+ // never match and reported `custom:9` for a plain `--proc self`. Measured, one
+ // commit after fixing the same symlink assumption in four other guards.
+ let curated: std::collections::BTreeSet = PROC_SELF_READONLY
+ .iter()
+ .map(|p| canonical_or_literal(Path::new(p)))
+ .collect();
+ let got: std::collections::BTreeSet = under_proc.iter().cloned().collect();
+ if got == curated {
+ return "self".to_string();
+ }
+ // Neither shape: report the count rather than guessing a label, so an operator
+ // reading `proc=custom:3` knows to look.
+ format!("custom:{}", got.len())
+ }
+
pub fn apply_proc_mode(&mut self, mode: ProcMode) {
- self.landlock.read_only.retain(|p| !p.starts_with("/proc"));
+ // CANONICALISE BEFORE COMPARING. `add_tier` opens with `PathFd::new`, which
+ // FOLLOWS symlinks, so the rule lands on the TARGET inode — a lexical
+ // `starts_with("/proc")` therefore misses `/tmp/procs -> /proc` and this retain
+ // leaves the grant in place.
+ //
+ // MEASURED 2026-07-29 with `__probe proc-cmdline 1` (world-readable, so gated
+ // only by the Landlock /proc grant): `/proc` named directly gives EACCES under
+ // both `--proc self` and `--proc none`; a SYMLINK to `/proc` gives 0 — READABLE —
+ // under both. `--proc self` is the mode `--help` and DESIGN §2 advertise, and it
+ // was the unmeasured half of BUILD-QUEUE T24 until this probe.
+ // EVERY TIER, not just `read_only`. Measured 2026-07-29 with
+ // `__probe proc-cmdline 1`: under `--proc none` a `/proc` grant in `read_exec` or
+ // `write_noexec` survived and peer cmdline stayed READABLE — **no symlink needed**.
+ // The symlink defeat (T24) and this tier gap are two independent routes to the same
+ // breach, and filtering one tier closed neither properly.
+ //
+ // The flag WINS over the policy here, which is what the previous `read_only`-only
+ // retain already did; refusing on the conflict instead would be defensible and is
+ // an operator-visible choice, so it is named rather than taken.
+ let strip =
+ |v: &mut Vec| v.retain(|p| !canonical_or_literal(p).starts_with("/proc"));
+ strip(&mut self.landlock.read_only);
+ strip(&mut self.landlock.read_exec);
+ strip(&mut self.landlock.write_noexec);
+ strip(&mut self.landlock.write_exec);
match mode {
ProcMode::Full => self.landlock.read_only.push(PathBuf::from("/proc")),
ProcMode::SelfOnly => self
@@ -192,12 +443,16 @@ impl Policy {
}
}
- // Loud warns for the known footguns (§2/§7).
+ // Loud warns for the known footguns (§2/§7). The UDP-DNS residual is
+ // conditional now: `egress_udp` covers it at V10+, so warning
+ // unconditionally would train operators to ignore a warning that no
+ // longer describes their cage.
if let Some(ports) = &self.landlock.egress_tcp {
- if ports.contains(&53) {
+ if ports.contains(&53) && self.landlock.egress_udp.is_none() {
eprintln!(
- "oring: WARN egress_tcp includes 53 but Landlock does not cover UDP DNS — \
- DNS/UDP egress is unrestricted (see §2)"
+ "oring: WARN egress_tcp includes 53 but egress_udp is unset — \
+ DNS/UDP egress is unrestricted (see §2). On a V10 kernel, set \
+ egress_udp to close it."
);
}
}
@@ -221,10 +476,16 @@ impl Policy {
}
/// Warn if a granted tier holds (or contains) a live AF_UNIX socket, WHEN the
- /// pathname-socket wall is not active (built without `resolve-unix`, or a
- /// kernel below V9). There, `connect()` to a pathname socket is unmediated —
- /// a mounted `docker.sock` or the operator's `/tmp/ssh-*/agent.*` is reachable.
- /// At/above the wall this is inert, so we stay silent.
+ /// pathname-socket wall is not active. There, `connect()` to a pathname socket
+ /// is unmediated — a mounted `docker.sock` or the operator's
+ /// `/tmp/ssh-*/agent.*` is reachable. At/above the wall this is inert, so we
+ /// stay silent.
+ ///
+ /// Since the floor was introduced this fires only on a sub-floor kernel — i.e. moments
+ /// before `landlock::apply` refuses outright — so it is no longer a live lint
+ /// on any run that reaches exec. It is KEPT deliberately: it is the tripwire
+ /// that would start warning again if the floor were ever lowered below V9,
+ /// which is exactly the change that would resurrect the residual.
///
/// Scans **two** levels: the granted dir and one level of subdirs. The socket
/// that matters most, ssh-agent, lives at `/tmp/ssh-XXXXXX/agent.NNNN` — two
@@ -242,7 +503,7 @@ impl Policy {
"oring: WARN granted tier holds a unix socket: {} — the pathname-AF_UNIX \
wall is not active, so connect() to it is UNMEDIATED; don't grant dirs \
holding docker.sock/podman.sock/bus or an ssh-agent socket",
- sock.display()
+ crate::diag::safe_display(&sock.display())
);
}
}
@@ -286,7 +547,13 @@ impl Policy {
const SOCKET_FREE_ROOTS: [&str; 3] = ["/proc", "/sys", "/usr"];
for (_tier, paths) in self.all_tiers() {
for p in paths {
- if SOCKET_FREE_ROOTS.iter().any(|root| p.starts_with(root)) {
+ // Canonicalise so the prune agrees with what is actually scanned.
+ // This one fails CONSERVATIVELY on a symlink (it scans a path it could
+ // have skipped), but the inverse bites: `/proc/../home/x` lexically
+ // starts_with `/proc` and would be pruned while resolving elsewhere, so a
+ // socket there would be missed. Fixed for the class, not the direction.
+ let cp = canonical_or_literal(p);
+ if SOCKET_FREE_ROOTS.iter().any(|root| cp.starts_with(root)) {
continue;
}
let Ok(md) = std::fs::symlink_metadata(p) else {
@@ -322,6 +589,193 @@ impl Policy {
/// refusing: that means `/proc/self/exe` is unreadable, an environment
/// defect rather than a policy input, and refusing there would brick hosts
/// whose policy is in fact sound. The detected-overlap case is a hard error.
+ ///
+ /// Companion: [`Policy::check_profile_grants`], which refuses the inverse —
+ /// a caller-supplied variable widening a *profile's* tiers.
+ /// Refuse (or warn about) a policy whose tiers OVERLAP such that a path ends
+ /// up in a wider tier than the one that named it.
+ ///
+ /// # Why this is needed at all
+ ///
+ /// **The tier lattice is not closed under the union Landlock performs.**
+ /// Landlock has no deny rule and no unmount: rules on an ancestor and a
+ /// descendant are OR'd, and the wider grant wins unconditionally. So
+ /// `read_exec ∪ write_noexec = write_exec` — two grants that each
+ /// individually satisfy W^X compose into a tier **neither one names**, and
+ /// the narrower grant is provably inert.
+ ///
+ /// Measured consequences before this check existed:
+ /// * `--workdir /tmp` under `balanced` (which has `/tmp` in `write_noexec`)
+ /// silently upgraded the whole of `/tmp` to `write_exec` and a script
+ /// written there executed — while `--exec-tmp`'s own help text promises
+ /// "`/tmp` itself stays no-exec", `grants=` reported full success, and the
+ /// seal line was indistinguishable from a sound run.
+ /// * `TMPDIR=$HOME/.cargo` broke all three of `balanced`'s own documented
+ /// invariants; the tree's `profile::` unit tests fail under it while oring
+ /// sealed happily. The invariants were written down and testable, and
+ /// nothing checked them at seal time.
+ ///
+ /// This is the same defect Vitaly Chikunov reported upstream in 2024 as
+ /// "permission additivity" (`LL_FS_RW=/ LL_FS_RO=/home` → the file is
+ /// created), where Salaün's reply proposed either OR-ing rules or erroring
+ /// out. Landlock cannot make the restrictive one win, so oring errors out.
+ ///
+ /// # The rule
+ ///
+ /// For each declared `(path, tier)`, the EFFECTIVE tier is the union of
+ /// every declared tier whose path covers it. Then:
+ ///
+ /// | condition | verdict |
+ /// |---|---|
+ /// | effective grants **write** where declared did not | **REFUSE** |
+ /// | effective grants **exec** where declared did not, and is writable | **REFUSE** |
+ /// | effective grants **exec** where declared did not, not writable | WARN |
+ ///
+ /// In one sentence: *the effective tier must equal the declared tier, except
+ /// that `read_only` may effectively be `read_exec`.* The two refusing arms
+ /// are `landlock.rs`'s own tier invariants — "read_only never grants write"
+ /// and "W^X" — lifted from per-rule to **per-path-after-union**, which is the
+ /// level the kernel actually enforces at. The warning arm is inert: nothing
+ /// writable gains exec, so the child can only execute content it could
+ /// already read and cannot modify.
+ ///
+ /// # The trap this must not fall into
+ ///
+ /// A naive "any cross-tier containment" rule REFUSES THE SHIPPED PROFILE:
+ /// `balanced` puts `~/go` in `read_only` with `~/go/bin` in `read_exec` and
+ /// `~/go/pkg` in `write_noexec` (§7's deliberate split). Those are
+ /// *outer-narrower* — the union with a narrower ancestor does not widen
+ /// anything — and the effective-tier formulation handles them by
+ /// construction rather than by a special case. Both profiles verify clean.
+ ///
+ /// # Residual, stated rather than claimed away
+ ///
+ /// Paths are compared after canonicalization, so symlinks are seen but
+ /// **bind mounts and hard links are not**: if `/mnt/x` bind-mounts `$HOME`,
+ /// `write_noexec /mnt/x` and `read_exec ~/.cargo/bin` do not compare as
+ /// nested and the union is missed. Landlock enforces on inodes; this
+ /// compares paths. It is a pre-seal predicate, not the enforcement boundary.
+ pub fn check_tier_overlap(&self) -> Result<()> {
+ // (path, writable, executable, tier name) for every declaration.
+ let ll = &self.landlock;
+ let decls: Vec<(PathBuf, bool, bool, &str)> = [
+ (&ll.read_only, false, false, "read_only"),
+ (&ll.read_exec, false, true, "read_exec"),
+ (&ll.write_noexec, true, false, "write_noexec"),
+ (&ll.write_exec, true, true, "write_exec"),
+ ]
+ .iter()
+ .flat_map(|(list, w, x, name)| {
+ list.iter()
+ .map(move |p| (canonical_or_literal(p), *w, *x, *name))
+ })
+ .collect();
+
+ let mut warnings: Vec = Vec::new();
+ for (path, dw, dx, dname) in &decls {
+ // Effective tier: union over every declaration covering this path.
+ // `Path::starts_with` is component-wise, so /tmpfoo is not "under"
+ // /tmp — a prefix-string compare here would be a false positive.
+ let (mut ew, mut ex) = (*dw, *dx);
+ let mut widened_by: Option<(&Path, &str)> = None;
+ for (q, qw, qx, qname) in &decls {
+ if !path.starts_with(q) {
+ continue;
+ }
+ let widens = (*qw && !ew) || (*qx && !ex);
+ ew |= *qw;
+ ex |= *qx;
+ if widens && q != path {
+ widened_by = Some((q.as_path(), qname));
+ } else if widens {
+ // Same path declared in two tiers — the union still widens.
+ widened_by = Some((q.as_path(), qname));
+ }
+ }
+ let Some((by, byname)) = widened_by else {
+ continue;
+ };
+ let effective = match (ew, ex) {
+ (false, false) => "read_only",
+ (false, true) => "read_exec",
+ (true, false) => "write_noexec",
+ (true, true) => "write_exec",
+ };
+ // Class A: gained write. Class B: gained exec while writable.
+ if (ew && !dw) || (ex && !dx && ew) {
+ // Same path in two tiers reads very differently from an ancestor
+ // widening a descendant, and the repair is different too.
+ let how = if by == path.as_path() {
+ format!(
+ "{} is declared BOTH {dname} and {byname} — Landlock ORs them",
+ path.display()
+ )
+ } else {
+ format!(
+ "{} is declared {dname}, but its ancestor {} is declared {byname}, and \
+ Landlock ORs rules on an ancestor with rules on a descendant",
+ crate::diag::safe_display(&path.display()),
+ crate::diag::safe_display(&by.display())
+ )
+ };
+ return Err(OringError::policy(format!(
+ "tier overlap: {how} — so the EFFECTIVE tier is {effective}. The narrower \
+ grant is inert and the cage is wider than the policy says. Refusing rather \
+ than sealing a cage whose own description is wrong. Fix by naming the \
+ union tier deliberately, or by narrowing the wider grant."
+ )));
+ }
+ // Class C: gained exec but nothing writable did — inert, so warn.
+ if ex && !dx {
+ warnings.push(format!(
+ "oring: WARN tier overlap: {} is declared {dname} but is effectively \
+ {effective}, because {} is declared {byname}. Harmless here (nothing \
+ writable gained exec), but the policy does not describe the cage.",
+ path.display(),
+ by.display()
+ ));
+ }
+ }
+ for w in warnings {
+ eprintln!("{w}");
+ }
+ Ok(())
+ }
+
+ pub fn check_profile_grants(&self, home: Option<&Path>) -> Result<()> {
+ // An operator who writes $HOME into a --policy file means it; a profile
+ // that arrives there via the environment does not. `user_authored` is
+ // the flag that already draws that line (:35).
+ if self.user_authored {
+ return Ok(());
+ }
+ let Some(home) = home else {
+ return Ok(());
+ };
+ let home = canonical_or_literal(home);
+ for tier in [&self.landlock.write_exec, &self.landlock.write_noexec] {
+ for p in tier {
+ let cp = canonical_or_literal(p);
+ if home.starts_with(&cp) {
+ return Err(OringError::policy(format!(
+ "profile self-protection: a built-in profile would grant write over \
+ {} , which CONTAINS your home directory ({}) — almost certainly \
+ because $TMPDIR points there. balanced honours $TMPDIR so linkers \
+ can write scratch, and the only guards were 'absolute' and 'not \
+ /tmp'; measured, TMPDIR=$HOME granted write on ~/.ssh and \
+ ~/.bashrc. In oring's threat model the CALLER is the adversary, so \
+ a caller-supplied variable must not widen a profile's tiers. \
+ Refusing rather than sealing that cage. Narrow or unset $TMPDIR, \
+ or state the grant deliberately in a --policy file",
+ cp.display(),
+ home.display()
+ )));
+ }
+ }
+ }
+ Ok(())
+ }
+
pub fn check_self_protection(&self, policy_file: Option<&Path>) -> Result<()> {
let writable: Vec = self
.landlock
@@ -399,6 +853,91 @@ mod tests {
/// A policy that fills SOME tiers but not all is valid — only an *all-empty*
/// policy is the refuse-worthy footgun. Pins the empty-check to `&&` (all)
/// rather than `||` (any), which would over-reject partial policies.
+ /// D1b: every named shared cache must be detected individually, in BOTH
+ /// directions, and an unrelated write tier must report clean.
+ ///
+ /// Asserts a per-element property rather than "some cache was found" — the
+ /// T21 lesson is that a single-element check misses the element nobody
+ /// listed. Mutating `SHARED_CACHES` (dropping any entry) turns this red.
+ #[test]
+ fn every_named_shared_cache_is_detected_individually() {
+ let Some(h) = crate::profile::home() else {
+ eprintln!("SKIP: every_named_shared_cache_is_detected_individually: HOME unset");
+ return;
+ };
+ let mk = |w: PathBuf| {
+ let mut ll = Landlock::default();
+ ll.write_noexec.push(w);
+ Policy {
+ version: 1,
+ landlock: ll,
+ seccomp: Seccomp::default(),
+ user_authored: true,
+ require_all_grants: false,
+ require_full_coverage: false,
+ }
+ };
+
+ // HARDCODED, deliberately NOT derived from `SHARED_CACHES`. Deriving the
+ // expectation from the constant under test makes the check unable to
+ // fail: dropping an entry would silently drop its assertion too.
+ // Measured — that is exactly what the first version of this test did,
+ // and three of five mutations came back green.
+ let expected = [
+ "~/.cargo/registry",
+ "~/.cargo/git",
+ "~/.npm",
+ "~/go/pkg",
+ "~/.cache",
+ ];
+ assert_eq!(
+ SHARED_CACHES.len(),
+ expected.len(),
+ "SHARED_CACHES changed size — update `expected` deliberately, do not \
+ derive it from the constant this test exists to check"
+ );
+
+ for label in expected {
+ let mut cache = h.clone();
+ for c in label.trim_start_matches("~/").split('/') {
+ cache.push(c);
+ }
+ let label = label.to_string();
+
+ // exact grant
+ assert_eq!(
+ mk(cache.clone()).writable_shared_caches(),
+ vec![label.clone()],
+ "{label}: an exact write grant must be reported"
+ );
+ // grant NESTED inside the cache
+ assert!(
+ mk(cache.join("nested"))
+ .writable_shared_caches()
+ .contains(&label),
+ "{label}: a write tier inside the cache must be reported — part of a \
+ shared cache is still writable"
+ );
+ }
+
+ // Ancestor grant covers everything beneath it (Landlock unions along the walk).
+ let anc = mk(h.join(".cargo")).writable_shared_caches();
+ assert!(
+ anc.contains(&"~/.cargo/registry".to_string())
+ && anc.contains(&"~/.cargo/git".to_string()),
+ "an ancestor grant on ~/.cargo must report both caches beneath it, got {anc:?}"
+ );
+
+ // Negative control: an unrelated write tier must report clean, or the
+ // field means nothing.
+ assert!(
+ mk(PathBuf::from("/tmp"))
+ .writable_shared_caches()
+ .is_empty(),
+ "an unrelated write tier must not be reported as a shared cache"
+ );
+ }
+
#[test]
fn partial_policy_is_accepted() {
let mut ll = Landlock::default();
@@ -409,6 +948,7 @@ mod tests {
seccomp: Seccomp::default(),
user_authored: false,
require_all_grants: false,
+ require_full_coverage: false,
};
assert!(
p.validate().is_ok(),
@@ -426,6 +966,7 @@ mod tests {
seccomp: Seccomp::default(),
user_authored: false,
require_all_grants: false,
+ require_full_coverage: false,
};
p.add_workdir_noexec(PathBuf::from("/work/scratch"));
assert_eq!(
@@ -460,6 +1001,7 @@ mod tests {
seccomp: Seccomp::default(),
user_authored: false,
require_all_grants: false,
+ require_full_coverage: false,
};
let found = p.sockets_in_granted_tiers();
@@ -474,4 +1016,195 @@ mod tests {
drop((l1, l2));
let _ = std::fs::remove_dir_all(&root);
}
+
+ /// **`guard(link_to_X) == guard(X)`** — the property BUILD-QUEUE T24 asks for, over
+ /// every tier and every mode.
+ ///
+ /// `add_tier` opens with `PathFd::new`, which FOLLOWS symlinks, so a rule lands on the
+ /// target inode. Any guard predicting that must canonicalise, or it silently disagrees
+ /// with the kernel. Four guards compared lexically; this pins the one that was a
+ /// measured breach.
+ ///
+ /// MEASURED before the fix, with `__probe proc-cmdline 1` (world-readable, so gated
+ /// only by the Landlock `/proc` grant): `/proc` named directly gave EACCES under both
+ /// `--proc self` and `--proc none`, while a SYMLINK to `/proc` gave **0 — READABLE**
+ /// under both. `--proc self` is the mode `--help` and `docs/DESIGN.md` §2 advertise,
+ /// and it was the unmeasured half of T24 until then.
+ ///
+ /// AND A SECOND, INDEPENDENT ROUTE, needing no symlink at all: the retain covered only
+ /// `read_only`, so a `/proc` grant in `read_exec` or `write_noexec` survived `--proc
+ /// none` outright. Both routes are quantified here — 4 tiers × 3 modes × 2 spellings.
+ #[test]
+ fn proc_mode_treats_a_symlink_exactly_like_its_target_in_every_tier() {
+ let d = std::env::temp_dir().join(format!("oring-t24-{}", std::process::id()));
+ std::fs::create_dir_all(&d).unwrap();
+ let link = d.join("procs");
+ let _ = std::fs::remove_file(&link);
+ #[cfg(unix)]
+ std::os::unix::fs::symlink("/proc", &link).unwrap();
+ assert!(
+ std::fs::canonicalize(&link).is_ok_and(|t| t == std::path::Path::new("/proc")),
+ "the fixture must really resolve to /proc, or this test proves nothing"
+ );
+
+ // Which tier the grant sits in, as a mutable accessor.
+ type Tier = fn(&mut Landlock) -> &mut Vec;
+ let tiers: [(&str, Tier); 4] = [
+ ("read_only", |l| &mut l.read_only),
+ ("read_exec", |l| &mut l.read_exec),
+ ("write_noexec", |l| &mut l.write_noexec),
+ ("write_exec", |l| &mut l.write_exec),
+ ];
+ let modes = [
+ ("full", ProcMode::Full),
+ ("self", ProcMode::SelfOnly),
+ ("none", ProcMode::None),
+ ];
+
+ let mut cases = 0;
+ for (tname, tier) in tiers {
+ for (mname, mode) in modes {
+ let outcome = |grant: &Path| -> Vec {
+ let mut p = Policy {
+ version: 1,
+ landlock: Landlock::default(),
+ seccomp: Seccomp::default(),
+ user_authored: true,
+ require_all_grants: false,
+ require_full_coverage: false,
+ };
+ tier(&mut p.landlock).push(grant.to_path_buf());
+ p.apply_proc_mode(mode);
+ let mut all: Vec = p
+ .landlock
+ .read_only
+ .iter()
+ .chain(p.landlock.read_exec.iter())
+ .chain(p.landlock.write_noexec.iter())
+ .chain(p.landlock.write_exec.iter())
+ .cloned()
+ .collect();
+ all.sort();
+ all
+ };
+ let direct = outcome(Path::new("/proc"));
+ let via_link = outcome(&link);
+ assert_eq!(
+ direct, via_link,
+ "tier {tname}, --proc {mname}: a symlink to /proc must be treated \
+ exactly like /proc. `add_tier` follows symlinks, so any difference \
+ here is a guard disagreeing with the kernel."
+ );
+ // The property ALONE would be satisfied by both arms leaking equally, so
+ // pin the absolute outcome per mode too. Written per-mode because a blanket
+ // "nothing under /proc survives" is FALSE for `self` — that mode grants
+ // `/proc/self`, `/proc/cpuinfo` and friends by design, and asserting
+ // otherwise failed loudly here, which is the test being wrong rather than
+ // the code.
+ for (label, got) in [("direct", &direct), ("via symlink", &via_link)] {
+ let under_proc: Vec<&PathBuf> = got
+ .iter()
+ .filter(|p| canonical_or_literal(p).starts_with("/proc"))
+ .collect();
+ match mname {
+ "none" => assert!(
+ under_proc.is_empty(),
+ "tier {tname}, --proc none, {label}: {under_proc:?} survived — \
+ `none` must leave NOTHING resolving under /proc"
+ ),
+ "self" => {
+ let allowed: Vec =
+ PROC_SELF_READONLY.iter().map(PathBuf::from).collect();
+ for p in &under_proc {
+ assert!(
+ allowed.contains(p),
+ "tier {tname}, --proc self, {label}: {p:?} survived and \
+ is not in PROC_SELF_READONLY — `self` grants only the \
+ named self-scoped paths"
+ );
+ }
+ }
+ _ => {}
+ }
+ }
+ cases += 1;
+ }
+ }
+ assert_eq!(cases, 12, "4 tiers x 3 modes, each checked both spellings");
+ std::fs::remove_dir_all(&d).ok();
+ }
+
+ /// `proc=` must be DERIVED from the tiers, so it can disagree with `--proc`.
+ ///
+ /// The weakness `CONSTRAINTS.md` CON-5 records about `unhandled=` is that it compares
+ /// a printed token to the *request*; a `proc=` that echoed the flag would repeat it and
+ /// could never disagree. The last case here is the one that matters: a policy granting
+ /// a `/proc` path with `ProcMode::None` applied reports `custom:1`, **not** `none`.
+ #[test]
+ fn proc_exposure_is_derived_from_the_tiers_not_from_the_flag() {
+ let d = std::env::temp_dir().join(format!("oring-procx-{}", std::process::id()));
+ std::fs::create_dir_all(&d).unwrap();
+ let link = d.join("procs");
+ let _ = std::fs::remove_file(&link);
+ #[cfg(unix)]
+ std::os::unix::fs::symlink("/proc", &link).unwrap();
+
+ let mk = |grants: Vec, mode: Option| -> String {
+ let ll = Landlock {
+ read_only: grants,
+ ..Default::default()
+ };
+ let mut p = Policy {
+ version: 1,
+ landlock: ll,
+ seccomp: Seccomp::default(),
+ user_authored: true,
+ require_all_grants: false,
+ require_full_coverage: false,
+ };
+ if let Some(m) = mode {
+ p.apply_proc_mode(m);
+ }
+ p.proc_exposure()
+ };
+
+ assert_eq!(mk(vec![PathBuf::from("/etc")], None), "none");
+ assert_eq!(mk(vec![PathBuf::from("/proc")], None), "full");
+ assert_eq!(
+ mk(vec![link.clone()], None),
+ "full",
+ "a symlink to /proc is /proc — `add_tier` follows it"
+ );
+ assert_eq!(
+ mk(PROC_SELF_READONLY.iter().map(PathBuf::from).collect(), None),
+ "self",
+ "the curated set must resolve to `self`; /proc/self is itself a symlink, so \
+ both sides have to be canonicalised or this reports custom"
+ );
+ assert_eq!(mk(vec![PathBuf::from("/proc/cpuinfo")], None), "custom:1");
+
+ // THE PROPERTY: the field reports the tiers, not the flag. `apply_proc_mode` runs
+ // first and strips /proc, then the grant is re-added the way a `--policy` would.
+ let ll = Landlock {
+ read_only: vec![PathBuf::from("/etc")],
+ ..Default::default()
+ };
+ let mut p = Policy {
+ version: 1,
+ landlock: ll,
+ seccomp: Seccomp::default(),
+ user_authored: true,
+ require_all_grants: false,
+ require_full_coverage: false,
+ };
+ p.apply_proc_mode(ProcMode::None);
+ p.landlock.read_only.push(PathBuf::from("/proc/cpuinfo"));
+ assert_eq!(
+ p.proc_exposure(),
+ "custom:1",
+ "with ProcMode::None applied and a /proc path still granted, the seal line must \
+ say custom:1 — a field that echoed the flag would say `none` and be wrong"
+ );
+ std::fs::remove_dir_all(&d).ok();
+ }
}
diff --git a/src/profile.rs b/src/profile.rs
index 8c4f5cd..1c32184 100644
--- a/src/profile.rs
+++ b/src/profile.rs
@@ -14,7 +14,7 @@ fn paths(list: &[&str]) -> Vec {
}
/// Absolute $HOME, if set. Toolchain caches live under it.
-fn home() -> Option {
+pub(crate) fn home() -> Option {
std::env::var_os("HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
@@ -36,15 +36,88 @@ pub fn balanced() -> Policy {
"/usr/libexec",
"/opt",
]);
- let mut read_only = paths(&["/etc", "/usr/share", "/proc"]);
- // /run dropped from balanced: granting it broadly exposes docker.sock /
- // podman.sock / journald sockets (connect() to a pathname socket is
- // unmediated by both rings, §2). Add /run/user/ via --policy if a tool
- // genuinely needs XDG_RUNTIME_DIR.
- let mut write_noexec = paths(&[
+ // `/etc/resolv.conf` is named EXPLICITLY, in addition to `/etc`, and the
+ // redundancy is the point: on most modern hosts it is a **symlink out of
+ // `/etc`** and the `/etc` grant does not reach its target.
+ //
+ // Landlock rules an inode subtree. A symlink makes the VFS restart the walk
+ // at the target path — `/run/systemd/resolve/stub-resolv.conf` on
+ // Fedora/Ubuntu, `/run/NetworkManager/resolv.conf` under NM,
+ // `/run/connman/...` under ConnMan — which never touches `/etc`, so no rule
+ // matches and the open is denied. (A *bind mount*, as containers use, keeps
+ // the path: Landlock walks up to the mount root and `follow_up`s into
+ // `/etc`, where the rule does match. So the container case worked and the
+ // systemd case did not — the opposite of what "different filesystem" would
+ // predict. The dividing line is symlink vs mount, and it is a property of
+ // Landlock, not of resolv.conf.)
+ //
+ // This entry needs no new machinery because `add_tier` classifies with
+ // `std::fs::metadata` and opens with `PathFd::new`, both of which FOLLOW
+ // symlinks (see the usrmerge note there). The rule therefore lands on the
+ // resolved target inode, whatever it happens to be — so one literal path
+ // covers every layout above without hardcoding any of them.
+ //
+ // Exposure is exactly ONE additional readable inode — measured against the
+ // alternatives, all rejected: granting `/run/systemd/resolve` hands over the
+ // *uplink* resolv.conf (the real upstream nameserver — network-topology
+ // recon for an untrusted child) plus a directory listing; generalising to
+ // "the target's parent directory" is worse still, because on an NM host that
+ // is `/run/NetworkManager`, holding `system-connections/*.nmconnection` —
+ // WiFi PSKs and 802.1x secrets.
+ //
+ // WHY THIS IS A SECURITY FIX AND NOT A CONVENIENCE ONE: with the file
+ // unreadable, glibc's `dns` module does not fail closed. It falls back to
+ // its compiled-in default nameserver `127.0.0.1:53` — measured, with a UDP
+ // catcher: the query really goes there. On any host running dnsmasq,
+ // unbound, bind or a dev stub on that address, the confined child silently
+ // resolves through a resolver the operator never chose, bypassing
+ // systemd-resolved's split-DNS routing, per-link domains, DNSSEC and DoT.
+ // Silent wrong-resolver is a worse failure than the visible outage.
+ //
+ // Residual, stated rather than hidden: the grant pins an INODE, so an atomic
+ // replace (write-temp + rename, which systemd-resolved does) invalidates it
+ // for an already-sealed child — measured. Bounded by systemd's
+ // `conservative_renameat`, which removes the temp instead of renaming when
+ // content is unchanged, so the inode survives no-op rewrites.
+ let mut read_only = paths(&["/etc", "/etc/resolv.conf", "/usr/share", "/proc"]);
+ // /run stays out of balanced. The original reason — pathname-socket
+ // connect() being unmediated — is gone since the floor walls that class
+ // outright [D-4b], but least privilege is reason enough: no build tool
+ // needs /run. Add /run/user/ via --policy for a genuine
+ // XDG_RUNTIME_DIR case. Note `/etc/resolv.conf` above resolves INTO /run on
+ // most hosts — that single inode is the deliberate exception, not a crack in
+ // this rule.
+ //
+ // /var/log and /var/cache are NOT granted. Nothing a build does writes
+ // there, and both are shared system sinks: /var/log is where evidence of
+ // the session lands, /var/cache holds packages a later root process
+ // installs. On a normal host DAC already refuses the write, which is
+ // precisely why granting them looked free — the grant is dead weight that
+ // becomes a real widening on any host where a subdirectory is group- or
+ // world-writable. A tier that only ever matters when something else is
+ // misconfigured is a tier to delete.
+ // NOT `mut`, since 2026-07-29. The `mut` was residue from the shared-cache grant
+ // removal (D1): this binding used to be pushed to, and once those pushes went the
+ // compiler started warning and the warning sat in the build. That matters more than
+ // one keyword — a security tool whose build prints warnings is a tool whose NEXT
+ // warning goes unread.
+ //
+ // It is also one half of CON-2's mutation point, so the exact recipe is written down
+ // rather than left to be re-derived. That row's `enforced` status rests on re-adding
+ // a shared-cache grant and watching
+ // `no_profile_grants_write_on_a_shared_package_cache` turn red, and the mutation is
+ // now TWO lines in TWO places:
+ //
+ // 1. restore `mut` on this binding, and
+ // 2. add `write_noexec.push(h.join(".cargo/registry"));` inside the
+ // `if let Some(h) = home()` block below — NOT here, where `h` is not yet bound.
+ //
+ // Verified 2026-07-29: that mutant panics at the assertion in this file's tests;
+ // reverted, sha256 identical. An earlier attempt put the push at this binding and did
+ // not compile, which is worth recording because a mutation that fails to build is
+ // indistinguishable from one the test caught if you only check for a red run.
+ let write_noexec = paths(&[
"/tmp",
- "/var/log",
- "/var/cache",
"/var/tmp",
// Standard character devices (file-set → RW+ioctl, no exec). No real
// program runs without /dev/null; RNG needs /dev/urandom. /dev/tty when
@@ -88,26 +161,121 @@ pub fn balanced() -> Policy {
read_exec.push(h.join(".rustup")); // rustc/cargo toolchain binaries
read_only.push(h.join(".gitconfig")); // git identity
- // Read the cache roots (cargo needs ~/.cargo/config.toml, ~/.cargo/env)
- // WITHOUT granting write on them — they are parents of bin/.
- read_only.push(h.join(".cargo"));
+ // git's XDG spelling of the SAME files. `~/.gitconfig` and
+ // `$XDG_CONFIG_HOME/git/config` are one thing in two locations — the
+ // identical relationship `.cargo/config.toml` / `.cargo/config` has
+ // below, and the same reason for granting both.
+ //
+ // Named individually rather than granting `~/.config/git/`, for the
+ // reason stated on the cargo lines: a directory grant unions down the
+ // path walk and sweeps whatever the tool puts there later. These three
+ // are the closed set git reads from that directory.
+ //
+ // WHY THIS IS NOT COSMETIC (T23, measured 2026-07-28). Landlock hooks
+ // the open and NOT the metadata check, so a denied path answers
+ // `access(R_OK)` → true, `stat()` → ok, `open()` → EACCES. **DAC cannot
+ // produce that combination** — `chmod 000` fails both together. git
+ // pre-flights with `access()`, is told the file is readable, then dies
+ // when the open fails. Ungranted, `~/.config/git/config` made EVERY git
+ // operation fail in-cage with `fatal: unknown error occurred while
+ // reading the configuration files` — naming neither the file nor the
+ // sandbox. `ignore` is fatal too; `attributes` degrades cleanly and is
+ // granted anyway, because granting two of the three would leave a
+ // silent behavioural difference instead of a loud one.
+ //
+ // This grant fixes the INSTANCE. The class — any tool that pre-flights
+ // then opens — is a DESIGN §2 residual and is not closeable here.
+ // `notes/2026-07-28-landlock-access-open-divergence.md`.
+ read_only.push(h.join(".config").join("git").join("config"));
+ read_only.push(h.join(".config").join("git").join("ignore"));
+ read_only.push(h.join(".config").join("git").join("attributes"));
+
+ // cargo needs its CONFIG, not its directory. A `read_only` grant on
+ // `~/.cargo` unions down the path walk and sweeps in
+ // `credentials.toml` — the crates.io token, plaintext — along with any
+ // `[registries.*] token` in config. That is the same union reasoning
+ // that forced the write-side bin/ split below, applied to the read
+ // axis; it was missed here. Name the files instead. A build or test
+ // never needs the token: only `cargo publish` and authenticated
+ // registry operations do, and those are not what a cage is for.
+ read_only.push(h.join(".cargo").join("config.toml"));
+ read_only.push(h.join(".cargo").join("config")); // pre-1.39 spelling
+ read_only.push(h.join(".cargo").join("env"));
read_only.push(h.join("go"));
read_exec.push(h.join(".cargo").join("bin"));
read_exec.push(h.join("go").join("bin"));
- // Write only where the toolchains actually write, none of it a parent
- // of bin/. `.package-cache` is cargo's top-level lock FILE; add_tier
- // routes it to the file mask, so this grants write to that inode alone
- // and not to `~/.cargo`. It is skipped if absent — a $HOME that has
- // never run cargo has no lock file yet, and cargo cannot create one
- // under a read-only `~/.cargo` (see the compat note in DESIGN §7).
- write_noexec.push(h.join(".cargo").join("registry"));
- write_noexec.push(h.join(".cargo").join("git"));
- write_noexec.push(h.join(".cargo").join(".package-cache"));
- write_noexec.push(h.join("go").join("pkg"));
-
- write_noexec.push(h.join(".npm"));
- write_noexec.push(h.join(".cache"));
+ // Shared package caches: READABLE, never writable.
+ //
+ // CORRECTED 2026-07-28, hours after the first attempt, and the bug is
+ // worth stating because it is not obvious: `write_noexec` CONFERRED
+ // READ. The first cut simply deleted those lines, which removed the
+ // paths from every tier — so `cargo build --offline` failed against a
+ // fully warm registry with "no matching package found / location
+ // searched: crates.io index". The runtime check that was run
+ // ("the write now returns EACCES") CANNOT DISTINGUISH "write removed"
+ // from "path removed entirely" — both produce EACCES. The functionality
+ // check was run against a hand-built read-only `CARGO_HOME`, i.e. a
+ // model of the change rather than the change. Demote, do not delete.
+ read_only.push(h.join(".cargo").join("registry"));
+ read_only.push(h.join(".cargo").join("git"));
+ read_only.push(h.join(".npm"));
+ // `~/go/pkg` needs no line: `~/go` is already read_only above.
+ // `~/.cache` is deliberately NOT restored — see the note below.
+
+ // NO WRITE GRANT ON ANY SHARED PACKAGE CACHE. Removed 2026-07-28 by
+ // operator decision (the maintainer's decision log, D1). This block used
+ // to grant `write_noexec` on `~/.cargo/{registry,git,.package-cache}`,
+ // `~/go/pkg`, `~/.npm` and `~/.cache`, on the reasoning "write only
+ // where the toolchains actually write, none of it a parent of bin/".
+ // That reasoning was sound about `bin/` and wrong about the caches.
+ //
+ // WHY, MEASURED END-TO-END 2026-07-28: cargo verifies a tarball once at
+ // download and never verifies anything on disk again. `.cargo-ok` is a
+ // version tag with no hash; the `Source::verify` hook exists and
+ // `RegistrySource` does not implement it; a cache hit on a `.crate`
+ // checks only that the file is non-empty. So a payload appended to a
+ // `build.rs` under `registry/src`, with `.cargo-ok` left untouched,
+ // executes at the OPERATOR's uid on an ordinary `cargo build` — or on
+ // `cargo check` alone, which is what an editor runs — in a completely
+ // unrelated project. Cross-project, persistent across sessions, and
+ // absent from both repos' git history.
+ //
+ // W^X buys nothing against it: the chain plants SOURCE that a later
+ // toolchain compiles, it does not execute from the cache. And this is
+ // not the workdir-poisoning residual — that tree is the one the agent
+ // exists to edit and the operator knows is tainted. These are not.
+ //
+ // WHAT THIS COSTS, MEASURED: a warm offline build against a fully
+ // read-only CARGO_HOME succeeds (rc=0) — cargo tolerates an unwritable
+ // lock file and an unwritable registry. What breaks is FETCHING a
+ // dependency that is not already cached, and it breaks loudly, naming
+ // the crate. Pre-warm outside the cage, or grant a cache explicitly
+ // with `--policy`.
+ //
+ // Three designs to AVOID re-proposing; each was tried and failed:
+ // - read-only `registry/src` only: cargo's GC evicts `src` at 1 month
+ // and `cache` at 3, then re-extracts from a length-checked-only
+ // tarball, so the poison outlives the eviction.
+ // - a private per-scope `CARGO_HOME`: that is a `CARGO_HOME` whose
+ // `config.toml` the agent owns, and `[build] rustc-wrapper` there is
+ // code execution — reopening the hole `config.toml` above closes.
+ // - seeding a private scope from the shared tree: the agent owns its
+ // scope's symlinks and aims them at the shared tree, so the
+ // unconfined seeder writes the poison for it.
+ //
+ // `~/.cache` IS NOT RESTORED, and that is a separate judgement from the
+ // write question. It is an XDG namespace whose membership third parties
+ // decide after this policy was written — `ccache`, `sccache`, `bazel`,
+ // `uv`, `pre-commit` each join it on the next install — and it holds a
+ // credential (`~/.cache/huggingface/token`), which is the same
+ // credentials-in-a-granted-tier defect this file already guards for
+ // `~/.cargo/credentials.toml` by name. Read access to it was collateral
+ // from the old write grant, not a considered decision. If a build needs
+ // a specific subdirectory, name it in `--policy`. NOTE `~/.cache/go-build`
+ // is `GOCACHE` and is therefore unavailable in the cage — unmeasured,
+ // and the honest statement is that Go builds are not covered by the
+ // cargo cost measurement below.
} else {
// No HOME → no toolchain caches granted; a subsequent `cargo build` will
// EACCES with no obvious cause. Announce it (matches the skip-path
@@ -119,22 +287,50 @@ pub fn balanced() -> Policy {
);
}
- // Honor $TMPDIR when it differs from /tmp — linkers (cc/ld) and many build
- // tools write scratch there; without it `cargo build` fails at link. Kept
- // write_noexec (W^X): temp objects are written, not executed. Tools that
- // exec from scratch (`go test`/`go run`) need an exec scratch via --policy.
- if let Some(t) = std::env::var_os("TMPDIR") {
- let p = PathBuf::from(t);
- if p.is_absolute() && p.as_path() != std::path::Path::new("/tmp") {
- write_noexec.push(p);
- }
- }
+ // $TMPDIR IS NO LONGER READ FROM THE ENVIRONMENT. It used to be pushed into
+ // write_noexec here, guarded only by "absolute" and "not /tmp".
+ //
+ // In oring's threat model the CALLER is the adversary, and this made a
+ // caller-supplied environment variable widen a built-in profile's tiers.
+ // `check_profile_grants` was added to catch the obvious case and refuses a
+ // tier that CONTAINS $HOME — but it tests `home.starts_with(tier)`, so
+ // narrowing by one component walked straight past it. Measured:
+ // `TMPDIR=$HOME/.ssh --profile balanced` sealed, and the child got
+ // `O_WRONLY` on `~/.ssh/authorized_keys` — the exact escalation that
+ // refusal message claims to prevent. `TMPDIR=$HOME/.cargo` additionally
+ // broke all three of this profile's own documented invariants, and the
+ // tree's `profile::` unit tests fail under it while oring sealed happily.
+ //
+ // NO PREDICATE ON THE PATH FIXES THIS. Each candidate was tested and each
+ // either misses the case or breaks a legitimate one: a symmetric $HOME guard
+ // breaks `TMPDIR=~/tmp` and misses `/usr/local/bin` and `/etc`; banning
+ // dot-paths misses `~/Mail` and wrongly rejects `~/.cache/tmp`; requiring a
+ // sticky or world-writable dir breaks `/run/user/` (0700);
+ // caller-created-this-run is forgeable by the caller who owns $HOME. The
+ // decisive point: **`~/.ssh` is an existing, caller-owned, mode-0700
+ // directory — exactly the shape `create_exec_tmp` accepts as a GOOD scratch
+ // dir.** The distinguishing information is intent, and intent is not in the
+ // environment.
+ //
+ // So oring stops taking the path from there and takes it from the command
+ // line instead: `--tmpdir` (write_noexec) or `--exec-tmp` (write_exec). Both
+ // are operator assertions rather than ambient state, and both are validated.
+ // oring then SETS the child's `TMPDIR` to whatever it actually granted, so
+ // the grant and the variable can no longer disagree.
+ //
+ // Prior art is unanimous — no unprivileged sandbox honours a caller-supplied
+ // $TMPDIR as a path specification: bubblewrap never reads it; flatpak
+ // unconditionally UNSETS TMPDIR/TEMP/TMP/TEMPDIR ("their session values are
+ // likely to interfere with the functioning of the sandbox"); systemd SETS
+ // $TMPDIR=/tmp under PrivateTmp=; sudo's env_reset drops it; glibc's
+ // tempnam(3) honours it "only when the program is not set-user-ID".
Policy {
version: 1,
// Built-in profile: paths are best-effort defaults, not author intent.
user_authored: false,
require_all_grants: false,
+ require_full_coverage: false,
landlock: Landlock {
read_exec,
// /proc read is required by almost every real toolchain (Go/node
@@ -146,6 +342,19 @@ pub fn balanced() -> Policy {
write_noexec,
egress_tcp: Some(vec![443]),
ingress_tcp: Some(Vec::new()), // explicit deny-all binds
+ // DNS out, and NOTHING else in UDP. Port 0 on the ingress side is
+ // not an inbound grant: it is the kernel's synthetic port for
+ // AUTOBIND of a sending socket's ephemeral source port, so without
+ // it the egress grant above is inert and DNS fails closed. See the
+ // autobind note in landlock.rs, which warns on exactly that pair.
+ //
+ // These were `None` — UNHANDLED, i.e. unrestricted UDP — because a
+ // V10-only default would have refused to seal on every kernel in
+ // the field. FLOOR_ABI is now 10, so oring refuses those kernels
+ // regardless and that reason is gone; leaving them unset would have
+ // kept the §2 UDP/DNS residual for nothing.
+ egress_udp: Some(vec![53]),
+ ingress_udp: Some(vec![0]),
scope_ipc: true,
},
seccomp: Seccomp {
@@ -169,6 +378,7 @@ pub fn strict() -> Policy {
// Built-in profile: paths are best-effort defaults, not author intent.
user_authored: false,
require_all_grants: false,
+ require_full_coverage: false,
landlock: Landlock {
read_exec: paths(&[
"/usr/bin",
@@ -197,6 +407,16 @@ pub fn strict() -> Policy {
// §2 residual.
egress_tcp: Some(Vec::new()),
ingress_tcp: Some(Vec::new()),
+ // Deny-all UDP, matching the TCP posture above. Empty-beside-empty
+ // is the coherent case the autobind warning deliberately does not
+ // fire on: nothing may be sent, so no source port is needed.
+ //
+ // These were `None`, which left the STRICTER profile with deny-all
+ // TCP and UNRESTRICTED UDP egress — the §2 residual at its most
+ // surprising, and the widest gap between posture and intent of
+ // anything oring shipped. At FLOOR_ABI 10 it closes.
+ egress_udp: Some(Vec::new()),
+ ingress_udp: Some(Vec::new()),
scope_ipc: true,
},
seccomp: Seccomp {
@@ -205,13 +425,18 @@ pub fn strict() -> Policy {
}
}
+/// Every built-in profile, name beside constructor. **This is the span every
+/// ∀-over-profiles test quantifies over**, which is why `by_name` is derived
+/// from it rather than being a second `match` beside it: a profile that is
+/// resolvable but absent from this table would be silently exempt from all of
+/// them, and the tests would still report a pass over the wrong span.
+pub type Profile = (&'static str, fn() -> Policy);
+
+pub const ALL: &[Profile] = &[("balanced", balanced), ("strict", strict)];
+
/// Resolve a profile name to its baked policy.
pub fn by_name(name: &str) -> Option {
- match name {
- "balanced" => Some(balanced()),
- "strict" => Some(strict()),
- _ => None,
- }
+ ALL.iter().find(|(n, _)| *n == name).map(|(_, ctor)| ctor())
}
/// Does the named profile put `--workdir` in write_exec (true) or write_noexec
@@ -225,6 +450,22 @@ mod tests {
use super::*;
use std::path::Path;
+ /// EVERY path in EVERY tier, for both baked profiles.
+ ///
+ /// A credential must not be READABLE either, so the write-only view is the
+ /// wrong span for the check below — `read_only` is exactly where a
+ /// convenience grant for `~/.ssh/known_hosts` would land.
+ fn all_tiers(p: &crate::policy::Policy) -> Vec {
+ p.landlock
+ .read_exec
+ .iter()
+ .chain(p.landlock.read_only.iter())
+ .chain(p.landlock.write_exec.iter())
+ .chain(p.landlock.write_noexec.iter())
+ .cloned()
+ .collect()
+ }
+
/// Every path in a write tier, for both baked profiles.
fn write_tiers(p: &crate::policy::Policy) -> Vec {
p.landlock
@@ -235,6 +476,102 @@ mod tests {
.collect()
}
+ /// No built-in profile may leave a net right UNHANDLED.
+ ///
+ /// `None` on one of these fields does not mean "denied", it means oring never
+ /// names the right to the kernel and the child keeps it in full. That is how
+ /// `strict` shipped with deny-all TCP beside UNRESTRICTED UDP egress — a
+ /// DNS/exfil path out of the profile whose whole promise was no egress.
+ ///
+ /// Asserted over every field rather than the two UDP ones, and over `ALL`
+ /// rather than a written-out pair, so this closes the SHAPE of the defect
+ /// instead of the instance: a future net right added to `Landlock`, or a
+ /// third profile, is covered the day it exists.
+ #[test]
+ fn no_profile_leaves_a_net_right_unhandled() {
+ for (name, ctor) in ALL {
+ let ll = ctor().landlock;
+ for (field, handled) in [
+ ("egress_tcp", ll.egress_tcp.is_some()),
+ ("ingress_tcp", ll.ingress_tcp.is_some()),
+ ("egress_udp", ll.egress_udp.is_some()),
+ ("ingress_udp", ll.ingress_udp.is_some()),
+ ] {
+ assert!(
+ handled,
+ "{name}: {field} is None — the right is UNHANDLED, so the child \
+ keeps it entirely. Write Some(vec![]) for an explicit deny-all."
+ );
+ }
+ }
+ }
+
+ /// A profile granting UDP egress must also permit ephemeral autobind.
+ ///
+ /// `sendto` on an unbound socket acquires its source port via kernel
+ /// autobind, which Landlock checks against a synthetic **port 0**. So
+ /// handling `BindUdp` without granting 0 denies the source port and with it
+ /// every datagram `egress_udp` appears to permit: the grant is INERT and the
+ /// profile silently does not do the thing it says. `landlock::apply` warns on
+ /// this at run time; for shipped profiles it is a build-time property, because
+ /// a warning nobody reads is not a control.
+ #[test]
+ fn every_profile_granting_udp_egress_permits_autobind() {
+ for (name, ctor) in ALL {
+ let ll = ctor().landlock;
+ let (Some(eg), Some(ing)) = (&ll.egress_udp, &ll.ingress_udp) else {
+ continue; // handled by no_profile_leaves_a_net_right_unhandled
+ };
+ if !eg.is_empty() {
+ assert!(
+ ing.contains(&0),
+ "{name}: egress_udp grants {eg:?} but ingress_udp {ing:?} omits port 0 \
+ — autobind is denied, so the egress grant sends nothing"
+ );
+ }
+ }
+ }
+
+ /// The shipped UDP decision itself: `balanced` resolves names and nothing
+ /// more, `strict` sends no datagrams at all.
+ ///
+ /// The two properties above are shape checks — both would still pass if
+ /// `balanced` quietly grew a grant on :123 or :5353. This is the VALUE, and
+ /// it is a deliberate narrowing: DNS is the one UDP port a toolchain needs to
+ /// fetch anything, and it is also the classic exfil channel, so widening it
+ /// is a decision that should have to edit a test that says so.
+ ///
+ /// Measured at this posture on 7.2.0-rc4, via bash `/dev/udp` under each
+ /// profile with an unsealed control: balanced :53 permitted / :9 denied,
+ /// strict both denied, and `getent hosts` resolves under balanced (0) but
+ /// not under strict (2).
+ #[test]
+ fn the_shipped_udp_posture_is_dns_only_or_deny_all() {
+ let b = balanced().landlock;
+ assert_eq!(
+ b.egress_udp.as_deref(),
+ Some(&[53u16][..]),
+ "balanced grants DNS out and nothing else"
+ );
+ assert_eq!(
+ b.ingress_udp.as_deref(),
+ Some(&[0u16][..]),
+ "balanced permits autobind only — port 0 is not an inbound service grant"
+ );
+
+ let s = strict().landlock;
+ assert_eq!(
+ s.egress_udp.as_deref(),
+ Some(&[][..]),
+ "strict sends no UDP; empty is an explicit deny-all, not an omission"
+ );
+ assert_eq!(
+ s.ingress_udp.as_deref(),
+ Some(&[][..]),
+ "strict binds no UDP port, not even for autobind"
+ );
+ }
+
/// The invariant the tier split exists to hold, stated so that
/// reverting the split fails here rather than shipping green.
///
@@ -251,10 +588,8 @@ mod tests {
/// so this runs anywhere the crate compiles.
#[test]
fn no_write_tier_is_an_ancestor_of_a_path_bin_dir() {
- for name in ["balanced", "strict"] {
- let Some(p) = by_name(name) else {
- panic!("{name}: profile missing");
- };
+ for (name, ctor) in ALL {
+ let p = ctor();
let writes = write_tiers(&p);
for exec_dir in &p.landlock.read_exec {
// Only the $PATH-bearing bin dirs matter: a writable /usr/bin
@@ -276,6 +611,186 @@ mod tests {
}
}
+ /// No shipped profile may grant write on a SHARED, CROSS-PROJECT package
+ /// cache. This is the negative assertion D1 turned on: the mechanism half
+ /// of "an out-of-tier write is denied" was always tested, but nothing
+ /// asserted that the *shipped profiles* leave these paths out of every
+ /// write tier — and a convenience grant added later would have kept the
+ /// whole suite green while reopening the class.
+ ///
+ /// Measured 2026-07-28: with these granted, a payload appended to a
+ /// `build.rs` under `~/.cargo/registry/src` executes at the operator's uid
+ /// on an ordinary `cargo build`, or on `cargo check` alone, in an unrelated
+ /// project. Decided in the maintainer's decision log, D1 — that log is not in
+ /// this repository, so the reasoning that matters is restated above.
+ ///
+ /// This asserts the ABSENCE of a grant, so it can only fail by someone
+ /// adding one back. That is exactly its job.
+ /// T16: no shipped profile grants ANY access to a host credential store.
+ ///
+ /// This is the first sentence of the guarantee list in `docs/RESIDUALS.md`
+ /// and of the README — *"FS access outside policy tiers — host creds
+ /// (`~/.ssh`, `~/.aws`)"* — and until now **nothing tested it**.
+ /// `read_outside_tiers_is_eacces` builds its own policy, so it proves
+ /// Landlock denies an out-of-tier read; it says nothing about whether the
+ /// SHIPPED profiles leave these paths out of every tier. A convenience grant
+ /// added to `balanced()` would keep the entire suite green while falsifying
+ /// the example a reader meets first.
+ ///
+ /// Checked over ALL FOUR tiers, not just the write ones: `read_only` is
+ /// precisely where a well-meaning `~/.ssh/known_hosts` or `~/.netrc` grant
+ /// would be added, and reading a private key is the loss this bullet names.
+ ///
+ /// **Ancestor test, both directions.** A tier that IS a credential path and a
+ /// tier that CONTAINS one are both failures — and so is a credential path
+ /// that contains a tier, which is how `~/.ssh` would be caught if someone
+ /// granted `~/.ssh/config` specifically.
+ #[test]
+ fn no_profile_grants_any_access_to_a_credential_store() {
+ let Some(h) = home() else {
+ eprintln!("SKIP: no_profile_grants_any_access_to_a_credential_store: HOME unset");
+ return;
+ };
+
+ // VACUITY CONTROL. If HOME resolution silently produced a tier set with
+ // nothing under it, every assertion below would hold over an empty span
+ // and this test would be a clean-looking zero. `balanced` is documented
+ // to grant the toolchain caches, so it must have at least one tier under
+ // $HOME for the check to mean anything. (`strict` grants no $HOME at all
+ // by design, which is why the control is asserted on `balanced` only.)
+ let balanced_home_tiers = all_tiers(&balanced())
+ .into_iter()
+ .filter(|t| t.starts_with(&h))
+ .count();
+ assert!(
+ balanced_home_tiers > 0,
+ "VACUOUS: `balanced` has no tier under {} at all, so a credential check over $HOME paths asserts nothing. Either HOME resolution is broken here or the profile stopped granting the toolchain caches — both make this test a clean-looking zero rather than a pass.",
+ h.display()
+ );
+
+ // Host credential stores. Each is a store a compromised child reaching it
+ // would be a BREACH of the first guarantee oring advertises.
+ let creds = [
+ h.join(".ssh"),
+ h.join(".aws"),
+ h.join(".gnupg"),
+ h.join(".netrc"),
+ h.join(".kube"),
+ h.join(".docker").join("config.json"),
+ h.join(".config").join("gh"),
+ // Shell history: not a credential store by design, but routinely a
+ // credential store in practice.
+ h.join(".bash_history"),
+ h.join(".zsh_history"),
+ ];
+
+ for (name, ctor) in ALL {
+ let p = ctor();
+ for tier in all_tiers(&p) {
+ for cred in &creds {
+ assert!(
+ !cred.starts_with(&tier) && !tier.starts_with(cred),
+ "{name}: {} is reachable via tier {} — this is the FIRST example in the guarantee list in README.md and docs/RESIDUALS.md (\"host creds (~/.ssh, ~/.aws)\"), so granting it does not merely widen the cage, it falsifies the sentence a reader meets first. If a credential path must be reachable, the operator names it in a hand-written --policy and owns that decision; a shipped profile may not make it for them.",
+ cred.display(),
+ tier.display()
+ );
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn no_profile_grants_write_on_a_shared_package_cache() {
+ let Some(h) = home() else {
+ eprintln!("SKIP: no_profile_grants_write_on_a_shared_package_cache: HOME unset");
+ return;
+ };
+ // Cross-project caches a later UNCONFINED toolchain reads.
+ let caches = [
+ h.join(".cargo").join("registry"),
+ h.join(".cargo").join("git"),
+ // The lock FILE inode. It was granted write until 2026-07-28 and is
+ // listed here because it is not a descendant of any path above, so
+ // re-adding exactly that one grant would otherwise leave this test
+ // green — the single omission the first mutation run happened not
+ // to probe.
+ h.join(".cargo").join(".package-cache"),
+ h.join("go").join("pkg"),
+ h.join(".npm"),
+ h.join(".cache"),
+ ];
+ for (name, ctor) in ALL {
+ let p = ctor();
+ for w in write_tiers(&p) {
+ for cache in &caches {
+ assert!(
+ !cache.starts_with(&w) && &w != cache,
+ "{name}: {} is writable via tier {} — a confined child can plant a \
+ build.rs/postinstall/setup.py there that a LATER UNCONFINED build \
+ in an UNRELATED project compiles and runs at the operator's uid. \
+ Cross-project, persistent, absent from both repos' git history. \
+ W^X does not help: the chain plants SOURCE, it does not exec from \
+ the cache. If a cache must be writable, the operator grants it \
+ explicitly with --policy.",
+ cache.display(),
+ w.display()
+ );
+ }
+ }
+ }
+ }
+
+ /// Both spellings of a tool's config are granted, or the tool breaks in a
+ /// way that names neither the file nor the sandbox (T23).
+ ///
+ /// The expected set is written out LONGHAND on purpose. Deriving it from
+ /// the profile — or from a shared constant — is what made the T22 test
+ /// unable to fail: three of five mutations came back green because the
+ /// expectation moved with the thing under test. The length assertion forces
+ /// a deliberate edit here when a path is added, rather than a silent one.
+ #[test]
+ fn both_spellings_of_a_config_are_readable() {
+ let Some(h) = home() else {
+ eprintln!("SKIP: both_spellings_of_a_config_are_readable: HOME unset");
+ return;
+ };
+ let Some(p) = by_name("balanced") else {
+ panic!("balanced profile missing");
+ };
+ let expected = [
+ h.join(".gitconfig"),
+ h.join(".config").join("git").join("config"),
+ h.join(".config").join("git").join("ignore"),
+ h.join(".config").join("git").join("attributes"),
+ h.join(".cargo").join("config.toml"),
+ h.join(".cargo").join("config"),
+ ];
+ assert_eq!(
+ expected.len(),
+ 6,
+ "update this test deliberately when the set changes"
+ );
+ // Readable = named in read_only/read_exec, or beneath something that is.
+ let readable: Vec<&PathBuf> = p
+ .landlock
+ .read_only
+ .iter()
+ .chain(p.landlock.read_exec.iter())
+ .collect();
+ for want in &expected {
+ assert!(
+ readable.iter().any(|r| want.starts_with(r)),
+ "balanced does not grant read on {} — a tool that pre-flights with \
+ access() (which Landlock leaves answering from DAC) will be told the \
+ file is readable, fail the open with EACCES, and take a die() branch. \
+ Measured: without ~/.config/git/config every git operation in-cage \
+ fails with a message naming neither the file nor the sandbox. \
+ Grant BOTH spellings or neither.",
+ want.display()
+ );
+ }
+ }
+
/// Guards the specific grants by name, so a rename or a "helpful" widening
/// back to `write_exec.push(h.join(".cargo"))` is caught even if the generic
/// invariant above is somehow satisfied.
@@ -315,4 +830,60 @@ mod tests {
}
let _ = Path::new("/");
}
+
+ /// The read-side counterpart of the split above, and the one that was
+ /// missed: EVERY tier confers read, so a `read_only` grant on `~/.cargo`
+ /// unions down onto `credentials.toml` and hands a confined child the
+ /// crates.io token in plaintext. One unprivileged read, no kernel bug, no
+ /// cooperating peer — and a leaked publish token is not revocable in any
+ /// way that undoes what was published with it. It also falsifies the
+ /// stated invariant that credentials are withheld by construction.
+ ///
+ /// Guards the credential paths by name so a "helpful" re-widening back to
+ /// `read_only.push(h.join(".cargo"))` fails here rather than shipping.
+ #[test]
+ fn cargo_credentials_are_never_readable() {
+ let Some(p) = by_name("balanced") else {
+ panic!("balanced profile missing");
+ };
+ let Some(h) = home() else {
+ eprintln!("SKIP: cargo_credentials_are_never_readable: HOME unset");
+ return;
+ };
+ // Read is conferred by every tier, not just the read_* ones.
+ let all: Vec = p
+ .landlock
+ .read_only
+ .iter()
+ .chain(p.landlock.read_exec.iter())
+ .chain(p.landlock.write_noexec.iter())
+ .chain(p.landlock.write_exec.iter())
+ .cloned()
+ .collect();
+ for secret in [
+ h.join(".cargo").join("credentials.toml"),
+ h.join(".cargo").join("credentials"), // pre-1.39 spelling
+ ] {
+ for t in &all {
+ assert!(
+ !secret.starts_with(t),
+ "{} is covered by tier {} — Landlock unions along the path, \
+ so the crates.io token is READABLE by the child. Grant \
+ ~/.cargo/config.toml and ~/.cargo/env by name, never the \
+ directory.",
+ secret.display(),
+ t.display()
+ );
+ }
+ }
+ // And the config cargo actually needs must still be granted, or builds
+ // break while this test stays green.
+ assert!(
+ p.landlock
+ .read_only
+ .iter()
+ .any(|r| r == &h.join(".cargo").join("config.toml")),
+ "~/.cargo/config.toml must stay in read_only or cargo loses its config"
+ );
+ }
}
diff --git a/src/seccomp.rs b/src/seccomp.rs
index 9a6e164..5faf22a 100644
--- a/src/seccomp.rs
+++ b/src/seccomp.rs
@@ -51,10 +51,26 @@ const MSG_OOB: u64 = 0x1;
// net=on, connect(2) to a denied port is EACCES while
// sendto(..., MSG_FASTOPEN, &sa) establishes a full bidirectional session.
// Landlock fixed this in 33cb713db016 (mainline, merged 2026-07-01, ships in
-// 7.2, Cc: stable), but oring's floor is V7/6.15 and every supported kernel
-// through 7.1.x still has the gap — so ring 2 closes it. (The floor raise to
-// the fix ships in 7.2; every kernel at oring's 6.15 floor through 7.1.x is
-// still pre-fix, so this guard stays until the floor reaches 7.2.)
+// 7.2, Cc: stable).
+//
+// **THIS RULE'S OLD RETIREMENT CONDITION HAS FIRED, AND IT STAYS ANYWAY — the
+// reason changed, so read this before deleting it.** The comment here used to say
+// "oring's floor is V9/7.1 and every supported kernel through 7.1.x still has the
+// gap … It stays until the floor reaches 7.2." The floor reached 7.2 on
+// 2026-07-29, so that sentence now reads as an instruction to remove the rule.
+//
+// What replaces it: ring 1's mediation of this path is NOT DETECTABLE AT RUNTIME.
+// `sys::landlock_errata` returns the errata the kernel *declares*, the tree
+// declares exactly three, and **none covers this fix** — so a vendor kernel
+// reporting ABI V10, assembled by backporting, can lack the behaviour while
+// looking identical to a fixed one from oring's side. A floor is a statement about
+// the interface; this gap is behavioural. So ring 2 is what makes `strict`'s "no
+// TCP egress" true independent of how the kernel was assembled, and it is cheap:
+// one seccompiler rule on a flag bit.
+//
+// Mainline 7.2-rc4 does mediate it (`security/landlock/net.c:354`, source-read) —
+// which is exactly why this must not be read as "the kernel is broken". It is
+// "oring cannot verify that this kernel is not."
const MSG_FASTOPEN: u64 = 0x2000_0000;
// pipe2(O_NOTIFICATION_PIPE) attaches a watch_queue to the pipe, handing the
// child kernel/watch_queue.c — the same unprivileged-LPE launchpad class oring
@@ -210,17 +226,146 @@ fn enosys_group() -> Result>> {
fn eafnosupport_group() -> Result>> {
let mut m = BTreeMap::new();
// socket(family, ...): block family >= 17.
+ //
+ // PLUS: inside AF_INET/AF_INET6, allow ONLY the protocols ring 1 can
+ // mediate. The family gate alone was a MEASURED egress bypass: ring 1
+ // handles ConnectTcp/BindTcp, and `sk_is_tcp()` is FALSE for
+ // IPPROTO_MPTCP, so under `--profile strict` (`egress_tcp: []`, documented
+ // "deny-all TCP egress") a plain socket(AF_INET, SOCK_STREAM,
+ // IPPROTO_MPTCP) connected to a plain-TCP listener and delivered payload
+ // while IPPROTO_TCP got EACCES. Landlock is behaving as specified —
+ // erratum 1 deliberately narrowed the TCP rights to real TCP — so closing
+ // it is ring 2's job, which is exactly the §3 split.
+ //
+ // Expressed as an ALLOWLIST (protocol NOT IN {0, TCP, UDP}) rather than a
+ // denylist of {MPTCP, SCTP, DCCP, …}: a protocol denylist drifts every time
+ // the kernel gains a stream protocol, which is the failure mode the x32
+ // guard exists to compensate for elsewhere. 0 is the family default.
+ //
+ // CONSEQUENCE, deliberate and wider than the reported bypass: this also
+ // denies IPPROTO_ICMP, so §2's ICMP residual closes for AF_INET/AF_INET6
+ // and in-cage `ping` stops working. That follows from the principle — ring
+ // 1 cannot mediate ICMP either — rather than being an accident. Narrow it
+ // if the compatibility cost is unwanted. AF_UNIX and AF_NETLINK are
+ // untouched, so toolchains and getaddrinfo are unaffected.
+ // TRUNCATION: `protocol` is a 32-bit C type, so these MUST go through the
+ // `cond_*` helpers (ArgLen::Dword). A hand-rolled Qword compare here would
+ // reintroduce H2 — a child sets garbage upper bits, the kernel acts on the
+ // low 32, the Qword `Ne` fails to match and the wall falls open. Verified
+ // both ways: the truncation variants (0x1_0000_0106, low 32 = MPTCP; and
+ // high bits on the family arg) are all refused, and switching `cond_ne` to
+ // Qword makes `tests/seccomp_smt.rs` name this exact cell —
+ // "eafnosupport/socket arg2: verdict depends on the upper 32 bits". So the
+ // SMT gate does watch this rule. Note it only runs with `--features
+ // bpf-dump`, which `make check` does not pass; `make smt` is its own target,
+ // and CI runs that — the SMT gate is pure computation and needs no kernel.
+ const IPPROTO_DEFAULT: u64 = 0;
+ const IPPROTO_TCP_N: u64 = 6;
+ const IPPROTO_UDP_N: u64 = 17;
+ let inet_unmediated_proto = |family: u64| -> Result {
+ rule(vec![
+ cond_eq(0, family)?,
+ cond_ne(2, IPPROTO_DEFAULT)?,
+ cond_ne(2, IPPROTO_TCP_N)?,
+ cond_ne(2, IPPROTO_UDP_N)?,
+ ])
+ };
+
+ // THE TYPE AXIS. arg 1 was never examined, which made the rule above's
+ // stated contract false: `type` selects the transport INDEPENDENTLY of
+ // `protocol`. `inet_create` dispatches on `inetsw[sock->type]`
+ // (`net/ipv4/af_inet.c`), and with `protocol == 0` the first registrant for
+ // that type wins and rewrites protocol to its own. In 7.2-rc4 SCTP is the
+ // only registrant of SOCK_SEQPACKET on AF_INET/AF_INET6
+ // (`net/sctp/protocol.c`), so `socket(AF_INET, SOCK_SEQPACKET, 0)` walked
+ // straight past a protocol allowlist that only ever saw arg 2.
+ //
+ // COMPLETE, not sampled. `__sys_socket_create` (`net/socket.c:1742`) rejects
+ // any bit outside `SOCK_TYPE_MASK | SOCK_CLOEXEC | SOCK_NONBLOCK` with
+ // EINVAL *before* dispatch, so the reachable domain for an allowed type is
+ // exactly {STREAM, DGRAM} x {0, NONBLOCK, CLOEXEC, both} = 8 spellings.
+ // Enumerating them with `cond_ne` is therefore exhaustive. (seccompiler has
+ // `MaskedEq` but no `MaskedNe`, and conditions AND within a rule, so
+ // "masked value NOT in {1,2}" is not expressible as a masked condition —
+ // the enumeration is the cheapest correct encoding, not a shortcut.)
+ //
+ // Dword comparison, deliberately, matching the protocol rule: the kernel
+ // truncates `type` to int, so a caller passing 0x1_0000_0001 gets
+ // SOCK_STREAM and this filter must agree with that. Widening to Qword makes
+ // `tests/seccomp_smt.rs` name the cell, which is the gate noticing.
+ //
+ // Over-block, stated: `socket(AF_INET, SOCK_STREAM|0x10, 0)` now returns
+ // EAFNOSUPPORT where the kernel would have said EINVAL — a call that already
+ // fails, failing differently. AF_UNIX is untouched (the family guard), which
+ // matters because AF_UNIX SOCK_SEQPACKET is common (systemd, D-Bus, logging).
+ const SOCK_STREAM_N: u64 = 1;
+ const SOCK_DGRAM_N: u64 = 2;
+ const SOCK_NONBLOCK_N: u64 = 0x800;
+ const SOCK_CLOEXEC_N: u64 = 0x8_0000;
+ let inet_unmediated_type = |family: u64| -> Result {
+ let mut conds = vec![cond_eq(0, family)?];
+ for ty in [SOCK_STREAM_N, SOCK_DGRAM_N] {
+ for fl in [
+ 0,
+ SOCK_NONBLOCK_N,
+ SOCK_CLOEXEC_N,
+ SOCK_NONBLOCK_N | SOCK_CLOEXEC_N,
+ ] {
+ conds.push(cond_ne(1, ty | fl)?);
+ }
+ }
+ rule(conds)
+ };
m.insert(
libc::SYS_socket,
- vec![rule(vec![cond_ge(0, AF_BLOCK_FROM)?])?],
+ vec![
+ rule(vec![cond_ge(0, AF_BLOCK_FROM)?])?,
+ inet_unmediated_proto(libc::AF_INET as u64)?,
+ inet_unmediated_proto(libc::AF_INET6 as u64)?,
+ inet_unmediated_type(libc::AF_INET as u64)?,
+ inet_unmediated_type(libc::AF_INET6 as u64)?,
+ ],
);
// socketpair walks past the socket() family wall: __sys_socketpair calls
// sock_create() for the family — request_module("net-pf-%d") then
// pf->create() — BEFORE checking whether the family implements
// ->socketpair. Same family gate, symmetric with the rule above.
+ //
+ // The PROTOCOL gate belongs here for the same reason and was missing, which
+ // made this the more serious half of the type-axis gap. `sock_create()` runs
+ // to completion — including `request_module("net-pf-2-proto-%d-type-%d")` —
+ // for a call that can NEVER return a usable inet fd, because inet has no
+ // ->socketpair. So an unprivileged, sealed child could reach
+ // `socketpair(AF_INET, SOCK_SEQPACKET, IPPROTO_SCTP)` purely for its side
+ // effect: the kernel asks modprobe — a usermode helper running OUTSIDE the
+ // sandbox — to load `sctp.ko`, after which the child can open a real SCTP
+ // socket that ring 1 does not mediate (`sk_is_tcp()`/`sk_is_udp()` are both
+ // false for it). That is a self-serve path from inside the cage to an
+ // unmediated transport, and it removes the "sctp.ko must already be
+ // resident" precondition the finding was originally discounted for.
+ //
+ // MEASURED, unprivileged, on 7.2.0-rc4 — the errno proves the ordering:
+ // socketpair(AF_INET, SOCK_STREAM, IPPROTO_TCP) -> EOPNOTSUPP
+ // socketpair(AF_INET, SOCK_SEQPACKET, 132) -> ESOCKTNOSUPPORT
+ // `EOPNOTSUPP` is returned only when `sock->ops->socketpair` is NULL, i.e.
+ // AFTER `sock_create()` already succeeded — so the create path, and the
+ // autoload with it, ran. (The second errno comes from inside `inet_create`'s
+ // `inetsw[]` lookup, which is precisely what the autoload exists to satisfy;
+ // it fails here only because this box's `localmodconfig` kernel has no
+ // sctp.ko, which is a fact about THIS BUILD, not about Linux.)
+ //
+ // Denying the call outright is why this fix does not depend on whether
+ // modprobe would have succeeded under SELinux — unresolved at the time of
+ // writing. The syscall is refused before it enters the kernel path at all.
m.insert(
libc::SYS_socketpair,
- vec![rule(vec![cond_ge(0, AF_BLOCK_FROM)?])?],
+ vec![
+ rule(vec![cond_ge(0, AF_BLOCK_FROM)?])?,
+ inet_unmediated_proto(libc::AF_INET as u64)?,
+ inet_unmediated_proto(libc::AF_INET6 as u64)?,
+ inet_unmediated_type(libc::AF_INET as u64)?,
+ inet_unmediated_type(libc::AF_INET6 as u64)?,
+ ],
);
Ok(m)
}
@@ -504,7 +649,11 @@ fn eval_cbpf(prog: &[libc::sock_filter; 7], nr: u32, arch: u32) -> u32 {
} else if i.k == OFF_ARCH {
arch
} else {
- 0
+ // An offset this model does not know. Returning a value here
+ // would make the interpreter answer for a program it cannot
+ // read — see the panic on the opcode arm below for why that
+ // is worse than stopping.
+ panic!("eval_cbpf: unmodelled seccomp_data offset {}", i.k)
};
pc += 1;
}
@@ -515,10 +664,31 @@ fn eval_cbpf(prog: &[libc::sock_filter; 7], nr: u32, arch: u32) -> u32 {
pc += 1 + usize::from(if acc & i.k != 0 { i.jt } else { i.jf });
}
BPF_RET_K => return i.k,
- _ => return 0,
+ // An opcode this model does not implement. This MUST NOT return a
+ // value: `0` is `SECCOMP_RET_KILL_THREAD`, so the old `_ => return 0`
+ // reported "this program kills" for any program using an opcode the
+ // interpreter had never heard of.
+ //
+ // Measured 2026-07-28: mutating the guard's `JSET` to `JGT` — which is
+ // how anyone would naturally write "nr >= the x32 base" — produced a
+ // guard that leaked x32 `read`, `write`, `open`, `mmap`, `mprotect` and
+ // `ioctl` while `x32_guard_corners` failed with `left: 0` at the ALLOW
+ // corner. **The gate reported "the guard allows nothing" about a guard
+ // that allowed too much.** It was not detecting the mutant; it was
+ // detecting an unmodelled opcode, and it inverted the diagnosis on the
+ // way. Anyone completing this interpreter would have turned that whole
+ // class green.
+ //
+ // Panicking is also the right answer for Kani: the proof then covers
+ // exactly the opcodes modelled here, and an unmodelled one is a failed
+ // check rather than a silently-assumed KILL.
+ _ => panic!("eval_cbpf: unmodelled cBPF opcode {:#06x}", i.code),
}
}
- 0
+ // Fell off the end without a RET. The guard is 7 instructions and every path
+ // returns within 5; reaching here means the program under test is not the
+ // program this model was written for.
+ panic!("eval_cbpf: no RET within the instruction bound")
}
#[cfg(kani)]
@@ -620,4 +790,112 @@ mod fv_tests {
// arch=0 → KILL
assert_eq!(eval_cbpf(&p, 0, 0), SECCOMP_RET_KILL_PROCESS);
}
+
+ /// The guard agrees with its specification over a DENSE table, not at four
+ /// corners. `BUILD-QUEUE.md` T1.
+ ///
+ /// **Why this exists, measured 2026-07-28.** A mutation sweep enumerated 89
+ /// mutants of `x32_guard_program()` and scored every gate in the tree against
+ /// them. The result:
+ ///
+ /// | killed by | count |
+ /// |---|---|
+ /// | `x32_guard_corners`, with both `deny_x32` process cells green | 56 |
+ /// | `deny_x32` and nothing else | **1** |
+ /// | survived every gate | 9 |
+ ///
+ /// The single mutant the process cells caught was the one they were written
+ /// against. Two of the survivors matter:
+ ///
+ /// * `i4.k := 0xC0000000` passed the **entire** shipped gate — `make check`
+ /// rc=0, 135 tests — and was seen only by Kani, which is in no CI workflow.
+ /// * `i4 := JGT X32_SYSCALL_BIT|38` leaked x32 `read`, `write`, `open`, `mmap`,
+ /// `mprotect` and `ioctl` **in the kernel, measured**, while both `deny_x32`
+ /// cells stayed green — because `getpid` (39) and `ptrace` (101) both sit
+ /// ABOVE the threshold. Probing more individual numbers does not fix this:
+ /// the dominant leak family is monotone, so it always leaks the bottom of the
+ /// numbering, and a cell can only ever witness the points it names.
+ ///
+ /// This cell kills all 89 enumerated mutants and all 79 leaky survivors found
+ /// by a wider replacement search, in microseconds, with no kernel involvement —
+ /// so it runs everywhere `cargo test` runs, including the hosted CI job where
+ /// every process-level x32 cell takes the `require_or_skip` path and counts
+ /// green.
+ ///
+ /// **What it does NOT establish.** This is a property of oring's CONSTRUCTION —
+ /// the decision `x32_guard_program()` emits, evaluated by a model of the cBPF
+ /// VM. It is not a claim about the kernel's VM, and a table is not a proof over
+ /// the whole 2^32 numbering space: that is `x32_guard_total`'s claim and this
+ /// must never be cited in its place. The model↔kernel anchor is
+ /// `tests/deny_x32.rs`, which is the only thing here that runs a real syscall.
+ #[test]
+ fn x32_guard_matches_its_spec_over_a_dense_table() {
+ let p = x32_guard_program();
+ // The specification, stated once: kill anything not x86_64, and anything
+ // carrying the x32 bit. Everything else defers to the errno filters.
+ let oracle = |nr: u32, arch: u32| -> u32 {
+ if arch != AUDIT_ARCH_X86_64 || (nr & X32_SYSCALL_BIT) != 0 {
+ SECCOMP_RET_KILL_PROCESS
+ } else {
+ SECCOMP_RET_ALLOW
+ }
+ };
+
+ let arches = [
+ AUDIT_ARCH_X86_64,
+ 0x4000_0003, // i386
+ 0xC000_00B7, // aarch64
+ 0x4000_0028, // arm
+ 0,
+ u32::MAX,
+ ];
+
+ let mut checked: u64 = 0;
+ for &arch in &arches {
+ // Dense over the real syscall range, and over the same range with the
+ // x32 bit set — the two halves the guard must separate.
+ for nr in 0u32..=1024 {
+ for candidate in [nr, nr | X32_SYSCALL_BIT] {
+ let got = eval_cbpf(&p, candidate, arch);
+ let want = oracle(candidate, arch);
+ assert_eq!(
+ got, want,
+ "guard disagrees with its spec at nr={candidate:#010x} \
+ arch={arch:#010x}: got {got:#010x}, want {want:#010x}"
+ );
+ checked += 1;
+ }
+ }
+ // Boundary values around the x32 bit itself, where an off-by-one or a
+ // threshold comparison hides.
+ for candidate in [
+ 0,
+ 1,
+ X32_SYSCALL_BIT - 1,
+ X32_SYSCALL_BIT,
+ X32_SYSCALL_BIT + 1,
+ X32_SYSCALL_BIT | 0x3FFF_FFFF,
+ 0x8000_0000,
+ 0xC000_0000,
+ u32::MAX,
+ ] {
+ let got = eval_cbpf(&p, candidate, arch);
+ let want = oracle(candidate, arch);
+ assert_eq!(
+ got, want,
+ "guard disagrees with its spec at boundary nr={candidate:#010x} \
+ arch={arch:#010x}: got {got:#010x}, want {want:#010x}"
+ );
+ checked += 1;
+ }
+ }
+ // Print what this DID, not what it intended: a table cell whose loops were
+ // mis-bounded would otherwise pass having checked nothing.
+ assert_eq!(
+ checked,
+ (arches.len() as u64) * (1025 * 2 + 9),
+ "table coverage count is not what the loops should have produced"
+ );
+ eprintln!("x32_guard_matches_its_spec_over_a_dense_table: {checked} points checked");
+ }
}
diff --git a/src/sys.rs b/src/sys.rs
index 5adc453..86db694 100644
--- a/src/sys.rs
+++ b/src/sys.rs
@@ -8,10 +8,10 @@
//! reads exactly one module to see everything oring does at the kernel edge.
#![allow(unsafe_code)]
-use std::ffi::CString;
+use std::ffi::{CStr, CString};
use std::io;
use std::os::unix::ffi::OsStrExt;
-use std::path::Path;
+use std::path::{Path, PathBuf};
use std::ptr;
const CLOSE_RANGE_CLOEXEC: libc::c_uint = 1 << 2;
@@ -133,6 +133,45 @@ pub fn access_executable(path: &Path) -> bool {
unsafe { libc::access(c.as_ptr(), libc::X_OK) == 0 }
}
+/// The invoking user's home directory **from the passwd database**, not from
+/// `$HOME`.
+///
+/// `$HOME` is an ambient source, and this project's threat model says so
+/// explicitly: the caller is trusted to *author* a policy, and the caller's
+/// ambient state is not trusted to widen or override what was authored
+/// (`CONSTRAINTS.md` CON-6). Anything that must remain correct when an untrusted
+/// earlier step has set the environment cannot read `$HOME` and call it the
+/// home directory — it can only read it and call it a hint.
+///
+/// Returns `None` if the entry is absent or has no directory. Uses the
+/// reentrant `getpwuid_r`; the fixed buffer is sized generously and a `pw_dir`
+/// longer than it yields `None` rather than a truncated path, because a
+/// truncated home would silently compare against the wrong prefix.
+pub fn passwd_home() -> Option {
+ let mut pwd: libc::passwd = unsafe { std::mem::zeroed() };
+ let mut buf = [0i8; 4096];
+ let mut out: *mut libc::passwd = std::ptr::null_mut();
+ // SAFETY: `pwd` and `buf` outlive the call and are sized as passed; `out`
+ // receives either NULL or a pointer to `pwd`. getpwuid_r writes only within
+ // the buffer it is given and never allocates.
+ let rc = unsafe {
+ libc::getpwuid_r(
+ libc::geteuid(),
+ &raw mut pwd,
+ buf.as_mut_ptr(),
+ buf.len(),
+ &raw mut out,
+ )
+ };
+ if rc != 0 || out.is_null() || pwd.pw_dir.is_null() {
+ return None;
+ }
+ // SAFETY: `pw_dir` is non-null and points into `buf`, which is still live.
+ let dir = unsafe { CStr::from_ptr(pwd.pw_dir) };
+ let path = PathBuf::from(std::ffi::OsStr::from_bytes(dir.to_bytes()));
+ path.is_absolute().then_some(path)
+}
+
/// Query the kernel's Landlock ABI via the version probe. Returns the ABI
/// integer, or 0 if Landlock is unavailable (`ENOSYS`/error).
pub fn landlock_abi_version() -> i32 {
@@ -154,6 +193,43 @@ pub fn landlock_abi_version() -> i32 {
}
}
+/// Query the kernel's Landlock **errata** bitmask — which behavioural fixes the
+/// running kernel actually carries.
+///
+/// Not the same question as [`landlock_abi_version`]. The ABI integer describes
+/// the *interface*; the errata bitmask reports which **declared** errata the
+/// running kernel carries. The two can disagree, because vendor kernels are
+/// assembled by backporting — which is why the kernel exposes this at all.
+///
+/// **It is a LOWER BOUND on behavioural difference, not a certificate.** The
+/// mask enumerates only errata the kernel *declares*, and Landlock behaviour
+/// changes have shipped without one: 7.2-rc4 mediates `MSG_FASTOPEN`'s implicit
+/// connect at `security/landlock/net.c:354`, and no erratum covers it — the tree
+/// declares exactly three (1 at ABI 4, 2 at ABI 6, 3 at ABI 1). So a full mask
+/// means "carries the declared fixes", never "behaves as documented".
+///
+/// Returns `None` when the probe is unsupported (a kernel predating the flag
+/// returns `EINVAL`) or Landlock is absent — deliberately indistinguishable,
+/// because both mean "the kernel did not tell us" and neither is a refusal.
+pub fn landlock_errata() -> Option {
+ const LANDLOCK_CREATE_RULESET_ERRATA: libc::c_uint = 1 << 1;
+ // SAFETY: NULL attr + 0 size + ERRATA flag is the documented errata probe;
+ // like the version probe it creates no ruleset and only returns an integer.
+ let rc = unsafe {
+ libc::syscall(
+ libc::SYS_landlock_create_ruleset,
+ ptr::null::(),
+ 0usize,
+ LANDLOCK_CREATE_RULESET_ERRATA,
+ )
+ };
+ if rc < 0 {
+ None
+ } else {
+ Some(rc as u64)
+ }
+}
+
/// Install a raw classic-BPF seccomp filter via `seccomp(SET_MODE_FILTER)`.
/// Requires `NO_NEW_PRIVS` already set. Used for the x32 guard.
pub fn install_raw_seccomp_filter(prog: &[libc::sock_filter]) -> io::Result<()> {
diff --git a/tests/agent_survival.rs b/tests/agent_survival.rs
index e3521af..f7afe25 100644
--- a/tests/agent_survival.rs
+++ b/tests/agent_survival.rs
@@ -99,8 +99,66 @@ fn git_workflow_survives_balanced() {
eprintln!("SKIP: git/sh not found");
return;
}
- let proj = scratch("git");
- std::fs::create_dir_all(&proj).expect("mkdir");
+ // HOME IS HARNESS-OWNED, and that is the point of this shape (T23).
+ //
+ // Until 2026-07-28 this test inherited the operator's real $HOME, so its
+ // inputs were whatever the box happened to contain. It passed for four days
+ // because `~/.config/git/ignore` did not exist here; unrelated tooling
+ // created that file and the test went red with no oring change behind it.
+ // A test whose result turns on undeclared host state cannot distinguish a
+ // regression from a Tuesday.
+ //
+ // Declaring HOME also makes the test EXERCISE the grant rather than merely
+ // coexist with it: `profile::home()` reads $HOME, so the profile's
+ // `~/.config/git/*` grants are computed against this directory, and the
+ // files below are the ones git then opens. Drop those grants and this goes
+ // red — which is the mutation, and it is not available when the files are
+ // absent, because absent is the case every tool already handles.
+ // AND HOME MUST SIT OUTSIDE EVERY TIER `balanced` GRANTS, or the test cannot
+ // fail — if git can read the config through some other grant, dropping the
+ // `~/.config/git` grants changes nothing and the mutation comes back green.
+ //
+ // `env::temp_dir()` is NOT a safe place for it, and the first version of
+ // this test used it. `balanced` grants `/tmp` write_noexec, so a $HOME under
+ // `/tmp` is readable anyway *and* trips oring's own "a built-in profile
+ // grants write over a tier containing $HOME" self-check — **rc=121, an oring
+ // REFUSAL, not git's 128**. That version passed only because this box sets
+ // `TMPDIR=/scratch`; with `TMPDIR` unset, which is the default everywhere
+ // else, it failed for a reason its own assertion message explicitly denied.
+ // The tier check below is the durable form: it is the *property* that makes
+ // the mutation meaningful, so a future profile change that grants this path
+ // fails loudly here instead of silently disarming the test.
+ let real_home = std::env::var_os("HOME")
+ .map(PathBuf::from)
+ .expect("HOME unset");
+ let root = real_home.join(format!(".oring-test-{}-git", std::process::id()));
+ let _ = std::fs::remove_dir_all(&root);
+ let home = root.join("home");
+ let proj = root.join("proj");
+ let b = oring::profile::balanced();
+ for grant in b
+ .landlock
+ .read_only
+ .iter()
+ .chain(b.landlock.read_exec.iter())
+ .chain(b.landlock.write_noexec.iter())
+ .chain(b.landlock.write_exec.iter())
+ {
+ assert!(
+ !home.starts_with(grant),
+ "test precondition lost: the harness HOME {} sits under granted tier {} — \
+ git would read its config through that grant, so dropping the \
+ ~/.config/git grants would not turn this test red",
+ home.display(),
+ grant.display()
+ );
+ }
+ let gitcfg = home.join(".config").join("git");
+ std::fs::create_dir_all(&gitcfg).expect("mkdir home");
+ std::fs::create_dir_all(&proj).expect("mkdir proj");
+ std::fs::write(gitcfg.join("config"), "[core]\n\tpager = cat\n").expect("write config");
+ std::fs::write(gitcfg.join("ignore"), "*.tmp\n").expect("write ignore");
+
// init + stage + commit — the real /dev, /proc, .gitconfig-tolerant path.
let script = format!(
"cd {p} && git init -q && echo hi > f && \
@@ -108,9 +166,29 @@ fn git_workflow_survives_balanced() {
git -c user.email=t@t -c user.name=t commit -q -m x",
p = proj.display()
);
- let code = run_under_balanced(&proj, &["sh", "-c", &script]);
- let _ = std::fs::remove_dir_all(&proj);
- assert_eq!(code, Some(0), "git init+commit must succeed under balanced");
+ let code = Command::new(oring_bin())
+ .current_dir(&proj)
+ .arg("--profile")
+ .arg("balanced")
+ .arg("--workdir")
+ .arg(&proj)
+ .arg("--")
+ .args(["sh", "-c", &script])
+ .env("ORING_QUIET", "1")
+ .env("HOME", &home)
+ .env("XDG_CONFIG_HOME", home.join(".config"))
+ .status()
+ .expect("spawn oring")
+ .code();
+ let _ = std::fs::remove_dir_all(&root);
+ assert_eq!(
+ code,
+ Some(0),
+ "git init+commit must succeed under balanced with a global config present. \
+ rc=128 means git could access() its config and then failed to open it — the \
+ ~/.config/git grants are missing (T23). rc=121 means ORING REFUSED to seal \
+ and git never ran; read its stderr, do not read it as a grant problem."
+ );
}
// NOTE: no JIT-runtime survival test here on purpose. The mprotect(PROT_EXEC)
@@ -119,3 +197,249 @@ fn git_workflow_survives_balanced() {
// mprotect-strict=Deny), so a node/JS-specific end-to-end test would only re-
// prove that for one runtime while dragging a JS dependency into a Rust tool's
// suite. cargo + git cover real multi-process toolchain survival.
+
+/// **CON-3's positive arm: the confined RESULT equals the unconfined result.**
+///
+/// `cargo_build_survives_balanced` and `git_workflow_survives_balanced` above assert that
+/// a confined tool **completes** — that is CON-8. This asserts something strictly
+/// stronger and different: that it produces **the same output**. A tool can exit 0 under
+/// confinement and still return a wrong answer, and that is the `DIVERGE` loss.
+///
+/// **Why `git write-tree` is the observable.** It is content-addressed — the hash is a
+/// function of file names, modes and blob contents and **nothing else**. A commit hash
+/// would be useless here because it folds in the author and two timestamps, so the two
+/// arms would differ for reasons that have nothing to do with confinement. `write-tree`
+/// also needs no `user.email`, which removes a whole class of config divergence.
+///
+/// **What this does NOT establish.** One tool, one operation, one profile. It is a
+/// positive instance of CON-3 over a bounded domain, not the constraint — and the row
+/// stays `unenforced` because the counterexample pinned in
+/// `tests/residual_boundary.rs::access_says_readable_where_open_is_denied` is real and
+/// unreported. Both directions matter: this arm would go green while that one stays red.
+#[test]
+fn the_confined_result_equals_the_unconfined_result() {
+ if !require_or_skip("the_confined_result_equals_the_unconfined_result") {
+ return;
+ }
+ let Some(git) = which("git") else {
+ eprintln!("SKIP: the_confined_result_equals_the_unconfined_result: no git");
+ return;
+ };
+
+ // HOME is harness-owned and OUTSIDE every granted tier, same reasoning as the git
+ // test above: an inherited $HOME would let git read the operator's config through a
+ // grant, so a dropped config grant would not turn this red.
+ let real_home = std::env::var_os("HOME")
+ .map(PathBuf::from)
+ .expect("HOME unset");
+ let root = real_home.join(format!(".oring-test-{}-effect", std::process::id()));
+ let _ = std::fs::remove_dir_all(&root);
+ let home = root.join("home");
+ std::fs::create_dir_all(&home).unwrap();
+
+ // Two dirs with BYTE-IDENTICAL content, built by the same code so the inputs cannot
+ // drift between the arms.
+ let mut dirs = Vec::new();
+ for tag in ["confined", "bare"] {
+ let d = root.join(tag);
+ std::fs::create_dir_all(d.join("sub")).unwrap();
+ std::fs::write(d.join("a.txt"), b"alpha\n").unwrap();
+ std::fs::write(d.join("sub/b.txt"), b"beta\n").unwrap();
+ dirs.push(d);
+ }
+
+ let tree_of = |dir: &Path, confined: bool| -> String {
+ let script = format!(
+ "cd {d} && {git} init -q && {git} add -A && {git} write-tree",
+ d = dir.display(),
+ git = git.display()
+ );
+ let mut cmd = if confined {
+ let mut c = Command::new(oring_bin());
+ c.args(["--profile", "balanced", "--workdir"])
+ .arg(dir)
+ .arg("--")
+ .args(["sh", "-c", &script]);
+ c
+ } else {
+ let mut c = Command::new("sh");
+ c.args(["-c", &script]);
+ c
+ };
+ let out = cmd
+ .current_dir(dir)
+ .env("HOME", &home)
+ .env("ORING_QUIET", "1")
+ .env_remove("GIT_DIR")
+ .output()
+ .expect("spawn");
+ assert!(
+ out.status.success(),
+ "the {} arm must succeed, or there is no result to compare: {}",
+ if confined { "confined" } else { "bare" },
+ String::from_utf8_lossy(&out.stderr)
+ );
+ String::from_utf8_lossy(&out.stdout)
+ .lines()
+ .rfind(|l| !l.trim().is_empty())
+ .unwrap_or_default()
+ .trim()
+ .to_string()
+ };
+
+ let confined = tree_of(&dirs[0], true);
+ let bare = tree_of(&dirs[1], false);
+ assert_eq!(
+ confined.len(),
+ 40,
+ "expected a 40-char tree hash from the confined arm, got {confined:?}"
+ );
+ assert_eq!(
+ confined, bare,
+ "SAME inputs must produce the SAME tree. A difference here is the DIVERGE loss: \
+ the confined tool ran, exited 0, and returned something else."
+ );
+ let _ = std::fs::remove_dir_all(&root);
+}
+
+// ---------------------------------------------------------------------------
+// CON-8 for the 2026-07-29 UDP tightening.
+//
+// The rest of this file builds with `--offline`, deliberately — but that makes it
+// STRUCTURALLY BLIND to the axis that tightening touched. `balanced` went from
+// unrestricted UDP to `egress_udp: [53]`, and an offline build resolves no names,
+// so every cell above would stay green if the DNS grant were wrong or absent.
+// CON-8's own text says "every tightening is a candidate violation of this row";
+// these two cells are that check for this one, in both directions.
+// ---------------------------------------------------------------------------
+
+/// IANA-reserved for documentation, so it is stable and belongs to nobody.
+const DNS_NAME: &str = "example.com";
+
+fn run_under(profile: &str, cmd: &[&str]) -> Option {
+ Command::new(oring_bin())
+ .arg("--profile")
+ .arg(profile)
+ .arg("--")
+ .args(cmd)
+ .env("ORING_QUIET", "1")
+ .status()
+ .expect("spawn oring")
+ .code()
+}
+
+/// Can this HOST resolve at all? Without the bare arm these cells cannot tell
+/// "oring denied it" from "there is no resolver here" — and on this project's
+/// own measurement box the corpus lane runs inside a netns with no route
+/// anywhere, where that is the normal state. A host that cannot resolve must
+/// SKIP rather than report the tightening as witnessed; `ORING_REQUIRE_NET=1`
+/// makes the skip fatal, per the `make check-full` convention.
+fn require_dns_or_skip(test: &str) -> bool {
+ let ok = which("getent").is_some_and(|g| {
+ Command::new(g)
+ .args(["hosts", DNS_NAME])
+ .status()
+ .ok()
+ .and_then(|s| s.code())
+ == Some(0)
+ });
+ if ok {
+ return true;
+ }
+ let msg = format!("{test}: host cannot resolve {DNS_NAME} unconfined (no getent, or no DNS)");
+ assert!(std::env::var_os("ORING_REQUIRE_NET").is_none(), "{msg}");
+ eprintln!("SKIP: {msg}");
+ false
+}
+
+/// `balanced` must still RESOLVE NAMES — the legitimate work the DNS grant exists
+/// for. Fails if `egress_udp` loses :53, or if `ingress_udp` loses port 0 (kernel
+/// autobind of the source port is denied, and the grant sends nothing at all).
+#[test]
+fn name_resolution_survives_under_balanced() {
+ if !require_or_skip("name_resolution_survives_under_balanced")
+ || !require_dns_or_skip("name_resolution_survives_under_balanced")
+ {
+ return;
+ }
+ assert_eq!(
+ run_under("balanced", &["/usr/bin/getent", "hosts", DNS_NAME]),
+ Some(0),
+ "balanced must resolve {DNS_NAME}: it grants egress_udp :53 with ingress_udp 0 \
+ for autobind, and a real toolchain cannot fetch anything without it"
+ );
+}
+
+/// Send one UDP datagram to `127.0.0.1:` using only bash's `/dev/udp`
+/// redirection, which does socket+connect. `None` profile means no seal.
+///
+/// bash rather than oring's own `__probe`: the probe binary lives under
+/// `target/`, which no profile puts in a `read_exec` tier, so oring refuses the
+/// run with 122 before the probe executes — a could-not-check that is easy to
+/// misread as a denial. `/bin/bash` is granted by both profiles.
+fn udp_open(profile: Option<&str>, port: u16) -> Option {
+ let script = format!("exec 3<>/dev/udp/127.0.0.1/{port}");
+ match profile {
+ Some(p) => run_under(p, &["/bin/bash", "-c", &script]),
+ None => Command::new("/bin/bash")
+ .args(["-c", &script])
+ .status()
+ .expect("spawn bash")
+ .code(),
+ }
+}
+
+/// The COST of the same tightening, at the layer that can actually attribute it.
+///
+/// **This cell replaced one asserting `getent` fails under `strict`, which could
+/// not fail.** Measured: with `strict` mutated to grant :53, `getent` still
+/// returned 2 — because `strict` does not grant `/etc/resolv.conf` (read → exit
+/// 1), so name resolution is dead there for a SECOND, unrelated reason. The
+/// assertion passed whatever the UDP grant did, which is the unfalsifiable-check
+/// shape this project treats as the defect rather than the safeguard.
+///
+/// So this asserts the mechanism instead: the datagram itself. It needs no
+/// resolver config and no network, and the bare arm establishes that the port is
+/// reachable when oring is absent — without it, a denial here attributes to
+/// nothing in particular.
+#[test]
+fn the_profiles_udp_posture_holds_at_the_socket_layer() {
+ if !require_or_skip("the_profiles_udp_posture_holds_at_the_socket_layer") {
+ return;
+ }
+ for port in [53u16, 9] {
+ assert_eq!(
+ udp_open(None, port),
+ Some(0),
+ "control: :{port} must be reachable UNSEALED, or the cells below \
+ attribute a denial to oring that something else produced"
+ );
+ }
+
+ // balanced: DNS out, nothing else.
+ assert_eq!(
+ udp_open(Some("balanced"), 53),
+ Some(0),
+ "balanced grants egress_udp :53 (with ingress_udp 0 for autobind)"
+ );
+ assert_ne!(
+ udp_open(Some("balanced"), 9),
+ Some(0),
+ "balanced must deny every UDP port it does not name — the grant is per-port"
+ );
+
+ // strict: no datagrams at all, :53 included.
+ for port in [53u16, 9] {
+ let rc = udp_open(Some("strict"), port);
+ assert_ne!(
+ rc,
+ Some(0),
+ "strict sets egress_udp [] — no UDP leaves it, :{port} included"
+ );
+ assert!(
+ !matches!(rc, Some(120..=122)),
+ "must be the CHILD denied, not oring refusing to seal \
+ (120 confinement / 121 policy / 122 target); :{port} gave {rc:?}"
+ );
+ }
+}
diff --git a/tests/citation_handles.rs b/tests/citation_handles.rs
new file mode 100644
index 0000000..8cafd07
--- /dev/null
+++ b/tests/citation_handles.rs
@@ -0,0 +1,404 @@
+// SPDX-License-Identifier: BSD-3-Clause
+//! `docs/DESIGN.md` counts its own unresolvable citations. This checks the count.
+//!
+//! oring cites two things a reader cannot open: `BUILD-QUEUE.md T` work-item
+//! handles and `deep_research/…` notes. Both are disclosed in the `DESIGN.md`
+//! preamble, which **states how many there are** — the right shape, because a
+//! reader who greps and finds more than the document admits to has been misled
+//! about the scale of the wart. Both counts were wrong on 2026-08-01: "nine"
+//! was the `docs/` count presented as the tree count (actual 21), and "three
+//! comments in `src/`" was actually zero. Neither could have been caught by
+//! anything in the tree — counted claims about the repository, checked by nobody.
+//!
+//! **Establishes:** every count in the preamble equals the count obtained by
+//! scanning the tree with the rules below, and the stated total is the sum of its
+//! parts. Drift in either direction fails.
+//!
+//! **Does NOT establish** that a citation is appropriate, that the gap it names
+//! is real, or that the surrounding sentence is accurate. It knows arithmetic. It
+//! also cannot see a *new* unresolvable-citation convention cited by some token
+//! neither rule matches.
+//!
+//! # Counting rules — a count is meaningless without its frame
+//!
+//! * **T-handle:** `BUILD-QUEUE`, optional `.md`, optional backtick/spaces, then
+//! `T` and **at least one digit**. The digit requirement excludes the
+//! disclosure's own meta-mention `BUILD-QUEUE.md T`, so the sentence does
+//! not count itself.
+//! * **Corpus note:** the literal `deep_research/notes/` — likewise excluding the
+//! disclosure's own `deep_research/…`, which has no `notes/` segment.
+//! * **Corpus comment (`tests/` only):** the prose spellings, because the
+//! `src/`→`tests/` move went unnoticed precisely when the old count looked for
+//! a path and the comments had stopped containing one.
+//!
+//! The counts are digits in the prose so this can parse them — a real cost paid
+//! to the gate, recorded at the sentence rather than left looking like a lapse.
+
+use std::path::{Path, PathBuf};
+
+fn root() -> PathBuf {
+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+}
+
+fn read(rel: &str) -> String {
+ let p = root().join(rel);
+ std::fs::read_to_string(&p).unwrap_or_else(|e| {
+ panic!(
+ "COULD NOT CHECK: {} is unreadable ({e}). A missing input must not read as a \
+ clean count.",
+ p.display()
+ )
+ })
+}
+
+/// This file, excluded from every scan below.
+///
+/// **A counting gate must not count its own fixtures.** The red tests here
+/// contain real handle spellings (`` `BUILD-QUEUE.md` T21 ``) and the prose
+/// above names the maintainer's corpus repeatedly, including a literal list of
+/// the three spellings the rule matches. **Measured on this file, not estimated:
+/// 5 phantom T-handles and 11 phantom corpus mentions** — the first run failed
+/// on numbers `DESIGN.md` had no way to be right about. (This comment said "4
+/// and 3" before either was counted, which is the same unmeasured-number defect
+/// the gate exists to catch, committed inside the gate itself.) The exclusion is
+/// the fix; `the_self_exclusion_still_matches_this_file` keeps a rename from
+/// quietly re-polluting the count.
+const SELF_FILE: &str = "citation_handles.rs";
+
+/// Every `.rs` / `.md` file under `rel`, concatenated, except [`SELF_FILE`].
+/// Recursive; `target/` and `third_party/` are never under the directories this
+/// gate scans.
+fn read_tree(rel: &str) -> String {
+ fn walk(dir: &Path, out: &mut String) {
+ let mut entries: Vec<_> = std::fs::read_dir(dir)
+ .unwrap_or_else(|e| panic!("COULD NOT CHECK: {} unreadable ({e})", dir.display()))
+ .filter_map(Result::ok)
+ .map(|e| e.path())
+ .collect();
+ // Sorted so a failure message is reproducible run to run.
+ entries.sort();
+ for p in entries {
+ if p.is_dir() {
+ walk(&p, out);
+ } else if p.file_name().and_then(|f| f.to_str()) == Some(SELF_FILE) {
+ // See SELF_FILE: this file's own fixtures are citations of the
+ // shapes it counts, and counting them makes the document wrong.
+ } else if matches!(p.extension().and_then(|e| e.to_str()), Some("rs" | "md")) {
+ if let Ok(s) = std::fs::read_to_string(&p) {
+ out.push_str(&s);
+ out.push('\n');
+ }
+ }
+ }
+ }
+ let mut out = String::new();
+ walk(&root().join(rel), &mut out);
+ out
+}
+
+// ---------------------------------------------------------------------------
+// Counting rules
+// ---------------------------------------------------------------------------
+
+/// `BUILD-QUEUE` + optional `.md` + optional backtick/spaces + `T` + ≥1 digit.
+///
+/// The digit requirement is load-bearing: the preamble's own meta-mention is
+/// `BUILD-QUEUE.md T`, and `<` is not a digit, so the sentence announcing the
+/// count is not itself counted.
+fn count_t_handles(hay: &str) -> usize {
+ let mut n = 0;
+ let bytes = hay.as_bytes();
+ let mut i = 0;
+ while let Some(off) = hay.get(i..).and_then(|s| s.find("BUILD-QUEUE")) {
+ let mut j = i + off + "BUILD-QUEUE".len();
+ if hay.get(j..).is_some_and(|s| s.starts_with(".md")) {
+ j += 3;
+ }
+ while bytes.get(j).is_some_and(|&b| b == b'`' || b == b' ') {
+ j += 1;
+ }
+ if bytes.get(j) == Some(&b'T') && bytes.get(j + 1).is_some_and(u8::is_ascii_digit) {
+ n += 1;
+ }
+ i = i + off + "BUILD-QUEUE".len();
+ }
+ n
+}
+
+fn count_substr(hay: &str, needle: &str) -> usize {
+ hay.matches(needle).count()
+}
+
+const CORPUS_PROSE: [&str; 3] = [
+ "maintainer's corpus",
+ "maintainer's research corpus",
+ "private research corpus",
+];
+
+/// The three prose spellings, counted without double-counting: "maintainer's
+/// research corpus" contains no other member as a substring, so a plain sum is
+/// correct here — asserted rather than assumed, because a future fourth
+/// spelling that nests inside another would silently inflate this.
+fn count_corpus_prose(hay: &str) -> usize {
+ for (a, b) in [(0usize, 1usize), (1, 0), (0, 2), (2, 0), (1, 2), (2, 1)] {
+ assert!(
+ !CORPUS_PROSE[a].contains(CORPUS_PROSE[b]),
+ "COULD NOT CHECK: corpus spellings nest ({:?} contains {:?}), so summing them \
+ double-counts. Rewrite the rule before trusting the number.",
+ CORPUS_PROSE[a],
+ CORPUS_PROSE[b]
+ );
+ }
+ CORPUS_PROSE.iter().map(|s| count_substr(hay, s)).sum()
+}
+
+// ---------------------------------------------------------------------------
+// Reading the numbers the document claims
+// ---------------------------------------------------------------------------
+
+/// The digits immediately preceding `needle` in `hay`, e.g. `7` from
+/// ``7 in `src/` ``. Returns `None` if `needle` is absent — the caller must
+/// treat that as could-not-check, never as agreement.
+fn number_before(hay: &str, needle: &str) -> Option {
+ let at = hay.find(needle)?;
+ let head = hay.get(..at)?.trim_end();
+ let digits: String = head
+ .chars()
+ .rev()
+ .take_while(char::is_ascii_digit)
+ .collect::>()
+ .into_iter()
+ .rev()
+ .collect();
+ digits.parse().ok()
+}
+
+/// The paragraph carrying the T-handle counts.
+///
+/// **Whole LINE, not a slice from the anchor.** Slicing forward from
+/// `"such citations exist"` drops the `21 ` that precedes it — the total would
+/// then read as could-not-check on a correct document. `DESIGN.md` is one
+/// paragraph per line, and the two disclosures are on different lines, so the
+/// line is the sentence and the two identical ``N in `src/` `` shapes cannot be
+/// confused with each other.
+fn t_handle_claim(design: &str) -> String {
+ claim_line(design, "such citations exist")
+}
+
+fn corpus_claim(design: &str) -> String {
+ claim_line(design, "The same applies to any")
+}
+
+fn claim_line(design: &str, anchor: &str) -> String {
+ design
+ .lines()
+ .find(|l| l.contains(anchor))
+ .unwrap_or_else(|| {
+ panic!(
+ "COULD NOT CHECK: docs/DESIGN.md has no line containing {anchor:?}. The \
+ disclosure this gate checks is gone or reworded — an absent claim must \
+ not read as an agreeing one."
+ )
+ })
+ .to_string()
+}
+
+// ---------------------------------------------------------------------------
+// The checks
+// ---------------------------------------------------------------------------
+
+fn expect(claim: &str, needle: &str, actual: usize, what: &str) {
+ let stated = number_before(claim, needle).unwrap_or_else(|| {
+ panic!(
+ "COULD NOT CHECK: no number precedes {needle:?} in the DESIGN.md claim. The \
+ sentence must state {what} as a digit for this gate to read it."
+ )
+ });
+ assert_eq!(
+ stated as usize, actual,
+ "docs/DESIGN.md says {stated} for {what}, the tree has {actual}. One of them moved \
+ and the other did not: update the sentence, or the citations. This exact drift \
+ (nine claimed, 21 actual) is why this gate exists."
+ );
+}
+
+#[test]
+fn design_states_the_true_number_of_build_queue_handles() {
+ let design = read("docs/DESIGN.md");
+ let claim = t_handle_claim(&design);
+
+ // `docs/` as a TREE, not DESIGN.md and VERIFICATION.md by name.
+ //
+ // The named-file version was correct and became wrong the moment `docs/` gained a
+ // file. On 2026-08-01 §2 moved to `RESIDUALS.md`, taking 6 of DESIGN.md's 8
+ // T-handles with it; a gate counting two files by name would have reported 3 where
+ // the tree had 9, and then demanded the document be "corrected" to match its own
+ // shrunken frame. A counting gate whose span is a hardcoded list of files loses
+ // coverage every time the project grows one.
+ let in_docs = count_t_handles(&read_tree("docs"));
+ let in_src = count_t_handles(&read_tree("src"));
+ let in_tests = count_t_handles(&read_tree("tests"));
+
+ expect(&claim, "in `docs/`", in_docs, "T-handles in docs/");
+ expect(&claim, "in `src/`", in_src, "T-handles in src/");
+ expect(&claim, "in `tests/`", in_tests, "T-handles in tests/");
+
+ // The total is checked against the SUM, so a document cannot state three correct
+ // parts and an incoherent whole.
+ let total = in_docs + in_src + in_tests;
+ expect(&claim, "such citations exist", total, "the T-handle total");
+}
+
+#[test]
+fn design_states_the_true_number_of_corpus_citations() {
+ let design = read("docs/DESIGN.md");
+ let claim = corpus_claim(&design);
+
+ let docs_notes = count_substr(&read_tree("docs"), "deep_research/notes/");
+ let src_notes = count_substr(&read_tree("src"), "deep_research/notes/");
+ let tests_comments = count_corpus_prose(&read_tree("tests"));
+
+ expect(
+ &claim,
+ "citations in `docs/`",
+ docs_notes,
+ "corpus notes in docs/",
+ );
+ expect(&claim, "in `src/`", src_notes, "corpus notes in src/");
+ // `in `tests/`` rather than `comments in `tests/``: the claim is sliced to a
+ // single line, so the shorter needle is still unambiguous — and it survives
+ // the count going singular, which broke the longer one.
+ expect(
+ &claim,
+ "in `tests/`",
+ tests_comments,
+ "corpus comments in tests/",
+ );
+}
+
+/// The `src/` zero is asserted directly as well as through the claim, because it
+/// is the number that rotted: the document said three, the tree had none, and a
+/// gate that only compares two figures agrees with itself the moment someone
+/// "fixes" the doc to match a wrong scan.
+#[test]
+fn no_deep_research_path_is_cited_from_src() {
+ let src = read_tree("src");
+ assert_eq!(
+ count_substr(&src, "deep_research"),
+ 0,
+ "src/ cites the unpublished corpus by path. Either inline what the reader needs, \
+ or move the comment and update the DESIGN.md preamble — which claimed three such \
+ comments in src/ until 2026-08-01, when there were none."
+ );
+}
+
+/// A rename must not silently re-admit this file's fixtures to the scan. If
+/// `SELF_FILE` stops naming a real file, the exclusion has become a no-op and
+/// every count above is inflated by this file's own examples.
+#[test]
+fn the_self_exclusion_still_matches_this_file() {
+ let p = root().join("tests").join(SELF_FILE);
+ assert!(
+ p.exists(),
+ "SELF_FILE = {SELF_FILE:?} names no file under tests/. This gate was renamed and \
+ the constant was not, so it now excludes nothing and counts its own red-test \
+ fixtures as real citations."
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Red tests — every arm above, seen failing
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod red {
+ use super::*;
+
+ const CLAIM: &str = "21 such citations exist: 8 in `DESIGN.md`, 1 in `VERIFICATION.md`, \
+ 7 in `src/`, 5 in `tests/`";
+
+ #[test]
+ fn a_number_that_is_too_high_is_caught() {
+ let r = std::panic::catch_unwind(|| expect(CLAIM, "in `src/`", 6, "T-handles in src/"));
+ assert!(r.is_err(), "claim says 7, tree has 6 — must fail");
+ }
+
+ #[test]
+ fn a_number_that_is_too_low_is_caught() {
+ let r = std::panic::catch_unwind(|| expect(CLAIM, "in `src/`", 8, "T-handles in src/"));
+ assert!(r.is_err(), "claim says 7, tree has 8 — must fail");
+ }
+
+ #[test]
+ fn the_matching_number_passes() {
+ expect(CLAIM, "in `src/`", 7, "T-handles in src/");
+ }
+
+ #[test]
+ fn an_absent_claim_is_could_not_check_not_pass() {
+ let r = std::panic::catch_unwind(|| {
+ expect(
+ "no numbers here at all",
+ "in `src/`",
+ 7,
+ "T-handles in src/",
+ )
+ });
+ assert!(
+ r.is_err(),
+ "a missing sentence must fail loudly; a claim nobody can find is not a claim \
+ everybody agrees with"
+ );
+ }
+
+ #[test]
+ fn a_missing_preamble_sentence_is_could_not_check() {
+ let r = std::panic::catch_unwind(|| t_handle_claim("a DESIGN.md with no disclosure"));
+ assert!(r.is_err(), "absent disclosure must be could-not-check");
+ }
+
+ #[test]
+ fn the_meta_mention_does_not_count_itself() {
+ assert_eq!(
+ count_t_handles("`BUILD-QUEUE.md T` is a work-item handle, not a link."),
+ 0,
+ "the sentence announcing the count must not be counted by it"
+ );
+ }
+
+ #[test]
+ fn real_handle_spellings_all_count() {
+ assert_eq!(count_t_handles("`BUILD-QUEUE.md` T21"), 1);
+ assert_eq!(count_t_handles("(BUILD-QUEUE T24)"), 1);
+ assert_eq!(
+ count_t_handles("BUILD-QUEUE.md T7 and `BUILD-QUEUE.md` T1"),
+ 2
+ );
+ }
+
+ #[test]
+ fn the_disclosure_ellipsis_is_not_a_corpus_note() {
+ assert_eq!(
+ count_substr("any `deep_research/…` path", "deep_research/notes/"),
+ 0,
+ "the disclosure's own token has no notes/ segment and must not be counted"
+ );
+ assert_eq!(
+ count_substr(
+ "see deep_research/notes/2026-07-28-t21.md",
+ "deep_research/notes/"
+ ),
+ 1
+ );
+ }
+
+ #[test]
+ fn corpus_prose_spellings_do_not_double_count() {
+ assert_eq!(count_corpus_prose("the maintainer's research corpus"), 1);
+ assert_eq!(count_corpus_prose("a private research corpus"), 1);
+ assert_eq!(
+ count_corpus_prose("maintainer's corpus and private research corpus"),
+ 2
+ );
+ }
+}
diff --git a/tests/common/mod.rs b/tests/common/mod.rs
index 30cc49d..b866380 100644
--- a/tests/common/mod.rs
+++ b/tests/common/mod.rs
@@ -59,8 +59,9 @@ fn crate_floor() -> i32 {
}
/// Returns true if the kernel can run the confinement tests: Landlock present at
-/// oring's floor (V7 / kernel 6.15). Below that oring itself refuses (exit 120),
-/// so the tests must SKIP, not fail — GitHub runners are far below it.
+/// oring's floor (V10 / kernel 7.2). Below that oring itself refuses (exit 120),
+/// so the tests must SKIP, not fail — GitHub runners are far below it, and since
+/// the V10 floor so is every pre-7.2 developer box.
/// ORING_REQUIRE_KERNEL=1 turns a would-be skip into a hard failure (no silent
/// green on a box that SHOULD meet the floor, §11d).
pub fn require_or_skip(test: &str) -> bool {
@@ -70,14 +71,16 @@ pub fn require_or_skip(test: &str) -> bool {
}
if std::env::var_os("ORING_REQUIRE_KERNEL").is_some() {
panic!(
- "{test}: needs Landlock V{} / kernel 6.15 (present={}, abi=V{abi})",
+ "{test}: needs Landlock V{} / kernel {}+ (present={}, abi=V{abi})",
crate_floor(),
+ oring::landlock::FLOOR_KERNEL,
landlock_present()
);
}
eprintln!(
- "SKIP: {test}: needs Landlock V{} / kernel 6.15 (present={}, abi=V{abi})",
+ "SKIP: {test}: needs Landlock V{} / kernel {}+ (present={}, abi=V{abi})",
crate_floor(),
+ oring::landlock::FLOOR_KERNEL,
landlock_present()
);
false
@@ -99,6 +102,27 @@ pub fn policy_json(write_exec: &[&str], egress_tcp: Option<&[u16]>) -> String {
/// read_exec always covers the oring bindir + broad system dirs + /proc so the
/// (debug, dynamically-linked) child oring can exec + load libs + start.
pub fn policy_json_sec(write_exec: &[&str], egress_tcp: Option<&[u16]>, seccomp: &str) -> String {
+ policy_json_ro(write_exec, &[], egress_tcp, seccomp)
+}
+
+/// As [`policy_json_sec`], plus extra paths granted `read_only`.
+///
+/// Exists for ONE reason and it is worth stating: a cell that asserts "a
+/// `read_only` tier denies a write" is only evidence if DAC would have permitted
+/// that write. Pointing such a cell at `/etc` makes it EACCES bare from DAC and
+/// EACCES sealed — indistinguishable, and green even with ring 1's FS layer
+/// mutated off. Granting a harness-owned directory `read_only` instead keeps the
+/// claim (a read_only tier denies write) while making the denial attributable.
+///
+/// The directory must sit outside every other granted tier. Landlock unions
+/// rights along the path walk, so a subdirectory of the workdir or of `/tmp`
+/// would inherit their write grants and this would silently test nothing.
+pub fn policy_json_ro(
+ write_exec: &[&str],
+ read_only_extra: &[&str],
+ egress_tcp: Option<&[u16]>,
+ seccomp: &str,
+) -> String {
let mut read_exec = vec![
bindir(),
"/usr".into(),
@@ -110,6 +134,9 @@ pub fn policy_json_sec(write_exec: &[&str], egress_tcp: Option<&[u16]>, seccomp:
read_exec.dedup();
let re = json_arr(&read_exec.iter().map(String::as_str).collect::>());
let wx = json_arr(write_exec);
+ let mut ro: Vec<&str> = vec!["/etc", "/usr/share", "/proc"];
+ ro.extend_from_slice(read_only_extra);
+ let ro = json_arr(&ro);
let egress = match egress_tcp {
Some(ports) => {
let p = ports
@@ -124,7 +151,7 @@ pub fn policy_json_sec(write_exec: &[&str], egress_tcp: Option<&[u16]>, seccomp:
format!(
r#"{{"version":1,"landlock":{{
"read_exec":{re},
- "read_only":["/etc","/usr/share","/proc"],
+ "read_only":{ro},
"write_noexec":["/tmp","/dev/null","/dev/zero","/dev/urandom"],
"write_exec":{wx},
"egress_tcp":{egress},
@@ -148,6 +175,26 @@ pub fn run_probe(policy: &str, op: &str, arg: &str) -> (Option, Option
run_probe_proc(policy, op, arg, None)
}
+/// Run the probe payload with NO seal — the bare control.
+///
+/// A sealed cell that asserts a denial only attributes that denial to oring if the
+/// same operation is permitted unsealed. Where the environment might supply the
+/// same denial (an inherited seccomp filter, a host MAC, DAC), the bare arm is the
+/// only thing separating "oring did this" from "something did this". Cells that
+/// need it should assert on it rather than assume it.
+///
+/// Returns `(exit_code, signal)` exactly as [`run_probe`] does.
+pub fn run_probe_unsealed(op: &str, arg: &str) -> (Option, Option) {
+ use std::os::unix::process::ExitStatusExt;
+ let mut cmd = Command::new(oring_bin());
+ cmd.arg("__probe").arg(op);
+ if !arg.is_empty() {
+ cmd.arg(arg);
+ }
+ let out = cmd.output().expect("spawn bare probe");
+ (out.status.code(), out.status.signal())
+}
+
/// Like `run_probe` but with an optional `--proc ` axis.
pub fn run_probe_proc(
policy: &str,
@@ -258,8 +305,21 @@ fn raw_write(path: &[u8], data: &[u8]) -> std::io::Result<()> {
Ok(())
}
-/// A live sibling process holding a decoy secret in its environ — the target
-/// for the peer-procfs residual cell. Killed on drop. Confirmed alive by pid.
+/// A live OUT-OF-DOMAIN process holding a decoy secret in its environ. Killed on drop,
+/// confirmed alive by pid.
+///
+/// **Corrected 2026-07-29 — both halves of the old description were wrong, and neither was
+/// dated, so both read as live.** It said *"a live **sibling** process"* and *"the target
+/// for the peer-procfs **residual** cell"*.
+///
+/// It is spawned **before the seal**, so it is **out-of-domain** — Landlock scopes
+/// ancestor-or-equal and this process is neither. `tests/containment_matrix.rs` said so 120
+/// lines above the fixture while this comment said otherwise. And the cell it feeds has
+/// been `Deny(EACCES)` since 2026-07-26, so it witnesses a **wall**, not a residual.
+///
+/// The genuinely in-domain case — two siblings under ONE seal — is
+/// `proc-indomain-env-{full,self}`, added 2026-07-29, and it needs no fixture here because
+/// the probe spawns both processes itself.
pub struct Sentinel {
child: std::process::Child,
pub pid: u32,
@@ -365,3 +425,37 @@ pub fn plant_sig_ign(cmd: &mut Command) {
});
}
}
+
+/// Place an already-open, readable descriptor at `target_fd` in the spawned
+/// child, so a test can observe whether the FD scrub removed it. (T10.)
+///
+/// `dup2(2)` is used rather than passing the descriptor through, and that choice
+/// is load-bearing: `dup2` CLEARS `FD_CLOEXEC` on the new descriptor, so the fd
+/// survives the `execve` into oring and is present at seal time. That is what
+/// makes it the same shape as the leak this scrub exists for
+/// (CVE-2024-21626) — a descriptor the caller left open, not one oring made.
+///
+/// The parent's copy is deliberately leaked with `into_raw_fd`: it must still be
+/// open when `fork` happens, and the `Command` is consumed shortly after.
+pub fn plant_open_fd(cmd: &mut Command, target_fd: i32, path: &std::path::Path) {
+ use std::os::unix::io::IntoRawFd;
+ use std::os::unix::process::CommandExt;
+ let raw = std::fs::File::open(path)
+ .unwrap_or_else(|e| {
+ panic!(
+ "COULD NOT CHECK: cannot open {} to plant ({e})",
+ path.display()
+ )
+ })
+ .into_raw_fd();
+ // SAFETY: pre_exec runs in the post-fork child; `dup2(2)` is
+ // async-signal-safe and nothing here allocates.
+ unsafe {
+ cmd.pre_exec(move || {
+ if libc::dup2(raw, target_fd) < 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ Ok(())
+ });
+ }
+}
diff --git a/tests/constraint_register.rs b/tests/constraint_register.rs
new file mode 100644
index 0000000..fa9add6
--- /dev/null
+++ b/tests/constraint_register.rs
@@ -0,0 +1,673 @@
+// SPDX-License-Identifier: BSD-3-Clause
+//! Gate `CONSTRAINTS.md` — the register's status column, checked mechanically.
+//!
+//! Four register-shaped artifacts in this project drifted for one reason:
+//! **nothing could check the status column.** A `confidence` field sat 94.7% in
+//! its top two grades; an attack catalogue's `[unverified]` markers were unowned;
+//! a queue's ranks were recomputed and never consumed; the §2 residual list went
+//! stale twice in two days. A constraint's status is different because it is
+//! *checkable* — does the row name an artifact, and does that artifact exist?
+//!
+//! **It is Rust, in this tree, deliberately.** It was python in a private corpus
+//! reading this repository across a tree boundary, so four gates verified oring
+//! and none of them ran here. As an integration test it needs no workflow change,
+//! no kernel, no privileges and no features. No `regex` dependency either — a
+//! dev-dependency is a supply-chain decision in a tree that runs `cargo deny`.
+//!
+//! **Does NOT establish** that a row is *true*. It cannot know whether a cited
+//! test fails when the mechanism is removed, which is exactly what
+//! `status: enforced` claims; only a mutation run supports that, and adversarial
+//! review owns it. This checks presence, the closed status set, that named
+//! artifacts resolve, that verification is dated, and that the derivation closes
+//! in **both** directions.
+//!
+//! **"Nothing wrong" and "nothing examined" are kept apart.** The python original
+//! separated exit 1 (a row violates the schema) from exit 2 (the register could
+//! not be checked). A Rust test has only pass and panic, so the distinction lives
+//! in the test *names*: `the_register_is_present_and_parses` is the old exit 2.
+//!
+//! **Failure paths:** status outside the closed set; a required field removed; an
+//! `enforced-by` naming a path that does not exist; `last-verified` undated; a
+//! statement rephrased as a prohibition; an orphaned loss; a `derives-from`
+//! naming nothing; the stated fraction disagreeing with the status fields;
+//! register absent; no rows parsed. All confirmed red.
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::path::{Path, PathBuf};
+
+/// The closed set. A status outside it is a violation, not a new category.
+const STATUSES: [&str; 4] = [
+ "enforced",
+ "enforced-unpinned",
+ "unenforced",
+ "unenforced-by-decision",
+];
+
+const REQUIRED: [&str; 7] = [
+ "derives-from",
+ "controller",
+ "enforced-by",
+ "observable-via",
+ "status",
+ "last-verified",
+ "would-change",
+];
+
+/// The three derivation levels. All must be present; a level the parser cannot find
+/// is a could-not-check, never a clean result.
+const LEVELS: [&str; 3] = ["Losses", "Hazards", "Feedback defects"];
+
+/// A field may decline to name an artifact, but it must decline EXPLICITLY. This is the
+/// defect that let `[unverified]` markers sit for days meaning nothing in particular.
+const GAP_PREFIXES: [&str; 2] = ["*(gap", "*(none"];
+
+fn register_path() -> PathBuf {
+ PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("CONSTRAINTS.md")
+}
+
+struct Row {
+ id: String,
+ statement: String,
+ fields: BTreeMap,
+}
+
+/// `- **key:** value` — the key must be lowercase-and-hyphens, mirroring the original
+/// `^- \*\*([a-z-]+):\*\* (.+?)$`, so a bolded prose bullet is not mistaken for a field.
+fn parse_field(line: &str) -> Option<(String, String)> {
+ let rest = line.strip_prefix("- **")?;
+ let (key, value) = rest.split_once(":** ")?;
+ if key.is_empty() || !key.chars().all(|c| c.is_ascii_lowercase() || c == '-') {
+ return None;
+ }
+ Some((key.to_string(), value.trim().to_string()))
+}
+
+fn parse_rows(text: &str) -> Vec {
+ let mut rows: Vec = Vec::new();
+ for line in text.lines() {
+ if let Some(rest) = line.strip_prefix("## CON-") {
+ if let Some((num, statement)) = rest.split_once(" — ") {
+ if num.chars().all(|c| c.is_ascii_digit()) && !num.is_empty() {
+ rows.push(Row {
+ id: format!("CON-{num}"),
+ statement: statement.trim().to_string(),
+ fields: BTreeMap::new(),
+ });
+ continue;
+ }
+ }
+ }
+ // A new `## ` section that is not a row ends the current row's body, so later
+ // prose cannot contribute fields to the last constraint.
+ if line.starts_with("## ") {
+ if let Some(last) = rows.last_mut() {
+ last.fields.insert("__closed__".to_string(), String::new());
+ }
+ continue;
+ }
+ if let Some(last) = rows.last_mut() {
+ if last.fields.contains_key("__closed__") {
+ continue;
+ }
+ if let Some((k, v)) = parse_field(line) {
+ last.fields.insert(k, v);
+ }
+ }
+ }
+ for r in &mut rows {
+ r.fields.remove("__closed__");
+ }
+ rows
+}
+
+/// Derivation entries, as `id -> level`. Each entry is `` - `ID` `` optionally followed
+/// by an em-dash gloss; the gloss is deliberately not required and not parsed. The
+/// original anchored on end-of-line here and silently found ZERO entries the day a
+/// gloss was added — and `0 entries, 0 orphans` reads exactly like clean.
+fn parse_derivation(text: &str) -> Result, String> {
+ let lines: Vec<&str> = text.lines().collect();
+ let mut out = BTreeMap::new();
+ for level in LEVELS {
+ let head = format!("### {level}");
+ // Prefix match, not equality: a heading may carry a trailing annotation such as
+ // "(multipliers, not ranks)". An over-strict anchor turns a prose edit into a
+ // false alarm, which is how a gate stops being read.
+ let Some(start) = lines.iter().position(|l| l.starts_with(&head)) else {
+ return Err(format!(
+ "derivation section '{level}' is absent — the gate cannot check coverage \
+ against a list it cannot find"
+ ));
+ };
+ let mut found = 0usize;
+ for line in &lines[start + 1..] {
+ if line.starts_with("### ") || line.starts_with("---") {
+ break;
+ }
+ if let Some(rest) = line.strip_prefix("- `") {
+ if let Some((id, _)) = rest.split_once('`') {
+ out.insert(id.to_string(), level.to_string());
+ found += 1;
+ }
+ }
+ }
+ if found == 0 {
+ return Err(format!(
+ "derivation section '{level}' parsed ZERO entries — an empty list would \
+ make every loss name resolve vacuously"
+ ));
+ }
+ }
+ Ok(out)
+}
+
+/// Tokens between backticks, odd-indexed on a split.
+fn backticked(s: &str) -> Vec<&str> {
+ s.split('`').skip(1).step_by(2).collect()
+}
+
+fn has_iso_date(s: &str) -> bool {
+ let b = s.as_bytes();
+ b.windows(10).any(|w| {
+ w[..4].iter().all(u8::is_ascii_digit)
+ && w[4] == b'-'
+ && w[5..7].iter().all(u8::is_ascii_digit)
+ && w[7] == b'-'
+ && w[8..10].iter().all(u8::is_ascii_digit)
+ })
+}
+
+/// Leveson's distinction, made greppable. "The child must not escape" is not an
+/// enforceable constraint; "every capability is named by the policy" is.
+///
+/// **The carve-out is gone, and that is the interesting part.** This function used to
+/// special-case the phrase `does not deny` for exactly one row, CON-4 — a
+/// loss-avoidance goal that could not be phrased positively because it had no enforcer
+/// to name. The comment here said that if CON-4's scope was ever settled the carve-out
+/// should go with it. CON-4 was deleted on 2026-07-29 as out of scope, so it did.
+///
+/// The check is now unconditional over all 8 statements, which makes it **strictly
+/// stronger than the python original it was ported from**: that one still carries the
+/// carve-out. A single-row exception in a schema check is a place a second row can hide
+/// later, and it was only removable because the row was removable.
+fn is_prohibition(statement: &str) -> bool {
+ let padded = format!(" {} ", statement.to_lowercase());
+ [" must not ", " does not ", " never "]
+ .iter()
+ .any(|p| padded.contains(p))
+}
+
+fn stated_fraction(text: &str) -> Option<(usize, usize)> {
+ let line = text
+ .lines()
+ .find(|l| l.contains("Fraction enforced:") && l.contains(" of "))?;
+ let after = line.split("Fraction enforced:").nth(1)?;
+ let nums: Vec = after
+ .split(|c: char| !c.is_ascii_digit())
+ .filter(|s| !s.is_empty())
+ .filter_map(|s| s.parse().ok())
+ .collect();
+ match nums.as_slice() {
+ [n, d, ..] => Some((*n, *d)),
+ _ => None,
+ }
+}
+
+fn load() -> String {
+ let p = register_path();
+ std::fs::read_to_string(&p).unwrap_or_else(|e| {
+ panic!(
+ "COULD NOT CHECK: {} is unreadable ({e}). A register that is missing must \
+ not read as a register that is clean.",
+ p.display()
+ )
+ })
+}
+
+/// The old exit 2. Kept as its own test so a reader learns from the FAILING TEST NAME
+/// whether the register was found wanting or simply not examined.
+#[test]
+fn the_register_is_present_and_parses() {
+ let text = load();
+ let rows = parse_rows(&text);
+ assert!(
+ !rows.is_empty(),
+ "COULD NOT CHECK: no `## CON- — …` rows parsed out of CONSTRAINTS.md. The \
+ parser found nothing, which is not the same as finding nothing wrong."
+ );
+ let deriv = parse_derivation(&text).unwrap_or_else(|e| panic!("COULD NOT CHECK: {e}"));
+ println!(" rows parsed: {}", rows.len());
+ println!(" derivation entries: {}", deriv.len());
+}
+
+/// The schema check, as a FUNCTION over the register's TEXT.
+///
+/// Taking text rather than a path is what lets this be red-tested **in-process**: a
+/// mutation test passes a modified `String` and asserts the specific violation comes
+/// back. No temp files, no subprocess, and — the part that matters — **no mutation of a
+/// tracked document**. The previous red-test lived outside this tree in `sh` and worked
+/// by editing `CONSTRAINTS.md` in place and re-running `cargo test`; three of its anchors
+/// silently went stale within hours because they were string literals in another repo.
+/// Here the gate and its red-tests are the same file and cannot drift apart.
+///
+/// `root` stays a parameter for artifact resolution only, so a mutated-text test still
+/// resolves `tests/*.rs` against the real tree.
+fn conformance_violations(text: &str, root: &Path) -> (Vec, usize, usize) {
+ let rows = parse_rows(text);
+ let deriv = parse_derivation(text).unwrap_or_else(|e| panic!("COULD NOT CHECK: {e}"));
+
+ let mut violations: Vec = Vec::new();
+ let mut checked_fields = 0usize;
+ let mut checked_paths = 0usize;
+
+ for row in &rows {
+ for key in REQUIRED {
+ if row.fields.contains_key(key) {
+ checked_fields += 1;
+ } else {
+ violations.push(format!("{}: missing required field `{key}`", row.id));
+ }
+ }
+
+ if is_prohibition(&row.statement) {
+ violations.push(format!(
+ "{}: statement is phrased as a prohibition, not an enforceable \
+ constraint: {:?}",
+ row.id, row.statement
+ ));
+ }
+
+ let df = row
+ .fields
+ .get("derives-from")
+ .map(|v| {
+ v.split('(')
+ .next()
+ .unwrap_or("")
+ .trim()
+ .trim_matches('`')
+ .trim()
+ .to_string()
+ })
+ .unwrap_or_default();
+ if !df.is_empty() && !deriv.contains_key(&df) {
+ violations.push(format!(
+ "{}: `derives-from` names {df:?}, which is in no derivation section. A \
+ row deriving from nothing is a rule someone invented.",
+ row.id
+ ));
+ }
+
+ let st = row
+ .fields
+ .get("status")
+ .map(|v| v.trim().trim_matches('`').to_string())
+ .unwrap_or_default();
+ if !STATUSES.contains(&st.as_str()) {
+ violations.push(format!(
+ "{}: status {st:?} is not in the closed set {STATUSES:?}",
+ row.id
+ ));
+ }
+
+ for key in ["enforced-by", "observable-via"] {
+ let Some(val) = row.fields.get(key) else {
+ continue; // already reported as a missing required field
+ };
+ if GAP_PREFIXES.iter().any(|p| val.starts_with(p)) {
+ continue;
+ }
+ let toks = backticked(val);
+ let paths: Vec<&&str> = toks
+ .iter()
+ .filter(|t| t.ends_with(".rs") || t.ends_with(".md"))
+ .collect();
+ if paths.is_empty() && toks.is_empty() {
+ violations.push(format!(
+ "{}: `{key}` names neither an artifact nor an explicit gap",
+ row.id
+ ));
+ continue;
+ }
+ for p in paths {
+ checked_paths += 1;
+ if !root.join(p).is_file() {
+ violations.push(format!(
+ "{}: `{key}` names {p} which does not exist in this tree",
+ row.id
+ ));
+ }
+ }
+ }
+
+ if !row
+ .fields
+ .get("last-verified")
+ .is_some_and(|v| has_iso_date(v))
+ {
+ violations.push(format!(
+ "{}: `last-verified` carries no date — a status with no date is a status \
+ nobody can age",
+ row.id
+ ));
+ }
+ }
+
+ (violations, checked_fields, checked_paths)
+}
+
+#[test]
+fn every_row_conforms_to_the_schema() {
+ let text = load();
+ let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+ let (violations, checked_fields, checked_paths) = conformance_violations(&text, &root);
+
+ // Print what this DID, not what it intended. A driver that enumerates work must
+ // count what it fed, and it must print the FRAME it counted over.
+ println!(
+ " rows parsed: {} fields checked: {checked_fields} artifact paths resolved: \
+ {checked_paths}",
+ parse_rows(&text).len()
+ );
+
+ assert!(
+ violations.is_empty(),
+ "constraint register: {} violation(s)\n {}",
+ violations.len(),
+ violations.join("\n ")
+ );
+}
+
+/// The half the first version of this gate could not see. It enumerated rows and asked
+/// whether each named a loss, so **a loss with no constraint was invisible by
+/// construction** and it returned clean — this project's own count-what-you-fed defect,
+/// one level up.
+fn derivation_orphans(text: &str) -> (Vec, usize, usize) {
+ let rows = parse_rows(text);
+ let deriv = parse_derivation(text).unwrap_or_else(|e| panic!("COULD NOT CHECK: {e}"));
+
+ let claimed: BTreeSet = rows
+ .iter()
+ .filter_map(|r| r.fields.get("derives-from"))
+ .map(|v| {
+ v.split('(')
+ .next()
+ .unwrap_or("")
+ .trim()
+ .trim_matches('`')
+ .trim()
+ .to_string()
+ })
+ .filter(|s| !s.is_empty())
+ .collect();
+
+ let orphans: Vec = deriv
+ .iter()
+ .filter(|(id, _)| !claimed.contains(*id))
+ .map(|(id, level)| {
+ let singular = level.strip_suffix('s').unwrap_or(level);
+ format!(
+ "{singular} {id:?} is named by no constraint — an unconstrained loss is \
+ invisible to every other check in this file"
+ )
+ })
+ .collect();
+
+ let claimed_real = claimed.iter().filter(|c| deriv.contains_key(*c)).count();
+ (orphans, deriv.len(), claimed_real)
+}
+
+/// The half the first version of this gate could not see.
+#[test]
+fn the_derivation_closes_in_both_directions() {
+ let text = load();
+ let (orphans, entries, claimed_real) = derivation_orphans(&text);
+ println!(
+ " derivation entries: {entries} claimed by a row: {claimed_real} orphaned: {}",
+ orphans.len()
+ );
+ assert!(
+ orphans.is_empty(),
+ "derivation does not close: {} orphan(s)\n {}",
+ orphans.len(),
+ orphans.join("\n ")
+ );
+}
+
+/// The register states a fraction in prose. A hand-maintained count is exactly the kind
+/// of row this project has already watched drift four times, so it is recomputed here
+/// and the two must agree.
+/// `Ok((enforced, total))` when prose and rows agree; `Err(message)` when they do not.
+fn fraction_agreement(text: &str) -> Result<(usize, usize), String> {
+ let rows = parse_rows(text);
+ assert!(!rows.is_empty(), "COULD NOT CHECK: no rows parsed");
+
+ let statuses: Vec<(String, String)> = rows
+ .iter()
+ .map(|r| {
+ (
+ r.id.clone(),
+ r.fields
+ .get("status")
+ .map(|v| v.trim().trim_matches('`').to_string())
+ .unwrap_or_default(),
+ )
+ })
+ .collect();
+ let enforced = statuses.iter().filter(|(_, s)| s == "enforced").count();
+
+ let detail = statuses
+ .iter()
+ .map(|(c, s)| format!("{c}={s}"))
+ .collect::>()
+ .join(", ");
+ println!(
+ " fraction enforced: {enforced} of {} ({detail})",
+ rows.len()
+ );
+
+ let (stated_n, stated_d) = stated_fraction(text).unwrap_or_else(|| {
+ panic!(
+ "COULD NOT CHECK: no `Fraction enforced: of ` line found. The register \
+ promises the gate recomputes this number; without the line there is nothing \
+ to compare against."
+ )
+ });
+ if (stated_n, stated_d) != (enforced, rows.len()) {
+ return Err(format!(
+ "the register states 'Fraction enforced: {stated_n} of {stated_d}' but the \
+ status fields compute {enforced} of {}. The prose and the rows disagree — fix \
+ whichever is wrong, and note that this number moves when SCOPE changes as \
+ well as when work lands.",
+ rows.len()
+ ));
+ }
+ Ok((enforced, rows.len()))
+}
+
+/// The register states a fraction in prose; a hand-maintained count has drifted four
+/// times in this project, so it is recomputed and the two must agree.
+#[test]
+fn the_stated_fraction_enforced_matches_the_status_fields() {
+ let text = load();
+ match fraction_agreement(&text) {
+ Ok((enforced, total)) => println!(" fraction enforced: {enforced} of {total}"),
+ Err(e) => panic!("{e}"),
+ }
+}
+
+// ---------------------------------------------------------------------------
+// RED-TESTS — can this gate fail at all?
+//
+// A gate never seen red is not known to work. These live in the SAME FILE as the checks
+// they mutate, which is the point of taking text rather than a path: each case edits an
+// in-memory `String` and asserts the SPECIFIC violation comes back.
+//
+// WHAT THIS REPLACES. The red-test used to live outside this tree, in `sh`, and worked by
+// editing `CONSTRAINTS.md` in place and re-running `cargo test` — so it could only observe
+// an EXIT CODE, it mutated a tracked document, and three of its anchors silently went
+// stale within hours because they were string literals in a different repository. Every
+// `expect_violation` below asserts on the message, so a case that stops mutating anything
+// fails loudly here instead of passing quietly there.
+#[cfg(test)]
+mod red {
+ use super::*;
+
+ fn real() -> (String, PathBuf) {
+ (load(), PathBuf::from(env!("CARGO_MANIFEST_DIR")))
+ }
+
+ /// Apply a mutation and REQUIRE that it changed something — a no-op mutation is a
+ /// stale anchor, and a stale anchor that still runs the check is a case testing
+ /// nothing while printing success.
+ fn mutate(text: &str, from: &str, to: &str) -> String {
+ assert!(
+ text.contains(from),
+ "STALE ANCHOR: {from:?} is not in CONSTRAINTS.md — this case tested nothing"
+ );
+ text.replacen(from, to, 1)
+ }
+
+ fn expect_violation(text: &str, root: &Path, needle: &str) {
+ let (v, _, _) = conformance_violations(text, root);
+ assert!(
+ v.iter().any(|m| m.contains(needle)),
+ "expected a violation containing {needle:?}; got {v:?}"
+ );
+ }
+
+ #[test]
+ fn the_unmutated_register_is_clean() {
+ let (text, root) = real();
+ let (v, fields, paths) = conformance_violations(&text, &root);
+ assert!(v.is_empty(), "baseline must be clean; got {v:?}");
+ assert!(
+ fields > 0 && paths > 0,
+ "and it must have checked something"
+ );
+ assert!(derivation_orphans(&text).0.is_empty());
+ assert!(fraction_agreement(&text).is_ok());
+ }
+
+ #[test]
+ fn a_status_outside_the_closed_set_is_caught() {
+ let (text, root) = real();
+ let m = mutate(&text, "**status:** `enforced`", "**status:** `mostly`");
+ expect_violation(&m, &root, "not in the closed set");
+ }
+
+ #[test]
+ fn a_missing_required_field_is_caught() {
+ let (text, root) = real();
+ let m = text
+ .lines()
+ .filter(|l| !l.starts_with("- **controller:**"))
+ .collect::>()
+ .join("\n");
+ assert_ne!(m, text, "STALE ANCHOR: no controller field to drop");
+ expect_violation(&m, &root, "missing required field `controller`");
+ }
+
+ #[test]
+ fn an_artifact_that_does_not_exist_is_caught() {
+ let (text, root) = real();
+ let m = mutate(&text, "`src/landlock.rs`", "`src/does_not_exist.rs`");
+ expect_violation(&m, &root, "does not exist in this tree");
+ }
+
+ #[test]
+ fn an_undated_last_verified_is_caught() {
+ let (text, root) = real();
+ // Anchored on the FIELD, not on the date's prose: the previous driver pinned
+ // "2026-07-29 — 134 tests" and went stale the same day the count changed.
+ let i = text
+ .find("- **last-verified:** 2")
+ .expect("STALE ANCHOR: no dated last-verified field");
+ let end = text[i..].find('\n').map_or(text.len(), |n| i + n);
+ let m = format!(
+ "{}- **last-verified:** recently{}",
+ &text[..i],
+ &text[end..]
+ );
+ expect_violation(&m, &root, "carries no date");
+ }
+
+ #[test]
+ fn a_prohibition_shaped_statement_is_caught() {
+ let (text, root) = real();
+ let i = text.find("## CON-").expect("no rows");
+ let end = text[i..].find('\n').map_or(text.len(), |n| i + n);
+ let m = format!(
+ "{}## CON-1 — The child must not obtain a capability{}",
+ &text[..i],
+ &text[end..]
+ );
+ expect_violation(&m, &root, "phrased as a prohibition");
+ }
+
+ #[test]
+ fn a_derives_from_naming_nothing_real_is_caught() {
+ let (text, root) = real();
+ let m = mutate(
+ &text,
+ "- **derives-from:** `LAUNDER`",
+ "- **derives-from:** `NOT_A_LOSS`",
+ );
+ expect_violation(&m, &root, "is in no derivation section");
+ }
+
+ /// The reverse direction — a loss no row claims. Anchored on the HEADING so it
+ /// survives any loss being renamed or retired.
+ #[test]
+ fn a_derivation_entry_no_row_claims_is_caught() {
+ let (text, _) = real();
+ let m = mutate(
+ &text,
+ "### Losses\n",
+ "### Losses\n- `STARVE` — an invented loss that no row claims\n",
+ );
+ let (orphans, _, _) = derivation_orphans(&m);
+ assert!(
+ orphans.iter().any(|o| o.contains("STARVE")),
+ "an unclaimed loss must be reported; got {orphans:?}"
+ );
+ }
+
+ /// Anchored on the numerator only — the DENOMINATOR moves when a row is retired,
+ /// which is exactly what went stale in the external driver.
+ #[test]
+ fn a_stated_fraction_that_disagrees_is_caught() {
+ let (text, _) = real();
+ let (n, d) = stated_fraction(&text).expect("no fraction line");
+ let m = mutate(
+ &text,
+ &format!("Fraction enforced: {n} of {d}"),
+ &format!("Fraction enforced: {} of {d}", n + 2),
+ );
+ let err = fraction_agreement(&m).expect_err("a disagreeing fraction must be an Err");
+ assert!(err.contains("disagree"), "got {err:?}");
+ }
+
+ /// The old exit 2: no rows at all. A parser that finds nothing has not found nothing
+ /// wrong.
+ #[test]
+ fn a_register_with_no_rows_parses_to_nothing() {
+ let (text, _) = real();
+ let m = text
+ .lines()
+ .map(|l| {
+ if l.starts_with("## CON-") {
+ format!("#{l}")
+ } else {
+ l.to_string()
+ }
+ })
+ .collect::>()
+ .join("\n");
+ assert_ne!(m, text, "STALE ANCHOR: no CON- rows to disable");
+ assert!(
+ parse_rows(&m).is_empty(),
+ "with every row heading demoted, the parser must find none — which the \
+ presence test turns into a failure"
+ );
+ }
+}
diff --git a/tests/containment_matrix.rs b/tests/containment_matrix.rs
index 10be82b..38170ff 100644
--- a/tests/containment_matrix.rs
+++ b/tests/containment_matrix.rs
@@ -1,14 +1,41 @@
// SPDX-License-Identifier: BSD-3-Clause
-//! Containment matrix (§11 / NCC F4): ONE committed table pinning oring's entire
-//! allow/deny/residual surface per profile. A single test runs every cell,
-//! collects ALL mismatches, and renders an aligned diff. Any silent widening —
-//! a block regressing to allow, OR a documented §2 residual quietly closing —
-//! fails loud and names the DESIGN §2 line to fix.
+//! Containment matrix (§11 / NCC F4): ONE committed table pinning oring's whole
+//! allow/deny/residual surface per profile. One test runs every cell, collects
+//! ALL mismatches, and renders an aligned diff. Any silent widening — a block
+//! regressing to allow, or a documented residual quietly closing — fails loud.
//!
-//! Construction-safe: every payload targets only harness-owned throwaway
-//! resources (tempdir / loopback / $HOME sentinel socket / decoy sibling) with
-//! null-or-benign primitives, so a containment REGRESSION cannot harm the host.
-//! (The live-destructive cells behind the userns/netns jail are a separate file.)
+//! Construction-safe: every payload targets harness-owned throwaway resources
+//! with null-or-benign primitives, so a containment regression cannot harm the
+//! host. The live-destructive cells behind the userns/netns jail are elsewhere.
+//!
+//! # Attribution — checked against a bare control, every run (T21)
+//!
+//! A cell asserting an errno does not establish that *oring* produced the
+//! denial: where DAC or a host MAC already forbids the operation, a `Deny` cell
+//! passes on the other layer's authority. The failure is silent and
+//! one-directional — a Residual cell that starts being denied fails loudly, but a
+//! `Deny` cell denied for the wrong reason passes forever.
+//!
+//! **Every denial cell runs BARE first and the pair is compared** with the same
+//! `verdict_ok` the sealed arm uses, so "indistinguishable" means exactly what
+//! PASS means. Such a cell is reported UNATTRIBUTABLE, not PASS, and must appear
+//! in `UNATTRIBUTABLE_CELLS` — checked in both directions, so the list can only
+//! shrink. It is currently **empty: 21 of 21 attributed.**
+//!
+//! **Why this replaced a hand-check rather than supplementing one.** The control
+//! was run by hand on two dates, and a manual pass does not survive people adding
+//! cells: the T19 pair landed the day after the last one and was never covered.
+//! A dated note describes the tree on the day it was written; this describes it on
+//! the run. Recomputing reproduced the hand-check exactly before the one
+//! unattributable cell was **fixed rather than declared** — it now writes into a
+//! harness-owned `read_only` grant that DAC permits, with `fs-read-readonly-tier`
+//! as the positive control, because "write denied in a read_only tier" and "write
+//! denied because the path was never granted" are otherwise indistinguishable
+//! (measured: dropping the grant leaves the write cell passing).
+//!
+//! **Still not established:** which layer produced an *attributed* denial.
+//! Bare-succeeds shows the environment does not supply it; not that ring 1 rather
+//! than ring 2 did. Stamping host MAC state into the report is the other half.
#![cfg(target_os = "linux")]
mod common;
use common::*;
@@ -16,6 +43,20 @@ use common::*;
const SIGSYS: i32 = 31;
const PORTS_443: &[u16] = &[443];
+/// T21: denial cells whose bare arm produces the SAME observation as the sealed
+/// arm, so the denial cannot be attributed to oring.
+///
+/// **This list may only shrink**, and the check below fails in both directions:
+/// a cell that becomes unattributable and is not listed is new debt, and a
+/// listed cell that becomes attributable is a stale entry. Its length is the
+/// size of the gap, computed every run rather than hand-checked on a date.
+///
+/// A cell here is NOT wrong — the sealed behaviour is still correct. What it
+/// cannot do is serve as *evidence*, because the environment already produces
+/// the same answer. Prefer re-targeting the cell at something DAC permits over
+/// adding a name here.
+const UNATTRIBUTABLE_CELLS: [(&str, &str); 0] = [];
+
#[derive(Clone, Copy)]
enum Expect {
Deny(i32), // ring blocked with this errno; sig None
@@ -33,6 +74,9 @@ enum Arg {
AbstractName, // name of the harness-bound abstract-namespace socket
WorkFile, // a file in the write_exec workdir
TmpFile, // a file in /tmp (write_noexec tier)
+ RoFile, // a NEW file in a harness-owned dir granted READ_ONLY, outside every
+ // other tier — DAC permits the write, Landlock denies it
+ RoFileExists, // an EXISTING file in that same dir, for the positive control
}
struct Row {
@@ -50,7 +94,13 @@ struct Row {
#[rustfmt::skip]
const ROWS: &[Row] = &[
// --- FS ---
- Row{id:"fs-write-readonly", taxon:"FS", op:"fs-write", arg:Arg::S("/etc/oring-matrix"), profile:"balanced", proc_mode:None, connect:None, expect:Expect::Deny(EACCES), doc:""},
+ Row{id:"fs-write-readonly", taxon:"FS", op:"fs-write", arg:Arg::RoFile, profile:"balanced", proc_mode:None, connect:None, expect:Expect::Deny(EACCES), doc:""},
+ // POSITIVE CONTROL for the row above, and the reason it is not optional: without
+ // it, "write denied in a read_only tier" is indistinguishable from "write denied
+ // because the path was never granted". This cell shows the grant is LIVE — the
+ // same directory is readable under the same policy — so the write denial is the
+ // tier's semantics rather than an absent rule.
+ Row{id:"fs-read-readonly-tier", taxon:"FS", op:"fs-read", arg:Arg::RoFileExists, profile:"balanced", proc_mode:None, connect:None, expect:Expect::Allow, doc:""},
Row{id:"fs-write-workdir", taxon:"FS", op:"fs-write", arg:Arg::WorkFile, profile:"balanced", proc_mode:None, connect:None, expect:Expect::Allow, doc:""},
Row{id:"wx-noexec-tier", taxon:"FS", op:"write-exec-file", arg:Arg::TmpFile, profile:"balanced", proc_mode:None, connect:None, expect:Expect::Deny(EACCES), doc:""},
Row{id:"wx-exec-workdir", taxon:"FS", op:"write-exec-file", arg:Arg::WorkFile, profile:"balanced", proc_mode:None, connect:None, expect:Expect::Allow, doc:"§2 in-tree W^X residual"},
@@ -58,7 +108,13 @@ const ROWS: &[Row] = &[
// --- NET ---
Row{id:"tcp-unlisted-port", taxon:"NET", op:"net-connect", arg:Arg::S("22"), profile:"balanced", proc_mode:None, connect:Some(PORTS_443), expect:Expect::Deny(EACCES), doc:""},
Row{id:"tcp-allowed-port", taxon:"NET", op:"net-connect", arg:Arg::S("443"), profile:"balanced", proc_mode:None, connect:Some(PORTS_443), expect:Expect::NotBlocked, doc:"§2 any-IP on allowed port"},
- Row{id:"udp-egress", taxon:"NET", op:"udp-send", arg:Arg::S("127.0.0.1:53"), profile:"balanced", proc_mode:None, connect:Some(PORTS_443), expect:Expect::NotBlocked, doc:"§2 UDP/DNS uncovered"},
+ // NotBlocked here is the UNHANDLED-field residual, NOT the shipped profile: every
+ // net row runs a policy synthesized by `policy_json_sec`, whose `profile` argument
+ // selects the SECCOMP profile only and which never emits `egress_udp`. The shipped
+ // `balanced` grants :53 and denies every other UDP port (2026-07-29), so reading this
+ // cell as "the balanced profile leaves UDP open" is wrong in both directions. The
+ // profile's own posture is pinned in `src/profile.rs`, not here.
+ Row{id:"udp-egress", taxon:"NET", op:"udp-send", arg:Arg::S("127.0.0.1:53"), profile:"balanced", proc_mode:None, connect:Some(PORTS_443), expect:Expect::NotBlocked, doc:"§2 residual of an UNHANDLED egress_udp — synthesized policy, not the profile"},
// --- SC (syscall LPE) ---
Row{id:"ptrace", taxon:"SC", op:"sc-ptrace", arg:Arg::None, profile:"balanced", proc_mode:None, connect:None, expect:Expect::Deny(EPERM), doc:""},
@@ -79,8 +135,9 @@ const ROWS: &[Row] = &[
// --- IPC (AF_UNIX connect — walled by two complementary mechanisms) ---
// Pathname sockets: RESOLVE_UNIX (V9, handled + granted nowhere) -> EACCES.
- // Requires the `resolve-unix` feature AND kernel ABI>=9 (the wall); the runner
- // skips these otherwise, where the connect is the §2 unmediated residual.
+ // V9 is the hard floor as of v0.2, so these are unconditional proof cells:
+ // every kernel oring agrees to run on has the wall, and the sub-floor kernels
+ // where this connect was the §2 unmediated residual are refused outright.
Row{id:"unix-connect-balanced", taxon:"IPC", op:"unix-connect", arg:Arg::UnixPath, profile:"balanced", proc_mode:None, connect:None, expect:Expect::Deny(EACCES), doc:""},
Row{id:"unix-connect-strict", taxon:"IPC", op:"unix-connect", arg:Arg::UnixPath, profile:"strict", proc_mode:None, connect:None, expect:Expect::Deny(EACCES), doc:""},
// Abstract sockets: RESOLVE_UNIX is path-based and does NOT cover them; IPC
@@ -89,16 +146,126 @@ const ROWS: &[Row] = &[
Row{id:"unix-abstract-strict", taxon:"IPC", op:"unix-connect-abstract", arg:Arg::AbstractName, profile:"strict", proc_mode:None, connect:None, expect:Expect::Deny(EPERM), doc:""},
// --- PROC (peer procfs + --proc mitigation) ---
- // Peer SECRET (environ) is a RESIDUAL at the V7 floor: with /proc granted
- // (--proc full) the read is NOT blocked on kernel 6.15 (measured). A newer
- // kernel's ptrace_access_check hook was seen to deny it on 7.1.3, but that is
- // not a floor guarantee — so the honest expectation here is NotBlocked. Peer
- // METADATA (cmdline) likewise leaks under --proc full; --proc self closes the
- // /proc PATH for both. Own environ under --proc self is readable (control).
- Row{id:"proc-peer-env-full", taxon:"PROC", op:"proc-read", arg:Arg::SentinelPid, profile:"balanced", proc_mode:Some("full"), connect:None, expect:Expect::NotBlocked, doc:"§2 peer environ read is a residual (not walled at the V7 floor)"},
+ // CORRECTED 2026-07-26. This block used to say peer environ "was a RESIDUAL
+ // at the old V7 floor ... NOT blocked on kernel 6.15 (measured) ... seen to
+ // deny it on 7.1.3, which at a V7 floor was a version bonus, not a
+ // guarantee". That framing is wrong, and the chain of docs built on it has
+ // been retracted (DESIGN §2/§4/D-30, ATTACKS row 12, ROADMAP G11).
+ //
+ // The denier is hook_ptrace_access_check, which consults NO handled access
+ // right — domain_scope_le() compares domain hierarchies — and the rule
+ // predates the 5.13 merge, so the V9 floor bought none of it. The outcome
+ // turns on domain ancestry-or-EQUALITY (siblings under one seal read each
+ // other), and ALSO on privilege: CAP_PERFMON bypasses it entirely (6.15+).
+ // A peer forked BEFORE the seal is denied; a child forked AFTER it is
+ // readable. The old 6.15 NotBlocked result is UNEXPLAINED — not
+ // evidence of a kernel change — it is what this cell reports when its
+ // target sits inside the reader's own domain.
+ //
+ // THIS CELL IS SOUND FOR THE RIGHT REASON: the sentinel is spawned by the
+ // harness, OUTSIDE the cage, which is the case that matters. If that ever
+ // changes, the cell silently stops measuring anything.
+ //
+ // Measured on Fedora 44 / 7.1.4 / ABI V9:
+ // the confined peer-environ read returns EACCES. Ruled out SELinux as the
+ // denier rather than assuming — the same read succeeds BARE on that host
+ // (rc=0) under the identical security context, `deny_ptrace` is off, and no
+ // AVC is involved; and it is peer-SPECIFIC, since own environ still reads
+ // (proc-self-env-self below). So the denial is oring's ring on a >=7.1 kernel.
+ //
+ // SCOPE OF THE CLAIM: `environ` is what this cell probes. `maps`/`mem` pass
+ // through the same ptrace_may_access gate and are expected to follow, but are
+ // NOT directly witnessed here — do not widen the claim without a cell.
+ //
+ // Peer METADATA (cmdline) still leaks under --proc full (world-readable,
+ // ptrace-independent) — that residual stands. --proc self closes the /proc
+ // PATH for both. Own environ under --proc self is readable (control).
+ Row{id:"proc-peer-env-full", taxon:"PROC", op:"proc-read", arg:Arg::SentinelPid, profile:"balanced", proc_mode:Some("full"), connect:None, expect:Expect::Deny(EACCES), doc:"peer environ walled by hook_ptrace_access_check — sentinel is OUTSIDE our domain; rule predates 5.13, not bought by the V9 floor"},
Row{id:"proc-peer-cmd-full", taxon:"PROC", op:"proc-cmdline", arg:Arg::SentinelPid, profile:"balanced", proc_mode:Some("full"), connect:None, expect:Expect::NotBlocked, doc:"§2 peer metadata (cmdline) leak"},
Row{id:"proc-peer-cmd-self", taxon:"PROC", op:"proc-cmdline", arg:Arg::SentinelPid, profile:"balanced", proc_mode:Some("self"), connect:None, expect:Expect::Deny(EACCES), doc:"--proc self closes the /proc path"},
Row{id:"proc-self-env-self", taxon:"PROC", op:"proc-read", arg:Arg::S("self"), profile:"balanced", proc_mode:Some("self"), connect:None, expect:Expect::NotBlocked, doc:""},
+ // IN-DOMAIN, added 2026-07-29 (BUILD-QUEUE T19). The four rows above all target the
+ // Sentinel — spawned BEFORE the seal, so OUT-OF-DOMAIN — or `self`. None was in-domain,
+ // and the mitigation the project publishes is about exactly
+ // that case: *"use --proc self if you need environ isolation between your own
+ // subprocesses"*. It rested on one hand-run measurement on one kernel and was pinned by
+ // nothing here, so nothing would have noticed if a kernel change closed the read or if
+ // --proc self stopped denying it.
+ //
+ // The probe spawns two SIBLINGS under one seal. Siblings and not parent->child on
+ // purpose: `ptrace_may_access` permits an ancestor to read a descendant regardless of
+ // Landlock, so a parent reading its own child would pass for reasons unrelated to the
+ // domain relation.
+ //
+ // TWO DIFFERENT MECHANISMS, which SECURITY.md had to separate on 2026-07-28 after
+ // presenting them as one controlled pair — the ids say which is which:
+ // full: Landlock scopes ancestor-or-equal, so siblings share a domain and the ptrace
+ // hook PERMITS the read. A documented residual, not a defect.
+ // self: the sibling's /proc/ is not a granted path, so it fails at the Landlock
+ // FS TIER *before* ptrace_may_access is consulted. Different layer entirely.
+ Row{id:"proc-indomain-env-full", taxon:"PROC", op:"proc-indomain-env", arg:Arg::S("x"), profile:"balanced", proc_mode:Some("full"), connect:None, expect:Expect::NotBlocked, doc:"§2 in-domain peer environ: two processes under one seal share a domain"},
+ Row{id:"proc-indomain-env-self", taxon:"PROC", op:"proc-indomain-env", arg:Arg::S("x"), profile:"balanced", proc_mode:Some("self"), connect:None, expect:Expect::Deny(EACCES), doc:"§2: --proc self isolates a subprocess's environ — denied at the FS tier, not by the domain relation"},
+];
+
+/// A COMPOSITION cell: several probes under ONE policy, all of which must hold.
+///
+/// **Why this is a separate shape and not a wider `Row`.** A `Row` carries exactly one
+/// `op`, and that is not an oversight to be patched — a per-mechanism cell and a
+/// composition cell answer different questions and need different invariants. Widening
+/// `Row` would have meant rewriting all 30 existing cells in a safety-critical table to
+/// express something none of them needs. `CONSTRAINTS.md` CON-1 records the gap this
+/// closes: *"every suite is per-mechanism and nothing exercises a pair."*
+///
+/// **Two shape rules are enforced at run time, below, because a composition cell is easy
+/// to write badly:**
+///
+/// 1. **At least two probes.** One probe is a `Row`; putting it here claims something it
+/// does not test.
+/// 2. **At least one non-denial expectation.** A conjunction of denials is satisfied by a
+/// seal that refuses *everything*, so without a reachability control the cell can pass
+/// while proving nothing. My first hand-written composition test needed exactly this
+/// control, which is why the rule is encoded rather than remembered.
+struct Composition {
+ id: &'static str,
+ /// The documented dependency being exercised. Required prose: a composition cell that
+ /// cannot name the pair it tests is two assertions in a row wearing one id.
+ dependency: &'static str,
+ profile: &'static str,
+ proc_mode: Option<&'static str>,
+ connect: Option<&'static [u16]>,
+ probes: &'static [(&'static str, Arg, Expect)],
+}
+
+#[rustfmt::skip]
+const COMPOSITIONS: &[Composition] = &[
+ // §4: ring 1's TCP gate is "port-only, and dependent on the ring-2 MSG_FASTOPEN block
+ // for the wall to hold". `connect(2)` is refused by ring 1 at
+ // security_socket_connect; `sendto(.., MSG_FASTOPEN, &sa)` performs an implicit
+ // connect INSIDE the send path that never reaches that hook, so only ring 2's
+ // arg-masked rule can stop it. Port 9 is what the fastopen probe targets, and
+ // `connect: [443]` switches ring 1's net gate ON while leaving 9 unlisted — a policy
+ // shape `deny_syscall::msg_fastopen_is_blocked` does not cover, since it runs with
+ // `egress_tcp: None` while testing the route that exists to bypass ring 1.
+ //
+ // WHAT THIS CELL ACTUALLY WITNESSES ON A 7.2 HOST, measured rather than assumed.
+ // Dropping ring 2's rule makes the fastopen probe return EACCES (13), not a success:
+ // mainline 33cb713db016 fixed the Landlock fastopen gap in 7.2, so on this kernel
+ // ring 1 catches the route by itself and the wall holds WITHOUT ring 2. So the
+ // EOPNOTSUPP expectation below asserts LAYERING — that ring 2 gets there first — and
+ // is load-bearing only at the floor (7.1.x, pre-fix), which is exactly where oring
+ // still supports and no local run exercises. The cell is honest either way: it fails
+ // if ring 2 stops denying, and the failure line shows 13 instead of 95, which is the
+ // signal that ring 1 covered for it.
+ Composition{
+ id: "tcp-wall-both-rings",
+ dependency: "§4: ring 1 TCP port gate depends on the ring-2 MSG_FASTOPEN block",
+ profile: "balanced", proc_mode: None, connect: Some(&[443]),
+ probes: &[
+ ("net-connect", Arg::S("9"), Expect::Deny(EACCES)), // ring 1
+ ("sc-msg-fastopen", Arg::None, Expect::Deny(EOPNOTSUPP)), // ring 2
+ ("net-connect", Arg::S("443"), Expect::NotBlocked), // vacuity control
+ ],
+ },
];
fn verdict_ok(e: Expect, code: Option, sig: Option) -> bool {
@@ -126,15 +293,17 @@ fn fmt_obs(code: Option, sig: Option) -> String {
#[test]
fn containment_matrix() {
// The matrix pins EXACT per-kernel allow/deny behavior, so it is only valid
- // on a CLEAN, controlled kernel. A bare shared CI runner is V7 but dirty
- // (nested namespaces, a populated /tmp), which perturbs individual cells.
- // Run only in a controlled-kernel context — the CI virtme-ng/KVM job sets
+ // on a CLEAN, controlled kernel. A bare shared CI runner is below the floor
+ // (and, when it is not, dirty: nested namespaces, a populated /tmp), which
+ // perturbs individual cells.
+ // Run only in a controlled-kernel context. NO CI job sets this: the vng jobs
+ // that would have were deleted 2026-08-02 having never run. What sets
// ORING_REQUIRE_KERNEL, as does a developer who opts in. deny_*/agent_survival
// cover the bare host; this matrix is the controlled witness.
if std::env::var_os("ORING_REQUIRE_KERNEL").is_none() {
eprintln!(
"SKIP containment_matrix: needs a controlled kernel \
- (set ORING_REQUIRE_KERNEL=1 on a clean >=6.15 host)"
+ (set ORING_REQUIRE_KERNEL=1 on a clean >=7.1 host)"
);
return;
}
@@ -144,26 +313,38 @@ fn containment_matrix() {
// Shared throwaway fixtures — the whole safety story.
let sentinel = Sentinel::spawn();
let sock = UnixSock::bind();
+ // A harness-owned directory granted READ_ONLY, in $HOME so it is outside the
+ // workdir and outside /tmp — both of which carry write grants that Landlock
+ // would union in, silently turning the read_only cell into a no-op.
+ let ro_dir = std::path::PathBuf::from(std::env::var("HOME").expect("HOME set"))
+ .join(format!(".oring-ro-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&ro_dir);
+ std::fs::create_dir_all(&ro_dir).expect("create read_only fixture dir");
+ std::fs::write(ro_dir.join("readable"), "ro\n").expect("seed read_only fixture");
let abstract_sock = AbstractSock::bind();
let wd = tmp_workdir("matrix"); // write_exec workdir
// RESOLVE_UNIX (pathname-socket wall) needs the `resolve-unix` feature AND a
- // kernel ABI>=9. When either is absent the right is not applied and pathname
- // connect is the unmediated residual — skip those cells rather than assert a
- // deny that only holds with the wall (the abstract/scope cells are V6, always
- // run).
+ // kernel ABI>=9. Since the V10 floor BOTH are guaranteed here: the feature by
+ // the compile_error! in src/landlock.rs, the kernel by require_or_skip above
+ // (which gates on the crate's FLOOR_ABI = 10). So the pathname-unix cells are
+ // unconditional proof cells now, not build-gated ones that auto-skip — assert
+ // that rather than branching on it, so a floor regression fails the matrix
+ // instead of quietly reverting these to skips.
let host_abi = landlock_abi();
- let wall_active = cfg!(feature = "resolve-unix") && host_abi >= 9;
+ assert!(
+ cfg!(feature = "resolve-unix") && host_abi >= 9,
+ "post-floor invariant: the RESOLVE_UNIX wall must be active here \
+ (feature={}, host_abi=V{host_abi}) — the V10 floor should have refused earlier",
+ cfg!(feature = "resolve-unix")
+ );
let mut rows_out: Vec = Vec::new();
let mut fails: Vec = Vec::new();
- let mut skipped_cells: Vec<&str> = Vec::new();
+
+ let mut unattributable_cells: Vec<(&str, String)> = Vec::new();
for row in ROWS {
- if row.op == "unix-connect" && !wall_active {
- skipped_cells.push(row.id);
- continue;
- }
// Resolve the payload arg to a concrete, harness-owned target. TmpFile
// must be under /tmp specifically — a write_noexec tier in the test
// policy (NOT $TMPDIR, which may be /scratch and ungranted).
@@ -181,19 +362,73 @@ fn containment_matrix() {
Arg::AbstractName => abstract_sock.name.clone(),
Arg::WorkFile => work_target.to_string_lossy().into_owned(),
Arg::TmpFile => tmp_target.to_string_lossy().into_owned(),
+ Arg::RoFile => ro_dir
+ .join(format!("ro-{}", row.id))
+ .to_string_lossy()
+ .into_owned(),
+ Arg::RoFileExists => ro_dir.join("readable").to_string_lossy().into_owned(),
};
// Target-path lint (load-bearing guardrail): any filesystem target MUST
// be under the tempdir / workdir / $HOME sentinel socket — never a real
// resource — so a mis-authored cell can't damage the host.
- if matches!(row.arg, Arg::WorkFile | Arg::TmpFile | Arg::UnixPath) {
+ if matches!(
+ row.arg,
+ Arg::WorkFile | Arg::TmpFile | Arg::UnixPath | Arg::RoFile
+ ) {
let safe = arg.starts_with("/tmp/")
|| arg.starts_with(wd.to_str().unwrap_or("\0"))
+ || arg.starts_with(ro_dir.to_str().unwrap_or("\0"))
|| arg == sock.path.to_string_lossy();
assert!(safe, "unsafe target for cell {}: {arg}", row.id);
}
- let policy = policy_json_sec(&[wd.to_str().unwrap()], row.connect, row.profile);
+ // ---- T21: the BARE control, computed rather than hand-checked ----------
+ //
+ // A `Deny` cell only attributes its denial to oring if the SAME operation
+ // is permitted unsealed. Where the environment supplies the same denial —
+ // DAC, a host MAC, an inherited filter — the cell is green whatever oring
+ // does, and `fs-write-readonly` is a measured instance: it writes
+ // `/etc/oring-matrix`, which is EACCES bare and EACCES sealed, and stays
+ // green with ring 1's FS layer mutated off.
+ //
+ // This ran twice as a hand-check, on 2026-07-24 and 2026-07-28, and a
+ // manual pass does not survive people adding cells: the T19 in-domain
+ // pair landed afterwards and was never covered by one. Computing it here
+ // means a cell added tomorrow is checked tomorrow.
+ //
+ // Bare runs FIRST so its side effects are cleaned before the sealed arm
+ // sees the target — for `fs-write` cells the file would otherwise already
+ // exist. Only denial cells need it: `Allow` and `NotBlocked` assert
+ // reachability, which a bare run cannot make more or less credible.
+ let bare = match row.expect {
+ Expect::Deny(_) | Expect::Kill(_) => {
+ let (bc, bs) = run_probe_unsealed(row.op, &arg);
+ let _ = std::fs::remove_file(&tmp_target);
+ // The bare arm of an RoFile cell SUCCEEDS by design — that is
+ // what makes it attributable — so it leaves a real file behind.
+ if matches!(row.arg, Arg::RoFile) {
+ let _ = std::fs::remove_file(&arg);
+ }
+ // If the bare run satisfies the very expectation the sealed run
+ // asserts, the two are indistinguishable and the cell proves
+ // nothing about oring.
+ Some((verdict_ok(row.expect, bc, bs), fmt_obs(bc, bs)))
+ }
+ Expect::Allow | Expect::NotBlocked => None,
+ };
+ if let Some((unattributable, obs)) = &bare {
+ if *unattributable {
+ unattributable_cells.push((row.id, obs.clone()));
+ }
+ }
+
+ let ro_grant: Vec<&str> = if matches!(row.arg, Arg::RoFile | Arg::RoFileExists) {
+ vec![ro_dir.to_str().unwrap_or("")]
+ } else {
+ vec![]
+ };
+ let policy = policy_json_ro(&[wd.to_str().unwrap()], &ro_grant, row.connect, row.profile);
let (code, sig) = run_probe_proc(&policy, row.op, &arg, row.proc_mode);
let _ = std::fs::remove_file(&tmp_target);
@@ -224,27 +459,166 @@ fn containment_matrix() {
}
}
- let table = rows_out.join("\n");
- if std::env::var_os("ORING_MATRIX_REPORT").is_some() {
- eprintln!(
- "\n=== oring containment matrix ({} cells) ===\n{table}",
- ROWS.len()
+ // ---- COMPOSITION cells ------------------------------------------------------
+ //
+ // Several probes under ONE policy, every expectation required. The two shape rules
+ // documented on `Composition` are asserted here rather than trusted, because a
+ // composition cell that fails them passes while proving nothing.
+ let mut comps_run = 0usize;
+ for c in COMPOSITIONS {
+ assert!(
+ c.probes.len() >= 2,
+ "composition {}: fewer than two probes — a single probe is a Row, and filing \
+ it here claims a pair was exercised when none was",
+ c.id
);
- }
- if !skipped_cells.is_empty() {
- // Never silently drop coverage (§ no-silent-caps): a pre-V9 host can't
- // exercise the RESOLVE_UNIX wall — say so.
- eprintln!(
- "oring: NOTE skipped {} pathname-unix cell(s) — host Landlock ABI V{host_abi} < 9 \
- (RESOLVE_UNIX unavailable): {}",
- skipped_cells.len(),
- skipped_cells.join(", ")
+ assert!(
+ c.probes
+ .iter()
+ .any(|(_, _, e)| matches!(e, Expect::Allow | Expect::NotBlocked)),
+ "composition {}: every probe expects a denial, so a seal that refuses \
+ EVERYTHING satisfies this cell. Add a reachability control.",
+ c.id
+ );
+ assert!(
+ !c.dependency.is_empty(),
+ "composition {}: must NAME the documented dependency it exercises",
+ c.id
);
+ let policy = policy_json_sec(&[wd.to_str().unwrap()], c.connect, c.profile);
+ for (op, arg_spec, expect) in c.probes {
+ let arg: String = match arg_spec {
+ Arg::None => String::new(),
+ Arg::S(v) => (*v).to_string(),
+ Arg::SentinelPid => sentinel.pid.to_string(),
+ Arg::UnixPath => sock.path.to_string_lossy().into_owned(),
+ Arg::AbstractName => abstract_sock.name.clone(),
+ Arg::WorkFile => wd
+ .join(format!("cf-{}", c.id))
+ .to_string_lossy()
+ .into_owned(),
+ Arg::TmpFile => std::path::PathBuf::from("/tmp")
+ .join(format!("oring-c-{}-{}", std::process::id(), c.id))
+ .to_string_lossy()
+ .into_owned(),
+ // Compositions build ONE policy for all their probes
+ // (`policy_json_sec`, no read_only extra), so an `RoFile` probe
+ // here would be granted nothing and would fail for the wrong
+ // reason — a denial from an absent grant rather than from the
+ // read_only tier under test. Refused loudly rather than
+ // resolved to a path that would quietly mean something else.
+ Arg::RoFile | Arg::RoFileExists => panic!(
+ "composition {}: Arg::RoFile needs a read_only grant that \
+ composition policies do not build. Use policy_json_ro here \
+ first, or keep the read_only cell in ROWS.",
+ c.id
+ ),
+ };
+ let (code, sig) = run_probe_proc(&policy, op, &arg, c.proc_mode);
+ let pass = verdict_ok(*expect, code, sig);
+ let exp = match expect {
+ Expect::Deny(e) => format!("Deny({e})"),
+ Expect::Kill(sg) => format!("Kill({sg})"),
+ Expect::Allow => "Allow".into(),
+ Expect::NotBlocked => "NotBlocked".into(),
+ };
+ let cell = format!("{}/{op}", c.id);
+ let dep = format!(" [{}]", c.dependency);
+ rows_out.push(format!(
+ "{:<24} {:<5} {:<9} {:<5} {:<12} {:<10} {}{}",
+ cell,
+ "COMP",
+ c.profile,
+ c.proc_mode.unwrap_or("-"),
+ exp,
+ fmt_obs(code, sig),
+ if pass { "PASS" } else { "FAIL" },
+ dep
+ ));
+ if !pass {
+ fails.push(format!("{}/{op}", c.id));
+ }
+ comps_run += 1;
+ }
+ }
+
+ // ---- T21: attribution verdict, in both directions -------------------------
+ //
+ // Printed unconditionally, because a count with no frame cannot be wrong —
+ // it can only be published.
+ let denial_cells = ROWS
+ .iter()
+ .filter(|r| matches!(r.expect, Expect::Deny(_) | Expect::Kill(_)))
+ .count();
+ eprintln!(
+ " attribution: {} denial cell(s) run bare; {} attributed, {} UNATTRIBUTABLE \
+ (declared: {})",
+ denial_cells,
+ denial_cells - unattributable_cells.len(),
+ unattributable_cells.len(),
+ UNATTRIBUTABLE_CELLS.len()
+ );
+ for (id, obs) in &unattributable_cells {
+ eprintln!(" · {id}: bare {obs} matches the sealed expectation — proves nothing");
+ }
+
+ let declared: Vec<&str> = UNATTRIBUTABLE_CELLS.iter().map(|(id, _)| *id).collect();
+ let new_debt: Vec<&str> = unattributable_cells
+ .iter()
+ .map(|(id, _)| *id)
+ .filter(|id| !declared.contains(id))
+ .collect();
+ let found: Vec<&str> = unattributable_cells.iter().map(|(id, _)| *id).collect();
+ let stale: Vec<&str> = declared
+ .iter()
+ .copied()
+ .filter(|id| !found.contains(id))
+ .collect();
+
+ assert!(
+ new_debt.is_empty(),
+ "{} denial cell(s) are UNATTRIBUTABLE and not declared: {:?}\nThe bare run produced \
+ the same observation as the sealed run, so the cell is green whatever oring does. \
+ Re-target it at something DAC permits, or add it to UNATTRIBUTABLE_CELLS with the \
+ reason — but a cell listed there is not evidence.",
+ new_debt.len(),
+ new_debt
+ );
+ assert!(
+ stale.is_empty(),
+ "STALE: {:?} are declared UNATTRIBUTABLE but the bare run now distinguishes them. \
+ Remove them from UNATTRIBUTABLE_CELLS — a list that does not shrink when the work \
+ is done stops measuring anything.",
+ stale
+ );
+
+ let table = rows_out.join("\n");
+
+ // ALWAYS print the frame, pass or fail. It used to print only under
+ // ORING_MATRIX_REPORT, so a green run said nothing about WHAT ran — and when two new
+ // cells landed, the only way to show they had executed was to mutate their
+ // expectations and watch the failure. A count with no printed frame cannot be wrong;
+ // it can only be published.
+ eprintln!(
+ " matrix: {} per-mechanism cells + {} composition probes ({} composition{}) = {} \
+ checks, {} failed",
+ ROWS.len(),
+ comps_run,
+ COMPOSITIONS.len(),
+ if COMPOSITIONS.len() == 1 { "" } else { "s" },
+ rows_out.len(),
+ fails.len()
+ );
+ if std::env::var_os("ORING_MATRIX_REPORT").is_some() {
+ eprintln!("\n=== oring containment matrix ===\n{table}");
}
+ // Denominator is rows_out.len(), NOT ROWS.len(). Those diverged the moment
+ // composition probes joined the table, and reporting "N of 30" over a 33-line table
+ // is a correct count over the wrong span.
assert!(
fails.is_empty(),
"containment matrix mismatches ({} of {}):\n{table}",
fails.len(),
- ROWS.len()
+ rows_out.len()
);
}
diff --git a/tests/control_efficacy.rs b/tests/control_efficacy.rs
new file mode 100644
index 0000000..4845076
--- /dev/null
+++ b/tests/control_efficacy.rs
@@ -0,0 +1,357 @@
+// SPDX-License-Identifier: BSD-3-Clause
+//! CON-7: run a documented control against the residual it is named for.
+//!
+//! `CONSTRAINTS.md` CON-7 is `unenforced` — *"no fixture runs a documented
+//! control against the residual it is named for"*. The row exists because the
+//! workdir-poisoning advice was **retracted twice in two days** (C-10, C-11):
+//! first "run the first build inside the cage", which laundered a bounded hazard
+//! into a persistent cross-project one; then "review it with `git status`" —
+//! which *is* the execution, because `core.fsmonitor` runs on index refresh. A
+//! control that fires the hazard it detects is worse than none, because it is
+//! performed with confidence.
+//!
+//! **Establishes**, for two controls: the planted hazard is live under the
+//! retracted control (the vacuity arm); the retracted control does not report it;
+//! the documented control detects it; and the documented control leaves the
+//! payload unfired. For `--explain` the "unfired" half is that it executes
+//! nothing, checked against a positive control that runs the same command
+//! without the flag.
+//!
+//! **Does NOT establish** that the workdir recipe catches every shape of
+//! poisoning — it catches the seven `.git/config` keys and non-sample hooks.
+//! `Makefile`, `build.rs` and `package.json` scripts are named in the same
+//! residual and are **not** covered. Nor anything about the other controls.
+//!
+//! **Both arms live in one test on purpose.** Split, one could rot while the
+//! other stayed green, and the failure message would not say which half lied.
+
+mod common;
+use common::*;
+
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+/// The seven `.git/config` keys the documented recipe greps for, verbatim from
+/// `docs/RESIDUALS.md`. If the recipe there changes, this must change with it —
+/// a fixture that tests a different command than the one documented proves
+/// nothing about what an operator will actually run.
+const RECIPE_PATTERN: &str =
+ r"fsmonitor|pager|sshCommand|textconv|hooksPath|askpass|^[[:space:]]*\[alias\]";
+
+fn have(bin: &str) -> bool {
+ Command::new(bin)
+ .arg("--version")
+ .output()
+ .is_ok_and(|o| o.status.success())
+}
+
+fn git(dir: &Path, home: &Path, args: &[&str]) -> (bool, String) {
+ let out = Command::new("git")
+ .current_dir(dir)
+ // HOME is redirected so the maintainer's global git config cannot
+ // change what this measures — a global `core.fsmonitor`, a template
+ // hooks dir, or an alias would all silently alter both arms.
+ .env("HOME", home)
+ .env("GIT_CONFIG_GLOBAL", home.join("gitconfig-none"))
+ .env("GIT_CONFIG_SYSTEM", home.join("gitconfig-none"))
+ .args(args)
+ .output()
+ .unwrap_or_else(|e| panic!("COULD NOT CHECK: git {args:?} did not run ({e})"));
+ (
+ out.status.success(),
+ format!(
+ "{}{}",
+ String::from_utf8_lossy(&out.stdout),
+ String::from_utf8_lossy(&out.stderr)
+ ),
+ )
+}
+
+fn firings(sentinel: &Path) -> usize {
+ std::fs::read_to_string(sentinel)
+ .map(|s| s.lines().filter(|l| !l.is_empty()).count())
+ .unwrap_or(0)
+}
+
+#[test]
+fn the_documented_workdir_review_detects_the_poisoning_without_firing_it() {
+ if !have("git") {
+ eprintln!("SKIP: git is not installed, so neither arm can run");
+ return;
+ }
+
+ let root = std::env::temp_dir().join(format!("oring-con7-{}", std::process::id()));
+ let repo = root.join("repo");
+ let home = root.join("home");
+ let sentinel = root.join("SENTINEL");
+ let _ = std::fs::remove_dir_all(&root);
+ std::fs::create_dir_all(&repo).expect("create repo");
+ std::fs::create_dir_all(&home).expect("create home");
+
+ // A workdir an agent has touched: a real repository with one commit.
+ git(&repo, &home, &["init", "-q", "."]);
+ git(&repo, &home, &["config", "user.email", "t@example.invalid"]);
+ git(&repo, &home, &["config", "user.name", "t"]);
+ std::fs::write(repo.join("file.txt"), "hi\n").expect("write file");
+ git(&repo, &home, &["add", "file.txt"]);
+ git(&repo, &home, &["commit", "-qm", "init"]);
+
+ // THE POISONING, exactly as C-11 describes: a hook inside `.git/` plus a
+ // config key that runs it. Both are within what a `write_exec` workdir
+ // grant permits, which is the whole point — oring cannot deny this without
+ // refusing to grant the repo root at all.
+ //
+ // The sentinel lives OUTSIDE the worktree. Inside it, the file itself shows
+ // up as untracked and `git status` stops looking clean, which would hide the
+ // property arm A exists to demonstrate.
+ let hook = repo.join(".git/fsm.sh");
+ std::fs::write(
+ &hook,
+ format!("#!/bin/sh\necho fired >> {}\nexit 0\n", sentinel.display()),
+ )
+ .expect("write hook");
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755))
+ .expect("chmod hook");
+ }
+ git(
+ &repo,
+ &home,
+ &["config", "core.fsmonitor", &hook.display().to_string()],
+ );
+
+ // ---- ARM A — the RETRACTED control, and the vacuity check ----------------
+ let (_, status_out) = git(&repo, &home, &["status", "--porcelain"]);
+ let (_, diff_out) = git(&repo, &home, &["diff"]);
+ let fired = firings(&sentinel);
+
+ assert!(
+ fired > 0,
+ "VACUOUS: the planted payload did not fire under `git status`/`git diff`, so arm B \
+ would report a working control over an experiment that proved nothing. Either this \
+ git ignores a path-valued core.fsmonitor, or the hook is not executable, or the \
+ sentinel path is not writable. Measured firing on git 2.55.0."
+ );
+ let named_by_git = status_out.contains("fsmonitor")
+ || status_out.contains("fsm.sh")
+ || diff_out.contains("fsmonitor")
+ || diff_out.contains("fsm.sh");
+ assert!(
+ !named_by_git,
+ "the retracted control REPORTED the poisoning, which contradicts C-11. If git now \
+ surfaces it, the residual's account is stale and needs re-measuring before this \
+ fixture is trusted.\n status: {status_out:?}\n diff: {diff_out:?}"
+ );
+ println!(" arm A (retracted control): payload fired {fired}x, and git named it: false");
+
+ // ---- ARM B — the DOCUMENTED control -------------------------------------
+ let _ = std::fs::remove_file(&sentinel);
+
+ let grep = Command::new("grep")
+ .current_dir(&repo)
+ .args(["-nE", RECIPE_PATTERN, ".git/config"])
+ .output()
+ .expect("grep did not run");
+ let detected = String::from_utf8_lossy(&grep.stdout).to_string();
+
+ let find = Command::new("find")
+ .current_dir(&repo)
+ .args([".git/hooks", "-type", "f", "!", "-name", "*.sample"])
+ .output()
+ .expect("find did not run");
+ let hooks_listed = String::from_utf8_lossy(&find.stdout).to_string();
+
+ let fired_after = firings(&sentinel);
+
+ assert!(
+ detected.contains("fsmonitor"),
+ "the documented control did NOT detect the poisoning. Recipe: grep -nE {RECIPE_PATTERN:?} \
+ .git/config\n got: {detected:?}\nIf the recipe in docs/RESIDUALS.md changed, change \
+ RECIPE_PATTERN with it — a fixture testing a different command than the documented \
+ one proves nothing about what an operator will run."
+ );
+ assert_eq!(
+ fired_after, 0,
+ "THE DOCUMENTED CONTROL FIRED THE PAYLOAD {fired_after}x. This is the C-11 defect \
+ recurring in the replacement recipe: a control that fires the hazard it detects is \
+ worse than no control. Retract it from docs/RESIDUALS.md before anything else."
+ );
+ println!(
+ " arm B (documented control): detected {} line(s), hooks listed {}, payload fired {}x",
+ detected.lines().count(),
+ hooks_listed.lines().filter(|l| !l.is_empty()).count(),
+ fired_after
+ );
+
+ let _ = std::fs::remove_dir_all(&root);
+}
+
+/// The recipe this fixture runs must be the recipe the document tells operators
+/// to run. Without this, the two drift and the fixture silently starts proving
+/// something about a command nobody will type.
+#[test]
+fn the_fixture_runs_the_recipe_the_document_publishes() {
+ let doc = std::fs::read_to_string(
+ PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("docs/RESIDUALS.md"),
+ )
+ .expect("COULD NOT CHECK: docs/RESIDUALS.md unreadable");
+
+ for key in [
+ "fsmonitor",
+ "pager",
+ "sshCommand",
+ "textconv",
+ "hooksPath",
+ "askpass",
+ "alias",
+ ] {
+ assert!(
+ RECIPE_PATTERN.contains(key),
+ "RECIPE_PATTERN is missing {key:?}, which the document's recipe names"
+ );
+ assert!(
+ doc.contains(key),
+ "docs/RESIDUALS.md no longer names {key:?} in its workdir-review recipe, but \
+ RECIPE_PATTERN still greps for it — the fixture and the document have drifted"
+ );
+ }
+ assert!(
+ doc.contains("find .git/hooks -type f ! -name '*.sample'"),
+ "the document's hooks half of the recipe changed; update this fixture with it"
+ );
+}
+
+// ---------------------------------------------------------------------------
+// CONTROL 2 — `oring --explain`, for the access()/open() divergence residual.
+// ---------------------------------------------------------------------------
+
+/// The divergence residual's control is `oring --explain`, and its whole point is
+/// that the *failure* points away from oring: a tool that pre-flights with
+/// `access()` gets told the path is readable, opens it, fails, and reports an
+/// error naming neither the file nor the sandbox.
+///
+/// **Establishes:**
+///
+/// * the divergence is **live** under the policy used here — `access()` says
+/// readable where `open()` is denied (arm A, the vacuity control);
+/// * `--explain` **discriminates** — it names a path the policy granted and does
+/// not name the withheld one, so an operator can see which side of the tier
+/// boundary their path fell on (arm B);
+/// * `--explain` **leaves the payload unfired** — it does not execute the
+/// target. That is the CON-7 "unfired" half for this control: the operator is
+/// diagnosing a cage, and a diagnostic that runs the command would be the
+/// C-11 defect in a new place.
+///
+/// **Does NOT establish** that an operator will *think* to run it. The residual
+/// says plainly that nothing in the failure suggests `--explain`, and that gap
+/// is a documentation problem no fixture closes.
+#[test]
+fn explain_discriminates_the_withheld_path_and_runs_nothing() {
+ if !require_or_skip("explain_discriminates_the_withheld_path_and_runs_nothing") {
+ return;
+ }
+
+ let dir = tmp_workdir("con7-explain");
+ let withheld = dir.join("withheld.conf");
+ std::fs::write(&withheld, "secret=1\n").expect("write withheld");
+ let granted = dir.join("granted");
+ std::fs::create_dir_all(&granted).expect("mkdir granted");
+
+ // A policy granting `granted/` and NOT the file beside it. `/etc` is granted
+ // read-only so the child has something real to succeed on.
+ let policy = policy_json(&[granted.to_str().expect("utf8")], Some(&[]));
+
+ // ---- ARM A — the divergence is live under this exact policy -------------
+ let (code, _) = run_probe(
+ &policy,
+ "fs-access-vs-open",
+ withheld.to_str().expect("utf8"),
+ );
+ assert_eq!(
+ code,
+ Some(10),
+ "VACUOUS: expected exit 10 (access says READABLE, open DENIED) for the withheld \
+ path, got {code:?}. 0 = both succeeded (the path is granted, so there is no \
+ divergence to diagnose and arm B proves nothing); 30 = both failed (ordinary DAC, \
+ which is not this residual). Without the divergence there is nothing for --explain \
+ to be the control for."
+ );
+
+ // ---- ARM B — the documented control -------------------------------------
+ // The sentinel lives INSIDE the granted tier so the target can actually
+ // create it; anywhere else and Landlock would deny the write, making the
+ // positive control below pass for the wrong reason.
+ let sentinel = granted.join("EXPLAIN_RAN_THE_TARGET");
+ let touch = format!("touch {}", sentinel.display());
+
+ // POSITIVE CONTROL for the "unfired" half. Without --explain the very same
+ // command must RUN and create the sentinel. Skipping this would let
+ // `!sentinel.exists()` pass whenever the target is broken — a missing
+ // /bin/sh, an unwritable path, a seal that denies the write — and report
+ // "--explain executed nothing" over a command that could never have run.
+ let _ = std::fs::remove_file(&sentinel);
+ let ran = Command::new(oring_bin())
+ .env("ORING_POLICY", &policy)
+ .env("ORING_QUIET", "1")
+ .args(["--"])
+ .arg("/bin/sh")
+ .arg("-c")
+ .arg(&touch)
+ .status()
+ .expect("spawn oring (positive control)");
+ assert!(
+ ran.success() && sentinel.exists(),
+ "VACUOUS: without --explain the target did not run (exit {:?}, sentinel {}). The \
+ 'runs nothing' assertion below would then pass over a command that could never \
+ have executed.",
+ ran.code(),
+ sentinel.exists()
+ );
+
+ let _ = std::fs::remove_file(&sentinel);
+ let out = Command::new(oring_bin())
+ .env("ORING_POLICY", &policy)
+ .env("ORING_QUIET", "1")
+ .args(["--explain", "--"])
+ .arg("/bin/sh")
+ .arg("-c")
+ .arg(&touch)
+ .output()
+ .expect("spawn oring --explain");
+ let text = format!(
+ "{}{}",
+ String::from_utf8_lossy(&out.stdout),
+ String::from_utf8_lossy(&out.stderr)
+ );
+
+ assert!(
+ out.status.success(),
+ "--explain did not exit 0: {:?}\n{text}",
+ out.status.code()
+ );
+ assert!(
+ text.contains(granted.to_str().expect("utf8")),
+ "--explain did not name the GRANTED path, so it cannot be used to tell granted from \
+ withheld. This is the discrimination half; an --explain that printed nothing useful \
+ would pass a mere absence check.\n{text}"
+ );
+ assert!(
+ !text.contains(withheld.to_str().expect("utf8")),
+ "--explain named the WITHHELD path as granted, which would send an operator looking \
+ in the wrong place.\n{text}"
+ );
+ assert!(
+ !sentinel.exists(),
+ "--explain EXECUTED THE TARGET. A diagnostic that runs the command it is diagnosing \
+ is the C-11 defect in a new place: the operator reaches for it precisely when they \
+ do not yet know what the command will do."
+ );
+
+ println!(
+ " arm A: divergence live (access ok / open EACCES). arm B: --explain named the \
+ granted tier, withheld absent, target not executed"
+ );
+ let _ = std::fs::remove_dir_all(&dir);
+}
diff --git a/tests/decoy_home.rs b/tests/decoy_home.rs
new file mode 100644
index 0000000..95e64b3
--- /dev/null
+++ b/tests/decoy_home.rs
@@ -0,0 +1,120 @@
+// SPDX-License-Identifier: BSD-3-Clause
+//! T25: a decoyed `$HOME` must not silence the `shared_cache_write=` disclosure.
+//!
+//! `$HOME` is **ambient** — `docs/RESIDUALS.md` names it beside `ORING_POLICY`
+//! and `$TMPDIR` as a source the caller's environment is not trusted to control
+//! (CON-6). `Policy::writable_shared_caches` read it and nothing else, so
+//! `HOME=/tmp/decoy` made every cache path resolve where no tier could match and
+//! the seal line reported `none` over a policy granting write on the real
+//! `~/.cargo/registry`. The confinement was never affected; the operator's view
+//! of it was, which is `MISREPORT` — worse than an opaque field, because `none`
+//! reads as a positive result.
+//!
+//! **Establishes:** with `HOME` decoyed, and separately unset, the grant is still
+//! reported.
+//!
+//! **The controls are the load-bearing part.** "Still reported" is what passes
+//! when the decoy never took effect, so the same run asserts a policy granting no
+//! cache reports `none` (the field is not reporting unconditionally) and that an
+//! honest `HOME` reports the grant (the policy grants what the test assumes).
+//!
+//! **Does NOT establish** that every ambient-source disclosure is safe. One
+//! field, one variable; `unhandled=` is derived by set difference and is not
+//! exposed this way.
+mod common;
+use common::*;
+
+use std::process::Command;
+
+/// Run oring with a chosen `HOME` and return the `shared_cache_write=` field of
+/// the seal line. `ORING_QUIET` is deliberately unset — it suppresses the line.
+fn shared_cache_field(policy: &str, home: Option<&str>) -> String {
+ let mut cmd = Command::new(oring_bin());
+ cmd.env("ORING_POLICY", policy);
+ match home {
+ Some(h) => {
+ cmd.env("HOME", h);
+ }
+ None => {
+ cmd.env_remove("HOME");
+ }
+ }
+ let out = cmd
+ .arg("--")
+ .arg(oring_bin())
+ .arg("__probe")
+ .arg("fs-read")
+ .arg("/etc/hostname")
+ .output()
+ .expect("spawn oring");
+ let stderr = String::from_utf8_lossy(&out.stderr);
+ stderr
+ .lines()
+ .find(|l| l.contains("SEALED"))
+ .and_then(|l| {
+ l.split_whitespace()
+ .find_map(|t| t.strip_prefix("shared_cache_write="))
+ })
+ .unwrap_or_else(|| panic!("COULD NOT CHECK: no shared_cache_write= field:\n{stderr}"))
+ .to_string()
+}
+
+#[test]
+fn a_decoyed_home_cannot_silence_the_shared_cache_disclosure() {
+ if !require_or_skip("a_decoyed_home_cannot_silence_the_shared_cache_disclosure") {
+ return;
+ }
+ let Some(real) = std::env::var_os("HOME").map(std::path::PathBuf::from) else {
+ eprintln!("SKIP: HOME unset in the harness, so there is no real home to decoy away from");
+ return;
+ };
+
+ let decoy = tmp_workdir("t25-decoy");
+ let real_cache = real.join(".cargo").join("registry");
+
+ // A policy granting write on the REAL home's cargo registry, named
+ // absolutely — exactly what a hand-written `--policy` would contain.
+ let granting = policy_json(&[real_cache.to_str().expect("utf8")], Some(&[]));
+ // A policy granting nothing of the sort, for the negative control.
+ let clean = policy_json(&[decoy.join("work").to_str().expect("utf8")], Some(&[]));
+
+ // ---- NEGATIVE CONTROL: the field can say `none`, so `-`/`none` is meaningful
+ let clean_field = shared_cache_field(&clean, Some(real.to_str().expect("utf8")));
+ assert_eq!(
+ clean_field, "none",
+ "VACUOUS: a policy granting no cache reported {clean_field:?} rather than `none`, so \
+ this field reports something regardless and the assertions below prove nothing."
+ );
+
+ // ---- POSITIVE CONTROL: with an honest HOME the grant IS reported ---------
+ let honest = shared_cache_field(&granting, Some(real.to_str().expect("utf8")));
+ assert!(
+ honest.contains(".cargo/registry"),
+ "VACUOUS: with an honest HOME the granting policy reported {honest:?}, which does not \
+ name the cache. The policy is not granting what this test assumes, so decoying HOME \
+ below would prove nothing."
+ );
+
+ // ---- THE DEFECT: a decoyed HOME must not change the answer ---------------
+ let decoyed = shared_cache_field(&granting, Some(decoy.to_str().expect("utf8")));
+ assert!(
+ decoyed.contains(".cargo/registry"),
+ "T25: with HOME decoyed to {} the disclosure reported {decoyed:?} — the write grant on \
+ {} is live and unreported. An ambient variable must not be able to silence a \
+ disclosure field; the confinement is unchanged either way, but the operator's view \
+ of it is not.",
+ decoy.display(),
+ real_cache.display()
+ );
+
+ // ---- and with HOME removed entirely --------------------------------------
+ let unset = shared_cache_field(&granting, None);
+ assert!(
+ unset.contains(".cargo/registry"),
+ "T25: with HOME unset the disclosure reported {unset:?}. Absence of the variable is \
+ the cheapest decoy of all."
+ );
+
+ println!(" none={clean_field} honest={honest} decoyed={decoyed} unset={unset}");
+ let _ = std::fs::remove_dir_all(&decoy);
+}
diff --git a/tests/denial_attribution.rs b/tests/denial_attribution.rs
new file mode 100644
index 0000000..1750adb
--- /dev/null
+++ b/tests/denial_attribution.rs
@@ -0,0 +1,287 @@
+// SPDX-License-Identifier: BSD-3-Clause
+//! Assert the MECHANISM, not the placement — §8 / D-4b, ATTACKS row 11.
+//!
+//! `containment_matrix`'s `unix-connect-*` cells assert `EACCES`, which at least
+//! two things produce: `RESOLVE_UNIX` refusing the lookup, or an ordinary path
+//! walk failing. So they witness "oring denied it", not "the V9 wall denied it" —
+//! leaving the floor's own evidence weaker than the claim resting on it.
+//!
+//! The kernel names its own mechanism. A Landlock denial is
+//! `AUDIT_LANDLOCK_ACCESS` (type 1423, distinct from SELinux's AVC 1400) and
+//! carries `blockers=` with the right that refused. Each cell asserts that NAME —
+//! `fs.resolve_unix` — not merely the errno.
+//!
+//! **Establishes:** for this run, the kernel attributed the denial to
+//! `fs.resolve_unix`, for both `connect(2)` and addressed `sendmsg(2)` — the
+//! second being the half the UAPI documents and oring's `connect`-shaped prose
+//! never witnessed.
+//!
+//! **Does NOT establish** that the wall is complete, or that no other path
+//! reaches a pathname socket. Two calls, one kernel.
+//!
+//! **The `read_only` grant on the socket's directory makes the cell legible, not
+//! valid** — and it is easy to write the stronger sentence by mistake. Measured:
+//! revoking it does not change the outcome. Landlock has no directory-*traverse*
+//! right, so resolving the path needs no FS grant and `RESOLVE_UNIX` is the only
+//! right that can refuse this operation. The confound the matrix was suspected of
+//! does not exist here; `blockers=` is what makes the cell evidence.
+//!
+//! **Correlation errs closed.** `--denials` joins records on the kernel-generated
+//! domain id, unique per `restrict_self`. On pid reuse `.rev()` takes the newer
+//! domain, so a recycled pid makes this **fail**, never falsely pass; a missing
+//! record names no right and also fails.
+//!
+//! **Skip loudly, never green.** These cells need a journal carrying audit
+//! records. Absent one the honest outcome is "not witnessed" — so SKIP with the
+//! reason on stderr, and hard failure under `ORING_REQUIRE_AUDIT=1`.
+#![cfg(target_os = "linux")]
+mod common;
+use common::*;
+
+use std::io::Write as _;
+use std::os::unix::net::{UnixDatagram, UnixListener, UnixStream};
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+/// Env var carrying the payload's target socket into the sealed child.
+/// Passed with `--env-keep`, since oring's default env allowlist would drop it.
+const TARGET_VAR: &str = "ORING_FERRULE_TARGET";
+/// Which call the payload should make: `connect` or `sendto`.
+const MODE_VAR: &str = "ORING_FERRULE_MODE";
+
+// --- the payload -----------------------------------------------------------
+
+/// Runs INSIDE the cage, as the sealed child. `#[ignore]` so it never executes
+/// as part of an ordinary run — a test that does nothing would be noise, and a
+/// payload that fires by accident would be worse.
+///
+/// Exits with the raw errno so the parent can read the verdict from the exit
+/// code, the same contract `oring __probe` uses.
+#[test]
+#[ignore = "payload: re-executed as the sealed child, not a test"]
+fn payload() {
+ let target = std::env::var(TARGET_VAR).expect("payload spawned without a target");
+ let mode = std::env::var(MODE_VAR).unwrap_or_default();
+ let r = match mode.as_str() {
+ // connect(2) to a pathname AF_UNIX socket.
+ "connect" => UnixStream::connect(&target).map(|_| ()),
+ // sendmsg(2) with an explicit recipient address — the half with no cell.
+ "sendto" => UnixDatagram::unbound().and_then(|d| d.send_to(b"x", &target).map(|_| ())),
+ other => panic!("unknown payload mode {other:?}"),
+ };
+ let code = match r {
+ Ok(()) => 0,
+ Err(e) => e.raw_os_error().unwrap_or(255),
+ };
+ std::process::exit(code);
+}
+
+// --- harness ---------------------------------------------------------------
+
+/// Is there an audit-carrying journal to read? Fatal under `ORING_REQUIRE_AUDIT`.
+fn audit_or_skip(test: &str) -> bool {
+ // `journalctl -n 0 ` exits 0 even when NOTHING matches — verified:
+ // `_TRANSPORT=nonexistent_xyz` also returns 0. Success therefore proved
+ // journalctl exists, not that audit records reach the journal, so this gate
+ // returned true on any systemd host and the "SKIP by default" contract
+ // below was a fiction: a journald box with auditd off got a hard failure
+ // with no env var set. Ask for one record and require it.
+ let ok = Command::new("journalctl")
+ .args(["-n", "1", "_TRANSPORT=audit", "-o", "cat"])
+ .output()
+ .is_ok_and(|o| o.status.success() && !o.stdout.is_empty());
+ if ok {
+ return true;
+ }
+ assert!(
+ std::env::var_os("ORING_REQUIRE_AUDIT").is_none(),
+ "{test}: ORING_REQUIRE_AUDIT=1 but no audit records are readable from \
+ journald — the denial attribution cells cannot witness anything. Is \
+ auditd running, and does journald carry _TRANSPORT=audit?"
+ );
+ eprintln!(
+ "SKIP: {test}: no readable audit journal (set ORING_REQUIRE_AUDIT=1 to \
+ make this fatal)"
+ );
+ false
+}
+
+struct Cage {
+ dir: PathBuf,
+ policy: PathBuf,
+ sock: PathBuf,
+}
+
+impl Cage {
+ /// A socket in a directory the policy grants `read_only` — legibility, not
+ /// isolation; see the module docs for what that grant does and does not buy.
+ ///
+ /// The policy file lives OUTSIDE every tier: `check_self_protection` refuses
+ /// otherwise, which is not a hypothetical — it refused this fixture twice
+ /// while it was being written.
+ fn build(tag: &str) -> Self {
+ let dir = tmp_workdir(&format!("attrib-{tag}"));
+ let sock = dir.join("s.sock");
+ let _ = std::fs::remove_file(&sock);
+
+ // The policy file must not sit under any granted tier, and `dir` is
+ // granted, so park it beside the test binary instead.
+ let policy = std::env::current_exe()
+ .expect("current_exe")
+ .parent()
+ .expect("exe parent")
+ .join(format!("ferrule-attrib-{}-{tag}.json", std::process::id()));
+
+ let exe_dir = std::env::current_exe()
+ .unwrap()
+ .parent()
+ .unwrap()
+ .to_owned();
+ let bin_dir = bindir();
+ let json = format!(
+ r#"{{"version":1,
+ "landlock":{{
+ "read_only":["/usr","/etc","/lib","/lib64","/proc","{d}"],
+ "read_exec":["/usr","/lib","/lib64","{x}","{b}"],
+ "write_noexec":[],
+ "write_exec":[],
+ "scope_ipc":true}},
+ "seccomp":{{"profile":"balanced"}}}}"#,
+ d = dir.display(),
+ x = exe_dir.display(),
+ b = bin_dir,
+ );
+ let mut f = std::fs::File::create(&policy).expect("write policy");
+ f.write_all(json.as_bytes()).expect("write policy");
+ Self { dir, policy, sock }
+ }
+
+ /// Seal a child running `payload` in the given mode; return (exit code, pid).
+ fn run(&self, mode: &str) -> (i32, u32) {
+ let exe = std::env::current_exe().expect("current_exe");
+ let out = Command::new(oring_bin())
+ .args(["--policy", self.policy.to_str().unwrap()])
+ .args(["--env-keep", TARGET_VAR, "--env-keep", MODE_VAR])
+ .arg("--")
+ .arg(&exe)
+ .args(["--exact", "payload", "--ignored", "--nocapture"])
+ .env(TARGET_VAR, &self.sock)
+ .env(MODE_VAR, mode)
+ .output()
+ .expect("spawn oring");
+
+ let stderr = String::from_utf8_lossy(&out.stderr);
+ let pid = stderr
+ .split_whitespace()
+ .find_map(|t| t.strip_prefix("pid="))
+ .and_then(|p| p.trim().parse::().ok())
+ .unwrap_or_else(|| panic!("no seal line in oring stderr:\n{stderr}"));
+ (out.status.code().unwrap_or(-1), pid)
+ }
+
+ /// `oring --denials