diff --git a/Dockerfile b/Dockerfile index d62ee0558..20ededbf0 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,21 @@ RUN sha256sum /sandbox/.openclaw/openclaw.json > /sandbox/.openclaw/.config-hash && chmod 444 /sandbox/.openclaw/.config-hash \ && chown root:root /sandbox/.openclaw/.config-hash +# DAC-protect .nemoclaw directory: /sandbox/.nemoclaw is Landlock read_write +# (for plugin state/config), but the parent and blueprints are immutable at +# runtime. Root ownership on the parent prevents the agent from renaming or +# replacing the root-owned blueprints directory. Only state/, migration/, +# snapshots/, and config.json are sandbox-owned for runtime writes. +# Ref: https://github.com/NVIDIA/NemoClaw/issues/804 +RUN chown root:root /sandbox/.nemoclaw \ + && chmod 755 /sandbox/.nemoclaw \ + && chown -R root:root /sandbox/.nemoclaw/blueprints \ + && chmod -R 755 /sandbox/.nemoclaw/blueprints \ + && mkdir -p /sandbox/.nemoclaw/state /sandbox/.nemoclaw/migration /sandbox/.nemoclaw/snapshots /sandbox/.nemoclaw/staging \ + && chown sandbox:sandbox /sandbox/.nemoclaw/state /sandbox/.nemoclaw/migration /sandbox/.nemoclaw/snapshots /sandbox/.nemoclaw/staging \ + && touch /sandbox/.nemoclaw/config.json \ + && chown sandbox:sandbox /sandbox/.nemoclaw/config.json + # Entrypoint runs as root to start the gateway as the gateway user, # then drops to sandbox for agent commands. See nemoclaw-start.sh. ENTRYPOINT ["/usr/local/bin/nemoclaw-start"] diff --git a/Dockerfile.base b/Dockerfile.base index 3fd658485..2c6bc1e44 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -117,6 +117,22 @@ RUN mkdir -p /sandbox/.openclaw-data/agents/main/agent \ && ln -s /sandbox/.openclaw-data/update-check.json /sandbox/.openclaw/update-check.json \ && chown -R sandbox:sandbox /sandbox/.openclaw /sandbox/.openclaw-data +# Pre-create shell init files for the sandbox user. +# The /sandbox home directory is Landlock read-only at runtime (#804), so these +# files must exist at build time. Runtime proxy config is written by the +# entrypoint to /tmp/nemoclaw-proxy-env.sh (root-owned, sticky-bit protected) +# and sourced from here on every interactive session. +# hadolint ignore=SC2028 +RUN printf '%s\n' \ + '# Source runtime proxy config (Landlock read-only home, #804)' \ + '[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh' \ + > /sandbox/.bashrc \ + && printf '%s\n' \ + '# Source runtime proxy config (Landlock read-only home, #804)' \ + '[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh' \ + > /sandbox/.profile \ + && chown sandbox:sandbox /sandbox/.bashrc /sandbox/.profile + # Install OpenClaw CLI + PyYAML for inline Python scripts in e2e tests. # When bumping the openclaw version, rebuild this base image. RUN npm install -g openclaw@2026.3.11 \ diff --git a/docs/deployment/sandbox-hardening.md b/docs/deployment/sandbox-hardening.md index a90231b16..106b97c21 100644 --- a/docs/deployment/sandbox-hardening.md +++ b/docs/deployment/sandbox-hardening.md @@ -80,8 +80,32 @@ services: > capability dropping in your `docker run` flags, Compose file, or Kubernetes > `securityContext`. +## Read-Only Home Directory + +The sandbox Landlock policy restricts `/sandbox` (the agent's home directory) to read-only access. +Only explicitly declared directories are writable: + +| Path | Access | Purpose | +|------|--------|---------| +| `/sandbox` | read-only | Home directory — agents cannot create arbitrary files | +| `/sandbox/.openclaw` | read-only | Immutable gateway config (auth tokens, CORS) | +| `/sandbox/.openclaw-data` | read-write | Agent state, workspace, plugins (via symlinks) | +| `/sandbox/.nemoclaw` | read-write | Plugin state and config; blueprints within are DAC-protected (root-owned) | +| `/tmp` | read-write | Temporary files and logs | + +This prevents agents from: + +- Writing scripts and executing them later +- Modifying their own runtime environment +- Creating hidden files that persist across invocations +- Using writable space for data staging before exfiltration + +The image build pre-creates shell init files `.bashrc` and `.profile`. +These files source runtime proxy configuration from `/tmp/nemoclaw-proxy-env.sh`. + ## References +- [#804](https://github.com/NVIDIA/NemoClaw/issues/804) — Read-only home directory - [#807](https://github.com/NVIDIA/NemoClaw/issues/807) — gcc in sandbox image - [#808](https://github.com/NVIDIA/NemoClaw/issues/808) — netcat in sandbox image - [#809](https://github.com/NVIDIA/NemoClaw/issues/809) — No process limit 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..73c99169c 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,47 @@ export no_proxy="$_NO_PROXY_VAL" # OpenShell re-injects narrow NO_PROXY/no_proxy=127.0.0.1,localhost,::1 every # time a user connects via `openshell sandbox connect`. The connect path spawns # `/bin/bash -i` (interactive, non-login), which sources ~/.bashrc — NOT -# ~/.profile or /etc/profile.d/*. Write the full proxy config to ~/.bashrc so -# interactive sessions see the correct values. +# ~/.profile or /etc/profile.d/*. +# +# The /sandbox home directory is Landlock read-only (#804), so we write the proxy +# config to /tmp/nemoclaw-proxy-env.sh. The pre-built .bashrc and .profile +# source this file automatically. +# +# SECURITY: /tmp has the sticky bit, so when running as root the sandbox user +# cannot delete or replace this root-owned file. In non-root mode privilege +# separation is already disabled, so this is an accepted limitation. # # Both uppercase and lowercase variants are required: Node.js undici prefers # lowercase (no_proxy) over uppercase (NO_PROXY) when both are set. # curl/wget use uppercase. gRPC C-core uses lowercase. -# -# Also write to ~/.profile for login-shell paths (e.g. `sandbox create -- cmd` -# which spawns `bash -lc`). -# -# Idempotency: begin/end markers delimit the block so it can be replaced -# on restart if NEMOCLAW_PROXY_HOST/PORT change, without duplicating. -_PROXY_MARKER_BEGIN="# nemoclaw-proxy-config begin" -_PROXY_MARKER_END="# nemoclaw-proxy-config end" -_PROXY_SNIPPET="${_PROXY_MARKER_BEGIN} -export HTTP_PROXY=\"$_PROXY_URL\" -export HTTPS_PROXY=\"$_PROXY_URL\" -export NO_PROXY=\"$_NO_PROXY_VAL\" -export http_proxy=\"$_PROXY_URL\" -export https_proxy=\"$_PROXY_URL\" -export no_proxy=\"$_NO_PROXY_VAL\" -${_PROXY_MARKER_END}" - -if [ "$(id -u)" -eq 0 ]; then - _SANDBOX_HOME=$(getent passwd sandbox 2>/dev/null | cut -d: -f6) - _SANDBOX_HOME="${_SANDBOX_HOME:-/sandbox}" -else - _SANDBOX_HOME="${HOME:-/sandbox}" -fi - -_write_proxy_snippet() { - local target="$1" - if [ -f "$target" ] && grep -qF "$_PROXY_MARKER_BEGIN" "$target" 2>/dev/null; then - local tmp - tmp="$(mktemp)" - awk -v b="$_PROXY_MARKER_BEGIN" -v e="$_PROXY_MARKER_END" \ - '$0==b{s=1;next} $0==e{s=0;next} !s' "$target" >"$tmp" - printf '%s\n' "$_PROXY_SNIPPET" >>"$tmp" - cat "$tmp" >"$target" - rm -f "$tmp" - return 0 - fi - printf '\n%s\n' "$_PROXY_SNIPPET" >>"$target" -} - -if [ -w "$_SANDBOX_HOME" ]; then - _write_proxy_snippet "${_SANDBOX_HOME}/.bashrc" - _write_proxy_snippet "${_SANDBOX_HOME}/.profile" -fi +_PROXY_ENV_FILE="/tmp/nemoclaw-proxy-env.sh" +# Remove any pre-existing file/symlink to prevent symlink-following attacks, +# then write a fresh file. +rm -f "$_PROXY_ENV_FILE" 2>/dev/null || true +cat >"$_PROXY_ENV_FILE" </dev/null || true # ── Non-root fallback ────────────────────────────────────────── # OpenShell runs containers with --security-opt=no-new-privileges, which diff --git a/test/service-env.test.js b/test/service-env.test.js index 3d9e39c75..1052e0f93 100644 --- a/test/service-env.test.js +++ b/test/service-env.test.js @@ -184,141 +184,162 @@ 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" } ); + 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(), + // Override the hardcoded path to use our temp dir + persistBlock.trimEnd().replaceAll( + '/tmp/nemoclaw-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 }, - }); + execFileSync("bash", [tmpFile], { encoding: "utf-8" }); - 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"); - - 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" } ); + 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(), + persistBlock.trimEnd().replaceAll( + '/tmp/nemoclaw-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" } ); + 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(), + persistBlock.trimEnd().replaceAll( + '/tmp/nemoclaw-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 +347,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 */ } } }); });