Package sandbox definitions and build a pinned owner runtime
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
bfe0e4c4c8
commit
be42de7caf
9 changed files with 127 additions and 8 deletions
|
|
@ -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).
|
[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
|
SAND-WP-0015-T06 is complete. T04 still owns configured execution, native
|
||||||
credential adoption, real-model acceptance and production placement.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,10 @@ sandboxer = "sandboxer.cli:app"
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src/sandboxer"]
|
packages = ["src/sandboxer"]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel.force-include]
|
||||||
|
"profiles" = "sandboxer/data/profiles"
|
||||||
|
"extensions" = "sandboxer/data/extensions"
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
pythonpath = ["src"]
|
pythonpath = ["src"]
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from __future__ import annotations
|
||||||
import argparse
|
import argparse
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -16,8 +17,8 @@ from pathlib import Path
|
||||||
from sandboxer.extensions.runtime import RUNTIME_MOUNT, runtime_digest
|
from sandboxer.extensions.runtime import RUNTIME_MOUNT, runtime_digest
|
||||||
|
|
||||||
|
|
||||||
def checked(command: list[str]) -> str:
|
def checked(command: list[str], *, env: dict[str, str] | None = None) -> str:
|
||||||
result = subprocess.run(command, capture_output=True, text=True, timeout=300)
|
result = subprocess.run(command, capture_output=True, text=True, timeout=300, env=env)
|
||||||
if result.returncode:
|
if result.returncode:
|
||||||
raise RuntimeError(f"runtime build command failed: {Path(command[0]).name}")
|
raise RuntimeError(f"runtime build command failed: {Path(command[0]).name}")
|
||||||
return result.stdout.strip()
|
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,
|
def build(output: Path, rein_source: Path, llm_source: Path,
|
||||||
claude_binary: Path | None = None, claude_sha256: str | None = None,
|
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)
|
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):
|
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")
|
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"]):
|
if checked(["git", "-C", str(source), "status", "--porcelain"]):
|
||||||
raise ValueError(f"{name} source must be committed before building")
|
raise ValueError(f"{name} source must be committed before building")
|
||||||
revisions[name] = checked(["git", "-C", str(source), "rev-parse", "HEAD"])
|
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)
|
output.mkdir(parents=True, exist_ok=False)
|
||||||
checked(["/usr/bin/python3", "-m", "venv", "--copies", "--without-pip", str(output)])
|
checked(["/usr/bin/python3", "-m", "venv", "--copies", "--without-pip", str(output)])
|
||||||
checked(["uv", "pip", "install", "--python", str(output / "bin/python3"),
|
if owner_runtime:
|
||||||
str(rein_source), str(llm_source)])
|
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.
|
# Console entrypoints must reference the in-sandbox mount, not the build host.
|
||||||
for path in (output / "bin").iterdir():
|
for path in (output / "bin").iterdir():
|
||||||
if not path.is_file() or path.is_symlink():
|
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:
|
if claude_binary is not None:
|
||||||
metadata["claude"] = install_claude(output, claude_binary, claude_sha256, claude_version)
|
metadata["claude"] = install_claude(output, claude_binary, claude_sha256, claude_version)
|
||||||
metadata["source_revisions"] = revisions
|
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")
|
(output / "build-info.json").write_text(json.dumps(metadata, indent=2) + "\n")
|
||||||
return {"runtime": {"path": str(output), "sha256": runtime_digest(output)},
|
return {"runtime": {"path": str(output), "sha256": runtime_digest(output)},
|
||||||
"build": metadata}
|
"build": metadata}
|
||||||
|
|
@ -88,9 +111,12 @@ def main() -> int:
|
||||||
parser.add_argument("--claude-binary", type=Path)
|
parser.add_argument("--claude-binary", type=Path)
|
||||||
parser.add_argument("--claude-sha256")
|
parser.add_argument("--claude-sha256")
|
||||||
parser.add_argument("--claude-version")
|
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()
|
args = parser.parse_args()
|
||||||
result = build(args.output.resolve(), args.rein_source.resolve(), args.llm_source.resolve(),
|
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))
|
print(json.dumps(result, indent=2))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ class OwnerMessagesRoute:
|
||||||
run_id: str
|
run_id: str
|
||||||
private_paths: tuple[Path, ...] = field(default=(), repr=False)
|
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:
|
def validate(self, profile, consumer, backend, inputs) -> None:
|
||||||
from sandboxer.extensions.bwrap import BwrapExtension
|
from sandboxer.extensions.bwrap import BwrapExtension
|
||||||
|
|
||||||
|
|
@ -32,6 +35,15 @@ class OwnerMessagesRoute:
|
||||||
or not self.run_id
|
or not self.run_id
|
||||||
):
|
):
|
||||||
raise ValueError("metered route consumer mismatch")
|
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:
|
if profile.setup.secret_refs:
|
||||||
raise ValueError("metered route refuses setup credential acquisition")
|
raise ValueError("metered route refuses setup credential acquisition")
|
||||||
if profile.network.default != "deny" or profile.network.egress:
|
if profile.network.default != "deny" or profile.network.egress:
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ import yaml
|
||||||
from sandboxer.models import Extension, Profile
|
from sandboxer.models import Extension, Profile
|
||||||
|
|
||||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
_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")
|
_REQUIRED_CAPABILITY_FIELDS = ("isolation_levels", "pricing_model")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ import yaml
|
||||||
from sandboxer.models import Profile
|
from sandboxer.models import Profile
|
||||||
|
|
||||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
_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:
|
def profiles_dir() -> Path:
|
||||||
|
|
|
||||||
|
|
@ -62,3 +62,19 @@ def test_route_refuses_expansion(binding, tmp_path, change):
|
||||||
backend = SimpleNamespace()
|
backend = SimpleNamespace()
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
binding.validate(profile, consumer, backend, {})
|
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, {})
|
||||||
|
|
|
||||||
|
|
@ -50,3 +50,22 @@ def test_incomplete_claude_pin_refuses_before_build(tmp_path):
|
||||||
with pytest.raises(ValueError, match="supplied together"):
|
with pytest.raises(ValueError, match="supplied together"):
|
||||||
builder.build(tmp_path / "out", tmp_path, tmp_path, claude_binary=tmp_path / "claude")
|
builder.build(tmp_path / "out", tmp_path, tmp_path, claude_binary=tmp_path / "claude")
|
||||||
assert not (tmp_path / "out").exists()
|
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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
placement, live compatibility and G0. Existing child-provider-key/direct-CONNECT
|
||||||
proofs do not admit this different credential holder or metered profile. No CCR,
|
proofs do not admit this different credential holder or metered profile. No CCR,
|
||||||
secret read, deployment or paid request was performed.
|
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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue