diff --git a/README.md b/README.md index 95e8dad..16aa4e8 100644 --- a/README.md +++ b/README.md @@ -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), diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 4c2fff9..55ad404 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -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 | diff --git a/docs/evidence/GLAS-WP-0005-owner-boundary-2026-09-05.md b/docs/evidence/GLAS-WP-0005-owner-boundary-2026-09-05.md new file mode 100644 index 0000000..fd30351 --- /dev/null +++ b/docs/evidence/GLAS-WP-0005-owner-boundary-2026-09-05.md @@ -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. diff --git a/docs/harness-contract.md b/docs/harness-contract.md index d68af8e..e24f41b 100644 --- a/docs/harness-contract.md +++ b/docs/harness-contract.md @@ -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 manager’s `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 diff --git a/docs/intakes/residuals.md b/docs/intakes/residuals.md index 5727347..681ba2d 100644 --- a/docs/intakes/residuals.md +++ b/docs/intakes/residuals.md @@ -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. diff --git a/scripts/prove-owner-boundary.py b/scripts/prove-owner-boundary.py new file mode 100644 index 0000000..94b7d24 --- /dev/null +++ b/scripts/prove-owner-boundary.py @@ -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()) diff --git a/src/glas_harness/contract.py b/src/glas_harness/contract.py index 3c727e9..c386435 100644 --- a/src/glas_harness/contract.py +++ b/src/glas_harness/contract.py @@ -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) diff --git a/src/glas_harness/gateway.py b/src/glas_harness/gateway.py index 44714c4..6a9bf48 100644 --- a/src/glas_harness/gateway.py +++ b/src/glas_harness/gateway.py @@ -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, diff --git a/src/glas_harness/transport.py b/src/glas_harness/transport.py index bba591b..7ab018a 100644 --- a/src/glas_harness/transport.py +++ b/src/glas_harness/transport.py @@ -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") diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 03bb6d0..199cee5 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -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() diff --git a/tests/test_rein_aharness.py b/tests/test_rein_aharness.py index f0eac8b..37eacdd 100644 --- a/tests/test_rein_aharness.py +++ b/tests/test_rein_aharness.py @@ -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: diff --git a/tests/test_rein_openweights.py b/tests/test_rein_openweights.py index 3b7a402..0d9e793 100644 --- a/tests/test_rein_openweights.py +++ b/tests/test_rein_openweights.py @@ -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: diff --git a/tests/test_transport.py b/tests/test_transport.py index b8db43d..05e03dd 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -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: diff --git a/workplans/GLAS-WP-0005-sandbox-boundary-runtime-hardening.md b/workplans/GLAS-WP-0005-sandbox-boundary-runtime-hardening.md index 41ddbdf..51feefa 100644 --- a/workplans/GLAS-WP-0005-sandbox-boundary-runtime-hardening.md +++ b/workplans/GLAS-WP-0005-sandbox-boundary-runtime-hardening.md @@ -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.