Stop the prune phase producing a policy that is quietly incomplete - #19
Merged
Merged
Conversation
Seven defects, three of which each meant on their own that the prune phase could never have run. The pruner asked bubblewrap for --unshare-utc, an option it has never had; the option is --unshare-uts, which the allow-list in emit_artifacts.py has named all along, so the misspelling was being dropped before it could reach TailPhobos.cfg. Both prune images name run_minimal_fs_all.sh as their entry point, and it was committed non-executable, so the container stopped at exec; the script in turn requires detect_minimal_fs.sh to be executable. And log() returned 1 whenever logging was off, which under set -e ended the run at its first call, so a run without --verbose was never possible. The other four let a run finish successfully while producing less than it should. merge_tail appended every token regardless of whether it had seen it, so the tail grew on each run. A language whose pruning raised was reported and then merged around. A language that exited zero without producing artefacts, or produced a union naming nothing, dropped out of the merge silently. And an emitter failure was a warning, so a language kept the artefacts of whichever exercises happened to work. The last three matter because these files are a policy: everything they do not name is denied. Assembled from part of what was asked for, they are indistinguishable downstream from a policy for all of it. Each of those cases now stops the merge instead of narrowing it, and a language's artefacts from an earlier run are removed before it produces its own, so a leftover file cannot answer for a language that produced nothing today. That cleanup lives in the producer because the compose pipeline runs the orchestrator with --skip-prune. Testing this needed three seams: run_minimal_fs_all.sh takes TESTING_DIR, PRUNE_SCRIPT and OUTPUT_DIR the way it already took HELPER_DIR, and the orchestrator takes --prune-script and --core-dir. With stubs behind them the new suites need no bubblewrap, no container and no exercises. The new Test workflow runs those suites plus the two this repository already had and was starting from nothing.
8 of 13 tasks
MarkusPaulsen
pushed a commit
that referenced
this pull request
Sep 11, 2026
#19 merged, so GitHub retargeted this pull request from fix/prune-and-helper-defects to main, and main has since taken #21 and #23 as well. .gitignore was the only textual conflict. #23 rewrote the file with comments and section headings, and the two lines this branch added, __pycache__/ and *.pyc, are already covered there by __pycache__/ and *.py[cod]. Main's version is taken whole; the intent is checked rather than assumed, with git check-ignore over .pyc, .pyo and two __pycache__ paths. The four base-branch filters naming fix/prune-and-helper-defects are removed. Each carried a comment saying to remove it once that branch had landed, and it has: a filter naming a branch that no longer exists would be dead weight, and this pull request now targets main, which the remaining entry already matches. All four workflow files are byte identical to main again, which is what those temporary entries were always going to collapse to. What is left over main is exactly this branch's own work: the pruner, the two tail sanitisers, their tests and the new sandbox suite.
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.
Summary
Eight defects in the prune phase and the helpers that turn its output into an allow-list. Three of them each meant on their own that the prune phase could never have run at all. Four more let a run finish successfully while producing less than it should, which matters because what it produces is a policy: everything it does not name is denied. The eighth writes every tail flag twice. Adds a Test workflow that runs the suites this repository already had.
Linked issues
No linked issues.
1. Problem
The prune phase discovers which paths a language environment needs, and its output becomes an allow-list. Nothing ran it in CI, nothing tested it, and it had stopped working.
It could not start. It asked bubblewrap for
--unshare-utc, which has never existed. Both prune images namerun_minimal_fs_all.shas their entry point, and it was committed non-executable, so the container stopped atexec. Andlog()returned 1 whenever logging was off, ending the run at its first call underset -e.It could also finish while producing too little. A failing language was merged around. A language that exited zero producing nothing, or naming no path, dropped out in silence. An emitter failure was only a warning, so a language kept whichever exercises happened to work. A policy built from part of what was asked for is indistinguishable from one built from all of it.
And
merge_tailappended every token rather than only the unseen ones, so the tail gained a duplicate on each run.2. Improvement from the user's perspective
A submission is no longer at risk of running under an allow-list that is quietly narrower than the one the instructor asked for. That is the failure that is hardest to recognise from the outside: the sandbox denies a path the exercise legitimately needs, the submission fails, and nothing in the output says the policy was built from half a pruning run.
For an instructor generating a policy, a pruning run that goes wrong now says so and stops, instead of writing files that look complete. The prune phase can also be run at all, which it could not before.
3. Improvement from the maintainer's perspective
The two entry points and the orchestrator gained the seams they needed to be tested:
run_minimal_fs_all.shtakesTESTING_DIR,PRUNE_SCRIPTandOUTPUT_DIRthe same way it already tookHELPER_DIR, and the orchestrator takes--prune-scriptand--core-dir. With stubs behind them the new suites need no bubblewrap, no container and no exercises, so they run in seconds on an ordinary runner.CI now executes the suites rather than only linting the files. Three of the seven defects were the kind that a single test run would have caught the day they were introduced, and the executable modes are asserted, because every suite invokes its script through
bashand so none of them would ever notice a lost mode.4. Testing manual
Prerequisites
gcc,python3andbash, which the suites use. On Ubuntu,build-essentialandpython3.Steps
bash tests/prune_producer.sh.bash tests/timeout_units.sh.bash tests/network_cache_ports.sh.python3 -m pip install pytest==8.4.2and thenpython3 -m pytest tests/python -q. On a PEP 668 distribution, which Ubuntu 26.04 is, pip refuses to write into the system installation, so install it into a virtual environment or add--break-system-packages.docker run --rm -v "$PWD/var/tmp:/var/tmp" ubuntu:26.04 /var/tmp/pruning/run_minimal_fs_all.sh javaExpected result
7 passed, 0 failed.58 passed, 0 failed, 0 skipped.20 passed, 0 failed, 0 skipped.13 passed.[FAIL] Language folder not found: /var/tmp/testing-dir/java. That is the correct next error, because this branch adds no exercises. Before this branch the same command printedexec /var/tmp/pruning/run_minimal_fs_all.sh: permission deniedand never reached the script.Negative case (what must still be rejected)
Nothing here widens the sandbox; the point of the change is that a policy can no longer come out narrower than intended without saying so. Each fix is therefore checked against its own broken state, and each check fails when its fix is removed:
return 0fromlog()inrun_minimal_fs_all.sh:a run without --verbose completesfails.rm -f -- "${stale_artefacts[@]}"line:an earlier run's artefacts are removedfails, whileanother language's artefacts are left alonekeeps passing.an emitter failure fails the runfails.missing_languageswith an empty list inorchestrate.py: three tests fail, one for a language that produces nothing, one for a stale artefact standing in for it, and one for a result naming no path. The third belongs here because an empty union leaves the language out ofcollect_language_data, so this guard is what reports it, and removing the guard removes both protections at once.merged.append(t)back outside theifinemit_artifacts.py: two of the tail-merge tests fail.Confirm also that a language failing does not cancel the others:
test_pruning_still_runs_for_the_other_languagesasserts on a line the stub prints itself.Layers exercised
No layer-specific behaviour changed. The prune phase sits upstream of all three layers and produces the allow-list the filesystem layer later reads, so the suites here assert on that output rather than on a layer at runtime.
5. Test case coverage regarding this PR
tests/prune_producer.shtests/python/test_orchestrate.pytests/python/test_emit_artifacts.py--chdirand dropping a flag outside the allow-listtests/timeout_units.shtests/network_cache_ports.shRun on
ubuntu:26.04, kernel 7.0.12-linuxkit, arm64, in a container started with a plaindocker run --rm: no--privileged, no--cap-add, no--security-opt. None of these suites needs any of those, which is why they are the ones that can run on every pull request.Breaking changes and migration
Exit codes change, deliberately. A pruning run that previously reported success while producing an incomplete result now fails: a language whose emitter failed, a language that produced no artefacts, and a language whose result names no path all stop the run instead of being merged around. A pipeline that was silently generating partial policies will therefore start failing, and that failure is the point rather than a regression. The fix is to look at why that language produces nothing, not to reinstate the old behaviour.
run_minimal_fs_all.shnow honoursTESTING_DIR,PRUNE_SCRIPTandOUTPUT_DIRif they are set in the environment, andorchestrate.pygained--prune-scriptand--core-dir. All five keep their previous values as defaults, so an existing invocation behaves as before. No configuration file, policy key or path requirement changes.Checklist
--privileged,--cap-addor--security-opt, or the manual says why that was not possible.Review progress