docs: design spec for bubblewrap-sandboxed sessions

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 20:54:09 -04:00
parent 6ed598b443
commit cabac16fd4
@@ -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 … -- <command>` 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.