diff --git a/cmd/homed/bootstrap.go b/cmd/homed/bootstrap.go index 683b17a..3d3066c 100644 --- a/cmd/homed/bootstrap.go +++ b/cmd/homed/bootstrap.go @@ -514,6 +514,11 @@ func newMainAgent(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry, prov sysPrompt = defaultSystemPrompt } + // D4:把"设备是否已授权"的判据注入插件侧(toolImpl.CanUse 用)。 + // ⚠️ 必须放在 agent 构造**之后**——判据要用 agent 的 allowedOutputs, + // 而 registry 早于 agent 构造(故这里传的是晚绑定闭包)。 + // 未注入时 ToolAPI 路径对设备放行:那是"授权可被绕过"的既成缺口。 + // 注入后,cli 的 /terminal、seq 序列等一切走 ToolAPI 的调用都受同一道闸。 agent := agentCore.New(agentCore.AgentConfig{ ID: "main", SystemPrompt: sysPrompt, @@ -536,12 +541,12 @@ func newMainAgent(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry, prov PluginDir: cfg.Plugin.Dir, // DataDir:驻留子的 temp 图库锚点(/residents//graph.db)。 // 漏接时的现象是"工具存在、可调用、但创建必失败"——只有真实二进制才看得出来。 - DataDir: cfg.Daemon.DataDir, - DistillInterval: cfgReg.GetDuration("core.agent.distill_interval", 30*time.Minute), - ArchiveInterval: cfgReg.GetDuration("core.agent.archive_interval", 60*time.Minute), - ReviewInterval: cfgReg.GetDuration("core.agent.review_interval", 120*time.Minute), - MergeInterval: cfgReg.GetDuration("core.agent.merge_interval", 120*time.Minute), - MaxToolTurns: cfgReg.GetInt("core.agent.max_tool_turns", 10), + DataDir: cfg.Daemon.DataDir, + DistillInterval: cfgReg.GetDuration("core.agent.distill_interval", 30*time.Minute), + ArchiveInterval: cfgReg.GetDuration("core.agent.archive_interval", 60*time.Minute), + ReviewInterval: cfgReg.GetDuration("core.agent.review_interval", 120*time.Minute), + MergeInterval: cfgReg.GetDuration("core.agent.merge_interval", 120*time.Minute), + MaxToolTurns: cfgReg.GetInt("core.agent.max_tool_turns", 10), Offload: agentCore.OffloadOptions{ Enabled: cfgReg.GetBool("core.agent.offload_enabled", false), BusyAfter: cfgReg.GetDuration("core.agent.offload_busy_after", 5*time.Minute), @@ -560,6 +565,21 @@ func newMainAgent(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry, prov InputProcessing: cfg.InputProcessing, }) + // D4:把「设备是否已授权」的判据注入插件侧(toolImpl.CanUse 消费它)。 + // + // 为什么必须在这里:判据要用 agent 自己的 allowedOutputs,而 + // pluginReg 早于 agent 构造(newStageAndRegistry 在 main() 里先跑), + // 所以 registry 存的是**晚绑定**闭包,注入点必须在 agent 建好之后。 + // + // 不注入的后果(已实测的真实缺口):设备授权闸只存在于 + // core.executeToolCallInner,即「agent 收到模型 tool_call」那条路径; + // 而 ToolAPI.ExecuteTool 是**另一条**独立入口,不经那道闸 ⇒ + // 凡是走 ToolAPI 的调用都能绕过 AllowedOutputs。实测范围不止序列: + // cli 的 /terminal 就直接经 ToolAPI 调 agentcli 的终端工具。 + pluginReg.SetDeviceAuthQuery(func(deviceID string) bool { + return agent.IsOutputAllowed("device/" + deviceID) + }) + return agent } diff --git a/internal/agent/core/toolapi_auth_test.go b/internal/agent/core/toolapi_auth_test.go new file mode 100644 index 0000000..6a59540 --- /dev/null +++ b/internal/agent/core/toolapi_auth_test.go @@ -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)。已钉住,若要改须两边同时改。") + } +} diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index ed9fb89..b77852a 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -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 受限启动用)。 // 空/未设置 = 装载全部。违反白名单的插件(含已注册工厂)一律跳过。 diff --git a/internal/sdk/tool.go b/internal/sdk/tool.go index 3b7d464..55ff4dc 100644 --- a/internal/sdk/tool.go +++ b/internal/sdk/tool.go @@ -15,6 +15,20 @@ type ToolAPI interface { GetToolDefs() []ToolDef // GetAllTools returns all tools exposed by IO devices/channels. GetAllTools() []ToolDef + // CanUse 报告「执行该工具是否被授权」。 + // + // 存在的理由(D4):设备类工具的授权闸原本只存在于 core 的 + // executeToolCallInner,即**「agent 收到模型 tool_call」那条路径**。 + // 而 ExecuteTool 是**另一条**独立入口,不经那道闸 —— 于是凡是走 + // ToolAPI 的调用都能绕过 AllowedOutputs。实测范围不止序列: + // cli 插件的 /terminal 就直接经 ToolAPI 调 agentcli 的终端工具 + // (cli/plugin.go:1038 自陈"ToolAPI 已允许跨插件调用工具")。 + // + // 语义与内核那道闸**必须一致**(core 的 TestCanUseAgreesWithInnerPath + // 钉住这一点),否则两条路径判定不同同样是漏洞。 + // + // 零值实现返回 true:未实现者行为不变(存量插件与测试替身不受影响)。 + CanUse(toolName string, args map[string]interface{}) bool // ToolDefByName 按名字查任一来源(StageHost 插件工具 / IO 设备工具)的声明。 // 插件需要它来在**运行前**判断目标是否存在、是否并发安全 —— // 而工具是动态注册的,"不存在"是常态(见 seq 包的 missing 策略)。 diff --git a/internal/sdk/tool_impl.go b/internal/sdk/tool_impl.go index 8f4566b..bb901e8 100644 --- a/internal/sdk/tool_impl.go +++ b/internal/sdk/tool_impl.go @@ -83,3 +83,49 @@ func (t *toolImpl) ToolDefByName(name string) *ToolDef { } return nil } + +// CanUse 报告「执行该工具是否被授权」(D4)。 +// +// 实现要点:**必须与 core.executeToolCallInner 里的那道闸同源同语义**, +// 否则两条路径判定不同,本身就是漏洞。判据 core.TestCanUseAgreesWithInnerPath +// 逐例比对两条路径的结论。 +// +// 判据只有一条:设备类工具按 `device/` 查当前 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) +}