From f773b5c101c9886a2d03a2613f54420ae559fa93 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 21 Aug 2026 10:40:29 +0200 Subject: [PATCH] fix: enforce sandbox execution boundary --- .forgejo/workflows/ci-smoke.yaml | 60 ++++-- README.md | 4 + WORK-RECORDS.md | 7 + .../GLAS-WP-0004-live-proof-2026-08-20.md | 13 +- docs/execution-profiles.md | 14 +- docs/harness-contract.md | 28 ++- docs/intakes/residuals.md | 35 ++++ pyproject.toml | 1 + src/glas_harness/contract.py | 9 +- src/glas_harness/gateway.py | 10 +- src/glas_harness/reins/_shared.py | 22 --- src/glas_harness/reins/rein_aharness.py | 67 ++++--- src/glas_harness/reins/rein_openweights.py | 71 ++++--- src/glas_harness/transport.py | 184 ++++++++++++++++++ tests/test_gateway.py | 15 +- tests/test_rein_aharness.py | 115 +++++++---- tests/test_rein_openweights.py | 100 +++++++--- tests/test_transport.py | 135 +++++++++++++ ...0005-sandbox-boundary-runtime-hardening.md | 156 +++++++++++++++ 19 files changed, 865 insertions(+), 181 deletions(-) create mode 100644 src/glas_harness/transport.py create mode 100644 tests/test_transport.py create mode 100644 workplans/GLAS-WP-0005-sandbox-boundary-runtime-hardening.md diff --git a/.forgejo/workflows/ci-smoke.yaml b/.forgejo/workflows/ci-smoke.yaml index 2d09e3b..f1da179 100644 --- a/.forgejo/workflows/ci-smoke.yaml +++ b/.forgejo/workflows/ci-smoke.yaml @@ -1,27 +1,53 @@ -name: CI Smoke +# Forgejo Actions CI for glas-harness. +# ubuntu-latest maps to a minimal node:20-bookworm runner, so use archive +# checkout and install Python explicitly. sand-boxer is a sibling source +# dependency and is fetched separately for contract tests. +name: ci on: push: - branches: - - main + branches: [main] + pull_request: + branches: [main] workflow_dispatch: jobs: - host-smoke: - runs-on: self-hosted - steps: - - name: Routing probe (host runner) - run: | - set -eu - echo "repository=${GITHUB_REPOSITORY:-unknown}" - echo "sha=${GITHUB_SHA:-unknown}" - echo "runner=${RUNNER_NAME:-unknown}" - uname -a - - container-smoke: + validate: runs-on: ubuntu-latest steps: - - name: Routing probe (container label) + - name: Fetch glas-harness source archive + env: + REF: ${{ github.sha }} run: | set -eu - echo "glas-harness container-smoke ok" \ No newline at end of file + SHORT="${REF:0:7}" + mkdir -p /tmp/glas-harness + wget -qO /tmp/glas-harness.tar.gz \ + "https://forgejo.coulomb.social/${GITHUB_REPOSITORY}/archive/${SHORT}.tar.gz" + tar xzf /tmp/glas-harness.tar.gz -C /tmp/glas-harness --strip-components=1 + + - name: Fetch sand-boxer contract dependency + run: | + set -eu + mkdir -p /tmp/sand-boxer + wget -qO /tmp/sand-boxer.tar.gz \ + "https://forgejo.coulomb.social/coulomb/sand-boxer/archive/main.tar.gz" + tar xzf /tmp/sand-boxer.tar.gz -C /tmp/sand-boxer --strip-components=1 + + - name: Install Python and packages + working-directory: /tmp/glas-harness + run: | + set -eu + apt-get update -qq + apt-get install -y -qq python3 python3-pip python3-venv >/dev/null + python3 -m pip install --break-system-packages --upgrade pip + python3 -m pip install --break-system-packages /tmp/sand-boxer + python3 -m pip install --break-system-packages ".[dev]" + + - name: Run unit and boundary tests + working-directory: /tmp/glas-harness + run: python3 -m pytest tests/ -q + + - name: Validate packaged execution catalog + working-directory: /tmp/glas-harness + run: glas-harness profiles diff --git a/README.md b/README.md index c9d3e78..4c94d37 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,10 @@ There is no governed default rein, model, sandbox, or tool profile. Unknown, disabled, incompatible, and ambiguous selections are refused before sandbox creation. +After creation, execution uses only the reachability-derived sandbox workspace: +same-host namespaces are entered with `nsenter`, while remote workspaces are +reached over SSH. The source checkout is provisioning input, not a rein workdir. + See [SCOPE.md](SCOPE.md), [INTENT.md](INTENT.md), [docs/execution-profiles.md](docs/execution-profiles.md), and [docs/harness-contract.md](docs/harness-contract.md). diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 97899ee..75c7489 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -13,6 +13,7 @@ | workplan | GLAS-WP-0002 | finished | — | workplans/GLAS-WP-0002-observability-and-composability-followups.md | | workplan | GLAS-WP-0003 | finished | — | workplans/GLAS-WP-0003-first-channel-extension.md | | workplan | GLAS-WP-0004 | finished | — | workplans/GLAS-WP-0004-versioned-execution-constellation-profiles.md | +| workplan | GLAS-WP-0005 | active | — | workplans/GLAS-WP-0005-sandbox-boundary-runtime-hardening.md | | task | GLAS-0001-T01 | done | — | workplans/GLAS-0001-statehub-bootstrap.md | | task | GLAS-0001-T02 | done | — | workplans/GLAS-0001-statehub-bootstrap.md | | task | GLAS-0001-T03 | done | — | workplans/GLAS-0001-statehub-bootstrap.md | @@ -38,4 +39,10 @@ | task | GLAS-WP-0004-T06 | done | — | workplans/GLAS-WP-0004-versioned-execution-constellation-profiles.md | | task | GLAS-WP-0004-T07 | done | — | workplans/GLAS-WP-0004-versioned-execution-constellation-profiles.md | | task | GLAS-WP-0004-T08 | done | — | workplans/GLAS-WP-0004-versioned-execution-constellation-profiles.md | +| task | GLAS-WP-0005-T01 | done | — | workplans/GLAS-WP-0005-sandbox-boundary-runtime-hardening.md | +| task | GLAS-WP-0005-T02 | done | — | workplans/GLAS-WP-0005-sandbox-boundary-runtime-hardening.md | +| task | GLAS-WP-0005-T03 | done | — | workplans/GLAS-WP-0005-sandbox-boundary-runtime-hardening.md | +| task | GLAS-WP-0005-T04 | progress | — | workplans/GLAS-WP-0005-sandbox-boundary-runtime-hardening.md | +| task | GLAS-WP-0005-T05 | wait | — | workplans/GLAS-WP-0005-sandbox-boundary-runtime-hardening.md | | intake | GLAS-IN-0001 | done | blue | docs/intakes/residuals.md | +| intake | GLAS-IN-0002 | todo | red | docs/intakes/residuals.md | diff --git a/docs/evidence/GLAS-WP-0004-live-proof-2026-08-20.md b/docs/evidence/GLAS-WP-0004-live-proof-2026-08-20.md index a795142..38b70c0 100644 --- a/docs/evidence/GLAS-WP-0004-live-proof-2026-08-20.md +++ b/docs/evidence/GLAS-WP-0004-live-proof-2026-08-20.md @@ -5,6 +5,16 @@ Acceptance task for both profiles: change one `README.md` status value from used a separate temporary initialized repository and `profile.bwrap-local`. No credential value, prompt transcript, or raw model output is recorded here. +> **Correction recorded 2026-08-21 (GLAS-WP-0005):** these runs proved profile +> resolution, rein dispatch, normalized evidence, commit detection, and +> sand-boxer lifecycle teardown. They did **not** prove that the rein process ran +> inside bwrap. The gateway passed the source checkout to the adapters and the +> adapters launched ordinary host subprocesses instead of entering the reported +> namespace. The commit and model results below remain valid execution facts; +> the earlier isolation implication is withdrawn. GLAS-WP-0005 changes the +> runtime to use only reachability-derived workspaces and transports and requires +> a new boundary proof. + ## `harness.agent-dev-local@1.0.0` - Result: succeeded. @@ -54,7 +64,8 @@ rotation. Both explicit profiles resolved and dispatched distinct rein/model constellations, completed the same semantic task with a real commit, returned -the common Glas evidence envelope, and destroyed their local sandboxes. No +the common Glas evidence envelope, and destroyed their local sandbox lifecycle +records. Per the correction above, this is not isolation evidence. No alternative workload credential was borrowed. Rollback for consumers is to repin to the last approved Glas profile. The old diff --git a/docs/execution-profiles.md b/docs/execution-profiles.md index 0ddd45f..d46e837 100644 --- a/docs/execution-profiles.md +++ b/docs/execution-profiles.md @@ -35,12 +35,14 @@ enabled revision exists. Inline secrets and token-looking values are refused. Use `glas-harness profiles` as the catalog/packaging validation command. -Catalog validation proves schema/compatibility, not host installation. An -execution host must install `glas-harness` with sand-boxer support and each rein -used by its enabled profiles. The Claude-backed rein also needs its documented -`rein-aharness[llm]`/sibling `llm-connect` adapter dependency. Missing runtime -dependencies surface as startup/execution failure evidence and still trigger -sandbox teardown. +Catalog validation proves schema/compatibility, not transport installation. An +execution host must install `glas-harness` with sand-boxer support and make each +selected rein plus its dependencies available *inside* the sandbox transport, +not merely on the host. Same-host bwrap execution enters the descriptor's +namespace; remote execution crosses its SSH endpoint. The Claude-backed rein +also needs its documented `rein-aharness[llm]`/sibling `llm-connect` adapter +dependency in that environment. Missing runtime dependencies fail closed at +session start and still trigger sandbox teardown. ## Consumer request diff --git a/docs/harness-contract.md b/docs/harness-contract.md index 9f13ab4..b700479 100644 --- a/docs/harness-contract.md +++ b/docs/harness-contract.md @@ -30,13 +30,15 @@ from the State Hub detail. class Rein(ABC): def start_session( self, profile: HarnessProfile, inputs: dict[str, str], sandbox: SandboxHandle - ) -> dict[str, str]: ... + ) -> dict[str, Any]: ... def dispatch_tool( - self, session: dict[str, str], tool_call: ToolCall + self, session: dict[str, Any], tool_call: ToolCall ) -> ToolResult: ... - def end_session(self, session: dict[str, str]) -> ExecutionSummary: ... + def end_session(self, session: dict[str, Any]) -> ExecutionSummary: ... + + def cleanup_session(self, session: dict[str, Any]) -> None: ... ``` ```text @@ -44,13 +46,24 @@ Glas rein sand-boxer ---- ---- ---------- resolve profile + rein descriptor create sandbox ------------------------------------------> create -start_session(profile, inputs, handle) -> rein setup -dispatch_tool(session, call) -> inner loop +derive workspace + transport from handle +start_session(profile, inputs, handle) -> sandbox-local setup +dispatch_tool(session, call) -> inner loop across transport end_session(session) -> normalized facts +cleanup_session(session) -> remove ephemeral task material destroy sandbox -----------------------------------------> destroy publish compact ExecutionEvidence ``` +After sandbox creation, the caller's source checkout is no longer an execution +path. A same-host descriptor must contain `pid` plus `workspace_dir` and every +rein command is wrapped with `nsenter`; a remote descriptor must contain `ssh` +plus `remote_dir` and every command crosses SSH. Incomplete, mixed, or unknown +reachability refuses at session start. The transport also bounds the outer rein +subprocess with the profile timeout. A host must make the selected rein command +and its dependencies available inside that transport; host-only installation is +not treated as sandbox availability. + There is no production default rein. Direct `Rein` injection remains a narrow library/test seam but still requires a valid profile so profile, sandbox, model, tool policy, and evidence are explicit. @@ -86,8 +99,9 @@ The gateway returns evidence for every normal refusal/failure path: - `teardown`. Profile resolution happens before sandbox creation. Once a sandbox exists, -teardown is attempted on every path. `refused` means governed execution did not -proceed; `failed` means an attempted lifecycle did not complete successfully. +rein cleanup and sandbox teardown are attempted on every path. `refused` means +governed execution did not proceed; `failed` means an attempted lifecycle did +not complete successfully. ## Deliberate non-goals diff --git a/docs/intakes/residuals.md b/docs/intakes/residuals.md index fba0a71..e886ae2 100644 --- a/docs/intakes/residuals.md +++ b/docs/intakes/residuals.md @@ -36,3 +36,38 @@ description: | commit b0600b25066731c6e1fc458409429f76a844f959, and sandbox 220482bc was verified destroyed. The AppRole directory is now the code default. ``` + +## GLAS-IN-0002 — Provide executable sandbox runtime reachability + +```yaml +id: GLAS-IN-0002 +kind: intake +title: "Make bwrap reachability executable for governed Glas reins" +lane: red +status: todo +priority: high +owner: sand-boxer +repo: glas-harness +origin: residual +origin_ref: GLAS-WP-0005 +description: | + GLAS-WP-0005 corrected the gateway so a rein command must execute through + the sand-boxer reachability descriptor and can no longer operate on the + caller's source checkout. The existing profile.bwrap-local environment does + not yet carry an executable rein runtime: ext.bwrap mounts /usr, /bin, /lib, + /lib64, and resolv.conf, while rein-aharness/rein-openweights and their + dependencies live outside those mounts. The open-weight profile also needs a + governed egress/credential-delivery answer rather than the current empty + network allow-list. + + A 2026-08-21 managed-agent probe also found direct consumer nsenter denied on + the reported pid (IPC/UTS setns operation not permitted). Determine whether + sand-boxer should expose an owner-implemented exec operation instead of + requiring each consumer to call nsenter, and provide the runtime mount/image, + identity, and network contract needed by both local profiles. Do not restore + host-side execution as a workaround. + + Done when a non-secret probe and one real rein command execute inside the + namespace, the source checkout is not visible/mutable, required egress is + explicit, and teardown removes the sandbox workspace. +``` diff --git a/pyproject.toml b/pyproject.toml index 445c5b0..af891dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ version = "0.1.0" description = "Meta-framework routing between concrete agent-harness backends (reins)" requires-python = ">=3.11" dependencies = [ + "httpx>=0.27", "pydantic>=2.0", "pyyaml>=6.0", ] diff --git a/src/glas_harness/contract.py b/src/glas_harness/contract.py index b23a0ce..f386276 100644 --- a/src/glas_harness/contract.py +++ b/src/glas_harness/contract.py @@ -205,13 +205,16 @@ class Rein(ABC): @abstractmethod def start_session( self, profile: HarnessProfile, inputs: dict[str, str], sandbox: SandboxHandle - ) -> dict[str, str]: + ) -> dict[str, Any]: """Begin an agent session bound to a sandbox.""" @abstractmethod - def dispatch_tool(self, session: dict[str, str], tool_call: ToolCall) -> ToolResult: + def dispatch_tool(self, session: dict[str, Any], tool_call: ToolCall) -> ToolResult: """Run one tool call under the session policy.""" @abstractmethod - def end_session(self, session: dict[str, str]) -> ExecutionSummary: + def end_session(self, session: dict[str, Any]) -> ExecutionSummary: """Close the session and return normalized rein evidence.""" + + def cleanup_session(self, session: dict[str, Any]) -> None: + """Remove rein-owned ephemeral material after any terminal path.""" diff --git a/src/glas_harness/gateway.py b/src/glas_harness/gateway.py index cc4c32b..08fb97e 100644 --- a/src/glas_harness/gateway.py +++ b/src/glas_harness/gateway.py @@ -95,6 +95,7 @@ def run_execution( manager = manager or SandboxManager() status = None + session = None try: try: status = manager.create( @@ -128,7 +129,6 @@ def run_execution( { "title": request.title, "description": request.description, - "target_repo": request.repo, "request_id": request_id, }, sandbox, @@ -165,6 +165,14 @@ def run_execution( # return normalized evidence rather than leaking a provider exception. pass finally: + if session is not None: + try: + selected_rein.cleanup_session(session) + except Exception as exc: + if outcome == "succeeded" or not error: + outcome = "failed" + failure_stage = "teardown" + error = str(exc) if status is not None: try: manager.destroy(status.sandbox_id) diff --git a/src/glas_harness/reins/_shared.py b/src/glas_harness/reins/_shared.py index 9215b0e..8ff7da3 100644 --- a/src/glas_harness/reins/_shared.py +++ b/src/glas_harness/reins/_shared.py @@ -3,31 +3,9 @@ from __future__ import annotations import json -import subprocess -import tempfile -from pathlib import Path from typing import Any -def git_head(repo: str) -> str: - proc = subprocess.run( - ["git", "-C", str(Path(repo).expanduser()), "rev-parse", "HEAD"], - capture_output=True, - text=True, - ) - return proc.stdout.strip() if proc.returncode == 0 else "" - - -def write_task_file(title: str, description: str, target_repo: str, **extra: Any) -> str: - task_spec = {"title": title, "description": description, "target_repo": target_repo, **extra} - fd = tempfile.NamedTemporaryFile( - mode="w", suffix=".json", prefix="glas-harness-task-", delete=False - ) - json.dump(task_spec, fd) - fd.close() - return fd.name - - def parse_json_object(text: str) -> dict[str, Any]: """Parse a rein CLI's final JSON object from otherwise human-readable output.""" diff --git a/src/glas_harness/reins/rein_aharness.py b/src/glas_harness/reins/rein_aharness.py index 7e3a8ee..d76f52c 100644 --- a/src/glas_harness/reins/rein_aharness.py +++ b/src/glas_harness/reins/rein_aharness.py @@ -21,8 +21,6 @@ the contract itself). from __future__ import annotations import json -import shutil -import subprocess from typing import Any from glas_harness.contract import ( @@ -33,7 +31,8 @@ from glas_harness.contract import ( ToolCall, ToolResult, ) -from glas_harness.reins._shared import git_head, parse_json_object, write_task_file +from glas_harness.reins._shared import parse_json_object +from glas_harness.transport import ExecutionTransport, transport_from_sandbox class ReinAharnessNotInstalled(RuntimeError): @@ -56,43 +55,43 @@ class ReinAharness(Rein): self.budget_tokens = budget_tokens self._last_result: dict[str, Any] = {} - def _bin(self) -> str: - resolved = shutil.which(self.cli_bin) - if not resolved: - raise ReinAharnessNotInstalled( - f"'{self.cli_bin}' not found on PATH — install rein-aharness first" - ) - return resolved + def _bin(self, transport: ExecutionTransport) -> str: + try: + return transport.resolve_executable(self.cli_bin) + except RuntimeError as exc: + raise ReinAharnessNotInstalled(str(exc)) from exc def start_session( self, profile: HarnessProfile, inputs: dict[str, str], sandbox: SandboxHandle - ) -> dict[str, str]: - target_repo = ( - inputs.get("target_repo") - or sandbox.reachability.get("workspace_dir") - or sandbox.reachability.get("remote_dir") - ) - if not target_repo: - raise ValueError("no target_repo resolvable from inputs or sandbox reachability") - - task_file = inputs.get("task_file") or write_task_file( - inputs["title"], - inputs["description"], - target_repo, - agent=inputs.get("agent", "coach"), - timeout_seconds=profile.limits.timeout_seconds or 600, + ) -> dict[str, Any]: + transport = transport_from_sandbox(sandbox) + target_repo = transport.workspace + head_before = transport.git_head() + generated_task_file = not bool(inputs.get("task_file")) + task_file = inputs.get("task_file") or transport.write_task_file( + { + "title": inputs["title"], + "description": inputs["description"], + "target_repo": target_repo, + "agent": inputs.get("agent", "coach"), + "timeout_seconds": profile.limits.timeout_seconds or 600, + } ) return { "sandbox_id": sandbox.sandbox_id, + "transport": transport, "task_file": task_file, + "generated_task_file": generated_task_file, "target_repo": target_repo, - "head_before": git_head(target_repo), + "head_before": head_before, + "timeout_seconds": profile.limits.timeout_seconds or 600, } - def dispatch_tool(self, session: dict[str, str], tool_call: ToolCall) -> ToolResult: + def dispatch_tool(self, session: dict[str, Any], tool_call: ToolCall) -> ToolResult: + transport: ExecutionTransport = session["transport"] argv = [ - self._bin(), + self._bin(transport), "run", "--task-file", session["task_file"], @@ -107,7 +106,7 @@ class ReinAharness(Rein): argv += ["--tool-profile", self.tool_profile] if self.budget_tokens: argv += ["--budget-tokens", str(self.budget_tokens)] - proc = subprocess.run(argv, capture_output=True, text=True) + proc = transport.run(argv, timeout=session["timeout_seconds"]) ok = proc.returncode == 0 output, events = self._split_stream_events(proc.stdout) self._last_result = parse_json_object(output) @@ -146,8 +145,9 @@ class ReinAharness(Rein): remaining_lines.append(line) return "\n".join(remaining_lines), events - def end_session(self, session: dict[str, str]) -> ExecutionSummary: - head_after = git_head(session["target_repo"]) + def end_session(self, session: dict[str, Any]) -> ExecutionSummary: + transport: ExecutionTransport = session["transport"] + head_after = transport.git_head() committed = bool(head_after) and head_after != session.get("head_before") reported_ok = bool(self._last_result.get("ok", committed)) reason = self._last_result.get("reason") or None @@ -168,3 +168,8 @@ class ReinAharness(Rein): "persona_source": self._last_result.get("persona_source"), }, ) + + def cleanup_session(self, session: dict[str, Any]) -> None: + if session.get("generated_task_file") and session.get("task_file"): + transport: ExecutionTransport = session["transport"] + transport.remove_file(session["task_file"]) diff --git a/src/glas_harness/reins/rein_openweights.py b/src/glas_harness/reins/rein_openweights.py index c540546..ffa846a 100644 --- a/src/glas_harness/reins/rein_openweights.py +++ b/src/glas_harness/reins/rein_openweights.py @@ -9,8 +9,6 @@ run into a single call. from __future__ import annotations -import shutil -import subprocess from typing import Any from glas_harness.contract import ( @@ -21,7 +19,8 @@ from glas_harness.contract import ( ToolCall, ToolResult, ) -from glas_harness.reins._shared import git_head, parse_json_object, write_task_file +from glas_harness.reins._shared import parse_json_object +from glas_harness.transport import ExecutionTransport, transport_from_sandbox class ReinOpenWeightsNotInstalled(RuntimeError): @@ -44,41 +43,47 @@ class ReinOpenWeights(Rein): self.tool_profile = tool_profile self._last_result: dict[str, Any] = {} - def _bin(self) -> str: - resolved = shutil.which(self.cli_bin) - if not resolved: - raise ReinOpenWeightsNotInstalled( - f"'{self.cli_bin}' not found on PATH — install rein-openweights first" - ) - return resolved + def _bin(self, transport: ExecutionTransport) -> str: + try: + return transport.resolve_executable(self.cli_bin) + except RuntimeError as exc: + raise ReinOpenWeightsNotInstalled(str(exc)) from exc def start_session( self, profile: HarnessProfile, inputs: dict[str, str], sandbox: SandboxHandle - ) -> dict[str, str]: - target_repo = ( - inputs.get("target_repo") - or sandbox.reachability.get("workspace_dir") - or sandbox.reachability.get("remote_dir") - ) - if not target_repo: - raise ValueError("no target_repo resolvable from inputs or sandbox reachability") - - task_file = inputs.get("task_file") or write_task_file( - inputs["title"], - inputs["description"], - target_repo, - timeout_seconds=profile.limits.timeout_seconds or 600, + ) -> dict[str, Any]: + transport = transport_from_sandbox(sandbox) + target_repo = transport.workspace + head_before = transport.git_head() + generated_task_file = not bool(inputs.get("task_file")) + task_file = inputs.get("task_file") or transport.write_task_file( + { + "title": inputs["title"], + "description": inputs["description"], + "target_repo": target_repo, + "timeout_seconds": profile.limits.timeout_seconds or 600, + } ) return { "sandbox_id": sandbox.sandbox_id, + "transport": transport, "task_file": task_file, + "generated_task_file": generated_task_file, "target_repo": target_repo, - "head_before": git_head(target_repo), + "head_before": head_before, + "timeout_seconds": profile.limits.timeout_seconds or 600, } - def dispatch_tool(self, session: dict[str, str], tool_call: ToolCall) -> ToolResult: - argv = [self._bin(), "run", "--task-file", session["task_file"], "--no-hub"] + def dispatch_tool(self, session: dict[str, Any], tool_call: ToolCall) -> ToolResult: + transport: ExecutionTransport = session["transport"] + argv = [ + self._bin(transport), + "run", + "--task-file", + session["task_file"], + "--no-hub", + ] if self.model: argv += ["--model", self.model] if self.max_turns: @@ -87,7 +92,7 @@ class ReinOpenWeights(Rein): argv += ["--budget-tokens", str(self.budget_tokens)] if self.tool_profile: argv += ["--tool-profile", self.tool_profile] - proc = subprocess.run(argv, capture_output=True, text=True) + proc = transport.run(argv, timeout=session["timeout_seconds"]) ok = proc.returncode == 0 self._last_result = parse_json_object(proc.stdout) return ToolResult( @@ -104,8 +109,9 @@ class ReinOpenWeights(Rein): }, ) - def end_session(self, session: dict[str, str]) -> ExecutionSummary: - head_after = git_head(session["target_repo"]) + def end_session(self, session: dict[str, Any]) -> ExecutionSummary: + transport: ExecutionTransport = session["transport"] + head_after = transport.git_head() committed = bool(head_after) and head_after != session.get("head_before") reported_ok = bool(self._last_result.get("ok", committed)) reason = self._last_result.get("reason") or None @@ -126,3 +132,8 @@ class ReinOpenWeights(Rein): "tool_profile": self._last_result.get("tool_profile") or self.tool_profile, }, ) + + def cleanup_session(self, session: dict[str, Any]) -> None: + if session.get("generated_task_file") and session.get("task_file"): + transport: ExecutionTransport = session["transport"] + transport.remove_file(session["task_file"]) diff --git a/src/glas_harness/transport.py b/src/glas_harness/transport.py new file mode 100644 index 0000000..dc8248d --- /dev/null +++ b/src/glas_harness/transport.py @@ -0,0 +1,184 @@ +"""Execution transports derived from sand-boxer reachability descriptors. + +The source checkout is an input to sandbox provisioning, never an execution +workspace. Once sand-boxer returns READY, every rein subprocess crosses exactly +one declared boundary: nsenter for a same-host namespace or SSH for a remote +workspace. +""" + +from __future__ import annotations + +import json +import os +import re +import shlex +import subprocess +import uuid +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any, Literal, Sequence + +from glas_harness.contract import SandboxHandle + + +class TransportError(RuntimeError): + """The sandbox reachability descriptor cannot safely execute commands.""" + + +_SSH_TARGET = re.compile(r"^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9._-]+$") + + +@dataclass(frozen=True) +class ExecutionTransport: + kind: Literal["local_namespace", "ssh"] + workspace: str + pid: int | None = None + ssh_target: str | None = None + + def command(self, argv: Sequence[str]) -> list[str]: + if not argv: + raise TransportError("cannot execute an empty command") + command = [str(item) for item in argv] + scoped_command = [ + "sh", + "-c", + 'cd "$1" && shift && exec "$@"', + "sh", + self.workspace, + *command, + ] + if self.kind == "local_namespace": + if self.pid is None: + raise TransportError("local namespace transport is missing pid") + return [ + "nsenter", + "--target", + str(self.pid), + "--mount", + "--pid", + "--net", + "--uts", + "--ipc", + "--", + *scoped_command, + ] + if self.kind == "ssh": + if not self.ssh_target: + raise TransportError("SSH transport is missing target") + return ["ssh", self.ssh_target, shlex.join(scoped_command)] + raise TransportError(f"unsupported execution transport: {self.kind}") + + def run( + self, + argv: Sequence[str], + *, + input_text: str | None = None, + timeout: float | None = None, + ) -> subprocess.CompletedProcess[str]: + return subprocess.run( + self.command(argv), + input=input_text, + capture_output=True, + text=True, + timeout=timeout, + ) + + def resolve_executable(self, name: str, *, timeout: float = 15.0) -> str: + if not name or "/" in name: + raise TransportError("rein executable must be a command name, not a host path") + proc = self.run( + ["sh", "-c", 'command -v -- "$1"', "sh", name], timeout=timeout + ) + resolved = proc.stdout.strip() + if proc.returncode != 0 or not resolved: + raise TransportError( + f"'{name}' is not installed inside the selected sandbox transport" + ) + return resolved + + def git_head(self, *, timeout: float = 15.0) -> str: + proc = self.run( + ["git", "-C", self.workspace, "rev-parse", "HEAD"], timeout=timeout + ) + head = proc.stdout.strip() + if proc.returncode != 0 or not head: + raise TransportError( + "cannot read git HEAD inside the selected sandbox transport" + ) + return head + + def write_task_file(self, payload: dict[str, Any]) -> str: + task_name = f".glas-harness-task-{uuid.uuid4().hex}.json" + # Keep the task spec under Git's private metadata directory so an + # agent's broad `git add -A` cannot accidentally commit its prompt. + task_path = str(PurePosixPath(self.workspace) / ".git" / task_name) + content = json.dumps(payload) + if self.kind == "local_namespace": + path = Path(task_path) + try: + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "w") as task_file: + task_file.write(content) + except OSError as exc: + raise TransportError(f"cannot create sandbox task file: {exc}") from exc + return task_path + + script = 'umask 077; cat > "$1"' + proc = self.run(["sh", "-c", script, "sh", task_path], input_text=content, timeout=15) + if proc.returncode != 0: + raise TransportError( + "cannot create remote sandbox task file: " + + (proc.stderr.strip() or f"exit {proc.returncode}") + ) + return task_path + + def remove_file(self, path: str) -> None: + if self.kind == "local_namespace": + Path(path).unlink(missing_ok=True) + return + proc = self.run(["rm", "-f", "--", path], timeout=15) + if proc.returncode != 0: + raise TransportError( + "cannot remove remote sandbox task file: " + + (proc.stderr.strip() or f"exit {proc.returncode}") + ) + + +def transport_from_sandbox(sandbox: SandboxHandle) -> ExecutionTransport: + reachability = sandbox.reachability + pid = reachability.get("pid") + workspace = reachability.get("workspace_dir") + ssh_target = reachability.get("ssh") + remote_dir = reachability.get("remote_dir") + + has_local = pid is not None or workspace is not None + has_remote = ssh_target is not None or remote_dir is not None + if has_local and has_remote: + raise TransportError("ambiguous sandbox reachability: local and SSH fields coexist") + if has_local: + if not pid or not workspace: + raise TransportError( + "incomplete local sandbox reachability: pid and workspace_dir are required" + ) + try: + parsed_pid = int(str(pid)) + except ValueError as exc: + raise TransportError("local sandbox reachability pid must be an integer") from exc + if parsed_pid <= 0 or not Path(str(workspace)).is_absolute(): + raise TransportError("local sandbox reachability has invalid pid or workspace_dir") + return ExecutionTransport( + kind="local_namespace", workspace=str(workspace), pid=parsed_pid + ) + if has_remote: + if not ssh_target or not remote_dir: + raise TransportError( + "incomplete remote sandbox reachability: ssh and remote_dir are required" + ) + if not _SSH_TARGET.fullmatch(str(ssh_target)): + raise TransportError("SSH reachability must be a single host or user@host target") + if not PurePosixPath(str(remote_dir)).is_absolute(): + raise TransportError("remote sandbox workspace must be an absolute path") + return ExecutionTransport( + kind="ssh", workspace=str(remote_dir), ssh_target=str(ssh_target) + ) + raise TransportError("sandbox exposes no supported execution reachability") diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 7ce7bbd..9e50c4a 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -26,6 +26,7 @@ class _FakeRein(Rein): assert str(profile.ref) == PROFILE assert sandbox.sandbox_id == "sbx1" assert sandbox.reachability.get("workspace_dir") == "/tmp/ws" + assert "target_repo" not in inputs return {"session": "s1"} def dispatch_tool(self, session, tool_call: ToolCall) -> ToolResult: @@ -50,6 +51,9 @@ class _FakeRein(Rein): resolved_model="claude-sonnet-4-6", ) + def cleanup_session(self, session): + self.calls.append("cleanup_session") + def _fake_status(sandbox_id: str = "sbx1") -> SandboxStatus: now = datetime.now(UTC) @@ -86,7 +90,12 @@ def test_run_execution_creates_and_destroys_sandbox() -> None: result = run_execution(_request(), rein=rein, manager=manager) - assert rein.calls == ["start_session", "dispatch_tool", "end_session"] + assert rein.calls == [ + "start_session", + "dispatch_tool", + "end_session", + "cleanup_session", + ] manager.create.assert_called_once() manager.destroy.assert_called_once_with("sbx1") assert result.ok is True @@ -106,13 +115,15 @@ def test_run_execution_normalizes_execution_failure_and_tears_down() -> None: def dispatch_tool(self, session, tool_call): raise RuntimeError("boom") - result = run_execution(_request(), rein=_FailingRein(), manager=manager) + rein = _FailingRein() + result = run_execution(_request(), rein=rein, manager=manager) assert result.ok is False assert result.evidence.outcome == "failed" assert result.evidence.failure_stage == "execution" assert result.evidence.error == "execution failed; inspect direct caller error" assert result.tool_error == "boom" + assert rein.calls == ["start_session", "cleanup_session"] manager.destroy.assert_called_once_with("sbx1") diff --git a/tests/test_rein_aharness.py b/tests/test_rein_aharness.py index 4b82aa7..6b9791e 100644 --- a/tests/test_rein_aharness.py +++ b/tests/test_rein_aharness.py @@ -1,4 +1,5 @@ import json +import os import subprocess from unittest.mock import MagicMock, patch @@ -7,6 +8,7 @@ import pytest from glas_harness.contract import Rein, SandboxHandle, ToolCall from glas_harness.profiles import ProfileCatalog from glas_harness.reins.rein_aharness import ReinAharness, ReinAharnessNotInstalled +from glas_harness.transport import ExecutionTransport, TransportError def test_is_rein_subclass() -> None: @@ -15,8 +17,10 @@ def test_is_rein_subclass() -> None: def test_bin_raises_if_not_installed() -> None: rein = ReinAharness(cli_bin="does-not-exist-binary") + transport = MagicMock(spec=ExecutionTransport) + transport.resolve_executable.side_effect = TransportError("missing") with pytest.raises(ReinAharnessNotInstalled): - rein._bin() + rein._bin(transport) def test_start_session_writes_task_file_and_captures_head(tmp_path) -> None: @@ -26,24 +30,32 @@ def test_start_session_writes_task_file_and_captures_head(tmp_path) -> None: subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "init"], cwd=repo, check=True) rein = ReinAharness() - sandbox = SandboxHandle(sandbox_id="abc", host="localhost", reachability={"workspace_dir": str(repo)}) - session = rein.start_session( - profile=ProfileCatalog().resolve("harness.agent-dev-local@1.0.0")[0], - inputs={"title": "t", "description": "d"}, - sandbox=sandbox, + sandbox = SandboxHandle( + sandbox_id="abc", + host="localhost", + reachability={"pid": str(os.getpid()), "workspace_dir": str(repo)}, ) + with patch.object(ExecutionTransport, "git_head", return_value="abc123"): + session = rein.start_session( + profile=ProfileCatalog().resolve("harness.agent-dev-local@1.0.0")[0], + inputs={"title": "t", "description": "d"}, + sandbox=sandbox, + ) assert session["target_repo"] == str(repo) - assert session["head_before"] + assert session["head_before"] == "abc123" + assert session["transport"].workspace == str(repo) task_spec = json.loads(open(session["task_file"]).read()) assert task_spec["title"] == "t" assert task_spec["target_repo"] == str(repo) + rein.cleanup_session(session) + assert not os.path.exists(session["task_file"]) def test_start_session_requires_resolvable_target_repo() -> None: rein = ReinAharness() sandbox = SandboxHandle(sandbox_id="abc", host="localhost", reachability={}) - with pytest.raises(ValueError, match="target_repo"): + with pytest.raises(RuntimeError, match="reachability"): rein.start_session(profile={}, inputs={"title": "t", "description": "d"}, sandbox=sandbox) @@ -53,30 +65,31 @@ def test_dispatch_tool_invokes_agent_harness_cli() -> None: tool_profile="green-commit-only", budget_tokens=1234, ) - session = {"task_file": "/tmp/task.json"} + transport = MagicMock(spec=ExecutionTransport) + transport.run.return_value = MagicMock(returncode=0, stdout="ok", stderr="") + session = {"task_file": "/tmp/task.json", "transport": transport, "timeout_seconds": 30} - fake_result = MagicMock(returncode=0, stdout="ok", stderr="") with patch.object(rein, "_bin", return_value="/usr/bin/agent-harness"): - with patch("glas_harness.reins.rein_aharness.subprocess.run", return_value=fake_result) as run: - result = rein.dispatch_tool(session, ToolCall(name="run_task")) + result = rein.dispatch_tool(session, ToolCall(name="run_task")) - argv = run.call_args.args[0] + argv = transport.run.call_args.args[0] assert argv[:3] == ["/usr/bin/agent-harness", "run", "--task-file"] assert argv[argv.index("--model") + 1] == "claude-sonnet-4-6" assert argv[argv.index("--tool-profile") + 1] == "green-commit-only" assert argv[argv.index("--budget-tokens") + 1] == "1234" + assert transport.run.call_args.kwargs["timeout"] == 30 assert result.ok is True assert result.output == "ok" def test_dispatch_tool_reports_failure() -> None: rein = ReinAharness() - session = {"task_file": "/tmp/task.json"} + transport = MagicMock(spec=ExecutionTransport) + transport.run.return_value = MagicMock(returncode=1, stdout="", stderr="boom") + session = {"task_file": "/tmp/task.json", "transport": transport, "timeout_seconds": 30} - fake_result = MagicMock(returncode=1, stdout="", stderr="boom") with patch.object(rein, "_bin", return_value="/usr/bin/agent-harness"): - with patch("glas_harness.reins.rein_aharness.subprocess.run", return_value=fake_result): - result = rein.dispatch_tool(session, ToolCall(name="run_task")) + result = rein.dispatch_tool(session, ToolCall(name="run_task")) assert result.ok is False assert result.error == "boom" @@ -88,14 +101,24 @@ def test_end_session_detects_new_commit(tmp_path) -> None: subprocess.run(["git", "init", "-q"], cwd=repo, check=True) subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "init"], cwd=repo, check=True) - from glas_harness.reins._shared import git_head - rein = ReinAharness() - head_before = git_head(str(repo)) + head_before = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "task"], cwd=repo, check=True) - summary = rein.end_session({"target_repo": str(repo), "head_before": head_before}) + transport = MagicMock(spec=ExecutionTransport) + transport.git_head.return_value = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + summary = rein.end_session({"transport": transport, "head_before": head_before}) assert summary.committed is True assert summary.outcome == "succeeded" assert summary.commit_sha != head_before @@ -107,12 +130,17 @@ def test_end_session_no_new_commit(tmp_path) -> None: subprocess.run(["git", "init", "-q"], cwd=repo, check=True) subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "init"], cwd=repo, check=True) - from glas_harness.reins._shared import git_head - rein = ReinAharness() - head = git_head(str(repo)) + head = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() - summary = rein.end_session({"target_repo": str(repo), "head_before": head}) + transport = MagicMock(spec=ExecutionTransport) + transport.git_head.return_value = head + summary = rein.end_session({"transport": transport, "head_before": head}) assert summary.committed is False assert summary.outcome == "failed" @@ -121,20 +149,22 @@ def test_dispatch_tool_streams_events_when_enabled() -> None: import json rein = ReinAharness(stream_tool_events=True) - session = {"task_file": "/tmp/task.json"} + transport = MagicMock(spec=ExecutionTransport) + session = {"task_file": "/tmp/task.json", "transport": transport, "timeout_seconds": 30} stdout_lines = [ json.dumps({"stream_event": {"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Read"}]}}}), json.dumps({"stream_event": {"type": "system", "subtype": "hook_started"}}), json.dumps({"ok": True, "committed": True}, indent=2), ] - fake_result = MagicMock(returncode=0, stdout="\n".join(stdout_lines), stderr="") + transport.run.return_value = MagicMock( + returncode=0, stdout="\n".join(stdout_lines), stderr="" + ) with patch.object(rein, "_bin", return_value="/usr/bin/agent-harness"): - with patch("glas_harness.reins.rein_aharness.subprocess.run", return_value=fake_result) as run: - result = rein.dispatch_tool(session, ToolCall(name="run_task")) + result = rein.dispatch_tool(session, ToolCall(name="run_task")) - argv = run.call_args.args[0] + argv = transport.run.call_args.args[0] assert "--stream-tool-events" in argv assert len(result.events) == 2 assert result.events[0]["message"]["content"][0]["name"] == "Read" @@ -145,13 +175,28 @@ def test_dispatch_tool_streams_events_when_enabled() -> None: def test_dispatch_tool_no_stream_flag_by_default() -> None: rein = ReinAharness() - session = {"task_file": "/tmp/task.json"} - fake_result = MagicMock(returncode=0, stdout="ok", stderr="") + transport = MagicMock(spec=ExecutionTransport) + transport.run.return_value = MagicMock(returncode=0, stdout="ok", stderr="") + session = {"task_file": "/tmp/task.json", "transport": transport, "timeout_seconds": 30} with patch.object(rein, "_bin", return_value="/usr/bin/agent-harness"): - with patch("glas_harness.reins.rein_aharness.subprocess.run", return_value=fake_result) as run: - result = rein.dispatch_tool(session, ToolCall(name="run_task")) + result = rein.dispatch_tool(session, ToolCall(name="run_task")) - argv = run.call_args.args[0] + argv = transport.run.call_args.args[0] assert "--stream-tool-events" not in argv assert result.events == [] + + +def test_cleanup_session_removes_only_generated_task_file() -> None: + rein = ReinAharness() + transport = MagicMock(spec=ExecutionTransport) + rein.cleanup_session( + {"transport": transport, "task_file": "/sandbox/task.json", "generated_task_file": True} + ) + transport.remove_file.assert_called_once_with("/sandbox/task.json") + + transport.reset_mock() + rein.cleanup_session( + {"transport": transport, "task_file": "/sandbox/task.json", "generated_task_file": False} + ) + transport.remove_file.assert_not_called() diff --git a/tests/test_rein_openweights.py b/tests/test_rein_openweights.py index 6ebf716..d270802 100644 --- a/tests/test_rein_openweights.py +++ b/tests/test_rein_openweights.py @@ -1,4 +1,5 @@ import json +import os import subprocess from unittest.mock import MagicMock, patch @@ -7,6 +8,7 @@ import pytest from glas_harness.contract import Rein, SandboxHandle, ToolCall from glas_harness.profiles import ProfileCatalog from glas_harness.reins.rein_openweights import ReinOpenWeights, ReinOpenWeightsNotInstalled +from glas_harness.transport import ExecutionTransport, TransportError def test_is_rein_subclass() -> None: @@ -15,8 +17,10 @@ def test_is_rein_subclass() -> None: def test_bin_raises_if_not_installed() -> None: rein = ReinOpenWeights(cli_bin="does-not-exist-binary") + transport = MagicMock(spec=ExecutionTransport) + transport.resolve_executable.side_effect = TransportError("missing") with pytest.raises(ReinOpenWeightsNotInstalled): - rein._bin() + rein._bin(transport) def test_start_session_writes_task_file_and_captures_head(tmp_path) -> None: @@ -26,37 +30,45 @@ def test_start_session_writes_task_file_and_captures_head(tmp_path) -> None: subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "init"], cwd=repo, check=True) rein = ReinOpenWeights() - sandbox = SandboxHandle(sandbox_id="abc", host="localhost", reachability={"workspace_dir": str(repo)}) - session = rein.start_session( - profile=ProfileCatalog().resolve("harness.agent-dev-openweights-local@1.0.0")[0], - inputs={"title": "t", "description": "d"}, - sandbox=sandbox, + sandbox = SandboxHandle( + sandbox_id="abc", + host="localhost", + reachability={"pid": str(os.getpid()), "workspace_dir": str(repo)}, ) + with patch.object(ExecutionTransport, "git_head", return_value="abc123"): + session = rein.start_session( + profile=ProfileCatalog().resolve("harness.agent-dev-openweights-local@1.0.0")[0], + inputs={"title": "t", "description": "d"}, + sandbox=sandbox, + ) assert session["target_repo"] == str(repo) - assert session["head_before"] + assert session["head_before"] == "abc123" + assert session["transport"].workspace == str(repo) task_spec = json.loads(open(session["task_file"]).read()) assert task_spec["title"] == "t" assert task_spec["target_repo"] == str(repo) + rein.cleanup_session(session) + assert not os.path.exists(session["task_file"]) def test_start_session_requires_resolvable_target_repo() -> None: rein = ReinOpenWeights() sandbox = SandboxHandle(sandbox_id="abc", host="localhost", reachability={}) - with pytest.raises(ValueError, match="target_repo"): + with pytest.raises(RuntimeError, match="reachability"): rein.start_session(profile={}, inputs={"title": "t", "description": "d"}, sandbox=sandbox) def test_dispatch_tool_invokes_rein_openweights_cli() -> None: rein = ReinOpenWeights() - session = {"task_file": "/tmp/task.json"} + transport = MagicMock(spec=ExecutionTransport) + transport.run.return_value = MagicMock(returncode=0, stdout="ok", stderr="") + session = {"task_file": "/tmp/task.json", "transport": transport, "timeout_seconds": 30} - fake_result = MagicMock(returncode=0, stdout="ok", stderr="") with patch.object(rein, "_bin", return_value="/usr/bin/rein-openweights"): - with patch("glas_harness.reins.rein_openweights.subprocess.run", return_value=fake_result) as run: - result = rein.dispatch_tool(session, ToolCall(name="run_task")) + result = rein.dispatch_tool(session, ToolCall(name="run_task")) - argv = run.call_args.args[0] + argv = transport.run.call_args.args[0] assert argv[:3] == ["/usr/bin/rein-openweights", "run", "--task-file"] assert "--no-hub" in argv assert result.ok is True @@ -70,14 +82,14 @@ def test_dispatch_tool_passes_model_when_set() -> None: budget_tokens=1234, tool_profile="green-commit-only", ) - session = {"task_file": "/tmp/task.json"} - fake_result = MagicMock(returncode=0, stdout="ok", stderr="") + transport = MagicMock(spec=ExecutionTransport) + transport.run.return_value = MagicMock(returncode=0, stdout="ok", stderr="") + session = {"task_file": "/tmp/task.json", "transport": transport, "timeout_seconds": 30} with patch.object(rein, "_bin", return_value="/usr/bin/rein-openweights"): - with patch("glas_harness.reins.rein_openweights.subprocess.run", return_value=fake_result) as run: - rein.dispatch_tool(session, ToolCall(name="run_task")) + rein.dispatch_tool(session, ToolCall(name="run_task")) - argv = run.call_args.args[0] + argv = transport.run.call_args.args[0] assert "--model" in argv assert "meta-llama/llama-3.1-70b-instruct" in argv assert argv[argv.index("--max-turns") + 1] == "8" @@ -87,17 +99,34 @@ def test_dispatch_tool_passes_model_when_set() -> None: def test_dispatch_tool_reports_failure() -> None: rein = ReinOpenWeights() - session = {"task_file": "/tmp/task.json"} + transport = MagicMock(spec=ExecutionTransport) + transport.run.return_value = MagicMock(returncode=1, stdout="", stderr="boom") + session = {"task_file": "/tmp/task.json", "transport": transport, "timeout_seconds": 30} - fake_result = MagicMock(returncode=1, stdout="", stderr="boom") with patch.object(rein, "_bin", return_value="/usr/bin/rein-openweights"): - with patch("glas_harness.reins.rein_openweights.subprocess.run", return_value=fake_result): - result = rein.dispatch_tool(session, ToolCall(name="run_task")) + result = rein.dispatch_tool(session, ToolCall(name="run_task")) assert result.ok is False assert result.error == "boom" +def test_dispatch_tool_enforces_outer_timeout() -> None: + rein = ReinOpenWeights() + transport = MagicMock(spec=ExecutionTransport) + transport.run.side_effect = subprocess.TimeoutExpired("rein-openweights", 30) + session = { + "task_file": "/tmp/task.json", + "transport": transport, + "timeout_seconds": 30, + } + + with patch.object(rein, "_bin", return_value="/usr/bin/rein-openweights"): + with pytest.raises(subprocess.TimeoutExpired): + rein.dispatch_tool(session, ToolCall(name="run_task")) + + assert transport.run.call_args.kwargs["timeout"] == 30 + + def test_end_session_detects_new_commit(tmp_path) -> None: repo = tmp_path / "repo" repo.mkdir() @@ -105,12 +134,31 @@ def test_end_session_detects_new_commit(tmp_path) -> None: subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "init"], cwd=repo, check=True) rein = ReinOpenWeights() - from glas_harness.reins._shared import git_head - - head_before = git_head(str(repo)) + head_before = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "task"], cwd=repo, check=True) - summary = rein.end_session({"target_repo": str(repo), "head_before": head_before}) + transport = MagicMock(spec=ExecutionTransport) + transport.git_head.return_value = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + summary = rein.end_session({"transport": transport, "head_before": head_before}) assert summary.committed is True assert summary.outcome == "succeeded" assert summary.commit_sha != head_before + + +def test_cleanup_session_removes_generated_task_file() -> None: + rein = ReinOpenWeights() + transport = MagicMock(spec=ExecutionTransport) + rein.cleanup_session( + {"transport": transport, "task_file": "/sandbox/task.json", "generated_task_file": True} + ) + transport.remove_file.assert_called_once_with("/sandbox/task.json") diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 0000000..36845bd --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,135 @@ +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from glas_harness.contract import SandboxHandle +from glas_harness.transport import ExecutionTransport, TransportError, transport_from_sandbox + + +def test_local_namespace_transport_requires_pid_and_workspace(tmp_path: Path) -> None: + sandbox = SandboxHandle( + sandbox_id="sbx", + host="localhost", + reachability={"pid": "4321", "workspace_dir": str(tmp_path)}, + ) + transport = transport_from_sandbox(sandbox) + + assert transport.kind == "local_namespace" + assert transport.workspace == str(tmp_path) + assert transport.command(["git", "status"]) == [ + "nsenter", + "--target", + "4321", + "--mount", + "--pid", + "--net", + "--uts", + "--ipc", + "--", + "sh", + "-c", + 'cd "$1" && shift && exec "$@"', + "sh", + str(tmp_path), + "git", + "status", + ] + + +def test_remote_transport_wraps_command_without_local_shell() -> None: + sandbox = SandboxHandle( + sandbox_id="sbx", + host="sandboxer01", + reachability={"ssh": "agent@sandboxer01", "remote_dir": "/tmp/sbx"}, + ) + transport = transport_from_sandbox(sandbox) + + assert transport.kind == "ssh" + assert transport.command(["git", "-C", "/tmp/sbx", "status"]) == [ + "ssh", + "agent@sandboxer01", + "sh -c 'cd \"$1\" && shift && exec \"$@\"' sh /tmp/sbx git -C /tmp/sbx status", + ] + + +@pytest.mark.parametrize( + "reachability", + [ + {}, + {"workspace_dir": "/tmp/ws"}, + {"pid": "12"}, + {"ssh": "agent@host"}, + {"remote_dir": "/tmp/ws"}, + { + "pid": "12", + "workspace_dir": "/tmp/ws", + "ssh": "agent@host", + "remote_dir": "/tmp/ws", + }, + ], +) +def test_incomplete_or_ambiguous_reachability_fails_closed(reachability) -> None: + with pytest.raises(TransportError): + transport_from_sandbox( + SandboxHandle(sandbox_id="sbx", host="localhost", reachability=reachability) + ) + + +def test_local_task_file_is_private_and_removable(tmp_path: Path) -> None: + (tmp_path / ".git").mkdir() + transport = ExecutionTransport( + kind="local_namespace", workspace=str(tmp_path), pid=123 + ) + task_path = transport.write_task_file({"title": "bounded", "description": "safe"}) + path = Path(task_path) + + assert path.parent == tmp_path / ".git" + assert path.stat().st_mode & 0o777 == 0o600 + assert json.loads(path.read_text())["title"] == "bounded" + + transport.remove_file(task_path) + assert not path.exists() + + +def test_remote_task_file_uses_ssh_stdin_and_cleanup() -> None: + transport = ExecutionTransport( + kind="ssh", workspace="/tmp/sbx", ssh_target="agent@sandboxer01" + ) + completed = MagicMock(returncode=0, stdout="", stderr="") + with patch("glas_harness.transport.subprocess.run", return_value=completed) as run: + task_path = transport.write_task_file({"title": "bounded"}) + transport.remove_file(task_path) + + create = run.call_args_list[0] + assert create.args[0][:2] == ["ssh", "agent@sandboxer01"] + assert create.kwargs["input"] == '{"title": "bounded"}' + assert "cat >" in create.args[0][2] + assert "rm -f" in run.call_args_list[1].args[0][2] + + +def test_resolve_executable_fails_inside_transport() -> None: + transport = ExecutionTransport( + kind="ssh", workspace="/tmp/sbx", ssh_target="agent@sandboxer01" + ) + with patch.object( + ExecutionTransport, + "run", + return_value=MagicMock(returncode=127, stdout="", stderr="not found"), + ): + with pytest.raises(TransportError, match="not installed inside"): + transport.resolve_executable("rein-aharness") + + +def test_git_head_fails_closed_on_transport_error() -> None: + transport = ExecutionTransport( + kind="ssh", workspace="/tmp/sbx", ssh_target="agent@sandboxer01" + ) + with patch.object( + ExecutionTransport, + "run", + return_value=MagicMock(returncode=1, stdout="", stderr="permission denied"), + ): + with pytest.raises(TransportError, match="inside the selected sandbox"): + transport.git_head() diff --git a/workplans/GLAS-WP-0005-sandbox-boundary-runtime-hardening.md b/workplans/GLAS-WP-0005-sandbox-boundary-runtime-hardening.md new file mode 100644 index 0000000..f61680a --- /dev/null +++ b/workplans/GLAS-WP-0005-sandbox-boundary-runtime-hardening.md @@ -0,0 +1,156 @@ +--- +id: GLAS-WP-0005 +type: workplan +title: "Sandbox-boundary correction and runtime hardening" +domain: infotech +repo: glas-harness +status: active +owner: codex +topic_slug: sandbox-boundary-runtime-hardening +created: "2026-08-21" +updated: "2026-08-21" +--- + +# Sandbox-boundary correction and runtime hardening + +## Context + +The versioned profile path provisions and tears down sand-boxer instances, but +the concrete rein adapters currently prefer the caller's original repository +path and invoke their CLIs as ordinary local subprocesses. They do not enter a +same-host bwrap namespace or cross the SSH reachability boundary for a remote +sandbox. As a result, the existing lifecycle evidence does not prove that the +agent process executed inside the selected sandbox, and the enabled remote +profile is not executable through its declared transport. + +The review also found persistent task-spec files in `/tmp`, no outer subprocess +timeout, an undeclared direct `httpx` dependency, and a Forgejo workflow that +only prints routing probes rather than running tests or catalog validation. + +## Goal + +Make every governed execution use the workspace and execution transport +declared by the resolved sand-boxer reachability descriptor, fail closed when +that descriptor is unsupported, and close the adjacent runtime/CI gaps without +changing scheduling, rein credential ownership, or sand-boxer provisioning +ownership. + +## Task: Correct workspace and execution transport selection + +```task +id: GLAS-WP-0005-T01 +status: done +priority: high +``` + +Introduce one validated execution transport derived from `SandboxHandle`: + +- same-host bwrap uses `workspace_dir` and enters the namespace identified by + `pid` for the rein subprocess; +- remote sandboxes use `remote_dir` and the declared SSH endpoint; +- the caller's source checkout is never passed to the rein as its execution + workspace after sandbox creation; and +- missing, ambiguous, or unsupported reachability fails closed before the rein + subprocess starts. + +Add tests proving the original checkout is not selected, the expected transport +argv is used, remote execution is represented honestly, and teardown still +runs on every failure. + +**Completed 2026-08-21:** added strict reachability-derived execution +transports. Local descriptors require `pid` + `workspace_dir` and wrap commands +with `nsenter`; remote descriptors require `ssh` + `remote_dir` and wrap +commands over SSH. Mixed/incomplete descriptors fail closed. The gateway no +longer passes the source checkout to a rein after provisioning, and tests cover +transport argv, workspace selection, refusal, cleanup, and teardown. + +## Task: Bound subprocesses and remove temporary task material + +```task +id: GLAS-WP-0005-T02 +status: done +priority: high +``` + +Enforce the profile timeout on the outer rein subprocess, normalize timeout +failures, and remove generated task-spec files on success, ordinary failure, +timeout, and interruption. Caller-supplied task files must not be deleted. +Record and safely remove already-orphaned `glas-harness-task-*.json` files after +their exact scope is verified. + +**Completed 2026-08-21:** the profile timeout now bounds the outer transport +subprocess. Generated task specs are mode-0600 files inside the sandbox +workspace and the gateway calls rein cleanup before sandbox teardown on every +terminal path; caller-owned task files are preserved. Verified all 22 legacy +`/tmp/glas-harness-task-*.json` files were owned by the current user, mode 0600, +and valid Glas task specs with no active rein process, then securely removed +those exact files. + +## Task: Repair package dependency metadata + +```task +id: GLAS-WP-0005-T03 +status: done +priority: medium +``` + +Declare direct runtime dependencies directly, build the wheel, install it in an +isolated environment with the sandbox extra, and validate that packaged profile +and rein data resolve without relying on the source checkout. + +**Completed 2026-08-21:** declared `httpx>=0.27` directly. Built the wheel with +uv, installed it outside the checkout, resolved all three profiles from packaged +data, verified both rein descriptors are present, and inspected wheel metadata +for direct and optional dependencies. + +## Task: Replace routing-only CI with executable validation + +```task +id: GLAS-WP-0005-T04 +status: progress +priority: high +``` + +Make Forgejo CI check out the repository, install the project and its test +dependencies, run the full unit suite, and run `glas-harness profiles`. Retain +only routing probes that still provide operational value. + +**Implemented 2026-08-21:** replaced the two echo-only jobs with the estate's +archive-checkout pattern, explicit Python/sand-boxer/project installation, the +full test suite, and packaged catalog validation. Closure waits on the first +post-push Forgejo run. + +## Task: Re-prove the sandbox boundary and close evidence + +```task +id: GLAS-WP-0005-T05 +status: wait +priority: high +``` + +Run offline boundary tests and, where the already-approved local runtime permits +it, a bounded non-secret bwrap proof showing that execution targets the sandbox +copy and cannot mutate the source checkout. Correct prior evidence claims rather +than silently treating lifecycle-only proof as isolation proof. Run the full +suite and catalog validation, synchronize work records, and hand off any live +residual before finishing the workplan. + +**2026-08-21 boundary recheck:** offline transport/package tests pass and the +prior GLAS-WP-0004 evidence now explicitly withdraws its isolation implication. +A real managed-agent bwrap probe failed closed: direct `nsenter` was denied on +the reported namespace and neither rein command is mounted inside the bwrap +root. The source checkout is no longer used as a fallback. Runtime mounting, +owner-exec, network, and credential delivery are handed off as residual +`GLAS-IN-0002`; live positive proof waits on that owner contract. + +## Overall acceptance + +1. No governed rein invocation operates on the caller's original checkout. +2. The selected process crosses the declared local namespace or remote SSH + boundary, or fails closed without execution. +3. Profile timeouts bound the outer subprocess and generated task files do not + survive any terminal path. +4. Package metadata names every direct dependency and the built artifact carries + the executable profile/rein catalog. +5. Forgejo CI executes tests plus catalog validation. +6. Evidence distinguishes sandbox lifecycle from verified sandbox execution.