fix(sandbox): preserve standard sbin paths for CDI workloads

Signed-off-by: Evan Lezar <elezar@nvidia.com>
This commit is contained in:
Evan Lezar
2026-10-05 17:15:39 +02:00
parent 966d8eab99
commit 371a2eaeb9
6 changed files with 626 additions and 19 deletions
-1
View File
@@ -2216,4 +2216,3 @@ containerEdits:
- nodev
- rbind
- rprivate
+50 -5
View File
@@ -35,6 +35,7 @@ pub struct LocalBoundaryExec {
ca_file_paths: Option<Arc<(std::path::PathBuf, std::path::PathBuf)>>,
provider_credentials: ProviderCredentialState,
user_environment: HashMap<String, String>,
repair_standard_sbin: bool,
runtime: Arc<crate::boundary_io::BoundaryRuntimeState>,
#[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<Command, BackendError> {
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()) {
@@ -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<String, String>,
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,
+399
View File
@@ -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<StartupFile> {
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<bool> {
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<std::fs::File> {
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<std::fs::File> {
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<Item = impl Into<std::path::PathBuf>>,
) -> 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");
+14 -3
View File
@@ -35,6 +35,7 @@ pub async fn spawn_workload(
timeout_secs: u64,
interactive: bool,
policy: &SandboxPolicy,
cdi_active: bool,
entrypoint_pid: Arc<AtomicU32>,
provider_credentials: ProviderCredentialState,
provider_env: std::collections::HashMap<String, String>,
@@ -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<dyn BoundaryLoopbackConnector> = Arc::new(
crate::boundary_io::LocalLoopbackConnector::new(Some(boundary_runtime.clone())),
);
let boundary_exec: Arc<dyn BoundaryExec> =
Arc::new(crate::boundary_exec::LocalBoundaryExec::new(
let boundary_exec: Arc<dyn BoundaryExec> = 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,
)?;
+158 -10
View File
@@ -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<String, String>) {
fn inject_provider_env(
cmd: &mut Command,
provider_env: &HashMap<String, String>,
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<String, String>,
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<usize> {
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<String> {
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<String, String>,
) -> Result<Self> {
@@ -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<String, String>,
) -> Result<Self> {
@@ -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<String, String>,
) -> Result<Self> {
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<String, String>,
) -> Result<Self> {
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());