Skip to content
Merged
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
9 changes: 3 additions & 6 deletions docs/content/docs/tasks/network-policy.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,12 @@ Network policies can be specified for the following phases:
| Phase | Description | Supported environments |
| --- | --- | --- |
| `[environment]` | The baseline network policy configured at environment start time. | Any environment that supports the requested network mode |
| `[agent]` | Network access during `agent.run()` phase. This requires the environment provider to support dynamic network policy switching. This overrides the `[environment]` baseline. | `docker`⁴, `e2b`, `islo`, `modal` |
| `[verifier]` | Network access during `verify()` phase. This requires the environment provider to support dynamic network policy switching. This overrides the `[environment]` baseline. | `docker`⁴, `e2b`, `islo`, `modal` |
| `[agent]` | Network access during `agent.run()` phase. This requires the environment provider to support dynamic network policy switching. This overrides the `[environment]` baseline. | `docker`⁴, `e2b`, `islo`, `modal`¹ |
| `[verifier]` | Network access during `verify()` phase. This requires the environment provider to support dynamic network policy switching. This overrides the `[environment]` baseline. | `docker`⁴, `e2b`, `islo`, `modal`¹ |
| `[verifier.environment]` | The baseline network policy configured at verifier environment start time, when using a separate verifier environment. | Any environment that supports the requested network mode |

Baseline phases are subject to the environment supporting the requested network mode (see the table above).

⁵ Modal requires the `dynamic_network=true` environment kwarg (`--ek dynamic_network=true`).

These phases can also be specified at the step-level, when using [multi-step tasks](/docs/tasks/multi-step).

## Capabilities
Expand All @@ -90,13 +88,12 @@ Each `BaseEnvironment` implementation declares an `EnvironmentCapabilities` mode
| --- | --- | --- |
| `disable_internet` | The environment can run containers without internet access (`no-network`). | `docker`³, `daytona`, `e2b`, `langsmith`, `tensorlake`, `cwsandbox`, `wandb`, `runloop`, `modal`¹, `gke`², `ec2`, `novita`, `islo`, `blaxel` |
| `network_allowlist` | The environment can restrict egress to configured hostnames (`allowlist`). | `docker`⁴, `e2b`, `islo`, `runloop`, `modal`¹, `novita`¹, `blaxel`¹ |
| `dynamic_network_policy` | The environment can switch the active network policy after start, enabling `[agent]` and `[verifier]` phase overrides. | `docker`⁴, `e2b`, `islo`, `modal` |
| `dynamic_network_policy` | The environment can switch the active network policy after start, enabling `[agent]` and `[verifier]` phase overrides. | `docker`⁴, `e2b`, `islo`, `modal`¹ |

¹ Single-container tasks only (not in Docker Compose mode).
² Docker Compose (multi-container) tasks only.
³ Docker support requires Linux containers; Docker Windows containers do not support this network policy mode.
⁴ Docker support requires Linux containers and local Docker runtime support for the nftables kernel features used by Harbor's egress-control sidecar.
⁵ Modal requires the `dynamic_network=true` environment kwarg (`--ek dynamic_network=true`).

If a task requests a network mode the environment does not declare a capability for, Harbor rejects the trial at validation time rather than running with a weaker policy.

Expand Down
31 changes: 21 additions & 10 deletions src/harbor/environments/modal.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import tempfile
from abc import abstractmethod
from collections import OrderedDict
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from pathlib import Path
from threading import Lock
from typing import Any, override
Expand Down Expand Up @@ -883,6 +883,8 @@ def __init__(
auto_labels: bool = True,
labels: dict[str, str] | None = None,
extra_docker_compose: list[Path] | None = None,
network_policy: NetworkPolicy | None = None,
phase_network_policies: Sequence[NetworkPolicy] | None = None,
*args,
**kwargs,
):
Expand Down Expand Up @@ -921,26 +923,23 @@ def __init__(
kwargs: Model-specific settings from ``environment.kwargs`` / ``--ek``
- ``modal_vm_runtime=true``: Use vm_runtime (alpha feature).
See https://modal.com/docs/guide/vm-sandboxes for more details.
- ``dynamic_network=true``: Allow sandbox egress
networking to be dynamically configured at runtime.
"""
self._vm_runtime_enabled = parse_bool_env_value(
kwargs.get("modal_vm_runtime", False), name="modal_vm_runtime"
)
self._dynamic_network = parse_bool_env_value(
kwargs.get("dynamic_network", False), name="dynamic_network"
startup_network_policy = network_policy or NetworkPolicy()
resolved_phase_network_policies = tuple(phase_network_policies or ())
self._dynamic_network = self._requires_dynamic_network(
startup_network_policy=startup_network_policy,
phase_network_policies=resolved_phase_network_policies,
)
Comment thread
alexgshaw marked this conversation as resolved.

# Detect compose mode *before* super().__init__ which calls
# _validate_definition
self._compose_mode = (environment_dir / "docker-compose.yaml").exists() or bool(
extra_docker_compose
)
if self._dynamic_network and self._compose_mode:
raise ValueError(
"dynamic_network is only supported in Modal direct "
"(single-container) mode, not Docker Compose mode."
)
self._dynamic_network = self._dynamic_network and not self._compose_mode
# DinD mode requires host networking — cannot enforce network isolation.
self._capabilities = EnvironmentCapabilities(
gpus=not self._vm_runtime_enabled, # Not supported as of 2026-06-11
Expand All @@ -967,6 +966,8 @@ def __init__(
trial_paths=trial_paths,
task_env_config=task_env_config,
extra_docker_compose=extra_docker_compose,
network_policy=startup_network_policy,
phase_network_policies=resolved_phase_network_policies,
**kwargs,
)
self._image: Image | None = None
Expand All @@ -987,6 +988,16 @@ def __init__(
if self._vm_runtime_enabled:
self.logger.debug(f"Using vm_runtime: {self._vm_runtime_enabled}")

@staticmethod
def _requires_dynamic_network(
*,
startup_network_policy: NetworkPolicy,
phase_network_policies: Sequence[NetworkPolicy],
) -> bool:
return any(
policy != startup_network_policy for policy in phase_network_policies
)

@property
def _default_shell(self) -> str:
"""Shell available on the sandbox VM.
Expand Down
12 changes: 9 additions & 3 deletions tests/integration/environments/test_modal_network_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def _make_live_env(
tmp_path: Path,
network_policy: NetworkPolicy,
*,
dynamic_network: bool = False,
phase_network_policies: list[NetworkPolicy] | None = None,
) -> ModalEnvironment:
env_dir = tmp_path / "environment"
env_dir.mkdir()
Expand All @@ -54,7 +54,7 @@ def _make_live_env(
trial_paths=trial_paths,
task_env_config=EnvironmentConfig(),
network_policy=network_policy,
dynamic_network=dynamic_network,
phase_network_policies=phase_network_policies,
)


Expand Down Expand Up @@ -116,7 +116,13 @@ async def test_modal_dynamic_network_switching(tmp_path):
env = _make_live_env(
tmp_path,
NetworkPolicy(network_mode=NetworkMode.PUBLIC),
dynamic_network=True,
phase_network_policies=[
NetworkPolicy(
network_mode=NetworkMode.ALLOWLIST,
allowed_hosts=["example.com"],
),
NetworkPolicy(network_mode=NetworkMode.NO_NETWORK),
],
)
try:
await env.start(force_build=False)
Expand Down
43 changes: 36 additions & 7 deletions tests/unit/environments/test_modal.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def _make_env(
mounts: list[ServiceVolumeConfig] | None = None,
extra_docker_compose: list[Path] | None = None,
network_policy: NetworkPolicy | None = None,
phase_network_policies: list[NetworkPolicy] | None = None,
environment_kwargs: dict[str, object] | None = None,
) -> ModalEnvironment:
env_dir = temp_dir / "environment"
Expand Down Expand Up @@ -89,6 +90,7 @@ def _make_env(
env=task_env or {},
),
network_policy=network_policy or NetworkPolicy(network_mode=NetworkMode.PUBLIC),
phase_network_policies=phase_network_policies,
cpu_enforcement_policy=cpu_mode,
memory_enforcement_policy=memory_mode,
**extra,
Expand Down Expand Up @@ -257,23 +259,50 @@ async def _create_kwargs(env, monkeypatch) -> dict:
return sandbox_cls.create.aio.await_args.kwargs

@staticmethod
def _dynamic_env(temp_dir, policy: NetworkPolicy | None = None) -> ModalEnvironment:
def _dynamic_env(
temp_dir,
policy: NetworkPolicy | None = None,
phase_policies: list[NetworkPolicy] | None = None,
) -> ModalEnvironment:
baseline = policy or NetworkPolicy(network_mode=NetworkMode.PUBLIC)
return _make_env(
temp_dir,
network_policy=policy,
environment_kwargs={"dynamic_network": True},
network_policy=baseline,
phase_network_policies=phase_policies
or [
NetworkPolicy(
network_mode=NetworkMode.ALLOWLIST,
allowed_hosts=["api.example.com"],
)
],
)

def test_disabled_by_default(self, temp_dir):
assert _make_env(temp_dir).capabilities.dynamic_network_policy is False

def test_opt_in_advertises_dynamic_policy(self, temp_dir):
def test_mismatched_phase_policy_advertises_dynamic_policy(self, temp_dir):
assert self._dynamic_env(temp_dir).capabilities.dynamic_network_policy is True

def test_compose_mode_rejects_dynamic(self, temp_dir):
with pytest.raises(ValueError, match="dynamic_network"):
def test_matching_phase_policy_does_not_advertise_dynamic_policy(self, temp_dir):
policy = NetworkPolicy(network_mode=NetworkMode.PUBLIC)
env = _make_env(
temp_dir,
network_policy=policy,
phase_network_policies=[policy],
)
assert env.capabilities.dynamic_network_policy is False

def test_compose_mode_rejects_mismatched_phase_policy(self, temp_dir):
with pytest.raises(ValueError, match="allowlist"):
_make_env(
temp_dir, compose=True, environment_kwargs={"dynamic_network": True}
temp_dir,
compose=True,
phase_network_policies=[
NetworkPolicy(
network_mode=NetworkMode.ALLOWLIST,
allowed_hosts=["api.example.com"],
)
],
)

async def test_create_public_starts_allow_all(self, temp_dir, monkeypatch):
Expand Down