Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 3 additions & 7 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1278,13 +1278,9 @@ enum SandboxCommands {
#[arg(long, conflicts_with_all = ["from", "gpu", "cpu", "memory", "driver_config_json", "envs"])]
template: Option<String>,

/// Sandbox source: a community sandbox name (e.g., `ollama`), a rootfs
/// tar archive (`.tar`, `.tar.gz`, or `.tgz`), or a full container
/// image reference (e.g., `myregistry.com/img:tag`).
///
/// Community names are resolved to
/// `ghcr.io/nvidia/openshell-community/sandboxes/<name>:latest`
/// (override the prefix with `OPENSHELL_COMMUNITY_REGISTRY`).
/// Sandbox source: a full container image reference (e.g.,
/// `ghcr.io/owner/image:tag`, `myregistry.com/img:tag`) or a
/// rootfs tar archive (`.tar`, `.tar.gz`, or `.tgz`).
///
/// To use a local Dockerfile, build and tag it with the container
/// engine used by your local gateway, then pass the resulting image
Expand Down
7 changes: 2 additions & 5 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1313,11 +1313,8 @@ fn resolve_from(value: &str) -> Result<ResolvedSource> {
));
}

// Full image reference or community sandbox name — delegate to shared
// resolution in openshell-core.
Ok(ResolvedSource::Image(
openshell_core::image::resolve_community_image(value),
))
// Explicit OCI image reference — passed through to the gateway unchanged.
Ok(ResolvedSource::Image(value.to_string()))
}

#[allow(clippy::case_sensitive_file_extension_comparisons)] // already lowercased
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,6 @@ async fn create_running_sandbox(
"create",
"--name",
sandbox_name,
"--from",
"base",
"--detach",
"--no-tty",
"--",
Expand Down
10 changes: 1 addition & 9 deletions crates/openshell-conformance/src/scenarios/smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,7 @@ async fn run_smoke_inner(runner: &mut OpenShellRunner) -> Result<(), String> {
.step("create")
.description("sandbox creation succeeds")
.with_timeout(CREATE_TIMEOUT)
.run(&[
"sandbox",
"create",
"--name",
&sandbox_name,
"--from",
"base",
"--detach",
])
.run(&["sandbox", "create", "--name", &sandbox_name, "--detach"])
.await
.map_err(|error| error.to_string())?;
create.require_success()?;
Expand Down
121 changes: 10 additions & 111 deletions crates/openshell-core/src/image.rs
Original file line number Diff line number Diff line change
@@ -1,124 +1,23 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Shared image-name resolution for community sandbox images.
//! Default sandbox image.
//!
//! Both the CLI and TUI need to expand bare sandbox names (e.g. `"base"`) into
//! fully-qualified container image references. This module centralises that
//! logic so every client resolves names identically.
//! Provides the fallback image used by all compute drivers when a sandbox spec
//! does not specify one. User-supplied `--from` values are explicit OCI image
//! references passed through unchanged by the CLI and TUI.

/// Default registry prefix for community sandbox images.
/// Default sandbox base image reference.
///
/// Bare sandbox names are expanded to `{prefix}/{name}:latest`.
/// Override at runtime with the `OPENSHELL_COMMUNITY_REGISTRY` env var.
pub const DEFAULT_COMMUNITY_REGISTRY: &str = "ghcr.io/nvidia/openshell-community/sandboxes";
/// A generic, version-qualified official Alpine image so a fresh install does
/// not depend on the community image catalog.
pub const DEFAULT_SANDBOX_BASE_IMAGE: &str = "docker.io/library/alpine:3.22";

/// Return the default sandbox image reference (`{registry}/base:latest`).
/// Return the default sandbox image reference.
///
/// Used by all compute drivers as the fallback image when none is specified in
/// the sandbox spec.
#[must_use]
pub fn default_sandbox_image() -> String {
format!("{DEFAULT_COMMUNITY_REGISTRY}/base:latest")
}

/// Resolve a user-supplied image string into a fully-qualified reference.
///
/// Resolution rules (applied in order):
/// 1. If the value contains `/`, `:`, or `.` it is treated as a complete image
/// reference and returned as-is.
/// 2. Otherwise it is treated as a community sandbox name and expanded to
/// `{registry}/{value}:latest` where `{registry}` defaults to
/// [`DEFAULT_COMMUNITY_REGISTRY`] but can be overridden via the
/// `OPENSHELL_COMMUNITY_REGISTRY` environment variable.
///
/// This function only handles image-name resolution. Dockerfile detection is
/// the responsibility of the caller (e.g. the CLI's `resolve_from()`).
pub fn resolve_community_image(value: &str) -> String {
// Already a fully-qualified reference.
if value.contains('/') || value.contains(':') || value.contains('.') {
return value.to_string();
}

// Community sandbox shorthand → expand with registry prefix.
let prefix = std::env::var("OPENSHELL_COMMUNITY_REGISTRY")
.unwrap_or_else(|_| DEFAULT_COMMUNITY_REGISTRY.to_string());
let prefix = prefix.trim_end_matches('/');
format!("{prefix}/{value}:latest")
}

#[cfg(test)]
#[allow(unsafe_code)]
mod tests {
use super::*;
use std::sync::{Mutex, OnceLock};

fn env_lock() -> &'static Mutex<()> {
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
ENV_LOCK.get_or_init(|| Mutex::new(()))
}

#[test]
fn bare_name_expands_to_community_registry() {
let _guard = env_lock().lock().unwrap();
let result = resolve_community_image("base");
assert_eq!(
result,
"ghcr.io/nvidia/openshell-community/sandboxes/base:latest"
);
}

#[test]
fn bare_name_with_env_override() {
let _guard = env_lock().lock().unwrap();
// Use a temp env override. Safety: test-only, and these env-var tests
// are not run concurrently with other tests reading the same var.
let key = "OPENSHELL_COMMUNITY_REGISTRY";
let prev = std::env::var(key).ok();
// SAFETY: single-threaded test context; no other thread reads this var.
unsafe { std::env::set_var(key, "my-registry.example.com/sandboxes") };
let result = resolve_community_image("python");
assert_eq!(result, "my-registry.example.com/sandboxes/python:latest");
// Restore.
match prev {
Some(v) => unsafe { std::env::set_var(key, v) },
None => unsafe { std::env::remove_var(key) },
}
}

#[test]
fn full_reference_with_slash_passes_through() {
let _guard = env_lock().lock().unwrap();
let input = "ghcr.io/myorg/myimage:v1";
assert_eq!(resolve_community_image(input), input);
}

#[test]
fn reference_with_colon_passes_through() {
let _guard = env_lock().lock().unwrap();
let input = "myimage:latest";
assert_eq!(resolve_community_image(input), input);
}

#[test]
fn reference_with_dot_passes_through() {
let _guard = env_lock().lock().unwrap();
let input = "registry.example.com";
assert_eq!(resolve_community_image(input), input);
}

#[test]
fn trailing_slash_in_env_is_trimmed() {
let _guard = env_lock().lock().unwrap();
let key = "OPENSHELL_COMMUNITY_REGISTRY";
let prev = std::env::var(key).ok();
// SAFETY: single-threaded test context; no other thread reads this var.
unsafe { std::env::set_var(key, "my-registry.example.com/sandboxes/") };
let result = resolve_community_image("base");
assert_eq!(result, "my-registry.example.com/sandboxes/base:latest");
match prev {
Some(v) => unsafe { std::env::set_var(key, v) },
None => unsafe { std::env::remove_var(key) },
}
}
DEFAULT_SANDBOX_BASE_IMAGE.to_string()
}
12 changes: 12 additions & 0 deletions crates/openshell-core/src/sandbox_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,18 @@ pub const SANDBOX_UID: &str = "OPENSHELL_SANDBOX_UID";
/// supervisor drops privileges to a group other than the UID's primary group.
pub const SANDBOX_GID: &str = "OPENSHELL_SANDBOX_GID";

/// Default numeric UID assigned to a sandbox when the image declares no OCI
/// `USER` (e.g. a plain Alpine base).
///
/// Local container drivers (Docker, Podman) supply this in place of an empty
/// OCI declaration so the supervisor runs the sandbox as a synthesized non-root
/// account instead of rejecting the image, matching the numeric-identity
/// behavior of the Kubernetes and VM drivers.
pub const DEFAULT_SANDBOX_UID: u32 = 1000;

/// Default numeric GID paired with [`DEFAULT_SANDBOX_UID`].
pub const DEFAULT_SANDBOX_GID: u32 = 1000;

/// Raw OCI `Config.User` declaration from the immutable image selected by a
/// local container driver.
///
Expand Down
15 changes: 12 additions & 3 deletions crates/openshell-driver-docker/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -630,9 +630,18 @@ fn resolve_docker_identity_from_accounts(
requested_user
};
if user_selector.is_empty() {
return Err(Status::failed_precondition(
"the pinned image defaults to root; configure a non-root process.run_as_user",
));
// The image declares no USER (e.g. a plain Alpine base) and the policy
// requested none. Synthesize a numeric non-root identity instead of
// rejecting, matching the Podman driver's USER-less default and the
// numeric-identity behavior of the Kubernetes and VM drivers.
return ResolvedWorkloadIdentity::new(
openshell_core::sandbox_env::DEFAULT_SANDBOX_UID,
openshell_core::sandbox_env::DEFAULT_SANDBOX_GID,
Vec::new(),
"default".to_string(),
image.id.clone(),
)
.map_err(|error| Status::failed_precondition(error.to_string()));
}
let (uid, passwd_entry) = resolve_numeric_or_named_user(user_selector, &passwd)?;
let username = passwd_entry.map(|entry| entry.name.as_str());
Expand Down
43 changes: 31 additions & 12 deletions crates/openshell-driver-podman/src/container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -593,18 +593,37 @@ fn build_env(
// hostname could otherwise present a certificate for a name they control
// and intercept the sandbox JWT.
env.remove(openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME);
env.insert(
openshell_core::sandbox_env::OCI_IMAGE_USER.into(),
oci_user.to_string(),
);
env.insert(
openshell_core::sandbox_env::SANDBOX_UID.into(),
String::new(),
);
env.insert(
openshell_core::sandbox_env::SANDBOX_GID.into(),
String::new(),
);
if oci_user.is_empty() {
// The image declares no OCI USER (e.g. a plain Alpine base). Assign a
// numeric non-root identity like the Kubernetes and VM drivers so the
// supervisor synthesizes the account instead of rejecting the image.
env.insert(
openshell_core::sandbox_env::OCI_IMAGE_USER.into(),
String::new(),
);
env.insert(
openshell_core::sandbox_env::SANDBOX_UID.into(),
openshell_core::sandbox_env::DEFAULT_SANDBOX_UID.to_string(),
);
env.insert(
openshell_core::sandbox_env::SANDBOX_GID.into(),
openshell_core::sandbox_env::DEFAULT_SANDBOX_GID.to_string(),
);
} else {
// The image declares a USER; preserve the OCI resolution path.
env.insert(
openshell_core::sandbox_env::OCI_IMAGE_USER.into(),
oci_user.to_string(),
);
env.insert(
openshell_core::sandbox_env::SANDBOX_UID.into(),
String::new(),
);
env.insert(
openshell_core::sandbox_env::SANDBOX_GID.into(),
String::new(),
);
}

// 4. Gateway-minted sandbox JWT. Keep the raw bearer out of container
// metadata; the supervisor reads it from a driver-owned bind mount.
Expand Down
6 changes: 5 additions & 1 deletion crates/openshell-policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -988,11 +988,11 @@ pub fn restrictive_default_policy() -> SandboxPolicy {
filesystem: Some(FilesystemPolicy {
include_workdir: true,
read_only: vec![
"/bin".into(),
"/usr".into(),
"/lib".into(),
"/proc".into(),
"/dev/urandom".into(),
"/app".into(),
"/etc".into(),
"/var/log".into(),
],
Expand Down Expand Up @@ -2128,6 +2128,10 @@ network_policies:
let policy = restrictive_default_policy();
let fs = policy.filesystem.expect("must have filesystem policy");
assert!(fs.include_workdir);
assert!(
fs.read_only.iter().any(|p| p == "/bin"),
"read_only should contain /bin"
);
assert!(
fs.read_only.iter().any(|p| p == "/usr"),
"read_only should contain /usr"
Expand Down
3 changes: 2 additions & 1 deletion crates/openshell-sandbox/src/boundary_exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,13 @@ impl LocalBoundaryExec {
let (session_user, session_home) =
crate::process::session_user_and_home(&self.policy, effective_workdir);
let path = std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into());
let shell = openshell_core::shell::detect_login_shell();
command
.env_clear()
.env(openshell_core::sandbox_env::SANDBOX, "1")
.env("HOME", session_home)
.env("USER", session_user)
.env("SHELL", "/bin/bash")
.env("SHELL", shell)
.env("PATH", path)
.env("TERM", if spec.pty { "xterm-256color" } else { "dumb" });
for (key, value) in &self.user_environment {
Expand Down
21 changes: 19 additions & 2 deletions crates/openshell-sandbox/src/sandbox/linux/landlock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,6 @@ fn prepare_with_path_open_mode(

let result: Result<PreparedRuleset> = (|| {
let access_all = AccessFs::from_all(abi);
let access_read = AccessFs::from_read(abi);

let mut ruleset = Ruleset::default();
ruleset = ruleset
Expand All @@ -315,7 +314,8 @@ fn prepare_with_path_open_mode(

for path in &read_only {
if let Some(path_fd) = try_open_path(path, compatibility, path_open_mode)? {
let allowed_access = access_for_path_fd(&path_fd, access_read, abi)?;
let allowed_access =
access_for_path_fd(&path_fd, read_only_access(path, abi), abi)?;
debug!(path = %path.display(), "Landlock allow read-only");
ruleset = ruleset
.add_rule(PathBeneath::new(path_fd, allowed_access))
Expand Down Expand Up @@ -434,6 +434,17 @@ pub fn enforce(prepared: PreparedRuleset) -> Result<()> {
Ok(())
}

/// The baseline permits execution only from Alpine's `/bin`. Other read-only
/// paths, including `/proc` and `/etc`, remain non-executable.
fn read_only_access(path: &Path, abi: ABI) -> BitFlags<AccessFs> {
let access = AccessFs::from_read(abi);
if path == Path::new("/bin") {
access | AccessFs::Execute
} else {
access
}
}

/// Tailor a rule's access mask to the inode referenced by its already-open FD.
///
/// Landlock directory-only rights such as `ReadDir` are invalid for regular
Expand Down Expand Up @@ -727,6 +738,12 @@ mod tests {
);
}

#[test]
fn only_bin_read_only_access_includes_execute() {
assert!(read_only_access(Path::new("/bin"), ABI::V3).contains(AccessFs::Execute));
assert!(!read_only_access(Path::new("/usr"), ABI::V3).contains(AccessFs::Execute));
}

#[test]
fn access_for_path_fd_limits_regular_file_access() {
let file = tempfile::NamedTempFile::new().unwrap();
Expand Down
Loading
Loading