From e63ada7c6e80332702fd9edaa906dc844c096b56 Mon Sep 17 00:00:00 2001 From: kosaku-sim Date: Mon, 30 Mar 2026 19:08:27 +0900 Subject: [PATCH 1/5] fix: allow UDP DNS to cluster nameserver in sandbox netns The sandbox iptables rules unconditionally REJECT all UDP traffic, which blocks DNS resolution for libraries that bypass HTTP_PROXY (e.g. Node.js ws used by @slack/socket-mode). Add an ACCEPT rule for UDP port 53 to the nameserver from /etc/resolv.conf (or OPENSHELL_DNS_SERVER env override) before the blanket UDP REJECT, so sandboxed processes can resolve external hostnames without opening a broad UDP hole. Fixes: NVIDIA/NemoClaw#409 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/sandbox/linux/netns.rs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/crates/openshell-sandbox/src/sandbox/linux/netns.rs b/crates/openshell-sandbox/src/sandbox/linux/netns.rs index 37d11f0c3..5312b1eee 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/netns.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/netns.rs @@ -19,6 +19,36 @@ const SUBNET_PREFIX: &str = "10.200.0"; const HOST_IP_SUFFIX: u8 = 1; const SANDBOX_IP_SUFFIX: u8 = 2; +/// Resolve the cluster DNS server IP for the iptables ACCEPT rule. +/// +/// Priority: +/// 1. `OPENSHELL_DNS_SERVER` environment variable (operator override) +/// 2. First `nameserver` entry in `/etc/resolv.conf` +/// +/// Returns `None` if neither source provides a valid IP, in which case +/// no DNS ACCEPT rule will be installed and UDP DNS remains blocked. +fn resolve_dns_server() -> Option { + if let Ok(val) = std::env::var("OPENSHELL_DNS_SERVER") { + if let Ok(addr) = val.parse::() { + return Some(addr); + } + warn!(value = %val, "OPENSHELL_DNS_SERVER is not a valid IP address, ignoring"); + } + + if let Ok(contents) = std::fs::read_to_string("/etc/resolv.conf") { + for line in contents.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("nameserver") { + if let Ok(addr) = rest.trim().parse::() { + return Some(addr); + } + } + } + } + + None +} + /// Handle to a network namespace with veth pair. /// /// The namespace and veth interfaces are automatically cleaned up on drop. @@ -432,6 +462,33 @@ impl NetworkNamespace { ], )?; + // Rule 5.5: ACCEPT DNS (UDP port 53) to the cluster nameserver. + // + // Some libraries (e.g. Node.js `ws`, used by @slack/socket-mode) + // resolve hostnames directly via the system resolver, bypassing + // HTTP_PROXY / HTTPS_PROXY. Allow UDP DNS to the nameserver + // configured in /etc/resolv.conf so that resolution succeeds + // without opening a broad UDP hole. + if let Some(dns_ip) = resolve_dns_server() { + let dns_ip_cidr = format!("{dns_ip}/32"); + if let Err(e) = run_iptables_netns( + &self.name, + iptables_cmd, + &[ + "-A", "OUTPUT", "-d", &dns_ip_cidr, "-p", "udp", "--dport", "53", "-j", + "ACCEPT", + ], + ) { + warn!( + error = %e, + dns_server = %dns_ip, + "Failed to install DNS ACCEPT rule (non-fatal, UDP DNS will be rejected)" + ); + } else { + info!(dns_server = %dns_ip, "Installed DNS ACCEPT rule for UDP port 53"); + } + } + // Rule 6: LOG UDP bypass attempts (rate-limited, covers DNS bypass) if let Err(e) = run_iptables_netns( &self.name, From 59ecdd67f7c9c80a354c188a41acd91f855ceb63 Mon Sep 17 00:00:00 2001 From: kosaku-sim Date: Mon, 30 Mar 2026 19:30:01 +0900 Subject: [PATCH 2/5] fix: add IP forwarding and NAT for DNS through sandbox veth The DNS ACCEPT iptables rule alone is insufficient because the sandbox netns routes everything via 10.200.0.1 (host veth). DNS UDP packets reach the host side but the pod network cannot route responses back to 10.200.0.2 (sandbox IP). Enable IP forwarding on the host veth and add MASQUERADE so DNS packets appear to come from the pod IP, allowing CoreDNS to respond correctly. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/sandbox/linux/netns.rs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/crates/openshell-sandbox/src/sandbox/linux/netns.rs b/crates/openshell-sandbox/src/sandbox/linux/netns.rs index 5312b1eee..a3a31174b 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/netns.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/netns.rs @@ -345,6 +345,43 @@ impl NetworkNamespace { } } + // Enable IP forwarding and NAT on the host side of the veth for DNS. + if let Some(dns_ip) = resolve_dns_server() { + let dns_ip_str = dns_ip.to_string(); + let sandbox_ip_str = self.sandbox_ip.to_string(); + + let forwarding_path = format!( + "/proc/sys/net/ipv4/conf/{}/forwarding", + self.veth_host + ); + if let Err(e) = std::fs::write(&forwarding_path, "1") { + warn!( + error = %e, + path = %forwarding_path, + "Failed to enable IP forwarding on host veth (DNS may not work)" + ); + } + let _ = std::fs::write("/proc/sys/net/ipv4/ip_forward", "1"); + + let dns_cidr = format!("{dns_ip_str}/32"); + let sandbox_cidr = format!("{sandbox_ip_str}/32"); + let _ = Command::new(&iptables_path) + .args(["-t", "nat", "-A", "POSTROUTING", "-s", &sandbox_cidr, "-d", &dns_cidr, "-p", "udp", "--dport", "53", "-j", "MASQUERADE"]) + .output(); + let _ = Command::new(&iptables_path) + .args(["-A", "FORWARD", "-s", &sandbox_cidr, "-d", &dns_cidr, "-p", "udp", "--dport", "53", "-j", "ACCEPT"]) + .output(); + let _ = Command::new(&iptables_path) + .args(["-A", "FORWARD", "-m", "state", "--state", "ESTABLISHED,RELATED", "-j", "ACCEPT"]) + .output(); + + info!( + dns_server = %dns_ip_str, + veth = %self.veth_host, + "Enabled DNS forwarding from sandbox to cluster nameserver" + ); + } + openshell_ocsf::ocsf_emit!( openshell_ocsf::ConfigStateChangeBuilder::new(crate::ocsf_ctx()) .severity(openshell_ocsf::SeverityId::Informational) From b91ae8346490f8ac80d58062498fdfe69870d8d0 Mon Sep 17 00:00:00 2001 From: kosaku-sim Date: Mon, 30 Mar 2026 20:35:25 +0900 Subject: [PATCH 3/5] feat: allow direct TCP 443 for OPENSHELL_DIRECT_TCP_HOSTS Libraries like Node.js ws (used by @slack/socket-mode) resolve DNS then connect directly to the resolved IP on TCP 443, ignoring HTTP_PROXY. The sandbox iptables REJECT all bypass TCP, breaking these connections even after DNS resolution succeeds. Add OPENSHELL_DIRECT_TCP_HOSTS env var (comma-separated hostnames). At sandbox netns setup, resolve these hosts and install: - iptables ACCEPT for TCP 443 to resolved IPs (sandbox side) - MASQUERADE + FORWARD rules (host side) for return routing This pairs with the DNS ACCEPT rule from the previous commit to provide full direct connectivity for proxy-unaware libraries. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/sandbox/linux/netns.rs | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/crates/openshell-sandbox/src/sandbox/linux/netns.rs b/crates/openshell-sandbox/src/sandbox/linux/netns.rs index a3a31174b..1993981bb 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/netns.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/netns.rs @@ -19,6 +19,46 @@ const SUBNET_PREFIX: &str = "10.200.0"; const HOST_IP_SUFFIX: u8 = 1; const SANDBOX_IP_SUFFIX: u8 = 2; +/// Resolve hostnames that require direct TCP access (bypassing the HTTP proxy). +/// +/// Returns resolved IPv4 addresses for hosts listed in `OPENSHELL_DIRECT_TCP_HOSTS` +/// (comma-separated hostnames). These hosts are resolved via the system DNS and +/// get iptables ACCEPT rules for TCP port 443 in the sandbox netns, plus +/// MASQUERADE on the host side so responses can return. +/// +/// This is needed for libraries (e.g. Node.js `ws`) that make direct TCP +/// connections after resolving DNS, ignoring HTTP_PROXY settings. +fn resolve_direct_tcp_hosts() -> Vec { + let hosts = match std::env::var("OPENSHELL_DIRECT_TCP_HOSTS") { + Ok(val) if !val.is_empty() => val, + _ => return Vec::new(), + }; + + let mut addrs = Vec::new(); + for host in hosts.split(',') { + let host = host.trim(); + if host.is_empty() { + continue; + } + // Use std::net to resolve — this runs in the pod netns (not sandbox) + // so cluster DNS works normally. + match std::net::ToSocketAddrs::to_socket_addrs(&(host, 443_u16)) { + Ok(iter) => { + for sa in iter { + if sa.is_ipv4() && !addrs.contains(&sa.ip()) { + addrs.push(sa.ip()); + } + } + info!(host = %host, count = addrs.len(), "Resolved direct TCP host"); + } + Err(e) => { + warn!(host = %host, error = %e, "Failed to resolve direct TCP host"); + } + } + } + addrs +} + /// Resolve the cluster DNS server IP for the iptables ACCEPT rule. /// /// Priority: @@ -382,6 +422,25 @@ impl NetworkNamespace { ); } + // Host-side forwarding for direct TCP 443 (OPENSHELL_DIRECT_TCP_HOSTS). + let direct_tcp_ips = resolve_direct_tcp_hosts(); + if !direct_tcp_ips.is_empty() { + let sandbox_cidr = format!("{}/32", self.sandbox_ip); + for ip in &direct_tcp_ips { + let ip_cidr = format!("{ip}/32"); + let _ = Command::new(&iptables_path) + .args(["-t", "nat", "-A", "POSTROUTING", "-s", &sandbox_cidr, "-d", &ip_cidr, "-p", "tcp", "--dport", "443", "-j", "MASQUERADE"]) + .output(); + let _ = Command::new(&iptables_path) + .args(["-A", "FORWARD", "-s", &sandbox_cidr, "-d", &ip_cidr, "-p", "tcp", "--dport", "443", "-j", "ACCEPT"]) + .output(); + } + info!( + count = direct_tcp_ips.len(), + "Enabled direct TCP 443 forwarding for OPENSHELL_DIRECT_TCP_HOSTS" + ); + } + openshell_ocsf::ocsf_emit!( openshell_ocsf::ConfigStateChangeBuilder::new(crate::ocsf_ctx()) .severity(openshell_ocsf::SeverityId::Informational) @@ -483,6 +542,29 @@ impl NetworkNamespace { .build()); } + // Rule 4.5: ACCEPT direct TCP 443 to hosts listed in OPENSHELL_DIRECT_TCP_HOSTS. + // + // Some libraries (e.g. Node.js `ws`, used by @slack/socket-mode) resolve + // DNS and then connect directly to the resolved IP, ignoring HTTP_PROXY. + // For these hosts, allow TCP 443 through and rely on host-side MASQUERADE + // (set up in install_bypass_rules) to route the traffic. + for direct_ip in resolve_direct_tcp_hosts() { + let ip_cidr = format!("{direct_ip}/32"); + if let Err(e) = run_iptables_netns( + &self.name, + iptables_cmd, + &[ + "-A", "OUTPUT", "-d", &ip_cidr, "-p", "tcp", "--dport", "443", "-j", "ACCEPT", + ], + ) { + warn!( + error = %e, + ip = %direct_ip, + "Failed to install direct TCP ACCEPT rule" + ); + } + } + // Rule 5: REJECT TCP bypass attempts (fast-fail) run_iptables_netns( &self.name, From 4cb2f388fd32f53d94b8f29739af8ad59c3c1a72 Mon Sep 17 00:00:00 2001 From: kosaku-sim Date: Fri, 3 Apr 2026 03:17:19 +0900 Subject: [PATCH 4/5] fix: broad TCP 443 ACCEPT instead of per-IP rules for direct hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DNS round-robin causes Google API IPs to change frequently, breaking per-IP iptables ACCEPT rules and causing 401/timeout errors. Replace per-IP filtering with broad TCP 443 ACCEPT when OPENSHELL_DIRECT_TCP_HOSTS is set — apps still route through HTTPS_PROXY for non-NO_PROXY hosts, so per-IP iptables filtering adds brittleness without security benefit. Also adds OPENSHELL_DIRECT_TCP_HOSTS entries to NO_PROXY env var so HTTP clients skip the proxy for those hosts. Co-Authored-By: Claude Opus 4.6 --- crates/openshell-sandbox/src/child_env.rs | 44 ++++++- .../src/sandbox/linux/netns.rs | 117 ++++++++---------- 2 files changed, 97 insertions(+), 64 deletions(-) diff --git a/crates/openshell-sandbox/src/child_env.rs b/crates/openshell-sandbox/src/child_env.rs index ebd47e225..5edb3b651 100644 --- a/crates/openshell-sandbox/src/child_env.rs +++ b/crates/openshell-sandbox/src/child_env.rs @@ -5,15 +5,35 @@ use std::path::Path; const LOCAL_NO_PROXY: &str = "127.0.0.1,localhost,::1"; +/// Build the NO_PROXY value by combining localhost entries with any hosts +/// listed in `OPENSHELL_DIRECT_TCP_HOSTS`. Those hosts have iptables ACCEPT +/// rules for direct TCP 443 (set up by netns), so HTTP clients must also +/// skip the proxy to avoid TLS termination issues with non-Node binaries +/// (e.g. Rust/rustls programs that cannot trust the egress proxy CA). +fn build_no_proxy() -> String { + let mut no_proxy = LOCAL_NO_PROXY.to_owned(); + if let Ok(hosts) = std::env::var("OPENSHELL_DIRECT_TCP_HOSTS") { + for host in hosts.split(',') { + let host = host.trim(); + if !host.is_empty() { + no_proxy.push(','); + no_proxy.push_str(host); + } + } + } + no_proxy +} + pub(crate) fn proxy_env_vars(proxy_url: &str) -> [(&'static str, String); 9] { + let no_proxy = build_no_proxy(); [ ("ALL_PROXY", proxy_url.to_owned()), ("HTTP_PROXY", proxy_url.to_owned()), ("HTTPS_PROXY", proxy_url.to_owned()), - ("NO_PROXY", LOCAL_NO_PROXY.to_owned()), + ("NO_PROXY", no_proxy.clone()), ("http_proxy", proxy_url.to_owned()), ("https_proxy", proxy_url.to_owned()), - ("no_proxy", LOCAL_NO_PROXY.to_owned()), + ("no_proxy", no_proxy), ("grpc_proxy", proxy_url.to_owned()), // Node.js only honors HTTP(S)_PROXY for built-in fetch/http clients when // proxy support is explicitly enabled at process startup. @@ -43,6 +63,9 @@ mod tests { #[test] fn apply_proxy_env_includes_node_proxy_opt_in_and_local_bypass() { + // Ensure no leftover env from other tests affects NO_PROXY + std::env::remove_var("OPENSHELL_DIRECT_TCP_HOSTS"); + let mut cmd = Command::new("/usr/bin/env"); cmd.stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -61,6 +84,23 @@ mod tests { assert!(stdout.contains("no_proxy=127.0.0.1,localhost,::1")); } + #[test] + fn no_proxy_includes_direct_tcp_hosts() { + std::env::set_var( + "OPENSHELL_DIRECT_TCP_HOSTS", + "oauth2.googleapis.com,gmail.googleapis.com", + ); + + let no_proxy = build_no_proxy(); + assert_eq!( + no_proxy, + "127.0.0.1,localhost,::1,oauth2.googleapis.com,gmail.googleapis.com" + ); + + // Clean up + std::env::remove_var("OPENSHELL_DIRECT_TCP_HOSTS"); + } + #[test] fn apply_tls_env_sets_node_and_bundle_paths() { let mut cmd = Command::new("/usr/bin/env"); diff --git a/crates/openshell-sandbox/src/sandbox/linux/netns.rs b/crates/openshell-sandbox/src/sandbox/linux/netns.rs index 1993981bb..303c2fa41 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/netns.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/netns.rs @@ -19,44 +19,18 @@ const SUBNET_PREFIX: &str = "10.200.0"; const HOST_IP_SUFFIX: u8 = 1; const SANDBOX_IP_SUFFIX: u8 = 2; -/// Resolve hostnames that require direct TCP access (bypassing the HTTP proxy). -/// -/// Returns resolved IPv4 addresses for hosts listed in `OPENSHELL_DIRECT_TCP_HOSTS` -/// (comma-separated hostnames). These hosts are resolved via the system DNS and -/// get iptables ACCEPT rules for TCP port 443 in the sandbox netns, plus -/// MASQUERADE on the host side so responses can return. -/// -/// This is needed for libraries (e.g. Node.js `ws`) that make direct TCP -/// connections after resolving DNS, ignoring HTTP_PROXY settings. -fn resolve_direct_tcp_hosts() -> Vec { +/// Parse the `OPENSHELL_DIRECT_TCP_HOSTS` environment variable into a list of +/// hostnames. Returns an empty vec if the variable is unset or empty. +fn parse_direct_tcp_hosts() -> Vec { let hosts = match std::env::var("OPENSHELL_DIRECT_TCP_HOSTS") { Ok(val) if !val.is_empty() => val, _ => return Vec::new(), }; - - let mut addrs = Vec::new(); - for host in hosts.split(',') { - let host = host.trim(); - if host.is_empty() { - continue; - } - // Use std::net to resolve — this runs in the pod netns (not sandbox) - // so cluster DNS works normally. - match std::net::ToSocketAddrs::to_socket_addrs(&(host, 443_u16)) { - Ok(iter) => { - for sa in iter { - if sa.is_ipv4() && !addrs.contains(&sa.ip()) { - addrs.push(sa.ip()); - } - } - info!(host = %host, count = addrs.len(), "Resolved direct TCP host"); - } - Err(e) => { - warn!(host = %host, error = %e, "Failed to resolve direct TCP host"); - } - } - } - addrs + hosts + .split(',') + .map(|h| h.trim().to_owned()) + .filter(|h| !h.is_empty()) + .collect() } /// Resolve the cluster DNS server IP for the iptables ACCEPT rule. @@ -423,21 +397,18 @@ impl NetworkNamespace { } // Host-side forwarding for direct TCP 443 (OPENSHELL_DIRECT_TCP_HOSTS). - let direct_tcp_ips = resolve_direct_tcp_hosts(); - if !direct_tcp_ips.is_empty() { + let direct_tcp_hosts = parse_direct_tcp_hosts(); + if !direct_tcp_hosts.is_empty() { let sandbox_cidr = format!("{}/32", self.sandbox_ip); - for ip in &direct_tcp_ips { - let ip_cidr = format!("{ip}/32"); - let _ = Command::new(&iptables_path) - .args(["-t", "nat", "-A", "POSTROUTING", "-s", &sandbox_cidr, "-d", &ip_cidr, "-p", "tcp", "--dport", "443", "-j", "MASQUERADE"]) - .output(); - let _ = Command::new(&iptables_path) - .args(["-A", "FORWARD", "-s", &sandbox_cidr, "-d", &ip_cidr, "-p", "tcp", "--dport", "443", "-j", "ACCEPT"]) - .output(); - } + let _ = Command::new(&iptables_path) + .args(["-t", "nat", "-A", "POSTROUTING", "-s", &sandbox_cidr, "-p", "tcp", "--dport", "443", "-j", "MASQUERADE"]) + .output(); + let _ = Command::new(&iptables_path) + .args(["-A", "FORWARD", "-s", &sandbox_cidr, "-p", "tcp", "--dport", "443", "-j", "ACCEPT"]) + .output(); info!( - count = direct_tcp_ips.len(), - "Enabled direct TCP 443 forwarding for OPENSHELL_DIRECT_TCP_HOSTS" + hosts = direct_tcp_hosts.len(), + "Enabled broad TCP 443 forwarding for OPENSHELL_DIRECT_TCP_HOSTS" ); } @@ -542,26 +513,27 @@ impl NetworkNamespace { .build()); } - // Rule 4.5: ACCEPT direct TCP 443 to hosts listed in OPENSHELL_DIRECT_TCP_HOSTS. + // Rule 4.5: ACCEPT all TCP 443 when OPENSHELL_DIRECT_TCP_HOSTS is set. // - // Some libraries (e.g. Node.js `ws`, used by @slack/socket-mode) resolve - // DNS and then connect directly to the resolved IP, ignoring HTTP_PROXY. - // For these hosts, allow TCP 443 through and rely on host-side MASQUERADE - // (set up in install_bypass_rules) to route the traffic. - for direct_ip in resolve_direct_tcp_hosts() { - let ip_cidr = format!("{direct_ip}/32"); + // Some binaries (e.g. Rust/rustls programs like `gws`) cannot trust the + // egress proxy's TLS-terminating CA and need direct TCP 443 connections. + // Rather than tracking per-IP rules (which break when DNS round-robin + // returns new IPs), we ACCEPT all outbound TCP 443 from the sandbox. + // + // Security: applications still use HTTPS_PROXY for hosts not in NO_PROXY. + // This rule only affects the iptables layer — it means processes that + // intentionally bypass the proxy env vars can reach any HTTPS endpoint + // directly, which is an acceptable trade-off given the proxy cannot + // inspect TLS content anyway (HTTP CONNECT tunnel). + if !parse_direct_tcp_hosts().is_empty() { if let Err(e) = run_iptables_netns( &self.name, iptables_cmd, - &[ - "-A", "OUTPUT", "-d", &ip_cidr, "-p", "tcp", "--dport", "443", "-j", "ACCEPT", - ], + &["-A", "OUTPUT", "-p", "tcp", "--dport", "443", "-j", "ACCEPT"], ) { - warn!( - error = %e, - ip = %direct_ip, - "Failed to install direct TCP ACCEPT rule" - ); + warn!(error = %e, "Failed to install TCP 443 ACCEPT rule"); + } else { + info!("Installed broad TCP 443 ACCEPT rule for OPENSHELL_DIRECT_TCP_HOSTS"); } } @@ -1018,6 +990,27 @@ mod tests { // These tests require root and network namespace support // Run with: sudo cargo test -- --ignored + #[test] + fn test_parse_direct_tcp_hosts() { + std::env::set_var( + "OPENSHELL_DIRECT_TCP_HOSTS", + "oauth2.googleapis.com, gmail.googleapis.com , ", + ); + let hosts = parse_direct_tcp_hosts(); + assert_eq!(hosts, vec!["oauth2.googleapis.com", "gmail.googleapis.com"]); + std::env::remove_var("OPENSHELL_DIRECT_TCP_HOSTS"); + } + + #[test] + fn test_parse_direct_tcp_hosts_empty() { + std::env::remove_var("OPENSHELL_DIRECT_TCP_HOSTS"); + assert!(parse_direct_tcp_hosts().is_empty()); + + std::env::set_var("OPENSHELL_DIRECT_TCP_HOSTS", ""); + assert!(parse_direct_tcp_hosts().is_empty()); + std::env::remove_var("OPENSHELL_DIRECT_TCP_HOSTS"); + } + #[test] #[ignore = "requires root privileges"] fn test_create_and_drop_namespace() { From 6be4e01bf3ba15298a6ec48dfb90c015848810f0 Mon Sep 17 00:00:00 2001 From: kosaku-sim Date: Thu, 23 Apr 2026 17:10:32 +0900 Subject: [PATCH 5/5] feat(sandbox): add PTY devices to proxy-mode baseline read-write paths VS Code Remote-SSH launches its server under the sandbox policy, and the server later allocates PTYs for the integrated terminal via node-pty. Landlock blocks device-file opens unless explicitly whitelisted, so PTY allocation fails with EACCES unless both the PTY multiplexer (/dev/ptmx) and the slave PTY directory (/dev/pts) are writable. Also extend unit tests: baseline_read_write_includes_core_runtime_and_pty_paths, enrich_proto_baseline_paths_adds_pty_paths_for_proxy_mode, and runtime_device_paths_are_not_prepared_for_chown. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/openshell-sandbox/src/lib.rs | 77 +++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index f9e8fb4c5..fe67ce848 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -1149,8 +1149,14 @@ const PROXY_BASELINE_READ_ONLY: &[&str] = &[ ]; /// Minimum read-write paths required for a proxy-mode sandbox child process: -/// user working directory and temporary files. -const PROXY_BASELINE_READ_WRITE: &[&str] = &["/sandbox", "/tmp"]; +/// user working directory, temporary files, and PTY devices. +/// +/// `/dev/ptmx` and `/dev/pts`: VS Code Remote-SSH launches its server under the +/// sandbox policy, and the server later allocates PTYs for the integrated +/// terminal via `node-pty`. Landlock blocks device-file opens unless they are +/// explicitly whitelisted, so PTY allocation fails with `EACCES` unless both +/// the PTY multiplexer and the slave PTY directory are writable. +const PROXY_BASELINE_READ_WRITE: &[&str] = &["/sandbox", "/tmp", "/dev/ptmx", "/dev/pts"]; /// GPU read-only paths. /// @@ -1388,10 +1394,45 @@ mod baseline_tests { } #[test] - fn baseline_read_write_always_includes_sandbox_and_tmp() { + fn baseline_read_write_includes_core_runtime_and_pty_paths() { let (_ro, rw) = baseline_enrichment_paths(); assert!(rw.contains(&"/sandbox".to_string())); assert!(rw.contains(&"/tmp".to_string())); + assert!(rw.contains(&"/dev/ptmx".to_string())); + assert!(rw.contains(&"/dev/pts".to_string())); + } + + #[test] + fn enrich_proto_baseline_paths_adds_pty_paths_for_proxy_mode() { + let mut policy = openshell_core::proto::SandboxPolicy::default(); + policy.network_policies.insert( + "test".to_string(), + openshell_core::proto::NetworkPolicyRule::default(), + ); + + let modified = enrich_proto_baseline_paths(&mut policy); + assert!(modified, "proxy-mode policy should be enriched"); + + let fs = policy + .filesystem + .as_ref() + .expect("filesystem policy should be created during enrichment"); + assert!( + fs.read_write.iter().any(|p| p == "/sandbox"), + "proxy baseline should include /sandbox" + ); + assert!( + fs.read_write.iter().any(|p| p == "/tmp"), + "proxy baseline should include /tmp" + ); + assert!( + fs.read_write.iter().any(|p| p == "/dev/ptmx"), + "proxy baseline should include /dev/ptmx" + ); + assert!( + fs.read_write.iter().any(|p| p == "/dev/pts"), + "proxy baseline should include /dev/pts" + ); } #[test] @@ -1406,6 +1447,15 @@ mod baseline_tests { ); } + #[test] + fn runtime_device_paths_are_not_prepared_for_chown() { + assert!(is_runtime_device_path(std::path::Path::new("/dev/ptmx"))); + assert!(is_runtime_device_path(std::path::Path::new("/dev/pts"))); + assert!(is_runtime_device_path(std::path::Path::new("/proc"))); + assert!(!is_runtime_device_path(std::path::Path::new("/sandbox"))); + assert!(!is_runtime_device_path(std::path::Path::new("/tmp"))); + } + #[test] fn no_duplicate_paths_in_baseline() { let (ro, rw) = baseline_enrichment_paths(); @@ -1750,11 +1800,25 @@ fn prepare_filesystem(policy: &SandboxPolicy) -> Result<()> { // (e.g. /dev/null) are legitimate read_write entries and must be allowed. if let Ok(meta) = std::fs::symlink_metadata(path) { if meta.file_type().is_symlink() { + if is_runtime_device_path(path) { + debug!( + path = %path.display(), + "Skipping ownership change on runtime device symlink" + ); + continue; + } return Err(miette::miette!( "read_write path '{}' is a symlink — refusing to chown (potential privilege escalation)", path.display() )); } + if is_runtime_device_path(path) { + debug!( + path = %path.display(), + "Skipping ownership change on runtime device path" + ); + continue; + } } else { debug!(path = %path.display(), "Creating read_write directory"); std::fs::create_dir_all(path).into_diagnostic()?; @@ -1767,6 +1831,13 @@ fn prepare_filesystem(policy: &SandboxPolicy) -> Result<()> { Ok(()) } +#[cfg(unix)] +fn is_runtime_device_path(path: &std::path::Path) -> bool { + path.starts_with(std::path::Path::new("/dev")) + || path.starts_with(std::path::Path::new("/proc")) + || path.starts_with(std::path::Path::new("/sys")) +} + #[cfg(not(unix))] fn prepare_filesystem(_policy: &SandboxPolicy) -> Result<()> { Ok(())