From c282694214ae2696f5b2df2000430627ff50d6f7 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Tue, 26 May 2026 13:52:22 -0700 Subject: [PATCH 1/3] fix(sandbox): refuse to start when bounding-set cap drop fails (#4264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `drop_capabilities` previously logged `[SECURITY] CAP_SETPCAP not available` (or `[SECURITY WARNING] capsh not available`) and silently continued. On hosts that don't grant CAP_SETPCAP — e.g. Brev shadecloud, where Hermes onboard was just confirmed to run with residual cap_dac_override, cap_sys_admin, cap_sys_ptrace, cap_net_raw, cap_net_bind_service in the bounding set — the sandbox boots with a security posture weaker than the script's stated intent and any future code path that relies on the drop is silently more privileged than the model assumes. This change makes the failure loud and explicit: - `report_residual_capabilities` now returns non-zero when dangerous caps are detected so the caller can refuse to continue. - New `enforce_residual_capability_policy` is invoked from both fall-through branches of `drop_capabilities` (CAP_SETPCAP-missing and capsh-missing). It exits 1 with a multi-line banner unless the operator sets `NEMOCLAW_ALLOW_RESIDUAL_CAPS=1` to acknowledge the weaker posture explicitly. - The banner cites the issue (#4264) and names the env-var escape hatch so shadecloud users have a one-line path to keep running. Behavior change for operators on platforms without CAP_SETPCAP: they must set NEMOCLAW_ALLOW_RESIDUAL_CAPS=1 once. Everyone else is unaffected (capsh succeeds, exec replaces this process before the new policy check runs). Tests: 3 new cases in test/sandbox-init.test.ts cover (a) refuse-to-start without the opt-in, (b) opt-in continues with a visible note, (c) enforce_residual_capability_policy's banner contents (including the issue URL). Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Charan Jagwani --- scripts/lib/sandbox-init.sh | 41 +++++++++++++++++++++ test/sandbox-init.test.ts | 73 +++++++++++++++++++++++++++++++++---- 2 files changed, 107 insertions(+), 7 deletions(-) diff --git a/scripts/lib/sandbox-init.sh b/scripts/lib/sandbox-init.sh index 54edab75337..34a490964d7 100755 --- a/scripts/lib/sandbox-init.sh +++ b/scripts/lib/sandbox-init.sh @@ -236,9 +236,11 @@ drop_capabilities() { -- -c "exec $entrypoint \"\$@\"" -- "$@" else report_residual_capabilities + 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 +248,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 +278,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 < { - 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("skips when NEMOCLAW_CAPS_DROPPED=1", () => { @@ -405,6 +432,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 From 7cb6be6c47d917bc4e41b234592f16d8b23afe97 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 27 May 2026 08:06:56 -0700 Subject: [PATCH 2/3] fix(sandbox): handle residual-cap fallback under set -e --- .github/workflows/nightly-e2e.yaml | 4 +- docs/reference/commands.mdx | 1 + scripts/lib/sandbox-init.sh | 5 +- src/lib/onboard.ts | 6 +++ test/e2e-gateway-isolation.sh | 39 ++++++++++---- test/e2e/test-full-e2e.sh | 5 ++ test/e2e/test-hermes-e2e.sh | 5 ++ test/sandbox-init.test.ts | 83 ++++++++++++++++++++++++++++++ 8 files changed, 135 insertions(+), 13 deletions(-) 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 34a490964d7..d54f945a6b8 100755 --- a/scripts/lib/sandbox-init.sh +++ b/scripts/lib/sandbox-init.sh @@ -235,7 +235,10 @@ 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 diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index ed0519a242d..86228448e18 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -3753,6 +3753,12 @@ async function createSandbox( if (sandboxProxyPort && isValidProxyPort(sandboxProxyPort)) { envArgs.push(formatEnvAssignment("NEMOCLAW_PROXY_PORT", sandboxProxyPort)); } + if (process.env.NEMOCLAW_ALLOW_RESIDUAL_CAPS === "1") { + // Runtime-only operator acknowledgement for hosts that cannot grant + // CAP_SETPCAP (for example Brev shadecloud). Do not bake this into image + // layers; pass it only to the sandbox entrypoint invocation. + envArgs.push(formatEnvAssignment("NEMOCLAW_ALLOW_RESIDUAL_CAPS", "1")); + } if (hermesToolBrokerToken) { // Runtime-only: do not bake the per-sandbox broker token into image layers. envArgs.push(formatEnvAssignment("TOOL_GATEWAY_USER_TOKEN", hermesToolBrokerToken)); diff --git a/test/e2e-gateway-isolation.sh b/test/e2e-gateway-isolation.sh index 25ce2678a68..538257cc098 100755 --- a/test/e2e-gateway-isolation.sh +++ b/test/e2e-gateway-isolation.sh @@ -491,26 +491,45 @@ else fail "proxy env not set in all bash modes (#2704): $OUT" fi -# ── Test 27: Non-root mode executes without gosu ────────────────── +# ── Test 27: Non-root mode refuses residual caps without opt-in ─── +# This simulates the CAP_SETPCAP-unavailable posture seen on Brev shadecloud: +# capsh exists, but the process cannot drop bounding-set caps. The entrypoint +# must fail closed by default instead of silently starting with residual caps. + +info "27. Non-root mode refuses residual capabilities without explicit opt-in" +RC=0 +OUT=$(docker run --rm --user "${SB_UID}:${SB_GID}" "$IMAGE" bash -c 'printf "%s\n" "SHOULD_NOT_REACH"' 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 2d1c0929b4d..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 = {}; @@ -420,6 +447,62 @@ EOF 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", () => { const { stdout } = runWithLib( ` From d53ebcdd092040454633f4fe8049f1e6b3ecdd54 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 27 May 2026 08:16:42 -0700 Subject: [PATCH 3/3] chore(onboard): keep entrypoint budget neutral --- src/lib/onboard.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 86228448e18..63cc2313994 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -3720,13 +3720,6 @@ async function createSandbox( // and passed EVERYTHING else — including GITHUB_TOKEN, // AWS_SECRET_ACCESS_KEY, SSH_AUTH_SOCK, KUBECONFIG, NPM_TOKEN, and // any CI/CD secrets that happened to be in the host environment. - // The allowlist inverts the default: only known-safe env vars are - // forwarded, everything else is dropped. - // - // For the sandbox specifically, we also strip KUBECONFIG and - // SSH_AUTH_SOCK — the generic allowlist includes these for host-side - // subprocesses (gateway start, openshell CLI) but the sandbox should - // never have access to the host's Kubernetes cluster or SSH agent. const envArgs = [formatEnvAssignment("CHAT_UI_URL", chatUiUrl)]; // Always pass the effective dashboard port into the sandbox so // nemoclaw-start.sh starts the gateway on the correct port. When the