diff --git a/.agents/skills/nemoclaw-user-deploy-remote/references/sandbox-hardening.md b/.agents/skills/nemoclaw-user-deploy-remote/references/sandbox-hardening.md index dc0ad59fa1a..e71fd84e804 100644 --- a/.agents/skills/nemoclaw-user-deploy-remote/references/sandbox-hardening.md +++ b/.agents/skills/nemoclaw-user-deploy-remote/references/sandbox-hardening.md @@ -60,8 +60,49 @@ services: > capability dropping in your `docker run` flags, Compose file, or Kubernetes > `securityContext`. +## Read-Only Home Directory + +The sandbox Landlock policy restricts `/sandbox` (the agent's home directory) to read-only access. +Only explicitly declared directories are writable: + +| Path | Access | Purpose | +|------|--------|---------| +| `/sandbox` | read-only | Home directory — agents cannot create arbitrary files | +| `/sandbox/.openclaw` | read-only | Immutable gateway config (auth tokens, CORS) | +| `/sandbox/.openclaw-data` | read-write | Agent state, workspace, plugins (via symlinks) | +| `/sandbox/.nemoclaw` | read-write | Plugin state and config; blueprints within are DAC-protected (root-owned) | +| `/tmp` | read-write | Temporary files and logs | + +This prevents agents from: + +- Writing scripts and executing them later +- Modifying their own runtime environment +- Creating hidden files that persist across invocations +- Using writable space for data staging before exfiltration + +The image build pre-creates shell init files `.bashrc` and `.profile`. +These files source runtime proxy configuration from `/tmp/nemoclaw-proxy-env.sh`. + +### Landlock Kernel Requirements + +Landlock LSM requires Linux kernel 5.13 or later with `CONFIG_SECURITY_LANDLOCK=y`. +The NemoClaw sandbox policy uses `compatibility: best_effort`, which means Landlock enforcement is silently skipped on kernels that do not support it. + +On such kernels, protection falls back to DAC (file ownership and permissions) only. +Files owned by the sandbox user (e.g., `.bashrc`, `.profile`) would be writable by the agent despite the Landlock read-only policy. + +Operators should verify Landlock availability: + +```console +$ ls /sys/kernel/security/landlock +``` + +For production deployments, kernel 5.13+ with Landlock enabled is strongly recommended. +The `test/e2e/e2e-cloud-experimental/checks/04-landlock-readonly.sh` script validates enforcement at runtime. + ## References +- [#804](https://github.com/NVIDIA/NemoClaw/issues/804): Read-only home directory - [#807](https://github.com/NVIDIA/NemoClaw/issues/807): gcc in sandbox image - [#808](https://github.com/NVIDIA/NemoClaw/issues/808): netcat in sandbox image - [#809](https://github.com/NVIDIA/NemoClaw/issues/809): No process limit diff --git a/Dockerfile b/Dockerfile index 134e41e3997..d0d8b0926b2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,7 +41,9 @@ COPY nemoclaw-blueprint/ /opt/nemoclaw-blueprint/ WORKDIR /opt/nemoclaw RUN npm ci --omit=dev -# Set up blueprint for local resolution +# Set up blueprint for local resolution. +# Blueprints are immutable at runtime; DAC protection (root ownership) is applied +# later since /sandbox/.nemoclaw is Landlock read_write for plugin state (#804). RUN mkdir -p /sandbox/.nemoclaw/blueprints/0.1.0 \ && cp -r /opt/nemoclaw-blueprint/* /sandbox/.nemoclaw/blueprints/0.1.0/ @@ -176,6 +178,28 @@ RUN openclaw doctor --fix > /dev/null 2>&1 || true \ # The writable state lives in .openclaw-data, reached via the symlinks. # hadolint ignore=DL3002 USER root + +# Ensure .openclaw-data subdirs and symlinks exist for logs, credentials, and +# sandbox. These are defined in Dockerfile.base but the GHCR base image may +# not have been rebuilt yet. Idempotent — harmless once the base catches up. +# Ref: https://github.com/NVIDIA/NemoClaw/issues/804 +RUN mkdir -p /sandbox/.openclaw-data/logs \ + /sandbox/.openclaw-data/credentials \ + /sandbox/.openclaw-data/sandbox \ + && chown sandbox:sandbox /sandbox/.openclaw-data/logs \ + /sandbox/.openclaw-data/credentials \ + /sandbox/.openclaw-data/sandbox \ + && for dir in logs credentials sandbox; do \ + if [ -L "/sandbox/.openclaw/$dir" ]; then true; \ + elif [ -e "/sandbox/.openclaw/$dir" ]; then \ + cp -a "/sandbox/.openclaw/$dir/." "/sandbox/.openclaw-data/$dir/" 2>/dev/null || true; \ + rm -rf "/sandbox/.openclaw/$dir"; \ + ln -s "/sandbox/.openclaw-data/$dir" "/sandbox/.openclaw/$dir"; \ + else \ + ln -s "/sandbox/.openclaw-data/$dir" "/sandbox/.openclaw/$dir"; \ + fi; \ + done + RUN chown root:root /sandbox/.openclaw \ && rm -rf /root/.npm /sandbox/.npm \ && find /sandbox/.openclaw -mindepth 1 -maxdepth 1 -exec chown -h root:root {} + \ @@ -189,6 +213,26 @@ RUN sha256sum /sandbox/.openclaw/openclaw.json > /sandbox/.openclaw/.config-hash && chmod 444 /sandbox/.openclaw/.config-hash \ && chown root:root /sandbox/.openclaw/.config-hash +# DAC-protect .nemoclaw directory: /sandbox/.nemoclaw is Landlock read_write +# (for plugin state/config), but the parent and blueprints are immutable at +# runtime. Root ownership on the parent prevents the agent from renaming or +# replacing the root-owned blueprints directory. Only state/, migration/, +# snapshots/, and config.json are sandbox-owned for runtime writes. +# Sticky bit (1755): OpenShell's prepare_filesystem() chowns read_write paths +# to run_as_user at sandbox start, flipping this dir to sandbox:sandbox. +# The sticky bit survives the chown and prevents the sandbox user from +# renaming or deleting root-owned entries (blueprints/). +# Ref: https://github.com/NVIDIA/NemoClaw/issues/804 +# Ref: https://github.com/NVIDIA/NemoClaw/issues/1607 +RUN chown root:root /sandbox/.nemoclaw \ + && chmod 1755 /sandbox/.nemoclaw \ + && chown -R root:root /sandbox/.nemoclaw/blueprints \ + && chmod -R 755 /sandbox/.nemoclaw/blueprints \ + && mkdir -p /sandbox/.nemoclaw/state /sandbox/.nemoclaw/migration /sandbox/.nemoclaw/snapshots /sandbox/.nemoclaw/staging \ + && chown sandbox:sandbox /sandbox/.nemoclaw/state /sandbox/.nemoclaw/migration /sandbox/.nemoclaw/snapshots /sandbox/.nemoclaw/staging \ + && touch /sandbox/.nemoclaw/config.json \ + && chown sandbox:sandbox /sandbox/.nemoclaw/config.json + # 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"] diff --git a/Dockerfile.base b/Dockerfile.base index 104f5e6dd23..ce8ba4efd5e 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -102,8 +102,10 @@ RUN mkdir -p /sandbox/.openclaw-data/agents/main/agent \ /sandbox/.openclaw-data/canvas \ /sandbox/.openclaw-data/cron \ /sandbox/.openclaw-data/memory \ - /sandbox/.openclaw-data/telegram \ + /sandbox/.openclaw-data/logs \ /sandbox/.openclaw-data/credentials \ + /sandbox/.openclaw-data/sandbox \ + /sandbox/.openclaw-data/telegram \ && mkdir -p /sandbox/.openclaw \ && ln -s /sandbox/.openclaw-data/agents /sandbox/.openclaw/agents \ && ln -s /sandbox/.openclaw-data/extensions /sandbox/.openclaw/extensions \ @@ -115,14 +117,32 @@ RUN mkdir -p /sandbox/.openclaw-data/agents/main/agent \ && ln -s /sandbox/.openclaw-data/canvas /sandbox/.openclaw/canvas \ && ln -s /sandbox/.openclaw-data/cron /sandbox/.openclaw/cron \ && ln -s /sandbox/.openclaw-data/memory /sandbox/.openclaw/memory \ + && ln -s /sandbox/.openclaw-data/logs /sandbox/.openclaw/logs \ + && ln -s /sandbox/.openclaw-data/credentials /sandbox/.openclaw/credentials \ + && ln -s /sandbox/.openclaw-data/sandbox /sandbox/.openclaw/sandbox \ && touch /sandbox/.openclaw-data/update-check.json \ && ln -s /sandbox/.openclaw-data/update-check.json /sandbox/.openclaw/update-check.json \ && touch /sandbox/.openclaw-data/exec-approvals.json \ && ln -s /sandbox/.openclaw-data/exec-approvals.json /sandbox/.openclaw/exec-approvals.json \ && ln -s /sandbox/.openclaw-data/telegram /sandbox/.openclaw/telegram \ - && ln -s /sandbox/.openclaw-data/credentials /sandbox/.openclaw/credentials \ && chown -R sandbox:sandbox /sandbox/.openclaw /sandbox/.openclaw-data +# Pre-create shell init files for the sandbox user. +# The /sandbox home directory is Landlock read-only at runtime (#804), so these +# files must exist at build time. Runtime proxy config is written by the +# entrypoint to /tmp/nemoclaw-proxy-env.sh (root-owned, sticky-bit protected) +# and sourced from here on every interactive session. +# hadolint ignore=SC2028 +RUN printf '%s\n' \ + '# Source runtime proxy config (Landlock read-only home, #804)' \ + '[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh' \ + > /sandbox/.bashrc \ + && printf '%s\n' \ + '# Source runtime proxy config (Landlock read-only home, #804)' \ + '[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh' \ + > /sandbox/.profile \ + && chown sandbox:sandbox /sandbox/.bashrc /sandbox/.profile + # Install OpenClaw CLI + PyYAML for inline Python scripts in e2e tests. # When bumping the openclaw version, rebuild this base image. RUN npm install -g openclaw@2026.3.11 \ diff --git a/docs/deployment/sandbox-hardening.md b/docs/deployment/sandbox-hardening.md index 3608f6163e4..d4175fd17d7 100644 --- a/docs/deployment/sandbox-hardening.md +++ b/docs/deployment/sandbox-hardening.md @@ -82,8 +82,49 @@ services: > capability dropping in your `docker run` flags, Compose file, or Kubernetes > `securityContext`. +## Read-Only Home Directory + +The sandbox Landlock policy restricts `/sandbox` (the agent's home directory) to read-only access. +Only explicitly declared directories are writable: + +| Path | Access | Purpose | +|------|--------|---------| +| `/sandbox` | read-only | Home directory — agents cannot create arbitrary files | +| `/sandbox/.openclaw` | read-only | Immutable gateway config (auth tokens, CORS) | +| `/sandbox/.openclaw-data` | read-write | Agent state, workspace, plugins (via symlinks) | +| `/sandbox/.nemoclaw` | read-write | Plugin state and config; blueprints within are DAC-protected (root-owned) | +| `/tmp` | read-write | Temporary files and logs | + +This prevents agents from: + +- Writing scripts and executing them later +- Modifying their own runtime environment +- Creating hidden files that persist across invocations +- Using writable space for data staging before exfiltration + +The image build pre-creates shell init files `.bashrc` and `.profile`. +These files source runtime proxy configuration from `/tmp/nemoclaw-proxy-env.sh`. + +### Landlock Kernel Requirements + +Landlock LSM requires Linux kernel 5.13 or later with `CONFIG_SECURITY_LANDLOCK=y`. +The NemoClaw sandbox policy uses `compatibility: best_effort`, which means Landlock enforcement is silently skipped on kernels that do not support it. + +On such kernels, protection falls back to DAC (file ownership and permissions) only. +Files owned by the sandbox user (e.g., `.bashrc`, `.profile`) would be writable by the agent despite the Landlock read-only policy. + +Operators should verify Landlock availability: + +```console +$ ls /sys/kernel/security/landlock +``` + +For production deployments, kernel 5.13+ with Landlock enabled is strongly recommended. +The `test/e2e/e2e-cloud-experimental/checks/04-landlock-readonly.sh` script validates enforcement at runtime. + ## References +- [#804](https://github.com/NVIDIA/NemoClaw/issues/804): Read-only home directory - [#807](https://github.com/NVIDIA/NemoClaw/issues/807): gcc in sandbox image - [#808](https://github.com/NVIDIA/NemoClaw/issues/808): netcat in sandbox image - [#809](https://github.com/NVIDIA/NemoClaw/issues/809): No process limit diff --git a/nemoclaw-blueprint/policies/openclaw-sandbox.yaml b/nemoclaw-blueprint/policies/openclaw-sandbox.yaml index d54ffd70360..09db269a40c 100644 --- a/nemoclaw-blueprint/policies/openclaw-sandbox.yaml +++ b/nemoclaw-blueprint/policies/openclaw-sandbox.yaml @@ -16,7 +16,12 @@ version: 1 filesystem_policy: - include_workdir: true + # SECURITY: must be false. When true, OpenShell adds WORKDIR (/sandbox) to + # read_write automatically, which overrides our read_only entry below because + # Landlock grants the union of all matching rules. All needed writable paths + # are declared explicitly in read_write. + # Ref: https://github.com/NVIDIA/NemoClaw/issues/804 + include_workdir: false read_only: - /usr - /lib @@ -25,16 +30,23 @@ filesystem_policy: - /app - /etc - /var/log + - /sandbox # Home directory — read-only to prevent agents + # from creating arbitrary files or modifying + # their own runtime environment. Writable state + # is restricted to /sandbox/.openclaw-data. + # Ref: https://github.com/NVIDIA/NemoClaw/issues/804 - /sandbox/.openclaw # Immutable gateway config — prevents agent # from tampering with auth tokens or CORS. # Writable state (agents, plugins) lives in # /sandbox/.openclaw-data via symlinks. # Ref: https://github.com/NVIDIA/NemoClaw/issues/514 read_write: - - /sandbox - /tmp - /dev/null - /sandbox/.openclaw-data # Writable agent/plugin state (symlinked from .openclaw) + - /sandbox/.nemoclaw # Plugin state and config (state.ts, config.ts). + # Blueprints are root-owned; sticky bit (1755) + # on parent prevents rename/delete (#1607). landlock: compatibility: best_effort diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index d367760d010..9155cdcc36c 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -32,6 +32,53 @@ fi # into commands executed by the entrypoint or auto-pair watcher. export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +# Redirect tool caches and state to /tmp so they don't fail on the read-only +# /sandbox home directory (#804). Without these, tools would try to create +# dotfiles (~/.npm, ~/.cache, ~/.bash_history, ~/.gitconfig, ~/.local, ~/.claude) +# in the Landlock read-only home and fail. +# +# IMPORTANT: This array is the single source of truth for tool-cache redirects. +# The same entries are emitted into /tmp/nemoclaw-proxy-env.sh (see below) so +# that `openshell sandbox connect` sessions also pick up the redirects. +_TOOL_REDIRECTS=( + 'npm_config_cache=/tmp/.npm-cache' + 'XDG_CACHE_HOME=/tmp/.cache' + 'XDG_CONFIG_HOME=/tmp/.config' + 'XDG_DATA_HOME=/tmp/.local/share' + 'XDG_STATE_HOME=/tmp/.local/state' + 'XDG_RUNTIME_DIR=/tmp/.runtime' + 'NODE_REPL_HISTORY=/tmp/.node_repl_history' + 'HISTFILE=/tmp/.bash_history' + 'GIT_CONFIG_GLOBAL=/tmp/.gitconfig' + 'GNUPGHOME=/tmp/.gnupg' + 'PYTHONUSERBASE=/tmp/.local' + 'PYTHONHISTFILE=/tmp/.python_history' + 'CLAUDE_CONFIG_DIR=/tmp/.claude' + 'npm_config_prefix=/tmp/npm-global' +) +for _redir in "${_TOOL_REDIRECTS[@]}"; do + export "${_redir?}" +done + +# Pre-create redirected directories to prevent ownership conflicts. +# In root mode: the gateway starts first (as gateway user) and inherits these +# env vars — if it creates a dir first, it would be gateway:gateway 755 and +# the sandbox user couldn't write subdirs later. Creating them as root with +# explicit sandbox ownership ensures the sandbox user always has write access. +# In non-root mode: we're already the sandbox user, so mkdir -p is sufficient — +# directories are owned by us automatically. Using install -o would fail with +# EPERM because only root can chown. Ref: #804 +if [ "$(id -u)" -eq 0 ]; then + install -d -o sandbox -g sandbox -m 755 \ + /tmp/.npm-cache /tmp/.cache /tmp/.config /tmp/.local/share \ + /tmp/.local/state /tmp/.runtime /tmp/.gnupg /tmp/.claude \ + /tmp/npm-global +else + mkdir -p /tmp/.npm-cache /tmp/.cache /tmp/.config /tmp/.local/share \ + /tmp/.local/state /tmp/.runtime /tmp/.gnupg /tmp/.claude \ + /tmp/npm-global +fi + # ── Drop unnecessary Linux capabilities ────────────────────────── # CIS Docker Benchmark 5.3: containers should not run with default caps. # OpenShell manages the container runtime so we cannot pass --cap-drop=ALL @@ -96,6 +143,7 @@ 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 +_SANDBOX_HOME="/sandbox" # Home dir for the sandbox user (useradd -d /sandbox in Dockerfile.base) # ── Config integrity check ────────────────────────────────────── # The config hash was pinned at build time. If it doesn't match, @@ -439,55 +487,40 @@ export no_proxy="$_NO_PROXY_VAL" # OpenShell re-injects narrow NO_PROXY/no_proxy=127.0.0.1,localhost,::1 every # time a user connects via `openshell sandbox connect`. The connect path spawns # `/bin/bash -i` (interactive, non-login), which sources ~/.bashrc — NOT -# ~/.profile or /etc/profile.d/*. Write the full proxy config to ~/.bashrc so -# interactive sessions see the correct values. +# ~/.profile or /etc/profile.d/*. +# +# The /sandbox home directory is Landlock read-only (#804), so we write the proxy +# config to /tmp/nemoclaw-proxy-env.sh. The pre-built .bashrc and .profile +# source this file automatically. +# +# SECURITY: /tmp has the sticky bit, so when running as root the sandbox user +# cannot delete or replace this root-owned file. In non-root mode privilege +# separation is already disabled, so this is an accepted limitation. # # Both uppercase and lowercase variants are required: Node.js undici prefers # lowercase (no_proxy) over uppercase (NO_PROXY) when both are set. # curl/wget use uppercase. gRPC C-core uses lowercase. -# -# Also write to ~/.profile for login-shell paths (e.g. `sandbox create -- cmd` -# which spawns `bash -lc`). -# -# Idempotency: begin/end markers delimit the block so it can be replaced -# on restart if NEMOCLAW_PROXY_HOST/PORT change, without duplicating. -_PROXY_MARKER_BEGIN="# nemoclaw-proxy-config begin" -_PROXY_MARKER_END="# nemoclaw-proxy-config end" -_PROXY_SNIPPET="${_PROXY_MARKER_BEGIN} -export HTTP_PROXY=\"$_PROXY_URL\" -export HTTPS_PROXY=\"$_PROXY_URL\" -export NO_PROXY=\"$_NO_PROXY_VAL\" -export http_proxy=\"$_PROXY_URL\" -export https_proxy=\"$_PROXY_URL\" -export no_proxy=\"$_NO_PROXY_VAL\" -${_PROXY_MARKER_END}" - -if [ "$(id -u)" -eq 0 ]; then - _SANDBOX_HOME=$(getent passwd sandbox 2>/dev/null | cut -d: -f6) - _SANDBOX_HOME="${_SANDBOX_HOME:-/sandbox}" -else - _SANDBOX_HOME="${HOME:-/sandbox}" -fi - -_write_proxy_snippet() { - local target="$1" - if [ -f "$target" ] && grep -qF "$_PROXY_MARKER_BEGIN" "$target" 2>/dev/null; then - local tmp - tmp="$(mktemp)" - awk -v b="$_PROXY_MARKER_BEGIN" -v e="$_PROXY_MARKER_END" \ - '$0==b{s=1;next} $0==e{s=0;next} !s' "$target" >"$tmp" - printf '%s\n' "$_PROXY_SNIPPET" >>"$tmp" - cat "$tmp" >"$target" - rm -f "$tmp" - return 0 - fi - printf '\n%s\n' "$_PROXY_SNIPPET" >>"$target" -} - -if [ -w "$_SANDBOX_HOME" ]; then - _write_proxy_snippet "${_SANDBOX_HOME}/.bashrc" - _write_proxy_snippet "${_SANDBOX_HOME}/.profile" -fi +_PROXY_ENV_FILE="/tmp/nemoclaw-proxy-env.sh" +# Remove any pre-existing file/symlink to prevent symlink-following attacks, +# then write a fresh file. +rm -f "$_PROXY_ENV_FILE" 2>/dev/null || true +{ + cat <"$_PROXY_ENV_FILE" +chmod 644 "$_PROXY_ENV_FILE" # Forward SIGTERM/SIGINT to child processes for graceful shutdown. # This script is PID 1 — without a trap, signals interrupt wait and @@ -508,7 +541,12 @@ cleanup() { # ── Main ───────────────────────────────────────────────────────── echo 'Setting up NemoClaw...' >&2 -[ -f .env ] && chmod 600 .env +# Best-effort: .env may not exist, and /sandbox is Landlock read-only (#804). +if [ -f .env ]; then + if ! chmod 600 .env 2>/dev/null; then + echo "[SECURITY WARNING] Could not restrict .env permissions — file may be world-readable (read-only filesystem)" >&2 + fi +fi # ── Non-root fallback ────────────────────────────────────────── # OpenShell runs containers with --security-opt=no-new-privileges, which diff --git a/test/e2e-gateway-isolation.sh b/test/e2e-gateway-isolation.sh index a1eb6726b1f..d9c58299d2b 100755 --- a/test/e2e-gateway-isolation.sh +++ b/test/e2e-gateway-isolation.sh @@ -147,7 +147,7 @@ fi info "9. All .openclaw symlinks point to .openclaw-data" FAILED_LINKS="" -for link in agents extensions workspace skills hooks identity devices canvas cron; do +for link in agents extensions workspace skills hooks identity devices canvas cron memory logs credentials sandbox telegram; do OUT=$(run_as_root "readlink -f /sandbox/.openclaw/$link") if [ "$OUT" != "/sandbox/.openclaw-data/$link" ]; then FAILED_LINKS="$FAILED_LINKS $link->$OUT" @@ -228,6 +228,146 @@ else fi fi +# ── Test 13: Sandbox user cannot write to .nemoclaw parent ──────── +# Note: /sandbox itself is sandbox-owned (DAC allows writes). Landlock makes it +# read-only in production — tested in checks/04-landlock-readonly.sh instead. + +info "13. Sandbox user cannot create files in /sandbox/.nemoclaw" +OUT=$(run_as_sandbox "touch /sandbox/.nemoclaw/testfile 2>&1 || echo BLOCKED") +if echo "$OUT" | grep -q "BLOCKED\|Permission denied"; then + pass "sandbox cannot create files in .nemoclaw parent (root-owned)" +else + fail "sandbox CAN create files in .nemoclaw parent: $OUT" +fi + +# ── Test 14: Sandbox user cannot modify blueprints ──────────────── + +info "14. Sandbox user cannot modify blueprints" +OUT=$(run_as_sandbox "touch /sandbox/.nemoclaw/blueprints/testfile 2>&1 || echo BLOCKED") +if echo "$OUT" | grep -q "BLOCKED\|Permission denied"; then + pass "sandbox cannot write to blueprints (root-owned)" +else + fail "sandbox CAN write to blueprints: $OUT" +fi + +# ── Test 15: Sandbox user CAN write to .nemoclaw/state ──────────── + +info "15. Sandbox user can write to .nemoclaw/state" +OUT=$(run_as_sandbox "touch /sandbox/.nemoclaw/state/testfile && echo OK || echo FAILED") +if echo "$OUT" | grep -q "OK"; then + pass "sandbox can write to .nemoclaw/state (sandbox-owned)" +else + fail "sandbox cannot write to .nemoclaw/state: $OUT" +fi + +# ── Test 16: Sandbox user CAN write to .openclaw-data ───────────── + +info "16. Sandbox user can write to .openclaw-data" +OUT=$(run_as_sandbox "touch /sandbox/.openclaw-data/testfile && echo OK || echo FAILED") +if echo "$OUT" | grep -q "OK"; then + pass "sandbox can write to .openclaw-data (sandbox-owned)" +else + fail "sandbox cannot write to .openclaw-data: $OUT" +fi + +# ── Test 17: Sandbox user cannot rename/delete blueprints dir ───── + +info "17. Sandbox user cannot rename blueprints directory" +OUT=$(run_as_sandbox "mv /sandbox/.nemoclaw/blueprints /sandbox/.nemoclaw/blueprints-evil 2>&1 || echo BLOCKED") +if echo "$OUT" | grep -q "BLOCKED\|Permission denied"; then + pass "sandbox cannot rename blueprints (parent is root-owned)" +else + fail "sandbox CAN rename blueprints: $OUT" +fi + +# ── Test 18: Sandbox user CAN write to .nemoclaw/migration ──────── + +info "18. Sandbox user can write to .nemoclaw/migration" +OUT=$(run_as_sandbox "touch /sandbox/.nemoclaw/migration/testfile && echo OK || echo FAILED") +if echo "$OUT" | grep -q "OK"; then + pass "sandbox can write to .nemoclaw/migration (sandbox-owned)" +else + fail "sandbox cannot write to .nemoclaw/migration: $OUT" +fi + +# ── Test 19: Sandbox user CAN write to .nemoclaw/snapshots ──────── + +info "19. Sandbox user can write to .nemoclaw/snapshots" +OUT=$(run_as_sandbox "touch /sandbox/.nemoclaw/snapshots/testfile && echo OK || echo FAILED") +if echo "$OUT" | grep -q "OK"; then + pass "sandbox can write to .nemoclaw/snapshots (sandbox-owned)" +else + fail "sandbox cannot write to .nemoclaw/snapshots: $OUT" +fi + +# ── Test 20: Sandbox user CAN write to .nemoclaw/staging ────────── + +info "20. Sandbox user can write to .nemoclaw/staging" +OUT=$(run_as_sandbox "touch /sandbox/.nemoclaw/staging/testfile && echo OK || echo FAILED") +if echo "$OUT" | grep -q "OK"; then + pass "sandbox can write to .nemoclaw/staging (sandbox-owned)" +else + fail "sandbox cannot write to .nemoclaw/staging: $OUT" +fi + +# ── Test 21: Sandbox user CAN write to .nemoclaw/config.json ────── + +info "21. Sandbox user can write to .nemoclaw/config.json" +OUT=$(run_as_sandbox "echo '{}' > /sandbox/.nemoclaw/config.json && echo OK || echo FAILED") +if echo "$OUT" | grep -q "OK"; then + pass "sandbox can write to .nemoclaw/config.json (sandbox-owned)" +else + fail "sandbox cannot write to .nemoclaw/config.json: $OUT" +fi + +# ── Test 22: Sandbox user cannot create new files in .openclaw ──── + +info "22. Sandbox user cannot create new files in .openclaw directory" +OUT=$(run_as_sandbox "touch /sandbox/.openclaw/newfile 2>&1 || echo BLOCKED") +if echo "$OUT" | grep -q "BLOCKED\|Permission denied"; then + pass "sandbox cannot create new files in .openclaw (root-owned dir)" +else + fail "sandbox CAN create new files in .openclaw: $OUT" +fi + +# ── Test 23: .bashrc sources proxy-env from /tmp ────────────────── +# Requires base image with pre-built .bashrc (#804). Skip gracefully +# if the file doesn't exist yet (base image not rebuilt). + +info "23. .bashrc sources proxy config from /tmp" +OUT=$(run_as_sandbox "cat /sandbox/.bashrc 2>/dev/null || echo MISSING") +if echo "$OUT" | grep -q "/tmp/nemoclaw-proxy-env.sh"; then + pass ".bashrc sources /tmp/nemoclaw-proxy-env.sh" +elif echo "$OUT" | grep -q "MISSING\|No such file"; then + info "SKIP: .bashrc not present (base image needs rebuild for #804)" +else + fail ".bashrc does not source from expected path: $OUT" +fi + +# ── Test 24: .profile sources proxy-env from /tmp ───────────────── + +info "24. .profile sources proxy config from /tmp" +OUT=$(run_as_sandbox "cat /sandbox/.profile 2>/dev/null || echo MISSING") +if echo "$OUT" | grep -q "/tmp/nemoclaw-proxy-env.sh"; then + pass ".profile sources /tmp/nemoclaw-proxy-env.sh" +elif echo "$OUT" | grep -q "MISSING\|No such file"; then + info "SKIP: .profile not present (base image needs rebuild for #804)" +else + fail ".profile does not source from expected path: $OUT" +fi + +# ── Test 25: Non-root mode executes without gosu ────────────────── +# The entrypoint detects uid != 0, skips gosu, and execs the command directly. +# Verifies the non-root fallback path works after read-only /sandbox (#804). + +info "25. Non-root mode executes command without gosu" +OUT=$(docker run --rm --user 1000:1000 "$IMAGE" echo "NON_ROOT_EXEC_OK" 2>&1 || true) +if echo "$OUT" | grep -q "NON_ROOT_EXEC_OK"; then + pass "non-root mode executed command directly (no gosu)" +else + fail "non-root command execution failed: $OUT" +fi + # ── Summary ────────────────────────────────────────────────────── echo "" diff --git a/test/e2e/e2e-cloud-experimental/checks/04-landlock-readonly.sh b/test/e2e/e2e-cloud-experimental/checks/04-landlock-readonly.sh new file mode 100755 index 00000000000..e0ae3ebf52e --- /dev/null +++ b/test/e2e/e2e-cloud-experimental/checks/04-landlock-readonly.sh @@ -0,0 +1,125 @@ +#!/bin/bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Case: Landlock read-only enforcement on /sandbox (#804). +# +# These checks run INSIDE a real OpenShell sandbox where Landlock is active. +# They verify that the kernel enforces read-only on paths that DAC alone +# cannot protect (e.g., sandbox-owned .bashrc/.profile in a root-owned dir). +# +# The Docker-only e2e tests (test/e2e-gateway-isolation.sh) cover DAC +# enforcement but cannot exercise Landlock. This script closes that gap. +# +# Prerequisites: +# - openshell on PATH, sandbox exists and is Ready +# - SANDBOX_NAME set (default: e2e-cloud-experimental) + +set -euo pipefail + +SANDBOX_NAME="${SANDBOX_NAME:-${NEMOCLAW_SANDBOX_NAME:-e2e-cloud-experimental}}" + +die() { + printf '%s\n' "04-landlock-readonly: FAIL: $*" >&2 + exit 1 +} +ok() { printf '%s\n' "04-landlock-readonly: OK ($*)"; } +info() { printf '%s\n' "04-landlock-readonly: $*"; } + +PASSED=0 +FAILED=0 + +pass() { + ok "$1" + PASSED=$((PASSED + 1)) +} +fail_test() { + printf '%s\n' "04-landlock-readonly: FAIL: $1" >&2 + FAILED=$((FAILED + 1)) +} + +# Helper: run a command inside the sandbox via openshell +sandbox_exec() { + openshell sandbox exec "$SANDBOX_NAME" -- bash -c "$1" 2>&1 +} + +info "Running Landlock read-only checks in sandbox: $SANDBOX_NAME" + +# ── 1: Cannot create files in /sandbox (Landlock read_only) ─────── +info "1. Cannot create files in /sandbox" +OUT=$(sandbox_exec "touch /sandbox/landlock-test 2>&1 || echo BLOCKED") +if echo "$OUT" | grep -qi "BLOCKED\|Permission denied\|Read-only\|EACCES"; then + pass "sandbox home is Landlock read-only" +else + fail_test "/sandbox is writable under Landlock: $OUT" +fi + +# ── 2: Cannot modify .bashrc (sandbox-owned but Landlock read_only) ─ +info "2. Cannot modify .bashrc (Landlock protects sandbox-owned files)" +OUT=$(sandbox_exec "echo 'malicious' >> /sandbox/.bashrc 2>&1 || echo BLOCKED") +if echo "$OUT" | grep -qi "BLOCKED\|Permission denied\|Read-only\|EACCES"; then + pass ".bashrc is Landlock read-only despite sandbox ownership" +else + fail_test ".bashrc is writable under Landlock: $OUT" +fi + +# ── 3: Cannot modify .profile (sandbox-owned but Landlock read_only) ─ +info "3. Cannot modify .profile (Landlock protects sandbox-owned files)" +OUT=$(sandbox_exec "echo 'malicious' >> /sandbox/.profile 2>&1 || echo BLOCKED") +if echo "$OUT" | grep -qi "BLOCKED\|Permission denied\|Read-only\|EACCES"; then + pass ".profile is Landlock read-only despite sandbox ownership" +else + fail_test ".profile is writable under Landlock: $OUT" +fi + +# ── 4: Cannot write to .openclaw/openclaw.json ──────────────────── +info "4. Cannot write to openclaw.json (Landlock + DAC)" +OUT=$(sandbox_exec "echo '{}' > /sandbox/.openclaw/openclaw.json 2>&1 || echo BLOCKED") +if echo "$OUT" | grep -qi "BLOCKED\|Permission denied\|Read-only\|EACCES"; then + pass "openclaw.json is read-only under Landlock" +else + fail_test "openclaw.json is writable under Landlock: $OUT" +fi + +# ── 5: Cannot create new files in .openclaw dir ────────────────── +info "5. Cannot create files in .openclaw (Landlock read_only)" +OUT=$(sandbox_exec "touch /sandbox/.openclaw/evil 2>&1 || echo BLOCKED") +if echo "$OUT" | grep -qi "BLOCKED\|Permission denied\|Read-only\|EACCES"; then + pass ".openclaw dir is Landlock read-only" +else + fail_test ".openclaw dir is writable under Landlock: $OUT" +fi + +# ── 6: CAN write to .openclaw-data (Landlock read_write) ───────── +info "6. Can write to .openclaw-data (Landlock read_write)" +OUT=$(sandbox_exec "touch /sandbox/.openclaw-data/landlock-test && echo OK || echo FAILED") +if echo "$OUT" | grep -q "OK"; then + pass ".openclaw-data is writable under Landlock" +else + fail_test ".openclaw-data is NOT writable under Landlock: $OUT" +fi + +# ── 7: CAN write to .nemoclaw/state (Landlock read_write via parent) ─ +info "7. Can write to .nemoclaw/state (Landlock read_write)" +OUT=$(sandbox_exec "touch /sandbox/.nemoclaw/state/landlock-test && echo OK || echo FAILED") +if echo "$OUT" | grep -q "OK"; then + pass ".nemoclaw/state is writable under Landlock" +else + fail_test ".nemoclaw/state is NOT writable under Landlock: $OUT" +fi + +# ── 8: CAN write to /tmp (Landlock read_write) ─────────────────── +info "8. Can write to /tmp (Landlock read_write)" +OUT=$(sandbox_exec "touch /tmp/landlock-test && echo OK || echo FAILED") +if echo "$OUT" | grep -q "OK"; then + pass "/tmp is writable under Landlock" +else + fail_test "/tmp is NOT writable under Landlock: $OUT" +fi + +# ── Cleanup test artifacts ──────────────────────────────────────── +sandbox_exec "rm -f /sandbox/.openclaw-data/landlock-test /sandbox/.nemoclaw/state/landlock-test /tmp/landlock-test 2>/dev/null" || true + +# ── Summary ─────────────────────────────────────────────────────── +printf '%s\n' "04-landlock-readonly: $PASSED passed, $FAILED failed" +[ "$FAILED" -eq 0 ] || exit 1 diff --git a/test/nemoclaw-start.test.js b/test/nemoclaw-start.test.js index 2973fc68599..11cfff4adc0 100644 --- a/test/nemoclaw-start.test.js +++ b/test/nemoclaw-start.test.js @@ -68,6 +68,40 @@ describe("nemoclaw-start non-root fallback", () => { }); }); +describe("nemoclaw-start _SANDBOX_HOME variable (#1609)", () => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + + it("defines _SANDBOX_HOME before first use", () => { + const defPos = src.indexOf('_SANDBOX_HOME="/sandbox"'); + expect(defPos).toBeGreaterThan(-1); + + // All usages must come after the definition + const usages = [...src.matchAll(/\$\{?_SANDBOX_HOME\}?/g)]; + expect(usages.length).toBeGreaterThanOrEqual(3); + for (const m of usages) { + // Skip the definition line itself + if (m.index === defPos) continue; + expect(m.index).toBeGreaterThan(defPos); + } + }); + + it("uses _SANDBOX_HOME for rc file paths in export_gateway_token", () => { + const exportFn = src.match(/export_gateway_token\(\) \{([\s\S]*?)^\}/m); + expect(exportFn).toBeTruthy(); + expect(exportFn[1]).toContain("${_SANDBOX_HOME}/.bashrc"); + expect(exportFn[1]).toContain("${_SANDBOX_HOME}/.profile"); + }); + + it("uses _SANDBOX_HOME for rc file paths in install_configure_guard", () => { + const guardFn = src.match( + /install_configure_guard\(\) \{([\s\S]*?)^validate_openclaw_symlinks/m, + ); + expect(guardFn).toBeTruthy(); + expect(guardFn[1]).toContain("${_SANDBOX_HOME}/.bashrc"); + expect(guardFn[1]).toContain("${_SANDBOX_HOME}/.profile"); + }); +}); + describe("nemoclaw-start gateway token export (#1114)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); diff --git a/test/service-env.test.js b/test/service-env.test.js index 6e811dc9ea4..2b0cb60211f 100644 --- a/test/service-env.test.js +++ b/test/service-env.test.js @@ -3,7 +3,7 @@ import { describe, it, expect } from "vitest"; import { execSync, execFileSync } from "node:child_process"; -import { mkdtempSync, writeFileSync, unlinkSync, readFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync, unlinkSync, readFileSync, lstatSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { resolveOpenshell } from "../bin/lib/resolve-openshell"; @@ -161,7 +161,53 @@ describe("service environment", () => { }); }); + describe("XDG and tool cache redirects (issue #804)", () => { + it("entrypoint exports redirect all XDG and tool dirs to /tmp", () => { + const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); + const src = readFileSync(scriptPath, "utf-8"); + // Redirects are defined in the _TOOL_REDIRECTS array (single source of truth) + expect(src).toContain("_TOOL_REDIRECTS=("); + // XDG base dirs + expect(src).toContain("XDG_CACHE_HOME=/tmp/.cache"); + expect(src).toContain("XDG_CONFIG_HOME=/tmp/.config"); + expect(src).toContain("XDG_DATA_HOME=/tmp/.local/share"); + expect(src).toContain("XDG_STATE_HOME=/tmp/.local/state"); + expect(src).toContain("XDG_RUNTIME_DIR=/tmp/.runtime"); + // Tool-specific redirects + expect(src).toContain("GNUPGHOME=/tmp/.gnupg"); + expect(src).toContain("PYTHONHISTFILE=/tmp/.python_history"); + expect(src).toContain("npm_config_prefix=/tmp/npm-global"); + }); + + it("entrypoint pre-creates redirected dirs as sandbox user", () => { + const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); + const src = readFileSync(scriptPath, "utf-8"); + // install -d creates dirs with correct ownership before the gateway + // starts, preventing gateway:gateway ownership that blocks sandbox writes + expect(src).toContain("install -d -o sandbox -g sandbox"); + expect(src).toContain("/tmp/.config"); + expect(src).toContain("/tmp/.cache"); + expect(src).toContain("/tmp/.local/share"); + expect(src).toContain("/tmp/.gnupg"); + expect(src).toContain("/tmp/npm-global"); + }); + }); + describe("proxy environment variables (issue #626)", () => { + function extractToolRedirects() { + const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); + const block = execFileSync("sed", ["-n", "/^_TOOL_REDIRECTS=/,/^done$/p", scriptPath], { + encoding: "utf-8", + }); + if (!block.trim()) { + throw new Error( + "Failed to extract _TOOL_REDIRECTS from scripts/nemoclaw-start.sh — " + + "the array may have been moved or renamed", + ); + } + return block.trimEnd(); + } + function extractProxyVars(env = {}) { const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); const proxyBlock = execFileSync( @@ -252,38 +298,55 @@ describe("service environment", () => { expect(noProxy).toContain("10.200.0.1"); }); - it("entrypoint persistence writes proxy snippet to ~/.bashrc and ~/.profile", () => { - const fakeHome = join(tmpdir(), `nemoclaw-home-test-${process.pid}`); - execFileSync("mkdir", ["-p", fakeHome]); - const tmpFile = join(tmpdir(), `nemoclaw-bashrc-write-test-${process.pid}.sh`); + it("entrypoint writes proxy-env.sh to writable data dir", () => { + const fakeDataDir = join(tmpdir(), `nemoclaw-data-test-${process.pid}`); + execFileSync("mkdir", ["-p", fakeDataDir]); + const tmpFile = join(tmpdir(), `nemoclaw-proxyenv-write-test-${process.pid}.sh`); try { const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); const persistBlock = execFileSync( "sed", - ["-n", "/^_PROXY_URL=/,/^# ── Main/{ /^# ── Main/d; p; }", scriptPath], + ["-n", "/^_PROXY_URL=/,/^chmod 644/p", scriptPath], { encoding: "utf-8" }, ); + if (!persistBlock.trim()) { + throw new Error( + "Failed to extract proxy persistence block from scripts/nemoclaw-start.sh — " + + "the _PROXY_URL..chmod block may have been moved or renamed", + ); + } + const toolRedirects = extractToolRedirects(); const wrapper = [ "#!/usr/bin/env bash", + toolRedirects, 'PROXY_HOST="10.200.0.1"', 'PROXY_PORT="3128"', - persistBlock.trimEnd(), + // Override the hardcoded path to use our temp dir + persistBlock + .trimEnd() + .replaceAll("/tmp/nemoclaw-proxy-env.sh", `${fakeDataDir}/proxy-env.sh`), ].join("\n"); writeFileSync(tmpFile, wrapper, { mode: 0o700 }); - execFileSync("bash", [tmpFile], { - encoding: "utf-8", - env: { ...process.env, HOME: fakeHome }, - }); - - const bashrc = readFileSync(join(fakeHome, ".bashrc"), "utf-8"); - expect(bashrc).toContain("export HTTP_PROXY="); - expect(bashrc).toContain("export HTTPS_PROXY="); - expect(bashrc).toContain("export NO_PROXY="); - expect(bashrc).not.toContain("inference.local"); - expect(bashrc).toContain("10.200.0.1"); - - const profile = readFileSync(join(fakeHome, ".profile"), "utf-8"); - expect(profile).not.toContain("inference.local"); + execFileSync("bash", [tmpFile], { encoding: "utf-8" }); + + const envFile = readFileSync(join(fakeDataDir, "proxy-env.sh"), "utf-8"); + expect(envFile).toContain('export HTTP_PROXY="http://10.200.0.1:3128"'); + expect(envFile).toContain('export HTTPS_PROXY="http://10.200.0.1:3128"'); + expect(envFile).toContain("export NO_PROXY="); + expect(envFile).not.toContain("inference.local"); + expect(envFile).toContain("10.200.0.1"); + // Tool cache redirects should be present (#804) + expect(envFile).toContain("npm_config_cache"); + expect(envFile).toContain("HISTFILE"); + expect(envFile).toContain("GIT_CONFIG_GLOBAL"); + // XDG redirects prevent tools from writing to read-only /sandbox (#804) + expect(envFile).toContain("XDG_CONFIG_HOME=/tmp/.config"); + expect(envFile).toContain("XDG_DATA_HOME=/tmp/.local/share"); + expect(envFile).toContain("XDG_STATE_HOME=/tmp/.local/state"); + expect(envFile).toContain("XDG_RUNTIME_DIR=/tmp/.runtime"); + expect(envFile).toContain("GNUPGHOME=/tmp/.gnupg"); + expect(envFile).toContain("PYTHONHISTFILE=/tmp/.python_history"); + expect(envFile).toContain("npm_config_prefix=/tmp/npm-global"); } finally { try { unlinkSync(tmpFile); @@ -291,44 +354,51 @@ describe("service environment", () => { /* ignore */ } try { - execFileSync("rm", ["-rf", fakeHome]); + execFileSync("rm", ["-rf", fakeDataDir]); } catch { /* ignore */ } } }); - it("entrypoint persistence is idempotent across repeated invocations", () => { - const fakeHome = join(tmpdir(), `nemoclaw-idempotent-test-${process.pid}`); - execFileSync("mkdir", ["-p", fakeHome]); + it("entrypoint overwrites proxy-env.sh cleanly on repeated invocations", () => { + const fakeDataDir = join(tmpdir(), `nemoclaw-idempotent-test-${process.pid}`); + execFileSync("mkdir", ["-p", fakeDataDir]); const tmpFile = join(tmpdir(), `nemoclaw-idempotent-write-test-${process.pid}.sh`); try { const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); const persistBlock = execFileSync( "sed", - ["-n", "/^_PROXY_URL=/,/^# ── Main/{ /^# ── Main/d; p; }", scriptPath], + ["-n", "/^_PROXY_URL=/,/^chmod 644/p", scriptPath], { encoding: "utf-8" }, ); + if (!persistBlock.trim()) { + throw new Error( + "Failed to extract proxy persistence block from scripts/nemoclaw-start.sh — " + + "the _PROXY_URL..chmod block may have been moved or renamed", + ); + } + const toolRedirects = extractToolRedirects(); const wrapper = [ "#!/usr/bin/env bash", + toolRedirects, 'PROXY_HOST="10.200.0.1"', 'PROXY_PORT="3128"', - persistBlock.trimEnd(), + persistBlock + .trimEnd() + .replaceAll("/tmp/nemoclaw-proxy-env.sh", `${fakeDataDir}/proxy-env.sh`), ].join("\n"); writeFileSync(tmpFile, wrapper, { mode: 0o700 }); - const runOpts = { - encoding: /** @type {const} */ ("utf-8"), - env: { ...process.env, HOME: fakeHome }, - }; + const runOpts = { encoding: /** @type {const} */ ("utf-8") }; execFileSync("bash", [tmpFile], runOpts); execFileSync("bash", [tmpFile], runOpts); execFileSync("bash", [tmpFile], runOpts); - const bashrc = readFileSync(join(fakeHome, ".bashrc"), "utf-8"); - const beginCount = (bashrc.match(/nemoclaw-proxy-config begin/g) || []).length; - const endCount = (bashrc.match(/nemoclaw-proxy-config end/g) || []).length; - expect(beginCount).toBe(1); - expect(endCount).toBe(1); + const envFile = readFileSync(join(fakeDataDir, "proxy-env.sh"), "utf-8"); + // cat > overwrites the file each time, so there should be exactly one + // HTTP_PROXY line — no duplication from repeated runs. + const httpProxyCount = (envFile.match(/export HTTP_PROXY=/g) || []).length; + expect(httpProxyCount).toBe(1); } finally { try { unlinkSync(tmpFile); @@ -336,50 +406,100 @@ describe("service environment", () => { /* ignore */ } try { - execFileSync("rm", ["-rf", fakeHome]); + execFileSync("rm", ["-rf", fakeDataDir]); } catch { /* ignore */ } } }); - it("entrypoint persistence replaces stale proxy values on restart", () => { - const fakeHome = join(tmpdir(), `nemoclaw-replace-test-${process.pid}`); - execFileSync("mkdir", ["-p", fakeHome]); + it("entrypoint replaces stale proxy values on restart", () => { + const fakeDataDir = join(tmpdir(), `nemoclaw-replace-test-${process.pid}`); + execFileSync("mkdir", ["-p", fakeDataDir]); const tmpFile = join(tmpdir(), `nemoclaw-replace-write-test-${process.pid}.sh`); try { const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); const persistBlock = execFileSync( "sed", - ["-n", "/^_PROXY_URL=/,/^# ── Main/{ /^# ── Main/d; p; }", scriptPath], + ["-n", "/^_PROXY_URL=/,/^chmod 644/p", scriptPath], { encoding: "utf-8" }, ); + if (!persistBlock.trim()) { + throw new Error( + "Failed to extract proxy persistence block from scripts/nemoclaw-start.sh — " + + "the _PROXY_URL..chmod block may have been moved or renamed", + ); + } + const toolRedirects = extractToolRedirects(); const makeWrapper = (host) => [ "#!/usr/bin/env bash", + toolRedirects, `PROXY_HOST="${host}"`, 'PROXY_PORT="3128"', - persistBlock.trimEnd(), + persistBlock + .trimEnd() + .replaceAll("/tmp/nemoclaw-proxy-env.sh", `${fakeDataDir}/proxy-env.sh`), ].join("\n"); writeFileSync(tmpFile, makeWrapper("10.200.0.1"), { mode: 0o700 }); - execFileSync("bash", [tmpFile], { - encoding: "utf-8", - env: { ...process.env, HOME: fakeHome }, - }); - let bashrc = readFileSync(join(fakeHome, ".bashrc"), "utf-8"); - expect(bashrc).toContain("10.200.0.1"); + execFileSync("bash", [tmpFile], { encoding: "utf-8" }); + let envFile = readFileSync(join(fakeDataDir, "proxy-env.sh"), "utf-8"); + expect(envFile).toContain("10.200.0.1"); writeFileSync(tmpFile, makeWrapper("192.168.1.99"), { mode: 0o700 }); - execFileSync("bash", [tmpFile], { - encoding: "utf-8", - env: { ...process.env, HOME: fakeHome }, - }); - bashrc = readFileSync(join(fakeHome, ".bashrc"), "utf-8"); - expect(bashrc).toContain("192.168.1.99"); - expect(bashrc).not.toContain("10.200.0.1"); - const beginCount = (bashrc.match(/nemoclaw-proxy-config begin/g) || []).length; - expect(beginCount).toBe(1); + execFileSync("bash", [tmpFile], { encoding: "utf-8" }); + envFile = readFileSync(join(fakeDataDir, "proxy-env.sh"), "utf-8"); + expect(envFile).toContain("192.168.1.99"); + expect(envFile).not.toContain("10.200.0.1"); + } finally { + try { + unlinkSync(tmpFile); + } catch { + /* ignore */ + } + try { + execFileSync("rm", ["-rf", fakeDataDir]); + } catch { + /* ignore */ + } + } + }); + + it("rm -f prevents symlink-following attack on proxy-env.sh", () => { + const fakeDataDir = join(tmpdir(), `nemoclaw-symlink-test-${process.pid}`); + execFileSync("mkdir", ["-p", fakeDataDir]); + const tmpFile = join(tmpdir(), `nemoclaw-symlink-write-test-${process.pid}.sh`); + try { + const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); + const persistBlock = execFileSync( + "sed", + ["-n", "/^_PROXY_URL=/,/^chmod 644/p", scriptPath], + { encoding: "utf-8" }, + ); + if (!persistBlock.trim()) { + throw new Error( + "Failed to extract proxy persistence block from scripts/nemoclaw-start.sh — " + + "the _PROXY_URL..chmod block may have been moved or renamed", + ); + } + const sensitiveFile = join(fakeDataDir, "sensitive"); + writeFileSync(sensitiveFile, "SECRET_DATA"); + const proxyEnvPath = join(fakeDataDir, "proxy-env.sh"); + execFileSync("ln", ["-sf", sensitiveFile, proxyEnvPath]); + const toolRedirects = extractToolRedirects(); + const wrapper = [ + "#!/usr/bin/env bash", + toolRedirects, + 'PROXY_HOST="10.200.0.1"', + 'PROXY_PORT="3128"', + persistBlock.trimEnd().replaceAll("/tmp/nemoclaw-proxy-env.sh", proxyEnvPath), + ].join("\n"); + writeFileSync(tmpFile, wrapper, { mode: 0o700 }); + execFileSync("bash", [tmpFile], { encoding: "utf-8" }); + const stat = lstatSync(proxyEnvPath); + expect(stat.isSymbolicLink()).toBe(false); + expect(readFileSync(sensitiveFile, "utf-8")).toBe("SECRET_DATA"); } finally { try { unlinkSync(tmpFile); @@ -387,28 +507,26 @@ describe("service environment", () => { /* ignore */ } try { - execFileSync("rm", ["-rf", fakeHome]); + execFileSync("rm", ["-rf", fakeDataDir]); } catch { /* ignore */ } } }); - it("[simulation] sourcing ~/.bashrc overrides narrow NO_PROXY and no_proxy", () => { - const fakeHome = join(tmpdir(), `nemoclaw-bashi-test-${process.pid}`); - execFileSync("mkdir", ["-p", fakeHome]); + it("[simulation] sourcing proxy-env.sh overrides narrow NO_PROXY and no_proxy", () => { + const fakeDataDir = join(tmpdir(), `nemoclaw-bashi-test-${process.pid}`); + execFileSync("mkdir", ["-p", fakeDataDir]); try { - const bashrcContent = [ - "# nemoclaw-proxy-config begin", + const envContent = [ 'export HTTP_PROXY="http://10.200.0.1:3128"', 'export HTTPS_PROXY="http://10.200.0.1:3128"', 'export NO_PROXY="localhost,127.0.0.1,::1,10.200.0.1"', 'export http_proxy="http://10.200.0.1:3128"', 'export https_proxy="http://10.200.0.1:3128"', 'export no_proxy="localhost,127.0.0.1,::1,10.200.0.1"', - "# nemoclaw-proxy-config end", ].join("\n"); - writeFileSync(join(fakeHome, ".bashrc"), bashrcContent); + writeFileSync(join(fakeDataDir, "proxy-env.sh"), envContent); const out = execFileSync( "bash", @@ -416,10 +534,9 @@ describe("service environment", () => { "--norc", "-c", [ - `export HOME=${JSON.stringify(fakeHome)}`, 'export NO_PROXY="127.0.0.1,localhost,::1"', 'export no_proxy="127.0.0.1,localhost,::1"', - `source ${JSON.stringify(join(fakeHome, ".bashrc"))}`, + `source ${JSON.stringify(join(fakeDataDir, "proxy-env.sh"))}`, 'echo "NO_PROXY=$NO_PROXY"', 'echo "no_proxy=$no_proxy"', ].join("; "), @@ -431,7 +548,7 @@ describe("service environment", () => { expect(out).toContain("no_proxy=localhost,127.0.0.1,::1,10.200.0.1"); } finally { try { - execFileSync("rm", ["-rf", fakeHome]); + execFileSync("rm", ["-rf", fakeDataDir]); } catch { /* ignore */ }