From 8c79539a22964685c12aa37b688ac3e4dc5a89d7 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 22 Mar 2026 10:47:14 -0700 Subject: [PATCH 01/25] fix(install): prevent nvm/login shell from resetting PATH in subshells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues caused install_nemoclaw() subshells to lose the caller's PATH, making install-preflight tests fail on macOS while passing in CI: 1. `bash -lc` sourced /etc/profile which runs path_helper on macOS, resetting PATH to system defaults and hiding the caller's binaries. Fix: replace `bash -lc` with `bash -c` — the parent shell already has the correct PATH, so a login shell is unnecessary. 2. `ensure_nvm_loaded()` unconditionally sourced nvm.sh, which also resets PATH. Fix: skip sourcing when node is already on PATH. Also export `info` and `warn` helpers via `declare -f` so pre_extract_openclaw can use them in the subshell. Additionally: - Fix SC2206 shellcheck warning in version_gte() by using `read -ra` instead of unquoted word splitting into arrays. - Add .shellcheckrc to suppress SC2059 (printf format string with color vars) and SC1091 (dynamically sourced files) across the project. Co-Authored-By: Claude Opus 4.6 (1M context) --- .shellcheckrc | 9 +++++++++ install.sh | 20 ++++++++++++-------- scripts/install.sh | 3 +++ 3 files changed, 24 insertions(+), 8 deletions(-) create mode 100644 .shellcheckrc diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 00000000000..709c50eed61 --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,9 @@ +# SC2059: Don't use variables in the printf format string. +# Our info/warn/ok/error helpers and banner intentionally embed ANSI color +# variables in printf format strings. This is safe because the variables +# contain only escape sequences, never user input. +# +# SC1091: Not following sourced file. +# We dynamically source nvm.sh and runtime.sh at paths that shellcheck +# cannot resolve statically. +disable=SC2059,SC1091 diff --git a/install.sh b/install.sh index 2c0c0554635..d43698a3602 100755 --- a/install.sh +++ b/install.sh @@ -191,8 +191,9 @@ ORIGINAL_PATH="${PATH:-}" # Compare two semver strings (major.minor.patch). Returns 0 if $1 >= $2. version_gte() { - local IFS=. - local -a a=($1) b=($2) + local -a a b + IFS=. read -ra a <<< "$1" + IFS=. read -ra b <<< "$2" for i in 0 1 2; do local ai=${a[$i]:-0} bi=${b[$i]:-0} if (( ai > bi )); then return 0; fi @@ -202,7 +203,10 @@ version_gte() { } # Ensure nvm environment is loaded in the current shell. +# Skip if node is already on PATH — sourcing nvm.sh can reset PATH and +# override the caller's node/npm (e.g. in test environments with stubs). ensure_nvm_loaded() { + command -v node &>/dev/null && return 0 if [[ -z "${NVM_DIR:-}" ]]; then export NVM_DIR="$HOME/.nvm" fi @@ -435,10 +439,10 @@ pre_extract_openclaw() { install_nemoclaw() { if [[ -f "./package.json" ]] && grep -q '"name": "nemoclaw"' ./package.json 2>/dev/null; then info "NemoClaw package.json found in current directory — installing from source…" - spin "Preparing OpenClaw package" bash -lc "$(declare -f pre_extract_openclaw); pre_extract_openclaw \"\$1\"" _ "$(pwd)" || \ + spin "Preparing OpenClaw package" bash -c "$(declare -f info warn pre_extract_openclaw); pre_extract_openclaw \"\$1\"" _ "$(pwd)" || \ warn "Pre-extraction failed — npm install may fail if openclaw tarball is broken" spin "Installing NemoClaw dependencies" npm install --ignore-scripts - spin "Building NemoClaw plugin" bash -lc 'cd nemoclaw && npm install --ignore-scripts && npm run build' + spin "Building NemoClaw plugin" bash -c 'cd nemoclaw && npm install --ignore-scripts && npm run build' spin "Linking NemoClaw CLI" npm link else info "Installing NemoClaw from GitHub…" @@ -449,11 +453,11 @@ install_nemoclaw() { rm -rf "$nemoclaw_src" mkdir -p "$(dirname "$nemoclaw_src")" spin "Cloning NemoClaw source" git clone --depth 1 https://github.com/NVIDIA/NemoClaw.git "$nemoclaw_src" - spin "Preparing OpenClaw package" bash -lc "$(declare -f pre_extract_openclaw); pre_extract_openclaw \"\$1\"" _ "$nemoclaw_src" || \ + spin "Preparing OpenClaw package" bash -c "$(declare -f info warn pre_extract_openclaw); pre_extract_openclaw \"\$1\"" _ "$nemoclaw_src" || \ warn "Pre-extraction failed — npm install may fail if openclaw tarball is broken" - spin "Installing NemoClaw dependencies" bash -lc "cd \"$nemoclaw_src\" && npm install --ignore-scripts" - spin "Building NemoClaw plugin" bash -lc "cd \"$nemoclaw_src\"/nemoclaw && npm install --ignore-scripts && npm run build" - spin "Linking NemoClaw CLI" bash -lc "cd \"$nemoclaw_src\" && npm link" + spin "Installing NemoClaw dependencies" bash -c "cd \"$nemoclaw_src\" && npm install --ignore-scripts" + spin "Building NemoClaw plugin" bash -c "cd \"$nemoclaw_src\"/nemoclaw && npm install --ignore-scripts && npm run build" + spin "Linking NemoClaw CLI" bash -c "cd \"$nemoclaw_src\" && npm link" fi refresh_path diff --git a/scripts/install.sh b/scripts/install.sh index 9e3dc1a2e00..2d47b2f5dd1 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -97,7 +97,10 @@ else fi # Ensure nvm environment is loaded in the current shell. +# Skip if node is already on PATH — sourcing nvm.sh can reset PATH and +# override the caller's node/npm (e.g. in test environments with stubs). ensure_nvm_loaded() { + command -v node &>/dev/null && return 0 if [ -z "${NVM_DIR:-}" ]; then export NVM_DIR="$HOME/.nvm" fi From 0c03d9691736778210df47050a9498f63f5069e0 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 22 Mar 2026 21:02:30 -0700 Subject: [PATCH 02/25] fix(ci): drop stray '.' arg from pyright pre-push hook Passing '.' to pyright overrides the include/exclude paths in pyproject.toml, causing it to scan .venv and test files. Without the argument, pyright respects the configured include paths. Co-Authored-By: Claude Opus 4.6 (1M context) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cac42595d90..f6a1471218d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -181,7 +181,7 @@ repos: - id: pyright-check name: Pyright (nemoclaw-blueprint) - entry: bash -c 'cd nemoclaw-blueprint && uv run --with pyright pyright .' + entry: bash -c 'cd nemoclaw-blueprint && uv run --with pyright pyright' language: system pass_filenames: false always_run: true From 5675007682818eb1056c183656ef474a9857727e Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 22 Mar 2026 21:08:27 -0700 Subject: [PATCH 03/25] chore: apply shfmt formatting to shell scripts Auto-formatted by shfmt via pre-commit hooks after merging upstream/main. Co-Authored-By: Claude Opus 4.6 (1M context) --- install.sh | 74 ++++++++++------- scripts/backup-workspace.sh | 155 ++++++++++++++++++----------------- scripts/install-openshell.sh | 4 +- 3 files changed, 126 insertions(+), 107 deletions(-) diff --git a/install.sh b/install.sh index 7028d5df6ee..792f7955c45 100755 --- a/install.sh +++ b/install.sh @@ -27,9 +27,9 @@ NEMOCLAW_VERSION="$(resolve_installer_version)" # --------------------------------------------------------------------------- if [[ -z "${NO_COLOR:-}" && -t 1 ]]; then if [[ "${COLORTERM:-}" == "truecolor" || "${COLORTERM:-}" == "24bit" ]]; then - C_GREEN=$'\033[38;2;118;185;0m' # #76B900 — exact NVIDIA green + C_GREEN=$'\033[38;2;118;185;0m' # #76B900 — exact NVIDIA green else - C_GREEN=$'\033[38;5;148m' # closest 256-color on dark backgrounds + C_GREEN=$'\033[38;5;148m' # closest 256-color on dark backgrounds fi C_BOLD=$'\033[1m' C_DIM=$'\033[2m' @@ -44,10 +44,13 @@ fi # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- -info() { printf "${C_CYAN}[INFO]${C_RESET} %s\n" "$*"; } -warn() { printf "${C_YELLOW}[WARN]${C_RESET} %s\n" "$*"; } -error() { printf "${C_RED}[ERROR]${C_RESET} %s\n" "$*" >&2; exit 1; } -ok() { printf " ${C_GREEN}✓${C_RESET} %s\n" "$*"; } +info() { printf "${C_CYAN}[INFO]${C_RESET} %s\n" "$*"; } +warn() { printf "${C_YELLOW}[WARN]${C_RESET} %s\n" "$*"; } +error() { + printf "${C_RED}[ERROR]${C_RESET} %s\n" "$*" >&2 + exit 1 +} +ok() { printf " ${C_GREEN}✓${C_RESET} %s\n" "$*"; } resolve_default_sandbox_name() { local registry_file="${HOME}/.nemoclaw/sandboxes.json" @@ -95,7 +98,7 @@ print_banner() { } print_done() { - local elapsed=$(( SECONDS - _INSTALL_START )) + local elapsed=$((SECONDS - _INSTALL_START)) local sandbox_name sandbox_name="$(resolve_default_sandbox_name)" info "=== Installation complete ===" @@ -146,7 +149,8 @@ usage() { # Stdout/stderr are captured; dumped only on failure. # Falls back to plain output when stdout is not a TTY (CI / piped installs). spin() { - local msg="$1"; shift + local msg="$1" + shift if [[ ! -t 1 ]]; then info "$msg" @@ -154,7 +158,8 @@ spin() { return fi - local log; log=$(mktemp) + local log + log=$(mktemp) "$@" >"$log" 2>&1 & local pid=$! i=0 local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') @@ -192,12 +197,12 @@ ORIGINAL_PATH="${PATH:-}" # Compare two semver strings (major.minor.patch). Returns 0 if $1 >= $2. version_gte() { local -a a b - IFS=. read -ra a <<< "$1" - IFS=. read -ra b <<< "$2" + IFS=. read -ra a <<<"$1" + IFS=. read -ra b <<<"$2" for i in 0 1 2; do local ai=${a[$i]:-0} bi=${b[$i]:-0} - if (( ai > bi )); then return 0; fi - if (( ai < bi )); then return 1; fi + if ((ai > bi)); then return 0; fi + if ((ai < bi)); then return 1; fi done return 0 } @@ -269,7 +274,7 @@ ensure_supported_runtime() { [[ "$node_major" =~ ^[0-9]+$ ]] || error "Could not determine Node.js version from '${node_version}'. ${RUNTIME_REQUIREMENT_MSG}" [[ "$npm_major" =~ ^[0-9]+$ ]] || error "Could not determine npm version from '${npm_version}'. ${RUNTIME_REQUIREMENT_MSG}" - if (( node_major < MIN_NODE_MAJOR || npm_major < MIN_NPM_MAJOR )); then + if ((node_major < MIN_NODE_MAJOR || npm_major < MIN_NPM_MAJOR)); then error "Unsupported runtime detected: Node.js ${node_version:-unknown}, npm ${npm_version:-unknown}. ${RUNTIME_REQUIREMENT_MSG} Upgrade Node.js and rerun the installer." fi @@ -292,7 +297,10 @@ install_nodejs() { local nvm_tmp nvm_tmp="$(mktemp)" curl -fsSL "https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_VERSION}/install.sh" -o "$nvm_tmp" \ - || { rm -f "$nvm_tmp"; error "Failed to download nvm installer"; } + || { + rm -f "$nvm_tmp" + error "Failed to download nvm installer" + } local actual_hash if command_exists sha256sum; then actual_hash="$(sha256sum "$nvm_tmp" | awk '{print $1}')" @@ -300,7 +308,7 @@ install_nodejs() { actual_hash="$(shasum -a 256 "$nvm_tmp" | awk '{print $1}')" else warn "No SHA-256 tool found — skipping nvm integrity check" - actual_hash="$NVM_SHA256" # allow execution + actual_hash="$NVM_SHA256" # allow execution fi if [[ "$actual_hash" != "$NVM_SHA256" ]]; then rm -f "$nvm_tmp" @@ -345,7 +353,7 @@ get_vram_mb() { if [[ "$(uname -s)" == "Darwin" ]] && command_exists sysctl; then local bytes bytes=$(sysctl -n hw.memsize 2>/dev/null || echo 0) - echo $(( bytes / 1024 / 1024 )) + echo $((bytes / 1024 / 1024)) return fi echo 0 @@ -377,10 +385,10 @@ install_or_upgrade_ollama() { # Pull the appropriate model based on VRAM local vram_mb vram_mb=$(get_vram_mb) - local vram_gb=$(( vram_mb / 1024 )) + local vram_gb=$((vram_mb / 1024)) info "Detected ${vram_gb} GB VRAM" - if (( vram_gb >= 120 )); then + if ((vram_gb >= 120)); then info "Pulling nemotron-3-super:120b…" ollama pull nemotron-3-super:120b else @@ -410,13 +418,12 @@ pre_extract_openclaw() { info "Pre-extracting openclaw@${openclaw_version} with system tar (GH-503 workaround)…" local tmpdir tmpdir="$(mktemp -d)" - if npm pack "openclaw@${openclaw_version}" --pack-destination "$tmpdir" > /dev/null 2>&1; then + if npm pack "openclaw@${openclaw_version}" --pack-destination "$tmpdir" >/dev/null 2>&1; then local tgz tgz="$(find "$tmpdir" -maxdepth 1 -name 'openclaw-*.tgz' -print -quit)" if [[ -n "$tgz" && -f "$tgz" ]]; then if mkdir -p "${install_dir}/node_modules/openclaw" \ - && tar xzf "$tgz" -C "${install_dir}/node_modules/openclaw" --strip-components=1 - then + && tar xzf "$tgz" -C "${install_dir}/node_modules/openclaw" --strip-components=1; then info "openclaw pre-extracted successfully" else warn "Failed to extract openclaw tarball" @@ -439,8 +446,8 @@ pre_extract_openclaw() { install_nemoclaw() { if [[ -f "./package.json" ]] && grep -q '"name": "nemoclaw"' ./package.json 2>/dev/null; then info "NemoClaw package.json found in current directory — installing from source…" - spin "Preparing OpenClaw package" bash -c "$(declare -f info warn pre_extract_openclaw); pre_extract_openclaw \"\$1\"" _ "$(pwd)" || \ - warn "Pre-extraction failed — npm install may fail if openclaw tarball is broken" + spin "Preparing OpenClaw package" bash -c "$(declare -f info warn pre_extract_openclaw); pre_extract_openclaw \"\$1\"" _ "$(pwd)" \ + || warn "Pre-extraction failed — npm install may fail if openclaw tarball is broken" spin "Installing NemoClaw dependencies" npm install --ignore-scripts spin "Building NemoClaw plugin" bash -c 'cd nemoclaw && npm install --ignore-scripts && npm run build' spin "Linking NemoClaw CLI" npm link @@ -453,8 +460,8 @@ install_nemoclaw() { rm -rf "$nemoclaw_src" mkdir -p "$(dirname "$nemoclaw_src")" spin "Cloning NemoClaw source" git clone --depth 1 https://github.com/NVIDIA/NemoClaw.git "$nemoclaw_src" - spin "Preparing OpenClaw package" bash -c "$(declare -f info warn pre_extract_openclaw); pre_extract_openclaw \"\$1\"" _ "$nemoclaw_src" || \ - warn "Pre-extraction failed — npm install may fail if openclaw tarball is broken" + spin "Preparing OpenClaw package" bash -c "$(declare -f info warn pre_extract_openclaw); pre_extract_openclaw \"\$1\"" _ "$nemoclaw_src" \ + || warn "Pre-extraction failed — npm install may fail if openclaw tarball is broken" spin "Installing NemoClaw dependencies" bash -c "cd \"$nemoclaw_src\" && npm install --ignore-scripts" spin "Building NemoClaw plugin" bash -c "cd \"$nemoclaw_src\"/nemoclaw && npm install --ignore-scripts && npm run build" spin "Linking NemoClaw CLI" bash -c "cd \"$nemoclaw_src\" && npm link" @@ -562,9 +569,18 @@ main() { for arg in "$@"; do case "$arg" in --non-interactive) NON_INTERACTIVE=1 ;; - --version|-v) printf "nemoclaw-installer v%s\n" "$NEMOCLAW_VERSION"; exit 0 ;; - --help|-h) usage; exit 0 ;; - *) usage; error "Unknown option: $arg" ;; + --version | -v) + printf "nemoclaw-installer v%s\n" "$NEMOCLAW_VERSION" + exit 0 + ;; + --help | -h) + usage + exit 0 + ;; + *) + usage + error "Unknown option: $arg" + ;; esac done # Also honor env var diff --git a/scripts/backup-workspace.sh b/scripts/backup-workspace.sh index 0e83ca3fe72..57382344321 100755 --- a/scripts/backup-workspace.sh +++ b/scripts/backup-workspace.sh @@ -16,10 +16,13 @@ NC='\033[0m' info() { echo -e "${GREEN}[backup]${NC} $1"; } warn() { echo -e "${YELLOW}[backup]${NC} $1"; } -fail() { echo -e "${RED}[backup]${NC} $1" >&2; exit 1; } +fail() { + echo -e "${RED}[backup]${NC} $1" >&2 + exit 1 +} usage() { - cat < $(basename "$0") restore [timestamp] @@ -31,88 +34,88 @@ Commands: Backup location: ${BACKUP_BASE}// EOF - exit 1 + exit 1 } do_backup() { - local sandbox="$1" - local ts - ts="$(date +%Y%m%d-%H%M%S)" - local dest="${BACKUP_BASE}/${ts}" - - mkdir -p "$BACKUP_BASE" - chmod 0700 "${HOME}/.nemoclaw" "$BACKUP_BASE" || \ - fail "Failed to set secure permissions on ${HOME}/.nemoclaw — check directory ownership." - mkdir -p "$dest" - chmod 0700 "$dest" - - info "Backing up workspace from sandbox '${sandbox}'..." - - local count=0 - for f in "${FILES[@]}"; do - if openshell sandbox download "$sandbox" "${WORKSPACE_PATH}/${f}" "${dest}/"; then - count=$((count + 1)) - else - warn "Skipped ${f} (not found or download failed)" - fi - done - - for d in "${DIRS[@]}"; do - if openshell sandbox download "$sandbox" "${WORKSPACE_PATH}/${d}/" "${dest}/${d}/"; then - count=$((count + 1)) - else - warn "Skipped ${d}/ (not found or download failed)" - fi - done - - if [ "$count" -eq 0 ]; then - fail "No files were backed up. Check that the sandbox '${sandbox}' exists and has workspace files." + local sandbox="$1" + local ts + ts="$(date +%Y%m%d-%H%M%S)" + local dest="${BACKUP_BASE}/${ts}" + + mkdir -p "$BACKUP_BASE" + chmod 0700 "${HOME}/.nemoclaw" "$BACKUP_BASE" \ + || fail "Failed to set secure permissions on ${HOME}/.nemoclaw — check directory ownership." + mkdir -p "$dest" + chmod 0700 "$dest" + + info "Backing up workspace from sandbox '${sandbox}'..." + + local count=0 + for f in "${FILES[@]}"; do + if openshell sandbox download "$sandbox" "${WORKSPACE_PATH}/${f}" "${dest}/"; then + count=$((count + 1)) + else + warn "Skipped ${f} (not found or download failed)" + fi + done + + for d in "${DIRS[@]}"; do + if openshell sandbox download "$sandbox" "${WORKSPACE_PATH}/${d}/" "${dest}/${d}/"; then + count=$((count + 1)) + else + warn "Skipped ${d}/ (not found or download failed)" fi + done + + if [ "$count" -eq 0 ]; then + fail "No files were backed up. Check that the sandbox '${sandbox}' exists and has workspace files." + fi - info "Backup saved to ${dest}/ (${count} items)" + info "Backup saved to ${dest}/ (${count} items)" } do_restore() { - local sandbox="$1" - local ts="${2:-}" - - if [ -z "$ts" ]; then - ts="$(ls -1 "$BACKUP_BASE" 2>/dev/null | sort -r | head -n1)" - [ -n "$ts" ] || fail "No backups found in ${BACKUP_BASE}/" - info "Using most recent backup: ${ts}" + local sandbox="$1" + local ts="${2:-}" + + if [ -z "$ts" ]; then + ts="$(ls -1 "$BACKUP_BASE" 2>/dev/null | sort -r | head -n1)" + [ -n "$ts" ] || fail "No backups found in ${BACKUP_BASE}/" + info "Using most recent backup: ${ts}" + fi + + local src="${BACKUP_BASE}/${ts}" + [ -d "$src" ] || fail "Backup directory not found: ${src}" + + info "Restoring workspace to sandbox '${sandbox}' from ${src}..." + + local count=0 + for f in "${FILES[@]}"; do + if [ -f "${src}/${f}" ]; then + if openshell sandbox upload "$sandbox" "${src}/${f}" "${WORKSPACE_PATH}/"; then + count=$((count + 1)) + else + warn "Failed to restore ${f}" + fi fi - - local src="${BACKUP_BASE}/${ts}" - [ -d "$src" ] || fail "Backup directory not found: ${src}" - - info "Restoring workspace to sandbox '${sandbox}' from ${src}..." - - local count=0 - for f in "${FILES[@]}"; do - if [ -f "${src}/${f}" ]; then - if openshell sandbox upload "$sandbox" "${src}/${f}" "${WORKSPACE_PATH}/"; then - count=$((count + 1)) - else - warn "Failed to restore ${f}" - fi - fi - done - - for d in "${DIRS[@]}"; do - if [ -d "${src}/${d}" ]; then - if openshell sandbox upload "$sandbox" "${src}/${d}/" "${WORKSPACE_PATH}/${d}/"; then - count=$((count + 1)) - else - warn "Failed to restore ${d}/" - fi - fi - done - - if [ "$count" -eq 0 ]; then - fail "No files were restored. Check that the sandbox '${sandbox}' is running." + done + + for d in "${DIRS[@]}"; do + if [ -d "${src}/${d}" ]; then + if openshell sandbox upload "$sandbox" "${src}/${d}/" "${WORKSPACE_PATH}/${d}/"; then + count=$((count + 1)) + else + warn "Failed to restore ${d}/" + fi fi + done + + if [ "$count" -eq 0 ]; then + fail "No files were restored. Check that the sandbox '${sandbox}' is running." + fi - info "Restored ${count} items to sandbox '${sandbox}'." + info "Restored ${count} items to sandbox '${sandbox}'." } # --- Main --- @@ -125,7 +128,7 @@ sandbox="$2" shift 2 case "$action" in - backup) do_backup "$sandbox" ;; - restore) do_restore "$sandbox" "$@" ;; - *) usage ;; + backup) do_backup "$sandbox" ;; + restore) do_restore "$sandbox" "$@" ;; + *) usage ;; esac diff --git a/scripts/install-openshell.sh b/scripts/install-openshell.sh index 1eeec7d2bd7..dbbeed40933 100755 --- a/scripts/install-openshell.sh +++ b/scripts/install-openshell.sh @@ -40,8 +40,8 @@ version_gte() { # Returns 0 (true) if $1 >= $2 — portable, no sort -V (BSD compat) local IFS=. local -a a b - read -r -a a <<< "$1" - read -r -a b <<< "$2" + read -r -a a <<<"$1" + read -r -a b <<<"$2" for i in 0 1 2; do local ai=${a[$i]:-0} bi=${b[$i]:-0} if ((ai > bi)); then return 0; fi From 350cba02ecd0ba6e1a7cc6aac6a98e821d974dba Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 22 Mar 2026 21:15:02 -0700 Subject: [PATCH 04/25] fix(ci): exclude test files from pyright strict type-checking The test_endpoint_validation.py file imports pytest which is only available as a dev dependency. Pyright in strict mode cannot resolve it, breaking the pre-push hook. Exclude test files from pyright's include paths since they have their own type discipline via pytest. Co-Authored-By: Claude Opus 4.6 (1M context) --- nemoclaw-blueprint/pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/nemoclaw-blueprint/pyproject.toml b/nemoclaw-blueprint/pyproject.toml index 47c6481c742..111be4ee341 100644 --- a/nemoclaw-blueprint/pyproject.toml +++ b/nemoclaw-blueprint/pyproject.toml @@ -44,6 +44,7 @@ ignore = [ pythonVersion = "3.11" typeCheckingMode = "strict" include = ["orchestrator", "migrations"] +exclude = ["**/test_*.py"] [tool.ruff.format] quote-style = "double" From e2b955b97a22d59646fa5950ed7bccb119eb7e2c Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 22 Mar 2026 21:30:59 -0700 Subject: [PATCH 05/25] fix(ci): drop stray '.' arg from pyright in blueprint Makefile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same issue as the pre-commit hook fix — passing '.' to pyright overrides the include/exclude paths in pyproject.toml, causing it to scan .venv and site-packages (17k+ errors in CI). Co-Authored-By: Claude Opus 4.6 (1M context) --- nemoclaw-blueprint/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nemoclaw-blueprint/Makefile b/nemoclaw-blueprint/Makefile index 448e7981c26..e00180ccef3 100644 --- a/nemoclaw-blueprint/Makefile +++ b/nemoclaw-blueprint/Makefile @@ -10,4 +10,4 @@ format: check: ruff check . ruff format --check . - uv run --extra dev --with pyright pyright . + uv run --extra dev --with pyright pyright From e6cc705334075c489f1b1109e94a3419fc3afe2b Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Mon, 23 Mar 2026 08:29:31 -0700 Subject: [PATCH 06/25] fix(security): isolate gateway process from sandbox agent The sandboxed agent could bypass config immutability by creating a copy of openclaw.json in a writable directory, modifying auth tokens and CORS origins, then killing the gateway and restarting it with HOME pointing to the tampered config. This was publicly demonstrated and is being actively discussed. Fix: run the gateway as a dedicated 'gateway' user via gosu. The 'sandbox' user (agent) cannot kill processes owned by a different UID. The fake-HOME attack no longer works because the agent cannot restart the gateway. Additional defense-in-depth: - Pin config hash at build time (sha256sum), verify at startup - Entrypoint runs as root, drops to gateway/sandbox as appropriate - gosu for clean privilege separation (no shell, no env leakage) --- Dockerfile | 30 ++++++++++++++++--- scripts/nemoclaw-start.sh | 61 ++++++++++++++++++++++++++++++--------- 2 files changed, 73 insertions(+), 18 deletions(-) diff --git a/Dockerfile b/Dockerfile index fe85ed2e1f7..b267df9040a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,8 +19,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ iproute2 \ && rm -rf /var/lib/apt/lists/* -# Create sandbox user (matches OpenShell convention) -RUN groupadd -r sandbox && useradd -r -g sandbox -d /sandbox -s /bin/bash sandbox \ +# Install gosu for privilege separation (gateway vs sandbox user) +# hadolint ignore=DL3008 +RUN apt-get update && apt-get install -y --no-install-recommends gosu \ + && rm -rf /var/lib/apt/lists/* + +# Create sandbox user (matches OpenShell convention) and gateway user. +# The gateway runs as 'gateway' so the 'sandbox' user (agent) cannot +# kill it or restart it with a tampered HOME/config. +RUN groupadd -r gateway && useradd -r -g gateway -d /sandbox -s /usr/sbin/nologin gateway \ + && groupadd -r sandbox && useradd -r -g sandbox -d /sandbox -s /bin/bash sandbox \ && mkdir -p /sandbox/.nemoclaw \ && chown -R sandbox:sandbox /sandbox @@ -151,7 +159,21 @@ RUN chown root:root /sandbox/.openclaw \ && find /sandbox/.openclaw -mindepth 1 -maxdepth 1 -exec chown -h root:root {} + \ && chmod 1777 /sandbox/.openclaw \ && chmod 444 /sandbox/.openclaw/openclaw.json -USER sandbox -ENTRYPOINT ["/bin/bash"] +# Pin config hash at build time so the entrypoint can verify integrity. +# Prevents the agent from creating a copy with a tampered config and +# restarting the gateway pointing at it. +RUN sha256sum /sandbox/.openclaw/openclaw.json > /sandbox/.openclaw/.config-hash \ + && chmod 444 /sandbox/.openclaw/.config-hash \ + && chown root:root /sandbox/.openclaw/.config-hash + +# Allow the gateway user to read the openclaw config and data dirs +RUN setfacl -m u:gateway:rx /sandbox/.openclaw 2>/dev/null \ + || chmod o+rx /sandbox/.openclaw +RUN setfacl -m u:gateway:r /sandbox/.openclaw/openclaw.json 2>/dev/null \ + || true + +# Entrypoint runs as root to start the gateway as the gateway user, +# then drops to sandbox for agent commands. See nemoclaw-start.sh. +ENTRYPOINT ["/usr/local/bin/nemoclaw-start"] CMD [] diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 05350642505..78fe2c6bfac 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -2,8 +2,12 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# NemoClaw sandbox entrypoint. Configures OpenClaw and starts the dashboard -# gateway inside the sandbox so the forwarded host port has a live upstream. +# NemoClaw sandbox entrypoint. Runs as root (via ENTRYPOINT) to start the +# gateway as the 'gateway' user, then drops to 'sandbox' for agent commands. +# +# SECURITY: The gateway runs as a separate user so the sandboxed agent cannot +# kill it or restart it with a tampered config (CVE: fake-HOME bypass). +# The config hash is verified at startup to detect tampering. # # Optional env: # NVIDIA_API_KEY API key for NVIDIA-hosted inference @@ -15,6 +19,24 @@ NEMOCLAW_CMD=("$@") CHAT_UI_URL="${CHAT_UI_URL:-http://127.0.0.1:18789}" PUBLIC_PORT=18789 +# ── Config integrity check ────────────────────────────────────── +# The config hash was pinned at build time. If it doesn't match, +# someone (or something) has tampered with the config. + +verify_config_integrity() { + local hash_file="/sandbox/.openclaw/.config-hash" + if [ ! -f "$hash_file" ]; then + echo "[SECURITY] Config hash file missing — cannot verify openclaw.json integrity" + return 0 # Don't block on older images that predate this check + fi + if ! (cd /sandbox/.openclaw && sha256sum -c "$hash_file" --status 2>/dev/null); then + echo "[SECURITY] openclaw.json integrity check FAILED — config may have been tampered with" + echo "[SECURITY] Expected hash: $(cat "$hash_file")" + echo "[SECURITY] Actual hash: $(sha256sum /sandbox/.openclaw/openclaw.json)" + return 1 + fi +} + write_auth_profile() { if [ -z "${NVIDIA_API_KEY:-}" ]; then return @@ -40,11 +62,10 @@ PYAUTH print_dashboard_urls() { local token chat_ui_base local_url remote_url - token="$( - python3 - <<'PYTOKEN' + token="$(python3 - <<'PYTOKEN' import json import os -path = os.path.expanduser('~/.openclaw/openclaw.json') +path = '/sandbox/.openclaw/openclaw.json' try: cfg = json.load(open(path)) except Exception: @@ -52,7 +73,7 @@ except Exception: else: print(cfg.get('gateway', {}).get('auth', {}).get('token', '')) PYTOKEN - )" +)" chat_ui_base="${CHAT_UI_URL%/}" local_url="http://127.0.0.1:${PUBLIC_PORT}/" @@ -67,7 +88,8 @@ PYTOKEN } start_auto_pair() { - nohup python3 - <<'PYAUTOPAIR' >>/tmp/gateway.log 2>&1 & + # Run auto-pair as sandbox user (it talks to the gateway via CLI) + nohup gosu sandbox python3 - <<'PYAUTOPAIR' >> /tmp/gateway.log 2>&1 & import json import subprocess import time @@ -127,17 +149,28 @@ PYAUTOPAIR echo "[gateway] auto-pair watcher launched (pid $!)" } +# ── Main ───────────────────────────────────────────────────────── + echo 'Setting up NemoClaw...' -# openclaw doctor --fix and openclaw plugins install already ran at build time -# (Dockerfile Step 28). At runtime they fail with EPERM against the locked -# /sandbox/.openclaw directory and accomplish nothing. -write_auth_profile +# Verify config integrity before starting anything +verify_config_integrity + +# Write auth profile as sandbox user (needs writable .openclaw-data) +gosu sandbox bash -c "$(declare -f write_auth_profile); write_auth_profile" + +# If a command was passed (e.g., "openclaw agent ..."), run it as sandbox user if [ ${#NEMOCLAW_CMD[@]} -gt 0 ]; then - exec "${NEMOCLAW_CMD[@]}" + exec gosu sandbox "${NEMOCLAW_CMD[@]}" fi -nohup openclaw gateway run >/tmp/gateway.log 2>&1 & -echo "[gateway] openclaw gateway launched (pid $!)" +# Start the gateway as the 'gateway' user. +# SECURITY: The sandbox user cannot kill this process because it runs +# under a different UID. The fake-HOME attack no longer works because +# the agent cannot restart the gateway with a tampered config. +nohup gosu gateway openclaw gateway run > /tmp/gateway.log 2>&1 & +GATEWAY_PID=$! +echo "[gateway] openclaw gateway launched as 'gateway' user (pid $GATEWAY_PID)" + start_auto_pair print_dashboard_urls From 63b0d72d42ebeed8307b42638d295625408261d4 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 23 Mar 2026 08:32:21 -0700 Subject: [PATCH 07/25] chore: apply shfmt formatting to brev-setup.sh Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/brev-setup.sh | 59 +++++++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/scripts/brev-setup.sh b/scripts/brev-setup.sh index a4b421577fc..cc8701ba9ac 100755 --- a/scripts/brev-setup.sh +++ b/scripts/brev-setup.sh @@ -23,7 +23,10 @@ NC='\033[0m' info() { echo -e "${GREEN}[brev]${NC} $1"; } warn() { echo -e "${YELLOW}[brev]${NC} $1"; } -fail() { echo -e "${RED}[brev]${NC} $1"; exit 1; } +fail() { + echo -e "${RED}[brev]${NC} $1" + exit 1 +} SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -34,20 +37,20 @@ export NEEDRESTART_MODE=a export DEBIAN_FRONTEND=noninteractive # --- 0. Node.js (needed for services) --- -if ! command -v node > /dev/null 2>&1; then +if ! command -v node >/dev/null 2>&1; then info "Installing Node.js..." - curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - > /dev/null 2>&1 - sudo apt-get install -y -qq nodejs > /dev/null 2>&1 + curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - >/dev/null 2>&1 + sudo apt-get install -y -qq nodejs >/dev/null 2>&1 info "Node.js $(node --version) installed" else info "Node.js already installed: $(node --version)" fi # --- 1. Docker --- -if ! command -v docker > /dev/null 2>&1; then +if ! command -v docker >/dev/null 2>&1; then info "Installing Docker..." - sudo apt-get update -qq > /dev/null 2>&1 - sudo apt-get install -y -qq docker.io > /dev/null 2>&1 + sudo apt-get update -qq >/dev/null 2>&1 + sudo apt-get install -y -qq docker.io >/dev/null 2>&1 sudo usermod -aG docker "$(whoami)" info "Docker installed" else @@ -55,17 +58,17 @@ else fi # --- 2. NVIDIA Container Toolkit (if GPU present) --- -if command -v nvidia-smi > /dev/null 2>&1; then - if ! dpkg -s nvidia-container-toolkit > /dev/null 2>&1; then +if command -v nvidia-smi >/dev/null 2>&1; then + if ! dpkg -s nvidia-container-toolkit >/dev/null 2>&1; then info "Installing NVIDIA Container Toolkit..." curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \ | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \ | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \ - | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list > /dev/null - sudo apt-get update -qq > /dev/null 2>&1 - sudo apt-get install -y -qq nvidia-container-toolkit > /dev/null 2>&1 - sudo nvidia-ctk runtime configure --runtime=docker > /dev/null 2>&1 + | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list >/dev/null + sudo apt-get update -qq >/dev/null 2>&1 + sudo apt-get install -y -qq nvidia-container-toolkit >/dev/null 2>&1 + sudo nvidia-ctk runtime configure --runtime=docker >/dev/null 2>&1 sudo systemctl restart docker info "NVIDIA Container Toolkit installed" else @@ -74,16 +77,16 @@ if command -v nvidia-smi > /dev/null 2>&1; then fi # --- 3. openshell CLI (binary release, not pip) --- -if ! command -v openshell > /dev/null 2>&1; then +if ! command -v openshell >/dev/null 2>&1; then info "Installing openshell CLI from GitHub release..." - if ! command -v gh > /dev/null 2>&1; then - sudo apt-get update -qq > /dev/null 2>&1 - sudo apt-get install -y -qq gh > /dev/null 2>&1 + if ! command -v gh >/dev/null 2>&1; then + sudo apt-get update -qq >/dev/null 2>&1 + sudo apt-get install -y -qq gh >/dev/null 2>&1 fi ARCH="$(uname -m)" case "$ARCH" in - x86_64|amd64) ASSET="openshell-x86_64-unknown-linux-musl.tar.gz" ;; - aarch64|arm64) ASSET="openshell-aarch64-unknown-linux-musl.tar.gz" ;; + x86_64 | amd64) ASSET="openshell-x86_64-unknown-linux-musl.tar.gz" ;; + aarch64 | arm64) ASSET="openshell-aarch64-unknown-linux-musl.tar.gz" ;; *) fail "Unsupported architecture: $ARCH" ;; esac tmpdir="$(mktemp -d)" @@ -98,12 +101,12 @@ else fi # --- 3b. cloudflared (for public tunnel) --- -if ! command -v cloudflared > /dev/null 2>&1; then +if ! command -v cloudflared >/dev/null 2>&1; then info "Installing cloudflared..." CF_ARCH="$(uname -m)" case "$CF_ARCH" in - x86_64|amd64) CF_ARCH="amd64" ;; - aarch64|arm64) CF_ARCH="arm64" ;; + x86_64 | amd64) CF_ARCH="amd64" ;; + aarch64 | arm64) CF_ARCH="arm64" ;; *) fail "Unsupported architecture for cloudflared: $CF_ARCH" ;; esac tmpdir=$(mktemp -d) @@ -117,11 +120,11 @@ fi # --- 4. vLLM (local inference, if GPU present) --- VLLM_MODEL="nvidia/nemotron-3-nano-30b-a3b" -if command -v nvidia-smi > /dev/null 2>&1; then +if command -v nvidia-smi >/dev/null 2>&1; then if ! python3 -c "import vllm" 2>/dev/null; then info "Installing vLLM..." - if ! command -v pip3 > /dev/null 2>&1; then - sudo apt-get install -y -qq python3-pip > /dev/null 2>&1 + if ! command -v pip3 >/dev/null 2>&1; then + sudo apt-get install -y -qq python3-pip >/dev/null 2>&1 fi pip3 install --break-system-packages vllm 2>/dev/null || pip3 install vllm info "vLLM installed" @@ -130,7 +133,7 @@ if command -v nvidia-smi > /dev/null 2>&1; then fi # Start vLLM if not already running - if curl -s http://localhost:8000/v1/models > /dev/null 2>&1; then + if curl -s http://localhost:8000/v1/models >/dev/null 2>&1; then info "vLLM already running on :8000" elif python3 -c "import vllm" 2>/dev/null; then info "Starting vLLM with $VLLM_MODEL..." @@ -138,11 +141,11 @@ if command -v nvidia-smi > /dev/null 2>&1; then --model "$VLLM_MODEL" \ --port 8000 \ --host 0.0.0.0 \ - > /tmp/vllm-server.log 2>&1 & + >/tmp/vllm-server.log 2>&1 & VLLM_PID=$! info "Waiting for vLLM to load model (this can take a few minutes)..." for _ in $(seq 1 120); do - if curl -s http://localhost:8000/v1/models > /dev/null 2>&1; then + if curl -s http://localhost:8000/v1/models >/dev/null 2>&1; then info "vLLM ready (PID $VLLM_PID)" break fi From 72d7d3a3b359ddae86b3e247d2fb766fefa8d2f5 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Mon, 23 Mar 2026 08:35:46 -0700 Subject: [PATCH 08/25] fix(security): harden entrypoint against symlink injection, PATH hijacking, and log tampering Additional mitigations beyond gateway process isolation: - Lock PATH to system dirs at entrypoint start - Resolve openclaw binary path once, pass as absolute path to auto-pair - Verify .openclaw symlinks point to expected targets at startup - Protect gateway log (chown gateway, chmod 600) - Change .openclaw dir from 1777 to 755 (prevents symlink replacement) --- Dockerfile | 7 ++++++- scripts/nemoclaw-start.sh | 31 +++++++++++++++++++++++++++---- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index b267df9040a..ba930a9d6b6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -154,10 +154,15 @@ RUN openclaw doctor --fix > /dev/null 2>&1 || true \ # The Landlock policy (/sandbox/.openclaw in read_only) provides defense-in-depth # once OpenShell enables enforcement. # Ref: https://github.com/NVIDIA/NemoClaw/issues/514 +# Lock the entire .openclaw directory tree. +# SECURITY: chmod 755 (not 1777) — the sandbox user can READ but not WRITE +# to this directory. This prevents the agent from replacing symlinks +# (e.g., pointing /sandbox/.openclaw/hooks to an attacker-controlled path). +# The writable state lives in .openclaw-data, reached via the symlinks. USER root RUN chown root:root /sandbox/.openclaw \ && find /sandbox/.openclaw -mindepth 1 -maxdepth 1 -exec chown -h root:root {} + \ - && chmod 1777 /sandbox/.openclaw \ + && chmod 755 /sandbox/.openclaw \ && chmod 444 /sandbox/.openclaw/openclaw.json # Pin config hash at build time so the entrypoint can verify integrity. diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 78fe2c6bfac..317ce873dd7 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -15,9 +15,14 @@ set -euo pipefail +# SECURITY: Lock down PATH so the agent cannot inject malicious binaries +# into commands executed by the entrypoint or auto-pair watcher. +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + NEMOCLAW_CMD=("$@") CHAT_UI_URL="${CHAT_UI_URL:-http://127.0.0.1:18789}" PUBLIC_PORT=18789 +OPENCLAW="$(command -v openclaw)" # Resolve once, use absolute path everywhere # ── Config integrity check ────────────────────────────────────── # The config hash was pinned at build time. If it doesn't match, @@ -89,11 +94,14 @@ PYTOKEN start_auto_pair() { # Run auto-pair as sandbox user (it talks to the gateway via CLI) - nohup gosu sandbox python3 - <<'PYAUTOPAIR' >> /tmp/gateway.log 2>&1 & + # SECURITY: Pass resolved openclaw path to prevent PATH hijacking + OPENCLAW_BIN="$OPENCLAW" nohup gosu sandbox python3 - <<'PYAUTOPAIR' >> /tmp/gateway.log 2>&1 & import json +import os import subprocess import time +OPENCLAW = os.environ.get('OPENCLAW_BIN', 'openclaw') DEADLINE = time.time() + 600 QUIET_POLLS = 0 APPROVED = 0 @@ -103,7 +111,7 @@ def run(*args): return proc.returncode, proc.stdout.strip(), proc.stderr.strip() while time.time() < DEADLINE: - rc, out, err = run('openclaw', 'devices', 'list', '--json') + rc, out, err = run(OPENCLAW, 'devices', 'list', '--json') if rc != 0 or not out: time.sleep(1) continue @@ -123,7 +131,7 @@ while time.time() < DEADLINE: request_id = (device or {}).get('requestId') if not request_id: continue - arc, aout, aerr = run('openclaw', 'devices', 'approve', request_id, '--json') + arc, aout, aerr = run(OPENCLAW, 'devices', 'approve', request_id, '--json') if arc == 0: APPROVED += 1 print(f'[auto-pair] approved request={request_id}') @@ -164,11 +172,26 @@ if [ ${#NEMOCLAW_CMD[@]} -gt 0 ]; then exec gosu sandbox "${NEMOCLAW_CMD[@]}" fi +# SECURITY: Protect gateway log from sandbox user tampering +touch /tmp/gateway.log +chown gateway:gateway /tmp/gateway.log +chmod 600 /tmp/gateway.log + +# Verify symlinks in .openclaw point to expected targets (prevent symlink injection) +for link in agents extensions workspace skills hooks identity devices canvas cron; do + target="$(readlink -f "/sandbox/.openclaw/$link" 2>/dev/null || true)" + expected="/sandbox/.openclaw-data/$link" + if [ "$target" != "$expected" ]; then + echo "[SECURITY] Symlink /sandbox/.openclaw/$link points to unexpected target: $target (expected $expected)" + exit 1 + fi +done + # Start the gateway as the 'gateway' user. # SECURITY: The sandbox user cannot kill this process because it runs # under a different UID. The fake-HOME attack no longer works because # the agent cannot restart the gateway with a tampered config. -nohup gosu gateway openclaw gateway run > /tmp/gateway.log 2>&1 & +nohup gosu gateway "$OPENCLAW" gateway run > /tmp/gateway.log 2>&1 & GATEWAY_PID=$! echo "[gateway] openclaw gateway launched as 'gateway' user (pid $GATEWAY_PID)" From f02d161b1598b14e4ed7afb7cfd0fe9f54f9ed37 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 23 Mar 2026 08:40:49 -0700 Subject: [PATCH 09/25] fix(security): pin apt and pip package versions in Dockerfile Addresses hadolint DL3008, DL3013, DL3042, and DL3059: pin all apt packages to exact versions, pin pyyaml, add --no-cache-dir, and consolidate consecutive RUN instructions. Co-Authored-By: Claude Opus 4.6 (1M context) --- Dockerfile | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 471a806b9f8..8ba3e2d0ad5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,9 +13,13 @@ FROM node:22-slim@sha256:4f77a690f2f8946ab16fe1e791a3ac0667ae1c3575c3e4d0d4589e9 ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get install -y --no-install-recommends \ - python3 python3-pip python3-venv \ - curl git ca-certificates \ - iproute2 \ + python3=3.11.2-1+b1 \ + python3-pip=23.0.1+dfsg-1 \ + python3-venv=3.11.2-1+b1 \ + curl=7.88.1-10+deb12u14 \ + git=1:2.39.5-0+deb12u3 \ + ca-certificates=20230311+deb12u1 \ + iproute2=6.1.0-3 \ && rm -rf /var/lib/apt/lists/* # Create sandbox user (matches OpenShell convention) @@ -52,10 +56,8 @@ RUN mkdir -p /sandbox/.openclaw-data/agents/main/agent \ && chown -R sandbox:sandbox /sandbox/.openclaw /sandbox/.openclaw-data # Install OpenClaw CLI -RUN npm install -g openclaw@2026.3.11 - -# Install PyYAML for blueprint runner -RUN pip3 install --break-system-packages pyyaml +RUN npm install -g openclaw@2026.3.11 \ + && pip3 install --no-cache-dir --break-system-packages pyyaml==6.0.3 # Copy built plugin and blueprint into the sandbox COPY --from=builder /opt/nemoclaw/dist/ /opt/nemoclaw/dist/ From c7cd3bfc18e3f20b1225c6b00ea822f563058e51 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Mon, 23 Mar 2026 08:48:14 -0700 Subject: [PATCH 10/25] fix: address CodeRabbit review feedback on gateway isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use separate /tmp/auto-pair.log for the auto-pair watcher (runs as sandbox user) instead of appending to /tmp/gateway.log which is owned by gateway:gateway with mode 600 — fixes permission denied on write. - Wait on GATEWAY_PID at end of entrypoint so PID 1 stays alive and Docker does not terminate the container immediately after launch. --- scripts/nemoclaw-start.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 317ce873dd7..fe4ca52bae4 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -95,7 +95,7 @@ PYTOKEN start_auto_pair() { # Run auto-pair as sandbox user (it talks to the gateway via CLI) # SECURITY: Pass resolved openclaw path to prevent PATH hijacking - OPENCLAW_BIN="$OPENCLAW" nohup gosu sandbox python3 - <<'PYAUTOPAIR' >> /tmp/gateway.log 2>&1 & + OPENCLAW_BIN="$OPENCLAW" nohup gosu sandbox python3 - <<'PYAUTOPAIR' >> /tmp/auto-pair.log 2>&1 & import json import os import subprocess @@ -177,6 +177,11 @@ touch /tmp/gateway.log chown gateway:gateway /tmp/gateway.log chmod 600 /tmp/gateway.log +# Separate log for auto-pair so sandbox user can write to it +touch /tmp/auto-pair.log +chown sandbox:sandbox /tmp/auto-pair.log +chmod 600 /tmp/auto-pair.log + # Verify symlinks in .openclaw point to expected targets (prevent symlink injection) for link in agents extensions workspace skills hooks identity devices canvas cron; do target="$(readlink -f "/sandbox/.openclaw/$link" 2>/dev/null || true)" @@ -197,3 +202,7 @@ echo "[gateway] openclaw gateway launched as 'gateway' user (pid $GATEWAY_PID)" start_auto_pair print_dashboard_urls + +# Keep container running by waiting on the gateway process. +# This script is PID 1 (ENTRYPOINT); if it exits, Docker kills all children. +wait "$GATEWAY_PID" From cf56a342a057dad86fae0b426b7a9e5783a67340 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Mon, 23 Mar 2026 08:53:27 -0700 Subject: [PATCH 11/25] test: add E2E test for gateway process isolation (10 checks) Builds the sandbox image and verifies: 1. Gateway and sandbox are different UIDs 2. openclaw.json not writable by sandbox 3. Symlinks in .openclaw cannot be replaced by sandbox 4. Config hash exists and matches 5. Config hash not writable by sandbox 6. gosu installed 7. PATH locked in entrypoint 8. openclaw resolved to absolute path 9. Symlink verification present 10. Sandbox user cannot kill gateway-user processes Added as test-e2e-gateway-isolation parallel CI job in pr.yaml. --- .github/workflows/pr.yaml | 10 ++ test/e2e-gateway-isolation.sh | 170 ++++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100755 test/e2e-gateway-isolation.sh diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index d95796d6229..34587c90d68 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -96,3 +96,13 @@ jobs: - name: Run sandbox E2E tests run: docker run --rm -v "${{ github.workspace }}/test:/opt/test" nemoclaw-sandbox-test /opt/test/e2e-test.sh + + test-e2e-gateway-isolation: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Run gateway isolation E2E tests + run: bash test/e2e-gateway-isolation.sh diff --git a/test/e2e-gateway-isolation.sh b/test/e2e-gateway-isolation.sh new file mode 100755 index 00000000000..0b090b976f2 --- /dev/null +++ b/test/e2e-gateway-isolation.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# E2E test for gateway process isolation and entrypoint hardening. +# Builds the sandbox image and verifies that the sandboxed agent cannot +# compromise the gateway via the fake-HOME attack or related vectors. +# +# Requires: docker + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +IMAGE="nemoclaw-isolation-test" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +pass() { echo -e "${GREEN}PASS${NC}: $1"; PASSED=$((PASSED + 1)); } +fail() { echo -e "${RED}FAIL${NC}: $1"; FAILED=$((FAILED + 1)); } +info() { echo -e "${YELLOW}TEST${NC}: $1"; } + +PASSED=0 +FAILED=0 + +# ── Build the image ────────────────────────────────────────────── + +info "Building sandbox image..." +docker build -t "$IMAGE" "$REPO_DIR" > /dev/null 2>&1 || { + fail "Docker build failed" + exit 1 +} + +# Helper: run a command inside the container as the sandbox user +run_as_sandbox() { + docker run --rm --entrypoint "" "$IMAGE" gosu sandbox bash -c "$1" 2>&1 +} + +# Helper: run a command inside the container as root +run_as_root() { + docker run --rm --entrypoint "" "$IMAGE" bash -c "$1" 2>&1 +} + +# ── Test 1: Gateway user exists and is different from sandbox ──── + +info "1. Gateway user exists with separate UID" +OUT=$(run_as_root "id gateway && id sandbox") +GW_UID=$(echo "$OUT" | grep "^uid=" | head -1 | sed 's/uid=\([0-9]*\).*/\1/') +SB_UID=$(echo "$OUT" | grep "^uid=" | tail -1 | sed 's/uid=\([0-9]*\).*/\1/') +if [ -n "$GW_UID" ] && [ -n "$SB_UID" ] && [ "$GW_UID" != "$SB_UID" ]; then + pass "gateway (uid=$GW_UID) and sandbox (uid=$SB_UID) are different users" +else + fail "gateway and sandbox UIDs not distinct: $OUT" +fi + +# ── Test 2: openclaw.json is not writable by sandbox user ──────── + +info "2. openclaw.json is not writable by sandbox user" +OUT=$(run_as_sandbox "touch /sandbox/.openclaw/openclaw.json 2>&1 || echo BLOCKED") +if echo "$OUT" | grep -q "BLOCKED\|Permission denied\|Read-only"; then + pass "sandbox cannot write to openclaw.json" +else + fail "sandbox CAN write to openclaw.json: $OUT" +fi + +# ── Test 3: .openclaw directory is not writable by sandbox ─────── + +info "3. .openclaw directory not writable by sandbox (no symlink replacement)" +# ln -sf may return 0 even when it fails to replace (silent failure on perm denied). +# Verify the symlink still points to the expected target after the attempt. +OUT=$(run_as_sandbox "ln -sf /tmp/evil /sandbox/.openclaw/hooks 2>&1; readlink /sandbox/.openclaw/hooks") +TARGET=$(echo "$OUT" | tail -1) +if [ "$TARGET" = "/sandbox/.openclaw-data/hooks" ]; then + pass "sandbox cannot replace symlinks in .openclaw (target unchanged)" +else + fail "sandbox replaced symlink — hooks now points to: $TARGET" +fi + +# ── Test 4: Config hash file exists and is valid ───────────────── + +info "4. Config hash exists and matches openclaw.json" +OUT=$(run_as_root "cd /sandbox/.openclaw && sha256sum -c .config-hash --status && echo VALID || echo INVALID") +if echo "$OUT" | grep -q "VALID"; then + pass "config hash matches openclaw.json" +else + fail "config hash mismatch: $OUT" +fi + +# ── Test 5: Config hash is not writable by sandbox ─────────────── + +info "5. Config hash not writable by sandbox user" +OUT=$(run_as_sandbox "echo fake > /sandbox/.openclaw/.config-hash 2>&1 || echo BLOCKED") +if echo "$OUT" | grep -q "BLOCKED\|Permission denied"; then + pass "sandbox cannot tamper with config hash" +else + fail "sandbox CAN write to config hash: $OUT" +fi + +# ── Test 6: gosu is installed ──────────────────────────────────── + +info "6. gosu binary is available" +OUT=$(run_as_root "command -v gosu && gosu --version") +if echo "$OUT" | grep -q "gosu"; then + pass "gosu installed" +else + fail "gosu not found: $OUT" +fi + +# ── Test 7: nemoclaw-start.sh has PATH hardening ──────────────── + +info "7. Entrypoint locks PATH" +OUT=$(run_as_root "grep 'export PATH=' /usr/local/bin/nemoclaw-start") +if echo "$OUT" | grep -q 'export PATH='; then + pass "PATH is explicitly set in entrypoint" +else + fail "PATH not locked: $OUT" +fi + +# ── Test 8: nemoclaw-start.sh resolves openclaw absolute path ─── + +info "8. Entrypoint resolves openclaw to absolute path" +OUT=$(run_as_root "grep 'command -v openclaw' /usr/local/bin/nemoclaw-start") +if [ -n "$OUT" ]; then + pass "openclaw resolved via command -v" +else + fail "no absolute path resolution for openclaw" +fi + +# ── Test 9: Symlink verification code exists ───────────────────── + +info "9. Entrypoint verifies symlink targets" +OUT=$(run_as_root "grep 'readlink' /usr/local/bin/nemoclaw-start") +if [ -n "$OUT" ]; then + pass "symlink verification present in entrypoint" +else + fail "no symlink verification in entrypoint" +fi + +# ── Test 10: Sandbox user cannot kill gateway-user processes ───── + +info "10. Sandbox user cannot kill gateway-user processes" +# Start a dummy process as gateway, try to kill it as sandbox +OUT=$(docker run --rm --entrypoint "" "$IMAGE" bash -c ' + gosu gateway sleep 60 & + GW_PID=$! + sleep 0.5 + RESULT=$(gosu sandbox kill $GW_PID 2>&1 || echo "EPERM") + echo "$RESULT" + kill $GW_PID 2>/dev/null || true +') +if echo "$OUT" | grep -qi "EPERM\|not permitted\|operation not permitted"; then + pass "sandbox cannot kill gateway-user processes" +else + fail "sandbox CAN kill gateway processes: $OUT" +fi + +# ── Summary ────────────────────────────────────────────────────── + +echo "" +echo -e "${GREEN}========================================${NC}" +echo -e " Results: ${GREEN}$PASSED passed${NC}, ${RED}$FAILED failed${NC}" +echo -e "${GREEN}========================================${NC}" + +# Cleanup +docker rmi "$IMAGE" > /dev/null 2>&1 || true + +[ "$FAILED" -eq 0 ] || exit 1 From 24821c801bac22456748966522bc123abf8472b0 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 23 Mar 2026 08:55:12 -0700 Subject: [PATCH 12/25] fix: remove hadolint ignore and quote pyyaml version pin Remove stale DL3008 suppression (versions are pinned) and add quotes around the pyyaml version specifier for consistency with upstream. Co-Authored-By: Claude Opus 4.6 (1M context) --- Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5a53bb216e4..4967deb6243 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,6 @@ FROM node:22-slim@sha256:4f77a690f2f8946ab16fe1e791a3ac0667ae1c3575c3e4d0d4589e9 ENV DEBIAN_FRONTEND=noninteractive -# hadolint ignore=DL3008 RUN apt-get update && apt-get install -y --no-install-recommends \ python3=3.11.2-1+b1 \ python3-pip=23.0.1+dfsg-1 \ @@ -58,7 +57,7 @@ RUN mkdir -p /sandbox/.openclaw-data/agents/main/agent \ # Install OpenClaw CLI and PyYAML for blueprint runner (single layer) RUN npm install -g openclaw@2026.3.11 \ - && pip3 install --no-cache-dir --break-system-packages pyyaml==6.0.3 + && pip3 install --no-cache-dir --break-system-packages "pyyaml==6.0.3" # Copy built plugin and blueprint into the sandbox COPY --from=builder /opt/nemoclaw/dist/ /opt/nemoclaw/dist/ From 88f98ce1c07b450f87dec891988d6e5f1389ca8d Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Mon, 23 Mar 2026 08:58:22 -0700 Subject: [PATCH 13/25] fix: prevent infinite recursion when openshell passes nemoclaw-start as command arg --- scripts/nemoclaw-start.sh | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index fe4ca52bae4..612d45bedea 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -19,7 +19,17 @@ set -euo pipefail # into commands executed by the entrypoint or auto-pair watcher. export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" -NEMOCLAW_CMD=("$@") +# Filter out self-invocation: openshell sandbox create passes "nemoclaw-start" +# as the command, but since this script is now the ENTRYPOINT, receiving our +# own name as $1 would cause infinite recursion via the NEMOCLAW_CMD exec path. +ARGS=() +for arg in "$@"; do + case "$arg" in + nemoclaw-start|/usr/local/bin/nemoclaw-start) ;; # skip self-reference + *) ARGS+=("$arg") ;; + esac +done +NEMOCLAW_CMD=("${ARGS[@]}") CHAT_UI_URL="${CHAT_UI_URL:-http://127.0.0.1:18789}" PUBLIC_PORT=18789 OPENCLAW="$(command -v openclaw)" # Resolve once, use absolute path everywhere From 2f0bf3bfe479a6dd49bd6c33cc6b9fdeaf444bd0 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 23 Mar 2026 08:59:00 -0700 Subject: [PATCH 14/25] fix: document -h short alias in usage output Co-Authored-By: Claude Opus 4.6 (1M context) --- install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install.sh b/install.sh index 792f7955c45..0452d282789 100755 --- a/install.sh +++ b/install.sh @@ -126,7 +126,7 @@ usage() { printf " ${C_DIM}Options:${C_RESET}\n" printf " --non-interactive Skip prompts (uses env vars / defaults)\n" printf " --version, -v Print installer version and exit\n" - printf " --help Show this help message and exit\n\n" + printf " --help, -h Show this help message and exit\n\n" printf " ${C_DIM}Environment:${C_RESET}\n" printf " NVIDIA_API_KEY API key (skips credential prompt)\n" printf " NEMOCLAW_NON_INTERACTIVE=1 Same as --non-interactive\n" From 006a916ff0efb6aa18e1aa960d879d1f4244ad42 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Mon, 23 Mar 2026 09:14:48 -0700 Subject: [PATCH 15/25] =?UTF-8?q?fix:=20suppress=20hadolint=20DL3002=20?= =?UTF-8?q?=E2=80=94=20root=20USER=20is=20intentional=20for=20gosu=20privi?= =?UTF-8?q?lege=20separation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index ba930a9d6b6..7d250df456c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -180,5 +180,6 @@ RUN setfacl -m u:gateway:r /sandbox/.openclaw/openclaw.json 2>/dev/null \ # Entrypoint runs as root to start the gateway as the gateway user, # then drops to sandbox for agent commands. See nemoclaw-start.sh. +# hadolint ignore=DL3002 ENTRYPOINT ["/usr/local/bin/nemoclaw-start"] CMD [] From 46a822f3987e07c06c237836599adf426734f3ea Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Mon, 23 Mar 2026 10:40:07 -0700 Subject: [PATCH 16/25] chore: consolidate apt-get layers (merge gosu into main install) --- Dockerfile | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5608d87070f..138964b1718 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,7 @@ FROM node:22-slim@sha256:4f77a690f2f8946ab16fe1e791a3ac0667ae1c3575c3e4d0d4589e9 ENV DEBIAN_FRONTEND=noninteractive +# gosu is for privilege separation (gateway vs sandbox user) RUN apt-get update && apt-get install -y --no-install-recommends \ python3=3.11.2-1+b1 \ python3-pip=23.0.1+dfsg-1 \ @@ -20,11 +21,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ git=1:2.39.5-0+deb12u3 \ ca-certificates=20230311+deb12u1 \ iproute2=6.1.0-3 \ - && rm -rf /var/lib/apt/lists/* - -# Install gosu for privilege separation (gateway vs sandbox user) -# hadolint ignore=DL3008 -RUN apt-get update && apt-get install -y --no-install-recommends gosu \ + gosu \ && rm -rf /var/lib/apt/lists/* # Create sandbox user (matches OpenShell convention) and gateway user. From e63e9424d0f5de6c93ced7609363b482374e4fa1 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 24 Mar 2026 14:58:48 -0700 Subject: [PATCH 17/25] fix: address review feedback on gateway isolation PR - Pin gosu=1.14-1+b10 in Dockerfile - Remove dead setfacl ACL lines (acl package not installed) - Fail closed on missing config hash file instead of warn-and-continue - Only strip nemoclaw-start from $1, not all arg positions - Capture Docker build output on failure instead of /dev/null - Replace grep-based tests 7-9 with behavioral assertions - Share single Docker image build across both e2e CI jobs --- .github/workflows/pr.yaml | 43 +++++++++++++++++++-- Dockerfile | 8 +--- scripts/nemoclaw-start.sh | 17 ++++----- test/e2e-gateway-isolation.sh | 70 +++++++++++++++++++++-------------- 4 files changed, 91 insertions(+), 47 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 34587c90d68..332d29336be 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -84,15 +84,42 @@ jobs: - name: Check coverage ratchet run: bash scripts/check-coverage-ratchet.sh + build-sandbox-image: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Build sandbox image + run: docker build -t nemoclaw-sandbox-test . + + - name: Save image to tarball + run: docker save nemoclaw-sandbox-test | gzip > /tmp/sandbox-image.tar.gz + + - name: Upload image artifact + uses: actions/upload-artifact@v4 + with: + name: sandbox-image + path: /tmp/sandbox-image.tar.gz + retention-days: 1 + test-e2e-sandbox: runs-on: ubuntu-latest timeout-minutes: 15 + needs: build-sandbox-image steps: - name: Checkout uses: actions/checkout@v6 - - name: Build sandbox test image - run: docker build -f test/Dockerfile.sandbox -t nemoclaw-sandbox-test . + - name: Download image artifact + uses: actions/download-artifact@v4 + with: + name: sandbox-image + path: /tmp + + - name: Load image + run: gunzip -c /tmp/sandbox-image.tar.gz | docker load - name: Run sandbox E2E tests run: docker run --rm -v "${{ github.workspace }}/test:/opt/test" nemoclaw-sandbox-test /opt/test/e2e-test.sh @@ -100,9 +127,19 @@ jobs: test-e2e-gateway-isolation: runs-on: ubuntu-latest timeout-minutes: 15 + needs: build-sandbox-image steps: - name: Checkout uses: actions/checkout@v6 + - name: Download image artifact + uses: actions/download-artifact@v4 + with: + name: sandbox-image + path: /tmp + + - name: Load image + run: gunzip -c /tmp/sandbox-image.tar.gz | docker load + - name: Run gateway isolation E2E tests - run: bash test/e2e-gateway-isolation.sh + run: NEMOCLAW_TEST_IMAGE=nemoclaw-sandbox-test bash test/e2e-gateway-isolation.sh diff --git a/Dockerfile b/Dockerfile index 138964b1718..3b371b631a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ git=1:2.39.5-0+deb12u3 \ ca-certificates=20230311+deb12u1 \ iproute2=6.1.0-3 \ - gosu \ + gosu=1.14-1+b10 \ && rm -rf /var/lib/apt/lists/* # Create sandbox user (matches OpenShell convention) and gateway user. @@ -172,12 +172,6 @@ RUN sha256sum /sandbox/.openclaw/openclaw.json > /sandbox/.openclaw/.config-hash && chmod 444 /sandbox/.openclaw/.config-hash \ && chown root:root /sandbox/.openclaw/.config-hash -# Allow the gateway user to read the openclaw config and data dirs -RUN setfacl -m u:gateway:rx /sandbox/.openclaw 2>/dev/null \ - || chmod o+rx /sandbox/.openclaw -RUN setfacl -m u:gateway:r /sandbox/.openclaw/openclaw.json 2>/dev/null \ - || true - # Entrypoint runs as root to start the gateway as the gateway user, # then drops to sandbox for agent commands. See nemoclaw-start.sh. # hadolint ignore=DL3002 diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 612d45bedea..162798445f2 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -22,14 +22,11 @@ export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" # Filter out self-invocation: openshell sandbox create passes "nemoclaw-start" # as the command, but since this script is now the ENTRYPOINT, receiving our # own name as $1 would cause infinite recursion via the NEMOCLAW_CMD exec path. -ARGS=() -for arg in "$@"; do - case "$arg" in - nemoclaw-start|/usr/local/bin/nemoclaw-start) ;; # skip self-reference - *) ARGS+=("$arg") ;; - esac -done -NEMOCLAW_CMD=("${ARGS[@]}") +# Only strip from $1 — later args with this name are legitimate user arguments. +case "${1:-}" in + nemoclaw-start|/usr/local/bin/nemoclaw-start) shift ;; +esac +NEMOCLAW_CMD=("$@") CHAT_UI_URL="${CHAT_UI_URL:-http://127.0.0.1:18789}" PUBLIC_PORT=18789 OPENCLAW="$(command -v openclaw)" # Resolve once, use absolute path everywhere @@ -41,8 +38,8 @@ OPENCLAW="$(command -v openclaw)" # Resolve once, use absolute path everywhere verify_config_integrity() { local hash_file="/sandbox/.openclaw/.config-hash" if [ ! -f "$hash_file" ]; then - echo "[SECURITY] Config hash file missing — cannot verify openclaw.json integrity" - return 0 # Don't block on older images that predate this check + echo "[SECURITY] Config hash file missing — refusing to start without integrity verification" + return 1 fi if ! (cd /sandbox/.openclaw && sha256sum -c "$hash_file" --status 2>/dev/null); then echo "[SECURITY] openclaw.json integrity check FAILED — config may have been tampered with" diff --git a/test/e2e-gateway-isolation.sh b/test/e2e-gateway-isolation.sh index 0b090b976f2..e7635d967bf 100755 --- a/test/e2e-gateway-isolation.sh +++ b/test/e2e-gateway-isolation.sh @@ -12,7 +12,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -IMAGE="nemoclaw-isolation-test" +IMAGE="${NEMOCLAW_TEST_IMAGE:-nemoclaw-isolation-test}" RED='\033[0;31m' GREEN='\033[0;32m' @@ -28,11 +28,18 @@ FAILED=0 # ── Build the image ────────────────────────────────────────────── -info "Building sandbox image..." -docker build -t "$IMAGE" "$REPO_DIR" > /dev/null 2>&1 || { - fail "Docker build failed" - exit 1 -} +# Skip build if image already exists (e.g., loaded from CI artifact) +if docker image inspect "$IMAGE" > /dev/null 2>&1; then + info "Using pre-built image: $IMAGE" +else + info "Building sandbox image..." + BUILD_LOG="$(mktemp)" + if ! docker build -t "$IMAGE" "$REPO_DIR" > "$BUILD_LOG" 2>&1; then + tail -40 "$BUILD_LOG" + fail "Docker build failed (last 40 lines above)" + exit 1 + fi +fi # Helper: run a command inside the container as the sandbox user run_as_sandbox() { @@ -109,34 +116,41 @@ else fail "gosu not found: $OUT" fi -# ── Test 7: nemoclaw-start.sh has PATH hardening ──────────────── +# ── Test 7: Entrypoint PATH is locked to system dirs ───────────── -info "7. Entrypoint locks PATH" -OUT=$(run_as_root "grep 'export PATH=' /usr/local/bin/nemoclaw-start") -if echo "$OUT" | grep -q 'export PATH='; then - pass "PATH is explicitly set in entrypoint" +info "7. Entrypoint locks PATH to system directories" +# Run the entrypoint preamble (up to the PATH export) and verify the result +OUT=$(run_as_root "bash -c 'source <(head -21 /usr/local/bin/nemoclaw-start) 2>/dev/null; echo \$PATH'") +if echo "$OUT" | grep -q "^/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin$"; then + pass "PATH is locked to system directories" else - fail "PATH not locked: $OUT" + fail "PATH not locked as expected: $OUT" fi -# ── Test 8: nemoclaw-start.sh resolves openclaw absolute path ─── +# ── Test 8: openclaw resolves to expected absolute path ────────── -info "8. Entrypoint resolves openclaw to absolute path" -OUT=$(run_as_root "grep 'command -v openclaw' /usr/local/bin/nemoclaw-start") -if [ -n "$OUT" ]; then - pass "openclaw resolved via command -v" +info "8. Gateway runs the expected openclaw binary" +OUT=$(run_as_root "gosu gateway which openclaw") +if [ "$OUT" = "/usr/local/bin/openclaw" ]; then + pass "openclaw resolves to /usr/local/bin/openclaw" else - fail "no absolute path resolution for openclaw" + fail "openclaw resolves to unexpected path: $OUT" fi -# ── Test 9: Symlink verification code exists ───────────────────── - -info "9. Entrypoint verifies symlink targets" -OUT=$(run_as_root "grep 'readlink' /usr/local/bin/nemoclaw-start") -if [ -n "$OUT" ]; then - pass "symlink verification present in entrypoint" +# ── Test 9: Symlinks point to expected targets ─────────────────── + +info "9. All .openclaw symlinks point to .openclaw-data" +FAILED_LINKS="" +for link in agents extensions workspace skills hooks identity devices canvas cron; do + OUT=$(run_as_root "readlink -f /sandbox/.openclaw/$link") + if [ "$OUT" != "/sandbox/.openclaw-data/$link" ]; then + FAILED_LINKS="$FAILED_LINKS $link->$OUT" + fi +done +if [ -z "$FAILED_LINKS" ]; then + pass "all symlinks point to .openclaw-data" else - fail "no symlink verification in entrypoint" + fail "symlink targets wrong:$FAILED_LINKS" fi # ── Test 10: Sandbox user cannot kill gateway-user processes ───── @@ -164,7 +178,9 @@ echo -e "${GREEN}========================================${NC}" echo -e " Results: ${GREEN}$PASSED passed${NC}, ${RED}$FAILED failed${NC}" echo -e "${GREEN}========================================${NC}" -# Cleanup -docker rmi "$IMAGE" > /dev/null 2>&1 || true +# Cleanup — only remove images we built ourselves +if [ -z "${NEMOCLAW_TEST_IMAGE:-}" ]; then + docker rmi "$IMAGE" > /dev/null 2>&1 || true +fi [ "$FAILED" -eq 0 ] || exit 1 From 027b9fc7dfe123c615205088d47a5f5be5ee8a1a Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 24 Mar 2026 15:27:13 -0700 Subject: [PATCH 18/25] ci: allow 'merge' type in commitlint config Merge commits from conflict resolution use the 'merge:' prefix, which was not in the allowed type list, causing commit-lint CI to fail. Co-Authored-By: Claude Opus 4.6 (1M context) --- commitlint.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/commitlint.config.js b/commitlint.config.js index b4ee2777d5c..45dec93e406 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -16,6 +16,7 @@ module.exports = { "test", "ci", "perf", + "merge", ], ], }, From 430ceb9cb25ced50abe78af37c03f31dedd301a6 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 24 Mar 2026 15:35:06 -0700 Subject: [PATCH 19/25] fix: add hadolint ignore for intentional root USER and fix CI image builds - Add hadolint DL3002 ignore on USER root (intentional for config locking) - Build both test and production Docker images in CI: sandbox-test for e2e fixtures, production for gateway isolation tests --- .github/workflows/pr.yaml | 42 +++++++++++++++++++++++++-------------- Dockerfile | 1 + 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 9e7b67a6e27..8027af3f080 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -84,30 +84,42 @@ jobs: - name: Check coverage ratchet run: bash scripts/check-coverage-ratchet.sh - build-sandbox-image: + build-sandbox-images: runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v6 - - name: Build sandbox image - run: docker build -t nemoclaw-sandbox-test . + - name: Build sandbox test image (e2e fixtures) + run: docker build -f test/Dockerfile.sandbox -t nemoclaw-sandbox-test . - - name: Save image to tarball - run: docker save nemoclaw-sandbox-test | gzip > /tmp/sandbox-image.tar.gz + - name: Build production image (gateway isolation) + run: docker build -t nemoclaw-isolation-test . - - name: Upload image artifact + - name: Save images to tarballs + run: | + docker save nemoclaw-sandbox-test | gzip > /tmp/sandbox-test-image.tar.gz + docker save nemoclaw-isolation-test | gzip > /tmp/isolation-image.tar.gz + + - name: Upload sandbox test image + uses: actions/upload-artifact@v4 + with: + name: sandbox-test-image + path: /tmp/sandbox-test-image.tar.gz + retention-days: 1 + + - name: Upload isolation image uses: actions/upload-artifact@v4 with: - name: sandbox-image - path: /tmp/sandbox-image.tar.gz + name: isolation-image + path: /tmp/isolation-image.tar.gz retention-days: 1 test-e2e-sandbox: runs-on: ubuntu-latest timeout-minutes: 15 - needs: build-sandbox-image + needs: build-sandbox-images steps: - name: Checkout uses: actions/checkout@v6 @@ -115,11 +127,11 @@ jobs: - name: Download image artifact uses: actions/download-artifact@v4 with: - name: sandbox-image + name: sandbox-test-image path: /tmp - name: Load image - run: gunzip -c /tmp/sandbox-image.tar.gz | docker load + run: gunzip -c /tmp/sandbox-test-image.tar.gz | docker load - name: Run sandbox E2E tests run: docker run --rm -v "${{ github.workspace }}/test:/opt/test" nemoclaw-sandbox-test /opt/test/e2e-test.sh @@ -127,7 +139,7 @@ jobs: test-e2e-gateway-isolation: runs-on: ubuntu-latest timeout-minutes: 15 - needs: build-sandbox-image + needs: build-sandbox-images steps: - name: Checkout uses: actions/checkout@v6 @@ -135,14 +147,14 @@ jobs: - name: Download image artifact uses: actions/download-artifact@v4 with: - name: sandbox-image + name: isolation-image path: /tmp - name: Load image - run: gunzip -c /tmp/sandbox-image.tar.gz | docker load + run: gunzip -c /tmp/isolation-image.tar.gz | docker load - name: Run gateway isolation E2E tests - run: NEMOCLAW_TEST_IMAGE=nemoclaw-sandbox-test bash test/e2e-gateway-isolation.sh + run: NEMOCLAW_TEST_IMAGE=nemoclaw-isolation-test bash test/e2e-gateway-isolation.sh validate-profiles: runs-on: ubuntu-latest diff --git a/Dockerfile b/Dockerfile index e4d0990e310..9dc47f70b19 100644 --- a/Dockerfile +++ b/Dockerfile @@ -169,6 +169,7 @@ RUN openclaw doctor --fix > /dev/null 2>&1 || true \ # to this directory. This prevents the agent from replacing symlinks # (e.g., pointing /sandbox/.openclaw/hooks to an attacker-controlled path). # The writable state lives in .openclaw-data, reached via the symlinks. +# hadolint ignore=DL3002 USER root RUN chown root:root /sandbox/.openclaw \ && find /sandbox/.openclaw -mindepth 1 -maxdepth 1 -exec chown -h root:root {} + \ From 6e418343314c57cb7a01359f1b70089f8ca07354 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 24 Mar 2026 15:36:37 -0700 Subject: [PATCH 20/25] fix: resolve remaining CI failures and address symlink review comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move hadolint DL3002 ignore to directly before USER root (fixes lint) - Rebase test/Dockerfile.sandbox on the production image instead of rebuilding from scratch — shares the build-sandbox-image artifact while layering test fixtures on top (fixes test-e2e-sandbox) - Verify all symlinks in .openclaw dynamically instead of hardcoding a fixed list, so future OpenClaw symlinks are covered automatically Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/pr.yaml | 5 ++- Dockerfile | 2 +- scripts/nemoclaw-start.sh | 26 ++++++++------ test/Dockerfile.sandbox | 72 +++++++++------------------------------ 4 files changed, 36 insertions(+), 69 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 9e7b67a6e27..ecf0bab91dc 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -121,8 +121,11 @@ jobs: - name: Load image run: gunzip -c /tmp/sandbox-image.tar.gz | docker load + - name: Build test fixture layer + run: docker build -f test/Dockerfile.sandbox --build-arg BASE_IMAGE=nemoclaw-sandbox-test -t nemoclaw-e2e-test . + - name: Run sandbox E2E tests - run: docker run --rm -v "${{ github.workspace }}/test:/opt/test" nemoclaw-sandbox-test /opt/test/e2e-test.sh + run: docker run --rm -v "${{ github.workspace }}/test:/opt/test" nemoclaw-e2e-test /opt/test/e2e-test.sh test-e2e-gateway-isolation: runs-on: ubuntu-latest diff --git a/Dockerfile b/Dockerfile index e4d0990e310..d5dbb55c30b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -169,6 +169,7 @@ RUN openclaw doctor --fix > /dev/null 2>&1 || true \ # to this directory. This prevents the agent from replacing symlinks # (e.g., pointing /sandbox/.openclaw/hooks to an attacker-controlled path). # The writable state lives in .openclaw-data, reached via the symlinks. +# hadolint ignore=DL3002 USER root RUN chown root:root /sandbox/.openclaw \ && find /sandbox/.openclaw -mindepth 1 -maxdepth 1 -exec chown -h root:root {} + \ @@ -184,6 +185,5 @@ RUN sha256sum /sandbox/.openclaw/openclaw.json > /sandbox/.openclaw/.config-hash # Entrypoint runs as root to start the gateway as the gateway user, # then drops to sandbox for agent commands. See nemoclaw-start.sh. -# hadolint ignore=DL3002 ENTRYPOINT ["/usr/local/bin/nemoclaw-start"] CMD [] diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index e651bd8dd0f..c41e33ddbc3 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -24,12 +24,12 @@ export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" # own name as $1 would cause infinite recursion via the NEMOCLAW_CMD exec path. # Only strip from $1 — later args with this name are legitimate user arguments. case "${1:-}" in - nemoclaw-start|/usr/local/bin/nemoclaw-start) shift ;; + nemoclaw-start | /usr/local/bin/nemoclaw-start) shift ;; esac NEMOCLAW_CMD=("$@") CHAT_UI_URL="${CHAT_UI_URL:-http://127.0.0.1:18789}" PUBLIC_PORT=18789 -OPENCLAW="$(command -v openclaw)" # Resolve once, use absolute path everywhere +OPENCLAW="$(command -v openclaw)" # Resolve once, use absolute path everywhere # ── Config integrity check ────────────────────────────────────── # The config hash was pinned at build time. If it doesn't match, @@ -74,7 +74,8 @@ PYAUTH print_dashboard_urls() { local token chat_ui_base local_url remote_url - token="$(python3 - <<'PYTOKEN' + token="$( + python3 - <<'PYTOKEN' import json import os path = '/sandbox/.openclaw/openclaw.json' @@ -85,7 +86,7 @@ except Exception: else: print(cfg.get('gateway', {}).get('auth', {}).get('token', '')) PYTOKEN -)" + )" chat_ui_base="${CHAT_UI_URL%/}" local_url="http://127.0.0.1:${PUBLIC_PORT}/" @@ -102,7 +103,7 @@ PYTOKEN start_auto_pair() { # Run auto-pair as sandbox user (it talks to the gateway via CLI) # SECURITY: Pass resolved openclaw path to prevent PATH hijacking - OPENCLAW_BIN="$OPENCLAW" nohup gosu sandbox python3 - <<'PYAUTOPAIR' >> /tmp/auto-pair.log 2>&1 & + OPENCLAW_BIN="$OPENCLAW" nohup gosu sandbox python3 - <<'PYAUTOPAIR' >>/tmp/auto-pair.log 2>&1 & import json import os import subprocess @@ -190,12 +191,15 @@ touch /tmp/auto-pair.log chown sandbox:sandbox /tmp/auto-pair.log chmod 600 /tmp/auto-pair.log -# Verify symlinks in .openclaw point to expected targets (prevent symlink injection) -for link in agents extensions workspace skills hooks identity devices canvas cron; do - target="$(readlink -f "/sandbox/.openclaw/$link" 2>/dev/null || true)" - expected="/sandbox/.openclaw-data/$link" +# Verify ALL symlinks in .openclaw point to expected .openclaw-data targets. +# Dynamic scan so future OpenClaw symlinks are covered automatically. +for entry in /sandbox/.openclaw/*; do + [ -L "$entry" ] || continue + name="$(basename "$entry")" + target="$(readlink -f "$entry" 2>/dev/null || true)" + expected="/sandbox/.openclaw-data/$name" if [ "$target" != "$expected" ]; then - echo "[SECURITY] Symlink /sandbox/.openclaw/$link points to unexpected target: $target (expected $expected)" + echo "[SECURITY] Symlink $entry points to unexpected target: $target (expected $expected)" exit 1 fi done @@ -204,7 +208,7 @@ done # SECURITY: The sandbox user cannot kill this process because it runs # under a different UID. The fake-HOME attack no longer works because # the agent cannot restart the gateway with a tampered config. -nohup gosu gateway "$OPENCLAW" gateway run > /tmp/gateway.log 2>&1 & +nohup gosu gateway "$OPENCLAW" gateway run >/tmp/gateway.log 2>&1 & GATEWAY_PID=$! echo "[gateway] openclaw gateway launched as 'gateway' user (pid $GATEWAY_PID)" diff --git a/test/Dockerfile.sandbox b/test/Dockerfile.sandbox index 7552e041bfc..f26d49bc91e 100644 --- a/test/Dockerfile.sandbox +++ b/test/Dockerfile.sandbox @@ -1,67 +1,27 @@ -# hadolint global ignore=DL3008,DL3013,DL3042,DL3059,DL4006,SC2038 -# Lightweight test sandbox for NemoClaw E2E testing -# Simulates the OpenClaw-in-OpenShell environment without requiring -# the full NVIDIA base image or openshell CLI +# hadolint global ignore=DL3002,DL3059 +# Test sandbox for NemoClaw E2E testing. +# Layers test fixtures on top of the production sandbox image so both +# e2e jobs share a single Docker build for the base layers. -FROM node:22-slim +ARG BASE_IMAGE=nemoclaw-sandbox-test +# hadolint ignore=DL3006 +FROM ${BASE_IMAGE} -ENV DEBIAN_FRONTEND=noninteractive - -RUN apt-get update && apt-get install -y --no-install-recommends \ - python3 python3-pip python3-venv \ - curl git ca-certificates \ - iproute2 \ - && rm -rf /var/lib/apt/lists/* - -# Create sandbox user (matches OpenShell convention) -RUN groupadd -r sandbox && useradd -r -g sandbox -d /sandbox -s /bin/bash sandbox \ - && mkdir -p /sandbox/.openclaw /sandbox/.nemoclaw \ - && chown -R sandbox:sandbox /sandbox - -# Install OpenClaw CLI -RUN npm install -g openclaw@2026.3.11 - -# Install PyYAML for blueprint runner -RUN pip3 install --break-system-packages pyyaml - -# Copy our plugin and blueprint into the sandbox -COPY nemoclaw/ /opt/nemoclaw/ -COPY nemoclaw-blueprint/ /opt/nemoclaw-blueprint/ - -# Build the TS plugin inside the container -WORKDIR /opt/nemoclaw -RUN npm install && rm -rf dist && npx tsc - -# Patch OpenClaw's nvidia provider to replace Meta Llama 3.3 70B with -# Nemotron 3 Super 120B. OpenClaw's embedded agent has a hardcoded model -# catalog that doesn't include Nemotron 3 Super yet. This replaces the -# one Meta/Llama model with NVIDIA's latest until OpenClaw ships an update. -RUN find /usr/local/lib/node_modules/openclaw/dist -name "*.js" \ - -exec grep -l "meta/llama-3.3-70b-instruct" {} \; \ - | xargs -I{} sed -i \ - -e 's|meta/llama-3.3-70b-instruct|nvidia/nemotron-3-super-120b-a12b|g' \ - -e 's|Meta Llama 3.3 70B Instruct|Nemotron 3 Super 120B|g' \ - {} -# Nemotron 3 Super supports reasoning; flip the flag and bump maxTokens -RUN find /usr/local/lib/node_modules/openclaw/dist -name "*.js" \ - -exec grep -l "nemotron-3-super-120b-a12b" {} \; \ - | xargs -I{} python3 -c "import re,sys;\ -s=open('{}').read();\ -s=re.sub(r'(id:\\s*\"nvidia/nemotron-3-super-120b-a12b.*?reasoning:\\s*)false',r'\\1true',s,flags=re.DOTALL);\ -s=re.sub(r'(id:\\s*\"nvidia/nemotron-3-super-120b-a12b.*?maxTokens:\\s*)4096',r'\\g<1>8192',s,flags=re.DOTALL);\ -open('{}','w').write(s)" - -# Set up blueprint for local resolution -RUN mkdir -p /sandbox/.nemoclaw/blueprints/0.1.0 \ - && cp -r /opt/nemoclaw-blueprint/* /sandbox/.nemoclaw/blueprints/0.1.0/ +# The production image locks .openclaw (root:root, 755, openclaw.json 444). +# Tests need writable fixtures and a sandbox-owned config for rollback tests, +# so relax permissions here. +USER root +RUN chown -R sandbox:sandbox /sandbox/.openclaw /sandbox/.openclaw-data \ + && chmod 700 /sandbox/.openclaw \ + && chmod 600 /sandbox/.openclaw/openclaw.json -WORKDIR /sandbox USER sandbox # Simulate a host OpenClaw installation for migration testing. # Keep the legacy ~/.openclaw fixture for the blueprint snapshot tests. +# Directories like hooks/ are symlinks to .openclaw-data/; mkdir -p follows +# them and creates subdirectories in the writable data dir. RUN mkdir -p /sandbox/.openclaw/workspace /sandbox/.openclaw/extensions /sandbox/.openclaw/skills /sandbox/.openclaw/hooks/demo-hook \ - && chmod 700 /sandbox/.openclaw \ && printf '%s\n' \ '{' \ ' "wizard": {' \ From 6f68ca3e4cbea29bac6a1ccea01df50edeb06c26 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 24 Mar 2026 15:41:49 -0700 Subject: [PATCH 21/25] chore: apply shfmt formatting --- scripts/nemoclaw-start.sh | 13 +++++++------ test/e2e-gateway-isolation.sh | 16 +++++++++++----- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index e651bd8dd0f..ae7d3ef3d58 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -24,12 +24,12 @@ export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" # own name as $1 would cause infinite recursion via the NEMOCLAW_CMD exec path. # Only strip from $1 — later args with this name are legitimate user arguments. case "${1:-}" in - nemoclaw-start|/usr/local/bin/nemoclaw-start) shift ;; +nemoclaw-start | /usr/local/bin/nemoclaw-start) shift ;; esac NEMOCLAW_CMD=("$@") CHAT_UI_URL="${CHAT_UI_URL:-http://127.0.0.1:18789}" PUBLIC_PORT=18789 -OPENCLAW="$(command -v openclaw)" # Resolve once, use absolute path everywhere +OPENCLAW="$(command -v openclaw)" # Resolve once, use absolute path everywhere # ── Config integrity check ────────────────────────────────────── # The config hash was pinned at build time. If it doesn't match, @@ -74,7 +74,8 @@ PYAUTH print_dashboard_urls() { local token chat_ui_base local_url remote_url - token="$(python3 - <<'PYTOKEN' + token="$( + python3 - <<'PYTOKEN' import json import os path = '/sandbox/.openclaw/openclaw.json' @@ -85,7 +86,7 @@ except Exception: else: print(cfg.get('gateway', {}).get('auth', {}).get('token', '')) PYTOKEN -)" + )" chat_ui_base="${CHAT_UI_URL%/}" local_url="http://127.0.0.1:${PUBLIC_PORT}/" @@ -102,7 +103,7 @@ PYTOKEN start_auto_pair() { # Run auto-pair as sandbox user (it talks to the gateway via CLI) # SECURITY: Pass resolved openclaw path to prevent PATH hijacking - OPENCLAW_BIN="$OPENCLAW" nohup gosu sandbox python3 - <<'PYAUTOPAIR' >> /tmp/auto-pair.log 2>&1 & + OPENCLAW_BIN="$OPENCLAW" nohup gosu sandbox python3 - <<'PYAUTOPAIR' >>/tmp/auto-pair.log 2>&1 & import json import os import subprocess @@ -204,7 +205,7 @@ done # SECURITY: The sandbox user cannot kill this process because it runs # under a different UID. The fake-HOME attack no longer works because # the agent cannot restart the gateway with a tampered config. -nohup gosu gateway "$OPENCLAW" gateway run > /tmp/gateway.log 2>&1 & +nohup gosu gateway "$OPENCLAW" gateway run >/tmp/gateway.log 2>&1 & GATEWAY_PID=$! echo "[gateway] openclaw gateway launched as 'gateway' user (pid $GATEWAY_PID)" diff --git a/test/e2e-gateway-isolation.sh b/test/e2e-gateway-isolation.sh index e7635d967bf..cee6763bbeb 100755 --- a/test/e2e-gateway-isolation.sh +++ b/test/e2e-gateway-isolation.sh @@ -19,8 +19,14 @@ GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' -pass() { echo -e "${GREEN}PASS${NC}: $1"; PASSED=$((PASSED + 1)); } -fail() { echo -e "${RED}FAIL${NC}: $1"; FAILED=$((FAILED + 1)); } +pass() { + echo -e "${GREEN}PASS${NC}: $1" + PASSED=$((PASSED + 1)) +} +fail() { + echo -e "${RED}FAIL${NC}: $1" + FAILED=$((FAILED + 1)) +} info() { echo -e "${YELLOW}TEST${NC}: $1"; } PASSED=0 @@ -29,12 +35,12 @@ FAILED=0 # ── Build the image ────────────────────────────────────────────── # Skip build if image already exists (e.g., loaded from CI artifact) -if docker image inspect "$IMAGE" > /dev/null 2>&1; then +if docker image inspect "$IMAGE" >/dev/null 2>&1; then info "Using pre-built image: $IMAGE" else info "Building sandbox image..." BUILD_LOG="$(mktemp)" - if ! docker build -t "$IMAGE" "$REPO_DIR" > "$BUILD_LOG" 2>&1; then + if ! docker build -t "$IMAGE" "$REPO_DIR" >"$BUILD_LOG" 2>&1; then tail -40 "$BUILD_LOG" fail "Docker build failed (last 40 lines above)" exit 1 @@ -180,7 +186,7 @@ echo -e "${GREEN}========================================${NC}" # Cleanup — only remove images we built ourselves if [ -z "${NEMOCLAW_TEST_IMAGE:-}" ]; then - docker rmi "$IMAGE" > /dev/null 2>&1 || true + docker rmi "$IMAGE" >/dev/null 2>&1 || true fi [ "$FAILED" -eq 0 ] || exit 1 From 4aedfcbb1612637fc2907e1c67c63d180bfd7109 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 24 Mar 2026 15:43:27 -0700 Subject: [PATCH 22/25] fix: upgrade gosu from 1.14 (Debian bookworm) to 1.19 (2025-09) Install gosu directly from GitHub release with SHA-256 checksum verification instead of Debian bookworm's 5-year-old apt package. --- Dockerfile | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index d5dbb55c30b..2e7d2d2df09 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,6 @@ FROM node:22-slim@sha256:4f77a690f2f8946ab16fe1e791a3ac0667ae1c3575c3e4d0d4589e9 ENV DEBIAN_FRONTEND=noninteractive -# gosu is for privilege separation (gateway vs sandbox user) RUN apt-get update && apt-get install -y --no-install-recommends \ python3=3.11.2-1+b1 \ python3-pip=23.0.1+dfsg-1 \ @@ -21,9 +20,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ git=1:2.39.5-0+deb12u3 \ ca-certificates=20230311+deb12u1 \ iproute2=6.1.0-3 \ - gosu=1.14-1+b10 \ && rm -rf /var/lib/apt/lists/* +# gosu for privilege separation (gateway vs sandbox user). +# Install from GitHub release with checksum verification instead of +# Debian bookworm's ancient 1.14 (2020). Pinned to 1.19 (2025-09). +RUN curl -fsSL -o /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/1.19/gosu-amd64" \ + && echo "52c8749d0142edd234e9d6bd5237dff2d81e71f43537e2f4f66f75dd4b243dd0 /usr/local/bin/gosu" | sha256sum -c - \ + && chmod +x /usr/local/bin/gosu \ + && gosu --version + # Create sandbox user (matches OpenShell convention) and gateway user. # The gateway runs as 'gateway' so the 'sandbox' user (agent) cannot # kill it or restart it with a tampered HOME/config. From 99f8785f35a268eb60981b92a9a4cfb785e17053 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 24 Mar 2026 15:52:05 -0700 Subject: [PATCH 23/25] fix: add hadolint DL4006 ignore for gosu checksum pipe --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 2e7d2d2df09..5f8bdd9f889 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # gosu for privilege separation (gateway vs sandbox user). # Install from GitHub release with checksum verification instead of # Debian bookworm's ancient 1.14 (2020). Pinned to 1.19 (2025-09). +# hadolint ignore=DL4006 RUN curl -fsSL -o /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/1.19/gosu-amd64" \ && echo "52c8749d0142edd234e9d6bd5237dff2d81e71f43537e2f4f66f75dd4b243dd0 /usr/local/bin/gosu" | sha256sum -c - \ && chmod +x /usr/local/bin/gosu \ From 153ddc0816016f766d43f3d8f80dc7ec88c0ffbc Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 24 Mar 2026 15:53:17 -0700 Subject: [PATCH 24/25] fix: apply shfmt indent to case pattern in nemoclaw-start Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/nemoclaw-start.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 8bad9005083..c41e33ddbc3 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -24,7 +24,7 @@ export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" # own name as $1 would cause infinite recursion via the NEMOCLAW_CMD exec path. # Only strip from $1 — later args with this name are legitimate user arguments. case "${1:-}" in -nemoclaw-start | /usr/local/bin/nemoclaw-start) shift ;; + nemoclaw-start | /usr/local/bin/nemoclaw-start) shift ;; esac NEMOCLAW_CMD=("$@") CHAT_UI_URL="${CHAT_UI_URL:-http://127.0.0.1:18789}" From a75e55ed0db8e27e8fd0fdc89a4ef24e15658a65 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 24 Mar 2026 16:08:19 -0700 Subject: [PATCH 25/25] chore: re-sign with verified SSH key