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 等价)。
132 lines
5.1 KiB
Go
132 lines
5.1 KiB
Go
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))
|
||
}
|
||
}
|