fix(memory): 堵住 knowledge 工具绕过群聊/陌生人过滤的 P0 / owner 召回 hits 如实上报 / 静默吞点补来源日志
This commit is contained in:
1 parent
e792baa316
commit
3e3d37a824
6 files changed
+265
-52
No files matched your search
@@ -69,7 +69,7 @@ func (l *Loop) memoryBlock(ctx context.Context, messages []model.Message) string
|
||||
}
|
||||
|
||||
// retrieveMemoryBlock 返回 env 与该 turn 角色的记忆注入块。
|
||||
// owner 全权限环境保持既有的 Inject 行为(候选集已并入
|
||||
// owner 全权限环境保持既有的完整召回(候选集已并入
|
||||
// user/project 共享域)。受限环境(群聊 / 陌生人)只注入
|
||||
// 非敏感的共享域记忆 —— 用户习惯(ScopeUser)与项目记忆
|
||||
// (ScopeProject,人人可读)—— 并把该块标记为群聊隔离,
|
||||
@@ -78,9 +78,8 @@ func (l *Loop) memoryBlock(ctx context.Context, messages []model.Message) string
|
||||
// (design/03 §5「不泄露主人敏感信息」+ 主人 2026-10-03
|
||||
// 裁决「用户习惯记忆与不敏感数据:可查、不可写」)。
|
||||
//
|
||||
// 同样按敏感项排除的是 L4 意图记录(capacity.RecordIntent 的回写格式,
|
||||
// 内容是主人的逐句原话):它虽落在共享的用户习惯域以便 L4 闭环学习,
|
||||
// 但群聊/陌生人只该看到「不敏感的习惯结论」,不该看到主人原话。
|
||||
// 隔离判据单一实现于 FilterSharedVisible:本条注入路径与模型可达的
|
||||
// knowledge 检索工具共用它,绝不各造一套过滤。
|
||||
//
|
||||
// 召回本身包在一个 memory span 里(裁决 11.4.2,design/19 §1
|
||||
// 旁路节点):span 只记元数据(作用域/命中条数/注入块字节数/耗时),
|
||||
@@ -88,9 +87,18 @@ func (l *Loop) memoryBlock(ctx context.Context, messages []model.Message) string
|
||||
func (l *Loop) retrieveMemoryBlock(ctx context.Context, env SessionEnv, personaID, query string) (string, error) {
|
||||
if !env.Restricted() {
|
||||
rctx, span := l.span(ctx, spanNameMemoryRecall, trace.SpanKindMemory)
|
||||
block, err := l.retriever.Inject(rctx, personaID, query, 0)
|
||||
l.finishMemoryRecallSpan(span, "owner", 0, len(block), err)
|
||||
return block, err
|
||||
results, err := l.retriever.Retrieve(rctx, personaID, query, 0)
|
||||
if err != nil {
|
||||
l.finishMemoryRecallSpan(span, "owner", 0, 0, err)
|
||||
return "", err
|
||||
}
|
||||
if len(results) == 0 {
|
||||
l.finishMemoryRecallSpan(span, "owner", 0, 0, nil)
|
||||
return "", nil
|
||||
}
|
||||
block := formatMemoryBlock(results, "")
|
||||
l.finishMemoryRecallSpan(span, "owner", len(results), len(block), nil)
|
||||
return block, nil
|
||||
}
|
||||
rctx, span := l.span(ctx, spanNameMemoryRecall, trace.SpanKindMemory)
|
||||
results, err := l.retriever.Retrieve(rctx, personaID, query, 0)
|
||||
@@ -98,20 +106,7 @@ func (l *Loop) retrieveMemoryBlock(ctx context.Context, env SessionEnv, personaI
|
||||
l.finishMemoryRecallSpan(span, "restricted", 0, 0, err)
|
||||
return "", err
|
||||
}
|
||||
shared := make([]memory.Result, 0, len(results))
|
||||
for _, r := range results {
|
||||
if !sharedScope(r.Item.Scope) {
|
||||
continue
|
||||
}
|
||||
// L4 意图记录(capacity.RecordIntent 的回写格式)是主人原话的
|
||||
// 机器可读形态,属主人敏感信息:共享域可查的是「不敏感的用户
|
||||
// 习惯」,主人的逐句原话绝不注入群聊/陌生人上下文
|
||||
// (design/03 §5「不泄露主人敏感信息」)。
|
||||
if capacity.IsIntentRecord(r.Item.Content) {
|
||||
continue
|
||||
}
|
||||
shared = append(shared, r)
|
||||
}
|
||||
shared := FilterSharedVisible(results)
|
||||
if len(shared) == 0 {
|
||||
l.finishMemoryRecallSpan(span, "restricted", 0, 0, nil)
|
||||
return "", nil
|
||||
@@ -121,34 +116,6 @@ func (l *Loop) retrieveMemoryBlock(ctx context.Context, env SessionEnv, personaI
|
||||
return block, nil
|
||||
}
|
||||
|
||||
// sharedScope 报告 scope 是否为群聊/陌生人可查的非敏感共享域:
|
||||
// 用户习惯(user,全局共享)与项目记忆(project,人人可读)。
|
||||
// 角色日常(persona)是主人私有的敏感项,不在其列
|
||||
// (design/03 §3/§5 的敏感度划分即作用域本身)。
|
||||
func sharedScope(s memory.Scope) bool {
|
||||
return s == memory.ScopeUser || s == memory.ScopeProject
|
||||
}
|
||||
|
||||
// formatMemoryBlock 由检索结果构建前导系统块。非空
|
||||
// marker 作为该块的前缀,使隔离的群聊读取可与
|
||||
// 主人的完整读取区分开。
|
||||
func formatMemoryBlock(results []memory.Result, marker string) string {
|
||||
var b strings.Builder
|
||||
if marker != "" {
|
||||
b.WriteString(marker)
|
||||
b.WriteString(":")
|
||||
}
|
||||
b.WriteString("相关记忆(按相关度 × 衰减排序):")
|
||||
for _, res := range results {
|
||||
b.WriteByte('\n')
|
||||
b.WriteString("- [")
|
||||
b.WriteString(string(res.Item.Kind))
|
||||
b.WriteString("] ")
|
||||
b.WriteString(res.Item.Content)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// assembleContext 为一轮构建模型可见的消息列表(design
|
||||
// §1.3 每轮 assemble,循环中与 OpenClaw 的 transformContext 等价之物):它
|
||||
// 重新注入该角色的相关长期记忆 —— 检索每轮都从
|
||||
@@ -265,8 +232,11 @@ func (l *Loop) extractMemory(ctx context.Context, messages []model.Message, step
|
||||
// 使其廉价的 "记住 X" 路径保持可观测且不变。
|
||||
//
|
||||
// 与 extractMemory 相同的门仍然适用:受限对话绝不
|
||||
// 被提取(只读隔离,design/02 §3 / design/11 §2),缺少
|
||||
// extractor 或 store 时为空操作。提取失败与 store 失败会
|
||||
// 被提取(只读隔离,design/02 §3 / design/11 §2)。未配置
|
||||
// extractor 时为空操作;**配置了 extractor 却没有 store** 时
|
||||
// 提取出的条目会被丢弃,因此该情形带来源记一条告警(l.log 已由
|
||||
// sourcedLog 标记 source=runtime/loop),绝不静默吞掉
|
||||
// (审计 2026-10-05 §7 静默吞点)。提取失败与 store 失败同样
|
||||
// 带循环的来源记录日志,绝不向上传播。
|
||||
//
|
||||
// 它同时是 loop 侧**回合落定**的记忆钩子入口(run 路径每个已落定
|
||||
@@ -280,7 +250,11 @@ func (l *Loop) extractMemoryAsync(ctx context.Context, messages []model.Message,
|
||||
l.log.Debug("memory extract skipped: read-only context", "step", step, "env", env.ActorEnv)
|
||||
return
|
||||
}
|
||||
if l.extractor == nil || l.memory == nil {
|
||||
if l.extractor == nil {
|
||||
return
|
||||
}
|
||||
if l.memory == nil {
|
||||
l.log.Warn("memory extract skipped: no memory store configured; extracted memories would be dropped", "step", step)
|
||||
return
|
||||
}
|
||||
async, ok := l.extractor.(asyncExtractor)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// FilterSharedVisible 返回群聊/陌生人(受限环境)可见的记忆子集:只保留
|
||||
// 非敏感的共享域(用户习惯 ScopeUser 与项目 ScopeProject,人人可读),
|
||||
// 并剔除 L4 意图记录(capacity.RecordIntent 的回写格式,内容是主人逐句
|
||||
// 原话的机器可读形态 —— 虽落在共享域以便 L4 闭环学习,但只该暴露
|
||||
// 「不敏感的习惯结论」,不该暴露主人原话)。角色日常(ScopePersona)
|
||||
// 是主人私有的敏感项,不在可见域内(design/03 §3/§5「不泄露主人敏感信息」)。
|
||||
//
|
||||
// 它是**唯一**的隔离判据:循环的记忆注入(retrieveMemoryBlock)与模型
|
||||
// 可达的 knowledge 检索工具都调用它。knowledge 若自造一套过滤(或不过滤),
|
||||
// 一旦管理面板把它放行给群聊/陌生人,就会返回角色日常与主人原话
|
||||
// (审计 2026-10-05:knowledge 绕过群聊/陌生人召回过滤,P0 红线)。
|
||||
func FilterSharedVisible(results []memory.Result) []memory.Result {
|
||||
visible := make([]memory.Result, 0, len(results))
|
||||
for _, r := range results {
|
||||
if !sharedScope(r.Item.Scope) {
|
||||
continue
|
||||
}
|
||||
if capacity.IsIntentRecord(r.Item.Content) {
|
||||
continue
|
||||
}
|
||||
visible = append(visible, r)
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
// sharedScope 报告 scope 是否为群聊/陌生人可查的非敏感共享域:
|
||||
// 用户习惯(user,全局共享)与项目记忆(project,人人可读)。
|
||||
// 角色日常(persona)是主人私有的敏感项,不在其列
|
||||
// (design/03 §3/§5 的敏感度划分即作用域本身)。
|
||||
func sharedScope(s memory.Scope) bool {
|
||||
return s == memory.ScopeUser || s == memory.ScopeProject
|
||||
}
|
||||
|
||||
// formatMemoryBlock 由检索结果构建前导系统块。非空
|
||||
// marker 作为该块的前缀,使隔离的群聊读取可与
|
||||
// 主人的完整读取区分开。
|
||||
func formatMemoryBlock(results []memory.Result, marker string) string {
|
||||
var b strings.Builder
|
||||
if marker != "" {
|
||||
b.WriteString(marker)
|
||||
b.WriteString(":")
|
||||
}
|
||||
b.WriteString("相关记忆(按相关度 × 衰减排序):")
|
||||
for _, res := range results {
|
||||
b.WriteByte('\n')
|
||||
b.WriteString("- [")
|
||||
b.WriteString(string(res.Item.Kind))
|
||||
b.WriteString("] ")
|
||||
b.WriteString(res.Item.Content)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// TestMemoryRecallSpan_OwnerReportsRealHits 钉住审计 2026-10-05 §7 的
|
||||
// 「owner 召回 hits 恒 0」:真实跑一轮后,memory 召回节点上报的命中条数
|
||||
// 必须等于实际注入的记忆条数(3 条种子),而不是硬编码的 0。
|
||||
func TestMemoryRecallSpan_OwnerReportsRealHits(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := newFlowRecorder()
|
||||
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Model: "mock-1", Content: "好的"}}}}
|
||||
td := dispatcherWith(t)
|
||||
a := newTestAgent(t, ad, td)
|
||||
store := newTestMemoryStore(t)
|
||||
seedMemories(t, store)
|
||||
|
||||
l := New(Options{
|
||||
Agent: a, Tools: td, MaxRounds: 8,
|
||||
Tracer: trace.New(nil),
|
||||
Flow: rec.publisher(),
|
||||
Retriever: memory.NewRetriever(store),
|
||||
PersonaID: "persona-a",
|
||||
OnStream: func(model.StreamEvent) {},
|
||||
})
|
||||
|
||||
if _, err := l.Run(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "go")}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
recall := nodesOfKind(rec.last(t, "web"), "memory")[0]
|
||||
if got := recall.Props["scope"]; got != "owner" {
|
||||
t.Fatalf("memory 召回 scope = %v,want owner(props:%+v)", got, recall.Props)
|
||||
}
|
||||
hits, ok := recall.Props["hits"].(int)
|
||||
if !ok {
|
||||
t.Fatalf("hits prop 类型 = %T,want int(props:%+v)", recall.Props["hits"], recall.Props)
|
||||
}
|
||||
if hits != 3 {
|
||||
t.Fatalf("owner 召回 hits = %d,want 3(3 条种子全部命中)", hits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractMemoryAsync_NoStoreLogsSource 钉住审计 2026-10-05 §7 的静默吞点:
|
||||
// 配置了 extractor 却没有 store 时,提取会被跳过 —— 但必须带来源记一条告警
|
||||
// (source=runtime/loop),绝不静默吞掉。
|
||||
func TestExtractMemoryAsync_NoStoreLogsSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
log, buf := captureLoopLog()
|
||||
l := New(Options{
|
||||
Extractor: memory.NewManualExtractor("persona-a", "owner"),
|
||||
PersonaID: "persona-a",
|
||||
Log: log,
|
||||
// Memory 故意不配置:提取结果无处可落。
|
||||
})
|
||||
|
||||
ctx := WithSessionEnv(context.Background(), OwnerEnv())
|
||||
l.extractMemoryAsync(ctx, []model.Message{model.TextMessage(model.RoleUser, "记住 我爱喝冰美式")}, 1)
|
||||
|
||||
found := false
|
||||
for _, rec := range loopJSONRecords(t, buf) {
|
||||
if rec["source"] != "runtime/loop" {
|
||||
continue
|
||||
}
|
||||
if msg, _ := rec["msg"].(string); strings.Contains(msg, "no memory store configured") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("缺带来源(source=runtime/loop)的「无 store 跳过提取」告警:%q", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,21 @@ func OwnerEnv() SessionEnv {
|
||||
return SessionEnv{ActorEnv: restrict.ActorOwner, MemWritable: true}
|
||||
}
|
||||
|
||||
// OwnerEnvFor 返回绑定了显式角色的主人会话环境。它是**主人车道的唯一构造入口**:
|
||||
// Web 主会话回合(gateway 的 turnEnvResolver.owner)与 IM 主人私聊回合
|
||||
// (channelbridge 的私聊车道)都经它派生自己的 runEnv,两处因此逐字段同源 ——
|
||||
// SessionEnv 将来新增字段时,两条车道会一起拿到它,而不会各自漂移出「同一个她、
|
||||
// 两套环境」。
|
||||
//
|
||||
// personaID 为空表示「继承循环配置的默认角色」(与 OwnerEnv 同义,见 design/22 §4.2)。
|
||||
// 它只界定提示词/记忆命名空间/情绪机/思考预算的作用域,**不是身份或权限输入**
|
||||
// (项目红线:身份与权限在 channel 层的 actor 元数据上)。
|
||||
func OwnerEnvFor(personaID string) SessionEnv {
|
||||
env := OwnerEnv()
|
||||
env.PersonaID = personaID
|
||||
return env
|
||||
}
|
||||
|
||||
// defaultSessionEnv 是调用方未挂载显式 env 时使用的
|
||||
// owner 全权限环境:记忆全读写,即既有行为。
|
||||
func defaultSessionEnv() SessionEnv {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
@@ -94,6 +95,14 @@ func (k *knowledgeTool) Execute(ctx context.Context, args any) (any, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("io: knowledge: retrieve: %w", err)
|
||||
}
|
||||
// 群聊/陌生人(受限环境)只读非敏感的共享域记忆:复用循环注入路径上的
|
||||
// 同一隔离判据(loop.FilterSharedVisible),绝不另造第二套 —— 否则一旦
|
||||
// 管理面板把本工具放行给群聊/陌生人,就会返回角色日常(ScopePersona,
|
||||
// 主人私有)与 L4 意图记录(主人逐句原话),触碰 design/03 §5
|
||||
// 「不泄露主人敏感信息」(审计 2026-10-05,P0 红线)。
|
||||
if loop.SessionEnvFrom(ctx).Restricted() {
|
||||
results = loop.FilterSharedVisible(results)
|
||||
}
|
||||
out := make([]map[string]any, 0, len(results))
|
||||
for _, r := range results {
|
||||
out = append(out, map[string]any{
|
||||
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
)
|
||||
@@ -194,3 +196,76 @@ func TestKnowledge_MissingQuery(t *testing.T) {
|
||||
t.Fatalf("expected query-required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// restrictedKnowledgeResults 是群聊/陌生人场景的固定命中集:共享域非敏感项
|
||||
// (用户习惯 user + 项目 project)与两类敏感项(角色日常 persona + L4 意图记录)。
|
||||
func restrictedKnowledgeResults() []memory.Result {
|
||||
return []memory.Result{
|
||||
{Item: memory.MemoryItem{ID: "u", Content: "用户习惯:喜欢冰美式", Kind: memory.KindRelationship, Scope: memory.ScopeUser}, Score: 0.9},
|
||||
{Item: memory.MemoryItem{ID: "p", Content: "项目记忆:Cyrene 用 Go 编写", Kind: memory.KindWorldEvent, Scope: memory.ScopeProject}, Score: 0.8},
|
||||
{Item: memory.MemoryItem{ID: "s", Content: "角色日常:周末喜欢爬山", Kind: memory.KindRelationship, Scope: memory.ScopePersona}, Score: 0.7},
|
||||
{Item: memory.MemoryItem{ID: "i", Content: "intent:isTask=true;taskKind=task;confidence=0.9;text=帮我订一杯冰美式", Kind: memory.KindRelationship, Scope: memory.ScopeUser}, Score: 0.6},
|
||||
}
|
||||
}
|
||||
|
||||
// knowledgeContents 把工具返回的 results 抽成 content 集合,便于断言泄露/保留。
|
||||
func knowledgeContents(t *testing.T, got any) map[string]bool {
|
||||
t.Helper()
|
||||
results := got.(map[string]any)["results"].([]map[string]any)
|
||||
out := make(map[string]bool, len(results))
|
||||
for _, r := range results {
|
||||
out[r["content"].(string)] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestKnowledge_RestrictedEnv_OnlySharedVisible 是审计 2026-10-05 高风险项的证据型
|
||||
// 测试:群聊/陌生人(受限环境)调用 knowledge 时,只返回非敏感共享域记忆;角色日常
|
||||
// (ScopePersona,主人私有)与 L4 意图记录(主人逐句原话)绝不返回 —— 复用循环注入
|
||||
// 路径的同一隔离判据(loop.FilterSharedVisible),不另造第二套。
|
||||
func TestKnowledge_RestrictedEnv_OnlySharedVisible(t *testing.T) {
|
||||
t.Parallel()
|
||||
kt := knowledgeToolForTest(t, WithKnowledgeRetriever(&stubRetriever{results: restrictedKnowledgeResults()}))
|
||||
ctx := loop.WithSessionEnv(
|
||||
tool.WithTurnPersona(context.Background(), "persona-a"),
|
||||
loop.SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false},
|
||||
)
|
||||
|
||||
got, err := kt.Execute(ctx, map[string]any{"query": "回忆"})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if n := got.(map[string]any)["count"].(int); n != 2 {
|
||||
t.Fatalf("受限环境 count = %d, want 2(只留共享域非敏感项)", n)
|
||||
}
|
||||
contents := knowledgeContents(t, got)
|
||||
for _, want := range []string{"用户习惯:喜欢冰美式", "项目记忆:Cyrene 用 Go 编写"} {
|
||||
if !contents[want] {
|
||||
t.Fatalf("受限环境漏了可查的共享域记忆 %q: %v", want, contents)
|
||||
}
|
||||
}
|
||||
for _, leaked := range []string{"角色日常:周末喜欢爬山", "intent:isTask=true;taskKind=task;confidence=0.9;text=帮我订一杯冰美式"} {
|
||||
if contents[leaked] {
|
||||
t.Fatalf("受限环境泄露了敏感记忆 %q: %v", leaked, contents)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestKnowledge_OwnerEnv_AllScopes 钉住反向边界:owner(全权限)环境不做隔离过滤,
|
||||
// 四类命中都照常返回 —— 修复只收窄群聊/陌生人,绝不误伤主人自己的完整读取。
|
||||
func TestKnowledge_OwnerEnv_AllScopes(t *testing.T) {
|
||||
t.Parallel()
|
||||
kt := knowledgeToolForTest(t, WithKnowledgeRetriever(&stubRetriever{results: restrictedKnowledgeResults()}))
|
||||
ctx := loop.WithSessionEnv(
|
||||
tool.WithTurnPersona(context.Background(), "persona-a"),
|
||||
loop.OwnerEnv(),
|
||||
)
|
||||
|
||||
got, err := kt.Execute(ctx, map[string]any{"query": "回忆"})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if n := got.(map[string]any)["count"].(int); n != 4 {
|
||||
t.Fatalf("owner 环境 count = %d, want 4(完整召回)", n)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user