Files
HomeAgent/internal/agent/core/toolapi_auth_test.go
JianFeeeee 994f198bc5 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/... 全绿。
2026-09-27 13:15:22 +08:00

159 lines
6.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
import (
"strings"
"testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
"gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// toolAPIOf 造出与插件侧**完全同一个** ToolAPI 实现(PluginSDK.Tool()
// 内部就是 sdk.NewTool(stageHost, iom))。
// 刻意不另写一份判据实现——两处会漂移,而漂移本身就是漏洞。
func toolAPIOf(t *testing.T, a *Agent) sdk.ToolAPI {
// 注入当前 agent 的授权判据:与 bootstrap 装配时的做法一致。
// 不注入则 CanUse 对设备放行(那是"尚未接线"的状态,见 sdk 包注释)。
sdk.SetDeviceAuthQuery(func(deviceID string) bool {
return a.IsOutputAllowed("device/" + deviceID)
})
t.Cleanup(func() { sdk.SetDeviceAuthQuery(nil) })
return sdk.NewTool(a.stageHost, a.io)
}
// 阶段 D4:设备授权闸下沉到 ToolAPI 路径。
//
// 问题:设备类工具的授权闸只存在于 `executeToolCallInner`
// (toolcall.go:151-152),即**「agent 收到模型 tool_call」这条路径**。
// 而 `ToolAPI.ExecuteTool` 是另一条独立的执行入口,**不经那道闸**。
//
// 实测范围(不止 seq):`cli` 插件的 /terminal 直接经 ToolAPI 调
// agentcli 的终端工具(cli/plugin.go:1038 的注释自陈"SDK 的 ToolAPI
// 已允许跨插件调用工具"),这条路同样不过闸。
// ⇒ 凡是走 ToolAPI 的调用都能绕过 AllowedOutputs,不只是序列。
// ① 收窄授权时,ToolAPI 路径必须**同样**被拦。
//
// 这是本阶段的核心断言:同一份 allowedOutputs,两条路径判定必须一致。
func TestToolAPIPathRespectsDeviceGrant(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
a.allowedOutputs = []string{"device/ok-1"}
registerFakeDevice(t, a, "devicectl", nil)
tc := agentAPI.ToolCall{
ID: "c1", Name: "device_ctl_cmdrun",
Arguments: map[string]interface{}{"device_id": "other-2", "command": "rm -rf /"},
}
// ① 内核路径(现状已有)
gotInner := a.executeToolCall(tc, "cli")
if !strings.Contains(gotInner, "未授权") {
t.Fatalf("内核路径应拒绝未授权设备,实际: %s", gotInner)
}
// ② ToolAPI 路径(此前无此判定 ⇒ 缺口)
if toolAPIOf(t, a).CanUse(tc.Name, tc.Arguments) {
t.Error("ToolAPI 路径对未授权设备返回了 true —— 授权可被绕过(缺口未堵)")
}
}
// ② 反向:已授权的设备必须放行,否则正常能力被误杀。
func TestToolAPIPathAllowsGrantedDevice(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
a.allowedOutputs = []string{"device/ok-1"}
registerFakeDevice(t, a, "devicectl", nil)
tc := agentAPI.ToolCall{
ID: "c1", Name: "device_ctl_cmdrun",
Arguments: map[string]interface{}{"device_id": "ok-1", "command": "ls"},
}
if !toolAPIOf(t, a).CanUse(tc.Name, tc.Arguments) {
t.Error("已授权设备被误拒 —— 授权闸过严会把正常能力杀掉")
}
}
// ③ 非设备工具不受该闸影响(否则会把所有工具都锁死)。
func TestToolAPIPathIgnoresNonDeviceTools(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
a.allowedOutputs = []string{"device/ok-1"} // 收窄到只给一台设备
for _, name := range []string{"cmd_run", "knowledge_search", "memory_recall", "output_list_channels"} {
if !toolAPIOf(t, a).CanUse(name, map[string]interface{}{}) {
t.Errorf("非设备工具 %q 被设备授权闸拦了 —— 闸的作用域过宽", name)
}
}
}
// ④ 枚举类工具(无 device_id)不受闸——与内核现有测试
// TestDeviceToolAuth_EnumerationNotGated 保持同一语义。
func TestToolAPIPathAllowsEnumerationTools(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
a.allowedOutputs = []string{"device/ok-1"}
registerFakeDevice(t, a, "devicectl", nil)
if !toolAPIOf(t, a).CanUse("devicedetect", map[string]interface{}{}) {
t.Error("枚举类工具不应被设备授权闸拦")
}
}
// ⑤ 未配置白名单(根 agent 默认)= 完整授权。
func TestToolAPIPathFullGrantByDefault(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
registerFakeDevice(t, a, "devicectl", nil)
if !toolAPIOf(t, a).CanUse("device_ctl_cmdrun", map[string]interface{}{"device_id": "any-1"}) {
t.Error("未配置白名单时应为完整授权")
}
}
// ⑥ 两条路径的判定必须**一致** —— 这是本设计的核心不变式。
func TestCanUseAgreesWithInnerPath(t *testing.T) {
cases := []struct {
deviceID string
allowed []string
}{
{"ok-1", []string{"device/ok-1"}},
{"other-2", []string{"device/ok-1"}},
{"ok-1", nil}, // 完整授权
{"other-2", nil},
}
for _, c := range cases {
a := newPreemptAgent(t, newPreemptProvider())
a.allowedOutputs = c.allowed
registerFakeDevice(t, a, "devicectl", nil)
tc := agentAPI.ToolCall{
ID: "c1", Name: "device_ctl_cmdrun",
Arguments: map[string]interface{}{"device_id": c.deviceID, "command": "ls"},
}
innerOK := !strings.Contains(a.executeToolCall(tc, "cli"), "未授权")
apiOK := toolAPIOf(t, a).CanUse(tc.Name, tc.Arguments)
if innerOK != apiOK {
t.Errorf("device=%s allowed=%v:内核路径=%v 而 ToolAPI 路径=%v —— 两条路径判定不一致",
c.deviceID, c.allowed, innerOK, apiOK)
}
}
}
// ⑦ 设备工具但 device_id 缺失:内核现状是 **fail-open**。
// 本判据把现状钉住,避免无意中改变既有行为(内核有测试
// TestDeviceToolAuth_* 依赖它);若将来要改成 fail-closed,
// 必须同时改内核与此处,并更新两边判据。
func TestCanUseMatchesInnerFailOpenOnMissingDeviceID(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
a.allowedOutputs = []string{"device/ok-1"}
registerFakeDevice(t, a, "devicectl", nil)
tc := agentAPI.ToolCall{
ID: "c1", Name: "device_ctl_cmdrun",
Arguments: map[string]interface{}{"command": "ls"}, // 无 device_id
}
innerOK := !strings.Contains(a.executeToolCall(tc, "cli"), "未授权")
apiOK := toolAPIOf(t, a).CanUse(tc.Name, tc.Arguments)
if innerOK != apiOK {
t.Errorf("缺 device_id 时两条路径不一致:内核=%v ToolAPI=%v", innerOK, apiOK)
}
if innerOK {
t.Log("现状:缺 device_id 时放行(fail-open)。已钉住,若要改须两边同时改。")
}
}