diff --git a/deploy/runtime-contract-lock.json b/deploy/runtime-contract-lock.json index 5fa0526..b1a1218 100644 --- a/deploy/runtime-contract-lock.json +++ b/deploy/runtime-contract-lock.json @@ -19,7 +19,7 @@ "version": "0.1.0" }, { - "commit": "bfe0e4c4c86003892796ba0fb990ae21d72278e1", + "commit": "be42de7caf90e7d9fe6c8d7fea263603167ac8c5", "distribution": "sandboxer", "source": "../sand-boxer", "version": "0.0.0" diff --git a/docs/owner-bootstrap.md b/docs/owner-bootstrap.md new file mode 100644 index 0000000..89c9f8e --- /dev/null +++ b/docs/owner-bootstrap.md @@ -0,0 +1,73 @@ +# One-cycle owner bootstrap + +`rein-aharness metered-once --owner-config /absolute/private/owner.json` is the +explicit exec-env child entry point. It prepares the exact admitted pins, consumes +only the deliberately delivered `ANTHROPIC_API_KEY`, removes provider authentication +and base-URL variables before other child processes, and runs at most one claim +cycle. The provider key is held by MessagesOwner outside the sandbox. Missing/mixed +credentials, invalid pins, missing ledgers, blocked profiles, failed readiness and +unclaimed refusals return nonzero. Receipt output excludes raw errors and prompts. +There is no key fetch, alternate provider, key file, OAuth/HOME or daemon fallback. +`--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. + +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 +continue to use their existing entry points. The owner config selects the pinned +runtime through the trusted, ephemeral sandbox binding; it cannot add egress or +provider credentials to the child. Normal `claim-loop` behavior is unchanged. + +The mode-0600, regular, owner-owned config has exactly these fields: + +```json +{ + "version": "1", + "authority_ref": "REPLACE_WITH_ACCEPTED_SPEND_AUTHORITY", + "spend_policy_sha256": "REPLACE_WITH_ACCEPTED_SPEND_POLICY_DIGEST", + "messages_policy": { + "tariff_ref": "REPLACE_WITH_ACCEPTED_PROVIDER_BOUNDS_AND_RATES", + "model": "claude-sonnet-4-6", + "context_tokens": 0, + "max_output_tokens": 0, + "input_microusd_per_token": 0, + "output_microusd_per_token": 0, + "allowed_betas": [] + }, + "runtime": { + "path": "/absolute/accepted/runtime", + "sha256": "REPLACE_WITH_ACCEPTED_COMPLETE_ARTIFACT_DIGEST" + } +} +``` + +The placeholders and zero bounds deliberately refuse. The authority reference and +spend digest must match the private existing SpendPolicy. Worker/project, exact +profile/descriptor digests, operational readiness, model, empty-egress bwrap profile +and runtime digest are checked before claim. Runtime, owner state and target checkout +must not overlap. Provision parent and request ledgers as separate reviewed actions. +The normal ACTIVITY_CORE/AGENT_HARNESS worker and state configuration still applies. + +This config is not an authorization decision or a custody provenance proof. The +invoking credential engine must already have passed its exact action approval, +consume, scoped backend/readiness and admitted consumer checks. Its reviewed command +must be the fixed one-cycle owner entry point, with the accepted immutable config. +The current `glas-claude-agent-dev-anthropic` catalog describes delivery through the +sandbox helper and does not yet admit this owner holder. SECRETS-WP-0009-T03 and +HFACT-WP-0001-T03/T04 retain that review, existing client/audit/service dependencies, +Railiance placement and live negative tests. No real key is read by local validation. + +This initial bootstrap obtains delivery before one claim cycle; an empty queue still +uses that delivery attempt. It intentionally exits after that cycle. Native scheduled +activation must not wrap a persistent claim loop with one reusable provider credential. +Later per-run acquisition for a continuous worker belongs to REINAH-WP-0003-T05/T06 +and the same credential owner; it must retain exact action/lease/budget semantics. + +`scripts/prove-metered-runtime.py` runs with a built candidate's `python -I -B`. +It verifies all four packages and definitions come from the artifact, exercises this +CLI once against an empty fake HTTP queue with a synthetic exec-env key, and runs the +pinned actual Claude CLI through the protected bwrap mount and metered owner. The +positive fake stream and pre-forward insufficient-capacity refusal are separate +cases. It checks unchanged artifact digest and teardown. Queue/provider/key/profile +are disposable fixtures, never evidence of live admission. The project records the +candidate result in `prj-helixforge-factory/evidence/2026-09-09-owner-bootstrap.json`. diff --git a/docs/owner-messages-route.md b/docs/owner-messages-route.md index c8bf6be..c0e4b94 100644 --- a/docs/owner-messages-route.md +++ b/docs/owner-messages-route.md @@ -4,7 +4,9 @@ Source API: `rein_aharness.messages_owner.MessagesOwner`. The trusted host boots constructs it with an accepted immutable `MessagesPolicy` and an explicitly supplied provider key, then sets `OpsRunConfig.messages_owner`. This is an in-process owner capability, not a queue field, serialized profile or remote sandbox API parameter. -The normal CLI does not acquire a key or construct an owner. Set +The normal claim-loop CLI does not acquire a key or construct an owner. The explicit +[metered-once bootstrap](owner-bootstrap.md) now supplies the one-cycle exec-env +child path; native delivery still needs admission. Set `AGENT_HARNESS_REQUIRE_REQUEST_ADMISSION=1` in a future admitted service so a missing bootstrap refuses before claiming work. Parent spend admission must also be configured; provision the RequestLedger schema explicitly before dispatch. No live bootstrap or diff --git a/rein_aharness/cli.py b/rein_aharness/cli.py index 7aaa05a..4af738d 100644 --- a/rein_aharness/cli.py +++ b/rein_aharness/cli.py @@ -554,6 +554,13 @@ def main(argv: list[str] | None = None) -> int: ) poll.add_argument("--no-hub", action="store_true", help="Skip hub on execute") + metered = sub.add_parser( + "metered-once", help="One admitted owner claim cycle with explicit exec-env delivery" + ) + 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("--no-hub", action="store_true") + claim_loop = sub.add_parser( "claim-loop", help="Continuously claim ops_runs, select approach, execute, complete/fail", @@ -622,6 +629,9 @@ def main(argv: list[str] | None = None) -> int: if args.command == "poll": return _cmd_poll(args) + 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) if args.command == "claim-loop": return _cmd_claim_loop(args) diff --git a/rein_aharness/messages_owner.py b/rein_aharness/messages_owner.py index 3e2d479..59fbeb9 100644 --- a/rein_aharness/messages_owner.py +++ b/rein_aharness/messages_owner.py @@ -40,6 +40,8 @@ class MessagesOwner: provider_key: str = field(repr=False) upstream_url: str = "https://api.anthropic.com" allow_test_http: bool = False + runtime_path: Path | None = None + runtime_sha256: str | None = None @contextmanager def activate(self, run, config, spend, profile, cancel: ExecutionCancel): @@ -100,6 +102,7 @@ class MessagesOwner: socket_path, token, profile.sandbox_profile, "agt", config.execution_project, run.id, private_paths=(spend.path.parent,), + runtime_path=self.runtime_path, runtime_sha256=self.runtime_sha256, ) server.start() timer.start() diff --git a/rein_aharness/owner_bootstrap.py b/rein_aharness/owner_bootstrap.py new file mode 100644 index 0000000..712da11 --- /dev/null +++ b/rein_aharness/owner_bootstrap.py @@ -0,0 +1,158 @@ +"""One-cycle entry point for an explicitly admitted exec-env owner child. + +Custody/approval belong to the invoking delivery engine. This module neither +fetches a key nor claims that a configuration file authorizes credential access. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import signal +import threading + +from llm_connect.messages_gate import MessagesPolicy +from rein_aharness.messages_owner import MessagesOwner +from rein_aharness.ops_run_client import ActivityCoreOpsClient, OpsRunConfig +from rein_aharness.spend_admission import SpendAdmissionError, _private_file, worker_spend + + +class BootstrapRefused(RuntimeError): + pass + + +def prepare(path: Path, config: OpsRunConfig) -> tuple[MessagesPolicy, Path, str]: + """Validate value-free, owner-controlled pins without a key or queue claim.""" + from glas_harness.profiles import ProfileCatalog + from sandboxer.extensions.runtime import verified_runtime + try: + _private_file(path) + if not path.is_absolute() or path.resolve() != path or path.stat().st_size > 65536: + raise ValueError + + def unique(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError + result[key] = value + return result + + data = json.loads(path.read_text(), object_pairs_hook=unique) + if set(data) != {"version", "authority_ref", "spend_policy_sha256", "messages_policy", "runtime"}: + raise ValueError + if data["version"] != "1": + raise ValueError + spend = worker_spend(config) + if spend is None or data["authority_ref"] != spend.policy.authority_ref: + raise ValueError + if data["spend_policy_sha256"] != spend.policy.sha256: + raise ValueError + if (config.worker_id != spend.policy.worker_id + or config.execution_project != spend.policy.project + or path.is_relative_to(Path(spend.policy.target_repo).resolve())): + raise ValueError + values = data["messages_policy"] + if not isinstance(values, dict): + raise ValueError + if "allowed_betas" in values: + if not isinstance(values["allowed_betas"], list): + raise ValueError + values["allowed_betas"] = tuple(values["allowed_betas"]) + policy = MessagesPolicy(**values) + runtime = verified_runtime({"runtime": data["runtime"]}) + if runtime is None or not runtime.is_absolute() or runtime.resolve() != runtime: + raise ValueError + for private in (path.parent, spend.path.parent, Path(spend.policy.target_repo)): + a, b = runtime.resolve(), private.resolve() + if a.is_relative_to(b) or b.is_relative_to(a): + raise ValueError + catalog = ProfileCatalog() + profile, descriptor = catalog.resolve(spend.policy.profile_ref) + catalog.require_operational(profile) + from rein_aharness.spend_admission import digest + if (digest(profile.model_dump(mode="json")) != spend.policy.profile_sha256 + or digest(descriptor.model_dump(mode="json")) != spend.policy.descriptor_sha256 + or profile.model.model != policy.model or profile.credential_route_refs): + raise ValueError + from sandboxer.profiles.loader import load_profile + sandbox = load_profile(profile.sandbox_profile) + if sandbox.extension != "ext.bwrap" or sandbox.network.default != "deny" or sandbox.network.egress or sandbox.setup.secret_refs: + raise ValueError + spend.preflight() + # Provisioning is a separate owner action; never silently initialize/reset. + with spend._db() as db: + db.execute("SELECT run_id, token_sha256, revoked FROM request_routes LIMIT 0") + db.execute("SELECT receipt, state FROM request_reservations LIMIT 0") + return policy, runtime, data["runtime"]["sha256"] + except Exception: + raise BootstrapRefused("owner bootstrap configuration refused") from None + + +def run_once(path: Path, *, check_only: bool = False, report_to_hub: bool = True) -> 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. + 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"): + os.environ.pop(name, None) + stop = threading.Event() + previous = {} + config = None + try: + 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 + 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)): + raise BootstrapRefused("explicit exec-env provider delivery required") + owner = MessagesOwner(policy, provider_key, runtime_path=runtime_path, + runtime_sha256=runtime_sha256) + + class OneCycleOwner: + def activate(self, run, config, spend, profile, cancel): + if stop.is_set(): + cancel.cancel("signal") + return owner.activate(run, config, spend, profile, cancel) + + config.messages_owner = OneCycleOwner() + config.require_request_admission = True + config.require_spend_admission = True + client = ActivityCoreOpsClient(config) + from rein_aharness.claim_loop import _cancel_active_run, process_one + from rein_aharness.readiness import run_readiness_checks + + def cancelled(signum, frame): + stop.set() + _cancel_active_run("signal") + + for signum in (signal.SIGINT, signal.SIGTERM): + previous[signum] = signal.signal(signum, cancelled) + if not run_readiness_checks(client).ok: + raise BootstrapRefused("owner readiness refused") + if stop.is_set(): + raise BootstrapRefused("owner stopped before claim") + result = process_one(client, report_to_hub=report_to_hub) + ok = not stop.is_set() and (result.empty or (result.claimed and result.ok is True)) + print(json.dumps({"ok": ok, "claimed": result.claimed, "empty": result.empty, + "run_id": (result.run_id or "")[:200], "ops_state": result.ops_state})) + return 0 if ok else 1 + except (BootstrapRefused, SpendAdmissionError): + print(json.dumps({"ok": False, "code": "owner_bootstrap_refused"})) + return 2 + except Exception: + # Provider/queue exceptions and config excerpts cannot enter logs/receipts. + print(json.dumps({"ok": False, "code": "owner_cycle_failed"})) + return 1 + finally: + for signum, handler in previous.items(): + signal.signal(signum, handler) + if config is not None: + config.messages_owner = None + provider_key = None diff --git a/scripts/prove-metered-runtime.py b/scripts/prove-metered-runtime.py new file mode 100644 index 0000000..05dea5b --- /dev/null +++ b/scripts/prove-metered-runtime.py @@ -0,0 +1,252 @@ +"""Installed owner + CLI + bwrap proof with a fake provider and an empty fake queue. + +Run using the candidate's python -I -B. Never acquires a real credential. +""" +from __future__ import annotations + +import argparse +from datetime import UTC, datetime, timedelta +import hashlib +import http.server +import importlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import threading + +import yaml +from glas_harness.contract import ExecutionLimits, OperationalReadiness +from glas_harness.profiles import ProfileCatalog +from llm_connect.messages_gate import MessagesPolicy +from llm_connect.models import RunConfig +from rein_aharness.adapter import AgenticClaudeCodeAdapter +from rein_aharness.execution_cancel import ExecutionCancel +from rein_aharness.messages_owner import MessagesOwner +from rein_aharness.ops_run_client import OpsRun, OpsRunConfig +from rein_aharness.repository_grant import RepositoryGrant +from rein_aharness.request_admission import RequestLedger +from rein_aharness.spend_admission import SpendLedger, SpendPolicy, digest +from sandboxer.extensions.runtime import runtime_digest +from sandboxer.models import Consumer, SandboxCreateRequest, SandboxExecRequest +from sandboxer.profiles.loader import profiles_dir +from sandboxer.extensions.registry import extensions_dir + +BETAS = ( + "claude-code-20250219", "interleaved-thinking-2025-05-14", + "thinking-token-count-2026-05-13", "context-management-2025-06-27", + "prompt-caching-scope-2026-01-05", "effort-2025-11-24", +) +DUMMY_KEY = "synthetic-provider-key-no-live-authority" + + +def response_bytes(): + events = [ + {"type": "message_start", "message": {"id": "msg_fixture", "type": "message", + "role": "assistant", "model": "claude-sonnet-4-6", "content": [], + "stop_reason": None, "stop_sequence": None, + "usage": {"input_tokens": 100, "output_tokens": 0, + "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}}, + {"type": "content_block_start", "index": 0, + "content_block": {"type": "text", "text": ""}}, + {"type": "content_block_delta", "index": 0, + "delta": {"type": "text_delta", "text": "fixture complete"}}, + {"type": "content_block_stop", "index": 0}, + {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"input_tokens": 100, "output_tokens": 10, + "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}, + {"type": "message_stop"}, + ] + return "".join("event: " + x["type"] + "\ndata: " + json.dumps(x) + "\n\n" for x in events).encode() + + +class FixtureServer(http.server.ThreadingHTTPServer): + provider_calls = 0 + queue_claims = 0 + provider_key_correct = False + + +class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def reply(self, body, content_type="application/json"): + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if self.path.startswith("/ops-runs"): + self.reply(b'{"items":[]}') + else: + self.send_error(404) + + def do_POST(self): + self.rfile.read(int(self.headers.get("Content-Length", "0"))) + if self.path == "/ops-runs/claim": + self.server.queue_claims += 1 + self.reply(b'{"items":[]}') + elif self.path == "/v1/messages": + self.server.provider_calls += 1 + self.server.provider_key_correct = self.headers.get("x-api-key") == DUMMY_KEY + self.reply(response_bytes(), "text/event-stream") + else: + self.send_error(404) + + +def case(root, runtime, checksum, server, cap, bootstrap=False): + root.mkdir() + private = root / "private"; private.mkdir(mode=0o700) + source = root / "source"; source.mkdir() + subprocess.run(["git", "init", "-q", str(source)], check=True) + (source / "README.md").write_text("Disposable fixture. Do not edit files.\n") + profiles = root / "profiles"; profiles.mkdir() + catalog = ProfileCatalog() + profile, descriptor = catalog.resolve("harness.agent-dev-local@1.0.0") + profile = profile.model_copy(update={ + "limits": ExecutionLimits(max_budget_usd=cap, max_turns=4), + "operational_readiness": OperationalReadiness(status="ready", reason="disposable fixture only", + owner="tests", evidence_ref="test:installed-owner"), + }) + (profiles / "fixture.yaml").write_text(yaml.safe_dump(profile.model_dump(mode="json"))) + grant = RepositoryGrant("1", ("result.txt",), 1, 1, False) + policy = SpendPolicy( + version="1", envelope_id="fixture", authority_ref="fixture:no-live-authority", + valid_from="2026-09-01T00:00:00Z", expires_at="2099-01-01T00:00:00Z", + timezone="Europe/Berlin", worker_id="fixture-worker", activity_definition_id="fixture-definition", + target_repo=str(source), project="fixture-factory", profile_ref=str(profile.ref), + profile_sha256=digest(profile.model_dump(mode="json")), + descriptor_sha256=digest(descriptor.model_dump(mode="json")), repository_grant_id=grant.grant_id, + max_budget_usd=str(cap), max_liability_usd="5" if cap > 0.01 else "0.01", max_turns=4, + eur_per_usd="1", per_run_eur="5", daily_eur="10", total_eur="15", + ) + parent = SpendLedger(private / "spend.sqlite3", policy); parent.initialize() + meter = RequestLedger(parent); meter.initialize() + policy_path = private / "spend-policy.json" + policy_path.write_text(json.dumps(policy.__dict__)); policy_path.chmod(0o600) + messages = MessagesPolicy("fixture:not-live-prices", profile.model.model, 200000, 32000, + 3, 15, allowed_betas=BETAS) + owner_path = private / "owner.json" + owner_path.write_text(json.dumps({"version": "1", "authority_ref": policy.authority_ref, + "spend_policy_sha256": policy.sha256, "messages_policy": messages.__dict__, + "runtime": {"path": str(runtime), "sha256": checksum}})); owner_path.chmod(0o600) + bootstrap_result = None + if bootstrap: + env = {"PATH": os.defpath, "LANG": "C.UTF-8", "PYTHONDONTWRITEBYTECODE": "1", + "ACTIVITY_CORE_URL": f"http://127.0.0.1:{server.server_port}", + "AGENT_HARNESS_WORKER_ID": policy.worker_id, + "AGENT_HARNESS_EXECUTION_PROJECT": policy.project, + "AGENT_HARNESS_SPEND_POLICY": str(policy_path), + "AGENT_HARNESS_SPEND_LEDGER": str(parent.path), + "GLAS_PROFILE_DIR": str(profiles), "XDG_DATA_HOME": str(root / "data"), + "ANTHROPIC_API_KEY": DUMMY_KEY} + child = subprocess.run([str(runtime / "bin/python3"), "-I", "-B", "-m", "rein_aharness.cli", + "metered-once", "--owner-config", str(owner_path), "--no-hub"], + env=env, capture_output=True, text=True, timeout=30) + assert child.returncode == 0, "installed bootstrap refused disposable configuration" + bootstrap_result = json.loads(child.stdout) + assert bootstrap_result["empty"] and server.queue_claims == 1 + assert server.provider_calls == 0 and not parent.status()["reservations"] + config = OpsRunConfig(worker_id=policy.worker_id, execution_project=policy.project) + item = OpsRun(id="fixture-run", activity_definition_id=policy.activity_definition_id, + idempotency_key="fixture-key", target_repo=str(source), title="fixture", description="", + state="claimed", claim_owner=policy.worker_id, attempt=1, repository_grant=grant, + harness_profile_ref=str(profile.ref), + lease_until=(datetime.now(UTC) + timedelta(seconds=90)).isoformat()) + parent.reserve(item) + owner = MessagesOwner(messages, DUMMY_KEY, f"http://127.0.0.1:{server.server_port}", True, + runtime_path=runtime, runtime_sha256=checksum) + before = server.provider_calls + consumer = Consumer(actor="agt", project=policy.project, run_id=item.id) + with owner.activate(item, config, parent, profile, ExecutionCancel()) as manager: + status = manager.create(SandboxCreateRequest(profile="profile.bwrap-local", consumer=consumer, + inputs={"repo": str(source)})) + try: + assert status.state.value == "ready", "sandbox not ready" + probe = manager.execute(status.sandbox_id, SandboxExecRequest( + consumer=consumer, timeout_seconds=30, command=["python3", "-c", """import os,json,subprocess,sys +from pathlib import Path +runtime=Path('/opt/sandboxer/runtime') +try: + (runtime/'must-not-write').write_text('no') +except OSError: readonly=True +else: readonly=False +print(json.dumps({'runtime_readonly':readonly,'python_prefix':sys.prefix, + 'cli_version':subprocess.check_output(['claude','--version'],text=True).strip(), + 'private_absent':not Path(sys.argv[1]).exists(),'source_absent':not Path(sys.argv[2]).exists(), + 'proxy_absent':not any('proxy' in k.lower() for k in os.environ), + 'interfaces':[x.split(':')[0].strip() for x in Path('/proc/net/dev').read_text().splitlines()[2:]]})) +""", str(private), str(source)])) + assert probe.exit_code == 0, "installed runtime probe failed" + facts = json.loads(probe.stdout) + assert facts["runtime_readonly"] and facts["private_absent"] and facts["source_absent"] + assert facts["proxy_absent"] and facts["interfaces"] == ["lo"] + assert facts["cli_version"] == "2.1.266 (Claude Code)" + adapter = AgenticClaudeCodeAdapter(workdir=Path(status.inputs["workspace_dir"]), + model=profile.model.model) + argv = adapter._build_command(RunConfig(model_params={"max_budget_usd": cap, "max_turns": 4})) + executed = manager.execute(status.sandbox_id, SandboxExecRequest( + consumer=consumer, command=argv, stdin_text="Reply fixture complete. Do not edit files.", + timeout_seconds=45)) + terminal = json.loads(executed.stdout) + if cap <= 0.01: + assert executed.exit_code != 0 and terminal["is_error"] + assert server.provider_calls == before and not meter.status() + else: + assert executed.exit_code == 0 and not terminal.get("is_error") + assert server.provider_calls == before + 1 and server.provider_key_correct + assert len(meter.status()) == 1 and meter.status()[0]["state"] == "charged" + token = manager._messages_route.token + finally: + manager.destroy(status.sandbox_id) + assert not Path(status.inputs["workspace_dir"]).exists() + with parent._db() as db: + assert db.execute("SELECT revoked FROM request_routes").fetchone()[0] == 1 + output = {"native_threshold_usd": cap, "probe": facts, + "provider_requests": server.provider_calls-before, "cli_exit_code": executed.exit_code, + "cli_is_error": terminal.get("is_error"), "cli_estimated_usd": terminal.get("total_cost_usd"), + "request_reservations": meter.status(), "route_revoked": True, "workspace_removed": True, + "bootstrap": bootstrap_result} + assert token not in json.dumps(output) and DUMMY_KEY not in json.dumps(output) + return output + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--runtime", required=True, type=Path) + parser.add_argument("--sha256", required=True) + args = parser.parse_args() + runtime = args.runtime.resolve() + assert sys.flags.isolated and sys.dont_write_bytecode, "run candidate python with -I -B" + assert runtime_digest(runtime) == args.sha256 + imports = {} + for package in ("rein_aharness", "llm_connect", "glas_harness", "sandboxer"): + path = Path(importlib.import_module(package).__file__).resolve() + assert path.is_relative_to(runtime), "editable source fallback" + imports[package] = str(path.relative_to(runtime)) + assert profiles_dir().is_relative_to(runtime) and extensions_dir().is_relative_to(runtime) + os.environ["SANDBOXER_NO_STATE_HUB"] = "1" + with tempfile.TemporaryDirectory(prefix="installed-metered-proof-") as directory: + root = Path(directory) + os.environ["XDG_DATA_HOME"] = str(root / "data") + server = FixtureServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + try: + positive = case(root / "positive", runtime, args.sha256, server, 1.0, bootstrap=True) + refused = case(root / "refused", runtime, args.sha256, server, 0.01) + finally: + server.shutdown(); server.server_close() + assert runtime_digest(runtime) == args.sha256, "proof mutated the candidate artifact" + print(json.dumps({"scope": "standalone installed packages and real CLI/bwrap; synthetic key/provider/empty queue", + "ok": True, "runtime_sha256": args.sha256, + "claude_sha256": hashlib.sha256((runtime/'bin/claude').read_bytes()).hexdigest(), + "imports": imports, "packaged_definitions": True, "positive": positive, "refused": refused, + "artifact_unchanged": True, "live_factory_attempts": 0}, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/verify-recovery-contracts.sh b/scripts/verify-recovery-contracts.sh index 70c1ef2..3a0378e 100755 --- a/scripts/verify-recovery-contracts.sh +++ b/scripts/verify-recovery-contracts.sh @@ -13,6 +13,7 @@ PYTHONPATH="${REPO_ROOT}:${REPO_ROOT}/../llm-connect" \ tests/test_spend_admission.py \ tests/test_request_admission.py \ tests/test_messages_owner.py \ + tests/test_owner_bootstrap.py \ tests/test_claim_loop.py::test_process_one_initial_heartbeat_rejection_refuses_dispatch \ tests/test_claim_loop.py::test_process_one_lease_loss_cancels_registered_adapter_process \ tests/test_claim_loop.py::test_profiled_close_failure_happens_after_repository_lock_release \ diff --git a/scripts/verify-runtime-contracts.sh b/scripts/verify-runtime-contracts.sh index 228299a..4851b4b 100755 --- a/scripts/verify-runtime-contracts.sh +++ b/scripts/verify-runtime-contracts.sh @@ -14,6 +14,7 @@ PYTHONPATH="${REPO_ROOT}:${REPO_ROOT}/../llm-connect" \ tests/test_spend_admission.py \ tests/test_request_admission.py \ tests/test_messages_owner.py \ + tests/test_owner_bootstrap.py \ tests/test_glas_execution.py \ tests/test_ops_run_client.py \ tests/test_claim_loop.py \ diff --git a/tests/test_owner_bootstrap.py b/tests/test_owner_bootstrap.py new file mode 100644 index 0000000..a439b9d --- /dev/null +++ b/tests/test_owner_bootstrap.py @@ -0,0 +1,136 @@ +"""Synthetic owner config/key; no native custody, provider or queue call.""" + +from dataclasses import replace +import json +import os +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from glas_harness.contract import ExecutionLimits, OperationalReadiness +from glas_harness.profiles import ProfileCatalog +from llm_connect.messages_gate import MessagesPolicy +from sandboxer.extensions.runtime import runtime_digest +from rein_aharness.claim_loop import ProcessResult +from rein_aharness.owner_bootstrap import BootstrapRefused, prepare, run_once +from rein_aharness.request_admission import RequestLedger +from rein_aharness.spend_admission import SpendLedger, digest +from test_spend_admission import ledger as ledger, configured + + +@pytest.fixture +def prepared(ledger, tmp_path, monkeypatch): + catalog = ProfileCatalog() + profile, descriptor = catalog.resolve("harness.agent-dev-local@1.0.0") + profile = profile.model_copy(update={ + "sandbox_profile": "profile.bwrap-local", "credential_route_refs": [], + "limits": ExecutionLimits(max_budget_usd=4.0, max_turns=8), + "operational_readiness": OperationalReadiness(status="ready", reason="fixture only", + owner="tests", evidence_ref="test:bootstrap"), + }) + catalog.profiles()[(profile.id, profile.version)] = profile + monkeypatch.setattr("glas_harness.profiles.ProfileCatalog", lambda: catalog) + policy = replace(ledger.policy, profile_ref=str(profile.ref), + profile_sha256=digest(profile.model_dump(mode="json")), + descriptor_sha256=digest(descriptor.model_dump(mode="json"))) + parent = SpendLedger(ledger.path.parent / "prepared.sqlite3", policy) + parent.initialize() + RequestLedger(parent).initialize() + config = configured(parent) + runtime = tmp_path / "runtime" + (runtime / "bin").mkdir(parents=True) + (runtime / "bin/python3").write_bytes(b"not executed; fixture runtime structure") + (runtime / "pyvenv.cfg").write_text("fixture only") + messages = MessagesPolicy("fixture:upper-rate", profile.model.model, 1000, 1000, 1000, 1000) + data = {"version": "1", "authority_ref": policy.authority_ref, + "spend_policy_sha256": policy.sha256, "messages_policy": messages.__dict__, + "runtime": {"path": str(runtime), "sha256": runtime_digest(runtime)}} + path = parent.path.parent / "owner.json" + path.write_text(json.dumps(data)); path.chmod(0o600) + monkeypatch.setattr("rein_aharness.owner_bootstrap.OpsRunConfig.from_env", lambda: config) + return path, config, data, runtime + + +def test_prepare_pins_without_key_or_claim(prepared, monkeypatch): + path, config, data, runtime = prepared + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + policy, selected, checksum = prepare(path, config) + assert selected == runtime and checksum == data["runtime"]["sha256"] + assert policy.model == data["messages_policy"]["model"] + + +@pytest.mark.parametrize("case", ["authority", "policy_digest", "model", "version", "unknown_field", + "duplicate", "public", "runtime_changed", "schema_missing"]) +def test_bad_bootstrap_refuses_before_claim(prepared, monkeypatch, capsys, case): + path, config, data, runtime = prepared + if case == "authority": data["authority_ref"] = "unrelated" + elif case == "policy_digest": data["spend_policy_sha256"] = "0" * 64 + elif case == "model": data["messages_policy"]["model"] = "unadmitted" + elif case == "version": data["version"] = "2" + elif case == "unknown_field": data["upstream_url"] = "https://not-admitted.invalid" + elif case == "runtime_changed": (runtime / "added-file").write_text("changed") + elif case == "schema_missing": + import sqlite3 + with sqlite3.connect(config.spend_ledger_path) as db: db.execute("DROP TABLE request_routes") + path.write_text(json.dumps(data)) + if case == "duplicate": path.write_text(path.read_text().replace('"version": "1"', '"version": "1", "version": "1"')) + if case == "public": path.chmod(0o644) + claimant = MagicMock() + monkeypatch.setattr("rein_aharness.claim_loop.process_one", claimant) + monkeypatch.setenv("ANTHROPIC_API_KEY", "fixture-owner-key") + assert run_once(path) == 2 + claimant.assert_not_called() + assert "fixture-owner-key" not in capsys.readouterr().out + assert "ANTHROPIC_API_KEY" not in os.environ + + +@pytest.mark.parametrize("check,key,alternate,code", [ + (True, None, None, 0), (False, None, None, 2), + (False, "fixture-key", "alternate-token", 2), (False, "invalid key", None, 2), +]) +def test_check_and_missing_or_mixed_auth_never_claim(prepared, monkeypatch, check, key, alternate, code): + path, _, _, _ = prepared + for name, value in [("ANTHROPIC_API_KEY", key), ("ANTHROPIC_AUTH_TOKEN", alternate)]: + if value is None: monkeypatch.delenv(name, raising=False) + else: monkeypatch.setenv(name, value) + claimant = MagicMock() + monkeypatch.setattr("rein_aharness.claim_loop.process_one", claimant) + assert run_once(path, check_only=check) == code + claimant.assert_not_called() + + +@pytest.mark.parametrize("result,code", [ + (ProcessResult(claimed=False, empty=True), 0), + (ProcessResult(claimed=True, ok=True, run_id="fixture-run"), 0), + (ProcessResult(claimed=False, ok=False, reason="private exception"), 1), + (ProcessResult(claimed=True, ok=False, reason="private prompt"), 1), +]) +def test_exactly_one_cycle_without_key_in_environment(prepared, monkeypatch, capsys, result, code): + path, config, data, runtime = prepared + monkeypatch.setenv("ANTHROPIC_API_KEY", "fixture-key") + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + monkeypatch.setattr("rein_aharness.readiness.run_readiness_checks", lambda client: SimpleNamespace(ok=True)) + seen = [] + def process(client, **kwargs): + assert client.config is config + assert config.require_request_admission and config.require_spend_admission + assert config.messages_owner is not None + assert "ANTHROPIC_API_KEY" not in os.environ + seen.append(client) + return result + monkeypatch.setattr("rein_aharness.claim_loop.process_one", process) + assert run_once(path, report_to_hub=False) == code + output = capsys.readouterr().out + assert len(seen) == 1 and "fixture-key" not in output and "private" not in output + + +def test_unexpected_exception_is_bounded(prepared, monkeypatch, capsys): + path, _, _, _ = prepared + monkeypatch.setenv("ANTHROPIC_API_KEY", "fixture-key") + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + monkeypatch.setattr("rein_aharness.readiness.run_readiness_checks", lambda client: SimpleNamespace(ok=True)) + 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"} diff --git a/workplans/REINAH-WP-0003-governed-runtime-integrity.md b/workplans/REINAH-WP-0003-governed-runtime-integrity.md index adf35f3..ba879cf 100644 --- a/workplans/REINAH-WP-0003-governed-runtime-integrity.md +++ b/workplans/REINAH-WP-0003-governed-runtime-integrity.md @@ -765,3 +765,29 @@ protected runtime/CLI artifact, Railiance host/profile/consumer/custody/recovery admission, live provider compatibility and accepted bounds/tariffs/FX, then G0 and natural model/queue evidence. No protected runtime was installed or promoted, no existing CCR changed, no secret read or paid execution took place. + +### Explicit one-cycle owner bootstrap and installed-candidate proof — 2026-09-09 + +Implemented `metered-once` with exact private policy/runtime/profile pins, explicit +exec-env key consumption outside bwrap, environment scrubbing, one claim cycle, +nonzero refusal and bounded receipt output. `--check` never claims or fetches a key. +Nineteen synthetic bootstrap tests cover invalid/changed config/artifact/schema, +missing/mixed authentication, exactly one empty/success/refused cycle and sanitized +exceptions. The existing cancellation/lease lifecycle remains in MessagesOwner. +Runtime selection passes through the trusted ephemeral sandbox binding. + +The worker's source suite retains 392 passing non-native cases; the four native +cases use the exact proved 2.1.266 binary/digest. The workstation alias has advanced +to 2.1.267, which correctly fails the existing pin check; this is not a migration. +SAND-WP-0015 now supplies packaged owner definitions and a frozen-lock build mode. +Run `scripts/prove-metered-runtime.py` using the candidate's isolated interpreter; +source/receipt details live in docs/owner-bootstrap.md and the project's +`evidence/2026-09-09-owner-bootstrap.json`. + +T05 remains progress and T06 wait. This supplies executable bootstrap source, not +native delivery acceptance. SECRETS-WP-0009-T03 must admit the changed credential +holder and fixed child command through the existing action/consume/backend gates. +The current catalog/CCRs, live credentials, profile readiness, host service and +factory queue remain unchanged. Protected placement, provider bounds/tariffs/FX, +G0 and natural execution remain. Per-run acquisition for a future continuous worker +is retained here; a delivered key must not authorize an unbounded daemon lifetime.