From 36ee0c51f438b1d314852545d2d9884d0033e9ec Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 30 Mar 2026 14:19:57 -0700 Subject: [PATCH 1/6] security(sandbox): restrict /sandbox to read-only via Landlock filesystem policy (#804) Tighten the Landlock filesystem policy so agents cannot write arbitrary files in the /sandbox home directory. Only explicitly declared paths remain writable (/sandbox/.openclaw-data, /sandbox/.nemoclaw, /tmp). - Set include_workdir to false (verified against OpenShell landlock.rs: when true, WORKDIR is added to read_write, overriding read_only) - Move /sandbox from read_write to read_only in the policy - Add /sandbox/.nemoclaw to read_write for plugin state/config writes - DAC-protect blueprints with root ownership (defense-in-depth) - Pre-create .bashrc/.profile at build time (read-only home prevents runtime writes); source proxy config from writable proxy-env.sh - Redirect tool dotfiles (npm, git, pip, bash, claude, node) to /tmp via env vars in both the entrypoint and the sourced proxy-env.sh so interactive connect sessions also get the redirects Closes #804 --- Dockerfile | 14 +++- Dockerfile.base | 15 ++++ docs/deployment/sandbox-hardening.md | 25 ++++++ .../policies/openclaw-sandbox.yaml | 16 +++- scripts/nemoclaw-start.sh | 84 +++++++++---------- 5 files changed, 105 insertions(+), 49 deletions(-) diff --git a/Dockerfile b/Dockerfile index d62ee0558..874ede98e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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/ @@ -146,6 +148,16 @@ 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 blueprints: /sandbox/.nemoclaw is Landlock read_write (for plugin +# state/config), but blueprints are immutable at runtime. Root ownership prevents +# the agent from modifying them even though the directory is writable. The state/ +# subdirectory stays sandbox-owned for runtime writes. +# Ref: https://github.com/NVIDIA/NemoClaw/issues/804 +RUN chown -R root:root /sandbox/.nemoclaw/blueprints \ + && chmod -R 755 /sandbox/.nemoclaw/blueprints \ + && mkdir -p /sandbox/.nemoclaw/state /sandbox/.nemoclaw/migration \ + && chown sandbox:sandbox /sandbox/.nemoclaw/state /sandbox/.nemoclaw/migration + # 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"] diff --git a/Dockerfile.base b/Dockerfile.base index 3fd658485..4ea73ab11 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -117,6 +117,21 @@ 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 to the writable +# /sandbox/.openclaw-data/proxy-env.sh and sourced from here. +# hadolint ignore=SC2028 +RUN printf '%s\n' \ + '# Source runtime proxy config from writable path (Landlock read-only home)' \ + '[ -f /sandbox/.openclaw-data/proxy-env.sh ] && . /sandbox/.openclaw-data/proxy-env.sh' \ + > /sandbox/.bashrc \ + && printf '%s\n' \ + '# Source runtime proxy config from writable path (Landlock read-only home)' \ + '[ -f /sandbox/.openclaw-data/proxy-env.sh ] && . /sandbox/.openclaw-data/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 \ diff --git a/docs/deployment/sandbox-hardening.md b/docs/deployment/sandbox-hardening.md index a90231b16..a9c356a59 100644 --- a/docs/deployment/sandbox-hardening.md +++ b/docs/deployment/sandbox-hardening.md @@ -80,8 +80,33 @@ 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 + +Shell init files (`.bashrc`, `.profile`) are pre-created at image build time and +source runtime proxy configuration from the writable +`/sandbox/.openclaw-data/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 diff --git a/nemoclaw-blueprint/policies/openclaw-sandbox.yaml b/nemoclaw-blueprint/policies/openclaw-sandbox.yaml index 06f1b9d4a..8b781a8f1 100644 --- a/nemoclaw-blueprint/policies/openclaw-sandbox.yaml +++ b/nemoclaw-blueprint/policies/openclaw-sandbox.yaml @@ -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 @@ -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 diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 4c68e2925..0a97126e9 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -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 @@ -231,60 +243,40 @@ 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 /sandbox/.openclaw-data/proxy-env.sh (writable). The pre-built +# .bashrc and .profile source this file automatically. # # 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="/sandbox/.openclaw-data/proxy-env.sh" +cat > "$_PROXY_ENV_FILE" </dev/null || true # ── Non-root fallback ────────────────────────────────────────── # OpenShell runs containers with --security-opt=no-new-privileges, which From 8fce1bb918ed2a9415ea5015f355cb54480c2d95 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 30 Mar 2026 14:27:43 -0700 Subject: [PATCH 2/6] test(service-env): update proxy persistence tests for proxy-env.sh approach The entrypoint no longer writes proxy config directly to ~/.bashrc (read-only home). Tests now verify that proxy-env.sh is written to the writable data dir and that .bashrc sourcing works correctly. --- test/service-env.test.js | 129 ++++++++++++++++++++------------------- 1 file changed, 66 insertions(+), 63 deletions(-) diff --git a/test/service-env.test.js b/test/service-env.test.js index 3d9e39c75..bafdd37b3 100644 --- a/test/service-env.test.js +++ b/test/service-env.test.js @@ -184,141 +184,144 @@ describe("service environment", () => { expect(noProxy).toContain("10.200.0.1"); }); - it("entrypoint persistence writes proxy snippet to ~/.bashrc and ~/.profile", () => { - const fakeHome = join(tmpdir(), `nemoclaw-home-test-${process.pid}`); - execFileSync("mkdir", ["-p", fakeHome]); - const tmpFile = join(tmpdir(), `nemoclaw-bashrc-write-test-${process.pid}.sh`); + it("entrypoint writes proxy-env.sh to writable data dir", () => { + const fakeDataDir = join(tmpdir(), `nemoclaw-data-test-${process.pid}`); + execFileSync("mkdir", ["-p", fakeDataDir]); + const tmpFile = join(tmpdir(), `nemoclaw-proxyenv-write-test-${process.pid}.sh`); try { const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); const persistBlock = execFileSync( "sed", - ["-n", "/^_PROXY_URL=/,/^# ── Main/{ /^# ── Main/d; p; }", scriptPath], + ["-n", "/^_PROXY_URL=/,/^chmod 644/p", scriptPath], { encoding: "utf-8" } ); const wrapper = [ "#!/usr/bin/env bash", 'PROXY_HOST="10.200.0.1"', 'PROXY_PORT="3128"', - persistBlock.trimEnd(), + // Override the hardcoded path to use our temp dir + persistBlock.trimEnd().replace( + '/sandbox/.openclaw-data/proxy-env.sh', + `${fakeDataDir}/proxy-env.sh` + ), ].join("\n"); writeFileSync(tmpFile, wrapper, { mode: 0o700 }); - execFileSync("bash", [tmpFile], { - encoding: "utf-8", - env: { ...process.env, HOME: fakeHome }, - }); - - const bashrc = readFileSync(join(fakeHome, ".bashrc"), "utf-8"); - expect(bashrc).toContain("export HTTP_PROXY="); - expect(bashrc).toContain("export HTTPS_PROXY="); - expect(bashrc).toContain("export NO_PROXY="); - expect(bashrc).not.toContain("inference.local"); - expect(bashrc).toContain("10.200.0.1"); + execFileSync("bash", [tmpFile], { encoding: "utf-8" }); - const profile = readFileSync(join(fakeHome, ".profile"), "utf-8"); - expect(profile).not.toContain("inference.local"); + const envFile = readFileSync(join(fakeDataDir, "proxy-env.sh"), "utf-8"); + expect(envFile).toContain('export HTTP_PROXY="http://10.200.0.1:3128"'); + expect(envFile).toContain('export HTTPS_PROXY="http://10.200.0.1:3128"'); + expect(envFile).toContain("export NO_PROXY="); + expect(envFile).not.toContain("inference.local"); + expect(envFile).toContain("10.200.0.1"); + // Tool cache redirects should be present (#804) + expect(envFile).toContain("npm_config_cache"); + expect(envFile).toContain("HISTFILE"); + expect(envFile).toContain("GIT_CONFIG_GLOBAL"); } finally { try { unlinkSync(tmpFile); } catch { /* ignore */ } - try { execFileSync("rm", ["-rf", fakeHome]); } catch { /* ignore */ } + try { execFileSync("rm", ["-rf", fakeDataDir]); } catch { /* ignore */ } } }); - it("entrypoint persistence is idempotent across repeated invocations", () => { - const fakeHome = join(tmpdir(), `nemoclaw-idempotent-test-${process.pid}`); - execFileSync("mkdir", ["-p", fakeHome]); + it("entrypoint overwrites proxy-env.sh cleanly on repeated invocations", () => { + const fakeDataDir = join(tmpdir(), `nemoclaw-idempotent-test-${process.pid}`); + execFileSync("mkdir", ["-p", fakeDataDir]); const tmpFile = join(tmpdir(), `nemoclaw-idempotent-write-test-${process.pid}.sh`); try { const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); const persistBlock = execFileSync( "sed", - ["-n", "/^_PROXY_URL=/,/^# ── Main/{ /^# ── Main/d; p; }", scriptPath], + ["-n", "/^_PROXY_URL=/,/^chmod 644/p", scriptPath], { encoding: "utf-8" } ); const wrapper = [ "#!/usr/bin/env bash", 'PROXY_HOST="10.200.0.1"', 'PROXY_PORT="3128"', - persistBlock.trimEnd(), + persistBlock.trimEnd().replace( + '/sandbox/.openclaw-data/proxy-env.sh', + `${fakeDataDir}/proxy-env.sh` + ), ].join("\n"); writeFileSync(tmpFile, wrapper, { mode: 0o700 }); - const runOpts = { encoding: /** @type {const} */ ("utf-8"), env: { ...process.env, HOME: fakeHome } }; + const runOpts = { encoding: /** @type {const} */ ("utf-8") }; execFileSync("bash", [tmpFile], runOpts); execFileSync("bash", [tmpFile], runOpts); execFileSync("bash", [tmpFile], runOpts); - const bashrc = readFileSync(join(fakeHome, ".bashrc"), "utf-8"); - const beginCount = (bashrc.match(/nemoclaw-proxy-config begin/g) || []).length; - const endCount = (bashrc.match(/nemoclaw-proxy-config end/g) || []).length; - expect(beginCount).toBe(1); - expect(endCount).toBe(1); + const envFile = readFileSync(join(fakeDataDir, "proxy-env.sh"), "utf-8"); + // cat > overwrites the file each time, so there should be exactly one + // HTTP_PROXY line — no duplication from repeated runs. + const httpProxyCount = (envFile.match(/export HTTP_PROXY=/g) || []).length; + expect(httpProxyCount).toBe(1); } finally { try { unlinkSync(tmpFile); } catch { /* ignore */ } - try { execFileSync("rm", ["-rf", fakeHome]); } catch { /* ignore */ } + try { execFileSync("rm", ["-rf", fakeDataDir]); } catch { /* ignore */ } } }); - it("entrypoint persistence replaces stale proxy values on restart", () => { - const fakeHome = join(tmpdir(), `nemoclaw-replace-test-${process.pid}`); - execFileSync("mkdir", ["-p", fakeHome]); + it("entrypoint replaces stale proxy values on restart", () => { + const fakeDataDir = join(tmpdir(), `nemoclaw-replace-test-${process.pid}`); + execFileSync("mkdir", ["-p", fakeDataDir]); const tmpFile = join(tmpdir(), `nemoclaw-replace-write-test-${process.pid}.sh`); try { const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); const persistBlock = execFileSync( "sed", - ["-n", "/^_PROXY_URL=/,/^# ── Main/{ /^# ── Main/d; p; }", scriptPath], + ["-n", "/^_PROXY_URL=/,/^chmod 644/p", scriptPath], { encoding: "utf-8" } ); const makeWrapper = (host) => [ "#!/usr/bin/env bash", `PROXY_HOST="${host}"`, 'PROXY_PORT="3128"', - persistBlock.trimEnd(), + persistBlock.trimEnd().replace( + '/sandbox/.openclaw-data/proxy-env.sh', + `${fakeDataDir}/proxy-env.sh` + ), ].join("\n"); writeFileSync(tmpFile, makeWrapper("10.200.0.1"), { mode: 0o700 }); - execFileSync("bash", [tmpFile], { - encoding: "utf-8", - env: { ...process.env, HOME: fakeHome }, - }); - let bashrc = readFileSync(join(fakeHome, ".bashrc"), "utf-8"); - expect(bashrc).toContain("10.200.0.1"); + execFileSync("bash", [tmpFile], { encoding: "utf-8" }); + let envFile = readFileSync(join(fakeDataDir, "proxy-env.sh"), "utf-8"); + expect(envFile).toContain("10.200.0.1"); writeFileSync(tmpFile, makeWrapper("192.168.1.99"), { mode: 0o700 }); - execFileSync("bash", [tmpFile], { - encoding: "utf-8", - env: { ...process.env, HOME: fakeHome }, - }); - bashrc = readFileSync(join(fakeHome, ".bashrc"), "utf-8"); - expect(bashrc).toContain("192.168.1.99"); - expect(bashrc).not.toContain("10.200.0.1"); - const beginCount = (bashrc.match(/nemoclaw-proxy-config begin/g) || []).length; - expect(beginCount).toBe(1); + execFileSync("bash", [tmpFile], { encoding: "utf-8" }); + envFile = readFileSync(join(fakeDataDir, "proxy-env.sh"), "utf-8"); + expect(envFile).toContain("192.168.1.99"); + expect(envFile).not.toContain("10.200.0.1"); } finally { try { unlinkSync(tmpFile); } catch { /* ignore */ } - try { execFileSync("rm", ["-rf", fakeHome]); } catch { /* ignore */ } + try { execFileSync("rm", ["-rf", fakeDataDir]); } catch { /* ignore */ } } }); - it("[simulation] sourcing ~/.bashrc overrides narrow NO_PROXY and no_proxy", () => { - const fakeHome = join(tmpdir(), `nemoclaw-bashi-test-${process.pid}`); - execFileSync("mkdir", ["-p", fakeHome]); + it("[simulation] sourcing proxy-env.sh via .bashrc overrides narrow NO_PROXY", () => { + const fakeDataDir = join(tmpdir(), `nemoclaw-bashi-test-${process.pid}`); + execFileSync("mkdir", ["-p", fakeDataDir]); try { - const bashrcContent = [ - "# nemoclaw-proxy-config begin", + // Simulate the proxy-env.sh written by the entrypoint + const proxyEnvContent = [ 'export HTTP_PROXY="http://10.200.0.1:3128"', 'export HTTPS_PROXY="http://10.200.0.1:3128"', 'export NO_PROXY="localhost,127.0.0.1,::1,10.200.0.1"', 'export http_proxy="http://10.200.0.1:3128"', 'export https_proxy="http://10.200.0.1:3128"', 'export no_proxy="localhost,127.0.0.1,::1,10.200.0.1"', - "# nemoclaw-proxy-config end", ].join("\n"); - writeFileSync(join(fakeHome, ".bashrc"), bashrcContent); + const proxyEnvPath = join(fakeDataDir, "proxy-env.sh"); + writeFileSync(proxyEnvPath, proxyEnvContent); + + // Simulate .bashrc that sources proxy-env.sh (as built by Dockerfile.base) + const bashrcPath = join(fakeDataDir, ".bashrc"); + writeFileSync(bashrcPath, `[ -f ${JSON.stringify(proxyEnvPath)} ] && . ${JSON.stringify(proxyEnvPath)}`); const out = execFileSync("bash", ["--norc", "-c", [ - `export HOME=${JSON.stringify(fakeHome)}`, 'export NO_PROXY="127.0.0.1,localhost,::1"', 'export no_proxy="127.0.0.1,localhost,::1"', - `source ${JSON.stringify(join(fakeHome, ".bashrc"))}`, + `source ${JSON.stringify(bashrcPath)}`, 'echo "NO_PROXY=$NO_PROXY"', 'echo "no_proxy=$no_proxy"', ].join("; ")], { encoding: "utf-8" }).trim(); @@ -326,7 +329,7 @@ describe("service environment", () => { expect(out).toContain("NO_PROXY=localhost,127.0.0.1,::1,10.200.0.1"); expect(out).toContain("no_proxy=localhost,127.0.0.1,::1,10.200.0.1"); } finally { - try { execFileSync("rm", ["-rf", fakeHome]); } catch { /* ignore */ } + try { execFileSync("rm", ["-rf", fakeDataDir]); } catch { /* ignore */ } } }); }); From 7c4203455c9132472f6293ac31218ed53d8dd725 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 30 Mar 2026 14:32:38 -0700 Subject: [PATCH 3/6] fix(test): use replaceAll for proxy-env path substitution in tests The sed-extracted block contains the path in comments before the variable assignment. replace() only swaps the first occurrence (the comment), leaving the actual _PROXY_ENV_FILE assignment pointing at /sandbox/.openclaw-data/ which doesn't exist in CI. --- test/service-env.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/service-env.test.js b/test/service-env.test.js index bafdd37b3..46a58e73e 100644 --- a/test/service-env.test.js +++ b/test/service-env.test.js @@ -200,7 +200,7 @@ describe("service environment", () => { 'PROXY_HOST="10.200.0.1"', 'PROXY_PORT="3128"', // Override the hardcoded path to use our temp dir - persistBlock.trimEnd().replace( + persistBlock.trimEnd().replaceAll( '/sandbox/.openclaw-data/proxy-env.sh', `${fakeDataDir}/proxy-env.sh` ), @@ -239,7 +239,7 @@ describe("service environment", () => { "#!/usr/bin/env bash", 'PROXY_HOST="10.200.0.1"', 'PROXY_PORT="3128"', - persistBlock.trimEnd().replace( + persistBlock.trimEnd().replaceAll( '/sandbox/.openclaw-data/proxy-env.sh', `${fakeDataDir}/proxy-env.sh` ), @@ -276,7 +276,7 @@ describe("service environment", () => { "#!/usr/bin/env bash", `PROXY_HOST="${host}"`, 'PROXY_PORT="3128"', - persistBlock.trimEnd().replace( + persistBlock.trimEnd().replaceAll( '/sandbox/.openclaw-data/proxy-env.sh', `${fakeDataDir}/proxy-env.sh` ), From 1d0e381adcff818b9a65177714b2aa76b9e7f4e1 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 30 Mar 2026 16:58:35 -0700 Subject: [PATCH 4/6] fix(security): harden .nemoclaw DAC and move proxy-env.sh to /tmp Address CodeRabbit review findings: - Lock /sandbox/.nemoclaw parent directory (root:root 755) so the agent cannot rename or replace the root-owned blueprints directory - Pre-create config.json and snapshots/ as sandbox-owned for runtime writes - Move proxy-env.sh from sandbox-writable .openclaw-data to /tmp where sticky-bit protection prevents the sandbox user from tampering with the root-owned file - Add rm -f before write to prevent symlink-following attacks - Add empty sed extraction guards in proxy persistence tests - Fix docs: one sentence per line, active voice Ref: https://github.com/NVIDIA/NemoClaw/issues/804 --- Dockerfile | 19 ++++++++++++------- Dockerfile.base | 13 +++++++------ docs/deployment/sandbox-hardening.md | 9 ++++----- scripts/nemoclaw-start.sh | 13 ++++++++++--- test/service-env.test.js | 24 +++++++++++++++++++++--- 5 files changed, 54 insertions(+), 24 deletions(-) diff --git a/Dockerfile b/Dockerfile index 874ede98e..cebe01ba7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -148,15 +148,20 @@ 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 blueprints: /sandbox/.nemoclaw is Landlock read_write (for plugin -# state/config), but blueprints are immutable at runtime. Root ownership prevents -# the agent from modifying them even though the directory is writable. The state/ -# subdirectory stays sandbox-owned for runtime writes. +# 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 -R root:root /sandbox/.nemoclaw/blueprints \ +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 \ - && chown sandbox:sandbox /sandbox/.nemoclaw/state /sandbox/.nemoclaw/migration + && mkdir -p /sandbox/.nemoclaw/state /sandbox/.nemoclaw/migration /sandbox/.nemoclaw/snapshots \ + && chown sandbox:sandbox /sandbox/.nemoclaw/state /sandbox/.nemoclaw/migration /sandbox/.nemoclaw/snapshots \ + && 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. diff --git a/Dockerfile.base b/Dockerfile.base index 4ea73ab11..2c6bc1e44 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -119,16 +119,17 @@ RUN mkdir -p /sandbox/.openclaw-data/agents/main/agent \ # 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 to the writable -# /sandbox/.openclaw-data/proxy-env.sh and sourced from here. +# 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 from writable path (Landlock read-only home)' \ - '[ -f /sandbox/.openclaw-data/proxy-env.sh ] && . /sandbox/.openclaw-data/proxy-env.sh' \ + '# 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 from writable path (Landlock read-only home)' \ - '[ -f /sandbox/.openclaw-data/proxy-env.sh ] && . /sandbox/.openclaw-data/proxy-env.sh' \ + '# 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 diff --git a/docs/deployment/sandbox-hardening.md b/docs/deployment/sandbox-hardening.md index a9c356a59..106b97c21 100644 --- a/docs/deployment/sandbox-hardening.md +++ b/docs/deployment/sandbox-hardening.md @@ -82,8 +82,8 @@ services: ## 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: +The sandbox Landlock policy restricts `/sandbox` (the agent's home directory) to read-only access. +Only explicitly declared directories are writable: | Path | Access | Purpose | |------|--------|---------| @@ -100,9 +100,8 @@ This prevents agents from: - Creating hidden files that persist across invocations - Using writable space for data staging before exfiltration -Shell init files (`.bashrc`, `.profile`) are pre-created at image build time and -source runtime proxy configuration from the writable -`/sandbox/.openclaw-data/proxy-env.sh`. +The image build pre-creates shell init files `.bashrc` and `.profile`. +These files source runtime proxy configuration from `/tmp/nemoclaw-proxy-env.sh`. ## References diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 0a97126e9..8c6b2da0b 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -246,13 +246,20 @@ export no_proxy="$_NO_PROXY_VAL" # ~/.profile or /etc/profile.d/*. # # The /sandbox home directory is Landlock read-only (#804), so we write the proxy -# config to /sandbox/.openclaw-data/proxy-env.sh (writable). The pre-built -# .bashrc and .profile source this file automatically. +# 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. -_PROXY_ENV_FILE="/sandbox/.openclaw-data/proxy-env.sh" +_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" < { ["-n", "/^_PROXY_URL=/,/^chmod 644/p", scriptPath], { encoding: "utf-8" } ); + if (!persistBlock.trim()) { + throw new Error( + "Failed to extract proxy persistence block from scripts/nemoclaw-start.sh — " + + "the _PROXY_URL..chmod block may have been moved or renamed" + ); + } const wrapper = [ "#!/usr/bin/env bash", 'PROXY_HOST="10.200.0.1"', 'PROXY_PORT="3128"', // Override the hardcoded path to use our temp dir persistBlock.trimEnd().replaceAll( - '/sandbox/.openclaw-data/proxy-env.sh', + '/tmp/nemoclaw-proxy-env.sh', `${fakeDataDir}/proxy-env.sh` ), ].join("\n"); @@ -235,12 +241,18 @@ describe("service environment", () => { ["-n", "/^_PROXY_URL=/,/^chmod 644/p", scriptPath], { encoding: "utf-8" } ); + if (!persistBlock.trim()) { + throw new Error( + "Failed to extract proxy persistence block from scripts/nemoclaw-start.sh — " + + "the _PROXY_URL..chmod block may have been moved or renamed" + ); + } const wrapper = [ "#!/usr/bin/env bash", 'PROXY_HOST="10.200.0.1"', 'PROXY_PORT="3128"', persistBlock.trimEnd().replaceAll( - '/sandbox/.openclaw-data/proxy-env.sh', + '/tmp/nemoclaw-proxy-env.sh', `${fakeDataDir}/proxy-env.sh` ), ].join("\n"); @@ -272,12 +284,18 @@ describe("service environment", () => { ["-n", "/^_PROXY_URL=/,/^chmod 644/p", scriptPath], { encoding: "utf-8" } ); + if (!persistBlock.trim()) { + throw new Error( + "Failed to extract proxy persistence block from scripts/nemoclaw-start.sh — " + + "the _PROXY_URL..chmod block may have been moved or renamed" + ); + } const makeWrapper = (host) => [ "#!/usr/bin/env bash", `PROXY_HOST="${host}"`, 'PROXY_PORT="3128"', persistBlock.trimEnd().replaceAll( - '/sandbox/.openclaw-data/proxy-env.sh', + '/tmp/nemoclaw-proxy-env.sh', `${fakeDataDir}/proxy-env.sh` ), ].join("\n"); From 9f449416f8d8b369b4bfc64739160e6674629dab Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 30 Mar 2026 17:27:42 -0700 Subject: [PATCH 5/6] style: fix shfmt formatting in nemoclaw-start.sh --- scripts/nemoclaw-start.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 8c6b2da0b..73c99169c 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -260,7 +260,7 @@ _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" <"$_PROXY_ENV_FILE" < Date: Mon, 30 Mar 2026 20:28:04 -0700 Subject: [PATCH 6/6] fix(sandbox): pre-create .nemoclaw/staging for non-persistent snapshots --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index cebe01ba7..20ededbf0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -158,8 +158,8 @@ 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 \ - && chown sandbox:sandbox /sandbox/.nemoclaw/state /sandbox/.nemoclaw/migration /sandbox/.nemoclaw/snapshots \ + && 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