feat: configurators declare sandbox skip-permission flags and binds

Add sandbox_skip_permission_flags class attr and sandbox_binds() method
to HarnessConfigurator ABC; thread sandboxed: bool = False through all
build_spawn_config / build_resume_config signatures. Claude and Codex
append their respective skip-permission flags when sandboxed=True; Kiro
and Generic accept the param but add no flags.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 21:20:50 -04:00
parent 9db79c68f4
commit 4c36385045
6 changed files with 137 additions and 15 deletions
+18 -2
View File
@@ -2,6 +2,8 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
from hqt.sandbox import Bind
@dataclass
class SpawnConfig:
@@ -14,20 +16,34 @@ class HarnessConfigurator(ABC):
name: str
display_name: str
captures_session_id: bool = False
# Flags appended to the harness command when the session is sandboxed.
sandbox_skip_permission_flags: list[str] = []
@abstractmethod
def generate_session_id(self, hqt_session_id: int) -> str: ...
@abstractmethod
def build_spawn_config(
self, project_path: Path, harness_session_id: str, model: str | None = None
self,
project_path: Path,
harness_session_id: str,
model: str | None = None,
sandboxed: bool = False,
) -> SpawnConfig: ...
@abstractmethod
def build_resume_config(
self, project_path: Path, harness_session_id: str, model: str | None = None
self,
project_path: Path,
harness_session_id: str,
model: str | None = None,
sandboxed: bool = False,
) -> SpawnConfig: ...
def sandbox_binds(self) -> list[Bind]:
"""Host paths this harness needs inside the jail (credentials, config)."""
return []
def capture_session_id(self, project_path: Path, since: float) -> str | None:
return None
+19 -2
View File
@@ -2,6 +2,7 @@ import uuid
from pathlib import Path
from hqt.harnesses.base import HarnessConfigurator, SpawnConfig
from hqt.sandbox import Bind
NAMESPACE = uuid.UUID("a1b2c3d4-e5f6-7890-abcd-ef1234567890")
@@ -9,6 +10,7 @@ NAMESPACE = uuid.UUID("a1b2c3d4-e5f6-7890-abcd-ef1234567890")
class ClaudeConfigurator(HarnessConfigurator):
name = "claude"
display_name = "Claude Code"
sandbox_skip_permission_flags = ["--dangerously-skip-permissions"]
# Markers for parse_status — conservative, case-insensitive substring checks.
# None returned when none match; caller falls back to activity-based status.
@@ -21,20 +23,35 @@ class ClaudeConfigurator(HarnessConfigurator):
def generate_session_id(self, hqt_session_id: int) -> str:
return str(uuid.uuid5(NAMESPACE, str(hqt_session_id)))
def sandbox_binds(self) -> list[Bind]:
return [Bind(Path.home() / ".claude", writable=True)]
def build_spawn_config(
self, project_path: Path, harness_session_id: str, model: str | None = None
self,
project_path: Path,
harness_session_id: str,
model: str | None = None,
sandboxed: bool = False,
) -> SpawnConfig:
cmd = ["claude", "--session-id", harness_session_id]
if model:
cmd += ["--model", model]
if sandboxed:
cmd += self.sandbox_skip_permission_flags
return SpawnConfig(command=cmd, cwd=project_path)
def build_resume_config(
self, project_path: Path, harness_session_id: str, model: str | None = None
self,
project_path: Path,
harness_session_id: str,
model: str | None = None,
sandboxed: bool = False,
) -> SpawnConfig:
cmd = ["claude", "--resume", harness_session_id]
if model:
cmd += ["--model", model]
if sandboxed:
cmd += self.sandbox_skip_permission_flags
return SpawnConfig(command=cmd, cwd=project_path)
def parse_status(self, pane_text: str) -> str | None:
+19 -2
View File
@@ -4,6 +4,7 @@ from datetime import datetime
from pathlib import Path
from hqt.harnesses.base import HarnessConfigurator, SpawnConfig
from hqt.sandbox import Bind
log = logging.getLogger(__name__)
@@ -12,6 +13,7 @@ class CodexConfigurator(HarnessConfigurator):
name = "codex"
display_name = "Codex"
captures_session_id = True
sandbox_skip_permission_flags = ["--dangerously-bypass-approvals-and-sandbox"]
# Markers for parse_status — conservative, case-insensitive substring checks.
_WORKING_MARKERS = ("esc to interrupt",)
@@ -19,20 +21,35 @@ class CodexConfigurator(HarnessConfigurator):
def generate_session_id(self, hqt_session_id: int) -> str:
return str(hqt_session_id)
def sandbox_binds(self) -> list[Bind]:
return [Bind(Path.home() / ".codex", writable=True)]
def build_spawn_config(
self, project_path: Path, harness_session_id: str, model: str | None = None
self,
project_path: Path,
harness_session_id: str,
model: str | None = None,
sandboxed: bool = False,
) -> SpawnConfig:
cmd = ["codex"]
if model:
cmd += ["-m", model]
if sandboxed:
cmd += self.sandbox_skip_permission_flags
return SpawnConfig(command=cmd, cwd=project_path)
def build_resume_config(
self, project_path: Path, harness_session_id: str, model: str | None = None
self,
project_path: Path,
harness_session_id: str,
model: str | None = None,
sandboxed: bool = False,
) -> SpawnConfig:
cmd = ["codex", "resume", harness_session_id]
if model:
cmd += ["-m", model]
if sandboxed:
cmd += self.sandbox_skip_permission_flags
return SpawnConfig(command=cmd, cwd=project_path)
def parse_status(self, pane_text: str) -> str | None:
+10 -2
View File
@@ -14,11 +14,19 @@ class GenericConfigurator(HarnessConfigurator):
return str(hqt_session_id)
def build_spawn_config(
self, project_path: Path, harness_session_id: str, model: str | None = None
self,
project_path: Path,
harness_session_id: str,
model: str | None = None,
sandboxed: bool = False,
) -> SpawnConfig:
return SpawnConfig(command=self._command, cwd=project_path)
def build_resume_config(
self, project_path: Path, harness_session_id: str, model: str | None = None
self,
project_path: Path,
harness_session_id: str,
model: str | None = None,
sandboxed: bool = False,
) -> SpawnConfig:
return SpawnConfig(command=self._command, cwd=project_path)
+14 -2
View File
@@ -1,6 +1,7 @@
from pathlib import Path
from hqt.harnesses.base import HarnessConfigurator, SpawnConfig
from hqt.sandbox import Bind
class KiroConfigurator(HarnessConfigurator):
@@ -10,12 +11,23 @@ class KiroConfigurator(HarnessConfigurator):
def generate_session_id(self, hqt_session_id: int) -> str:
return str(hqt_session_id)
def sandbox_binds(self) -> list[Bind]:
return [Bind(Path.home() / ".kiro", writable=True)]
def build_spawn_config(
self, project_path: Path, harness_session_id: str, model: str | None = None
self,
project_path: Path,
harness_session_id: str,
model: str | None = None,
sandboxed: bool = False,
) -> SpawnConfig:
return SpawnConfig(command=["kiro-cli", "chat"], cwd=project_path)
def build_resume_config(
self, project_path: Path, harness_session_id: str, model: str | None = None
self,
project_path: Path,
harness_session_id: str,
model: str | None = None,
sandboxed: bool = False,
) -> SpawnConfig:
return SpawnConfig(command=["kiro-cli", "chat"], cwd=project_path)
+57 -5
View File
@@ -4,8 +4,11 @@ import time
from pathlib import Path
from unittest.mock import patch
from hqt.harnesses.configurators.codex import CodexConfigurator
from hqt.harnesses.base import HarnessConfigurator
from hqt.harnesses.configurators.claude import ClaudeConfigurator
from hqt.harnesses.configurators.codex import CodexConfigurator
from hqt.harnesses.configurators.generic import GenericConfigurator
from hqt.harnesses.configurators.kiro import KiroConfigurator
from hqt.harnesses.registry import discover_harnesses
@@ -129,8 +132,6 @@ def test_codex_captures_session_id_flag():
def test_base_captures_session_id_flag_false():
"""HarnessConfigurator base class default must be False."""
from hqt.harnesses.base import HarnessConfigurator
assert HarnessConfigurator.captures_session_id is False
@@ -287,8 +288,6 @@ CODEX_IDLE_SCREEN = """\
def test_base_parse_status_returns_none():
"""Base HarnessConfigurator.parse_status always returns None."""
from hqt.harnesses.configurators.kiro import KiroConfigurator
k = KiroConfigurator()
assert k.parse_status("anything") is None
assert k.parse_status("") is None
@@ -346,3 +345,56 @@ def test_codex_parse_status_none_for_empty():
"""CodexConfigurator: empty text → None."""
c = CodexConfigurator()
assert c.parse_status("") is None
# ---------------------------------------------------------------------------
# Task 3: sandbox skip-permission flags and binds
# ---------------------------------------------------------------------------
def test_base_skip_flags_default_empty():
assert HarnessConfigurator.sandbox_skip_permission_flags == []
def test_base_sandbox_binds_default_empty():
# GenericConfigurator does not override sandbox_binds; verifies the base default.
assert GenericConfigurator(["mytool"]).sandbox_binds() == []
def test_claude_skip_flag_added_when_sandboxed():
c = ClaudeConfigurator()
sid = c.generate_session_id(1)
cfg = c.build_spawn_config(Path("/tmp"), sid, sandboxed=True)
assert "--dangerously-skip-permissions" in cfg.command
def test_claude_skip_flag_absent_when_not_sandboxed():
c = ClaudeConfigurator()
sid = c.generate_session_id(1)
cfg = c.build_spawn_config(Path("/tmp"), sid, sandboxed=False)
assert "--dangerously-skip-permissions" not in cfg.command
def test_claude_resume_skip_flag_when_sandboxed():
c = ClaudeConfigurator()
sid = c.generate_session_id(1)
cfg = c.build_resume_config(Path("/tmp"), sid, sandboxed=True)
assert "--dangerously-skip-permissions" in cfg.command
def test_claude_sandbox_binds_includes_dot_claude():
c = ClaudeConfigurator()
binds = c.sandbox_binds()
assert any(b.src == Path.home() / ".claude" and b.writable for b in binds)
def test_codex_skip_flag_added_when_sandboxed():
c = CodexConfigurator()
cfg = c.build_spawn_config(Path("/tmp"), "1", sandboxed=True)
assert "--dangerously-bypass-approvals-and-sandbox" in cfg.command
def test_generic_sandboxed_is_noop_flagwise():
g = GenericConfigurator(["mytool"])
cfg = g.build_spawn_config(Path("/tmp"), "1", sandboxed=True)
assert cfg.command == ["mytool"]