mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 17:27:31 +00:00
refactor(security): 删除三份包级单例,共享数据统一挂 kstate
## 用户裁定
> secutiry 的实现也是错误的,所有共享数据统一挂载一个 kernel 提供的
> 挂载点上暴露。
> store也应该调用security的加密能力!
## 缺陷
security 包原有**三份**独立包级单例,各自被 InitXxx/SetXxx 装入、
各自有访问器,三份都绕开了内核统一挂载点 kstate.State:
global *Store 变量库(凭据别名) Global()
runtime *Runtime 安全防火墙 RuntimeGlobal()
globalCodec *Codec 配置落盘加密 ConfigCodec()
## 为什么单例是错的(不是"多一个全局变量")
本 agent 的 a.security 与 security.Global() 是**两个独立变量**。
正常装配下它们恰好指向同一份数据,但"恰好"不是保证:
· 测试替身:测试装单例、被测代码用 agent 字段 ⇒ 测的与跑的不是同一份
· 多 Runtime:两个内核实例/热重载时单例只有一份,另一方被覆盖
· 装配顺序:单例装晚了,先注册的 provider 就没被装饰
第三条**曾真实存在**:ProviderManager.Register 在注册时读单例套装饰器,
而 llm 模块原本不声明 FacSecurity 依赖 —— 顺序正确纯属巧合。
改动过程中判据如实报红 640 次泄漏。
## 本次做的(T3.1 + T3.3)
### 删 global *Store → 注入
变量库的唯一来源是 security 模块产出的 Runtime,经注入面分发:
AgentConfig.Security → a.security.Store() → a.secretStore()
ProviderManager.SetSecretStore
plugin.Registry.SetSecretStore → sdk.NewSecretsAPI(store)
core 的 4 处 security.Global()(output/toolcall/tooldefs/secret_tools)
改为 a.secretStore();context 与 stageHost 改为注入字段;
provider 装饰器改为读注入的 Store。
### 删 runtime *Runtime → 注入
plugin.Registry.SetSecurityRuntime → sdk.NewSecurityAPI(name, rt)
→ sdk.newEventsAPI(name, bus, rt)
Prepare 的幂等缓存改为 kernelmod/security 私有变量 ——
装配器私有,不再是任何包都能取的公开访问器。
### 顺带删死代码
security.FillToolArgs / FillPayload 生产调用者为 0(StageHost 已自己
用注入的 Store 做同一件事),连同只测它们的判据一起删除。
### llm 模块补 FacSecurity 依赖
这是**真实存在**的依赖:注册 provider 时要套出向装饰器,而装饰器需要
变量库。此前靠"单例先装了"掩盖了顺序错误。
## 判据(均经变异验证)
TestNoPackageLevelSingletons 源码扫描;加回 var global ⇒ 红(实测)
TestMustStoreDoesNotRegisterGlobally 两次 MustStore 必须得到不同实例
TestStoreEncryptsWithOwnKey 落盘无明文 + 重载可解
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 63 包全过,0 FAIL
干净检出(git worktree) build + 3 包测试全绿
## 未做(见 kernelplan.md T3.2)
globalCodec 收敛 + Store 复用 Codec
还牵涉 config 的 6 处调用点与**存量密文惰性迁移**,单独一步做。
TestNoPackageLevelSingletons 的禁表暂不含 globalCodec ——
半成品状态长期红的判据会被人习惯性忽略,那时它就等于不存在。
This commit is contained in:
@ -65,8 +65,9 @@ type eventsAPI struct {
|
||||
// 返回 nil 会让 `s.Events().Publish(...)` 在未接总线的宿主里直接 panic
|
||||
// (实测:internal/plugins 的集成测试在 loadOne 阶段崩在 clawhubadapter 上)。
|
||||
// 插件不该为"宿主没接总线"写 nil 判断——那是一个该报错、而不是该崩溃的状态。
|
||||
func newEventsAPI(pluginName string, bus *events.Bus) pubsdk.EventsAPI {
|
||||
api := &eventsAPI{pluginName: pluginName, bus: bus, sec: security.RuntimeGlobal()}
|
||||
func newEventsAPI(pluginName string, bus *events.Bus, rt *security.Runtime) pubsdk.EventsAPI {
|
||||
// sec 由内核注入(不再读 RuntimeGlobal 包级单例)。
|
||||
api := &eventsAPI{pluginName: pluginName, bus: bus, sec: rt}
|
||||
if bus == nil {
|
||||
api.unavailable = "event bus is not wired in this host"
|
||||
}
|
||||
|
||||
@ -28,16 +28,14 @@ import (
|
||||
func newTestEvents(t *testing.T, name string) (pubsdk.EventsAPI, *events.Bus) {
|
||||
t.Helper()
|
||||
bus := events.NewBus()
|
||||
// 用当前全局 Runtime 造闸门(未装配时 NewRuntime(nil) 给 Disabled Runtime,
|
||||
// 策略仍生效 —— 与生产同一条纪律:关闭凭据替换 ≠ 放开授权)。
|
||||
rt := security.RuntimeGlobal()
|
||||
if rt == nil {
|
||||
rt = security.NewRuntime(nil)
|
||||
}
|
||||
// ★ 2026-11-04:包级单例已删,Runtime 改为**显式构造并注入**。
|
||||
// NewRuntime(nil) 给 Disabled Runtime,策略仍生效 ——
|
||||
// 与生产同一条纪律:关闭凭据替换 ≠ 放开授权。
|
||||
rt := security.NewRuntime(nil)
|
||||
if g := security.AsEventGuard(rt); g != nil {
|
||||
bus.SetGuard(g)
|
||||
}
|
||||
return newEventsAPI(name, bus), bus
|
||||
return newEventsAPI(name, bus, rt), bus
|
||||
}
|
||||
|
||||
// ① 白名单事件可发布,且 Source 被强制设成插件名(不能冒名)。
|
||||
@ -130,7 +128,7 @@ func TestEventsSubscribeAllowlistedOnly(t *testing.T) {
|
||||
// ⑤ 载荷出向过安全闸:明文凭据被换成 @{别名},不会原样进事件。
|
||||
func TestEventsPayloadIsScrubbed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := security.InitGlobal(security.Options{
|
||||
store, err := security.MustStore(security.Options{
|
||||
Path: dir + "/vars.json",
|
||||
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
|
||||
Enabled: true,
|
||||
@ -138,11 +136,15 @@ func TestEventsPayloadIsScrubbed(t *testing.T) {
|
||||
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")
|
||||
// ★ 2026-11-04:单例已删。这个用例要验证"出向载荷被闸门脱敏",
|
||||
// 所以 Runtime 必须带 store;显式构造后注入给事件面。
|
||||
rt := security.NewRuntime(store)
|
||||
bus0 := events.NewBus()
|
||||
if g := security.AsEventGuard(rt); g != nil {
|
||||
bus0.SetGuard(g)
|
||||
}
|
||||
e := newEventsAPI("leaky", bus0, rt)
|
||||
bus := bus0
|
||||
var payload map[string]interface{}
|
||||
bus.Subscribe(events.EventSkillDetected, func(evt *events.Event) { payload = evt.Payload })
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"github.com/JianFeeeee/HologramAgent/internal/security"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
@ -335,15 +336,25 @@ type AudioURL = pubsdk.AudioURL
|
||||
|
||||
// SDKConfig holds all dependencies for creating a PluginSDK.
|
||||
type SDKConfig struct {
|
||||
IOManager *agentIO.IOManager
|
||||
EventBus *events.Bus
|
||||
Memory MemoryAPI
|
||||
TextMemory TextMemoryAPI
|
||||
DocMemory DocMemoryAPI
|
||||
Knowledge KnowledgeAPI
|
||||
LLM LLMAPI
|
||||
Settings SettingsAPI
|
||||
Secrets pubsdk.SecretsAPI
|
||||
IOManager *agentIO.IOManager
|
||||
EventBus *events.Bus
|
||||
// SecurityRuntime 是安全运行时(插件事件闸门 + 档位声明需要它)。
|
||||
//
|
||||
// ★ 注入而不是让 sdk 读 security.RuntimeGlobal() 包级单例:
|
||||
// 单例绕开了内核统一挂载点(kstate.State.Security),且 sdk 不能
|
||||
// import kstate(kstate 依赖 sdk,会成环)。
|
||||
//
|
||||
// 本判据的由来(2026-11-04):原实现让 sdk 自己取单例,导致
|
||||
// "单例装的时机"与"插件加载的时机"隐式耦合 —— 装晚了插件就用默认
|
||||
// 最小策略,而日志里一个字都没有。
|
||||
SecurityRuntime *security.Runtime
|
||||
Memory MemoryAPI
|
||||
TextMemory TextMemoryAPI
|
||||
DocMemory DocMemoryAPI
|
||||
Knowledge KnowledgeAPI
|
||||
LLM LLMAPI
|
||||
Settings SettingsAPI
|
||||
Secrets pubsdk.SecretsAPI
|
||||
// Security 是**策略声明面**(插件声明安全档位;判定由内核执行)。
|
||||
//
|
||||
// ★ 只声明、不判定:本接口没有 CheckTool 之类的方法 —— 那属于内核。
|
||||
@ -411,7 +422,7 @@ func New(name string, cfg SDKConfig) *PluginSDK {
|
||||
|
||||
iom: cfg.IOManager,
|
||||
eventBus: cfg.EventBus,
|
||||
events: newEventsAPI(name, cfg.EventBus),
|
||||
events: newEventsAPI(name, cfg.EventBus, cfg.SecurityRuntime),
|
||||
logger: log.Default(),
|
||||
pluginMgr: cfg.PluginMgr,
|
||||
|
||||
|
||||
@ -1,19 +1,25 @@
|
||||
package sdk
|
||||
|
||||
// SecretsAPI 的内核实现:桥接到 `internal/security` 的进程级变量库。
|
||||
// SecretsAPI 的内核实现:桥接到注入的变量库(`*security.Store`)。
|
||||
//
|
||||
// ## 为何是"桥接"而不是"另建一套"
|
||||
//
|
||||
// 变量库是**进程级单例**(`security.Global()`):主/子/驻留子 agent 共享
|
||||
// 同一套别名表(设计 S10)。插件若自建一套,两库互不可见 ——
|
||||
// 内核拦截时登记的变量插件看不到,插件登记的拦截又不认识,
|
||||
// 同一个凭据可能两处各存一份而使用者不知哪个权威。
|
||||
// 变量库是**全进程唯一的一份**:主/子/驻留子 agent 共享同一套别名表
|
||||
// (设计 S10)。插件若自建一套,两库互不可见 —— 内核拦截时登记的变量
|
||||
// 插件看不到,插件登记的拦截又不认识,同一个凭据可能两处各存一份而
|
||||
// 使用者不知哪个权威。
|
||||
//
|
||||
// 所以这个实现只是把调用转发到那个单例。**没有第二份存储**。
|
||||
// 所以这个实现只是把调用转发给注入的 Store。**没有第二份存储**。
|
||||
//
|
||||
// ## 注入而不是读单例(2026-11-04)
|
||||
//
|
||||
// 此前读 `security.Global()` 包级单例。那个单例绕开了内核的统一挂载点
|
||||
// (kstate.State.Security),已删除。现在由 plugin.Registry 从
|
||||
// ks.Security 取出 Store 传进来 —— 单一事实来源、单一注入路径。
|
||||
//
|
||||
// ## 未启用时的行为
|
||||
//
|
||||
// `security.Global()` 为 nil 时**不返回 nil**,而是返回一个 Available()
|
||||
// 注入的 Store 为 nil 时**不返回 nil API**,而是返回一个 Available()
|
||||
// 为 false 的实现。理由:nil 会让插件到处判空,而判空漏一处就 panic
|
||||
// (本仓在 `*SensitiveInterceptor` 上吃过这个亏)。给一个明确的
|
||||
// "不可用" 实现,插件只需问 `Available()`。
|
||||
@ -26,12 +32,14 @@ import (
|
||||
)
|
||||
|
||||
// secretsImpl 实现 pubsdk.SecretsAPI。
|
||||
type secretsImpl struct{}
|
||||
type secretsImpl struct{ store *internalSecurity.Store }
|
||||
|
||||
// NewSecretsAPI 构造变量库接口(内核装配时注入给每个插件)。
|
||||
func NewSecretsAPI() pubsdk.SecretsAPI { return &secretsImpl{} }
|
||||
func NewSecretsAPI(store *internalSecurity.Store) pubsdk.SecretsAPI {
|
||||
return &secretsImpl{store: store}
|
||||
}
|
||||
|
||||
func (s *secretsImpl) Available() bool { return internalSecurity.Global() != nil }
|
||||
func (s *secretsImpl) Available() bool { return s.store != nil }
|
||||
|
||||
// AvailableForRPC 是 proc 桥用来**明确报告**可用性的入口。
|
||||
//
|
||||
@ -42,7 +50,7 @@ func (s *secretsImpl) Available() bool { return internalSecurity.Global() != nil
|
||||
func (s *secretsImpl) AvailableForRPC() bool { return s.Available() }
|
||||
|
||||
func (s *secretsImpl) Store(value, note string) (string, error) {
|
||||
st := internalSecurity.Global()
|
||||
st := s.store
|
||||
if st == nil {
|
||||
// ★ 响亮失败:不静默降级成"存到别处"或"不存"。
|
||||
// 插件以为存好了而实际没存,比直接报错危险得多。
|
||||
@ -52,7 +60,7 @@ func (s *secretsImpl) Store(value, note string) (string, error) {
|
||||
}
|
||||
|
||||
func (s *secretsImpl) List() []pubsdk.SecretInfo {
|
||||
st := internalSecurity.Global()
|
||||
st := s.store
|
||||
if st == nil {
|
||||
return nil
|
||||
}
|
||||
@ -68,7 +76,7 @@ func (s *secretsImpl) List() []pubsdk.SecretInfo {
|
||||
}
|
||||
|
||||
func (s *secretsImpl) Forget(alias string) error {
|
||||
st := internalSecurity.Global()
|
||||
st := s.store
|
||||
if st == nil {
|
||||
return fmt.Errorf("安全模块未启用,变量库不可用")
|
||||
}
|
||||
@ -76,7 +84,7 @@ func (s *secretsImpl) Forget(alias string) error {
|
||||
}
|
||||
|
||||
func (s *secretsImpl) Fill(text string) (string, int, []string) {
|
||||
st := internalSecurity.Global()
|
||||
st := s.store
|
||||
if st == nil {
|
||||
return text, 0, nil
|
||||
}
|
||||
|
||||
@ -25,7 +25,7 @@ package sdk
|
||||
//
|
||||
// ## 未启用时的行为
|
||||
//
|
||||
// security 未装配(`RuntimeGlobal()` 为 nil)时返回明确错误,而不是静默成功。
|
||||
// security 未注入 Runtime 时返回明确错误,而不是静默成功。
|
||||
// 理由:插件会以为自己的档位生效了,而实际什么都没登记 ——
|
||||
// 等到注入时带 `SecurityProfile: "qq.guest"` 才会响亮报"未声明档位",
|
||||
// 那时排查方向已经跑偏到"档位名拼错"。早失败比晚失败好。
|
||||
@ -42,18 +42,23 @@ import (
|
||||
type securityImpl struct {
|
||||
// pluginName 是内核注入的**真实**插件名,不来自插件自述。
|
||||
pluginName string
|
||||
// rt 是内核注入的安全运行时(可 nil)。
|
||||
//
|
||||
// ★ 2026-11-04:改用注入而不是 `RuntimeGlobal()` 包级单例 ——
|
||||
// 那个单例绕开了内核统一挂载点(kstate.State.Security)。
|
||||
rt *internalSecurity.Runtime
|
||||
}
|
||||
|
||||
// NewSecurityAPI 构造策略声明面(内核装配时注入给每个插件)。
|
||||
//
|
||||
// pluginName 必须是内核侧的插件标识(Registry 里的 name),
|
||||
// **不能**取自插件自己的 Name() 返回值。
|
||||
func NewSecurityAPI(pluginName string) pubsdk.SecurityAPI {
|
||||
return &securityImpl{pluginName: pluginName}
|
||||
func NewSecurityAPI(pluginName string, rt *internalSecurity.Runtime) pubsdk.SecurityAPI {
|
||||
return &securityImpl{pluginName: pluginName, rt: rt}
|
||||
}
|
||||
|
||||
func (s *securityImpl) DeclareProfile(p pubsdk.SecurityProfile) error {
|
||||
rt := internalSecurity.RuntimeGlobal()
|
||||
rt := s.rt
|
||||
if rt == nil {
|
||||
return fmt.Errorf("security: declare profile %q failed - security module is not assembled "+
|
||||
"(the profile would be silently unregistered)", p.Name)
|
||||
|
||||
@ -20,20 +20,19 @@ import (
|
||||
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
|
||||
)
|
||||
|
||||
// withRuntime 装一个干净的 Runtime 供测试,结束后恢复。
|
||||
// withRuntime 造一个干净的 Runtime 供测试。
|
||||
//
|
||||
// ★ 2026-11-04:不再装包级单例(已删)。调用方必须把返回值**显式注入**
|
||||
// 给被测面(NewSecurityAPI(name, rt)),这正是生产注入路径。
|
||||
func withRuntime(t *testing.T) *internalSecurity.Runtime {
|
||||
t.Helper()
|
||||
prev := internalSecurity.RuntimeGlobal()
|
||||
rt := internalSecurity.NewRuntime(nil)
|
||||
internalSecurity.SetRuntimeGlobal(rt)
|
||||
t.Cleanup(func() { internalSecurity.SetRuntimeGlobal(prev) })
|
||||
return rt
|
||||
return internalSecurity.NewRuntime(nil)
|
||||
}
|
||||
|
||||
// ① 正常声明:登记到 Runtime,且 Owner 是内核给的名字。
|
||||
func TestSecurityDeclareProfileRegistersUnderRealOwner(t *testing.T) {
|
||||
rt := withRuntime(t)
|
||||
api := NewSecurityAPI("qq")
|
||||
api := NewSecurityAPI("qq", rt)
|
||||
|
||||
if err := api.DeclareProfile(pubsdk.SecurityProfile{
|
||||
Name: "qq.guest",
|
||||
@ -53,8 +52,8 @@ func TestSecurityDeclareProfileRegistersUnderRealOwner(t *testing.T) {
|
||||
// 若能声明 `qq.owner` 且给 AllowTools=["*"],等于把 QQ 的 owner 档位
|
||||
// 改成全放行 —— 非所有者立刻能调用所有工具。
|
||||
func TestSecurityDeclareProfileRejectsForeignOwner(t *testing.T) {
|
||||
withRuntime(t)
|
||||
api := NewSecurityAPI("evil")
|
||||
rt := withRuntime(t)
|
||||
api := NewSecurityAPI("evil", rt)
|
||||
|
||||
err := api.DeclareProfile(pubsdk.SecurityProfile{
|
||||
Name: "qq.owner",
|
||||
@ -71,8 +70,8 @@ func TestSecurityDeclareProfileRejectsForeignOwner(t *testing.T) {
|
||||
|
||||
// ③ 名字必须带本插件前缀(否则登记了也命中不到)。
|
||||
func TestSecurityDeclareProfileRequiresOwnerPrefix(t *testing.T) {
|
||||
withRuntime(t)
|
||||
api := NewSecurityAPI("qq")
|
||||
rt := withRuntime(t)
|
||||
api := NewSecurityAPI("qq", rt)
|
||||
|
||||
for _, bad := range []string{"guest", "qqguest", "qqq.guest", ""} {
|
||||
if err := api.DeclareProfile(pubsdk.SecurityProfile{Name: bad}); err == nil {
|
||||
@ -86,11 +85,8 @@ func TestSecurityDeclareProfileRequiresOwnerPrefix(t *testing.T) {
|
||||
// 静默成功的后果:插件以为档位生效,直到注入时带 SecurityProfile
|
||||
// 才报"未声明档位",此时排查方向已跑到"名字拼错"上。
|
||||
func TestSecurityDeclareProfileFailsLoudlyWhenUnassembled(t *testing.T) {
|
||||
prev := internalSecurity.RuntimeGlobal()
|
||||
internalSecurity.SetRuntimeGlobal(nil)
|
||||
t.Cleanup(func() { internalSecurity.SetRuntimeGlobal(prev) })
|
||||
|
||||
api := NewSecurityAPI("qq")
|
||||
// 未装配 = Runtime 为 nil(不注入),而不是"装了 nil 单例"。
|
||||
api := NewSecurityAPI("qq", nil)
|
||||
err := api.DeclareProfile(pubsdk.SecurityProfile{Name: "qq.guest"})
|
||||
if err == nil {
|
||||
t.Fatal("★ 安全模块未装配时必须报错 —— 静默成功会让插件误以为策略已生效")
|
||||
@ -102,8 +98,8 @@ func TestSecurityDeclareProfileFailsLoudlyWhenUnassembled(t *testing.T) {
|
||||
|
||||
// ⑤ 批量声明:任一失败即整体报错,且错误带下标便于定位。
|
||||
func TestSecurityDeclareProfilesReportsIndex(t *testing.T) {
|
||||
withRuntime(t)
|
||||
api := NewSecurityAPI("qq")
|
||||
rt := withRuntime(t)
|
||||
api := NewSecurityAPI("qq", rt)
|
||||
|
||||
err := api.DeclareProfiles([]pubsdk.SecurityProfile{
|
||||
{Name: "qq.owner"},
|
||||
|
||||
Reference in New Issue
Block a user