diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index e53ddc9f3f..6cd9d59839 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -89,11 +89,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`. @@ -130,6 +139,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 42571f00f1..3ded9fff3b 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, /// Host path to a SPIFFE Workload API Unix socket exposed to sandbox /// supervisors for provider token exchange client assertions. pub provider_spiffe_workload_api_socket: Option, @@ -546,6 +558,7 @@ impl Default for PodmanComputeConfig { guest_tls_key: None, sandbox_pids_limit: DEFAULT_SANDBOX_PIDS_LIMIT, enable_bind_mounts: false, + allow_userns: false, provider_spiffe_workload_api_socket: None, health_check_interval_secs: DEFAULT_HEALTH_CHECK_INTERVAL_SECS, https_proxy: None, @@ -579,6 +592,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( "provider_spiffe_workload_api_socket", &self.provider_spiffe_workload_api_socket, diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index a81ee13e1d..e9dbcc003e 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -81,6 +81,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, } @@ -236,6 +244,12 @@ struct ContainerSpec { /// Resolver options written to `/etc/resolv.conf` by Podman. dns_option: 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. @@ -354,6 +368,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 UserNS { nsmode: String, @@ -1019,10 +1047,12 @@ pub fn build_container_spec_for_image( ) -> Result { let name = container_name(&sandbox.workspace, &sandbox.name, &sandbox.id); let vol = volume_name(&sandbox.id); + let sandbox_driver_config = PodmanSandboxDriverConfig::from_sandbox(sandbox)?; let env = build_env(sandbox, config, requested_image, oci_user); 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)?; if sandbox @@ -1254,6 +1284,8 @@ pub fn build_container_spec_for_image( 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 @@ -1392,6 +1424,80 @@ pub fn build_container_spec_for_image( 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 provider_spiffe_workload_api_socket_env_value(config: &PodmanComputeConfig) -> Option { let host_path = config.provider_spiffe_workload_api_socket.as_ref()?; let raw = host_path.to_str()?; @@ -3152,6 +3258,29 @@ mod tests { 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_includes_userns_when_configured() { let sandbox = test_sandbox("userns-id", "userns-name"); @@ -3172,6 +3301,53 @@ mod tests { } #[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" fn container_spec_auto_with_params() { let sandbox = test_sandbox("userns-auto-params-id", "userns-auto-params-name"); let mut config = test_config(); @@ -3207,6 +3383,138 @@ mod tests { } #[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}" fn container_spec_nomap_mode() { let sandbox = test_sandbox("userns-nomap-id", "userns-nomap-name"); let mut config = test_config(); @@ -3280,6 +3588,54 @@ mod tests { } #[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"); fn container_spec_uses_bind_mount_for_supervisor_when_path_provided() { let sandbox = test_sandbox("bind-sv-id", "bind-sv-name"); let config = test_config(); diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 1c846c29a8..e4bee7c8a6 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -668,6 +668,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 987e66b0d9..d290580ff6 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -255,6 +255,21 @@ For proxy-required networks, the Podman driver also accepts the corporate egress 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. Direct local callbacks from rootless Podman require Podman to report the pasta network helper. Slirp4netns, other helpers, and Podman versions that do not report their helper require an explicitly remote `grpc_endpoint`; otherwise the gateway fails startup rather than leaving sandbox callbacks unreachable. Rootful Podman continues to use the configured network's bridge gateway address. +### 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`