From 79eaa0b7048bce1cedd4910684c0d3afd4ffff26 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Fri, 29 May 2026 15:41:56 +0200 Subject: [PATCH 1/2] feat(policy): add runtime baseline conflict controls Signed-off-by: Evan Lezar --- Cargo.lock | 1 + architecture/security-policy.md | 26 +- crates/openshell-policy/Cargo.toml | 1 + crates/openshell-policy/src/lib.rs | 208 ++++++- crates/openshell-sandbox/src/lib.rs | 528 ++++++++++++++---- crates/openshell-sandbox/src/opa.rs | 91 ++- crates/openshell-sandbox/src/policy.rs | 39 ++ crates/openshell-server/src/grpc/policy.rs | 1 + .../openshell-server/src/grpc/validation.rs | 51 ++ docs/reference/policy-schema.mdx | 22 + docs/sandboxes/policies.mdx | 4 + proto/sandbox.proto | 18 + 12 files changed, 885 insertions(+), 105 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4bc657be3..b43b2dba6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3589,6 +3589,7 @@ dependencies = [ name = "openshell-policy" version = "0.0.0" dependencies = [ + "glob", "miette", "openshell-core", "serde", diff --git a/architecture/security-policy.md b/architecture/security-policy.md index b7f5262e6..66ce7aa01 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -21,12 +21,26 @@ For the field-by-field YAML reference, use Filesystem and process policy are startup-time controls. Network policy is 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`. +## Filesystem Baseline + +The supervisor enriches filesystem policy at startup with OpenShell runtime +baseline paths required by proxy mode and optional runtime features such as GPU +support. Baseline paths are only added if they exist in the sandbox image, which +prevents a missing baseline path from causing the whole Landlock ruleset to be +skipped under best-effort mode. + +`filesystem_policy.runtime_baseline_conflicts` controls how OpenShell resolves +conflicts between runtime baseline requirements and the effective filesystem +policy. The current conflict policy is `read_only_to_read_write`, where the +default is equivalent to `mode: reject_unlisted` with +`allow_promotion: [/proc]`: `/proc` may be promoted from read-only to +read-write for GPU runtime needs because CUDA workloads write thread metadata +under `/proc//task//comm`, while device-node conflicts are rejected +unless the policy explicitly allows a matching promotion pattern or sets `mode: +promote_all`. `mode: reject_all` disables promotion entirely. + +The promotion allow list is not an access grant by itself. It only applies to +paths that are already part of the active OpenShell runtime baseline. ## Network Decisions diff --git a/crates/openshell-policy/Cargo.toml b/crates/openshell-policy/Cargo.toml index 8936b85be..98974cda2 100644 --- a/crates/openshell-policy/Cargo.toml +++ b/crates/openshell-policy/Cargo.toml @@ -16,6 +16,7 @@ serde = { workspace = true } serde_json = { workspace = true } serde_yml = { workspace = true } miette = { workspace = true } +glob = { workspace = true } [lints] workspace = true diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index 26c8fc9d3..ce73384e8 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -20,7 +20,7 @@ use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_core::proto::{ FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7QueryMatcher, L7Rule, LandlockPolicy, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, ProcessPolicy, - SandboxPolicy, + ReadOnlyToReadWriteConflictPolicy, RuntimeBaselineConflicts, SandboxPolicy, }; use serde::{Deserialize, Serialize}; @@ -30,6 +30,11 @@ pub use merge::{ merge_policy, policy_covers_rule, }; +pub const RUNTIME_BASELINE_CONFLICT_MODE_REJECT_UNLISTED: &str = "reject_unlisted"; +pub const RUNTIME_BASELINE_CONFLICT_MODE_PROMOTE_ALL: &str = "promote_all"; +pub const RUNTIME_BASELINE_CONFLICT_MODE_REJECT_ALL: &str = "reject_all"; +pub const DEFAULT_RUNTIME_BASELINE_ALLOW_PROMOTION: &[&str] = &["/proc"]; + // --------------------------------------------------------------------------- // YAML serde types (canonical — used for both parsing and serialization) // --------------------------------------------------------------------------- @@ -57,6 +62,24 @@ struct FilesystemDef { read_only: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] read_write: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + runtime_baseline_conflicts: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct RuntimeBaselineConflictsDef { + #[serde(default, skip_serializing_if = "Option::is_none")] + read_only_to_read_write: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ReadOnlyToReadWriteConflictPolicyDef { + #[serde(default, skip_serializing_if = "String::is_empty")] + mode: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + allow_promotion: Vec, } #[derive(Debug, Serialize, Deserialize)] @@ -369,6 +392,9 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy { include_workdir: fs.include_workdir, read_only: fs.read_only, read_write: fs.read_write, + runtime_baseline_conflicts: fs + .runtime_baseline_conflicts + .map(runtime_baseline_conflicts_to_proto), }), landlock: raw.landlock.map(|ll| LandlockPolicy { compatibility: ll.compatibility, @@ -381,6 +407,32 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy { } } +fn runtime_baseline_conflicts_to_proto( + conflicts: RuntimeBaselineConflictsDef, +) -> RuntimeBaselineConflicts { + RuntimeBaselineConflicts { + read_only_to_read_write: conflicts.read_only_to_read_write.map(|policy| { + ReadOnlyToReadWriteConflictPolicy { + mode: policy.mode, + allow_promotion: policy.allow_promotion, + } + }), + } +} + +fn runtime_baseline_conflicts_from_proto( + conflicts: &RuntimeBaselineConflicts, +) -> RuntimeBaselineConflictsDef { + RuntimeBaselineConflictsDef { + read_only_to_read_write: conflicts.read_only_to_read_write.as_ref().map(|policy| { + ReadOnlyToReadWriteConflictPolicyDef { + mode: policy.mode.clone(), + allow_promotion: policy.allow_promotion.clone(), + } + }), + } +} + // --------------------------------------------------------------------------- // Proto → YAML conversion // --------------------------------------------------------------------------- @@ -390,6 +442,10 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile { include_workdir: fs.include_workdir, read_only: fs.read_only.clone(), read_write: fs.read_write.clone(), + runtime_baseline_conflicts: fs + .runtime_baseline_conflicts + .as_ref() + .map(runtime_baseline_conflicts_from_proto), }); let landlock = policy.landlock.as_ref().map(|ll| LandlockDef { @@ -640,6 +696,7 @@ pub fn restrictive_default_policy() -> SandboxPolicy { "/var/log".into(), ], read_write: vec!["/sandbox".into(), "/tmp".into(), "/dev/null".into()], + runtime_baseline_conflicts: None, }), landlock: Some(LandlockPolicy { compatibility: "best_effort".into(), @@ -694,6 +751,10 @@ pub enum PolicyViolation { TooManyPaths { count: usize }, /// A network endpoint uses a TLD wildcard (e.g. `*.com`). TldWildcard { policy_name: String, host: String }, + /// Runtime baseline read-only conflict mode is not recognized. + InvalidRuntimeBaselineConflictMode { value: String }, + /// Runtime baseline promotion pattern is invalid. + InvalidRuntimeBaselinePromotionPattern { pattern: String, reason: String }, } impl fmt::Display for PolicyViolation { @@ -730,6 +791,21 @@ impl fmt::Display for PolicyViolation { use subdomain wildcards like '*.example.com' instead" ) } + Self::InvalidRuntimeBaselineConflictMode { value } => { + write!( + f, + "runtime baseline read_only_to_read_write mode must be one of \ + '{RUNTIME_BASELINE_CONFLICT_MODE_REJECT_UNLISTED}', \ + '{RUNTIME_BASELINE_CONFLICT_MODE_PROMOTE_ALL}', or \ + '{RUNTIME_BASELINE_CONFLICT_MODE_REJECT_ALL}', got '{value}'" + ) + } + Self::InvalidRuntimeBaselinePromotionPattern { pattern, reason } => { + write!( + f, + "runtime baseline promotion pattern is invalid: {pattern} ({reason})" + ) + } } } } @@ -747,6 +823,8 @@ impl fmt::Display for PolicyViolation { /// - Read-write paths must not be overly broad (just `/`) /// - Individual path lengths must not exceed [`MAX_PATH_LENGTH`] /// - Total path count must not exceed [`MAX_FILESYSTEM_PATHS`] +/// - Runtime baseline conflict controls must use known modes and absolute +/// promotion patterns without `..` /// - Network endpoint hosts must not use TLD wildcards (e.g. `*.com`) pub fn validate_sandbox_policy( policy: &SandboxPolicy, @@ -815,6 +893,12 @@ pub fn validate_sandbox_policy( }); } } + + if let Some(conflicts) = &fs.runtime_baseline_conflicts + && let Some(policy) = &conflicts.read_only_to_read_write + { + validate_runtime_baseline_conflict_policy(policy, &mut violations); + } } // Check network policy endpoint hosts for TLD wildcards. @@ -844,6 +928,55 @@ pub fn validate_sandbox_policy( } } +fn validate_runtime_baseline_conflict_policy( + policy: &ReadOnlyToReadWriteConflictPolicy, + violations: &mut Vec, +) { + let mode = policy.mode.as_str(); + if !mode.is_empty() + && mode != RUNTIME_BASELINE_CONFLICT_MODE_REJECT_UNLISTED + && mode != RUNTIME_BASELINE_CONFLICT_MODE_PROMOTE_ALL + && mode != RUNTIME_BASELINE_CONFLICT_MODE_REJECT_ALL + { + violations.push(PolicyViolation::InvalidRuntimeBaselineConflictMode { + value: policy.mode.clone(), + }); + } + + for pattern in &policy.allow_promotion { + if pattern.is_empty() { + violations.push(PolicyViolation::InvalidRuntimeBaselinePromotionPattern { + pattern: pattern.clone(), + reason: "pattern must not be empty".to_string(), + }); + continue; + } + + let path = Path::new(pattern); + if !path.has_root() { + violations.push(PolicyViolation::InvalidRuntimeBaselinePromotionPattern { + pattern: pattern.clone(), + reason: "pattern must be absolute".to_string(), + }); + } + if path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + violations.push(PolicyViolation::InvalidRuntimeBaselinePromotionPattern { + pattern: pattern.clone(), + reason: "pattern must not contain '..' components".to_string(), + }); + } + if let Err(error) = glob::Pattern::new(pattern) { + violations.push(PolicyViolation::InvalidRuntimeBaselinePromotionPattern { + pattern: pattern.clone(), + reason: error.to_string(), + }); + } + } +} + /// Truncate a string for safe inclusion in error messages. fn truncate_for_display(s: &str) -> String { if s.len() <= 80 { @@ -976,6 +1109,38 @@ network_policies: assert_eq!(proto2.network_policies["my_api"].name, "my-custom-api-name"); } + #[test] + fn round_trip_preserves_runtime_baseline_conflicts() { + let yaml = r#" +version: 1 +filesystem_policy: + include_workdir: true + read_only: [/usr, /proc] + read_write: [/sandbox, /tmp] + runtime_baseline_conflicts: + read_only_to_read_write: + mode: reject_unlisted + allow_promotion: + - /proc + - "/dev/nvidia*" +"#; + let proto1 = parse_sandbox_policy(yaml).expect("parse failed"); + let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed"); + let proto2 = parse_sandbox_policy(&yaml_out).expect("re-parse failed"); + + let conflicts = proto2 + .filesystem + .as_ref() + .and_then(|fs| fs.runtime_baseline_conflicts.as_ref()) + .and_then(|conflicts| conflicts.read_only_to_read_write.as_ref()) + .expect("runtime conflict policy"); + assert_eq!( + conflicts.mode, + RUNTIME_BASELINE_CONFLICT_MODE_REJECT_UNLISTED + ); + assert_eq!(conflicts.allow_promotion, vec!["/proc", "/dev/nvidia*"]); + } + #[test] fn restrictive_default_has_no_network_policies() { let policy = restrictive_default_policy(); @@ -1207,6 +1372,7 @@ network_policies: include_workdir: true, read_only: vec!["/usr/../etc/shadow".into()], read_write: vec!["/tmp".into()], + ..Default::default() }); let violations = validate_sandbox_policy(&policy).unwrap_err(); assert!( @@ -1223,6 +1389,7 @@ network_policies: include_workdir: true, read_only: vec!["usr/lib".into()], read_write: vec!["/tmp".into()], + ..Default::default() }); let violations = validate_sandbox_policy(&policy).unwrap_err(); assert!( @@ -1239,6 +1406,7 @@ network_policies: include_workdir: true, read_only: vec!["/usr".into()], read_write: vec!["/".into()], + ..Default::default() }); let violations = validate_sandbox_policy(&policy).unwrap_err(); assert!( @@ -1285,6 +1453,7 @@ network_policies: include_workdir: true, read_only: many_paths, read_write: vec!["/tmp".into()], + ..Default::default() }); let violations = validate_sandbox_policy(&policy).unwrap_err(); assert!( @@ -1294,6 +1463,42 @@ network_policies: ); } + #[test] + fn validate_rejects_invalid_runtime_baseline_conflict_mode() { + let mut policy = restrictive_default_policy(); + let fs = policy.filesystem.as_mut().expect("filesystem policy"); + fs.runtime_baseline_conflicts = Some(RuntimeBaselineConflicts { + read_only_to_read_write: Some(ReadOnlyToReadWriteConflictPolicy { + mode: "ask".into(), + allow_promotion: vec!["/proc".into()], + }), + }); + + let violations = validate_sandbox_policy(&policy).unwrap_err(); + assert!(violations.iter().any(|v| matches!( + v, + PolicyViolation::InvalidRuntimeBaselineConflictMode { .. } + ))); + } + + #[test] + fn validate_rejects_invalid_runtime_baseline_promotion_pattern() { + let mut policy = restrictive_default_policy(); + let fs = policy.filesystem.as_mut().expect("filesystem policy"); + fs.runtime_baseline_conflicts = Some(RuntimeBaselineConflicts { + read_only_to_read_write: Some(ReadOnlyToReadWriteConflictPolicy { + mode: RUNTIME_BASELINE_CONFLICT_MODE_REJECT_UNLISTED.into(), + allow_promotion: vec!["dev/nvidia*".into(), "/proc/../etc".into()], + }), + }); + + let violations = validate_sandbox_policy(&policy).unwrap_err(); + assert!(violations.iter().any(|v| matches!( + v, + PolicyViolation::InvalidRuntimeBaselinePromotionPattern { .. } + ))); + } + #[test] fn validate_rejects_path_too_long() { let mut policy = restrictive_default_policy(); @@ -1302,6 +1507,7 @@ network_policies: include_workdir: true, read_only: vec![long_path], read_write: vec!["/tmp".into()], + ..Default::default() }); let violations = validate_sandbox_policy(&policy).unwrap_err(); assert!( diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index e9d8921b6..84935ad28 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -175,7 +175,7 @@ use crate::l7::tls::{ write_ca_files, }; use crate::opa::OpaEngine; -use crate::policy::{NetworkMode, NetworkPolicy, ProxyPolicy, SandboxPolicy}; +use crate::policy::{FilesystemPolicy, NetworkMode, NetworkPolicy, ProxyPolicy, SandboxPolicy}; use crate::proxy::ProxyHandle; #[cfg(target_os = "linux")] use crate::sandbox::linux::netns::NetworkNamespace; @@ -1523,6 +1523,44 @@ fn enumerate_gpu_device_nodes() -> Vec { paths } +#[derive(Debug, Clone)] +struct BaselineEnrichmentPaths { + read_only: Vec, + read_write: Vec, +} + +impl BaselineEnrichmentPaths { + fn is_empty(&self) -> bool { + self.read_only.is_empty() && self.read_write.is_empty() + } +} + +#[derive(Debug, Clone)] +struct RuntimeReadOnlyConflictPolicy { + mode: String, + allow_promotion: Vec, +} + +// Omitted policy is equivalent to: +// +// filesystem_policy: +// runtime_baseline_conflicts: +// read_only_to_read_write: +// mode: reject_unlisted +// allow_promotion: +// - /proc +impl Default for RuntimeReadOnlyConflictPolicy { + fn default() -> Self { + Self { + mode: openshell_policy::RUNTIME_BASELINE_CONFLICT_MODE_REJECT_UNLISTED.to_string(), + allow_promotion: openshell_policy::DEFAULT_RUNTIME_BASELINE_ALLOW_PROMOTION + .iter() + .map(|path| (*path).to_string()) + .collect(), + } + } +} + fn push_unique(paths: &mut Vec, path: String) { if !paths.iter().any(|p| p == &path) { paths.push(path); @@ -1533,40 +1571,43 @@ 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(); +) -> BaselineEnrichmentPaths { + let mut read_only = Vec::new(); + let mut read_write = Vec::new(); if include_proxy { for &path in PROXY_BASELINE_READ_ONLY { - push_unique(&mut ro, path.to_string()); + push_unique(&mut read_only, path.to_string()); } for &path in PROXY_BASELINE_READ_WRITE { - push_unique(&mut rw, path.to_string()); + push_unique(&mut read_write, path.to_string()); } } if include_gpu { for &path in GPU_BASELINE_READ_ONLY { - push_unique(&mut ro, path.to_string()); + push_unique(&mut read_only, path.to_string()); } for &path in GPU_BASELINE_READ_WRITE { - push_unique(&mut rw, path.to_string()); + push_unique(&mut read_write, path.to_string()); } for path in gpu_device_nodes { - push_unique(&mut rw, path); + push_unique(&mut read_write, path); } } // 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)); + read_only.retain(|p| !read_write.contains(p)); - (ro, rw) + BaselineEnrichmentPaths { + read_only, + read_write, + } } -fn active_baseline_enrichment_paths(include_proxy: bool) -> (Vec, Vec) { +fn active_baseline_enrichment_paths(include_proxy: bool) -> BaselineEnrichmentPaths { let include_gpu = has_gpu_devices(); let gpu_device_nodes = if include_gpu { enumerate_gpu_device_nodes() @@ -1580,22 +1621,87 @@ fn active_baseline_enrichment_paths(include_proxy: bool) -> (Vec, Vec (Vec, Vec) { - active_baseline_enrichment_paths(true) + let paths = active_baseline_enrichment_paths(true); + (paths.read_only, paths.read_write) +} + +fn effective_runtime_read_only_conflict_policy_from_proto( + fs: Option<&openshell_core::proto::FilesystemPolicy>, +) -> RuntimeReadOnlyConflictPolicy { + let Some(policy) = fs + .and_then(|fs| fs.runtime_baseline_conflicts.as_ref()) + .and_then(|conflicts| conflicts.read_only_to_read_write.as_ref()) + else { + return RuntimeReadOnlyConflictPolicy::default(); + }; + + RuntimeReadOnlyConflictPolicy { + mode: if policy.mode.is_empty() { + openshell_policy::RUNTIME_BASELINE_CONFLICT_MODE_REJECT_UNLISTED.to_string() + } else { + policy.mode.clone() + }, + allow_promotion: policy.allow_promotion.clone(), + } +} + +fn effective_runtime_read_only_conflict_policy_from_local( + fs: &FilesystemPolicy, +) -> RuntimeReadOnlyConflictPolicy { + let Some(policy) = fs + .runtime_baseline_conflicts + .as_ref() + .and_then(|conflicts| conflicts.read_only_to_read_write.as_ref()) + else { + return RuntimeReadOnlyConflictPolicy::default(); + }; + + RuntimeReadOnlyConflictPolicy { + mode: if policy.mode.is_empty() { + openshell_policy::RUNTIME_BASELINE_CONFLICT_MODE_REJECT_UNLISTED.to_string() + } else { + policy.mode.clone() + }, + allow_promotion: policy.allow_promotion.clone(), + } +} + +fn promotion_allowed(policy: &RuntimeReadOnlyConflictPolicy, path: &str) -> bool { + match policy.mode.as_str() { + openshell_policy::RUNTIME_BASELINE_CONFLICT_MODE_PROMOTE_ALL => true, + openshell_policy::RUNTIME_BASELINE_CONFLICT_MODE_REJECT_ALL => false, + _ => policy + .allow_promotion + .iter() + .any(|pattern| glob::Pattern::new(pattern).is_ok_and(|glob| glob.matches(path))), + } +} + +fn runtime_baseline_read_write_conflict(path: &str) -> miette::Report { + miette::miette!( + "Runtime baseline requires path '{path}' to be read-write, \ + but the effective policy lists it as read-only. \ + Move '{path}' from filesystem_policy.read_only to filesystem_policy.read_write, \ + or allow promotion with \ + filesystem_policy.runtime_baseline_conflicts.read_only_to_read_write.allow_promotion." + ) } fn enrich_proto_baseline_paths_with( proto: &mut openshell_core::proto::SandboxPolicy, - ro: &[String], - rw: &[String], + paths: &BaselineEnrichmentPaths, + conflict_policy: &RuntimeReadOnlyConflictPolicy, path_exists: F, -) -> bool +) -> Result where F: Fn(&str) -> bool, { - if ro.is_empty() && rw.is_empty() { - return false; + if paths.is_empty() { + return Ok(false); } + let mut modified = false; + let fs = proto .filesystem .get_or_insert_with(|| openshell_core::proto::FilesystemPolicy { @@ -1603,8 +1709,7 @@ where ..Default::default() }); - let mut modified = false; - for path in ro { + for path in &paths.read_only { if !fs.read_only.iter().any(|p| p == path) && !fs.read_write.iter().any(|p| p == path) { if !path_exists(path) { debug!( @@ -1617,10 +1722,21 @@ where modified = true; } } - for path in rw { + for path in &paths.read_write { if fs.read_write.iter().any(|p| p == path) { continue; } + + let read_only_conflict = fs.read_only.iter().position(|p| p == path); + if let Some(index) = read_only_conflict { + if promotion_allowed(conflict_policy, path) { + fs.read_only.remove(index); + fs.read_write.push(path.clone()); + modified = true; + continue; + } + return Err(runtime_baseline_read_write_conflict(path)); + } if !path_exists(path) { debug!( path, @@ -1628,23 +1744,11 @@ where ); continue; } - 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; - } - continue; - } fs.read_write.push(path.clone()); modified = true; } - modified + Ok(modified) } /// Ensure a proto `SandboxPolicy` includes the baseline filesystem paths @@ -1652,17 +1756,19 @@ where /// missing; user-specified paths are never removed. /// /// 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()); +fn enrich_proto_baseline_paths(proto: &mut openshell_core::proto::SandboxPolicy) -> Result { + let paths = active_baseline_enrichment_paths(!proto.network_policies.is_empty()); + let conflict_policy = + effective_runtime_read_only_conflict_policy_from_proto(proto.filesystem.as_ref()); // 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| { + let modified = enrich_proto_baseline_paths_with(proto, &paths, &conflict_policy, |path| { std::path::Path::new(path).exists() - }); + })?; if modified { ocsf_emit!( @@ -1675,7 +1781,7 @@ fn enrich_proto_baseline_paths(proto: &mut openshell_core::proto::SandboxPolicy) ); } - modified + Ok(modified) } /// Ensure a `SandboxPolicy` (Rust type) includes the baseline filesystem @@ -1683,22 +1789,22 @@ fn enrich_proto_baseline_paths(proto: &mut openshell_core::proto::SandboxPolicy) /// local-file code path where no proto is available. fn enrich_sandbox_baseline_paths_with( policy: &mut SandboxPolicy, - ro: &[String], - rw: &[String], + paths: &BaselineEnrichmentPaths, + conflict_policy: &RuntimeReadOnlyConflictPolicy, path_exists: F, -) -> bool +) -> Result where - F: Fn(&std::path::Path) -> bool, + F: Fn(&str) -> bool, { - if ro.is_empty() && rw.is_empty() { - return false; + if paths.is_empty() { + return Ok(false); } let mut modified = false; - for path in ro { + for path in &paths.read_only { let p = std::path::PathBuf::from(path); if !policy.filesystem.read_only.contains(&p) && !policy.filesystem.read_write.contains(&p) { - if !path_exists(&p) { + if !path_exists(path) { debug!( path, "Baseline read-only path does not exist, skipping enrichment" @@ -1709,12 +1815,27 @@ where modified = true; } } - for path in rw { + for path in &paths.read_write { let p = std::path::PathBuf::from(path); - if policy.filesystem.read_only.contains(&p) || policy.filesystem.read_write.contains(&p) { + if policy.filesystem.read_write.contains(&p) { continue; } - if !path_exists(&p) { + + if let Some(index) = policy + .filesystem + .read_only + .iter() + .position(|existing| existing == &p) + { + if promotion_allowed(conflict_policy, path) { + policy.filesystem.read_only.remove(index); + policy.filesystem.read_write.push(p); + modified = true; + continue; + } + return Err(runtime_baseline_read_write_conflict(path)); + } + if !path_exists(path) { debug!( path, "Baseline read-write path does not exist, skipping enrichment" @@ -1725,13 +1846,19 @@ where modified = true; } - modified + Ok(modified) } -fn enrich_sandbox_baseline_paths(policy: &mut SandboxPolicy) { - let (ro, rw) = - active_baseline_enrichment_paths(matches!(policy.network.mode, NetworkMode::Proxy)); - let modified = enrich_sandbox_baseline_paths_with(policy, &ro, &rw, std::path::Path::exists); +/// 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) -> Result { + let paths = active_baseline_enrichment_paths(matches!(policy.network.mode, NetworkMode::Proxy)); + let conflict_policy = + effective_runtime_read_only_conflict_policy_from_local(&policy.filesystem); + let modified = enrich_sandbox_baseline_paths_with(policy, &paths, &conflict_policy, |path| { + std::path::Path::new(path).exists() + })?; if modified { ocsf_emit!( @@ -1743,6 +1870,8 @@ fn enrich_sandbox_baseline_paths(policy: &mut SandboxPolicy) { .build() ); } + + Ok(modified) } #[cfg(test)] @@ -1823,36 +1952,36 @@ mod baseline_tests { } #[test] - fn proto_enrichment_preserves_explicit_read_only_for_baseline_read_write_paths() { + fn proto_enrichment_rejects_unlisted_read_only_conflict() { let mut policy = openshell_policy::restrictive_default_policy(); policy.filesystem = Some(openshell_core::proto::FilesystemPolicy { read_only: vec!["/tmp".to_string()], read_write: vec![], include_workdir: false, + ..Default::default() }); - policy.network_policies.insert( - "test".into(), - openshell_core::proto::NetworkPolicyRule { - name: "test-rule".into(), - endpoints: vec![openshell_core::proto::NetworkEndpoint { - host: "example.com".into(), - port: 443, - ..Default::default() - }], - ..Default::default() - }, - ); - enrich_proto_baseline_paths(&mut policy); + let paths = BaselineEnrichmentPaths { + read_only: vec![], + read_write: vec!["/tmp".to_string()], + }; + let err = enrich_proto_baseline_paths_with( + &mut policy, + &paths, + &RuntimeReadOnlyConflictPolicy::default(), + |_| true, + ) + .expect_err("unlisted read-only conflict should be rejected"); - let filesystem = policy.filesystem.expect("filesystem policy"); assert!( - filesystem.read_only.contains(&"/tmp".to_string()), - "explicit read_only baseline path should be preserved" + err.to_string() + .contains("requires path '/tmp' to be read-write"), + "unexpected error: {err}" ); + let filesystem = policy.filesystem.expect("filesystem policy"); assert!( - !filesystem.read_write.contains(&"/tmp".to_string()), - "baseline enrichment must not promote explicit read_only /tmp to read_write" + filesystem.read_only.contains(&"/tmp".to_string()), + "rejected conflict should preserve the original read_only entry" ); } @@ -1863,12 +1992,16 @@ mod baseline_tests { policy.network_policies.is_empty(), "regression setup must exercise the no-network default path" ); - let (ro, rw) = + let paths = collect_baseline_enrichment_paths(false, true, vec!["/dev/nvidia0".to_string()]); - let enriched = enrich_proto_baseline_paths_with(&mut policy, &ro, &rw, |path| { - matches!(path, "/proc" | "/dev/nvidia0") - }); + let enriched = enrich_proto_baseline_paths_with( + &mut policy, + &paths, + &RuntimeReadOnlyConflictPolicy::default(), + |path| matches!(path, "/proc" | "/dev/nvidia0"), + ) + .expect("default policy should allow /proc promotion"); let filesystem = policy.filesystem.expect("filesystem policy"); assert!( @@ -1908,6 +2041,7 @@ mod baseline_tests { read_only: vec![], read_write: vec![], include_workdir: false, + runtime_baseline_conflicts: None, }, network: NetworkPolicy { mode: NetworkMode::Block, @@ -1916,12 +2050,16 @@ mod baseline_tests { landlock: LandlockPolicy::default(), process: ProcessPolicy::default(), }; - let (ro, rw) = + let paths = collect_baseline_enrichment_paths(false, true, vec!["/dev/nvidia0".to_string()]); - let enriched = enrich_sandbox_baseline_paths_with(&mut policy, &ro, &rw, |path| { - path == std::path::Path::new("/proc") || path == std::path::Path::new("/dev/nvidia0") - }); + let enriched = enrich_sandbox_baseline_paths_with( + &mut policy, + &paths, + &RuntimeReadOnlyConflictPolicy::default(), + |path| matches!(path, "/proc" | "/dev/nvidia0"), + ) + .expect("default policy should allow /proc promotion"); assert!( enriched, @@ -1934,16 +2072,207 @@ mod baseline_tests { .contains(&std::path::PathBuf::from("/dev/nvidia0")), "GPU enrichment should add enumerated device nodes without proxy mode" ); + assert!( + policy + .filesystem + .read_write + .contains(&std::path::PathBuf::from("/proc")), + "GPU enrichment should add /proc as read_write without proxy mode" + ); } #[test] - fn local_enrichment_preserves_explicit_read_only_for_baseline_read_write_paths() { + fn proto_default_conflict_policy_promotes_proc() { + let mut policy = openshell_policy::restrictive_default_policy(); + policy.filesystem = Some(openshell_core::proto::FilesystemPolicy { + read_only: vec!["/proc".to_string()], + read_write: vec![], + include_workdir: false, + ..Default::default() + }); + let paths = BaselineEnrichmentPaths { + read_only: vec![], + read_write: vec!["/proc".to_string()], + }; + + let enriched = enrich_proto_baseline_paths_with( + &mut policy, + &paths, + &RuntimeReadOnlyConflictPolicy::default(), + |_| true, + ) + .expect("default policy should allow /proc promotion"); + + let filesystem = policy.filesystem.expect("filesystem policy"); + assert!(enriched); + assert!(!filesystem.read_only.contains(&"/proc".to_string())); + assert!(filesystem.read_write.contains(&"/proc".to_string())); + } + + #[test] + fn proto_default_conflict_policy_rejects_device_node() { + 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![], + include_workdir: false, + ..Default::default() + }); + let paths = BaselineEnrichmentPaths { + read_only: vec![], + read_write: vec!["/dev/nvidia0".to_string()], + }; + + let err = enrich_proto_baseline_paths_with( + &mut policy, + &paths, + &RuntimeReadOnlyConflictPolicy::default(), + |_| true, + ) + .expect_err("device node conflict should require explicit opt-in"); + + assert!( + err.to_string() + .contains("requires path '/dev/nvidia0' to be read-write"), + "unexpected error: {err}" + ); + } + + #[test] + fn proto_device_node_already_read_write_is_not_a_promotion_conflict() { + 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!["/dev/nvidia0".to_string()], + include_workdir: false, + ..Default::default() + }); + let paths = BaselineEnrichmentPaths { + read_only: vec![], + read_write: vec!["/dev/nvidia0".to_string()], + }; + + let enriched = enrich_proto_baseline_paths_with( + &mut policy, + &paths, + &RuntimeReadOnlyConflictPolicy::default(), + |_| true, + ) + .expect("path that is already read-write should not require promotion"); + + assert!(!enriched); + } + + #[test] + fn proto_reject_all_conflict_policy_rejects_proc() { + let mut policy = openshell_policy::restrictive_default_policy(); + policy.filesystem = Some(openshell_core::proto::FilesystemPolicy { + read_only: vec!["/proc".to_string()], + read_write: vec![], + include_workdir: false, + ..Default::default() + }); + let paths = BaselineEnrichmentPaths { + read_only: vec![], + read_write: vec!["/proc".to_string()], + }; + let conflict_policy = RuntimeReadOnlyConflictPolicy { + mode: openshell_policy::RUNTIME_BASELINE_CONFLICT_MODE_REJECT_ALL.to_string(), + allow_promotion: vec!["/proc".to_string()], + }; + + let err = enrich_proto_baseline_paths_with(&mut policy, &paths, &conflict_policy, |_| true) + .expect_err("reject_all should reject even allow-listed /proc"); + + assert!( + err.to_string() + .contains("requires path '/proc' to be read-write"), + "unexpected error: {err}" + ); + } + + #[test] + fn proto_promote_all_conflict_policy_promotes_device_node() { + 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![], + include_workdir: false, + ..Default::default() + }); + let paths = BaselineEnrichmentPaths { + read_only: vec![], + read_write: vec!["/dev/nvidia0".to_string()], + }; + let conflict_policy = RuntimeReadOnlyConflictPolicy { + mode: openshell_policy::RUNTIME_BASELINE_CONFLICT_MODE_PROMOTE_ALL.to_string(), + allow_promotion: vec![], + }; + + enrich_proto_baseline_paths_with(&mut policy, &paths, &conflict_policy, |_| true) + .expect("promote_all should promote device-node conflicts"); + + let filesystem = policy.filesystem.expect("filesystem policy"); + assert!(!filesystem.read_only.contains(&"/dev/nvidia0".to_string())); + assert!(filesystem.read_write.contains(&"/dev/nvidia0".to_string())); + } + + #[test] + fn proto_allow_promotion_pattern_promotes_device_node() { + let mut policy = policy_with_read_only_to_read_write_conflict_policy( + "/dev/nvidia0", + openshell_policy::RUNTIME_BASELINE_CONFLICT_MODE_REJECT_UNLISTED, + vec!["/dev/nvidia*"], + ); + let paths = BaselineEnrichmentPaths { + read_only: vec![], + read_write: vec!["/dev/nvidia0".to_string()], + }; + let conflict_policy = + effective_runtime_read_only_conflict_policy_from_proto(policy.filesystem.as_ref()); + + enrich_proto_baseline_paths_with(&mut policy, &paths, &conflict_policy, |_| true) + .expect("allow_promotion pattern should promote matching device node"); + + let filesystem = policy.filesystem.expect("filesystem policy"); + assert!(!filesystem.read_only.contains(&"/dev/nvidia0".to_string())); + assert!(filesystem.read_write.contains(&"/dev/nvidia0".to_string())); + } + + fn policy_with_read_only_to_read_write_conflict_policy( + read_only_path: &str, + mode: &str, + allow_promotion: Vec<&str>, + ) -> openshell_core::proto::SandboxPolicy { + let mut policy = openshell_policy::restrictive_default_policy(); + policy.filesystem = Some(openshell_core::proto::FilesystemPolicy { + read_only: vec![read_only_path.to_string()], + read_write: vec![], + include_workdir: false, + runtime_baseline_conflicts: Some(openshell_core::proto::RuntimeBaselineConflicts { + read_only_to_read_write: Some( + openshell_core::proto::ReadOnlyToReadWriteConflictPolicy { + mode: mode.to_string(), + allow_promotion: allow_promotion + .into_iter() + .map(ToString::to_string) + .collect(), + }, + ), + }), + }); + policy + } + + #[test] + fn local_enrichment_rejects_unlisted_read_only_conflict() { let mut policy = SandboxPolicy { version: 1, filesystem: FilesystemPolicy { read_only: vec![std::path::PathBuf::from("/tmp")], read_write: vec![], include_workdir: false, + runtime_baseline_conflicts: None, }, network: NetworkPolicy { mode: NetworkMode::Proxy, @@ -1952,22 +2281,30 @@ mod baseline_tests { landlock: LandlockPolicy::default(), process: ProcessPolicy::default(), }; + let paths = BaselineEnrichmentPaths { + read_only: vec![], + read_write: vec!["/tmp".to_string()], + }; - enrich_sandbox_baseline_paths(&mut policy); + let err = enrich_sandbox_baseline_paths_with( + &mut policy, + &paths, + &RuntimeReadOnlyConflictPolicy::default(), + |_| true, + ) + .expect_err("unlisted local read-only conflict should be rejected"); assert!( - policy - .filesystem - .read_only - .contains(&std::path::PathBuf::from("/tmp")), - "explicit read_only baseline path should be preserved" + err.to_string() + .contains("requires path '/tmp' to be read-write"), + "unexpected error: {err}" ); assert!( - !policy + policy .filesystem - .read_write + .read_only .contains(&std::path::PathBuf::from("/tmp")), - "baseline enrichment must not promote explicit read_only /tmp to read_write" + "rejected conflict should preserve the original read_only entry" ); } @@ -2106,7 +2443,7 @@ async fn load_policy( landlock: config.landlock, process: config.process, }; - enrich_sandbox_baseline_paths(&mut policy); + enrich_sandbox_baseline_paths(&mut policy)?; return Ok((policy, Some(Arc::new(engine)), None)); } @@ -2137,7 +2474,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_baseline_paths(&mut discovered)?; let sandbox = sandbox.as_deref().ok_or_else(|| { miette::miette!( "Cannot sync discovered policy: sandbox not available.\n\ @@ -2156,7 +2493,7 @@ 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 enriched = enrich_proto_baseline_paths(&mut proto_policy)?; if enriched && let Some(sandbox_name) = sandbox.as_deref() && let Err(e) = grpc_client::sync_policy(endpoint, sandbox_name, &proto_policy).await @@ -3399,6 +3736,7 @@ filesystem_policy: read_only: vec![], read_write: vec![path], include_workdir: false, + runtime_baseline_conflicts: None, }, network: NetworkPolicy::default(), landlock: LandlockPolicy::default(), diff --git a/crates/openshell-sandbox/src/opa.rs b/crates/openshell-sandbox/src/opa.rs index f73f3bc14..1c703e69a 100644 --- a/crates/openshell-sandbox/src/opa.rs +++ b/crates/openshell-sandbox/src/opa.rs @@ -7,7 +7,10 @@ //! access decisions. The engine is loaded once at sandbox startup and queried //! on every proxy CONNECT request. -use crate::policy::{FilesystemPolicy, LandlockCompatibility, LandlockPolicy, ProcessPolicy}; +use crate::policy::{ + FilesystemPolicy, LandlockCompatibility, LandlockPolicy, ProcessPolicy, + ReadOnlyToReadWriteConflictPolicy, RuntimeBaselineConflictsPolicy, +}; use miette::Result; use openshell_core::proto::SandboxPolicy as ProtoSandboxPolicy; use std::path::{Path, PathBuf}; @@ -661,6 +664,18 @@ fn get_bool(val: ®orus::Value, key: &str) -> Option { } } +/// Extract an object field from a `regorus::Value` object. +fn get_object<'a>(val: &'a regorus::Value, key: &str) -> Option<&'a regorus::Value> { + let key_val = regorus::Value::String(key.into()); + match val { + regorus::Value::Object(map) => { + let value = map.get(&key_val)?; + matches!(value, regorus::Value::Object(_)).then_some(value) + } + _ => None, + } +} + /// Extract a string array from a `regorus::Value` object field. fn get_str_array(val: ®orus::Value, key: &str) -> Vec { let key_val = regorus::Value::String(key.into()); @@ -682,6 +697,20 @@ fn get_str_array(val: ®orus::Value, key: &str) -> Vec { } } +fn parse_runtime_baseline_conflicts( + val: ®orus::Value, +) -> Option { + let conflicts = get_object(val, "runtime_baseline_conflicts")?; + Some(RuntimeBaselineConflictsPolicy { + read_only_to_read_write: get_object(conflicts, "read_only_to_read_write").map(|policy| { + ReadOnlyToReadWriteConflictPolicy { + mode: get_str(policy, "mode").unwrap_or_default(), + allow_promotion: get_str_array(policy, "allow_promotion"), + } + }), + }) +} + fn parse_filesystem_policy(val: ®orus::Value) -> FilesystemPolicy { FilesystemPolicy { read_only: get_str_array(val, "read_only") @@ -693,6 +722,7 @@ fn parse_filesystem_policy(val: ®orus::Value) -> FilesystemPolicy { .map(PathBuf::from) .collect(), include_workdir: get_bool(val, "include_workdir").unwrap_or(true), + runtime_baseline_conflicts: parse_runtime_baseline_conflicts(val), } } @@ -948,11 +978,22 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St }) }, |fs| { - serde_json::json!({ + let mut policy = serde_json::json!({ "include_workdir": fs.include_workdir, "read_only": fs.read_only, "read_write": fs.read_write, - }) + }); + if let Some(conflicts) = &fs.runtime_baseline_conflicts { + let mut conflicts_json = serde_json::json!({}); + if let Some(read_only_to_read_write) = &conflicts.read_only_to_read_write { + conflicts_json["read_only_to_read_write"] = serde_json::json!({ + "mode": read_only_to_read_write.mode, + "allow_promotion": read_only_to_read_write.allow_promotion, + }); + } + policy["runtime_baseline_conflicts"] = conflicts_json; + } + policy }, ); @@ -1253,6 +1294,7 @@ mod tests { include_workdir: true, read_only: vec!["/usr".to_string(), "/lib".to_string()], read_write: vec!["/sandbox".to_string(), "/tmp".to_string()], + ..Default::default() }), landlock: Some(openshell_core::proto::LandlockPolicy { compatibility: "best_effort".to_string(), @@ -1412,6 +1454,39 @@ mod tests { ); } + #[test] + fn query_sandbox_config_extracts_runtime_baseline_conflicts() { + let data = r#" +network_policies: {} +filesystem_policy: + include_workdir: true + read_only: [/usr] + read_write: [/tmp] + runtime_baseline_conflicts: + read_only_to_read_write: + mode: reject_unlisted + allow_promotion: [/proc, "/dev/nvidia*"] +landlock: + compatibility: best_effort +process: + run_as_user: sandbox + run_as_group: sandbox +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + let config = engine.query_sandbox_config().unwrap(); + + let conflict_policy = config + .filesystem + .runtime_baseline_conflicts + .and_then(|conflicts| conflicts.read_only_to_read_write) + .expect("runtime conflict policy"); + assert_eq!(conflict_policy.mode, "reject_unlisted"); + assert_eq!( + conflict_policy.allow_promotion, + vec!["/proc", "/dev/nvidia*"] + ); + } + #[test] fn query_sandbox_config_extracts_process() { let engine = test_engine(); @@ -2509,6 +2584,7 @@ network_policies: include_workdir: true, read_only: vec![], read_write: vec![], + ..Default::default() }), landlock: Some(openshell_core::proto::LandlockPolicy { compatibility: "best_effort".to_string(), @@ -2632,6 +2708,7 @@ network_policies: include_workdir: true, read_only: vec![], read_write: vec![], + ..Default::default() }), landlock: Some(openshell_core::proto::LandlockPolicy { compatibility: "best_effort".to_string(), @@ -2689,6 +2766,7 @@ network_policies: include_workdir: true, read_only: vec![], read_write: vec![], + ..Default::default() }), landlock: Some(openshell_core::proto::LandlockPolicy { compatibility: "best_effort".to_string(), @@ -2746,6 +2824,7 @@ network_policies: include_workdir: true, read_only: vec![], read_write: vec![], + ..Default::default() }), landlock: Some(openshell_core::proto::LandlockPolicy { compatibility: "best_effort".to_string(), @@ -3695,6 +3774,7 @@ process: include_workdir: true, read_only: vec![], read_write: vec![], + ..Default::default() }), landlock: Some(openshell_core::proto::LandlockPolicy { compatibility: "best_effort".to_string(), @@ -3749,6 +3829,7 @@ process: include_workdir: true, read_only: vec![], read_write: vec![], + ..Default::default() }), landlock: Some(openshell_core::proto::LandlockPolicy { compatibility: "best_effort".to_string(), @@ -3819,6 +3900,7 @@ process: include_workdir: true, read_only: vec![], read_write: vec![], + ..Default::default() }), landlock: Some(openshell_core::proto::LandlockPolicy { compatibility: "best_effort".to_string(), @@ -4049,6 +4131,7 @@ network_policies: include_workdir: true, read_only: vec![], read_write: vec![], + ..Default::default() }), landlock: Some(openshell_core::proto::LandlockPolicy { compatibility: "best_effort".to_string(), @@ -5008,6 +5091,7 @@ network_policies: include_workdir: true, read_only: vec![], read_write: vec![], + ..Default::default() }), landlock: Some(openshell_core::proto::LandlockPolicy { compatibility: "best_effort".to_string(), @@ -5085,6 +5169,7 @@ network_policies: include_workdir: true, read_only: vec![], read_write: vec![], + ..Default::default() }), landlock: Some(openshell_core::proto::LandlockPolicy { compatibility: "best_effort".to_string(), diff --git a/crates/openshell-sandbox/src/policy.rs b/crates/openshell-sandbox/src/policy.rs index 0827fa0d0..3c0c4f0e6 100644 --- a/crates/openshell-sandbox/src/policy.rs +++ b/crates/openshell-sandbox/src/policy.rs @@ -29,6 +29,9 @@ pub struct FilesystemPolicy { /// Automatically include the workdir as read-write. pub include_workdir: bool, + + /// Controls runtime baseline read-only to read-write conflicts. + pub runtime_baseline_conflicts: Option, } impl Default for FilesystemPolicy { @@ -37,10 +40,22 @@ impl Default for FilesystemPolicy { read_only: Vec::new(), read_write: Vec::new(), include_workdir: true, + runtime_baseline_conflicts: None, } } } +#[derive(Debug, Clone)] +pub struct RuntimeBaselineConflictsPolicy { + pub read_only_to_read_write: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct ReadOnlyToReadWriteConflictPolicy { + pub mode: String, + pub allow_promotion: Vec, +} + #[derive(Debug, Clone)] pub struct NetworkPolicy { pub mode: NetworkMode, @@ -133,6 +148,30 @@ impl From for FilesystemPolicy { .map(|p| PathBuf::from(openshell_policy::normalize_path(&p))) .collect(), include_workdir: proto.include_workdir, + runtime_baseline_conflicts: proto + .runtime_baseline_conflicts + .map(RuntimeBaselineConflictsPolicy::from), + } + } +} + +impl From for RuntimeBaselineConflictsPolicy { + fn from(proto: openshell_core::proto::RuntimeBaselineConflicts) -> Self { + Self { + read_only_to_read_write: proto + .read_only_to_read_write + .map(ReadOnlyToReadWriteConflictPolicy::from), + } + } +} + +impl From + for ReadOnlyToReadWriteConflictPolicy +{ + fn from(proto: openshell_core::proto::ReadOnlyToReadWriteConflictPolicy) -> Self { + Self { + mode: proto.mode, + allow_promotion: proto.allow_promotion, } } } diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 380671f10..f83822785 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -5302,6 +5302,7 @@ mod tests { include_workdir: true, read_only: vec!["/usr".into()], read_write: vec!["/tmp".into()], + ..Default::default() }), landlock: Some(LandlockPolicy { compatibility: "best_effort".into(), diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 53f292053..4144f000d 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -595,6 +595,11 @@ fn validate_filesystem_additive( "filesystem include_workdir cannot be changed on a live sandbox", )); } + if base.runtime_baseline_conflicts != upd.runtime_baseline_conflicts { + return Err(Status::invalid_argument( + "filesystem runtime_baseline_conflicts cannot be changed on a live sandbox", + )); + } for path in &base.read_only { if !upd.read_only.contains(path) { return Err(Status::invalid_argument(format!( @@ -1292,6 +1297,7 @@ mod tests { include_workdir: true, read_only: vec!["/usr".into()], read_write: vec!["/tmp".into()], + ..Default::default() }), process: Some(ProcessPolicy { run_as_user: "root".into(), @@ -1314,6 +1320,7 @@ mod tests { include_workdir: true, read_only: vec!["/usr/../etc/shadow".into()], read_write: vec!["/tmp".into()], + ..Default::default() }), ..Default::default() }; @@ -1332,6 +1339,7 @@ mod tests { include_workdir: true, read_only: vec!["/usr".into()], read_write: vec!["/".into()], + ..Default::default() }), ..Default::default() }; @@ -1380,6 +1388,7 @@ mod tests { include_workdir: true, read_only: vec!["/usr".into()], read_write: vec!["/tmp".into()], + ..Default::default() }), landlock: Some(LandlockPolicy { compatibility: "best_effort".into(), @@ -1470,6 +1479,7 @@ mod tests { read_only: vec!["/usr".into(), "/lib".into(), "/etc".into()], read_write: vec!["/sandbox".into(), "/tmp".into()], include_workdir: true, + ..Default::default() }), ..Default::default() }; @@ -1499,6 +1509,47 @@ mod tests { assert!(result.unwrap_err().message().contains("include_workdir")); } + #[test] + fn validate_static_fields_rejects_runtime_baseline_conflict_change() { + use openshell_core::proto::{ + FilesystemPolicy, ReadOnlyToReadWriteConflictPolicy, RuntimeBaselineConflicts, + }; + + let baseline = ProtoSandboxPolicy { + filesystem: Some(FilesystemPolicy { + runtime_baseline_conflicts: Some(RuntimeBaselineConflicts { + read_only_to_read_write: Some(ReadOnlyToReadWriteConflictPolicy { + mode: "reject_unlisted".into(), + allow_promotion: vec!["/proc".into()], + }), + }), + ..Default::default() + }), + ..Default::default() + }; + let changed = ProtoSandboxPolicy { + filesystem: Some(FilesystemPolicy { + runtime_baseline_conflicts: Some(RuntimeBaselineConflicts { + read_only_to_read_write: Some(ReadOnlyToReadWriteConflictPolicy { + mode: "promote_all".into(), + allow_promotion: vec![], + }), + }), + ..Default::default() + }), + ..Default::default() + }; + + let result = validate_static_fields_unchanged(&baseline, &changed); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .message() + .contains("runtime_baseline_conflicts") + ); + } + // ---- Exec validation ---- #[test] diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index 59f72c9f7..c0def2b11 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -51,6 +51,7 @@ Controls filesystem access inside the sandbox. Paths not listed in either `read_ | `include_workdir` | bool | No | When `true`, automatically adds the agent's working directory to `read_write`. | | `read_only` | list of strings | No | Paths the agent can read but not modify. Typically system directories like `/usr`, `/lib`, `/etc`. | | `read_write` | list of strings | No | Paths the agent can read and write. Typically `/sandbox` (working directory) and `/tmp`. | +| `runtime_baseline_conflicts` | object | No | Controls how OpenShell resolves conflicts between runtime baseline requirements and the effective filesystem policy. | **Validation constraints:** @@ -59,6 +60,7 @@ Controls filesystem access inside the sandbox. Paths not listed in either `read_ - Read-write paths must not be overly broad (for example, `/` alone is rejected). - Each individual path must not exceed 4096 characters. - The combined total of `read_only` and `read_write` paths must not exceed 256. +- Runtime baseline promotion patterns must be absolute full-path globs and must not contain `..` components. Policies that violate these constraints are rejected with `INVALID_ARGUMENT` at creation or update time. Disk-loaded YAML policies that fail validation fall back to a restrictive default. @@ -77,8 +79,28 @@ filesystem_policy: - /sandbox - /tmp - /dev/null + runtime_baseline_conflicts: + read_only_to_read_write: + mode: reject_unlisted + allow_promotion: + - /proc ``` +### Runtime Baseline Conflicts + +OpenShell adds runtime baseline paths at sandbox startup for controls such as proxy mode and GPU support. Some runtime features require filesystem access that can conflict with a user or gateway policy. + +The current conflict policy is `runtime_baseline_conflicts.read_only_to_read_write`, which controls whether OpenShell may promote runtime-required paths that are already listed as read-only: + +| Field | Type | Required | Values | Description | +|---|---|---|---|---| +| `mode` | string | No | `reject_unlisted`, `promote_all`, `reject_all` | Conflict handling mode. Defaults to `reject_unlisted`. | +| `allow_promotion` | list of strings | No | Absolute full-path globs | Paths that may be promoted when `mode` is `reject_unlisted`. An explicit empty list allows no listed promotions. | + +The allow list does not grant arbitrary filesystem access. It only permits promotion when the path is already part of OpenShell's active runtime baseline and would otherwise conflict with `read_only`. + +When `runtime_baseline_conflicts` is omitted, OpenShell behaves as if `mode: reject_unlisted` and `allow_promotion: [/proc]` were configured. This allows `/proc` promotion because GPU runtimes may need write access to selected `/proc` entries. Device-node conflicts such as `/dev/nvidia0` are rejected unless the policy explicitly allows them with a matching pattern or uses `promote_all`. + ## Landlock **Category:** Static diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index 406ed12b8..eefeb31b3 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -66,6 +66,10 @@ For GPU sandboxes, OpenShell also adds existing GPU device nodes as read-write p 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. +Some runtime features add extra baseline paths. GPU-enabled sandboxes may require read-write access to `/proc` and GPU device nodes. If a runtime-required read-write path is already listed as `read_only`, OpenShell rejects the policy unless promotion is allowed by `filesystem_policy.runtime_baseline_conflicts.read_only_to_read_write`. + +When this field is omitted, OpenShell allows `/proc` promotion and rejects other read-only to read-write conflicts. To opt out completely, set `mode: reject_all`. To permit specific device-node promotions, keep `mode: reject_unlisted` and add absolute full-path glob patterns such as `/dev/nvidia*` to `allow_promotion`. + User-specified paths in your policy YAML are not pre-filtered. If you list a path that does not exist: - In `best_effort` mode, the path is skipped with a warning and remaining rules are still applied. diff --git a/proto/sandbox.proto b/proto/sandbox.proto index ef0b0540f..0e5370370 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -35,6 +35,24 @@ message FilesystemPolicy { repeated string read_only = 2; // Read-write directory allow list. repeated string read_write = 3; + // Controls how runtime baseline enrichment handles paths that must become + // read-write but are already listed as read-only by the effective policy. + RuntimeBaselineConflicts runtime_baseline_conflicts = 4; +} + +// Filesystem conflict controls for runtime baseline enrichment. +message RuntimeBaselineConflicts { + // Policy for read-only to read-write baseline conflicts. + ReadOnlyToReadWriteConflictPolicy read_only_to_read_write = 1; +} + +// Controls whether OpenShell may promote baseline-required read-write paths +// that are already listed in read_only. +message ReadOnlyToReadWriteConflictPolicy { + // Conflict mode: "reject_unlisted", "promote_all", or "reject_all". + string mode = 1; + // Full-path glob patterns that may be promoted when mode is "reject_unlisted". + repeated string allow_promotion = 2; } // Landlock policy configuration. From f7ff30804ccdbce0968524bc304d4307f77d1dba Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Fri, 29 May 2026 15:42:34 +0200 Subject: [PATCH 2/2] refactor(policy): normalize runtime baseline paths Signed-off-by: Evan Lezar --- crates/openshell-sandbox/src/lib.rs | 43 ++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 84935ad28..d0722da53 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -1641,7 +1641,11 @@ fn effective_runtime_read_only_conflict_policy_from_proto( } else { policy.mode.clone() }, - allow_promotion: policy.allow_promotion.clone(), + allow_promotion: policy + .allow_promotion + .iter() + .map(|path| openshell_policy::normalize_path(path)) + .collect(), } } @@ -1662,7 +1666,11 @@ fn effective_runtime_read_only_conflict_policy_from_local( } else { policy.mode.clone() }, - allow_promotion: policy.allow_promotion.clone(), + allow_promotion: policy + .allow_promotion + .iter() + .map(|path| openshell_policy::normalize_path(path)) + .collect(), } } @@ -1723,11 +1731,18 @@ where } } for path in &paths.read_write { - if fs.read_write.iter().any(|p| p == path) { + if fs + .read_write + .iter() + .any(|p| openshell_policy::normalize_path(p) == *path) + { continue; } - let read_only_conflict = fs.read_only.iter().position(|p| p == path); + let read_only_conflict = fs + .read_only + .iter() + .position(|p| openshell_policy::normalize_path(p) == *path); if let Some(index) = read_only_conflict { if promotion_allowed(conflict_policy, path) { fs.read_only.remove(index); @@ -1817,16 +1832,18 @@ where } for path in &paths.read_write { let p = std::path::PathBuf::from(path); - if policy.filesystem.read_write.contains(&p) { - continue; - } - - if let Some(index) = policy + if policy .filesystem - .read_only + .read_write .iter() - .position(|existing| existing == &p) + .any(|existing| openshell_policy::normalize_path(&existing.to_string_lossy()) == *path) { + continue; + } + + if let Some(index) = policy.filesystem.read_only.iter().position(|existing| { + openshell_policy::normalize_path(&existing.to_string_lossy()) == *path + }) { if promotion_allowed(conflict_policy, path) { policy.filesystem.read_only.remove(index); policy.filesystem.read_write.push(p); @@ -2085,7 +2102,7 @@ mod baseline_tests { fn proto_default_conflict_policy_promotes_proc() { let mut policy = openshell_policy::restrictive_default_policy(); policy.filesystem = Some(openshell_core::proto::FilesystemPolicy { - read_only: vec!["/proc".to_string()], + read_only: vec!["/proc/".to_string()], read_write: vec![], include_workdir: false, ..Default::default() @@ -2105,7 +2122,7 @@ mod baseline_tests { let filesystem = policy.filesystem.expect("filesystem policy"); assert!(enriched); - assert!(!filesystem.read_only.contains(&"/proc".to_string())); + assert!(!filesystem.read_only.contains(&"/proc/".to_string())); assert!(filesystem.read_write.contains(&"/proc".to_string())); }