Files
HomeAgent/internal/plugin/proc/capability_test.go
JianFeeeee 7a328679da feat(proc): Cleaner 跨进程修复(tool/input/output) + 共享内存安全模式 + plan §13
- Cleaner 协议统一为 cleaner.invoke(scope,name,text),覆盖工具/输入/输出三类
- 新增 ShmSecurityMode (safe/debug/full),Linux 审计探针,Windows crypto nonce
- plan.md 追加 §13 步骤分组
2026-09-10 10:21:50 +08:00

326 lines
11 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 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,
MethodCleanerInvoke: true,
MethodStageInvoke: true,
MethodOutputInvoke: true,
}
// 插件→内核的全部 method手工清单与 protocol.go 对照)
pluginToKernel := []string{
MethodHandshake,
MethodToolRegister, MethodStageRegister, MethodOutputRegister,
MethodAPIRegister, MethodInputRegister,
MethodIOInjectText, MethodIOInjectInterrupt, MethodIOInjectTextNoMem,
MethodIOInjectSync, MethodIOSetToolBlocks,
MethodIOInjectMedia, MethodIOInjectMediaSync, MethodIOInjectInterruptMedia,
MethodLifecycleAutoRestart,
MethodMemoryRecall, MethodMemoryCommit, MethodMemoryIntrospect,
MethodMemoryMerge, MethodMemoryPurge,
MethodDocQuery, MethodDocInsert, MethodDocRemove, MethodDocStats,
MethodDocInsertMedia,
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)
}
}