feat(hooks): 新增 prompt.submit 事件与同步前置校验闸 - #1221
Conversation
现有 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 并告警——不让配置声称发射点做不到的事。
|
自动评审记录(供合入前参考;非阻断) 结论:0 阻断,可合。 以下 3 条为非阻断建议,可在合前顺手处理:
合并顺序注意: 本 PR 与 #1224 均修改 待维护者决定的产品取舍: 验证: |
背景:现有 hook 无法做前置校验,且这是结构性的
不只是「没 await」——
hook-runner.tsspawn 时stdio[1]硬编码'ignore',stdout 被丢弃,退出码只进logger.warn,emitHookEvent返回void。外部命令连「表达裁决」的通道都没有。仓库里两个长得像否决的东西方向是反的:
src/core/ask-hook/*里的permissionDecision/decision.behavior是 CLI 调用 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之前 —— 该函数自己写着「扣了费就不能丢任务,丢任务就不能扣费」。放扣费后被拒 = 用户额度少一格却什么都没发生。这条不是靠注释保证的:把闸挪到扣费之后,测试会红(见下)。
其它设计取舍
onError,默认 fail-openonError:"deny"sync用在非 gate 事件上降级为 async 并告警'ignore',避免没人排空的管道被写满影响面
动的是公共层(
hook-runner+ daemon 收信主路),逐项说明:evaluatePromptGate无匹配直接返回 allow,不 spawn。stdio[1]='ignore'对 async 全部原样保留。enforceMessageQuotaForCliInput漏斗(已分别接线并传入promptContent)。__dirname拼路径或 dist 路径构造(已 grep 确认)。timeoutMs设小(1-3s)。测试验证
变异验证:11 个变异全部被杀。 其中三个不是走过场:
beginCharge之后e2e:对编译后的
dist/跑真实脚本,配阴性对照(deny 拦下、allow 放行、无 hook 放行三种都正确判别)。随附示例examples/hooks/prompt-gate.sh也实测:正常消息放行 /rm -rf /拦下 / 掐掉 PATH 后无 jq 时按其自述 fail-open。全量:
--project unit20599 passed / 12 failed。12 条失败已用git stash在未修改的 master 上复跑证明为既有的 root/bwrap 环境问题(config-dir、mojo-launcher-env-quarantine、plugin-*-sandbox、worker-dsh-turn),与本改动无关。过程中自查出的两个问题(已修)
BOTMUX_SESSION_ID/BOTMUX_LARK_APP_ID,emitHookEvent走的是转发给 daemon 的分支、根本没本地 spawn——把去重过滤整行删掉测试照样全绿。改用emitHookEventLocal后同一变异精确变红。listenerAuthorized提前 return),已补齐并用变异证明有牙。尚未覆盖
switch:here+ 重启 daemon,会影响本机全部 bot,未擅自执行)。hooks.json/BOTMUX_HOOKS_JSON。文档:
docs-site/docs/{zh,en}/hooks.md已补同步闸章节(含边界、快速验证、延迟提醒)。