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 1e0f603ad0
commit 028537f77a
5 changed files with 262 additions and 8 deletions

View File

@ -0,0 +1,158 @@
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)。已钉住,若要改须两边同时改。")
}
}