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 等价)。
69 lines
5.1 KiB
SQL
69 lines
5.1 KiB
SQL
-- 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);
|