This is an automated email from the ASF dual-hosted git repository.
kaxil pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 55b8e9a47b9 Add an address-layer egress allowlist for hosted sandboxes
(#73534)
55b8e9a47b9 is described below
commit 55b8e9a47b9b865d1ae7e58a6a1043ccad9d18a5
Author: Kaxil Naik <[email protected]>
AuthorDate: Tue Sep 22 13:33:42 2026 +0100
Add an address-layer egress allowlist for hosted sandboxes (#73534)
The Modal backend offered two egress modes: deny everything, which is exact,
or a hostname allowlist matched on the TLS handshake name, which is weak in
ways the author cannot see and so needs an explicit opt-in. Modal has a
third
mode the backend did not expose: an address allowlist enforced on the
destination for any port and protocol, deny-by-default outside the list.
SandboxSpec gains allow_egress_to_cidrs. On Modal it maps onto
outbound_cidr_allowlist and needs no opt-in, because there is no caveat to
accept; entries are validated and normalised to canonical CIDR form, since
Modal passes the list through unchecked and a hostname or a range with host
bits set would silently mean something other than what was written. It can
be
combined with the hostname list, which Modal applies additively. sbx refuses
the field, having no per-sandbox address rule.
Measured live: a listed address connects on 443 and 53, an unlisted one is
dropped so the client times out, and hostnames still resolve through Modal's
own resolver. The tool description tells the model both facts, so a
successful
lookup is not read as a reachable host. The system test gains a task that
checks all three on a real sandbox.
---
providers/common/ai/docs/sandbox/backends.rst | 51 +++++++-
providers/common/ai/docs/sandbox/configuration.rst | 14 +-
providers/common/ai/docs/sandbox/index.rst | 4 +-
.../airflow/providers/common/ai/sandbox/base.py | 11 ++
.../airflow/providers/common/ai/sandbox/modal.py | 142 +++++++++++++++++----
.../src/airflow/providers/common/ai/sandbox/sbx.py | 8 ++
.../providers/common/ai/toolsets/sandbox.py | 21 ++-
.../common/ai/example_sandbox_toolset_modal.py | 71 ++++++++++-
.../ai/tests/unit/common/ai/sandbox/test_modal.py | 141 ++++++++++++++++++++
.../ai/tests/unit/common/ai/sandbox/test_sbx.py | 8 ++
.../tests/unit/common/ai/toolsets/test_sandbox.py | 35 +++++
11 files changed, 468 insertions(+), 38 deletions(-)
diff --git a/providers/common/ai/docs/sandbox/backends.rst
b/providers/common/ai/docs/sandbox/backends.rst
index 0843cf69270..119be4a0fbf 100644
--- a/providers/common/ai/docs/sandbox/backends.rst
+++ b/providers/common/ai/docs/sandbox/backends.rst
@@ -97,6 +97,54 @@ Entries must be bare hostnames or one leading ``*.`` label;
a URL, a ``host:port
an address or a single-label name is refused, because Modal applies the list
without
checking it and any of those would silently match nothing.
+**An address allowlist is enforced properly, and needs no opt-in.**
+``allow_egress_to_cidrs`` maps onto Modal's ``outbound_cidr_allowlist``, which
+decides on the destination address for any port and protocol. Measured on
+2026-09-22 with ``["1.1.1.1/32"]``: the listed address connected on 443 and on
53,
+an unlisted address timed out on both, and ``block_network`` with the list set
was
+refused at create, as with the hostname list. This is the right mode for one
+service at a fixed public address, which is the case the hostname list serves
+worst. It cannot serve a package registry behind a CDN, whose addresses rotate
+faster than a sandbox lives.
+
+.. code-block:: python
+
+ SandboxToolset(
+ ModalSandboxBackend(),
+ spec=SandboxSpec(block_network=True,
allow_egress_to_cidrs=["203.0.113.0/24", "198.51.100.7/32"]),
+ )
+
+Four things to know about it:
+
+- **The address has to be public, and IPv4.** Private ranges are unreachable
from a
+ Modal sandbox whatever the allowlist says: measured, connections to
``10.20.0.1``,
+ ``172.16.0.1``, ``192.168.1.1`` and the cloud metadata address timed out
both under
+ an open network and with those ranges on the allowlist, so a service on your
own private
+ network cannot be reached this way; it needs a public address, or a way onto
your
+ network that Modal provides and this backend does not configure. Modal's
allowlist
+ also rejects IPv6 ranges outright, and the sandbox has no IPv6 route, so an
IPv6
+ entry is refused here with the reason.
+
+- **Hostnames still resolve.** Modal's own resolver inside the sandbox answers
+ every lookup, so ``pypi.org`` resolves to its addresses and a connection to
them
+ then times out. The tool description tells the model this so a successful
lookup
+ is not read as a reachable host. DNS is therefore still a channel out, as it
is
+ under the hostname list; only ``block_network=True`` with no allowlist
closes it.
+- **Entries are canonical CIDR.** A bare address is written as ``/32``. A
range with
+ host bits set, such as ``203.0.113.1/24``, is refused rather than widened to
+ ``203.0.113.0/24``, because that is not what was written. A hostname, a
+ URL or a ``host:port`` is refused, since Modal would accept it and match
nothing.
+ ``0.0.0.0/0`` and ``::/0`` are refused too: an allowlist of every address is
an
+ open network, and ``block_network=False`` is how to ask for one.
+- **Combining the two lists weakens the address one.** Modal applies them
+ together, and traffic matching either passes. Measured, adding ``pypi.org``
to the
+ hostname list beside ``["1.1.1.1/32"]`` made a TCP connection to
``8.8.8.8:443``
+ succeed, because port 443 is then routed by handshake name for every
address. So
+ a combined spec has the address list's guarantee on every port except 443,
and
+ the hostname list's caveats there. The hostname half keeps its
+ ``egress_enforcement="sni"`` opt-in when combined, and the backend logs a
warning
+ at create naming the weakening.
+
**What the image needs.** ``write_file`` and ``list_directory`` use Modal's own
filesystem API, served by a helper Modal injects into the sandbox, so they need
nothing from the image. ``read_file`` deliberately does not: Modal's read API
takes
@@ -165,7 +213,8 @@ behaves identically in both places:
one, so set ``cpu``.
- **Egress allowlists.** ``sbx`` enforces ``allow_egress_to`` at the host
policy
layer; Modal matches TLS handshake names, which is weaker and has to be opted
- into.
+ into. ``allow_egress_to_cidrs`` is enforced at the address layer on Modal and
+ refused on ``sbx``, which has no per-sandbox address rule.
- **Command timeouts.** A timeout destroys an ``sbx`` sandbox and its files; a
Modal sandbox survives with its files intact.
- **Symlinks.** ``write_file`` through a symlink follows the link on ``sbx``
and
diff --git a/providers/common/ai/docs/sandbox/configuration.rst
b/providers/common/ai/docs/sandbox/configuration.rst
index fa375be32aa..176f079cd33 100644
--- a/providers/common/ai/docs/sandbox/configuration.rst
+++ b/providers/common/ai/docs/sandbox/configuration.rst
@@ -42,8 +42,8 @@ being in force.
),
)
-**Network.** Three modes exist. ``block_network=True``, the default, drops all
-outbound traffic including name resolution. On Modal it maps onto the sandbox's
+**Network.** ``block_network=True``, the default, drops all outbound traffic
+including name resolution. On Modal it maps onto the sandbox's
own ``block_network`` flag. ``sbx`` has no per-sandbox enforcement of it at
all:
egress there is a host-level ``sbx policy``, so the backend honors the default
by
refusing to provision unless the Deployment Manager has declared
@@ -56,8 +56,14 @@ since a local rule can narrow egress and never widen it, and
Modal matches
hostnames in the TLS handshake, which is weaker than it sounds and has to be
opted into with ``egress_enforcement="sni"`` (see
:ref:`the Modal backend <sandbox-backend-modal>` for exactly what it does and
does
-not stop). ``block_network=False`` opens outbound access; on Modal the cloud
-metadata endpoint and private address ranges stay unreachable even then.
+not stop). ``allow_egress_to_cidrs`` names address ranges instead, and on
Modal it
+is enforced on the destination address for any port, so it needs no opt-in; it
is
+the mode for one service at a fixed public IPv4 address, and ``sbx`` refuses
it. A
+private address is unreachable from a hosted sandbox whether or not it is
listed.
+The two lists can be set together, and traffic matching either is allowed,
which
+weakens the address list on port 443.
+``block_network=False`` opens outbound access; on Modal the cloud metadata
+endpoint and private address ranges stay unreachable even then.
**Packages.** A default sandbox has the Python standard library and no network,
so an agent that reaches for ``pip install`` gets a DNS failure in a few
seconds.
diff --git a/providers/common/ai/docs/sandbox/index.rst
b/providers/common/ai/docs/sandbox/index.rst
index daa15497efd..68de7370423 100644
--- a/providers/common/ai/docs/sandbox/index.rst
+++ b/providers/common/ai/docs/sandbox/index.rst
@@ -389,7 +389,9 @@ is the list to read before designing a Dag around an agent
with a sandbox.
``AgentOperator`` raises. :ref:`Lifecycle <sandbox-lifecycle>`.
- **A run that outlives** ``sandbox_timeout`` **fails the task.**
:ref:`Lifecycle <sandbox-lifecycle>`.
-- **The hostname allowlist is a weak control** and refused unless opted into.
+- **The hostname allowlist is a weak control** and refused unless opted into.
The
+ address allowlist is enforced properly but cannot serve a package registry
whose
+ addresses rotate.
:ref:`Modal <sandbox-backend-modal>`.
- **Commands run as root and** ``workdir`` **is not a jail.**
:ref:`Modal <sandbox-backend-modal>`.
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/sandbox/base.py
b/providers/common/ai/src/airflow/providers/common/ai/sandbox/base.py
index d43a0cdf3e8..6eeaae52236 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/sandbox/base.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/sandbox/base.py
@@ -99,11 +99,22 @@ class SandboxSpec:
:param allow_egress_to: Hostnames the sandbox may reach when
``block_network`` is ``True``. An empty or unset value with
``block_network=True`` means no egress at all.
+ :param allow_egress_to_cidrs: IPv4 address ranges, in CIDR notation such as
+ ``"203.0.113.0/24"`` or ``"203.0.113.7/32"``, the sandbox may reach
when
+ ``block_network`` is ``True``, on any port and protocol. This is the
+ right field for one service at a fixed public address; it cannot serve
+ a package registry behind a CDN, whose addresses rotate, and a hosted
+ backend cannot reach private (RFC 1918) addresses at all. A backend
that
+ enforces it does so at the address layer, which is a stronger guarantee
+ than a hostname list gives, so it needs no opt-in. Both lists may be
set
+ together; how a backend combines them, and what that costs, is the
+ backend's to document.
"""
env: Mapping[str, str] | None = None
block_network: bool = True
allow_egress_to: Sequence[str] | None = None
+ allow_egress_to_cidrs: Sequence[str] | None = None
@dataclass(frozen=True)
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/sandbox/modal.py
b/providers/common/ai/src/airflow/providers/common/ai/sandbox/modal.py
index a88519894d0..68e8e9224dd 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/sandbox/modal.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/sandbox/modal.py
@@ -99,6 +99,20 @@ _ALLOWED_HOSTNAME = re.compile(
EgressEnforcement = Literal["strict", "sni"]
+def _reject_bare_string(value: object, *, field: str, items: str) -> None:
+ """
+ Refuse a ``str`` where a ``Sequence[str]`` was meant.
+
+ A str is a Sequence[str], so it would otherwise be read one character at a
time: a
+ name without dots would pass every character check and then allow nothing
at all.
+ """
+ if isinstance(value, str):
+ raise SandboxTerminalError(
+ f"SandboxSpec.{field} must be a sequence of {items}, not one
string: "
+ f"{value!r} would be read a character at a time. Wrap it in a
list."
+ )
+
+
def _is_tls_hostname(value: object) -> bool:
"""Whether ``value`` is a name a TLS handshake could actually present."""
if not isinstance(value, str) or not _ALLOWED_HOSTNAME.match(value):
@@ -152,6 +166,19 @@ class ModalSandboxBackend(SandboxBackend):
data out through DNS queries. Pass ``egress_enforcement="sni"`` to accept
that and
have the allowlist applied.
+ ``allow_egress_to_cidrs`` needs no opt-in. It maps onto Modal's
+ ``outbound_cidr_allowlist``, which is enforced at the address layer for
any port and
+ protocol: a listed address connects, anything else is dropped (measured: a
+ connection to an unlisted address times out rather than being refused).
Name
+ resolution still works through Modal's own resolver, so hostnames resolve
but only
+ listed addresses are reachable; DNS itself therefore remains a channel, as
under the
+ hostname list. It is IPv4 only, since Modal rejects IPv6 ranges. Private
ranges
+ (RFC 1918, link-local) are unreachable from a Modal sandbox whatever the
allowlist
+ says, so the destination has to have a public address. The two lists apply
+ together, traffic matching either passes, and adding hostnames reopens TCP
on port
+ 443 to every address, gated by the handshake name alone; the backend logs a
+ warning at create when both are set.
+
**A timeout does not cost you the sandbox.** Modal stops the command
server-side and
the sandbox stays usable, so files written by earlier calls survive and
the model can
inspect them. That differs from ``sbx``, which has to destroy the sandbox
to be sure
@@ -204,7 +231,8 @@ class ModalSandboxBackend(SandboxBackend):
:param egress_enforcement: ``"strict"`` (default) refuses a
``SandboxSpec`` that
names ``allow_egress_to``, because Modal cannot enforce a hostname
allowlist
below TLS. ``"sni"`` accepts it and applies Modal's SNI-matched
allowlist, with
- the limits described above.
+ the limits described above. ``allow_egress_to_cidrs`` is accepted
under either
+ setting.
"""
name = "modal"
@@ -652,31 +680,98 @@ class ModalSandboxBackend(SandboxBackend):
# "No requirements stated" -- see SandboxBackend.create. The
toolset always
# sends a concrete spec, so this is the direct-caller path.
return {}
+ if not spec.allow_egress_to and not spec.allow_egress_to_cidrs:
+ return {"block_network": True} if spec.block_network else {}
+ if not spec.block_network:
+ raise SandboxTerminalError(
+ "SandboxSpec names an egress allowlist but leaves
block_network False, "
+ "which asks for an open network and an allowlist at the same
time. Set "
+ "block_network=True to restrict egress to the allowlist, or
drop the "
+ "allowlist to leave the network open."
+ )
+ # block_network is deliberately not set alongside either list: Modal
rejects the
+ # combination outright, and an allowlist on its own already blocks
every
+ # destination that is not on it.
+ kwargs: dict[str, object] = {}
+ if spec.allow_egress_to_cidrs:
+ # Enforced at the address layer for any port and protocol, so
there is
+ # nothing here for the author to accept; no opt-in is required.
+ kwargs["outbound_cidr_allowlist"] =
self._cidrs(spec.allow_egress_to_cidrs)
if spec.allow_egress_to:
- if not spec.block_network:
- raise SandboxTerminalError(
- "SandboxSpec names an egress allowlist but leaves
block_network False, "
- "which asks for an open network and an allowlist at the
same time. Set "
- "block_network=True to restrict egress to allow_egress_to,
or drop "
- "allow_egress_to to leave the network open."
- )
if self._egress_enforcement != "sni":
raise SandboxTerminalError(
- "SandboxSpec names an egress allowlist, which Modal can
only enforce by "
+ "SandboxSpec names a hostname allowlist, which Modal can
only enforce by "
"matching the TLS SNI: it allows TLS on port 443 to those
hosts, but "
"leaves DNS open for every hostname and cannot stop a host
that shares a "
"TLS endpoint with an allowed one from being reached. Pass
"
- "ModalSandboxBackend(egress_enforcement='sni') to accept
that, or use "
- "SandboxSpec(block_network=True) with no allowlist, which
Modal enforces "
- "exactly and which also blocks DNS."
+ "ModalSandboxBackend(egress_enforcement='sni') to accept
that, use "
+ "allow_egress_to_cidrs for a destination with a fixed
address, which Modal "
+ "enforces at the address layer, or use
SandboxSpec(block_network=True) with "
+ "no allowlist, which Modal enforces exactly and which also
blocks DNS."
+ )
+ kwargs["outbound_domain_allowlist"] =
self._hostnames(spec.allow_egress_to)
+ if spec.allow_egress_to_cidrs:
+ # Modal passes traffic that matches either list, and the
hostname list
+ # admits TLS on 443 to every address as long as the handshake
names a
+ # listed host. So the address list's guarantee holds on every
port except
+ # 443 the moment a hostname is added. Measured: with
1.1.1.1/32 alone,
+ # 8.8.8.8:443 timed out; with pypi.org beside it, 8.8.8.8:443
connected.
+ log.warning(
+ "SandboxSpec combines allow_egress_to_cidrs with
allow_egress_to. Modal applies "
+ "the two together, so TCP on port 443 is admitted to ANY
address whenever the "
+ "TLS handshake names one of %s; the address allowlist %s
is only enforced on "
+ "the other ports. Drop the hostnames if the address list
is meant to be exact.",
+ kwargs["outbound_domain_allowlist"],
+ kwargs["outbound_cidr_allowlist"],
)
- # block_network is deliberately not set alongside this: Modal
rejects the
- # combination outright, and an allowlist on its own already blocks
every
- # host that is not on it.
- return {"outbound_domain_allowlist":
self._hostnames(spec.allow_egress_to)}
- if spec.block_network:
- return {"block_network": True}
- return {}
+ return kwargs
+
+ @staticmethod
+ def _cidrs(allow_egress_to_cidrs: Sequence[str]) -> list[str]:
+ """
+ Check that every entry is an address range Modal can match, or refuse
the spec.
+
+ Modal passes these strings to its API without validating them. A
hostname or a
+ URL would be accepted and match nothing, so the author would be told
egress is
+ restricted to an address that nothing can ever match. Entries are
normalised to
+ canonical CIDR form: a bare address becomes a ``/32``, and a range
with host bits
+ set, such as ``10.0.0.1/8``, is refused rather than silently widened
to the network
+ it sits in, since that is not what was written. IPv6 is refused
because Modal's
+ allowlist rejects it at create and the sandbox has no IPv6 route
anyway.
+ """
+ _reject_bare_string(allow_egress_to_cidrs,
field="allow_egress_to_cidrs", items="CIDR ranges")
+ normalised: list[str] = []
+ rejected: list[object] = []
+ for entry in allow_egress_to_cidrs:
+ try:
+ network = ipaddress.ip_network(entry.strip(), strict=True) if
isinstance(entry, str) else None
+ except ValueError:
+ network = None
+ # Modal refuses IPv6 at create ("Network access allowlist does not
support IPv6
+ # CIDRs", measured 2026-09-22) and its sandboxes have no IPv6
route, so an IPv6
+ # entry could only ever fail the task later and less legibly.
+ if network is None or network.version == 6:
+ rejected.append(entry)
+ continue
+ if network.prefixlen == 0:
+ # 0.0.0.0/0 allows every address, which is an open network
wearing an
+ # allowlist's clothes. Say so rather than provisioning one.
+ raise SandboxTerminalError(
+ f"SandboxSpec.allow_egress_to_cidrs contains {entry!r},
which matches every "
+ "address and so restricts nothing. Pass
SandboxSpec(block_network=False) to "
+ "ask for an open network explicitly, or list the ranges
you mean."
+ )
+ normalised.append(str(network))
+ if rejected:
+ raise SandboxTerminalError(
+ "SandboxSpec.allow_egress_to_cidrs must contain IPv4 address
ranges in CIDR "
+ "notation, such as '203.0.113.0/24' or '203.0.113.7/32',
because Modal matches them "
+ f"against the destination address without checking them. These
entries are not: "
+ f"{rejected}. A hostname belongs in allow_egress_to; a range
with host bits set, "
+ "such as '203.0.113.1/24', must be written as the network it
means; and Modal's "
+ "allowlist does not support IPv6."
+ )
+ return normalised
@staticmethod
def _hostnames(allow_egress_to: Sequence[str]) -> list[str]:
@@ -690,14 +785,7 @@ class ModalSandboxBackend(SandboxBackend):
restricted while it is not restricted the way they wrote it, which is
the belief
the contract exists to protect.
"""
- if isinstance(allow_egress_to, str):
- # A str is a Sequence[str], so this would otherwise be read one
character at a
- # time, and a name without dots would pass every character check
and then
- # allow nothing at all.
- raise SandboxTerminalError(
- "SandboxSpec.allow_egress_to must be a sequence of hostnames,
not one string: "
- f"{allow_egress_to!r} would be read a character at a time.
Wrap it in a list."
- )
+ _reject_bare_string(allow_egress_to, field="allow_egress_to",
items="hostnames")
rejected = [host for host in allow_egress_to if not
_is_tls_hostname(host)]
if rejected:
raise SandboxTerminalError(
diff --git a/providers/common/ai/src/airflow/providers/common/ai/sandbox/sbx.py
b/providers/common/ai/src/airflow/providers/common/ai/sandbox/sbx.py
index 583c42a4740..d9dbdaf7454 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/sandbox/sbx.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/sandbox/sbx.py
@@ -169,6 +169,14 @@ class SbxSandboxBackend(SandboxBackend):
# "No requirements stated" -- see SandboxBackend.create. The
toolset
# always sends a concrete spec, so this is the direct-caller path.
return
+ if spec.allow_egress_to_cidrs:
+ # ``sbx policy allow network`` takes hostnames. There is no
per-sandbox
+ # address-range rule to map this onto, so it cannot be enforced
here.
+ raise SandboxTerminalError(
+ "SandboxSpec names allow_egress_to_cidrs, which this backend
cannot enforce: "
+ "sbx has no per-sandbox address-range rule. Use
allow_egress_to with hostnames "
+ "on a deny-all host policy, or use a backend with an
address-layer allowlist."
+ )
if spec.allow_egress_to and self._host_network_policy != "deny-all":
# A per-sandbox allow rule only means anything on top of a deny-all
# global policy; against an open host policy it grants nothing and
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/toolsets/sandbox.py
b/providers/common/ai/src/airflow/providers/common/ai/toolsets/sandbox.py
index d06288cd512..5f5ea8b8aac 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/toolsets/sandbox.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/toolsets/sandbox.py
@@ -309,15 +309,28 @@ class SandboxToolset(AbstractToolset[Any]):
"""
if not self._spec.block_network:
return "This sandbox has outbound network access."
- allowed = list(self._spec.allow_egress_to or ())
- if not allowed:
+ hosts = list(self._spec.allow_egress_to or ())
+ cidrs = list(self._spec.allow_egress_to_cidrs or ())
+ if not hosts and not cidrs:
return (
"This sandbox has NO network access, including DNS, so
installing packages "
"or downloading anything will fail. Work with what the image
already has."
)
+ by_name = f"these hosts, over HTTPS on port 443 only: {',
'.join(hosts)}"
+ by_address = f"these address ranges, on any port: {', '.join(cidrs)}"
+ if hosts and cidrs:
+ return (
+ f"This sandbox reaches only {by_name}; and {by_address}.
Anything else will fail. "
+ "The hosts accept HTTPS only, so plain HTTP to them fails; the
address ranges accept "
+ "any port. Hostnames resolve, but only listed hosts and
addresses answer."
+ )
+ if hosts:
+ return (
+ f"This sandbox reaches only {by_name}. Anything else, and
plain HTTP to any host, will fail."
+ )
return (
- "This sandbox reaches only these hosts, over HTTPS on port 443: "
- f"{', '.join(allowed)}. Anything else, and plain HTTP to any host,
will fail."
+ f"This sandbox reaches only {by_address}. Anything else will fail;
hostnames still resolve, "
+ "but only those addresses answer."
)
async def get_tools(self, ctx: RunContext[Any]) -> dict[str,
ToolsetTool[Any]]:
diff --git
a/providers/common/ai/tests/system/common/ai/example_sandbox_toolset_modal.py
b/providers/common/ai/tests/system/common/ai/example_sandbox_toolset_modal.py
index 97d024f56f5..b2312e1e21a 100644
---
a/providers/common/ai/tests/system/common/ai/example_sandbox_toolset_modal.py
+++
b/providers/common/ai/tests/system/common/ai/example_sandbox_toolset_modal.py
@@ -25,7 +25,8 @@ filesystem, that a non-zero exit is output rather than a
failure, that a command
hitting its deadline leaves the sandbox and its files intact (which is where
Modal
differs from ``sbx``), that the default spec really does deny egress, that
none of
Airflow's own environment crosses the boundary, and that teardown terminates
the
-sandbox.
+sandbox. A second task checks the address allowlist: a listed address connects
on
+any port, an unlisted one is dropped, and hostnames still resolve.
"""
from __future__ import annotations
@@ -247,7 +248,75 @@ def example_sandbox_toolset_modal():
return result.output
+ @task
+ def run_address_allowlist_agent() -> str:
+ """
+ The address allowlist does what the docs say, on a live sandbox.
+
+ A listed address connects on a port the hostname list could never
cover, an
+ unlisted one is dropped rather than refused (so the client times out),
and a
+ hostname still resolves while its addresses stay unreachable.
+ """
+ from pydantic_ai import Agent
+ from pydantic_ai.messages import ModelMessage, ModelResponse,
TextPart, ToolCallPart
+ from pydantic_ai.models.function import AgentInfo, FunctionModel
+
+ from airflow.providers.common.ai.sandbox import ModalSandboxBackend,
SandboxSpec
+ from airflow.providers.common.ai.toolsets import SandboxToolset
+
+ probe = (
+ 'python3 -c "import socket\n'
+ "def tcp(h, p):\n"
+ " try:\n"
+ " socket.create_connection((h, p), timeout=6).close();
return 'open'\n"
+ " except OSError as e:\n"
+ " return type(e).__name__\n"
+ "print('listed-443', tcp('1.1.1.1', 443))\n"
+ "print('listed-53', tcp('1.1.1.1', 53))\n"
+ "print('unlisted-443', tcp('8.8.8.8', 443))\n"
+ "print('resolves', bool(socket.getaddrinfo('pypi.org', 443)))\""
+ )
+
+ def model_function(messages: list[ModelMessage], _info: AgentInfo) ->
ModelResponse:
+ returns = [
+ str(part.content)
+ for message in messages
+ for part in message.parts
+ if part.part_kind in ("tool-return", "retry-prompt")
+ ]
+ if not returns:
+ return ModelResponse(
+ parts=[
+ ToolCallPart(tool_name="run_command", args={"command":
probe}, tool_call_id="probe")
+ ]
+ )
+ out = returns[0]
+ expected = ("listed-443 open", "listed-53 open", "unlisted-443
TimeoutError", "resolves True")
+ missing = [line for line in expected if line not in out]
+ if missing:
+ raise RuntimeError(
+ f"Address allowlist did not behave as documented; missing
{missing}: {out!r}"
+ )
+ return ModelResponse(parts=[TextPart(content="address allowlist
e2e passed")])
+
+ agent = Agent(
+ FunctionModel(model_function),
+ instructions="Use the sandbox tools as requested.",
+ toolsets=[
+ SandboxToolset(
+
ModalSandboxBackend(app_name="airflow-sandbox-system-test",
sandbox_timeout=300),
+ spec=SandboxSpec(block_network=True,
allow_egress_to_cidrs=["1.1.1.1/32"]),
+ max_command_timeout=60.0,
+ )
+ ],
+ )
+ result = agent.run_sync("Probe the network policy.")
+ if result.output != "address allowlist e2e passed":
+ raise RuntimeError(f"Unexpected agent output: {result.output!r}")
+ return result.output
+
run_sandbox_agent()
+ run_address_allowlist_agent()
dag = example_sandbox_toolset_modal()
diff --git a/providers/common/ai/tests/unit/common/ai/sandbox/test_modal.py
b/providers/common/ai/tests/unit/common/ai/sandbox/test_modal.py
index 41553d5b9a8..134a56874bb 100644
--- a/providers/common/ai/tests/unit/common/ai/sandbox/test_modal.py
+++ b/providers/common/ai/tests/unit/common/ai/sandbox/test_modal.py
@@ -113,6 +113,7 @@ class TestVendorContract:
"cloud",
"block_network",
"outbound_domain_allowlist",
+ "outbound_cidr_allowlist",
):
assert name in parameters, f"Sandbox.create no longer takes
{name!r}"
@@ -354,6 +355,146 @@ class TestSpecEnforcement:
assert sandbox.create_kwargs["env"] is None
+class TestAddressAllowlist:
+ """
+ ``allow_egress_to_cidrs`` maps onto Modal's address-layer allowlist.
+
+ Measured live on 2026-09-22 with
``outbound_cidr_allowlist=["1.1.1.1/32"]``: the listed
+ address connected on 443 and 53, an unlisted one timed out on both, and
hostnames still
+ resolved through Modal's own resolver. That enforcement has no caveat for
the author
+ to accept, so unlike the hostname list it needs no opt-in.
+ """
+
+ def test_maps_to_the_cidr_allowlist_without_an_opt_in(self, backend, fake):
+ _, sandbox = _created(backend, fake,
SandboxSpec(allow_egress_to_cidrs=["10.20.0.0/16"]))
+
+ assert sandbox.create_kwargs["outbound_cidr_allowlist"] ==
["10.20.0.0/16"]
+ # Modal rejects the two together, and an allowlist alone already
denies the rest.
+ assert "block_network" not in sandbox.create_kwargs
+ assert "outbound_domain_allowlist" not in sandbox.create_kwargs
+
+ def test_combines_with_a_hostname_allowlist_under_sni(self, backend_class,
fake):
+ """Modal applies the two lists additively, so both are passed when
both are given."""
+ backend = backend_class(egress_enforcement="sni")
+
+ _, sandbox = _created(
+ backend,
+ fake,
+ SandboxSpec(allow_egress_to=["pypi.org"],
allow_egress_to_cidrs=["203.0.113.7/32"]),
+ )
+
+ assert sandbox.create_kwargs["outbound_cidr_allowlist"] ==
["203.0.113.7/32"]
+ assert sandbox.create_kwargs["outbound_domain_allowlist"] ==
["pypi.org"]
+
+ def test_the_hostname_half_still_needs_the_opt_in(self, backend, fake):
+ """Adding an address list does not launder the hostname list past its
opt-in."""
+ with pytest.raises(SandboxTerminalError,
match="egress_enforcement='sni'"):
+ backend.create(
+ spec=SandboxSpec(allow_egress_to=["pypi.org"],
allow_egress_to_cidrs=["10.0.0.0/8"])
+ )
+ assert fake.Sandbox.created == []
+
+ def test_the_refusal_names_the_address_list_as_the_way_out(self, backend):
+ with pytest.raises(SandboxTerminalError,
match="allow_egress_to_cidrs"):
+ backend.create(spec=SandboxSpec(allow_egress_to=["pypi.org"]))
+
+ def test_without_block_network_is_contradictory(self, backend, fake):
+ with pytest.raises(SandboxTerminalError, match="block_network"):
+ backend.create(spec=SandboxSpec(block_network=False,
allow_egress_to_cidrs=["10.0.0.0/8"]))
+ assert fake.Sandbox.created == []
+
+ @pytest.mark.parametrize(
+ ("entry", "canonical"),
+ [
+ ("203.0.113.7", "203.0.113.7/32"),
+ ("203.0.113.7/32", "203.0.113.7/32"),
+ ("203.0.113.0/24", "203.0.113.0/24"),
+ ],
+ )
+ def test_normalises_entries_to_canonical_cidr(self, backend, fake, entry,
canonical):
+ """A bare address means that one address, and is written as such for
Modal."""
+ _, sandbox = _created(backend, fake,
SandboxSpec(allow_egress_to_cidrs=[entry]))
+
+ assert sandbox.create_kwargs["outbound_cidr_allowlist"] == [canonical]
+
+ @pytest.mark.parametrize(
+ "entry",
+ [
+ "pypi.org",
+ "https://10.0.0.1",
+ "10.0.0.1:443",
+ "10.0.0.1/8",
+ "10.0.0.0/33",
+ "",
+ "*",
+ 443,
+ # Modal's allowlist rejects IPv6 at create ("does not support IPv6
CIDRs",
+ # measured 2026-09-22) and the sandbox has no IPv6 route, so
refuse it here
+ # with the reason rather than letting the task fail on the
vendor's message.
+ "2001:db8::",
+ "2001:db8::/32",
+ "::/0",
+ "fe80::1%eth0",
+ ],
+ )
+ def test_refuses_an_entry_that_is_not_an_ipv4_cidr(self, backend, fake,
entry):
+ """
+ Modal sends these strings on unvalidated and matches them against the
address.
+
+ A hostname or URL would match nothing while reading as a restriction,
and a range
+ with host bits set names a different network from the one written.
+ """
+ with pytest.raises(SandboxTerminalError, match="IPv4 address ranges"):
+ backend.create(spec=SandboxSpec(allow_egress_to_cidrs=[entry]))
+ assert fake.Sandbox.created == []
+
+ def test_refuses_a_bare_string_instead_of_a_list(self, backend, fake):
+ with pytest.raises(SandboxTerminalError, match="not one string"):
+
backend.create(spec=SandboxSpec(allow_egress_to_cidrs="10.0.0.0/8"))
+ assert fake.Sandbox.created == []
+
+ def test_refuses_a_range_that_matches_every_address(self, backend, fake):
+ """An allowlist of everything is an open network that reads as a
restriction."""
+ with pytest.raises(SandboxTerminalError, match="restricts nothing"):
+
backend.create(spec=SandboxSpec(allow_egress_to_cidrs=["203.0.113.0/24",
"0.0.0.0/0"]))
+ assert fake.Sandbox.created == []
+
+ def
test_combining_with_hostnames_warns_that_port_443_is_no_longer_confined(
+ self, backend_class, fake, caplog
+ ):
+ """
+ Modal admits traffic matching either list, and the hostname list
admits TLS on 443
+ to every address whose handshake names a listed host. Measured:
8.8.8.8:443 timed
+ out under 1.1.1.1/32 alone and connected once pypi.org was added
beside it.
+ """
+ caplog.set_level(logging.WARNING,
logger="airflow.providers.common.ai.sandbox.modal")
+ backend = backend_class(egress_enforcement="sni")
+
+ _created(
+ backend, fake, SandboxSpec(allow_egress_to=["pypi.org"],
allow_egress_to_cidrs=["1.1.1.1/32"])
+ )
+
+ assert "port 443 is admitted to ANY address" in caplog.text
+ assert "1.1.1.1/32" in caplog.text
+
+ def test_an_address_list_alone_does_not_warn(self, backend, fake, caplog):
+ caplog.set_level(logging.WARNING,
logger="airflow.providers.common.ai.sandbox.modal")
+
+ _created(backend, fake,
SandboxSpec(allow_egress_to_cidrs=["1.1.1.1/32"]))
+
+ assert not caplog.records
+
+ def test_surrounding_whitespace_is_tolerated(self, backend, fake):
+ _, sandbox = _created(backend, fake,
SandboxSpec(allow_egress_to_cidrs=[" 10.20.0.0/16 "]))
+
+ assert sandbox.create_kwargs["outbound_cidr_allowlist"] ==
["10.20.0.0/16"]
+
+ def test_an_empty_list_means_no_egress(self, backend, fake):
+ _, sandbox = _created(backend, fake,
SandboxSpec(allow_egress_to_cidrs=[]))
+
+ assert sandbox.create_kwargs["block_network"] is True
+
+
class TestEnvironment:
def test_env_reaches_the_sandbox(self, backend, fake):
_, sandbox = _created(backend, fake, SandboxSpec(env={"HF_TOKEN":
"secret"}))
diff --git a/providers/common/ai/tests/unit/common/ai/sandbox/test_sbx.py
b/providers/common/ai/tests/unit/common/ai/sandbox/test_sbx.py
index 106b94a284b..8a509a659c9 100644
--- a/providers/common/ai/tests/unit/common/ai/sandbox/test_sbx.py
+++ b/providers/common/ai/tests/unit/common/ai/sandbox/test_sbx.py
@@ -83,6 +83,14 @@ class TestSpecEnforcement:
assert policy[3:5] == ["--sandbox", name]
assert policy[5:] == ["pypi.org", "files.pythonhosted.org"]
+ def test_refuses_an_address_allowlist_it_has_no_rule_for(self):
+ # ``sbx policy allow network`` takes hostnames; there is nothing to
map a range onto,
+ # and this holds whatever the host policy says.
+ with pytest.raises(SandboxTerminalError,
match="allow_egress_to_cidrs"):
+ SbxSandboxBackend(host_network_policy="deny-all").create(
+ spec=SandboxSpec(allow_egress_to_cidrs=["10.0.0.0/8"])
+ )
+
def test_refuses_no_egress_when_the_host_policy_is_undeclared(self):
with pytest.raises(SandboxError, match="host policy has not been
declared"):
SbxSandboxBackend().create(spec=SandboxSpec(block_network=True))
diff --git a/providers/common/ai/tests/unit/common/ai/toolsets/test_sandbox.py
b/providers/common/ai/tests/unit/common/ai/toolsets/test_sandbox.py
index c05055c68dd..29336eb6be4 100644
--- a/providers/common/ai/tests/unit/common/ai/toolsets/test_sandbox.py
+++ b/providers/common/ai/tests/unit/common/ai/toolsets/test_sandbox.py
@@ -209,6 +209,41 @@ class TestNetworkNote:
# Plain HTTP is the trap worth naming, because nothing refuses it.
assert "plain HTTP" in description
+ @pytest.mark.asyncio
+ async def
test_an_address_allowlist_names_the_ranges_and_that_names_still_resolve(self):
+ # Measured: hostnames resolve under a CIDR list, but only listed
addresses answer.
+ # Without saying so the model reads a successful lookup as a reachable
host.
+ toolset = SandboxToolset(
+ _RecordingBackend(),
+ spec=SandboxSpec(block_network=True,
allow_egress_to_cidrs=["10.20.0.0/16", "203.0.113.7/32"]),
+ )
+
+ description = (await
toolset.get_tools(_ctx()))["run_command"].tool_def.description
+ assert "10.20.0.0/16, 203.0.113.7/32" in description
+ assert "on any port" in description
+ assert "hostnames still resolve" in description
+ assert "plain HTTP" not in description, "plain HTTP is only a trap
under the hostname list"
+
+ @pytest.mark.asyncio
+ async def test_both_lists_are_described_together(self):
+ toolset = SandboxToolset(
+ _RecordingBackend(),
+ spec=SandboxSpec(
+ block_network=True, allow_egress_to=["pypi.org"],
allow_egress_to_cidrs=["10.20.0.0/16"]
+ ),
+ )
+
+ description = (await
toolset.get_tools(_ctx()))["run_command"].tool_def.description
+ assert (
+ "over HTTPS on port 443 only: pypi.org; and these address ranges,
on any port: 10.20.0.0/16"
+ in (description)
+ )
+ # Plain HTTP fails for the hosts but not for the ranges, which take
any port, so the
+ # blanket "plain HTTP to any host will fail" of the hosts-only note
would be wrong here.
+ assert "plain HTTP to them fails" in description
+ assert "address ranges accept any port" in description
+ assert "plain HTTP to any host" not in description
+
@pytest.mark.asyncio
async def test_an_open_sandbox_says_so(self):
toolset = SandboxToolset(_RecordingBackend(),
spec=SandboxSpec(block_network=False))