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
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
10 changes: 4 additions & 6 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3514,6 +3514,10 @@ 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.
// For sandbox create, also strip KUBECONFIG and SSH_AUTH_SOCK: the generic
// allowlist needs them for host-side subprocesses, but sandbox code must not
// access host Kubernetes or SSH-agent credentials.
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 +3544,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