fix: execute local reins through the sandbox owner
Some checks failed
ci / validate (push) Has been cancelled

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0726e-5232-73f2-aaca-2c05ceb62efb
This commit is contained in:
tegwick 2026-09-05 19:16:22 +02:00
parent 92392f75f1
commit 63a7f9f160
14 changed files with 463 additions and 81 deletions

View file

@ -55,7 +55,7 @@ bounded proof can establish or disprove readiness, and its evidence retains
that state.
After creation, execution uses only the reachability-derived sandbox workspace:
same-host namespaces are entered with `nsenter`, while remote workspaces are
same-host commands use sand-boxer owner-mediated execution, 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),

View file

@ -12,7 +12,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 | blocked | — | workplans/GLAS-WP-0005-sandbox-boundary-runtime-hardening.md |
| workplan | GLAS-WP-0005 | finished | — | workplans/GLAS-WP-0005-sandbox-boundary-runtime-hardening.md |
| workplan | GLAS-WP-0006 | finished | — | workplans/GLAS-WP-0006-consumer-actor-validation.md |
| workplan | GLAS-WP-0007 | finished | — | workplans/GLAS-WP-0007-capability-scope-truth.md |
| workplan | GLAS-WP-0008 | finished | — | workplans/GLAS-WP-0008-profile-operational-readiness.md |
@ -45,7 +45,7 @@
| 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 | done | — | workplans/GLAS-WP-0005-sandbox-boundary-runtime-hardening.md |
| task | GLAS-WP-0005-T05 | wait | — | workplans/GLAS-WP-0005-sandbox-boundary-runtime-hardening.md |
| task | GLAS-WP-0005-T05 | done | — | workplans/GLAS-WP-0005-sandbox-boundary-runtime-hardening.md |
| task | GLAS-WP-0006-T01 | done | — | workplans/GLAS-WP-0006-consumer-actor-validation.md |
| task | GLAS-WP-0007-T01 | done | — | workplans/GLAS-WP-0007-capability-scope-truth.md |
| task | GLAS-WP-0007-T02 | done | — | workplans/GLAS-WP-0007-capability-scope-truth.md |

View file

@ -0,0 +1,68 @@
# GLAS-WP-0005 sandbox owner boundary proof
Date: 2026-09-05. Sand-boxer revision:
`b6655d88590e03820675719d6dd574e5f5a9f82e`.
The Glas gateway now binds same-host execution to the creating manager's
`execute` operation. Each request repeats the exact actor/project identity and
resolved request id as `consumer.run_id`, forwards the selected profile's
value-free credential route references, and caps execution at the profile
timeout. Local descriptors without that process-local binding fail closed.
There is no direct `nsenter` or host task-file write path.
Generated task files travel through bounded stdin to an exclusive mode-0600
writer inside the sandbox's `.git` directory. Removal uses owner execution.
Timeouts, owner refusals, and truncated output fail closed; gateway teardown
still runs when session initialization fails.
## Live non-secret proof
Command: `.venv/bin/python scripts/prove-owner-boundary.py`.
This reproducible fixture exercises the real gateway, real sand-boxer manager,
real bwrap owner broker, and the rein-aharness adapter's session setup, commit
verification, and cleanup. Its dispatch is a deterministic Python/Git probe,
not an installed agent CLI or model call. Only the fixture's in-memory profile
is made unverified; the committed profile catalog is unchanged.
Successful sandbox: `e3907e55`, workspace `/tmp/sandboxer-bwrap/e3907e55`.
- Source checkout was absent inside the sandbox; a source write was refused.
- The sandbox copy was readable and accepted a new committed file.
- Host source sentinel and Git HEAD remained unchanged; no proof file appeared
in the source checkout.
- Actor `agt`, project `glas-boundary-proof`, and run id
`glas-wp-0005-boundary-proof` matched inside the child environment.
- Task content arrived through stdin and its file mode was `0600`.
- Only `lo` appeared in the network namespace.
- The generated task was removed before teardown. Final state was `destroyed`
and the workspace was absent.
An initial fixture run (`78be8886`) encountered a Python quoting error in the
probe itself. It failed during dispatch, removed its generated task, preserved
the source, and destroyed the workspace. The fixture quoting was corrected
before the successful run above.
## Validation and limits
`.venv/bin/python -m pytest tests/ -q`: **81 passed**. Coverage includes exact
create/exec identity with supplied and generated request ids, unchanged route
references, timeout caps, stdin-only task content, no local subprocess or host
file fallback, and teardown on owner refusal, timeout, truncation, and writer
failure. Both concrete adapters' session and cleanup tests pass.
`.venv/bin/glas-harness profiles`: catalog validation passed. Both local
profiles remain **blocked**; the remote profile remains **unverified**.
This closes the boundary-hardening acceptance in GLAS-WP-0005. It does not
prove an installed production rein runtime, credential delivery, required
outbound connectivity, or the remote SSH runtime. Registered residual
`GLAS-IN-0002` (`01a02b76-f020-7d60-a3ce-12a34c13ebce`) remains live for the
local production requirements and was refreshed before workplan closure.
Sand-boxer owns runtime/credential/egress provision; Glas owns the subsequent
real-rein acceptance and profile readiness review. The prior GLAS-WP-0004
lifecycle-only evidence remains explicitly withdrawn as an isolation proof.
Earlier T03 package and T04 Forgejo CI evidence remains historical; this
session validates the changed code locally and does not claim a new published
Forgejo run.

View file

@ -72,13 +72,27 @@ 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`
rein command uses the creating managers `execute` operation; 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.
The gateway binds local execution to the exact create-time consumer: actor,
project, and resolved request id as `run_id`. Every owner command forwards the
selected profile's value-free `credential_route_refs` and a timeout capped by
its limit. The binding is process-local and excluded from serialized handles.
An unbound local descriptor refuses execution; no consumer namespace entry or
host execution fallback exists. Owner timeouts and truncated output fail closed.
Generated task specs travel over stdin to an exclusive mode-0600 writer inside
`.git`; removal also crosses the owner boundary. Sand-boxer revision `b6655d8`
or a compatible implementation with bounded `stdin_text` is required.
Run `.venv/bin/python scripts/prove-owner-boundary.py` for a non-secret gateway
boundary proof. It injects deterministic dispatch in place of the agent loop
and does not establish production rein, credential, or egress readiness.
SSH reachability accepts only one non-option host or `user@host` target whose
components begin with an alphanumeric character. Glas also terminates SSH
option parsing with `--`; a reachability descriptor cannot reinterpret its

View file

@ -77,6 +77,22 @@ description: |
consumer namespace-entry authority. This strengthens the owner-exec need; it
does not justify widening host execution.
2026-09-05 handoff refresh: sand-boxer b6655d8 now supplies owner-mediated
execution and bounded stdin. Glas replaced direct nsenter and host task-file
writes with that operation, preserving exact actor/project/request run_id,
profile credential_route_refs, and timeout. The non-secret gateway proof
passed in sandbox e3907e55: source absent and mutation refused, sandbox-only
commit, loopback-only network, mode-0600 task removed before teardown, and
destroyed workspace. Evidence: docs/evidence/GLAS-WP-0005-owner-boundary-2026-09-05.md.
Remaining owner work: supply the declared rein runtimes and dependencies,
governed credential delivery and explicit required egress, then prove a real
rein command through the Glas adapter before changing either local profile
from blocked. Glas owns the final profile review and acceptance run once the
sand-boxer runtime contract is available. The deterministic non-secret proof
does not satisfy this production acceptance. GLAS-WP-0005 can close its
boundary-hardening scope with this registered live residual still open.
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

@ -0,0 +1,141 @@
"""Bounded non-secret gateway proof; does not certify a production rein runtime.
Run with .venv/bin/python scripts/prove-owner-boundary.py.
"""
import json
import subprocess
import tempfile
from pathlib import Path
from sandboxer.core.manager import SandboxManager
from sandboxer.lifecycle.store import SandboxStore
from sandboxer.payments.credits import CreditsStore
from sandboxer.snapshots.store import SnapshotStore
from glas_harness.contract import ExecutionRequest, OperationalReadiness, ToolResult
from glas_harness.gateway import run_execution
from glas_harness.profiles import ProfileCatalog
from glas_harness.reins.rein_aharness import ReinAharness
class BoundaryProof(ReinAharness):
"""Use real adapter setup/cleanup, with a deterministic non-agent dispatch."""
def __init__(self, source):
super().__init__()
self.source = source
self.proof = {}
self.workspace = None
self.task_removed = False
def start_session(self, profile, inputs, sandbox):
self.workspace = sandbox.reachability.get("workspace_dir")
return super().start_session(profile, inputs, sandbox)
def dispatch_tool(self, session, tool_call):
transport = session["transport"]
code = r"""
import json, os, stat, sys
from pathlib import Path
source, task = map(Path, sys.argv[1:])
assert not source.exists(), 'source checkout visible'
try:
(source / 'sentinel').write_text('escaped')
except OSError:
pass
else:
raise AssertionError('source mutation succeeded')
assert Path('sentinel').read_text() == 'unchanged'
assert stat.S_IMODE(task.stat().st_mode) == 0o600
assert json.loads(task.read_text())['description'] == 'non-secret boundary probe'
Path('proof.txt').write_text('sandbox copy only\n')
interfaces = [line.split(':', 1)[0].strip()
for line in Path('/proc/net/dev').read_text().splitlines()[2:]]
assert interfaces == ['lo']
assert os.environ['SANDBOXER_ACTOR'] == 'agt'
assert os.environ['SANDBOXER_PROJECT'] == 'glas-boundary-proof'
assert os.environ['SANDBOXER_RUN_ID'] == 'glas-wp-0005-boundary-proof'
print(json.dumps({'source_absent': True, 'source_mutation_refused': True,
'task_mode': '0600', 'interfaces': interfaces,
'cwd': str(Path.cwd()), 'identity_exact': True}))
"""
proc = transport.run(
["/usr/bin/python3", "-c", code, str(self.source), session["task_file"]],
timeout=30,
)
if proc.returncode:
return ToolResult(ok=False, error=proc.stderr)
self.proof = json.loads(proc.stdout)
for command in (["git", "add", "proof.txt"],
["git", "commit", "-q", "-m", "non-secret boundary proof"]):
result = transport.run(command, timeout=30)
if result.returncode:
return ToolResult(ok=False, error=result.stderr)
return ToolResult(ok=True, output=proc.stdout)
def cleanup_session(self, session):
super().cleanup_session(session)
proc = session["transport"].run(
["test", "!", "-e", session["task_file"]], timeout=30
)
self.task_removed = proc.returncode == 0
assert self.task_removed
def main():
with tempfile.TemporaryDirectory(prefix="glas-owner-proof-") as root:
root = Path(root)
source = root / "source"
source.mkdir()
for command in (["git", "init", "-q"],
["git", "config", "user.name", "Glas Boundary Proof"],
["git", "config", "user.email", "proof@example.invalid"]):
subprocess.run(command, cwd=source, check=True)
(source / "sentinel").write_text("unchanged")
subprocess.run(["git", "add", "sentinel"], cwd=source, check=True)
subprocess.run(["git", "commit", "-qm", "fixture"], cwd=source, check=True)
source_head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=source)
manager = SandboxManager(
store=SandboxStore(path=root / "sandboxes.json"),
credits=CreditsStore(path=root / "credits.json"),
snapshots=SnapshotStore(path=root / "snapshots.json"),
)
catalog = ProfileCatalog()
profile, _ = catalog.resolve("harness.agent-dev-local@1.0.0")
# Only this in-memory injected non-agent fixture is eligible for proof.
catalog.profiles()[(profile.id, profile.version)] = profile.model_copy(update={
"operational_readiness": OperationalReadiness(
status="unverified", reason="non-secret boundary fixture only",
owner="glas-harness", evidence_ref="GLAS-WP-0005",
),
"credential_route_refs": [],
"limits": profile.limits.model_copy(update={"timeout_seconds": 30}),
})
rein = BoundaryProof(source)
result = run_execution(ExecutionRequest(
harness_profile_ref=str(profile.ref), repo=str(source), title="boundary proof",
description="non-secret boundary probe", actor="agt",
project="glas-boundary-proof", request_id="glas-wp-0005-boundary-proof",
report_to_hub=False,
), catalog=catalog, rein=rein, manager=manager)
unchanged = (
(source / "sentinel").read_text() == "unchanged"
and not (source / "proof.txt").exists()
and subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=source) == source_head
)
state = manager.store.get(result.evidence.sandbox_id) if result.evidence.sandbox_id else None
removed = bool(rein.workspace) and not Path(rein.workspace).exists()
payload = {
"ok": result.ok, "sandbox_id": result.evidence.sandbox_id,
"proof": rein.proof, "source_unchanged": unchanged,
"task_removed_before_teardown": rein.task_removed,
"workspace_removed": removed, "state": state.state.value if state else None,
"error": result.tool_error, "production_rein_proven": False,
}
print(json.dumps(payload, indent=2))
return 0 if result.ok and unchanged and removed and rein.task_removed else 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -11,7 +11,7 @@ import re
from abc import ABC, abstractmethod
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator, model_validator
CONTRACT_VERSION = "1.0"
_SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:[-+][0-9A-Za-z.-]+)?$")
@ -114,6 +114,10 @@ class ReinDescriptor(ContractModel):
class SandboxHandle(ContractModel):
# Process-local owner binding; never serialized as reachability or evidence.
_owner_execute: Any = PrivateAttr(default=None)
_timeout_seconds: int = PrivateAttr(default=600)
sandbox_id: str
host: str
reachability: dict[str, Any] = Field(default_factory=dict)

View file

@ -12,7 +12,7 @@ from datetime import UTC, datetime
from pydantic import ValidationError
from sandboxer.core.manager import SandboxManager
from sandboxer.models import Consumer, SandboxCreateRequest
from sandboxer.models import Consumer, SandboxCreateRequest, SandboxExecRequest
from glas_harness import hub
from glas_harness.contract import (
@ -82,7 +82,7 @@ def run_execution(
error: str | None = None
try:
consumer = _consumer_from_request(request)
consumer = _consumer_from_request(request).model_copy(update={"run_id": request_id})
profile, descriptor = catalog.resolve(request.harness_profile_ref)
catalog.require_operational(profile)
selected_rein = rein or catalog.build_rein(profile, descriptor)
@ -138,6 +138,21 @@ def run_execution(
reachability=reachability,
)
def owner_execute(argv, input_text, timeout):
return manager.execute(
sandbox.sandbox_id,
SandboxExecRequest(
command=list(argv),
consumer=consumer,
credential_route_refs=profile.credential_route_refs,
stdin_text=input_text,
timeout_seconds=timeout,
),
)
sandbox._owner_execute = owner_execute
sandbox._timeout_seconds = profile.limits.timeout_seconds or 600
try:
session = selected_rein.start_session(
profile,

View file

@ -2,21 +2,20 @@
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.
one declared boundary: owner-mediated execution 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 typing import Any, Callable, Literal, Sequence
from glas_harness.contract import SandboxHandle
@ -36,6 +35,8 @@ class ExecutionTransport:
workspace: str
pid: int | None = None
ssh_target: str | None = None
owner_execute: Callable | None = None
timeout_seconds: int = 600
def command(self, argv: Sequence[str]) -> list[str]:
if not argv:
@ -50,20 +51,7 @@ class ExecutionTransport:
*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,
]
raise TransportError("local commands require sandbox owner execution")
if self.kind == "ssh":
if not self.ssh_target or not _SSH_TARGET.fullmatch(self.ssh_target):
raise TransportError(
@ -79,12 +67,31 @@ class ExecutionTransport:
input_text: str | None = None,
timeout: float | None = None,
) -> subprocess.CompletedProcess[str]:
if not argv:
raise TransportError("cannot execute an empty command")
bounded_timeout = min(
timeout if timeout is not None else self.timeout_seconds,
self.timeout_seconds,
)
if self.kind == "local_namespace":
if self.owner_execute is None:
raise TransportError("local sandbox requires an owner execution binding")
if bounded_timeout < 1:
raise TransportError("owner execution timeout must be at least one second")
result = self.owner_execute(list(argv), input_text, int(bounded_timeout))
if result.timed_out:
raise subprocess.TimeoutExpired(list(argv), bounded_timeout)
if result.output_truncated:
raise TransportError("sandbox owner execution output was truncated")
return subprocess.CompletedProcess(
list(argv), result.exit_code, result.stdout, result.stderr
)
return subprocess.run(
self.command(argv),
input=input_text,
capture_output=True,
text=True,
timeout=timeout,
timeout=bounded_timeout,
)
def resolve_executable(self, name: str, *, timeout: float = 15.0) -> str:
@ -117,33 +124,20 @@ class ExecutionTransport:
# 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"'
script = 'umask 077; set -C; 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: "
"cannot create 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: "
"cannot remove sandbox task file: "
+ (proc.stderr.strip() or f"exit {proc.returncode}")
)
@ -170,8 +164,12 @@ def transport_from_sandbox(sandbox: SandboxHandle) -> ExecutionTransport:
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")
if sandbox._owner_execute is None:
raise TransportError("local sandbox requires an owner execution binding")
return ExecutionTransport(
kind="local_namespace", workspace=str(workspace), pid=parsed_pid
kind="local_namespace", workspace=str(workspace), pid=parsed_pid,
owner_execute=sandbox._owner_execute,
timeout_seconds=sandbox._timeout_seconds,
)
if has_remote:
if not ssh_target or not remote_dir:
@ -185,6 +183,7 @@ def transport_from_sandbox(sandbox: SandboxHandle) -> ExecutionTransport:
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)
kind="ssh", workspace=str(remote_dir), ssh_target=str(ssh_target),
timeout_seconds=sandbox._timeout_seconds
)
raise TransportError("sandbox exposes no supported execution reachability")

View file

@ -1,6 +1,8 @@
from datetime import UTC, datetime
from unittest.mock import MagicMock, patch
import pytest
from sandboxer.models import Reachability, SandboxState, SandboxStatus
from glas_harness.contract import (
@ -14,6 +16,7 @@ from glas_harness.contract import (
)
from glas_harness.gateway import run_execution, run_task_through_rein
from glas_harness.profiles import ProfileCatalog
from glas_harness.transport import transport_from_sandbox
PROFILE = "harness.agent-dev-local@1.0.0"
@ -127,6 +130,81 @@ def test_run_execution_creates_and_destroys_sandbox() -> None:
assert result.tool_output == "sensitive direct output"
@pytest.mark.parametrize("request_id", ["exact-request", None])
def test_owner_commands_preserve_create_identity_routes_and_timeout(request_id):
manager = MagicMock()
manager.create.return_value = _fake_status()
manager.execute.return_value = MagicMock(
timed_out=False, output_truncated=False, exit_code=0, stdout="", stderr=""
)
catalog = _catalog_with_readiness()
profile, _ = catalog.resolve(PROFILE)
refs = ["proof-route-a", "proof-route-b"]
catalog.profiles()[(profile.id, profile.version)] = profile.model_copy(update={
"credential_route_refs": refs,
"limits": profile.limits.model_copy(update={"timeout_seconds": 7}),
})
class OwnerRein(_FakeRein):
def start_session(self, profile, inputs, sandbox):
assert "_owner_execute" not in sandbox.model_dump()
transport = transport_from_sandbox(sandbox)
transport.run(["printf", "%s", "non-secret"], timeout=30)
path = transport.write_task_file({"title": "private prompt"})
transport.remove_file(path)
return super().start_session(profile, inputs, sandbox)
request = _request().model_copy(update={
"request_id": request_id, "actor": "atm", "project": "exact-project"
})
result = run_execution(request, catalog=catalog, rein=OwnerRein(), manager=manager)
assert result.ok
create_consumer = manager.create.call_args.args[0].consumer
assert create_consumer.actor.value == "atm"
assert create_consumer.project == "exact-project"
assert create_consumer.run_id == result.evidence.request_id
assert create_consumer.session_id is None
assert manager.execute.call_count == 3
for call in manager.execute.call_args_list:
sandbox_id, command = call.args
assert sandbox_id == "sbx1"
assert command.consumer == create_consumer
assert command.credential_route_refs == refs
assert command.timeout_seconds == 7
writer = manager.execute.call_args_list[1].args[1]
assert writer.stdin_text == '{"title": "private prompt"}'
assert "private prompt" not in str(writer.command)
manager.destroy.assert_called_once_with("sbx1")
@pytest.mark.parametrize("failure", ["refused", "timeout", "truncated", "write"])
def test_owner_session_failure_still_destroys_sandbox(failure):
from glas_harness.reins.rein_aharness import ReinAharness
manager = MagicMock()
manager.create.return_value = _fake_status()
good = MagicMock(timed_out=False, output_truncated=False, exit_code=0,
stdout="head-before", stderr="")
if failure == "refused":
manager.execute.side_effect = PermissionError("owner denied execution")
elif failure == "write":
manager.execute.side_effect = [good, MagicMock(
timed_out=False, output_truncated=False, exit_code=1,
stdout="", stderr="task write refused",
)]
else:
manager.execute.return_value = MagicMock(
timed_out=failure == "timeout", output_truncated=failure == "truncated"
)
with patch("glas_harness.transport.subprocess.run") as host_run:
result = run_execution(_request(), catalog=_catalog_with_readiness(),
rein=ReinAharness(), manager=manager)
assert not result.ok
assert result.evidence.failure_stage == "session_start"
manager.destroy.assert_called_once_with("sbx1")
host_run.assert_not_called()
def test_run_execution_normalizes_execution_failure_and_tears_down() -> None:
manager = MagicMock()
manager.create.return_value = _fake_status()

View file

@ -45,6 +45,10 @@ def test_start_session_writes_task_file_and_captures_head(tmp_path) -> None:
host="localhost",
reachability={"pid": str(os.getpid()), "workspace_dir": str(repo)},
)
sandbox._owner_execute = MagicMock(
return_value=MagicMock(timed_out=False, output_truncated=False, exit_code=0,
stdout="", stderr="")
)
with patch.object(ExecutionTransport, "git_head", return_value="abc123"):
session = rein.start_session(
profile=ProfileCatalog().resolve("harness.agent-dev-local@1.0.0")[0],
@ -55,11 +59,11 @@ def test_start_session_writes_task_file_and_captures_head(tmp_path) -> None:
assert session["target_repo"] == str(repo)
assert session["head_before"] == "abc123"
assert session["transport"].workspace == str(repo)
task_spec = json.loads(open(session["task_file"]).read())
task_spec = json.loads(sandbox._owner_execute.call_args.args[1])
assert task_spec["title"] == "t"
assert task_spec["target_repo"] == str(repo)
rein.cleanup_session(session)
assert not os.path.exists(session["task_file"])
assert sandbox._owner_execute.call_args.args[0] == ["rm", "-f", "--", session["task_file"]]
def test_start_session_requires_resolvable_target_repo() -> None:

View file

@ -45,6 +45,10 @@ def test_start_session_writes_task_file_and_captures_head(tmp_path) -> None:
host="localhost",
reachability={"pid": str(os.getpid()), "workspace_dir": str(repo)},
)
sandbox._owner_execute = MagicMock(
return_value=MagicMock(timed_out=False, output_truncated=False, exit_code=0,
stdout="", stderr="")
)
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],
@ -55,11 +59,11 @@ def test_start_session_writes_task_file_and_captures_head(tmp_path) -> None:
assert session["target_repo"] == str(repo)
assert session["head_before"] == "abc123"
assert session["transport"].workspace == str(repo)
task_spec = json.loads(open(session["task_file"]).read())
task_spec = json.loads(sandbox._owner_execute.call_args.args[1])
assert task_spec["title"] == "t"
assert task_spec["target_repo"] == str(repo)
rein.cleanup_session(session)
assert not os.path.exists(session["task_file"])
assert sandbox._owner_execute.call_args.args[0] == ["rm", "-f", "--", session["task_file"]]
def test_start_session_requires_resolvable_target_repo() -> None:

View file

@ -14,28 +14,18 @@ def test_local_namespace_transport_requires_pid_and_workspace(tmp_path: Path) ->
host="localhost",
reachability={"pid": "4321", "workspace_dir": str(tmp_path)},
)
sandbox._owner_execute = MagicMock(return_value=MagicMock(
timed_out=False, output_truncated=False, exit_code=0, stdout="ok", stderr=""
))
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",
]
with patch("glas_harness.transport.subprocess.run") as run:
assert transport.run(["git", "status"], timeout=30).stdout == "ok"
run.assert_not_called()
sandbox._owner_execute.assert_called_once_with(["git", "status"], None, 30)
with pytest.raises(TransportError, match="owner execution"):
transport.command(["git", "status"])
def test_remote_transport_wraps_command_without_local_shell() -> None:
@ -100,20 +90,47 @@ def test_incomplete_or_ambiguous_reachability_fails_closed(reachability) -> None
)
def test_local_task_file_is_private_and_removable(tmp_path: Path) -> None:
(tmp_path / ".git").mkdir()
def test_local_task_file_uses_owner_stdin_and_cleanup(tmp_path: Path) -> None:
owner = MagicMock(return_value=MagicMock(
timed_out=False, output_truncated=False, exit_code=0, stdout="", stderr=""
))
transport = ExecutionTransport(
kind="local_namespace", workspace=str(tmp_path), pid=123
kind="local_namespace", workspace=str(tmp_path), pid=123, owner_execute=owner,
timeout_seconds=7,
)
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"
task_path = transport.write_task_file({"title": "private prompt"})
command, stdin, timeout = owner.call_args.args
assert "umask 077; set -C; cat" in command[2]
assert "private prompt" not in str(command)
assert json.loads(stdin) == {"title": "private prompt"}
assert timeout == 7
assert Path(task_path).parent == tmp_path / ".git"
assert not Path(task_path).exists() # No host write, even with a fake owner.
transport.remove_file(task_path)
assert not path.exists()
owner.assert_called_with(["rm", "-f", "--", task_path], None, 7)
def test_local_descriptor_without_owner_fails_closed():
with pytest.raises(TransportError, match="owner execution binding"):
transport_from_sandbox(SandboxHandle(
sandbox_id="sbx", host="localhost",
reachability={"pid": "123", "workspace_dir": "/tmp/ws"},
))
@pytest.mark.parametrize("timed_out,truncated", [(True, False), (False, True)])
def test_owner_timeout_and_truncation_fail_closed(timed_out, truncated):
import subprocess
owner = MagicMock(return_value=MagicMock(
timed_out=timed_out, output_truncated=truncated
))
transport = ExecutionTransport(
kind="local_namespace", workspace="/tmp/ws", owner_execute=owner,
timeout_seconds=3,
)
with pytest.raises(subprocess.TimeoutExpired if timed_out else TransportError):
transport.run(["true"], timeout=30)
owner.assert_called_once_with(["true"], None, 3)
def test_remote_task_file_uses_ssh_stdin_and_cleanup() -> None:

View file

@ -4,11 +4,11 @@ type: workplan
title: "Sandbox-boundary correction and runtime hardening"
domain: infotech
repo: glas-harness
status: blocked
status: finished
owner: codex
topic_slug: sandbox-boundary-runtime-hardening
created: "2026-08-21"
updated: "2026-08-23"
updated: "2026-09-05"
state_hub_workstream_id: "c8049700-73ca-549a-b250-1020de1cddc5"
---
@ -133,7 +133,7 @@ commit `994b2da`.
```task
id: GLAS-WP-0005-T05
status: wait
status: done
priority: high
state_hub_task_id: "a4be4d68-b0af-5273-978a-367bba414f0c"
```
@ -171,6 +171,28 @@ with child `CapEff=0`, but did not make direct namespace entry permissible.
This confirms that T05 needs sand-boxer-owned execution rather than another
consumer-side namespace workaround.
**Completed 2026-09-05:** reviewed sand-boxer `b6655d8` and replaced the stale
T01 direct-nsenter implementation with sandbox-owner execution. Create and exec
now preserve exact actor/project and resolved request id as `consumer.run_id`;
every command carries the profile's value-free credential route references and
a timeout capped by its limit. Task writes and removal both execute inside the
sandbox, with prompt content supplied only over bounded stdin. Unbound local
descriptors, owner refusals, timeouts, and truncated output fail closed.
The real non-secret gateway proof passed in sandbox `e3907e55`: source checkout
absent and mutation refused, a new commit only in the sandbox copy, exact child
identity, mode-0600 task removed before teardown, loopback-only networking,
unchanged host source, and destroyed/absent sandbox workspace. Full suite:
**81 passed**; catalog validation passes. Reproduction and limitations:
[owner boundary evidence](../docs/evidence/GLAS-WP-0005-owner-boundary-2026-09-05.md).
The production runtime/credential/explicit-egress requirements remain live in
registered residual `GLAS-IN-0002` (`01a02b76-f020-7d60-a3ce-12a34c13ebce`),
refreshed before closure with owner responsibilities and real-rein acceptance.
Both local profiles remain blocked and the remote profile unverified. This
closes T05's bounded boundary proof and residual handoff; it does not certify
production rein readiness. Decision: `6d927f9f-d37d-4234-ad97-bad562b31eaf`.
## Overall acceptance
1. No governed rein invocation operates on the caller's original checkout.