rein-aharness/rein_aharness/repository_artifact.py
tegwick c63caf5568
Some checks are pending
Governed runtime contract / contract (push) Waiting to run
feat: import governed sandbox commits and enforce native CLI limits
Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-09 14:08:10 +02:00

320 lines
12 KiB
Python

"""Bounded sandbox Git return into an already-owned repository transaction.
Artifact bytes stay process-local and are discarded on any gateway/teardown
failure. They never enter GatewayResult, Hub, metrics or the close outbox.
The initial contract supports one local commit, matching the factory pilot.
"""
from __future__ import annotations
import base64
import binascii
import hashlib
import json
import os
import re
import subprocess
import tempfile
from pathlib import Path
from typing import Any
from rein_aharness.execution_cancel import ExecutionCancel, resolve_cancel
from rein_aharness.repository_grant import RepositoryGrant
from rein_aharness.repository_transaction import (
RepositoryTransaction,
RepositoryTransactionError,
)
MAX_BUNDLE_BYTES = 128 * 1024
MAX_NEW_OBJECT_BYTES = 2 * 1024 * 1024
_OID = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}")
class RepositoryArtifactError(RepositoryTransactionError):
"""A sandbox result cannot be safely imported under this transaction."""
# Executed inside the selected sandbox by its owner, with no host path access.
# The owner transport separately caps output and wall time. No binary or source
# content is placed in command arguments or exception messages.
_EXPORT = r"""
import base64, json, pathlib, subprocess, sys, tempfile
repo, baseline, expected, limit = sys.argv[1:]
limit = int(limit)
def git(*args):
p = subprocess.run(["git", "-C", repo, "-c", "core.hooksPath=/dev/null", *args], capture_output=True, timeout=20)
if p.returncode: raise RuntimeError("sandbox artifact Git operation refused")
return p.stdout
if git("rev-parse", "HEAD").decode().strip() != expected:
raise RuntimeError("sandbox artifact head mismatch")
if git("status", "--porcelain=v1", "--untracked-files=all").strip():
raise RuntimeError("sandbox artifact checkout is not clean")
if git("rev-list", "--count", baseline + "..HEAD").strip() != b"1":
raise RuntimeError("sandbox artifact must contain one commit")
git("merge-base", "--is-ancestor", baseline, "HEAD")
with tempfile.TemporaryDirectory(prefix="rein-artifact-") as d:
path = pathlib.Path(d) / "return.bundle"
git("bundle", "create", str(path), baseline + "..HEAD")
if path.stat().st_size > limit: raise RuntimeError("sandbox artifact too large")
payload = path.read_bytes()
print(json.dumps({"version": "1", "baseline": baseline, "head": expected,
"bundle": base64.b64encode(payload).decode("ascii")}))
"""
def _git(repo: Path, *args: str) -> bytes:
# Do not permit inherited Git directory/object/config overrides to redirect
# the transfer. All transport is between owner-selected local paths.
env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
env.update(
GIT_CONFIG_NOSYSTEM="1",
GIT_CONFIG_GLOBAL=os.devnull,
GIT_TERMINAL_PROMPT="0",
GIT_NO_REPLACE_OBJECTS="1",
)
try:
proc = subprocess.run(
[
"git",
"-C",
str(repo),
"-c",
"core.hooksPath=/dev/null",
"-c",
"core.fsmonitor=false",
"-c",
"protocol.allow=never",
"-c",
"protocol.file.allow=always",
*args,
],
env=env,
capture_output=True,
timeout=30,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise RepositoryArtifactError(
"artifact Git operation unavailable or timed out"
) from exc
if proc.returncode:
raise RepositoryArtifactError("artifact Git validation or import refused")
return proc.stdout
class RepositoryArtifactTransfer:
"""Trusted worker-owned capture; source mutation waits for successful teardown."""
def __init__(
self,
transaction: RepositoryTransaction,
grant: RepositoryGrant,
*,
cancel: ExecutionCancel | None = None,
) -> None:
if (
not isinstance(transaction, RepositoryTransaction)
or not transaction.locked
or transaction.baseline is None
):
raise RepositoryArtifactError(
"artifact transfer requires the active repository transaction"
)
if grant.min_commits != 1 or grant.max_commits != 1 or grant.publish:
raise RepositoryArtifactError(
"artifact transfer v1 requires exactly one local commit"
)
self.transaction = transaction
self.grant = grant
self.cancel = resolve_cancel(cancel)
self._bundle: bytes | None = None
self._head: str | None = None
def _check(self) -> None:
if self.cancel is not None:
self.cancel.check()
self.transaction.assert_baseline_unchanged()
def capture(self, sandbox: Any, summary: Any) -> None:
"""Called by Glas after session cleanup, before sandbox destruction."""
from glas_harness.transport import transport_from_sandbox
self._check()
if self._bundle is not None:
raise RepositoryArtifactError("artifact capture was already completed")
head = summary.commit_sha
if not isinstance(head, str) or not _OID.fullmatch(head):
raise RepositoryArtifactError(
"sandbox did not report an exact commit identity"
)
transport = transport_from_sandbox(sandbox)
if transport.kind != "local_namespace":
raise RepositoryArtifactError(
"artifact transfer v1 requires bounded sandbox owner execution"
)
result = transport.run(
[
"python3",
"-c",
_EXPORT,
transport.workspace,
self.transaction.baseline.head,
head,
str(MAX_BUNDLE_BYTES),
],
timeout=30,
)
if result.returncode:
raise RepositoryArtifactError("sandbox artifact export refused")
if len(result.stdout) > (MAX_BUNDLE_BYTES * 4 // 3) + 1024:
raise RepositoryArtifactError("sandbox artifact exceeds transport limit")
try:
payload = json.loads(result.stdout)
if (
not isinstance(payload, dict)
or set(payload) != {"version", "baseline", "head", "bundle"}
or payload["version"] != "1"
or payload["baseline"] != self.transaction.baseline.head
or payload["head"] != head
):
raise ValueError("artifact envelope mismatch")
bundle = base64.b64decode(payload["bundle"], validate=True)
if not bundle or len(bundle) > MAX_BUNDLE_BYTES:
raise ValueError("artifact byte limit")
except (ValueError, TypeError, binascii.Error) as exc:
raise RepositoryArtifactError(
"sandbox artifact envelope is invalid"
) from exc
self._bundle, self._head = bundle, head
self._check()
def import_after_teardown(self, reported_head: str | None) -> dict[str, Any]:
"""Validate in a disposable checkout, then fast-forward under the live lease."""
self._check()
if self._bundle is None or self._head is None or reported_head != self._head:
raise RepositoryArtifactError(
"successful gateway did not return the captured artifact"
)
baseline = self.transaction.baseline
with tempfile.TemporaryDirectory(prefix="rein-artifact-verify-") as temporary:
root = Path(temporary)
bundle_path = root / "return.bundle"
bundle_path.write_bytes(self._bundle)
bundle_path.chmod(0o600)
staging = root / "checkout"
_git(
root,
"clone",
"--quiet",
"--no-local",
"--no-hardlinks",
"--no-checkout",
"--",
str(baseline.repo_root),
str(staging),
)
_git(staging, "checkout", "--quiet", "--detach", baseline.head)
with RepositoryTransaction(staging, state_dir=root / "state") as inspection:
_git(staging, "bundle", "verify", str(bundle_path))
advertised = (
_git(staging, "bundle", "list-heads", str(bundle_path))
.decode()
.strip()
)
if advertised != self._head + " HEAD":
raise RepositoryArtifactError(
"artifact must advertise only the expected HEAD"
)
_git(
staging,
"fetch",
"--quiet",
"--no-tags",
"--no-write-fetch-head",
str(bundle_path),
"HEAD",
)
if (
_git(
staging, "rev-list", "--count", f"{baseline.head}..{self._head}"
).strip()
!= b"1"
):
raise RepositoryArtifactError(
"artifact contains an unexpected commit count"
)
_git(staging, "merge-base", "--is-ancestor", baseline.head, self._head)
# Reject symlinks and gitlinks before checking out untrusted data.
changes = _git(
staging,
"diff",
"--raw",
"--no-renames",
"-z",
baseline.head,
self._head,
"--",
).split(b"\0")
for header in changes[::2]:
if header and header.split()[1] not in (
b"100644",
b"100755",
b"000000",
):
raise RepositoryArtifactError(
"artifact changes an unsupported file mode"
)
oids = _git(
staging,
"rev-list",
"--objects",
"--no-object-names",
f"{baseline.head}..{self._head}",
).splitlines()
if len(oids) > 1024:
raise RepositoryArtifactError(
"artifact contains too many new objects"
)
total = 0
for oid in oids:
total += int(_git(staging, "cat-file", "-s", oid.decode()).strip())
if total > MAX_NEW_OBJECT_BYTES:
raise RepositoryArtifactError(
"artifact expands beyond the accepted byte limit"
)
_git(
staging, "merge", "--ff-only", "--no-edit", "--no-stat", self._head
)
inspection.validate_acceptance(self.grant.acceptance_policy())
self._check()
_git(
baseline.repo_root,
"fetch",
"--quiet",
"--no-tags",
"--no-write-fetch-head",
str(bundle_path),
"HEAD",
)
self._check()
_git(
baseline.repo_root,
"merge",
"--ff-only",
"--no-edit",
"--no-stat",
self._head,
)
if self.cancel is not None:
self.cancel.check()
accepted = self.transaction.validate_acceptance(
self.grant.acceptance_policy()
)
evidence = {
"version": "1",
"sha256": hashlib.sha256(self._bundle).hexdigest(),
"bytes": len(self._bundle),
"head": accepted.head,
"imported": True,
}
self._bundle = None
return evidence