Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/nightly-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
46 changes: 45 additions & 1 deletion scripts/lib/sandbox-init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -235,17 +235,24 @@ 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
}

# Emit a loud diagnostic when capsh-based dropping is unavailable so that
# 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

Expand Down Expand Up @@ -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 <<EOF

╔══════════════════════════════════════════════════════════════════════════════╗
║ [SECURITY] Refusing to start sandbox: bounding-set capability drop failed. ║
║ ║
║ Reason: ${reason}
║ ║
║ NemoClaw's runtime security model expects cap_sys_admin, cap_sys_ptrace, ║
║ cap_net_raw, cap_dac_override, cap_net_bind_service (and others) to be ║
║ dropped from the bounding set before any sandbox process starts. The drop ║
║ failed on this host, so the sandbox would inherit privileges the model ║
║ assumes are gone. ║
║ ║
║ To run anyway (acknowledging the weaker posture), set: ║
║ NEMOCLAW_ALLOW_RESIDUAL_CAPS=1 ║
║ ║
║ Filed as: https://github.com/NVIDIA/NemoClaw/issues/4264 ║
╚══════════════════════════════════════════════════════════════════════════════╝

EOF
exit 1
}

# ── Privilege step-down (issue #3280 follow-up) ──────────────────
Expand Down
13 changes: 6 additions & 7 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -3753,6 +3746,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"));
}
Comment on lines +3749 to +3754

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

CI budget blocker: move this onboarding-entrypoint growth out of src/lib/onboard.ts.

The logic is good, but this addition is currently blocking merge: CI reports top-level onboard entrypoint growth (+6 lines). Since the budget only allows growth in src/lib/onboard/ modules, please relocate this env-arg injection behind an existing/new helper in an src/lib/onboard/* module (or otherwise keep net-zero growth in this file).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/onboard.ts` around lines 3756 - 3761, The added env-arg injection for
NEMOCLAW_ALLOW_RESIDUAL_CAPS is causing top-level growth in src/lib/onboard.ts;
extract that logic into a helper inside src/lib/onboard/ (e.g., create a new
function like buildEntrypointEnvArgs or add to an existing helper such as
getOnboardEnvArgs) that returns the formatted env assignment using
formatEnvAssignment("NEMOCLAW_ALLOW_RESIDUAL_CAPS","1") when
process.env.NEMOCLAW_ALLOW_RESIDUAL_CAPS === "1", then replace the inline
envArgs.push(...) in onboard.ts with a single call to that helper (keeping the
same use of envArgs and formatEnvAssignment) so the file’s net line-count
doesn’t grow.

if (hermesToolBrokerToken) {
// Runtime-only: do not bake the per-sandbox broker token into image layers.
envArgs.push(formatEnvAssignment("TOOL_GATEWAY_USER_TOKEN", hermesToolBrokerToken));
Expand Down
39 changes: 29 additions & 10 deletions test/e2e-gateway-isolation.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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\"])")
Expand Down
5 changes: 5 additions & 0 deletions test/e2e/test-full-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ══════════════════════════════════════════════════════════════════
Expand Down
5 changes: 5 additions & 0 deletions test/e2e/test-hermes-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ══════════════════════════════════════════════════════════════════
Expand Down
Loading
Loading