From 174dba17b640c7a168707654f92d991910b28093 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 6 Sep 2026 23:51:50 +0200 Subject: [PATCH] Package pinned native Claude and prove isolated startup Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0726e-5232-73f2-aaca-2c05ceb62efb --- WORK-RECORDS.md | 1 + docs/bwrap-runtime.md | 43 ++++++++- ...AND-WP-0015-claude-startup-2026-09-06.json | 94 +++++++++++++++++++ scripts/build-rein-runtime.py | 36 ++++++- scripts/smoke-bwrap-runtime.py | 28 +++++- tests/test_runtime_builder.py | 52 ++++++++++ ...WP-0015-bwrap-runtime-and-private-state.md | 53 +++++++++++ 7 files changed, 299 insertions(+), 8 deletions(-) create mode 100644 docs/evidence/SAND-WP-0015-claude-startup-2026-09-06.json create mode 100644 tests/test_runtime_builder.py diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index a51aff0..2d383a0 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -122,3 +122,4 @@ | task | SAND-WP-0015-T02 | done | — | workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md | | task | SAND-WP-0015-T03 | done | — | workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md | | task | SAND-WP-0015-T04 | wait | — | workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md | +| task | SAND-WP-0015-T05 | done | — | workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md | diff --git a/docs/bwrap-runtime.md b/docs/bwrap-runtime.md index 63c8093..63da92d 100644 --- a/docs/bwrap-runtime.md +++ b/docs/bwrap-runtime.md @@ -58,9 +58,9 @@ It imports the rein's Claude adapter and llm-connect, runs `rein-aharness worktree, verifies source absence and a clean Git tree, checks loopback-only networking, and tears down. This is real CLI startup, not a model task. -The Python bundle does not package or pin the separate Claude executable. -That executable's release, startup behavior, and authentication still require -review in the eventual production runtime. No committed profile selects a +The default Python-only build does not package Claude. The optional explicit +Claude pin and isolated startup proof are documented in the later candidate +section below; production authentication remains a separate gate. No committed profile selects a temporary `/tmp` build as its production runtime. ## Private writable state @@ -157,3 +157,40 @@ of CCR-2026-0016), owner machine authentication, the pinned Claude executable with its own HTTPS_PROXY startup proof, and the real-model acceptance run. Provider workspace scope and spend limit remain operator inputs; a model `budget_tokens` setting is not a provider spend limit. + + +## Pinned Claude candidate and isolated startup — 2026-09-06 + +The builder now accepts `--claude-binary`, `--claude-sha256` and +`--claude-version` together. It requires a regular native ELF file, verifies the +exact copied bytes, refuses symlinks/wrappers/mismatches and existing destination +files, and copies only the executable as bin/claude mode 0755. The expected +version is metadata until the sandbox smoke checks the actual output. No +interactive HOME, provider key or login state is copied. + +Candidate provenance: the explicitly selected installed native release +`/home/worsch/.local/share/claude/versions/2.1.263`, SHA-256 +`26d020351e8112f4006790f3cfce43b4c9df0c1bb1d0e542364d64151b81d5ba`. +This is a digest pin of the selected local artifact, not an upstream signature +verification claim. Complete runtime candidate: +`/tmp/sandboxer-claude-runtime-20260906`, SHA-256 +`5cf9a16c5d77a16bdb2cb5b3df06ea655356bc2d44741791e3fedfee20d7e922`. +Mount remains `/opt/sandboxer/runtime`; Claude is `/opt/sandboxer/runtime/bin/claude`. +Rein sources remain 1429db5 / llm-connect 0056094; resolved dependency versions +are recorded in the evidence. Rebuilding may resolve different dependencies; +the complete digest pins this artifact, not future builds. + +Sandbox f8821ec2 ran real `claude --version` -> `2.1.263 (Claude Code)` plus rein +CLI/import checks, with owner allowlisted provider egress and HTTPS_PROXY set +by the broker. Runtime read-only, private 0700 HOME/state persistence, clean Git, +absent source and loopback-only interfaces passed; workspace and proxy removed. +`make check`: lint passed, 175 tests passed. + +Evidence: docs/evidence/SAND-WP-0015-claude-startup-2026-09-06.json. +Reproduce with the builder options above and `scripts/smoke-bwrap-runtime.py +--runtime-path --runtime-sha256 --claude-version 2.1.263`. + +Limits: `--version` proves startup with the proxy environment, not that Claude +has sent a provider request through it. No credential acquisition or model run. +The /tmp candidate is not production placement; protected artifact installation, +owner configuration, credential adoption and real-model proof remain T04 gates. diff --git a/docs/evidence/SAND-WP-0015-claude-startup-2026-09-06.json b/docs/evidence/SAND-WP-0015-claude-startup-2026-09-06.json new file mode 100644 index 0000000..f8823ec --- /dev/null +++ b/docs/evidence/SAND-WP-0015-claude-startup-2026-09-06.json @@ -0,0 +1,94 @@ +{ + "build": { + "runtime": { + "path": "/tmp/sandboxer-claude-runtime-20260906", + "sha256": "5cf9a16c5d77a16bdb2cb5b3df06ea655356bc2d44741791e3fedfee20d7e922" + }, + "build": { + "python": "3.12.3", + "packages": { + "idna": "3.19", + "h11": "0.16.0", + "httpcore": "1.0.9", + "llm-connect": "0.1.0", + "certifi": "2026.7.22", + "PyYAML": "6.0.3", + "toml": "0.10.2", + "typing_extensions": "4.16.0", + "httpx": "0.28.1", + "rein-aharness": "0.1.0", + "anyio": "4.15.1" + }, + "claude": { + "path": "/opt/sandboxer/runtime/bin/claude", + "sha256": "26d020351e8112f4006790f3cfce43b4c9df0c1bb1d0e542364d64151b81d5ba", + "expected_version": "2.1.263", + "source": "/home/worsch/.local/share/claude/versions/2.1.263" + }, + "source_revisions": { + "rein-aharness": "1429db5ad4c83331b6375349ffde1eb13af9575b", + "llm-connect": "00560945f81ba6ff1f5cacd9fe99c7fe756cc4b1" + } + } + }, + "smoke": { + "ok": true, + "sandbox_id": "f8821ec2", + "runtime_sha256": "5cf9a16c5d77a16bdb2cb5b3df06ea655356bc2d44741791e3fedfee20d7e922", + "proof": { + "rein_cli_started": true, + "adapter_imported": true, + "runtime_readonly": true, + "source_absent": true, + "home_outside_workspace": true, + "home_mode": "0700", + "worktree_clean": true, + "interfaces": [ + "lo" + ], + "python_prefix": "/opt/sandboxer/runtime", + "claude_version": "2.1.263 (Claude Code)", + "https_proxy_present": true, + "claude_provider_request_proven": false, + "credential_refs": [] + }, + "workspace_removed": true, + "exit_code": 0, + "model_run_proven": false, + "private_state_persisted": true, + "proxy_removed": true + }, + "verification": { + "lint": "passed", + "tests_passed": 175 + }, + "production_deployed": false, + "provenance": "Explicitly selected installed native executable; no interactive HOME copied.", + "absolute_path_smoke": { + "ok": true, + "sandbox_id": "979c0208", + "runtime_sha256": "5cf9a16c5d77a16bdb2cb5b3df06ea655356bc2d44741791e3fedfee20d7e922", + "proof": { + "rein_cli_started": true, + "adapter_imported": true, + "runtime_readonly": true, + "source_absent": true, + "home_outside_workspace": true, + "home_mode": "0700", + "worktree_clean": true, + "interfaces": [ + "lo" + ], + "python_prefix": "/opt/sandboxer/runtime", + "claude_version": "2.1.263 (Claude Code)", + "https_proxy_present": true, + "claude_provider_request_proven": false, + "credential_refs": [] + }, + "workspace_removed": true, + "exit_code": 0, + "model_run_proven": false, + "private_state_persisted": true, + "proxy_removed": true + } +} diff --git a/scripts/build-rein-runtime.py b/scripts/build-rein-runtime.py index 66ee0fa..0868d3b 100644 --- a/scripts/build-rein-runtime.py +++ b/scripts/build-rein-runtime.py @@ -7,7 +7,9 @@ Run with the sand-boxer Python environment; uv must be available on PATH. from __future__ import annotations import argparse +import hashlib import json +import re import subprocess from pathlib import Path @@ -21,7 +23,31 @@ def checked(command: list[str]) -> str: return result.stdout.strip() -def build(output: Path, rein_source: Path, llm_source: Path) -> dict: +def install_claude(output: Path, source: Path, sha256: str, version: str) -> dict: + """Copy only an explicitly pinned native executable, never an interactive HOME.""" + if not re.fullmatch(r"[0-9a-f]{64}", sha256) or not version.strip(): + raise ValueError("Claude requires an exact SHA-256 and expected version") + if source.is_symlink() or not source.is_file(): + raise ValueError("Claude source must be a regular file, not a symlink") + content = source.read_bytes() + if hashlib.sha256(content).hexdigest() != sha256: + raise ValueError("Claude executable digest mismatch") + if not content.startswith(b"\x7fELF"): + raise ValueError("Claude executable must be native ELF, not a host wrapper") + destination = output / "bin/claude" + with destination.open("xb") as stream: + stream.write(content) + destination.chmod(0o755) + return {"path": f"{RUNTIME_MOUNT}/bin/claude", "sha256": sha256, + "expected_version": version, "source": str(source)} + + +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: + 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") revisions = {} for name, source in (("rein-aharness", rein_source), ("llm-connect", llm_source)): if checked(["git", "-C", str(source), "status", "--porcelain"]): @@ -46,6 +72,8 @@ def build(output: Path, rein_source: Path, llm_source: Path) -> dict: "print(json.dumps({'python':platform.python_version(),'packages':" "{d.metadata['Name']:d.version for d in importlib.metadata.distributions()}}))", ])) + if claude_binary is not None: + metadata["claude"] = install_claude(output, claude_binary, claude_sha256, claude_version) metadata["source_revisions"] = revisions (output / "build-info.json").write_text(json.dumps(metadata, indent=2) + "\n") return {"runtime": {"path": str(output), "sha256": runtime_digest(output)}, @@ -57,8 +85,12 @@ def main() -> int: parser.add_argument("--output", type=Path, required=True) parser.add_argument("--rein-source", type=Path, required=True) parser.add_argument("--llm-source", type=Path, required=True) + parser.add_argument("--claude-binary", type=Path) + parser.add_argument("--claude-sha256") + parser.add_argument("--claude-version") 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) print(json.dumps(result, indent=2)) return 0 diff --git a/scripts/smoke-bwrap-runtime.py b/scripts/smoke-bwrap-runtime.py index 476bc20..e4133c8 100644 --- a/scripts/smoke-bwrap-runtime.py +++ b/scripts/smoke-bwrap-runtime.py @@ -38,6 +38,17 @@ assert not source.exists() help_result = subprocess.run(['rein-aharness', '--help'], capture_output=True, timeout=15) assert help_result.returncode == 0 assert b'usage:' in help_result.stdout +claude_version = None +proxy_present = False +if sys.argv[2]: + assert 'ANTHROPIC_API_KEY' not in os.environ + proxy_present = os.environ['HTTPS_PROXY'].startswith('http://127.0.0.1:') + assert proxy_present + cli = subprocess.run([str(runtime / 'bin/claude'), '--version'], + capture_output=True, text=True, timeout=20) + assert cli.returncode == 0, cli.stderr + claude_version = cli.stdout.strip() + assert claude_version == sys.argv[2] + ' (Claude Code)', claude_version assert subprocess.check_output(['git', 'status', '--porcelain', '--ignored=matching']) == b'' interfaces = [line.split(':', 1)[0].strip() for line in Path('/proc/net/dev').read_text().splitlines()[2:]] @@ -47,6 +58,8 @@ print(json.dumps({'rein_cli_started': True, 'adapter_imported': True, 'home_outside_workspace': True, 'home_mode': '0700', 'worktree_clean': True, 'interfaces': interfaces, 'python_prefix': sys.prefix, + 'claude_version': claude_version, 'https_proxy_present': proxy_present, + 'claude_provider_request_proven': False, 'credential_refs': json.loads(os.environ['SANDBOXER_CREDENTIAL_ROUTE_REFS'])})) ''' @@ -55,22 +68,26 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--runtime-path", required=True) parser.add_argument("--runtime-sha256", required=True) + parser.add_argument("--claude-version", default="") args = parser.parse_args() with tempfile.TemporaryDirectory(prefix="sandboxer-runtime-proof-") as temporary: root = Path(temporary) source = root / "source" source.mkdir() subprocess.run(["git", "init", "-q", str(source)], check=True) + egress = ["api.anthropic.com:443"] if args.claude_version else [] extension = BwrapExtension({ + "allowed_egress": egress, "base_dir": str(root / "sandboxes"), "runtime": {"path": args.runtime_path, "sha256": args.runtime_sha256}, }) - profile = Profile(id="profile.runtime-proof", version="1", extension="ext.bwrap") + profile = Profile(id="profile.runtime-proof", version="1", extension="ext.bwrap", + network={"default": "deny", "egress": egress}) handle = extension.provision(profile, {"repo": str(source)}, "localhost") try: extension.wait_ready(handle) result = extension.execute( - handle, ["python3", "-c", PROBE, str(source)], + handle, ["python3", "-c", PROBE, str(source), args.claude_version], credential_route_refs=[], execution_context={"actor": "agt", "project": "sand-boxer-runtime-proof", "run_id": "sand-wp-0015-proof"}, @@ -91,12 +108,17 @@ def main() -> int: facts = json.loads(result["stdout"]) if passed else {} private_state_persisted = persistence["exit_code"] == 0 passed = passed and private_state_persisted and teardown["workspace_removed"] == "True" + proxy_removed = not egress or ( + not Path(handle["egress_dir"]).exists() + and not extension._pid_alive(int(handle["egress_pid"])) + ) + passed = passed and proxy_removed print(json.dumps({ "ok": passed, "sandbox_id": handle["sandbox_id"], "runtime_sha256": args.runtime_sha256, "proof": facts, "workspace_removed": teardown["workspace_removed"] == "True", "exit_code": result["exit_code"], "model_run_proven": False, - "private_state_persisted": private_state_persisted, + "private_state_persisted": private_state_persisted, "proxy_removed": proxy_removed, }, indent=2)) return 0 if passed else 1 diff --git a/tests/test_runtime_builder.py b/tests/test_runtime_builder.py new file mode 100644 index 0000000..db61cf1 --- /dev/null +++ b/tests/test_runtime_builder.py @@ -0,0 +1,52 @@ +import hashlib +import importlib.util +from pathlib import Path + +import pytest + +spec = importlib.util.spec_from_file_location( + "runtime_builder", Path(__file__).parents[1] / "scripts/build-rein-runtime.py" +) +builder = importlib.util.module_from_spec(spec) +spec.loader.exec_module(builder) + + +def test_claude_copy_is_pinned_and_excludes_home(tmp_path): + source = tmp_path / "native" + source.write_bytes(b"\x7fELFtest artifact, never executed") + (tmp_path / "credentials.json").write_text("must not enter artifact") + output = tmp_path / "runtime" + (output / "bin").mkdir(parents=True) + digest = hashlib.sha256(source.read_bytes()).hexdigest() + metadata = builder.install_claude(output, source, digest, "test-version") + assert metadata["sha256"] == digest + assert metadata["path"] == "/opt/sandboxer/runtime/bin/claude" + assert list((output / "bin").iterdir()) == [output / "bin/claude"] + assert (output / "bin/claude").read_bytes() == source.read_bytes() + assert (output / "bin/claude").stat().st_mode & 0o777 == 0o755 + assert not (output / "credentials.json").exists() + with pytest.raises(FileExistsError): + builder.install_claude(output, source, digest, "test-version") + + +def test_changed_binary_and_host_wrapper_refuse(tmp_path): + source = tmp_path / "binary" + source.write_bytes(b"#!/usr/bin/env node\n") + output = tmp_path / "runtime" + (output / "bin").mkdir(parents=True) + with pytest.raises(ValueError, match="digest mismatch"): + builder.install_claude(output, source, "0" * 64, "test-version") + digest = hashlib.sha256(source.read_bytes()).hexdigest() + with pytest.raises(ValueError, match="native ELF"): + builder.install_claude(output, source, digest, "test-version") + link = tmp_path / "link" + link.symlink_to(source) + with pytest.raises(ValueError, match="symlink"): + builder.install_claude(output, link, digest, "test-version") + assert not (output / "bin/claude").exists() + + +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() 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 f8139b7..43955cb 100644 --- a/workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md +++ b/workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md @@ -151,3 +151,56 @@ contract, owner machine authentication, the pinned Claude executable with its own proxy startup proof, and the real-model acceptance are still operator and upstream-owner gated. Provider workspace scope and spend limit remain operator inputs. `SAND-WP-0014-T05` remains open on the same gates. + + +## Package and prove the pinned Claude executable independently of custody + +```task +id: SAND-WP-0015-T05 +status: done +priority: high +state_hub_task_id: "f0163cda-b581-56a0-a0dd-9c8a436f5bbe" +``` + +Split the independent executable packaging/startup portion from T04 so credential +custody does not hide completed local runtime work. Implement explicit binary +pinning, reject mismatches/host wrappers, and prove actual startup with the +broker proxy environment and complete cleanup. T04 retains protected deployment, +credential adoption and real-provider acceptance. + + +## Pinned Claude candidate and isolated startup — 2026-09-06 + +The builder now accepts `--claude-binary`, `--claude-sha256` and +`--claude-version` together. It requires a regular native ELF file, verifies the +exact copied bytes, refuses symlinks/wrappers/mismatches and existing destination +files, and copies only the executable as bin/claude mode 0755. The expected +version is metadata until the sandbox smoke checks the actual output. No +interactive HOME, provider key or login state is copied. + +Candidate provenance: the explicitly selected installed native release +`/home/worsch/.local/share/claude/versions/2.1.263`, SHA-256 +`26d020351e8112f4006790f3cfce43b4c9df0c1bb1d0e542364d64151b81d5ba`. +This is a digest pin of the selected local artifact, not an upstream signature +verification claim. Complete runtime candidate: +`/tmp/sandboxer-claude-runtime-20260906`, SHA-256 +`5cf9a16c5d77a16bdb2cb5b3df06ea655356bc2d44741791e3fedfee20d7e922`. +Mount remains `/opt/sandboxer/runtime`; Claude is `/opt/sandboxer/runtime/bin/claude`. +Rein sources remain 1429db5 / llm-connect 0056094; resolved dependency versions +are recorded in the evidence. Rebuilding may resolve different dependencies; +the complete digest pins this artifact, not future builds. + +Sandbox f8821ec2 ran real `claude --version` -> `2.1.263 (Claude Code)` plus rein +CLI/import checks, with owner allowlisted provider egress and HTTPS_PROXY set +by the broker. Runtime read-only, private 0700 HOME/state persistence, clean Git, +absent source and loopback-only interfaces passed; workspace and proxy removed. +`make check`: lint passed, 175 tests passed. + +Evidence: docs/evidence/SAND-WP-0015-claude-startup-2026-09-06.json. +Reproduce with the builder options above and `scripts/smoke-bwrap-runtime.py +--runtime-path --runtime-sha256 --claude-version 2.1.263`. + +Limits: `--version` proves startup with the proxy environment, not that Claude +has sent a provider request through it. No credential acquisition or model run. +The /tmp candidate is not production placement; protected artifact installation, +owner configuration, credential adoption and real-model proof remain T04 gates.