Add free bwrap rehearsal to the owner check (REINAH-WP-0008-T01)
Some checks failed
Governed runtime contract / contract (push) Failing after 10s
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:
parent
2210a31d7f
commit
caaea1e90e
7 changed files with 249 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)):
|
||||
|
|
|
|||
91
rein_aharness/owner_rehearsal.py
Normal file
91
rein_aharness/owner_rehearsal.py
Normal 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
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
78
tests/test_owner_rehearsal.py
Normal file
78
tests/test_owner_rehearsal.py
Normal 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}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue