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 } }