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
Original file line number Diff line number Diff line change
Expand Up @@ -209,32 +209,21 @@ The container mounts system directories read-only to prevent the agent from modi

### Read-Only `.openclaw` Config

The `/sandbox/.openclaw` directory contains the OpenClaw gateway configuration (model routing, CORS settings, channel config).
The gateway auth token is **not** stored in this directory — it is generated at container startup and passed via the `OPENCLAW_GATEWAY_TOKEN` environment variable only to the gateway process (which runs as the `gateway` user).

The token file location depends on the startup mode:

- **Root mode** (production): `/run/nemoclaw/gateway-token` (`gateway:gateway 0400`).
The sandbox user cannot read this file (different uid), cannot read the gateway process env (`/proc/pid/environ` is uid-gated), and `no-new-privileges` prevents escalation.
- **Non-root mode** (dev/fallback): `/tmp/.runtime/nemoclaw/gateway-token` (`sandbox:sandbox 0400`).
Without uid separation the sandbox user owns the file, matching the pre-externalization security posture.
The token is not exported to the shell env or written to rc files.

The container mounts `.openclaw` read-only while writable agent state (plugins, agent data) lives in `/sandbox/.openclaw-data` through symlinks.
The `/sandbox/.openclaw` directory contains the OpenClaw gateway configuration, including auth tokens and CORS settings.
The container mounts it read-only while writable agent state (plugins, agent data) lives in `/sandbox/.openclaw-data` through symlinks.

Multiple defense layers protect this directory:

- **DAC permissions.** Root owns the directory and `openclaw.json` with `chmod 444`, so the sandbox user cannot write to them.
- **Immutable flag.** The entrypoint applies `chattr +i` to the directory and all symlinks, preventing modification even if other controls fail.
- **Symlink validation.** At startup, the entrypoint verifies every symlink in `.openclaw` points to the expected `.openclaw-data` target. If any symlink points elsewhere, the container refuses to start.
- **Config integrity hash.** The build process pins a SHA256 hash of `openclaw.json`. The entrypoint verifies it at startup and refuses to start if the hash does not match.
- **Externalized gateway token.** The gateway auth token never appears in `openclaw.json`. It is generated at container startup, written to a mode-dependent token file, and passed to the gateway process via an environment variable. In root mode, the token file is owned by the `gateway` user and unreadable by the sandbox agent.

| Aspect | Detail |
|---|---|
| Default | The container mounts `/sandbox/.openclaw` as read-only, root-owned, immutable, and integrity-verified at startup. `/sandbox/.openclaw-data` remains writable. The gateway auth token is stored separately: `/run/nemoclaw/gateway-token` in root mode (`gateway:gateway 0400`) or `/tmp/.runtime/nemoclaw/gateway-token` in non-root mode (`sandbox:sandbox 0400`). |
| Default | The container mounts `/sandbox/.openclaw` as read-only, root-owned, immutable, and integrity-verified at startup. `/sandbox/.openclaw-data` remains writable. |
| What you can change | Move `/sandbox/.openclaw` from `read_only` to `read_write` in the policy file. |
| Risk if relaxed | A writable `.openclaw` directory lets the agent modify its own gateway config: disabling CORS or redirecting inference to an attacker-controlled endpoint. This is the single most dangerous filesystem change. |
| Risk if relaxed | A writable `.openclaw` directory lets the agent modify its own gateway config: disabling CORS, changing auth tokens, or redirecting inference to an attacker-controlled endpoint. This is the single most dangerous filesystem change. |
| Recommendation | Never make `/sandbox/.openclaw` writable. |

### Writable Paths
Expand Down
33 changes: 7 additions & 26 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,8 @@ ARG NEMOCLAW_DISCORD_GUILDS_B64=e30=
# Set to "1" to disable device-pairing auth (development/headless only).
# Default: "0" (device auth enabled — secure by default).
ARG NEMOCLAW_DISABLE_DEVICE_AUTH=0
# Unique per build to bust Docker cache for config materialization layers.
# Unique per build to ensure each image gets a fresh auth token.
# Pass --build-arg NEMOCLAW_BUILD_ID=$(date +%s) to bust the cache.
# Gateway auth token is generated at container startup by the entrypoint.
ARG NEMOCLAW_BUILD_ID=default
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Sandbox egress proxy host/port. Defaults match the OpenShell-injected
# gateway (10.200.0.1:3128). Operators on non-default networks can override
Expand Down Expand Up @@ -268,18 +267,11 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \
WORKDIR /sandbox
USER sandbox

# Write openclaw.json with gateway config but WITHOUT the real auth token.
# The gateway auth token is generated at container startup by the entrypoint
# and passed via OPENCLAW_GATEWAY_TOKEN env var only to the gateway process
# (running as 'gateway' user). The token file location depends on startup mode:
# Root mode: /run/nemoclaw/gateway-token (gateway:gateway 0400)
# Non-root mode: $XDG_RUNTIME_DIR/nemoclaw/gateway-token (sandbox:sandbox 0400)
# In root mode the sandbox user cannot read the env var (/proc/pid/environ is
# uid-gated) or the file (wrong uid, no-new-privileges blocks escalation).
# See: scripts/nemoclaw-start.sh generate_gateway_token()
#
# Write the COMPLETE openclaw.json including gateway config and auth token.
# This file is immutable at runtime (Landlock read-only on /sandbox/.openclaw).
# No runtime writes to openclaw.json are needed or possible.
# Build args (NEMOCLAW_MODEL, CHAT_UI_URL) customize per deployment.
# Auth token is generated per build so each image has a unique token.
#
# Temporary workaround for NemoClaw#1738: the OpenClaw Discord extension's
# gateway uses `ws` (via @buape/carbon), which ignores HTTPS_PROXY/HTTP_PROXY
Expand All @@ -291,8 +283,8 @@ USER sandbox
# the OpenShell proxy. Mirror of the Telegram treatment immediately below.
# Remove once OpenClaw lands an env-var-honouring fix for the Discord
# gateway equivalent to openclaw/openclaw#62878 (Slack Socket Mode).
RUN python3 -c "\
import base64, json, os; \
RUN NEMOCLAW_BUILD_ID="${NEMOCLAW_BUILD_ID}" python3 -c "\
import base64, json, os, secrets; \
from urllib.parse import urlparse; \
proxy_url = f\"http://{os.environ['NEMOCLAW_PROXY_HOST']}:{os.environ['NEMOCLAW_PROXY_PORT']}\"; \
model = os.environ['NEMOCLAW_MODEL']; \
Expand Down Expand Up @@ -341,7 +333,7 @@ config = { \
'allowedOrigins': origins, \
}, \
'trustedProxies': ['127.0.0.1', '::1'], \
'auth': {'token': ''} \
'auth': {'token': secrets.token_hex(32)} \
} \
}; \
config.update({ \
Expand All @@ -364,17 +356,6 @@ os.chmod(path, 0o600)"
RUN openclaw doctor --fix > /dev/null 2>&1 || true \
&& openclaw plugins install /opt/nemoclaw > /dev/null 2>&1 || true

# SECURITY: Clear any gateway auth token that openclaw doctor/plugins may have
# auto-generated. The real token is created at container startup by the
# entrypoint (generate_gateway_token) and never stored in openclaw.json.
RUN python3 -c "\
import json, os; \
path = os.path.expanduser('~/.openclaw/openclaw.json'); \
cfg = json.load(open(path)); \
cfg.setdefault('gateway', {}).setdefault('auth', {})['token'] = ''; \
json.dump(cfg, open(path, 'w'), indent=2); \
os.chmod(path, 0o600)"

# Lock openclaw.json via DAC: chown to root so the sandbox user cannot modify
# it at runtime. This works regardless of Landlock enforcement status.
# The Landlock policy (/sandbox/.openclaw in read_only) provides defense-in-depth
Expand Down
19 changes: 4 additions & 15 deletions docs/security/best-practices.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,32 +229,21 @@ The container mounts system directories read-only to prevent the agent from modi

### Read-Only `.openclaw` Config

The `/sandbox/.openclaw` directory contains the OpenClaw gateway configuration (model routing, CORS settings, channel config).
The gateway auth token is **not** stored in this directory — it is generated at container startup and passed via the `OPENCLAW_GATEWAY_TOKEN` environment variable only to the gateway process (which runs as the `gateway` user).

The token file location depends on the startup mode:

- **Root mode** (production): `/run/nemoclaw/gateway-token` (`gateway:gateway 0400`).
The sandbox user cannot read this file (different uid), cannot read the gateway process env (`/proc/pid/environ` is uid-gated), and `no-new-privileges` prevents escalation.
- **Non-root mode** (dev/fallback): `/tmp/.runtime/nemoclaw/gateway-token` (`sandbox:sandbox 0400`).
Without uid separation the sandbox user owns the file, matching the pre-externalization security posture.
The token is not exported to the shell env or written to rc files.

The container mounts `.openclaw` read-only while writable agent state (plugins, agent data) lives in `/sandbox/.openclaw-data` through symlinks.
The `/sandbox/.openclaw` directory contains the OpenClaw gateway configuration, including auth tokens and CORS settings.
The container mounts it read-only while writable agent state (plugins, agent data) lives in `/sandbox/.openclaw-data` through symlinks.

Multiple defense layers protect this directory:

- **DAC permissions.** Root owns the directory and `openclaw.json` with `chmod 444`, so the sandbox user cannot write to them.
- **Immutable flag.** The entrypoint applies `chattr +i` to the directory and all symlinks, preventing modification even if other controls fail.
- **Symlink validation.** At startup, the entrypoint verifies every symlink in `.openclaw` points to the expected `.openclaw-data` target. If any symlink points elsewhere, the container refuses to start.
- **Config integrity hash.** The build process pins a SHA256 hash of `openclaw.json`. The entrypoint verifies it at startup and refuses to start if the hash does not match.
- **Externalized gateway token.** The gateway auth token never appears in `openclaw.json`. It is generated at container startup, written to a mode-dependent token file, and passed to the gateway process via an environment variable. In root mode, the token file is owned by the `gateway` user and unreadable by the sandbox agent.

| Aspect | Detail |
|---|---|
| Default | The container mounts `/sandbox/.openclaw` as read-only, root-owned, immutable, and integrity-verified at startup. `/sandbox/.openclaw-data` remains writable. The gateway auth token is stored separately: `/run/nemoclaw/gateway-token` in root mode (`gateway:gateway 0400`) or `/tmp/.runtime/nemoclaw/gateway-token` in non-root mode (`sandbox:sandbox 0400`). |
| Default | The container mounts `/sandbox/.openclaw` as read-only, root-owned, immutable, and integrity-verified at startup. `/sandbox/.openclaw-data` remains writable. |
| What you can change | Move `/sandbox/.openclaw` from `read_only` to `read_write` in the policy file. |
| Risk if relaxed | A writable `.openclaw` directory lets the agent modify its own gateway config: disabling CORS or redirecting inference to an attacker-controlled endpoint. This is the single most dangerous filesystem change. |
| Risk if relaxed | A writable `.openclaw` directory lets the agent modify its own gateway config: disabling CORS, changing auth tokens, or redirecting inference to an attacker-controlled endpoint. This is the single most dangerous filesystem change. |
| Recommendation | Never make `/sandbox/.openclaw` writable. |

### Writable Paths
Expand Down
137 changes: 73 additions & 64 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -466,43 +466,6 @@ PYSLACK
printf '[channels] Config hash recomputed after Slack token override\n' >&2
}

# ── Gateway auth token (externalized) ──────────────────────────
# The gateway auth token is NOT stored in openclaw.json. It is generated
# at container startup and passed as OPENCLAW_GATEWAY_TOKEN env var only
# to the gateway process launch line. OpenClaw reads this natively via
# its resolveGatewayCredentialsFromValues() path.
#
# Token file location depends on startup mode:
# Root mode: /run/nemoclaw/gateway-token (gateway:gateway 0400)
# Host reads via kubectl exec (runs as root in pod).
# Sandbox user cannot access: wrong uid, /proc/pid/environ
# is uid-gated, no-new-privileges blocks escalation.
# Non-root mode: $XDG_RUNTIME_DIR/nemoclaw/gateway-token (sandbox:sandbox 0400)
# Host reads via openshell sandbox download (sandbox user).
# No uid isolation — matches pre-externalization posture.
#
# Both paths regenerate the token on every container start.
GATEWAY_TOKEN_DIR="/run/nemoclaw"
GATEWAY_TOKEN_FILE="${GATEWAY_TOKEN_DIR}/gateway-token"

generate_gateway_token() {
[ "$(id -u)" -eq 0 ] || {
printf '[SECURITY] generate_gateway_token requires root — skipping\n' >&2
return 1
}

mkdir -p "$GATEWAY_TOKEN_DIR"
chmod 755 "$GATEWAY_TOKEN_DIR"

python3 -c "import secrets; print(secrets.token_hex(32), end='')" \
>"$GATEWAY_TOKEN_FILE"

chown gateway:gateway "$GATEWAY_TOKEN_FILE"
chmod 400 "$GATEWAY_TOKEN_FILE"
printf '[token] Gateway auth token generated at %s (gateway:gateway 0400)\n' \
"$GATEWAY_TOKEN_FILE" >&2
}

# ── Slack channel guard (unhandled-rejection safety net) ─────────
# Prevents the gateway from crashing when a Slack channel fails to
# initialize (e.g., invalid_auth, token_revoked, unresolved placeholder
Expand Down Expand Up @@ -638,10 +601,74 @@ SLACK_GUARD_EOF
}

_read_gateway_token() {
# Read the gateway token from the externalized file.
# Callable by root (entrypoint) and gateway user only.
# Returns the token on stdout; empty output means no token.
cat "$GATEWAY_TOKEN_FILE" 2>/dev/null || true
python3 - <<'PYTOKEN'
import json
try:
with open('/sandbox/.openclaw/openclaw.json') as f:
cfg = json.load(f)
print(cfg.get('gateway', {}).get('auth', {}).get('token', ''))
except Exception:
print('')
PYTOKEN
}

export_gateway_token() {
local token
token="$(_read_gateway_token)"
local marker_begin="# nemoclaw-gateway-token begin"
local marker_end="# nemoclaw-gateway-token end"

if [ -z "$token" ]; then
# Remove any stale marker blocks from rc files so revoked/old tokens
# are not re-exported in later interactive sessions.
unset OPENCLAW_GATEWAY_TOKEN
for rc_file in "${_SANDBOX_HOME}/.bashrc" "${_SANDBOX_HOME}/.profile"; do
if [ -f "$rc_file" ] && grep -qF "$marker_begin" "$rc_file" 2>/dev/null; then
local tmp
tmp="$(mktemp)" || continue
awk -v b="$marker_begin" -v e="$marker_end" \
'$0==b{s=1;next} $0==e{s=0;next} !s' "$rc_file" >"$tmp" 2>/dev/null || {
rm -f "$tmp"
continue
}
cat "$tmp" >"$rc_file" 2>/dev/null || true
rm -f "$tmp"
fi
done
return
fi
export OPENCLAW_GATEWAY_TOKEN="$token"

# Persist to .bashrc/.profile so interactive sessions (openshell sandbox
# connect) also see the token — same pattern as the proxy config above.
# Shell-escape the token so quotes/dollars/backticks cannot break the
# sourced snippet or allow code injection.
local escaped_token
escaped_token="$(printf '%s' "$token" | sed "s/'/'\\\\''/g")"
local snippet
snippet="${marker_begin}
export OPENCLAW_GATEWAY_TOKEN='${escaped_token}'
${marker_end}"

for rc_file in "${_SANDBOX_HOME}/.bashrc" "${_SANDBOX_HOME}/.profile"; do
[ -f "$rc_file" ] || continue
# All writes use || true because Landlock may block writes even though
# DAC (-w) says writable (#804) — same pattern as install_configure_guard.
if grep -qF "$marker_begin" "$rc_file" 2>/dev/null; then
local tmp
tmp="$(mktemp)" || continue
awk -v b="$marker_begin" -v e="$marker_end" \
'$0==b{s=1;next} $0==e{s=0;next} !s' "$rc_file" >"$tmp" 2>/dev/null || {
rm -f "$tmp"
continue
}
printf '%s\n' "$snippet" >>"$tmp"
cat "$tmp" >"$rc_file" 2>/dev/null || true
rm -f "$tmp"
else
printf '\n%s\n' "$snippet" >>"$rc_file" 2>/dev/null || true
fi
done
}

install_configure_guard() {
Expand Down Expand Up @@ -1393,19 +1420,7 @@ if [ "$(id -u)" -ne 0 ]; then
apply_model_override
apply_cors_override
apply_slack_token_override
# Non-root: no privilege separation — uid separation is unavailable, so the
# sandbox user can read the token file. This is no worse than the pre-PR
# state where the token lived in openclaw.json (also sandbox-readable).
# Write the token to a restrictive file (0400) so it is not world-readable,
# and pass it on the gateway launch line (not exported to the shell env).
_NONROOT_GATEWAY_TOKEN="$(python3 -c "import secrets; print(secrets.token_hex(32), end='')")"
_NONROOT_TOKEN_DIR="${XDG_RUNTIME_DIR:-/tmp}/nemoclaw"
_NONROOT_TOKEN_FILE="${_NONROOT_TOKEN_DIR}/gateway-token"
mkdir -p "$_NONROOT_TOKEN_DIR"
rm -f "$_NONROOT_TOKEN_FILE"
printf '%s' "$_NONROOT_GATEWAY_TOKEN" >"$_NONROOT_TOKEN_FILE"
chmod 0400 "$_NONROOT_TOKEN_FILE"
printf '[SECURITY] Non-root mode — gateway token at %s (no uid isolation)\n' "$_NONROOT_TOKEN_FILE" >&2
export_gateway_token
install_configure_guard
configure_messaging_channels
install_slack_channel_guard
Expand Down Expand Up @@ -1495,11 +1510,8 @@ if [ "$(id -u)" -ne 0 ]; then
# inject code into any Node process via NODE_OPTIONS).
validate_tmp_permissions "$_SANDBOX_SAFETY_NET" "$_PROXY_FIX_SCRIPT" "$_NEMOTRON_FIX_SCRIPT" "$_CIAO_GUARD_SCRIPT" "$_SLACK_GUARD_SCRIPT"

# Start gateway in background, auto-pair, then wait.
# Pass OPENCLAW_GATEWAY_TOKEN only on this launch line so it lives solely
# in the gateway process env — not exported to the sandbox shell.
OPENCLAW_GATEWAY_TOKEN="$_NONROOT_GATEWAY_TOKEN" \
nohup "$OPENCLAW" gateway run --port "${_DASHBOARD_PORT}" >/tmp/gateway.log 2>&1 &
# Start gateway in background, auto-pair, then wait
nohup "$OPENCLAW" gateway run --port "${_DASHBOARD_PORT}" >/tmp/gateway.log 2>&1 &
GATEWAY_PID=$!
echo "[gateway] openclaw gateway launched (pid $GATEWAY_PID)" >&2
start_auto_pair
Expand All @@ -1524,7 +1536,7 @@ verify_config_integrity /sandbox/.openclaw
apply_model_override
apply_cors_override
apply_slack_token_override
generate_gateway_token
export_gateway_token
install_configure_guard

# Inject messaging channel config if provider tokens are present.
Expand Down Expand Up @@ -1640,10 +1652,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.
# SECURITY: OPENCLAW_GATEWAY_TOKEN is passed only to the gateway process
# env — the sandbox user cannot read /proc/<pid>/environ (different uid).
OPENCLAW_GATEWAY_TOKEN="$(_read_gateway_token)" \
nohup gosu gateway "$OPENCLAW" gateway run --port "${_DASHBOARD_PORT}" >/tmp/gateway.log 2>&1 &
nohup gosu 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
Loading
Loading