Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
29 changes: 16 additions & 13 deletions tests/v1/test_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,20 @@ def test_e2b_config_rejects_unenforceable_egress_rules(rule: str) -> None:
E2BConfig(allow=[rule])


@pytest.mark.parametrize("rule", ["api.example.com", "*.example.com"])
def test_e2b_config_rejects_hostname_block_rules(rule: str) -> None:
with pytest.raises(ValueError, match="block rules must be IP addresses or CIDR"):
@pytest.mark.parametrize(
"rule",
[
"api.example.com",
"*.example.com",
"10.0.0.1",
"10.0.0.0/24",
"2001:db8::1",
"2001:db8::/32",
"0.0.0.0/0",
],
)
def test_e2b_config_rejects_blocklists(rule: str) -> None:
with pytest.raises(ValueError, match="blocklists cannot preserve framework routes"):
E2BConfig(block=[rule])


Expand All @@ -60,13 +71,6 @@ def test_e2b_config_accepts_supported_egress_rules(rule: str) -> None:
assert E2BConfig(allow=[rule]).allow == [rule]


@pytest.mark.parametrize(
"rule", ["10.0.0.1", "10.0.0.0/24", "2001:db8::1", "2001:db8::/32"]
)
def test_e2b_config_accepts_supported_block_rules(rule: str) -> None:
assert E2BConfig(block=[rule]).block == [rule]


def test_e2b_egress_update_states_the_complete_policy() -> None:
# The security-critical policy table: whether a "restricted" sandbox actually
# gets a deny-everything floor, with framework routes kept reachable. No e2e
Expand All @@ -76,9 +80,6 @@ def test_e2b_egress_update_states_the_complete_policy() -> None:
unrestricted = _egress_update(E2BConfig(), None)
assert unrestricted == {"allow_internet_access": True}

blocklist = _egress_update(E2BConfig(block=["203.0.113.0/24"]), routes)
assert blocklist == {"deny_out": ["203.0.113.0/24"]}

allowlist = _egress_update(E2BConfig(allow=["api.example.com"]), routes)
assert allowlist == {
"allow_out": ["tunnel.example.com", "api.example.com"],
Expand All @@ -90,6 +91,8 @@ def test_e2b_egress_update_states_the_complete_policy() -> None:
"allow_out": ["tunnel.example.com"],
"deny_out": ["0.0.0.0/0"],
}
assert _egress_update(E2BConfig(block=["*"]), routes) == framework_only
assert _egress_update(E2BConfig(allow=[]), None) == unrestricted

no_routes = _egress_update(E2BConfig(allow=[]), [])
assert no_routes == {"allow_internet_access": False}
68 changes: 31 additions & 37 deletions verifiers/v1/runtimes/e2b.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,12 @@ def _is_address(rule: str) -> bool:


def _validate_egress_rules(allow: list[str], block: list[str]) -> None:
"""Validate the two selector shapes E2B's network API accepts: allow rules take
hostnames (a leading ``*.`` wildcard included), IPs, and CIDRs; deny rules take IPs
and CIDRs only."""
for rule in block:
if not _is_address(rule):
raise ValueError(
f"E2B block rules must be IP addresses or CIDR blocks, got {rule!r}"
)
"""Require an allowlist: E2B's domain exceptions need a deny-all floor, so a
partial blocklist cannot keep framework domains reachable."""
if block:
raise ValueError(
"E2B blocklists cannot preserve framework routes; use an allowlist or allow=[]"
)
for rule in allow:
if not (_is_address(rule) or _HOSTNAME.fullmatch(rule)):
raise ValueError(
Expand All @@ -120,14 +118,10 @@ def _egress_update(config: "E2BConfig", routes: list[str] | None) -> dict:
"""The complete egress policy for `update_network`, which replaces all rules
atomically. None restores unrestricted egress for another trusted setup phase;
otherwise `routes` (the interception and MCP endpoints) stay reachable alongside
the configured allowlist. E2B resolves an `allow_out` entry over a same-host
`deny_out` one and requires the deny-everything floor whenever `allow_out` names
domains, so an allowlist is expressed as allow + deny-everything and a blocklist
as deny only (everything else, framework routes included, stays reachable)."""
if routes is None:
the configured allowlist. E2B allows those entries through the deny-everything
floor required for domain exceptions."""
if routes is None or not config.network_restricted:
return {"allow_internet_access": True}
if config.allow == ["*"]:
return {"deny_out": list(config.block)}
hosts = list(
dict.fromkeys(
host for route in routes if (host := urlsplit(route).hostname) is not None
Expand Down Expand Up @@ -400,28 +394,28 @@ async def start(self) -> None:
self.info.template = template

async def _create() -> None:
async with (
creation_limiter(
self.config.creates_per_sec, "e2b-sandbox", run_scope()
)
or contextlib.nullcontext()
):
# Created unrestricted: setup (uv installs, task staging) needs open
# egress; `prepare_execution` locks the policy down before the agent.
self._sandbox = await AsyncSandbox.create(
template,
# Maximum lifetime of any sandbox (Pro; Base's 1h cap rejects it).
timeout=24 * 60 * 60,
metadata={"runtime": "verifiers", "name": self.name},
)
# The atexit backstop kills by id, so record it the moment the sandbox
# exists — the cancellation `run_shielded` re-raises can keep the code
# after it from ever running.
self.info.id = self._sandbox.sandbox_id

# Finish an accepted create request even if the rollout is cancelled so the
# surrounding provision_runtime finally can see and kill the sandbox.
await run_shielded(_create())
# Created unrestricted: setup (uv installs, task staging) needs open
# egress; `prepare_execution` locks the policy down before the agent.
self._sandbox = await AsyncSandbox.create(
template,
# Maximum lifetime of any sandbox (Pro; Base's 1h cap rejects it).
timeout=24 * 60 * 60,
metadata={"runtime": "verifiers", "name": self.name},
)
# The atexit backstop kills by id, so record it the moment the sandbox
# exists — the cancellation `run_shielded` re-raises can keep the code
# after it from ever running.
self.info.id = self._sandbox.sandbox_id

async with (
creation_limiter(
self.config.creates_per_sec, "e2b-sandbox", run_scope()
)
or contextlib.nullcontext()
):
# Keep the rate-limit wait cancellable; finish an accepted create so
# provision_runtime's finally can see and kill the sandbox.
await run_shielded(_create())
logger.info(
"e2b: sandbox %s up (image=%s, template=%s)",
self.info.id,
Expand Down