diff --git a/.github/workflows/nightly-e2e.yaml b/.github/workflows/nightly-e2e.yaml index ebc5738828d..e6c6e662b23 100644 --- a/.github/workflows/nightly-e2e.yaml +++ b/.github/workflows/nightly-e2e.yaml @@ -681,7 +681,7 @@ jobs: timeout_minutes: 60 artifact_name: "openclaw-onboard-security-posture-install-log" artifact_path: "/tmp/nemoclaw-e2e-install.log" - env_json: '{"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE":"1","NEMOCLAW_E2E_EXPECT_NON_ROOT_HOST":"1","NEMOCLAW_E2E_SECURITY_POSTURE":"1","NEMOCLAW_NON_INTERACTIVE":"1","NEMOCLAW_RECREATE_SANDBOX":"1","NEMOCLAW_SANDBOX_NAME":"e2e-openclaw-security-posture"}' + env_json: '{"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE":"1","NEMOCLAW_ALLOW_RESIDUAL_CAPS":"1","NEMOCLAW_E2E_EXPECT_NON_ROOT_HOST":"1","NEMOCLAW_E2E_SECURITY_POSTURE":"1","NEMOCLAW_NON_INTERACTIVE":"1","NEMOCLAW_RECREATE_SANDBOX":"1","NEMOCLAW_SANDBOX_NAME":"e2e-openclaw-security-posture"}' nvidia_api_key: true github_token: true secrets: @@ -699,7 +699,7 @@ jobs: timeout_minutes: 60 artifact_name: "hermes-onboard-security-posture-install-log" artifact_path: "/tmp/nemoclaw-e2e-hermes-install.log" - env_json: '{"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE":"1","NEMOCLAW_AGENT":"hermes","NEMOCLAW_E2E_EXPECT_NON_ROOT_HOST":"1","NEMOCLAW_E2E_SECURITY_POSTURE":"1","NEMOCLAW_NON_INTERACTIVE":"1","NEMOCLAW_RECREATE_SANDBOX":"1","NEMOCLAW_SANDBOX_NAME":"e2e-hermes-security-posture"}' + env_json: '{"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE":"1","NEMOCLAW_AGENT":"hermes","NEMOCLAW_ALLOW_RESIDUAL_CAPS":"1","NEMOCLAW_E2E_EXPECT_NON_ROOT_HOST":"1","NEMOCLAW_E2E_SECURITY_POSTURE":"1","NEMOCLAW_NON_INTERACTIVE":"1","NEMOCLAW_RECREATE_SANDBOX":"1","NEMOCLAW_SANDBOX_NAME":"e2e-hermes-security-posture"}' nvidia_api_key: true github_token: true secrets: diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 909749d3eeb..f300e984f85 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1261,6 +1261,7 @@ Set them before running `nemoclaw onboard`. | `NEMOCLAW_OLLAMA_INSTALL_MODE` | `system`, `user`, or empty/unset | Pins the Linux Ollama install location; see the Linux Ollama install mode details below. | | `NEMOCLAW_PROXY_HOST` | hostname or IP | Overrides the sandbox-side outbound HTTP proxy host. Defaults to `10.200.0.1`. | | `NEMOCLAW_PROXY_PORT` | integer port | Overrides the sandbox-side outbound HTTP proxy port. Defaults to `3128`. | +| `NEMOCLAW_ALLOW_RESIDUAL_CAPS` | `1` to opt in; unset by default | Allows sandbox startup to continue on hosts that cannot drop dangerous bounding-set capabilities. Only set this when you accept the weaker security posture. | | `NEMOCLAW_OPENSHELL_BIN` | path | Overrides the `openshell` binary the CLI invokes. Defaults to `openshell` (resolved via `PATH`). | | `NEMOCLAW_SANDBOX` | sandbox name | Alternate spelling of `NEMOCLAW_SANDBOX_NAME`; used by `services` and `debug` lookups when neither a flag nor `NEMOCLAW_SANDBOX_NAME` is set. | | `NEMOCLAW_INSTALL_REF` | git ref | For internal installer commands: the git ref to install from. Overridden by the `--install-ref` flag. | diff --git a/scripts/lib/sandbox-init.sh b/scripts/lib/sandbox-init.sh index 54edab75337..d54f945a6b8 100755 --- a/scripts/lib/sandbox-init.sh +++ b/scripts/lib/sandbox-init.sh @@ -235,10 +235,15 @@ drop_capabilities() { --drop=cap_sys_admin,cap_sys_ptrace,cap_net_raw,cap_dac_override,cap_sys_chroot,cap_fsetid,cap_setfcap,cap_mknod,cap_audit_write,cap_net_bind_service \ -- -c "exec $entrypoint \"\$@\"" -- "$@" else - report_residual_capabilities + # report_residual_capabilities intentionally returns non-zero when + # dangerous caps remain. Handle that status explicitly so `set -e` + # cannot skip the refusal banner or the opt-in escape hatch. + report_residual_capabilities || true + enforce_residual_capability_policy "CAP_SETPCAP unavailable" fi elif [ "${NEMOCLAW_CAPS_DROPPED:-}" != "1" ]; then echo "[SECURITY WARNING] capsh not available — running with default capabilities" >&2 + enforce_residual_capability_policy "capsh not available" fi } @@ -246,6 +251,8 @@ drop_capabilities() { # residual dangerous bounding-set caps surface in logs instead of being # silently inherited from the container runtime. Called from the # CAP_SETPCAP-missing fallback path of drop_capabilities() (issue #3280). +# Returns 0 when no dangerous caps remain (or status unreadable), non-zero +# when dangerous caps were detected so callers can refuse to start. report_residual_capabilities() { echo "[SECURITY] CAP_SETPCAP not available — cannot drop bounding-set caps via capsh" >&2 @@ -274,7 +281,44 @@ report_residual_capabilities() { done if [ -n "$present_caps" ]; then echo "[SECURITY] Dangerous caps remain in bounding set: ${present_caps}" >&2 + return 1 + fi + return 0 +} + +# Refuse to continue when the bounding-set drop did not actually happen, so +# the sandbox is never started with a security posture weaker than the +# script's stated intent (issue #4264). NEMOCLAW_ALLOW_RESIDUAL_CAPS=1 is +# the explicit opt-in for environments like Brev shadecloud where the host +# kernel doesn't grant CAP_SETPCAP — operators acknowledge they are running +# with a weaker posture. +enforce_residual_capability_policy() { + local reason="$1" + if [ "${NEMOCLAW_ALLOW_RESIDUAL_CAPS:-}" = "1" ]; then + echo "[SECURITY] NEMOCLAW_ALLOW_RESIDUAL_CAPS=1 set — continuing with weakened posture" >&2 + return 0 fi + cat >&2 <&1) || RC=$? +if [ "$RC" -ne 0 ] \ + && echo "$OUT" | grep -q "Refusing to start sandbox" \ + && echo "$OUT" | grep -q "NEMOCLAW_ALLOW_RESIDUAL_CAPS=1" \ + && ! echo "$OUT" | grep -q "SHOULD_NOT_REACH"; then + pass "non-root CAP_SETPCAP fallback fails closed with an explicit residual-cap banner" +else + fail "non-root residual-cap fallback did not fail closed as expected (rc=$RC): $OUT" +fi + +# ── Test 28: Non-root mode executes without gosu when opted in ──── # The entrypoint detects uid != 0, skips gosu, and execs the command directly. # Use the image's actual sandbox uid/gid here: the system-assigned sandbox uid # is not guaranteed to be 1000 on every runner, and the non-root fallback is -# designed to run as that sandbox user. +# designed to run as that sandbox user. This test opts in to the known weaker +# residual-cap posture because it is validating the non-root execution path, +# while Test 27 validates the default refusal. -info "27. Non-root mode executes command without gosu" -OUT=$(docker run --rm --user "${SB_UID}:${SB_GID}" "$IMAGE" bash -c 'printf "%s\n" "NON_ROOT_EXEC_OK"; sleep 0.2' 2>&1 || true) +info "28. Non-root mode executes command without gosu when residual caps are explicitly allowed" +OUT=$(docker run --rm --user "${SB_UID}:${SB_GID}" -e NEMOCLAW_ALLOW_RESIDUAL_CAPS=1 "$IMAGE" bash -c 'printf "%s\n" "NON_ROOT_EXEC_OK"; sleep 0.2' 2>&1 || true) if echo "$OUT" | grep -q "NON_ROOT_EXEC_OK"; then - pass "non-root mode executed command directly (no gosu)" + pass "non-root mode executed command directly (no gosu) with explicit residual-cap opt-in" else - fail "non-root command execution failed: $OUT" + fail "non-root command execution failed despite residual-cap opt-in: $OUT" fi -# ── Test 28: Model override patches openclaw.json at startup ───── +# ── Test 29: Model override patches openclaw.json at startup ───── # NEMOCLAW_MODEL_OVERRIDE should patch agents.defaults.model.primary, # model id, and model name in openclaw.json before Landlock locks it. # Ref: https://github.com/NVIDIA/NemoClaw/issues/759 -info "28. NEMOCLAW_MODEL_OVERRIDE patches openclaw.json" +info "29. NEMOCLAW_MODEL_OVERRIDE patches openclaw.json" OUT=$(docker run --rm -e NEMOCLAW_MODEL_OVERRIDE="test/override-model" \ --entrypoint "" "$IMAGE" bash -c ' # Source the entrypoint functions without running the full startup @@ -540,9 +559,9 @@ else fail "model override did not patch correctly: $OUT" fi -# ── Test 29: Model override is a no-op when env var is unset ───── +# ── Test 30: Model override is a no-op when env var is unset ───── -info "29. No override when NEMOCLAW_MODEL_OVERRIDE is unset" +info "30. No override when NEMOCLAW_MODEL_OVERRIDE is unset" OUT=$(docker run --rm --entrypoint "" "$IMAGE" bash -c ' source <(sed -n "/^apply_model_override/,/^}/p" /usr/local/bin/nemoclaw-start) ORIGINAL=$(python3 -c "import json; print(json.load(open(\"/sandbox/.openclaw/openclaw.json\"))[\"agents\"][\"defaults\"][\"model\"][\"primary\"])") diff --git a/test/e2e/test-full-e2e.sh b/test/e2e/test-full-e2e.sh index f8685b1181b..ecfc15862f6 100755 --- a/test/e2e/test-full-e2e.sh +++ b/test/e2e/test-full-e2e.sh @@ -138,6 +138,11 @@ if [ "${NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE:-}" != "1" ]; then exit 1 fi +if [ "${NEMOCLAW_E2E_SECURITY_POSTURE:-}" = "1" ] && [ "${NEMOCLAW_E2E_EXPECT_NON_ROOT_HOST:-}" = "1" ]; then + export NEMOCLAW_ALLOW_RESIDUAL_CAPS="${NEMOCLAW_ALLOW_RESIDUAL_CAPS:-1}" + info "Security-posture E2E is explicitly opting in to residual caps on non-root hosts; fail-closed behavior is covered by gateway-isolation." +fi + # ══════════════════════════════════════════════════════════════════ # Phase 2: Install nemoclaw (non-interactive mode) # ══════════════════════════════════════════════════════════════════ diff --git a/test/e2e/test-hermes-e2e.sh b/test/e2e/test-hermes-e2e.sh index 94029f182d0..6829d01e773 100755 --- a/test/e2e/test-hermes-e2e.sh +++ b/test/e2e/test-hermes-e2e.sh @@ -185,6 +185,11 @@ fi info "NEMOCLAW_AGENT=${NEMOCLAW_AGENT}" +if [ "${NEMOCLAW_E2E_SECURITY_POSTURE:-}" = "1" ] && [ "${NEMOCLAW_E2E_EXPECT_NON_ROOT_HOST:-}" = "1" ]; then + export NEMOCLAW_ALLOW_RESIDUAL_CAPS="${NEMOCLAW_ALLOW_RESIDUAL_CAPS:-1}" + info "Security-posture E2E is explicitly opting in to residual caps on non-root hosts; fail-closed behavior is covered by gateway-isolation." +fi + # ══════════════════════════════════════════════════════════════════ # Phase 2: Install nemoclaw (non-interactive mode, --agent hermes) # ══════════════════════════════════════════════════════════════════ diff --git a/test/sandbox-init.test.ts b/test/sandbox-init.test.ts index b9ee593cf0f..38bd02fc066 100644 --- a/test/sandbox-init.test.ts +++ b/test/sandbox-init.test.ts @@ -97,6 +97,33 @@ function pathExists(filePath: string): boolean { } } +function makeCapSetpcapUnavailableStubs(): string { + const stubDir = mkdtempSync(join(tmpdir(), "sandbox-init-capsh-")); + writeFileSync( + join(stubDir, "capsh"), + [ + "#!/bin/sh", + 'if [ "${1:-}" = "--has-p=cap_setpcap" ]; then', + " exit 1", + "fi", + "exit 99", + "", + ].join("\n"), + { mode: 0o755 }, + ); + writeFileSync( + join(stubDir, "awk"), + [ + "#!/bin/sh", + "# cap_sys_admin (bit 21) + cap_dac_override (bit 1)", + "printf '%s\\n' '0000000000200002'", + "", + ].join("\n"), + { mode: 0o755 }, + ); + return stubDir; +} + function backupTmpArtifacts(paths: string[], backupDir: string): Record { const backups: Record = {}; @@ -378,19 +405,102 @@ EOF }); describe("drop_capabilities", () => { - it("function is defined and callable", () => { - // We can't test actual capsh on macOS, but verify the function exists - // and handles the no-capsh case gracefully. Capture stderr via redirect. + it("refuses to start when capsh is unavailable (no NEMOCLAW_ALLOW_RESIDUAL_CAPS) (#4264)", () => { + const { stdout, stderr } = runWithLib( + ` + # Hide capsh from PATH so the function falls through; should exit 1 + drop_capabilities /usr/local/bin/fake-entrypoint + echo "SHOULD_NOT_REACH" + `, + { + env: { + PATH: "/usr/bin:/bin", + NEMOCLAW_CAPS_DROPPED: "", + NEMOCLAW_ALLOW_RESIDUAL_CAPS: "", + }, + expectFail: true, + }, + ); + const combined = `${stdout}\n${stderr}`; + expect(combined).toContain("capsh not available"); + expect(combined).toContain("Refusing to start sandbox"); + expect(combined).toContain("NEMOCLAW_ALLOW_RESIDUAL_CAPS=1"); + expect(combined).not.toContain("SHOULD_NOT_REACH"); + }); + + it("continues with explicit opt-in via NEMOCLAW_ALLOW_RESIDUAL_CAPS=1 (#4264)", () => { const { stdout } = runWithLib( ` - # Hide capsh from PATH so the function falls through drop_capabilities /usr/local/bin/fake-entrypoint 2>&1 - echo "FALLTHROUGH_OK" + echo "CONTINUED_OK" `, - { env: { PATH: "/usr/bin:/bin", NEMOCLAW_CAPS_DROPPED: "" } }, + { + env: { + PATH: "/usr/bin:/bin", + NEMOCLAW_CAPS_DROPPED: "", + NEMOCLAW_ALLOW_RESIDUAL_CAPS: "1", + }, + }, ); expect(stdout).toContain("capsh not available"); - expect(stdout).toContain("FALLTHROUGH_OK"); + expect(stdout).toContain("NEMOCLAW_ALLOW_RESIDUAL_CAPS=1 set"); + expect(stdout).toContain("CONTINUED_OK"); + }); + + it("prints the refusal banner when CAP_SETPCAP is unavailable and dangerous caps remain (#4264)", () => { + const stubDir = makeCapSetpcapUnavailableStubs(); + try { + const { stdout, stderr } = runWithLib( + ` + drop_capabilities /usr/local/bin/fake-entrypoint + echo "SHOULD_NOT_REACH" + `, + { + env: { + PATH: `${stubDir}:${process.env.PATH ?? ""}`, + NEMOCLAW_CAPS_DROPPED: "", + NEMOCLAW_ALLOW_RESIDUAL_CAPS: "", + }, + expectFail: true, + }, + ); + const combined = `${stdout}\n${stderr}`; + expect(combined).toContain("CAP_SETPCAP unavailable"); + expect(combined).toContain("Residual CapBnd=0000000000200002"); + expect(combined).toContain( + "Dangerous caps remain in bounding set: cap_sys_admin,cap_dac_override", + ); + expect(combined).toContain("Refusing to start sandbox"); + expect(combined).toContain("NEMOCLAW_ALLOW_RESIDUAL_CAPS=1"); + expect(combined).not.toContain("SHOULD_NOT_REACH"); + } finally { + rmSync(stubDir, { recursive: true, force: true }); + } + }); + + it("honors residual-cap opt-in after CAP_SETPCAP diagnostics under set -e (#4264)", () => { + const stubDir = makeCapSetpcapUnavailableStubs(); + try { + const { stdout } = runWithLib( + ` + drop_capabilities /usr/local/bin/fake-entrypoint 2>&1 + echo "CONTINUED_CAP_SETPCAP_OPT_IN" + `, + { + env: { + PATH: `${stubDir}:${process.env.PATH ?? ""}`, + NEMOCLAW_CAPS_DROPPED: "", + NEMOCLAW_ALLOW_RESIDUAL_CAPS: "1", + }, + }, + ); + expect(stdout).toContain("CAP_SETPCAP not available"); + expect(stdout).toContain("Dangerous caps remain in bounding set"); + expect(stdout).toContain("NEMOCLAW_ALLOW_RESIDUAL_CAPS=1 set"); + expect(stdout).toContain("CONTINUED_CAP_SETPCAP_OPT_IN"); + } finally { + rmSync(stubDir, { recursive: true, force: true }); + } }); it("skips when NEMOCLAW_CAPS_DROPPED=1", () => { @@ -405,6 +515,38 @@ EOF }); }); + describe("enforce_residual_capability_policy", () => { + it("returns 0 with an opt-in note when NEMOCLAW_ALLOW_RESIDUAL_CAPS=1 (#4264)", () => { + const { stdout } = runWithLib( + ` + enforce_residual_capability_policy "test reason" 2>&1 + echo "RETURNED_OK" + `, + { env: { NEMOCLAW_ALLOW_RESIDUAL_CAPS: "1" } }, + ); + expect(stdout).toContain("continuing with weakened posture"); + expect(stdout).toContain("RETURNED_OK"); + }); + + it("exits 1 with a banner when no opt-in (#4264)", () => { + const { stdout, stderr } = runWithLib( + ` + enforce_residual_capability_policy "test reason" + echo "SHOULD_NOT_REACH" + `, + { + env: { NEMOCLAW_ALLOW_RESIDUAL_CAPS: "" }, + expectFail: true, + }, + ); + const combined = `${stdout}\n${stderr}`; + expect(combined).toContain("Refusing to start sandbox"); + expect(combined).toContain("test reason"); + expect(combined).toContain("https://github.com/NVIDIA/NemoClaw/issues/4264"); + expect(stdout).not.toContain("SHOULD_NOT_REACH"); + }); + }); + describe("init_step_down_prefixes", () => { it("falls back to gosu when setpriv is unavailable", () => { // Source-time init runs before our test body, so re-run it with a