fix: execute local reins through the sandbox owner
Some checks failed
ci / validate (push) Has been cancelled
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:
parent
92392f75f1
commit
63a7f9f160
14 changed files with 463 additions and 81 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
68
docs/evidence/GLAS-WP-0005-owner-boundary-2026-09-05.md
Normal file
68
docs/evidence/GLAS-WP-0005-owner-boundary-2026-09-05.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
141
scripts/prove-owner-boundary.py
Normal file
141
scripts/prove-owner-boundary.py
Normal 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())
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue