Files
Cyrene_v2/internal/runtime/memory/sensitive_restricted_test.go
AskaEth 44fbd29836 feat(memory): sensitive 标记 + 提取器敏感判据,私事退出受限车道召回
M63(M60 审计 P0-1/P0-2)。作用域 ≠ 敏感度:`scope='user'` 是共享域,里
面却装着住址(花溪 64 条 active)/私称(叶酱 63)/作息(早睡 38),于是受限车
道(群聊/陌生人)把共享域整锅注入上下文 —— 2026-10-29 群 531082168 的住址泄漏
即此路径。本片给 memory_items 加 `sensitive` 列,把「这条归谁」(scope)与
「这条能不能外说」(sensitive)拆成两根正交的轴。

- 迁移 0028:加列(INTEGER NOT NULL DEFAULT 0)+ **同迁移幂等回填**(精确短语
  白名单 花溪/叶酱/早睡,`AND sensitive=0`,不改 updated_at),down 只撤结构;
  memory 包内 `ensureSensitiveSchema` 同形前滚(任意 *sql.DB 自举)。
- 受限判据:新增 `MemoryItem.SharedVisible()` = 共享域 且 !sensitive,成为
  restricted 侧唯一起点;`loop.FilterSharedVisible` 与复用它的 knowledge 工具
  自动跟随。主人车道不过这道判据,照常读到全部记忆(sensitive 不是权限系统)。
- 提取器:prompt 追加 sensitive 位(住址/私人称呼/家人/健康/作息与行程 → true,
  口味/兴趣/项目约定 → false,含正反例);产出侧与落库侧共用
  `resolveSensitive`(模型的类目判断 ∨ 已实证短语白名单),只加标不撤标。
- 回填入口 `BackfillSensitive(ctx, db, dryRun)`:幂等、可 dry-run、返回候选池
  前后规模与逐短语计数(生产快照实测 3116 → 2948,标 725 行)。
- 读路径并到 15 列(scanItem + 7 处 SELECT);store.go 拆分 store_insert.go
  守住 500 行上限。

验收:go build/vet/test 全绿(memory + loop 单包复跑绿);删实现三处变异分别打红
(sensitiveColumn 恒 0 / SharedVisible 忽略 Sensitive / Extractor 跳过白名单);
生产库快照上跑 0028 后 Go 侧回填 marked=0(迁移 SQL 与 privateFacts 等价)。
2026-10-30 09:26:15 +08:00

132 lines
5.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package memory
import (
"context"
"strings"
"testing"
)
// 本文件是审计 §9-4 明确要求补的**对抗用例**:群里有人直接问「她住哪」时,主人
// 私事**不许进受限车道的候选池**(审计原话:这类提问会把花溪类记忆直接顶到 topK,
// 风险高于随机触发)。
//
// 🔴 它同时钉住两个方向,缺一个就不算修好:
// - 受限车道(群聊 / 陌生人)的可见集里**没有**私事;
// - 主人车道(同一次检索,不带限制)**照常看得到** —— sensitive 是数据层标记,
// 不是权限系统(塔裁决 2026-10-30)。
//
// ⚠️ 这里的受限可见集用 `MemoryItem.SharedVisible()` 复现 —— 它正是
// `internal/runtime/loop/memory_filter.go` 的 FilterSharedVisible 该用的判据
// (loop 侧把 `sharedScope(r.Item.Scope)` 换成 `r.Item.SharedVisible()` 即完成接线,
// 那一行在 loop 包,不在本切片的 scope 内)。本文件因此保证的是**数据侧判据**;
// 「loop 真的接了线」由 loop 的用例与端到端回放兜底。
// restrictedVisible 复现受限车道过滤:只保留 SharedVisible 的命中。
// (loop 侧还有一条「剔除 L4 意图记录」,与本片无关,故不在此复现。)
func restrictedVisible(results []Result) []Result {
visible := make([]Result, 0, len(results))
for _, r := range results {
if !r.Item.SharedVisible() {
continue
}
visible = append(visible, r)
}
return visible
}
// contents 把命中拼成一段文本,供 Contains 断言。
func contents(results []Result) string {
var b strings.Builder
for _, r := range results {
b.WriteString(r.Item.Content)
b.WriteByte('\n')
}
return b.String()
}
// TestRestrictedRecall_群里问住址拿不到私事 是对抗主用例:query 刻意写成群里那句
// 「她住哪」的直白形态(上海 / 住在哪 / 花溪),让私事记忆在**排序上**占优 ——
// 这样才能证明挡住它的是敏感标记而不是相关度不够。
func TestRestrictedRecall_群里问住址拿不到私事(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
addItem(t, s, MemoryItem{
PersonaID: "persona.a", Scope: ScopeUser, Kind: KindWorldEvent,
Content: "用户居住在花溪(贵阳花溪区),不在上海工作",
})
addItem(t, s, MemoryItem{
PersonaID: "persona.a", Scope: ScopeUser, Kind: KindRelationship,
Content: "用户喜欢冰美式",
})
addItem(t, s, MemoryItem{
PersonaID: "persona.a", Scope: ScopeProject, Kind: KindWorldEvent,
ProjectID: "proj-1", Content: "Cyrene 用 Go 编写,编号规则 CY-日期-序号",
})
r := NewRetriever(s)
results, err := r.Retrieve(ctx, "persona.a", "她住在哪里?是上海还是花溪?", 10)
if err != nil {
t.Fatalf("Retrieve: %v", err)
}
// 前提:这条私事确实在候选池里、且排得进前 K —— 否则下面的断言是空转。
if !strings.Contains(contents(results), "花溪") {
t.Fatalf("主人车道读不到这条私事,用例前提不成立:%s", contents(results))
}
restricted := contents(restrictedVisible(results))
if strings.Contains(restricted, "花溪") {
t.Fatalf("受限车道拿到了住址:\n%s", restricted)
}
if !strings.Contains(restricted, "冰美式") {
t.Fatalf("受限车道把合法的用户习惯也挡掉了(冻结裁决 design/03 §5 被推翻):\n%s", restricted)
}
if !strings.Contains(restricted, "Cyrene 用 Go 编写") {
t.Fatalf("受限车道丢了项目域记忆:\n%s", restricted)
}
}
// TestRestrictedRecall_主人车道照常读到全部 钉住「sensitive 不是权限系统」:
// 同一个 Store、同一批条目,不经受限过滤的读路径(owner 车道走的就是这条)
// 必须一条不少地读到,含敏感项。判据一旦被写成权限,这条立刻红。
func TestRestrictedRecall_主人车道照常读到全部(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
stored := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Scope: ScopeUser, Kind: KindWorldEvent,
Content: "主人希望被称呼为「叶酱」",
})
if !stored.Sensitive {
t.Fatal("私称没有被标成敏感,用例前提不成立")
}
byID, err := s.Get(ctx, stored.ID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if !byID.Sensitive || !strings.Contains(byID.Content, "叶酱") {
t.Fatal("主人车道的按 id 读取被敏感标记挡住了 —— sensitive 变成了权限")
}
personaItems, err := s.ListByPersona(ctx, "persona.a")
if err != nil {
t.Fatalf("ListByPersona: %v", err)
}
if !containsSensitive(personaItems, stored.ID) {
t.Fatal("主人车道的列表读路径读不到这条(或被读成非敏感)")
}
// 检索路径同样不因敏感而剔除:owner 车道的 Retrieve 结果里必须有它。
r := NewRetriever(s)
results, err := r.Retrieve(ctx, "persona.a", "主人希望被怎么称呼?叶酱吗?", 10)
if err != nil {
t.Fatalf("Retrieve: %v", err)
}
if !strings.Contains(contents(results), "叶酱") {
t.Fatalf("主人车道的检索丢了敏感项:\n%s", contents(results))
}
}