Skip to content

Build the run-phase image in CI and hold that exact image to the suites - #20

Merged
MarkusPaulsen merged 3 commits into
feat/landlock-instead-of-bubblewrapfrom
ci/build-and-acceptance
Sep 11, 2026
Merged

MarkusPaulsen merged 3 commits into
feat/landlock-instead-of-bubblewrapfrom
ci/build-and-acceptance

Conversation

@MarkusPaulsen

@MarkusPaulsen MarkusPaulsen commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

Builds the run-phase image in CI and runs the three Landlock acceptance suites against that exact image, in an ordinary container with no security flags and no network. The claim this branch is built on stops being something a reviewer takes on trust. Repairs three things that stood in the way, including a prune image that could not build at all, and adds unit tests, coverage and a weekly drift run.

Linked issues

Stacked on #8, and targets its branch rather than main.

1. Problem

This branch's central claim is that the filesystem sandbox now works inside a stock unprivileged container. Nothing in CI built the image or ran a suite, so the claim rested on someone having run them by hand and reported the numbers.

Three defects stood in the way. 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. The run-phase compose file pointed at a context holding no Dockerfile and no file its COPY lines match, so it could not build. And the python prune image could not build either: Ubuntu 26.04 ships a PEP 668 Python and pip3 install stops with externally-managed-environment.

The phase test also reached the network. Its probe POM named no compiler plugin version, so Maven asked for its default 3.13.0 while the image carries only 3.14.0. A registry outage could therefore have arrived looking like a sandbox regression.

2. Improvement from the user's perspective

No Improvement from the user's perspective.

3. Improvement from the maintainer's perspective

A reviewer no longer has to take the acceptance numbers on trust, or build the image by hand to check them. The suites run on every pull request against the image the same job just built, and a failure names which of the three it was.

The build context is assembled by one script that both CI and the acceptance README call. That recipe had already drifted once, when the wrapper was split into modules and the README kept listing a single .c file, and this is what stops it happening again. The script refuses a destination it did not create and clears one it did, so a stale file from an earlier context cannot be built in by a wildcard.

The mutation suite in this branch runs weekly rather than never. There is deliberately no coverage step: the unit suite interposes open and close to inject failures, which is also how gcov writes its data, so an instrumented run breaks the test asserting that a clean run says nothing. --coverage DIR still keeps the build for measuring by hand.

4. Testing manual

Prerequisites

  1. A checkout of this branch and Docker. Nothing on the host has to be prepared, and no container needs --privileged, --cap-add or --security-opt.
  2. gcc and python3 for the unit and coverage steps.

Steps

  1. Assemble the context and build the image:
    .github/scripts/assemble-run-phase-context.sh /tmp/ctx && docker build -f docker/run_phase/java/Dockerfile -t phobos-landlock:test /tmp/ctx
  2. Run each acceptance suite against it, with no network and no security flags:
    docker run --rm --network none -v "$PWD/tests/landlock-acceptance:/testsuite:ro" phobos-landlock:test bash /testsuite/run-tests.sh, then the same for extra-tests.sh and phase-test.sh.
  3. Run the unit suite and keep its coverage: bash tests/unit/run.sh and then bash tests/unit/run.sh --coverage /tmp/cov.
  4. Build the four prune images: for d in c java python orchestrate; do docker build -t "prune-$d" "docker/prune_phase/$d"; done.
  5. Run the assembler twice into the same directory, then once into a directory you created yourself holding a file.

Expected result

  1. The image builds. Before this branch the documented recipe worked, but only because it had just been repaired; the script is what keeps it working.
  2. bestanden: 11, fehlgeschlagen: 0, then 15, 0, then 13, 0. All three with --network none, so nothing in them depends on a registry being reachable.
  3. 134 passed, 0 failed. /tmp/cov then holds the .gcno and .gcda files, which is the point: before this branch that directory was deleted before anything could read it.
  4. All four build. python is the one to watch: on this branch's parent it stops with externally-managed-environment.
  5. The second run replaces the context, including removing a file you add to it between runs. The third refuses, names the directory, and leaves your file untouched.

Negative case (what must still be rejected)

The suites carry their own negative half and it is the reason they are worth running: a path outside the allow-list stays unreadable and unwritable, a write into a read-only tree is refused, and both hold for a child process and for a second JVM, as root and as a non-root user. extra-tests.sh also reads the forbidden file without the sandbox, so a denial cannot be a file permission wearing Landlock's clothes.

For this change specifically, the guard that must not be weakened is the container invocation. Adding --privileged, --cap-add or --security-opt to any of the three would make them pass while proving nothing, which is why the workflow spells all three out in a comment.

One honest limit: --network none guarantees the suites pass without a network, but it does not police the -o flags. Maven falls back to the local repository when it cannot reach a registry, so a removed -o still passes. The flag pins the property, not the spelling.

Layers exercised

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

run-tests.sh exercises all three in one run: denied and permitted paths, denied and permitted endpoints, and the timeout terminating a JVM that blocks SIGTERM.

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 15 0 0 Inheritance by children, non-root, unenforceable policy, the control probe
tests/landlock-acceptance/phase-test.sh 13 0 0 Tightening and widening rights across phases; changed here to run offline
tests/unit/run.sh 134 0 0 The wrapper modules; changed here to keep its coverage data
tests/network_cache_ports.sh 20 0 0 The address cache, now built with the image's own hardening flags

Measured locally on ls1tum/artemis-maven-template:java17-25, kernel 7.0.12-linuxkit, arm64, and reproduced by this pull request's own Build run on ubuntu-24.04, x86-64, with the same figures. All acceptance runs use docker run --rm --network none and no --privileged, --cap-add or --security-opt.

Breaking changes and migration

docker/run_phase/java/docker-compose.yaml now expects an assembled context at build/run-phase-context and no longer names the repository root. Anyone using it must run .github/scripts/assemble-run-phase-context.sh build/run-phase-context first, which the file says in a comment. It could not build before this change, so nothing that worked stops working.

The image tag it produces changes from ajayvir/phobos to phobos-run-phase-java. No configuration file, policy key, command line or exit code changes.

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, or the manual says why that was not possible.
  • 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

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.
@MarkusPaulsen
MarkusPaulsen requested review from a team and krusche as code owners September 9, 2026 21:47
Markus Paulsen added 2 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.
@MarkusPaulsen
MarkusPaulsen merged commit e4d7e16 into feat/landlock-instead-of-bubblewrap Sep 11, 2026
15 checks passed
@MarkusPaulsen
MarkusPaulsen deleted the ci/build-and-acceptance branch September 11, 2026 15:10
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