diff --git a/Cargo.lock b/Cargo.lock index 92bc18499..41fcfb155 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3584,6 +3584,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 bc7b0c7a8..b24f4b751 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -21,6 +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. +## 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, 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 Ordinary network traffic follows this order: 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 8dbaf077c..2c2c7114c 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)] @@ -366,6 +389,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, @@ -378,6 +404,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 // --------------------------------------------------------------------------- @@ -387,6 +439,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 { @@ -637,6 +693,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(), @@ -691,6 +748,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 { @@ -727,6 +788,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})" + ) + } } } } @@ -744,6 +820,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, @@ -812,6 +890,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. @@ -841,6 +925,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 { @@ -973,6 +1106,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(); @@ -1204,6 +1369,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!( @@ -1220,6 +1386,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!( @@ -1236,6 +1403,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!( @@ -1282,6 +1450,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!( @@ -1291,6 +1460,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(); @@ -1299,6 +1504,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 4a0e61e57..5b7e689ad 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -174,7 +174,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; @@ -1461,6 +1461,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); @@ -1471,40 +1509,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() @@ -1518,22 +1559,95 @@ 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 + .iter() + .map(|path| openshell_policy::normalize_path(path)) + .collect(), + } +} + +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 + .iter() + .map(|path| openshell_policy::normalize_path(path)) + .collect(), + } +} + +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 { @@ -1541,8 +1655,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!( @@ -1555,10 +1668,28 @@ where modified = true; } } - for path in rw { - if fs.read_only.iter().any(|p| p == path) || fs.read_write.iter().any(|p| p == path) { + for path in &paths.read_write { + if fs + .read_write + .iter() + .any(|p| openshell_policy::normalize_path(p) == *path) + { continue; } + + 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); + fs.read_write.push(path.clone()); + modified = true; + continue; + } + return Err(runtime_baseline_read_write_conflict(path)); + } if !path_exists(path) { debug!( path, @@ -1570,7 +1701,7 @@ where modified = true; } - modified + Ok(modified) } /// Ensure a proto `SandboxPolicy` includes the baseline filesystem paths @@ -1578,17 +1709,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!( @@ -1601,24 +1734,30 @@ fn enrich_proto_baseline_paths(proto: &mut openshell_core::proto::SandboxPolicy) ); } - modified + Ok(modified) } /// Ensure a `SandboxPolicy` (Rust type) includes the baseline filesystem /// paths required by proxy-mode sandboxes and GPU runtimes. Used for the /// local-file code path where no proto is available. -fn enrich_sandbox_baseline_paths(policy: &mut SandboxPolicy) { - let (ro, rw) = - active_baseline_enrichment_paths(matches!(policy.network.mode, NetworkMode::Proxy)); - if ro.is_empty() && rw.is_empty() { - return; +fn enrich_sandbox_baseline_paths_with( + policy: &mut SandboxPolicy, + paths: &BaselineEnrichmentPaths, + conflict_policy: &RuntimeReadOnlyConflictPolicy, + path_exists: F, +) -> Result +where + F: Fn(&str) -> bool, +{ + 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 !p.exists() { + if !path_exists(path) { debug!( path, "Baseline read-only path does not exist, skipping enrichment" @@ -1629,12 +1768,29 @@ fn enrich_sandbox_baseline_paths(policy: &mut SandboxPolicy) { 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 + .iter() + .any(|existing| openshell_policy::normalize_path(&existing.to_string_lossy()) == *path) + { continue; } - if !p.exists() { + + 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); + 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" @@ -1645,6 +1801,20 @@ fn enrich_sandbox_baseline_paths(policy: &mut SandboxPolicy) { modified = true; } + Ok(modified) +} + +/// 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!( ConfigStateChangeBuilder::new(ocsf_ctx()) @@ -1655,6 +1825,8 @@ fn enrich_sandbox_baseline_paths(policy: &mut SandboxPolicy) { .build() ); } + + Ok(modified) } #[cfg(test)] @@ -1735,36 +1907,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" ); } @@ -1775,12 +1947,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!( @@ -1805,13 +1981,197 @@ mod baseline_tests { } #[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, @@ -1820,22 +2180,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" ); } @@ -1974,7 +2342,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)); } @@ -2005,7 +2373,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\ @@ -2024,7 +2392,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 @@ -3152,6 +3520,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 0acbbe93d..bb0b4373d 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}; @@ -615,6 +618,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()); @@ -636,6 +651,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") @@ -647,6 +676,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), } } @@ -902,11 +932,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 }, ); @@ -1193,6 +1234,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(), @@ -1352,6 +1394,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(); @@ -2449,6 +2524,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(), @@ -2572,6 +2648,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(), @@ -2629,6 +2706,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(), @@ -2686,6 +2764,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(), @@ -3578,6 +3657,7 @@ process: include_workdir: true, read_only: vec![], read_write: vec![], + ..Default::default() }), landlock: Some(openshell_core::proto::LandlockPolicy { compatibility: "best_effort".to_string(), @@ -3808,6 +3888,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(), @@ -4767,6 +4848,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(), @@ -4844,6 +4926,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 bdc96d862..00deeb25b 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -4358,6 +4358,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 b87e17676..b5cab63c7 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 d7e762445..fcc626493 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -64,6 +64,10 @@ When a sandbox runs in proxy mode (the default), OpenShell automatically adds ba 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 b40d95cb1..863dbd25d 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.