Add free bwrap rehearsal to the owner check (REINAH-WP-0008-T01)
Some checks failed
Governed runtime contract / contract (push) Failing after 10s

metered-once --rehearse runs the admitted runtime's launchers (and claude
--version when the build declares it) inside bwrap: runtime read-only at
the mount path, no network, cleared environment, no credentials, no claim.
Fails on the deleted-build-interpreter trampoline that failed run 6efa9436;
the corrected runtime ee3d0a53 passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 269692@bnt-lap001
Assistant-Session: 8077e68a-0985-4b41-898c-2dc602b0d6be
This commit is contained in:
tegwick 2026-10-13 23:13:49 +02:00
parent 2210a31d7f
commit caaea1e90e
7 changed files with 249 additions and 12 deletions

View file

@ -11,6 +11,18 @@ There is no key fetch, alternate provider, key file, OAuth/HOME or daemon fallba
`--check` validates local pins without a key, queue access or workload dispatch;
it can advance the ledger's clock watermark but never initializes/reset its schema.
**Rehearse before every paid attempt.** `--rehearse` (implies `--check`) also
runs the admitted runtime's `rein-aharness` and `glas-harness` launchers, and
`claude --version` when the build declares Claude, inside bwrap. The runtime is
read-only at the sandbox mount path; there is no network, a cleared
environment, no credentials, no provider route and no queue access. It writes
nothing beyond what `--check` writes. The receipt lists each exit code and
returns nonzero if any is not 0. The launcher check in `--check` reads only the
first line of each launcher; the rehearsal executes them. Run 6efa9436
(2026-09-27) failed with exit 127 on exactly such a launcher.
python -I -B -m rein_aharness.cli metered-once --owner-config /abs/owner.json --rehearse
Use the standalone candidate interpreter as `python -I -B -m rein_aharness.cli`.
`-I` excludes editable PYTHONPATH/user-site fallbacks; `-B` prevents bytecode writes
from changing the complete artifact digest. The source and namespace rein commands

View file

@ -559,6 +559,8 @@ def main(argv: list[str] | None = None) -> int:
)
metered.add_argument("--owner-config", required=True, type=Path)
metered.add_argument("--check", action="store_true", help="Validate local pins without claiming")
metered.add_argument("--rehearse", action="store_true",
help="With --check: also run the runtime's launchers in bwrap, offline")
metered.add_argument("--no-hub", action="store_true")
claim_loop = sub.add_parser(
@ -631,7 +633,8 @@ def main(argv: list[str] | None = None) -> int:
if args.command == "metered-once":
from rein_aharness.owner_bootstrap import run_once
return run_once(args.owner_config, check_only=args.check, report_to_hub=not args.no_hub)
return run_once(args.owner_config, check_only=args.check, report_to_hub=not args.no_hub,
rehearse=args.rehearse)
if args.command == "claim-loop":
return _cmd_claim_loop(args)

View file

@ -99,10 +99,12 @@ def prepare(path: Path, config: OpsRunConfig) -> tuple[MessagesPolicy, Path, str
raise BootstrapRefused("owner bootstrap configuration refused") from None
def run_once(path: Path, *, check_only: bool = False, report_to_hub: bool = True) -> int:
def run_once(path: Path, *, check_only: bool = False, report_to_hub: bool = True,
rehearse: bool = False) -> int:
# Only this explicit exec-env child entry point consumes the conventional key.
# Remove it before any subprocess or queue client can inherit it. No OAuth,
# operator HOME, alternate provider URL or credential-file fallback is used.
check_only = check_only or rehearse
provider_key = None if check_only else os.environ.get("ANTHROPIC_API_KEY")
alternate_auth = None if check_only else os.environ.get("ANTHROPIC_AUTH_TOKEN")
for name in ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"):
@ -114,9 +116,16 @@ def run_once(path: Path, *, check_only: bool = False, report_to_hub: bool = True
config = OpsRunConfig.from_env()
policy, runtime_path, runtime_sha256 = prepare(path, config)
if check_only:
print(json.dumps({"ok": True, "check_only": True, "dispatch_enabled": False,
"policy_sha256": policy.sha256, "runtime_sha256": runtime_sha256}))
return 0
ok = True
receipt = {"check_only": True, "dispatch_enabled": False,
"policy_sha256": policy.sha256, "runtime_sha256": runtime_sha256}
if rehearse:
from rein_aharness.owner_rehearsal import rehearse as rehearse_runtime
steps = rehearse_runtime(runtime_path)
ok = all(step.ok for step in steps)
receipt["rehearsal"] = {step.name: step.exit_code for step in steps}
print(json.dumps({"ok": ok, **receipt}))
return 0 if ok else 1
if (alternate_auth or not isinstance(provider_key, str) or not provider_key
or len(provider_key) > 8192
or any(ord(c) < 33 or ord(c) > 126 for c in provider_key)):

View file

@ -0,0 +1,91 @@
"""Free host rehearsal of an admitted owner runtime (REINAH-WP-0008-T01).
The owner check reads only the first line of each launcher. The 2026-09-27
run 6efa9436 passed that and still failed with exit 127, because a launcher
re-executed a deleted build-host interpreter. This rehearsal runs the
launchers the way the sandbox does: inside bwrap, with the runtime read-only
at the mount path, no network, a cleared environment and nothing else
writable. It uses no credentials, provider route, queue or ledger.
"""
from __future__ import annotations
import json
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Sequence
RUNTIME_MOUNT = "/opt/sandboxer/runtime"
# Same read-only system binds as sand-boxer's bwrap extension.
SYSTEM_RO_BINDS = ("/usr", "/bin", "/lib", "/lib64", "/etc/resolv.conf", "/etc/ssl/certs")
PYTHON_LAUNCHERS = ("rein-aharness", "glas-harness")
TIMEOUT_SECONDS = 60
Runner = Callable[[Sequence[str], int], int]
@dataclass(frozen=True)
class Step:
name: str
exit_code: int
@property
def ok(self) -> bool:
return self.exit_code == 0
def bwrap_argv(runtime: Path, command: Sequence[str], *, bwrap_bin: str = "bwrap") -> list[str]:
argv = [
bwrap_bin,
"--unshare-user", "--unshare-pid", "--unshare-ipc",
"--unshare-uts", "--unshare-cgroup", "--unshare-net",
"--tmpfs", "/", "--proc", "/proc", "--dev", "/dev", "--clearenv",
]
for path in SYSTEM_RO_BINDS:
if Path(path).exists():
argv += ["--ro-bind", path, path]
argv += [
"--dir", "/opt", "--dir", "/opt/sandboxer",
"--ro-bind", str(runtime), RUNTIME_MOUNT,
"--dir", "/tmp",
"--setenv", "HOME", "/tmp",
"--setenv", "PATH", f"{RUNTIME_MOUNT}/bin:/usr/bin:/bin",
"--chdir", "/tmp",
]
return argv + list(command)
def _run(argv: Sequence[str], timeout: int) -> int:
try:
return subprocess.run(
list(argv), stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, timeout=timeout,
).returncode
except subprocess.TimeoutExpired:
return 124
except OSError:
return 127
def _expects_claude(runtime: Path) -> bool:
try:
info = json.loads((runtime / "build-info.json").read_text())
except (OSError, ValueError):
return False
return isinstance(info, dict) and isinstance(info.get("claude"), dict)
def rehearse(runtime: Path, *, run: Runner = _run, bwrap_bin: str | None = None) -> list[Step]:
"""Run each launcher once inside bwrap; return one step per launcher."""
bwrap = bwrap_bin or shutil.which("bwrap")
if bwrap is None:
return [Step("bwrap", 127)]
commands = [(name, [f"{RUNTIME_MOUNT}/bin/{name}", "--help"]) for name in PYTHON_LAUNCHERS]
if _expects_claude(runtime):
commands.append(("claude", [f"{RUNTIME_MOUNT}/bin/claude", "--version"]))
return [
Step(name, run(bwrap_argv(runtime, command, bwrap_bin=bwrap), TIMEOUT_SECONDS))
for name, command in commands
]

View file

@ -149,3 +149,26 @@ def test_unexpected_exception_is_bounded(prepared, monkeypatch, capsys):
monkeypatch.setattr("rein_aharness.claim_loop.process_one", MagicMock(side_effect=RuntimeError("fixture-key")))
assert run_once(path) == 1
assert json.loads(capsys.readouterr().out) == {"ok": False, "code": "owner_cycle_failed"}
@pytest.mark.parametrize("exit_codes,code", [((0, 0), 0), ((0, 127), 1)])
def test_rehearsal_reports_launcher_exits_and_never_claims_or_reads_key(
prepared, monkeypatch, capsys, exit_codes, code):
from rein_aharness.owner_rehearsal import Step
path, _, _, runtime = prepared
monkeypatch.setenv("ANTHROPIC_API_KEY", "fixture-owner-key")
seen = []
def fake(runtime_path):
seen.append(runtime_path)
return [Step(n, c) for n, c in zip(("rein-aharness", "glas-harness"), exit_codes)]
monkeypatch.setattr("rein_aharness.owner_rehearsal.rehearse", fake)
claimant = MagicMock()
monkeypatch.setattr("rein_aharness.claim_loop.process_one", claimant)
assert run_once(path, rehearse=True) == code
claimant.assert_not_called()
assert seen == [runtime]
out = json.loads(capsys.readouterr().out)
assert out["ok"] is (code == 0) and out["dispatch_enabled"] is False
assert out["rehearsal"] == {"rein-aharness": exit_codes[0], "glas-harness": exit_codes[1]}
assert "fixture-owner-key" not in json.dumps(out)
assert "ANTHROPIC_API_KEY" not in os.environ

View file

@ -0,0 +1,78 @@
"""Real bwrap rehearsal of owner runtimes (REINAH-WP-0008-T01)."""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
import pytest
from rein_aharness.owner_rehearsal import RUNTIME_MOUNT, bwrap_argv, rehearse
def _bwrap_usable() -> bool:
bwrap = shutil.which("bwrap")
if bwrap is None:
return False
probe = subprocess.run(bwrap_argv(Path("/usr"), ["/usr/bin/true"], bwrap_bin=bwrap),
capture_output=True, timeout=30)
return probe.returncode == 0
real_bwrap = pytest.mark.skipif(not _bwrap_usable(), reason="bwrap user namespaces unavailable")
GOOD = f"#!{RUNTIME_MOUNT}/bin/python3\nimport sys\nsys.exit(0)\n"
# uv's long-path trampoline that failed run 6efa9436: a shell script that
# re-executes a build-host interpreter which no longer exists.
BROKEN = "#!/bin/sh\n'''exec' '/old-build/bin/python3' \"$0\" \"$@\"\n' '''\nimport sys\n"
def _runtime(tmp_path: Path, glas: str = GOOD) -> Path:
runtime = tmp_path / "runtime"
(runtime / "bin").mkdir(parents=True)
(runtime / "bin/python3").symlink_to("/usr/bin/python3")
for name, body in (("rein-aharness", GOOD), ("glas-harness", glas)):
launcher = runtime / "bin" / name
launcher.write_text(body)
launcher.chmod(0o755)
return runtime
def test_argv_is_offline_read_only_and_credential_free(tmp_path):
argv = bwrap_argv(tmp_path, [f"{RUNTIME_MOUNT}/bin/rein-aharness", "--help"])
assert "--unshare-net" in argv and "--clearenv" in argv
assert "--bind" not in argv
i = argv.index(str(tmp_path))
assert argv[i - 1] == "--ro-bind" and argv[i + 1] == RUNTIME_MOUNT
assert not any("ANTHROPIC" in part or "/run/sandboxer" in part for part in argv)
def test_missing_bwrap_fails_closed(tmp_path, monkeypatch):
monkeypatch.setattr("rein_aharness.owner_rehearsal.shutil.which", lambda name: None)
steps = rehearse(_runtime(tmp_path), run=lambda argv, timeout: 0)
assert [(s.name, s.ok) for s in steps] == [("bwrap", False)]
def test_claude_is_rehearsed_only_when_the_build_declares_it(tmp_path):
runtime = _runtime(tmp_path)
calls = []
run = lambda argv, timeout: calls.append(argv[-2:]) or 0
assert [s.name for s in rehearse(runtime, run=run, bwrap_bin="bwrap")] == [
"rein-aharness", "glas-harness"]
(runtime / "build-info.json").write_text(json.dumps({"claude": {"expected_version": "x"}}))
assert [s.name for s in rehearse(runtime, run=run, bwrap_bin="bwrap")][-1] == "claude"
assert calls[-1] == [f"{RUNTIME_MOUNT}/bin/claude", "--version"]
@real_bwrap
def test_relocated_runtime_passes(tmp_path):
steps = rehearse(_runtime(tmp_path))
assert [(s.name, s.exit_code) for s in steps] == [("rein-aharness", 0), ("glas-harness", 0)]
@real_bwrap
def test_launcher_with_deleted_build_interpreter_fails_like_run_6efa9436(tmp_path):
steps = {s.name: s.exit_code for s in rehearse(_runtime(tmp_path, glas=BROKEN))}
assert steps == {"rein-aharness": 0, "glas-harness": 127}

View file

@ -46,17 +46,17 @@ once, run.
```task
id: REINAH-WP-0008-T01
status: todo
status: done
priority: high
state_hub_task_id: "c6536c8d-7862-5de6-ad2b-401077b60c04"
```
Add `rein-aharness owner check --rehearse <owner.json>`. It runs everything
`metered-once --check` does. It then executes the admitted runtime's
`rein-aharness` and `glas-harness` launchers inside bwrap, from the mount
path, with `--unshare-all`, no credentials, no provider route and no ledger
write. It also checks that the sandbox profile resolves. It prints a single
pass or fail line with the runtime digest.
Add a free rehearsal to the owner check. It runs everything
`metered-once --check` does, then executes the admitted runtime's
`rein-aharness` and `glas-harness` launchers inside bwrap from the mount
path, with no network, credentials, provider route or ledger write. The
sandbox profile is already resolved by `--check`. It prints one receipt with
the runtime digest and each launcher's exit code.
Today's launcher check reads only the first line of each launcher. This
rehearsal runs them, the same way the failing run did.
@ -67,6 +67,27 @@ relocated runtime. It must make no network calls and write nothing. The
owner procedure in `docs/owner-bootstrap.md` makes the rehearsal a required
step before any paid attempt.
### Implemented — 2026-10-13
`rein-aharness metered-once --owner-config <owner.json> --rehearse` (the flag
implies `--check`, so it never reads a provider key or claims). The code is in
`rein_aharness/owner_rehearsal.py`. It uses sand-boxer's isolation flags and
read-only system binds, with the runtime read-only at `/opt/sandboxer/runtime`,
`--unshare-net` and `--clearenv`. It runs each Python launcher with `--help`,
plus `claude --version` when `build-info.json` declares Claude. A missing
bwrap fails closed.
Tests (`tests/test_owner_rehearsal.py`, `tests/test_owner_bootstrap.py`):
- With real bwrap, a relocated runtime passes. The uv trampoline that
re-executes a deleted build interpreter fails with 127, as run `6efa9436`
did.
- The rehearsal never claims and never reads the key.
- The command line has no writable bind and no credential.
The corrected runtime `ee3d0a53…` rehearses clean: rein-aharness 0,
glas-harness 0, claude 0.
## B. Release holds that provably cost nothing
```task