24 -- Constrain Outbound Calls with the L7 Egress Policy Language
Prerequisite: 11 -- Discovery: Kubernetes You will need: The operator with the
MCPEgressPolicyCRD installed, a namespace opted into egress enforcement (mcp-hangar.io/enforce-egress=true), the operator run with--hangar-url(for the L7 half), and Cilium if any upstream is an FQDN. See the Egress Policy guide for the full prerequisites and trust model.Version requirement: end-to-end L7 delivery needs core v1.6.0+ (the L7 engine and REST intake) and operator v0.14.0+ (the
MCPEgressPolicycontroller — the half that compiles the CR and pushes L7 to the core). Both are released, so a policy delivers end-to-end today. On an operator older than v0.14.0 the controller is absent and only the L3/L4 network backstop applies. Time: Read first, then plan -- this is a policy-language reference, not a 20-minute run Adds: A declarative, deny-by-defaultMCPEgressPolicythat controls which upstreams a server may reach, which tool calls it may make, and what happens when a call carries a secret or an oversized payload Concept: The L7 MCPEgressPolicy language
The Problem
Registration answers "may this server receive traffic at all?" It does not answer the question that actually bounds a compromised or misbehaving provider: which upstreams, which tool calls, with which arguments -- and what happens when the answer is no. Without that layer, a registered server that gets popped can still call any tool it exposes, exfiltrate a secret in a tool argument, or reach any host it likes.
MCPEgressPolicy is the declarative policy language above the binary
registration switch. It is deny-by-default: you list the upstreams a server
may reach and the tool calls it may make, and everything else is refused.
This is not tool_access.mode: egress
Two very different things share the word "egress." Do not confuse them:
tool_access.mode: egress | MCPEgressPolicy (this recipe) | |
|---|---|---|
| Direction | Inbound -- how Hangar treats the callers in front of it | Outbound -- what a server may do to its upstreams |
| What it is | A topology/trust mode (the default; trusted internal callers see the full hangar_* meta-API) -- see Front-Door Mode | A Kubernetes CRD compiled and enforced by the operator + core |
| Governs | Client-facing tool projection and caller trust | Upstream hosts, tool-call globs, and argument content |
tool_access.mode: egress is a client-facing posture. MCPEgressPolicy is an
outbound enforcement policy. They are orthogonal and can both be in effect at
once.
How It Enforces (two layers)
Enforcement is two layers applied together (see ADR-013):
| Layer | Enforced by | Governs |
|---|---|---|
| L3/L4 (network backstop) | operator → NetworkPolicy / CiliumNetworkPolicy | which upstream hosts/CIDRs the pods can reach |
| L7 (semantics) | core, on the connections Hangar proxies | which tool calls (by name) and which arguments are allowed |
The trust boundary is explicit: a policy without the network backstop is a
suggestion. If a pod can bypass DNS and NetworkPolicy, it can bypass Hangar.
The L7 half is only delivered when the operator runs with --hangar-url;
otherwise only the L3/L4 backstop applies.
The Config
A complete policy. Every field below mirrors the Egress Policy guide spec reference.
# egress-policy.yaml -- Recipe 24: deny-by-default L7 egress policy
apiVersion: mcp-hangar.io/v1alpha2
kind: MCPEgressPolicy
metadata:
name: gh-only
namespace: prod # namespace must carry mcp-hangar.io/enforce-egress=true
spec:
mode: Enforce # Audit (default) observes; Enforce blocks
targetRef:
kind: MCPServer # or MCPServerGroup (applies to every member)
name: srv
defaultAction: Deny # deny-by-default: outcome for any tool no rule matches
upstreams:
- name: github
match:
host: api.github.com # FQDN -> requires the Cilium backstop flavor
tools:
allow: ["get_*", "list_*"] # glob allow-list
deny: ["*_admin"] # deny wins over everything
requireApproval: ["create_*"] # held for a human decision (see below)
arguments:
deny:
secretPatterns: [aws-keys, jwt, github-tokens] # reject calls carrying these
maxPayloadBytes: 262144 # reject oversized argument payloads
networkBackstop:
generate: true # emit the L3/L4 backstop
flavor: Auto # Auto | Cilium | Vanilla
Deny everything
defaultAction: Deny with no upstreams is a locked-down server -- it can
resolve DNS but reach no upstream:
apiVersion: mcp-hangar.io/v1alpha2
kind: MCPEgressPolicy
metadata: {name: lockdown, namespace: prod}
spec:
mode: Enforce
targetRef: {kind: MCPServer, name: srv}
# no upstreams -> nothing is allowed out
The Policy Language
Tool-call matching (glob, precedence order)
A tool name is resolved by glob in precedence order -- the first match wins:
deny— reject.requireApproval— held until a human approves it (refused if denied or expired).allow— permit.- otherwise — the policy's
defaultAction(Denyby default).
Globs are case-sensitive for determinism (get_* does not match GET_user).
requireApprovalholds the call in Hangar's approval queue. Undermode: Enforcea match does not reach the upstream: the call waits as a pending approval (GET /api/approvals, loggedegress_policy_approval_routing) until someone resolves it withPOST /api/approvals/{id}/resolveand{"decision": "approve"}or"deny". Approved, it proceeds; denied or expired, it is refused. Undermode: Auditnothing is held. Nobody watching the queue means every such call waits and then fails, so wire an approval channel before you rely on it.
Argument scanning
A tool call is rejected -- even when the tool name is allowed -- if its
arguments contain a configured secret pattern or exceed maxPayloadBytes. Deny
always wins. Arguments that cannot be serialized for inspection also fail closed.
secretPatterns names deterministic value-regex groups (no ML, no heuristics),
shared with Hangar's output redactor -- what the redactor masks on the way out is
what a policy refuses on the way in:
| Group | Detects |
|---|---|
aws-keys | AWS access key IDs (AKIA…) |
jwt | JSON Web Tokens |
pem-blocks | PEM private-key blocks |
github-tokens | GitHub PATs / OAuth / server / refresh tokens |
stripe-keys | Stripe live/test/restricted keys |
slack-tokens | Slack tokens (xox…) |
google-api-keys | Google API keys (AIza…) |
bearer-tokens | Bearer … credentials |
npm-tokens, pypi-tokens | npm / PyPI tokens |
What argument scanning does and does not catch.
secretPatternsmatches the literal shape of a secret in the serialized arguments. It is a deterministic tripwire against a secret being passed in the clear -- an agent pasting an AWS key or a bearer token into a tool call -- not a defense against a motivated exfiltrator. Any encoding defeats it: URL-encoding the separators, base64-wrapping the value, or splitting a token across two arguments all pass a value that no longer matches the pattern, and the receiving upstream decodes it on the far side. Treat it as leak detection for well-formed secrets, and put the real boundary on which upstreams a tool may reach at all (tools.allow/denyand the L3/L4 backstop) -- an argument a policy cannot inspect cannot be trusted, but an upstream a policy never allows cannot be reached regardless of how the argument is dressed up.
Try It
-
Apply the policy to a governed namespace and confirm it compiled:
kubectl apply -f egress-policy.yaml kubectl -n prod get mcpegresspolicy gh-only \ -o jsonpath='{range .status.conditions[*]}{.type}={.status} ({.reason}){"\n"}{end}'Expected -- compiled, backstop in place and observed to be enforced, L7 policy taken by core, not degraded (the order of the lines may differ):
Compiled=True (Compiled) BackstopApplied=True (BackstopApplied) BackstopEnforceable=True (EnforcerObserved) L7Delivered=True (Delivered) Degraded=False (NotDegraded)L7Deliveredarrived in operator 0.17.5.FalsewithCoreAuthRejected,CoreUnreachableorPushFailedmeans core refused the push (the policy is then alsoDegraded=True/L7PushFailed);Unknown/CoreIntegrationOffmeans the operator runs without--hangar-url. -
Prove the network backstop. From a pod behind the policy, the allow-listed host answers and any other host times out, while DNS still resolves:
# allowed upstream -> reachable kubectl -n prod exec deploy/srv -- curl -sS -o /dev/null -w "%{http_code}\n" https://api.github.com # 200-ish # any other host -> connection times out kubectl -n prod exec deploy/srv -- curl -sS --max-time 5 https://example.com ; echo "exit=$?" # non-zero -
Prove the L7 half (requires the operator running with
--hangar-url). A tool call that is allowed by name but carries a secret in its arguments is still refused at the invocation chokepoint, before it reaches the upstream -- deny wins over allow. The call fails withEgressPolicyDeniedError,Tool call denied by egress policy; so does an argument payload overmaxPayloadBytes.
What Just Happened
The operator compiles the policy's per-upstream tools/arguments rules into a
single per-server policy -- the union of the upstreams' allow/deny/require-approval
globs and secret-pattern groups, and the most restrictive (smallest)
maxPayloadBytes -- and pushes it to the core over the same channel that already
feeds routing config (this needs --hangar-url). The same reconcile emits the
L3/L4 backstop:
Vanilla— a standardNetworkPolicy: default-deny egress, DNS allowed, plus any upstream whosehostis a literal IP/CIDR. FQDN upstreams are failed closed (denied, never opened to "any destination") and surfaced asDegraded/FQDNUpstreamsUnenforceable.Cilium— aCiliumNetworkPolicywithtoFQDNs, which enforces hostname allow-lists via an L7 DNS-proxy rule.Auto(default) — Cilium if its CRD is installed, otherwise Vanilla.
Because the core enforces one policy per server (not per upstream
connection), a policy's per-upstream tools/arguments rules are flattened
together. If you need host-specific tool rules kept apart, use separate policies.
The whole design is fail-closed by construction: deny-default + a generated
backstop + a Degraded condition mean a policy that cannot compile its backstop,
or whose L7 rules core refused, is visibly unsafe rather than silently
permissive. Deleting the policy clears
the L7 rules from the core.
Key Config Reference
| Field | Type | Default | Description |
|---|---|---|---|
spec.mode | Audit | Enforce | Audit | Audit observes; Enforce blocks (Gatekeeper-style adoption path) |
spec.targetRef.kind | MCPServer | MCPServerGroup | — | What the policy attaches to (a group covers every member) |
spec.defaultAction | Deny | Allow | Deny | Outcome for a tool name no upstreams[].tools rule matches |
spec.upstreams[].match.host | string | — | FQDN (needs Cilium) or literal IP/CIDR (any CNI) |
spec.upstreams[].tools.allow/deny/requireApproval | list of globs | — | Precedence: deny > requireApproval (held for approval) > allow > defaultAction |
spec.upstreams[].arguments.deny.secretPatterns | list | — | Named secret-pattern groups to reject |
spec.upstreams[].arguments.deny.maxPayloadBytes | integer | — | Reject argument payloads larger than this |
spec.networkBackstop.generate | bool | true | Emit the L3/L4 backstop. false drops only the backstop; the L7 rules are still delivered (operator 0.17.5) |
spec.networkBackstop.flavor | Auto | Cilium | Vanilla | Auto | Backstop implementation |
What's Next
You now have a deny-by-default egress policy language over both the network and the tool/argument layers.
- Full spec, status conditions, and backstop flavors -- Egress Policy guide.
- The enforcement model and the alternatives that were rejected -- ADR-013.
- How this maps to OWASP MCP09 -- OWASP MCP Top 10 coverage.
- The public-edge capstone that references this as a compromised-backend control -- 23 -- Harden a Public Authenticated Gateway.