diff --git a/internal/db/migrations/0028_memory_sensitive.down.sql b/internal/db/migrations/0028_memory_sensitive.down.sql new file mode 100644 index 0000000..8423dee --- /dev/null +++ b/internal/db/migrations/0028_memory_sensitive.down.sql @@ -0,0 +1,17 @@ +-- 0028 的回滚:只撤结构(删列),**不改写任何一行的其它数据**。 +-- +-- 回滚的代价(如实登记):`sensitive` 的标记值全部丢失 —— 所有行回到「非敏感」, +-- 于是受限车道(群聊 / 陌生人)会重新把主人的私事捞进候选池,即 2026-10-29 的 +-- 泄漏面重新打开。这是**预期**的回滚代价,不是 bug:本列的值无法从其它列派生 +-- (判据是内容白名单 + 提取器判断,见 up 文件),因此回滚必须显式承认它丢信息。 +-- +-- 回滚**不许**顺手把那些私事行改成别的形态(删行、改 scope、改 status): +-- - 删行 / 改 scope 是一次 schema 回滚**改数据**,且不可逆(内容就没了); +-- - 改 status 会把在册记忆藏起来,读侧闸看起来「干净」,但数据其实还在 —— +-- 那正是本片要消灭的「靠不可见假装安全」。 +-- 要恢复保护就重跑 up(列以默认 0 重建)或执行 memory 包的 BackfillSensitive +-- (幂等、可 dry-run,见 internal/runtime/memory/sensitive_backfill.go)。 +-- +-- SQLite 的 DROP COLUMN 要求该列不被索引/视图/触发器引用:本列没有索引(见 up), +-- 因此无需先删索引(0026 的 down 因为带索引才以 DROP INDEX 开头)。 +ALTER TABLE memory_items DROP COLUMN sensitive; diff --git a/internal/db/migrations/0028_memory_sensitive.up.sql b/internal/db/migrations/0028_memory_sensitive.up.sql new file mode 100644 index 0000000..4631b4d --- /dev/null +++ b/internal/db/migrations/0028_memory_sensitive.up.sql @@ -0,0 +1,68 @@ +-- 0028_memory_sensitive:**主人的私事不许进受限车道**(切片 M63,M60 审计 P0-1)。 +-- +-- 依据:DevDocs/audit/群聊主人信息泄漏调查-2026-10-30.md §6 R1 + §7 P0-1; +-- 塔裁决 2026-10-30:私事用 sensitive 标记,**不迁 scope**(迁 persona 域会让其他角色 +-- 可读,否决);「用户习惯可查」的既有裁决(design/03 §5)不动。 +-- +-- 为什么是**新列**而不是换作用域:作用域与敏感度是两根正交的轴。`scope` 回答 +-- 「这条归谁、谁可写谁可读」,本列回答「这条能不能说给第三方听」。实证(只读实测, +-- 审计 §2):`scope='user'` 里装着住址(`花溪` 64 条 active)、私称(`叶酱` 63 条)、 +-- 作息(`早睡` 38 条)—— 作用域是共享域,内容却是私事。把它们迁进 `persona` 域 +-- 等于让其他角色可读(memory/rules.go 的跨角色只读),语义更差。 +-- +-- 🔴 **sensitive 是数据层标记,不是权限系统**:主人车道(非受限环境)照常读到全部 +-- 记忆(含 sensitive=1);它只让**受限车道**(群聊 / 陌生人)的召回过滤器把这一条 +-- 排除在候选池外(internal/runtime/memory/sensitive.go 的 SharedVisible → +-- internal/runtime/loop/memory_filter.go 的 FilterSharedVisible)。谁是主人仍只由 +-- actor 元数据 + 白名单 + 策略引擎判定(AGENTS §9),本列不参与任何身份判定, +-- 也不改变任何一行的读/写权限。 +-- +-- 列值:0 = 非敏感(默认,含全部历史行)、1 = 敏感。闭集是 BOOL 语义,不是枚举。 +-- +-- 幂等性与形状:`ALTER TABLE ADD COLUMN` 在 SQLite 里没有 IF NOT EXISTS 形式,因此 +-- 与 0019 / 0026 / 0027 同一手法 —— 依赖启动顺序「先 MigrateUp、后 NewStore」 +-- (cmd/cyrene/wire.go):迁移先落地,随后自举前滚 +-- (internal/runtime/memory/sensitive_schema.go 的 ensureSensitiveSchema)走到 +-- "duplicate column name" 是预期路径。两份 DDL 必须**逐字同形** +-- (INTEGER NOT NULL DEFAULT 0),由 internal/runtime/memory/sensitive_migration_test.go +-- 钉住;列一律追加在末尾,尾序因此变成 `… retention_tier, temporal, sensitive`。 +-- +-- 本列**不建索引**:受限车道的过滤在内存里做(候选集本来就已过 status / 时效 / +-- scope,规模在千级),而「按 sensitive 取样巡检」是人工低频查询。将来真出现按它 +-- 筛的读路径,迁移与自举 DDL 两条真源一起加。 +ALTER TABLE memory_items ADD COLUMN sensitive INTEGER NOT NULL DEFAULT 0; + +-- 回填历史数据(本迁移唯一的一次性数据动作)。 +-- +-- 与 0026 / 0027「刻意不回填」的差别(这里必须说清,否则下一次会有人照着抄错): +-- 那两条不回填,是因为它们的「正确值」只能靠**再判一次**得到(0026 是留存规则、 +-- 0027 是拿正文反推时间性)—— 把一次全表判定藏在迁移里,等于在启动路径上静默跑 +-- 第二现场。本列的回填判据是**人工确认的精确短语白名单**(塔裁决 2026-10-30 批准): +-- 结论已定、不需要模型、结果可用一条只读查询复核,因此回填就该与建列一起落地 —— +-- 否则「列建好了、历史行还是敏感=0」这件事本身就是一个持续存在的泄漏面。 +-- +-- 白名单口径(判据与理由见 internal/runtime/memory/sensitive.go 的 privateFacts): +-- `花溪` 住址(审计 §2.1:全库 user 消息只在主人私聊出现过 1 次) +-- `叶酱` 主人私称(审计 §2.2) +-- `早睡` 作息(审计 §7 P0-1 的回填面数据) +-- 刻意**不**用泛类目关键词(「住」「称呼」「睡」「家人」「健康」):会把「住手」 +-- 「喜欢睡懒觉的猫」这类习惯一起标掉,而受限车道少一条合法习惯是主人看不见、 +-- 也不会去改回来的缺口 —— 缺口的可见性远低于泄漏,故宁窄勿宽。 +-- +-- 幂等:`sensitive = 0` 前置条件使重跑只补标、不撤标、不改已标行(连跑两次的第二次 +-- 影响 0 行)。同口径的可重跑入口是 internal/runtime/memory 的 BackfillSensitive +-- (dry-run + 报告,白名单扩充后用它补跑;本迁移只在 0028 首次应用时跑一次)。 +-- +-- 覆盖**全部** scope 与 status,不限于 `user`+`active`:merged / archived 的行会被 +-- 「撤销软合并」拉回可见集(store_list.go 的 Get 刻意不过 status 闸),漏标它们等于 +-- 留一条可被复活的泄漏面;persona 域虽然已经被 Loop 的受限过滤排除,标上也不改变 +-- 行为(只让巡检口径一致)。不依赖「先去重」(审计 P2-2):标重复行没有额外代价, +-- 漏标一条才有。 +-- +-- 不改 `updated_at`:本列是数据层标记,不是内容/活跃度事件;改它会扰动「最新在前」 +-- 的列表顺序与衰减基准(decay 由 updated_at 参与排序)。 +UPDATE memory_items SET sensitive = 1 + WHERE sensitive = 0 + AND (instr(content, '花溪') > 0 + OR instr(content, '叶酱') > 0 + OR instr(content, '早睡') > 0); diff --git a/internal/runtime/loop/memory_filter.go b/internal/runtime/loop/memory_filter.go index 1aa5c6f..4c8df67 100644 --- a/internal/runtime/loop/memory_filter.go +++ b/internal/runtime/loop/memory_filter.go @@ -8,23 +8,24 @@ import ( ) // FilterSharedVisible 返回群聊/陌生人(受限环境)可见的记忆子集:按**作用域** -// 只保留共享域(用户习惯 ScopeUser 与项目 ScopeProject,人人可读), -// 并剔除 L4 意图记录(capacity.RecordIntent 的回写格式,内容是主人逐句 -// 原话的机器可读形态 —— 虽落在共享域以便 L4 闭环学习,但只该暴露 -// 「不敏感的习惯结论」,不该暴露主人原话)。角色日常(ScopePersona) -// 是主人私有的敏感项,不在可见域内(design/03 §3/§5「不泄露主人敏感信息」)。 +// 只保留共享域(用户习惯 ScopeUser 与项目 ScopeProject,人人可读),剔除主人 +// 私有的角色日常(ScopePersona);再按**敏感度**剔除敏感标记为真的条目 +// (MemoryItem.SharedVisible,迁移 0028);并剔除 L4 意图记录 +// (capacity.RecordIntent 的回写格式,内容是主人逐句原话的机器可读形态 —— 虽落在 +// 共享域以便 L4 闭环学习,但只该暴露「不敏感的习惯结论」,不该暴露主人原话)。 // // 它是**唯一**的隔离判据:循环的记忆注入(retrieveMemoryBlock)与模型 // 可达的 knowledge 检索工具都调用它。knowledge 若自造一套过滤(或不过滤), // 一旦管理面板把它放行给群聊/陌生人,就会返回角色日常与主人原话 // (审计 2026-10-05:knowledge 绕过群聊/陌生人召回过滤,P0 红线)。 // -// ⚠️ 它按作用域分档,**不按内容分档**:过了这道过滤不等于内容不敏感 -// (见 sharedScope 的说明与审计 2026-10-30 §6 R1)。 +// 🔴 **它只决定「受限车道看不看得见」**:主人车道(`!env.Restricted()`)根本不过 +// 这道过滤,照常读到全部记忆(含 sensitive=1)。sensitive 是数据层标记,不是 +// 权限系统(切片 M63、审计 2026-10-30 §7 P0-1)。 func FilterSharedVisible(results []memory.Result) []memory.Result { visible := make([]memory.Result, 0, len(results)) for _, r := range results { - if !sharedScope(r.Item.Scope) { + if !r.Item.SharedVisible() { continue } if capacity.IsIntentRecord(r.Item.Content) { @@ -35,23 +36,6 @@ func FilterSharedVisible(results []memory.Result) []memory.Result { return visible } -// sharedScope 报告 scope 是否为群聊/陌生人可查的共享域:用户习惯 -// (user,全局共享)与项目记忆(project,人人可读)。角色日常(persona) -// 是主人私有的敏感项,不在其列(design/03 §3/§5)。 -// -// 🔴 **作用域 ≠ 敏感度**(审计 2026-10-30 §6 R1 纠正了本注释此前的说法: -// 原文写「敏感度划分即作用域本身」,那个前提是错的)。生效裁决只按作用域 -// 分档,而 `user` 域里事实上装着住址、私称、作息这类私事(data/cyrene.db -// 实测:花溪 64 条 / 叶酱 63 条 active),它们会随本判据进入受限车道的召回 -// 候选。因此「过了 FilterSharedVisible 就一定不敏感」**不成立**:群聊/陌生人 -// 车道还有一道呈现层防线 —— 公域呈现块与发言归属口径 -// (channelbridge 的 publicPresencePrompt、loop/attribution.go)明令 -// 即使读到主人私事也不得外说。按内容逐条判敏感(sensitive 列 + 提取器判据) -// 是 MEMSENS 切片的事,届时本判据才会带上内容维度。 -func sharedScope(s memory.Scope) bool { - return s == memory.ScopeUser || s == memory.ScopeProject -} - // formatMemoryBlock 由检索结果构建前导系统块。非空 // marker 作为该块的前缀,使隔离的群聊读取可与 // 主人的完整读取区分开。 diff --git a/internal/runtime/memory/llmextractor.go b/internal/runtime/memory/llmextractor.go index 789f3b4..6845af4 100644 --- a/internal/runtime/memory/llmextractor.go +++ b/internal/runtime/memory/llmextractor.go @@ -38,6 +38,19 @@ import ( // 结论集与没有它逐字相同 —— 保留它只为积累「模型到底怎么标」的证据(temporal.go // 头注记着两次被生产重放否掉的接线方案与接线前必须看的那份分布)。因此这段 prompt // 的**唯一**作用是让观测面有数据;即便模型完全不听,判定的行为与今天一致。 +// +// 🆕 2026-10-30(切片 M63,审计 §7 P0-2):prompt 里追加**敏感位** `sensitive`。 +// 它**参与**受限车道的可见性(落库是 `sensitive` 列,迁移 0028;消费方是 +// sensitive.go 的 SharedVisible),但判据不走「靠提示词」那条老路: +// - 模型只做**类目级标注**(住址 / 私人称呼 / 家人 / 健康 / 作息与行程 / 收入 +// → true),因为新私事的措辞无法穷举; +// - 确定性那一半在代码里:`resolveSensitive` 会把产出与**已实证短语白名单** +// 取「或」(sensitive.go 的 privateFacts),模型漏标时由白名单补上。 +// +// ⚠️ 注意 prompt 的**提取面没有缩小**:住址 / 家人 / 健康 / 称呼习惯仍然要提取, +// 只是必须标 sensitive —— 塔裁决 2026-10-30 明确「私事用 sensitive 标记,不迁 +// scope」,因为迁进 persona 域会让其他角色可读(memory/rules.go 跨角色只读)。 +// 主人车道照常读到这些记忆,受限车道才看不见。 const extractSystemPrompt = `你是记忆提取器。从对话中提取**值得长期记住**的记忆项。 只保留这些(正向清单): @@ -58,6 +71,25 @@ const extractSystemPrompt = `你是记忆提取器。从对话中提取**值得 **允许返回空数组 []**:一轮对话里没有值得长期记住的内容时,正确做法就是回 [],不要为了凑数而提取。 +每条还要标一个**敏感位** "sensitive"(布尔值 true / false)。判据只有一句话: +**这句话能不能说给主人的朋友听**。 +- true —— 主人只愿在私下被提起的事:住址与所在城市、真实姓名与私人称呼(主人 + 希望被怎么叫、专属昵称)、家庭成员的姓名与情况、健康与身体约束、作息与出行 + 行程、收入与工作单位,以及任何「主人告诉了助手、但不该转述给第三方」的内容。 +- false —— 对谁都能说的稳定信息:口味与饮食偏好、兴趣爱好、喜欢的作品与游戏、 + 不涉及上面那些私事的相处方式、项目约定与编号规则。 + +正反例(照这个尺度标,不要自己另立一套): +- 「用户喜欢冰美式」→ sensitive=false(口味习惯,谁都能知道)。 +- 「主人住在花溪」→ sensitive=true(住址)。 +- 「主人希望被称作叶酱」→ sensitive=true(私人称呼)。 +- 「主人常熬夜,助手会提醒早睡」→ sensitive=true(作息)。 +- 「用户喜欢《塞尔达传说》」→ sensitive=false(兴趣爱好)。 +- 「项目编号规则是 CY-日期-序号」→ sensitive=false(项目约定)。 + +拿不准就标 true —— 标成 true 的代价只是群聊里少提一句,标成 false 的代价是主人 +的私事被说给外人。这个字段必填,不要省掉、不要填字符串。 + 每条还要标一个**时间性位** "temporal",只能取下面四个英文值之一: - "one_off":这条说的是**某一次**发生的事(今天 / 昨天 / 这次 / 某个具体日子), 即使你已经把它写成「用户……」这样的陈述句; @@ -69,7 +101,7 @@ temporal 只许填这四个英文值之一(不要换成中文、不要自造 "unknown"。这个标记会被记录下来供以后核对用途,如实标注即可,不要为了省字省掉它。 只输出一个 JSON 数组,不要任何其他文字。每个元素形如: -{"content":"记忆内容","scope":"user|persona|project","kind":"relationship|world_event","temporal":"one_off|recurring|durable|unknown","tags":["标签"],"confidence":0.9,"project_id":"可选项目id"} +{"content":"记忆内容","scope":"user|persona|project","kind":"relationship|world_event","sensitive":true,"temporal":"one_off|recurring|durable|unknown","tags":["标签"],"confidence":0.9,"project_id":"可选项目id"} 去噪去重:只保留值得长期记住的,忽略寒暄与临时信息;确实发生过的**一次性事件** 如果值得记,也照实提取,并把 temporal 标成 one_off。` @@ -86,6 +118,15 @@ type llmCandidate struct { Tags []string `json:"tags"` Confidence float64 `json:"confidence"` ProjectID string `json:"project_id"` + // Sensitive 是模型给出的**敏感位**(迁移 0028,切片 M63):true = 这条是主人的 + // 私事(住址 / 私人称呼 / 家人 / 健康 / 作息/行程 / 收入 …),受限车道不许召回。 + // + // 🔴 它**不是**权限判定(AGENTS §9 只约束身份判定):它只是一个数据层标记,主人 + // 车道照常读到。模型漏标或干脆没给这个键 → 缺席即 false,再落到 resolveSensitive + // 的第二道判据(已实证短语白名单)上;键的类型填错(例如给字符串 "true")会让 + // **整批** JSON 解码失败 —— 与其它字段同一口径,由调用方决定重试还是回退, + // 这里不为了容错去猜模型的意图(猜错的方向是把私事当成可外说)。 + Sensitive bool `json:"sensitive"` } // LLMExtractor 让 model.Adapter 从对话中推导候选记忆条目 @@ -266,6 +307,11 @@ func (e *LLMExtractor) dedupeAndBuild(cands []llmCandidate) []MemoryItem { // 其余(空 / 非法 / 自造)一律归 unknown —— 解析失败绝不报错, // 也不拖累同批的其他候选(见 temporal.go 的 normalizeTemporal)。 Temporal: normalizeTemporal(c.Temporal), + // 敏感位(迁移 0028)在**产出侧**合成:模型的类目判断与已实证短语白名单 + // 取或(resolveSensitive)。两次判据都放在这里而不是只等 Store.Add 兜底, + // 是为了让 **dry-run**(只跑 Extract、不落库)看到的位就是将来落库的位 —— + // 否则「提取器判据」这条验收只能靠真落库才看得见。 + Sensitive: resolveSensitive(c.Sensitive, content), } if !it.Scope.valid() || !it.Kind.valid() { continue diff --git a/internal/runtime/memory/llmextractor_sensitive_test.go b/internal/runtime/memory/llmextractor_sensitive_test.go new file mode 100644 index 0000000..a5e5543 --- /dev/null +++ b/internal/runtime/memory/llmextractor_sensitive_test.go @@ -0,0 +1,91 @@ +package memory + +import ( + "context" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// 本文件钉住**提取器侧的敏感判据**(切片 M63、审计 §7 P0-2): +// +// 1. prompt 里真的给了判据与正反例(不是「交给模型自由发挥」); +// 2. **dry-run 私事不落 `scope=user & sensitive=false`** —— 即模型漏标时,产出侧 +// 仍按白名单补上敏感位。这是审计 P0-2 的验收原话,也是最容易复发的一条: +// 模型的标注是概率性的,白名单是确定性的。 +// 3. 模型显式标 true 的非白名单内容不被撤标。 +// +// 删掉实现(prompt 里的敏感位、resolveSensitive 在 dedupeAndBuild 里的调用)会让 +// 2 直接变红。 + +// extractOne 跑一次 Extract,返回第一条候选(没有候选即失败)。 +func extractOne(t *testing.T, reply string) MemoryItem { + t.Helper() + adapter := &mockAdapter{fn: func(_ context.Context, _ model.Request, _ model.Options) (model.Response, error) { + return model.Response{Content: reply}, nil + }} + ex := NewLLMExtractor(adapter, "persona.a", "role.1") + items, err := ex.Extract(context.Background(), []model.Message{ + model.TextMessage(model.RoleUser, "我们聊聊住哪儿"), + }) + if err != nil { + t.Fatalf("Extract: %v", err) + } + if len(items) == 0 { + t.Fatal("Extract 没有产出候选") + } + return items[0] +} + +// TestExtractPrompt_带敏感位判据与正反例 钉住 prompt 本体:敏感位的键名、判据、 +// 正反例(住址 = 私事 / 口味 = 习惯)必须都在。这三样少任何一样,模型就只能瞎猜。 +func TestExtractPrompt_带敏感位判据与正反例(t *testing.T) { + t.Parallel() + for _, want := range []string{ + `"sensitive"`, + "住址", + "私人称呼", + "「用户喜欢冰美式」→ sensitive=false", + "「主人住在花溪」→ sensitive=true", + "拿不准就标 true", + } { + if !strings.Contains(extractSystemPrompt, want) { + t.Fatalf("extractSystemPrompt 缺少 %q —— 提取器没有敏感判据,模型只能把私事当习惯", want) + } + } +} + +// TestExtract_私事漏标仍落敏感 是审计 P0-2 的验收:模型(这里是脚本化的回复) +// 把「主人住在花溪」标成 scope=user 且**没有**给 sensitive 位 —— 产出侧必须补上 +// 敏感位,绝不吐出 `user & sensitive=false` 的候选。 +func TestExtract_私事漏标仍落敏感(t *testing.T) { + t.Parallel() + got := extractOne(t, `[{"content":"主人住在花溪(贵阳花溪区),不在上海工作","scope":"user","kind":"world_event","confidence":0.9}]`) + if got.Scope != ScopeUser { + t.Fatalf("scope = %q, want user(本用例考的正是 user 域里的私事)", got.Scope) + } + if !got.Sensitive { + t.Fatal("产出侧漏了敏感位:候选是 scope=user & sensitive=false,正是审计 P0-2 的复发点") + } +} + +// TestExtract_HabitStaysShared 是方向性护栏:习惯类内容在模型标 false(或缺省)时 +// 必须保持非敏感 —— 否则本片就把已冻结的「用户习惯可查」(design/03 §5)推翻了。 +func TestExtract_HabitStaysShared(t *testing.T) { + t.Parallel() + got := extractOne(t, `[{"content":"用户喜欢冰美式","scope":"user","kind":"relationship","sensitive":false}]`) + if got.Sensitive { + t.Fatal("习惯类候选被标成敏感 —— 受限车道会平白丢掉合法习惯") + } +} + +// TestExtract_模型显式敏感不被撤标 钉住方向性:模型标 true 的非白名单内容 +// (例如家人情况,措辞不在白名单里)必须保持 true。 +func TestExtract_模型显式敏感不被撤标(t *testing.T) { + t.Parallel() + got := extractOne(t, `[{"content":"主人有个弟弟在读高三","scope":"user","kind":"relationship","sensitive":true}]`) + if !got.Sensitive { + t.Fatal("模型显式标 sensitive=true 被改写成 false —— 产出侧不许撤标") + } +} diff --git a/internal/runtime/memory/preinsert_store.go b/internal/runtime/memory/preinsert_store.go index a5751ed..7e1175c 100644 --- a/internal/runtime/memory/preinsert_store.go +++ b/internal/runtime/memory/preinsert_store.go @@ -32,7 +32,7 @@ const ingestStatusMerged = "merged" // (design/23 §19),**不会复活**。姊妹注释在 tidy_store.go 的 listTidyPartitionItemsSQL。 const listReviewCandidatesSQL = `SELECT id, persona_id, role_id, project_id, scope, kind, content, tags, confidence, source_conversation, created_at, updated_at, - decay_weight, retention_tier FROM memory_items + decay_weight, retention_tier, sensitive FROM memory_items WHERE persona_id = ? AND scope = ? AND project_id = ? AND status = ? AND id <> ? ORDER BY created_at DESC, rowid DESC LIMIT ?` diff --git a/internal/runtime/memory/retention_migration_test.go b/internal/runtime/memory/retention_migration_test.go index 837e7f6..290602a 100644 --- a/internal/runtime/memory/retention_migration_test.go +++ b/internal/runtime/memory/retention_migration_test.go @@ -176,18 +176,20 @@ func TestMigration0026ShapeMatchesStoreBootstrap(t *testing.T) { t.Fatalf("%s 侧的 %s 列形状不是 TEXT NOT NULL DEFAULT 空串:%v", name, retentionTierColumn, cols) } } - // 自举 DDL 的尾序(新建库的期望形状):`… retention_tier, temporal`。 - // 迁移路径上这两列之后可能还有自举追加的 project_id,因此只对自举侧断言顺序。 - // (0027 之后 retention_tier **不再是**最后一列 —— temporal 追加在它之后。) + // 自举 DDL 的尾序(新建库的期望形状):`… retention_tier, temporal, sensitive`。 + // 迁移路径上这几列之后可能还有自举追加的 project_id,因此只对自举侧断言顺序。 + // (0027 之后 retention_tier **不再是**最后一列 —— temporal 追加在它之后; + // 0028 又把 sensitive 追加在 temporal 之后。) bootCols := tableColumns(t, bootstrapped, "memory_items") - if len(bootCols) < 2 { - t.Fatalf("自举建的 memory_items 列数 = %d, want ≥ 2", len(bootCols)) + if len(bootCols) < 3 { + t.Fatalf("自举建的 memory_items 列数 = %d, want ≥ 3", len(bootCols)) } - tail := bootCols[len(bootCols)-2:] + tail := bootCols[len(bootCols)-3:] if !strings.Contains(tail[0], "|"+retentionTierColumn+"|") || - !strings.Contains(tail[1], "|"+temporalColumn+"|") { - t.Fatalf("自举建的 memory_items 尾两列 = %v, want …, %s, %s", - tail, retentionTierColumn, temporalColumn) + !strings.Contains(tail[1], "|"+temporalColumn+"|") || + !strings.Contains(tail[2], "|"+sensitiveColumn+"|") { + t.Fatalf("自举建的 memory_items 尾三列 = %v, want …, %s, %s, %s", + tail, retentionTierColumn, temporalColumn, sensitiveColumn) } } diff --git a/internal/runtime/memory/rules.go b/internal/runtime/memory/rules.go index ed85a2a..4bf41b1 100644 --- a/internal/runtime/memory/rules.go +++ b/internal/runtime/memory/rules.go @@ -102,7 +102,7 @@ func (s *Store) listScope(ctx context.Context, scope Scope, personaID string, ti now := timeGateNow() query := `SELECT id, persona_id, role_id, project_id, scope, kind, content, tags, confidence, source_conversation, created_at, updated_at, - decay_weight, retention_tier FROM memory_items WHERE scope = ? AND status = ?` + decay_weight, retention_tier, sensitive FROM memory_items WHERE scope = ? AND status = ?` args := []any{string(scope), string(tidyStatusActive)} query, args, err := retentionTierList(query, args, tiers) if err != nil { @@ -144,7 +144,7 @@ func (s *Store) listProject(ctx context.Context, projectID string, tiers []Reten now := timeGateNow() query := `SELECT id, persona_id, role_id, project_id, scope, kind, content, tags, confidence, source_conversation, created_at, updated_at, - decay_weight, retention_tier FROM memory_items WHERE project_id = ? AND status = ?` + decay_weight, retention_tier, sensitive FROM memory_items WHERE project_id = ? AND status = ?` args := []any{projectID, string(tidyStatusActive)} query, args, err := retentionTierList(query, args, tiers) if err != nil { diff --git a/internal/runtime/memory/sensitive.go b/internal/runtime/memory/sensitive.go new file mode 100644 index 0000000..bba91ae --- /dev/null +++ b/internal/runtime/memory/sensitive.go @@ -0,0 +1,111 @@ +package memory + +import "strings" + +// 本文件是「敏感标记」这根轴(迁移 0028、`memory_items.sensitive`)的**数据层定义**: +// 字段语义、受限可见性判据、写入侧的关键词兜底白名单,以及回填判据的理由。 +// +// 🔴 **sensitive 是数据层标记,不是权限系统**(塔裁决 2026-10-30,依据 M60 审计 +// `DevDocs/audit/群聊主人信息泄漏调查-2026-10-30.md` §7 P0-1):主人车道(非受限 +// 环境)照常读到**全部**记忆,含 `sensitive=1`;本标记只决定「这一条能不能进 +// **受限车道**(群聊 / 陌生人)的召回候选池」。谁是主人依旧只由 actor 元数据 + +// 白名单 + 策略引擎判定(AGENTS §9),本标记不参与任何身份判定,也绝不改变 +// 任何一行的读/写权限。 +// +// **为什么需要这根轴**:作用域 ≠ 敏感度(审计 §6 R1)。`scope='user'` 是「用户习惯 +// 全局共享域」,但实证域内装着住址 / 私称 / 作息(只读实测:`花溪` 64 条、`叶酱` +// 63 条、`早睡` 38 条 active),于是「共享域整锅注入」把主人的私事送进了群聊上下文 +// —— 2026-10-29 群 531082168 的住址泄漏即此路径(审计 §2.1)。sensitive 把 +// 「这条归谁」(scope)与「这条能不能外说」(sensitive)拆成两根正交的轴:scope +// 仍决定谁可写谁可读,sensitive 只决定受限车道看不看得见。**没有改动**已冻结的 +// 「用户习惯可查」裁决(design/03 §5):习惯类记忆 `sensitive=0`,分组照常可查。 +// +// 呈现层(channelbridge 的公域呈现块,M62 已合 caa19d6)与本标记互为纵深: +// 数据面挡住「私事进不了受限候选池」,呈现面挡住「即使读到也不许外说」。 + +// privateFact 是一条**已实证**的主人私事:类别只用于留痕与报告,判据是 phrase +// 的字面命中(`strings.Contains`)。 +type privateFact struct { + // Category 是私事类别(住址 / 私称 / 作息 …),只作日志与回填报告的标签。 + Category string + // Phrase 是**精确短语**,不是泛类别关键词。 + Phrase string +} + +// privateFacts 是回填与写入兜底共用的判据表 —— **人工确认清单的精确短语白名单**, +// 不是关键词类目(审计 §7 P0-2 的「关键词白名单 vs 人工清单」二选一,这里选后者, +// 理由三条): +// +// 1. **来源可追**:三条短语都是审计 §2 按「全库 token 唯一性」逐字实证的主人私事 +// (花溪:全库 user 消息只在主人私聊出现过 1 次;叶酱:主人私称;早睡:作息), +// 而不是「住址/称呼/作息」这类需要模型再判一次的类目。 +// 2. **误标不可逆**:泛关键词(「住」「名字」「称呼」「睡」「家人」「健康」)会 +// 把习惯类记忆一起标掉(「住手」「喜欢睡懒觉的猫」「家人群里发的梗」),而受限 +// 车道一旦少了一条合法习惯,主人看不出、也不会去改回来 —— 缺口的可见性远低于 +// 泄漏,所以宁窄勿宽。 +// 3. **可增量**:新实证的私事短语往这张表里加一行即可,回填脚本幂等重跑 +// (`BackfillSensitive`)只会补标、不会撤标。 +// +// ⚠️ 这张表是**兜底**,不是提取器判据的替代:类目级判断(住址 / 家人 / 健康 / +// 称呼习惯 → 标 sensitive)由提取器在产出侧做(见 llmextractor.go 的正反例), +// 因为新私事的措辞无法穷举。两道判据只做「或」:提取器标了就是标了,模型漏标时 +// 这里按白名单补上(口径同 AGENTS §9「判据靠代码,提示词只辅助」)。 +var privateFacts = []privateFact{ + {Category: "住址", Phrase: "花溪"}, + {Category: "私称", Phrase: "叶酱"}, + {Category: "作息", Phrase: "早睡"}, +} + +// IsPrivateContent 报告 content 是否命中已实证的主人私事白名单。它是**纯函数**, +// 因此写入侧兜底(Store.Add)与回填(BackfillSensitive)用的是同一个判据。 +func IsPrivateContent(content string) bool { + for _, f := range privateFacts { + if strings.Contains(content, f.Phrase) { + return true + } + } + return false +} + +// resolveSensitive 是敏感位的**唯一**合成判据:调用方给出的标记(提取器的类目 +// 判断 / 人工显式标注)与已实证短语白名单取「或」。两条写路径(提取器产出候选、 +// Store.Add 落库)都走它,避免「一处只认模型、另一处只认白名单」的分叉 —— +// 也让 dry-run(只跑 Extract、不落库)看到的位与真正落库的位一致。 +// +// 方向性:只能把 false 翻成 true,**不撤标**(flagged=true 而白名单没命中时仍是 +// true)。撤标需要人工判据,不是任何自动路径该做的事。 +func resolveSensitive(flagged bool, content string) bool { + return flagged || IsPrivateContent(content) +} + +// sensitiveColumn 返回要写进 `sensitive` 列的值(INTEGER,0 / 1)。与 +// temporalColumn 同族:把「条目 → 落库值」这一步收在类型自己身上,插入语句里 +// 不留裸转换,也就不可能出现两处各转一次、口径分叉。 +func (it MemoryItem) sensitiveColumn() int { + if it.Sensitive { + return 1 + } + return 0 +} + +// SharedVisible 报告这条记忆是否属于受限车道(群聊 / 陌生人)**可注入**的共享域: +// 作用域是共享域(`user` / `project`)、且**未**标 sensitive。 +// +// 它是作用域与敏感度两根轴的合取,也是 restricted 侧唯一的可见性判据( +// `internal/runtime/loop/memory_filter.go` 的 FilterSharedVisible 与复用它的 +// `knowledge` 工具)—— 放在这里而不是各调用点,是为了让「谁能看见」只有一个 +// 注册点(审计 2026-10-05:knowledge 曾自造过滤绕过群聊召回,P0 红线)。 +// +// ⚠️ 它只说「受限车道看不看得见」,**不**说明「谁可以读这一行」:主人车道 +// (`env.Restricted() == false`)根本不过这道判据,照常读到全部记忆。 +func (it MemoryItem) SharedVisible() bool { + if it.Sensitive { + return false + } + switch it.Scope { + case ScopeUser, ScopeProject: + return true + default: + return false + } +} diff --git a/internal/runtime/memory/sensitive_backfill.go b/internal/runtime/memory/sensitive_backfill.go new file mode 100644 index 0000000..048d8b7 --- /dev/null +++ b/internal/runtime/memory/sensitive_backfill.go @@ -0,0 +1,168 @@ +package memory + +import ( + "context" + "database/sql" + "fmt" + "strings" +) + +// 本文件是**历史数据的敏感标记回填**(切片 M63,审计 §7 P0-1 的回填面)。 +// +// 它是一次性数据动作,与结构前滚(sensitive_schema.go)**刻意分开**: +// 前滚永远幂等且零副作用,回填则可 dry-run、可重跑、只在需要时跑一次 —— +// 两个验收因此互不牵连(前滚失败是启动失败,回填标错是可以回头修的数据问题)。 +// +// 🔴 **回填判据 = 人工确认清单的精确短语白名单**(sensitive.go 的 privateFacts, +// 理由见那里的三条)。不用泛类目关键词(「住」「称呼」「睡」「家人」「健康」): +// 误标会让受限车道永久丢掉合法习惯,而缺口主人看不见、也不会改回来。 +// +// 🔴 **不依赖「先去重」**(审计 §7 P2-2 建议的先做去重):重复行全部标上才是安全 +// 方向 —— 标一条重复行没有额外代价(合并/归档后它本来就不进召回),而漏标一条 +// 就等于留一条泄漏面。去重只影响工作量,不是回填的前置条件。 +// +// 🔴 **不改写 updated_at**:本列是数据层标记,不是内容/活跃度事件。改 updated_at +// 会连带篡改「最新在前」的列表顺序与衰减基准(decay 由 updated_at 参与排序), +// 让一次打标变成一次全库召回排序的扰动。 +// +// **幂等**:UPDATE 带 `sensitive = 0` 前置条件,重跑只补标、不撤标、不改已标行; +// 重跑一次的 `Marked` 恒为 0(tests 钉住这条)。 +// +// **生产执行口径**(本片实际执行的就是这一步,见交付报告):先在**备份**上跑 +// `dryRun=true` 核对 `Matched`,再对生产库跑一次 `dryRun=false`; +// 生产库此刻若还没有 `sensitive` 列,先由 NewStore(或迁移 0028)补列 —— 顺序是 +// 「补列 → 回填」,反过来会直接报 no such column。 + +// SensitiveBackfillReport 是一次回填(含 dry-run)的结果。它同时是**验收证据**: +// 受限车道的候选池规模(CandidatesBefore → CandidatesAfter)下降多少、每条白名单 +// 短语各命中多少行,都在这里,调用方不必再跑去数据库里数一遍。 +type SensitiveBackfillReport struct { + // DryRun 为 true 时本报告只统计、不写库(Marked 恒为 0)。 + DryRun bool + // Matched 是命中白名单的行数(**全部** scope 与 status:仓库里还有 merged / + // archived 的行,撤销软合并会让 merged 的行重新进 active,故一并标)。 + Matched int + // AlreadyFlagged 是命中白名单且**已是** sensitive=1 的行数(重跑时为 Matched + // 的子集,等于 Matched 即「已经跑过」)。 + AlreadyFlagged int + // Marked 是本次实际把 sensitive 从 0 改成 1 的行数(dry-run 恒为 0)。 + Marked int + // ByPhrase 是每条白名单短语各自命中的行数(标签见 privateFacts 的 Category)。 + ByPhrase map[string]int + // CandidatesBefore / CandidatesAfter 是**受限车道候选池**的规模(active 且 + // scope ∈ {user, project} 且 sensitive=0 的行数)—— 回填前后各取一次,差值就是 + // 本片从群聊/陌生人候选池里移除的条数。 + CandidatesBefore int + CandidatesAfter int +} + +// sensitiveCandidatePoolSQL 数的是「受限车道(群聊 / 陌生人)的候选池」:四条列表 +// 读路径给检索器的共享域候选,与 loop.FilterSharedVisible 的判据同源 +// (active + scope ∈ {user, project} + 未标 sensitive)。回填前后各跑一次即可量化 +// 本片收窄了多少。**它不接时效闸**(timeGateClause):这里量的是「表里有多少行 +// 属于共享域」,不是「此刻能不能被召回」,接上闸会让两个时点的口径随墙上时钟漂移。 +const sensitiveCandidatePoolSQL = `SELECT COUNT(*) FROM memory_items + WHERE status = ? AND scope IN (?, ?) AND sensitive = 0` + +// BackfillSensitive 按 privateFacts 白名单给 memory_items 打敏感标记。 +// +// dryRun=true 只统计不写库(生产执行的第 1 步:先在备份上核对 Matched)。 +// 返回的报告同时是回填验收证据(见 SensitiveBackfillReport 各字段)。 +// 调用前 `sensitive` 列必须已存在(NewStore 或迁移 0028 负责补列)。 +func BackfillSensitive(ctx context.Context, db *sql.DB, dryRun bool) (SensitiveBackfillReport, error) { + report := SensitiveBackfillReport{DryRun: dryRun, ByPhrase: map[string]int{}} + clause, args := privateMatchClause() + + before, err := countSharedCandidates(ctx, db) + if err != nil { + return report, err + } + report.CandidatesBefore = before + + if err := countByPhrase(ctx, db, &report); err != nil { + return report, err + } + if report.Matched, err = countWhere(ctx, db, clause, args); err != nil { + return report, err + } + alreadyArgs := append(append([]any{}, args...), 1) + if report.AlreadyFlagged, err = countWhere(ctx, db, clause+" AND sensitive = ?", alreadyArgs); err != nil { + return report, err + } + + if !dryRun && report.Matched > report.AlreadyFlagged { + marked, err := markSensitive(ctx, db, clause, args) + if err != nil { + return report, err + } + report.Marked = marked + } + + if report.CandidatesAfter, err = countSharedCandidates(ctx, db); err != nil { + return report, err + } + return report, nil +} + +// privateMatchClause 把白名单翻成 SQL 条件(`instr(content, ?) > 0` 的 OR 组合)与 +// 参数。用占位符而不是把短语拼进 SQL:白名单是代码常量,但把「判据字符串」 +// 与「SQL 文本」分开,才不会有人为了加一条短语去改 SQL 结构。 +func privateMatchClause() (string, []any) { + parts := make([]string, 0, len(privateFacts)) + args := make([]any, 0, len(privateFacts)) + for _, f := range privateFacts { + parts = append(parts, "instr(content, ?) > 0") + args = append(args, f.Phrase) + } + return "(" + strings.Join(parts, " OR ") + ")", args +} + +// countSharedCandidates 数受限车道的候选池规模(SQL 与口径见 sensitiveCandidatePoolSQL)。 +func countSharedCandidates(ctx context.Context, db *sql.DB) (int, error) { + var n int + err := db.QueryRowContext(ctx, sensitiveCandidatePoolSQL, + string(tidyStatusActive), string(ScopeUser), string(ScopeProject)).Scan(&n) + if err != nil { + return 0, fmt.Errorf("memory: count shared candidates: %w", err) + } + return n, nil +} + +// countByPhrase 逐条短语统计命中行数,填进 report.ByPhrase(键为 Category)。 +func countByPhrase(ctx context.Context, db *sql.DB, report *SensitiveBackfillReport) error { + for _, f := range privateFacts { + var n int + if err := db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM memory_items WHERE instr(content, ?) > 0`, + f.Phrase).Scan(&n); err != nil { + return fmt.Errorf("memory: count private fact %s: %w", f.Category, err) + } + report.ByPhrase[f.Category] = n + } + return nil +} + +// countWhere 数满足 where 的行数。 +func countWhere(ctx context.Context, db *sql.DB, where string, args []any) (int, error) { + var n int + if err := db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM memory_items WHERE `+where, args...).Scan(&n); err != nil { + return 0, fmt.Errorf("memory: count sensitive candidates: %w", err) + } + return n, nil +} + +// markSensitive 执行回填 UPDATE,返回实际改动的行数。`sensitive = 0` 前置条件 +// 与「不动 updated_at」是本函数的两条硬要求(理由见文件头注)。 +func markSensitive(ctx context.Context, db *sql.DB, clause string, args []any) (int, error) { + res, err := db.ExecContext(ctx, + `UPDATE memory_items SET sensitive = 1 WHERE sensitive = 0 AND `+clause, args...) + if err != nil { + return 0, fmt.Errorf("memory: backfill sensitive: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("memory: backfill sensitive row count: %w", err) + } + return int(n), nil +} diff --git a/internal/runtime/memory/sensitive_backfill_test.go b/internal/runtime/memory/sensitive_backfill_test.go new file mode 100644 index 0000000..b58b085 --- /dev/null +++ b/internal/runtime/memory/sensitive_backfill_test.go @@ -0,0 +1,162 @@ +package memory + +import ( + "context" + "database/sql" + "testing" +) + +// 本文件钉住**历史数据回填**(sensitive_backfill.go)的四条: +// +// 1. dry-run 一行都不写(生产执行的第 1 步是在备份上核对数量,写坏就没法核对); +// 2. 幂等:连跑两次,第二次 Marked=0、AlreadyFlagged=Matched; +// 3. 只动 sensitive,**不动 updated_at**(否则一次打标会扰动「最新在前」的排序与 +// 衰减基准); +// 4. 报告的候选池前后对比与白名单逐短语计数可用于验收(回填前后受限召回候选数)。 +// +// 历史行在这里用**裸 SQL** 造(只给必填列),sensitive 走列默认值 0 —— 这正是 +// 0028 之前落库的行的形态;若改用 Store.Add 造,写入侧兜底会先把它们标成 1, +// 就考不出「回填有没有生效」了。 + +// seedLegacySensitiveRow 以 0028 之前的形态写一行(不列 sensitive)。 +func seedLegacySensitiveRow(t *testing.T, d *sql.DB, id, scope, content string) { + t.Helper() + if _, err := d.ExecContext(context.Background(), `INSERT INTO memory_items + (id, persona_id, scope, kind, content, created_at, updated_at, status) + VALUES (?, 'persona.a', ?, 'relationship', ?, `+legacyTimeLiteral+`, `+legacyTimeLiteral+`, 'active')`, + id, scope, content); err != nil { + t.Fatalf("seed legacy row %s: %v", id, err) + } +} + +// readSensitiveCell 直读 sensitive 列(不走 scanItem:这里要验的是库里那一格)。 +func readSensitiveCell(t *testing.T, d *sql.DB, id string) int { + t.Helper() + var v int + if err := d.QueryRowContext(context.Background(), + `SELECT sensitive FROM memory_items WHERE id = ?`, id).Scan(&v); err != nil { + t.Fatalf("read sensitive of %s: %v", id, err) + } + return v +} + +// seedSensitiveFixture 造一份「0028 之前的库」:两条私事(user / project 各一)+ +// 一条习惯 + 一条角色日常私事。 +func seedSensitiveFixture(t *testing.T, s *Store) { + t.Helper() + seedLegacySensitiveRow(t, s.db, "legacy-addr", string(ScopeUser), "用户居住在花溪(贵阳花溪区)") + seedLegacySensitiveRow(t, s.db, "legacy-name", string(ScopeProject), "称呼约定:主人只叫「叶酱」") + seedLegacySensitiveRow(t, s.db, "legacy-habit", string(ScopeUser), "用户喜欢冰美式") + seedLegacySensitiveRow(t, s.db, "legacy-persona", string(ScopePersona), "主人常熬夜,助手提醒早睡") +} + +// TestBackfillSensitive_干跑不写库 钉住 dry-run 的零副作用。 +func TestBackfillSensitive_干跑不写库(t *testing.T) { + t.Parallel() + ctx := context.Background() + s := newTestStore(t) + seedSensitiveFixture(t, s) + + report, err := BackfillSensitive(ctx, s.db, true) + if err != nil { + t.Fatalf("BackfillSensitive(dry-run): %v", err) + } + if report.Marked != 0 { + t.Fatalf("dry-run 的 Marked = %d, want 0(干跑一行都不许写)", report.Marked) + } + if report.Matched != 3 { + t.Fatalf("Matched = %d, want 3(花溪 / 叶酱 / 早睡 各一条)", report.Matched) + } + for _, id := range []string{"legacy-addr", "legacy-name", "legacy-persona", "legacy-habit"} { + if got := readSensitiveCell(t, s.db, id); got != 0 { + t.Fatalf("dry-run 之后 %s 的 sensitive = %d, want 0", id, got) + } + } +} + +// TestBackfillSensitive_幂等且不动updatedAt 是回填的主用例:标对、标全、 +// 重跑无副作用、且不碰 updated_at。 +func TestBackfillSensitive_幂等且不动updatedAt(t *testing.T) { + t.Parallel() + ctx := context.Background() + s := newTestStore(t) + seedSensitiveFixture(t, s) + + report, err := BackfillSensitive(ctx, s.db, false) + if err != nil { + t.Fatalf("BackfillSensitive: %v", err) + } + if report.Marked != 3 { + t.Fatalf("Marked = %d, want 3", report.Marked) + } + if report.Matched != 3 || report.AlreadyFlagged != 0 { + t.Fatalf("Matched/AlreadyFlagged = %d/%d, want 3/0(首跑前没有任何一行已标)", + report.Matched, report.AlreadyFlagged) + } + for _, id := range []string{"legacy-addr", "legacy-name", "legacy-persona"} { + if got := readSensitiveCell(t, s.db, id); got != 1 { + t.Fatalf("%s 的 sensitive = %d, want 1", id, got) + } + } + if got := readSensitiveCell(t, s.db, "legacy-habit"); got != 0 { + t.Fatalf("习惯行被误标 sensitive = %d, want 0", got) + } + + // updated_at 必须一个字节都没变(打标不是活跃度事件)。 + var updated string + if err := s.db.QueryRowContext(ctx, + `SELECT updated_at FROM memory_items WHERE id = 'legacy-addr'`).Scan(&updated); err != nil { + t.Fatalf("read updated_at: %v", err) + } + if updated != "2026-10-01T10:00:00.000000000Z" { + t.Fatalf("回填改写了 updated_at = %q(不许动它:会扰动排序与衰减基准)", updated) + } + + // 幂等:重跑只补标。 + again, err := BackfillSensitive(ctx, s.db, false) + if err != nil { + t.Fatalf("BackfillSensitive(第二次): %v", err) + } + if again.Marked != 0 || again.Matched != 3 || again.AlreadyFlagged != 3 { + t.Fatalf("重跑 = Marked %d / Matched %d / Already %d, want 0/3/3(必须幂等)", + again.Marked, again.Matched, again.AlreadyFlagged) + } +} + +// TestBackfillSensitive_候选池收窄 是验收口径:受限车道候选池(active 且 +// scope ∈ {user, project} 且 sensitive=0)在回填后按标记条数下降。 +func TestBackfillSensitive_候选池收窄(t *testing.T) { + t.Parallel() + ctx := context.Background() + s := newTestStore(t) + seedSensitiveFixture(t, s) + + before := mustCandidateCount(t, ctx, s) + report, err := BackfillSensitive(ctx, s.db, false) + if err != nil { + t.Fatalf("BackfillSensitive: %v", err) + } + after := mustCandidateCount(t, ctx, s) + + if report.CandidatesBefore != before || report.CandidatesAfter != after { + t.Fatalf("报告候选池 = %d→%d, 实际 = %d→%d(报告必须能当验收证据)", + report.CandidatesBefore, report.CandidatesAfter, before, after) + } + // 前后各 3 条共享域行(2 user + 1 project),回填标掉 2 条(花溪 / 叶酱)。 + if before != 3 || after != 1 { + t.Fatalf("候选池 = %d→%d, want 3→1", before, after) + } + if report.ByPhrase["住址"] != 1 || report.ByPhrase["私称"] != 1 || report.ByPhrase["作息"] != 1 { + t.Fatalf("逐短语计数 = %v, want 每条各 1", report.ByPhrase) + } +} + +// mustCandidateCount 取受限车道候选池规模(与回填报告同一个查询)。 +func mustCandidateCount(t *testing.T, ctx context.Context, s *Store) int { + t.Helper() + n, err := countSharedCandidates(ctx, s.db) + if err != nil { + t.Fatalf("count shared candidates: %v", err) + } + return n +} diff --git a/internal/runtime/memory/sensitive_migration_test.go b/internal/runtime/memory/sensitive_migration_test.go new file mode 100644 index 0000000..80004ba --- /dev/null +++ b/internal/runtime/memory/sensitive_migration_test.go @@ -0,0 +1,188 @@ +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) + } +} diff --git a/internal/runtime/memory/sensitive_restricted_test.go b/internal/runtime/memory/sensitive_restricted_test.go new file mode 100644 index 0000000..e62df82 --- /dev/null +++ b/internal/runtime/memory/sensitive_restricted_test.go @@ -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)) + } +} diff --git a/internal/runtime/memory/sensitive_schema.go b/internal/runtime/memory/sensitive_schema.go new file mode 100644 index 0000000..e51643a --- /dev/null +++ b/internal/runtime/memory/sensitive_schema.go @@ -0,0 +1,46 @@ +package memory + +import ( + "context" + "database/sql" + "fmt" + "strings" +) + +// 本文件是「敏感标记」轴(迁移 0028)的 schema 前滚:给**既有**的 memory_items +// 补 `sensitive` 列。手法与 retention_schema.go / temporal_schema.go 逐字同族, +// 原因也一样:`createMemoryItemsTable` 是 `CREATE TABLE IF NOT EXISTS`,只对 +// **新库**生效,既有部署的表不会自己长出新列;而记忆存储必须自包含、能在任意 +// *sql.DB 上使用,所以这条前滚不能只靠 `internal/db/migrations`。 +// +// 🔴 与迁移的分工(两份真源,必须同形):权威机制是 +// `internal/db/migrations/0028_memory_sensitive`;这里是「任意 *sql.DB」的前滚。 +// 启动顺序是「先 MigrateUp、后 NewStore」(cmd/cyrene/wire.go),因此生产库里 +// 迁移先落地、这里走到 `duplicate column name` 是**预期路径**。 +// +// 默认值 0(= 非敏感):既有行因此**行为逐字不变**(受限车道照旧看得到它们), +// 本前滚刻意**不做**任何回填判定 —— 回填是独立的一次性数据动作 +// (`BackfillSensitive`,sensitive_backfill.go):把「结构前滚」与「给历史数据 +// 打标」分成两步,才能使两者的验收互不牵连(前滚永远幂等且零副作用;回填可 +// dry-run、可重跑、可只跑一次)。 +// +// 本列**不建索引**:受限车道的过滤在内存里做(候选集本来就已 `status` + 时效 + +// scope 过滤过,行数在千级),而「按 sensitive 取样巡检」是人工低频查询。将来 +// 若真出现按它筛的读路径,两条真源(迁移与本文件)一起加。 + +// sensitiveColumn 是被测列名(迁移与自举 DDL 里的字面量)。 +const sensitiveColumn = "sensitive" + +// ensureSensitiveSchema 幂等地给 memory_items 补上敏感标记列(列缺失时 ALTER, +// 已存在时忽略 SQLite 的 "duplicate column name" —— 那是预期路径,不是错误)。 +func ensureSensitiveSchema(ctx context.Context, db *sql.DB) error { + _, err := db.ExecContext(ctx, `ALTER TABLE memory_items + ADD COLUMN sensitive INTEGER NOT NULL DEFAULT 0`) + if err == nil { + return nil + } + if strings.Contains(err.Error(), "duplicate column name") { + return nil + } + return fmt.Errorf("memory: roll forward sensitive column: %w", err) +} diff --git a/internal/runtime/memory/sensitive_test.go b/internal/runtime/memory/sensitive_test.go new file mode 100644 index 0000000..bbe5bc5 --- /dev/null +++ b/internal/runtime/memory/sensitive_test.go @@ -0,0 +1,170 @@ +package memory + +import ( + "context" + "testing" +) + +// 本文件钉住「敏感标记」这根轴(迁移 0028,切片 M63)在**数据层**的四条: +// +// 1. 白名单判据的方向性:已实证私事命中、习惯类不命中(审计 §2 的实测短语); +// 2. SharedVisible 是作用域与敏感度的合取(受限车道唯一判据); +// 3. 写入侧兜底:白名单命中的内容经 Store.Add 落库后 sensitive=1,且**只加不撤**; +// 4. 15 列读回路:sensitive 在四条列表读路径(Get / ListByPersona / ListScope / +// List / ListProject)上都读得回来 —— 少同步一处 SELECT,这里就红。 +// +// 删掉实现(sensitive 列、resolveSensitive、scanItem 的第 15 列、SharedVisible 的 +// 判据)会分别让 1/2/3/4 变红;这正是本文件存在的意义。 + +// TestIsPrivateContent_判据方向 钉住白名单的两个方向:主人的私事命中,习惯与 +// 普通词不命中。反向用例(「住手」「喜欢睡懒觉的猫」)是**刻意**的:泛关键词 +// (住 / 睡)会把它们一起标掉,那正是本片拒绝的误标方向(sensitive.go 理由 2)。 +func TestIsPrivateContent_判据方向(t *testing.T) { + t.Parallel() + for _, content := range []string{ + "用户不在上海上班,目前所在城市为花溪(贵阳花溪区一带)", + "主人希望被称呼为「叶酱」", + "主人有上班日程,工作日需要早起,助手提醒早睡", + } { + if !IsPrivateContent(content) { + t.Fatalf("IsPrivateContent(%q) = false, want true(已实证私事必须命中)", content) + } + } + for _, content := range []string{ + "用户喜欢冰美式", + "住手!不要再改了", + "用户喜欢睡懒觉的猫,周末会看猫的视频", + "Cyrene 用 Go 编写,项目编号规则是 CY-日期-序号", + } { + if IsPrivateContent(content) { + t.Fatalf("IsPrivateContent(%q) = true, want false(习惯与普通词不许误标)", content) + } + } +} + +// TestSharedVisible_作用域与敏感度合取 钉住受限车道唯一判据的矩阵: +// 只有「共享域 且 未标敏感」才可注入;persona 域无论标没标都不可注入 +// (它本来就是主人私有的角色日常,design/03 §3/§5)。 +func TestSharedVisible_作用域与敏感度合取(t *testing.T) { + t.Parallel() + cases := []struct { + name string + item MemoryItem + want bool + }{ + {"user+非敏感", MemoryItem{Scope: ScopeUser}, true}, + {"project+非敏感", MemoryItem{Scope: ScopeProject}, true}, + {"user+敏感", MemoryItem{Scope: ScopeUser, Sensitive: true}, false}, + {"project+敏感", MemoryItem{Scope: ScopeProject, Sensitive: true}, false}, + {"persona+非敏感", MemoryItem{Scope: ScopePersona}, false}, + {"persona+敏感", MemoryItem{Scope: ScopePersona, Sensitive: true}, false}, + } + for _, c := range cases { + if got := c.item.SharedVisible(); got != c.want { + t.Fatalf("%s: SharedVisible() = %v, want %v", c.name, got, c.want) + } + } +} + +// TestAdd_私事按白名单补标 钉住写入侧兜底:模型(或人工调用方)漏标时,白名单 +// 命中的内容落库后必须 sensitive=1;而 habit 类内容必须保持 0(否则冻结的 +// 「用户习惯可查」裁决会被这次改动悄悄推翻)。 +func TestAdd_私事按白名单补标(t *testing.T) { + t.Parallel() + s := newTestStore(t) + + private := baseItem() + private.Scope = ScopeUser + private.Content = "用户居住在花溪(贵阳花溪区),不在上海工作" + storedPrivate := addItem(t, s, private) + if !storedPrivate.Sensitive { + t.Fatal("白名单命中的条目落库后 Sensitive = false, want true(写入侧兜底没生效)") + } + + habit := baseItem() + habit.Scope = ScopeUser + habit.Content = "用户喜欢冰美式" + storedHabit := addItem(t, s, habit) + if storedHabit.Sensitive { + t.Fatal("习惯类条目被误标成敏感 —— 受限车道会平白丢掉合法习惯") + } + + // 只加不撤:调用方显式标 true 的习惯类内容必须保持 true(撤标是人工动作)。 + explicit := baseItem() + explicit.Scope = ScopeUser + explicit.Content = "用户喜欢手冲咖啡" + explicit.Sensitive = true + if got := addItem(t, s, explicit); !got.Sensitive { + t.Fatal("显式 sensitive=true 被改写成 false —— 写入路径不许撤标") + } +} + +// TestSensitive_十五条读路径回读 钉住「15 列读回路」:sensitive 必须在每一条 +// 走 scanItem 的读路径上都读得回来。任一处 SELECT 少一列,这里 first 就会扫错列 +// (报错或把别的列当成 sensitive),不会有静默通过的余地。 +func TestSensitive_十五条读路径回读(t *testing.T) { + t.Parallel() + ctx := context.Background() + s := newTestStore(t) + + personaPrivate := baseItem() + personaPrivate.Content = "主人只愿在私下被叫作「叶酱」" + stored := addItem(t, s, personaPrivate) + + userPrivate := baseItem() + userPrivate.Scope = ScopeUser + userPrivate.Content = "主人现居贵阳花溪区,不在上海" + userStored := addItem(t, s, userPrivate) + + projectPlain := baseItem() + projectPlain.Scope = ScopeProject + projectPlain.ProjectID = "proj-1" + projectPlain.Content = "Cyrene 用 Go 编写" + projectStored := addItem(t, s, projectPlain) + + byID, err := s.Get(ctx, stored.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if !byID.Sensitive { + t.Fatal("Get 读回的 sensitive = false, want true(Get 的 SELECT 少一列?)") + } + byPersona, err := s.ListByPersona(ctx, personaPrivate.PersonaID) + if err != nil { + t.Fatalf("ListByPersona: %v", err) + } + if !containsSensitive(byPersona, stored.ID) { + t.Fatal("ListByPersona 读回的条目丢掉了 sensitive 位") + } + byScope, err := s.ListScope(ctx, ScopeUser, personaPrivate.PersonaID) + if err != nil { + t.Fatalf("ListScope: %v", err) + } + if !containsSensitive(byScope, userStored.ID) { + t.Fatal("ListScope 读回的条目丢掉了 sensitive 位") + } + byProject, err := s.ListProject(ctx, "proj-1") + if err != nil { + t.Fatalf("ListProject: %v", err) + } + if containsSensitive(byProject, projectStored.ID) { + t.Fatal("ListProject 把非敏感的条目读成了敏感") + } + all, err := s.List(ctx) + if err != nil { + t.Fatalf("List: %v", err) + } + if !containsSensitive(all, stored.ID) || containsSensitive(all, projectStored.ID) { + t.Fatal("List 读回的 sensitive 位与落库值不一致") + } +} + +// containsSensitive 报告 items 中 id 那条是否为 sensitive(找不到即失败)。 +func containsSensitive(items []MemoryItem, id string) bool { + for _, it := range items { + if it.ID == id { + return it.Sensitive + } + } + return false +} diff --git a/internal/runtime/memory/store.go b/internal/runtime/memory/store.go index 0c0ca7c..809c2d4 100644 --- a/internal/runtime/memory/store.go +++ b/internal/runtime/memory/store.go @@ -29,9 +29,7 @@ package memory import ( "context" - "crypto/rand" "database/sql" - "encoding/hex" "encoding/json" "errors" "fmt" @@ -124,7 +122,7 @@ type MemoryItem struct { // - 但 `Get` / `List*` 一律不 SELECT 该列(读侧闸是纯 SQL,见 timegate.go), // 故**从库里读回来的**条目 ExpiresAt 恒为零值。 // - // 要读回时效需同步 14 列 SELECT 与 scanItem,并连带 tidy/preinsert 的内部召回 + // 要读回时效需同步 15 列 SELECT 与 scanItem,并连带 tidy/preinsert 的内部召回 // SQL —— 那是改扫描口径,不在本切片授权范围。 ExpiresAt time.Time @@ -146,10 +144,20 @@ type MemoryItem struct { // // ⚠️ **与 ExpiresAt 同样是「只写不读」的列**:`Add` 把它写进 `temporal` 列, // 但四条列表读路径的 SELECT 与 scanItem 都不读它 —— 改扫描口径要动全部读点 - // (rules.go / store_list.go / tidy_store.go / preinsert_store.go,共 14→15 列), - // 那是另一件事,不在本片授权范围。要按时间性位巡检/回填时直读该列即可; - // 写入侧的校验/编码见 store_temporal.go。 + // (rules.go / store_list.go / tidy_store.go / preinsert_store.go,现为 15 列, + // 加它就是 16 列),那是另一件事,不在本片授权范围。要按时间性位巡检/回填时 + // 直读该列即可;写入侧的校验/编码见 store_temporal.go。 Temporal TemporalKind + + // Sensitive 是**敏感标记**(迁移 0028、sensitive.go):true = 这条记忆是主人的 + // 私事(住址 / 私称 / 作息 ……),因此**受限车道**(群聊 / 陌生人)的召回过滤器 + // 会把整条排除在候选池外。 + // + // 🔴 它是**数据层标记,不是权限系统**:主人车道(非受限环境)照常读到全部记忆 + // (含 sensitive=1),没有任何读路径因它拒绝主人;它只影响 + // `MemoryItem.SharedVisible` 这一条判据。谁是主人仍只由 actor 元数据 + 白名单 + // + 策略引擎决定(AGENTS §9)。判据与理由见 sensitive.go 头注。 + Sensitive bool } // 区分 memory 包各类失败的哨兵错误。每个都在 @@ -265,6 +273,11 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) if err := ensureTemporalSchema(ctx, db); err != nil { return nil, err } + // 敏感标记(迁移 0028):同上,只补列、不回填 —— 历史数据的打标是独立的一次性 + // 动作(BackfillSensitive),使结构前滚保持零副作用(见 sensitive_schema.go)。 + if err := ensureSensitiveSchema(ctx, db); err != nil { + return nil, err + } if err := normalizeMemoryTimestamps(ctx, db); err != nil { return nil, err } @@ -309,6 +322,11 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) // 不读这一位,因此档位/有效期与「没有这一位」逐字相同;列值的作用是**观测**—— // 事后能按它分组统计、抽样看模型把哪些行标成了 `one_off`(这是将来决定要不要接线 // 的唯一证据来源,见 temporal.go 头注的查询)。 +// +// 🆕 切片 M63:`item.Sensitive`(敏感标记,迁移 0028)也随**这一笔** INSERT 落下。 +// 它**不参与任何判定**:档位 / 时效 / 读路径的可见性集合与「没有这一位」逐字相同 +// —— 唯一的消费者是受限车道的过滤判据(sensitive.go 的 SharedVisible),而主人 +// 车道根本不过那道判据。写入侧另有一条**白名单兜底**(见下面 Add 内的判定)。 func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) { if err := item.normalize(); err != nil { return MemoryItem{}, err @@ -317,6 +335,16 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) { if item, err = applyTimestamps(item); err != nil { return MemoryItem{}, err } + // 🆕 切片 M63(迁移 0028):敏感标记的**写入侧兜底** —— 判据靠代码,不靠提示词 + // (AGENTS §9)。提取器的 `sensitive` 位是**类目级**判断(新私事的措辞无法穷举, + // 见 llmextractor.go 的正反例);这里是**已实证短语**的确定性兜底 + // (IsPrivateContent,判据与理由见 sensitive.go)。合成只在 resolveSensitive + // 一处做(「或」,只加标不撤标),提取器产出侧与这里走同一个函数。 + if resolved := resolveSensitive(item.Sensitive, item.Content); resolved != item.Sensitive { + item.Sensitive = resolved + s.log.Debug("memory: sensitive flag set by private-fact whitelist", + "id", item.ID, "scope", string(item.Scope)) + } // 🔴 档位判定(唯一入口):把结果写回条目,闸与异步审查因此**只读**这一位, // 不再各自跑一遍留存规则(见 preinsert_judge.go 的 defer 分支)。 verdict, err := resolveRetention(item) @@ -337,18 +365,8 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) { plan := s.planIngest(ctx, item) item.ExpiresAt = plan.ExpiresAt - _, err = s.db.ExecContext(ctx, `INSERT INTO memory_items - (id, persona_id, role_id, project_id, scope, kind, content, tags, confidence, - source_conversation, created_at, updated_at, decay_weight, status, merged_into, - expires_at, retention_tier, temporal) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - item.ID, item.PersonaID, item.RoleID, item.ProjectID, string(item.Scope), string(item.Kind), - item.Content, string(tagsJSON), item.Confidence, item.SourceConversation, - formatTime(item.CreatedAt), formatTime(item.UpdatedAt), item.DecayWeight, - plan.insertStatus(), plan.absorbedID(), plan.expiresAt(), plan.retention(), - item.temporalColumn()) - if err != nil { - return MemoryItem{}, fmt.Errorf("memory: insert item: %w", err) + if err := s.insertItem(ctx, item, tagsJSON, plan); err != nil { + return MemoryItem{}, err } s.finishIngest(ctx, item, plan) if plan.Action != ingestPlanAbsorbNew { @@ -460,37 +478,6 @@ func (it *MemoryItem) validate() error { return nil } -// applyTimestamps 分配 id 与缺失的 CreatedAt/UpdatedAt 值。 -// 仅在无法生成新 id 时返回错误。 -func applyTimestamps(item MemoryItem) (MemoryItem, error) { - now := time.Now().UTC() - if item.ID == "" { - id, err := newID() - if err != nil { - return MemoryItem{}, err - } - item.ID = id - } - if item.CreatedAt.IsZero() { - item.CreatedAt = now - } - if item.UpdatedAt.IsZero() { - item.UpdatedAt = now - } - return item, nil -} - -// newID 返回一个 128 位密码学随机 id,十六进制编码。它避开 -// UUID 依赖,使本包保持自包含。rand.Read 只在 -// 平台熵源损坏时失败,因此错误向上传播。 -func newID() (string, error) { - b := make([]byte, 16) - if _, err := rand.Read(b); err != nil { - return "", fmt.Errorf("memory: generate id: %w", err) - } - return hex.EncodeToString(b), nil -} - // discardLogger 在 log 非 nil 时返回 log,否则返回一个不写任何地方的 logger。 func discardLogger(log *slog.Logger) *slog.Logger { if log != nil { diff --git a/internal/runtime/memory/store_insert.go b/internal/runtime/memory/store_insert.go new file mode 100644 index 0000000..440d5e0 --- /dev/null +++ b/internal/runtime/memory/store_insert.go @@ -0,0 +1,76 @@ +package memory + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "time" +) + +// 本文件是 memory_items 的**写入侧行装配**(唯一 INSERT 语句、id 与时间戳分配), +// 从 store.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行)与「模块按职责拆 +// 源文件」那条规范 —— 口径逐字未变。 +// +// 与 store_rows.go(读侧行映射)成对:那里是「列 → MemoryItem」,这里是 +// 「MemoryItem → 列」。两边的列序必须**同一套**:scanItem 读 15 列, +// 本文件的 INSERT 也写 15 列(`… temporal, sensitive` 收尾)。 + +// insertMemoryItemSQL 是全仓唯一的一条 memory_items INSERT。列序与 store_rows.go +// 的 scanItem 同源(同一张表的同一套列),新增列一律追加在末尾。 +// +// 🔴 本语句**不经** `status` / `expires_at` / `retention_tier` / `temporal` / +// `sensitive` 的默认值路径:前四者由 `ingestGatePlan` 与条目自身给出(档位与时效 +// 是**同一个判定**的产物,见 store.go 的 Add),`sensitive` 由条目给出(提取器判据 +// + 白名单兜底,见 sensitive.go),一句话都写死在这里,别处不再拼第二份。 +const insertMemoryItemSQL = `INSERT INTO memory_items + (id, persona_id, role_id, project_id, scope, kind, content, tags, confidence, + source_conversation, created_at, updated_at, decay_weight, status, merged_into, + expires_at, retention_tier, temporal, sensitive) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + +// insertItem 落一行 memory_items。调用方(Add)负责在此前完成规范化、时间戳与 +// 档位判定;这里只做「条目 → 列」的映射。 +func (s *Store) insertItem(ctx context.Context, item MemoryItem, tagsJSON []byte, plan ingestGatePlan) error { + _, err := s.db.ExecContext(ctx, insertMemoryItemSQL, + item.ID, item.PersonaID, item.RoleID, item.ProjectID, string(item.Scope), string(item.Kind), + item.Content, string(tagsJSON), item.Confidence, item.SourceConversation, + formatTime(item.CreatedAt), formatTime(item.UpdatedAt), item.DecayWeight, + plan.insertStatus(), plan.absorbedID(), plan.expiresAt(), plan.retention(), + item.temporalColumn(), item.sensitiveColumn()) + if err != nil { + return fmt.Errorf("memory: insert item: %w", err) + } + return nil +} + +// applyTimestamps 分配 id 与缺失的 CreatedAt/UpdatedAt 值。 +// 仅在无法生成新 id 时返回错误。 +func applyTimestamps(item MemoryItem) (MemoryItem, error) { + now := time.Now().UTC() + if item.ID == "" { + id, err := newID() + if err != nil { + return MemoryItem{}, err + } + item.ID = id + } + if item.CreatedAt.IsZero() { + item.CreatedAt = now + } + if item.UpdatedAt.IsZero() { + item.UpdatedAt = now + } + return item, nil +} + +// newID 返回一个 128 位密码学随机 id,十六进制编码。它避开 +// UUID 依赖,使本包保持自包含。rand.Read 只在 +// 平台熵源损坏时失败,因此错误向上传播。 +func newID() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("memory: generate id: %w", err) + } + return hex.EncodeToString(b), nil +} diff --git a/internal/runtime/memory/store_list.go b/internal/runtime/memory/store_list.go index bc0a52f..8f48864 100644 --- a/internal/runtime/memory/store_list.go +++ b/internal/runtime/memory/store_list.go @@ -33,7 +33,7 @@ func (s *Store) Get(ctx context.Context, id string) (MemoryItem, error) { } row := s.db.QueryRowContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind, content, tags, confidence, source_conversation, created_at, updated_at, - decay_weight, retention_tier FROM memory_items WHERE id = ?`, id) + decay_weight, retention_tier, sensitive FROM memory_items WHERE id = ?`, id) item, err := scanItem(row) if errors.Is(err, sql.ErrNoRows) { return MemoryItem{}, ErrNotFound @@ -84,7 +84,7 @@ func (s *Store) listByPersona(ctx context.Context, personaID string, tiers []Ret now := timeGateNow() query := `SELECT id, persona_id, role_id, project_id, scope, kind, content, tags, confidence, source_conversation, created_at, updated_at, - decay_weight, retention_tier FROM memory_items WHERE persona_id = ? AND status = ?` + decay_weight, retention_tier, sensitive FROM memory_items WHERE persona_id = ? AND status = ?` args := []any{personaID, string(tidyStatusActive)} query, args, err := retentionTierList(query, args, tiers) if err != nil { @@ -129,7 +129,7 @@ func (s *Store) listAll(ctx context.Context, tiers []RetentionTier) ([]MemoryIte now := timeGateNow() query := `SELECT id, persona_id, role_id, project_id, scope, kind, content, tags, confidence, source_conversation, created_at, updated_at, - decay_weight, retention_tier FROM memory_items WHERE status = ?` + decay_weight, retention_tier, sensitive FROM memory_items WHERE status = ?` args := []any{string(tidyStatusActive)} query, args, err := retentionTierList(query, args, tiers) if err != nil { diff --git a/internal/runtime/memory/store_rows.go b/internal/runtime/memory/store_rows.go index 05d3b72..28a231f 100644 --- a/internal/runtime/memory/store_rows.go +++ b/internal/runtime/memory/store_rows.go @@ -46,21 +46,27 @@ func scanItems(rows *sql.Rows, op string) ([]MemoryItem, error) { // retention_tier 是**第 14 列**(迁移 0026):它与 `kind` 是两根独立的轴, // 因此这里只做「读出来」,不参与任何推导。空串原样成为 // RetentionTierUnset(= 尚未分型),绝不被折成某一档。 +// +// sensitive 是**第 15 列**(迁移 0028):SQLite 的 INTEGER 0/1 在这里折回 bool。 +// 它被读出来的**唯一**理由是要给受限车道的可见性判据用(SharedVisible)—— +// 条目不带这一位,过滤就无从过滤;它不参与任何排名/判定。 func scanItem(sc rowScanner) (MemoryItem, error) { var ( it MemoryItem scope, kind, tier string tagsJSON string + sensitive int createdAt, updatedAt string ) if err := sc.Scan(&it.ID, &it.PersonaID, &it.RoleID, &it.ProjectID, &scope, &kind, &it.Content, &tagsJSON, &it.Confidence, &it.SourceConversation, - &createdAt, &updatedAt, &it.DecayWeight, &tier); err != nil { + &createdAt, &updatedAt, &it.DecayWeight, &tier, &sensitive); err != nil { return MemoryItem{}, err } it.Scope = Scope(scope) it.Kind = Kind(kind) it.RetentionTier = RetentionTier(tier) + it.Sensitive = sensitive != 0 if err := json.Unmarshal([]byte(tagsJSON), &it.Tags); err != nil { return MemoryItem{}, fmt.Errorf("memory: decode tags: %w", err) } diff --git a/internal/runtime/memory/store_schema.go b/internal/runtime/memory/store_schema.go index a528d4b..f150fe7 100644 --- a/internal/runtime/memory/store_schema.go +++ b/internal/runtime/memory/store_schema.go @@ -28,7 +28,8 @@ CREATE TABLE IF NOT EXISTS memory_items ( valid_to TEXT NOT NULL DEFAULT '', expires_at TEXT NOT NULL DEFAULT '', retention_tier TEXT NOT NULL DEFAULT '', - temporal TEXT NOT NULL DEFAULT '' + temporal TEXT NOT NULL DEFAULT '', + sensitive INTEGER NOT NULL DEFAULT 0 )` const createMemoryItemsPersonaIndex = ` @@ -46,9 +47,10 @@ CREATE INDEX IF NOT EXISTS idx_memory_items_persona_id_created_at // (retention_migration_test.go 钉住这条同形,少一列就是「新库有、老库无」的漂移)。 // // ⚠️ 0027 之后 `retention_tier` **不再是最后一列**:`temporal`(迁移 0027)追加在 -// 它之后,两条路径的尾序都是 `… expires_at, retention_tier, temporal`。新列一律 -// 追加在末尾这条口径不变,只是「末尾」会随迁移推进(那条同形断言比对**列集合**, -// 另外对尾序做一条显式断言)。 +// 它之后,0028 又把 `sensitive` 追加在 `temporal` 之后。两条路径的尾序都是 +// `… expires_at, retention_tier, temporal, sensitive`。新列一律追加在末尾这条口径 +// 不变,只是「末尾」会随迁移推进(那条同形断言比对**列集合**,另外对尾序做一条 +// 显式断言)。 const createMemoryItemsRetentionTierIndex = ` CREATE INDEX IF NOT EXISTS idx_memory_items_retention_tier ON memory_items (retention_tier)` diff --git a/internal/runtime/memory/temporal_migration_test.go b/internal/runtime/memory/temporal_migration_test.go index 369cfa2..dc8853c 100644 --- a/internal/runtime/memory/temporal_migration_test.go +++ b/internal/runtime/memory/temporal_migration_test.go @@ -148,14 +148,17 @@ func TestMigration0027ShapeMatchesStoreBootstrap(t *testing.T) { } } bootCols := tableColumns(t, bootstrapped, "memory_items") - if len(bootCols) < 2 { - t.Fatalf("自举建的 memory_items 列数 = %d, want ≥ 2", len(bootCols)) + if len(bootCols) < 3 { + t.Fatalf("自举建的 memory_items 列数 = %d, want ≥ 3", len(bootCols)) } - tail := bootCols[len(bootCols)-2:] + // 0028(`sensitive`)之后尾序是 `… retention_tier, temporal, sensitive`: + // 新列一律追加在末尾,因此这条断言的「末尾」也随迁移推进(见 store_schema.go 头注)。 + tail := bootCols[len(bootCols)-3:] if !strings.Contains(tail[0], "|"+retentionTierColumn+"|") || - !strings.Contains(tail[1], "|"+temporalColumn+"|") { - t.Fatalf("自举建的 memory_items 尾两列 = %v, want …, %s, %s", - tail, retentionTierColumn, temporalColumn) + !strings.Contains(tail[1], "|"+temporalColumn+"|") || + !strings.Contains(tail[2], "|"+sensitiveColumn+"|") { + t.Fatalf("自举建的 memory_items 尾三列 = %v, want …, %s, %s, %s", + tail, retentionTierColumn, temporalColumn, sensitiveColumn) } } diff --git a/internal/runtime/memory/tidy_store.go b/internal/runtime/memory/tidy_store.go index d2f926a..a3553ed 100644 --- a/internal/runtime/memory/tidy_store.go +++ b/internal/runtime/memory/tidy_store.go @@ -50,7 +50,7 @@ const tidyStatusActive = "active" // 查询(listTidyPartitionsSQL / countTidyPartitionItemsSQL)是同一裁定的实例。 const listTidyPartitionItemsSQL = `SELECT id, persona_id, role_id, project_id, scope, kind, content, tags, confidence, source_conversation, created_at, updated_at, - decay_weight, retention_tier FROM memory_items + decay_weight, retention_tier, sensitive FROM memory_items WHERE persona_id = ? AND scope = ? AND project_id = ? AND status = ? ORDER BY created_at DESC, rowid DESC LIMIT ?`