{"slug": "two-agentgateway-cel-gotchas-one-fails-open-one-fails-closed", "title": "Two agentgateway CEL Gotchas: One Fails Open, One Fails Closed", "summary": "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.", "body_md": "*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.*\n\nI 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.\n\nThe 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.\n\nThe 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.\n\nTwo 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.\n\nWritten the obvious way, that is two entries in `matchExpressions`:\n\n```\ntraffic:\n  authorization:\n    action: Allow\n    policy:\n      matchExpressions:\n      - \"has(jwt.country) && !(jwt.country in ['CU', 'IR', 'KP', 'SY'])\"\n      - \"json(request.body).model in ['claude-sonnet-4-6', 'claude-haiku-4-5']\"\n```\n\nThat reads as \"both must hold\". It is not what it does.\n\nI 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`.\n\nWith the two-entry policy above:\n\n| Caller | Model | Expected | Actual | \n|---|---|---|---|\n| maria (US) | approved | 200 | 200 | \n| pat (IR) | approved | **403** | **200** | \n| maria (US) | not approved | **403** | **200** | \n| no JWT | approved | 401 | 401 | \n\nBoth of the requests that should have been refused went through to the provider.\n\nThe 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.\n\nThe [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.\n\nThe fix is to stop treating the list as a conjunction and write one expression:\n\n```\nmatchExpressions:\n- >-\n  has(jwt.country) && !(jwt.country in ['CU', 'IR', 'KP', 'SY'])\n  && json(request.body).model in ['claude-sonnet-4-6', 'claude-haiku-4-5']\n```\n\nSame two conditions, one entry, joined with `&&`. Now the table comes out right:\n\n| Caller | Model | Result | \n|---|---|---|\n| maria (US) | approved | 200 | \n| pat (IR) | approved | **403** | \n| maria (US) | not approved | **403** | \n| no JWT | approved | 401 | \n\nWhat 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.\n\nIf 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.\n\nFixing 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:\n\n```\nmatchExpressions:\n- \"llm.requestModel in ['claude-sonnet-4-6', 'claude-haiku-4-5']\"\n```\n\nEvery 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.\n\n`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.\n\nThis 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.\n\nWhen 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:\n\n```\nkubectl patch eagpol cel-probe -n agentgateway-system --type=json \\\n  -p \"[{\\\"op\\\":\\\"replace\\\",\\\"path\\\":\\\"/spec/traffic/authorization/policy/matchExpressions\\\",\\\"value\\\":[\\\"$EXPR\\\"]}]\"\n```\n\n| Expression under test | approved model | unapproved model | \n|---|---|---|\n| `true` | 200 | 200 | \n| `'admins' in jwt.Groups` | 200 | 200 | \n| `jwt.preferred_username == 'demo'` | 200 | 200 | \n| `has(llm.requestModel)` | **403** | **403** | \n| `llm.requestModel in [...]` | **403** | **403** | \n| `json(request.body).model in [...]` | 200 | **403** | \n\nThe 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.\n\n`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.\n\nBoth findings in one resource:\n\n```\napiVersion: enterpriseagentgateway.solo.io/v1alpha1\nkind: EnterpriseAgentgatewayPolicy\nmetadata:\n  name: governed-llm-access\n  namespace: agentgateway-system\nspec:\n  targetRefs:\n  - group: gateway.networking.k8s.io\n    kind: HTTPRoute\n    name: governed-llm\n  traffic:\n    authorization:\n      action: Allow\n      policy:\n        matchExpressions:\n        - >-\n          has(jwt.country) && !(jwt.country in ['CU', 'IR', 'KP', 'SY'])\n          && json(request.body).model in ['claude-sonnet-4-6', 'claude-haiku-4-5', 'acme-standard', 'acme-premium']\n```\n\n`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.\n\nA 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.\n\nThey 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.\n\nThat 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.\n\n```\n# should pass\ncurl -s -o /dev/null -w '%{http_code}\\n' localhost:8081/governed-llm/v1/chat/completions \\\n  -H \"Authorization: Bearer $MARIA\" -H 'content-type: application/json' \\\n  -d '{\"model\":\"acme-standard\",\"max_tokens\":8,\"messages\":[{\"role\":\"user\",\"content\":\"Say OK.\"}]}'\n\n# violates the country rule only\ncurl -s -o /dev/null -w '%{http_code}\\n' localhost:8081/governed-llm/v1/chat/completions \\\n  -H \"Authorization: Bearer $PAT\" -H 'content-type: application/json' \\\n  -d '{\"model\":\"acme-standard\",\"max_tokens\":8,\"messages\":[{\"role\":\"user\",\"content\":\"Say OK.\"}]}'\n\n# violates the model rule only\ncurl -s -o /dev/null -w '%{http_code}\\n' localhost:8081/governed-llm/v1/chat/completions \\\n  -H \"Authorization: Bearer $MARIA\" -H 'content-type: application/json' \\\n  -d '{\"model\":\"claude-opus-4-1\",\"max_tokens\":8,\"messages\":[{\"role\":\"user\",\"content\":\"Say OK.\"}]}'\n\n# no credential\ncurl -s -o /dev/null -w '%{http_code}\\n' localhost:8081/governed-llm/v1/chat/completions \\\n  -H 'content-type: application/json' \\\n  -d '{\"model\":\"acme-standard\",\"max_tokens\":8,\"messages\":[{\"role\":\"user\",\"content\":\"Say OK.\"}]}'\n```\n\nExpected: `200`, `403`, `403`, `401`. Anything else and one of the two bugs above is in your policy.\n\nThe demo lives in [themsquared/agentic-demo](https://github.com/themsquared/agentic-demo). It needs a Solo Enterprise license, since `EnterpriseAgentgatewayPolicy` is an enterprise CRD.\n\n```\n./setup.sh              # k3d cluster, mesh, gateway, agents (~15 min)\n./port-forward.sh\n./governance-demo.sh --check\n```\n\n`--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.\n\nThe 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.\n\nNext 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.\n\n**Are agentgateway matchExpressions AND'ed or OR'ed together?**\n\nThey 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.\n\n**Why does my agentgateway CEL policy return 403 for every request?**\n\nMost 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.\n\n**How do I check which LLM model a caller requested in a CEL policy?**\n\nAt 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.\n\n*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/)*", "url": "https://wpnews.pro/news/two-agentgateway-cel-gotchas-one-fails-open-one-fails-closed", "canonical_source": "https://dev.to/webofmike/two-agentgateway-cel-gotchas-one-fails-open-one-fails-closed-17eg", "published_at": "2026-09-18 16:09:49+00:00", "updated_at": "2026-09-18 16:22:54.798057+00:00", "lang": "en", "topics": ["ai-agents", "ai-infrastructure", "ai-tools", "developer-tools"], "entities": ["Solo Enterprise for agentgateway", "agentgateway", "Keycloak", "webofmike.com", "themsquared/agentic-demo", "Solo.io"], "alternates": {"html": "https://wpnews.pro/news/two-agentgateway-cel-gotchas-one-fails-open-one-fails-closed", "markdown": "https://wpnews.pro/news/two-agentgateway-cel-gotchas-one-fails-open-one-fails-closed.md", "text": "https://wpnews.pro/news/two-agentgateway-cel-gotchas-one-fails-open-one-fails-closed.txt", "jsonld": "https://wpnews.pro/news/two-agentgateway-cel-gotchas-one-fails-open-one-fails-closed.jsonld"}}