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 等价)。
This commit is contained in:
AskaEth committed 2026-10-30 09:26:15 +08:00
1 parent a8bc2b4470
commit 44fbd29836
22 files changed
+1360 -102

No files matched your search

@@ -0,0 +1,131 @@
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))
}
}