From 4efd52d6190d0778d46213e950cfc3fc42f17fb0 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 30 Sep 2026 10:31:07 -0700 Subject: [PATCH 1/2] refactor(sandbox): remove unreachable root-side identity and workspace code RFC 0012 moved the workload into its own capability-free container that starts as the final sandbox identity. The sandbox no longer runs a root supervisor that prepares the filesystem, rewrites account files, resolves OCI USER entries, or drops privileges before launching the workload, so that code had no production callers. Remove the unreachable paths and their tests: - prepare_filesystem / prepare_filesystem_with_identity, the /sandbox and OCI workspace chown preparation, and the root-side workspace validation (validate_oci_workspace and its privilege-dropped subprocess) - the hidden validate-workspace subcommand - drop_privileges / drop_privileges_with_identity, capability bounding set clearing, validate_sandbox_user/group, and /etc/passwd and /etc/group rewriting - the sandbox-side OCI USER resolver (identity.rs) and ResolvedProcessIdentity; the boundary now writes the driver-resolved UID/GID into the policy directly The workspace check that still runs inside the capability-free boundary (validate_oci_workspace_as_effective_identity) is unchanged. Signed-off-by: Matthew Grossman --- .../openshell-sandbox/src/boundary_server.rs | 10 +- crates/openshell-sandbox/src/identity.rs | 833 ------ crates/openshell-sandbox/src/lib.rs | 2 - crates/openshell-sandbox/src/main.rs | 73 - crates/openshell-sandbox/src/process.rs | 2457 +---------------- 5 files changed, 78 insertions(+), 3297 deletions(-) delete mode 100644 crates/openshell-sandbox/src/identity.rs diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index 6e7dd23ca3..f84f98d424 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -27,7 +27,6 @@ mod linux { use crate::boundary_io::BoundaryRuntimeState; use crate::delegated::{AgentSignaler, spawn_workload}; - use crate::identity::{DriverIdentity, resolve_process_identity}; use crate::main_session::{MainOutput, MainSession}; use crate::network_broker::NetworkBroker; use crate::process::ProcessStatus; @@ -2498,13 +2497,8 @@ mod linux { .build() ); } - let driver_identity = DriverIdentity::Resolved { - uid: self.config.workload_identity.uid, - gid: self.config.workload_identity.gid, - }; - if let Err(error) = resolve_process_identity(&mut policy, &driver_identity) { - return guest_error(BoundaryErrorKind::Process, error.to_string()); - } + policy.process.run_as_user = Some(self.config.workload_identity.uid.to_string()); + policy.process.run_as_group = Some(self.config.workload_identity.gid.to_string()); let launch = ManagedProcessLaunch { process_id: format!("{}:main:0", self.config.generation), spec, diff --git a/crates/openshell-sandbox/src/identity.rs b/crates/openshell-sandbox/src/identity.rs deleted file mode 100644 index df79a4137d..0000000000 --- a/crates/openshell-sandbox/src/identity.rs +++ /dev/null @@ -1,833 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Driver identity normalization and OCI `USER` resolution. - -use crate::process::ResolvedProcessIdentity; -use miette::{IntoDiagnostic, Result}; -use openshell_core::policy::SandboxPolicy; -use std::fs::{File, OpenOptions}; -use std::io::Read; -use std::os::unix::fs::OpenOptionsExt; -use std::path::Path; - -const PASSWD_PATH: &str = "/etc/passwd"; -const GROUP_PATH: &str = "/etc/group"; -const MAX_ACCOUNT_FILE_SIZE: u64 = 1024 * 1024; -const MAX_ACCOUNT_LINE_SIZE: usize = 8 * 1024; -const MAX_ACCOUNT_FIELD_SIZE: usize = 1024; - -/// Identity input selected by the active compute driver. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum DriverIdentity { - /// Platform-selected identity used by Kubernetes and `OpenShift`. - Resolved { uid: u32, gid: u32 }, - /// Raw OCI `Config.User` selected by Docker and Podman. - OciUser { declaration: String }, - /// Drivers with no authoritative identity metadata. - None, -} - -impl DriverIdentity { - /// Normalize the protected driver environment into one identity variant. - pub fn from_env() -> Result { - let oci_user = optional_utf8_env(openshell_core::sandbox_env::OCI_IMAGE_USER)?; - let uid = optional_nonempty_utf8_env(openshell_core::sandbox_env::SANDBOX_UID)?; - let gid = optional_nonempty_utf8_env(openshell_core::sandbox_env::SANDBOX_GID)?; - Self::from_values(oci_user, uid, gid) - } - - fn from_values( - oci_user: Option, - uid: Option, - gid: Option, - ) -> Result { - // Resolved-identity drivers explicitly clear the OCI declaration so - // an image-baked or user-supplied value cannot select the OCI path. - // Preserve an empty declaration when no resolved pair is present: - // Docker and Podman use that state to reject images without USER. - let oci_user = if oci_user.as_deref() == Some("") && (uid.is_some() || gid.is_some()) { - None - } else { - oci_user - }; - - match (oci_user, uid, gid) { - (Some(declaration), None, None) => Ok(Self::OciUser { declaration }), - (None, Some(uid), Some(gid)) => { - let uid = uid.parse::().ok().filter(|uid| { - (openshell_policy::MIN_SANDBOX_UID..=openshell_policy::MAX_SANDBOX_UID) - .contains(uid) - }); - let gid = gid.parse::().ok().filter(|gid| { - (openshell_policy::MIN_SANDBOX_UID..=openshell_policy::MAX_SANDBOX_UID) - .contains(gid) - }); - let (Some(uid), Some(gid)) = (uid, gid) else { - return Err(miette::miette!( - "driver UID/GID must be numeric identities in range [{}, {}]", - openshell_policy::MIN_SANDBOX_UID, - openshell_policy::MAX_SANDBOX_UID - )); - }; - Ok(Self::Resolved { uid, gid }) - } - (None, None, None) => Ok(Self::None), - (Some(_), _, _) => Err(miette::miette!( - "{} conflicts with non-empty {}/{} driver identity", - openshell_core::sandbox_env::OCI_IMAGE_USER, - openshell_core::sandbox_env::SANDBOX_UID, - openshell_core::sandbox_env::SANDBOX_GID - )), - (None, _, _) => Err(miette::miette!( - "{} and {} must be supplied together", - openshell_core::sandbox_env::SANDBOX_UID, - openshell_core::sandbox_env::SANDBOX_GID - )), - } - } -} - -/// Apply a driver identity before any workload child becomes reachable. -pub fn resolve_process_identity( - policy: &mut SandboxPolicy, - driver_identity: &DriverIdentity, -) -> Result { - match driver_identity { - DriverIdentity::Resolved { uid, gid } => { - policy.process.run_as_user = Some(uid.to_string()); - policy.process.run_as_group = Some(gid.to_string()); - // Kubernetes/OpenShift already supply numeric policy values and - // retain their existing privilege-drop path. - Ok(ResolvedProcessIdentity::default()) - } - DriverIdentity::OciUser { declaration } => resolve_oci_process_identity_at( - policy, - declaration, - Path::new(PASSWD_PATH), - Path::new(GROUP_PATH), - ), - DriverIdentity::None => { - // VM/offline drivers retain the pre-OCI per-field fallback. A - // partial policy must never leave the omitted component at the - // root supervisor identity. - if policy - .process - .run_as_user - .as_deref() - .is_none_or(str::is_empty) - { - policy.process.run_as_user = Some("sandbox".into()); - } - if policy - .process - .run_as_group - .as_deref() - .is_none_or(str::is_empty) - { - policy.process.run_as_group = Some("sandbox".into()); - } - Ok(ResolvedProcessIdentity::default()) - } - } -} - -#[allow(clippy::similar_names)] -fn resolve_oci_process_identity_at( - policy: &mut SandboxPolicy, - declaration: &str, - passwd_path: &Path, - group_path: &Path, -) -> Result { - let explicit_user = policy - .process - .run_as_user - .as_deref() - .is_some_and(|value| !value.is_empty()); - let explicit_group = policy - .process - .run_as_group - .as_deref() - .is_some_and(|value| !value.is_empty()); - - if explicit_user && explicit_group { - return Ok(ResolvedProcessIdentity::default()); - } - - let (oci_user, oci_group) = split_oci_declaration(declaration); - let needs_primary_gid = !explicit_group && oci_group.is_none(); - let resolved_user = if !explicit_user || needs_primary_gid { - Some(resolve_required_oci_user( - oci_user, - passwd_path, - declaration, - needs_primary_gid, - )?) - } else { - None - }; - - let oci_uid = if explicit_user { - None - } else { - Some( - resolved_user - .as_ref() - .expect("omitted OCI user must have been resolved") - .0, - ) - }; - - if !explicit_user { - policy.process.run_as_user = Some(oci_user.to_string()); - } - - let oci_gid = if explicit_group { - None - } else { - let (group_value, gid) = match oci_group { - Some(group) if !group.is_empty() => { - let gid = validate_oci_group(group, group_path, declaration)?; - (group.to_string(), gid) - } - Some(_) => { - return Err(miette::miette!( - "OCI USER '{declaration}' has an empty group component" - )); - } - None => { - let gid = resolved_user - .and_then(|(_, primary_gid)| primary_gid) - .ok_or_else(|| { - miette::miette!( - "OCI USER '{declaration}' uses a numeric UID without an explicit group, \ - but /etc/passwd has no matching primary GID" - ) - })?; - (gid.to_string(), gid) - } - }; - policy.process.run_as_group = Some(group_value); - Some(gid) - }; - - Ok(ResolvedProcessIdentity::new(oci_uid, oci_gid)) -} - -fn split_oci_declaration(declaration: &str) -> (&str, Option<&str>) { - declaration - .split_once(':') - .map_or((declaration, None), |(user, group)| (user, Some(group))) -} - -fn resolve_required_oci_user( - user: &str, - passwd_path: &Path, - declaration: &str, - require_primary_gid: bool, -) -> Result<(u32, Option)> { - if user.is_empty() { - return Err(miette::miette!( - "OCI USER is required because run_as_user is omitted" - )); - } - validate_component(user, "OCI user")?; - if user == "root" { - return Err(miette::miette!("OCI USER '{declaration}' selects root")); - } - if let Ok(uid) = user.parse::() { - if uid == 0 { - return Err(miette::miette!("OCI USER '{declaration}' selects UID 0")); - } - let primary_gid = if require_primary_gid { - find_passwd_by_uid(passwd_path, uid)?.map(|entry| entry.gid) - } else { - None - }; - if primary_gid == Some(0) { - return Err(miette::miette!( - "OCI USER '{declaration}' resolves to prohibited primary GID 0" - )); - } - return Ok((uid, primary_gid)); - } - let entry = find_passwd_by_name(passwd_path, user)? - .ok_or_else(|| miette::miette!("OCI USER name '{user}' was not found in /etc/passwd"))?; - if entry.uid == 0 { - return Err(miette::miette!( - "OCI USER '{declaration}' resolves to prohibited UID 0" - )); - } - if require_primary_gid && entry.gid == 0 { - return Err(miette::miette!( - "OCI USER '{declaration}' resolves to prohibited primary GID 0" - )); - } - Ok((entry.uid, require_primary_gid.then_some(entry.gid))) -} - -fn validate_oci_group(value: &str, group_path: &Path, declaration: &str) -> Result { - validate_component(value, "OCI group")?; - if value == "root" { - return Err(miette::miette!( - "OCI USER '{declaration}' selects root group" - )); - } - let gid = if let Ok(gid) = value.parse::() { - gid - } else { - find_group_by_name(group_path, value)? - .ok_or_else(|| miette::miette!("OCI group '{value}' was not found in /etc/group"))? - .gid - }; - if gid == 0 { - return Err(miette::miette!( - "OCI USER '{declaration}' resolves to prohibited GID 0" - )); - } - Ok(gid) -} - -fn validate_component(value: &str, kind: &str) -> Result<()> { - if value.is_empty() - || value.len() > MAX_ACCOUNT_FIELD_SIZE - || value.trim() != value - || value.chars().any(|ch| ch.is_control() || ch == ':') - { - return Err(miette::miette!("{kind} component '{value}' is malformed")); - } - Ok(()) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct PasswdEntry { - uid: u32, - gid: u32, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct GroupEntry { - gid: u32, -} - -fn find_passwd_by_name(path: &Path, name: &str) -> Result> { - find_unique(path, |fields| { - (fields.first().copied() == Some(name)).then(|| parse_passwd(fields)) - }) -} - -fn find_passwd_by_uid(path: &Path, uid: u32) -> Result> { - find_unique(path, |fields| { - fields - .get(2) - .and_then(|value| value.parse::().ok()) - .filter(|candidate| *candidate == uid) - .map(|_| parse_passwd(fields)) - }) -} - -fn find_group_by_name(path: &Path, name: &str) -> Result> { - find_unique(path, |fields| { - (fields.first().copied() == Some(name)).then(|| parse_group(fields)) - }) -} - -/// Resolve supplementary groups declared for an OCI named user without -/// consulting NSS. Numeric OCI users have no trustworthy group-membership -/// name and therefore receive no supplementary groups. -pub fn resolve_oci_supplementary_gids(declaration: &str, primary_gid: u32) -> Result> { - resolve_oci_supplementary_gids_at(declaration, primary_gid, Path::new(GROUP_PATH)) -} - -fn resolve_oci_supplementary_gids_at( - declaration: &str, - primary_gid: u32, - group_path: &Path, -) -> Result> { - let (user, _) = split_oci_declaration(declaration); - validate_component(user, "OCI user")?; - if user.parse::().is_ok() { - return Ok(Vec::new()); - } - - let content = read_account_file(group_path)?; - let mut gids = vec![primary_gid]; - for line in content.lines() { - if line.is_empty() || line.starts_with('#') { - continue; - } - if line.len() > MAX_ACCOUNT_LINE_SIZE { - return Err(miette::miette!( - "account file '{}' contains an oversized line", - group_path.display() - )); - } - let fields = line.split(':').collect::>(); - if fields.len() != 4 - || fields - .iter() - .any(|field| field.len() > MAX_ACCOUNT_FIELD_SIZE) - { - return Err(miette::miette!( - "group membership entry in '{}' is malformed", - group_path.display() - )); - } - if !fields[3].split(',').any(|member| member == user) { - continue; - } - let gid = fields[2].parse::().map_err(|_| { - miette::miette!( - "group membership GID in '{}' is malformed", - group_path.display() - ) - })?; - if gid == 0 { - return Err(miette::miette!( - "OCI user '{user}' is a member of prohibited GID 0" - )); - } - gids.push(gid); - } - gids.sort_unstable(); - gids.dedup(); - Ok(gids) -} - -fn find_unique( - path: &Path, - mut select: impl FnMut(&[&str]) -> Option>, -) -> Result> { - let content = read_account_file(path)?; - let mut found = None; - for line in content.lines() { - if line.is_empty() || line.starts_with('#') { - continue; - } - if line.len() > MAX_ACCOUNT_LINE_SIZE { - return Err(miette::miette!( - "account file '{}' contains an oversized line", - path.display() - )); - } - let fields = line.split(':').collect::>(); - if fields - .iter() - .any(|field| field.len() > MAX_ACCOUNT_FIELD_SIZE) - { - return Err(miette::miette!( - "account file '{}' contains an oversized field", - path.display() - )); - } - let Some(candidate) = select(&fields) else { - continue; - }; - let candidate = candidate?; - if found.replace(candidate).is_some() { - return Err(miette::miette!( - "account identity is ambiguous in '{}'", - path.display() - )); - } - } - Ok(found) -} - -fn parse_passwd(fields: &[&str]) -> Result { - if fields.len() != 7 { - return Err(miette::miette!("matching /etc/passwd entry is malformed")); - } - Ok(PasswdEntry { - uid: fields[2] - .parse() - .map_err(|_| miette::miette!("matching /etc/passwd UID is malformed"))?, - gid: fields[3] - .parse() - .map_err(|_| miette::miette!("matching /etc/passwd GID is malformed"))?, - }) -} - -fn parse_group(fields: &[&str]) -> Result { - if fields.len() != 4 { - return Err(miette::miette!("matching /etc/group entry is malformed")); - } - Ok(GroupEntry { - gid: fields[2] - .parse() - .map_err(|_| miette::miette!("matching /etc/group GID is malformed"))?, - }) -} - -fn read_account_file(path: &Path) -> Result { - let mut options = OpenOptions::new(); - options - .read(true) - .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW); - let mut file = options - .open(path) - .into_diagnostic() - .map_err(|error| miette::miette!("failed to open '{}': {error}", path.display()))?; - validate_account_file(&file, path)?; - - let mut bytes = Vec::new(); - file.by_ref() - .take(MAX_ACCOUNT_FILE_SIZE + 1) - .read_to_end(&mut bytes) - .into_diagnostic()?; - if bytes.len() as u64 > MAX_ACCOUNT_FILE_SIZE { - return Err(miette::miette!( - "account file '{}' exceeds {MAX_ACCOUNT_FILE_SIZE} bytes", - path.display() - )); - } - String::from_utf8(bytes) - .map_err(|_| miette::miette!("account file '{}' is not valid UTF-8", path.display())) -} - -fn validate_account_file(file: &File, path: &Path) -> Result<()> { - let metadata = file.metadata().into_diagnostic()?; - if !metadata.is_file() { - return Err(miette::miette!( - "account path '{}' is not a regular file", - path.display() - )); - } - if metadata.len() > MAX_ACCOUNT_FILE_SIZE { - return Err(miette::miette!( - "account file '{}' exceeds {MAX_ACCOUNT_FILE_SIZE} bytes", - path.display() - )); - } - Ok(()) -} - -fn optional_utf8_env(name: &str) -> Result> { - std::env::var_os(name) - .map(|value| { - value - .into_string() - .map_err(|_| miette::miette!("{name} is not valid UTF-8")) - }) - .transpose() -} - -fn optional_nonempty_utf8_env(name: &str) -> Result> { - Ok(optional_utf8_env(name)?.filter(|value| !value.is_empty())) -} - -#[cfg(test)] -mod tests { - use super::*; - use openshell_core::policy::SandboxPolicy; - use std::fs; - use tempfile::tempdir; - - fn account_files( - passwd: &str, - group: &str, - ) -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) { - let dir = tempdir().unwrap(); - let passwd_path = dir.path().join("passwd"); - let group_path = dir.path().join("group"); - fs::write(&passwd_path, passwd).unwrap(); - fs::write(&group_path, group).unwrap(); - (dir, passwd_path, group_path) - } - - fn policy(user: Option<&str>, group: Option<&str>) -> SandboxPolicy { - let mut policy = SandboxPolicy { - version: 1, - filesystem: openshell_core::policy::FilesystemPolicy::default(), - network: openshell_core::policy::NetworkPolicy::default(), - landlock: openshell_core::policy::LandlockPolicy::default(), - process: openshell_core::policy::ProcessPolicy::default(), - }; - policy.process.run_as_user = user.map(str::to_string); - policy.process.run_as_group = group.map(str::to_string); - policy - } - - #[test] - fn per_field_policy_precedence_resolves_complete_pair() { - let (_dir, passwd, group) = account_files( - "app:x:1234:1235::/home/app:/bin/sh\nsandbox:x:2000:2001::/sandbox:/bin/sh\n", - "staff:x:1235:\nsandbox:x:2001:\n", - ); - let cases = [ - ( - Some("2000"), - Some("2001"), - "root", - "2000", - "2001", - None, - None, - ), - ( - Some("2000"), - None, - "app:staff", - "2000", - "staff", - None, - Some(1235), - ), - ( - None, - Some("2001"), - "app:root", - "app", - "2001", - Some(1234), - None, - ), - ( - None, - None, - "app:staff", - "app", - "staff", - Some(1234), - Some(1235), - ), - (None, None, "app", "app", "1235", Some(1234), Some(1235)), - ]; - for ( - user, - group_name, - declaration, - expected_user, - expected_group, - resolved_uid, - resolved_gid, - ) in cases - { - let mut policy = policy(user, group_name); - let resolved = - resolve_oci_process_identity_at(&mut policy, declaration, &passwd, &group).unwrap(); - assert_eq!(policy.process.run_as_user.as_deref(), Some(expected_user)); - assert_eq!(policy.process.run_as_group.as_deref(), Some(expected_group)); - assert_eq!(resolved.uid(), resolved_uid); - assert_eq!(resolved.gid(), resolved_gid); - } - } - - #[test] - fn numeric_pair_does_not_require_account_entries() { - let dir = tempdir().unwrap(); - let passwd = dir.path().join("missing-passwd"); - let group = dir.path().join("missing-group"); - let mut policy = policy(None, None); - let resolved = - resolve_oci_process_identity_at(&mut policy, "1234:1235", &passwd, &group).unwrap(); - assert_eq!(policy.process.run_as_user.as_deref(), Some("1234")); - assert_eq!(policy.process.run_as_group.as_deref(), Some("1235")); - assert_eq!( - resolved, - ResolvedProcessIdentity::new(Some(1234), Some(1235)) - ); - } - - #[test] - fn explicit_identity_is_preserved_without_inspecting_oci_or_accounts() { - let dir = tempdir().unwrap(); - let mut policy = policy(Some("sandbox"), Some("sandbox")); - - let resolved = resolve_oci_process_identity_at( - &mut policy, - "root:root", - &dir.path().join("missing-passwd"), - &dir.path().join("missing-group"), - ) - .unwrap(); - - assert_eq!(policy.process.run_as_user.as_deref(), Some("sandbox")); - assert_eq!(policy.process.run_as_group.as_deref(), Some("sandbox")); - assert_eq!(resolved, ResolvedProcessIdentity::default()); - } - - #[test] - fn driver_identity_inputs_are_mutually_exclusive_and_complete() { - assert_eq!( - DriverIdentity::from_values(Some("app".into()), None, None).unwrap(), - DriverIdentity::OciUser { - declaration: "app".into() - } - ); - assert_eq!( - DriverIdentity::from_values(None, Some("1234".into()), Some("1235".into())).unwrap(), - DriverIdentity::Resolved { - uid: 1234, - gid: 1235 - } - ); - assert_eq!( - DriverIdentity::from_values(None, Some("500".into()), Some("30".into())).unwrap(), - DriverIdentity::Resolved { uid: 500, gid: 30 } - ); - assert_eq!( - DriverIdentity::from_values( - Some(String::new()), - Some("1234".into()), - Some("1235".into()) - ) - .unwrap(), - DriverIdentity::Resolved { - uid: 1234, - gid: 1235 - } - ); - assert_eq!( - DriverIdentity::from_values(Some(String::new()), None, None).unwrap(), - DriverIdentity::OciUser { - declaration: String::new() - } - ); - assert_eq!( - DriverIdentity::from_values(None, None, None).unwrap(), - DriverIdentity::None - ); - assert!( - DriverIdentity::from_values( - Some("app".into()), - Some("1234".into()), - Some("1235".into()) - ) - .is_err() - ); - assert!(DriverIdentity::from_values(None, Some("1234".into()), None).is_err()); - } - - #[test] - fn no_driver_identity_completes_partial_policy_with_sandbox() { - let cases = [ - (None, Some("staff"), "sandbox", "staff"), - (Some("app"), None, "app", "sandbox"), - (None, None, "sandbox", "sandbox"), - (Some("app"), Some("staff"), "app", "staff"), - ]; - - for (user, group, expected_user, expected_group) in cases { - let mut policy = policy(user, group); - let resolved = resolve_process_identity(&mut policy, &DriverIdentity::None).unwrap(); - - assert_eq!(policy.process.run_as_user.as_deref(), Some(expected_user)); - assert_eq!(policy.process.run_as_group.as_deref(), Some(expected_group)); - assert_eq!(resolved, ResolvedProcessIdentity::default()); - } - } - - #[test] - fn numeric_uid_uses_passwd_primary_gid() { - let (_dir, passwd, group) = account_files("app:x:1234:4321::/home/app:/bin/sh\n", ""); - let mut policy = policy(None, None); - let resolved = - resolve_oci_process_identity_at(&mut policy, "1234", &passwd, &group).unwrap(); - assert_eq!(policy.process.run_as_group.as_deref(), Some("4321")); - assert_eq!( - resolved, - ResolvedProcessIdentity::new(Some(1234), Some(4321)) - ); - } - - #[test] - fn named_oci_user_resolves_bounded_supplementary_groups() { - let (_dir, _passwd, group) = account_files( - "", - "primary:x:1235:\nvideo:x:44:app,other\naudio:x:63:other\nrender:x:107:app\n", - ); - - let gids = resolve_oci_supplementary_gids_at("app:primary", 1235, &group).unwrap(); - assert_eq!(gids, vec![44, 107, 1235]); - } - - #[test] - fn numeric_oci_user_has_no_named_supplementary_groups() { - let dir = tempdir().unwrap(); - let missing_group = dir.path().join("missing-group"); - - let gids = resolve_oci_supplementary_gids_at("1234:1235", 1235, &missing_group).unwrap(); - assert!(gids.is_empty()); - } - - #[test] - fn oci_supplementary_membership_rejects_root_group() { - let (_dir, _passwd, group) = account_files("", "root:x:0:app\n"); - - let error = - resolve_oci_supplementary_gids_at("app", 1235, &group).expect_err("GID 0 must fail"); - assert!(error.to_string().contains("prohibited GID 0")); - } - - #[test] - fn missing_unknown_ambiguous_and_root_identities_fail() { - let (_dir, passwd, group) = account_files( - "app:x:1234:1235::/home/app:/bin/sh\napp:x:2234:2235::/home/app2:/bin/sh\n", - "staff:x:1235:\nstaff:x:2235:\n", - ); - for declaration in ["", "unknown", "app", "9999", "0:1235", "1234:0"] { - let mut policy = policy(None, None); - assert!( - resolve_oci_process_identity_at(&mut policy, declaration, &passwd, &group).is_err(), - "{declaration:?} unexpectedly resolved" - ); - } - } - - #[test] - fn selected_component_is_validated_independently() { - let (_dir, passwd, group) = - account_files("app:x:1234:1235::/home/app:/bin/sh\n", "staff:x:1235:\n"); - - let mut explicit_user = policy(Some("1234"), None); - let resolved = - resolve_oci_process_identity_at(&mut explicit_user, "root:staff", &passwd, &group) - .unwrap(); - assert_eq!(explicit_user.process.run_as_user.as_deref(), Some("1234")); - assert_eq!(explicit_user.process.run_as_group.as_deref(), Some("staff")); - assert_eq!(resolved, ResolvedProcessIdentity::new(None, Some(1235))); - - let mut explicit_group = policy(None, Some("1235")); - let resolved = - resolve_oci_process_identity_at(&mut explicit_group, "app:root", &passwd, &group) - .unwrap(); - assert_eq!(explicit_group.process.run_as_user.as_deref(), Some("app")); - assert_eq!(explicit_group.process.run_as_group.as_deref(), Some("1235")); - assert_eq!(resolved, ResolvedProcessIdentity::new(Some(1234), None)); - } - - #[test] - fn named_oci_components_mapping_to_root_are_rejected() { - let (_dir, passwd, group) = account_files( - "root_alias:x:0:1235::/root:/bin/sh\napp:x:1234:1235::/home/app:/bin/sh\n", - "root_alias:x:0:\nstaff:x:1235:\n", - ); - - let mut root_user = policy(None, None); - assert!( - resolve_oci_process_identity_at(&mut root_user, "root_alias:staff", &passwd, &group) - .is_err() - ); - - let mut root_group = policy(None, None); - assert!( - resolve_oci_process_identity_at(&mut root_group, "app:root_alias", &passwd, &group) - .is_err() - ); - } - - #[cfg(unix)] - #[test] - fn account_file_symlinks_are_rejected() { - use std::os::unix::fs::symlink; - - let (_dir, passwd, group) = - account_files("app:x:1234:1235::/home/app:/bin/sh\n", "staff:x:1235:\n"); - let link = passwd.with_file_name("passwd-link"); - symlink(&passwd, &link).unwrap(); - - let mut policy = policy(None, None); - assert!(resolve_oci_process_identity_at(&mut policy, "app:staff", &link, &group).is_err()); - } -} diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 5ba996181a..6c3a9829f9 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -11,8 +11,6 @@ mod boundary_server; pub mod child_env; #[cfg(target_os = "linux")] pub(crate) mod delegated; -#[cfg(unix)] -pub mod identity; #[cfg(target_os = "linux")] pub mod main_session; pub mod managed_children; diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 4dad330771..8ef97be1a3 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -33,7 +33,6 @@ const SANDBOX_RUNTIME_ROOT: &str = "/.openshell/runtime"; #[cfg(target_os = "linux")] const SANDBOX_STATE_ROOT: &str = "/.openshell/state"; -const VALIDATE_WORKSPACE_SUBCOMMAND: &str = "validate-workspace"; const CAPABILITY_PROBE_SUBCOMMAND: &str = "capability-probe"; const CAPABILITY_PROBE_LAUNCH_SUBCOMMAND: &str = "capability-probe-launch"; const CAPABILITY_SOCKET_CHILD_SUBCOMMAND: &str = "capability-socket-child"; @@ -60,50 +59,6 @@ struct BoundaryArgs { log_level: String, } -/// Internal one-shot command used by trusted driver bootstrap to validate an -/// image-provided workdir as the final sandbox identity. -#[derive(Parser, Debug)] -#[command(name = "validate-workspace", hide = true)] -struct ValidateWorkspaceArgs { - #[arg(long)] - workdir: String, - #[arg(long)] - expected_uid: u32, - #[arg(long)] - expected_gid: u32, -} - -#[cfg(target_os = "linux")] -fn validate_workspace(args: &[String]) -> Result<()> { - let args = ValidateWorkspaceArgs::try_parse_from( - std::iter::once(VALIDATE_WORKSPACE_SUBCOMMAND.to_string()).chain(args.iter().cloned()), - ) - .into_diagnostic()?; - let actual = ( - nix::unistd::geteuid().as_raw(), - nix::unistd::getegid().as_raw(), - ); - if actual != (args.expected_uid, args.expected_gid) { - return Err(miette::miette!( - "workspace validator privilege drop failed: expected {}:{}, got {}:{}", - args.expected_uid, - args.expected_gid, - actual.0, - actual.1 - )); - } - openshell_sandbox::process::validate_oci_workspace_as_effective_identity(Path::new( - &args.workdir, - )) -} - -#[cfg(not(target_os = "linux"))] -fn validate_workspace(_args: &[String]) -> Result<()> { - Err(miette::miette!( - "workspace validation is only supported on Unix" - )) -} - /// Run the active Phase 0 probe inside the exact workload runtime profile. #[cfg(target_os = "linux")] #[allow(unsafe_code)] @@ -1923,9 +1878,6 @@ fn main() -> Result<()> { } return seed_kubernetes_workspace(); } - if raw_args.get(1).map(String::as_str) == Some(VALIDATE_WORKSPACE_SUBCOMMAND) { - return validate_workspace(&raw_args[2..]); - } if raw_args.get(1).map(String::as_str) == Some(CAPABILITY_PROBE_SUBCOMMAND) { return run_capability_probe(); } @@ -2031,31 +1983,6 @@ mod tests { assert!(destination.join(".openshell-initialized").is_file()); } - #[cfg(target_os = "linux")] - #[test] - fn workspace_validation_subcommand_uses_final_policy_identity() { - let uid = nix::unistd::geteuid().as_raw(); - let gid = nix::unistd::getegid().as_raw(); - if uid < 1000 || gid < 1000 { - return; - } - let dir = tempfile::tempdir_in("/tmp").unwrap(); - std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o711)).unwrap(); - let root = dir.path().canonicalize().unwrap().join("workspace"); - std::fs::create_dir(&root).unwrap(); - std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap(); - let args = vec![ - "--workdir".to_string(), - root.display().to_string(), - "--expected-uid".to_string(), - uid.to_string(), - "--expected-gid".to_string(), - gid.to_string(), - ]; - - validate_workspace(&args).expect("current identity should retain workspace authority"); - } - /// Drives `copy_self`'s file-copy logic against an arbitrary source path /// so tests don't depend on `current_exe()`. fn copy_executable(src: &Path, dest: &Path) -> Result<()> { diff --git a/crates/openshell-sandbox/src/process.rs b/crates/openshell-sandbox/src/process.rs index 7b61d30030..595ceac29e 100644 --- a/crates/openshell-sandbox/src/process.rs +++ b/crates/openshell-sandbox/src/process.rs @@ -11,22 +11,19 @@ use crate::sandbox; use miette::WrapErr; use miette::{IntoDiagnostic, Result}; use nix::sys::signal::{self, Signal}; -use nix::unistd::{Gid, Group, Pid, Uid, User}; +use nix::unistd::{Pid, User}; use openshell_core::policy::SandboxPolicy; use std::collections::HashMap; -use std::ffi::CString; #[cfg(unix)] use std::os::fd::AsRawFd; -#[cfg(unix)] -use std::os::unix::fs::{MetadataExt, PermissionsExt}; -#[cfg(any(test, unix))] +#[cfg(target_os = "linux")] use std::path::Path; use std::path::PathBuf; use std::process::Stdio; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command}; -use tracing::{debug, info}; +use tracing::debug; // `libc::TIOCSCTTY` and the request parameter accepted by `ioctl` vary across // glibc, musl, and BSD targets. The conversion is a no-op on some targets but @@ -40,45 +37,6 @@ fn set_controlling_tty(fd: libc::c_int) -> std::io::Result<()> { Ok(()) } -/// Numeric identity components resolved once from driver-owned metadata. -/// -/// A component is `None` when the corresponding policy field was explicit and -/// must continue through the existing policy identity path. OCI-derived -/// components are carried numerically so later filesystem setup and direct/SSH -/// privilege drops cannot resolve them differently through NSS. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct ResolvedProcessIdentity { - uid: Option, - gid: Option, -} - -impl ResolvedProcessIdentity { - #[must_use] - pub const fn new(uid: Option, gid: Option) -> Self { - Self { uid, gid } - } - - #[must_use] - pub const fn uid(self) -> Option { - self.uid - } - - #[must_use] - pub const fn gid(self) -> Option { - self.gid - } - - /// Whether at least one process identity component came from OCI `USER`. - /// - /// Platform-resolved identities are written directly into the policy and - /// return the default value, so this is specific to Docker/Podman OCI - /// fallback without adding another driver contract. - #[must_use] - pub const fn uses_oci_user_fallback(self) -> bool { - self.uid.is_some() || self.gid.is_some() - } -} - /// Resolved process workspace and its child-environment semantics. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ResolvedWorkspace { @@ -380,46 +338,6 @@ fn parse_pids_max(contents: &str) -> RuntimePidLimitStatus { } } -#[cfg(target_os = "linux")] -fn drop_capability_bounding_set() -> Result<()> { - let clear_result = capctl::caps::bounding::clear(); - let remaining = capctl::caps::bounding::probe(); - - validate_capability_bounding_set_clear( - clear_result, - remaining, - capctl::caps::bounding::clear_unknown, - ) -} - -#[cfg(target_os = "linux")] -fn validate_capability_bounding_set_clear( - clear_result: capctl::Result<()>, - remaining: capctl::caps::CapSet, - clear_unknown: impl FnOnce() -> capctl::Result<()>, -) -> Result<()> { - match clear_result { - Ok(()) if remaining.is_empty() => Ok(()), - Ok(()) => Err(miette::miette!( - "Failed to clear child capability bounding set: capabilities remain raised: {remaining:?}" - )), - Err(err) if err.code() == libc::EPERM && remaining.is_empty() => match clear_unknown() { - Ok(()) => { - debug!( - "CAP_SETPCAP is unavailable, but the child capability bounding set is already empty" - ); - Ok(()) - } - Err(unknown_err) => Err(miette::miette!( - "Failed to clear unknown child capability bounding set entries: {unknown_err}" - )), - }, - Err(err) => Err(miette::miette!( - "Failed to clear child capability bounding set: {err}" - )), - } -} - #[cfg(target_os = "linux")] pub fn spawn_command_with_workload_launcher( launcher: &openshell_isolation_interface::linux::workload_launcher::WorkloadLauncher, @@ -920,513 +838,11 @@ impl Drop for ProcessHandle { } } -/// Validate the configured process user. -/// -/// Numeric identities do not require a passwd entry. The legacy explicit -/// `"sandbox"` identity and other names must resolve in `/etc/passwd`. -#[cfg(unix)] -pub fn validate_sandbox_user(policy: &SandboxPolicy) -> Result<()> { - let identity = policy.process.run_as_user.as_deref().unwrap_or("sandbox"); - - if let Ok(uid) = identity.parse::() { - if !(MIN_SANDBOX_UID..=MAX_SANDBOX_UID).contains(&uid) { - return Err(miette::miette!( - "process user UID must be in range [{MIN_SANDBOX_UID}, {MAX_SANDBOX_UID}]" - )); - } - openshell_ocsf::ocsf_emit!( - openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) - .severity(openshell_ocsf::SeverityId::Informational) - .status(openshell_ocsf::StatusId::Success) - .state(openshell_ocsf::StateId::Enabled, "validated") - .message(format!( - "Accepted numeric UID {identity} (no passwd entry required)" - )) - .build() - ); - return Ok(()); - } - - // Legacy explicit "sandbox" name — must exist in /etc/passwd. - if identity == "sandbox" { - match User::from_name("sandbox") { - Ok(Some(_)) => { - openshell_ocsf::ocsf_emit!( - openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) - .severity(openshell_ocsf::SeverityId::Informational) - .status(openshell_ocsf::StatusId::Success) - .state(openshell_ocsf::StateId::Enabled, "validated") - .message("Validated 'sandbox' user exists in image") - .build() - ); - } - Ok(None) => { - return Err(miette::miette!( - "explicit process user 'sandbox' was not found in the image" - )); - } - Err(e) => { - return Err(miette::miette!("failed to look up 'sandbox' user: {e}")); - } - } - } else if !identity.is_empty() { - // Other names are supported by local/offline policy paths and must - // resolve before privilege dropping. - match User::from_name(identity) { - Ok(Some(_)) => { - tracing::warn!(identity, "named process user accepted via passwd entry"); - } - Ok(None) => { - return Err(miette::miette!( - "unrecognized sandbox identity '{identity}'; \ - expected 'sandbox' or a numeric UID in range [{MIN_SANDBOX_UID}, {MAX_SANDBOX_UID}]" - )); - } - Err(e) => { - return Err(miette::miette!( - "failed to look up identity '{identity}': {e}" - )); - } - } - } - - Ok(()) -} - -/// Validate that the configured sandbox group identity is acceptable. -/// -/// Mirrors [`validate_sandbox_user`] for the group dimension. -#[cfg(unix)] -pub fn validate_sandbox_group(policy: &SandboxPolicy) -> Result<()> { - let identity = policy.process.run_as_group.as_deref().unwrap_or("sandbox"); - - if let Ok(gid) = identity.parse::() { - if !(MIN_SANDBOX_UID..=MAX_SANDBOX_UID).contains(&gid) { - return Err(miette::miette!( - "process group GID must be in range [{MIN_SANDBOX_UID}, {MAX_SANDBOX_UID}]" - )); - } - openshell_ocsf::ocsf_emit!( - openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) - .severity(openshell_ocsf::SeverityId::Informational) - .status(openshell_ocsf::StatusId::Success) - .state(openshell_ocsf::StateId::Enabled, "validated") - .message(format!( - "Accepted numeric GID {identity} (no group entry required)" - )) - .build() - ); - return Ok(()); - } - - if identity == "sandbox" { - match Group::from_name("sandbox") { - Ok(Some(_)) => { - openshell_ocsf::ocsf_emit!( - openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) - .severity(openshell_ocsf::SeverityId::Informational) - .status(openshell_ocsf::StatusId::Success) - .state(openshell_ocsf::StateId::Enabled, "validated") - .message("Validated 'sandbox' group exists in image") - .build() - ); - } - Ok(None) => { - return Err(miette::miette!( - "explicit process group 'sandbox' was not found in the image" - )); - } - Err(e) => { - return Err(miette::miette!("failed to look up 'sandbox' group: {e}")); - } - } - } else if !identity.is_empty() { - match Group::from_name(identity) { - Ok(Some(_)) => { - tracing::warn!(identity, "named process group accepted via group entry"); - } - Ok(None) => { - return Err(miette::miette!( - "unrecognized sandbox group identity '{identity}'; \ - expected 'sandbox' or a numeric GID in range [{MIN_SANDBOX_UID}, {MAX_SANDBOX_UID}]" - )); - } - Err(e) => { - return Err(miette::miette!( - "failed to look up group identity '{identity}': {e}" - )); - } - } - } - - Ok(()) -} - -#[cfg(unix)] -pub fn validate_sandbox_user_with_identity( - policy: &SandboxPolicy, - resolved_identity: ResolvedProcessIdentity, -) -> Result<()> { - let Some(uid) = resolved_identity.uid() else { - return validate_sandbox_user(policy); - }; - if uid == 0 { - return Err(miette::miette!("process user must not select UID 0")); - } - Ok(()) -} - -#[cfg(unix)] -pub fn validate_sandbox_group_with_identity( - policy: &SandboxPolicy, - resolved_identity: ResolvedProcessIdentity, -) -> Result<()> { - let Some(gid) = resolved_identity.gid() else { - return validate_sandbox_group(policy); - }; - if gid == 0 { - return Err(miette::miette!("process group must not select GID 0")); - } - Ok(()) -} - -pub use openshell_policy::{MAX_SANDBOX_UID, MIN_SANDBOX_UID}; - -/// Prepare a `read_write` path for the sandboxed process. -/// -/// Returns `true` when the path was created by the supervisor and therefore -/// still needs to be chowned to the sandbox user/group. Existing paths keep -/// their image-defined ownership. -#[cfg(unix)] -fn prepare_read_write_path(path: &Path) -> Result { - // SECURITY: use symlink_metadata (lstat) to inspect each path *before* - // calling chown. chown follows symlinks, so a malicious container image - // could place a symlink (e.g. /sandbox -> /etc/shadow) to trick the - // root supervisor into transferring ownership of arbitrary files. - // The TOCTOU window between lstat and chown is not exploitable because - // no untrusted process is running yet (the child has not been forked). - if let Ok(meta) = std::fs::symlink_metadata(path) { - if meta.file_type().is_symlink() { - return Err(miette::miette!( - "read_write path '{}' is a symlink — refusing to chown (potential privilege escalation)", - path.display() - )); - } - - debug!( - path = %path.display(), - "Preserving ownership for existing read_write path" - ); - Ok(false) - } else { - debug!(path = %path.display(), "Creating read_write directory"); - std::fs::create_dir_all(path).into_diagnostic()?; - Ok(true) - } -} - -/// Update `/etc/passwd` and `/etc/group` so the "sandbox" user/group entries -/// match the driver-injected UID/GID from environment variables. -/// -/// When `OPENSHELL_SANDBOX_UID` is set, the image-baked "sandbox" entry may -/// have a different UID. Updating the files ensures `whoami`, `id`, `ls -l`, -/// SSH sessions, and `initgroups` resolve the sandbox identity correctly. -/// If no "sandbox" entry exists, one is appended. -#[cfg(unix)] -pub fn update_sandbox_passwd_entries() -> Result<()> { - let uid_str = match std::env::var(openshell_core::sandbox_env::SANDBOX_UID) { - Ok(v) if !v.is_empty() => v, - _ => return Ok(()), - }; - let gid_str = match std::env::var(openshell_core::sandbox_env::SANDBOX_GID) { - Ok(v) if !v.is_empty() => v, - _ => uid_str.clone(), - }; - - let _: u32 = uid_str - .parse() - .map_err(|e| miette::miette!("invalid OPENSHELL_SANDBOX_UID '{uid_str}': {e}"))?; - let _: u32 = gid_str - .parse() - .map_err(|e| miette::miette!("invalid OPENSHELL_SANDBOX_GID '{gid_str}': {e}"))?; - - update_passwd_file(&uid_str, &gid_str)?; - update_group_file(&gid_str)?; - - info!( - uid = %uid_str, - gid = %gid_str, - "Updated /etc/passwd and /etc/group for sandbox identity" - ); - Ok(()) -} - -/// Rewrite the `sandbox` line in `/etc/passwd` with the given UID/GID, -/// or append a new entry if none exists. -#[cfg(unix)] -fn update_passwd_file(uid: &str, gid: &str) -> Result<()> { - rewrite_passwd_at(Path::new("/etc/passwd"), uid, gid) -} - -/// Rewrite the `sandbox` line in `/etc/group` with the given GID, -/// or append a new entry if none exists. -#[cfg(unix)] -fn update_group_file(gid: &str) -> Result<()> { - rewrite_group_at(Path::new("/etc/group"), gid) -} - -#[cfg(unix)] -fn rewrite_passwd_at(path: &Path, uid: &str, gid: &str) -> Result<()> { - let content = std::fs::read_to_string(path).into_diagnostic()?; - - let mut found = false; - let mut lines: Vec = content - .lines() - .map(|line| { - if line.starts_with("sandbox:") { - found = true; - let fields: Vec<&str> = line.split(':').collect(); - if let [name, pass, _, _, gecos, home, shell, ..] = fields.as_slice() { - format!("{name}:{pass}:{uid}:{gid}:{gecos}:{home}:{shell}") - } else { - line.to_string() - } - } else { - line.to_string() - } - }) - .collect(); - - if !found { - lines.push(format!("sandbox:x:{uid}:{gid}::/sandbox:/bin/sh")); - } - - let mut output = lines.join("\n"); - if content.ends_with('\n') || !found { - output.push('\n'); - } - - std::fs::write(path, output).into_diagnostic()?; - Ok(()) -} - -#[cfg(unix)] -fn rewrite_group_at(path: &Path, gid: &str) -> Result<()> { - let content = std::fs::read_to_string(path).into_diagnostic()?; - - let mut found = false; - let mut lines: Vec = content - .lines() - .map(|line| { - if line.starts_with("sandbox:") { - found = true; - let fields: Vec<&str> = line.split(':').collect(); - if let [name, pass, _, members, ..] = fields.as_slice() { - format!("{name}:{pass}:{gid}:{members}") - } else { - line.to_string() - } - } else { - line.to_string() - } - }) - .collect(); - - if !found { - lines.push(format!("sandbox:x:{gid}:")); - } - - let mut output = lines.join("\n"); - if content.ends_with('\n') || !found { - output.push('\n'); - } - - std::fs::write(path, output).into_diagnostic()?; - Ok(()) -} - -/// Recursively chown a directory tree to the given UID/GID. -/// -/// This retains the Kubernetes/OpenShift workspace reconciliation from before -/// OCI image identity fallback. Symlinks are skipped, and read-only nested -/// mounts are not traversed. -#[cfg(unix)] -fn chown_sandbox_home(root: &Path, uid: Option, gid: Option) -> Result<()> { - let meta = std::fs::symlink_metadata(root).into_diagnostic()?; - if meta.file_type().is_symlink() { - return Err(miette::miette!( - "path '{}' is a symlink — refusing to chown (potential privilege escalation)", - root.display() - )); - } - - nix::unistd::chown(root, uid, gid).into_diagnostic()?; - - if meta.is_dir() { - chown_children(root, uid, gid, &nix::unistd::chown)?; - } - - Ok(()) -} - -#[cfg(unix)] -fn prepare_oci_workspace( - root: &Path, - uid: Option, - gid: Option, - supplementary_gids: &[Gid], -) -> Result<()> { - prepare_oci_workspace_with(root, uid, gid, supplementary_gids, &nix::unistd::chown) -} - -/// Validate that selecting an image-provided OCI workdir does not grant the -/// sandbox identity any filesystem authority it lacked in the immutable image. -/// -/// Every path component must be a real directory (never a symlink), every -/// parent must already be traversable, and the final directory must already be -/// writable and traversable. No ownership or mode bits are changed. -#[cfg(unix)] -pub fn validate_oci_workspace( - root: &Path, - uid: Option, - gid: Option, - supplementary_gids: &[Gid], -) -> Result<()> { - let components = validated_workspace_components(root, false)?; - let mut current = PathBuf::from("/"); - validate_workspace_component(¤t, uid, gid, supplementary_gids, false)?; - let last_component = components.len().saturating_sub(1); - for (index, component) in components.into_iter().enumerate() { - current.push(component); - validate_workspace_component( - ¤t, - uid, - gid, - supplementary_gids, - index == last_component, - )?; - } - Ok(()) -} - -/// Validate an image-provided workdir in a clean copy of the supervisor so the -/// main process retains the root authority needed for subsequent setup. -#[cfg(target_os = "linux")] -fn validate_oci_workspace_in_subprocess( - policy: &SandboxPolicy, - resolved_identity: ResolvedProcessIdentity, - workdir: &Path, -) -> Result<()> { - use std::os::unix::process::CommandExt; - - let (uid, gid, supplementary_gids) = resolve_filesystem_identity(policy, resolved_identity)?; - let uid = uid.ok_or_else(|| miette::miette!("workspace validator UID is unresolved"))?; - let gid = gid.ok_or_else(|| miette::miette!("workspace validator GID is unresolved"))?; - let groups = supplementary_gids - .iter() - .map(|group| group.as_raw()) - .collect::>(); - let executable = std::env::current_exe().into_diagnostic()?; - let mut command = std::process::Command::new(executable); - command - .arg("validate-workspace") - .arg("--workdir") - .arg(workdir) - .arg("--expected-uid") - .arg(uid.to_string()) - .arg("--expected-gid") - .arg(gid.to_string()) - .env_clear() - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()); - - // `pre_exec` runs after fork and before exec. These direct credential - // syscalls are async-signal-safe and affect only the one-shot child. - #[allow(unsafe_code)] - unsafe { - command.pre_exec(move || { - if libc::setgroups(groups.len(), groups.as_ptr()) != 0 - || libc::setgid(gid.as_raw()) != 0 - || libc::setuid(uid.as_raw()) != 0 - { - return Err(std::io::Error::last_os_error()); - } - Ok(()) - }); - } - - let output = command.output().into_diagnostic()?; - if output.status.success() { - return Ok(()); - } - - let diagnostic = String::from_utf8_lossy(&output.stderr); - let diagnostic = diagnostic.trim(); - if diagnostic.is_empty() { - return Err(miette::miette!( - "image workspace validation failed with status {}", - output.status - )); - } - Err(miette::miette!( - "image workspace validation failed: {diagnostic}" - )) -} - -#[cfg(unix)] -fn validate_workspace_component( - path: &Path, - uid: Option, - gid: Option, - supplementary_gids: &[Gid], - is_workspace: bool, -) -> Result<()> { - let metadata = std::fs::symlink_metadata(path).map_err(|error| { - if error.kind() == std::io::ErrorKind::NotFound { - miette::miette!( - "image workspace path component '{}' does not exist", - path.display() - ) - } else { - miette::miette!( - "failed to inspect image workspace path component '{}': {error}", - path.display() - ) - } - })?; - if metadata.file_type().is_symlink() { - return Err(miette::miette!( - "workspace path component '{}' is a symlink — refusing to follow it", - path.display() - )); - } - if !metadata.is_dir() { - return Err(miette::miette!( - "workspace path component '{}' is not a directory", - path.display() - )); - } - let required = if is_workspace { 0o3 } else { 0o1 }; - if !identity_has_permissions(&metadata, uid, gid, supplementary_gids, required) { - let requirement = if is_workspace { - "writable and traversable" - } else { - "traversable" - }; - return Err(miette::miette!( - "workspace path component '{}' is not {requirement} by the sandbox identity in the image", - path.display() - )); - } - Ok(()) -} - #[cfg(target_os = "linux")] pub fn validate_oci_workspace_as_effective_identity(root: &Path) -> Result<()> { use rustix::fs::{Access, AtFlags, FileType, Mode, OFlags}; - let components = validated_workspace_components(root, false)?; + let components = validated_workspace_components(root)?; let open_flags = OFlags::PATH | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC; let mut current_path = PathBuf::from("/"); let mut current_fd = rustix::fs::open("/", open_flags, Mode::empty()).into_diagnostic()?; @@ -1554,91 +970,21 @@ fn validate_effective_workspace_write(fd: &impl std::os::fd::AsFd, path: &Path) )) } -/// Prepare only the resolved `OpenShell` workspace directory itself. -/// -/// Image-provided children retain their declared ownership. This avoids -/// crossing symlinks or user-provided nested mounts. -#[cfg(unix)] -fn prepare_oci_workspace_with( - root: &Path, - uid: Option, - gid: Option, - supplementary_gids: &[Gid], - do_chown: &impl Fn(&Path, Option, Option) -> nix::Result<()>, -) -> Result<()> { - let components = validated_workspace_components(root, true)?; - - let last_component = components.len().saturating_sub(1); - let mut current = PathBuf::from("/"); - for (index, component) in components.into_iter().enumerate() { - current.push(component); - match std::fs::symlink_metadata(¤t) { - Ok(metadata) if metadata.file_type().is_symlink() => { - return Err(miette::miette!( - "workspace path component '{}' is a symlink — refusing to follow it", - current.display() - )); - } - Ok(metadata) if !metadata.is_dir() => { - return Err(miette::miette!( - "workspace path component '{}' is not a directory", - current.display() - )); - } - Ok(metadata) => { - if index != last_component - && !identity_can_traverse(&metadata, uid, gid, supplementary_gids) - { - return Err(miette::miette!( - "workspace parent '{}' is not traversable by the sandbox identity", - current.display() - )); - } - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - std::fs::create_dir(¤t).into_diagnostic()?; - std::fs::set_permissions(¤t, std::fs::Permissions::from_mode(0o755)) - .into_diagnostic()?; - } - Err(error) => return Err(error).into_diagnostic(), - } - } - - do_chown(root, uid, gid).into_diagnostic()?; - - let metadata = std::fs::symlink_metadata(root).into_diagnostic()?; - let mode = metadata.permissions().mode() & 0o7777; - if mode & 0o300 != 0o300 { - std::fs::set_permissions(root, std::fs::Permissions::from_mode(mode | 0o300)) - .into_diagnostic()?; - } - Ok(()) -} - -#[cfg(unix)] -fn validated_workspace_components( - root: &Path, - allow_managed_fallback: bool, -) -> Result> { - let root_str = root - .to_str() - .ok_or_else(|| miette::miette!("workspace path must be valid UTF-8"))?; - let validated_root = openshell_core::driver_mounts::resolve_oci_workspace_root(root_str) - .map_err(|error| miette::miette!(error))?; - if Path::new(&validated_root) != root - || (!allow_managed_fallback - && validated_root == openshell_core::driver_mounts::DEFAULT_WORKSPACE_ROOT) - { - return Err(miette::miette!( - "workspace path '{}' must be a normalized absolute {}path", - root.display(), - if allow_managed_fallback { - "non-root " - } else { - "non-fallback " - } - )); - } +#[cfg(target_os = "linux")] +fn validated_workspace_components(root: &Path) -> Result> { + let root_str = root + .to_str() + .ok_or_else(|| miette::miette!("workspace path must be valid UTF-8"))?; + let validated_root = openshell_core::driver_mounts::resolve_oci_workspace_root(root_str) + .map_err(|error| miette::miette!(error))?; + if Path::new(&validated_root) != root + || validated_root == openshell_core::driver_mounts::DEFAULT_WORKSPACE_ROOT + { + return Err(miette::miette!( + "workspace path '{}' must be a normalized absolute non-fallback path", + root.display() + )); + } root.components() .skip(1) @@ -1652,476 +998,6 @@ fn validated_workspace_components( .collect() } -#[cfg(unix)] -fn identity_can_traverse( - metadata: &std::fs::Metadata, - uid: Option, - gid: Option, - supplementary_gids: &[Gid], -) -> bool { - identity_has_permissions(metadata, uid, gid, supplementary_gids, 0o1) -} - -#[cfg(unix)] -fn identity_has_permissions( - metadata: &std::fs::Metadata, - uid: Option, - gid: Option, - supplementary_gids: &[Gid], - required: u32, -) -> bool { - let user_id = uid.unwrap_or_else(nix::unistd::geteuid).as_raw(); - if user_id == 0 { - return true; - } - - let group_id = gid.unwrap_or_else(nix::unistd::getegid).as_raw(); - let mode = metadata.permissions().mode(); - if metadata.uid() == user_id { - mode & (required << 6) == required << 6 - } else if metadata.gid() == group_id - || supplementary_gids - .iter() - .any(|supplementary_gid| supplementary_gid.as_raw() == metadata.gid()) - { - mode & (required << 3) == required << 3 - } else { - mode & required == required - } -} - -#[cfg(not(any( - target_os = "aix", - target_os = "haiku", - target_os = "illumos", - target_os = "ios", - target_os = "macos", - target_os = "redox", - target_os = "solaris" -)))] -fn named_user_supplementary_groups(user_name: &str, primary_gid: Gid) -> Result> { - let user_name = CString::new(user_name).map_err(|_| miette::miette!("Invalid user name"))?; - nix::unistd::getgrouplist(user_name.as_c_str(), primary_gid).into_diagnostic() -} - -#[cfg(any( - target_os = "aix", - target_os = "haiku", - target_os = "illumos", - target_os = "ios", - target_os = "macos", - target_os = "redox", - target_os = "solaris" -))] -#[allow(clippy::unnecessary_wraps)] -fn named_user_supplementary_groups(_user_name: &str, _primary_gid: Gid) -> Result> { - // Privilege dropping does not call initgroups on these targets. - Ok(Vec::new()) -} - -#[cfg(unix)] -fn chown_children( - dir: &Path, - uid: Option, - gid: Option, - do_chown: &impl Fn(&Path, Option, Option) -> nix::Result<()>, -) -> Result<()> { - match std::fs::read_dir(dir) { - Ok(entries) => { - for entry in entries { - let entry = entry.into_diagnostic()?; - chown_recursive(&entry.path(), uid, gid, do_chown)?; - } - } - Err(error) => { - debug!( - path = %dir.display(), - %error, - "Cannot list directory during sandbox home chown" - ); - } - } - Ok(()) -} - -#[cfg(unix)] -fn chown_recursive( - path: &Path, - uid: Option, - gid: Option, - do_chown: &impl Fn(&Path, Option, Option) -> nix::Result<()>, -) -> Result<()> { - let meta = std::fs::symlink_metadata(path).into_diagnostic()?; - if meta.file_type().is_symlink() { - debug!(path = %path.display(), "Skipping symlink during sandbox home chown"); - return Ok(()); - } - - if let Err(error) = do_chown(path, uid, gid) { - if error == nix::errno::Errno::EROFS { - debug!(path = %path.display(), "Skipping read-only path during sandbox home chown"); - return Ok(()); - } - return Err(error).into_diagnostic(); - } - - if meta.is_dir() { - chown_children(path, uid, gid, do_chown)?; - } - - Ok(()) -} - -/// Prepare filesystem for the sandboxed process. -/// -/// Creates `read_write` directories if they don't exist and sets ownership -/// on newly-created paths to the configured sandbox user/group. This runs as -/// the supervisor (root) before forking the child process. -/// -/// Accepts both name-based identities (resolved via `/etc/passwd`) and numeric -/// UIDs/GIDs (passed directly to `chown` without a passwd lookup). -#[cfg(unix)] -pub fn prepare_filesystem(policy: &SandboxPolicy) -> Result<()> { - prepare_filesystem_with_identity(policy, ResolvedProcessIdentity::default(), None, false) -} - -#[cfg(unix)] -pub fn prepare_filesystem_with_identity( - policy: &SandboxPolicy, - resolved_identity: ResolvedProcessIdentity, - workdir: Option<&str>, - prepare_workspace: bool, -) -> Result<()> { - use nix::unistd::chown; - - // If no user/group configured, nothing to do - if policy - .process - .run_as_user - .as_deref() - .is_none_or(str::is_empty) - && policy - .process - .run_as_group - .as_deref() - .is_none_or(str::is_empty) - { - return Ok(()); - } - - let (uid, gid, supplementary_gids) = resolve_filesystem_identity(policy, resolved_identity)?; - - // Docker owns workspace resolution and must make the selected root usable - // by the final effective identity, including when both policy identity - // fields were explicit. Validate it before processing any user-authored - // read-write paths so an unsafe image path fails first. Other drivers - // retain their preparation. - if prepare_workspace { - let workspace = workdir.ok_or_else(|| { - miette::miette!("local container driver did not supply a workspace workdir") - })?; - let workspace = Path::new(workspace); - if workspace == Path::new(openshell_core::driver_mounts::DEFAULT_WORKSPACE_ROOT) { - info!(path = %workspace.display(), ?uid, ?gid, "Preparing managed workspace"); - prepare_oci_workspace(workspace, uid, gid, &supplementary_gids)?; - } else { - info!(path = %workspace.display(), ?uid, ?gid, "Validating image workspace authority"); - #[cfg(target_os = "linux")] - validate_oci_workspace_in_subprocess(policy, resolved_identity, workspace)?; - #[cfg(not(target_os = "linux"))] - validate_oci_workspace(workspace, uid, gid, &supplementary_gids)?; - } - } - - // Create missing read_write paths and only chown the ones we created. - for path in &policy.filesystem.read_write { - if prepare_read_write_path(path)? { - debug!( - path = %path.display(), - ?uid, - ?gid, - "Setting ownership on newly created read_write path" - ); - chown(path, uid, gid).into_diagnostic()?; - } - } - - // Retain the existing Kubernetes/OpenShift behavior for driver-injected - // numeric identities. Docker clears this variable and does not receive - // identity-specific workspace preparation. - if std::env::var(openshell_core::sandbox_env::SANDBOX_UID).is_ok_and(|uid| !uid.is_empty()) { - let sandbox_home = Path::new("/sandbox"); - if sandbox_home.exists() { - info!(?uid, ?gid, "Chowning /sandbox for driver-injected UID/GID"); - chown_sandbox_home(sandbox_home, uid, gid)?; - } - } - - Ok(()) -} - -#[cfg(unix)] -fn resolve_filesystem_identity( - policy: &SandboxPolicy, - resolved_identity: ResolvedProcessIdentity, -) -> Result<(Option, Option, Vec)> { - let user_name = policy - .process - .run_as_user - .as_deref() - .filter(|name| !name.is_empty()); - let group_name = policy - .process - .run_as_group - .as_deref() - .filter(|name| !name.is_empty()); - - let uid = match resolved_identity.uid() { - Some(uid) => Some(Uid::from_raw(uid)), - None => match user_name { - Some(name) if name.parse::().is_ok() => { - Some(Uid::from_raw(name.parse().into_diagnostic()?)) - } - Some(name) => User::from_name(name).into_diagnostic()?.map(|u| u.uid), - _ => None, - }, - }; - - // Resolve GID: numeric values are passed directly; names resolve via group. - let gid = match resolved_identity.gid() { - Some(gid) => Some(Gid::from_raw(gid)), - None => match group_name { - Some(name) if name.parse::().is_ok() => { - Some(Gid::from_raw(name.parse().into_diagnostic()?)) - } - Some(name) => Group::from_name(name).into_diagnostic()?.map(|g| g.gid), - _ => None, - }, - }; - - let supplementary_gids = match user_name { - Some(name) if name.parse::().is_err() => { - let primary_gid = if let Some(gid) = gid { - gid - } else { - let uid = - uid.ok_or_else(|| miette::miette!("Failed to resolve sandbox user '{name}'"))?; - User::from_uid(uid) - .into_diagnostic()? - .ok_or_else(|| miette::miette!("Failed to resolve user from UID {uid}"))? - .gid - }; - if resolved_identity.uid().is_some() { - crate::identity::resolve_oci_supplementary_gids(name, primary_gid.as_raw())? - .into_iter() - .map(Gid::from_raw) - .collect() - } else { - named_user_supplementary_groups(name, primary_gid)? - } - } - _ => Vec::new(), - }; - - Ok((uid, gid, supplementary_gids)) -} - -#[cfg(not(unix))] -pub fn prepare_filesystem(_policy: &SandboxPolicy) -> Result<()> { - Ok(()) -} - -// `effective_gid`/`effective_uid` are intentionally parallel names (same role -// for different identifiers) and the noise from renaming would obscure intent. -#[cfg(unix)] -#[allow(clippy::similar_names)] -pub fn drop_privileges(policy: &SandboxPolicy) -> Result<()> { - drop_privileges_with_identity(policy, ResolvedProcessIdentity::default()) -} - -#[cfg(unix)] -#[allow(clippy::similar_names)] -pub fn drop_privileges_with_identity( - policy: &SandboxPolicy, - resolved_identity: ResolvedProcessIdentity, -) -> Result<()> { - let user_name = match policy.process.run_as_user.as_deref() { - Some(name) if !name.is_empty() => Some(name), - _ => None, - }; - let group_name = match policy.process.run_as_group.as_deref() { - Some(name) if !name.is_empty() => Some(name), - _ => None, - }; - - // If no user/group is configured and we are running as root, fall back to - // "sandbox:sandbox" instead of silently keeping root. This covers the - // local/dev-mode path for drivers that provide no identity metadata. - // For non-root runtimes, the no-op is safe -- we are already unprivileged. - if user_name.is_none() && group_name.is_none() { - if nix::unistd::geteuid().is_root() { - let mut fallback = policy.clone(); - fallback.process.run_as_user = Some("sandbox".into()); - fallback.process.run_as_group = Some("sandbox".into()); - return drop_privileges_with_identity(&fallback, resolved_identity); - } - return Ok(()); - } - - // Resolve UID: numeric values are used directly; names resolve via passwd. - let target_uid = match resolved_identity.uid() { - Some(uid) => Uid::from_raw(uid), - None => match user_name { - Some(name) if name.parse::().is_ok() => { - Uid::from_raw(name.parse().into_diagnostic()?) - } - Some(name) => { - User::from_name(name) - .into_diagnostic()? - .ok_or_else(|| miette::miette!("Sandbox user not found: {name}"))? - .uid - } - None => nix::unistd::geteuid(), - }, - }; - - // Resolve group: if a numeric GID is configured use it directly. - // Otherwise try name resolution, then fall back to current user's primary group. - let target_gid = match resolved_identity.gid() { - Some(gid) => Gid::from_raw(gid), - None => match group_name { - Some(name) if name.parse::().is_ok() => { - Gid::from_raw(name.parse().into_diagnostic()?) - } - Some(name) => { - Group::from_name(name) - .into_diagnostic()? - .ok_or_else(|| miette::miette!("Sandbox group not found: {name}"))? - .gid - } - None => match target_uid.as_raw() { - 0 => nix::unistd::getegid(), - _ => Group::from_gid( - User::from_uid(target_uid) - .into_diagnostic()? - .ok_or_else(|| { - miette::miette!("Failed to resolve user from UID {target_uid}") - })? - .gid, - ) - .into_diagnostic()? - .map_or_else(nix::unistd::getegid, |g| g.gid), - }, - }, - }; - - // Resolve the name for initgroups only for the existing explicit-policy - // path. OCI-derived users carry a numeric UID from the bounded parser and - // must not be looked up again through NSS. - let user_name_is_numeric = user_name.is_some_and(|n| n.parse::().is_ok()); - let initgroups_name = - if user_name.is_some() && !user_name_is_numeric && resolved_identity.uid().is_none() { - Some( - User::from_uid(target_uid) - .into_diagnostic()? - .ok_or_else(|| { - miette::miette!("Failed to resolve user record for UID {target_uid}") - })? - .name, - ) - } else { - None - }; - - if target_uid != nix::unistd::geteuid() { - if resolved_identity.uses_oci_user_fallback() { - // OCI named users use the bounded /etc/group parser shared with - // workspace validation. Numeric OCI users resolve to an empty - // list. Never retain the root supervisor's inherited groups. - #[cfg(not(any( - target_os = "macos", - target_os = "ios", - target_os = "haiku", - target_os = "redox" - )))] - { - let (_, _, supplementary_gids) = - resolve_filesystem_identity(policy, resolved_identity)?; - nix::unistd::setgroups(&supplementary_gids).into_diagnostic()?; - } - } else if let Some(ref user_name) = initgroups_name { - let user_cstr = CString::new(user_name.as_str()) - .map_err(|_| miette::miette!("Invalid user name"))?; - #[cfg(any( - target_os = "macos", - target_os = "ios", - target_os = "haiku", - target_os = "redox" - ))] - { - let _ = user_cstr; - } - #[cfg(not(any( - target_os = "macos", - target_os = "ios", - target_os = "haiku", - target_os = "redox" - )))] - { - nix::unistd::initgroups(user_cstr.as_c_str(), target_gid).into_diagnostic()?; - } - } - } - - if target_gid != nix::unistd::getegid() { - nix::unistd::setgid(target_gid).into_diagnostic()?; - } - - // Verify effective GID actually changed (defense-in-depth, CWE-250 / CERT POS37-C) - let effective_gid = nix::unistd::getegid(); - if effective_gid != target_gid { - return Err(miette::miette!( - "Privilege drop verification failed: expected effective GID {}, got {}", - target_gid, - effective_gid - )); - } - - #[cfg(target_os = "linux")] - if nix::unistd::geteuid().is_root() { - drop_capability_bounding_set()?; - } - - if user_name.is_some() { - if target_uid != nix::unistd::geteuid() { - nix::unistd::setuid(target_uid).into_diagnostic()?; - } - - // Verify effective UID actually changed (defense-in-depth, CWE-250 / CERT POS37-C) - let effective_uid = nix::unistd::geteuid(); - if effective_uid != target_uid { - return Err(miette::miette!( - "Privilege drop verification failed: expected effective UID {}, got {}", - target_uid, - effective_uid - )); - } - - // Verify root cannot be re-acquired (CERT POS37-C hardening). - // If we dropped from root, setuid(0) must fail; success means privileges - // were not fully relinquished. - if nix::unistd::setuid(Uid::from_raw(0)).is_ok() && target_uid.as_raw() != 0 { - return Err(miette::miette!( - "Privilege drop verification failed: process can still re-acquire root (UID 0) \ - after switching to UID {}", - target_uid - )); - } - } - - Ok(()) -} - /// Process exit status. #[derive(Debug, Clone, Copy)] pub struct ProcessStatus { @@ -2188,8 +1064,12 @@ mod tests { use openshell_core::policy::{ FilesystemPolicy, LandlockPolicy, NetworkPolicy, ProcessPolicy, SandboxPolicy, }; + #[cfg(target_os = "linux")] + use std::ffi::CString; #[cfg(unix)] use std::mem::size_of; + #[cfg(target_os = "linux")] + use std::os::unix::fs::PermissionsExt; use std::process::Stdio as StdStdio; /// Helper to create a minimal `SandboxPolicy` with the given process policy. @@ -2294,333 +1174,6 @@ mod tests { } } - /// Unknown names may yield `Ok(None)` (`… not found …`) or `Err` when NSS fails first - /// (e.g. `ENOENT: No such file or directory`). - fn assert_unknown_identity_lookup_failed(msg: &str) { - assert!( - msg.contains("not found") - || msg.contains("ENOENT") - || msg.contains("No such file or directory"), - "expected unknown user/group lookup failure (…not found… or ENOENT): {msg}" - ); - } - - #[test] - #[cfg(unix)] - fn explicit_identity_accepts_non_root_system_ids() { - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some("101".into()), - run_as_group: Some("102".into()), - }); - - assert!(validate_sandbox_user(&policy).is_ok()); - assert!(validate_sandbox_group(&policy).is_ok()); - } - - #[test] - #[cfg(unix)] - fn resolved_oci_identity_accepts_non_root_system_ids() { - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some("app".into()), - run_as_group: Some("staff".into()), - }); - let resolved = ResolvedProcessIdentity::new(Some(101), Some(102)); - - assert!(validate_sandbox_user_with_identity(&policy, resolved).is_ok()); - assert!(validate_sandbox_group_with_identity(&policy, resolved).is_ok()); - } - - #[test] - #[cfg(unix)] - fn completed_runtime_identity_rejects_numeric_root() { - let root_user = policy_with_process(ProcessPolicy { - run_as_user: Some("0".into()), - run_as_group: Some("102".into()), - }); - let root_group = policy_with_process(ProcessPolicy { - run_as_user: Some("101".into()), - run_as_group: Some("0".into()), - }); - - assert!(validate_sandbox_user(&root_user).is_err()); - assert!(validate_sandbox_group(&root_group).is_err()); - } - - #[test] - #[cfg(unix)] - fn resolved_oci_components_do_not_repeat_nss_validation() { - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some("__oci_name_not_in_host_nss__".into()), - run_as_group: Some("__oci_group_not_in_host_nss__".into()), - }); - let resolved = ResolvedProcessIdentity::new(Some(1234), Some(1235)); - - assert!(validate_sandbox_user_with_identity(&policy, resolved).is_ok()); - assert!(validate_sandbox_group_with_identity(&policy, resolved).is_ok()); - } - - #[test] - #[cfg(unix)] - fn explicit_policy_components_keep_existing_validation_path() { - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some("__explicit_name_not_in_host_nss__".into()), - run_as_group: Some("__oci_group_not_in_host_nss__".into()), - }); - let resolved = ResolvedProcessIdentity::new(None, Some(1235)); - - assert!(validate_sandbox_user_with_identity(&policy, resolved).is_err()); - assert!(validate_sandbox_group_with_identity(&policy, resolved).is_ok()); - } - - #[cfg(target_os = "linux")] - fn capability_bounding_set_clear_available() -> bool { - capctl::caps::CapState::get_current() - .is_ok_and(|state| state.effective.has(capctl::caps::Cap::SETPCAP)) - || capctl::caps::bounding::probe().is_empty() - } - - #[test] - #[cfg(target_os = "linux")] - fn capability_bounding_set_clear_accepts_empty_eperm() { - let remaining = capctl::caps::CapSet::empty(); - - assert!( - validate_capability_bounding_set_clear( - Err(capctl::Error::from_code(libc::EPERM)), - remaining, - || Ok(()), - ) - .is_ok() - ); - } - - #[test] - #[cfg(target_os = "linux")] - fn capability_bounding_set_clear_rejects_nonempty_eperm() { - let mut remaining = capctl::caps::CapSet::empty(); - remaining.add(capctl::caps::Cap::CHOWN); - - let result = validate_capability_bounding_set_clear( - Err(capctl::Error::from_code(libc::EPERM)), - remaining, - || panic!("unknown capabilities should not be checked when known caps remain"), - ); - - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("Failed to clear child capability bounding set") - ); - } - - #[test] - #[cfg(target_os = "linux")] - fn capability_bounding_set_clear_rejects_nonempty_success() { - let mut remaining = capctl::caps::CapSet::empty(); - remaining.add(capctl::caps::Cap::CHOWN); - - let result = validate_capability_bounding_set_clear(Ok(()), remaining, || { - panic!("unknown capabilities should not be checked when known caps remain") - }); - - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("capabilities remain raised") - ); - } - - #[test] - #[cfg(target_os = "linux")] - fn capability_bounding_set_clear_rejects_unknown_eperm() { - let remaining = capctl::caps::CapSet::empty(); - - let result = validate_capability_bounding_set_clear( - Err(capctl::Error::from_code(libc::EPERM)), - remaining, - || Err(capctl::Error::from_code(libc::EPERM)), - ); - - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("Failed to clear unknown child capability bounding set entries") - ); - } - - #[test] - #[cfg(target_os = "linux")] - fn capability_probe_child() { - if std::env::var_os("OPENSHELL_TEST_PROBE_CHILD_CAPS").is_none() { - return; - } - - assert!( - capctl::caps::bounding::probe().is_empty(), - "child CapBnd should be empty after exec" - ); - } - - #[test] - fn drop_privileges_noop_when_no_user_or_group() { - let policy = policy_with_process(ProcessPolicy { - run_as_user: None, - run_as_group: None, - }); - if nix::unistd::geteuid().is_root() { - // As root, drop_privileges falls back to "sandbox:sandbox". - // If that user exists, it succeeds; if not (e.g. CI), it - // must error rather than silently keep root. - let has_sandbox = User::from_name("sandbox").ok().flatten().is_some(); - assert_eq!(drop_privileges(&policy).is_ok(), has_sandbox); - } else { - assert!(drop_privileges(&policy).is_ok()); - } - } - - #[test] - fn drop_privileges_noop_when_empty_strings() { - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some(String::new()), - run_as_group: Some(String::new()), - }); - if nix::unistd::geteuid().is_root() { - let has_sandbox = User::from_name("sandbox").ok().flatten().is_some(); - assert_eq!(drop_privileges(&policy).is_ok(), has_sandbox); - } else { - assert!(drop_privileges(&policy).is_ok()); - } - } - - #[test] - fn drop_privileges_succeeds_for_current_group() { - // Set only run_as_group (no run_as_user) so that initgroups() is not - // called. initgroups(3) requires CAP_SETGID/root even when the target - // is the current user, so it cannot be exercised without elevated - // privileges. This test covers the setgid() + GID post-condition - // verification path without needing root. - let current_group = Group::from_gid(nix::unistd::getegid()) - .expect("getgrgid") - .expect("current group entry"); - - let policy = policy_with_process(ProcessPolicy { - run_as_user: None, - run_as_group: Some(current_group.name), - }); - - let result = drop_privileges(&policy); - #[cfg(target_os = "linux")] - { - if nix::unistd::geteuid().is_root() && !capability_bounding_set_clear_available() { - let msg = format!("{}", result.unwrap_err()); - assert!( - msg.contains("Failed to clear child capability bounding set"), - "unexpected failure: {msg}" - ); - return; - } - } - assert!(result.is_ok(), "drop_privileges failed: {result:?}"); - } - - #[test] - #[cfg(target_os = "linux")] - #[allow(unsafe_code)] - fn drop_privileges_clears_bounding_set_for_spawned_child_when_permitted() { - use std::os::unix::process::CommandExt; - - if !capability_bounding_set_clear_available() { - eprintln!( - "skipping: CAP_SETPCAP is not effective and the capability bounding set is nonempty" - ); - return; - } - - let current_group = Group::from_gid(nix::unistd::getegid()) - .expect("getgrgid") - .expect("current group entry"); - - let policy = policy_with_process(ProcessPolicy { - run_as_user: None, - run_as_group: Some(current_group.name), - }); - - let mut cmd = std::process::Command::new(std::env::current_exe().expect("current exe")); - cmd.arg("capability_probe_child") - .arg("--nocapture") - .env("OPENSHELL_TEST_PROBE_CHILD_CAPS", "1") - .stdin(StdStdio::null()) - .stdout(StdStdio::piped()) - .stderr(StdStdio::piped()); - - unsafe { - cmd.pre_exec(move || { - drop_privileges(&policy).map_err(|err| std::io::Error::other(err.to_string())) - }); - } - - let output = cmd.output().expect("spawn child status probe"); - assert!( - output.status.success(), - "status probe failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - #[test] - #[ignore = "initgroups(3) requires CAP_SETGID; run as root: sudo cargo test -- --ignored"] - fn drop_privileges_succeeds_for_current_user() { - // Exercises the full privilege-drop path including initgroups(), - // setgid(), setuid(), and the root-reacquisition check. Requires - // CAP_SETGID (root) because initgroups(3) calls setgroups(2) - // internally. Fixes: https://github.com/NVIDIA/OpenShell/issues/622 - let current_user = User::from_uid(nix::unistd::geteuid()) - .expect("getpwuid") - .expect("current user entry"); - let current_group = Group::from_gid(nix::unistd::getegid()) - .expect("getgrgid") - .expect("current group entry"); - - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some(current_user.name), - run_as_group: Some(current_group.name), - }); - - assert!(drop_privileges(&policy).is_ok()); - } - - #[test] - fn drop_privileges_fails_for_nonexistent_user() { - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some("__nonexistent_test_user_42__".to_string()), - run_as_group: None, - }); - - let result = drop_privileges(&policy); - assert!(result.is_err()); - let msg = format!("{}", result.unwrap_err()); - assert_unknown_identity_lookup_failed(&msg); - } - - #[test] - fn drop_privileges_fails_for_nonexistent_group() { - let policy = policy_with_process(ProcessPolicy { - run_as_user: None, - run_as_group: Some("__nonexistent_test_group_42__".to_string()), - }); - - let result = drop_privileges(&policy); - assert!(result.is_err()); - let msg = format!("{}", result.unwrap_err()); - assert_unknown_identity_lookup_failed(&msg); - } - #[cfg(unix)] #[allow(unsafe_code)] fn probe_hardened_child(probe: unsafe fn() -> i64) -> i64 { @@ -2697,416 +1250,75 @@ mod tests { } #[test] - #[cfg(target_os = "linux")] - fn harden_child_process_marks_process_nondumpable() { - assert_eq!(probe_hardened_child(dumpable_flag_probe), 0); - } - - #[test] - #[cfg(target_os = "linux")] - fn parse_pids_max_detects_limited_runtime() { - assert_eq!( - parse_pids_max("2048\n"), - RuntimePidLimitStatus::Limited(2048) - ); - } - - #[test] - #[cfg(target_os = "linux")] - fn parse_pids_max_detects_unlimited_runtime() { - assert_eq!(parse_pids_max("max\n"), RuntimePidLimitStatus::Unlimited); - } - - #[test] - #[cfg(target_os = "linux")] - fn parse_pids_max_reports_invalid_values() { - let status = parse_pids_max("not-a-number\n"); - assert!(matches!(status, RuntimePidLimitStatus::Unavailable(_))); - } - - #[test] - #[cfg(target_os = "linux")] - fn pid_limit_require_mode_rejects_missing_guardrail_statuses() { - for status in [ - RuntimePidLimitStatus::Unlimited, - RuntimePidLimitStatus::Unavailable("missing".to_string()), - ] { - let result = check_runtime_pid_limit_status(status, RuntimePidLimitMode::Require); - assert!(result.is_err()); - } - } - - #[test] - #[cfg(target_os = "linux")] - fn pid_limit_warn_mode_accepts_missing_guardrail_statuses() { - for status in [ - RuntimePidLimitStatus::Unlimited, - RuntimePidLimitStatus::Unavailable("missing".to_string()), - ] { - let result = check_runtime_pid_limit_status(status, RuntimePidLimitMode::Warn); - assert!(result.is_ok()); - } - } - - #[tokio::test] - async fn inject_provider_env_sets_placeholder_values() { - let mut cmd = Command::new("/usr/bin/env"); - cmd.stdin(StdStdio::null()) - .stdout(StdStdio::piped()) - .stderr(StdStdio::null()); - - let provider_env = std::iter::once(( - "ANTHROPIC_API_KEY".to_string(), - "openshell:resolve:env:ANTHROPIC_API_KEY".to_string(), - )) - .collect(); - - inject_provider_env(&mut cmd, &provider_env); - - 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")); - } - - #[cfg(unix)] - fn sandbox_policy_with_read_write( - path: PathBuf, - run_as_user: Option, - run_as_group: Option, - ) -> SandboxPolicy { - SandboxPolicy { - version: 1, - filesystem: FilesystemPolicy { - read_only: vec![], - read_write: vec![path], - include_workdir: false, - }, - network: NetworkPolicy::default(), - landlock: LandlockPolicy::default(), - process: ProcessPolicy { - run_as_user, - run_as_group, - }, - } - } - - #[cfg(unix)] - #[test] - fn prepare_read_write_path_creates_missing_directory() { - let dir = tempfile::tempdir().unwrap(); - let missing = dir.path().join("missing").join("nested"); - - assert!(prepare_read_write_path(&missing).unwrap()); - assert!(missing.is_dir()); - } - - #[cfg(unix)] - #[test] - fn prepare_read_write_path_preserves_existing_directory() { - let dir = tempfile::tempdir().unwrap(); - let existing = dir.path().join("existing"); - std::fs::create_dir(&existing).unwrap(); - - assert!(!prepare_read_write_path(&existing).unwrap()); - assert!(existing.is_dir()); - } - - #[cfg(unix)] - #[test] - fn prepare_read_write_path_rejects_symlink() { - use std::os::unix::fs::symlink; - - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().join("target"); - let link = dir.path().join("link"); - std::fs::create_dir(&target).unwrap(); - symlink(&target, &link).unwrap(); - - let error = prepare_read_write_path(&link).unwrap_err(); - assert!( - error - .to_string() - .contains("is a symlink — refusing to chown"), - "unexpected error: {error}" - ); - } - - #[cfg(unix)] - #[test] - fn prepare_filesystem_skips_chown_for_existing_read_write_paths() { - use std::os::unix::fs::MetadataExt; - - if nix::unistd::geteuid().is_root() { - return; - } - - let Ok(Some(current_user)) = User::from_uid(nix::unistd::geteuid()) else { - eprintln!("skipping: current UID has no /etc/passwd entry"); - return; - }; - let restricted_group = Group::from_gid(Gid::from_raw(0)) - .unwrap() - .expect("gid 0 group entry"); - if restricted_group.gid == nix::unistd::getegid() { - return; - } - - let dir = tempfile::tempdir().unwrap(); - let existing = dir.path().join("existing"); - std::fs::create_dir(&existing).unwrap(); - let before = std::fs::metadata(&existing).unwrap(); - - let policy = sandbox_policy_with_read_write( - existing.clone(), - Some(current_user.name), - Some(restricted_group.name), - ); - - prepare_filesystem(&policy).expect("existing path should not be re-owned"); - - let after = std::fs::metadata(&existing).unwrap(); - assert_eq!(after.uid(), before.uid()); - assert_eq!(after.gid(), before.gid()); - } - - #[cfg(unix)] - #[test] - #[allow(clippy::similar_names)] - fn chown_sandbox_home_changes_ownership_recursively() { - use std::os::unix::fs::MetadataExt; - - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join("sandbox"); - std::fs::create_dir(&root).unwrap(); - std::fs::write(root.join("file.txt"), "hello").unwrap(); - std::fs::create_dir(root.join("subdir")).unwrap(); - std::fs::write(root.join("subdir").join("nested.txt"), "world").unwrap(); - - let expected_uid = nix::unistd::geteuid(); - let expected_gid = nix::unistd::getegid(); - chown_sandbox_home(&root, Some(expected_uid), Some(expected_gid)).unwrap(); - - for path in &[ - root.clone(), - root.join("file.txt"), - root.join("subdir"), - root.join("subdir").join("nested.txt"), - ] { - let meta = std::fs::metadata(path).unwrap(); - assert_eq!(meta.uid(), expected_uid.as_raw()); - assert_eq!(meta.gid(), expected_gid.as_raw()); - } - } - - #[cfg(unix)] - #[test] - fn chown_sandbox_home_rejects_symlink_root() { - use std::os::unix::fs::symlink; - - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().join("real"); - let link = dir.path().join("link"); - std::fs::create_dir(&target).unwrap(); - symlink(&target, &link).unwrap(); - - let err = chown_sandbox_home( - &link, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - ) - .unwrap_err(); - assert!( - err.to_string().contains("symlink"), - "expected symlink rejection: {err}" - ); - } - - #[cfg(unix)] - #[test] - fn chown_sandbox_home_skips_symlink_children() { - use std::os::unix::fs::symlink; - - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join("sandbox"); - std::fs::create_dir(&root).unwrap(); - let target = dir.path().join("outside"); - std::fs::write(&target, "secret").unwrap(); - symlink(&target, root.join("link")).unwrap(); - - chown_sandbox_home( - &root, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - ) - .expect("symlink children should be skipped"); - } - - #[cfg(unix)] - #[test] - fn chown_recursive_skips_erofs_subtree_but_continues_siblings() { - use std::sync::{Arc, Mutex}; - - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join("sandbox"); - std::fs::create_dir(&root).unwrap(); - - let readonly_dir = root.join("ro-mount"); - std::fs::create_dir(&readonly_dir).unwrap(); - std::fs::write(readonly_dir.join("child-under-ro.txt"), "data").unwrap(); - std::fs::write(root.join("writable-sibling.txt"), "data").unwrap(); - - let chowned = Arc::new(Mutex::new(Vec::new())); - let observed = Arc::clone(&chowned); - let readonly_dir_for_chown = readonly_dir.clone(); - let fake_chown = - move |path: &Path, _uid: Option, _gid: Option| -> nix::Result<()> { - if path == readonly_dir_for_chown { - return Err(nix::errno::Errno::EROFS); - } - observed.lock().unwrap().push(path.to_path_buf()); - Ok(()) - }; - - chown_children( - &root, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &fake_chown, - ) - .expect("read-only subtree should be skipped"); - - let chowned = chowned.lock().unwrap(); - assert!( - !chowned.contains(&readonly_dir.join("child-under-ro.txt")), - "children under EROFS directory must not be traversed" - ); - assert!( - chowned.contains(&root.join("writable-sibling.txt")), - "writable sibling should still be chowned" - ); - } - - #[cfg(unix)] - #[test] - fn chown_recursive_propagates_non_erofs_errors() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join("sandbox"); - std::fs::create_dir(&root).unwrap(); - let fake_chown = |_path: &Path, _uid: Option, _gid: Option| -> nix::Result<()> { - Err(nix::errno::Errno::EPERM) - }; + #[cfg(target_os = "linux")] + fn harden_child_process_marks_process_nondumpable() { + assert_eq!(probe_hardened_child(dumpable_flag_probe), 0); + } - let result = chown_recursive( - &root, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &fake_chown, + #[test] + #[cfg(target_os = "linux")] + fn parse_pids_max_detects_limited_runtime() { + assert_eq!( + parse_pids_max("2048\n"), + RuntimePidLimitStatus::Limited(2048) ); - assert!(result.is_err(), "non-EROFS errors should propagate"); } - #[cfg(unix)] #[test] - fn prepare_oci_workspace_chowns_only_root() { - use std::sync::{Arc, Mutex}; - - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().canonicalize().unwrap().join("sandbox"); - std::fs::create_dir(&root).unwrap(); - let child = root.join("image-content.txt"); - std::fs::write(&child, "image-owned").unwrap(); - - let chowned = Arc::new(Mutex::new(Vec::new())); - let observed = Arc::clone(&chowned); - let fake_chown = - move |path: &Path, _uid: Option, _gid: Option| -> nix::Result<()> { - observed.lock().unwrap().push(path.to_path_buf()); - Ok(()) - }; - - prepare_oci_workspace_with( - &root, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - &fake_chown, - ) - .expect("workspace root should be prepared"); - - assert_eq!(*chowned.lock().unwrap(), vec![root]); - assert!(child.exists(), "image-provided child should be untouched"); + #[cfg(target_os = "linux")] + fn parse_pids_max_detects_unlimited_runtime() { + assert_eq!(parse_pids_max("max\n"), RuntimePidLimitStatus::Unlimited); } - #[cfg(unix)] #[test] - fn validate_oci_workspace_accepts_existing_owner_writable_directory() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().canonicalize().unwrap().join("project"); - std::fs::create_dir(&root).unwrap(); - std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap(); - - validate_oci_workspace( - &root, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - ) - .expect("image owner already has write and traverse authority"); + #[cfg(target_os = "linux")] + fn parse_pids_max_reports_invalid_values() { + let status = parse_pids_max("not-a-number\n"); + assert!(matches!(status, RuntimePidLimitStatus::Unavailable(_))); } - #[cfg(unix)] #[test] - fn validate_oci_workspace_accepts_supplementary_group_write_authority() { - let dir = tempfile::tempdir_in("/tmp").unwrap(); - std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o711)).unwrap(); - let root = dir.path().canonicalize().unwrap().join("project"); - std::fs::create_dir(&root).unwrap(); - std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o070)).unwrap(); - let metadata = std::fs::symlink_metadata(&root).unwrap(); - - validate_oci_workspace( - &root, - Some(Uid::from_raw(metadata.uid().wrapping_add(1))), - Some(Gid::from_raw(metadata.gid().wrapping_add(1))), - &[Gid::from_raw(metadata.gid())], - ) - .expect("supplementary group already has write and traverse authority"); + #[cfg(target_os = "linux")] + fn pid_limit_require_mode_rejects_missing_guardrail_statuses() { + for status in [ + RuntimePidLimitStatus::Unlimited, + RuntimePidLimitStatus::Unavailable("missing".to_string()), + ] { + let result = check_runtime_pid_limit_status(status, RuntimePidLimitMode::Require); + assert!(result.is_err()); + } } - #[cfg(unix)] #[test] - fn validate_oci_workspace_rejects_unwritable_directory() { - let dir = tempfile::tempdir_in("/tmp").unwrap(); - std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o711)).unwrap(); - let root = dir.path().canonicalize().unwrap().join("project"); - std::fs::create_dir(&root).unwrap(); - std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap(); - let metadata = std::fs::symlink_metadata(&root).unwrap(); - - let error = validate_oci_workspace( - &root, - Some(Uid::from_raw(metadata.uid().wrapping_add(1))), - Some(Gid::from_raw(metadata.gid().wrapping_add(1))), - &[], - ) - .unwrap_err(); - assert!(error.to_string().contains("not writable and traversable")); + #[cfg(target_os = "linux")] + fn pid_limit_warn_mode_accepts_missing_guardrail_statuses() { + for status in [ + RuntimePidLimitStatus::Unlimited, + RuntimePidLimitStatus::Unavailable("missing".to_string()), + ] { + let result = check_runtime_pid_limit_status(status, RuntimePidLimitMode::Warn); + assert!(result.is_ok()); + } } - #[cfg(unix)] - #[test] - fn validate_oci_workspace_rejects_missing_path() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().canonicalize().unwrap().join("missing"); - - let error = validate_oci_workspace( - &root, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - ) - .unwrap_err(); - assert!(error.to_string().contains("does not exist")); + #[tokio::test] + async fn inject_provider_env_sets_placeholder_values() { + let mut cmd = Command::new("/usr/bin/env"); + cmd.stdin(StdStdio::null()) + .stdout(StdStdio::piped()) + .stderr(StdStdio::null()); + + let provider_env = std::iter::once(( + "ANTHROPIC_API_KEY".to_string(), + "openshell:resolve:env:ANTHROPIC_API_KEY".to_string(), + )) + .collect(); + + inject_provider_env(&mut cmd, &provider_env); + + 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")); } #[cfg(target_os = "linux")] @@ -3301,405 +1513,6 @@ mod tests { } } - #[cfg(unix)] - #[test] - fn validate_oci_workspace_rejects_restrictive_parent() { - let dir = tempfile::tempdir().unwrap(); - let parent = dir.path().canonicalize().unwrap().join("private"); - let root = parent.join("project"); - std::fs::create_dir_all(&root).unwrap(); - std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); - std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o777)).unwrap(); - let metadata = std::fs::symlink_metadata(&parent).unwrap(); - - let error = validate_oci_workspace( - &root, - Some(Uid::from_raw(metadata.uid().wrapping_add(1))), - Some(Gid::from_raw(metadata.gid().wrapping_add(1))), - &[], - ) - .unwrap_err(); - assert!(error.to_string().contains("not traversable")); - } - - #[cfg(unix)] - #[test] - fn validate_oci_workspace_rejects_symlink_component() { - use std::os::unix::fs::symlink; - - let dir = tempfile::tempdir().unwrap(); - let base = dir.path().canonicalize().unwrap(); - let target = base.join("target"); - let link = base.join("link"); - std::fs::create_dir(&target).unwrap(); - symlink(&target, &link).unwrap(); - - let error = validate_oci_workspace( - &link, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - ) - .unwrap_err(); - assert!(error.to_string().contains("symlink")); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_makes_existing_root_owner_writable() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().canonicalize().unwrap().join("sandbox"); - std::fs::create_dir(&root).unwrap(); - std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o555)).unwrap(); - - prepare_oci_workspace_with(&root, None, None, &[], &|_, _, _| Ok(())) - .expect("read-only workspace root should be prepared"); - - let mode = std::fs::symlink_metadata(&root) - .unwrap() - .permissions() - .mode(); - assert_eq!(mode & 0o777, 0o755); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_rejects_symlink_root() { - use std::os::unix::fs::symlink; - - let dir = tempfile::tempdir().unwrap(); - let base = dir.path().canonicalize().unwrap(); - let target = base.join("real"); - let link = base.join("link"); - std::fs::create_dir(&target).unwrap(); - symlink(&target, &link).unwrap(); - - let err = prepare_oci_workspace( - &link, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - ) - .unwrap_err(); - assert!( - err.to_string().contains("symlink"), - "expected symlink rejection: {err}" - ); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_rejects_symlink_parent() { - use std::os::unix::fs::symlink; - - let dir = tempfile::tempdir().unwrap(); - let base = dir.path().canonicalize().unwrap(); - let target = base.join("real"); - let parent_link = base.join("parent-link"); - std::fs::create_dir(&target).unwrap(); - symlink(&target, &parent_link).unwrap(); - - let err = prepare_oci_workspace( - &parent_link.join("workspace"), - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - ) - .unwrap_err(); - assert!( - err.to_string().contains("symlink"), - "expected parent symlink rejection: {err}" - ); - assert!( - !target.join("workspace").exists(), - "workspace must not be created through a symlink parent" - ); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_rejects_parent_traversal() { - let err = prepare_oci_workspace( - Path::new("/tmp/workspace/../escape"), - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - ) - .unwrap_err(); - assert!( - err.to_string().contains("must be normalized"), - "expected traversal rejection: {err}" - ); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_rejects_inaccessible_existing_parent() { - let dir = tempfile::tempdir().unwrap(); - let parent = dir.path().canonicalize().unwrap().join("workspace"); - std::fs::create_dir(&parent).unwrap(); - std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); - let metadata = std::fs::symlink_metadata(&parent).unwrap(); - let different_user = Uid::from_raw(metadata.uid().wrapping_add(1)); - let different_group = Gid::from_raw(metadata.gid().wrapping_add(1)); - let root = parent.join("project"); - - let error = prepare_oci_workspace_with( - &root, - Some(different_user), - Some(different_group), - &[], - &|_, _, _| Ok(()), - ) - .unwrap_err(); - - assert!( - error.to_string().contains("is not traversable"), - "unexpected error: {error}" - ); - assert!( - !root.exists(), - "workspace must not be created below an inaccessible parent" - ); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_accepts_supplementary_group_parent() { - let dir = tempfile::tempdir_in("/tmp").unwrap(); - std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o710)).unwrap(); - let parent = dir.path().canonicalize().unwrap().join("workspace"); - std::fs::create_dir(&parent).unwrap(); - std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o710)).unwrap(); - let metadata = std::fs::symlink_metadata(&parent).unwrap(); - let different_user = Uid::from_raw(metadata.uid().wrapping_add(1)); - let different_group = Gid::from_raw(metadata.gid().wrapping_add(1)); - let supplementary_group = Gid::from_raw(metadata.gid()); - let root = parent.join("project"); - - prepare_oci_workspace_with( - &root, - Some(different_user), - Some(different_group), - &[supplementary_group], - &|_, _, _| Ok(()), - ) - .expect("supplementary group execute permission should allow traversal"); - - assert!(root.is_dir()); - } - - #[cfg(not(any( - target_os = "aix", - target_os = "haiku", - target_os = "illumos", - target_os = "ios", - target_os = "macos", - target_os = "redox", - target_os = "solaris" - )))] - #[test] - fn named_user_supplementary_groups_include_primary_group() { - let user = User::from_uid(nix::unistd::geteuid()) - .expect("resolve current UID") - .expect("current user exists"); - - let groups = named_user_supplementary_groups(&user.name, user.gid) - .expect("resolve named-user supplementary groups"); - - assert!(groups.contains(&user.gid)); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_rejects_non_directory_root() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().canonicalize().unwrap().join("sandbox"); - std::fs::write(&root, "not a directory").unwrap(); - - let error = prepare_oci_workspace( - &root, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - ) - .unwrap_err(); - assert!( - error.to_string().contains("is not a directory"), - "unexpected error: {error}" - ); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_propagates_root_chown_error() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().canonicalize().unwrap().join("sandbox"); - std::fs::create_dir(&root).unwrap(); - let fake_chown = |_path: &Path, _uid: Option, _gid: Option| -> nix::Result<()> { - Err(nix::errno::Errno::EROFS) - }; - - let error = prepare_oci_workspace_with( - &root, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - &fake_chown, - ) - .unwrap_err(); - - assert!( - error.to_string().contains("Read-only file system"), - "unexpected error: {error}" - ); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_creates_missing_root() { - use std::sync::{Arc, Mutex}; - - let dir = tempfile::tempdir().unwrap(); - let missing = dir - .path() - .canonicalize() - .unwrap() - .join("missing") - .join("sandbox"); - let chowned = Arc::new(Mutex::new(Vec::new())); - let observed = Arc::clone(&chowned); - let fake_chown = - move |path: &Path, _uid: Option, _gid: Option| -> nix::Result<()> { - observed.lock().unwrap().push(path.to_path_buf()); - Ok(()) - }; - - prepare_oci_workspace_with( - &missing, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - &fake_chown, - ) - .expect("missing OCI workspace should be created"); - - assert!(missing.is_dir()); - assert_eq!( - std::fs::symlink_metadata(missing.parent().unwrap()) - .unwrap() - .permissions() - .mode() - & 0o777, - 0o755 - ); - assert_eq!(*chowned.lock().unwrap(), vec![missing]); - } - - #[cfg(unix)] - #[test] - fn rewrite_passwd_modifies_existing_sandbox_entry() { - let dir = tempfile::tempdir().unwrap(); - let passwd = dir.path().join("passwd"); - std::fs::write( - &passwd, - "root:x:0:0:root:/root:/bin/bash\nsandbox:x:1000:1000::/sandbox:/bin/bash\n", - ) - .unwrap(); - - rewrite_passwd_at(&passwd, "5000", "6000").unwrap(); - - let content = std::fs::read_to_string(&passwd).unwrap(); - assert!(content.contains("sandbox:x:5000:6000::/sandbox:/bin/bash")); - assert!(content.contains("root:x:0:0:root:/root:/bin/bash")); - } - - #[cfg(unix)] - #[test] - fn rewrite_passwd_appends_when_no_sandbox_entry() { - let dir = tempfile::tempdir().unwrap(); - let passwd = dir.path().join("passwd"); - std::fs::write(&passwd, "root:x:0:0:root:/root:/bin/bash\n").unwrap(); - - rewrite_passwd_at(&passwd, "5000", "6000").unwrap(); - - let content = std::fs::read_to_string(&passwd).unwrap(); - assert!(content.contains("root:x:0:0:root:/root:/bin/bash")); - assert!(content.contains("sandbox:x:5000:6000::/sandbox:/bin/sh")); - } - - #[cfg(unix)] - #[test] - fn rewrite_group_modifies_existing_sandbox_entry() { - let dir = tempfile::tempdir().unwrap(); - let group = dir.path().join("group"); - std::fs::write(&group, "root:x:0:\nsandbox:x:1000:\n").unwrap(); - - rewrite_group_at(&group, "6000").unwrap(); - - let content = std::fs::read_to_string(&group).unwrap(); - assert!(content.contains("sandbox:x:6000:")); - assert!(content.contains("root:x:0:")); - } - - #[cfg(unix)] - #[test] - fn rewrite_group_appends_when_no_sandbox_entry() { - let dir = tempfile::tempdir().unwrap(); - let group = dir.path().join("group"); - std::fs::write(&group, "root:x:0:\n").unwrap(); - - rewrite_group_at(&group, "6000").unwrap(); - - let content = std::fs::read_to_string(&group).unwrap(); - assert!(content.contains("root:x:0:")); - assert!(content.contains("sandbox:x:6000:")); - } - - #[cfg(unix)] - #[test] - fn rewrite_passwd_leaves_malformed_entry_unchanged() { - let dir = tempfile::tempdir().unwrap(); - let passwd = dir.path().join("passwd"); - // Only 3 fields — slice pattern should fall through instead of panic. - std::fs::write(&passwd, "sandbox:x:1000\n").unwrap(); - rewrite_passwd_at(&passwd, "5000", "6000").unwrap(); - let content = std::fs::read_to_string(&passwd).unwrap(); - assert!(content.contains("sandbox:x:1000")); - } - - #[cfg(unix)] - #[test] - fn rewrite_group_leaves_malformed_entry_unchanged() { - let dir = tempfile::tempdir().unwrap(); - let group = dir.path().join("group"); - // Only 2 fields — slice pattern should fall through instead of panic. - std::fs::write(&group, "sandbox:x\n").unwrap(); - rewrite_group_at(&group, "6000").unwrap(); - let content = std::fs::read_to_string(&group).unwrap(); - assert!(content.contains("sandbox:x")); - } - - #[cfg(unix)] - #[test] - fn rewrite_passwd_preserves_other_entries() { - let dir = tempfile::tempdir().unwrap(); - let passwd = dir.path().join("passwd"); - std::fs::write( - &passwd, - "root:x:0:0:root:/root:/bin/bash\nnobody:x:65534:65534:nobody:/:/usr/sbin/nologin\nsandbox:x:1000:1000::/sandbox:/bin/bash\n", - ) - .unwrap(); - - rewrite_passwd_at(&passwd, "1234567", "1234567").unwrap(); - - let content = std::fs::read_to_string(&passwd).unwrap(); - assert!(content.contains("root:x:0:0:root:/root:/bin/bash")); - assert!(content.contains("nobody:x:65534:65534:nobody:/:/usr/sbin/nologin")); - assert!(content.contains("sandbox:x:1234567:1234567::/sandbox:/bin/bash")); - assert_eq!(content.lines().count(), 3); - } - #[tokio::test] async fn inject_provider_env_skips_supervisor_identity_material() { let mut cmd = Command::new("/usr/bin/env"); @@ -3789,122 +1602,4 @@ mod tests { } assert!(stdout.contains("PATH=/usr/bin:/bin")); } - - // ---- Numeric UID tests (Phase 2) ---- - - // Even a failing setuid(0) probe synchronizes libc credentials across all - // threads. Other tests own seccomp-notified launcher threads in this same - // process; signaling those while they await their broker can deadlock the - // parallel harness. Re-exec just the credential probe, without those threads. - fn numeric_uid_probe_runs_in_child(test_name: &str) -> bool { - const MARKER: &str = "OPENSHELL_TEST_ISOLATED_NUMERIC_UID_PROBE"; - if std::env::var(MARKER).as_deref() == Ok(test_name) { - return true; - } - let output = std::process::Command::new(std::env::current_exe().expect("test executable")) - .args(["--exact", test_name, "--test-threads=1", "--nocapture"]) - .env(MARKER, test_name) - .output() - .expect("run isolated credential probe"); - assert!( - output.status.success(), - "isolated credential probe failed: {}\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - false - } - - #[test] - fn drop_privileges_accepts_numeric_uid() { - if !numeric_uid_probe_runs_in_child("process::tests::drop_privileges_accepts_numeric_uid") { - return; - } - // When running as non-root, a numeric UID/GID that matches the - // current process should succeed without any passwd lookup. - if nix::unistd::geteuid().is_root() { - return; - } - - let uid_raw = nix::unistd::geteuid().as_raw(); - let gid_raw = nix::unistd::getegid().as_raw(); - - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some(uid_raw.to_string()), - run_as_group: Some(gid_raw.to_string()), - }); - - assert!( - drop_privileges(&policy).is_ok(), - "should accept current process UID/GID as numeric strings" - ); - } - - #[test] - fn drop_privileges_numeric_uid_skips_initgroups() { - if !numeric_uid_probe_runs_in_child( - "process::tests::drop_privileges_numeric_uid_skips_initgroups", - ) { - return; - } - // When running as non-root with a numeric user but group matches, - // initgroups should not be called (guard: target_uid != geteuid()). - if nix::unistd::geteuid().is_root() { - return; - } - - let current_uid = nix::unistd::geteuid().as_raw(); - - // Use a different group name that exists (the current one). - let current_group = Group::from_gid(nix::unistd::getegid()) - .expect("should resolve current group") - .expect("current group should exist"); - - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some(current_uid.to_string()), // numeric UID, no passwd entry needed - run_as_group: Some(current_group.name), // name-based group - }); - - assert!( - drop_privileges(&policy).is_ok(), - "should accept numeric UID with name-based group (initgroups guarded)" - ); - } - - #[test] - fn numeric_uid_privilege_drop_child() { - if std::env::var_os("OPENSHELL_TEST_NUMERIC_UID_CHILD").is_none() { - return; - } - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some("999999".into()), - run_as_group: Some("999999".into()), - }); - match drop_privileges(&policy) { - Ok(()) => {} - Err(e) => { - assert!( - !e.to_string().contains("Failed to resolve user record"), - "unexpected error for numeric UID without passwd entry: {e}" - ); - } - } - } - - #[test] - fn drop_privileges_numeric_uid_without_passwd_entry_skips_lookup() { - let mut cmd = std::process::Command::new(std::env::current_exe().expect("current exe")); - cmd.arg("numeric_uid_privilege_drop_child") - .arg("--nocapture") - .env("OPENSHELL_TEST_NUMERIC_UID_CHILD", "1") - .stdin(StdStdio::null()) - .stdout(StdStdio::piped()) - .stderr(StdStdio::piped()); - let output = cmd.output().expect("spawn child"); - assert!( - output.status.success(), - "numeric UID privilege drop child failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } } From 0124680f8edef50675e961a3377dcd021a1e320e Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 30 Sep 2026 12:50:06 -0700 Subject: [PATCH 2/2] chore(sandbox): remove unused capability dependency and refresh Landlock comments Signed-off-by: Matthew Grossman --- Cargo.lock | 66 +++++++------------ crates/openshell-sandbox/Cargo.toml | 1 - .../src/sandbox/linux/landlock.rs | 20 +++--- .../src/sandbox/linux/mod.rs | 9 +-- 4 files changed, 40 insertions(+), 56 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b5e21ec266..c6173fee09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -770,12 +770,6 @@ dependencies = [ "sha2 0.11.0", ] -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.13.2" @@ -923,17 +917,6 @@ dependencies = [ "libbz2-rs-sys", ] -[[package]] -name = "capctl" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a6e71767585f51c2a33fed6d67147ec0343725fc3c03bf4b89fe67fede56aa5" -dependencies = [ - "bitflags 1.3.2", - "cfg-if", - "libc", -] - [[package]] name = "cassowary" version = "0.3.0" @@ -1299,7 +1282,7 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" dependencies = [ - "bitflags 2.13.2", + "bitflags", "crossterm_winapi", "libc", "mio 0.8.11", @@ -1315,7 +1298,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.13.2", + "bitflags", "crossterm_winapi", "mio 1.2.0", "parking_lot", @@ -2912,7 +2895,7 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" dependencies = [ - "bitflags 2.13.2", + "bitflags", "inotify-sys", "libc", ] @@ -3247,7 +3230,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 2.13.2", + "bitflags", "libc", ] @@ -3421,7 +3404,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.13.2", + "bitflags", "libc", "plain", "redox_syscall 0.7.4", @@ -3700,7 +3683,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.13.2", + "bitflags", "cfg-if", "cfg_aliases", "libc", @@ -3712,7 +3695,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.13.2", + "bitflags", "cfg-if", "cfg_aliases", "libc", @@ -3734,7 +3717,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.13.2", + "bitflags", "fsevent-sys", "inotify", "kqueue", @@ -3752,7 +3735,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.13.2", + "bitflags", ] [[package]] @@ -4553,7 +4536,6 @@ dependencies = [ "async-trait", "base64 0.22.1", "bytes", - "capctl", "clap", "hex", "ipnet", @@ -5675,7 +5657,7 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" dependencies = [ - "bitflags 2.13.2", + "bitflags", "memchr", "unicase", ] @@ -5882,7 +5864,7 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" dependencies = [ - "bitflags 2.13.2", + "bitflags", "cassowary", "compact_str", "crossterm 0.27.0", @@ -5902,7 +5884,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.13.2", + "bitflags", ] [[package]] @@ -5925,7 +5907,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.2", + "bitflags", ] [[package]] @@ -5934,7 +5916,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ - "bitflags 2.13.2", + "bitflags", ] [[package]] @@ -6167,7 +6149,7 @@ checksum = "da7c230e0ed9cbeb92fbad6c8848985d6df2a1464c0dc247a021abd666e9005e" dependencies = [ "aes", "aws-lc-rs", - "bitflags 2.13.2", + "bitflags", "block-padding", "byteorder", "bytes", @@ -6249,7 +6231,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "093197e526668d92bba562e2bbbe98d1af9831bf080b619c736316ca1fa35101" dependencies = [ - "bitflags 2.13.2", + "bitflags", "bytes", "chrono", "dashmap", @@ -6317,7 +6299,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.13.2", + "bitflags", "errno", "libc", "linux-raw-sys 0.4.15", @@ -6330,7 +6312,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.2", + "bitflags", "errno", "libc", "linux-raw-sys 0.12.1", @@ -6557,7 +6539,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.2", + "bitflags", "core-foundation", "core-foundation-sys", "libc", @@ -7088,7 +7070,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" dependencies = [ - "bitflags 2.13.2", + "bitflags", "byteorder", "bytes", "crc", @@ -7116,7 +7098,7 @@ checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.13.2", + "bitflags", "byteorder", "crc", "dotenvy", @@ -7812,7 +7794,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "base64 0.22.1", - "bitflags 2.13.2", + "bitflags", "bytes", "futures-util", "http 1.4.0", @@ -8327,7 +8309,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.13.2", + "bitflags", "hashbrown 0.15.5", "indexmap", "semver", @@ -8911,7 +8893,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.13.2", + "bitflags", "indexmap", "log", "serde", diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index da8ddd45aa..50d91f0abf 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -73,7 +73,6 @@ russh-sftp = "3.0" uuid = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] -capctl = "0.2.4" landlock = "0.4" seccompiler = "0.5" socket2 = { workspace = true } diff --git a/crates/openshell-sandbox/src/sandbox/linux/landlock.rs b/crates/openshell-sandbox/src/sandbox/linux/landlock.rs index c18b633e75..486495c956 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/landlock.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/landlock.rs @@ -88,9 +88,10 @@ pub fn probe_availability() -> LandlockAvailability { /// A prepared Landlock ruleset ready to be enforced via `restrict_self()`. /// -/// Created by [`prepare`] while running as root (so `PathFd::new()` can open -/// any path regardless of DAC permissions). Enforced by [`enforce`] after -/// `drop_privileges()` — `restrict_self()` does not require elevated privileges. +/// Path FDs are opened before enforcement. The capability-free launch path +/// prepares the baseline and user rules as the workload identity, then calls +/// [`enforce`] in the child before exec. `restrict_self()` does not require +/// elevated privileges. pub struct PreparedRuleset { ruleset: landlock::RulesetCreated, compatibility: LandlockCompatibility, @@ -102,10 +103,11 @@ enum PathOpenMode { CurrentUser, } -/// Phase 1: Open `PathFds` and build the Landlock ruleset **as root**. +/// Phase 1: Open `PathFds` and build the Landlock ruleset with strict path opening. /// -/// This must run before `drop_privileges()` so that `PathFd::new()` can open -/// paths that are only accessible to root (e.g. mode 700 directories). +/// Opens configured paths as the calling identity. Inaccessible paths fail in +/// hard-requirement mode and are skipped in best-effort mode. Unlike +/// [`prepare_current_user`], this does not always omit inaccessible paths. /// /// Returns `None` if there are no filesystem paths to restrict (no-op). /// Returns `Some(PreparedRuleset)` on success, or an error. @@ -394,9 +396,9 @@ fn prepare_with_path_open_mode( /// Phase 2: Enforce a prepared Landlock ruleset by calling `restrict_self()`. /// -/// This runs **after** `drop_privileges()`. The `restrict_self()` syscall does -/// not require root — it only restricts the calling thread (and its future -/// children), which is always permitted. +/// The capability-free launch path calls this in the child before exec, already +/// running as the workload identity. `restrict_self()` does not require root; +/// it restricts the calling thread and its future children. /// /// Respects the same `best_effort` / `hard_requirement` compatibility as /// [`prepare`]: if `restrict_self()` fails and the policy is `best_effort`, diff --git a/crates/openshell-sandbox/src/sandbox/linux/mod.rs b/crates/openshell-sandbox/src/sandbox/linux/mod.rs index 3f0084450e..acfd46edc5 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/mod.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/mod.rs @@ -18,10 +18,11 @@ pub struct PreparedSandbox { policy: SandboxPolicy, } -/// Phase 1: Prepare sandbox restrictions **as root** (before `drop_privileges`). +/// Phase 1: Prepare sandbox restrictions with strict path opening. /// -/// Opens Landlock `PathFds` while the process still has root privileges, -/// ensuring paths like mode-700 directories are accessible. +/// Opens configured paths as the calling identity and handles failures according +/// to the policy's Landlock compatibility mode. +/// The capability-free launch path uses [`prepare_capability_free`] instead. pub fn prepare(policy: &SandboxPolicy, workdir: Option<&str>) -> Result { let landlock = landlock::prepare(policy, workdir)?; Ok(PreparedSandbox { @@ -66,7 +67,7 @@ pub fn prepare_capability_free( }) } -/// Phase 2: Enforce prepared sandbox restrictions (after `drop_privileges`). +/// Phase 2: Enforce prepared sandbox restrictions in the child before exec. /// /// Calls `restrict_self()` for Landlock and applies seccomp filters. /// Neither operation requires root privileges.