From 869b3f0bba0ca0438651617f2b6ae6a713f3a38f Mon Sep 17 00:00:00 2001 From: Ladislav Smola Date: Fri, 12 Jun 2026 18:25:40 +0200 Subject: [PATCH] feat(sandbox): add Platform network mode for restricted K8s platforms Add NetworkMode::Platform that enables the OpenShell supervisor to run without any elevated capabilities on Kubernetes platforms enforcing the restricted Pod Security Standard (e.g. OpenShift restricted-v2 SCC). Platform Mode keeps Landlock filesystem isolation, seccomp syscall filtering, OPA policy evaluation, credential injection, and L7 inspection via a loopback CONNECT proxy. It replaces the network namespace (which requires CAP_SYS_ADMIN + CAP_NET_ADMIN) with Kubernetes NetworkPolicy for L3/L4 egress control. Changes: - proto: add NetworkEnforcementMode enum to SandboxPolicy (field 6) and DriverSandboxSpec (field 12), backward-compatible (zero = Namespace) - sandbox: add Platform variant to NetworkMode, wire TryFrom conversion - sandbox: skip netns creation, bind proxy to loopback (127.0.0.1:3128) - sandbox: allow AF_INET sockets in seccomp for Platform mode - sandbox: inject loopback proxy env for child processes - driver-k8s: zero capabilities (drop ALL) in Platform mode, typed enum - driver-k8s: skip runAsUser: 0 in Platform mode - server: propagate network_enforcement from SandboxSpec to DriverSandboxSpec - policy: add network_enforcement field to all SandboxPolicy constructors Ref: NVIDIA/OpenShell#899 --- .../openshell-driver-kubernetes/src/driver.rs | 76 +++++--- crates/openshell-policy/src/lib.rs | 3 + crates/openshell-sandbox/src/lib.rs | 173 ++++++++++-------- crates/openshell-sandbox/src/opa.rs | 11 ++ crates/openshell-sandbox/src/policy.rs | 15 +- crates/openshell-sandbox/src/process.rs | 35 ++-- .../src/sandbox/linux/seccomp.rs | 5 +- crates/openshell-server/src/compute/mod.rs | 4 + proto/compute_driver.proto | 5 + proto/sandbox.proto | 15 ++ 10 files changed, 223 insertions(+), 119 deletions(-) diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index 5a43eb980..ddc8b47f0 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -330,6 +330,10 @@ impl KubernetesComputeDriver { enable_user_namespaces: self.config.enable_user_namespaces, workspace_default_storage_size: &self.config.workspace_default_storage_size, sa_token_ttl_secs: self.config.effective_sa_token_ttl_secs(), + is_platform_mode: sandbox + .spec + .as_ref() + .is_some_and(|s| s.network_enforcement == 1), }; obj.data = sandbox_to_k8s_spec(sandbox.spec.as_ref(), ¶ms); let api = self.api(); @@ -823,6 +827,7 @@ fn apply_supervisor_sideload( supervisor_image: &str, supervisor_image_pull_policy: &str, method: SupervisorSideloadMethod, + is_platform_mode: bool, ) { let Some(spec) = pod_template.get_mut("spec").and_then(|v| v.as_object_mut()) else { return; @@ -882,16 +887,16 @@ fn apply_supervisor_sideload( serde_json::json!([format!("{}/openshell-sandbox", SUPERVISOR_MOUNT_PATH)]), ); - // Force the supervisor to run as root (UID 0). Sandbox images may set - // a non-root USER directive (e.g. `USER sandbox`), but the supervisor - // needs root to create network namespaces, set up the proxy, and - // configure Landlock/seccomp. The supervisor itself drops privileges - // for child processes via the policy's `run_as_user`/`run_as_group`. - let security_context = container - .entry("securityContext") - .or_insert_with(|| serde_json::json!({})); - if let Some(sc) = security_context.as_object_mut() { - sc.insert("runAsUser".to_string(), serde_json::json!(0)); + // In namespace mode, force root (UID 0) so the supervisor can create + // network namespaces and drop privileges for child processes. + // In platform mode, keep the image's default non-root user. + if !is_platform_mode { + let security_context = container + .entry("securityContext") + .or_insert_with(|| serde_json::json!({})); + if let Some(sc) = security_context.as_object_mut() { + sc.insert("runAsUser".to_string(), serde_json::json!(0)); + } } // Add volume mount @@ -1044,6 +1049,10 @@ struct SandboxPodParams<'a> { /// Lifetime (seconds) of the projected `ServiceAccount` token used /// for the bootstrap `IssueSandboxToken` exchange. sa_token_ttl_secs: i64, + /// Platform network enforcement mode (Issue #899). When true, sandbox + /// pods are emitted without elevated capabilities, compatible with + /// restricted-v2 SCC and restricted Pod Security Standard. + is_platform_mode: bool, } impl Default for SandboxPodParams<'_> { @@ -1065,6 +1074,7 @@ impl Default for SandboxPodParams<'_> { enable_user_namespaces: false, workspace_default_storage_size: DEFAULT_WORKSPACE_STORAGE_SIZE, sa_token_ttl_secs: 3600, + is_platform_mode: false, } } } @@ -1265,22 +1275,32 @@ fn sandbox_template_to_k8s( container.insert("env".to_string(), serde_json::Value::Array(env)); - let mut capabilities: Vec<&str> = vec!["SYS_ADMIN", "NET_ADMIN", "SYS_PTRACE", "SYSLOG"]; - if use_user_namespaces { - // In a user namespace the bounding set is reset. SETUID/SETGID are - // needed for the supervisor to drop privileges to the sandbox user. - // DAC_READ_SEARCH is needed for cross-UID /proc//fd/ access - // for process identity resolution in network policy enforcement. - capabilities.extend(["SETUID", "SETGID", "DAC_READ_SEARCH"]); + if params.is_platform_mode { + // Platform mode: zero elevated capabilities. Compatible with + // restricted-v2 SCC and restricted Pod Security Standard. + container.insert( + "securityContext".to_string(), + serde_json::json!({ + "allowPrivilegeEscalation": false, + "capabilities": { + "drop": ["ALL"] + } + }), + ); + } else { + let mut capabilities: Vec<&str> = vec!["SYS_ADMIN", "NET_ADMIN", "SYS_PTRACE", "SYSLOG"]; + if use_user_namespaces { + capabilities.extend(["SETUID", "SETGID", "DAC_READ_SEARCH"]); + } + container.insert( + "securityContext".to_string(), + serde_json::json!({ + "capabilities": { + "add": capabilities + } + }), + ); } - container.insert( - "securityContext".to_string(), - serde_json::json!({ - "capabilities": { - "add": capabilities - } - }), - ); // Mount client TLS secret for mTLS to the server, plus the projected // ServiceAccount token used to bootstrap the sandbox's gateway JWT @@ -1363,6 +1383,7 @@ fn sandbox_template_to_k8s( params.supervisor_image, params.supervisor_image_pull_policy, params.supervisor_sideload_method, + params.is_platform_mode, ); // Inject workspace persistence (init container + PVC volume mount) so @@ -1750,6 +1771,7 @@ mod tests { "custom-image:latest", "IfNotPresent", SupervisorSideloadMethod::InitContainer, + false, ); let sc = &pod_template["spec"]["containers"][0]["securityContext"]; @@ -1779,6 +1801,7 @@ mod tests { "supervisor-image:latest", "IfNotPresent", SupervisorSideloadMethod::InitContainer, + false, ); let sc = &pod_template["spec"]["containers"][0]["securityContext"]; @@ -1804,6 +1827,7 @@ mod tests { "supervisor-image:latest", "IfNotPresent", SupervisorSideloadMethod::InitContainer, + false, ); // Volume should be an emptyDir @@ -1878,6 +1902,7 @@ mod tests { "supervisor-image:latest", "IfNotPresent", SupervisorSideloadMethod::ImageVolume, + false, ); let volumes = pod_template["spec"]["volumes"] @@ -1932,6 +1957,7 @@ mod tests { "supervisor-image:latest", "", SupervisorSideloadMethod::ImageVolume, + false, ); let volume = &pod_template["spec"]["volumes"][0]; diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index 26c8fc9d3..d55a2806c 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -378,6 +378,7 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy { run_as_group: p.run_as_group, }), network_policies, + network_enforcement: 0, } } @@ -649,6 +650,7 @@ pub fn restrictive_default_policy() -> SandboxPolicy { run_as_group: "sandbox".into(), }), network_policies: HashMap::new(), + network_enforcement: 0, // NAMESPACE (default) } } @@ -1262,6 +1264,7 @@ network_policies: filesystem: None, landlock: None, network_policies: HashMap::new(), + network_enforcement: 0, }; assert!(validate_sandbox_policy(&policy).is_ok()); } diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 126416546..8e4fc9286 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -487,7 +487,10 @@ pub async fn run_sandbox( // Generate ephemeral CA and TLS state for HTTPS L7 inspection. // The CA cert is written to disk so sandbox processes can trust it. - let (tls_state, ca_file_paths) = if matches!(policy.network.mode, NetworkMode::Proxy) { + let (tls_state, ca_file_paths) = if matches!( + policy.network.mode, + NetworkMode::Proxy | NetworkMode::Platform + ) { match SandboxCa::generate() { Ok(ca) => { let tls_dir = std::path::Path::new("/etc/openshell-tls"); @@ -600,79 +603,91 @@ pub async fn run_sandbox( // the entrypoint process's /proc/net/tcp for identity binding. let entrypoint_pid = Arc::new(AtomicU32::new(0)); - let (_proxy, denial_rx, bypass_denial_tx, activity_rx, bypass_activity_tx) = - if matches!(policy.network.mode, NetworkMode::Proxy) { - let proxy_policy = policy.network.proxy.as_ref().ok_or_else(|| { - miette::miette!( - "Network mode is set to proxy but no proxy configuration was provided" - ) - })?; + let (_proxy, denial_rx, bypass_denial_tx, activity_rx, bypass_activity_tx) = if matches!( + policy.network.mode, + NetworkMode::Proxy | NetworkMode::Platform + ) { + let proxy_policy = policy.network.proxy.as_ref().ok_or_else(|| { + miette::miette!("Network mode is set to proxy but no proxy configuration was provided") + })?; - let engine = opa_engine.clone().ok_or_else(|| { - miette::miette!("Proxy mode requires an OPA engine (--rego-policy and --rego-data)") - })?; + let engine = opa_engine.clone().ok_or_else(|| { + miette::miette!("Proxy mode requires an OPA engine (--rego-policy and --rego-data)") + })?; - let cache = identity_cache.clone().ok_or_else(|| { - miette::miette!( - "Proxy mode requires an identity cache (OPA engine must be configured)" - ) - })?; + let cache = identity_cache.clone().ok_or_else(|| { + miette::miette!("Proxy mode requires an identity cache (OPA engine must be configured)") + })?; + + // If we have a network namespace, bind to the veth host IP so sandboxed + // processes can reach the proxy via TCP. + #[cfg(target_os = "linux")] + let bind_addr = netns.as_ref().map(|ns| { + let port = proxy_policy.http_addr.map_or(3128, |addr| addr.port()); + SocketAddr::new(ns.host_ip(), port) + }); - // If we have a network namespace, bind to the veth host IP so sandboxed - // processes can reach the proxy via TCP. - #[cfg(target_os = "linux")] - let bind_addr = netns.as_ref().map(|ns| { + // Platform mode: no netns, bind proxy to loopback. + #[cfg(target_os = "linux")] + let bind_addr = bind_addr.or_else(|| { + if matches!(policy.network.mode, NetworkMode::Platform) { let port = proxy_policy.http_addr.map_or(3128, |addr| addr.port()); - SocketAddr::new(ns.host_ip(), port) - }); + Some(SocketAddr::new( + std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + port, + )) + } else { + None + } + }); - #[cfg(not(target_os = "linux"))] - let bind_addr: Option = None; + #[cfg(not(target_os = "linux"))] + let bind_addr: Option = None; - // Build inference context for local routing of intercepted inference calls. - let inference_ctx = build_inference_context( - sandbox_id.as_deref(), - openshell_endpoint_for_proxy.as_deref(), - inference_routes.as_deref(), - ) - .await?; - - // Create denial aggregator channel if in gRPC mode (sandbox_id present). - // Clone the sender for the bypass monitor before passing to the proxy. - let (denial_tx, denial_rx, bypass_denial_tx) = if sandbox_id.is_some() { - let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); - let bypass_tx = tx.clone(); - (Some(tx), Some(rx), Some(bypass_tx)) - } else { - (None, None, None) - }; - let (activity_tx, activity_rx, bypass_activity_tx) = - activity_collection_channels(sandbox_id.as_deref()); - - let proxy_handle = ProxyHandle::start_with_bind_addr( - proxy_policy, - bind_addr, - engine, - cache, - entrypoint_pid.clone(), - tls_state, - inference_ctx, - Some(provider_credentials.clone()), - Some(policy_local_ctx.clone()), - denial_tx, - activity_tx, - ) - .await?; - ( - Some(proxy_handle), - denial_rx, - bypass_denial_tx, - activity_rx, - bypass_activity_tx, - ) + // Build inference context for local routing of intercepted inference calls. + let inference_ctx = build_inference_context( + sandbox_id.as_deref(), + openshell_endpoint_for_proxy.as_deref(), + inference_routes.as_deref(), + ) + .await?; + + // Create denial aggregator channel if in gRPC mode (sandbox_id present). + // Clone the sender for the bypass monitor before passing to the proxy. + let (denial_tx, denial_rx, bypass_denial_tx) = if sandbox_id.is_some() { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let bypass_tx = tx.clone(); + (Some(tx), Some(rx), Some(bypass_tx)) } else { - (None, None, None, None, None) + (None, None, None) }; + let (activity_tx, activity_rx, bypass_activity_tx) = + activity_collection_channels(sandbox_id.as_deref()); + + let proxy_handle = ProxyHandle::start_with_bind_addr( + proxy_policy, + bind_addr, + engine, + cache, + entrypoint_pid.clone(), + tls_state, + inference_ctx, + Some(provider_credentials.clone()), + Some(policy_local_ctx.clone()), + denial_tx, + activity_tx, + ) + .await?; + ( + Some(proxy_handle), + denial_rx, + bypass_denial_tx, + activity_rx, + bypass_activity_tx, + ) + } else { + (None, None, None, None, None) + }; // Spawn bypass detection monitor (Linux only, proxy mode only). // Reads /dev/kmsg for nftables log entries and emits structured @@ -705,18 +720,30 @@ pub async fn run_sandbox( #[cfg(not(target_os = "linux"))] let ssh_netns_fd: Option = None; - let ssh_proxy_url = if matches!(policy.network.mode, NetworkMode::Proxy) { + let ssh_proxy_url = if matches!( + policy.network.mode, + NetworkMode::Proxy | NetworkMode::Platform + ) { #[cfg(target_os = "linux")] { - netns.as_ref().map(|ns| { + if let Some(ns) = netns.as_ref() { let port = policy .network .proxy .as_ref() .and_then(|p| p.http_addr) .map_or(3128, |addr| addr.port()); - format!("http://{}:{port}", ns.host_ip()) - }) + Some(format!("http://{}:{port}", ns.host_ip())) + } else { + // Platform mode: proxy on loopback + let port = policy + .network + .proxy + .as_ref() + .and_then(|p| p.http_addr) + .map_or(3128, |addr| addr.port()); + Some(format!("http://127.0.0.1:{port}")) + } } #[cfg(not(target_os = "linux"))] { @@ -1729,8 +1756,10 @@ where } fn enrich_sandbox_baseline_paths(policy: &mut SandboxPolicy) { - let (ro, rw) = - active_baseline_enrichment_paths(matches!(policy.network.mode, NetworkMode::Proxy)); + let (ro, rw) = active_baseline_enrichment_paths(matches!( + policy.network.mode, + NetworkMode::Proxy | NetworkMode::Platform + )); let modified = enrich_sandbox_baseline_paths_with(policy, &ro, &rw, std::path::Path::exists); if modified { diff --git a/crates/openshell-sandbox/src/opa.rs b/crates/openshell-sandbox/src/opa.rs index f5ff5923b..cbb7b4074 100644 --- a/crates/openshell-sandbox/src/opa.rs +++ b/crates/openshell-sandbox/src/opa.rs @@ -1262,6 +1262,7 @@ mod tests { run_as_group: "sandbox".to_string(), }), network_policies, + network_enforcement: 0, } } @@ -2518,6 +2519,7 @@ network_policies: run_as_group: "sandbox".to_string(), }), network_policies, + network_enforcement: 0, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -2641,6 +2643,7 @@ network_policies: run_as_group: "sandbox".to_string(), }), network_policies, + network_enforcement: 0, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -2698,6 +2701,7 @@ network_policies: run_as_group: "sandbox".to_string(), }), network_policies, + network_enforcement: 0, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -2755,6 +2759,7 @@ network_policies: run_as_group: "sandbox".to_string(), }), network_policies, + network_enforcement: 0, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -3704,6 +3709,7 @@ process: run_as_group: "sandbox".to_string(), }), network_policies, + network_enforcement: 0, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); let input = NetworkInput { @@ -3758,6 +3764,7 @@ process: run_as_group: "sandbox".to_string(), }), network_policies, + network_enforcement: 0, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); let input = NetworkInput { @@ -3828,6 +3835,7 @@ process: run_as_group: "sandbox".to_string(), }), network_policies, + network_enforcement: 0, }; let engine = OpaEngine::from_proto(&proto).expect("Failed to create engine from proto"); @@ -4058,6 +4066,7 @@ network_policies: run_as_group: "sandbox".to_string(), }), network_policies, + network_enforcement: 0, }; let engine = OpaEngine::from_proto(&proto).unwrap(); // Port 443 @@ -5017,6 +5026,7 @@ network_policies: run_as_group: "sandbox".to_string(), }), network_policies, + network_enforcement: 0, }; // Build engine with our PID (symlink resolution will work via /proc/self/root/) @@ -5094,6 +5104,7 @@ network_policies: run_as_group: "sandbox".to_string(), }), network_policies, + network_enforcement: 0, }; // Initial load at pid=0 — no symlink expansion diff --git a/crates/openshell-sandbox/src/policy.rs b/crates/openshell-sandbox/src/policy.rs index 0827fa0d0..fde345b83 100644 --- a/crates/openshell-sandbox/src/policy.rs +++ b/crates/openshell-sandbox/src/policy.rs @@ -5,7 +5,8 @@ use openshell_core::proto::{ FilesystemPolicy as ProtoFilesystemPolicy, LandlockPolicy as ProtoLandlockPolicy, - ProcessPolicy as ProtoProcessPolicy, SandboxPolicy as ProtoSandboxPolicy, + NetworkEnforcementMode, ProcessPolicy as ProtoProcessPolicy, + SandboxPolicy as ProtoSandboxPolicy, }; use std::net::SocketAddr; use std::path::PathBuf; @@ -62,6 +63,9 @@ pub enum NetworkMode { Block, Proxy, Allow, + /// Platform mode: Landlock + seccomp + loopback proxy, no network namespace. + /// Compatible with restricted-v2 SCC and restricted Pod Security Standard. + Platform, } #[derive(Debug, Clone)] @@ -99,10 +103,13 @@ impl TryFrom for SandboxPolicy { type Error = miette::Report; fn try_from(proto: ProtoSandboxPolicy) -> Result { - // In cluster mode we always run with proxy networking so all egress - // can be evaluated by OPA and `inference.local` is always addressable. + let mode = match proto.network_enforcement() { + NetworkEnforcementMode::NetworkEnforcementNamespace => NetworkMode::Proxy, + NetworkEnforcementMode::NetworkEnforcementPlatform => NetworkMode::Platform, + }; + let network = NetworkPolicy { - mode: NetworkMode::Proxy, + mode, proxy: Some(ProxyPolicy { http_addr: None }), }; diff --git a/crates/openshell-sandbox/src/process.rs b/crates/openshell-sandbox/src/process.rs index d004bb7d4..f059584ce 100644 --- a/crates/openshell-sandbox/src/process.rs +++ b/crates/openshell-sandbox/src/process.rs @@ -226,27 +226,25 @@ impl ProcessHandle { cmd.current_dir(dir); } - if matches!(policy.network.mode, NetworkMode::Proxy) { + if matches!( + policy.network.mode, + NetworkMode::Proxy | NetworkMode::Platform + ) { let proxy = policy.network.proxy.as_ref().ok_or_else(|| { miette::miette!( "Network mode is set to proxy but no proxy configuration was provided" ) })?; - // When using network namespace, set proxy URL to the veth host IP - if netns_fd.is_some() { - // The proxy is on 10.200.0.1:3128 (or configured port) - let port = proxy.http_addr.map_or(3128, |addr| addr.port()); - let proxy_url = format!("http://10.200.0.1:{port}"); - // Both uppercase and lowercase variants: curl/wget use uppercase, - // gRPC C-core (libgrpc) checks lowercase http_proxy/https_proxy. - for (key, value) in child_env::proxy_env_vars(&proxy_url) { - cmd.env(key, value); - } - } else if let Some(http_addr) = proxy.http_addr { - let proxy_url = format!("http://{http_addr}"); - for (key, value) in child_env::proxy_env_vars(&proxy_url) { - cmd.env(key, value); - } + let port = proxy.http_addr.map_or(3128, |addr| addr.port()); + let proxy_url = if netns_fd.is_some() { + // Namespace mode: proxy on veth host IP + format!("http://10.200.0.1:{port}") + } else { + // Platform mode (or non-Linux): proxy on loopback + format!("http://127.0.0.1:{port}") + }; + for (key, value) in child_env::proxy_env_vars(&proxy_url) { + cmd.env(key, value); } } @@ -368,7 +366,10 @@ impl ProcessHandle { cmd.current_dir(dir); } - if matches!(policy.network.mode, NetworkMode::Proxy) { + if matches!( + policy.network.mode, + NetworkMode::Proxy | NetworkMode::Platform + ) { let proxy = policy.network.proxy.as_ref().ok_or_else(|| { miette::miette!( "Network mode is set to proxy but no proxy configuration was provided" diff --git a/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs b/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs index 675b60b24..a708b4b97 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs @@ -70,7 +70,10 @@ pub fn apply_supervisor_prelude() -> Result<()> { } pub fn apply(policy: &SandboxPolicy) -> Result<()> { - let allow_inet = matches!(policy.network.mode, NetworkMode::Proxy | NetworkMode::Allow); + let allow_inet = matches!( + policy.network.mode, + NetworkMode::Proxy | NetworkMode::Allow | NetworkMode::Platform + ); let main_filter = build_filter(allow_inet)?; let clone3_filter = build_clone3_filter()?; diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 30a21c643..152848c57 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -1271,6 +1271,10 @@ fn driver_sandbox_spec_from_public(spec: &SandboxSpec) -> DriverSandboxSpec { gpu: spec.gpu, gpu_device: spec.gpu_device.clone(), sandbox_token: String::new(), + network_enforcement: spec + .policy + .as_ref() + .map_or(0, |p| p.network_enforcement), } } diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index 610d491c7..48a7f4beb 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -6,6 +6,7 @@ syntax = "proto3"; package openshell.compute.v1; import "google/protobuf/struct.proto"; +import "sandbox.proto"; // Internal compute-driver contract used by the gateway. // @@ -96,6 +97,10 @@ message DriverSandboxSpec { // ServiceAccount token bootstrap instead). Never echoed to the public // Sandbox proto. string sandbox_token = 11; + // Network enforcement mode for this sandbox. When set to + // NETWORK_ENFORCEMENT_PLATFORM (1), the sandbox runs without elevated + // capabilities. Populated by the gateway from the SandboxPolicy. + openshell.sandbox.v1.NetworkEnforcementMode network_enforcement = 12; } // Driver-owned runtime template consumed by the compute platform. diff --git a/proto/sandbox.proto b/proto/sandbox.proto index ef0b0540f..e3330b3d0 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -13,6 +13,18 @@ package openshell.sandbox.v1; // - Public sandbox resource types live in `openshell.proto`. // - Internal compute-driver sandbox observation types live in `compute_driver.proto`. +// Network enforcement strategy for sandbox isolation. +enum NetworkEnforcementMode { + // Use a dedicated network namespace with veth pair and nftables bypass + // rules. Requires CAP_SYS_ADMIN and CAP_NET_ADMIN. Default. + NETWORK_ENFORCEMENT_NAMESPACE = 0; + // Rely on Kubernetes NetworkPolicy for L3/L4 egress control. The + // supervisor binds the CONNECT proxy to loopback instead of veth. No + // elevated capabilities required -- compatible with restricted-v2 SCC + // and restricted Pod Security Standard. + NETWORK_ENFORCEMENT_PLATFORM = 1; +} + // Sandbox security policy configuration. message SandboxPolicy { // Policy version. @@ -25,6 +37,9 @@ message SandboxPolicy { ProcessPolicy process = 4; // Network access policies keyed by name (e.g. "claude_code", "gitlab"). map network_policies = 5; + // Network enforcement mode. Default (0) preserves current namespace-based + // isolation for backward compatibility. + NetworkEnforcementMode network_enforcement = 6; } // Filesystem access policy.