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

8.5 KiB
Raw Blame History

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 ($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:

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 /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.