Files
Cyrene_v2/internal/runtime/memory/sensitive_migration_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

189 lines
7.8 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"
"slices"
"testing"
)
// 本文件钉住**迁移 0028(`memory_sensitive`)与 Store 自举 DDL 的同形、可回滚、
// 以及「迁移里的白名单回填」与 Go 侧回填判据等价**。手法照 retention_migration_test.go
// 对 0026、temporal_migration_test.go 对 0027 的那一套(同一批 helpers)。
//
// 真实启动顺序是「先 MigrateUp、后 NewStore」(cmd/cyrene/wire.go),因此新列有两份
// 真源:迁移(权威)与 NewStore 的幂等前滚(sensitive_schema.go)。少一列就是
// 「新库有、老库无」的漂移,因此三条判据都必须有:
//
// 1. TestMigration0028AddsColumnAndBackfills —— 迁移落在**已有数据**上:行不丢、
// 列补上、白名单命中的历史行被标成 sensitive=1、非白名单行保持 0;
// 2. TestMigration0028DownDropsColumnOnly —— down 只撤结构(行数与其它数据不变);
// 3. TestMigration0028ShapeMatchesStoreBootstrap —— 两份 DDL 的列集合与列形状同形,
// 且自举侧尾序是 `… temporal, sensitive`。
//
// 第 1 条同时是**白名单等价性**的证据:迁移里那份短语清单与 Go 的 privateFacts
// 若不一致,`BackfillSensitive` 在迁移之后重跑会标出额外的行(Marked > 0)——
// 那条断言把「两份实现悄悄分叉」变成一次红灯。
// sensitiveMigrationID 是 0028 在 schema_migrations 里的记账 id(= 文件名去掉 .up.sql)。
const sensitiveMigrationID = "0028_memory_sensitive"
// TestMigration0028AddsColumnAndBackfills 造出「0028 之前的既有部署」再升级:
// 历史行一行不丢、白名单命中的被标、没命中的不被标。
func TestMigration0028AddsColumnAndBackfills(t *testing.T) {
t.Parallel()
ctx := context.Background()
d := openRawMemoryDB(t)
migrateUp(t, d)
if !hasMemoryColumn(t, d, sensitiveColumn) {
t.Fatalf("迁移后 memory_items 应带 %s 列", sensitiveColumn)
}
// 回到 0028 之前,按旧形态写四行(花溪 / 叶酱 / 早睡 / 习惯)。
migrateDownToBefore(t, d, sensitiveMigrationID)
if hasMemoryColumn(t, d, sensitiveColumn) {
t.Fatalf("0028 回滚后 %s 应已消失", sensitiveColumn)
}
seedLegacySensitiveRow(t, d, "legacy-1", string(ScopeUser), "用户居住在花溪(贵阳花溪区)")
seedLegacySensitiveRow(t, d, "legacy-2", string(ScopePersona), "主人希望被称作「叶酱」")
seedLegacySensitiveRow(t, d, "legacy-3", string(ScopeProject), "主人常熬夜,助手提醒早睡")
seedLegacySensitiveRow(t, d, "legacy-4", string(ScopeUser), "用户喜欢冰美式")
// 升级:0028 落在**已有数据**上(建列 + 回填)。
migrateUp(t, d)
if !hasMemoryColumn(t, d, sensitiveColumn) {
t.Fatalf("迁移后 memory_items 应带 %s 列", sensitiveColumn)
}
var rows int
if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM memory_items`).Scan(&rows); err != nil {
t.Fatalf("count rows: %v", err)
}
if rows != 4 {
t.Fatalf("迁移后行数 = %d, want 4(既有行一行都不能丢)", rows)
}
for _, id := range []string{"legacy-1", "legacy-2", "legacy-3"} {
if got := readSensitiveCell(t, d, id); got != 1 {
t.Fatalf("%s 回填后 sensitive = %d, want 1(白名单命中必须被标)", id, got)
}
}
if got := readSensitiveCell(t, d, "legacy-4"); got != 0 {
t.Fatalf("legacy-4(习惯类)被标成敏感 = %d —— 迁移把「用户习惯可查」推翻了", got)
}
// 白名单等价性:迁移已经标完,Go 侧回填不该再标出一行。
report, err := BackfillSensitive(ctx, d, false)
if err != nil {
t.Fatalf("BackfillSensitive(迁移之后): %v", err)
}
if report.Marked != 0 {
t.Fatalf("迁移之后 Go 侧回填又标了 %d 行 —— 迁移里的短语清单与 privateFacts 已经分叉",
report.Marked)
}
if report.Matched != 3 {
t.Fatalf("Matched = %d, want 3(两条实现的判据必须一致)", report.Matched)
}
}
// TestMigration0028DownDropsColumnOnly 钉住 down 的唯一语义:只撤结构。
func TestMigration0028DownDropsColumnOnly(t *testing.T) {
t.Parallel()
ctx := context.Background()
d := openRawMemoryDB(t)
migrateUp(t, d)
seedLegacySensitiveRow(t, d, "keep-1", string(ScopeUser), "用户居住在花溪")
if _, err := d.ExecContext(ctx,
`UPDATE memory_items SET sensitive = 1 WHERE id = 'keep-1'`); err != nil {
t.Fatalf("mark row: %v", err)
}
migrateDownToBefore(t, d, sensitiveMigrationID)
if hasMemoryColumn(t, d, sensitiveColumn) {
t.Fatalf("回滚后 %s 应已消失", sensitiveColumn)
}
var rows int
if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM memory_items`).Scan(&rows); err != nil {
t.Fatalf("count rows after down: %v", err)
}
if rows != 1 {
t.Fatalf("回滚后行数 = %d, want 1(down 只撤结构,不删行)", rows)
}
var content, scope string
if err := d.QueryRowContext(ctx,
`SELECT content, scope FROM memory_items WHERE id = 'keep-1'`).Scan(&content, &scope); err != nil {
t.Fatalf("read row after down: %v", err)
}
if content != "用户居住在花溪" || scope != string(ScopeUser) {
t.Fatalf("down 改写了行数据 = (%q, %q) —— 回滚只撤结构", content, scope)
}
// 再 up:列以默认 0 重建,回填重新按白名单标上。
migrateUp(t, d)
if !hasMemoryColumn(t, d, sensitiveColumn) {
t.Fatalf("再次迁移后应重建 %s 列", sensitiveColumn)
}
if got := readSensitiveCell(t, d, "keep-1"); got != 1 {
t.Fatalf("再 up 之后 sensitive = %d, want 1(回填随迁移重跑)", got)
}
}
// TestMigration0028ShapeMatchesStoreBootstrap 是**漂移护栏**:迁移路径与自举路径
// 建出的 memory_items 列集合必须一致,且 `sensitive` 在两边的形状逐字相同
// (INTEGER NOT NULL DEFAULT 0)。
func TestMigration0028ShapeMatchesStoreBootstrap(t *testing.T) {
t.Parallel()
ctx := context.Background()
migrated := openRawMemoryDB(t)
migrateUp(t, migrated)
if _, err := NewStore(ctx, migrated, testLogger()); err != nil {
t.Fatalf("NewStore(migrated): %v", err)
}
bootstrapped := openRawMemoryDB(t)
if _, err := NewStore(ctx, bootstrapped, testLogger()); err != nil {
t.Fatalf("NewStore: %v", err)
}
gotCols := sortedColumns(tableColumns(t, migrated, "memory_items"))
wantCols := sortedColumns(tableColumns(t, bootstrapped, "memory_items"))
if !slices.Equal(gotCols, wantCols) {
t.Fatalf("迁移与自举的 memory_items 列集合不一致(两份 DDL 必须同形):\n迁移=%v\n自举=%v",
gotCols, wantCols)
}
for name, cols := range map[string][]string{"迁移": gotCols, "自举": wantCols} {
if !slices.Contains(cols, sensitiveColumn+"|INTEGER|1|0|0") {
t.Fatalf("%s 侧的 %s 列形状不是 INTEGER NOT NULL DEFAULT 0:%v", name, sensitiveColumn, cols)
}
}
}
// TestMigration0028ThenStoreBootstrapIsARegularPath 覆盖真实启动顺序:迁移先建列
// (含回填),随后 Store 自举走到 "duplicate column name" —— 预期路径必须无害,
// 且写入路径可用(敏感位随同一笔 INSERT 落下)。
func TestMigration0028ThenStoreBootstrapIsARegularPath(t *testing.T) {
t.Parallel()
ctx := context.Background()
d := openRawMemoryDB(t)
migrateUp(t, d)
store, err := NewStore(ctx, d, testLogger())
if err != nil {
t.Fatalf("NewStore(列已由迁移建好): %v", err)
}
after := tableColumns(t, d, "memory_items")
if _, err := NewStore(ctx, d, testLogger()); err != nil {
t.Fatalf("NewStore(第二次): %v", err)
}
if got := tableColumns(t, d, "memory_items"); !slices.Equal(got, after) {
t.Fatalf("第二次自举改变了表结构:\n got=%v\nwant=%v", got, after)
}
item := habitItem("主人现居贵阳花溪区", tidyTestTime(t), 0.9)
got, err := store.Add(ctx, item)
if err != nil {
t.Fatalf("Add: %v", err)
}
if cell := readSensitiveCell(t, store.db, got.ID); cell != 1 {
t.Fatalf("迁移建列后经 Store 落库的 sensitive = %d, want 1(写入侧兜底没跑)", cell)
}
}