diff --git a/crates/openshell-core/src/extension_protocol.rs b/crates/openshell-core/src/extension_protocol.rs index d6d93418d8..0052f14689 100644 --- a/crates/openshell-core/src/extension_protocol.rs +++ b/crates/openshell-core/src/extension_protocol.rs @@ -28,6 +28,8 @@ pub const COMPUTE_SUSPEND_RESUME: &str = "openshell.compute.suspend-resume"; /// Optional stopped-state workload configuration updates retaining the resource and storage. pub const COMPUTE_UPDATE_SANDBOX_CONFIGURATION: &str = "openshell.compute.update-sandbox-configuration"; +/// Driver declares the proxy prerequisites admitted before runtime signing. +pub const COMPUTE_PROXY_FILESYSTEM_BASELINE: &str = "openshell.compute.proxy-filesystem-baseline"; const MAX_IMPLEMENTATION_NAME_BYTES: usize = 128; const MAX_IMPLEMENTATION_VERSION_BYTES: usize = 128; @@ -133,6 +135,7 @@ pub fn gateway_metadata(family: ExtensionFamily) -> PeerMetadata { supported_capabilities.push(COMPUTE_LAUNCH_AUTHENTICATION.to_string()); supported_capabilities.push(COMPUTE_ISOLATION_BACKEND.to_string()); supported_capabilities.push(COMPUTE_UPDATE_SANDBOX_CONFIGURATION.to_string()); + supported_capabilities.push(COMPUTE_PROXY_FILESYSTEM_BASELINE.to_string()); } PeerMetadata { protocol_version: Some(ProtocolVersion { diff --git a/crates/openshell-core/src/filesystem_baseline.rs b/crates/openshell-core/src/filesystem_baseline.rs new file mode 100644 index 0000000000..befff771b0 --- /dev/null +++ b/crates/openshell-core/src/filesystem_baseline.rs @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Trusted backend prerequisites composed before effective policy admission. + +use std::collections::BTreeSet; + +use crate::proto::SandboxPolicy; +use crate::proto::compute::v1::ProxyFilesystemBaseline; + +/// Proxy runtime paths selected by trusted backend composition. +/// An explicit empty baseline suppresses legacy defaults. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FilesystemBaseline { + pub read_only: Vec, + pub read_write: Vec, +} + +impl From for FilesystemBaseline { + fn from(value: ProxyFilesystemBaseline) -> Self { + Self { + read_only: value.read_only, + read_write: value.read_write, + } + } +} + +impl From for ProxyFilesystemBaseline { + fn from(value: FilesystemBaseline) -> Self { + Self { + read_only: value.read_only, + read_write: value.read_write, + } + } +} + +impl FilesystemBaseline { + /// Validate bounded, canonical POSIX path syntax without inspecting the + /// gateway host filesystem, which need not be the workload's platform. + pub fn validate(&self) -> Result<(), &'static str> { + if self.read_only.len() + self.read_write.len() > 64 { + return Err("proxy filesystem baseline has too many paths"); + } + let mut seen = BTreeSet::new(); + for path in self.read_only.iter().chain(&self.read_write) { + if path.len() > 4096 + || !path.starts_with('/') + || path.chars().any(char::is_control) + || (path != "/" + && path[1..] + .split('/') + .any(|part| matches!(part, "" | "." | ".."))) + || !seen.insert(path) + { + return Err( + "proxy filesystem baseline requires unique canonical POSIX absolute paths", + ); + } + } + Ok(()) + } + + /// Add prerequisites to a proxy-mode policy without replacing explicit + /// grants. An explicit read-only path stays read-only. `path_exists` is + /// used only by legacy image-local Linux defaults; advertised prerequisites + /// are unconditional and callers pass a predicate that always returns true. + /// Validate the descriptor at the trusted admission boundary before use. + pub fn enrich(&self, policy: &mut SandboxPolicy, path_exists: impl Fn(&str) -> bool) -> bool { + if policy.network_policies.is_empty() + || (self.read_only.is_empty() && self.read_write.is_empty()) + { + return false; + } + let filesystem = policy + .filesystem + .get_or_insert_with(|| crate::proto::FilesystemPolicy { + include_workdir: true, + ..Default::default() + }); + let mut modified = false; + for path in &self.read_only { + if !filesystem.read_only.contains(path) + && !filesystem.read_write.contains(path) + && path_exists(path) + { + filesystem.read_only.push(path.clone()); + modified = true; + } + } + for path in &self.read_write { + if !filesystem.read_only.contains(path) + && !filesystem.read_write.contains(path) + && path_exists(path) + { + filesystem.read_write.push(path.clone()); + modified = true; + } + } + modified + } +} diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index 83faf18ef0..adfb016801 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -29,6 +29,7 @@ pub mod error; pub mod extension_protocol; #[cfg(unix)] pub mod external_driver_socket; +pub mod filesystem_baseline; pub mod forward; pub mod google_cloud; pub mod gpu; diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index 6b761f2f59..aa144f4fc4 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -1090,6 +1090,7 @@ impl DockerComputeDriver { }), rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + proxy_filesystem_baseline: None, extension: Some(openshell_core::extension_protocol::extension_metadata( openshell_core::extension_protocol::ExtensionFamily::Compute, "openshell/docker", diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index afc06e58c2..a6d8f5e24d 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -1323,6 +1323,7 @@ impl KubernetesComputeDriver { }), rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + proxy_filesystem_baseline: None, extension: Some(openshell_core::extension_protocol::extension_metadata( openshell_core::extension_protocol::ExtensionFamily::Compute, "openshell/kubernetes", diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 2772f1a69c..9f5492ce35 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -476,6 +476,7 @@ impl MxcComputeBackend { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + proxy_filesystem_baseline: None, extension: Some(openshell_core::extension_protocol::extension_metadata( openshell_core::extension_protocol::ExtensionFamily::Compute, "openshell/mxc", diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index c70cc6eca1..bffba1fbbc 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -740,6 +740,7 @@ impl PodmanComputeDriver { }), rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + proxy_filesystem_baseline: None, extension: Some(openshell_core::extension_protocol::extension_metadata( openshell_core::extension_protocol::ExtensionFamily::Compute, "openshell/podman", diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index f7946c34c2..eb6aa54fda 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -1574,6 +1574,7 @@ impl VmDriver { .to_string_lossy() .into_owned(), rootfs_tar_max_bytes: self.config.rootfs_tar_max_bytes(), + proxy_filesystem_baseline: None, extension: Some(extension), } } diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 95ec004c6f..8536f904e8 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -31,6 +31,7 @@ use hyper_util::rt::TokioIo; use openshell_core::extension_protocol::{ ExtensionFamily, NegotiatedExtension, gateway_metadata, negotiate, }; +use openshell_core::filesystem_baseline::FilesystemBaseline; #[cfg(test)] use openshell_core::proto::ServiceAuthorizationMode; use openshell_core::proto::compute::v1::{ @@ -513,6 +514,8 @@ pub struct ComputeDriverInfoSnapshot { pub driver_reports_runtime_readiness: bool, /// Static portable resource request forms from the startup capability snapshot. pub resource_capabilities: Option, + /// Trusted workload proxy prerequisites, present only after negotiation. + pub proxy_filesystem_baseline: Option, /// Directory where rootfs tar files must be staged. pub rootfs_tar_staging_dir: String, /// Maximum rootfs tar file size in bytes. @@ -1002,6 +1005,32 @@ impl ComputeRuntime { capabilities.extension.clone(), ) .map_err(|error| ComputeError::Message(error.to_string()))?; + let declares_baseline = + negotiated_extension + .supported_capabilities + .iter() + .any(|capability| { + capability + == openshell_core::extension_protocol::COMPUTE_PROXY_FILESYSTEM_BASELINE + }); + let proxy_filesystem_baseline = match ( + declares_baseline, + capabilities.proxy_filesystem_baseline.clone(), + ) { + (false, None) => None, + (true, Some(descriptor)) => { + let baseline = FilesystemBaseline::from(descriptor); + baseline + .validate() + .map_err(|error| ComputeError::Message(error.to_string()))?; + Some(baseline) + } + _ => { + return Err(ComputeError::Message( + "compute proxy filesystem baseline capability and descriptor must agree".into(), + )); + } + }; info!( configured_driver = %driver_name, advertised_driver = %capabilities.driver_name, @@ -1016,6 +1045,7 @@ impl ComputeRuntime { supports_sandbox_authentication: capabilities.supports_sandbox_authentication, driver_reports_runtime_readiness: capabilities.driver_reports_runtime_readiness, resource_capabilities: capabilities.resource_capabilities, + proxy_filesystem_baseline, rootfs_tar_staging_dir: capabilities.rootfs_tar_staging_dir, rootfs_tar_max_bytes: capabilities.rootfs_tar_max_bytes, }; @@ -1313,6 +1343,10 @@ impl ComputeRuntime { std::slice::from_ref(&self.driver_info) } + pub(crate) fn proxy_filesystem_baseline(&self) -> Option<&FilesystemBaseline> { + self.driver_info.proxy_filesystem_baseline.as_ref() + } + #[must_use] pub(crate) fn rootfs_tar_staging(&self) -> &rootfs_tar::RootfsTarStagingRegistry { &self.rootfs_tar_staging @@ -8425,6 +8459,7 @@ impl ComputeDriver for NoopTestDriver { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + proxy_filesystem_baseline: None, extension: Some(openshell_core::extension_protocol::extension_metadata( ExtensionFamily::Compute, "openshell/noop-test-driver", @@ -8620,6 +8655,7 @@ pub fn new_test_runtime_with_driver( resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + proxy_filesystem_baseline: None, }, telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process: None, @@ -9058,6 +9094,7 @@ mod tests { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + proxy_filesystem_baseline: None, extension: (!self.omit_protocol_metadata).then(|| { let mut metadata = openshell_core::extension_protocol::extension_metadata( ExtensionFamily::Compute, @@ -9524,6 +9561,7 @@ mod tests { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + proxy_filesystem_baseline: None, extension: Some(openshell_core::extension_protocol::extension_metadata( ExtensionFamily::Compute, "openshell/controlled-test-driver", @@ -9824,6 +9862,7 @@ mod tests { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + proxy_filesystem_baseline: None, }, telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process: None, @@ -18778,6 +18817,83 @@ endpoint = { kind = "unix", socket_path = "/run/agent-substrate/isolation.sock" assert!(runtime.image_update_driver.is_none()); } + #[tokio::test] + async fn proxy_filesystem_baseline_handshake_requires_a_valid_matching_descriptor() { + use crate::test_support::FakeComputeDriver; + use openshell_core::extension_protocol::COMPUTE_PROXY_FILESYSTEM_BASELINE; + use openshell_core::proto::compute::v1::ProxyFilesystemBaseline; + + let valid = ProxyFilesystemBaseline { + read_only: vec!["/System".into()], + read_write: vec!["/dev".into()], + }; + for (declared, descriptor, accepted) in [ + (false, None, true), + (true, Some(ProxyFilesystemBaseline::default()), true), + (true, Some(valid.clone()), true), + (true, None, false), + (false, Some(valid), false), + ( + true, + Some(ProxyFilesystemBaseline { + read_only: vec!["/a/../b".into()], + ..Default::default() + }), + false, + ), + ( + true, + Some(ProxyFilesystemBaseline { + read_only: vec!["/same".into()], + read_write: vec!["/same".into()], + }), + false, + ), + ( + true, + Some(ProxyFilesystemBaseline { + read_only: vec!["/a".into(); 65], + ..Default::default() + }), + false, + ), + ] { + let expected = descriptor.clone().map(FilesystemBaseline::from); + let driver = Arc::new(FakeComputeDriver::new().with_capabilities( + GetCapabilitiesResponse { + proxy_filesystem_baseline: descriptor, + extension: Some(openshell_core::extension_protocol::extension_metadata( + ExtensionFamily::Compute, + "openshell/test", + "test", + if declared { + vec![COMPUTE_PROXY_FILESYSTEM_BASELINE.to_string()] + } else { + vec![] + }, + )), + ..Default::default() + }, + )); + let store = Arc::new(Store::connect("sqlite::memory:").await.unwrap()); + let result = ComputeRuntime::from_driver( + "test".into(), + driver, + None, + store, + SandboxIndex::new(), + SandboxWatchBus::new(), + TracingLogBus::new(), + Arc::new(SupervisorSessionRegistry::new()), + ) + .await; + assert_eq!(result.is_ok(), accepted); + if let Ok(runtime) = result { + assert_eq!(runtime.proxy_filesystem_baseline(), expected.as_ref()); + } + } + } + #[tokio::test] async fn negotiated_launch_requirement_rejects_create_before_driver_or_store() { let driver = Arc::new(TestDriver { diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 504c339690..40a51002b4 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -34,6 +34,7 @@ use crate::auth::workspace_authz::{ }; use crate::compute::MutationScope; use crate::pagination::Pagination; +#[cfg(test)] use crate::persistence::ObjectType; use crate::persistence::{ DraftChunkRecord, ObjectId, ObjectListQuery, ObjectName, ObjectWorkspace, PolicyRecord, Store, @@ -1998,6 +1999,9 @@ async fn current_effective_policy_from_records( .await .map_err(|e| Status::internal(format!("fetch latest policy failed: {e}")))? }; + // This projection also owns authored-policy proposal/rebase identities. + // Trusted runtime prerequisites are composed at launch/config/credential + // delivery boundaries, not into editable policy history or review hashes. effective_policy_from_captured_inputs( catalog, records, @@ -3018,6 +3022,10 @@ pub async fn build_sandbox_config_snapshot_from_inputs( policy = Some(effective_policy); } + if let Some(effective_policy) = policy.as_mut() { + apply_backend_proxy_baseline(state, effective_policy, policy_source)?; + } + let policy_credential_bindings = policy_static_credential_endpoint_bindings(policy.as_ref())?; extend_credentialed_scopes_from_policy_bindings( &mut provider_policy_context.credentialed_scopes, @@ -3036,6 +3044,7 @@ pub async fn build_sandbox_config_snapshot_from_inputs( if let Err(error) = validate_uninspected_credentialed_endpoints(effective_policy) { configuration_error = bounded_configuration_diagnostic(error.message()); } + validate_backend_policy_size(state, effective_policy)?; policy_hash = deterministic_policy_hash(effective_policy); } @@ -3294,6 +3303,7 @@ pub(super) async fn compute_provider_env_revision_with_catalog( .await } +#[cfg(test)] async fn compute_provider_env_revision_with_catalog_and_policy_bindings( store: &Store, catalog: &EffectiveProviderProfileCatalog, @@ -3771,6 +3781,44 @@ pub(super) async fn handle_get_gateway_config( })) } +fn apply_backend_proxy_baseline( + state: &ServerState, + effective_policy: &mut ProtoSandboxPolicy, + source: PolicySource, +) -> Result<(), Status> { + // Compose trusted workload-namespace prerequisites after provider/global + // selection, before compute admission and effective configuration identity. + if let Some(baseline) = state.compute.proxy_filesystem_baseline() + && baseline.enrich(effective_policy, |_| true) + { + *effective_policy = validate_and_canonicalize_policy(effective_policy.clone())?; + validate_policy_safety(effective_policy)?; + } + if state.compute.proxy_filesystem_baseline().is_some() && matches!(source, PolicySource::Global) + { + // Global credential authorities hash the complete effective policy. + // Re-stamp after enrichment so create, config and credential delivery agree. + openshell_core::policy_identity::stamp_global_token_grant_owners(effective_policy); + } + + validate_backend_policy_size(state, effective_policy)?; + Ok(()) +} + +fn validate_backend_policy_size( + state: &ServerState, + policy: &ProtoSandboxPolicy, +) -> Result<(), Status> { + if state.compute.proxy_filesystem_baseline().is_some() + && policy.encoded_len() > super::MAX_POLICY_SIZE + { + return Err(Status::failed_precondition( + "backend proxy filesystem baseline exceeds the effective policy size limit", + )); + } + Ok(()) +} + /// Resolve the effective create-time policy without exporting credential state. /// Provider refresh and secret delivery remain in the supervisor session. pub(super) async fn resolve_sandbox_create_runtime_inputs( @@ -3794,7 +3842,7 @@ pub(super) async fn resolve_sandbox_create_runtime_inputs( provider_names, ) .await?; - let (effective_policy, _) = current_effective_policy_from_records( + let (mut effective_policy, source) = current_effective_policy_from_records( state, &provider_profile_catalog, sandbox, @@ -3802,6 +3850,7 @@ pub(super) async fn resolve_sandbox_create_runtime_inputs( &provider_records, ) .await?; + apply_backend_proxy_baseline(state, &mut effective_policy, source)?; let policy_credential_bindings = policy_static_credential_endpoint_bindings(Some(&effective_policy))?; validate_policy_credential_binding_context( @@ -3885,9 +3934,10 @@ struct ProviderEnvironmentTarget<'a> { provider_env_revision: u64, } -fn provider_environment_target( - inputs: &SandboxConfigInputs, -) -> Result, Status> { +fn provider_environment_target<'a>( + state: &ServerState, + inputs: &'a SandboxConfigInputs, +) -> Result, Status> { let sandbox = &inputs.sandbox; let spec = sandbox .spec @@ -3895,13 +3945,14 @@ fn provider_environment_target( .ok_or_else(|| Status::internal("sandbox has no spec"))?; let provider_profile_catalog = &inputs.catalog; let provider_records = &inputs.provider_records; - let (effective_policy, policy_source) = effective_policy_from_captured_inputs( + let (mut effective_policy, policy_source) = effective_policy_from_captured_inputs( provider_profile_catalog, provider_records, decode_policy_from_global_settings(&inputs.global_settings)?, inputs.latest_policy.as_ref(), sandbox, )?; + apply_backend_proxy_baseline(state, &mut effective_policy, policy_source)?; let policy_credential_bindings = policy_static_credential_endpoint_bindings(Some(&effective_policy))?; validate_policy_credential_binding_context( @@ -3940,7 +3991,7 @@ pub async fn build_provider_environment_snapshot_from_inputs( policy_source, policy_credential_bindings, provider_env_revision, - } = provider_environment_target(inputs)?; + } = provider_environment_target(state, inputs)?; let provider_names = &spec.providers; let provider_profile_catalog = &inputs.catalog; @@ -9217,6 +9268,202 @@ mod tests { assert!(inputs.launch_authentication.is_none()); } + #[tokio::test] + async fn backend_proxy_baseline_is_admitted_before_create_and_config_identity() { + use openshell_core::extension_protocol::{ + COMPUTE_PROXY_FILESYSTEM_BASELINE, ExtensionFamily, extension_metadata, + }; + use openshell_core::proto::compute::v1::{ + GetCapabilitiesResponse, ProxyFilesystemBaseline, + }; + + let mut state = test_server_state().await; + let baseline = ProxyFilesystemBaseline { + read_only: vec!["/platform-runtime-not-on-gateway".into()], + read_write: vec!["/platform-device-not-on-gateway".into()], + }; + let driver = Arc::new( + crate::test_support::FakeComputeDriver::new().with_capabilities( + GetCapabilitiesResponse { + driver_name: "test".into(), + resource_admission_policy: + openshell_core::resource_admission::DriverAdmissionConfig::default() + .acknowledgement(), + proxy_filesystem_baseline: Some(baseline.clone()), + extension: Some(extension_metadata( + ExtensionFamily::Compute, + "openshell/test", + "test", + [COMPUTE_PROXY_FILESYSTEM_BASELINE.to_string()], + )), + ..Default::default() + }, + ), + ); + let compute = crate::compute::ComputeRuntime::from_driver( + "test".into(), + driver, + None, + state.store.clone(), + crate::sandbox_index::SandboxIndex::new(), + crate::sandbox_watch::SandboxWatchBus::new(), + crate::tracing_bus::TracingLogBus::new(), + Arc::new(crate::supervisor_session::SupervisorSessionRegistry::new()), + ) + .await + .unwrap(); + Arc::get_mut(&mut state).unwrap().compute = compute; + let sandbox = test_sandbox( + "baseline", + "baseline", + mcp_policy_with_versions(&[]), + Vec::new(), + ); + state.store.put_message(&sandbox).await.unwrap(); + let policy = sandbox.spec.as_ref().unwrap().policy.as_ref().unwrap(); + state + .store + .put_policy_revision( + "baseline-v1", + "baseline", + "default", + 1, + &policy.encode_to_vec(), + &deterministic_policy_hash(policy), + ) + .await + .unwrap(); + + for global in [false, true] { + state.store.put_message(&sandbox).await.unwrap(); + if global { + install_test_global_policy(&state).await; + } + let snapshot = build_sandbox_config_snapshot(&state, &sandbox) + .await + .unwrap(); + let provider = build_provider_environment_snapshot(&state, &sandbox, true, true) + .await + .unwrap(); + assert_eq!( + snapshot.provider_env_revision, + provider.provider_env_revision + ); + let create = resolve_sandbox_create_runtime_inputs(&state, &sandbox) + .await + .unwrap(); + let admitted = create.effective_policy.unwrap(); + assert_eq!(snapshot.policy.as_ref(), Some(&admitted)); + assert_eq!(snapshot.policy_hash, deterministic_policy_hash(&admitted)); + let session_id = if global { + "baseline-global" + } else { + "baseline-local" + }; + let (session_tx, _session_rx) = tokio::sync::mpsc::channel(1); + let (shutdown_tx, _shutdown_rx) = tokio::sync::oneshot::channel(); + state.supervisor_sessions.register( + "baseline".into(), + session_id.into(), + session_tx, + shutdown_tx, + ); + reset_endpoint_status_for_supervisor_session(&state, "baseline", session_id) + .await + .unwrap(); + assert!( + state + .supervisor_sessions + .initialize_endpoint_status_authority("baseline", session_id) + ); + for loaded in [false, true] { + if loaded { + handle_report_policy_status( + &state, + with_sandbox( + Request::new(ReportPolicyStatusRequest { + sandbox_id: "baseline".into(), + version: 1, + status: PolicyStatus::Loaded as i32, + ..Default::default() + }), + "baseline", + ), + ) + .await + .unwrap(); + } + let observations = admitted + .network_policies + .values() + .flat_map(|rule| &rule.endpoints) + .map(|endpoint| openshell_core::proto::EndpointObservation { + endpoint_id: openshell_core::endpoint_status::endpoint_id(endpoint), + result: openshell_core::proto::EndpointResult::NoObservedExchange as i32, + failure: None, + }) + .collect(); + handle_report_endpoint_status( + &state, + with_sandbox( + Request::new(openshell_core::proto::ReportEndpointStatusRequest { + sandbox_id: "baseline".into(), + policy_hash: snapshot.policy_hash.clone(), + provider_env_revision: snapshot.provider_env_revision, + observations, + supervisor_session_id: session_id.into(), + report_sequence: if loaded { 2 } else { 1 }, + ..Default::default() + }), + "baseline", + ), + ) + .await + .expect( + "runtime endpoint report matches the admitted config before and after load", + ); + } + let filesystem = admitted.filesystem.unwrap(); + assert!(filesystem.read_only.contains(&baseline.read_only[0])); + assert!(filesystem.read_write.contains(&baseline.read_write[0])); + assert!( + !sandbox + .spec + .as_ref() + .unwrap() + .policy + .as_ref() + .unwrap() + .filesystem + .as_ref() + .unwrap() + .read_only + .contains(&baseline.read_only[0]) + ); + } + let mut oversized = mcp_policy_with_versions(&["2025-11-25"]); + oversized.filesystem.as_mut().unwrap().read_only = (0..128) + .map(|index| format!("/{index:04}{}", "x".repeat(2035))) + .collect(); + let padding = super::super::MAX_POLICY_SIZE - 16 - oversized.encoded_len(); + oversized + .filesystem + .as_mut() + .unwrap() + .read_only + .last_mut() + .unwrap() + .push_str(&"x".repeat(padding)); + assert!(oversized.encoded_len() <= super::super::MAX_POLICY_SIZE); + assert!(validate_and_canonicalize_policy(oversized.clone()).is_ok()); + assert_eq!( + apply_backend_proxy_baseline(&state, &mut oversized, PolicySource::Sandbox) + .unwrap_err() + .code(), + Code::FailedPrecondition + ); + } + #[tokio::test] async fn list_sandbox_policies_traverses_multiple_pages_exactly_once() { let state = test_server_state().await; diff --git a/crates/openshell-server/src/grpc/policy/endpoint_status.rs b/crates/openshell-server/src/grpc/policy/endpoint_status.rs index 680aa369d6..eb4b13c276 100644 --- a/crates/openshell-server/src/grpc/policy/endpoint_status.rs +++ b/crates/openshell-server/src/grpc/policy/endpoint_status.rs @@ -4,13 +4,15 @@ //! Passive network results for configured tool server endpoints. use super::{ - apply_effective_policy_context, canonical_policy_record_identity, - compute_provider_env_revision_with_catalog_and_policy_bindings, - current_effective_policy_for_sandbox, decode_policy_from_global_settings, + apply_backend_proxy_baseline, apply_captured_policy_context, canonical_policy_record_identity, + compute_provider_env_revision_from_records_and_policy_bindings, + current_effective_policy_from_records, decode_policy_from_global_settings, deterministic_policy_hash, load_global_settings, policy_static_credential_endpoint_bindings, + provider_policy_context_from_records, }; use crate::ServerState; use crate::compute::MutationScope; +use crate::grpc::provider::{ProviderEnvironmentRecord, load_provider_environment_records}; use crate::persistence::{ ObjectCursor, ObjectId, ObjectListQuery, ObjectWorkspace, PersistenceError, }; @@ -263,15 +265,23 @@ async fn current_endpoint_context( .provider_profile_sources .snapshot_catalog(state.store.as_ref(), workspace) .await?; - let policy = current_effective_policy_for_sandbox( + let provider_names = sandbox + .spec + .as_ref() + .map(|spec| spec.providers.clone()) + .unwrap_or_default(); + let records = + load_provider_environment_records(state.store.as_ref(), workspace, &provider_names).await?; + let (mut policy, source) = current_effective_policy_from_records( state, &catalog, - workspace, sandbox, sandbox.object_id(), + &records, ) .await?; - derive_endpoint_context(state, sandbox, &catalog, policy).await + apply_backend_proxy_baseline(state, &mut policy, source)?; + derive_endpoint_context(&catalog, &records, policy, source) } async fn active_endpoint_context( @@ -328,16 +338,10 @@ async fn endpoint_context_for_loaded_policy_with_inputs( .as_ref() .map(|spec| spec.providers.clone()) .unwrap_or_default(); - let policy = if let Some(global_policy) = global_policy { - apply_effective_policy_context( - state, - catalog, - workspace, - &provider_names, - global_policy.clone(), - PolicySource::Global, - ) - .await? + let records = + load_provider_environment_records(state.store.as_ref(), workspace, &provider_names).await?; + let (policy, source) = if let Some(global_policy) = global_policy { + (global_policy.clone(), PolicySource::Global) } else { let record = state .store @@ -345,42 +349,33 @@ async fn endpoint_context_for_loaded_policy_with_inputs( .await .map_err(|error| Status::internal(format!("fetch policy revision failed: {error}")))? .ok_or_else(|| Status::not_found("policy revision not found"))?; - let policy = canonical_policy_record_identity(&record)?.0; - apply_effective_policy_context( - state, - catalog, - workspace, - &provider_names, - policy, + ( + canonical_policy_record_identity(&record)?.0, PolicySource::Sandbox, ) - .await? }; - derive_endpoint_context(state, sandbox, catalog, policy).await + let mut policy = apply_captured_policy_context( + provider_policy_context_from_records(catalog, &records), + policy, + source, + )?; + apply_backend_proxy_baseline(state, &mut policy, source)?; + derive_endpoint_context(catalog, &records, policy, source) } -async fn derive_endpoint_context( - state: &ServerState, - sandbox: &Sandbox, +fn derive_endpoint_context( catalog: &EffectiveProviderProfileCatalog, + records: &[ProviderEnvironmentRecord], policy: ProtoSandboxPolicy, + source: PolicySource, ) -> Result { - let workspace = sandbox.object_workspace(); - let provider_names = sandbox - .spec - .as_ref() - .map(|spec| spec.providers.clone()) - .unwrap_or_default(); let bindings = policy_static_credential_endpoint_bindings(Some(&policy))?; - let provider_env_revision = compute_provider_env_revision_with_catalog_and_policy_bindings( - state.store.as_ref(), + let provider_env_revision = compute_provider_env_revision_from_records_and_policy_bindings( catalog, - workspace, - &provider_names, + records, &bindings, - ) - .await?; - + matches!(source, PolicySource::Global).then_some(&policy), + )?; Ok(EndpointContext { policy_hash: deterministic_policy_hash(&policy), provider_env_revision, diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index a797da2589..4f95efc1fd 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -4326,6 +4326,7 @@ mod tests { default_image: "test/image:latest".to_string(), rootfs_tar_staging_dir: directory.path().to_string_lossy().into_owned(), rootfs_tar_max_bytes: 1024, + proxy_filesystem_baseline: None, resource_admission_policy: admission.acknowledgement(), extension: Some(metadata), ..Default::default() diff --git a/crates/openshell-server/src/test_support.rs b/crates/openshell-server/src/test_support.rs index e12bd617a9..fdd5a9a87c 100644 --- a/crates/openshell-server/src/test_support.rs +++ b/crates/openshell-server/src/test_support.rs @@ -182,6 +182,7 @@ impl FakeComputeDriver { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + proxy_filesystem_baseline: None, extension: Some(openshell_core::extension_protocol::extension_metadata( openshell_core::extension_protocol::ExtensionFamily::Compute, "openshell/fake-compute-driver", diff --git a/crates/openshell-supervisor/src/backend_setup.rs b/crates/openshell-supervisor/src/backend_setup.rs index a0cd52ea87..63b02aaa8a 100644 --- a/crates/openshell-supervisor/src/backend_setup.rs +++ b/crates/openshell-supervisor/src/backend_setup.rs @@ -11,6 +11,7 @@ use std::sync::{Arc, Mutex}; use miette::Result; use openshell_core::SandboxSessionId; +use openshell_core::filesystem_baseline::FilesystemBaseline; use openshell_core::jwt::{SessionBearerTokenSlot, SupervisorAuthBundle}; use openshell_core::provider_credentials::ProviderCredentialState; use openshell_isolation_interface::AgentSpec; @@ -58,6 +59,13 @@ pub trait BackendSetup: Sync { /// Name chosen by trusted composition and checked against admission before decoding. fn backend_name(&self) -> &str; + /// Trusted proxy prerequisites. `None` retains legacy Linux image defaults; + /// an explicit empty baseline suppresses them. Match the compute driver's + /// advertised baseline so policy admission precedes immutable signing. + fn proxy_filesystem_baseline(&self) -> Option { + None + } + /// Decode native launch data without starting workload code. The returned /// identity must describe the same resource retained by the prepared backend. fn decode( @@ -91,6 +99,7 @@ pub struct SelectedBackend { descriptor: BackendDescriptor, identity: LaunchIdentity, prepared: Box, + proxy_filesystem_baseline: Option, } impl SelectedBackend { @@ -137,10 +146,17 @@ impl SelectedBackend { "supervisor authentication bundle does not match runtime generation" )); } + let proxy_filesystem_baseline = setup.proxy_filesystem_baseline(); + if let Some(baseline) = &proxy_filesystem_baseline { + baseline + .validate() + .map_err(|error| miette::miette!(error))?; + } Ok(Self { descriptor, identity, prepared, + proxy_filesystem_baseline, }) } @@ -149,6 +165,11 @@ impl SelectedBackend { &self.descriptor.backend_name } + /// Baseline captured only after admitted name and launch identity checks. + pub fn proxy_filesystem_baseline(&self) -> Option<&FilesystemBaseline> { + self.proxy_filesystem_baseline.as_ref() + } + /// Carry the selected backend's VM identity constraint into policy handling. pub fn vm_policy_identity(&self) -> Option { self.identity.vm_policy_identity diff --git a/crates/openshell-supervisor/src/legacy_startup_regression_tests.rs b/crates/openshell-supervisor/src/legacy_startup_regression_tests.rs index 7b2b60859f..fce7944f35 100644 --- a/crates/openshell-supervisor/src/legacy_startup_regression_tests.rs +++ b/crates/openshell-supervisor/src/legacy_startup_regression_tests.rs @@ -133,7 +133,7 @@ where } "/openshell.v1.OpenShell/GetSandboxConfig" => { let mut policy = openshell_policy::restrictive_default_policy(); - enrich_proto_baseline_paths(&mut policy); + enrich_proto_baseline_paths(&mut policy, None); legacy_unary::( request, GetSandboxConfigResponse { diff --git a/crates/openshell-supervisor/src/lib.rs b/crates/openshell-supervisor/src/lib.rs index 398c4bef77..0a00ac8942 100644 --- a/crates/openshell-supervisor/src/lib.rs +++ b/crates/openshell-supervisor/src/lib.rs @@ -43,6 +43,7 @@ use std::time::Duration; use tracing::{Instrument as _, debug, info, warn}; use openshell_core::PolicyValidationFailureMode; +use openshell_core::filesystem_baseline::FilesystemBaseline; use openshell_supervisor_control::RegistrationTraceContext; use openshell_ocsf::{ @@ -1206,6 +1207,7 @@ pub async fn run_sandbox_instance( Some(image_discovery), &RemoteStartupGateway { gateway: gateway.clone(), + proxy_filesystem_baseline: selected_backend.proxy_filesystem_baseline().cloned(), }, ) .instrument(tracing::info_span!(parent: &startup, "supervisor.policy.load")) @@ -2153,80 +2155,32 @@ fn proxy_baseline_paths() -> (Vec, Vec) { ) } -fn enrich_proto_baseline_paths_with( - proto: &mut openshell_core::proto::SandboxPolicy, - ro: &[String], - rw: &[String], - path_exists: F, -) -> bool -where - F: Fn(&str) -> bool, -{ - if ro.is_empty() && rw.is_empty() { - return false; - } - - let fs = proto - .filesystem - .get_or_insert_with(|| openshell_core::proto::FilesystemPolicy { - include_workdir: true, - ..Default::default() - }); - - let mut modified = false; - for path in ro { - if !fs.read_only.iter().any(|p| p == path) && !fs.read_write.iter().any(|p| p == path) { - if !path_exists(path) { - debug!( - path, - "Baseline read-only path does not exist, skipping enrichment" - ); - continue; - } - fs.read_only.push(path.clone()); - modified = true; - } - } - for path in rw { - if fs.read_write.iter().any(|p| p == path) { - continue; - } - if !path_exists(path) { - debug!( - path, - "Baseline read-write path does not exist, skipping enrichment" - ); - continue; - } - if fs.read_only.iter().any(|p| p == path) { - continue; - } - fs.read_write.push(path.clone()); - modified = true; - } - - modified -} - /// Ensure a proto `SandboxPolicy` includes the baseline filesystem paths /// required by proxy-mode sandboxes. Paths are only added if /// 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 { +fn enrich_proto_baseline_paths( + proto: &mut openshell_core::proto::SandboxPolicy, + baseline: Option<&FilesystemBaseline>, +) -> bool { if proto.network_policies.is_empty() { return false; } - let (ro, rw) = proxy_baseline_paths(); - - // 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| { - std::path::Path::new(path).exists() - }); + let modified = if let Some(baseline) = baseline { + // Backend-advertised paths belong to the workload namespace, not this + // supervisor's host filesystem. They are unconditional prerequisites. + baseline.enrich(proto, |_| true) + } else { + // Legacy Linux image defaults retain existence filtering: a missing + // path must not cause Landlock to abandon the ruleset (issue #664). + let (read_only, read_write) = proxy_baseline_paths(); + FilesystemBaseline { + read_only, + read_write, + } + .enrich(proto, |path| std::path::Path::new(path).exists()) + }; if modified { ocsf_emit!( @@ -2262,17 +2216,22 @@ fn proto_sync_payload_for_enriched_policy( /// Ensure a `SandboxPolicy` (Rust type) includes the baseline filesystem /// paths required by proxy-mode sandboxes. Used for the /// local-file code path where no proto is available. -fn enrich_sandbox_baseline_paths(policy: &mut SandboxPolicy) { +fn enrich_sandbox_baseline_paths( + policy: &mut SandboxPolicy, + baseline: Option<&FilesystemBaseline>, +) { if !matches!(policy.network.mode, NetworkMode::Proxy) { return; } - let (ro, rw) = proxy_baseline_paths(); + let (ro, rw) = baseline.map_or_else(proxy_baseline_paths, |baseline| { + (baseline.read_only.clone(), baseline.read_write.clone()) + }); let mut modified = false; for path in &ro { let p = std::path::PathBuf::from(path); if !policy.filesystem.read_only.contains(&p) && !policy.filesystem.read_write.contains(&p) { - if !p.exists() { + if baseline.is_none() && !p.exists() { debug!( path, "Baseline read-only path does not exist, skipping enrichment" @@ -2288,7 +2247,7 @@ fn enrich_sandbox_baseline_paths(policy: &mut SandboxPolicy) { if policy.filesystem.read_only.contains(&p) || policy.filesystem.read_write.contains(&p) { continue; } - if !p.exists() { + if baseline.is_none() && !p.exists() { debug!( path, "Baseline read-write path does not exist, skipping enrichment" @@ -2373,7 +2332,7 @@ mod baseline_tests { }, ); - enrich_proto_baseline_paths(&mut policy); + enrich_proto_baseline_paths(&mut policy, None); let filesystem = policy.filesystem.expect("filesystem policy"); assert!(filesystem.read_only.contains(&"/proc".to_string())); @@ -2487,7 +2446,7 @@ mod baseline_tests { ); let original = policy.clone(); - let enriched = enrich_proto_baseline_paths(&mut policy); + let enriched = enrich_proto_baseline_paths(&mut policy, None); assert!(!enriched); assert_eq!(policy, original); @@ -2511,7 +2470,7 @@ mod baseline_tests { process: ProcessPolicy::default(), }; - enrich_sandbox_baseline_paths(&mut policy); + enrich_sandbox_baseline_paths(&mut policy, None); assert!( policy.filesystem.read_only.contains(&PathBuf::from("/tmp")), @@ -2525,6 +2484,57 @@ mod baseline_tests { "baseline enrichment must not promote explicit read_only /tmp to read_write" ); } + + #[test] + fn selected_backend_baseline_replaces_linux_defaults_in_both_policy_formats() { + let baseline = FilesystemBaseline { + read_only: vec!["/platform-runtime-not-on-host".into()], + read_write: vec!["/explicit-read-only".into()], + }; + for baseline in [baseline, FilesystemBaseline::default()] { + baseline.validate().unwrap(); + let mut proto = openshell_policy::restrictive_default_policy(); + proto.filesystem = Some(openshell_core::proto::FilesystemPolicy { + read_only: vec!["/explicit-read-only".into()], + ..Default::default() + }); + proto.network_policies.insert( + "proxy".into(), + openshell_core::proto::NetworkPolicyRule::default(), + ); + enrich_proto_baseline_paths(&mut proto, Some(&baseline)); + let filesystem = proto.filesystem.unwrap(); + let mut expected = vec!["/explicit-read-only".to_string()]; + expected.extend(baseline.read_only.iter().cloned()); + assert_eq!(filesystem.read_only, expected); + assert!(filesystem.read_write.is_empty()); + + let mut local = SandboxPolicy { + version: 1, + filesystem: FilesystemPolicy { + read_only: vec!["/explicit-read-only".into()], + read_write: vec![], + include_workdir: false, + }, + network: NetworkPolicy { + mode: NetworkMode::Proxy, + proxy: None, + }, + landlock: LandlockPolicy::default(), + process: ProcessPolicy::default(), + }; + enrich_sandbox_baseline_paths(&mut local, Some(&baseline)); + assert_eq!( + local.filesystem.read_only, + expected.iter().map(PathBuf::from).collect::>() + ); + assert!(local.filesystem.read_write.is_empty()); + local.network.mode = NetworkMode::Block; + let original = local.filesystem.read_only.clone(); + enrich_sandbox_baseline_paths(&mut local, Some(&baseline)); + assert_eq!(local.filesystem.read_only, original); + } + } } /// Returns `true` if the error is transient and worth retrying. @@ -2602,6 +2612,9 @@ where } trait StartupGateway: Send + Sync { + fn proxy_filesystem_baseline(&self) -> Option { + None + } fn snapshot( &self, sandbox: &str, @@ -2628,6 +2641,7 @@ trait StartupGateway: Send + Sync { struct RemoteStartupGateway { gateway: Option, + proxy_filesystem_baseline: Option, } impl RemoteStartupGateway { @@ -2639,6 +2653,9 @@ impl RemoteStartupGateway { } impl StartupGateway for RemoteStartupGateway { + fn proxy_filesystem_baseline(&self) -> Option { + self.proxy_filesystem_baseline.clone() + } async fn snapshot( &self, sandbox: &str, @@ -2800,6 +2817,7 @@ async fn load_policy( None, &RemoteStartupGateway { gateway: openshell_gateway, + proxy_filesystem_baseline: None, }, ) .await @@ -2831,6 +2849,12 @@ async fn load_policy_with_gateway( Option, )> { use openshell_core::proto::ConfigurationAdmissionState; + let proxy_baseline = gateway.proxy_filesystem_baseline(); + if let Some(baseline) = &proxy_baseline { + baseline + .validate() + .map_err(|error| miette::miette!(error))?; + } // File mode: load OPA engine from rego rules + YAML data (dev override) if let (Some(policy_file), Some(data_file)) = (&policy_rules, &policy_data) { if sandbox_id.is_some() && openshell_gateway.is_some() { @@ -2884,7 +2908,7 @@ async fn load_policy_with_gateway( landlock: config.landlock, process: config.process, }; - enrich_sandbox_baseline_paths(&mut policy); + enrich_sandbox_baseline_paths(&mut policy, proxy_baseline.as_ref()); // File mode has no operator-registered middleware to connect. return Ok(( policy, @@ -2974,7 +2998,7 @@ async fn load_policy_with_gateway( }; // 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, proxy_baseline.as_ref()); strip_proto_provider_policy_entries(&mut discovered); // Sync and re-fetch over a single connection to avoid extra // TLS handshakes. @@ -3035,7 +3059,7 @@ async fn load_policy_with_gateway( // a sandbox-sourced policy is written back. The gateway refuses // every sandbox policy write while a global policy is active, so a // global policy keeps the added paths in this process only. - let enriched = enrich_proto_baseline_paths(&mut proto_policy); + let enriched = enrich_proto_baseline_paths(&mut proto_policy, proxy_baseline.as_ref()); let sync_policy = proto_sync_payload_for_enriched_policy(&proto_policy, enriched) .filter(|_| snapshot.policy_source == openshell_core::proto::PolicySource::Sandbox); if let Some(sync_policy) = sync_policy { @@ -5262,7 +5286,7 @@ network_policies: async fn startup_rejects_vm_identity_until_matching_policy_is_available() { use openshell_core::proto::{ConfigurationAdmissionState, PolicySource}; let mut policy = proto_policy_fixture(); - enrich_proto_baseline_paths(&mut policy); + enrich_proto_baseline_paths(&mut policy, None); policy.process = Some(openshell_core::proto::ProcessPolicy { run_as_user: "10000".into(), run_as_group: "1001".into(), @@ -5405,7 +5429,7 @@ network_policies: async fn startup_pending_gateway_calls_exhaust_their_budgets() { for pending_snapshot in [true, false] { let mut policy = proto_policy_fixture(); - enrich_proto_baseline_paths(&mut policy); + enrich_proto_baseline_paths(&mut policy, None); let (reports, _reported) = tokio::sync::mpsc::unbounded_channel(); let gateway = TestStartupGateway { desired: Arc::new(std::sync::Mutex::new(settings_poll_result( @@ -5530,7 +5554,7 @@ network_policies: async fn startup_waits_for_repair_and_retries_stale_activation_before_returning() { use openshell_core::proto::{ConfigurationAdmissionState, PolicySource}; let mut policy = proto_policy_fixture(); - enrich_proto_baseline_paths(&mut policy); + enrich_proto_baseline_paths(&mut policy, None); let mut rejected = settings_poll_result(Some(policy), 1, PolicySource::Sandbox); rejected.configuration_admitted = false; let (reports, mut reported) = tokio::sync::mpsc::unbounded_channel(); @@ -5979,7 +6003,7 @@ network_policies: // paths, so startup writes an enriched copy back. The repaired policy // already carries those paths and needs no write-back. let mut repaired = proto_tcp_policy_fixture(); - assert!(enrich_proto_baseline_paths(&mut repaired)); + assert!(enrich_proto_baseline_paths(&mut repaired, None)); assert_refused_startup_write_waits_for_repair( settings_poll_result( Some(proto_tcp_policy_fixture()), @@ -6107,7 +6131,7 @@ network_policies: #[tokio::test(start_paused = true)] async fn startup_policy_write_refusal_baseline_repair_before_report_refetches() { let mut repaired = proto_tcp_policy_fixture(); - assert!(enrich_proto_baseline_paths(&mut repaired)); + assert!(enrich_proto_baseline_paths(&mut repaired, None)); Box::pin(assert_startup_repair_before_rejection_refetches( settings_poll_result( Some(proto_tcp_policy_fixture()), @@ -6253,7 +6277,7 @@ network_policies: async fn startup_policy_write_refusal_write_back_outlasts_the_attempt_limit() { // The same limit applies to a refused baseline-path write-back. let mut repaired = proto_tcp_policy_fixture(); - assert!(enrich_proto_baseline_paths(&mut repaired)); + assert!(enrich_proto_baseline_paths(&mut repaired, None)); assert_refused_startup_write_waits_for_repair( settings_poll_result( Some(proto_tcp_policy_fixture()), @@ -6350,7 +6374,7 @@ network_policies: global.global_policy_version = 1; let global_generation = report_generation(&global); let mut enriched = proto_tcp_policy_fixture(); - assert!(enrich_proto_baseline_paths(&mut enriched)); + assert!(enrich_proto_baseline_paths(&mut enriched, None)); let (calls, mut observed) = tokio::sync::mpsc::unbounded_channel(); let gateway = WriteRefusingStartupGateway { desired: Arc::new(std::sync::Mutex::new(global)), diff --git a/docs/extensibility/isolation-backends.mdx b/docs/extensibility/isolation-backends.mdx index 8dd5903176..b7fd920c14 100644 --- a/docs/extensibility/isolation-backends.mdx +++ b/docs/extensibility/isolation-backends.mdx @@ -39,6 +39,13 @@ accept an operator-selected `IsolationBackendRegistration`, including its Unix, TCP, or vsock endpoint. The driver provisions the resource and delivers the backend's trusted launch metadata to the supervisor. +Drivers advertising `openshell.compute.proxy-filesystem-baseline` must provide +a `ProxyFilesystemBaseline` descriptor and the same baseline in `BackendSetup`. +Use paths in the workload's namespace; they need not exist on the gateway host. +An explicitly empty baseline disables legacy Linux defaults. Required paths +contribute to effective proxy-mode policy without widening authored read-only +grants. + ## Who Owns What | Component | Owns | diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index e50f41d0ee..a794b9428e 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -146,6 +146,17 @@ message GetCapabilitiesResponse { // Gateways require an exact policy match before activating the driver. // Empty denotes a legacy driver and requires explicit admission opt-out. string resource_admission_policy = 13; + // Trusted, unconditional workload-namespace prerequisites for proxy mode. + // Requires openshell.compute.proxy-filesystem-baseline. Presence, including + // an empty message, replaces legacy Linux defaults. The gateway includes + // these paths in effective policy before compute admission and signing. + ProxyFilesystemBaseline proxy_filesystem_baseline = 14; +} + +message ProxyFilesystemBaseline { + // POSIX absolute workload paths, never paths checked on the gateway host. + repeated string read_only = 1; + repeated string read_write = 2; } message AuthenticateSandboxRequest { diff --git a/tests/tmachine/ansible/playbooks/k3s.yaml b/tests/tmachine/ansible/playbooks/k3s.yaml index 8666b2ac6f..d27c6c487b 100644 --- a/tests/tmachine/ansible/playbooks/k3s.yaml +++ b/tests/tmachine/ansible/playbooks/k3s.yaml @@ -21,10 +21,12 @@ state: present update_cache: true + # get.k3s.io serves the latest script, so pin the one from the k3s_version + # release tag. Update the checksum whenever k3s_version changes. - name: Download K3s installer ansible.builtin.get_url: - url: https://get.k3s.io - checksum: sha256:e5cc3b3d9dfc1662c2d9be6da5abc9a4cd317d6abc3a5ffc02e3dd3248207fee + url: "https://raw.githubusercontent.com/k3s-io/k3s/v{{ k3s_version | urlencode }}/install.sh" + checksum: sha256:3ce239d57d43b2d836d2b561043433e6decae8b9dc41f5d13908c0fafb0340cd dest: /tmp/install-k3s.sh mode: "0755"