Skip to main content

agent_bridle_core/
net_proxy.rs

1//! Loopback egress proxy for the macOS `net` host allow-list (#124, ADR 0016).
2//!
3//! SBPL cannot name a remote host (ADR 0015), so a general `net: Only([host, …])`
4//! grant cannot be kernel-confined by hostname. The honest mechanism: kernel-fence
5//! a spawned child's egress to the **loopback interface** (the ADR 0015 rule, via
6//! [`crate::loopback_fenced_caveats`]), then run **this** loopback
7//! forward proxy — which the child is pointed at through `*_PROXY` env — to enforce
8//! the hostname allow-list. The child can reach *nothing* off-box except through
9//! the proxy, and the proxy admits only allow-listed hosts.
10//!
11//! **Grade (honesty):** the loopback fence is kernel-grade and unbypassable; the
12//! hostname match is **userspace** (this parent-side process). So the `net` axis
13//! stays reported `Advisory` — the proxy *over-delivers* above that floor (the
14//! `report.rs` doctrine), it does not raise the honest kernel claim.
15//!
16//! **Scope:** HTTP `CONNECT` (HTTPS tunnelling — the proxy never terminates TLS)
17//! and `http://` absolute-form forwarding. Non-proxy-aware traffic (raw sockets,
18//! tools ignoring `*_PROXY`) is kernel-fenced to loopback and therefore blocked
19//! off-box — fail-closed, the safe direction.
20//!
21//! Std-only (`std::net` + `std::thread`); no async runtime, no new dependency —
22//! so [`ProxyHandle`] is a plain RAII value whose `Drop` tears the listener down.
23
24use std::collections::HashSet;
25use std::io::{self, BufRead, BufReader, Read, Write};
26use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream, ToSocketAddrs};
27use std::sync::atomic::{AtomicBool, Ordering};
28use std::sync::{Arc, Mutex};
29use std::thread::{self, JoinHandle};
30use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
31
32use serde::{Deserialize, Serialize};
33
34/// Longest request line / header block the proxy will buffer before giving up
35/// (a request line is short; this only bounds a hostile client). 8 KiB.
36const MAX_HEAD: usize = 8 * 1024;
37/// Per-connection socket timeout, so a stuck peer cannot pin a proxy thread.
38const CONN_TIMEOUT: Duration = Duration::from_secs(30);
39
40// ── Audit (#124, ADR 0016): the proxy is the child's sole egress chokepoint, so
41// every proxy-visible connection is recorded through an operator-supplied sink.
42// This is observability only — it never changes an enforcement decision. ────────
43
44/// The kind of egress a child requested through the proxy.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum NetKind {
48    /// `CONNECT host:port` — an opaque (HTTPS) tunnel.
49    Connect,
50    /// `http://…` plaintext forward.
51    Http,
52}
53
54/// The proxy's allow-list decision for one connection.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum NetDecision {
58    /// Host on the allow-list — connection made.
59    Allowed,
60    /// Host **not** on the allow-list — refused with 403 (the exfil-attempt signal).
61    Denied,
62    /// Allow-listed but the origin could not be reached (DNS/connect failure).
63    Error,
64}
65
66/// One audited egress connection through the proxy — a complete record of the
67/// child's proxy-visible network activity (#124, ADR 0016). Serialised as one
68/// JSON line by [`JsonlSink`]; the `bridle-netmon` binary renders a live view.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct NetAuditEvent {
71    /// Unix-epoch milliseconds when the connection was decided.
72    pub ts_ms: u64,
73    /// The requested host (CONNECT authority or `http://` URI host).
74    pub host: String,
75    /// The requested port.
76    pub port: u16,
77    /// Tunnel (`connect`) or plaintext forward (`http`).
78    pub kind: NetKind,
79    /// Allow-list outcome.
80    pub decision: NetDecision,
81    /// Bytes the child sent (client → origin); `0` for a denied/errored conn.
82    pub bytes_up: u64,
83    /// Bytes the origin returned (origin → child).
84    pub bytes_down: u64,
85    /// Connection lifetime in milliseconds.
86    pub dur_ms: u64,
87}
88
89/// A destination for [`NetAuditEvent`]s — the operator's audit trail. `record` is
90/// called from a per-connection thread, so implementations must be thread-safe
91/// and must never block the connection for long.
92pub trait AuditSink: Send + Sync {
93    /// Record one completed connection.
94    fn record(&self, event: &NetAuditEvent);
95}
96
97/// The default sink — discard everything (audit off, zero overhead).
98pub struct NullSink;
99
100impl AuditSink for NullSink {
101    fn record(&self, _event: &NetAuditEvent) {}
102}
103
104/// Append each event as one JSON line to a `Write` (a file, stderr, a pipe).
105pub struct JsonlSink<W: Write + Send>(Mutex<W>);
106
107impl<W: Write + Send> JsonlSink<W> {
108    /// Wrap a writer as a JSON-lines audit sink.
109    pub fn new(w: W) -> Self {
110        Self(Mutex::new(w))
111    }
112}
113
114impl<W: Write + Send> AuditSink for JsonlSink<W> {
115    fn record(&self, event: &NetAuditEvent) {
116        if let (Ok(mut w), Ok(mut line)) = (self.0.lock(), serde_json::to_string(event)) {
117            line.push('\n');
118            let _ = w.write_all(line.as_bytes());
119            let _ = w.flush();
120        }
121    }
122}
123
124/// Unix-epoch milliseconds now (saturating to 0 before the epoch — never panics).
125fn now_ms() -> u64 {
126    SystemTime::now()
127        .duration_since(UNIX_EPOCH)
128        .map(|d| d.as_millis() as u64)
129        .unwrap_or(0)
130}
131
132/// Resolves a proxied hostname to the address the proxy dials. A seam so a test
133/// can map an allow-listed name to a loopback origin (hermetic, no real DNS).
134pub trait Resolver: Send + Sync {
135    /// Resolve `host:port` to a single dial target, or an error if it cannot.
136    fn resolve(&self, host: &str, port: u16) -> io::Result<SocketAddr>;
137}
138
139/// The production resolver — the platform's own `getaddrinfo`, run in the parent
140/// (never the fenced child), taking the first address.
141pub struct StdResolver;
142
143impl Resolver for StdResolver {
144    fn resolve(&self, host: &str, port: u16) -> io::Result<SocketAddr> {
145        (host, port)
146            .to_socket_addrs()?
147            .next()
148            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no address for host"))
149    }
150}
151
152// ── I/O seams (#166): the per-connection logic is written against `Conn` +
153// `Connector` traits, not concrete `TcpStream`, so the parse / allow-list /
154// forward / tunnel / audit LOGIC can be unit-tested against an in-memory duplex
155// with NO real socket (deterministic, portable). Production wires the real
156// `TcpStream` / `TcpConnector`; the tests wire scripted in-memory endpoints. ───
157
158/// A bidirectional connection the proxy speaks over. Abstracts the three
159/// `TcpStream`-specific operations the forward/tunnel path needs beyond
160/// `Read`/`Write` — a second owned handle ([`Self::dup`], for the read/write
161/// split and the origin clone), directional [`Self::shutdown`] (how the copy
162/// threads signal EOF), and the per-connection timeouts ([`Self::set_timeouts`]).
163/// `TcpStream` implements it for production; an in-memory scripted stream
164/// implements it for tests.
165pub trait Conn: Read + Write + Send {
166    /// A second owned handle to the SAME underlying stream — for `TcpStream`
167    /// this is `try_clone` (both handles share one socket); the proxy holds one
168    /// half for reading and one for writing, and `splice` needs a third for the
169    /// origin write direction.
170    fn dup(&self) -> io::Result<Box<dyn Conn>>;
171    /// Shut down the read half, write half, or both — how a copy thread signals
172    /// EOF to its peer and how a response closes the client.
173    fn shutdown(&self, how: Shutdown) -> io::Result<()>;
174    /// Apply the per-connection read+write timeouts. A no-op for in-memory
175    /// fakes, which never block on a real socket.
176    fn set_timeouts(&self, dur: Duration) -> io::Result<()>;
177}
178
179impl Conn for TcpStream {
180    fn dup(&self) -> io::Result<Box<dyn Conn>> {
181        Ok(Box::new(self.try_clone()?))
182    }
183    fn shutdown(&self, how: Shutdown) -> io::Result<()> {
184        TcpStream::shutdown(self, how)
185    }
186    fn set_timeouts(&self, dur: Duration) -> io::Result<()> {
187        self.set_read_timeout(Some(dur))?;
188        self.set_write_timeout(Some(dur))?;
189        Ok(())
190    }
191}
192
193/// Dials a resolved origin, returning the connection to forward to. A seam so a
194/// test can hand the proxy a scripted in-memory origin instead of a real TCP
195/// dial — the second half (with [`Conn`]) of what makes the forward path
196/// testable without a socket.
197pub trait Connector: Send + Sync {
198    /// Connect to `addr`, or fail (surfaced to the client as `502 Bad Gateway`).
199    fn connect(&self, addr: SocketAddr) -> io::Result<Box<dyn Conn>>;
200}
201
202/// The production connector — a real bounded TCP dial to the resolved origin.
203pub struct TcpConnector;
204
205impl Connector for TcpConnector {
206    fn connect(&self, addr: SocketAddr) -> io::Result<Box<dyn Conn>> {
207        let s = TcpStream::connect_timeout(&addr, CONN_TIMEOUT)?;
208        s.set_read_timeout(Some(CONN_TIMEOUT))?;
209        s.set_write_timeout(Some(CONN_TIMEOUT))?;
210        Ok(Box::new(s))
211    }
212}
213
214/// What a client's first request line asked for.
215#[derive(Debug, PartialEq, Eq)]
216enum Target {
217    /// `CONNECT host:port HTTP/x` — a TLS tunnel to open.
218    Connect { host: String, port: u16 },
219    /// `METHOD http://host[:port]/path HTTP/x` — a plaintext request to forward;
220    /// `origin_line` is the same line rewritten to origin-form (`METHOD /path …`).
221    Http {
222        host: String,
223        port: u16,
224        origin_line: String,
225    },
226}
227
228/// Parse a proxy request line. Returns `None` on anything malformed or a scheme
229/// the proxy does not speak (only `CONNECT` and `http://` absolute-form).
230fn parse_request_line(line: &str) -> Option<Target> {
231    let line = line.trim_end_matches(['\r', '\n']);
232    let mut parts = line.split(' ');
233    let method = parts.next()?;
234    let uri = parts.next()?;
235    let version = parts.next()?;
236    if !version.starts_with("HTTP/") || parts.next().is_some() {
237        return None;
238    }
239    if method.eq_ignore_ascii_case("CONNECT") {
240        let (host, port) = split_host_port(uri, 443)?;
241        return Some(Target::Connect { host, port });
242    }
243    // Absolute-form: METHOD http://host[:port]/path HTTP/x  (proxy requests only).
244    let rest = uri.strip_prefix("http://")?;
245    let (authority, path) = match rest.find('/') {
246        Some(i) => (&rest[..i], &rest[i..]),
247        None => (rest, "/"),
248    };
249    let (host, port) = split_host_port(authority, 80)?;
250    Some(Target::Http {
251        host,
252        port,
253        origin_line: format!("{method} {path} {version}\r\n"),
254    })
255}
256
257/// Split `host:port` (or a bare host, or a bracketed IPv6 literal) into
258/// `(host, port)`, defaulting the port. Returns `None` on an unparsable port.
259fn split_host_port(authority: &str, default_port: u16) -> Option<(String, u16)> {
260    // Bracketed IPv6 literal: [::1] or [::1]:8080
261    if let Some(rest) = authority.strip_prefix('[') {
262        let (host, after) = rest.split_once(']')?;
263        let port = match after.strip_prefix(':') {
264            Some(p) => p.parse().ok()?,
265            None if after.is_empty() => default_port,
266            None => return None,
267        };
268        return Some((host.to_string(), port));
269    }
270    match authority.rsplit_once(':') {
271        // Host part still holds a ':' → an unbracketed IPv6 literal ("::1"); take
272        // the whole authority as the host at the default port (fail-safe: a weird
273        // string just misses the exact-name allow-list).
274        Some((h, _)) if h.contains(':') => Some((authority.to_string(), default_port)),
275        // ":443" — no host.
276        Some(("", _)) => None,
277        // A single ':' → host:port; a non-numeric port is malformed (reject).
278        Some((h, p)) => Some((h.to_string(), p.parse::<u16>().ok()?)),
279        // No ':' → a bare host at the default port.
280        None => Some((authority.to_string(), default_port)),
281    }
282}
283
284/// The exact-hostname allow-list, mirroring `ToolContext::check_net`'s membership.
285#[derive(Clone)]
286struct HostPolicy {
287    allowed: Arc<HashSet<String>>,
288    private: Arc<HashSet<String>>,
289}
290
291impl HostPolicy {
292    fn new(hosts: impl IntoIterator<Item = String>) -> Self {
293        Self {
294            allowed: Arc::new(hosts.into_iter().collect()),
295            private: Arc::new(HashSet::new()),
296        }
297    }
298    fn allows(&self, host: &str) -> bool {
299        self.allowed.contains(host)
300    }
301    fn with_private_hosts(
302        hosts: impl IntoIterator<Item = String>,
303        private_hosts: impl IntoIterator<Item = String>,
304    ) -> io::Result<Self> {
305        let mut policy = Self::new(hosts);
306        policy.private = Arc::new(canonical_private_hosts(private_hosts)?);
307        Ok(policy)
308    }
309    fn allows_private(&self, host: &str) -> bool {
310        self.allows(host)
311            && canonical_private_host(host).is_some_and(|host| self.private.contains(&host))
312    }
313}
314
315// Exact names only: no wildcard, URL, port, path, or ambiguous numeric address.
316// ASCII DNS labels include ACE/punycode; embedders must IDNA-normalize Unicode.
317fn canonical_private_host(host: &str) -> Option<String> {
318    use std::net::IpAddr;
319    if let Ok(ip) = host.parse::<IpAddr>() {
320        return Some(ip.to_string());
321    }
322    if let Some(ip) = host.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
323        return ip
324            .parse::<std::net::Ipv6Addr>()
325            .ok()
326            .map(|ip| ip.to_string());
327    }
328    let host = host.strip_suffix('.').unwrap_or(host);
329    if host.is_empty() || host.len() > 253 || !host.is_ascii() {
330        return None;
331    }
332    let labels: Vec<_> = host.split('.').collect();
333    if labels.iter().any(|label| {
334        label.is_empty()
335            || label.len() > 63
336            || label.starts_with('-')
337            || label.ends_with('-')
338            || !label
339                .bytes()
340                .all(|b| b.is_ascii_alphanumeric() || b == b'-')
341    }) || labels.last().is_some_and(|label| {
342        label.bytes().all(|b| b.is_ascii_digit())
343            || label.starts_with("0x")
344            || label.starts_with("0X")
345    }) {
346        return None;
347    }
348    Some(host.to_ascii_lowercase())
349}
350
351/// Validate and canonicalize exact private-host approvals from an owning
352/// harness. This does not grant ordinary network authority or permit an address;
353/// the proxy still intersects the host scope and screens each resolved address.
354pub fn canonical_private_hosts(
355    hosts: impl IntoIterator<Item = String>,
356) -> io::Result<HashSet<String>> {
357    hosts
358        .into_iter()
359        .map(|host| {
360            canonical_private_host(&host).ok_or_else(|| {
361                io::Error::new(
362                    io::ErrorKind::InvalidInput,
363                    "private-host grants require exact DNS names or IP addresses",
364                )
365            })
366        })
367        .collect()
368}
369
370/// A running loopback egress proxy. Dropping the handle shuts it down.
371#[derive(Debug)]
372pub struct ProxyHandle {
373    addr: SocketAddr,
374    shutdown: Arc<AtomicBool>,
375    accept: Option<JoinHandle<()>>,
376    /// #196: out-of-allow-list hosts the child tried to reach — refused with 403.
377    /// Accumulated across all connections (independent of the opt-in audit sink)
378    /// so the shell tool can surface them as structured `net` denials.
379    refused: Arc<Mutex<HashSet<String>>>,
380}
381
382impl ProxyHandle {
383    /// The loopback address the proxy listens on. A spawned child is wired via
384    /// [`Self::proxy_env`]; a **no-subprocess** caller (#257 — e.g. a
385    /// `reqwest::Client`) points itself here instead:
386    /// `reqwest::Proxy::all(format!("http://{}", handle.addr()))`.
387    pub fn addr(&self) -> SocketAddr {
388        self.addr
389    }
390
391    /// #196: the CONNECT hosts this proxy REFUSED (not on the allow-list),
392    /// deduplicated and sorted. The shell tool reads this after the child is
393    /// reaped and turns each into a `Denial { kind: Net, target: host }` so a
394    /// consumer can prompt per-host. Empty when the child only reached
395    /// allow-listed hosts (or none).
396    #[must_use]
397    pub fn refused_hosts(&self) -> Vec<String> {
398        self.refused
399            .lock()
400            .map(|s| {
401                let mut v: Vec<String> = s.iter().cloned().collect();
402                v.sort();
403                v
404            })
405            .unwrap_or_default()
406    }
407
408    /// The `*_PROXY` environment the child needs to route through this proxy.
409    /// Both cases are set: curl honours lowercase `http_proxy` (it ignores the
410    /// uppercase form for CGI-safety) but uppercase `HTTPS_PROXY`/`ALL_PROXY`;
411    /// other tools (wget, requests, node) read the rest.
412    #[must_use]
413    pub fn proxy_env(&self) -> Vec<(String, String)> {
414        let url = format!("http://{}", self.addr);
415        [
416            "http_proxy",
417            "https_proxy",
418            "all_proxy",
419            "HTTP_PROXY",
420            "HTTPS_PROXY",
421            "ALL_PROXY",
422        ]
423        .iter()
424        .map(|k| ((*k).to_string(), url.clone()))
425        .collect()
426    }
427}
428
429impl Drop for ProxyHandle {
430    fn drop(&mut self) {
431        self.shutdown.store(true, Ordering::SeqCst);
432        // Wake the blocking `accept()` so the loop observes the flag and exits.
433        let _ = TcpStream::connect_timeout(&self.addr, Duration::from_millis(200));
434        if let Some(h) = self.accept.take() {
435            let _ = h.join();
436        }
437    }
438}
439
440/// Start a loopback forward proxy that admits only `allow_hosts`, resolving via
441/// `resolver` and auditing every connection through `sink` ([`NullSink`] for no
442/// audit). Binds `127.0.0.1:0` (an ephemeral port — concurrent runs never
443/// collide) and serves until the returned [`ProxyHandle`] is dropped.
444///
445/// Fail-closed: an error binding the listener is returned so the caller refuses
446/// the run rather than spawning an unfenced child.
447pub fn start(
448    allow_hosts: impl IntoIterator<Item = String>,
449    resolver: Arc<dyn Resolver>,
450    sink: Arc<dyn AuditSink>,
451) -> io::Result<ProxyHandle> {
452    start_with_private_hosts(allow_hosts, [], resolver, sink)
453}
454
455/// Start the same fenced egress proxy with an additional, exact private-host
456/// approval supplied by the owning harness. Both the ordinary host allow-list
457/// and this independent approval must permit the requested name. Only RFC1918
458/// and IPv6 unique-local destinations may be opted in; metadata/link-local,
459/// unspecified, multicast, and other reserved ranges remain blocked.
460///
461/// The default constructors pass an empty set. This is transient host policy,
462/// not a new caveat or a substitute for the child's kernel loopback fence.
463/// Malformed names, URLs, ports and wildcard patterns fail before binding.
464pub fn start_with_private_hosts(
465    allow_hosts: impl IntoIterator<Item = String>,
466    private_hosts: impl IntoIterator<Item = String>,
467    resolver: Arc<dyn Resolver>,
468    sink: Arc<dyn AuditSink>,
469) -> io::Result<ProxyHandle> {
470    let policy = HostPolicy::with_private_hosts(allow_hosts, private_hosts)?;
471    let listener = TcpListener::bind(("127.0.0.1", 0))?;
472    let addr = listener.local_addr()?;
473    let shutdown = Arc::new(AtomicBool::new(false));
474    // The production origin dialer — a real TCP connect. Tests bypass `start`
475    // entirely and drive `handle_conn` with a scripted in-memory connector.
476    let connector: Arc<dyn Connector> = Arc::new(TcpConnector);
477    // #196: shared refused-host accumulator, populated by each connection thread.
478    let refused: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
479
480    let accept = {
481        let shutdown = Arc::clone(&shutdown);
482        let refused = Arc::clone(&refused);
483        thread::Builder::new()
484            .name("agent-bridle-egress-proxy".to_string())
485            .spawn(move || {
486                for stream in listener.incoming() {
487                    if shutdown.load(Ordering::SeqCst) {
488                        break;
489                    }
490                    let Ok(client) = stream else { continue };
491                    let policy = policy.clone();
492                    let resolver = Arc::clone(&resolver);
493                    let connector = Arc::clone(&connector);
494                    let sink = Arc::clone(&sink);
495                    let refused = Arc::clone(&refused);
496                    // One detached thread per connection; it ends at EOF.
497                    let _ = thread::Builder::new()
498                        .name("agent-bridle-egress-conn".to_string())
499                        .spawn(move || {
500                            let _ = handle_conn(
501                                Box::new(client),
502                                &policy,
503                                connector.as_ref(),
504                                resolver.as_ref(),
505                                sink.as_ref(),
506                                &refused,
507                            );
508                        });
509                }
510            })?
511    };
512
513    Ok(ProxyHandle {
514        addr,
515        shutdown,
516        accept: Some(accept),
517        refused,
518    })
519}
520
521/// Start the egress proxy for a **general remote-host** `net` grant (#257) — the
522/// caveats-level convenience over [`start`], for an external caller.
523///
524/// `Ok(None)` when `caveats` does not call for a proxy
525/// ([`crate::net_egress_proxy_hosts`] is `None`): `net: All` needs no fence,
526/// and deny-all / loopback-only stay with their kernel owners. `Ok(Some(handle))`
527/// = the proxy is live on `handle.addr()`, admitting exactly the granted hosts.
528/// `Err` = the grant calls for a proxy but the loopback listener could not bind —
529/// the caller must fail closed, never proceed unfenced.
530///
531/// Two consumption shapes:
532/// - **Spawned child**: pair with [`crate::loopback_fenced_caveats`] (the kernel
533///   fence) and wire the child via [`ProxyHandle::proxy_env`] — what
534///   `ConfinedCommand::spawn_tokio` does automatically under this grant.
535/// - **No subprocess** (an in-process `reqwest::Client`): route the client at
536///   `http://{handle.addr()}` (`reqwest::Proxy::all`). No kernel fence applies —
537///   the caller is pointing *itself* at the proxy, so the per-host allow-list is
538///   exactly as strong as the caller's routing (advisory grade, honestly).
539///
540/// Audit is off ([`NullSink`]); an embedder that wants the audit trail or a
541/// custom resolver uses [`start`] directly.
542pub fn start_egress_proxy(caveats: &crate::Caveats) -> io::Result<Option<ProxyHandle>> {
543    let Some(hosts) = crate::net_egress_proxy_hosts(caveats) else {
544        return Ok(None);
545    };
546    start_for_hosts(hosts).map(Some)
547}
548
549/// [`start`] with the production defaults (platform resolver, audit off) — the
550/// shared entry both [`start_egress_proxy`] and `ConfinedCommand::spawn_tokio`'s
551/// egress wiring (#257) call, so "start the proxy for these hosts" has exactly
552/// one production spelling.
553pub fn start_for_hosts(allow_hosts: impl IntoIterator<Item = String>) -> io::Result<ProxyHandle> {
554    start(allow_hosts, Arc::new(StdResolver), Arc::new(NullSink))
555}
556
557/// Serve one client connection: parse its request line, enforce the allow-list,
558/// and either tunnel (`CONNECT`) or forward (`http://`) to the resolved origin.
559/// Every connection with a parsed host is recorded through `sink`.
560fn handle_conn(
561    client: Box<dyn Conn>,
562    policy: &HostPolicy,
563    connector: &dyn Connector,
564    resolver: &dyn Resolver,
565    sink: &dyn AuditSink,
566    refused: &Mutex<HashSet<String>>,
567) -> io::Result<()> {
568    client.set_timeouts(CONN_TIMEOUT)?;
569    let mut reader = BufReader::new(client.dup()?);
570    let t0 = Instant::now();
571
572    let line = read_line_bounded(&mut reader)?;
573    let Some(target) = parse_request_line(&line) else {
574        // No host to attribute — a malformed request is not an egress event.
575        return respond(client.as_ref(), 400, "Bad Request");
576    };
577
578    let (host, port, kind) = match &target {
579        Target::Connect { host, port } => (host.clone(), *port, NetKind::Connect),
580        Target::Http { host, port, .. } => (host.clone(), *port, NetKind::Http),
581    };
582    // Emit the audit record once, whatever the outcome.
583    let audit = |decision: NetDecision, up: u64, down: u64| {
584        sink.record(&NetAuditEvent {
585            ts_ms: now_ms(),
586            host: host.clone(),
587            port,
588            kind,
589            decision,
590            bytes_up: up,
591            bytes_down: down,
592            dur_ms: t0.elapsed().as_millis() as u64,
593        });
594    };
595
596    if !policy.allows(&host) {
597        audit(NetDecision::Denied, 0, 0); // the exfil-attempt signal
598                                          // #196: record the refused host so the shell tool can surface it as a
599                                          // structured `net` denial (the audit sink is opt-in; this is always on).
600        if let Ok(mut set) = refused.lock() {
601            set.insert(host.clone());
602        }
603        return respond(client.as_ref(), 403, "Forbidden");
604    }
605
606    match target {
607        Target::Connect { host, port } => {
608            // CONNECT: drain the remaining request headers (up to the blank line)
609            // before the tunnel begins — the client waits for our 200 first.
610            drain_headers(&mut reader)?;
611            let origin = match resolver
612                .resolve(&host, port)
613                .and_then(|addr| guard_target(addr, &host, policy))
614                .and_then(|addr| connector.connect(addr))
615            {
616                Ok(o) => o,
617                Err(_) => {
618                    audit(NetDecision::Error, 0, 0);
619                    return respond(client.as_ref(), 502, "Bad Gateway");
620                }
621            };
622            // A CONNECT success is a *bare* status line — no body, no
623            // `Content-Length` — after which the socket is an opaque tunnel. (Do
624            // NOT use `respond`, which appends a body that would corrupt it.)
625            let mut client = client;
626            client.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")?;
627            // Forward via `splice_buffered` (not a raw `tunnel`) so any bytes the
628            // client already pipelined past the CONNECT header block — buffered in
629            // `reader` — reach the origin (a TLS ClientHello sent with the CONNECT;
630            // #138). The up-copy reads the BufReader, which drains its buffer first.
631            let (up, down) = splice_buffered(reader, client, origin)?;
632            audit(NetDecision::Allowed, up, down);
633            Ok(())
634        }
635        Target::Http {
636            host,
637            port,
638            origin_line,
639        } => {
640            // Read the client's request headers and **drop any client-supplied
641            // Host** — an attacker could send `Host: disallowed.com` to domain-front
642            // off a shared/CDN origin at the allow-listed IP. Substitute the proxy's
643            // own Host, derived from the *validated* authority, so the origin only
644            // ever sees the host the allow-list approved.
645            let headers = read_headers(&mut reader)?;
646            let mut origin = match resolver
647                .resolve(&host, port)
648                .and_then(|addr| guard_target(addr, &host, policy))
649                .and_then(|addr| connector.connect(addr))
650            {
651                Ok(o) => o,
652                Err(_) => {
653                    audit(NetDecision::Error, 0, 0);
654                    return respond(client.as_ref(), 502, "Bad Gateway");
655                }
656            };
657            let host_hdr = if port == 80 {
658                format!("Host: {host}\r\n")
659            } else {
660                format!("Host: {host}:{port}\r\n")
661            };
662            origin.write_all(origin_line.as_bytes())?;
663            origin.write_all(host_hdr.as_bytes())?;
664            for h in &headers {
665                if !h.get(..5).is_some_and(|p| p.eq_ignore_ascii_case("host:")) {
666                    origin.write_all(h.as_bytes())?;
667                }
668            }
669            origin.write_all(b"\r\n")?; // end of the (rewritten) header block
670            let (up, down) = splice_buffered(reader, client, origin)?; // forward the body
671            audit(NetDecision::Allowed, up, down);
672            Ok(())
673        }
674    }
675}
676
677/// Read one CRLF-terminated line, bounded to [`MAX_HEAD`]. Generic over the
678/// buffered reader's source so the same logic drives a real socket or an
679/// in-memory test stream.
680fn read_line_bounded<R: Read>(reader: &mut BufReader<R>) -> io::Result<String> {
681    let mut buf = Vec::new();
682    reader.take(MAX_HEAD as u64).read_until(b'\n', &mut buf)?;
683    Ok(String::from_utf8_lossy(&buf).into_owned())
684}
685
686/// Read request header lines up to (not including) the terminating blank line,
687/// bounded by [`MAX_HEAD`]. Each returned line keeps its trailing CRLF.
688fn read_headers<R: Read>(reader: &mut BufReader<R>) -> io::Result<Vec<String>> {
689    let mut lines = Vec::new();
690    let mut total = 0usize;
691    loop {
692        let line = read_line_bounded(reader)?;
693        total += line.len();
694        if line == "\r\n" || line == "\n" || line.is_empty() || total > MAX_HEAD {
695            return Ok(lines);
696        }
697        lines.push(line);
698    }
699}
700
701/// Consume request headers up to and including the terminating blank line.
702fn drain_headers<R: Read>(reader: &mut BufReader<R>) -> io::Result<()> {
703    let mut total = 0usize;
704    loop {
705        let line = read_line_bounded(reader)?;
706        total += line.len();
707        if line == "\r\n" || line == "\n" || line.is_empty() || total > MAX_HEAD {
708            return Ok(());
709        }
710    }
711}
712
713/// Write a minimal HTTP/1.1 status response and close.
714fn respond(client: &dyn Conn, code: u16, reason: &str) -> io::Result<()> {
715    let mut c = client.dup()?;
716    let body = format!("{code} {reason}\n");
717    write!(
718        c,
719        "HTTP/1.1 {code} {reason}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
720        body.len()
721    )?;
722    let _ = c.flush();
723    let _ = client.shutdown(Shutdown::Both);
724    Ok(())
725}
726
727/// Copy `from` → `to`, returning the bytes **forwarded** — preserved even if a
728/// stream errors mid-copy (a reset after data still counts what flowed), so the
729/// audit totals are not silently zeroed by an abrupt close (`std::io::copy`
730/// discards its count on error). Stops at EOF, a write failure, or a read error.
731fn copy_counted(from: &mut impl Read, to: &mut impl Write) -> u64 {
732    let mut buf = [0u8; 16 * 1024];
733    let mut total = 0u64;
734    loop {
735        match from.read(&mut buf) {
736            Ok(0) => break,
737            Ok(n) => {
738                if to.write_all(&buf[..n]).is_err() {
739                    break;
740                }
741                total += n as u64;
742            }
743            Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
744            Err(_) => break,
745        }
746    }
747    total
748}
749
750/// Bidirectional raw byte tunnel between `client` and `origin` (the `CONNECT`
751/// case): two copy threads, each shutting its write half at EOF. Returns
752/// `(bytes_up, bytes_down)` — child→origin and origin→child — for the audit.
753/// SSRF-pivot guard (#138): refuse to dial a resolved origin whose IP is on an
754/// **internal** range — RFC1918 private, `169.254/16` link-local (incl. the cloud
755/// metadata endpoint `169.254.169.254`), `100.64/10` CGNAT, and IPv6 ULA/link-local.
756/// The egress proxy fronts a *loopback-fenced* child; without this an allow-listed
757/// name that resolves (or is rebound) to an internal address would give that child
758/// a parent-mediated path to endpoints its own kernel fence forbids. Loopback is
759/// **allowed** (the fenced child can already reach loopback directly, and the test
760/// origins live there) and global addresses are allowed.
761///
762/// Default-on: only a separately approved exact host may resolve to RFC1918 or
763/// ULA space. Returns the address unchanged when permitted, else PermissionDenied.
764fn guard_target(addr: SocketAddr, host: &str, policy: &HostPolicy) -> io::Result<SocketAddr> {
765    let ip = match addr.ip() {
766        std::net::IpAddr::V6(ip) => ip.to_ipv4_mapped().map_or(addr.ip(), std::net::IpAddr::V4),
767        ip => ip,
768    };
769    let approvable = match ip {
770        std::net::IpAddr::V4(ip) => ip.is_private(),
771        std::net::IpAddr::V6(ip) => {
772            (ip.segments()[0] & 0xfe00) == 0xfc00
773                // EC2's IPv6 metadata endpoint lies inside ULA, not link-local.
774                && ip.segments() != [0xfd00, 0x0ec2, 0, 0, 0, 0, 0, 0x0254]
775        }
776    };
777    if is_internal_ip(&ip) && !(approvable && policy.allows_private(host)) {
778        return Err(io::Error::new(
779            io::ErrorKind::PermissionDenied,
780            "SSRF-guard: refusing to proxy to an internal (non-loopback) address",
781        ));
782    }
783    Ok(addr)
784}
785
786/// `true` if `ip` is on a private/internal range the egress proxy must not pivot
787/// to (see [`guard_target`]). Loopback and global addresses return `false`.
788fn is_internal_ip(ip: &std::net::IpAddr) -> bool {
789    match ip {
790        std::net::IpAddr::V4(v4) => {
791            let o = v4.octets();
792            v4.is_private()
793                || v4.is_link_local()
794                || v4.is_unspecified()
795                || v4.is_broadcast()
796                || v4.is_multicast()
797                || v4.is_documentation()
798                || o[0] == 0 || o[0] >= 240
799                || (o[0] == 198 && (o[1] == 18 || o[1] == 19))
800                // 100.64.0.0/10 (CGNAT) — not covered by the std predicates.
801                || (o[0] == 100 && (64..=127).contains(&o[1]))
802        }
803        std::net::IpAddr::V6(v6) => {
804            if let Some(v4) = v6.to_ipv4_mapped() {
805                return is_internal_ip(&std::net::IpAddr::V4(v4));
806            }
807            let seg0 = v6.segments()[0];
808            v6.is_unspecified()
809                || v6.is_multicast()
810                || (seg0 & 0xfe00) == 0xfc00 // fc00::/7 unique-local
811                || (seg0 & 0xffc0) == 0xfe80 // fe80::/10 link-local
812                || (seg0 & 0xffc0) == 0xfec0 // deprecated site-local
813                || (seg0 == 0x2001 && v6.segments()[1] == 0x0db8) // documentation
814        }
815    }
816}
817
818/// Like [`tunnel`] but the client side is a `BufReader` that may already hold
819/// buffered bytes (the `http://` forward case, after the request line was read).
820/// Returns `(bytes_up, bytes_down)` for the audit.
821fn splice_buffered(
822    mut client_reader: BufReader<Box<dyn Conn>>,
823    client: Box<dyn Conn>,
824    origin: Box<dyn Conn>,
825) -> io::Result<(u64, u64)> {
826    let mut o_write = origin.dup()?;
827    let up = thread::spawn(move || {
828        let n = copy_counted(&mut client_reader, &mut o_write);
829        let _ = o_write.shutdown(Shutdown::Write);
830        n
831    });
832    let mut o_read = origin;
833    let mut c_write = client;
834    let down = copy_counted(&mut o_read, &mut c_write);
835    // The origin side has closed, so tear the whole connection down: shut down
836    // BOTH client halves, not just write. `Write` alone leaves the `up` thread
837    // blocked reading the client's (half-open) upload direction until CONN_TIMEOUT
838    // — a plain client that sends its request then only reads the response never
839    // closes its write half until it sees our EOF, and we never see its EOF: a
840    // ~30s deadlock on every forward/tunnel (surfaced by the flaky `audit_records_*`
841    // test, since the audit fires only once `up` joins). `Both` gives `up`'s read
842    // on the shared socket an immediate EOF.
843    let _ = c_write.shutdown(Shutdown::Both);
844    let up = up.join().unwrap_or(0);
845    Ok((up, down))
846}
847
848#[cfg(test)]
849mod tests {
850    use super::*;
851    use std::collections::VecDeque;
852
853    // ── #257: the caveats-level public entry ────────────────────────────────
854
855    /// `start_egress_proxy` starts NOTHING for the three grants that keep their
856    /// kernel owners: `net: All` (nothing to fence), deny-all (kernel-denied),
857    /// loopback-only (the ADR 0015 fence alone suffices) — criterion 3.
858    #[test]
859    fn start_egress_proxy_is_none_for_non_proxy_grants() {
860        use crate::{Caveats, Scope};
861        let all = Caveats::top();
862        assert!(start_egress_proxy(&all).unwrap().is_none(), "net: All");
863        let deny = Caveats {
864            net: Scope::only([] as [String; 0]),
865            ..Caveats::top()
866        };
867        assert!(start_egress_proxy(&deny).unwrap().is_none(), "deny-all");
868        let loopback = Caveats {
869            net: Scope::only(["localhost".to_string()]),
870            ..Caveats::top()
871        };
872        assert!(
873            start_egress_proxy(&loopback).unwrap().is_none(),
874            "loopback-only"
875        );
876    }
877
878    /// A general remote-host grant starts a live proxy on loopback; a raw
879    /// CONNECT for a non-granted host is refused 403 and recorded in
880    /// `refused_hosts()` — the no-subprocess (reqwest-style) consumption shape,
881    /// exercised with a plain std TcpStream speaking the proxy protocol.
882    #[test]
883    fn start_egress_proxy_serves_a_remote_host_grant_and_refuses_off_list() {
884        use crate::{Caveats, Scope};
885        use std::io::{Read as _, Write as _};
886        let granted = Caveats {
887            net: Scope::only(["api.example.com".to_string()]),
888            ..Caveats::top()
889        };
890        let handle = start_egress_proxy(&granted)
891            .expect("bind loopback")
892            .expect("a remote-host grant calls for the proxy");
893        assert!(
894            handle.addr().ip().is_loopback(),
895            "proxy binds loopback only"
896        );
897
898        let mut client = TcpStream::connect(handle.addr()).expect("dial proxy");
899        client
900            .write_all(
901                b"CONNECT evil.example.net:443 HTTP/1.1\r\nHost: evil.example.net:443\r\n\r\n",
902            )
903            .expect("send CONNECT");
904        // The proxy answers 403 and closes; read to EOF so a short first read
905        // can't truncate the status line.
906        let mut reply = String::new();
907        client
908            .set_read_timeout(Some(Duration::from_secs(10)))
909            .expect("timeout");
910        let _ = client.read_to_string(&mut reply);
911        assert!(
912            reply.contains("403"),
913            "off-list CONNECT must be refused: {reply:?}"
914        );
915        assert!(
916            handle
917                .refused_hosts()
918                .contains(&"evil.example.net".to_string()),
919            "refusal must be recorded: {:?}",
920            handle.refused_hosts()
921        );
922    }
923
924    /// #138 (SSRF pivot): the proxy must refuse to dial an allow-listed name that
925    /// resolves to an internal address (RFC1918 / link-local incl. cloud metadata /
926    /// CGNAT / IPv6 ULA+link-local), while permitting loopback (the fenced child can
927    /// reach it directly + the test origins live there) and global addresses.
928    #[test]
929    fn guard_target_refuses_internal_permits_loopback_and_global() {
930        use std::net::{IpAddr, Ipv4Addr, SocketAddr};
931        // Built from octets (not dotted-string literals) so the internal-specifics
932        // linter doesn't flag the RFC1918/CGNAT probe addresses (docs/PRIVACY.md).
933        let refused: [IpAddr; 7] = [
934            Ipv4Addr::new(10, 0, 0, 5).into(),        // RFC1918
935            Ipv4Addr::new(172, 16, 9, 9).into(),      // RFC1918
936            Ipv4Addr::new(192, 168, 1, 1).into(),     // RFC1918
937            Ipv4Addr::new(169, 254, 169, 254).into(), // link-local: cloud metadata
938            Ipv4Addr::new(100, 64, 0, 1).into(),      // CGNAT
939            "fe80::1".parse().unwrap(),               // v6 link-local
940            "fc00::1".parse().unwrap(),               // v6 unique-local
941        ];
942        for ip in refused {
943            assert!(is_internal_ip(&ip), "{ip} must classify as internal");
944            assert!(
945                guard_target(SocketAddr::new(ip, 80), "", &HostPolicy::new([])).is_err(),
946                "{ip} must be refused"
947            );
948        }
949        let allowed = [
950            "127.0.0.1",
951            "::1",
952            "8.8.8.8",
953            "1.1.1.1",
954            "2606:4700:4700::1111",
955        ];
956        for s in allowed {
957            let ip: IpAddr = s.parse().unwrap();
958            assert!(!is_internal_ip(&ip), "{s} must be permitted");
959            assert!(
960                guard_target(SocketAddr::new(ip, 443), "", &HostPolicy::new([])).is_ok(),
961                "{s} must be permitted"
962            );
963        }
964    }
965
966    #[test]
967    fn parses_connect() {
968        assert_eq!(
969            parse_request_line("CONNECT example.com:443 HTTP/1.1\r\n"),
970            Some(Target::Connect {
971                host: "example.com".to_string(),
972                port: 443
973            })
974        );
975        // Default port when omitted.
976        assert_eq!(
977            parse_request_line("CONNECT example.com HTTP/1.1"),
978            Some(Target::Connect {
979                host: "example.com".to_string(),
980                port: 443
981            })
982        );
983    }
984
985    #[test]
986    fn parses_http_absolute_form_and_rewrites_to_origin_form() {
987        let t = parse_request_line("GET http://example.com/a/b?q=1 HTTP/1.1\r\n").unwrap();
988        assert_eq!(
989            t,
990            Target::Http {
991                host: "example.com".to_string(),
992                port: 80,
993                origin_line: "GET /a/b?q=1 HTTP/1.1\r\n".to_string(),
994            }
995        );
996        // No path → "/". Explicit port honoured.
997        let t = parse_request_line("HEAD http://h:8080 HTTP/1.0").unwrap();
998        assert_eq!(
999            t,
1000            Target::Http {
1001                host: "h".to_string(),
1002                port: 8080,
1003                origin_line: "HEAD / HTTP/1.0\r\n".to_string(),
1004            }
1005        );
1006    }
1007
1008    #[test]
1009    fn parses_ipv6_authority() {
1010        assert_eq!(
1011            parse_request_line("CONNECT [::1]:8443 HTTP/1.1"),
1012            Some(Target::Connect {
1013                host: "::1".to_string(),
1014                port: 8443
1015            })
1016        );
1017    }
1018
1019    #[test]
1020    fn rejects_malformed_and_unspoken_schemes() {
1021        assert!(parse_request_line("GET / HTTP/1.1").is_none()); // origin-form, not a proxy req
1022        assert!(parse_request_line("GET https://x/ HTTP/1.1").is_none()); // https absolute-form
1023        assert!(parse_request_line("GET ftp://x/ HTTP/1.1").is_none());
1024        assert!(parse_request_line("garbage").is_none());
1025        assert!(parse_request_line("CONNECT x:notaport HTTP/1.1").is_none());
1026    }
1027
1028    /// A resolver that maps every name to a fixed loopback origin — hermetic.
1029    struct FixedResolver(SocketAddr);
1030    impl Resolver for FixedResolver {
1031        fn resolve(&self, _host: &str, _port: u16) -> io::Result<SocketAddr> {
1032            Ok(self.0)
1033        }
1034    }
1035
1036    /// Start the proxy with no audit sink (most tests don't inspect the audit).
1037    fn start_null(
1038        hosts: impl IntoIterator<Item = String>,
1039        resolver: Arc<dyn Resolver>,
1040    ) -> io::Result<ProxyHandle> {
1041        start(hosts, resolver, Arc::new(NullSink))
1042    }
1043
1044    /// An audit sink that collects every event into a shared vec, for assertions.
1045    #[derive(Clone, Default)]
1046    struct CapturingSink(Arc<Mutex<Vec<NetAuditEvent>>>);
1047    impl AuditSink for CapturingSink {
1048        fn record(&self, event: &NetAuditEvent) {
1049            self.0.lock().unwrap().push(event.clone());
1050        }
1051    }
1052    impl CapturingSink {
1053        fn events(&self) -> Vec<NetAuditEvent> {
1054            self.0.lock().unwrap().clone()
1055        }
1056    }
1057
1058    // ── In-memory connection fakes (#166) ───────────────────────────────────
1059    //
1060    // The proxy's forward/tunnel/allow-list/audit LOGIC is driven through the
1061    // real `handle_conn` against these scripted endpoints — no sockets, no
1062    // accept loops, no timing races. `handle_conn` joins its splice thread
1063    // before returning, so every assertion below is fully synchronous and
1064    // deterministic (the old socket tests polled/slept up to 30s and still
1065    // flaked on constrained runners; #135/#155/#165/#166).
1066
1067    /// A scripted in-memory [`Conn`]: `read` drains a preset script then returns
1068    /// EOF (never blocks); `write` captures bytes for assertions; `dup` shares
1069    /// both (as `TcpStream::try_clone` shares one socket). `shutdown`/timeouts
1070    /// are no-ops — the fake EOFs on script exhaustion, so nothing can block.
1071    #[derive(Clone, Default)]
1072    struct ScriptedConn {
1073        /// Bytes the proxy reads FROM this endpoint (the client's request, or the
1074        /// origin's canned response). Drained by `read`; empty ⇒ EOF.
1075        to_read: Arc<Mutex<VecDeque<u8>>>,
1076        /// Bytes the proxy WROTE to this endpoint (its client response, or the
1077        /// request it forwarded to the origin). Captured for assertions.
1078        written: Arc<Mutex<Vec<u8>>>,
1079    }
1080
1081    impl ScriptedConn {
1082        fn with_script(bytes: &[u8]) -> Self {
1083            Self {
1084                to_read: Arc::new(Mutex::new(bytes.iter().copied().collect())),
1085                written: Arc::new(Mutex::new(Vec::new())),
1086            }
1087        }
1088        /// The bytes the proxy wrote to this endpoint.
1089        fn written(&self) -> Vec<u8> {
1090            self.written.lock().unwrap().clone()
1091        }
1092        /// The bytes the proxy wrote, as a lossy string (for readable asserts).
1093        fn written_str(&self) -> String {
1094            String::from_utf8_lossy(&self.written()).into_owned()
1095        }
1096    }
1097
1098    impl Read for ScriptedConn {
1099        fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
1100            let mut q = self.to_read.lock().unwrap();
1101            let n = buf.len().min(q.len());
1102            for slot in buf.iter_mut().take(n) {
1103                *slot = q.pop_front().unwrap();
1104            }
1105            Ok(n) // n == 0 ⇒ script exhausted ⇒ EOF (never blocks)
1106        }
1107    }
1108
1109    impl Write for ScriptedConn {
1110        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1111            self.written.lock().unwrap().extend_from_slice(buf);
1112            Ok(buf.len())
1113        }
1114        fn flush(&mut self) -> io::Result<()> {
1115            Ok(())
1116        }
1117    }
1118
1119    impl Conn for ScriptedConn {
1120        fn dup(&self) -> io::Result<Box<dyn Conn>> {
1121            Ok(Box::new(self.clone()))
1122        }
1123        fn shutdown(&self, _how: Shutdown) -> io::Result<()> {
1124            Ok(())
1125        }
1126        fn set_timeouts(&self, _dur: Duration) -> io::Result<()> {
1127            Ok(())
1128        }
1129    }
1130
1131    /// A connector that hands back a scripted in-memory origin (no real dial).
1132    struct FakeConnector(ScriptedConn);
1133    impl Connector for FakeConnector {
1134        fn connect(&self, _addr: SocketAddr) -> io::Result<Box<dyn Conn>> {
1135            Ok(Box::new(self.0.clone()))
1136        }
1137    }
1138
1139    /// A connector whose dial always fails — for the `502 Bad Gateway` path.
1140    struct FailingConnector;
1141    impl Connector for FailingConnector {
1142        fn connect(&self, _addr: SocketAddr) -> io::Result<Box<dyn Conn>> {
1143            Err(io::Error::new(
1144                io::ErrorKind::ConnectionRefused,
1145                "origin unreachable",
1146            ))
1147        }
1148    }
1149
1150    /// The outcome of driving one connection through the real `handle_conn`.
1151    struct Driven {
1152        /// What the proxy sent back to the client (response / forwarded origin bytes).
1153        client: ScriptedConn,
1154        /// What the proxy forwarded to the origin (empty if it was never dialled).
1155        origin: ScriptedConn,
1156        /// Hosts refused with 403 (deduped, sorted).
1157        refused: Vec<String>,
1158        /// Audit events emitted (synchronously, before `handle_conn` returned).
1159        audit: Vec<NetAuditEvent>,
1160    }
1161
1162    /// Drive one client `request` through `handle_conn` with `connector` (used
1163    /// for the deny / malformed / 502 paths, where the origin is never
1164    /// successfully forwarded to — `out.origin` stays empty). Deterministic:
1165    /// `handle_conn` joins its splice thread before returning, so all buffers +
1166    /// the audit are fully populated on return. For the ALLOWED forward path
1167    /// (where you want to inspect the forwarded bytes) use [`drive_forward`].
1168    fn drive_with(request: &[u8], allow: &[&str], connector: &dyn Connector) -> Driven {
1169        let client = ScriptedConn::with_script(request);
1170        let policy = HostPolicy::new(allow.iter().map(|s| s.to_string()));
1171        let resolver = FixedResolver("127.0.0.1:9".parse().unwrap());
1172        let sink = CapturingSink::default();
1173        let refused = Mutex::new(HashSet::new());
1174        let _ = handle_conn(
1175            Box::new(client.clone()),
1176            &policy,
1177            connector,
1178            &resolver,
1179            &sink,
1180            &refused,
1181        );
1182        let mut refused: Vec<String> = refused.into_inner().unwrap().into_iter().collect();
1183        refused.sort();
1184        Driven {
1185            client,
1186            origin: ScriptedConn::default(),
1187            refused,
1188            audit: sink.events(),
1189        }
1190    }
1191
1192    /// Convenience: drive an ALLOWED forward/tunnel with an explicit origin
1193    /// script, returning the `Driven` outcome (with the origin's captured bytes).
1194    fn drive_forward(request: &[u8], allow: &[&str], origin_script: &[u8]) -> Driven {
1195        let client = ScriptedConn::with_script(request);
1196        let origin = ScriptedConn::with_script(origin_script);
1197        let policy = HostPolicy::new(allow.iter().map(|s| s.to_string()));
1198        let resolver = FixedResolver("127.0.0.1:9".parse().unwrap());
1199        let sink = CapturingSink::default();
1200        let refused = Mutex::new(HashSet::new());
1201        let connector = FakeConnector(origin.clone());
1202        let _ = handle_conn(
1203            Box::new(client.clone()),
1204            &policy,
1205            &connector,
1206            &resolver,
1207            &sink,
1208            &refused,
1209        );
1210        let mut refused: Vec<String> = refused.into_inner().unwrap().into_iter().collect();
1211        refused.sort();
1212        Driven {
1213            client,
1214            origin,
1215            refused,
1216            audit: sink.events(),
1217        }
1218    }
1219
1220    #[test]
1221    fn allowed_http_host_is_forwarded_to_origin() {
1222        // The allow-listed host is forwarded; the origin's body reaches the client.
1223        let out = drive_forward(
1224            b"GET http://allowed.test/x HTTP/1.1\r\nHost: ignored\r\nConnection: close\r\n\r\n",
1225            &["allowed.test"],
1226            b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\norigin",
1227        );
1228        let client_saw = out.client.written_str();
1229        assert!(client_saw.contains("200"), "client saw: {client_saw}");
1230        assert!(
1231            client_saw.contains("origin"),
1232            "the origin's body must reach the client: {client_saw}"
1233        );
1234        // The request reached the origin (proof the forward actually happened).
1235        assert!(
1236            out.origin.written_str().starts_with("GET /x HTTP/1.1"),
1237            "origin must receive the origin-form request: {}",
1238            out.origin.written_str()
1239        );
1240        assert!(out.refused.is_empty(), "an allowed host is not refused");
1241    }
1242
1243    /// Grounds the exact-private-host policy through the production connection
1244    /// handler; only DNS and the destination socket are in-memory seams.
1245    #[test]
1246    fn exact_private_hosts_require_both_grants_and_pin_the_screened_address() {
1247        use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
1248        for request in [
1249            &b"CONNECT service.test:443 HTTP/1.1\r\n\r\nPING"[..],
1250            &b"GET http://service.test:443/x HTTP/1.1\r\n\r\n"[..],
1251        ] {
1252            // Synthetic address classes, following the existing guard fixtures.
1253            for ip in [
1254                IpAddr::V4(Ipv4Addr::new(10, 20, 30, 40)),
1255                IpAddr::V4(Ipv4Addr::new(172, 16, 10, 20)),
1256                IpAddr::V4(Ipv4Addr::new(192, 168, 50, 10)),
1257                IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0x1234)),
1258                IpAddr::V6(Ipv4Addr::new(10, 20, 30, 40).to_ipv6_mapped()),
1259            ] {
1260                let address = SocketAddr::new(ip, 443);
1261                for (allow, private, expected) in [
1262                    (vec!["service.test"], vec!["SERVICE.TEST."], 200),
1263                    (vec!["service.test"], vec![], 502),
1264                    (vec!["service.test"], vec!["other.test"], 502),
1265                    (vec!["other.test"], vec!["service.test"], 403),
1266                ] {
1267                    struct CountResolver(SocketAddr, std::sync::atomic::AtomicUsize);
1268                    impl Resolver for CountResolver {
1269                        fn resolve(&self, _: &str, _: u16) -> io::Result<SocketAddr> {
1270                            assert_eq!(self.1.fetch_add(1, Ordering::SeqCst), 0, "resolve once");
1271                            Ok(self.0)
1272                        }
1273                    }
1274                    struct PinnedConnector(SocketAddr, std::sync::atomic::AtomicUsize);
1275                    impl Connector for PinnedConnector {
1276                        fn connect(&self, address: SocketAddr) -> io::Result<Box<dyn Conn>> {
1277                            assert_eq!(address, self.0, "dial the screened address without DNS");
1278                            self.1.fetch_add(1, Ordering::SeqCst);
1279                            Ok(Box::new(ScriptedConn::with_script(
1280                                b"HTTP/1.1 200 OK\r\n\r\n",
1281                            )))
1282                        }
1283                    }
1284                    let policy = HostPolicy::with_private_hosts(
1285                        allow.into_iter().map(str::to_string),
1286                        private.into_iter().map(str::to_string),
1287                    )
1288                    .unwrap();
1289                    let resolver = CountResolver(address, 0.into());
1290                    let connector = PinnedConnector(address, 0.into());
1291                    let client = ScriptedConn::with_script(request);
1292                    handle_conn(
1293                        Box::new(client.clone()),
1294                        &policy,
1295                        &connector,
1296                        &resolver,
1297                        &NullSink,
1298                        &Mutex::new(HashSet::new()),
1299                    )
1300                    .unwrap();
1301                    assert!(
1302                        client
1303                            .written_str()
1304                            .starts_with(&format!("HTTP/1.1 {expected}")),
1305                        "address {address}, expected {expected}: {}",
1306                        client.written_str()
1307                    );
1308                    assert_eq!(
1309                        connector.1.load(Ordering::SeqCst),
1310                        usize::from(expected == 200)
1311                    );
1312                    assert_eq!(
1313                        resolver.1.load(Ordering::SeqCst),
1314                        usize::from(expected != 403)
1315                    );
1316                }
1317            }
1318        }
1319    }
1320
1321    #[test]
1322    fn exact_private_hosts_never_approve_forbidden_ranges() {
1323        use std::net::{IpAddr, Ipv4Addr};
1324        let policy = HostPolicy::with_private_hosts(
1325            ["service.test".to_string()],
1326            ["service.test".to_string()],
1327        )
1328        .unwrap();
1329        for ip in [
1330            "169.254.169.254",
1331            "fd00:ec2::254",
1332            "0.0.0.0",
1333            "0.1.2.3",
1334            "224.0.0.1",
1335            "255.255.255.255",
1336            "192.0.2.1",
1337            "198.18.0.1",
1338            "::",
1339            "fe80::1",
1340            "ff02::1",
1341            "::ffff:169.254.169.254",
1342            "::ffff:224.0.0.1",
1343        ]
1344        .into_iter()
1345        .map(|ip| ip.parse::<IpAddr>().unwrap())
1346        .chain([
1347            IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1)),
1348            IpAddr::V6(Ipv4Addr::new(100, 64, 0, 1).to_ipv6_mapped()),
1349        ]) {
1350            let resolver = FixedResolver(SocketAddr::new(ip, 443));
1351            for request in [
1352                &b"CONNECT service.test:443 HTTP/1.1\r\n\r\n"[..],
1353                &b"GET http://service.test/ HTTP/1.1\r\n\r\n"[..],
1354            ] {
1355                let client = ScriptedConn::with_script(request);
1356                let sink = CapturingSink::default();
1357                // A successful fake dial would return 200, so 502 proves it was screened out.
1358                handle_conn(
1359                    Box::new(client.clone()),
1360                    &policy,
1361                    &FakeConnector(ScriptedConn::with_script(b"HTTP/1.1 200 OK\r\n\r\n")),
1362                    &resolver,
1363                    &sink,
1364                    &Mutex::new(HashSet::new()),
1365                )
1366                .unwrap();
1367                assert!(
1368                    client.written_str().contains("502"),
1369                    "forbidden address {ip}"
1370                );
1371                assert_eq!(sink.events()[0].decision, NetDecision::Error);
1372            }
1373        }
1374    }
1375
1376    #[test]
1377    fn exact_private_hosts_reject_patterns_urls_and_ambiguous_names() {
1378        for invalid in [
1379            "",
1380            "*",
1381            "*.test",
1382            ".test",
1383            "https://service.test",
1384            "service.test:443",
1385            "user@service.test",
1386            "service.test/path",
1387            " service.test",
1388            "service.test ",
1389            "a..test",
1390            "-bad.test",
1391            "bad-.test",
1392            "127.1",
1393            "0x7f000001",
1394            "service.test?x",
1395        ] {
1396            assert!(
1397                HostPolicy::with_private_hosts(["service.test".to_string()], [invalid.to_string()])
1398                    .is_err(),
1399                "invalid exact name: {invalid}"
1400            );
1401        }
1402        let policy =
1403            HostPolicy::with_private_hosts(["fd00::1".to_string()], ["[FD00::1]".to_string()])
1404                .unwrap();
1405        assert!(policy.allows_private("fd00::1"));
1406    }
1407
1408    #[test]
1409    fn disallowed_http_host_is_refused_403_without_reaching_origin() {
1410        // A denied host must never be dialled: FailingConnector would surface as a
1411        // 502 if it were ever called, so a 403 here also proves it was NOT.
1412        let out = drive_with(
1413            b"GET http://evil.test/x HTTP/1.1\r\nHost: ignored\r\n\r\n",
1414            &["allowed.test"],
1415            &FailingConnector,
1416        );
1417        let client_saw = out.client.written_str();
1418        assert!(
1419            client_saw.contains("403"),
1420            "denied host must get 403: {client_saw}"
1421        );
1422        assert!(
1423            !client_saw.contains("502"),
1424            "the origin must not be dialled for a denied host: {client_saw}"
1425        );
1426        assert!(out.origin.written().is_empty(), "origin must see nothing");
1427    }
1428
1429    #[test]
1430    fn unreachable_allowed_origin_yields_502() {
1431        // Allow-listed, but the dial fails → 502 Bad Gateway + an `Error` audit.
1432        let out = drive_with(
1433            b"GET http://allowed.test/x HTTP/1.1\r\nHost: ignored\r\n\r\n",
1434            &["allowed.test"],
1435            &FailingConnector,
1436        );
1437        assert!(
1438            out.client.written_str().contains("502"),
1439            "an unreachable allowed origin must get 502: {}",
1440            out.client.written_str()
1441        );
1442        let ev = out.audit.iter().find(|e| e.host == "allowed.test").unwrap();
1443        assert_eq!(ev.decision, NetDecision::Error);
1444    }
1445
1446    #[test]
1447    fn malformed_request_yields_400_and_no_audit() {
1448        // A request line the proxy does not speak → 400, and (deliberately) NO
1449        // audit event, since there is no host to attribute an egress attempt to.
1450        let out = drive_with(
1451            b"GET / HTTP/1.1\r\n\r\n",
1452            &["allowed.test"],
1453            &FailingConnector,
1454        );
1455        assert!(out.client.written_str().contains("400"));
1456        assert!(
1457            out.audit.is_empty(),
1458            "a malformed request is not an egress event: {:?}",
1459            out.audit
1460        );
1461        assert!(out.refused.is_empty());
1462    }
1463
1464    /// #196: the proxy accumulates every host it REFUSES and surfaces them via
1465    /// `refused_hosts()` (deduped, sorted); allowed hosts are never listed.
1466    #[test]
1467    fn refused_hosts_surfaces_denied_hosts_deduped_and_omits_allowed() {
1468        // Drive three requests through ONE shared refused-set: allowed (forwarded),
1469        // then the same denied host twice (must dedupe to a single entry).
1470        let policy = HostPolicy::new(["allowed.test".to_string()]);
1471        let resolver = FixedResolver("127.0.0.1:9".parse().unwrap());
1472        let sink = NullSink;
1473        let refused = Mutex::new(HashSet::new());
1474        let origin = ScriptedConn::with_script(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
1475        let connector = FakeConnector(origin);
1476
1477        for (req, conn) in [
1478            (
1479                &b"GET http://allowed.test/x HTTP/1.1\r\nHost: a\r\n\r\n"[..],
1480                &connector as &dyn Connector,
1481            ),
1482            (
1483                &b"GET http://evil.test/y HTTP/1.1\r\nHost: a\r\n\r\n"[..],
1484                &FailingConnector,
1485            ),
1486            (
1487                &b"GET http://evil.test/z HTTP/1.1\r\nHost: a\r\n\r\n"[..],
1488                &FailingConnector,
1489            ),
1490        ] {
1491            let client = ScriptedConn::with_script(req);
1492            let _ = handle_conn(Box::new(client), &policy, conn, &resolver, &sink, &refused);
1493        }
1494
1495        let mut got: Vec<String> = refused.into_inner().unwrap().into_iter().collect();
1496        got.sort();
1497        assert_eq!(
1498            got,
1499            vec!["evil.test".to_string()],
1500            "only the denied host, deduped; the allowed host must NOT appear: {got:?}"
1501        );
1502    }
1503
1504    #[test]
1505    fn audit_records_allowed_with_bytes_and_denied_attempts() {
1506        // Allowed forward: an `Allowed` Http event on port 80 with response bytes.
1507        let allowed = drive_forward(
1508            b"GET http://allowed.test/x HTTP/1.1\r\nHost: ignored\r\n\r\n",
1509            &["allowed.test"],
1510            b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\norigin",
1511        );
1512        let ev = allowed
1513            .audit
1514            .iter()
1515            .find(|e| e.host == "allowed.test")
1516            .expect("an allowed event");
1517        assert_eq!(ev.decision, NetDecision::Allowed);
1518        assert_eq!(ev.kind, NetKind::Http);
1519        assert_eq!(ev.port, 80);
1520        assert!(
1521            ev.bytes_down > 0,
1522            "an allowed connection records response bytes: {ev:?}"
1523        );
1524
1525        // Denied: a `Denied` event (the exfil-attempt signal) with zero bytes.
1526        let denied = drive_with(
1527            b"GET http://evil.test/y HTTP/1.1\r\nHost: ignored\r\n\r\n",
1528            &["allowed.test"],
1529            &FailingConnector,
1530        );
1531        let ev = denied
1532            .audit
1533            .iter()
1534            .find(|e| e.host == "evil.test")
1535            .expect("a denied event");
1536        assert_eq!(ev.decision, NetDecision::Denied);
1537        assert_eq!(ev.bytes_up, 0);
1538        assert_eq!(ev.bytes_down, 0);
1539    }
1540
1541    #[test]
1542    fn jsonl_sink_appends_one_newline_terminated_json_line_per_event() {
1543        #[derive(Clone, Default)]
1544        struct SharedBuf(Arc<Mutex<Vec<u8>>>);
1545        impl Write for SharedBuf {
1546            fn write(&mut self, b: &[u8]) -> io::Result<usize> {
1547                self.0.lock().unwrap().extend_from_slice(b);
1548                Ok(b.len())
1549            }
1550            fn flush(&mut self) -> io::Result<()> {
1551                Ok(())
1552            }
1553        }
1554        let buf = SharedBuf::default();
1555        let sink = JsonlSink::new(buf.clone());
1556        let mk = |host: &str| NetAuditEvent {
1557            ts_ms: 1,
1558            host: host.into(),
1559            port: 80,
1560            kind: NetKind::Http,
1561            decision: NetDecision::Allowed,
1562            bytes_up: 1,
1563            bytes_down: 2,
1564            dur_ms: 3,
1565        };
1566        sink.record(&mk("a"));
1567        sink.record(&mk("b"));
1568        let text = String::from_utf8(buf.0.lock().unwrap().clone()).unwrap();
1569        let lines: Vec<&str> = text.lines().collect();
1570        assert_eq!(lines.len(), 2, "one JSON line per event: {text:?}");
1571        assert_eq!(
1572            serde_json::from_str::<NetAuditEvent>(lines[0])
1573                .unwrap()
1574                .host,
1575            "a"
1576        );
1577        assert_eq!(
1578            serde_json::from_str::<NetAuditEvent>(lines[1])
1579                .unwrap()
1580                .host,
1581            "b"
1582        );
1583    }
1584
1585    #[test]
1586    fn audit_event_json_round_trips() {
1587        let e = NetAuditEvent {
1588            ts_ms: 1,
1589            host: "h".into(),
1590            port: 443,
1591            kind: NetKind::Connect,
1592            decision: NetDecision::Allowed,
1593            bytes_up: 10,
1594            bytes_down: 20,
1595            dur_ms: 5,
1596        };
1597        let line = serde_json::to_string(&e).unwrap();
1598        assert!(line.contains("\"decision\":\"allowed\"") && line.contains("\"kind\":\"connect\""));
1599        assert_eq!(serde_json::from_str::<NetAuditEvent>(&line).unwrap(), e);
1600    }
1601
1602    #[test]
1603    fn connect_allowed_host_tunnels_opaque_bytes() {
1604        // The client pipelines "PING" right after the CONNECT header block; the
1605        // (scripted) origin returns "PING" as its side of the opaque exchange.
1606        // Proof of a real bidirectional tunnel: the origin receives the client's
1607        // PING (up-copy) and the client receives the origin's PING (down-copy),
1608        // after a bare 200 status line — driven through the real splice logic.
1609        let out = drive_forward(
1610            b"CONNECT allowed.test:443 HTTP/1.1\r\nHost: allowed.test:443\r\n\r\nPING",
1611            &["allowed.test"],
1612            b"PING",
1613        );
1614        let client_saw = out.client.written_str();
1615        assert!(
1616            client_saw.starts_with("HTTP/1.1 200"),
1617            "CONNECT must be accepted with a bare 200: {client_saw:?}"
1618        );
1619        assert!(
1620            client_saw.contains("PING"),
1621            "the origin's bytes must tunnel back to the client: {client_saw:?}"
1622        );
1623        assert_eq!(
1624            out.origin.written(),
1625            b"PING",
1626            "the client's pipelined bytes must reach the origin through the tunnel"
1627        );
1628    }
1629
1630    #[test]
1631    fn connect_disallowed_host_is_refused_403() {
1632        let out = drive_with(
1633            b"CONNECT evil.test:443 HTTP/1.1\r\n\r\n",
1634            &["allowed.test"],
1635            &FailingConnector,
1636        );
1637        assert!(
1638            out.client.written_str().contains("403"),
1639            "a denied CONNECT must get 403, not a tunnel: {}",
1640            out.client.written_str()
1641        );
1642    }
1643
1644    #[test]
1645    fn http_host_header_is_normalized_to_the_validated_authority() {
1646        // The authority (allowed.test) is validated, but the client LIES with a
1647        // spoofed `Host: evil.test` to domain-front. The origin must see the
1648        // validated authority substituted in, never the spoof.
1649        let out = drive_forward(
1650            b"GET http://allowed.test/ HTTP/1.1\r\nHost: evil.test\r\nConnection: close\r\n\r\n",
1651            &["allowed.test"],
1652            b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
1653        );
1654        let origin_saw = out.origin.written_str();
1655        assert!(
1656            origin_saw.contains("Host: allowed.test\r\n"),
1657            "origin must receive the validated Host: {origin_saw:?}"
1658        );
1659        assert!(
1660            !origin_saw.contains("evil.test"),
1661            "the spoofed Host must not reach the origin: {origin_saw:?}"
1662        );
1663    }
1664
1665    /// Serialize the tests that touch REAL loopback sockets (#155, #207). They
1666    /// share the host's loopback and ephemeral-port space, so concurrent
1667    /// siblings can interfere — e.g. a port released by one test can be
1668    /// re-bound by a sibling before the first is done probing it, and the
1669    /// probe then observes the sibling's live listener. One shared lock
1670    /// removes that interference class wholesale; the motivating (never
1671    /// locally reproduced) flake was `Empty reply from server` on the
1672    /// fenced_child test in PR #195's macOS CI. The in-memory tests above
1673    /// (#216) need no lock — only the real-socket tests below take it.
1674    /// Process-local, which suffices because `cargo test` runs test binaries
1675    /// sequentially; a per-test-process runner (e.g. nextest) would NOT be
1676    /// covered, nor would the loopback binds in other crates' test binaries.
1677    /// Poison is ignored so a panicking test does not cascade-fail the rest.
1678    fn net_test_lock() -> std::sync::MutexGuard<'static, ()> {
1679        static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1680        LOCK.lock().unwrap_or_else(|e| e.into_inner())
1681    }
1682
1683    #[test]
1684    fn proxy_env_points_at_the_bound_loopback_addr() {
1685        // A REAL-socket test: `proxy_env` is derived from the actually-bound
1686        // loopback address, so this exercises the real `bind` — and its live
1687        // accept-loop listener must not interleave with the port probes of the
1688        // sibling real-socket tests.
1689        let _serial = net_test_lock();
1690        let proxy = start_null(["x".to_string()], Arc::new(StdResolver)).unwrap();
1691        let env = proxy.proxy_env();
1692        let url = format!("http://127.0.0.1:{}", proxy.addr().port());
1693        assert!(env.iter().any(|(k, v)| k == "https_proxy" && *v == url));
1694        assert!(env.iter().any(|(k, v)| k == "HTTPS_PROXY" && *v == url));
1695        assert!(env.iter().all(|(_, v)| v.starts_with("http://127.0.0.1:")));
1696    }
1697
1698    /// A one-shot HTTP origin on loopback that replies 200 with a marker body —
1699    /// used ONLY by the macOS kernel e2e below, which must drive real `curl`
1700    /// through the real proxy over a real socket (the in-memory fakes cannot
1701    /// exercise a kernel fence). Gated to that test's platform so it is not dead
1702    /// code elsewhere.
1703    #[cfg(all(target_os = "macos", feature = "macos-seatbelt"))]
1704    fn spawn_origin() -> SocketAddr {
1705        let l = TcpListener::bind(("127.0.0.1", 0)).unwrap();
1706        let addr = l.local_addr().unwrap();
1707        thread::spawn(move || {
1708            for s in l.incoming().flatten() {
1709                let mut s = s;
1710                let mut b = [0u8; 512];
1711                let _ = s.read(&mut b);
1712                let _ = s.write_all(
1713                    b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\norigin",
1714                );
1715            }
1716        });
1717        addr
1718    }
1719
1720    /// End-to-end kernel proof (#124, ADR 0016): a real `curl` child, confined by
1721    /// the ADR 0015 loopback fence, reaches an ALLOWED host only through the proxy
1722    /// (200), is refused a DENIED host by the proxy (403), and — crucially —
1723    /// **cannot bypass** the proxy: a direct off-box connect is kernel-denied. All
1724    /// hermetic — the resolver maps the allow-listed name to a loopback origin, so
1725    /// no real network is touched. macOS + `macos-seatbelt` only; self-skips if
1726    /// `sandbox-exec`/`curl` are unavailable. This is the one test that genuinely
1727    /// needs the real socket path (the fence is a kernel property); the forward /
1728    /// tunnel / allow-list / audit LOGIC is covered deterministically in-memory
1729    /// above. The exact-private-host constructor is used here to ground its
1730    /// unchanged fence/allowlist behavior; private address classification and
1731    /// pinned dialing are covered by `exact_private_hosts_require_both_grants_and_pin_the_screened_address`.
1732    #[cfg(all(target_os = "macos", feature = "macos-seatbelt"))]
1733    #[test]
1734    fn fenced_child_reaches_allowed_via_proxy_denied_refused_direct_kernel_blocked() {
1735        use crate::{
1736            best_available_sandbox, loopback_fenced_caveats, seatbelt_is_supported, Caveats,
1737            SandboxPolicy, Scope,
1738        };
1739        // Serialize with sibling loopback tests (issue #207): this is the
1740        // heaviest net test (origin + proxy + real curl child) and must not
1741        // race siblings on loopback. Held for the whole test via RAII.
1742        let _serial = net_test_lock();
1743        if !seatbelt_is_supported() {
1744            eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
1745            return;
1746        }
1747        let curl = "/usr/bin/curl";
1748        if !std::path::Path::new(curl).exists() {
1749            eprintln!("skipping: no curl(1)");
1750            return;
1751        }
1752
1753        let origin = spawn_origin();
1754        let proxy = start_with_private_hosts(
1755            ["allowed.test".to_string()],
1756            ["allowed.test".to_string()],
1757            Arc::new(FixedResolver(origin)),
1758            Arc::new(NullSink),
1759        )
1760        .unwrap();
1761
1762        // The grant is a general remote-host allow-list; its loopback-fenced form
1763        // is what actually confines the child (the ADR 0016 mechanism).
1764        let granted = Caveats {
1765            net: Scope::only(["allowed.test".to_string()]),
1766            ..Caveats::top()
1767        };
1768        let prefix = best_available_sandbox(&Arc::new(SandboxPolicy::default()))
1769            .command_prefix(&loopback_fenced_caveats(&granted))
1770            .expect("seatbelt wrapper");
1771
1772        // Run `curl` wrapped by the fence, with the given env. `-v` surfaces the
1773        // connect-time error on stderr so the FENCE leg can assert a *permission*
1774        // denial (not a mere routing failure).
1775        let run = |proxy_env: bool, url: &str| -> std::process::Output {
1776            let mut cmd = std::process::Command::new(&prefix[0]);
1777            cmd.args(&prefix[1..])
1778                .arg(curl)
1779                .args(["-sv", "--max-time", "5", url])
1780                .env_clear();
1781            if proxy_env {
1782                cmd.envs(proxy.proxy_env());
1783            }
1784            cmd.output().expect("spawn sandbox-exec")
1785        };
1786
1787        // ALLOW: via the proxy, the allow-listed host reaches the loopback origin.
1788        //
1789        // In a few CI environments the first `curl` attempt can be an
1790        // in-flight startup race that ends in "Empty reply from server" even
1791        // though the proxy and origin are otherwise healthy. A tiny bounded retry
1792        // keeps this assertion focused on policy while filtering transient transport
1793        // noise.
1794        let mut allow = None;
1795        for attempt in 0..3 {
1796            let output = run(true, "http://allowed.test/");
1797            let stdout = String::from_utf8_lossy(&output.stdout);
1798            if output.status.success() && stdout.contains("origin") {
1799                allow = Some(output);
1800                break;
1801            }
1802            if attempt < 2 {
1803                thread::sleep(Duration::from_millis(100));
1804                continue;
1805            }
1806            allow = Some(output);
1807        }
1808        let allow = allow.expect("at least one allow-leg probe should run");
1809        assert!(
1810            allow.status.success() && String::from_utf8_lossy(&allow.stdout).contains("origin"),
1811            "allow-listed host must reach the origin through the proxy: {allow:?}"
1812        );
1813        // DENY: via the proxy, a non-allow-listed host gets the proxy's 403 — and
1814        // never reaches any origin.
1815        let deny = run(true, "http://denied.test/");
1816        assert!(
1817            String::from_utf8_lossy(&deny.stdout).contains("403"),
1818            "denied host must get the proxy's 403: {deny:?}"
1819        );
1820        // FENCE: WITHOUT the proxy env the child tries to egress directly; a literal
1821        // off-box IP is kernel-denied at the socket. Assert curl exit 7 AND an EPERM
1822        // signal ("Operation not permitted") — so a no-internet runner (ENETUNREACH,
1823        // also exit 7) cannot make this pass vacuously; it must be a *permission*
1824        // denial, proving the fence (not the network) blocked it.
1825        let direct = run(false, "http://1.1.1.1/");
1826        let stderr = String::from_utf8_lossy(&direct.stderr);
1827        assert_eq!(
1828            direct.status.code(),
1829            Some(7),
1830            "direct off-box egress must be kernel-denied (curl exit 7): {stderr}"
1831        );
1832        assert!(
1833            stderr.contains("Operation not permitted"),
1834            "the block must be a kernel EPERM, not a routing failure: {stderr}"
1835        );
1836
1837        drop(proxy);
1838    }
1839
1840    /// The other always-on REAL-socket test: it must exercise the actual
1841    /// `TcpListener` bind + accept loop + `Drop` teardown, which no in-memory
1842    /// fake can. It probes a just-released ephemeral port, so it serializes on
1843    /// `net_test_lock()` — a concurrent sibling could re-bind that port between
1844    /// the drop and the probe, and the probe would then hit the sibling's live
1845    /// listener (#207).
1846    #[test]
1847    fn dropping_the_handle_stops_the_listener() {
1848        let _serial = net_test_lock();
1849        let proxy = start_null(["x".to_string()], Arc::new(StdResolver)).unwrap();
1850        let addr = proxy.addr();
1851        drop(proxy);
1852        // After shutdown the port is no longer served: a connect either refuses
1853        // or the accept loop has exited. Give the OS a moment, then assert we
1854        // cannot complete an HTTP exchange through it.
1855        thread::sleep(Duration::from_millis(100));
1856        if let Ok(mut c) = TcpStream::connect_timeout(&addr, Duration::from_millis(200)) {
1857            c.set_read_timeout(Some(Duration::from_millis(500)))
1858                .unwrap();
1859            let _ = write!(c, "GET http://x/ HTTP/1.1\r\n\r\n");
1860            let mut resp = Vec::new();
1861            let _ = c.read_to_end(&mut resp);
1862            assert!(resp.is_empty(), "a stopped proxy must not serve requests");
1863        }
1864    }
1865}