Skip to content

feat(hooks): 新增 prompt.submit 事件与同步前置校验闸 - #1221

Open
deepcoldy wants to merge 5 commits into
masterfrom
feat/prompt-submit-sync-gate
Open

feat(hooks): 新增 prompt.submit 事件与同步前置校验闸#1221
deepcoldy wants to merge 5 commits into
masterfrom
feat/prompt-submit-sync-gate

Conversation

@deepcoldy

Copy link
Copy Markdown
Owner

背景:现有 hook 无法做前置校验,且这是结构性的

不只是「没 await」——hook-runner.ts spawn 时 stdio[1] 硬编码 'ignore'stdout 被丢弃,退出码只进 logger.warnemitHookEvent 返回 void。外部命令连「表达裁决」的通道都没有。

仓库里两个长得像否决的东西方向是反的:src/core/ask-hook/* 里的 permissionDecision / decision.behaviorCLI 调用 botmux(botmux 当 hook 被调),不是 botmux 调用用户配置的外部命令。

改了什么

新增 prompt.submit 事件 + mode: "sync",daemon 等它跑完并按裁决放行/拒绝:

[{ "event": "prompt.submit", "mode": "sync",
   "command": "/root/bin/prompt-gate.sh",
   "timeoutMs": 3000, "onError": "allow" }]

裁决两种写法,stdout JSON 优先于退出码{"decision":"deny","reason":"原因"}(reason 回给用户),或直接 exit 非0(让只会 exit 1 的老脚本不改也能用)。

为什么闸放在那个位置

落点被 enforceMessageQuotaForCliInput已经写下来的两条不变量夹死,不是风格选择:

  • 必须在 evaluateTalk 之后 —— 内置权限模型仍是第一道,外部 hook 只能在其放行后再收紧,不能把内置闸拒掉的人放进来。
  • 必须在 beginCharge 之前 —— 该函数自己写着「扣了费就不能丢任务,丢任务就不能扣费」。放扣费后被拒 = 用户额度少一格却什么都没发生。

这条不是靠注释保证的:把闸挪到扣费之后,测试会红(见下)。

其它设计取舍

取舍 理由
hook 自身失败走 onError,默认 fail-open 校验器超时/崩溃不该让整个 bot 变砖头;要 fail-closed 显式写 onError:"deny"
拒绝明确回复用户而非静默丢弃 有权限却消息凭空消失是最难排查的形态(内置模型的静默是另一回事——那是为了不泄露 bot 存在)
多个 sync hook 取 AND,首个 deny 短路 省无谓 spawn,且拒绝原因归属确定
sync 用在非 gate 事件上降级为 async 并告警 那些发射点是 fire-and-forget,硬撑「能拦」等于误导运维
sync hook 只跑一次 否则闸执行后又作为异步通知重跑一遍,副作用翻倍
只有 sync hook 捕获 stdout async 保持 'ignore',避免没人排空的管道被写满
message-listener 流量同样过闸 第三方告警 bot 的内容可被影响、且确实进 CLI,正是最该校验的;该路径不扣费,不影响上述不变量

影响面

动的是公共层(hook-runner + daemon 收信主路),逐项说明:

  • 未配置 sync hook 的部署零开销零行为变化evaluatePromptGate 无匹配直接返回 allow,不 spawn。
  • 异步 hook 语义未变:env 白名单、超时杀进程组、stdio[1]='ignore' 对 async 全部原样保留。
  • 跨 CLI / 跨后端 / 跨会话类型:闸在 daemon 侧的授权层,位于任何 CLI 适配器与 PTY/tmux 后端之上,不区分 CLI 与后端;新话题、话题续聊、斜杠命令冷启动三条入口都经同一个 enforceMessageQuotaForCliInput 漏斗(已分别接线并传入 promptContent)。
  • 编译态:未引入任何 __dirname 拼路径或 dist 路径构造(已 grep 确认)。
  • 并发:bot 级 admission 本就是并发的,慢闸只拖自己那一轮;但同话题续聊持 per-anchor 顺序锁,故文档明确建议 timeoutMs 设小(1-3s)。

测试验证

npx vitest run test/hook-runner.test.ts            → 37 passed
npx vitest run test/message-quota-enforcement.test.ts → 33 passed
改动面 7 个文件合计                                  → 109/109 passed
npx tsc --noEmit                                    → 0 error
bun run build                                       → OK

变异验证:11 个变异全部被杀。 其中三个不是走过场:

变异 结果
把闸挪到 beginCharge 之后 🔴 红(钉住扣费不变量)
恢复 message-listener 的绕过 🔴 红
反转 stdout/退出码优先级 🔴 红
闸恒 allow / 去掉 AND 短路 / 忽略 filter / 忽略 onError / 去掉 sync 去重 等 🔴 全红

e2e:对编译后的 dist/ 跑真实脚本,配阴性对照(deny 拦下、allow 放行、无 hook 放行三种都正确判别)。随附示例 examples/hooks/prompt-gate.sh 也实测:正常消息放行 / rm -rf / 拦下 / 掐掉 PATH 后无 jq 时按其自述 fail-open。

全量--project unit 20599 passed / 12 failed。12 条失败已用 git stash未修改的 master 上复跑证明为既有的 root/bwrap 环境问题(config-dirmojo-launcher-env-quarantineplugin-*-sandboxworker-dsh-turn),与本改动无关。

过程中自查出的两个问题(已修)

  1. 第一版去重测试是惰性的:测试进程继承了宿主会话的 BOTMUX_SESSION_ID/BOTMUX_LARK_APP_IDemitHookEvent 走的是转发给 daemon 的分支、根本没本地 spawn——把去重过滤整行删掉测试照样全绿。改用 emitHookEventLocal 后同一变异精确变红。
  2. 自查 diff 时发现 message-listener 流量绕过了闸listenerAuthorized 提前 return),已补齐并用变异证明有牙。

尚未覆盖

  • 无真实飞书 e2e(需 switch:here + 重启 daemon,会影响本机全部 bot,未擅自执行)。
  • Dashboard 无配置界面,当前只能改 hooks.json / BOTMUX_HOOKS_JSON

文档:docs-site/docs/{zh,en}/hooks.md 已补同步闸章节(含边界、快速验证、延迟提醒)。

现有 hook 全部是异步通知,且是结构性的:spawn 时 stdio[1] 硬编码
'ignore',stdout 被丢弃,退出码只进日志,emitHookEvent 返回 void——
外部命令连表达裁决的通道都没有,无法做提交前的权限校验。

新增 `prompt.submit` 事件 + `mode: "sync"`:daemon 等 hook 跑完并按裁决
决定该消息是否提交给 CLI。裁决优先级为 stdout 的 JSON(
{"decision":"deny","reason":"..."},reason 回给用户)高于退出码(0 放行
/非 0 拒绝),让只会 exit 1 的老脚本不改也能当校验器。

闸的落点由本函数既有的两条不变量夹死,不是风格选择:
- 在 evaluateTalk 之后:内置权限模型仍是第一道,外部 hook 只能在其放行后
  再收紧,不能把内置闸拒掉的人放进来;
- 在 beginCharge 之前:保住「扣了费就不能丢任务,丢任务就不能扣费」。
  放在扣费之后被拒 = 用户额度少一格却什么都没发生。

其它设计取舍:
- hook 自身失败(超时/spawn 不到/崩溃)不按 deny 处理,走 onError,默认
  fail-open——校验器挂掉不该让整个 bot 变砖头;需要 fail-closed 显式配
  onError:"deny"。
- 拒绝时明确回复用户而非静默丢弃:有权限却消息凭空消失最难排查。
- 多个 sync hook 取 AND,第一个 deny 短路。
- sync 声明在非 gate 事件上降级为 async 并告警:那些发射点是
  fire-and-forget,硬撑「能拦」等于误导运维。
- sync hook 只跑一次,不再作为异步通知重复触发(否则副作用翻倍)。
- 只有 sync hook 捕获 stdout;async 保持 'ignore',避免没人排空的管道写满。
- message-listener 命中的第三方内容同样过闸:那类内容来自外部告警 bot 且
  确实会进 CLI,正是最该校验的;该路径不扣费,故不影响上述不变量。

影响面:动的是公共层(hook-runner + daemon 收信主路),但对未配置 sync
hook 的部署是零开销零行为变化(evaluatePromptGate 无匹配即返回 allow,
不 spawn)。异步 hook 的既有语义、env 白名单、超时杀进程组均未改动。
bot 级 admission 是并发的,慢闸只拖自己那一轮;但同话题续聊持顺序锁,
故文档建议 timeoutMs 设小。

测试:hook-runner 37 例、配额接线 33 例;11 个变异全部被杀,含「把闸挪到
扣费之后」「恢复 listener 绕过」「反转 stdout 优先级」三个关键位。对编译后
dist/ 跑了真实脚本 e2e 并配阴性对照(allow/无 hook 均正确放行),随附示例
脚本 examples/hooks/prompt-gate.sh 也实测了拦截与无 jq 时的 fail-open。
unit tier 20599 passed / 12 failed,12 条已用 stash 到未修改 master 复跑
证明为既有的 root/bwrap 环境问题,与本改动无关。
evaluateOneGateHook 复用 prepareHookPayload,而后者对 content 有 600 字符
默认截断。该截断是为**通知类** hook 设计的(避免巨大 payload 灌进日志),
但对**裁决类** hook,content 恰恰就是判断依据——截断让闸对超长输入结构性
失明:把恶意内容垫到 600 字符之后即可绕过。

实测(对编译后 dist/,闸脚本 grep `rm -rf /`):
- 'A'.repeat(700) + ' rm -rf /' → allowed=true,hook 只看到
  {len:600, truncated:true, hasBad:false};
- 阴性对照:同一串放在 600 字符内 → allowed=false,hook 看到
  {len:23, hasBad:true}。差别纯粹来自截断,闸与探针均正常。

修法:仅当 hook 确为 sync gate(mode==='sync' 且事件属 GATE_EVENTS)时豁免
截断。异步 hook、以及 sync 声明在非 gate 事件上的(加载时already降级为
async)仍按原规则截断,行为逐字不变。

隐私含义已写入中英文文档:配置 sync 闸等于把完整消息正文交给该命令。

测试:新增两条守卫并双向反变异——撤回修复(闸重新拿到截断内容)→ 红;
把豁免放宽成「gate 事件上的所有 hook」(异步 hook 也拿全文,属隐私泄漏)
→ 另一条红。hook-runner 39 例、配额接线 33 例全绿。
排查上一条截断绕过时发现同一形状的第二处输入盲区:闸只拿到正文,而本轮
附带的图片/文件对它完全不可见——但那些附件确实会进 CLI。

不把闸挪到下载之后:下载必须排在授权之后(否则未授权发送者也能让 bot 去
拉文件),而闸又必须排在扣费之前,两者不可兼得。因此按能力如实收敛:闸拿
`attachments: [{type, name}]` 元信息(不含内容),足以支撑「禁止上传 .env」
「只许图片」这类策略;文件内容级判断明确不在能力范围内,已写进中英文文档,
避免运维以为配了闸就等于扫了附件。

只传 type + name,不传 key/messageId:那两个是可用于拉取资源的句柄,闸没有
下载能力也不该获得。

新话题与话题续聊两条入口均已接线。反变异:去掉转发 → 新守卫精确变红。
hook-runner 39 例、配额接线 34 例、改动面 7 文件 112 例全绿。
复审发现的阻断项:p2pMode='group' 下每个会话群的**第一条消息**(正是开场
prompt)只按元信息判定,内容级规则全盲。成因是两处叠加——建群前扣费点调
enforceMessageQuotaForCliInput 时不传 opts;改写后的那一轮又带
alreadyAuthorizedAndCharged 在闸之前 return,不会补课。

修法:在建群前扣费点用**一次性 numberer** 单独 parseEventMessage 一遍,把
正文与附件元信息一并交给闸。parseEventMessage 是纯函数(无网络/磁盘/共享
状态),numberer 的计数器为闭包私有,故这次预解析不扰动后续那次真正解析
——已实测:预解析与真解析产出逐字相同,图片编号仍从 [图片 1] 起。解析失败
退回只给元信息(与本次改动前行为一致),不拖垮收信主路。

没有选择「把 parseEventMessage 上提到建群块之前」:resolveNonsupportMessage
有副作用必须留在授权之后,重构面大于收益。

同时处理复审的非阻断项:
- passthrough 冷启动(/goal、/loop 等)补传附件元信息,两个调用点均已接线;
- 文档写明覆盖边界:定时任务与 workflow 自动 prompt 不过闸,避免运维误以为
  「所有进 CLI 的文本都查过了」;
- 订正 stdout 注释(verdict 后跟调试噪声其实不会 parse,会回退退出码);
- 删除未被引用的 hasSyncGateHooks;
- 冻结共享的 GATE_ALLOW,防日后被就地改写污染后续裁决。

测试:新增出生轮**路由级**回归(走真实 handleNewTopic,非直调 helper——
第一版直调 helper 的写法恒绿,已废弃重写)。反变异:撤回修复 → 该用例精确
变红。改动面 7 个文件 113 例全绿,tsc 0 错。
用户提出的疑问:闸是「prompt 送进 CLI 之前」还是「已输入到 CLI、按 Enter
之前」。后者在本架构下不存在——写文本与按 Enter 是同一次适配器调用中的原子
动作(writeInput 逐行打字,末尾 Enter 即提交),中间没有可插入的停顿。补上
完整链路图,并点明闸执行于 daemon 进程、此时 CLI 子进程尚未拿到该轮输入。

同时说明 outbound.send / outbound.reply 为何不能当拦截点:它们的发射位置在
飞书 API 调用成功之后(需先拿到 messageId),那一刻消息已在群里,加 sync 也
只能事后撤回。写 sync 会降级 async 并告警——不让配置声称发射点做不到的事。
@deepcoldy

Copy link
Copy Markdown
Owner Author

自动评审记录(供合入前参考;非阻断)

结论:0 阻断,可合。 以下 3 条为非阻断建议,可在合前顺手处理:

  1. prompt.submit 上的 async hook 生产中不可达,建议补对称告警。 该事件在生产代码中没有任何 emitHookEvent 发射点,唯一产出路径是 evaluatePromptGate,且它只执行 mode:"sync" 的 hook。运维若按事件表配置 {event:"prompt.submit"}(默认 async),将零触发、零日志、零报错。建议在 normalizeHookConfig 中对「gate 事件上配置了 async/默认 hook」发出 warn,与现有「非 gate 事件配置 sync → 降级并告警」对称。

  2. sync gate 的 timeoutMs: 0 会静默停用闸,建议加载时告警。 timeoutFor0 返回 0(立即超时),随后走 onError(默认 allow),闸安静失效;而「0 = 不超时」是常见外部约定,运维很容易踩。建议对 sync gate 的 timeoutMs: 0 在加载时 warn。

  3. 文档措辞订正。 docs-site/docs/{en,zh}/hooks.md 称「workflow 自动跑出来的 prompt 不过闸」,实际 workflow 路径(v3 saved workflow 扣费点)会过闸,只是不传 promptContent——sender 级规则仍生效、内容级规则失明。建议改为「workflow 过闸但不传正文」。

合并顺序注意: 本 PR 与 #1224 均修改 hooks.md;「syncprompt.submit 支持」一句在 #1224(新增 outbound.pre_send 为第二个 gate 事件)合入后失效,合第二个时需对账。

待维护者决定的产品取舍: onError 默认 fail-open(校验器自身故障时默认放行)是否为期望默认值。

验证:bun run build 通过、tsc --noEmit 0 错、改动测试文件 74/74 通过;关键不变量(闸位于内置授权之后、扣费之前;拒绝不扣费)已经变异测试验证(翻转裁决后测试精确变红)。

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant