diff --git a/docs/bwrap-egress.md b/docs/bwrap-egress.md new file mode 100644 index 0000000..869e8d0 --- /dev/null +++ b/docs/bwrap-egress.md @@ -0,0 +1,39 @@ +# 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 index 43e33b3..2ec17cd 100644 --- a/docs/bwrap-runtime.md +++ b/docs/bwrap-runtime.md @@ -82,9 +82,10 @@ 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 +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. diff --git a/scripts/smoke-bwrap-egress-manager.py b/scripts/smoke-bwrap-egress-manager.py new file mode 100644 index 0000000..2328585 --- /dev/null +++ b/scripts/smoke-bwrap-egress-manager.py @@ -0,0 +1,70 @@ +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 new file mode 100644 index 0000000..89a9488 --- /dev/null +++ b/scripts/smoke-bwrap-egress.py @@ -0,0 +1,89 @@ +"""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/src/sandboxer/core/manager.py b/src/sandboxer/core/manager.py index ad6ae79..af41c14 100644 --- a/src/sandboxer/core/manager.py +++ b/src/sandboxer/core/manager.py @@ -75,6 +75,8 @@ 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", ""), } @@ -166,6 +168,8 @@ 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 6b3e3a1..7bdbee8 100644 --- a/src/sandboxer/extensions/bwrap.py +++ b/src/sandboxer/extensions/bwrap.py @@ -9,12 +9,14 @@ 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 @@ -36,11 +38,9 @@ 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"] + "ro_binds", ["/usr", "/bin", "/lib", "/lib64", "/etc/resolv.conf", "/etc/ssl/certs"] ) def _bwrap_bin(self) -> str: @@ -49,7 +49,9 @@ 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) -> list[str]: + def _bwrap_argv( + self, workspace_dir: str, *, info_fd: int | None = None, egress_socket: str | None = None + ) -> list[str]: argv = [ self._bwrap_bin(), "--unshare-user", @@ -73,6 +75,10 @@ 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() @@ -90,6 +96,8 @@ class BwrapExtension(SandboxExtension): ] if runtime is not None: argv.append("--runtime") + if egress_socket: + argv.append("--egress") return argv @staticmethod @@ -109,11 +117,13 @@ 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" or profile.network.egress: - raise ValueError("bwrap currently supports only default-deny with empty egress") + 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) @@ -133,22 +143,60 @@ class BwrapExtension(SandboxExtension): shutil.copytree(repo_path, workspace_dir, dirs_exist_ok=True) Path(workspace_dir).chmod(0o700) - info_read_fd, info_write_fd = os.pipe() - try: - argv = self._bwrap_argv(workspace_dir, info_fd=info_write_fd) - proc = subprocess.Popen( - argv, + 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), + ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, - pass_fds=(info_write_fd,), ) - finally: - os.close(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) + try: - child_pid = self._read_child_pid(proc, info_read_fd) - finally: - os.close(info_read_fd) + 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 return { "sandbox_id": sandbox_id, @@ -156,9 +204,18 @@ 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") @@ -252,6 +309,13 @@ 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 01d3ce6..5f88415 100644 --- a/src/sandboxer/extensions/bwrap_runner.py +++ b/src/sandboxer/extensions/bwrap_runner.py @@ -24,7 +24,7 @@ def _bounded_output(value: bytes | None, limit: int) -> tuple[str, bool]: def _run( - payload: dict, workspace: Path, *, runtime_enabled: bool = False + payload: dict, workspace: Path, *, runtime_enabled: bool = False, proxy_port: int | None = None ) -> dict[str, object]: command = payload["command"] timeout_seconds = int(payload["timeout_seconds"]) @@ -47,6 +47,9 @@ def _run( } 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, @@ -59,9 +62,7 @@ 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 @@ -83,9 +84,14 @@ def _run( 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: + 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"): @@ -113,7 +119,10 @@ def main() -> int: if not chunks: raise ValueError("empty or oversized execution request") response = _run( - json.loads(b"".join(chunks)), workspace, runtime_enabled=runtime_enabled + json.loads(b"".join(chunks)), + workspace, + runtime_enabled=runtime_enabled, + proxy_port=proxy_port, ) except Exception as exc: response = {"boundary_error": str(exc)} diff --git a/src/sandboxer/extensions/egress.py b/src/sandboxer/extensions/egress.py new file mode 100644 index 0000000..84f2bdf --- /dev/null +++ b/src/sandboxer/extensions/egress.py @@ -0,0 +1,130 @@ +"""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/tests/test_bwrap_runtime.py b/tests/test_bwrap_runtime.py index ef02d62..fe887e7 100644 --- a/tests/test_bwrap_runtime.py +++ b/tests/test_bwrap_runtime.py @@ -88,7 +88,7 @@ def test_source_and_runtime_overlap_refuses_before_copy(artifact): 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"): + with pytest.raises(ValueError, match="default-deny|owner allowlist"): BwrapExtension({"base_dir": str(base)}).provision(profile, {}, "localhost") assert not base.exists() diff --git a/tests/test_egress.py b/tests/test_egress.py new file mode 100644 index 0000000..e36cb89 --- /dev/null +++ b/tests/test_egress.py @@ -0,0 +1,115 @@ +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-0015-bwrap-runtime-and-private-state.md b/workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md index 6099e09..15db26b 100644 --- a/workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md +++ b/workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md @@ -107,3 +107,13 @@ 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.