Skip to content
14 changes: 5 additions & 9 deletions Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -127,19 +127,15 @@ RUN mkdir -p /sandbox/.openclaw/agents/main/agent \
&& chmod -R g+w /sandbox/.openclaw \
&& find /sandbox/.openclaw -type d -exec chmod g+s {} +

# Pre-create shell init files for the sandbox user.
# Runtime proxy config is written by the entrypoint to /tmp/nemoclaw-proxy-env.sh
# (root-owned, mode 444, sticky-bit protected) and sourced from here on every
# interactive session.
# Ref: #2181 — the file must not be writable by the sandbox user.
# 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.
# hadolint ignore=SC2028
RUN printf '%s\n' \
'# Source runtime proxy config' \
'[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh' \
'# NemoClaw sandbox shell init' \
> /sandbox/.bashrc \
&& printf '%s\n' \
'# Source runtime proxy config' \
'[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh' \
'# NemoClaw sandbox login init' \
> /sandbox/.profile \
&& chown root:root /sandbox/.bashrc /sandbox/.profile \
&& chmod 444 /sandbox/.bashrc /sandbox/.profile
Expand Down
4 changes: 2 additions & 2 deletions docs/deployment/sandbox-hardening.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,8 @@ System paths remain read-only to prevent agents from:
- Modifying DNS resolution or TLS trust stores
- Tampering with libraries or shell configuration outside `/sandbox`

The image build pre-creates shell init files `.bashrc` and `.profile`.
These files source runtime proxy configuration from `/tmp/nemoclaw-proxy-env.sh`.
The image build pre-creates locked shell init files `.bashrc` and `.profile` without proxy entries.
Runtime proxy configuration is sourced from system-wide shell hooks that read `/tmp/nemoclaw-proxy-env.sh`.

### Landlock Kernel Requirements

Expand Down
2 changes: 1 addition & 1 deletion docs/security/best-practices.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ The container mounts system directories read-only to prevent the agent from modi
### Agent Config Directory

The `/sandbox/.openclaw` directory contains the OpenClaw gateway configuration (model routing, CORS settings, channel config).
The current entrypoint reads the gateway auth token from OpenClaw config when present, exports it as `OPENCLAW_GATEWAY_TOKEN`, and writes it to `/tmp/nemoclaw-proxy-env.sh` so interactive sandbox sessions can reach the gateway through the static `/sandbox/.bashrc` and `/sandbox/.profile` source shims.
The current entrypoint reads the gateway auth token from OpenClaw config when present, exports it as `OPENCLAW_GATEWAY_TOKEN`, and writes it to `/tmp/nemoclaw-proxy-env.sh` so interactive sandbox sessions can reach the gateway through system-wide shell hooks.
In root mode, the gateway process still runs as the separate `gateway` user, but the token is intentionally available to sandbox shells for local gateway access.

Writable agent state such as plugins, skills, hooks, and workspace metadata lives directly under `/sandbox/.openclaw`.
Expand Down
36 changes: 32 additions & 4 deletions scripts/lib/sandbox-init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@
_SANDBOX_INIT_LOADED=1

# ── /tmp trust boundary map ──────────────────────────────────────
# Files in /tmp that cross user boundaries. Every file sourced by
# .bashrc/.profile MUST be root-owned 444 in root mode.
# Files in /tmp that cross user boundaries. Every file sourced by system-wide
# shell hooks MUST be root-owned 444 in root mode.
#
# File Owner Mode Writer Reader Sourced?
# /tmp/nemoclaw-proxy-env.sh root 444 root sandbox YES (.bashrc/.profile)
# /tmp/nemoclaw-proxy-env.sh root 444 root sandbox YES (/etc shell hooks)
# /tmp/gateway.log gateway 644 gateway all no (world-readable for diagnostics)
# /tmp/auto-pair.log sandbox 600 sandbox sandbox no
# /tmp/.npm-cache/ sandbox 755 sandbox sandbox no (tool data)
Expand Down Expand Up @@ -453,7 +453,35 @@ lock_rc_files() {
continue
fi
if [ -f "$rc_file" ]; then
if ! chmod 444 "$rc_file" 2>/dev/null; then
if ! python3 - "$rc_file" "$(id -u)" <<'PY' 2>/dev/null; then
import errno
import os
import stat
import sys

path, uid_text = sys.argv[1:3]
uid = int(uid_text)
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
try:
fd = os.open(path, flags)
except OSError as exc:
if exc.errno == errno.ELOOP:
print(f"[SECURITY] Refusing to lock symlinked rc file: {path}", file=sys.stderr)
else:
print(f"[SECURITY] Could not open rc file for locking: {path}: {exc}", file=sys.stderr)
sys.exit(1)

try:
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode):
print(f"[SECURITY] Refusing to lock non-regular rc file: {path}", file=sys.stderr)
sys.exit(1)
if uid == 0:
os.fchown(fd, 0, 0)
os.fchmod(fd, 0o444)
finally:
os.close(fd)
PY
echo "[SECURITY] Could not lock ${rc_file} to 444 — continuing (best-effort, Landlock may enforce)" >&2
fi
fi
Expand Down
163 changes: 125 additions & 38 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1601,12 +1601,9 @@ emit_sandbox_sourced_file "$_SECCOMP_GUARD_SCRIPT" <"$_SECCOMP_GUARD_SOURCE"
export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $_SECCOMP_GUARD_SCRIPT"

# 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/*.
#
# We write dynamic connect-session config to /tmp/nemoclaw-proxy-env.sh. The
# pre-built .bashrc and .profile source this file automatically.
# time a user connects via `openshell sandbox connect`. Dynamic connect-session
# config lives in /tmp/nemoclaw-proxy-env.sh and is sourced by system-wide shell
# hooks from the base image, keeping per-user rc files free of proxy entries.
#
# SECURITY: The proxy-env file is written via emit_sandbox_sourced_file()
# which ensures root:root 444 in root mode (sandbox cannot modify) and
Expand Down Expand Up @@ -1813,10 +1810,9 @@ GUARDENVEOF
# primary process whose exit status is returned).
# Each code path below sets these before registering the trap.

# Stale base images may have rc files from before the runtime env source shim
# was baked into Dockerfile.base. Backfill the static shim before lock_rc_files
# makes those files read-only so connect sessions still receive proxy config,
# gateway auth, and command guards through /tmp/nemoclaw-proxy-env.sh.
# Keep per-user rc files out of runtime proxy wiring. Older images and prior
# entrypoint versions wrote a two-line shim into .bashrc/.profile; remove that
# managed stanza before lock_rc_files makes the files read-only again.
ensure_runtime_shell_env_shim() {
local failed=0
local rc_file
Expand All @@ -1832,40 +1828,131 @@ ensure_runtime_shell_env_shim() {
failed=1
continue
fi
if [ -f "$rc_file" ] && grep -qxF "$_RUNTIME_SHELL_ENV_SHIM" "$rc_file" 2>/dev/null; then
if [ ! -f "$rc_file" ]; then
continue
fi

if [ "$(id -u)" -eq 0 ] && [ -f "$rc_file" ]; then
if ! chown root:root "$rc_file" 2>/dev/null; then
echo "[SECURITY] could not take ownership of $rc_file before shim backfill" >&2
failed=1
continue
fi
if ! chmod 644 "$rc_file" 2>/dev/null; then
echo "[SECURITY] could not make $rc_file writable before shim backfill" >&2
failed=1
continue
fi
elif [ -f "$rc_file" ]; then
chmod u+w "$rc_file" 2>/dev/null || true
fi
if ! command python3 - "$rc_file" "$_RUNTIME_SHELL_ENV_SHIM" "$(id -u)" <<'PY'; then
import errno
import os
import stat
import sys
import tempfile

rc_path, shim, uid_text = sys.argv[1:4]
uid = int(uid_text)
fd = None
tmp_path = None

if [ -e "$rc_file" ]; then
if ! printf '\n%s\n%s\n' '# Source runtime proxy config' "$_RUNTIME_SHELL_ENV_SHIM" >>"$rc_file"; then
echo "[SECURITY] could not backfill runtime env shim into $rc_file" >&2
failed=1
continue
fi
elif ! printf '%s\n%s\n' '# Source runtime proxy config' "$_RUNTIME_SHELL_ENV_SHIM" >"$rc_file"; then
echo "[SECURITY] could not create $rc_file with runtime env shim" >&2
failed=1
continue
fi

if ! grep -qxF "$_RUNTIME_SHELL_ENV_SHIM" "$rc_file" 2>/dev/null; then
echo "[SECURITY] runtime env shim missing after backfill: $rc_file" >&2
def same_file(left, right):
return left.st_dev == right.st_dev and left.st_ino == right.st_ino


def rewrite_open_rc_file(read_fd, original_stat, cleaned_lines):
# The runtime test image can make /sandbox non-writable while leaving legacy
# shims in the rc files. In that case atomic rename into /sandbox fails, so
# rewrite the already-validated inode through /proc/self/fd instead.
if uid == 0:
os.fchown(read_fd, 0, 0)
os.fchmod(read_fd, 0o600)
write_fd = os.open(
f"/proc/self/fd/{read_fd}",
os.O_WRONLY | os.O_TRUNC | getattr(os, "O_CLOEXEC", 0),
)
try:
if not same_file(original_stat, os.fstat(write_fd)):
raise RuntimeError("rc file descriptor target changed during cleanup")
with os.fdopen(write_fd, "w", encoding="utf-8", errors="surrogateescape") as handle:
write_fd = None
handle.writelines(cleaned_lines)
handle.flush()
os.fsync(handle.fileno())
finally:
if write_fd is not None:
os.close(write_fd)
os.fchmod(read_fd, 0o644)


def rewrite_by_rename(cleaned_lines):
global tmp_path
tmp_fd, tmp_path = tempfile.mkstemp(prefix="nemoclaw-rc-clean.", dir="/tmp", text=True)
with os.fdopen(tmp_fd, "w", encoding="utf-8", errors="surrogateescape") as handle:
handle.writelines(cleaned_lines)
handle.flush()
os.fsync(handle.fileno())
if uid == 0:
os.chown(tmp_path, 0, 0)
os.chmod(tmp_path, 0o644)
os.replace(tmp_path, rc_path)
tmp_path = None

try:
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
try:
fd = os.open(rc_path, flags)
except OSError as exc:
if exc.errno == errno.ELOOP:
print(f"[SECURITY] refusing symlinked rc file during cleanup: {rc_path}", file=sys.stderr)
else:
print(f"[SECURITY] could not open rc file for cleanup: {rc_path}: {exc}", file=sys.stderr)
sys.exit(1)

st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode):
print(f"[SECURITY] refusing non-regular rc file during cleanup: {rc_path}", file=sys.stderr)
sys.exit(1)
with os.fdopen(os.dup(fd), "r", encoding="utf-8", errors="surrogateescape") as handle:
lines = handle.readlines()

cleaned = []
index = 0
while index < len(lines):
line = lines[index]
bare = line.rstrip("\n")
if bare == "# Source runtime proxy config":
if index + 1 < len(lines):
next_line = lines[index + 1]
next_bare = next_line.rstrip("\n")
if next_bare == shim or "/tmp/nemoclaw-proxy-env.sh" in next_line:
index += 2
continue
cleaned.append(line)
cleaned.append(next_line)
index += 2
continue
if bare == shim or "/tmp/nemoclaw-proxy-env.sh" in line:
index += 1
continue
cleaned.append(line)
index += 1

if any(line.rstrip("\n") == shim or "/tmp/nemoclaw-proxy-env.sh" in line for line in cleaned):
print(f"[SECURITY] runtime env shim still present after cleanup: {rc_path}", file=sys.stderr)
sys.exit(1)
if cleaned == lines:
sys.exit(0)

try:
rewrite_open_rc_file(fd, st, cleaned)
except OSError as exc:
if exc.errno != errno.ENOENT:
raise
rewrite_by_rename(cleaned)
except Exception as exc:
print(f"[SECURITY] could not safely clean runtime env shim from {rc_path}: {exc}", file=sys.stderr)
sys.exit(1)
finally:
if fd is not None:
os.close(fd)
if tmp_path:
try:
os.unlink(tmp_path)
except FileNotFoundError:
pass
PY
failed=1
continue
fi
done

Expand Down
32 changes: 15 additions & 17 deletions test/e2e-gateway-isolation.sh
Original file line number Diff line number Diff line change
Expand Up @@ -376,36 +376,34 @@ else
fail "sandbox cannot create new files in .openclaw — should be writable: $OUT"
fi

# ── Test 23: .bashrc sources proxy-env from /tmp ──────────────────
# Requires base image with pre-built .bashrc (#804). Skip gracefully
# if the file doesn't exist yet (base image not rebuilt).
# ── Test 23: .bashrc has no proxy entries ────────────────────────

info "23. .bashrc sources proxy config from /tmp"
OUT=$(run_as_sandbox "cat /sandbox/.bashrc 2>/dev/null || echo MISSING")
if echo "$OUT" | grep -q "/tmp/nemoclaw-proxy-env.sh"; then
pass ".bashrc sources /tmp/nemoclaw-proxy-env.sh"
info "23. .bashrc has no proxy entries"
OUT=$(run_as_sandbox "if [ ! -f /sandbox/.bashrc ]; then echo MISSING; elif grep -i proxy /sandbox/.bashrc; then echo FOUND; else echo OK; fi")
if echo "$OUT" | grep -qx "OK"; then
pass ".bashrc has no proxy entries"
elif echo "$OUT" | grep -q "MISSING\|No such file"; then
info "SKIP: .bashrc not present (base image needs rebuild for #804)"
fail ".bashrc is missing"
else
fail ".bashrc does not source from expected path: $OUT"
fail ".bashrc contains proxy entries: $OUT"
fi

# ── Test 24: .profile sources proxy-env from /tmp ─────────────────
# ── Test 24: .profile has no proxy entries ───────────────────────

info "24. .profile sources proxy config from /tmp"
OUT=$(run_as_sandbox "cat /sandbox/.profile 2>/dev/null || echo MISSING")
if echo "$OUT" | grep -q "/tmp/nemoclaw-proxy-env.sh"; then
pass ".profile sources /tmp/nemoclaw-proxy-env.sh"
info "24. .profile has no proxy entries"
OUT=$(run_as_sandbox "if [ ! -f /sandbox/.profile ]; then echo MISSING; elif grep -i proxy /sandbox/.profile; then echo FOUND; else echo OK; fi")
if echo "$OUT" | grep -qx "OK"; then
pass ".profile has no proxy entries"
elif echo "$OUT" | grep -q "MISSING\|No such file"; then
info "SKIP: .profile not present (base image needs rebuild for #804)"
fail ".profile is missing"
else
fail ".profile does not source from expected path: $OUT"
fail ".profile contains proxy entries: $OUT"
fi

# ── Test 25: proxy-env.sh is NOT writable by sandbox user (#2181) ──
# The entrypoint writes /tmp/nemoclaw-proxy-env.sh via emit_sandbox_sourced_file()
# which sets mode 444 and root ownership. The sandbox user must not be able to
# modify this file, as .bashrc/.profile source it on every connect.
# modify this file, as the system-wide shell hooks source it on every connect.
# Since the E2E bypasses the entrypoint (--entrypoint ""), we simulate what the
# entrypoint does: create the file as root with mode 444, then verify sandbox
# cannot modify it.
Expand Down
6 changes: 3 additions & 3 deletions test/repro-2376.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
* configuration from /tmp/nemoclaw-proxy-env.sh are missing.
*
* Root cause:
* The OpenClaw base image (Dockerfile.base) pre-creates /sandbox/.bashrc
* and /sandbox/.profile that source /tmp/nemoclaw-proxy-env.sh — the file
* the entrypoint writes with HERMES_HOME (and proxy vars) at runtime.
* Older OpenClaw base images pre-created /sandbox/.bashrc and
* /sandbox/.profile entries that sourced /tmp/nemoclaw-proxy-env.sh — the
* file the entrypoint writes with HERMES_HOME (and proxy vars) at runtime.
* The Hermes base image (agents/hermes/Dockerfile.base) was missing the
* equivalent block, so the proxy-env file existed but was never sourced.
*
Expand Down
6 changes: 3 additions & 3 deletions test/sandbox-provisioning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ describe("sandbox provisioning: unified .openclaw layout (#2227)", () => {
);
});

it("provisions unified mutable .openclaw layout and trusted rc shims", () => {
it("provisions unified mutable .openclaw layout and clean trusted rc files", () => {
const dockerfile = fs.readFileSync(DOCKERFILE_BASE, "utf-8");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-base-layout-"));
const sandboxRoot = path.join(tmp, "sandbox");
Expand Down Expand Up @@ -418,11 +418,11 @@ describe("sandbox provisioning: unified .openclaw layout (#2227)", () => {
sandboxRoot,
);
expect(rc.result.status).toBe(0);
const runtimeEnvShim = "[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh";
for (const rcName of [".bashrc", ".profile"]) {
const rcPath = path.join(sandboxRoot, rcName);
const content = fs.readFileSync(rcPath, "utf-8");
expect(content.split(runtimeEnvShim).length - 1).toBe(1);
expect(content.toLowerCase()).not.toContain("proxy");
expect(content).not.toContain("/tmp/nemoclaw-proxy-env.sh");
expect((fs.statSync(rcPath).mode & 0o777).toString(8)).toBe("444");
}
expect(rc.calls).toContain(
Expand Down
Loading
Loading