diff --git a/Cargo.lock b/Cargo.lock index 76dedf0625..fb64106149 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3656,6 +3656,7 @@ dependencies = [ "bytes", "futures", "openshell-core", + "openshell-policy", "prost-types", "serde", "serde_json", @@ -3706,6 +3707,7 @@ dependencies = [ "miette", "nix", "openshell-core", + "openshell-policy", "prost-types", "rustix 1.1.4", "serde", diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index d5d0912e23..6f2a43ae91 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -38,6 +38,12 @@ when a sandbox create request asks for GPU resources. Per-sandbox CPU and memory values currently enter the driver layer through template resource limits. Docker and Podman apply them as runtime limits. + +Docker and Podman operators can configure `sandbox_uid` and `sandbox_gid` in +their driver tables. When unset, the drivers resolve `10001:10001` and inject +it for the agent child; the root supervisor remains the container entry process. +Rootless Podman requires that the chosen values are available in its subordinate +UID/GID mappings. Kubernetes mirrors each limit into the matching request. VM accepts the fields but currently ignores them. diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 30d1bf4783..24653c399c 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -21,6 +21,13 @@ container-granted capabilities. This is fail-closed: the supervisor retains aborts unless the bounding set ends up empty. A `setpcap` `EPERM` is tolerated only when the set is already empty; any other outcome fails the spawn. +The compute runtime selects the agent's numeric UID and GID; sandbox creators +do not control it. Docker and Podman start the supervisor as root, inject the +driver-resolved identity, and the supervisor drops only the agent child to that +identity. A container image therefore need not contain a matching passwd or +group entry. Agent children receive a stable `HOME=/sandbox`; numeric identities +use the UID as their `USER` and `LOGNAME` presentation value. + ## Startup Flow 1. The compute runtime starts the workload with sandbox identity, callback diff --git a/crates/openshell-core/src/sandbox_env.rs b/crates/openshell-core/src/sandbox_env.rs index f066580636..75d52647e8 100644 --- a/crates/openshell-core/src/sandbox_env.rs +++ b/crates/openshell-core/src/sandbox_env.rs @@ -103,7 +103,7 @@ pub const PROVIDER_SPIFFE_WORKLOAD_API_SOCKET: &str = /// Resolved sandbox UID used to override `run_as_user` when the policy /// specifies a numeric value instead of the hardcoded "sandbox" user name. /// -/// Set by compute drivers (Kubernetes, Docker, VM) from resolved config or +/// Set by compute drivers (Kubernetes, Docker, Podman, and VM) from resolved config or /// cluster autodetection. The supervisor reads this at startup and uses it /// directly with `setuid()` / `chown()` without requiring an `/etc/passwd` /// entry in the sandbox image. diff --git a/crates/openshell-driver-docker/Cargo.toml b/crates/openshell-driver-docker/Cargo.toml index 7e1bc069cb..7c4c50ac20 100644 --- a/crates/openshell-driver-docker/Cargo.toml +++ b/crates/openshell-driver-docker/Cargo.toml @@ -12,6 +12,7 @@ repository.workspace = true [dependencies] openshell-core = { path = "../openshell-core", default-features = false } +openshell-policy = { path = "../openshell-policy" } tokio = { workspace = true } tonic = { workspace = true } diff --git a/crates/openshell-driver-docker/README.md b/crates/openshell-driver-docker/README.md index 82e5eea4b7..9ad2b5a95a 100644 --- a/crates/openshell-driver-docker/README.md +++ b/crates/openshell-driver-docker/README.md @@ -32,6 +32,7 @@ contract: | `apparmor=unconfined` | Avoids Docker's default profile blocking required mount operations. | | `restart_policy = unless-stopped` | Keeps managed sandboxes resumable across daemon or gateway restarts. | | `PidsLimit` | Enforces the sandbox PID budget at the Docker cgroup layer. Set `[openshell.drivers.docker].sandbox_pids_limit = 0` to inherit the Docker/runtime default. | +| `sandbox_uid` / `sandbox_gid` | Operator-controlled numeric identity for the agent child. The supervisor still starts as root; the UID defaults to `10001` and the GID defaults to the resolved UID. | | CDI GPU request | Uses opaque `driver_config.cdi_devices` values when set; otherwise selects the requested count of NVIDIA CDI GPUs in round-robin order when daemon CDI support is detected. Docker daemon `/info` can permit `nvidia.com/gpu=all` as a WSL2 all-only compatibility fallback, where it counts as one selectable device. Exact CDI device lists must not contain duplicates and must match the effective GPU count. | The agent child process does not retain these supervisor privileges. diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index 06d575a1e1..3c1467fa5c 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -77,6 +77,7 @@ const SUPERVISOR_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin const HOST_OPENSHELL_INTERNAL: &str = "host.openshell.internal"; const HOST_DOCKER_INTERNAL: &str = "host.docker.internal"; const DOCKER_NETWORK_DRIVER: &str = "bridge"; +const DEFAULT_SANDBOX_UID: u32 = 10_001; /// Queried by the Docker driver to decide when a sandbox's supervisor /// relay is live. Implementations return `true` once a sandbox has an @@ -136,6 +137,14 @@ pub struct DockerComputeConfig { /// Set to `0` to leave Docker's runtime/default PID limit unchanged. pub sandbox_pids_limit: i64, + /// Numeric identity used for the agent process. The root supervisor + /// retains UID 0 while preparing the sandbox. + pub sandbox_uid: Option, + + /// Numeric group used for the agent process. Defaults to the resolved + /// sandbox UID when unset. + pub sandbox_gid: Option, + /// Allow sandbox requests to attach host bind mounts through /// `template.driver_config`. #[serde(default)] @@ -158,6 +167,8 @@ 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, + sandbox_uid: None, + sandbox_gid: None, enable_bind_mounts: false, } } @@ -187,6 +198,8 @@ struct DockerDriverRuntimeConfig { supports_gpu: bool, allow_all_default_gpu: bool, sandbox_pids_limit: i64, + sandbox_uid: u32, + sandbox_gid: u32, enable_bind_mounts: bool, } @@ -307,6 +320,7 @@ impl DockerComputeDriver { docker_config: &DockerComputeConfig, supervisor_readiness: Arc, ) -> CoreResult { + let sandbox_identity = resolve_sandbox_identity(docker_config)?; let docker = Docker::connect_with_local_defaults() .map_err(|err| Error::execution(format!("failed to create Docker client: {err}")))?; let version = docker.version().await.map_err(|err| { @@ -370,6 +384,8 @@ impl DockerComputeDriver { supports_gpu, allow_all_default_gpu, sandbox_pids_limit: docker_config.sandbox_pids_limit, + sandbox_uid: sandbox_identity.0, + sandbox_gid: sandbox_identity.1, enable_bind_mounts: docker_config.enable_bind_mounts, }, events: broadcast::channel(WATCH_BUFFER).0, @@ -2162,6 +2178,14 @@ fn build_environment(sandbox: &DriverSandbox, config: &DockerDriverRuntimeConfig openshell_core::sandbox_env::TELEMETRY_ENABLED.to_string(), openshell_core::telemetry::enabled_env_value().to_string(), ); + environment.insert( + openshell_core::sandbox_env::SANDBOX_UID.to_string(), + config.sandbox_uid.to_string(), + ); + environment.insert( + openshell_core::sandbox_env::SANDBOX_GID.to_string(), + config.sandbox_gid.to_string(), + ); // The root supervisor executes namespace helpers during bootstrap; keep // their search path driver-owned even when the template/spec set PATH. environment.insert("PATH".to_string(), SUPERVISOR_PATH.to_string()); @@ -2619,6 +2643,22 @@ fn validate_sandbox_pids_limit(value: i64) -> CoreResult<()> { Ok(()) } +fn resolve_sandbox_identity(config: &DockerComputeConfig) -> CoreResult<(u32, u32)> { + let uid = config.sandbox_uid.unwrap_or(DEFAULT_SANDBOX_UID); + let gid = config.sandbox_gid.unwrap_or(uid); + for (field, value) in [("sandbox_uid", uid), ("sandbox_gid", gid)] { + if !(openshell_policy::MIN_SANDBOX_UID..=openshell_policy::MAX_SANDBOX_UID).contains(&value) + { + return Err(Error::config(format!( + "docker {field} must be in [{}, {}]", + openshell_policy::MIN_SANDBOX_UID, + openshell_policy::MAX_SANDBOX_UID, + ))); + } + } + Ok((uid, gid)) +} + fn docker_pids_limit(value: i64) -> Result, Status> { if value < 0 { return Err(Status::failed_precondition( diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index 594308bce5..1f2386cae0 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -113,6 +113,8 @@ fn runtime_config() -> DockerDriverRuntimeConfig { supports_gpu: false, allow_all_default_gpu: false, sandbox_pids_limit: DEFAULT_SANDBOX_PIDS_LIMIT, + sandbox_uid: 10_001, + sandbox_gid: 10_001, enable_bind_mounts: false, } } @@ -551,6 +553,50 @@ fn docker_compute_config_disables_bind_mounts_by_default() { assert!(!cfg.enable_bind_mounts); } +#[test] +fn docker_sandbox_identity_defaults_to_10001() { + assert_eq!( + resolve_sandbox_identity(&DockerComputeConfig::default()).unwrap(), + (10_001, 10_001) + ); +} + +#[test] +fn docker_sandbox_identity_uses_resolved_uid_for_default_gid() { + let config = DockerComputeConfig { + sandbox_uid: Some(12_345), + ..DockerComputeConfig::default() + }; + assert_eq!(resolve_sandbox_identity(&config).unwrap(), (12_345, 12_345)); +} + +#[test] +fn docker_sandbox_identity_allows_configured_gid_without_uid() { + let config = DockerComputeConfig { + sandbox_gid: Some(12_346), + ..DockerComputeConfig::default() + }; + assert_eq!(resolve_sandbox_identity(&config).unwrap(), (10_001, 12_346)); +} + +#[test] +fn docker_sandbox_identity_rejects_system_uid() { + let config = DockerComputeConfig { + sandbox_uid: Some(999), + ..DockerComputeConfig::default() + }; + assert!(resolve_sandbox_identity(&config).is_err()); +} + +#[test] +fn docker_sandbox_identity_rejects_gid_above_policy_range() { + let config = DockerComputeConfig { + sandbox_gid: Some(openshell_policy::MAX_SANDBOX_UID + 1), + ..DockerComputeConfig::default() + }; + assert!(resolve_sandbox_identity(&config).is_err()); +} + #[test] fn container_create_body_sets_driver_owned_pids_limit() { let body = build_container_create_body(&test_sandbox(), &runtime_config()).unwrap(); @@ -567,6 +613,28 @@ fn build_environment_sets_docker_tls_paths() { assert!(env.contains(&"TEMPLATE_ENV=template".to_string())); assert!(env.contains(&"SPEC_ENV=spec".to_string())); assert!(env.contains(&"OPENSHELL_SANDBOX_COMMAND=sleep infinity".to_string())); + assert!(env.contains(&"OPENSHELL_SANDBOX_UID=10001".to_string())); + assert!(env.contains(&"OPENSHELL_SANDBOX_GID=10001".to_string())); +} + +#[test] +fn build_environment_keeps_sandbox_identity_driver_controlled() { + let mut sandbox = test_sandbox(); + let spec = sandbox.spec.as_mut().unwrap(); + spec.environment.insert( + openshell_core::sandbox_env::SANDBOX_UID.to_string(), + "1234".to_string(), + ); + spec.template.as_mut().unwrap().environment.insert( + openshell_core::sandbox_env::SANDBOX_GID.to_string(), + "1234".to_string(), + ); + + let env = build_environment(&sandbox, &runtime_config()); + assert!(env.contains(&"OPENSHELL_SANDBOX_UID=10001".to_string())); + assert!(env.contains(&"OPENSHELL_SANDBOX_GID=10001".to_string())); + assert!(!env.contains(&"OPENSHELL_SANDBOX_UID=1234".to_string())); + assert!(!env.contains(&"OPENSHELL_SANDBOX_GID=1234".to_string())); } #[test] diff --git a/crates/openshell-driver-podman/Cargo.toml b/crates/openshell-driver-podman/Cargo.toml index ed798c0ab2..ef8c2ddcf1 100644 --- a/crates/openshell-driver-podman/Cargo.toml +++ b/crates/openshell-driver-podman/Cargo.toml @@ -16,6 +16,7 @@ path = "src/main.rs" [dependencies] openshell-core = { path = "../openshell-core", default-features = false } +openshell-policy = { path = "../openshell-policy" } tokio = { workspace = true } tonic = { workspace = true, features = ["transport"] } diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index c6a619f3d1..0feb7c9269 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -50,6 +50,12 @@ The container spec in `container.rs` sets these security-critical fields: The restricted agent child does not retain these supervisor privileges. +The gateway operator may set `[openshell.drivers.podman].sandbox_uid` and +`sandbox_gid` for the agent child. The supervisor remains `0:0` during setup; +the UID defaults to `10001` and the GID defaults to the resolved UID. In +rootless mode, configure values that are available in Podman's subordinate +UID/GID mapping. + ## Driver Config Mounts The gateway forwards the `podman` block from `--driver-config-json` to this diff --git a/crates/openshell-driver-podman/src/config.rs b/crates/openshell-driver-podman/src/config.rs index def8e5f3dc..bdfac34ecd 100644 --- a/crates/openshell-driver-podman/src/config.rs +++ b/crates/openshell-driver-podman/src/config.rs @@ -9,6 +9,7 @@ use std::str::FromStr; /// Default Podman bridge network name. pub const DEFAULT_NETWORK_NAME: &str = "openshell"; pub const MACOS_PODMAN_MACHINE_HOST_GATEWAY_IP: &str = "192.168.127.254"; +pub const DEFAULT_SANDBOX_UID: u32 = 10_001; // Re-export the shared default so existing imports inside this crate keep working. pub use openshell_core::config::DEFAULT_SANDBOX_PIDS_LIMIT; @@ -122,6 +123,11 @@ pub struct PodmanComputeConfig { /// /// Set to `0` to leave Podman's runtime/default PID limit unchanged. pub sandbox_pids_limit: i64, + /// Numeric identity used for the agent process. The supervisor remains + /// root while it prepares the sandbox. + pub sandbox_uid: Option, + /// Numeric group used for the agent process. Defaults to the resolved UID. + pub sandbox_gid: Option, /// Allow sandbox requests to attach host bind mounts through /// `template.driver_config`. #[serde(default)] @@ -189,6 +195,24 @@ impl PodmanComputeConfig { Ok(()) } + /// Resolve and validate the numeric identity injected into the supervisor. + pub fn resolve_sandbox_identity(&self) -> Result<(u32, u32), crate::client::PodmanApiError> { + let uid = self.sandbox_uid.unwrap_or(DEFAULT_SANDBOX_UID); + let gid = self.sandbox_gid.unwrap_or(uid); + for (field, value) in [("sandbox_uid", uid), ("sandbox_gid", gid)] { + if !(openshell_policy::MIN_SANDBOX_UID..=openshell_policy::MAX_SANDBOX_UID) + .contains(&value) + { + return Err(crate::client::PodmanApiError::InvalidInput(format!( + "{field} must be in [{}, {}]", + openshell_policy::MIN_SANDBOX_UID, + openshell_policy::MAX_SANDBOX_UID, + ))); + } + } + Ok((uid, gid)) + } + /// Validate optional host gateway override. pub fn validate_host_gateway_ip(&self) -> Result<(), crate::client::PodmanApiError> { let trimmed = self.host_gateway_ip.trim(); @@ -260,6 +284,8 @@ impl Default for PodmanComputeConfig { guest_tls_cert: None, guest_tls_key: None, sandbox_pids_limit: DEFAULT_SANDBOX_PIDS_LIMIT, + sandbox_uid: None, + sandbox_gid: None, enable_bind_mounts: false, health_check_interval_secs: DEFAULT_HEALTH_CHECK_INTERVAL_SECS, } @@ -283,6 +309,8 @@ 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("sandbox_uid", &self.sandbox_uid) + .field("sandbox_gid", &self.sandbox_gid) .field("enable_bind_mounts", &self.enable_bind_mounts) .field( "health_check_interval_secs", @@ -382,6 +410,52 @@ mod tests { assert!(err.to_string().contains("sandbox_pids_limit")); } + #[test] + fn sandbox_identity_defaults_to_10001() { + assert_eq!( + PodmanComputeConfig::default() + .resolve_sandbox_identity() + .unwrap(), + (10_001, 10_001) + ); + } + + #[test] + fn sandbox_identity_uses_uid_for_default_gid() { + let cfg = PodmanComputeConfig { + sandbox_uid: Some(12_345), + ..PodmanComputeConfig::default() + }; + assert_eq!(cfg.resolve_sandbox_identity().unwrap(), (12_345, 12_345)); + } + + #[test] + fn sandbox_identity_allows_configured_gid_without_uid() { + let cfg = PodmanComputeConfig { + sandbox_gid: Some(12_346), + ..PodmanComputeConfig::default() + }; + assert_eq!(cfg.resolve_sandbox_identity().unwrap(), (10_001, 12_346)); + } + + #[test] + fn sandbox_identity_rejects_system_uid() { + let cfg = PodmanComputeConfig { + sandbox_uid: Some(999), + ..PodmanComputeConfig::default() + }; + assert!(cfg.resolve_sandbox_identity().is_err()); + } + + #[test] + fn sandbox_identity_rejects_gid_above_policy_range() { + let cfg = PodmanComputeConfig { + sandbox_gid: Some(openshell_policy::MAX_SANDBOX_UID + 1), + ..PodmanComputeConfig::default() + }; + assert!(cfg.resolve_sandbox_identity().is_err()); + } + #[test] #[cfg(target_os = "macos")] fn default_socket_path_uses_podman_machine_on_macos() { diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index e4a0b79189..af2c0644a1 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -407,6 +407,17 @@ fn build_env( openshell_core::sandbox_env::SANDBOX_COMMAND.into(), "sleep infinity".into(), ); + let sandbox_identity = config + .resolve_sandbox_identity() + .expect("Podman configuration is validated before build_env"); + env.insert( + openshell_core::sandbox_env::SANDBOX_UID.into(), + sandbox_identity.0.to_string(), + ); + env.insert( + openshell_core::sandbox_env::SANDBOX_GID.into(), + sandbox_identity.1.to_string(), + ); env.insert( openshell_core::sandbox_env::TELEMETRY_ENABLED.into(), openshell_core::telemetry::enabled_env_value().into(), @@ -1610,6 +1621,8 @@ mod tests { "OPENSHELL_SSH_SOCKET_PATH".to_string(), "/tmp/evil.sock".to_string(), ); + env_overrides.insert("OPENSHELL_SANDBOX_UID".to_string(), "1234".to_string()); + env_overrides.insert("OPENSHELL_SANDBOX_GID".to_string(), "1234".to_string()); sandbox.spec = Some(DriverSandboxSpec { environment: env_overrides, template: Some(DriverSandboxTemplate::default()), @@ -1631,6 +1644,20 @@ mod tests { Some("test-id"), "OPENSHELL_SANDBOX_ID must not be overridden by user env" ); + assert_eq!( + env_map + .get(openshell_core::sandbox_env::SANDBOX_UID) + .and_then(|v| v.as_str()), + Some("10001"), + "OPENSHELL_SANDBOX_UID must not be overridden by user env" + ); + assert_eq!( + env_map + .get(openshell_core::sandbox_env::SANDBOX_GID) + .and_then(|v| v.as_str()), + Some("10001"), + "OPENSHELL_SANDBOX_GID must not be overridden by user env" + ); assert_eq!( env_map .get("OPENSHELL_SSH_SOCKET_PATH") diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index cf639fa265..c99460afb2 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -184,6 +184,7 @@ impl PodmanComputeDriver { // get a clear error instead of a silent fallback to plaintext HTTP. config.validate_tls_config()?; config.validate_runtime_limits()?; + config.resolve_sandbox_identity()?; config.validate_host_gateway_ip()?; let client = PodmanClient::new(config.socket_path.clone()); diff --git a/crates/openshell-driver-podman/src/main.rs b/crates/openshell-driver-podman/src/main.rs index c57aff4277..2f225076c2 100644 --- a/crates/openshell-driver-podman/src/main.rs +++ b/crates/openshell-driver-podman/src/main.rs @@ -88,6 +88,14 @@ struct Args { )] sandbox_pids_limit: i64, + /// Numeric UID for the agent process. Defaults to 10001. + #[arg(long, env = "OPENSHELL_PODMAN_SANDBOX_UID")] + sandbox_uid: Option, + + /// Numeric GID for the agent process. Defaults to the resolved UID. + #[arg(long, env = "OPENSHELL_PODMAN_SANDBOX_GID")] + sandbox_gid: Option, + /// OCI image containing the openshell-sandbox supervisor binary. #[arg(long, env = "OPENSHELL_SUPERVISOR_IMAGE")] supervisor_image: Option, @@ -137,6 +145,8 @@ 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, + sandbox_uid: args.sandbox_uid, + sandbox_gid: args.sandbox_gid, ..PodmanComputeConfig::default() }) .await diff --git a/crates/openshell-supervisor-process/src/process.rs b/crates/openshell-supervisor-process/src/process.rs index 72effa98f8..69179fc11e 100644 --- a/crates/openshell-supervisor-process/src/process.rs +++ b/crates/openshell-supervisor-process/src/process.rs @@ -562,6 +562,11 @@ impl ProcessHandle { // supervisor credentials from its inherited environment. strip_supervisor_only_env(&mut cmd); + let (child_user, child_home) = child_user_and_home(policy); + cmd.env("HOME", child_home) + .env("USER", &child_user) + .env("LOGNAME", child_user); + inject_provider_env(&mut cmd, provider_env); if let Some(dir) = workdir { @@ -713,6 +718,11 @@ impl ProcessHandle { // inherited environment. strip_supervisor_only_env(&mut cmd); + let (child_user, child_home) = child_user_and_home(policy); + cmd.env("HOME", child_home) + .env("USER", &child_user) + .env("LOGNAME", child_user); + inject_provider_env(&mut cmd, provider_env); if let Some(dir) = workdir { @@ -848,6 +858,26 @@ impl ProcessHandle { } } +/// Derive the presentation identity and home directory for an agent child. +/// +/// Numeric identities intentionally do not require an NSS entry, so they use +/// `/sandbox` as their stable home while retaining the numeric UID as USER. +pub(crate) fn child_user_and_home(policy: &SandboxPolicy) -> (String, String) { + match policy.process.run_as_user.as_deref() { + Some(user) if !user.is_empty() && user.parse::().is_ok() => { + (user.to_string(), "/sandbox".to_string()) + } + Some(user) if !user.is_empty() => { + let home = User::from_name(user).ok().flatten().map_or_else( + || format!("/home/{user}"), + |u| u.dir.to_string_lossy().into_owned(), + ); + (user.to_string(), home) + } + _ => ("sandbox".to_string(), "/sandbox".to_string()), + } +} + impl Drop for ProcessHandle { fn drop(&mut self) { #[cfg(target_os = "linux")] diff --git a/crates/openshell-supervisor-process/src/ssh.rs b/crates/openshell-supervisor-process/src/ssh.rs index f5a3ee0793..6064810a12 100644 --- a/crates/openshell-supervisor-process/src/ssh.rs +++ b/crates/openshell-supervisor-process/src/ssh.rs @@ -690,24 +690,7 @@ impl Default for PtyRequest { /// `("{uid}", "/sandbox")` so the agent session still has a meaningful /// USER identifier. fn session_user_and_home(policy: &SandboxPolicy) -> (String, String) { - match policy.process.run_as_user.as_deref() { - Some(user) if !user.is_empty() => { - // Numeric UID — no passwd entry expected; use default HOME. - if user.parse::().is_ok() { - return (user.to_string(), "/sandbox".to_string()); - } - // Name-based identity — look up home from /etc/passwd. - let home = nix::unistd::User::from_name(user) - .ok() - .flatten() - .map_or_else( - || format!("/home/{user}"), - |u| u.dir.to_string_lossy().into_owned(), - ); - (user.to_string(), home) - } - _ => ("sandbox".to_string(), "/sandbox".to_string()), - } + crate::process::child_user_and_home(policy) } #[allow(clippy::too_many_arguments)] @@ -727,6 +710,7 @@ fn apply_child_env( .env(openshell_core::sandbox_env::SANDBOX, "1") .env("HOME", session_home) .env("USER", session_user) + .env("LOGNAME", session_user) .env("SHELL", "/bin/bash") .env("PATH", &path) .env("TERM", term); diff --git a/deploy/docker/gateway.toml b/deploy/docker/gateway.toml index 4fe84d633a..1d8911cc99 100644 --- a/deploy/docker/gateway.toml +++ b/deploy/docker/gateway.toml @@ -49,3 +49,7 @@ sandbox_namespace = "openshell" # The gateway must be published on port 8080 on the Docker host so that # host.openshell.internal:8080 resolves to the gateway container. grpc_endpoint = "http://host.openshell.internal:8080" +# The supervisor starts as root, then launches the agent with this numeric +# identity. Defaults are sandbox_uid=10001 and sandbox_gid=sandbox_uid. +# sandbox_uid = 10001 +# sandbox_gid = 10001 diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 247774a6c0..ba7dd238be 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -257,6 +257,11 @@ 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 +# Agent identity is selected by the gateway operator, not the sandbox creator. +# The root supervisor starts the container and drops the agent to this identity. +# sandbox_uid defaults to 10001; sandbox_gid defaults to the resolved UID. +# sandbox_uid = 10001 +# sandbox_gid = 10001 ``` ### Podman @@ -297,6 +302,12 @@ 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 +# Agent identity is selected by the gateway operator, not the sandbox creator. +# Values must be in OpenShell's non-root identity range. The UID defaults to +# 10001 and the GID defaults to the resolved UID. Rootless Podman must map +# these values through its subordinate UID/GID ranges. +# sandbox_uid = 10001 +# sandbox_gid = 10001 # Health check interval in seconds. Lower values detect readiness faster # but increase process churn (each check spawns a conmon subprocess). # Set to 0 to disable health checks entirely. Default: 10. diff --git a/e2e/fixtures/userless/Dockerfile b/e2e/fixtures/userless/Dockerfile new file mode 100644 index 0000000000..88cfefb965 --- /dev/null +++ b/e2e/fixtures/userless/Dockerfile @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +FROM public.ecr.aws/docker/library/python:3.13-slim + +# iproute2 is required for sandbox network namespace isolation. +RUN apt-get update && apt-get install -y --no-install-recommends iproute2 \ + && rm -rf /var/lib/apt/lists/* \ + && ! getent passwd sandbox \ + && ! getent group sandbox \ + && ! getent passwd 10001 \ + && ! getent group 10001 \ + && mkdir /sandbox + +# The compute driver must override image-provided identity values. +ENV OPENSHELL_SANDBOX_UID=1234 +ENV OPENSHELL_SANDBOX_GID=1234 + +RUN echo "userless-image-e2e-marker" > /etc/marker.txt + +CMD ["sleep", "infinity"] diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index c6fec9aeb8..ca4370498a 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -35,7 +35,7 @@ e2e-vm = ["e2e"] [[test]] name = "custom_image" path = "tests/custom_image.rs" -required-features = ["e2e-docker"] +required-features = ["e2e-local-container-driver"] [[test]] name = "docker_preflight" diff --git a/e2e/rust/tests/custom_image.rs b/e2e/rust/tests/custom_image.rs index ff3cda86ae..8d7addbd30 100644 --- a/e2e/rust/tests/custom_image.rs +++ b/e2e/rust/tests/custom_image.rs @@ -6,69 +6,98 @@ //! E2E test: build a custom container image and run a sandbox with it. //! //! Prerequisites: -//! - A running Docker-backed openshell gateway (`mise run gateway:docker`) -//! - Docker daemon running (for image build) +//! - A running Docker- or Podman-backed OpenShell gateway +//! - Docker or Podman runtime running (for image build) //! - The `openshell` binary (built automatically from the workspace) -use std::io::Write; - use openshell_e2e::harness::output::strip_ansi; use openshell_e2e::harness::sandbox::SandboxGuard; -const DOCKERFILE_CONTENT: &str = r#"FROM public.ecr.aws/docker/library/python:3.13-slim - -# iproute2 is required for sandbox network namespace isolation. -RUN apt-get update && apt-get install -y --no-install-recommends iproute2 \ - && rm -rf /var/lib/apt/lists/* +const MARKER: &str = "userless-image-e2e-marker"; -# Create the sandbox user/group so the supervisor can switch to it. -# Use a high UID range to avoid conflicts with host users when running without -# user namespace remapping (UID in container = UID on host). -RUN groupadd -g 1000660000 sandbox && \ - useradd -m -u 1000660000 -g sandbox sandbox - -# Write a marker file so we can verify this is our custom image. -# Place under /etc (Landlock baseline read-only path) so the sandbox -# can read it when filesystem restrictions are properly enforced. -RUN echo "custom-image-e2e-marker" > /etc/marker.txt +fn assert_userless_output(output: &str) { + let lines = output.lines().map(str::trim).collect::>(); + assert!( + lines.contains(&MARKER), + "expected marker '{MARKER}' in sandbox output:\n{output}" + ); + assert!( + lines.iter().filter(|line| **line == "10001").count() >= 2, + "expected UID and GID 10001:\n{output}" + ); + assert!( + lines.contains(&"/sandbox"), + "expected sandbox home:\n{output}" + ); +} -CMD ["sleep", "infinity"] -"#; +#[cfg(all(feature = "e2e-podman", not(feature = "e2e-docker")))] +async fn assert_userless_image(image: &str) { + let mut guard = SandboxGuard::create(&[ + "--from", + image, + "--", + "sh", + "-lc", + "cat /etc/marker.txt; id -u; id -g; printf \"%s\\n\" \"$HOME\"; touch \"$HOME/userless-e2e\"", + ]) + .await + .expect("sandbox create from userless image"); -const MARKER: &str = "custom-image-e2e-marker"; + let clean_output = strip_ansi(&guard.create_output); + assert_userless_output(&clean_output); + guard.cleanup().await; +} -/// Build a custom Docker image from a Dockerfile and verify that a sandbox -/// created from it contains the expected marker file. +/// Docker's local Dockerfile builder provides the image directly to a Docker +/// gateway, so this path uses the CLI's standard `--from Dockerfile` flow. +#[cfg(feature = "e2e-docker")] #[tokio::test] -async fn sandbox_from_custom_dockerfile() { - // Step 1: Write a temporary Dockerfile. - let tmpdir = tempfile::tempdir().expect("create tmpdir"); - let dockerfile_path = tmpdir.path().join("Dockerfile"); - { - let mut f = std::fs::File::create(&dockerfile_path).expect("create Dockerfile"); - f.write_all(DOCKERFILE_CONTENT.as_bytes()) - .expect("write Dockerfile"); - } - - // Step 2: Create a sandbox from the Dockerfile. +async fn docker_sandbox_from_userless_dockerfile() { + let dockerfile_path = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../fixtures/userless/Dockerfile"); let dockerfile_str = dockerfile_path.to_str().expect("Dockerfile path is UTF-8"); let mut guard = SandboxGuard::create(&[ "--from", dockerfile_str, "--", - "cat", - "/etc/marker.txt", + "sh", + "-lc", + "cat /etc/marker.txt; id -u; id -g; printf \"%s\\n\" \"$HOME\"; touch \"$HOME/userless-e2e\"", ]) .await .expect("sandbox create from Dockerfile"); - // Step 3: Verify the marker file content appears in the output. let clean_output = strip_ansi(&guard.create_output); - assert!( - clean_output.contains(MARKER), - "expected marker '{MARKER}' in sandbox output:\n{clean_output}" - ); + assert_userless_output(&clean_output); // Explicit cleanup (also happens in Drop, but explicit is clearer in tests). guard.cleanup().await; } + +/// Podman has its own local image store, so build the shared fixture with the +/// selected engine instead of relying on Docker's `--from Dockerfile` path. +#[cfg(all(feature = "e2e-podman", not(feature = "e2e-docker")))] +#[tokio::test] +async fn podman_sandbox_from_userless_image() { + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../fixtures/userless"); + let image = format!("openshell/e2e-userless:{}", std::process::id()); + let status = tokio::process::Command::new("podman") + .args([ + "build", + "--tag", + &image, + fixture.to_str().expect("fixture path is UTF-8"), + ]) + .status() + .await + .expect("run podman build"); + assert!(status.success(), "build userless fixture with podman"); + + assert_userless_image(&image).await; + + let _ = tokio::process::Command::new("podman") + .args(["image", "rm", "--force", &image]) + .status() + .await; +} diff --git a/examples/bring-your-own-container/README.md b/examples/bring-your-own-container/README.md index ea4f1cb9e6..a602918bb4 100644 --- a/examples/bring-your-own-container/README.md +++ b/examples/bring-your-own-container/README.md @@ -59,17 +59,13 @@ key requirements are: - **Pass your start command explicitly** — use `-- ` on the CLI. The image's `CMD` / `ENTRYPOINT` is replaced by the sandbox supervisor at runtime. -- **Create a `sandbox` user** (uid/gid 1000660000) for non-root execution. - Use a high UID (1000000000+) to avoid conflicts with host users when running - without user namespace remapping. -- **Make your application workdir writable by `sandbox`**. This example creates - `/sandbox` with `sandbox:sandbox` ownership before copying `app.py`. +- **Do not create a `sandbox` user**. The compute driver starts the supervisor + as root and injects the non-root agent UID/GID at runtime. Make mutable paths + such as `/sandbox` writable for the configured runtime identity. - **Install `iproute2`** for full network namespace isolation. - **Use a standard Linux base image** — distroless and `FROM scratch` images are not supported. -TODO(#70): Remove the sandbox user note once custom images are secure by default without requiring manual setup. - ## How it works OpenShell handles all the wiring automatically. You build a standard