feat: enforce owner allowlisted bwrap HTTPS egress
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0726e-5232-73f2-aaca-2c05ceb62efb
This commit is contained in:
tegwick 2026-09-05 22:08:18 +02:00
parent d69827aaa2
commit d477c3b5d9
11 changed files with 563 additions and 32 deletions

39
docs/bwrap-egress.md Normal file
View file

@ -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.

View file

@ -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.

View file

@ -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,
}
)
)

View file

@ -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()

View file

@ -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)

View file

@ -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():

View file

@ -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)}

View file

@ -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()

View file

@ -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()

115
tests/test_egress.py Normal file
View file

@ -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)

View file

@ -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.