feat: enforce owner allowlisted bwrap HTTPS egress
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0726e-5232-73f2-aaca-2c05ceb62efb
This commit is contained in:
parent
d69827aaa2
commit
d477c3b5d9
11 changed files with 563 additions and 32 deletions
39
docs/bwrap-egress.md
Normal file
39
docs/bwrap-egress.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
70
scripts/smoke-bwrap-egress-manager.py
Normal file
70
scripts/smoke-bwrap-egress-manager.py
Normal 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,
|
||||
}
|
||||
)
|
||||
)
|
||||
89
scripts/smoke-bwrap-egress.py
Normal file
89
scripts/smoke-bwrap-egress.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
130
src/sandboxer/extensions/egress.py
Normal file
130
src/sandboxer/extensions/egress.py
Normal 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()
|
||||
|
|
@ -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
115
tests/test_egress.py
Normal 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)
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue