Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/design/a7-sandbox-loosen-auto.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions src/tools/exec/sandbox/bwrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions src/tools/exec/sandbox/sandbox-exec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
8 changes: 5 additions & 3 deletions src/tools/exec/sandbox/sandbox-exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down