From 466c952995a3a58409e8c978da8b8cfee0043e22 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 15 Sep 2026 23:46:28 -0700 Subject: [PATCH 1/6] refactor(isolation): make confirmation backend-neutral Signed-off-by: Drew Newberry --- Cargo.lock | 1 + architecture/sandbox.md | 8 +- .../openshell-isolation-interface/Cargo.toml | 1 + .../src/contract.rs | 181 +++++++--------- .../openshell-isolation-interface/src/lib.rs | 6 +- .../tests/backend_conformance.rs | 67 ++---- .../src/boundary_protocol.rs | 204 +++++++++++++++++- .../openshell-sandbox-backend/src/runtime.rs | 45 ++-- .../openshell-sandbox/src/boundary_server.rs | 47 ++-- crates/openshell-sandbox/src/lib.rs | 2 +- crates/openshell-sandbox/src/main.rs | 2 +- 11 files changed, 375 insertions(+), 189 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 109e6df8d5..c1751d7d0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4394,6 +4394,7 @@ dependencies = [ "openshell-core", "rustix 1.1.4", "serde", + "serde_json", "tokio", ] diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 74bbfb4aea..07c49c5c50 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -70,8 +70,12 @@ replacement from granting authority. 3. `openshell-supervisor` loads policy and runtime settings from the gateway, attaches to the sandbox, and verifies the driver's generation and evidence. 4. The sandbox installs its seccomp notification broker and Landlock baseline, - then reports measured confirmation. The supervisor must accept that evidence -before it sends the launch permit. + validates its mechanism-specific audit evidence, and reports backend-neutral + enforcement properties. The supervisor must accept those properties and + their immutable session and resource binding before it sends the launch + permit. Other isolation backends may establish the same properties with + different mechanisms and retain their detailed evidence in backend-owned + audit data. 5. The sandbox starts the canonical process through its single workload launcher. The supervisor starts SSH and registers its gateway session. 6. Exec, signaling, PTY, DNS, TCP, and loopback-forwarding operations cross the diff --git a/crates/openshell-isolation-interface/Cargo.toml b/crates/openshell-isolation-interface/Cargo.toml index 94220dda73..43affc4927 100644 --- a/crates/openshell-isolation-interface/Cargo.toml +++ b/crates/openshell-isolation-interface/Cargo.toml @@ -14,6 +14,7 @@ repository.workspace = true openshell-core = { path = "../openshell-core", default-features = false } async-trait = "0.1" serde = { workspace = true } +serde_json = { workspace = true } tokio = { workspace = true } [target.'cfg(unix)'.dependencies] diff --git a/crates/openshell-isolation-interface/src/contract.rs b/crates/openshell-isolation-interface/src/contract.rs index 8e68d48c77..8dd01be38b 100644 --- a/crates/openshell-isolation-interface/src/contract.rs +++ b/crates/openshell-isolation-interface/src/contract.rs @@ -16,7 +16,8 @@ //! //! Each transition consumes the prior state by value (`self: Box`). //! Trusted backend implementations construct confirmation through a validating -//! constructor; the supervisor cannot obtain a ready boundary without evidence. +//! constructor; the supervisor cannot obtain a ready boundary without confirmed +//! backend-neutral enforcement properties. //! The supervisor holds no `match`/downcast on concrete backends: the //! registry is the only lookup by `backend_name`, and everything past it is a //! `Box` / `Arc`. @@ -385,46 +386,6 @@ pub trait BoundBoundary: Send { async fn confirm(self: Box) -> Result; } -/// Capability masks measured from `/proc//status`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct CapabilityEvidence { - pub inheritable: u64, - pub permitted: u64, - pub effective: u64, - pub bounding: u64, - pub ambient: u64, -} - -impl CapabilityEvidence { - /// True only when every Linux capability set is empty. - #[must_use] - pub const fn is_empty(self) -> bool { - self.inheritable == 0 - && self.permitted == 0 - && self.effective == 0 - && self.bounding == 0 - && self.ambient == 0 - } -} - -/// Active seccomp notification and socket-broker evidence. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[allow( - clippy::struct_excessive_bools, - reason = "each independently measured kernel operation is reported explicitly" -)] -pub struct SeccompEvidence { - pub new_listener: bool, - pub notification_round_trip: bool, - pub id_validation: bool, - pub addfd_send: bool, - pub retained_socket_operation: bool, - pub proc_fd_identity: bool, - pub task_memory_read: bool, - pub task_memory_write: bool, - pub cancellation: bool, -} - /// Driver-owned evidence that the mandatory outer network fence is installed. /// /// The sandbox cannot observe the Docker daemon, Kubernetes API, or VM device @@ -512,29 +473,67 @@ impl DriverFenceEvidence { } } -/// Measured sandbox-owned evidence produced before agent launch. +/// A backend-neutral security property established before agent launch. +/// +/// `mechanism` is diagnostic and audit metadata. It never authorizes launch; +/// the registered backend is responsible for validating its mechanism-specific +/// evidence before setting `enforced`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnforcedProperty { + pub enforced: bool, + pub mechanism: String, +} + +impl EnforcedProperty { + #[must_use] + pub fn new(enforced: bool, mechanism: impl Into) -> Self { + Self { + enforced, + mechanism: mechanism.into(), + } + } + + fn validate(&self, name: &str) -> Result<(), BackendError> { + if self.enforced && !self.mechanism.trim().is_empty() { + Ok(()) + } else { + Err(BackendError::Confirm(format!( + "{name} is not enforced or has no declared mechanism" + ))) + } + } +} + +/// Security properties every isolation backend establishes before launch. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[allow( - clippy::struct_excessive_bools, - reason = "confirmation preserves independently measured security results" -)] -pub struct SandboxConfirmEvidence { +pub struct BoundaryProperties { + pub filesystem_confinement: EnforcedProperty, + pub egress_interception: EnforcedProperty, + pub request_attribution: EnforcedProperty, + pub privilege_floor: EnforcedProperty, +} + +impl BoundaryProperties { + fn validate(&self) -> Result<(), BackendError> { + self.filesystem_confinement + .validate("filesystem confinement")?; + self.egress_interception.validate("egress interception")?; + self.request_attribution.validate("request attribution")?; + self.privilege_floor.validate("privilege floor") + } +} + +/// Per-boundary confirmation produced before agent launch. +/// +/// Common validation binds the confirmation to the admitted workload and +/// checks backend-neutral properties. `backend_audit` remains opaque to this +/// crate; the registered backend owns its schema and validates it before +/// constructing [`ConfirmedBoundary`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BoundaryConfirmation { pub generation: String, pub identity: ResolvedWorkloadIdentity, - pub capabilities: CapabilityEvidence, - pub no_new_privileges: bool, - pub sandbox_dumpable: bool, - pub child_dumpable: bool, - pub core_limit_zero: bool, - pub native_architecture: String, - pub kernel_release: String, - pub seccomp: SeccompEvidence, - pub landlock_abi: u32, - pub landlock_allow_deny: bool, - pub udp_dns_round_trip: bool, - pub tcp_dns_round_trip: bool, - pub tcp_allow_round_trip: bool, - pub tcp_deny_round_trip: bool, + pub properties: BoundaryProperties, pub authenticated_supervisor: bool, pub session_id: SandboxSessionId, pub driver_fence: DriverFenceEvidence, @@ -542,33 +541,15 @@ pub struct SandboxConfirmEvidence { /// Sandbox Runtime exits. pub runtime_exit_terminates_workload: bool, pub resource_claims: BTreeMap, + pub backend_audit: serde_json::Value, } -impl SandboxConfirmEvidence { - /// Validate the security-critical evidence required before launch. +impl BoundaryConfirmation { + /// Validate common security properties and immutable launch binding. pub fn validate(&self, expected: &ResolvedWorkloadIdentity) -> Result<(), BackendError> { self.driver_fence.validate()?; + self.properties.validate()?; let complete = &self.identity == expected - && self.capabilities.is_empty() - && self.no_new_privileges - && !self.sandbox_dumpable - && self.child_dumpable - && self.core_limit_zero - && self.seccomp.new_listener - && self.seccomp.notification_round_trip - && self.seccomp.id_validation - && self.seccomp.addfd_send - && self.seccomp.retained_socket_operation - && self.seccomp.proc_fd_identity - && self.seccomp.task_memory_read - && self.seccomp.task_memory_write - && self.seccomp.cancellation - && self.landlock_abi >= 3 - && self.landlock_allow_deny - && self.udp_dns_round_trip - && self.tcp_dns_round_trip - && self.tcp_allow_round_trip - && self.tcp_deny_round_trip && self.authenticated_supervisor && self.runtime_exit_terminates_workload && !self.generation.is_empty(); @@ -576,41 +557,45 @@ impl SandboxConfirmEvidence { Ok(()) } else { Err(BackendError::Confirm( - "sandbox confirmation evidence is incomplete or mismatched".to_string(), + "boundary confirmation is incomplete or mismatched".to_string(), )) } } } -/// Ready boundary paired with the evidence measured by `confirm`. +/// Ready boundary paired with the confirmation established by `confirm`. pub struct ConfirmedBoundary { boundary: Box, - evidence: SandboxConfirmEvidence, + confirmation: BoundaryConfirmation, } impl ConfirmedBoundary { - /// Construct confirmation after checking measured evidence against the - /// immutable identity admitted at attach time. + /// Construct confirmation after checking backend-neutral properties and + /// immutable identity binding. /// - /// Backend implementations are trusted to collect this evidence and bind - /// it to their resource. This constructor enforces the common requirements - /// without requiring those implementations to live in the interface crate. + /// Backend implementations are trusted to validate their audit evidence and + /// bind this confirmation to their resource. This constructor enforces the + /// common requirements without requiring those implementations to live in + /// the interface crate. /// /// # Errors /// - /// Returns an error if evidence is incomplete or the identity does not match. + /// Returns an error if confirmation is incomplete or the identity does not match. pub fn try_new( boundary: Box, - evidence: SandboxConfirmEvidence, + confirmation: BoundaryConfirmation, expected: &ResolvedWorkloadIdentity, ) -> Result { - evidence.validate(expected)?; - Ok(Self { boundary, evidence }) + confirmation.validate(expected)?; + Ok(Self { + boundary, + confirmation, + }) } - /// Return the measured evidence carried by this confirmed state. - pub fn evidence(&self) -> &SandboxConfirmEvidence { - &self.evidence + /// Return the record carried by this confirmed state. + pub fn confirmation(&self) -> &BoundaryConfirmation { + &self.confirmation } /// Consume confirmation and advance to the sole launch-capable state. diff --git a/crates/openshell-isolation-interface/src/lib.rs b/crates/openshell-isolation-interface/src/lib.rs index a7740d8a7d..b09320dc97 100644 --- a/crates/openshell-isolation-interface/src/lib.rs +++ b/crates/openshell-isolation-interface/src/lib.rs @@ -21,8 +21,10 @@ //! `start_agent` -> Running. Nothing untrusted runs inside the boundary until it //! is confirmed ready. This is enforced *by construction*: each transition //! consumes the prior state by value. Trusted backends construct confirmation -//! through [`contract::ConfirmedBoundary::try_new`], which checks common evidence -//! before the supervisor can obtain a [`contract::ReadyBoundary`]. +//! through [`contract::ConfirmedBoundary::try_new`], which checks common +//! enforcement properties and immutable launch binding before the supervisor +//! can obtain a [`contract::ReadyBoundary`]. Mechanism-specific evidence stays +//! owned by the backend that can interpret it. //! //! [`AgentSpec`] is shared between the workload definition the supervisor //! submits and the [`contract::SandboxContext`] that `attach` binds to a diff --git a/crates/openshell-isolation-interface/tests/backend_conformance.rs b/crates/openshell-isolation-interface/tests/backend_conformance.rs index 73559b203f..d5f6e5b22a 100644 --- a/crates/openshell-isolation-interface/tests/backend_conformance.rs +++ b/crates/openshell-isolation-interface/tests/backend_conformance.rs @@ -239,7 +239,7 @@ impl BoundBoundary for MockBound { async fn confirm(self: Box) -> Result { ConfirmedBoundary::try_new( Box::new(MockReady:: { _k: PhantomData }), - confirmation_evidence(), + confirmation(), &workload_identity(), ) } @@ -368,40 +368,16 @@ fn workload_identity() -> ResolvedWorkloadIdentity { .unwrap() } -fn confirmation_evidence() -> SandboxConfirmEvidence { - SandboxConfirmEvidence { +fn confirmation() -> BoundaryConfirmation { + BoundaryConfirmation { generation: "generation-1".to_string(), identity: workload_identity(), - capabilities: CapabilityEvidence { - inheritable: 0, - permitted: 0, - effective: 0, - bounding: 0, - ambient: 0, + properties: BoundaryProperties { + filesystem_confinement: EnforcedProperty::new(true, "mock-filesystem"), + egress_interception: EnforcedProperty::new(true, "mock-egress"), + request_attribution: EnforcedProperty::new(true, "mock-attribution"), + privilege_floor: EnforcedProperty::new(true, "mock-privilege-floor"), }, - no_new_privileges: true, - sandbox_dumpable: false, - child_dumpable: true, - core_limit_zero: true, - native_architecture: std::env::consts::ARCH.to_string(), - kernel_release: "test".to_string(), - seccomp: SeccompEvidence { - new_listener: true, - notification_round_trip: true, - id_validation: true, - addfd_send: true, - retained_socket_operation: true, - proc_fd_identity: true, - task_memory_read: true, - task_memory_write: true, - cancellation: true, - }, - landlock_abi: 3, - landlock_allow_deny: true, - udp_dns_round_trip: true, - tcp_dns_round_trip: true, - tcp_allow_round_trip: true, - tcp_deny_round_trip: true, authenticated_supervisor: true, session_id: SandboxSessionId::new(), driver_fence: DriverFenceEvidence::Vm { @@ -410,6 +386,7 @@ fn confirmation_evidence() -> SandboxConfirmEvidence { }, runtime_exit_terminates_workload: true, resource_claims: BTreeMap::new(), + backend_audit: serde_json::json!({"backend": "mock"}), } } @@ -458,7 +435,7 @@ async fn drive( let _ingress = bound.network_mediation_source(); assert_eq!(bound.host_gateway_ip(), None); let confirmed = bound.confirm().await?; - confirmed.evidence().validate(&sandbox_ctx().identity)?; + confirmed.confirmation().validate(&sandbox_ctx().identity)?; confirmed.into_boundary().start_agent().await } @@ -536,12 +513,12 @@ async fn one_driver_runs_both_backends() { } #[test] -fn confirmation_constructor_rejects_incomplete_evidence() { - let mut evidence = confirmation_evidence(); - evidence.seccomp.cancellation = false; +fn confirmation_constructor_rejects_unenforced_property() { + let mut confirmation = confirmation(); + confirmation.properties.egress_interception.enforced = false; let result = ConfirmedBoundary::try_new( Box::new(MockReady:: { _k: PhantomData }), - evidence, + confirmation, &workload_identity(), ); assert!(matches!(result, Err(BackendError::Confirm(_)))); @@ -559,7 +536,7 @@ fn confirmation_constructor_rejects_another_workload_identity() { .unwrap(); let result = ConfirmedBoundary::try_new( Box::new(MockReady:: { _k: PhantomData }), - confirmation_evidence(), + confirmation(), &expected, ); assert!(matches!(result, Err(BackendError::Confirm(_)))); @@ -842,16 +819,16 @@ fn workload_identity_rejects_root_and_normalizes_groups() { } #[test] -fn confirmation_evidence_rejects_identity_or_posture_drift() { +fn confirmation_rejects_identity_or_property_drift() { let expected = workload_identity(); - let evidence = confirmation_evidence(); - evidence.validate(&expected).unwrap(); + let baseline = confirmation(); + baseline.validate(&expected).unwrap(); - let mut drifted = confirmation_evidence(); - drifted.capabilities.effective = 1; + let mut drifted = confirmation(); + drifted.properties.privilege_floor.enforced = false; assert!(drifted.validate(&expected).is_err()); - let mut unmanaged = confirmation_evidence(); + let mut unmanaged = confirmation(); unmanaged.runtime_exit_terminates_workload = false; assert!(unmanaged.validate(&expected).is_err()); @@ -863,7 +840,7 @@ fn confirmation_evidence_rejects_identity_or_posture_drift() { "sha256:test".into(), ) .unwrap(); - assert!(evidence.validate(&different).is_err()); + assert!(baseline.validate(&different).is_err()); } // --------------------------------------------------------------------------- diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index 05542fb2bc..d983b329e9 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -23,8 +23,9 @@ use openshell_core::policy::{ use openshell_isolation_interface::AgentSpec; use openshell_isolation_interface::contract::Sha256Digest; use openshell_isolation_interface::contract::{ - BackendDescriptor, BackendError, BinaryIdentity, BoundaryExitStatus, BoundarySignal, - DriverFenceEvidence, ExecSpec, ResolveError, SandboxConfirmEvidence, + BackendDescriptor, BackendError, BinaryIdentity, BoundaryConfirmation, BoundaryExitStatus, + BoundaryProperties, BoundarySignal, DriverFenceEvidence, EnforcedProperty, ExecSpec, + ResolveError, }; use rcgen::{CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose}; use serde::de::DeserializeOwned; @@ -42,6 +43,145 @@ pub const STREAM_STDIN_CLOSED: u8 = 4; pub const STREAM_NETWORK_DECISION: u8 = 5; pub const MAX_STREAM_FRAME_BYTES: usize = 64 * 1024; +/// Capability masks measured from `/proc//status` by the `OpenShell` +/// co-located runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityEvidence { + pub inheritable: u64, + pub permitted: u64, + pub effective: u64, + pub bounding: u64, + pub ambient: u64, +} + +impl CapabilityEvidence { + #[must_use] + pub const fn is_empty(self) -> bool { + self.inheritable == 0 + && self.permitted == 0 + && self.effective == 0 + && self.bounding == 0 + && self.ambient == 0 + } +} + +/// Active seccomp notification and socket-broker measurements specific to the +/// `OpenShell` co-located runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[allow( + clippy::struct_excessive_bools, + reason = "each independently measured kernel operation is reported explicitly" +)] +pub struct SeccompEvidence { + pub new_listener: bool, + pub notification_round_trip: bool, + pub id_validation: bool, + pub addfd_send: bool, + pub retained_socket_operation: bool, + pub proc_fd_identity: bool, + pub task_memory_read: bool, + pub task_memory_write: bool, + pub cancellation: bool, +} + +/// Mechanism-specific audit evidence for the `OpenShell` co-located runtime. +/// +/// This schema belongs to this backend rather than the generic isolation +/// interface. The host-side backend validates it before constructing a +/// backend-neutral `ConfirmedBoundary`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[allow( + clippy::struct_excessive_bools, + reason = "audit evidence preserves independently measured security results" +)] +pub struct OpenShellSandboxAuditEvidence { + pub capabilities: CapabilityEvidence, + pub no_new_privileges: bool, + pub sandbox_dumpable: bool, + pub child_dumpable: bool, + pub core_limit_zero: bool, + pub native_architecture: String, + pub kernel_release: String, + pub seccomp: SeccompEvidence, + pub landlock_abi: u32, + pub landlock_allow_deny: bool, + pub udp_dns_round_trip: bool, + pub tcp_dns_round_trip: bool, + pub tcp_allow_round_trip: bool, + pub tcp_deny_round_trip: bool, +} + +impl OpenShellSandboxAuditEvidence { + /// Validate the complete mechanism-specific posture required by this backend. + pub fn validate(&self) -> Result<(), BackendError> { + let complete = self.capabilities.is_empty() + && self.no_new_privileges + && !self.sandbox_dumpable + && self.child_dumpable + && self.core_limit_zero + && !self.native_architecture.is_empty() + && !self.kernel_release.is_empty() + && self.seccomp.new_listener + && self.seccomp.notification_round_trip + && self.seccomp.id_validation + && self.seccomp.addfd_send + && self.seccomp.retained_socket_operation + && self.seccomp.proc_fd_identity + && self.seccomp.task_memory_read + && self.seccomp.task_memory_write + && self.seccomp.cancellation + && self.landlock_abi >= 3 + && self.landlock_allow_deny + && self.udp_dns_round_trip + && self.tcp_dns_round_trip + && self.tcp_allow_round_trip + && self.tcp_deny_round_trip; + if complete { + Ok(()) + } else { + Err(BackendError::Confirm( + "OpenShell sandbox audit evidence is incomplete".to_string(), + )) + } + } + + /// Project backend measurements into the common property contract. + #[must_use] + pub fn properties(&self) -> BoundaryProperties { + BoundaryProperties { + filesystem_confinement: EnforcedProperty::new( + self.landlock_abi >= 3 && self.landlock_allow_deny, + format!("landlock-v{}", self.landlock_abi), + ), + egress_interception: EnforcedProperty::new( + self.seccomp.new_listener + && self.seccomp.notification_round_trip + && self.seccomp.addfd_send + && self.udp_dns_round_trip + && self.tcp_dns_round_trip + && self.tcp_allow_round_trip + && self.tcp_deny_round_trip, + "seccomp-notify", + ), + request_attribution: EnforcedProperty::new( + self.seccomp.id_validation + && self.seccomp.proc_fd_identity + && self.seccomp.task_memory_read + && self.seccomp.task_memory_write, + "seccomp-notify-procfs", + ), + privilege_floor: EnforcedProperty::new( + self.capabilities.is_empty() + && self.no_new_privileges + && !self.sandbox_dumpable + && self.child_dumpable + && self.core_limit_zero, + "linux-capability-free", + ), + } + } +} + /// Ephemeral identity of the supervisor process that owns one sandbox runtime. /// /// The supervisor generates this value in memory and presents it on every @@ -712,8 +852,9 @@ pub enum Response { snapshot: SessionSnapshotWire, }, Confirmed { - /// Measured capability-free posture produced before workload launch. - evidence: Box, + /// Backend-neutral properties and backend-owned audit evidence produced + /// before workload launch. + confirmation: Box, }, Started { process_id: String, @@ -1196,6 +1337,61 @@ pub enum FrameError { mod tests { use super::*; + fn complete_audit_evidence() -> OpenShellSandboxAuditEvidence { + OpenShellSandboxAuditEvidence { + capabilities: CapabilityEvidence { + inheritable: 0, + permitted: 0, + effective: 0, + bounding: 0, + ambient: 0, + }, + no_new_privileges: true, + sandbox_dumpable: false, + child_dumpable: true, + core_limit_zero: true, + native_architecture: "x86_64".to_string(), + kernel_release: "6.12.0".to_string(), + seccomp: SeccompEvidence { + new_listener: true, + notification_round_trip: true, + id_validation: true, + addfd_send: true, + retained_socket_operation: true, + proc_fd_identity: true, + task_memory_read: true, + task_memory_write: true, + cancellation: true, + }, + landlock_abi: 6, + landlock_allow_deny: true, + udp_dns_round_trip: true, + tcp_dns_round_trip: true, + tcp_allow_round_trip: true, + tcp_deny_round_trip: true, + } + } + + #[test] + fn openshell_audit_evidence_projects_backend_neutral_properties() { + let audit = complete_audit_evidence(); + audit.validate().unwrap(); + let properties = audit.properties(); + assert!(properties.filesystem_confinement.enforced); + assert_eq!(properties.filesystem_confinement.mechanism, "landlock-v6"); + assert!(properties.egress_interception.enforced); + assert!(properties.request_attribution.enforced); + assert!(properties.privilege_floor.enforced); + } + + #[test] + fn openshell_audit_evidence_rejects_mechanism_failure() { + let mut audit = complete_audit_evidence(); + audit.seccomp.addfd_send = false; + assert!(audit.validate().is_err()); + assert!(!audit.properties().egress_interception.enforced); + } + #[test] fn binary_identity_wire_rejects_ambiguous_or_invalid_shapes() { for encoded in [ diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index 1b56d6df9d..5721618824 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -307,19 +307,29 @@ impl BoundBoundary for RemoteBound { async fn confirm(self: Box) -> Result { let response = self.client.call_idempotent(Request::Confirm).await?; - let Response::Confirmed { evidence } = response else { - return Err(unexpected_response("confirmed_with_evidence", &response)); + let Response::Confirmed { confirmation } = response else { + return Err(unexpected_response("confirmed", &response)); }; - if evidence.generation != self.generation - || evidence.session_id != self.session_id - || evidence.resource_claims != self.resource_claims - || evidence.driver_fence != self.driver_fence + if confirmation.generation != self.generation + || confirmation.session_id != self.session_id + || confirmation.resource_claims != self.resource_claims + || confirmation.driver_fence != self.driver_fence { return Err(BackendError::Confirm( "sandbox confirmation generation, session, resource claims, or driver fence do not match runtime descriptor" .to_string(), )); } + let audit: crate::boundary_protocol::OpenShellSandboxAuditEvidence = + serde_json::from_value(confirmation.backend_audit.clone()).map_err(|error| { + BackendError::Confirm(format!("decode OpenShell sandbox audit evidence: {error}")) + })?; + audit.validate()?; + if confirmation.properties != audit.properties() { + return Err(BackendError::Confirm( + "sandbox confirmation properties do not match OpenShell audit evidence".to_string(), + )); + } self.client.start_credential_monitor(); ConfirmedBoundary::try_new( Box::new(RemoteReady { @@ -330,7 +340,7 @@ impl BoundBoundary for RemoteBound { ca_file_paths: self.ca_file_paths, provider_credentials: self.provider_credentials, }), - *evidence, + *confirmation, &self.identity, ) } @@ -2024,7 +2034,7 @@ mod tests { }, }, Request::Confirm => Response::Confirmed { - evidence: Box::new(test_confirmation_evidence()), + confirmation: Box::new(test_confirmation()), }, Request::OpenMediation if mediation_ready => Response::MediationReady, Request::OpenMediation => Response::Error { @@ -2552,12 +2562,9 @@ mod tests { } } - fn test_confirmation_evidence() - -> openshell_isolation_interface::contract::SandboxConfirmEvidence { - openshell_isolation_interface::contract::SandboxConfirmEvidence { - generation: "test-generation".to_string(), - identity: sandbox().identity, - capabilities: openshell_isolation_interface::contract::CapabilityEvidence { + fn test_confirmation() -> openshell_isolation_interface::contract::BoundaryConfirmation { + let audit = crate::boundary_protocol::OpenShellSandboxAuditEvidence { + capabilities: crate::boundary_protocol::CapabilityEvidence { inheritable: 0, permitted: 0, effective: 0, @@ -2570,7 +2577,7 @@ mod tests { core_limit_zero: true, native_architecture: std::env::consts::ARCH.to_string(), kernel_release: "test".to_string(), - seccomp: openshell_isolation_interface::contract::SeccompEvidence { + seccomp: crate::boundary_protocol::SeccompEvidence { new_listener: true, notification_round_trip: true, id_validation: true, @@ -2587,11 +2594,17 @@ mod tests { tcp_dns_round_trip: true, tcp_allow_round_trip: true, tcp_deny_round_trip: true, + }; + openshell_isolation_interface::contract::BoundaryConfirmation { + generation: "test-generation".to_string(), + identity: sandbox().identity, + properties: audit.properties(), authenticated_supervisor: true, session_id: test_session_id(), driver_fence: test_driver_fence(), runtime_exit_terminates_workload: true, resource_claims: std::collections::BTreeMap::new(), + backend_audit: serde_json::to_value(audit).expect("serialize audit evidence"), } } @@ -2709,7 +2722,7 @@ mod tests { .await .expect("TLS request"), Response::Confirmed { - evidence: Box::new(test_confirmation_evidence()), + confirmation: Box::new(test_confirmation()), } ); server.abort(); diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index 5452ea6efa..a465b144bd 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -36,9 +36,8 @@ mod linux { }; use openshell_core::provider_credentials::ProviderCredentialState; use openshell_isolation_interface::contract::{ - BoundaryExec, BoundaryLoopbackConnector, BoundaryProcess, BoundaryTerminal, - CapabilityEvidence, ExecSession, LoopbackTarget, ResolvedWorkloadIdentity, - SandboxConfirmEvidence, + BoundaryConfirmation, BoundaryExec, BoundaryLoopbackConnector, BoundaryProcess, + BoundaryTerminal, ExecSession, LoopbackTarget, ResolvedWorkloadIdentity, }; use openshell_sandbox_backend::GPU_RESOURCE_CLAIM; use openshell_sandbox_backend::mediation::{ @@ -60,11 +59,11 @@ mod linux { use openshell_sandbox_backend::boundary_protocol::{ AgentSpecWire, BinaryIdentityWire, BoundaryConfig, BoundaryErrorKind, BoundaryListener as BoundaryListenerConfig, DnsQueryResultWire, ExecSpecWire, - ExitStatusWire, MediationTimingWire, OutputWindowWire, ProcessKindWire, - ProcessSnapshotWire, Request, RequestEnvelope, Response, ResponseEnvelope, STREAM_EXIT, - STREAM_NETWORK_DECISION, STREAM_STDERR, STREAM_STDIN, STREAM_STDIN_CLOSED, STREAM_STDOUT, - SandboxPolicyWire, SessionSnapshotWire, SignalWire, encode_frame, read_frame, - read_stream_frame, validate_resource_claims, write_frame, write_stream_frame, + ExitStatusWire, MediationTimingWire, OpenShellSandboxAuditEvidence, OutputWindowWire, + ProcessKindWire, ProcessSnapshotWire, Request, RequestEnvelope, Response, ResponseEnvelope, + STREAM_EXIT, STREAM_NETWORK_DECISION, STREAM_STDERR, STREAM_STDIN, STREAM_STDIN_CLOSED, + STREAM_STDOUT, SandboxPolicyWire, SessionSnapshotWire, SignalWire, encode_frame, + read_frame, read_stream_frame, validate_resource_claims, write_frame, write_stream_frame, }; const CONTROL_IO_TIMEOUT: Duration = Duration::from_secs(30); @@ -2262,20 +2261,20 @@ mod linux { if let Err(error) = prepared.confirm(&self.process_runtime) { return guest_error(BoundaryErrorKind::Process, error); } - let evidence = match self.measure_confirmation_evidence() { - Ok(evidence) => evidence, + let confirmation = match self.measure_confirmation() { + Ok(confirmation) => confirmation, Err(error) => return guest_error(BoundaryErrorKind::Process, error), }; *state = RuntimeState::Ready(prepared.clone()); Response::Confirmed { - evidence: Box::new(evidence), + confirmation: Box::new(confirmation), } } RuntimeState::Ready(_) | RuntimeState::Running(_) => { - self.measure_confirmation_evidence().map_or_else( + self.measure_confirmation().map_or_else( |error| guest_error(BoundaryErrorKind::Process, error), - |evidence| Response::Confirmed { - evidence: Box::new(evidence), + |confirmation| Response::Confirmed { + confirmation: Box::new(confirmation), }, ) } @@ -2286,7 +2285,7 @@ mod linux { } } - fn measure_confirmation_evidence(&self) -> Result { + fn measure_confirmation(&self) -> Result { validate_running_identity( &self.config.workload_identity, allows_runtime_supplementary_groups(&self.config), @@ -2299,7 +2298,7 @@ mod linux { } let status = std::fs::read_to_string("/proc/self/status") .map_err(|error| format!("read sandbox process status: {error}"))?; - let capabilities = CapabilityEvidence { + let capabilities = openshell_sandbox_backend::boundary_protocol::CapabilityEvidence { inheritable: parse_status_hex(&status, "CapInh")?, permitted: parse_status_hex(&status, "CapPrm")?, effective: parse_status_hex(&status, "CapEff")?, @@ -2320,9 +2319,7 @@ mod linux { // SAFETY: successful getrlimit initialized the value. let core_limit = unsafe { core_limit.assume_init() }; let (native_architecture, kernel_release) = uname_values()?; - Ok(SandboxConfirmEvidence { - generation: self.config.generation.clone(), - identity: self.config.workload_identity.clone(), + let audit = OpenShellSandboxAuditEvidence { capabilities, no_new_privileges, sandbox_dumpable, @@ -2337,11 +2334,21 @@ mod linux { tcp_dns_round_trip: self.qualification.tcp_dns_round_trip, tcp_allow_round_trip: self.qualification.tcp_allow_round_trip, tcp_deny_round_trip: self.qualification.tcp_deny_round_trip, + }; + audit.validate().map_err(|error| error.to_string())?; + let properties = audit.properties(); + let backend_audit = serde_json::to_value(audit) + .map_err(|error| format!("encode OpenShell sandbox audit evidence: {error}"))?; + Ok(BoundaryConfirmation { + generation: self.config.generation.clone(), + identity: self.config.workload_identity.clone(), + properties, authenticated_supervisor: true, session_id: self.config.session_id, driver_fence: self.config.driver_fence.clone(), runtime_exit_terminates_workload: true, resource_claims: self.config.resource_claims.clone(), + backend_audit, }) } @@ -4267,7 +4274,7 @@ mod linux { fn test_runtime_qualification() -> crate::RuntimeQualification { crate::RuntimeQualification { - seccomp: openshell_isolation_interface::contract::SeccompEvidence { + seccomp: openshell_sandbox_backend::boundary_protocol::SeccompEvidence { new_listener: true, notification_round_trip: true, id_validation: true, diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 4928fe6f40..4166d8d2c1 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -34,7 +34,7 @@ pub mod sandbox; reason = "qualification preserves independently exercised security results" )] pub struct RuntimeQualification { - pub seccomp: openshell_isolation_interface::contract::SeccompEvidence, + pub seccomp: openshell_sandbox_backend::boundary_protocol::SeccompEvidence, pub landlock_abi: u32, pub landlock_allow_deny: bool, pub udp_dns_round_trip: bool, diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 24fa65ab16..dbdc8733ec 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -239,7 +239,7 @@ fn qualify_runtime() -> Result<(openshell_sandbox::RuntimeQualification, Qualifi wait_killable_recv: notification.wait_killable_recv, }; let qualification = openshell_sandbox::RuntimeQualification { - seccomp: openshell_isolation_interface::contract::SeccompEvidence { + seccomp: openshell_sandbox_backend::boundary_protocol::SeccompEvidence { new_listener: notification.notification_round_trip(), notification_round_trip: notification.notification_round_trip(), id_validation: notification.notification_round_trip(), From 63ff35114c30992d66a50080cf4a7aad5f9cdfae Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 16 Sep 2026 00:48:01 -0700 Subject: [PATCH 2/6] fix(sandbox): validate confirmation evidence at host boundary Signed-off-by: Drew Newberry --- crates/openshell-sandbox/src/boundary_server.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index a465b144bd..94bc0d0d5b 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -2335,7 +2335,10 @@ mod linux { tcp_allow_round_trip: self.qualification.tcp_allow_round_trip, tcp_deny_round_trip: self.qualification.tcp_deny_round_trip, }; - audit.validate().map_err(|error| error.to_string())?; + // The boundary reports mechanism evidence; the authenticated host + // backend validates it before constructing a ConfirmedBoundary. + // Keeping that decision at the verifier also lets lifecycle tests + // exercise the protocol without claiming host-kernel enforcement. let properties = audit.properties(); let backend_audit = serde_json::to_value(audit) .map_err(|error| format!("encode OpenShell sandbox audit evidence: {error}"))?; From d8d27f2162c9efdff1b51ff8dd0aebb774c61f33 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 16 Sep 2026 11:58:44 -0700 Subject: [PATCH 3/6] refactor(isolation): keep fence evidence driver-owned Signed-off-by: Drew Newberry --- Cargo.lock | 1 + architecture/sandbox.md | 12 +- .../openshell-driver-docker/src/isolation.rs | 63 ++++++-- crates/openshell-driver-docker/src/lib.rs | 3 +- .../openshell-driver-kubernetes/src/driver.rs | 6 +- .../src/isolation.rs | 67 +++++--- .../openshell-driver-podman/src/isolation.rs | 45 ++++-- .../openshell-driver-vm/src/isolation/mod.rs | 42 +++-- .../openshell-isolation-interface/Cargo.toml | 1 + .../src/contract.rs | 152 +++++++++--------- .../tests/backend_conformance.rs | 57 ++++--- .../src/boundary_protocol.rs | 14 +- .../openshell-sandbox-backend/src/runtime.rs | 41 ++--- .../openshell-sandbox/src/boundary_server.rs | 31 ++-- 14 files changed, 328 insertions(+), 207 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c1751d7d0d..d8e5d933a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4395,6 +4395,7 @@ dependencies = [ "rustix 1.1.4", "serde", "serde_json", + "sha2 0.10.9", "tokio", ] diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 07c49c5c50..1ee0750804 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -63,7 +63,10 @@ replacement from granting authority. ## Startup Flow 1. The driver resolves the immutable workload identity, installs the outer - network fence, and starts `openshell-sandbox` with one-use bootstrap state. + network fence, validates its native evidence, and starts `openshell-sandbox` + with one-use bootstrap state. Docker inspects container networking, + Kubernetes verifies its NetworkPolicy, and VM drivers inspect the guest + device model; those native schemas remain in their driver crates. 2. The sandbox consumes and unlinks bootstrap material, proves the admitted runtime posture, and listens on the protected driver channel. It does not run untrusted code yet. @@ -81,6 +84,13 @@ replacement from granting authority. 6. Exec, signaling, PTY, DNS, TCP, and loopback-forwarding operations cross the authenticated channel for the lifetime of the sandbox generation. +The shared isolation contract receives only the driver's normalized outer-fence +guarantees: egress is default-deny, there is no unmanaged egress path, the +evidence is bound to the sandbox generation, revocation has been verified, and +controller loss fails closed. A digest commits those guarantees to the native +driver evidence without teaching the shared contract about container networks, +Kubernetes objects, VM devices, or accelerator resources. + When the admitted main process exits, its status and retained terminal output remain available. The confirmed sandbox and supervisor-owned access plane continue to serve policy-authorized exec and loopback forwarding until explicit stop or diff --git a/crates/openshell-driver-docker/src/isolation.rs b/crates/openshell-driver-docker/src/isolation.rs index 5e50a37ad7..b7f75efee4 100644 --- a/crates/openshell-driver-docker/src/isolation.rs +++ b/crates/openshell-driver-docker/src/isolation.rs @@ -11,12 +11,39 @@ use std::collections::{BTreeMap, HashMap}; use std::net::IpAddr; use std::path::PathBuf; -use openshell_isolation_interface::contract::{DriverFenceEvidence, ResolvedWorkloadIdentity}; +use openshell_isolation_interface::contract::{ + BackendError, OuterFenceGuarantees, ResolvedWorkloadIdentity, +}; use openshell_sandbox_backend::GPU_RESOURCE_CLAIM; use openshell_sandbox_backend::boundary_protocol::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, }; +use serde::Serialize; + +#[derive(Serialize)] +struct DockerOuterFenceEvidence<'a> { + container_id: &'a str, + network_mode: &'static str, + unexpected_networks: &'a [String], +} + +impl DockerOuterFenceEvidence<'_> { + fn project(&self, generation: &str) -> Result { + if self.container_id.is_empty() + || self.network_mode != "none" + || !self.unexpected_networks.is_empty() + { + return Err(BackendError::Descriptor( + "Docker outer fence evidence is incomplete".to_string(), + )); + } + let encoded = serde_json::to_vec(self).map_err(|error| { + BackendError::Descriptor(format!("encode Docker outer fence evidence: {error}")) + })?; + OuterFenceGuarantees::confirmed(generation, &encoded) + } +} /// Driver-owned inputs that bind one Docker container to one boundary. pub struct DockerBoundarySpec { @@ -48,8 +75,7 @@ pub struct DockerBoundaryProvisioning { impl DockerBoundarySpec { /// Produce both sides of the common protocol from the same immutable /// Docker coordinates so attach cannot bind a different container. - #[must_use] - pub fn provision(self) -> DockerBoundaryProvisioning { + pub fn provision(self) -> Result { let mut resource_claims = BTreeMap::from([ ("docker.container_id".to_string(), self.container_id), ("docker.image_identity".to_string(), self.image_identity), @@ -57,12 +83,14 @@ impl DockerBoundarySpec { if self.gpu_requested { resource_claims.insert(GPU_RESOURCE_CLAIM.to_string(), "true".to_string()); } - let driver_fence = DriverFenceEvidence::Docker { - container_id: resource_claims["docker.container_id"].clone(), - network_mode: "none".to_string(), - unexpected_networks: Vec::new(), - }; - DockerBoundaryProvisioning { + let unexpected_networks = Vec::new(); + let outer_fence = DockerOuterFenceEvidence { + container_id: &resource_claims["docker.container_id"], + network_mode: "none", + unexpected_networks: &unexpected_networks, + } + .project(&self.generation)?; + Ok(DockerBoundaryProvisioning { boundary_config: BoundaryConfig { boundary_id: self.boundary_id.clone(), generation: self.generation.clone(), @@ -78,7 +106,7 @@ impl DockerBoundarySpec { resource_claims: resource_claims.clone(), resource_claim_files: BTreeMap::new(), workload_identity: self.workload_identity.clone(), - driver_fence: driver_fence.clone(), + outer_fence: outer_fence.clone(), child_env: self.child_env, }, runtime_descriptor: SandboxRuntimeDescriptor { @@ -92,9 +120,9 @@ impl DockerBoundarySpec { tls: self.supervisor_tls, host_gateway_ip: self.host_gateway_ip, resource_claims, - driver_fence, + outer_fence, }, - } + }) } } @@ -143,7 +171,8 @@ mod tests { .unwrap(), child_env: HashMap::new(), } - .provision(); + .provision() + .unwrap(); assert_eq!( provisioned.boundary_config.resource_claims, @@ -158,14 +187,14 @@ mod tests { "true" ); assert_eq!( - provisioned.boundary_config.driver_fence, - provisioned.runtime_descriptor.driver_fence + provisioned.boundary_config.outer_fence, + provisioned.runtime_descriptor.outer_fence ); assert!( provisioned .runtime_descriptor - .driver_fence - .validate() + .outer_fence + .validate("generation-1") .is_ok() ); } diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index 4230f75136..461ec6079e 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -4433,7 +4433,8 @@ async fn prepare_docker_boundary_files( workload_identity: workload_identity.clone(), child_env: docker_child_environment(sandbox), } - .provision(); + .provision() + .map_err(|error| Status::failed_precondition(error.to_string()))?; let boundary_config = provisioning .boundary_config .encode() diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index cbdd5bcc7f..1813496660 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -2374,7 +2374,8 @@ impl KubernetesComputeDriver { workload_identity, child_env, } - .provision(); + .provision() + .map_err(|error| KubernetesDriverError::Message(error.to_string()))?; let descriptor = provisioned .runtime_descriptor .backend_descriptor() @@ -2612,7 +2613,8 @@ impl KubernetesComputeDriver { workload_identity, child_env, } - .provision(); + .provision() + .map_err(|error| KubernetesDriverError::Message(error.to_string()))?; let descriptor = provisioned .runtime_descriptor .backend_descriptor() diff --git a/crates/openshell-driver-kubernetes/src/isolation.rs b/crates/openshell-driver-kubernetes/src/isolation.rs index bb9ae8ff66..db82f2adc5 100644 --- a/crates/openshell-driver-kubernetes/src/isolation.rs +++ b/crates/openshell-driver-kubernetes/src/isolation.rs @@ -20,11 +20,42 @@ use k8s_openapi::api::networking::v1::{ use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector; use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString; use kube::core::ObjectMeta; -use openshell_isolation_interface::contract::{DriverFenceEvidence, ResolvedWorkloadIdentity}; +use openshell_isolation_interface::contract::{ + BackendError, OuterFenceGuarantees, ResolvedWorkloadIdentity, +}; use openshell_sandbox_backend::boundary_protocol::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, }; +use serde::Serialize; + +#[derive(Serialize)] +struct KubernetesOuterFenceEvidence<'a> { + network_policy_uid: &'a str, + network_policy_resource_version: &'a str, + ingress_isolated: bool, + egress_isolated: bool, + egress_rule_count: u32, +} + +impl KubernetesOuterFenceEvidence<'_> { + fn project(&self, generation: &str) -> Result { + if self.network_policy_uid.is_empty() + || self.network_policy_resource_version.is_empty() + || !self.ingress_isolated + || !self.egress_isolated + || self.egress_rule_count != 0 + { + return Err(BackendError::Descriptor( + "Kubernetes outer fence evidence is incomplete".to_string(), + )); + } + let encoded = serde_json::to_vec(self).map_err(|error| { + BackendError::Descriptor(format!("encode Kubernetes outer fence evidence: {error}")) + })?; + OuterFenceGuarantees::confirmed(generation, &encoded) + } +} /// Isolation backend implemented by the `OpenShell` sandbox runtime. pub const BACKEND_NAME: &str = openshell_sandbox_backend::BACKEND_NAME; @@ -181,8 +212,7 @@ pub struct KubernetesSandboxRuntimeBoundaryProvisioning { impl KubernetesSandboxRuntimeBoundarySpec { /// Produce both sides of the common protocol from one observed Kubernetes /// resource set so a stale or recreated object cannot be attached. - #[must_use] - pub fn provision(self) -> KubernetesSandboxRuntimeBoundaryProvisioning { + pub fn provision(self) -> Result { let resource_claims = BTreeMap::from([ ("kubernetes.namespace_uid".to_string(), self.namespace_uid), ( @@ -206,15 +236,16 @@ impl KubernetesSandboxRuntimeBoundarySpec { self.egress_policy_resource_version, ), ]); - let driver_fence = DriverFenceEvidence::Kubernetes { - network_policy_uid: resource_claims["kubernetes.egress_policy_uid"].clone(), - network_policy_resource_version: - resource_claims["kubernetes.egress_policy_resource_version"].clone(), + let outer_fence = KubernetesOuterFenceEvidence { + network_policy_uid: &resource_claims["kubernetes.egress_policy_uid"], + network_policy_resource_version: &resource_claims + ["kubernetes.egress_policy_resource_version"], ingress_isolated: true, egress_isolated: true, egress_rule_count: 0, - }; - KubernetesSandboxRuntimeBoundaryProvisioning { + } + .project(&self.generation)?; + Ok(KubernetesSandboxRuntimeBoundaryProvisioning { boundary_config: BoundaryConfig { boundary_id: self.boundary_id.clone(), generation: self.generation.clone(), @@ -233,7 +264,7 @@ impl KubernetesSandboxRuntimeBoundarySpec { self.workload_pod_uid_path, )]), workload_identity: self.workload_identity.clone(), - driver_fence: driver_fence.clone(), + outer_fence: outer_fence.clone(), child_env: self.child_env, }, runtime_descriptor: SandboxRuntimeDescriptor { @@ -248,9 +279,9 @@ impl KubernetesSandboxRuntimeBoundarySpec { tls: self.supervisor_tls, host_gateway_ip: self.host_gateway_ip, resource_claims, - driver_fence, + outer_fence, }, - } + }) } } @@ -303,7 +334,7 @@ mod tests { #[test] fn provisioning_binds_identical_kubernetes_resource_claims() { - let provisioned = spec().provision(); + let provisioned = spec().provision().unwrap(); assert_eq!( provisioned.boundary_config.resource_claims, @@ -318,21 +349,21 @@ mod tests { "1945" ); assert_eq!( - provisioned.boundary_config.driver_fence, - provisioned.runtime_descriptor.driver_fence + provisioned.boundary_config.outer_fence, + provisioned.runtime_descriptor.outer_fence ); assert!( provisioned .runtime_descriptor - .driver_fence - .validate() + .outer_fence + .validate("generation-1") .is_ok() ); } #[test] fn provisioning_uses_one_shared_tcp_protocol_across_pods() { - let provisioned = spec().provision(); + let provisioned = spec().provision().unwrap(); assert_eq!( provisioned.boundary_config.listener, diff --git a/crates/openshell-driver-podman/src/isolation.rs b/crates/openshell-driver-podman/src/isolation.rs index b616fa6474..d66c29b77c 100644 --- a/crates/openshell-driver-podman/src/isolation.rs +++ b/crates/openshell-driver-podman/src/isolation.rs @@ -10,7 +10,7 @@ use std::path::PathBuf; use openshell_core::ComputeDriverError; use openshell_core::proto::compute::v1::DriverSandbox; -use openshell_isolation_interface::contract::{DriverFenceEvidence, ResolvedWorkloadIdentity}; +use openshell_isolation_interface::contract::{OuterFenceGuarantees, ResolvedWorkloadIdentity}; use openshell_sandbox_backend::boundary_protocol::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, @@ -27,6 +27,26 @@ pub const AUTH_BUNDLE_PATH: &str = "/.openshell/supervisor/auth.json"; pub const RESTART_METADATA_PATH: &str = "/.openshell/supervisor/restart-metadata.json"; const SOCKET_PATH: &str = "/.openshell/channel/sandbox/control.sock"; +#[derive(Serialize)] +struct PodmanOuterFenceEvidence<'a> { + container_id: &'a str, + network_mode: &'static str, + unexpected_networks: &'a [String], +} + +impl PodmanOuterFenceEvidence<'_> { + fn project(&self, generation: &str) -> Result { + if self.container_id.is_empty() + || self.network_mode != "none" + || !self.unexpected_networks.is_empty() + { + return Err(invalid("Podman outer fence evidence is incomplete")); + } + let encoded = serde_json::to_vec(self).map_err(invalid)?; + OuterFenceGuarantees::confirmed(generation, &encoded).map_err(invalid) + } +} + pub fn supervisor_name(id: &str) -> String { format!("openshell-supervisor-{id}") } @@ -159,15 +179,17 @@ pub fn bootstrap_archives( identity.resource_digest.clone(), ), ]); - let driver_fence = DriverFenceEvidence::Podman { - container_id: container_id.into(), - network_mode: "none".into(), - unexpected_networks: Vec::new(), - }; let runtime_generation = launch_authentication .supervisor .runtime_generation .to_string(); + let unexpected_networks = Vec::new(); + let outer_fence = PodmanOuterFenceEvidence { + container_id, + network_mode: "none", + unexpected_networks: &unexpected_networks, + } + .project(&runtime_generation)?; let verification_keys = launch_authentication .verification_keys .iter() @@ -198,7 +220,7 @@ pub fn bootstrap_archives( resource_claims: resource_claims.clone(), resource_claim_files: BTreeMap::new(), workload_identity: identity.clone(), - driver_fence: driver_fence.clone(), + outer_fence: outer_fence.clone(), child_env: child_env.clone(), }; let runtime_descriptor = SandboxRuntimeDescriptor { @@ -215,7 +237,7 @@ pub fn bootstrap_archives( host_gateway_ip: None, resource_claims, workload_identity: identity.clone(), - driver_fence, + outer_fence, }; // Libpod resolves the requested upload destination once for a stopped // container. Archive entries must be relative to the selected named volume, @@ -440,9 +462,12 @@ mod tests { .unwrap(); assert_eq!(config.boundary_id, runtime_descriptor.boundary_id); assert_eq!(config.session_id, runtime_descriptor.session_id); - assert_eq!(config.driver_fence, runtime_descriptor.driver_fence); + assert_eq!(config.outer_fence, runtime_descriptor.outer_fence); assert_eq!(config.workload_identity, identity); - runtime_descriptor.driver_fence.validate().unwrap(); + runtime_descriptor + .outer_fence + .validate(&runtime_descriptor.generation) + .unwrap(); let restart_metadata: RestartMetadata = serde_json::from_slice( supervisor .get(&PathBuf::from( diff --git a/crates/openshell-driver-vm/src/isolation/mod.rs b/crates/openshell-driver-vm/src/isolation/mod.rs index 49c1135310..6a909cd003 100644 --- a/crates/openshell-driver-vm/src/isolation/mod.rs +++ b/crates/openshell-driver-vm/src/isolation/mod.rs @@ -9,14 +9,35 @@ //! the common control and boundary behavior. use openshell_isolation_interface::contract::{ - BackendError, DriverFenceEvidence, ResolvedWorkloadIdentity, + BackendError, OuterFenceGuarantees, ResolvedWorkloadIdentity, }; use openshell_sandbox_backend::boundary_protocol::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, }; +use serde::Serialize; use std::collections::{BTreeMap, HashMap}; +#[derive(Serialize)] +struct VmOuterFenceEvidence<'a> { + generation: &'a str, + network_device_count: u32, +} + +impl VmOuterFenceEvidence<'_> { + fn project(&self) -> Result { + if self.generation.is_empty() || self.network_device_count != 0 { + return Err(BackendError::Descriptor( + "VM outer fence evidence is incomplete".to_string(), + )); + } + let encoded = serde_json::to_vec(self).map_err(|error| { + BackendError::Descriptor(format!("encode VM outer fence evidence: {error}")) + })?; + OuterFenceGuarantees::confirmed(self.generation, &encoded) + } +} + /// Driver-owned inputs that bind one VM generation to one supervisor boundary. pub struct VmBoundarySpec { pub boundary_id: String, @@ -57,10 +78,11 @@ impl VmBoundarySpec { ("vm.generation".to_string(), self.generation.clone()), ("vm.image_identity".to_string(), self.image_identity), ]); - let driver_fence = DriverFenceEvidence::Vm { - generation: self.generation.clone(), + let outer_fence = VmOuterFenceEvidence { + generation: &self.generation, network_device_count: 0, - }; + } + .project()?; Ok(VmBoundaryProvisioning { boundary_config: BoundaryConfig { boundary_id: self.boundary_id.clone(), @@ -77,7 +99,7 @@ impl VmBoundarySpec { resource_claims: resource_claims.clone(), resource_claim_files: BTreeMap::new(), workload_identity: workload_identity.clone(), - driver_fence: driver_fence.clone(), + outer_fence: outer_fence.clone(), child_env: self.child_env, }, runtime_descriptor: SandboxRuntimeDescriptor { @@ -92,7 +114,7 @@ impl VmBoundarySpec { // after crossing the authenticated boundary channel. host_gateway_ip: Some(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)), resource_claims, - driver_fence, + outer_fence, }, }) } @@ -155,14 +177,14 @@ mod tests { Some(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)) ); assert_eq!( - provisioned.boundary_config.driver_fence, - provisioned.runtime_descriptor.driver_fence + provisioned.boundary_config.outer_fence, + provisioned.runtime_descriptor.outer_fence ); assert!( provisioned .runtime_descriptor - .driver_fence - .validate() + .outer_fence + .validate("generation-1") .is_ok() ); } diff --git a/crates/openshell-isolation-interface/Cargo.toml b/crates/openshell-isolation-interface/Cargo.toml index 43affc4927..0426a9011f 100644 --- a/crates/openshell-isolation-interface/Cargo.toml +++ b/crates/openshell-isolation-interface/Cargo.toml @@ -15,6 +15,7 @@ openshell-core = { path = "../openshell-core", default-features = false } async-trait = "0.1" serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } tokio = { workspace = true } [target.'cfg(unix)'.dependencies] diff --git a/crates/openshell-isolation-interface/src/contract.rs b/crates/openshell-isolation-interface/src/contract.rs index 8dd01be38b..1621113a99 100644 --- a/crates/openshell-isolation-interface/src/contract.rs +++ b/crates/openshell-isolation-interface/src/contract.rs @@ -31,7 +31,7 @@ //! The contract is transport-neutral. Compute drivers keep runtime placement //! and coordination details behind these interfaces. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; @@ -386,89 +386,81 @@ pub trait BoundBoundary: Send { async fn confirm(self: Box) -> Result; } -/// Driver-owned evidence that the mandatory outer network fence is installed. +/// Backend-neutral guarantees established by the compute driver's outer fence. /// -/// The sandbox cannot observe the Docker daemon, Kubernetes API, or VM device -/// model directly. Drivers therefore bind the exact fence they validated into -/// both protected bootstrap halves. The sandbox reports that value back during -/// confirmation, and the supervisor rejects any mismatch before agent launch. +/// Each driver owns its native evidence schema and the code that validates it. +/// After validation, the driver projects that evidence into these guarantees +/// and supplies a digest that binds the original evidence to this generation. +/// The common runtime only validates and compares this projection; it never +/// interprets runtime- or accelerator-specific fields. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OuterFenceGuarantee { + /// No workload packet can leave without an explicit mediated decision. + DefaultDenyEgress, + /// The driver found no network path outside the mediated boundary. + NoUnmanagedEgressPath, + /// Previously granted access can be revoked by the driver-owned fence. + RevocationVerified, + /// Loss of the driver or its controller does not open network access. + ControllerLossFailsClosed, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "backend", rename_all = "kebab-case", deny_unknown_fields)] -pub enum DriverFenceEvidence { - Docker { - container_id: String, - network_mode: String, - unexpected_networks: Vec, - }, - Podman { - container_id: String, - network_mode: String, - unexpected_networks: Vec, - }, - Kubernetes { - network_policy_uid: String, - network_policy_resource_version: String, - ingress_isolated: bool, - egress_isolated: bool, - egress_rule_count: u32, - }, - Vm { - generation: String, - network_device_count: u32, - }, +pub struct OuterFenceGuarantees { + /// Sandbox generation for which the evidence was collected. + pub generation: String, + /// Complete set of normalized guarantees established by the driver. + pub established: BTreeSet, + /// Commitment to the driver-owned native evidence used for this projection. + pub evidence_digest: Sha256Digest, } -impl DriverFenceEvidence { - #[must_use] - pub const fn driver_name(&self) -> &'static str { - match self { - Self::Docker { .. } => "docker", - Self::Podman { .. } => "podman", - Self::Kubernetes { .. } => "kubernetes", - Self::Vm { .. } => "vm", +impl OuterFenceGuarantees { + /// Construct guarantees after the driver has validated its native evidence. + pub fn confirmed( + generation: impl Into, + native_evidence: &[u8], + ) -> Result { + let generation = generation.into(); + if generation.is_empty() || native_evidence.is_empty() { + return Err(BackendError::Descriptor( + "outer fence generation and native evidence are required".to_string(), + )); } + let mut binding = Vec::with_capacity(8 + generation.len() + native_evidence.len()); + binding.extend_from_slice(&(generation.len() as u64).to_be_bytes()); + binding.extend_from_slice(generation.as_bytes()); + binding.extend_from_slice(native_evidence); + Ok(Self { + generation, + established: BTreeSet::from([ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ]), + evidence_digest: Sha256Digest::compute(&binding), + }) } - /// Validate the concrete outer-fence properties reported by the compute driver. - pub fn validate(&self) -> Result<(), BackendError> { - let valid = match self { - Self::Docker { - container_id, - network_mode, - unexpected_networks, - } - | Self::Podman { - container_id, - network_mode, - unexpected_networks, - } => { - !container_id.is_empty() && network_mode == "none" && unexpected_networks.is_empty() - } - Self::Kubernetes { - network_policy_uid, - network_policy_resource_version, - ingress_isolated, - egress_isolated, - egress_rule_count, - } => { - !network_policy_uid.is_empty() - && !network_policy_resource_version.is_empty() - && *ingress_isolated - && *egress_isolated - && *egress_rule_count == 0 - } - Self::Vm { - generation, - network_device_count, - } => !generation.is_empty() && *network_device_count == 0, - }; - if valid { + /// Validate the common guarantees against the admitted generation. + pub fn validate(&self, expected_generation: &str) -> Result<(), BackendError> { + let required = BTreeSet::from([ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ]); + let complete = !self.generation.is_empty() + && self.generation == expected_generation + && self.established == required; + if complete { Ok(()) } else { - Err(BackendError::Confirm(format!( - "{} driver fence evidence is incomplete", - self.driver_name() - ))) + Err(BackendError::Confirm( + "outer fence guarantees are incomplete or bound to another generation".to_string(), + )) } } } @@ -536,7 +528,7 @@ pub struct BoundaryConfirmation { pub properties: BoundaryProperties, pub authenticated_supervisor: bool, pub session_id: SandboxSessionId, - pub driver_fence: DriverFenceEvidence, + pub outer_fence: OuterFenceGuarantees, /// The driver-owned containment primitive terminates the workload when its /// Sandbox Runtime exits. pub runtime_exit_terminates_workload: bool, @@ -547,7 +539,7 @@ pub struct BoundaryConfirmation { impl BoundaryConfirmation { /// Validate common security properties and immutable launch binding. pub fn validate(&self, expected: &ResolvedWorkloadIdentity) -> Result<(), BackendError> { - self.driver_fence.validate()?; + self.outer_fence.validate(&self.generation)?; self.properties.validate()?; let complete = &self.identity == expected && self.authenticated_supervisor @@ -874,6 +866,12 @@ impl From for String { } impl Sha256Digest { + fn compute(bytes: &[u8]) -> Self { + use sha2::{Digest as _, Sha256}; + + Self(Sha256::digest(bytes).into()) + } + /// Return the raw digest bytes. #[must_use] pub fn as_bytes(&self) -> &[u8; 32] { diff --git a/crates/openshell-isolation-interface/tests/backend_conformance.rs b/crates/openshell-isolation-interface/tests/backend_conformance.rs index d5f6e5b22a..517b494356 100644 --- a/crates/openshell-isolation-interface/tests/backend_conformance.rs +++ b/crates/openshell-isolation-interface/tests/backend_conformance.rs @@ -380,10 +380,8 @@ fn confirmation() -> BoundaryConfirmation { }, authenticated_supervisor: true, session_id: SandboxSessionId::new(), - driver_fence: DriverFenceEvidence::Vm { - generation: "generation-1".to_string(), - network_device_count: 0, - }, + outer_fence: OuterFenceGuarantees::confirmed("generation-1", b"mock-fence-evidence") + .unwrap(), runtime_exit_terminates_workload: true, resource_claims: BTreeMap::new(), backend_audit: serde_json::json!({"backend": "mock"}), @@ -391,34 +389,33 @@ fn confirmation() -> BoundaryConfirmation { } #[test] -fn driver_fence_evidence_is_backend_specific_and_fail_closed() { - let docker = DriverFenceEvidence::Docker { - container_id: "sha256:container".to_string(), - network_mode: "none".to_string(), - unexpected_networks: Vec::new(), - }; - let kubernetes = DriverFenceEvidence::Kubernetes { - network_policy_uid: "policy-uid".to_string(), - network_policy_resource_version: "42".to_string(), - ingress_isolated: true, - egress_isolated: true, - egress_rule_count: 0, - }; - let vm = DriverFenceEvidence::Vm { - generation: "generation-1".to_string(), - network_device_count: 0, - }; +fn outer_fence_guarantees_are_backend_neutral_and_fail_closed() { + let fence = OuterFenceGuarantees::confirmed("generation-1", b"native-driver-evidence").unwrap(); + assert!(fence.validate("generation-1").is_ok()); + assert_ne!( + fence.evidence_digest, + OuterFenceGuarantees::confirmed("generation-2", b"native-driver-evidence") + .unwrap() + .evidence_digest + ); - assert!(docker.validate().is_ok()); - assert!(kubernetes.validate().is_ok()); - assert!(vm.validate().is_ok()); + let mut wrong_generation = fence.clone(); + wrong_generation.generation = "generation-2".to_string(); + assert!(wrong_generation.validate("generation-1").is_err()); - let drifted = DriverFenceEvidence::Docker { - container_id: "sha256:container".to_string(), - network_mode: "bridge".to_string(), - unexpected_networks: vec!["bridge".to_string()], - }; - assert!(drifted.validate().is_err()); + for guarantee in [ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ] { + let mut incomplete = fence.clone(); + incomplete.established.remove(&guarantee); + assert!(incomplete.validate("generation-1").is_err()); + } + + assert!(OuterFenceGuarantees::confirmed("", b"evidence").is_err()); + assert!(OuterFenceGuarantees::confirmed("generation-1", b"").is_err()); } /// The backend-independent supervisor sequence. Identical for every backend: diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index d983b329e9..1a5cf41e8a 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -24,7 +24,7 @@ use openshell_isolation_interface::AgentSpec; use openshell_isolation_interface::contract::Sha256Digest; use openshell_isolation_interface::contract::{ BackendDescriptor, BackendError, BinaryIdentity, BoundaryConfirmation, BoundaryExitStatus, - BoundaryProperties, BoundarySignal, DriverFenceEvidence, EnforcedProperty, ExecSpec, + BoundaryProperties, BoundarySignal, EnforcedProperty, ExecSpec, OuterFenceGuarantees, ResolveError, }; use rcgen::{CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose}; @@ -426,8 +426,8 @@ pub struct SandboxRuntimeDescriptor { /// example pod UID, VM generation, or container ID). #[serde(default)] pub resource_claims: std::collections::BTreeMap, - /// Concrete outer-fence evidence validated by the driver. - pub driver_fence: DriverFenceEvidence, + /// Backend-neutral projection of the driver-validated outer fence. + pub outer_fence: OuterFenceGuarantees, } impl fmt::Debug for SandboxRuntimeDescriptor { @@ -441,7 +441,7 @@ impl fmt::Debug for SandboxRuntimeDescriptor { .field("tls", &self.tls) .field("host_gateway_ip", &self.host_gateway_ip) .field("resource_claims", &self.resource_claims) - .field("driver_fence", &self.driver_fence) + .field("outer_fence", &self.outer_fence) .finish() } } @@ -494,8 +494,8 @@ pub struct BoundaryConfig { pub resource_claim_files: std::collections::BTreeMap, /// Exact identity already applied by the runtime to the sandbox process. pub workload_identity: openshell_isolation_interface::contract::ResolvedWorkloadIdentity, - /// Concrete outer-fence evidence validated by the driver. - pub driver_fence: DriverFenceEvidence, + /// Backend-neutral projection of the driver-validated outer fence. + pub outer_fence: OuterFenceGuarantees, /// Driver-resolved environment exposed only to workload processes. #[serde(default)] pub child_env: std::collections::HashMap, @@ -523,7 +523,7 @@ impl fmt::Debug for BoundaryConfig { .field("resource_claims", &self.resource_claims) .field("resource_claim_files", &self.resource_claim_files) .field("workload_identity", &self.workload_identity) - .field("driver_fence", &self.driver_fence) + .field("outer_fence", &self.outer_fence) .field("child_env_keys", &self.child_env.keys().collect::>()) .finish() } diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index 5721618824..c0fe71c3b0 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -116,7 +116,7 @@ impl IsolationBackend for OpenShellRuntimeBackend { let resource_claims = runtime_descriptor.resource_claims.clone(); let generation = runtime_descriptor.generation.clone(); let session_id = runtime_descriptor.session_id; - let driver_fence = runtime_descriptor.driver_fence.clone(); + let outer_fence = runtime_descriptor.outer_fence.clone(); let client = Arc::new(BoundaryClient::new( runtime_descriptor, self.sandbox_bearer.clone(), @@ -149,7 +149,7 @@ impl IsolationBackend for OpenShellRuntimeBackend { generation, session_id, resource_claims, - driver_fence, + outer_fence, })) } } @@ -181,7 +181,9 @@ fn validate_runtime_descriptor( )); } validate_resource_claims(&runtime_descriptor.resource_claims)?; - runtime_descriptor.driver_fence.validate()?; + runtime_descriptor + .outer_fence + .validate(&runtime_descriptor.generation)?; match &runtime_descriptor.transport { SandboxTransport::Unix { socket_path } => { validate_socket_path(socket_path)?; @@ -292,7 +294,7 @@ struct RemoteBound { generation: String, session_id: openshell_core::SandboxSessionId, resource_claims: std::collections::BTreeMap, - driver_fence: openshell_isolation_interface::contract::DriverFenceEvidence, + outer_fence: openshell_isolation_interface::contract::OuterFenceGuarantees, } #[async_trait] @@ -313,10 +315,10 @@ impl BoundBoundary for RemoteBound { if confirmation.generation != self.generation || confirmation.session_id != self.session_id || confirmation.resource_claims != self.resource_claims - || confirmation.driver_fence != self.driver_fence + || confirmation.outer_fence != self.outer_fence { return Err(BackendError::Confirm( - "sandbox confirmation generation, session, resource claims, or driver fence do not match runtime descriptor" + "sandbox confirmation generation, session, resource claims, or outer fence do not match runtime descriptor" .to_string(), )); } @@ -1934,11 +1936,12 @@ mod tests { FilesystemPolicy, LandlockPolicy, NetworkPolicy, ProcessPolicy, SandboxPolicy, }; - fn test_driver_fence() -> openshell_isolation_interface::contract::DriverFenceEvidence { - openshell_isolation_interface::contract::DriverFenceEvidence::Vm { - generation: "test-generation".to_string(), - network_device_count: 0, - } + fn test_outer_fence() -> openshell_isolation_interface::contract::OuterFenceGuarantees { + openshell_isolation_interface::contract::OuterFenceGuarantees::confirmed( + "test-generation", + b"test-vm-fence", + ) + .unwrap() } #[tokio::test] @@ -2477,7 +2480,7 @@ mod tests { tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), } } @@ -2601,7 +2604,7 @@ mod tests { properties: audit.properties(), authenticated_supervisor: true, session_id: test_session_id(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), runtime_exit_terminates_workload: true, resource_claims: std::collections::BTreeMap::new(), backend_audit: serde_json::to_value(audit).expect("serialize audit evidence"), @@ -2622,7 +2625,7 @@ mod tests { tls: certificate.client_tls.clone(), host_gateway_ip: Some(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)), resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }; let debug = format!("{runtime_descriptor:?}"); assert!(debug.contains("")); @@ -2642,7 +2645,7 @@ mod tests { tls: test_certificate().client_tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }; assert!(matches!( validate_runtime_descriptor(&runtime_descriptor, &sandbox()), @@ -2664,7 +2667,7 @@ mod tests { tls: test_certificate().client_tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }; assert!(matches!( validate_runtime_descriptor(&runtime_descriptor, &sandbox()), @@ -2686,7 +2689,7 @@ mod tests { tls: test_certificate().client_tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }; validate_runtime_descriptor(&runtime_descriptor, &sandbox()) .expect("TCP runtime descriptor should be valid"); @@ -2880,7 +2883,7 @@ mod tests { tls: certificate.client_tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }, test_bearer(&"a".repeat(32)), )); @@ -3071,7 +3074,7 @@ mod tests { tls: certificate.client_tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }, test_bearer(&"a".repeat(32)), ); diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index 94bc0d0d5b..bee799a5c6 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -303,8 +303,8 @@ mod linux { } validate_resource_claims(&config.resource_claims).map_err(|error| error.to_string())?; config - .driver_fence - .validate() + .outer_fence + .validate(&config.generation) .map_err(|error| error.to_string())?; for (claim, path) in &config.resource_claim_files { if !config.resource_claims.contains_key(claim) { @@ -2348,7 +2348,7 @@ mod linux { properties, authenticated_supervisor: true, session_id: self.config.session_id, - driver_fence: self.config.driver_fence.clone(), + outer_fence: self.config.outer_fence.clone(), runtime_exit_terminates_workload: true, resource_claims: self.config.resource_claims.clone(), backend_audit, @@ -3715,7 +3715,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }; let debug = format!("{config:?}"); @@ -3866,7 +3866,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }, tokio::runtime::Handle::current(), @@ -4268,11 +4268,12 @@ mod linux { .unwrap() } - fn test_driver_fence() -> openshell_isolation_interface::contract::DriverFenceEvidence { - openshell_isolation_interface::contract::DriverFenceEvidence::Vm { - generation: "generation-1".to_string(), - network_device_count: 0, - } + fn test_outer_fence() -> openshell_isolation_interface::contract::OuterFenceGuarantees { + openshell_isolation_interface::contract::OuterFenceGuarantees::confirmed( + "generation-1", + b"test-vm-fence", + ) + .unwrap() } fn test_runtime_qualification() -> crate::RuntimeQualification { @@ -4420,7 +4421,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }; @@ -4455,7 +4456,7 @@ mod linux { pod_uid_path, )]), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }; @@ -4495,7 +4496,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }, tokio::runtime::Handle::current(), @@ -4670,7 +4671,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }, process_runtime.handle().clone(), @@ -4988,7 +4989,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }, process_runtime.handle().clone(), From da59a01c0e59a8b3045e271f87b3ef1a8c2c74c1 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 17 Sep 2026 09:48:16 -0700 Subject: [PATCH 4/6] fix(isolation)!: validate explicit fence projections Require each compute driver to map its native evidence to individual outer-fence guarantees, and reject incomplete projections before a boundary becomes ready. Exercise the assembled remote confirmation path for invalid audit, property, generation, and digest evidence. BREAKING CHANGE: BoundaryConfig and SandboxRuntimeDescriptor use outer_fence projections rather than the earlier driver_fence representation. State written by earlier builds cannot be decoded; operators must stop and recreate affected sandboxes after upgrading. Signed-off-by: Drew Newberry --- architecture/sandbox.md | 15 ++ .../openshell-driver-docker/src/isolation.rs | 49 +++++- .../src/isolation.rs | 70 +++++++- .../openshell-driver-podman/src/isolation.rs | 52 +++++- .../openshell-driver-vm/src/isolation/mod.rs | 41 ++++- .../src/contract.rs | 16 +- .../tests/backend_conformance.rs | 35 +++- .../src/boundary_protocol.rs | 16 +- .../openshell-sandbox-backend/src/runtime.rs | 158 ++++++++++++++++-- .../src/runtime/tests/credential_renewal.rs | 1 + .../openshell-sandbox/src/boundary_server.rs | 14 +- 11 files changed, 407 insertions(+), 60 deletions(-) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 1ee0750804..26a5caaeeb 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -91,6 +91,21 @@ controller loss fails closed. A digest commits those guarantees to the native driver evidence without teaching the shared contract about container networks, Kubernetes objects, VM devices, or accelerator resources. +Each driver makes that projection explicitly. Non-empty native evidence alone +does not establish a guarantee: + +| Driver | Native evidence | Guarantees projected by the driver | +|---|---|---| +| Docker | Pinned container ID, `network_mode=none`, and no unexpected network attachments | No workload route establishes default-deny, revocation, and controller-loss behavior; the attachment inspection establishes that no unmanaged route exists. | +| Podman | Pinned container ID, `--network=none`, and no unexpected network attachments | The same container-network facts establish the same four guarantees. | +| Kubernetes | NetworkPolicy UID and resource version, ingress and egress isolation, and zero workload egress rules | The persisted, selecting policy establishes default-deny and continued denial after revocation or controller loss; zero egress rules establish that no unmanaged route is permitted. | +| VM | Generation and zero guest network devices | The absent NIC establishes all four guarantees; approved traffic uses the separate supervisor-owned channel. | + +The shared contract checks that all four guarantees are present, that the +projection names the admitted generation, and that its evidence digest matches +the value passed to the workload-side runtime. It does not infer guarantees or +interpret the native fields. + When the admitted main process exits, its status and retained terminal output remain available. The confirmed sandbox and supervisor-owned access plane continue to serve policy-authorized exec and loopback forwarding until explicit stop or diff --git a/crates/openshell-driver-docker/src/isolation.rs b/crates/openshell-driver-docker/src/isolation.rs index b7f75efee4..d462f78234 100644 --- a/crates/openshell-driver-docker/src/isolation.rs +++ b/crates/openshell-driver-docker/src/isolation.rs @@ -12,7 +12,7 @@ use std::net::IpAddr; use std::path::PathBuf; use openshell_isolation_interface::contract::{ - BackendError, OuterFenceGuarantees, ResolvedWorkloadIdentity, + BackendError, OuterFenceGuarantee, OuterFenceGuarantees, ResolvedWorkloadIdentity, }; use openshell_sandbox_backend::GPU_RESOURCE_CLAIM; use openshell_sandbox_backend::boundary_protocol::{ @@ -30,18 +30,31 @@ struct DockerOuterFenceEvidence<'a> { impl DockerOuterFenceEvidence<'_> { fn project(&self, generation: &str) -> Result { - if self.container_id.is_empty() - || self.network_mode != "none" - || !self.unexpected_networks.is_empty() - { + if self.container_id.is_empty() { return Err(BackendError::Descriptor( "Docker outer fence evidence is incomplete".to_string(), )); } + let mut established = Vec::new(); + if self.network_mode == "none" { + // With no container network namespace attachment, workload egress + // remains denied both after revocation and if the supervisor exits. + established.extend([ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ]); + } + if self.unexpected_networks.is_empty() { + established.push(OuterFenceGuarantee::NoUnmanagedEgressPath); + } let encoded = serde_json::to_vec(self).map_err(|error| { BackendError::Descriptor(format!("encode Docker outer fence evidence: {error}")) })?; - OuterFenceGuarantees::confirmed(generation, &encoded) + let projection = + OuterFenceGuarantees::from_driver_evidence(generation, established, &encoded)?; + projection.validate(generation)?; + Ok(projection) } } @@ -130,6 +143,30 @@ impl DockerBoundarySpec { mod tests { use super::*; + #[test] + fn outer_fence_projection_rejects_each_missing_native_fact() { + let unexpected_networks = vec!["bridge".to_string()]; + for evidence in [ + DockerOuterFenceEvidence { + container_id: "", + network_mode: "none", + unexpected_networks: &[], + }, + DockerOuterFenceEvidence { + container_id: "container", + network_mode: "bridge", + unexpected_networks: &[], + }, + DockerOuterFenceEvidence { + container_id: "container", + network_mode: "none", + unexpected_networks: &unexpected_networks, + }, + ] { + assert!(evidence.project("generation-1").is_err()); + } + } + #[test] fn provisioning_binds_container_and_image_claims() { let session_id = openshell_core::SandboxSessionId::new(); diff --git a/crates/openshell-driver-kubernetes/src/isolation.rs b/crates/openshell-driver-kubernetes/src/isolation.rs index db82f2adc5..463924242b 100644 --- a/crates/openshell-driver-kubernetes/src/isolation.rs +++ b/crates/openshell-driver-kubernetes/src/isolation.rs @@ -21,7 +21,7 @@ use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector; use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString; use kube::core::ObjectMeta; use openshell_isolation_interface::contract::{ - BackendError, OuterFenceGuarantees, ResolvedWorkloadIdentity, + BackendError, OuterFenceGuarantee, OuterFenceGuarantees, ResolvedWorkloadIdentity, }; use openshell_sandbox_backend::boundary_protocol::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, @@ -40,20 +40,31 @@ struct KubernetesOuterFenceEvidence<'a> { impl KubernetesOuterFenceEvidence<'_> { fn project(&self, generation: &str) -> Result { - if self.network_policy_uid.is_empty() - || self.network_policy_resource_version.is_empty() - || !self.ingress_isolated - || !self.egress_isolated - || self.egress_rule_count != 0 - { + if self.network_policy_uid.is_empty() || self.network_policy_resource_version.is_empty() { return Err(BackendError::Descriptor( "Kubernetes outer fence evidence is incomplete".to_string(), )); } + let mut established = Vec::new(); + if self.ingress_isolated && self.egress_isolated && self.egress_rule_count == 0 { + // A persisted policy selecting both directions with no egress rule + // continues to deny direct egress after revocation or controller loss. + established.extend([ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ]); + } + if self.egress_isolated && self.egress_rule_count == 0 { + established.push(OuterFenceGuarantee::NoUnmanagedEgressPath); + } let encoded = serde_json::to_vec(self).map_err(|error| { BackendError::Descriptor(format!("encode Kubernetes outer fence evidence: {error}")) })?; - OuterFenceGuarantees::confirmed(generation, &encoded) + let projection = + OuterFenceGuarantees::from_driver_evidence(generation, established, &encoded)?; + projection.validate(generation)?; + Ok(projection) } } @@ -289,6 +300,49 @@ impl KubernetesSandboxRuntimeBoundarySpec { mod tests { use super::*; + #[test] + fn outer_fence_projection_rejects_each_missing_native_fact() { + for evidence in [ + KubernetesOuterFenceEvidence { + network_policy_uid: "", + network_policy_resource_version: "1", + ingress_isolated: true, + egress_isolated: true, + egress_rule_count: 0, + }, + KubernetesOuterFenceEvidence { + network_policy_uid: "uid", + network_policy_resource_version: "", + ingress_isolated: true, + egress_isolated: true, + egress_rule_count: 0, + }, + KubernetesOuterFenceEvidence { + network_policy_uid: "uid", + network_policy_resource_version: "1", + ingress_isolated: false, + egress_isolated: true, + egress_rule_count: 0, + }, + KubernetesOuterFenceEvidence { + network_policy_uid: "uid", + network_policy_resource_version: "1", + ingress_isolated: true, + egress_isolated: false, + egress_rule_count: 0, + }, + KubernetesOuterFenceEvidence { + network_policy_uid: "uid", + network_policy_resource_version: "1", + ingress_isolated: true, + egress_isolated: true, + egress_rule_count: 1, + }, + ] { + assert!(evidence.project("generation-1").is_err()); + } + } + fn spec() -> KubernetesSandboxRuntimeBoundarySpec { KubernetesSandboxRuntimeBoundarySpec { boundary_id: "sandbox-1".to_string(), diff --git a/crates/openshell-driver-podman/src/isolation.rs b/crates/openshell-driver-podman/src/isolation.rs index d66c29b77c..c7ff1a7ec7 100644 --- a/crates/openshell-driver-podman/src/isolation.rs +++ b/crates/openshell-driver-podman/src/isolation.rs @@ -10,7 +10,9 @@ use std::path::PathBuf; use openshell_core::ComputeDriverError; use openshell_core::proto::compute::v1::DriverSandbox; -use openshell_isolation_interface::contract::{OuterFenceGuarantees, ResolvedWorkloadIdentity}; +use openshell_isolation_interface::contract::{ + OuterFenceGuarantee, OuterFenceGuarantees, ResolvedWorkloadIdentity, +}; use openshell_sandbox_backend::boundary_protocol::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, @@ -36,14 +38,28 @@ struct PodmanOuterFenceEvidence<'a> { impl PodmanOuterFenceEvidence<'_> { fn project(&self, generation: &str) -> Result { - if self.container_id.is_empty() - || self.network_mode != "none" - || !self.unexpected_networks.is_empty() - { + if self.container_id.is_empty() { return Err(invalid("Podman outer fence evidence is incomplete")); } + let mut established = Vec::new(); + if self.network_mode == "none" { + // With no container network namespace attachment, workload egress + // remains denied both after revocation and if the supervisor exits. + established.extend([ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ]); + } + if self.unexpected_networks.is_empty() { + established.push(OuterFenceGuarantee::NoUnmanagedEgressPath); + } let encoded = serde_json::to_vec(self).map_err(invalid)?; - OuterFenceGuarantees::confirmed(generation, &encoded).map_err(invalid) + let projection = + OuterFenceGuarantees::from_driver_evidence(generation, established, &encoded) + .map_err(invalid)?; + projection.validate(generation).map_err(invalid)?; + Ok(projection) } } @@ -349,6 +365,30 @@ mod tests { SupervisorAuthBundle, }; + #[test] + fn outer_fence_projection_rejects_each_missing_native_fact() { + let unexpected_networks = vec!["podman".to_string()]; + for evidence in [ + PodmanOuterFenceEvidence { + container_id: "", + network_mode: "none", + unexpected_networks: &[], + }, + PodmanOuterFenceEvidence { + container_id: "container", + network_mode: "bridge", + unexpected_networks: &[], + }, + PodmanOuterFenceEvidence { + container_id: "container", + network_mode: "none", + unexpected_networks: &unexpected_networks, + }, + ] { + assert!(evidence.project("generation-1").is_err()); + } + } + fn authentication() -> SandboxLaunchAuthentication { SandboxLaunchAuthentication { supervisor: SupervisorAuthBundle { diff --git a/crates/openshell-driver-vm/src/isolation/mod.rs b/crates/openshell-driver-vm/src/isolation/mod.rs index 6a909cd003..5d3ba3cd30 100644 --- a/crates/openshell-driver-vm/src/isolation/mod.rs +++ b/crates/openshell-driver-vm/src/isolation/mod.rs @@ -9,7 +9,7 @@ //! the common control and boundary behavior. use openshell_isolation_interface::contract::{ - BackendError, OuterFenceGuarantees, ResolvedWorkloadIdentity, + BackendError, OuterFenceGuarantee, OuterFenceGuarantees, ResolvedWorkloadIdentity, }; use openshell_sandbox_backend::boundary_protocol::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, @@ -26,15 +26,30 @@ struct VmOuterFenceEvidence<'a> { impl VmOuterFenceEvidence<'_> { fn project(&self) -> Result { - if self.generation.is_empty() || self.network_device_count != 0 { + if self.generation.is_empty() { return Err(BackendError::Descriptor( "VM outer fence evidence is incomplete".to_string(), )); } + let established = (self.network_device_count == 0).then_some([ + // A guest with no NIC has no kernel network path. Closing the + // supervisor-owned channel revokes access, and controller loss + // cannot introduce a device. + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ]); let encoded = serde_json::to_vec(self).map_err(|error| { BackendError::Descriptor(format!("encode VM outer fence evidence: {error}")) })?; - OuterFenceGuarantees::confirmed(self.generation, &encoded) + let projection = OuterFenceGuarantees::from_driver_evidence( + self.generation, + established.into_iter().flatten(), + &encoded, + )?; + projection.validate(self.generation)?; + Ok(projection) } } @@ -128,6 +143,26 @@ mod tests { generate_sandbox_tls_material, }; + #[test] + fn outer_fence_projection_rejects_each_missing_native_fact() { + assert!( + VmOuterFenceEvidence { + generation: "", + network_device_count: 0, + } + .project() + .is_err() + ); + assert!( + VmOuterFenceEvidence { + generation: "generation-1", + network_device_count: 1, + } + .project() + .is_err() + ); + } + #[test] fn provisioning_binds_identical_resource_claims() { let session_id = openshell_core::SandboxSessionId::new(); diff --git a/crates/openshell-isolation-interface/src/contract.rs b/crates/openshell-isolation-interface/src/contract.rs index 1621113a99..022eb1110c 100644 --- a/crates/openshell-isolation-interface/src/contract.rs +++ b/crates/openshell-isolation-interface/src/contract.rs @@ -417,9 +417,14 @@ pub struct OuterFenceGuarantees { } impl OuterFenceGuarantees { - /// Construct guarantees after the driver has validated its native evidence. - pub fn confirmed( + /// Bind the guarantees explicitly established by driver-owned evidence. + /// + /// This constructor deliberately does not infer guarantees from the mere + /// presence of evidence. The driver must inspect its native state and + /// project each established guarantee before calling this function. + pub fn from_driver_evidence( generation: impl Into, + established: impl IntoIterator, native_evidence: &[u8], ) -> Result { let generation = generation.into(); @@ -434,12 +439,7 @@ impl OuterFenceGuarantees { binding.extend_from_slice(native_evidence); Ok(Self { generation, - established: BTreeSet::from([ - OuterFenceGuarantee::DefaultDenyEgress, - OuterFenceGuarantee::NoUnmanagedEgressPath, - OuterFenceGuarantee::RevocationVerified, - OuterFenceGuarantee::ControllerLossFailsClosed, - ]), + established: established.into_iter().collect(), evidence_digest: Sha256Digest::compute(&binding), }) } diff --git a/crates/openshell-isolation-interface/tests/backend_conformance.rs b/crates/openshell-isolation-interface/tests/backend_conformance.rs index 517b494356..1dbda9ed16 100644 --- a/crates/openshell-isolation-interface/tests/backend_conformance.rs +++ b/crates/openshell-isolation-interface/tests/backend_conformance.rs @@ -368,6 +368,20 @@ fn workload_identity() -> ResolvedWorkloadIdentity { .unwrap() } +fn complete_outer_fence(generation: &str, evidence: &[u8]) -> OuterFenceGuarantees { + OuterFenceGuarantees::from_driver_evidence( + generation, + [ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ], + evidence, + ) + .unwrap() +} + fn confirmation() -> BoundaryConfirmation { BoundaryConfirmation { generation: "generation-1".to_string(), @@ -380,8 +394,7 @@ fn confirmation() -> BoundaryConfirmation { }, authenticated_supervisor: true, session_id: SandboxSessionId::new(), - outer_fence: OuterFenceGuarantees::confirmed("generation-1", b"mock-fence-evidence") - .unwrap(), + outer_fence: complete_outer_fence("generation-1", b"mock-fence-evidence"), runtime_exit_terminates_workload: true, resource_claims: BTreeMap::new(), backend_audit: serde_json::json!({"backend": "mock"}), @@ -390,13 +403,11 @@ fn confirmation() -> BoundaryConfirmation { #[test] fn outer_fence_guarantees_are_backend_neutral_and_fail_closed() { - let fence = OuterFenceGuarantees::confirmed("generation-1", b"native-driver-evidence").unwrap(); + let fence = complete_outer_fence("generation-1", b"native-driver-evidence"); assert!(fence.validate("generation-1").is_ok()); assert_ne!( fence.evidence_digest, - OuterFenceGuarantees::confirmed("generation-2", b"native-driver-evidence") - .unwrap() - .evidence_digest + complete_outer_fence("generation-2", b"native-driver-evidence").evidence_digest ); let mut wrong_generation = fence.clone(); @@ -414,8 +425,16 @@ fn outer_fence_guarantees_are_backend_neutral_and_fail_closed() { assert!(incomplete.validate("generation-1").is_err()); } - assert!(OuterFenceGuarantees::confirmed("", b"evidence").is_err()); - assert!(OuterFenceGuarantees::confirmed("generation-1", b"").is_err()); + assert!(OuterFenceGuarantees::from_driver_evidence("", [], b"evidence").is_err()); + assert!(OuterFenceGuarantees::from_driver_evidence("generation-1", [], b"").is_err()); + + let unproven = OuterFenceGuarantees::from_driver_evidence( + "generation-1", + [OuterFenceGuarantee::DefaultDenyEgress], + b"native-driver-evidence", + ) + .unwrap(); + assert!(unproven.validate("generation-1").is_err()); } /// The backend-independent supervisor sequence. Identical for every backend: diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index 1a5cf41e8a..4f7b4e77ef 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -84,7 +84,7 @@ pub struct SeccompEvidence { pub cancellation: bool, } -/// Mechanism-specific audit evidence for the `OpenShell` co-located runtime. +/// Mechanism-specific audit evidence for the native Linux sandbox adapter. /// /// This schema belongs to this backend rather than the generic isolation /// interface. The host-side backend validates it before constructing a @@ -94,7 +94,7 @@ pub struct SeccompEvidence { clippy::struct_excessive_bools, reason = "audit evidence preserves independently measured security results" )] -pub struct OpenShellSandboxAuditEvidence { +pub struct NativeLinuxSandboxAuditEvidence { pub capabilities: CapabilityEvidence, pub no_new_privileges: bool, pub sandbox_dumpable: bool, @@ -111,7 +111,7 @@ pub struct OpenShellSandboxAuditEvidence { pub tcp_deny_round_trip: bool, } -impl OpenShellSandboxAuditEvidence { +impl NativeLinuxSandboxAuditEvidence { /// Validate the complete mechanism-specific posture required by this backend. pub fn validate(&self) -> Result<(), BackendError> { let complete = self.capabilities.is_empty() @@ -140,7 +140,7 @@ impl OpenShellSandboxAuditEvidence { Ok(()) } else { Err(BackendError::Confirm( - "OpenShell sandbox audit evidence is incomplete".to_string(), + "native Linux sandbox audit evidence is incomplete".to_string(), )) } } @@ -1337,8 +1337,8 @@ pub enum FrameError { mod tests { use super::*; - fn complete_audit_evidence() -> OpenShellSandboxAuditEvidence { - OpenShellSandboxAuditEvidence { + fn complete_audit_evidence() -> NativeLinuxSandboxAuditEvidence { + NativeLinuxSandboxAuditEvidence { capabilities: CapabilityEvidence { inheritable: 0, permitted: 0, @@ -1373,7 +1373,7 @@ mod tests { } #[test] - fn openshell_audit_evidence_projects_backend_neutral_properties() { + fn native_linux_audit_evidence_projects_backend_neutral_properties() { let audit = complete_audit_evidence(); audit.validate().unwrap(); let properties = audit.properties(); @@ -1385,7 +1385,7 @@ mod tests { } #[test] - fn openshell_audit_evidence_rejects_mechanism_failure() { + fn native_linux_audit_evidence_rejects_mechanism_failure() { let mut audit = complete_audit_evidence(); audit.seccomp.addfd_send = false; assert!(audit.validate().is_err()); diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index c0fe71c3b0..43ed099708 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -322,18 +322,21 @@ impl BoundBoundary for RemoteBound { .to_string(), )); } - let audit: crate::boundary_protocol::OpenShellSandboxAuditEvidence = + let audit: crate::boundary_protocol::NativeLinuxSandboxAuditEvidence = serde_json::from_value(confirmation.backend_audit.clone()).map_err(|error| { - BackendError::Confirm(format!("decode OpenShell sandbox audit evidence: {error}")) + BackendError::Confirm(format!( + "decode native Linux sandbox audit evidence: {error}" + )) })?; audit.validate()?; if confirmation.properties != audit.properties() { return Err(BackendError::Confirm( - "sandbox confirmation properties do not match OpenShell audit evidence".to_string(), + "sandbox confirmation properties do not match native Linux audit evidence" + .to_string(), )); } - self.client.start_credential_monitor(); - ConfirmedBoundary::try_new( + let client = self.client.clone(); + let confirmed = ConfirmedBoundary::try_new( Box::new(RemoteReady { client: self.client, agent: self.agent, @@ -344,7 +347,9 @@ impl BoundBoundary for RemoteBound { }), *confirmation, &self.identity, - ) + )?; + client.start_credential_monitor(); + Ok(confirmed) } } @@ -1937,8 +1942,16 @@ mod tests { }; fn test_outer_fence() -> openshell_isolation_interface::contract::OuterFenceGuarantees { - openshell_isolation_interface::contract::OuterFenceGuarantees::confirmed( + use openshell_isolation_interface::contract::OuterFenceGuarantee; + + openshell_isolation_interface::contract::OuterFenceGuarantees::from_driver_evidence( "test-generation", + [ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ], b"test-vm-fence", ) .unwrap() @@ -1965,6 +1978,7 @@ mod tests { mediation_failures: Arc, mediation_ready: bool, provider_environment_generation: u64, + confirmation: openshell_isolation_interface::contract::BoundaryConfirmation, } type TestGrpcStream = Pin< @@ -1994,6 +2008,7 @@ mod tests { let requests = self.requests.clone(); let mediation_ready = self.mediation_ready; let provider_environment_generation = self.provider_environment_generation; + let confirmation = self.confirmation.clone(); let (outbound, outbound_rx) = tokio::sync::mpsc::channel(1); tokio::spawn(async move { let mut frame = Vec::new(); @@ -2037,7 +2052,7 @@ mod tests { }, }, Request::Confirm => Response::Confirmed { - confirmation: Box::new(test_confirmation()), + confirmation: Box::new(confirmation), }, Request::OpenMediation if mediation_ready => Response::MediationReady, Request::OpenMediation => Response::Error { @@ -2142,6 +2157,7 @@ mod tests { mediation_failures: Arc::new(std::sync::atomic::AtomicUsize::new(0)), mediation_ready: false, provider_environment_generation: 0, + confirmation: test_confirmation(), }; let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); @@ -2206,6 +2222,7 @@ mod tests { mediation_failures: Arc::new(std::sync::atomic::AtomicUsize::new(0)), mediation_ready: false, provider_environment_generation: 0, + confirmation: test_confirmation(), }; tokio::spawn(async move { tonic::transport::Server::builder() @@ -2299,6 +2316,7 @@ mod tests { mediation_failures: server_failures.clone(), mediation_ready: true, provider_environment_generation: 0, + confirmation: test_confirmation(), }; tokio::spawn(async move { tonic::transport::Server::builder() @@ -2353,6 +2371,7 @@ mod tests { mediation_failures: Arc::new(std::sync::atomic::AtomicUsize::new(0)), mediation_ready: false, provider_environment_generation: 0, + confirmation: test_confirmation(), }; let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); @@ -2513,12 +2532,25 @@ mod tests { else { return; }; - serve_test_grpc(Box::new(stream), expected_token).await; + serve_test_grpc_with_confirmation( + Box::new(stream), + expected_token, + test_confirmation(), + ) + .await; }); (address, task) } async fn serve_test_grpc(stream: BoundaryDuplexStream, expected_token: String) { + serve_test_grpc_with_confirmation(stream, expected_token, test_confirmation()).await; + } + + async fn serve_test_grpc_with_confirmation( + stream: BoundaryDuplexStream, + expected_token: String, + confirmation: openshell_isolation_interface::contract::BoundaryConfirmation, + ) { let service = TestGrpcBoundary { wait_for_half_close: false, expected_token, @@ -2526,6 +2558,7 @@ mod tests { mediation_failures: Arc::new(std::sync::atomic::AtomicUsize::new(0)), mediation_ready: false, provider_environment_generation: 0, + confirmation, }; tonic::transport::Server::builder() .add_service(IsolationBoundaryServer::new(service)) @@ -2566,7 +2599,7 @@ mod tests { } fn test_confirmation() -> openshell_isolation_interface::contract::BoundaryConfirmation { - let audit = crate::boundary_protocol::OpenShellSandboxAuditEvidence { + let audit = crate::boundary_protocol::NativeLinuxSandboxAuditEvidence { capabilities: crate::boundary_protocol::CapabilityEvidence { inheritable: 0, permitted: 0, @@ -2611,6 +2644,110 @@ mod tests { } } + async fn assert_remote_confirmation_rejected( + confirmation: openshell_isolation_interface::contract::BoundaryConfirmation, + ) { + let certificate = test_certificate(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind confirmation test server"); + let address = listener.local_addr().expect("confirmation test address"); + let expected_token = "a".repeat(32); + let server_config = certificate.server_config; + let server_token = expected_token.clone(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept confirmation client"); + let stream = tokio_rustls::TlsAcceptor::from(server_config) + .accept(stream) + .await + .expect("accept confirmation TLS"); + serve_test_grpc_with_confirmation(Box::new(stream), server_token, confirmation).await; + }); + + let descriptor = tls_runtime_descriptor(address, certificate.client_tls); + let outer_fence = descriptor.outer_fence.clone(); + let context = sandbox(); + let client = Arc::new(BoundaryClient::new( + descriptor, + test_bearer(&expected_token), + )); + let bound = RemoteBound { + client: client.clone(), + agent: context.agent, + policy: context.policy, + sandbox_id: context.sandbox_id, + mediation: Arc::new(RemoteNetworkMediation { + client: client.clone(), + }), + host_gateway_ip: None, + ca_file_paths: Arc::new(std::sync::Mutex::new(None)), + provider_credentials: + openshell_core::provider_credentials::ProviderCredentialState::from_environment( + 0, + HashMap::new(), + HashMap::new(), + HashMap::new(), + ), + identity: context.identity, + generation: "test-generation".to_string(), + session_id: test_session_id(), + resource_claims: std::collections::BTreeMap::new(), + outer_fence, + }; + + assert!(matches!( + Box::new(bound).confirm().await, + Err(BackendError::Confirm(_)) + )); + assert!( + !client.credential_monitor_started.load(Ordering::Acquire), + "credential monitoring must start only after confirmation succeeds" + ); + server.abort(); + } + + #[tokio::test] + async fn remote_confirm_rejects_invalid_native_audit_before_monitoring() { + let mut confirmation = test_confirmation(); + let mut audit: crate::boundary_protocol::NativeLinuxSandboxAuditEvidence = + serde_json::from_value(confirmation.backend_audit.clone()).expect("decode test audit"); + audit.seccomp.notification_round_trip = false; + confirmation.backend_audit = serde_json::to_value(audit).expect("encode test audit"); + + assert_remote_confirmation_rejected(confirmation).await; + } + + #[tokio::test] + async fn remote_confirm_rejects_property_projection_mismatch_before_monitoring() { + let mut confirmation = test_confirmation(); + confirmation.properties.egress_interception.mechanism = "untrusted projection".to_string(); + + assert_remote_confirmation_rejected(confirmation).await; + } + + #[tokio::test] + async fn remote_confirm_rejects_outer_fence_generation_mismatch_before_monitoring() { + let mut confirmation = test_confirmation(); + confirmation.outer_fence.generation = "other-generation".to_string(); + + assert_remote_confirmation_rejected(confirmation).await; + } + + #[tokio::test] + async fn remote_confirm_rejects_outer_fence_digest_mismatch_before_monitoring() { + let mut confirmation = test_confirmation(); + let different_fence = + openshell_isolation_interface::contract::OuterFenceGuarantees::from_driver_evidence( + "test-generation", + confirmation.outer_fence.established.iter().copied(), + b"different evidence", + ) + .expect("construct different test evidence"); + confirmation.outer_fence.evidence_digest = different_fence.evidence_digest; + + assert_remote_confirmation_rejected(confirmation).await; + } + #[test] fn runtime_descriptor_debug_redacts_trust_anchor() { let certificate = test_certificate(); @@ -2849,6 +2986,7 @@ mod tests { mediation_failures: Arc::new(std::sync::atomic::AtomicUsize::new(0)), mediation_ready: false, provider_environment_generation: 0, + confirmation: test_confirmation(), }; let server = tokio::spawn(async move { loop { diff --git a/crates/openshell-sandbox-backend/src/runtime/tests/credential_renewal.rs b/crates/openshell-sandbox-backend/src/runtime/tests/credential_renewal.rs index 9e82bfe0e1..df40832d4e 100644 --- a/crates/openshell-sandbox-backend/src/runtime/tests/credential_renewal.rs +++ b/crates/openshell-sandbox-backend/src/runtime/tests/credential_renewal.rs @@ -66,6 +66,7 @@ async fn renewing_boundary_client( mediation_failures: Arc::new(std::sync::atomic::AtomicUsize::new(0)), mediation_ready: false, provider_environment_generation: 0, + confirmation: test_confirmation(), }, expected_token: Arc::new(std::sync::RwLock::new("a".repeat(32))), failures: Arc::new(std::sync::atomic::AtomicUsize::new(0)), diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index bee799a5c6..17a1e78df7 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -59,7 +59,7 @@ mod linux { use openshell_sandbox_backend::boundary_protocol::{ AgentSpecWire, BinaryIdentityWire, BoundaryConfig, BoundaryErrorKind, BoundaryListener as BoundaryListenerConfig, DnsQueryResultWire, ExecSpecWire, - ExitStatusWire, MediationTimingWire, OpenShellSandboxAuditEvidence, OutputWindowWire, + ExitStatusWire, MediationTimingWire, NativeLinuxSandboxAuditEvidence, OutputWindowWire, ProcessKindWire, ProcessSnapshotWire, Request, RequestEnvelope, Response, ResponseEnvelope, STREAM_EXIT, STREAM_NETWORK_DECISION, STREAM_STDERR, STREAM_STDIN, STREAM_STDIN_CLOSED, STREAM_STDOUT, SandboxPolicyWire, SessionSnapshotWire, SignalWire, encode_frame, @@ -2319,7 +2319,7 @@ mod linux { // SAFETY: successful getrlimit initialized the value. let core_limit = unsafe { core_limit.assume_init() }; let (native_architecture, kernel_release) = uname_values()?; - let audit = OpenShellSandboxAuditEvidence { + let audit = NativeLinuxSandboxAuditEvidence { capabilities, no_new_privileges, sandbox_dumpable, @@ -4269,8 +4269,16 @@ mod linux { } fn test_outer_fence() -> openshell_isolation_interface::contract::OuterFenceGuarantees { - openshell_isolation_interface::contract::OuterFenceGuarantees::confirmed( + use openshell_isolation_interface::contract::OuterFenceGuarantee; + + openshell_isolation_interface::contract::OuterFenceGuarantees::from_driver_evidence( "generation-1", + [ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ], b"test-vm-fence", ) .unwrap() From 828118e48e1b59d96106cabafad7ac4d92f7e99d Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 17 Sep 2026 17:48:30 -0700 Subject: [PATCH 5/6] refactor(isolation): clarify outer fence ownership Signed-off-by: Drew Newberry --- architecture/sandbox.md | 23 ++++++------ .../openshell-driver-docker/src/isolation.rs | 2 +- .../src/isolation.rs | 2 +- .../openshell-driver-podman/src/isolation.rs | 2 +- .../openshell-driver-vm/src/isolation/mod.rs | 2 +- .../src/contract.rs | 35 ++++++++++--------- .../tests/backend_conformance.rs | 8 ++--- .../src/boundary_protocol.rs | 4 +-- .../openshell-sandbox-backend/src/runtime.rs | 4 +-- .../openshell-sandbox/src/boundary_server.rs | 2 +- .../src/sandbox/linux/seccomp.rs | 2 +- 11 files changed, 46 insertions(+), 40 deletions(-) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 26a5caaeeb..bf1b920ee7 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -84,17 +84,20 @@ replacement from granting authority. 6. Exec, signaling, PTY, DNS, TCP, and loopback-forwarding operations cross the authenticated channel for the lifetime of the sandbox generation. -The shared isolation contract receives only the driver's normalized outer-fence -guarantees: egress is default-deny, there is no unmanaged egress path, the -evidence is bound to the sandbox generation, revocation has been verified, and -controller loss fails closed. A digest commits those guarantees to the native -driver evidence without teaching the shared contract about container networks, +The shared isolation contract receives only normalized outer-fence guarantees: +egress is default-deny, there is no unmanaged egress path, the evidence is bound +to the sandbox generation, revocation has been verified, and controller loss +fails closed. A digest commits those guarantees to the native +evidence without teaching the shared contract about container networks, Kubernetes objects, VM devices, or accelerator resources. -Each driver makes that projection explicitly. Non-empty native evidence alone -does not establish a guarantee: +The component that owns the outer fence also validates its native evidence and +makes that projection explicitly. In the current Docker, Podman, Kubernetes, +and VM placements, that component is the compute driver. A delegated isolation +backend may own the fence and make the same projection instead. Non-empty native +evidence alone does not establish a guarantee: -| Driver | Native evidence | Guarantees projected by the driver | +| Current enforcement owner | Native evidence | Guarantees projected by the owner | |---|---|---| | Docker | Pinned container ID, `network_mode=none`, and no unexpected network attachments | No workload route establishes default-deny, revocation, and controller-loss behavior; the attachment inspection establishes that no unmanaged route exists. | | Podman | Pinned container ID, `--network=none`, and no unexpected network attachments | The same container-network facts establish the same four guarantees. | @@ -129,7 +132,7 @@ OpenShell uses overlapping controls rather than a single sandbox primitive: | Filesystem policy | Landlock restricts the paths the agent can read or write. | | Process policy | Sandbox and children run as one immutable non-root identity with zero capabilities. | | Seccomp notification | Virtualizes supported INET sockets and sends DNS/TCP decisions to the supervisor without nftables or proxy environment variables. | -| Driver outer fence | Docker `network_mode=none`, a NIC-less VM, or Kubernetes NetworkPolicy prevents any missed or unsupported kernel path from escaping. | +| Outer network fence | The component that owns network enforcement prevents any missed or unsupported kernel path from escaping. Current examples are Docker `network_mode=none`, a NIC-less VM, and Kubernetes NetworkPolicy. | | Policy proxy | Evaluates destination, binary identity, TLS/L7 rules, SSRF checks, and inference interception. | The supervisor may enrich baseline filesystem allowances for runtime-required @@ -230,7 +233,7 @@ cannot transfer that approval to another socket. The outer fence remains mandatory. If notification handling misses a syscall, loses the supervisor, exceeds a bound, or encounters an unsupported socket -type, the request fails and the driver-owned fence still blocks direct egress. +type, the request fails and the outer fence still blocks direct egress. CONNECT and absolute-form forward HTTP are explicit-proxy adapters over the same egress pipeline. Each adapter normalizes its request into an egress intent, and diff --git a/crates/openshell-driver-docker/src/isolation.rs b/crates/openshell-driver-docker/src/isolation.rs index d462f78234..d921b8c348 100644 --- a/crates/openshell-driver-docker/src/isolation.rs +++ b/crates/openshell-driver-docker/src/isolation.rs @@ -52,7 +52,7 @@ impl DockerOuterFenceEvidence<'_> { BackendError::Descriptor(format!("encode Docker outer fence evidence: {error}")) })?; let projection = - OuterFenceGuarantees::from_driver_evidence(generation, established, &encoded)?; + OuterFenceGuarantees::from_enforcement_evidence(generation, established, &encoded)?; projection.validate(generation)?; Ok(projection) } diff --git a/crates/openshell-driver-kubernetes/src/isolation.rs b/crates/openshell-driver-kubernetes/src/isolation.rs index 463924242b..6a94e6abaa 100644 --- a/crates/openshell-driver-kubernetes/src/isolation.rs +++ b/crates/openshell-driver-kubernetes/src/isolation.rs @@ -62,7 +62,7 @@ impl KubernetesOuterFenceEvidence<'_> { BackendError::Descriptor(format!("encode Kubernetes outer fence evidence: {error}")) })?; let projection = - OuterFenceGuarantees::from_driver_evidence(generation, established, &encoded)?; + OuterFenceGuarantees::from_enforcement_evidence(generation, established, &encoded)?; projection.validate(generation)?; Ok(projection) } diff --git a/crates/openshell-driver-podman/src/isolation.rs b/crates/openshell-driver-podman/src/isolation.rs index c7ff1a7ec7..69679661f9 100644 --- a/crates/openshell-driver-podman/src/isolation.rs +++ b/crates/openshell-driver-podman/src/isolation.rs @@ -56,7 +56,7 @@ impl PodmanOuterFenceEvidence<'_> { } let encoded = serde_json::to_vec(self).map_err(invalid)?; let projection = - OuterFenceGuarantees::from_driver_evidence(generation, established, &encoded) + OuterFenceGuarantees::from_enforcement_evidence(generation, established, &encoded) .map_err(invalid)?; projection.validate(generation).map_err(invalid)?; Ok(projection) diff --git a/crates/openshell-driver-vm/src/isolation/mod.rs b/crates/openshell-driver-vm/src/isolation/mod.rs index 5d3ba3cd30..841323ab23 100644 --- a/crates/openshell-driver-vm/src/isolation/mod.rs +++ b/crates/openshell-driver-vm/src/isolation/mod.rs @@ -43,7 +43,7 @@ impl VmOuterFenceEvidence<'_> { let encoded = serde_json::to_vec(self).map_err(|error| { BackendError::Descriptor(format!("encode VM outer fence evidence: {error}")) })?; - let projection = OuterFenceGuarantees::from_driver_evidence( + let projection = OuterFenceGuarantees::from_enforcement_evidence( self.generation, established.into_iter().flatten(), &encoded, diff --git a/crates/openshell-isolation-interface/src/contract.rs b/crates/openshell-isolation-interface/src/contract.rs index 022eb1110c..163cb9100f 100644 --- a/crates/openshell-isolation-interface/src/contract.rs +++ b/crates/openshell-isolation-interface/src/contract.rs @@ -386,23 +386,25 @@ pub trait BoundBoundary: Send { async fn confirm(self: Box) -> Result; } -/// Backend-neutral guarantees established by the compute driver's outer fence. +/// Backend-neutral guarantees established by the component that owns the outer +/// network fence. /// -/// Each driver owns its native evidence schema and the code that validates it. -/// After validation, the driver projects that evidence into these guarantees -/// and supplies a digest that binds the original evidence to this generation. -/// The common runtime only validates and compares this projection; it never -/// interprets runtime- or accelerator-specific fields. +/// The enforcement owner may be a compute driver or a delegated isolation +/// backend. It owns its native evidence schema and the code that validates it. +/// After validation, it projects that evidence into these guarantees and +/// supplies a digest that binds the original evidence to this generation. The +/// common runtime only validates and compares this projection; it never +/// interprets backend- or runtime-specific fields. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum OuterFenceGuarantee { /// No workload packet can leave without an explicit mediated decision. DefaultDenyEgress, - /// The driver found no network path outside the mediated boundary. + /// The enforcement owner found no network path outside the mediated boundary. NoUnmanagedEgressPath, - /// Previously granted access can be revoked by the driver-owned fence. + /// Previously granted access can be revoked by the enforcement owner. RevocationVerified, - /// Loss of the driver or its controller does not open network access. + /// Loss of the fence's controller does not open network access. ControllerLossFailsClosed, } @@ -410,19 +412,20 @@ pub enum OuterFenceGuarantee { pub struct OuterFenceGuarantees { /// Sandbox generation for which the evidence was collected. pub generation: String, - /// Complete set of normalized guarantees established by the driver. + /// Complete set of normalized guarantees established by the enforcement owner. pub established: BTreeSet, - /// Commitment to the driver-owned native evidence used for this projection. + /// Commitment to the enforcement owner's native evidence. pub evidence_digest: Sha256Digest, } impl OuterFenceGuarantees { - /// Bind the guarantees explicitly established by driver-owned evidence. + /// Bind guarantees explicitly established by validated enforcement evidence. /// /// This constructor deliberately does not infer guarantees from the mere - /// presence of evidence. The driver must inspect its native state and - /// project each established guarantee before calling this function. - pub fn from_driver_evidence( + /// presence of evidence. The enforcement owner must inspect its native + /// state and project each established guarantee before calling this + /// function. + pub fn from_enforcement_evidence( generation: impl Into, established: impl IntoIterator, native_evidence: &[u8], @@ -529,7 +532,7 @@ pub struct BoundaryConfirmation { pub authenticated_supervisor: bool, pub session_id: SandboxSessionId, pub outer_fence: OuterFenceGuarantees, - /// The driver-owned containment primitive terminates the workload when its + /// The backend-owned containment primitive terminates the workload when its /// Sandbox Runtime exits. pub runtime_exit_terminates_workload: bool, pub resource_claims: BTreeMap, diff --git a/crates/openshell-isolation-interface/tests/backend_conformance.rs b/crates/openshell-isolation-interface/tests/backend_conformance.rs index 1dbda9ed16..37abe4cd73 100644 --- a/crates/openshell-isolation-interface/tests/backend_conformance.rs +++ b/crates/openshell-isolation-interface/tests/backend_conformance.rs @@ -369,7 +369,7 @@ fn workload_identity() -> ResolvedWorkloadIdentity { } fn complete_outer_fence(generation: &str, evidence: &[u8]) -> OuterFenceGuarantees { - OuterFenceGuarantees::from_driver_evidence( + OuterFenceGuarantees::from_enforcement_evidence( generation, [ OuterFenceGuarantee::DefaultDenyEgress, @@ -425,10 +425,10 @@ fn outer_fence_guarantees_are_backend_neutral_and_fail_closed() { assert!(incomplete.validate("generation-1").is_err()); } - assert!(OuterFenceGuarantees::from_driver_evidence("", [], b"evidence").is_err()); - assert!(OuterFenceGuarantees::from_driver_evidence("generation-1", [], b"").is_err()); + assert!(OuterFenceGuarantees::from_enforcement_evidence("", [], b"evidence").is_err()); + assert!(OuterFenceGuarantees::from_enforcement_evidence("generation-1", [], b"").is_err()); - let unproven = OuterFenceGuarantees::from_driver_evidence( + let unproven = OuterFenceGuarantees::from_enforcement_evidence( "generation-1", [OuterFenceGuarantee::DefaultDenyEgress], b"native-driver-evidence", diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index 4f7b4e77ef..c706056621 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -426,7 +426,7 @@ pub struct SandboxRuntimeDescriptor { /// example pod UID, VM generation, or container ID). #[serde(default)] pub resource_claims: std::collections::BTreeMap, - /// Backend-neutral projection of the driver-validated outer fence. + /// Backend-neutral projection of the validated outer network fence. pub outer_fence: OuterFenceGuarantees, } @@ -494,7 +494,7 @@ pub struct BoundaryConfig { pub resource_claim_files: std::collections::BTreeMap, /// Exact identity already applied by the runtime to the sandbox process. pub workload_identity: openshell_isolation_interface::contract::ResolvedWorkloadIdentity, - /// Backend-neutral projection of the driver-validated outer fence. + /// Backend-neutral projection of the validated outer network fence. pub outer_fence: OuterFenceGuarantees, /// Driver-resolved environment exposed only to workload processes. #[serde(default)] diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index 43ed099708..0ef99dae43 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -1944,7 +1944,7 @@ mod tests { fn test_outer_fence() -> openshell_isolation_interface::contract::OuterFenceGuarantees { use openshell_isolation_interface::contract::OuterFenceGuarantee; - openshell_isolation_interface::contract::OuterFenceGuarantees::from_driver_evidence( + openshell_isolation_interface::contract::OuterFenceGuarantees::from_enforcement_evidence( "test-generation", [ OuterFenceGuarantee::DefaultDenyEgress, @@ -2737,7 +2737,7 @@ mod tests { async fn remote_confirm_rejects_outer_fence_digest_mismatch_before_monitoring() { let mut confirmation = test_confirmation(); let different_fence = - openshell_isolation_interface::contract::OuterFenceGuarantees::from_driver_evidence( + openshell_isolation_interface::contract::OuterFenceGuarantees::from_enforcement_evidence( "test-generation", confirmation.outer_fence.established.iter().copied(), b"different evidence", diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index 17a1e78df7..f4bb815f95 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -4271,7 +4271,7 @@ mod linux { fn test_outer_fence() -> openshell_isolation_interface::contract::OuterFenceGuarantees { use openshell_isolation_interface::contract::OuterFenceGuarantee; - openshell_isolation_interface::contract::OuterFenceGuarantees::from_driver_evidence( + openshell_isolation_interface::contract::OuterFenceGuarantees::from_enforcement_evidence( "generation-1", [ OuterFenceGuarantee::DefaultDenyEgress, diff --git a/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs b/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs index f3da87aeef..81d0b1630f 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs @@ -25,7 +25,7 @@ //! The risk is contained by existing sandbox layers: //! - **Privilege drop**: `CAP_NET_ADMIN` is not granted, so all write operations //! (add/delete routes, addresses, interfaces) fail with `EPERM` regardless. -//! - **Driver outer fence**: direct workload egress is rejected outside this +//! - **Outer network fence**: direct workload egress is rejected outside this //! process by Docker network-none, Kubernetes `NetworkPolicy`, or a NIC-less //! VM. //! From 19ac2bd01f9bdbeb11b575753d62c6f84335f1f1 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 18 Sep 2026 13:06:24 -0700 Subject: [PATCH 6/6] fix(isolation): update confirmation test fixtures Signed-off-by: Drew Newberry --- crates/openshell-sandbox-backend/src/runtime.rs | 1 + .../src/runtime/tests/network_recovery.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index 0ef99dae43..16840ca577 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -3058,6 +3058,7 @@ mod tests { mediation_failures: Arc::new(std::sync::atomic::AtomicUsize::new(0)), mediation_ready: false, provider_environment_generation: 50, + confirmation: test_confirmation(), }; let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); diff --git a/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs b/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs index d131c3ffa0..0ad9c7b441 100644 --- a/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs +++ b/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs @@ -119,7 +119,7 @@ impl NetworkPeer { assert!(self.attached.load(Ordering::Acquire)); *self.state.active.lock().unwrap() = Some(self.connection); Response::Confirmed { - evidence: Box::new(test_confirmation_evidence()), + confirmation: Box::new(test_confirmation()), } } Request::AcceptNetwork => {