diff --git a/docs/bwrap-runtime.md b/docs/bwrap-runtime.md index bf3a4cc..2cef21f 100644 --- a/docs/bwrap-runtime.md +++ b/docs/bwrap-runtime.md @@ -220,3 +220,27 @@ candidate unchanged. Its installed-path startup/lifecycle proof is [the local installation receipt](evidence/SAND-WP-0015-protected-local-install-2026-09-08.json). SAND-WP-0015-T06 is complete. T04 still owns configured execution, native credential adoption, real-model acceptance and production placement. + +## Standalone owner candidate + +`build-rein-runtime.py --owner-runtime` verifies rein's runtime-contract lock, +then installs non-editable rein/llm/Glas/sandboxer packages from rein's frozen +uv.lock. The artifact records source pins, package versions and the uv.lock digest. +Use the existing explicit `--claude-binary/--claude-sha256/--claude-version` pin; +a rolling workstation alias is not a candidate input. Console shebangs remain +fixed to the namespace mount; use the candidate interpreter with `-I -B -m` for +host owner commands. No credential, provider call or installation is part of build. + +Sandboxer wheels now include profile and extension definitions with packaged-first +lookup and source-checkout fallback. This closes a concrete local-versus-installed +owner discrepancy for bwrap. It is not a claim that every remote backend's external +hosts/templates/tools are packaged or admitted. The ephemeral Messages binding may +select an exact runtime path/digest; usual overlap and default-deny checks apply. + +Required `make check`: lint clean, 201 tests passed. Rein's +`scripts/prove-metered-runtime.py` consumes the candidate to test installed imports, +packaged definitions, explicit one-cycle bootstrap, actual pinned CLI/metered stream, +insufficient-capacity refusal and teardown against fixtures. The resulting receipt +belongs to `prj-helixforge-factory/evidence/2026-09-09-owner-bootstrap.json`. +Production installation and credential-holder/empty-egress profile/placement admission +remain SAND-WP-0015-T04 and HFACT-WP-0001-T03/T04; no prior profile is promoted. diff --git a/pyproject.toml b/pyproject.toml index 850fde1..ef75fc7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,10 @@ sandboxer = "sandboxer.cli:app" [tool.hatch.build.targets.wheel] packages = ["src/sandboxer"] +[tool.hatch.build.targets.wheel.force-include] +"profiles" = "sandboxer/data/profiles" +"extensions" = "sandboxer/data/extensions" + [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src"] diff --git a/scripts/build-rein-runtime.py b/scripts/build-rein-runtime.py index 0868d3b..bbcd071 100644 --- a/scripts/build-rein-runtime.py +++ b/scripts/build-rein-runtime.py @@ -9,6 +9,7 @@ from __future__ import annotations import argparse import hashlib import json +import os import re import subprocess from pathlib import Path @@ -16,8 +17,8 @@ from pathlib import Path from sandboxer.extensions.runtime import RUNTIME_MOUNT, runtime_digest -def checked(command: list[str]) -> str: - result = subprocess.run(command, capture_output=True, text=True, timeout=300) +def checked(command: list[str], *, env: dict[str, str] | None = None) -> str: + result = subprocess.run(command, capture_output=True, text=True, timeout=300, env=env) if result.returncode: raise RuntimeError(f"runtime build command failed: {Path(command[0]).name}") return result.stdout.strip() @@ -44,7 +45,7 @@ def install_claude(output: Path, source: Path, sha256: str, version: str) -> dic def build(output: Path, rein_source: Path, llm_source: Path, claude_binary: Path | None = None, claude_sha256: str | None = None, - claude_version: str | None = None) -> dict: + claude_version: str | None = None, *, owner_runtime: bool = False) -> dict: supplied = (claude_binary, claude_sha256, claude_version) if any(x is not None for x in supplied) and not all(x is not None for x in supplied): raise ValueError("Claude binary, SHA-256 and expected version must be supplied together") @@ -53,10 +54,26 @@ def build(output: Path, rein_source: Path, llm_source: Path, if checked(["git", "-C", str(source), "status", "--porcelain"]): raise ValueError(f"{name} source must be committed before building") revisions[name] = checked(["git", "-C", str(source), "rev-parse", "HEAD"]) + lock_report = None + if owner_runtime: + lock_report = json.loads(checked([ + "/usr/bin/python3", str(rein_source / "scripts/verify_runtime_lock.py") + ])) + if not lock_report.get("ok"): + raise ValueError("owner runtime requires the verified sibling lock") + if next(x["commit"] for x in lock_report["dependencies"] + if x["distribution"] == "llm-connect") != revisions["llm-connect"]: + raise ValueError("owner runtime llm source does not match sibling lock") + revisions.update({x["distribution"]: x["commit"] for x in lock_report["dependencies"]}) output.mkdir(parents=True, exist_ok=False) checked(["/usr/bin/python3", "-m", "venv", "--copies", "--without-pip", str(output)]) - checked(["uv", "pip", "install", "--python", str(output / "bin/python3"), - str(rein_source), str(llm_source)]) + if owner_runtime: + checked(["uv", "sync", "--project", str(rein_source), "--frozen", "--no-editable", + "--no-dev", "--extra", "glas", "--extra", "llm", "--python", "/usr/bin/python3"], + env={**os.environ, "UV_PROJECT_ENVIRONMENT": str(output)}) + else: + checked(["uv", "pip", "install", "--python", str(output / "bin/python3"), + str(rein_source), str(llm_source)]) # Console entrypoints must reference the in-sandbox mount, not the build host. for path in (output / "bin").iterdir(): if not path.is_file() or path.is_symlink(): @@ -75,6 +92,12 @@ def build(output: Path, rein_source: Path, llm_source: Path, if claude_binary is not None: metadata["claude"] = install_claude(output, claude_binary, claude_sha256, claude_version) metadata["source_revisions"] = revisions + if owner_runtime: + metadata["owner_runtime"] = True + metadata["runtime_contract"] = lock_report + metadata["uv_lock_sha256"] = hashlib.sha256( + (rein_source / "uv.lock").read_bytes() + ).hexdigest() (output / "build-info.json").write_text(json.dumps(metadata, indent=2) + "\n") return {"runtime": {"path": str(output), "sha256": runtime_digest(output)}, "build": metadata} @@ -88,9 +111,12 @@ def main() -> int: parser.add_argument("--claude-binary", type=Path) parser.add_argument("--claude-sha256") parser.add_argument("--claude-version") + parser.add_argument("--owner-runtime", action="store_true", + help="Include the pinned owner packages from rein uv.lock") args = parser.parse_args() result = build(args.output.resolve(), args.rein_source.resolve(), args.llm_source.resolve(), - args.claude_binary, args.claude_sha256, args.claude_version) + args.claude_binary, args.claude_sha256, args.claude_version, + owner_runtime=args.owner_runtime) print(json.dumps(result, indent=2)) return 0 diff --git a/src/sandboxer/extensions/messages_route.py b/src/sandboxer/extensions/messages_route.py index c26bb72..fcaf9e1 100644 --- a/src/sandboxer/extensions/messages_route.py +++ b/src/sandboxer/extensions/messages_route.py @@ -19,6 +19,9 @@ class OwnerMessagesRoute: run_id: str private_paths: tuple[Path, ...] = field(default=(), repr=False) + runtime_path: Path | None = None + runtime_sha256: str | None = None + def validate(self, profile, consumer, backend, inputs) -> None: from sandboxer.extensions.bwrap import BwrapExtension @@ -32,6 +35,15 @@ class OwnerMessagesRoute: or not self.run_id ): raise ValueError("metered route consumer mismatch") + if self.runtime_path is not None or self.runtime_sha256 is not None: + if (self.runtime_path is None or not self.runtime_path.is_absolute() + or not isinstance(self.runtime_sha256, str) + or not re.fullmatch(r"[0-9a-f]{64}", self.runtime_sha256)): + raise ValueError("metered runtime requires exact path and digest") + # Trusted, in-memory bootstrap selection; never copied into public status. + backend.config = {**backend.config, "runtime": { + "path": str(self.runtime_path), "sha256": self.runtime_sha256, + }} if profile.setup.secret_refs: raise ValueError("metered route refuses setup credential acquisition") if profile.network.default != "deny" or profile.network.egress: diff --git a/src/sandboxer/extensions/registry.py b/src/sandboxer/extensions/registry.py index 6aecde6..a6c0491 100644 --- a/src/sandboxer/extensions/registry.py +++ b/src/sandboxer/extensions/registry.py @@ -11,7 +11,8 @@ import yaml from sandboxer.models import Extension, Profile _REPO_ROOT = Path(__file__).resolve().parents[3] -_EXTENSIONS_DIR = _REPO_ROOT / "extensions" +_PACKAGED_DIR = Path(__file__).resolve().parents[1] / "data" / "extensions" +_EXTENSIONS_DIR = _PACKAGED_DIR if _PACKAGED_DIR.is_dir() else _REPO_ROOT / "extensions" _REQUIRED_CAPABILITY_FIELDS = ("isolation_levels", "pricing_model") diff --git a/src/sandboxer/profiles/loader.py b/src/sandboxer/profiles/loader.py index 4da8035..8421c2b 100644 --- a/src/sandboxer/profiles/loader.py +++ b/src/sandboxer/profiles/loader.py @@ -9,7 +9,8 @@ import yaml from sandboxer.models import Profile _REPO_ROOT = Path(__file__).resolve().parents[3] -_PROFILES_DIR = _REPO_ROOT / "profiles" +_PACKAGED_DIR = Path(__file__).resolve().parents[1] / "data" / "profiles" +_PROFILES_DIR = _PACKAGED_DIR if _PACKAGED_DIR.is_dir() else _REPO_ROOT / "profiles" def profiles_dir() -> Path: diff --git a/tests/test_messages_route.py b/tests/test_messages_route.py index efeb25c..c2a0498 100644 --- a/tests/test_messages_route.py +++ b/tests/test_messages_route.py @@ -62,3 +62,19 @@ def test_route_refuses_expansion(binding, tmp_path, change): backend = SimpleNamespace() with pytest.raises(ValueError): binding.validate(profile, consumer, backend, {}) + + +def test_trusted_runtime_pin_is_injected_and_overlap_refused(binding, tmp_path): + runtime = tmp_path / "runtime" + binding = replace(binding, runtime_path=runtime, runtime_sha256="b" * 64) + backend = BwrapExtension({"base_dir": str(tmp_path / "workspaces")}) + profile = load_profile("profile.bwrap-local") + consumer = Consumer(actor="agt", project="fixture", run_id="run-1") + binding.validate(profile, consumer, backend, {}) + assert backend.config["runtime"] == {"path": str(runtime), "sha256": "b" * 64} + with pytest.raises(ValueError, match="overlaps"): + replace(binding, runtime_path=binding.socket_path.parent).validate( + profile, consumer, backend, {} + ) + with pytest.raises(ValueError, match="exact path"): + replace(binding, runtime_sha256=None).validate(profile, consumer, backend, {}) diff --git a/tests/test_runtime_builder.py b/tests/test_runtime_builder.py index db61cf1..15717d0 100644 --- a/tests/test_runtime_builder.py +++ b/tests/test_runtime_builder.py @@ -50,3 +50,22 @@ def test_incomplete_claude_pin_refuses_before_build(tmp_path): with pytest.raises(ValueError, match="supplied together"): builder.build(tmp_path / "out", tmp_path, tmp_path, claude_binary=tmp_path / "claude") assert not (tmp_path / "out").exists() + + +def test_owner_build_requires_matching_sibling_lock_before_output(tmp_path, monkeypatch): + import json + rein = tmp_path / "rein" + llm = tmp_path / "llm" + output = tmp_path / "output" + def checked(argv, **kwargs): + if "--porcelain" in argv: + return "" + if "rev-parse" in argv: + return "a" * 40 + return json.dumps({"ok": True, "dependencies": [ + {"distribution": "llm-connect", "commit": "b" * 40} + ]}) + monkeypatch.setattr(builder, "checked", checked) + with pytest.raises(ValueError, match="does not match"): + builder.build(output, rein, llm, owner_runtime=True) + assert not output.exists() diff --git a/workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md b/workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md index 366a364..1751c5a 100644 --- a/workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md +++ b/workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md @@ -260,3 +260,19 @@ admitted provider-to-owner bootstrap, updated protected runtime/profile, Railian placement, live compatibility and G0. Existing child-provider-key/direct-CONNECT proofs do not admit this different credential holder or metered profile. No CCR, secret read, deployment or paid request was performed. + +## 2026-09-09 standalone owner packaging and runtime selection + +Added frozen-lock owner build mode for the matched rein/llm/Glas/sandboxer set, +with non-editable installation and recorded lock/source/package pins. Sand-boxer +wheels now carry their profile and extension definitions, fixing a bwrap owner +failure that source-checkout tests could hide. The trusted Messages binding can +select the digest-pinned runtime without adding an API or profile override. +`make check`: lint clean, 201 passed. Existing standalone workload builds remain +supported. See docs/bwrap-runtime.md and rein's docs/owner-bootstrap.md. + +The project's `evidence/2026-09-09-owner-bootstrap.json` records the actual candidate +build and isolated installed-interpreter/CLI proof. T04 remains wait for accepted +credential-to-owner delivery, current protected installation and Railiance placement, +provider compatibility and G0. Source packaging does not reopen completed T01-T03, +T05-T06, broaden the old CCRs or activate the previously installed 2.1.263 artifact.