From 647c7a4f8e69be610aef6cadd0de34e222dc77b4 Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Fri, 27 Feb 2026 13:43:09 -0500 Subject: [PATCH 01/14] Add Linux managed proxy routing for domain-restricted sandbox network --- Cargo.toml | 1 + docs/sandbox.md | 7 + src/linux_proxy_routing.rs | 823 +++++++++++++++++++++++++++++++++++++ src/main.rs | 2 + src/sandbox.rs | 245 +++++++++-- 5 files changed, 1044 insertions(+), 34 deletions(-) create mode 100644 src/linux_proxy_routing.rs diff --git a/Cargo.toml b/Cargo.toml index 74b95c8d..b137e3e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ toml_edit = "0.25.0" [target.'cfg(target_os = "linux")'.dependencies] landlock = "0.4.4" seccompiler = "0.5.0" +url = "2.5.8" [target.'cfg(target_os = "macos")'.dependencies] url = "2.5.8" diff --git a/docs/sandbox.md b/docs/sandbox.md index 67a821af..4dfd195d 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -76,6 +76,13 @@ Optional `bwrap` stage: - `MCP_CONSOLE_LINUX_BWRAP_NO_PROC=1` skips `/proc` mounting. - if `bwrap` is requested but unavailable, worker startup fails fast. +Managed-network behavior on Linux: + +- when network is enabled and domain restrictions are present, Linux sandbox runs in proxy-routed mode, +- proxy-routed mode requires loopback proxy env vars (`HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY`, etc.), +- in bwrap mode, sandbox networking is isolated and proxy traffic is bridged into the namespace, +- if managed proxy routing is requested but no usable loopback proxy is configured, startup fails fast. + ## Windows behavior (experimental) - R backend is supported with the same policy surface (`read-only`, `workspace-write`, `danger-full-access`). diff --git a/src/linux_proxy_routing.rs b/src/linux_proxy_routing.rs new file mode 100644 index 00000000..ac5c1a0d --- /dev/null +++ b/src/linux_proxy_routing.rs @@ -0,0 +1,823 @@ +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::fs::DirBuilder; +use std::fs::File; +use std::fs::Permissions; +use std::io; +use std::io::Read; +use std::io::Write; +use std::net::IpAddr; +use std::net::Ipv4Addr; +use std::net::SocketAddr; +use std::net::TcpListener; +use std::net::TcpStream; +use std::os::fd::FromRawFd; +use std::os::unix::fs::DirBuilderExt; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::net::UnixListener; +use std::os::unix::net::UnixStream; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; +use url::Url; + +const PROXY_ENV_KEYS: &[&str] = &[ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "FTP_PROXY", + "YARN_HTTP_PROXY", + "YARN_HTTPS_PROXY", + "NPM_CONFIG_HTTP_PROXY", + "NPM_CONFIG_HTTPS_PROXY", + "NPM_CONFIG_PROXY", + "BUNDLE_HTTP_PROXY", + "BUNDLE_HTTPS_PROXY", + "PIP_PROXY", + "DOCKER_HTTP_PROXY", + "DOCKER_HTTPS_PROXY", +]; + +const PROXY_SOCKET_DIR_PREFIX: &str = "codex-linux-sandbox-proxy-"; +const HOST_BRIDGE_READY: u8 = 1; +const LOOPBACK_INTERFACE_NAME: &[u8] = b"lo"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct ProxyRouteSpec { + routes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct ProxyRouteEntry { + env_key: String, + uds_path: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PlannedProxyRoute { + env_key: String, + endpoint: SocketAddr, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ProxyRoutePlan { + routes: Vec, + has_proxy_config: bool, +} + +pub(crate) fn prepare_host_proxy_route_spec() -> io::Result { + let env: HashMap = std::env::vars().collect(); + let plan = plan_proxy_routes(&env); + + if plan.routes.is_empty() { + let message = if plan.has_proxy_config { + "managed proxy mode requires parseable loopback proxy endpoints" + } else { + "managed proxy mode requires proxy environment variables" + }; + return Err(io::Error::new(io::ErrorKind::InvalidInput, message)); + } + + let socket_parent_dir = proxy_socket_parent_dir(); + let _ = cleanup_stale_proxy_socket_dirs_in(socket_parent_dir.as_path()); + + let socket_dir = create_proxy_socket_dir()?; + let mut socket_by_endpoint: BTreeMap = BTreeMap::new(); + let mut next_index = 0usize; + for route in &plan.routes { + if socket_by_endpoint.contains_key(&route.endpoint) { + continue; + } + let socket_path = socket_dir.join(format!("proxy-route-{next_index}.sock")); + next_index += 1; + socket_by_endpoint.insert(route.endpoint, socket_path); + } + + let mut host_bridge_pids = Vec::with_capacity(socket_by_endpoint.len()); + for (endpoint, socket_path) in &socket_by_endpoint { + host_bridge_pids.push(spawn_host_bridge(*endpoint, socket_path)?); + } + spawn_proxy_socket_dir_cleanup_worker(socket_dir, host_bridge_pids)?; + + let mut routes = Vec::with_capacity(plan.routes.len()); + for route in plan.routes { + let Some(uds_path) = socket_by_endpoint.get(&route.endpoint) else { + return Err(io::Error::other(format!( + "missing UDS path for endpoint {}", + route.endpoint + ))); + }; + routes.push(ProxyRouteEntry { + env_key: route.env_key, + uds_path: uds_path.clone(), + }); + } + + serde_json::to_string(&ProxyRouteSpec { routes }).map_err(io::Error::other) +} + +pub(crate) fn activate_proxy_routes_in_netns(serialized_spec: &str) -> io::Result<()> { + let spec: ProxyRouteSpec = serde_json::from_str(serialized_spec).map_err(io::Error::other)?; + + if spec.routes.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "proxy routing spec contained no routes", + )); + } + + let mut local_port_by_uds_path: BTreeMap = BTreeMap::new(); + for route in &spec.routes { + if local_port_by_uds_path.contains_key(&route.uds_path) { + continue; + } + let local_port = spawn_local_bridge(route.uds_path.as_path())?; + local_port_by_uds_path.insert(route.uds_path.clone(), local_port); + } + + for route in spec.routes { + let Some(local_port) = local_port_by_uds_path.get(&route.uds_path) else { + return Err(io::Error::other(format!( + "missing local bridge port for UDS path {}", + route.uds_path.display() + ))); + }; + let original_value = std::env::var(&route.env_key).map_err(|_| { + io::Error::new( + io::ErrorKind::NotFound, + format!("missing proxy env key {}", route.env_key), + ) + })?; + let Some(rewritten) = rewrite_proxy_env_value(&original_value, *local_port) else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("could not rewrite proxy URL for env key {}", route.env_key), + )); + }; + // SAFETY: this helper process is single-threaded at this point, and + // env mutation happens before execing the user command. + unsafe { + std::env::set_var(route.env_key, rewritten); + } + } + + Ok(()) +} + +fn plan_proxy_routes(env: &HashMap) -> ProxyRoutePlan { + let mut routes = Vec::new(); + let mut has_proxy_config = false; + + for (key, value) in env { + if !is_proxy_env_key(key) { + continue; + } + + let trimmed = value.trim(); + if trimmed.is_empty() { + continue; + } + has_proxy_config = true; + + let Some(endpoint) = parse_loopback_proxy_endpoint(trimmed) else { + continue; + }; + routes.push(PlannedProxyRoute { + env_key: key.clone(), + endpoint, + }); + } + + routes.sort_by(|left, right| left.env_key.cmp(&right.env_key)); + ProxyRoutePlan { + routes, + has_proxy_config, + } +} + +fn is_proxy_env_key(key: &str) -> bool { + let upper = key.to_ascii_uppercase(); + PROXY_ENV_KEYS.contains(&upper.as_str()) +} + +fn parse_loopback_proxy_endpoint(proxy_url: &str) -> Option { + let candidate = if proxy_url.contains("://") { + proxy_url.to_string() + } else { + format!("http://{proxy_url}") + }; + + let parsed = Url::parse(&candidate).ok()?; + let host = parsed.host_str()?; + if !is_loopback_host(host) { + return None; + } + + let scheme = parsed.scheme().to_ascii_lowercase(); + let port = parsed + .port() + .unwrap_or_else(|| default_proxy_port(scheme.as_str())); + if port == 0 { + return None; + } + + let ip = if host.eq_ignore_ascii_case("localhost") { + IpAddr::V4(Ipv4Addr::LOCALHOST) + } else { + host.parse::().ok()? + }; + if ip.is_loopback() { + Some(SocketAddr::new(ip, port)) + } else { + None + } +} + +fn is_loopback_host(host: &str) -> bool { + host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1" +} + +fn default_proxy_port(scheme: &str) -> u16 { + match scheme { + "https" => 443, + "socks5" | "socks5h" | "socks4" | "socks4a" => 1080, + _ => 80, + } +} + +fn rewrite_proxy_env_value(proxy_url: &str, local_port: u16) -> Option { + let had_scheme = proxy_url.contains("://"); + let candidate = if had_scheme { + proxy_url.to_string() + } else { + format!("http://{proxy_url}") + }; + + let mut parsed = Url::parse(&candidate).ok()?; + parsed.set_host(Some("127.0.0.1")).ok()?; + parsed.set_port(Some(local_port)).ok()?; + let mut rewritten = parsed.to_string(); + if !had_scheme { + rewritten = rewritten + .strip_prefix("http://") + .unwrap_or(rewritten.as_str()) + .to_string(); + } + if !proxy_url.ends_with('/') + && !proxy_url.contains('?') + && !proxy_url.contains('#') + && rewritten.ends_with('/') + { + rewritten.pop(); + } + Some(rewritten) +} + +fn create_proxy_socket_dir() -> io::Result { + let temp_dir = proxy_socket_parent_dir(); + let pid = std::process::id(); + for attempt in 0..128 { + let candidate = temp_dir.join(format!("{PROXY_SOCKET_DIR_PREFIX}{pid}-{attempt}")); + // The bridge UDS paths live under a shared temp root, so the per-run + // directory should not be traversable by other processes. + let mut dir_builder = DirBuilder::new(); + dir_builder.mode(0o700); + match dir_builder.create(&candidate) { + Ok(()) => return Ok(candidate), + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err), + } + } + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!( + "failed to allocate proxy routing temp dir under {}", + temp_dir.display() + ), + )) +} + +fn proxy_socket_parent_dir() -> PathBuf { + if let Some(codex_home) = std::env::var_os("CODEX_HOME") { + let candidate = PathBuf::from(codex_home).join("tmp"); + if ensure_private_proxy_socket_parent_dir(candidate.as_path()).is_ok() { + return candidate; + } + } + std::env::temp_dir() +} + +fn ensure_private_proxy_socket_parent_dir(path: &Path) -> io::Result<()> { + let mut dir_builder = DirBuilder::new(); + dir_builder.recursive(true); + dir_builder.mode(0o700); + match dir_builder.create(path) { + Ok(()) => {} + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {} + Err(err) => return Err(err), + } + std::fs::set_permissions(path, Permissions::from_mode(0o700)) +} + +fn cleanup_stale_proxy_socket_dirs_in(temp_dir: &Path) -> io::Result<()> { + for entry in std::fs::read_dir(temp_dir)? { + let entry = match entry { + Ok(entry) => entry, + Err(_) => continue, + }; + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(_) => continue, + }; + if !file_type.is_dir() { + continue; + } + + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + let Some(owner_pid) = parse_proxy_socket_dir_owner_pid(file_name.as_ref()) else { + continue; + }; + if is_pid_alive(owner_pid) { + continue; + } + + let _ = cleanup_proxy_socket_dir(entry.path().as_path()); + } + + Ok(()) +} + +fn parse_proxy_socket_dir_owner_pid(file_name: &str) -> Option { + let suffix = file_name.strip_prefix(PROXY_SOCKET_DIR_PREFIX)?; + let (pid_raw, _) = suffix.split_once('-')?; + pid_raw.parse::().ok().filter(|pid| *pid != 0) +} + +fn is_pid_alive(pid: u32) -> bool { + let Ok(pid) = libc::pid_t::try_from(pid) else { + return false; + }; + is_pid_alive_raw(pid) +} + +fn is_pid_alive_raw(pid: libc::pid_t) -> bool { + // SAFETY: kill with signal 0 does not send a signal and is the POSIX liveness probe. + let status = unsafe { libc::kill(pid, 0) }; + if status == 0 { + return true; + } + let err = io::Error::last_os_error(); + !matches!(err.raw_os_error(), Some(libc::ESRCH)) +} + +fn spawn_proxy_socket_dir_cleanup_worker( + socket_dir: PathBuf, + host_bridge_pids: Vec, +) -> io::Result<()> { + // SAFETY: fork is called in this short-lived helper context before spawning user code. + let pid = unsafe { libc::fork() }; + if pid < 0 { + return Err(io::Error::last_os_error()); + } + + if pid == 0 { + loop { + if host_bridge_pids + .iter() + .copied() + .all(|bridge_pid| !is_pid_alive_raw(bridge_pid)) + { + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + + let _ = cleanup_proxy_socket_dir(socket_dir.as_path()); + // SAFETY: child process exits immediately without running Rust destructors. + unsafe { libc::_exit(0) }; + } + + Ok(()) +} + +fn cleanup_proxy_socket_dir(socket_dir: &Path) -> io::Result<()> { + for _ in 0..20 { + match std::fs::remove_dir_all(socket_dir) { + Ok(()) => return Ok(()), + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(_) => std::thread::sleep(Duration::from_millis(100)), + } + } + + match std::fs::remove_dir_all(socket_dir) { + Ok(()) => Ok(()), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err), + } +} + +fn spawn_host_bridge(endpoint: SocketAddr, uds_path: &Path) -> io::Result { + let (read_fd, write_fd) = create_ready_pipe()?; + // SAFETY: fork is called in this short-lived helper context before spawning user code. + let pid = unsafe { libc::fork() }; + if pid < 0 { + let err = io::Error::last_os_error(); + close_fd(read_fd)?; + close_fd(write_fd)?; + return Err(err); + } + + if pid == 0 { + if close_fd(read_fd).is_err() { + // SAFETY: child process exits immediately without running Rust destructors. + unsafe { libc::_exit(1) }; + } + let result = run_host_bridge(endpoint, uds_path, write_fd); + if result.is_err() { + // SAFETY: child process exits immediately without running Rust destructors. + unsafe { libc::_exit(1) }; + } + // SAFETY: child process exits immediately without running Rust destructors. + unsafe { libc::_exit(0) }; + } + + close_fd(write_fd)?; + // SAFETY: read_fd is a valid owned file descriptor from create_ready_pipe. + let mut read_file = unsafe { File::from_raw_fd(read_fd) }; + let mut ready = [0_u8; 1]; + read_file.read_exact(&mut ready)?; + if ready[0] != HOST_BRIDGE_READY { + return Err(io::Error::other( + "host bridge did not acknowledge readiness", + )); + } + Ok(pid) +} + +fn run_host_bridge(endpoint: SocketAddr, uds_path: &Path, ready_fd: libc::c_int) -> io::Result<()> { + set_parent_death_signal()?; + if uds_path.exists() { + std::fs::remove_file(uds_path)?; + } + let listener = UnixListener::bind(uds_path)?; + + // SAFETY: ready_fd is a valid owned file descriptor from create_ready_pipe. + let mut ready_file = unsafe { File::from_raw_fd(ready_fd) }; + ready_file.write_all(&[HOST_BRIDGE_READY])?; + drop(ready_file); + + loop { + let (unix_stream, _) = listener.accept()?; + std::thread::spawn(move || { + let tcp_stream = match TcpStream::connect(endpoint) { + Ok(stream) => stream, + Err(_) => return, + }; + let _ = proxy_bidirectional(tcp_stream, unix_stream); + }); + } +} + +fn spawn_local_bridge(uds_path: &Path) -> io::Result { + let (read_fd, write_fd) = create_ready_pipe()?; + // SAFETY: fork is called in this short-lived helper context before spawning user code. + let pid = unsafe { libc::fork() }; + if pid < 0 { + let err = io::Error::last_os_error(); + close_fd(read_fd)?; + close_fd(write_fd)?; + return Err(err); + } + + if pid == 0 { + if close_fd(read_fd).is_err() { + // SAFETY: child process exits immediately without running Rust destructors. + unsafe { libc::_exit(1) }; + } + let result = run_local_bridge(uds_path, write_fd); + if result.is_err() { + // SAFETY: child process exits immediately without running Rust destructors. + unsafe { libc::_exit(1) }; + } + // SAFETY: child process exits immediately without running Rust destructors. + unsafe { libc::_exit(0) }; + } + + close_fd(write_fd)?; + // SAFETY: read_fd is a valid owned file descriptor from create_ready_pipe. + let mut read_file = unsafe { File::from_raw_fd(read_fd) }; + let mut port_bytes = [0_u8; 2]; + read_file.read_exact(&mut port_bytes)?; + Ok(u16::from_be_bytes(port_bytes)) +} + +fn run_local_bridge(uds_path: &Path, ready_fd: libc::c_int) -> io::Result<()> { + set_parent_death_signal()?; + let listener = bind_local_loopback_listener()?; + let port = listener.local_addr()?.port(); + + // SAFETY: ready_fd is a valid owned file descriptor from create_ready_pipe. + let mut ready_file = unsafe { File::from_raw_fd(ready_fd) }; + ready_file.write_all(&port.to_be_bytes())?; + drop(ready_file); + + let uds_path = uds_path.to_path_buf(); + loop { + let (tcp_stream, _) = listener.accept()?; + let socket_path = uds_path.clone(); + std::thread::spawn(move || { + let unix_stream = match UnixStream::connect(socket_path) { + Ok(stream) => stream, + Err(_) => return, + }; + let _ = proxy_bidirectional(tcp_stream, unix_stream); + }); + } +} + +fn bind_local_loopback_listener() -> io::Result { + match TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) { + Ok(listener) => Ok(listener), + Err(bind_err) => { + let should_retry_after_lo_up = matches!( + bind_err.raw_os_error(), + Some(errno) if errno == libc::EADDRNOTAVAIL || errno == libc::ENETUNREACH + ); + if !should_retry_after_lo_up { + return Err(bind_err); + } + + ensure_loopback_interface_up()?; + TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + } + } +} + +fn ensure_loopback_interface_up() -> io::Result<()> { + // SAFETY: libc::socket is called with valid arguments for a datagram IPv4 socket. + let fd = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0) }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + + // SAFETY: zeroed ifreq is a valid initialization pattern. + let mut ifreq = unsafe { std::mem::zeroed::() }; + for (index, byte) in LOOPBACK_INTERFACE_NAME.iter().copied().enumerate() { + ifreq.ifr_name[index] = byte as libc::c_char; + } + + // SAFETY: fd is valid and ifreq points to writable memory. + let read_flags_result = unsafe { libc::ioctl(fd, libc::SIOCGIFFLAGS as _, &mut ifreq) }; + if read_flags_result < 0 { + let err = io::Error::last_os_error(); + let _ = close_fd(fd); + return Err(err); + } + + // SAFETY: union field is populated by SIOCGIFFLAGS. + let current_flags = unsafe { ifreq.ifr_ifru.ifru_flags }; + let up_flag = libc::IFF_UP as libc::c_short; + if (current_flags & up_flag) != up_flag { + ifreq.ifr_ifru.ifru_flags = current_flags | up_flag; + // SAFETY: fd is valid and ifreq contains the desired flag updates. + let set_flags_result = unsafe { libc::ioctl(fd, libc::SIOCSIFFLAGS as _, &ifreq) }; + if set_flags_result < 0 { + let err = io::Error::last_os_error(); + let _ = close_fd(fd); + return Err(err); + } + } + + // SAFETY: zeroed ifreq is a valid initialization pattern. + let mut addr_req = unsafe { std::mem::zeroed::() }; + for (index, byte) in LOOPBACK_INTERFACE_NAME.iter().copied().enumerate() { + addr_req.ifr_name[index] = byte as libc::c_char; + } + let loopback_addr = libc::sockaddr_in { + sin_family: libc::AF_INET as libc::sa_family_t, + sin_port: 0, + sin_addr: libc::in_addr { + s_addr: libc::htonl(libc::INADDR_LOOPBACK), + }, + sin_zero: [0; 8], + }; + // SAFETY: sockaddr_in can be reinterpreted as sockaddr for ifreq assignment. + unsafe { + addr_req.ifr_ifru.ifru_addr = + *(&loopback_addr as *const libc::sockaddr_in as *const libc::sockaddr); + } + // SAFETY: fd is valid and addr_req contains loopback address. + let set_addr_result = unsafe { libc::ioctl(fd, libc::SIOCSIFADDR as _, &addr_req) }; + if set_addr_result < 0 { + let err = io::Error::last_os_error(); + let allow_existing_or_immutable_addr = + matches!(err.raw_os_error(), Some(libc::EEXIST | libc::EPERM)); + if !allow_existing_or_immutable_addr { + let _ = close_fd(fd); + return Err(err); + } + } + + close_fd(fd) +} + +fn set_parent_death_signal() -> io::Result<()> { + // SAFETY: PR_SET_PDEATHSIG configures process behavior and does not access invalid memory. + let res = unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) }; + if res != 0 { + Err(io::Error::last_os_error()) + } else { + // SAFETY: getppid has no preconditions. + let parent_pid = unsafe { libc::getppid() }; + if parent_pid == 1 { + Err(io::Error::other("parent process already exited")) + } else { + Ok(()) + } + } +} + +fn proxy_bidirectional(mut tcp_stream: TcpStream, mut unix_stream: UnixStream) -> io::Result<()> { + let mut tcp_reader = tcp_stream.try_clone()?; + let mut unix_writer = unix_stream.try_clone()?; + let tcp_to_unix = std::thread::spawn(move || std::io::copy(&mut tcp_reader, &mut unix_writer)); + let unix_to_tcp = std::io::copy(&mut unix_stream, &mut tcp_stream); + let tcp_to_unix = tcp_to_unix + .join() + .map_err(|_| io::Error::other("bridge thread panicked"))?; + tcp_to_unix?; + unix_to_tcp?; + Ok(()) +} + +fn create_ready_pipe() -> io::Result<(libc::c_int, libc::c_int)> { + let mut pipe_fds = [0; 2]; + // SAFETY: pipe2 writes exactly two fds into the provided array. + let res = unsafe { libc::pipe2(pipe_fds.as_mut_ptr(), libc::O_CLOEXEC) }; + if res != 0 { + return Err(io::Error::last_os_error()); + } + Ok((pipe_fds[0], pipe_fds[1])) +} + +fn close_fd(fd: libc::c_int) -> io::Result<()> { + // SAFETY: fd was created/opened by this process. + let res = unsafe { libc::close(fd) }; + if res < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::PROXY_SOCKET_DIR_PREFIX; + use super::ProxyRouteEntry; + use super::ProxyRouteSpec; + use super::cleanup_proxy_socket_dir; + use super::cleanup_stale_proxy_socket_dirs_in; + use super::default_proxy_port; + use super::is_proxy_env_key; + use super::parse_loopback_proxy_endpoint; + use super::parse_proxy_socket_dir_owner_pid; + use super::plan_proxy_routes; + use super::rewrite_proxy_env_value; + use std::collections::HashMap; + use std::net::SocketAddr; + use std::path::PathBuf; + + #[test] + fn recognizes_proxy_env_keys_case_insensitively() { + assert!(is_proxy_env_key("HTTP_PROXY")); + assert!(is_proxy_env_key("http_proxy")); + assert!(!is_proxy_env_key("PATH")); + } + + #[test] + fn parses_loopback_proxy_endpoint() { + let endpoint = parse_loopback_proxy_endpoint("http://127.0.0.1:43128"); + assert_eq!( + endpoint, + Some( + "127.0.0.1:43128" + .parse::() + .expect("valid socket") + ) + ); + } + + #[test] + fn ignores_non_loopback_proxy_endpoint() { + assert_eq!( + parse_loopback_proxy_endpoint("http://example.com:3128"), + None + ); + } + + #[test] + fn plan_proxy_routes_only_includes_valid_loopback_endpoints() { + let mut env = HashMap::new(); + env.insert( + "HTTP_PROXY".to_string(), + "http://127.0.0.1:43128".to_string(), + ); + env.insert( + "HTTPS_PROXY".to_string(), + "http://example.com:3128".to_string(), + ); + env.insert("PATH".to_string(), "/usr/bin".to_string()); + + let plan = plan_proxy_routes(&env); + assert!(plan.has_proxy_config); + assert_eq!(plan.routes.len(), 1); + assert_eq!(plan.routes[0].env_key, "HTTP_PROXY"); + assert_eq!( + plan.routes[0].endpoint, + "127.0.0.1:43128" + .parse::() + .expect("valid socket") + ); + } + + #[test] + fn rewrites_proxy_url_to_local_loopback_port() { + let rewritten = + rewrite_proxy_env_value("socks5h://127.0.0.1:8081", 43210).expect("rewritten value"); + assert_eq!(rewritten, "socks5h://127.0.0.1:43210"); + } + + #[test] + fn default_proxy_ports_match_expected_schemes() { + assert_eq!(default_proxy_port("http"), 80); + assert_eq!(default_proxy_port("https"), 443); + assert_eq!(default_proxy_port("socks5h"), 1080); + } + + #[test] + fn cleanup_proxy_socket_dir_removes_bridge_artifacts() { + let root = tempfile::tempdir().expect("tempdir should create"); + let socket_dir = root.path().join("codex-linux-sandbox-proxy-test"); + std::fs::create_dir(&socket_dir).expect("socket dir should create"); + let marker = socket_dir.join("bridge.sock"); + std::fs::write(&marker, b"test").expect("marker should write"); + + cleanup_proxy_socket_dir(socket_dir.as_path()).expect("cleanup should succeed"); + + assert!(!socket_dir.exists()); + } + + #[test] + fn proxy_route_spec_serialization_omits_proxy_urls() { + let spec = ProxyRouteSpec { + routes: vec![ProxyRouteEntry { + env_key: "HTTP_PROXY".to_string(), + uds_path: PathBuf::from("/tmp/proxy-route-0.sock"), + }], + }; + let serialized = serde_json::to_string(&spec).expect("proxy route spec should serialize"); + + assert_eq!( + serialized, + r#"{"routes":[{"env_key":"HTTP_PROXY","uds_path":"/tmp/proxy-route-0.sock"}]}"# + ); + } + + #[test] + fn parse_proxy_socket_dir_owner_pid_reads_owner_pid() { + assert_eq!( + parse_proxy_socket_dir_owner_pid("codex-linux-sandbox-proxy-1234-0"), + Some(1234) + ); + assert_eq!( + parse_proxy_socket_dir_owner_pid("codex-linux-sandbox-proxy-x"), + None + ); + assert_eq!(parse_proxy_socket_dir_owner_pid("not-a-proxy-dir"), None); + } + + #[test] + fn cleanup_stale_proxy_socket_dirs_removes_dead_pid_directories() { + let root = tempfile::tempdir().expect("tempdir should create"); + let dead_dir = root + .path() + .join(format!("{PROXY_SOCKET_DIR_PREFIX}{}-0", u32::MAX)); + std::fs::create_dir(&dead_dir).expect("dead dir should create"); + + let alive_dir = root + .path() + .join(format!("{PROXY_SOCKET_DIR_PREFIX}{}-1", std::process::id())); + std::fs::create_dir(&alive_dir).expect("alive dir should create"); + + let unrelated_dir = root.path().join("unrelated-proxy-dir"); + std::fs::create_dir(&unrelated_dir).expect("unrelated dir should create"); + + cleanup_stale_proxy_socket_dirs_in(root.path()).expect("stale cleanup should succeed"); + + assert!(!dead_dir.exists()); + assert!(alive_dir.exists()); + assert!(unrelated_dir.exists()); + } +} diff --git a/src/main.rs b/src/main.rs index 4997613a..f3d32c13 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,8 @@ mod html_to_markdown; mod input_protocol; mod install; mod ipc; +#[cfg(target_os = "linux")] +mod linux_proxy_routing; mod output_capture; mod output_stream; mod pager; diff --git a/src/sandbox.rs b/src/sandbox.rs index c5e196ab..5a79a77f 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -12,6 +12,8 @@ use std::process; #[cfg(target_os = "macos")] use url::Url; +#[cfg(target_os = "linux")] +use crate::linux_proxy_routing::{activate_proxy_routes_in_netns, prepare_host_proxy_route_spec}; use serde::{Deserialize, Serialize}; use tempfile::Builder; @@ -625,11 +627,14 @@ pub fn prepare_worker_command( } let policy = sanitize_linux_sandbox_policy(&policy); let command = build_command_vec(program, &args); + let allow_network_for_proxy = policy.has_full_network_access() + && state.managed_network_policy.has_domain_restrictions(); let sandbox_args = create_linux_sandbox_command_args( command, &policy, &policy_cwd, state.use_linux_sandbox_bwrap, + allow_network_for_proxy, env_var_truthy(LINUX_BWRAP_NO_PROC_ENV), ); let sandbox_program = state @@ -685,6 +690,7 @@ fn create_linux_sandbox_command_args( sandbox_policy: &SandboxPolicy, sandbox_policy_cwd: &Path, use_bwrap_sandbox: bool, + allow_network_for_proxy: bool, no_proc: bool, ) -> Vec { let sandbox_policy_cwd = sandbox_policy_cwd.to_string_lossy().to_string(); @@ -700,6 +706,9 @@ fn create_linux_sandbox_command_args( if use_bwrap_sandbox { linux_cmd.push("--use-bwrap-sandbox".to_string()); } + if allow_network_for_proxy { + linux_cmd.push("--allow-network-for-proxy".to_string()); + } if no_proc { linux_cmd.push("--no-proc".to_string()); } @@ -1124,6 +1133,8 @@ struct LinuxSandboxArgs { command: Vec, use_bwrap_sandbox: bool, apply_seccomp_then_exec: bool, + allow_network_for_proxy: bool, + proxy_route_spec: Option, no_proc: bool, } @@ -1131,9 +1142,19 @@ struct LinuxSandboxArgs { fn linux_sandbox_main_impl() -> Result<(), String> { let args = linux_sandbox_parse_args()?; if args.apply_seccomp_then_exec { + if args.allow_network_for_proxy { + let spec = args + .proxy_route_spec + .as_deref() + .ok_or_else(|| "managed proxy mode requires --proxy-route-spec".to_string())?; + activate_proxy_routes_in_netns(spec) + .map_err(|err| format!("failed to activate Linux proxy routing bridge: {err}"))?; + } linux_apply_sandbox_policy_to_current_thread( &args.sandbox_policy, &args.sandbox_policy_cwd, + args.allow_network_for_proxy, + args.allow_network_for_proxy, )?; linux_execvp(args.command)?; return Ok(()); @@ -1142,7 +1163,12 @@ fn linux_sandbox_main_impl() -> Result<(), String> { linux_exec_bwrap_sandbox(args)?; return Ok(()); } - linux_apply_sandbox_policy_to_current_thread(&args.sandbox_policy, &args.sandbox_policy_cwd)?; + linux_apply_sandbox_policy_to_current_thread( + &args.sandbox_policy, + &args.sandbox_policy_cwd, + args.allow_network_for_proxy, + false, + )?; linux_execvp(args.command)?; Ok(()) } @@ -1154,6 +1180,8 @@ fn linux_sandbox_parse_args() -> Result { let mut command: Vec = Vec::new(); let mut use_bwrap_sandbox = false; let mut apply_seccomp_then_exec = false; + let mut allow_network_for_proxy = false; + let mut proxy_route_spec: Option = None; let mut no_proc = false; let mut args = std::env::args_os().skip(1).peekable(); @@ -1166,6 +1194,20 @@ fn linux_sandbox_parse_args() -> Result { apply_seccomp_then_exec = true; continue; } + if arg == "--allow-network-for-proxy" { + allow_network_for_proxy = true; + continue; + } + if arg == "--proxy-route-spec" { + let value = args + .next() + .ok_or_else(|| "missing value for --proxy-route-spec".to_string())?; + let value = value + .into_string() + .map_err(|_| "--proxy-route-spec must be valid UTF-8".to_string())?; + proxy_route_spec = Some(value); + continue; + } if arg == "--no-proc" { no_proc = true; continue; @@ -1210,6 +1252,8 @@ fn linux_sandbox_parse_args() -> Result { command, use_bwrap_sandbox, apply_seccomp_then_exec, + allow_network_for_proxy, + proxy_route_spec, no_proc, }) } @@ -1232,7 +1276,10 @@ fn linux_find_bwrap_program() -> Option { } #[cfg(target_os = "linux")] -fn linux_build_inner_seccomp_command(args: &LinuxSandboxArgs) -> Result, String> { +fn linux_build_inner_seccomp_command( + args: &LinuxSandboxArgs, + proxy_route_spec: Option, +) -> Result, String> { let current_exe = std::env::current_exe().map_err(|err| err.to_string())?; let policy = sanitize_linux_sandbox_policy(&args.sandbox_policy); let policy_json = serde_json::to_string(&policy).map_err(|err| err.to_string())?; @@ -1243,8 +1290,15 @@ fn linux_build_inner_seccomp_command(args: &LinuxSandboxArgs) -> Result Result Result<(), String> { let bwrap_program = linux_find_bwrap_program() .ok_or_else(|| "bwrap executable not found (tried /usr/bin/bwrap and PATH)".to_string())?; - let inner = linux_build_inner_seccomp_command(&args)?; + let proxy_route_spec = if args.allow_network_for_proxy { + Some( + prepare_host_proxy_route_spec() + .map_err(|err| format!("failed to prepare Linux proxy routing bridge: {err}"))?, + ) + } else { + None + }; + let inner = linux_build_inner_seccomp_command(&args, proxy_route_spec)?; let mount_proc = !args.no_proc && linux_bwrap_supports_proc_mount( bwrap_program.as_path(), &args.sandbox_policy, &args.sandbox_policy_cwd, + args.allow_network_for_proxy, ); let bwrap_args = create_linux_bwrap_command_args( inner, &args.sandbox_policy, &args.sandbox_policy_cwd, + args.allow_network_for_proxy, mount_proc, )?; let mut full_command = Vec::with_capacity(1 + bwrap_args.len()); @@ -1282,6 +1346,7 @@ fn linux_bwrap_supports_proc_mount( bwrap_program: &Path, sandbox_policy: &SandboxPolicy, sandbox_policy_cwd: &Path, + allow_network_for_proxy: bool, ) -> bool { let true_path = if Path::new("/usr/bin/true").is_file() { "/usr/bin/true" @@ -1294,6 +1359,7 @@ fn linux_bwrap_supports_proc_mount( vec![true_path.to_string()], sandbox_policy, sandbox_policy_cwd, + allow_network_for_proxy, true, ) { Ok(args) => args, @@ -1321,6 +1387,7 @@ fn create_linux_bwrap_command_args( command: Vec, sandbox_policy: &SandboxPolicy, sandbox_policy_cwd: &Path, + allow_network_for_proxy: bool, mount_proc: bool, ) -> Result, String> { let sandbox_policy = sanitize_linux_sandbox_policy(sandbox_policy); @@ -1332,7 +1399,7 @@ fn create_linux_bwrap_command_args( "--new-session".to_string(), "--unshare-pid".to_string(), ]; - if !sandbox_policy.has_full_network_access() { + if allow_network_for_proxy || !sandbox_policy.has_full_network_access() { bwrap_args.push("--unshare-net".to_string()); } if mount_proc { @@ -1483,13 +1550,21 @@ fn is_proc_mount_failure(stderr: &str) -> bool { fn linux_apply_sandbox_policy_to_current_thread( sandbox_policy: &SandboxPolicy, cwd: &Path, + allow_network_for_proxy: bool, + proxy_routed_network: bool, ) -> Result<(), String> { - if !sandbox_policy.has_full_disk_write_access() || !sandbox_policy.has_full_network_access() { + let network_seccomp_mode = linux_network_seccomp_mode( + sandbox_policy, + allow_network_for_proxy, + proxy_routed_network, + ); + + if !sandbox_policy.has_full_disk_write_access() || network_seccomp_mode.is_some() { linux_set_no_new_privs()?; } - if !sandbox_policy.has_full_network_access() { - linux_install_network_seccomp_filter_on_current_thread()?; + if let Some(mode) = network_seccomp_mode { + linux_install_network_seccomp_filter_on_current_thread(mode)?; } if !sandbox_policy.has_full_disk_write_access() { @@ -1500,6 +1575,36 @@ fn linux_apply_sandbox_policy_to_current_thread( Ok(()) } +#[cfg(target_os = "linux")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LinuxNetworkSeccompMode { + Restricted, + ProxyRouted, +} + +#[cfg(target_os = "linux")] +fn linux_should_install_network_seccomp( + sandbox_policy: &SandboxPolicy, + allow_network_for_proxy: bool, +) -> bool { + !sandbox_policy.has_full_network_access() || allow_network_for_proxy +} + +#[cfg(target_os = "linux")] +fn linux_network_seccomp_mode( + sandbox_policy: &SandboxPolicy, + allow_network_for_proxy: bool, + proxy_routed_network: bool, +) -> Option { + if !linux_should_install_network_seccomp(sandbox_policy, allow_network_for_proxy) { + None + } else if proxy_routed_network { + Some(LinuxNetworkSeccompMode::ProxyRouted) + } else { + Some(LinuxNetworkSeccompMode::Restricted) + } +} + #[cfg(target_os = "linux")] fn linux_set_no_new_privs() -> Result<(), String> { let result = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) }; @@ -1575,7 +1680,9 @@ fn linux_install_filesystem_landlock_rules_on_current_thread( } #[cfg(target_os = "linux")] -fn linux_install_network_seccomp_filter_on_current_thread() -> Result<(), String> { +fn linux_install_network_seccomp_filter_on_current_thread( + mode: LinuxNetworkSeccompMode, +) -> Result<(), String> { use seccompiler::{ BpfProgram, SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter, SeccompRule, TargetArch, apply_filter, @@ -1587,37 +1694,74 @@ fn linux_install_network_seccomp_filter_on_current_thread() -> Result<(), String rules.insert(nr, vec![]); }; - deny_syscall(libc::SYS_connect); - deny_syscall(libc::SYS_accept); - deny_syscall(libc::SYS_accept4); - deny_syscall(libc::SYS_bind); - deny_syscall(libc::SYS_listen); - deny_syscall(libc::SYS_getpeername); - deny_syscall(libc::SYS_getsockname); - deny_syscall(libc::SYS_shutdown); - deny_syscall(libc::SYS_sendto); - deny_syscall(libc::SYS_sendmmsg); - deny_syscall(libc::SYS_recvmmsg); - deny_syscall(libc::SYS_getsockopt); - deny_syscall(libc::SYS_setsockopt); deny_syscall(libc::SYS_ptrace); deny_syscall(libc::SYS_io_uring_setup); deny_syscall(libc::SYS_io_uring_enter); deny_syscall(libc::SYS_io_uring_register); - let unix_only_rule = SeccompRule::new(vec![ - SeccompCondition::new( - 0, - SeccompCmpArgLen::Dword, - SeccompCmpOp::Ne, - libc::AF_UNIX as u64, - ) - .map_err(|err| err.to_string())?, - ]) - .map_err(|err| err.to_string())?; + match mode { + LinuxNetworkSeccompMode::Restricted => { + deny_syscall(libc::SYS_connect); + deny_syscall(libc::SYS_accept); + deny_syscall(libc::SYS_accept4); + deny_syscall(libc::SYS_bind); + deny_syscall(libc::SYS_listen); + deny_syscall(libc::SYS_getpeername); + deny_syscall(libc::SYS_getsockname); + deny_syscall(libc::SYS_shutdown); + deny_syscall(libc::SYS_sendto); + deny_syscall(libc::SYS_sendmmsg); + deny_syscall(libc::SYS_recvmmsg); + deny_syscall(libc::SYS_getsockopt); + deny_syscall(libc::SYS_setsockopt); + + let unix_only_rule = SeccompRule::new(vec![ + SeccompCondition::new( + 0, + SeccompCmpArgLen::Dword, + SeccompCmpOp::Ne, + libc::AF_UNIX as u64, + ) + .map_err(|err| err.to_string())?, + ]) + .map_err(|err| err.to_string())?; - rules.insert(libc::SYS_socket, vec![unix_only_rule.clone()]); - rules.insert(libc::SYS_socketpair, vec![unix_only_rule]); + rules.insert(libc::SYS_socket, vec![unix_only_rule.clone()]); + rules.insert(libc::SYS_socketpair, vec![unix_only_rule]); + } + LinuxNetworkSeccompMode::ProxyRouted => { + let deny_non_ip_socket = SeccompRule::new(vec![ + SeccompCondition::new( + 0, + SeccompCmpArgLen::Dword, + SeccompCmpOp::Ne, + libc::AF_INET as u64, + ) + .map_err(|err| err.to_string())?, + SeccompCondition::new( + 0, + SeccompCmpArgLen::Dword, + SeccompCmpOp::Ne, + libc::AF_INET6 as u64, + ) + .map_err(|err| err.to_string())?, + ]) + .map_err(|err| err.to_string())?; + let deny_unix_socketpair = SeccompRule::new(vec![ + SeccompCondition::new( + 0, + SeccompCmpArgLen::Dword, + SeccompCmpOp::Eq, + libc::AF_UNIX as u64, + ) + .map_err(|err| err.to_string())?, + ]) + .map_err(|err| err.to_string())?; + + rules.insert(libc::SYS_socket, vec![deny_non_ip_socket]); + rules.insert(libc::SYS_socketpair, vec![deny_unix_socketpair]); + } + } let arch = if cfg!(target_arch = "x86_64") { TargetArch::x86_64 @@ -2405,6 +2549,26 @@ mod tests { ); } + #[cfg(target_os = "linux")] + #[test] + fn linux_sandbox_command_args_include_proxy_flag_when_requested() { + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: Vec::new(), + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + let args = create_linux_sandbox_command_args( + vec!["/bin/true".to_string()], + &policy, + Path::new("/tmp"), + true, + true, + false, + ); + assert!(args.contains(&"--allow-network-for-proxy".to_string())); + } + #[cfg(target_os = "linux")] #[test] fn sandbox_state_defaults_with_environment_respects_linux_bwrap_env() { @@ -2426,4 +2590,17 @@ mod tests { "Linux bwrap env should be applied at defaults layer" ); } + + #[cfg(target_os = "linux")] + #[test] + fn linux_network_seccomp_mode_proxy_routed_for_managed_network() { + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: Vec::new(), + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + let mode = linux_network_seccomp_mode(&policy, true, true); + assert_eq!(mode, Some(LinuxNetworkSeccompMode::ProxyRouted)); + } } From 30e6cd997e397fd8ec34769d029dc3136da14955 Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Fri, 27 Feb 2026 21:07:21 -0500 Subject: [PATCH 02/14] Align managed-network sandbox behavior and Linux helper API parity --- docs/sandbox.md | 2 +- src/install.rs | 1 + src/main.rs | 9 +++ src/sandbox.rs | 137 ++++++++++++++++++++++++++++++++++++++++-- src/sandbox_cli.rs | 8 +++ src/worker_process.rs | 1 + 6 files changed, 152 insertions(+), 6 deletions(-) diff --git a/docs/sandbox.md b/docs/sandbox.md index 4dfd195d..0231d49f 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -78,7 +78,7 @@ Optional `bwrap` stage: Managed-network behavior on Linux: -- when network is enabled and domain restrictions are present, Linux sandbox runs in proxy-routed mode, +- when network is enabled and managed-network mode is enabled, Linux sandbox runs in proxy-routed mode, - proxy-routed mode requires loopback proxy env vars (`HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY`, etc.), - in bwrap mode, sandbox networking is isolated and proxy traffic is bridged into the namespace, - if managed proxy routing is requested but no usable loopback proxy is configured, startup fails fast. diff --git a/src/install.rs b/src/install.rs index 17a5f18f..814c937c 100644 --- a/src/install.rs +++ b/src/install.rs @@ -296,6 +296,7 @@ fn is_sandbox_config_override(raw: &str) -> bool { | "permissions.network.allowed_domains" | "permissions.network.denied_domains" | "permissions.network.allow_local_binding" + | "permissions.network.enabled" | "features.use_linux_sandbox_bwrap" | "use_linux_sandbox_bwrap" ) diff --git a/src/main.rs b/src/main.rs index f3d32c13..8d8c4ecc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -638,6 +638,15 @@ mod tests { )); } + #[test] + fn parse_config_override_supports_managed_network_enabled() { + let op = parse_sandbox_config_override("permissions.network.enabled=true").expect("config"); + assert!(matches!( + op, + SandboxConfigOperation::SetManagedNetworkEnabled(true) + )); + } + #[test] fn ordered_layering_last_argument_wins() { let plan = SandboxCliPlan { diff --git a/src/sandbox.rs b/src/sandbox.rs index 5a79a77f..0a81ae1e 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -79,6 +79,7 @@ impl NetworkAccess { #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ManagedNetworkPolicy { + pub enabled: bool, pub allowed_domains: Vec, pub denied_domains: Vec, pub allow_local_binding: bool, @@ -88,6 +89,10 @@ impl ManagedNetworkPolicy { pub fn has_domain_restrictions(&self) -> bool { !self.allowed_domains.is_empty() || !self.denied_domains.is_empty() } + + pub fn is_enabled(&self) -> bool { + self.enabled || self.has_domain_restrictions() + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -515,7 +520,7 @@ pub fn prepare_worker_command( ); env.insert( MANAGED_NETWORK_ENV_KEY.to_string(), - if state.managed_network_policy.has_domain_restrictions() { + if state.managed_network_policy.is_enabled() { "1".to_string() } else { "0".to_string() @@ -627,8 +632,8 @@ pub fn prepare_worker_command( } let policy = sanitize_linux_sandbox_policy(&policy); let command = build_command_vec(program, &args); - let allow_network_for_proxy = policy.has_full_network_access() - && state.managed_network_policy.has_domain_restrictions(); + let allow_network_for_proxy = + policy.has_full_network_access() && state.managed_network_policy.is_enabled(); let sandbox_args = create_linux_sandbox_command_args( command, &policy, @@ -834,6 +839,7 @@ const ALLOW_LOCAL_BINDING_ENV_KEY: &str = "ALLOW_LOCAL_BINDING"; pub fn sandbox_state_defaults_with_environment() -> SandboxState { let mut defaults = SandboxState::default(); + defaults.managed_network_policy.enabled = env_var_truthy(MANAGED_NETWORK_ENV_KEY); defaults.managed_network_policy.allow_local_binding = env_var_truthy(ALLOW_LOCAL_BINDING_ENV_KEY); #[cfg(target_os = "linux")] @@ -1141,6 +1147,7 @@ struct LinuxSandboxArgs { #[cfg(target_os = "linux")] fn linux_sandbox_main_impl() -> Result<(), String> { let args = linux_sandbox_parse_args()?; + linux_validate_inner_stage_mode(args.apply_seccomp_then_exec, args.use_bwrap_sandbox)?; if args.apply_seccomp_then_exec { if args.allow_network_for_proxy { let spec = args @@ -1173,6 +1180,17 @@ fn linux_sandbox_main_impl() -> Result<(), String> { Ok(()) } +#[cfg(target_os = "linux")] +fn linux_validate_inner_stage_mode( + apply_seccomp_then_exec: bool, + use_bwrap_sandbox: bool, +) -> Result<(), String> { + if apply_seccomp_then_exec && !use_bwrap_sandbox { + return Err("--apply-seccomp-then-exec requires --use-bwrap-sandbox".to_string()); + } + Ok(()) +} + #[cfg(target_os = "linux")] fn linux_sandbox_parse_args() -> Result { let mut sandbox_policy_cwd: Option = None; @@ -1293,8 +1311,8 @@ fn linux_build_inner_seccomp_command( ]; if args.allow_network_for_proxy { inner.push("--allow-network-for-proxy".to_string()); - } - if let Some(proxy_route_spec) = proxy_route_spec { + let proxy_route_spec = proxy_route_spec + .ok_or_else(|| "managed proxy mode requires --proxy-route-spec".to_string())?; inner.push("--proxy-route-spec".to_string()); inner.push(proxy_route_spec); } @@ -2480,6 +2498,7 @@ mod tests { fn prepare_worker_command_clears_managed_domain_env_when_lists_are_empty() { let mut state = SandboxState::default(); state.sandbox_policy = SandboxPolicy::DangerFullAccess; + state.managed_network_policy.enabled = false; state.managed_network_policy.allowed_domains = Vec::new(); state.managed_network_policy.denied_domains = Vec::new(); @@ -2513,6 +2532,53 @@ mod tests { ); } + #[cfg(target_os = "linux")] + #[test] + fn prepare_worker_command_enables_proxy_mode_when_managed_network_env_is_true() { + let previous_env = std::env::var_os(MANAGED_NETWORK_ENV_KEY); + unsafe { + std::env::set_var(MANAGED_NETWORK_ENV_KEY, "1"); + } + + let mut state = sandbox_state_defaults_with_environment(); + state.sandbox_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: Vec::new(), + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + state.managed_network_policy.allowed_domains.clear(); + state.managed_network_policy.denied_domains.clear(); + + let prepared = + prepare_worker_command(Path::new("/bin/echo"), vec!["ok".to_string()], &state) + .expect("prepare_worker_command should succeed"); + + match previous_env { + Some(value) => unsafe { + std::env::set_var(MANAGED_NETWORK_ENV_KEY, value); + }, + None => unsafe { + std::env::remove_var(MANAGED_NETWORK_ENV_KEY); + }, + } + + assert_eq!( + prepared + .env + .get(MANAGED_NETWORK_ENV_KEY) + .map(String::as_str), + Some("1"), + "managed-network marker should honor explicit environment enablement" + ); + assert!( + prepared + .args + .contains(&"--allow-network-for-proxy".to_string()), + "managed-network mode should enable proxy-routed Linux networking even without domain lists" + ); + } + #[cfg(target_os = "linux")] #[test] fn prepare_worker_command_bwrap_env_does_not_override_explicit_false() { @@ -2603,4 +2669,65 @@ mod tests { let mode = linux_network_seccomp_mode(&policy, true, true); assert_eq!(mode, Some(LinuxNetworkSeccompMode::ProxyRouted)); } + + #[cfg(target_os = "linux")] + #[test] + fn linux_validate_inner_stage_mode_rejects_apply_seccomp_without_bwrap() { + let err = + linux_validate_inner_stage_mode(true, false).expect_err("expected validation err"); + assert_eq!( + err, + "--apply-seccomp-then-exec requires --use-bwrap-sandbox" + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_build_inner_seccomp_command_requires_proxy_route_spec_in_proxy_mode() { + let args = LinuxSandboxArgs { + sandbox_policy_cwd: PathBuf::from("/tmp"), + sandbox_policy: SandboxPolicy::WorkspaceWrite { + writable_roots: Vec::new(), + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, + command: vec![std::ffi::OsString::from("/bin/true")], + use_bwrap_sandbox: true, + apply_seccomp_then_exec: false, + allow_network_for_proxy: true, + proxy_route_spec: None, + no_proc: false, + }; + + let err = + linux_build_inner_seccomp_command(&args, None).expect_err("expected missing spec"); + assert_eq!(err, "managed proxy mode requires --proxy-route-spec"); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_build_inner_seccomp_command_includes_proxy_route_spec_when_present() { + let args = LinuxSandboxArgs { + sandbox_policy_cwd: PathBuf::from("/tmp"), + sandbox_policy: SandboxPolicy::WorkspaceWrite { + writable_roots: Vec::new(), + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, + command: vec![std::ffi::OsString::from("/bin/true")], + use_bwrap_sandbox: true, + apply_seccomp_then_exec: false, + allow_network_for_proxy: true, + proxy_route_spec: None, + no_proc: false, + }; + + let inner = linux_build_inner_seccomp_command(&args, Some("spec-json".to_string())) + .expect("inner command should build"); + assert!(inner.contains(&"--allow-network-for-proxy".to_string())); + assert!(inner.contains(&"--proxy-route-spec".to_string())); + assert!(inner.contains(&"spec-json".to_string())); + } } diff --git a/src/sandbox_cli.rs b/src/sandbox_cli.rs index ac151d82..6c2ea188 100644 --- a/src/sandbox_cli.rs +++ b/src/sandbox_cli.rs @@ -35,6 +35,7 @@ pub enum SandboxConfigOperation { SetAllowedDomains(Vec), SetDeniedDomains(Vec), SetAllowLocalBinding(bool), + SetManagedNetworkEnabled(bool), SetUseLinuxSandboxBwrap(bool), } @@ -99,6 +100,9 @@ pub fn parse_sandbox_config_override(raw: &str) -> Result Ok( SandboxConfigOperation::SetAllowLocalBinding(parse_bool_value(value)?), ), + "permissions.network.enabled" => Ok(SandboxConfigOperation::SetManagedNetworkEnabled( + parse_bool_value(value)?, + )), "features.use_linux_sandbox_bwrap" => Ok(SandboxConfigOperation::SetUseLinuxSandboxBwrap( parse_bool_value(value)?, )), @@ -315,6 +319,10 @@ fn apply_config_op( state.managed_network_policy.allow_local_binding = *value; Ok(()) } + SandboxConfigOperation::SetManagedNetworkEnabled(value) => { + state.managed_network_policy.enabled = *value; + Ok(()) + } SandboxConfigOperation::SetUseLinuxSandboxBwrap(value) => { state.use_linux_sandbox_bwrap = *value; Ok(()) diff --git a/src/worker_process.rs b/src/worker_process.rs index 78abbaa2..0746358a 100644 --- a/src/worker_process.rs +++ b/src/worker_process.rs @@ -443,6 +443,7 @@ fn worker_context_event_payload( .map(|path| path.to_string_lossy().to_string()), "use_linux_sandbox_bwrap": sandbox_state.use_linux_sandbox_bwrap, "managed_network_policy": { + "enabled": sandbox_state.managed_network_policy.enabled, "allowed_domains": sandbox_state.managed_network_policy.allowed_domains.clone(), "denied_domains": sandbox_state.managed_network_policy.denied_domains.clone(), "allow_local_binding": sandbox_state.managed_network_policy.allow_local_binding, From 61cf3a7240191671e403bb105ab2a17baeeccc9a Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Fri, 27 Feb 2026 22:06:18 -0500 Subject: [PATCH 03/14] Fix Linux sandbox parity and stabilize Codex TUI snapshots --- src/sandbox.rs | 35 +++++++++++++++++++++++++++++++++-- tests/codex_approvals_tui.rs | 20 ++++++++++++-------- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/sandbox.rs b/src/sandbox.rs index 0a81ae1e..d3d11eac 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -1307,8 +1307,11 @@ fn linux_build_inner_seccomp_command( args.sandbox_policy_cwd.to_string_lossy().to_string(), "--sandbox-policy".to_string(), policy_json, - "--apply-seccomp-then-exec".to_string(), ]; + if args.use_bwrap_sandbox { + inner.push("--use-bwrap-sandbox".to_string()); + inner.push("--apply-seccomp-then-exec".to_string()); + } if args.allow_network_for_proxy { inner.push("--allow-network-for-proxy".to_string()); let proxy_route_spec = proxy_route_spec @@ -1561,7 +1564,11 @@ fn find_first_non_existent_component(target_path: &Path) -> Option { #[cfg(target_os = "linux")] fn is_proc_mount_failure(stderr: &str) -> bool { - stderr.contains("Can't mount proc") || stderr.contains("mount proc") + stderr.contains("Can't mount proc") + && stderr.contains("/newroot/proc") + && (stderr.contains("Invalid argument") + || stderr.contains("Operation not permitted") + || stderr.contains("Permission denied")) } #[cfg(target_os = "linux")] @@ -2337,6 +2344,16 @@ mod tests { use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; + #[cfg(target_os = "linux")] + use std::sync::{Mutex, OnceLock}; + + #[cfg(target_os = "linux")] + fn linux_env_test_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .expect("linux env test lock poisoned") + } #[test] fn session_temp_dir_rejects_outside_system_tmp() { @@ -2453,6 +2470,15 @@ mod tests { assert!(is_proc_mount_failure( "bwrap: Can't mount proc on /newroot/proc: Invalid argument" )); + assert!(is_proc_mount_failure( + "bwrap: Can't mount proc on /newroot/proc: Operation not permitted" + )); + assert!(is_proc_mount_failure( + "bwrap: Can't mount proc on /newroot/proc: Permission denied" + )); + assert!(!is_proc_mount_failure( + "bwrap: Can't bind mount /dev/null: Operation not permitted" + )); assert!(!is_proc_mount_failure("bwrap: unrelated failure")); } @@ -2535,6 +2561,7 @@ mod tests { #[cfg(target_os = "linux")] #[test] fn prepare_worker_command_enables_proxy_mode_when_managed_network_env_is_true() { + let _guard = linux_env_test_lock(); let previous_env = std::env::var_os(MANAGED_NETWORK_ENV_KEY); unsafe { std::env::set_var(MANAGED_NETWORK_ENV_KEY, "1"); @@ -2582,6 +2609,7 @@ mod tests { #[cfg(target_os = "linux")] #[test] fn prepare_worker_command_bwrap_env_does_not_override_explicit_false() { + let _guard = linux_env_test_lock(); let previous_env = std::env::var_os(LINUX_BWRAP_ENABLED_ENV); unsafe { std::env::set_var(LINUX_BWRAP_ENABLED_ENV, "1"); @@ -2638,6 +2666,7 @@ mod tests { #[cfg(target_os = "linux")] #[test] fn sandbox_state_defaults_with_environment_respects_linux_bwrap_env() { + let _guard = linux_env_test_lock(); let previous_env = std::env::var_os(LINUX_BWRAP_ENABLED_ENV); unsafe { std::env::set_var(LINUX_BWRAP_ENABLED_ENV, "1"); @@ -2726,6 +2755,8 @@ mod tests { let inner = linux_build_inner_seccomp_command(&args, Some("spec-json".to_string())) .expect("inner command should build"); + assert!(inner.contains(&"--use-bwrap-sandbox".to_string())); + assert!(inner.contains(&"--apply-seccomp-then-exec".to_string())); assert!(inner.contains(&"--allow-network-for-proxy".to_string())); assert!(inner.contains(&"--proxy-route-spec".to_string())); assert!(inner.contains(&"spec-json".to_string())); diff --git a/tests/codex_approvals_tui.rs b/tests/codex_approvals_tui.rs index 8dd5e450..46f0bf3f 100644 --- a/tests/codex_approvals_tui.rs +++ b/tests/codex_approvals_tui.rs @@ -363,6 +363,13 @@ tryCatch({ trimmed.chars().all(|ch| ch == '─') } + fn is_context_footer_line(line: &str) -> bool { + let trimmed = line.trim(); + !trimmed.is_empty() + && trimmed.contains('·') + && (trimmed.contains("context left") || trimmed.contains("% left")) + } + let mut lines: Vec = Vec::new(); let mut skipping_underdev_warning = false; let mut skipping_wrapped_tool_args = false; @@ -480,15 +487,12 @@ tryCatch({ lines.pop(); } - if lines.len() >= 2 { - let last_line = lines[lines.len() - 1].trim_start(); - if last_line.contains("context left") { + if matches!(lines.last(), Some(line) if is_context_footer_line(line)) { + lines.pop(); + if let Some(prev) = lines.last() + && is_prompt_line(prev) + { lines.pop(); - if let Some(prev) = lines.last() - && is_prompt_line(prev) - { - lines.pop(); - } } } From 39fb224ac78f5cc009bfecb311814b7760c7a830 Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Fri, 27 Feb 2026 22:07:12 -0500 Subject: [PATCH 04/14] Align README sandbox CLI and inherit semantics --- README.md | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index cdd1833c..243dee43 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ mcp-repl install --client claude mcp-repl install --client codex --interpreter r ``` -`install --client codex` writes `--sandbox-state inherit` by default. That sentinel means `mcp-repl` should +`install --client codex` writes `--sandbox inherit` by default. That sentinel means `mcp-repl` should inherit sandbox policy updates from Codex for the session. Example `R` REPL Codex config (paths vary by OS/user): @@ -95,10 +95,10 @@ Example `R` REPL Codex config (paths vary by OS/user): command = "/Users/alice/.cargo/bin/mcp-repl" # mcp-repl handles the primary timeout; this higher Codex timeout is only an outer guard. tool_timeout_sec = 1800 -# --sandbox-state inherit: use sandbox policy updates sent by Codex for this session. -# If no update is sent, mcp-repl falls back to its internal default policy. +# --sandbox inherit: use sandbox policy updates sent by Codex for this session. +# If no update is sent, mcp-repl exits with an error. args = [ - "--sandbox-state", "inherit", + "--sandbox", "inherit", "--interpreter", "r", ] ``` @@ -110,10 +110,10 @@ Example `Python` REPL Codex config: command = "/Users/alice/.cargo/bin/mcp-repl" # mcp-repl handles the primary timeout; this higher Codex timeout is only an outer guard. tool_timeout_sec = 1800 -# --sandbox-state inherit: use sandbox policy updates sent by Codex for this session. -# If no update is sent, mcp-repl falls back to its internal default policy. +# --sandbox inherit: use sandbox policy updates sent by Codex for this session. +# If no update is sent, mcp-repl exits with an error. args = [ - "--sandbox-state", "inherit", + "--sandbox", "inherit", "--interpreter", "python", ] ``` @@ -126,11 +126,11 @@ propagate sandbox state updates to MCP servers: "mcpServers": { "r_repl": { "command": "/Users/alice/.cargo/bin/mcp-repl", - "args": ["--sandbox-state", "workspace-write", "--interpreter", "r"] + "args": ["--sandbox", "workspace-write", "--interpreter", "r"] }, "py_repl": { "command": "/Users/alice/.cargo/bin/mcp-repl", - "args": ["--sandbox-state", "workspace-write", "--interpreter", "python"] + "args": ["--sandbox", "workspace-write", "--interpreter", "python"] } } } @@ -220,9 +220,12 @@ Tool guides: ## Docs -- Tool behavior and usage guidance: +Tool behavior and usage guidance: - `docs/tool-descriptions/repl_tool_r.md` - `docs/tool-descriptions/repl_tool_python.md` +- `docs/tool-descriptions/repl_reset_tool.md` + +Additional references: - Sandbox behavior and configuration: `docs/sandbox.md` - Worker sideband protocol: `docs/worker_sideband_protocol.md` From b79eb06ff11dba419bad581c6df4cb8130155c90 Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Fri, 27 Feb 2026 22:47:50 -0500 Subject: [PATCH 05/14] WIP readme --- README.md | 99 ++++++++++++++++++++++--------------------------------- 1 file changed, 40 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 243dee43..7514f7e7 100644 --- a/README.md +++ b/README.md @@ -2,45 +2,58 @@ `mcp-repl` is an MCP server that exposes a long-lived interactive REPL runtime over stdio. -It is backend-agnostic in design. The default interpreter is R, with an opt-in Python interpreter (`--interpreter python`). +It is designed to accelerate LLMs at tasks like EDA (Exploratory data analysis) and debugging of interperted code (like R or Python). The REPL gives the LLM a tight feed-back loop for exploration and development, just like it does for a human. -Session state persists across calls, so agents can iterate in place, inspect intermediate values, debug, and read docs in-band. +Unlike a regular REPL, `mcp-repl` presents an interface that is tailored to the +strengths and weaknesses of an LLM. It is chock-full of affordances designed for agents, giving them access to the same affordances as a repl does a human, but with much more token efficient way, and with the safety of a process sandbox. -## Why use it -- Stateful REPL execution in one long-lived process. -- LLM-oriented output handling: prompt/echo cleanup and built-in pager mode. -- In-band docs for common help flows (`?`, `help()`, `vignette()`, `RShowDoc()`). -- Plot images returned as MCP image content. -- OS-level sandboxing by default, plus a memory resource guardrail. +It is backend-agnostic in design. It comes with built in support for R and Python. + + +## Highlights: ### Safe by default -Like a shell, R and Python are powerful. Without guardrails, an LLM can do real damage on the host (both accidental and prompt-induced). To reduce this risk, `mcp-repl` runs the backend process in a sandboxed environment. By default, network is disabled and writes are constrained to workspace roots and temp paths required by the worker. Sandbox policy is enforced with OS primitives at the process level, not command-specific runtime rules. On Unix backends, `mcp-repl` also enforces a memory resource guardrail on the child process tree and kills the worker if it exceeds the configured threshold. +The interperter runs in a sandbox. These are restrictions on the process built with OS primitives, not command-specific runtime rules. +By default, the process only has permissions to write to the current in the current directory, and network is disabled (many system calls generally are disabled). + +It is also possible to configure the sandbox policy, with extra affordances for adding additional writeable directories, or allowing a specific set of network domains that can be accessed. On Unix backends, `mcp-repl` also enforces a memory resource guardrail on the child process tree and kills the worker if it exceeds the configured threshold. + + +### Plots + +`mcp-repl` provides a private space for the LLM to easily visualize plots of data. This allows it to iterate safely and privately, without demanding your attention until it can return with a grounded, verified result. + ### Token efficient `mcp-repl` can be substantially more token efficient for an LLM than a standard persistent shell call. It includes affordances tailored to common LLM workflow strengths and weaknesses. For example: -- There is rarely a need to repeatedly poll, since the console is embedded in the backend and normally returns as soon as evaluation is complete. -- Echoed inputs are automatically pruned or elided so output is easy to attribute. +- There is rarely a need for the LLM to poll the pty, since the console is embedded in the backend and returns normally only when evaluation is complete. +- Echoed inputs are automatically pruned or elided to save context, but in a way where output is always easy to attribute to individual commands. + +- Documentation receives special handling. Built-in entry points like `?`, `help()`, `vignette()`, and `RShowDoc()` are customized to present plain text or converted Markdown in-band. - A rich pager, purpose-built for an LLM, prevents context floods while supporting search and controlled navigation. -- Documentation receives special handling. Built-in entry points like `?`, `help`, `vignette()`, and `RShowDoc()` are customized to present plain text or converted Markdown in-band, replacing the usual HTML browser flow. -### Pager + + + +#### Pager The pager activates only when output exceeds roughly one page, and scales from small multi-page outputs to hundreds of pages (for example, navigating the R manuals). It is designed to keep context focused for the model while still allowing deterministic navigation. -Internally, the pager is backed by a bounded ring buffer with an event timeline, not a naive "dump and slice" stream. That gives it predictable memory usage while still supporting strong navigation semantics: -- Output is tracked with stable offsets, so commands like `:seek` (offset/percent/line) and `:range` can jump deterministically. -- Text and image events are merged into one timeline, so pagination decisions can account for both without duplicating content. -- Already-shown ranges and images are tracked explicitly; when overlap occurs, the pager emits offset-based elision markers instead of replaying content. -- UTF-8-aware indexing keeps search and cursor movement aligned to characters while preserving exact byte offsets internally. +Internally, the pager is backed by a bounded ring buffer with an event timeline That gives it predictable memory usage and strong navigation semantics. + + +The llm can use the pager to `:seek` or jump to a `:range` with (offset/percent/line) values. If the llm jumps around, the pager _never_ shows duplicated content - instead inserts a reference back to the earlier shown content. This enalbes the llm to efficiently browse large documents without wasting context on repeated content. + +Already-shown ranges and images are tracked explicitly; when overlap occurs, the pager emits offset-based elision markers instead of replaying content. + +Text and image events are merged into one timeline, so pagination decisions can account for both without duplicating content. These affordances are all driven by observed LLM workflows and aim to reduce token waste while improving access to reference material. -### Plots -`mcp-repl` provides a private space for the LLM to easily visualize plots of data. This allows it to iterate safely and privately, without demanding your attention until it can return with a grounded, verified result. ## Quickstart @@ -67,24 +80,20 @@ This installs `mcp-repl` into Cargo’s bin directory (typically `~/.cargo/bin`) Point your MCP client at the binary (either via `PATH` or by using an explicit path like `~/.cargo/bin/mcp-repl` or `target/release/mcp-repl`). -You can auto-install into existing agent config files: +You can auto-install into existing agent config files both `R` and `python` tools: ```sh # install to all existing agent homes (does not create ~/.codex or ~/.claude) mcp-repl install +``` -# install only codex MCP config -mcp-repl install --client codex - -# install only claude MCP config -# Note: there may be some rough edges with Claude. -# This has been primarily developed and tested with Codex. -mcp-repl install --client claude +If you only want to install one interperter tool, or only for a specific client, you can specify it: -# install only one interpreter for a specific client +```sh mcp-repl install --client codex --interpreter r ``` + `install --client codex` writes `--sandbox inherit` by default. That sentinel means `mcp-repl` should inherit sandbox policy updates from Codex for the session. @@ -103,6 +112,8 @@ args = [ ] ``` +### TODO: bring back writeable root discovery for common R cache dirs + Example `Python` REPL Codex config: ```toml @@ -140,34 +151,10 @@ By default install creates one entry per supported interpreter: - `r_repl` - `py_repl` -Use `--interpreter r`, `--interpreter python`, or comma-separated/repeatable forms -to limit which interpreters are installed. - -Optional: enable rich JSONL debug logs for each `mcp-repl` startup: - -- CLI arg: `--debug-events-dir /path/to/log-dir` -- Environment: `MCP_REPL_DEBUG_EVENTS_DIR=/path/to/log-dir` - -When enabled, each startup writes a new `mcp-repl-*.jsonl` file containing startup -metadata (cwd, argv, Codex session hints) plus tool calls and sandbox state updates. - -### 3) Pick interpreter (optional) - -- Default interpreter: R -- CLI: `mcp-repl --interpreter r|python` -- Environment: `MCP_REPL_INTERPRETER=r|python` (compatibility alias: `MCP_REPL_BACKEND`) - ## Runtime discovery ### Interpreter selection order -`mcp-repl` chooses interpreter in this order: -- `--interpreter ` (if provided) -- compatibility CLI alias: `--backend ` -- `MCP_REPL_INTERPRETER` -- compatibility env alias: `MCP_REPL_BACKEND` -- default: `r` - ### R interpreter: which R installation is used - To force a specific R installation, set `R_HOME` in the environment that launches `mcp-repl`. @@ -181,16 +168,10 @@ Interpreter resolution order: - nearest `.venv/bin/python3` from current working directory upward - first executable `python3` on `PATH` - first executable `python` on `PATH` -- fallback literal `python3` Notes: - Upward `.venv` search stops at `$HOME` (inclusive) when applicable, otherwise at filesystem root. -- Python backend starts in basic REPL mode (`PYTHON_BASIC_REPL=1`) and loads `python/driver.py`. - -## Platform support -- **macOS / Linux**: supported. -- **Windows**: experimental for the R backend. The Python backend currently requires a Unix PTY and is not available on Windows. ## Sandbox From 130d3b633d3dd568c61c4511538de909feb6a940 Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Sat, 28 Feb 2026 10:19:21 -0500 Subject: [PATCH 06/14] Remove external-sandbox option --- README.md | 42 ++++++++-- docs/sandbox-config.md | 175 +++++++++++++++++++++++++++++++++++++++++ docs/sandbox.md | 7 ++ src/install.rs | 175 ++++++++++++++++++++++++++++++++++++++++- src/main.rs | 56 ++++++++++++- src/sandbox_cli.rs | 59 +++++--------- src/worker_process.rs | 22 ++++-- 7 files changed, 481 insertions(+), 55 deletions(-) create mode 100644 docs/sandbox-config.md diff --git a/README.md b/README.md index 7514f7e7..af5594b7 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,12 @@ mcp-repl install --client codex --interpreter r `install --client codex` writes `--sandbox inherit` by default. That sentinel means `mcp-repl` should inherit sandbox policy updates from Codex for the session. -Example `R` REPL Codex config (paths vary by OS/user): +For `r_repl`, install now also tries to discover the R cache root at install time and appends it +as an extra writable root. This uses: +- `dirname(tools::R_user_dir("mcp_repl_install_probe", which = "cache"))` +- If the probe fails, no extra writable root is added. + +Example `R` REPL Codex config (paths vary by OS/user and platform): ```toml [mcp_servers.r_repl] @@ -109,11 +114,10 @@ tool_timeout_sec = 1800 args = [ "--sandbox", "inherit", "--interpreter", "r", + "--add-writable-root", "/Users/alice/Library/Caches/org.R-project.R/R", ] ``` -### TODO: bring back writeable root discovery for common R cache dirs - Example `Python` REPL Codex config: ```toml @@ -137,7 +141,7 @@ propagate sandbox state updates to MCP servers: "mcpServers": { "r_repl": { "command": "/Users/alice/.cargo/bin/mcp-repl", - "args": ["--sandbox", "workspace-write", "--interpreter", "r"] + "args": ["--sandbox", "workspace-write", "--interpreter", "r", "--add-writable-root", "/Users/alice/Library/Caches/org.R-project.R/R"] }, "py_repl": { "command": "/Users/alice/.cargo/bin/mcp-repl", @@ -151,6 +155,31 @@ By default install creates one entry per supported interpreter: - `r_repl` - `py_repl` +To include additional domain allowlist entries in generated client config, pass repeatable install args: + +```sh +mcp-repl install --client codex --interpreter python \ + --arg=--sandbox --arg=workspace-write \ + --arg=--config --arg='sandbox_workspace_write.network_access=true' \ + --arg=--add-allowed-domain --arg=pypi.org \ + --arg=--add-allowed-domain --arg=files.pythonhosted.org +``` + +Equivalent manual `~/.codex/config.toml` example: + +```toml +[mcp_servers.py_repl] +command = "/Users/alice/.cargo/bin/mcp-repl" +tool_timeout_sec = 1800 +args = [ + "--sandbox", "workspace-write", + "--config", "sandbox_workspace_write.network_access=true", + "--add-allowed-domain", "pypi.org", + "--add-allowed-domain", "files.pythonhosted.org", + "--interpreter", "python", +] +``` + ## Runtime discovery ### Interpreter selection order @@ -179,7 +208,9 @@ Default sandbox policy is `workspace-write` with network disabled. Write access includes the working area and temp paths required by the worker (exact roots vary by OS/policy). On Windows, sandbox enforcement is still under active development and is not yet fully functional/reliable across environments. -See `docs/sandbox.md` for precise behavior, runtime updates, and OS-specific details. +See: +- `docs/sandbox.md` for a quick reference. +- `docs/sandbox-config.md` for detailed semantics, preset behavior, platform-specific behavior, and allowed-domain interactions. ## MCP surface @@ -208,6 +239,7 @@ Tool behavior and usage guidance: Additional references: - Sandbox behavior and configuration: `docs/sandbox.md` +- Detailed sandbox semantics and platform matrix: `docs/sandbox-config.md` - Worker sideband protocol: `docs/worker_sideband_protocol.md` ## License diff --git a/docs/sandbox-config.md b/docs/sandbox-config.md new file mode 100644 index 00000000..e4917e8b --- /dev/null +++ b/docs/sandbox-config.md @@ -0,0 +1,175 @@ +# Sandbox Configuration Semantics + +This document describes how `mcp-repl` builds effective sandbox behavior from CLI flags, +`--config` overrides, client sandbox updates, and platform-specific enforcement. + +## Scope + +`mcp-repl` tracks sandbox state in three parts: + +- `sandbox_policy`: filesystem + base network mode (`read-only`, `workspace-write`, `danger-full-access`, or `external-sandbox` via client update) +- `managed_network_policy`: managed network flags and domain lists (`allowed_domains`, `denied_domains`, `allow_local_binding`, `enabled`) +- feature flags such as `use_linux_sandbox_bwrap` + +## Effective State Resolution + +Operations are applied in argument order. + +- `--sandbox ...` sets the base mode at that point in the sequence +- `--add-writable-root` mutates the current `workspace-write` roots +- `--add-allowed-domain` appends to managed `allowed_domains` +- `--config key=value` applies one structured mutation +- Later operations win when they set the same field + +Important reset rule: + +- `--sandbox` resets the policy to the chosen base mode and default values for that mode. + +Failure rules are explicit (fail fast): + +- `--sandbox inherit` fails if no client sandbox update was provided +- invalid key/value parsing fails (for example malformed JSON in `--config`) + +Workspace-write-only operations are accepted in all modes, but apply only when the effective +policy is `workspace-write`: + +- `--add-writable-root` +- `sandbox_workspace_write.network_access` +- `sandbox_workspace_write.writable_roots` +- `sandbox_workspace_write.exclude_tmpdir_env_var` +- `sandbox_workspace_write.exclude_slash_tmp` + +## Presets and Defaults + +Server default (no sandbox flags): + +- `sandbox_policy = workspace-write` +- `sandbox_workspace_write.network_access = false` +- writable roots start empty and are expanded by runtime defaults (cwd/temp/session temp) + +Install defaults: + +- Codex install (`mcp-repl install --client codex`): injects `--sandbox inherit` unless sandbox args were explicitly supplied via `--arg` +- Claude install (`mcp-repl install --client claude`): injects `--sandbox workspace-write` unless sandbox args were explicitly supplied via `--arg` + +R install-time writable root injection: + +- For `r_repl`, installer probes `R` for the R cache root: + `dirname(tools::R_user_dir("mcp_repl_install_probe", which = "cache"))` +- If detected and absolute, installer appends `--add-writable-root ` +- Injection is skipped when explicit sandbox config is supplied via install `--arg` + +## Allowed Domains Semantics + +`allowed_domains` and `denied_domains` are policy inputs for managed networking. + +How to set them: + +- CLI append: `--add-allowed-domain ` +- Structured replace: `--config permissions.network.allowed_domains=["..."]` +- Structured deny list: `--config permissions.network.denied_domains=["..."]` + +Key behavior: + +- Domain lists do not enable network by themselves. + You still need a sandbox mode with network enabled (`workspace-write` + `sandbox_workspace_write.network_access=true`, or full-access modes). +- Domain entries are passed through as strings; `mcp-repl` does not parse wildcard/domain syntax. +- Domain restrictions are meaningful only when managed proxy routing is active and the proxy honors them. + +## Platform Matrix + +### macOS + +Enforcement: + +- Uses `sandbox-exec` policy generation. +- `workspace-write` includes: + - configured writable roots + - cwd + - temp roots (`/tmp`, `TMPDIR` if absolute) + - session temp root +- `.git`, `.codex`, and `.agents` subpaths are forced read-only when present inside writable roots. + +Network behavior: + +- If network is disabled in sandbox policy: no network. +- If network is enabled and neither proxy-managed mode nor domain restrictions are active, outbound/inbound network is allowed. +- If network is enabled and loopback proxy env vars are present, outbound is restricted to those loopback proxy endpoints. +- If managed mode/domain lists are set but no usable loopback proxy endpoint exists, policy fails closed (effectively no network). +- `ALLOW_LOCAL_BINDING=1` permits localhost bind/inbound traffic in proxy-managed mode. + +### Linux + +Enforcement: + +- Uses the Linux sandbox helper (Landlock + seccomp). +- `workspace-write` always includes session temp dir. +- `read-only` is converted to a minimal writable policy for session temp dir. +- Optional outer `bwrap` stage is controlled by `MCP_CONSOLE_USE_LINUX_BWRAP=1`. + +Network behavior: + +- If network is disabled in sandbox policy: seccomp blocks network syscalls. +- If network is enabled and managed mode/domain lists are active: + - Linux runs proxy-routed mode + - loopback proxy env vars must be present and parseable + - startup fails if proxy routing cannot be prepared +- In `bwrap` mode, proxy traffic is bridged into the namespace. + +### Windows (experimental) + +Enforcement: + +- `read-only` and `workspace-write` use the Windows sandbox runner. +- `danger-full-access` and `external-sandbox` bypass built-in sandbox enforcement. +- Python backend is currently unavailable on Windows in this project. + +Managed network and domains: + +- Domain lists are not actively enforced by the Windows runner. +- Restricted network mode uses environment-level offline/proxy poisoning (`HTTP_PROXY=127.0.0.1:9`, etc.). +- Treat allowed/denied domain lists as non-authoritative on Windows today. + +## Intersections and Gotchas + +- `--add-allowed-domain` with default `workspace-write` still yields no network unless you also enable network access. +- `--sandbox inherit` + `--add-writable-root` is portable across inherited policies; if inherited mode is not `workspace-write`, the writable-root addition is a no-op. +- `danger-full-access` ignores sandbox enforcement, so domain controls are not enforced locally by `mcp-repl`. +- Managed-network behavior is proxy-driven; if there is no managed proxy path, domain controls may not be effective. + +## Configuration Examples + +Minimal network-restricted default: + +```toml +[mcp_servers.r_repl] +command = "/Users/alice/.cargo/bin/mcp-repl" +args = [ + "--sandbox", "workspace-write", + "--interpreter", "r", +] +``` + +Enable managed-domain network for Python: + +```toml +[mcp_servers.py_repl] +command = "/Users/alice/.cargo/bin/mcp-repl" +args = [ + "--sandbox", "workspace-write", + "--config", "sandbox_workspace_write.network_access=true", + "--add-allowed-domain", "pypi.org", + "--add-allowed-domain", "files.pythonhosted.org", + "--interpreter", "python", +] +``` + +Install with the same domain settings: + +```sh +mcp-repl install --client codex --interpreter python \ + --arg=--sandbox --arg=workspace-write \ + --arg=--config --arg='sandbox_workspace_write.network_access=true' \ + --arg=--add-allowed-domain --arg=pypi.org \ + --arg=--add-allowed-domain --arg=files.pythonhosted.org +``` diff --git a/docs/sandbox.md b/docs/sandbox.md index 0231d49f..8a07f1a2 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -3,6 +3,9 @@ `mcp-repl` applies an OS sandbox to worker processes unless the sandbox policy is `danger-full-access` (or `external-sandbox`). +For a full configuration model (ordering semantics, preset install behavior, domain restrictions, +and OS/platform intersections), see `docs/sandbox-config.md`. + ## Default policy When no CLI sandbox mode is provided, the default is: @@ -32,6 +35,10 @@ The worker also gets a per-session temp directory, exported as: Operations are applied strictly in CLI argument order. Later operations win. `--sandbox ...` resets the base policy at the point where it appears. +Workspace-write-only options are accepted in any mode, but are no-ops unless effective mode is +`workspace-write`. +`--add-allowed-domain` updates managed-network policy only; it does not by itself enable network +access in `workspace-write`. ## macOS behavior diff --git a/src/install.rs b/src/install.rs index 814c937c..58ba56ad 100644 --- a/src/install.rs +++ b/src/install.rs @@ -4,6 +4,7 @@ use std::ffi::OsString; use std::fmt::Write as _; use std::fs; use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; use serde_json::{Map as JsonMap, Value as JsonValue}; use toml_edit::{Array, DocumentMut, Item, Table, value}; @@ -91,6 +92,7 @@ pub fn run(options: InstallOptions) -> Result<(), Box> { let targets = resolve_target_roots(&options.targets)?; let codex_args = codex_install_args(&options.args); let claude_args = claude_install_args(&options.args); + let user_supplied_sandbox_config = has_sandbox_config_arg(&options.args); let interpreters = effective_interpreters(&options.interpreters); let mut server_specs = Vec::new(); let mut used_server_names = std::collections::BTreeSet::new(); @@ -112,13 +114,27 @@ pub fn run(options: InstallOptions) -> Result<(), Box> { } server_specs.push((server_name, *interpreter)); } + let install_time_r_cache_root = if user_supplied_sandbox_config + || !server_specs + .iter() + .any(|(_, interpreter)| *interpreter == InstallInterpreter::R) + { + None + } else { + install_time_r_cache_root() + }; for (target, root) in targets { match target { InstallTarget::Codex => { let path = root.join("config.toml"); for (server_name, interpreter) in &server_specs { - let server_args = with_interpreter_arg(&codex_args, *interpreter); + let server_args = apply_install_time_r_writable_root( + &codex_args, + *interpreter, + user_supplied_sandbox_config, + install_time_r_cache_root.as_deref(), + ); upsert_codex_mcp_server(&path, server_name, &command, &server_args)?; } println!("Updated codex MCP config: {}", path.display()); @@ -126,7 +142,12 @@ pub fn run(options: InstallOptions) -> Result<(), Box> { InstallTarget::Claude => { let path = resolve_claude_config_path(&root); for (server_name, interpreter) in &server_specs { - let server_args = with_interpreter_arg(&claude_args, *interpreter); + let server_args = apply_install_time_r_writable_root( + &claude_args, + *interpreter, + user_supplied_sandbox_config, + install_time_r_cache_root.as_deref(), + ); upsert_claude_mcp_server(&path, server_name, &command, &server_args)?; } println!("Updated claude MCP config: {}", path.display()); @@ -343,6 +364,89 @@ fn with_interpreter_arg(base_args: &[String], interpreter: InstallInterpreter) - args } +fn apply_install_time_r_writable_root( + base_args: &[String], + interpreter: InstallInterpreter, + user_supplied_sandbox_config: bool, + r_cache_root: Option<&Path>, +) -> Vec { + let mut args = with_interpreter_arg(base_args, interpreter); + if interpreter != InstallInterpreter::R || user_supplied_sandbox_config { + return args; + } + let Some(root) = r_cache_root else { + return args; + }; + if !root.is_absolute() { + return args; + } + let root = root.to_string_lossy().to_string(); + if contains_writable_root_value(&args, root.as_str()) { + return args; + } + args.push("--add-writable-root".to_string()); + args.push(root); + args +} + +fn contains_writable_root_value(args: &[String], target: &str) -> bool { + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + if arg == "--add-writable-root" || arg == "--add-writeable-root" { + if iter.next().is_some_and(|value| value == target) { + return true; + } + continue; + } + if arg + .strip_prefix("--add-writable-root=") + .is_some_and(|value| value == target) + { + return true; + } + if arg + .strip_prefix("--add-writeable-root=") + .is_some_and(|value| value == target) + { + return true; + } + } + false +} + +fn install_time_r_cache_root() -> Option { + let output = Command::new("R") + .stdin(Stdio::null()) + .arg("-s") + .arg("-e") + .arg( + r#"cat("MCP_REPL_INSTALL_R_CACHE_ROOT=", dirname(tools::R_user_dir("mcp_repl_install_probe", which = "cache")), "\n", sep = "")"#, + ) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8(output.stdout).ok()?; + parse_r_cache_root_probe_output(&stdout) +} + +fn parse_r_cache_root_probe_output(stdout: &str) -> Option { + for line in stdout.lines() { + let Some((key, value)) = line.trim_start().split_once('=') else { + continue; + }; + if key != "MCP_REPL_INSTALL_R_CACHE_ROOT" { + continue; + } + let path = PathBuf::from(value.trim()); + if path.is_absolute() { + return Some(path); + } + } + None +} + fn effective_interpreters(configured: &[InstallInterpreter]) -> Vec { if configured.is_empty() { return vec![InstallInterpreter::R, InstallInterpreter::Python]; @@ -1030,6 +1134,73 @@ name="demo" ); } + #[test] + fn parse_r_cache_root_probe_output_extracts_absolute_path() { + let root = parse_r_cache_root_probe_output( + "noise\nMCP_REPL_INSTALL_R_CACHE_ROOT=relative/path\nMCP_REPL_INSTALL_R_CACHE_ROOT=/tmp/r-cache\n", + ); + assert_eq!(root, Some(PathBuf::from("/tmp/r-cache"))); + } + + #[test] + fn apply_install_time_r_writable_root_adds_root_for_r() { + let args = apply_install_time_r_writable_root( + &["--sandbox".to_string(), "inherit".to_string()], + InstallInterpreter::R, + false, + Some(Path::new("/tmp/r-cache")), + ); + assert_eq!( + args, + vec![ + "--sandbox".to_string(), + "inherit".to_string(), + "--interpreter".to_string(), + "r".to_string(), + "--add-writable-root".to_string(), + "/tmp/r-cache".to_string(), + ] + ); + } + + #[test] + fn apply_install_time_r_writable_root_skips_python() { + let args = apply_install_time_r_writable_root( + &["--sandbox".to_string(), "inherit".to_string()], + InstallInterpreter::Python, + false, + Some(Path::new("/tmp/r-cache")), + ); + assert_eq!( + args, + vec![ + "--sandbox".to_string(), + "inherit".to_string(), + "--interpreter".to_string(), + "python".to_string(), + ] + ); + } + + #[test] + fn apply_install_time_r_writable_root_skips_when_user_supplies_sandbox() { + let args = apply_install_time_r_writable_root( + &["--sandbox".to_string(), "inherit".to_string()], + InstallInterpreter::R, + true, + Some(Path::new("/tmp/r-cache")), + ); + assert_eq!( + args, + vec![ + "--sandbox".to_string(), + "inherit".to_string(), + "--interpreter".to_string(), + "r".to_string(), + ] + ); + } + #[test] fn resolve_home_dir_prefers_home() { let resolved = resolve_home_dir_from_env( diff --git a/src/main.rs b/src/main.rs index 8d8c4ecc..ccde8419 100644 --- a/src/main.rs +++ b/src/main.rs @@ -480,7 +480,9 @@ If no target is specified for `install`, all existing agent homes are used:\n\ - codex: $CODEX_HOME or ~/.codex\n\ - claude: ~/.claude\n\ Missing homes are not created.\n\ -If no --interpreter is specified, install uses the full interpreter grid for each selected client." +If no --interpreter is specified, install uses the full interpreter grid for each selected client.\n\ +Use repeated --arg values to include extra sandbox/network settings in generated client config entries,\n\ +for example: --arg=--add-allowed-domain --arg=pypi.org" ); } @@ -672,6 +674,58 @@ mod tests { } } + #[test] + fn add_writable_root_is_noop_when_mode_is_not_workspace_write() { + let plan = SandboxCliPlan { + operations: vec![ + SandboxCliOperation::SetMode(SandboxModeArg::ReadOnly), + SandboxCliOperation::AddWritableRoot(PathBuf::from("/tmp/ignored")), + ], + }; + let inherited = SandboxState::default(); + let resolved = resolve_effective_sandbox_state(&plan, Some(&inherited)) + .expect("effective sandbox state"); + assert_eq!(resolved.sandbox_policy, SandboxPolicy::ReadOnly); + } + + #[test] + fn workspace_write_config_is_noop_when_mode_is_not_workspace_write() { + let plan = SandboxCliPlan { + operations: vec![ + SandboxCliOperation::SetMode(SandboxModeArg::DangerFullAccess), + SandboxCliOperation::Config( + parse_sandbox_config_override("sandbox_workspace_write.network_access=true") + .expect("config override"), + ), + ], + }; + + let inherited = SandboxState::default(); + let resolved = resolve_effective_sandbox_state(&plan, Some(&inherited)) + .expect("effective sandbox state"); + assert_eq!(resolved.sandbox_policy, SandboxPolicy::DangerFullAccess); + } + + #[test] + fn workspace_write_config_is_noop_for_inherited_non_workspace_policy() { + let plan = SandboxCliPlan { + operations: vec![ + SandboxCliOperation::SetMode(SandboxModeArg::Inherit), + SandboxCliOperation::Config( + parse_sandbox_config_override( + "sandbox_workspace_write.writable_roots=[\"/tmp/ignored\"]", + ) + .expect("config override"), + ), + ], + }; + let mut inherited = SandboxState::default(); + inherited.sandbox_policy = SandboxPolicy::DangerFullAccess; + let resolved = resolve_effective_sandbox_state(&plan, Some(&inherited)) + .expect("effective sandbox state"); + assert_eq!(resolved.sandbox_policy, SandboxPolicy::DangerFullAccess); + } + #[test] fn empty_plan_uses_inherited_state_when_available() { let plan = SandboxCliPlan::default(); diff --git a/src/sandbox_cli.rs b/src/sandbox_cli.rs index 6c2ea188..b18cddee 100644 --- a/src/sandbox_cli.rs +++ b/src/sandbox_cli.rs @@ -173,15 +173,10 @@ pub fn resolve_effective_sandbox_state_with_defaults( apply_mode(&mut state, *mode, inherited, defaults)? } SandboxCliOperation::AddWritableRoot(path) => { - let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = + if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut state.sandbox_policy - else { - return Err( - "--add-writable-root can only be used while sandbox mode is workspace-write" - .to_string(), - ); - }; - if !writable_roots.iter().any(|root| root == path) { + && !writable_roots.iter().any(|root| root == path) + { writable_roots.push(path.clone()); } } @@ -253,58 +248,42 @@ fn apply_config_op( match op { SandboxConfigOperation::SetMode(mode) => apply_mode(state, *mode, inherited, defaults), SandboxConfigOperation::SetWorkspaceNetworkAccess(network_access) => { - let SandboxPolicy::WorkspaceWrite { + if let SandboxPolicy::WorkspaceWrite { network_access: current, .. } = &mut state.sandbox_policy - else { - return Err( - "sandbox_workspace_write.network_access requires workspace-write mode" - .to_string(), - ); - }; - *current = *network_access; + { + *current = *network_access; + } Ok(()) } SandboxConfigOperation::SetWorkspaceWritableRoots(roots) => { - let SandboxPolicy::WorkspaceWrite { + if let SandboxPolicy::WorkspaceWrite { writable_roots: current, .. } = &mut state.sandbox_policy - else { - return Err( - "sandbox_workspace_write.writable_roots requires workspace-write mode" - .to_string(), - ); - }; - *current = roots.clone(); + { + *current = roots.clone(); + } Ok(()) } SandboxConfigOperation::SetWorkspaceExcludeTmpdirEnvVar(value) => { - let SandboxPolicy::WorkspaceWrite { + if let SandboxPolicy::WorkspaceWrite { exclude_tmpdir_env_var, .. } = &mut state.sandbox_policy - else { - return Err( - "sandbox_workspace_write.exclude_tmpdir_env_var requires workspace-write mode" - .to_string(), - ); - }; - *exclude_tmpdir_env_var = *value; + { + *exclude_tmpdir_env_var = *value; + } Ok(()) } SandboxConfigOperation::SetWorkspaceExcludeSlashTmp(value) => { - let SandboxPolicy::WorkspaceWrite { + if let SandboxPolicy::WorkspaceWrite { exclude_slash_tmp, .. } = &mut state.sandbox_policy - else { - return Err( - "sandbox_workspace_write.exclude_slash_tmp requires workspace-write mode" - .to_string(), - ); - }; - *exclude_slash_tmp = *value; + { + *exclude_slash_tmp = *value; + } Ok(()) } SandboxConfigOperation::SetAllowedDomains(values) => { diff --git a/src/worker_process.rs b/src/worker_process.rs index 0746358a..66a8644e 100644 --- a/src/worker_process.rs +++ b/src/worker_process.rs @@ -3648,7 +3648,7 @@ mod tests { } #[test] - fn failed_sandbox_update_does_not_commit_inherited_state() { + fn workspace_write_overrides_are_noop_for_non_workspace_inherited_state() { let _guard = cwd_test_mutex().lock().expect("cwd mutex"); let original_initial = std::env::var_os(crate::sandbox::INITIAL_SANDBOX_STATE_ENV); let initial = serde_json::json!({ @@ -3681,7 +3681,7 @@ mod tests { .clone() .expect("inherited state should be present"); - let err = manager + let changed = manager .update_sandbox_state( SandboxStateUpdate { sandbox_policy: SandboxPolicy::DangerFullAccess, @@ -3691,15 +3691,23 @@ mod tests { }, Duration::from_millis(1), ) - .expect_err("danger-full-access should fail workspace-write-only config"); + .expect("danger-full-access should succeed; workspace-write-only override is no-op"); assert!( - matches!(err, WorkerError::Sandbox(ref msg) if msg.contains("requires workspace-write mode")), - "unexpected error: {err}" + changed, + "sandbox state should change after inherited update" ); assert_eq!( manager.inherited_sandbox_state, - Some(inherited_before), - "failed updates must not mutate inherited sandbox baseline" + Some(SandboxState { + sandbox_policy: SandboxPolicy::DangerFullAccess, + ..inherited_before.clone() + }), + "inherited sandbox baseline should commit successful updates" + ); + assert_eq!( + manager.sandbox_state.sandbox_policy, + SandboxPolicy::DangerFullAccess, + "workspace-write-only override should be ignored for non-workspace policy" ); match original_initial { From e963dfc55d13c51955526828df90bb110ebfc4e2 Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Sat, 28 Feb 2026 10:26:01 -0500 Subject: [PATCH 07/14] Align sandbox config docs --- docs/sandbox-config.md | 4 ++-- docs/sandbox.md | 4 ++-- src/sandbox.rs | 30 +----------------------------- src/windows_sandbox.rs | 11 +---------- 4 files changed, 6 insertions(+), 43 deletions(-) diff --git a/docs/sandbox-config.md b/docs/sandbox-config.md index e4917e8b..411847fd 100644 --- a/docs/sandbox-config.md +++ b/docs/sandbox-config.md @@ -7,7 +7,7 @@ This document describes how `mcp-repl` builds effective sandbox behavior from CL `mcp-repl` tracks sandbox state in three parts: -- `sandbox_policy`: filesystem + base network mode (`read-only`, `workspace-write`, `danger-full-access`, or `external-sandbox` via client update) +- `sandbox_policy`: filesystem + base network mode (`read-only`, `workspace-write`, or `danger-full-access`) - `managed_network_policy`: managed network flags and domain lists (`allowed_domains`, `denied_domains`, `allow_local_binding`, `enabled`) - feature flags such as `use_linux_sandbox_bwrap` @@ -121,7 +121,7 @@ Network behavior: Enforcement: - `read-only` and `workspace-write` use the Windows sandbox runner. -- `danger-full-access` and `external-sandbox` bypass built-in sandbox enforcement. +- `danger-full-access` bypasses built-in sandbox enforcement. - Python backend is currently unavailable on Windows in this project. Managed network and domains: diff --git a/docs/sandbox.md b/docs/sandbox.md index 8a07f1a2..7772d338 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -1,7 +1,7 @@ # Sandbox `mcp-repl` applies an OS sandbox to worker processes unless the sandbox policy is -`danger-full-access` (or `external-sandbox`). +`danger-full-access`. For a full configuration model (ordering semantics, preset install behavior, domain restrictions, and OS/platform intersections), see `docs/sandbox-config.md`. @@ -95,5 +95,5 @@ Managed-network behavior on Linux: - R backend is supported with the same policy surface (`read-only`, `workspace-write`, `danger-full-access`). - Python backend is currently unavailable on Windows (it requires a Unix PTY). - `read-only` and `workspace-write` are enforced by the Windows sandbox runner. -- `danger-full-access` and `external-sandbox` run without built-in sandbox enforcement. +- `danger-full-access` runs without built-in sandbox enforcement. - Some Windows environments may not support the restricted-token setup required by sandboxed modes. diff --git a/src/sandbox.rs b/src/sandbox.rs index d3d11eac..5ff86eec 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -63,20 +63,6 @@ impl std::fmt::Display for SandboxError { impl std::error::Error for SandboxError {} -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "kebab-case")] -pub enum NetworkAccess { - #[default] - Restricted, - Enabled, -} - -impl NetworkAccess { - pub fn is_enabled(self) -> bool { - matches!(self, NetworkAccess::Enabled) - } -} - #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ManagedNetworkPolicy { pub enabled: bool, @@ -102,11 +88,6 @@ pub enum SandboxPolicy { DangerFullAccess, #[serde(rename = "read-only")] ReadOnly, - #[serde(rename = "external-sandbox")] - ExternalSandbox { - #[serde(default)] - network_access: NetworkAccess, - }, #[serde(rename = "workspace-write")] WorkspaceWrite { #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -132,7 +113,6 @@ impl SandboxPolicy { pub fn has_full_disk_write_access(&self) -> bool { match self { SandboxPolicy::DangerFullAccess => true, - SandboxPolicy::ExternalSandbox { .. } => true, SandboxPolicy::ReadOnly => false, SandboxPolicy::WorkspaceWrite { .. } => false, } @@ -142,7 +122,6 @@ impl SandboxPolicy { pub fn has_full_disk_read_access(&self) -> bool { match self { SandboxPolicy::DangerFullAccess => true, - SandboxPolicy::ExternalSandbox { .. } => true, SandboxPolicy::ReadOnly => true, SandboxPolicy::WorkspaceWrite { .. } => true, } @@ -151,17 +130,13 @@ impl SandboxPolicy { pub fn has_full_network_access(&self) -> bool { match self { SandboxPolicy::DangerFullAccess => true, - SandboxPolicy::ExternalSandbox { network_access } => network_access.is_enabled(), SandboxPolicy::ReadOnly => false, SandboxPolicy::WorkspaceWrite { network_access, .. } => *network_access, } } pub fn requires_sandbox(&self) -> bool { - !matches!( - self, - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } - ) + !matches!(self, SandboxPolicy::DangerFullAccess) } #[cfg(target_os = "macos")] @@ -763,9 +738,6 @@ fn sanitize_linux_sandbox_policy(policy: &SandboxPolicy) -> SandboxPolicy { exclude_slash_tmp: *exclude_slash_tmp, } } - SandboxPolicy::ExternalSandbox { network_access } => SandboxPolicy::ExternalSandbox { - network_access: *network_access, - }, SandboxPolicy::DangerFullAccess => SandboxPolicy::DangerFullAccess, SandboxPolicy::ReadOnly => SandboxPolicy::ReadOnly, } diff --git a/src/windows_sandbox.rs b/src/windows_sandbox.rs index 0be4e257..d171ad4c 100644 --- a/src/windows_sandbox.rs +++ b/src/windows_sandbox.rs @@ -235,7 +235,7 @@ fn make_random_cap_sid_string() -> String { fn validate_windows_policy(policy: &SandboxPolicy) -> Result<(), String> { match policy { SandboxPolicy::ReadOnly | SandboxPolicy::WorkspaceWrite { .. } => Ok(()), - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { + SandboxPolicy::DangerFullAccess => { Err("windows sandbox runner only supports read-only/workspace-write".to_string()) } } @@ -1278,15 +1278,6 @@ mod tests { assert!(err.contains("read-only/workspace-write")); } - #[test] - fn rejects_external_sandbox_policy() { - let err = validate_windows_policy(&SandboxPolicy::ExternalSandbox { - network_access: crate::sandbox::NetworkAccess::Enabled, - }) - .expect_err("external-sandbox should be rejected"); - assert!(err.contains("read-only/workspace-write")); - } - #[test] fn compute_allow_paths_includes_additional_writable_roots() { let tmp = tempdir().expect("tempdir"); From 76e924638c4443b156ce5e076758b889a41c472c Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Sat, 28 Feb 2026 14:11:10 -0500 Subject: [PATCH 08/14] Describe isolation layers --- README.md | 26 +++++++++++--- docs/sandbox-config.md | 50 +++++++++++++++++++++++--- docs/sandbox.md | 16 ++++++++- src/install.rs | 34 +++++++++++++++++- src/main.rs | 79 ++++++++++++++++++++++++++++++++++++++++-- src/sandbox_cli.rs | 68 +++++++++++++++++++++++++++++++++--- 6 files changed, 256 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index af5594b7..7fe10338 100644 --- a/README.md +++ b/README.md @@ -141,11 +141,18 @@ propagate sandbox state updates to MCP servers: "mcpServers": { "r_repl": { "command": "/Users/alice/.cargo/bin/mcp-repl", - "args": ["--sandbox", "workspace-write", "--interpreter", "r", "--add-writable-root", "/Users/alice/Library/Caches/org.R-project.R/R"] + "args": [ + "--sandbox", "workspace-write", + "--interpreter", "r", + "--add-writable-root", "/Users/alice/Library/Caches/org.R-project.R/R" + ] }, "py_repl": { "command": "/Users/alice/.cargo/bin/mcp-repl", - "args": ["--sandbox", "workspace-write", "--interpreter", "python"] + "args": [ + "--sandbox", "workspace-write", + "--interpreter", "python" + ] } } } @@ -160,7 +167,7 @@ To include additional domain allowlist entries in generated client config, pass ```sh mcp-repl install --client codex --interpreter python \ --arg=--sandbox --arg=workspace-write \ - --arg=--config --arg='sandbox_workspace_write.network_access=true' \ + --arg=--network-mode --arg=managed \ --arg=--add-allowed-domain --arg=pypi.org \ --arg=--add-allowed-domain --arg=files.pythonhosted.org ``` @@ -173,7 +180,7 @@ command = "/Users/alice/.cargo/bin/mcp-repl" tool_timeout_sec = 1800 args = [ "--sandbox", "workspace-write", - "--config", "sandbox_workspace_write.network_access=true", + "--network-mode", "managed", "--add-allowed-domain", "pypi.org", "--add-allowed-domain", "files.pythonhosted.org", "--interpreter", "python", @@ -205,9 +212,20 @@ Notes: ## Sandbox Default sandbox policy is `workspace-write` with network disabled. +Default network mode is `off`. Write access includes the working area and temp paths required by the worker (exact roots vary by OS/policy). On Windows, sandbox enforcement is still under active development and is not yet fully functional/reliable across environments. +High-level network mode flag: +- `--network-mode off`: network disabled (default) +- `--network-mode direct`: direct outbound network (no managed-domain routing) +- `--network-mode managed`: network enabled + managed routing/domain policy enabled + +Proxy ownership: +- `mcp-repl` does not run its own domain-enforcing proxy. +- With Codex, managed mode uses Codex's managed proxy path. +- With other clients (for example Claude), managed mode only works if the client/environment also provides a compatible loopback proxy setup; otherwise behavior is fail-closed (no usable network path). + See: - `docs/sandbox.md` for a quick reference. - `docs/sandbox-config.md` for detailed semantics, preset behavior, platform-specific behavior, and allowed-domain interactions. diff --git a/docs/sandbox-config.md b/docs/sandbox-config.md index 411847fd..85fd35ce 100644 --- a/docs/sandbox-config.md +++ b/docs/sandbox-config.md @@ -16,6 +16,7 @@ This document describes how `mcp-repl` builds effective sandbox behavior from CL Operations are applied in argument order. - `--sandbox ...` sets the base mode at that point in the sequence +- `--network-mode ...` applies a high-level network profile (`off`, `direct`, or `managed`) - `--add-writable-root` mutates the current `workspace-write` roots - `--add-allowed-domain` appends to managed `allowed_domains` - `--config key=value` applies one structured mutation @@ -39,11 +40,39 @@ policy is `workspace-write`: - `sandbox_workspace_write.exclude_tmpdir_env_var` - `sandbox_workspace_write.exclude_slash_tmp` +## Tri-Modal Network API + +To simplify common setups, `mcp-repl` exposes a high-level network mode: + +- CLI: `--network-mode off|direct|managed` (alias: `--network`) +- Config override: `network.mode=off|direct|managed` (alias: `network_mode=...`) + +Semantics: + +- `off`: + - disables managed mode (`permissions.network.enabled=false`) + - clears configured `allowed_domains` and `denied_domains` + - disables network when the effective policy is `workspace-write` +- `direct`: + - disables managed mode + - clears configured `allowed_domains` and `denied_domains` + - enables network when the effective policy is `workspace-write` +- `managed`: + - enables managed mode (`permissions.network.enabled=true`) + - enables network when the effective policy is `workspace-write` + +Mode-specific caveats: + +- In `read-only`, network remains disabled regardless of `--network-mode`. +- In `danger-full-access`, network is intrinsically full-access; `off` cannot force a no-network + runtime because the filesystem/sandbox mode itself grants full access. + ## Presets and Defaults Server default (no sandbox flags): - `sandbox_policy = workspace-write` +- `network.mode = off` - `sandbox_workspace_write.network_access = false` - writable roots start empty and are expanded by runtime defaults (cwd/temp/session temp) @@ -71,11 +100,19 @@ How to set them: Key behavior: -- Domain lists do not enable network by themselves. - You still need a sandbox mode with network enabled (`workspace-write` + `sandbox_workspace_write.network_access=true`, or full-access modes). +- Domain lists do not enable network by themselves unless `managed` mode is active. + Recommended path: `--network-mode managed` plus explicit allow/deny entries. - Domain entries are passed through as strings; `mcp-repl` does not parse wildcard/domain syntax. - Domain restrictions are meaningful only when managed proxy routing is active and the proxy honors them. +Proxy ownership and client compatibility: + +- `mcp-repl` does not run a domain-filtering proxy process. +- With Codex, managed mode integrates with Codex managed network. +- With non-Codex clients (for example Claude), managed mode only works if the client/runtime + provides a compatible loopback proxy environment. Without that, managed mode is fail-closed + (no usable network route). + ## Platform Matrix ### macOS @@ -121,7 +158,7 @@ Network behavior: Enforcement: - `read-only` and `workspace-write` use the Windows sandbox runner. -- `danger-full-access` bypasses built-in sandbox enforcement. +- `danger-full-access` bypasses built-in sandbox enforcement. Use this when isolation is provided externally (for example, running inside a Docker container). - Python backend is currently unavailable on Windows in this project. Managed network and domains: @@ -133,6 +170,8 @@ Managed network and domains: ## Intersections and Gotchas - `--add-allowed-domain` with default `workspace-write` still yields no network unless you also enable network access. +- `--network-mode direct` or `--network-mode off` clears configured allow/deny domains by design, + so domain policy does not silently remain active. - `--sandbox inherit` + `--add-writable-root` is portable across inherited policies; if inherited mode is not `workspace-write`, the writable-root addition is a no-op. - `danger-full-access` ignores sandbox enforcement, so domain controls are not enforced locally by `mcp-repl`. - Managed-network behavior is proxy-driven; if there is no managed proxy path, domain controls may not be effective. @@ -146,6 +185,7 @@ Minimal network-restricted default: command = "/Users/alice/.cargo/bin/mcp-repl" args = [ "--sandbox", "workspace-write", + "--network-mode", "off", "--interpreter", "r", ] ``` @@ -157,7 +197,7 @@ Enable managed-domain network for Python: command = "/Users/alice/.cargo/bin/mcp-repl" args = [ "--sandbox", "workspace-write", - "--config", "sandbox_workspace_write.network_access=true", + "--network-mode", "managed", "--add-allowed-domain", "pypi.org", "--add-allowed-domain", "files.pythonhosted.org", "--interpreter", "python", @@ -169,7 +209,7 @@ Install with the same domain settings: ```sh mcp-repl install --client codex --interpreter python \ --arg=--sandbox --arg=workspace-write \ - --arg=--config --arg='sandbox_workspace_write.network_access=true' \ + --arg=--network-mode --arg=managed \ --arg=--add-allowed-domain --arg=pypi.org \ --arg=--add-allowed-domain --arg=files.pythonhosted.org ``` diff --git a/docs/sandbox.md b/docs/sandbox.md index 7772d338..f7c7fb99 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -24,6 +24,7 @@ The worker also gets a per-session temp directory, exported as: ## Configure sandbox policy - Base mode: `mcp-repl --sandbox inherit|read-only|workspace-write|danger-full-access` +- Network mode: `mcp-repl --network-mode off|direct|managed` (alias: `--network`) - Add writable roots (workspace-write only, repeatable): `mcp-repl --add-writable-root /absolute/path` - Add allowed domains (repeatable): @@ -35,11 +36,24 @@ The worker also gets a per-session temp directory, exported as: Operations are applied strictly in CLI argument order. Later operations win. `--sandbox ...` resets the base policy at the point where it appears. +`--network-mode` applies a high-level network profile: + +- `off`: disable managed mode and keep network disabled where sandbox policy allows toggling +- `direct`: disable managed mode and request direct network access +- `managed`: enable managed mode and request network access in `workspace-write` + Workspace-write-only options are accepted in any mode, but are no-ops unless effective mode is `workspace-write`. `--add-allowed-domain` updates managed-network policy only; it does not by itself enable network access in `workspace-write`. +Managed-proxy ownership: + +- `mcp-repl` does not run a domain-filtering proxy process. +- In Codex sessions, managed mode relies on Codex's managed proxy path. +- In non-Codex clients, managed mode requires a compatible proxy environment to be provided by the + host/client; otherwise managed routing has no usable network path (fail closed). + ## macOS behavior Sandboxing is enforced via `sandbox-exec`. @@ -95,5 +109,5 @@ Managed-network behavior on Linux: - R backend is supported with the same policy surface (`read-only`, `workspace-write`, `danger-full-access`). - Python backend is currently unavailable on Windows (it requires a Unix PTY). - `read-only` and `workspace-write` are enforced by the Windows sandbox runner. -- `danger-full-access` runs without built-in sandbox enforcement. +- `danger-full-access` runs without built-in sandbox enforcement. Use this when isolation is provided externally (for example, running inside a Docker container). - Some Windows environments may not support the restricted-token setup required by sandboxed modes. diff --git a/src/install.rs b/src/install.rs index 58ba56ad..a972aa79 100644 --- a/src/install.rs +++ b/src/install.rs @@ -277,8 +277,15 @@ fn has_sandbox_config_arg(args: &[String]) -> bool { while let Some(arg) = iter.next() { if matches!( arg.as_str(), - "--sandbox" | "--add-writable-root" | "--add-writeable-root" | "--add-allowed-domain" + "--sandbox" + | "--network-mode" + | "--network" + | "--add-writable-root" + | "--add-writeable-root" + | "--add-allowed-domain" ) || arg.starts_with("--sandbox=") + || arg.starts_with("--network-mode=") + || arg.starts_with("--network=") || arg.starts_with("--add-writable-root=") || arg.starts_with("--add-writeable-root=") || arg.starts_with("--add-allowed-domain=") @@ -310,6 +317,8 @@ fn is_sandbox_config_override(raw: &str) -> bool { matches!( key.trim(), "sandbox_mode" + | "network.mode" + | "network_mode" | "sandbox_workspace_write.network_access" | "sandbox_workspace_write.writable_roots" | "sandbox_workspace_write.exclude_tmpdir_env_var" @@ -1015,6 +1024,29 @@ name="demo" assert_eq!(claude_install_args(&base), base); } + #[test] + fn install_args_preserve_explicit_sandbox_config_via_network_mode_flag() { + let base = vec![ + "--network-mode".to_string(), + "managed".to_string(), + "--interpreter".to_string(), + "python".to_string(), + ]; + assert_eq!(codex_install_args(&base), base); + assert_eq!(claude_install_args(&base), base); + } + + #[test] + fn install_args_preserve_explicit_sandbox_config_via_network_mode_key() { + let base = vec![ + "--config=network.mode=managed".to_string(), + "--interpreter".to_string(), + "python".to_string(), + ]; + assert_eq!(codex_install_args(&base), base); + assert_eq!(claude_install_args(&base), base); + } + #[test] fn with_interpreter_arg_adds_python_interpreter_when_missing() { let args = with_interpreter_arg( diff --git a/src/main.rs b/src/main.rs index ccde8419..2a4887dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,7 +28,8 @@ use std::path::PathBuf; use crate::backend::{Backend, backend_from_env}; use crate::sandbox_cli::{ - SandboxCliOperation, SandboxCliPlan, SandboxModeArg, parse_sandbox_config_override, + NetworkModeArg, SandboxCliOperation, SandboxCliPlan, SandboxModeArg, + parse_sandbox_config_override, }; enum CliCommand { @@ -147,6 +148,25 @@ fn parse_cli_args() -> Result> { .operations .push(SandboxCliOperation::SetMode(mode)); } + "--network-mode" | "--network" => { + let value = parser.next_value("--network-mode")?; + let mode = NetworkModeArg::parse(&value).map_err(|err| err.to_string())?; + sandbox_args + .plan + .operations + .push(SandboxCliOperation::SetNetworkMode(mode)); + } + _ if arg.starts_with("--network-mode=") || arg.starts_with("--network=") => { + let value = arg.split_once('=').map(|(_, value)| value).unwrap_or(""); + if value.is_empty() { + return Err("missing value for --network-mode".into()); + } + let mode = NetworkModeArg::parse(value).map_err(|err| err.to_string())?; + sandbox_args + .plan + .operations + .push(SandboxCliOperation::SetNetworkMode(mode)); + } "--add-writable-root" | "--add-writeable-root" => { let value = parser.next_value("--add-writable-root")?; sandbox_args @@ -457,13 +477,14 @@ fn parse_writable_root(raw: &str) -> Result> fn print_usage() { println!( "Usage:\n\ -mcp-repl [--debug-repl] [--interpreter ] [--sandbox ] [--add-writable-root ] [--add-allowed-domain ] [--config ]...\n\ +mcp-repl [--debug-repl] [--interpreter ] [--sandbox ] [--network-mode ] [--add-writable-root ] [--add-allowed-domain ] [--config ]...\n\ mcp-repl install [codex] [claude] [--client ]... [--interpreter [,r|python]...]... [--server-name ] [--command ] [--arg ]...\n\n\ --debug-repl: run an interactive debug REPL over stdio\n\ --debug-events-dir: optional directory for per-startup JSONL debug event logs (env: MCP_REPL_DEBUG_EVENTS_DIR)\n\ --interpreter: choose REPL interpreter (default: r; env MCP_REPL_INTERPRETER, compatibility env MCP_REPL_BACKEND)\n\ --backend: compatibility alias for --interpreter\n\ --sandbox: base sandbox mode (inherit requires client sandbox update)\n\ +--network-mode / --network: high-level network mode (off/direct/managed)\n\ --add-writable-root / --add-writeable-root: append absolute writable root in argument order\n\ --add-allowed-domain: append allowed domain pattern in argument order\n\ --config: apply advanced ordered sandbox/network override (Codex-compatible keys)\n\ @@ -617,6 +638,12 @@ mod tests { assert!(matches!(mode, SandboxModeArg::Inherit)); } + #[test] + fn parse_network_mode_accepts_managed() { + let mode = NetworkModeArg::parse("managed").expect("network mode"); + assert!(matches!(mode, NetworkModeArg::Managed)); + } + #[test] fn parse_config_override_supports_codex_bwrap_alias() { let op = @@ -649,6 +676,15 @@ mod tests { )); } + #[test] + fn parse_config_override_supports_network_mode_alias() { + let op = parse_sandbox_config_override("network_mode=managed").expect("config"); + assert!(matches!( + op, + SandboxConfigOperation::SetNetworkMode(NetworkModeArg::Managed) + )); + } + #[test] fn ordered_layering_last_argument_wins() { let plan = SandboxCliPlan { @@ -688,6 +724,45 @@ mod tests { assert_eq!(resolved.sandbox_policy, SandboxPolicy::ReadOnly); } + #[test] + fn network_mode_direct_enables_workspace_network_and_clears_domains() { + let plan = SandboxCliPlan { + operations: vec![ + SandboxCliOperation::SetMode(SandboxModeArg::WorkspaceWrite), + SandboxCliOperation::AddAllowedDomain("pypi.org".to_string()), + SandboxCliOperation::SetNetworkMode(NetworkModeArg::Direct), + ], + }; + let inherited = SandboxState::default(); + let resolved = resolve_effective_sandbox_state(&plan, Some(&inherited)) + .expect("effective sandbox state"); + match resolved.sandbox_policy { + SandboxPolicy::WorkspaceWrite { network_access, .. } => assert!(network_access), + other => panic!("expected workspace-write policy, got {other:?}"), + } + assert!(!resolved.managed_network_policy.enabled); + assert!(resolved.managed_network_policy.allowed_domains.is_empty()); + assert!(resolved.managed_network_policy.denied_domains.is_empty()); + } + + #[test] + fn network_mode_managed_enables_workspace_network_and_managed_mode() { + let plan = SandboxCliPlan { + operations: vec![ + SandboxCliOperation::SetMode(SandboxModeArg::WorkspaceWrite), + SandboxCliOperation::SetNetworkMode(NetworkModeArg::Managed), + ], + }; + let inherited = SandboxState::default(); + let resolved = resolve_effective_sandbox_state(&plan, Some(&inherited)) + .expect("effective sandbox state"); + match resolved.sandbox_policy { + SandboxPolicy::WorkspaceWrite { network_access, .. } => assert!(network_access), + other => panic!("expected workspace-write policy, got {other:?}"), + } + assert!(resolved.managed_network_policy.enabled); + } + #[test] fn workspace_write_config_is_noop_when_mode_is_not_workspace_write() { let plan = SandboxCliPlan { diff --git a/src/sandbox_cli.rs b/src/sandbox_cli.rs index b18cddee..37e8ac8e 100644 --- a/src/sandbox_cli.rs +++ b/src/sandbox_cli.rs @@ -24,10 +24,31 @@ impl SandboxModeArg { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NetworkModeArg { + Off, + Direct, + Managed, +} + +impl NetworkModeArg { + pub fn parse(value: &str) -> Result { + match value.trim() { + "off" => Ok(Self::Off), + "direct" => Ok(Self::Direct), + "managed" => Ok(Self::Managed), + _ => Err(format!( + "invalid network mode: {value} (expected off|direct|managed)" + )), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] #[allow(clippy::enum_variant_names)] pub enum SandboxConfigOperation { SetMode(SandboxModeArg), + SetNetworkMode(NetworkModeArg), SetWorkspaceNetworkAccess(bool), SetWorkspaceWritableRoots(Vec), SetWorkspaceExcludeTmpdirEnvVar(bool), @@ -42,6 +63,7 @@ pub enum SandboxConfigOperation { #[derive(Debug, Clone, PartialEq, Eq)] pub enum SandboxCliOperation { SetMode(SandboxModeArg), + SetNetworkMode(NetworkModeArg), AddWritableRoot(PathBuf), AddAllowedDomain(String), Config(SandboxConfigOperation), @@ -65,6 +87,9 @@ pub fn parse_sandbox_config_override(raw: &str) -> Result Ok(SandboxConfigOperation::SetMode(SandboxModeArg::parse( &parse_string_value(value), )?)), + "network.mode" => Ok(SandboxConfigOperation::SetNetworkMode( + NetworkModeArg::parse(&parse_string_value(value))?, + )), "sandbox_workspace_write.network_access" => Ok( SandboxConfigOperation::SetWorkspaceNetworkAccess(parse_bool_value(value)?), ), @@ -111,10 +136,10 @@ pub fn parse_sandbox_config_override(raw: &str) -> Result String { - if key == "use_linux_sandbox_bwrap" { - "features.use_linux_sandbox_bwrap".to_string() - } else { - key.to_string() + match key { + "use_linux_sandbox_bwrap" => "features.use_linux_sandbox_bwrap".to_string(), + "network_mode" => "network.mode".to_string(), + _ => key.to_string(), } } @@ -172,6 +197,7 @@ pub fn resolve_effective_sandbox_state_with_defaults( SandboxCliOperation::SetMode(mode) => { apply_mode(&mut state, *mode, inherited, defaults)? } + SandboxCliOperation::SetNetworkMode(mode) => apply_network_mode(&mut state, *mode), SandboxCliOperation::AddWritableRoot(path) => { if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut state.sandbox_policy @@ -247,6 +273,10 @@ fn apply_config_op( ) -> Result<(), String> { match op { SandboxConfigOperation::SetMode(mode) => apply_mode(state, *mode, inherited, defaults), + SandboxConfigOperation::SetNetworkMode(mode) => { + apply_network_mode(state, *mode); + Ok(()) + } SandboxConfigOperation::SetWorkspaceNetworkAccess(network_access) => { if let SandboxPolicy::WorkspaceWrite { network_access: current, @@ -308,3 +338,33 @@ fn apply_config_op( } } } + +fn apply_network_mode(state: &mut SandboxState, mode: NetworkModeArg) { + match mode { + NetworkModeArg::Off => { + if let SandboxPolicy::WorkspaceWrite { network_access, .. } = &mut state.sandbox_policy + { + *network_access = false; + } + state.managed_network_policy.enabled = false; + state.managed_network_policy.allowed_domains.clear(); + state.managed_network_policy.denied_domains.clear(); + } + NetworkModeArg::Direct => { + if let SandboxPolicy::WorkspaceWrite { network_access, .. } = &mut state.sandbox_policy + { + *network_access = true; + } + state.managed_network_policy.enabled = false; + state.managed_network_policy.allowed_domains.clear(); + state.managed_network_policy.denied_domains.clear(); + } + NetworkModeArg::Managed => { + if let SandboxPolicy::WorkspaceWrite { network_access, .. } = &mut state.sandbox_policy + { + *network_access = true; + } + state.managed_network_policy.enabled = true; + } + } +} From bf376244acd75aac114214e042fb8dd29079fdad Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Sat, 28 Feb 2026 18:47:21 -0500 Subject: [PATCH 09/14] Fix managed-network module, Linux bwrap requirement, and authoritative enable flag - P0: include src/managed_network_proxy.rs in the change set and wire it via main/worker process so the new module compiles and is retained for worker lifetime. - P1: fail fast on Linux when managed proxy routing is requested without bwrap by validating both prepare_worker_command and codex-linux-sandbox flag combinations. - P2: make permissions.network.enabled authoritative by changing ManagedNetworkPolicy::is_enabled() and removing domain-list implicit enablement; add regression tests for the enabled flag behavior and Linux routing validation. --- Cargo.lock | 1915 +++++++++++++++++++++++++++++++++- Cargo.toml | 3 + src/main.rs | 1 + src/managed_network_proxy.rs | 165 +++ src/sandbox.rs | 148 ++- src/worker_process.rs | 13 +- 6 files changed, 2198 insertions(+), 47 deletions(-) create mode 100644 src/managed_network_proxy.rs diff --git a/Cargo.lock b/Cargo.lock index c35a493b..3e47e6de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,19 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -20,6 +33,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + [[package]] name = "anyhow" version = "1.0.101" @@ -38,6 +101,45 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "asn1-rs" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -49,12 +151,51 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "asynk-strim" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52697735bdaac441a29391a9e97102c74c6ef0f9b60a40cf109b1b404e29d2f6" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "base64" version = "0.22.1" @@ -96,12 +237,37 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borsh" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +dependencies = [ + "cfg_aliases 0.2.1", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -115,6 +281,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -150,6 +318,119 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "codex-network-proxy" +version = "0.106.0" +source = "git+https://github.com/openai/codex.git?rev=ffd726a656403b69b75130025587d5e0a0d6b7d1#ffd726a656403b69b75130025587d5e0a0d6b7d1" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "clap", + "codex-utils-absolute-path", + "codex-utils-home-dir", + "codex-utils-rustls-provider", + "globset", + "rama-core", + "rama-http", + "rama-http-backend", + "rama-net", + "rama-socks5", + "rama-tcp", + "rama-tls-rustls", + "rama-unix", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "codex-utils-absolute-path" +version = "0.106.0" +source = "git+https://github.com/openai/codex.git?rev=ffd726a656403b69b75130025587d5e0a0d6b7d1#ffd726a656403b69b75130025587d5e0a0d6b7d1" +dependencies = [ + "dirs", + "path-absolutize", + "schemars 0.8.22", + "serde", + "ts-rs", +] + +[[package]] +name = "codex-utils-home-dir" +version = "0.106.0" +source = "git+https://github.com/openai/codex.git?rev=ffd726a656403b69b75130025587d5e0a0d6b7d1#ffd726a656403b69b75130025587d5e0a0d6b7d1" +dependencies = [ + "dirs", +] + +[[package]] +name = "codex-utils-rustls-provider" +version = "0.106.0" +source = "git+https://github.com/openai/codex.git?rev=ffd726a656403b69b75130025587d5e0a0d6b7d1#ffd726a656403b69b75130025587d5e0a0d6b7d1" +dependencies = [ + "rustls", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "console" version = "0.15.11" @@ -162,12 +443,42 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + [[package]] name = "constant_time_eq" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -183,6 +494,36 @@ dependencies = [ "libc", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -193,6 +534,27 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctor" version = "0.1.26" @@ -253,6 +615,35 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -263,6 +654,27 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.59.0", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -295,6 +707,12 @@ version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -327,6 +745,24 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "endian-type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.115", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -368,6 +804,9 @@ name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "filedescriptor" @@ -387,7 +826,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "foldhash" +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "fastrand", + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" @@ -401,6 +858,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futf" version = "0.1.5" @@ -500,6 +963,21 @@ dependencies = [ "slab", ] +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -510,6 +988,31 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.1" @@ -523,6 +1026,38 @@ dependencies = [ "wasip3", ] +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "harp" version = "0.1.0" @@ -533,7 +1068,7 @@ dependencies = [ "ctor 0.1.26", "embed-resource", "harp-macros", - "itertools", + "itertools 0.10.5", "libc", "libloading", "libr", @@ -579,6 +1114,58 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.2", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.2", + "resolv-conf", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "htmd" version = "0.5.0" @@ -601,6 +1188,57 @@ dependencies = [ "match_token", ] +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -764,6 +1402,40 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2 0.5.10", + "widestring", + "windows-sys 0.48.0", + "winreg 0.50.0", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.10.5" @@ -773,12 +1445,31 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.85" @@ -839,6 +1530,15 @@ dependencies = [ "paste", ] +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -866,6 +1566,22 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "pin-utils", + "scoped-tls", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] + [[package]] name = "mac" version = "0.1.1" @@ -906,12 +1622,30 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3eede3bdf92f3b4f9dc04072a9ce5ab557d5ec9038773bf9ffcd5588b3cc05b" + [[package]] name = "mcp-repl" version = "0.1.0" dependencies = [ + "anyhow", + "async-trait", "base64", "blake3", + "codex-network-proxy", "ctor 0.6.3", "harp", "htmd", @@ -923,7 +1657,7 @@ dependencies = [ "portable-pty", "regex-lite", "rmcp", - "schemars", + "schemars 1.2.1", "seccompiler", "serde", "serde_json", @@ -936,12 +1670,40 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "md5" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.1.1" @@ -953,12 +1715,38 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moka" +version = "0.12.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + [[package]] name = "nix" version = "0.28.0" @@ -984,47 +1772,131 @@ dependencies = [ ] [[package]] -name = "ntapi" -version = "0.4.3" +name = "nom" +version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" dependencies = [ - "winapi", + "memchr", + "minimal-lexical", ] [[package]] -name = "num-traits" -version = "0.2.19" +name = "nom" +version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" dependencies = [ - "autocfg", + "memchr", ] [[package]] -name = "objc2-core-foundation" -version = "0.3.1" +name = "ntapi" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" dependencies = [ - "bitflags 2.10.0", + "winapi", ] [[package]] -name = "objc2-io-kit" -version = "0.3.1" +name = "nu-ansi-term" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71c1c64d6120e51cd86033f67176b1cb66780c2efe34dec55176f77befd93c0a" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "libc", - "objc2-core-foundation", + "windows-sys 0.59.0", ] [[package]] -name = "once_cell" -version = "1.21.3" +name = "num-bigint" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c1c64d6120e51cd86033f67176b1cb66780c2efe34dec55176f77befd93c0a" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "parking_lot" @@ -1061,6 +1933,34 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" +[[package]] +name = "path-absolutize" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4af381fe79fa195b4909485d99f73a80792331df0625188e707854f0b3383f5" +dependencies = [ + "path-dedot", +] + +[[package]] +name = "path-dedot" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07ba0ad7e047712414213ff67533e6dd477af0a4e1d14fb52343e53d30ea9397" +dependencies = [ + "once_cell", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1104,7 +2004,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand", + "rand 0.8.5", ] [[package]] @@ -1160,6 +2060,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "portable-pty" version = "0.9.0" @@ -1190,6 +2096,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -1206,6 +2127,15 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.10+spec-1.0.0", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1229,6 +2159,21 @@ dependencies = [ "windows", ] +[[package]] +name = "psl" +version = "2.1.195" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd5cbe0eeb605e2029aa8bd29e17ae797c156705dc515e4db22c3d69f7126d9" +dependencies = [ + "psl-types", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + [[package]] name = "quote" version = "1.0.44" @@ -1244,13 +2189,353 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "radix_trie" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" +dependencies = [ + "endian-type", + "nibble_vec", +] + +[[package]] +name = "rama-core" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b93751ab27c9d151e84c1100057eab3f2a6a1378bc31b62abd416ecb1847658" +dependencies = [ + "ahash", + "asynk-strim", + "bytes", + "futures", + "parking_lot", + "pin-project-lite", + "rama-error", + "rama-macros", + "rama-utils", + "serde", + "serde_json", + "tokio", + "tokio-graceful", + "tokio-util", + "tracing", +] + +[[package]] +name = "rama-dns" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e340fef2799277e204260b17af01bc23604712092eacd6defe40167f304baed8" +dependencies = [ + "ahash", + "hickory-resolver", + "rama-core", + "rama-net", + "rama-utils", + "serde", + "tokio", +] + +[[package]] +name = "rama-error" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c452aba1beb7e29b873ff32f304536164cffcc596e786921aea64e858ff8f40" + +[[package]] +name = "rama-http" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "453d60af031e23af2d48995e41b17023f6150044738680508b63671f8d7417dd" +dependencies = [ + "ahash", + "base64", + "bitflags 2.10.0", + "chrono", + "const_format", + "csv", + "http", + "http-range-header", + "httpdate", + "iri-string", + "matchit", + "parking_lot", + "percent-encoding", + "pin-project-lite", + "radix_trie", + "rama-core", + "rama-error", + "rama-http-headers", + "rama-http-types", + "rama-net", + "rama-utils", + "rand 0.9.2", + "serde", + "serde_html_form", + "serde_json", + "tokio", + "uuid", +] + +[[package]] +name = "rama-http-backend" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ff6a3c8ae690be8167e43777ba0bf6b0c8c2f6de165c538666affe2a32fd81" +dependencies = [ + "h2", + "pin-project-lite", + "rama-core", + "rama-http", + "rama-http-core", + "rama-http-headers", + "rama-http-types", + "rama-net", + "rama-tcp", + "rama-unix", + "rama-utils", + "tokio", +] + +[[package]] +name = "rama-http-core" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3822be6703e010afec0bcfeb5dbb6e5a3b23ca5689d9b1215b66ce6446653b77" +dependencies = [ + "ahash", + "atomic-waker", + "futures-channel", + "httparse", + "httpdate", + "indexmap", + "itoa", + "parking_lot", + "pin-project-lite", + "rama-core", + "rama-http", + "rama-http-types", + "rama-utils", + "slab", + "tokio", + "tokio-test", + "want", +] + +[[package]] +name = "rama-http-headers" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d74fe0cd9bd4440827dc6dc0f504cf66065396532e798891dee2c1b740b2285" +dependencies = [ + "ahash", + "base64", + "chrono", + "const_format", + "httpdate", + "rama-core", + "rama-error", + "rama-http-types", + "rama-macros", + "rama-net", + "rama-utils", + "rand 0.9.2", + "serde", + "sha1", +] + +[[package]] +name = "rama-http-types" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6dae655a72da5f2b97cfacb67960d8b28c5025e62707b4c8c5f0c5c9843a444" +dependencies = [ + "ahash", + "bytes", + "const_format", + "fnv", + "http", + "http-body", + "http-body-util", + "itoa", + "memchr", + "mime", + "mime_guess", + "nom 8.0.0", + "pin-project-lite", + "rama-core", + "rama-error", + "rama-macros", + "rama-utils", + "rand 0.9.2", + "serde", + "serde_json", + "sync_wrapper", + "tokio", +] + +[[package]] +name = "rama-macros" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea18a110bcf21e35c5f194168e6914ccea45ffdd0fea51bc4b169fbeafef6428" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "rama-net" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b28ee9e1e5d39264414b71f5c33e7fbb66b382c3fac456fe0daad39cf5509933" +dependencies = [ + "ahash", + "const_format", + "flume", + "hex", + "ipnet", + "itertools 0.14.0", + "md5", + "nom 8.0.0", + "parking_lot", + "pin-project-lite", + "psl", + "radix_trie", + "rama-core", + "rama-http-types", + "rama-macros", + "rama-utils", + "serde", + "sha2", + "socket2 0.6.2", + "tokio", +] + +[[package]] +name = "rama-socks5" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5468b263516daaf258de32542c1974b7cbe962363ad913dcb669f5d46db0ef3e" +dependencies = [ + "byteorder", + "rama-core", + "rama-net", + "rama-tcp", + "rama-udp", + "rama-utils", + "tokio", +] + +[[package]] +name = "rama-tcp" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe60cd604f91196b3659a1b28945add2e8b10bd0b4e6373c93d024fb3197704b" +dependencies = [ + "pin-project-lite", + "rama-core", + "rama-dns", + "rama-http-types", + "rama-net", + "rama-utils", + "rand 0.9.2", + "tokio", +] + +[[package]] +name = "rama-tls-rustls" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "536d47f6b269fb20dffd45e4c04aa8b340698b3509326e3c36e444b4f33ce0d6" +dependencies = [ + "pin-project-lite", + "rama-core", + "rama-http-types", + "rama-net", + "rama-utils", + "rcgen", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "webpki-roots", + "x509-parser", +] + +[[package]] +name = "rama-udp" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36ed05e0ecac73e084e92a3a8b1fbf16fdae8958c506f0f0eada180a2d99eef4" +dependencies = [ + "rama-core", + "rama-net", + "tokio", + "tokio-util", +] + +[[package]] +name = "rama-unix" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91acb16d571428ba4cece072dfab90d2667cdfa910a7b3cb4530c3f31542d708" +dependencies = [ + "pin-project-lite", + "rama-core", + "rama-net", + "tokio", +] + +[[package]] +name = "rama-utils" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf28b18ba4a57f8334d7992d3f8020194ea359b246ae6f8f98b8df524c7a14ef" +dependencies = [ + "const_format", + "parking_lot", + "pin-project-lite", + "rama-macros", + "regex", + "serde", + "smallvec", + "smol_str", + "tokio", + "wildcard", +] + [[package]] name = "rand" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1259,6 +2544,29 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rcgen" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" +dependencies = [ + "aws-lc-rs", + "pem", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1268,6 +2576,17 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -1323,6 +2642,26 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + [[package]] name = "rmcp" version = "0.15.0" @@ -1337,7 +2676,7 @@ dependencies = [ "pin-project-lite", "process-wrap", "rmcp-macros", - "schemars", + "schemars 1.2.1", "serde", "serde_json", "thiserror 2.0.18", @@ -1403,6 +2742,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "1.1.3" @@ -1416,12 +2764,67 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -1431,6 +2834,27 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive 0.8.22", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "1.2.1" @@ -1440,11 +2864,23 @@ dependencies = [ "chrono", "dyn-clone", "ref-cast", - "schemars_derive", + "schemars_derive 1.2.1", "serde", "serde_json", ] +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.115", +] + [[package]] name = "schemars_derive" version = "1.2.1" @@ -1458,17 +2894,46 @@ dependencies = [ ] [[package]] -name = "scopeguard" -version = "1.2.0" +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "seccompiler" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae55de56877481d112a559bbc12667635fdaf5e005712fd4e2b2fa50ffc884" +dependencies = [ + "libc", +] + +[[package]] +name = "security-framework" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" +dependencies = [ + "bitflags 2.10.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] [[package]] -name = "seccompiler" -version = "0.5.0" +name = "security-framework-sys" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae55de56877481d112a559bbc12667635fdaf5e005712fd4e2b2fa50ffc884" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ + "core-foundation-sys", "libc", ] @@ -1519,6 +2984,19 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "serde_html_form" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acf96b1d9364968fce46ebb548f1c0e1d7eceae27bdff73865d42e6c7369d94" +dependencies = [ + "form_urlencoded", + "indexmap", + "itoa", + "ryu", + "serde_core", +] + [[package]] name = "serde_json" version = "1.0.149" @@ -1553,6 +3031,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1628,6 +3117,29 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "smol_str" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f7a918bd2a9951d18ee6e48f076843e8e73a9a5d22cf05bcd4b7a81bdd04e17" +dependencies = [ + "borsh", + "serde_core", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] [[package]] name = "socket2" @@ -1639,6 +3151,15 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1685,6 +3206,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -1707,6 +3234,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synstructure" version = "0.13.2" @@ -1732,6 +3265,12 @@ dependencies = [ "windows", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tempfile" version = "3.25.0" @@ -1739,7 +3278,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.1", "once_cell", "rustix", "windows-sys 0.59.0", @@ -1756,6 +3295,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1805,6 +3353,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -1815,6 +3394,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.49.0" @@ -1824,13 +3418,27 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.2", "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-graceful" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45740b38b48641855471cd402922e89156bdfbd97b69b45eeff170369cc18c7d" +dependencies = [ + "loom", + "pin-project-lite", + "slab", + "tokio", + "tracing", +] + [[package]] name = "tokio-macros" version = "2.6.0" @@ -1842,6 +3450,16 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -1853,6 +3471,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-test" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" +dependencies = [ + "futures-core", + "tokio", + "tokio-stream", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -1887,6 +3516,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_datetime" version = "1.0.0+spec-1.1.0" @@ -1910,6 +3548,18 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow", +] + [[package]] name = "toml_edit" version = "0.25.0+spec-1.1.0" @@ -1973,6 +3623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", ] [[package]] @@ -1985,15 +3636,62 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", "sharded-slab", + "smallvec", "thread_local", + "tracing", "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ts-rs" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4994acea2522cd2b3b85c1d9529a55991e3ad5e25cdcd3de9d505972c4379424" +dependencies = [ + "serde_json", + "thiserror 2.0.18", + "ts-rs-macros", +] + +[[package]] +name = "ts-rs-macros" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6ff59666c9cbaec3533964505d39154dc4e0a56151fdea30a09ed0301f62e2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", + "termcolor", ] [[package]] @@ -2002,6 +3700,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.23" @@ -2020,6 +3724,18 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -2044,6 +3760,29 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "getrandom 0.4.1", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" @@ -2101,6 +3840,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2216,6 +3964,30 @@ dependencies = [ "string_cache_codegen", ] +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "wildcard" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9b0540e91e49de3817c314da0dd3bc518093ceacc6ea5327cb0e1eb073e5189" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2357,6 +4129,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -2597,6 +4378,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winreg" version = "0.52.0" @@ -2701,6 +4492,25 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "aws-lc-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + [[package]] name = "xml5ever" version = "0.35.0" @@ -2711,6 +4521,15 @@ dependencies = [ "markup5ever", ] +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.1" @@ -2734,6 +4553,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + [[package]] name = "zerofrom" version = "0.1.6" @@ -2755,6 +4594,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/Cargo.toml b/Cargo.toml index b137e3e2..4feee87b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,9 @@ path = "src/main.rs" [dependencies] ctor = "0.6.3" base64 = "0.22.1" +anyhow = "1" +async-trait = "0.1.89" +codex-network-proxy = { git = "https://github.com/openai/codex.git", rev = "ffd726a656403b69b75130025587d5e0a0d6b7d1", package = "codex-network-proxy" } harp = { git = "https://github.com/t-kalinowski/ark" } htmd = "0.5" libc = "0.2" diff --git a/src/main.rs b/src/main.rs index 2a4887dd..2d2d8751 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod install; mod ipc; #[cfg(target_os = "linux")] mod linux_proxy_routing; +mod managed_network_proxy; mod output_capture; mod output_stream; mod pager; diff --git a/src/managed_network_proxy.rs b/src/managed_network_proxy.rs new file mode 100644 index 00000000..3037a754 --- /dev/null +++ b/src/managed_network_proxy.rs @@ -0,0 +1,165 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use crate::sandbox::SandboxState; +use async_trait::async_trait; +use codex_network_proxy::ConfigReloader; +use codex_network_proxy::ConfigState; +use codex_network_proxy::NetworkMode; +use codex_network_proxy::NetworkProxy; +use codex_network_proxy::NetworkProxyConfig; +use codex_network_proxy::NetworkProxyConstraints; +use codex_network_proxy::NetworkProxyHandle; +use codex_network_proxy::NetworkProxyState; +use codex_network_proxy::build_config_state; + +pub struct ManagedNetworkProxy { + runtime: tokio::runtime::Runtime, + handle: Option, +} + +impl ManagedNetworkProxy { + pub fn start_for_state( + state: &SandboxState, + env: &mut HashMap, + ) -> Result, String> { + if !state.managed_network_policy.is_enabled() + || !state.sandbox_policy.has_full_network_access() + { + return Ok(None); + } + + let mut config = NetworkProxyConfig::default(); + config.network.enabled = true; + config.network.mode = NetworkMode::Full; + config.network.allowed_domains = state.managed_network_policy.allowed_domains.clone(); + config.network.denied_domains = state.managed_network_policy.denied_domains.clone(); + config.network.allow_local_binding = state.managed_network_policy.allow_local_binding; + + let config_state = build_config_state(config, NetworkProxyConstraints::default()) + .map_err(|err| format!("failed to build managed network proxy config: {err}"))?; + let state = NetworkProxyState::with_reloader( + config_state.clone(), + Arc::new(StaticConfigReloader { + state: config_state, + }), + ); + + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|err| format!("failed to build managed network proxy runtime: {err}"))?; + + let proxy = runtime + .block_on(async { + NetworkProxy::builder() + .state(Arc::new(state)) + .managed_by_codex(false) + .build() + .await + }) + .map_err(|err| format!("failed to build managed network proxy: {err:#}"))?; + + let handle = runtime + .block_on(proxy.run()) + .map_err(|err| format!("failed to run managed network proxy: {err:#}"))?; + + proxy.apply_to_env(env); + + Ok(Some(Self { + runtime, + handle: Some(handle), + })) + } +} + +impl Drop for ManagedNetworkProxy { + fn drop(&mut self) { + if let Some(handle) = self.handle.take() { + let _ = self.runtime.block_on(async { handle.shutdown().await }); + } + } +} + +#[derive(Clone)] +struct StaticConfigReloader { + state: ConfigState, +} + +#[async_trait] +impl ConfigReloader for StaticConfigReloader { + fn source_label(&self) -> String { + "mcp-repl managed network proxy static config".to_string() + } + + async fn maybe_reload(&self) -> anyhow::Result> { + Ok(None) + } + + async fn reload_now(&self) -> anyhow::Result { + Ok(self.state.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::ManagedNetworkProxy; + use crate::sandbox::{ManagedNetworkPolicy, SandboxPolicy, SandboxState}; + use std::collections::HashMap; + use std::path::PathBuf; + + fn managed_state(network_access: bool) -> SandboxState { + SandboxState { + sandbox_policy: SandboxPolicy::WorkspaceWrite { + writable_roots: Vec::new(), + network_access, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, + sandbox_cwd: PathBuf::from("/"), + codex_linux_sandbox_exe: None, + use_linux_sandbox_bwrap: false, + managed_network_policy: ManagedNetworkPolicy { + enabled: true, + allowed_domains: vec!["example.com".to_string()], + denied_domains: vec!["blocked.example.com".to_string()], + allow_local_binding: false, + }, + session_temp_dir: std::env::temp_dir().join("mcp-repl-managed-proxy-test"), + } + } + + #[test] + fn start_for_state_skips_when_network_disabled() { + let mut env = HashMap::new(); + let state = managed_state(false); + let proxy = ManagedNetworkProxy::start_for_state(&state, &mut env) + .expect("start_for_state should succeed"); + assert!(proxy.is_none()); + assert!(!env.contains_key("HTTP_PROXY")); + } + + #[test] + fn start_for_state_sets_proxy_env_when_managed_network_is_enabled() { + let mut env = HashMap::new(); + let state = managed_state(true); + let proxy = match ManagedNetworkProxy::start_for_state(&state, &mut env) { + Ok(proxy) => proxy, + Err(err) if err.contains("Operation not permitted") => return, + Err(err) => panic!("start_for_state should succeed: {err}"), + }; + assert!(proxy.is_some(), "expected managed proxy to start"); + assert!( + env.contains_key("HTTP_PROXY"), + "managed proxy should set HTTP_PROXY" + ); + assert!( + env.contains_key("HTTPS_PROXY"), + "managed proxy should set HTTPS_PROXY" + ); + assert!( + env.contains_key("NO_PROXY"), + "managed proxy should set NO_PROXY" + ); + } +} diff --git a/src/sandbox.rs b/src/sandbox.rs index 5ff86eec..18122dcf 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -14,6 +14,7 @@ use url::Url; #[cfg(target_os = "linux")] use crate::linux_proxy_routing::{activate_proxy_routes_in_netns, prepare_host_proxy_route_spec}; +use crate::managed_network_proxy::ManagedNetworkProxy; use serde::{Deserialize, Serialize}; use tempfile::Builder; @@ -37,6 +38,9 @@ pub const LINUX_BWRAP_NO_PROC_ENV: &str = "MCP_CONSOLE_LINUX_BWRAP_NO_PROC"; #[derive(Debug, Clone)] pub enum SandboxError { SessionTempDir(String), + #[cfg(target_os = "linux")] + InvalidConfiguration(String), + ManagedNetworkProxy(String), #[cfg(target_os = "macos")] SeatbeltMissing, #[cfg(target_os = "windows")] @@ -49,6 +53,13 @@ impl std::fmt::Display for SandboxError { SandboxError::SessionTempDir(message) => { write!(f, "failed to create session temp dir: {message}") } + #[cfg(target_os = "linux")] + SandboxError::InvalidConfiguration(message) => { + write!(f, "invalid sandbox configuration: {message}") + } + SandboxError::ManagedNetworkProxy(message) => { + write!(f, "failed to start managed network proxy: {message}") + } #[cfg(target_os = "macos")] SandboxError::SeatbeltMissing => { write!(f, "seatbelt sandbox executable not found") @@ -72,12 +83,8 @@ pub struct ManagedNetworkPolicy { } impl ManagedNetworkPolicy { - pub fn has_domain_restrictions(&self) -> bool { - !self.allowed_domains.is_empty() || !self.denied_domains.is_empty() - } - pub fn is_enabled(&self) -> bool { - self.enabled || self.has_domain_restrictions() + self.enabled } } @@ -461,6 +468,7 @@ pub struct PreparedCommand { pub args: Vec, pub env: HashMap, pub arg0: Option, + pub managed_network_proxy: Option, #[cfg(target_os = "macos")] pub denial_logger: Option, } @@ -470,6 +478,16 @@ pub fn prepare_worker_command( args: Vec, state: &SandboxState, ) -> Result { + #[cfg(target_os = "linux")] + if state.sandbox_policy.has_full_network_access() + && state.managed_network_policy.is_enabled() + && !state.use_linux_sandbox_bwrap + { + return Err(SandboxError::InvalidConfiguration( + "managed network requires --use-bwrap-sandbox on Linux".to_string(), + )); + } + let mut env = HashMap::new(); if !state.sandbox_policy.has_full_network_access() { env.insert( @@ -521,6 +539,8 @@ pub fn prepare_worker_command( ); } } + let managed_network_proxy = ManagedNetworkProxy::start_for_state(state, &mut env) + .map_err(SandboxError::ManagedNetworkProxy)?; if !state.sandbox_policy.requires_sandbox() { return Ok(PreparedCommand { @@ -528,6 +548,7 @@ pub fn prepare_worker_command( args, env, arg0: None, + managed_network_proxy, #[cfg(target_os = "macos")] denial_logger: None, }); @@ -550,6 +571,11 @@ pub fn prepare_worker_command( network_env.insert(key.to_string(), value.clone()); } } + for key in PROXY_URL_ENV_KEYS { + if let Some(value) = env.get(key) { + network_env.insert(key.to_string(), value.clone()); + } + } let command = build_command_vec(program, &args); let mut seatbelt_args = create_seatbelt_command_args( command, @@ -569,6 +595,7 @@ pub fn prepare_worker_command( args: full_command[1..].to_vec(), env, arg0: None, + managed_network_proxy, denial_logger, }) } @@ -626,6 +653,7 @@ pub fn prepare_worker_command( args: sandbox_args, env, arg0: Some("codex-linux-sandbox".to_string()), + managed_network_proxy, }) } @@ -643,6 +671,7 @@ pub fn prepare_worker_command( args: sandbox_args, env, arg0: None, + managed_network_proxy, }) } @@ -653,6 +682,7 @@ pub fn prepare_worker_command( args, env, arg0: None, + managed_network_proxy, }) } } @@ -1068,8 +1098,7 @@ fn create_seatbelt_command_args( let proxy = proxy_policy_inputs_from_env(network_env); let allow_local_binding = managed_network_policy.allow_local_binding; - let enforce_managed_network = - managed_network_enabled(network_env) || managed_network_policy.has_domain_restrictions(); + let enforce_managed_network = managed_network_enabled(network_env); let network_policy = dynamic_network_policy( sandbox_policy, enforce_managed_network, @@ -1119,7 +1148,11 @@ struct LinuxSandboxArgs { #[cfg(target_os = "linux")] fn linux_sandbox_main_impl() -> Result<(), String> { let args = linux_sandbox_parse_args()?; - linux_validate_inner_stage_mode(args.apply_seccomp_then_exec, args.use_bwrap_sandbox)?; + linux_validate_inner_stage_mode( + args.apply_seccomp_then_exec, + args.use_bwrap_sandbox, + args.allow_network_for_proxy, + )?; if args.apply_seccomp_then_exec { if args.allow_network_for_proxy { let spec = args @@ -1156,7 +1189,11 @@ fn linux_sandbox_main_impl() -> Result<(), String> { fn linux_validate_inner_stage_mode( apply_seccomp_then_exec: bool, use_bwrap_sandbox: bool, + allow_network_for_proxy: bool, ) -> Result<(), String> { + if allow_network_for_proxy && !use_bwrap_sandbox { + return Err("--allow-network-for-proxy requires --use-bwrap-sandbox".to_string()); + } if apply_seccomp_then_exec && !use_bwrap_sandbox { return Err("--apply-seccomp-then-exec requires --use-bwrap-sandbox".to_string()); } @@ -2350,6 +2387,13 @@ mod tests { "unexpected error message: {message}" ); } + #[cfg(target_os = "linux")] + SandboxError::InvalidConfiguration(message) => { + panic!("unexpected error: {message}") + } + SandboxError::ManagedNetworkProxy(message) => { + panic!("unexpected error: {message}") + } #[cfg(target_os = "macos")] SandboxError::SeatbeltMissing => { panic!("unexpected error: SeatbeltMissing") @@ -2530,6 +2574,21 @@ mod tests { ); } + #[test] + fn managed_network_policy_enabled_flag_is_authoritative() { + let policy = ManagedNetworkPolicy { + enabled: false, + allowed_domains: vec!["pypi.org".to_string()], + denied_domains: vec!["blocked.example".to_string()], + allow_local_binding: false, + }; + + assert!( + !policy.is_enabled(), + "enabled=false should disable managed mode even when domain lists are present" + ); + } + #[cfg(target_os = "linux")] #[test] fn prepare_worker_command_enables_proxy_mode_when_managed_network_env_is_true() { @@ -2578,6 +2637,64 @@ mod tests { ); } + #[cfg(target_os = "linux")] + #[test] + fn prepare_worker_command_rejects_managed_network_without_bwrap() { + let mut state = SandboxState::default(); + state.sandbox_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: Vec::new(), + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + state.managed_network_policy.enabled = true; + state.use_linux_sandbox_bwrap = false; + + let err = prepare_worker_command(Path::new("/bin/echo"), vec!["ok".to_string()], &state) + .expect_err("managed network without bwrap should fail"); + + assert!( + err.to_string() + .contains("managed network requires --use-bwrap-sandbox on Linux"), + "unexpected error message: {err}" + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn prepare_worker_command_keeps_managed_mode_disabled_with_domain_lists() { + let mut state = SandboxState::default(); + state.sandbox_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: Vec::new(), + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + state.managed_network_policy.enabled = false; + state.managed_network_policy.allowed_domains = vec!["pypi.org".to_string()]; + state.managed_network_policy.denied_domains = vec!["blocked.example".to_string()]; + state.use_linux_sandbox_bwrap = true; + + let prepared = + prepare_worker_command(Path::new("/bin/echo"), vec!["ok".to_string()], &state) + .expect("prepare_worker_command should succeed"); + + assert_eq!( + prepared + .env + .get(MANAGED_NETWORK_ENV_KEY) + .map(String::as_str), + Some("0"), + "explicit enabled=false should keep managed mode disabled even with domain lists" + ); + assert!( + !prepared + .args + .contains(&"--allow-network-for-proxy".to_string()), + "managed proxy flag should stay disabled when enabled=false" + ); + } + #[cfg(target_os = "linux")] #[test] fn prepare_worker_command_bwrap_env_does_not_override_explicit_false() { @@ -2674,14 +2791,25 @@ mod tests { #[cfg(target_os = "linux")] #[test] fn linux_validate_inner_stage_mode_rejects_apply_seccomp_without_bwrap() { - let err = - linux_validate_inner_stage_mode(true, false).expect_err("expected validation err"); + let err = linux_validate_inner_stage_mode(true, false, false) + .expect_err("expected validation err"); assert_eq!( err, "--apply-seccomp-then-exec requires --use-bwrap-sandbox" ); } + #[cfg(target_os = "linux")] + #[test] + fn linux_validate_inner_stage_mode_rejects_proxy_mode_without_bwrap() { + let err = linux_validate_inner_stage_mode(false, false, true) + .expect_err("expected validation err"); + assert_eq!( + err, + "--allow-network-for-proxy requires --use-bwrap-sandbox" + ); + } + #[cfg(target_os = "linux")] #[test] fn linux_build_inner_seccomp_command_requires_proxy_route_spec_in_proxy_mode() { diff --git a/src/worker_process.rs b/src/worker_process.rs index 66a8644e..45ac0419 100644 --- a/src/worker_process.rs +++ b/src/worker_process.rs @@ -25,6 +25,7 @@ use crate::ipc::{ }; #[cfg(any(target_family = "unix", target_family = "windows"))] use crate::ipc::{IpcHandlers, IpcPlotImage}; +use crate::managed_network_proxy::ManagedNetworkProxy; use crate::output_capture::{ OUTPUT_RING_CAPACITY_BYTES, OutputBuffer, OutputEventKind, OutputRange, OutputTimeline, ensure_output_ring, reset_last_reply_marker_offset, reset_output_ring, @@ -2465,6 +2466,7 @@ struct WorkerProcess { child: Child, stdin_tx: mpsc::Sender, session_tmpdir: Option, + _managed_network_proxy: Option, ipc: IpcHandle, expected_exit: bool, exit_status: Option, @@ -2492,6 +2494,7 @@ struct SpawnedWorker { child: Child, stdin_tx: mpsc::Sender, session_tmpdir: Option, + _managed_network_proxy: Option, #[cfg(target_os = "macos")] denial_logger: Option, } @@ -2595,6 +2598,7 @@ impl WorkerProcess { child, stdin_tx, session_tmpdir, + _managed_network_proxy, #[cfg(target_os = "macos")] denial_logger, } = match backend { @@ -2649,6 +2653,7 @@ impl WorkerProcess { child, stdin_tx, session_tmpdir, + _managed_network_proxy, ipc, expected_exit: false, exit_status: None, @@ -2669,9 +2674,10 @@ impl WorkerProcess { output_timeline: OutputTimeline, ipc_server: &mut IpcServer, ) -> Result { - let prepared = + let mut prepared = prepare_worker_command(exe_path, vec![WORKER_MODE_ARG.to_string()], sandbox_state) .map_err(|err| WorkerError::Sandbox(err.to_string()))?; + let managed_network_proxy = prepared.managed_network_proxy.take(); let session_tmpdir = prepared .env .get(R_SESSION_TMPDIR_ENV) @@ -2748,6 +2754,7 @@ impl WorkerProcess { child, stdin_tx, session_tmpdir, + _managed_network_proxy: managed_network_proxy, #[cfg(target_os = "macos")] denial_logger, }) @@ -2781,9 +2788,10 @@ impl WorkerProcess { #[cfg(target_family = "unix")] { let python_program = Self::resolve_python_program(); - let prepared = + let mut prepared = prepare_worker_command(&python_program, Self::python_command_args(), sandbox_state) .map_err(|err| WorkerError::Sandbox(err.to_string()))?; + let managed_network_proxy = prepared.managed_network_proxy.take(); let session_tmpdir = prepared .env .get(R_SESSION_TMPDIR_ENV) @@ -2856,6 +2864,7 @@ impl WorkerProcess { child, stdin_tx, session_tmpdir, + _managed_network_proxy: managed_network_proxy, #[cfg(target_os = "macos")] denial_logger, }) From 593a050f41479e844b3a83f1c9d70a87041efaf7 Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Sun, 1 Mar 2026 02:14:50 -0500 Subject: [PATCH 10/14] Fix Linux managed-network bwrap gating and websocket proxy routing - Restrict the Linux managed-network bwrap precheck to sandboxed policies so danger-full-access managed mode no longer fails before spawn. - Extend Linux proxy env-key routing to include WS_PROXY/WSS_PROXY (case-insensitive via normalization) so websocket proxy variables are rewritten for netns bridge routing. - Add Linux regression tests covering danger-full-access managed mode without bwrap and websocket proxy-key route planning. --- src/linux_proxy_routing.rs | 19 ++++++++++++++++++- src/sandbox.rs | 18 +++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/linux_proxy_routing.rs b/src/linux_proxy_routing.rs index ac5c1a0d..68539927 100644 --- a/src/linux_proxy_routing.rs +++ b/src/linux_proxy_routing.rs @@ -27,6 +27,8 @@ const PROXY_ENV_KEYS: &[&str] = &[ "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", + "WS_PROXY", + "WSS_PROXY", "FTP_PROXY", "YARN_HTTP_PROXY", "YARN_HTTPS_PROXY", @@ -693,6 +695,10 @@ mod tests { fn recognizes_proxy_env_keys_case_insensitively() { assert!(is_proxy_env_key("HTTP_PROXY")); assert!(is_proxy_env_key("http_proxy")); + assert!(is_proxy_env_key("WS_PROXY")); + assert!(is_proxy_env_key("ws_proxy")); + assert!(is_proxy_env_key("WSS_PROXY")); + assert!(is_proxy_env_key("wss_proxy")); assert!(!is_proxy_env_key("PATH")); } @@ -728,11 +734,15 @@ mod tests { "HTTPS_PROXY".to_string(), "http://example.com:3128".to_string(), ); + env.insert( + "WSS_PROXY".to_string(), + "http://127.0.0.1:43129".to_string(), + ); env.insert("PATH".to_string(), "/usr/bin".to_string()); let plan = plan_proxy_routes(&env); assert!(plan.has_proxy_config); - assert_eq!(plan.routes.len(), 1); + assert_eq!(plan.routes.len(), 2); assert_eq!(plan.routes[0].env_key, "HTTP_PROXY"); assert_eq!( plan.routes[0].endpoint, @@ -740,6 +750,13 @@ mod tests { .parse::() .expect("valid socket") ); + assert_eq!(plan.routes[1].env_key, "WSS_PROXY"); + assert_eq!( + plan.routes[1].endpoint, + "127.0.0.1:43129" + .parse::() + .expect("valid socket") + ); } #[test] diff --git a/src/sandbox.rs b/src/sandbox.rs index 18122dcf..d6d7419a 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -479,7 +479,8 @@ pub fn prepare_worker_command( state: &SandboxState, ) -> Result { #[cfg(target_os = "linux")] - if state.sandbox_policy.has_full_network_access() + if state.sandbox_policy.requires_sandbox() + && state.sandbox_policy.has_full_network_access() && state.managed_network_policy.is_enabled() && !state.use_linux_sandbox_bwrap { @@ -2660,6 +2661,21 @@ mod tests { ); } + #[cfg(target_os = "linux")] + #[test] + fn prepare_worker_command_allows_managed_network_without_bwrap_in_danger_full_access() { + let mut state = SandboxState::default(); + state.sandbox_policy = SandboxPolicy::DangerFullAccess; + state.managed_network_policy.enabled = true; + state.use_linux_sandbox_bwrap = false; + + let prepared = + prepare_worker_command(Path::new("/bin/echo"), vec!["ok".to_string()], &state) + .expect("danger-full-access should not require Linux bwrap"); + + assert_eq!(prepared.program, PathBuf::from("/bin/echo")); + } + #[cfg(target_os = "linux")] #[test] fn prepare_worker_command_keeps_managed_mode_disabled_with_domain_lists() { From a080fd64445a957b8fa783f4207e8cff68aa1d0d Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Sun, 1 Mar 2026 08:52:21 -0500 Subject: [PATCH 11/14] Fix managed-proxy Drop panic and missing writable-root injection - P1: avoid calling Runtime::block_on from ManagedNetworkProxy::Drop on a Tokio runtime thread by storing runtime as Option and offloading shutdown to a dedicated OS thread when inside a runtime. - P2: avoid generating invalid install args by requiring the probed R cache root to exist as a directory before adding --add-writable-root. - Add regression tests for drop-within-runtime behavior and skipping non-existent install-time R writable roots. --- src/install.rs | 30 +++++++++++++++++++--- src/managed_network_proxy.rs | 50 +++++++++++++++++++++++++++++++++--- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/src/install.rs b/src/install.rs index a972aa79..cbd28af3 100644 --- a/src/install.rs +++ b/src/install.rs @@ -386,7 +386,7 @@ fn apply_install_time_r_writable_root( let Some(root) = r_cache_root else { return args; }; - if !root.is_absolute() { + if !root.is_absolute() || !root.is_dir() { return args; } let root = root.to_string_lossy().to_string(); @@ -1176,11 +1176,14 @@ name="demo" #[test] fn apply_install_time_r_writable_root_adds_root_for_r() { + let temp = tempfile::tempdir().expect("tempdir should create"); + let cache_root = temp.path().join("r-cache"); + std::fs::create_dir_all(&cache_root).expect("cache root should create"); let args = apply_install_time_r_writable_root( &["--sandbox".to_string(), "inherit".to_string()], InstallInterpreter::R, false, - Some(Path::new("/tmp/r-cache")), + Some(cache_root.as_path()), ); assert_eq!( args, @@ -1190,7 +1193,28 @@ name="demo" "--interpreter".to_string(), "r".to_string(), "--add-writable-root".to_string(), - "/tmp/r-cache".to_string(), + cache_root.to_string_lossy().to_string(), + ] + ); + } + + #[test] + fn apply_install_time_r_writable_root_skips_missing_root_path() { + let temp = tempfile::tempdir().expect("tempdir should create"); + let missing_root = temp.path().join("missing-r-cache"); + let args = apply_install_time_r_writable_root( + &["--sandbox".to_string(), "inherit".to_string()], + InstallInterpreter::R, + false, + Some(missing_root.as_path()), + ); + assert_eq!( + args, + vec![ + "--sandbox".to_string(), + "inherit".to_string(), + "--interpreter".to_string(), + "r".to_string(), ] ); } diff --git a/src/managed_network_proxy.rs b/src/managed_network_proxy.rs index 3037a754..d19ef98d 100644 --- a/src/managed_network_proxy.rs +++ b/src/managed_network_proxy.rs @@ -14,7 +14,7 @@ use codex_network_proxy::NetworkProxyState; use codex_network_proxy::build_config_state; pub struct ManagedNetworkProxy { - runtime: tokio::runtime::Runtime, + runtime: Option, handle: Option, } @@ -67,7 +67,7 @@ impl ManagedNetworkProxy { proxy.apply_to_env(env); Ok(Some(Self { - runtime, + runtime: Some(runtime), handle: Some(handle), })) } @@ -75,8 +75,23 @@ impl ManagedNetworkProxy { impl Drop for ManagedNetworkProxy { fn drop(&mut self) { - if let Some(handle) = self.handle.take() { - let _ = self.runtime.block_on(async { handle.shutdown().await }); + let Some(handle) = self.handle.take() else { + return; + }; + let Some(runtime) = self.runtime.take() else { + return; + }; + + let shutdown = move || { + let _ = runtime.block_on(async { handle.shutdown().await }); + }; + + if tokio::runtime::Handle::try_current().is_ok() { + let _ = std::thread::Builder::new() + .name("mcp-managed-network-proxy-shutdown".to_string()) + .spawn(shutdown); + } else { + shutdown(); } } } @@ -162,4 +177,31 @@ mod tests { "managed proxy should set NO_PROXY" ); } + + #[test] + fn drop_inside_tokio_runtime_does_not_panic() { + let mut env = HashMap::new(); + let state = managed_state(true); + let proxy = match ManagedNetworkProxy::start_for_state(&state, &mut env) { + Ok(Some(proxy)) => proxy, + Ok(None) => panic!("expected managed proxy to start"), + Err(err) if err.contains("Operation not permitted") => return, + Err(err) => panic!("start_for_state should succeed: {err}"), + }; + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime should build"); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + runtime.block_on(async move { + drop(proxy); + }); + })); + + assert!( + result.is_ok(), + "dropping managed proxy inside a tokio runtime should not panic" + ); + } } From 10b773f44c19360d667f41f223e4171de2129653 Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Sun, 1 Mar 2026 09:00:52 -0500 Subject: [PATCH 12/14] Fix managed-network proxy fixed-port collisions with per-session listeners - P1: switch managed network proxy startup to Codex-managed mode so each session reserves loopback ephemeral HTTP/SOCKS/admin listeners instead of reusing fixed ports. - Add regression test that starts two managed sessions and asserts distinct HTTP_PROXY ports, preventing cross-session port collisions and misrouting. --- src/managed_network_proxy.rs | 45 +++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/src/managed_network_proxy.rs b/src/managed_network_proxy.rs index d19ef98d..ddf179f2 100644 --- a/src/managed_network_proxy.rs +++ b/src/managed_network_proxy.rs @@ -54,7 +54,7 @@ impl ManagedNetworkProxy { .block_on(async { NetworkProxy::builder() .state(Arc::new(state)) - .managed_by_codex(false) + .managed_by_codex(true) .build() .await }) @@ -122,6 +122,7 @@ mod tests { use crate::sandbox::{ManagedNetworkPolicy, SandboxPolicy, SandboxState}; use std::collections::HashMap; use std::path::PathBuf; + use url::Url; fn managed_state(network_access: bool) -> SandboxState { SandboxState { @@ -204,4 +205,46 @@ mod tests { "dropping managed proxy inside a tokio runtime should not panic" ); } + + #[test] + fn start_for_state_uses_distinct_http_proxy_ports_per_session() { + let state = managed_state(true); + let mut env_a = HashMap::new(); + let proxy_a = match ManagedNetworkProxy::start_for_state(&state, &mut env_a) { + Ok(Some(proxy)) => proxy, + Ok(None) => panic!("expected managed proxy to start"), + Err(err) if err.contains("Operation not permitted") => return, + Err(err) => panic!("start_for_state should succeed: {err}"), + }; + let mut env_b = HashMap::new(); + let proxy_b = match ManagedNetworkProxy::start_for_state(&state, &mut env_b) { + Ok(Some(proxy)) => proxy, + Ok(None) => panic!("expected managed proxy to start"), + Err(err) if err.contains("Operation not permitted") => return, + Err(err) => panic!("start_for_state should succeed: {err}"), + }; + + let proxy_a_url = env_a + .get("HTTP_PROXY") + .expect("managed proxy should set HTTP_PROXY"); + let proxy_b_url = env_b + .get("HTTP_PROXY") + .expect("managed proxy should set HTTP_PROXY"); + let port_a = Url::parse(proxy_a_url) + .expect("HTTP_PROXY should be a valid URL") + .port() + .expect("HTTP_PROXY should contain a port"); + let port_b = Url::parse(proxy_b_url) + .expect("HTTP_PROXY should be a valid URL") + .port() + .expect("HTTP_PROXY should contain a port"); + + assert_ne!( + port_a, port_b, + "managed sessions should not share fixed HTTP proxy ports" + ); + + drop(proxy_a); + drop(proxy_b); + } } From 918f9e02af81088b445bb7680ce1db36f25e2e64 Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Mon, 2 Mar 2026 09:26:54 -0500 Subject: [PATCH 13/14] Fix managed-network sandbox tests on Linux --- src/sandbox.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/sandbox.rs b/src/sandbox.rs index d6d7419a..b881ccfb 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -2606,12 +2606,12 @@ mod tests { exclude_tmpdir_env_var: false, exclude_slash_tmp: false, }; + state.use_linux_sandbox_bwrap = true; state.managed_network_policy.allowed_domains.clear(); state.managed_network_policy.denied_domains.clear(); - let prepared = - prepare_worker_command(Path::new("/bin/echo"), vec!["ok".to_string()], &state) - .expect("prepare_worker_command should succeed"); + let prepared_result = + prepare_worker_command(Path::new("/bin/echo"), vec!["ok".to_string()], &state); match previous_env { Some(value) => unsafe { @@ -2622,6 +2622,8 @@ mod tests { }, } + let prepared = prepared_result.expect("prepare_worker_command should succeed"); + assert_eq!( prepared .env @@ -2651,8 +2653,11 @@ mod tests { state.managed_network_policy.enabled = true; state.use_linux_sandbox_bwrap = false; - let err = prepare_worker_command(Path::new("/bin/echo"), vec!["ok".to_string()], &state) - .expect_err("managed network without bwrap should fail"); + let err = + match prepare_worker_command(Path::new("/bin/echo"), vec!["ok".to_string()], &state) { + Ok(_) => panic!("managed network without bwrap should fail"), + Err(err) => err, + }; assert!( err.to_string() From 172a779f23e57d0871e71bb6cbd43c8bb2af6b05 Mon Sep 17 00:00:00 2001 From: Tomasz Kalinowski Date: Mon, 2 Mar 2026 15:24:46 -0500 Subject: [PATCH 14/14] Fix Windows CI test-target build and cross-platform sandbox tests --- Cargo.toml | 1 + src/install.rs | 9 ++++++--- src/main.rs | 10 ++++++---- src/worker_process.rs | 6 ++++-- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4feee87b..cf9b47d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,7 @@ blake3 = "1.8.3" insta = { version = "1.46.3", features = ["yaml"] } portable-pty = "0.9.0" tokio = { version = "1", features = ["fs", "io-std", "io-util", "macros", "net", "process", "rt-multi-thread", "sync", "time"] } +url = "2.5.8" vt100 = "0.16.2" [features] diff --git a/src/install.rs b/src/install.rs index cbd28af3..81dd18fd 100644 --- a/src/install.rs +++ b/src/install.rs @@ -1168,10 +1168,13 @@ name="demo" #[test] fn parse_r_cache_root_probe_output_extracts_absolute_path() { - let root = parse_r_cache_root_probe_output( - "noise\nMCP_REPL_INSTALL_R_CACHE_ROOT=relative/path\nMCP_REPL_INSTALL_R_CACHE_ROOT=/tmp/r-cache\n", + let expected = std::env::temp_dir().join("mcp-repl-r-cache"); + let probe_output = format!( + "noise\nMCP_REPL_INSTALL_R_CACHE_ROOT=relative/path\nMCP_REPL_INSTALL_R_CACHE_ROOT={}\n", + expected.display() ); - assert_eq!(root, Some(PathBuf::from("/tmp/r-cache"))); + let root = parse_r_cache_root_probe_output(&probe_output); + assert_eq!(root, Some(expected)); } #[test] diff --git a/src/main.rs b/src/main.rs index 2d2d8751..91c003a8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -784,14 +784,16 @@ mod tests { #[test] fn workspace_write_config_is_noop_for_inherited_non_workspace_policy() { + let ignored_root = std::env::temp_dir().join("mcp-repl-ignored"); + let writable_roots = + serde_json::to_string(&vec![ignored_root.to_string_lossy().to_string()]) + .expect("writable_roots json"); + let config_override = format!("sandbox_workspace_write.writable_roots={writable_roots}"); let plan = SandboxCliPlan { operations: vec![ SandboxCliOperation::SetMode(SandboxModeArg::Inherit), SandboxCliOperation::Config( - parse_sandbox_config_override( - "sandbox_workspace_write.writable_roots=[\"/tmp/ignored\"]", - ) - .expect("config override"), + parse_sandbox_config_override(&config_override).expect("config override"), ), ], }; diff --git a/src/worker_process.rs b/src/worker_process.rs index 45ac0419..12772b1b 100644 --- a/src/worker_process.rs +++ b/src/worker_process.rs @@ -1495,9 +1495,11 @@ impl WorkerManager { if let Some(process) = self.process.take() { let _ = process.shutdown_graceful(timeout); + self.guardrail.busy.store(false, Ordering::Relaxed); + self.process = Some(self.spawn_process()?); + } else { + self.guardrail.busy.store(false, Ordering::Relaxed); } - self.guardrail.busy.store(false, Ordering::Relaxed); - self.process = Some(self.spawn_process()?); Ok(true) }