diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index c0c84132b9..44b36639e0 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -50,11 +50,20 @@ 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. 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: - `bind`: mounts an absolute host path when `[openshell.drivers.podman]` has `enable_bind_mounts = true`. @@ -89,6 +98,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/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 7c9ab269f1..5309deca66 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -71,6 +71,14 @@ pub struct PodmanSandboxDriverConfig { deserialize_with = "deserialize_optional_non_empty_string_list" )] pub cdi_devices: Option>, + /// Optional per-sandbox user namespace mode. + /// + /// 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, } @@ -196,6 +204,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 +312,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(Debug, Serialize)] +struct UserNS { + nsmode: String, + #[serde(skip_serializing_if = "String::is_empty")] + value: String, +} + #[derive(Serialize)] struct NetworkAttachment {} @@ -813,10 +841,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 +1001,8 @@ pub fn build_container_spec_with_token_and_gpu_devices( netns: NetNS { nsmode: "bridge".to_string(), }, + 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 @@ -1050,6 +1082,82 @@ pub fn build_container_spec_with_token_and_gpu_devices( Ok(serde_json::to_value(container_spec).expect("ContainerSpec serialization cannot fail")) } +/// 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: +/// - `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 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 { let host_gateway_ip = config.host_gateway_ip.trim(); if host_gateway_ip.is_empty() { @@ -2361,4 +2469,268 @@ mod tests { .count(); assert_eq!(bind_count, 0, "no bind mounts without TLS config"); } + + // ── 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); + + 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 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": " " + }))), + ..Default::default() + }), + ..Default::default() + }); + // Whitespace trims to empty — gate is never reached. + 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" + ); + } + + // -- 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}; + + 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 = userns_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 = userns_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 = userns_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 = userns_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 = 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 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 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 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 f860d64d4b..08ef1ac60f 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -183,6 +183,21 @@ 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. 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 +``` + ### Podman Driver Config Mounts Podman driver config accepts user-supplied `volume`, `tmpfs`, and `image`