diff --git a/Cargo.lock b/Cargo.lock index c9c14fdca8..54853cdfba 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/compute-runtimes.md b/architecture/compute-runtimes.md index a2bb25c312..a178c3d2f6 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -297,12 +297,24 @@ sandbox and can negate OpenShell workspace isolation and filesystem-policy controls. Driver-owned supervisor, token, and TLS bind mounts stay reserved. Network features follow the driver/substrate split. Drivers own only the outer -fence and protected channel. The sandbox owns seccomp notification, local DNS, -socket virtualization, process observation, and binary identity. The supervisor -owns DNS eligibility, policy authorization, destination filtering, upstream -dials, relay behavior, credential rewriting, and OCSF decisions. No supported -path requires nftables, a workload network namespace, proxy environment -variables, added capabilities, or an unconfined AppArmor profile. +fence and protected channel. The native Linux adapter owns seccomp notification, +local DNS, socket virtualization, process observation, and binary identity. The +Kubernetes gVisor adapter instead combines the sentry and zero-rule workload +`NetworkPolicy` with a workload-local explicit proxy whose streams cross the +protected channel. The supervisor owns DNS eligibility, policy authorization, +destination filtering, upstream dials, relay behavior, credential rewriting, +and OCSF decisions. Native mode does not require proxy environment variables; +gVisor mode injects loopback HTTP proxy variables and applies endpoint-only +policy without added capabilities or an unconfined AppArmor profile. + +The BlueField VM prototype keeps the same split while allowing one accelerated +guest VF behind a separate hardware outer fence. The compute driver owns VF and +representor assignment; the isolation backend proves that DPU steering starts +default-deny and that supervisor-authorized, process-attributed flows can be +installed and revoked for the current sandbox generation. Ordinary VM evidence +continues to require a NIC-less guest. The prototype currently defines only the +fence and confirmation evidence; it does not attach hardware or enable a runtime +adapter. The Kubernetes deployment packaging has two ownership boundaries. The gateway chart owns the gateway workload, configuration, Services, PKI, and diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 60b8aa245e..bb5a9848f0 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -11,8 +11,8 @@ Each sandbox has three trust levels: | Component | Role | |---|---| | Supervisor | Owns gateway credentials, admitted policy, L7 proxying, SSH, and gateway relays. It never executes inside the agent workload. | -| Sandbox | Runs as the same non-root identity as the agent, installs the workload seccomp listener, applies the Landlock baseline, owns child processes, and mediates the protected supervisor channel. | -| Agent child | Inherits the sandbox network listener and runs with zero capabilities, `no_new_privs`, Landlock, and the final syscall filter. | +| Sandbox | Runs as the same non-root identity as the agent, owns child processes, mediates the protected supervisor channel, and instantiates the driver-selected native Linux or gVisor adapter. | +| Agent child | Runs with zero capabilities behind the selected adapter. Native Linux children inherit seccomp mediation, `no_new_privs`, Landlock, and the final syscall filter; gVisor children rely on the sentry, OCI mounts, and the outer network fence. | The runtime grants neither trusted component nor agent child any Linux capability inside the workload. Drivers resolve one exact non-root UID, GID, @@ -65,9 +65,13 @@ replacement from granting authority. run untrusted code yet. 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. +4. The sandbox starts the selected runtime adapter, 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 @@ -96,9 +100,30 @@ 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. | +| Driver outer fence | Docker `network_mode=none`, a NIC-less VM, Kubernetes NetworkPolicy, or a default-deny BlueField DPU assignment prevents any missed or unsupported kernel path from escaping. | | Policy proxy | Evaluates destination, binary identity, TLS/L7 rules, SSRF checks, and inference interception. | +The BlueField VM prototype permits one workload-visible VF without weakening +the ordinary VM fence. Its distinct fence binds the VF, representor, DPU, +assignment generation, and policy generation to the sandbox generation. The +DPU starts default-deny with no unmanaged guest uplinks. Before an accelerated +flow can carry traffic, the backend must prove an attributed supervisor +authorization round trip, revocation, and fail-closed behavior when its control +channel is lost. These measurements remain backend-owned audit evidence and +project into the same backend-neutral properties as other adapters. This layer +defines and tests that confirmation contract; hardware discovery, VF attachment, +and flow programming remain follow-up work and no driver selects it yet. + +The Kubernetes gVisor adapter uses the same authenticated lifecycle and process +backend with different enforcement mechanisms. The gVisor sentry and OCI mounts +provide the workload boundary. A zero-rule Kubernetes egress `NetworkPolicy` +blocks direct connections, while a workload-local HTTP/CONNECT listener reverse- +tunnels streams to the existing supervisor proxy. The supervisor loads policy +in endpoint-only mode because the tunnel authenticates the sandbox generation, +not an individual executable. This adapter intentionally does not claim native +Landlock path policy, nested child seccomp, transparent TCP, or per-binary +network attribution. + The supervisor may enrich baseline filesystem allowances for runtime-required paths, such as proxy support files or GPU device paths when a GPU is present. These internal allowances must stay sandbox-scoped and avoid exposing host diff --git a/crates/openshell-driver-docker/src/isolation.rs b/crates/openshell-driver-docker/src/isolation.rs index 5e50a37ad7..812519aea5 100644 --- a/crates/openshell-driver-docker/src/isolation.rs +++ b/crates/openshell-driver-docker/src/isolation.rs @@ -14,8 +14,8 @@ use std::path::PathBuf; use openshell_isolation_interface::contract::{DriverFenceEvidence, ResolvedWorkloadIdentity}; use openshell_sandbox_backend::GPU_RESOURCE_CLAIM; use openshell_sandbox_backend::boundary_protocol::{ - BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, - SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, + BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeAdapter, + SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, }; /// Driver-owned inputs that bind one Docker container to one boundary. @@ -78,6 +78,7 @@ impl DockerBoundarySpec { resource_claims: resource_claims.clone(), resource_claim_files: BTreeMap::new(), workload_identity: self.workload_identity.clone(), + adapter: SandboxRuntimeAdapter::default(), driver_fence: driver_fence.clone(), child_env: self.child_env, }, @@ -86,6 +87,7 @@ impl DockerBoundarySpec { generation: self.generation, session_id: self.session_id, workload_identity: self.workload_identity, + adapter: SandboxRuntimeAdapter::default(), transport: SandboxTransport::Unix { socket_path: self.control_socket, }, diff --git a/crates/openshell-driver-kubernetes/README.md b/crates/openshell-driver-kubernetes/README.md index c24cd9c9dd..87976e4034 100644 --- a/crates/openshell-driver-kubernetes/README.md +++ b/crates/openshell-driver-kubernetes/README.md @@ -63,6 +63,16 @@ default seccomp profile. The sandbox installs a nested seccomp user-notification filter without requesting a capability in the Pod spec. Startup fails closed when the runtime blocks the required seccomp or Landlock operations. +An effective `pod.runtime_class_name` of `gvisor` selects the gVisor adapter in +the same sandbox backend. The workload Pod keeps the non-root, drop-all- +capabilities posture but omits the Kubernetes seccomp profile and custom sysctl +that GKE Sandbox does not support. The sandbox qualifies the gVisor sentry, +starts an explicit proxy on `127.0.0.1:3128`, and reverse-tunnels proxy streams +to the supervisor over the authenticated boundary protocol. The empty-egress +`NetworkPolicy` blocks direct workload connections. This mode applies endpoint- +only network policy and does not apply Landlock path rules, the nested child +seccomp filter, transparent TCP, or per-binary network attribution. + The supervisor Pod has a direct, non-controller owner reference to the Sandbox resource. This links its garbage-collection lifecycle to the sandbox without competing with the Agent Sandbox controller for workload-Pod ownership. diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index b1bee60a9a..6e2211af32 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -1962,6 +1962,7 @@ impl KubernetesComputeDriver { return Err(fail("pair labels changed")); } let spec = pod.spec.as_ref().ok_or_else(|| fail("missing Pod spec"))?; + let gvisor = spec.runtime_class_name.as_deref() == Some("gvisor"); if spec.host_network == Some(true) || spec.host_pid == Some(true) || spec.host_ipc == Some(true) @@ -2013,10 +2014,12 @@ impl KubernetesComputeDriver { .supplemental_groups .as_deref() .is_some_and(|groups| !groups.is_empty()) - || security - .seccomp_profile - .as_ref() - .is_none_or(|profile| profile.type_ != "RuntimeDefault") + || (!gvisor + && security + .seccomp_profile + .as_ref() + .is_none_or(|profile| profile.type_ != "RuntimeDefault")) + || (gvisor && security.seccomp_profile.is_some()) { return Err(fail("numeric identity, groups, or seccomp profile changed")); } @@ -2046,9 +2049,17 @@ impl KubernetesComputeDriver { && sysctl.get("value").and_then(serde_json::Value::as_str) == Some("0") }) }); - if !unprivileged_port_sysctl { + if !gvisor && !unprivileged_port_sysctl { return Err(fail("safe unprivileged-port sysctl changed")); } + if gvisor + && pod_json + .pointer("/spec/securityContext/sysctls") + .and_then(serde_json::Value::as_array) + .is_some_and(|sysctls| !sysctls.is_empty()) + { + return Err(fail("gVisor workload must not request custom sysctls")); + } let check_container = |container: &k8s_openapi::api::core::v1::Container, name: &str| -> Result<(), KubernetesDriverError> { @@ -2265,6 +2276,16 @@ impl KubernetesComputeDriver { agent_gid, &names.sandbox_secret, )?; + let runtime_adapter = if workload_pod + .spec + .as_ref() + .and_then(|spec| spec.runtime_class_name.as_deref()) + == Some("gvisor") + { + openshell_sandbox_backend::boundary_protocol::SandboxRuntimeAdapter::Gvisor + } else { + openshell_sandbox_backend::boundary_protocol::SandboxRuntimeAdapter::NativeLinux + }; let workload_pod_uid = workload_pod.metadata.uid.clone().ok_or_else(|| { KubernetesDriverError::Message("workload Pod has no UID".to_string()) @@ -2308,6 +2329,22 @@ impl KubernetesComputeDriver { child_env.extend(spec.environment.clone()); } child_env.retain(|name, _| !name.starts_with("OPENSHELL_")); + if matches!( + runtime_adapter, + openshell_sandbox_backend::boundary_protocol::SandboxRuntimeAdapter::Gvisor + ) { + for name in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"] { + child_env.insert(name.to_string(), "http://127.0.0.1:3128".to_string()); + } + child_env.insert( + "NO_PROXY".to_string(), + "localhost,127.0.0.1,::1".to_string(), + ); + child_env.insert( + "no_proxy".to_string(), + "localhost,127.0.0.1,::1".to_string(), + ); + } let host_gateway_ip = self.config.host_gateway_ip.parse().ok(); let session_id = launch_authentication.supervisor.session_id; let tls = generate_sandbox_tls_material(session_id) @@ -2368,6 +2405,7 @@ impl KubernetesComputeDriver { }, host_gateway_ip, workload_identity, + adapter: runtime_adapter, child_env, } .provision(); @@ -2470,7 +2508,7 @@ impl KubernetesComputeDriver { supervisor_uid: &str, agent_uid: u32, agent_gid: u32, - child_env: std::collections::HashMap, + mut child_env: std::collections::HashMap, launch_authentication: &openshell_core::jwt::SandboxLaunchAuthentication, ) -> Result<(), KubernetesDriverError> { let namespace_uid = Api::::all(self.client.clone()) @@ -2525,6 +2563,32 @@ impl KubernetesComputeDriver { agent_gid, &names.sandbox_secret, )?; + let runtime_adapter = if workload_pod + .spec + .as_ref() + .and_then(|spec| spec.runtime_class_name.as_deref()) + == Some("gvisor") + { + openshell_sandbox_backend::boundary_protocol::SandboxRuntimeAdapter::Gvisor + } else { + openshell_sandbox_backend::boundary_protocol::SandboxRuntimeAdapter::NativeLinux + }; + if matches!( + runtime_adapter, + openshell_sandbox_backend::boundary_protocol::SandboxRuntimeAdapter::Gvisor + ) { + for name in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"] { + child_env.insert(name.to_string(), "http://127.0.0.1:3128".to_string()); + } + child_env.insert( + "NO_PROXY".to_string(), + "localhost,127.0.0.1,::1".to_string(), + ); + child_env.insert( + "no_proxy".to_string(), + "localhost,127.0.0.1,::1".to_string(), + ); + } let workload_pod_uid = workload_pod.metadata.uid.clone().ok_or_else(|| { KubernetesDriverError::Message("workload Pod has no UID".to_string()) @@ -2606,6 +2670,7 @@ impl KubernetesComputeDriver { }, host_gateway_ip: self.config.host_gateway_ip.parse().ok(), workload_identity, + adapter: runtime_adapter, child_env, } .provision(); @@ -5215,6 +5280,10 @@ fn apply_supervisor_sandbox_runtime_boundary( else { return; }; + let gvisor = spec + .get("runtimeClassName") + .and_then(serde_json::Value::as_str) + == Some("gvisor"); spec.insert("hostNetwork".to_string(), serde_json::json!(false)); spec.insert("hostPID".to_string(), serde_json::json!(false)); spec.insert("hostIPC".to_string(), serde_json::json!(false)); @@ -5238,20 +5307,22 @@ fn apply_supervisor_sandbox_runtime_boundary( ] }), ); - spec.insert( - "securityContext".to_string(), - serde_json::json!({ - "runAsUser": params.sandbox_uid, - "runAsGroup": params.sandbox_gid, - "runAsNonRoot": true, - "fsGroup": params.sandbox_gid, - "fsGroupChangePolicy": "OnRootMismatch", - "supplementalGroups": [], - "supplementalGroupsPolicy": "Strict", - "seccompProfile": {"type": "RuntimeDefault"}, - "sysctls": [{"name": "net.ipv4.ip_unprivileged_port_start", "value": "0"}] - }), - ); + let mut pod_security = serde_json::json!({ + "runAsUser": params.sandbox_uid, + "runAsGroup": params.sandbox_gid, + "runAsNonRoot": true, + "fsGroup": params.sandbox_gid, + "fsGroupChangePolicy": "OnRootMismatch", + "supplementalGroups": [], + "supplementalGroupsPolicy": "Strict" + }); + if !gvisor { + pod_security["seccompProfile"] = serde_json::json!({"type": "RuntimeDefault"}); + pod_security["sysctls"] = serde_json::json!([ + {"name": "net.ipv4.ip_unprivileged_port_start", "value": "0"} + ]); + } + spec.insert("securityContext".to_string(), pod_security); let volumes = spec .entry("volumes") .or_insert_with(|| serde_json::json!([])) @@ -8504,6 +8575,40 @@ mod tests { ); } + #[test] + fn gvisor_runtime_omits_incompatible_kernel_security_context() { + let pod_template = { + let params = SandboxPodParams { + default_runtime_class_name: "gvisor", + ..SandboxPodParams::default() + }; + sandbox_template_to_k8s( + &SandboxTemplate::default(), + false, + &std::collections::HashMap::new(), + true, + ¶ms, + ) + }; + + assert_eq!( + pod_template["spec"]["runtimeClassName"], + serde_json::json!("gvisor") + ); + assert!( + pod_template["spec"]["securityContext"]["seccompProfile"].is_null(), + "GKE Sandbox rejects Kubernetes seccomp profiles" + ); + assert!( + pod_template["spec"]["securityContext"]["sysctls"].is_null(), + "GKE Sandbox rejects custom sysctls" + ); + assert_eq!( + pod_template["spec"]["containers"][0]["securityContext"]["capabilities"]["drop"], + serde_json::json!(["ALL"]) + ); + } + #[test] fn template_runtime_class_name_overrides_config_default() { let template = SandboxTemplate { diff --git a/crates/openshell-driver-kubernetes/src/isolation.rs b/crates/openshell-driver-kubernetes/src/isolation.rs index bb9ae8ff66..9490cb943e 100644 --- a/crates/openshell-driver-kubernetes/src/isolation.rs +++ b/crates/openshell-driver-kubernetes/src/isolation.rs @@ -22,8 +22,8 @@ use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString; use kube::core::ObjectMeta; use openshell_isolation_interface::contract::{DriverFenceEvidence, ResolvedWorkloadIdentity}; use openshell_sandbox_backend::boundary_protocol::{ - BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, - SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, + BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeAdapter, + SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, }; /// Isolation backend implemented by the `OpenShell` sandbox runtime. @@ -169,6 +169,7 @@ pub struct KubernetesSandboxRuntimeBoundarySpec { pub supervisor_tls: SandboxTlsClientConfig, pub host_gateway_ip: Option, pub workload_identity: ResolvedWorkloadIdentity, + pub adapter: SandboxRuntimeAdapter, pub child_env: HashMap, } @@ -233,6 +234,7 @@ impl KubernetesSandboxRuntimeBoundarySpec { self.workload_pod_uid_path, )]), workload_identity: self.workload_identity.clone(), + adapter: self.adapter, driver_fence: driver_fence.clone(), child_env: self.child_env, }, @@ -241,6 +243,7 @@ impl KubernetesSandboxRuntimeBoundarySpec { generation: self.generation, session_id: self.session_id, workload_identity: self.workload_identity, + adapter: self.adapter, transport: SandboxTransport::Tcp { authority: self.control_authority, addresses: vec![self.control_address], @@ -297,6 +300,7 @@ mod tests { "sandbox:sandbox-resource-uid".to_string(), ) .unwrap(), + adapter: SandboxRuntimeAdapter::NativeLinux, child_env: HashMap::new(), } } @@ -360,6 +364,22 @@ mod tests { ); } + #[test] + fn provisioning_binds_gvisor_adapter_on_both_protocol_sides() { + let mut boundary = spec(); + boundary.adapter = SandboxRuntimeAdapter::Gvisor; + let provisioned = boundary.provision(); + + assert_eq!( + provisioned.boundary_config.adapter, + SandboxRuntimeAdapter::Gvisor + ); + assert_eq!( + provisioned.runtime_descriptor.adapter, + SandboxRuntimeAdapter::Gvisor + ); + } + #[test] fn network_fence_denies_all_workload_initiated_egress() { let fence = KubernetesSandboxRuntimeNetworkFenceSpec { diff --git a/crates/openshell-driver-podman/src/isolation.rs b/crates/openshell-driver-podman/src/isolation.rs index b616fa6474..3cbe73b2a9 100644 --- a/crates/openshell-driver-podman/src/isolation.rs +++ b/crates/openshell-driver-podman/src/isolation.rs @@ -12,8 +12,8 @@ use openshell_core::ComputeDriverError; use openshell_core::proto::compute::v1::DriverSandbox; use openshell_isolation_interface::contract::{DriverFenceEvidence, ResolvedWorkloadIdentity}; use openshell_sandbox_backend::boundary_protocol::{ - BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, - SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, + BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeAdapter, + SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, generate_sandbox_tls_material, }; use serde::{Deserialize, Serialize}; @@ -198,6 +198,7 @@ pub fn bootstrap_archives( resource_claims: resource_claims.clone(), resource_claim_files: BTreeMap::new(), workload_identity: identity.clone(), + adapter: SandboxRuntimeAdapter::default(), driver_fence: driver_fence.clone(), child_env: child_env.clone(), }; @@ -215,6 +216,7 @@ pub fn bootstrap_archives( host_gateway_ip: None, resource_claims, workload_identity: identity.clone(), + adapter: SandboxRuntimeAdapter::default(), driver_fence, }; // Libpod resolves the requested upload destination once for a stopped diff --git a/crates/openshell-driver-vm/src/isolation/mod.rs b/crates/openshell-driver-vm/src/isolation/mod.rs index 49c1135310..6ab09734ad 100644 --- a/crates/openshell-driver-vm/src/isolation/mod.rs +++ b/crates/openshell-driver-vm/src/isolation/mod.rs @@ -12,8 +12,8 @@ use openshell_isolation_interface::contract::{ BackendError, DriverFenceEvidence, ResolvedWorkloadIdentity, }; use openshell_sandbox_backend::boundary_protocol::{ - BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, - SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, + BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeAdapter, + SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, }; use std::collections::{BTreeMap, HashMap}; @@ -77,6 +77,7 @@ impl VmBoundarySpec { resource_claims: resource_claims.clone(), resource_claim_files: BTreeMap::new(), workload_identity: workload_identity.clone(), + adapter: SandboxRuntimeAdapter::default(), driver_fence: driver_fence.clone(), child_env: self.child_env, }, @@ -85,6 +86,7 @@ impl VmBoundarySpec { generation: self.generation, session_id: self.session_id, workload_identity, + adapter: SandboxRuntimeAdapter::default(), transport: self.transport, tls: self.supervisor_tls, // The host-side control process is the network broker, so 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 f671dd99e9..7e606ab2a0 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 @@ -455,6 +416,22 @@ pub enum DriverFenceEvidence { generation: String, network_device_count: u32, }, + /// A VM with one `BlueField` VF whose direct datapath is fenced by the DPU. + /// + /// Unlike the generic VM fence, this permits a workload-visible network + /// device only when the driver has established a generation-scoped, + /// default-deny hardware boundary with no unmanaged guest uplinks. + BluefieldVm { + generation: String, + vf_pci_address: String, + representor: String, + dpu_id: String, + assignment_generation: String, + policy_generation: u64, + default_deny: bool, + attached_vf_count: u32, + unmanaged_network_device_count: u32, + }, } impl DriverFenceEvidence { @@ -465,6 +442,7 @@ impl DriverFenceEvidence { Self::Podman { .. } => "podman", Self::Kubernetes { .. } => "kubernetes", Self::Vm { .. } => "vm", + Self::BluefieldVm { .. } => "bluefield-vm", } } @@ -500,6 +478,27 @@ impl DriverFenceEvidence { generation, network_device_count, } => !generation.is_empty() && *network_device_count == 0, + Self::BluefieldVm { + generation, + vf_pci_address, + representor, + dpu_id, + assignment_generation, + policy_generation, + default_deny, + attached_vf_count, + unmanaged_network_device_count, + } => { + !generation.is_empty() + && !vf_pci_address.is_empty() + && !representor.is_empty() + && !dpu_id.is_empty() + && !assignment_generation.is_empty() + && *policy_generation > 0 + && *default_deny + && *attached_vf_count == 1 + && *unmanaged_network_device_count == 0 + } }; if valid { Ok(()) @@ -512,29 +511,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)] -#[allow( - clippy::struct_excessive_bools, - reason = "confirmation preserves independently measured security results" -)] -pub struct SandboxConfirmEvidence { +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)] +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 +579,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 +595,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. @@ -995,13 +1018,34 @@ pub struct PendingTcpOpen { /// An `Err` means that mediation lane is unusable and fails closed. #[async_trait] pub trait NetworkMediationSource: Send + Sync { + /// Shape of the workload-side stream exposed by this backend. + fn mode(&self) -> NetworkMediationMode { + NetworkMediationMode::TransparentTcp + } + /// Await the next staged workload TCP open. async fn accept_tcp(&self) -> Result; + /// Await a raw HTTP/CONNECT proxy client stream. Backends using this mode + /// authenticate the boundary and enforce direct-egress denial externally; + /// the supervisor proxy performs endpoint-only authorization. + async fn accept_explicit_proxy(&self) -> Result { + Err(BackendError::Unsupported( + "explicit proxy streams are not supported by this backend".to_string(), + )) + } + /// Await the next workload DNS query. async fn accept_dns(&self) -> Result; } +/// Network stream shape exposed by an isolation backend. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NetworkMediationMode { + TransparentTcp, + ExplicitProxy, +} + /// DNS transport used by one workload exchange. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum DnsTransport { 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/src/linux/workload_launcher.rs b/crates/openshell-isolation-interface/src/linux/workload_launcher.rs index 8602d9df80..3f8976349b 100644 --- a/crates/openshell-isolation-interface/src/linux/workload_launcher.rs +++ b/crates/openshell-isolation-interface/src/linux/workload_launcher.rs @@ -26,6 +26,7 @@ type LaunchJob = Box; pub struct WorkloadLauncher { jobs: mpsc::SyncSender, alive: Arc, + native_linux_isolation: bool, } impl WorkloadLauncher { @@ -63,6 +64,12 @@ impl WorkloadLauncher { pub fn is_alive(&self) -> bool { self.alive.load(Ordering::Acquire) } + + /// Whether children need the native Landlock and seccomp launch prelude. + #[must_use] + pub const fn uses_native_linux_isolation(&self) -> bool { + self.native_linux_isolation + } } /// Start the only workload launcher and return its listener to an unfiltered @@ -108,11 +115,35 @@ pub fn start() -> io::Result<(WorkloadLauncher, NotificationListener)> { WorkloadLauncher { jobs: jobs_tx, alive, + native_linux_isolation: true, }, listener, )) } +/// Start a serialized launcher without installing a host-kernel seccomp +/// listener. This is used inside gVisor, where the sentry and the driver's +/// network fence provide the isolation boundary. +pub fn start_unfiltered() -> io::Result { + let (jobs_tx, jobs_rx) = mpsc::sync_channel::(64); + let alive = Arc::new(AtomicBool::new(true)); + let thread_alive = alive.clone(); + thread::Builder::new() + .name("openshell-workload-launcher".to_string()) + .spawn(move || { + while let Ok(job) = jobs_rx.recv() { + job(); + } + thread_alive.store(false, Ordering::Release); + }) + .map_err(|error| io::Error::other(format!("start workload launcher thread: {error}")))?; + Ok(WorkloadLauncher { + jobs: jobs_tx, + alive, + native_linux_isolation: false, + }) +} + #[cfg(test)] #[allow(unsafe_code)] mod tests { @@ -121,6 +152,14 @@ mod tests { use super::*; + #[test] + fn unfiltered_launcher_serializes_work_without_native_controls() { + let launcher = start_unfiltered().expect("start unfiltered launcher"); + assert!(!launcher.uses_native_linux_isolation()); + assert_eq!(launcher.execute(|| 42).expect("execute launch job"), 42); + assert!(launcher.is_alive()); + } + #[test] fn one_listener_mediates_launcher_and_inherited_child() { let (launcher, listener) = start().expect("start launcher"); diff --git a/crates/openshell-isolation-interface/tests/backend_conformance.rs b/crates/openshell-isolation-interface/tests/backend_conformance.rs index 73559b203f..b1b7dd0760 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"}), } } @@ -431,10 +408,22 @@ fn driver_fence_evidence_is_backend_specific_and_fail_closed() { generation: "generation-1".to_string(), network_device_count: 0, }; + let bluefield_vm = DriverFenceEvidence::BluefieldVm { + generation: "generation-1".to_string(), + vf_pci_address: "0000:03:00.2".to_string(), + representor: "pf0vf0".to_string(), + dpu_id: "dpu-1".to_string(), + assignment_generation: "assignment-1".to_string(), + policy_generation: 1, + default_deny: true, + attached_vf_count: 1, + unmanaged_network_device_count: 0, + }; assert!(docker.validate().is_ok()); assert!(kubernetes.validate().is_ok()); assert!(vm.validate().is_ok()); + assert!(bluefield_vm.validate().is_ok()); let drifted = DriverFenceEvidence::Docker { container_id: "sha256:container".to_string(), @@ -442,6 +431,19 @@ fn driver_fence_evidence_is_backend_specific_and_fail_closed() { unexpected_networks: vec!["bridge".to_string()], }; assert!(drifted.validate().is_err()); + + let unfenced_bluefield_vm = DriverFenceEvidence::BluefieldVm { + generation: "generation-1".to_string(), + vf_pci_address: "0000:03:00.2".to_string(), + representor: "pf0vf0".to_string(), + dpu_id: "dpu-1".to_string(), + assignment_generation: "assignment-1".to_string(), + policy_generation: 1, + default_deny: false, + attached_vf_count: 1, + unmanaged_network_device_count: 0, + }; + assert!(unfenced_bluefield_vm.validate().is_err()); } /// The backend-independent supervisor sequence. Identical for every backend: @@ -458,7 +460,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 +538,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 +561,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 +844,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 +865,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 e619d68dc2..4f23624593 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,339 @@ pub const STREAM_STDIN_CLOSED: u8 = 4; pub const STREAM_NETWORK_DECISION: u8 = 5; pub const MAX_STREAM_FRAME_BYTES: usize = 64 * 1024; +/// Workload-isolation mechanism used behind the shared `OpenShell` Sandbox +/// Protocol. +/// +/// The native adapter uses Linux Landlock and seccomp notification; the gVisor +/// adapter relies on the sentry boundary, the driver's outer network fence, +/// and an explicit proxy tunnel. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SandboxRuntimeAdapter { + #[default] + NativeLinux, + Gvisor, +} + +/// 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", + ), + } + } +} + +/// Host-validated evidence for a `BlueField`-accelerated VM datapath. +/// +/// The native sandbox evidence proves that the workload is confined and that +/// connection requests have authoritative process attribution. The `BlueField` +/// measurements prove that an attached VF remains default-deny until the +/// supervisor authorizes a generation-scoped hardware flow, and that the flow +/// can be revoked without falling back to unrestricted guest networking. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[allow( + clippy::struct_excessive_bools, + reason = "audit evidence preserves independently measured DPU safety results" +)] +pub struct BluefieldVmAccelerationEvidence { + pub native_sandbox: OpenShellSandboxAuditEvidence, + pub vf_pci_address: String, + pub representor: String, + pub dpu_id: String, + pub assignment_generation: String, + pub policy_generation: u64, + pub default_deny: bool, + pub authorization_round_trip: bool, + pub revocation_round_trip: bool, + pub controller_loss_fails_closed: bool, +} + +impl BluefieldVmAccelerationEvidence { + /// Validate the software boundary and hardware acceleration proof against + /// the exact driver-owned outer fence bound to this launch. + pub fn validate_against(&self, fence: &DriverFenceEvidence) -> Result<(), BackendError> { + self.native_sandbox.validate()?; + let DriverFenceEvidence::BluefieldVm { + vf_pci_address, + representor, + dpu_id, + assignment_generation, + policy_generation, + default_deny, + attached_vf_count, + unmanaged_network_device_count, + .. + } = fence + else { + return Err(BackendError::Confirm( + "BlueField acceleration evidence requires a BlueField VM driver fence".to_string(), + )); + }; + fence.validate()?; + let complete = self.vf_pci_address == *vf_pci_address + && self.representor == *representor + && self.dpu_id == *dpu_id + && self.assignment_generation == *assignment_generation + && self.policy_generation == *policy_generation + && self.default_deny + && *default_deny + && *attached_vf_count == 1 + && *unmanaged_network_device_count == 0 + && self.authorization_round_trip + && self.revocation_round_trip + && self.controller_loss_fails_closed; + if complete { + Ok(()) + } else { + Err(BackendError::Confirm( + "BlueField VM acceleration evidence is incomplete or mismatched".to_string(), + )) + } + } + + /// Project the composite Linux and DPU measurements into the common + /// backend-neutral confirmation contract introduced by PR 3366. + #[must_use] + pub fn properties(&self) -> BoundaryProperties { + let native = self.native_sandbox.properties(); + BoundaryProperties { + filesystem_confinement: native.filesystem_confinement, + egress_interception: EnforcedProperty::new( + native.egress_interception.enforced + && self.default_deny + && self.authorization_round_trip + && self.revocation_round_trip + && self.controller_loss_fails_closed, + "seccomp-notify+bluefield-dpu-flow-steering", + ), + request_attribution: EnforcedProperty::new( + native.request_attribution.enforced && self.authorization_round_trip, + "seccomp-notify-procfs+bluefield-flow-binding", + ), + privilege_floor: native.privilege_floor, + } + } +} + +/// Mechanism-specific evidence produced by the gVisor runtime adapter. +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GvisorSandboxAuditEvidence { + pub sentry_detected: bool, + pub capabilities: CapabilityEvidence, + pub sandbox_dumpable: bool, + pub core_limit_zero: bool, + pub workload_launcher_healthy: bool, + pub explicit_proxy_healthy: bool, + pub outer_egress_isolated: bool, + pub outer_egress_rule_count: u32, +} + +impl GvisorSandboxAuditEvidence { + pub fn validate(&self) -> Result<(), BackendError> { + let complete = self.sentry_detected + && self.capabilities.is_empty() + && !self.sandbox_dumpable + && self.core_limit_zero + && self.workload_launcher_healthy + && self.explicit_proxy_healthy + && self.outer_egress_isolated + && self.outer_egress_rule_count == 0; + if complete { + Ok(()) + } else { + Err(BackendError::Confirm( + "gVisor sandbox audit evidence is incomplete".to_string(), + )) + } + } + + #[must_use] + pub fn properties(&self) -> BoundaryProperties { + BoundaryProperties { + filesystem_confinement: EnforcedProperty::new( + self.sentry_detected, + "gvisor-sentry-oci-mounts", + ), + egress_interception: EnforcedProperty::new( + self.outer_egress_isolated + && self.outer_egress_rule_count == 0 + && self.explicit_proxy_healthy, + "kubernetes-network-policy+authenticated-proxy-tunnel", + ), + request_attribution: EnforcedProperty::new( + self.explicit_proxy_healthy, + "authenticated-boundary-session+endpoint-policy", + ), + privilege_floor: EnforcedProperty::new( + self.sentry_detected && self.capabilities.is_empty(), + "gvisor-sentry+capability-free-container", + ), + } + } +} + +/// Tagged adapter evidence validated by the `OpenShell` runtime backend. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "adapter", content = "evidence", rename_all = "kebab-case")] +pub enum OpenShellSandboxAdapterAudit { + NativeLinux(OpenShellSandboxAuditEvidence), + Gvisor(GvisorSandboxAuditEvidence), +} + +impl OpenShellSandboxAdapterAudit { + pub fn validate_for(&self, adapter: SandboxRuntimeAdapter) -> Result<(), BackendError> { + match (adapter, self) { + (SandboxRuntimeAdapter::NativeLinux, Self::NativeLinux(evidence)) => { + evidence.validate() + } + (SandboxRuntimeAdapter::Gvisor, Self::Gvisor(evidence)) => evidence.validate(), + _ => Err(BackendError::Confirm( + "sandbox audit evidence does not match the selected runtime adapter".to_string(), + )), + } + } + + #[must_use] + pub fn properties(&self) -> BoundaryProperties { + match self { + Self::NativeLinux(evidence) => evidence.properties(), + Self::Gvisor(evidence) => evidence.properties(), + } + } +} + /// Ephemeral identity of the supervisor process that owns one sandbox runtime. /// /// The supervisor generates this value in memory and presents it on every @@ -274,6 +608,9 @@ pub struct SandboxRuntimeDescriptor { pub session_id: SandboxSessionId, /// Immutable numeric identity already applied to the sandbox workload. pub workload_identity: openshell_isolation_interface::contract::ResolvedWorkloadIdentity, + /// Mechanism adapter used inside the workload boundary. + #[serde(default)] + pub adapter: SandboxRuntimeAdapter, /// Driver-provisioned byte-stream endpoint. pub transport: SandboxTransport, /// Per-generation pinned TLS server identity. @@ -297,6 +634,7 @@ impl fmt::Debug for SandboxRuntimeDescriptor { .field("boundary_id", &self.boundary_id) .field("generation", &self.generation) .field("session_id", &self.session_id) + .field("adapter", &self.adapter) .field("transport", &self.transport) .field("tls", &self.tls) .field("host_gateway_ip", &self.host_gateway_ip) @@ -354,6 +692,9 @@ 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, + /// Mechanism adapter selected by the trusted compute driver. + #[serde(default)] + pub adapter: SandboxRuntimeAdapter, /// Concrete outer-fence evidence validated by the driver. pub driver_fence: DriverFenceEvidence, /// Driver-resolved environment exposed only to workload processes. @@ -383,6 +724,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("adapter", &self.adapter) .field("driver_fence", &self.driver_fence) .field("child_env_keys", &self.child_env.keys().collect::>()) .finish() @@ -576,6 +918,8 @@ pub enum Request { /// plane. OpenMediation, AcceptNetwork, + /// Upgrade one authenticated stream into a raw explicit-proxy tunnel. + AcceptExplicitProxy, } impl Request { @@ -687,6 +1031,7 @@ impl fmt::Debug for Request { .finish(), Self::OpenMediation => formatter.write_str("OpenMediation"), Self::AcceptNetwork => formatter.write_str("AcceptNetwork"), + Self::AcceptExplicitProxy => formatter.write_str("AcceptExplicitProxy"), } } } @@ -704,8 +1049,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, @@ -737,6 +1083,7 @@ pub enum Response { policy_generation: u64, timing: MediationTimingWire, }, + ExplicitProxyConnected, Error { kind: BoundaryErrorKind, message: String, @@ -1184,6 +1531,154 @@ 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); + } + + fn bluefield_fence() -> DriverFenceEvidence { + DriverFenceEvidence::BluefieldVm { + generation: "generation-1".to_string(), + vf_pci_address: "0000:03:00.2".to_string(), + representor: "pf0vf0".to_string(), + dpu_id: "dpu-1".to_string(), + assignment_generation: "assignment-1".to_string(), + policy_generation: 7, + default_deny: true, + attached_vf_count: 1, + unmanaged_network_device_count: 0, + } + } + + fn bluefield_acceleration_evidence() -> BluefieldVmAccelerationEvidence { + BluefieldVmAccelerationEvidence { + native_sandbox: complete_audit_evidence(), + vf_pci_address: "0000:03:00.2".to_string(), + representor: "pf0vf0".to_string(), + dpu_id: "dpu-1".to_string(), + assignment_generation: "assignment-1".to_string(), + policy_generation: 7, + default_deny: true, + authorization_round_trip: true, + revocation_round_trip: true, + controller_loss_fails_closed: true, + } + } + + #[test] + fn bluefield_acceleration_requires_matching_fail_closed_hardware_evidence() { + let evidence = bluefield_acceleration_evidence(); + evidence.validate_against(&bluefield_fence()).unwrap(); + + let properties = evidence.properties(); + assert!(properties.egress_interception.enforced); + assert_eq!( + properties.egress_interception.mechanism, + "seccomp-notify+bluefield-dpu-flow-steering" + ); + assert!(properties.request_attribution.enforced); + + let mut unavailable = evidence.clone(); + unavailable.controller_loss_fails_closed = false; + assert!(unavailable.validate_against(&bluefield_fence()).is_err()); + assert!(!unavailable.properties().egress_interception.enforced); + + let mut mismatched = evidence; + mismatched.policy_generation += 1; + assert!(mismatched.validate_against(&bluefield_fence()).is_err()); + assert!( + mismatched + .validate_against(&DriverFenceEvidence::Vm { + generation: "generation-1".to_string(), + network_device_count: 0, + }) + .is_err() + ); + } + + #[test] + fn gvisor_audit_requires_sentry_proxy_and_zero_rule_outer_fence() { + let mut evidence = GvisorSandboxAuditEvidence { + sentry_detected: true, + capabilities: CapabilityEvidence { + inheritable: 0, + permitted: 0, + effective: 0, + bounding: 0, + ambient: 0, + }, + sandbox_dumpable: false, + core_limit_zero: true, + workload_launcher_healthy: true, + explicit_proxy_healthy: true, + outer_egress_isolated: true, + outer_egress_rule_count: 0, + }; + evidence.validate().unwrap(); + let audit = OpenShellSandboxAdapterAudit::Gvisor(evidence.clone()); + audit.validate_for(SandboxRuntimeAdapter::Gvisor).unwrap(); + assert!(audit.properties().egress_interception.enforced); + assert!( + audit + .validate_for(SandboxRuntimeAdapter::NativeLinux) + .is_err() + ); + + evidence.outer_egress_rule_count = 1; + assert!(evidence.validate().is_err()); + assert!(!evidence.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 86964fc3c8..cbbcfafbc2 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -24,9 +24,9 @@ use openshell_isolation_interface::contract::{ BackendError, BoundBoundary, BoundaryDuplexStream, BoundaryExec, BoundaryExitStatus, BoundaryInput, BoundaryLoopbackConnector, BoundaryOutput, BoundaryProcess, BoundarySignal, BoundaryTerminal, ConfirmedBoundary, ExecSession, ExecSpec, IsolationBackend, LoopbackTarget, - MediationTiming, NetworkMediationSource, PendingDnsQuery, PendingTcpOpen, ProcessAttachment, - ReadyBoundary, RunningBoundary, SandboxContext, TcpOpenDecision, TcpOpenDenial, - VerifiedBackendDescriptor, + MediationTiming, NetworkMediationMode, NetworkMediationSource, PendingDnsQuery, PendingTcpOpen, + ProcessAttachment, ReadyBoundary, RunningBoundary, SandboxContext, TcpOpenDecision, + TcpOpenDenial, VerifiedBackendDescriptor, }; use tokio::io::{AsyncReadExt, AsyncWriteExt}; #[cfg(unix)] @@ -102,6 +102,7 @@ impl IsolationBackend for OpenShellRuntimeBackend { let generation = runtime_descriptor.generation.clone(); let session_id = runtime_descriptor.session_id; let driver_fence = runtime_descriptor.driver_fence.clone(); + let adapter = runtime_descriptor.adapter; let client = Arc::new(BoundaryClient::new( runtime_descriptor, self.sandbox_bearer.clone(), @@ -126,7 +127,7 @@ impl IsolationBackend for OpenShellRuntimeBackend { agent: sandbox.agent, policy: sandbox.policy, sandbox_id: sandbox.sandbox_id, - mediation: Arc::new(RemoteNetworkMediation { client }), + mediation: Arc::new(RemoteNetworkMediation { client, adapter }), host_gateway_ip, ca_file_paths: self.ca_file_paths.clone(), provider_credentials: self.provider_credentials.clone(), @@ -135,6 +136,7 @@ impl IsolationBackend for OpenShellRuntimeBackend { session_id, resource_claims, driver_fence, + adapter, })) } } @@ -278,6 +280,7 @@ struct RemoteBound { session_id: openshell_core::SandboxSessionId, resource_claims: std::collections::BTreeMap, driver_fence: openshell_isolation_interface::contract::DriverFenceEvidence, + adapter: crate::boundary_protocol::SandboxRuntimeAdapter, } #[async_trait] @@ -292,19 +295,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::OpenShellSandboxAdapterAudit = + serde_json::from_value(confirmation.backend_audit.clone()).map_err(|error| { + BackendError::Confirm(format!("decode OpenShell sandbox audit evidence: {error}")) + })?; + audit.validate_for(self.adapter)?; + 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 { @@ -315,7 +328,7 @@ impl BoundBoundary for RemoteBound { ca_file_paths: self.ca_file_paths, provider_credentials: self.provider_credentials, }), - *evidence, + *confirmation, &self.identity, ) } @@ -751,10 +764,22 @@ async fn pump_exec_input( /// head-of-line blocking during concurrent TLS handshakes. struct RemoteNetworkMediation { client: Arc, + adapter: crate::boundary_protocol::SandboxRuntimeAdapter, } #[async_trait] impl NetworkMediationSource for RemoteNetworkMediation { + fn mode(&self) -> NetworkMediationMode { + match self.adapter { + crate::boundary_protocol::SandboxRuntimeAdapter::NativeLinux => { + NetworkMediationMode::TransparentTcp + } + crate::boundary_protocol::SandboxRuntimeAdapter::Gvisor => { + NetworkMediationMode::ExplicitProxy + } + } + } + async fn accept_tcp(&self) -> Result { let (stream, response) = self.client.open_exchange(Request::AcceptNetwork).await?; let Response::NetworkConnected { @@ -787,6 +812,17 @@ impl NetworkMediationSource for RemoteNetworkMediation { }) } + async fn accept_explicit_proxy(&self) -> Result { + let (stream, response) = self + .client + .open_exchange(Request::AcceptExplicitProxy) + .await?; + if !matches!(response, Response::ExplicitProxyConnected) { + return Err(unexpected_response("explicit_proxy_connected", &response)); + } + Ok(stream) + } + async fn accept_dns(&self) -> Result { loop { let session = self.client.mediation_session().await?; @@ -1759,7 +1795,9 @@ mod tests { use std::task::{Context, Poll}; use super::*; - use crate::boundary_protocol::{ExitStatusWire, generate_sandbox_tls_material}; + use crate::boundary_protocol::{ + ExitStatusWire, SandboxRuntimeAdapter, generate_sandbox_tls_material, + }; use crate::proto::{ BoundaryChunk, isolation_boundary_server::{IsolationBoundary, IsolationBoundaryServer}, @@ -1863,7 +1901,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 { @@ -1901,6 +1939,10 @@ mod tests { kind: crate::boundary_protocol::BoundaryErrorKind::Unavailable, message: "no pending network request".to_string(), }, + Request::AcceptExplicitProxy => Response::Error { + kind: crate::boundary_protocol::BoundaryErrorKind::Unavailable, + message: "no pending explicit proxy stream".to_string(), + }, }, }) { Ok(response) => response, @@ -2267,6 +2309,7 @@ mod tests { generation: "test-generation".to_string(), session_id: test_session_id(), workload_identity: sandbox().identity, + adapter: SandboxRuntimeAdapter::default(), transport: SandboxTransport::Tcp { authority: "sandbox.test".to_string(), addresses: vec![address], @@ -2358,12 +2401,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 native_audit = crate::boundary_protocol::OpenShellSandboxAuditEvidence { + capabilities: crate::boundary_protocol::CapabilityEvidence { inheritable: 0, permitted: 0, effective: 0, @@ -2376,7 +2416,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, @@ -2393,11 +2433,19 @@ mod tests { tcp_dns_round_trip: true, tcp_allow_round_trip: true, tcp_deny_round_trip: true, + }; + let audit = + crate::boundary_protocol::OpenShellSandboxAdapterAudit::NativeLinux(native_audit); + 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"), } } @@ -2409,6 +2457,7 @@ mod tests { generation: "test-generation".to_string(), session_id: test_session_id(), workload_identity: sandbox().identity, + adapter: SandboxRuntimeAdapter::default(), transport: SandboxTransport::Unix { socket_path: PathBuf::from("/tmp/vsock.sock"), }, @@ -2429,6 +2478,7 @@ mod tests { generation: "test-generation".to_string(), session_id: test_session_id(), workload_identity: sandbox().identity, + adapter: SandboxRuntimeAdapter::default(), transport: SandboxTransport::Unix { socket_path: PathBuf::from("/tmp/vsock.sock"), }, @@ -2450,6 +2500,7 @@ mod tests { generation: "test-generation".to_string(), session_id: test_session_id(), workload_identity: sandbox().identity, + adapter: SandboxRuntimeAdapter::default(), transport: SandboxTransport::Tcp { authority: "sandbox.test".to_string(), addresses: vec!["0.0.0.0:5500".parse().expect("valid address")], @@ -2472,6 +2523,7 @@ mod tests { generation: "test-generation".to_string(), session_id: test_session_id(), workload_identity: sandbox().identity, + adapter: SandboxRuntimeAdapter::default(), transport: SandboxTransport::Tcp { authority: "sandbox.test".to_string(), addresses: vec!["10.42.0.7:5500".parse().expect("valid address")], @@ -2515,7 +2567,7 @@ mod tests { .await .expect("TLS request"), Response::Confirmed { - evidence: Box::new(test_confirmation_evidence()), + confirmation: Box::new(test_confirmation()), } ); server.abort(); @@ -2665,6 +2717,7 @@ mod tests { generation: "test-generation".to_string(), session_id: test_session_id(), workload_identity: sandbox().identity, + adapter: SandboxRuntimeAdapter::default(), transport: SandboxTransport::Tcp { authority: "sandbox.test".to_string(), addresses: vec![address], @@ -2753,6 +2806,7 @@ mod tests { generation: "test-generation".to_string(), session_id: test_session_id(), workload_identity: context.identity.clone(), + adapter: SandboxRuntimeAdapter::default(), transport: SandboxTransport::Unix { socket_path: socket_path.clone(), }, diff --git a/crates/openshell-sandbox/src/boundary_exec.rs b/crates/openshell-sandbox/src/boundary_exec.rs index 57d9778a6f..63d54c6216 100644 --- a/crates/openshell-sandbox/src/boundary_exec.rs +++ b/crates/openshell-sandbox/src/boundary_exec.rs @@ -92,7 +92,9 @@ impl LocalBoundaryExec { command.env(key, value); } } - crate::process::strip_proxy_env_std(&mut command); + if self.launcher.uses_native_linux_isolation() { + crate::process::strip_proxy_env_std(&mut command); + } for (key, value) in &spec.env { if !key.starts_with("OPENSHELL_") { command.env(key, value); @@ -109,6 +111,9 @@ impl LocalBoundaryExec { &self, workdir: Option<&str>, ) -> Result, BackendError> { + if !self.launcher.uses_native_linux_isolation() { + return Ok(None); + } crate::sandbox::linux::log_sandbox_readiness(&self.policy, workdir); let runtime_read_only = crate::process::ca_runtime_read_only_paths(self.ca_file_paths.as_deref()); @@ -127,9 +132,14 @@ impl LocalBoundaryExec { #[cfg(target_os = "linux")] let prepared = self.prepare_sandbox(effective_workdir)?; #[cfg(target_os = "linux")] - let child_hardening = - openshell_isolation_interface::linux::child_seccomp::prepare(std::process::id()) - .map_err(|error| BackendError::Process(error.to_string()))?; + let child_hardening = self + .launcher + .uses_native_linux_isolation() + .then(|| { + openshell_isolation_interface::linux::child_seccomp::prepare(std::process::id()) + }) + .transpose() + .map_err(|error| BackendError::Process(error.to_string()))?; crate::pty::install_dedicated_process_group(&mut command); crate::pty::install_pre_exec_no_pty( &mut command, @@ -238,9 +248,14 @@ impl LocalBoundaryExec { #[cfg(target_os = "linux")] let prepared = self.prepare_sandbox(effective_workdir)?; #[cfg(target_os = "linux")] - let child_hardening = - openshell_isolation_interface::linux::child_seccomp::prepare(std::process::id()) - .map_err(|error| BackendError::Process(error.to_string()))?; + let child_hardening = self + .launcher + .uses_native_linux_isolation() + .then(|| { + openshell_isolation_interface::linux::child_seccomp::prepare(std::process::id()) + }) + .transpose() + .map_err(|error| BackendError::Process(error.to_string()))?; crate::pty::install_pre_exec( &mut command, self.policy.clone(), diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index 3326570d96..6b8990f5bd 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,13 @@ 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, GvisorSandboxAuditEvidence, MediationTimingWire, + OpenShellSandboxAdapterAudit, OpenShellSandboxAuditEvidence, OutputWindowWire, + ProcessKindWire, ProcessSnapshotWire, Request, RequestEnvelope, Response, ResponseEnvelope, + STREAM_EXIT, STREAM_NETWORK_DECISION, STREAM_STDERR, STREAM_STDIN, STREAM_STDIN_CLOSED, + STREAM_STDOUT, SandboxPolicyWire, SandboxRuntimeAdapter, 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); @@ -195,6 +196,9 @@ mod linux { let config: BoundaryConfig = serde_json::from_slice(&bytes).map_err(|error| { format!("decode boundary config {}: {error}", config_path.display()) })?; + if config.adapter != qualification.adapter { + return Err("runtime qualification does not match boundary adapter".to_string()); + } validate_config(&config)?; validate_runtime_resource_claims(&config)?; validate_running_identity( @@ -212,20 +216,38 @@ mod linux { unsafe { std::env::set_var(openshell_core::sandbox_env::USER_ENVIRONMENT, child_env); } - crate::sandbox::apply_supervisor_startup_hardening() - .map_err(|error| format!("install sandbox process prelude: {error}"))?; + if matches!(config.adapter, SandboxRuntimeAdapter::NativeLinux) { + crate::sandbox::apply_supervisor_startup_hardening() + .map_err(|error| format!("install sandbox process prelude: {error}"))?; + } if nix::unistd::getpid().as_raw() == 1 { crate::managed_children::start_orphan_reaper() .map_err(|error| format!("start sandbox orphan reaper: {error}"))?; } - let (launcher, listener) = openshell_isolation_interface::linux::workload_launcher::start() - .map_err(|error| format!("start sandbox workload launcher: {error}"))?; - let protected_control_port = match &config.listener { - BoundaryListenerConfig::TlsTcp { address, .. } => Some(address.port()), - BoundaryListenerConfig::Unix { .. } | BoundaryListenerConfig::Vsock { .. } => None, + let (launcher, network_broker) = match config.adapter { + SandboxRuntimeAdapter::NativeLinux => { + let (launcher, listener) = + openshell_isolation_interface::linux::workload_launcher::start() + .map_err(|error| format!("start sandbox workload launcher: {error}"))?; + let protected_control_port = match &config.listener { + BoundaryListenerConfig::TlsTcp { address, .. } => Some(address.port()), + BoundaryListenerConfig::Unix { .. } | BoundaryListenerConfig::Vsock { .. } => { + None + } + }; + let broker = NetworkBroker::start(listener, protected_control_port) + .map_err(|error| format!("start sandbox network broker: {error}"))?; + (launcher, broker) + } + SandboxRuntimeAdapter::Gvisor => { + let launcher = + openshell_isolation_interface::linux::workload_launcher::start_unfiltered() + .map_err(|error| format!("start gVisor workload launcher: {error}"))?; + let broker = NetworkBroker::start_explicit_proxy() + .map_err(|error| format!("start gVisor explicit proxy: {error}"))?; + (launcher, broker) + } }; - let network_broker = NetworkBroker::start(listener, protected_control_port) - .map_err(|error| format!("start sandbox network broker: {error}"))?; let process_runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() @@ -1230,6 +1252,38 @@ mod linux { })?; return Ok(()); } + Request::AcceptExplicitProxy => { + let broker = runtime.network_accept_context()?; + if !broker.is_explicit_proxy() { + return Err("explicit proxy requested from native network adapter".to_string()); + } + let request_id = request.request_id; + runtime.process_runtime.block_on(async move { + let target = broker.accept_explicit_proxy().await.map_err(|error| { + format!("accept sandbox explicit proxy stream: {error}") + })?; + target.set_nonblocking(true).map_err(|error| { + format!("set explicit proxy stream nonblocking: {error}") + })?; + let mut target = tokio::net::TcpStream::from_std(target) + .map_err(|error| format!("register explicit proxy stream: {error}"))?; + let mut stream = stream.into_tokio()?; + let response = encode_frame(&ResponseEnvelope { + request_id, + response: Response::ExplicitProxyConnected, + }) + .map_err(|error| format!("encode explicit proxy response: {error}"))?; + stream + .write_all(&response) + .await + .map_err(|error| format!("write explicit proxy response: {error}"))?; + tokio::io::copy_bidirectional(&mut stream, &mut target) + .await + .map(|_| ()) + .map_err(|error| format!("bridge explicit proxy stream: {error}")) + })?; + return Ok(()); + } _ => {} } let supervisor_instance_id = match &request.request { @@ -1884,7 +1938,8 @@ mod linux { | Request::TerminateBoundary | Request::AttachProcess { .. } | Request::LoopbackConnect { .. } - | Request::AcceptNetwork => guest_error( + | Request::AcceptNetwork + | Request::AcceptExplicitProxy => guest_error( BoundaryErrorKind::Invalid, "streaming request used on control path", ), @@ -2220,20 +2275,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), }, ) } @@ -2244,7 +2299,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), @@ -2257,7 +2312,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")?, @@ -2277,29 +2332,64 @@ 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 { + let audit = match self.config.adapter { + SandboxRuntimeAdapter::NativeLinux => { + let (native_architecture, kernel_release) = uname_values()?; + OpenShellSandboxAdapterAudit::NativeLinux(OpenShellSandboxAuditEvidence { + capabilities, + no_new_privileges, + sandbox_dumpable, + child_dumpable: true, + core_limit_zero: core_limit.rlim_cur == 0 && core_limit.rlim_max == 0, + native_architecture, + kernel_release, + seccomp: self.qualification.seccomp, + landlock_abi: self.qualification.landlock_abi, + landlock_allow_deny: self.qualification.landlock_allow_deny, + udp_dns_round_trip: self.qualification.udp_dns_round_trip, + 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, + }) + } + SandboxRuntimeAdapter::Gvisor => { + let (outer_egress_isolated, outer_egress_rule_count) = match &self.config.driver_fence { + openshell_isolation_interface::contract::DriverFenceEvidence::Kubernetes { + egress_isolated, + egress_rule_count, + .. + } => (*egress_isolated, *egress_rule_count), + _ => (false, u32::MAX), + }; + OpenShellSandboxAdapterAudit::Gvisor(GvisorSandboxAuditEvidence { + sentry_detected: self.qualification.gvisor_sentry_detected, + capabilities, + sandbox_dumpable, + core_limit_zero: core_limit.rlim_cur == 0 && core_limit.rlim_max == 0, + workload_launcher_healthy: self.workload_launcher.is_alive(), + explicit_proxy_healthy: self.network_broker.is_explicit_proxy(), + outer_egress_isolated, + outer_egress_rule_count, + }) + } + }; + // 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}"))?; + Ok(BoundaryConfirmation { generation: self.config.generation.clone(), identity: self.config.workload_identity.clone(), - capabilities, - no_new_privileges, - sandbox_dumpable, - child_dumpable: true, - core_limit_zero: core_limit.rlim_cur == 0 && core_limit.rlim_max == 0, - native_architecture, - kernel_release, - seccomp: self.qualification.seccomp, - landlock_abi: self.qualification.landlock_abi, - landlock_allow_deny: self.qualification.landlock_allow_deny, - udp_dns_round_trip: self.qualification.udp_dns_round_trip, - 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, + 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, }) } @@ -3639,6 +3729,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), + adapter: SandboxRuntimeAdapter::default(), driver_fence: test_driver_fence(), child_env: std::collections::HashMap::new(), }; @@ -3790,6 +3881,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), + adapter: SandboxRuntimeAdapter::default(), driver_fence: test_driver_fence(), child_env: std::collections::HashMap::new(), }, @@ -4201,7 +4293,9 @@ mod linux { fn test_runtime_qualification() -> crate::RuntimeQualification { crate::RuntimeQualification { - seccomp: openshell_isolation_interface::contract::SeccompEvidence { + adapter: SandboxRuntimeAdapter::default(), + gvisor_sentry_detected: false, + seccomp: openshell_sandbox_backend::boundary_protocol::SeccompEvidence { new_listener: true, notification_round_trip: true, id_validation: true, @@ -4304,6 +4398,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), + adapter: SandboxRuntimeAdapter::default(), driver_fence: test_driver_fence(), child_env: std::collections::HashMap::new(), }; @@ -4339,6 +4434,7 @@ mod linux { pod_uid_path, )]), workload_identity: test_workload_identity(), + adapter: SandboxRuntimeAdapter::default(), driver_fence: test_driver_fence(), child_env: std::collections::HashMap::new(), }; @@ -4379,6 +4475,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), + adapter: SandboxRuntimeAdapter::default(), driver_fence: test_driver_fence(), child_env: std::collections::HashMap::new(), }, @@ -4554,6 +4651,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), + adapter: SandboxRuntimeAdapter::default(), driver_fence: test_driver_fence(), child_env: std::collections::HashMap::new(), }, @@ -4583,7 +4681,11 @@ mod linux { boundary.attach(policy.clone()), Response::Attached { .. } )); - assert!(matches!(boundary.confirm(), Response::Confirmed { .. })); + let confirmation = boundary.confirm(); + assert!( + matches!(confirmation, Response::Confirmed { .. }), + "unexpected confirmation response: {confirmation:?}" + ); let start = || { boundary.start_agent( "sandbox-reconnect".to_string(), @@ -4656,7 +4758,11 @@ mod linux { boundary.attach(policy.clone()), Response::Attached { .. } )); - assert!(matches!(boundary.confirm(), Response::Confirmed { .. })); + let confirmation = boundary.confirm(); + assert!( + matches!(confirmation, Response::Confirmed { .. }), + "unexpected confirmation response: {confirmation:?}" + ); assert_eq!( start(), Response::Started { @@ -4827,6 +4933,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), + adapter: SandboxRuntimeAdapter::default(), driver_fence: test_driver_fence(), child_env: std::collections::HashMap::new(), }, diff --git a/crates/openshell-sandbox/src/delegated.rs b/crates/openshell-sandbox/src/delegated.rs index e294f81ac0..487a65eac6 100644 --- a/crates/openshell-sandbox/src/delegated.rs +++ b/crates/openshell-sandbox/src/delegated.rs @@ -74,7 +74,9 @@ pub async fn spawn_workload( .ok() .and_then(|json| serde_json::from_str(&json).ok()) .unwrap_or_default(); - user_environment.retain(|key, _value| !crate::process::is_proxy_env_var(key)); + if launcher.uses_native_linux_isolation() { + user_environment.retain(|key, _value| !crate::process::is_proxy_env_var(key)); + } let loopback_connector: Arc = Arc::new( crate::boundary_io::LocalLoopbackConnector::new(Some(boundary_runtime.clone())), ); diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 4928fe6f40..aa4c7c616a 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -34,7 +34,9 @@ pub mod sandbox; reason = "qualification preserves independently exercised security results" )] pub struct RuntimeQualification { - pub seccomp: openshell_isolation_interface::contract::SeccompEvidence, + pub adapter: openshell_sandbox_backend::boundary_protocol::SandboxRuntimeAdapter, + pub gvisor_sentry_detected: bool, + 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..6e55ceeee6 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -8,7 +8,7 @@ use std::mem::size_of; use std::path::Path; use clap::Parser; -use miette::{IntoDiagnostic, Result}; +use miette::{IntoDiagnostic, Result, WrapErr as _}; #[cfg(target_os = "linux")] use openshell_ocsf::OcsfShorthandLayer; #[cfg(target_os = "linux")] @@ -239,7 +239,9 @@ 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 { + adapter: openshell_sandbox_backend::boundary_protocol::SandboxRuntimeAdapter::NativeLinux, + gvisor_sentry_detected: false, + 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(), @@ -1847,7 +1849,50 @@ fn run_boundary(bootstrap: &Path, log_level: &str) -> Result<()> { .with_filter(console_filter), ) .try_init(); - let (qualification, _) = qualify_runtime()?; + let config_bytes = std::fs::read(bootstrap) + .into_diagnostic() + .wrap_err_with(|| format!("read boundary config {}", bootstrap.display()))?; + let config: openshell_sandbox_backend::boundary_protocol::BoundaryConfig = + serde_json::from_slice(&config_bytes) + .into_diagnostic() + .wrap_err("decode boundary config for runtime adapter selection")?; + let qualification = match config.adapter { + openshell_sandbox_backend::boundary_protocol::SandboxRuntimeAdapter::NativeLinux => { + qualify_runtime()?.0 + } + openshell_sandbox_backend::boundary_protocol::SandboxRuntimeAdapter::Gvisor => { + let version = std::fs::read_to_string("/proc/version") + .into_diagnostic() + .wrap_err("read /proc/version for gVisor qualification")?; + let sentry_detected = version.to_ascii_lowercase().contains("gvisor"); + if !sentry_detected { + return Err(miette::miette!( + "gVisor runtime adapter selected but the gVisor sentry was not detected" + )); + } + openshell_sandbox::RuntimeQualification { + adapter: config.adapter, + gvisor_sentry_detected: true, + seccomp: openshell_sandbox_backend::boundary_protocol::SeccompEvidence { + new_listener: false, + notification_round_trip: false, + id_validation: false, + addfd_send: false, + retained_socket_operation: false, + proc_fd_identity: false, + task_memory_read: false, + task_memory_write: false, + cancellation: false, + }, + landlock_abi: 0, + landlock_allow_deny: false, + udp_dns_round_trip: false, + tcp_dns_round_trip: false, + tcp_allow_round_trip: false, + tcp_deny_round_trip: false, + } + } + }; openshell_sandbox::run(bootstrap, qualification) } diff --git a/crates/openshell-sandbox/src/network_broker.rs b/crates/openshell-sandbox/src/network_broker.rs index 6b01cc0967..b8a93bc4af 100644 --- a/crates/openshell-sandbox/src/network_broker.rs +++ b/crates/openshell-sandbox/src/network_broker.rs @@ -198,10 +198,12 @@ struct NotificationQueues { /// Live broker handle retained by the sandbox boundary. #[derive(Clone)] pub struct NetworkBroker { - _accept_monitor: Arc, + _accept_monitor: Option>, pending: Arc>>, pending_dns: Arc>>, + pending_proxy: Option>>>, dns_address: SocketAddr, + proxy_address: Option, healthy: Arc, } @@ -298,14 +300,68 @@ impl NetworkBroker { }) .map_err(|error| io::Error::other(format!("start network broker: {error}")))?; Ok(Self { - _accept_monitor: accept_monitor, + _accept_monitor: Some(accept_monitor), pending: Arc::new(tokio::sync::Mutex::new(pending_rx)), pending_dns: Arc::new(tokio::sync::Mutex::new(pending_dns_rx)), + pending_proxy: None, dns_address, + proxy_address: None, healthy, }) } + /// Start the workload-local HTTP/CONNECT listener used by the gVisor + /// adapter. Accepted byte streams are reverse-tunnelled to the existing + /// supervisor proxy over authenticated Sandbox Protocol connections. + pub(crate) fn start_explicit_proxy() -> io::Result { + Self::start_explicit_proxy_at(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128)) + } + + fn start_explicit_proxy_at(address: SocketAddr) -> io::Result { + let listener = TcpListener::bind(address)?; + let proxy_address = listener.local_addr()?; + let (_pending_tx, pending_rx) = mpsc::channel(1); + let (_dns_tx, pending_dns_rx) = mpsc::channel(1); + let (proxy_tx, proxy_rx) = mpsc::channel(OPEN_QUEUE_CAPACITY); + let healthy = Arc::new(AtomicBool::new(true)); + let broker_healthy = healthy.clone(); + std::thread::Builder::new() + .name("openshell-explicit-proxy".to_string()) + .spawn(move || { + for accepted in listener.incoming() { + match accepted { + Ok(stream) => { + let _ = stream.set_nodelay(true); + if proxy_tx.blocking_send(stream).is_err() { + break; + } + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(error) => { + tracing::error!(%error, "sandbox explicit proxy listener failed"); + break; + } + } + } + broker_healthy.store(false, Ordering::Release); + }) + .map_err(|error| io::Error::other(format!("start explicit proxy listener: {error}")))?; + Ok(Self { + _accept_monitor: None, + pending: Arc::new(tokio::sync::Mutex::new(pending_rx)), + pending_dns: Arc::new(tokio::sync::Mutex::new(pending_dns_rx)), + pending_proxy: Some(Arc::new(tokio::sync::Mutex::new(proxy_rx))), + dns_address: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0), + proxy_address: Some(proxy_address), + healthy, + }) + } + + #[cfg(test)] + fn start_explicit_proxy_for_test() -> io::Result { + Self::start_explicit_proxy_at(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)) + } + pub(crate) async fn accept(&self) -> io::Result { self.pending .lock() @@ -324,13 +380,30 @@ impl NetworkBroker { .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "DNS broker queue closed")) } + pub(crate) async fn accept_explicit_proxy(&self) -> io::Result { + let pending = self.pending_proxy.as_ref().ok_or_else(|| { + io::Error::new( + io::ErrorKind::Unsupported, + "network broker is not in explicit-proxy mode", + ) + })?; + pending + .lock() + .await + .recv() + .await + .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "explicit proxy queue closed")) + } + #[cfg(test)] pub(crate) fn dns_address(&self) -> SocketAddr { self.dns_address } pub(crate) fn confirm_healthy(&self) -> io::Result<()> { - if self.healthy.load(Ordering::Acquire) && self.dns_address.port() != 0 { + if self.healthy.load(Ordering::Acquire) + && (self.dns_address.port() != 0 || self.proxy_address.is_some()) + { Ok(()) } else { Err(io::Error::new( @@ -339,6 +412,16 @@ impl NetworkBroker { )) } } + + #[must_use] + pub(crate) const fn is_explicit_proxy(&self) -> bool { + self.proxy_address.is_some() + } + + #[cfg(test)] + fn explicit_proxy_address(&self) -> Option { + self.proxy_address + } } fn start_dns_relay( @@ -1754,6 +1837,30 @@ mod tests { use std::io::{Read as _, Write as _}; use std::os::unix::net::{UnixListener, UnixStream}; + #[tokio::test] + async fn explicit_proxy_accepts_raw_client_streams() { + let broker = + NetworkBroker::start_explicit_proxy_for_test().expect("start explicit proxy listener"); + let address = broker + .explicit_proxy_address() + .expect("explicit proxy address"); + let client = tokio::task::spawn_blocking(move || { + let mut stream = TcpStream::connect(address).expect("connect explicit proxy"); + stream + .write_all(b"CONNECT example.com:443 HTTP/1.1\r\n\r\n") + .expect("write proxy request"); + }); + let mut accepted = broker + .accept_explicit_proxy() + .await + .expect("accept explicit proxy stream"); + let mut request = [0_u8; 44]; + let length = accepted.read(&mut request).expect("read proxy request"); + assert!(request[..length].starts_with(b"CONNECT example.com:443")); + client.await.expect("proxy client task"); + broker.confirm_healthy().expect("healthy explicit proxy"); + } + #[test] fn relay_rejects_descriptor_replaced_after_policy_decision() { let metadata = SocketMetadata { diff --git a/crates/openshell-sandbox/src/process.rs b/crates/openshell-sandbox/src/process.rs index 5e07337214..56403a38f8 100644 --- a/crates/openshell-sandbox/src/process.rs +++ b/crates/openshell-sandbox/src/process.rs @@ -577,7 +577,9 @@ impl ProcessHandle { cmd.current_dir(dir); } - strip_proxy_env(&mut cmd); + if launcher.uses_native_linux_isolation() { + strip_proxy_env(&mut cmd); + } // Set TLS trust store env vars so sandbox processes trust the ephemeral CA if let Some((ca_cert_path, combined_bundle_path)) = ca_paths { @@ -590,20 +592,31 @@ impl ProcessHandle { // process where the tracing subscriber is functional. The child's // pre_exec context cannot reliably emit structured logs. #[cfg(target_os = "linux")] - sandbox::linux::log_sandbox_readiness(policy, workspace.root()); + if launcher.uses_native_linux_isolation() { + sandbox::linux::log_sandbox_readiness(policy, workspace.root()); + } // Prepare the Landlock ruleset as the workload UID. Inaccessible paths // are already unavailable to the child and remain omitted. #[cfg(target_os = "linux")] let runtime_read_only = ca_runtime_read_only_paths(ca_paths); - let prepared_sandbox = prepare_child_sandbox(policy, workspace.root(), &runtime_read_only) - .map_err(|err| miette::miette!("Failed to prepare sandbox: {err}"))?; + let prepared_sandbox = if launcher.uses_native_linux_isolation() { + prepare_child_sandbox(policy, workspace.root(), &runtime_read_only) + .map_err(|err| miette::miette!("Failed to prepare sandbox: {err}"))? + } else { + None + }; #[cfg(target_os = "linux")] - let mut child_hardening = - openshell_isolation_interface::linux::child_seccomp::prepare(std::process::id()) - .map_err(|error| { - miette::miette!("prepare child self-protection filter: {error}") - })?; + let mut child_hardening = if launcher.uses_native_linux_isolation() { + Some( + openshell_isolation_interface::linux::child_seccomp::prepare(std::process::id()) + .map_err(|error| { + miette::miette!("prepare child self-protection filter: {error}") + })?, + ) + } else { + None + }; // Set up process group for signal handling (non-interactive mode only). // In interactive mode, we inherit the parent's process group to maintain // proper terminal control for shells and interactive programs. @@ -626,14 +639,16 @@ impl ProcessHandle { return Err(std::io::Error::last_os_error()); } - harden_child_process().map_err(|err| std::io::Error::other(err.to_string()))?; - // Phase 2 (as unprivileged user): Enforce the prepared // Landlock ruleset via restrict_self() + apply seccomp. // restrict_self() does not require root. #[cfg(target_os = "linux")] - if let Some(prepared) = prepared_sandbox.take() { - sandbox::linux::enforce_capability_free(prepared, &mut child_hardening) + if let (Some(prepared), Some(child_hardening)) = + (prepared_sandbox.take(), child_hardening.as_mut()) + { + harden_child_process() + .map_err(|err| std::io::Error::other(err.to_string()))?; + sandbox::linux::enforce_capability_free(prepared, child_hardening) .map_err(|err| std::io::Error::other(err.to_string()))?; } diff --git a/crates/openshell-sandbox/src/pty.rs b/crates/openshell-sandbox/src/pty.rs index d887342306..0c77a52861 100644 --- a/crates/openshell-sandbox/src/pty.rs +++ b/crates/openshell-sandbox/src/pty.rs @@ -57,8 +57,9 @@ pub fn install_pre_exec( _workdir: Option, slave_fd: RawFd, #[cfg(target_os = "linux")] prepared: Option, - #[cfg(target_os = "linux")] - child_hardening: openshell_isolation_interface::linux::child_seccomp::ChildHardeningProgram, + #[cfg(target_os = "linux")] child_hardening: Option< + openshell_isolation_interface::linux::child_seccomp::ChildHardeningProgram, + >, ) -> anyhow::Result<()> { #[cfg(target_os = "linux")] let mut prepared = prepared; @@ -75,7 +76,7 @@ pub fn install_pre_exec( #[cfg(target_os = "linux")] prepared.take(), #[cfg(target_os = "linux")] - &mut child_hardening, + child_hardening.as_mut(), ) }); } @@ -92,8 +93,9 @@ pub fn install_pre_exec_no_pty( policy: SandboxPolicy, _workdir: Option, #[cfg(target_os = "linux")] prepared: Option, - #[cfg(target_os = "linux")] - child_hardening: openshell_isolation_interface::linux::child_seccomp::ChildHardeningProgram, + #[cfg(target_os = "linux")] child_hardening: Option< + openshell_isolation_interface::linux::child_seccomp::ChildHardeningProgram, + >, ) -> anyhow::Result<()> { #[cfg(target_os = "linux")] let mut prepared = prepared; @@ -111,7 +113,7 @@ pub fn install_pre_exec_no_pty( #[cfg(target_os = "linux")] prepared.take(), #[cfg(target_os = "linux")] - &mut child_hardening, + child_hardening.as_mut(), ) }); } @@ -121,14 +123,14 @@ pub fn install_pre_exec_no_pty( fn enter_sandbox( policy: &SandboxPolicy, #[cfg(target_os = "linux")] prepared: Option, - #[cfg(target_os = "linux")] - child_hardening: &mut openshell_isolation_interface::linux::child_seccomp::ChildHardeningProgram, + #[cfg(target_os = "linux")] child_hardening: Option< + &mut openshell_isolation_interface::linux::child_seccomp::ChildHardeningProgram, + >, ) -> std::io::Result<()> { - crate::process::harden_child_process() - .map_err(|error| std::io::Error::other(error.to_string()))?; - #[cfg(target_os = "linux")] - if let Some(prepared) = prepared { + if let (Some(prepared), Some(child_hardening)) = (prepared, child_hardening) { + crate::process::harden_child_process() + .map_err(|error| std::io::Error::other(error.to_string()))?; crate::sandbox::linux::enforce_capability_free(prepared, child_hardening) .map_err(|error| std::io::Error::other(error.to_string()))?; } diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index dbb8c98fd9..4fb1641dcc 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -32,7 +32,8 @@ use openshell_core::provider_credentials::{ProviderCredentialSnapshot, ProviderC use openshell_core::secrets::{self, SecretResolver, rewrite_header_line_checked}; use openshell_isolation_interface::contract::{ BinaryIdentity as ContractBinaryIdentity, BoundaryDuplexStream, MediationTiming, - NetworkMediationSource, PendingTcpOpen, ResolveError, TcpOpenDecision, TcpOpenDenial, + NetworkMediationMode, NetworkMediationSource, PendingTcpOpen, ResolveError, TcpOpenDecision, + TcpOpenDenial, }; use openshell_ocsf::{ ActionId, ActivityId, DispositionId, Endpoint, HttpActivityBuilder, HttpRequest, HttpResponse, @@ -348,13 +349,28 @@ impl ProxyHandle { } } - let mut network_accepts = network_mediation_source.as_ref().map(|source| { - let accepts = FuturesUnordered::new(); - for _ in 0..MEDIATION_ACCEPT_WINDOW { - let source = source.clone(); - accepts.push(async move { source.accept_tcp().await }.boxed()); - } - accepts + let mediation_mode = network_mediation_source + .as_ref() + .map(|source| source.mode()); + let mut network_accepts = network_mediation_source.as_ref().and_then(|source| { + (source.mode() == NetworkMediationMode::TransparentTcp).then(|| { + let accepts = FuturesUnordered::new(); + for _ in 0..MEDIATION_ACCEPT_WINDOW { + let source = source.clone(); + accepts.push(async move { source.accept_tcp().await }.boxed()); + } + accepts + }) + }); + let mut explicit_accepts = network_mediation_source.as_ref().and_then(|source| { + (source.mode() == NetworkMediationMode::ExplicitProxy).then(|| { + let accepts = FuturesUnordered::new(); + for _ in 0..MEDIATION_ACCEPT_WINDOW { + let source = source.clone(); + accepts.push(async move { source.accept_explicit_proxy().await }.boxed()); + } + accepts + }) }); // Transparent opens require policy evaluation and destination // validation before the sandbox may complete connect(2). Keep @@ -368,40 +384,52 @@ impl ProxyHandle { let mut consecutive_unknown_errors: u32 = 0; loop { let accepted = if let Some(source) = network_mediation_source.as_ref() { - let accepts = network_accepts - .as_mut() - .expect("mediation source has an accept window"); - tokio::select! { - pending = accepts.next() => { - let pending = pending.expect("accept window is never empty"); - let source = source.clone(); - accepts.push(async move { source.accept_tcp().await }.boxed()); - match pending { - Ok(connection) => { - let tx = preauthorized_tx.clone(); - let dns_store = policy_dns_store.clone(); - let opa = opa_engine.clone(); - let backend_gateway = *backend_host_gateway; - let trusted_gateway = *trusted_host_gateway; - tokio::spawn(async move { - if let Some(connection) = preauthorize_transparent_open( - connection, - dns_store.as_ref(), - &opa, - backend_gateway, - trusted_gateway, - ) - .await - { - let _ = tx.send(connection).await; - } - }); - continue; + if mediation_mode == Some(NetworkMediationMode::ExplicitProxy) { + let accepts = explicit_accepts + .as_mut() + .expect("explicit mediation source has an accept window"); + let pending = accepts.next().await.expect("accept window is never empty"); + let source = source.clone(); + accepts.push(async move { source.accept_explicit_proxy().await }.boxed()); + pending + .map(|stream| (stream, None, None, None)) + .map_err(ProxyAcceptError::Source) + } else { + let accepts = network_accepts + .as_mut() + .expect("transparent mediation source has an accept window"); + tokio::select! { + pending = accepts.next() => { + let pending = pending.expect("accept window is never empty"); + let source = source.clone(); + accepts.push(async move { source.accept_tcp().await }.boxed()); + match pending { + Ok(connection) => { + let tx = preauthorized_tx.clone(); + let dns_store = policy_dns_store.clone(); + let opa = opa_engine.clone(); + let backend_gateway = *backend_host_gateway; + let trusted_gateway = *trusted_host_gateway; + tokio::spawn(async move { + if let Some(connection) = preauthorize_transparent_open( + connection, + dns_store.as_ref(), + &opa, + backend_gateway, + trusted_gateway, + ) + .await + { + let _ = tx.send(connection).await; + } + }); + continue; + } + Err(error) => Err(ProxyAcceptError::Source(error)), } - Err(error) => Err(ProxyAcceptError::Source(error)), } + Some(connection) = preauthorized_rx.recv() => Ok(connection), } - Some(connection) = preauthorized_rx.recv() => Ok(connection), } } else { let listener = listener diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index 024185d1ac..a4019bf477 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -38,7 +38,7 @@ use crate::opa::OpaEngine; use crate::policy_local::PolicyLocalContext; use crate::proxy::ProxyHandle; use openshell_core::endpoint_status::EndpointObservationSender; -use openshell_isolation_interface::contract::NetworkMediationSource; +use openshell_isolation_interface::contract::{NetworkMediationMode, NetworkMediationSource}; #[cfg(target_os = "linux")] pub struct TransparentRuntimeSetup { @@ -436,7 +436,10 @@ pub async fn run_networking( (None, None) }; - let mediated_policy_dns = if let Some(source) = network_mediation_source.clone() { + let mediated_policy_dns = if let Some(source) = network_mediation_source + .clone() + .filter(|source| source.mode() == NetworkMediationMode::TransparentTcp) + { let engine = opa_engine .cloned() .ok_or_else(|| miette::miette!("Mediated DNS requires an OPA engine"))?; diff --git a/crates/openshell-supervisor/src/lib.rs b/crates/openshell-supervisor/src/lib.rs index 3b6f6c436d..985cf80900 100644 --- a/crates/openshell-supervisor/src/lib.rs +++ b/crates/openshell-supervisor/src/lib.rs @@ -462,6 +462,7 @@ pub async fn run_network_proxy( } let extension_credentials = openshell_extension_core::ExtensionCredentialStore::new(); + let (mut policy, opa_engine, _, _, _, initial_agent_proposals_enabled, _) = load_policy( None, None, @@ -608,6 +609,20 @@ pub async fn run_sandbox( // and the policy poll loop that rotates them stay the same objects. let extension_credentials = openshell_extension_core::ExtensionCredentialStore::new(); + let selected_runtime_adapter = serde_json::from_slice::< + openshell_sandbox_backend::boundary_protocol::SandboxRuntimeDescriptor, + >(&backend_descriptor.payload) + .map_err(|error| miette::miette!("decode sandbox runtime descriptor: {error}"))? + .adapter; + let local_policy_identity = match selected_runtime_adapter { + openshell_sandbox_backend::boundary_protocol::SandboxRuntimeAdapter::NativeLinux => { + LocalPolicyIdentity::Required + } + openshell_sandbox_backend::boundary_protocol::SandboxRuntimeAdapter::Gvisor => { + LocalPolicyIdentity::EndpointOnly + } + }; + // Load policy and initialize OPA engine let openshell_endpoint_for_proxy = openshell_endpoint.clone(); let sandbox_name_for_agg = sandbox.clone(); @@ -626,7 +641,7 @@ pub async fn run_sandbox( policy_rules, policy_data, &extension_credentials, - LocalPolicyIdentity::Required, + local_policy_identity, ) .await?; @@ -806,8 +821,11 @@ pub async fn run_sandbox( info!(backend = %admitted_backend_name, "Isolation boundary attached"); let remote_boundary = (bound, admitted_backend_name, ca_file_paths); - let transparent_tcp_capable = true; - let transparent_tcp_substrate_ready = true; + let transparent_tcp_capable = matches!( + selected_runtime_adapter, + openshell_sandbox_backend::boundary_protocol::SandboxRuntimeAdapter::NativeLinux + ); + let transparent_tcp_substrate_ready = transparent_tcp_capable; // The denial channel is owned by the orchestrator: the proxy (in the // networking leaf) and the bypass monitor (in the process leaf) both // produce DenialEvents that the denial aggregator (orchestrator-side) diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index 76548f60c7..509bd91a40 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -458,6 +458,31 @@ the agent. One namespace-wide, empty-egress `NetworkPolicy` is the mandatory outer fence for all OpenShell workload Pods. It permits supervisor Pods to reach sandbox listeners; TLS and JWT identity enforce the exact pairing. +Set the effective Kubernetes `runtimeClassName` to `gvisor` to select the +gVisor adapter inside the same OpenShell sandbox backend: + +```shell +openshell sandbox create \ + --driver-config-json '{"kubernetes":{"pod":{"runtime_class_name":"gvisor"}}}' \ + --name gvisor-agent +``` + +The gVisor adapter omits the Pod seccomp profile and custom unprivileged-port +sysctl because GKE Sandbox rejects those settings. It replaces nested Landlock +and seccomp-notify setup with gVisor sentry qualification. The mandatory empty- +egress `NetworkPolicy` remains the direct-egress fence. OpenShell injects +`HTTP_PROXY` and `HTTPS_PROXY` for the workload, accepts proxy traffic on +`127.0.0.1:3128`, and reverse-tunnels it over the authenticated sandbox channel +to the existing supervisor proxy. + +This adapter provides endpoint-only network policy. It does not provide +per-binary network attribution, transparent TCP interception, Landlock path +allowlists, or OpenShell's nested child seccomp filter. Applications must honor +the HTTP proxy environment and use HTTP or CONNECT-compatible transports; +direct DNS and non-proxy TCP remain blocked by the workload `NetworkPolicy`. +The adapter fails confirmation unless it detects gVisor, observes a healthy +proxy tunnel, and verifies the zero-rule Kubernetes egress fence. + The Kubernetes driver creates namespaced `agents.x-k8s.io` `Sandbox` resources from the Kubernetes SIG Apps [agent-sandbox](https://github.com/kubernetes-sigs/agent-sandbox) project. It detects the served Sandbox API at runtime, caches the selected API version for the gateway process, and uses `v1beta1` when available before falling back to `v1alpha1`, so supported Agent Sandbox installations work without version-specific operator configuration. The Agent Sandbox controller turns those resources into sandbox pods and related storage. Stop patches the existing resource rather than deleting it. For `v1beta1`, diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index 2b753cf3f9..e097687453 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -618,8 +618,10 @@ supervisor Pods to reach sandbox TLS listeners. The driver then creates a per-sandbox Service, split immutable bootstrap Secrets, and a gated supervisor Pod before releasing either Pod. The supervisor Pod runs `/openshell-supervisor`. Both Pods use the -same resolved non-root identity, request no capabilities, drop `ALL`, disable -privilege escalation, and use `RuntimeDefault` seccomp. The supervisor reaches +same resolved non-root identity, request no capabilities, drop `ALL`, and disable +privilege escalation. Native workload Pods use `RuntimeDefault` seccomp. An +effective `runtimeClassName: gvisor` selects the gVisor adapter and deliberately +omits the Pod seccomp profile and custom sysctl that GKE Sandbox rejects. The supervisor reaches the sandbox over per-sandbox TLS with server-certificate verification plus bootstrap-token client authentication, and owns gateway policy, provider credentials, DNS, and mediated upstream connections. @@ -647,6 +649,14 @@ a required unprivileged seccomp, task-memory, or Landlock operation. Do not add capabilities, gateway egress, or credentials to the workload Pod as a workaround. +For a gVisor workload, confirmation instead requires `/proc/version` to report +gVisor, a healthy workload-local proxy on `127.0.0.1:3128`, and the observed +zero-rule workload egress fence. Verify the workload environment contains +`HTTP_PROXY` and `HTTPS_PROXY` with that loopback address. Direct DNS and raw +TCP are expected to fail: the adapter supports HTTP/CONNECT-aware applications +and endpoint-only policy, not native transparent interception or per-binary +network rules. + #### Corporate upstream proxy When the deployment routes sandbox egress through a corporate HTTP forward