1use 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
66pub const HTTPS_PROXY_ENV: &str = "HTTPS_PROXY";
68pub const HTTP_PROXY_ENV: &str = "HTTP_PROXY";
69pub const NO_PROXY_ENV: &str = "NO_PROXY";
70pub const NO_PROXY_VALUE: &str = "localhost,127.0.0.1";
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct EgressDenial {
77 pub host: String,
78 pub port: u16,
79}
80
81const MAX_HEAD_BYTES: usize = 8192;
83const HEAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
85
86#[derive(Debug, Clone, PartialEq, Eq)]
92struct AllowEntry {
93 host: String,
95 port: u16,
96 wildcard: bool,
97}
98
99impl AllowEntry {
100 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 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#[derive(Debug, Clone, PartialEq, Eq)]
162struct ConnectTarget {
163 host: String,
164 port: u16,
165}
166
167fn 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
192struct DenialSink {
197 file: tokio::sync::Mutex<tokio::fs::File>,
198 records: Mutex<Vec<EgressDenial>>,
199}
200
201impl DenialSink {
202 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
230pub 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 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 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 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 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 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 pub fn addr(&self) -> SocketAddr {
371 self.addr
372 }
373
374 pub fn port(&self) -> u16 {
375 self.addr.port()
376 }
377
378 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 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
489async 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
522async 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
533pub 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
565pub 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
589async 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 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 #[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 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 "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 "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 "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 "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 "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 #[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 assert!(entries.iter().any(|e| e.matches("API.Anthropic.COM", 443)));
742 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 assert!(!entries.iter().any(|e| e.matches("api.anthropic.com", 8443)));
749 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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}