Package pinned native Claude and prove isolated startup
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0726e-5232-73f2-aaca-2c05ceb62efb
This commit is contained in:
parent
cfc79d51af
commit
174dba17b6
7 changed files with 299 additions and 8 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 <candidate> --runtime-sha256 <digest> --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.
|
||||
|
|
|
|||
94
docs/evidence/SAND-WP-0015-claude-startup-2026-09-06.json
Normal file
94
docs/evidence/SAND-WP-0015-claude-startup-2026-09-06.json
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
52
tests/test_runtime_builder.py
Normal file
52
tests/test_runtime_builder.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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 <candidate> --runtime-sha256 <digest> --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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue