From 64bbcd7c470494336417895512364aa35cc0e9ba Mon Sep 17 00:00:00 2001 From: Florian Bergmann Date: Fri, 3 Jul 2026 23:35:55 +0200 Subject: [PATCH 1/2] feat(driver-podman): add per-sandbox user namespace mode Add a `userns_mode` field to `PodmanSandboxDriverConfig` so each sandbox can independently select its user namespace mode via `--driver-config-json`. Accepts the same values as `podman run --userns`, e.g. "keep-id", "keep-id:uid=200,gid=210", "auto", "nomap", or "host". Empty (default) leaves Podman's default behavior unchanged. The `build_userns` helper reads from the per-sandbox driver config and parses the string into a libpod `Namespace` object, splitting on the first colon: the left side becomes `nsmode` and the right side becomes `value`. When unset or whitespace-only, the `userns` field is omitted from the container spec JSON so Podman uses its default (host namespace). Includes unit tests covering all parsing paths, whitespace trimming, and coexistence with mounts in driver config. Updates the driver README with usage examples. Signed-off-by: Florian Bergmann --- crates/openshell-driver-podman/README.md | 20 +- .../openshell-driver-podman/src/container.rs | 228 ++++++++++++++++++ docs/reference/sandbox-compute-drivers.mdx | 10 + 3 files changed, 255 insertions(+), 3 deletions(-) diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index c0c84132b9..4f09b4a823 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -50,11 +50,17 @@ The container spec in `container.rs` sets these security-critical fields: The restricted agent child does not retain these supervisor privileges. -## Driver Config Mounts +## Per-Sandbox Driver Config 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: +driver. + +`userns_mode`: optional per-sandbox user namespace mode. Accepts the same +values as `podman run --userns`, such as `keep-id`, +`keep-id:uid=998,gid=998`, `auto`, `nomap`, or `host`. + +The driver also 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`. @@ -89,6 +95,14 @@ openshell sandbox create \ -- claude ``` +Example per-sandbox user namespace selection: + +```shell +openshell sandbox create \ + --driver-config-json '{"podman":{"userns_mode":"keep-id:uid=998,gid=998"}}' \ + -- claude +``` + ### Capability Breakdown | Capability | Purpose | diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 7c9ab269f1..49b8313473 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -71,6 +71,12 @@ pub struct PodmanSandboxDriverConfig { deserialize_with = "deserialize_optional_non_empty_string_list" )] pub cdi_devices: Option>, + /// Optional per-sandbox user namespace mode. + /// + /// Accepts the same values as `podman run --userns`, e.g. `"keep-id"`, + /// `"keep-id:uid=200,gid=210"`, `"auto"`, `"nomap"`, or `"host"`. + #[serde(default)] + pub userns_mode: String, mounts: Vec, } @@ -196,6 +202,12 @@ struct ContainerSpec { /// the gateway server running on the host in rootless mode. hostadd: Vec, netns: NetNS, + /// User namespace mode. Defaults to Podman's own behavior when + /// `driver_config.userns_mode` is unset. When set to e.g. + /// `"keep-id:uid=200,gid=210"`, the sandbox container runs inside + /// a user namespace where container UID 0 maps to the host UID 200. + #[serde(skip_serializing_if = "Option::is_none")] + userns: Option, // Matches libpod's network spec format, which is `{name: {opts}}` where // empty opts is a unit struct rather than `()`. Keep as a map so JSON // serialization matches the API exactly. @@ -298,6 +310,20 @@ struct NetNS { nsmode: String, } +/// User namespace specification for the libpod container create API. +/// +/// Mirrors Podman's `specgen.Namespace` struct, which has two fields: +/// `nsmode` (e.g. `"keep-id"`, `"host"`, `"auto"`) and an optional `value` +/// string holding the mode-specific options (e.g. `"uid=200,gid=210"` for +/// `keep-id`). The CLI `--userns keep-id:uid=200,gid=210` is split on the +/// first colon: `nsmode = "keep-id"`, `value = "uid=200,gid=210"`. +#[derive(Serialize)] +struct UserNS { + nsmode: String, + #[serde(skip_serializing_if = "String::is_empty")] + value: String, +} + #[derive(Serialize)] struct NetworkAttachment {} @@ -813,10 +839,12 @@ pub fn build_container_spec_with_token_and_gpu_devices( let image = resolve_image(sandbox, config); let name = container_name(&sandbox.name); let vol = volume_name(&sandbox.id); + let sandbox_driver_config = PodmanSandboxDriverConfig::from_sandbox(sandbox)?; let env = build_env(sandbox, config, image); let labels = build_labels(sandbox); let resource_limits = build_resource_limits(sandbox, config); + let user_mounts = podman_user_mounts(sandbox, config.enable_bind_mounts) .map_err(ComputeDriverError::InvalidArgument)?; let devices = gpu_device_ids.map(|device_ids| { @@ -971,6 +999,7 @@ pub fn build_container_spec_with_token_and_gpu_devices( netns: NetNS { nsmode: "bridge".to_string(), }, + userns: build_userns(&sandbox_driver_config.userns_mode), networks, devices, // Mount a tmpfs at /run/netns so the sandbox supervisor can create @@ -1050,6 +1079,32 @@ pub fn build_container_spec_with_token_and_gpu_devices( Ok(serde_json::to_value(container_spec).expect("ContainerSpec serialization cannot fail")) } +/// Parse a `--userns` style string (e.g. `"keep-id:uid=200,gid=210"`) +/// into a libpod `Namespace` object. +/// +/// Splits on the first colon: the left side becomes `nsmode`, the right +/// side becomes `value`. If there is no colon, the entire string is the +/// `nsmode` and `value` is empty. +/// +/// Returns `None` when the input is empty, so the `userns` field is +/// omitted from the JSON and Podman uses its default (host namespace). +fn build_userns(userns_mode: &str) -> Option { + let userns_mode = userns_mode.trim(); + if userns_mode.is_empty() { + return None; + } + match userns_mode.split_once(':') { + Some((nsmode, value)) => Some(UserNS { + nsmode: nsmode.to_string(), + value: value.to_string(), + }), + None => Some(UserNS { + nsmode: userns_mode.to_string(), + value: String::new(), + }), + } +} + fn hostadd_entries(config: &PodmanComputeConfig) -> Vec { let host_gateway_ip = config.host_gateway_ip.trim(); if host_gateway_ip.is_empty() { @@ -2361,4 +2416,177 @@ mod tests { .count(); assert_eq!(bind_count, 0, "no bind mounts without TLS config"); } + + // ── userns tests ───────────────────────────────────────────────────── + + #[test] + fn container_spec_omits_userns_when_unset() { + let sandbox = test_sandbox("test-id", "test-name"); + let config = test_config(); + let spec = build_container_spec(&sandbox, &config); + + assert!( + spec.get("userns").is_none(), + "userns should be omitted when userns_mode is empty" + ); + } + + #[test] + fn container_spec_omits_userns_when_whitespace_only() { + use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + + let sandbox = test_sandbox("test-id", "test-name"); + let mut sandbox = sandbox; + sandbox.spec = Some(DriverSandboxSpec { + template: Some(DriverSandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "userns_mode": " " + }))), + ..Default::default() + }), + ..Default::default() + }); + let config = test_config(); + let spec = build_container_spec(&sandbox, &config); + + assert!( + spec.get("userns").is_none(), + "userns should be omitted when userns_mode is whitespace only" + ); + } + + #[test] + fn container_spec_userns_keep_id_without_options_from_driver_config() { + 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!({ + "userns_mode": "keep-id" + }))), + ..Default::default() + }), + ..Default::default() + }); + let config = test_config(); + let spec = build_container_spec(&sandbox, &config); + + let userns = spec.get("userns").expect("userns should be present"); + assert_eq!(userns["nsmode"].as_str(), Some("keep-id")); + assert!( + userns.get("value").is_none() || userns["value"].as_str() == Some(""), + "value should be omitted or empty for keep-id without options" + ); + } + + #[test] + fn container_spec_userns_keep_id_with_uid_and_gid_from_driver_config() { + 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!({ + "userns_mode": "keep-id:uid=200,gid=210" + }))), + ..Default::default() + }), + ..Default::default() + }); + let config = test_config(); + let spec = build_container_spec(&sandbox, &config); + + let userns = spec.get("userns").expect("userns should be present"); + assert_eq!(userns["nsmode"].as_str(), Some("keep-id")); + assert_eq!(userns["value"].as_str(), Some("uid=200,gid=210")); + } + + #[test] + fn container_spec_userns_auto_from_driver_config() { + 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!({ + "userns_mode": "auto" + }))), + ..Default::default() + }), + ..Default::default() + }); + let config = test_config(); + let spec = build_container_spec(&sandbox, &config); + + let userns = spec.get("userns").expect("userns should be present"); + assert_eq!(userns["nsmode"].as_str(), Some("auto")); + } + + #[test] + fn container_spec_userns_trims_whitespace_from_driver_config() { + 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!({ + "userns_mode": " keep-id:uid=200,gid=210 " + }))), + ..Default::default() + }), + ..Default::default() + }); + let config = test_config(); + let spec = build_container_spec(&sandbox, &config); + + let userns = spec.get("userns").expect("userns should be present"); + assert_eq!(userns["nsmode"].as_str(), Some("keep-id")); + assert_eq!(userns["value"].as_str(), Some("uid=200,gid=210")); + } + + #[test] + fn driver_config_accepts_userns_mode_alongside_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!({ + "userns_mode": "nomap", + "mounts": [{ + "type": "tmpfs", + "target": "/sandbox/tmp" + }] + }))), + ..Default::default() + }), + ..Default::default() + }); + let config = test_config(); + let spec = build_container_spec(&sandbox, &config); + + assert_eq!(spec["userns"]["nsmode"].as_str(), Some("nomap")); + } + + #[test] + fn build_userns_splits_on_first_colon() { + // Single colon: left = nsmode, right = value + let result = build_userns("keep-id:uid=200,gid=210").unwrap(); + assert_eq!(result.nsmode, "keep-id"); + assert_eq!(result.value, "uid=200,gid=210"); + } + + #[test] + fn build_userns_no_colon_returns_nsmode_only() { + let result = build_userns("auto").unwrap(); + assert_eq!(result.nsmode, "auto"); + assert!(result.value.is_empty()); + } + + #[test] + fn build_userns_empty_returns_none() { + assert!(build_userns("").is_none()); + assert!(build_userns(" ").is_none()); + } } diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index f860d64d4b..eb424d3573 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -183,6 +183,16 @@ 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 User Namespace Mode + +Pass `userns_mode` in per-sandbox driver config to run a sandbox container inside a user namespace. Accepts the same values as `podman run --userns`, such as `"keep-id"`, `"keep-id:uid=998,gid=998"`, `"auto"`, `"nomap"`, or `"host"`. Omitting it leaves Podman's default behavior unchanged. + +```shell +openshell sandbox create \ + --driver-config-json '{"podman":{"userns_mode":"keep-id:uid=998,gid=998"}}' \ + -- claude +``` + ### Podman Driver Config Mounts Podman driver config accepts user-supplied `volume`, `tmpfs`, and `image` From 7db359d48352e76ed61a949b7187e3ec3b071285 Mon Sep 17 00:00:00 2001 From: Florian Bergmann Date: Fri, 24 Jul 2026 10:58:29 +0200 Subject: [PATCH 2/2] fix(driver-podman): gate userns_mode behind allow_userns operator flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tenant-supplied userns_mode flowed into the libpod spec with no validation, allowing escape modes such as host and ns: to weaken or bypass user-namespace isolation. Introduce an operator gate (allow_userns = false by default) modelled on the existing enable_bind_mounts pattern. When false, any non-empty userns_mode in a sandbox request is rejected. When true, only the safe modes keep-id, auto, and nomap are accepted; host, ns:, and all other modes are unconditionally rejected regardless of operator config. For keep-id, the uid and gid option values are validated to be non-negative integers and must use only the uid/gid keys — arbitrary option strings are rejected to prevent injection via the value field. Changes: - PodmanComputeConfig: add allow_userns: bool (default false) - Replace build_userns with validated_userns + validate_keep_id_value - Existing userns tests updated to use userns_config() helper that sets allow_userns: true; 10 new security-focused unit tests added - docs/reference/gateway-config.mdx: document allow_userns field - docs/reference/sandbox-compute-drivers.mdx: update userns section to reflect gate and permitted modes - README.md: update userns_mode description with gate and safe modes Signed-off-by: Florian Bergmann --- crates/openshell-driver-podman/README.md | 9 +- crates/openshell-driver-podman/src/config.rs | 14 ++ .../openshell-driver-podman/src/container.rs | 222 +++++++++++++++--- docs/reference/gateway-config.mdx | 5 + docs/reference/sandbox-compute-drivers.mdx | 7 +- 5 files changed, 214 insertions(+), 43 deletions(-) diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index 4f09b4a823..44b36639e0 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -55,9 +55,12 @@ The restricted agent child does not retain these supervisor privileges. The gateway forwards the `podman` block from `--driver-config-json` to this driver. -`userns_mode`: optional per-sandbox user namespace mode. Accepts the same -values as `podman run --userns`, such as `keep-id`, -`keep-id:uid=998,gid=998`, `auto`, `nomap`, or `host`. +`userns_mode`: optional per-sandbox user namespace mode. Requires +`allow_userns = true` in the gateway config (analogous to +`enable_bind_mounts`). Permitted modes are `keep-id`, +`keep-id:uid=N,gid=N`, `auto`, and `nomap`. Escape modes (`host`, +`ns:…`) are rejected unconditionally. For `keep-id`, uid/gid option +values must be non-negative integers. The driver also accepts user-supplied `mounts` entries with these Podman mount types: diff --git a/crates/openshell-driver-podman/src/config.rs b/crates/openshell-driver-podman/src/config.rs index 0e29f52dd2..9ed879ac94 100644 --- a/crates/openshell-driver-podman/src/config.rs +++ b/crates/openshell-driver-podman/src/config.rs @@ -126,6 +126,18 @@ pub struct PodmanComputeConfig { /// `template.driver_config`. #[serde(default)] pub enable_bind_mounts: bool, + /// Allow sandboxes to select a user namespace mode via `userns_mode` in + /// per-sandbox driver config. + /// + /// When `false` (the default), any non-empty `userns_mode` in a sandbox + /// request is rejected. When `true`, sandboxes may request the following + /// safe modes: `keep-id`, `auto`, and `nomap`. Escape modes (`host`, + /// `ns:`, etc.) are always rejected regardless of this setting. + /// + /// Analogous to [`enable_bind_mounts`][Self::enable_bind_mounts]: the + /// operator explicitly opts in to tenant-controlled namespace selection. + #[serde(default)] + pub allow_userns: bool, /// Health check interval in seconds for sandbox containers. /// /// Podman runs the health check command at this interval to determine @@ -261,6 +273,7 @@ impl Default for PodmanComputeConfig { guest_tls_key: None, sandbox_pids_limit: DEFAULT_SANDBOX_PIDS_LIMIT, enable_bind_mounts: false, + allow_userns: false, health_check_interval_secs: DEFAULT_HEALTH_CHECK_INTERVAL_SECS, } } @@ -284,6 +297,7 @@ impl std::fmt::Debug for PodmanComputeConfig { .field("guest_tls_key", &self.guest_tls_key) .field("sandbox_pids_limit", &self.sandbox_pids_limit) .field("enable_bind_mounts", &self.enable_bind_mounts) + .field("allow_userns", &self.allow_userns) .field( "health_check_interval_secs", &self.health_check_interval_secs, diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 49b8313473..5309deca66 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -73,8 +73,10 @@ pub struct PodmanSandboxDriverConfig { pub cdi_devices: Option>, /// Optional per-sandbox user namespace mode. /// - /// Accepts the same values as `podman run --userns`, e.g. `"keep-id"`, - /// `"keep-id:uid=200,gid=210"`, `"auto"`, `"nomap"`, or `"host"`. + /// Accepted values when `allow_userns = true` in gateway config: + /// `"keep-id"`, `"keep-id:uid=N,gid=N"`, `"auto"`, or `"nomap"`. + /// Escape modes such as `"host"` and `"ns:..."` are always rejected. + /// Requests are rejected when `allow_userns` is `false` (the default). #[serde(default)] pub userns_mode: String, mounts: Vec, @@ -317,7 +319,7 @@ struct NetNS { /// string holding the mode-specific options (e.g. `"uid=200,gid=210"` for /// `keep-id`). The CLI `--userns keep-id:uid=200,gid=210` is split on the /// first colon: `nsmode = "keep-id"`, `value = "uid=200,gid=210"`. -#[derive(Serialize)] +#[derive(Debug, Serialize)] struct UserNS { nsmode: String, #[serde(skip_serializing_if = "String::is_empty")] @@ -999,7 +1001,8 @@ pub fn build_container_spec_with_token_and_gpu_devices( netns: NetNS { nsmode: "bridge".to_string(), }, - userns: build_userns(&sandbox_driver_config.userns_mode), + userns: validated_userns(&sandbox_driver_config.userns_mode, config.allow_userns) + .map_err(ComputeDriverError::InvalidArgument)?, networks, devices, // Mount a tmpfs at /run/netns so the sandbox supervisor can create @@ -1079,30 +1082,80 @@ pub fn build_container_spec_with_token_and_gpu_devices( Ok(serde_json::to_value(container_spec).expect("ContainerSpec serialization cannot fail")) } -/// Parse a `--userns` style string (e.g. `"keep-id:uid=200,gid=210"`) -/// into a libpod `Namespace` object. -/// -/// Splits on the first colon: the left side becomes `nsmode`, the right -/// side becomes `value`. If there is no colon, the entire string is the -/// `nsmode` and `value` is empty. +/// Safe user-namespace modes a sandbox may request when `allow_userns` is +/// enabled. Escape modes (`host`, `ns:`, `private`) are never permitted even +/// if the operator enables the feature. +const ALLOWED_USERNS_MODES: &[&str] = &["keep-id", "auto", "nomap"]; + +/// Parse and validate a sandbox-supplied `userns_mode` string. /// -/// Returns `None` when the input is empty, so the `userns` field is -/// omitted from the JSON and Podman uses its default (host namespace). -fn build_userns(userns_mode: &str) -> Option { +/// Returns: +/// - `Ok(None)` when the string is empty — the `userns` field is omitted +/// from the JSON and Podman uses its default behavior. +/// - `Ok(Some(UserNS))` when the mode is in [`ALLOWED_USERNS_MODES`] and +/// any `uid`/`gid` options are numeric. +/// - `Err(String)` when: +/// - `allow_userns` is `false` and a non-empty mode was requested. +/// - the mode is not in [`ALLOWED_USERNS_MODES`] (e.g. `"host"`, `"ns:…"`). +/// - a `keep-id` value contains a non-numeric `uid` or `gid`. +fn validated_userns(userns_mode: &str, allow_userns: bool) -> Result, String> { let userns_mode = userns_mode.trim(); if userns_mode.is_empty() { - return None; - } - match userns_mode.split_once(':') { - Some((nsmode, value)) => Some(UserNS { - nsmode: nsmode.to_string(), - value: value.to_string(), - }), - None => Some(UserNS { - nsmode: userns_mode.to_string(), - value: String::new(), - }), + return Ok(None); + } + if !allow_userns { + return Err( + "userns_mode is not permitted; set allow_userns = true in gateway config to enable \ + per-sandbox user namespace selection" + .to_string(), + ); + } + let (nsmode, value) = match userns_mode.split_once(':') { + Some((m, v)) => (m, v), + None => (userns_mode, ""), + }; + if !ALLOWED_USERNS_MODES.contains(&nsmode) { + return Err(format!( + "userns_mode '{nsmode}' is not permitted; allowed modes are: {}", + ALLOWED_USERNS_MODES.join(", ") + )); + } + if nsmode == "keep-id" && !value.is_empty() { + validate_keep_id_value(value)?; + } + Ok(Some(UserNS { + nsmode: nsmode.to_string(), + value: value.to_string(), + })) +} + +/// Validate the options string for `keep-id`, e.g. `"uid=200,gid=210"`. +/// +/// Each comma-separated segment must be `uid=N` or `gid=N` where N is a +/// non-negative integer. Unknown keys and non-numeric values are rejected +/// to prevent unexpected Podman behaviour or injection via the option string. +fn validate_keep_id_value(value: &str) -> Result<(), String> { + for part in value.split(',') { + let part = part.trim(); + let Some((key, val)) = part.split_once('=') else { + return Err(format!( + "keep-id option '{part}' is not in 'key=N' form; expected 'uid=N' or 'gid=N'" + )); + }; + match key { + "uid" | "gid" => { + val.parse::().map_err(|_| { + format!("keep-id {key} value '{val}' must be a non-negative integer") + })?; + } + other => { + return Err(format!( + "keep-id option '{other}' is not recognised; only 'uid' and 'gid' are allowed" + )); + } + } } + Ok(()) } fn hostadd_entries(config: &PodmanComputeConfig) -> Vec { @@ -2419,9 +2472,21 @@ mod tests { // ── userns tests ───────────────────────────────────────────────────── + /// Helper: `test_config()` with `allow_userns` enabled so tests that + /// exercise valid userns modes don't have to repeat the override. + fn userns_config() -> PodmanComputeConfig { + PodmanComputeConfig { + allow_userns: true, + ..test_config() + } + } + + // -- empty / whitespace (no gate needed, trimmed to empty before gate) --- + #[test] fn container_spec_omits_userns_when_unset() { let sandbox = test_sandbox("test-id", "test-name"); + // allow_userns = false in test_config(); empty mode is always a no-op. let config = test_config(); let spec = build_container_spec(&sandbox, &config); @@ -2435,8 +2500,7 @@ mod tests { fn container_spec_omits_userns_when_whitespace_only() { use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; - let sandbox = test_sandbox("test-id", "test-name"); - let mut sandbox = sandbox; + let mut sandbox = test_sandbox("test-id", "test-name"); sandbox.spec = Some(DriverSandboxSpec { template: Some(DriverSandboxTemplate { driver_config: Some(json_struct(serde_json::json!({ @@ -2446,6 +2510,7 @@ mod tests { }), ..Default::default() }); + // Whitespace trims to empty — gate is never reached. let config = test_config(); let spec = build_container_spec(&sandbox, &config); @@ -2455,6 +2520,8 @@ mod tests { ); } + // -- allowed modes (require allow_userns = true) ---------------------- + #[test] fn container_spec_userns_keep_id_without_options_from_driver_config() { use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; @@ -2469,7 +2536,7 @@ mod tests { }), ..Default::default() }); - let config = test_config(); + let config = userns_config(); let spec = build_container_spec(&sandbox, &config); let userns = spec.get("userns").expect("userns should be present"); @@ -2494,7 +2561,7 @@ mod tests { }), ..Default::default() }); - let config = test_config(); + let config = userns_config(); let spec = build_container_spec(&sandbox, &config); let userns = spec.get("userns").expect("userns should be present"); @@ -2516,7 +2583,7 @@ mod tests { }), ..Default::default() }); - let config = test_config(); + let config = userns_config(); let spec = build_container_spec(&sandbox, &config); let userns = spec.get("userns").expect("userns should be present"); @@ -2537,7 +2604,7 @@ mod tests { }), ..Default::default() }); - let config = test_config(); + let config = userns_config(); let spec = build_container_spec(&sandbox, &config); let userns = spec.get("userns").expect("userns should be present"); @@ -2563,30 +2630,107 @@ mod tests { }), ..Default::default() }); - let config = test_config(); + let config = userns_config(); let spec = build_container_spec(&sandbox, &config); assert_eq!(spec["userns"]["nsmode"].as_str(), Some("nomap")); } + // -- validated_userns unit tests -------------------------------------- + + #[test] + fn validated_userns_empty_returns_none_regardless_of_gate() { + assert!(validated_userns("", false).unwrap().is_none()); + assert!(validated_userns(" ", false).unwrap().is_none()); + assert!(validated_userns("", true).unwrap().is_none()); + } + #[test] - fn build_userns_splits_on_first_colon() { - // Single colon: left = nsmode, right = value - let result = build_userns("keep-id:uid=200,gid=210").unwrap(); + fn validated_userns_splits_on_first_colon() { + let result = validated_userns("keep-id:uid=200,gid=210", true) + .unwrap() + .unwrap(); assert_eq!(result.nsmode, "keep-id"); assert_eq!(result.value, "uid=200,gid=210"); } #[test] - fn build_userns_no_colon_returns_nsmode_only() { - let result = build_userns("auto").unwrap(); + fn validated_userns_no_colon_returns_nsmode_only() { + let result = validated_userns("auto", true).unwrap().unwrap(); assert_eq!(result.nsmode, "auto"); assert!(result.value.is_empty()); } + // -- security gate tests ---------------------------------------------- + + #[test] + fn validated_userns_gate_blocks_non_empty_mode_when_disabled() { + for mode in &["keep-id", "auto", "nomap", "host"] { + let err = validated_userns(mode, false).unwrap_err(); + assert!( + err.contains("allow_userns"), + "gate error should mention allow_userns, got: {err}" + ); + } + } + + #[test] + fn validated_userns_rejects_host_even_when_gate_open() { + let err = validated_userns("host", true).unwrap_err(); + assert!( + err.contains("not permitted"), + "host mode should be rejected: {err}" + ); + } + #[test] - fn build_userns_empty_returns_none() { - assert!(build_userns("").is_none()); - assert!(build_userns(" ").is_none()); + fn validated_userns_rejects_ns_mode_even_when_gate_open() { + for mode in &["ns:/proc/1/ns/user", "ns:/container:other"] { + let err = validated_userns(mode, true).unwrap_err(); + assert!( + err.contains("not permitted"), + "ns: mode should be rejected: {err}" + ); + } + } + + #[test] + fn validated_userns_rejects_unknown_modes() { + let err = validated_userns("private", true).unwrap_err(); + assert!(err.contains("not permitted"), "got: {err}"); + } + + #[test] + fn validated_userns_rejects_keep_id_with_non_numeric_uid() { + let err = validated_userns("keep-id:uid=root,gid=0", true).unwrap_err(); + assert!( + err.contains("non-negative integer"), + "non-numeric uid should be rejected: {err}" + ); + } + + #[test] + fn validated_userns_rejects_keep_id_with_unknown_option_key() { + let err = validated_userns("keep-id:size=100", true).unwrap_err(); + assert!( + err.contains("not recognised"), + "unknown option key should be rejected: {err}" + ); + } + + #[test] + fn validated_userns_rejects_keep_id_value_without_equals() { + let err = validated_userns("keep-id:uid200", true).unwrap_err(); + assert!( + err.contains("key=N"), + "malformed option should be rejected: {err}" + ); + } + + #[test] + fn validated_userns_accepts_keep_id_uid_only() { + let result = validated_userns("keep-id:uid=500", true).unwrap().unwrap(); + assert_eq!(result.nsmode, "keep-id"); + assert_eq!(result.value, "uid=500"); } } diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 88b82870de..59462fdc92 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -278,6 +278,11 @@ guest_tls_key = "/etc/openshell/certs/client-key.pem" # bind-backed volumes, expose gateway-host paths inside sandboxes and can # negate OpenShell isolation and filesystem controls. enable_bind_mounts = false +# Opt-in to per-sandbox user namespace selection via userns_mode in driver +# config. When false (default), any non-empty userns_mode is rejected. +# Only the safe modes keep-id, auto, and nomap are ever permitted; escape +# modes such as host and ns: are blocked unconditionally. +allow_userns = false # Set to 0 to leave Podman's runtime default unchanged. sandbox_pids_limit = 2048 # Health check interval in seconds. Lower values detect readiness faster diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index eb424d3573..08ef1ac60f 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -185,9 +185,14 @@ On macOS with `podman machine`, the driver uses gvproxy's host-loopback IP, `192 ### Podman User Namespace Mode -Pass `userns_mode` in per-sandbox driver config to run a sandbox container inside a user namespace. Accepts the same values as `podman run --userns`, such as `"keep-id"`, `"keep-id:uid=998,gid=998"`, `"auto"`, `"nomap"`, or `"host"`. Omitting it leaves Podman's default behavior unchanged. +Pass `userns_mode` in per-sandbox driver config to run a sandbox container inside a user namespace. Requires `allow_userns = true` in `[openshell.drivers.podman]` in `gateway.toml` (disabled by default). Permitted modes are `keep-id`, `auto`, and `nomap`; escape modes such as `host` and `ns:` are blocked unconditionally. + +`keep-id` maps a specific container UID/GID to the gateway user on the host, which is useful for rootless Podman when the image runs as a non-default UID. The `uid` and `gid` options must be non-negative integers. ```shell +# Enable the gate in gateway.toml first: +# allow_userns = true + openshell sandbox create \ --driver-config-json '{"podman":{"userns_mode":"keep-id:uid=998,gid=998"}}' \ -- claude