diff --git a/crates/openshell-core/testdata/orin-nvidia.yaml b/crates/openshell-core/testdata/orin-nvidia.yaml index 1fdc0aaeaa..1c0b76bcc1 100644 --- a/crates/openshell-core/testdata/orin-nvidia.yaml +++ b/crates/openshell-core/testdata/orin-nvidia.yaml @@ -2216,4 +2216,3 @@ containerEdits: - nodev - rbind - rprivate - diff --git a/crates/openshell-sandbox/src/boundary_exec.rs b/crates/openshell-sandbox/src/boundary_exec.rs index da35294c06..1a3d835be4 100644 --- a/crates/openshell-sandbox/src/boundary_exec.rs +++ b/crates/openshell-sandbox/src/boundary_exec.rs @@ -35,6 +35,7 @@ pub struct LocalBoundaryExec { ca_file_paths: Option>, provider_credentials: ProviderCredentialState, user_environment: HashMap, + repair_standard_sbin: bool, runtime: Arc, #[cfg(target_os = "linux")] launcher: openshell_isolation_interface::linux::workload_launcher::WorkloadLauncher, @@ -59,6 +60,7 @@ impl LocalBoundaryExec { ca_file_paths, provider_credentials, user_environment, + repair_standard_sbin: false, runtime, #[cfg(target_os = "linux")] launcher, @@ -121,6 +123,13 @@ impl LocalBoundaryExec { }) } + /// Configure whether workload paths should include the standard sbin directories. + #[must_use] + pub fn with_standard_sbin_path_repair(mut self, enabled: bool) -> Self { + self.repair_standard_sbin = enabled; + self + } + fn command(&self, spec: &ExecSpec) -> Result { let (program, args) = if let Some(shell_spec) = &spec.shell { let shell = openshell_core::shell::find_login_shell().ok_or_else(|| { @@ -151,12 +160,18 @@ impl LocalBoundaryExec { if program.is_empty() { return Err(BackendError::Process("exec program is empty".to_string())); } + let repair_standard_sbin = self.repair_standard_sbin; + let args = crate::process::process_args_with_standard_sbin_paths( + &program, + &args, + repair_standard_sbin, + ); let mut command = Command::new(&program); - command.args(&args); + command.args(args); let effective_workdir = spec.workdir.as_deref().or(self.base_workdir.as_deref()); let (session_user, session_home) = crate::process::session_user_and_home(&self.policy, effective_workdir); - let path = std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into()); + let path = crate::child_env::child_path_from_env(repair_standard_sbin); command .env_clear() .env(openshell_core::sandbox_env::SANDBOX, "1") @@ -169,7 +184,17 @@ impl LocalBoundaryExec { } for (key, value) in &self.user_environment { if !key.starts_with("OPENSHELL_") { - command.env(key, value); + if key == "PATH" { + command.env( + key, + crate::child_env::maybe_path_with_standard_sbin_paths( + value, + repair_standard_sbin, + ), + ); + } else { + command.env(key, value); + } } } if let Some((ca_cert_path, combined_bundle_path)) = self.ca_file_paths.as_deref() { @@ -179,13 +204,33 @@ impl LocalBoundaryExec { } for (key, value) in self.provider_credentials.child_env_with_gcp_resolved() { if !crate::process::is_supervisor_only_env_var(&key) { - command.env(key, value); + if key == "PATH" { + command.env( + key, + crate::child_env::maybe_path_with_standard_sbin_paths( + &value, + repair_standard_sbin, + ), + ); + } else { + command.env(key, value); + } } } crate::process::strip_proxy_env_std(&mut command); for (key, value) in &spec.env { if !key.starts_with("OPENSHELL_") { - command.env(key, value); + if key == "PATH" { + command.env( + key, + crate::child_env::maybe_path_with_standard_sbin_paths( + value, + repair_standard_sbin, + ), + ); + } else { + command.env(key, value); + } } } if let Some(workdir) = spec.workdir.as_deref().or(self.base_workdir.as_deref()) { diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index c095402156..829f5db949 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -2732,6 +2732,7 @@ mod linux { process_id: format!("{}:main:0", self.config.generation), spec, policy, + cdi_active: self.config.cdi_context.is_some(), provider_env_revision, provider_env, ca_file_paths, @@ -3124,6 +3125,7 @@ mod linux { process_id: String, spec: AgentSpecWire, policy: openshell_core::policy::SandboxPolicy, + cdi_active: bool, provider_env_revision: u64, provider_env: std::collections::HashMap, ca_file_paths: Option<(std::path::PathBuf, std::path::PathBuf)>, @@ -3158,6 +3160,7 @@ mod linux { process_id, spec, policy, + cdi_active, provider_env_revision, provider_env, ca_file_paths, @@ -3178,6 +3181,7 @@ mod linux { spec.timeout_secs, spec.interactive, &policy, + cdi_active, entrypoint_pid, provider_credentials.clone(), provider_env, @@ -5734,6 +5738,7 @@ containerEdits: process_id: "generation-retained:main:0".to_string(), spec: agent_spec.clone(), policy, + cdi_active: false, provider_env_revision: 0, provider_env: std::collections::HashMap::new(), ca_file_paths: None, diff --git a/crates/openshell-sandbox/src/child_env.rs b/crates/openshell-sandbox/src/child_env.rs index 50549a7439..8cea781cf8 100644 --- a/crates/openshell-sandbox/src/child_env.rs +++ b/crates/openshell-sandbox/src/child_env.rs @@ -1,8 +1,215 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +use std::io::{Read, Write}; use std::path::Path; +use openshell_core::policy::SandboxPolicy; + +pub const DEFAULT_CHILD_PATH: &str = "/usr/local/bin:/usr/bin:/bin"; +const STANDARD_SBIN_PATHS: &[&str] = &["/usr/local/sbin", "/usr/sbin", "/sbin"]; +const ENSURE_STANDARD_SBIN_PATHS_SCRIPT: &str = "for dir in /usr/local/sbin /usr/sbin /sbin; do case \":${PATH:-}:\" in *:\"$dir\":*) ;; *) PATH=\"${PATH:+$PATH:}$dir\" ;; esac; done; export PATH"; +const STARTUP_SNIPPET_MARKER: &str = "# OpenShell standard sbin PATH"; + +enum StartupFile { + Missing, + Regular(String), + Unsafe, +} + +pub fn standard_sbin_path_repair_enabled(policy: &SandboxPolicy, cdi_active: bool) -> bool { + cdi_active && policy_has_standard_sbin_path(policy) +} + +fn policy_has_standard_sbin_path(policy: &SandboxPolicy) -> bool { + policy + .filesystem + .read_only + .iter() + .chain(policy.filesystem.read_write.iter()) + .any(|path| { + STANDARD_SBIN_PATHS + .iter() + .any(|dir| Path::new(dir).starts_with(path) || path.starts_with(dir)) + }) +} + +pub fn child_path_from_env(repair_standard_sbin: bool) -> String { + let path = std::env::var("PATH") + .ok() + .filter(|path| !path.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_CHILD_PATH.to_string()); + + maybe_path_with_standard_sbin_paths(&path, repair_standard_sbin) +} + +pub fn maybe_path_with_standard_sbin_paths(path: &str, repair_standard_sbin: bool) -> String { + if repair_standard_sbin { + path_with_standard_sbin_paths(path) + } else { + path.to_string() + } +} + +pub fn path_with_standard_sbin_paths(path: &str) -> String { + let mut path = if path.trim().is_empty() { + DEFAULT_CHILD_PATH.to_string() + } else { + path.to_string() + }; + + for dir in STANDARD_SBIN_PATHS { + if !path.split(':').any(|entry| entry == *dir) { + if !path.is_empty() { + path.push(':'); + } + path.push_str(dir); + } + } + + path +} + +pub fn shell_command_with_standard_sbin_paths(command: &str) -> String { + format!("{ENSURE_STANDARD_SBIN_PATHS_SCRIPT}\n{command}") +} + +pub fn maybe_shell_command_with_standard_sbin_paths( + command: &str, + repair_standard_sbin: bool, +) -> String { + if repair_standard_sbin { + shell_command_with_standard_sbin_paths(command) + } else { + command.to_string() + } +} + +pub fn install_standard_sbin_path_startup_files(home: Option<&str>) { + if let Some(home) = home { + let bashrc_path = Path::new(home).join(".bashrc"); + if let Err(error) = append_startup_snippet(&bashrc_path) { + tracing::debug!( + path = %bashrc_path.display(), + error = %error, + "failed to install OpenShell PATH shell startup snippet" + ); + } + } +} + +fn startup_snippet() -> String { + format!("{STARTUP_SNIPPET_MARKER}\n{ENSURE_STANDARD_SBIN_PATHS_SCRIPT}\n") +} + +fn append_startup_snippet(path: &Path) -> std::io::Result<()> { + if let Some(parent) = path.parent() + && !existing_directory_without_symlink(parent)? + { + return Ok(()); + } + + let existing = match read_startup_file(path)? { + StartupFile::Regular(content) => content, + StartupFile::Missing | StartupFile::Unsafe => return Ok(()), + }; + if existing.contains(STARTUP_SNIPPET_MARKER) { + return Ok(()); + } + + let snippet = startup_snippet(); + let mut file = open_startup_file_for_append(path)?; + if !existing.is_empty() && !existing.ends_with('\n') { + file.write_all(b"\n")?; + } + file.write_all(snippet.as_bytes()) +} + +fn read_startup_file(path: &Path) -> std::io::Result { + let metadata = match std::fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(StartupFile::Missing); + } + Err(error) => return Err(error), + }; + let file_type = metadata.file_type(); + if file_type.is_symlink() || !file_type.is_file() { + tracing::debug!( + path = %path.display(), + "skipping OpenShell PATH startup repair for non-regular file" + ); + return Ok(StartupFile::Unsafe); + } + + let mut content = String::new(); + open_startup_file_for_read(path)?.read_to_string(&mut content)?; + Ok(StartupFile::Regular(content)) +} + +fn existing_directory_without_symlink(path: &Path) -> std::io::Result { + if let Some(parent) = path.parent() + && parent != path + && !parent.as_os_str().is_empty() + && !existing_directory_without_symlink(parent)? + { + return Ok(false); + } + + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + tracing::debug!( + path = %path.display(), + "skipping OpenShell PATH startup repair through symlink directory" + ); + Ok(false) + } + Ok(metadata) if metadata.is_dir() => Ok(true), + Ok(_) => { + tracing::debug!( + path = %path.display(), + "skipping OpenShell PATH startup repair through non-directory path" + ); + Ok(false) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } +} + +fn open_startup_file_for_read(path: &Path) -> std::io::Result { + let file = no_follow_options().read(true).open(path)?; + ensure_opened_file_is_regular(&file, path)?; + Ok(file) +} + +fn open_startup_file_for_append(path: &Path) -> std::io::Result { + let file = no_follow_options().append(true).open(path)?; + ensure_opened_file_is_regular(&file, path)?; + Ok(file) +} + +fn ensure_opened_file_is_regular(file: &std::fs::File, path: &Path) -> std::io::Result<()> { + let metadata = file.metadata()?; + if metadata.is_file() { + return Ok(()); + } + Err(std::io::Error::other(format!( + "'{}' is not a regular file", + path.display() + ))) +} + +fn no_follow_options() -> std::fs::OpenOptions { + let mut options = std::fs::OpenOptions::new(); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + } + options +} + pub fn tls_env_vars( ca_cert_path: &Path, combined_bundle_path: &Path, @@ -27,6 +234,198 @@ mod tests { use std::process::Command; use std::process::Stdio; + #[test] + fn path_with_standard_sbin_paths_uses_default_for_empty_path() { + assert_eq!( + path_with_standard_sbin_paths(""), + "/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin" + ); + } + + #[test] + fn path_with_standard_sbin_paths_appends_missing_sbin_dirs() { + assert_eq!( + path_with_standard_sbin_paths("/sandbox/.venv/bin:/usr/local/bin:/usr/bin:/bin"), + "/sandbox/.venv/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin" + ); + } + + #[test] + fn path_with_standard_sbin_paths_is_idempotent() { + let path = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; + assert_eq!(path_with_standard_sbin_paths(path), path); + } + + #[test] + fn maybe_path_with_standard_sbin_paths_respects_gate() { + let path = "/sandbox/.venv/bin:/usr/local/bin:/usr/bin:/bin"; + + assert_eq!(maybe_path_with_standard_sbin_paths(path, false), path); + assert_eq!( + maybe_path_with_standard_sbin_paths(path, true), + "/sandbox/.venv/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin" + ); + } + + #[test] + fn maybe_shell_command_with_standard_sbin_paths_respects_gate() { + assert_eq!( + maybe_shell_command_with_standard_sbin_paths("nvidia-smi -L", false), + "nvidia-smi -L" + ); + assert!( + maybe_shell_command_with_standard_sbin_paths("nvidia-smi -L", true) + .contains("/usr/sbin") + ); + } + + #[test] + fn standard_sbin_repair_requires_cdi_context_and_policy_path() { + let policy = policy_with_read_only(["/usr/sbin/nvidia-smi"]); + + assert!(standard_sbin_path_repair_enabled(&policy, true)); + assert!(!standard_sbin_path_repair_enabled(&policy, false)); + } + + #[test] + fn standard_sbin_repair_requires_standard_sbin_policy_path() { + let policy = policy_with_read_only(["/usr/local/bin/nvidia-smi"]); + + assert!(!standard_sbin_path_repair_enabled(&policy, true)); + } + + #[test] + fn standard_sbin_repair_accepts_read_write_standard_sbin_policy_path() { + let mut policy = policy_with_read_only(std::iter::empty::<&str>()); + policy + .filesystem + .read_write + .push("/sbin/vendor-tool".into()); + + assert!(standard_sbin_path_repair_enabled(&policy, true)); + } + + #[test] + fn standard_sbin_repair_accepts_read_only_ancestor() { + let policy = policy_with_read_only(["/usr"]); + + assert!(standard_sbin_path_repair_enabled(&policy, true)); + } + + fn policy_with_read_only( + paths: impl IntoIterator>, + ) -> SandboxPolicy { + SandboxPolicy { + version: 1, + filesystem: openshell_core::policy::FilesystemPolicy { + read_only: paths.into_iter().map(Into::into).collect(), + read_write: Vec::new(), + include_workdir: false, + }, + network: openshell_core::policy::NetworkPolicy::default(), + landlock: openshell_core::policy::LandlockPolicy::default(), + process: openshell_core::policy::ProcessPolicy::default(), + } + } + + #[test] + fn shell_command_with_standard_sbin_paths_extends_runtime_path() { + let command = shell_command_with_standard_sbin_paths("printf '%s' \"$PATH\""); + let output = Command::new("/bin/sh") + .arg("-c") + .arg(format!( + "PATH=/sandbox/.venv/bin:/usr/local/bin:/usr/bin:/bin\n{command}" + )) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .expect("spawn shell"); + + assert!( + output.status.success(), + "shell command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + String::from_utf8(output.stdout).expect("utf8"), + "/sandbox/.venv/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin" + ); + } + + #[test] + fn bashrc_startup_snippet_is_idempotent() { + let dir = tempfile::tempdir().expect("tempdir"); + // macOS commonly exposes its temporary directory through `/var`, a + // symlink to `/private/var`. Use the canonical directory because the + // startup-file writer deliberately refuses to traverse symlinked + // ancestors. + let root = dir.path().canonicalize().expect("canonical tempdir"); + let home = root.join("sandbox"); + std::fs::create_dir_all(&home).expect("home dir"); + let bashrc_path = home.join(".bashrc"); + std::fs::write( + &bashrc_path, + "export PATH=\"/sandbox/.venv/bin:/usr/local/bin:/usr/bin:/bin\"\n", + ) + .expect("write bashrc"); + + append_startup_snippet(&bashrc_path).expect("append startup snippet"); + append_startup_snippet(&bashrc_path).expect("append startup snippet again"); + + let bashrc = std::fs::read_to_string(&bashrc_path).expect("read bashrc"); + + assert_eq!(bashrc.matches(STARTUP_SNIPPET_MARKER).count(), 1); + assert!(bashrc.contains("export PATH=\"/sandbox/.venv/bin:/usr/local/bin:/usr/bin:/bin\"")); + } + + #[cfg(unix)] + #[test] + fn bashrc_snippet_skips_symlink() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().expect("tempdir"); + let target = dir.path().join("target"); + std::fs::write(&target, "keep me").expect("write target"); + let bashrc_path = dir.path().join(".bashrc"); + symlink(&target, &bashrc_path).expect("symlink bashrc"); + + append_startup_snippet(&bashrc_path).expect("skip symlink bashrc"); + + assert_eq!( + std::fs::read_to_string(&target).expect("read target"), + "keep me" + ); + assert!( + std::fs::symlink_metadata(&bashrc_path) + .expect("bashrc metadata") + .file_type() + .is_symlink() + ); + } + + #[cfg(unix)] + #[test] + fn bashrc_snippet_skips_symlink_parent() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().expect("tempdir"); + let target_dir = dir.path().join("target-dir"); + std::fs::create_dir(&target_dir).expect("target dir"); + std::fs::write( + target_dir.join(".bashrc"), + "export PATH=\"/sandbox/.venv/bin:/usr/local/bin:/usr/bin:/bin\"\n", + ) + .expect("target bashrc"); + let home = dir.path().join("home"); + symlink(&target_dir, &home).expect("symlink home"); + + append_startup_snippet(&home.join(".bashrc")).expect("skip symlink home"); + + let bashrc = std::fs::read_to_string(target_dir.join(".bashrc")).expect("read target"); + assert!(!bashrc.contains(STARTUP_SNIPPET_MARKER)); + } + #[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/delegated.rs b/crates/openshell-sandbox/src/delegated.rs index f594fa35f2..126e400b77 100644 --- a/crates/openshell-sandbox/src/delegated.rs +++ b/crates/openshell-sandbox/src/delegated.rs @@ -35,6 +35,7 @@ pub async fn spawn_workload( timeout_secs: u64, interactive: bool, policy: &SandboxPolicy, + cdi_active: bool, entrypoint_pid: Arc, provider_credentials: ProviderCredentialState, provider_env: std::collections::HashMap, @@ -47,6 +48,12 @@ pub async fn spawn_workload( // setup inside the capability-free boundary. let workspace = ResolvedWorkspace::new(workdir.map(str::to_string), true); + let repair_standard_sbin = + crate::child_env::standard_sbin_path_repair_enabled(policy, cdi_active); + if repair_standard_sbin { + crate::child_env::install_standard_sbin_path_startup_files(workspace.home()); + } + #[cfg(target_os = "linux")] if let Some(workspace_root) = workspace.root() && workspace_root != openshell_core::driver_mounts::DEFAULT_WORKSPACE_ROOT @@ -78,8 +85,8 @@ pub async fn spawn_workload( let loopback_connector: Arc = Arc::new( crate::boundary_io::LocalLoopbackConnector::new(Some(boundary_runtime.clone())), ); - let boundary_exec: Arc = - Arc::new(crate::boundary_exec::LocalBoundaryExec::new( + let boundary_exec: Arc = Arc::new( + crate::boundary_exec::LocalBoundaryExec::new( policy.clone(), workspace.owned_root(), ca_file_paths.clone().map(Arc::new), @@ -87,7 +94,9 @@ pub async fn spawn_workload( user_environment, boundary_runtime.clone(), launcher.clone(), - )); + ) + .with_standard_sbin_path_repair(repair_standard_sbin), + ); #[cfg(target_os = "linux")] let mut handle = ProcessHandle::spawn( @@ -97,6 +106,7 @@ pub async fn spawn_workload( &workspace, interactive, policy, + repair_standard_sbin, ca_file_paths.as_ref(), &provider_env, ) @@ -108,6 +118,7 @@ pub async fn spawn_workload( &workspace, interactive, policy, + repair_standard_sbin, ca_file_paths.as_ref(), &provider_env, )?; diff --git a/crates/openshell-sandbox/src/process.rs b/crates/openshell-sandbox/src/process.rs index cd0a4f6ce7..9a7dff9ef2 100644 --- a/crates/openshell-sandbox/src/process.rs +++ b/crates/openshell-sandbox/src/process.rs @@ -163,12 +163,23 @@ pub fn is_proxy_env_var(key: &str) -> bool { PROXY_ENV_VARS.contains(&key) } -fn inject_provider_env(cmd: &mut Command, provider_env: &HashMap) { +fn inject_provider_env( + cmd: &mut Command, + provider_env: &HashMap, + repair_standard_sbin: bool, +) { for (key, value) in provider_env { if is_supervisor_only_env_var(key) { continue; } - cmd.env(key, value); + if key == "PATH" { + cmd.env( + key, + child_env::maybe_path_with_standard_sbin_paths(value, repair_standard_sbin), + ); + } else { + cmd.env(key, value); + } } } @@ -204,8 +215,18 @@ fn apply_canonical_process_environment( workspace: &ResolvedWorkspace, interactive: bool, user_environment: &HashMap, + repair_standard_sbin: bool, ) { - cmd.envs(user_environment); + for (key, value) in user_environment { + if key == "PATH" { + cmd.env( + key, + child_env::maybe_path_with_standard_sbin_paths(value, repair_standard_sbin), + ); + } else { + cmd.env(key, value); + } + } let (session_user, session_home) = session_user_and_home(policy, workspace.home()); // Resolve a shell present in the workload image. This code runs inside the // workload boundary, where the image filesystem is visible. @@ -380,6 +401,35 @@ pub enum ProcessIo { }, } +fn shell_command_arg_index(args: &[String]) -> Option { + for (index, arg) in args.iter().enumerate() { + if arg == "-c" || (arg.starts_with('-') && !arg.starts_with("--") && arg.contains('c')) { + return (index + 1 < args.len()).then_some(index + 1); + } + } + None +} + +pub(crate) fn process_args_with_standard_sbin_paths( + program: &str, + args: &[String], + repair_standard_sbin: bool, +) -> Vec { + let mut args = args.to_vec(); + if !repair_standard_sbin { + return args; + } + + let basename = program.rsplit('/').next().unwrap_or(program); + if matches!(basename, "bash" | "sh") + && let Some(command_index) = shell_command_arg_index(&args) + { + args[command_index] = + child_env::shell_command_with_standard_sbin_paths(&args[command_index]); + } + args +} + impl ProcessHandle { /// Spawn a new process. /// @@ -395,6 +445,7 @@ impl ProcessHandle { workspace: &ResolvedWorkspace, interactive: bool, policy: &SandboxPolicy, + repair_standard_sbin: bool, ca_paths: Option<&(PathBuf, PathBuf)>, provider_env: &HashMap, ) -> Result { @@ -405,6 +456,7 @@ impl ProcessHandle { workspace, interactive, policy, + repair_standard_sbin, ca_paths, provider_env, ) @@ -423,6 +475,7 @@ impl ProcessHandle { workspace: &ResolvedWorkspace, interactive: bool, policy: &SandboxPolicy, + repair_standard_sbin: bool, ca_paths: Option<&(PathBuf, PathBuf)>, provider_env: &HashMap, ) -> Result { @@ -432,6 +485,7 @@ impl ProcessHandle { workspace, interactive, policy, + repair_standard_sbin, ca_paths, provider_env, ) @@ -446,13 +500,16 @@ impl ProcessHandle { workspace: &ResolvedWorkspace, interactive: bool, policy: &SandboxPolicy, + repair_standard_sbin: bool, ca_paths: Option<&(PathBuf, PathBuf)>, provider_env: &HashMap, ) -> Result { + let args = process_args_with_standard_sbin_paths(program, args, repair_standard_sbin); let mut cmd = Command::new(program); cmd.args(args) .kill_on_drop(true) - .env(openshell_core::sandbox_env::SANDBOX, "1"); + .env(openshell_core::sandbox_env::SANDBOX, "1") + .env("PATH", child_env::child_path_from_env(repair_standard_sbin)); let mut pty_master = None; let mut terminal_slave_fd = None; @@ -487,10 +544,11 @@ impl ProcessHandle { workspace, interactive, &configured_user_environment(), + repair_standard_sbin, ); strip_supervisor_only_env(&mut cmd); - inject_provider_env(&mut cmd, provider_env); + inject_provider_env(&mut cmd, provider_env, repair_standard_sbin); if let Some(dir) = workspace.root() { cmd.current_dir(dir); @@ -611,13 +669,16 @@ impl ProcessHandle { workspace: &ResolvedWorkspace, interactive: bool, policy: &SandboxPolicy, + repair_standard_sbin: bool, ca_paths: Option<&(PathBuf, PathBuf)>, provider_env: &HashMap, ) -> Result { + let args = process_args_with_standard_sbin_paths(program, args, repair_standard_sbin); let mut cmd = Command::new(program); cmd.args(args) .kill_on_drop(true) - .env(openshell_core::sandbox_env::SANDBOX, "1"); + .env(openshell_core::sandbox_env::SANDBOX, "1") + .env("PATH", child_env::child_path_from_env(repair_standard_sbin)); let mut pty_master = None; let mut terminal_slave_fd = None; @@ -652,10 +713,11 @@ impl ProcessHandle { workspace, interactive, &configured_user_environment(), + repair_standard_sbin, ); strip_supervisor_only_env(&mut cmd); - inject_provider_env(&mut cmd, provider_env); + inject_provider_env(&mut cmd, provider_env, repair_standard_sbin); if let Some(dir) = workspace.root() { cmd.current_dir(dir); @@ -1072,6 +1134,35 @@ mod tests { use std::os::unix::fs::PermissionsExt; use std::process::Stdio as StdStdio; + #[test] + fn process_args_wrap_shell_c_command_with_standard_sbin_paths() { + let args = vec!["-lc".to_string(), "nvidia-smi -L".to_string()]; + + let wrapped = process_args_with_standard_sbin_paths("sh", &args, true); + + assert_eq!(wrapped[0], "-lc"); + assert!(wrapped[1].contains("/usr/sbin")); + assert!(wrapped[1].contains("nvidia-smi -L")); + } + + #[test] + fn process_args_leave_non_shell_commands_unchanged() { + let args = vec!["nvidia-smi -L".to_string()]; + + let wrapped = process_args_with_standard_sbin_paths("python", &args, true); + + assert_eq!(wrapped, args); + } + + #[test] + fn process_args_do_not_wrap_when_standard_sbin_repair_disabled() { + let args = vec!["-lc".to_string(), "nvidia-smi -L".to_string()]; + + let wrapped = process_args_with_standard_sbin_paths("sh", &args, false); + + assert_eq!(wrapped, args); + } + /// Helper to create a minimal `SandboxPolicy` with the given process policy. fn policy_with_process(process: ProcessPolicy) -> SandboxPolicy { SandboxPolicy { @@ -1101,7 +1192,14 @@ mod tests { .env("TERM", "dumb") .stdout(StdStdio::piped()); - apply_canonical_process_environment(&mut cmd, &policy, &workspace, true, &HashMap::new()); + apply_canonical_process_environment( + &mut cmd, + &policy, + &workspace, + true, + &HashMap::new(), + false, + ); let output = cmd.output().await.expect("run environment probe"); assert!(output.status.success()); @@ -1150,6 +1248,7 @@ mod tests { &ResolvedWorkspace::default(), interactive, &declared, + false, ); strip_supervisor_only_env(&mut cmd); inject_provider_env( @@ -1158,6 +1257,7 @@ mod tests { "ANTHROPIC_API_KEY".into(), "openshell:resolve:env:ANTHROPIC_API_KEY".into(), )]), + false, ); let output = cmd.output().await.expect("run environment probe"); assert!(output.status.success()); @@ -1316,13 +1416,61 @@ mod tests { )) .collect(); - inject_provider_env(&mut cmd, &provider_env); + inject_provider_env(&mut cmd, &provider_env, false); let output = cmd.output().await.expect("spawn env"); let stdout = String::from_utf8(output.stdout).expect("utf8"); assert!(stdout.contains("ANTHROPIC_API_KEY=openshell:resolve:env:ANTHROPIC_API_KEY")); } + #[tokio::test] + async fn inject_provider_env_appends_standard_sbin_to_path_when_enabled() { + let mut cmd = Command::new("/usr/bin/env"); + cmd.env_clear() + .stdin(StdStdio::null()) + .stdout(StdStdio::piped()) + .stderr(StdStdio::null()); + + let provider_env = HashMap::from([( + "PATH".to_string(), + "/sandbox/.venv/bin:/usr/local/bin:/usr/bin:/bin".to_string(), + )]); + + inject_provider_env(&mut cmd, &provider_env, true); + + let output = cmd.output().await.expect("spawn env"); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("utf8"); + assert!(stdout.lines().any(|line| { + line == "PATH=/sandbox/.venv/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin" + })); + } + + #[tokio::test] + async fn inject_provider_env_leaves_path_unchanged_when_standard_sbin_repair_disabled() { + let mut cmd = Command::new("/usr/bin/env"); + cmd.env_clear() + .stdin(StdStdio::null()) + .stdout(StdStdio::piped()) + .stderr(StdStdio::null()); + + let provider_env = HashMap::from([( + "PATH".to_string(), + "/sandbox/.venv/bin:/usr/local/bin:/usr/bin:/bin".to_string(), + )]); + + inject_provider_env(&mut cmd, &provider_env, false); + + let output = cmd.output().await.expect("spawn env"); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("utf8"); + assert!( + stdout + .lines() + .any(|line| { line == "PATH=/sandbox/.venv/bin:/usr/local/bin:/usr/bin:/bin" }) + ); + } + #[cfg(target_os = "linux")] #[test] #[allow(unsafe_code)] @@ -1538,7 +1686,7 @@ mod tests { ), ]); - inject_provider_env(&mut cmd, &provider_env); + inject_provider_env(&mut cmd, &provider_env, false); let output = cmd.output().await.expect("spawn env"); assert!(output.status.success());