feat: wrap sandboxed sessions in bwrap on spawn and resume

- Import `hqt.sandbox` module and `SandboxPolicy` in service.py
- Add `_sandbox_policy` helper to decode persisted JSON policy from a session row
- Add `_wrap_command` helper that calls `sandbox.is_available` / `sandbox.wrap` and raises `ServiceError` when bwrap is unavailable for a sandboxed session
- Add `sandbox: SandboxPolicy | None = None` parameter to `create_session`; persist as `sandbox_json`, pass `sandboxed=` to `build_spawn_config`, wrap command before spawning
- Update `_get_resume_config` to pass `sandboxed=` to `build_resume_config` and wrap the resulting command
- Update rung-2 path in `_respawn_with_fallback` to pass `sandboxed=` and wrap the fresh-spawn command
- Add three new tests covering: sandboxed create wraps command, unsandboxed create does not wrap, and ServiceError on missing bwrap

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 21:28:22 -04:00
parent 080ddd9364
commit e54cbb96b6
2 changed files with 117 additions and 8 deletions
+65 -1
View File
@@ -8,6 +8,7 @@ from sqlalchemy.pool import StaticPool
from hqt.db.models import Base, Harness, Project
from hqt.errors import ServiceError
from hqt.sandbox import SandboxPolicy
from hqt.sessions.service import SessionInfo, SessionService
from hqt.tmux.manager import SpawnResult, TmuxManager
@@ -816,7 +817,7 @@ async def test_attach_late_capture_updates_id_before_resume(
assert ok is True
harness.build_resume_config.assert_called_with(
Path("/tmp/myproj"), "late-codex-id", result.session.model
Path("/tmp/myproj"), "late-codex-id", result.session.model, sandboxed=False
)
from hqt.db.models import Session as DBSession
@@ -1518,3 +1519,66 @@ async def test_create_session_no_lock_for_non_capturing_harness(service, db, tmu
# The `service` fixture's harness has captures_session_id = False, so the
# spawn must NOT be serialized under the capture lock.
assert observed["locked_during_spawn"] is False
# ---------------------------------------------------------------------------
# Task 5: sandbox wrapping on spawn and resume
# ---------------------------------------------------------------------------
@pytest.fixture
def sandbox_harness():
h = MagicMock()
h.captures_session_id = False
h.generate_session_id.return_value = "sess-1"
h.build_spawn_config.return_value = MagicMock(
command=["claude", "--dangerously-skip-permissions"],
env={},
cwd=Path("/tmp/myproj"),
)
h.sandbox_binds.return_value = []
h.parse_status = MagicMock(return_value=None)
return {"claude-code": h}
@pytest.fixture
def sandbox_service(factory, db, tmux, sandbox_harness):
return SessionService(factory=factory, tmux=tmux, harnesses=sandbox_harness)
@pytest.mark.asyncio
async def test_sandboxed_create_wraps_command(sandbox_service, db, tmux, sandbox_harness):
with patch("hqt.sessions.service.sandbox.is_available", return_value=True), patch(
"hqt.sessions.service.sandbox.wrap", return_value=["bwrap", "--", "claude"]
) as mock_wrap:
result = await sandbox_service.create_session(
project_id=1,
harness_name="claude-code",
sandbox=SandboxPolicy(fs="rw", net=True),
)
mock_wrap.assert_called_once()
sandbox_harness["claude-code"].build_spawn_config.assert_called_once()
assert sandbox_harness["claude-code"].build_spawn_config.call_args.kwargs[
"sandboxed"
] is True
assert tmux.spawn.call_args.args[0].command == ["bwrap", "--", "claude"]
assert result.session.sandbox_json == '{"fs": "rw", "net": true}'
@pytest.mark.asyncio
async def test_unsandboxed_create_does_not_wrap(service, db, tmux, harnesses):
with patch("hqt.sessions.service.sandbox.wrap") as mock_wrap:
result = await service.create_session(project_id=1, harness_name="claude-code")
mock_wrap.assert_not_called()
assert result.session.sandbox_json is None
@pytest.mark.asyncio
async def test_sandboxed_create_raises_when_bwrap_missing(sandbox_service, db):
with patch("hqt.sessions.service.sandbox.is_available", return_value=False):
with pytest.raises(ServiceError, match="bubblewrap"):
await sandbox_service.create_session(
project_id=1,
harness_name="claude-code",
sandbox=SandboxPolicy(fs="rw", net=True),
)