Files
hqt/docs/superpowers/specs/2026-06-10-sandboxed-sessions-design.md
T
2026-06-10 20:54:09 -04:00

205 lines
8.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.