diff --git a/architecture/security-policy.md b/architecture/security-policy.md index ff285a6a11..6cdd7485b6 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -23,10 +23,20 @@ dynamic and can be hot-reloaded when the new policy validates successfully. Before applying Landlock, the supervisor enriches baseline filesystem paths that the runtime needs. Missing baseline paths are skipped so one absent runtime path -does not weaken the whole ruleset. When GPU devices are present, GPU baseline -enrichment adds existing GPU device nodes as read-write paths and promotes -`/proc` to read-write because CUDA workloads write thread metadata under -`/proc//task//comm`. +does not weaken the whole ruleset. When GPU devices are present without a CDI +context, GPU baseline enrichment adds existing GPU device nodes as read-write +paths. GPU sandboxes with CDI context use CDI-derived paths instead of the +hard-coded GPU baseline. Both paths promote `/proc` to read-write because CUDA +workloads write thread metadata under `/proc//task//comm`. + +GPU/CDI sandboxes can also carry a supervisor-only CDI context from the compute +driver. The supervisor resolves selected CDI IDs from mounted CDI specs and +adds derived device nodes, library mount destinations, and supplemental GIDs +before agent exec. CDI host paths are ignored for policy. Derived mount +destinations default to read-only; writable CDI single-file mounts require an +exact `filesystem_policy.read_write` opt-in, and writable CDI directory mounts +fail closed. CDI resolution errors are security-relevant startup failures and +emit OCSF findings. Landlock rules are tailored to the inode type reported by the already-opened path descriptor. Directories retain the requested directory and file rights; diff --git a/crates/openshell-core/src/cdi.rs b/crates/openshell-core/src/cdi.rs index b5fc406986..e07d2beef3 100644 --- a/crates/openshell-core/src/cdi.rs +++ b/crates/openshell-core/src/cdi.rs @@ -9,6 +9,12 @@ use serde::{Deserialize, Serialize}; pub const CDI_CONTEXT_VERSION: u32 = 1; +/// Absolute supervisor path for the CDI context file mounted by a compute driver. +pub const CDI_CONTEXT_PATH: &str = "/run/openshell/supervisor/cdi-context.json"; + +/// Base supervisor path under which compute drivers mount CDI specification directories. +pub const CDI_SPEC_DIR_BASE: &str = "/run/openshell/supervisor/cdi-specs"; + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CdiContext { pub version: u32, diff --git a/crates/openshell-core/src/policy.rs b/crates/openshell-core/src/policy.rs index 1645b9da44..30c19ca4dc 100644 --- a/crates/openshell-core/src/policy.rs +++ b/crates/openshell-core/src/policy.rs @@ -83,6 +83,12 @@ pub struct ProcessPolicy { /// Group name to run the sandboxed process as. pub run_as_group: Option, + + /// Linux supplemental groups to apply before dropping privileges. + /// + /// Runtime-specific inputs can use different terminology; CDI + /// `additionalGids` are converted into this process-level representation. + pub supplemental_groups: Vec, } #[derive(Debug, Clone, Default)] @@ -162,6 +168,7 @@ impl From for ProcessPolicy { } else { Some(proto.run_as_group) }, + supplemental_groups: Vec::new(), } } } diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 9394037c84..7f6edb4aa2 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -15,7 +15,9 @@ mod metadata_server; mod sidecar_control; use miette::{IntoDiagnostic, Result, WrapErr}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::future::Future; +use std::path::PathBuf; use std::sync::Arc; #[cfg(target_os = "linux")] use std::sync::atomic::Ordering; @@ -502,7 +504,7 @@ pub async fn run_sandbox( retained_proto.clone(), openshell_endpoint.clone(), sandbox_id.clone(), - std::path::PathBuf::from(trusted_ssh_socket_path), + PathBuf::from(trusted_ssh_socket_path), ); } @@ -869,11 +871,11 @@ fn process_enforcement_mode() -> ProcessEnforcementMode { } } -fn sidecar_control_socket() -> Option { +fn sidecar_control_socket() -> Option { std::env::var(openshell_core::sandbox_env::SIDECAR_CONTROL_SOCKET) .ok() .filter(|path| !path.is_empty()) - .map(std::path::PathBuf::from) + .map(PathBuf::from) } #[cfg_attr(not(target_os = "linux"), allow(dead_code))] @@ -985,7 +987,7 @@ fn spawn_sidecar_entrypoint_handler( retained_proto: Option, openshell_endpoint: Option, sandbox_id: Option, - trusted_ssh_socket_path: std::path::PathBuf, + trusted_ssh_socket_path: PathBuf, ) { tokio::spawn(async move { let mut session_started = false; @@ -1049,7 +1051,7 @@ fn spawn_sidecar_entrypoint_handler( }); } -fn sidecar_ca_file_paths() -> Option<(std::path::PathBuf, std::path::PathBuf)> { +fn sidecar_ca_file_paths() -> Option<(PathBuf, PathBuf)> { let tls_dir = std::env::var(openshell_core::sandbox_env::PROXY_TLS_DIR) .unwrap_or_else(|_| SIDECAR_TLS_DIR.to_string()); let cert = std::path::Path::new(&tls_dir).join(SIDECAR_CA_CERT); @@ -1217,42 +1219,33 @@ const PROXY_BASELINE_READ_ONLY: &[&str] = &[ /// The active workspace is granted separately through `include_workdir`. const PROXY_BASELINE_READ_WRITE: &[&str] = &["/tmp"]; -/// GPU read-only paths. +/// GPU read-only paths for the legacy device-scan fallback. /// /// `/run/nvidia-persistenced`: NVML tries to connect to the persistenced /// socket at init time. If the directory exists but Landlock denies traversal /// (EACCES vs ECONNREFUSED), NVML returns `NVML_ERROR_INSUFFICIENT_PERMISSIONS` /// even though the daemon is optional. Only read/traversal access is needed. /// -/// `/usr/lib/wsl`: On WSL2, CDI bind-mounts GPU libraries (libdxcore.so, -/// libcuda.so.1.1, etc.) into paths under `/usr/lib/wsl/`. Although `/usr` -/// is already in `PROXY_BASELINE_READ_ONLY`, individual file bind-mounts may -/// not be covered by the parent-directory Landlock rule when the mount crosses -/// a filesystem boundary. Listing `/usr/lib/wsl` explicitly ensures traversal -/// is permitted regardless of Landlock's cross-mount behaviour. +/// `/usr/lib/wsl`: retained for the legacy device-scan fallback. CDI +/// sandboxes use resolved mount destinations instead of this broad directory +/// baseline. const GPU_BASELINE_READ_ONLY: &[&str] = &[ "/run/nvidia-persistenced", - "/usr/lib/wsl", // WSL2: CDI-injected GPU library directory + "/usr/lib/wsl", // Legacy fallback; CDI uses resolved mount destinations. ]; -/// GPU read-write paths (static). +/// GPU read-write paths for the legacy device-scan fallback. /// /// `/dev/nvidiactl`, `/dev/nvidia-uvm`, `/dev/nvidia-uvm-tools`, -/// `/dev/nvidia-modeset`: control and UVM devices injected by CDI on native -/// Linux. Landlock restricts `open(2)` on device files even when DAC allows -/// it; these need read-write because NVML/CUDA opens them with `O_RDWR`. -/// These devices do not exist on WSL2 and will be skipped by the existence -/// check in `enrich_proto_baseline_paths()`. +/// `/dev/nvidia-modeset`: control and UVM devices. Landlock restricts +/// `open(2)` on device files even when DAC allows it; these need read-write +/// because NVML/CUDA opens them with `O_RDWR`. CDI sandboxes derive device +/// nodes from the selected CDI specs instead of this hard-coded list. /// /// `/dev/dxg`: On WSL2, NVIDIA GPUs are exposed through the DXG kernel driver -/// (DirectX Graphics) rather than the native nvidia* devices. CDI injects -/// `/dev/dxg` as the sole GPU device node; it does not exist on native Linux -/// and will be skipped there by the existence check. -/// -/// `/proc`: CUDA writes to `/proc//task//comm` during `cuInit()` -/// to set thread names. Without write access, `cuInit()` returns error 304. -/// Must use `/proc` (not `/proc/self/task`) because Landlock rules bind to -/// inodes and child processes have different procfs inodes than the parent. +/// (DirectX Graphics) rather than the native nvidia* devices. This is retained +/// for the legacy device-scan fallback; CDI sandboxes derive it from specs when +/// needed. /// /// Per-GPU device files (`/dev/nvidia0`, …) are enumerated at runtime by /// `enumerate_gpu_device_nodes()` since the count varies. @@ -1261,190 +1254,533 @@ const GPU_BASELINE_READ_WRITE: &[&str] = &[ "/dev/nvidia-uvm", "/dev/nvidia-uvm-tools", "/dev/nvidia-modeset", - "/dev/dxg", // WSL2: DXG device (GPU via DirectX kernel driver, injected by CDI) - "/proc", + "/dev/dxg", // WSL2: DXG device exposed through the DirectX kernel driver. ]; -/// Returns true if GPU devices are present in the container. -/// -/// Checks both the native Linux NVIDIA control device (`/dev/nvidiactl`) and -/// the WSL2 DXG device (`/dev/dxg`). CDI injects exactly one of these -/// depending on the host kernel; the other will not exist. -fn has_gpu_devices() -> bool { - std::path::Path::new("/dev/nvidiactl").exists() || std::path::Path::new("/dev/dxg").exists() +/// CUDA writes to `/proc//task//comm` during `cuInit()` to set thread +/// names. Without write access, `cuInit()` returns error 304. Must use `/proc` +/// (not `/proc/self/task`) because Landlock rules bind to inodes and child +/// processes have different procfs inodes than the parent. +const GPU_PROC_READ_WRITE: &str = "/proc"; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct EnrichmentPathSources { + baseline: bool, + runtime: bool, } -/// Enumerate per-GPU device nodes (`/dev/nvidia0`, `/dev/nvidia1`, …). -fn enumerate_gpu_device_nodes() -> Vec { - let mut paths = Vec::new(); - if let Ok(entries) = std::fs::read_dir("/dev") { - for entry in entries.flatten() { - let name = entry.file_name(); - let name = name.to_string_lossy(); - if let Some(suffix) = name.strip_prefix("nvidia") { - if suffix.is_empty() || !suffix.chars().all(|c| c.is_ascii_digit()) { - continue; - } - paths.push(entry.path().to_string_lossy().into_owned()); - } +impl EnrichmentPathSources { + fn baseline() -> Self { + Self { + baseline: true, + runtime: false, } } - paths + + fn runtime() -> Self { + Self { + baseline: false, + runtime: true, + } + } + + fn merge(&mut self, other: Self) { + self.baseline |= other.baseline; + self.runtime |= other.runtime; + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MissingPathBehavior { + Skip, + Error, } -fn push_unique(paths: &mut Vec, path: String) { - if !paths.iter().any(|p| p == &path) { - paths.push(path); +impl MissingPathBehavior { + fn merge(self, other: Self) -> Self { + match (self, other) { + (Self::Error, _) | (_, Self::Error) => Self::Error, + (Self::Skip, Self::Skip) => Self::Skip, + } } } -fn collect_baseline_enrichment_paths( - include_proxy: bool, - include_gpu: bool, - gpu_device_nodes: Vec, -) -> (Vec, Vec) { - let mut ro = Vec::new(); - let mut rw = Vec::new(); - - if include_proxy { - for &path in PROXY_BASELINE_READ_ONLY { - push_unique(&mut ro, path.to_string()); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReadOnlyConflictBehavior { + KeepReadOnly, + PromoteToReadWrite, + Reject, +} + +impl ReadOnlyConflictBehavior { + fn merge(self, other: Self) -> Self { + match (self, other) { + (Self::Reject, _) | (_, Self::Reject) => Self::Reject, + (Self::PromoteToReadWrite, _) | (_, Self::PromoteToReadWrite) => { + Self::PromoteToReadWrite + } + (Self::KeepReadOnly, Self::KeepReadOnly) => Self::KeepReadOnly, } - for &path in PROXY_BASELINE_READ_WRITE { - push_unique(&mut rw, path.to_string()); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct EnrichmentPathPolicy { + sources: EnrichmentPathSources, + missing_path: MissingPathBehavior, + read_only_conflict: ReadOnlyConflictBehavior, +} + +impl EnrichmentPathPolicy { + fn baseline() -> Self { + Self { + sources: EnrichmentPathSources::baseline(), + missing_path: MissingPathBehavior::Skip, + read_only_conflict: ReadOnlyConflictBehavior::KeepReadOnly, } } - if include_gpu { - for &path in GPU_BASELINE_READ_ONLY { - push_unique(&mut ro, path.to_string()); + fn runtime_device_node() -> Self { + Self { + sources: EnrichmentPathSources::runtime(), + missing_path: MissingPathBehavior::Error, + read_only_conflict: ReadOnlyConflictBehavior::KeepReadOnly, } - for &path in GPU_BASELINE_READ_WRITE { - push_unique(&mut rw, path.to_string()); + } + + fn runtime_mount() -> Self { + Self { + sources: EnrichmentPathSources::runtime(), + missing_path: MissingPathBehavior::Error, + read_only_conflict: ReadOnlyConflictBehavior::KeepReadOnly, } - for path in gpu_device_nodes { - push_unique(&mut rw, path); + } + + fn runtime_writable_mount() -> Self { + Self { + sources: EnrichmentPathSources::runtime(), + missing_path: MissingPathBehavior::Error, + read_only_conflict: ReadOnlyConflictBehavior::Reject, } } - // A path promoted to read_write (e.g. /proc for GPU) should not also - // appear in read_only — Landlock handles the overlap correctly but the - // duplicate is confusing when inspecting the effective policy. - ro.retain(|p| !rw.contains(p)); + fn gpu_proc(sources: EnrichmentPathSources) -> Self { + Self { + sources, + missing_path: MissingPathBehavior::Error, + read_only_conflict: ReadOnlyConflictBehavior::PromoteToReadWrite, + } + } - (ro, rw) + fn merge(&mut self, other: Self) { + self.sources.merge(other.sources); + self.missing_path = self.missing_path.merge(other.missing_path); + self.read_only_conflict = self.read_only_conflict.merge(other.read_only_conflict); + } } -fn active_baseline_enrichment_paths(include_proxy: bool) -> (Vec, Vec) { - let include_gpu = has_gpu_devices(); - let gpu_device_nodes = if include_gpu { - enumerate_gpu_device_nodes() - } else { - Vec::new() - }; - collect_baseline_enrichment_paths(include_proxy, include_gpu, gpu_device_nodes) +#[derive(Debug, Clone, PartialEq, Eq)] +struct EnrichmentPath { + path: String, + policy: EnrichmentPathPolicy, } -/// Collect all active baseline paths for tests and diagnostics. -/// Returns `(read_only, read_write)` as owned `String` vecs. -#[cfg(test)] -fn baseline_enrichment_paths() -> (Vec, Vec) { - active_baseline_enrichment_paths(true) +impl EnrichmentPath { + fn should_apply(&self, access: &str, path_exists: &F) -> Result + where + F: Fn(&str) -> bool, + { + if path_exists(&self.path) { + return Ok(true); + } + + match self.policy.missing_path { + MissingPathBehavior::Skip => { + debug!( + path = %self.path, + access, + "Baseline enrichment path does not exist, skipping" + ); + Ok(false) + } + MissingPathBehavior::Error => Err(miette::miette!( + "Runtime-derived enrichment path '{}' does not exist", + self.path + )), + } + } } -fn enrich_proto_baseline_paths_with( - proto: &mut openshell_core::proto::SandboxPolicy, - ro: &[String], - rw: &[String], - path_exists: F, -) -> bool -where - F: Fn(&str) -> bool, -{ - if ro.is_empty() && rw.is_empty() { - return false; +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct EnrichmentPlan { + read_only: BTreeMap, + read_write: BTreeMap, + additional_gids: BTreeSet, +} + +impl EnrichmentPlan { + fn for_proto_policy( + proto: &openshell_core::proto::SandboxPolicy, + cdi_requirements: Option<&openshell_core::cdi::CdiDerivedRequirements>, + ) -> Self { + Self::active(!proto.network_policies.is_empty(), cdi_requirements) + } + + fn for_sandbox_policy( + policy: &SandboxPolicy, + cdi_requirements: Option<&openshell_core::cdi::CdiDerivedRequirements>, + ) -> Self { + Self::active( + matches!(policy.network.mode, NetworkMode::Proxy), + cdi_requirements, + ) } - let fs = proto - .filesystem - .get_or_insert_with(|| openshell_core::proto::FilesystemPolicy { - include_workdir: true, - ..Default::default() - }); + fn active( + include_proxy: bool, + cdi_requirements: Option<&openshell_core::cdi::CdiDerivedRequirements>, + ) -> Self { + let mut plan = Self::default(); + if include_proxy { + plan = plan.merge(Self::proxy_baseline()); + } + let gpu_plan = cdi_requirements.map_or_else( + || { + if has_gpu_devices() { + Self::legacy_gpu_fallback(enumerate_gpu_device_nodes()) + } else { + Self::default() + } + }, + Self::cdi_gpu, + ); + plan.merge(gpu_plan) + } - let mut modified = false; - for path in ro { - if !fs.read_only.iter().any(|p| p == path) && !fs.read_write.iter().any(|p| p == path) { - if !path_exists(path) { - debug!( - path, - "Baseline read-only path does not exist, skipping enrichment" - ); + fn proxy_baseline() -> Self { + Self::from_baseline_paths(PROXY_BASELINE_READ_ONLY, PROXY_BASELINE_READ_WRITE) + } + + fn legacy_gpu_fallback(gpu_device_nodes: Vec) -> Self { + let mut plan = Self::from_baseline_paths(GPU_BASELINE_READ_ONLY, GPU_BASELINE_READ_WRITE); + for path in gpu_device_nodes { + plan.insert_read_write_path(path, EnrichmentPathPolicy::baseline()); + } + plan.insert_read_write_path( + GPU_PROC_READ_WRITE, + EnrichmentPathPolicy::gpu_proc(EnrichmentPathSources::baseline()), + ); + plan + } + + fn cdi_gpu(requirements: &openshell_core::cdi::CdiDerivedRequirements) -> Self { + let mut plan = Self::default(); + for path in &requirements.read_only_mount_paths { + plan.insert_read_only_path(path, EnrichmentPathPolicy::runtime_mount()); + } + for path in &requirements.device_node_paths { + plan.insert_read_write_path(path, EnrichmentPathPolicy::runtime_device_node()); + } + for path in &requirements.read_write_mount_paths { + plan.insert_read_write_path(path, EnrichmentPathPolicy::runtime_writable_mount()); + } + plan.insert_read_write_path( + GPU_PROC_READ_WRITE, + EnrichmentPathPolicy::gpu_proc(EnrichmentPathSources::runtime()), + ); + for gid in &requirements.additional_gids { + plan.insert_additional_gid(*gid); + } + plan + } + + fn from_baseline_paths(read_only: &[&str], read_write: &[&str]) -> Self { + let mut plan = Self::default(); + for &path in read_only { + plan.insert_read_only_path(path, EnrichmentPathPolicy::baseline()); + } + for &path in read_write { + plan.insert_read_write_path(path, EnrichmentPathPolicy::baseline()); + } + plan + } + + fn merge(mut self, other: Self) -> Self { + for (path, policy) in other.read_only { + self.insert_read_only_path(path, policy); + } + for (path, policy) in other.read_write { + self.insert_read_write_path(path, policy); + } + for gid in other.additional_gids { + self.insert_additional_gid(gid); + } + self + } + + fn insert_read_only_path(&mut self, path: impl Into, policy: EnrichmentPathPolicy) { + insert_enrichment_path(&mut self.read_only, path.into(), policy); + } + + fn insert_read_write_path(&mut self, path: impl Into, policy: EnrichmentPathPolicy) { + insert_enrichment_path(&mut self.read_write, path.into(), policy); + } + + fn insert_additional_gid(&mut self, gid: u32) { + self.additional_gids.insert(gid); + } + + fn entries(&self) -> (Vec, Vec) { + let mut read_write = self.read_write.clone(); + let mut read_only = Vec::new(); + + // A path promoted to read_write (e.g. /proc for GPU) should not also + // appear in read_only — Landlock handles the overlap correctly but the + // duplicate is confusing when inspecting the effective policy. + for (path, policy) in &self.read_only { + if let Some(read_write_policy) = read_write.get_mut(path) { + read_write_policy.merge(*policy); + } else { + read_only.push(EnrichmentPath { + path: path.clone(), + policy: *policy, + }); + } + } + + let read_write = read_write + .into_iter() + .map(|(path, policy)| EnrichmentPath { path, policy }) + .collect(); + (read_only, read_write) + } + + fn has_additional_gids(&self) -> bool { + !self.additional_gids.is_empty() + } + + fn additional_gids(&self) -> Vec { + self.additional_gids.iter().copied().collect() + } + + fn apply_to_proto_policy( + &self, + proto: &mut openshell_core::proto::SandboxPolicy, + ) -> Result { + // Baseline paths are system-injected, not user-specified. Skip paths + // that do not exist in this container image to avoid noisy warnings + // from Landlock and, more critically, to prevent a single missing + // baseline path from abandoning the entire Landlock ruleset under + // best-effort mode (see issue #664). + self.apply_to_proto_policy_with(proto, |path| std::path::Path::new(path).exists()) + } + + fn apply_to_proto_policy_with( + &self, + proto: &mut openshell_core::proto::SandboxPolicy, + path_exists: F, + ) -> Result + where + F: Fn(&str) -> bool, + { + let (read_only, read_write) = self.entries(); + if read_only.is_empty() && read_write.is_empty() { + return Ok(EnrichmentApplication::default()); + } + + let fs = proto + .filesystem + .get_or_insert_with(|| openshell_core::proto::FilesystemPolicy { + include_workdir: true, + ..Default::default() + }); + + let mut application = EnrichmentApplication::default(); + for addition in read_only { + if !addition.should_apply("read_only", &path_exists)? { continue; } - fs.read_only.push(path.clone()); - modified = true; + if !fs.read_only.iter().any(|p| p == &addition.path) + && !fs.read_write.iter().any(|p| p == &addition.path) + { + fs.read_only.push(addition.path); + application.record(addition.policy.sources); + } } + for addition in read_write { + if !addition.should_apply("read_write", &path_exists)? { + continue; + } + if fs.read_write.iter().any(|p| p == &addition.path) { + continue; + } + if fs.read_only.iter().any(|p| p == &addition.path) { + match addition.policy.read_only_conflict { + ReadOnlyConflictBehavior::KeepReadOnly => {} + ReadOnlyConflictBehavior::PromoteToReadWrite => { + info!( + path = %addition.path, + "Promoting /proc from read-only to read-write for GPU runtime compatibility" + ); + fs.read_only.retain(|p| p != &addition.path); + fs.read_write.push(addition.path); + application.record(addition.policy.sources); + } + ReadOnlyConflictBehavior::Reject => { + return Err(miette::miette!( + "Runtime-derived read-write path '{}' conflicts with sandbox policy read_only", + addition.path + )); + } + } + continue; + } + fs.read_write.push(addition.path); + application.record(addition.policy.sources); + } + + Ok(application) } - for path in rw { - if fs.read_write.iter().any(|p| p == path) { - continue; + + fn apply_to_sandbox_policy(&self, policy: &mut SandboxPolicy) -> Result { + let (read_only, read_write) = self.entries(); + let mut application = EnrichmentApplication::default(); + + for addition in read_only { + if !addition.should_apply("read_only", &|path| std::path::Path::new(path).exists())? { + continue; + } + let p = PathBuf::from(&addition.path); + if !policy.filesystem.read_only.contains(&p) + && !policy.filesystem.read_write.contains(&p) + { + policy.filesystem.read_only.push(p); + application.record(addition.policy.sources); + } } - if !path_exists(path) { - debug!( - path, - "Baseline read-write path does not exist, skipping enrichment" - ); - continue; + for addition in read_write { + if !addition.should_apply("read_write", &|path| std::path::Path::new(path).exists())? { + continue; + } + let p = PathBuf::from(&addition.path); + if policy.filesystem.read_write.contains(&p) { + continue; + } + if policy.filesystem.read_only.contains(&p) { + match addition.policy.read_only_conflict { + ReadOnlyConflictBehavior::KeepReadOnly => {} + ReadOnlyConflictBehavior::PromoteToReadWrite => { + info!( + path = %addition.path, + "Promoting /proc from read-only to read-write for GPU runtime compatibility" + ); + policy + .filesystem + .read_only + .retain(|existing| existing != &p); + policy.filesystem.read_write.push(p); + application.record(addition.policy.sources); + } + ReadOnlyConflictBehavior::Reject => { + return Err(miette::miette!( + "Runtime-derived read-write path '{}' conflicts with sandbox policy read_only", + addition.path + )); + } + } + continue; + } + policy.filesystem.read_write.push(p); + application.record(addition.policy.sources); } - if fs.read_only.iter().any(|p| p == path) { - if path == "/proc" { - info!( - path, - "Promoting /proc from read-only to read-write for GPU runtime compatibility" - ); - fs.read_only.retain(|p| p != path); - fs.read_write.push(path.clone()); - modified = true; + + if self.has_additional_gids() { + let additional_gids = self.additional_gids(); + if policy.process.supplemental_groups != additional_gids { + // CDI calls these `additionalGids`; the supervisor applies them + // as Linux supplemental groups before dropping privileges. + policy.process.supplemental_groups = additional_gids; + application.record(EnrichmentPathSources::runtime()); } - continue; } - fs.read_write.push(path.clone()); - modified = true; + + Ok(application) } - modified + #[cfg(test)] + fn paths(&self) -> (Vec, Vec) { + let (read_only, read_write) = self.entries(); + ( + read_only.into_iter().map(|path| path.path).collect(), + read_write.into_iter().map(|path| path.path).collect(), + ) + } } -/// Ensure a proto `SandboxPolicy` includes the baseline filesystem paths -/// required by proxy-mode sandboxes and GPU runtimes. Paths are only added if -/// missing; user-specified paths are never removed. +/// Returns true if GPU devices are present in the container. /// -/// Returns `true` if the policy was modified (caller may want to sync back). -fn enrich_proto_baseline_paths(proto: &mut openshell_core::proto::SandboxPolicy) -> bool { - let (ro, rw) = active_baseline_enrichment_paths(!proto.network_policies.is_empty()); - - // Baseline paths are system-injected, not user-specified. Skip paths - // that do not exist in this container image to avoid noisy warnings from - // Landlock and, more critically, to prevent a single missing baseline - // path from abandoning the entire Landlock ruleset under best-effort - // mode (see issue #664). - let modified = enrich_proto_baseline_paths_with(proto, &ro, &rw, |path| { - std::path::Path::new(path).exists() - }); +/// Checks both the native Linux NVIDIA control device (`/dev/nvidiactl`) and +/// the WSL2 DXG device (`/dev/dxg`) for the legacy fallback path. +fn has_gpu_devices() -> bool { + std::path::Path::new("/dev/nvidiactl").exists() || std::path::Path::new("/dev/dxg").exists() +} - if modified { - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::Informational) - .status(StatusId::Success) - .state(StateId::Enabled, "enriched") - .message("Enriched policy with baseline filesystem paths for proxy mode") - .build() - ); +/// Enumerate per-GPU device nodes (`/dev/nvidia0`, `/dev/nvidia1`, …). +fn enumerate_gpu_device_nodes() -> Vec { + let mut paths = Vec::new(); + if let Ok(entries) = std::fs::read_dir("/dev") { + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if let Some(suffix) = name.strip_prefix("nvidia") { + if suffix.is_empty() || !suffix.chars().all(|c| c.is_ascii_digit()) { + continue; + } + paths.push(entry.path().to_string_lossy().into_owned()); + } + } + } + paths +} + +fn insert_enrichment_path( + paths: &mut BTreeMap, + path: String, + policy: EnrichmentPathPolicy, +) { + if let Some(existing) = paths.get_mut(&path) { + existing.merge(policy); + } else { + paths.insert(path, policy); + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct EnrichmentApplication { + baseline_modified: bool, + runtime_modified: bool, +} + +impl EnrichmentApplication { + fn modified(self) -> bool { + self.baseline_modified || self.runtime_modified } - modified + fn record(&mut self, sources: EnrichmentPathSources) { + if sources.baseline { + self.baseline_modified = true; + } + if sources.runtime { + self.runtime_modified = true; + } + } +} + +/// Collect all active baseline paths for tests and diagnostics. +/// Returns `(read_only, read_write)` as owned `String` vecs. +#[cfg(test)] +fn baseline_enrichment_paths() -> (Vec, Vec) { + EnrichmentPlan::active(true, None).paths() } fn strip_proto_provider_policy_entries(proto: &mut openshell_core::proto::SandboxPolicy) -> bool { @@ -1464,59 +1800,315 @@ fn proto_sync_payload_for_enriched_policy( Some(sync_policy) } -/// Ensure a `SandboxPolicy` (Rust type) includes the baseline filesystem -/// paths required by proxy-mode sandboxes and GPU runtimes. Used for the -/// local-file code path where no proto is available. -fn enrich_sandbox_baseline_paths(policy: &mut SandboxPolicy) { - let (ro, rw) = - active_baseline_enrichment_paths(matches!(policy.network.mode, NetworkMode::Proxy)); - if ro.is_empty() && rw.is_empty() { - return; +fn cdi_writable_file_allowlist_from_proto( + proto: &openshell_core::proto::SandboxPolicy, +) -> HashSet { + proto + .filesystem + .as_ref() + .map(|fs| fs.read_write.iter().cloned().collect()) + .unwrap_or_default() +} + +fn cdi_writable_file_allowlist_from_policy(policy: &SandboxPolicy) -> HashSet { + policy + .filesystem + .read_write + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect() +} + +struct CdiPolicyEnrichment { + enriched: bool, + additional_gids: Vec, +} + +fn enrich_sandbox_policy_with_baseline_and_cdi(policy: &mut SandboxPolicy) -> Result<()> { + let cdi_writable_file_allowlist = cdi_writable_file_allowlist_from_policy(policy); + let requirements = resolve_cdi_requirements_from_env(&cdi_writable_file_allowlist)?; + let plan = EnrichmentPlan::for_sandbox_policy(policy, requirements.as_ref()); + let application = plan.apply_to_sandbox_policy(policy)?; + emit_policy_enrichment_events(application, requirements.as_ref(), &plan); + Ok(()) +} + +fn enrich_proto_policy_with_baseline_and_cdi( + proto: &mut openshell_core::proto::SandboxPolicy, +) -> Result { + let cdi_writable_file_allowlist = cdi_writable_file_allowlist_from_proto(proto); + let requirements = resolve_cdi_requirements_from_env(&cdi_writable_file_allowlist)?; + let plan = EnrichmentPlan::for_proto_policy(proto, requirements.as_ref()); + let application = plan.apply_to_proto_policy(proto)?; + emit_policy_enrichment_events(application, requirements.as_ref(), &plan); + + Ok(CdiPolicyEnrichment { + enriched: application.modified(), + additional_gids: plan.additional_gids(), + }) +} + +fn resolve_cdi_requirements_from_env( + writable_file_allowlist: &HashSet, +) -> Result> { + let Ok(context_path) = std::env::var(openshell_core::sandbox_env::CDI_CONTEXT) else { + return Ok(None); + }; + let context_path = context_path.trim(); + if context_path.is_empty() { + return Ok(None); + } + if context_path != openshell_core::cdi::CDI_CONTEXT_PATH { + let message = format!( + "CDI context path must be '{}', got '{context_path}'", + openshell_core::cdi::CDI_CONTEXT_PATH + ); + emit_cdi_validation_failure(&message); + return Err(miette::miette!("{message}")); } - let mut modified = false; - for path in &ro { - let p = std::path::PathBuf::from(path); - if !policy.filesystem.read_only.contains(&p) && !policy.filesystem.read_write.contains(&p) { - if !p.exists() { - debug!( - path, - "Baseline read-only path does not exist, skipping enrichment" - ); - continue; - } - policy.filesystem.read_only.push(p); - modified = true; + let context = openshell_core::cdi::read_context(context_path).map_err(|err| { + emit_cdi_validation_failure(&err.to_string()); + miette::miette!("Failed to load CDI context from {context_path}: {err}") + })?; + validate_cdi_context_projection(&context)?; + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Enabled, "loaded") + .unmapped( + "cdi_selected_device_count", + serde_json::json!(context.selected_devices.len()) + ) + .message(format!("Loaded CDI context [path:{context_path}]")) + .build() + ); + + let requirements = openshell_core::cdi::resolve_cdi_context(&context, writable_file_allowlist) + .map_err(|err| { + emit_cdi_validation_failure(&err.to_string()); + miette::miette!("Failed to resolve CDI requirements: {err}") + })?; + Ok(Some(requirements)) +} + +fn validate_cdi_context_projection(context: &openshell_core::cdi::CdiContext) -> Result<()> { + if !context + .selected_devices + .iter() + .any(|device| !device.trim().is_empty()) + { + let message = "CDI context must select at least one device"; + emit_cdi_validation_failure(message); + return Err(miette::miette!(message)); + } + if context.spec_dirs.is_empty() { + let message = "CDI context must list at least one spec directory"; + emit_cdi_validation_failure(message); + return Err(miette::miette!(message)); + } + let expected_prefix = format!("{}/", openshell_core::cdi::CDI_SPEC_DIR_BASE); + for (index, spec_dir) in context.spec_dirs.iter().enumerate() { + let normalized = openshell_core::paths::normalize_path(&spec_dir.path); + if !normalized.starts_with(&expected_prefix) { + let message = format!( + "CDI spec dir must be under '{}', got '{}'", + openshell_core::cdi::CDI_SPEC_DIR_BASE, + spec_dir.path + ); + emit_cdi_validation_failure(&message); + return Err(miette::miette!("{message}")); + } + let expected_path = cdi_spec_mount_path(index); + if spec_dir.path != expected_path { + let message = format!( + "CDI spec dir at index {index} must be '{expected_path}', got '{}'", + spec_dir.path + ); + emit_cdi_validation_failure(&message); + return Err(miette::miette!("{message}")); } } - for path in &rw { - let p = std::path::PathBuf::from(path); - if policy.filesystem.read_only.contains(&p) || policy.filesystem.read_write.contains(&p) { - continue; + validate_cdi_projection_mounts(context) +} + +#[cfg(target_os = "linux")] +fn validate_cdi_projection_mounts(context: &openshell_core::cdi::CdiContext) -> Result<()> { + let mounts = read_mount_info()?; + validate_cdi_projection_mounts_with(context, &mounts) +} + +#[cfg(target_os = "linux")] +fn validate_cdi_projection_mounts_with( + context: &openshell_core::cdi::CdiContext, + mounts: &[MountInfoEntry], +) -> Result<()> { + let mut expected_mounts = Vec::with_capacity(context.spec_dirs.len() + 1); + expected_mounts.push(openshell_core::cdi::CDI_CONTEXT_PATH); + expected_mounts.extend( + context + .spec_dirs + .iter() + .map(|spec_dir| spec_dir.path.as_str()), + ); + + for expected_path in expected_mounts { + let Some(mount) = mounts + .iter() + .find(|mount| mount.mount_point == expected_path) + else { + let message = format!("CDI projection path '{expected_path}' is not a mount"); + emit_cdi_validation_failure(&message); + return Err(miette::miette!("{message}")); + }; + if !mount.read_only { + let message = format!("CDI projection mount '{expected_path}' must be read-only"); + emit_cdi_validation_failure(&message); + return Err(miette::miette!("{message}")); } - if !p.exists() { - debug!( - path, - "Baseline read-write path does not exist, skipping enrichment" - ); + } + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +#[expect( + clippy::unnecessary_wraps, + reason = "matches the Linux implementation so shared CDI validation can propagate errors uniformly" +)] +fn validate_cdi_projection_mounts(_context: &openshell_core::cdi::CdiContext) -> Result<()> { + Ok(()) +} + +fn cdi_spec_mount_path(index: usize) -> String { + format!("{}/{index}", openshell_core::cdi::CDI_SPEC_DIR_BASE) +} + +#[cfg(target_os = "linux")] +#[derive(Debug, PartialEq, Eq)] +struct MountInfoEntry { + mount_point: String, + read_only: bool, +} + +#[cfg(target_os = "linux")] +fn read_mount_info() -> Result> { + let mount_info = std::fs::read_to_string("/proc/self/mountinfo") + .into_diagnostic() + .wrap_err("read /proc/self/mountinfo")?; + mount_info.lines().map(parse_mount_info_entry).collect() +} + +#[cfg(target_os = "linux")] +fn parse_mount_info_entry(line: &str) -> Result { + let fields = line.split_whitespace().collect::>(); + let mount_point = fields + .get(4) + .ok_or_else(|| miette::miette!("mountinfo entry is missing a mount point: {line}"))?; + let mount_options = fields + .get(5) + .ok_or_else(|| miette::miette!("mountinfo entry is missing mount options: {line}"))?; + Ok(MountInfoEntry { + mount_point: unescape_mount_info_path(mount_point)?, + read_only: mount_options.split(',').any(|option| option == "ro"), + }) +} + +#[cfg(target_os = "linux")] +fn unescape_mount_info_path(path: &str) -> Result { + let mut decoded = Vec::with_capacity(path.len()); + let bytes = path.as_bytes(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] != b'\\' { + decoded.push(bytes[index]); + index += 1; continue; } - policy.filesystem.read_write.push(p); - modified = true; + let octal = bytes + .get(index + 1..index + 4) + .ok_or_else(|| miette::miette!("invalid mountinfo path escape in '{path}'"))?; + if !octal.iter().all(|byte| matches!(byte, b'0'..=b'7')) { + return Err(miette::miette!("invalid mountinfo path escape in '{path}'")); + } + decoded.push((octal[0] - b'0') * 64 + (octal[1] - b'0') * 8 + (octal[2] - b'0')); + index += 4; } + String::from_utf8(decoded).into_diagnostic() +} - if modified { - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::Informational) - .status(StatusId::Success) - .state(StateId::Enabled, "enriched") - .message("Enriched policy with baseline filesystem paths for proxy mode") - .build() - ); +fn emit_cdi_validation_failure(message: &str) { + ocsf_emit!( + DetectionFindingBuilder::new(ocsf_ctx()) + .activity(ActivityId::Open) + .severity(SeverityId::High) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .finding_info( + FindingInfo::new( + "cdi-policy-validation-failed", + "CDI Policy Validation Failed", + ) + .with_desc(message), + ) + .message(format!("CDI validation failed: {message}")) + .build() + ); +} + +fn emit_baseline_enrichment_event() { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Enabled, "enriched") + .message("Enriched policy with baseline filesystem paths") + .build() + ); +} + +fn emit_policy_enrichment_events( + application: EnrichmentApplication, + cdi_requirements: Option<&openshell_core::cdi::CdiDerivedRequirements>, + plan: &EnrichmentPlan, +) { + if application.baseline_modified { + emit_baseline_enrichment_event(); + } + if let Some(requirements) = cdi_requirements + && (application.runtime_modified || plan.has_additional_gids()) + { + emit_cdi_enrichment_event(requirements); } } +fn emit_cdi_enrichment_event(requirements: &openshell_core::cdi::CdiDerivedRequirements) { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Enabled, "enriched") + .unmapped( + "cdi_device_node_count", + serde_json::json!(requirements.device_node_paths.len()) + ) + .unmapped( + "cdi_read_only_mount_count", + serde_json::json!(requirements.read_only_mount_paths.len()) + ) + .unmapped( + "cdi_read_write_mount_count", + serde_json::json!(requirements.read_write_mount_paths.len()) + ) + .unmapped( + "cdi_additional_gid_count", + serde_json::json!(requirements.additional_gids.len()) + ) + .message("Enriched policy with CDI-derived filesystem and process requirements") + .build() + ); +} + #[cfg(test)] #[allow( clippy::needless_raw_string_hashes, @@ -1615,7 +2207,8 @@ mod baseline_tests { }, ); - enrich_proto_baseline_paths(&mut policy); + let plan = EnrichmentPlan::for_proto_policy(&policy, None); + plan.apply_to_proto_policy(&mut policy).unwrap(); let filesystem = policy.filesystem.expect("filesystem policy"); assert!( @@ -1718,16 +2311,17 @@ mod baseline_tests { policy.network_policies.is_empty(), "regression setup must exercise the no-network default path" ); - let (ro, rw) = - collect_baseline_enrichment_paths(false, true, vec!["/dev/nvidia0".to_string()]); + let plan = EnrichmentPlan::legacy_gpu_fallback(vec!["/dev/nvidia0".to_string()]); - let enriched = enrich_proto_baseline_paths_with(&mut policy, &ro, &rw, |path| { - matches!(path, "/proc" | "/dev/nvidia0") - }); + let application = plan + .apply_to_proto_policy_with(&mut policy, |path| { + matches!(path, "/proc" | "/dev/nvidia0") + }) + .unwrap(); let filesystem = policy.filesystem.expect("filesystem policy"); assert!( - enriched, + application.modified(), "GPU enrichment should not require network policies" ); assert!( @@ -1745,13 +2339,235 @@ mod baseline_tests { } #[test] - fn gpu_baseline_read_write_contains_dxg() { - // /dev/dxg must be present so WSL2 sandboxes get the Landlock - // read-write rule for the CDI-injected DXG device. The existence - // check in enrich_proto_baseline_paths() skips it on native Linux. + fn cdi_gpu_baseline_uses_proc_without_legacy_gpu_paths() { + let requirements = openshell_core::cdi::CdiDerivedRequirements::default(); + let (ro, rw) = EnrichmentPlan::cdi_gpu(&requirements).paths(); + + assert!( + rw.contains(&GPU_PROC_READ_WRITE.to_string()), + "CDI GPU baseline should keep the CUDA procfs write exception" + ); + for path in [ + "/dev/nvidiactl", + "/dev/nvidia-uvm", + "/dev/dxg", + "/dev/nvidia0", + ] { + assert!( + !rw.contains(&path.to_string()), + "CDI GPU baseline should not include legacy read-write path {path}" + ); + } + for path in ["/run/nvidia-persistenced", "/usr/lib/wsl"] { + assert!( + !ro.contains(&path.to_string()), + "CDI GPU baseline should not include legacy read-only path {path}" + ); + } + } + + #[test] + fn active_cdi_gpu_baseline_ignores_detected_gpu_devices() { + let requirements = openshell_core::cdi::CdiDerivedRequirements::default(); + let (ro, rw) = EnrichmentPlan::active(false, Some(&requirements)).paths(); + + assert!( + ro.is_empty(), + "CDI-only GPU baseline should not add read-only legacy paths" + ); + assert_eq!(rw, vec![GPU_PROC_READ_WRITE.to_string()]); + } + + #[test] + fn enrichment_plan_composes_proxy_baseline_and_cdi_gpu_paths() { + let requirements = openshell_core::cdi::CdiDerivedRequirements { + device_node_paths: vec!["/dev/dxg".to_string()], + read_only_mount_paths: vec!["/usr/lib/wsl/lib/libcuda.so.1".to_string()], + read_write_mount_paths: Vec::new(), + additional_gids: vec![44], + }; + let plan = EnrichmentPlan::proxy_baseline().merge(EnrichmentPlan::cdi_gpu(&requirements)); + let (ro, rw) = plan.paths(); + + assert!(ro.contains(&"/usr".to_string())); + assert!(ro.contains(&"/usr/lib/wsl/lib/libcuda.so.1".to_string())); + assert!(rw.contains(&"/tmp".to_string())); + assert!(rw.contains(&"/dev/dxg".to_string())); + assert!(rw.contains(&GPU_PROC_READ_WRITE.to_string())); + assert!( + !ro.contains(&GPU_PROC_READ_WRITE.to_string()), + "read_write /proc should normalize away proxy read_only /proc" + ); + assert_eq!(plan.additional_gids(), vec![44]); + } + + #[test] + fn enrichment_plan_merges_baseline_and_runtime_path_sources() { + let mut plan = EnrichmentPlan::default(); + plan.insert_read_write_path("/shared", EnrichmentPathPolicy::baseline()); + plan.insert_read_write_path("/shared", EnrichmentPathPolicy::runtime_device_node()); + + let (_ro, rw) = plan.entries(); + assert_eq!(rw.len(), 1); + assert!(rw[0].policy.sources.baseline); + assert!(rw[0].policy.sources.runtime); + } + + #[test] + fn proto_cdi_enrichment_adds_derived_paths() { + let mut policy = openshell_policy::restrictive_default_policy(); + let requirements = openshell_core::cdi::CdiDerivedRequirements { + device_node_paths: vec!["/dev/dxg".to_string()], + read_only_mount_paths: vec!["/usr/lib/wsl/lib/libcuda.so.1".to_string()], + read_write_mount_paths: Vec::new(), + additional_gids: Vec::new(), + }; + + let plan = EnrichmentPlan::cdi_gpu(&requirements); + assert!( + plan.apply_to_proto_policy_with(&mut policy, |path| { + matches!(path, "/usr/lib/wsl/lib/libcuda.so.1" | "/dev/dxg" | "/proc") + }) + .unwrap() + .modified() + ); + let filesystem = policy.filesystem.expect("filesystem policy"); + assert!( + filesystem + .read_only + .contains(&"/usr/lib/wsl/lib/libcuda.so.1".to_string()) + ); + assert!(filesystem.read_write.contains(&"/dev/dxg".to_string())); + } + + #[test] + fn proto_cdi_enrichment_errors_for_missing_runtime_path() { + let mut policy = openshell_policy::restrictive_default_policy(); + let requirements = openshell_core::cdi::CdiDerivedRequirements { + device_node_paths: vec!["/dev/dxg".to_string()], + read_only_mount_paths: Vec::new(), + read_write_mount_paths: Vec::new(), + additional_gids: Vec::new(), + }; + + let plan = EnrichmentPlan::cdi_gpu(&requirements); + let err = plan + .apply_to_proto_policy_with(&mut policy, |path| path == "/proc") + .unwrap_err(); + assert!( + err.to_string().contains("does not exist"), + "unexpected error: {err}" + ); + } + + #[test] + fn proto_cdi_device_node_conflict_keeps_explicit_read_only() { + let mut policy = openshell_policy::restrictive_default_policy(); + policy.filesystem = Some(openshell_core::proto::FilesystemPolicy { + read_only: vec!["/dev/nvidia0".to_string()], + read_write: Vec::new(), + include_workdir: false, + }); + let requirements = openshell_core::cdi::CdiDerivedRequirements { + device_node_paths: vec!["/dev/nvidia0".to_string()], + read_only_mount_paths: Vec::new(), + read_write_mount_paths: Vec::new(), + additional_gids: Vec::new(), + }; + + let plan = EnrichmentPlan::cdi_gpu(&requirements); + plan.apply_to_proto_policy_with(&mut policy, |path| { + matches!(path, "/dev/nvidia0" | "/proc") + }) + .unwrap(); + let filesystem = policy.filesystem.expect("filesystem policy"); + assert!( + filesystem.read_only.contains(&"/dev/nvidia0".to_string()), + "CDI device nodes should not promote existing read_only policy entries" + ); + assert!( + !filesystem.read_write.contains(&"/dev/nvidia0".to_string()), + "existing read_only device node should remain read_only" + ); + } + + #[test] + fn proto_cdi_writable_mount_rejects_read_write_conflict() { + let mut policy = openshell_policy::restrictive_default_policy(); + policy.filesystem = Some(openshell_core::proto::FilesystemPolicy { + read_only: vec!["/opt/nvidia/cache.db".to_string()], + read_write: Vec::new(), + include_workdir: false, + }); + let requirements = openshell_core::cdi::CdiDerivedRequirements { + device_node_paths: Vec::new(), + read_only_mount_paths: Vec::new(), + read_write_mount_paths: vec!["/opt/nvidia/cache.db".to_string()], + additional_gids: Vec::new(), + }; + + let plan = EnrichmentPlan::cdi_gpu(&requirements); + let err = plan + .apply_to_proto_policy_with(&mut policy, |path| { + matches!(path, "/opt/nvidia/cache.db" | "/proc") + }) + .unwrap_err(); + assert!( + err.to_string().contains("conflicts"), + "unexpected error: {err}" + ); + } + + #[test] + fn proto_cdi_enrichment_promotes_proc_read_write() { + let mut policy = openshell_policy::restrictive_default_policy(); + policy.filesystem = Some(openshell_core::proto::FilesystemPolicy { + read_only: vec!["/proc".to_string()], + read_write: Vec::new(), + include_workdir: false, + }); + let requirements = openshell_core::cdi::CdiDerivedRequirements { + device_node_paths: vec!["/proc".to_string()], + read_only_mount_paths: Vec::new(), + read_write_mount_paths: Vec::new(), + additional_gids: Vec::new(), + }; + + let plan = EnrichmentPlan::cdi_gpu(&requirements); + assert!(plan.apply_to_proto_policy(&mut policy).unwrap().modified()); + let filesystem = policy.filesystem.expect("filesystem policy"); + assert!(!filesystem.read_only.contains(&"/proc".to_string())); + assert!(filesystem.read_write.contains(&"/proc".to_string())); + } + + #[test] + fn local_cdi_enrichment_applies_additional_gids_as_supplemental_groups() { + let mut policy = SandboxPolicy { + version: 1, + filesystem: FilesystemPolicy::default(), + network: NetworkPolicy::default(), + landlock: LandlockPolicy::default(), + process: ProcessPolicy::default(), + }; + let requirements = openshell_core::cdi::CdiDerivedRequirements { + device_node_paths: Vec::new(), + read_only_mount_paths: Vec::new(), + read_write_mount_paths: Vec::new(), + additional_gids: vec![44, 107], + }; + + let plan = EnrichmentPlan::cdi_gpu(&requirements); + plan.apply_to_sandbox_policy(&mut policy).unwrap(); + assert_eq!(policy.process.supplemental_groups, vec![44, 107]); + } + + #[test] + fn legacy_gpu_baseline_read_write_contains_dxg() { + // /dev/dxg stays in the legacy device-scan fallback. CDI mode derives + // device nodes from the selected specs instead of this list. assert!( GPU_BASELINE_READ_WRITE.contains(&"/dev/dxg"), - "/dev/dxg must be in GPU_BASELINE_READ_WRITE for WSL2 support" + "/dev/dxg should remain in the legacy GPU read-write fallback" ); } @@ -1760,7 +2576,7 @@ mod baseline_tests { let mut policy = SandboxPolicy { version: 1, filesystem: FilesystemPolicy { - read_only: vec![std::path::PathBuf::from("/tmp")], + read_only: vec![PathBuf::from("/tmp")], read_write: vec![], include_workdir: false, }, @@ -1772,31 +2588,61 @@ mod baseline_tests { process: ProcessPolicy::default(), }; - enrich_sandbox_baseline_paths(&mut policy); + let plan = EnrichmentPlan::for_sandbox_policy(&policy, None); + plan.apply_to_sandbox_policy(&mut policy).unwrap(); assert!( - policy - .filesystem - .read_only - .contains(&std::path::PathBuf::from("/tmp")), + policy.filesystem.read_only.contains(&PathBuf::from("/tmp")), "explicit read_only baseline path should be preserved" ); assert!( !policy .filesystem .read_write - .contains(&std::path::PathBuf::from("/tmp")), + .contains(&PathBuf::from("/tmp")), "baseline enrichment must not promote explicit read_only /tmp to read_write" ); } #[test] - fn gpu_baseline_read_only_contains_usr_lib_wsl() { - // /usr/lib/wsl must be present so CDI-injected WSL2 GPU library - // bind-mounts are accessible under Landlock. Skipped on native Linux. + fn legacy_gpu_baseline_read_only_contains_usr_lib_wsl() { + // /usr/lib/wsl stays in the legacy device-scan fallback. CDI mode uses + // resolved mount destinations instead of this broad directory. assert!( GPU_BASELINE_READ_ONLY.contains(&"/usr/lib/wsl"), - "/usr/lib/wsl must be in GPU_BASELINE_READ_ONLY for WSL2 CDI library paths" + "/usr/lib/wsl should remain in the legacy GPU read-only fallback" + ); + } + + #[test] + fn local_cdi_baseline_promotes_proc_read_write() { + let mut policy = SandboxPolicy { + version: 1, + filesystem: FilesystemPolicy { + read_only: vec![PathBuf::from(GPU_PROC_READ_WRITE)], + read_write: Vec::new(), + include_workdir: false, + }, + network: NetworkPolicy::default(), + landlock: LandlockPolicy::default(), + process: ProcessPolicy::default(), + }; + + let requirements = openshell_core::cdi::CdiDerivedRequirements::default(); + let plan = EnrichmentPlan::for_sandbox_policy(&policy, Some(&requirements)); + plan.apply_to_sandbox_policy(&mut policy).unwrap(); + + assert!( + !policy + .filesystem + .read_only + .contains(&PathBuf::from(GPU_PROC_READ_WRITE)) + ); + assert!( + policy + .filesystem + .read_write + .contains(&PathBuf::from(GPU_PROC_READ_WRITE)) ); } @@ -1942,7 +2788,7 @@ async fn load_policy( landlock: config.landlock, process: config.process, }; - enrich_sandbox_baseline_paths(&mut policy); + enrich_sandbox_policy_with_baseline_and_cdi(&mut policy)?; // File mode has no operator-registered middleware to connect. return Ok(( policy, @@ -1984,7 +2830,7 @@ async fn load_policy( let mut discovered = discover_policy_from_disk_or_default(); // Enrich before syncing so the gateway baseline includes // baseline paths from the start. - enrich_proto_baseline_paths(&mut discovered); + enrich_proto_policy_with_baseline_and_cdi(&mut discovered)?; strip_proto_provider_policy_entries(&mut discovered); let sandbox = sandbox.as_deref().ok_or_else(|| { miette::miette!( @@ -2020,7 +2866,8 @@ async fn load_policy( // Ensure baseline filesystem paths are present for proxy-mode // sandboxes. If the policy was enriched, sync the updated version // back to the gateway so users can see the effective policy. - let enriched = enrich_proto_baseline_paths(&mut proto_policy); + let cdi_enrichment = enrich_proto_policy_with_baseline_and_cdi(&mut proto_policy)?; + let enriched = cdi_enrichment.enriched; let sync_policy = proto_sync_payload_for_enriched_policy(&proto_policy, enriched); if let Some(sync_policy) = sync_policy { if let Some(sandbox_name) = sandbox.as_deref() { @@ -2164,7 +3011,7 @@ async fn load_policy( }; let opa_engine = Some(engine); - let policy = match SandboxPolicy::try_from(proto_policy.clone()) { + let mut policy = match SandboxPolicy::try_from(proto_policy.clone()) { Ok(policy) => policy, Err(e) => { report_initial_policy_failure(endpoint, id, loaded_policy_revision.as_ref(), &e) @@ -2172,6 +3019,9 @@ async fn load_policy( return Err(e); } }; + if !cdi_enrichment.additional_gids.is_empty() { + policy.process.supplemental_groups = cdi_enrichment.additional_gids; + } return Ok(( policy, opa_engine, @@ -2922,7 +3772,7 @@ fn retain_extension_credentials( .map(|service| service.name.as_str()) .collect() } else { - std::collections::HashSet::default() + HashSet::default() }; store.retain(&retained); } @@ -3932,6 +4782,52 @@ mod tests { } } + #[cfg(target_os = "linux")] + #[test] + fn cdi_projection_requires_read_only_context_and_spec_mounts() { + let context = openshell_core::cdi::CdiContext::new( + vec!["nvidia.com/gpu=0".to_string()], + vec![openshell_core::cdi::CdiSpecDirectory::new( + cdi_spec_mount_path(0), + "/host/cdi", + )], + ); + let mounts = vec![ + MountInfoEntry { + mount_point: openshell_core::cdi::CDI_CONTEXT_PATH.to_string(), + read_only: true, + }, + MountInfoEntry { + mount_point: cdi_spec_mount_path(0), + read_only: true, + }, + ]; + + validate_cdi_projection_mounts_with(&context, &mounts) + .expect("read-only CDI context and spec mounts should be accepted"); + + let mut writable_mounts = mounts; + writable_mounts[1].read_only = false; + let err = validate_cdi_projection_mounts_with(&context, &writable_mounts) + .expect_err("writable CDI spec mount must be rejected"); + assert!(err.to_string().contains("must be read-only")); + } + + #[cfg(target_os = "linux")] + #[test] + fn parse_mount_info_entry_decodes_mount_path_and_access_mode() { + let mount = parse_mount_info_entry( + "42 35 0:42 / /run/openshell/supervisor/cdi-specs/0\\040with-space ro,nosuid - tmpfs tmpfs rw", + ) + .expect("mountinfo fixture should parse"); + + assert_eq!( + mount.mount_point, + "/run/openshell/supervisor/cdi-specs/0 with-space" + ); + assert!(mount.read_only); + } + #[test] fn sidecar_process_policy_sets_loopback_proxy_addr() { let policy = proxy_policy(None); @@ -4066,8 +4962,8 @@ mod tests { apply_agent_proposals_enabled(&agent_proposals, true, "test", Some(1), None, || { installs.fetch_add(1, Ordering::Relaxed); Ok(skills::InstalledSkills { - policy_advisor: std::path::PathBuf::from("/tmp/policy_advisor.md"), - policy_advisor_skill: std::path::PathBuf::from("/tmp/SKILL.md"), + policy_advisor: PathBuf::from("/tmp/policy_advisor.md"), + policy_advisor_skill: PathBuf::from("/tmp/SKILL.md"), agents: None, }) }); @@ -4077,8 +4973,8 @@ mod tests { apply_agent_proposals_enabled(&agent_proposals, true, "test", Some(2), None, || { installs.fetch_add(1, Ordering::Relaxed); Ok(skills::InstalledSkills { - policy_advisor: std::path::PathBuf::from("/tmp/policy_advisor.md"), - policy_advisor_skill: std::path::PathBuf::from("/tmp/SKILL.md"), + policy_advisor: PathBuf::from("/tmp/policy_advisor.md"), + policy_advisor_skill: PathBuf::from("/tmp/SKILL.md"), agents: None, }) }); @@ -4087,8 +4983,8 @@ mod tests { apply_agent_proposals_enabled(&agent_proposals, false, "test", Some(3), None, || { installs.fetch_add(1, Ordering::Relaxed); Ok(skills::InstalledSkills { - policy_advisor: std::path::PathBuf::from("/tmp/policy_advisor.md"), - policy_advisor_skill: std::path::PathBuf::from("/tmp/SKILL.md"), + policy_advisor: PathBuf::from("/tmp/policy_advisor.md"), + policy_advisor_skill: PathBuf::from("/tmp/SKILL.md"), agents: None, }) }); diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 6dd92b40db..5b455c1d07 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -1195,6 +1195,7 @@ fn parse_process_policy(val: ®orus::Value) -> ProcessPolicy { ProcessPolicy { run_as_user: get_str(val, "run_as_user"), run_as_group: get_str(val, "run_as_group"), + supplemental_groups: Vec::new(), } } diff --git a/crates/openshell-supervisor-process/src/process.rs b/crates/openshell-supervisor-process/src/process.rs index 659fe3dc06..14ff86ea3b 100644 --- a/crates/openshell-supervisor-process/src/process.rs +++ b/crates/openshell-supervisor-process/src/process.rs @@ -13,7 +13,10 @@ use miette::{IntoDiagnostic, Result}; use nix::sys::signal::{self, Signal}; use nix::unistd::{Gid, Group, Pid, Uid, User}; use openshell_core::policy::{NetworkMode, SandboxPolicy}; +#[cfg(unix)] +use std::collections::BTreeSet; use std::collections::HashMap; +#[cfg(target_os = "linux")] use std::ffi::CString; #[cfg(target_os = "linux")] use std::os::fd::{AsRawFd, OwnedFd, RawFd}; @@ -1943,7 +1946,11 @@ fn resolve_filesystem_identity( _ => Vec::new(), }; - Ok((uid, gid, supplementary_gids)) + Ok(( + uid, + gid, + merged_supplemental_gids(supplementary_gids, &policy.process.supplemental_groups)?, + )) } #[cfg(not(unix))] @@ -1953,6 +1960,24 @@ pub fn prepare_filesystem(_policy: &SandboxPolicy) -> Result<()> { // `effective_gid`/`effective_uid` are intentionally parallel names (same role // for different identifiers) and the noise from renaming would obscure intent. +#[cfg(unix)] +fn merged_supplemental_gids(base: Vec, extra: &[u32]) -> Result> { + let mut groups = base + .into_iter() + .map(Gid::as_raw) + .filter(|gid| *gid != 0) + .collect::>(); + + for raw_gid in extra { + if *raw_gid == 0 { + return Err(miette::miette!("Supplemental group GID 0 is not allowed")); + } + groups.insert(*raw_gid); + } + + Ok(groups.into_iter().map(Gid::from_raw).collect()) +} + #[cfg(unix)] #[allow(clippy::similar_names)] pub fn drop_privileges(policy: &SandboxPolicy) -> Result<()> { @@ -1985,6 +2010,11 @@ pub fn drop_privileges_with_identity( fallback.process.run_as_group = Some("sandbox".into()); return drop_privileges_with_identity(&fallback, resolved_identity); } + if !policy.process.supplemental_groups.is_empty() { + return Err(miette::miette!( + "Supplemental groups require a privileged supervisor process" + )); + } return Ok(()); } @@ -2035,61 +2065,46 @@ pub fn drop_privileges_with_identity( }, }; - // 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 - }; - + #[cfg(not(any( + target_os = "macos", + target_os = "ios", + target_os = "haiku", + target_os = "redox" + )))] if target_uid != nix::unistd::geteuid() { - if resolved_identity.uses_oci_user_fallback() { + 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 + }; + let supplemental_groups = 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()?; - } + resolve_filesystem_identity(policy, resolved_identity)?.2 } 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()?; - } + nix::unistd::initgroups(user_cstr.as_c_str(), target_gid).into_diagnostic()?; + merged_supplemental_gids( + nix::unistd::getgroups().into_diagnostic()?, + &policy.process.supplemental_groups, + )? + } else { + merged_supplemental_gids(Vec::new(), &policy.process.supplemental_groups)? + }; + + if !supplemental_groups.is_empty() || resolved_identity.uses_oci_user_fallback() { + nix::unistd::setgroups(&supplemental_groups).into_diagnostic()?; } } @@ -2234,6 +2249,7 @@ mod tests { let policy = policy_with_process(ProcessPolicy { run_as_user: Some("101".into()), run_as_group: Some("102".into()), + ..Default::default() }); assert!(validate_sandbox_user(&policy).is_err()); @@ -2246,6 +2262,7 @@ mod tests { let policy = policy_with_process(ProcessPolicy { run_as_user: Some("app".into()), run_as_group: Some("staff".into()), + ..Default::default() }); let resolved = ResolvedProcessIdentity::new(Some(101), Some(102)); @@ -2259,10 +2276,12 @@ mod tests { let root_user = policy_with_process(ProcessPolicy { run_as_user: Some("0".into()), run_as_group: Some("102".into()), + ..Default::default() }); let root_group = policy_with_process(ProcessPolicy { run_as_user: Some("101".into()), run_as_group: Some("0".into()), + ..Default::default() }); assert!(validate_sandbox_user(&root_user).is_err()); @@ -2275,6 +2294,7 @@ mod tests { 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()), + ..Default::default() }); let resolved = ResolvedProcessIdentity::new(Some(1234), Some(1235)); @@ -2288,6 +2308,7 @@ mod tests { 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()), + ..Default::default() }); let resolved = ResolvedProcessIdentity::new(None, Some(1235)); @@ -2407,6 +2428,7 @@ mod tests { let policy = policy_with_process(ProcessPolicy { run_as_user: None, run_as_group: None, + ..Default::default() }); if nix::unistd::geteuid().is_root() { // As root, drop_privileges falls back to "sandbox:sandbox". @@ -2424,6 +2446,7 @@ mod tests { let policy = policy_with_process(ProcessPolicy { run_as_user: Some(String::new()), run_as_group: Some(String::new()), + ..Default::default() }); if nix::unistd::geteuid().is_root() { let has_sandbox = User::from_name("sandbox").ok().flatten().is_some(); @@ -2433,6 +2456,21 @@ mod tests { } } + #[test] + fn merged_supplemental_gids_deduplicates_and_rejects_root() { + let merged = merged_supplemental_gids( + vec![Gid::from_raw(44), Gid::from_raw(44), Gid::from_raw(0)], + &[44, 107], + ) + .unwrap(); + + assert_eq!( + merged.iter().map(|gid| gid.as_raw()).collect::>(), + vec![44, 107] + ); + assert!(merged_supplemental_gids(Vec::new(), &[0]).is_err()); + } + #[test] fn drop_privileges_succeeds_for_current_group() { // Set only run_as_group (no run_as_user) so that initgroups() is not @@ -2447,6 +2485,7 @@ mod tests { let policy = policy_with_process(ProcessPolicy { run_as_user: None, run_as_group: Some(current_group.name), + ..Default::default() }); let result = drop_privileges(&policy); @@ -2484,6 +2523,7 @@ mod tests { let policy = policy_with_process(ProcessPolicy { run_as_user: None, run_as_group: Some(current_group.name), + ..Default::default() }); let mut cmd = std::process::Command::new(std::env::current_exe().expect("current exe")); @@ -2525,6 +2565,7 @@ mod tests { let policy = policy_with_process(ProcessPolicy { run_as_user: Some(current_user.name), run_as_group: Some(current_group.name), + ..Default::default() }); assert!(drop_privileges(&policy).is_ok()); @@ -2535,6 +2576,7 @@ mod tests { let policy = policy_with_process(ProcessPolicy { run_as_user: Some("__nonexistent_test_user_42__".to_string()), run_as_group: None, + ..Default::default() }); let result = drop_privileges(&policy); @@ -2548,6 +2590,7 @@ mod tests { let policy = policy_with_process(ProcessPolicy { run_as_user: None, run_as_group: Some("__nonexistent_test_group_42__".to_string()), + ..Default::default() }); let result = drop_privileges(&policy); @@ -2721,6 +2764,7 @@ mod tests { process: ProcessPolicy { run_as_user, run_as_group, + ..Default::default() }, } } @@ -3683,6 +3727,7 @@ mod tests { let policy = policy_with_process(ProcessPolicy { run_as_user: Some(uid_raw.to_string()), run_as_group: Some(gid_raw.to_string()), + ..Default::default() }); assert!( @@ -3709,6 +3754,7 @@ mod tests { 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 + ..Default::default() }); assert!( @@ -3725,6 +3771,7 @@ mod tests { let policy = policy_with_process(ProcessPolicy { run_as_user: Some("999999".into()), run_as_group: Some("999999".into()), + ..Default::default() }); match drop_privileges(&policy) { Ok(()) => {} diff --git a/crates/openshell-supervisor-process/src/ssh.rs b/crates/openshell-supervisor-process/src/ssh.rs index 07302da953..f19375f199 100644 --- a/crates/openshell-supervisor-process/src/ssh.rs +++ b/crates/openshell-supervisor-process/src/ssh.rs @@ -1725,6 +1725,7 @@ mod tests { process: ProcessPolicy { run_as_user: Some("1000".into()), run_as_group: None, + ..Default::default() }, }; let (user, home) = session_user_and_home(&policy, None); @@ -1746,6 +1747,7 @@ mod tests { process: ProcessPolicy { run_as_user: Some("1234".into()), run_as_group: Some("1235".into()), + ..Default::default() }, }; @@ -1767,6 +1769,7 @@ mod tests { process: ProcessPolicy { run_as_user: Some("sandbox".into()), run_as_group: None, + ..Default::default() }, }; let (user, home) = session_user_and_home(&policy, None); @@ -1788,6 +1791,7 @@ mod tests { process: ProcessPolicy { run_as_user: Some(String::new()), run_as_group: None, + ..Default::default() }, }; let (user, home) = session_user_and_home(&policy, None); @@ -1808,6 +1812,7 @@ mod tests { process: ProcessPolicy { run_as_user: None, run_as_group: None, + ..Default::default() }, }; let (user, home) = session_user_and_home(&policy, None); @@ -1828,6 +1833,7 @@ mod tests { process: ProcessPolicy { run_as_user: Some("1000660000".into()), run_as_group: None, + ..Default::default() }, }; let (user, home) = session_user_and_home(&policy, None); @@ -1857,6 +1863,7 @@ mod tests { process: ProcessPolicy { run_as_user: None, run_as_group: None, + ..Default::default() }, }; @@ -1888,6 +1895,7 @@ mod tests { process: ProcessPolicy { run_as_user: None, run_as_group: None, + ..Default::default() }, }, None, @@ -1930,6 +1938,7 @@ mod tests { process: ProcessPolicy { run_as_user: Some("__oci_user_not_in_host_nss__".into()), run_as_group: Some("__oci_group_not_in_host_nss__".into()), + ..Default::default() }, }; let resolved = ResolvedProcessIdentity::new( @@ -2002,6 +2011,7 @@ mod tests { process: ProcessPolicy { run_as_user: None, run_as_group: None, + ..Default::default() }, } } diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index f57a21fc3f..7b2ad77237 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -102,7 +102,23 @@ See [Supervisor Middleware](/extensibility/supervisor-middleware) for registrati When a sandbox runs in proxy mode (the default), OpenShell automatically adds baseline filesystem paths required for the sandbox child process to function: `/usr`, `/lib`, `/etc`, and `/var/log` (read-only), plus `/tmp` (read-write). When `filesystem.include_workdir` is `true`, OpenShell also adds the resolved working directory as read-write. Paths like `/app` are included in the baseline set but are only added if they exist in the container image. -For GPU sandboxes, OpenShell also adds existing GPU device nodes as read-write paths. CUDA workloads require write access to procfs for thread metadata, so GPU baseline enrichment moves `/proc` from read-only to read-write when GPU devices are present. +For GPU sandboxes without a CDI context, OpenShell also adds existing GPU +device nodes as read-write paths. CUDA workloads require write access to procfs +for thread metadata, so GPU baseline enrichment moves `/proc` from read-only to +read-write when GPU devices or CDI specs are present. + +Docker GPU/CDI sandboxes receive CDI-derived filesystem requirements instead +of the hard-coded GPU device and library baseline. The supervisor resolves the +selected CDI device specs inside the sandbox and adds CDI device nodes as +read-write paths and CDI mount destinations as read-only paths. CDI +`additionalGids` are applied as supplemental groups before agent processes drop +privileges. + +Writable CDI mount destinations are fail-closed. OpenShell accepts a writable +CDI mount only when it targets a single file and that exact path is already +listed in `filesystem_policy.read_write`. Writable CDI directory mounts and +CDI paths such as `/`, `/dev`, `/proc`, `/sys`, `/run`, or `/usr` are rejected +during sandbox startup. This filtering prevents a missing baseline path from degrading Landlock enforcement. Without it, a single missing path could cause the entire Landlock ruleset to fail, leaving the sandbox with no filesystem restrictions at all.