第四阶段同步·第 2 步的安全审查(附录)

审查对象:e2d6c98c(个人载荷存储、迁移 14、清除边界)。审查方式:Claude 安排的独立审查,读代码并用合成探针复现;没有修改仓库。新增的四组测试(personal-payloads、personal-upgrade、personal-purge、ownership)全部通过,没有跳过。

确认无误的部分

  • hub_append_only() 的豁免只能由迁移所有者在迁移 14 记入之前使用;运行角色、之后的会话、只设置自定义参数都用不了;每行正文只能替换一次,其他列必须不变。CREATE OR REPLACE 没有丢掉原有行为。
  • hub_purge_personal 只删除本人的行;团队知识与团队记忆保留;运行角色不能写 personal_purges。
  • 清除后,所有读取路径都拿不到被清除的内容;“缺失与无权相同”成立。

需要修正(第 4 步之前,单独提交)

  1. 严重:团队空间挂载了产品知识时,升级后服务无法启动。
    • 位置:hub/records/objects.ts 的 protect(),与 hub/knowledge/spaces.ts 的团队版本树。
    • 原因:团队版本树把产品挂载的根作为子节点摘要,它是 tree_node 记录,不是对象文件;protect() 找不到文件就报“团队目录原件缺失”,退役日志是持久的,每次重启都失败,上一版本也因迁移 14 已应用而拒绝启动。
    • 修正:遍历团队树时跳过各版本的 products[].root;加一个带产品挂载的升级回归测试。
  2. 中:退役旧文件会删掉其他人仍在用的同一份字节。
    • 位置:objects.ts 的保留判定、迁移 14 的原件转换、artifacts.ts 的去重键。
    • 原因:保留判定只看团队树和部分记录种类,漏掉资源与变量定义、个人工作流的清单与文件、执行器定义、知识列表与投喂对象等持有者;个人工作流文件与控制台导入共用去重条件,迁移 14 可能把工作流的原件转成个人内容。
    • 修正:按精确字段在所有可能引用对象的记录种类里核对持有者,不用子串匹配;个人上传的去重键加入个人范围;被非知识记录引用的原件不转换、不退役。有任何其他持有者时保留全局文件,只移除个人的引用。
  3. 低:仅被“提到”的原件在清除后仍留在磁盘。 同上,用 sha256 字段精确比较,不用子串。
  4. 低:运行角色的几处缺口。
    • 在约束检查里要求:被绑定的原件必须有同一所有者的知识来源引用它。
    • 给 personal_payloads、personal_objects 加插入前触发器:拒绝已清除或不是“同步到 Hub”方式的代次。
    • personal_legacy_ready、personal_legacy_retired 不再给运行角色插入权限,改由所有者定义的函数在完成实际转移后写入。
    • 清除本人以外成员的授权检查仍在应用层(与其他写操作一致),在实施记录里写明。
  5. 低:清除后管理员仍能看到个人文件夹路径。 knowledge_share 回执里的个人路径属于个人内容:放进可清除的载荷,或在来源代次已清除时只显示“已清除的个人文件夹”。

一并处理的风险(未完全证实)

  • 首次启动时对每个退役摘要做全表子串扫描、重复遍历历史个人树:改为精确字段索引与已访问集合,给出大库的耗时上界。
  • 团队读取每次都加载全部团队来源(#clearedGrounds):改为按请求缓存或索引。
  • 删除文件后没有对目录做同步落盘就记为已退役:先 fsync 目录,再提交退役记录。
  • 清除后本人的控制台与 MCP 个人视图报 SYNC_MODE_LOCAL:控制台改为显示“仅保存在本机”的状态与切换入口,MCP 按现有规则返回空的个人范围。

修正完成后,在 kw-sync.md 的“做了的 / 验证”里记录每一项的处理与测试,然后再进入第 4 步。

复核(b3b97cce)

原有五项与四个疑点已修正;运行角色仍可按设计清除、恢复或收集任一成员的个人内容,授权检查在应用层,已在记录中写明。复核发现:

  1. 严重:迁移 15 的 hub_collect_personal_sync 可被运行角色借临时对象提权。
    • 原因:它以所有者身份执行(SECURITY DEFINER),search_path 写成 pg_catalog,public,没有把 pg_temp 放在最后;PostgreSQL 会先在 pg_temp 里找类型名,而函数声明了 removed text[]。
    • 复现:运行角色在自己的会话里建 pg_temp 下的同名域类型,域检查以表所有者身份执行,可以关掉 records 的只追加触发器、改写正文。
    • 影响面:平台自建的数据库已收回 PUBLIC 的临时对象权限,挡住了这条路;测试环境和外部管理的数据库没有挡住。
    • 修正:所有以所有者身份执行的函数,search_path 一律以 pg_temp 结尾;类型写全名(pg_catalog.text[]);把这个复现做成回归测试;授权运行角色或启动自检时,发现它有临时对象权限就拒绝。
  2. 策略:被运行引用的个人上传。 旧数据中被运行来源或其他非空间记录引用的个人上传在清除时保留,新数据中被运行引用的会被清除,前后不一致。
    • Claude 的决定:个人上传一旦被绑定为运行的依据,就已向那次运行的读者声明过,与团队副本一样属于独立持有者。清除时保留文件,只断开与个人空间的关联;新旧数据一致处理,并写入文档。
  3. 说明: 迁移 14 的 SQL 在 e2d6c98c 之后改过。只有应用过旧版本的数据库会拒绝启动;迁移 14 没有部署过,按审查结论允许修改。

第三轮复核(0ab652a8)

  • 临时对象提权已关闭:全部 12 个以所有者身份执行的函数,search_path 都以 pg_temp 结尾;授权和启动时都拒绝带临时对象权限的运行角色。用 11 种类型名做冒充探针,没有一次以所有者身份执行。
  • 运行引用的个人上传按决定保留,新旧数据一致。另一成员的运行经分享引用了本人的上传时,清除后字节同样保留,只有本人可读。这一点交所有者确认。
  • 第 4 步(a266c5e9)逐项检查无误:提交不会部分生效;过期的期望状态不能覆盖新头;冲突不会被悄悄解决;服务器总会重新核验;控制台、MCP 与同步写入都受代次与“仅保存在本机”守卫;缺失与无权相同。

需要修正:

  1. 严重(可用性):迁移 17 让每次记录读取都扫全表。
    • 原因:hub_read_records() 加了 SET search_path 子句,PostgreSQL 不再内联带 SET 子句的 SQL 函数。
    • 影响:get、getById、getMany、find、list 等每次读取都构造整张表再过滤。5 万条记录时,单次读取从 0.49 毫秒(走 records_digest_key 索引)变成 22 毫秒(全表扫描并溢出到临时文件),并随记录数线性增长。
    • 修正:去掉这个 SET 子句。这个函数不以所有者身份执行,内部名称都已写全模式名。再加一个执行计划回归测试,要求按摘要读取时走索引扫描。
  2. 说明: 迁移 14、15 又被就地修改过;只在它们从未部署的前提下可以接受,合并前冻结。

第四轮复核(5509e847)

  • 读取性能已恢复:5 万条记录时按摘要读取走 records_digest_key 索引,每次 0.27 毫秒;执行计划回归测试通过。
  • 第 5 步逐项无误:
    • 只认未撤销、未过期、账号启用的成员令牌,在读取请求体之前拒绝;
    • 拒绝外来的 Host 与 Origin,不发 CORS 头;
    • 线上大小、批量与并发写入受限,按账号限流,换令牌或设备编号也绕不过;
    • 他人的会话、操作与游标与不存在相同;
    • 控制台切换同步方式需要会话与防跨站校验;
    • 切到仅保存在本机在一个事务内完成;
    • 审计与日志不含令牌、正文、标题、路径或引文;
    • 幂等键按账户与代次隔离;
    • MCP 仍是六个工具。

需要修正:

  1. 中:审计记录在限流之前写入。
    • 原因:hub/server/personal-sync.ts 在令牌通过后、路由匹配与准入之前就写审计,出错时再写一条;未知路由(404)和错误方法(405)在限流之前就返回,不受限。
    • 复现:限额每分钟 2 次时发 40 个请求(一半是未知路由),18 个得到 429,却写入了 80 条只追加的审计记录。
    • 影响:任一成员的令牌都能让只追加的记录表无限增长。
    • 修正:先准入,再写任何记录。404、405、429 不写成记录,只计数;或者只审计已准入的请求。加回归测试:超额请求和未知路由不增加记录数。
  2. 低(设计):令牌没有作用范围。 MCP 客户端持有的令牌也能调用 /mode 清除本人在 Hub 上的内容(需先调用 /mode/check)。Claude 的决定:v1 接受。这是本人的令牌操作本人的数据,清除前要先预检,本机副本不受影响、重新同步可以恢复。在文档中写明,令牌作用范围留待以后。