diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index a51aff0..dc2f581 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -22,7 +22,6 @@ | 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 | @@ -118,7 +117,3 @@ | 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-egress.md b/docs/bwrap-egress.md deleted file mode 100644 index 869e8d0..0000000 --- a/docs/bwrap-egress.md +++ /dev/null @@ -1,39 +0,0 @@ -# Bwrap HTTPS egress - -Owner extension config can set `allowed_egress: [api.anthropic.com:443]`. -A profile must declare a subset in `network.egress`, with `default: deny`. -Defaults remain empty. Only exact lowercase DNS names ending in `:443` -are accepted; no wildcards, IP literals, URLs or other ports. - -Each opted-in sandbox gets a separate host-side Unix CONNECT proxy. Only that -socket is mounted into the sandbox; an in-namespace loopback bridge provides -HTTPS_PROXY/https_proxy to child processes. The namespace retains only lo, -without a host interface or direct DNS/network access. The owner proxy resolves -the declared hostname, rejects any non-global result, and connects to a checked -numeric address without a second lookup. TLS stays between client and provider; -system CA certificates are mounted read-only. No TLS interception or body logging. - -This enforces connection destinations, not HTTP paths, provider account identity, -or TLS SNI on shared hosting. A client can send arbitrary bytes to an allowed -server. It is not an application firewall or a defense against an allowed -provider's own forwarding features. A future stronger policy needs separate -application enforcement. DNS resolution uses the trusted host resolver. - -Up to 16 concurrent tunnels per sandbox, 8 KiB CONNECT headers, 10-second -socket operations, 30-second idle and 900-second tunnel lifetime. Unsupported -methods, hosts, ports and request framing close without upstream dialing. -Teardown kills the dedicated proxy process and removes its socket directory. -The current host owner must remain trusted; no caller-selected proxy config. - -Validation: `scripts/smoke-bwrap-egress.py` made a credential-free TLS GET to -api.anthropic.com from sandbox e290e788 and received HTTP 404. Undeclared -example.com and direct 1.1.1.1:443 were denied, only lo existed, and proxy plus -workspace teardown passed. This is transport evidence, not model authentication. -Unit tests cover invalid destinations, private and mapped-loopback DNS, -checked-address dialing, CONNECT framing and owner/profile allowlist separation. - -Claude documents HTTPS_PROXY at https://code.claude.com/docs/en/network-config. -The pinned Claude executable must still pass its own proxy/startup compatibility -proof. The existing production profiles are unchanged. CCR-2026-0016 custody is -complete at version 2, but owner machine authentication and protected credential -delivery remain outstanding under SAND-WP-0015-T04 and GLAS-WP-0012-T02. diff --git a/docs/bwrap-runtime.md b/docs/bwrap-runtime.md deleted file mode 100644 index 2ec17cd..0000000 --- a/docs/bwrap-runtime.md +++ /dev/null @@ -1,124 +0,0 @@ -# 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 refuses `network.default: allow` and setup secret references. Network -egress is opt-in through the owner allowlist described in bwrap-egress.md; -empty-egress profiles retain loopback-only networking. There is still 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 deleted file mode 100644 index 66ee0fa..0000000 --- a/scripts/build-rein-runtime.py +++ /dev/null @@ -1,67 +0,0 @@ -"""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-egress-manager.py b/scripts/smoke-bwrap-egress-manager.py deleted file mode 100644 index 2328585..0000000 --- a/scripts/smoke-bwrap-egress-manager.py +++ /dev/null @@ -1,70 +0,0 @@ -import importlib.util -import json -import os -import tempfile -from pathlib import Path -from unittest.mock import patch - -from sandboxer.core.manager import SandboxManager -from sandboxer.extensions.bwrap import BwrapExtension -from sandboxer.lifecycle.store import SandboxStore -from sandboxer.models import Extension, Profile, SandboxCreateRequest, SandboxExecRequest - -spec = importlib.util.spec_from_file_location( - "proof", Path(__file__).with_name("smoke-bwrap-egress.py") -) -proof = importlib.util.module_from_spec(spec) -spec.loader.exec_module(proof) -os.environ["SANDBOXER_NO_STATE_HUB"] = "1" -with tempfile.TemporaryDirectory(prefix="glas-managed-egress-") as directory: - root = Path(directory) - profile = Profile( - id="profile.egress-proof", - version="1", - extension="ext.bwrap", - network={"default": "deny", "egress": ["api.anthropic.com:443"]}, - ) - ext = Extension( - id="ext.bwrap", - title="proof", - handler="sandboxer.extensions.bwrap:BwrapExtension", - config={"base_dir": str(root / "sandboxes"), "allowed_egress": ["api.anthropic.com:443"]}, - ) - consumer = {"actor": "agt", "project": "glas-harness", "run_id": "egress-proof"} - with ( - patch("sandboxer.core.manager.load_profile", return_value=profile), - patch("sandboxer.core.manager.resolve_extension", return_value=ext), - patch("sandboxer.core.manager.load_extension", return_value=ext), - ): - store = SandboxStore(path=root / "sandboxes.json") - mgr = SandboxManager(store=store) - created = mgr.create( - SandboxCreateRequest(profile=profile.id, consumer=consumer), host="localhost" - ) - try: - # Different manager and store instances reconstruct the persisted owner handle. - resumed = SandboxManager(store=SandboxStore(path=root / "sandboxes.json")) - result = resumed.execute( - created.sandbox_id, - SandboxExecRequest( - command=["python3", "-c", proof.PROBE], consumer=consumer, timeout_seconds=40 - ), - ) - assert result.exit_code == 0, result.stderr - finally: - SandboxManager(store=SandboxStore(path=root / "sandboxes.json")).destroy( - created.sandbox_id - ) - assert not Path(created.inputs["egress_dir"]).exists() - assert not BwrapExtension._pid_alive(int(created.inputs["egress_pid"])) - print( - json.dumps( - { - "sandbox_id": created.sandbox_id, - "manager_restart": True, - "proof": json.loads(result.stdout), - "proxy_removed": True, - "model_call": False, - } - ) - ) diff --git a/scripts/smoke-bwrap-egress.py b/scripts/smoke-bwrap-egress.py deleted file mode 100644 index 89a9488..0000000 --- a/scripts/smoke-bwrap-egress.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Non-secret HTTPS reachability and denied egress proof.""" - -import json -import tempfile -from pathlib import Path - -from sandboxer.extensions.bwrap import BwrapExtension -from sandboxer.models import Profile - -PROBE = r""" -import json, os, socket, ssl, urllib.request, urllib.error -from pathlib import Path -proxy = os.environ['HTTPS_PROXY'] -# GET without any credential: TLS and a provider HTTP response suffice. -client = urllib.request.build_opener(urllib.request.ProxyHandler({'https': proxy})) -try: - response = client.open('https://api.anthropic.com/', timeout=20) - status = response.status - response.close() -except urllib.error.HTTPError as error: - status = error.code -assert 100 <= status <= 599 -try: - client.open('https://example.com/', timeout=5) -except (urllib.error.URLError, OSError): - denied = True -else: - raise AssertionError('undeclared destination reachable') -try: - socket.create_connection(('1.1.1.1',443),timeout=2) -except OSError: - direct_denied = True -else: - raise AssertionError('direct network reachable') -interfaces = Path('/proc/net/dev').read_text().splitlines()[2:] -assert [line.split(':')[0].strip() for line in interfaces] == ['lo'] -print(json.dumps({'provider_http_status':status, 'tls_verified':True, - 'undeclared_host_denied':denied, 'direct_network_denied':direct_denied})) -""" - - -def main(): - with tempfile.TemporaryDirectory(prefix="sandboxer-egress-proof-") as directory: - extension = BwrapExtension( - { - "base_dir": directory, - "allowed_egress": ["api.anthropic.com:443"], - "ro_binds": ["/usr", "/bin", "/lib", "/lib64", "/etc/ssl/certs"], - } - ) - profile = Profile( - id="profile.egress-proof", - version="1", - extension="ext.bwrap", - network={"default": "deny", "egress": ["api.anthropic.com:443"]}, - ) - handle = extension.provision(profile, {}, "localhost") - try: - extension.wait_ready(handle) - result = extension.execute( - handle, - ["python3", "-c", PROBE], - credential_route_refs=[], - execution_context={}, - timeout_seconds=40, - max_output_bytes=4096, - ) - finally: - teardown = extension.teardown(handle) - assert result["exit_code"] == 0, result - assert teardown["workspace_removed"] == "True" - assert not Path(handle["egress_dir"]).exists() - assert not extension._pid_alive(int(handle["egress_pid"])) - print( - json.dumps( - { - "sandbox_id": handle["sandbox_id"], - "proof": json.loads(result["stdout"]), - "proxy_removed": True, - "workspace_removed": True, - "model_call": False, - }, - indent=2, - ) - ) - - -if __name__ == "__main__": - main() diff --git a/scripts/smoke-bwrap-runtime.py b/scripts/smoke-bwrap-runtime.py deleted file mode 100644 index 476bc20..0000000 --- a/scripts/smoke-bwrap-runtime.py +++ /dev/null @@ -1,105 +0,0 @@ -"""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/core/manager.py b/src/sandboxer/core/manager.py index af41c14..ad6ae79 100644 --- a/src/sandboxer/core/manager.py +++ b/src/sandboxer/core/manager.py @@ -75,8 +75,6 @@ class SandboxManager: "provider_sandbox_id": status.inputs.get("provider_sandbox_id", ""), "provider": status.inputs.get("provider", ""), "pid": status.inputs.get("pid", ""), - "egress_pid": status.inputs.get("egress_pid", ""), - "egress_dir": status.inputs.get("egress_dir", ""), "workspace_dir": status.inputs.get("workspace_dir", ""), } @@ -168,8 +166,6 @@ class SandboxManager: status.inputs["provider_sandbox_id"] = handle.get("provider_sandbox_id", "") status.inputs["provider"] = handle.get("provider", "") status.inputs["pid"] = handle.get("pid", "") - status.inputs["egress_pid"] = handle.get("egress_pid", "") - status.inputs["egress_dir"] = handle.get("egress_dir", "") status.inputs["workspace_dir"] = handle.get("workspace_dir", "") reach = backend.wait_ready(handle) reach = enrich_reachability(reach, profile, handle) diff --git a/src/sandboxer/extensions/bwrap.py b/src/sandboxer/extensions/bwrap.py index 7bdbee8..e57e7d1 100644 --- a/src/sandboxer/extensions/bwrap.py +++ b/src/sandboxer/extensions/bwrap.py @@ -9,15 +9,12 @@ import shutil import signal import socket import subprocess -import sys import time from contextlib import suppress from pathlib import Path from typing import Any from sandboxer.extensions.base import SandboxExtension -from sandboxer.extensions.egress import destinations -from sandboxer.extensions.runtime import RUNTIME_MOUNT, verified_runtime from sandboxer.models import Profile @@ -38,9 +35,11 @@ class BwrapExtension(SandboxExtension): cfg = self.config self.base_dir: str = cfg.get("base_dir", "/tmp/sandboxer-bwrap") self.bwrap_bin: str = cfg.get("bwrap_bin", "bwrap") - self.control_socket_name: str = cfg.get("control_socket_name", ".sandboxer-owner.sock") + self.control_socket_name: str = cfg.get( + "control_socket_name", ".sandboxer-owner.sock" + ) self.ro_binds: list[str] = cfg.get( - "ro_binds", ["/usr", "/bin", "/lib", "/lib64", "/etc/resolv.conf", "/etc/ssl/certs"] + "ro_binds", ["/usr", "/bin", "/lib", "/lib64", "/etc/resolv.conf"] ) def _bwrap_bin(self) -> str: @@ -49,9 +48,7 @@ class BwrapExtension(SandboxExtension): def _existing_ro_binds(self) -> list[str]: return [path for path in self.ro_binds if Path(path).exists()] - def _bwrap_argv( - self, workspace_dir: str, *, info_fd: int | None = None, egress_socket: str | None = None - ) -> list[str]: + def _bwrap_argv(self, workspace_dir: str, *, info_fd: int | None = None) -> list[str]: argv = [ self._bwrap_bin(), "--unshare-user", @@ -75,17 +72,6 @@ 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"] - if egress_socket: - egress_module = Path(__file__).with_name("egress.py") - argv += ["--ro-bind", str(egress_module), "/run/sandboxer/egress.py"] - argv += ["--ro-bind", egress_socket, "/run/sandboxer/egress.sock"] - 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 += [ @@ -94,10 +80,6 @@ class BwrapExtension(SandboxExtension): workspace_dir, f"{workspace_dir}/{self.control_socket_name}", ] - if runtime is not None: - argv.append("--runtime") - if egress_socket: - argv.append("--egress") return argv @staticmethod @@ -117,20 +99,9 @@ class BwrapExtension(SandboxExtension): raise RuntimeError("bwrap reported an invalid namespace child pid") return child_pid - def provision(self, profile: Profile, inputs: dict[str, str], host: str) -> dict[str, str]: - if profile.network.default != "deny": - raise ValueError("bwrap requires default-deny networking") - requested = destinations(profile.network.egress) - allowed = destinations(self.config.get("allowed_egress", [])) - if not requested.issubset(allowed): - raise ValueError("profile egress exceeds owner allowlist") - 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") + def provision( + self, profile: Profile, inputs: dict[str, str], host: str + ) -> dict[str, str]: sandbox_id = self.new_sandbox_id(inputs) workspace_dir = f"{self.base_dir}/{sandbox_id}" Path(workspace_dir).mkdir(parents=True, exist_ok=True) @@ -143,60 +114,22 @@ class BwrapExtension(SandboxExtension): shutil.copytree(repo_path, workspace_dir, dirs_exist_ok=True) Path(workspace_dir).chmod(0o700) - proxy = None - proxy_dir = None - egress_socket = None - if requested: - import tempfile - - proxy_dir = tempfile.mkdtemp(prefix="sandboxer-egress-") - egress_socket = str(Path(proxy_dir) / "proxy.sock") - proxy = subprocess.Popen( - [ - sys.executable, - str(Path(__file__).with_name("egress.py")), - egress_socket, - *sorted(requested), - ], + info_read_fd, info_write_fd = os.pipe() + try: + argv = self._bwrap_argv(workspace_dir, info_fd=info_write_fd) + proc = subprocess.Popen( + argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, + pass_fds=(info_write_fd,), ) - deadline = time.monotonic() + 5 - while not Path(egress_socket).is_socket(): - if proxy.poll() is not None or time.monotonic() >= deadline: - proxy.kill() - proxy.wait() - shutil.rmtree(proxy_dir) - raise RuntimeError("egress proxy failed to start") - time.sleep(0.02) - + finally: + os.close(info_write_fd) try: - info_read_fd, info_write_fd = os.pipe() - try: - argv = self._bwrap_argv( - workspace_dir, info_fd=info_write_fd, egress_socket=egress_socket - ) - proc = subprocess.Popen( - argv, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, - pass_fds=(info_write_fd,), - ) - finally: - os.close(info_write_fd) - try: - child_pid = self._read_child_pid(proc, info_read_fd) - finally: - os.close(info_read_fd) - except BaseException: - if proxy: - proxy.kill() - proxy.wait() - if proxy_dir: - shutil.rmtree(proxy_dir, ignore_errors=True) - raise + child_pid = self._read_child_pid(proc, info_read_fd) + finally: + os.close(info_read_fd) return { "sandbox_id": sandbox_id, @@ -204,18 +137,9 @@ class BwrapExtension(SandboxExtension): "pid": str(child_pid), "supervisor_pid": str(proc.pid), "workspace_dir": workspace_dir, - **({"egress_pid": str(proxy.pid), "egress_dir": proxy_dir} if proxy else {}), } def wait_ready(self, handle: dict[str, str]) -> dict[str, str]: - try: - return self._wait_ready(handle) - except Exception: - if handle.get("egress_pid"): - self.teardown(handle) - raise - - def _wait_ready(self, handle: dict[str, str]) -> dict[str, str]: pid = int(handle["pid"]) if not self._pid_alive(pid): raise RuntimeError(f"bwrap process {pid} is not running") @@ -309,13 +233,6 @@ class BwrapExtension(SandboxExtension): os.kill(pid, signal.SIGKILL) killed = True - if handle.get("egress_pid"): - with suppress(ProcessLookupError): - os.killpg(int(handle["egress_pid"]), signal.SIGKILL) - with suppress(ChildProcessError): - os.waitpid(int(handle["egress_pid"]), 0) - if handle.get("egress_dir"): - shutil.rmtree(handle["egress_dir"], ignore_errors=True) workspace_dir = handle.get("workspace_dir", "") removed = False if workspace_dir and Path(workspace_dir).exists(): diff --git a/src/sandboxer/extensions/bwrap_runner.py b/src/sandboxer/extensions/bwrap_runner.py index 5f88415..9ef720c 100644 --- a/src/sandboxer/extensions/bwrap_runner.py +++ b/src/sandboxer/extensions/bwrap_runner.py @@ -23,9 +23,7 @@ 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, *, runtime_enabled: bool = False, proxy_port: int | None = None -) -> dict[str, object]: +def _run(payload: dict, workspace: Path) -> dict[str, object]: command = payload["command"] timeout_seconds = int(payload["timeout_seconds"]) max_output_bytes = int(payload["max_output_bytes"]) @@ -33,23 +31,12 @@ def _run( context = payload.get("execution_context", {}) stdin_text = payload.get("stdin_text") child_env = { - "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", + "HOME": str(workspace), "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"] - if proxy_port is not None: - child_env["HTTPS_PROXY"] = f"http://127.0.0.1:{proxy_port}" - child_env["https_proxy"] = child_env["HTTPS_PROXY"] timed_out = False process = subprocess.Popen( command, @@ -62,7 +49,9 @@ def _run( ) try: stdin_bytes = stdin_text.encode("utf-8") if stdin_text is not None else None - stdout_raw, stderr_raw = process.communicate(input=stdin_bytes, timeout=timeout_seconds) + stdout_raw, stderr_raw = process.communicate( + input=stdin_bytes, timeout=timeout_seconds + ) exit_code = process.returncode except subprocess.TimeoutExpired: timed_out = True @@ -84,18 +73,6 @@ def _run( def main() -> int: workspace = Path(sys.argv[1]).resolve(strict=True) socket_path = Path(sys.argv[2]) - runtime_enabled = "--runtime" in sys.argv[3:] - if any(flag not in {"--runtime", "--egress"} for flag in sys.argv[3:]): - raise ValueError("invalid owner runtime mode") - proxy_port = None - if "--egress" in sys.argv[3:]: - from egress import bridge - - proxy_port = bridge("/run/sandboxer/egress.sock") - 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)) @@ -118,12 +95,7 @@ def main() -> int: try: if not chunks: raise ValueError("empty or oversized execution request") - response = _run( - json.loads(b"".join(chunks)), - workspace, - runtime_enabled=runtime_enabled, - proxy_port=proxy_port, - ) + response = _run(json.loads(b"".join(chunks)), workspace) except Exception as exc: response = {"boundary_error": str(exc)} connection.sendall(json.dumps(response).encode("utf-8")) diff --git a/src/sandboxer/extensions/egress.py b/src/sandboxer/extensions/egress.py deleted file mode 100644 index 84f2bdf..0000000 --- a/src/sandboxer/extensions/egress.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Bounded HTTPS CONNECT egress; host-side destination enforcement.""" - -from __future__ import annotations - -import ipaddress -import select -import socket -import threading -import time -from contextlib import suppress -from pathlib import Path - - -def destinations(entries: list[str]) -> frozenset[str]: - """Only exact lowercase DNS names with an explicit TLS port.""" - import re - - if any( - not re.fullmatch(r"(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}:443", x) - for x in entries - ): - raise ValueError("egress requires exact lowercase DNS names with :443") - return frozenset(entries) - - -def connect_public(host: str) -> socket.socket: - addresses = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM) - if not addresses or any(not ipaddress.ip_address(a[4][0]).is_global for a in addresses): - raise ValueError("non-public destination") - # Connect to the already checked numeric address; never resolve a second time. - for family, kind, proto, _, address in addresses: - connection = socket.socket(family, kind, proto) - connection.settimeout(10) - try: - connection.connect(address) - return connection - except OSError: - connection.close() - raise OSError("destination unavailable") - - -def relay(left: socket.socket, right: socket.socket) -> None: - deadline = time.monotonic() + 900 - for connection in (left, right): - connection.settimeout(10) - while time.monotonic() < deadline: - ready, _, _ = select.select([left, right], [], [], 30) - if not ready: - return - for source in ready: - chunk = source.recv(65536) - if not chunk: - return - (right if source is left else left).sendall(chunk) - - -def tunnel(client: socket.socket, allowed: frozenset[str]) -> None: - client.settimeout(10) - header = bytearray() - # Do not consume any TLS bytes following CONNECT headers. - while not header.endswith(b"\r\n\r\n"): - byte = client.recv(1) - if not byte or len(header) >= 8192: - raise ValueError("invalid CONNECT header") - header.extend(byte) - lines = header.decode("ascii").split("\r\n") - request = lines[0].split(" ") - if len(request) != 3 or request[0] != "CONNECT" or request[2] != "HTTP/1.1": - raise ValueError("CONNECT required") - target = request[1] - if target not in allowed: - raise ValueError("destination denied") - # No request body or transfer framing on CONNECT. - for line in lines[1:-2]: - name, separator, value = line.partition(":") - if not separator or name.lower() not in {"host", "proxy-connection", "user-agent"}: - raise ValueError("unsupported CONNECT header") - if name.lower() == "host" and value.strip() != target: - raise ValueError("inconsistent host") - with connect_public(target[:-4]) as upstream: - client.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n") - relay(client, upstream) - - -def serve(listener: socket.socket, handler) -> None: - slots = threading.BoundedSemaphore(16) - - def worker(client): - try: - with client, suppress(OSError, ValueError, UnicodeError): - handler(client) - finally: - slots.release() - - listener.listen(16) - while True: - client, _ = listener.accept() - if not slots.acquire(blocking=False): - client.close() - continue - threading.Thread(target=worker, args=(client,), daemon=True).start() - - -def bridge(unix_path: str) -> int: - listener = socket.socket() - listener.bind(("127.0.0.1", 0)) - port = listener.getsockname()[1] - - def forward(client): - with socket.socket(socket.AF_UNIX) as upstream: - upstream.connect(unix_path) - relay(client, upstream) - - threading.Thread(target=serve, args=(listener, forward), daemon=True).start() - return port - - -def main() -> None: - import sys - - path = Path(sys.argv[1]) - allowed = destinations(sys.argv[2:]) - with socket.socket(socket.AF_UNIX) as listener: - listener.bind(str(path)) - path.chmod(0o600) - serve(listener, lambda client: tunnel(client, allowed)) - - -if __name__ == "__main__": - main() diff --git a/src/sandboxer/extensions/runtime.py b/src/sandboxer/extensions/runtime.py deleted file mode 100644 index 231fb72..0000000 --- a/src/sandboxer/extensions/runtime.py +++ /dev/null @@ -1,52 +0,0 @@ -"""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 907a86a..ed237ad 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. Commands enter through owner-mediated execute; - # the pid is lifecycle metadata, not consumer setns authority. + # like ext.bwrap. A consumer execs into the sandbox directly + # (e.g. `nsenter --target ...`) rather than over SSH. pid: str | None = None workspace_dir: str | None = None diff --git a/tests/test_bwrap.py b/tests/test_bwrap.py index 66a876a..e1453e2 100644 --- a/tests/test_bwrap.py +++ b/tests/test_bwrap.py @@ -298,20 +298,12 @@ 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 deleted file mode 100644 index fe887e7..0000000 --- a/tests/test_bwrap_runtime.py +++ /dev/null @@ -1,116 +0,0 @@ -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="default-deny|owner allowlist"): - 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/tests/test_egress.py b/tests/test_egress.py deleted file mode 100644 index e36cb89..0000000 --- a/tests/test_egress.py +++ /dev/null @@ -1,115 +0,0 @@ -import socket -from unittest.mock import patch - -import pytest - -from sandboxer.extensions.egress import connect_public, destinations, tunnel - - -@pytest.mark.parametrize( - "entry", - [ - "*", - "api.anthropic.com", - "api.anthropic.com:80", - "127.0.0.1:443", - "API.anthropic.com:443", - "api.anthropic.com.evil:443/path", - "x@:443", - ], -) -def test_invalid_destination(entry): - with pytest.raises(ValueError): - destinations([entry]) - - -@pytest.mark.parametrize( - "address", ["127.0.0.1", "10.0.0.1", "169.254.169.254", "::1", "::ffff:127.0.0.1"] -) -def test_nonpublic_dns_refused(address): - with ( - patch("socket.getaddrinfo", return_value=[(socket.AF_INET, 1, 6, "", (address, 443))]), - patch("socket.socket") as factory, - pytest.raises(ValueError), - ): - connect_public("api.anthropic.com") - factory.assert_not_called() - - -@pytest.mark.parametrize( - "wire_request", - [ - b"CONNECT evil.example:443 HTTP/1.1\r\n\r\n", - b"CONNECT api.anthropic.com:80 HTTP/1.1\r\n\r\n", - b"GET https://api.anthropic.com/ HTTP/1.1\r\n\r\n", - b"CONNECT api.anthropic.com:443 HTTP/1.1\r\nHost: evil.example:443\r\n\r\n", - b"CONNECT api.anthropic.com:443 HTTP/1.1\r\nContent-Length: 1\r\n\r\n", - ], -) -def test_denied_connect_never_dials(wire_request): - left, right = socket.socketpair() - with left, right, patch("sandboxer.extensions.egress.connect_public") as connect: - left.sendall(wire_request) - with pytest.raises(ValueError): - tunnel(right, destinations(["api.anthropic.com:443"])) - connect.assert_not_called() - - -def test_valid_connect_preserves_tls_bytes(): - left, right = socket.socketpair() - upstream, peer = socket.socketpair() - with left, right, upstream, peer: - left.sendall( - b"CONNECT api.anthropic.com:443 HTTP/1.1\r\nHost: api.anthropic.com:443\r\n\r\nTLS" - ) - with ( - patch("sandboxer.extensions.egress.connect_public", return_value=upstream) as connect, - patch("sandboxer.extensions.egress.relay") as relay, - ): - tunnel(right, destinations(["api.anthropic.com:443"])) - connect.assert_called_once_with("api.anthropic.com") - relay.assert_called_once_with(right, upstream) - assert right.recv(3) == b"TLS" - assert b"200 Connection Established" in left.recv(100) - - -def test_dns_result_is_used_without_second_resolution(): - with ( - patch( - "socket.getaddrinfo", - return_value=[(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("1.1.1.1", 443))], - ) as dns, - patch("socket.socket") as factory, - ): - assert connect_public("api.anthropic.com") is factory.return_value - dns.assert_called_once() - factory.return_value.connect.assert_called_once_with(("1.1.1.1", 443)) - - -def test_profile_cannot_expand_owner_allowlist(tmp_path): - from sandboxer.extensions.bwrap import BwrapExtension - from sandboxer.models import Profile - - ext = BwrapExtension( - {"base_dir": str(tmp_path / "unused"), "allowed_egress": ["api.anthropic.com:443"]} - ) - profile = Profile( - id="test", - version="1", - extension="ext.bwrap", - network={"default": "deny", "egress": ["example.com:443"]}, - ) - with pytest.raises(ValueError, match="owner allowlist"): - ext.provision(profile, {}, "localhost") - assert not (tmp_path / "unused").exists() - - -def test_failed_broker_readiness_removes_egress(): - from sandboxer.extensions.bwrap import BwrapExtension - ext = BwrapExtension() - handle = {"egress_pid": "123"} - with patch.object(ext, "_wait_ready", side_effect=RuntimeError("startup failed")), \ - patch.object(ext, "teardown") as cleanup: - with pytest.raises(RuntimeError, match="startup failed"): - ext.wait_ready(handle) - cleanup.assert_called_once_with(handle) diff --git a/workplans/SAND-WP-0014-owner-mediated-execution.md b/workplans/SAND-WP-0014-owner-mediated-execution.md index 23195f1..cb37b5c 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-05" +updated: "2026-09-04" state_hub_workstream_id: "b616d1cd-208f-5ecf-a4a0-a028396422c4" --- @@ -112,16 +112,9 @@ 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 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. +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. ## 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 deleted file mode 100644 index 15db26b..0000000 --- a/workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md +++ /dev/null @@ -1,119 +0,0 @@ ---- -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`. - -## 2026-09-05 transport implementation evidence - -T04 remains waiting on owner machine authentication, protected credential -delivery, pinned Claude startup and the real model proof. CCR-2026-0016 custody -is confirmed by a metadata-only check of live version 2; no value was read. -HTTPS egress is now implemented as an exact owner/profile allowlisted CONNECT -proxy while retaining an isolated network namespace. Non-secret live smoke -sandbox e290e788 verified provider TLS response, undeclared destination and -direct-IP denial, and proxy/workspace teardown. See docs/bwrap-egress.md.