diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 0d22656a0d8..a94006a07db 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -84,19 +84,78 @@ jobs: - name: Check coverage ratchet run: bash scripts/check-coverage-ratchet.sh + build-sandbox-images: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Build production image + run: docker build -t nemoclaw-production . + + - name: Build sandbox test image (fixtures layered on production) + run: docker build -f test/Dockerfile.sandbox --build-arg BASE_IMAGE=nemoclaw-production -t nemoclaw-sandbox-test . + + - name: Save images to tarballs + run: | + docker save nemoclaw-sandbox-test | gzip > /tmp/sandbox-test-image.tar.gz + docker save nemoclaw-production | 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: isolation-image + path: /tmp/isolation-image.tar.gz + retention-days: 1 + test-e2e-sandbox: runs-on: ubuntu-latest timeout-minutes: 15 + needs: build-sandbox-images 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-test-image + path: /tmp + + - name: Load image + 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 + test-e2e-gateway-isolation: + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: build-sandbox-images + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download image artifact + uses: actions/download-artifact@v4 + with: + name: isolation-image + path: /tmp + + - name: Load image + run: gunzip -c /tmp/isolation-image.tar.gz | docker load + + - name: Run gateway isolation E2E tests + run: NEMOCLAW_TEST_IMAGE=nemoclaw-production bash test/e2e-gateway-isolation.sh + validate-profiles: runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/Dockerfile b/Dockerfile index f269ed5481a..5f8bdd9f889 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,8 +22,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ iproute2=6.1.0-3 \ && 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 \ +# 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 \ + && 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. +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 @@ -159,12 +171,26 @@ 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. +# hadolint ignore=DL3002 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 -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 + +# 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/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", ], ], }, diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index f98d4c6f2c8..c41e33ddbc3 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 @@ -11,9 +15,39 @@ 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" + +# 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. +# 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 + +# ── 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 — 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" + 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 @@ -44,7 +78,7 @@ print_dashboard_urls() { 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: @@ -67,11 +101,15 @@ 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) + # SECURITY: Pass resolved openclaw path to prevent PATH hijacking + OPENCLAW_BIN="$OPENCLAW" nohup gosu sandbox python3 - <<'PYAUTOPAIR' >>/tmp/auto-pair.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 @@ -81,7 +119,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 @@ -101,7 +139,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}') @@ -127,19 +165,56 @@ PYAUTOPAIR echo "[gateway] auto-pair watcher launched (pid $!)" } +# ── Main ───────────────────────────────────────────────────────── + echo 'Setting up NemoClaw...' [ -f .env ] && chmod 600 .env -# 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 $!)" +# SECURITY: Protect gateway log from sandbox user tampering +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 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 $entry 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 & +GATEWAY_PID=$! +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" 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": {' \ diff --git a/test/e2e-gateway-isolation.sh b/test/e2e-gateway-isolation.sh new file mode 100755 index 00000000000..cee6763bbeb --- /dev/null +++ b/test/e2e-gateway-isolation.sh @@ -0,0 +1,192 @@ +#!/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_TEST_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 ────────────────────────────────────────────── + +# 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() { + 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: Entrypoint PATH is locked to system dirs ───────────── + +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 as expected: $OUT" +fi + +# ── Test 8: openclaw resolves to expected absolute path ────────── + +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 "openclaw resolves to unexpected path: $OUT" +fi + +# ── 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 "symlink targets wrong:$FAILED_LINKS" +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 — 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