一个工具请求的 OAuth 作用域,为何被其他所有工具悄悄继承了?
设计文档规定每个工具都拥有独立的 OAuth 令牌,并被限制在该工具所需的最小权限范围内。而实现代码则使用 (user_id, provider) 作为键(key)来存储令牌。在 v1 版本发布当天,这两个表述都是成立的,因为当时每个提供商(provider)恰好只有一个工具。当针对同一提供商的第二个工具上线时,设计文档依然成立,但存储层却在悄无声息中使其失效了。
六个月后,一次安全审查将一起事故追溯到了那行模式(schema)定义。一个日历读取工具通过日程描述中的提示词注入(prompt injection)被攻破,并成功调用了用户主日历上的 events.delete 接口。读取工具从未被授予过该作用域(scope),但写入工具被授予了。令牌存储层并没有区分它们。
这种故障模式在于,基于每个提供商(per-provider)的键结构会在共享同一提供商的工具之间悄悄累积权限——这也让人们在架构上意识到:OAuth 作用域是令牌(token)的属性,而不是工具(tool)的属性。
吞噬边界的模式
最初的集成是一个 Google 日历读取器。它请求了 calendar.events.readonly 权限,将授权码交换为令牌,并将结果存储在以 (user_id, provider) 为主键的令牌表中。简单的键比复合键成本更低,当时团队的路线图中也只有一个日历集成,而且当时的安全审查基于设计文档中“每个工具独立作用域”的图表批准了该设计。
几个月后,一项产品决策下达:智能体(agent)需要代表用户创建、移动和拒绝日历日程。第二个工具,即日历写入器,其作用域被设定为 calendar.events。写入器的 OAuth 流程运行在与读取器相同的 Google Calendar OAuth 应用上,因为从 Google 的角度来看,这两个工具属于同一个提供商。用户点击通过了写入器扩展作用域集的授权屏幕,新令牌随后发放。
令牌存储层对 (user_id, provider) 执行了 UPSERT 操作。写入器权限更广的令牌覆盖了读取器权限较窄的令牌。两行记录共享同一个主键,数据库完全按照指令执行了操作。
读取器的运行时路径在下一次加载令牌时,加载的是写入器的令牌。读取器从未重新部署,其代码仍然认为自己是一个只读工具。但 Google 的 API 不会拒绝来自拥有 delete 作用域令牌的删除请求——它无法得知调用代码到底是读取器还是写入器。设计文档中指定的边界仅存在于设计文档中。
