diff --git a/internal/runtime/loop/memory.go b/internal/runtime/loop/memory.go index 12f1055..2a30924 100644 --- a/internal/runtime/loop/memory.go +++ b/internal/runtime/loop/memory.go @@ -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) diff --git a/internal/runtime/loop/memory_filter.go b/internal/runtime/loop/memory_filter.go new file mode 100644 index 0000000..be40f4a --- /dev/null +++ b/internal/runtime/loop/memory_filter.go @@ -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() +} diff --git a/internal/runtime/loop/memory_isolation_test.go b/internal/runtime/loop/memory_isolation_test.go new file mode 100644 index 0000000..e985f0b --- /dev/null +++ b/internal/runtime/loop/memory_isolation_test.go @@ -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()) + } +} diff --git a/internal/runtime/loop/sessionenv.go b/internal/runtime/loop/sessionenv.go index 7d1cc48..cb9f300 100644 --- a/internal/runtime/loop/sessionenv.go +++ b/internal/runtime/loop/sessionenv.go @@ -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 { diff --git a/plugins/tools/io/knowledge.go b/plugins/tools/io/knowledge.go index 620804e..16016da 100644 --- a/plugins/tools/io/knowledge.go +++ b/plugins/tools/io/knowledge.go @@ -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{ diff --git a/plugins/tools/io/knowledge_test.go b/plugins/tools/io/knowledge_test.go index 5793112..ae630b7 100644 --- a/plugins/tools/io/knowledge_test.go +++ b/plugins/tools/io/knowledge_test.go @@ -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) + } +}