From cabac16fd4925a705f0f3d4462cb1f80be23e3d4 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Wed, 10 Jun 2026 20:54:09 -0400 Subject: [PATCH] docs: design spec for bubblewrap-sandboxed sessions Co-Authored-By: Claude Opus 4.8 --- .../2026-06-10-sandboxed-sessions-design.md | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-10-sandboxed-sessions-design.md diff --git a/docs/superpowers/specs/2026-06-10-sandboxed-sessions-design.md b/docs/superpowers/specs/2026-06-10-sandboxed-sessions-design.md new file mode 100644 index 0000000..27ac26a --- /dev/null +++ b/docs/superpowers/specs/2026-06-10-sandboxed-sessions-design.md @@ -0,0 +1,204 @@ +# Sandboxed sessions via bubblewrap — design + +**Date:** 2026-06-10 +**Status:** Approved (pending implementation plan) + +## Problem + +Users want to start harness sessions inside a [bubblewrap](https://github.com/containers/bubblewrap) +(`bwrap`) sandbox. Inside the jail the agent can run freely — so the harness is +launched with its "skip permissions" flag (`--dangerously-skip-permissions` for +Claude Code, the equivalent for other harnesses) — while bubblewrap provides the +real safety boundary: controlled filesystem access (read-only or read-write to +the project directory) and optional network access. The configuration must be +quick to set per session. + +## Decisions (from brainstorming) + +- **Config scope:** per session, chosen in the New Session dialog and stored on + the session row so resume/respawn reuse it. +- **Controls:** a "Sandboxed" toggle plus two sub-toggles — filesystem mode + (read-only / read-write) and network (on / off). +- **Skip-permissions:** automatic. Sandbox on ⇒ the harness's skip-permissions + flag is added. The bwrap container is the safety boundary, so there is no + separate widget for it. +- **Filesystem model:** minimal allowlist. User data (`$HOME` and other + directories) is hidden. System directories are bound read-only so binaries and + libraries work; the project directory and a per-harness set of + credential/config paths are bound explicitly. +- **Defaults when sandbox is enabled:** read-write project dir, network on (the + common "let it work, but contained" case; the harness needs network to reach a + hosted model API). +- **bwrap missing:** hard failure, never a silent downgrade to unsandboxed. + +## Key constraint: network is all-or-nothing + +bubblewrap toggles networking at the namespace level — it cannot selectively +allow the model API while blocking everything else. Therefore: + +- **Network on** = the sandbox shares the host network namespace; the agent (and + its tools: `curl`, `git push`, etc.) can reach anything the host can. +- **Network off** = no connectivity at all. The harness process itself cannot + reach a hosted model API, so this mode is only useful with local/offline + models. The toggle's value is blocking the agent's network for offline or + untrusted-code review. + +This constraint is surfaced in the UI defaults (network defaults on). + +## Architecture + +### 1. Data model + +Add one nullable column to `Session`: + +```python +sandbox_json: Mapped[str | None] = mapped_column(default=None) +``` + +`NULL` = unsandboxed. When set it holds: + +```json +{ "fs": "rw" | "ro", "net": true } +``` + +Presence of the value means sandboxed; `fs` and `net` capture the two +sub-toggles. Skip-permissions is *derived* (sandboxed ⇒ on) and is not stored. +A single `user_version`-based migration (see `db/migrations.py`) adds the column. + +Because the config lives on the row, `attach_session` → `_respawn_with_fallback` +and `_get_resume_config` rebuild the same sandboxed command after a stop. + +### 2. The configuration ABC — `HarnessConfigurator` + +Per-harness knowledge lives here. Two additions to `harnesses/base.py`: + +```python +class Bind: + """A path to expose inside the sandbox.""" + src: Path + writable: bool = False + +class HarnessConfigurator(ABC): + ... + sandbox_skip_permission_flags: list[str] = [] + + def sandbox_binds(self) -> list[Bind]: + return [] +``` + +Per-harness values: + +| Harness | `sandbox_skip_permission_flags` | `sandbox_binds()` | +|-----------|----------------------------------------------|--------------------------| +| `claude` | `["--dangerously-skip-permissions"]` | `~/.claude` (rw) | +| `codex` | `["--dangerously-bypass-approvals-and-sandbox"]` | `~/.codex` (rw) | +| `kiro` | `[]` | `~/.kiro` (rw) | +| `generic` | `[]` | `[]` | + +The exact flag spelling and credential paths must be verified against each +installed binary during implementation (e.g. `claude --help`); the values above +are the expected defaults. + +`build_spawn_config` / `build_resume_config` gain a `sandboxed: bool = False` +parameter. When true, the configurator appends +`self.sandbox_skip_permission_flags` to the command list. The configurator does +**not** construct the bwrap invocation — it only declares flags and binds. + +### 3. Bubblewrap policy module — `hqt/sandbox.py` + +A pure, argv-only function: + +```python +@dataclass(frozen=True) +class SandboxPolicy: + fs: str # "rw" | "ro" + net: bool + +def wrap( + command: list[str], + cwd: Path, + policy: SandboxPolicy, + binds: list[Bind], +) -> list[str]: + """Return the `bwrap … -- ` argv.""" +``` + +Assembled from three layers: + +- **Base (always):** `--unshare-all`, `--die-with-parent`; RO binds of system + dirs (`/usr`, `/bin`, `/lib`, `/lib64`, and curated `/etc` essentials such as + `resolv.conf`, `ssl`, `passwd`); `--proc /proc`; `--dev /dev`; `--tmpfs /tmp`; + pass-through of `PATH`, `HOME`, `TERM`, `LANG`. `$HOME` itself is not bound, so + user data is hidden. `~/.gitconfig` bound RO when present. +- **Harness binds:** each `Bind` from the configurator, `--bind` (writable) or + `--ro-bind`, creating parent dirs as needed. +- **cwd:** `--bind cwd cwd` when `fs == "rw"`, else `--ro-bind cwd cwd`. +- **net:** when `policy.net` is true, the network namespace is shared (omit the + net-unshare); otherwise it stays unshared and there is no connectivity. + +Being pure and producing only argv (no subprocess), `wrap` is unit-testable +without bwrap installed. + +### 4. Spawn / resume integration — `SessionService` + +In `create_session`: call `build_spawn_config(..., sandboxed=True)` when the +request is sandboxed, then pass `SpawnConfig.command` through `sandbox.wrap(...)` +(using the configurator's `sandbox_binds()` and the session policy) before +constructing the `SpawnRequest`. `cwd` remains the project path — bwrap binds it. + +The same wrapping applies in `_respawn_with_fallback` (both the resume rung and +the fresh-spawn rung) and in `_get_resume_config`, reading the policy back from +`sandbox_json`. + +`create_session` gains a `sandbox` parameter (the parsed policy or `None`). + +### 5. UI — New Session dialog + +`tui/screens/new_session.py` adds: + +- A `Switch` labelled "Sandboxed" (default off). +- Revealed when on: a filesystem `Select` ("Read-write" / "Read-only", default + Read-write) and a "Network" `Switch` (default on). + +The dialog's result tuple is extended to carry the sandbox config (or `None`), +threaded into `create_session`. + +**bwrap availability gates the toggle.** The dialog checks bubblewrap +availability (the same check `doctor` uses — see §6) on mount. When bwrap is +unavailable, the "Sandboxed" switch is disabled (cannot be turned on) and an +inline warning explains why (e.g. "bubblewrap not found — run `hqt doctor`"). +This makes the unavailable state visible up front rather than only at spawn. + +### 6. Availability check, doctor & failure handling + +- A single shared helper (e.g. `sandbox.is_available()`) reports whether `bwrap` + is on `PATH` and the platform is Linux. Both `doctor` and the New Session + dialog use it, so there is one source of truth. +- `hqt doctor` reports bubblewrap availability as an optional capability + (present / missing), alongside the existing checks. +- The dialog uses the helper to disable the Sandboxed toggle (§5), so an + unsandboxable environment is caught before the user picks anything. +- As a backstop (defense in depth), if a sandboxed session somehow reaches spawn + while bwrap is unavailable, spawn fails loudly with a `ServiceError` — never a + silent downgrade to an unsandboxed session. + +## Testing + +- **`sandbox.wrap` unit tests:** assert the argv for each toggle combination + (rw/ro × net/no-net), that harness binds are spliced in with the right + `--bind`/`--ro-bind`, and that the command appears after `--`. +- **Configurator tests:** skip-permission flags are appended only when + `sandboxed=True`; absent otherwise. +- **Service test:** a sandboxed `create_session` wraps the command in `bwrap`; + a bwrap-missing environment raises `ServiceError`. +- **Availability/UI test:** `sandbox.is_available()` is false when `bwrap` is + absent or the platform is non-Linux, and the dialog disables the Sandboxed + toggle in that case. +- **Migration test:** the new column is added and round-trips a policy. + +## Out of scope (YAGNI for v1) + +- Per-project or named global sandbox profiles (per-session only for now). +- A freeform "extra bind paths" field in the dialog. +- Selective network filtering (proxy/firewall) — bwrap is namespace-level only. +- Remembering the last-used sandbox config as a default.