diff --git a/ci/platform-matrix.json b/ci/platform-matrix.json index fdf963af6c..1dfdb3dc06 100644 --- a/ci/platform-matrix.json +++ b/ci/platform-matrix.json @@ -203,7 +203,7 @@ { "name": "Sandboxed execution", "status": "caveated", - "notes": "Landlock, seccomp, network namespace isolation, no-new-privileges, privilege dropping, and process limits (nproc 512 at `scripts/lib/sandbox-rlimits.sh:8`) are tested and on by default. The `DANGEROUS_CAPS` list at `scripts/lib/sandbox-init.sh:275-286` drops 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 with `capsh --drop` when CAP_SETPCAP is present. Limitation (active issue #3280): the fail-closed bounding-set gate is opt-in via `NEMOCLAW_REQUIRE_CAP_DROP=1`; the default is warn-and-continue so hosts without CAP_SETPCAP still boot, which means dangerous caps can remain in the bounding set on some hosts even though the effective set is empty." + "notes": "Landlock, seccomp, network namespace isolation, no-new-privileges, privilege dropping, and process limits (nproc 512 at `scripts/lib/sandbox-rlimits.sh:8`) are tested and on by default. Under full enforcement, OpenShell clears the child bounding set. OpenClaw and Hermes skip duplicate capability dropping only after observing five empty capability sets in the calling process. Other states retain the `DANGEROUS_CAPS` list and one `capsh` attempt in `scripts/lib/sandbox-init.sh`. Limitation (active issue #3280): `NEMOCLAW_REQUIRE_CAP_DROP=1` requires verification of the listed dangerous bounding capabilities; it does not require all five sets to be empty. Without strict mode, the fallback warns and continues when dangerous bounding capabilities remain or cannot be verified." }, { "name": "Routed inference", diff --git a/docs/deployment/sandbox-hardening.mdx b/docs/deployment/sandbox-hardening.mdx index 1b345c1237..46fe6f3040 100644 --- a/docs/deployment/sandbox-hardening.mdx +++ b/docs/deployment/sandbox-hardening.mdx @@ -53,18 +53,21 @@ Enforce both limits at the container runtime when that residual risk matters to ## Dropping Linux Capabilities -The NemoClaw entrypoint drops dangerous capabilities from the process bounding set before it starts agent services. -It removes `CAP_SYS_ADMIN`, `CAP_SYS_PTRACE`, `CAP_NET_RAW`, `CAP_DAC_OVERRIDE`, `CAP_SYS_CHROOT`, `CAP_FSETID`, `CAP_SETFCAP`, `CAP_MKNOD`, `CAP_AUDIT_WRITE`, and `CAP_NET_BIND_SERVICE`. +Under full enforcement, OpenShell clears the child bounding set before starting the entrypoint. +The entrypoint skips the duplicate drop only when its effective, permitted, inheritable, ambient, and bounding capability sets are present and empty. +Other states retain the legacy fallback, which can attempt `capsh` once when it is available and the process has `CAP_SETPCAP`. +The fallback removes `CAP_SYS_ADMIN`, `CAP_SYS_PTRACE`, `CAP_NET_RAW`, `CAP_DAC_OVERRIDE`, `CAP_SYS_CHROOT`, `CAP_FSETID`, `CAP_SETFCAP`, `CAP_MKNOD`, `CAP_AUDIT_WRITE`, and `CAP_NET_BIND_SERVICE`. The managed images install `setpriv` from `util-linux` and require it for the switch from root to the `sandbox` and `gateway` users. When `CAP_SETPCAP` is available, the same `setpriv` operation also removes the remaining privilege-separation capabilities from the child process bounding set. -The bounding-set drop is best effort: if `capsh` or `CAP_SETPCAP` is unavailable, the entrypoint logs a warning and still uses `setpriv` to change the user, group, and supplementary groups. +For root-to-user transitions, the extra bounding-set drop is best effort: if `capsh` or `CAP_SETPCAP` is unavailable, the entrypoint warns and uses `setpriv` without that drop. When a root entrypoint must change identity, it fails closed if `setpriv` is missing instead of starting an agent service as root. -To make the drop fail-closed instead, set `NEMOCLAW_REQUIRE_CAP_DROP=1` in the entrypoint environment: the agent then refuses to start unless the agent process tree's bounding set is verified free of the dangerous capabilities. +Set `NEMOCLAW_REQUIRE_CAP_DROP=1` to refuse startup unless the entrypoint verifies that its bounding set is free of the listed dangerous capabilities. +This strict-mode gate checks the bounding set only; it does not require all five capability sets to be empty. This is opt-in because many hosts cannot drop capabilities, including cloud VMs, Docker Desktop, and WSL environments without `CAP_SETPCAP`. -The check covers the agent process tree only. +This startup check does not inspect fresh exec or connect processes. For defense-in-depth, also drop all Linux capabilities at the container runtime when you launch the image directly. In the examples below, `nemoclaw-sandbox` is a placeholder for the sandbox image NemoClaw builds during onboarding; substitute the image tag your install produced, which you can find with `docker images`. diff --git a/docs/reference/platform-support.mdx b/docs/reference/platform-support.mdx index 7cf0b4c833..344272dd9d 100644 --- a/docs/reference/platform-support.mdx +++ b/docs/reference/platform-support.mdx @@ -134,7 +134,7 @@ Each row below is a launch-facing capability claim that NemoClaw makes in docs, | Capability | Status | Notes | |------------|--------|-------| | Guided onboarding | Tested | Single-command interactive wizard (`$$nemoclaw onboard`) that walks the user through inference provider selection, credential setup, and sandbox creation or update. It opens the agent dashboard when the selected runtime provides one. Non-interactive mode is supported with `--non-interactive` and `NEMOCLAW_*` environment variables for CI and scripted installs. | -| Sandboxed execution | Tested with limitations | Landlock, seccomp, network namespace isolation, no-new-privileges, privilege dropping, and process limits (nproc 512 at `scripts/lib/sandbox-rlimits.sh:8`) are tested and on by default. The `DANGEROUS_CAPS` list at `scripts/lib/sandbox-init.sh:275-286` drops 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 with `capsh --drop` when CAP_SETPCAP is present. Limitation (active issue #3280): the fail-closed bounding-set gate is opt-in via `NEMOCLAW_REQUIRE_CAP_DROP=1`; the default is warn-and-continue so hosts without CAP_SETPCAP still boot, which means dangerous caps can remain in the bounding set on some hosts even though the effective set is empty. | +| Sandboxed execution | Tested with limitations | Landlock, seccomp, network namespace isolation, no-new-privileges, privilege dropping, and process limits (nproc 512 at `scripts/lib/sandbox-rlimits.sh:8`) are tested and on by default. Under full enforcement, OpenShell clears the child bounding set. OpenClaw and Hermes skip duplicate capability dropping only after observing five empty capability sets in the calling process. Other states retain the `DANGEROUS_CAPS` list and one `capsh` attempt in `scripts/lib/sandbox-init.sh`. Limitation (active issue #3280): `NEMOCLAW_REQUIRE_CAP_DROP=1` requires verification of the listed dangerous bounding capabilities; it does not require all five sets to be empty. Without strict mode, the fallback warns and continues when dangerous bounding capabilities remain or cannot be verified. | | Routed inference | Tested | Provider-routed model calls through the OpenShell gateway, transparent to the agent. The agent uses `inference.local` inside the sandbox; provider credentials stay on the host. Supports every entry in the Providers table. | | Declarative network policy | Tested | YAML-defined egress with policy presets. Presets include `slack`, `discord`, `telegram`, `weather`, `openclaw-pricing`, `huggingface`, `npm`, `pypi`, `brew`, and others. Hot-reloadable at runtime with `$$nemoclaw policy add`. | | Snapshot and restore | Tested | Create, list, and restore named snapshots of sandbox state with the `$$nemoclaw snapshot` subcommands (`create`, `list`, `restore`). Credential stripping is enforced on capture. Unsafe symlinks are rejected on restore. | diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 0d0cce72b6..63e0ac3fd1 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -407,7 +407,7 @@ The following patterns weaken security without providing meaningful benefit. |---------|---------------|-------------------| | Omitting `protocol: rest` on REST API endpoints without a compatibility reason | Endpoints without a `protocol` field use L4-only enforcement. The proxy allows the TCP stream through after checking host, port, and binary, but cannot see or filter individual HTTP requests. | Add `protocol: rest` with explicit `rules` to enable per-request method and path control on REST APIs. Use L4 pass-through only for documented cases such as npm/Yarn on Node 22, where the client requires a CONNECT tunnel that L7 inspection would break. | | Adding endpoints to the baseline policy for one-off requests | Adding an endpoint to the baseline policy makes it permanently reachable across all sandbox instances. | Use operator approval. Approved endpoints persist within the sandbox instance but reset when you destroy and recreate the sandbox. | -| Relying solely on the entrypoint for capability drops | The entrypoint drops dangerous capabilities using `capsh`, but this is best-effort. If `capsh` is unavailable or `CAP_SETPCAP` is not in the bounding set, the container runs with the default capability set. | Pass `--cap-drop=ALL` at the container runtime level as defense-in-depth. | +| Relying solely on the entrypoint for capability drops | OpenShell owns child capability enforcement in full mode. Without strict mode, the legacy `capsh` fallback warns and continues when dangerous bounding capabilities remain or cannot be verified. | Keep OpenShell enforcement enabled. For direct container launches, pass `--cap-drop=ALL` as defense-in-depth. | | Treating mutable agent config as an isolation boundary | The generated config tree contains model routing, channel settings, and runtime integration state (`/sandbox/.openclaw` for OpenClaw, `/sandbox/.hermes` for Hermes, `/sandbox/.deepagents` for Deep Agents). NemoClaw supports this state as mutable, so the agent can change it independently of host-managed policy and credential routing. | Treat OpenShell policy and credential providers as the enforcement boundary. Monitor agent-config drift and recreate the sandbox from trusted inputs after suspected compromise. | | Adding inference provider hosts to the network policy for NemoClaw inference | Direct network access to an inference host bypasses credential isolation and usage tracking. | Use OpenShell inference routing instead of adding hosts like `api.openai.com` or `api.anthropic.com` to the network policy. Apply `claude-code` only when intentionally running the separate Claude Code CLI inside the sandbox. | | Disabling device auth for remote deployments | Without device auth, any device on the network can connect to the gateway without pairing. Combined with a cloudflared tunnel, this makes the dashboard publicly accessible and unauthenticated. | Keep `NEMOCLAW_DISABLE_DEVICE_AUTH` at its default (`0`). Only set it to `1` for local headless or development environments. | diff --git a/docs/security/process-controls.mdx b/docs/security/process-controls.mdx index dd4174892f..14513e6d70 100644 --- a/docs/security/process-controls.mdx +++ b/docs/security/process-controls.mdx @@ -16,31 +16,32 @@ Refer to the [Process Controls](https://docs.nvidia.com/openshell/latest/securit ## Capability Drops -The entrypoint drops dangerous Linux capabilities from the bounding set at startup using `capsh`. -This limits what capabilities any child process (gateway, sandbox, agent) can ever acquire. +Under full enforcement, OpenShell clears the child bounding set before starting the agent. +The OpenClaw and Hermes entrypoints check their calling process's effective, permitted, inheritable, ambient, and bounding capability sets. +When all five sets are present and empty, startup skips the duplicate `capsh` drop. +Other states retain the legacy fallback, which can attempt `capsh` once when it is available and the process has `CAP_SETPCAP`. The managed images install `setpriv` from `util-linux` and require it when the entrypoint switches from root to the `sandbox` and `gateway` users. When `CAP_SETPCAP` is available, the same `setpriv` operation removes the remaining privilege-separation capabilities from the child process at the same time as the user change. -The initial entrypoint drop removes `cap_sys_admin`, `cap_sys_ptrace`, `cap_net_raw`, `cap_dac_override`, `cap_sys_chroot`, `cap_fsetid`, `cap_setfcap`, `cap_mknod`, `cap_audit_write`, and `cap_net_bind_service`. +The fallback drop removes `cap_sys_admin`, `cap_sys_ptrace`, `cap_net_raw`, `cap_dac_override`, `cap_sys_chroot`, `cap_fsetid`, `cap_setfcap`, `cap_mknod`, `cap_audit_write`, and `cap_net_bind_service`. When the additional `setpriv` bounding-set drop runs, the child process also loses `cap_setuid`, `cap_setgid`, `cap_fowner`, `cap_chown`, and `cap_kill`. -The extra bounding-set capability drop is best effort. -If `capsh` is not available or `CAP_SETPCAP` is not in the bounding set, the entrypoint logs a warning and retains the runtime-provided bounding set. -The entrypoint still uses `setpriv` to change the user, group, and supplementary groups without the extra bounding-set drop. +For root-to-user transitions, the extra bounding-set capability drop is best effort. +If `capsh` or `CAP_SETPCAP` is unavailable, the root entrypoint logs a warning and uses `setpriv` without the extra bounding-set drop. When a root entrypoint must change identity, it fails closed if `setpriv` is unavailable instead of starting an agent service as root. To make the drop fail-closed instead of best-effort, set `NEMOCLAW_REQUIRE_CAP_DROP=1` in the entrypoint environment. -The agent then refuses to start unless it verifies that the agent process tree's bounding set is free of dangerous capabilities. +The agent then refuses to start unless the entrypoint verifies that its bounding set is free of the listed dangerous capabilities. +This strict-mode gate checks the bounding set only; it does not require all five capability sets to be empty. It does not boot on a host whose bounding set still holds them, typically one that cannot perform the drop because `CAP_SETPCAP` or `capsh` is missing and the container runtime did not provide a clean bounding set. This is opt-in because such hosts are common, including many cloud VMs, Docker Desktop, and WSL. Leaving it unset preserves the best-effort default. -The check covers the agent process tree only. -The container runtime spawns a `$$nemoclaw connect` shell outside that tree, so the check does not affect it (tracked in [NVIDIA/OpenShell#1452](https://github.com/NVIDIA/OpenShell/issues/1452)). +This startup check does not inspect fresh exec or connect processes. For additional protection, pass `--cap-drop=ALL` with `docker run` or Compose. @@ -49,9 +50,9 @@ Refer to [Review Sandbox Hardening](../../manage-sandboxes/configure-sandboxes/r | Aspect | Detail | |---|---| -| Default | The entrypoint drops dangerous capabilities at startup using `capsh`, then requires `setpriv` for user step-down. When `CAP_SETPCAP` is unavailable, the user step-down continues without the extra privilege-separation bounding-set drop and logs a warning. | -| What you can change | When launching with `docker run` directly, pass `--cap-drop=ALL --cap-add=NET_BIND_SERVICE` for stricter enforcement. In the standard NemoClaw onboarding flow, the entrypoint handles capability dropping automatically. | -| Risk if relaxed | `CAP_SYS_ADMIN` and `CAP_SYS_PTRACE` expand kernel and process attack surface. `CAP_NET_RAW` allows raw socket access for network sniffing. `CAP_DAC_OVERRIDE` bypasses filesystem permission checks. If `capsh` cannot run or `CAP_SETPCAP` is unavailable, the container retains more of the runtime-provided capability set. | +| Default | OpenShell full enforcement clears the child bounding set. OpenClaw and Hermes skip the duplicate drop only after observing five empty capability sets. Root-to-user transitions still require `setpriv`. | +| What you can change | When launching with `docker run` directly, pass `--cap-drop=ALL --cap-add=NET_BIND_SERVICE` for stricter enforcement. Set `NEMOCLAW_REQUIRE_CAP_DROP=1` to require verification of the dangerous bounding capabilities. | +| Risk if relaxed | `CAP_SYS_ADMIN` and `CAP_SYS_PTRACE` expand kernel and process attack surface. `CAP_NET_RAW` allows raw socket access for network sniffing. `CAP_DAC_OVERRIDE` bypasses filesystem permission checks. Without strict mode, the fallback warns and continues when dangerous bounding capabilities remain or cannot be verified. | | Recommendation | Run on an image that includes `capsh` and `setpriv` (NemoClaw-managed images include them). For defense-in-depth, also pass `--cap-drop=ALL` at the container runtime level. | diff --git a/scripts/lib/sandbox-init.sh b/scripts/lib/sandbox-init.sh index 70e956a6fa..8abf6d8e33 100755 --- a/scripts/lib/sandbox-init.sh +++ b/scripts/lib/sandbox-init.sh @@ -185,42 +185,17 @@ validate_tmp_permissions() { } # ── Capability dropping ────────────────────────────────────────── -# CIS Docker Benchmark 5.3: containers should not run with default caps. -# OpenShell manages the container runtime so we cannot pass --cap-drop=ALL -# to docker run. Instead, drop dangerous capabilities from the bounding set -# at startup using capsh. The bounding set limits what caps any child process -# (gateway, sandbox, agent) can ever acquire. +# OpenShell full enforcement clears the child bounding set before launch. +# Skip compatibility handling only when all five capability sets are empty. +# Do not infer enforcement from runtime environment values. # -# Dropped (issue #3280): cap_sys_admin, cap_sys_ptrace plus the historical -# set (cap_net_raw, cap_dac_override, cap_sys_chroot, cap_fsetid, -# cap_setfcap, cap_mknod, cap_audit_write, cap_net_bind_service). -# Dashboard listens on a high port (default 18789, validated >=1024 in -# nemoclaw-start.sh), so cap_net_bind_service is unconditionally unused. +# Direct-root entrypoints can still need capsh. Their retained bounding caps +# (chown, fowner, setuid, setgid, kill) support initialization and supervised +# shutdown; init_step_down_prefixes can remove them when changing user. +# NEMOCLAW_REQUIRE_CAP_DROP=1 retains fail-closed verification for unavailable +# drops. The default preserves the legacy warn-and-continue behavior. # -# Kept (each load-bearing — do not drop without an entrypoint refactor): -# cap_chown, cap_fowner — needed to chown/chmod files we did not create -# after dropping cap_dac_override (see #2659). -# cap_setuid, cap_setgid — required by setpriv to step down from root into -# the sandbox/gateway UIDs during entrypoint privilege separation. -# cap_kill — root PID 1 terminates stepped-down gateway and sandbox child -# processes during supervised shutdown. The managed-image security test -# separately verifies that sandbox cannot signal gateway-user processes. -# When the runtime cannot drop the bounding set (no CAP_SETPCAP, or capsh -# missing), the default is to warn and continue. Set NEMOCLAW_REQUIRE_CAP_DROP=1 -# to make that case fail-closed instead — see enforce_cap_drop_if_required. -# -# Ref: https://github.com/NVIDIA/NemoClaw/issues/797 -# https://github.com/NVIDIA/NemoClaw/issues/3280 -# https://github.com/NVIDIA/OpenShell/issues/1452 (connect-shell scope) -# -# Usage: -# drop_capabilities /usr/local/bin/nemoclaw-start "$@" -# -# Single source of truth for the dangerous capabilities the entrypoint drops -# and (in strict mode) verifies are gone. "bit:name" pairs; bit numbers per -# /usr/include/linux/capability.h. Both the capsh --drop list and -# dangerous_caps_in_capbnd() derive from this array, so the drop-set and the -# strict-mode verify-set cannot drift apart (issue #3280). +# Single drop/verification list, with bit numbers from linux/capability.h. DANGEROUS_CAPS=( "21:cap_sys_admin" "19:cap_sys_ptrace" @@ -243,33 +218,72 @@ dangerous_caps_drop_list() { printf '%s' "$out" } -# The first argument is the absolute path to the entrypoint script to -# re-exec via capsh. Remaining arguments are forwarded. +# Use built-ins: exec'ing a reader can lower its permitted/effective set and +# hide capabilities still held by this shell. The caller supplies the fixed +# procfs path; an explicit file argument also permits deterministic fixtures. +read_capability_state() { + local key value rest bit seen=0 all_zero=1 cap_bnd_hex="" + while IFS=$' \t' read -r key value rest; do + case "$key" in + CapInh:) bit=1 ;; + CapPrm:) bit=2 ;; + CapEff:) bit=4 ;; + CapBnd:) + bit=8 + cap_bnd_hex="$value" + ;; + CapAmb:) bit=16 ;; + *) continue ;; + esac + [ $((seen & bit)) -eq 0 ] || return 1 + seen=$((seen | bit)) + case "$value" in + "" | *[!0]*) all_zero=0 ;; + esac + [ -z "$rest" ] || all_zero=0 + done <"$1" || return 1 + printf '%s:%s\n' "$cap_bnd_hex" "$((seen == 31 && all_zero == 1))" +} + +# The first argument is the absolute entrypoint path; remaining args are forwarded. drop_capabilities() { local entrypoint="$1" shift - if [ "${NEMOCLAW_CAPS_DROPPED:-}" != "1" ] && command -v capsh >/dev/null 2>&1; then - # capsh --drop requires CAP_SETPCAP in the bounding set. OpenShell's - # sandbox runtime may strip it, so check before attempting the drop. - if capsh --has-p=cap_setpcap 2>/dev/null; then - export NEMOCLAW_CAPS_DROPPED=1 - exec capsh \ - --drop="$(dangerous_caps_drop_list)" \ - -- -c "exec $entrypoint \"\$@\"" -- "$@" + local cap_state cap_bnd_hex present reason="" + if ! cap_state="$(read_capability_state /proc/self/status 2>/dev/null)"; then + reason="could not read bounding set from /proc/self/status" + else + cap_bnd_hex="${cap_state%:*}" + [ "${cap_state##*:}" = 1 ] && return 0 + if [ -z "$cap_bnd_hex" ]; then + reason="could not read bounding set from /proc/self/status" + elif ! present="$(dangerous_caps_in_capbnd "$cap_bnd_hex")"; then + reason="could not parse bounding set (CapBnd=${cap_bnd_hex})" + elif [ -n "$present" ]; then + reason="dangerous caps remain in bounding set (CapBnd=${cap_bnd_hex}): ${present}" fi - # CAP_SETPCAP missing (or the exec above failed): the drop could not run. - # Surface the residual bounding-set caps in the log. - report_residual_capabilities || true - elif [ "${NEMOCLAW_CAPS_DROPPED:-}" != "1" ]; then - echo "[SECURITY WARNING] capsh not available — running with default capabilities" >&2 fi - # Opt-in fail-closed gate (issue #3280). Deliberately runs on EVERY path, - # including when NEMOCLAW_CAPS_DROPPED is already set: it verifies the actual - # bounding set rather than trusting that sentinel, so an inherited marker - # cannot mask a drop that never happened. - enforce_cap_drop_if_required + # Keep one capsh attempt for legacy entrypoints even when /proc is unreadable. + # The sentinel prevents re-execution loops; it never proves a successful drop. + if [ "${NEMOCLAW_CAPS_DROPPED:-}" != "1" ] \ + && command -v capsh >/dev/null 2>&1 \ + && capsh --has-p=cap_setpcap 2>/dev/null; then + export NEMOCLAW_CAPS_DROPPED=1 + # capsh expands the positional parameters in its child shell. + # shellcheck disable=SC2016 + exec capsh \ + --drop="$(dangerous_caps_drop_list)" \ + -- -c 'exec "$0" "$@"' "$entrypoint" "$@" + fi + + [ -z "$reason" ] && return 0 + if [ "${NEMOCLAW_REQUIRE_CAP_DROP:-}" = "1" ]; then + echo "[SECURITY] Refusing to start sandbox: ${reason}" >&2 + exit 1 + fi + echo "[SECURITY WARNING] Cannot drop bounding-set capabilities with capsh: ${reason}" >&2 } # Pure decode: given a CapBnd hex string, echo the comma-separated list of the @@ -299,83 +313,6 @@ dangerous_caps_in_capbnd() { printf '%s' "$present" } -# Opt-in fail-closed enforcement (issue #3280). When NEMOCLAW_REQUIRE_CAP_DROP=1 -# the sandbox refuses to start unless the bounding set is provably free of the -# dangerous capabilities. It verifies by reading the ACTUAL CapBnd — NOT by -# trusting the NEMOCLAW_CAPS_DROPPED sentinel, which an inherited environment -# could forge to bypass the gate. -# -# DEFAULT (unset) IS WARN-AND-CONTINUE — no host loses the ability to boot. This -# is the lesson of #4266/#4341: a default-fail-closed drop broke EVERY host that -# does not grant CAP_SETPCAP (GitHub runners, Brev shadecloud, Colossus Ubuntu -# 24.04, Docker Desktop, WSL) and was reverted within hours. Inverting the -# default to opt-in keeps that regression off by default. -# -# Scope: the AGENT process tree only. A `nemoclaw connect` shell is spawned by -# the container runtime outside that tree and inherits the container's OCI -# bounding set; tightening that requires cap_drop at sandbox create, tracked -# upstream in NVIDIA/OpenShell#1452. -# -# Test seam: NEMOCLAW_PROC_STATUS overrides the status source so unit tests can -# feed a known CapBnd fixture without a real /proc. -enforce_cap_drop_if_required() { - [ "${NEMOCLAW_REQUIRE_CAP_DROP:-}" = "1" ] || return 0 - - local status_path="${NEMOCLAW_PROC_STATUS:-/proc/self/status}" - local cap_bnd_hex present reason="" - cap_bnd_hex=$(awk '/^CapBnd:/{print $2}' "$status_path" 2>/dev/null || true) - if [ -z "$cap_bnd_hex" ]; then - # Cannot verify → in strict mode, refuse rather than assume safety. - reason="could not read bounding set from ${status_path} — cannot verify drop" - elif ! present="$(dangerous_caps_in_capbnd "$cap_bnd_hex")"; then - # Non-empty but unparseable CapBnd is equally unverifiable → refuse. - reason="could not parse bounding set (CapBnd=${cap_bnd_hex}) — cannot verify drop" - elif [ -n "$present" ]; then - reason="dangerous caps remain in bounding set (CapBnd=${cap_bnd_hex}): ${present}" - fi - [ -n "$reason" ] || return 0 - - cat >&2 <<'EOF' - -┌─ [SECURITY] Refusing to start sandbox: bounding-set capability drop failed ── -│ -│ NEMOCLAW_REQUIRE_CAP_DROP=1 is set, so NemoClaw refuses to start a sandbox -│ that still holds dangerous bounding-set capabilities. The runtime could not -│ drop them (capsh or CAP_SETPCAP unavailable on this host), so they remain. -│ -│ To run anyway with the weaker (warn-only) posture, unset the variable: -│ unset NEMOCLAW_REQUIRE_CAP_DROP -│ -│ Tracking: https://github.com/NVIDIA/NemoClaw/issues/3280 -└────────────────────────────────────────────────────────────────────────────── -EOF - echo "[SECURITY] ${reason}" >&2 - exit 1 -} - -# 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). -report_residual_capabilities() { - echo "[SECURITY] CAP_SETPCAP not available — cannot drop bounding-set caps via capsh" >&2 - - local status_path="${NEMOCLAW_PROC_STATUS:-/proc/self/status}" - local cap_bnd_hex present - if ! cap_bnd_hex=$(awk '/^CapBnd:/{print $2}' "$status_path" 2>/dev/null) \ - || [ -z "$cap_bnd_hex" ]; then - echo "[SECURITY] Could not read ${status_path} — residual caps unknown" >&2 - return 0 - fi - echo "[SECURITY] Residual CapBnd=${cap_bnd_hex}" >&2 - - if ! present="$(dangerous_caps_in_capbnd "$cap_bnd_hex")"; then - echo "[SECURITY] Could not parse CapBnd=${cap_bnd_hex} — residual caps unknown" >&2 - elif [ -n "$present" ]; then - echo "[SECURITY] Dangerous caps remain in bounding set: ${present}" >&2 - fi -} - # ── Privilege step-down (issue #3280 follow-up) ────────────────── # Uses `setpriv` for every root-to-user transition. When CAP_SETPCAP is # available, setpriv also strips the load-bearing caps (cap_setuid, @@ -395,7 +332,7 @@ report_residual_capabilities() { # If CAP_SETPCAP is unavailable, setpriv still changes identity and initializes # supplementary groups, but cannot remove the remaining load-bearing caps from # the bounding set. That case is logged consistently with -# report_residual_capabilities. If setpriv itself is unavailable, the prefix +# drop_capabilities. If setpriv itself is unavailable, the prefix # invokes a fail-closed helper instead of risking execution as root. # File-scope array declarations: bash 3.2 (macOS) does not accept `declare -g`, # but plain assignment at file scope is global by default. Inside @@ -457,7 +394,9 @@ init_step_down_prefixes() { # shellcheck disable=SC2034 # consumed by entrypoint scripts (cross-file) STEP_DOWN_PREFIX_GATEWAY=("${gateway_prefix[@]}") } -init_step_down_prefixes +if [ "$(id -u)" -eq 0 ]; then + init_step_down_prefixes +fi # ── Config integrity check ────────────────────────────────────── # The config hash was pinned at build time. If it doesn't match, diff --git a/test/e2e/fixtures/security-posture.ts b/test/e2e/fixtures/security-posture.ts index 64f0cc9bb3..909385d3b0 100644 --- a/test/e2e/fixtures/security-posture.ts +++ b/test/e2e/fixtures/security-posture.ts @@ -913,8 +913,8 @@ grep -q 'cannot modify config inside the sandbox' /tmp/nemoclaw-security-guard-p log=/tmp/nemoclaw-start.log test -f "$log" || { echo MISSING_START_LOG; exit 1; } grep -qi '${launchPattern}' "$log" || { echo MISSING_GATEWAY_LAUNCH_MARKER; exit 1; } -if grep -E 'mktemp:.*(/sandbox/\.\.(bashrc|profile)\.tmp|/sandbox/\.nemoclaw.*tmp)|Permission denied.*(/sandbox/\.bashrc|/sandbox/\.profile)' "$log"; then - echo START_LOG_HAS_RC_WRITE_FAILURE +if grep -E 'mktemp:.*(/sandbox/\.\.(bashrc|profile)\.tmp|/sandbox/\.nemoclaw.*tmp)|Permission denied.*(/sandbox/\.bashrc|/sandbox/\.profile)|\[SECURITY( WARNING)?\].*(capsh|CAP_SETPCAP)' "$log"; then + echo START_LOG_HAS_SECURITY_FAILURE exit 1 fi tail -n 20 "$log" diff --git a/test/runtime/sandbox/sandbox-init.test.ts b/test/runtime/sandbox/sandbox-init.test.ts index c2cb906cee..15023f9116 100644 --- a/test/runtime/sandbox/sandbox-init.test.ts +++ b/test/runtime/sandbox/sandbox-init.test.ts @@ -348,264 +348,288 @@ 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. - const { stdout } = runWithLib( - ` - # Hide capsh from PATH so the function falls through - drop_capabilities /usr/local/bin/fake-entrypoint 2>&1 - echo "FALLTHROUGH_OK" - `, - { env: { PATH: "/usr/bin:/bin", NEMOCLAW_CAPS_DROPPED: "" } }, - ); - expect(stdout).toContain("capsh not available"); - expect(stdout).toContain("FALLTHROUGH_OK"); - }); - - it("skips when NEMOCLAW_CAPS_DROPPED=1", () => { - const { stdout } = runWithLib( - ` - NEMOCLAW_CAPS_DROPPED=1 - drop_capabilities /usr/local/bin/fake-entrypoint - echo "SKIPPED_OK" - `, - ); - expect(stdout).toContain("SKIPPED_OK"); - }); - - // Context for reopened issue #3280 (NVBug 6159223), QA FAIL reported by - // hulynn on v0.0.54: on a host whose container runtime does not grant - // CAP_SETPCAP (e.g. the Colossus Ubuntu 24.04 image), capsh --drop cannot - // run, so the bounding-set drop is skipped and the dangerous caps - // (cap_sys_admin, cap_sys_ptrace, cap_net_raw, cap_dac_override, - // cap_net_bind_service, ...) remain in the bounding set. - // - // The strict-mode tests below use NEMOCLAW_PROC_STATUS — a test seam in - // sandbox-init.sh — to feed a known CapBnd fixture, so they exercise the - // real enforcement against a controlled bounding set without depending on - // the test runner's own /proc/self/status. CapBnd 0x4a82c35fb is the exact - // value hulynn decoded on the failing Colossus host. - const QA_CAPBND = "00000004a82c35fb"; // contains all 10 inspected dangerous caps - const CLEAN_CAPBND = "0000000000000000"; // none present + const QA_CAPBND = "00000004a82c35fb"; // All ten dangerous capabilities from issue #3280. + const CLEAN_CAPBND = "0000000000000000"; + const RETAINED_SETPCAP = "0000000000000100"; + const CLEAN_STATUS = `CapInh: 0 +CapPrm: 0 +CapEff: 0 +CapBnd: ${CLEAN_CAPBND} +CapAmb: 0 +`; const QA_DANGEROUS = "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"; + const forwardedArgs = ["argument with spaces", "literal;$value"]; + let workDir: string; - // Stub capsh so it is found on PATH (command -v succeeds) but reports - // CAP_SETPCAP absent, forcing the fall-through that skips the real drop. - const capshNoSetpcapStub = [ - "cat >\"$TMP/capsh\" <<'STUB'", - "#!/bin/sh", - '[ "$1" = "--has-p=cap_setpcap" ] && exit 1', - "exit 0", - "STUB", - 'chmod +x "$TMP/capsh"', - 'export PATH="$TMP:$PATH"', - ]; - const writeStatusFixture = (capbndHex: string) => [ - `printf 'CapBnd:\\t${capbndHex}\\n' >"$TMP/status"`, - 'export NEMOCLAW_PROC_STATUS="$TMP/status"', - ]; - - // Default (no NEMOCLAW_REQUIRE_CAP_DROP): warns and CONTINUES even though - // dangerous caps remain — preserving the zero-regression posture for - // CAP_SETPCAP-less hosts. report_residual_capabilities still names them. - it("warns without refusing to start when CAP_SETPCAP is unavailable (#3280)", () => { - const { stdout } = runWithLib( - [ - "TMP=$(mktemp -d)", - ...capshNoSetpcapStub, - ...writeStatusFixture(QA_CAPBND), - "drop_capabilities /usr/local/bin/fake-entrypoint 2>&1", - 'echo "SANDBOX_CONTINUED_DESPITE_RESIDUAL_CAPS"', - 'rm -rf "$TMP"', - ].join("\n"), - { env: { NEMOCLAW_CAPS_DROPPED: "", NEMOCLAW_REQUIRE_CAP_DROP: "" } }, - ); - expect(stdout).toContain( - "CAP_SETPCAP not available — cannot drop bounding-set caps via capsh", - ); - expect(stdout).toContain(`Dangerous caps remain in bounding set: ${QA_DANGEROUS}`); - expect(stdout).toContain("SANDBOX_CONTINUED_DESPITE_RESIDUAL_CAPS"); - expect(stdout).not.toContain("Refusing to start sandbox"); + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), "nemoclaw-cap-drop-")); }); - - // Exercise the REAL decode function (not a copy of its loop) so future - // drift in dangerous_caps_in_capbnd is caught. - it("dangerous_caps_in_capbnd decodes the inspected caps from a CapBnd hex", () => { - const { stdout } = runWithLib( - [ - `echo "DANGEROUS:[$(dangerous_caps_in_capbnd ${QA_CAPBND})]"`, - `echo "CLEAN:[$(dangerous_caps_in_capbnd ${CLEAN_CAPBND})]"`, - ].join("\n"), + afterEach(() => rmSync(workDir, { recursive: true, force: true })); + + function runDrop({ + caps = QA_CAPBND, + strict = false, + sentinel = "", + capsh = "unavailable", + afterDrop = caps, + procStatus = caps === null ? null : CLEAN_STATUS.replace(CLEAN_CAPBND, caps), + }: { + caps?: string | null; + strict?: boolean; + sentinel?: string; + capsh?: string; + afterDrop?: string | null; + procStatus?: string | null; + } = {}) { + const entrypoint = join(workDir, "entrypoint with spaces"); + for (const name of ["reads", "calls", "args"]) writeFileSync(join(workDir, name), ""); + writeFileSync(join(workDir, "status"), procStatus ?? ""); + writeFileSync( + join(workDir, "after-status"), + CLEAN_STATUS.replace(CLEAN_CAPBND, afterDrop ?? ""), ); - expect(stdout).toContain(`DANGEROUS:[${QA_DANGEROUS}]`); - expect(stdout).toContain("CLEAN:[]"); - }); - - // ── Fix: opt-in fail-closed strict mode (issue #3280) ────────────── - // The inverse of the reverted #4266: default stays warn-and-continue (no - // regression), but NEMOCLAW_REQUIRE_CAP_DROP=1 refuses to start unless the - // ACTUAL bounding set is provably free of the dangerous caps. - - it("refuses to start when REQUIRE_CAP_DROP=1 and dangerous caps remain (CAP_SETPCAP path)", () => { - const { stdout, stderr } = runWithLib( - [ - "TMP=$(mktemp -d)", - ...capshNoSetpcapStub, - ...writeStatusFixture(QA_CAPBND), - "drop_capabilities /usr/local/bin/fake-entrypoint", - 'echo "SHOULD_NOT_REACH"', - 'rm -rf "$TMP"', - ].join("\n"), - { env: { NEMOCLAW_CAPS_DROPPED: "", NEMOCLAW_REQUIRE_CAP_DROP: "1" }, expectFail: true }, + writeFileSync( + join(workDir, "capsh"), + `#!/bin/bash +printf '%s\\n' "$1" >>"$TEST_CAPSH_CALLS" +if [ "$1" = --has-p=cap_setpcap ]; then + [ "$TEST_CAPSH_MODE" = available ] || [ "$TEST_CAPSH_MODE" = error ] + exit $? +fi +printf '%s\\0' "$@" >>"$TEST_CAPSH_ARGS" +if [ "$TEST_CAPSH_MODE" = error ]; then echo CAPSH_EXEC_FAILED >&2; exit 71; fi +[ "$NEMOCLAW_CAPS_DROPPED" = 1 ] || exit 72 +export TEST_PROC_STATUS="$TEST_AFTER_DROP" +shift 2 +exec /bin/bash "$@" +`, + { mode: 0o700 }, ); - const combined = `${stdout}\n${stderr}`; - expect(combined).toContain("Refusing to start sandbox"); - expect(combined).toContain( - `dangerous caps remain in bounding set (CapBnd=${QA_CAPBND}): ${QA_DANGEROUS}`, + writeFileSync( + entrypoint, + `#!/bin/bash +set -euo pipefail +source ${JSON.stringify(SANDBOX_INIT)} +install_capability_reader_fixture +drop_capabilities "$0" "$@" +printf 'ENTRYPOINT_RETURNED\\n' +printf 'FORWARDED:%s\\n' "$@" +`, + { mode: 0o700 }, ); - expect(combined).not.toContain("SHOULD_NOT_REACH"); - }); - - it("refuses to start when REQUIRE_CAP_DROP=1 and capsh is missing", () => { - const { stdout, stderr } = runWithLib( - [ - "TMP=$(mktemp -d)", - ...writeStatusFixture(QA_CAPBND), - "drop_capabilities /usr/local/bin/fake-entrypoint", - 'echo "SHOULD_NOT_REACH"', - 'rm -rf "$TMP"', - ].join("\n"), + const { stdout } = runWithLib( + ` + install_capability_reader_fixture() { + eval "$(declare -f read_capability_state | sed '1s/read_capability_state/read_fixture_capability_state/')" + read_capability_state() { + printf '%s\\n' "$1" >>"$TEST_CAP_READS" + [ "$#" -eq 1 ] && [ "$1" = /proc/self/status ] || return 90 + read_fixture_capability_state "$TEST_PROC_STATUS" + } + } + command() { + if [ "$TEST_CAPSH_MODE" = missing ] && [ "$*" = '-v capsh' ]; then return 1; fi + builtin command "$@" + } + export -f install_capability_reader_fixture command + install_capability_reader_fixture + : >"$TEST_CAPSH_CALLS" + set +e + (set -e; drop_capabilities ${JSON.stringify(entrypoint)} 'argument with spaces' 'literal;$value'; echo ENTRYPOINT_RETURNED) 2>&1 + printf 'DROP_STATUS=%s\\n' "$?" + `, { - // Hide capsh so command -v fails, exercising the capsh-missing branch. - env: { PATH: "/usr/bin:/bin", NEMOCLAW_CAPS_DROPPED: "", NEMOCLAW_REQUIRE_CAP_DROP: "1" }, - expectFail: true, + env: { + PATH: `${workDir}:/usr/bin:/bin`, + TEST_PROC_STATUS: join(workDir, procStatus === null ? "unreadable" : "status"), + TEST_AFTER_DROP: join(workDir, afterDrop === null ? "unreadable" : "after-status"), + TEST_CAPSH_MODE: capsh, + TEST_CAP_READS: join(workDir, "reads"), + TEST_CAPSH_CALLS: join(workDir, "calls"), + TEST_CAPSH_ARGS: join(workDir, "args"), + NEMOCLAW_PROC_STATUS: join(workDir, "forged-status"), + NEMOCLAW_CAPS_DROPPED: sentinel, + NEMOCLAW_REQUIRE_CAP_DROP: strict ? "1" : "", + }, }, ); - const combined = `${stdout}\n${stderr}`; - expect(combined).toContain("capsh not available"); - expect(combined).toContain("Refusing to start sandbox"); - expect(combined).not.toContain("SHOULD_NOT_REACH"); - }); + return { + stdout, + reads: readFileSync(join(workDir, "reads"), "utf8").trim().split("\n"), + calls: readFileSync(join(workDir, "calls"), "utf8"), + args: readFileSync(join(workDir, "args"), "utf8").split("\0").slice(0, -1), + entrypoint, + }; + } - // Regression for the sentinel-bypass finding: a pre-set NEMOCLAW_CAPS_DROPPED=1 - // must NOT let a host with residual caps slip past strict mode. The gate - // verifies the actual bounding set, so it still refuses. - it("refuses despite a pre-set NEMOCLAW_CAPS_DROPPED=1 when dangerous caps remain (strict)", () => { - const { stdout, stderr } = runWithLib( - [ - "TMP=$(mktemp -d)", - ...writeStatusFixture(QA_CAPBND), - "drop_capabilities /usr/local/bin/fake-entrypoint", - 'echo "BYPASSED_STRICT_MODE"', - 'rm -rf "$TMP"', - ].join("\n"), - { - env: { NEMOCLAW_CAPS_DROPPED: "1", NEMOCLAW_REQUIRE_CAP_DROP: "1" }, - expectFail: true, - }, - ); - const combined = `${stdout}\n${stderr}`; - expect(combined).toContain("Refusing to start sandbox"); - expect(combined).toContain("dangerous caps remain in bounding set"); - expect(combined).not.toContain("BYPASSED_STRICT_MODE"); - }); + it.each([false, true])( + "returns before capsh for a verified clean set (strict=%s)", + (strict) => { + const result = runDrop({ + caps: CLEAN_CAPBND, + strict, + sentinel: strict ? "1" : "", + capsh: "error", + }); + expect(result.stdout).toBe("ENTRYPOINT_RETURNED\nDROP_STATUS=0"); + expect(result.reads).toEqual(["/proc/self/status"]); + expect(result.calls).toBe(""); + expect(result.args).toEqual([]); + }, + ); + + it.each([ + { uid: 0, users: ["--reuid=sandbox", "--reuid=gateway"], drops: 2, refusals: 0 }, + { uid: 1000, users: [], drops: 0, refusals: 2 }, + ])( + "initializes source-time privilege prefixes only for UID 0 (uid=$uid)", + ({ uid, users, drops, refusals }) => { + writeFileSync(join(workDir, "capsh"), "#!/bin/sh\nexit 1\n", { mode: 0o700 }); + const { stdout } = runWithLib( + `id() { printf '%s\\n' ${uid}; } + setpriv() { :; } + capsh() { echo CAPSH_PREFIX_CHECK; } + unset _SANDBOX_INIT_LOADED + source ${JSON.stringify(SANDBOX_INIT)} 2>&1 + printf '%s\\n' "\${STEP_DOWN_PREFIX_SANDBOX[@]}" "\${STEP_DOWN_PREFIX_GATEWAY[@]}"`, + { env: { PATH: `${workDir}:/usr/bin:/bin` } }, + ); + expect(stdout.includes("CAPSH_PREFIX_CHECK")).toBe(uid === 0); + expect(stdout.match(/--reuid=(?:sandbox|gateway)/g) ?? []).toEqual(users); + expect( + stdout.match(/--bounding-set=-setuid,-setgid,-fowner,-chown,-kill/g) ?? [], + ).toHaveLength(drops); + expect(stdout.match(/refusing to execute a root privilege transition/g) ?? []).toHaveLength( + refusals, + ); + }, + ); - // Strict mode trusts the verified state, not the fall-through: if the - // bounding set is already clean it must NOT refuse. - it("continues under REQUIRE_CAP_DROP=1 when the bounding set is already clean", () => { + it.each([ + { procStatus: `CapBnd: ${CLEAN_CAPBND}\n`, afterDrop: CLEAN_CAPBND }, + { + procStatus: CLEAN_STATUS.replace( + "CapPrm: 0\nCapEff: 0", + "CapPrm: 102\nCapEff: 102", + ).replace(CLEAN_CAPBND, RETAINED_SETPCAP), + afterDrop: RETAINED_SETPCAP, + }, + ])( + "retains capsh handling for a clean bounding set without five empty sets: %j", + ({ procStatus, afterDrop }) => { + const fallback = runDrop({ caps: afterDrop, procStatus, strict: true }); + expect(fallback.calls).toBe("--has-p=cap_setpcap\n"); + expect(fallback.stdout).toBe("ENTRYPOINT_RETURNED\nDROP_STATUS=0"); + expect(fallback.reads).toEqual(["/proc/self/status"]); + const reexec = runDrop({ + caps: afterDrop, + procStatus, + strict: true, + capsh: "available", + }); + expect(reexec.reads).toEqual(["/proc/self/status", "/proc/self/status"]); + expect(reexec.args[0]).toBe(`--drop=${QA_DANGEROUS}`); + expect(reexec.stdout).toContain("ENTRYPOINT_RETURNED"); + expect(reexec.stdout).toContain("DROP_STATUS=0"); + }, + ); + + it("decodes the dangerous set and preserves the empty-set result", () => { const { stdout } = runWithLib( - [ - "TMP=$(mktemp -d)", - ...capshNoSetpcapStub, - ...writeStatusFixture(CLEAN_CAPBND), - "drop_capabilities /usr/local/bin/fake-entrypoint 2>&1", - 'echo "CONTINUED_CLEAN"', - 'rm -rf "$TMP"', - ].join("\n"), - { env: { NEMOCLAW_CAPS_DROPPED: "", NEMOCLAW_REQUIRE_CAP_DROP: "1" } }, + `echo "DANGEROUS:[$(dangerous_caps_in_capbnd ${QA_CAPBND})]" + echo "CLEAN:[$(dangerous_caps_in_capbnd ${CLEAN_CAPBND})]"`, ); - expect(stdout).toContain("CONTINUED_CLEAN"); - expect(stdout).not.toContain("Refusing to start sandbox"); + expect(stdout).toBe(`DANGEROUS:[${QA_DANGEROUS}]\nCLEAN:[]`); }); - it("refuses under REQUIRE_CAP_DROP=1 when the bounding set cannot be verified", () => { - const { stdout, stderr } = runWithLib( - ` - export NEMOCLAW_PROC_STATUS=/nonexistent/sandbox-init-status - drop_capabilities /usr/local/bin/fake-entrypoint - echo "SHOULD_NOT_REACH" - `, + it.each( + [ { - env: { PATH: "/usr/bin:/bin", NEMOCLAW_CAPS_DROPPED: "", NEMOCLAW_REQUIRE_CAP_DROP: "1" }, - expectFail: true, + caps: QA_CAPBND, + reason: `dangerous caps remain in bounding set (CapBnd=${QA_CAPBND}): ${QA_DANGEROUS}`, }, - ); - const combined = `${stdout}\n${stderr}`; - expect(combined).toContain("Refusing to start sandbox"); - expect(combined).toContain("could not read bounding set"); - expect(combined).not.toContain("SHOULD_NOT_REACH"); - }); - - // Harden (issue #3280): a non-empty but unparseable CapBnd (corrupt /proc, - // CRLF fixture, future format change) must be treated as "cannot verify" - // — refusing in strict mode — and must NOT surface a raw bash arithmetic - // error. MALFORMED_CAPBND contains non-hex characters. - const MALFORMED_CAPBND = "00000000nothex0"; - it("refuses under REQUIRE_CAP_DROP=1 when CapBnd is non-empty but unparseable", () => { - const { stdout, stderr } = runWithLib( - [ - "TMP=$(mktemp -d)", - ...writeStatusFixture(MALFORMED_CAPBND), - "drop_capabilities /usr/local/bin/fake-entrypoint", - 'echo "SHOULD_NOT_REACH"', - 'rm -rf "$TMP"', - ].join("\n"), + { caps: null, reason: "could not read bounding set from /proc/self/status" }, { - env: { PATH: "/usr/bin:/bin", NEMOCLAW_CAPS_DROPPED: "", NEMOCLAW_REQUIRE_CAP_DROP: "1" }, - expectFail: true, + caps: CLEAN_CAPBND, + procStatus: `${CLEAN_STATUS}CapPrm: 0\n`, + reason: "could not read bounding set from /proc/self/status", }, - ); - const combined = `${stdout}\n${stderr}`; - expect(combined).toContain("Refusing to start sandbox"); - expect(combined).toContain("could not parse bounding set"); - expect(combined).not.toContain("SHOULD_NOT_REACH"); - // No leaked bash arithmetic error. - expect(combined).not.toMatch(/value too great for base|invalid arithmetic|16#/); - }); + { + caps: "00000000nothex0", + reason: "could not parse bounding set (CapBnd=00000000nothex0)", + }, + ].flatMap((failure) => [false, true].map((strict) => ({ ...failure, strict }))), + )( + "retains residual and unverifiable outcomes (caps=$caps, strict=$strict)", + ({ reason, strict, ...state }) => { + const result = runDrop({ ...state, strict }); + expect(result.reads).toEqual(["/proc/self/status"]); + expect(result.stdout).toContain(reason); + expect(result.stdout).toContain( + strict + ? "[SECURITY] Refusing to start sandbox:" + : "[SECURITY WARNING] Cannot drop bounding-set capabilities with capsh:", + ); + expect(result.stdout).toContain(`DROP_STATUS=${strict ? 1 : 0}`); + expect(result.stdout.includes("ENTRYPOINT_RETURNED")).toBe(!strict); + expect(result.stdout).not.toMatch(/value too great for base|invalid arithmetic|16#/); + expect(result.args).toEqual([]); + }, + ); - it("warns and continues (no abort) on an unparseable CapBnd when REQUIRE_CAP_DROP is unset", () => { - const { stdout } = runWithLib( - [ - "TMP=$(mktemp -d)", - ...capshNoSetpcapStub, - ...writeStatusFixture(MALFORMED_CAPBND), - "drop_capabilities /usr/local/bin/fake-entrypoint 2>&1", - 'echo "CONTINUED_ON_BAD_CAPBND"', - 'rm -rf "$TMP"', - ].join("\n"), - { env: { NEMOCLAW_CAPS_DROPPED: "", NEMOCLAW_REQUIRE_CAP_DROP: "" } }, - ); - expect(stdout).toContain("residual caps unknown"); - expect(stdout).toContain("CONTINUED_ON_BAD_CAPBND"); - expect(stdout).not.toContain("Refusing to start sandbox"); - }); + it.each([{ capsh: "missing" }, { capsh: "available", sentinel: "1" }])( + "refuses residual caps with missing capsh or a forged sentinel: %j", + (options) => { + const result = runDrop({ ...options, strict: true }); + expect(result.reads).toEqual(["/proc/self/status"]); + expect(result.stdout).toContain( + `dangerous caps remain in bounding set (CapBnd=${QA_CAPBND}): ${QA_DANGEROUS}`, + ); + expect(result.stdout).toContain("DROP_STATUS=1"); + expect(result.stdout).not.toContain("ENTRYPOINT_RETURNED"); + expect(result.calls).toBe(""); + }, + ); - it("continues (no regression) when NEMOCLAW_REQUIRE_CAP_DROP is unset even with residual caps", () => { - const { stdout } = runWithLib( - [ - "TMP=$(mktemp -d)", - ...capshNoSetpcapStub, - ...writeStatusFixture(QA_CAPBND), - "drop_capabilities /usr/local/bin/fake-entrypoint 2>&1", - 'echo "CONTINUED_OK"', - 'rm -rf "$TMP"', - ].join("\n"), - { env: { NEMOCLAW_CAPS_DROPPED: "", NEMOCLAW_REQUIRE_CAP_DROP: "" } }, - ); - expect(stdout).toContain("CONTINUED_OK"); - expect(stdout).not.toContain("Refusing to start sandbox"); + it.each([ + { + caps: QA_CAPBND, + afterDrop: CLEAN_CAPBND, + strict: true, + status: 0, + forwarded: forwardedArgs, + }, + { caps: QA_CAPBND, afterDrop: QA_CAPBND, strict: false, status: 0, forwarded: forwardedArgs }, + { caps: QA_CAPBND, afterDrop: QA_CAPBND, strict: true, status: 1, forwarded: [] }, + { caps: null, afterDrop: null, strict: true, status: 1, forwarded: [] }, + ])( + "reexecutes once and verifies the resulting state: %j", + ({ status, forwarded, ...options }) => { + const result = runDrop({ ...options, capsh: "available" }); + expect(result.reads).toEqual(["/proc/self/status", "/proc/self/status"]); + expect(result.args).toEqual([ + `--drop=${QA_DANGEROUS}`, + "--", + "-c", + 'exec "$0" "$@"', + result.entrypoint, + ...forwardedArgs, + ]); + expect(result.stdout).toContain(`DROP_STATUS=${status}`); + expect(result.stdout.includes("ENTRYPOINT_RETURNED")).toBe(status === 0); + expect([...result.stdout.matchAll(/^FORWARDED:(.*)$/gm)].map((match) => match[1])).toEqual( + forwarded, + ); + }, + ); + + it("keeps a capsh execution error terminal without continuing the entrypoint", () => { + const result = runDrop({ capsh: "error" }); + expect(result.reads).toEqual(["/proc/self/status"]); + expect(result.stdout).toContain("CAPSH_EXEC_FAILED\nDROP_STATUS=71"); + expect(result.stdout).not.toContain("ENTRYPOINT_RETURNED"); + expect(result.args[0]).toBe(`--drop=${QA_DANGEROUS}`); }); });