feat(sdk): 受限事件总线的内核实现 + clawhubadapter 改用它

- internal/sdk/events.go:实现 pubsdk.EventsAPI
  · publishAllowed:白名单精确匹配 或 本插件命名空间 plugin.<name>.<后缀>
  · 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、
别的插件命名空间、空类型)/ 自己命名空间放行 / 订阅越界拒绝 / 载荷凭据被替换成别名 / 键数上限
This commit is contained in:
JianFeeeee
2026-10-27 11:16:40 +08:00
parent ffffb94005
commit 981b969f58
4 changed files with 313 additions and 9 deletions

View File

@ -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.<name>.*`)
// 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.<name>.<后缀>)。
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
}