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))

Reply via email to