mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 09:17:31 +00:00
feat: 段页式记忆流转、coldcontext 重命名与安全治理
记忆层: - MemoryBlock 承载 FrameTS/PageSeq/PageKind/BlockRole/CallID - coldcontext 归档与回填按完整页传递,工具调用参数和配对键不丢失 - 召回先估算总 token,超预算时按全局页相似度标记 inactive - inactive 仅是 context 渲染期状态,不落盘、不进入 coldcontext,Prune 后重置 - Peek/确认/Remove 两阶段召回,失败保留冷上下文副本 - stablemem 记录 last_recalled_at,删块连带清理边 命名: - internal/memory/document → internal/memory/coldcontext - Doc/DocEvent/DocHit → ColdDoc/ColdEvent/ColdHit - ContextToColdContext/RecentColdContexts/FindColdContexts - 保留既有 JSON 标签、doc_ 文件前缀与 doc_count 状态键,兼容存量数据 安全治理: - 工具策略门禁、安全配置、事件总线发布者身份与入站脱敏 - 声明式 SecurityAPI 与 QQ owner/guest profile - 日志保留策略与相关 e2e/回归判据 验证:go build ./...、go vet ./...、go test ./...(62 包); 生产副本 e2e:7013 块迁移后 ID/向量/指纹 0 变化、0 缺失。
This commit is contained in:
@ -96,44 +96,46 @@ func (e *eventsAPI) EventPolicy() security.EventPolicy {
|
||||
return security.DefaultEventPolicy()
|
||||
}
|
||||
|
||||
// Publish 以**本插件身份**发布事件。
|
||||
//
|
||||
// ## ★ 为什么必须走 PublishAs(2026-10-29 收敛)
|
||||
//
|
||||
// 此前这里调的是 `bus.Publish`,而它的实现是 `publishAs(Publisher{}, ...)` ——
|
||||
// 空 Publisher 的 `IsKernel()` 为真,于是 security 的 `AllowPublish`
|
||||
// **第一行就直接放行**。也就是说:总线上的事件策略对这条路径从未生效过,
|
||||
// 它之所以“安全”,只是因为本函数自己又查了一遍策略。
|
||||
//
|
||||
// 后果是**同一条策略有两份实现**(本文件 + security.eventGuard),
|
||||
// 一旦口径漂移就会出现“总线放行、适配层拦截”或反之的分裂。
|
||||
//
|
||||
// 现在只保留一份:
|
||||
//
|
||||
// e.bus.PublishAs(Publisher{Name: e.pluginName}, ...)
|
||||
// → security.eventGuard.AllowPublish (授权,唯一一处)
|
||||
// → security.eventGuard.ScrubPayload (出向脱敏)
|
||||
// → 投递时 security.eventGuard.ScrubInbound(入向清洗)
|
||||
//
|
||||
// 本文件只剩“把身份如实告诉总线”这一件职责 —— 插件模块不再持有策略判断
|
||||
// (用户裁定:所有安全部分归安全模块)。
|
||||
func (e *eventsAPI) Publish(eventType string, payload map[string]interface{}) error {
|
||||
if e.unavailable != "" {
|
||||
return fmt.Errorf("events: %s", e.unavailable)
|
||||
}
|
||||
if d := e.checkPublish(eventType); !d.Allowed {
|
||||
return fmt.Errorf("%s", d.Reason)
|
||||
}
|
||||
if payload == nil {
|
||||
payload = map[string]interface{}{}
|
||||
}
|
||||
// 载荷键数上限仍在这里判:它是**当前唯一实现**(eventGuard.AllowPublish
|
||||
// 只拿到 eventType、看不到 payload,故无法在那里做)。
|
||||
// 删掉它 = 直接失去这项保护,所以不属“重复”而不应一并丢弃。
|
||||
if d := e.checkSize(len(payload)); !d.Allowed {
|
||||
return fmt.Errorf("%s", d.Reason)
|
||||
}
|
||||
|
||||
// ★ 载荷出向审查:事件载荷会被其他订阅者(含 WebUI/状态面)读到,
|
||||
// 插件顺手把凭据放进去就等于绕过凭据闸门。
|
||||
e.bus.Publish(&events.Event{
|
||||
return e.bus.PublishAs(events.Publisher{Name: e.pluginName}, &events.Event{
|
||||
Type: events.EventType(eventType),
|
||||
Source: e.pluginName,
|
||||
Payload: e.scrubOut(eventType, payload),
|
||||
Payload: payload,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *eventsAPI) checkPublish(t string) security.Decision {
|
||||
if e.sec != nil {
|
||||
return e.sec.CheckEventPublish(e.pluginName, t)
|
||||
}
|
||||
return security.DefaultEventPolicy().CheckEventPublish(e.pluginName, t)
|
||||
}
|
||||
|
||||
func (e *eventsAPI) checkSubscribe(t string) security.Decision {
|
||||
if e.sec != nil {
|
||||
return e.sec.CheckEventSubscribe(e.pluginName, t)
|
||||
}
|
||||
return security.DefaultEventPolicy().CheckEventSubscribe(e.pluginName, t)
|
||||
}
|
||||
|
||||
func (e *eventsAPI) checkSize(keys int) security.Decision {
|
||||
if e.sec != nil {
|
||||
return e.sec.CheckEventPayloadSize(keys)
|
||||
@ -141,66 +143,26 @@ func (e *eventsAPI) checkSize(keys int) security.Decision {
|
||||
return security.DefaultEventPolicy().CheckEventPayloadSize(keys)
|
||||
}
|
||||
|
||||
// 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.unavailable != "" {
|
||||
return nil, fmt.Errorf("events: %s", e.unavailable)
|
||||
}
|
||||
if d := e.checkSubscribe(eventType); !d.Allowed {
|
||||
return nil, fmt.Errorf("%s", d.Reason)
|
||||
}
|
||||
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))
|
||||
})
|
||||
// 走 SubscribeAs:授权交给 security.eventGuard.AllowSubscribe(唯一一处),
|
||||
// 入向清洗已由总线在投递前统一执行 —— 这里不再自己洗一遍。
|
||||
unsub, err := e.bus.SubscribeAs(events.Publisher{Name: e.pluginName},
|
||||
events.EventType(eventType), func(evt *events.Event) {
|
||||
handler(evt.Payload)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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 {
|
||||
|
||||
@ -15,9 +15,28 @@ import (
|
||||
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
|
||||
)
|
||||
|
||||
// newTestEvents 构造一个**已装上安全闸门**的受限事件面。
|
||||
//
|
||||
// ## ★ 为什么必须装 Guard(2026-10-29 收敛后)
|
||||
//
|
||||
// 此前 `sdk/events.go` 自己查策略(checkPublish/checkSubscribe/scrubOut),
|
||||
// 所以测试里的裸 bus 也能"看起来工作"。收敛后授权与脱敏全部归
|
||||
// `security.eventGuard`,而闸门是挂在总线上的 —— **测试不装 Guard,
|
||||
// 就等于在测一条没有执行者的策略**,于是判据会假绿。
|
||||
//
|
||||
// 这正好顺便钉住了生产装配要求:**总线必须带闸门**,否则策略不生效。
|
||||
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)
|
||||
}
|
||||
if g := security.AsEventGuard(rt); g != nil {
|
||||
bus.SetGuard(g)
|
||||
}
|
||||
return newEventsAPI(name, bus), bus
|
||||
}
|
||||
|
||||
|
||||
@ -6,13 +6,13 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
|
||||
)
|
||||
|
||||
// indexerImpl 桥接 memory.Indexer 到中立的 IndexerAPI。
|
||||
type indexerImpl struct {
|
||||
idx *memory.Indexer
|
||||
docs *document.Store // 可选:给「上下文」诊断面提供 L2 那一路
|
||||
docs *coldcontext.Store // 可选:给「上下文」诊断面提供 L2 那一路
|
||||
}
|
||||
|
||||
func NewIndexer(idx *memory.Indexer) IndexerAPI {
|
||||
@ -20,7 +20,7 @@ func NewIndexer(idx *memory.Indexer) IndexerAPI {
|
||||
}
|
||||
|
||||
// NewIndexerWithDocs 让诊断面同时覆盖 L2 冷上下文与 L3 图记忆(双路召回)。
|
||||
func NewIndexerWithDocs(idx *memory.Indexer, docs *document.Store) IndexerAPI {
|
||||
func NewIndexerWithDocs(idx *memory.Indexer, docs *coldcontext.Store) IndexerAPI {
|
||||
return &indexerImpl{idx: idx, docs: docs}
|
||||
}
|
||||
|
||||
@ -81,14 +81,14 @@ func (i *indexerImpl) queryDocs(q string, limit int) []DocSnippet {
|
||||
when = h.Doc.Events[0].Timestamp.Format(time.DateTime)
|
||||
}
|
||||
// 正文在事件的**文本块**里(DocEvent 只有 Blocks,没有 Input/Response)。
|
||||
text := docFirstText(h.Doc)
|
||||
text := coldcontextFirstText(h.Doc)
|
||||
out = append(out, DocSnippet{ID: h.Doc.ID, When: when, Text: truncateRunes(text, 120), Score: h.Score})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// docFirstText 取文档可用于展示的首段正文(跳过媒体块)。
|
||||
func docFirstText(d *document.Doc) string {
|
||||
// coldcontextFirstText 取文档可用于展示的首段正文(跳过媒体块)。
|
||||
func coldcontextFirstText(d *coldcontext.ColdDoc) string {
|
||||
if d == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
doc "github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
coldcontext "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/text"
|
||||
)
|
||||
|
||||
@ -342,14 +342,14 @@ func (m *textMemoryImpl) Stats() map[string]interface{} {
|
||||
// ---------- 文档记忆(知识库) ----------
|
||||
|
||||
type docMemoryImpl struct {
|
||||
ds *doc.Store
|
||||
ds *coldcontext.Store
|
||||
plugin string
|
||||
}
|
||||
|
||||
func NewDocMemory(ds *doc.Store) DocMemoryAPI { return &docMemoryImpl{ds: ds} }
|
||||
func NewDocMemory(ds *coldcontext.Store) DocMemoryAPI { return &docMemoryImpl{ds: ds} }
|
||||
|
||||
// NewDocMemoryWithPlugin 创建带上报名的文档记忆包装。
|
||||
func NewDocMemoryWithPlugin(plugin string, ds *doc.Store) DocMemoryAPI {
|
||||
func NewDocMemoryWithPlugin(plugin string, ds *coldcontext.Store) DocMemoryAPI {
|
||||
return &docMemoryImpl{ds: ds, plugin: plugin}
|
||||
}
|
||||
|
||||
@ -374,7 +374,7 @@ func (m *docMemoryImpl) Query(text string, topK int) []*Doc {
|
||||
//
|
||||
// 唯一的来源是文档直接持有的一等记忆块:媒体不靠正文标记、
|
||||
// 也不靠任何生成的描述文本。
|
||||
func (m *docMemoryImpl) fillMedia(out *Doc, d *doc.Doc) {
|
||||
func (m *docMemoryImpl) fillMedia(out *Doc, d *coldcontext.ColdDoc) {
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
@ -421,7 +421,7 @@ func (m *docMemoryImpl) InsertWithMedia(d *Doc, attachments []MediaAttachment) e
|
||||
if m.ds == nil || d == nil {
|
||||
return nil
|
||||
}
|
||||
target := &doc.Doc{ID: d.ID, Summary: d.Title, Content: d.Content}
|
||||
target := &coldcontext.ColdDoc{ID: d.ID, Summary: d.Title, Content: d.Content}
|
||||
if target.Source == "" {
|
||||
target.Source = "plugin:" + m.plugin
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
doc "github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
coldcontext "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/text"
|
||||
)
|
||||
|
||||
@ -18,7 +18,7 @@ import (
|
||||
// 3. 媒体块挂到正确的 owner 上,删除时随之释放;
|
||||
// 4. 块身份是内容派生(blk_media_<sha256(mime+data)>),同内容同块。
|
||||
|
||||
func newTestStores(t *testing.T) (*memory.GraphDB, *doc.Store, *text.Memory) {
|
||||
func newTestStores(t *testing.T) (*memory.GraphDB, *coldcontext.Store, *text.Memory) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
@ -28,7 +28,7 @@ func newTestStores(t *testing.T) (*memory.GraphDB, *doc.Store, *text.Memory) {
|
||||
}
|
||||
t.Cleanup(func() { g.Close() })
|
||||
|
||||
ds := doc.NewStore(filepath.Join(dir, "documents"), memory.TokenizeWords)
|
||||
ds := coldcontext.NewStore(filepath.Join(dir, "documents"), memory.TokenizeWords)
|
||||
if err := ds.Start(); err != nil {
|
||||
t.Fatalf("doc store start: %v", err)
|
||||
}
|
||||
|
||||
@ -329,15 +329,19 @@ 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
|
||||
Memory MemoryAPI
|
||||
TextMemory TextMemoryAPI
|
||||
DocMemory DocMemoryAPI
|
||||
Knowledge KnowledgeAPI
|
||||
LLM LLMAPI
|
||||
Settings SettingsAPI
|
||||
Secrets pubsdk.SecretsAPI
|
||||
// Security 是**策略声明面**(插件声明安全档位;判定由内核执行)。
|
||||
//
|
||||
// ★ 只声明、不判定:本接口没有 CheckTool 之类的方法 —— 那属于内核。
|
||||
Security pubsdk.SecurityAPI
|
||||
RawFiles pubsdk.RawFilesAPI
|
||||
RegTool ToolRegistrar
|
||||
RegStage StageRegistrar
|
||||
@ -363,6 +367,9 @@ func New(name string, cfg SDKConfig) *PluginSDK {
|
||||
if cfg.Secrets != nil {
|
||||
base.SetSecretsAPI(cfg.Secrets)
|
||||
}
|
||||
if cfg.Security != nil {
|
||||
base.SetSecurityAPI(cfg.Security)
|
||||
}
|
||||
if cfg.RawFiles != nil {
|
||||
base.SetRawFilesAPI(cfg.RawFiles)
|
||||
}
|
||||
|
||||
110
internal/sdk/security_impl.go
Normal file
110
internal/sdk/security_impl.go
Normal file
@ -0,0 +1,110 @@
|
||||
package sdk
|
||||
|
||||
// SecurityAPI 的内核实现:把插件声明的安全档位登记到进程级 security.Runtime。
|
||||
//
|
||||
// ## 分工(用户裁定 2026-10-26 / 2026-10-29)
|
||||
//
|
||||
// 插件:DeclareProfile / DeclareProfiles (只声明,不判定)
|
||||
// 内核:Runtime.CheckTool / ActivateProfile (只执行,不信任插件)
|
||||
//
|
||||
// 本文件是**声明面的转发层**,只做三件事:
|
||||
//
|
||||
// ① 用**内核侧真实插件名**填 Owner(插件无法伪造自己的身份)
|
||||
// ② 校验名字前缀是 `<插件名>.`(防插件 A 声明 `qq.owner` 抢插件 B 的语义)
|
||||
// ③ 转发到 security.Runtime.DeclareProfile
|
||||
//
|
||||
// ## 为什么 Owner 必须由内核填,而不是信任插件传的值
|
||||
//
|
||||
// 档位名是**全局唯一**的(`qq.owner` / `qq.guest`),而 Runtime 拒绝跨 owner
|
||||
// 覆盖。若允许插件自报 owner,那么任何拿到声明面的插件都能声明
|
||||
// `qq.owner` 并给它 `AllowTools: ["*"]` —— 直接把 QQ 的 owner 档位改成全放行。
|
||||
//
|
||||
// 所以 `SecurityProfile.Owner` 字段在**内核侧一律被覆盖**为真实插件名,
|
||||
// 插件传什么都无效(包括传空、传错)。这不是"校验后拒绝",而是
|
||||
// **根本不采信** —— 后者没有可绕过的边界。
|
||||
//
|
||||
// ## 未启用时的行为
|
||||
//
|
||||
// security 未装配(`RuntimeGlobal()` 为 nil)时返回明确错误,而不是静默成功。
|
||||
// 理由:插件会以为自己的档位生效了,而实际什么都没登记 ——
|
||||
// 等到注入时带 `SecurityProfile: "qq.guest"` 才会响亮报"未声明档位",
|
||||
// 那时排查方向已经跑偏到"档位名拼错"。早失败比晚失败好。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
internalSecurity "github.com/JianFeeeee/HologramAgent/internal/security"
|
||||
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
|
||||
)
|
||||
|
||||
// securityImpl 实现 pubsdk.SecurityAPI。
|
||||
type securityImpl struct {
|
||||
// pluginName 是内核注入的**真实**插件名,不来自插件自述。
|
||||
pluginName string
|
||||
}
|
||||
|
||||
// NewSecurityAPI 构造策略声明面(内核装配时注入给每个插件)。
|
||||
//
|
||||
// pluginName 必须是内核侧的插件标识(Registry 里的 name),
|
||||
// **不能**取自插件自己的 Name() 返回值。
|
||||
func NewSecurityAPI(pluginName string) pubsdk.SecurityAPI {
|
||||
return &securityImpl{pluginName: pluginName}
|
||||
}
|
||||
|
||||
func (s *securityImpl) DeclareProfile(p pubsdk.SecurityProfile) error {
|
||||
rt := internalSecurity.RuntimeGlobal()
|
||||
if rt == nil {
|
||||
return fmt.Errorf("security: declare profile %q failed - security module is not assembled "+
|
||||
"(the profile would be silently unregistered)", p.Name)
|
||||
}
|
||||
name := strings.TrimSpace(p.Name)
|
||||
if name == "" {
|
||||
return fmt.Errorf("security: declare profile failed - empty profile name")
|
||||
}
|
||||
|
||||
// ★ Owner 用内核侧真实插件名覆盖,不采信 p.Owner。
|
||||
owner := s.pluginName
|
||||
if p.Owner != "" && p.Owner != owner {
|
||||
// 不静默改:显式告知插件作者"你填的 owner 被忽略了",
|
||||
// 否则他照着错误认知继续写(如以为能给别的插件声明档位)。
|
||||
return fmt.Errorf("security: declare profile %q failed - Owner is %q but this plugin is %q "+
|
||||
"(a plugin may only declare profiles under its own name)", name, p.Owner, owner)
|
||||
}
|
||||
|
||||
// 名字必须是 `<本插件名>.` 开头。前缀约束在 Runtime 里也有一份,
|
||||
// 这里提前判是为了给出**能指向插件作者**的错误文案。
|
||||
if !strings.HasPrefix(name, owner+".") {
|
||||
return fmt.Errorf("security: declare profile %q failed - name must be prefixed with %q "+
|
||||
"(e.g. %q)", name, owner+".", owner+".guest")
|
||||
}
|
||||
|
||||
if err := rt.DeclareProfile(internalSecurity.Profile{
|
||||
Name: name,
|
||||
Owner: owner,
|
||||
Policy: internalSecurity.PrincipalPolicy{
|
||||
AllowTools: p.AllowTools,
|
||||
DenyTools: p.DenyTools,
|
||||
},
|
||||
SensitiveWords: p.SensitiveWords,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("security: declare profile %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *securityImpl) DeclareProfiles(ps []pubsdk.SecurityProfile) error {
|
||||
// 先全量校验再逐个登记:部分成功比全不成功更难排查
|
||||
//("为什么 qq.owner 生效了 qq.guest 没生效"这类问题很难定位)。
|
||||
// 但 Runtime 的 DeclareProfile 是幂等覆盖的,所以即使中途失败,
|
||||
// 已登记的那些也只是"多登记了",不会留下半个档位。
|
||||
for i, p := range ps {
|
||||
if err := s.DeclareProfile(p); err != nil {
|
||||
return fmt.Errorf("profile[%d]: %w", i, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 编译期确认实现了公开接口。
|
||||
var _ pubsdk.SecurityAPI = (*securityImpl)(nil)
|
||||
127
internal/sdk/security_impl_test.go
Normal file
127
internal/sdk/security_impl_test.go
Normal file
@ -0,0 +1,127 @@
|
||||
package sdk
|
||||
|
||||
// SecurityAPI 内核实现的判据。
|
||||
//
|
||||
// ## 钉什么
|
||||
//
|
||||
// 声明面只有三件事可能出错,且三种都**静默**:
|
||||
//
|
||||
// ① Owner 被插件伪造 ⇒ 能声明别人的档位(qq.owner 全放行)
|
||||
// ② 名字前缀不对 ⇒ 档位登记成功但在别的 owner 名下(永远命中不到)
|
||||
// ③ 未装配时静默成功 ⇒ 插件以为生效了,实际没登记
|
||||
//
|
||||
// 本文件的判据对每一条都要求**响亮失败**。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
internalSecurity "github.com/JianFeeeee/HologramAgent/internal/security"
|
||||
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
|
||||
)
|
||||
|
||||
// withRuntime 装一个干净的 Runtime 供测试,结束后恢复。
|
||||
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
|
||||
}
|
||||
|
||||
// ① 正常声明:登记到 Runtime,且 Owner 是内核给的名字。
|
||||
func TestSecurityDeclareProfileRegistersUnderRealOwner(t *testing.T) {
|
||||
rt := withRuntime(t)
|
||||
api := NewSecurityAPI("qq")
|
||||
|
||||
if err := api.DeclareProfile(pubsdk.SecurityProfile{
|
||||
Name: "qq.guest",
|
||||
DenyTools: []string{"cmd_*"},
|
||||
AllowTools: []string{"qq_*"},
|
||||
}); err != nil {
|
||||
t.Fatalf("声明应成功: %v", err)
|
||||
}
|
||||
|
||||
if !contains(rt.Profiles(), "qq.guest") {
|
||||
t.Fatalf("档位未登记,实际已有: %v", rt.Profiles())
|
||||
}
|
||||
}
|
||||
|
||||
// ② ★ Owner 不可伪造:插件声明别人的档位必须报错。
|
||||
//
|
||||
// 若能声明 `qq.owner` 且给 AllowTools=["*"],等于把 QQ 的 owner 档位
|
||||
// 改成全放行 —— 非所有者立刻能调用所有工具。
|
||||
func TestSecurityDeclareProfileRejectsForeignOwner(t *testing.T) {
|
||||
withRuntime(t)
|
||||
api := NewSecurityAPI("evil")
|
||||
|
||||
err := api.DeclareProfile(pubsdk.SecurityProfile{
|
||||
Name: "qq.owner",
|
||||
Owner: "qq", // 冒名
|
||||
AllowTools: []string{"*"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("★ 冒名声明必须被拒绝 —— 否则任何插件都能把 qq.owner 改成全放行")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "evil") {
|
||||
t.Errorf("错误信息应指出真实插件名,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 名字必须带本插件前缀(否则登记了也命中不到)。
|
||||
func TestSecurityDeclareProfileRequiresOwnerPrefix(t *testing.T) {
|
||||
withRuntime(t)
|
||||
api := NewSecurityAPI("qq")
|
||||
|
||||
for _, bad := range []string{"guest", "qqguest", "qqq.guest", ""} {
|
||||
if err := api.DeclareProfile(pubsdk.SecurityProfile{Name: bad}); err == nil {
|
||||
t.Errorf("名字 %q 应被拒绝(必须形如 qq.xxx)", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 未装配 security 时必须报错,不能静默成功。
|
||||
//
|
||||
// 静默成功的后果:插件以为档位生效,直到注入时带 SecurityProfile
|
||||
// 才报"未声明档位",此时排查方向已跑到"名字拼错"上。
|
||||
func TestSecurityDeclareProfileFailsLoudlyWhenUnassembled(t *testing.T) {
|
||||
prev := internalSecurity.RuntimeGlobal()
|
||||
internalSecurity.SetRuntimeGlobal(nil)
|
||||
t.Cleanup(func() { internalSecurity.SetRuntimeGlobal(prev) })
|
||||
|
||||
api := NewSecurityAPI("qq")
|
||||
err := api.DeclareProfile(pubsdk.SecurityProfile{Name: "qq.guest"})
|
||||
if err == nil {
|
||||
t.Fatal("★ 安全模块未装配时必须报错 —— 静默成功会让插件误以为策略已生效")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not assembled") {
|
||||
t.Errorf("错误信息应说明未装配,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ 批量声明:任一失败即整体报错,且错误带下标便于定位。
|
||||
func TestSecurityDeclareProfilesReportsIndex(t *testing.T) {
|
||||
withRuntime(t)
|
||||
api := NewSecurityAPI("qq")
|
||||
|
||||
err := api.DeclareProfiles([]pubsdk.SecurityProfile{
|
||||
{Name: "qq.owner"},
|
||||
{Name: "bogus.name"}, // 第 1 个(0-based)非法
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("批量声明中任一失败应整体报错")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "profile[1]") {
|
||||
t.Errorf("错误应带失败下标,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(list []string, want string) bool {
|
||||
for _, v := range list {
|
||||
if v == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@ -8,7 +8,7 @@ import (
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/knowledge"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
doc "github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
coldcontext "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/text"
|
||||
)
|
||||
|
||||
@ -63,7 +63,7 @@ func (v *VirtualInstance) initLocked() error {
|
||||
}
|
||||
v.Knowledge = NewKnowledge(ks)
|
||||
|
||||
ds := doc.NewStore(filepath.Join(v.dir, "documents"), memory.TokenizeWords)
|
||||
ds := coldcontext.NewStore(filepath.Join(v.dir, "documents"), memory.TokenizeWords)
|
||||
if err := ds.Start(); err != nil {
|
||||
return fmt.Errorf("virtual doc store: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user