Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
7 changes: 0 additions & 7 deletions .github/workflows/e2e-script.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,6 @@ on:
permissions:
contents: read

env:
# GitHub-hosted Linux runners retain dangerous bounding-set capabilities but
# do not grant CAP_SETPCAP to the sandbox entrypoint. Functional E2E runs opt
# in explicitly so #4264's fail-closed guard remains covered by the dedicated
# gateway-isolation security regression instead of breaking every sandbox use.
NEMOCLAW_ALLOW_RESIDUAL_CAPS: "1"

jobs:
run:
runs-on: ${{ inputs.runner }}
Expand Down
12 changes: 2 additions & 10 deletions .github/workflows/nightly-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -137,14 +137,6 @@ on:
permissions:
contents: read

env:
# GitHub-hosted Linux runners retain dangerous bounding-set capabilities but
# do not grant CAP_SETPCAP to the sandbox entrypoint. Nightly functional E2E
# jobs opt in explicitly so #4264's fail-closed guard remains covered by the
# dedicated gateway-isolation security regression instead of breaking every
# sandbox use on these CI hosts.
NEMOCLAW_ALLOW_RESIDUAL_CAPS: "1"

concurrency:
group: nightly-e2e-${{ github.event_name }}-${{ github.event_name == 'workflow_dispatch' && format('{0}-{1}', github.ref, inputs.pr_number || 'manual') || 'schedule' }}
cancel-in-progress: true
Expand Down Expand Up @@ -720,7 +712,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_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"}'
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"}'
nvidia_api_key: true
github_token: true
secrets:
Expand All @@ -738,7 +730,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_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"}'
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"}'
nvidia_api_key: true
github_token: true
secrets:
Expand Down
1 change: 0 additions & 1 deletion docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1277,7 +1277,6 @@ 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: 1 addition & 45 deletions scripts/lib/sandbox-init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -235,24 +235,17 @@ 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 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"
report_residual_capabilities
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 @@ -281,44 +274,7 @@ 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: 7 additions & 6 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3514,6 +3514,13 @@ 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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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 @@ -3540,12 +3547,6 @@ 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));
Expand Down
39 changes: 10 additions & 29 deletions test/e2e-gateway-isolation.sh
Original file line number Diff line number Diff line change
Expand Up @@ -491,45 +491,26 @@ else
fail "proxy env not set in all bash modes (#2704): $OUT"
fi

# ── 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 ────
# ── Test 27: Non-root mode executes without gosu ──────────────────
# 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. 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.
# designed to run as that sandbox user.

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)
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)
if echo "$OUT" | grep -q "NON_ROOT_EXEC_OK"; then
pass "non-root mode executed command directly (no gosu) with explicit residual-cap opt-in"
pass "non-root mode executed command directly (no gosu)"
else
fail "non-root command execution failed despite residual-cap opt-in: $OUT"
fail "non-root command execution failed: $OUT"
fi

# ── Test 29: Model override patches openclaw.json at startup ─────
# ── Test 28: 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 "29. NEMOCLAW_MODEL_OVERRIDE patches openclaw.json"
info "28. 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 @@ -559,9 +540,9 @@ else
fail "model override did not patch correctly: $OUT"
fi

# ── Test 30: Model override is a no-op when env var is unset ─────
# ── Test 29: Model override is a no-op when env var is unset ─────

info "30. No override when NEMOCLAW_MODEL_OVERRIDE is unset"
info "29. 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: 0 additions & 5 deletions test/e2e-script-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,6 @@ import { loadE2eWorkflowContract, reusableNightlyJobs } from "./helpers/e2e-work
describe("E2E reusable workflow contract", () => {
const { runnerWorkflow, nightlyWorkflow, action } = loadE2eWorkflowContract();

it("opts functional E2E workflows into residual-cap execution on CI hosts", () => {
expect(runnerWorkflow.env?.NEMOCLAW_ALLOW_RESIDUAL_CAPS).toBe("1");
expect(nightlyWorkflow.env?.NEMOCLAW_ALLOW_RESIDUAL_CAPS).toBe("1");
});

it("does not persist checkout credentials in the reusable runner", () => {
const checkoutSteps = runnerWorkflow.jobs.run.steps.filter((step) =>
String(step.uses ?? "").startsWith("actions/checkout@"),
Expand Down
5 changes: 0 additions & 5 deletions test/e2e/test-full-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -138,11 +138,6 @@ 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: 0 additions & 5 deletions test/e2e/test-hermes-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -185,11 +185,6 @@ 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
2 changes: 0 additions & 2 deletions test/helpers/e2e-workflow-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,10 @@ export type WorkflowStep = {
};

export type NightlyWorkflow = {
env?: Record<string, string>;
jobs: Record<string, WorkflowJob>;
};

export type RunnerWorkflow = {
env?: Record<string, string>;
jobs: {
run: {
steps: WorkflowStep[];
Expand Down
Loading
Loading