Egress governance
An autonomous agent with a network connection can reach anything on the internet — and a prompt-injected, compromised, or simply buggy one eventually will; Whisper gives every agent one governed door out, with policy no static blocklist can express.
The problem
Give an agent a shell and a network stack and you've given it the whole internet. That's the point of the agent, and it's also the risk:
- No chokepoint. Agents egress from wherever they run, to wherever a tool call sends them. Exfiltrating a secret to an attacker-controlled host, calling out to a C2 server after prompt injection, or quietly resolving a typosquatted API domain all look identical to "the agent made an HTTP request" — because that's all they are.
- Blocklists rot on arrival. A hand-maintained deny-list of domains or IPs is stale the moment you save the file. It can't express "anything this entity owns, on any provider, under any name", and it can't react to a hijacked BGP prefix in the fifteen minutes before your team notices.
- No record. When something does go wrong, "what did the agent actually touch, and when" is usually answered by grepping application logs that were never meant to answer it — if they exist at all.
Firewalling agent traffic with the same tools you'd use for a web server misses the thing that makes agent traffic different: the decision to connect somewhere is made by a model, not a developer, and the destination is rarely known in advance.
The mechanism: graph-first resolution + a bound egress door
Whisper's fix has two halves that share one policy: a resolver that answers every DNS query only after checking it against a live security graph, and an egress path that only ever carries traffic sourced from the requesting agent's own routable /128 (2a04:2a01::/32, AS219419) — so the policy that governed the lookup is the same policy that governed the connection.
For every name an agent asks about, the resolver evaluates:
- Category — is the resolved host a Tor exit node, bulletproof hosting, a newly-registered domain, a known C2 rendezvous, a CDN, a paste site?
- Geography — where does the destination actually sit, independent of what its registrar claims (cross-referenced against the delegation chain, not a self-reported
Country:field)? - Ownership — who ultimately controls the destination, following
RESOLVES_TO → DELEGATED_TO → ORGANIZATIONedges rather than trusting a WHOISOrgNamestring that can say anything? - Routing — is the announcing prefix currently valid per RPKI, or has it just been hijacked (an invalid ROA showing up in real time)?
The verdict is one of three, and only one of them costs a round trip:
- Allow — the answer flows immediately, the agent connects.
- Deny — the query is sinkholed and the attempt is logged before a single packet leaves the box. No connection is ever attempted to a blocked destination.
- No opinion — the graph has nothing to say, so the resolver steps aside and returns the real DNS answer at full speed. A policy engine that turns "I don't know" into an outage is a bug, not a feature — see
/docs/resolverfor the fail-open design in full. This is the same Robustness Principle every other Whisper surface holds to: strict about what we forward as an answer, liberal about not blocking a name we have no basis to block.
Because the check runs against the graph rather than a list you maintain, it expresses controls no static file can: "nothing in this category, ever", "nothing this specific entity owns, under any name it registers tomorrow", "drop it the instant its route is hijacked, before I've even heard about the hijack."
Setting policy: the graph verdict, plus your own allow/block list
Two layers decide every answer, and you only configure the second. The graph verdict — the category / geography / ownership / routing evaluation above — is applied to every query automatically; there is no lever to set and no feed to maintain to get it. On top of it sits a per-tenant policy that governs every agent resolving through your door: your own allow and block names, and the default action for everything the graph has no opinion on. Both allow and block match a name or any subdomain of it (suffix match), and an explicit allow is the strongest signal — it overrides both block and a deny-default.
It's one control-plane call, op:'policy' on whisper.agents, over POST https://graph.whisper.security/api/query with your API key (see /docs/control-plane):
CALL whisper.agents({op:'policy', args:{
block: ['ads.example.com', 'telemetry.vendor.net'],
allow: ['api.stripe.com'],
default: 'deny'
}})
The whisper CLI does the same thing without you touching Cypher — and with no flags at all it reads your current policy back:
whisper policy \
--block ads.example.com --block telemetry.vendor.net \
--allow api.stripe.com \
--default deny
--default deny is the strong posture: nothing resolves unless the graph allows it or it's on your allow list — so an agent that a prompt injection points at a brand-new attacker domain gets an NXDOMAIN, not a connection. Every field is additive and revocable — re-run with a narrower or wider set at any time; the next query any of your agents makes is evaluated under the new policy, with nothing to redeploy.
With stock tools vs. with Whisper
Blocking a category of infrastructure. With stock tools, you're maintaining a DNS Response Policy Zone or an iptables/nft set by hand, refreshed from whatever public Tor-exit or bulletproof-hosting feed you can find and trust to still be current:
# stock: hand-rolled RPZ zone entry + iptables rule, one host at a time
echo "known-bad.example. CNAME ." >> /etc/bind/rpz.whisper-test.zone && rndc reload
iptables -A OUTPUT -d 203.0.113.7 -j DROP # and again, for every IP that host ever uses
# whisper: the graph denies known-malicious destinations (threat-intel indicators, known
# C2, known-bad hosting) in its verdict automatically — no feed to maintain. One call
# closes the unknown tail too, so anything the graph has no opinion on is denied by default:
CALL whisper.agents({op:'policy', args:{default:'deny'}})
Geofencing. Stock tooling means a MaxMind/GeoIP lookup glued into your proxy's ACL config by hand:
# stock: resolve, GeoIP-lookup, then decide — three tools, three trust boundaries
ip=$(dig +short AAAA api.example.com | head -1)
geoiplookup6 "$ip" # -> "GeoIP Country Edition: RU, Russian Federation" (or stale/wrong)
# whisper: geography is graph-derived (not a downloaded GeoIP DB) and already weighed in
# every verdict. To hard-pin the exact destinations an agent may reach, list them and deny
# the rest — no GeoIP plumbing, no ACL to keep in sync:
CALL whisper.agents({op:'policy', args:{allow:['api.eu.example.com'], default:'deny'}})
Route-hijack defense. Stock tools mean polling RPKI validators yourself and wiring the result into a firewall reload:
# stock: routinator / rpki-client, cross-referenced against BGP by hand, then a manual firewall push
rpki-client -j -o /tmp/roas.json && jq '.roas[] | select(.asid==64500)' /tmp/roas.json
On the Whisper side there is nothing to wire: routing validity (RPKI) is a live edge in the graph and is already folded into every verdict per-query, so a name whose prefix is being hijacked is denied the moment the graph sees the invalid ROA — you can confirm any name's current standing with the keyless check below.
Ad-hoc "is this safe?" from inside a running agent. A pattern that's genuinely new: no key needed, because the question is authenticated by the agent's own source address:
# stock: whois + dig + your own judgment call
whois api.openai.com | grep -i "OrgName\|Country"
dig +short api.openai.com
# whisper: one keyless call, answered by the graph — authorized by the agent's own /128
# (the address IS the auth). The SAME path from off the agent's address is an opaque 403.
curl -s "https://rdap.whisper.online/identify?q=api.openai.com"
# -> a graph verdict for the host (fields illustrative):
# {"host":"api.openai.com","category":"cdn","operator":"Cloudflare",
# "evidence":"RESOLVES_TO -> IPV4 -> DELEGATED_TO -> cloudflare"}
Proving egress is actually bound
Policy on the resolver only matters if the packet that follows really sources from the governed address. Never trust a client-side claim — fetch the check through the egress path itself:
curl -x "$http_proxy" -s https://whisper.online/egress-ip
# -> {"ip":"2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4"} must equal the agent's own /128
whisper ip --agent shipping-bot folds this same echo-through-the-proxy check into one command, and whisper connect / whisper run run it automatically before handing control to your process — a broken egress binding fails loudly at startup, never silently mid-run. The proxy itself speaks standard SOCKS5 (RFC 1928) or HTTP CONNECT (RFC 9110 §9.3.6), source-bound to the agent's /128 via AnyIP + IP_FREEBIND, so any tool that already understands ALL_PROXY/HTTPS_PROXY — curl, git, Node, Claude Code — needs zero code changes to pick up the governed path.
The record
Every query and connection, allowed or blocked, is logged per agent and queryable live:
# stock: grep whatever your proxy happened to log, hope it's enough
grep "shipping-bot" /var/log/squid/access.log | tail -50
CALL whisper.agents({op:'logs', args:{agent:'shipping-bot'}})
# -> conn ...:443 · dns allow · dns=13 blocked=3 conns=20
That log is the same per-agent record surfaced through /docs/compliance — a queryable answer to "where did this thing go" that doesn't depend on remembering to instrument it.
This is for security teams governing what they deploy, regulated environments that must show egress controls on demand, and anyone who wants an agent to have real reach without unsupervised reach.
Next
Read /docs/resolver for the full mechanism behind the graph-first check, and /docs/access-control for the inbound half — authorizing calls into your services by an agent's verified name.