release: Compartment 1.4.0 (compartment-bpf 0.8.0) - #5
Merged
Conversation
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.
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.
There was a problem hiding this comment.
🔵 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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release Compartment 1.4.0 and the optional compartment-bpf 0.8.0 module from
release-1.4intomain.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:
--user-profiles.pivot_root.make installno longer installs example policies; usemake install-profilesexplicitly.0x0008, adds ACL/mount/ioctl enforcement and optional--self-protect/--authorize-loader, and requires root-owned maintenance images. Timestamp updates on directlyno-chmod-sealed files are now denied to non-actors. Seecompartment-bpf/CHANGELOG.mdandLIMITATIONS.mdfor 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 --checkpasses.make -C compartment-bpf check-coverage-staticpasses, including all 11 coverage-map self-tests; 12 declared exemptions remain.599d5a2); that historical result is not validation of the current release head.