fix(security): 设备授权闸下沉到 ToolAPI 路径(D4,堵住绕过)

问题:设备类工具的授权闸只存在于 core.executeToolCallInner
(toolcall.go:151-152),即**「agent 收到模型 tool_call」那条路径**。
而 ToolAPI.ExecuteTool 是**另一条**独立执行入口,不经那道闸
⇒ 凡是走 ToolAPI 的调用都能绕过 AllowedOutputs。

实测范围**不止序列**:cli 插件的 /terminal 直接经 ToolAPI 调 agentcli 的
终端工具(cli/plugin.go:1038 的注释自陈"SDK 的 ToolAPI 已允许跨插件调用
工具")。任何插件拿 ToolAPI 都能指挥未授权的设备。

改动:
· internal/sdk/tool.go: ToolAPI 新增 CanUse(toolName, args) bool。
  **纯新增方法**,零值实现返回 true ⇒ 未实现者(存量插件、测试替身)
  行为不变。
· internal/sdk/tool_impl.go: 实现 CanUse。判据只有一条——设备类工具按
  `device/<id>` 查授权;非设备工具不受影响(闸的作用域必须窄,否则会把
  所有工具锁死)。
  授权查询走**可注入**的晚绑定闭包:toolImpl 在 internal/sdk,而
  IsOutputAllowed 是 core.*Agent 的方法,sdk 不能依赖 core。
· internal/plugin/registry.go: 新增 SetDeviceAuthQuery。
· cmd/homed/bootstrap.go: 在 newMainAgent 末尾注入。⚠️ 必须在 agent
  构造**之后**——判据要用 agent 自己的 allowedOutputs,而 registry 早于
  agent 构造,故 registry 存的是晚绑定闭包。

判据(toolapi_auth_test.go,7 条),核心是**两条路径必须一致**:
· 收窄授权时 ToolAPI 路径同样被拦
· 已授权设备放行(防闸过严杀掉正常能力)
· 非设备工具不受影响
· 枚举类工具不受影响(与内核 TestDeviceToolAuth_EnumerationNotGated 同语义)
· 未配置白名单 = 完整授权
· ★ TestCanUseAgreesWithInnerPath:4 组用例逐例比对内核路径与 ToolAPI
  路径的结论 —— 判定不同本身就是漏洞
· ★ TestCanUseMatchesInnerFailOpenOnMissingDeviceID:把现状
  (缺 device_id 时**放行**)钉住。⚠️ 这是 fail-open,是既有的可疑设计
  (core 的 TestDeviceToolAuth_* 依赖它),本次不擅自改语义;判据写明
  "若要改成 fail-closed,必须两处同时改"。

过程中三次自伤:
1. 一度在 core 写了个 toolAPIRef —— **只实现部分方法的替身**是过度设计,
   且两份实现必然漂移。改为判据直接用 sdk.NewTool(stageHost, iom),
   与插件侧走**同一个**实现。
2. 判据里又写了 `var _ = agentIO.DeviceOutput` 这种压 unused import 的
   占位 hack(第二次犯这个),并重造了 strings.Contains。都已去掉。
3. 注入点一开始找错了位置(以为 newStageAndRegistry 能拿到 agent,
   实际 pluginReg 是 main() 的局部变量)。核实 newMainAgent 的签名后
   确认它同时持有 agent 与 pluginReg,注入点落在那里。

变异验证:让 CanUse 恒返回 true(还原成原缺口)⇒ 两条判据 FAIL,
其中一条直指「内核路径=false 而 ToolAPI 路径=true —— 两条路径判定不一致」。

回归:go build ./... 通过;go test ./internal/... 全绿。
This commit is contained in:
JianFeeeee
2026-09-27 13:15:22 +08:00
parent 532500e3c9
commit 994f198bc5
5 changed files with 262 additions and 8 deletions

View File

@ -115,6 +115,13 @@ type Registry struct {
idx *memory.Indexer
termAPI sdk.TerminalAPI
// deviceAuth 回答"设备 deviceID 是否已授权给当前 agent"(D4)。
//
// 由 bootstrap 在 agent 建好之后注入(registry 本身早于 agent 构造,
// 所以这里存的是**晚绑定**的闭包)。为 nil 时 ToolAPI.CanUse 对
// 设备工具放行 —— 保持存量行为不变。
deviceAuth func(deviceID string) bool
knownDisabled map[string]bool
allowlist map[string]bool
@ -207,8 +214,17 @@ func (r *Registry) SetSupervisor(sup sdk.SupervisorAPI) { r.
func (r *Registry) SetTracker(trk *tracker.Tracker) { r.trk = trk }
func (r *Registry) SetConfig(cfg *types.Config) { r.cfg = cfg }
func (r *Registry) SetStageHost(sh sdk.ToolSource) { r.stageHost = sh }
func (r *Registry) SetIndexer(idx *memory.Indexer) { r.idx = idx }
func (r *Registry) SetTerminalAPI(t sdk.TerminalAPI) { r.termAPI = t }
// SetDeviceAuthQuery 注入"设备是否已授权"的查询(D4)。
//
// 由 bootstrap 在 agent 构造完成后调用。注入后,ToolAPI.CanUse 才会
// 对未授权设备返回 false —— 此前 ToolAPI 路径**完全不过授权闸**。
func (r *Registry) SetDeviceAuthQuery(fn func(deviceID string) bool) {
r.deviceAuth = fn
sdk.SetDeviceAuthQuery(fn)
}
func (r *Registry) SetIndexer(idx *memory.Indexer) { r.idx = idx }
func (r *Registry) SetTerminalAPI(t sdk.TerminalAPI) { r.termAPI = t }
// SetLoadAllowlist 限制 Load 仅装载指定插件名(failback 受限启动用)。
// 空/未设置 = 装载全部。违反白名单的插件(含已注册工厂)一律跳过。