diff --git a/internal/runtime/loop/memory_filter_sensitive_test.go b/internal/runtime/loop/memory_filter_sensitive_test.go new file mode 100644 index 0000000..933ead9 --- /dev/null +++ b/internal/runtime/loop/memory_filter_sensitive_test.go @@ -0,0 +1,134 @@ +package loop + +import ( + "context" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// 本文件钉住 **P0-1 的唯一集成点**:`FilterSharedVisible` 真的看了 `sensitive` +// (切片 M63、审计 2026-10-30 §7 P0-1;r1 复审 P1-1)。 +// +// 为什么必须单独有这一条:memors 包的用例钉的是 `MemoryItem.SharedVisible()` 这个 +// **判据**,它管不到 loop 有没有调用它。r1 复审的变异实验证明了缺口的具体形态 —— +// 把 `memory_filter.go` 里那行改回 `sharedScope(r.Item.Scope)`(等价于本片被 +// revert),`go test ./internal/runtime/loop/` **全绿**,而群聊/陌生人的候选池会 +// 重新放进主人的私事。因此这里的用例必须走**真实函数/真实注入路径**, +// 不许在本地复现过滤(memory 包那份 restrictedVisible 只作数据层判据用)。 +// +// 两条用例分别守两层: +// 1. TestFilterSharedVisible_敏感共享行被排除 —— 直接调真实 FilterSharedVisible; +// 2. TestInjectMemory_RestrictedEnv_排除敏感私事 —— 走 assembleContext 的完整注入 +// 路径(真实 Store + 真实 Retriever + 真实过滤),并把「群里问『她住哪』」写成 +// query,同时断言 owner 车道照常读得到(本片红线:sensitive 不是权限系统)。 + +// resultIDs 返回命中集的 id 列表(顺序保留),供集合断言用。 +func resultIDs(results []memory.Result) []string { + ids := make([]string, 0, len(results)) + for _, r := range results { + ids = append(ids, r.Item.ID) + } + return ids +} + +// TestFilterSharedVisible_敏感共享行被排除 直接调真实过滤函数,钉住三条判据: +// 作用域(persona 不在共享域)、敏感度(sensitive=1 一律排除)、意图记录(L4 原话)。 +func TestFilterSharedVisible_敏感共享行被排除(t *testing.T) { + t.Parallel() + results := []memory.Result{ + {Item: memory.MemoryItem{ID: "private-user", Scope: memory.ScopeUser, Sensitive: true, + Content: "用户居住在花溪(贵阳花溪区),不在上海工作"}}, + {Item: memory.MemoryItem{ID: "habit-user", Scope: memory.ScopeUser, + Content: "用户喜欢冰美式"}}, + {Item: memory.MemoryItem{ID: "plain-project", Scope: memory.ScopeProject, + Content: "项目记忆:Cyrene 用 Go 编写"}}, + {Item: memory.MemoryItem{ID: "private-project", Scope: memory.ScopeProject, Sensitive: true, + Content: "称呼约定:主人只叫「叶酱」"}}, + {Item: memory.MemoryItem{ID: "persona-daily", Scope: memory.ScopePersona, + Content: "角色日常:周末喜欢爬山"}}, + {Item: memory.MemoryItem{ID: "intent-record", Scope: memory.ScopeUser, + Content: "intent:isTask=true;taskKind=query;confidence=0.9;text=帮我看看这个"}}, + } + + visible := FilterSharedVisible(results) + got := resultIDs(visible) + want := []string{"habit-user", "plain-project"} + if len(got) != len(want) { + t.Fatalf("FilterSharedVisible 保留 %v, want %v(敏感/角色日常/意图记录都必须被排除)", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("FilterSharedVisible 保留 %v, want %v", got, want) + } + } +} + +// seedSensitiveMemories 写入一条主人私事(会经 memory 包的白名单兜底标成 +// sensitive=1)、一条用户习惯、一条项目记忆。 +func seedSensitiveMemories(t *testing.T, store *memory.Store) { + t.Helper() + ctx := context.Background() + seeds := []memory.MemoryItem{ + {PersonaID: "persona-a", Content: "用户居住在花溪(贵阳花溪区),不在上海工作", + Scope: memory.ScopeUser, Kind: memory.KindWorldEvent}, + {PersonaID: "persona-a", Content: "用户习惯:喜欢冰美式", + Scope: memory.ScopeUser, Kind: memory.KindRelationship}, + {PersonaID: "persona-a", Content: "项目记忆:Cyrene 用 Go 编写", + Scope: memory.ScopeProject, Kind: memory.KindWorldEvent}, + } + for _, it := range seeds { + stored, err := store.Add(ctx, it) + if err != nil { + t.Fatalf("seed memory: %v", err) + } + if it.Content == seeds[0].Content && !stored.Sensitive { + t.Fatalf("私事种子没有被标成 sensitive —— 用例前提不成立(白名单兜底没生效)") + } + } +} + +// TestInjectMemory_RestrictedEnv_排除敏感私事 是集成级对抗用例:query 直译群里那句 +// 「她住哪」,走完整的 assembleContext 注入路径。 +// +// 断言顺序是刻意的:先证明 owner 车道**读得到**花溪(否则「受限车道读不到」可能只是 +// 相关度不够,用例空转),再断言受限车道读不到。 +// +// 这条用例正是 r1 复审变异 A 的杀手:把那行改回 sharedScope(r.Item.Scope) 之后, +// 受限块会重新出现「花溪」,本用例变红。 +func TestInjectMemory_RestrictedEnv_排除敏感私事(t *testing.T) { + t.Parallel() + store := newTestMemoryStore(t) + seedSensitiveMemories(t, store) + l := New(Options{Retriever: memory.NewRetriever(store), PersonaID: "persona-a"}) + + msgs := []model.Message{model.TextMessage(model.RoleUser, "她住在哪里?是上海还是花溪?")} + + ownerView := l.assembleContext(WithSessionEnv(context.Background(), OwnerEnv()), msgs, 1) + if len(ownerView) < 1 { + t.Fatal("owner 车道没有注入记忆块,用例前提不成立") + } + ownerBlock := messageText(ownerView[0]) + if !strings.Contains(ownerBlock, "花溪") { + t.Fatalf("owner 车道读不到这条私事(前提不成立,后续断言会空转):\n%s", ownerBlock) + } + + restrictedCtx := WithSessionEnv(context.Background(), + SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}) + restrictedView := l.assembleContext(restrictedCtx, msgs, 1) + if len(restrictedView) < 1 { + t.Fatal("受限车道应当仍注入非敏感记忆块") + } + restrictedBlock := messageText(restrictedView[0]) + if strings.Contains(restrictedBlock, "花溪") { + t.Fatalf("受限车道拿到了主人的住址 —— FilterSharedVisible 没有看 sensitive:\n%s", restrictedBlock) + } + if !strings.Contains(restrictedBlock, "群聊隔离") || + !strings.Contains(restrictedBlock, "喜欢冰美式") || + !strings.Contains(restrictedBlock, "Cyrene 用 Go 编写") { + t.Fatalf("受限车道丢了非敏感记忆或隔离标记(用户习惯可查的裁决被推翻):\n%s", restrictedBlock) + } +}