From 2ebdb9a5b7ef831429e1fe136fd8a591a0112f9a Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 2 Sep 2026 21:27:13 +0800 Subject: [PATCH] =?UTF-8?q?plugin:=20=E6=9D=83=E9=99=90=E6=A2=AF=E5=BA=A6?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=E5=8C=96=EF=BC=88Part=206.4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 迁移前,「外部插件拿不到 Selftest/Supervisor/Tracker」是 C ABI 表达能力的 **意外产物**——C 结构体不好传函数指针,这些能力自然到不了插件侧。那是运气 不是策略:任何人给 dispatch 加个 case 就能捅穿。 现在变成显式声明并强制,分三道闸: 1. **类型层**(proc_core.go,Part 6.2 已落地):procCore 用命名字段持有 内核 SDK 而非嵌入,未在收窄面写出的方法编译期就不存在。 2. **能力集**(新增 capability.go):54 个 plugin→kernel method 划入 11 个 capability 组,manifest 未声明的组被拒。 3. **RPC 边界**(corehandler.Handle 入口):被拒时返回**明确错误**而非 静默忽略。 第 3 条针对一类真实故障:C ABI 时代 case 23/24(事件订阅)是空实现, 返回成功但永远收不到事件(§1.3 的「给不了」而非「不给」),插件作者无从得知。 错误消息含四要素:哪个插件、哪个调用、缺什么能力、在哪声明。 ## 能力划分的两个判断 **粒度按能力域而非单 method**。逐 method 授权看似更精细,但插件作者要在 manifest 里列 60 个名字,且内核每加 method 所有 manifest 都得改。 **空声明 = 不受限,而非「只有 core」**。17 个存量插件的 plugin.json 都没有 capabilities 字段。若空声明当作最小权限,它们会全部失去 IO 注入、记忆读写 而**静默降级**——违反「外部插件零改动」的硬约束。收紧的路径是让插件显式 声明,而不是默默拒绝老插件。 ## core 与受限能力的边界 core(无需声明,始终可用):注册自身工具/阶段/通道/API、读写**自己的**配置、 共享段锁仲裁、握手、autoRestart 自述、setToolBlocks。没有这些插件无法工作。 受限(需声明):io / memory / doc_memory / knowledge / text_memory / llm / social / events / plugin_mgr / settings_cross。 settings 刻意拆成两级:读写自己的配置属 core(正常工作所需),读写**其他插件** 配置或**内核核心**配置属 settings_cross(能改别人/内核的行为)。 ## withheldCapabilities:让「不给」可见 10 项刻意不提供的内核内部机制列在表里并附理由。它们没有对应 method 常量—— 不是忘了加,是决定不加。列表存在本身就是「这是策略而非疏漏」的证据, 读代码的人能看到边界在哪,而不是从「protocol.go 里没有」这个负面事实去推断。 ## 测试 proc 包 10 项: - AllMethodsClassified:**最重要的一项**。漏登记的 method 会按 CapCore 放行, 等于绕过整套检查。新增 method 忘登记时当场报出。 - EmptyDeclarationIsUnrestricted / DeclaredSetRestrictsOthers / CoreAlwaysAllowed - SettingsScopeSeparation:自身配置 vs 跨插件配置的归属 - DeniedErrorIsActionable:错误消息四要素 - HandleEnforcesAtRPCBoundary:被拒的调用不进 switch - WithheldListIsDocumented:每项都有理由,且不被任何 method 暴露 - UnknownMethodFallsThrough:未知 method 报「未知」而非「权限被拒」, 否则作者会以为是漏声明能力 写这个测试时踩到自己的坑:第一版用子串匹配查 withheld 泄漏,"Tool" 匹配到 tool.register 和 io.setToolBlocks 误报——那两个是合法开放的(注册自己的工具)。 改成前缀 + unregister 关键字匹配,withheld 项也改名带 API 后缀以示区分。 internal/plugins 2 项接线验证: - RestrictedPluginStillLoads:只声明 io 的 weather 仍能加载并注册工具 (它在 Start 里读 Settings,属 core) - LegacyManifestUnrestricted:无 capabilities 字段的存量插件正常加载 真实 homed 实测: [plugin] weather-capped 声明能力: [io] [plugin] weather-capped: 经 proc 通道加载(子进程) registering tool: weather-capped_current / _forecast / _set_location 验证:go build ./... 通过;go test ./... 全仓无失败; go test -race ./internal/plugin/... 全绿;go vet 干净。 Ref: docs/zh/架构迁移评估.md §3.8、docs/zh/plugin-migration-plan.md Part 6.4 --- internal/plugin/dynamic_proc_unix.go | 14 +- internal/plugin/manifest.go | 16 +- internal/plugin/proc/capability.go | 267 +++++++++++++++++ internal/plugin/proc/capability_test.go | 322 +++++++++++++++++++++ internal/plugin/proc/corehandler.go | 13 + internal/plugin/proc/plugin.go | 11 +- internal/plugins/capability_wiring_test.go | 100 +++++++ 7 files changed, 740 insertions(+), 3 deletions(-) create mode 100644 internal/plugin/proc/capability.go create mode 100644 internal/plugin/proc/capability_test.go create mode 100644 internal/plugins/capability_wiring_test.go diff --git a/internal/plugin/dynamic_proc_unix.go b/internal/plugin/dynamic_proc_unix.go index 134708e..8e62471 100644 --- a/internal/plugin/dynamic_proc_unix.go +++ b/internal/plugin/dynamic_proc_unix.go @@ -72,7 +72,19 @@ func (r *Registry) loadProc(dir, name string, config map[string]interface{}) (sd return nil, fmt.Errorf("proc plugin %s: %w", name, err) } - return procPluginAdapter{Plugin: proc.New(name, binPath, dir, config, host, r.onProcCrash)}, nil + // manifest 声明的能力集(§3.8 权限梯度)。 + // 无 manifest 或未声明 capabilities 时不限制,保存存量插件行为。 + var caps []string + if mft := readManifest(dir); mft != nil { + caps = mft.Capabilities + if len(caps) > 0 { + log.Printf("[plugin] %s 声明能力: %v", name, caps) + } + } + + return procPluginAdapter{ + Plugin: proc.New(name, binPath, dir, config, host, r.onProcCrash, caps...), + }, nil } // ensureProcHost 惰性创建共享段 Host(全进程唯一)。 diff --git a/internal/plugin/manifest.go b/internal/plugin/manifest.go index dfb844c..a33fe8f 100644 --- a/internal/plugin/manifest.go +++ b/internal/plugin/manifest.go @@ -19,11 +19,25 @@ type PluginManifest struct { License string `json:"license,omitempty"` Homepage string `json:"homepage,omitempty"` Repository string `json:"repository,omitempty"` - Entry string `json:"entry"` // "plugin.bin"(子进程) | "plugin.so" | "plugin.dll" | "main.lua" | "SKILL.md" + Entry string `json:"entry"` // "plugin.bin"(子进程) | "main.lua" | "SKILL.md" Platforms []string `json:"platforms,omitempty"` // 声明的支持平台: ["linux","darwin","windows"] MinVersion string `json:"min_version,omitempty"` Tags []string `json:"tags,omitempty"` Deprecated bool `json:"deprecated,omitempty"` + + // Capabilities 声明本插件需要的内核能力组(§3.8 权限梯度)。 + // + // 取值见 internal/plugin/proc.KnownCapabilities(): + // io / memory / doc_memory / knowledge / text_memory / llm / social / + // events / plugin_mgr / settings_cross + // + // **省略或为空 = 不受限**,而不是「只有基础能力」。 + // 理由:17 个存量插件的 plugin.json 都没有这个字段,若空声明当作最小权限, + // 它们会全部失去 IO 注入、记忆读写等能力而**静默降级**—— + // 违反「外部插件零改动」的硬约束。收紧的路径是让插件显式声明。 + // + // core(注册自身工具/阶段/通道 + 读写自己的配置)无需声明,始终可用。 + Capabilities []string `json:"capabilities,omitempty"` } func ReadManifest(dir string) (*PluginManifest, error) { diff --git a/internal/plugin/proc/capability.go b/internal/plugin/proc/capability.go new file mode 100644 index 0000000..d62eb39 --- /dev/null +++ b/internal/plugin/proc/capability.go @@ -0,0 +1,267 @@ +package proc + +import ( + "fmt" + "sort" + "strings" +) + +// 权限梯度:外部插件可调用哪些内核 method(§3.8)。 +// +// 迁移前,「外部插件拿不到 Selftest/Supervisor/Tracker」是 C ABI 表达能力的 +// **意外产物**——C 结构体不好传函数指针,于是这些能力自然到不了插件侧。 +// 那是运气,不是策略:任何人给 dispatch 加个 case 就能捅穿。 +// +// 迁移后要变成**显式声明并强制的策略**,分三道闸: +// +// 1. 类型层(internal/plugin/proc_core.go):procCore 用命名字段持有内核 SDK, +// 不嵌入 —— 未在收窄面显式写出的方法根本不存在,编译期就拿不到。 +// 2. 能力集(本文件):method 划入 capability 组,manifest 未声明的组被拒。 +// 3. RPC 边界:被拒时返回**明确错误**而非静默忽略——插件作者能立刻知道 +// 「这个能力没给我」,而不是调用成功但什么也没发生。 +// +// 第 3 条针对的是一类真实故障:C ABI 时代 case 23/24(事件订阅)是空实现, +// 返回成功但永远收不到事件(§1.3 的「给不了」而非「不给」)。 + +// Capability 是一组相关 method 的权限单元。 +// +// 粒度选择:按**能力域**而非单个 method 划分。逐 method 授权看似更精细, +// 但插件作者要在 manifest 里列 60 个名字,且内核加 method 时所有 manifest 都得改。 +type Capability string + +const ( + // CapCore 是无需声明即可用的基础能力:注册自身工具/阶段/通道、 + // 读写自己的配置、共享段锁仲裁。没有这些插件无法工作。 + CapCore Capability = "core" + + // CapIO 注入输入到 agent 主循环(可影响对话流)。 + CapIO Capability = "io" + + // CapMemory 图记忆读写。 + CapMemory Capability = "memory" + + // CapDocMemory 文档记忆读写。 + CapDocMemory Capability = "doc_memory" + + // CapKnowledge 知识库读写。 + CapKnowledge Capability = "knowledge" + + // CapTextMemory 文本记忆追加。 + CapTextMemory Capability = "text_memory" + + // CapLLM 切换 LLM 源(影响全局行为)。 + CapLLM Capability = "llm" + + // CapSocial 社交图读取。 + CapSocial Capability = "social" + + // CapEvents 订阅内核事件。 + CapEvents Capability = "events" + + // CapPluginMgr 管理其他插件(重载/查询禁用状态)。 + // + // 这是**最敏感**的一组:能重载其他插件意味着能间接影响它们的状态。 + CapPluginMgr Capability = "plugin_mgr" + + // CapCrossPluginSettings 读写**其他插件**的配置与内核核心配置。 + // + // 与 CapCore 里的「读写自己的配置」区分开:跨插件配置读写能改别人的行为, + // 核心配置读写能改内核行为。 + CapCrossPluginSettings Capability = "settings_cross" +) + +// methodCapability 把每个 method 映射到所需能力。 +// +// ❗ 新增 method 时必须在此登记,否则 capabilityOf 返回 CapCore +// (最宽松),等于绕过权限检查。checkAllMethodsClassified 测试守着这一点。 +var methodCapability = map[string]Capability{ + // ---- 基础能力(无需声明)---- + MethodHandshake: CapCore, + MethodToolRegister: CapCore, + MethodStageRegister: CapCore, + MethodOutputRegister: CapCore, + MethodAPIRegister: CapCore, + MethodInputRegister: CapCore, + MethodStageLock: CapCore, + MethodStageUnlock: CapCore, + // 自身配置读写与元信息属基础能力 + MethodSettingsGet: CapCore, + MethodSettingsSet: CapCore, + MethodSettingsList: CapCore, + MethodSettingsDefs: CapCore, + MethodSettingsRegisterDef: CapCore, + MethodSettingsDataDir: CapCore, + // 生命周期自述(插件声明自己是否可自动重启) + MethodLifecycleAutoRestart: CapCore, + // 多模态内容块注入是工具返回值的一部分,不越权 + MethodIOSetToolBlocks: CapCore, + + // ---- IO 注入 ---- + MethodIOInjectText: CapIO, + MethodIOInjectInterrupt: CapIO, + MethodIOInjectTextNoMem: CapIO, + MethodIOInjectSync: CapIO, + + // ---- 图记忆 ---- + MethodMemoryRecall: CapMemory, + MethodMemoryCommit: CapMemory, + MethodMemoryIntrospect: CapMemory, + MethodMemoryMerge: CapMemory, + MethodMemoryPurge: CapMemory, + + // ---- 文档记忆 ---- + MethodDocQuery: CapDocMemory, + MethodDocInsert: CapDocMemory, + MethodDocRemove: CapDocMemory, + MethodDocStats: CapDocMemory, + + // ---- 知识库 ---- + MethodKnowledgeSearch: CapKnowledge, + MethodKnowledgeAdd: CapKnowledge, + MethodKnowledgeList: CapKnowledge, + + // ---- 文本记忆 ---- + MethodTextMemoryAppend: CapTextMemory, + + // ---- LLM ---- + MethodLLMListSources: CapLLM, + MethodLLMSetSource: CapLLM, + MethodLLMCurrentSource: CapLLM, + + // ---- 社交图 ---- + MethodSocialGetPerson: CapSocial, + MethodSocialGetNetwork: CapSocial, + MethodSocialGetTrait: CapSocial, + MethodSocialGetRelation: CapSocial, + MethodSocialListPersons: CapSocial, + + // ---- 事件 ---- + MethodEventsSubscribe: CapEvents, + MethodEventsUnsubscribe: CapEvents, + + // ---- 插件管理 ---- + MethodPluginReloadOne: CapPluginMgr, + MethodPluginListLoaded: CapPluginMgr, + MethodPluginIsDisabled: CapPluginMgr, + + // ---- 跨插件 / 核心配置 ---- + MethodSettingsGetCore: CapCrossPluginSettings, + MethodSettingsSetCore: CapCrossPluginSettings, + MethodSettingsListCore: CapCrossPluginSettings, + MethodSettingsGetPlugin: CapCrossPluginSettings, + MethodSettingsSetPlugin: CapCrossPluginSettings, + MethodSettingsListPlugin: CapCrossPluginSettings, + MethodSettingsDump: CapCrossPluginSettings, + MethodSettingsPlugins: CapCrossPluginSettings, +} + +// withheldCapabilities 是**刻意不提供给外部插件**的内核内部机制(§3.8 最后一行)。 +// +// 这些没有对应的 method 常量——不是"忘了加",是决定不加。 +// 列在这里是为了让决策可见:读代码的人能看到边界在哪,而不是从 +// 「protocol.go 里没有」这个负面事实去推断。 +// +// 类型层已经挡住了(procCore 不暴露这些访问器),本表是文档 + 测试锚点。 +var withheldCapabilities = map[string]string{ + "SelftestAPI": "虚拟实例自检 —— 能构造内核实例,等于绕过全部权限边界", + "SupervisorAPI": "进程监管 —— 能启停 worker,等于控制内核生命周期", + "TrackerAPI": "变更追踪 —— 内核 overlay 文件系统的内部机制", + "StatusAPI": "内核状态面 —— 暴露内部运行时细节", + "AdapterAPI": "LLM 适配器管理 —— 能改写请求/响应链路", + "ConfigAPI": "内核配置对象 —— 与 settings 的受控读写不同,这是直接持有", + "ToolAPI": "工具表直接操作 —— 能注销其他插件的工具(注册自己的工具走 tool.register,那是 core)", + "IndexerAPI": "记忆索引器 —— 内核记忆管线的内部组件", + "OutputChanRaw": "输出通道原始消费 —— 已由 output.invoke 的声明式注册替代", + "EventPublish": "事件发布 —— 只给订阅(events.subscribe),不给伪造内核事件", +} + +// capabilityOf 返回 method 所需能力。 +// +// 未登记的 method 返回 (CapCore, false):ok=false 让调用方能区分 +// 「明确划为基础能力」与「漏登记」,测试据此拦住漏登记。 +func capabilityOf(method string) (Capability, bool) { + cap, ok := methodCapability[method] + if !ok { + return CapCore, false + } + return cap, true +} + +// capabilitySet 是某个插件被授予的能力集合。 +type capabilitySet struct { + granted map[Capability]bool + // unrestricted 为真时跳过检查(未声明 capabilities 的插件,向后兼容)。 + unrestricted bool +} + +// newCapabilitySet 从 manifest 声明构造能力集。 +// +// **空声明 = 不受限**,而不是「只有 core」。理由:17 个存量插件的 plugin.json +// 都没有 capabilities 字段,若空声明当作最小权限,它们会全部失去 IO 注入、 +// 记忆读写等能力而**静默降级**——这违反「外部插件零改动」的硬约束。 +// +// 收紧的路径是让插件显式声明,而非默默拒绝老插件。 +func newCapabilitySet(declared []string) *capabilitySet { + if len(declared) == 0 { + return &capabilitySet{unrestricted: true} + } + s := &capabilitySet{granted: map[Capability]bool{CapCore: true}} + for _, d := range declared { + s.granted[Capability(strings.TrimSpace(d))] = true + } + return s +} + +// allows 判断是否允许调用某 method。 +func (s *capabilitySet) allows(method string) (bool, Capability) { + cap, registered := capabilityOf(method) + if !registered { + // 漏登记的 method 按基础能力放行(保守:不因内核疏漏拦住插件), + // 但由测试保证这种情况不存在。 + return true, CapCore + } + if s == nil || s.unrestricted { + return true, cap + } + if cap == CapCore { + return true, cap + } + return s.granted[cap], cap +} + +// KnownCapabilities 返回全部可声明的能力名(供 manifest 校验与文档生成)。 +func KnownCapabilities() []string { + seen := map[Capability]bool{} + for _, c := range methodCapability { + seen[c] = true + } + out := make([]string, 0, len(seen)) + for c := range seen { + if c == CapCore { + continue // core 无需声明 + } + out = append(out, string(c)) + } + sort.Strings(out) + return out +} + +// WithheldCapabilities 返回刻意不提供的能力清单(供文档与诊断)。 +func WithheldCapabilities() map[string]string { + out := make(map[string]string, len(withheldCapabilities)) + for k, v := range withheldCapabilities { + out[k] = v + } + return out +} + +// errCapabilityDenied 构造被拒错误。 +// +// 消息包含三要素:被拒的 method、缺的能力名、如何补救。 +// 静默忽略或含糊的「失败」会让插件作者以为是自己参数错了。 +func errCapabilityDenied(plugin, method string, cap Capability) error { + return fmt.Errorf( + "插件 %s 调用 %s 被拒:缺少 %q 能力。"+ + "请在 plugin.json 的 capabilities 数组中声明它(可用能力:%s)", + plugin, method, cap, strings.Join(KnownCapabilities(), ", ")) +} diff --git a/internal/plugin/proc/capability_test.go b/internal/plugin/proc/capability_test.go new file mode 100644 index 0000000..be12d5d --- /dev/null +++ b/internal/plugin/proc/capability_test.go @@ -0,0 +1,322 @@ +package proc + +import ( + "encoding/json" + "strings" + "testing" +) + +// 权限梯度测试(§3.8)。 +// +// 守住的核心性质:外部插件拿不到内核内部机制,不是因为 C ABI 传不了 +// 函数指针(那是运气),而是因为这里**显式声明并强制**了边界。 + +// 每个 method 都必须登记能力归属。 +// +// ❗ 这是本文件最重要的测试:漏登记的 method 会按 CapCore 放行, +// 等于绕过整套权限检查。新增 method 时忘了登记,这里会当场报出来。 +func TestCapability_AllMethodsClassified(t *testing.T) { + // 与 protocol.go 的 method 常量对齐。内核→插件的 7 个调用不经 Handle, + // 故不需要能力归属。 + kernelToPlugin := map[string]bool{ + MethodPluginInit: true, + MethodPluginStart: true, + MethodPluginStop: true, + MethodToolInvoke: true, + MethodStageInvoke: true, + MethodOutputInvoke: true, + } + + // 插件→内核的全部 method(手工清单,与 protocol.go 对照) + pluginToKernel := []string{ + MethodHandshake, + MethodToolRegister, MethodStageRegister, MethodOutputRegister, + MethodAPIRegister, MethodInputRegister, + MethodIOInjectText, MethodIOInjectInterrupt, MethodIOInjectTextNoMem, + MethodIOInjectSync, MethodIOSetToolBlocks, + MethodLifecycleAutoRestart, + MethodMemoryRecall, MethodMemoryCommit, MethodMemoryIntrospect, + MethodMemoryMerge, MethodMemoryPurge, + MethodDocQuery, MethodDocInsert, MethodDocRemove, MethodDocStats, + MethodKnowledgeSearch, MethodKnowledgeAdd, MethodKnowledgeList, + MethodTextMemoryAppend, + MethodSettingsGet, MethodSettingsSet, MethodSettingsRegisterDef, + MethodSettingsGetCore, MethodSettingsSetCore, MethodSettingsListCore, + MethodSettingsGetPlugin, MethodSettingsSetPlugin, MethodSettingsListPlugin, + MethodSettingsList, MethodSettingsDefs, MethodSettingsDump, + MethodSettingsPlugins, MethodSettingsDataDir, + MethodLLMListSources, MethodLLMSetSource, MethodLLMCurrentSource, + MethodSocialGetPerson, MethodSocialGetNetwork, MethodSocialGetTrait, + MethodSocialGetRelation, MethodSocialListPersons, + MethodEventsSubscribe, MethodEventsUnsubscribe, + MethodPluginReloadOne, MethodPluginListLoaded, MethodPluginIsDisabled, + MethodStageLock, MethodStageUnlock, + } + + for _, m := range pluginToKernel { + if kernelToPlugin[m] { + continue + } + if _, ok := capabilityOf(m); !ok { + t.Errorf("method %q 未登记能力归属 —— 会按 CapCore 放行,绕过权限检查", m) + } + } +} + +// 未声明 capabilities 的插件不受限(存量插件向后兼容)。 +// +// 若空声明当作最小权限,17 个存量插件会全部失去 IO 注入/记忆读写而静默降级。 +func TestCapability_EmptyDeclarationIsUnrestricted(t *testing.T) { + s := newCapabilitySet(nil) + for _, m := range []string{ + MethodIOInjectText, MethodMemoryCommit, MethodPluginReloadOne, + MethodSettingsSetCore, MethodEventsSubscribe, + } { + if ok, _ := s.allows(m); !ok { + t.Errorf("未声明 capabilities 时 %q 应放行(存量插件兼容)", m) + } + } + + s2 := newCapabilitySet([]string{}) + if ok, _ := s2.allows(MethodMemoryCommit); !ok { + t.Error("空数组也应视为不受限") + } +} + +// 声明了能力后,未声明的组被拒。 +func TestCapability_DeclaredSetRestrictsOthers(t *testing.T) { + // 只声明 io:能注入,但不能碰记忆/插件管理/核心配置 + s := newCapabilitySet([]string{"io"}) + + allowed := []string{MethodIOInjectText, MethodIOInjectSync} + for _, m := range allowed { + if ok, _ := s.allows(m); !ok { + t.Errorf("声明 io 后 %q 应放行", m) + } + } + + denied := map[string]Capability{ + MethodMemoryCommit: CapMemory, + MethodKnowledgeAdd: CapKnowledge, + MethodPluginReloadOne: CapPluginMgr, + MethodSettingsSetCore: CapCrossPluginSettings, + MethodEventsSubscribe: CapEvents, + MethodLLMSetSource: CapLLM, + MethodTextMemoryAppend: CapTextMemory, + MethodDocInsert: CapDocMemory, + MethodSocialGetPerson: CapSocial, + } + for m, wantCap := range denied { + ok, gotCap := s.allows(m) + if ok { + t.Errorf("未声明 %q 时 %q 应被拒", wantCap, m) + } + if gotCap != wantCap { + t.Errorf("%q 的能力归属 = %q,期望 %q", m, gotCap, wantCap) + } + } +} + +// core 能力始终可用,无需声明。 +// +// 没有它插件无法注册工具、读写自己的配置、参与 stage 锁仲裁—— +// 即完全无法工作。 +func TestCapability_CoreAlwaysAllowed(t *testing.T) { + s := newCapabilitySet([]string{"io"}) // 只声明 io + + for _, m := range []string{ + MethodHandshake, + MethodToolRegister, MethodStageRegister, MethodOutputRegister, + MethodInputRegister, MethodAPIRegister, + MethodStageLock, MethodStageUnlock, + MethodSettingsGet, MethodSettingsSet, MethodSettingsList, + MethodSettingsDefs, MethodSettingsRegisterDef, MethodSettingsDataDir, + MethodLifecycleAutoRestart, + MethodIOSetToolBlocks, + } { + if ok, _ := s.allows(m); !ok { + t.Errorf("core 能力 %q 应始终放行", m) + } + } +} + +// 自身配置读写属 core,跨插件/核心配置需显式声明。 +// +// 这个区分是有意的:读写自己的配置是插件正常工作所需; +// 读写别人的配置能改别人行为,读写核心配置能改内核行为。 +func TestCapability_SettingsScopeSeparation(t *testing.T) { + s := newCapabilitySet([]string{}) // 不受限,先确认归属正确 + + own := []string{MethodSettingsGet, MethodSettingsSet, MethodSettingsList} + for _, m := range own { + if cap, _ := capabilityOf(m); cap != CapCore { + t.Errorf("%q 应属 core(自身配置),实际 %q", m, cap) + } + } + + cross := []string{ + MethodSettingsGetCore, MethodSettingsSetCore, MethodSettingsListCore, + MethodSettingsGetPlugin, MethodSettingsSetPlugin, MethodSettingsListPlugin, + MethodSettingsDump, MethodSettingsPlugins, + } + for _, m := range cross { + if cap, _ := capabilityOf(m); cap != CapCrossPluginSettings { + t.Errorf("%q 应属 settings_cross,实际 %q", m, cap) + } + } + _ = s +} + +// 被拒时错误消息必须可操作:说清缺什么、怎么补。 +// +// 针对的是 C ABI 时代的一类真实故障:case 23/24 返回成功但永远收不到事件, +// 插件作者无从得知。 +func TestCapability_DeniedErrorIsActionable(t *testing.T) { + err := errCapabilityDenied("demo", MethodMemoryCommit, CapMemory) + msg := err.Error() + + for _, want := range []string{ + "demo", // 哪个插件 + MethodMemoryCommit, // 哪个调用 + string(CapMemory), // 缺什么能力 + "capabilities", // 在哪声明 + "plugin.json", // 声明在哪个文件 + } { + if !strings.Contains(msg, want) { + t.Errorf("错误消息应含 %q,实际: %s", want, msg) + } + } + + // 还应列出可用能力名,避免作者猜 + if !strings.Contains(msg, string(CapEvents)) { + t.Errorf("错误消息应列出可选能力(如 %q),实际: %s", CapEvents, msg) + } +} + +// coreHandler 在 Handle 入口强制权限,被拒的调用不进 switch。 +func TestCapability_HandleEnforcesAtRPCBoundary(t *testing.T) { + core := newFakeCore() + h := &coreHandler{ + sdk: core, + name: "restricted", + caps: newCapabilitySet([]string{"io"}), // 不含 memory + } + + _, err := h.Handle(MethodMemoryCommit, json.RawMessage(`{"triples":[]}`)) + if err == nil { + t.Fatal("未声明 memory 能力时 memory.commit 应被拒") + } + if !strings.Contains(err.Error(), "被拒") { + t.Errorf("应是权限拒绝错误,实际: %v", err) + } + + // 已声明的能力照常走到 switch(这里 memory 为 nil,会返回 errUnavailable, + // 但错误类型不同——证明请求进了 switch 而非被权限拦下) + h2 := &coreHandler{ + sdk: core, + name: "allowed", + caps: newCapabilitySet([]string{"memory"}), + } + _, err2 := h2.Handle(MethodMemoryCommit, json.RawMessage(`{"triples":[]}`)) + if err2 != nil && strings.Contains(err2.Error(), "被拒") { + t.Errorf("声明了 memory 后不应被权限拒绝,实际: %v", err2) + } +} + +// 刻意不提供的内核内部机制必须有明确记录。 +// +// 这些没有对应 method 常量——不是忘了加,是决定不加。 +// 列表存在本身就是「这是策略而非疏漏」的证据。 +func TestCapability_WithheldListIsDocumented(t *testing.T) { + withheld := WithheldCapabilities() + + // §3.8 明确列为「不提供」的 + for _, name := range []string{"SelftestAPI", "SupervisorAPI", "TrackerAPI"} { + reason, ok := withheld[name] + if !ok { + t.Errorf("%s 应在 withheld 清单中(§3.8 明确不提供)", name) + continue + } + if reason == "" { + t.Errorf("%s 缺少不提供的理由", name) + } + } + + // 每一项都必须有理由,否则读代码的人无从判断边界为何在此 + for name, reason := range withheld { + if strings.TrimSpace(reason) == "" { + t.Errorf("withheld 项 %q 缺少理由", name) + } + } + + // 这些能力不应被任何 method 暴露。 + // + // 匹配用的是去掉 API 后缀的词根 + 词边界,而非直接子串: + // 直接子串匹配会把 tool.register / io.setToolBlocks 误判为泄露 ToolAPI, + // 而那两个是合法开放的(注册自己的工具、设置自己工具的返回块)。 + // 真正要拦的是形如 "tool.unregister" / "tracker.diff" 这类新增的越权 method。 + forbiddenPrefixes := map[string]string{ + "selftest.": "SelftestAPI", + "supervisor.": "SupervisorAPI", + "tracker.": "TrackerAPI", + "status.": "StatusAPI", + "adapter.": "AdapterAPI", + "config.": "ConfigAPI", + "indexer.": "IndexerAPI", + "outputchan.": "OutputChanRaw", + "events.publish": "EventPublish", + } + for m := range methodCapability { + lower := strings.ToLower(m) + for prefix, capName := range forbiddenPrefixes { + if strings.HasPrefix(lower, prefix) { + t.Errorf("method %q 暴露了刻意不提供的能力 %q", m, capName) + } + } + // 工具表直接操作:注册自己的工具合法,注销别人的不合法 + if strings.Contains(lower, "unregister") { + t.Errorf("method %q 暴露了工具注销能力(ToolAPI,刻意不提供)", m) + } + } +} + +// KnownCapabilities 不含 core(无需声明),且与 methodCapability 一致。 +func TestCapability_KnownListExcludesCore(t *testing.T) { + known := KnownCapabilities() + for _, k := range known { + if k == string(CapCore) { + t.Error("KnownCapabilities 不应含 core(无需声明)") + } + } + + // 每个非 core 能力都应可声明 + declared := map[string]bool{} + for _, k := range known { + declared[k] = true + } + for _, cap := range methodCapability { + if cap == CapCore { + continue + } + if !declared[string(cap)] { + t.Errorf("能力 %q 在 methodCapability 中使用但不在 KnownCapabilities 里", cap) + } + } +} + +// 未知 method 走 Handle 的兜底分支,不因权限检查提前返回误导性错误。 +func TestCapability_UnknownMethodFallsThrough(t *testing.T) { + h := &coreHandler{ + sdk: newFakeCore(), + name: "demo", + caps: newCapabilitySet([]string{"io"}), + } + _, err := h.Handle("nonexistent.method", nil) + if err == nil { + t.Fatal("未知 method 应报错") + } + // 应是「未知 method」而非「权限被拒」——否则作者会以为是漏声明能力 + if strings.Contains(err.Error(), "被拒") { + t.Errorf("未知 method 不应报权限错误,实际: %v", err) + } +} diff --git a/internal/plugin/proc/corehandler.go b/internal/plugin/proc/corehandler.go index fd3cf5d..2a36dab 100644 --- a/internal/plugin/proc/corehandler.go +++ b/internal/plugin/proc/corehandler.go @@ -39,6 +39,11 @@ type coreHandler struct { // evtRing 是事件环的订阅接口(实现由 internal/plugin 提供,避免循环依赖)。 evtRing EvtRingSubscriber + + // caps 是本插件被授予的能力集(§3.8 权限梯度)。 + // nil 或 unrestricted 时不限制——存量插件未声明 capabilities, + // 若按最小权限处理会让它们静默降级。 + caps *capabilitySet } // EvtRingSubscriber 是事件环订阅接口,由 internal/plugin.EventRing 实现。 @@ -91,7 +96,15 @@ type CoreSDK interface { } // Handle 分派一次插件 → 内核的调用。 +// +// 权限梯度在此强制(§3.8):manifest 未声明的能力组被**明确拒绝**。 +// 不静默忽略:C ABI 时代 case 23/24 返回成功但永远收不到事件 +// (§1.3 的「给不了」而非「不给」),插件作者无从得知。 func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}, error) { + if ok, cap := h.caps.allows(method); !ok { + return nil, errCapabilityDenied(h.name, method, cap) + } + switch method { // ---- 注册面(原 case 1/2/3/4/46)---- diff --git a/internal/plugin/proc/plugin.go b/internal/plugin/proc/plugin.go index eac7b75..3c99cd3 100644 --- a/internal/plugin/proc/plugin.go +++ b/internal/plugin/proc/plugin.go @@ -39,13 +39,20 @@ type Plugin struct { // onCrash 由 registry 注入,把进程退出喂给 plugin_health.recordCrash(§2.3)。 onCrash func(name string, err error) + // caps 是 manifest 声明的能力集(§3.8 权限梯度)。 + caps *capabilitySet + stopOnce sync.Once } // New 创建子进程插件(不启动进程)。 // // host 必须是全部子进程插件共用的实例(由 registry 创建一次)。 -func New(name, bin, dir string, config map[string]interface{}, host *Host, onCrash func(string, error)) *Plugin { +// New 创建子进程插件(不启动进程)。 +// +// host 必须是全部子进程插件共用的实例(由 registry 创建一次)。 +// capabilities 来自 manifest 的 capabilities 字段;为空时不限制(存量插件向后兼容)。 +func New(name, bin, dir string, config map[string]interface{}, host *Host, onCrash func(string, error), capabilities ...string) *Plugin { return &Plugin{ name: name, bin: bin, @@ -53,6 +60,7 @@ func New(name, bin, dir string, config map[string]interface{}, host *Host, onCra config: config, host: host, onCrash: onCrash, + caps: newCapabilitySet(capabilities), } } @@ -73,6 +81,7 @@ func (p *Plugin) Start(core CoreSDK) error { host: p.host, locks: p.host.locks, evtRing: p.host.evtSubscriber, + caps: p.caps, } // 反向调用闭包:注册回调时捕获,运行期经 RPC 打到插件进程。 p.handler.invokeTool = p.invokeTool diff --git a/internal/plugins/capability_wiring_test.go b/internal/plugins/capability_wiring_test.go new file mode 100644 index 0000000..b606b68 --- /dev/null +++ b/internal/plugins/capability_wiring_test.go @@ -0,0 +1,100 @@ +//go:build linux || darwin + +package plugins + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// manifest 声明的 capabilities 在真实内核加载路径上生效(Part 6.4)。 +// +// capability_test.go 在 proc 包内验证判定逻辑;这里验证**接线**: +// manifest → readManifest → proc.New(caps...) → coreHandler.Handle 的强制。 + +// installPluginWithCaps 装插件并写入指定 capabilities 声明。 +func installPluginWithCaps(t *testing.T, plgDir, name string, caps []string) { + t.Helper() + src := realPluginBinary(t, name) + + dst := filepath.Join(plgDir, name) + if err := os.MkdirAll(dst, 0o755); err != nil { + t.Fatalf("建插件目录: %v", err) + } + data, err := os.ReadFile(src) + if err != nil { + t.Fatalf("读产物: %v", err) + } + if err := os.WriteFile(filepath.Join(dst, "plugin.bin"), data, 0o755); err != nil { + t.Fatalf("写产物: %v", err) + } + + capsJSON := "" + if caps != nil { + quoted := make([]string, len(caps)) + for i, c := range caps { + quoted[i] = fmt.Sprintf("%q", c) + } + capsJSON = fmt.Sprintf(`,"capabilities":[%s]`, strings.Join(quoted, ",")) + } + manifest := fmt.Sprintf( + `{"name":%q,"name_zh":%q,"name_en":%q,"version":"1.0.0","entry":"plugin.so"%s}`, + name, name, name, capsJSON) + if err := os.WriteFile(filepath.Join(dst, "plugin.json"), []byte(manifest), 0o644); err != nil { + t.Fatalf("写 manifest: %v", err) + } +} + +// 声明了受限能力集的插件仍能正常加载并注册工具。 +// +// core 能力(注册工具/阶段/通道 + 读写自己的配置)无需声明, +// 否则插件根本无法启动——weather 在 Start 里就要读 Settings。 +func TestCapability_RestrictedPluginStillLoads(t *testing.T) { + env := setupIntegration(t) + defer env.cleanup() + + plgDir := filepath.Join(env.tmpDir, "plugins") + // 只声明 io:weather 用到的 Settings 属 core,应放行 + installPluginWithCaps(t, plgDir, "weather", []string{"io"}) + + if err := env.pluginReg.Load(plgDir); err != nil { + t.Fatalf("加载插件: %v", err) + } + if env.pluginReg.Get("weather") == nil { + t.Fatal("声明受限能力后插件应仍能加载(core 能力无需声明)") + } + + // 工具注册也属 core + found := false + for _, def := range env.stageHost.GetToolDefs() { + if strings.Contains(def.Name, "weather") { + found = true + break + } + } + if !found { + t.Error("受限插件仍应能注册工具(tool.register 属 core)") + } +} + +// 未声明 capabilities 的插件不受限——存量插件向后兼容。 +// +// 17 个存量插件的 plugin.json 都没有这个字段。若空声明当作最小权限, +// 它们会静默失去 IO 注入/记忆读写等能力,违反「外部插件零改动」。 +func TestCapability_LegacyManifestUnrestricted(t *testing.T) { + env := setupIntegration(t) + defer env.cleanup() + + plgDir := filepath.Join(env.tmpDir, "plugins") + installPluginWithCaps(t, plgDir, "weather", nil) // 无 capabilities 字段 + + if err := env.pluginReg.Load(plgDir); err != nil { + t.Fatalf("加载插件: %v", err) + } + if env.pluginReg.Get("weather") == nil { + t.Fatal("未声明 capabilities 的存量插件必须能正常加载") + } +}