docs: design spec for bubblewrap-sandboxed sessions
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||||
Reference in New Issue
Block a user