fix(sandbox): drop CAP_FOWNER/SETUID/SETGID via setpriv (#3280)#3329
Conversation
…#3280) Append cap_sys_admin and cap_sys_ptrace to the capsh --drop list so they no longer remain in the bounding set after the entrypoint re-execs. The historical drop list already covered cap_net_raw / cap_dac_override / cap_net_bind_service, but T6002104 still observed them present — the root cause is the CAP_SETPCAP-missing fallback silently skipping the entire drop and inheriting the runtime defaults. Replace the misleading "runtime already restricts capabilities" message on that fallback path with report_residual_capabilities(), which reads CapBnd from /proc/self/status and names which of the 5 must-drop caps remain. Uses bash 64-bit arithmetic so it does not depend on gawk strtonum. Also enumerate the load-bearing kept caps (cap_chown/cap_fowner for post-drop chown, cap_setuid/cap_setgid for gosu, cap_kill for sandbox→ gateway signaling) inline so a future contributor can audit why each one stays. Signed-off-by: Dongni Yang <dongniy@nvidia.com>
…VIDIA#3280) Rewrite e2e-gateway-isolation.sh test 14 to inventory every cap named in issue NVIDIA#3280 (CAP_SYS_ADMIN, CAP_SYS_PTRACE, CAP_NET_RAW, CAP_NET_BIND_SERVICE, CAP_DAC_OVERRIDE, CAP_FOWNER, CAP_SETUID, CAP_SETGID) against CapBnd from /proc/self/status. Each is classified as must-drop or allowed-load-bearing; any must-drop cap still present fails the test by name. The previous assertion only decoded bit 13 (CAP_NET_RAW) and would have passed unchanged for an incomplete drop list or a silently skipped drop step. Run the test container with `--cap-add CAP_SYS_ADMIN --cap-add CAP_SYS_PTRACE` so the bounding set entering capsh matches the permissive OpenShell runtime that triggered T6002104. Without this, docker's default bounding set already excludes those caps and the test would have been a no-op for the regression we care about. Validated locally against a derived nemoclaw-isolation-test image: - drop list including cap_sys_admin,cap_sys_ptrace → PASS, CapBnd=0x1e9 (load-bearing caps only). - drop list with cap_sys_admin omitted → FAIL with "CAP_SYS_ADMIN still present in CapBnd after capsh drop", CapBnd=0x2001e9 (bit 21 set), exactly the T6002104 signature. Signed-off-by: Dongni Yang <dongniy@nvidia.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Enterprise Run ID: 📒 Files selected for processing (4)
🚧 Files skipped from review as they are similar to previous changes (3)
📝 WalkthroughWalkthroughImplements setpriv-based privilege step-down prefixes, updates drop_capabilities to use capsh with residual-cap diagnostics, replaces gosu invocations across Hermes and Nemoclaw with STEP_DOWN_PREFIX_* prefixes, and adds tests asserting eight dangerous capabilities are removed from CapBnd. ChangesPrivilege Step-Down and Capability Bounding Set Remediation
🎯 4 (Complex) | ⏱️ ~45 minutes
Suggested reviewers:
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with 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.
Inline comments:
In `@scripts/lib/sandbox-init.sh`:
- Around line 302-303: Replace the Bash 4+ specific declarations by assigning
empty arrays directly: remove the two uses of "declare -ga" for
STEP_DOWN_PREFIX_SANDBOX and STEP_DOWN_PREFIX_GATEWAY and instead initialize
those symbols with plain array assignments compatible with Bash 3.2 (i.e., set
each variable to an empty array using the array assignment syntax), ensuring the
script remains sourceable on macOS CI; locate the lines referencing
STEP_DOWN_PREFIX_SANDBOX and STEP_DOWN_PREFIX_GATEWAY and change their
initialization accordingly.
- Around line 313-318: When stepping down to the gateway user in the
STEP_DOWN_PREFIX_GATEWAY array, stop clearing supplementary groups so the
gateway process keeps the sandbox group needed to write
/sandbox/.openclaw/openclaw.json; update the setpriv invocation in
STEP_DOWN_PREFIX_GATEWAY (the one that currently includes --clear-groups) to
either remove --clear-groups or replace it with an explicit group list that
includes sandbox (e.g., use --groups=sandbox) so gateway retains group write
access required by mutateConfigFile.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: 9643b7c2-2a57-497f-a75b-33df3e7e7b5f
📒 Files selected for processing (5)
agents/hermes/start.shscripts/lib/sandbox-init.shscripts/nemoclaw-start.shtest/e2e-gateway-isolation.shtest/sandbox-init.test.ts
3f5e404 to
06c62be
Compare
…NVIDIA#3280) Follow-up to NVIDIA#3328, which dropped 5/8 of the caps named in issue NVIDIA#3280 but left CAP_FOWNER, CAP_SETUID, and CAP_SETGID present in the sandbox- user process's bounding set. Those three were blocked by gosu: gosu needs CAP_SETUID in permitted to make its setuid() syscall, but the bounding set can only be modified with CAP_SETPCAP (root-only). So dropping CAP_SETUID before gosu would break the privilege transition, and dropping it after would be too late because we are no longer root. setpriv from util-linux solves this by performing reuid + bounding-set drop atomically inside a single process: setuid first (still holds CAP_SETUID), then strip the bounding set (still root long enough to hold CAP_SETPCAP), then exec the target. Add init_step_down_prefixes() to scripts/lib/sandbox-init.sh which populates two bash arrays at source time: STEP_DOWN_PREFIX_SANDBOX — step down to sandbox user STEP_DOWN_PREFIX_GATEWAY — step down to gateway user Each array expands to a setpriv invocation that drops cap_setuid / cap_setgid / cap_fowner / cap_chown / cap_kill from the bounding set during the reuid. If setpriv or CAP_SETPCAP is unavailable, the arrays stay at the gosu fallback and a warning is logged so the residual cap retention surfaces in the entrypoint log (matches the design of report_residual_capabilities from NVIDIA#3328). Notes: * Arrays default to (gosu sandbox)/(gosu gateway) at file scope (NOT empty). This prevents a privesc regression if init_step_down_prefixes is ever skipped: an unset/empty array would expand to nothing and `exec "${ARR[@]}" "${NEMOCLAW_CMD[@]}"` would run the agent as root. init_step_down_prefixes() only upgrades to setpriv when available. * setpriv uses unprefixed cap names (per `setpriv --list`), unlike capsh which uses cap_*. The arrays use the setpriv format. * --init-groups (NOT --clear-groups): the gateway user is a member of the sandbox group via `usermod -aG sandbox gateway` in Dockerfile.base, which is required to write the chmod 660 /sandbox/.openclaw/openclaw.json (setgid'd config dir, see NVIDIA#2681). --clear-groups would strip that membership and break mutateConfigFile with EACCES. --init-groups matches gosu's setgroups+initgroups behaviour and restores exactly the groups defined in /etc/group for the target user. * Plain array assignment (not `declare -ga`) at file scope: bash 3.2 on macOS rejects `declare -g`, and bash 3.2+ treats file-scope assignment as global by default. Inside init_step_down_prefixes() the reassignment is unscoped, so it targets the same globals in both bash 3.2 and 4+. * Per-assignment shellcheck SC2034 disables: the prefix arrays are consumed cross-file (by scripts/nemoclaw-start.sh and agents/hermes/start.sh), which shellcheck cannot follow. Replace the seven gosu call sites across both entrypoints: scripts/nemoclaw-start.sh: line 795 — auto-pair (sandbox) line 1610 — write_auth_profile + harden_auth_profiles (sandbox) line 1614 — final exec to NEMOCLAW_CMD (sandbox) line 1720 — OpenClaw gateway (gateway) agents/hermes/start.sh: line 294 — Discord facade (gateway) line 586 — final exec to NEMOCLAW_CMD (sandbox) line 607 — Hermes gateway (gateway) The non-root fallback path in nemoclaw-start.sh (lines 1488+) and the no-new-privileges history comments at lines 138-139 / 1490-1493 are unchanged — that path does not use a privilege-step-down tool at all. Validated live: with --cap-add CAP_SYS_ADMIN --cap-add CAP_SYS_PTRACE (simulating permissive OpenShell runtime), source sandbox-init.sh and chain drop_capabilities + STEP_DOWN_PREFIX_SANDBOX → final sandbox- user CapBnd=0x100 (only CAP_SETPCAP remains; all 8 issue-NVIDIA#3280 caps absent). Negative path: removing -setuid from the setpriv drop list correctly leaves CAP_SETUID present (bit 7), matching the regression signature the test in the follow-up commit catches. Signed-off-by: Dongni Yang <dongniy@nvidia.com>
…VIDIA#3280) Flip CAP_FOWNER / CAP_SETUID / CAP_SETGID in e2e-gateway-isolation.sh test 14 from "allowed" (as documented in NVIDIA#3328) to "must-drop". The preceding commit replaces gosu with setpriv so the three load-bearing caps now drop atomically with reuid; the sandbox-user process should have ALL eight caps named in issue NVIDIA#3280 absent from CapBnd. Rewrite test 14 to exercise the full two-stage drop end-to-end: source sandbox-init.sh, run drop_capabilities() (stage 1: capsh strips the entrypoint-wide --drop list), then exec STEP_DOWN_PREFIX_SANDBOX (stage 2: setpriv strips the load-bearing caps during reuid), then capture CapBnd of the resulting sandbox-user process. The test container is started with --cap-add CAP_SYS_ADMIN --cap-add CAP_SYS_PTRACE so the bounding set entering the entrypoint resembles the permissive OpenShell runtime that triggered T6002104 — otherwise docker's default bounding set already excludes those caps and the test would be a no-op for the bug condition. Use grep ^CapBnd: + awk for extraction rather than a triple-quoted awk script: the awk script's $2 would otherwise be expanded by bash on the way through capsh re-exec, producing /^CapBnd:/{print } which prints the whole line and breaks downstream parsing. Add two unit tests in test/sandbox-init.test.ts for the new init_step_down_prefixes() helper: - falls back to gosu when setpriv/capsh are unavailable - uses setpriv with the issue-3280 bounding-set drop when available Update the existing snapshot-style test for Hermes start.sh's start_discord_facade body to assert on the new STEP_DOWN_PREFIX_GATEWAY invocation instead of the legacy gosu gateway sh -c. Update nemoclaw-start.test.ts test scaffolding to initialise STEP_DOWN_PREFIX_SANDBOX and STEP_DOWN_PREFIX_GATEWAY in the fallback form (gosu sandbox / gosu gateway) inside both runLaunchBlock() and runPreGatewaySetup(). The extracted launch and setup blocks reference these arrays, and the test scaffolding doesn't source sandbox-init.sh, so without an explicit initialisation `set -u` fails on the unbound array and the stubbed gosu() never receives the call. Validated locally with docker build + docker run --cap-add against a test image overlaid with the new sandbox-init.sh: - Forward: CapBnd=0x100 (only CAP_SETPCAP), test PASS. - Regression (omit -setuid from setpriv drop): CapBnd=0x180, test correctly fails with "CAP_SETUID still present" by name. Full npm test on this branch: same 67 failures as upstream/main baseline (all pre-existing on main), +2 new passing tests for init_step_down_prefixes — net zero regressions. Signed-off-by: Dongni Yang <dongniy@nvidia.com>
06c62be to
a5ea171
Compare
Signed-off-by: Aaron Erickson <aerickson@nvidia.com> # Conflicts: # scripts/lib/sandbox-init.sh # test/e2e-gateway-isolation.sh
ericksoa
left a comment
There was a problem hiding this comment.
Reviewed the setpriv step-down follow-up and the post-#3328 stack repair. The repaired head is mergeable against current main, CodeRabbit is green with only resolved/outdated threads, PR checks are green, and local validation passed bash syntax, build:cli, diff check, and the focused sandbox-init/nemoclaw-start tests. The full stack nightly also passed on the pre-repair stack head a5ea171.
## Summary Refreshes the release-prep docs for v0.0.39 based on changes merged since the Friday 4pm doc refresh. Updates the source docs, bumps the docs version metadata, and regenerates the NemoClaw user skills from the refreshed docs. ## Changes - #3314 -> `docs/get-started/prerequisites.md`, `docs/get-started/quickstart.md`, `docs/reference/troubleshooting.md`: Documents installer Docker setup, Docker group activation, and retry guidance. - #3317 -> `docs/get-started/quickstart.md`, `docs/reference/commands.md`: Documents the DGX Spark and DGX Station express install prompt and `NEMOCLAW_NO_EXPRESS`. - #3328 and #3329 -> `docs/security/best-practices.md`, `docs/deployment/sandbox-hardening.md`: Updates sandbox capability hardening docs for the stricter bounding-set and `setpriv` step-down behavior. - #3330, #3335, and #3346 -> `docs/inference/use-local-inference.md`: Documents Windows-host Ollama relaunch behavior, NIM key passthrough, early health-fail diagnostics, and mixed-GPU preflight detail. - #2406, #2883, #3001, #3244, #3267, #3318, #3320, and #3354 -> `docs/about/release-notes.md`: Adds the v0.0.39 release-prep section while keeping the v0.0.38 release notes intact. - Advances the release-prep docs metadata from v0.0.38 to v0.0.39. - Regenerates `.agents/skills/nemoclaw-user-*` from the updated source docs. ## Type of Change - [ ] Code change (feature, bug fix, or refactor) - [ ] Code change with doc updates - [ ] Doc only (prose changes, no code sample modifications) - [x] Doc only (includes code sample changes) ## Verification - [x] `npx prek run --all-files` passes - [ ] `npm test` passes - [ ] Tests added or updated for new or changed behavior - [x] No secrets, API keys, or credentials committed - [x] Docs updated for user-facing behavior changes - [x] `make docs` builds without warnings (doc changes only) - [x] Doc pages follow the [style guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md) (doc changes only) - [ ] New doc pages include SPDX header and frontmatter (new pages only) --- Signed-off-by: Miyoung Choi <miyoungc@nvidia.com> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes v0.0.39 * **New Features** * Host alias management commands for easier configuration * Sandbox GPU control options during onboarding * Update command with check and confirmation modes * **Documentation** * Enhanced Linux installer guidance with Docker and group membership handling * Expanded troubleshooting for permission and connectivity issues * Improved capability-dropping security documentation * Updated inference model switching commands * Brev environment-specific troubleshooting * **Improvements** * DGX Spark/Station express install flow * Windows Ollama relay and health-check enhancements * NVIDIA NIM preflight GPU reporting [](https://app.coderabbit.ai/change-stack/NVIDIA/NemoClaw/pull/3375) <!-- end of auto-generated comment: release notes by coderabbit.ai -->
Summary
Follow-up to #3328, which dropped 5/8 of the caps named in issue #3280 and left CAP_FOWNER, CAP_SETUID, CAP_SETGID present because the entrypoint's
gosu-based privilege separation prevented dropping them from the bounding set (gosu needs CAP_SETUID in permitted to do itssetuid()syscall, but the bounding set can only be modified with CAP_SETPCAP, which only root holds — there's no point in the entrypoint where the drop can happen without breaking either gosu or the privilege transition).This PR resolves the chicken-and-egg by replacing
gosuwithsetpriv(util-linux, already present in the image).setprivdoes reuid + bounding-set drop atomically inside a single process: setuid first (still holds CAP_SETUID), then strip the bounding set (still root long enough to hold CAP_SETPCAP), then exec — noexecbetween the setuid and the bounding-set drop.After this PR, all 8 caps named in issue #3280 are absent from the sandbox-user process's CapBnd (verified live; see Test plan).
Stacking note
Stacked on PR #3328. The full PR diff shows 4 commits:
fix(sandbox): tighten bounding-set caps and surface residualstest(sandbox): inventory dangerous-cap set in bounding-set assertionfix(sandbox): replace gosu with setpriv to drop all bounding-set caps05954bf49)test(sandbox): require all 8 issue-3280 caps absent after step-downa5ea1712c)Reviewers should focus on the bottom two commits. Merge after #3328 lands; I'll rebase if #3328 changes during review.
Changes
scripts/lib/sandbox-init.shAdd
init_step_down_prefixes()and two file-scope arrays:STEP_DOWN_PREFIX_SANDBOX— defaults to(gosu sandbox); upgraded byinit_step_down_prefixes()to(setpriv --reuid=sandbox --regid=sandbox --init-groups --bounding-set=-setuid,-setgid,-fowner,-chown,-kill --)whensetpriv+CAP_SETPCAPare available.STEP_DOWN_PREFIX_GATEWAY— same shape, gateway user.If
setprivis missing orCAP_SETPCAPis unavailable, the arrays stay at the gosu fallback (matching the previous behavior) and a[SECURITY WARNING]is logged so the residual cap retention surfaces in the entrypoint log (matchesreport_residual_capabilities()from #3328).Implementation notes:
(gosu …), not()— hardens against a theoretical privesc regression: ifinit_step_down_prefixes()were ever skipped by a future refactor, an empty array would expand to nothing, andexec "${STEP_DOWN_PREFIX_SANDBOX[@]}" "${NEMOCLAW_CMD[@]}"would run the agent as root. The gosu default makes the failure mode safe.--init-groups(not--clear-groups) — gateway is a member of the sandbox group viausermod -aG sandbox gatewayinDockerfile.base:99, required to write the chmod 660/sandbox/.openclaw/openclaw.json(setgid'd config dir per OpenClaw UI "Enable Dreaming" doesn't work because of GatewayRequestError: EACCES: permission denied #2681).--clear-groupswould strip that membership and breakmutateConfigFilewith EACCES.--init-groupsmatches gosu's setgroups + initgroups behaviour. (Addresses CodeRabbit comment.)declare -ga) — bash 3.2 on macOS rejectsdeclare -g, which would break macOS CI when any test sourcessandbox-init.sh. File-scopeARR=()is global by default in bash 3.2+; the function-internal reassignment withoutlocaltargets the same global. (Addresses CodeRabbit comment.)setprivuses unprefixed cap names (persetpriv --list), unlikecapshwhich usescap_*. The arrays follow the setpriv convention.# shellcheck disable=SC2034— the prefix arrays are consumed cross-file (byscripts/nemoclaw-start.shandagents/hermes/start.sh), which shellcheck cannot follow fromsandbox-init.shalone.scripts/nemoclaw-start.sh(4 sites) andagents/hermes/start.sh(3 sites)Replace all
gosu <user>invocations with"${STEP_DOWN_PREFIX_<USER>[@]}":Non-root fallback path in
nemoclaw-start.sh(lines 1488+) and the no-new-privileges history comments at 138-139 / 1490-1493 are unchanged — that path doesn't use a privilege-step-down tool at all.test/e2e-gateway-isolation.shFlip CAP_FOWNER / CAP_SETUID / CAP_SETGID in test 14 from
allowedtomust-drop. Rewrite the test to exercise the full two-stage drop end-to-end: sourcesandbox-init.sh, rundrop_capabilities()(stage 1: capsh), then execSTEP_DOWN_PREFIX_SANDBOX(stage 2: setpriv), then capture CapBnd.test/sandbox-init.test.tsTwo new unit tests for
init_step_down_prefixes():Update the existing
start_discord_facadesnapshot test to expect the newSTEP_DOWN_PREFIX_GATEWAYinvocation instead of the legacygosu gateway sh -c.test/nemoclaw-start.test.tsInitialise
STEP_DOWN_PREFIX_SANDBOX=(gosu sandbox)andSTEP_DOWN_PREFIX_GATEWAY=(gosu gateway)in the test scaffolding for bothrunLaunchBlock()andrunPreGatewaySetup(). The extracted launch / setup blocks reference these arrays, and the test scaffolding doesn't sourcesandbox-init.sh, so without an explicit initialisationset -ufails on the unbound array and the stubbedgosu()never receives the call (this caused theuser=gatewayCI failure on the prior push).Test plan
Forward case (full production image, post-build)
Built
nemoclaw-3329-testdirectly from this branch'sDockerfile(63 steps, no overlay). Ran the full two-stage drop end-to-end with--cap-add CAP_SYS_ADMIN --cap-add CAP_SYS_PTRACE(worst-case permissive runtime):Gateway path (full production image, post-build)
Same image, but invoking
STEP_DOWN_PREFIX_GATEWAYinstead:This is the exact case CodeRabbit flagged: gateway must retain
sandboxgroup membership to write the chmod 660 setgid'd config (per #2681). Confirmed.Negative case (live container)
Rebuilt with
-setuidremoved from the setpriv--bounding-setarg.CapBnd=0x180(bit 7 set = CAP_SETUID). Test correctly fails with "CAP_SETUID still present in sandbox-user CapBnd (issue #3280)" — matches the regression signature this PR is designed to catch.Full regression baseline
npm teston this branch vsupstream/main:upstream/main(baseline)Net: 2 new passing tests (the new
init_step_down_prefixescases), zero new failures. All 67 baseline failures pre-date this PR (staledist/, unrelated TypeScript files).Targeted
npx vitest run test/{sandbox-init,nemoclaw-start,seccomp-guard,service-env}.test.ts→ 132/132 pass.bash -nclean on all 4 touched shell files.shfmt -d -i 2 -ci -bnclean.Security review
execsemantics → fail-closed on setpriv failure. E2E test 14 verifies in CI.[SECURITY WARNING]to log).--init-groupspreserves gateway's sandbox-group membership (chmod 660 config write still works).Net assessment: no new CWEs introduced. Sandbox-user CapBnd: 6 entries → 1 entry. Attack surface for setuid-root-binary cap regain: reduced to empty.
Risks and notes for review
setuidsyscall.setpriv --reuidsets ruid+euid but not saved UID (gosu usessetresuidwhich sets all three). Saved-UID=0 is inert here because using it requires CAP_SETUID in permitted, which is empty after the bounding-set drop onexec.setprivperforms the setuid syscall as root, which is unrestricted regardless ofno_new_privs. Different failure mode from gosu (documented atnemoclaw-start.sh:138-139and:1490-1493). Worth verifying on Spark/arm64 in CI.cat /proc/self/statusshowing an empty CapBnd (apart from CAP_SETPCAP itself, which is harmless in an unprivileged process).[SECURITY WARNING]so the residual surfaces indocker logs.Review feedback addressed
declare -ga) → replaced with plain array assignment.--clear-groupsremoves gateway from sandbox group → switched to--init-groups; verified live.(gosu …)instead of();init_step_down_prefixes()only upgrades.shellcheck SC2034→ per-assignment# shellcheck disable=SC2034with cross-file-consumption note.test/nemoclaw-start.test.ts:1201user=gateway→ scaffolding initialisesSTEP_DOWN_PREFIX_*in fallback form so the stubbed gosu still receives the call.Closes #3280.
Signed-off-by: Dongni Yang dongniy@nvidia.com
🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes
Security Improvements
Tests