From 4c3638504559c328a186c166bc4610575ff2f471 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Wed, 10 Jun 2026 21:20:50 -0400 Subject: [PATCH] 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 --- src/hqt/harnesses/base.py | 20 ++++++- src/hqt/harnesses/configurators/claude.py | 21 +++++++- src/hqt/harnesses/configurators/codex.py | 21 +++++++- src/hqt/harnesses/configurators/generic.py | 12 ++++- src/hqt/harnesses/configurators/kiro.py | 16 +++++- tests/test_harnesses.py | 62 ++++++++++++++++++++-- 6 files changed, 137 insertions(+), 15 deletions(-) diff --git a/src/hqt/harnesses/base.py b/src/hqt/harnesses/base.py index ba03a19..6f49ab9 100644 --- a/src/hqt/harnesses/base.py +++ b/src/hqt/harnesses/base.py @@ -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 diff --git a/src/hqt/harnesses/configurators/claude.py b/src/hqt/harnesses/configurators/claude.py index 3adbded..d9e08a5 100644 --- a/src/hqt/harnesses/configurators/claude.py +++ b/src/hqt/harnesses/configurators/claude.py @@ -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: diff --git a/src/hqt/harnesses/configurators/codex.py b/src/hqt/harnesses/configurators/codex.py index 10107f4..8555fa0 100644 --- a/src/hqt/harnesses/configurators/codex.py +++ b/src/hqt/harnesses/configurators/codex.py @@ -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: diff --git a/src/hqt/harnesses/configurators/generic.py b/src/hqt/harnesses/configurators/generic.py index 505ea61..28b48e4 100644 --- a/src/hqt/harnesses/configurators/generic.py +++ b/src/hqt/harnesses/configurators/generic.py @@ -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) diff --git a/src/hqt/harnesses/configurators/kiro.py b/src/hqt/harnesses/configurators/kiro.py index c652aff..2b30a87 100644 --- a/src/hqt/harnesses/configurators/kiro.py +++ b/src/hqt/harnesses/configurators/kiro.py @@ -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) diff --git a/tests/test_harnesses.py b/tests/test_harnesses.py index 2e164a6..eae807e 100644 --- a/tests/test_harnesses.py +++ b/tests/test_harnesses.py @@ -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"]