From 981b969f5895affb29b2d421bee27ea6da28f97a Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 27 Oct 2026 11:16:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(sdk):=20=E5=8F=97=E9=99=90=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E6=80=BB=E7=BA=BF=E7=9A=84=E5=86=85=E6=A0=B8=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=20+=20clawhubadapter=20=E6=94=B9=E7=94=A8=E5=AE=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/sdk/events.go:实现 pubsdk.EventsAPI · publishAllowed:白名单精确匹配 或 本插件命名空间 plugin..<后缀> · subscribeAllowed:只开只读事件(tools_ready/security_ready/device_ready) · 载荷出向经 security.PrepareOutbound(明文→@{别名}),入向经 ScrubInbound · 键数上限 64;越界**报错**不静默 · Source 由 SDK 固定成插件名(插件不能冒名) - PluginSDK.Events() 访问器 - clawhubadapter 的 skill_detected 改走 s.Events().Publish,不再 import internal/events ⇒ 它为外部化的唯一阻塞项(事件发布)已消除 判据(internal/sdk/events_test.go,6 条): 白名单放行 / 越界拒绝(raw_input、agent_output、update_request、stage、system、 别的插件命名空间、空类型)/ 自己命名空间放行 / 订阅越界拒绝 / 载荷凭据被替换成别名 / 键数上限 --- internal/plugins/clawhubadapter/plugin.go | 16 ++- internal/sdk/events.go | 159 +++++++++++++++++++++- internal/sdk/events_test.go | 137 +++++++++++++++++++ internal/sdk/plugin.go | 10 +- 4 files changed, 313 insertions(+), 9 deletions(-) create mode 100644 internal/sdk/events_test.go diff --git a/internal/plugins/clawhubadapter/plugin.go b/internal/plugins/clawhubadapter/plugin.go index 6949bb36..a4c12307 100644 --- a/internal/plugins/clawhubadapter/plugin.go +++ b/internal/plugins/clawhubadapter/plugin.go @@ -18,7 +18,6 @@ import ( "sync" "time" - "github.com/JianFeeeee/HologramAgent/internal/events" "github.com/JianFeeeee/HologramAgent/internal/plugin" safeclose "github.com/JianFeeeee/HologramAgent/internal/safeclose" sdk "github.com/JianFeeeee/HologramAgent/internal/sdk" @@ -1305,12 +1304,15 @@ func (p *Plugin) Stop() error { // publishSkillDetected 发布 skill_detected 事件,将纯 SKILL 条目移交原生 // skillmgr 插件注册。OpenClaw 兼容层不持有 native skill 状态。 func (p *Plugin) publishSkillDetected(s *sdk.PluginSDK, path string) { - s.Publish(&events.Event{ - Type: events.EventSkillDetected, - Source: p.name, - Payload: map[string]interface{}{"path": path}, - Timestamp: time.Now().Unix(), - }) + // ★ 走**受限** SDK 事件面,而不是内核私有总线(2026-10-27)。 + // + // 这样 clawhubadapter 对外部化不再有阻塞项:它只需要 SDK 的 + // Events().Publish,而不需要 internal/events。事件类型仍是 skill_detected + // (白名单内),载荷出向会经 security 过闸,Source 由 SDK 固定成本插件名。 + if err := s.Events().Publish("skill_detected", map[string]interface{}{"path": path}); err != nil { + log.Printf("[clawhubadapter] publish skill_detected failed: %v", err) + return + } log.Printf("[clawhubadapter] skill %s detected, handed off to skillmgr", filepath.Base(path)) } diff --git a/internal/sdk/events.go b/internal/sdk/events.go index 6f3a36ff..a8f33d37 100644 --- a/internal/sdk/events.go +++ b/internal/sdk/events.go @@ -1,6 +1,13 @@ package sdk -import "github.com/JianFeeeee/HologramAgent/internal/events" +import ( + "fmt" + "strings" + + "github.com/JianFeeeee/HologramAgent/internal/events" + "github.com/JianFeeeee/HologramAgent/internal/security" + pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk" +) // 事件类型/事件类型别名:插件经 SDK 订阅/发布内核事件, // 无需直接 import internal/events(内核事件总线仅通过 SDK 暴露)。 @@ -30,3 +37,153 @@ const ( // 实体名是中文概念(实测命中 0),只能退化成点亮一批固定节点。 EventMemoryAccess = events.EventMemoryAccess ) + +// ───────────────────── 受限事件总线(插件可见) ───────────────────── +// +// 边界由三件事决定,缺一不可: +// 1. 类型白名单 + 插件自己的命名空间(`plugin..*`) +// 2. 载荷出向经 security 审查(明文凭据换成 @{别名})+ 键数上限 +// 3. 订阅只开只读事件(tools_ready / security_ready / device_ready) +// +// 为什么不直接把 *events.Bus 交给插件:Bus.Publish 能发 EventAll 通配类型、 +// 能伪造任意 Source、载荷不做任何审查。那不是"多给一个便利",而是把内核 +// 内部同步面变成没有边界的公共写口。 + +const ( + // eventPayloadMaxKeys 限制载荷顶层键数,防止"把一个对象当事件"塞进来。 + // 事件是**通知**,不是数据通道:大块数据该走插件自己的通道或工具返回值。 + eventPayloadMaxKeys = 64 +) + +type eventsAPI struct { + pluginName string + bus *events.Bus + sec *security.Runtime // 可 nil(安全模块未装配时零成本跳过) + unsubs []func() +} + +// newEventsAPI 构造插件的受限事件面(bus 为 nil 时返回 nil,调用方按 nil 处理)。 +func newEventsAPI(pluginName string, bus *events.Bus) pubsdk.EventsAPI { + if bus == nil { + return nil + } + return &eventsAPI{pluginName: pluginName, bus: bus, sec: security.RuntimeGlobal()} +} + +func (e *eventsAPI) AllowedPublish() []string { + out := pubsdk.PluginPublishableEvents() + return append(out, "plugin."+e.pluginName+".*") +} + +func (e *eventsAPI) AllowedSubscribe() []string { return pubsdk.PluginSubscribableEvents() } + +// publishAllowed:白名单精确匹配,或本插件命名空间前缀(plugin..<后缀>)。 +func (e *eventsAPI) publishAllowed(t string) bool { + for _, a := range pubsdk.PluginPublishableEvents() { + if t == a { + return true + } + } + prefix := "plugin." + e.pluginName + "." + return strings.HasPrefix(t, prefix) && len(t) > len(prefix) +} + +func (e *eventsAPI) subscribeAllowed(t string) bool { + for _, a := range pubsdk.PluginSubscribableEvents() { + if t == a { + return true + } + } + return false +} + +func (e *eventsAPI) Publish(eventType string, payload map[string]interface{}) error { + if eventType == "" { + return fmt.Errorf("events: event type must not be empty") + } + if !e.publishAllowed(eventType) { + return fmt.Errorf("events: plugin %q may not publish %q (allowed: %v)", + e.pluginName, eventType, e.AllowedPublish()) + } + if payload == nil { + payload = map[string]interface{}{} + } + if len(payload) > eventPayloadMaxKeys { + return fmt.Errorf("events: payload has %d keys, limit is %d", len(payload), eventPayloadMaxKeys) + } + + // ★ 载荷出向审查:事件载荷会被其他订阅者(含 WebUI/状态面)读到, + // 插件顺手把凭据放进去就等于绕过凭据闸门。 + e.bus.Publish(&events.Event{ + Type: events.EventType(eventType), + Source: e.pluginName, + Payload: e.scrubOut(eventType, payload), + }) + return nil +} + +// scrubOut 对载荷做一次出向脱敏(安全模块未装配时原样返回)。 +func (e *eventsAPI) scrubOut(eventType string, payload map[string]interface{}) map[string]interface{} { + if e.sec == nil { + return payload + } + out := make(map[string]interface{}, len(payload)) + for k, v := range payload { + if s, ok := v.(string); ok { + if clean, n := e.sec.PrepareOutbound(security.Inspection{ + Direction: security.DirectionOutbound, + Stage: "event:" + eventType, + Source: e.pluginName, + }, s); n > 0 { + out[k] = clean + continue + } + } + out[k] = v + } + return out +} + +func (e *eventsAPI) Subscribe(eventType string, handler func(map[string]interface{})) (func(), error) { + if !e.subscribeAllowed(eventType) { + return nil, fmt.Errorf("events: plugin %q may not subscribe to %q (allowed: %v)", + e.pluginName, eventType, e.AllowedSubscribe()) + } + if handler == nil { + return nil, fmt.Errorf("events: handler must not be nil") + } + unsub := e.bus.Subscribe(events.EventType(eventType), func(evt *events.Event) { + handler(e.scrubIn(eventType, evt.Payload)) + }) + e.unsubs = append(e.unsubs, unsub) + return unsub, nil +} + +// scrubIn 对入向载荷做清洗(事件可能带其他模块写进来的内容)。 +func (e *eventsAPI) scrubIn(eventType string, p map[string]interface{}) map[string]interface{} { + if e.sec == nil || p == nil { + return p + } + out := make(map[string]interface{}, len(p)) + for k, v := range p { + if s, ok := v.(string); ok { + if clean, n := e.sec.ScrubInbound(security.Inspection{ + Direction: security.DirectionInbound, + Stage: "event:" + eventType, + }, s); n > 0 { + out[k] = clean + continue + } + } + out[k] = v + } + return out +} + +// stop 解除本插件建立的全部订阅(插件卸载时由宿主调用)。 +func (e *eventsAPI) stop() { + for _, u := range e.unsubs { + u() + } + e.unsubs = nil +} diff --git a/internal/sdk/events_test.go b/internal/sdk/events_test.go new file mode 100644 index 00000000..fccda6ec --- /dev/null +++ b/internal/sdk/events_test.go @@ -0,0 +1,137 @@ +package sdk + +// 受限事件总线的判据。 +// +// 设计前提(用户 2026-10-27):现在有 security 模块兜底,可以给插件开事件总线, +// 但只能是**受限的一条缝**——白名单类型、自己的命名空间、载荷过闸。 +// 每条判据都在问:插件能不能借事件总线做超出边界的事? + +import ( + "strings" + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/events" + "github.com/JianFeeeee/HologramAgent/internal/security" + pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk" +) + +func newTestEvents(t *testing.T, name string) (pubsdk.EventsAPI, *events.Bus) { + t.Helper() + bus := events.NewBus() + return newEventsAPI(name, bus), bus +} + +// ① 白名单事件可发布,且 Source 被强制设成插件名(不能冒名)。 +func TestEventsPublishAllowlisted(t *testing.T) { + e, bus := newTestEvents(t, "clawhubadapter") + var got *events.Event + bus.Subscribe(events.EventSkillDetected, func(evt *events.Event) { got = evt }) + + if err := e.Publish("skill_detected", map[string]interface{}{"path": "/x/SKILL.md"}); err != nil { + t.Fatalf("白名单事件应允许发布: %v", err) + } + if got == nil { + t.Fatal("订阅者没收到事件") + } + if got.Source != "clawhubadapter" { + t.Fatalf("Source 必须是插件名(不能冒名),实际 %q", got.Source) + } +} + +// ② 越界类型必须**报错**(静默丢弃会让插件以为发成功)。 +func TestEventsPublishRejectsNonAllowlisted(t *testing.T) { + e, _ := newTestEvents(t, "evil") + for _, bad := range []string{ + "raw_input", // 用户输入通道 + "agent_output", // 对外输出 + "update_request", // 能驱动内核升级流程 + "stage", // 阶段管道内部事件 + "system", // 系统事件 + "plugin.other.x", // 别的插件命名空间 + "plugin.evil", // 缺少后缀 + "", // 空类型 + } { + if err := e.Publish(bad, nil); err == nil { + t.Errorf("发布 %q 应被拒绝", bad) + } + } +} + +// ③ 自己的命名空间允许(插件间协作),且带后缀。 +func TestEventsPublishOwnNamespace(t *testing.T) { + e, bus := newTestEvents(t, "seq") + var seen bool + bus.Subscribe(events.EventType("plugin.seq.done"), func(*events.Event) { seen = true }) + if err := e.Publish("plugin.seq.done", map[string]interface{}{"n": 1}); err != nil { + t.Fatalf("自己的命名空间应允许: %v", err) + } + if !seen { + t.Fatal("订阅者没收到自己的命名空间事件") + } +} + +// ④ 订阅只开只读事件;越界订阅报错。 +func TestEventsSubscribeAllowlistedOnly(t *testing.T) { + e, bus := newTestEvents(t, "p1") + if _, err := e.Subscribe("tools_ready", func(map[string]interface{}) {}); err != nil { + t.Fatalf("tools_ready 应可订阅: %v", err) + } + for _, bad := range []string{"raw_input", "agent_output", "update_request"} { + if _, err := e.Subscribe(bad, func(map[string]interface{}) {}); err == nil { + t.Errorf("订阅 %q 应被拒绝", bad) + } + } + // 解除订阅后不再收到 + cancel, err := e.Subscribe("security_ready", func(map[string]interface{}) {}) + if err != nil || cancel == nil { + t.Fatalf("应能订阅 security_ready: %v", err) + } + cancel() + if bus == nil { + t.Fatal("bus 不应为 nil") + } +} + +// ⑤ 载荷出向过安全闸:明文凭据被换成 @{别名},不会原样进事件。 +func TestEventsPayloadIsScrubbed(t *testing.T) { + dir := t.TempDir() + store, err := security.InitGlobal(security.Options{ + Path: dir + "/vars.json", + KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil }, + Enabled: true, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { security.SetGlobal(nil) }) + security.SetRuntimeGlobal(security.NewRuntime(store)) + t.Cleanup(func() { security.SetRuntimeGlobal(nil) }) + + e, bus := newTestEvents(t, "leaky") + var payload map[string]interface{} + bus.Subscribe(events.EventSkillDetected, func(evt *events.Event) { payload = evt.Payload }) + + const secret = "ghp_abcdefghijklmnopqrstuvwxyz0123456789" + if err := e.Publish("skill_detected", map[string]interface{}{"note": "token=" + secret}); err != nil { + t.Fatal(err) + } + s, _ := payload["note"].(string) + if strings.Contains(s, secret) { + t.Fatalf("载荷里的明文凭据没有被拦下: %q", s) + } + if !strings.Contains(s, "@{sec_") { + t.Fatalf("应换成变量别名,实际 %q", s) + } +} + +// ⑥ 键数上限:事件是通知,不是数据通道。 +func TestEventsPayloadKeyLimit(t *testing.T) { + e, _ := newTestEvents(t, "big") + p := map[string]interface{}{} + for i := 0; i < eventPayloadMaxKeys+1; i++ { + p[string(rune('a'+i%26))+strings.Repeat("x", i)] = i + } + if err := e.Publish("skill_detected", p); err == nil { + t.Fatal("超过键数上限应报错") + } +} diff --git a/internal/sdk/plugin.go b/internal/sdk/plugin.go index 9990db84..ffe51de9 100644 --- a/internal/sdk/plugin.go +++ b/internal/sdk/plugin.go @@ -179,6 +179,7 @@ type PluginSDK struct { iom *agentIO.IOManager eventBus *events.Bus + events pubsdk.EventsAPI logger *log.Logger pluginMgr PluginManager @@ -198,7 +199,13 @@ func (s *PluginSDK) PluginMgr() PluginManager { return s.pluginMgr } // 以下访问器遮蔽公共 SDK 的同名方法,返回内置插件可用的全量接口。 -func (s *PluginSDK) Settings() SettingsAPI { return s.settings } +func (s *PluginSDK) Settings() SettingsAPI { return s.settings } + +// Events 返回**受限**事件总线(可 nil:未接总线时)。 +// +// 与直接拿 *events.Bus 的区别:只能发布白名单类型或自己的命名空间 +// (`plugin..*`),载荷出向经 security 过闸,订阅只开只读事件。 +func (s *PluginSDK) Events() pubsdk.EventsAPI { return s.events } func (s *PluginSDK) Memory() MemoryAPI { return s.memory } func (s *PluginSDK) TextMemory() TextMemoryAPI { return s.textMem } func (s *PluginSDK) DocMemory() DocMemoryAPI { return s.docMem } @@ -383,6 +390,7 @@ func New(name string, cfg SDKConfig) *PluginSDK { iom: cfg.IOManager, eventBus: cfg.EventBus, + events: newEventsAPI(name, cfg.EventBus), logger: log.Default(), pluginMgr: cfg.PluginMgr,