From 2a6984a5405ddcb0cfb67d0fdad3bfe1b9fb2fbb Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 5 Jun 2026 13:55:44 -0700 Subject: [PATCH 1/9] feat(drivers): support docker and podman config mounts Signed-off-by: Drew Newberry --- Cargo.lock | 2 + architecture/compute-runtimes.md | 6 + crates/openshell-driver-docker/Cargo.toml | 1 + crates/openshell-driver-docker/README.md | 35 ++ crates/openshell-driver-docker/src/lib.rs | 372 ++++++++++- crates/openshell-driver-docker/src/tests.rs | 162 +++++ crates/openshell-driver-podman/Cargo.toml | 1 + crates/openshell-driver-podman/README.md | 37 ++ crates/openshell-driver-podman/src/client.rs | 17 + .../openshell-driver-podman/src/container.rs | 584 +++++++++++++++++- crates/openshell-driver-podman/src/driver.rs | 43 +- crates/openshell-driver-podman/src/grpc.rs | 1 + docs/reference/sandbox-compute-drivers.mdx | 98 +++ docs/sandboxes/manage-sandboxes.mdx | 23 + 14 files changed, 1360 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d0cd77f85..04b561899 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3481,6 +3481,7 @@ dependencies = [ "bytes", "futures", "openshell-core", + "prost-types", "serde", "serde_json", "tar", @@ -3529,6 +3530,7 @@ dependencies = [ "miette", "nix", "openshell-core", + "prost-types", "rustix 1.1.4", "serde", "serde_json", diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index ef6f6dc5a..b024a8eea 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -40,6 +40,12 @@ template resource limits. Docker and Podman apply them as runtime limits. Kubernetes mirrors each limit into the matching request. VM accepts the fields but currently ignores them. +Docker and Podman also accept per-sandbox driver-config mounts for existing +runtime-managed named volumes and tmpfs mounts. Podman additionally accepts +image mounts through its image-volume API. User-supplied host bind mounts are +excluded from the driver-config contract; bind mounts remain reserved for +driver-owned supervisor, token, and TLS material. + Kubernetes deployments may set an AppArmor profile on sandbox agent containers through the driver configuration. The Helm chart defaults sandbox agents to `Unconfined` so runtime/default AppArmor profiles do not block supervisor diff --git a/crates/openshell-driver-docker/Cargo.toml b/crates/openshell-driver-docker/Cargo.toml index 4ddb1a913..195a79151 100644 --- a/crates/openshell-driver-docker/Cargo.toml +++ b/crates/openshell-driver-docker/Cargo.toml @@ -21,6 +21,7 @@ tracing = { workspace = true } bytes = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +prost-types = { workspace = true } bollard = { version = "0.20" } tar = "0.4" tempfile = "3" diff --git a/crates/openshell-driver-docker/README.md b/crates/openshell-driver-docker/README.md index ea57f44e4..d71b18a84 100644 --- a/crates/openshell-driver-docker/README.md +++ b/crates/openshell-driver-docker/README.md @@ -36,6 +36,41 @@ contract: The agent child process does not retain these supervisor privileges. +## Driver Config Mounts + +The gateway forwards the `docker` block from `--driver-config-json` to this +driver. The driver accepts user-supplied `mounts` entries with these Docker +mount types: + +- `volume`: mounts an existing Docker named volume. The driver validates that + the volume exists before provisioning and never creates or removes it. +- `tmpfs`: mounts an in-memory filesystem with optional `options`, + `size_bytes`, and `mode`. + +Host bind mounts and image mounts are intentionally not part of the Docker +driver-config schema. The driver still uses internal bind mounts for +OpenShell-owned supervisor, token, and TLS material. + +Docker `volume` mounts may include `subpath`. Mount targets must be absolute +container paths and must not replace the workspace root (`/sandbox`) or overlap +OpenShell supervisor files, auth material, TLS material, or `/run/netns`. + +Example NFS usage relies on Docker's named-volume support rather than a host +bind: + +```shell +docker volume create \ + --driver local \ + --opt type=nfs \ + --opt o=addr=10.0.0.10,rw,nfsvers=4 \ + --opt device=:/exports/work \ + work-nfs + +openshell sandbox create \ + --driver-config-json '{"docker":{"mounts":[{"type":"volume","source":"work-nfs","target":"/sandbox/work"}]}}' \ + -- claude +``` + ## Supervisor Binary Resolution The Docker driver bind-mounts a host-side Linux `openshell-sandbox` binary into diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index 49bd3d3f6..e96324d23 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -9,8 +9,9 @@ use bollard::Docker; use bollard::errors::Error as BollardError; use bollard::models::{ ContainerCreateBody, ContainerSummary, ContainerSummaryStateEnum, CreateImageInfo, - DeviceRequest, EndpointSettings, HostConfig, NetworkCreateRequest, NetworkingConfig, - ProgressDetail, RestartPolicy, RestartPolicyNameEnum, SystemInfo, + DeviceRequest, EndpointSettings, HostConfig, Mount, MountTmpfsOptions, MountTypeEnum, + MountVolumeOptions, NetworkCreateRequest, NetworkingConfig, ProgressDetail, RestartPolicy, + RestartPolicyNameEnum, SystemInfo, }; use bollard::query_parameters::{ CreateContainerOptionsBuilder, CreateImageOptions, DownloadFromContainerOptionsBuilder, @@ -41,7 +42,7 @@ use openshell_core::proto::compute::v1::{ watch_sandboxes_event, }; use openshell_core::{Config, Error, Result as CoreResult}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::io::Read; use std::net::{IpAddr, SocketAddr}; use std::path::{Path, PathBuf}; @@ -258,6 +259,34 @@ struct DockerResourceLimits { memory_bytes: Option, } +#[derive(Debug, Clone, Default, serde::Deserialize)] +#[serde(default, deny_unknown_fields)] +struct DockerSandboxDriverConfig { + mounts: Vec, +} + +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +enum DockerDriverMountConfig { + Volume { + source: String, + target: String, + #[serde(default)] + read_only: bool, + #[serde(default)] + subpath: Option, + }, + Tmpfs { + target: String, + #[serde(default)] + options: Vec, + #[serde(default)] + size_bytes: Option, + #[serde(default)] + mode: Option, + }, +} + type WatchStream = Pin> + Send + 'static>>; @@ -391,6 +420,7 @@ impl DockerComputeDriver { )); } + let _ = docker_driver_config(template)?; let _ = docker_resource_limits(template)?; Ok(()) } @@ -418,6 +448,35 @@ impl DockerComputeDriver { Ok(()) } + async fn validate_user_volume_mounts_available( + &self, + sandbox: &DriverSandbox, + ) -> Result<(), Status> { + let template = sandbox + .spec + .as_ref() + .and_then(|spec| spec.template.as_ref()) + .ok_or_else(|| Status::invalid_argument("sandbox.spec.template is required"))?; + let config = docker_driver_config(template)?; + for mount in config.mounts { + if let DockerDriverMountConfig::Volume { source, .. } = mount { + match self.docker.inspect_volume(source.trim()).await { + Ok(_) => {} + Err(err) if is_not_found_error(&err) => { + return Err(Status::failed_precondition(format!( + "docker volume '{}' does not exist", + source.trim() + ))); + } + Err(err) => { + return Err(internal_status("inspect docker volume", err)); + } + } + } + } + Ok(()) + } + async fn get_sandbox_snapshot( &self, sandbox_id: &str, @@ -455,6 +514,7 @@ impl DockerComputeDriver { async fn create_sandbox_inner(&self, sandbox: &DriverSandbox) -> Result<(), Status> { Self::validate_sandbox(sandbox, &self.config)?; Self::validate_sandbox_auth(sandbox)?; + self.validate_user_volume_mounts_available(sandbox).await?; let _ = build_container_create_body(sandbox, &self.config)?; if self @@ -1165,6 +1225,7 @@ impl ComputeDriver for DockerComputeDriver { .sandbox .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; Self::validate_sandbox(&sandbox, &self.config)?; + self.validate_user_volume_mounts_available(&sandbox).await?; Ok(Response::new(ValidateSandboxCreateResponse {})) } @@ -1505,6 +1566,309 @@ fn attach_docker_progress_metadata( } } +fn docker_driver_config( + template: &DriverSandboxTemplate, +) -> Result { + let Some(config) = template.driver_config.as_ref() else { + return Ok(DockerSandboxDriverConfig::default()); + }; + + let json = serde_json::Value::Object(proto_struct_to_json_object(config)); + let config: DockerSandboxDriverConfig = serde_json::from_value(json).map_err(|err| { + Status::failed_precondition(format!("invalid docker driver_config: {err}")) + })?; + validate_docker_driver_mounts(&config.mounts)?; + Ok(config) +} + +fn docker_driver_mounts(template: &DriverSandboxTemplate) -> Result, Status> { + let config = docker_driver_config(template)?; + config.mounts.iter().map(docker_mount_from_config).collect() +} + +fn docker_mount_from_config(config: &DockerDriverMountConfig) -> Result { + match config { + DockerDriverMountConfig::Volume { + source, + target, + read_only, + subpath, + } => Ok(Mount { + typ: Some(MountTypeEnum::VOLUME), + source: Some(validate_mount_source(source, "volume source")?), + target: Some(validate_container_mount_target(target)?), + read_only: Some(*read_only), + volume_options: subpath + .as_ref() + .map(|subpath| { + Ok::(MountVolumeOptions { + subpath: Some(validate_mount_subpath(subpath)?), + ..Default::default() + }) + }) + .transpose()?, + ..Default::default() + }), + DockerDriverMountConfig::Tmpfs { + target, + options, + size_bytes, + mode, + } => Ok(Mount { + typ: Some(MountTypeEnum::TMPFS), + target: Some(validate_container_mount_target(target)?), + tmpfs_options: Some(MountTmpfsOptions { + size_bytes: validate_optional_positive_integral_i64( + *size_bytes, + "tmpfs size_bytes", + )?, + mode: validate_optional_nonnegative_integral_i64(*mode, "tmpfs mode")?, + options: (!options.is_empty()) + .then(|| { + options + .iter() + .map(|option| docker_tmpfs_option(option)) + .collect::, _>>() + }) + .transpose()?, + }), + ..Default::default() + }), + } +} + +fn validate_docker_driver_mounts(mounts: &[DockerDriverMountConfig]) -> Result<(), Status> { + let mut targets = HashSet::new(); + for mount in mounts { + let target = match mount { + DockerDriverMountConfig::Volume { + source, + target, + subpath, + .. + } => { + validate_mount_source(source, "volume source")?; + if let Some(subpath) = subpath { + validate_mount_subpath(subpath)?; + } + target + } + DockerDriverMountConfig::Tmpfs { + target, + options, + size_bytes, + mode, + } => { + validate_optional_positive_integral_i64(*size_bytes, "tmpfs size_bytes")?; + validate_optional_nonnegative_integral_i64(*mode, "tmpfs mode")?; + for option in options { + docker_tmpfs_option(option)?; + } + target + } + }; + let target = validate_container_mount_target(target)?; + if !targets.insert(target.clone()) { + return Err(Status::failed_precondition(format!( + "duplicate docker driver_config mount target '{target}'" + ))); + } + } + Ok(()) +} + +fn validate_mount_source(source: &str, field: &str) -> Result { + let source = source.trim(); + if source.is_empty() { + return Err(Status::failed_precondition(format!( + "{field} must not be empty" + ))); + } + if source.as_bytes().contains(&0) { + return Err(Status::failed_precondition(format!( + "{field} must not contain NUL bytes" + ))); + } + Ok(source.to_string()) +} + +fn validate_mount_subpath(subpath: &str) -> Result { + let subpath = subpath.trim(); + if subpath.is_empty() { + return Err(Status::failed_precondition( + "mount subpath must not be empty", + )); + } + let path = Path::new(subpath); + if path.is_absolute() + || path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(Status::failed_precondition( + "mount subpath must be relative and must not contain '..'", + )); + } + Ok(subpath.to_string()) +} + +fn validate_optional_positive_integral_i64( + value: Option, + field: &str, +) -> Result, Status> { + let Some(value) = validate_optional_integral_i64(value, field)? else { + return Ok(None); + }; + if value <= 0 { + return Err(Status::failed_precondition(format!( + "{field} must be positive" + ))); + } + Ok(Some(value)) +} + +fn validate_optional_nonnegative_integral_i64( + value: Option, + field: &str, +) -> Result, Status> { + let Some(value) = validate_optional_integral_i64(value, field)? else { + return Ok(None); + }; + if value < 0 { + return Err(Status::failed_precondition(format!( + "{field} must be zero or greater" + ))); + } + Ok(Some(value)) +} + +fn validate_optional_integral_i64(value: Option, field: &str) -> Result, Status> { + let Some(value) = value else { + return Ok(None); + }; + if !value.is_finite() || value.fract() != 0.0 { + return Err(Status::failed_precondition(format!( + "{field} must be an integer" + ))); + } + value.to_string().parse::().map(Some).map_err(|_| { + Status::failed_precondition(format!("{field} must be representable as an i64")) + }) +} + +fn docker_tmpfs_option(option: &str) -> Result, Status> { + let option = option.trim(); + if option.is_empty() { + return Err(Status::failed_precondition( + "tmpfs options must not contain empty values", + )); + } + if let Some((key, value)) = option.split_once('=') { + let key = key.trim(); + let value = value.trim(); + if key.is_empty() || value.is_empty() { + return Err(Status::failed_precondition( + "tmpfs key=value options must include both key and value", + )); + } + Ok(vec![key.to_string(), value.to_string()]) + } else { + Ok(vec![option.to_string()]) + } +} + +fn validate_container_mount_target(target: &str) -> Result { + let target = normalize_container_mount_target(target); + if target.is_empty() { + return Err(Status::failed_precondition( + "mount target must not be empty", + )); + } + if target.as_bytes().contains(&0) { + return Err(Status::failed_precondition( + "mount target must not contain NUL bytes", + )); + } + if !target.starts_with('/') { + return Err(Status::failed_precondition( + "mount target must be an absolute container path", + )); + } + if target == "/" { + return Err(Status::failed_precondition( + "mount target must not be the container root", + )); + } + let path = Path::new(&target); + if path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(Status::failed_precondition( + "mount target must not contain '..'", + )); + } + if target == "/sandbox" { + return Err(Status::failed_precondition( + "mount target '/sandbox' is reserved for the OpenShell workspace", + )); + } + for reserved in [ + "/opt/openshell", + "/etc/openshell/auth", + "/etc/openshell/tls", + "/run/netns", + ] { + if path_is_or_under(&target, reserved) { + return Err(Status::failed_precondition(format!( + "mount target '{target}' conflicts with reserved OpenShell path '{reserved}'" + ))); + } + } + Ok(target) +} + +fn normalize_container_mount_target(target: &str) -> String { + let target = target.trim(); + if target == "/" { + return target.to_string(); + } + target.trim_end_matches('/').to_string() +} + +fn path_is_or_under(path: &str, parent: &str) -> bool { + path == parent + || path + .strip_prefix(parent) + .is_some_and(|rest| rest.starts_with('/')) +} + +fn proto_struct_to_json_object( + config: &prost_types::Struct, +) -> serde_json::Map { + config + .fields + .iter() + .map(|(key, value)| (key.clone(), proto_value_to_json(value))) + .collect() +} + +fn proto_value_to_json(value: &prost_types::Value) -> serde_json::Value { + match value.kind.as_ref() { + Some(prost_types::value::Kind::NumberValue(num)) => serde_json::Number::from_f64(*num) + .map_or(serde_json::Value::Null, serde_json::Value::Number), + Some(prost_types::value::Kind::StringValue(val)) => serde_json::Value::String(val.clone()), + Some(prost_types::value::Kind::BoolValue(val)) => serde_json::Value::Bool(*val), + Some(prost_types::value::Kind::StructValue(val)) => { + serde_json::Value::Object(proto_struct_to_json_object(val)) + } + Some(prost_types::value::Kind::ListValue(list)) => { + serde_json::Value::Array(list.values.iter().map(proto_value_to_json).collect()) + } + Some(prost_types::value::Kind::NullValue(_)) | None => serde_json::Value::Null, + } +} + fn build_binds( sandbox: &DriverSandbox, config: &DockerDriverRuntimeConfig, @@ -1746,6 +2110,7 @@ fn build_container_create_body( .as_ref() .ok_or_else(|| Status::invalid_argument("sandbox.spec.template is required"))?; let resource_limits = docker_resource_limits(template)?; + let user_mounts = docker_driver_mounts(template)?; let mut labels = template.labels.clone(); labels.insert( LABEL_MANAGED_BY.to_string(), @@ -1777,6 +2142,7 @@ fn build_container_create_body( pids_limit: docker_pids_limit(config.sandbox_pids_limit)?, device_requests: docker_gpu_device_requests(spec.gpu, &spec.gpu_device), binds: Some(build_binds(sandbox, config)?), + mounts: (!user_mounts.is_empty()).then_some(user_mounts), restart_policy: Some(RestartPolicy { name: Some(RestartPolicyNameEnum::UNLESS_STOPPED), maximum_retry_count: None, diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index 4a902a48b..3864217ea 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -78,6 +78,43 @@ fn runtime_config() -> DockerDriverRuntimeConfig { } } +fn json_struct(value: serde_json::Value) -> prost_types::Struct { + match json_value(value).kind { + Some(prost_types::value::Kind::StructValue(value)) => value, + _ => panic!("expected JSON object"), + } +} + +fn json_value(value: serde_json::Value) -> prost_types::Value { + match value { + serde_json::Value::Null => prost_types::Value { kind: None }, + serde_json::Value::Bool(value) => prost_types::Value { + kind: Some(prost_types::value::Kind::BoolValue(value)), + }, + serde_json::Value::Number(value) => prost_types::Value { + kind: value.as_f64().map(prost_types::value::Kind::NumberValue), + }, + serde_json::Value::String(value) => prost_types::Value { + kind: Some(prost_types::value::Kind::StringValue(value)), + }, + serde_json::Value::Array(values) => prost_types::Value { + kind: Some(prost_types::value::Kind::ListValue( + prost_types::ListValue { + values: values.into_iter().map(json_value).collect(), + }, + )), + }, + serde_json::Value::Object(values) => prost_types::Value { + kind: Some(prost_types::value::Kind::StructValue(prost_types::Struct { + fields: values + .into_iter() + .map(|(key, value)| (key, json_value(value))) + .collect(), + })), + }, + } +} + #[test] fn container_visible_endpoint_rewrites_loopback_hosts() { assert_eq!( @@ -522,6 +559,131 @@ fn build_binds_uses_docker_tls_directory() { ); } +#[test] +fn build_container_create_body_includes_driver_config_mounts() { + let mut sandbox = test_sandbox(); + let template = sandbox.spec.as_mut().unwrap().template.as_mut().unwrap(); + template.driver_config = Some(json_struct(serde_json::json!({ + "mounts": [ + { + "type": "volume", + "source": "work-nfs", + "target": "/sandbox/work", + "read_only": true, + "subpath": "project-a" + }, + { + "type": "tmpfs", + "target": "/sandbox/cache", + "options": ["nosuid", "size=1048576"], + "size_bytes": 1_048_576, + "mode": 511 + } + ] + }))); + + let body = build_container_create_body(&sandbox, &runtime_config()).unwrap(); + let mounts = body + .host_config + .unwrap() + .mounts + .expect("driver config mounts should be set"); + + assert_eq!(mounts.len(), 2); + assert_eq!(mounts[0].typ, Some(MountTypeEnum::VOLUME)); + assert_eq!(mounts[0].source.as_deref(), Some("work-nfs")); + assert_eq!(mounts[0].target.as_deref(), Some("/sandbox/work")); + assert_eq!(mounts[0].read_only, Some(true)); + assert_eq!( + mounts[0] + .volume_options + .as_ref() + .and_then(|options| options.subpath.as_deref()), + Some("project-a") + ); + assert_eq!(mounts[1].typ, Some(MountTypeEnum::TMPFS)); + assert_eq!(mounts[1].target.as_deref(), Some("/sandbox/cache")); + assert_eq!( + mounts[1] + .tmpfs_options + .as_ref() + .and_then(|options| options.size_bytes), + Some(1_048_576) + ); +} + +#[test] +fn driver_config_rejects_bind_mounts() { + let mut sandbox = test_sandbox(); + sandbox + .spec + .as_mut() + .unwrap() + .template + .as_mut() + .unwrap() + .driver_config = Some(json_struct(serde_json::json!({ + "mounts": [{ + "type": "bind", + "source": "/host/path", + "target": "/sandbox/host" + }] + }))); + + let err = build_container_create_body(&sandbox, &runtime_config()).unwrap_err(); + + assert_eq!(err.code(), tonic::Code::FailedPrecondition); + assert!(err.message().contains("invalid docker driver_config")); +} + +#[test] +fn driver_config_rejects_image_mounts() { + let mut sandbox = test_sandbox(); + sandbox + .spec + .as_mut() + .unwrap() + .template + .as_mut() + .unwrap() + .driver_config = Some(json_struct(serde_json::json!({ + "mounts": [{ + "type": "image", + "source": "ghcr.io/acme/tools:latest", + "target": "/opt/tools" + }] + }))); + + let err = build_container_create_body(&sandbox, &runtime_config()).unwrap_err(); + + assert_eq!(err.code(), tonic::Code::FailedPrecondition); + assert!(err.message().contains("invalid docker driver_config")); +} + +#[test] +fn driver_config_rejects_reserved_mount_targets() { + let mut sandbox = test_sandbox(); + sandbox + .spec + .as_mut() + .unwrap() + .template + .as_mut() + .unwrap() + .driver_config = Some(json_struct(serde_json::json!({ + "mounts": [{ + "type": "volume", + "source": "work-nfs", + "target": "/etc/openshell/auth/custom" + }] + }))); + + let err = build_container_create_body(&sandbox, &runtime_config()).unwrap_err(); + + assert_eq!(err.code(), tonic::Code::FailedPrecondition); + assert!(err.message().contains("reserved OpenShell path")); +} + #[test] fn build_environment_uses_token_file_without_raw_token_env() { let mut sandbox = test_sandbox(); diff --git a/crates/openshell-driver-podman/Cargo.toml b/crates/openshell-driver-podman/Cargo.toml index 4a1c8de83..d4d216544 100644 --- a/crates/openshell-driver-podman/Cargo.toml +++ b/crates/openshell-driver-podman/Cargo.toml @@ -26,6 +26,7 @@ hyper-util = { workspace = true } http-body-util = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +prost-types = { workspace = true } clap = { workspace = true } nix = { workspace = true } rustix = { workspace = true } diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index 77b42ba37..2f238821f 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -50,6 +50,43 @@ The container spec in `container.rs` sets these security-critical fields: The restricted agent child does not retain these supervisor privileges. +## Driver Config Mounts + +The gateway forwards the `podman` block from `--driver-config-json` to this +driver. The driver accepts user-supplied `mounts` entries with these Podman +mount types: + +- `volume`: mounts an existing Podman named volume. The driver validates that + the volume exists before provisioning and never creates or removes it. +- `tmpfs`: mounts an in-memory filesystem with optional `options`, + `size_bytes`, and `mode`. +- `image`: mounts an OCI image through Podman's image-volume API. The driver + pulls the image during provisioning using the sandbox image pull policy. + +Host bind mounts are intentionally not part of the driver-config schema. The +driver still uses internal bind mounts for OpenShell-owned token and TLS +material. + +Podman image and volume mounts do not support `subpath` in OpenShell driver +config. Mount targets must be absolute container paths and must not replace the +workspace root (`/sandbox`) or overlap OpenShell supervisor files, auth +material, TLS material, or `/run/netns`. + +Example NFS usage relies on Podman's named-volume support rather than a host +bind: + +```shell +podman volume create \ + --opt type=nfs \ + --opt o=addr=10.0.0.10,rw,nfsvers=4 \ + --opt device=:/exports/work \ + work-nfs + +openshell sandbox create \ + --driver-config-json '{"podman":{"mounts":[{"type":"volume","source":"work-nfs","target":"/sandbox/work"}]}}' \ + -- claude +``` + ### Capability Breakdown | Capability | Purpose | diff --git a/crates/openshell-driver-podman/src/client.rs b/crates/openshell-driver-podman/src/client.rs index 834fb21a2..a9d36b842 100644 --- a/crates/openshell-driver-podman/src/client.rs +++ b/crates/openshell-driver-podman/src/client.rs @@ -497,6 +497,23 @@ impl PodmanClient { } } + /// Return whether a named volume exists. Does not create the volume. + pub async fn volume_exists(&self, name: &str) -> Result { + validate_name(name)?; + match self + .request_json::( + hyper::Method::GET, + &format!("/libpod/volumes/{name}/json"), + None, + ) + .await + { + Ok(_) => Ok(true), + Err(PodmanApiError::NotFound(_)) => Ok(false), + Err(e) => Err(e), + } + } + // ── Network operations ─────────────────────────────────────────────── /// Create a bridge network with DNS enabled. Idempotent. diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index f3aceb9bf..50391196a 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -5,10 +5,11 @@ use crate::config::PodmanComputeConfig; use openshell_core::gpu::cdi_gpu_device_ids; -use openshell_core::proto::compute::v1::DriverSandbox; +use openshell_core::proto::compute::v1::{DriverSandbox, DriverSandboxTemplate}; use serde::Serialize; use serde_json::Value; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; +use std::path::Path; /// Returns `true` when `SELinux` is enabled (enforcing or permissive). /// @@ -57,6 +58,46 @@ const SUPERVISOR_MOUNT_DIR: &str = openshell_core::driver_utils::SUPERVISOR_CONT /// Full path to the supervisor binary inside sandbox containers. const SUPERVISOR_BINARY_PATH: &str = openshell_core::driver_utils::SUPERVISOR_CONTAINER_BINARY; +#[derive(Debug, Clone, Default, serde::Deserialize)] +#[serde(default, deny_unknown_fields)] +struct PodmanSandboxDriverConfig { + mounts: Vec, +} + +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +enum PodmanDriverMountConfig { + Volume { + source: String, + target: String, + #[serde(default)] + read_only: bool, + #[serde(default)] + subpath: Option, + }, + Tmpfs { + target: String, + #[serde(default)] + options: Vec, + #[serde(default)] + size_bytes: Option, + #[serde(default)] + mode: Option, + }, + Image { + source: String, + target: String, + #[serde(default = "default_true")] + read_only: bool, + #[serde(default)] + subpath: Option, + }, +} + +fn default_true() -> bool { + true +} + /// Build a Podman container name from the sandbox name. #[must_use] pub fn container_name(sandbox_name: &str) -> String { @@ -171,6 +212,13 @@ struct NamedVolume { options: Vec, } +#[derive(Default)] +struct PodmanUserMounts { + volumes: Vec, + image_volumes: Vec, + mounts: Vec, +} + #[derive(Serialize)] struct HealthConfig { test: Vec, @@ -401,6 +449,325 @@ fn build_devices(sandbox: &DriverSandbox) -> Option> { }) } +pub fn podman_driver_volume_mount_sources(sandbox: &DriverSandbox) -> Result, String> { + let template = sandbox + .spec + .as_ref() + .and_then(|spec| spec.template.as_ref()); + let Some(template) = template else { + return Ok(Vec::new()); + }; + let config = podman_driver_config(template)?; + Ok(config + .mounts + .into_iter() + .filter_map(|mount| match mount { + PodmanDriverMountConfig::Volume { source, .. } => Some(source.trim().to_string()), + _ => None, + }) + .collect()) +} + +pub fn podman_driver_image_mount_sources(sandbox: &DriverSandbox) -> Result, String> { + let template = sandbox + .spec + .as_ref() + .and_then(|spec| spec.template.as_ref()); + let Some(template) = template else { + return Ok(Vec::new()); + }; + let config = podman_driver_config(template)?; + Ok(config + .mounts + .into_iter() + .filter_map(|mount| match mount { + PodmanDriverMountConfig::Image { source, .. } => Some(source.trim().to_string()), + _ => None, + }) + .collect()) +} + +fn podman_user_mounts(sandbox: &DriverSandbox) -> Result { + let template = sandbox + .spec + .as_ref() + .and_then(|spec| spec.template.as_ref()); + let Some(template) = template else { + return Ok(PodmanUserMounts::default()); + }; + let config = podman_driver_config(template)?; + let mut result = PodmanUserMounts::default(); + for mount in config.mounts { + match mount { + PodmanDriverMountConfig::Volume { + source, + target, + read_only, + subpath, + } => { + reject_subpath(subpath.as_deref(), "podman volume mounts")?; + result.volumes.push(NamedVolume { + name: validate_mount_source(&source, "volume source")?, + dest: validate_container_mount_target(&target)?, + options: vec![if read_only { "ro" } else { "rw" }.to_string()], + }); + } + PodmanDriverMountConfig::Tmpfs { + target, + options, + size_bytes, + mode, + } => { + let mut options = validate_tmpfs_options(&options)?; + if options.is_empty() { + options.push("rw".to_string()); + } + if let Some(size_bytes) = + validate_optional_positive_integral_i64(size_bytes, "tmpfs size_bytes")? + { + options.push(format!("size={size_bytes}")); + } + if let Some(mode) = validate_optional_nonnegative_integral_i64(mode, "tmpfs mode")? + { + options.push(format!("mode={mode:o}")); + } + result.mounts.push(Mount { + kind: "tmpfs".into(), + source: "tmpfs".into(), + destination: validate_container_mount_target(&target)?, + options, + }); + } + PodmanDriverMountConfig::Image { + source, + target, + read_only, + subpath, + } => { + reject_subpath(subpath.as_deref(), "podman image mounts")?; + result.image_volumes.push(ImageVolume { + source: validate_mount_source(&source, "image source")?, + destination: validate_container_mount_target(&target)?, + rw: !read_only, + }); + } + } + } + Ok(result) +} + +fn podman_driver_config( + template: &DriverSandboxTemplate, +) -> Result { + let Some(config) = template.driver_config.as_ref() else { + return Ok(PodmanSandboxDriverConfig::default()); + }; + let json = Value::Object(proto_struct_to_json_object(config)); + let config: PodmanSandboxDriverConfig = serde_json::from_value(json) + .map_err(|err| format!("invalid podman driver_config: {err}"))?; + validate_podman_driver_mounts(&config.mounts)?; + Ok(config) +} + +fn validate_podman_driver_mounts(mounts: &[PodmanDriverMountConfig]) -> Result<(), String> { + let mut targets = HashSet::new(); + for mount in mounts { + let target = match mount { + PodmanDriverMountConfig::Volume { + source, + target, + subpath, + .. + } => { + validate_mount_source(source, "volume source")?; + reject_subpath(subpath.as_deref(), "podman volume mounts")?; + target + } + PodmanDriverMountConfig::Tmpfs { + target, + options, + size_bytes, + mode, + } => { + validate_tmpfs_options(options)?; + validate_optional_positive_integral_i64(*size_bytes, "tmpfs size_bytes")?; + validate_optional_nonnegative_integral_i64(*mode, "tmpfs mode")?; + target + } + PodmanDriverMountConfig::Image { + source, + target, + subpath, + .. + } => { + validate_mount_source(source, "image source")?; + reject_subpath(subpath.as_deref(), "podman image mounts")?; + target + } + }; + let target = validate_container_mount_target(target)?; + if !targets.insert(target.clone()) { + return Err(format!( + "duplicate podman driver_config mount target '{target}'" + )); + } + } + Ok(()) +} + +fn validate_mount_source(source: &str, field: &str) -> Result { + let source = source.trim(); + if source.is_empty() { + return Err(format!("{field} must not be empty")); + } + if source.as_bytes().contains(&0) { + return Err(format!("{field} must not contain NUL bytes")); + } + Ok(source.to_string()) +} + +fn reject_subpath(subpath: Option<&str>, mount_type: &str) -> Result<(), String> { + let Some(subpath) = subpath else { + return Ok(()); + }; + if subpath.trim().is_empty() { + return Err("mount subpath must not be empty".to_string()); + } + Err(format!("{mount_type} do not support subpath")) +} + +fn validate_optional_positive_integral_i64( + value: Option, + field: &str, +) -> Result, String> { + let Some(value) = validate_optional_integral_i64(value, field)? else { + return Ok(None); + }; + if value <= 0 { + return Err(format!("{field} must be positive")); + } + Ok(Some(value)) +} + +fn validate_optional_nonnegative_integral_i64( + value: Option, + field: &str, +) -> Result, String> { + let Some(value) = validate_optional_integral_i64(value, field)? else { + return Ok(None); + }; + if value < 0 { + return Err(format!("{field} must be zero or greater")); + } + Ok(Some(value)) +} + +fn validate_optional_integral_i64(value: Option, field: &str) -> Result, String> { + let Some(value) = value else { + return Ok(None); + }; + if !value.is_finite() || value.fract() != 0.0 { + return Err(format!("{field} must be an integer")); + } + value + .to_string() + .parse::() + .map(Some) + .map_err(|_| format!("{field} must be representable as an i64")) +} + +fn validate_tmpfs_options(options: &[String]) -> Result, String> { + options + .iter() + .map(|option| { + let option = option.trim(); + if option.is_empty() { + return Err("tmpfs options must not contain empty values".to_string()); + } + Ok(option.to_string()) + }) + .collect() +} + +fn validate_container_mount_target(target: &str) -> Result { + let target = normalize_container_mount_target(target); + if target.is_empty() { + return Err("mount target must not be empty".to_string()); + } + if target.as_bytes().contains(&0) { + return Err("mount target must not contain NUL bytes".to_string()); + } + if !target.starts_with('/') { + return Err("mount target must be an absolute container path".to_string()); + } + if target == "/" { + return Err("mount target must not be the container root".to_string()); + } + let path = Path::new(&target); + if path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err("mount target must not contain '..'".to_string()); + } + if target == "/sandbox" { + return Err("mount target '/sandbox' is reserved for the OpenShell workspace".to_string()); + } + for reserved in [ + "/opt/openshell", + "/etc/openshell/auth", + "/etc/openshell/tls", + "/run/netns", + ] { + if path_is_or_under(&target, reserved) { + return Err(format!( + "mount target '{target}' conflicts with reserved OpenShell path '{reserved}'" + )); + } + } + Ok(target) +} + +fn normalize_container_mount_target(target: &str) -> String { + let target = target.trim(); + if target == "/" { + return target.to_string(); + } + target.trim_end_matches('/').to_string() +} + +fn path_is_or_under(path: &str, parent: &str) -> bool { + path == parent + || path + .strip_prefix(parent) + .is_some_and(|rest| rest.starts_with('/')) +} + +fn proto_struct_to_json_object(config: &prost_types::Struct) -> serde_json::Map { + config + .fields + .iter() + .map(|(key, value)| (key.clone(), proto_value_to_json(value))) + .collect() +} + +fn proto_value_to_json(value: &prost_types::Value) -> Value { + match value.kind.as_ref() { + Some(prost_types::value::Kind::NumberValue(num)) => { + serde_json::Number::from_f64(*num).map_or(Value::Null, Value::Number) + } + Some(prost_types::value::Kind::StringValue(val)) => Value::String(val.clone()), + Some(prost_types::value::Kind::BoolValue(val)) => Value::Bool(*val), + Some(prost_types::value::Kind::StructValue(val)) => { + Value::Object(proto_struct_to_json_object(val)) + } + Some(prost_types::value::Kind::ListValue(list)) => { + Value::Array(list.values.iter().map(proto_value_to_json).collect()) + } + Some(prost_types::value::Kind::NullValue(_)) | None => Value::Null, + } +} + /// Build the Podman container creation JSON spec. #[cfg(test)] #[must_use] @@ -409,11 +776,21 @@ pub fn build_container_spec(sandbox: &DriverSandbox, config: &PodmanComputeConfi } #[must_use] +#[cfg(test)] pub fn build_container_spec_with_token( sandbox: &DriverSandbox, config: &PodmanComputeConfig, - token_host_path: Option<&std::path::Path>, + token_host_path: Option<&Path>, ) -> Value { + try_build_container_spec_with_token(sandbox, config, token_host_path) + .expect("valid Podman container spec") +} + +pub fn try_build_container_spec_with_token( + sandbox: &DriverSandbox, + config: &PodmanComputeConfig, + token_host_path: Option<&Path>, +) -> Result { let image = resolve_image(sandbox, config); let name = container_name(&sandbox.name); let vol = volume_name(&sandbox.id); @@ -422,6 +799,7 @@ pub fn build_container_spec_with_token( let labels = build_labels(sandbox); let resource_limits = build_resource_limits(sandbox, config); let devices = build_devices(sandbox); + let user_mounts = podman_user_mounts(sandbox)?; // Network configuration -- always bridge mode. // Matches libpod's network spec format `{name: {opts}}`; the unit-struct @@ -430,27 +808,33 @@ pub fn build_container_spec_with_token( let mut networks = BTreeMap::new(); networks.insert(config.network_name.clone(), NetworkAttachment {}); + let mut volumes = vec![NamedVolume { + name: vol, + dest: "/sandbox".into(), + options: vec!["rw".into()], + }]; + volumes.extend(user_mounts.volumes); + + let mut image_volumes = vec![ImageVolume { + source: config.supervisor_image.clone(), + destination: SUPERVISOR_MOUNT_DIR.into(), + rw: false, + }]; + image_volumes.extend(user_mounts.image_volumes); + let container_spec = ContainerSpec { name, image: image.to_string(), labels, env, - volumes: vec![NamedVolume { - name: vol, - dest: "/sandbox".into(), - options: vec!["rw".into()], - }], + volumes, // Side-load the supervisor binary from a standalone OCI image. // Podman resolves image_volumes at the libpod layer, mounting the // image's filesystem at the destination path without starting a // container from it. The supervisor image is FROM scratch with just // the binary at /openshell-sandbox, so it appears at // /opt/openshell/bin/openshell-sandbox. - image_volumes: vec![ImageVolume { - source: config.supervisor_image.clone(), - destination: SUPERVISOR_MOUNT_DIR.into(), - rw: false, - }], + image_volumes, hostname: format!("sandbox-{}", sandbox.name), // Override the image's ENTRYPOINT so the supervisor binary runs // directly. Sandbox images (e.g. the community base image) set @@ -621,6 +1005,7 @@ pub fn build_container_spec_with_token( options: ro, }); } + m.extend(user_mounts.mounts); m }, // Publish the SSH port with host_port=0 to get an ephemeral host port. @@ -633,7 +1018,7 @@ pub fn build_container_spec_with_token( }], }; - serde_json::to_value(container_spec).expect("ContainerSpec serialization cannot fail") + Ok(serde_json::to_value(container_spec).expect("ContainerSpec serialization cannot fail")) } fn hostadd_entries(config: &PodmanComputeConfig) -> Vec { @@ -712,6 +1097,43 @@ mod tests { static ENV_LOCK: std::sync::LazyLock> = std::sync::LazyLock::new(|| std::sync::Mutex::new(())); + fn json_struct(value: Value) -> prost_types::Struct { + match json_value(value).kind { + Some(prost_types::value::Kind::StructValue(value)) => value, + _ => panic!("expected JSON object"), + } + } + + fn json_value(value: Value) -> prost_types::Value { + match value { + Value::Null => prost_types::Value { kind: None }, + Value::Bool(value) => prost_types::Value { + kind: Some(prost_types::value::Kind::BoolValue(value)), + }, + Value::Number(value) => prost_types::Value { + kind: value.as_f64().map(prost_types::value::Kind::NumberValue), + }, + Value::String(value) => prost_types::Value { + kind: Some(prost_types::value::Kind::StringValue(value)), + }, + Value::Array(values) => prost_types::Value { + kind: Some(prost_types::value::Kind::ListValue( + prost_types::ListValue { + values: values.into_iter().map(json_value).collect(), + }, + )), + }, + Value::Object(values) => prost_types::Value { + kind: Some(prost_types::value::Kind::StructValue(prost_types::Struct { + fields: values + .into_iter() + .map(|(key, value)| (key, json_value(value))) + .collect(), + })), + }, + } + } + #[test] fn parse_cpu_millicore() { assert_eq!(parse_cpu_to_microseconds("500m"), Some(50_000)); @@ -1177,6 +1599,138 @@ mod tests { ); } + #[test] + fn container_spec_includes_driver_config_mounts() { + use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + + let mut sandbox = test_sandbox("test-id", "test-name"); + sandbox.spec = Some(DriverSandboxSpec { + template: Some(DriverSandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "mounts": [ + { + "type": "volume", + "source": "work-nfs", + "target": "/sandbox/work", + "read_only": true + }, + { + "type": "tmpfs", + "target": "/sandbox/cache", + "options": ["nosuid", "nodev"], + "size_bytes": 1_048_576, + "mode": 511 + }, + { + "type": "image", + "source": "ghcr.io/acme/tools:latest", + "target": "/opt/tools", + "read_only": true + } + ] + }))), + ..Default::default() + }), + ..Default::default() + }); + let config = test_config(); + let spec = build_container_spec(&sandbox, &config); + + let volumes = spec["volumes"] + .as_array() + .expect("volumes should be an array"); + assert!(volumes.iter().any(|volume| { + volume["name"].as_str() == Some("openshell-sandbox-test-id-workspace") + && volume["dest"].as_str() == Some("/sandbox") + })); + assert!(volumes.iter().any(|volume| { + volume["name"].as_str() == Some("work-nfs") + && volume["dest"].as_str() == Some("/sandbox/work") + && volume["options"].as_array().is_some_and(|options| { + options.iter().any(|option| option.as_str() == Some("ro")) + }) + })); + + let mounts = spec["mounts"] + .as_array() + .expect("mounts should be an array"); + assert!(mounts.iter().any(|mount| { + mount["type"].as_str() == Some("tmpfs") + && mount["destination"].as_str() == Some("/sandbox/cache") + && mount["options"].as_array().is_some_and(|options| { + options + .iter() + .any(|option| option.as_str() == Some("size=1048576")) + && options + .iter() + .any(|option| option.as_str() == Some("mode=777")) + }) + })); + + let image_volumes = spec["image_volumes"] + .as_array() + .expect("image_volumes should be an array"); + assert!(image_volumes.iter().any(|volume| { + volume["source"].as_str() == Some("ghcr.io/nvidia/openshell/supervisor:latest") + && volume["destination"].as_str() == Some("/opt/openshell/bin") + })); + assert!(image_volumes.iter().any(|volume| { + volume["source"].as_str() == Some("ghcr.io/acme/tools:latest") + && volume["destination"].as_str() == Some("/opt/tools") + && volume["rw"].as_bool() == Some(false) + })); + } + + #[test] + fn driver_config_rejects_bind_mounts() { + use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + + let mut sandbox = test_sandbox("test-id", "test-name"); + sandbox.spec = Some(DriverSandboxSpec { + template: Some(DriverSandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "mounts": [{ + "type": "bind", + "source": "/host/path", + "target": "/sandbox/host" + }] + }))), + ..Default::default() + }), + ..Default::default() + }); + let config = test_config(); + + let err = try_build_container_spec_with_token(&sandbox, &config, None).unwrap_err(); + + assert!(err.contains("invalid podman driver_config")); + } + + #[test] + fn driver_config_rejects_reserved_mount_targets() { + use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + + let mut sandbox = test_sandbox("test-id", "test-name"); + sandbox.spec = Some(DriverSandboxSpec { + template: Some(DriverSandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "mounts": [{ + "type": "volume", + "source": "work-nfs", + "target": "/etc/openshell/tls/custom" + }] + }))), + ..Default::default() + }), + ..Default::default() + }); + let config = test_config(); + + let err = try_build_container_spec_with_token(&sandbox, &config, None).unwrap_err(); + + assert!(err.contains("reserved OpenShell path")); + } + #[test] fn container_spec_uses_configured_host_gateway_ip() { let sandbox = test_sandbox("test-id", "test-name"); @@ -1279,7 +1833,7 @@ mod tests { ..Default::default() }); let config = test_config(); - let token_path = std::path::Path::new("/host/token.jwt"); + let token_path = Path::new("/host/token.jwt"); let spec = build_container_spec_with_token(&sandbox, &config, Some(token_path)); diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index 1358d8945..0d3dcccb0 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -277,12 +277,14 @@ impl PodmanComputeDriver { } /// Validate a sandbox before creation. - pub fn validate_sandbox_create( + pub async fn validate_sandbox_create( &self, sandbox: &DriverSandbox, ) -> Result<(), ComputeDriverError> { let gpu_requested = sandbox.spec.as_ref().is_some_and(|s| s.gpu); - Self::validate_gpu_request(gpu_requested) + Self::validate_gpu_request(gpu_requested)?; + self.validate_user_volume_mounts_available(sandbox).await?; + Ok(()) } fn validate_gpu_request(gpu_requested: bool) -> Result<(), ComputeDriverError> { @@ -294,6 +296,27 @@ impl PodmanComputeDriver { Ok(()) } + async fn validate_user_volume_mounts_available( + &self, + sandbox: &DriverSandbox, + ) -> Result<(), ComputeDriverError> { + let volumes = container::podman_driver_volume_mount_sources(sandbox) + .map_err(ComputeDriverError::Precondition)?; + for volume in volumes { + let exists = self + .client + .volume_exists(&volume) + .await + .map_err(ComputeDriverError::from)?; + if !exists { + return Err(ComputeDriverError::Precondition(format!( + "podman volume '{volume}' does not exist" + ))); + } + } + Ok(()) + } + /// Create a sandbox container. pub async fn create_sandbox(&self, sandbox: &DriverSandbox) -> Result<(), ComputeDriverError> { if sandbox.name.is_empty() { @@ -311,6 +334,7 @@ impl PodmanComputeDriver { // resources (volume), so we don't leave orphans when the name is // invalid. let name = validated_container_name(&sandbox.name)?; + self.validate_sandbox_create(sandbox).await?; let vol_name = container::volume_name(&sandbox.id); @@ -352,6 +376,16 @@ impl PodmanComputeDriver { .await .map_err(ComputeDriverError::from)?; + for image in container::podman_driver_image_mount_sources(sandbox) + .map_err(ComputeDriverError::Precondition)? + { + info!(image = %image, policy = %pull_policy, "Ensuring image mount source"); + self.client + .pull_image(&image, pull_policy) + .await + .map_err(ComputeDriverError::from)?; + } + // 2. Create workspace volume. if let Err(e) = self.client.create_volume(&vol_name).await { return Err(ComputeDriverError::from(e)); @@ -365,11 +399,12 @@ impl PodmanComputeDriver { }; // 3. Create container. - let spec = container::build_container_spec_with_token( + let spec = container::try_build_container_spec_with_token( sandbox, &self.config, token_host_path.as_deref(), - ); + ) + .map_err(ComputeDriverError::Precondition)?; match self.client.create_container(&spec).await { Ok(_) => {} Err(PodmanApiError::Conflict(_)) => { diff --git a/crates/openshell-driver-podman/src/grpc.rs b/crates/openshell-driver-podman/src/grpc.rs index 0c6015776..4840ee281 100644 --- a/crates/openshell-driver-podman/src/grpc.rs +++ b/crates/openshell-driver-podman/src/grpc.rs @@ -50,6 +50,7 @@ impl ComputeDriver for ComputeDriverService { .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; self.driver .validate_sandbox_create(&sandbox) + .await .map_err(Status::from)?; Ok(Response::new(ValidateSandboxCreateResponse {})) } diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index 229bb1bdb..eacac85ef 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -72,6 +72,42 @@ Select Docker with `compute_drivers = ["docker"]` in `[openshell.gateway]`. Conf For GPU-backed Docker sandboxes, configure Docker CDI before starting the gateway so OpenShell can detect the daemon capability. +### Docker Driver Config Mounts + +Docker driver config accepts user-supplied `volume` and `tmpfs` mounts. Host +bind mounts and image mounts are not accepted in Docker sandbox driver config. +Docker's [storage documentation](https://docs.docker.com/engine/storage/) +documents container storage mounts as volumes, bind mounts, tmpfs mounts, and +named pipes. OpenShell intentionally exposes only Docker-managed volumes and +tmpfs mounts for sandbox create. + +Use a `volume` mount for existing Docker named volumes. This includes NFS +volumes created with Docker's local volume driver: + +```shell +docker volume create \ + --driver local \ + --opt type=nfs \ + --opt o=addr=10.0.0.10,rw,nfsvers=4 \ + --opt device=:/exports/work \ + work-nfs + +openshell sandbox create \ + --driver-config-json '{"docker":{"mounts":[{"type":"volume","source":"work-nfs","target":"/sandbox/work","read_only":false}]}}' \ + -- claude +``` + +Docker mount schema: + +| Type | Fields | +|---|---| +| `volume` | `source`, `target`, optional `read_only` (`false` by default), optional `subpath`. The named volume must already exist. | +| `tmpfs` | `target`, optional `options`, optional `size_bytes`, optional `mode`. | + +OpenShell rejects mount targets that replace the workspace root, container root, +supervisor files, TLS material, authentication material, or network namespace +paths. Mounted paths remain subject to sandbox filesystem policy. + ## Podman Driver [Podman](https://podman.io/)-backed sandboxes run as rootless containers on the gateway host. Use Podman for Linux workstation workflows that avoid a rootful Docker daemon. @@ -84,6 +120,49 @@ Select Podman with `compute_drivers = ["podman"]` in `[openshell.gateway]`. Conf On macOS with `podman machine`, the driver uses gvproxy's host-loopback IP, `192.168.127.254`, for sandbox host aliases by default. Set `host_gateway_ip` only when your Podman machine uses a non-standard host-loopback address. On Linux, an empty `host_gateway_ip` keeps Podman's `host-gateway` resolver behavior. +### Podman Driver Config Mounts + +Podman driver config accepts user-supplied `volume`, `tmpfs`, and `image` +mounts. Host bind mounts are not accepted in Podman sandbox driver config. + +Use a `volume` mount for existing Podman named volumes. This includes NFS +volumes created with Podman's local volume driver: + +```shell +podman volume create \ + --opt type=nfs \ + --opt o=addr=10.0.0.10,rw,nfsvers=4 \ + --opt device=:/exports/work \ + work-nfs + +openshell sandbox create \ + --driver-config-json '{"podman":{"mounts":[{"type":"volume","source":"work-nfs","target":"/sandbox/work","read_only":false}]}}' \ + -- claude +``` + +Use an `image` mount to mount an OCI image filesystem through Podman's +image-volume API: + +```shell +openshell sandbox create \ + --driver-config-json '{"podman":{"mounts":[{"type":"image","source":"ghcr.io/acme/tools:latest","target":"/opt/tools"}]}}' \ + -- claude +``` + +Podman mount schema: + +| Type | Fields | +|---|---| +| `volume` | `source`, `target`, optional `read_only` (`false` by default). The named volume must already exist. | +| `tmpfs` | `target`, optional `options`, optional `size_bytes`, optional `mode`. | +| `image` | `source`, `target`, optional `read_only` (`true` by default). | + +Podman `volume` and `image` mounts do not support `subpath` in OpenShell driver +config. OpenShell rejects mount targets that replace the workspace root, +container root, supervisor files, TLS material, authentication material, or +network namespace paths. Mounted paths remain subject to sandbox filesystem +policy. + ## MicroVM Driver MicroVM-backed sandboxes run inside VM-backed isolation instead of a container boundary. Use MicroVM when workloads need a VM boundary instead of a local container boundary. @@ -115,6 +194,13 @@ Configure VM driver values such as `grpc_endpoint`, `driver_dir`, `state_dir`, ` The gateway starts `openshell-driver-vm` over a private Unix socket and passes its process ID so the driver can reject unexpected local clients. The driver's standalone TCP listener is disabled unless `--allow-unauthenticated-tcp` is set for local development. +### VM Driver Config Mounts + +The VM driver does not currently support user-supplied mounts through +`--driver-config-json`. Each sandbox VM boots from a cached read-only image disk +plus a per-sandbox writable overlay disk, and the driver owns the guest mount +layout. + ### Local image resolution The VM driver resolves sandbox images from a local container engine before falling back to registry pulls. It tries Docker first, then falls back to the Podman socket (Docker-compatible API). On Linux with Podman, enable the API socket so the driver can find local images: @@ -156,6 +242,18 @@ For maintainer-level implementation details, refer to the [Kubernetes driver REA The Kubernetes driver creates namespaced `agents.x-k8s.io/v1alpha1` `Sandbox` resources from the Kubernetes SIG Apps [agent-sandbox](https://github.com/kubernetes-sigs/agent-sandbox) project. The Agent Sandbox controller turns those resources into sandbox pods and related storage. +### Kubernetes Driver Config Mounts + +The Kubernetes driver does not currently support Docker or Podman-style +`mounts` entries in `--driver-config-json`. Kubernetes driver config is limited +to pod scheduling and agent resource fields such as `pod.node_selector`, +`pod.tolerations`, `pod.runtime_class_name`, `pod.priority_class_name`, +`containers.agent.resources.requests`, and `containers.agent.resources.limits`. + +Kubernetes sandbox workspace storage uses Agent Sandbox +`volumeClaimTemplates`. The gateway injects the default workspace claim when the +template does not provide one. + `Sandbox.spec.volumeClaimTemplates` is immutable after creation. To change storage configuration, delete the sandbox and create a new one with the updated spec. diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index 6ca24cccd..9396d09cd 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -62,6 +62,29 @@ Use this only for driver-specific fields that do not have a stable CLI flag. Prefer stable flags such as `--cpu`, `--memory`, and `--gpu` when they cover the same behavior. +Docker accepts `volume` and `tmpfs` mounts in driver config. Podman accepts +`volume`, `tmpfs`, and `image` mounts. Host bind mounts are not supported +through `--driver-config-json`. Create NFS storage as a Docker or Podman named +volume first, then mount that existing volume into the sandbox: + +```shell +docker volume create \ + --driver local \ + --opt type=nfs \ + --opt o=addr=10.0.0.10,rw,nfsvers=4 \ + --opt device=:/exports/work \ + work-nfs + +openshell sandbox create \ + --driver-config-json '{"docker":{"mounts":[{"type":"volume","source":"work-nfs","target":"/sandbox/work"}]}}' \ + -- claude +``` + +Use the `podman` envelope key and `podman volume create` for Podman-backed +gateways. The volume must already exist before sandbox creation. Mounted paths +remain subject to the sandbox filesystem policy, so allow the mount target when +the agent needs access outside the default policy. + ### GPU Resources To request GPU resources, add `--gpu`: From 6333d22372c22e7095350a40b9b98e29f94299f4 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 5 Jun 2026 14:04:57 -0700 Subject: [PATCH 2/9] docs(drivers): trim mount docs Signed-off-by: Drew Newberry --- docs/reference/sandbox-compute-drivers.mdx | 19 ------------------ docs/sandboxes/manage-sandboxes.mdx | 23 ---------------------- 2 files changed, 42 deletions(-) diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index eacac85ef..a15833497 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -194,13 +194,6 @@ Configure VM driver values such as `grpc_endpoint`, `driver_dir`, `state_dir`, ` The gateway starts `openshell-driver-vm` over a private Unix socket and passes its process ID so the driver can reject unexpected local clients. The driver's standalone TCP listener is disabled unless `--allow-unauthenticated-tcp` is set for local development. -### VM Driver Config Mounts - -The VM driver does not currently support user-supplied mounts through -`--driver-config-json`. Each sandbox VM boots from a cached read-only image disk -plus a per-sandbox writable overlay disk, and the driver owns the guest mount -layout. - ### Local image resolution The VM driver resolves sandbox images from a local container engine before falling back to registry pulls. It tries Docker first, then falls back to the Podman socket (Docker-compatible API). On Linux with Podman, enable the API socket so the driver can find local images: @@ -242,18 +235,6 @@ For maintainer-level implementation details, refer to the [Kubernetes driver REA The Kubernetes driver creates namespaced `agents.x-k8s.io/v1alpha1` `Sandbox` resources from the Kubernetes SIG Apps [agent-sandbox](https://github.com/kubernetes-sigs/agent-sandbox) project. The Agent Sandbox controller turns those resources into sandbox pods and related storage. -### Kubernetes Driver Config Mounts - -The Kubernetes driver does not currently support Docker or Podman-style -`mounts` entries in `--driver-config-json`. Kubernetes driver config is limited -to pod scheduling and agent resource fields such as `pod.node_selector`, -`pod.tolerations`, `pod.runtime_class_name`, `pod.priority_class_name`, -`containers.agent.resources.requests`, and `containers.agent.resources.limits`. - -Kubernetes sandbox workspace storage uses Agent Sandbox -`volumeClaimTemplates`. The gateway injects the default workspace claim when the -template does not provide one. - `Sandbox.spec.volumeClaimTemplates` is immutable after creation. To change storage configuration, delete the sandbox and create a new one with the updated spec. diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index 9396d09cd..6ca24cccd 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -62,29 +62,6 @@ Use this only for driver-specific fields that do not have a stable CLI flag. Prefer stable flags such as `--cpu`, `--memory`, and `--gpu` when they cover the same behavior. -Docker accepts `volume` and `tmpfs` mounts in driver config. Podman accepts -`volume`, `tmpfs`, and `image` mounts. Host bind mounts are not supported -through `--driver-config-json`. Create NFS storage as a Docker or Podman named -volume first, then mount that existing volume into the sandbox: - -```shell -docker volume create \ - --driver local \ - --opt type=nfs \ - --opt o=addr=10.0.0.10,rw,nfsvers=4 \ - --opt device=:/exports/work \ - work-nfs - -openshell sandbox create \ - --driver-config-json '{"docker":{"mounts":[{"type":"volume","source":"work-nfs","target":"/sandbox/work"}]}}' \ - -- claude -``` - -Use the `podman` envelope key and `podman volume create` for Podman-backed -gateways. The volume must already exist before sandbox creation. Mounted paths -remain subject to the sandbox filesystem policy, so allow the mount target when -the agent needs access outside the default policy. - ### GPU Resources To request GPU resources, add `--gpu`: From e6ce137182ee0720aeefb2896283e1f094818525 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 5 Jun 2026 14:45:18 -0700 Subject: [PATCH 3/9] test(e2e): cover local driver volume mounts Signed-off-by: Drew Newberry --- e2e/rust/Cargo.toml | 10 +- e2e/rust/tests/driver_config_volume.rs | 159 +++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 e2e/rust/tests/driver_config_volume.rs diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index 31c6a3347..2ff799c98 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -23,11 +23,12 @@ e2e = [] # `server.hostGatewayIP` is set, so `e2e-kubernetes` does NOT imply this and # the helm wrapper opts in explicitly when it has resolved an IP. e2e-host-gateway = ["e2e"] -e2e-docker = ["e2e", "e2e-host-gateway"] +e2e-local-container-driver = ["e2e"] +e2e-docker = ["e2e", "e2e-host-gateway", "e2e-local-container-driver"] e2e-gpu = ["e2e"] e2e-docker-gpu = ["e2e-docker", "e2e-gpu"] e2e-kubernetes = ["e2e"] -e2e-podman = ["e2e", "e2e-host-gateway"] +e2e-podman = ["e2e", "e2e-host-gateway", "e2e-local-container-driver"] e2e-podman-gpu = ["e2e-podman", "e2e-gpu"] e2e-vm = ["e2e"] @@ -41,6 +42,11 @@ name = "docker_preflight" path = "tests/docker_preflight.rs" required-features = ["e2e-docker"] +[[test]] +name = "driver_config_volume" +path = "tests/driver_config_volume.rs" +required-features = ["e2e-local-container-driver"] + [[test]] name = "gateway_resume" path = "tests/gateway_resume.rs" diff --git a/e2e/rust/tests/driver_config_volume.rs b/e2e/rust/tests/driver_config_volume.rs new file mode 100644 index 000000000..7124cb0ab --- /dev/null +++ b/e2e/rust/tests/driver_config_volume.rs @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "e2e-local-container-driver")] + +use std::process::Output; +use std::time::{SystemTime, UNIX_EPOCH}; + +use openshell_e2e::harness::container::{ContainerEngine, e2e_driver}; +use openshell_e2e::harness::sandbox::SandboxGuard; + +const TEST_IMAGE: &str = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest"; +const VOLUME_TARGET: &str = "/sandbox/e2e-volume"; + +struct VolumeGuard { + engine: ContainerEngine, + name: String, +} + +impl VolumeGuard { + fn create(engine: ContainerEngine, driver: &str) -> Result { + let name = unique_volume_name(driver); + run_engine(&engine, &["volume", "create", &name])?; + Ok(Self { engine, name }) + } +} + +impl Drop for VolumeGuard { + fn drop(&mut self) { + let _ = self + .engine + .command() + .args(["volume", "rm", "-f", &self.name]) + .output(); + } +} + +#[tokio::test] +async fn sandbox_mounts_existing_driver_config_volume() { + let driver = e2e_driver().expect("OPENSHELL_E2E_DRIVER must be set by the e2e wrapper"); + assert!( + matches!(driver.as_str(), "docker" | "podman"), + "driver_config volume e2e requires docker or podman, got {driver}" + ); + + let engine = ContainerEngine::from_env(); + let volume = VolumeGuard::create(engine, &driver).expect("create named test volume"); + + seed_volume(&volume).expect("seed named test volume"); + + let driver_config = format!( + r#"{{"{driver}":{{"mounts":[{{"type":"volume","source":"{}","target":"{VOLUME_TARGET}","read_only":false}}]}}}}"#, + volume.name + ); + let mut sandbox = SandboxGuard::create(&[ + "--no-keep", + "--driver-config-json", + &driver_config, + "--", + "sh", + "-lc", + "set -eu; test \"$(cat /sandbox/e2e-volume/input.txt)\" = host-volume-ok; printf sandbox-volume-ok > /sandbox/e2e-volume/output.txt; cat /sandbox/e2e-volume/output.txt", + ]) + .await + .expect("sandbox create with driver-config volume"); + + assert!( + sandbox.create_output.contains("sandbox-volume-ok"), + "sandbox should read and write the mounted volume:\n{}", + sandbox.create_output + ); + + sandbox.cleanup().await; + verify_volume(&volume).expect("verify sandbox wrote to named test volume"); +} + +fn seed_volume(volume: &VolumeGuard) -> Result<(), String> { + run_engine( + &volume.engine, + &[ + "run", + "--rm", + "--user", + "0:0", + "--volume", + &format!("{}:/vol", volume.name), + "--entrypoint", + "sh", + TEST_IMAGE, + "-lc", + "set -eu; chmod 0777 /vol; printf host-volume-ok > /vol/input.txt", + ], + )?; + Ok(()) +} + +fn verify_volume(volume: &VolumeGuard) -> Result<(), String> { + let output = run_engine( + &volume.engine, + &[ + "run", + "--rm", + "--user", + "0:0", + "--volume", + &format!("{}:/vol:ro", volume.name), + "--entrypoint", + "sh", + TEST_IMAGE, + "-lc", + "set -eu; test \"$(cat /vol/input.txt)\" = host-volume-ok; test \"$(cat /vol/output.txt)\" = sandbox-volume-ok; echo volume-ok", + ], + )?; + if !output.contains("volume-ok") { + return Err(format!( + "volume verification did not print expected marker:\n{output}" + )); + } + Ok(()) +} + +fn run_engine(engine: &ContainerEngine, args: &[&str]) -> Result { + let output = engine + .command() + .args(args) + .output() + .map_err(|err| format!("spawn {} {}: {err}", engine.name(), args.join(" ")))?; + engine_output(engine, args, &output) +} + +fn engine_output( + engine: &ContainerEngine, + args: &[&str], + output: &Output, +) -> Result { + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let combined = format!("{stdout}{stderr}"); + if output.status.success() { + return Ok(combined); + } + Err(format!( + "{} {} failed (exit {:?}):\n{combined}", + engine.name(), + args.join(" "), + output.status.code() + )) +} + +fn unique_volume_name(driver: &str) -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + format!( + "openshell-e2e-driver-config-volume-{driver}-{}-{nanos}", + std::process::id() + ) +} From 17f2ea8db8f39568733676ba37dd429b26803d4d Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Sun, 7 Jun 2026 21:14:19 -0700 Subject: [PATCH 4/9] fix(podman): satisfy linux clippy lint Signed-off-by: Drew Newberry --- crates/openshell-driver-podman/src/container.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 50391196a..54373f2ac 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -22,7 +22,7 @@ use std::path::Path; /// exist and this returns `false`, leaving mount options unchanged. #[cfg(target_os = "linux")] fn is_selinux_enabled() -> bool { - std::path::Path::new("/sys/fs/selinux").is_dir() + Path::new("/sys/fs/selinux").is_dir() } #[cfg(not(target_os = "linux"))] From b34350c62fc93ca5236c8f51b93481720dfb3039 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 8 Jun 2026 11:15:24 -0700 Subject: [PATCH 5/9] feat(drivers): gate bind mounts behind gateway config --- architecture/compute-runtimes.md | 5 +- crates/openshell-driver-docker/README.md | 6 +- crates/openshell-driver-docker/src/lib.rs | 65 +++++++- crates/openshell-driver-docker/src/tests.rs | 75 +++++++++- crates/openshell-driver-podman/README.md | 9 +- crates/openshell-driver-podman/src/config.rs | 7 + .../openshell-driver-podman/src/container.rs | 140 ++++++++++++++++-- crates/openshell-driver-podman/src/driver.rs | 10 +- crates/openshell-driver-podman/src/main.rs | 1 + crates/openshell-server/src/cli.rs | 14 ++ crates/openshell-server/src/lib.rs | 15 ++ docs/reference/gateway-config.mdx | 2 + docs/reference/sandbox-compute-drivers.mdx | 42 +++++- e2e/rust/tests/driver_config_volume.rs | 66 +++++++++ e2e/with-docker-gateway.sh | 1 + e2e/with-podman-gateway.sh | 1 + 16 files changed, 424 insertions(+), 35 deletions(-) diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index b024a8eea..0019b500c 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -43,8 +43,9 @@ but currently ignores them. Docker and Podman also accept per-sandbox driver-config mounts for existing runtime-managed named volumes and tmpfs mounts. Podman additionally accepts image mounts through its image-volume API. User-supplied host bind mounts are -excluded from the driver-config contract; bind mounts remain reserved for -driver-owned supervisor, token, and TLS material. +available only when explicitly enabled in the active local driver table of +`gateway.toml`; driver-owned supervisor, token, and TLS bind mounts stay +reserved. Kubernetes deployments may set an AppArmor profile on sandbox agent containers through the driver configuration. The Helm chart defaults sandbox agents to diff --git a/crates/openshell-driver-docker/README.md b/crates/openshell-driver-docker/README.md index d71b18a84..22a82c753 100644 --- a/crates/openshell-driver-docker/README.md +++ b/crates/openshell-driver-docker/README.md @@ -42,15 +42,19 @@ The gateway forwards the `docker` block from `--driver-config-json` to this driver. The driver accepts user-supplied `mounts` entries with these Docker mount types: +- `bind`: mounts an absolute host path when `[openshell.drivers.docker]` + has `enable_bind_mounts = true`. - `volume`: mounts an existing Docker named volume. The driver validates that the volume exists before provisioning and never creates or removes it. - `tmpfs`: mounts an in-memory filesystem with optional `options`, `size_bytes`, and `mode`. -Host bind mounts and image mounts are intentionally not part of the Docker +Host bind mounts are disabled by default because they expose gateway host +paths to sandbox requests. Image mounts are not part of the Docker driver-config schema. The driver still uses internal bind mounts for OpenShell-owned supervisor, token, and TLS material. +Docker `bind` mounts accept `source`, `target`, and optional `read_only`. Docker `volume` mounts may include `subpath`. Mount targets must be absolute container paths and must not replace the workspace root (`/sandbox`) or overlap OpenShell supervisor files, auth material, TLS material, or `/run/netns`. diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index e96324d23..c1b4c5f50 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -177,6 +177,11 @@ pub struct DockerComputeConfig { /// /// Set to `0` to leave Docker's runtime/default PID limit unchanged. pub sandbox_pids_limit: i64, + + /// Allow sandbox requests to attach host bind mounts through + /// `template.driver_config`. + #[serde(default)] + pub enable_bind_mounts: bool, } impl Default for DockerComputeConfig { @@ -195,6 +200,7 @@ impl Default for DockerComputeConfig { host_gateway_ip: String::new(), ssh_socket_path: "/run/openshell/ssh.sock".to_string(), sandbox_pids_limit: DEFAULT_SANDBOX_PIDS_LIMIT, + enable_bind_mounts: false, } } } @@ -222,6 +228,7 @@ struct DockerDriverRuntimeConfig { daemon_version: String, supports_gpu: bool, sandbox_pids_limit: i64, + enable_bind_mounts: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -268,6 +275,12 @@ struct DockerSandboxDriverConfig { #[derive(Debug, Clone, serde::Deserialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] enum DockerDriverMountConfig { + Bind { + source: String, + target: String, + #[serde(default)] + read_only: bool, + }, Volume { source: String, target: String, @@ -356,6 +369,7 @@ impl DockerComputeDriver { daemon_version: version.version.unwrap_or_else(|| "unknown".to_string()), supports_gpu, sandbox_pids_limit: docker_config.sandbox_pids_limit, + enable_bind_mounts: docker_config.enable_bind_mounts, }, events: broadcast::channel(WATCH_BUFFER).0, pending: Arc::new(Mutex::new(HashMap::new())), @@ -420,7 +434,7 @@ impl DockerComputeDriver { )); } - let _ = docker_driver_config(template)?; + let _ = docker_driver_config(template, config.enable_bind_mounts)?; let _ = docker_resource_limits(template)?; Ok(()) } @@ -457,7 +471,7 @@ impl DockerComputeDriver { .as_ref() .and_then(|spec| spec.template.as_ref()) .ok_or_else(|| Status::invalid_argument("sandbox.spec.template is required"))?; - let config = docker_driver_config(template)?; + let config = docker_driver_config(template, self.config.enable_bind_mounts)?; for mount in config.mounts { if let DockerDriverMountConfig::Volume { source, .. } = mount { match self.docker.inspect_volume(source.trim()).await { @@ -1568,6 +1582,7 @@ fn attach_docker_progress_metadata( fn docker_driver_config( template: &DriverSandboxTemplate, + enable_bind_mounts: bool, ) -> Result { let Some(config) = template.driver_config.as_ref() else { return Ok(DockerSandboxDriverConfig::default()); @@ -1577,17 +1592,31 @@ fn docker_driver_config( let config: DockerSandboxDriverConfig = serde_json::from_value(json).map_err(|err| { Status::failed_precondition(format!("invalid docker driver_config: {err}")) })?; - validate_docker_driver_mounts(&config.mounts)?; + validate_docker_driver_mounts(&config.mounts, enable_bind_mounts)?; Ok(config) } -fn docker_driver_mounts(template: &DriverSandboxTemplate) -> Result, Status> { - let config = docker_driver_config(template)?; +fn docker_driver_mounts( + template: &DriverSandboxTemplate, + enable_bind_mounts: bool, +) -> Result, Status> { + let config = docker_driver_config(template, enable_bind_mounts)?; config.mounts.iter().map(docker_mount_from_config).collect() } fn docker_mount_from_config(config: &DockerDriverMountConfig) -> Result { match config { + DockerDriverMountConfig::Bind { + source, + target, + read_only, + } => Ok(Mount { + typ: Some(MountTypeEnum::BIND), + source: Some(validate_absolute_mount_source(source, "bind source")?), + target: Some(validate_container_mount_target(target)?), + read_only: Some(*read_only), + ..Default::default() + }), DockerDriverMountConfig::Volume { source, target, @@ -1637,10 +1666,22 @@ fn docker_mount_from_config(config: &DockerDriverMountConfig) -> Result Result<(), Status> { +fn validate_docker_driver_mounts( + mounts: &[DockerDriverMountConfig], + enable_bind_mounts: bool, +) -> Result<(), Status> { let mut targets = HashSet::new(); for mount in mounts { let target = match mount { + DockerDriverMountConfig::Bind { source, target, .. } => { + if !enable_bind_mounts { + return Err(Status::failed_precondition( + "docker bind mounts require enable_bind_mounts = true in [openshell.drivers.docker]", + )); + } + validate_absolute_mount_source(source, "bind source")?; + target + } DockerDriverMountConfig::Volume { source, target, @@ -1677,6 +1718,16 @@ fn validate_docker_driver_mounts(mounts: &[DockerDriverMountConfig]) -> Result<( Ok(()) } +fn validate_absolute_mount_source(source: &str, field: &str) -> Result { + let source = validate_mount_source(source, field)?; + if !Path::new(&source).is_absolute() { + return Err(Status::failed_precondition(format!( + "{field} must be an absolute host path" + ))); + } + Ok(source) +} + fn validate_mount_source(source: &str, field: &str) -> Result { let source = source.trim(); if source.is_empty() { @@ -2110,7 +2161,7 @@ fn build_container_create_body( .as_ref() .ok_or_else(|| Status::invalid_argument("sandbox.spec.template is required"))?; let resource_limits = docker_resource_limits(template)?; - let user_mounts = docker_driver_mounts(template)?; + let user_mounts = docker_driver_mounts(template, config.enable_bind_mounts)?; let mut labels = template.labels.clone(); labels.insert( LABEL_MANAGED_BY.to_string(), diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index 3864217ea..61b93742c 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -75,6 +75,7 @@ fn runtime_config() -> DockerDriverRuntimeConfig { daemon_version: "28.0.0".to_string(), supports_gpu: false, sandbox_pids_limit: DEFAULT_SANDBOX_PIDS_LIMIT, + enable_bind_mounts: false, } } @@ -465,6 +466,12 @@ fn docker_pids_limit_uses_driver_default_and_allows_runtime_inherit() { assert!(docker_pids_limit(-1).is_err()); } +#[test] +fn docker_compute_config_disables_bind_mounts_by_default() { + let cfg = DockerComputeConfig::default(); + assert!(!cfg.enable_bind_mounts); +} + #[test] fn container_create_body_sets_driver_owned_pids_limit() { let body = build_container_create_body(&test_sandbox(), &runtime_config()).unwrap(); @@ -613,7 +620,7 @@ fn build_container_create_body_includes_driver_config_mounts() { } #[test] -fn driver_config_rejects_bind_mounts() { +fn driver_config_rejects_bind_mounts_unless_enabled() { let mut sandbox = test_sandbox(); sandbox .spec @@ -633,7 +640,71 @@ fn driver_config_rejects_bind_mounts() { let err = build_container_create_body(&sandbox, &runtime_config()).unwrap_err(); assert_eq!(err.code(), tonic::Code::FailedPrecondition); - assert!(err.message().contains("invalid docker driver_config")); + assert!(err.message().contains("enable_bind_mounts = true")); +} + +#[test] +fn build_container_create_body_includes_bind_mounts_when_enabled() { + let mut sandbox = test_sandbox(); + sandbox + .spec + .as_mut() + .unwrap() + .template + .as_mut() + .unwrap() + .driver_config = Some(json_struct(serde_json::json!({ + "mounts": [{ + "type": "bind", + "source": "/host/path", + "target": "/sandbox/host", + "read_only": true + }] + }))); + let mut config = runtime_config(); + config.enable_bind_mounts = true; + + let body = build_container_create_body(&sandbox, &config).unwrap(); + let mounts = body + .host_config + .unwrap() + .mounts + .expect("driver config mounts should be set"); + + assert_eq!(mounts.len(), 1); + assert_eq!(mounts[0].typ, Some(MountTypeEnum::BIND)); + assert_eq!(mounts[0].source.as_deref(), Some("/host/path")); + assert_eq!(mounts[0].target.as_deref(), Some("/sandbox/host")); + assert_eq!(mounts[0].read_only, Some(true)); +} + +#[test] +fn driver_config_rejects_relative_bind_sources_when_enabled() { + let mut sandbox = test_sandbox(); + sandbox + .spec + .as_mut() + .unwrap() + .template + .as_mut() + .unwrap() + .driver_config = Some(json_struct(serde_json::json!({ + "mounts": [{ + "type": "bind", + "source": "relative/path", + "target": "/sandbox/host" + }] + }))); + let mut config = runtime_config(); + config.enable_bind_mounts = true; + + let err = build_container_create_body(&sandbox, &config).unwrap_err(); + + assert_eq!(err.code(), tonic::Code::FailedPrecondition); + assert!( + err.message() + .contains("bind source must be an absolute host path") + ); } #[test] diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index 2f238821f..6dc62da06 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -56,6 +56,8 @@ The gateway forwards the `podman` block from `--driver-config-json` to this driver. The driver accepts user-supplied `mounts` entries with these Podman mount types: +- `bind`: mounts an absolute host path when `[openshell.drivers.podman]` + has `enable_bind_mounts = true`. - `volume`: mounts an existing Podman named volume. The driver validates that the volume exists before provisioning and never creates or removes it. - `tmpfs`: mounts an in-memory filesystem with optional `options`, @@ -63,10 +65,11 @@ mount types: - `image`: mounts an OCI image through Podman's image-volume API. The driver pulls the image during provisioning using the sandbox image pull policy. -Host bind mounts are intentionally not part of the driver-config schema. The -driver still uses internal bind mounts for OpenShell-owned token and TLS -material. +Host bind mounts are disabled by default because they expose gateway host paths +to sandbox requests. The driver still uses internal bind mounts for +OpenShell-owned token and TLS material. +Podman `bind` mounts accept `source`, `target`, and optional `read_only`. Podman image and volume mounts do not support `subpath` in OpenShell driver config. Mount targets must be absolute container paths and must not replace the workspace root (`/sandbox`) or overlap OpenShell supervisor files, auth diff --git a/crates/openshell-driver-podman/src/config.rs b/crates/openshell-driver-podman/src/config.rs index 79a463b23..e21c66176 100644 --- a/crates/openshell-driver-podman/src/config.rs +++ b/crates/openshell-driver-podman/src/config.rs @@ -122,6 +122,10 @@ pub struct PodmanComputeConfig { /// /// Set to `0` to leave Podman's runtime/default PID limit unchanged. pub sandbox_pids_limit: i64, + /// Allow sandbox requests to attach host bind mounts through + /// `template.driver_config`. + #[serde(default)] + pub enable_bind_mounts: bool, } impl PodmanComputeConfig { @@ -246,6 +250,7 @@ impl Default for PodmanComputeConfig { guest_tls_cert: None, guest_tls_key: None, sandbox_pids_limit: DEFAULT_SANDBOX_PIDS_LIMIT, + enable_bind_mounts: false, } } } @@ -267,6 +272,7 @@ impl std::fmt::Debug for PodmanComputeConfig { .field("guest_tls_cert", &self.guest_tls_cert) .field("guest_tls_key", &self.guest_tls_key) .field("sandbox_pids_limit", &self.sandbox_pids_limit) + .field("enable_bind_mounts", &self.enable_bind_mounts) .finish() } } @@ -312,6 +318,7 @@ mod tests { fn default_config_sets_driver_owned_pids_limit() { let cfg = PodmanComputeConfig::default(); assert_eq!(cfg.sandbox_pids_limit, DEFAULT_SANDBOX_PIDS_LIMIT); + assert!(!cfg.enable_bind_mounts); assert!(cfg.validate_runtime_limits().is_ok()); } diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 54373f2ac..f2783181b 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -67,6 +67,12 @@ struct PodmanSandboxDriverConfig { #[derive(Debug, Clone, serde::Deserialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] enum PodmanDriverMountConfig { + Bind { + source: String, + target: String, + #[serde(default)] + read_only: bool, + }, Volume { source: String, target: String, @@ -449,7 +455,10 @@ fn build_devices(sandbox: &DriverSandbox) -> Option> { }) } -pub fn podman_driver_volume_mount_sources(sandbox: &DriverSandbox) -> Result, String> { +pub fn podman_driver_volume_mount_sources( + sandbox: &DriverSandbox, + enable_bind_mounts: bool, +) -> Result, String> { let template = sandbox .spec .as_ref() @@ -457,7 +466,7 @@ pub fn podman_driver_volume_mount_sources(sandbox: &DriverSandbox) -> Result Result Result, String> { +pub fn podman_driver_image_mount_sources( + sandbox: &DriverSandbox, + enable_bind_mounts: bool, +) -> Result, String> { let template = sandbox .spec .as_ref() @@ -476,7 +488,7 @@ pub fn podman_driver_image_mount_sources(sandbox: &DriverSandbox) -> Result Result Result { +fn podman_user_mounts( + sandbox: &DriverSandbox, + enable_bind_mounts: bool, +) -> Result { let template = sandbox .spec .as_ref() @@ -495,10 +510,25 @@ fn podman_user_mounts(sandbox: &DriverSandbox) -> Result { + result.mounts.push(Mount { + kind: "bind".into(), + source: validate_absolute_mount_source(&source, "bind source")?, + destination: validate_container_mount_target(&target)?, + options: vec![ + if read_only { "ro" } else { "rw" }.to_string(), + "rbind".to_string(), + ], + }); + } PodmanDriverMountConfig::Volume { source, target, @@ -558,6 +588,7 @@ fn podman_user_mounts(sandbox: &DriverSandbox) -> Result Result { let Some(config) = template.driver_config.as_ref() else { return Ok(PodmanSandboxDriverConfig::default()); @@ -565,14 +596,27 @@ fn podman_driver_config( let json = Value::Object(proto_struct_to_json_object(config)); let config: PodmanSandboxDriverConfig = serde_json::from_value(json) .map_err(|err| format!("invalid podman driver_config: {err}"))?; - validate_podman_driver_mounts(&config.mounts)?; + validate_podman_driver_mounts(&config.mounts, enable_bind_mounts)?; Ok(config) } -fn validate_podman_driver_mounts(mounts: &[PodmanDriverMountConfig]) -> Result<(), String> { +fn validate_podman_driver_mounts( + mounts: &[PodmanDriverMountConfig], + enable_bind_mounts: bool, +) -> Result<(), String> { let mut targets = HashSet::new(); for mount in mounts { let target = match mount { + PodmanDriverMountConfig::Bind { source, target, .. } => { + if !enable_bind_mounts { + return Err( + "podman bind mounts require enable_bind_mounts = true in [openshell.drivers.podman]" + .to_string(), + ); + } + validate_absolute_mount_source(source, "bind source")?; + target + } PodmanDriverMountConfig::Volume { source, target, @@ -615,6 +659,14 @@ fn validate_podman_driver_mounts(mounts: &[PodmanDriverMountConfig]) -> Result<( Ok(()) } +fn validate_absolute_mount_source(source: &str, field: &str) -> Result { + let source = validate_mount_source(source, field)?; + if !Path::new(&source).is_absolute() { + return Err(format!("{field} must be an absolute host path")); + } + Ok(source) +} + fn validate_mount_source(source: &str, field: &str) -> Result { let source = source.trim(); if source.is_empty() { @@ -799,7 +851,7 @@ pub fn try_build_container_spec_with_token( let labels = build_labels(sandbox); let resource_limits = build_resource_limits(sandbox, config); let devices = build_devices(sandbox); - let user_mounts = podman_user_mounts(sandbox)?; + let user_mounts = podman_user_mounts(sandbox, config.enable_bind_mounts)?; // Network configuration -- always bridge mode. // Matches libpod's network spec format `{name: {opts}}`; the unit-struct @@ -1682,7 +1734,7 @@ mod tests { } #[test] - fn driver_config_rejects_bind_mounts() { + fn driver_config_rejects_bind_mounts_unless_enabled() { use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; let mut sandbox = test_sandbox("test-id", "test-name"); @@ -1703,7 +1755,73 @@ mod tests { let err = try_build_container_spec_with_token(&sandbox, &config, None).unwrap_err(); - assert!(err.contains("invalid podman driver_config")); + assert!(err.contains("enable_bind_mounts = true")); + } + + #[test] + fn container_spec_includes_bind_mounts_when_enabled() { + use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + + let mut sandbox = test_sandbox("test-id", "test-name"); + sandbox.spec = Some(DriverSandboxSpec { + template: Some(DriverSandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "mounts": [{ + "type": "bind", + "source": "/host/path", + "target": "/sandbox/host", + "read_only": true + }] + }))), + ..Default::default() + }), + ..Default::default() + }); + let mut config = test_config(); + config.enable_bind_mounts = true; + + let spec = build_container_spec(&sandbox, &config); + let mounts = spec["mounts"] + .as_array() + .expect("mounts should be an array"); + + assert!(mounts.iter().any(|mount| { + mount["type"].as_str() == Some("bind") + && mount["source"].as_str() == Some("/host/path") + && mount["destination"].as_str() == Some("/sandbox/host") + && mount["options"].as_array().is_some_and(|options| { + options.iter().any(|option| option.as_str() == Some("ro")) + && options + .iter() + .any(|option| option.as_str() == Some("rbind")) + }) + })); + } + + #[test] + fn driver_config_rejects_relative_bind_sources_when_enabled() { + use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + + let mut sandbox = test_sandbox("test-id", "test-name"); + sandbox.spec = Some(DriverSandboxSpec { + template: Some(DriverSandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "mounts": [{ + "type": "bind", + "source": "relative/path", + "target": "/sandbox/host" + }] + }))), + ..Default::default() + }), + ..Default::default() + }); + let mut config = test_config(); + config.enable_bind_mounts = true; + + let err = try_build_container_spec_with_token(&sandbox, &config, None).unwrap_err(); + + assert!(err.contains("bind source must be an absolute host path")); } #[test] diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index 0d3dcccb0..df955ad1c 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -300,8 +300,9 @@ impl PodmanComputeDriver { &self, sandbox: &DriverSandbox, ) -> Result<(), ComputeDriverError> { - let volumes = container::podman_driver_volume_mount_sources(sandbox) - .map_err(ComputeDriverError::Precondition)?; + let volumes = + container::podman_driver_volume_mount_sources(sandbox, self.config.enable_bind_mounts) + .map_err(ComputeDriverError::Precondition)?; for volume in volumes { let exists = self .client @@ -376,8 +377,9 @@ impl PodmanComputeDriver { .await .map_err(ComputeDriverError::from)?; - for image in container::podman_driver_image_mount_sources(sandbox) - .map_err(ComputeDriverError::Precondition)? + for image in + container::podman_driver_image_mount_sources(sandbox, self.config.enable_bind_mounts) + .map_err(ComputeDriverError::Precondition)? { info!(image = %image, policy = %pull_policy, "Ensuring image mount source"); self.client diff --git a/crates/openshell-driver-podman/src/main.rs b/crates/openshell-driver-podman/src/main.rs index 53af4e190..2d8d4055b 100644 --- a/crates/openshell-driver-podman/src/main.rs +++ b/crates/openshell-driver-podman/src/main.rs @@ -135,6 +135,7 @@ async fn main() -> Result<()> { guest_tls_cert: args.podman_tls_cert, guest_tls_key: args.podman_tls_key, sandbox_pids_limit: args.sandbox_pids_limit, + enable_bind_mounts: false, }) .await .into_diagnostic()?; diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 748cec264..f8815f87a 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -1538,4 +1538,18 @@ default_image = "k8s-specific:1.0" .expect("deserializes"); assert_eq!(parsed.default_image, "k8s-specific:1.0"); } + + #[test] + fn docker_config_reads_bind_mount_opt_in_from_driver_table() { + let file = config_file_from_toml( + r" +[openshell.drivers.docker] +enable_bind_mounts = true +", + ); + + let cfg = super::build_docker_config(Some(&file), None).expect("docker config"); + + assert!(cfg.enable_bind_mounts); + } } diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 676e23071..eb8ace0ce 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -1367,6 +1367,21 @@ service_account_name = "sandbox-sa" assert_eq!(cfg.service_account_name, "sandbox-sa"); } + #[test] + fn podman_config_reads_bind_mount_opt_in_from_driver_table() { + let file: crate::config_file::ConfigFile = toml::from_str( + r" +[openshell.drivers.podman] +enable_bind_mounts = true +", + ) + .expect("valid config"); + + let cfg = crate::podman_config_from_file(Some(&file)).expect("podman config"); + + assert!(cfg.enable_bind_mounts); + } + #[test] fn gateway_listener_addresses_skip_driver_address_covered_by_wildcard() { let primary: SocketAddr = "0.0.0.0:8080".parse().unwrap(); diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index c70d8acbd..b22853948 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -215,6 +215,7 @@ guest_tls_key = "/etc/openshell/certs/client-key.pem" network_name = "openshell-docker" host_gateway_ip = "172.17.0.1" ssh_socket_path = "/run/openshell/ssh.sock" +enable_bind_mounts = false # Set to 0 to leave Docker's runtime default unchanged. sandbox_pids_limit = 2048 ``` @@ -250,6 +251,7 @@ supervisor_image = "ghcr.io/nvidia/openshell/supervisor:latest" guest_tls_ca = "/etc/openshell/certs/ca.pem" guest_tls_cert = "/etc/openshell/certs/client.pem" guest_tls_key = "/etc/openshell/certs/client-key.pem" +enable_bind_mounts = false # Set to 0 to leave Podman's runtime default unchanged. sandbox_pids_limit = 2048 ``` diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index a15833497..bd087eff0 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -74,12 +74,14 @@ For GPU-backed Docker sandboxes, configure Docker CDI before starting the gatewa ### Docker Driver Config Mounts -Docker driver config accepts user-supplied `volume` and `tmpfs` mounts. Host -bind mounts and image mounts are not accepted in Docker sandbox driver config. +Docker driver config accepts user-supplied `volume` and `tmpfs` mounts. It also +accepts `bind` mounts when `[openshell.drivers.docker]` sets +`enable_bind_mounts = true` in `gateway.toml`. Host bind mounts expose gateway +host paths to sandbox requests, so they are disabled by default. Image mounts +are not accepted in Docker sandbox driver config. Docker's [storage documentation](https://docs.docker.com/engine/storage/) documents container storage mounts as volumes, bind mounts, tmpfs mounts, and -named pipes. OpenShell intentionally exposes only Docker-managed volumes and -tmpfs mounts for sandbox create. +named pipes. Use a `volume` mount for existing Docker named volumes. This includes NFS volumes created with Docker's local volume driver: @@ -97,10 +99,24 @@ openshell sandbox create \ -- claude ``` +Use a `bind` mount only after enabling it in the Docker driver table: + +```toml +[openshell.drivers.docker] +enable_bind_mounts = true +``` + +```shell +openshell sandbox create \ + --driver-config-json '{"docker":{"mounts":[{"type":"bind","source":"/srv/openshell/work","target":"/sandbox/work","read_only":false}]}}' \ + -- claude +``` + Docker mount schema: | Type | Fields | |---|---| +| `bind` | `source`, `target`, optional `read_only` (`false` by default). `source` must be an absolute host path. Requires `enable_bind_mounts = true`. | | `volume` | `source`, `target`, optional `read_only` (`false` by default), optional `subpath`. The named volume must already exist. | | `tmpfs` | `target`, optional `options`, optional `size_bytes`, optional `mode`. | @@ -123,7 +139,9 @@ On macOS with `podman machine`, the driver uses gvproxy's host-loopback IP, `192 ### Podman Driver Config Mounts Podman driver config accepts user-supplied `volume`, `tmpfs`, and `image` -mounts. Host bind mounts are not accepted in Podman sandbox driver config. +mounts. It also accepts `bind` mounts when `[openshell.drivers.podman]` sets +`enable_bind_mounts = true` in `gateway.toml`. Host bind mounts expose gateway +host paths to sandbox requests, so they are disabled by default. Use a `volume` mount for existing Podman named volumes. This includes NFS volumes created with Podman's local volume driver: @@ -149,10 +167,24 @@ openshell sandbox create \ -- claude ``` +Use a `bind` mount only after enabling it in the Podman driver table: + +```toml +[openshell.drivers.podman] +enable_bind_mounts = true +``` + +```shell +openshell sandbox create \ + --driver-config-json '{"podman":{"mounts":[{"type":"bind","source":"/srv/openshell/work","target":"/sandbox/work","read_only":false}]}}' \ + -- claude +``` + Podman mount schema: | Type | Fields | |---|---| +| `bind` | `source`, `target`, optional `read_only` (`false` by default). `source` must be an absolute host path. Requires `enable_bind_mounts = true`. | | `volume` | `source`, `target`, optional `read_only` (`false` by default). The named volume must already exist. | | `tmpfs` | `target`, optional `options`, optional `size_bytes`, optional `mode`. | | `image` | `source`, `target`, optional `read_only` (`true` by default). | diff --git a/e2e/rust/tests/driver_config_volume.rs b/e2e/rust/tests/driver_config_volume.rs index 7124cb0ab..79f50ea0f 100644 --- a/e2e/rust/tests/driver_config_volume.rs +++ b/e2e/rust/tests/driver_config_volume.rs @@ -3,14 +3,18 @@ #![cfg(feature = "e2e-local-container-driver")] +use std::fs; +use std::os::unix::fs::PermissionsExt; use std::process::Output; use std::time::{SystemTime, UNIX_EPOCH}; use openshell_e2e::harness::container::{ContainerEngine, e2e_driver}; use openshell_e2e::harness::sandbox::SandboxGuard; +use serde_json::{Map, Value}; const TEST_IMAGE: &str = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest"; const VOLUME_TARGET: &str = "/sandbox/e2e-volume"; +const BIND_TARGET: &str = "/sandbox/e2e-bind"; struct VolumeGuard { engine: ContainerEngine, @@ -74,6 +78,57 @@ async fn sandbox_mounts_existing_driver_config_volume() { verify_volume(&volume).expect("verify sandbox wrote to named test volume"); } +#[tokio::test] +async fn sandbox_mounts_enabled_driver_config_bind() { + let driver = e2e_driver().expect("OPENSHELL_E2E_DRIVER must be set by the e2e wrapper"); + assert!( + matches!(driver.as_str(), "docker" | "podman"), + "driver_config bind e2e requires docker or podman, got {driver}" + ); + + let cwd = std::env::current_dir().expect("resolve current dir"); + let host_dir = tempfile::Builder::new() + .prefix("openshell-e2e-driver-config-bind-") + .tempdir_in(cwd) + .expect("create bind mount host dir"); + fs::set_permissions(host_dir.path(), fs::Permissions::from_mode(0o777)) + .expect("make bind mount host dir writable by sandbox user"); + fs::write(host_dir.path().join("input.txt"), "host-bind-ok") + .expect("seed bind mount host dir"); + + let driver_config = driver_config_mount_json( + &driver, + serde_json::json!({ + "type": "bind", + "source": host_dir.path(), + "target": BIND_TARGET, + "read_only": false + }), + ); + let mut sandbox = SandboxGuard::create(&[ + "--no-keep", + "--driver-config-json", + &driver_config, + "--", + "sh", + "-lc", + "set -eu; test \"$(cat /sandbox/e2e-bind/input.txt)\" = host-bind-ok; printf sandbox-bind-ok > /sandbox/e2e-bind/output.txt; cat /sandbox/e2e-bind/output.txt", + ]) + .await + .expect("sandbox create with driver-config bind mount"); + + assert!( + sandbox.create_output.contains("sandbox-bind-ok"), + "sandbox should read and write the bind mount:\n{}", + sandbox.create_output + ); + + sandbox.cleanup().await; + let output = fs::read_to_string(host_dir.path().join("output.txt")) + .expect("read sandbox output from bind mount host dir"); + assert_eq!(output, "sandbox-bind-ok"); +} + fn seed_volume(volume: &VolumeGuard) -> Result<(), String> { run_engine( &volume.engine, @@ -157,3 +212,14 @@ fn unique_volume_name(driver: &str) -> String { std::process::id() ) } + +fn driver_config_mount_json(driver: &str, mount: Value) -> String { + let mut root = Map::new(); + root.insert( + driver.to_string(), + serde_json::json!({ + "mounts": [mount] + }), + ); + Value::Object(root).to_string() +} diff --git a/e2e/with-docker-gateway.sh b/e2e/with-docker-gateway.sh index 2ef5495b8..4c7ccd9ff 100755 --- a/e2e/with-docker-gateway.sh +++ b/e2e/with-docker-gateway.sh @@ -450,6 +450,7 @@ GATEWAY_CONFIG="${STATE_DIR}/gateway.toml" printf 'guest_tls_ca = %s\n' "$(toml_string "${PKI_DIR}/ca.crt")" printf 'guest_tls_cert = %s\n' "$(toml_string "${PKI_DIR}/client/tls.crt")" printf 'guest_tls_key = %s\n' "$(toml_string "${PKI_DIR}/client/tls.key")" + printf 'enable_bind_mounts = true\n' # DOCKER_SUPERVISOR_ARGS holds either ("--docker-supervisor-bin" "") # or ("--docker-supervisor-image" ""); both map to TOML keys on # the docker driver config. diff --git a/e2e/with-podman-gateway.sh b/e2e/with-podman-gateway.sh index dc4f4ede1..cda924b6a 100755 --- a/e2e/with-podman-gateway.sh +++ b/e2e/with-podman-gateway.sh @@ -391,6 +391,7 @@ cp "${ROOT}/deploy/rpm/gateway.toml.default" "${GATEWAY_CONFIG}" printf 'guest_tls_ca = %s\n' "$(toml_string "${PKI_DIR}/ca.crt")" printf 'guest_tls_cert = %s\n' "$(toml_string "${PKI_DIR}/client/tls.crt")" printf 'guest_tls_key = %s\n' "$(toml_string "${PKI_DIR}/client/tls.key")" + printf 'enable_bind_mounts = true\n' # The in-process Podman driver reads `socket_path` from TOML only — the # OPENSHELL_PODMAN_SOCKET env var is honoured by the standalone driver # binary, not the in-process driver used here. Pin the socket to the one From 712b03e358f63e8e5baa0148e7aecd68eefae722 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 9 Jun 2026 16:52:37 -0700 Subject: [PATCH 6/9] docs(sandbox): simplify mount examples --- crates/openshell-driver-docker/README.md | 12 ++---- crates/openshell-driver-podman/README.md | 11 ++---- docs/reference/sandbox-compute-drivers.mdx | 44 ++++++++++------------ e2e/rust/tests/driver_config_volume.rs | 34 ++++++++++++++++- e2e/with-podman-gateway.sh | 9 +++-- 5 files changed, 62 insertions(+), 48 deletions(-) diff --git a/crates/openshell-driver-docker/README.md b/crates/openshell-driver-docker/README.md index 22a82c753..5c389116e 100644 --- a/crates/openshell-driver-docker/README.md +++ b/crates/openshell-driver-docker/README.md @@ -59,19 +59,13 @@ Docker `volume` mounts may include `subpath`. Mount targets must be absolute container paths and must not replace the workspace root (`/sandbox`) or overlap OpenShell supervisor files, auth material, TLS material, or `/run/netns`. -Example NFS usage relies on Docker's named-volume support rather than a host -bind: +Example named-volume usage: ```shell -docker volume create \ - --driver local \ - --opt type=nfs \ - --opt o=addr=10.0.0.10,rw,nfsvers=4 \ - --opt device=:/exports/work \ - work-nfs +docker volume create openshell-work openshell sandbox create \ - --driver-config-json '{"docker":{"mounts":[{"type":"volume","source":"work-nfs","target":"/sandbox/work"}]}}' \ + --driver-config-json '{"docker":{"mounts":[{"type":"volume","source":"openshell-work","target":"/sandbox/work"}]}}' \ -- claude ``` diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index 6dc62da06..79741ae6d 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -75,18 +75,13 @@ config. Mount targets must be absolute container paths and must not replace the workspace root (`/sandbox`) or overlap OpenShell supervisor files, auth material, TLS material, or `/run/netns`. -Example NFS usage relies on Podman's named-volume support rather than a host -bind: +Example named-volume usage: ```shell -podman volume create \ - --opt type=nfs \ - --opt o=addr=10.0.0.10,rw,nfsvers=4 \ - --opt device=:/exports/work \ - work-nfs +podman volume create openshell-work openshell sandbox create \ - --driver-config-json '{"podman":{"mounts":[{"type":"volume","source":"work-nfs","target":"/sandbox/work"}]}}' \ + --driver-config-json '{"podman":{"mounts":[{"type":"volume","source":"openshell-work","target":"/sandbox/work"}]}}' \ -- claude ``` diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index bd087eff0..93eb29ffd 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -83,22 +83,23 @@ Docker's [storage documentation](https://docs.docker.com/engine/storage/) documents container storage mounts as volumes, bind mounts, tmpfs mounts, and named pipes. -Use a `volume` mount for existing Docker named volumes. This includes NFS -volumes created with Docker's local volume driver: +Use a `volume` mount for existing Docker named volumes: ```shell -docker volume create \ - --driver local \ - --opt type=nfs \ - --opt o=addr=10.0.0.10,rw,nfsvers=4 \ - --opt device=:/exports/work \ - work-nfs +docker volume create openshell-work openshell sandbox create \ - --driver-config-json '{"docker":{"mounts":[{"type":"volume","source":"work-nfs","target":"/sandbox/work","read_only":false}]}}' \ + --driver-config-json '{"docker":{"mounts":[{"type":"volume","source":"openshell-work","target":"/sandbox/work","read_only":false}]}}' \ -- claude ``` + +Bind mounts share gateway-host filesystem resources with the sandbox. They may +be considered insecure because they can negate OpenShell controls such as +workspace isolation and filesystem policy. Use them only when you understand and +accept that loss of isolation. + + Use a `bind` mount only after enabling it in the Docker driver table: ```toml @@ -143,29 +144,22 @@ mounts. It also accepts `bind` mounts when `[openshell.drivers.podman]` sets `enable_bind_mounts = true` in `gateway.toml`. Host bind mounts expose gateway host paths to sandbox requests, so they are disabled by default. -Use a `volume` mount for existing Podman named volumes. This includes NFS -volumes created with Podman's local volume driver: +Use a `volume` mount for existing Podman named volumes: ```shell -podman volume create \ - --opt type=nfs \ - --opt o=addr=10.0.0.10,rw,nfsvers=4 \ - --opt device=:/exports/work \ - work-nfs +podman volume create openshell-work openshell sandbox create \ - --driver-config-json '{"podman":{"mounts":[{"type":"volume","source":"work-nfs","target":"/sandbox/work","read_only":false}]}}' \ + --driver-config-json '{"podman":{"mounts":[{"type":"volume","source":"openshell-work","target":"/sandbox/work","read_only":false}]}}' \ -- claude ``` -Use an `image` mount to mount an OCI image filesystem through Podman's -image-volume API: - -```shell -openshell sandbox create \ - --driver-config-json '{"podman":{"mounts":[{"type":"image","source":"ghcr.io/acme/tools:latest","target":"/opt/tools"}]}}' \ - -- claude -``` + +Bind mounts share gateway-host filesystem resources with the sandbox. They may +be considered insecure because they can negate OpenShell controls such as +workspace isolation and filesystem policy. Use them only when you understand and +accept that loss of isolation. + Use a `bind` mount only after enabling it in the Podman driver table: diff --git a/e2e/rust/tests/driver_config_volume.rs b/e2e/rust/tests/driver_config_volume.rs index 79f50ea0f..4efb9747e 100644 --- a/e2e/rust/tests/driver_config_volume.rs +++ b/e2e/rust/tests/driver_config_volume.rs @@ -4,6 +4,7 @@ #![cfg(feature = "e2e-local-container-driver")] use std::fs; +use std::io::Write; use std::os::unix::fs::PermissionsExt; use std::process::Output; use std::time::{SystemTime, UNIX_EPOCH}; @@ -93,8 +94,10 @@ async fn sandbox_mounts_enabled_driver_config_bind() { .expect("create bind mount host dir"); fs::set_permissions(host_dir.path(), fs::Permissions::from_mode(0o777)) .expect("make bind mount host dir writable by sandbox user"); - fs::write(host_dir.path().join("input.txt"), "host-bind-ok") - .expect("seed bind mount host dir"); + let input_path = host_dir.path().join("input.txt"); + fs::write(&input_path, "host-bind-ok").expect("seed bind mount host dir"); + fs::set_permissions(&input_path, fs::Permissions::from_mode(0o666)) + .expect("make bind mount input readable by sandbox user"); let driver_config = driver_config_mount_json( &driver, @@ -105,8 +108,14 @@ async fn sandbox_mounts_enabled_driver_config_bind() { "read_only": false }), ); + // Host bind mounts are explicitly unsafe: this test validates driver mount + // wiring, not Landlock enforcement over Docker Desktop's fakeowner mounts. + let policy = write_bind_mount_policy().expect("write bind mount policy"); + let policy_path = policy.path().to_str().expect("policy path must be utf-8"); let mut sandbox = SandboxGuard::create(&[ "--no-keep", + "--policy", + policy_path, "--driver-config-json", &driver_config, "--", @@ -129,6 +138,27 @@ async fn sandbox_mounts_enabled_driver_config_bind() { assert_eq!(output, "sandbox-bind-ok"); } +fn write_bind_mount_policy() -> Result { + let mut file = + tempfile::NamedTempFile::new().map_err(|err| format!("create bind policy: {err}"))?; + file.write_all( + br#"version: 1 + +filesystem_policy: + include_workdir: false + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox +"#, + ) + .map_err(|err| format!("write bind policy: {err}"))?; + Ok(file) +} + fn seed_volume(volume: &VolumeGuard) -> Result<(), String> { run_engine( &volume.engine, diff --git a/e2e/with-podman-gateway.sh b/e2e/with-podman-gateway.sh index cda924b6a..94ec217c1 100755 --- a/e2e/with-podman-gateway.sh +++ b/e2e/with-podman-gateway.sh @@ -252,10 +252,6 @@ resolve_podman_supervisor_image() { ensure_podman_supervisor_image() { local image=$1 - if podman_cmd image exists "${image}" 2>/dev/null; then - return 0 - fi - if [ "${image}" = "openshell/supervisor:dev" ] \ && [ -z "${OPENSHELL_SUPERVISOR_IMAGE:-}" ] \ && [ -z "${CI:-}" ]; then @@ -270,6 +266,10 @@ ensure_podman_supervisor_image() { exit 2 fi + if podman_cmd image exists "${image}" 2>/dev/null; then + return 0 + fi + echo "Pulling Podman supervisor image ${image}..." if podman_cmd pull "${image}"; then return 0 @@ -342,6 +342,7 @@ HOST_PORT=$(e2e_pick_port) HEALTH_PORT=$(e2e_pick_port) STATE_DIR="${WORKDIR}/state" mkdir -p "${STATE_DIR}" +export XDG_STATE_HOME="${STATE_DIR}" JWT_DIR="${STATE_DIR}/jwt" E2E_NAMESPACE="e2e-podman-$$-${HOST_PORT}" From 1a498d83ab6898c7b9e2f23d9051cf80ef307de0 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 9 Jun 2026 18:01:31 -0700 Subject: [PATCH 7/9] cleanup --- docs/reference/sandbox-compute-drivers.mdx | 8 +- e2e/rust/Cargo.lock | 512 +++++++++++++++++++++ e2e/rust/Cargo.toml | 2 + e2e/rust/tests/driver_config_volume.rs | 340 ++++++++++---- 4 files changed, 773 insertions(+), 89 deletions(-) diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index 93eb29ffd..28f3a5067 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -76,12 +76,8 @@ For GPU-backed Docker sandboxes, configure Docker CDI before starting the gatewa Docker driver config accepts user-supplied `volume` and `tmpfs` mounts. It also accepts `bind` mounts when `[openshell.drivers.docker]` sets -`enable_bind_mounts = true` in `gateway.toml`. Host bind mounts expose gateway -host paths to sandbox requests, so they are disabled by default. Image mounts -are not accepted in Docker sandbox driver config. -Docker's [storage documentation](https://docs.docker.com/engine/storage/) -documents container storage mounts as volumes, bind mounts, tmpfs mounts, and -named pipes. +`enable_bind_mounts = true` in `gateway.toml`. Image mounts are not accepted in +Docker sandbox driver config. See Docker's [storage documentation](https://docs.docker.com/engine/storage/) for more information. Use a `volume` mount for existing Docker named volumes: diff --git a/e2e/rust/Cargo.lock b/e2e/rust/Cargo.lock index aceacf682..953449c57 100644 --- a/e2e/rust/Cargo.lock +++ b/e2e/rust/Cargo.lock @@ -35,6 +35,49 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bollard" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" +dependencies = [ + "base64", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "http", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-util", + "hyperlocal", + "log", + "pin-project-lite", + "serde", + "serde_derive", + "serde_json", + "serde_urlencoded", + "thiserror", + "tokio", + "tokio-util", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-stubs" +version = "1.52.1-rc.29.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" +dependencies = [ + "serde", + "serde_json", + "serde_repr", +] + [[package]] name = "bytes" version = "1.11.1" @@ -76,6 +119,17 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "either" version = "1.16.0" @@ -110,6 +164,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -125,6 +188,42 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -226,6 +325,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.9.0" @@ -239,6 +344,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -246,6 +352,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-named-pipe" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", + "winapi", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -253,11 +374,114 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", "pin-project-lite", "tokio", + "tower-service", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", ] [[package]] @@ -266,6 +490,27 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -311,6 +556,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "lock_api" version = "0.4.14" @@ -354,7 +605,9 @@ name = "openshell-e2e" version = "0.1.0" dependencies = [ "base64", + "bollard", "bytes", + "futures-util", "hex", "http-body-util", "hyper", @@ -391,12 +644,27 @@ dependencies = [ "windows-link", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -520,6 +788,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "scopeguard" version = "1.2.0" @@ -539,6 +813,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -574,6 +849,29 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -606,6 +904,12 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" @@ -622,6 +926,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "syn" version = "2.0.117" @@ -633,6 +943,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.26.0" @@ -646,6 +967,36 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.50.0" @@ -674,6 +1025,44 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -698,6 +1087,24 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "version_check" version = "0.9.5" @@ -771,6 +1178,28 @@ dependencies = [ "semver", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" @@ -948,6 +1377,35 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.40" @@ -968,6 +1426,60 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index 2ff799c98..083c622df 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -104,7 +104,9 @@ required-features = ["e2e-gpu"] [dependencies] base64 = "0.22" +bollard = "0.20" bytes = "1" +futures-util = "0.3" http-body-util = "0.1" hyper = { version = "1", features = ["client", "http1"] } hyper-util = { version = "0.1", features = ["tokio"] } diff --git a/e2e/rust/tests/driver_config_volume.rs b/e2e/rust/tests/driver_config_volume.rs index 4efb9747e..26725a066 100644 --- a/e2e/rust/tests/driver_config_volume.rs +++ b/e2e/rust/tests/driver_config_volume.rs @@ -6,10 +6,17 @@ use std::fs; use std::io::Write; use std::os::unix::fs::PermissionsExt; -use std::process::Output; +use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; -use openshell_e2e::harness::container::{ContainerEngine, e2e_driver}; +use bollard::Docker; +use bollard::models::{ContainerCreateBody, HostConfig, Mount, MountTypeEnum, VolumeCreateRequest}; +use bollard::query_parameters::{ + CreateContainerOptionsBuilder, CreateImageOptionsBuilder, LogsOptions, RemoveContainerOptions, + RemoveVolumeOptionsBuilder, StartContainerOptions, WaitContainerOptions, +}; +use futures_util::TryStreamExt; +use openshell_e2e::harness::container::e2e_driver; use openshell_e2e::harness::sandbox::SandboxGuard; use serde_json::{Map, Value}; @@ -18,25 +25,37 @@ const VOLUME_TARGET: &str = "/sandbox/e2e-volume"; const BIND_TARGET: &str = "/sandbox/e2e-bind"; struct VolumeGuard { - engine: ContainerEngine, + docker: Docker, name: String, } impl VolumeGuard { - fn create(engine: ContainerEngine, driver: &str) -> Result { + async fn create(driver: &str) -> Result { let name = unique_volume_name(driver); - run_engine(&engine, &["volume", "create", &name])?; - Ok(Self { engine, name }) + let docker = connect_container_api(driver).await?; + docker + .create_volume(VolumeCreateRequest { + name: Some(name.clone()), + ..Default::default() + }) + .await + .map_err(|err| format!("create {driver} volume {name}: {err}"))?; + Ok(Self { docker, name }) } } impl Drop for VolumeGuard { fn drop(&mut self) { - let _ = self - .engine - .command() - .args(["volume", "rm", "-f", &self.name]) - .output(); + let docker = self.docker.clone(); + let name = self.name.clone(); + tokio::spawn(async move { + let _ = docker + .remove_volume( + &name, + Some(RemoveVolumeOptionsBuilder::new().force(true).build()), + ) + .await; + }); } } @@ -48,10 +67,11 @@ async fn sandbox_mounts_existing_driver_config_volume() { "driver_config volume e2e requires docker or podman, got {driver}" ); - let engine = ContainerEngine::from_env(); - let volume = VolumeGuard::create(engine, &driver).expect("create named test volume"); + let volume = VolumeGuard::create(&driver) + .await + .expect("create named test volume"); - seed_volume(&volume).expect("seed named test volume"); + seed_volume(&volume).await.expect("seed named test volume"); let driver_config = format!( r#"{{"{driver}":{{"mounts":[{{"type":"volume","source":"{}","target":"{VOLUME_TARGET}","read_only":false}}]}}}}"#, @@ -76,7 +96,9 @@ async fn sandbox_mounts_existing_driver_config_volume() { ); sandbox.cleanup().await; - verify_volume(&volume).expect("verify sandbox wrote to named test volume"); + verify_volume(&volume) + .await + .expect("verify sandbox wrote to named test volume"); } #[tokio::test] @@ -93,21 +115,19 @@ async fn sandbox_mounts_enabled_driver_config_bind() { .tempdir_in(cwd) .expect("create bind mount host dir"); fs::set_permissions(host_dir.path(), fs::Permissions::from_mode(0o777)) - .expect("make bind mount host dir writable by sandbox user"); + .expect("make bind mount host dir writable by sandbox user"); let input_path = host_dir.path().join("input.txt"); fs::write(&input_path, "host-bind-ok").expect("seed bind mount host dir"); fs::set_permissions(&input_path, fs::Permissions::from_mode(0o666)) .expect("make bind mount input readable by sandbox user"); - let driver_config = driver_config_mount_json( - &driver, - serde_json::json!({ - "type": "bind", - "source": host_dir.path(), - "target": BIND_TARGET, - "read_only": false - }), - ); + let bind_mount = serde_json::json!({ + "type": "bind", + "source": host_dir.path(), + "target": BIND_TARGET, + "read_only": false + }); + let driver_config = driver_config_mount_json(&driver, &bind_mount); // Host bind mounts are explicitly unsafe: this test validates driver mount // wiring, not Landlock enforcement over Docker Desktop's fakeowner mounts. let policy = write_bind_mount_policy().expect("write bind mount policy"); @@ -142,7 +162,7 @@ fn write_bind_mount_policy() -> Result { let mut file = tempfile::NamedTempFile::new().map_err(|err| format!("create bind policy: {err}"))?; file.write_all( - br#"version: 1 + br"version: 1 filesystem_policy: include_workdir: false @@ -153,49 +173,31 @@ landlock: process: run_as_user: sandbox run_as_group: sandbox -"#, +", ) .map_err(|err| format!("write bind policy: {err}"))?; Ok(file) } -fn seed_volume(volume: &VolumeGuard) -> Result<(), String> { - run_engine( - &volume.engine, - &[ - "run", - "--rm", - "--user", - "0:0", - "--volume", - &format!("{}:/vol", volume.name), - "--entrypoint", - "sh", - TEST_IMAGE, - "-lc", - "set -eu; chmod 0777 /vol; printf host-volume-ok > /vol/input.txt", - ], - )?; +async fn seed_volume(volume: &VolumeGuard) -> Result<(), String> { + run_volume_container( + volume, + "seed", + false, + "set -eu; chmod 0777 /vol; printf host-volume-ok > /vol/input.txt", + ) + .await?; Ok(()) } -fn verify_volume(volume: &VolumeGuard) -> Result<(), String> { - let output = run_engine( - &volume.engine, - &[ - "run", - "--rm", - "--user", - "0:0", - "--volume", - &format!("{}:/vol:ro", volume.name), - "--entrypoint", - "sh", - TEST_IMAGE, - "-lc", - "set -eu; test \"$(cat /vol/input.txt)\" = host-volume-ok; test \"$(cat /vol/output.txt)\" = sandbox-volume-ok; echo volume-ok", - ], - )?; +async fn verify_volume(volume: &VolumeGuard) -> Result<(), String> { + let output = run_volume_container( + volume, + "verify", + true, + "set -eu; test \"$(cat /vol/input.txt)\" = host-volume-ok; test \"$(cat /vol/output.txt)\" = sandbox-volume-ok; echo volume-ok", + ) + .await?; if !output.contains("volume-ok") { return Err(format!( "volume verification did not print expected marker:\n{output}" @@ -204,34 +206,206 @@ fn verify_volume(volume: &VolumeGuard) -> Result<(), String> { Ok(()) } -fn run_engine(engine: &ContainerEngine, args: &[&str]) -> Result { - let output = engine - .command() - .args(args) - .output() - .map_err(|err| format!("spawn {} {}: {err}", engine.name(), args.join(" ")))?; - engine_output(engine, args, &output) +async fn run_volume_container( + volume: &VolumeGuard, + purpose: &str, + read_only: bool, + script: &str, +) -> Result { + ensure_test_image(&volume.docker).await?; + + let container_name = format!("{}-{purpose}", volume.name); + let create_options = CreateContainerOptionsBuilder::new() + .name(&container_name) + .build(); + let host_config = HostConfig { + mounts: Some(vec![Mount { + target: Some("/vol".to_string()), + source: Some(volume.name.clone()), + typ: Some(MountTypeEnum::VOLUME), + read_only: Some(read_only), + ..Default::default() + }]), + ..Default::default() + }; + volume + .docker + .create_container( + Some(create_options), + ContainerCreateBody { + image: Some(TEST_IMAGE.to_string()), + user: Some("0:0".to_string()), + entrypoint: Some(vec!["sh".to_string()]), + cmd: Some(vec!["-lc".to_string(), script.to_string()]), + attach_stdout: Some(true), + attach_stderr: Some(true), + host_config: Some(host_config), + ..Default::default() + }, + ) + .await + .map_err(|err| format!("create helper container {container_name}: {err}"))?; + + let result = run_created_container(volume, &container_name).await; + let remove_result = volume + .docker + .remove_container(&container_name, None::) + .await; + + match (result, remove_result) { + (Ok(output), Ok(())) => Ok(output), + (Ok(_), Err(err)) => Err(format!("remove helper container {container_name}: {err}")), + (Err(err), Ok(())) => Err(err), + (Err(err), Err(remove_err)) => Err(format!( + "{err}\nremove helper container {container_name}: {remove_err}" + )), + } } -fn engine_output( - engine: &ContainerEngine, - args: &[&str], - output: &Output, -) -> Result { - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let combined = format!("{stdout}{stderr}"); - if output.status.success() { - return Ok(combined); +async fn ensure_test_image(docker: &Docker) -> Result<(), String> { + if docker.inspect_image(TEST_IMAGE).await.is_ok() { + return Ok(()); } + + let pull_events = docker + .create_image( + Some( + CreateImageOptionsBuilder::new() + .from_image(TEST_IMAGE) + .build(), + ), + None, + None, + ) + .try_collect::>() + .await + .map_err(|err| format!("pull helper image {TEST_IMAGE}: {err}"))?; + + let pull_errors = pull_events + .iter() + .filter_map(|event| { + event + .error_detail + .as_ref() + .and_then(|detail| detail.message.as_deref()) + }) + .collect::>(); + if pull_errors.is_empty() { + return Ok(()); + } + Err(format!( - "{} {} failed (exit {:?}):\n{combined}", - engine.name(), - args.join(" "), - output.status.code() + "pull helper image {TEST_IMAGE} failed:\n{}", + pull_errors.join("\n") )) } +async fn run_created_container( + volume: &VolumeGuard, + container_name: &str, +) -> Result { + volume + .docker + .start_container(container_name, None::) + .await + .map_err(|err| format!("start helper container {container_name}: {err}"))?; + + let wait_result = volume + .docker + .wait_container(container_name, None::) + .try_collect::>() + .await; + let logs = volume + .docker + .logs( + container_name, + Some(LogsOptions { + stdout: true, + stderr: true, + tail: "all".to_string(), + ..Default::default() + }), + ) + .try_collect::>() + .await + .map(|chunks| { + chunks + .into_iter() + .map(|chunk| chunk.to_string()) + .collect::() + }); + + match (wait_result, logs) { + (Ok(_), Ok(output)) => Ok(output), + (Ok(_), Err(err)) => Err(format!( + "read helper container {container_name} logs: {err}" + )), + (Err(err), Ok(output)) => Err(format!( + "helper container {container_name} failed: {err}\n{output}" + )), + (Err(err), Err(log_err)) => Err(format!( + "helper container {container_name} failed: {err}\nread logs failed: {log_err}" + )), + } +} + +async fn connect_container_api(driver: &str) -> Result { + let docker = match driver { + "docker" => Docker::connect_with_local_defaults() + .map_err(|err| format!("connect to Docker API: {err}"))?, + "podman" => { + let socket = podman_socket_path(); + let socket_display = socket.display().to_string(); + Docker::connect_with_unix( + socket + .to_str() + .ok_or_else(|| format!("podman socket path is not UTF-8: {socket_display}"))?, + 120, + bollard::API_DEFAULT_VERSION, + ) + .map_err(|err| format!("connect to Podman Docker-compatible API: {err}"))? + } + other => return Err(format!("unsupported e2e driver for volume API: {other}")), + }; + docker + .ping() + .await + .map_err(|err| format!("ping {driver} Docker-compatible API: {err}"))?; + Ok(docker) +} + +fn podman_socket_path() -> PathBuf { + if let Some(path) = std::env::var_os("OPENSHELL_PODMAN_SOCKET") { + return PathBuf::from(path); + } + + #[cfg(target_os = "macos")] + { + let home = std::env::var_os("HOME").unwrap_or_default(); + PathBuf::from(home).join(".local/share/containers/podman/machine/podman.sock") + } + #[cfg(target_os = "linux")] + { + std::env::var_os("XDG_RUNTIME_DIR").map_or_else( + || { + let uid = std::process::Command::new("id") + .arg("-u") + .output() + .ok() + .and_then(|output| { + String::from_utf8(output.stdout) + .ok() + .map(|value| value.trim().to_string()) + }) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "1000".to_string()); + PathBuf::from(format!("/run/user/{uid}/podman/podman.sock")) + }, + |xdg| PathBuf::from(xdg).join("podman/podman.sock"), + ) + } +} + fn unique_volume_name(driver: &str) -> String { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -243,7 +417,7 @@ fn unique_volume_name(driver: &str) -> String { ) } -fn driver_config_mount_json(driver: &str, mount: Value) -> String { +fn driver_config_mount_json(driver: &str, mount: &Value) -> String { let mut root = Map::new(); root.insert( driver.to_string(), From f6d9219d864a59ed31ef8a677fa39d31d2d07831 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 9 Jun 2026 19:09:20 -0700 Subject: [PATCH 8/9] test(e2e): stabilize branch checks --- e2e/python/test_sandbox_policy.py | 21 ++++++++------------- e2e/rust/tests/driver_config_volume.rs | 23 +++++++++++++++++++++-- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/e2e/python/test_sandbox_policy.py b/e2e/python/test_sandbox_policy.py index 6d797b49b..5ac37bd27 100644 --- a/e2e/python/test_sandbox_policy.py +++ b/e2e/python/test_sandbox_policy.py @@ -30,10 +30,11 @@ # Standard proxy address inside the sandbox network namespace _PROXY_HOST = "10.200.0.1" _PROXY_PORT = 3128 -# sslip.io keeps the wildcard test on deterministic public DNS. Vendor-owned -# telemetry subdomains can be NXDOMAIN or resolve to private ranges in CI. -_PUBLIC_WILDCARD_SUFFIX = "1.1.1.1.sslip.io" +# example.com keeps the wildcard test on public DNS while avoiding sslip.io +# rewrites that can resolve to internal ranges in CI. +_PUBLIC_WILDCARD_SUFFIX = "example.com" _PUBLIC_WILDCARD_PATTERN = f"*.{_PUBLIC_WILDCARD_SUFFIX}" +_PUBLIC_WILDCARD_SUBDOMAIN = f"www.{_PUBLIC_WILDCARD_SUFFIX}" def _base_policy( @@ -1865,19 +1866,13 @@ def test_host_wildcard_matches_subdomain( ) spec = datamodel_pb2.SandboxSpec(policy=policy) with sandbox(spec=spec, delete_on_exit=True) as sb: - first_subdomain = f"alpha.{_PUBLIC_WILDCARD_SUFFIX}" - result = sb.exec_python(_proxy_connect(), args=(first_subdomain, 443)) - assert result.exit_code == 0, result.stderr - assert "200" in result.stdout, ( - f"{_PUBLIC_WILDCARD_PATTERN} should match {first_subdomain}: " - f"{result.stdout}" + result = sb.exec_python( + _proxy_connect(), args=(_PUBLIC_WILDCARD_SUBDOMAIN, 443) ) - - second_subdomain = f"beta.{_PUBLIC_WILDCARD_SUFFIX}" - result = sb.exec_python(_proxy_connect(), args=(second_subdomain, 443)) assert result.exit_code == 0, result.stderr assert "200" in result.stdout, ( - f"{_PUBLIC_WILDCARD_PATTERN} should match {second_subdomain}: " + f"{_PUBLIC_WILDCARD_PATTERN} should match " + f"{_PUBLIC_WILDCARD_SUBDOMAIN}: " f"{result.stdout}" ) diff --git a/e2e/rust/tests/driver_config_volume.rs b/e2e/rust/tests/driver_config_volume.rs index 26725a066..51e56b97c 100644 --- a/e2e/rust/tests/driver_config_volume.rs +++ b/e2e/rust/tests/driver_config_volume.rs @@ -6,7 +6,7 @@ use std::fs; use std::io::Write; use std::os::unix::fs::PermissionsExt; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use bollard::Docker; @@ -121,9 +121,10 @@ async fn sandbox_mounts_enabled_driver_config_bind() { fs::set_permissions(&input_path, fs::Permissions::from_mode(0o666)) .expect("make bind mount input readable by sandbox user"); + let bind_source = bind_mount_source_path(&driver, host_dir.path()); let bind_mount = serde_json::json!({ "type": "bind", - "source": host_dir.path(), + "source": bind_source, "target": BIND_TARGET, "read_only": false }); @@ -427,3 +428,21 @@ fn driver_config_mount_json(driver: &str, mount: &Value) -> String { ); Value::Object(root).to_string() } + +fn bind_mount_source_path(driver: &str, path: &Path) -> PathBuf { + if driver == "docker" { + github_actions_host_work_path(path).unwrap_or_else(|| path.to_path_buf()) + } else { + path.to_path_buf() + } +} + +fn github_actions_host_work_path(path: &Path) -> Option { + if std::env::var("GITHUB_ACTIONS").ok().as_deref() != Some("true") { + return None; + } + + let relative = path.strip_prefix("/__w").ok()?; + let mapped = Path::new("/home/runner/_work").join(relative); + mapped.exists().then_some(mapped) +} From f48a9dd816f7f8b371716bfd7298c998136c22b2 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 10 Jun 2026 09:04:42 -0700 Subject: [PATCH 9/9] fix(drivers): tighten local mount validation Signed-off-by: Drew Newberry --- architecture/compute-runtimes.md | 11 +- crates/openshell-core/src/driver_mounts.rs | 153 +++++++++++ crates/openshell-core/src/lib.rs | 2 + crates/openshell-core/src/proto_struct.rs | 40 +++ crates/openshell-driver-docker/README.md | 10 +- crates/openshell-driver-docker/src/lib.rs | 207 +++++---------- crates/openshell-driver-docker/src/tests.rs | 140 ++++++++++ crates/openshell-driver-podman/README.md | 10 +- .../openshell-driver-podman/src/container.rs | 239 +++++++++--------- docs/reference/gateway-config.mdx | 3 + docs/reference/sandbox-compute-drivers.mdx | 25 +- 11 files changed, 553 insertions(+), 287 deletions(-) create mode 100644 crates/openshell-core/src/driver_mounts.rs create mode 100644 crates/openshell-core/src/proto_struct.rs diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index 0019b500c..8b66efac6 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -42,10 +42,13 @@ but currently ignores them. Docker and Podman also accept per-sandbox driver-config mounts for existing runtime-managed named volumes and tmpfs mounts. Podman additionally accepts -image mounts through its image-volume API. User-supplied host bind mounts are -available only when explicitly enabled in the active local driver table of -`gateway.toml`; driver-owned supervisor, token, and TLS bind mounts stay -reserved. +image mounts through its image-volume API. User-supplied bind and volume mounts +default to read-only. Direct host bind mounts, and Docker local-driver +bind-backed named volumes, are available only when explicitly enabled in the +active local driver table of `gateway.toml`. Host bind mounts are an unsafe +operator override because they place gateway-host filesystem state inside the +sandbox and can negate OpenShell workspace isolation and filesystem-policy +controls. Driver-owned supervisor, token, and TLS bind mounts stay reserved. Kubernetes deployments may set an AppArmor profile on sandbox agent containers through the driver configuration. The Helm chart defaults sandbox agents to diff --git a/crates/openshell-core/src/driver_mounts.rs b/crates/openshell-core/src/driver_mounts.rs new file mode 100644 index 000000000..0b27e0a3b --- /dev/null +++ b/crates/openshell-core/src/driver_mounts.rs @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared validation helpers for driver-config mounts. + +use std::path::Path; + +const RESERVED_MOUNT_TARGETS: &[&str] = &[ + "/opt/openshell", + "/etc/openshell", + "/etc/openshell-tls", + "/run/netns", +]; + +/// Validate a non-empty driver mount source. +pub fn validate_mount_source(source: &str, field: &str) -> Result { + let source = source.trim(); + if source.is_empty() { + return Err(format!("{field} must not be empty")); + } + if source.as_bytes().contains(&0) { + return Err(format!("{field} must not contain NUL bytes")); + } + Ok(source.to_string()) +} + +/// Validate a bind mount source as an absolute host path. +pub fn validate_absolute_mount_source(source: &str, field: &str) -> Result { + let source = validate_mount_source(source, field)?; + if !Path::new(&source).is_absolute() { + return Err(format!("{field} must be an absolute host path")); + } + Ok(source) +} + +/// Validate a relative subpath inside a runtime-managed mount source. +pub fn validate_mount_subpath(subpath: &str) -> Result { + let subpath = subpath.trim(); + if subpath.is_empty() { + return Err("mount subpath must not be empty".to_string()); + } + if subpath.as_bytes().contains(&0) { + return Err("mount subpath must not contain NUL bytes".to_string()); + } + let path = Path::new(subpath); + if path.is_absolute() + || path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err("mount subpath must be relative and must not contain '..'".to_string()); + } + Ok(subpath.to_string()) +} + +/// Validate a container-side mount target for user-supplied driver mounts. +pub fn validate_container_mount_target(target: &str) -> Result { + let target = normalize_container_mount_target(target); + if target.is_empty() { + return Err("mount target must not be empty".to_string()); + } + if target.as_bytes().contains(&0) { + return Err("mount target must not contain NUL bytes".to_string()); + } + if !target.starts_with('/') { + return Err("mount target must be an absolute container path".to_string()); + } + if target == "/" { + return Err("mount target must not be the container root".to_string()); + } + let path = Path::new(&target); + if path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err("mount target must not contain '..'".to_string()); + } + if target == "/sandbox" { + return Err("mount target '/sandbox' is reserved for the OpenShell workspace".to_string()); + } + for reserved in RESERVED_MOUNT_TARGETS { + if path_is_or_under(&target, reserved) { + return Err(format!( + "mount target '{target}' conflicts with reserved OpenShell path '{reserved}'" + )); + } + } + Ok(target) +} + +fn normalize_container_mount_target(target: &str) -> String { + let target = target.trim(); + if target == "/" { + return target.to_string(); + } + target.trim_end_matches('/').to_string() +} + +fn path_is_or_under(path: &str, parent: &str) -> bool { + path == parent + || path + .strip_prefix(parent) + .is_some_and(|rest| rest.starts_with('/')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn container_target_allows_paths_under_workspace() { + assert_eq!( + validate_container_mount_target("/sandbox/work/").unwrap(), + "/sandbox/work" + ); + } + + #[test] + fn container_target_rejects_workspace_root_only() { + let err = validate_container_mount_target("/sandbox/").unwrap_err(); + + assert!(err.contains("reserved for the OpenShell workspace")); + } + + #[test] + fn container_target_rejects_reserved_openshell_tls_legacy_path() { + let err = validate_container_mount_target("/etc/openshell-tls/client").unwrap_err(); + + assert!(err.contains("/etc/openshell-tls")); + } + + #[test] + fn container_target_rejects_reserved_openshell_tree() { + let err = validate_container_mount_target("/etc/openshell/tls/client").unwrap_err(); + + assert!(err.contains("/etc/openshell")); + } + + #[test] + fn container_target_does_not_prefix_match_unrelated_paths() { + assert_eq!( + validate_container_mount_target("/etc/openshell-tools").unwrap(), + "/etc/openshell-tools" + ); + } + + #[test] + fn mount_subpath_must_be_relative_without_parent_dirs() { + assert_eq!(validate_mount_subpath(" project/a ").unwrap(), "project/a"); + assert!(validate_mount_subpath("/project").is_err()); + assert!(validate_mount_subpath("../project").is_err()); + } +} diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index c3241cdd8..ceec0d617 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod auth; pub mod config; +pub mod driver_mounts; pub mod driver_utils; pub mod error; pub mod forward; @@ -22,6 +23,7 @@ pub mod net; pub mod paths; pub mod progress; pub mod proto; +pub mod proto_struct; pub mod sandbox_env; pub mod settings; pub mod telemetry; diff --git a/crates/openshell-core/src/proto_struct.rs b/crates/openshell-core/src/proto_struct.rs new file mode 100644 index 000000000..0d9f72236 --- /dev/null +++ b/crates/openshell-core/src/proto_struct.rs @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Helpers for decoding `google.protobuf.Struct` values. + +/// Convert a protobuf Struct into a JSON object for typed serde decoding. +#[must_use] +pub fn struct_to_json_object( + config: &prost_types::Struct, +) -> serde_json::Map { + config + .fields + .iter() + .map(|(key, value)| (key.clone(), value_to_json(value))) + .collect() +} + +/// Convert a protobuf Struct into a JSON value for typed serde decoding. +#[must_use] +pub fn struct_to_json_value(config: &prost_types::Struct) -> serde_json::Value { + serde_json::Value::Object(struct_to_json_object(config)) +} + +/// Convert a protobuf Value into a JSON value for typed serde decoding. +#[must_use] +pub fn value_to_json(value: &prost_types::Value) -> serde_json::Value { + match value.kind.as_ref() { + Some(prost_types::value::Kind::NumberValue(num)) => serde_json::Number::from_f64(*num) + .map_or(serde_json::Value::Null, serde_json::Value::Number), + Some(prost_types::value::Kind::StringValue(val)) => serde_json::Value::String(val.clone()), + Some(prost_types::value::Kind::BoolValue(val)) => serde_json::Value::Bool(*val), + Some(prost_types::value::Kind::StructValue(val)) => { + serde_json::Value::Object(struct_to_json_object(val)) + } + Some(prost_types::value::Kind::ListValue(list)) => { + serde_json::Value::Array(list.values.iter().map(value_to_json).collect()) + } + Some(prost_types::value::Kind::NullValue(_)) | None => serde_json::Value::Null, + } +} diff --git a/crates/openshell-driver-docker/README.md b/crates/openshell-driver-docker/README.md index 5c389116e..80ffbe18b 100644 --- a/crates/openshell-driver-docker/README.md +++ b/crates/openshell-driver-docker/README.md @@ -46,6 +46,8 @@ mount types: has `enable_bind_mounts = true`. - `volume`: mounts an existing Docker named volume. The driver validates that the volume exists before provisioning and never creates or removes it. + Docker local-driver volumes created with bind options are treated as host + bind mounts and require `enable_bind_mounts = true`. - `tmpfs`: mounts an in-memory filesystem with optional `options`, `size_bytes`, and `mode`. @@ -55,9 +57,11 @@ driver-config schema. The driver still uses internal bind mounts for OpenShell-owned supervisor, token, and TLS material. Docker `bind` mounts accept `source`, `target`, and optional `read_only`. -Docker `volume` mounts may include `subpath`. Mount targets must be absolute -container paths and must not replace the workspace root (`/sandbox`) or overlap -OpenShell supervisor files, auth material, TLS material, or `/run/netns`. +Docker `volume` mounts may include `subpath`. User-supplied bind and volume +mounts are read-only by default; set `read_only: false` to make them writable. +Mount targets must be absolute container paths and must not replace the +workspace root (`/sandbox`) or overlap OpenShell supervisor files, +`/etc/openshell`, `/etc/openshell-tls`, or `/run/netns`. Example named-volume usage: diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index c1b4c5f50..607e17bcb 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -42,6 +42,7 @@ use openshell_core::proto::compute::v1::{ watch_sandboxes_event, }; use openshell_core::{Config, Error, Result as CoreResult}; +use openshell_core::{driver_mounts, proto_struct}; use std::collections::{HashMap, HashSet}; use std::io::Read; use std::net::{IpAddr, SocketAddr}; @@ -278,13 +279,13 @@ enum DockerDriverMountConfig { Bind { source: String, target: String, - #[serde(default)] + #[serde(default = "default_true")] read_only: bool, }, Volume { source: String, target: String, - #[serde(default)] + #[serde(default = "default_true")] read_only: bool, #[serde(default)] subpath: Option, @@ -300,6 +301,10 @@ enum DockerDriverMountConfig { }, } +fn default_true() -> bool { + true +} + type WatchStream = Pin> + Send + 'static>>; @@ -475,7 +480,15 @@ impl DockerComputeDriver { for mount in config.mounts { if let DockerDriverMountConfig::Volume { source, .. } = mount { match self.docker.inspect_volume(source.trim()).await { - Ok(_) => {} + Ok(volume) => { + if !self.config.enable_bind_mounts && docker_volume_is_bind_backed(&volume) + { + return Err(Status::failed_precondition(format!( + "docker volume '{}' is backed by a host bind mount and requires enable_bind_mounts = true in [openshell.drivers.docker]", + source.trim() + ))); + } + } Err(err) if is_not_found_error(&err) => { return Err(Status::failed_precondition(format!( "docker volume '{}' does not exist", @@ -1588,7 +1601,7 @@ fn docker_driver_config( return Ok(DockerSandboxDriverConfig::default()); }; - let json = serde_json::Value::Object(proto_struct_to_json_object(config)); + let json = serde_json::Value::Object(proto_struct::struct_to_json_object(config)); let config: DockerSandboxDriverConfig = serde_json::from_value(json).map_err(|err| { Status::failed_precondition(format!("invalid docker driver_config: {err}")) })?; @@ -1612,8 +1625,14 @@ fn docker_mount_from_config(config: &DockerDriverMountConfig) -> Result Ok(Mount { typ: Some(MountTypeEnum::BIND), - source: Some(validate_absolute_mount_source(source, "bind source")?), - target: Some(validate_container_mount_target(target)?), + source: Some( + driver_mounts::validate_absolute_mount_source(source, "bind source") + .map_err(Status::failed_precondition)?, + ), + target: Some( + driver_mounts::validate_container_mount_target(target) + .map_err(Status::failed_precondition)?, + ), read_only: Some(*read_only), ..Default::default() }), @@ -1624,14 +1643,23 @@ fn docker_mount_from_config(config: &DockerDriverMountConfig) -> Result Ok(Mount { typ: Some(MountTypeEnum::VOLUME), - source: Some(validate_mount_source(source, "volume source")?), - target: Some(validate_container_mount_target(target)?), + source: Some( + driver_mounts::validate_mount_source(source, "volume source") + .map_err(Status::failed_precondition)?, + ), + target: Some( + driver_mounts::validate_container_mount_target(target) + .map_err(Status::failed_precondition)?, + ), read_only: Some(*read_only), volume_options: subpath .as_ref() .map(|subpath| { Ok::(MountVolumeOptions { - subpath: Some(validate_mount_subpath(subpath)?), + subpath: Some( + driver_mounts::validate_mount_subpath(subpath) + .map_err(Status::failed_precondition)?, + ), ..Default::default() }) }) @@ -1645,7 +1673,10 @@ fn docker_mount_from_config(config: &DockerDriverMountConfig) -> Result Ok(Mount { typ: Some(MountTypeEnum::TMPFS), - target: Some(validate_container_mount_target(target)?), + target: Some( + driver_mounts::validate_container_mount_target(target) + .map_err(Status::failed_precondition)?, + ), tmpfs_options: Some(MountTmpfsOptions { size_bytes: validate_optional_positive_integral_i64( *size_bytes, @@ -1679,7 +1710,8 @@ fn validate_docker_driver_mounts( "docker bind mounts require enable_bind_mounts = true in [openshell.drivers.docker]", )); } - validate_absolute_mount_source(source, "bind source")?; + driver_mounts::validate_absolute_mount_source(source, "bind source") + .map_err(Status::failed_precondition)?; target } DockerDriverMountConfig::Volume { @@ -1688,9 +1720,11 @@ fn validate_docker_driver_mounts( subpath, .. } => { - validate_mount_source(source, "volume source")?; + driver_mounts::validate_mount_source(source, "volume source") + .map_err(Status::failed_precondition)?; if let Some(subpath) = subpath { - validate_mount_subpath(subpath)?; + driver_mounts::validate_mount_subpath(subpath) + .map_err(Status::failed_precondition)?; } target } @@ -1708,7 +1742,8 @@ fn validate_docker_driver_mounts( target } }; - let target = validate_container_mount_target(target)?; + let target = driver_mounts::validate_container_mount_target(target) + .map_err(Status::failed_precondition)?; if !targets.insert(target.clone()) { return Err(Status::failed_precondition(format!( "duplicate docker driver_config mount target '{target}'" @@ -1718,51 +1753,6 @@ fn validate_docker_driver_mounts( Ok(()) } -fn validate_absolute_mount_source(source: &str, field: &str) -> Result { - let source = validate_mount_source(source, field)?; - if !Path::new(&source).is_absolute() { - return Err(Status::failed_precondition(format!( - "{field} must be an absolute host path" - ))); - } - Ok(source) -} - -fn validate_mount_source(source: &str, field: &str) -> Result { - let source = source.trim(); - if source.is_empty() { - return Err(Status::failed_precondition(format!( - "{field} must not be empty" - ))); - } - if source.as_bytes().contains(&0) { - return Err(Status::failed_precondition(format!( - "{field} must not contain NUL bytes" - ))); - } - Ok(source.to_string()) -} - -fn validate_mount_subpath(subpath: &str) -> Result { - let subpath = subpath.trim(); - if subpath.is_empty() { - return Err(Status::failed_precondition( - "mount subpath must not be empty", - )); - } - let path = Path::new(subpath); - if path.is_absolute() - || path - .components() - .any(|component| matches!(component, std::path::Component::ParentDir)) - { - return Err(Status::failed_precondition( - "mount subpath must be relative and must not contain '..'", - )); - } - Ok(subpath.to_string()) -} - fn validate_optional_positive_integral_i64( value: Option, field: &str, @@ -1828,96 +1818,13 @@ fn docker_tmpfs_option(option: &str) -> Result, Status> { } } -fn validate_container_mount_target(target: &str) -> Result { - let target = normalize_container_mount_target(target); - if target.is_empty() { - return Err(Status::failed_precondition( - "mount target must not be empty", - )); - } - if target.as_bytes().contains(&0) { - return Err(Status::failed_precondition( - "mount target must not contain NUL bytes", - )); - } - if !target.starts_with('/') { - return Err(Status::failed_precondition( - "mount target must be an absolute container path", - )); - } - if target == "/" { - return Err(Status::failed_precondition( - "mount target must not be the container root", - )); - } - let path = Path::new(&target); - if path - .components() - .any(|component| matches!(component, std::path::Component::ParentDir)) - { - return Err(Status::failed_precondition( - "mount target must not contain '..'", - )); - } - if target == "/sandbox" { - return Err(Status::failed_precondition( - "mount target '/sandbox' is reserved for the OpenShell workspace", - )); - } - for reserved in [ - "/opt/openshell", - "/etc/openshell/auth", - "/etc/openshell/tls", - "/run/netns", - ] { - if path_is_or_under(&target, reserved) { - return Err(Status::failed_precondition(format!( - "mount target '{target}' conflicts with reserved OpenShell path '{reserved}'" - ))); - } - } - Ok(target) -} - -fn normalize_container_mount_target(target: &str) -> String { - let target = target.trim(); - if target == "/" { - return target.to_string(); - } - target.trim_end_matches('/').to_string() -} - -fn path_is_or_under(path: &str, parent: &str) -> bool { - path == parent - || path - .strip_prefix(parent) - .is_some_and(|rest| rest.starts_with('/')) -} - -fn proto_struct_to_json_object( - config: &prost_types::Struct, -) -> serde_json::Map { - config - .fields - .iter() - .map(|(key, value)| (key.clone(), proto_value_to_json(value))) - .collect() -} - -fn proto_value_to_json(value: &prost_types::Value) -> serde_json::Value { - match value.kind.as_ref() { - Some(prost_types::value::Kind::NumberValue(num)) => serde_json::Number::from_f64(*num) - .map_or(serde_json::Value::Null, serde_json::Value::Number), - Some(prost_types::value::Kind::StringValue(val)) => serde_json::Value::String(val.clone()), - Some(prost_types::value::Kind::BoolValue(val)) => serde_json::Value::Bool(*val), - Some(prost_types::value::Kind::StructValue(val)) => { - serde_json::Value::Object(proto_struct_to_json_object(val)) - } - Some(prost_types::value::Kind::ListValue(list)) => { - serde_json::Value::Array(list.values.iter().map(proto_value_to_json).collect()) - } - Some(prost_types::value::Kind::NullValue(_)) | None => serde_json::Value::Null, - } +fn docker_volume_is_bind_backed(volume: &bollard::models::Volume) -> bool { + volume.driver == "local" + && volume.options.get("o").is_some_and(|options| { + options + .split(',') + .any(|option| option.trim().eq_ignore_ascii_case("bind")) + }) } fn build_binds( @@ -2193,7 +2100,7 @@ fn build_container_create_body( pids_limit: docker_pids_limit(config.sandbox_pids_limit)?, device_requests: docker_gpu_device_requests(spec.gpu, &spec.gpu_device), binds: Some(build_binds(sandbox, config)?), - mounts: (!user_mounts.is_empty()).then_some(user_mounts), + mounts: Some(user_mounts), restart_policy: Some(RestartPolicy { name: Some(RestartPolicyNameEnum::UNLESS_STOPPED), maximum_retry_count: None, diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index 61b93742c..cb3ef5c12 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -116,6 +116,21 @@ fn json_value(value: serde_json::Value) -> prost_types::Value { } } +fn inspected_volume(driver: &str, options: HashMap) -> bollard::models::Volume { + bollard::models::Volume { + name: "openshell-test-volume".to_string(), + driver: driver.to_string(), + mountpoint: "/var/lib/docker/volumes/openshell-test-volume/_data".to_string(), + created_at: None, + status: None, + labels: HashMap::new(), + scope: None, + cluster_volume: None, + options, + usage_data: None, + } +} + #[test] fn container_visible_endpoint_rewrites_loopback_hosts() { assert_eq!( @@ -619,6 +634,63 @@ fn build_container_create_body_includes_driver_config_mounts() { ); } +#[test] +fn driver_config_defaults_volume_mounts_to_read_only() { + let mut sandbox = test_sandbox(); + sandbox + .spec + .as_mut() + .unwrap() + .template + .as_mut() + .unwrap() + .driver_config = Some(json_struct(serde_json::json!({ + "mounts": [{ + "type": "volume", + "source": "work-nfs", + "target": "/sandbox/work" + }] + }))); + + let body = build_container_create_body(&sandbox, &runtime_config()).unwrap(); + let mounts = body + .host_config + .unwrap() + .mounts + .expect("driver config mounts should be set"); + + assert_eq!(mounts[0].read_only, Some(true)); +} + +#[test] +fn driver_config_allows_explicit_writable_volume_mounts() { + let mut sandbox = test_sandbox(); + sandbox + .spec + .as_mut() + .unwrap() + .template + .as_mut() + .unwrap() + .driver_config = Some(json_struct(serde_json::json!({ + "mounts": [{ + "type": "volume", + "source": "work-nfs", + "target": "/sandbox/work", + "read_only": false + }] + }))); + + let body = build_container_create_body(&sandbox, &runtime_config()).unwrap(); + let mounts = body + .host_config + .unwrap() + .mounts + .expect("driver config mounts should be set"); + + assert_eq!(mounts[0].read_only, Some(false)); +} + #[test] fn driver_config_rejects_bind_mounts_unless_enabled() { let mut sandbox = test_sandbox(); @@ -678,6 +750,36 @@ fn build_container_create_body_includes_bind_mounts_when_enabled() { assert_eq!(mounts[0].read_only, Some(true)); } +#[test] +fn driver_config_defaults_enabled_bind_mounts_to_read_only() { + let mut sandbox = test_sandbox(); + sandbox + .spec + .as_mut() + .unwrap() + .template + .as_mut() + .unwrap() + .driver_config = Some(json_struct(serde_json::json!({ + "mounts": [{ + "type": "bind", + "source": "/host/path", + "target": "/sandbox/host" + }] + }))); + let mut config = runtime_config(); + config.enable_bind_mounts = true; + + let body = build_container_create_body(&sandbox, &config).unwrap(); + let mounts = body + .host_config + .unwrap() + .mounts + .expect("driver config mounts should be set"); + + assert_eq!(mounts[0].read_only, Some(true)); +} + #[test] fn driver_config_rejects_relative_bind_sources_when_enabled() { let mut sandbox = test_sandbox(); @@ -755,6 +857,44 @@ fn driver_config_rejects_reserved_mount_targets() { assert!(err.message().contains("reserved OpenShell path")); } +#[test] +fn docker_local_volume_with_bind_option_is_bind_backed() { + let volume = inspected_volume( + "local", + HashMap::from([ + ("type".to_string(), "none".to_string()), + ("o".to_string(), "rw,bind".to_string()), + ("device".to_string(), "/tmp/openshell".to_string()), + ]), + ); + + assert!(docker_volume_is_bind_backed(&volume)); +} + +#[test] +fn docker_local_volume_without_bind_option_is_not_bind_backed() { + let volume = inspected_volume( + "local", + HashMap::from([ + ("type".to_string(), "nfs".to_string()), + ("o".to_string(), "addr=127.0.0.1,rw".to_string()), + ("device".to_string(), ":/exports/openshell".to_string()), + ]), + ); + + assert!(!docker_volume_is_bind_backed(&volume)); +} + +#[test] +fn docker_nonlocal_volume_with_bind_option_is_not_bind_backed() { + let volume = inspected_volume( + "custom", + HashMap::from([("o".to_string(), "bind".to_string())]), + ); + + assert!(!docker_volume_is_bind_backed(&volume)); +} + #[test] fn build_environment_uses_token_file_without_raw_token_env() { let mut sandbox = test_sandbox(); diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index 79741ae6d..ec5e5d12d 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -70,10 +70,12 @@ to sandbox requests. The driver still uses internal bind mounts for OpenShell-owned token and TLS material. Podman `bind` mounts accept `source`, `target`, and optional `read_only`. -Podman image and volume mounts do not support `subpath` in OpenShell driver -config. Mount targets must be absolute container paths and must not replace the -workspace root (`/sandbox`) or overlap OpenShell supervisor files, auth -material, TLS material, or `/run/netns`. +User-supplied bind and volume mounts are read-only by default; set +`read_only: false` to make them writable. Podman image and volume mounts do not +support `subpath` in OpenShell driver config. Mount targets must be absolute +container paths and must not replace the workspace root (`/sandbox`) or overlap +OpenShell supervisor files, `/etc/openshell`, `/etc/openshell-tls`, or +`/run/netns`. Example named-volume usage: diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index f2783181b..0dd0c42c2 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -6,6 +6,7 @@ use crate::config::PodmanComputeConfig; use openshell_core::gpu::cdi_gpu_device_ids; use openshell_core::proto::compute::v1::{DriverSandbox, DriverSandboxTemplate}; +use openshell_core::{driver_mounts, proto_struct}; use serde::Serialize; use serde_json::Value; use std::collections::{BTreeMap, HashSet}; @@ -70,13 +71,13 @@ enum PodmanDriverMountConfig { Bind { source: String, target: String, - #[serde(default)] + #[serde(default = "default_true")] read_only: bool, }, Volume { source: String, target: String, - #[serde(default)] + #[serde(default = "default_true")] read_only: bool, #[serde(default)] subpath: Option, @@ -521,8 +522,8 @@ fn podman_user_mounts( } => { result.mounts.push(Mount { kind: "bind".into(), - source: validate_absolute_mount_source(&source, "bind source")?, - destination: validate_container_mount_target(&target)?, + source: driver_mounts::validate_absolute_mount_source(&source, "bind source")?, + destination: driver_mounts::validate_container_mount_target(&target)?, options: vec![ if read_only { "ro" } else { "rw" }.to_string(), "rbind".to_string(), @@ -537,8 +538,8 @@ fn podman_user_mounts( } => { reject_subpath(subpath.as_deref(), "podman volume mounts")?; result.volumes.push(NamedVolume { - name: validate_mount_source(&source, "volume source")?, - dest: validate_container_mount_target(&target)?, + name: driver_mounts::validate_mount_source(&source, "volume source")?, + dest: driver_mounts::validate_container_mount_target(&target)?, options: vec![if read_only { "ro" } else { "rw" }.to_string()], }); } @@ -564,7 +565,7 @@ fn podman_user_mounts( result.mounts.push(Mount { kind: "tmpfs".into(), source: "tmpfs".into(), - destination: validate_container_mount_target(&target)?, + destination: driver_mounts::validate_container_mount_target(&target)?, options, }); } @@ -576,8 +577,8 @@ fn podman_user_mounts( } => { reject_subpath(subpath.as_deref(), "podman image mounts")?; result.image_volumes.push(ImageVolume { - source: validate_mount_source(&source, "image source")?, - destination: validate_container_mount_target(&target)?, + source: driver_mounts::validate_mount_source(&source, "image source")?, + destination: driver_mounts::validate_container_mount_target(&target)?, rw: !read_only, }); } @@ -593,7 +594,7 @@ fn podman_driver_config( let Some(config) = template.driver_config.as_ref() else { return Ok(PodmanSandboxDriverConfig::default()); }; - let json = Value::Object(proto_struct_to_json_object(config)); + let json = Value::Object(proto_struct::struct_to_json_object(config)); let config: PodmanSandboxDriverConfig = serde_json::from_value(json) .map_err(|err| format!("invalid podman driver_config: {err}"))?; validate_podman_driver_mounts(&config.mounts, enable_bind_mounts)?; @@ -614,7 +615,7 @@ fn validate_podman_driver_mounts( .to_string(), ); } - validate_absolute_mount_source(source, "bind source")?; + driver_mounts::validate_absolute_mount_source(source, "bind source")?; target } PodmanDriverMountConfig::Volume { @@ -623,7 +624,7 @@ fn validate_podman_driver_mounts( subpath, .. } => { - validate_mount_source(source, "volume source")?; + driver_mounts::validate_mount_source(source, "volume source")?; reject_subpath(subpath.as_deref(), "podman volume mounts")?; target } @@ -644,12 +645,12 @@ fn validate_podman_driver_mounts( subpath, .. } => { - validate_mount_source(source, "image source")?; + driver_mounts::validate_mount_source(source, "image source")?; reject_subpath(subpath.as_deref(), "podman image mounts")?; target } }; - let target = validate_container_mount_target(target)?; + let target = driver_mounts::validate_container_mount_target(target)?; if !targets.insert(target.clone()) { return Err(format!( "duplicate podman driver_config mount target '{target}'" @@ -659,32 +660,11 @@ fn validate_podman_driver_mounts( Ok(()) } -fn validate_absolute_mount_source(source: &str, field: &str) -> Result { - let source = validate_mount_source(source, field)?; - if !Path::new(&source).is_absolute() { - return Err(format!("{field} must be an absolute host path")); - } - Ok(source) -} - -fn validate_mount_source(source: &str, field: &str) -> Result { - let source = source.trim(); - if source.is_empty() { - return Err(format!("{field} must not be empty")); - } - if source.as_bytes().contains(&0) { - return Err(format!("{field} must not contain NUL bytes")); - } - Ok(source.to_string()) -} - fn reject_subpath(subpath: Option<&str>, mount_type: &str) -> Result<(), String> { let Some(subpath) = subpath else { return Ok(()); }; - if subpath.trim().is_empty() { - return Err("mount subpath must not be empty".to_string()); - } + driver_mounts::validate_mount_subpath(subpath)?; Err(format!("{mount_type} do not support subpath")) } @@ -741,85 +721,6 @@ fn validate_tmpfs_options(options: &[String]) -> Result, String> { .collect() } -fn validate_container_mount_target(target: &str) -> Result { - let target = normalize_container_mount_target(target); - if target.is_empty() { - return Err("mount target must not be empty".to_string()); - } - if target.as_bytes().contains(&0) { - return Err("mount target must not contain NUL bytes".to_string()); - } - if !target.starts_with('/') { - return Err("mount target must be an absolute container path".to_string()); - } - if target == "/" { - return Err("mount target must not be the container root".to_string()); - } - let path = Path::new(&target); - if path - .components() - .any(|component| matches!(component, std::path::Component::ParentDir)) - { - return Err("mount target must not contain '..'".to_string()); - } - if target == "/sandbox" { - return Err("mount target '/sandbox' is reserved for the OpenShell workspace".to_string()); - } - for reserved in [ - "/opt/openshell", - "/etc/openshell/auth", - "/etc/openshell/tls", - "/run/netns", - ] { - if path_is_or_under(&target, reserved) { - return Err(format!( - "mount target '{target}' conflicts with reserved OpenShell path '{reserved}'" - )); - } - } - Ok(target) -} - -fn normalize_container_mount_target(target: &str) -> String { - let target = target.trim(); - if target == "/" { - return target.to_string(); - } - target.trim_end_matches('/').to_string() -} - -fn path_is_or_under(path: &str, parent: &str) -> bool { - path == parent - || path - .strip_prefix(parent) - .is_some_and(|rest| rest.starts_with('/')) -} - -fn proto_struct_to_json_object(config: &prost_types::Struct) -> serde_json::Map { - config - .fields - .iter() - .map(|(key, value)| (key.clone(), proto_value_to_json(value))) - .collect() -} - -fn proto_value_to_json(value: &prost_types::Value) -> Value { - match value.kind.as_ref() { - Some(prost_types::value::Kind::NumberValue(num)) => { - serde_json::Number::from_f64(*num).map_or(Value::Null, Value::Number) - } - Some(prost_types::value::Kind::StringValue(val)) => Value::String(val.clone()), - Some(prost_types::value::Kind::BoolValue(val)) => Value::Bool(*val), - Some(prost_types::value::Kind::StructValue(val)) => { - Value::Object(proto_struct_to_json_object(val)) - } - Some(prost_types::value::Kind::ListValue(list)) => { - Value::Array(list.values.iter().map(proto_value_to_json).collect()) - } - Some(prost_types::value::Kind::NullValue(_)) | None => Value::Null, - } -} - /// Build the Podman container creation JSON spec. #[cfg(test)] #[must_use] @@ -1733,6 +1634,75 @@ mod tests { })); } + #[test] + fn container_spec_defaults_volume_mounts_to_read_only() { + use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + + let mut sandbox = test_sandbox("test-id", "test-name"); + sandbox.spec = Some(DriverSandboxSpec { + template: Some(DriverSandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "mounts": [{ + "type": "volume", + "source": "work-nfs", + "target": "/sandbox/work" + }] + }))), + ..Default::default() + }), + ..Default::default() + }); + let config = test_config(); + + let spec = build_container_spec(&sandbox, &config); + let volumes = spec["volumes"] + .as_array() + .expect("volumes should be an array"); + + assert!(volumes.iter().any(|volume| { + volume["name"].as_str() == Some("work-nfs") + && volume["dest"].as_str() == Some("/sandbox/work") + && volume["options"].as_array().is_some_and(|options| { + options.iter().any(|option| option.as_str() == Some("ro")) + }) + })); + } + + #[test] + fn container_spec_allows_explicit_writable_volume_mounts() { + use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + + let mut sandbox = test_sandbox("test-id", "test-name"); + sandbox.spec = Some(DriverSandboxSpec { + template: Some(DriverSandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "mounts": [{ + "type": "volume", + "source": "work-nfs", + "target": "/sandbox/work", + "read_only": false + }] + }))), + ..Default::default() + }), + ..Default::default() + }); + let config = test_config(); + + let spec = build_container_spec(&sandbox, &config); + let volumes = spec["volumes"] + .as_array() + .expect("volumes should be an array"); + + assert!(volumes.iter().any(|volume| { + volume["name"].as_str() == Some("work-nfs") + && volume["dest"].as_str() == Some("/sandbox/work") + && volume["options"].as_array().is_some_and(|options| { + options.iter().any(|option| option.as_str() == Some("rw")) + }) + })); + } + #[test] fn driver_config_rejects_bind_mounts_unless_enabled() { use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; @@ -1798,6 +1768,45 @@ mod tests { })); } + #[test] + fn container_spec_defaults_enabled_bind_mounts_to_read_only() { + use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + + let mut sandbox = test_sandbox("test-id", "test-name"); + sandbox.spec = Some(DriverSandboxSpec { + template: Some(DriverSandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "mounts": [{ + "type": "bind", + "source": "/host/path", + "target": "/sandbox/host" + }] + }))), + ..Default::default() + }), + ..Default::default() + }); + let mut config = test_config(); + config.enable_bind_mounts = true; + + let spec = build_container_spec(&sandbox, &config); + let mounts = spec["mounts"] + .as_array() + .expect("mounts should be an array"); + + assert!(mounts.iter().any(|mount| { + mount["type"].as_str() == Some("bind") + && mount["source"].as_str() == Some("/host/path") + && mount["destination"].as_str() == Some("/sandbox/host") + && mount["options"].as_array().is_some_and(|options| { + options.iter().any(|option| option.as_str() == Some("ro")) + && options + .iter() + .any(|option| option.as_str() == Some("rbind")) + }) + })); + } + #[test] fn driver_config_rejects_relative_bind_sources_when_enabled() { use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index b22853948..6ede5c4fd 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -215,6 +215,9 @@ guest_tls_key = "/etc/openshell/certs/client-key.pem" network_name = "openshell-docker" host_gateway_ip = "172.17.0.1" ssh_socket_path = "/run/openshell/ssh.sock" +# Unsafe operator override. Host bind mounts, including Docker local-driver +# bind-backed volumes, expose gateway-host paths inside sandboxes and can +# negate OpenShell isolation and filesystem controls. enable_bind_mounts = false # Set to 0 to leave Docker's runtime default unchanged. sandbox_pids_limit = 2048 diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index 28f3a5067..9526413fc 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -76,8 +76,10 @@ For GPU-backed Docker sandboxes, configure Docker CDI before starting the gatewa Docker driver config accepts user-supplied `volume` and `tmpfs` mounts. It also accepts `bind` mounts when `[openshell.drivers.docker]` sets -`enable_bind_mounts = true` in `gateway.toml`. Image mounts are not accepted in -Docker sandbox driver config. See Docker's [storage documentation](https://docs.docker.com/engine/storage/) for more information. +`enable_bind_mounts = true` in `gateway.toml`. See Docker's [storage documentation](https://docs.docker.com/engine/storage/) for more information. +Docker local-driver named volumes created with bind options also expose +gateway-host paths, so OpenShell treats them like bind mounts and requires +`enable_bind_mounts = true`. Use a `volume` mount for existing Docker named volumes: @@ -113,13 +115,14 @@ Docker mount schema: | Type | Fields | |---|---| -| `bind` | `source`, `target`, optional `read_only` (`false` by default). `source` must be an absolute host path. Requires `enable_bind_mounts = true`. | -| `volume` | `source`, `target`, optional `read_only` (`false` by default), optional `subpath`. The named volume must already exist. | +| `bind` | `source`, `target`, optional `read_only` (`true` by default). `source` must be an absolute host path. Requires `enable_bind_mounts = true`. | +| `volume` | `source`, `target`, optional `read_only` (`true` by default), optional `subpath`. The named volume must already exist. Docker local-driver bind-backed volumes require `enable_bind_mounts = true`. | | `tmpfs` | `target`, optional `options`, optional `size_bytes`, optional `mode`. | OpenShell rejects mount targets that replace the workspace root, container root, -supervisor files, TLS material, authentication material, or network namespace -paths. Mounted paths remain subject to sandbox filesystem policy. +supervisor files, `/etc/openshell`, `/etc/openshell-tls`, authentication +material, or network namespace paths. These checks do not make host bind mounts +safe. ## Podman Driver @@ -174,16 +177,16 @@ Podman mount schema: | Type | Fields | |---|---| -| `bind` | `source`, `target`, optional `read_only` (`false` by default). `source` must be an absolute host path. Requires `enable_bind_mounts = true`. | -| `volume` | `source`, `target`, optional `read_only` (`false` by default). The named volume must already exist. | +| `bind` | `source`, `target`, optional `read_only` (`true` by default). `source` must be an absolute host path. Requires `enable_bind_mounts = true`. | +| `volume` | `source`, `target`, optional `read_only` (`true` by default). The named volume must already exist. | | `tmpfs` | `target`, optional `options`, optional `size_bytes`, optional `mode`. | | `image` | `source`, `target`, optional `read_only` (`true` by default). | Podman `volume` and `image` mounts do not support `subpath` in OpenShell driver config. OpenShell rejects mount targets that replace the workspace root, -container root, supervisor files, TLS material, authentication material, or -network namespace paths. Mounted paths remain subject to sandbox filesystem -policy. +container root, supervisor files, `/etc/openshell`, `/etc/openshell-tls`, +authentication material, or network namespace paths. These checks do not make +host bind mounts safe. ## MicroVM Driver