cabac16fd4
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
205 lines
8.5 KiB
Markdown
205 lines
8.5 KiB
Markdown
# 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.
|