8000 T30 — Pin allowed names to the addresses they resolved to, refuse denied ranges, and answer AAAA, HTTPS and SVCB empty by minimal-foundry[bot] · Pull Request #1730 · gominimal/minimal · GitHub
[go: up one dir, main page]

Skip to content

T30 — Pin allowed names to the addresses they resolved to, refuse denied ranges, and answer AAAA, HTTPS and SVCB empty - #1730

Merged
norrietaylor merged 19 commits into
mainfrom
agent/1638-pin-allowed-names-to-the-addresses-they-resolved
Sep 27, 2026
Merged

norrietaylor merged 19 commits into
mainfrom
agent/1638-pin-allowed-names-to-the-addresses-they-resolved

Conversation

@minimal-foundry
Copy link
Copy Markdown
Contributor

T30 — Pin allowed names to the addresses they resolved to, refuse denied ranges, and answer AAAA, HTTPS and SVCB empty

slice: S7
kind: task
covers: NET-066, NET-067, NET-136
size: L
est: 480 code, 300 tests
executor: agent
difficulty: unknown ground
model: strongest
diagnostics: the bundle's daemon log tail carries each admission with the name, the addresses and the window, and each refused answer with the name and the answer
observability: one debug log line per admission; one rate-limited warn line per answer refused as a denied range with the name and the answer
layers:
- core:
- crates/sessions/src/core/egress.rs: the pure rebinding intersection with the infrastructure deny set, with its Kani harness
- scripts/kani.sh: expects one more sessions harness
test (deferred: kani): cargo nextest run -p sessions kani_rebinding_intersection_admits_no_denied_address
- daemon:
- crates/minimald/src/net/dns_gate.rs (new): parses and builds DNS messages at the relay, keeps the per-box admission table with its window, and synthesizes NODATA for AAAA, HTTPS and SVCB
- crates/minimald/src/net/mod.rs: declares the gate module
- crates/minimald/src/net/switch.rs: hooks both relay directions into the gate and shares its admission table
- crates/minimald/src/net/policy.rs: the refusal log goes through the limiter
- crates/minimald/src/net/gvproxy_network.rs: passes allow_dns_hosts and deny_subnets into the attach
test: cargo nextest run -p minimald dns_pinned_admission_window
test: cargo nextest run -p minimald denied_range_resolution_refused
test: cargo nextest run -p minimald denied_range_resolution_logged
test: cargo nextest run -p minimald aaaa_https_and_svcb_queries_are_nodata
produces:
- crates/minimald/src/net/dns_gate.rs
consumes:
- crates/sessions/src/core/egress.rs
proof: A box that allows github.com reaches the addresses github.com resolved to for the admission window and nothing else, an allowed name that resolves into a denied range is refused with the name and the answer in the log, and AAAA, HTTPS and SVCB lookups come back empty.
depends on:
- blocked by #1629
- assumes OQ1 (the spec's interim holds: a native non-deny-all host-address box resolves through the host's resolver, a name rule admits nothing on that host, and the per-box enforcement recorded there covers addresses, never names)

Proof

A box whose policy allows github.com now reaches exactly the addresses github.com resolved to — admitted for a five-minute window at its relay, with every other destination still dropped — an allowed name that resolves into a denied range (the box's deny_subnets or the infrastructure deny set) is refused at resolution time with one rate-limited warn line carrying the name and the answer, and the box's AAAA, HTTPS and SVCB lookups toward its resolver come back NODATA, answered empty by the relay itself.

Notes from the agent: Deferred, never run on this box (no Kani/CBMC inst 8000 alled): the core layer's test (deferred: kani): cargo nextest run -p sessions kani_rebinding_intersection_admits_no_denied_address. The harness is written (crates/sessions/src/core/egress.rs, kani_proofs, unwind 4) and scripts/kani.sh now expects sessions 8; the ci-kani lane owes the run. Two semantic calls made where the source docs were ambiguous, both documented in dns_gate.rs's module docs: (1) an undeclared egress.allow_dns_hosts (None) pins NOTHING — only a Some entry can admit — because the schema doc in crates/sessions/src/lib.rs calls None "allow-all hosts", and reading it that way would turn every deny-all-subnets box into a resolve-anything box, lifting a declared deny with an undeclared dimension; if the allow-all reading is ever the intended one it needs its own task, since lib.rs is outside this task's layers. (2) For the RFC 1918 exemption an undeclared allow_subnets (None) counts as covering (is_none_or), consistent with allow-all meaning the address dimension governs; Some([]) covers nothing. gvproxy_network.rs is doc-only: the attach already passed the whole policy into SessionGate::for_session, so allow_dns_hosts/deny_subnets were already riding it — the note names them, no behavior change. The gate watches only replies from the switch gateway (the resolver NET-079's carve-out is keyed to), matches the question name (CNAME chains work; a forged record owner …[truncated]

Tests

line exit tail
cargo nextest run -p minimald dns_pinned_admission_window 0 Applying loadouts: default Running task runtime-implement-test-0 in session task-runtime-implement-test-0-8784... Compiling ot v0.0.1 (/home/runner/.local/state/minimal/sessions/59817/tree/crates/ot) Compiling args v0.0.1 (/home/runner/.local/state/minimal/sessions/59817/tree/crates/args) C
cargo nextest run -p minimald denied_range_resolution_refused 0 Applying loadouts: default Running task runtime-implement-test-1 in session task-runtime-implement-test-1-c2e4... Compiling stdlib v0.0.20 (/home/runner/.local/state/minimal/sessions/6d6eb/tree/crates/stdlib) Compiling version v0.6.0 (/home/runner/.local/state/minimal/sessions/6d6eb/tree/crate
cargo nextest run -p minimald denied_range_resolution_logged 0 Applying loadouts: default Running task runtime-implement-test-2 in session task-runtime-implement-test-2-ae0e... Compiling stdlib v0.0.20 (/home/runner/.local/state/minimal/sessions/bcfbf/tree/crates/stdlib) Compiling version v0.6.0 (/home/runner/.local/state/minimal/sessions/bcfbf/tree/crate
cargo nextest run -p minimald aaaa_https_and_svcb_queries_are_nodata 0 Applying loadouts: default Running task runtime-implement-test-3 in session task-runtime-implement-test-3-5dd6... Compiling stdlib v0.0.20 (/home/runner/.local/state/minimal/sessions/9db9d/tree/crates/stdlib) Compiling version v0.6.0 (/home/runner/.local/state/minimal/sessions/9db9d/tree/crate

Verify

min task run agent-verify passed ([tasks.verify]).

Base

The base branch moved while the coder worked; it was merged in before the tests ran.

Deferred

The plan marked these test lines deferred: the executor lacks the lane, so nothing here ran them and they still owe evidence.

line lane
cargo nextest run -p sessions kani_rebinding_intersection_admits_no_denied_address kani

Closes #1638

NET-067's core: the pure decision of which addresses a name resolved to
may ever be admitted for a box, subtracting the box's own deny_subnets
and the infrastructure deny set (design §5.3) — link-local and the
metadata services in it, loopback space, the 100.64.0.0/10 plane, the
gateway's own two addresses (the answerer's and the helper's), and
RFC 1918 unless the box's allow_subnets covers the answer.

Kept beside the frame verdict in `core::egress` as a pure function over
owned addresses and CIDRs, separate from resolver I/O, so the new
`kani_rebinding_intersection_admits_no_denied_address` harness can
exhaust it at an unwind bound of 4: every comparison is u32 mask
arithmetic (no [u8; 4] memcmp loops), with each CIDR set carried as
None, empty, or two symbolic rules — at most 4 per set. The set-shaped
wrapper (`rebinding_intersection`) splits one name's answers into the
admitted addresses and the refusals to log; `kani.sh` now expects the
eighth sessions harness.
NET-066, NET-067 and NET-136 enforced where every one of a box's frames
already passes: the session relay. A new `net::dns_gate::DnsGate`, built
per box into the `SessionGate` at attach and shared by both relay legs,
holds the admission table and the three decisions that read it:

- Pinning: a DNS reply from the switch's own resolver answering a query
  for a name the box's `egress.allow_dns_hosts` declared is split by the
  pure rebinding intersection (the sessions core landed beside this),
  and each surviving answer is admitted for a five-minute window. On
  the egress leg an admitted address is the one thing that lifts an
  undeclared-destination drop — denied ranges and protocol rules keep
  governing, so a pin cannot smuggle a refused range through.
- Refusal: an answer landing in the box's `deny_subnets` or the
  infrastructure deny set never enters the table, and each says so
  through the session's rate limiter with the name and the answer
  (`warn_dns_refusal`, one line per box per rule per minute).
- NODATA: AAAA, HTTPS (65) and SVCB (64) queries toward the switch's
  resolver are answered empty by the relay itself, under the query's own
  id, and never written on to the switch.

The reply itself always passes through to the box; resolution is
honest, it is the connection to a refused address that is not admitted.
Synthesizing the reply frame needs honest length bookkeeping at the
relay, so the switch gains `udp_datagram` (bounds-checked UDP payload
extraction), `udp_reply_frame`/`ipv4_checksum` (a reply the box's
kernel accepts), and `write_tap_frame` (the ingress leg's write loop,
now shared with the NODATA answers). The relay harness items in the
switch tests become `pub(crate)` so the gate's tests drive the same
legs, and the own-IP attach doc names the DNS dimension it carries.
@coderabbitai
coderabbitai Bot commented Sep 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

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: c90fa487-0685-4bf9-9fc9-7976b9bc3988

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

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

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

Reviewed: 1 finding, none blocking at 8c33619c6cf9.

Address the comments on the diff.

Clobber: none: git log on switch.rs/egress.rs shows only this branch's own feat/fix commits merged in (no recent unrelated work silently reverted).

@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 — 1 finding, 1 inline.

Comment thread crates/minimald/src/net/switch.rs Resolved
The ingress leg parsed every inbound frame as a UDP datagram before the
gate could say whether the frame was one of its own, and the egress leg
parsed every frame twice — once for the conntrack window and once for the
NODATA interception. Both now cost nothing on a frame that is not
IPv4+UDP, which is most of a relay's traffic: the ingress leg reads the
EtherType and the IPv4 protocol byte before it parses anything, and the
egress leg shares one parse between the conntrack and the DNS gate, with
the datagram's length arithmetic split out so the payload needs no second
pass. The pre-check is a narrowing, never a reclassification — every
frame it rejects is one udp_datagram could not have read — and that is
what the new test asserts.
@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 — 1 finding, 1 inline.

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

Round 1 of 10 on the runtime's review findings, pushed as ff6037498042.

line exit tail
cargo nextest run -p minimald dns_pinned_admission_window 0 Applying loadouts: default Running task runtime-implement-test-0 in session task-runtime-implement-test-0-52af... Compiling ot v0.0.1 (/home/runner/.local/state/minimal/sessions/2a373/tree/crates/ot) Compiling args v0.0.1 (/home/runner/.local/state/minimal/sessions/2a373/tree/crates/args) C
cargo nextest run -p minimald denied_range_resolution_refused 0 Applying loadouts: default Running task runtime-implement-test-1 in session task-runtime-implement-test-1-eecc... Compiling stdlib v0.0.20 (/home/runner/.local/state/minimal/sessions/1e7ea/tree/crates/stdlib) Compiling version v0.6.0 (/home/runner/.local/state/minimal/sessions/1e7ea/tree/crate
cargo nextest run -p minimald denied_range_resolution_logged 0 Applying loadouts: default Running task runtime-implement-test-2 in session task-runtime-implement-test-2-2add... Compiling stdlib v0.0.20 (/home/runner/.local/state/minimal/sessions/a8b95/tree/crates/stdlib) Compiling version v0.6.0 (/home/runner/.local/state/minimal/sessions/a8b95/tree/crate
cargo nextest run -p minimald aaaa_https_and_svcb_queries_are_nodata 0 Applying loadouts: default Running task runtime-implement-test-3 in session task-runtime-implement-test-3-f781... Compiling stdlib v0.0.20 (/home/runner/.local/state/minimal/sessions/5c62d/tree/crates/stdlib) Compiling version v0.6.0 (/home/runner/.local/state/minimal/sessions/5c62d/tree/crate

Verify min task run agent-verify passed.

Review threads the coder judged:

  • 👍 valid — PRRT_kwDOPdfcxM6mLIoB: The ingress leg now reads the EtherType and the IPv4 protocol byte before it parses anything (is_ipv4_udp), and the egress leg shares one parse_ipv4_l4 between the conntrack window and the DNS gate, with the length arithmetic split into udp_payload so the datagram's payload needs no second pass; udp (not posted on the thread: not an open thread on the pull request)

A box that allows github.com reaches exactly the addresses github.com resolved to for the admission window and nothing else, an allowed name that resolves into a denied range is refused with the name and the answer in the rate-limited log line, and AAAA, HTTPS and SVCB lookups come back NODATA from the relay — all four proofs still pass at this branch after this round's change, which now spends the DNS gate's frame parse only on IPv4+UDP frames instead of on every frame the relay carries.

Notes from the agent: Revise round 1 of PR #1730: the directive carried one review finding (LOW perf, switch.rs) and one thread, and this round addresses only that. The fix is behavior-preserving and confined to switch.rs, a named layer file: the ingress leg (relay_switch_to_tap) now reads the EtherType and the IPv4 protocol byte (is_ipv4_udp) before parsing, so a peer's TCP/ARP/IPv6 frames cost three comparisons instead of a full udp_datagram parse; the egress leg (relay_tap_to_switch) now parses once and shares the result between the conntrack window and the NODATA interception, with the datagram's length arithmetic split out of udp_datagram into udp_payload so the payload needs no second pass. A new test, udp_precheck_narrows_without_losing_datagrams, asserts the pre-check only ever narrows (every frame it rejects is one udp_datagram could not have read). No other file needed a change; nothing was touched outside the layers. Deferred (lane: kani): cargo nextest run -p sessions kani_rebinding_intersection_admits_no_denied_address was not run on this box — the harness exists (crates/sessions/src/core/egress.rs, one of the 8 sessions harnesses scripts/kani.sh expects) from the earlier round, and this round did not touch it, so the lane still owes its evidence run.

@norrietaylor
Copy link
Copy Markdown
Member

/revise

Babysitter review against T30's plan block (covers NET-066, NET-067, NET-136; four test lines exit 0, gate exit 0, K 8000 ani lane green). The design's window (deployment-and-egress-gateway §5.3) is what NET-066 points at, and the gate departs from it in three places. Fix 1; decide 2 and 3 and say so in the module doc; the rest are notes.

  1. BLOCKING, dns_gate.rs and switch.rs: an established flow to a pinned address is severed when the pin expires. The pin lifts UndeclaredSubnet per frame, the verdict has no SYN/established notion, and admits_destination only checks now < expires with no refresh on use. A git clone or a keep-alive to github.com longer than the window stalls silently. §5.3 requires conntrack-aware retention: an established flow keeps its admitted destination past window expiry until the flow ends. The module doc's rationale (no libc cache, every getaddrinfo rides the relay) covers re-connects only. Add a relay-level test that a flow admitted before expiry still passes frames after it.
  2. MEDIUM, dns_gate.rs: the window is a fixed 300 s and ignores the answer's TTL. RData::A is read, record.ttl() never is. §5.3 defines a TTL-bounded window with a floor of 600 s and a cap of 24 h (proposed). Either make it TTL-bounded within those bounds or state the fixed window as a deliberate deviation with the reason.
  3. MEDIUM, dns_gate.rs: the admission table is unbounded per box. retain evicts expired entries only, and runs on every insert past 4096 entries, so an allowed name answering many distinct addresses grows the table for the whole window with an O(n) scan per admission. §5.3 caps admitted addresses at 32 per name per family; there is no per-name or per-box cap here.
  4. LOW, dns_gate.rs: reply matching uses queries.first() only and does not check qtype A or that the id matches a query the box sent; source trust rests on src == resolver:53, documented as inheriting the pre-NET-084 hairpin hole. Acceptable for now, name it in the doc.
  5. LOW: DNS over TCP from a deny-all box is dropped as UndeclaredSubnet, so a TC=1 answer can never be retried over TCP. Not a rebinding vector, but a resolution failure the PR does not mention.
  6. LOW, policy.rs: warn_dns_refusal keys the limiter on (session_id, rule), so a second refused name within the minute is suppressed; the spec's sub-requirement logs the name and the answer per refusal. denied_range_resolution_logged replays the same reply and never a second name.
  7. NIT: in denied_range_resolution_refused the 10.9.9.9 half is decided by DeniedSubnet in the verdict, not the intersection; only the 169.254.169.254 half proves the intersection at the relay.
  8. NIT: the Kani harness bounds at two CIDRs per set where the spec allows four. Within bound, weaker than the text.

Verified: both relay legs are hooked, "nothing else" is enforced at the relay (drop unless admitted), one table per SessionGate, no lock across an await, the parser bounds-checks every read after the 4096-byte cap. minimal-review's SOA note is RFC 2308 type-3 NODATA and is not blocking.

Round 2 of the review on the DNS gate. The blocking finding first: a
pin expired under a live connection. The relay's egress leg asked
admits_destination per frame, which checks the window and nothing else,
so a git clone or a keep-alive to an allowed name longer than five
minutes stalled silently once the pin aged out. The gate now carries a
flow table beside the admission table: the first frame a pin admits
establishes its flow (protocol, both ports, destination), and an
established flow keeps its admitted destination past window expiry
until a FIN or RST ends it — design §5.3's conntrack-aware retention —
while a new connection to the same address after the window is refused
until the box re-resolves. The egress leg asks the new admits_flow with
the L4 packet it already parsed on the pin's path.

The window itself stays fixed at 300 s, now stated in the module doc
as a deliberate deviation from §5.3's TTL-bounded window rather than
an unexplained one: a TTL-bounded window hands the duration of the
box's grant to a value its policy does not control and a
rebinding-controlled zone can set to the cap, and §5.3's floor alone
would double the reach every answer holds here. What the floor covers
is covered instead by retention for established flows and by
re-resolution for new ones — the box's libc holds no DNS cache, so
every getaddrinfo rides the relay again.

The admission table is also no longer unbounded: §5.3 caps admitted
addresses at 32 per name per family, and the gate enforces it, refusing
answers past the cap with a debug line naming the name and address. The
size-triggered sweep stays as the per-box backstop.

The review's notes are named in the module doc now: the forward NODATA
of NET-136 carries no SOA deliberately (the gate is authoritative for
no forward zone, and the box's stack holds no negative cache to warm —
the box-zone answerer's SOA is NET-124's and serves a different
resolver), reply matching reads the first question for the name and
nothing else (the relay's source check is the whole defence, the
pre-NET-084 hairpin inherited), and DNS over TCP is not carried, so a
TC=1 retry fails — a resolution failure recorded, not a rebinding
vector. The refused-range test also says which half proves the
intersection at the relay and which half the frame verdict's own
denied-subnet rule decides.

Tests: established_flow_keeps_its_pin_past_the_window drives a relay
whose window is shrunk to a second — the flow's frames pass after
expiry, a new flow to the same address is refused, the flow's end
releases the address, and a re-resolution re-admits it — and
admitted_addresses_are_capped_per_name proves the cap refuses the 33rd
address of a name while a second name still admits beside it.
The rate limiter behind warn_dns_refusal keyed on (session, rule)
alone, so a box refused on two names inside the minute heard about the
first only — the spec's sub-requirement logs the name and the answer
per refusal, and the log test replayed one reply twice and never a
second name. The key now carries the refused name beside the rule, so
each refused name spends its own once-per-minute line while every
other warning keeps its old budget. The test asserts it: a second
refused name inside the interval is heard, and the line says which
name and answer it is.
The rebinding harness bounds each deny set at two CIDRs where the tier
text allows four, and the doc let the text be read as the proof's. The
bullet now says the bound honestly: two per set, pinned for the same
reason two_protocols pins two protocols — a third adds no new failure
shape while it multiplies CBMC's search — and a set-shaped hole that
only opens at three or more rules is outside this proof's bound, which
the doc states rather than leaves to be inferred.
@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 — 1 finding, 1 inline.

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

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

line exit tail
cargo nextest run -p minimald dns_pinned_admission_window 0 Applying loadouts: default Running task runtime-implement-test-0 in session task-runtime-implement-test-0-f1ca... Compiling args v0.0.1 (/home/runner/.local/state/minimal/sessions/979e5/tree/crates/args) Compiling ot v0.0.1 (/home/runner/.local/state/minimal/sessions/979e5/tree/crates/ot) C
cargo nextest run -p minimald denied_range_resolution_refused 0 Applying loadouts: default Running task runtime-implement-test-1 in session task-runtime-implement-test-1-641e... Compiling stdlib v0.0.20 (/home/runner/.local/state/minimal/sessions/bfa96/tree/crates/stdlib) Compiling version v0.6.0 (/home/runner/.local/state/minimal/sessions/bfa96/tree/crate
cargo nextest run -p minimald denied_range_resolution_logged 0 Applying loadouts: default Running task runtime-implement-test-2 in session task-runtime-implement-test-2-cdaf... Compiling stdlib v0.0.20 (/home/runner/.local/state/minimal/sessions/f2a76/tree/crates/stdlib) Compiling version v0.6.0 (/home/runner/.local/state/minimal/sessions/f2a76/tree/crate
cargo nextest run -p minimald aaaa_https_and_svcb_queries_are_nodata 0 Applying loadouts: default Running task runtime-implement-test-3 in session task-runtime-implement-test-3-626f... Compiling stdlib v0.0.20 (/home/runner/.local/state/minimal/sessions/063f9/tree/crates/stdlib) Compiling version v0.6.0 (/home/runner/.local/state/minimal/sessions/063f9/tree/crate

Verify min task run agent-verify passed.

Review threads the coder judged:

  • 👍 valid — crates/minimald/src/net/dns_gate.rs:348: documented the deliberate omission in the gate's module doc: the gate is authoritative for no forward zone so there is no SOA it could honestly carry, and the box's own resolver stack holds no negative cache to warm — the answerer's SOA is NET-124's and serves the host resolver, a different one.

A box whose egress declares a name now keeps what it resolved for as long as it is using it: an established flow to a pinned address still passes the relay after the admission window expires (until FIN/RST ends it), a new connection to that same address is refused until the box re-resolves, the admission table is capped at 32 addresses per name per family, a second refused name inside the rate-limit minute is logged with its own name and answer, and the gate's module doc states the fixed 300s window as a deliberate deviation from design §5.3 (with reasons) plus every loose end the review named (no SOA on the forward NODATA, loose reply matching, DNS over TCP not carried).

Notes from the agent: Deferred (lane: kani, not runnable on this box): cargo nextest run -p sessions kani_rebinding_intersection_admits_no_denied_address — the harness name is real (crates/sessions/src/core/egress.rs:1324) and the round's only change to that file is prose inside its cfg(kani)-gated doc comment, so it cannot be compile-verified here; the kani lane still owes that run. Review findings: #1 fixed (flow table + admits_flow on the egress leg, with the relay-level test asked for; the window is shrunk to 1s through a #[cfg(test)] hook threaded spawn_test_relay_with -> SessionGate::shrink_admission_window -> DnsGate::shrink_window because the relay uses real Instant::now()). #2 decided, not changed: the window stays fixed at 300s and the module doc now states it as a deliberate deviation from §5.3's TTL-bounded window (the TTL hands the grant's duration to a value the box's policy does not control; the 600s floor alone would double the reach; the floor's job is covered by retention for established flows and by re-resolution for new ones). #3 fixed: MAX_ADDRESSES_PER_NAME = 32 per name per family, over-cap answers refused with a debug line, the size sweep kept as the per-box backstop. #4 and #5 (loose reply matching; DNS over TCP not carried) named in the module doc's 'Known loo…[truncated]

@norrietaylor
Copy link
Copy Markdown
Member

/revise

Round 2 covers the three points: retention past the window, a documented deviation on the fixed window with a rebinding rationale, and the per-name cap. Two minimal-review threads remain open on 897e330 and the second is a real gap:

  1. dns_gate.rs admits_flow: FLOW_IDLE_CAP is never checked at lookup, only by the sweep that runs past 4096 entries on insert. Below that size an idle retained flow keeps its pin for the box's whole uptime, and a UDP flow can never be released at all because tcp_flags is zero for UDP so ends is never true. Check the idle cap at lookup the way UdpConntrack::allows_ingress does, and add a test that drives a UDP flow past the idle cap and sees it dropped, plus one for an idle TCP flow without a FIN.
  2. intercept_query NODATA without SOA: add the SOA the box-zone answerer already synthesizes for its negatives, for parity, or reply on the thread with why not and resolve it. Either is fine.

Resolve both threads when addressed.

The retention's staleness bound was documented as FLOW_IDLE_CAP but
enforced only by the sweep that runs once the flow table crosses 4096
entries, and that sweep is the only place the cap was read. Below that
size — every ordinary box — an established flow kept its pinned
destination for the whole box uptime, and a UDP flow could never be
released at all: parse_ipv4_l4 zeroes the flags byte for every transport
that is not TCP, so a FIN or RST is a close signal a UDP flow cannot
carry and the flow's only possible release was idleness.

Read the cap at the lookup of every frame that would ride a flow, the way
the same file's UdpConntrack::allows_ingress reads its TTL, so an idle
flow is reclaimed at the frame that would have ridden it however small
the table is. The sweep stays, as the bound's memory half; the docs of
both constants now say which half is which, and admits_flow's contract
names the lookup as where the release happens.

Two proofs, one per transport the finding called untested: a UDP flow
ridden past the window is still admitted and the same flow idle past the
cap is dropped at the relay, re-resolution opening it again; and an idle
TCP flow that never sent a FIN behaves the same. Each also drives the
gate directly against its own clock, where a ridden flow holds up to the
cap and an idle one is released at its edge. The NODATA thread on the
same file is judged and left as documented: the gate is authoritative
for no forward zone, and the box-zone SOA it could borrow names a zone
that does not cover the qname, so it is not a record the reply can
carry.
The review thread on the NODATA reply's empty authority section asked
the one question the module doc left implicit: whether the record the
box-zone answerer synthesizes for its negatives could be borrowed for
parity. State the answer where the omission is documented, with the
facts it rests on rather than the conclusion alone — the reply is
non-authoritative, hickory's response_from_request clearing the AA bit,
and RFC 2308's negative answer carries the SOA of the zone holding the
asked name, so the answerer's min.internal SOA on a github.com answer
names a zone that does not cover the record. For the one client that
reads an authority section, a caching resolver, that is a discarded
record at best and a broken reply at worst, so carrying it would trade
the honest empty answer that lets getaddrinfo degrade to IPv4 for a
protocol violation.
@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 — 1 finding, 1 inline.

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

Round 3 of 10 on a /revise note, pushed as 29147b09d658.

line exit tail
cargo nextest run -p minimald dns_pinned_admission_window 0 Applying loadouts: default Running task runtime-implement-test-0 in session task-runtime-implement-test-0-9c0b... Compiling args v0.0.1 (/home/runner/.local/state/minimal/sessions/93917/tree/crates/args) Compiling ot v0.0.1 (/home/runner/.local/state/minimal/sessions/93917/tree/crates/ot) C
cargo nextest run -p minimald denied_range_resolution_refused 0 Applying loadouts: default Running task runtime-implement-test-1 in session task-runtime-implement-test-1-04cd... Compiling stdlib v0.0.20 (/home/runner/.local/state/minimal/sessions/05703/tree/crates/stdlib) Compiling version v0.6.0 (/home/runner/.local/state/minimal/sessions/05703/tree/crate
cargo nextest run -p minimald denied_range_resolution_logged 0 Applying loadouts: default Running task runtime-implement-test-2 in session task-runtime-implement-test-2-e79a... Compiling stdlib v0.0.20 (/home/runner/.local/state/minimal/sessions/ea574/tree/crates/stdlib) Compiling version v0.6.0 (/home/runner/.local/state/minimal/sessions/ea574/tree/crate
cargo nextest run -p minimald aaaa_https_and_svcb_queries_are_nodata 0 Applying loadouts: default Running task runtime-implement-test-3 in session task-runtime-implement-test-3-3f4e... Compiling stdlib v0.0.20 (/home/runner/.local/state/minimal/sessions/77fb8/tree/crates/stdlib) Compiling version v0.6.0 (/home/runner/.local/state/minimal/sessions/77fb8/tree/crate

Verify min task run agent-verify passed.

Review threads the coder judged:

  • 👍 valid — PRRT_kwDOPdfcxM6mLeKv: Took the finding's second remedy and made the documentation answer its own feasibility question: the module doc's loose-ends bullet now states that the reply is non-authoritative (hickory's Metadata::response_from_request clears the AA bit) and that RFC 2308's negative answer carries the SOA of the (not posted on the thread: not an open thread on the pull request)
  • 👍 valid — PRRT_kwDOPdfcxM6mL5sR: admits_flow now reads flow_idle_cap at the lookup of every frame that would ride a flow, the way UdpConntrack::allows_ingress reads its TTL, so an idle flow is reclaimed at the frame itself however small the table is; the sweep stays as the memory half only. Added udp_flow_past_the_idle_cap_is_relea (not posted on the thread: not an open thread on the pull request)

A box that allows only github.com reaches the addresses github.com resolved to for the admission window and nothing else, an established flow keeps its pin past the window only while it is ridden — an idle one, UDP included, is released at the idle cap, which the relay now reads at every lookup the way the conntrack reads its TTL — an allowed name that resolves into a denied range is refused with the name and the answer in the log, and AAAA, HTTPS and SVCB lookups come back empty.

Notes from the agent: Revise round 3: both open threads judged, one fixed in code, one resolved by the finding's own second remedy. Nothing outside the task's layers was touched this round; the branch's changed set is still the seven files the layers name, and this round adds only the two daemon files above. Inside switch.rs, IPPROTO_TCP was made pub(crate) for the DNS gate's tests, mirroring the existing pub(crate) note on IPPROTO_UDP in the same file. Deferred: cargo nextest run -p sessions kani_rebinding_intersection_admits_no_denied_address (lane kani) — the harness exists at crates/sessions/src/core/egress.rs:1324 and scripts/kani.sh counts it in its expect sessions 8, but this box has no Kani, so the lane still owes the evidence; no exit code is reported for it. Verification scope: the minimald crate only (its tests and its clippy, all targets) plus workspace fmt; the repo's canonical just ci (workspace clippy, cargo-deny, the dogfood build smoke, doctests) was not run here — its smoke fetches prebuilt packages over the network and the lanes re-run the four test lines anyway. For whoever comes next: FLOW_IDLE_CAP is now a field on DnsGate with a #[cfg(test)] shrink hook beside the window's, so the lookup-time check and the sweep both read the one field — a future change to the retention should keep them reading the same value.

@norrietaylor
Copy link
Copy Markdown
Member

/revise

One open minimal-review thread on 29147b0, and it is right: relay_egress records a conntrack solicitation window for every outbound UDP datagram before the AAAA/HTTPS/SVCB interception answers NODATA and continues, so an intercepted query that never reaches the resolver still opens a window keyed (resolver, 53, box_src_port) that lets a matching inbound datagram pass the ingress gate as solicited for the TTL. Move record_egress after the interception so only forwarded queries open a window, add the one assertion (an intercepted query opens no window), and resolve the thread. Nothing else is open; CI is green.

@norrietaylor
Copy link
Copy Markdown
Member

/revise

Re-posting: the round this note opened at 05:11Z never got a container (capacity refusal, job cd808c43) and no job exists for it now, so the task status is stuck on "round 4". Same directive:

One open minimal-review thread on 29147b0, and it is right: relay_egress records a conntrack solicitation window for every outbound UDP datagram before the AAAA/HTTPS/SVCB interception answers NODATA and continues, so an intercepted query that never reaches the resolver still opens a window keyed (resolver, 53, box_src_port) that lets a matching inbound datagram pass the ingress gate as solicited for the TTL. Move record_egress after the interception so only forwarded queries open a window, add the one assertion (an intercepted query opens no window), and resolve the thread. Nothing else is open; CI is green.

The egress relay leg recorded a conntrack window for every admitted
outbound UDP datagram before the AAAA/HTTPS/SVCB interception ran, so a
query the relay answered itself — one that never reached the resolver —
still opened a window keyed (resolver, 53, box_src_port). Nothing was
sent upstream, so no reply is coming through that window: its only
possible consumer was an unsolicited inbound datagram from the
resolver's address to the query's source port, which the ingress gate
would then pass as solicited for the TTL.

Record the window after the interception, so only a datagram this leg
actually forwards opens one. The normal A-query path is unchanged and
an undeclared datagram still never reaches the record, being dropped
earlier in the leg.

The NODATA test asserts both halves: an intercepted query leaves a
datagram from the resolver to its own source port unsolicited — dropped,
with only the sentinel behind it reaching the box — and the forwarded
A query's reply still comes back.
@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 — approved

Verdict APPROVE — No findings.

@minimal-foundry minimal-foundry Bot added the review:approved minimal-review approved the current head label Sep 26, 2026
@minimal-foundry
Copy link
Copy Markdown
Contributor Author

Round 4 of 10 on a /revise note, pushed as 25c909db16c4.

line exit tail
cargo nextest run -p minimald dns_pinned_admission_window 0 Applying loadouts: default Running task runtime-implement-test-0 in session task-runtime-implement-test-0-1555... Compiling args v0.0.1 (/home/runner/.local/state/minimal/sessions/7c31b/tree/crates/args) Compiling ot v0.0.1 (/home/runner/.local/state/minimal/sessions/7c31b/tree/crates/ot) C
cargo nextest run -p minimald denied_range_resolution_refused 0 Applying loadouts: default Running task runtime-implement-test-1 in session task-runtime-implement-test-1-c8f0... Compiling stdlib v0.0.20 (/home/runner/.local/state/minimal/sessions/94c0c/tree/crates/stdlib) Compiling version v0.6.0 (/home/runner/.local/state/minimal/sessions/94c0c/tree/crate
cargo nextest run -p minimald denied_range_resolution_logged 0 Applying loadouts: default Running task runtime-implement-test-2 in session task-runtime-implement-test-2-618c... Compiling stdlib v0.0.20 (/home/runner/.local/state/minimal/sessions/fbd70/tree/crates/stdlib) Compiling version v0.6.0 (/home/runner/.local/state/minimal/sessions/fbd70/tree/crate
cargo nextest run -p minimald aaaa_https_and_svcb_queries_are_nodata 0 Applying loadouts: default Running task runtime-implement-test-3 in session task-runtime-implement-test-3-c306... Compiling stdlib v0.0.20 (/home/runner/.local/state/minimal/sessions/b5e37/tree/crates/stdlib) Compiling version v0.6.0 (/home/runner/.local/state/minimal/sessions/b5e37/tree/crate

Verify min task run agent-verify passed.

Review threads the coder judged:

  • 👍 valid — PRRT_kwDOPdfcxM6mNjdL: Moved record_egress after the NODATA interception so only forwarded datagrams open a window, and asserted in aaaa_https_and_svcb_queries_are_nodata that an intercepted query leaves a resolver datagram to its own source port unsolicited (the assertion fails on the pre-fix code) while a forwarded A (not posted on the thread: not an open thread on the pull request)

A box that allows github.com reaches the addresses github.com resolved to for the admission window and nothing else, an allowed name that resolves into a denied range is refused with the name and the answer in the log, and AAAA, HTTPS and SVCB lookups come back empty from the relay — which this round also proves leave no inbound hole: an intercepted lookup opens no UDP conntrack window, so a datagram from the resolver to the query's own source port stays unsolicited and the ingress gate drops it, while the A query the relay does forward still opens the window its reply returns through.

Notes from the agent: Revise round 4: one open thread, judged valid and fixed. relay_tap_to_switch now records the conntrack window only after the AAAA/HTTPS/SVCB interception, so a query the relay answered itself — one that never reached the resolver — opens no window keyed (resolver, 53, box_src_port); aaaa_https_and_svcb_queries_are_nodata asserts it by sending an inbound datagram from the resolver to the intercepted query's source port and expecting the ingress gate to drop it, and asserts the counterweight that the forwarded A query's reply still returns. Verified the new assertion bites: with the pre-fix switch.rs restored the test fails on an intercepted AAAA query opened no conntrack window; with the fix all four test lines pass. cargo clippy -p minimald --all-targets and cargo fmt --check are clean. Nothing touched outside the layers. The deferred kani line, cargo nextest run -p sessions kani_rebinding_intersection_admits_no_denied_address, stays deferred on the kani lane (not runnable on this box); its harness is in place from the earlier round at crates/sessions/src/core/egress.rs:1324 and was not changed this round.

…d-names-to-the-addresses-they-resolved

# Conflicts:
#	crates/minimald/src/net/gvproxy_network.rs
#	crates/minimald/src/net/switch.rs
#	crates/sessions/src/core/egress.rs
Main added the diff-scoped strict clippy gate (`just clippy-strict`), and
once this branch had merged main in, it reported 26 diagnostics on the
lines this branch adds — all inside the DNS gate's three files.

Fix the ones whose input is an untrusted frame: `is_ipv4_udp` and
`ipv4_checksum` now read their fields the way `udp_payload` does, and
`udp_reply_frame` states the parsed-datagram invariant its MAC slices
were silently relying on. The two relay loops keep their `..n` slices and
name the invariant that bounds them with
`#[expect(clippy::indexing_slicing, reason = "...")]`: `n` is the count
the loop's own read returned for that buffer.

The test sites follow: the four `is_ok()` assertions unwrap, the eight
forwarded-query reads now check the `io::Result` they were discarding —
the inner read had been unchecked, so a failed read passed for a
forwarded query — and one `as u8` disappears by making the range `u8`.

`just clippy-strict`: clean.
@minimal-foundry
Copy link
Copy Markdown
Contributor Author

Round 5 of 10 on a conflict with the base branch, pushed as bf9fe26e939a.

Resolved conflicts in: crates/minimald/src/net/gvproxy_network.rs, crates/minimald/src/net/switch.rs, crates/sessions/src/core/egress.rs

line exit tail
cargo nextest run -p minimald dns_pinned_admission_window 0 Applying loadouts: default Running task runtime-implement-test-0 in session task-runtime-implement-test-0-4246... Compiling args v0.0.1 (/home/runner/.local/state/minimal/sessions/d54da/tree/crates/args) Compiling ot v0.0.1 (/home/runner/.local/state/minimal/sessions/d54da/tree/crates/ot) C
cargo nextest run -p minimald denied_range_resolution_refused 0 Applying loadouts: default Running task runtime-implement-test-1 in session task-runtime-implement-test-1-32b0... Compiling stdlib v0.0.20 (/home/runner/.local/state/minimal/sessions/e96dc/tree/crates/stdlib) Compiling version v0.6.0 (/home/runner/.local/state/minimal/sessions/e96dc/tree/crate
cargo nextest run -p minimald denied_range_resolution_logged 0 Applying loadouts: default Running task runtime-implement-test-2 in session task-runtime-implement-test-2-1ed3... Compiling stdlib v0.0.20 (/home/runner/.local/state/minimal/sessions/d00d3/tree/crates/stdlib) Compiling version v0.6.0 (/home/runner/.local/state/minimal/sessions/d00d3/tree/crate
cargo nextest run -p minimald aaaa_https_and_svcb_queries_are_nodata 0 Applying loadouts: default Running task runtime-implement-test-3 in session task-runtime-implement-test-3-eb01... Compiling stdlib v0.0.20 (/home/runner/.local/state/minimal/sessions/d674e/tree/crates/stdlib) Compiling version v0.6.0 (/home/runner/.local/state/minimal/sessions/d674e/tree/crate

Verify min task run agent-verify passed.

On this branch a box whose egress policy allows github.com reaches exactly the addresses that name resolved to for the admission window (a flow the pin established keeps its destination past the window until it goes idle, and nothing else — every other destination is still dropped); an allowed name that resolves into a denied range has that answer refused at resolution time and the refusal is logged rate-limited with the name and the answer; and the box's own AAAA, HTTPS and SVCB lookups toward the switch's resolver come back NODATA without ever reaching the switch. All three still hold with main's lease check (NET-084) now in the same relay legs.

Notes from the agent: Round 5 was a conflict round: the three paths in conflicts.txt were resolved and the merge completed as 9465c79 before anything else. The resolution combines this branch's DNS gate with main's new lease check (NET-084, T40): the egress leg rejects a foreign source before the verdict, the DNS pin stays the one drop the rejection-then-verdict path lifts, and both parents' doc comments were merged rather than one picked. The merge forced four adaptations, all inside the layers: main's arp_frame now takes the ARP sender address (its lease check reads it), so dns_gate.rs's eight sentinel calls pass LEASE; EgressRules::from_policy grew a lease argument, so the dns_gate test helper and the egress accessor test pass one; main's spawn_relay_for gained the configure hook the DNS-gate proofs shrink their window through; and main's lease() accessor was kept beside this branch's allow_subnets()/deny_subnets(). Second commit, out of the merge but in the layers: main also added the diff-scoped strict clippy gate (just clippy-strict, part of just ci), and once main was merged in it reported 26 diagnostics on this branch's own lines — fixed by bounds-checking is_ipv4_udp/ipv4_checksum/udp_reply_frame the way their sibling udp_payload already does, naming the relay loops' read-count invariant with #[expec…[truncated]

@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 — 1 finding, 1 inline.

Comment thread crates/minimald/src/net/dns_gate.rs Resolved
@minimal-foundry minimal-foundry Bot removed the review:approved minimal-review approved the current head label Sep 26, 2026
warn_dns_refusal keys the rate limit by rule and name while the frame
drops key on the rule alone, so the two paths hold separate buckets
inside the one limiter they share. The module doc and the limiter
field doc both claimed the budget was shared; they now say the bucket
is deliberately separate and why, matching what policy.rs documents.
@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 — 1 finding, 1 inline.

Comment on lines +499 to +528
let mut held = admitted
.values()
.filter(|admission| &*admission.name == name)
.count();
let mut admitted_now = Vec::new();
let mut over_cap = Vec::new();
for address in addresses {
if let Some(existing) = admitted.get_mut(address) {
// The name answered again: refresh the window it holds for,
// under whichever owner first admitted the address.
existing.expires = expires;
admitted_now.push(*address);
continue;
}
if held >= MAX_ADDRESSES_PER_NAME {
over_cap.push(Ipv4Addr::from(*address));
continue;
}
held += 1;
admitted.insert(
*address,
Admission {
name: Arc::from(name),
expires,
},
);
admitted_now.push(*address);
}
if admitted.len() > ADMISSION_SWEEP_AT {
admitted.retain(|_, admission| now < admission.expires);
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.

MEDIUM · correctness · confidence 0.62 · in DnsGate::admit

The per-name admission cap is never released for a box whose table stays under ADMISSION_SWEEP_AT (4096), so the cap fills up with stale, expired admissions and a name whose resolved address set changes becomes permanently unreachable. held (line 499-502) counts every admitted entry owned by the name with no expires filter, and the only prune of the admitted table is admitted.retain inside if admitted.len() > ADMISSION_SWEEP_AT (lines 527-528). For a typical box with one or two allowed names the table never crosses 4096, so expired admissions are never swept; once a name has resolved to MAX_ADDRESSES_PER_NAME (32) distinct addresses across its lifetime, every later resolution to a different disjoint set is refused as over-cap (the held >= MAX_ADDRESSES_PER_NAME branch at 513), even though the old addresses' windows have long expired. The module doc says the cap bounds addresses 'at once', which this does not: a stale entry admits nothing but still consumes the cap, so an allowed name that rotates its address set (a CDN, a host that moves providers) becomes reachable only while its first 32 ever-resolved addresses happen to be re-answered. Re-resolution to the same addresses is unaffected (the get_mut refresh path). The fix is to count only unexpired entries in held (or prune the name's expired entries as part of admit), independent of the 4096 sweep threshold.

What the reviewer checked

dns_gate.rs:499 let mut held = admitted.values().filter(|admission| &*admission.name == name).count(); / :527 if admitted.len() > ADMISSION_SWEEP_AT { admitted.retain(|_, admission| now < admission.expires); } — the sweep is the only release, gated at 4096.

Hand this to an agent
Act on a code-review finding.

File:       crates/minimald/src/net/dns_gate.rs
Lines:      499-528
Category:   correctness
Severity:   medium (reviewer confidence 0.62)

Finding:
The per-name admission cap is never released for a box whose table stays under `ADMISSION_SWEEP_AT` (4096), so the cap fills up with stale, expired admissions and a name whose resolved address set changes becomes permanently unreachable. `held` (line 499-502) counts every `admitted` entry owned by the name with no `expires` filter, and the only prune of the `admitted` table is `admitted.retain` inside `if admitted.len() > ADMISSION_SWEEP_AT` (lines 527-528). For a typical box with one or two allowed names the table never crosses 4096, so expired admissions are never swept; once a name has resolved to `MAX_ADDRESSES_PER_NAME` (32) distinct addresses across its lifetime, every later resolution to a *different* disjoint set is refused as over-cap (the `held >= MAX_ADDRESSES_PER_NAME` branch at 513), even though the old addresses' windows have long expired. The module doc says the cap bounds addresses 'at once', which this does not: a stale entry admits nothing but still consumes the cap, so an allowed name that rotates its address set (a CDN, a host that moves providers) becomes reachable only while its first 32 ever-resolved addresses happen to be re-answered. Re-resolution to the *same* addresses is unaffected (the `get_mut` refresh path). The fix is to count only unexpired entries in `held` (or prune the name's expired entries as part of `admit`), independent of the 4096 sweep threshold.

First verify this against the code yourself. It is model output and
may be wrong. If it is wrong, or the tradeoff was deliberate, say so
and change nothing.

👍 useful · 👎 noise — react to tell the reviewer. Replying works too, and says more.

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.

Addressed in 8c33619c6cf9: Fixed in 8c33619: admit releases a name's expired admissions before counting its cap, so a rotated answer set is admitted instead of refused as over-cap by long-expired entries; the 4096 sweep stays the table-wide bound. New test expired_admissions_release_the_per_name_cap covers the rotation case and fails on the unfixed code.

@minimal-foundry
Copy link
Copy Markdown
Contributor Author

Round 6 of 10 on the runtime's review findings, pushed as 7b3497f97b65.

line exit tail
cargo nextest run -p minimald dns_pinned_admission_window 0 Applying loadouts: default Running task runtime-implement-test-0 in session task-runtime-implement-test-0-e06a... Compiling args v0.0.1 (/home/runner/.local/state/minimal/sessions/5a165/tree/crates/args) Compiling ot v0.0.1 (/home/runner/.local/state/minimal/sessions/5a165/tree/crates/ot) C
cargo nextest run -p minimald denied_range_resolution_refused 0 Applying loadouts: default Running task runtime-implement-test-1 in session task-runtime-implement-test-1-c139... Compiling stdlib v0.0.20 (/home/runner/.local/state/minimal/sessions/1334c/tree/crates/stdlib) Compiling version v0.6.0 (/home/runner/.local/state/minimal/sessions/1334c/tree/crate
cargo nextest run -p minimald denied_range_resolution_logged 0 Applying loadouts: default Running task runtime-implement-test-2 in session task-runtime-implement-test-2-956d... Compiling stdlib v0.0.20 (/home/runner/.local/state/minimal/sessions/4adbb/tree/crates/stdlib) Compiling version v0.6.0 (/home/runner/.local/state/minimal/sessions/4adbb/tree/crate
cargo nextest run -p minimald aaaa_https_and_svcb_queries_are_nodata 0 Applying loadouts: default Running task runtime-implement-test-3 in session task-runtime-implement-test-3-ff01... Compiling stdlib v0.0.20 (/home/runner/.local/state/minimal/sessions/8096b/tree/crates/stdlib) Compiling version v0.6.0 (/home/runner/.local/state/minimal/sessions/8096b/tree/crate

Verify min task run agent-verify passed.

Review threads the coder judged:

  • 👍 valid — crates/minimald/src/net/dns_gate.rs:44: Fixed the doc: dns_gate.rs no longer claims the refusal shares the drops' budget — the module doc and the limiter field doc now say the one limiter and log are shared while the refusal keeps its own per-name-per-rule bucket, deliberate because the requirement is the name and the answer per refusal,

At this branch head a box that allows github.com reaches the addresses github.com resolved to for its admission window and nothing else, an allowed name that resolves into a denied range is refused with the name and the answer in the log (rate-limited per name per rule per minute, a bucket deliberately separate from the frame drops' per-rule one, as the dns_gate docs now say), and AAAA, HTTPS and SVCB lookups come back empty.

Notes from the agent: Revise round 6, one review finding, no behaviour change: the module doc and the limiter field doc in dns_gate.rs claimed the DNS refusal shares the frame drops' rate-limit budget; they now say the limiter instance and log are shared while the bucket is deliberately keyed by name as well as rule, which is what policy.rs already documents. Also verified: cargo fmt --check clean, and cargo doc --no-deps -p minimald resolves both new PolicyWarnLimiter::warn_dns_refusal links with no dns_gate warnings (the crate's pre-existing doc warnings are untouched). Deferred (lane: kani, not runnable on this box): cargo nextest run -p sessions kani_rebinding_intersection_admits_no_denied_address — the harness exists (crates/sessions/src/core/egress.rs) and scripts/kani.sh already expects sessions 8 for it from an earlier round; run nothing here.

@norrietaylor
Copy link
Copy Markdown
Member

/revise

Round 6 fixed the doc claim (that thread is addressed; resolve it). The MEDIUM thread on 7b3497f9, crates/minimald/src/net/dns_gate.rs DnsGate::admit, checks out: held counts every admitted entry owned by the name with no expires filter, and the only prune is the retain behind admitted.len() > ADMISSION_SWEEP_AT (4096). A box with one or two allowed names never crosses that threshold, so once a name has ever resolved to MAX_ADDRESSES_PER_NAME distinct addresses, every later disjoint answer is refused as over-cap even though the old windows expired. The doc says the cap bounds addresses held "at once"; make that true:

  1. In admit, count only unexpired entries in held (now < admission.expires), or prune this name's expired entries before counting. Keep the 4096 sweep as the table-wide bound.
  2. Add one test: a name resolves to MAX_ADDRESSES_PER_NAME addresses, the window passes, it resolves to a disjoint set of the same size, and every new address is admitted while the expired ones are gone.
  3. Resolve both threads.

CI is green on 7b3497f9; nothing else is open.

…d-names-to-the-addresses-they-resolved

# Conflicts:
#	crates/minimald/src/net/switch.rs
@minimal-foundry
Copy link
Copy Markdown
Contributor Author

Round 7 of 10 on a conflict with the base branch, pushed as 1503f116e70c.

Resolved conflicts in: crates/minimald/src/net/switch.rs

line exit tail
cargo nextest run -p minimald dns_pinned_admission_window 0 Applying loadouts: default Running task runtime-implement-test-0 in session task-runtime-implement-test-0-de12... Compiling args v0.0.1 (/home/runner/.local/state/minimal/sessions/8702f/tree/crates/args) Compiling ot v0.0.1 (/home/runner/.local/state/minimal/sessions/8702f/tree/crates/ot) C
cargo nextest run -p minimald denied_range_resolution_refused 0 Applying loadouts: default Running task runtime-implement-test-1 in session task-runtime-implement-test-1-283f... Compiling stdlib v0.0.20 (/home/runner/.local/state/minimal/sessions/e7610/tree/crates/stdlib) Compiling version v0.6.0 (/home/runner/.local/state/minimal/sessions/e7610/tree/crate
cargo nextest run -p minimald denied_range_resolution_logged 0 Applying loadouts: default Running task runtime-implement-test-2 in session task-runtime-implement-test-2-4fb8... Compiling stdlib v0.0.20 (/home/runner/.local/state/minimal/sessions/e4f5a/tree/crates/stdlib) Compiling version v0.6.0 (/home/runner/.local/state/minimal/sessions/e4f5a/tree/crate
cargo nextest run -p minimald aaaa_https_and_svcb_queries_are_nodata 0 Applying loadouts: default Running task runtime-implement-test-3 in session task-runtime-implement-test-3-9c6b... Compiling stdlib v0.0.20 (/home/runner/.local/state/minimal/sessions/c59a6/tree/crates/stdlib) Compiling version v0.6.0 (/home/runner/.local/state/minimal/sessions/c59a6/tree/crate

Verify min task run agent-verify passed.

A box that allows github.com reaches the addresses github.com resolved to for the admission window and nothing else; an allowed name that resolves into a denied range is refused with the name and the answer in a rate-limited warn line; and AAAA, HTTPS and SVCB lookups the box sends to its resolver come back empty (NODATA) from the relay — all of it intact at the branch head after main's proxy-parity refactor was merged in, with the four named tests green.

Notes from the agent: Round 7 was a conflict round: the directive carried only the merge instruction, no review threads. The conflict was in SessionGate::for_session — this branch had inlined the egress compilation, the infrastructure deny set and the DNS-gate construction there, while main's T34 (#1737) had refactored the same function to call two new helpers, declared_ingress_ports and compiled_egress. Resolved by keeping main's helper-based shape for the ingress halves and the egress field, and building the DNS gate on top: the gate now takes its rules from compiled_egress — the same one compilation the relay's frame verdict and the hostname proxy's caller check read — so a name's admission and a frame's verdict cannot disagree about what the box declared. No behaviour change; the four named tests and the whole minimald suite (475) pass. Files the runner may see in the merge commit beyond that one are main's own (dns.rs, proxy.rs, server.rs, session.rs) — they came in with the merge and are not this task's work. Deferred, not run: cargo nextest run -p sessions kani_rebinding_intersection_admits_no_denied_address (lane: kani) — this box has no Kani; the harness is in place at crates/sessions/src/core/egress.rs (#[kani::proof], unwind 4, per-address oracle plus a two-answer set wrapper over the allow, deny and both halves of the infrastructure set) and scripts/kani.sh's sessions expectation is already 8 to count it.

@norrietaylor
Copy link
Copy Markdown
Member

/revise

Round 7 was the conflict merge, so the MEDIUM thread on crates/minimald/src/net/dns_gate.rs DnsGate::admit is still open on 1503f116, and it checks out: held counts every admitted entry owned by the name with no expires filter, and the only prune is the retain behind admitted.len() > ADMISSION_SWEEP_AT (4096). A box with one or two allowed names never crosses that threshold, so once a name has ever resolved to MAX_ADDRESSES_PER_NAME distinct addresses, every later disjoint answer is refused as over-cap even though the old windows expired. The module doc says the cap bounds addresses held "at once"; make that true:

  1. In admit, count only unexpired entries in held (now < admission.expires), or prune this name's expired entries before counting. Keep the 4096 sweep as the table-wide bound.
  2. Add one test: a name resolves to MAX_ADDRESSES_PER_NAME addresses, the window passes, it resolves to a disjoint set of the same size, and every new address is admitted while the expired ones are gone.
  3. Reply on the thread and resolve it.

CI is green on 1503f116; nothing else is open. Do not touch .github/workflows/.

DnsGate::admit counted every entry the name owned, expired or not,
toward MAX_ADDRESSES_PER_NAME, and the only prune was the table-wide
sweep behind ADMISSION_SWEEP_AT (4096). A box with one or two allowed
names never crosses that threshold, so once a name had resolved to 32
distinct addresses across its lifetime every later disjoint answer was
refused as over-cap although the old windows had long expired: a stale
admission admitted nothing but still spent the cap, and a name whose
address set rotates (a CDN, a host that moves providers) became
reachable only while its first 32 ever-resolved addresses happened to
be re-answered.

admit now releases the name's expired entries before counting, so the
cap bounds what a name holds at once, as its module doc claims. The
4096 sweep stays the table's bound, and now only reclaims the entries
of names that stopped resolving.
@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 — 1 finding, 1 inline.

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

Round 8 of 10 on a /revise note, pushed as 8c33619c6cf9.

line exit tail
cargo nextest run -p minimald dns_pinned_admission_window 0 Applying loadouts: default Running task runtime-implement-test-0 in session task-runtime-implement-test-0-8f00... Compiling ot v0.0.1 (/home/runner/.local/state/minimal/sessions/5e45e/tree/crates/ot) Compiling args v0.0.1 (/home/runner/.local/state/minimal/sessions/5e45e/tree/crates/args) C
cargo nextest run -p minimald denied_range_resolution_refused 0 Applying loadouts: default Running task runtime-implement-test-1 in session task-runtime-implement-test-1-a7b4... Compiling stdlib v0.0.20 (/home/runner/.local/state/minimal/sessions/576f9/tree/crates/stdlib) Compiling version v0.6.0 (/home/runner/.local/state/minimal/sessions/576f9/tree/crate
cargo nextest run -p minimald denied_range_resolution_logged 0 Applying loadouts: default Running task runtime-implement-test-2 in session task-runtime-implement-test-2-6a45... Compiling stdlib v0.0.20 (/home/runner/.local/state/minimal/sessions/e7080/tree/crates/stdlib) Compiling version v0.6.0 (/home/runner/.local/state/minimal/sessions/e7080/tree/crate
cargo nextest run -p minimald aaaa_https_and_svcb_queries_are_nodata 0 Applying loadouts: default Running task runtime-implement-test-3 in session task-runtime-implement-test-3-3dde... Compiling stdlib v0.0.20 (/home/runner/.local/state/minimal/sessions/1b14b/tree/crates/stdlib) Compiling version v0.6.0 (/home/runner/.local/state/minimal/sessions/1b14b/tree/crate

Verify min task run agent-verify passed.

Review threads the coder judged:

  • 👍 valid — crates/minimald/src/net/dns_gate.rs:545: Fixed in 8c33619: admit releases a name's expired admissions before counting its cap, so a rotated answer set is admitted instead of refused as over-cap by long-expired entries; the 4096 sweep stays the table-wide bound. New test expired_admissions_release_the_per_name_cap covers the rotation case

A box that allows github.com reaches the addresses github.com resolved to for the admission window and nothing else — now including a name whose resolved address set rotates after the window, whose fresh answers are admitted instead of refused as over-cap — an allowed name that resolves into a denied range is refused with the name and the answer in the log, and AAAA, HTTPS and SVCB lookups come back empty.

Notes from the agent: Revise round 8 of 10, one thread open, and it held verbatim at the branch head: the head has moved past the directive's stated 1503f11 to 009c63c (a merge of origin/main), and DnsGate::admit still counted every entry the name owned, expired or not, with the 4096 sweep 10BC8 as the only prune. Fixed in 8c33619. I took the directive's second shape — admit now releases the name's expired entries before counting, rather than only filtering the count — because it also keeps the 'at most MAX_ADDRESSES_PER_NAME at once' bound exact (a filtered count would let one reply that both refreshed expired entries and added new ones overshoot the cap) and keeps the table proportionate to the box's declared names, which is what the constant's doc already claimed. ADMISSION_SWEEP_AT is unchanged and remains the table-wide bound; the two constants' docs and admit's doc were reworded to match. Mutation-checked the new test: with the retain line removed it fails on 'an expired admission spends no cap, so the rotated set is admitted', so it is load-bearing, not a tautology. Nothing outside the task's layers was touched. Not run on this box: the deferred kani line 'cargo nextest run -p sessions kani_rebinding_intersection_admits_no_denied_address' (lane: kani) — the harness from round 1 is still at crates/sessions/src/core/egress.rs:1624 and this round changed no code it covers; and the full-crate suite beyond the module scopes named above, which is why the runner's own re-run of the four test lines is the record for those.

@norrietaylor
norrietaylor merged commit 82ef7e3 into main Sep 27, 2026
33 checks passed
@norrietaylor
norrietaylor deleted the agent/1638-pin-allowed-names-to-the-addresses-they-resolved branch September 27, 2026 02:32
norrietaylor added a commit that referenced this pull request Sep 28, 2026
`just fix` is the innermost loop, and the gate failed there. Three agent
rounds on the NET walk then rewrote lines their task never named to clear it
(#1737 round 3, #1730 round 5, #1727 round 4), two of them in conflict rounds
whose only directive was the merge. A command that fails in the edit loop
outranks a sentence in AGENTS.md telling you to leave the hit alone, so the
sentence this branch added needs the recipe to agree with it.

CLIPPY_STRICT_REPORT_ONLY=1 prints the diagnostics and exits 0. `fix` sets it;
`clippy-strict` and `ci` do not, so "before opening a PR" stays the round that
owns the judgement. Only hits are demoted — a build or tooling failure still
exits non-zero, which is what the cargo check above the filter is for.
norrietaylor added a commit that referenced this pull request Sep 28, 2026
…l ref

The base was `git merge-base main HEAD`, and a branch that merges
`origin/main` leaves the local ref where it was — agent sandboxes and
worktrees never check main out at all. The merge-base against a ref that
stale predates the main commits the branch merged in, so every line of those
commits reads as a line this branch changed, and the gate reports
diagnostics on other people's code under a heading saying they are yours.
Three agent rounds fixing lines their task never named is what that looks
like from the outside (#1737, #1730, #1727).

Measured on this branch: a local `main` ten commits behind turned a change
touching no Rust at all into 14 changed Rust files across `minimald`,
`sessions` and `switch`. With `origin/main` it is nought, and the gate says
"no Rust changes" as it should.

The remote ref is the safe one by construction: it cannot be behind anything
the branch merged, because merging it required fetching it, and a fetch only
moves it forward. Stale means the base is older than the newest main, never
older than this branch's first commit. The local ref stays as the fallback
for a clone without a remote.
norrietaylor added a commit that referenced this pull request Sep 28, 2026
…d take it out of just fix (#1749)

* docs(agents): say the strict clippy gate is local and bounded by the directive

Three agent rounds since #1724 landed rewrote lines beyond their task to
satisfy `just clippy-strict`, two of them in conflict rounds whose only
directive was the merge, and two coders wrote that the PR lanes would have
gone red otherwise. They would not: the gate is local, no CI lane runs it.
Say so where AGENTS.md names the gate, and say what a revise or conflict
round does with its hits: report them in `notes`, not fix them.

* chore(lint): report the strict gate in just fix, enforce it in just ci

`just fix` is the innermost loop, and the gate failed there. Three agent
rounds on the NET walk then rewrote lines their task never named to clear it
(#1737 round 3, #1730 round 5, #1727 round 4), two of them in conflict rounds
whose only directive was the merge. A command that fails in the edit loop
outranks a sentence in AGENTS.md telling you to leave the hit alone, so the
sentence this branch added needs the recipe to agree with it.

CLIPPY_STRICT_REPORT_ONLY=1 prints the diagnostics and exits 0. `fix` sets it;
`clippy-strict` and `ci` do not, so "before opening a PR" stays the round that
owns the judgement. Only hits are demoted — a build or tooling failure still
exits non-zero, which is what the cargo check above the filter is for.

* fix(lint): demote only the filter's hits, and never inherit report-only

Two false-success paths in the report-only mode added a commit ago, both of
them the thing this gate exists to avoid: exiting 0 without having checked.

The filter's exit was read as "hits" whatever it was, so no python3 on PATH
or a crash inside it made `just fix` pass having reported nothing. It now
exits a distinct 2 for hits and only that is demoted; 1 from an unhandled
exception, 127 from a missing interpreter, and the cargo failure above the
filter all still leave non-zero.

CLIPPY_STRICT_REPORT_ONLY was read from the ambient environment, so one
export in a shell profile would have turned the gate off in `just
clippy-strict` and therefore in `just ci`, silently. The enforcing recipe
clears it.

* fix(lint): take the strict gate's base from origin/main, not the local ref

The base was `git merge-base main HEAD`, and a branch that merges
`origin/main` leaves the local ref where it was — agent sandboxes and
worktrees never check main out at all. The merge-base against a ref that
stale predates the main commits the branch merged in, so every line of those
commits reads as a line this branch changed, and the gate reports
diagnostics on other people's code under a heading saying they are yours.
Three agent rounds fixing lines their task never named is what that looks
like from the outside (#1737, #1730, #1727).

Measured on this branch: a local `main` ten commits behind turned a change
touching no Rust at all into 14 changed Rust files across `minimald`,
`sessions` and `switch`. With `origin/main` it is nought, and the gate says
"no Rust changes" as it should.

The remote ref is the safe one by construction: it cannot be behind anything
the branch merged, because merging it required fetching it, and a fetch only
moves it forward. Stale means the base is older than the newest main, never
older than this branch's first commit. The local ref stays as the fallback
for a clone without a remote.

* fix(lint): report-only never fails, whatever stopped the gate

`just fix` is the autofix loop: safe to run any number of times and never
stopping on the strict gate. Report-only demoted hits but still exited
101 on a build failure and 127 with no python3, so the loop failed on the
gate it was told only to report. Under report-only every failure is now
printed with its exit code and demoted; enforcing (`just ci`) keeps every
one fatal, so a gate that did not run still cannot pass there.

Driven through cargo and python3 shims: hit 2/0, cargo 101/0, no python3
127/0 (enforcing/report-only), and report-only twice gives the same
output.

* chore(lint): take the strict gate out of just fix

`just fix` is the edit loop, and everything it prints is something to
fix, so a strict hit printed beside fmt and clippy --fix reads as work
whatever a doc sentence says. Report-only kept the hits in that loop and
only removed the failure. Remove the gate from the recipe instead: it
runs in `just clippy-strict` and `just ci`, the round that owns the
judgement, and nowhere else. With no caller, the report-only mode and
the recipe that cleared it go too; the script's exit codes are 0 clean,
2 hits, anything else a failure to run, none read as clean.

AGENTS.md's tight-loop bullet says the loop prints only what to fix.
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.

T30 — Pin allowed names to the addresses they resolved to, refuse denied ranges, and answer AAAA, HTTPS and SVCB empty

1 participant

0