Skip to content
Merged
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
4 changes: 2 additions & 2 deletions architecture/sandbox-custom-containers.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The `--from` flag accepts four kinds of input:

| Input | Example | Behavior |
|-------|---------|----------|
| **Community sandbox name** | `--from openclaw` | Resolves to `ghcr.io/nvidia/nemoclaw-community/sandboxes/openclaw:latest` |
| **Community sandbox name** | `--from openclaw` | Resolves to `d1i0nduu2f6qxk.cloudfront.net/nemoclaw-community/sandboxes/openclaw:latest` |
| **Dockerfile path** | `--from ./Dockerfile` | Builds the image, pushes it into the cluster, then creates the sandbox |
| **Directory with Dockerfile** | `--from ./my-sandbox/` | Uses the directory as the build context |
| **Full image reference** | `--from myregistry.com/img:tag` | Uses the image directly |
Expand All @@ -22,7 +22,7 @@ The CLI classifies the value in this order:
3. **Contains `/`, `:`, or `.`** — treated as a full container image reference.
4. **Otherwise** — treated as a community sandbox name, expanded to `{NEMOCLAW_COMMUNITY_REGISTRY}/{name}:latest`.

The community registry prefix defaults to `ghcr.io/nvidia/nemoclaw-community/sandboxes` and can be overridden with the `NEMOCLAW_COMMUNITY_REGISTRY` environment variable.
The community registry prefix defaults to `d1i0nduu2f6qxk.cloudfront.net/nemoclaw-community/sandboxes` and can be overridden with the `NEMOCLAW_COMMUNITY_REGISTRY` environment variable.

### Dockerfile build flow

Expand Down
2 changes: 1 addition & 1 deletion crates/navigator-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -646,7 +646,7 @@ enum SandboxCommands {
/// image reference (e.g., `myregistry.com/img:tag`).
///
/// Community names are resolved to
/// `ghcr.io/nvidia/nemoclaw-community/sandboxes/<name>:latest`
/// `d1i0nduu2f6qxk.cloudfront.net/nemoclaw-community/sandboxes/<name>:latest`
/// (override the prefix with `NEMOCLAW_COMMUNITY_REGISTRY`).
///
/// When given a Dockerfile or directory, the image is built and pushed
Expand Down
3 changes: 2 additions & 1 deletion crates/navigator-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1379,7 +1379,8 @@ pub async fn sandbox_create(
/// Bare sandbox names (e.g., `openclaw`) are expanded to
/// `{prefix}/{name}:latest` using this value. Override with the
/// `NEMOCLAW_COMMUNITY_REGISTRY` environment variable.
const DEFAULT_COMMUNITY_REGISTRY: &str = "ghcr.io/nvidia/nemoclaw-community/sandboxes";
const DEFAULT_COMMUNITY_REGISTRY: &str =
"d1i0nduu2f6qxk.cloudfront.net/nemoclaw-community/sandboxes";

/// Resolved source for the `--from` flag on `sandbox create`.
enum ResolvedSource {
Expand Down
67 changes: 66 additions & 1 deletion crates/navigator-sandbox/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ pub async fn run_sandbox(

// Load policy and initialize OPA engine
let navigator_endpoint_for_proxy = navigator_endpoint.clone();
let (policy, opa_engine) = load_policy(
let (mut policy, opa_engine) = load_policy(
sandbox_id.clone(),
sandbox,
navigator_endpoint.clone(),
Expand All @@ -146,6 +146,14 @@ pub async fn run_sandbox(
)
.await?;

// When no run_as_user/group is configured (e.g. custom/community images
// where the CLI cleared identity) and the supervisor is running as root
// (forced via runAsUser: 0 in the pod spec), auto-detect the image's
// intended sandbox user. This prevents child processes from running as
// root unnecessarily.
#[cfg(unix)]
maybe_infer_sandbox_user(&mut policy);

// Fetch provider environment variables from the server.
// This is done after loading the policy so the sandbox can still start
// even if provider env fetch fails (graceful degradation).
Expand Down Expand Up @@ -926,6 +934,63 @@ fn discover_policy_from_path(path: &std::path::Path) -> navigator_core::proto::S
}
}

/// When the policy has no `run_as_user`/`run_as_group` and the supervisor is
/// running as root, try to detect the image's intended non-root user for
/// child process privilege dropping.
///
/// This handles the BYOC / community-image case where the CLI clears
/// `run_as_user` (to avoid failures if the default "sandbox" user doesn't
/// exist), but the K8s pod spec forces `runAsUser: 0` so the supervisor can
/// perform privileged setup. Without this, child processes would inherit
/// root — undermining the image author's security intent.
///
/// Detection strategy: look for a `sandbox` user in `/etc/passwd`. This is
/// the conventional non-root user in NemoClaw sandbox images.
#[cfg(unix)]
fn maybe_infer_sandbox_user(policy: &mut SandboxPolicy) {
use nix::unistd::{Uid, User};

// Only infer when both fields are empty and we're running as root.
let has_user = policy
.process
.run_as_user
.as_ref()
.is_some_and(|u| !u.is_empty());
let has_group = policy
.process
.run_as_group
.as_ref()
.is_some_and(|g| !g.is_empty());
if has_user || has_group || !Uid::effective().is_root() {
return;
}

// Check if a "sandbox" user exists in the image.
match User::from_name("sandbox") {
Ok(Some(_)) => {
info!(
"Policy has no run_as_user but image has 'sandbox' user; \
inferring run_as_user=sandbox, run_as_group=sandbox"
);
policy.process.run_as_user = Some("sandbox".to_string());
policy.process.run_as_group = Some("sandbox".to_string());
}
Ok(None) => {
warn!(
"Policy has no run_as_user and image has no 'sandbox' user; \
child processes will run as root. Set process.run_as_user in \
your sandbox policy to specify a non-root user."
);
}
Err(e) => {
warn!(
error = %e,
"Failed to look up 'sandbox' user; child processes will run as root"
);
}
}
}

/// Prepare filesystem for the sandboxed process.
///
/// Creates `read_write` directories if they don't exist and sets ownership
Expand Down
33 changes: 29 additions & 4 deletions crates/navigator-sandbox/src/ssh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,25 @@ impl Default for PtyRequest {
}
}

/// Derive the session USER and HOME from the policy's `run_as_user`.
///
/// Falls back to `("sandbox", "/sandbox")` when the policy has no explicit user,
/// preserving backward compatibility with images that use the default layout.
fn session_user_and_home(policy: &SandboxPolicy) -> (String, String) {
match policy.process.run_as_user.as_deref() {
Some(user) if !user.is_empty() => {
// Look up the user's home directory from /etc/passwd.
let home = nix::unistd::User::from_name(user)
.ok()
.flatten()
.map(|u| u.dir.to_string_lossy().into_owned())
.unwrap_or_else(|| format!("/home/{user}"));
(user.to_string(), home)
}
_ => ("sandbox".to_string(), "/sandbox".to_string()),
}
}

#[allow(clippy::too_many_arguments)]
fn spawn_pty_shell(
policy: &SandboxPolicy,
Expand Down Expand Up @@ -651,13 +670,17 @@ fn spawn_pty_shell(
// set in /sandbox/.bashrc by the Dockerfile and sourced via login shell.
let path = std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into());

// Derive USER and HOME from the policy's run_as_user when available,
// falling back to "sandbox" / "/sandbox" for backward compatibility.
let (session_user, session_home) = session_user_and_home(policy);

cmd.env_clear()
.stdin(stdin)
.stdout(stdout)
.stderr(stderr)
.env("NEMOCLAW_SANDBOX", "1")
.env("HOME", "/sandbox")
.env("USER", "sandbox")
.env("HOME", &session_home)
.env("USER", &session_user)
.env("SHELL", "/bin/bash")
.env("PATH", &path)
.env("TERM", term);
Expand Down Expand Up @@ -813,13 +836,15 @@ fn spawn_pipe_exec(

let path = std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into());

let (session_user, session_home) = session_user_and_home(policy);

cmd.env_clear()
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.env("NEMOCLAW_SANDBOX", "1")
.env("HOME", "/sandbox")
.env("USER", "sandbox")
.env("HOME", &session_home)
.env("USER", &session_user)
.env("SHELL", "/bin/bash")
.env("PATH", &path)
.env("TERM", "dumb");
Expand Down
Loading
Loading