agentgated is an egress control daemon for AI agents. It sits between an agent's tools and the network, enforcing a single allow/deny hostname policy across every path an agent can reach the outside world through: DNS lookups and HTTP CONNECT tunnels alike, so an agent that already knows a destination IP can't bypass policy by skipping resolution and data can't be exfiltrated through DNS.
Every query and connection is logged (name, type, client, allow/block decision, and whether it was served from cache or upstream), which is deliberate: this is step one toward using agentgated to spot data exfiltration attempts by misbehaving or compromised agents tunneling data out through "harmless" DNS lookups or HTTP requests.
- Transparent DNS proxy — forwards queries to an upstream resolver over UDP and TCP
- HTTP CONNECT tunnel proxy — filters by the tunnel's target hostname using the same allow/deny list as DNS, then relays bytes opaquely (no TLS termination). Independent of that hostname policy, it also: restricts which ports a target may use, always blocks hosts on a built-in denylist (seeded with known DNS-over-HTTPS/DNS-over-TLS resolvers) regardless of any allow list, rejects a target that resolves to an internal-only address (loopback, link-local/cloud-metadata, private, or shared/CGNAT space), and bounds how long any tunnel can stay open
- Static allow list or deny list filtering by hostname (suffix-matched, so an entry also covers its subdomains), shared by both proxies
- Optional multi-tenant mode: resolve each request's allow/deny list from a URL template (e.g. keyed by client IP), fetched and refreshed in the background with fail-safe fallback to the last-known-good list
- Configurable redirect IP for blocked A queries, or NXDOMAIN
- In-memory response cache honoring upstream TTLs, capped at a configurable maximum
- Structured per-query and per-connection logging to stdout or a log file
- Single static binary, no runtime dependencies
- Exfiltration heuristics (entropy scoring on query names, per-client rate/beaconing anomalies) built on top of the existing query log
- Go 1.23+ to build
- Root (or
CAP_NET_BIND_SERVICE) to bind port 53
make build
# binary at ./dist/agentgatedTagged versions (vX.Y.Z) are built automatically by
.github/workflows/release.yml into static
binaries for linux/amd64, linux/arm64, darwin/amd64, and
darwin/arm64, published on the
Releases page as
agentgated_<version>_<os>_<arch>.tar.gz alongside a checksums.txt.
curl -LO https://github.com/michaellandi/agentgated/releases/download/vX.Y.Z/agentgated_vX.Y.Z_linux_amd64.tar.gz
curl -LO https://github.com/michaellandi/agentgated/releases/download/vX.Y.Z/checksums.txt
sha256sum -c checksums.txt --ignore-missing
tar xzf agentgated_vX.Y.Z_linux_amd64.tar.gzagentgated -version prints the build's version.
sudo make installThis installs the binary to /usr/local/sbin/agentgated and the systemd unit
to /etc/systemd/system/agentgated.service. Then set up config:
sudo mkdir -p /etc/agentgated
sudo cp configs/agentgated.yaml.example /etc/agentgated/agentgated.yaml
sudo cp configs/allowlist.txt.example /etc/agentgated/allowlist.txt
sudo cp configs/denylist.txt.example /etc/agentgated/denylist.txt
sudo systemctl daemon-reload
sudo systemctl enable --now agentgatedConfiguration is read from the path given by -config (default
/etc/agentgated/agentgated.yaml). See
configs/agentgated.yaml.example for all
options:
| Option | Default | Description |
|---|---|---|
dns_listen |
:53 |
Address to listen on for DNS (UDP and TCP) |
dns_upstream |
1.1.1.1:53 |
Upstream resolver for permitted DNS queries |
filter_mode |
allow |
none, allow, or deny — applied to both DNS and CONNECT. Named for which list is authoritative, not the resulting posture: allow (an allow list) is default-deny and restrictive; deny (a deny list) is default-allow and permissive. Default is the restrictive allow mode — deploying deny against a threat model that assumes default-deny silently inverts it |
allowlist_file |
/etc/agentgated/allowlist.txt |
Hostnames to allow, one per line — the authoritative list under the default filter_mode: allow |
denylist_file |
/etc/agentgated/denylist.txt |
Hostnames to block, one per line — the authoritative list under filter_mode: deny |
dns_blocked_ip |
(empty) | IP to answer with for a blocked A query; empty means NXDOMAIN |
dns_cache |
true |
Enable in-memory DNS response caching |
dns_cache_max_ttl |
1h |
Upper bound on cached DNS entry lifetime |
log_path |
(empty) | Log file path; empty logs to stdout |
connect_listen |
(empty) | Address for the HTTP CONNECT proxy, e.g. :3128; empty disables it |
connect_allowed_ports |
["443"] |
Ports a CONNECT target may use; empty means unrestricted |
connect_denylist_file |
/etc/agentgated/connect-denylist.txt |
Hostnames a CONNECT target is always blocked against, regardless of filter_mode or any allow list — see Security model and known limitations |
connect_block_private_ips |
true |
Reject a CONNECT target resolving to a loopback/link-local/private/unspecified address, regardless of policy |
connect_idle_timeout |
5m |
Close a CONNECT tunnel after this long with no data in either direction; 0 disables it |
connect_max_duration |
1h |
Close a CONNECT tunnel after this long regardless of activity; 0 disables it |
allowlist_url_template |
(empty) | URL template to resolve a per-request allow list from; see Multi-tenant policy resolution |
denylist_url_template |
(empty) | URL template to resolve a per-request deny list from; see Multi-tenant policy resolution |
policy_refresh_interval |
5m |
How often a resolved policy URL is re-fetched |
policy_fetch_timeout |
10s |
Timeout for a single policy fetch |
Restart the daemon after changing config, allowlist_file, or
denylist_file — those are read once at startup. Lists resolved via
allowlist_url_template/denylist_url_template refresh themselves on
policy_refresh_interval without a restart.
Setting allowlist_url_template and/or denylist_url_template turns
agentgated into a multi-tenant system: instead of one fixed list, the URL to
fetch a request's allow/deny list from is built per-request by substituting
placeholders into the template. Two placeholders are supported:
{ip}— the client's source IP. Works for both DNS and CONNECT.{header.Name}— the value of HTTP headerNameon a CONNECT request. A DNS query has no headers, so a template that uses this placeholder can never resolve for DNS traffic — see the fallback behavior below.
allowlist_url_template: "https://policy.internal/tenants/{ip}/allow.txt"
denylist_url_template: "https://policy.internal/tenants/{ip}/deny.txt"Resolved lists are fetched once on first use, cached, and refreshed every
policy_refresh_interval. If a refresh fails, agentgated keeps serving the
last successfully fetched list rather than clearing it or failing the
request. If a template can't be resolved for a given request (e.g. a
{header.*} placeholder on a DNS query, or a missing header), or its first
fetch fails before anything is cached, agentgated falls back to the static
allowlist_file/denylist_file for that request.
Security note: agentgated substitutes these values into a URL — it does
not authenticate them. A bare {header.*} placeholder is only as trustworthy
as your network is at preventing a client from setting that header itself;
an agent that can set its own headers on its own CONNECT requests can set
that header to whatever it wants. {ip} is a stronger signal in topologies
where each tenant genuinely has its own unspoofable source IP, but is not
sufficient behind a shared NAT/gateway. Ensuring the identifying value can't
be spoofed for your topology (e.g. verifying a real Proxy-Authorization
credential upstream before a header is ever trusted, or enforcing one IP per
tenant at the network level) is the deploying admin/architect's
responsibility.
cp configs/agentgated.yaml.example configs/agentgated.yaml
go run ./cmd/agentgated -config ./configs/agentgated.yaml
dig @127.0.0.1 -p 5353 example.com # if dns_listen: ":5353" in your configBinding to port 53 requires root; for local testing without sudo, set
dns_listen: "127.0.0.1:5353" in your config. filter_mode: allow is the
default, so example.com above only resolves because it's already in
configs/allowlist.txt.example.
To test the CONNECT proxy, set connect_listen: "127.0.0.1:3128" and:
curl -x http://127.0.0.1:3128 https://example.comAn agent process can be sandboxed by setting HTTPS_PROXY=http://127.0.0.1:3128
(and HTTP_PROXY if it makes plain HTTP requests too — only CONNECT tunnels
are currently supported, so plain-HTTP proxying isn't filtered).
What hostname matching does guarantee: an allow/deny entry only matches
itself and its real subdomains — google.com matches mail.google.com but
never badgoogle.com, xgoogle.com, or any other look-alike glued on
without a label boundary (internal/filter's Contains splits on .
rather than doing a raw string-suffix check, so this can't regress silently;
see TestListDoesNotMatchLookalikeDomains). A CONNECT target that's a raw
IP literal (an agent that already has an address and skips hostname
resolution) is never implicitly allowed by "not on the deny list" — unlike
a hostname, there's no way to enumerate bad IPs in advance, so a raw IP must
be explicitly present on the allow list to pass, in every mode except
none.
What the CONNECT proxy additionally guarantees, independent of hostname
policy (see the connect_* options above):
- Port restriction. A target's port must be in
connect_allowed_ports(default: 443 only). An allowed hostname can't be reached on a different port running a different service — notably a DNS-over-TLS resolver (853). - A hard-override denylist.
connect_denylist_filealways blocks a matching host, even underfilter_mode: noneor an allow list — static or dynamically resolved viaallowlist_url_template— that includes it; deny wins over allow here, with no override mechanism other than editing the file. It ships seeded with known public DNS-over-HTTPS/DNS-over-TLS resolver hostnames (configs/connect-denylist.txt.example), since on port 443 those are otherwise indistinguishable from ordinary HTTPS traffic — letting an allowed tunnel become a channel for resolving (and exfiltrating data through) arbitrary domains entirely outside agentgated's own DNS filtering. - Private/internal-address blocking.
connect_block_private_ips(defaulttrue) rejects a target resolving to a loopback, link-local (including a cloud metadata endpoint like169.254.169.254), private, unspecified, or shared/carrier-grade-NAT (100.64.0.0/10— notably what overlay networks like Tailscale use) address, regardless of any allow list; Go's ownnet.IP.IsPrivateonly covers RFC 1918 + RFC 4193 and its documentation says as much ("should not be used for access control"), so this is deliberately broader than that one check. It also looks inside 6to4, NAT64, and Teredo IPv6 addresses, each of which can carry an otherwise-disallowed IPv4 address encoded in its low bits behind what otherwise looks like ordinary global-unicast IPv6. The resolved address is validated once and dialed directly rather than the hostname a second time, so a DNS answer that changes between the check and the dial can't slip a disallowed address through — and if resolution itself fails, the target is blocked rather than falling through to the dialer's own unvalidated lookup. - Bounded tunnel lifetime.
connect_idle_timeoutandconnect_max_durationclose a tunnel after inactivity or a hard ceiling, respectively, so a permitted tunnel can't stay open indefinitely.
What it does not (yet) guarantee, even with every other egress path from the network blocked and all traffic forced through agentgated's own listeners: neither proxy inspects content once a target is fully permitted.
- DNS tunneling to a permitted domain. Suffix matching says nothing
about the labels under an allowed/non-denied domain, so an agent can
encode data into query names like
<data>.api.anthropic.comand have it leave via every DNS query, regardless of what the response is. This is the gap the planned exfiltration heuristics (entropy/beaconing analysis on the query log) are meant to eventually close. - CONNECT tunnels are byte-blind once a target clears every check above (host, port, not on the built-in denylist, not a disallowed IP). Within those bounds, an agent can still send arbitrary HTTP request bodies, paths, and headers to an allowed host — e.g. exfiltrating via an allowed storage/paste/gist service's own write API — since agentgated has no visibility into what crosses an open tunnel. The port restriction and built-in denylist close the specific DNS-over-HTTPS/TLS bypass this previously allowed through any allowed hostname, but they don't give agentgated content-level visibility in general; only TLS termination (which agentgated deliberately does not do) could.
In short: tightening filter_mode: allow to a small, trusted set of
hostnames meaningfully shrinks what an agent can reach, but a hostname
allow list alone doesn't guarantee no data can leave through an allowed
destination — only that the destination is one you chose to trust, on the
port and address you meant to trust it on.
agentgated started as a Go port of dnsproxyd (Java/gcj) — same DNS filtering shape, one static binary, no JVM. It's since been repointed at a narrower problem: giving an AI agent a single, auditable point of control for what it can reach on the network, rather than being a general-purpose DNS filter.
MIT License. See LICENSE for details.