# 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.