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
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
6 changes: 6 additions & 0 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
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
156 changes: 149 additions & 7 deletions test/sandbox-init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
const backups: Record<string, string> = {};

Expand Down Expand Up @@ -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", () => {
Expand All @@ -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
Expand Down
Loading