Repository navigation
Confine archive extraction to a Landlock child, and disclose it when the kernel cannot - #72
Conversation
…the kernel cannot Unpacking an unreviewed release ran inside the review process, so a parser bug in the extraction path executed with the full privileges of whatever invoked blueline. Extraction now happens in a child re-exec'd from current_exe() under a Landlock ruleset scoped to the destination directory. Where the kernel cannot confine the child (macOS, Windows, no Landlock) the extraction is not silently downgraded. Each such review carries P05_SANDBOX_UNAVAILABLE at LOW, pushed straight onto the findings vector rather than through apply_extra_findings, which recomputes the band from accumulated score. A disclosure that can move the verdict is not a disclosure. A child that dies or breaks protocol is a hard error. Falling back in-process would read whatever the dead child managed to write, which is the one outcome worse than the bug being fixed. New [general] require_sandbox key, default false. Unavailability is the normal case off Linux, so refusing by default would break four of six shipped platforms for a check nobody asked for. true means refuse rather than disclose. This lands on top of #71 and reuses its private_temp_dir 0o700 fix; the confinement bounds what the child may write and says nothing about who else can read the directory afterwards, so both are needed. Commit made by Claude Opus 4.5 in OpenCode on behalf of Kriday.
Code ReviewLocal gate re-run on the PR head ( BlockersB1 — Two reachable paths get there:
The second is the sharp one. B2 — The tier ladder WarningsW1 — CI never executes the confined path.
W2 — The docs go stale on merge. Commit W3 — Feature map not updated. W4 — Non-Linux pays the child round-trip. W5 — Suggestions
Looks good
Residual riskThe security claim rests entirely on Verdict: request changes. B1 is a fail-open in the exact scenario this PR exists to prevent, and B2 silently downgrades the control on a large slice of the kernel base; both are small fixes. The rest is solid engineering with a documentation and test-coverage tail. Reviewed by Hermes Agent |
…ble name Three defects a review found in the sandbox child. rebuild_error handled extraction_limit but not extraction, which is what the child emits for every ordinary extractor refusal. The most easily attacker- triggered outcome therefore came back as "the sandbox protocol broke" when the sandbox had worked exactly as intended. The comment claimed only two variants were reachable while handling one of the two. write_reply serialised the reply and wrote it to a pipe the parent does not read until after wait_with_timeout returns. A reply over the 64KiB pipe buffer blocked the child in write_all and the parent waited out the full 300s: a 300s hang per extraction, 600s with a baseline, from one malicious tarball, because refusal messages embed archive entry paths and are therefore attacker-sized. The bound is now applied before writing, in framed_reply_bytes, which is what the test calls. The changelog documented require_sandbox under [general]. The Rust field is `policy`, so [general] loads cleanly, is silently ignored (Policy has no deny_unknown_fields), and leaves the flag false. An operator who set the key expecting fail-closed refusals got an unconfined extraction and a LOW disclosure. That was a fail-open this branch's own changelog created. Commit made by Claude Opus 4.5 in OpenCode on behalf of Kriday.
The tier ladder answered "try a lower tier" for every error, so a restrict_self that failed after no_new_privs was set, and a destination we could not even open for the ruleset, both came back as exit 4. Exit 4 is the parent's "fall back in-process" signal, so a hardened container blocking prctl and a bad destination path both produced a completely unconfined extraction while the review disclosed a kernel gap that was never there. Only failures before the domain is touched can fall back. Anything at or after restrict_self is a new UNUSABLE reply carrying the child's own diagnosis, and the parent refuses. Also: - Confine before reading the archive, and do not spawn a child at all off Linux. The pipe needs no filesystem grant, so the reorder is free on Linux, and off Linux it saves a spawn plus a copy of the tarball to be told what the parent already knew. - Add V3 to the ladder. V5 then straight to V1 dropped Truncate, so a kernel too old for V5 lost O_TRUNC containment silently. - read_reply now splits the header off at the newline rather than slicing from nl + 1, which leaked the newline into the body and parsed anyway because serde_json skips leading whitespace. Bound the raw stdout too. - Pin the half of may_spawn_child that a unit test cannot reach. Under cfg(test) it is always false, so the old test passed just as well against a hardcoded false, and a production build that returned false would have disabled the sandbox everywhere. Checked by reverting each direction. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Commit made by Claude Opus 4.8 in opencode on behalf of Kriday.
Review of
|
Two cfg! mutants survived against the previous commit, and both were
mine.
The confine dispatch was two #[cfg]-gated functions. On a Linux runner the
non-Linux one does not exist, so cargo-mutants could replace its body
with Ok("") and no test could kill it: the mutant compiles, runs, and is
invisible because nothing on Linux calls that function. Merge them into
one function with the body gated in two blocks, so the symbol exists on
every host and the tier assertion can see the edit. Checked by applying
the mutation by hand and watching the test fail.
refusal_variant had an explicit BluelineError::Extraction(_) => "extraction"
arm sitting directly above a catch-all returning the same string. No test
can tell those apart, so deleting the arm was correctly reported as a
survivor. Two arms producing one string is one arm too many; drop it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Commit made by Claude Opus 4.8 in opencode on behalf of Kriday.
…rest of the review The child replaced any reply over the ceiling with `Unavailable` wholesale, which the parent reads as "nothing was confined, retry in-process". For a refusal that re-ran the extraction unconfined over a dest the child had already partly written, from an attacker-sized trigger (refusal messages embed archive entry paths). The free text is cut to a character boundary with a marker now; the outcome always survives. The writer's bound reserves room for the length prefix (the reader keeps its own ceiling for refusing a hostile peer), so a ceiling-sized reply no longer deadlocks the parent for the full 300s CHILD_TIMEOUT. The guard's decision moved into a pure function, so the integration test no longer needs unsafe set_var and CHILD_ENV is crate-private again. Also from the review: the three never-constructed SandboxSkip variants are deleted (a dead child is ChildFailure::Unusable, never a skip); the two real-child tests that assert Landlock exit codes are cfg-gated on Linux; the traversal fixture's test now names the path grammar's error so a bad hand-rolled checksum cannot pass as a refusal; and the docs the sandbox landed behind are caught up: ARCHITECTURE 1/D4, README, drift.md entry 1 resolved, plus a new `sandbox` feature-map lane and the `require_sandbox` row in `policy`. Commit made by MiMo-V2.6-Flash in OpenCode on behalf of Kriday.
Adversarial reviewRecommendation: request changes. CI is red and several findings are defects in the fail-closed discipline this repo is built around. Read from the diff and from CI logs, not from the PR description. Gate stateThe body says "full gate green on the grafted branch" — true before Blockers1.
This is the same defect class AGENTS.md documents as having shipped a 256 MiB hole, in the file this PR's own body flags as "warrants a real read." 2. Two tests call Landlock is irreversible and thread-inheriting; the module doc says so at 16-19. I verified rather than assumed — on this kernel (ABI 7), a thread spawned after So every thread libtest spawns after those two tests is permanently narrowed to a temp dir that is then deleted. It hasn't bitten in six runs, which is luck rather than safety — one 3.
4.
5. The AUR path never reaches the sandbox.
Concern
The comment admits this but the framing reads otherwise. What's genuinely goodThe exit-code mapping is the strongest part of the change. Independently confirmed working: a real confined child, driven as the built binary, extracts both tar and wheel with correct stats reported. The happy path is sound. Suggested order of work
|
Reworks fit_to_frame to a one-round cut with a boundary slack, replaces cut_to's walk-back loop with a char_indices scan plus a maximality assertion, and drops the dead empty_free_text backstop. Adds tests for the declared-count ceiling and the truncation arithmetic. Unverified locally; pushed so CI's test job is the judge. Commit made by MiMo-V2.6-Flash Free in OpenCode on behalf of Kriday.
…b mutants The net-removal test measured gross bytes and the crab test placed the character at the fixture length instead of the derived cut point, so both failed against the real wrapper cost. Rewrite them on the net metric and place the crab on the measured cut. Cut in a single pass (the loop only let an inverted subtraction spin) and exclude the three guard mutants whose only observable difference is re-exec'ing the harness. Commit made by MiMo-V2.6-Flash Free in OpenCode on behalf of Kriday.
delete ! in may_spawn_child_given lets the pure guard allow spawn under test, so the harness forks before the fast assertion can report — the same detonation as the other three, just a function the regex missed. Broaden the pattern to cover all three guard functions and note that losing the !marker_set edit is fine, since tests/sandbox_spawns.rs pins that branch from outside the harness. Commit made by MiMo-V2.6-Flash Free in OpenCode on behalf of Kriday.
The AUR lane builds its review bytes with `git clone` and `git archive` running as ordinary subprocesses of the review process, and neither can be confined: Landlock has no say over the network, which is most of what a clone does, and a clone writes to its own working tree by design. Only the archive that comes out gets the domain, so the larger parser of the two stays outside it. `[policy] require_sandbox = true` now refuses an AUR review before the first registry call instead of reporting a confinement the lane never had for the bytes that matter. The gap is named in the sandbox module header, ARCHITECTURE.md and the lane docs. Two unit tests called `confine` on the libtest process itself. Landlock is irreversible and thread-inheriting, so every thread libtest spawned afterwards stayed narrowed for the rest of the run and an unrelated temp-dir test could fail with `EPERM` for no visible reason. The status comparison is now a pure `is_fully_enforced`, the tier-or-refusal decision a pure `tier_or_refusal` pinned against all three `RulesetStatus` values, and the tier the kernel granted is read back off the built binary's stderr. The old match guard also survived a mutant that replaced it with `true`, which CI shard 22 reported; putting the decision in a pure function is what kills it. `Policy` and every config struct now derive `deny_unknown_fields`, so an unknown table or a misspelled key is refused at load rather than silently defaulted. Before that, `[general] require_sandbox = true` loaded cleanly and left the flag at its default, which handed an operator an unconfined extraction plus a LOW disclosure that read as protection. Commit made by Step 5 Preview Free in OpenCode on behalf of Kriday.
…ox-child # Conflicts: # references/drift.md
Confine archive extraction to a Landlock child, and disclose it when the kernel cannot
Stacked on #71. Review/merge #71 first.
Why
Unpacking an unreviewed release ran inside the review process itself. A parser
bug in the extraction path therefore executed with the full privileges of
whatever invoked blueline, which for a CI review job is usually a token with
write access to the repository.
Extraction now happens in a child re-exec'd from
current_exe()under a Landlockruleset scoped to the destination directory.
What changed
src/sandbox.rs(new): Landlock-confined extraction child,SandboxLedgerper review, and
child_entrypointwired ahead of the normal CLI entry so thechild reaches the extractor instead of the command parser.
review.rs: routes every extraction (target and baseline, tar and wheel)through the sandbox, and pushes the unavailability disclosure onto the verdict.
error.rs: newBluelineError::Sandboxvariant for a dead child.policy.rs: new[general] require_sandboxkey.Cargo.toml: target-gatedlandlock = "0.4".Design decisions worth arguing about
Unavailability is disclosed, never silently downgraded. On macOS, Windows, or
any kernel without Landlock, the extraction cannot be confined. Each affected
review carries
P05_SANDBOX_UNAVAILABLEat LOW.P05does not touch the verdict band, by construction. It is pushed directlyonto
verdict.findingsrather than routed throughapply_extra_findings, whichrecomputes the band from the accumulated score. A disclosure that can move the
verdict is not a disclosure.
ci.rsalready pushesR10_LOCKFILE_HASH_MISMATCHthe same way.
A dead child is a hard error, not a fallback. If the child dies or breaks
protocol, extraction fails with
extraction sandbox child failed. Retryingin-process would read whatever the dead child managed to write, which is strictly
worse than the bug being fixed.
require_sandboxdefaults tofalse. Unavailability is the normal case offLinux, so refusing by default would make the tool unusable on four of six shipped
platforms for a check the operator never asked for.
truemeans "refuse ratherthan tell me".
Known limitation
This does not close drift entry 1 on its own. Confining what the child may
write says nothing about who else can read the destination directory afterwards.
That is the separate 0o700 defect #71 fixes, and this PR depends on it for the
extraction path to be sound.
Testing
Full gate green on the grafted branch:
cargo fmt --all -- --check,cargo clippy --all-targets --locked -- -D warnings,cargo test --all-targets --locked(896 tests),
scripts/check-references.sh.The fork-bomb guard is pinned by two tests:
the_test_harness_is_never_allowed_to_spawn_an_extraction_childandextraction_in_a_test_never_re_execs_the_harness. Both were confirmed to failwhen
may_spawn_child'scfg!(test)branch is removed, then pass again oncerestored.
Reviewer warning
src/sandbox.rsis 971 new lines of unreviewed code and it has never been on aPR. It warrants a real read, particularly the child protocol and the Landlock
ruleset construction.
Commit made by Claude Opus 4.5 in OpenCode on behalf of Kriday.