Skip to content

Commit 9c69246

Browse files
committed
feat(sandbox): enforce CDI policy in workload boundary
Signed-off-by: Evan Lezar <elezar@nvidia.com>
1 parent ff1ffe7 commit 9c69246

13 files changed

Lines changed: 415 additions & 93 deletions

File tree

‎architecture/security-policy.md‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,30 @@ before any consumer-specific projection runs. There is no permissive parsing
4141
profile: unsupported policy fields always invalidate the document. Middleware `config`, query and persisted-query names, and recursive MCP
4242
parameter names are open user-data maps rather than schema extensions.
4343

44-
Before applying Landlock, the supervisor enriches baseline filesystem paths that
45-
the runtime needs. Missing baseline paths are skipped so one absent runtime path
46-
does not weaken the whole ruleset. When GPU devices are present, GPU baseline
47-
enrichment adds existing GPU device nodes as read-write paths and promotes
48-
`/proc` to read-write because CUDA workloads write thread metadata under
49-
`/proc/<pid>/task/<tid>/comm`.
44+
Before applying Landlock, trusted runtime components enrich baseline filesystem
45+
paths that the workload needs. Missing optional baseline paths are skipped so
46+
one absent runtime path does not weaken the whole ruleset. The workload-side
47+
sandbox runtime performs GPU enrichment in the workload mount namespace. GPU
48+
sandboxes without CDI context use the legacy device baseline; CDI sandboxes use
49+
requirements derived from the selected CDI specs. Both paths grant read-only
50+
access to the existing container sysfs view and promote `/proc` to read-write.
51+
52+
A compute driver can place a protected CDI context and read-only CDI spec
53+
projection in the workload boundary. Before agent exec, `openshell-sandbox`
54+
resolves the selected CDI IDs and validates all derived entities against the
55+
workload namespace. It adds device nodes, exact destinations of non-library
56+
read-only mounts, parent directories of shared-library mounts, and supplemental
57+
GIDs. CDI host paths are ignored for policy. Library filenames must end in
58+
`.so` or a numeric SONAME suffix with any number of dot-separated components,
59+
such as `.so.1`, `.so.1.2`, or `.so.1.2.3`. A derived library directory or
60+
non-library mount is omitted when an existing read-only or read-write ancestor
61+
already covers it. Writable CDI single-file mounts require an exact
62+
`filesystem_policy.read_write` opt-in, and writable CDI directory mounts fail
63+
closed. CDI resolution errors fail agent startup.
64+
65+
The `/sys` grant is a runtime-owned GPU compatibility baseline, not a
66+
CDI-derived permission. CDI specs remain unable to request broad `/sys` access,
67+
and non-GPU sandboxes do not receive the grant.
5068

5169
Landlock rules are tailored to the inode type reported by the already-opened
5270
path descriptor. Directories retain the requested directory and file rights;

‎crates/openshell-core/src/cdi.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ pub enum CdiError {
105105
WritableMountNotFile { path: String, kind: String },
106106
#[error("CDI device node '{path}' must target a character or block device, found {kind}")]
107107
DeviceNodeNotDevice { path: String, kind: String },
108+
#[error("CDI read-only path '{path}' does not exist in the workload namespace")]
109+
ReadOnlyPathMissing { path: String },
108110
#[error("CDI additionalGids must not contain root GID 0")]
109111
RootAdditionalGid,
110112
#[error("CDI mount '{path}' has conflicting ro/rw options")]

‎crates/openshell-core/src/cdi_linux.rs‎

Lines changed: 43 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,11 @@ where
183183
});
184184
}
185185
}
186+
for path in &requirements.read_only_paths {
187+
if path_kind(path).is_none() {
188+
return Err(CdiError::ReadOnlyPathMissing { path: path.clone() });
189+
}
190+
}
186191
for path in &requirements.read_write_mount_paths {
187192
if !normalized_allowlist.contains(path) {
188193
return Err(CdiError::WritableMountNotAllowed { path: path.clone() });
@@ -445,12 +450,8 @@ mod tests {
445450
let dir = tempfile::tempdir().unwrap();
446451
write_spec(dir.path(), "nvidia.yaml", spec);
447452

448-
let requirements = resolve_with_kind(
449-
&context(dir.path(), &["nvidia.com/gpu=all"]),
450-
&[],
451-
fake_device_node,
452-
)
453-
.unwrap();
453+
let requirements =
454+
resolve_cdi_context(&context(dir.path(), &["nvidia.com/gpu=all"])).unwrap();
454455
let baseline = CdiRequirementsBaseline {
455456
device_node_paths: &requirements.device_node_paths,
456457
read_only_paths: &requirements.read_only_paths,
@@ -507,12 +508,8 @@ devices:
507508
"#,
508509
);
509510

510-
let requirements = resolve_with_kind(
511-
&context(dir.path(), &["nvidia.com/gpu=0"]),
512-
&[],
513-
fake_device_node,
514-
)
515-
.unwrap();
511+
let requirements =
512+
resolve_cdi_context(&context(dir.path(), &["nvidia.com/gpu=0"])).unwrap();
516513

517514
assert_eq!(
518515
requirements.device_node_paths,
@@ -540,12 +537,8 @@ devices:
540537
",
541538
);
542539

543-
let requirements = resolve_with_kind(
544-
&context(dir.path(), &["nvidia.com/gpu=all"]),
545-
&[],
546-
fake_device_node,
547-
)
548-
.unwrap();
540+
let requirements =
541+
resolve_cdi_context(&context(dir.path(), &["nvidia.com/gpu=all"])).unwrap();
549542

550543
assert_eq!(
551544
requirements.device_node_paths,
@@ -573,12 +566,8 @@ devices:
573566
",
574567
);
575568

576-
let requirements = resolve_with_kind(
577-
&context(dir.path(), &["nvidia.com/gpu=all"]),
578-
&[],
579-
fake_device_node,
580-
)
581-
.unwrap();
569+
let requirements =
570+
resolve_cdi_context(&context(dir.path(), &["nvidia.com/gpu=all"])).unwrap();
582571

583572
assert_eq!(requirements.device_node_paths, vec!["/dev/dxg"]);
584573
assert_eq!(requirements.read_only_paths, vec!["/usr/lib/wsl/lib"]);
@@ -618,12 +607,8 @@ devices:
618607
"#,
619608
);
620609

621-
let requirements = resolve_with_kind(
622-
&context(dir.path(), &["nvidia.com/gpu=0"]),
623-
&[],
624-
always_missing,
625-
)
626-
.unwrap();
610+
let requirements =
611+
resolve_cdi_context(&context(dir.path(), &["nvidia.com/gpu=0"])).unwrap();
627612

628613
assert_eq!(
629614
requirements.read_only_paths,
@@ -660,12 +645,8 @@ devices:
660645
"#,
661646
);
662647

663-
let requirements = resolve_with_kind(
664-
&context(dir.path(), &["nvidia.com/gpu=0"]),
665-
&[],
666-
fake_device_node,
667-
)
668-
.unwrap();
648+
let requirements =
649+
resolve_cdi_context(&context(dir.path(), &["nvidia.com/gpu=0"])).unwrap();
669650

670651
assert_eq!(requirements.additional_gids, vec![44]);
671652
assert_eq!(
@@ -950,6 +931,32 @@ devices:
950931
assert!(matches!(err, CdiError::RootAdditionalGid));
951932
}
952933

934+
#[test]
935+
fn validates_read_only_paths_in_the_workload_namespace() {
936+
let requirements = CdiDerivedRequirements {
937+
read_only_paths: vec!["/opt/nvidia/runtime.json".to_string()],
938+
..CdiDerivedRequirements::default()
939+
};
940+
let writable_file_allowlist = HashSet::<String>::new();
941+
942+
let err = validate_cdi_requirements_with_path_kind(
943+
&requirements,
944+
&writable_file_allowlist,
945+
always_missing,
946+
)
947+
.unwrap_err();
948+
assert!(matches!(
949+
err,
950+
CdiError::ReadOnlyPathMissing { path }
951+
if path == "/opt/nvidia/runtime.json"
952+
));
953+
954+
validate_cdi_requirements_with_path_kind(&requirements, &writable_file_allowlist, |_| {
955+
Some(CdiPathKind::File)
956+
})
957+
.unwrap();
958+
}
959+
953960
#[test]
954961
fn rejects_device_node_that_is_not_device() {
955962
let dir = tempfile::tempdir().unwrap();

‎crates/openshell-core/src/policy.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ pub struct ProcessPolicy {
8383

8484
/// Group name to run the sandboxed process as.
8585
pub run_as_group: Option<String>,
86+
87+
/// Linux supplemental groups to apply before dropping privileges.
88+
///
89+
/// Runtime-specific inputs can use different terminology; CDI
90+
/// `additionalGids` are converted into this process-level representation.
91+
pub supplemental_groups: Vec<u32>,
8692
}
8793

8894
#[derive(Debug, Clone, Default)]
@@ -185,6 +191,7 @@ impl From<ProtoProcessPolicy> for ProcessPolicy {
185191
} else {
186192
Some(proto.run_as_group)
187193
},
194+
supplemental_groups: Vec::new(),
188195
}
189196
}
190197
}

‎crates/openshell-driver-docker/src/isolation.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ impl DockerBoundarySpec {
118118
},
119119
resource_claims: resource_claims.clone(),
120120
resource_claim_files: BTreeMap::new(),
121+
cdi_context: None,
121122
workload_identity: self.workload_identity.clone(),
122123
outer_fence: outer_fence.clone(),
123124
child_env: self.child_env,

‎crates/openshell-driver-kubernetes/src/isolation.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ impl KubernetesSandboxRuntimeBoundarySpec {
274274
"kubernetes.workload_pod_uid".to_string(),
275275
self.workload_pod_uid_path,
276276
)]),
277+
cdi_context: None,
277278
workload_identity: self.workload_identity.clone(),
278279
outer_fence: outer_fence.clone(),
279280
child_env: self.child_env,

‎crates/openshell-driver-podman/src/isolation.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,7 @@ pub fn bootstrap_archives(
262262
},
263263
resource_claims: resource_claims.clone(),
264264
resource_claim_files: BTreeMap::new(),
265+
cdi_context: None,
265266
workload_identity: identity.clone(),
266267
outer_fence: outer_fence.clone(),
267268
child_env: child_env.clone(),

‎crates/openshell-driver-vm/src/isolation/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ impl VmBoundarySpec {
118118
},
119119
resource_claims: resource_claims.clone(),
120120
resource_claim_files: BTreeMap::new(),
121+
cdi_context: None,
121122
workload_identity: workload_identity.clone(),
122123
outer_fence: outer_fence.clone(),
123124
child_env: self.child_env,

‎crates/openshell-sandbox-backend/src/boundary_protocol.rs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,12 @@ pub struct BoundaryConfig {
493493
/// Downward API. Other drivers may leave the map empty.
494494
#[serde(default)]
495495
pub resource_claim_files: std::collections::BTreeMap<String, PathBuf>,
496+
/// Driver-protected CDI selection and workload-local specification projections.
497+
///
498+
/// The sandbox runtime resolves this context inside the workload mount
499+
/// namespace before applying launch-time filesystem controls.
500+
#[serde(default)]
501+
pub cdi_context: Option<openshell_core::cdi::CdiContext>,
496502
/// Exact identity already applied by the runtime to the sandbox process.
497503
pub workload_identity: openshell_isolation_interface::contract::ResolvedWorkloadIdentity,
498504
/// Backend-neutral projection of the validated outer network fence.
@@ -523,6 +529,7 @@ impl fmt::Debug for BoundaryConfig {
523529
.field("listener", &self.listener)
524530
.field("resource_claims", &self.resource_claims)
525531
.field("resource_claim_files", &self.resource_claim_files)
532+
.field("has_cdi_context", &self.cdi_context.is_some())
526533
.field("workload_identity", &self.workload_identity)
527534
.field("outer_fence", &self.outer_fence)
528535
.field("child_env_keys", &self.child_env.keys().collect::<Vec<_>>())
@@ -1114,6 +1121,8 @@ pub struct SandboxPolicyWire {
11141121
pub landlock: LandlockCompatibilityWire,
11151122
pub run_as_user: Option<String>,
11161123
pub run_as_group: Option<String>,
1124+
#[serde(default)]
1125+
pub supplemental_groups: Vec<u32>,
11171126
}
11181127

11191128
impl From<SandboxPolicy> for SandboxPolicyWire {
@@ -1138,6 +1147,7 @@ impl From<SandboxPolicy> for SandboxPolicyWire {
11381147
let ProcessPolicy {
11391148
run_as_user,
11401149
run_as_group,
1150+
supplemental_groups,
11411151
} = process;
11421152
Self {
11431153
version,
@@ -1149,6 +1159,7 @@ impl From<SandboxPolicy> for SandboxPolicyWire {
11491159
landlock: LandlockCompatibilityWire::from(compatibility),
11501160
run_as_user,
11511161
run_as_group,
1162+
supplemental_groups,
11521163
}
11531164
}
11541165
}
@@ -1175,6 +1186,7 @@ impl From<SandboxPolicyWire> for SandboxPolicy {
11751186
process: ProcessPolicy {
11761187
run_as_user: policy.run_as_user,
11771188
run_as_group: policy.run_as_group,
1189+
supplemental_groups: policy.supplemental_groups,
11781190
},
11791191
}
11801192
}

0 commit comments

Comments
 (0)