From 5e597ba62f2c1a1e606da0e8f38aba210df2d9ee Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Fri, 18 Sep 2026 11:36:42 +0200 Subject: [PATCH] feat(sandbox): enforce CDI policy in workload boundary Signed-off-by: Evan Lezar --- crates/openshell-core/src/cdi.rs | 2 + crates/openshell-core/src/cdi_linux.rs | 79 +++--- crates/openshell-core/src/policy.rs | 7 + .../openshell-driver-docker/src/isolation.rs | 1 + .../src/isolation.rs | 1 + .../openshell-driver-podman/src/isolation.rs | 1 + .../openshell-driver-vm/src/isolation/mod.rs | 1 + .../src/boundary_protocol.rs | 12 + .../openshell-sandbox/src/boundary_server.rs | 242 +++++++++++++++++- crates/openshell-sandbox/src/process.rs | 2 + .../openshell-supervisor-network/src/opa.rs | 1 + docs/how-it-works/policies/default-policy.mdx | 34 ++- 12 files changed, 341 insertions(+), 42 deletions(-) diff --git a/crates/openshell-core/src/cdi.rs b/crates/openshell-core/src/cdi.rs index 2c1d03b9d7..cc89ada422 100644 --- a/crates/openshell-core/src/cdi.rs +++ b/crates/openshell-core/src/cdi.rs @@ -105,6 +105,8 @@ pub enum CdiError { WritableMountNotFile { path: String, kind: String }, #[error("CDI device node '{path}' must target a character or block device, found {kind}")] DeviceNodeNotDevice { path: String, kind: String }, + #[error("CDI read-only path '{path}' does not exist in the workload namespace")] + ReadOnlyPathMissing { path: String }, #[error("CDI additionalGids must not contain root GID 0")] RootAdditionalGid, #[error("CDI mount '{path}' has conflicting ro/rw options")] diff --git a/crates/openshell-core/src/cdi_linux.rs b/crates/openshell-core/src/cdi_linux.rs index b10e98167b..8000467f3c 100644 --- a/crates/openshell-core/src/cdi_linux.rs +++ b/crates/openshell-core/src/cdi_linux.rs @@ -183,6 +183,11 @@ where }); } } + for path in &requirements.read_only_paths { + if path_kind(path).is_none() { + return Err(CdiError::ReadOnlyPathMissing { path: path.clone() }); + } + } for path in &requirements.read_write_mount_paths { if !normalized_allowlist.contains(path) { return Err(CdiError::WritableMountNotAllowed { path: path.clone() }); @@ -445,12 +450,8 @@ mod tests { let dir = tempfile::tempdir().unwrap(); write_spec(dir.path(), "nvidia.yaml", spec); - let requirements = resolve_with_kind( - &context(dir.path(), &["nvidia.com/gpu=all"]), - &[], - fake_device_node, - ) - .unwrap(); + let requirements = + resolve_cdi_context(&context(dir.path(), &["nvidia.com/gpu=all"])).unwrap(); let baseline = CdiRequirementsBaseline { device_node_paths: &requirements.device_node_paths, read_only_paths: &requirements.read_only_paths, @@ -507,12 +508,8 @@ devices: "#, ); - let requirements = resolve_with_kind( - &context(dir.path(), &["nvidia.com/gpu=0"]), - &[], - fake_device_node, - ) - .unwrap(); + let requirements = + resolve_cdi_context(&context(dir.path(), &["nvidia.com/gpu=0"])).unwrap(); assert_eq!( requirements.device_node_paths, @@ -540,12 +537,8 @@ devices: ", ); - let requirements = resolve_with_kind( - &context(dir.path(), &["nvidia.com/gpu=all"]), - &[], - fake_device_node, - ) - .unwrap(); + let requirements = + resolve_cdi_context(&context(dir.path(), &["nvidia.com/gpu=all"])).unwrap(); assert_eq!( requirements.device_node_paths, @@ -573,12 +566,8 @@ devices: ", ); - let requirements = resolve_with_kind( - &context(dir.path(), &["nvidia.com/gpu=all"]), - &[], - fake_device_node, - ) - .unwrap(); + let requirements = + resolve_cdi_context(&context(dir.path(), &["nvidia.com/gpu=all"])).unwrap(); assert_eq!(requirements.device_node_paths, vec!["/dev/dxg"]); assert_eq!(requirements.read_only_paths, vec!["/usr/lib/wsl/lib"]); @@ -618,12 +607,8 @@ devices: "#, ); - let requirements = resolve_with_kind( - &context(dir.path(), &["nvidia.com/gpu=0"]), - &[], - always_missing, - ) - .unwrap(); + let requirements = + resolve_cdi_context(&context(dir.path(), &["nvidia.com/gpu=0"])).unwrap(); assert_eq!( requirements.read_only_paths, @@ -660,12 +645,8 @@ devices: "#, ); - let requirements = resolve_with_kind( - &context(dir.path(), &["nvidia.com/gpu=0"]), - &[], - fake_device_node, - ) - .unwrap(); + let requirements = + resolve_cdi_context(&context(dir.path(), &["nvidia.com/gpu=0"])).unwrap(); assert_eq!(requirements.additional_gids, vec![44]); assert_eq!( @@ -950,6 +931,32 @@ devices: assert!(matches!(err, CdiError::RootAdditionalGid)); } + #[test] + fn validates_read_only_paths_in_the_workload_namespace() { + let requirements = CdiDerivedRequirements { + read_only_paths: vec!["/opt/nvidia/runtime.json".to_string()], + ..CdiDerivedRequirements::default() + }; + let writable_file_allowlist = HashSet::::new(); + + let err = validate_cdi_requirements_with_path_kind( + &requirements, + &writable_file_allowlist, + always_missing, + ) + .unwrap_err(); + assert!(matches!( + err, + CdiError::ReadOnlyPathMissing { path } + if path == "/opt/nvidia/runtime.json" + )); + + validate_cdi_requirements_with_path_kind(&requirements, &writable_file_allowlist, |_| { + Some(CdiPathKind::File) + }) + .unwrap(); + } + #[test] fn rejects_device_node_that_is_not_device() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/openshell-core/src/policy.rs b/crates/openshell-core/src/policy.rs index af474325c5..3cad02cbcf 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 required from the workload runtime. + /// + /// Runtime-specific inputs can use different terminology; CDI + /// `additionalGids` are converted into this process-level representation. + pub supplemental_groups: Vec, } #[derive(Debug, Clone, Default)] @@ -185,6 +191,7 @@ impl From for ProcessPolicy { } else { Some(proto.run_as_group) }, + supplemental_groups: Vec::new(), } } } diff --git a/crates/openshell-driver-docker/src/isolation.rs b/crates/openshell-driver-docker/src/isolation.rs index d921b8c348..b5d4544e00 100644 --- a/crates/openshell-driver-docker/src/isolation.rs +++ b/crates/openshell-driver-docker/src/isolation.rs @@ -118,6 +118,7 @@ impl DockerBoundarySpec { }, resource_claims: resource_claims.clone(), resource_claim_files: BTreeMap::new(), + cdi_context: None, workload_identity: self.workload_identity.clone(), outer_fence: outer_fence.clone(), child_env: self.child_env, diff --git a/crates/openshell-driver-kubernetes/src/isolation.rs b/crates/openshell-driver-kubernetes/src/isolation.rs index 6a94e6abaa..94ff87d21d 100644 --- a/crates/openshell-driver-kubernetes/src/isolation.rs +++ b/crates/openshell-driver-kubernetes/src/isolation.rs @@ -274,6 +274,7 @@ impl KubernetesSandboxRuntimeBoundarySpec { "kubernetes.workload_pod_uid".to_string(), self.workload_pod_uid_path, )]), + cdi_context: None, workload_identity: self.workload_identity.clone(), outer_fence: outer_fence.clone(), child_env: self.child_env, diff --git a/crates/openshell-driver-podman/src/isolation.rs b/crates/openshell-driver-podman/src/isolation.rs index ae33796ea6..0457984592 100644 --- a/crates/openshell-driver-podman/src/isolation.rs +++ b/crates/openshell-driver-podman/src/isolation.rs @@ -277,6 +277,7 @@ pub fn bootstrap_archives( }, resource_claims: resource_claims.clone(), resource_claim_files: BTreeMap::new(), + cdi_context: None, workload_identity: identity.clone(), outer_fence: outer_fence.clone(), child_env: child_env.clone(), diff --git a/crates/openshell-driver-vm/src/isolation/mod.rs b/crates/openshell-driver-vm/src/isolation/mod.rs index a660ad98c7..2316df5494 100644 --- a/crates/openshell-driver-vm/src/isolation/mod.rs +++ b/crates/openshell-driver-vm/src/isolation/mod.rs @@ -118,6 +118,7 @@ impl VmBoundarySpec { }, resource_claims: resource_claims.clone(), resource_claim_files: BTreeMap::new(), + cdi_context: None, workload_identity: workload_identity.clone(), outer_fence: outer_fence.clone(), child_env: self.child_env, diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index b7b32f72bd..f408966431 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -510,6 +510,12 @@ pub struct BoundaryConfig { /// Downward API. Other drivers may leave the map empty. #[serde(default)] pub resource_claim_files: std::collections::BTreeMap, + /// Driver-protected CDI selection and workload-local specification projections. + /// + /// The sandbox runtime resolves this context inside the workload mount + /// namespace before applying launch-time filesystem controls. + #[serde(default)] + pub cdi_context: Option, /// Exact identity already applied by the runtime to the sandbox process. pub workload_identity: openshell_isolation_interface::contract::ResolvedWorkloadIdentity, /// Backend-neutral projection of the validated outer network fence. @@ -540,6 +546,7 @@ impl fmt::Debug for BoundaryConfig { .field("listener", &self.listener) .field("resource_claims", &self.resource_claims) .field("resource_claim_files", &self.resource_claim_files) + .field("has_cdi_context", &self.cdi_context.is_some()) .field("workload_identity", &self.workload_identity) .field("outer_fence", &self.outer_fence) .field("child_env_keys", &self.child_env.keys().collect::>()) @@ -1191,6 +1198,8 @@ pub struct SandboxPolicyWire { pub landlock: LandlockCompatibilityWire, pub run_as_user: Option, pub run_as_group: Option, + #[serde(default)] + pub supplemental_groups: Vec, } impl From for SandboxPolicyWire { @@ -1215,6 +1224,7 @@ impl From for SandboxPolicyWire { let ProcessPolicy { run_as_user, run_as_group, + supplemental_groups, } = process; Self { version, @@ -1226,6 +1236,7 @@ impl From for SandboxPolicyWire { landlock: LandlockCompatibilityWire::from(compatibility), run_as_user, run_as_group, + supplemental_groups, } } } @@ -1252,6 +1263,7 @@ impl From for SandboxPolicy { process: ProcessPolicy { run_as_user: policy.run_as_user, run_as_group: policy.run_as_group, + supplemental_groups: policy.supplemental_groups, }, } } diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index 80ca6f32cd..c095402156 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -13,6 +13,7 @@ use std::path::Path; #[cfg(target_os = "linux")] mod linux { + use std::collections::BTreeSet; use std::fs::File; use std::io::{self, Read, Write}; use std::mem::size_of; @@ -108,6 +109,124 @@ mod linux { fn enrich_gpu_filesystem_paths( policy: &mut openshell_core::policy::SandboxPolicy, gpu_requested: bool, + cdi_context: Option<&openshell_core::cdi::CdiContext>, + ) -> Result { + if let Some(context) = cdi_context { + if !gpu_requested { + return Err("CDI context was provided without a GPU resource claim".to_string()); + } + return enrich_cdi_filesystem_paths(policy, context); + } + Ok(enrich_legacy_gpu_filesystem_paths(policy, gpu_requested)) + } + + fn enrich_cdi_filesystem_paths( + policy: &mut openshell_core::policy::SandboxPolicy, + context: &openshell_core::cdi::CdiContext, + ) -> Result { + let writable_file_allowlist = policy + .filesystem + .read_write + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect::>(); + let requirements = openshell_core::cdi::resolve_cdi_context(context) + .map_err(|error| format!("resolve workload CDI requirements: {error}"))?; + openshell_core::cdi::validate_cdi_requirements(&requirements, &writable_file_allowlist) + .map_err(|error| format!("validate workload CDI requirements: {error}"))?; + + let mut read_only = requirements + .read_only_paths + .iter() + .map(std::path::PathBuf::from) + .collect::>(); + if Path::new("/sys").exists() { + read_only.push("/sys".into()); + } + read_only.sort_by(path_depth_order); + read_only.dedup(); + + let mut modified = false; + for path in read_only { + if path_is_covered_by_policy(policy, &path) { + continue; + } + policy.filesystem.read_only.push(path); + modified = true; + } + + let mut read_write = requirements + .device_node_paths + .iter() + .map(std::path::PathBuf::from) + .collect::>(); + read_write.push("/proc".into()); + read_write.sort_by(path_depth_order); + read_write.dedup(); + for path in read_write { + if path_is_covered(&path, &policy.filesystem.read_write) { + continue; + } + if policy.filesystem.read_only.contains(&path) { + if path != Path::new("/proc") { + continue; + } + policy + .filesystem + .read_only + .retain(|allowed| allowed != &path); + } + policy.filesystem.read_write.push(path); + modified = true; + } + + let mut supplemental_groups = policy + .process + .supplemental_groups + .iter() + .copied() + .collect::>(); + let original_group_count = supplemental_groups.len(); + supplemental_groups.extend(requirements.additional_gids); + if supplemental_groups.len() != original_group_count { + policy.process.supplemental_groups = supplemental_groups.into_iter().collect(); + modified = true; + } + + Ok(modified) + } + + fn required_cdi_groups_present(required: &[u32], primary: u32, actual: &[u32]) -> bool { + required + .iter() + .all(|gid| *gid != 0 && (*gid == primary || actual.contains(gid))) + } + + fn path_depth_order( + left: &std::path::PathBuf, + right: &std::path::PathBuf, + ) -> std::cmp::Ordering { + left.components() + .count() + .cmp(&right.components().count()) + .then_with(|| left.cmp(right)) + } + + fn path_is_covered_by_policy( + policy: &openshell_core::policy::SandboxPolicy, + candidate: &Path, + ) -> bool { + path_is_covered(candidate, &policy.filesystem.read_only) + || path_is_covered(candidate, &policy.filesystem.read_write) + } + + fn path_is_covered(candidate: &Path, allowed: &[std::path::PathBuf]) -> bool { + allowed.iter().any(|path| candidate.starts_with(path)) + } + + fn enrich_legacy_gpu_filesystem_paths( + policy: &mut openshell_core::policy::SandboxPolicy, + gpu_requested: bool, ) -> bool { if !gpu_requested { return false; @@ -2565,7 +2684,15 @@ mod linux { .resource_claims .get(GPU_RESOURCE_CLAIM) .is_some_and(|value| value == "true"); - if enrich_gpu_filesystem_paths(&mut policy, gpu_requested) { + let enriched = match enrich_gpu_filesystem_paths( + &mut policy, + gpu_requested, + self.config.cdi_context.as_ref(), + ) { + Ok(enriched) => enriched, + Err(error) => return guest_error(BoundaryErrorKind::Process, error), + }; + if enriched { openshell_ocsf::ocsf_emit!( openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) .severity(openshell_ocsf::SeverityId::Informational) @@ -2575,6 +2702,30 @@ mod linux { .build() ); } + if !policy.process.supplemental_groups.is_empty() { + let actual_groups = match nix::unistd::getgroups() { + Ok(groups) => groups, + Err(error) => { + return guest_error( + BoundaryErrorKind::Process, + format!("read workload CDI groups: {error}"), + ); + } + }; + if !required_cdi_groups_present( + &policy.process.supplemental_groups, + nix::unistd::getegid().as_raw(), + &actual_groups + .iter() + .map(|gid| gid.as_raw()) + .collect::>(), + ) { + return guest_error( + BoundaryErrorKind::Process, + "workload runtime did not supply the required CDI groups", + ); + } + } 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 { @@ -3755,6 +3906,86 @@ mod linux { }; use rcgen::{KeyPair, PKCS_ED25519}; + fn cdi_test_policy(read_only: &[&str]) -> openshell_core::policy::SandboxPolicy { + openshell_core::policy::SandboxPolicy { + version: 1, + filesystem: openshell_core::policy::FilesystemPolicy { + read_only: read_only.iter().map(std::path::PathBuf::from).collect(), + read_write: Vec::new(), + include_workdir: false, + }, + network: openshell_core::policy::NetworkPolicy::default(), + landlock: openshell_core::policy::LandlockPolicy::default(), + process: openshell_core::policy::ProcessPolicy::default(), + } + } + + fn cdi_test_context() -> (tempfile::TempDir, openshell_core::cdi::CdiContext) { + let directory = tempfile::tempdir().unwrap(); + std::fs::write( + directory.path().join("nvidia.yaml"), + r#" +cdiVersion: 0.7.0 +kind: nvidia.com/gpu +devices: + - name: "0" + containerEdits: + deviceNodes: + - path: /dev/null +containerEdits: + mounts: + - hostPath: /host/libfake.so.1 + containerPath: /usr/lib/libfake.so.1 + options: [ro] + - hostPath: /host/nvidia-info + containerPath: /etc/hosts + options: [ro] + additionalGids: [44] +"#, + ) + .unwrap(); + let context = openshell_core::cdi::CdiContext::new( + vec!["nvidia.com/gpu=0".to_string()], + vec![openshell_core::cdi::CdiSpecDirectory::new( + directory.path().to_string_lossy(), + "/var/run/cdi", + )], + ); + (directory, context) + } + + #[test] + fn workload_cdi_enrichment_respects_authored_ancestors() { + let (_directory, context) = cdi_test_context(); + let mut policy = cdi_test_policy(&["/usr"]); + + assert!(enrich_cdi_filesystem_paths(&mut policy, &context).unwrap()); + + assert!(policy.filesystem.read_only.contains(&"/usr".into())); + assert!(!policy.filesystem.read_only.contains(&"/usr/lib".into())); + assert!(policy.filesystem.read_only.contains(&"/etc/hosts".into())); + assert!(policy.filesystem.read_only.contains(&"/sys".into())); + assert!(policy.filesystem.read_write.contains(&"/dev/null".into())); + assert!(policy.filesystem.read_write.contains(&"/proc".into())); + assert_eq!(policy.process.supplemental_groups, vec![44]); + } + + #[test] + fn cdi_groups_require_runtime_access() { + assert!(required_cdi_groups_present(&[44, 107], 44, &[107])); + assert!(!required_cdi_groups_present(&[44, 107], 44, &[])); + assert!(!required_cdi_groups_present(&[0], 1000, &[0])); + } + + #[test] + fn workload_cdi_enrichment_is_idempotent() { + let (_directory, context) = cdi_test_context(); + let mut policy = cdi_test_policy(&["/usr"]); + + assert!(enrich_cdi_filesystem_paths(&mut policy, &context).unwrap()); + assert!(!enrich_cdi_filesystem_paths(&mut policy, &context).unwrap()); + } + #[test] fn exec_tombstones_expire_without_a_lifetime_limit() { let mut ledger = ExecRequestLedger::default(); @@ -3959,6 +4190,7 @@ mod linux { }, resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), + cdi_context: None, workload_identity: test_workload_identity(), outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), @@ -4073,6 +4305,7 @@ mod linux { "true".to_string(), )]), resource_claim_files: std::collections::BTreeMap::new(), + cdi_context: None, workload_identity: test_workload_identity(), outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), @@ -4144,6 +4377,7 @@ mod linux { }, resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), + cdi_context: None, workload_identity: test_workload_identity(), outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), @@ -4741,6 +4975,7 @@ mod linux { }, resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), + cdi_context: None, workload_identity: test_workload_identity(), outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), @@ -4792,6 +5027,7 @@ mod linux { process: openshell_core::policy::ProcessPolicy { run_as_user: user.map(str::to_string), run_as_group: group.map(str::to_string), + ..Default::default() }, }) } @@ -4970,6 +5206,7 @@ mod linux { "kubernetes.pod_uid".to_string(), pod_uid_path, )]), + cdi_context: None, workload_identity: test_workload_identity(), outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), @@ -5010,6 +5247,7 @@ mod linux { }, resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), + cdi_context: None, workload_identity: test_workload_identity(), outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), @@ -5185,6 +5423,7 @@ mod linux { }, resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), + cdi_context: None, workload_identity: test_workload_identity(), outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), @@ -5520,6 +5759,7 @@ mod linux { }, resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), + cdi_context: None, workload_identity: test_workload_identity(), outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), diff --git a/crates/openshell-sandbox/src/process.rs b/crates/openshell-sandbox/src/process.rs index 595ceac29e..cd0a4f6ce7 100644 --- a/crates/openshell-sandbox/src/process.rs +++ b/crates/openshell-sandbox/src/process.rs @@ -1092,6 +1092,7 @@ mod tests { let policy = policy_with_process(ProcessPolicy { run_as_user: Some(current_user.name.clone()), run_as_group: None, + ..Default::default() }); let workspace = ResolvedWorkspace::default(); let mut cmd = Command::new("/usr/bin/env"); @@ -1129,6 +1130,7 @@ mod tests { let policy = policy_with_process(ProcessPolicy { run_as_user: Some(current_user.name), run_as_group: None, + ..Default::default() }); for interactive in [false, true] { let mut cmd = Command::new("/usr/bin/env"); diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 74a14466da..3b193da321 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -1401,6 +1401,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"), + ..ProcessPolicy::default() } } diff --git a/docs/how-it-works/policies/default-policy.mdx b/docs/how-it-works/policies/default-policy.mdx index 3f148dcf70..79b35da2e2 100644 --- a/docs/how-it-works/policies/default-policy.mdx +++ b/docs/how-it-works/policies/default-policy.mdx @@ -87,8 +87,8 @@ the sandbox's policy. ### GPU Sandboxes -On the Docker and VM compute drivers, a sandbox that requests a GPU receives -additional paths when the corresponding GPU device is present: +GPU sandboxes without a CDI context receive additional paths when the +corresponding GPU device is present: | Access | Paths | |---|---| @@ -96,9 +96,33 @@ additional paths when the corresponding GPU device is present: | Read-write | `/dev/nvidiactl`, `/dev/nvidia-uvm`, `/dev/nvidia-uvm-tools`, `/dev/nvidia-modeset`, `/dev/dxg`, numbered `/dev/nvidia` device nodes, and `/proc` | CUDA writes thread names under `/proc` during initialization, so GPU enrichment -moves `/proc` from read-only to read-write. OpenShell adds each path only when -it exists in the workload. These paths apply at runtime and are not saved in -the sandbox's policy. +moves `/proc` from read-only to read-write. GPU enrichment also grants read-only +access to the `/sys` tree exposed by the container runtime because CUDA reads +hardware and driver topology during initialization. OpenShell adds each path +only when it exists in the workload. These paths apply at runtime and are not +saved in the sandbox's policy. + +GPU sandboxes from CDI-capable compute drivers receive CDI-derived filesystem +requirements instead of the hard-coded GPU device and library baseline. The +workload-side sandbox runtime resolves the selected CDI device specs and adds +CDI device nodes as read-write paths. For read-only mounts, it adds the parent +directories of shared libraries whose filenames end in `.so` or a numeric +SONAME suffix with any number of dot-separated components, such as `.so.1`, +`.so.1.2`, or `.so.1.2.3`. It retains exact destinations for other read-only +mounts. OpenShell does not add a derived path when an existing read-only or +read-write parent already covers it. The container runtime supplies CDI `additionalGids` as supplemental groups. +OpenShell verifies that the workload already has those groups and fails startup +if any required group is missing. + +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. + +The GPU `/sys` permission is a runtime-owned compatibility baseline. CDI specs +cannot request broad sysfs access, and non-GPU sandboxes do not receive this +baseline. ### Protected Paths