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

+111
View File
@@ -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
}
}