最新资讯
dev.to 精简 MCP server 工具数量:更少工具、更小影响面,双重收益 dev.to 为什么 git checkout 无法撤销 AI 编码 agent 的改动? wechat AI短剧门槛降降降!实测即梦短剧工作室:剧本分镜预演一条龙,伙伴计划长期护航 dev.to 让模型改写文档前,先量化事实保真度 dev.to 回放 153 个 Claude Code 与 Codex 会话,审计 Agent 的生产影响范围 dev.to 银行 77 意图分类最便宜 LLM:GPT-6.1 Terra 每次成功任务 $0.000727 theinformation.com Chesky的副业:Airbnb向CEO新设AI实验室出售IP theverge.com OpenAI 宣布数据中心项目不再与公职人员签署保密协议 dev.to NVIDIA 发布四个开放 Cosmos 3 仿真模型 dev.to 真实 MCP 客户端击穿我们服务器的五种方式 theverge.com Airbnb 正在开发 AI 旅行 agent,计划 2027 年上半年推出 wechat 这可能是美国AI最魔幻的一天。
多元动态
登录 注册
智能体 dev.to · @Mike Moore

agentgateway CEL 的两个陷阱:一个 fail-open,一个 fail-closed

原文 我的收藏

作者为 agentgateway 的 LLM 路由编写 CEL 授权策略,用于限制调用方可访问的模型并拒绝来自不可服务司法辖区的调用者。第一版策略放行了本应拒绝的请求,第二版则拒绝了所有请求,两者均未报错,策略状态都显示 Accepted 与 Attached。作者在运行 v2026.8.2 的实机集群上验证了可用策略,示例位于 themsquared/agentic-demo 的 manifests/governance/。

AI推荐理由 给出 LLM 网关授权策略中 fail-open/fail-closed 两类静默失败的具体案例与可复现仓库,对做 Agent 与 LLM 访问治理的开发者有实操价值;局限在于仅针对特定产品版本与单一场景。

72

正文

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. The policy I meant to write 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. The country must be present and not on a restricted list. The model must be one the AI governance group has approved. 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']" Enter fullscreen mode Exit fullscreen mode That reads as "both must hold". It is not what it does. Failure one: matchExpressions is OR, and it fails open 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'] Enter fullscreen mode Exit fullscreen mode 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. Failure two: llm.requestModel is empty when authorization runs 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']" Enter fullscreen mode Exit fullscreen mode 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. Isolating which variables actually resolve 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\"]}]" Enter fullscreen mode Exit fullscreen mode 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. The policy that works 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'] Enter fullscreen mode Exit fullscreen mode 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. Why these two belong in the same post 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."}]}' Enter fullscreen mode Exit fullscreen mode Expected: 200, 403, 403, 401. Anything else and one of the two bugs above is in your policy. Running it 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 Enter fullscreen mode Exit fullscreen mode --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. Frequently asked questions 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/

来源报道 · 1 篇 · 以下链接均前往原始媒体

← 返回精选动态 原文

更多内容