Skip to content

Enforce the filesystem layer with Landlock instead of bubblewrap - #8

Merged
MarkusPaulsen merged 22 commits into
mainfrom
feat/landlock-instead-of-bubblewrap
Sep 12, 2026
Merged

MarkusPaulsen merged 22 commits into
mainfrom
feat/landlock-instead-of-bubblewrap

Conversation

@MarkusPaulsen

@MarkusPaulsen MarkusPaulsen commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

The filesystem sandbox is enforced by Landlock instead of bubblewrap, so it runs in an ordinary build container with nothing prepared on the host. Access is named right by right rather than as two coarse groups, so a policy can permit writing without also permitting the creation of device nodes. A policy that names real TCP ports is enforced by the kernel and not only by a library a submission can step around.

Linked issues

Relates to ls1intum/artemis-ansible-collection#162, the closed pull request that would have rolled the AppArmor profile out to every build host.

1. Problem

Three things, all in the filesystem sandbox, the part that decides which paths a submission may reach.

Bubblewrap needs an unprivileged user namespace, and a stock container denies that three times over: the default seccomp profile blocks unshare, mount and pivot_root, the docker-default AppArmor profile carries a blanket deny mount, and Ubuntu 23.10 and later want an explicit userns rule. The AppArmor half cannot be supplied from inside the container at all, so the sandbox could never be switched on by the build alone. In the container used for this work unshare -Ur fails, so bubblewrap could not have run there.

A policy could say only "read" or "write", and "write" meant thirteen kernel rights at once, creating device nodes and symbolic links among them. Nobody chose that; nobody could avoid it.

The [network] section reached only a preload library, which is a request to the dynamic loader rather than a boundary.

2. Improvement from the user's perspective

An instructor no longer depends on an administrator having installed a profile on the build agent before an exercise can be sandboxed. The sandbox works in an ordinary container, started with a plain docker run.

A policy can now be narrower than before. Writing into a file, creating one, and deleting one are separate permissions, so a directory that only has to be written into no longer also gains the right to create device nodes and symbolic links in it. Those two are no longer granted at all.

Where a policy names real TCP port numbers, the kernel enforces them, so a submission cannot reach an endpoint by avoiding the preload library.

Where something cannot be enforced, Phobos says so rather than appearing to hold. A policy whose nested entry claims fewer rights than its parent grants is refused, a kernel too old to restrict truncation is named on standard error, and a network section that cannot be expressed switches the kernel layer off out loud.

3. Improvement from the maintainer's perspective

943 lines of host security configuration and its deployment script are gone, with the class of problem they brought: a profile that has to match across a fleet, hosts drifting apart, and a container that fails to start when its profile is absent.

Both entry points now share one translation from a parsed policy into wrapper arguments. They had already drifted apart once.

The language the C is built in is pinned rather than inherited from the base image, and the hardening the Dockerfile claims is proved by readelf during the build instead of asserted in a comment.

The wrapper is covered at 100% of lines and branches by a suite needing one compiler and nothing else, with every system call interposed through the linker.

README.md still describes bubblewrap and is deliberately not rewritten here: it narrates the whole sandbox, and that belongs with the configuration format change still to come. The documentation box is therefore left unticked.

4. Testing manual

Prerequisites

  1. A kernel offering Landlock, which the wrapper checks for itself, and Docker. Nothing on the host has to be prepared, and no profile has to be installed.
  2. This branch checked out. No other tool is needed: the image build brings its own compiler.

Steps

  1. Build the run-phase image from the repository root. The context is assembled by the script both CI and this manual call, so the recipe cannot drift from the Dockerfile.

    bash .github/scripts/assemble-run-phase-context.sh /tmp/phobos-ctx
    docker build -t phobos-run-phase -f docker/run_phase/java/Dockerfile /tmp/phobos-ctx
    

    Expected result: the build succeeds. It contains a readelf step that fails the build unless the wrapper is position independent and both compiled artefacts carry BIND_NOW, so a successful build is itself the evidence that the hardening is in place.

  2. Run the main acceptance suite. Note the plain invocation: no --privileged, no --cap-add, no --security-opt.

    docker run --rm -v "$PWD/tests/landlock-acceptance:/testsuite:ro" \
      phobos-run-phase bash /testsuite/run-tests.sh
    

    Expected result: the last line reads bestanden: 11, fehlgeschlagen: 0. Read the lines above it rather than the exit status: each names a path or an endpoint and says whether it was permitted or denied, and both directions appear.

  3. Run the additional suite the same way, replacing run-tests.sh with extra-tests.sh.

    Expected result: bestanden: 24, fehlgeschlagen: 0. Section F shows each right being granted on its own: w writes an existing file but cannot create one, m creates but cannot delete, d deletes. Section G shows that creating a device node and creating a symbolic link are refused while creating an ordinary file still works. Section C prints that unshare -Ur fails in this very container, which is the point: the mechanism this replaces could not have run here.

  4. Run the phase suite the same way, replacing the script with phase-test.sh.

    Expected result: bestanden: 13, fehlgeschlagen: 0. It tightens and widens the rights across four phases in one container and checks that a child process cannot shed the restriction.

  5. Run the three suites that need no image, from a checkout with gcc-14 installed.

    bash tests/unit/run.sh
    bash tests/timeout_units.sh
    bash tests/network_cache_ports.sh
    

    Expected result: 183 passed, 0 failed, then 58 passed, then 20 passed. The first asserts what the wrapper hands to the kernel, not only that it ran.

Negative case (what must still be rejected)

A path outside the allow-list stays unreadable and unwritable, and a write into a read-only tree is refused. Both hold for a child process started with ProcessBuilder, for a second JVM launched from inside the sandbox, and as root and as a non-root user alike. A control probe in section E reads the same file without the sandbox, so the denial is shown to come from Landlock and not from file permissions. Creating a device node or a symbolic link in a writable directory is refused. A policy whose nested entry claims fewer rights than an enclosing entry grants is refused with PHB-EPOLICY rather than run. The timeout still terminates a JVM that blocks SIGTERM with a shutdown hook.

Layers exercised

  • Filesystem layer
  • Network layer
  • Timeout layer
  • All three together, as a run uses them by default

5. Test case coverage regarding this PR

Suite Passed Failed Skipped What it covers regarding this PR
tests/landlock-acceptance/run-tests.sh 11 0 0 Denied and permitted paths, denied and permitted endpoints, the timeout
tests/landlock-acceptance/extra-tests.sh 24 0 0 Each right on its own in both directions, device nodes and symbolic links refused, inheritance by a child process and by a second JVM, non-root, unenforceable policy, symlinked write path, inherited rights reported
tests/landlock-acceptance/phase-test.sh 13 0 0 Tightening and widening rights across phases in one container
tests/unit/run.sh 183 0 0 The wrapper modules against an interposed kernel: option parsing, every combination of the six rights at every version, and what reaches the kernel
tests/timeout_units.sh 58 0 0 The timeout contract, and the wrapper entry point that assembles the arguments
tests/network_cache_ports.sh 20 0 0 The preload library, which this pull request does not change

Run on ls1tum/artemis-maven-template:java17-25, kernel 7.0.12-linuxkit, aarch64, Landlock version 8, started with a plain docker run --rm and no --privileged, --cap-add or --security-opt.

Breaking changes and migration

[hide] changes meaning. Landlock withholds access but cannot overlay a path with emptiness, so a hidden path with no allowed ancestor is denied yet stays visible by name, which is weaker than the tmpfs mask, and it is logged on every run. A hidden path beneath an allowed tree cannot be excepted at all, so that policy is refused with PHB-EPOLICY; list the permitted siblings instead.

The wrapper's path flags --ro, --rox, --rw and --rwx are replaced by --rights=LETTERS PATH. A caller that invokes the wrapper directly has to be changed; a policy file does not.

Creating device nodes and symbolic links is no longer granted anywhere.

A policy that names concrete TCP ports requires Landlock version 4, that is kernel 6.7. Every other policy runs as before.

The host no longer needs the AppArmor profile, and containers must no longer be started with --security-opt apparmor=docker-bwrap.

Checklist

  • The title of this pull request describes the change, not the implementation.
  • I have self-reviewed the diff of this pull request.
  • Tests were added or updated for the behaviour changed here, in both directions: the forbidden case stays denied and the permitted case still works.
  • Any weakening of the sandbox boundary is stated explicitly above, including what it now permits that it did not permit before.
  • The change was exercised in a container started without --privileged, --cap-add or --security-opt.
  • Documentation (README.md, the comments in core/) was updated where the change is user-facing.
  • CI is green, or every remaining failure is explained above.
  • No secrets, tokens or absolute local paths are contained in the diff.

Review progress

  • Code review
  • Manual test

Bubblewrap needs an unprivileged user namespace, which a stock Docker
container denies three times over: the default seccomp profile blocks
unshare/mount/pivot_root, the docker-default AppArmor profile carries a
blanket "deny mount", and Ubuntu 23.10+ additionally requires an explicit
userns rule. Lifting all three means shipping a custom AppArmor profile to
every build host and starting containers with --security-opt, so the sandbox
could never be enabled from inside the container alone.

Landlock inverts that: a task may always restrict itself further, so no
privileges, no capabilities and no container flags are required. The policy
is applied by a small static helper that reads the same ro/rw path spec the
bubblewrap call used, then execs the build command.

- core/phobos-landlock.c: applies the ruleset and execs. Refuses to run when
  the kernel cannot enforce the requested minimum ABI rather than degrading
  silently, and sets PR_SET_NO_NEW_PRIVS before restricting.
- core/phobos-filesystem.sh: --ro-bind/--bind become --rox/--rw. Landlock
  cannot overlay a path with emptiness, so [hide] paths are no longer masked;
  they are denied but stay visible, and each one is logged so the weaker
  guarantee is never silent. Write paths ending in "/" are created first,
  because a rule needs an existing path to open.
- core/config/TailPhobos.cfg: namespace flags are gone, only --chdir remains.
- The AppArmor profile, the seccomp profile and their deploy script are
  removed: nothing on the host has to be prepared any more.

Verified in an ordinary container started without --privileged, --cap-add or
--security-opt: denied paths stay denied for reads and writes, allowed paths
keep working, the restriction is inherited by child processes and a second
JVM, it holds for root and non-root alike, and the timeout still terminates a
JVM that blocks SIGTERM with a shutdown hook. In the same container
"unshare -Ur" fails, so bubblewrap could not have run there at all.
MarkusPaulsen pushed a commit that referenced this pull request Sep 8, 2026
Two corrections to the previous commit, both found by comparing against the
Ares2 originals rather than by reading my own version again.

Section 4 had lost its fill-in scaffold. Ares2 ends the section with
Prerequisites, Steps, Expected result, Negative case and the modes exercised,
so an author is asked for the negative case in the body of the pull request,
where a reviewer looks for it. My version dropped all of it and then
reintroduced the same idea as a checklist tick, which is weaker: a tick claims
the case was covered, the scaffold makes the author write down what it was.
The scaffold is back, with the four Ares enforcement combinations replaced by
the three layers this project composes, each of which can be disabled on its
own.

Section 4 had also lost both recurring meta-rules, its character limit and the
"simple words" paragraph, even though the template states in its own header
that they are repeated in every section where they apply. The counts now match
Ares2 exactly: seven limits, six "simple words" paragraphs, eleven "always
required" statements.

The template check itself is adopted, which is what makes the template binding
rather than advisory:

- .github/scripts/CheckPullRequestTemplate.java differs from the Ares2 version
  in a single line, the escape phrase for section 5, because this repository
  has no production Java code to report coverage for and answers that section
  with "No behaviour covered by the suites changed".
- .github/workflows/pullrequest-template.yml is taken over unchanged. It runs
  the checker in single-file source mode, so there is no build step, exempts
  Renovate and Dependabot at step level so a required check never hangs
  pending, and passes the pull request body through the environment rather
  than interpolating it into the shell, since a fork author controls it
  verbatim.

Running the checker locally against the open pull requests: #11 and #12 pass,
#8, #9 and #10 do not yet, and their descriptions are being brought into shape
separately.
@MarkusPaulsen
MarkusPaulsen requested review from a team and krusche as code owners September 8, 2026 14:55
@MarkusPaulsen
MarkusPaulsen requested a review from a team September 8, 2026 14:55
Markus Paulsen added 2 commits September 8, 2026 16:57
…osed

Three findings from comparing the two gcc lines against each other and against
what they actually produce.

The netblocker library was built without any optimisation level. On this
toolchain that is what decides whether _FORTIFY_SOURCE is set at all: Ubuntu's
gcc turns it on at level 3 as soon as optimisation is requested, and off
entirely otherwise. The library that sits on every connection a submission
makes was therefore the one artefact built without it. It now gets the same
-O2 -Wall -Wextra as the other.

-static overrides the -fPIE default and produces a fixed-address executable, so
the binary that applies the sandbox was the one part of the image with no
address space randomisation. -static-pie produces a position-independent one,
costs 480 bytes, and the acceptance suites pass unchanged.

Both are now linked with -Wl,-z,now, which completes RELRO.

Measured with readelf before and after rather than assumed:

  phobos-landlock   EXEC, partial RELRO  ->  DYN, full RELRO
  libnetblocker.so  no FORTIFY           ->  FORTIFY at level 3

Stack canaries and stack-clash protection were already in place; they come from
Ubuntu's gcc defaults and need no flags.

Separately, the legacy wrapper treated an unenforceable hide rule as a warning
while phobos-filesystem.sh aborts on it. That gap matters most for the
restricted command paths it also handles: those live under /usr, which the base
policies allow as a read-only tree, so Landlock cannot except them and the rule
can never hold. Warning and continuing there states a guarantee that is not
given. Both entry points now refuse the policy instead.
…-of-bubblewrap

# Conflicts:
#	docker/run_phase/java/docker-compose.yaml
@MarkusPaulsen
MarkusPaulsen force-pushed the feat/landlock-instead-of-bubblewrap branch from 234a2b7 to b6782e5 Compare September 8, 2026 14:57
Markus Paulsen added 10 commits September 8, 2026 17:13
main was 190 lines and did everything in sequence: parse, ask the kernel what
it can enforce, build the ruleset, add path rules, add port rules, change
directory, restrict, exec. It now names those stages and does nothing else, in
14 lines. Every stage is one function, and the two loop bodies that were the
longest passages inside main became functions of their own, so add_path_rule
handles exactly one path and add_port_rule exactly one port.

The command line ends up in a struct rather than in eight locals, which is why
each stage takes one argument. It lives in static storage now: at 4096 rules
it is too large to keep on the stack for no reason.

No behaviour change is intended. The ordering that matters is unchanged and
still explicit: chdir before the restriction, because the working directory
may sit outside the allow-list while the paths reached from it are inside it,
and no_new_privs before landlock_restrict_self, because Landlock requires it.

Nothing about this refactoring could have been caught by the existing suites,
because coverage showed that --connect-tcp, --bind-tcp and --min-abi were
never executed by any of them. That is now fixed rather than noted: the extra
suite starts two loopback servers and asserts that an allowed port is
reachable while another one is not, and checks that a --min-abi above the
kernel's aborts, that one below it runs, and that an unknown option is
refused.

  coverage   64.60% of lines -> 79.79%,  58.62% of branches -> 71.30%
  suites     11, 10, 13 -> 11, 15, 13, and 58 and 20 unchanged

Builds with -Werror -fanalyzer, clean under cppcheck.
The acceptance suites run against a real kernel, which is what shows the
sandbox works, but they cannot reach the paths that decide whether it fails
closed: a kernel that refuses a rule, an ABI older than this machine has, an
exec that fails. Coverage put a number on that gap at 64.60% of lines and
58.62% of branches.

tests/unit/ closes it. The source is included rather than linked so the static
functions are reachable, main is renamed so the file can provide its own, and
every syscall is interposed through the linker's --wrap, so a failure can be
injected without a special kernel or any container flags. 56 cases now cover
the rights tables at every ABI from 1 to 8, all five ways to be refused before
anything is applied, the three table limits, the ABI gate in both directions,
each of the ten syscalls failing, and the ten calls that go all the way
through.

  lines     64.60% -> 100.00% of 191
  branches  58.62% -> 100.00% of 104

Two findings came out of chasing the last few branches, and they are worth
more than the number.

A test was passing without exercising what it claimed to. The case for "the
path cannot be opened" set a mock and got the expected exit code, but from a
different failure: coverage showed the branch was never taken. It now names a
path that genuinely does not exist and no longer depends on a mock.

The condition "flag[3] == 'w' || flag[4] == 'w'" was dead in its second half.
The flags are --ro, --rox, --rw and --rwx, so position 4 is either x or the
end of the string and can never be w. Removed rather than covered.

The measurement itself needed two corrections before it could be believed. A
first attempt dumped coverage before the restriction was applied, so
everything after it looked unexecuted. A second used longjmp instead of fork,
which loses counters because it leaves the control flow gcov updates them on.
Both showed higher or lower numbers than the truth.

tests/unit/run.sh builds and runs the suite, and prints the coverage summary
with --coverage. It needs gcc and nothing else.
cppcheck runs over every .c file in the repository, including the new test
file, and flags an ignored return value. Silencing the child's stderr is not
optional here: if it fails, the case would be judged on output that was never
suppressed. It now exits with a distinct status instead.

Found by the CI rather than locally, because I ran the acceptance suites and
shellcheck before pushing but not the C gates over the new file.
The file was readable only to someone who already knew it. LL_FS_MAKE_REG,
LL_DIR_ONLY, attr, fd, abi, opts, and single-letter loop counters all asked
the reader to hold a glossary in their head.

The access-right constants get the names the kernel itself uses:
LANDLOCK_ACCESS_FS_MAKE_REG rather than LL_FS_MAKE_REG. That is both the
spelled-out form and the one the Landlock documentation uses, so the code and
the manual page can now be read side by side.

Everything else is spelled out plainly. A few of the renames:

  LL_DIR_ONLY          DIRECTORY_ONLY_ACCESS_RIGHTS
  MAX_RULES            MAXIMUM_PATH_RULES
  EXIT_POLICY          EXIT_CODE_POLICY_ERROR
  fs_rights_for_abi    filesystem_rights_for_version
  grant_for            rights_granted_for
  vlog                 log_verbose
  fail / fail_msg      exit_with_system_error / exit_with_message
  usage                print_usage_and_exit
  abi                  landlock_version
  attr / beneath       attributes / path_rule_attributes
  fd / ruleset_fd      descriptor / ruleset_descriptor
  st                   file_status
  rule->write / ->exec rule->writable / rule->executable
  r, p, i              rule_index, port_index, argument_index
  argc, argv           argument_count, arguments

"ABI" is gone as well. The kernel calls these numbers ABI versions, short for
application binary interface, and they are versions and nothing else, so the
file calls them versions. The header says once that the kernel documentation
spells it ABI, so searching for that term still leads here. The command line
option follows: --min-abi is now --minimum-landlock-version.

The usage text spells out its placeholders too: PATH, PORT, DIRECTORY, NUMBER,
COMMAND and ARGUMENTS rather than P, N, D, CMD and ARGS.

Reformatted to a 96 column limit afterwards, because the longer names pushed
several lines well past it.

No behaviour change. All suites pass unchanged (11, 15, 13, 58, 20), the unit
tests still cover 100% of lines and branches, and gcc with -Werror -fanalyzer
and cppcheck are clean.
…iven

The names now say what they mean, including the ones taken from the kernel
headers. The header carries a mapping table, so the kernel documentation stays
searchable from this file: LANDLOCK_ACCESS_FILESYSTEM_* against the kernel's
LANDLOCK_ACCESS_FS_*, MAKE_SOCKET against MAKE_SOCK, READ_DIRECTORY against
READ_DIR, struct landlock_ruleset_attributes against landlock_ruleset_attr,
SYSCALL_NUMBER_LANDLOCK_* against __NR_landlock_*. TCP keeps its abbreviation,
because that is the name of the protocol.

Alongside that, three input problems, and one of them mattered.

atoi answers 0 for anything that is not a number, so
"--minimum-landlock-version abc" silently asked for version 0 rather than
being refused. In a tool whose whole point is to fail closed, a typo made the
policy weaker without saying a word. strtoull had the same hole for ports, and
no range check either, so "--connect-tcp 99999" reached the kernel as a port
that cannot exist.

Both now go through one function that rejects an empty value, a value with
anything after the digits, an overflow, and anything outside the range the
option can mean: 1 to 65535 for a port, and a Landlock version this build
actually knows.

The third is smaller: flag_name[strlen(flag_name) - 1] reads index -1 if the
flag is ever the empty string. All four flags are longer than that today, so
this is a guard against a future edit rather than a live defect, and it says
so.

strcmp stays. It reads rather than writes, and its arguments come from argv,
which the kernel guarantees to be null-terminated. The dangerous relatives
(strcpy, strcat, sprintf) do not appear in this file at all.

One test had to change with the behaviour: a demanded version of 99 is now
refused while the arguments are read, so the comparison against the kernel's
version is reached with a demand of 5 against a kernel reporting 3.

Nine new cases cover the rejected values. Coverage stays at 100% of lines and
branches, now over 210 lines and 116 branches, with 65 cases. All suites pass
unchanged, gcc with -Werror -fanalyzer and cppcheck are clean.
Preparation for the move to C++. The file had grown to 522 lines and held five
structures with the functions belonging to each of them mixed in between. Each
structure that will become a class now has its own pair of files, so that the
later step is a translation rather than a rewrite.

  phobos-landlock-path-rule    struct path_rule and the two questions asked of
                               it: which rights it grants at a given version,
                               and how it has to be opened.   -> class PathRule
  phobos-landlock-options      struct options and the reading of the command
                               line into it.                  -> class Options
  phobos-landlock-ruleset      the kernel object: create it, add a rule, apply
                               it.                            -> class Ruleset
  phobos-landlock-diagnostics  reporting and giving up.       -> free functions
  phobos-landlock.c            the stages and nothing else, 97 lines.

The three landlock_*_attributes structures stay raw data inside the ruleset
module. Their layout is fixed by the kernel and they exist only to be passed
to a system call, so making classes of them would force a second, raw
structure alongside each one.

One boundary is drawn tighter than before: the ruleset no longer sees the
options at all. It takes one path rule, one port, one minimum version, so the
later Ruleset::addPathRule(const PathRule&) needs no knowledge of how the
command line was written. The loops over the options moved to the stage
sequence, where they belong.

The split also removed a workaround. The unit tests used to include the whole
source file to reach its static functions; now they link the modules and
include only the stage sequence, for its main.

No behaviour change. All suites pass unchanged (11, 15, 13, 58, 20), the 65
unit tests still cover 100% of lines and branches, now measured per module,
and gcc with -Werror -fanalyzer, cppcheck and shellcheck are clean.
The suite had every line and every branch covered, yet a mutation run killed
only 69 percent of the faults it introduced. The reason was one gap: the
wrapped syscall read the attribute pointer as an unsigned and therefore never
looked at it. The tests proved that the calls happened and never what they
carried, which is the whole of the policy. A file could be given the rights
only a directory may hold, a port rule could be dropped, the rule loop could be
cut to its first turn, and every case stayed green.

The mock now reads each call's arguments in the branch that knows their types
and records them in shared memory, so the parent can judge what the forked
child handed over. On top of that the cases that sit exactly on a limit: the
highest port, a kernel exactly at the demanded version, version 4 with network
rules, descriptor 0 from both the ruleset and an opened path, and a rule table
filled to the brim.

One case turned out to be green for the wrong reason. With the table guard
moved by one, "one rule more than the table holds" still exited 125, because
the run wrote past the table and failed later at something else. It now also
asserts that a refused table hands no rule to the kernel at all.

tests/unit/mutation.sh reproduces the measurement. It mounts the repository
read-only and works on a copy, so a run leaves nothing behind.

Mutation score 69 to 87 percent, 66 survivors to 26. The rest are assertions
about message text and logging rather than about behaviour.
Splitting the wrapper into one file per structure left the documented build
context copying phobos-landlock.c alone. The Dockerfile compiles
phobos-landlock*.c, so following the README verbatim fails on the missing
modules and headers.
The remaining mutants all changed a message and nothing else: a warning that
appears one version too early, a refusal that names the wrong reason, a verbose
line that vanishes. The child's diagnostics went to /dev/null, so no case could
tell. The message is what a person reads when a policy is turned down, and it
is the only thing that separates "this kernel has no Landlock" from "this
kernel is too old", so it is worth as much as the exit code.

expect_exit now sends the child's stderr to a temporary file and reads it back
once the child has been waited for. Every refusal is now judged by what it
names, a run that goes through has to say nothing at all, and --verbose has to
report the version and each rule it applied. The wrapped close records which
descriptors were handed back.

One assertion was too weak, and the mutation run said so. Requiring only that
the empty value is reported left the abort behind it removable: the value then
falls through to the conversion, which reports the same input a second time as
something else. The case now also requires that second message to be absent.

Mutation score 87 to 98 percent, 26 survivors to 4. Of those four, three are an
artefact of the measurement: removing exit() from a _Noreturn function is
undefined behaviour that clang has already assumed away, and the suite kills all
three under gcc. The fourth, verbose = 1 mutated to another non-zero value, is
genuinely equivalent.
The run-phase image is the artefact: it is where both C products are
compiled and what an exercise runs in. Nothing built it, so the
acceptance suites had only ever run by hand, and the claim they exist to
make, that the sandbox works in an ordinary container, rested on someone
having run them.

The image is built and exercised in one job. Loading an image populates
one runner's Docker daemon and no other, so a separate acceptance job
would quietly build a second image and test that instead.

Three things had to be repaired before any of it could run.

The unit runner built its instrumented copy in a directory it removed on
the way out, so asking for coverage and then looking for the data found
nothing; --coverage now takes a directory and keeps it. The run-phase
compose file pointed at a context holding no Dockerfile and no file its
COPY lines match. And the python prune image could not build at all:
Ubuntu 26.04 ships a PEP 668 Python and pip stops with
externally-managed-environment.

The acceptance suites now run with no network at all. The phase test
compiled online, because the probe POM named no compiler plugin version
and Maven's default is 3.13.0 while the image carries only 3.14.0;
naming it lets every Maven call run offline. A network failure can no
longer arrive dressed as a Landlock regression.

Nothing is mounted over /root/.m2 either. The base image ships a
populated repository and a cache volume mounted there hides it, which
makes the unrestricted clean steps fail for want of a plugin.

The build context is assembled by one script that both CI and the
acceptance README call, so the recipe cannot drift from the Dockerfile
again. It refuses a destination it did not create and clears one it did.

The weekly run builds with no cache and pulls its base images, since a
cache hit is exactly what would hide the drift it exists to find.
Markus Paulsen and others added 4 commits September 10, 2026 05:57
The Build workflow never started. This organisation allows only actions
it owns and actions GitHub publishes, so docker/setup-buildx-action and
docker/build-push-action were refused before any job ran, whatever their
pinned commit. Every workflow already here uses actions/* alone, which is
why nothing had run into this before.

Plain docker build does the same work: buildx is on the runner, --pull
and --no-cache carry the weekly freshness the cache backend would have
undermined anyway, and the run-phase image still lands in this runner's
own daemon, so the suites still exercise the image the job just built.

The matrix values reach the shell through the environment rather than by
interpolation, which is the shape to avoid having anywhere, safe values
or not.

Lint and the template check also filter their pull_request trigger on
main, so neither ran on a pull request stacked on the feature branch.
Both now name that branch too, with the same note to remove it once the
branch has landed.
The unit suite wraps open and close through the linker, which is how it
injects syscall failures. The gcov runtime writes its .gcda files with
those same calls, so an instrumented run has gcov failing to write and
reporting so on stderr. That output breaks the test asserting that a run
which goes through says nothing, and it means any figure would have come
from a perturbed run.

It passed locally and failed in CI, which is the worst shape for a check
to have. Removed rather than silenced: tests/unit/mutation.sh already
measures this suite, and it measures the thing coverage only gestures
at, namely whether a test would notice the code being wrong.

The --coverage directory argument stays. It fixes a real defect, that
the instrumented build was deleted before anything could read it, and it
is still what someone investigating locally would reach for.
The acceptance suite's environment header read the ABI out of the wrapper's
--verbose output by matching on "Landlock ABI", which the wrapper has never
printed: it names the number "Landlock version". The sed therefore matched
nothing and the line rendered blank.

Nothing asserts on the header, so no check ever failed over it. That is
precisely why it went unnoticed, and it matters more now that CI runs this
suite: a reviewer reading the log finds a blank where the kernel's ABI
version should be.
Build the run-phase image in CI and hold that exact image to the suites
Markus Paulsen added 2 commits September 12, 2026 21:15
The sources were built with no -std at all, so the language was whatever the
compiler happened to default to. Measured in the run-phase image: it is Ubuntu
24.04.3 with gcc 13.3, defaulting to C17, and gcc changed that default between
its own versions 14 and 15. A base image update could therefore have changed
the language the sandbox is built in without anyone touching a line.

gcc 13 does not know -std=gnu23 at all; it answers "unrecognized command-line
option" and suggests the draft name. So gcc-14 is installed rather than assumed,
and the same -std=gnu23 now appears at all seven places that compile C: both
artefacts in the Dockerfile, the lint workflow, the two builds in the unit
runner, the two in the network suite, and the mutation run under clang-18. The
unit runner names gcov-14 as well, because coverage notes can only be read by
the gcov that matches the compiler that wrote them, and the build workflow
installs gcc-14 rather than relying on the runner's default.

cppcheck runs with --std=c23, which the installed 2.13.0 accepts.

The Dockerfile also stops claiming its hardening and starts proving it. The
comments explained why -static-pie replaced -static and why -Wl,-z,now was
added, but a comment cannot notice a later edit removing them. A readelf check
now fails the build unless the wrapper is DYN and both artefacts carry BIND_NOW.

No behaviour change: the existing sources are valid C23 and every suite passes
unchanged.
… needs

A path rule carried two flags, writable and executable, and "writable" meant
thirteen kernel rights at once: write, truncate, rename, delete, and create
files, directories, sockets, pipes, symbolic links, character devices and block
devices. A policy could not ask for less. Granting a build directory the right
to make device nodes is not a decision anyone took; it is one nobody could
avoid.

struct path_rule now holds six independent fields, and --rights=LETTERS PATH
replaces --ro, --rox, --rw and --rwx: r read, w write, x execute, m create,
d delete, i ioctl on a device. Letters are validated, an unknown one or a
repeated one is refused rather than absorbed, since a repetition in a policy is
a typo far more often than an intention. The old flags are removed outright
rather than kept as aliases: reusing --rw with a narrower meaning would have
been the one change that fails quietly.

Creating device nodes and symbolic links is now granted by no combination at
all. They stay in the handled set, so they are denied rather than unregulated.
REFER, which is what lets a file move between two directories, is granted only
where both creating and deleting are, because that is what moving is. ioctl
gets its own letter and no section default hands it out.

Landlock unions the rights of every rule along a path: a nested rule can only
add, never take away. Measured on Landlock 8 with a static ELF, since a shell
script only proves that its interpreter was executable:

  ancestor rx, child r     -> still executes
  ancestor r,  child rx    -> executes
  ancestor r,  no child    -> refused

So a policy declaring fewer rights on a nested path states a restriction that
will not hold, and phobos-common.sh now refuses it with PHB-EPOLICY, the same
way an unenforceable [hide] entry is already refused. A policy declaring merely
different rights is normal and stays allowed, but the inherited widening is
reported and the effective union is what reaches the kernel, so the verbose
output cannot disagree with what is enforced. Paths are compared after symlink
resolution, because /bin is a symlink to /usr/bin in the run-phase image and a
textual comparison sees two trees where the kernel sees one.

A kernel too old to handle a right is a gap Landlock offers no hook to close,
so it is reported unconditionally instead of silently tolerated: below version 3
a file on a read-only path can still be emptied with truncate(2), which the
suites confirm, and below version 5 ioctl on a device is unrestricted.

phobos_wrapper.sh now sources phobos-common.sh, so both entry points share one
translation rather than keeping two that already drifted apart once.

134 unit tests to 177, still 100% of lines and branches; acceptance suites 11,
15 to 24, and 13; timeout and network suites unchanged at 58 and 20.
Markus Paulsen added 3 commits September 12, 2026 21:18
The [network] section reached libnetblocker and nothing else. That library is
injected with LD_PRELOAD, which is a request to the dynamic loader rather than a
boundary: a statically linked program, a direct system call, or simply clearing
the variable before a child walks straight past it. The wrapper meanwhile had
--connect-tcp and --bind-tcp options that no policy file could ever reach, so
the one mechanism that cannot be stepped around sat unused.

Both entry points now translate the section through the same function. A rule
naming a real port becomes --connect-tcp, duplicates are folded, and the kernel
denies every other connection. libnetblocker stays and is still the only layer
that can judge a hostname or an address, which Landlock cannot express at all;
the two are not alternatives.

Landlock filters by port and knows nothing else, so a rule without a port cannot
be translated. The three shipped policies are exactly that case: 127.0.0.1:*,
::1:* and localhost. A section made only of such rules therefore leaves the
Landlock network layer off and says so on stderr rather than quietly. A section
mixing a wildcard with a concrete port is refused with PHB-EPOLICY instead,
because the explicit port would read as enforced and would not be. A port that
is not a number, or is outside 1..65535, is a policy error and no longer treated
as a wildcard.

The ruleset now handles only the directions the policy speaks about. Handling
both because one was mentioned would have denied the other outright: a policy
naming where a build may connect would have stopped it from listening at all,
breaking any exercise whose tests start a local server. That was not a decision
anyone took, and the two new cases assert it does not happen.

Kernel 6.7 is required only by a policy that names concrete ports, so nothing
else changes its reach.

177 unit tests to 183, still 100% of lines and branches; every suite unchanged.
Three files conflicted, and two of them were decided by main: .gitignore, whose
commented version is a superset of the terse block on this branch, and the
python prune image, where the difference was a pytest and ruff bump.

The run-phase image needed judging rather than choosing a side. main pins the
base image by digest, which this branch keeps. main also still installs
bubblewrap and compiles the preload library without the hardening flags, and
neither may come back: removing bubblewrap is what this branch is for, and the
flags were added here deliberately. So the result is this branch's build with
main's digest.

The readelf check that proves the hardening was rewritten to use files instead
of pipes. hadolint fails at warning level, and a pipe in a RUN without pipefail
is DL4006, so the check as written would have broken CI the moment this merge
landed.

main brought two new C sources, and the lint gate on this branch is stricter
than the one they were written against: gcc-14 with -std=gnu23, -Werror and
-fanalyzer, plus cppcheck at --std=c23. Both pass unchanged, as does the
rewritten netblocker.c.

Verified against the merged tree: 183 unit tests, acceptance suites 11, 24 and
13, timeout 58, network 20, shellcheck clean over all 20 shell files, and the
run-phase image builds.
AGENTS.md landed on main while this branch was being written, and the
translation added here broke three of its rules. They are worth following rather
than arguing with, and one of them found a real problem.

"No comments inside a function body. A function that needs one is a function that
should be two." build_path_args carried seven comment blocks, which is the rule
saying plainly that it did seven things. It is now a sequence of named steps in
the same shape phobos-landlock.c already uses: collect_rights_table,
fold_table_by_target, report_folded_widenings, resolve_rights_hierarchy,
emit_rights_arguments, with refuse_dangling_symlink and materialise_write_path
beside them. build_network_args is split the same way, into
collect_network_ports and refuse_unusable_port.

Splitting build_network_args surfaced the trap that had already been fixed twice
in this branch: report() prints on stdout, so a helper returning data through
stdout would swallow its own refusal. collect_network_ports therefore returns
both of its results through named files.

"One variable or function declaration per line, in every language." Eleven local
lines declared several at once.

"Every function in core/*.sh says what it does and what it assumes about its
environment." The new helpers now say both.

No behaviour change, and the three direct tests of the translation produce
byte-identical output: the alias case, the hierarchy cases including the one
through a symlink, the dangling symlink, and every network classification.
183 unit tests, acceptance 11, 24 and 13, timeout 58, network 20.

The readelf check in the Dockerfile also moves here in spirit: hadolint rejects
a pipe in RUN without pipefail at warning level, which is this repository's
failure threshold, so the check writes files and greps them instead.
@MarkusPaulsen
MarkusPaulsen merged commit 8313811 into main Sep 12, 2026
24 checks passed
@MarkusPaulsen
MarkusPaulsen deleted the feat/landlock-instead-of-bubblewrap branch September 12, 2026 23:01
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.

1 participant