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
10 changes: 5 additions & 5 deletions agents/hermes/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -291,9 +291,9 @@ start_discord_facade() {
"NEMOCLAW_DISCORD_FACADE_PORT=${DISCORD_FACADE_PORT}"
)

if [ "$(id -u)" -eq 0 ] && command -v gosu >/dev/null 2>&1 && id gateway >/dev/null 2>&1; then
if [ "$(id -u)" -eq 0 ] && id gateway >/dev/null 2>&1; then
prepare_restricted_log "$log_path" gateway:gateway 600
nohup env -u NEMOCLAW_DISCORD_FACADE_URL -u PYTHONPATH "${launch_env[@]}" gosu gateway sh -c 'umask 0007; exec "$@" >/tmp/discord-facade.log 2>&1' sh "$HERMES_VENV_PYTHON" /usr/local/bin/nemoclaw-discord-facade &
nohup env -u NEMOCLAW_DISCORD_FACADE_URL -u PYTHONPATH "${launch_env[@]}" "${STEP_DOWN_PREFIX_GATEWAY[@]}" sh -c 'umask 0007; exec "$@" >/tmp/discord-facade.log 2>&1' sh "$HERMES_VENV_PYTHON" /usr/local/bin/nemoclaw-discord-facade &
else
prepare_restricted_log "$log_path" "" 600
nohup env -u NEMOCLAW_DISCORD_FACADE_URL -u PYTHONPATH "${launch_env[@]}" sh -c 'umask 0007; exec "$@" >/tmp/discord-facade.log 2>&1' sh "$HERMES_VENV_PYTHON" /usr/local/bin/nemoclaw-discord-facade &
Expand Down Expand Up @@ -693,15 +693,15 @@ if [ "$(id -u)" -ne 0 ]; then
exit $?
fi

# ── Root path (full privilege separation via gosu) ─────────────
# ── Root path (full privilege separation via setpriv) ──────────

verify_config_integrity "${HERMES_DIR}" "${HERMES_HASH_FILE}"
refresh_hermes_provider_placeholders
install_configure_guard
configure_messaging_channels

if [ ${#NEMOCLAW_CMD[@]} -gt 0 ]; then
exec gosu sandbox "${NEMOCLAW_CMD[@]}"
exec "${STEP_DOWN_PREFIX_SANDBOX[@]}" "${NEMOCLAW_CMD[@]}"
fi

# SECURITY: Protect gateway log from sandbox user tampering
Expand All @@ -722,7 +722,7 @@ HERMES_HOME="${HERMES_DIR}" \
HTTP_PROXY="http://127.0.0.1:${DECODE_PROXY_PORT}" \
https_proxy="http://127.0.0.1:${DECODE_PROXY_PORT}" \
http_proxy="http://127.0.0.1:${DECODE_PROXY_PORT}" \
nohup gosu gateway sh -c 'umask 0007; exec "$@" >/tmp/gateway.log 2>&1' sh "$HERMES" gateway run &
nohup "${STEP_DOWN_PREFIX_GATEWAY[@]}" sh -c 'umask 0007; exec "$@" >/tmp/gateway.log 2>&1' sh "$HERMES" gateway run &
GATEWAY_PID=$!
echo "[gateway] hermes gateway launched as 'gateway' user (pid $GATEWAY_PID)" >&2
start_gateway_log_stream
Expand Down
72 changes: 72 additions & 0 deletions scripts/lib/sandbox-init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,78 @@ report_residual_capabilities() {
fi
}

# ── Privilege step-down (issue #3280 follow-up) ──────────────────
# Replaces direct `gosu <user>` invocations with `setpriv` so the load-
# bearing caps (cap_setuid, cap_setgid, cap_fowner, cap_chown, cap_kill)
# are stripped from the bounding set *atomically with* the setuid
# transition. gosu cannot do this: dropping those caps before gosu
# breaks its setuid() syscall, and after gosu we are non-root and have
# already lost CAP_SETPCAP. setpriv performs reuid + bounding-set drop
# in a single process, in the correct order, before exec.
#
# Two prefix arrays are populated at source time:
# STEP_DOWN_PREFIX_SANDBOX — step down to the 'sandbox' user
# STEP_DOWN_PREFIX_GATEWAY — step down to the 'gateway' user
#
# Callers use them like the old `gosu <user>` prefix:
# exec "${STEP_DOWN_PREFIX_SANDBOX[@]}" "${NEMOCLAW_CMD[@]}"
# "${STEP_DOWN_PREFIX_SANDBOX[@]}" bash -c "..."
# nohup "${STEP_DOWN_PREFIX_GATEWAY[@]}" gateway run --port "$port" &
#
# Fallback: if setpriv is missing or CAP_SETPCAP isn't available, the
# arrays fall back to plain `gosu <user>` and a warning is logged so the
# residual bounding-set caps surface in the entrypoint log (matches the
# residual-surface design of report_residual_capabilities).
# File-scope array declarations: bash 3.2 (macOS) does not accept `declare -g`,
# but plain assignment at file scope is global by default. Inside
# init_step_down_prefixes() we re-assign these without `local`, which targets
# the globals in both bash 3.2 and 4+.
#
# Initialize to the gosu fallback (NOT empty) so callers cannot accidentally
# `exec "${STEP_DOWN_PREFIX_SANDBOX[@]}" "${NEMOCLAW_CMD[@]}"` with an unset
# array — which would expand to nothing and run NEMOCLAW_CMD as root (privesc
# regression). init_step_down_prefixes() below upgrades to setpriv when
# CAP_SETPCAP is available; otherwise these stay at the gosu defaults.
# shellcheck disable=SC2034 # consumed by scripts/nemoclaw-start.sh and agents/hermes/start.sh
STEP_DOWN_PREFIX_SANDBOX=(gosu sandbox)
# shellcheck disable=SC2034 # consumed by scripts/nemoclaw-start.sh and agents/hermes/start.sh
STEP_DOWN_PREFIX_GATEWAY=(gosu gateway)

init_step_down_prefixes() {
if command -v setpriv >/dev/null 2>&1 \
&& command -v capsh >/dev/null 2>&1 \
&& capsh --has-p=cap_setpcap 2>/dev/null; then
# setpriv cap names are unprefixed (per `setpriv --list`); capsh uses
# cap_* names. Keep them in sync but format-distinct.
#
# --init-groups (NOT --clear-groups): gateway is a member of the sandbox
# group via `usermod -aG sandbox gateway` in Dockerfile.base so it can
# write the chmod 660 /sandbox/.openclaw/openclaw.json (setgid'd
# config dir, see #2681). --clear-groups would strip that membership
# and break mutateConfigFile / control-UI config edits with EACCES.
# --init-groups matches gosu's setgroups+initgroups behavior and
# restores exactly the groups defined in /etc/group for the target user.
local drop="-setuid,-setgid,-fowner,-chown,-kill"
# shellcheck disable=SC2034 # consumed by entrypoint scripts (cross-file)
STEP_DOWN_PREFIX_SANDBOX=(
setpriv --reuid=sandbox --regid=sandbox --init-groups
--bounding-set="$drop" --
)
# shellcheck disable=SC2034 # consumed by entrypoint scripts (cross-file)
STEP_DOWN_PREFIX_GATEWAY=(
setpriv --reuid=gateway --regid=gateway --init-groups
--bounding-set="$drop" --
)
else
echo "[SECURITY WARNING] setpriv or CAP_SETPCAP unavailable — falling back to gosu (bounding set will retain cap_setuid/setgid/fowner/chown/kill — issue #3280)" >&2
# shellcheck disable=SC2034 # consumed by entrypoint scripts (cross-file)
STEP_DOWN_PREFIX_SANDBOX=(gosu sandbox)
# shellcheck disable=SC2034 # consumed by entrypoint scripts (cross-file)
STEP_DOWN_PREFIX_GATEWAY=(gosu gateway)
fi
}
init_step_down_prefixes

# ── Config integrity check ──────────────────────────────────────
# The config hash was pinned at build time. If it doesn't match,
# someone (or something) has tampered with the config.
Expand Down
15 changes: 9 additions & 6 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -987,10 +987,13 @@ start_persistent_gateway_log_mirror() {
start_auto_pair() {
# Run auto-pair as sandbox user (it talks to the gateway via CLI)
# SECURITY: Pass resolved openclaw path to prevent PATH hijacking
# When running as non-root, skip gosu (we're already the sandbox user)
# When running as non-root, skip privilege step-down (we're already
# the sandbox user). When root, step down via STEP_DOWN_PREFIX_SANDBOX
# which uses setpriv to drop load-bearing caps from the bounding set
# atomically with reuid (issue #3280 follow-up).
local run_prefix=()
if [ "$(id -u)" -eq 0 ]; then
run_prefix=(gosu sandbox)
run_prefix=("${STEP_DOWN_PREFIX_SANDBOX[@]}")
fi
OPENCLAW_BIN="$OPENCLAW" nohup "${run_prefix[@]}" python3 - <<'PYAUTOPAIR' >>/tmp/auto-pair.log 2>&1 &
import json
Expand Down Expand Up @@ -1858,7 +1861,7 @@ if [ "$(id -u)" -ne 0 ]; then
exit $?
fi

# ── Root path (full privilege separation via gosu) ─────────────
# ── Root path (full privilege separation via setpriv) ──────────

# Verify locked config integrity before starting anything. Mutable-default
# config is intentionally writable and is not a trust anchor until shields-up.
Expand All @@ -1885,11 +1888,11 @@ verify_no_slack_secrets_on_disk

# Write auth profile as sandbox user and recursively re-tighten any
# auth-profiles.json files under ~/.openclaw.
gosu sandbox bash -c "$(declare -f write_auth_profile harden_auth_profiles); write_auth_profile; harden_auth_profiles"
"${STEP_DOWN_PREFIX_SANDBOX[@]}" bash -c "$(declare -f write_auth_profile harden_auth_profiles); write_auth_profile; harden_auth_profiles"

# If a command was passed (e.g., "openclaw agent ..."), run it as sandbox user
if [ ${#NEMOCLAW_CMD[@]} -gt 0 ]; then
exec gosu sandbox "${NEMOCLAW_CMD[@]}"
exec "${STEP_DOWN_PREFIX_SANDBOX[@]}" "${NEMOCLAW_CMD[@]}"
fi

# Gateway log: owned by gateway user, world-readable for diagnostics.
Expand Down Expand Up @@ -2001,7 +2004,7 @@ validate_tmp_permissions "$_SANDBOX_SAFETY_NET" "$_PROXY_FIX_SCRIPT" "$_NEMOTRON
# SECURITY: The sandbox user cannot kill this process because it runs
# under a different UID. The fake-HOME attack no longer works because
# the agent cannot restart the gateway with a tampered config.
nohup gosu gateway "$OPENCLAW" gateway run --port "${_DASHBOARD_PORT}" >/tmp/gateway.log 2>&1 &
nohup "${STEP_DOWN_PREFIX_GATEWAY[@]}" "$OPENCLAW" gateway run --port "${_DASHBOARD_PORT}" >/tmp/gateway.log 2>&1 &
GATEWAY_PID=$!
echo "[gateway] openclaw gateway launched as 'gateway' user (pid $GATEWAY_PID)" >&2

Expand Down
119 changes: 57 additions & 62 deletions test/e2e-gateway-isolation.sh
Original file line number Diff line number Diff line change
Expand Up @@ -207,75 +207,70 @@ fi

# ── Test 14: Dangerous capabilities are dropped by entrypoint ────

info "14. Entrypoint drops the issue #3280 dangerous-cap inventory from bounding set"
# Run capsh directly with the same --drop flags as the entrypoint, then
# decode every dangerous cap named in issue #3280 from CapBnd in
# /proc/self/status. This catches both an incomplete drop list and a
# silent skip of the drop step (CAP_SETPCAP missing) — the original test
# only checked CAP_NET_RAW and would pass either failure mode.
info "14. Entrypoint drops the full issue #3280 dangerous-cap inventory from sandbox-user bounding set"
# Inventory every cap named in issue #3280 against CapBnd of the
# sandbox-user process AFTER both stages of the entrypoint's privilege
# step-down: (1) the entrypoint-wide capsh drop in drop_capabilities()
# and (2) the per-user setpriv drop in STEP_DOWN_PREFIX_SANDBOX. The
# previous test (#3328) only exercised stage 1 and classified
# CAP_FOWNER/SETUID/SETGID as load-bearing because gosu needed them;
# the follow-up replaces gosu with setpriv so those three drop
# atomically with reuid, and ALL eight issue-named caps must be absent.
#
# IMPORTANT: docker's default bounding set already excludes CAP_SYS_ADMIN
# and CAP_SYS_PTRACE, so a plain "docker run" cannot reproduce the issue
# #3280 condition (permissive OpenShell runtime). We --cap-add those two
# caps here so the bounding set entering capsh resembles the runtime that
# triggered the original report. If the entrypoint's --drop list omits
# either cap, this test will see it PRESENT and fail — matching the
# T6002104 failure mode.
# caps here so the bounding set entering the entrypoint resembles the
# runtime that triggered T6002104.
#
# The drop list is extracted from the shared sandbox-init library to stay
# in sync with drop_capabilities(); each entry below is "bit:name".
# Caps documented as load-bearing in sandbox-init.sh (CAP_FOWNER,
# CAP_SETUID, CAP_SETGID) are inventoried but do not fail the test — see
# the kept-caps comment in sandbox-init.sh and the follow-up tracked
# under issue #3280.
DROP_LIST=$(docker run --rm --entrypoint "" "$IMAGE" \
bash -c "grep -oP '(?<=--drop=)[^ \\\\]+' /usr/local/lib/nemoclaw/sandbox-init.sh" 2>&1)
if [ -z "$DROP_LIST" ]; then
fail "could not extract --drop list from entrypoint"
else
OUT=$(docker run --rm --entrypoint "" \
--cap-add=CAP_SYS_ADMIN --cap-add=CAP_SYS_PTRACE \
"$IMAGE" \
bash -c "capsh --drop=${DROP_LIST} -- -c '
CAP_BND=\$(awk \"/^CapBnd:/{print \\\$2}\" /proc/self/status)
echo \"CapBnd=\$CAP_BND\"
val=\$((16#\$CAP_BND))
for entry in \
21:CAP_SYS_ADMIN:must-drop \
19:CAP_SYS_PTRACE:must-drop \
13:CAP_NET_RAW:must-drop \
10:CAP_NET_BIND_SERVICE:must-drop \
1:CAP_DAC_OVERRIDE:must-drop \
3:CAP_FOWNER:allowed \
7:CAP_SETUID:allowed \
6:CAP_SETGID:allowed; do
bit=\${entry%%:*}
rest=\${entry#*:}
name=\${rest%%:*}
class=\${rest#*:}
if [ \$(( (val >> bit) & 1 )) -ne 0 ]; then
echo \"\$name:\$class:PRESENT\"
else
echo \"\$name:\$class:ABSENT\"
fi
done
'")
echo "$OUT"

# Strategy: replay the two production drop stages inline by sourcing
# sandbox-init.sh and using its drop_capabilities() function and
# STEP_DOWN_PREFIX_SANDBOX array directly. This avoids depending on the
# entrypoint's volume mounts / config files while still exercising the
# exact production code paths.
OUT=$(docker run --rm --entrypoint "" \
--cap-add=CAP_SYS_ADMIN --cap-add=CAP_SYS_PTRACE \
"$IMAGE" \
bash -c '
source /usr/local/lib/nemoclaw/sandbox-init.sh
# Stage 1: drop_capabilities re-execs via capsh with the entrypoint-
# wide --drop list. The argument is the inner script that runs after
# the re-exec, which then does stage 2 (setpriv step-down) via
# STEP_DOWN_PREFIX_SANDBOX and prints CapBnd. We exec grep directly
# rather than wrapping in bash -c "awk ..." to avoid a triple-quoted
# awk script — the $2 in $print $2$ would otherwise be expanded by
# bash on its way through capsh re-exec.
drop_capabilities /bin/bash -c "
source /usr/local/lib/nemoclaw/sandbox-init.sh
exec \"\${STEP_DOWN_PREFIX_SANDBOX[@]}\" grep ^CapBnd: /proc/self/status
"
' 2>&1 || true)
echo "Sandbox-user CapBnd output: $OUT"

CAP_BND=$(echo "$OUT" | grep ^CapBnd: | head -1 | awk '{print $2}')
if [ -z "$CAP_BND" ]; then
fail "could not capture CapBnd from post-stepdown process: $OUT"
else
val=$((16#$CAP_BND))
bad=0
while IFS= read -r line; do
case "$line" in
*:must-drop:PRESENT)
bad=$((bad + 1))
fail "${line%%:*} still present in CapBnd after capsh drop"
;;
esac
done <<EOF
$OUT
EOF

for entry in \
21:CAP_SYS_ADMIN \
19:CAP_SYS_PTRACE \
13:CAP_NET_RAW \
10:CAP_NET_BIND_SERVICE \
1:CAP_DAC_OVERRIDE \
3:CAP_FOWNER \
7:CAP_SETUID \
6:CAP_SETGID; do
bit=${entry%%:*}
name=${entry#*:}
if [ $(((val >> bit) & 1)) -ne 0 ]; then
bad=$((bad + 1))
fail "$name still present in sandbox-user CapBnd (issue #3280)"
fi
done
if [ "$bad" -eq 0 ]; then
pass "entrypoint drops the issue #3280 dangerous-cap inventory (5 must-drop caps absent from CapBnd; 3 documented load-bearing caps allowed)"
pass "entrypoint drops the full issue #3280 dangerous-cap inventory (all 8 caps absent from sandbox-user CapBnd: 0x$CAP_BND)"
fi
fi

Expand Down
13 changes: 13 additions & 0 deletions test/nemoclaw-start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1211,6 +1211,13 @@ describe("nemoclaw-start gateway launch signal handling", () => {
"start_persistent_gateway_log_mirror() { sleep 30 & GATEWAY_LOG_PERSIST_PID=$!; }",
"start_auto_pair() { sleep 30 & AUTO_PAIR_PID=$!; }",
"cleanup_on_signal() { :; }",
// STEP_DOWN_PREFIX_* are normally populated by init_step_down_prefixes
// in sandbox-init.sh; the launch block uses STEP_DOWN_PREFIX_GATEWAY
// for the gateway exec. Initialize to the gosu fallback so the
// stubbed gosu() in fakeBin still receives the call (issue #3280
// follow-up).
"STEP_DOWN_PREFIX_SANDBOX=(gosu sandbox)",
"STEP_DOWN_PREFIX_GATEWAY=(gosu gateway)",
launchBlock(kind, gatewayLog),
kind === "root"
? `for _ in ${waitForLaunchLogIterations}; do [ -s ${JSON.stringify(gosuLog)} ] && [ -s ${JSON.stringify(openclawLog)} ] && break; sleep 0.1; done`
Expand Down Expand Up @@ -1775,6 +1782,12 @@ describe("Telegram diagnostics (#2766)", () => {
'chown_tree_no_symlink_follow() { :; }',
'start_persistent_gateway_log_mirror() { :; }',
'gosu() { shift; "$@"; }',
// STEP_DOWN_PREFIX_* are normally populated by init_step_down_prefixes
// in sandbox-init.sh; the test scaffolding doesn't source that, so
// initialize them here in their fallback form so the gosu() stub still
// gets invoked (issue #3280 follow-up).
"STEP_DOWN_PREFIX_SANDBOX=(gosu sandbox)",
"STEP_DOWN_PREFIX_GATEWAY=(gosu gateway)",
'validate_tmp_permissions() { printf "VALIDATE:%s\\n" "$*"; }',
'_SANDBOX_HOME=/sandbox',
`_SANDBOX_SAFETY_NET=${JSON.stringify(path.join(tmpDir, "safety.js"))}`,
Expand Down
Loading
Loading