mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 01:18:08 +00:00
plugin: 权限梯度显式化(Part 6.4)
迁移前,「外部插件拿不到 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
This commit is contained in:
@ -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(全进程唯一)。
|
||||
|
||||
@ -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) {
|
||||
|
||||
267
internal/plugin/proc/capability.go
Normal file
267
internal/plugin/proc/capability.go
Normal file
@ -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(), ", "))
|
||||
}
|
||||
322
internal/plugin/proc/capability_test.go
Normal file
322
internal/plugin/proc/capability_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)----
|
||||
|
||||
@ -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
|
||||
|
||||
100
internal/plugins/capability_wiring_test.go
Normal file
100
internal/plugins/capability_wiring_test.go
Normal file
@ -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 的存量插件必须能正常加载")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user