Files
Cyrene_v2/internal/runtime/memory/rules.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

198 lines
8.4 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 (
"context"
"database/sql"
"errors"
"fmt"
"strings"
)
// 作用域访问控制哨兵错误。当角色试图执行 design §3 规则
// 不允许的写入时返回。
var (
// ErrReadOnly 在角色试图写入或删除
// 不属于它的角色作用域记忆条目时返回。按 design §3,其他角色
// 对角色日常记忆只读。
ErrReadOnly = errors.New("memory: read-only: persona does not own this item")
// ErrMissingProjectID 在项目作用域查询的 id 为空时返回。
ErrMissingProjectID = errors.New("memory: project_id is required")
)
// canWrite 报告 actorID 按 design §3 作用域规则是否可以写入 item:
//
// - ScopeUser:任何角色都可写(用户习惯全局共享)。
// - ScopePersona:只有归属角色可写(其他角色只读)。
// - ScopeProject:任何角色都可写(项目记忆人人可写)。
//
// 调用方必须已校验 item.Scope;非法作用域对任何人
// 都不可写。
func canWrite(actorID string, it MemoryItem) bool {
switch it.Scope {
case ScopeUser, ScopeProject:
return true
case ScopePersona:
return it.PersonaID == actorID
default:
return false
}
}
// AddScoped 以 actorID 的权限持久化 item,按 persona_id + scope 施加
// design §3 写规则。actorID 为空返回 ErrMissingPersona。
// item.PersonaID 为空时用 actorID 填充它,因此角色总是
// 记录自身的来源;归属其他角色的角色作用域条目
// 返回 ErrReadOnly。用户与项目作用域的条目任何角色都可写。
func (s *Store) AddScoped(ctx context.Context, actorID string, item MemoryItem) (MemoryItem, error) {
if strings.TrimSpace(actorID) == "" {
return MemoryItem{}, ErrMissingPersona
}
if item.PersonaID == "" {
item.PersonaID = actorID
}
if !item.Scope.valid() {
return MemoryItem{}, ErrInvalidScope
}
if !canWrite(actorID, item) {
return MemoryItem{}, ErrReadOnly
}
return s.Add(ctx, item)
}
// ListScope 返回 personaID 可见的该作用域全部条目,最新的在前,
// 施加 design §3 读规则(rowid 打破 created_at 并列,使
// 同一时钟刻内写入的条目保持稳定的最新优先顺序):
//
// - ScopeUser:全部用户作用域条目(跨角色全局共享)。
// - ScopePersona:全部角色作用域条目。其他角色只读但
// 仍可读,因此角色在这里可以读到另一个角色的角色日常记忆。
// - ScopeProject:全部项目作用域条目(人人可读)。
//
// personaID 指明读取方角色,且必填,以便调用方即使在共享
// 作用域上也始终声明身份边界。是否允许受限上下文(群聊 / 陌生人,design §5)
// 接收这些内容仍由更高层策略决定;本方法只施加
// 基础读规则。
//
// 与 ListByPersona/List 同口径:只返回 status='active' 且过**时效闸**
// (expires_at / valid_to 均未到期)的行 —— 闸只有这里与 ListByPersona /
// ListProject / List 四处,检索路径经 Retriever.visibleCandidates 组合调用
// 本方法,因此自动获得同一判据(timegate.go 头注、design/03 §11.2)。
//
// `tiers` 的档位过滤见同族的 ListScopeTiers(签名不变的理由见
// store_list.go 的 ListByPersona 注释:网关接口钉着这批精确签名)。
func (s *Store) ListScope(ctx context.Context, scope Scope, personaID string) ([]MemoryItem, error) {
return s.listScope(ctx, scope, personaID, nil)
}
// ListScopeTiers 是 ListScope 的**可选档位过滤**版本(空 tiers = 不过滤;
// `RetentionTierUnset` = 只看尚未分型的行)。
func (s *Store) ListScopeTiers(ctx context.Context, scope Scope, personaID string, tiers ...RetentionTier) ([]MemoryItem, error) {
return s.listScope(ctx, scope, personaID, tiers)
}
// listScope 是 ListScope / ListScopeTiers 的共同实现(tiers 为空不过滤)。
func (s *Store) listScope(ctx context.Context, scope Scope, personaID string, tiers []RetentionTier) ([]MemoryItem, error) {
if strings.TrimSpace(personaID) == "" {
return nil, ErrMissingPersona
}
if !scope.valid() {
return nil, ErrInvalidScope
}
now := timeGateNow()
query := `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight, retention_tier, sensitive FROM memory_items WHERE scope = ? AND status = ?`
args := []any{string(scope), string(tidyStatusActive)}
query, args, err := retentionTierList(query, args, tiers)
if err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx, query+timeGateClause+`
ORDER BY created_at DESC, rowid DESC`, append(args, now, now)...)
if err != nil {
return nil, fmt.Errorf("memory: list scope items: %w", err)
}
return scanItems(rows, "scope item")
}
// ListProject 返回链接到 projectID 的全部记忆条目,最新的在前,
// rowid 打破 created_at 并列,使同一刻的写入保持稳定顺序。项目
// 作用域记忆携带一个 ProjectID,链到 projectindex
// 层级(design §3);这是深检索入口 —— 调用方把它与
// projectindex 存储结合,沿 项目索引 → 项目描述 → 章节内容 遍历。项目
// 记忆所有角色可读,因此不施加角色过滤。projectID 为空
// 返回 ErrMissingProjectID。与其余三条列表读路径同口径:只返回在册且过
// **时效闸**的行(timegate.go、design/03 §9.6/§11.2)。
//
// `tiers` 的档位过滤见同族的 ListProjectTiers —— 本方法虽然是深检索入口,但
// 「留多久」这根轴对项目记忆同样成立,因此一并支持。
func (s *Store) ListProject(ctx context.Context, projectID string) ([]MemoryItem, error) {
return s.listProject(ctx, projectID, nil)
}
// ListProjectTiers 是 ListProject 的**可选档位过滤**版本(空 tiers = 不过滤)。
func (s *Store) ListProjectTiers(ctx context.Context, projectID string, tiers ...RetentionTier) ([]MemoryItem, error) {
return s.listProject(ctx, projectID, tiers)
}
// listProject 是 ListProject / ListProjectTiers 的共同实现(tiers 为空不过滤)。
func (s *Store) listProject(ctx context.Context, projectID string, tiers []RetentionTier) ([]MemoryItem, error) {
if strings.TrimSpace(projectID) == "" {
return nil, ErrMissingProjectID
}
now := timeGateNow()
query := `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight, retention_tier, sensitive FROM memory_items WHERE project_id = ? AND status = ?`
args := []any{projectID, string(tidyStatusActive)}
query, args, err := retentionTierList(query, args, tiers)
if err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx, query+timeGateClause+`
ORDER BY created_at DESC, rowid DESC`, append(args, now, now)...)
if err != nil {
return nil, fmt.Errorf("memory: list project items: %w", err)
}
return scanItems(rows, "project item")
}
// DeleteScoped 删除给定 id 的条目,但仅当按 design §3 规则 actorID
// 可以写它。这在删除上施加角色只读边界:
// 角色可以删自己的角色作用域条目,以及任何用户或
// 项目作用域条目,但不能删另一个角色的角色日常记忆。id 为空返回
// ErrNotFound;改动他人的角色作用域条目返回 ErrReadOnly。
func (s *Store) DeleteScoped(ctx context.Context, actorID, id string) error {
if strings.TrimSpace(actorID) == "" {
return ErrMissingPersona
}
item, err := s.Get(ctx, id)
if err != nil {
return fmt.Errorf("memory: delete scoped load: %w", err)
}
if !canWrite(actorID, item) {
return ErrReadOnly
}
return s.Delete(ctx, id)
}
// ensureProjectIDColumn 在 memory_items 表创建于 M4 项目索引
// (design §3)存在之前时把它前滚:缺失时补加 project_id 列,
// 已存在时忽略重复列错误。它是
// SQLite 特有的(记忆存储基于 SQLite)且幂等,因此 NewStore
// 可在全新 schema 或既有 schema 上调用。
func ensureProjectIDColumn(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `ALTER TABLE memory_items
ADD COLUMN project_id TEXT NOT NULL DEFAULT ''`)
if err == nil {
return nil
}
// 当该列已存在(例如由 NewStore 中的 CREATE TABLE 创建,
// 或由上一次运行创建)时,SQLite 报 "duplicate column name: project_id"。
if strings.Contains(err.Error(), "duplicate column name") {
return nil
}
return fmt.Errorf("memory: roll forward project_id column: %w", err)
}