mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-28 13:23:03 +00:00
问题:设备类工具的授权闸只存在于 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/... 全绿。
132 lines
4.1 KiB
Go
132 lines
4.1 KiB
Go
package sdk
|
||
|
||
import (
|
||
"fmt"
|
||
|
||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||
)
|
||
|
||
// toolImpl 桥接 StageHost(插件工具)与 IOManager(设备/通道工具)。
|
||
type toolImpl struct {
|
||
stageHost ToolSource
|
||
iom *agentIO.IOManager
|
||
}
|
||
|
||
func NewTool(sh ToolSource, iom *agentIO.IOManager) ToolAPI {
|
||
return &toolImpl{stageHost: sh, iom: iom}
|
||
}
|
||
|
||
func (t *toolImpl) GetToolDefs() []ToolDef {
|
||
if t.stageHost == nil {
|
||
return nil
|
||
}
|
||
return t.stageHost.GetToolDefs()
|
||
}
|
||
|
||
func (t *toolImpl) GetAllTools() []ToolDef {
|
||
if t.iom == nil {
|
||
return nil
|
||
}
|
||
defs := t.iom.GetAllTools()
|
||
out := make([]ToolDef, 0, len(defs))
|
||
for _, d := range defs {
|
||
// ⚠️ ParallelSafe 必须一并带出:它决定该工具能否被并发执行。
|
||
// 此前这里漏了它 ⇒ 插件看到的设备工具一律"不可并发",
|
||
// 设备工具的并发声明等于对插件不可见。
|
||
out = append(out, ToolDef{
|
||
Name: d.Name,
|
||
Description: d.Description,
|
||
Parameters: d.Parameters,
|
||
ParallelSafe: d.ParallelSafe,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (t *toolImpl) ExecuteTool(name string, args map[string]interface{}) (interface{}, error) {
|
||
if t.stageHost != nil {
|
||
if def := t.stageHost.ToolDef(name); def != nil {
|
||
return t.stageHost.ExecuteTool(name, args)
|
||
}
|
||
}
|
||
if t.iom != nil {
|
||
return t.iom.ExecuteTool(name, args)
|
||
}
|
||
return nil, fmt.Errorf("tool %s not found", name)
|
||
}
|
||
|
||
var _ ToolAPI = (*toolImpl)(nil)
|
||
|
||
// ToolDefByName 按名字查任一来源的工具声明(StageHost 优先,再查 IO 设备)。
|
||
//
|
||
// 用途:插件在**运行前**判断目标工具是否存在、是否并发安全。工具是动态
|
||
// 注册的,"不存在"是常态(插件未加载/已卸载/崩溃),因此查不到一律返回
|
||
// nil 交由调用方按"不存在"处理——不得 panic。
|
||
func (t *toolImpl) ToolDefByName(name string) *ToolDef {
|
||
if t == nil || name == "" {
|
||
return nil
|
||
}
|
||
if t.stageHost != nil {
|
||
if def := t.stageHost.ToolDef(name); def != nil {
|
||
return def
|
||
}
|
||
}
|
||
if t.iom != nil {
|
||
if def, ok := t.iom.ToolDefOf(name); ok {
|
||
return &ToolDef{
|
||
Name: def.Name,
|
||
Description: def.Description,
|
||
Parameters: def.Parameters,
|
||
ParallelSafe: def.ParallelSafe,
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// CanUse 报告「执行该工具是否被授权」(D4)。
|
||
//
|
||
// 实现要点:**必须与 core.executeToolCallInner 里的那道闸同源同语义**,
|
||
// 否则两条路径判定不同,本身就是漏洞。判据 core.TestCanUseAgreesWithInnerPath
|
||
// 逐例比对两条路径的结论。
|
||
//
|
||
// 判据只有一条:设备类工具按 `device/<id>` 查当前 agent 的 allowedOutputs。
|
||
//
|
||
// ⚠️ device_id 缺失时**放行**(fail-open)——这是内核现状
|
||
// (core 的 TestDeviceToolAuth_* 依赖它)。两处行为已由
|
||
// TestCanUseMatchesInnerFailOpenOnMissingDeviceID 钉住;若将来要改成
|
||
// fail-closed,**必须两处同时改**,否则两条路径不一致。
|
||
func (t *toolImpl) CanUse(toolName string, args map[string]interface{}) bool {
|
||
if t == nil || t.iom == nil {
|
||
return true // 拿不到设备视图 ⇒ 不拦(与内核无 io 时的行为一致)
|
||
}
|
||
// 非设备工具不受此闸影响:闸的作用域必须窄,否则会把所有工具锁死。
|
||
if _, isDeviceTool := t.iom.DeviceOfTool(toolName); !isDeviceTool {
|
||
return true
|
||
}
|
||
id, _ := args["device_id"].(string)
|
||
if id == "" {
|
||
return true // fail-open,与内核一致
|
||
}
|
||
return t.canUseDevice(id)
|
||
}
|
||
|
||
// canUseDevice 是**可注入**的授权查询。
|
||
//
|
||
// 为何不直接在 toolImpl 里调 Agent:toolImpl 在 internal/sdk 包,
|
||
// 而 IsOutputAllowed 是 core.*Agent 的方法(core 反向依赖 sdk,
|
||
// sdk 不能依赖 core)。故内核在装配时把查询函数注入进来。
|
||
var deviceAuthQuery func(deviceID string) bool
|
||
|
||
// SetDeviceAuthQuery 注入"设备是否已授权"的查询(由内核在装配时调用)。
|
||
//
|
||
// 传 nil 表示尚未注入 ⇒ CanUse 对设备工具**放行**(保持存量行为不变)。
|
||
func SetDeviceAuthQuery(fn func(deviceID string) bool) { deviceAuthQuery = fn }
|
||
|
||
func (t *toolImpl) canUseDevice(deviceID string) bool {
|
||
if deviceAuthQuery == nil {
|
||
return true
|
||
}
|
||
return deviceAuthQuery(deviceID)
|
||
}
|