模型降级后,工具授权为何会越权
一份实验室案例展示:任务切换到禁止 shell 的免费模型与服务器后,执行器仍沿用付费路由的授权,导致越权调用。修复方案是将授权绑定到模型等级、服务器等级和任务 ID;该案例并非真实生产事故。
一份实验室案例展示:任务切换到禁止 shell 的免费模型与服务器后,执行器仍沿用付费路由的授权,导致越权调用。修复方案是将授权绑定到模型等级、服务器等级和任务 ID;该案例并非真实生产事故。
免费模型路由绝不能继承付费路由的工具授权。这次事件重建展示了:缺失的层级字段,如何让一次被禁止的 shell 调用被认证为合规。要彻底修复这个问题,就必须将每份授权绑定到模型类别、服务器类别和任务 ID。
这条时间线没有任何具名客户的生产日志作为依据。整个过程是一套实验室测试场景,面向负责把关 AI 工具调用的审查人员。请将下文所有时间戳视为测试场景中的时间,而不是实际故障时间。
编排器在路由变更后保留了工具授权。第一跳使用的是付费类别的模型和付费类别的服务器。随后,容量不足导致同一个任务被切换到免费模型路由。
该路由同时选择了免费服务器选项。已存储的授权仍然允许使用 shell 工具,但免费路由的策略禁止 shell 工具。
执行器信任这份授权,并启动了命令。命令仍然是已经获批的 dry-run 文本。违反策略的是路由,而不是一串新的参数。
测试场景的时钟从实验室当地时间 09:00 开始。下面每一行都是实验室事件,不是状态页上的故障通报。
09:00,任务只要求生成迁移方案。
09:01,规划器绑定付费模型和付费服务器。
09:02,人工批准了一份用于 dry-run 的 shell 授权。
09:03,授权记录存储了工具名称、过期时间和任务 ID。
09:04,授权记录缺少模型类别和服务器类别。
09:07,付费路由返回容量不足。
09:08,故障切换选择了免费模型访问和免费服务器。
09:09,执行器根据任务 ID 加载此前的授权。
09:10,免费路由执行了 shell 工具。
09:11,dry-run 命令没有修改任何已存储的数据。
09:12,审计日志将这次调用标记为符合策略。
09:14,一名审查人员注意到了 span 上的服务器类别。
09:20,路由停止运行,授权签发器被冻结。
测试场景中没有迁移任何客户数据库。真正的影响在于,这次调用被错误地认证为合规。一次被禁止的工具调用,看起来却已经通过了策略审批。
之后的一次真实迁移可能复用同一个漏洞。这也是为什么审计标记比 dry-run 的结果更重要。严重程度取决于工具类别,而不是 dry-run 恰好没有造成影响的运气。
几个小疏漏叠加,最终导致了一次错误放行。任何一个疏漏都不足以单独解释整个 bug。
授权查询键只使用了任务 ID。
模型类别只是用于展示的文本,不是经过验证的字段。
服务器类别在授权签发之后才被选定。
故障切换将容量不足视为一次纯粹的传输层重试。
策略引擎检查了工具名称,却没有检查路由类别。
免费路由和付费路由共用了同一个执行器池。
授权有效期为二十分钟,因此当时仍未过期。
dry-run 的成功掩盖了底层的策略违规。
测试覆盖了授权过期,却没有覆盖层级变更。
日志记录了工具名称,却没有记录服务器类别。
不是过期的缓存条目认证了这次调用。这里也没有重放来自其他环境的 eval 回执。预热的工作区没有锁定旧 commit。
没有复用其他卷上的 lockfile。这个 bug 出在权限范围,而不是产物的新鲜度。刷新文件无法堵住这个漏洞。
在任何工具调用之前,都要将授权绑定到四项字段。只要任意绑定字段不一致,就拒绝工具调用。故障切换期间,不要原地刷新授权。
要么在新路由上签发一份新授权,要么停止。过期时间仍应保留在记录中,用于时间检查。但仅检查过期时间,不能有效验证授权范围。
任务 ID 必须与当前路由一致。
模型类别必须与当前路由一致。
服务器类别必须与当前路由一致。
工具名称和参数哈希必须匹配。
在执行器的信任边界使用这张核对表。任意一项检查失败,都应拒绝工具调用。
对于 shell 工具,重新签发授权仍然需要一次新的人工批准。只有只读工具可以自动重新签发授权。这一区分应写入策略,而不是交给执行器决定。
下面的 Python 是一份明确标注为尚未执行的提案。它没有在生产控制平面上运行过。它将拒绝规则编码出来,供本地审查使用。
from hashlib import sha256
import time
def arg_hash(args: dict) -> str:
blob = repr(sorted(args.items())).encode()
return sha256(blob).hexdigest()
def accept_grant(grant: dict, route: dict, now: float) -> str:
if now > grant["exp"]:
return "deny_expired"
if grant["task_id"] != route["task_id"]:
return "deny_task"
if grant["model_class"] != route["model_class"]:
return "deny_model_class"
if grant["server_class"] != route["server_class"]:
return "deny_server_class"
if grant["tool"] != route["tool"]:
return "deny_tool"
if grant["arg_hash"] != arg_hash(route["args"]):
return "deny_args"
return "allow"
def test_free_route_rejects_paid_grant() -> None:
now = time.time()
args = {"cmd": "migrate --dry-run"}
grant = {
"task_id": "t-17",
"model_class": "paid",
"server_class": "paid",
"tool": "shell",
"arg_hash": arg_hash(args),
"exp": now + 600,
}
route = {
"task_id": "t-17",
"model_class": "free",
"server_class": "free",
"tool": "shell",
"args": args,
}
assert accept_grant(grant, route, now) == "deny_model_class"
def test_server_class_mismatch_is_denied() -> None:
now = time.time()
args = {"cmd": "migrate --dry-run"}
grant = {
"task_id": "t-17",
"model_class": "free",
"server_class": "paid",
"tool": "shell",
"arg_hash": arg_hash(args),
"exp": now + 600,
}
route = {
"task_id": "t-17",
"model_class": "free",
"server_class": "free",
"tool": "shell",
"args": args,
}
assert accept_grant(grant, route, now) == "deny_server_class"
运行前,先将测试保存为 test_grant_scope.py。在该文件所在文件夹中,以安静模式运行 pytest。预期结果是两个本地测试通过。
python -m pytest -q test_grant_scope.py
测试通过,意味着类别不匹配的调用会被拒绝。测试通过并不代表衡量了模型输出质量。在信任 CI 结果之前,先将同一个函数接入执行器。
每次因容量不足而发生故障切换时,都要按这个顺序执行。不要因为上一跳成功了,就跳过任何步骤。
首先停止该任务的工具执行器。
从新路由读取模型类别和服务器类别。
将这两个字段与已存储的授权进行比较。
任意字段不匹配,都拒绝工具调用。
丢弃旧授权,不要延长其有效期。
如果工具是只读工具,就申请一份新授权。
如果工具会写入数据或执行 shell,就必须重新取得人工批准。
在拒绝调用的 span 上记录授权与路由各自的模型类别和服务器类别。
将不匹配的用例加入每晚运行的测试场景。
只有这些检查全部通过后,才能恢复规划。
利益披露:本文是 MonkeyCode 产品推广的一部分。MonkeyCode 提供免费模型访问和免费服务器选项。因容量不足而发生故障切换时,这两种路径都可能被选中。
本文没有承诺任何 token 配额或硬件规格,也没有承诺使用时长或 benchmark 结果。具体限制仍应以当前产品文档为准。
在测试场景中,将免费路径作为不匹配的目标。把付费类别的授权用于免费类别的路由,确认执行器返回模型类别或服务器类别不匹配的拒绝结果。
能够使用这条免费路径的读者,可以在下一次演练前重放该测试场景。将书面结果保存在故障切换操作手册旁边。不要把一篇博客说明当作配额承诺。
当工具 span 的服务器类别与授权中的类别不一致时,发出告警。当 shell 工具在故障切换后运行时,发出告警。每条告警都应限定在对应的单个任务内。
不要为每一次免费路由完成事件触发值班告警。只有被策略禁止的 shell 工具险些获准执行时,才触发值班告警。如果免费路由的调用量很大,可以对 span 进行采样。
在每个工具 span 上记录任务 ID。
将授权的模型类别与路由的模型类别并排记录。
将授权的服务器类别与路由的服务器类别并排记录。
记录工具名称和决策代码。
只展示工具名称的仪表盘会漏掉这个 bug。每个工具 span 都应包含授权与路由的两组类别字段。
shell 授权还需要单独的审批记录。存储审批人 ID、时间、工具和参数哈希。不要将已有审批复制到新路由上。
新路由需要一条新的审批记录。只读工具可以省略这条人工审批记录。应在团队策略文件中写明这一例外。
对工具参数计算哈希之前,先对对象键排序。拒绝包含非有限数值的工具参数。拒绝包含原始秘密值的工具参数。
在生产环境中,应对规范化后的字节计算哈希,而不是对 Python repr 计算哈希。repr 哈希只能保留在这个实验室测试场景内。在测试名称旁边注明这一差距。
在一个发布版本中,将新的绑定校验器与旧检查器一起上线。在实验室中用两天时间只记录拒绝决策,不实际阻止调用。将这些拒绝次数与已知的故障切换演练进行比较。
只有不匹配测试在 CI 中通过后,才启用阻止调用。在同一次变更中移除旧的、仅按任务 ID 查询的逻辑。上线后,不要让两套查询逻辑同时保持启用。
如果绑定校验器拒绝了所有调用,就检查标签来源。调度器上的错误标签可能导致所有免费路由都被拒绝。回滚强制执行开关,不要撤掉日志字段。
开关关闭期间,继续记录授权与路由的两组类别字段。修复类别标签后,再重新启用强制执行。
这个测试场景使用了 free 和 paid 两个类别标签。实际集群可能有两个以上的类别。参数哈希使用的是排序后条目的 repr。
这种选择只对简单值稳定。嵌套对象可能需要使用规范化 JSON,而不是 repr。本地测试不会启动服务器。
本地测试不会调用模型。测试通过,并不能证明线上执行器已经完成接入。审查中的人工批准仍然可能流于形式。
故事中的 dry-run 没有触碰数据。如果使用的是写入工具,事件严重程度就会提高。本文没有宣称任何延迟或成本数据。
如果以下任意一项成立,就不要采用这个模式。
团队还没有书面的工具策略。
授权没有被存储,因此没有可绑定的对象。
目标是给免费模型的质量打分。
文章必须引用有供应商日志支持的真实故障。
法务审查连 dry-run 的 shell 授权都不允许。
即使绑定校验器本身正确,只要路由标签不真实,它仍然会失效。如果免费服务器将类别报告为 paid,检查就会通过。类别标签必须来自调度器,而不是模型生成的文本。
修复上线后,轮换授权签名密钥。旧的未签名授权应自然过期,不得续期。在同一个变更窗口内审查签名器。
路由一旦变化,工具权限就必须失效。授权仍在有效期内,不代表它适用于当前路由。绑定类别字段,拒绝不匹配,并且只按策略重新签发授权。
每当故障切换规则再次变化时,都要重放这个测试场景。演练中应保留免费路由,作为不匹配用例。如果类别标签来自模型生成的文字,就停止演练。
如果需要采取进一步行动,可以考虑屏蔽此人和/或举报滥用行为。