Skip to content

Latest commit

 

History

History
566 lines (448 loc) · 65.8 KB

DESIGN.md

File metadata and controls

566 lines (448 loc) · 65.8 KB

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).


1. Overview

oring is an unprivileged, self-applied, pre-exec confinement wrapper. It restricts itself — Landlock (FS + TCP + IPC scope) then seccomp-bpf — sets NO_NEW_PRIVS, scrubs FDs and its own policy env, then execve()s the target. The child inherits every restriction irrevocably; there is no supervisor and no window where the child runs less-confined than intended.

oring [--policy F] [--profile balanced] [--workdir D] -- <cmd> [args...]

It is one static-musl binary — a Landlock FS/net ring plus an in-process seccomp ring — usable as a container ENTRYPOINT or a bare-metal wrapper. Both rings are self-applied pre-execve, so it needs no daemon, no root, and no supervisor: the value over a plain --cap-drop=ALL --security-opt no-new-privileges --read-only --user 1000:1000 container is that Landlock and a custom seccomp denylist are actually installed, rather than riding Docker's default profile.

Not an orchestrator. Not a MAC authoring tool. Not a network policy engine. Not a VM boundary.


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/<pid>/{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.

3. Architecture — rings and host layering

Ring Mechanism Closes
1 Landlock: FS tiers + TCP connect/bind + IPC scope a plain hardened container has zero Landlock
2 seccomp-bpf denylist (+ x32 guard) a plain hardened container rides the runtime's default seccomp
pre-exec NO_NEW_PRIVS, DUMPABLE=0, signal-mask reset, FD-scrub, env-scrub NNP required before seccomp; FD-scrub = CVE-2024-21626

Layering is set-intersection — an op must pass every active layer:

effective = host_MAC ∩ cap-drop=ALL ∩ no_new_privs ∩ userns(uid1000)
          ∩ read-only-rootfs+tmpfs ∩ runtime-default-seccomp
          ┌───────────────────────────────────────┐
          ∩ oring Landlock  (ring 1)               │  ORING
          ∩ oring seccomp   (ring 2)               │
          └───────────────────────────────────────┘

Consequences: oring is additive and safe when the runtime already confines (intersection only tightens) and must not assume outer layers exist (standalone on the Xen rig = no SELinux, maybe no userns/pidns). Its core (Landlock FS + NNP + seccomp) stands alone and fails closed.

Intersection invariant (load-bearing): Landlock only ever tightens. It can never re-grant a right a stricter outer layer denies. A write_exec tier is only realizable on a mount that lacks noexec; Execute granted on a noexec mount (e.g. a container's noexec /tmp) is a silent no-op (§9).


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<Vec<u16>>. 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

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

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).

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<Infallible>, 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/<pid>/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.

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.

{
  "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" }
}
#[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<PathBuf>,      #[serde(default)] read_exec: Vec<PathBuf>,
  #[serde(default)] write_noexec: Vec<PathBuf>,   #[serde(default)] write_exec: Vec<PathBuf>,
  #[serde(default)] egress_tcp: Option<Vec<u16>>,// None=unrestricted, Some([])=deny-all
  #[serde(default)] ingress_tcp: Option<Vec<u16>>,
  #[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 <path> 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] -- <command> [args...]

  --policy <FILE>       policy JSON file (wins over env)
  --profile <NAME>      baked profile: balanced (default) | strict
  --workdir <DIR>       inject DIR into the profile's writable workdir tier
                        (write_exec for balanced; write_noexec for strict)
  --exec-tmp <DIR>      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 <MODE>         /proc grant: full (default) | self | none   (self closes peer-procfs)
  --env-clear           pass NO env to the child except --env-keep vars
  --env-keep <NAME>     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 <PID>       report what Landlock DENIED to a sealed run, from the kernel's V7 audit
  --since <WHEN>        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=<slug> (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 <pid> 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 <DIR> 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 lstats 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 <MODE> = 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.

9. Deployment — container entrypoint + standalone

Container entrypoint

oring runs as a container ENTRYPOINT: the runtime sets the hardened container (cap-drop, no-new-privileges, read-only rootfs, non-root uid), and oring adds the Landlock + seccomp rings in-process before execveing the workload. Two things the integrator owns: (a) the policy must name container paths, not host paths — Landlock resolves each rule inside the container mount ns, so a host path is silently ENOENT-skipped; (b) a write_exec tier only takes effect on a mount without noexec (intersection: the mount wins), so exec-scratch needs an exec-capable mount. Deliver the policy via ORING_POLICY (env) or --policy (file), and keep a reaper as PID 1 (e.g. --init) since oring is 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/<pid>/{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.

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.


10. Rust crate structure + deps

Single crate, bin+lib split (core is unit-testable and reusable in-process on the Xen rig).

src/ main.rs      (arg split, load, call pipeline::seal, execve)
     lib.rs error.rs
     policy.rs    (serde + validation; CONFIG edge — parent-supplied, pre-attacker)
     landlock.rs seccomp.rs preexec.rs denials.rs
     sys.rs       (ALL raw FFI — each site an `unsafe {}` block with a SAFETY: note)
     pipeline.rs  (THE ORDER)  profile.rs (balanced + strict constants)
tests/ deny_fs deny_net deny_syscall deny_x32 deny_env deny_strict exec_tmp
       signal_dispositions policy_props seccomp_smt containment_matrix common/
[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)
libc        = "0.2"      # raw syscalls (prctl/close_range/execve/sigprocmask/seccomp) + SYS_*/flag consts
serde       = { version = "1", features = ["derive"] }
serde_json  = "1"
[dev-dependencies]
insta    = "1"
proptest = "1"           # property tests over the policy layer
libc     = "0.2"         # integration tests are separate crates; the containment jail needs it directly
[profile.release]
opt-level="z"  lto=true  codegen-units=1  strip=true  panic="abort"   # fail-closed: no partial-seal-then-exec
[build-dependencies]
# build.rs: capture git SHA for the seal line (§12), SOURCE_DATE_EPOCH-safe
  • libc, not rustix/nix — seccompiler already pulls libc in and libc is musl-static-clean, so using it for the raw syscalls avoids a second FFI dependency. (The original §10 sketch specified rustix(linux_raw); the shipped crate uses libc — all raw FFI is isolated in src/sys.rs, each site in its own unsafe {} block with a SAFETY: note; unsafe_code is denied crate-wide outside that module.)
  • No clap — grammar is a -- scan + a few flags (~40 LOC hand-rolled). clap is the largest subtree in a binary whose point is being tiny.
  • thiserror in lib (callers pattern-match which ring failed), anyhow at the bin edge. run() -> Result<Infallible> encodes fail-closed at the type level.
  • edition 2021; rust-version = "1.80" (Cargo.toml), built --locked.
  • Single-threaded through the whole pipeline — Landlock restrict_self and seccomp apply_filter are per-thread; a second thread = only the caller sealed. Hard architectural constraint, and enforced: seal() refuses if /proc/self/task shows >1 thread, so an embedder calling it from a threaded process fails closed rather than leaving siblings unconfined while printing SEALED (the shipped CLI is single-threaded; this guards the in-process reuse path).

11. Testing strategy

a. Unit (runs anywhere incl. macOS): serde round-trip, malformed JSON → PolicyParse, deny_unknown_fields rejection, ../symlink/non-absolute rejection, port bounds, Option net None-vs-Some([])-vs-Some(ports) distinction, tier→AccessFs bitmask correctness.

b. Rule-table snapshot (deterministic, host-agnostic): insta-snapshot oring's OWN logical &[SyscallRule] (syscall nr → arg conditions incl. MaskedEq masks → action → errno), serialized deterministically (sorted by nr). This is where a denylist change appears as a one-line, legible diff — do NOT snapshot the compiled sock_filter[] (opaque codegen that churns on every seccompiler bump, training reviewers to blind-accept). Keep ONE tiny BPF-bytes canary, commented // canary: churns on seccompiler bumps; NOT a policy signal, that asserts the x32 guard's JSET 0x40000000 branch is present so the splice can't silently regress.

c. Integration (cfg(target_os="linux")) — the real assertions via self-exec probe (the MVP proof-of-boundary): oring re-execs itself with a hidden arg; child performs the op, exits with errno; parent asserts the exact errno.

  • 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).

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.

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.

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).


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.
  • 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 <version>+<sha> sealed ring1_fs=… abi=V<n> 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).


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/<self-pid> + 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.

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.


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-<V6; moved to conditional stop in §2 Scope no-op on every <6.12 host; container boundary is netns/pidns, standalone has none [18]
D-16 V7 audit logging Wired best-effort (log_new_exec/log_same_exec) denial visibility with no supervisor — dropping it ships blind [16]
D-17 O_PATH target fd Dropped; resolve to absolute string fexecve/execveat (the only fd-exec) is deliberately blocked, so the fd can't close the TOCTOU; no attacker in either deployment [23]
D-18 Delivery precedence 3 sources: --policy > 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]