Make the pruner safe to run against code it did not write - #22
Merged
Merged
Conversation
The prune phase decides which paths a language environment needs, and what it decides becomes an allow-list. Running it against a pull request means running whatever that pull request brought with it, and it was not built for that. The invocation was rendered into one string and handed to bash -c, so every path went through a second round of shell parsing on the way in: a directory whose name held a quote, a dollar or a semicolon was a command waiting for its turn. It is an argument vector now, and the build script is passed as an argument rather than as text for a shell to interpret. --target had no business defaulting to the whole filesystem, and the descent into it was a prefix match rather than a descent: a target of /srv/work also caught /srv/work-secrets, a sibling nobody named, entered it writable and never revisited it, all the way into the policy. Two more comparisons had the same shape, so a child called /proc-secret counted as the /proc pseudo-filesystem and /target/app-extra counted as living under /target/app. The environment is cleared and rebuilt rather than inherited, so a token or a path to a runner control file is not visible to code the sandbox is meant to contain. The host /tmp is no longer mounted over the sandbox's own tmpfs, which had made that tmpfs pointless. --new-session detaches the sandbox from the terminal that started it, which without a seccomp filter is what stops TIOCSTI pushing characters back out. What is generated has to be the sandbox the measurement was made in. Both tail sanitisers kept a subset, so a policy built from a prune ran without /proc, without /dev, without a PID namespace of its own and without a new session. They now keep validated pairs, deduplicate whole options rather than tokens, and hold the same allow-list, which they had already stopped doing. detect_minimal_fs.sh had the same log() defect the producer had, ending the run at its first line whenever it was not verbose. Every check run by hand had used --verbose, so nothing had noticed. tests/prune_sandbox.sh runs the pruner for real against a fixture tree, with a bwrap on PATH that records the argument vector so a check can read what reached the kernel rather than what a diagnostic said afterwards. It keeps its scratch and its logs inside its own fixture, which is why the pruner takes a log directory and the producer honours TMPDIR. No CI job yet. It could only skip where a user namespace is refused, and a green job meaning either everything passed or nothing ran is not a check. It goes in when the capability probe has answered.
--lang assigned the programming language to LANG, which is the locale variable every C library reads. That was harmless while the sandbox inherited the environment wholesale and nothing looked at it, but this branch added PRUNE_ENV_PASSTHROUGH, whose default list names LANG as one of the few variables worth carrying across --clearenv. The sandbox therefore received --setenv LANG java: not the caller's locale, and not a locale at all. The pruner's own value is now PRUNE_LANG, so the passthrough carries whatever locale the caller actually has. Nothing else read the old name.
#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 was referenced Sep 11, 2026
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
The prune phase decides which paths a language environment needs, and what it decides becomes an allow-list. Running it against a pull request means running whatever that pull request brought with it, and it was not built for that. This closes the ways it could reach outside what it was asked to inspect, makes the generated policy match the sandbox the measurement was made in, and adds a test that runs the real pruner against a fixture tree.
Linked issues
Was stacked on #19. That branch has landed, so GitHub retargeted this pull request onto main, and the four temporary base-branch filters it carried are removed: each said to remove it once that branch had landed, and all four workflow files are byte identical to main again.
1. Problem
The pruner could reach outside what it was told to inspect.
--targetdefaulted to the whole filesystem, and the descent into it was a prefix match: a target of/srv/workalso caught/srv/work-secrets, a sibling nobody named, which entered writable and was never revisited, all the way into the generated policy. Two further comparisons had the same shape, so/proc-secretcounted as the/procpseudo-filesystem.The invocation was rendered into one string and handed to
bash -c, so a directory whose name held a quote or a semicolon was a command waiting for its turn. The environment was inherited whole, and the host/tmpwas mounted over the sandbox's own tmpfs.Separately, what a prune generates was not the sandbox it measured in: both tail sanitisers kept three of the options the pruner passes, so the policy ran without
/proc, without/devand without a PID namespace of its own.2. Improvement from the user's perspective
A generated policy now describes the sandbox the pruning run was actually measured in. A submission running under one keeps the process isolation and the
/procand/devthat the measurement assumed, rather than a weaker sandbox that happens to share its name.3. Improvement from the maintainer's perspective
The prune phase can be tested at all.
tests/prune_sandbox.shruns the real pruner against a fixture tree in seconds, needs no exercise repository, and keeps its scratch and its logs inside its own fixture. AbwraponPATHrecords the argument vector, so a check reads what reached the kernel rather than what a diagnostic said about it afterwards.That harness immediately earned itself: it found that
log()in the pruner had the same defect the producer had, ending any non-verbose run at its first line. Every check anyone had run by hand used--verbose, so nothing had noticed.4. Testing manual
Prerequisites
bubblewrap,python3and a shell. No exercise repository and no container image.PHOBOS_REQUIRE_BWRAP=1turns that skip into a failure. In a container, run as a non-root user: bwrap falls back on privileges that root inside a container does not have. On Docker Desktop add--security-opt seccomp=unconfined --security-opt systempaths=unconfinedas well, or the masked/procrefuses--proc /proc. Neither flag is--privileged, and nothing else here needs either.Steps
PHOBOS_REQUIRE_BWRAP=1 bash tests/prune_sandbox.sh.bash tests/prune_producer.shandpython3 -m pytest tests/python -q.--clearenvfromBASE_OPTIONS; put--bind /tmp /tmpback after--tmpfs /tmp; restoreTARGET="/"together with the three lines that validate it, the-nguard, therealpath -eand the-dcheck, since the default alone only reaches one of the two checks; change the glob ininit_configback to"$TARGET"*; and replace theBWRAP_COMMANDarray with the old string handed tobash -c._TAIL_MOUNTShandling together, and re-running step 2. Reverting only the two allow-lists fails three of the five, because the mount handling is a second mechanism rather than another entry in the list.Expected result
10 passed, 0 failed, 0 skipped. A skip here means the host refused a user namespace, and with the variable set that is a failure instead.7 passedand18 passed./tmpone, the two--targetones,a sibling sharing the target's name is never reached, and, for the string invocation, the run stops completing at all, which is itself the point.Negative case (what must still be rejected)
This narrows the sandbox rather than widening it, and each narrowing has a check that fails when it is undone, listed above. The two that matter most for a pull request:
target/andtarget-secret/for exactly this.There is deliberately no check for a file the injected payload would have created: the old code would have evaluated it inside the per-exercise workroot, which the producer removes as soon as that exercise is done, so such a marker is gone before anything could look for it.
Layers exercised
The prune phase produces the allow-list the filesystem layer reads. The network and timeout layers are not involved, and the suites covering them are run above only to show they still pass.
5. Test case coverage regarding this PR
tests/prune_sandbox.sh/tmpinvisible, the environment not inheritedtests/python/test_orchestrate.pytests/python/test_emit_artifacts.pytests/prune_producer.shtests/timeout_units.shtests/network_cache_ports.shRun on
ubuntu:24.04, kernel 7.0.12-linuxkit, aarch64. The sandbox suite needs a user namespace, which that container was given with--security-opt seccomp=unconfined --security-opt systempaths=unconfinedand a non-root user, never--privileged; every other suite runs in a plaindocker run --rm.Breaking changes and migration
--targetis now required.run_minimal_fs_all.shpasses--target "${PRUNE_TARGET:-/}", so the production path is unchanged, but a caller invokingdetect_minimal_fs.shdirectly must name the tree to prune.The sandbox no longer inherits the environment.
PRUNE_ENV_PASSTHROUGHnames what survives, defaulting toPATH HOME LANG LC_ALL TERM, and--envadds to it. A build script that relied on something else being set must have it named. The host/tmpis no longer visible inside the sandbox, and a generated tail now carries--proc,--dev,--unshare-pidand--new-session, so a policy regenerated after this change is narrower than one generated before it.PRUNE_LOG_DIRis new and defaults to/tmp, so nothing moves unless it is set.detect_minimal_fs.shkeeps the--langvalue inPRUNE_LANGrather than inLANG.LANGis the locale variable, and the passthrough above was carrying the language name into the sandbox as one. Nothing read the old name.Checklist
--privileged,--cap-addor--security-opt, or the manual says why that was not possible.Review progress