Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion docs/sandbox-hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,15 @@ See [task authoring](./task-authoring-task-md.md#network-policy) for the field r

6. **Block log.** Each refused attempt is appended to a root-owned log that benchflow downloads to `trajectory/egress_denylist.jsonl` in the rollout directory at cleanup: one JSON object per line with `ts`, `action`, `method`, `url`, and `rule` (`host:<host>`, `url:<host><path>`, or `ip-literal`). For a refused `CONNECT`, `url` holds the `host:port` the client asked for.

Matching ignores scheme, port, query string, and case, strips a leading `www.`, and compares a normalized path: percent-encoding is decoded (repeatedly), `.` and `..` segments are resolved, duplicate slashes and backslashes collapse, and `;` path parameters are dropped, so `/abs/../abs/2401.12345` and `/abs/%2e%2e/abs/2401.12345` match the same entry as `/abs/2401.12345`. A `blocked_urls` entry blocks every path under it; a `blocked_hosts` entry blocks the host and its subdomains. Requests to addresses are refused in every notation a resolver accepts (dotted, decimal, hex, octal) and through wildcard DNS names that embed an address (`1-2-3-4.sslip.io`), so a blocked host cannot be reached by its address. A name the agent controls that resolves to the blocked address is not detected; that is the inherent limit of a hostname denylist. Before connecting anywhere, the proxy resolves the destination and refuses names that resolve to loopback, private, link-local, or other non-global addresses (cloud metadata included), so a hostname the agent controls cannot turn the root proxy into a bridge to sandbox-internal or host services. The uid firewall stays for the rest of the sandbox life, as in the no-web mode: a later oracle role in the same sandbox, and a verifier configured with `verifier.user` equal to the sandbox user, run without egress.
The controller also registers the exact `127.0.0.1:<port>` endpoint of its local
model gateway with the proxy. Clients such as Gemini's Undici `ProxyAgent`
ignore `NO_PROXY` and tunnel even HTTP model calls through the egress proxy.
Both direct and proxied requests can reach that one endpoint. This exception
comes from the running provider gateway, never task metadata or agent-supplied
environment variables; other IP addresses and private destinations remain
blocked. Each reconnect registers the current gateway port.

Matching ignores scheme, port, query string, and case, strips a leading `www.`, and compares a normalized path: percent-encoding is decoded (repeatedly), `.` and `..` segments are resolved, duplicate slashes and backslashes collapse, and `;` path parameters are dropped, so `/abs/../abs/2401.12345` and `/abs/%2e%2e/abs/2401.12345` match the same entry as `/abs/2401.12345`. A `blocked_urls` entry blocks every path under it; a `blocked_hosts` entry blocks the host and its subdomains. Apart from the registered model gateway, requests to addresses are refused in every notation a resolver accepts (dotted, decimal, hex, octal) and through wildcard DNS names that embed an address (`1-2-3-4.sslip.io`), so a blocked host cannot be reached by its address. A name the agent controls that resolves to the blocked address is not detected; that is the inherent limit of a hostname denylist. For every other destination, the proxy resolves its address and refuses names that resolve to loopback, private, link-local, or other non-global addresses (cloud metadata included), so a hostname the agent controls cannot turn the root proxy into a bridge to sandbox-internal or host services. The uid firewall stays for the rest of the sandbox life, as in the no-web mode: a later oracle role in the same sandbox, and a verifier configured with `verifier.user` equal to the sandbox user, run without egress.

### Requirements

Expand Down
6 changes: 2 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ dependencies = [
"pydantic>=2.7",
"pyyaml>=6.0",
"rich>=13.0",
# Denylist egress mints TLS certificates on both Docker and Daytona.
"cryptography>=44",
# Dataset registry bench_version checks (PEP 440 specifier sets).
"packaging>=24",
"litellm[proxy]==1.91.0",
Expand Down Expand Up @@ -100,10 +102,6 @@ sandbox-agentcore = [
# (InvokeAgentRuntimeCommand). The boto3 floor is the first release that
# models the bedrock-agentcore/-control services.
"bedrock-agentcore>=1.18",
# Used directly by the sealed AgentCore transport (RSA-OAEP key wrap +
# AES-CTR/HMAC). It otherwise arrives only transitively via
# litellm[proxy]; declaring it here keeps the dependency honest.
"cryptography>=44",
"boto3>=1.43.31",
# Docker-compatible build-context filtering without requiring a daemon.
"pathspec>=0.12",
Expand Down
7 changes: 6 additions & 1 deletion src/benchflow/contracts/planes.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,12 @@ async def link_skill_paths(self, *args: Any, **kwargs: Any) -> None: ...
async def ensure_litellm_runtime(self, *args: Any, **kwargs: Any) -> Any: ...
async def stop_provider_runtime(self, runtime: Any) -> None: ...
async def start_egress_denylist(
self, env: Any, sandbox_user: str | None, denylist: EgressDenylist
self,
env: Any,
sandbox_user: str | None,
denylist: EgressDenylist,
*,
model_gateway_url: str | None = None,
) -> None: ...
async def stop_egress_denylist(self, env: Any, rollout_dir: Path) -> None: ...
def extract_usage(self, runtime: Any) -> dict[str, Any]: ...
Expand Down
6 changes: 5 additions & 1 deletion src/benchflow/rollout/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1290,8 +1290,12 @@ def _session_factory_entrypoint(self, agent_name: str) -> str | None:

async def _start_egress_denylist(self, denylist: EgressDenylist) -> None:
"""(Re)start the egress proxy before an ACP connection; a restored sandbox has none running."""
runtime = self._usage_runtime
await self._planes.start_egress_denylist(
self._env, self._config.sandbox_user, denylist
self._env,
self._config.sandbox_user,
denylist,
model_gateway_url=runtime.agent_base_url if runtime is not None else None,
)

async def connect(self) -> None:
Expand Down
11 changes: 9 additions & 2 deletions src/benchflow/rollout_planes.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,16 @@ async def stop_provider_runtime(self, runtime: Any) -> None:
await stop_provider_runtime(runtime)

async def start_egress_denylist(
self, env: Any, sandbox_user: str | None, denylist: EgressDenylist
self,
env: Any,
sandbox_user: str | None,
denylist: EgressDenylist,
*,
model_gateway_url: str | None = None,
) -> None:
await start_egress_denylist(env, sandbox_user, denylist)
await start_egress_denylist(
env, sandbox_user, denylist, model_gateway_url=model_gateway_url
)

async def stop_egress_denylist(self, env: Any, rollout_dir: Path) -> None:
await stop_egress_denylist(env, rollout_dir)
Expand Down
44 changes: 35 additions & 9 deletions src/benchflow/sandbox/_egress_denylist_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,19 @@ def _looks_like_address(host: str) -> bool:
class Policy:
"""Match hosts and URLs against the denylist; scheme, port and query are ignored."""

def __init__(self, blocked_urls: list[str], blocked_hosts: list[str]):
def __init__(
self,
blocked_urls: list[str],
blocked_hosts: list[str],
model_gateway_port: int | None = None,
):
# Controller-supplied runtime state, never a task-authored allowlist.
if model_gateway_port is not None and (
type(model_gateway_port) is not int
or not 1024 <= model_gateway_port <= 65535
):
raise ValueError("invalid model gateway port")
self.model_gateway_port = model_gateway_port
self.prefixes: list[tuple[str, str]] = []
for raw in blocked_urls:
url = raw if "://" in raw else "https://" + raw
Expand All @@ -118,9 +130,12 @@ def load(cls, path: str) -> Policy:
return cls(
list(data.get("blocked_urls") or []),
list(data.get("blocked_hosts") or []),
data.get("model_gateway_port"),
)

def host_rule(self, host: str) -> str | None:
def host_rule(self, host: str, port: int = 0) -> str | None:
if (host, port) == ("127.0.0.1", self.model_gateway_port):
return None
name = host.strip().rstrip(".").lower()
if _looks_like_address(name):
return "ip-literal"
Expand All @@ -129,8 +144,8 @@ def host_rule(self, host: str) -> str | None:
return f"host:{blocked}"
return None

def url_rule(self, host: str, path: str) -> str | None:
rule = self.host_rule(host)
def url_rule(self, host: str, path: str, port: int = 0) -> str | None:
rule = self.host_rule(host, port)
if rule:
return rule
key, pkey = host_key(host), _path_key(path)
Expand Down Expand Up @@ -230,8 +245,15 @@ def _upstream_allowed(address: str) -> bool:
return False


def _connect_upstream(host: str, port: int) -> socket.socket:
def _connect_upstream(
host: str, port: int, *, model_gateway_port: int | None = None
) -> socket.socket:
"""Connect to a vetted address of ``host``; the root proxy must not reach sandbox-internal services."""
# Gemini's Undici ProxyAgent ignores NO_PROXY, even for the local model
# gateway. Permit only the endpoint BenchFlow created; do not resolve a
# hostname or expose any other private address/loopback port.
if (host, port) == ("127.0.0.1", model_gateway_port):
return socket.create_connection((host, port), timeout=HEAD_TIMEOUT)
addresses = _resolve(host, port)
if not addresses or not all(_upstream_allowed(a) for a in addresses):
raise _PrivateDestination(host)
Expand Down Expand Up @@ -363,7 +385,7 @@ def _connect(self, target: str, early: bytes) -> None:
host, _, port_s = target.rpartition(":")
host = host.strip("[]").rstrip(".").lower()
port = int(port_s) if port_s.isdigit() else 443
rule = self.proxy.policy.host_rule(host)
rule = self.proxy.policy.host_rule(host, port)
if rule:
self._deny(self.request, "CONNECT", f"{host}:{port}", rule)
return
Expand All @@ -378,7 +400,9 @@ def _connect(self, target: str, early: bytes) -> None:
)
return
try:
upstream = _connect_upstream(host, port)
upstream = _connect_upstream(
host, port, model_gateway_port=self.proxy.policy.model_gateway_port
)
except _PrivateDestination:
self._deny(self.request, "CONNECT", f"{host}:{port}", "private-address")
return
Expand Down Expand Up @@ -422,15 +446,17 @@ def _forward(
if not host:
sock.sendall(_response("400 Bad Request", "absolute URL required\n"))
return
rule = self.proxy.policy.url_rule(host, path)
rule = self.proxy.policy.url_rule(host, path, port)
if rule:
self._deny(sock, method, url, rule)
return
headers = [(n, v) for n, v in headers if n.lower() != "host"]
headers.insert(0, ("Host", authority))
rest = _body_prefix(headers, rest)
try:
upstream = _connect_upstream(host, port)
upstream = _connect_upstream(
host, port, model_gateway_port=self.proxy.policy.model_gateway_port
)
if secure:
upstream = self.proxy.upstream_ctx.wrap_socket(
upstream, server_hostname=host
Expand Down
19 changes: 19 additions & 0 deletions src/benchflow/sandbox/egress_denylist.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,15 +279,34 @@ async def start_egress_denylist(
sandbox_user: str | None,
denylist: EgressDenylist,
*,
model_gateway_url: str | None = None,
timeout_sec: int = 120,
) -> None:
"""Upload policy, certificates and the proxy script, then start the proxy as root."""
if not sandbox_user:
raise RuntimeError("network_mode='denylist' requires a sandbox_user")
gateway_port = None
if model_gateway_url is not None:
gateway = urllib.parse.urlsplit(model_gateway_url)
if (
gateway.scheme != "http"
or gateway.hostname != "127.0.0.1"
or gateway.username is not None
or gateway.path not in ("", "/")
or gateway.query
or gateway.fragment
or gateway.port is None
or not 1024 <= gateway.port <= 65535
):
raise ValueError(
"denylist model gateway must be a controller-owned loopback HTTP endpoint"
)
gateway_port = gateway.port
material = certificate_material(denylist.inspect_hosts)
policy = {
"blocked_urls": list(denylist.blocked_urls),
"blocked_hosts": list(denylist.blocked_hosts),
"model_gateway_port": gateway_port,
}
files = {
"policy.json": json.dumps(policy, indent=2).encode("utf-8"),
Expand Down
2 changes: 1 addition & 1 deletion tests/test_egress_denylist.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ def tls_server(host: str) -> http.server.ThreadingHTTPServer:
"plain.test": plain.server_address[1],
}

def fake_connect_upstream(host, port):
def fake_connect_upstream(host, port, *, model_gateway_port=None):
if host == "internal.test":
raise proxy_mod._PrivateDestination(host)
return socket.create_connection(("127.0.0.1", ports[host]), timeout=10)
Expand Down
Loading
Loading