diff --git a/docs/design/a7-sandbox-loosen-auto.md b/docs/design/a7-sandbox-loosen-auto.md index 47ed545..9773f97 100644 --- a/docs/design/a7-sandbox-loosen-auto.md +++ b/docs/design/a7-sandbox-loosen-auto.md @@ -2,6 +2,10 @@ _Status: Phase 1 Implemented — merged in PR [#148](https://github.com/zjshen14/opencli/pull/148) (2026-05-23). Phase 2 Implemented — merged via [#149](https://github.com/zjshen14/opencli/issues/149) (2026-05-24)._ +> **Amendment — issue [#299](https://github.com/zjshen14/opencli/issues/299) / PR [#305](https://github.com/zjshen14/opencli/pull/305):** `~/.config` and `~/.local` were later REMOVED from the auto write-set. The distinction that matters is not "dot-dir in home" but **"on a path that gets executed or sourced later"** — `~/.local/bin` is on `$PATH` and `~/.config/{fish,npm,...}` is an rc-load location, so a sandboxed write there becomes UNSANDBOXED execution on the user's next shell, a persistence/escape vector that defeats the sandbox. Package caches that are not on `$PATH` (`~/.npm`, `~/.cargo/registry`, `~/.cache`, …) remain writable. (The body below still shows the original Phase-1 set for historical accuracy.) +> +> Residual, deliberately-accepted risk: `~/.cargo/bin` and `~/.yarn/bin` are on `$PATH` for some users, yet `.cargo`/`.yarn` are still bound writable. Excluding just the `bin` subdirectory is the targeted fix if that becomes a real vector. Users who need `~/.config` writes in `auto` currently have no option short of `--sandbox off` (a targeted allowlist knob is the better future answer). + --- ## Problem diff --git a/src/tools/exec/sandbox/bwrap.ts b/src/tools/exec/sandbox/bwrap.ts index e70f159..5a211fa 100644 --- a/src/tools/exec/sandbox/bwrap.ts +++ b/src/tools/exec/sandbox/bwrap.ts @@ -10,11 +10,13 @@ const BWRAP_CANDIDATES = ["/usr/bin/bwrap", "/usr/local/bin/bwrap"]; // Common dev-tooling dot-dirs bound writable in auto mode. Pre-created at // runner construction so bwrap's --bind doesn't fail when a path is absent. -// See docs/design/a7-sandbox-loosen-auto.md. +// ~/.config and ~/.local are intentionally EXCLUDED: they sit on the user's shell +// $PATH / rc-load path (~/.local/bin, ~/.config/fish, ~/.config/npm, ...) so a +// sandboxed command could plant an executable or rc backdoor that runs UNSANDBOXED +// later — a persistence/escape vector. See #299. Package caches below are not on +// $PATH and are safe(r) to expose. See docs/design/a7-sandbox-loosen-auto.md. const AUTO_HOME_DIRS = [ ".cache", - ".config", - ".local", ".npm", ".cargo", ".yarn", diff --git a/src/tools/exec/sandbox/sandbox-exec.test.ts b/src/tools/exec/sandbox/sandbox-exec.test.ts index a87fdca..096ee22 100644 --- a/src/tools/exec/sandbox/sandbox-exec.test.ts +++ b/src/tools/exec/sandbox/sandbox-exec.test.ts @@ -95,6 +95,24 @@ describe.skipIf(!isMacOS)("SandboxExecRunner (macOS only)", () => { expect(result.exitCode).toBe(0); }); + it("blocks writes to ~/.config (persistence vector, #299)", async () => { + const testFile = join(HOME, ".config", `.sandbox-test-${Date.now()}`); + const result = await runner.exec( + `mkdir -p ~/.config && touch "${testFile}" 2>&1; rm -f "${testFile}" 2>/dev/null; exit $?`, + { cwd: process.cwd() }, + ); + expect(result.stderr + result.stdout).toMatch(/permitted|denied/i); + }); + + it("blocks writes to ~/.local (persistence vector, #299)", async () => { + const testFile = join(HOME, ".local", `.sandbox-test-${Date.now()}`); + const result = await runner.exec( + `mkdir -p ~/.local && touch "${testFile}" 2>&1; rm -f "${testFile}" 2>/dev/null; exit $?`, + { cwd: process.cwd() }, + ); + expect(result.stderr + result.stdout).toMatch(/permitted|denied/i); + }); + it("blocks writes to ~/.ssh (credential path)", async () => { const testFile = join(HOME, ".ssh", `.sandbox-test-${Date.now()}`); const result = await runner.exec( diff --git a/src/tools/exec/sandbox/sandbox-exec.ts b/src/tools/exec/sandbox/sandbox-exec.ts index dc65b8f..00ab228 100644 --- a/src/tools/exec/sandbox/sandbox-exec.ts +++ b/src/tools/exec/sandbox/sandbox-exec.ts @@ -113,10 +113,12 @@ function buildAutoProfile(cwd: string, home: string): string { (literal "/dev/urandom") (literal "/dev/random")) -; XDG base directories +; XDG cache and package-manager dot-dirs. ~/.config and ~/.local are intentionally +; NOT writable here: they sit on the shell $PATH / rc-load path (~/.local/bin, +; ~/.config/fish, ~/.config/npm, ...), so a sandboxed command could plant an +; executable or rc backdoor that runs UNSANDBOXED later (persistence/escape). +; See #299. (allow file-write* (subpath "${home}/.cache")) -(allow file-write* (subpath "${home}/.config")) -(allow file-write* (subpath "${home}/.local")) ; Package-manager dot-dirs (allow file-write* (subpath "${home}/.npm"))