fix: enforce sandbox execution boundary
Some checks failed
ci / validate (push) Has been cancelled

This commit is contained in:
tegwick 2026-08-21 10:40:29 +02:00
parent 1cd890d871
commit f773b5c101
19 changed files with 865 additions and 181 deletions

View file

@ -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"
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

View file

@ -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).

View file

@ -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 |

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.
```

View file

@ -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",
]

View file

@ -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."""

View file

@ -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)

View file

@ -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."""

View file

@ -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"])

View file

@ -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"])

View file

@ -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")

View file

@ -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")

View file

@ -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()

View file

@ -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")

135
tests/test_transport.py Normal file
View file

@ -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()

View file

@ -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.