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