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:
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user