Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 76 additions & 2 deletions crates/openshell-sandbox/src/child_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,51 @@ 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` / `OPENSHELL_DIRECT_TCP_ENDPOINTS`.
/// Those hosts have iptables ACCEPT rules for direct TCP (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();
let mut push_host = |raw: &str| {
let host = raw.trim();
if host.is_empty() {
return;
}
no_proxy.push(',');
no_proxy.push_str(host);
};
if let Ok(hosts) = std::env::var("OPENSHELL_DIRECT_TCP_HOSTS") {
for host in hosts.split(',') {
push_host(host);
}
}
if let Ok(endpoints) = std::env::var("OPENSHELL_DIRECT_TCP_ENDPOINTS") {
for entry in endpoints.split(',') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
let host = entry.rsplit_once(':').map(|(h, _)| h).unwrap_or(entry);
let host = host.trim().trim_start_matches('[').trim_end_matches(']');
push_host(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.
Expand Down Expand Up @@ -43,6 +79,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())
Expand All @@ -61,6 +100,41 @@ mod tests {
assert!(stdout.contains("no_proxy=127.0.0.1,localhost,::1"));
}

#[test]
fn no_proxy_includes_direct_tcp_hosts() {
std::env::remove_var("OPENSHELL_DIRECT_TCP_ENDPOINTS");
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 no_proxy_includes_direct_tcp_endpoints() {
std::env::remove_var("OPENSHELL_DIRECT_TCP_HOSTS");
std::env::set_var(
"OPENSHELL_DIRECT_TCP_ENDPOINTS",
"10.0.1.215:5432, 10.0.1.215:6379 , db.internal:1025,",
);

let no_proxy = build_no_proxy();
assert_eq!(
no_proxy,
"127.0.0.1,localhost,::1,10.0.1.215,10.0.1.215,db.internal"
);

std::env::remove_var("OPENSHELL_DIRECT_TCP_ENDPOINTS");
}

#[test]
fn apply_tls_env_sets_node_and_bundle_paths() {
let mut cmd = Command::new("/usr/bin/env");
Expand Down
77 changes: 74 additions & 3 deletions crates/openshell-sandbox/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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]
Expand All @@ -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();
Expand Down Expand Up @@ -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()?;
Expand All @@ -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(())
Expand Down
Loading