Skip to content

调度恢复存在 TOCTOU:在串行 mutation 边界内复核已批准定义 #7913

Open

Description

@zqchris

来源:PR #7782 review r4413108889。源码调用链核对成立;尚未实现修复或运行并发复现。原 PR 按明确文件范围登记本 issue,不把关闭讨论当作缺口消失。

问题

自主本机 schedule_resume 的许可确认展示并绑定已存定义,但最后一次 Host 快照复核发生在调用 scheduler handler 前。Scheduler.resume(id) 在 serializeScheduleMutation 队列中调用 resumeUnlocked,后者读取并激活届时最新定义,没有校验本次获准的定义。

确定的时序:

  1. 已暂停调度为命令 A。
  2. 另一个写入经 updateFromCurrent(id, async buildPatch) 进入同一 per-schedule 队列,异步稳定化/构造命令 B,尚未提交。
  3. 用户允许恢复 A;Host 的锁外快照检查仍读到 A,因此通过。
  4. schedule_resume handler 调用 scheduler.resume(id),排在更新后。
  5. 前方更新提交 B;resumeUnlocked 读到 B,将其激活。后续触发执行 B,用户实际确认的是 A。

证据入口:

  • apps/desktop/src/main/maker-ipc/taskActionHost.ts:scope.assertCurrent 的保存定义复核。
  • packages/lizi-mcps/src/scheduler/resume.ts:scheduler.resume(id) 未传批准定义/guard。
  • packages/maker-scheduler/src/engine/scheduler.ts:updateFromCurrent 的异步 buildPatch 与 resume/resumeUnlocked 的同队列先后关系。

修复约束

将批准定义或验证 guard 传到 scheduler,在同一串行 mutation 边界内、读取当前值之后且激活之前验证。普通无 guard 的 UI/既有调用保持兼容;不能通过恢复自主白名单、禁用恢复或破坏现有时间计算来规避。guard 不应重新取得同一条调度 mutation 锁,否则会死锁。锁外重复查询和只覆盖 Agent 调用方的互斥都不够。

最小验收

利用可控 Promise 阻塞真实 updateFromCurrent 的 buildPatch,不用真实延时:在 A 仍持久化时让许可复核成功并排入 resume,再释放 B 更新。修复后旧许可必须失效且调度保持暂停,B 不执行。另验证无并发恢复、对 B 的新许可恢复、拒绝、普通旧调用兼容及 resume 的 nextFireAt/interval 语义保留。

该问题已登记但未修复;不得据此宣称 PR #7782 已达到完整的“确认与最终 mutation 原子绑定”。

Activity

  1. dash-s-cindy commented on Oct 29, 2026

    @dash-s-cindy

    判断

    更新:提报者补充了基于 updateFromCurrent 异步构建与 resume 排队的确定并发时序及可控 Promise 验收方案。定时调度从暂停恢复为活跃状态时,确保系统最终激活的执行指令与用户在授权卡上确认的定义严格一致,是保障伙伴自主权限契约闭环的核心安全边界。

    进一步核对发现,现有的 Host 准入层快照复核(taskActionHost.ts)发生在调用 scheduler handler 之前,处在调度引擎的串行修改锁(serializeScheduleMutation)之外。若前序更新已进入同一 schedule 队列排队构造新定义,宿主锁外复核仍会读取旧定义并放行,随后队列内的 resumeUnlocked 无条件读取并激活新定义,导致未经用户复核的指令被直接激活(TOCTOU)。该缺口跨越调度引擎与 MCP 接口,且提报者已给出清晰的调用时序与最小验证思路,值得推进。

    行动

    1. 首要步骤(核心调度与 MCP 开发):

      • 在 packages/maker-scheduler/src/engine/scheduler.ts 中扩展 resume 与 resumeUnlocked 方法,支持可选守卫参数(如 opts?: { assertCurrent?: (current: Schedule) => void | Promise<void> });
      • 在 serializeScheduleMutation 的串行临界区内部、读取当前存储行之后且更新 status: 'active' 之前执行 assertCurrent 复核;若目标定义的关键字段与期望快照不一致,抛出异常中断恢复并维持原有的 paused 状态;守卫严禁重入同一 schedule 锁以防死锁,未传 opts 时对存量 UI 及既有调用保持完全兼容;
      • 在 packages/lizi-mcps/src/scheduler/resume.ts 与 apps/desktop/src/main/maker-ipc/taskActionHost.ts 之间打通快照上下文,将授权卡捕获的已批准定义指纹或守卫透传给 scheduler.resume;
      • 在 packages/maker-scheduler/src/__tests__/scheduler.test.ts 中补充用例:利用可控 Promise 阻塞 updateFromCurrent 的 buildPatch,模拟在定义 A 仍持久化时排入带 A 断言的 resume,随后释放 B 更新,验证 resume 抛错阻断、调度保持 paused 且 B 不执行;同时验证无并发恢复、新许可恢复、拒绝、普通旧调用兼容及 nextFireAt/intervalMs 时间重算语义保留。
      • 完成条件:单测证实串行锁内能准确阻断过期定义的恢复请求,保持暂停状态,且不带 opts 的普通恢复保持完全兼容。
    2. 后续集成(仅当首要步骤引擎接口就绪且 PR fix(teammates): 以少数风险确认替代自主白名单并显示授权状态 #7782 授权卡合并后,桌面端开发):

      • 在集成用例中验证端到端场景:在任务授权卡等待期间通过产品入口修改定义,旧卡点击允许后返回授权过期错误,调度维持暂停且无触发。

    关键证据与技术依据

    • 锁外复核与锁内盲区:apps/desktop/src/main/maker-ipc/taskActionHost.ts 的 scope.assertCurrent 仅在准入层检查,未持有目标调度的串行队列;而 packages/maker-scheduler/src/engine/scheduler.ts:1588 的 resume 通过 serializeScheduleMutation(id) 串行排队,其内部的 resumeUnlocked 仅无条件 await this.get(id) 后将状态写为 active,未接收任何获准定义凭据。
    • 确定并发时序可复现性:updateFromCurrent(id, async buildPatch)(L1379)已在 serializeScheduleMutation 锁内执行异步回调;当 buildPatch 在途未提交时,锁外查询仍为旧状态 A,放行后的 resume 紧随其后排队;一旦前序提交 B,后续 resumeUnlocked 便直接激活未经批准的 B。
    • 锁重入与兼容约束:调度引擎使用 Map 维系 per-schedule 串行 promise 链;若传入的 guard 再次调用 schedule mutation 接口会导致死锁,因此必须是无锁的纯函数或快照比对;同时不传 guard 时完全保持原有 UI 与既有调用行为。

    相关记录

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions