From 31cfbd04885bcccee7dd7a58e23a92f569ecfd6b Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 20 May 2026 08:54:57 +0800 Subject: [PATCH 1/6] fix: remove proxy hooks from sandbox rc files Signed-off-by: Test User --- Dockerfile.base | 14 ++---- docs/deployment/sandbox-hardening.mdx | 4 +- docs/security/best-practices.mdx | 2 +- scripts/lib/sandbox-init.sh | 6 +-- scripts/nemoclaw-start.sh | 68 +++++++++++++++++---------- test/e2e-gateway-isolation.sh | 32 ++++++------- test/repro-2376.test.ts | 6 +-- test/sandbox-provisioning.test.ts | 6 +-- test/service-env.test.ts | 31 ++++++++++-- 9 files changed, 102 insertions(+), 67 deletions(-) diff --git a/Dockerfile.base b/Dockerfile.base index 4d920cb614b..b568e112226 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -126,19 +126,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 diff --git a/docs/deployment/sandbox-hardening.mdx b/docs/deployment/sandbox-hardening.mdx index 75289102520..6b178f5989b 100644 --- a/docs/deployment/sandbox-hardening.mdx +++ b/docs/deployment/sandbox-hardening.mdx @@ -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 diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 208c806e42e..c1a18929f5a 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -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`. diff --git a/scripts/lib/sandbox-init.sh b/scripts/lib/sandbox-init.sh index 2bcb8125f87..d0d2b8f8d82 100755 --- a/scripts/lib/sandbox-init.sh +++ b/scripts/lib/sandbox-init.sh @@ -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) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 71c996c03ee..a73b1ff4c10 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1564,12 +1564,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 @@ -1776,10 +1773,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 @@ -1795,18 +1791,20 @@ 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" ] \ + && ! grep -qxF "$_RUNTIME_SHELL_ENV_SHIM" "$rc_file" 2>/dev/null \ + && ! grep -q '/tmp/nemoclaw-proxy-env\.sh' "$rc_file" 2>/dev/null \ + && ! grep -qxF '# Source runtime proxy config' "$rc_file" 2>/dev/null; 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 + echo "[SECURITY] could not take ownership of $rc_file before shim cleanup" >&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 + echo "[SECURITY] could not make $rc_file writable before shim cleanup" >&2 failed=1 continue fi @@ -1814,20 +1812,42 @@ ensure_runtime_shell_env_shim() { chmod u+w "$rc_file" 2>/dev/null || true fi - 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 + if [ ! -f "$rc_file" ]; then + continue + fi + + local tmp_file + tmp_file="${rc_file}.nemoclaw-clean.$$" + if ! awk -v shim="$_RUNTIME_SHELL_ENV_SHIM" ' + $0 == "# Source runtime proxy config" { + if ((getline next_line) > 0) { + if (next_line == shim || next_line ~ /\/tmp\/nemoclaw-proxy-env\.sh/) { + next + } + print $0 + print next_line + next + } + } + $0 == shim { next } + $0 ~ /\/tmp\/nemoclaw-proxy-env\.sh/ { next } + { print } + ' "$rc_file" >"$tmp_file"; then + rm -f "$tmp_file" + echo "[SECURITY] could not clean runtime env shim from $rc_file" >&2 + failed=1 + continue + fi + if ! cat "$tmp_file" >"$rc_file"; then + rm -f "$tmp_file" + echo "[SECURITY] could not replace cleaned rc file: $rc_file" >&2 failed=1 continue fi + rm -f "$tmp_file" - if ! grep -qxF "$_RUNTIME_SHELL_ENV_SHIM" "$rc_file" 2>/dev/null; then - echo "[SECURITY] runtime env shim missing after backfill: $rc_file" >&2 + if grep -qxF "$_RUNTIME_SHELL_ENV_SHIM" "$rc_file" 2>/dev/null; then + echo "[SECURITY] runtime env shim still present after cleanup: $rc_file" >&2 failed=1 fi done diff --git a/test/e2e-gateway-isolation.sh b/test/e2e-gateway-isolation.sh index e553786f38d..25ce2678a68 100755 --- a/test/e2e-gateway-isolation.sh +++ b/test/e2e-gateway-isolation.sh @@ -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. diff --git a/test/repro-2376.test.ts b/test/repro-2376.test.ts index dac45a73ffb..bd27a552c63 100644 --- a/test/repro-2376.test.ts +++ b/test/repro-2376.test.ts @@ -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. * diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index dcdef43c5d1..04472f41f81 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -96,7 +96,7 @@ function runLoggedDockerShell(command: string, tmp: string, functionDefs: string } 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"); @@ -138,11 +138,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( diff --git a/test/service-env.test.ts b/test/service-env.test.ts index 73a3cbb409c..69afeefec4e 100644 --- a/test/service-env.test.ts +++ b/test/service-env.test.ts @@ -533,15 +533,34 @@ describe("service environment", () => { } }); - it("backfills proxy-env.sh source shims into stale rc files", () => { + it("removes legacy proxy-env.sh source shims from sandbox user rc files", () => { const fakeHome = join(tmpdir(), `nemoclaw-rc-shim-test-${process.pid}`); const proxyEnvPath = join(fakeHome, "proxy-env.sh"); const tmpFile = join(tmpdir(), `nemoclaw-rc-shim-write-test-${process.pid}.sh`); - const runtimeEnvShim = `[ -f ${proxyEnvPath} ] && . ${proxyEnvPath}`; try { execFileSync("mkdir", ["-p", fakeHome]); - writeFileSync(join(fakeHome, ".bashrc"), "# old bashrc\n", { mode: 0o644 }); - writeFileSync(join(fakeHome, ".profile"), "# old profile\n", { mode: 0o444 }); + writeFileSync( + join(fakeHome, ".bashrc"), + [ + "# old bashrc", + "# Source runtime proxy config", + `[ -f ${proxyEnvPath} ] && . ${proxyEnvPath}`, + "export PATH=/usr/local/bin:$PATH", + "", + ].join("\n"), + { mode: 0o644 }, + ); + writeFileSync( + join(fakeHome, ".profile"), + [ + "# old profile", + "# Source runtime proxy config", + `[ -f ${proxyEnvPath} ] && . ${proxyEnvPath}`, + "umask 022", + "", + ].join("\n"), + { mode: 0o444 }, + ); const wrapper = [ "#!/usr/bin/env bash", @@ -556,7 +575,9 @@ describe("service environment", () => { for (const rcName of [".bashrc", ".profile"]) { const rcFile = readFileSync(join(fakeHome, rcName), "utf-8"); - expect(rcFile.split(runtimeEnvShim).length - 1).toBe(1); + expect(rcFile.toLowerCase()).not.toContain("proxy"); + expect(rcFile).not.toContain(proxyEnvPath); + expect(rcFile).toContain(rcName === ".bashrc" ? "export PATH" : "umask 022"); } } finally { try { From a9180391258e7966495249d75cfff9e57c113ab9 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Wed, 20 May 2026 14:25:58 +0800 Subject: [PATCH 2/6] test: use secure temp dir for rc shim test Signed-off-by: Chengjie Wang --- test/service-env.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/service-env.test.ts b/test/service-env.test.ts index 69afeefec4e..c0eb8c8343e 100644 --- a/test/service-env.test.ts +++ b/test/service-env.test.ts @@ -534,11 +534,10 @@ describe("service environment", () => { }); it("removes legacy proxy-env.sh source shims from sandbox user rc files", () => { - const fakeHome = join(tmpdir(), `nemoclaw-rc-shim-test-${process.pid}`); + const fakeHome = mkdtempSync(join(tmpdir(), "nemoclaw-rc-shim-test-")); const proxyEnvPath = join(fakeHome, "proxy-env.sh"); - const tmpFile = join(tmpdir(), `nemoclaw-rc-shim-write-test-${process.pid}.sh`); + const tmpFile = join(fakeHome, "rc-shim-write-test.sh"); try { - execFileSync("mkdir", ["-p", fakeHome]); writeFileSync( join(fakeHome, ".bashrc"), [ From 6188243db24d72292989d0aac4835203c85989bc Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 22 May 2026 16:09:01 -0700 Subject: [PATCH 3/6] fix(sandbox): avoid rc path chmod during cleanup --- scripts/nemoclaw-start.sh | 143 ++++++++++++++++++++------------------ test/service-env.test.ts | 20 +++--- 2 files changed, 85 insertions(+), 78 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 40c2e77d22e..16164918b4c 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1828,79 +1828,88 @@ ensure_runtime_shell_env_shim() { failed=1 continue fi - if [ -f "$rc_file" ] \ - && ! grep -qxF "$_RUNTIME_SHELL_ENV_SHIM" "$rc_file" 2>/dev/null \ - && ! grep -q '/tmp/nemoclaw-proxy-env\.sh' "$rc_file" 2>/dev/null \ - && ! grep -qxF '# Source runtime proxy config' "$rc_file" 2>/dev/null; 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 cleanup" >&2 - failed=1 - continue - fi - if ! chmod 644 "$rc_file" 2>/dev/null; then - echo "[SECURITY] could not make $rc_file writable before shim cleanup" >&2 - failed=1 - continue - fi - elif [ -f "$rc_file" ]; then - chmod u+w "$rc_file" 2>/dev/null || true - fi - if [ ! -f "$rc_file" ]; then continue fi - local tmp_file - tmp_file="$(mktemp "/tmp/nemoclaw-rc-clean.XXXXXX")" || { - echo "[SECURITY] could not allocate temp file for rc cleanup: $rc_file" >&2 - failed=1 - continue - } - if ! awk -v shim="$_RUNTIME_SHELL_ENV_SHIM" ' - $0 == "# Source runtime proxy config" { - if ((getline next_line) > 0) { - if (next_line == shim || next_line ~ /\/tmp\/nemoclaw-proxy-env\.sh/) { - next - } - print $0 - print next_line - next - } - } - $0 == shim { next } - $0 ~ /\/tmp\/nemoclaw-proxy-env\.sh/ { next } - { print } - ' "$rc_file" >"$tmp_file"; then - rm -f "$tmp_file" - echo "[SECURITY] could not clean runtime env shim from $rc_file" >&2 - failed=1 - continue - fi - if [ "$(id -u)" -eq 0 ] && ! chown root:root "$tmp_file" 2>/dev/null; then - rm -f "$tmp_file" - echo "[SECURITY] could not take ownership of cleaned rc file: $rc_file" >&2 - failed=1 - continue - fi - if ! mv -f "$tmp_file" "$rc_file"; then - rm -f "$tmp_file" - echo "[SECURITY] could not replace cleaned rc file: $rc_file" >&2 - failed=1 - continue - fi - if [ -L "$rc_file" ] || [ ! -f "$rc_file" ]; then - echo "[SECURITY] cleaned rc file was replaced by an unsafe path: $rc_file" >&2 - failed=1 - continue - fi - chmod 644 "$rc_file" 2>/dev/null || true + if ! command python3 - "$rc_file" "$_RUNTIME_SHELL_ENV_SHIM" "$(id -u)" <<'PY' +import errno +import os +import stat +import sys +import tempfile - if grep -qxF "$_RUNTIME_SHELL_ENV_SHIM" "$rc_file" 2>/dev/null; then - echo "[SECURITY] runtime env shim still present after cleanup: $rc_file" >&2 +rc_path, shim, uid_text = sys.argv[1:4] +uid = int(uid_text) +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) + + with os.fdopen(fd, "r", encoding="utf-8", errors="surrogateescape") as handle: + st = os.fstat(handle.fileno()) + 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) + 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) + + 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) + 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 +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 tmp_path: + try: + os.unlink(tmp_path) + except FileNotFoundError: + pass +PY + then failed=1 + continue fi done diff --git a/test/service-env.test.ts b/test/service-env.test.ts index 7641bd3e210..9a500f10228 100644 --- a/test/service-env.test.ts +++ b/test/service-env.test.ts @@ -645,11 +645,11 @@ describe("service environment", () => { } }); - it("fails rc shim cleanup if the target becomes a directory during replacement", () => { - const fakeHome = mkdtempSync(join(tmpdir(), "nemoclaw-rc-shim-race-test-")); + it("cleans rc shims without shell chown/chmod on the rc path", () => { + const fakeHome = mkdtempSync(join(tmpdir(), "nemoclaw-rc-shim-no-path-chmod-test-")); const proxyEnvPath = join(fakeHome, "proxy-env.sh"); const rcPath = join(fakeHome, ".bashrc"); - const tmpFile = join(fakeHome, "rc-shim-race-test.sh"); + const tmpFile = join(fakeHome, "rc-shim-no-path-chmod-test.sh"); try { writeFileSync( rcPath, @@ -668,18 +668,16 @@ describe("service environment", () => { `_SANDBOX_HOME=${JSON.stringify(fakeHome)}`, `_RUNTIME_SHELL_ENV_FILE=${JSON.stringify(proxyEnvPath)}`, '_RUNTIME_SHELL_ENV_SHIM="[ -f ${_RUNTIME_SHELL_ENV_FILE} ] && . ${_RUNTIME_SHELL_ENV_FILE}"', - 'mv() { if [ "${1:-}" = "-f" ]; then local src="$2"; local dest="$3"; rm -f "$dest"; mkdir "$dest"; command mv -f "$src" "$dest"; else command mv "$@"; fi; }', - extractRuntimeShellEnvShimSnippet().replace(/\nensure_runtime_shell_env_shim$/, ""), - "set +e", - "ensure_runtime_shell_env_shim", - "rc=$?", - "set -e", - '[ "$rc" -ne 0 ] || exit 7', + 'chown() { echo "unexpected chown $*" >&2; exit 42; }', + 'chmod() { echo "unexpected chmod $*" >&2; exit 43; }', + extractRuntimeShellEnvShimSnippet(), ].join("\n"); writeFileSync(tmpFile, wrapper, { mode: 0o700 }); execFileSync("bash", [tmpFile], { encoding: "utf-8" }); - expect(lstatSync(rcPath).isDirectory()).toBe(true); + const rcFile = readFileSync(rcPath, "utf-8"); + expect(rcFile.toLowerCase()).not.toContain("proxy"); + expect(rcFile).not.toContain(proxyEnvPath); } finally { try { unlinkSync(tmpFile); From 5a73a322af8477e0f53abe01a34dc130f4eb0b9b Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 22 May 2026 16:20:03 -0700 Subject: [PATCH 4/6] style(sandbox): format rc cleanup heredoc --- scripts/nemoclaw-start.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 16164918b4c..ad663565dbc 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1832,7 +1832,7 @@ ensure_runtime_shell_env_shim() { continue fi - if ! command python3 - "$rc_file" "$_RUNTIME_SHELL_ENV_SHIM" "$(id -u)" <<'PY' + if ! command python3 - "$rc_file" "$_RUNTIME_SHELL_ENV_SHIM" "$(id -u)" <<'PY'; then import errno import os import stat @@ -1907,7 +1907,6 @@ finally: except FileNotFoundError: pass PY - then failed=1 continue fi From 3ced5539cdabc8317ed6f5ce31e8a5cbdfad7726 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 22 May 2026 17:13:15 -0700 Subject: [PATCH 5/6] fix(sandbox): preserve locked clean rc files --- scripts/lib/sandbox-init.sh | 30 ++++++++++++++++++++++++- scripts/nemoclaw-start.sh | 2 ++ test/service-env.test.ts | 44 +++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/scripts/lib/sandbox-init.sh b/scripts/lib/sandbox-init.sh index d0d2b8f8d82..54edab75337 100755 --- a/scripts/lib/sandbox-init.sh +++ b/scripts/lib/sandbox-init.sh @@ -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 diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index ad663565dbc..fc99c8d2fa5 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1886,6 +1886,8 @@ try: 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) 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: diff --git a/test/service-env.test.ts b/test/service-env.test.ts index 9a500f10228..fe74f18268f 100644 --- a/test/service-env.test.ts +++ b/test/service-env.test.ts @@ -692,6 +692,50 @@ describe("service environment", () => { } }); + it("does not rewrite locked clean rc files", () => { + const fakeHome = mkdtempSync(join(tmpdir(), "nemoclaw-rc-shim-clean-locked-test-")); + const proxyEnvPath = join(fakeHome, "proxy-env.sh"); + const rcPath = join(fakeHome, ".bashrc"); + const profilePath = join(fakeHome, ".profile"); + const tmpFile = join(tmpdir(), `rc-shim-clean-locked-test-${process.pid}.sh`); + try { + writeFileSync(rcPath, "# clean bashrc\n", { mode: 0o444 }); + writeFileSync(profilePath, "# clean profile\n", { mode: 0o444 }); + execFileSync("chmod", ["555", fakeHome]); + + const wrapper = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `_SANDBOX_HOME=${JSON.stringify(fakeHome)}`, + `_RUNTIME_SHELL_ENV_FILE=${JSON.stringify(proxyEnvPath)}`, + '_RUNTIME_SHELL_ENV_SHIM="[ -f ${_RUNTIME_SHELL_ENV_FILE} ] && . ${_RUNTIME_SHELL_ENV_FILE}"', + extractRuntimeShellEnvShimSnippet(), + "ensure_runtime_shell_env_shim", + ].join("\n"); + writeFileSync(tmpFile, wrapper, { mode: 0o700 }); + execFileSync("bash", [tmpFile], { encoding: "utf-8" }); + + expect(readFileSync(rcPath, "utf-8")).toBe("# clean bashrc\n"); + expect(readFileSync(profilePath, "utf-8")).toBe("# clean profile\n"); + } finally { + try { + execFileSync("chmod", ["755", fakeHome]); + } catch { + /* ignore */ + } + try { + unlinkSync(tmpFile); + } catch { + /* ignore */ + } + try { + execFileSync("rm", ["-rf", fakeHome]); + } catch { + /* ignore */ + } + } + }); + it("entrypoint overwrites proxy-env.sh cleanly on repeated invocations", () => { const fakeDataDir = join(tmpdir(), `nemoclaw-idempotent-test-${process.pid}`); execFileSync("mkdir", ["-p", fakeDataDir]); From 3ec18da8287f58c5bdd9aed6b76b85795fb03237 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 22 May 2026 17:35:03 -0700 Subject: [PATCH 6/6] fix(sandbox): clean legacy rc shims without directory writes --- scripts/nemoclaw-start.sh | 72 +++++++++++++++++++++++++++++++-------- test/service-env.test.ts | 69 ++++++++++++++++++++++++++++++++++++- 2 files changed, 125 insertions(+), 16 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index fc99c8d2fa5..32a61242762 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1841,8 +1841,52 @@ import tempfile rc_path, shim, uid_text = sys.argv[1:4] uid = int(uid_text) +fd = None tmp_path = None + +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: @@ -1854,11 +1898,11 @@ try: print(f"[SECURITY] could not open rc file for cleanup: {rc_path}: {exc}", file=sys.stderr) sys.exit(1) - with os.fdopen(fd, "r", encoding="utf-8", errors="surrogateescape") as handle: - st = os.fstat(handle.fileno()) - 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) + 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 = [] @@ -1889,20 +1933,18 @@ try: if cleaned == lines: sys.exit(0) - 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) - 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: + 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) diff --git a/test/service-env.test.ts b/test/service-env.test.ts index fe74f18268f..6ac6f4c15b4 100644 --- a/test/service-env.test.ts +++ b/test/service-env.test.ts @@ -7,7 +7,14 @@ import { execFileSync, type ExecFileSyncOptionsWithStringEncoding, } from "node:child_process"; -import { mkdtempSync, writeFileSync, unlinkSync, readFileSync, lstatSync } from "node:fs"; +import { + existsSync, + mkdtempSync, + writeFileSync, + unlinkSync, + readFileSync, + lstatSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { resolveOpenshell } from "../dist/lib/adapters/openshell/resolve"; @@ -736,6 +743,66 @@ describe("service environment", () => { } }); + const itOnProcFd = existsSync("/proc/self/fd") ? it : it.skip; + itOnProcFd("removes legacy rc shims without directory write permission", () => { + const fakeHome = mkdtempSync(join(tmpdir(), "nemoclaw-rc-shim-unwritable-dir-test-")); + const proxyEnvPath = join(fakeHome, "proxy-env.sh"); + const rcPath = join(fakeHome, ".bashrc"); + const profilePath = join(fakeHome, ".profile"); + const tmpFile = join(tmpdir(), `rc-shim-unwritable-dir-test-${process.pid}.sh`); + try { + for (const rcPathToWrite of [rcPath, profilePath]) { + writeFileSync( + rcPathToWrite, + [ + "# old rc", + "# Source runtime proxy config", + `[ -f ${proxyEnvPath} ] && . ${proxyEnvPath}`, + "export PATH=/usr/local/bin:$PATH", + "", + ].join("\n"), + { mode: 0o444 }, + ); + } + execFileSync("chmod", ["555", fakeHome]); + + const wrapper = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `_SANDBOX_HOME=${JSON.stringify(fakeHome)}`, + `_RUNTIME_SHELL_ENV_FILE=${JSON.stringify(proxyEnvPath)}`, + '_RUNTIME_SHELL_ENV_SHIM="[ -f ${_RUNTIME_SHELL_ENV_FILE} ] && . ${_RUNTIME_SHELL_ENV_FILE}"', + extractRuntimeShellEnvShimSnippet(), + "ensure_runtime_shell_env_shim", + ].join("\n"); + writeFileSync(tmpFile, wrapper, { mode: 0o700 }); + execFileSync("bash", [tmpFile], { encoding: "utf-8" }); + + for (const rcPathToRead of [rcPath, profilePath]) { + const rcFile = readFileSync(rcPathToRead, "utf-8"); + expect(rcFile.toLowerCase()).not.toContain("proxy"); + expect(rcFile).not.toContain(proxyEnvPath); + expect(rcFile).toContain("export PATH"); + } + } finally { + try { + execFileSync("chmod", ["755", fakeHome]); + } catch { + /* ignore */ + } + try { + unlinkSync(tmpFile); + } catch { + /* ignore */ + } + try { + execFileSync("rm", ["-rf", fakeHome]); + } catch { + /* ignore */ + } + } + }); + it("entrypoint overwrites proxy-env.sh cleanly on repeated invocations", () => { const fakeDataDir = join(tmpdir(), `nemoclaw-idempotent-test-${process.pid}`); execFileSync("mkdir", ["-p", fakeDataDir]);