Skip to main content

kranz_engine/
egress_proxy.rs

1//! Userspace filtering egress proxy for `fs+net` sandboxed sessions (ticket
2//! `egress-grant-sandbox-instrumentation`, step 3.3a — enforcement + signal).
3//!
4//! One proxy per sandboxed run with `enforce: fs+net`, bound to an ephemeral
5//! 127.0.0.1 port whose number is read back after bind. The run wires
6//! `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` into the session env; HTTPS egress
7//! then flows through CONNECT only (a plain-HTTP forward is NOT v1 — the API
8//! endpoints and registries missions need are TLS). The per-host allowlist is
9//! enforced at CONNECT time: [`crate::sandbox::effective_egress`] (the
10//! `DEFAULT_EGRESS` Anthropic endpoints + the role's configured `egress[]` +
11//! the mission's operator-granted `egress_grants`, folded into the sandbox
12//! inputs at spec build). A denied CONNECT gets a `403` plus a structured
13//! `{"ts","host","port"}` record appended — and fsynced — to
14//! `<mission_dir>/runs/egress-denials.jsonl` (gitignored runtime). The run
15//! reads the records ITS proxy wrote into `RunOutcome.denied_egress`, so a
16//! later grant flow (3.3b) has a trigger that names the destination. Before
17//! `worker.completed`, the runner also persists a bounded, deduplicated batch
18//! as a scrubbed, run-attributed `worker.egress.denied` audit event; that
19//! durable copy can be projected into functional validation after disposable
20//! runtime cleanup.
21//!
22//! Platform coverage:
23//!
24//! - macOS Seatbelt: the `fs+net` SBPL restricts outbound TCP to loopback, so
25//!   the proxy is the ONLY way out — a hard boundary. (Seatbelt accepts only
26//!   `*`/`localhost` network hosts, which is why per-host filtering lives
27//!   here instead of in the profile.)
28//! - Tier-3 Docker containers (`provider: container`, `fs+net` with a
29//!   non-empty egress list): the worker has only a per-run internal network.
30//!   A dual-homed trusted relay injects a run-secret authorization header and
31//!   forwards CONNECT here; the worker has neither the token nor another
32//!   route. See `crate::container_egress`. An EMPTY egress list keeps the
33//!   simpler `--network none` boundary and runs no proxy.
34//! - Linux bubblewrap: OUT OF SCOPE for v1 — bwrap's all-or-nothing netns
35//!   cannot reach a host-side proxy without veth plumbing, so `fs+net` there
36//!   stays `--unshare-net` with no proxy and no denial signal. The macOS
37//!   unified-log tail (`Sandbox: … deny(1) network-outbound`) is likewise out
38//!   of scope: the proxy makes it unnecessary.
39//!
40//! Failure posture: a proxy that cannot bind or open its denial file fails
41//! the run CLOSED — the session never launches without its enforcement, the
42//! same discipline as `resolve_sandbox_or_refuse`. A denied host always
43//! produces a denial record, never a silent timeout.
44//!
45//! Denial correlation is mission-level: each run's proxy appends to the
46//! shared mission file (one line per denied CONNECT, fsynced), and
47//! `RunOutcome.denied_egress` carries exactly the records this run's proxy
48//! wrote (kept in memory alongside the file, so concurrent M3 runs never
49//! cross-attribute). The authenticated container relay also prevents another
50//! container from forging requests into this run's proxy.
51
52use crate::backend::SessionSpec;
53use crate::error::{EngineError, Result};
54use crate::paths::MissionPaths;
55use crate::sandbox::SandboxBackend;
56use crate::types::SandboxEnforce;
57use serde::{Deserialize, Serialize};
58use std::net::{Ipv4Addr, SocketAddr};
59use std::path::PathBuf;
60use std::sync::{Arc, Mutex};
61use subtle::ConstantTimeEq;
62use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
63use tokio::net::{TcpListener, TcpStream};
64use tokio::task::JoinHandle;
65
66/// `HTTPS_PROXY`/`HTTP_PROXY` value scheme is always `http://` (CONNECT).
67pub const HTTPS_PROXY_ENV: &str = "HTTPS_PROXY";
68pub const HTTP_PROXY_ENV: &str = "HTTP_PROXY";
69pub const NO_PROXY_ENV: &str = "NO_PROXY";
70/// Loopback must never route through the proxy (it would recurse).
71pub const NO_PROXY_VALUE: &str = "localhost,127.0.0.1";
72
73/// One denied CONNECT: the destination the allowlist refused. Carried on
74/// [`crate::runner::RunOutcome::denied_egress`]; the JSONL record adds `ts`.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct EgressDenial {
77    pub host: String,
78    pub port: u16,
79}
80
81/// Max bytes of CONNECT request head read before the connection is refused.
82const MAX_HEAD_BYTES: usize = 8192;
83/// Bound on a client dribbling headers, so idle connections cannot pin tasks.
84const HEAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
85
86// ---------------------------------------------------------------------------
87// Allowlist
88// ---------------------------------------------------------------------------
89
90/// One parsed `host:port` allowlist entry (`*.suffix` wildcards subdomains).
91#[derive(Debug, Clone, PartialEq, Eq)]
92struct AllowEntry {
93    /// Lowercased host pattern; the suffix when `wildcard` is set.
94    host: String,
95    port: u16,
96    wildcard: bool,
97}
98
99impl AllowEntry {
100    /// Strict parse: `host` or `host:port` (port defaults to 443), optional
101    /// `*.` wildcard prefix. A malformed entry is an ERROR, never silently
102    /// dropped — the caller fails closed rather than run with a narrower
103    /// allowlist than the operator configured. No catch-all (`*`) support.
104    fn parse(raw: &str) -> Result<AllowEntry> {
105        let raw = raw.trim();
106        let (host, port) = match raw.rsplit_once(':') {
107            Some((host, port)) => {
108                let port = port.parse::<u16>().map_err(|_| {
109                    EngineError::Config(format!(
110                        "egress allowlist entry {raw:?} has an invalid port"
111                    ))
112                })?;
113                (host, port)
114            }
115            None => (raw, 443),
116        };
117        let (host, wildcard) = match host.strip_prefix("*.") {
118            Some(suffix) => (suffix, true),
119            None => (host, false),
120        };
121        let host = host.to_ascii_lowercase();
122        if host.is_empty() || port == 0 || host.contains(['/', ' ', '\t']) || host.starts_with('[')
123        {
124            return Err(EngineError::Config(format!(
125                "egress allowlist entry {raw:?} is not a valid host[:port]"
126            )));
127        }
128        Ok(AllowEntry {
129            host,
130            port,
131            wildcard,
132        })
133    }
134
135    /// Exact host:port match, or — for `*.suffix` entries — any subdomain of
136    /// `suffix` (the apex itself is NOT matched; give it its own entry).
137    /// Hosts compare case-insensitively (DNS is case-insensitive).
138    fn matches(&self, host: &str, port: u16) -> bool {
139        if self.port != port {
140            return false;
141        }
142        let host = host.to_ascii_lowercase();
143        if self.wildcard {
144            host.len() > self.host.len() && host.ends_with(&format!(".{}", self.host))
145        } else {
146            host == self.host
147        }
148    }
149}
150
151fn parse_allowlist(raw: &[String]) -> Result<Vec<AllowEntry>> {
152    raw.iter().map(|entry| AllowEntry::parse(entry)).collect()
153}
154
155// ---------------------------------------------------------------------------
156// CONNECT request parsing
157// ---------------------------------------------------------------------------
158
159/// A well-formed `CONNECT host:port HTTP/1.x` target (IPv4/hostname only —
160/// IPv6 literals are refused, not mis-parsed).
161#[derive(Debug, Clone, PartialEq, Eq)]
162struct ConnectTarget {
163    host: String,
164    port: u16,
165}
166
167/// Parse the request head (request line + headers, CRLF-terminated). Only the
168/// request line is interpreted; headers (including any Proxy-Authorization —
169/// the documented per-run attribution hook) are ignored in v1. Returns `None`
170/// for anything that is not exactly `CONNECT <authority> HTTP/1.x` — the
171/// caller answers 400 and closes; a malformed request is never proxied.
172fn parse_connect_request(head: &str) -> Option<ConnectTarget> {
173    let request_line = head.lines().next()?;
174    let mut parts = request_line.split_whitespace();
175    let method = parts.next()?;
176    let authority = parts.next()?;
177    let version = parts.next()?;
178    if method != "CONNECT" || parts.next().is_some() || !version.starts_with("HTTP/1.") {
179        return None;
180    }
181    let (host, port) = authority.rsplit_once(':')?;
182    if host.is_empty() || host.contains(['/', ' ', '\t']) || host.starts_with('[') {
183        return None;
184    }
185    let port = port.parse::<u16>().ok().filter(|port| *port != 0)?;
186    Some(ConnectTarget {
187        host: host.to_string(),
188        port,
189    })
190}
191
192// ---------------------------------------------------------------------------
193// Denial sink: shared mission JSONL (fsynced) + exact in-memory records
194// ---------------------------------------------------------------------------
195
196struct DenialSink {
197    file: tokio::sync::Mutex<tokio::fs::File>,
198    records: Mutex<Vec<EgressDenial>>,
199}
200
201impl DenialSink {
202    /// Append `{"ts","host","port"}` to the mission JSONL and fsync it, then
203    /// keep the in-memory copy the run reads back at shutdown. Host/port only
204    /// — never request headers or credentials. A write failure loses audit
205    /// but must not change the denial outcome; the caller logs and continues.
206    async fn record(&self, host: &str, port: u16) -> std::io::Result<()> {
207        let line = serde_json::json!({
208            "ts": chrono::Utc::now().to_rfc3339(),
209            "host": host,
210            "port": port,
211        });
212        let mut bytes = line.to_string().into_bytes();
213        bytes.push(b'\n');
214        {
215            let mut file = self.file.lock().await;
216            file.write_all(&bytes).await?;
217            file.sync_data().await?;
218        }
219        self.records
220            .lock()
221            .expect("denial records lock")
222            .push(EgressDenial {
223                host: host.to_string(),
224                port,
225            });
226        Ok(())
227    }
228}
229
230// ---------------------------------------------------------------------------
231// Proxy
232// ---------------------------------------------------------------------------
233
234/// A running egress proxy: accept loop + per-connection tunnel tasks, all
235/// torn down by [`EgressProxy::shutdown`].
236pub struct EgressProxy {
237    addr: SocketAddr,
238    sink: Arc<DenialSink>,
239    accept_task: JoinHandle<()>,
240    conn_tasks: Arc<Mutex<tokio::task::JoinSet<()>>>,
241}
242
243impl Drop for EgressProxy {
244    fn drop(&mut self) {
245        // Runner error paths can leave before explicit shutdown (backend
246        // start failure, stream error, cancellation failure). Aborting both
247        // task sets makes that path fail closed and leak-free.
248        self.accept_task.abort();
249        self.conn_tasks.lock().expect("conn tasks lock").abort_all();
250    }
251}
252
253impl std::fmt::Debug for EgressProxy {
254    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255        f.debug_struct("EgressProxy")
256            .field("addr", &self.addr)
257            .finish_non_exhaustive()
258    }
259}
260
261impl EgressProxy {
262    /// Bind an ephemeral 127.0.0.1 port and start serving. `allowlist` is the
263    /// raw `host:port` strings (strictly parsed — a bad entry fails closed);
264    /// `denial_file` is created/appended, fsynced per record.
265    pub async fn start(allowlist: Vec<String>, denial_file: PathBuf) -> Result<EgressProxy> {
266        EgressProxy::start_bound(
267            SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
268            allowlist,
269            denial_file,
270        )
271        .await
272    }
273
274    /// [`EgressProxy::start`] on an explicit address — the seam tests use to
275    /// force a bind conflict (fail-closed proof).
276    pub async fn start_bound(
277        addr: SocketAddr,
278        allowlist: Vec<String>,
279        denial_file: PathBuf,
280    ) -> Result<EgressProxy> {
281        Self::start_bound_with_auth(addr, allowlist, denial_file, None).await
282    }
283
284    /// Bind an explicit address and require `Proxy-Authorization: Bearer …`
285    /// on every CONNECT. The Docker relay owns the token; the worker never
286    /// sees it. Authentication failure returns 407 and never records a host
287    /// denial, so an unauthenticated peer cannot forge grant requests.
288    pub async fn start_authenticated_bound(
289        addr: SocketAddr,
290        allowlist: Vec<String>,
291        denial_file: PathBuf,
292        relay_authority: String,
293    ) -> Result<EgressProxy> {
294        Self::start_bound_with_auth(addr, allowlist, denial_file, Some(relay_authority)).await
295    }
296
297    async fn start_bound_with_auth(
298        addr: SocketAddr,
299        allowlist: Vec<String>,
300        denial_file: PathBuf,
301        relay_authority: Option<String>,
302    ) -> Result<EgressProxy> {
303        let entries = parse_allowlist(&allowlist)?;
304        let listener = TcpListener::bind(addr).await.map_err(|e| {
305            EngineError::Backend(format!("egress proxy failed to bind {addr}: {e}"))
306        })?;
307        let bound = listener.local_addr().map_err(EngineError::Io)?;
308        if let Some(parent) = denial_file.parent() {
309            std::fs::create_dir_all(parent).map_err(|e| {
310                EngineError::Backend(format!(
311                    "egress proxy failed to create denial file dir {}: {e}",
312                    parent.display()
313                ))
314            })?;
315        }
316        let file = tokio::fs::OpenOptions::new()
317            .create(true)
318            .append(true)
319            .open(&denial_file)
320            .await
321            .map_err(|e| {
322                EngineError::Backend(format!(
323                    "egress proxy failed to open denial file {}: {e}",
324                    denial_file.display()
325                ))
326            })?;
327        let sink = Arc::new(DenialSink {
328            file: tokio::sync::Mutex::new(file),
329            records: Mutex::new(Vec::new()),
330        });
331        let conn_tasks = Arc::new(Mutex::new(tokio::task::JoinSet::new()));
332        let accept_task = {
333            let sink = Arc::clone(&sink);
334            let conn_tasks = Arc::clone(&conn_tasks);
335            tokio::spawn(async move {
336                loop {
337                    match listener.accept().await {
338                        Ok((stream, _peer)) => {
339                            let sink = Arc::clone(&sink);
340                            let entries = entries.clone();
341                            let relay_authority = relay_authority.clone();
342                            let mut tasks = conn_tasks.lock().expect("conn tasks lock");
343                            tasks.spawn(handle_connection(stream, entries, sink, relay_authority));
344                            // Reap finished tunnels so the set cannot grow
345                            // unboundedly over a long session.
346                            while tasks.try_join_next().is_some() {}
347                        }
348                        Err(e) => {
349                            tracing::warn!(error = %e, "egress proxy accept failed; stopping accept loop");
350                            break;
351                        }
352                    }
353                }
354            })
355        };
356        tracing::info!(
357            addr = %bound,
358            denial_file = %denial_file.display(),
359            "egress proxy listening"
360        );
361        Ok(EgressProxy {
362            addr: bound,
363            sink,
364            accept_task,
365            conn_tasks,
366        })
367    }
368
369    /// The address the proxy actually bound (ephemeral port read back).
370    pub fn addr(&self) -> SocketAddr {
371        self.addr
372    }
373
374    pub fn port(&self) -> u16 {
375        self.addr.port()
376    }
377
378    /// Stop accepting and abort any in-flight tunnels, then return the
379    /// denials THIS proxy recorded (first-seen order). Called once the run's
380    /// session stream has closed, so the returned vec is complete for the run.
381    pub async fn shutdown(self) -> Vec<EgressDenial> {
382        self.accept_task.abort();
383        self.conn_tasks.lock().expect("conn tasks lock").abort_all();
384        let records = std::mem::take(&mut *self.sink.records.lock().expect("denial records lock"));
385        tracing::info!(
386            addr = %self.addr,
387            denials = records.len(),
388            "egress proxy shut down"
389        );
390        records
391    }
392}
393
394async fn handle_connection(
395    stream: TcpStream,
396    allowlist: Vec<AllowEntry>,
397    sink: Arc<DenialSink>,
398    relay_authority: Option<String>,
399) {
400    if let Err(e) =
401        handle_connection_inner(stream, &allowlist, &sink, relay_authority.as_deref()).await
402    {
403        tracing::debug!(error = %e, "egress proxy connection closed with an error");
404    }
405}
406
407async fn handle_connection_inner(
408    stream: TcpStream,
409    allowlist: &[AllowEntry],
410    sink: &DenialSink,
411    relay_authority: Option<&str>,
412) -> std::io::Result<()> {
413    let mut reader = BufReader::new(stream);
414    let head = read_request_head(&mut reader).await?;
415    let Some(target) = parse_connect_request(&head) else {
416        write_response(
417            reader.get_mut(),
418            "400 Bad Request",
419            b"kranz egress proxy: expected 'CONNECT host:port HTTP/1.x'\r\n",
420        )
421        .await?;
422        return Ok(());
423    };
424    if let Some(authority) = relay_authority {
425        let authorized = proxy_authorization(&head)
426            .map(|candidate| candidate.as_bytes().ct_eq(authority.as_bytes()).into())
427            .unwrap_or(false);
428        if !authorized {
429            write_response(
430                reader.get_mut(),
431                "407 Proxy Authentication Required",
432                b"kranz egress proxy: relay authorization required\r\n",
433            )
434            .await?;
435            return Ok(());
436        }
437    }
438    if !allowlist
439        .iter()
440        .any(|entry| entry.matches(&target.host, target.port))
441    {
442        tracing::info!(host = %target.host, port = target.port, "egress denied");
443        if let Err(e) = sink.record(&target.host, target.port).await {
444            tracing::warn!(error = %e, host = %target.host, "egress denial record write failed (audit lost; denial stands)");
445        }
446        let body = format!(
447            "kranz egress proxy: {}:{} is not in the mission egress allowlist\r\n",
448            target.host, target.port
449        );
450        write_response(reader.get_mut(), "403 Forbidden", body.as_bytes()).await?;
451        return Ok(());
452    }
453    let mut upstream = match TcpStream::connect((target.host.as_str(), target.port)).await {
454        Ok(upstream) => upstream,
455        Err(e) => {
456            let body = format!(
457                "kranz egress proxy: connect to {}:{} failed: {e}\r\n",
458                target.host, target.port
459            );
460            write_response(reader.get_mut(), "502 Bad Gateway", body.as_bytes()).await?;
461            return Ok(());
462        }
463    };
464    reader
465        .get_mut()
466        .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
467        .await?;
468    // Any bytes the client pipelined past the header block must reach the
469    // target before the raw tunnel starts (into_inner discards the buffer).
470    let leftover = reader.buffer().to_vec();
471    let mut client = reader.into_inner();
472    if !leftover.is_empty() {
473        upstream.write_all(&leftover).await?;
474    }
475    tokio::io::copy_bidirectional(&mut client, &mut upstream).await?;
476    Ok(())
477}
478
479fn proxy_authorization(head: &str) -> Option<&str> {
480    head.lines().skip(1).find_map(|line| {
481        let (name, value) = line.split_once(':')?;
482        if !name.eq_ignore_ascii_case("proxy-authorization") {
483            return None;
484        }
485        value.trim().strip_prefix("Bearer ")
486    })
487}
488
489/// Read lines until the CRLF terminator, bounded by [`MAX_HEAD_BYTES`] and
490/// [`HEAD_TIMEOUT`]. Returns the whole head (request line + headers).
491async fn read_request_head(reader: &mut BufReader<TcpStream>) -> std::io::Result<String> {
492    let mut head = String::new();
493    loop {
494        let mut line = String::new();
495        let read = tokio::time::timeout(HEAD_TIMEOUT, reader.read_line(&mut line))
496            .await
497            .map_err(|_| {
498                std::io::Error::new(
499                    std::io::ErrorKind::TimedOut,
500                    "egress proxy: CONNECT header read timed out",
501                )
502            })??;
503        if read == 0 {
504            return Err(std::io::Error::new(
505                std::io::ErrorKind::UnexpectedEof,
506                "egress proxy: client closed before the CONNECT head completed",
507            ));
508        }
509        head.push_str(&line);
510        if head.len() > MAX_HEAD_BYTES {
511            return Err(std::io::Error::new(
512                std::io::ErrorKind::InvalidData,
513                "egress proxy: CONNECT head exceeds 8 KiB",
514            ));
515        }
516        if line == "\r\n" {
517            return Ok(head);
518        }
519    }
520}
521
522/// One status line + a plain-text body, then close (Connection: close).
523async fn write_response(stream: &mut TcpStream, status: &str, body: &[u8]) -> std::io::Result<()> {
524    let head = format!(
525        "HTTP/1.1 {status}\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
526        body.len()
527    );
528    stream.write_all(head.as_bytes()).await?;
529    stream.write_all(body).await?;
530    stream.flush().await
531}
532
533// ---------------------------------------------------------------------------
534// Runner seam: spawn the proxy for fs+net sessions and wire the session env
535// ---------------------------------------------------------------------------
536
537/// Spawn an egress proxy for `spec` when its resolved sandbox routes `fs+net`
538/// through one, pointing `spec.env` at it. Returns `None` — no proxy, no env —
539/// for unsandboxed runs, `fs`/`off`, bubblewrap (netns cannot reach a host
540/// proxy; v1 out of scope), and container `fs+net` with an empty egress list
541/// (`--network none`). A start failure is an Err: the run must fail closed
542/// rather than launch the session without enforcement.
543pub async fn maybe_start_for_session(
544    spec: &mut SessionSpec,
545    paths: &MissionPaths,
546) -> Result<Option<SessionEgress>> {
547    if matches!(
548        spec.sandbox.as_ref(),
549        Some(sandbox)
550            if sandbox.backend == SandboxBackend::Container
551                && sandbox.inputs.enforce == SandboxEnforce::FsNet
552                && !sandbox.inputs.egress.is_empty()
553    ) {
554        return crate::container_egress::ContainerEgressBoundary::start(spec, paths)
555            .await
556            .map(|boundary| Some(SessionEgress::Container(boundary)));
557    }
558    maybe_start_for_session_with(spec, paths, |allowlist, denial_file| {
559        Box::pin(EgressProxy::start(allowlist, denial_file))
560    })
561    .await
562    .map(|proxy| proxy.map(SessionEgress::Proxy))
563}
564
565/// Enforcement resource tied to one session. Both variants expose the same
566/// denial stream; the container variant additionally owns Docker resources
567/// and verifies their teardown.
568pub enum SessionEgress {
569    Proxy(EgressProxy),
570    Container(crate::container_egress::ContainerEgressBoundary),
571}
572
573impl SessionEgress {
574    pub fn port(&self) -> u16 {
575        match self {
576            SessionEgress::Proxy(proxy) => proxy.port(),
577            SessionEgress::Container(boundary) => boundary.proxy_port(),
578        }
579    }
580
581    pub async fn shutdown(self) -> Result<Vec<EgressDenial>> {
582        match self {
583            SessionEgress::Proxy(proxy) => Ok(proxy.shutdown().await),
584            SessionEgress::Container(boundary) => boundary.shutdown().await,
585        }
586    }
587}
588
589/// [`maybe_start_for_session`] with the proxy-start step injected, so tests
590/// can force a start failure and prove the fail-closed posture.
591async fn maybe_start_for_session_with(
592    spec: &mut SessionSpec,
593    paths: &MissionPaths,
594    start: impl FnOnce(
595        Vec<String>,
596        PathBuf,
597    ) -> std::pin::Pin<
598        Box<dyn std::future::Future<Output = Result<EgressProxy>> + Send>,
599    >,
600) -> Result<Option<EgressProxy>> {
601    let Some(sandbox) = &spec.sandbox else {
602        return Ok(None);
603    };
604    if sandbox.inputs.enforce != SandboxEnforce::FsNet {
605        return Ok(None);
606    }
607    // Seatbelt sessions reach their run proxy on host loopback. Container
608    // sessions took the authenticated internal-network path above; the other
609    // backends have no reachable host-proxy route.
610    let route_host = match sandbox.backend {
611        SandboxBackend::Seatbelt => "127.0.0.1",
612        SandboxBackend::Bubblewrap | SandboxBackend::AppContainer | SandboxBackend::Container => {
613            return Ok(None)
614        }
615    };
616    let allowlist = crate::sandbox::effective_egress(&sandbox.inputs.egress);
617    let denial_file = paths.egress_denials_file();
618    let proxy = start(allowlist, denial_file).await.map_err(|e| {
619        EngineError::Backend(format!(
620            "egress proxy failed to start for an fs+net session; refusing to run without enforcement: {e}"
621        ))
622    })?;
623    let url = format!("http://{}:{}", route_host, proxy.port());
624    spec.env.insert(HTTPS_PROXY_ENV.to_string(), url.clone());
625    spec.env.insert(HTTP_PROXY_ENV.to_string(), url);
626    spec.env
627        .insert(NO_PROXY_ENV.to_string(), NO_PROXY_VALUE.to_string());
628    Ok(Some(proxy))
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634    use tokio::io::{AsyncReadExt, AsyncWriteExt};
635
636    fn temp_paths(dir: &tempfile::TempDir) -> MissionPaths {
637        MissionPaths::new(dir.path(), "m-test")
638    }
639
640    fn fs_net_spec(
641        backend: SandboxBackend,
642        egress: Vec<String>,
643        dir: &std::path::Path,
644    ) -> SessionSpec {
645        SessionSpec {
646            cwd: dir.to_path_buf(),
647            prompt: crate::backend::PromptMode::SingleShot("task".to_string()),
648            append_system_prompt: None,
649            model: "mock".to_string(),
650            effort: "medium".to_string(),
651            session_id: "sess".to_string(),
652            resume: None,
653            permission_mode: None,
654            allowed_tools: Vec::new(),
655            disallowed_tools: Vec::new(),
656            tools: Vec::new(),
657            writable: true,
658            settings_json: None,
659            json_schema: None,
660            max_budget_usd: None,
661            max_turns: None,
662            env: std::collections::HashMap::new(),
663            sandbox: Some(crate::sandbox::ResolvedSandbox {
664                backend,
665                inputs: crate::sandbox::SandboxInputs {
666                    enforce: SandboxEnforce::FsNet,
667                    session_cwd: dir.to_path_buf(),
668                    mission_dir: dir.to_path_buf(),
669                    tmpdir: std::env::temp_dir(),
670                    extra_write: Vec::new(),
671                    egress,
672                    validator_read_deny_roots: Vec::new(),
673                },
674                container: None,
675            }),
676            hook_status: None,
677        }
678    }
679
680    // -- CONNECT parsing ----------------------------------------------------
681
682    #[test]
683    fn egress_proxy_connect_parsing_accepts_well_formed() {
684        let target = parse_connect_request(
685            "CONNECT api.anthropic.com:443 HTTP/1.1\r\nHost: api.anthropic.com:443\r\n\r\n",
686        )
687        .expect("well-formed CONNECT parses");
688        assert_eq!(target.host, "api.anthropic.com");
689        assert_eq!(target.port, 443);
690
691        // HTTP/1.0 and extra inter-token whitespace are still well-formed.
692        let target = parse_connect_request("CONNECT  example.com:8443  HTTP/1.0\r\n\r\n")
693            .expect("HTTP/1.0 with wide spacing parses");
694        assert_eq!(target.host, "example.com");
695        assert_eq!(target.port, 8443);
696    }
697
698    #[test]
699    fn egress_proxy_connect_parsing_refuses_malformed() {
700        for head in [
701            // Not CONNECT at all (plain-HTTP forward is NOT v1).
702            "GET http://example.com/ HTTP/1.1\r\n\r\n",
703            "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n",
704            // Missing / extra tokens.
705            "CONNECT example.com:443\r\n\r\n",
706            "CONNECT example.com:443 HTTP/1.1 EXTRA\r\n\r\n",
707            "CONNECT\r\n\r\n",
708            // Authority without a port, with a bad port, with port 0.
709            "CONNECT example.com HTTP/1.1\r\n\r\n",
710            "CONNECT example.com:notaport HTTP/1.1\r\n\r\n",
711            "CONNECT example.com:0 HTTP/1.1\r\n\r\n",
712            "CONNECT example.com:99999 HTTP/1.1\r\n\r\n",
713            // Empty host / path-shaped authority / IPv6 literal.
714            "CONNECT :443 HTTP/1.1\r\n\r\n",
715            "CONNECT example.com/x:443 HTTP/1.1\r\n\r\n",
716            "CONNECT [::1]:443 HTTP/1.1\r\n\r\n",
717            // Not HTTP.
718            "CONNECT example.com:443 FTP/2\r\n\r\n",
719            "",
720        ] {
721            assert!(
722                parse_connect_request(head).is_none(),
723                "malformed CONNECT must be refused: {head:?}"
724            );
725        }
726    }
727
728    // -- Allowlist ----------------------------------------------------------
729
730    #[test]
731    fn egress_proxy_allowlist_matching() {
732        let entries = parse_allowlist(&[
733            "api.anthropic.com:443".to_string(),
734            "*.anthropic.com:443".to_string(),
735            "127.0.0.1:8080".to_string(),
736        ])
737        .unwrap();
738
739        assert!(entries.iter().any(|e| e.matches("api.anthropic.com", 443)));
740        // Case-insensitive host compare.
741        assert!(entries.iter().any(|e| e.matches("API.Anthropic.COM", 443)));
742        // Wildcard covers subdomains at any depth, but not the apex.
743        assert!(entries.iter().any(|e| e.matches("x.anthropic.com", 443)));
744        assert!(entries.iter().any(|e| e.matches("a.b.anthropic.com", 443)));
745        assert!(!entries.iter().any(|e| e.matches("anthropic.com", 443)));
746        assert!(!entries.iter().any(|e| e.matches("notanthropic.com", 443)));
747        // Port must match.
748        assert!(!entries.iter().any(|e| e.matches("api.anthropic.com", 8443)));
749        // IP literal exact entry.
750        assert!(entries.iter().any(|e| e.matches("127.0.0.1", 8080)));
751        assert!(!entries.iter().any(|e| e.matches("127.0.0.1", 8081)));
752    }
753
754    #[test]
755    fn egress_proxy_allowlist_defaults_port_443_and_rejects_bad_entries() {
756        let entries = parse_allowlist(&["example.com".to_string()]).unwrap();
757        assert!(entries.iter().any(|e| e.matches("example.com", 443)));
758        assert!(!entries.iter().any(|e| e.matches("example.com", 80)));
759
760        for bad in [
761            "",
762            ":443",
763            "example.com:nope",
764            "example.com:0",
765            "ex ample.com:443",
766        ] {
767            assert!(
768                parse_allowlist(&[bad.to_string()]).is_err(),
769                "bad allowlist entry must fail closed: {bad:?}"
770            );
771        }
772    }
773
774    // -- Live proxy over loopback --------------------------------------------
775
776    /// Read one response head from `stream` (through the CRLF terminator).
777    async fn read_response_head(stream: &mut TcpStream) -> String {
778        let mut reader = BufReader::new(stream);
779        let mut head = String::new();
780        loop {
781            let mut line = String::new();
782            reader.read_line(&mut line).await.unwrap();
783            let done = line == "\r\n";
784            head.push_str(&line);
785            if done {
786                return head;
787            }
788        }
789    }
790
791    #[tokio::test]
792    async fn egress_proxy_tunnels_allowed_host() {
793        // Loopback echo server as the CONNECT target.
794        let echo = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
795        let echo_addr = echo.local_addr().unwrap();
796        tokio::spawn(async move {
797            let (mut socket, _) = echo.accept().await.unwrap();
798            let mut buf = [0u8; 64];
799            let n = socket.read(&mut buf).await.unwrap();
800            socket.write_all(&buf[..n]).await.unwrap();
801        });
802
803        let dir = tempfile::tempdir().unwrap();
804        let denial_file = dir.path().join("denials.jsonl");
805        let proxy =
806            EgressProxy::start(vec![format!("127.0.0.1:{}", echo_addr.port())], denial_file)
807                .await
808                .unwrap();
809
810        let mut client = TcpStream::connect(proxy.addr()).await.unwrap();
811        client
812            .write_all(
813                format!("CONNECT 127.0.0.1:{} HTTP/1.1\r\n\r\n", echo_addr.port()).as_bytes(),
814            )
815            .await
816            .unwrap();
817        let (read_half, mut write_half) = client.into_split();
818        let mut head_reader = BufReader::new(read_half);
819        let mut status = String::new();
820        head_reader.read_line(&mut status).await.unwrap();
821        assert!(
822            status.starts_with("HTTP/1.1 200"),
823            "allowed CONNECT gets a 200: {status}"
824        );
825        // Drain the rest of the response head.
826        loop {
827            let mut line = String::new();
828            head_reader.read_line(&mut line).await.unwrap();
829            if line == "\r\n" {
830                break;
831            }
832        }
833        write_half.write_all(b"ping-through-proxy").await.unwrap();
834        let mut buf = vec![0u8; b"ping-through-proxy".len()];
835        head_reader.read_exact(&mut buf).await.unwrap();
836        assert_eq!(buf, b"ping-through-proxy", "bytes tunnel both ways");
837
838        let denials = proxy.shutdown().await;
839        assert!(denials.is_empty(), "an allowed CONNECT records no denial");
840    }
841
842    #[tokio::test]
843    async fn egress_proxy_denied_connect_gets_403_and_fsynced_record() {
844        let dir = tempfile::tempdir().unwrap();
845        let denial_file = dir.path().join("denials.jsonl");
846        let proxy =
847            EgressProxy::start(vec!["allowed.example:443".to_string()], denial_file.clone())
848                .await
849                .unwrap();
850
851        // Denied host: 403, and the denial lands in the JSONL with ts+host+port.
852        let mut client = TcpStream::connect(proxy.addr()).await.unwrap();
853        client
854            .write_all(b"CONNECT denied.example:443 HTTP/1.1\r\n\r\n")
855            .await
856            .unwrap();
857        let head = read_response_head(&mut client).await;
858        assert!(head.starts_with("HTTP/1.1 403"), "denied CONNECT: {head}");
859
860        // A second denied CONNECT to a different host appends a second line.
861        let mut client = TcpStream::connect(proxy.addr()).await.unwrap();
862        client
863            .write_all(b"CONNECT other.example:8443 HTTP/1.1\r\n\r\n")
864            .await
865            .unwrap();
866        let head = read_response_head(&mut client).await;
867        assert!(head.starts_with("HTTP/1.1 403"), "denied CONNECT: {head}");
868
869        let denials = proxy.shutdown().await;
870        assert_eq!(
871            denials,
872            vec![
873                EgressDenial {
874                    host: "denied.example".to_string(),
875                    port: 443
876                },
877                EgressDenial {
878                    host: "other.example".to_string(),
879                    port: 8443
880                },
881            ]
882        );
883
884        // The fsynced JSONL carries the same records plus a timestamp.
885        let content = std::fs::read_to_string(&denial_file).unwrap();
886        let lines: Vec<&str> = content.lines().collect();
887        assert_eq!(lines.len(), 2, "one JSONL line per denial: {content}");
888        for (line, denial) in lines.iter().zip(denials.iter()) {
889            let record: serde_json::Value = serde_json::from_str(line).unwrap();
890            assert_eq!(record["host"], denial.host);
891            assert_eq!(record["port"], denial.port);
892            assert!(record["ts"].is_string(), "record carries ts: {line}");
893        }
894    }
895
896    #[tokio::test]
897    async fn egress_proxy_authenticated_listener_rejects_forgery_without_denial_record() {
898        let dir = tempfile::tempdir().unwrap();
899        let denial_file = dir.path().join("denials.jsonl");
900        let proxy = EgressProxy::start_authenticated_bound(
901            SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
902            vec!["allowed.example:443".to_string()],
903            denial_file.clone(),
904            "run-secret".to_string(),
905        )
906        .await
907        .unwrap();
908
909        let mut unauthenticated = TcpStream::connect(proxy.addr()).await.unwrap();
910        unauthenticated
911            .write_all(b"CONNECT forged.example:443 HTTP/1.1\r\n\r\n")
912            .await
913            .unwrap();
914        let response = read_response_head(&mut unauthenticated).await;
915        assert!(
916            response.starts_with("HTTP/1.1 407"),
917            "missing relay credential must be rejected: {response}"
918        );
919
920        let mut authenticated = TcpStream::connect(proxy.addr()).await.unwrap();
921        authenticated
922            .write_all(
923                b"CONNECT denied.example:443 HTTP/1.1\r\nProxy-Authorization: Bearer run-secret\r\n\r\n",
924            )
925            .await
926            .unwrap();
927        let response = read_response_head(&mut authenticated).await;
928        assert!(
929            response.starts_with("HTTP/1.1 403"),
930            "authenticated disallowed host reaches policy: {response}"
931        );
932
933        let denials = proxy.shutdown().await;
934        assert_eq!(
935            denials,
936            vec![EgressDenial {
937                host: "denied.example".to_string(),
938                port: 443,
939            }],
940            "the unauthenticated forged host must not become a grant signal"
941        );
942        let content = std::fs::read_to_string(denial_file).unwrap();
943        assert_eq!(content.lines().count(), 1);
944        assert!(!content.contains("forged.example"));
945    }
946
947    #[tokio::test]
948    async fn egress_proxy_malformed_connect_gets_400_and_no_record() {
949        let dir = tempfile::tempdir().unwrap();
950        let denial_file = dir.path().join("denials.jsonl");
951        let proxy =
952            EgressProxy::start(vec!["allowed.example:443".to_string()], denial_file.clone())
953                .await
954                .unwrap();
955
956        let mut client = TcpStream::connect(proxy.addr()).await.unwrap();
957        client
958            .write_all(b"GET http://example.com/ HTTP/1.1\r\n\r\n")
959            .await
960            .unwrap();
961        let head = read_response_head(&mut client).await;
962        assert!(
963            head.starts_with("HTTP/1.1 400"),
964            "malformed request: {head}"
965        );
966
967        let denials = proxy.shutdown().await;
968        assert!(
969            denials.is_empty(),
970            "a malformed request is not a policy denial"
971        );
972        assert_eq!(
973            std::fs::read_to_string(&denial_file).unwrap(),
974            "",
975            "no denial record for a malformed request"
976        );
977    }
978
979    #[tokio::test]
980    async fn egress_proxy_allowed_but_unreachable_gets_502_and_no_record() {
981        // A closed loopback port that is ON the allowlist.
982        let closed = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
983        let closed_port = closed.local_addr().unwrap().port();
984        drop(closed);
985
986        let dir = tempfile::tempdir().unwrap();
987        let denial_file = dir.path().join("denials.jsonl");
988        let proxy = EgressProxy::start(
989            vec![format!("127.0.0.1:{closed_port}")],
990            denial_file.clone(),
991        )
992        .await
993        .unwrap();
994
995        let mut client = TcpStream::connect(proxy.addr()).await.unwrap();
996        client
997            .write_all(format!("CONNECT 127.0.0.1:{closed_port} HTTP/1.1\r\n\r\n").as_bytes())
998            .await
999            .unwrap();
1000        let head = read_response_head(&mut client).await;
1001        assert!(
1002            head.starts_with("HTTP/1.1 502"),
1003            "unreachable target: {head}"
1004        );
1005
1006        let denials = proxy.shutdown().await;
1007        assert!(
1008            denials.is_empty(),
1009            "an allowed-but-unreachable target is not a policy denial"
1010        );
1011        assert_eq!(std::fs::read_to_string(&denial_file).unwrap(), "");
1012    }
1013
1014    #[tokio::test]
1015    async fn egress_proxy_bind_conflict_fails_closed() {
1016        // Occupy a loopback port; starting the proxy on it must error (the
1017        // runner turns this into a refused run, never an unenforced session).
1018        let blocker = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
1019        let occupied = blocker.local_addr().unwrap();
1020
1021        let dir = tempfile::tempdir().unwrap();
1022        let err = EgressProxy::start_bound(
1023            occupied,
1024            vec!["example.com:443".to_string()],
1025            dir.path().join("denials.jsonl"),
1026        )
1027        .await
1028        .expect_err("a bind conflict must fail closed");
1029        assert!(
1030            err.to_string().contains("failed to bind"),
1031            "bind failure is named: {err}"
1032        );
1033    }
1034
1035    // -- Runner seam ---------------------------------------------------------
1036
1037    #[tokio::test]
1038    async fn egress_proxy_maybe_start_wires_env_for_seatbelt_fs_net() {
1039        let dir = tempfile::tempdir().unwrap();
1040        let paths = temp_paths(&dir);
1041        let mut spec = fs_net_spec(SandboxBackend::Seatbelt, vec![], dir.path());
1042
1043        let proxy = maybe_start_for_session(&mut spec, &paths)
1044            .await
1045            .unwrap()
1046            .expect("seatbelt fs+net spawns a proxy");
1047        let expected = format!("http://127.0.0.1:{}", proxy.port());
1048        assert_eq!(
1049            spec.env.get(HTTPS_PROXY_ENV).map(String::as_str),
1050            Some(expected.as_str())
1051        );
1052        assert_eq!(
1053            spec.env.get(HTTP_PROXY_ENV).map(String::as_str),
1054            Some(expected.as_str())
1055        );
1056        assert_eq!(
1057            spec.env.get(NO_PROXY_ENV).map(String::as_str),
1058            Some(NO_PROXY_VALUE)
1059        );
1060        assert!(proxy.shutdown().await.unwrap().is_empty());
1061    }
1062
1063    #[tokio::test]
1064    async fn egress_proxy_maybe_start_skips_non_proxy_backends() {
1065        let dir = tempfile::tempdir().unwrap();
1066        let paths = temp_paths(&dir);
1067
1068        // No sandbox at all.
1069        let mut spec = fs_net_spec(SandboxBackend::Seatbelt, vec![], dir.path());
1070        spec.sandbox = None;
1071        assert!(maybe_start_for_session(&mut spec, &paths)
1072            .await
1073            .unwrap()
1074            .is_none());
1075        assert!(spec.env.is_empty());
1076
1077        // fs (not fs+net): no proxy.
1078        let mut spec = fs_net_spec(SandboxBackend::Seatbelt, vec![], dir.path());
1079        spec.sandbox.as_mut().unwrap().inputs.enforce = SandboxEnforce::Fs;
1080        assert!(maybe_start_for_session(&mut spec, &paths)
1081            .await
1082            .unwrap()
1083            .is_none());
1084        assert!(spec.env.is_empty());
1085
1086        // Bubblewrap fs+net: netns cannot reach a host proxy (v1 out of scope).
1087        let mut spec = fs_net_spec(SandboxBackend::Bubblewrap, vec![], dir.path());
1088        assert!(maybe_start_for_session(&mut spec, &paths)
1089            .await
1090            .unwrap()
1091            .is_none());
1092        assert!(spec.env.is_empty());
1093
1094        // Container fs+net with an EMPTY egress list keeps --network none.
1095        let mut spec = fs_net_spec(SandboxBackend::Container, vec![], dir.path());
1096        assert!(maybe_start_for_session(&mut spec, &paths)
1097            .await
1098            .unwrap()
1099            .is_none());
1100        assert!(spec.env.is_empty());
1101    }
1102
1103    #[tokio::test]
1104    async fn egress_proxy_maybe_start_failure_is_fail_closed_and_sets_no_env() {
1105        let dir = tempfile::tempdir().unwrap();
1106        let paths = temp_paths(&dir);
1107        let mut spec = fs_net_spec(SandboxBackend::Seatbelt, vec![], dir.path());
1108
1109        let err = maybe_start_for_session_with(&mut spec, &paths, |_allowlist, _denial_file| {
1110            Box::pin(async move { Err(EngineError::Backend("injected start failure".to_string())) })
1111        })
1112        .await
1113        .expect_err("a proxy start failure must error the run");
1114        let message = err.to_string();
1115        assert!(
1116            message.contains("refusing to run without enforcement"),
1117            "{message}"
1118        );
1119        assert!(message.contains("injected start failure"), "{message}");
1120        assert!(
1121            !spec.env.contains_key(HTTPS_PROXY_ENV),
1122            "a failed start must not leave proxy env behind"
1123        );
1124    }
1125}