Files
Cyrene_v2/internal/runtime/memory/sensitive.go
T
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

112 lines
6.2 KiB
Go
Raw 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 "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
}
}