Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8.5 KiB
Sandboxed sessions via bubblewrap — design
Date: 2026-06-10 Status: Approved (pending implementation plan)
Problem
Users want to start harness sessions inside a 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 (
$HOMEand 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:
sandbox_json: Mapped[str | None] = mapped_column(default=None)
NULL = unsandboxed. When set it holds:
{ "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:
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:
@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/etcessentials such asresolv.conf,ssl,passwd);--proc /proc;--dev /dev;--tmpfs /tmp; pass-through ofPATH,HOME,TERM,LANG.$HOMEitself is not bound, so user data is hidden.~/.gitconfigbound RO when present. - Harness binds: each
Bindfrom the configurator,--bind(writable) or--ro-bind, creating parent dirs as needed. - cwd:
--bind cwd cwdwhenfs == "rw", else--ro-bind cwd cwd. - net: when
policy.netis 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
Switchlabelled "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 whetherbwrapis onPATHand the platform is Linux. Bothdoctorand the New Session dialog use it, so there is one source of truth. hqt doctorreports 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.wrapunit 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_sessionwraps the command inbwrap; a bwrap-missing environment raisesServiceError. - Availability/UI test:
sandbox.is_available()is false whenbwrapis 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.