Skip to content
Open
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
19 changes: 18 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ COPY nemoclaw-blueprint/ /opt/nemoclaw-blueprint/
WORKDIR /opt/nemoclaw
RUN npm ci --omit=dev

# Set up blueprint for local resolution
# Set up blueprint for local resolution.
# Blueprints are immutable at runtime; DAC protection (root ownership) is applied
# later since /sandbox/.nemoclaw is Landlock read_write for plugin state (#804).
RUN mkdir -p /sandbox/.nemoclaw/blueprints/0.1.0 \
&& cp -r /opt/nemoclaw-blueprint/* /sandbox/.nemoclaw/blueprints/0.1.0/

Expand Down Expand Up @@ -146,6 +148,21 @@ RUN sha256sum /sandbox/.openclaw/openclaw.json > /sandbox/.openclaw/.config-hash
&& chmod 444 /sandbox/.openclaw/.config-hash \
&& chown root:root /sandbox/.openclaw/.config-hash

# DAC-protect .nemoclaw directory: /sandbox/.nemoclaw is Landlock read_write
# (for plugin state/config), but the parent and blueprints are immutable at
# runtime. Root ownership on the parent prevents the agent from renaming or
# replacing the root-owned blueprints directory. Only state/, migration/,
# snapshots/, and config.json are sandbox-owned for runtime writes.
# Ref: https://github.com/NVIDIA/NemoClaw/issues/804
RUN chown root:root /sandbox/.nemoclaw \
&& chmod 755 /sandbox/.nemoclaw \
&& chown -R root:root /sandbox/.nemoclaw/blueprints \
&& chmod -R 755 /sandbox/.nemoclaw/blueprints \
&& mkdir -p /sandbox/.nemoclaw/state /sandbox/.nemoclaw/migration /sandbox/.nemoclaw/snapshots /sandbox/.nemoclaw/staging \
&& chown sandbox:sandbox /sandbox/.nemoclaw/state /sandbox/.nemoclaw/migration /sandbox/.nemoclaw/snapshots /sandbox/.nemoclaw/staging \
&& touch /sandbox/.nemoclaw/config.json \
&& chown sandbox:sandbox /sandbox/.nemoclaw/config.json

# Entrypoint runs as root to start the gateway as the gateway user,
# then drops to sandbox for agent commands. See nemoclaw-start.sh.
ENTRYPOINT ["/usr/local/bin/nemoclaw-start"]
Expand Down
16 changes: 16 additions & 0 deletions Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,22 @@ RUN mkdir -p /sandbox/.openclaw-data/agents/main/agent \
&& ln -s /sandbox/.openclaw-data/update-check.json /sandbox/.openclaw/update-check.json \
&& chown -R sandbox:sandbox /sandbox/.openclaw /sandbox/.openclaw-data

# Pre-create shell init files for the sandbox user.
# The /sandbox home directory is Landlock read-only at runtime (#804), so these
# files must exist at build time. Runtime proxy config is written by the
# entrypoint to /tmp/nemoclaw-proxy-env.sh (root-owned, sticky-bit protected)
# and sourced from here on every interactive session.
# hadolint ignore=SC2028
RUN printf '%s\n' \
'# Source runtime proxy config (Landlock read-only home, #804)' \
'[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh' \
> /sandbox/.bashrc \
&& printf '%s\n' \
'# Source runtime proxy config (Landlock read-only home, #804)' \
'[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh' \
> /sandbox/.profile \
&& chown sandbox:sandbox /sandbox/.bashrc /sandbox/.profile

# Install OpenClaw CLI + PyYAML for inline Python scripts in e2e tests.
# When bumping the openclaw version, rebuild this base image.
RUN npm install -g openclaw@2026.3.11 \
Expand Down
24 changes: 24 additions & 0 deletions docs/deployment/sandbox-hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,32 @@ services:
> capability dropping in your `docker run` flags, Compose file, or Kubernetes
> `securityContext`.

## Read-Only Home Directory

The sandbox Landlock policy restricts `/sandbox` (the agent's home directory) to read-only access.
Only explicitly declared directories are writable:

| Path | Access | Purpose |
|------|--------|---------|
| `/sandbox` | read-only | Home directory — agents cannot create arbitrary files |
| `/sandbox/.openclaw` | read-only | Immutable gateway config (auth tokens, CORS) |
| `/sandbox/.openclaw-data` | read-write | Agent state, workspace, plugins (via symlinks) |
| `/sandbox/.nemoclaw` | read-write | Plugin state and config; blueprints within are DAC-protected (root-owned) |
| `/tmp` | read-write | Temporary files and logs |

This prevents agents from:

- Writing scripts and executing them later
- Modifying their own runtime environment
- Creating hidden files that persist across invocations
- Using writable space for data staging before exfiltration

The image build pre-creates shell init files `.bashrc` and `.profile`.
These files source runtime proxy configuration from `/tmp/nemoclaw-proxy-env.sh`.

## References

- [#804](https://github.com/NVIDIA/NemoClaw/issues/804) — Read-only home directory
- [#807](https://github.com/NVIDIA/NemoClaw/issues/807) — gcc in sandbox image
- [#808](https://github.com/NVIDIA/NemoClaw/issues/808) — netcat in sandbox image
- [#809](https://github.com/NVIDIA/NemoClaw/issues/809) — No process limit
Expand Down
16 changes: 14 additions & 2 deletions nemoclaw-blueprint/policies/openclaw-sandbox.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@
version: 1

filesystem_policy:
include_workdir: true
# SECURITY: must be false. When true, OpenShell adds WORKDIR (/sandbox) to
# read_write automatically, which overrides our read_only entry below because
# Landlock grants the union of all matching rules. All needed writable paths
# are declared explicitly in read_write.
# Ref: https://github.com/NVIDIA/NemoClaw/issues/804
include_workdir: false
read_only:
- /usr
- /lib
Expand All @@ -25,16 +30,23 @@ filesystem_policy:
- /app
- /etc
- /var/log
- /sandbox # Home directory — read-only to prevent agents
# from creating arbitrary files or modifying
# their own runtime environment. Writable state
# is restricted to /sandbox/.openclaw-data.
# Ref: https://github.com/NVIDIA/NemoClaw/issues/804
- /sandbox/.openclaw # Immutable gateway config — prevents agent
# from tampering with auth tokens or CORS.
# Writable state (agents, plugins) lives in
# /sandbox/.openclaw-data via symlinks.
# Ref: https://github.com/NVIDIA/NemoClaw/issues/514
read_write:
- /sandbox
- /tmp
- /dev/null
- /sandbox/.openclaw-data # Writable agent/plugin state (symlinked from .openclaw)
- /sandbox/.nemoclaw # Plugin state and config (state.ts, config.ts).
# Blueprints here are built at image time and
# owned by root (DAC-protected).

landlock:
compatibility: best_effort
Expand Down
91 changes: 45 additions & 46 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ fi
# into commands executed by the entrypoint or auto-pair watcher.
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

# Redirect tool caches and state to /tmp so they don't fail on the read-only
# /sandbox home directory (#804). Without these, tools would try to create
# dotfiles (~/.npm, ~/.cache, ~/.bash_history, ~/.gitconfig, ~/.local, ~/.claude)
# in the Landlock read-only home and fail.
export npm_config_cache="/tmp/.npm-cache"
export XDG_CACHE_HOME="/tmp/.cache"
export NODE_REPL_HISTORY="/tmp/.node_repl_history"
export HISTFILE="/tmp/.bash_history"
export GIT_CONFIG_GLOBAL="/tmp/.gitconfig"
export PYTHONUSERBASE="/tmp/.local"
export CLAUDE_CONFIG_DIR="/tmp/.claude"

# ── Drop unnecessary Linux capabilities ──────────────────────────
# 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 Expand Up @@ -231,60 +243,47 @@ export no_proxy="$_NO_PROXY_VAL"
# OpenShell re-injects narrow NO_PROXY/no_proxy=127.0.0.1,localhost,::1 every
# time a user connects via `openshell sandbox connect`. The connect path spawns
# `/bin/bash -i` (interactive, non-login), which sources ~/.bashrc — NOT
# ~/.profile or /etc/profile.d/*. Write the full proxy config to ~/.bashrc so
# interactive sessions see the correct values.
# ~/.profile or /etc/profile.d/*.
#
# The /sandbox home directory is Landlock read-only (#804), so we write the proxy
# config to /tmp/nemoclaw-proxy-env.sh. The pre-built .bashrc and .profile
# source this file automatically.
#
# SECURITY: /tmp has the sticky bit, so when running as root the sandbox user
# cannot delete or replace this root-owned file. In non-root mode privilege
# separation is already disabled, so this is an accepted limitation.
#
# Both uppercase and lowercase variants are required: Node.js undici prefers
# lowercase (no_proxy) over uppercase (NO_PROXY) when both are set.
# curl/wget use uppercase. gRPC C-core uses lowercase.
#
# Also write to ~/.profile for login-shell paths (e.g. `sandbox create -- cmd`
# which spawns `bash -lc`).
#
# Idempotency: begin/end markers delimit the block so it can be replaced
# on restart if NEMOCLAW_PROXY_HOST/PORT change, without duplicating.
_PROXY_MARKER_BEGIN="# nemoclaw-proxy-config begin"
_PROXY_MARKER_END="# nemoclaw-proxy-config end"
_PROXY_SNIPPET="${_PROXY_MARKER_BEGIN}
export HTTP_PROXY=\"$_PROXY_URL\"
export HTTPS_PROXY=\"$_PROXY_URL\"
export NO_PROXY=\"$_NO_PROXY_VAL\"
export http_proxy=\"$_PROXY_URL\"
export https_proxy=\"$_PROXY_URL\"
export no_proxy=\"$_NO_PROXY_VAL\"
${_PROXY_MARKER_END}"

if [ "$(id -u)" -eq 0 ]; then
_SANDBOX_HOME=$(getent passwd sandbox 2>/dev/null | cut -d: -f6)
_SANDBOX_HOME="${_SANDBOX_HOME:-/sandbox}"
else
_SANDBOX_HOME="${HOME:-/sandbox}"
fi

_write_proxy_snippet() {
local target="$1"
if [ -f "$target" ] && grep -qF "$_PROXY_MARKER_BEGIN" "$target" 2>/dev/null; then
local tmp
tmp="$(mktemp)"
awk -v b="$_PROXY_MARKER_BEGIN" -v e="$_PROXY_MARKER_END" \
'$0==b{s=1;next} $0==e{s=0;next} !s' "$target" >"$tmp"
printf '%s\n' "$_PROXY_SNIPPET" >>"$tmp"
cat "$tmp" >"$target"
rm -f "$tmp"
return 0
fi
printf '\n%s\n' "$_PROXY_SNIPPET" >>"$target"
}

if [ -w "$_SANDBOX_HOME" ]; then
_write_proxy_snippet "${_SANDBOX_HOME}/.bashrc"
_write_proxy_snippet "${_SANDBOX_HOME}/.profile"
fi
_PROXY_ENV_FILE="/tmp/nemoclaw-proxy-env.sh"
# Remove any pre-existing file/symlink to prevent symlink-following attacks,
# then write a fresh file.
rm -f "$_PROXY_ENV_FILE" 2>/dev/null || true
cat >"$_PROXY_ENV_FILE" <<PROXYEOF
# Proxy configuration (overrides narrow OpenShell defaults on connect)
export HTTP_PROXY="$_PROXY_URL"
export HTTPS_PROXY="$_PROXY_URL"
export NO_PROXY="$_NO_PROXY_VAL"
export http_proxy="$_PROXY_URL"
export https_proxy="$_PROXY_URL"
export no_proxy="$_NO_PROXY_VAL"
# Tool cache redirects — /sandbox is Landlock read-only (#804)
export npm_config_cache="/tmp/.npm-cache"
export XDG_CACHE_HOME="/tmp/.cache"
export NODE_REPL_HISTORY="/tmp/.node_repl_history"
export HISTFILE="/tmp/.bash_history"
export GIT_CONFIG_GLOBAL="/tmp/.gitconfig"
export PYTHONUSERBASE="/tmp/.local"
export CLAUDE_CONFIG_DIR="/tmp/.claude"
PROXYEOF
chmod 644 "$_PROXY_ENV_FILE"

# ── Main ─────────────────────────────────────────────────────────

echo 'Setting up NemoClaw...'
[ -f .env ] && chmod 600 .env
# Best-effort: .env may not exist, and /sandbox is Landlock read-only (#804).
[ -f .env ] && chmod 600 .env 2>/dev/null || true

# ── Non-root fallback ──────────────────────────────────────────
# OpenShell runs containers with --security-opt=no-new-privileges, which
Expand Down
Loading
Loading