fix(memory): 堵住 knowledge 工具绕过群聊/陌生人过滤的 P0 / owner 召回 hits 如实上报 / 静默吞点补来源日志
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
AskaEth committed 2026-10-05 11:30:42 +08:00
1 parent e792baa316
commit 3e3d37a824
6 files changed
+265 -52

No files matched your search

+26 -52
View File
@@ -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)
+61
View File
@@ -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())
}
}
+15
View File
@@ -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 {
+9
View File
@@ -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{
+75
View File
@@ -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)
}
}