From d2da8bbc04b06aa0660c93d741a556600f63cd35 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Sat, 19 Sep 2026 11:27:54 +0200 Subject: [PATCH] test(conformance): use Alpine workload image Signed-off-by: Evan Lezar --- .../src/scenarios/sandbox_lifecycle.rs | 2 - .../src/scenarios/smoke.rs | 10 +--- crates/openshell-policy/src/lib.rs | 5 ++ crates/openshell-sandbox/src/boundary_exec.rs | 3 +- .../src/sandbox/linux/landlock.rs | 21 ++++++- .../openshell-supervisor-process/src/ssh.rs | 60 ++++++++++++++----- .../templates/gateway-docker.toml.j2 | 1 - .../templates/gateway-podman.toml.j2 | 1 - 8 files changed, 72 insertions(+), 31 deletions(-) diff --git a/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs b/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs index a06de97a4..61a26558f 100644 --- a/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs +++ b/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs @@ -141,8 +141,6 @@ async fn create_running_sandbox( "create", "--name", sandbox_name, - "--from", - "base", "--detach", "--no-tty", "--", diff --git a/crates/openshell-conformance/src/scenarios/smoke.rs b/crates/openshell-conformance/src/scenarios/smoke.rs index 7da9ef221..e9ba070da 100644 --- a/crates/openshell-conformance/src/scenarios/smoke.rs +++ b/crates/openshell-conformance/src/scenarios/smoke.rs @@ -55,15 +55,7 @@ async fn run_smoke_inner(runner: &mut OpenShellRunner) -> Result<(), String> { .step("create") .description("sandbox creation succeeds") .with_timeout(CREATE_TIMEOUT) - .run(&[ - "sandbox", - "create", - "--name", - &sandbox_name, - "--from", - "base", - "--detach", - ]) + .run(&["sandbox", "create", "--name", &sandbox_name, "--detach"]) .await .map_err(|error| error.to_string())?; create.require_success()?; diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index 5443883ac..cefd4283b 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -988,6 +988,7 @@ pub fn restrictive_default_policy() -> SandboxPolicy { filesystem: Some(FilesystemPolicy { include_workdir: true, read_only: vec![ + "/bin".into(), "/usr".into(), "/lib".into(), "/proc".into(), @@ -2128,6 +2129,10 @@ network_policies: let policy = restrictive_default_policy(); let fs = policy.filesystem.expect("must have filesystem policy"); assert!(fs.include_workdir); + assert!( + fs.read_only.iter().any(|p| p == "/bin"), + "read_only should contain /bin" + ); assert!( fs.read_only.iter().any(|p| p == "/usr"), "read_only should contain /usr" diff --git a/crates/openshell-sandbox/src/boundary_exec.rs b/crates/openshell-sandbox/src/boundary_exec.rs index 57d9778a6..a01e430de 100644 --- a/crates/openshell-sandbox/src/boundary_exec.rs +++ b/crates/openshell-sandbox/src/boundary_exec.rs @@ -69,12 +69,13 @@ impl LocalBoundaryExec { 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 shell = openshell_core::shell::detect_login_shell(); command .env_clear() .env(openshell_core::sandbox_env::SANDBOX, "1") .env("HOME", session_home) .env("USER", session_user) - .env("SHELL", "/bin/bash") + .env("SHELL", shell) .env("PATH", path) .env("TERM", if spec.pty { "xterm-256color" } else { "dumb" }); for (key, value) in &self.user_environment { diff --git a/crates/openshell-sandbox/src/sandbox/linux/landlock.rs b/crates/openshell-sandbox/src/sandbox/linux/landlock.rs index c18b633e7..bd0f3520e 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/landlock.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/landlock.rs @@ -302,7 +302,6 @@ fn prepare_with_path_open_mode( let result: Result = (|| { let access_all = AccessFs::from_all(abi); - let access_read = AccessFs::from_read(abi); let mut ruleset = Ruleset::default(); ruleset = ruleset @@ -315,7 +314,8 @@ fn prepare_with_path_open_mode( for path in &read_only { if let Some(path_fd) = try_open_path(path, compatibility, path_open_mode)? { - let allowed_access = access_for_path_fd(&path_fd, access_read, abi)?; + let allowed_access = + access_for_path_fd(&path_fd, read_only_access(path, abi), abi)?; debug!(path = %path.display(), "Landlock allow read-only"); ruleset = ruleset .add_rule(PathBeneath::new(path_fd, allowed_access)) @@ -434,6 +434,17 @@ pub fn enforce(prepared: PreparedRuleset) -> Result<()> { Ok(()) } +/// The baseline permits execution only from Alpine's `/bin`. Other read-only +/// paths, including `/proc` and `/etc`, remain non-executable. +fn read_only_access(path: &Path, abi: ABI) -> BitFlags { + let access = AccessFs::from_read(abi); + if path == Path::new("/bin") { + access | AccessFs::Execute + } else { + access + } +} + /// Tailor a rule's access mask to the inode referenced by its already-open FD. /// /// Landlock directory-only rights such as `ReadDir` are invalid for regular @@ -727,6 +738,12 @@ mod tests { ); } + #[test] + fn only_bin_read_only_access_includes_execute() { + assert!(read_only_access(Path::new("/bin"), ABI::V3).contains(AccessFs::Execute)); + assert!(!read_only_access(Path::new("/usr"), ABI::V3).contains(AccessFs::Execute)); + } + #[test] fn access_for_path_fd_limits_regular_file_access() { let file = tempfile::NamedTempFile::new().unwrap(); diff --git a/crates/openshell-supervisor-process/src/ssh.rs b/crates/openshell-supervisor-process/src/ssh.rs index c93f361e2..5d3a56e2a 100644 --- a/crates/openshell-supervisor-process/src/ssh.rs +++ b/crates/openshell-supervisor-process/src/ssh.rs @@ -892,21 +892,11 @@ impl SshHandler { let no_login_shell = state.no_login_shell; let pty = state.pty_request.take(); let pty_requested = pty.is_some(); - let (program, args) = command.map_or_else( - || { - if pty_requested { - ("/bin/bash".to_string(), vec!["-i".to_string()]) - } else { - ("/bin/bash".to_string(), vec![]) - } - }, - |command| { - ( - "/bin/bash".to_string(), - vec![login_shell_flag(no_login_shell).to_string(), command], - ) - }, - ); + // The supervisor shares the workload filesystem, so select a shell + // that actually exists in the image. Alpine and other minimal images + // provide `/bin/sh` but not `/bin/bash`. + let shell = openshell_core::shell::detect_login_shell(); + let (program, args) = shell_command(shell, command, pty_requested, no_login_shell); let env = pty .as_ref() .map(|request| vec![("TERM".to_string(), request.term.clone())]) @@ -1084,6 +1074,25 @@ const fn login_shell_flag(no_login_shell: bool) -> &'static str { if no_login_shell { "-c" } else { "-lc" } } +fn shell_command( + shell: String, + command: Option, + pty_requested: bool, + no_login_shell: bool, +) -> (String, Vec) { + let args = command.map_or_else( + || { + if pty_requested { + vec!["-i".to_string()] + } else { + Vec::new() + } + }, + |command| vec![login_shell_flag(no_login_shell).to_string(), command], + ); + (shell, args) +} + #[allow(dead_code)] #[derive(Clone)] struct PtyRequest { @@ -1400,6 +1409,27 @@ mod tests { main_session.end_terminal_attachment(); } + #[test] + fn shell_command_uses_the_resolved_image_shell() { + let (program, args) = shell_command( + "/bin/sh".to_string(), + Some("printf ready".to_string()), + false, + false, + ); + + assert_eq!(program, "/bin/sh"); + assert_eq!(args, ["-lc", "printf ready"]); + } + + #[test] + fn interactive_shell_uses_the_resolved_image_shell() { + let (program, args) = shell_command("/bin/sh".to_string(), None, true, false); + + assert_eq!(program, "/bin/sh"); + assert_eq!(args, ["-i"]); + } + #[cfg(unix)] fn file_mode(path: &Path) -> u32 { use std::os::unix::fs::PermissionsExt; diff --git a/tests/ansible/roles/openshell_gateway/templates/gateway-docker.toml.j2 b/tests/ansible/roles/openshell_gateway/templates/gateway-docker.toml.j2 index 320d2979c..e1992c60e 100644 --- a/tests/ansible/roles/openshell_gateway/templates/gateway-docker.toml.j2 +++ b/tests/ansible/roles/openshell_gateway/templates/gateway-docker.toml.j2 @@ -4,7 +4,6 @@ {% block driver %} [openshell.drivers.docker] socket_path = "{{ openshell_runtime_socket }}" -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" image_pull_policy = "if_not_present" sandbox_label = "tmachine" grpc_endpoint = "http://127.0.0.1:17670" diff --git a/tests/ansible/roles/openshell_gateway/templates/gateway-podman.toml.j2 b/tests/ansible/roles/openshell_gateway/templates/gateway-podman.toml.j2 index 61658c1af..08f9ca4b1 100644 --- a/tests/ansible/roles/openshell_gateway/templates/gateway-podman.toml.j2 +++ b/tests/ansible/roles/openshell_gateway/templates/gateway-podman.toml.j2 @@ -4,7 +4,6 @@ {% block driver %} [openshell.drivers.podman] socket_path = "{{ openshell_runtime_socket }}" -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" image_pull_policy = "if_not_present" sandbox_runtime_image = "docker.io/openshell/sandbox:tmachine" supervisor_image = "docker.io/openshell/supervisor:tmachine"