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

> Source: <https://dev.to/webofmike/two-agentgateway-cel-gotchas-one-fails-open-one-fails-closed-17eg>
> Published: 2026-09-18 16:09:49+00:00

*Originally published at [webofmike.com](https://webofmike.com/agentgateway-cel-authorization-gotchas/?utm_source=devto&utm_medium=syndication&utm_campaign=agentgateway-cel-authorization-gotchas) 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](https://docs.solo.io/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](https://github.com/themsquared/agentic-demo) under [`manifests/governance/`](https://github.com/themsquared/agentic-demo/tree/main/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](https://docs.solo.io/agentgateway/) 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](https://webofmike.com/agentgateway-per-key-llm-budgets/). 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.

```
# should pass
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."}]}'

# violates the country rule only
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."}]}'

# violates the model rule only
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."}]}'

# no credential
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](https://github.com/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`](https://github.com/themsquared/agentic-demo/blob/main/manifests/governance/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/](https://webofmike.com/agentgateway-cel-authorization-gotchas/)*
