Egress Allowlist
A hybrid or on-prem deployment runs the run gateway inside your network, which means your firewall decides which upstreams a tool call can reach. Willow can produce the list of hosts to allow.
Most of that list is already knowable: a REST connector stores the API base URL, a remote MCP integration stores its endpoint, an OAuth provider stores its token URL. Willow reads those out of stored configuration and exports them.
Stdio MCP servers are the exception, and they are the reason this page has a second half. A stdio integration stores a package to run, not a host to reach:
{ "command": "npx", "args": ["-y", "example-mcp"] }
The upstreams live inside that package. No amount of configuration inspection produces them, so the export can only mark them unknown and ask you to find them yourself. Host discovery closes that gap by recording the DNS lookups each sandbox actually makes.
SaaS deployments run the gateway on Willow's infrastructure, so there is no firewall of yours in the path and nothing to allow. The export appears only for on-prem organizations and for organizations with an external gateway.
Export the allowlist
Integrations → the ⋮ menu in the page header → Export Egress Allowlist. You get a CSV, one row per host your network team has to allow.
| Column | What it holds |
|---|---|
Host | The hostname to allow, or unknown for a row Willow cannot name |
Port | Almost always 443; blank when the port is not knowable |
Integration | The integration's display name, or Willow platform for shared infrastructure |
Integration Slug | The integration's slug |
Integration Status | active, draft, or disabled |
Purpose | Why the host is reached — see below |
Provenance | How Willow determined it — see below |
Notes | The evidence behind the row, in prose |
Draft and disabled integrations are included deliberately. A firewall change takes longer to land than flipping an integration on, so you need the rules for what you are about to enable. Integration Status is how you tell "needed today" from "needed soon".
Purpose
| Value | Meaning |
|---|---|
tool-api | The third-party API a tool actually calls |
oauth | An authorization or token endpoint |
mcp-endpoint | A remote MCP server's own URL |
package-registry | npm or PyPI, fetched on every sandbox cold start |
container-image | The registry the sandbox base image is pulled from |
webhook | A URL a webhook workflow forwards events to |
platform | Willow infrastructure the gateway needs to run at all |
Provenance
Provenance is the column to read before you turn a row into a firewall rule. It says how much the row can be trusted.
| Value | Meaning | What to do |
|---|---|---|
resolved | Read straight out of stored configuration | Allow it |
observed | Seen in a real DNS lookup from the integration's sandbox | Allow it, but see the caveat below |
missing-config | The configuration is a template whose setting was never filled in | Finish configuring the integration, then re-export |
unknown-runtime | A stdio MCP server whose upstreams live inside its package | Turn on host discovery, or ask the server's vendor |
observed is "seen so far", not "required"An observed host is real — the sandbox resolved it. But observation is driven by use: a tool nobody has run has never resolved anything, so the list grows as more of the server's tools are exercised. Willow keeps observed distinct from resolved, and keeps emitting the unknown row alongside it, precisely so a partial answer cannot be mistaken for a complete one.
Host discovery
Optional, off by default, hybrid and on-prem only. It turns unknown rows into named hosts.
How it works
Every outbound connection resolves a name before it connects, whatever HTTP library the package happens to use. That makes DNS the one vantage point that sees every upstream, including ones a forward proxy would miss.
When you set SANDBOX_DNS_UPSTREAM, run binds a DNS listener on the sandbox network's gateway and starts each sandbox with two resolvers: the listener first, and your real resolver second. The listener forwards every query verbatim to your resolver, relays the answer back unchanged, and records the name that was asked. Those names are reported to Willow, and appear in the export with provenance observed.
Two consequences worth understanding:
- A blocked call still teaches you the host. Resolution happens before the connection, so when your firewall denies the connect, the lookup has already been recorded. The tool call that just failed is what produces the rule needed to make it succeed — which is why this works on exactly the locked-down networks where the answer matters most.
- It is not a policy point. The listener forwards every query and never blocks one. What a sandbox is allowed to reach remains your firewall's decision.
On hybrid, run reports observed hostnames to Willow SaaS, because that is where your organization's configuration lives. The hostnames themselves leave your network; the queries, the answers, and the traffic do not. On on-prem, everything stays in your cluster.
What is not recorded
- Reverse lookups (
*.in-addr.arpa,*.ip6.arpa) and single-label names. - Names ending in
.local,.localdomain,.internal, or.svc. These are usually search-domain expansions of a name the resolver then retries unqualified, and listing them would send your firewall team after names that never leave the cluster. If a genuine upstream of yours lives on one of these suffixes, it will not appear — add it by hand. - Package registries and sandbox image registries. The export already reports those from configuration, and recording them again would put one host on two rows with different provenance.
- Anything from an integration that already has 500 recorded hosts. Past that point the integration stops accruing rows rather than the table growing without limit.
Requirements
| Requirement | Why |
|---|---|
| The dind sandbox backend | The listener attaches to a docker network. The podmcp and Cloudflare backends are not covered. |
| Chart 1.0.59 or later | It grants NET_BIND_SERVICE to the run container. The listener needs port 53, and --dns cannot name any other port. |
| An IPv4 resolver address | --dns accepts nothing else. |
| A UDP/53 rule, if you filter sandbox egress | A hardened sandbox network drops RFC1918 wholesale, which includes its own gateway. |
Step 1 — Find your resolver address
This is the resolver run will forward to, and the fallback each sandbox gets as its second --dns entry. In almost every cluster it is the in-cluster DNS service, which is what sandboxes already resolve through today:
kubectl get svc -n kube-system kube-dns -o jsonpath='{.spec.clusterIP}'
# Common values: 10.96.0.10, 172.20.0.10
Use that address, or the address of whichever resolver you would rather sandboxes use. It must be an IPv4 literal — a hostname is rejected at startup and the feature stays off.
Step 2 — Set it in your values.yaml
deployments:
run:
env:
SANDBOX_DNS_UPSTREAM: "10.96.0.10"
NET_BIND_SERVICE is already a chart default for run, so there is nothing else to add. Upgrade the release:
helm upgrade willow webrix/webrix-helm -n willow -f values.yaml
Step 3 — Allow UDP/53 to the sandbox network gateway
Skip this step unless you filter the sandbox network's egress. If you have followed the hardening guidance and dropped RFC1918 from the sandbox network, that drop also covers the network's own gateway — which is the address the listener binds. Sandboxes will fall through to their second resolver and nothing will be recorded.
Add one narrow hole in the dind sidecar's rules: UDP, port 53, to the gateway address of the sandbox network only.
# The gateway address the listener binds, as run derives it
docker network inspect "$SANDBOX_NETWORK" \
-f '{{range .IPAM.Config}}{{.Gateway}}{{end}}'
Step 4 — Verify
The listener logs once when it binds:
kubectl logs -n willow deploy/run | grep EgressObserver
# [EgressObserver] Recording sandbox DNS lookups
Then run a tool on a stdio integration and open Integrations → your integration → Settings → Egress Hosts. Observations are batched and flushed once a minute, so give it that long and use Refresh.
The panel lists the upstreams that integration reaches, with observed rows marked. It appears only once there is something to show — a freshly installed server that has not been used yet has no observations, and an empty panel would read as "nothing to allow" rather than "nothing seen yet". The full list, including shared Willow infrastructure and the package registries, stays in the CSV export.
Turning it off
Remove SANDBOX_DNS_UPSTREAM and upgrade. Sandboxes then start with no --dns flag and resolve exactly as they did before. Rows already recorded stay in the export; they were real observations and remain accurate.
This is also the failure mode by design: if the listener cannot bind — no NET_BIND_SERVICE, a remote docker daemon, a resolver address that is not an IPv4 literal — run logs the reason, adds no --dns flag, and sandbox DNS is untouched. A deployment that asked for the feature but cannot run it behaves exactly like one that never asked.
What to watch
The listener sits in the resolution path of every dind stdio sandbox, so its failure modes matter more than the data it collects. All three metrics are on run's Prometheus endpoint.
| Metric | Watch for |
|---|---|
mcp_egress_dns_queries_total{outcome} | upstream_timeout and upstream_error mean sandboxes are falling through to their second resolver. Sustained overloaded means queries are going unanswered on the first try. A steady unattributed rate means lookups are seen but cannot be tied to an integration. Any spoofed_reply is a datagram from something other than your resolver — dropped, but worth an alert if it persists. |
mcp_egress_observations_pending | Rows buffered in memory. Should return to zero every minute. |
mcp_egress_report_total{outcome} | error means a batch could not be delivered. Batches are dropped rather than retried; the next tool call re-observes the same hosts. |
The second --dns entry covers a listener that has stopped answering: lookups fall through in about 0.2 seconds. It does not cover one that is slow — a listener that accepts packets and never replies costs roughly 5 seconds per lookup while the sandbox's resolver waits out its timeout. If upstream_timeout is climbing, restart the run pod rather than leaving it wedged.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
No [EgressObserver] line in the logs | SANDBOX_DNS_UPSTREAM is not set, or is not an IPv4 literal | Check the value; a rejected address logs egress_observer_bad_upstream |
Could not bind port 53 ... | Chart older than 1.0.59, so the container has no NET_BIND_SERVICE | Upgrade the chart |
| Listener started, but nothing is ever observed | The sandbox network's egress filter is dropping UDP/53 to its own gateway | Step 3 |
unattributed climbing, nothing in the panel | Lookups are arriving from something other than a sandbox run started | Expected at a low rate; sustained, check that the dind backend is in use rather than podmcp |
| Panel does not appear on the integration | Nothing observed yet, or the deployment has no external gateway | Run one of the server's tools and refresh after a minute |
| Hosts appear for one integration but not another | The other integration's tools have not been run | Observation is driven by use — exercise the tools you care about |
| A known internal upstream never appears | Its name ends in .internal, .local, .localdomain, or .svc | These are filtered as cluster-internal; add the host to your allowlist by hand |