Skip to content

release: Compartment 1.4.0 (compartment-bpf 0.8.0) - #5

Merged
nmicic merged 202 commits into
mainfrom
release-1.4
Sep 7, 2026
Merged

release: Compartment 1.4.0 (compartment-bpf 0.8.0)#5
nmicic merged 202 commits into
mainfrom
release-1.4

Conversation

@nmicic

@nmicic nmicic commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Release Compartment 1.4.0 and the optional compartment-bpf 0.8.0 module from release-1.4 into main.

This release strengthens profile trust and sandbox enforcement, adds per-file and TCP-port Landlock rules, and fixes container startup and isolation under real root. It also adds limited-root login profiles and opt-in BPF self-protection, with expanded regression tests and Ubuntu CI coverage.

Changes to review before upgrading:

  • Profile parsing is transactional, security switches may only tighten policy, all group-writable policy sources are refused, and home-directory profile discovery requires --user-profiles.
  • Inherited descriptors are closed before sandbox filters are installed, including high descriptors on the fallback path. Container Landlock path validation also runs after pivot_root.
  • make install no longer installs example policies; use make install-profiles explicitly.
  • compartment-bpf moves to ABI 0x0008, adds ACL/mount/ioctl enforcement and optional --self-protect / --authorize-loader, and requires root-owned maintenance images. Timestamp updates on directly no-chmod-sealed files are now denied to non-actors. See compartment-bpf/CHANGELOG.md and LIMITATIONS.md for migration details and remaining trust boundaries.

Release review also corrected the Ubuntu CI bpftool setup, restored AppArmor guidance when HARD mode is unavailable, refused unsafe fixture roots before cleanup, and aligned the default kernel matrix with its eight documented versions. Both automated review threads are resolved.

Validation at release head 1a4d7ebf09f259222b6f467d23b40424832ea457:

  • git diff --check passes.
  • Core ShellCheck and executable modes pass for all 43 core shell files; the BPF ShellCheck baseline passes for 123 files with no new or stale finding pairs. Test-discovery and workflow YAML checks pass.
  • make -C compartment-bpf check-coverage-static passes, including all 11 coverage-map self-tests; 12 declared exemptions remain.
  • All eight CI jobs pass: build/rootless, root, and ASan/UBSan suites on Ubuntu 22.04 and 24.04, plus BPF build/static gates and repository lint.
  • All CodeQL analyses and the aggregate CodeQL check pass; all 12 PR checks are green.
  • A focused local fixture-safety check verifies six unsafe path/environment cases are refused without deleting a sentinel, and a dedicated fixture directory can be populated twice successfully.
  • Full BPF enforcement/release tests, the kernel VM matrix, and the stability soak were not rerun during this PR review. The repository records a 1,024-cycle BPF stability soak on an earlier commit (599d5a2); that historical result is not validation of the current release head.

Every strdup() return in the profile loader and both CLI parsers was
used unchecked. Under memory pressure a path rule, environment name,
capability name or rootdir silently became NULL and was later passed to
open()/strcmp() — a sandboxing tool must never run with a partially
materialised policy.

Add xstrdup(), which prints a diagnostic and exits 1 on failure, and
route every policy-string duplication through it.
sscanf("%63s %1023[^\\n]") consumed the rest of the line verbatim, so a
trailing comment became part of the value:

  ro /usr           # system libraries
  env-deny LD_PRELOAD   # linker injection

parsed as the path "/usr           # system libraries" (no Landlock rule
installed, yet the rule count still reported it) and the environment name
"LD_PRELOAD   # linker injection" (LD_PRELOAD not stripped at all).

A '#' that begins a whitespace-separated token now starts a comment and
the remainder of the line is dropped; the value is right-trimmed. A '#'
inside a token stays literal so paths containing '#' still parse.
…nt shadowing, commit-time strict marker (ABI v0.8)

Security review pass over the BPF enforcement surface: four coverage gaps
closed and one strict-launch design flaw fixed. No struct layout change;
ABI 0x0007 -> 0x0008 so a v0.7 audit consumer fails loud on the new code.

BPF (compartment.bpf.c), 21 -> 26 hooks (27 programs, because
inode_setattr still ships the modern/legacy wrapper pair):

- inode_set_acl / inode_remove_acl: since Linux 6.2 POSIX ACL writes go
  through vfs_set_acl()/vfs_remove_acl(), which never reach the xattr
  hooks, so `setfacl -m/-x/-b` rewrote permission bits on a no-chmod
  seal. Mirrors the setxattr/removexattr programs (per-inode +
  parent-dir rule).
- file_ioctl: gate FS_IOC_SETFLAGS / FS_IOC32_SETFLAGS /
  FS_IOC_FSSETXATTR / FS_IOC_SETVERSION (chattr +i/+a, project ids)
  under no-chmod; every other ioctl returns after the cmd compares.
- sb_mount / move_mount: deny attaching a new mount on a sealed inode or
  inside a sealed subtree (ACTION_DENY_MOUNT = 14). MS_REMOUNT and
  propagation-only changes pass; actor allowlists honoured;
  bind-mounting FROM a sealed path elsewhere stays allowed and stays
  enforced.
- inode_setattr: ATTR_ATIME|ATTR_MTIME without ATTR_SIZE (touch -d,
  utimensat) is chmod-class on directly sealed inodes, matching the v0.5
  parent-dir rule. Truncation stays write-class. The rule lands in
  comp_inode_setattr_impl(), so both the 3-arg (7.0+) and 2-arg (pre-7.0)
  wrappers inherit it.
- Strict-launch marker set/keep/clear moved from bprm_check_security to
  bprm_committed_creds.

Loader (compartment-bpf.c): pin_links pins 26 links; KNOWN_LINK_NAMES
gains the five new names and keeps the legacy comp_bprm_check_security so
--unpin can sweep a v0.4..v0.7 pin tree; action_name knows DENY_MOUNT.
Makefile check-actor-hook gates each v0.8 hook, its PIN_LINK and its
unpin-table entry, and the 0x0008 version literal.

Tests: 07-mount-bind-decoy now asserts the deny (it documented the gap);
new witnesses 16-setfacl-no-chmod, 17-mount-inside-sealed-dir,
18-chattr-no-chmod, 19-utimes-no-chmod (each with an unsealed control and
an audit-line check); mesh 3.23(a) flips KNOWN-GAP -> ENFORCED with a
control bind; observe T17b ABI literal bumped. strict-launch/run.sh
comments now name bprm_committed_creds (restores the enforce-hook
witness) and tests/observe/run.sh names lsm.s/bprm_check_security as the
observe-side exec hook, so the coverage gate stays green in both
directions with no new exemption rows: enforce-hook 25/1/26,
action 12/2/14, TOTAL 61/12/73.

Docs: README hook table and limits, LIMITATIONS (the two mount rows
replaced by one residual row; new compat-ioctl and marker-vs-failed-exec
rows), HOWTO hook count and no-chmod coverage, CHANGELOG v0.8.
.gitignore: observe build artifacts and the tests/sealprobe path typo.

Ported from the private review branch onto the public tree. Conflict
resolutions: compartment.bpf.c header comment (v0.8 hook list),
LIMITATIONS.md (public's v0.6 recursive-subtree "In-place writes" row
kept, v0.8's mount-residual row taken), CHANGELOG.md (v0.8 entry inserted
above v0.7.3), tests/mesh/run-mesh.sh section 3.23 wording (v0.8), and the
experimental/ hunk dropped (that tree is not part of the public repo).
The fixture tree lived at the fixed, world-guessable path
/tmp/compartment-fixtures.  Any local user could pre-create it, or swap
files between the moment a test writes them and the moment the sandboxed
probe reads them, so an assertion could be steered from outside the run.
It also broke tests: /tmp is mapped rw (W^X, no exec) by the built-in
ai-agent profile, so the probe copy under /tmp could not be exec'd and
the assertions that depend on it never ran.

Each run now gets its own `mktemp -d` root under ${HOME}/.cache (rwx in
the ai-agent profile, so the probe is exec'able) which is removed by an
EXIT trap.  tests/profiles/*.conf become templates: @FIXTURES@ is
substituted with the per-run root and the rendered copies are written to
$FIXTURES/profiles/.

New tests/scripts/lib/harness.sh holds the shared pieces: repo/fixture
discovery, pass/fail/skip counters, cleanup registration and a summary
line.  run_all.sh creates one root for the whole run and exports
COMPARTMENT_FIXTURES so every suite reuses it.

Suite counts unchanged (52 + 6, green); one of the two "probe never ran"
warnings in the matrix suite disappears because the probe can now execute
under the built-in strict profile.
Every append into paths[], blocked_syscalls[], allowed_syscalls[],
env_deny[], env_allow[], cgroups[], cap_allowed_names[] and
mount_masks[] was a bare 'if (count < MAX)' with no else in the built-in
profiles and in both CLI parsers, so entries past the limit vanished
without a word. With MAX_BLOCKED_SC at 64 the built-in ai-agent profile
already used 43 slots: a handful of --block flags silently deleted
pidfd_getfd, mount_setattr, ioperm and iopl from the policy.

Raise MAX_BLOCKED_SC to 256 (519 BPF instructions at the limit, well
under BPF_MAXINSNS) and route every append through cfg_add_*() helpers
that print which entry overflowed and refuse to run. Verified that a
full 256-entry deny-list still loads and enforces (Seccomp: 2).
compartment-root could not start at all with a plain-directory rootdir:
every run died with "mount proc: Operation not permitted".

The child did pivot_root, immediately umount2("/.pivot_old", MNT_DETACH),
and only then mount("proc", "/proc", "proc", ...).  Mounting a fresh
procfs from inside a user namespace is gated by the kernel's
mount_too_revealing() check, which requires a fully visible mount of the
same filesystem to already exist in the current mount namespace.
Detaching the old root removed the last visible procfs, so the mount was
refused with EPERM and the kernel logged "VFS: Mount too revealing".

Reorder the child so /proc (and, later in this series, /sys and the /dev
device nodes) are set up while the old root is still attached, and detach
it afterwards.  The comment on the pivot_root block explains why the
order matters so it does not regress.

Verified on Ubuntu 24.04 / 6.8.0-139: a busybox rootdir with no
pre-mounted /proc now runs the target command to completion.
The "minimal /dev" was empty.  vfs_mknod() gates character-device creation
on capable(CAP_MKNOD) against the initial user namespace, so all five
mknod() calls in the CLONE_NEWUSER child returned EPERM every single run —
and their return values were discarded.  A container could not write to
/dev/null or read /dev/urandom, and the /proc/kcore and /proc/sysrq-trigger
masks (which bind /dev/null over them) failed with ENOENT for the same
reason.

Bind-mount null, zero, full, random, urandom and tty from the old root's
/dev onto the container's tmpfs instead — allowed in a user namespace —
creating the empty target file first.  Every bind is fatal on failure; so
is the /dev tmpfs mount itself, which previously fell back to whatever
/dev happened to exist inside rootdir.

Verified on Ubuntu 24.04 / 6.8.0-139: all six nodes present with the right
major/minor, DEVOK from the probe, and /proc/kcore now reads as 0 bytes.
landlock, seccomp, no-new-privs and env-sanitize could be turned off from
a profile file, and main() gated every mechanism on those flags with no
floor. A profile is data: it can live somewhere the sandboxed process
reaches, and 'seccomp off' in it was a complete escape.

A profile may now only turn a mechanism on. 'off' (or no/false/0) is a
fatal parse error naming the command-line flag that does have the
authority: --no-landlock, --no-seccomp, --no-env-sanitize. no_new_privs
has no escape hatch and is now genuinely always on, as both man pages
already claimed.

tests/profiles/ relied on 'landlock off' / 'seccomp off' to isolate one
mechanism per profile. Those that only needed an empty rule set now keep
the mechanism on with no rules; the two that needed an unrestricted
filesystem grant 'ro /' + 'rw /tmp' instead, which is what they meant.
Only /proc/sys and /proc/acpi were actually masked; /proc/keys,
/proc/timer_list, /proc/sched_debug, /proc/kallsyms, /proc/modules,
/proc/bus, /proc/fs, /proc/irq, /proc/scsi and /proc/latency_stats were
readable inside the container, and /sys was neither mounted nor masked.
Every mask was a discarded `(void)mount(...)`, so failures were invisible.

Add mask_path(): an empty read-only tmpfs over a directory, a bind of
/dev/null over anything else.  A target that does not exist on this kernel
is skipped (/proc/timer_stats went away in 4.11, /proc/latency_stats,
/proc/scsi and /proc/acpi are config-dependent); a target that exists and
cannot be masked is now fatal, for the built-in set and for --mount-mask
alike.

/sys is mounted as a fresh read-only sysfs, with /sys/firmware masked on
top.  Like procfs this is subject to mount_too_revealing(), so it happens
while the old root is still attached; if the kernel refuses it, /sys is
covered with an empty read-only tmpfs instead, so the container never gets
a writable or host-shared sysfs.

Verified on Ubuntu 24.04 / 6.8.0-139: no UNMASKED line from the probe loop,
/sys mounted ro,nosuid,nodev,noexec, /sys/firmware empty.
Six of the 52 assertions passed without testing anything.

1-2. Profile inheritance ("strict: ptrace blocked", "strict file: ptrace
     blocked").  compartment-user refused to exec the probe (W^X on the
     fixture path), the run produced no output, and expect_not_contains
     "rc=0" trivially held on an empty string.  The suite printed a
     WARNING and counted a PASS.
3-5. seccomp deny-list (userfaultfd, perf_event_open, io_uring_setup).
     expect_blocked accepted `grep -q "errno=1\|EPERM\|rc="`, and every
     RESULT line contains "rc=", so the only real check was "not rc=0".
     All three syscalls are refused by host policy on a stock Ubuntu
     kernel, so they passed with no seccomp filter at all.
6.   FD inheritance grepped for "fd=", which deny_probe never prints (it
     prints "FD 9 -> /etc/hostname" and "count=4"), so the count was
     permanently 0 and "0 <= 5" always held.

Fixes:

* deny_probe prints "PROBE_START op=<op> pid=<n>" first and sets stdout
  line buffered, so the marker survives a SIGSYS kill.  run_probe sets
  PROBE_RAN from that marker and every expect_* helper fails when the
  probe did not execute.  capture() gives the same guarantee to the
  invocations that cannot use run_probe.
* expect_blocked requires the exact "rc=-1 errno=N name=NAME" and runs a
  baseline of the same op with no sandbox: if the unsandboxed run already
  fails with the errno we expect, the case is skipped as unattributable
  instead of counted as a pass.
* The FD case opens fd 9 as a canary, requires the PROBE_START marker,
  requires count= to match the number of "FD " lines, requires exactly 3
  descriptors and requires "FD 9 ->" to be absent.
* The file-based inherit case gets --exec on the fixture root so the
  probe can run at all; the seccomp policy under test is unchanged.
* The shell-replacement case stashes deny_probe as the "real shell" so it
  can prove the shell was exec'd rather than only that rc was 0.
* Four self-tests assert the helpers themselves report FAIL (or SKIP)
  when fed a probe that never runs, a syscall with no filter installed,
  and a syscall the host already refuses.

deny_probe also gains sc_ptrace_x32 for the x32-ABI bypass test.

Proof the assertions now bite (commands in the report):
  --no-seccomp injected into run_probe: pass=45 fail=10 skip=1, rc=1
    (was: all green)
  COMPARTMENT_FIXTURE_BASE=/tmp (probe unexecutable): pass=51 fail=4,
    rc=1, each failure reading "probe never ran: rc=127 ... Permission
    denied" (was: WARNING + PASS)

Suite is 55 pass / 0 fail / 1 skip on this host; the skip is
userfaultfd, which vm.unprivileged_userfaultfd=0 already denies.
…o-new-privs

Two ways a setuid-root binary planted inside rootdir could still elevate.

The recursive bind of rootdir onto itself inherited the host mount's
flags, so the container root was mounted without nosuid or nodev.  Follow
it with a remount that sets both.  MS_REMOUNT|MS_BIND changes only the
per-mount flags — filesystem type, source and data are ignored and the
superblock is untouched, so the host's view of the same filesystem is
unaffected — but mount(2) ignores MS_REC on a remount, so it covers the
top mount only.  Use mount_setattr(2) with AT_RECURSIVE when the kernel
has it (5.12+) so submounts dragged in by MS_REC are covered too, and fall
back to the plain remount otherwise.  Failure is fatal either way.

Second, `no-new-privs off` in a profile was silently honoured even though
--help advertises PR_SET_NO_NEW_PRIVS as always on.  compartment-root now
refuses to disable it: main() forces the flag back on with a diagnostic,
and the prctl in the child is unconditional.

Verified on Ubuntu 24.04 / 6.8.0-139: a 4755 root-owned binary in rootdir
reports uid=999 euid=999, the root mount line shows rw,nosuid,nodev, and a
profile with `no-new-privs off` still yields NoNewPrivs: 1.
load_profile_file() mutated the Config in place and returned -1 on the
first bad line without rolling back, and both callers then treated -1 as
"not found" and layered a built-in on top — reporting the result as
"profile: ai-agent (built-in)" while the rejected file's already-parsed
rules were still in force. A deliberately malformed profile was
therefore a better attack than a valid one: 'rwx /' followed by a bad
directive injected a whole-filesystem rule into what the tool announced
as the built-in policy.

Parse into a scratch Config and commit only on success, and give the
loader a three-way result so "not found" (fall back to a built-in) is
distinguishable from "found but invalid" (refuse to run, non-zero exit,
--dry-run included). A rejected inherited profile propagates the same
way. Shell-replacement mode falls back to the built-in instead of
aborting, because it must never lock the user out — the transactional
loader guarantees the rejected file contributed nothing.

Also stop printing an invented '~/.config/compartment//abs/path.conf'
search line when an explicit --profile path is missing.
compartment-root's seccomp default was a no-op.  It has no built-in
policy of its own, so unless the operator passed --block/--seccomp-allowed
or a profile that did, apply_seccomp() printed "seccomp enabled but no
syscalls to block" and returned success: every container ran with
Seccomp: 0, byte-identical to --no-seccomp, while --help and README listed
seccomp BPF among the tool's enforcement layers.

Add apply_default_seccomp_denylist(), installed when neither an allow-list
nor a deny-list was supplied — 43 syscalls on x86-64, mirroring
compartment-user's built-in: ptrace and process_vm_*, the mount and
namespace family, the new mount API, handle-based file access, module
loading and reboot, the keyring, bpf/userfaultfd/perf_event_open/io_uring,
host-wide time and swap, pidfd_getfd, ioperm/iopl.  None of them is needed
by a container after exec; all namespace and mount setup happens before
the filter is installed.

The list lives in compartment-root.c rather than compartment.h on purpose:
the profile loader in that header is being reworked separately, so this
keeps the diff to one file.  Fold the two copies together afterwards.

A run that asks for seccomp and would end up with an empty filter is now
refused instead of silently running unfiltered.  --help states the default
and its size; --dry-run reports "built-in default" on the seccomp line.

Verified on Ubuntu 24.04 / 6.8.0-139: Seccomp: 2 by default, Seccomp: 0
only with --no-seccomp.
Adding a test meant editing run_all.sh, which serialises everyone working
on the suite. Both runners now discover their suites instead.

* run_all.sh runs every executable tests/scripts/rootless.d/*.sh as its
  own suite, in glob order, with the same PASS/FAIL accounting and
  exit-code semantics as the hand-wired suites.
* tests/scripts/run_root_tests.sh does the same for
  tests/scripts/root.d/*.sh and refuses to run as a non-root user
  (exit 2). New target: `sudo make test-root`.
* A non-executable *.sh in either directory is reported as a failed
  suite, not skipped: a test that never runs is precisely the defect
  this harness exists to catch.
* The final summary now names the suites that failed.

Each directory gets a README with the contract (independent, executable
with a shebang, one `SUMMARY <name>: pass=N fail=N skip=N` line, non-zero
exit on any failure, cleans up on failure too, skip rather than fail on a
missing precondition, assert that the thing under test actually ran) and
one tiny smoke script that proves discovery works and nothing else.

.gitignore needed a fix first: the `*.d` build-artifact pattern matches a
directory as happily as a file, so `tests/scripts/root.d/` and
`tests/scripts/rootless.d/` were ignored in their entirety — every suite
dropped into them would have been invisible to git.

Verified on this host:
  ./tests/scripts/run_root_tests.sh          -> rc=2, "must be run as root"
  sudo make test-root                        -> 1 suite, 4 pass, rc=0,
                                                no root-owned leftovers
  ./tests/scripts/run_all.sh --quick         -> 3 suites, 0 failed
  non-executable script dropped in rootless.d -> "SUITE NOT EXECUTABLE",
                                                Suites failed: 1, rc=1
  failing script dropped in rootless.d        -> Suites failed: 1, rc=1
…tls, make the strict marker sleepable

Three defects found reviewing the v0.8 enforcement surface against
fs/namespace.c, fs/ioctl.c and kernel/bpf/bpf_lsm.c on both target
kernels (6.8 and 7.0), plus the regression witnesses for each.

sb_mount: the propagation-flag exemption was a real bypass. path_mount()
tests the flag bits in a fixed order and the first match wins:

  security_sb_mount() -> may_mount() -> MS_REMOUNT|MS_BIND -> MS_REMOUNT
  -> MS_BIND (do_loopback) -> propagation bits (do_change_type)
  -> MS_MOVE -> do_new_mount()

MS_BIND is tested BEFORE the propagation bits, so
`mount(src, "/sealed/dir", NULL, MS_BIND|MS_PRIVATE, NULL)` was exempted
by the gate and executed as a bind mount by the kernel — and
do_loopback() reaches graft_tree() without calling security_move_mount(),
so the second hook missed it too. One extra flag bit defeated the whole
mount gate. The exemptions now mirror the kernel: MS_REMOUNT exits early,
the propagation bits exit only when MS_BIND is clear, everything else is
gated. tests/bypass/17 W5 drives the exact shape through a raw mount(2)
(mount(8) cannot emit it in one syscall).

file_ioctl_compat: a 32-bit process on a 64-bit kernel enters
COMPAT_SYSCALL_DEFINE3(ioctl), which calls security_file_ioctl_compat()
and never security_file_ioctl() — so a 32-bit `chattr +i` walked straight
past the v0.8 ioctl gate. The body is factored into
comp_file_ioctl_impl() and shared by a second SEC("lsm/file_ioctl_compat")
program. The hook was backported into stable 6.6.y, so a uname test is
unreliable: the loader BTF-probes bpf_lsm_file_ioctl_compat and
autoload-gates the program, mirroring select_inode_setattr_variant().
pin_links() pins it only when the link exists (a kernel without the hook
routes compat ioctls through file_ioctl, which is already covered);
KNOWN_LINK_NAMES, check-actor-hook and the pinned[] assert (>= 27) follow.
tests/bypass/18 W3 is a gcc -m32 witness, skipped in-line if multilib is
absent so the script still emits exactly one label.

bprm_committed_creds is attached sleepable (lsm.s/). It is in
sleepable_lsm_hooks on both 6.8 and 7.0, and sleepable context is what
makes bpf_task_storage_get(F_CREATE) a blocking allocation. Under a
plain lsm/ attach the marker could fail to allocate under memory
pressure: fail-closed, but silent. The remaining failure now bumps a new
marker_set_fail_total counter (13th counter: freeze table == 18,
TM_MIN_COUNTERS floor 13, COUNTERS.md catalogue, --stats table,
KNOWN_MAP_NAMES, pin_counter_maps, HOWTO counter table). strict-launch
SL-11 is its negative witness — a nonzero value means markers were
silently dropped for a reason unrelated to policy.

Also: comp_move_mount's comment now states that the hook is NOT on the
MS_MOVE path (do_move_mount_old() calls do_move_mount() directly), so
MS_MOVE is covered by comp_sb_mount and only there; and tests/bypass/17
gains W6 (open_tree(OPEN_TREE_CLONE) + move_mount(2)), the only witness
that reaches security_move_mount() without first passing
security_sb_mount(). Without it comp_move_mount could be entirely broken
and every other witness would still pass.

check-coverage-static: enforce-hook 26/1/27, action 12/2/14,
counter 13/0/13, TOTAL 63/12/75, exemptions still 12 — no new rows.
…v0.8 timestamp rule

19-utimes-no-chmod gains two witnesses.

W3 drives `touch -a -d` on the sealed target. vfs_utimes() clears
ATTR_MTIME for UTIME_OMIT, so an atime-only touch leaves ATTR_ATIME set
on its own — this proves the new rule tests (ATTR_ATIME|ATTR_MTIME) and
not (ATTR_ATIME&ATTR_MTIME). atime is compared across the deny.

G is the over-deny guard for the behaviour change: `cp -p` onto an
unsealed destination ends with a utimensat() carrying
ATTR_ATIME|ATTR_MTIME|ATTR_TIMES_SET, and it must keep working. rsync -a,
tar -x and install -p all finish the same way, so a regression that made
the timestamp rule fire outside the seal map would break ordinary
archival copies host-wide. The mtime is compared byte-for-byte, not just
the exit code.

The pre-existing sealprobe chmod witness renumbers W3 -> W4.
…-map

Two user-namespace gaps.

/proc/<pid>/setgroups was never set to "deny" before gid_map was written,
so setgroups(2) stayed available inside the namespace and a process could
drop a supplementary group that carried a negative permission.  The parent
now writes "deny" first — skipped when the policy explicitly keeps
CAP_SETGID, since such a container needs setgroups() to work.  Because the
child can no longer clear its own supplementary groups once that is done,
the parent clears them before clone(); otherwise the child would inherit
the invoking root's groups, which the identity gid map keeps meaningful
inside the container.

The uid/gid map was hard-coded to the identity map "0 0 65536", so the
user namespace gave a capability boundary but no uid isolation: container
uid 0 is host uid 0 for DAC.  Add optional `uid-map` and `gid-map` profile
directives taking "<container-start> <host-start> <count>", e.g.
"uid-map 0 100000 65536" to map the container onto a subuid range.  The
default is unchanged, so existing profiles behave exactly as before.

A shifted map needs one more thing to work: the child is cloned from host
root, whose uid is not mapped into the new namespace, so files it creates
are owned by an unmapped uid and open() then fails EOVERFLOW.  The child
now switches to uid 0 *of the new namespace* before touching anything — a
no-op under the identity map, and capability-preserving in both cases
since cap_emulate_setxuid() only clears capabilities when moving away from
the namespace's root uid.

mkdir(rootdir/.pivot_old) is fatal on failure too; it used to be discarded
and left pivot_root() reporting a bare ENOENT.

--dry-run prints both maps.  Verified on Ubuntu 24.04 / 6.8.0-139: default
run has empty Groups and an identity map; "uid-map 0 100000 65536" over a
rootdir chowned to 100000 gives uid=0 inside and host uid 100000 outside.
$HOME/.config/compartment/<name>.conf was searched before
/etc/compartment/, with no check on who owns the file. The built-in
ai-agent profile grants the sandboxed process read, write and execute on
$HOME, so an agent could write its own next-run policy and every later
invocation of the same command line ran with no_new_privs, Landlock,
seccomp and env-sanitize all off. compartment-root took the same path
while running as root, which handed an attacker who controls HOME the
rootdir, username, cap-allow and seccomp policy of the container.

Search order is now: explicit --profile path, then
/etc/compartment/<name>.conf, then $HOME/.config/compartment/<name>.conf
for compartment-user only and only behind the new --user-profiles flag,
then the built-in. A profile loaded from /etc drops the user-search flag,
so a system profile can never pull in a user file through 'inherit'.
compartment-root never consults $HOME, and neither does
shell-replacement mode.

Every profile file is validated on the fd it is read from: regular file,
owned by root or by the caller's real uid (root only for
compartment-root), not group- or world-writable, in a directory that
passes the same check. Symlinks stay usable — the target and the
directory it really lives in are checked too — and a sticky directory may
be world-writable, since the sticky bit is what stops a third party
replacing the file. Diagnostics name the offending path and the chmod
that fixes it.

The resolved profile source is printed under --verbose and recorded in
the audit line as source=.

make_fixtures.sh normalises the mode of the shipped .conf files, which
git does not track and which a umask-002 checkout leaves group-writable.
The hand-rolled pre-scan matched only the exact argv tokens "--profile"
and "-p", while getopt_long's own 'p' case was a no-op comment. So
--profile=FILE and -pFILE silently discarded the entire policy: no
seccomp allow-list, no cap-allow, no mount-mask, no env-deny, no
loopback, no cgroup, no error, no diagnostic, exit 0. That is the form
systemd units and Ansible templates tend to produce.

Replace it with two getopt_long passes over the same optstring: the first
resolves -p/--profile only (so the profile still loads before the rest of
the command line and CLI options override it), the second applies
everything else. All four spellings now behave identically, including
clustered short options such as -dvpNAME. A second, different --profile
is an error rather than a silent choice.
Covers behaviour the main matrix never touched, all of it rootless and all
of it green against the current code:

  A  seccomp — x86-64 x32-ABI bypass (ptrace|0x40000000) is killed with
     SIGSYS (rc=159) while the same call unsandboxed is only ENOSYS, so
     the kill is attributable to the filter; exact-errno assertions for
     ptrace and process_vm_readv, each gated on an unsandboxed baseline
     that succeeds; a deny-list must leave unblocked syscalls working.
  B  Landlock — W^X in both directions (--rw denies exec, --rw + --exec
     restores it), --ro allows read and exec but denies write and create
     with exact EACCES, --rw allows create/unlink/mkdir/rmdir, and a path
     covered by no rule is denied.
  C  Parser limits — over-long line, >64 paths, >64 env-deny entries all
     refuse to weaken the policy; inherit depth 2 is accepted and merges
     all three profiles, depth 3 is refused by name, and an inherit cycle
     terminates (under `timeout`) instead of recursing.
  D  --dry-run — required fields, the advertised path-rule and block
     counts match the rules actually listed, and it does not execute the
     command.
  E  --verify — exit code agrees with its own verdict; kernel, NNP,
     Landlock, seccomp, arch and the honest "Network: NOT RESTRICTED"
     line are present; the syscall-table size is non-empty.
  F  Environment sanitization as seen by the exec'd process (env_dump),
     not as intended by the parent: 12 dangerous variables absent, an
     unlisted variable survives, --no-env-sanitize keeps one as a control,
     --env-deny strips one on its own.
  G  Sandboxed child state: NoNewPrivs=1, Seccomp=2, and --no-seccomp
     really leaving Seccomp=0 as a control.

Every deny_probe assertion requires the PROBE_START marker, so none of
them can pass because the probe failed to execute.

Evidence: 52 pass / 0 fail on this host. Replacing compartment-user with
/bin/true turns 47 of the 51 applicable assertions red; the four that
survive are exit-code-only halves of pairs whose content half fails.
The built-in profiles added getenv("HOME") as a read-write-execute
Landlock root with no check at all. Landlock is additive, so HOME=/
granted 'rwx /' and the narrower ro rules could not claw it back:
/var, /srv, /opt, /boot, /mnt and other users' homes all became
writable, bounded only by DAC. $HOME is caller-supplied, and in the
login-shell deployment it need not even be the real home directory.

$HOME must now be absolute, not '/', and an existing directory owned by
the real uid of the caller; otherwise compartment-user refuses to start
and says which condition failed. The same check gates $HOME expansion
inside profile values, whose error message no longer claims every
expansion failure was a length problem.
…e target

Two ordering problems at the end of the child sequence.

The close_range() cleanup ran *after* apply_seccomp(), so a policy in
allow-list mode that did not list close_range/close left every inherited
host fd — including the audit log — open inside the container, with no
diagnostic.  Move the cleanup ahead of the filter, and emit the
CONTAINER_EXEC audit record just before it, while the log fd is still open.

The target command also became PID 1 of the new pid namespace, where the
kernel discards any signal for which no handler is installed (Ctrl-C did
not stop a plain /bin/sh) and orphaned grandchildren are reparented and
never reaped.  There was no PR_SET_PDEATHSIG either, so killing
compartment-root left the container running.

Add container_init(): the child sets PDEATHSIG (after the credential
change, which resets it), forks, and stays behind as PID 1 forwarding
SIGTERM/SIGINT/SIGHUP/SIGQUIT to the target, reaping everything else, and
exiting with the target's status (128+n when it was killed).  The fork is
deliberately before the seccomp filter: PID 1 has to keep
wait4/kill/rt_sigaction usable even under an allow-list policy that does
not mention them.  It runs with the same namespaces, dropped capabilities,
dropped uid and no-new-privs as the target.

Verified on Ubuntu 24.04 / 6.8.0-139: exit code 42 propagates, the target
is PID 2, fd 9 opened by the caller is gone inside, SIGTERM to
compartment-root yields rc=143, and SIGKILL to compartment-root takes the
container down with it.
assign_to_cgroups() validated only that the path was absolute and free of
"..", then opened <path>/cgroup.procs with fopen(..., "w") — which creates
the file.  `--cgroup /etc/cron.d` therefore created a root-owned
/etc/cron.d/cgroup.procs, and any writable directory on the host was a
valid target.

Require the /sys/fs/cgroup/ prefix, resolve the path with realpath() and
re-check the prefix so a symlinked component cannot redirect the write,
then open cgroup.procs with O_WRONLY|O_NOFOLLOW and no O_CREAT — the file
is created by the kernel and must already exist.

Verified on Ubuntu 24.04 / 6.8.0-139: `--cgroup /etc/cron.d` is refused
with rc=1 and creates nothing; a real cgroup directory still works.
…olicy

--dry-run set config.verbose itself, so `--dry-run --verbose` printed
exactly the same thing as `--dry-run` and the flag combination was a lie.
--dry-run no longer implies --verbose; -v now adds the parts of the policy
that are built in and therefore invisible in the summary: the bind and
nosuid/nodev remount of the container root, the /proc and /sys mounts, the
device nodes bound into /dev, the full list of masked paths (built-in plus
policy), the PID 1 reaper and PDEATHSIG, the privilege-drop steps, and the
blocked syscalls by name.

The summary itself was also wrong in three places: `audit-log` names a
directory and the log inside it is named after the day, but the banner
printed the directory as if it were the file; the mask count said "+ 4
default" when the built-in set is now 15; and no-new-privs was never
reported at all.

--help gains the same corrections: -L documents a directory, the
"Hardening (always on)" block lists what the tool actually does now, and -v
says what it adds under --dry-run.
The binary this project ships on Ubuntu is PIE, full-RELRO, non-exec-stack
and CET-enabled — all of it supplied by Debian/Ubuntu's gcc specs and none
of it by the Makefile. Built on Fedora, RHEL, Alpine/musl or a self-built
gcc, the same source produced a measurably weaker binary than the docs
describe.

CFLAGS/LDFLAGS now name what they need:

  -fPIE -pie                     position independent
  -Wl,-z,relro,-z,now            full RELRO, immediate binding
  -Wl,-z,noexecstack             non-executable stack
  -Wl,-z,separate-code           text and headers on separate pages
  -fstack-protector-strong       (kept)
  -fstack-clash-protection       probed
  -fcf-protection=full           probed
  -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3   probed, falls back to =2, then off
  -Wformat -Wformat=2 -Werror=format-security

Anything a toolchain may not have is chosen by a real compile-and-link
probe with -Werror, so an older or non-x86 target silently drops the flag
instead of failing the build. `make show-hardening` prints what was
selected. The build stays warning-free.

Also:
* `hardened:` now builds compartment-root too; it previously left it
  unbuilt, so `make hardened && sudo make install` shipped whatever
  compartment-root happened to be lying around.
* deny_probe is built with the same flags as the tools.
* `install:` creates /etc/compartment (0755) and installs examples/*.conf
  there 0644 — both tools search /etc/compartment/<name>.conf (HOWTO.md
  "Profile Files" step 3) and that directory has to exist and be
  root-owned, since a user-writable one would let any local user dictate
  the policy of every sandboxed process. Files are installed root-owned
  when make runs as root, and without an owner otherwise so unprivileged
  DESTDIR staging still works. The target prints a notice that
  ai-agent.conf and strict.conf shadow the built-ins of the same name.

Verified on this host (gcc 13.3, glibc 2.39):
  hardening-check compartment-user compartment-root tests/probes/deny_probe
    -> PIE yes, stack protected yes, fortify yes, RELRO yes,
       immediate binding yes, control flow integrity yes  (all three)
  readelf: Type DYN, FLAGS BIND_NOW, FLAGS_1 NOW PIE, GNU_STACK RW,
       115/152 endbr64, 20/18 stack-clash probes
  CC that rejects -fstack-clash-protection, -fcf-protection and
    _FORTIFY_SOURCE=3 -> flags dropped, =3 falls back to =2, build succeeds
  cc -fno-PIE -no-pie + old flags -> Type EXEC;
    same hostile defaults + new flags -> Type DYN
  make install DESTDIR=... -> /etc/compartment 0755, *.conf 0644;
    under sudo, all root:root
…sleading LIMITATIONS rows for v0.8

The strict-launch marker move is right; the reason given for it was not.
Every failure path the original text named — de_thread(),
unshare_files()/dup_fd() ENOMEM, exec_mmap() — runs inside
begin_new_exec(), which sets bprm->point_of_no_return before any of them,
and bprm_execve() converts a failure past that point into a fatal
SIGSEGV. Those paths kill the task; they can never return a marker to the
caller's old image, so as stated the change was unfounded.

The real window is between security_bprm_check() (called from
search_binary_handler() immediately before fmt->load_binary()) and
begin_new_exec(). Every failure inside load_elf_binary() up to that point
returns -errno to the caller's original image. The most usable is
open_exec(elf_interpreter) -> -ENOENT, which an attacker who controls a
mount namespace forces deterministically by shadowing the path in the
launcher's PT_INTERP — no race, no memory pressure needed.
load_elf_phdrs() -ENOMEM, the -ELIBBAD PT_INTERP checks and the -ENOEXEC
binfmt retry loop are the others. Rewritten in the hook comment,
compartment-abi.h, the CHANGELOG and the LIMITATIONS row.

Three LIMITATIONS rows were actively misleading an operator about a live
control, and are corrected:

- "CAP_BPF + direct map mutation" claimed bpftool could weaken
  sealed_inodes / sealed_dirs. It cannot: freeze_seal_maps() freezes them
  and map_get_sys_perms() strips FMODE_CAN_WRITE from any fd on a frozen
  map. The real residual is actor_marker_map, which is TASK_STORAGE and
  has no freeze at the kernel API level.
- "Privileged BPF link detach" told operators to harden against
  bpf(BPF_LINK_DETACH). That call cannot detach a BPF-LSM link:
  bpf_tracing_link_lops has no .detach, so link_detach() returns
  -EOPNOTSUPP on both target kernels. Hardening against it protects
  nothing while the bpffs pin directory — the actual removal path — stays
  unguarded. BX-15-prog-detach.sh asserted the old wording verbatim and is
  updated in the same commit, including a new assertion that the row keeps
  saying BPF_LINK_DETACH does not work.
- "VFS write-class transitive coverage" listed fallocate(2) as covered by
  security_file_permission(MAY_WRITE). vfs_fallocate() contains no
  security_* call at all; its only write gate is the FMODE_WRITE test. A
  writable fd that predates the seal can punch holes in a no-write sealed
  file with no deny and no audit event. copy_file_range, FICLONERANGE,
  FIDEDUPERANGE and splice are covered as claimed.

Four rows that had no text at all are added: ACL/xattr coverage under
no-chmod, 32-bit compat ioctls (rewritten as closed, with the residual
command list), timestamps (with the behaviour-change warning), and
pre-existing writable fds. Plus one for mount_setattr(2) /
open_tree_attr(2), which reach do_mount_setattr() with no security_* call
of any kind — not a seal bypass, since (dev,ino) is idmap-independent and
the denies are LSM-layer, but an unhooked mount-mutation primitive that
belongs in the threat model. The same row records why sb_remount is
deliberately not attached, so it is not re-proposed.

CHANGELOG: the v0.8.0 entry now leads with an explicit "Behaviour change"
block for the timestamp reclassification — cp -p, rsync -a, tar -x,
install -p and any touch-based liveness sentinel start getting EACCES on
directly no-chmod-sealed files, and every seal already in the field is
affected.

Also: HOWTO section 7.1 named deny_file_chmod() and a path_chmod program,
neither of which has ever existed in compartment.bpf.c; it now lists the
hooks that actually implement no-chmod. Hook count 26 -> 27, README
link-detach bullet and compat-ioctl bullet corrected, oracle.conf flagspec
spells out what no-chmod covers, deny-to-candidate.py records why
DENY_MOUNT has no reverse flag mapping, and the stale "16-hook" /
"21 PIN_LINK" / "v0.5 ABI" comments are brought current.
Refusing every group-writable profile made the tool unusable out of the
box wherever umask 002 is the norm — Debian, Ubuntu and Fedora all give
interactive users a private group and that umask, so everything they
create lands at 0664/0775 and group-write grants nothing beyond
owner-write.

Tolerate exactly that case: the object is owned by the caller (never by
root), its group is the caller's primary group, and that group has no
other members. World-writable, other-owned, shared-group and every
root-owned object keep the strict rule, so /etc/compartment/ and
compartment-root are unaffected.
`make test-integration` failed on any machine that happens to have the
third-party CLI installed under $HOME: the smoke profile maps $HOME rw, W^X
denies exec, and the suite reported a real failure for a policy decision
rather than a defect. On a build machine without the CLI it self-skipped,
so the difference between "not applicable" and "broken" was invisible.

run_all.sh now decides, before launching it, whether a suite whose
prerequisites are external can run at all:

  --no-external, or COMPARTMENT_SKIP_EXTERNAL=1   skip suites that need a
                                                  third-party CLI or an
                                                  outbound proxy

and it skips that suite automatically when the CLI is missing or its
config directory does not exist. The sandbox.sh suite keeps running: it needs
only unprivileged user namespaces and already skips its one proxy case
without a proxy.

Skipped suites are named in the final summary alongside failed ones, so a
skip is visible rather than silent.

  COMPARTMENT_SKIP_EXTERNAL=1 make test-integration
    -> 5 suites run, 0 failed, 1 skipped, rc=0
…l mode

COMPARTMENT_SHELL_DIR was read from the unsanitized environment and
validated only for 'absolute' and 'no ..', so the caller chose which
binary the replaced shell actually runs — defeating the whole point of
'make hardened' randomising REAL_SHELL_DIR. It is now honoured only when
the directory and the shell binary inside it are owned by root or by the
real uid of the caller and are neither group- nor world-writable;
otherwise compartment-user warns and falls back to the compile-time
REAL_SHELL_DIR. It never refuses: this path must not be able to lock a
user out. The sandbox is applied before the exec either way, so the
variable decides which binary runs, never whether it is confined.

Shell-replacement mode also skipped preflight_check(), the ambient
capability clear, PR_SET_DUMPABLE(0) and close_range() that the normal
path performs — omissions rather than choices, unlike the documented
fail-open-on-enforcement-failure behaviour. The hardening block is now a
shared apply_hardening() called from both paths, and preflight runs
advisory (syslog, counted as degraded) rather than fatally. Verified: an
fd left open by the caller reached the replaced shell before, and does
not now.
`git branch --show-current` prints the empty string in detached HEAD —
the exact state the script's own "HOW TO REDO" instructions produce
(`git checkout v1.1.0`, then run this). The restore at the end therefore
ran `git checkout -f ""` and died with "empty string is not a valid
pathspec", *after* the force-push had already happened, leaving the user
checked out on the orphan archive branch with no hint how to get back.

The starting ref is now captured before anything mutates the repository,
as a branch name when on one and a commit hash when detached, and an EXIT
trap restores it on every path including a failed commit or a rejected
push.

`git add -f "$ARCHIVE_DIR/"` also swept archive/shell-guard/ (tracked on
main, ignored by .gitignore) into every proof branch. Only the three
proof artifacts are force-added now.

Reproduced end to end in a throwaway repo with a local bare origin:

  before, from detached HEAD:
    fatal: empty string is not a valid pathspec ... rc=128
    left on branch archive/vTEST
    archive/vTEST contains SHA256SUMS, SHA256SUMS.root, shell-guard/guard.c
  after, same setup:
    rc=0, back on the original detached HEAD (same sha)
    archive/vTEST contains SHA256SUMS, SHA256SUMS.root only
The HOWTO table was written from the design, not from a run, and two of
its five lines were wrong: umount prints util-linux's "block devices are
not permitted on filesystem" for EACCES from umount2(2), not "Permission
denied", and --unpin fails with EACCES on the first pin unlink rather
than EPERM (only the bpf() side is EPERM). The stranded-loader transcript
had the same wrong errno.

Every line in the table is now verbatim from 6.8.0-139 and 7.0.0-31 with
bpftool 7.4 and 7.7, including the note that the umount wording is
util-linux's and not ours to change -- the audit stream is where the
operator gets DENY_UMOUNT.
…nges

The map-mutation row named "all 13 counters" as the mutable set. The
catalogue is 15 in this release, so the number dates the sentence
against its own tree. The claim is about every map, not about a count.
…rose

The new W3 assertion grepped for "authorised loader set", which the
refusal wraps across a line break, so the witness failed against a
message that says exactly what it should. Caught by check-bypass on
7.0.0-31 -- the assertion was wrong, not the loader.

Asserting on wrapped prose is the bug: the two things that must be in
that message are the flag that caused the refusal and the flag that
fixes it, and neither --self-protect nor --authorize-loader can straddle
a line. W3 now greps for those.
A suite that no runner, target or document names reads as coverage and
then rots. bench-bpf-syscall has a Makefile target; it now has a row in
the per-suite table with the number a healthy guest prints.
Appending it to the long shared .PHONY list rewrote a line whose other
contents have nothing to do with this change, which makes the diff read
as if unrelated targets moved. A one-line declaration next to the target
is the same thing check-limitations and regen-vmlinux already do.
HOWTO 3.6's "what it costs" section named the bpftool cost and the
read-only decision but not the number, which is the one an operator
deciding whether to turn the flag on actually needs. The three-leg
measurement is now a table there: nil without the flag (the program is
not loaded), ~130-145 ns per map-fd creation with it, and nothing on the
file or inode data plane either way.
…g anything

Both found by re-reading the witnesses against the code rather than
against their own comments.

W3 in witness 22 is the assertion that protects the central design
decision of this feature -- read-only fds are denied too, because a
read-only fd is a complete attack -- and it was inert. The sweep helper
incremented ok_ro in two places with two meanings: a read-only fd for a
map that REFUSED read-write (the hole) and a read-only fd for a map that
ALLOWED it (every unrelated map on the box, which is dozens). The
helper's own comment said to count the first as "other"; the code did
not. So `ok_ro < denied` compared a count of unrelated maps against a
count of compartment maps: it fails with zero leaks on a box with enough
BPF, and passes with any number of leaks on a box with little. The
helper now has a separate ro_leak counter and the witness asserts it is
exactly 0, twice.

W5 in witness 23 asserted that rmdir of the pin directory fails. The pin
directory is never empty, so it fails with ENOTEMPTY whether or not the
gate exists -- the assertion passed identically against a build with the
inode_rmdir branch deleted. vfs_rmdir() calls security_inode_rmdir()
before the filesystem's ->rmdir, so the two are distinguishable by
errno; W5 now requires the permission refusal and says explicitly what
ENOTEMPTY would mean.

Also: both audit-line greps used caller_dev=[0-9]* (zero or more), which
asserts only that the token exists. Now [0-9]+.
…strand the box

These witnesses are a different shape from every other one in the suite:
each pins a REAL self-protected policy, and the only image authorised to
remove it is a copy inside the witness's own $TMP. Four ways that could
end with a box that needs a reboot, all closed:

1. sp_teardown discarded the unpin rc and then let lib-bypass rm -rf
   $TMP, deleting the only authorised loader image. Now the rc is
   checked, and on failure the image is KEPT, its path and the exact
   --unpin command are printed, and $TMP is not removed. The bench
   harness had the identical shape and the identical fix.
2. The runner caps each witness at 60 s, which is smaller than these
   witnesses' own waits (a drain was 90 s) and much smaller than
   25-loader-upgrade's three pin/unpin cycles. A witness killed at the
   cap exits without a label -- counted FAIL, correctly -- but its
   teardown never runs. The internal waits are now well under the cap
   (drain 25 s, pin 15 s) AND witnesses 22-26 get 180 s in both runners,
   because the cap has to be larger than the work rather than the other
   way round.
3. W6 ran a --pin in the foreground through `| head -20`. It is expected
   to refuse before attaching; if that refusal ever regressed the loader
   would go live, block in its ringbuf poll, print fewer lines than head
   wants, and hang until SIGKILL -- with a self-protected policy pinned.
   Now under `timeout 30`, with rc=124 reported as the bypass it is.
4. The bx22ctl/bx23ctl control pins live outside PIN_ROOT, so --unpin
   never sweeps them. One left behind by an interrupted run made the
   next run's `bpftool map create` fail EEXIST and the witness SKIP
   forever on a misleading reason. Removed before creation and in
   teardown.

W1's refusal check grepped the multi-word phrase "authorised loader
set", which the message wraps; it now uses single-word tokens, the same
lesson W3 already carries in a comment.

Witness 26 gains the two preconditions it silently depended on: python3,
and a clean box (it picks a map by name out of a host-wide listing, so a
second instance would make it ambiguous which sealed_devs it measured).

The bench had three ways to report a fabricated number: a leg with no
samples became "0.0" through awk, a leg where every bpf() call failed
was timed as if it had succeeded, and the link counts it prints were
never compared. All three now fail the leg instead.
…efusal

The new --stats diagnostic collapsed EPERM and EACCES into one message.
They are not the same problem: EPERM on bpf_obj_get() is the
self-protection gate, and EACCES is the bpffs pin directory being
root-owned mode 700 -- which is what a plain non-root --stats has always
hit, with no policy self-protection involved anywhere. Telling that
operator to re-run from an authorised loader image sends them after a
feature that is not in play. Split on errno; EACCES gets its own line
saying to re-run as root.

Also corrects "eighteen pins" to fifteen in the same comment: eighteen
is the size of the pin_counter_maps table (15 counters plus
launcher_to_actor, policy_state_map and abi_version_map), and --stats
opens only the fifteen counters.

protected_pins is a HASH with max_entries 64, and
self_protect_record_pins() fills it from KNOWN_LINK_NAMES plus
KNOWN_MAP_NAMES plus four fixed paths -- 56 today. Overflowing it is an
E2BIG that fails the pin closed, but only on a live box and only for
whoever adds the hook that crosses the line. A _Static_assert now ties
the two numbers together at build time.
…d two

check-actor-hook greps for the "<name>, skel->maps.<name>" shape that
appears in both pin_counter_maps() and freeze_seal_maps(), and used
grep -q -- satisfied by either alone. Its error message says "is not in
both". Deleting the freeze entry for either self-protection counter
passed the gate.

Now it counts and requires two, and the message names which half is
missing and what that costs: a counter absent from pin_counter_maps is
invisible to --stats, and one absent from freeze_seal_maps is a map an
attacker can still write through the syscall path. Verified by deleting
the pin_tamper_denied_total freeze entry: the gate now fails with
"appears in 1 of the two tables".
1. HOWTO 3.6 listed the bpffs umount refusal under "what it refuses for
   every task whose mm->exe_file is not an authorised loader image".
   deny_umount_of_sealed_dev() has no caller_is_loader() check anywhere,
   so that refusal applies to EVERY caller, the loader included -- while
   the over-mount half, which goes through deny_pin_tamper(), does
   exempt the loader. Two different rules under one bullet. The code is
   right: the loader has no reason to detach that filesystem, and
   "--unpin first, then unmount" is already the rule v0.8 applies to a
   filesystem holding sealed paths. The doc now says so, in its own
   paragraph, and names DENY_UMOUNT rather than DENY_PIN_TAMPER.

2. move_mount also carries the pin-tamper branch -- it calls
   deny_mount_on_dentry() like sb_mount, and its from-side calls the
   umount gate -- so it can emit ACTION_DENY_PIN_TAMPER. Four places
   enumerated the emitting hooks and all four omitted it: the ABI
   header, the compartment.bpf.c hook list, README's hook table and the
   CHANGELOG.

3. The compartment.bpf.c hook census said "27 attach points; 29 SEC()
   entries" while its own enumeration below summed to 29. The tree has
   30 SEC("lsm...") programs, two of which are the mutually exclusive
   inode_setattr wrappers, so it is 29 attach points and 30 entries.

LIMITATIONS gains two residual rows the self-protection section was
missing: `mount --move` of the pin bpffs is invisible to both gates
(do_move_mount_old() has no security_move_mount(), and sb_mount only
sees the destination) and produces the same orphan the over-mount path
does; and the ED-11 unpin sentinel lives on /run, not bpffs, so
protected_pins does not cover it -- deleting it downgrades the
passphrase gate but does not remove enforcement, because --unpin still
has to come from an authorised image.

The generated coverage matrix now says in its header that the note
column names the first witnessing file in sorted order, not the
strongest or the only one. Without that, the row for deny_total -- whose
exact-delta witness is counter-smoke T4b.1 -- reads as if a new witness
that only greps for the counter's name had replaced it.
The first HOWTO table gave one figure per kernel from one run each.
Five runs across the two guests put the armed cost between +121 ns and
+252 ns: 7.0 reproduces to within 17 ns across three runs, 6.8 spread
145 -> 252 ns between two runs of the identical binary. That spread is
the 2-vCPU guest, not the hook, and quoting the low end as "the" number
would be false precision in the direction that flatters the feature.

The flag-off column is the one that is genuinely tight, and it is the
one that matters for the default build: every run inside +/-0.5 % of the
no-policy baseline, one of them measuring 0.24 % faster.

An operator with a latency budget on bpf(2) is now told to measure it on
their own hardware, which is what the bench target is for.
…, add acl and gcc-multilib to the guest lists, and tell the operator to regenerate vmlinux.h
…ir, the interactive login-shell fix, the limited-root profiles and an end-to-end sshd root suite
… the lsm/bpf_map and pin/bpffs gates in ABI 0x0008, witnesses 22-26, the bpf(2) overhead bench and the freeze correction
The merges were textually clean because the branches touched disjoint
files, which is exactly why the inconsistencies they left are the kind a
merge tool cannot see.

- `check-profiles` is 20/20, not 19/19: feat/limited-root added a
  twentieth profile to compartment-bpf/profiles/ and ON-RAMP.md still
  quoted the old expected line.
- tests/README.md listed five root suites and the tree has six. A suite
  the table does not name reads as absent coverage, which is the failure
  mode `make check-orphans` exists to prevent from the other direction.
- SECURITY.md's root-coverage paragraph quoted 61 + 55 + 32 and 152 with
  discovery. Measured on the host at this commit: 101 + 56 + 67 + 46 + 10
  + 5 = 285, `pass=279 fail=0 skip=6` over 6/6 suites. The paragraph tells
  the reader to run the suites rather than trust it; that is not licence
  to leave it wrong.
- SECURITY.md now carries the two compartment-bpf facts a reader of this
  file needs and could previously only find in
  compartment-bpf/LIMITATIONS.md: `bpf_map_freeze()` is not map integrity
  (the program path is ungated, and a map wipe emits no audit event), and
  `--self-protect` closes it opt-in with three named residuals. The
  limited-root paragraph already ended on "anything holding CAP_BPF owns
  the seals outright" and then said nothing about what to do with that.
- limited-root-authpath.conf called self-protection future work. It ships
  in the same release, so the profile now names the flag and points at the
  upgrade rule instead.
- compartment-bpf/profiles/README.md documents the auth-path profile and
  says why it is deliberately not in all-daemons.conf; the bpf README test
  pyramid gains the bpf(2) overhead bench.
…asured

Three documented figures did not survive being re-measured on the two
guests at the merge head. None of them is an enforcement change; all three
are the kind of number a reader takes on trust.

- The observe suite is 26 assertions, not 22. Every doc that quoted it
  quoted the AIDE-absent case — `PASS=21 FAIL=0 SKIP=1` — because T12 is a
  five-part group that reports a single skip when AIDE is missing. Both
  guests have AIDE and both measured `PASS=26 FAIL=0 SKIP=0`, and no file
  said so. Now they state both cases.

- The `--self-protect` bpf(2) cost table was built from two runs per
  kernel and this candidate's third run fell outside it on both: 6.8
  measured +8.8 % (+127.7 ns) against a documented floor of +10 % and
  +145 ns, and 7.0 measured +12.5 % (+143.5 ns) against a documented
  ceiling of +12 % and +138 ns. The ranges are widened to cover all three
  runs. The nanoseconds are the reproducible half — 7.0 within 23 ns
  across three runs — and the percentage moves with whatever else the
  guest is doing to the baseline, so the text now says to read the
  nanoseconds.

Measured on 6.8.0-139 and 7.0.0-31, loader rebuilt on each guest with
vmlinux.h regenerated from that guest's own BTF.
The residual-risk table in the HOWTO carried nine rows and the report it
was copied from has twelve residuals plus three findings that are limits
in their own right. Someone sizing this deployment for a restricted
maintenance shell had to read the design notes to find the rest, which is
exactly the audience that should not have to.

HOWTO.md section 8 is now the complete list, in operator language, with
the closure or the future work named for each: the enumerative mask list
and why neither Landlock nor seccomp can govern connect(2) to a pathname
unix socket (and the two fixes that would end it); that a mask
neutralises a write rather than refusing one, and which surfaces
therefore need a Landlock rule; seal-plus-mask; signals out of the domain
below ABI v6 and why --pin is mandatory; sshd stream-local forwarding and
internal-sftp with the exact sshd_config lines that close them; actor=
forgery by an unconfined CAP_SYS_RESOURCE root and the three ways to
close it; SCM_RIGHTS and memfd+fexecve; that service management is gone
by design while the bus sockets are masked, and the trade that buys;
that CAP_SYS_ADMIN being dropped rules the account out as a CI runner;
that CAP_BPF outside the session owns the seals unless --self-protect is
on; and the two results that are the host's doing (lockdown, Yama) plus
the 6.8/7.0 /proc/1 divergence.

Section 5 now says --self-protect exists and points at its upgrade rule
before an operator meets it the hard way. SECURITY.md mirrors the list in
condensed form and the man page gets a short LIMITATIONS section that
points at both.
…and leaves open

The self-protection story was spread over four documents and none of them
carried it whole: LIMITATIONS.md had only the residual table, HOWTO §3.6
had only the operator path, and the reason the flag exists at all — that
bpf_map_freeze() is not map integrity — was three sections away in both.
An operator deciding whether to turn the flag on had to assemble it.

LIMITATIONS.md now has one consolidated section: what it closes, as a
before/after table measured on 6.8.0-139 and 7.0.0-31; the freeze fact
restated for the default build, with the honest witness that keeps it
true; why read-only map access is not carved out; the errno split (EPERM
on the bpf(2) side, EACCES on the path side) and why ENOENT was rejected;
the cost, including the runtime range and the upgrade ceremony; the six
residuals plus the two adjacent upstream gaps; and the statement that
this and the limited-root profile compose, with neither replacing the
other.

HOWTO §3.6 gains the same three missing pieces in operator form — the
measurement, the freeze fact with the flag off, and the six residuals in
one list — and now points at the limited-root limitation list for the
other half of the pair. README's limits bullet and the CHANGELOG say the
same thing in fewer words, including the runtime numbers, which neither
of them carried.
…oss-link them

The limited-root deployment and compartment-bpf self-protection answer
different adversaries, and until now each document described its own
adversary without naming the other's. That is the reading that produces a
deployment with one half of a pair.

DESIGN.md gets the model in one place: the confined session (per-task
state that binds only the side it is on), unconfined root (which only an
inode-bound seal reaches), CAP_BPF underneath both, and the boot chain
underneath that, with each layer's limitation list named. Both profiles
carry a two-line header pointing at the complete list rather than relying
on the reader having found it. README's limited-root paragraph names
section 8 instead of "the residual-risk list", and SECURITY.md's
self-protection bullet is reconciled with the consolidated section: six
residuals and two adjacent upstream gaps, not "three known edges", plus
the runtime cost and the statement that the two controls compose.

No semantic change to either profile: comment lines only. Both still
parse - compartment-user --dry-run on the session profile and
compartment-bpf --parse-only on the auth-path profile, 51 seals, 0 errors.
Section 8 item 7 said the no-actor seal is the cheapest closure and that
the auth-path profile already uses it, but left the reader to work out how
much of the profile is actually exposed. It is one line: every seal on the
login path is `full` with no actor, and `/var/log/compartment` is the only
`actor=` seal in the file, because the wrapper has to keep writing it.
Saying so turns an open-ended residual into a bounded one.
Refuse all group-writable policy sources and require root-owned BPF maintenance images. Close inherited descriptors before installing filters, including high descriptors when close_range is unavailable. Recheck additive Landlock paths after pivot_root and cover the root-parent case. Add focused regressions and the missing CI dependency.
Copilot AI lite review requested due to automatic review settings September 7, 2026 01:17
Show mapped-root namespace guidance whenever HARD mode is unavailable, including when SOFT mode is available. Use the installed generic bpftool binary when the Ubuntu wrapper cannot find tools for the runner kernel.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It’s a large, security-sensitive release with broad behavioral and test-gating changes, and there are still review-blocking correctness/safety nits identified in the updated scripts.

Pull request overview

Releases Compartment 1.4.0 (and optional compartment-bpf 0.8.0) from release-1.4 into main, with a strong focus on tightening sandbox/profile trust rules and making regression coverage more reliable and harder to “false-green”.

Changes:

  • Overhauls test harnessing and suite discovery (rootless/root suites), adds invariant assertion counting + standardized SUMMARY ... lines to avoid silent skips.
  • Reworks fixtures and test profiles to use per-run rendered roots (@FIXTURES@) and strengthens profile examples/docs around Landlock TCP port rules and “mechanisms can only tighten” semantics.
  • Expands compartment-bpf v0.8 coverage with new witnesses, ratcheting gates, baseline shellcheck gate, and documentation consistency checks.
File summaries
File Description
tests/scripts/run_sandbox_proxy_matrix.sh Move HARD-mode assertions into shared helper; summary/total enforcement
tests/scripts/run_kernel_matrix.sh Fix argument shifting, add liveness marker, add suite summary line
tests/scripts/rootless.d/README.md Document rootless suite discovery + contract
tests/scripts/rootless.d/00-discovery-smoke.sh Rootless discovery smoke test using harness
tests/scripts/root.d/sandbox-hard.sh Root-only HARD-mode sandbox assertions with sysctl handling
tests/scripts/root.d/README.md Document root-only suite discovery + contract
tests/scripts/root.d/00-discovery-smoke.sh Root discovery smoke test using harness
tests/scripts/make_fixtures.sh Per-run fixture roots + rendered profiles + tightened permissions
tests/scripts/lib/harness.sh New shared test harness (counts, summary, cleanup, fixtures)
tests/profiles/test-seccomp-deny.conf Update profile semantics (no “off”), ensure Landlock unrestrictive
tests/profiles/test-fs-rw.conf Use @FIXTURES@, keep seccomp/env-sanitize on
tests/profiles/test-fs-readonly.conf Use @FIXTURES@, keep seccomp/env-sanitize on
tests/profiles/test-env-deny.conf Make Landlock/seccomp unrestrictive without disabling mechanisms
tests/profiles/test-combined.conf Use @FIXTURES@ rendered fixture root
tests/profiles/test-claude-smoke.conf Explicit exec grant for CLI dir rendered by runner
tests/probes/profile_group_probe.c New probe for group-writable policy trust refusal
tests/probes/fd_reader.c New probe to verify inherited fd closure behavior
scripts/timestamp.sh Robust restore to start ref + limit archived files added
scripts/shellcheck-bpf-baseline.txt Add tracked baseline for compartment-bpf shellcheck warnings
scripts/check-shell-bpf.sh New ratcheting shellcheck gate for compartment-bpf shell
scripts/check-orphans.sh New gate to detect orphaned test scripts
scripts/check-docs-symbols.sh New gate validating doc-named BPF symbols exist in sources
extra/tinyproxy/stop.sh Safer stop semantics via shared pidfile logic
extra/tinyproxy/status.sh Safer PID handling; better port/crontab reporting
extra/tinyproxy/start.sh Harden PID reuse checks via pidfile_read
extra/tinyproxy/reload.sh Validate PID via pidfile_read before HUP
extra/tinyproxy/pidfile.sh New shared pidfile utilities (verify comm, stop w/ wait+kill)
extra/tinyproxy/enable.sh Safer crontab edits with snapshot/backup/lock
extra/tinyproxy/docker-start.sh Optional Docker-based tinyproxy start helper
extra/tinyproxy/disable.sh Correct crontab removal logic; stop enforcement
extra/tinyproxy/crontab.sh New shared crontab utilities (lock, backup, install)
extra/tinyproxy/build.sh Build helper for tinyproxy into user prefix
extra/squid-proxy/srv/squid/stop.sh Add stop helper for squid container
extra/squid-proxy/srv/squid/start.sh Use host networking to keep loopback-only bind meaningful
extra/squid-proxy/srv/squid/squid.conf Harden ACLs/ports; loopback-only listener
extra/squid-proxy/srv/squid/enable.sh Enable restart policy for squid container
extra/squid-proxy/srv/squid/disable.sh Disable restart and stop squid container
examples/tcp-udp-relay.conf Correct network guidance; document Landlock TCP-only port limits
examples/ssh.conf Narrow /dev access; add writable known-hosts directory
examples/restricted-root.conf New restricted-root example (exec allow-list + TCP allow-list)
examples/paranoid-ssh.sh Add host-key aliasing + known-hosts handling + dry-run
examples/dns-client.conf Correct network guidance; document Landlock TCP-only port limits
examples/curl-wget.conf Clarify --user-profiles requirement for home discovery
examples/container.conf Clarify compartment-root-only usage; expand syscall allow-list docs
examples/ai-agent.conf Update built-in default doc; tighten /dev rules and env deny-list
compartment-bpf/tools/deny-to-candidate.py Handle v0.8 actions incl. self-protection denies as review-only
compartment-bpf/tests/telemetry-smoke.sh Update min counter floor for v0.8
compartment-bpf/tests/strict-launch/helpers/slm_traceme.c Update hook naming in comment
compartment-bpf/tests/stability/RESULTS.md Record actual 1024-cycle soak run details
compartment-bpf/tests/stability/corner-cases/CC-10-pin-unpin-pin-unpin.sh Render baseline profile with real ELF actor fixture
compartment-bpf/tests/stability/corner-cases/CC-09-sigstop-sigcont.sh Render baseline profile with real ELF actor fixture
compartment-bpf/tests/stability/corner-cases/CC-08-concurrent-pin.sh Render baseline profile with real ELF actor fixture
compartment-bpf/tests/stability/corner-cases/CC-07-unpin-during-ringbuf.sh Render baseline profile with real ELF actor fixture
compartment-bpf/tests/stability/corner-cases/CC-06-sigkill-repin.sh Render baseline profile with real ELF actor fixture
compartment-bpf/tests/stability/corner-cases/CC-05-rapid-pin-unpin.sh Render baseline profile with real ELF actor fixture
compartment-bpf/tests/stability/corner-cases/CC-04-child-actor-unpin.sh Render baseline profile with real ELF actor fixture
compartment-bpf/tests/stability/corner-cases/CC-03-exec-during-unpin.sh Use real ELF actor fixture for race witness
compartment-bpf/tests/stability/corner-cases/CC-02-unpin-during-enforcement.sh Render baseline profile with real ELF actor fixture
compartment-bpf/tests/stability/corner-cases/CC-01-pin-during-mesh-trial.sh Render baseline profile with real ELF actor fixture
compartment-bpf/tests/stability/baseline-profile.conf Template actor rendered to real ELF; seal that inode
compartment-bpf/tests/stability/baseline-profile-b.conf Template actor rendered to real ELF; seal that inode
compartment-bpf/tests/sealprobe.c Add fallocate punch-hole + fd-write operations and docs
compartment-bpf/tests/RESULTS-concurrency.md Update documented link counts for v0.8
compartment-bpf/tests/release-totals.sh New ratchet on suite assertion-count floors
compartment-bpf/tests/profile-e2e/aide.sh Always install bounded AIDE config; restore host state
compartment-bpf/tests/pin-regression.sh Add T4.6 expected pinned link set assertion + better skip visibility
compartment-bpf/tests/observe/run.sh Use real ELF actor fixtures; update ABI/version assertions
compartment-bpf/tests/lib-pinlock.sh Add advisory mutex for PIN_ROOT across concurrent suites
compartment-bpf/tests/expected-links.txt Define exact pinned link set with conditionals
compartment-bpf/tests/docs/RUNNING.md Document suite running and skip vocabulary
compartment-bpf/tests/docs/howto-examples.sh Use real ELF fixture for HOWTO examples
compartment-bpf/tests/deny-to-candidate.sh Add self-protection deny parsing test cases
compartment-bpf/tests/coverage/coverage-manifest.tsv Document stricter “witnessed” definition + audit notes
compartment-bpf/tests/counter-smoke.sh Update test count wording
compartment-bpf/tests/bypass/run-local.sh Fix REPO meaning; enforce witness set + single-label invariant
compartment-bpf/tests/bypass/lib-bypass.sh Add probe-integrity guard via loader wrapper + cleanup
compartment-bpf/tests/bypass/helpers/mount_witness.c New raw mount/move_mount witness helper
compartment-bpf/tests/bypass/helpers/ioctl32_setflags.c New 32-bit ioctl compat witness helper
compartment-bpf/tests/bypass/helpers/frozen_map_write.c New map-freeze “honest witness” runner
compartment-bpf/tests/bypass/helpers/frozen_map_write.bpf.c New BPF-side attacker program for freeze bypass measurement
compartment-bpf/tests/bypass/helpers/bpf_map_fd_sweep.c New helper sweeping map IDs for RO-fd leakage
compartment-bpf/tests/bypass/expected-witnesses.txt Track exact bypass witness set for runner parity
compartment-bpf/tests/bypass/exec-domain/lib-exec-domain.sh Add helper to treat DENY_MOUNT as a pass outcome
compartment-bpf/tests/bypass/exec-domain/BX-2-mount-namespace.sh Treat v0.8 DENY_MOUNT as pass; preserve skip otherwise
compartment-bpf/tests/bypass/exec-domain/BX-16-anon-bdev-refuse.sh Use real ELF fixture instead of symlink /bin/true
compartment-bpf/tests/bypass/exec-domain/BX-12-abi-size-gate.sh Fix bpftool syntax; assert fixture shape; avoid false-green skips
compartment-bpf/tests/bypass/exec-domain/BX-11-ld-preload-strict.sh Build fixtures in-place instead of referencing non-existent paths
compartment-bpf/tests/bypass/exec-domain/BX-1-bind-mount-actor.sh Treat v0.8 DENY_MOUNT as pass; preserve skip otherwise
compartment-bpf/tests/bypass/24-bpffs-umount.sh New witness denying bpffs detach/shadow of pin filesystem
compartment-bpf/tests/bypass/19-utimes-no-chmod.sh New witness for timestamp-forgery denial under no-chmod
compartment-bpf/tests/bypass/16-setfacl-no-chmod.sh New witness for POSIX ACL denial under no-chmod
compartment-bpf/tests/bypass/07-mount-bind-decoy.sh Update witness for v0.8 mount denial semantics + audit check
compartment-bpf/tests/bench/bpf-syscall-overhead.c New benchmark for self-protection hook cost
compartment-bpf/tests/bench/actor-overhead.sh Use real ELF fixture for exec-cost measurement
compartment-bpf/tests/bench-runner.sh Use real ELF fixture for mismatch actor selection
compartment-bpf/profiles/README.md Update profile counts; document limited-root authpath profile
compartment-bpf/oracle.conf Expand no-chmod definition with v0.8 coverage
compartment-bpf/ON-RAMP.md Add step-by-step suite run commands + expected tallies
compartment-bpf/kvm/quickstart-vng.md Clarify that transcripts are per-run artifacts, not committed
compartment-bpf/kvm/quickstart-vagrant/README.md Clarify that transcripts are per-run artifacts, not committed
compartment-bpf/.gitignore Ignore generated observe artifacts + root-owned wrapper build dir
.gitignore Ensure tests/scripts/*.d/ directories are not excluded by *.d
Review details
  • Files reviewed: 115/168 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread tests/scripts/make_fixtures.sh
Comment thread tests/scripts/run_kernel_matrix.sh
Canonicalize and refuse unsafe fixture roots before deleting or chmodding fixture contents. Align the default kernel matrix with its eight documented kernels. Prefer the packaged bpftool directory explicitly so Bash cannot retain the broken Ubuntu wrapper lookup.
@nmicic
nmicic merged commit 051a36b into main Sep 7, 2026
12 checks passed
@nmicic
nmicic deleted the release-1.4 branch September 8, 2026 11:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants