cd /news/ai-agents/two-agentgateway-cel-gotchas-one-fai… · home topics ai-agents article
[ARTICLE · art-133861] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=↓ negative

Two agentgateway CEL Gotchas: One Fails Open, One Fails Closed

A developer documented two CEL authorization pitfalls in Solo Enterprise for agentgateway, where a policy restricting LLM access by JWT country claim and requested model silently failed open. Writing each condition as a separate entry in matchExpressions ORs the rules together, allowing a caller from a blocked jurisdiction (pat, IR) to reach an approved model with a 200 response instead of the expected 403. The fix combines both conditions into a single expression joined with &&, which correctly denies the disallowed requests.

by read8 min views2 publishedSep 18, 2026

Originally published at webofmike.com on 2026-09-18. The demo repo and every command in it were run before publishing.

I was writing a CEL authorization policy for an LLM route in Solo Enterprise for agentgateway: restrict which models a caller may reach, and refuse callers whose identity provider says they are in a jurisdiction the company cannot serve. Two conditions, both read off the same request.

The first version I wrote allowed a request it should have denied. The second version denied every request, including the ones that should have passed. Neither one reported an error. Both showed Accepted and Attached in the policy status.

The working policy and the demo that proves it are in themsquared/agentic-demo under manifests/governance/. Everything below was verified against a live cluster running v2026.8.2.

Two rules on a route called governed-llm. The caller's JWT carries a country claim from the identity provider. The request body names a model.

Written the obvious way, that is two entries in matchExpressions:

traffic:
  authorization:
    action: Allow
    policy:
      matchExpressions:
      - "has(jwt.country) && !(jwt.country in ['CU', 'IR', 'KP', 'SY'])"
      - "json(request.body).model in ['claude-sonnet-4-6', 'claude-haiku-4-5']"

That reads as "both must hold". It is not what it does.

I have two test users from the same Keycloak realm, in the same group, with the same permissions. The only difference is the country claim: maria is US, pat is IR.

With the two-entry policy above:

Caller Model Expected Actual
maria (US) approved 200 200
pat (IR) approved 403 200
maria (US) not approved 403 200
no JWT approved 401 401

Both of the requests that should have been refused went through to the provider.

The reason is that entries in matchExpressions are OR'ed. A request is allowed when any expression evaluates true. pat fails the country rule but satisfies the model rule, so the policy allows the request. maria asking for an unapproved model is the mirror image: the country rule passes, so the model rule never gets to matter.

The documentation does state the behavior, in one sentence: requests that do not match any of the conditions are denied. Read closely that is unambiguous. Read at the speed you actually read reference docs, next to a YAML block with a list under it, and "a list of conditions" looks like a list of requirements.

The fix is to stop treating the list as a conjunction and write one expression:

matchExpressions:
- >-
  has(jwt.country) && !(jwt.country in ['CU', 'IR', 'KP', 'SY'])
  && json(request.body).model in ['claude-sonnet-4-6', 'claude-haiku-4-5']

Same two conditions, one entry, joined with &&. Now the table comes out right:

Caller Model Result
maria (US) approved 200
pat (IR) approved 403
maria (US) not approved 403
no JWT approved 401

What makes this one worth writing down is the direction of the failure. A policy with one entry per rule is the natural way to write it, it looks correct in review, the resource reports healthy, and it permits traffic. Nothing surfaces until someone audits denials that never happened.

If your allowlist is per-credential rather than per-claim, there is a second place to put it: agentgateway v1.5.0 added an allowedModels list directly on the API key, which I covered in per-key LLM budgets that return 429. The CEL route is the one to use when the decision depends on something in the token rather than on which key was presented.

Fixing the first bug, I reached for what looked like the correct variable. agentgateway exposes an llm context with the model, the provider, token counts, and realized cost. Reading the model from there is cleaner than parsing the body:

matchExpressions:
- "llm.requestModel in ['claude-sonnet-4-6', 'claude-haiku-4-5']"

Every request now returned 403. Not just the ones naming an unapproved model. All of them, including a request for a model literally present in that list.

llm.requestModel exists, and it is documented. It belongs to the backend AI phase, which runs after routing has selected a backend. A traffic.authorization policy runs earlier, at the route level. At that point the llm context has not been populated, the expression cannot evaluate true, and a policy whose action is Allow denies everything.

This one fails closed, which is the safer direction, but it is confusing in a specific way: the policy looks like it is working. Requests for unapproved models get 403, exactly as designed. You only catch it if your test set includes a request that is supposed to succeed.

When a CEL expression silently never matches, the fastest way to find out why is to hold the request constant and vary only the expression. I patched one field on the live policy and re-ran the same two requests each time, one naming an approved model and one naming an unapproved model:

kubectl patch eagpol cel-probe -n agentgateway-system --type=json \
  -p "[{\"op\":\"replace\",\"path\":\"/spec/traffic/authorization/policy/matchExpressions\",\"value\":[\"$EXPR\"]}]"
Expression under test approved model unapproved model
true 200 200
'admins' in jwt.Groups 200 200
jwt.preferred_username == 'demo' 200 200
has(llm.requestModel) 403 403
llm.requestModel in [...] 403 403
json(request.body).model in [...] 200 403

The first three lines prove the policy is attached and that JWT claims resolve fine at this phase. Line four is the diagnosis: has(llm.requestModel) is false, so the variable is not merely holding an unexpected value, it is absent. The last line is the working form.

has() is the probe worth remembering. It separates "this variable holds something I did not expect" from "this variable does not exist here", and those have completely different fixes.

Both findings in one resource:

apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata:
  name: governed-llm-access
  namespace: agentgateway-system
spec:
  targetRefs:
  - group: gateway.networking.k8s.io
    kind: HTTPRoute
    name: governed-llm
  traffic:
    authorization:
      action: Allow
      policy:
        matchExpressions:
        - >-
          has(jwt.country) && !(jwt.country in ['CU', 'IR', 'KP', 'SY'])
          && json(request.body).model in ['claude-sonnet-4-6', 'claude-haiku-4-5', 'acme-standard', 'acme-premium']

acme-standard and acme-premium are virtual model names, mapped to real models by a separate modelAliases policy on the backend. Callers ask for a tier, the platform team decides what that tier means today, and the allowlist keeps naming the same two strings when the underlying model changes.

A denied request gets HTTP 403 with the body authorization failed, and the caller never reaches the provider. That is the part that matters for a jurisdiction rule: nothing was sent upstream, so there is nothing exported, nothing logged on the provider side, and nothing billed.

They are the same class of bug seen from both sides. In each case the policy compiles, the controller reports it healthy, and the resource status says Accepted and Attached. The only signal is the HTTP status of a request you have to think to send.

That suggests a test set rather than a review habit. For any allow-style authorization policy, send four requests: one that should pass, one that fails each condition independently, and one with no credential at all. The OR bug is invisible unless you send a request that violates exactly one condition. The phase bug is invisible unless you send one that violates none.

curl -s -o /dev/null -w '%{http_code}\n' localhost:8081/governed-llm/v1/chat/completions \
  -H "Authorization: Bearer $MARIA" -H 'content-type: application/json' \
  -d '{"model":"acme-standard","max_tokens":8,"messages":[{"role":"user","content":"Say OK."}]}'

curl -s -o /dev/null -w '%{http_code}\n' localhost:8081/governed-llm/v1/chat/completions \
  -H "Authorization: Bearer $PAT" -H 'content-type: application/json' \
  -d '{"model":"acme-standard","max_tokens":8,"messages":[{"role":"user","content":"Say OK."}]}'

curl -s -o /dev/null -w '%{http_code}\n' localhost:8081/governed-llm/v1/chat/completions \
  -H "Authorization: Bearer $MARIA" -H 'content-type: application/json' \
  -d '{"model":"claude-opus-4-1","max_tokens":8,"messages":[{"role":"user","content":"Say OK."}]}'

curl -s -o /dev/null -w '%{http_code}\n' localhost:8081/governed-llm/v1/chat/completions \
  -H 'content-type: application/json' \
  -d '{"model":"acme-standard","max_tokens":8,"messages":[{"role":"user","content":"Say OK."}]}'

Expected: 200, 403, 403, 401. Anything else and one of the two bugs above is in your policy.

The demo lives in themsquared/agentic-demo. It needs a Solo Enterprise license, since EnterpriseAgentgatewayPolicy is an enterprise CRD.

./setup.sh              # k3d cluster, mesh, gateway, agents (~15 min)
./port-forward.sh
./governance-demo.sh --check

--check runs the whole governance walkthrough non-interactively and asserts 24 outcomes, including the four status codes above. The authorization act is --act 2 if you only want that part.

The policy discussed here is 02-ofac-model-allowlist.yaml, and both gotchas are written into the file's header comment so the next person to edit it does not re-derive them.

Next on this route: the same request body, read by a web application firewall instead of a policy engine, so the prompt itself gets inspected rather than just the claims around it.

Are agentgateway matchExpressions AND'ed or OR'ed together?

They are OR'ed. A request is allowed when any single expression in the list evaluates true, so writing two entries produces a policy that permits a request satisfying either one. To require several conditions at once, join them with && inside one expression. The failure is silent: both forms report Accepted and Attached in the policy status, and only a request that should have been denied reveals the difference.

Why does my agentgateway CEL policy return 403 for every request?

Most often because the expression references a variable that is not populated in the phase where the policy runs. Under traffic.authorization the llm.* variables are empty, so llm.requestModel resolves to nothing and the expression is never true, denying every request including valid ones. Read the model from json(request.body).model instead, which is available at that phase.

How do I check which LLM model a caller requested in a CEL policy?

At the traffic authorization phase, parse the request body: json(request.body).model. The llm.requestModel variable exists but belongs to the backend AI phase, which runs after routing, so it is unavailable to a route-level authorization policy. Verified on Solo Enterprise for agentgateway v2026.8.2 by testing both forms against the same route.

Canonical version, with machine-readable markdown at https://webofmike.com/agentgateway-cel-authorization-gotchas/index.md: https://webofmike.com/agentgateway-cel-authorization-gotchas/

── more in #ai-agents 4 stories · sorted by recency
── more on @solo enterprise for agentgateway 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/two-agentgateway-cel…] indexed:0 read:8min 2026-09-18 ·