From d69827aaa2a6262a66881ba2439500a6c170ecb4 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 5 Sep 2026 20:36:11 +0200 Subject: [PATCH] feat: pin bwrap rein runtimes and isolate private state Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0726e-5232-73f2-aaca-2c05ceb62efb --- WORK-RECORDS.md | 5 + docs/bwrap-runtime.md | 123 ++++++++++++++++++ scripts/build-rein-runtime.py | 67 ++++++++++ scripts/smoke-bwrap-runtime.py | 105 +++++++++++++++ src/sandboxer/extensions/bwrap.py | 19 +++ src/sandboxer/extensions/bwrap_runner.py | 25 +++- src/sandboxer/extensions/runtime.py | 52 ++++++++ src/sandboxer/models.py | 4 +- tests/test_bwrap.py | 8 ++ tests/test_bwrap_runtime.py | 116 +++++++++++++++++ .../SAND-WP-0014-owner-mediated-execution.md | 15 ++- ...WP-0015-bwrap-runtime-and-private-state.md | 109 ++++++++++++++++ 12 files changed, 639 insertions(+), 9 deletions(-) create mode 100644 docs/bwrap-runtime.md create mode 100644 scripts/build-rein-runtime.py create mode 100644 scripts/smoke-bwrap-runtime.py create mode 100644 src/sandboxer/extensions/runtime.py create mode 100644 tests/test_bwrap_runtime.py create mode 100644 workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index dc2f581..a51aff0 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -22,6 +22,7 @@ | workplan | SAND-WP-0012 | finished | — | workplans/SAND-WP-0012-packer-orchestration.md | | workplan | SAND-WP-0013 | finished | — | workplans/SAND-WP-0013-bwrap-extension.md | | workplan | SAND-WP-0014 | active | — | workplans/SAND-WP-0014-owner-mediated-execution.md | +| workplan | SAND-WP-0015 | blocked | — | workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md | | task | SAND-WP-0001-T01 | done | — | workplans/SAND-WP-0001-statehub-bootstrap.md | | task | SAND-WP-0001-T02 | done | — | workplans/SAND-WP-0001-statehub-bootstrap.md | | task | SAND-WP-0001-T03 | done | — | workplans/SAND-WP-0001-statehub-bootstrap.md | @@ -117,3 +118,7 @@ | task | SAND-WP-0014-T03 | done | — | workplans/SAND-WP-0014-owner-mediated-execution.md | | task | SAND-WP-0014-T04 | done | — | workplans/SAND-WP-0014-owner-mediated-execution.md | | task | SAND-WP-0014-T05 | wait | — | workplans/SAND-WP-0014-owner-mediated-execution.md | +| task | SAND-WP-0015-T01 | done | — | workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md | +| 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 | diff --git a/docs/bwrap-runtime.md b/docs/bwrap-runtime.md new file mode 100644 index 0000000..43e33b3 --- /dev/null +++ b/docs/bwrap-runtime.md @@ -0,0 +1,123 @@ +# Bwrap runtime and private state + +`ext.bwrap` can mount a standalone Python runtime selected by the owner's +extension configuration. A create/exec caller cannot select host paths or +replace the sanitized child environment. + +```yaml +config: + runtime: + path: /srv/sandboxer/runtimes/rein-aharness-example + sha256: +``` + +The path must be a canonical absolute directory with `pyvenv.cfg` and +`bin/python3`. Its complete tree digest includes file contents, modes, directory +entries, and internal symlinks. Absolute/escaping symlinks and special files +are refused. Missing files, added files, changed permissions, or changed +content invalidate the pin. The runtime must not overlap the source checkout +or writable sandbox workspace. + +The owner mounts the artifact read-only at `/opt/sandboxer/runtime` and prepends +its `bin` directory to the child PATH. Console entrypoints must use that fixed +mount prefix. Artifacts remain an owner trust boundary: publish them in a +protected location and do not mutate them while sandboxes use them. Digest +validation occurs at creation; it is not a mechanism for making a mutable host +directory immutable to its owner. + +## Building and testing the Python rein bundle + +From this repository, with committed rein/llm-connect source checkouts: + +```bash +uv run python scripts/build-rein-runtime.py \ + --output /tmp/rein-runtime-candidate \ + --rein-source ../rein-aharness \ + --llm-source ../llm-connect +``` + +The builder creates a standalone system-Python venv, installs both packages +non-editably with their dependencies, rewrites Python console entrypoint +shebangs to the fixed mount path, and records source revisions plus resolved +package versions in `build-info.json`. It prints the resulting path/digest. +Dependency versions are resolved during this candidate build; the digest pins +the produced artifact, not a promise that another build will be bit-identical. +No interactive home, credentials, or provider login is copied into the bundle. + +Prove the artifact using the returned values: + +```bash +uv run python scripts/smoke-bwrap-runtime.py \ + --runtime-path /tmp/rein-runtime-candidate \ + --runtime-sha256 +``` + +The smoke uses an isolated owner extension configuration and the real broker. +It imports the rein's Claude adapter and llm-connect, runs `rein-aharness +--help`, proves the runtime rejects writes, writes private state outside the +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 +temporary `/tmp` build as its production runtime. + +## Private writable state + +The broker creates mode-0700 directories in the namespace's temporary root: + +| Environment variable | Location | +|---|---| +| `HOME` | `/run/sandboxer/state/home` | +| `XDG_CONFIG_HOME` | `/run/sandboxer/state/config` | +| `XDG_CACHE_HOME` | `/run/sandboxer/state/cache` | +| `XDG_STATE_HOME` | `/run/sandboxer/state/data` | +| `TMPDIR` | `/run/sandboxer/state/tmp` | + +These directories persist between exec requests in the same sandbox, remain +outside the Git worktree, and disappear with namespace teardown. Python user +site packages and bytecode writes are disabled. The owner's child environment +continues to carry exact consumer identity and value-free route references. + +## Network and credentials remain separate gates + +Bwrap now refuses a profile with `network.default: allow`, nonempty egress, or +setup secret references. The implementation currently provides only a +loopback-only network and no credential-delivery mechanism. Returning a +declared egress list as evidence would not make that list enforced or usable. +Exec credential route references continue to be labels, not credential values +or delivery grants. + +On 2026-09-05, `warden route find anthropic --json` and `warden route find +claude-code --json` returned no matching workload routes. The generic OpenBao +entry is explicitly a routing template, not an executable credential lane. +The OpenRouter AppRole route belongs to the other rein and is not a substitute +for Claude authentication. + +`SAND-WP-0015-T04` / `SAND-WP-0014-T05` and `GLAS-WP-0012-T02` retain the +production requirements: a concrete owner-approved Claude credential route, +delivery and revocation semantics, a pinned Claude runtime, and explicitly +enforced provider egress with negative tests. Glas local profiles remain +blocked until those requirements and the real-model acceptance pass. + +## 2026-09-05 candidate evidence + +Runtime SHA-256: +`4c316737ec2715936a12c4f49621a5e4be3d1f4fe4739130393f28cdda66fbd9`. +Candidate path: `/tmp/sandboxer-rein-aharness-runtime-20260905-v2`. +Source revisions: rein-aharness `1429db5ad4c83331b6375349ffde1eb13af9575b`, +llm-connect `00560945f81ba6ff1f5cacd9fe99c7fe756cc4b1`; Python 3.12.3. + +Sandbox `f333fb66` passed the real CLI/import/read-only/private-state smoke. +`sys.prefix` was `/opt/sandboxer/runtime`, HOME was outside the worktree with +mode 0700, Git stayed clean, source was absent, only `lo` existed, credential +references were empty, and the workspace was removed. No model request or +credential acquisition occurred. + +Final validation: `make check` passed lint and 132 tests. Authenticated owner +API smoke `223db65b` returned HTTP 200 with exact identity/stdin and complete +teardown. Follow-up runtime smoke `d4de9531` repeated the real CLI/startup +checks and proved private state survives a second exec in the same namespace. +The default unconfigured `profile.bwrap-local` still has no selected rein +bundle; candidate startup does not constitute production deployment. diff --git a/scripts/build-rein-runtime.py b/scripts/build-rein-runtime.py new file mode 100644 index 0000000..66ee0fa --- /dev/null +++ b/scripts/build-rein-runtime.py @@ -0,0 +1,67 @@ +"""Build a standalone rein-aharness runtime for the fixed bwrap mount path. + +Only package installation occurs here. No credentials or model calls are used. +Run with the sand-boxer Python environment; uv must be available on PATH. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +from pathlib import Path + +from sandboxer.extensions.runtime import RUNTIME_MOUNT, runtime_digest + + +def checked(command: list[str]) -> str: + result = subprocess.run(command, capture_output=True, text=True, timeout=300) + if result.returncode: + raise RuntimeError(f"runtime build command failed: {Path(command[0]).name}") + return result.stdout.strip() + + +def build(output: Path, rein_source: Path, llm_source: Path) -> dict: + revisions = {} + for name, source in (("rein-aharness", rein_source), ("llm-connect", llm_source)): + if checked(["git", "-C", str(source), "status", "--porcelain"]): + raise ValueError(f"{name} source must be committed before building") + revisions[name] = checked(["git", "-C", str(source), "rev-parse", "HEAD"]) + output.mkdir(parents=True, exist_ok=False) + checked(["/usr/bin/python3", "-m", "venv", "--copies", "--without-pip", str(output)]) + checked(["uv", "pip", "install", "--python", str(output / "bin/python3"), + str(rein_source), str(llm_source)]) + # Console entrypoints must reference the in-sandbox mount, not the build host. + for path in (output / "bin").iterdir(): + if not path.is_file() or path.is_symlink(): + continue + with path.open("rb") as stream: + first_line = stream.readline(4096) + if first_line.startswith(f"#!{output}/bin/python".encode()): + body = path.read_bytes().partition(b"\n")[2] + path.write_bytes(f"#!{RUNTIME_MOUNT}/bin/python3\n".encode() + body) + metadata = json.loads(checked([ + str(output / "bin/python3"), "-c", + "import importlib.metadata,json,platform; " + "print(json.dumps({'python':platform.python_version(),'packages':" + "{d.metadata['Name']:d.version for d in importlib.metadata.distributions()}}))", + ])) + 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)}, + "build": metadata} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + 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) + args = parser.parse_args() + result = build(args.output.resolve(), args.rein_source.resolve(), args.llm_source.resolve()) + print(json.dumps(result, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/smoke-bwrap-runtime.py b/scripts/smoke-bwrap-runtime.py new file mode 100644 index 0000000..476bc20 --- /dev/null +++ b/scripts/smoke-bwrap-runtime.py @@ -0,0 +1,105 @@ +"""Prove a real rein CLI starts in a pinned, read-only owner runtime. + +No credential acquisition or model request is performed. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import tempfile +from pathlib import Path + +from sandboxer.extensions.bwrap import BwrapExtension +from sandboxer.models import Profile + +PROBE = r''' +import json, os, subprocess, sys +from pathlib import Path +import rein_aharness.adapter +import llm_connect +source = Path(sys.argv[1]) +runtime = Path('/opt/sandboxer/runtime') +home = Path(os.environ['HOME']) +private = home / 'private-state-proof' +private.write_text('non-secret private state\n') +assert not home.is_relative_to(Path.cwd()) +assert (home.stat().st_mode & 0o777) == 0o700 +assert private.is_file() +try: + (runtime / 'write-probe').write_text('must be refused') +except OSError: + readonly = True +else: + readonly = False +assert readonly +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 +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:]] +assert interfaces == ['lo'] +print(json.dumps({'rein_cli_started': True, 'adapter_imported': True, + 'runtime_readonly': readonly, 'source_absent': True, + 'home_outside_workspace': True, 'home_mode': '0700', + 'worktree_clean': True, 'interfaces': interfaces, + 'python_prefix': sys.prefix, + 'credential_refs': json.loads(os.environ['SANDBOXER_CREDENTIAL_ROUTE_REFS'])})) +''' + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--runtime-path", required=True) + parser.add_argument("--runtime-sha256", required=True) + 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) + extension = BwrapExtension({ + "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") + handle = extension.provision(profile, {"repo": str(source)}, "localhost") + try: + extension.wait_ready(handle) + result = extension.execute( + handle, ["python3", "-c", PROBE, str(source)], + credential_route_refs=[], + execution_context={"actor": "agt", "project": "sand-boxer-runtime-proof", + "run_id": "sand-wp-0015-proof"}, + timeout_seconds=30, max_output_bytes=65536, + ) + persistence = extension.execute( + handle, ["python3", "-c", + "import os; from pathlib import Path; " + "assert (Path(os.environ['HOME']) / 'private-state-proof').is_file()"], + credential_route_refs=[], + execution_context={"actor": "agt", "project": "sand-boxer-runtime-proof", + "run_id": "sand-wp-0015-proof"}, + timeout_seconds=15, max_output_bytes=1024, + ) + finally: + teardown = extension.teardown(handle) + passed = result["exit_code"] == 0 and not result["timed_out"] + 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" + 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, + }, indent=2)) + return 0 if passed else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/sandboxer/extensions/bwrap.py b/src/sandboxer/extensions/bwrap.py index e57e7d1..6b3e3a1 100644 --- a/src/sandboxer/extensions/bwrap.py +++ b/src/sandboxer/extensions/bwrap.py @@ -15,6 +15,7 @@ from pathlib import Path from typing import Any from sandboxer.extensions.base import SandboxExtension +from sandboxer.extensions.runtime import RUNTIME_MOUNT, verified_runtime from sandboxer.models import Profile @@ -72,6 +73,13 @@ class BwrapExtension(SandboxExtension): runner = Path(__file__).with_name("bwrap_runner.py") argv += ["--dir", "/run", "--dir", "/run/sandboxer"] argv += ["--ro-bind", str(runner), "/run/sandboxer/bwrap_runner.py"] + runtime = verified_runtime(self.config) + if runtime is not None: + workspace = Path(workspace_dir).resolve() + if runtime.is_relative_to(workspace) or workspace.is_relative_to(runtime): + raise ValueError("runtime and workspace must not overlap") + argv += ["--dir", "/opt", "--dir", "/opt/sandboxer"] + argv += ["--ro-bind", str(runtime), RUNTIME_MOUNT] argv += ["--bind", workspace_dir, workspace_dir] argv += ["--chdir", workspace_dir] argv += [ @@ -80,6 +88,8 @@ class BwrapExtension(SandboxExtension): workspace_dir, f"{workspace_dir}/{self.control_socket_name}", ] + if runtime is not None: + argv.append("--runtime") return argv @staticmethod @@ -102,6 +112,15 @@ class BwrapExtension(SandboxExtension): def provision( self, profile: Profile, inputs: dict[str, str], host: str ) -> dict[str, str]: + if profile.network.default != "deny" or profile.network.egress: + raise ValueError("bwrap currently supports only default-deny with empty egress") + if profile.setup.secret_refs: + raise ValueError("bwrap has no setup credential delivery contract") + runtime = verified_runtime(self.config) + if runtime is not None and inputs.get("repo"): + source = Path(inputs["repo"]).resolve() + if source.is_relative_to(runtime) or runtime.is_relative_to(source): + raise ValueError("runtime and source checkout must not overlap") sandbox_id = self.new_sandbox_id(inputs) workspace_dir = f"{self.base_dir}/{sandbox_id}" Path(workspace_dir).mkdir(parents=True, exist_ok=True) diff --git a/src/sandboxer/extensions/bwrap_runner.py b/src/sandboxer/extensions/bwrap_runner.py index 9ef720c..01d3ce6 100644 --- a/src/sandboxer/extensions/bwrap_runner.py +++ b/src/sandboxer/extensions/bwrap_runner.py @@ -23,7 +23,9 @@ def _bounded_output(value: bytes | None, limit: int) -> tuple[str, bool]: return raw.decode("utf-8", errors="replace"), truncated -def _run(payload: dict, workspace: Path) -> dict[str, object]: +def _run( + payload: dict, workspace: Path, *, runtime_enabled: bool = False +) -> dict[str, object]: command = payload["command"] timeout_seconds = int(payload["timeout_seconds"]) max_output_bytes = int(payload["max_output_bytes"]) @@ -31,12 +33,20 @@ def _run(payload: dict, workspace: Path) -> dict[str, object]: context = payload.get("execution_context", {}) stdin_text = payload.get("stdin_text") child_env = { - "HOME": str(workspace), + "HOME": "/run/sandboxer/state/home", + "XDG_CONFIG_HOME": "/run/sandboxer/state/config", + "XDG_CACHE_HOME": "/run/sandboxer/state/cache", + "XDG_STATE_HOME": "/run/sandboxer/state/data", + "TMPDIR": "/run/sandboxer/state/tmp", + "PYTHONDONTWRITEBYTECODE": "1", + "PYTHONNOUSERSITE": "1", "LANG": "C.UTF-8", "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "SANDBOXER_CREDENTIAL_ROUTE_REFS": json.dumps(credential_refs), **{f"SANDBOXER_{key.upper()}": value for key, value in context.items()}, } + if runtime_enabled: + child_env["PATH"] = "/opt/sandboxer/runtime/bin:" + child_env["PATH"] timed_out = False process = subprocess.Popen( command, @@ -73,6 +83,13 @@ def _run(payload: dict, workspace: Path) -> dict[str, object]: def main() -> int: workspace = Path(sys.argv[1]).resolve(strict=True) socket_path = Path(sys.argv[2]) + runtime_enabled = sys.argv[3:] == ["--runtime"] + if sys.argv[3:] and not runtime_enabled: + raise ValueError("invalid owner runtime mode") + state = Path("/run/sandboxer/state") + state.mkdir(mode=0o700) + for name in ("home", "config", "cache", "data", "tmp"): + (state / name).mkdir(mode=0o700) socket_path.unlink(missing_ok=True) with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server: server.bind(str(socket_path)) @@ -95,7 +112,9 @@ def main() -> int: try: if not chunks: raise ValueError("empty or oversized execution request") - response = _run(json.loads(b"".join(chunks)), workspace) + response = _run( + json.loads(b"".join(chunks)), workspace, runtime_enabled=runtime_enabled + ) except Exception as exc: response = {"boundary_error": str(exc)} connection.sendall(json.dumps(response).encode("utf-8")) diff --git a/src/sandboxer/extensions/runtime.py b/src/sandboxer/extensions/runtime.py new file mode 100644 index 0000000..231fb72 --- /dev/null +++ b/src/sandboxer/extensions/runtime.py @@ -0,0 +1,52 @@ +"""Digest-pinned, owner-configured Python runtimes for bwrap.""" + +from __future__ import annotations + +import hashlib +import json +import os +import stat +from pathlib import Path + +RUNTIME_MOUNT = "/opt/sandboxer/runtime" + + +def runtime_digest(root: Path) -> str: + """Hash the complete artifact, including modes and internal symlink targets.""" + if not root.is_absolute() or root.resolve(strict=True) != root or not root.is_dir(): + raise ValueError("runtime path must be a canonical absolute directory") + records = [] + for path in sorted(root.rglob("*")): + mode = path.lstat().st_mode + if stat.S_ISLNK(mode): + target = os.readlink(path) + if Path(target).is_absolute() or not path.resolve(strict=True).is_relative_to(root): + raise ValueError("runtime symlink escapes the artifact") + value = ["symlink", target] + elif stat.S_ISREG(mode): + with path.open("rb") as stream: + value = ["file", hashlib.file_digest(stream, "sha256").hexdigest()] + elif stat.S_ISDIR(mode): + value = ["directory"] + else: + raise ValueError("runtime contains an unsupported special file") + records.append([path.relative_to(root).as_posix(), stat.S_IMODE(mode), *value]) + return hashlib.sha256(json.dumps(records, separators=(",", ":")).encode()).hexdigest() + + +def verified_runtime(config: dict) -> Path | None: + """Only owner extension configuration can select a runtime artifact.""" + runtime = config.get("runtime") + if runtime is None: + return None + if not isinstance(runtime, dict) or set(runtime) != {"path", "sha256"}: + raise ValueError("runtime requires exactly path and sha256") + if not isinstance(runtime["path"], str) or not isinstance(runtime["sha256"], str): + raise ValueError("runtime path and sha256 must be strings") + root = Path(runtime["path"]) + if (not (root / "pyvenv.cfg").is_file() or not (root / "bin/python3").is_file() + or (root / ".git").exists()): + raise ValueError("runtime must be a standalone Python environment, not a checkout") + if runtime_digest(root) != runtime["sha256"]: + raise ValueError("runtime artifact digest does not match owner configuration") + return root diff --git a/src/sandboxer/models.py b/src/sandboxer/models.py index ed237ad..907a86a 100644 --- a/src/sandboxer/models.py +++ b/src/sandboxer/models.py @@ -188,8 +188,8 @@ class Reachability(BaseModel): tunnel_via: str | None = None identity: str | None = None # Local (no-SSH-hop) descriptor — populated for same-host extensions - # like ext.bwrap. A consumer execs into the sandbox directly - # (e.g. `nsenter --target ...`) rather than over SSH. + # like ext.bwrap. Commands enter through owner-mediated execute; + # the pid is lifecycle metadata, not consumer setns authority. pid: str | None = None workspace_dir: str | None = None diff --git a/tests/test_bwrap.py b/tests/test_bwrap.py index e1453e2..66a876a 100644 --- a/tests/test_bwrap.py +++ b/tests/test_bwrap.py @@ -298,12 +298,20 @@ def test_in_namespace_runner_uses_sanitized_environment(tmp_path) -> None: ] assert set(child_env) == { "HOME", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_STATE_HOME", + "TMPDIR", + "PYTHONDONTWRITEBYTECODE", + "PYTHONNOUSERSITE", "LANG", "PATH", "SANDBOXER_CREDENTIAL_ROUTE_REFS", "SANDBOXER_ACTOR", "SANDBOXER_RUN_ID", } + assert child_env["HOME"] == "/run/sandboxer/state/home" + assert child_env["HOME"] != str(tmp_path) assert result["stdout"] == "ok\n" diff --git a/tests/test_bwrap_runtime.py b/tests/test_bwrap_runtime.py new file mode 100644 index 0000000..ef02d62 --- /dev/null +++ b/tests/test_bwrap_runtime.py @@ -0,0 +1,116 @@ +import socket +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from sandboxer.extensions.bwrap import BwrapExtension +from sandboxer.extensions.bwrap_runner import _run +from sandboxer.extensions.runtime import RUNTIME_MOUNT, runtime_digest, verified_runtime +from sandboxer.models import Profile + + +@pytest.fixture +def artifact(tmp_path): + root = tmp_path / "runtime" + (root / "bin").mkdir(parents=True) + (root / "pyvenv.cfg").write_text("home = /usr/bin\n") + (root / "bin/python3").write_bytes(b"test-only-python") + (root / "bin/python3").chmod(0o755) + return root + + +def test_runtime_mount_requires_matching_complete_digest(artifact, tmp_path): + config = {"runtime": {"path": str(artifact), "sha256": runtime_digest(artifact)}} + assert verified_runtime(config) == artifact + argv = BwrapExtension(config)._bwrap_argv(str(tmp_path / "workspace")) + position = argv.index(str(artifact)) + assert argv[position - 1:position + 2] == ["--ro-bind", str(artifact), RUNTIME_MOUNT] + assert argv[-1] == "--runtime" + (artifact / "bin/python3").write_bytes(b"modified") + with pytest.raises(ValueError, match="digest"): + BwrapExtension(config)._bwrap_argv(str(tmp_path / "workspace")) + + +def test_mode_change_invalidates_runtime_pin(artifact): + config = {"runtime": {"path": str(artifact), "sha256": runtime_digest(artifact)}} + (artifact / "bin/python3").chmod(0o777) + with pytest.raises(ValueError, match="digest"): + verified_runtime(config) + + +def test_unexpected_file_invalidates_runtime_pin(artifact): + config = {"runtime": {"path": str(artifact), "sha256": runtime_digest(artifact)}} + (artifact / "injected.py").write_text("unexpected") + with pytest.raises(ValueError, match="digest"): + verified_runtime(config) + + +def test_runtime_symlinks_must_stay_inside_artifact(artifact, tmp_path): + (artifact / "python").symlink_to("bin/python3") + runtime_digest(artifact) + outside = tmp_path / "outside" + outside.write_text("host data") + (artifact / "escape").symlink_to(outside) + with pytest.raises(ValueError, match="symlink escapes"): + runtime_digest(artifact) + + +def test_runtime_rejects_socket(artifact): + with socket.socket(socket.AF_UNIX) as control: + control.bind(str(artifact / "host.sock")) + with pytest.raises(ValueError, match="special file"): + runtime_digest(artifact) + + +@pytest.mark.parametrize("runtime", [{}, {"path": "/tmp"}, {"path": 3, "sha256": "x"}]) +def test_incomplete_runtime_config_refuses(runtime): + with pytest.raises(ValueError): + verified_runtime({"runtime": runtime}) + + +def test_source_and_runtime_overlap_refuses_before_copy(artifact): + ext = BwrapExtension({"runtime": {"path": str(artifact), + "sha256": runtime_digest(artifact)}}) + profile = Profile(id="profile.test", version="1", extension="ext.bwrap") + with ( + patch("sandboxer.extensions.bwrap.shutil.copytree") as copy, + pytest.raises(ValueError, match="source checkout"), + ): + ext.provision(profile, {"repo": str(artifact.parent)}, "localhost") + copy.assert_not_called() + + +@pytest.mark.parametrize("network", [ + {"default": "allow", "egress": []}, + {"default": "deny", "egress": ["api.example.invalid:443"]}, +]) +def test_unsupported_network_refuses_before_provisioning(tmp_path, network): + profile = Profile(id="profile.test", version="1", extension="ext.bwrap", network=network) + base = tmp_path / "sandboxes" + with pytest.raises(ValueError, match="empty egress"): + BwrapExtension({"base_dir": str(base)}).provision(profile, {}, "localhost") + assert not base.exists() + + +def test_runtime_path_is_not_taken_from_command_payload(tmp_path): + process = MagicMock(returncode=0) + process.communicate.return_value = (b"", b"") + payload = {"command": ["rein-aharness", "--help"], "timeout_seconds": 10, + "max_output_bytes": 100, "runtime_path": "/host/source", "env": {"HOME": "/host"}} + with patch("sandboxer.extensions.bwrap_runner.subprocess.Popen", return_value=process) as run: + _run(payload, tmp_path, runtime_enabled=True) + env = run.call_args.kwargs["env"] + assert env["PATH"].split(":")[0] == RUNTIME_MOUNT + "/bin" + assert not Path(env["HOME"]).is_relative_to(tmp_path) + assert "/host" not in str(env) + assert env["PYTHONNOUSERSITE"] == "1" + + +def test_setup_credentials_refuse_before_provisioning(tmp_path): + profile = Profile(id="profile.test", version="1", extension="ext.bwrap", + setup={"secret_refs": ["test-route"]}) + base = tmp_path / "sandboxes" + with pytest.raises(ValueError, match="credential delivery contract"): + BwrapExtension({"base_dir": str(base)}).provision(profile, {}, "localhost") + assert not base.exists() diff --git a/workplans/SAND-WP-0014-owner-mediated-execution.md b/workplans/SAND-WP-0014-owner-mediated-execution.md index cb37b5c..23195f1 100644 --- a/workplans/SAND-WP-0014-owner-mediated-execution.md +++ b/workplans/SAND-WP-0014-owner-mediated-execution.md @@ -8,7 +8,7 @@ status: active owner: codex topic_slug: owner-mediated-execution created: "2026-09-04" -updated: "2026-09-04" +updated: "2026-09-05" state_hub_workstream_id: "b616d1cd-208f-5ecf-a4a0-a028396422c4" --- @@ -112,9 +112,16 @@ destroy the workspace, then update Glas readiness and Activity Core `ACTIVITY-WP-0032-T05`. Do not trigger the production pilot before readiness changes. -This task depends on a reviewed Glas profile revision, the owner-fronted -`rein-openweights-openrouter-approle` read, and a deployed sand-boxer owner -service. No credential value belongs in this workplan or State Hub. +This task depends on a reviewed Glas profile revision and its matching +credential/egress/runtime contract. Glas selected the Claude route first in +GLAS-WP-0012; the earlier OpenRouter AppRole dependency applies only to the +separate open-weight profile and does not establish Claude authentication. + +2026-09-05: SAND-WP-0015 implements pinned Python runtime mounts and private +namespace state, with a real rein CLI startup proof. Claude workload credential +routing, enforced provider egress, pinning/deploying the Claude executable, +and the real-model acceptance remain open in SAND-WP-0015-T04. No credential +value belongs in this workplan or State Hub. ## Acceptance criteria diff --git a/workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md b/workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md new file mode 100644 index 0000000..6099e09 --- /dev/null +++ b/workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md @@ -0,0 +1,109 @@ +--- +id: SAND-WP-0015 +type: workplan +title: "Provide a pinned bwrap rein runtime and private state" +domain: infotech +repo: sand-boxer +status: blocked +owner: codex +topic_slug: bwrap-runtime-and-private-state +created: "2026-09-05" +updated: "2026-09-05" +state_hub_workstream_id: "d3f12387-fd23-58f0-b979-9c811507614d" +--- + +# Provide a pinned bwrap rein runtime and private state + +Implement the runtime prerequisites returned by Glas in `GLAS-WP-0012` and +`docs/local-profile-acceptance.md`. Continue owner work `SAND-WP-0014-T05` and +live residual `GLAS-IN-0002`. Runtime startup is a separate acceptance gate +from credential delivery, enforced provider egress, and a real model task. + +## Mount an owner-selected, digest-pinned Python runtime + +```task +id: SAND-WP-0015-T01 +status: done +priority: high +state_hub_task_id: "93e4aad8-ba9b-5850-93d4-b5bf78ac8978" +``` + +Add exact artifact verification to trusted extension configuration and mount +the standalone runtime read-only at a fixed namespace path. Reject altered +content/modes, unexpected files, escaping symlinks, special files, and source +or workspace overlap. A caller cannot choose runtime paths through exec input. +Provide a non-editable rein-aharness/llm-connect bundle builder with recorded +source revisions and resolved dependency versions. + +Completed 2026-09-05. `extensions/runtime.py` verifies complete artifact +contents/modes before a read-only mount; the builder installed committed +rein-aharness `1429db5` and llm-connect `0056094` without editable source paths. +Candidate digest and source/package evidence are in `docs/bwrap-runtime.md`. + +## Keep writable runtime state outside the repository + +```task +id: SAND-WP-0015-T02 +status: done +priority: high +state_hub_task_id: "c8a689f8-bdf2-57a2-8521-a1c0ca44e750" +``` + +Create private mode-0700 HOME/config/cache/state/tmp directories within the +namespace, preserve them across exec requests, and remove them with teardown. +Keep the command environment sanitized and disable Python user-site/bytecode +writes. Fail closed on profile network/setup-credential declarations that the +current bwrap implementation cannot honor. + +Completed 2026-09-05. Mode-0700 namespace HOME and XDG/TMP directories sit +outside the copied Git tree. Explicit runtime PATH selection remains owner +controlled. Unsupported egress/default-allow and setup credentials now refuse +before workspace creation; regression tests cover these boundaries. + +## Verify the real rein runtime and owner regression paths + +```task +id: SAND-WP-0015-T03 +status: done +priority: high +state_hub_task_id: "1a3002f6-c6a8-59d0-9d38-942028419de5" +``` + +Run `make check`, the existing authenticated cross-request owner smoke, and a +real runtime startup smoke. Verify the actual rein CLI and adapter imports, +read-only runtime, private HOME, clean worktree, absent source, loopback-only +network, and teardown. Keep the production Glas profile blocked. + +Completed 2026-09-05. `make check`: lint clean, 132 tests passed. Authenticated +owner API smoke `223db65b` returned HTTP 200 and proved exact consumer identity, +stdin delivery, absent source, loopback-only network and complete teardown. +Pinned runtime smoke `d4de9531` ran the actual rein CLI and imported its Claude +adapter, proved read-only runtime/private HOME/clean worktree, retained private +state across a second exec, and removed the workspace. No model call or +credential acquisition occurred; T04 remains waiting. + +## Resolve Claude credentials, enforced egress, and production acceptance + +```task +id: SAND-WP-0015-T04 +status: wait +priority: high +state_hub_task_id: "58817ef6-76d9-5e34-908f-c024e6c99f93" +``` + +The 2026-09-05 routing lookup found no concrete Anthropic/Claude workload lane. +The generic OpenBao template is not a delegable lane; the OpenRouter AppRole +belongs to another rein. Credential owner railiance-platform/OpenBao plus +rein-aharness must establish a concrete Claude-compatible route and delivery/ +revocation contract before values can be requested or supplied to the runtime. + +Sand-boxer must then implement the matching enforced provider egress contract +(including DNS/TLS and denied-destination proof) and pin/deploy the actual +Claude executable. No unrestricted-network or interactive-login substitute is +permitted. Review the deployed runtime/profile with Glas, run its real-rein +acceptance fixture, and update only the proven profile's readiness. No +production schedule is enabled by the runtime-startup smoke. + +This task and SAND-WP-0014-T05 remain open until those gates pass. The detailed +return contract and runtime proof are in `docs/bwrap-runtime.md`; the live +cross-repo residual remains `GLAS-IN-0002`.