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
49 changes: 31 additions & 18 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,7 @@ RUN mkdir -p /sandbox/.nemoclaw/blueprints/0.1.0 \

# Copy startup script and shared sandbox initialisation library
COPY scripts/lib/sandbox-init.sh /usr/local/lib/nemoclaw/sandbox-init.sh
COPY scripts/lib/sandbox-rlimits.sh /usr/local/lib/nemoclaw/sandbox-rlimits.sh
COPY scripts/lib/openclaw_device_approval_policy.py /usr/local/lib/nemoclaw/openclaw_device_approval_policy.py
COPY scripts/lib/clean_runtime_shell_env_shim.py /usr/local/lib/nemoclaw/clean_runtime_shell_env_shim.py
COPY scripts/nemoclaw-start.sh /usr/local/bin/nemoclaw-start
Expand All @@ -548,6 +549,7 @@ RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-codex-acp \
/scripts/generate-openclaw-config.mts \
/src/lib/messaging/applier/build/messaging-build-applier.mts \
&& chmod -R a+rX /src/lib/messaging \
&& chmod 444 /usr/local/lib/nemoclaw/sandbox-rlimits.sh \
&& chmod 644 /usr/local/lib/nemoclaw/openclaw_device_approval_policy.py \
/usr/local/lib/nemoclaw/clean_runtime_shell_env_shim.py \
&& if [ -d /usr/local/lib/nemoclaw/preloads-compiled-channels ]; then \
Expand Down Expand Up @@ -935,33 +937,44 @@ RUN set -eu; \
chmod 660 /sandbox/.openclaw/openclaw.json; \
fi

# System-wide proxy hooks for shells where ~/.bashrc / ~/.profile aren't
# System-wide shell hooks for shells where ~/.bashrc / ~/.profile aren't
# sourced (e.g. `bash -ic` / `bash -lc` invoked under a different user or
# without HOME=/sandbox). Defined in Dockerfile.base; replayed here so the
# fix applies before the GHCR base image catches up. Idempotent — `mv` of
# a freshly-rebuilt /etc/bash.bashrc is harmless once the base layer
# already includes the prepended hook (the cat | mv block just rewrites
# with the same first line).
# without HOME=/sandbox). Dockerfile.base is the source of truth. This final
# image replay only repairs stale published bases that predate the v0.0.69
# base layer and therefore lack /etc/profile.d/nemoclaw-rlimits.sh, the
# /etc/bash.bashrc hook, or the root-owned helper mode. Remove this block after
# the minimum supported OpenClaw sandbox base tag is v0.0.69 or newer and those
# three artifacts are guaranteed by the base image and covered by
# test/sandbox-provisioning.test.ts.
# Ref: https://github.com/NVIDIA/NemoClaw/issues/2704
# hadolint ignore=SC2028,DL4006
RUN if ! grep -q "/tmp/nemoclaw-proxy-env.sh" /etc/profile.d/nemoclaw-proxy.sh 2>/dev/null; then \
RUN chmod 444 /usr/local/lib/nemoclaw/sandbox-rlimits.sh \
&& if ! grep -q "sandbox-rlimits.sh" /etc/profile.d/nemoclaw-rlimits.sh 2>/dev/null; then \
printf '%s\n' \
'# NemoClaw sandbox resource limits — see sandbox-rlimits.sh (#2173)' \
'[ -f /usr/local/lib/nemoclaw/sandbox-rlimits.sh ] && . /usr/local/lib/nemoclaw/sandbox-rlimits.sh && harden_resource_limits --quiet && verify_resource_limits' \
> /etc/profile.d/nemoclaw-rlimits.sh \
&& chmod 444 /etc/profile.d/nemoclaw-rlimits.sh; \
fi \
&& if ! grep -q "/tmp/nemoclaw-proxy-env.sh" /etc/profile.d/nemoclaw-proxy.sh 2>/dev/null; then \
printf '%s\n' \
'# NemoClaw runtime proxy config — see /tmp/nemoclaw-proxy-env.sh (#2704)' \
'[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh' \
> /etc/profile.d/nemoclaw-proxy.sh \
&& chmod 444 /etc/profile.d/nemoclaw-proxy.sh; \
fi \
&& if ! head -2 /etc/bash.bashrc | grep -q "/tmp/nemoclaw-proxy-env.sh"; then \
chmod 644 /etc/bash.bashrc 2>/dev/null || true; \
{ printf '%s\n' \
'# NemoClaw runtime proxy config — see /tmp/nemoclaw-proxy-env.sh (#2704)' \
'[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh' \
''; \
cat /etc/bash.bashrc; \
} > /etc/bash.bashrc.new \
&& mv /etc/bash.bashrc.new /etc/bash.bashrc \
&& chmod 444 /etc/bash.bashrc; \
fi
&& (chmod 644 /etc/bash.bashrc 2>/dev/null || true) \
&& { printf '%s\n' \
'# NemoClaw runtime proxy config — see /tmp/nemoclaw-proxy-env.sh (#2704)' \
'[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh' \
'' \
'# NemoClaw sandbox resource limits — see sandbox-rlimits.sh (#2173)' \
'[ -f /usr/local/lib/nemoclaw/sandbox-rlimits.sh ] && . /usr/local/lib/nemoclaw/sandbox-rlimits.sh && harden_resource_limits --quiet && verify_resource_limits' \
''; \
grep -Ev 'NemoClaw runtime proxy config|nemoclaw-proxy-env[.]sh|NemoClaw sandbox resource limits|sandbox-rlimits[.]sh' /etc/bash.bashrc || true; \
} > /etc/bash.bashrc.new \
&& mv /etc/bash.bashrc.new /etc/bash.bashrc \
&& chmod 444 /etc/bash.bashrc

# Pin config hash at build time so the entrypoint can verify integrity.
RUN sha256sum /sandbox/.openclaw/openclaw.json > /sandbox/.openclaw/.config-hash \
Expand Down
13 changes: 12 additions & 1 deletion Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ RUN mkdir -p /sandbox/.openclaw/agents/main/agent \
&& chmod -R g+w /sandbox/.openclaw \
&& find /sandbox/.openclaw -type d -exec chmod g+s {} +

COPY scripts/lib/sandbox-rlimits.sh /usr/local/lib/nemoclaw/sandbox-rlimits.sh

# Pre-create shell init files for the sandbox user. Runtime environment hooks
# are installed system-wide below; user rc files stay clean and locked so
# per-user startup files are not part of the trust boundary.
Expand Down Expand Up @@ -161,14 +163,23 @@ RUN printf '%s\n' \
# Both files are root-owned and not writable by the sandbox user.
# Ref: https://github.com/NVIDIA/NemoClaw/issues/2704
# hadolint ignore=SC2028
RUN printf '%s\n' \
RUN chmod 444 /usr/local/lib/nemoclaw/sandbox-rlimits.sh \
&& printf '%s\n' \
'# NemoClaw sandbox resource limits — see sandbox-rlimits.sh (#2173)' \
'[ -f /usr/local/lib/nemoclaw/sandbox-rlimits.sh ] && . /usr/local/lib/nemoclaw/sandbox-rlimits.sh && harden_resource_limits --quiet && verify_resource_limits' \
> /etc/profile.d/nemoclaw-rlimits.sh \
&& chmod 444 /etc/profile.d/nemoclaw-rlimits.sh \
&& printf '%s\n' \
'# NemoClaw runtime proxy config — see /tmp/nemoclaw-proxy-env.sh (#2704)' \
'[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh' \
> /etc/profile.d/nemoclaw-proxy.sh \
&& chmod 444 /etc/profile.d/nemoclaw-proxy.sh \
&& { printf '%s\n' \
'# NemoClaw runtime proxy config — see /tmp/nemoclaw-proxy-env.sh (#2704)' \
'[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh' \
'' \
'# NemoClaw sandbox resource limits — see sandbox-rlimits.sh (#2173)' \
'[ -f /usr/local/lib/nemoclaw/sandbox-rlimits.sh ] && . /usr/local/lib/nemoclaw/sandbox-rlimits.sh && harden_resource_limits --quiet && verify_resource_limits' \
''; \
cat /etc/bash.bashrc; \
} > /etc/bash.bashrc.new \
Expand Down
26 changes: 25 additions & 1 deletion agents/hermes/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,33 @@ RUN chmod -R a+rX /opt/nemoclaw-blueprint/
# gateway recovery script (src/lib/agent/runtime.ts) so `sandbox recover` enforces
# the same boundary as the initial entrypoint.
COPY scripts/lib/sandbox-init.sh /usr/local/lib/nemoclaw/sandbox-init.sh
COPY scripts/lib/sandbox-rlimits.sh /usr/local/lib/nemoclaw/sandbox-rlimits.sh
COPY agents/hermes/start.sh /usr/local/bin/nemoclaw-start
COPY agents/hermes/validate-env-secret-boundary.py /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py
RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/lib/nemoclaw/sandbox-init.sh /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py
# Dockerfile.base is the source of truth for rlimit hooks. This Hermes replay
# only repairs stale bases predating the v0.0.69 base layer, which may lack the
# profile hook, bashrc hook, or root-owned helper mode. Remove it once the
# minimum supported Hermes sandbox base tag guarantees those artifacts and
# test/sandbox-rlimit-hooks.test.ts covers that base.
RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/lib/nemoclaw/sandbox-init.sh /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py \
&& chmod 444 /usr/local/lib/nemoclaw/sandbox-rlimits.sh \
&& mkdir -p /etc/profile.d \
&& printf '%s\n' \
'# NemoClaw sandbox resource limits — see sandbox-rlimits.sh (#2173)' \
'[ -f /usr/local/lib/nemoclaw/sandbox-rlimits.sh ] && . /usr/local/lib/nemoclaw/sandbox-rlimits.sh && harden_resource_limits --quiet && verify_resource_limits' \
> /etc/profile.d/nemoclaw-rlimits.sh \
&& chmod 444 /etc/profile.d/nemoclaw-rlimits.sh \
&& (chmod 644 /etc/bash.bashrc 2>/dev/null || true) \
&& { printf '%s\n' \
'# NemoClaw sandbox resource limits — see sandbox-rlimits.sh (#2173)' \
'[ -f /usr/local/lib/nemoclaw/sandbox-rlimits.sh ] && . /usr/local/lib/nemoclaw/sandbox-rlimits.sh && harden_resource_limits --quiet && verify_resource_limits' \
''; \
if [ -f /etc/bash.bashrc ]; then \
grep -Ev 'NemoClaw sandbox resource limits|sandbox-rlimits[.]sh' /etc/bash.bashrc || true; \
fi; \
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} > /etc/bash.bashrc.new \
&& mv /etc/bash.bashrc.new /etc/bash.bashrc \
&& chmod 444 /etc/bash.bashrc

# Wrap the hermes CLI so the runtime env secret boundary is enforced for
# `hermes gateway` no matter how it is invoked. The entrypoint guard alone left
Expand Down
14 changes: 10 additions & 4 deletions agents/hermes/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,30 @@

set -euo pipefail

# SECURITY: Lock down PATH before resolving or sourcing root startup helpers.
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

# ── Source shared sandbox initialisation library ─────────────────
# Single source of truth for security-sensitive primitives shared with
# scripts/nemoclaw-start.sh (OpenClaw). Ref: #2277
# Installed location (container): /usr/local/lib/nemoclaw/sandbox-init.sh
# Dev fallback: scripts/lib/sandbox-init.sh relative to this script.
_SANDBOX_INIT="/usr/local/lib/nemoclaw/sandbox-init.sh"
if [ ! -f "$_SANDBOX_INIT" ]; then
_SANDBOX_INIT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../scripts/lib/sandbox-init.sh"
_HERMES_START_SOURCE="${BASH_SOURCE[0]}"
_HERMES_START_DIR="${_HERMES_START_SOURCE%/*}"
if [ "$_HERMES_START_DIR" = "$_HERMES_START_SOURCE" ]; then
_HERMES_START_DIR="."
fi
_SANDBOX_INIT="$(cd "$_HERMES_START_DIR" && pwd)/../../scripts/lib/sandbox-init.sh"
unset _HERMES_START_SOURCE _HERMES_START_DIR
fi
# shellcheck source=scripts/lib/sandbox-init.sh
source "$_SANDBOX_INIT"

# Harden RLIMITs (nproc #809 + nofile #4527) as root PID 1, before any step-down.
harden_resource_limits

# SECURITY: Lock down PATH
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

if [ -d /opt/hermes/hermes_cli/web_dist ]; then
export HERMES_WEB_DIST="${HERMES_WEB_DIST:-/opt/hermes/hermes_cli/web_dist}"
fi
Expand Down
41 changes: 12 additions & 29 deletions scripts/lib/sandbox-init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
# security fix applied here protects both agents automatically.
#
# Usage (from an entrypoint script):
# SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# SCRIPT_SOURCE="${BASH_SOURCE[0]}"
# SCRIPT_DIR="${SCRIPT_SOURCE%/*}"
# # shellcheck source=scripts/lib/sandbox-init.sh
# source "${SCRIPT_DIR}/../scripts/lib/sandbox-init.sh" # adjust path
#
Expand All @@ -20,6 +21,16 @@
[ -z "${_SANDBOX_INIT_LOADED:-}" ] || return 0
_SANDBOX_INIT_LOADED=1

_SANDBOX_INIT_SOURCE="${BASH_SOURCE[0]}"
_SANDBOX_INIT_DIR="${_SANDBOX_INIT_SOURCE%/*}"
if [ "$_SANDBOX_INIT_DIR" = "$_SANDBOX_INIT_SOURCE" ]; then
_SANDBOX_INIT_DIR="."
fi
_SANDBOX_INIT_DIR="$(cd "$_SANDBOX_INIT_DIR" && pwd)"
unset _SANDBOX_INIT_SOURCE
# shellcheck source=scripts/lib/sandbox-rlimits.sh
source "${_SANDBOX_INIT_DIR}/sandbox-rlimits.sh"

# ── /tmp trust boundary map ──────────────────────────────────────
# Files in /tmp that cross user boundaries. Every file sourced by system-wide
# shell hooks MUST be root-owned 444 in root mode.
Expand Down Expand Up @@ -220,34 +231,6 @@ lock_config_after_write() {
done
}

# ── Resource limits (RLIMITs) ────────────────────────────────────
# Harden RLIMITs at PID 1 (root) so the caps are inherited by every descendant
# and cannot be raised after the privilege step-down. nproc (#809) prevents
# fork bombs; nofile (#4527) caps open file descriptors so the sandbox no
# longer inherits the Docker daemon default (~1048576), which can exceed the
# host runtime limit. Best-effort: some container runtimes (e.g. brev, Docker
# Desktop, WSL) reject ulimit changes with "Invalid argument"; warn but do not
# block startup. Set the soft limit before the hard limit (ordering matters;
# see #951). Hard==soft makes the cap unraisable: raising the hard RLIMIT
# requires CAP_SYS_RESOURCE, which the unprivileged stepped-down agent never
# holds. NOTE: this only covers the PID 1 entrypoint process tree (gateway +
# agent); 'openshell sandbox connect' shells are spawned outside this tree and
# still inherit the runtime default (NVIDIA/OpenShell#1452).
harden_resource_limits() {
if ! ulimit -Su 512 2>/dev/null; then
echo "[SECURITY] Could not set soft nproc limit (container runtime may restrict ulimit)" >&2
fi
if ! ulimit -Hu 512 2>/dev/null; then
echo "[SECURITY] Could not set hard nproc limit (container runtime may restrict ulimit)" >&2
fi
if ! ulimit -Sn 65536 2>/dev/null; then
echo "[SECURITY] Could not set soft nofile limit (container runtime may restrict ulimit)" >&2
fi
if ! ulimit -Hn 65536 2>/dev/null; then
echo "[SECURITY] Could not set hard nofile limit (container runtime may restrict ulimit)" >&2
fi
}

# ── 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
Expand Down
95 changes: 95 additions & 0 deletions scripts/lib/sandbox-rlimits.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# shellcheck shell=bash
#
# Shared NemoClaw sandbox RLIMIT defaults.

NEMOCLAW_SANDBOX_NPROC_LIMIT=512
NEMOCLAW_SANDBOX_NOFILE_LIMIT=65536

_nemoclaw_set_resource_limit() {
_nemoclaw_limit_flag="$1"
_nemoclaw_limit_value="$2"
_nemoclaw_limit_label="$3"
_nemoclaw_limit_quiet="${4:-}"

if ! builtin ulimit "-S${_nemoclaw_limit_flag}" "$_nemoclaw_limit_value" 2>/dev/null; then
if [ "$_nemoclaw_limit_quiet" != "--quiet" ]; then
echo "[SECURITY] Could not set soft ${_nemoclaw_limit_label} limit (container runtime may restrict ulimit)" >&2
fi
fi
if ! builtin ulimit "-H${_nemoclaw_limit_flag}" "$_nemoclaw_limit_value" 2>/dev/null; then
if [ "$_nemoclaw_limit_quiet" != "--quiet" ]; then
echo "[SECURITY] Could not set hard ${_nemoclaw_limit_label} limit (container runtime may restrict ulimit)" >&2
fi
fi

unset _nemoclaw_limit_flag _nemoclaw_limit_value _nemoclaw_limit_label _nemoclaw_limit_quiet
}

_nemoclaw_is_decimal_limit() {
case "$1" in
"" | *[!0-9]*)
return 1
;;
*)
return 0
;;
esac
}

_nemoclaw_verify_resource_limit() {
_nemoclaw_limit_flag="$1"
_nemoclaw_limit_value="$2"
_nemoclaw_limit_label="$3"
_nemoclaw_limit_quiet="${4:-}"
_nemoclaw_limit_status=0

for _nemoclaw_limit_bound in soft hard; do
case "$_nemoclaw_limit_bound" in
soft)
_nemoclaw_limit_mode="S"
;;
hard)
_nemoclaw_limit_mode="H"
;;
esac
_nemoclaw_effective_limit="$(builtin ulimit "-${_nemoclaw_limit_mode}${_nemoclaw_limit_flag}" 2>/dev/null || printf '%s' unknown)"

if ! _nemoclaw_is_decimal_limit "$_nemoclaw_effective_limit" \
|| [ "$_nemoclaw_effective_limit" -gt "$_nemoclaw_limit_value" ]; then
if [ "$_nemoclaw_limit_quiet" != "--quiet" ]; then
echo "[SECURITY] Effective ${_nemoclaw_limit_bound} ${_nemoclaw_limit_label} limit is ${_nemoclaw_effective_limit}; expected <= ${_nemoclaw_limit_value} (container runtime may restrict ulimit)" >&2
fi
_nemoclaw_limit_status=1
fi
done

_nemoclaw_limit_return="$_nemoclaw_limit_status"
unset _nemoclaw_limit_flag _nemoclaw_limit_value _nemoclaw_limit_label _nemoclaw_limit_quiet
unset _nemoclaw_limit_status _nemoclaw_limit_bound _nemoclaw_limit_mode _nemoclaw_effective_limit
return "$_nemoclaw_limit_return"
}

# Harden RLIMITs at PID 1 (root) so caps are inherited by entrypoint descendants
# and cannot be raised after privilege step-down. The same function is also
# sourced by connect-shell hooks, because OpenShell connect shells are spawned
# outside the PID 1 tree and therefore do not inherit those lowered limits.
harden_resource_limits() {
_nemoclaw_rlimit_quiet="${1:-}"
_nemoclaw_set_resource_limit u "$NEMOCLAW_SANDBOX_NPROC_LIMIT" nproc "$_nemoclaw_rlimit_quiet"
_nemoclaw_set_resource_limit n "$NEMOCLAW_SANDBOX_NOFILE_LIMIT" nofile "$_nemoclaw_rlimit_quiet"
unset _nemoclaw_rlimit_quiet
}

verify_resource_limits() {
local _nemoclaw_rlimit_quiet="${1:-}"
local _nemoclaw_rlimit_status=0

_nemoclaw_verify_resource_limit u "$NEMOCLAW_SANDBOX_NPROC_LIMIT" nproc "$_nemoclaw_rlimit_quiet" \
|| _nemoclaw_rlimit_status=1
_nemoclaw_verify_resource_limit n "$NEMOCLAW_SANDBOX_NOFILE_LIMIT" nofile "$_nemoclaw_rlimit_quiet" \
|| _nemoclaw_rlimit_status=1

return "$_nemoclaw_rlimit_status"
}
4 changes: 4 additions & 0 deletions src/lib/sandbox/build-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ function stageOptimizedSandboxBuildContext(
path.join(rootDir, "scripts", "lib", "sandbox-init.sh"),
path.join(stagedScriptsDir, "lib", "sandbox-init.sh"),
);
fs.copyFileSync(
path.join(rootDir, "scripts", "lib", "sandbox-rlimits.sh"),
path.join(stagedScriptsDir, "lib", "sandbox-rlimits.sh"),
);
fs.copyFileSync(
path.join(rootDir, "scripts", "lib", "openclaw_device_approval_policy.py"),
path.join(stagedScriptsDir, "lib", "openclaw_device_approval_policy.py"),
Expand Down
Loading
Loading