Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions architecture/security-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,30 @@ before any consumer-specific projection runs. There is no permissive parsing
profile: unsupported policy fields always invalidate the document. Middleware `config`, query and persisted-query names, and recursive MCP
parameter names are open user-data maps rather than schema extensions.

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/<pid>/task/<tid>/comm`.
Before applying Landlock, trusted runtime components enrich baseline filesystem
paths that the workload needs. Missing optional baseline paths are skipped so
one absent runtime path does not weaken the whole ruleset. The workload-side
sandbox runtime performs GPU enrichment in the workload mount namespace. GPU
sandboxes without CDI context use the legacy device baseline; CDI sandboxes use
requirements derived from the selected CDI specs. Both paths grant read-only
access to the existing container sysfs view and promote `/proc` to read-write.

A compute driver can place a protected CDI context and read-only CDI spec
projection in the workload boundary. Before agent exec, `openshell-sandbox`
resolves the selected CDI IDs and validates all derived entities against the
workload namespace. It adds device nodes, exact destinations of non-library
read-only mounts, parent directories of shared-library mounts, and supplemental
GIDs. CDI host paths are ignored for policy. Library filenames must 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`. A derived library directory or
non-library mount is omitted when an existing read-only or read-write ancestor
already covers it. Writable CDI single-file mounts require an exact
`filesystem_policy.read_write` opt-in, and writable CDI directory mounts fail
closed. CDI resolution errors fail agent startup.

The `/sys` grant is a runtime-owned GPU compatibility baseline, not a
CDI-derived permission. CDI specs remain unable to request broad `/sys` access,
and non-GPU sandboxes do not receive the grant.

Landlock rules are tailored to the inode type reported by the already-opened
path descriptor. Directories retain the requested directory and file rights;
Expand Down
2 changes: 2 additions & 0 deletions crates/openshell-core/src/cdi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
79 changes: 43 additions & 36 deletions crates/openshell-core/src/cdi_linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() });
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"]);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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::<String>::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();
Expand Down
7 changes: 7 additions & 0 deletions crates/openshell-core/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ pub struct ProcessPolicy {

/// Group name to run the sandboxed process as.
pub run_as_group: Option<String>,

/// 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<u32>,
}

#[derive(Debug, Clone, Default)]
Expand Down Expand Up @@ -185,6 +191,7 @@ impl From<ProtoProcessPolicy> for ProcessPolicy {
} else {
Some(proto.run_as_group)
},
supplemental_groups: Vec::new(),
}
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-driver-docker/src/isolation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-driver-kubernetes/src/isolation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-driver-podman/src/isolation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-driver-vm/src/isolation/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions crates/openshell-sandbox-backend/src/boundary_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,12 @@ pub struct BoundaryConfig {
/// Downward API. Other drivers may leave the map empty.
#[serde(default)]
pub resource_claim_files: std::collections::BTreeMap<String, PathBuf>,
/// 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<openshell_core::cdi::CdiContext>,
/// 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.
Expand Down Expand Up @@ -537,6 +543,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::<Vec<_>>())
Expand Down Expand Up @@ -1149,6 +1156,8 @@ pub struct SandboxPolicyWire {
pub landlock: LandlockCompatibilityWire,
pub run_as_user: Option<String>,
pub run_as_group: Option<String>,
#[serde(default)]
pub supplemental_groups: Vec<u32>,
}

impl From<SandboxPolicy> for SandboxPolicyWire {
Expand All @@ -1173,6 +1182,7 @@ impl From<SandboxPolicy> for SandboxPolicyWire {
let ProcessPolicy {
run_as_user,
run_as_group,
supplemental_groups,
} = process;
Self {
version,
Expand All @@ -1184,6 +1194,7 @@ impl From<SandboxPolicy> for SandboxPolicyWire {
landlock: LandlockCompatibilityWire::from(compatibility),
run_as_user,
run_as_group,
supplemental_groups,
}
}
}
Expand All @@ -1210,6 +1221,7 @@ impl From<SandboxPolicyWire> for SandboxPolicy {
process: ProcessPolicy {
run_as_user: policy.run_as_user,
run_as_group: policy.run_as_group,
supplemental_groups: policy.supplemental_groups,
},
}
}
Expand Down
Loading
Loading