8000 feat(minimald): block UDP 443 for a host_ip cohort with a blocked QUIC stance (T53) by minimal-foundry[bot] · Pull Request #2553 · gominimal/minimal · GitHub
[go: up one dir, main page]

Skip to content

feat(minimald): block UDP 443 for a host_ip cohort with a blocked QUIC stance (T53) - #2553

Merged
norrietaylor merged 5 commits into
mainfrom
agent/2534-enforce-the-host-ip-cohort-s-blocked-quic-stance
Oct 25, 2026
Merged

norrietaylor merged 5 commits into
mainfrom
agent/2534-enforce-the-host-ip-cohort-s-blocked-quic-stance

Conversation

@minimal-foundry
@minimal-foundry minimal-foundry Bot commented Oct 25, 2026 •
Copy link
Copy Markdown
Contributor

T53 — Enforce the host_ip cohort's blocked QUIC stance in the host-address ruleset

slice: S9
kind: task
covers: BEP-018
size: M
est: 200 code, 160 tests
executor: agent
difficulty: needs care
model: standard
diagnostics: the bundle's daemon log tail carries one line per cohort stance change naming the box that flipped it
observability: one info log line when the cohort flag turns on or off, with the blocking members
layers:
- ruleset:
- crates/minimald/src/net/classifier.rs: CohortQuic443 learns the path of the cohort's blocked leaf and the daemon's move of a host_ip box's processes into and out of it (a cgroup.procs write inside the delegated slice, which needs no CAP_NET_ADMIN and never rewrites the table); its doc stops saying the type is unenforced
- scripts/install-host-classifier.sh: render_ruleset gains one rule keyed on a new delegated leaf under the boxes directory, the cohort's blocked leaf: socket cgroupv2 level <n> "<blocked leaf>" udp dport 443 reject with icmpx port-unreachable; the install step creates that leaf with the rest of the tree, so the privileged step ships the rule and the daemon flips it by membership (widened 2026-10-25: classifier.rs only embeds this script with include_str!, so the table is rendered here)
test: cargo nextest run -p minimald host_ip_cohort_blocked_stance_rejects_udp_443
- lifecycle:
- crates/minimald/src/box_actor/host.rs: the host_ip launch path calls CohortQuic443::join with the box's resolved stance and moves every resident host_ip box into the blocked leaf when blocked() turns true; the stop path calls leave and moves them back to their own leaves when the last blocking member goes
test: cargo nextest run -p minimald host_ip_cohort_join_block_leave_drives_the_rule
produces: none
consumes: none
proof: A host_ip cohort with one blocked member sends no UDP 443 and gets ICMP port unreachable; when that member stops, the cohort sends UDP 443 again.
depends on:
- blocked by T31

Proof

The classifier table that the installer loads now has the rule socket cgroupv2 level 3 "minimald.slice/boxes/quic443-blocked" udp dport 443 reject with icmpx port-unreachable. The install creates and delegates that leaf. When a host_ip box with a blocked quic443 stance launches, minimald moves the processes of every allow-subtree host_ip box into the leaf, so their UDP 443 gets ICMP port unreachable. When the last blocking box stops, minimald moves them back to their own leaves, and UDP 443 leaves again. The tests show this on the rendered table and on a stand-in cgroup tree. No test sent a real datagram through a loaded table on this box. This covers sockets opened after the flip; sockets opened earlier keep their own cgroup (#2558).

Notes from the agent: 1) Not proved on a kernel: no test loads the table with real nft and sends UDP 443. The proof rests on the rendered rule and on the moves over a stand-in tree. 2) Gap: min exec places a new process in the box's own leaf (crates/minimald/src/nsenter.rs, outside the layers). A process that an exec starts while the cohort is blocked is not in the blocked leaf and can send UDP 443. Processes forked inside the box follow their parent and are covered. 3) While a box is in the blocked leaf, it is outside its own leaf, so a per-leaf memory limit on that leaf does not apply. The blocked leaf has no limit of its own. 4) Deny-subtree boxes are not moved: deny_out already rejects all of their UDP 443 (with admin-prohibited), and a move would remove that refusal. 5) The guest builds its own tree in the sandbox crate (outside the layers). nft resolves cgroup paths at load, so load_guest_table_over in classifier.rs now creates boxes/quic443-blocked before the render when boxes/ exists. Without this, the guest load fails. 6) A host installed before this change has no blocked leaf. The daemon then logs a warning that says re-running the classifier install creates the leaf, and moves nothing. --check now reports the missing leaf as a problem. 7) A member leaves the cohort only in stop_running. A host that a …[truncated]

Tests

line exit tail
cargo nextest run -p minimald host_ip_cohort_blocked_stance_rejects_udp_443 0 Applying loadouts: default Running task runtime-implement-test-0 in session task-runtime-implement-test-0-f162... Compiling ot v0.0.1 (/workbench/crates/ot) Compiling args v0.0.1 (/workbench/crates/args) Compiling paths v0.0.1 (/workbench/crates/paths) Compiling stdlib v0.0.22 (/workbenc
cargo nextest run -p minimald host_ip_cohort_join_block_leave_drives_the_rule 0 Applying loadouts: default Running task runtime-implement-test-1 in session task-runtime-implement-test-1-2760... Compiling stdlib v0.0.22 (/workbench/crates/stdlib) Compiling version v0.8.0 (/workbench/crates/version) Compiling decode v0.0.1 (/workbench/crates/decode) Compiling mlog v0.

Verify

min task run agent-verify exited 101 under rustc 1.98.1, which the repository does not pin (1.97.0 in rust-toolchain.toml); the PR is not a draft on that alone — CI, which honours the pin, is the gate.

the sandbox ran rustc (1.98.1), the repository pins 1.97.0 in rust-toolchain.toml; a failure here may be the newer toolchain's, not this change's
Applying loadouts: default
Running task runtime-implement-verify in session task-runtime-implement-verify-c278...
    Checking args v0.0.1 (/workbench/crates/args)
    Checking ot v0.0.1 (/workbench/crates/ot)
    Checking paths v0.0.1 (/workbench/crates/paths)
   Compiling stdlib v0.0.22 (/workbench/crates/stdlib)
   Compiling version v0.8.0 (/workbench/crates/version)
    Checking diagnostics v0.0.1 (/workbench/crates/diagnostics)
    Checking common v0.0.1 (/workbench/crates/common)
    Checking async-dialog v0.0.1 (/workbench/crates/async-dialog)
    Checking switch v0.0.1 (/workbench/crates/switch)
    Checking boxes v0.0.1 (/workbench/crates/boxes)
    Checking sandbox v0.0.1 (/workbench/crates/sandbox)
    Checking mlog v0.0.1 (/workbench/crates/mlog)
    Checking mfile v0.0.1 (/workbench/crates/mfile)
    Checking minimald-rpc v0.0.1 (/workbench/crates/minimald-rpc)
    Checking decode v0.0.1 (/workbench/crates/decode)
    Checking checkouts v0.0.1 (/workbench/crates/checkouts)
    Checking graph v0.0.1 (/workbench/crates/graph)
    Checking lcache v0.0.1 (/workbench/crates/lcache)
    Checking rcache v0.0.1 (/workbench/crates/rcache)
    Checking op v0.0.1 (/workbench/crates/op)
    Checking orchestrator v0.0.1 (/workbench/crates/orchestrator)
    Checking check v0.0.1 (/workbench/crates/check)
error: using `chunks_exact` with a constant chunk size
   --> crates/minimald-rpc/src/trace.rs:199:46
    |
199 |     for (byte, pair) in out.iter_mut().zip(s.chunks_exact(2)) {
    |                                              ^^^^^^^^^^^^^^^
    |
help: consider using `as_chunks::<2>()` instead
   --> crates/minimald-rpc/src/trace.rs:199:46
    |
199 |     for (byte, pair) in out.iter_mut().zip(s.chunks_exact(2)) {
    |                                              ^^^^^^^^^^^^^^^
    = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.98.0/index.html#chunks_exact_to_as_chunks
    = note: `-D clippy::chunks-exact-to-as-chunks` implied by `-D warnings`
    = help: to override `-D warnings` add `#[allow(clippy::chunks_exact_to_as_chunks)]`

error: could not compile `minimald-rpc` (lib) due to 1 previous error
warning: build failed, waiting for other jobs to finish...

Residuals

Layer exception

T53's layers were widened on 2026-10-25 in the plan comment and in #2534: scripts/install-host-classifier.sh (the host-address table is rendered there, not in classifier.rs) and crates/minimald/src/box_runtime.rs (arch C1: command_in_session must hand the shim the blocked leaf while the cohort is blocked). Both are inside the task now.

Closes #2534


Generated by Claude Code

The installer creates and delegates boxes/quic443-blocked and keys one
rule on it: udp dport 443 reject with icmpx port-unreachable.
CohortQuic443 learns the leaf and moves the cohort's allow-subtree
processes into it while a member blocks, and back when none does.
…stop

A host_ip launch joins the cohort with its resolved quic443 stance. A
stop leaves it, so the cohort sends UDP 443 again once the last
blocking member is gone.
Two classifier tests pinned the output chain at one rule. They now
allow the blocked leaf's reject beside the deny subtree's jump.
@coderabbitai
coderabbitai Bot commented Oct 25, 2026 •
Copy link
Copy Markdown
Contributor

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: a9c17809-d98b-4fae-b135-f0fb05a266fa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@minimal-foundry
minimal-foundry Bot commented Oct 25, 2026 •
Copy link
Copy Markdown
Contributor Author

Reviewed: 2 findings, none blocking at ecdb372725da.

Address the comments on the diff.

Clobber: none seen.

@minimal-foundry minimal-foundry Bot left a comment
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minimal-review — advisory review

Verdict COMMENT — 3 findings, 3 inline.

Panel: 3 of 3 personas ran (correctness, security, supply-chain).

Comment thread crates/minimald/src/box_actor/host.rs Resolved
Comment thread crates/minimald/src/net/classifier.rs Resolved
Comment thread crates/minimald/src/net/classifier.rs Resolved
@minimal-foundry
Copy link
Copy Markdown
Contributor Author

Round 1 of 10: the container stopped before the coder ran (the container took the job and stopped before it ran (exit 0 and no result, and the second start failed: Error: The container is not running, consider calling start())). The round is given back.

@minimal-arch minimal-arch Bot left a comment
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

arch-review: CONDITIONS · 2 conditions · the full ruling

The table rule and the cohort moves conform to Networking §5.3 line 150. The rule is an active reject, and the leaf keeps the cohort address. A min box exec or injected hook still starts in the box's own leaf while the cohort is blocked, so it sends UDP 443. That breaks "blocked for the cohort iff any resident member's resolved stance is blocked". It also splits the exec from its entrypoint's cgroup (architecture line 493). C1 places injected processes in the blocked leaf and records them so they go home. C2 files issues for the two gaps the PR body names without one. The escalation asks how this mechanism fits Box Resources' one cgroup per box.

Ruled at 0f5d54c against gominimal/arch@109541a48c31 by claude-opus-5-5.

Comment thread crates/minimald/src/net/classifier.rs Resolved
Comment thread crates/minimald/src/net/classifier.rs Resolved
Comment thread crates/minimald/src/box_actor/host.rs Resolved
Copy link
Copy Markdown
Member

/revise

Round 1 of #2553, on the red native lane at 0f5d54c, the arch ruling there and three review findings. No round is in flight: the runtime's own round 1 was given back when its container stopped before the coder ran. T53's layers were widened on 2026-10-25 in the plan comment and in #2534: scripts/install-host-classifier.sh, crates/minimald/src/box_runtime.rs and scripts/install_test.sh are in the task now, so none of this is out of layers. Arch C2's two gaps are filed as #2554 and #2555 and listed under Residuals; do not change the exited-shell path or the shared-leaf design for them.

  1. Red lane: ci-linux-native fails in common::shell_harnesses all_script_test_harnesses_pass because scripts/install_test.sh does not model the new boxes/quic443-blocked leaf. Its expected delegation list (for _cg in "" "/daemon" "/boxes" "/boxes/deny" "/boxes/allow", near line 2765) lacks the leaf, so "delegation reached the slice and every leaf whole" sees four extra quic443-blocked entries; and its two drop_cgroup_files loops before --uninstall (near lines 2726 and 2974) drop only deny and allow, so --uninstall cannot remove the non-empty boxes directory and "the un-enrolled tree uninstalls", "uninstall removes the installed tree" and "uninstall leaves no tree behind" fail. Add the leaf to all three places and make the harness assert that the install creates it.
  2. Arch C1 (threads 4370601853 and 4370601855): command_in_session in crates/minimald/src/box_runtime.rs always hands the shim the box's own leaf, so a min box exec or an injected hook starts outside the blocked leaf while the cohort is blocked and sends UDP 443 (Networking §5.3 line 150; architecture line 493). While the cohort is blocked, hand the shim the blocked leaf, and record the shim pid in moved under the box id so owner_of sends the injected process home when the block lifts; pin both with tests.
  3. Finding 4370595764 (HIGH, join_quic443_cohort in host.rs): the block is applied after the host process starts and a failed move still lets the launch succeed. Place a blocking member in the blocked leaf before its first process runs, or fail the launch when a blocking member cannot be placed; pin it.
  4. Finding 4370595766 (CohortQuic443::admit): when a box re-joins with a weaker stance and was the only blocker, the stance flips but the processes in the blocked leaf never move home and moved keeps stale pids. Call move_all_home on that transition, as depart does; pin it.
  5. Finding 4370595768 (CohortQuic443::move_in): a failed migrate or read, a process that forks during the move, or a missing leaf only warns while the cohort reports blocked, so the stance fails open. Surface the failure to the caller (so item 3 can fail the launch) or retry until the leaf is empty; pin it.
  6. Reply on each thread naming the commit and resolve the ones the commit fixes.

One or two commits, Conventional Commits subjects, body lines at most 100 characters.


Generated by Claude Code

The install lays out boxes/quic443-blocked beside deny and allow. The harness
now expects it in the delegation list, drops its files before --uninstall,
and asserts that both installs create it.
The host_ip cohort's UDP 443 block now never fails open:

- A failed move, an unreadable leaf, a leaf that never empties or a
  missing blocked leaf comes back to the launch as an error. The box
  leaves the cohort again, and the launch is killed and refused.
- A box that re-joins with a weaker stance as the only blocker lifts
  the block and sends every process in the blocked leaf home.
- While the cohort blocks, command_in_session hands the shim the
  blocked leaf. The shim reports its pid from a pre_exec hook, and the
  pid is recorded under the box, so an exec or a hook goes home too.
@minimal-foundry
Copy link
Copy Markdown
Contributor Author

Round 2 of 10 on a /revise note, pushed as ecdb372725da.

line exit tail
cargo nextest run -p minimald host_ip_cohort_blocked_stance_rejects_udp_443 0 Applying loadouts: default Running task runtime-implement-test-0 in session task-runtime-implement-test-0-b3f9... Compiling ot v0.0.1 (/workbench/crates/ot) Compiling args v0.0.1 (/workbench/crates/args) Compiling paths v0.0.1 (/workbench/crates/paths) Compiling stdlib v0.0.22 (/workbenc
cargo nextest run -p minimald host_ip_cohort_join_block_leave_drives_the_rule 0 Applying loadouts: default Running task runtime-implement-test-1 in session task-runtime-implement-test-1-a5f6... Compiling stdlib v0.0.22 (/workbench/crates/stdlib) Compiling version v0.8.0 (/workbench/crates/version) Compiling decode v0.0.1 (/workbench/crates/decode) Compiling graph v0

Verify min task run agent-verify exited 101 under rustc 1.98.1, which the repository does not pin (1.97.0 in rust-toolchain.toml); the PR is not a draft on that alone — CI, which honours the pin, is the gate.

the sandbox ran rustc (1.98.1), the repository pins 1.97.0 in rust-toolchain.toml; a failure here may be the newer toolchain's, not this change's
Applying loadouts: default
Running task runtime-implement-verify in session task-runtime-implement-verify-bbcb...
    Checking args v0.0.1 (/workbench/crates/args)
    Checking ot v0.0.1 (/workbench/crates/ot)
    Checking paths v0.0.1 (/workbench/crates/paths)
   Compiling stdlib v0.0.22 (/workbench/crates/stdlib)
   Compiling version v0.8.0 (/workbench/crates/version)
    Checking common v0.0.1 (/workbench/crates/common)
    Checking diagnostics v0.0.1 (/workbench/crates/diagnostics)
    Checking async-dialog v0.0.1 (/workbench/crates/async-dialog)
    Checking switch v0.0.1 (/workbench/crates/switch)
    Checking boxes v0.0.1 (/workbench/crates/boxes)
    Checking sandbox v0.0.1 (/workbench/crates/sandbox)
    Checking mlog v0.0.1 (/workbench/crates/mlog)
    Checking mfile v0.0.1 (/workbench/crates/mfile)
    Checking minimald-rpc v0.0.1 (/workbench/crates/minimald-rpc)
    Checking checkouts v0.0.1 (/workbench/crates/checkouts)
    Checking decode v0.0.1 (/workbench/crates/decode)
    Checking graph v0.0.1 (/workbench/crates/graph)
    Checking lcache v0.0.1 (/workbench/crates/lcache)
    Checking rcache v0.0.1 (/workbench/crates/rcache)
    Checking op v0.0.1 (/workbench/crates/op)
    Checking check v0.0.1 (/workbench/crates/check)
    Checking orchestrator v0.0.1 (/workbench/crates/orchestrator)
error: using `chunks_exact` with a constant chunk size
   --> crates/minimald-rpc/src/trace.rs:199:46
    |
199 |     for (byte, pair) in out.iter_mut().zip(s.chunks_exact(2)) {
    |                                              ^^^^^^^^^^^^^^^
    |
help: consider using `as_chunks::<2>()` instead
   --> crates/minimald-rpc/src/trace.rs:199:46
    |
199 |     for (byte, pair) in out.iter_mut().zip(s.chunks_exact(2)) {
    |                                              ^^^^^^^^^^^^^^^
    = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.98.0/index.html#chunks_exact_to_as_chunks
    = note: `-D clippy::chunks-exact-to-as-chunks` implied by `-D warnings`
    = help: to override `-D warnings` add `#[allow(clippy::chunks_exact_to_as_chunks)]`

error: could not compile `minimald-rpc` (lib) due to 1 previous error
warning: build failed, waiting for other jobs to finish...

Review threads the coder judged:

  • 👍 valid — crates/minimald/src/box_actor/host.rs: join_quic443_cohort now returns the placement error. The box leaves the cohort again, and launch_host kills the host and refuses the launch. Pinned by host_ip_cohort_join_block_leave_drives_the_rule_or_fails_the_launch. Placement before the first process needs the launcher, which is outside this tas
  • 👍 valid — crates/minimald/src/net/classifier.rs:847: admit now calls move_all_home(None) when the block lifts on a re-join, and moved is cleared. Pinned by host_ip_cohort_blocked_stance_rejects_udp_443_until_a_weaker_rejoin.
  • 👍 valid — crates/minimald/src/net/classifier.rs: move_in returns CohortPlaceError for a missing leaf, a failed read, a refused move, or a leaf still busy after every pass. It re-reads until the leaf is empty. Pinned by host_ip_cohort_blocked_stance_rejects_udp_443_or_refuses_the_placement.
  • 👍 valid — crates/minimald/src/net/classifier.rs:689: command_in_session hands the shim the blocked leaf (injection_leaf) while the cohort blocks and moves this box. Pinned by host_ip_cohort_join_block_leave_drives_the_rule_for_injected_processes.
  • 👍 valid — crates/minimald/src/net/classifier.rs:1003: The shim reports its pid from a pre_exec hook, and adopt_injected records it in moved under the box id, so owner_of sends the shim and its program home. Pinned by host_ip_cohort_blocked_stance_rejects_udp_443_for_injected_processes.

A host_ip cohort with one blocked member has every member's processes in boxes/quic443-blocked. The table rejects UDP 443 from that leaf with ICMP port unreachable. A min box exec or a hook injected while the block is on joins the same leaf. When the blocking member stops, or joins again with a weaker stance, every process goes back to its own leaf and sends UDP 443 again. A launch that cannot be placed under the block is killed and refused, so the block never fails open.

Notes from the agent: Item 1: the harness also drops the leaf's files in the third drop_cgroup_files block (the uninstall_live case), so all three blocks agree. That case still exits 1 on the live leaf. Item 3: I chose the 'fail the launch' option, not 'place before the first process runs'. The launcher (box_runtime/launcher.rs) places the first process in the box's own leaf and is outside the layers. Until a placement moves there, a blocked box can send UDP 443 between its spawn and the move. Item 2: command_in_session only builds the Command. The caller spawns it, so the shim pid comes back from a pre_exec hook over a close-on-exec socket pair and is recorded by a reader thread. Known gap: if the block lifts between the build and the shim's own join, the shim can land in the blocked leaf after it was sent home. It then stays blocked (fails closed) until the next block lifts. New printed text: a refused launch prints 'session spawn: the session was stopped because QUIC (UDP to port 443) could not be blocked for it. Every session that runs with the host's address gets this block while the quic443 setting of one of them blocks QUIC. The cause: '. The missing-leaf cause names boxes/quic443-blocked and gives no remedy, because I did not prove that min finalize-install re-creates the leaf on an older install. Item 6: I did not resolve any thread, because resolution is the reviewer's decision. The verdicts are below. I ran neither just clippy-strict nor just ci. I ran cargo clippy -p…[truncated]

@minimal-foundry minimal-foundry Bot added the needs-human An agent handed off; a human must act, then clear this label. Set by defer and handoffs. label Oct 25, 2026
@minimal-foundry
Copy link
Copy Markdown
Contributor Author

The runtime will not revise this pull request again on its own: the diff is +1311/-16 (1327 lines), past 3x the plan's estimate of 360 lines (est: 200 code, 160 tests, as #2534 reads now), at round 2 (the last trigger was a /revise note). A person decides now. Remove needs-human from this pull request to let /revise <note> or the automatic rounds resume, or finish #2534 by hand.

@minimal-foundry minimal-foundry Bot left a comment
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minimal-review — advisory review

Verdict COMMENT — 2 findings, 2 inline.

Panel: 3 of 3 personas ran (correctness, security, supply-chain).

Comment thread crates/minimald/src/box_actor/host.rs Resolved
Comment thread crates/minimald/src/box_actor/host.rs Resolved
@minimal-arch minimal-arch Bot left a comment
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

arch-review: PASS_WITH_CONDITIONS · 2 conditions · the full ruling

The compare meets the two conditions from 0f5d54c. A failed placement now kills the box and fails the launch. Two paths still send UDP 443 while the cohort blocks, against Networking §5.3 line 150. A QUIC socket opened before the flip keeps its own cgroup (§4.1 line 56), so the rule never matches it (C1). An exec that takes its leaf just before the flip joins its own leaf after the move ends (C2). File an issue for each and list it under Residuals. The change can then merge.

Ruled at ecdb372 against gominimal/arch@109541a48c31 by claude-opus-5-5.

Comment thread scripts/install-host-classifier.sh Resolved
Comment thread crates/minimald/src/box_runtime.rs Resolved
@norrietaylor norrietaylor removed the needs-human An agent handed off; a human must act, then clear this label. Set by defer and handoffs. label Oct 25, 2026
@norrietaylor norrietaylor changed the title T53 — Enforce the host_ip cohort's blocked QUIC stance in the host-address ruleset feat(minimald): block UDP 443 for a host_ip cohort with a blocked QUIC stance (T53) Oct 25, 2026
@norrietaylor norrietaylor left a comment
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Babysitter approval at ecdb372: every merge gate passes at this head (arch PASS_WITH_CONDITIONS at ecdb372 whose conditions are filed residuals #2558 and #2559, zero open threads, every lane aggregator green including ci-macos-success). Out-of-layer files were widened into T53's layers. Squash title feat(minimald): block UDP 443 for a host_ip cohort with a blocked QUIC stance (T53) and the squashed message pass commitlint. Queued for squash merge.


Generated by Claude Code

@norrietaylor
norrietaylor added this pull request to the merge queue Oct 25, 2026
Merged via the queue into main with commit 160d3ed Oct 25, 2026
39 checks passed
@norrietaylor
norrietaylor deleted the agent/2534-enforce-the-host-ip-cohort-s-blocked-quic-stance branch October 25, 2026 16:06
norrietaylor added a commit that referenced this pull request Oct 29, 2026
…(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>
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.

T53 — Enforce the host_ip cohort's blocked QUIC stance in the host-address ruleset

1 participant

0