Repository navigation
feat(minimald): reject UDP 443 from a blocked cohort's own leaves (T72) - #2601
norrietaylor merged 14 commits into
Conversation
A socket keeps the cgroup it was created in. So a host_ip member's process moved into boxes/quic443-blocked still sent UDP 443 from a socket it opened in its own leaf before the cohort turned blocked. - The classifier table declares the quic443_blocked_leaves set of box leaves, and the output chain rejects UDP 443 from a socket whose leaf is in it, with ICMP port unreachable. - The install writes a root-owned helper that adds or removes one leaf under boxes/allow in that set and refuses every other path, plus a sudoers drop-in that lets the delegated account run only it with sudo -n. - CohortQuic443 keeps every resident allow member's own leaf in the set while the cohort blocks, and logs one info line per set change. The launch and stop paths run the helper through sudo -n, and a refusal is logged without failing the box. The process moves stay.
The harness installs the helper under its scratch root, checks the set and its reject rule in the loaded table, and runs the helper: it changes one allow-subtree leaf and refuses every other path.
…-443-from-a-blocked-cohort-s-own-leave
|
Important Review skippedBot user detected. To trigger a single review, invoke the
|
|
Reviewed: 1 finding, none blocking at Address the comments on the diff. Clobber: none seen. |
There was a problem hiding this comment.
arch-review: CONDITIONS · 2 conditions · the full ruling
The leaf set and its reject rule close the pre-flip gap that §5.3 line 150 requires closed. One path fails open. When the helper refuses or is missing, sync_leaf_set drops the add and admit returns Ok, so the cohort blocks only in part. C1 makes admit refuse the launch, as NoBlockedLeaf does today. C2 changes the test that expects the fail-open result. The escalation asks the design to record the runtime sudo helper in §7.4.
Ruled at 0437447 against gominimal/arch@109541a48c31 by claude-opus-5-5.
The cohort keeps a leaf recorded when its remove fails and the leaf still exists, so the next cohort change retries it. A leaf that is gone is dropped, because its cgroup id is never reused. A real install of the QUIC block helper refuses the sudoers drop-in when visudo is missing, because an unchecked drop-in can break sudo.
|
Round 1 of 10 on the runtime's review findings, pushed as
Verify Review threads the coder judged:
A host_ip cohort member's UDP 443 socket opened before the cohort turns blocked stops sending when the block flips, because its own leaf goes into the blocked-leaves set. It sends again when the block lifts. A remove that fails is now retried on the next cohort change, so a surviving box is not left blocked. Notes from the agent: Revise round 1. The sync now keeps a leaf recorded when its remove fails and the leaf directory still exists, and the next cohort change retries it. A leaf whose directory is gone is dropped, because the kernel never reuses a cgroup id and nft cannot resolve the path to delete it. A real install now refuses the sudoers drop-in when visudo is missing. A rehearsal still skips the check, because its drop-in is under a scratch root that sudo does not read. New refusal text: 'cannot install the sudoers rule for the QUIC block helper at : visudo is not installed, so the rule cannot be checked. Install the sudo package, then run this installer again.' I also ran |
There was a problem hiding this comment.
arch-review: PASS_WITH_CONDITIONS · 2 conditions · the full ruling
The compare fixes the two fail-open paths. A failed remove is now retried, and a real install refuses a sudoers rule that it cannot check. The set, the reject rule and the root helper conform to §5.3 line 150, §4.1 line 54 and §7.4 line 275. Two conditions stay. C1: the min finalize-install diagnostics item is undone and has no issue, so open one and link it. C2: the new CgroupMoves set methods default to Ok(()). Make them fail closed, and correct the stale sync_leaf_set doc comment.
Ruled at 4964cfd against gominimal/arch@109541a48c31 by claude-opus-5-5.
A refused add to the blocked-leaves set while the cohort blocks now returns CohortPlaceError::Unlisted from admit, so the launch fails instead of leaving a pre-flip socket able to send UDP 443. The CgroupMoves defaults fail with Unsupported, so a seam that changes no set never records a leaf as listed.
…-443-from-a-blocked-cohort-s-own-leave
There was a problem hiding this comment.
arch-review: PASS_WITH_CONDITIONS · 2 conditions · the full ruling
The new commit closes the fail-open gap. A refused add to the blocked-leaves set now fails the launch, so the block fails closed as §5.3 line 150 requires. BEP-018 conforms. Two items stay open and need issues. The kernel leg of the proof is not run (C1). The min finalize-install helper diagnostics item is left undone with no issue (C2). Open both issues and link them in the PR body.
Ruled at cc74186 against gominimal/arch@109541a48c31 by claude-opus-5-5.
|
Round 2 of 10 on the runtime's review findings, pushed as
Verify Review threads the coder judged:
A host_ip cohort member's UDP 443 socket that opened before the cohort turned blocked is in a leaf in the blocked-leaves set, so it stops sending when the block flips and sends again when the block lifts. If the helper refuses to add a leaf, the launch fails and the block does not fail open. Notes from the agent: Revise round 2. A refused add while the cohort blocks now returns CohortPlaceError::Unlisted from CohortQuic443::admit. The launch then fails through the existing join_quic443_cohort path, which departs the box. The CgroupMoves add and remove defaults now return ErrorKind::Unsupported. Not done, outside the layers: (1) the |
cc74186 to
f5ffed1
Compare
There was a problem hiding this comment.
arch-review: PASS_WITH_CONDITIONS · 3 conditions · the full ruling
The change conforms to §5.3 line 150 and §4.1 line 54. The head has the same tree as cc741861, so the earlier ruling stands. Three records are open. C1: open a gominimal/arch issue that records the sudo -n helper in §7.4 line 275, because the design names only the one-time step. C2: file the min finalize-install diagnostics gap. C3: file the kernel leg that proves a pre-flip socket stops and starts. Link all three issues from the PR body.
Ruled at f5ffed1 against gominimal/arch@109541a48c31 by claude-opus-5-5.
There was a problem hiding this comment.
Own review at f5ffed1, focused on the new root surface.
- The sudoers drop-in names one root-owned helper and is checked with
visudo -cfbefore it is moved in. A real install refuses without visudo. - The helper takes only
addorremoveand one leaf name under the allow subtree, matching[A-Za-z0-9._-]. It refuses a symlink or a non-directory on add, and it changes onlyquic443_blocked_leaves. Its paths and the nft binary are fixed at install. - The daemon runs it with
sudo -n, so a refused add fails the launch.
Arch conditions C1 and C2 are filed as #2606 and #2607. One thread stays open for the owner: the installer leaves the helper and its rule in place when nft -f fails (4374360491).
Generated by Claude Code
Root runs the helper for the delegated account, so the installer now fails unless the helper and sudoers directories and their parents are root-owned and not group or other writable. The staging files come from mktemp, so a planted staging name or symlink is never written through.
There was a problem hiding this comment.
arch-review: PASS_WITH_CONDITIONS · 2 conditions · the full ruling
The compare makes the install of the root helper safer. The install refuses a helper or sudoers directory that an account other than root can write. It also stages each file with mktemp. Both refusals stop the install, so the change fails closed in the install step of §7.4 line 275. The cohort and ruleset ruling on f5ffed16 stays. C1 (kernel leg, #2606) and C2 (min finalize-install names the helper, #2607) stay open as filed residuals.
Ruled at 07a4841 against gominimal/arch@109541a48c31 by claude-opus-5-5.
|
Round 3 of 10 on the runtime's review findings, pushed as
Verify Review threads the coder judged:
A host_ip cohort member's UDP 443 socket opened before the cohort turns blocked stops sending when the block flips, and sends again when it lifts. This round also makes the install refuse a helper directory that an account other than root can write, so the sudo rule for the helper cannot become a path to root. Notes from the agent: Revise round 3. New function assert_root_only_dir checks the helper and sudoers directories and every parent up to /. Each one must be owned by root and must not be group or other writable. A rehearsal (the test harness) checks only the directory itself and accepts the running account as owner, because its scratch tree is not root-owned. The staging files now come from mktemp. The non-rehearsal walk to / was checked by hand on this box, where every directory is owned by uid 1000, and it refused as expected. No test covers that walk, because the harness cannot create root-owned directories. The walk also refuses a sticky world-writable parent such as /tmp. That is stricter than needed, but the default helper path is under /usr/local/lib. dash is not installed on this box, so install_test.sh ran under sh only. The PR is not verified on a real host with KVM or sudo. |
There was a problem hiding this comment.
minimal-review — approval withheld
Verdict COMMENT — APPROVE withheld: 4 unresolved threads — minimal-foundry[bot] scripts/install-host-classifier.sh:757, minimal-arch[bot] crates/minimald/src/box_actor/host.rs:16, minimal-arch[bot] scripts/install-host-classifier.sh:1232, minimal-arch[bot] scripts/install-host-classifier.sh:895. No findings.
Panel: 3 of 3 personas ran (correctness, security, supply-chain).
|
/revise Round 4 of #2601, on the epic owner's ruling for thread 4374360491 (fix in this PR). No round is in flight.
Generated by Claude Code |
|
/revise Round 5 of #2601, on two findings at 2aafba3 that hold. No round is in flight.
Generated by Claude Code |
The QUIC block helper runs nft as root, so the install now resolves the nft path with readlink -f and refuses it unless the file and its directories are root-only. A re-install reads the blocked-leaves set with nft -j list set and declares the set with those leaves in the same transaction, so a blocked box does not send UDP 443 again.
There was a problem hiding this comment.
arch-review: CONDITIONS · 1 condition · the full ruling
The nft check conforms. The helper now runs only an nft that root owns, and no other account can write it or its directories. Keeping the blocked leaves on a re-install is correct for §5.3 line 150. But kept_quic443_leaves treats a failed nft -j list set as an empty set. The re-install then empties the set while minimald still records the leaves, so a blocked cohort can send UDP 443. C3 makes that path refuse before the load. The kernel leg and the min finalize-install diagnostic stay open as #2606 and #2607.
Ruled at 1cf159e against gominimal/arch@109541a48c31 by claude-opus-5-5.
|
Round 5 of 10 on a /revise note, pushed as
Verify Review threads the coder judged:
A host_ip cohort member's UDP 443 socket opened before the cohort turns blocked stops sending when the block flips, and sends again when it lifts. This round also keeps it blocked across a re-install of the classifier table, and the passwordless helper can no longer run an nft binary that another account can write. Notes from the agent: Round 5. (1) The install resolves nft with readlink -f in a new resolve_quic443_nft. It refuses the binary unless the file and every directory above it are root-owned and not group- or other-writable. The check runs before anything is laid out or loaded, so a refused nft changes nothing. The per-path check is now assert_root_only_entry, and assert_root_only_dir calls it, so the refusal text is the same for both. A rehearsal still checks only the directory itself and accepts the running account as owner, the same as for the helper directory. (2) Just before the render, kept_quic443_leaves reads the set with |
A failed nft -j list set while the loaded table declares the set now stops the install before the marker goes, instead of rendering the set empty. The installer tests match the physical scratch path that macOS reports under /private/var, and the awk check carries an SC2016 directive.
|
/revise Round 6 of #2601, on CI failures and arch condition C3 at 1cf159e. No round is in flight.
Generated by Claude Code |
|
That Nothing to type again: the command gets a rocket when its round starts, or a refusal that names the reason. No round was counted for it yet. |
There was a problem hiding this comment.
arch-review: PASS_WITH_CONDITIONS · 2 conditions · the full ruling
The compare closes the fail-open read that the ruling on 1cf159e named. A re-install that cannot list the declared blocked-leaves set now stops before the marker goes and before the load. It does not render the set empty. Section 5.3 line 150 requires that UDP/443 stays blocked for the cohort while any member blocks, and this head keeps it blocked. Two conditions stay open as filed residuals: C1 (#2606, the kernel leg) and C2 (#2607, the finalize-install diagnostic).
Ruled at d1504d7 against gominimal/arch@109541a48c31 by claude-opus-5-5.
There was a problem hiding this comment.
minimal-review — approval withheld
Verdict COMMENT — APPROVE withheld: 3 unresolved threads — minimal-arch[bot] scripts/install-host-classifier.sh, minimal-arch[bot] scripts/install-host-classifier.sh, minimal-foundry[bot] crates/minimald/src/box_actor/host.rs:100. No findings.
Panel: 3 of 3 personas ran (correctness, security, supply-chain).
|
Round 6 of 10 on a failed check suite, pushed as
Verify Review threads the coder judged:
A host_ip cohort member's UDP 443 socket opened before the cohort turns blocked stops sending when the block flips, because its own leaf goes into the blocked-leaves set that the output chain rejects. It sends again when the last blocker leaves. A re-install keeps the set's leaves, and a re-install that cannot read the set stops before it touches the table or the marker. Notes from the agent: Round 6. The shellcheck failure was SC2016 on the awk at install_test.sh:3058. A directive now covers it. The two macOS installer failures were very probably the helper-directory and nft-directory refusals: assert_root_only_dir and readlink -f print the physical path, and the macOS mktemp root is under /var, a symlink to /private/var. The tests now match the physical path. I could not run them on macOS. The log excerpt for the Linux 'tests' job does not name a failure. The full minimald nextest suite passes here (1132 passed), so I made no change for it. kept_quic443_leaves now reads the loaded table from the ruleset that scan_ct_mark_conflicts already listed (new global host_ruleset), so nft list ruleset still runs once. The read now happens before the marker is removed, so a refusal leaves the marker in place. |
…-443-from-a-blocked-cohort-s-own-leave
|
Revise round 6 ran against |
Older shellcheck releases flag the A && B || C forms as SC2015, so the nft resolve check and the kept-leaf directory check use if blocks.
…-443-from-a-blocked-cohort-s-own-leave
There was a problem hiding this comment.
arch-review: PASS_WITH_CONDITIONS · 2 conditions · the full ruling
The compare changes no behaviour. Commit 632a5f39 rewrites two shell checks as if blocks, and the checks still refuse the same cases. The merge of T73 does not need a change to the blocked set, because settle_injection changes no cohort membership. BEP-018 conforms to §5.3 line 150 and §7.4 line 275, as on the baseline. C1 (the kernel leg, #2606) and C2 (the min finalize-install item, #2607) stay open as filed residuals.
Ruled at a6472dd against gominimal/arch@109541a48c31 by claude-opus-5-5.
|
Round 7 of 10 on a /revise note, pushed as
Verify A host_ip cohort member's UDP 443 socket opened before the cohort turns blocked stops sending when the block flips, because its leaf goes into the blocked-leaves set that the reject rule matches. It sends again when the block lifts and the leaf leaves the set. A re-install keeps the leaves in the set, and an install that cannot read the set stops with the loaded table unchanged. Notes from the agent: Round 7. Items 2 and 3 were already on the branch in d1504d7: the macOS tests compare against the physical path from pwd -P, and kept_quic443_leaves returns 1 with a reason when the listing fails on a table that declares the set, with a test case. The SC2016 directive from item 1 was also there. This round rewrote the two SC2015 forms (the nft resolve check and the kept-leaf directory check) as if blocks. The local shellcheck is 0.11.0 and does not flag SC2015 on these forms, so the local lint passed before and after the change. The CI shellcheck is older and flagged them. Line 805 ( |
There was a problem hiding this comment.
Own review at a6472dd. The installer adds the helper and the sudoers rule only after the table loads. It refuses an nft binary, or a helper directory, that an account other than root can write. A blocked set survives a reinstall, and an unreadable set refuses the install. The fail-closed launch is intended. Arch conditions are residuals #2606 and #2607. The epic owner ruled the security thread on 2026-10-26.
Generated by Claude Code
There was a problem hiding this comment.
Babysitter approval at a6472dd: every merge gate passes at this head (arch PASS at a6472dd, zero open threads, every lane aggregator green). Squash title feat(minimald): reject UDP 443 from a blocked cohort's own leaves (T72) and the squashed message pass commitlint. Queued for squash merge.
Generated by Claude Code
…(0.7.0 backport) (#2742) * fix(minimal): hold the answerer channel socket owner-only (#2297) * fix(minimal,minvmd): hold the answerer channel socket owner-only The box-zone answerer's channel socket was world-connectable: the launchd plist bound it SockPathMode 0666 and the systemd socket unit SocketMode=0666, leaving the peer-uid gate the only check on who may connect. Both units now bind it owner-only -- SockPathOwner the operator, SockPathMode 0600 on launchd; SocketUser the operator, SocketMode=0600 on systemd -- with the uid gate kept as defence in depth. An install whose socket node carries group or other bits -- one that predates the mode, or a hand that relaxed it -- now fails custody through a channel_mode fact, so the advisory re-surfaces and a step re-run re-binds the socket. The accept loop's foreign-refusal warn is rate-limited to a few lines a second, so a connect storm cannot flood the log; the refusals themselves are never limited. * fix(minimal,minimald,minvmd): name the other account on a refused channel connect A daemon whose connect the owner-only channel socket refuses now says the host's DNS service belongs to another account, not a bare Permission denied. The answerer step needs the operator's account to resolve, with no stand-in uid, and custody fails a channel socket the operator does not own. * fix(minimal,minvmd): leave another operator's DNS service alone A channel socket owned by another existing, non-root account is that operator's service: the answerer step is now unavailable with the same sentence the daemons say on a refused connect, instead of a custody failure whose step would reinstall the unit and take the service over. A root-owned socket, or one whose uid names no account, still fails custody so the step re-binds it. The minvmd tests that take the process-global foreign-refusal slots now run one at a time and start and end with every slot free, and the warn-rate test bounds the burst at two windows' worth of lines, so a slow runner that spreads the burst does not fail it. * fix(minimal): offer the step over a socket another account left behind A channel socket another account owns refuses the answerer step only when a service is installed; with none, the step's install removes the leftover socket before it loads the unit, so it is offered. --------- Co-authored-by: gominimal-aw-bot[bot] <281738952+gominimal-aw-bot[bot]@users.noreply.github.com> (cherry picked from commit 746770d) * fix(net): refuse out-of-zone names at a guest deny-subtree DNS listener (#2300) * fix(net): refuse out-of-zone names at a guest deny-subtree DNS listener A deny-all host-address box on a VM-backed guest resolved out-of-zone names at the switch gateway: the interim NET-079 carve-out admitted the gateway's port 53 whole, UDP and TCP, so a box that admits no destination could still ask a resolver beyond the machine — and DNS over TCP could retry past the verdict. The guest's deny subtree now steers its UDP-53 lookups onto a loopback listener on the answerer's port (NET-141 slice 2): deny_out admits the listener beside the reply direction, the dstnat match carries no destination address (the box asks the gateway like every other host-address box; the steer is the carve-out), and the listener itself answers REFUSED for every name outside the box zone — apex and a non-lowercase zone suffix among them — NODATA for an in-zone non-A question, and forwards the zone's A questions to the switch gateway alone. DNS over TCP is neither steered nor admitted. The guest daemon starts the listener after its classifier table loads; the daemon's own lookups are untouched (cgroup-keyed match, deny path only). The interim --gateway-resolver installer flag and every test, digest and pin that spelled the interim carve-out follow the new shape. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(net): bound and pin the guest DNS listener's forwards Connect each forward socket to the gateway so a datagram from any other sender is dropped, cap forwards in flight so a flood cannot exhaust the daemon's descriptors, and drop the needless to_string calls clippy rejects in the guest classifier integration test. * fix(net): refuse questionless and non-QUERY messages at the guest DNS listener decide() forwarded a message with no question, and an UPDATE or NOTIFY whose zone section named an in-zone A, because every per-question check passed vacuously. Answer the first REFUSED and the rest NOTIMP, so the forward path carries only standard lookups. Bind the listener directly so its log does not read as the box-zone answerer, pin the integration test's boot-log check to the exact example.com REFUSED line, and cite NET-079 (the deny-all case) rather than NET-141 at the lines this change added. --------- Co-authored-by: gominimal-aw-bot[bot] <281738952+gominimal-aw-bot[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> (cherry picked from commit 2c2f8cc) * fix(net): spawn the guest DNS tasks with tokio::spawn on release/0.7.0 crate::traced landed on main after the release branch was cut, so the cherry-pick of #2300 did not compile here. * build: declare 0.7.3 for the release/0.7.0 backports v0.7.2 is tagged at the branch head, so the version check requires the next patch release. * chore(deps): update pulldown-cmark to 0.13.5 for RUSTSEC-2026-0414 * fix(minimald): close the host classifier's mark, IPv6 and probe gaps (T105) (#2712) * fix(minimald): close the classifier's mark, IPv6 and probe gaps The install's conflict scan now treats a ct-mark write with no mask as a user of every bit, names each rule with its table and chain, and --ct-mark-mask refuses the ranges Tailscale and kube-proxy class with. The table refuses non-loopback IPv6 from the boxes subtree and counts that refusal in the same named counter as the deny chain. The per-launch probe reads with a UDP datagram, so a launch no longer waits the SYN retransmit a refused TCP connect pays. The reading at daemon start keeps its TCP leg. Each probe logs its leg and duration. * test(minimald): prove the classifier's mark, IPv6 and probe gaps live Three root-lane proofs load the real table: the install refuses a whole-mark ct writer and a mask in a well-known range, and an allow box's IPv6 connection to a peer is refused and counted. The live probe proof also checks the per-launch reading waits no retransmit. * fix(minimald): pass shellcheck and pin the new mask refusals The rerun hint's empty assignment tripped SC1007 in the shell lint gate. The installer test pinned the old remedy text and escaped with 0x0000c000, the kube-proxy range the install now refuses: it now pins the new remedy, escapes with 0x03000000, and pins the well-known range refusal. The IPv6 refusal comment names the condition that replaces it. * fix(minimald): re-apply the recorded ct-mark mask and name min finalize-install With no --ct-mark-mask, the install step adopts the mask a previous install recorded under the tree. Mask refusals now name min finalize-install --ct-mark-mask <mask>. The conflict scan joins the nft and iptables-save hits with a newline. * feat(minimal): add min finalize-install --ct-mark-mask and keep it at boot The mask record lives in cgroupfs, so a reboot empties it. The classifier step's block now passes the chosen or recorded mask to the one-time run and to the boot unit's ExecStart, so the operator's bits survive a reboot. A requested mask that differs from the loaded table's re-runs the step. * fix(minimald): refuse a CONNMARK --restore-mark that copies the whole mark The install's ct-mark scan now treats --restore-mark with no --mask, --ctmask or --nfmask, or an all-ones one, as a whole-mark writer, as it does --save-mark. A restore with a narrow mask is still no conflict. * fix(minimald): allow --restore-mark, bind probe datagrams first A CONNMARK --restore-mark copies the connection mark into the packet mark and never writes the connection mark, which is all the classifier reads. This reverts 16d9160 and pins a restore with no mask as no conflict. hold_probe_listeners now binds its datagram endpoints before it spawns any TCP drain thread, so a failed datagram bind returns before a listener serves. --------- Co-authored-by: minimal-foundry[bot] <327719912+minimal-foundry[bot]@users.noreply.github.com> (cherry picked from commit 354548e) * fix(minimald): adapt the host classifier gaps backport to release/0.7.0 The cherry-pick before this one keeps main's text for every line #2712 changes. Main's neighbouring code comes from the QUIC block work (#2513, #2553, #2601, #2644), the box-name placement (#2722) and the crate renames, none of which release/0.7.0 has. The conflicts were resolved onto release's code without importing that work: - classifier.rs: the four new unit tests are appended after release's last test, without the QUIC cohort tests main has above them. Their sandbox::classifier paths are sandbox2::classifier here. - install-host-classifier.sh: the table gains the refused counter. The QUIC leaf set that main declares beside it is not on release, so it stays out. The --help range (2,87p) matches release's header too. - finalize_install.rs: release has one classifier item, built by classifier_item_on_this_host, not main's classifier_items_on_this_host with the QUIC block helper item. That function takes --ct-mark-mask and fills recorded_mask and requested_mask itself. plan_for_this_host keeps release's names item placement and only passes the mask on. - #2712's edits to finalize_install_names_the_quic_block_helper are dropped: release has no QUIC block helper item or test. - install_test.sh: run_hc puts HC_PATH_PREFIX ahead of PATH, the line #2601 added on main. Without it the new --restore-mark case never reaches its stand-in iptables-save, and shellcheck flags the variable as unused. The user-facing text is main's, unchanged. --------- Co-authored-by: gominimal-aw-bot[bot] <281738952+gominimal-aw-bot[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: minimal-foundry[bot] <327719912+minimal-foundry[bot]@users.noreply.github.com>
T72 — Reject UDP 443 from a blocked cohort's own leaves, sockets opened before the flip included
slice: S10
kind: task
covers: BEP-018
size: M
est: 220 code, 200 tests
executor: agent
difficulty: unknown ground
model: strongest
diagnostics: the daemon log names each leaf added to or removed from the blocked set, and
min finalize-installnames the helper item and why it cannot install itobservability: one info line per set change with the leaf and the cohort's blocking members
layers:
- ruleset:
- scripts/install-host-classifier.sh:
render_rulesetdeclares a set of cgroup v2 leaves (typeof socket cgroupv2 level N) and the UDP 443 reject rule also matches a socket whose leaf is in it, so a socket created in a member's own leaf before the flip is rejected from its next datagram; the install step adds a root-owned helper that only adds or removes one leaf under the delegatedboxesslice in that set, refusing any other path, and a sudoers drop-in that lets the login run only that helper withsudo -n(owner's decision 2026-10-26: a narrow helper, not CAP_NET_ADMIN for minimald)- scripts/install_test.sh: the rendered set and rule, and the helper refusing a path outside the slice
test: cargo nextest run -p minimald the_ruleset_rejects_udp_443_from_leaves_in_the_blocked_set
- cohort:
- crates/minimald/src/net/classifier.rs:
CohortQuic443keeps every residentallowmember's own leaf in the blocked set while the cohort blocks: all of them when it turns blocked, a joining member's at admit, none once the last blocker leaves; the process moves stay- crates/minimald/src/box_actor/host.rs: runs the helper through
sudo -nfor each set change; a refused add fails the launch that needed it, so the block fails closed (arch C1 at cc74186), and a refused remove is logged and retried while the box keeps runningtest: cargo nextest run -p minimald cohort_flip_puts_member_leaves_in_the_blocked_set
produces: none
consumes: none
proof: A host_ip cohort member's UDP 443 socket opened before the cohort turns blocked stops sending when the block flips, and sends again when it lifts.
depends on:
- blocked by #2534
Proof
The classifier table now declares the quic443_blocked_leaves set and rejects UDP 443, with ICMP port unreachable, from any socket whose own box leaf is in that set. While the host_ip cohort blocks, minimald puts the own leaf of every resident allow member in the set through the root-owned helper (sudo -n). It takes each leaf out when the block lifts. A socket a member opened before the flip still has its own leaf as its cgroup, so its next UDP 443 datagram is rejected, and it sends again when the block lifts. The tests show the rendered set and rule, the helper's changes and refusals, and the set following the cohort. No kernel run proves the end-to-end send and stop on this box.
Notes from the agent: Design: the set change goes on the CgroupMoves seam as add_blocked_leaf and remove_blocked_leaf, with no-op defaults. The admit and depart signatures did not change, because box_runtime.rs tests call admit and that file is outside the layers. host.rs adds HostMoves (KernelMoves plus the helper through sudo -n) and uses it on the launch, stop and exit paths. KernelMoves stays in box_runtime.rs adopt_injected and changes no set. That is correct, because an injected process joins the blocked leaf itself. The helper path is /usr/local/lib/minimal/quic443-leaf and the drop-in is /etc/sudoers.d/minimal-quic443-leaf (rule '# ALL=(root) NOPASSWD: ', checked with visudo when visudo is present). A rehearsal writes them only when MINIMAL_OVERRIDE_QUIC443_HELPER and MINIMAL_OVERRIDE_QUIC443_SUDOERS are set. Otherwise it skips them, so the existing stand-in installs are unchanged. The uninstall removes both. The install dies with 'cannot install the QUIC block helper at : ' (or the sudoers equivalent) before it loads the table. Not done: the diagnostics line asks that
min finalize-installname the helper item and why it cannot install it. That code is in crates/minimal (resolver.rs), outside the layers. Today finalize-install shows the helper …[truncated]Tests
cargo nextest run -p minimald the_ruleset_rejects_udp_443_from_leaves_in_the_blocked_setcargo nextest run -p minimald cohort_flip_puts_member_leaves_in_the_blocked_setVerify
min task run agent-verifyexited 101 under rustc 1.98.1, which the repository does not pin (1.99.0 in rust-toolchain.toml); the PR is not a draft on that alone — CI, which honours the pin, is the gate.Base
The base branch moved while the coder worked; it was merged in before the tests ran.
Residuals
min finalize-installnames the QUIC block helper item and its cause (arch C2).Closes #2558
Generated by Claude Code