The L7 MCPEgressPolicy language
Updated July 22, 2026
The L7 MCPEgressPolicy language
Registration answers one question – may this server receive traffic at all? A
namespace opted into enforcement gets a default-deny egress, unregistered pods
fail admission, and a registered server stays dark until its image is
digest-pinned. That is a switch, not a policy. MCPEgressPolicy is the
declarative language that sits above the switch and answers the next question:
which upstreams, which tool calls, with which arguments – and what happens when
the answer is no?
This page is about the L7 half of that language: the tool-call and argument rules Hangar enforces in the data plane it already operates. It is deliberately small, and every corner of it is deterministic – glob matching and named regex groups, no scores, no heuristics, no model to tune.
Two layers, applied together
A single policy compiles into two enforcement layers. The L3/L4 backstop is a
generated NetworkPolicy or CiliumNetworkPolicy that governs which upstream
hosts a server’s pods can reach at the network level. The L7 layer runs in the
core, on the connections Hangar already proxies, and governs which tool calls –
by name – are allowed and which arguments may ride along.
One deployment fact rides on that split: matching an upstream by hostname – as
api.github.com in the example below – requires the Cilium backstop flavor. A
vanilla NetworkPolicy cannot match FQDNs, so under it a hostname upstream fails
closed and surfaces as Degraded/FQDNUpstreamsUnenforceable. Off Cilium, list
upstreams as literal IP/CIDR instead.
The relationship between them is the whole trust model, stated verbatim in the API: a policy without the network backstop is a suggestion. If a pod can bypass DNS and NetworkPolicy, it can bypass Hangar. The backstop is what turns the L7 rules from a recommendation into enforcement. The L7 language is expressive precisely because the backstop guarantees the traffic cannot route around it.
The shape of a rule
A policy is deny-by-default. defaultAction: Deny means a tool name that no rule
matches is refused, and an empty upstreams list denies everything except the
DNS and backstop paths – a locked-down server that can resolve names but reach no
upstream. You open holes deliberately, per upstream:
upstreams:
- name: github
match:
host: api.github.com
tools:
allow: ["get_*", "list_*"]
requireApproval: ["create_*"]
arguments:
deny:
secretPatterns: [aws-keys, github-tokens]
maxPayloadBytes: 262144
The match block references trust primitives that already exist rather than
duplicating them – a per-tenant tool-schema digest pin, the image pin, the set of
token issuers that may be brokered to this upstream. The policy does not reinvent
identity; it composes with it.
Tool-call matching: a fixed precedence
Tool names resolve by glob, and the precedence is fixed and total:
deny > requireApproval > allow > defaultAction.
deny rejects. allow permits. defaultAction catches everything unmatched.
Globs are case-sensitive – get_* does not match GET_user – because
determinism means no locale-dependent or ambiguous matching. There is exactly one
correct outcome for any tool name under any policy, and you can compute it by hand.
requireApproval is the interesting one. In the compiled document it is its own
outcome – REQUIRE_APPROVAL, distinct from deny – and in 1.6.0 it
fails closed: a matched call is blocked
pending an out-of-band approval, not paused on a live session waiting for a click.
Read it as “this class of call is refused unless it has been approved out of
band,” not “pause here and ask.” Routing a gated call into an interactive
approval workflow is a planned follow-up, which is why the shipped verdict simply
blocks.
Argument scanning: deny always wins
Above the tool name, the L7 layer inspects arguments. Two deterministic checks:
Secret patterns. secretPatterns names groups – aws-keys, jwt,
github-tokens, stripe-keys, slack-tokens, pem-blocks, and more – each
mapping to a value-regex. Crucially these are the same regexes Hangar’s output
redactor uses, so what the redactor masks on the way out is exactly what a policy
refuses on the way in. A credential in a tool-call argument is caught before it
crosses the wire.
Payload size. maxPayloadBytes rejects tool-call arguments larger than the
limit – a blunt but deterministic guard against exfiltration-shaped payloads.
The rule that ties it together: a secret pattern or an oversized payload denies the call even when the tool name is allowed. Deny always wins. And arguments that cannot even be serialized for inspection fail closed too – ambiguity resolves to denial, never to “probably fine.” Full DLP and any ML-based classification are explicit non-goals. The scanner does one narrow, legible job, and refusing to grow into a heuristic classifier is the point, not a limitation.
What deploying it looks like
The operator compiles a policy’s per-upstream tools and arguments rules into a
single per-server document – the union of the allow/deny/approval globs and
secret-pattern groups, and the smallest (most restrictive) maxPayloadBytes –
and pushes the compiled policy to core over the REST API (POST/DELETE
/api/mcp_servers/{id}/l7_policy). That delivery is gated on running the operator with --hangar-url:
without it, the compiled L7 document never reaches the data plane and only the
L3/L4 backstop applies. This is the seam to watch when a policy looks applied but
tool rules are not biting.
The evaluator itself – the deterministic engine described on this page – ships in
core 1.6.0 and defines the contract: given a compiled document and a tool call, it
returns exactly one verdict, and a denied call raises before it reaches the
upstream at the tool-invocation chokepoint. The operator’s MCPEgressPolicy
controller – the half that compiles the CR and pushes L7 to the core – ships in
operator v0.14.0. End-to-end L7 therefore has a plain version requirement:
core 1.6.0+ and operator v0.14.0+, both released today. Delete the policy and
it clears from the core.
Because rules are merged per server, host-specific tool rules that must stay apart
belong in separate policies. And mode defaults to Audit: a policy observes and
records violations before it blocks, giving a Gatekeeper-style adoption path. Flip
it to Enforce when you have watched enough. A policy left in Audit enforces
nothing – deliberately, and it is surfaced in status.
Grounded in ADR-013
(explicit-proxy enforcement + generated network backstop) and the
Egress Policy guide. The core evaluator ships in
1.6.0; end-to-end delivery needs the operator’s MCPEgressPolicy controller,
shipped in operator v0.14.0. Run core 1.6.0+ and operator v0.14.0+ for
end-to-end L7. Everything is MIT and self-hosted – no SaaS.