Skip to content

feat(hooks): 新增 outbound.pre_send 发送前同步校验闸 - #1224

Open
deepcoldy wants to merge 6 commits into
masterfrom
feat/outbound-pre-send-gate
Open

feat(hooks): 新增 outbound.pre_send 发送前同步校验闸#1224
deepcoldy wants to merge 6 commits into
masterfrom
feat/outbound-pre-send-gate

Conversation

@deepcoldy

Copy link
Copy Markdown
Owner

依赖 #1221prompt.submit 同步闸)。本 PR 基于其分支,包含它的 4 个 commit;#1221 合入后本 PR 只剩 1 个新增 commit

为什么不能复用 outbound.send

outbound.send / outbound.reply 的发射点在飞书 API 调用成功之后(payload 需要返回的 messageId)。那一刻消息已经在群里了——给它们加 mode:"sync",deny 至多只能事后撤回,而用户已经看见。那不是拦截。

所以这两个事件明确不进 GATE_EVENTS(写 sync 会降级 async 并告警),新增 outbound.pre_send 跑在 API 调用之前,能真正拦下。

outbound.pre_send outbound.send / outbound.reply
时机 API 调用之前 调用成功之后
能否拦截 ✅ 消息不会出现在群里 ❌ 已经发出去了
messageId 没有
支持 sync

覆盖范围

函数 过闸
sendMessage(含 botmux send
replyMessage
sendUserMessage(DM)
sendEphemeralCard
updateMessage ❌ 编辑已有卡片,不是新发送

payload 里 surface 字段区分来源,可按入口写不同策略。

被拦时抛错,而不是返回假 messageId

这些函数的返回类型是「消息 id」,编一个假的会让调用方以为发送成功。故抛 OutboundBlockedError(带 blockedReason)。入站链路已有统一兜底,且失败通知自身也在 try/catch 内——不会因为通知同样被拦而循环(已核实 notifyOrdinaryIngressFailure)。

⚠️ 一个被回归倒逼出来的设计点(本 PR 最值得看的部分)

第一版无条件 await assertOutboundAllowed(...)。闸恒 allow、不 spawn 任何东西,但 4 个与本特性完全无关的 VC 会议测试变红

原因:那个 await 把飞书 API 调用推迟了一个 microtask。生产里有 void sendUserMessage(...)notifyVcMeetingInviteFailure)这种 fire-and-forget 调用方,其测试在调用后同步断言 DM 已发出。

定位过程值得记一笔:我先猜「时序问题」,去掉 await 验证——仍然红,假设被推翻。真正定位靠打真实调用栈new Error().stack),一眼看到调用方;再逐个调用点二分确认只有 sendUserMessage 那处受影响。

修法if (outboundGateArmed()) await assertOutboundAllowed(...)outboundGateArmed() 同步返回。未配置闸时一个 await 都不引入,调用时序与本特性存在之前逐字相同。为此把先前当死代码删掉的 hasSyncGateHooks 重新引入——它的真正价值就在这里。

测试

npx vitest run test/lark-outbound-hook.test.ts   → 11 passed
改动面 8 个测试文件                                → 447 passed
npx tsc --noEmit                                  → 0 error

新增 11 例:deny 时飞书 API 确实未被调用(这条是特性的核心断言)、reason 透出、四个入口、suppressHook 豁免、以及「未配置时不引入额外 microtask」的时序守卫。

反变异 4 种全部精确变红

变异 结果
裁决被忽略(装饰性闸) 🔴
同步判定恒 false(特性死掉) 🔴
移除 suppressHook 检查 🔴
恢复无条件 await(原 bug) 🔴

最后一条尤其重要——它证明那条时序守卫不是摆设。

全量 unit:20609 passed。余下失败为本机既有 root/bwrap 环境问题(先前已在未修改的 master 上复现证明)加两条负载 flake(超时阈值 935ms vs 900ms、端口占用 15157 vs 15156),均与本改动无关,且单独运行全绿。

尚未覆盖

  • 无真实飞书 e2e(需 switch:here + 重启 daemon 影响全机 bot)。
  • Dashboard 无配置界面,只能写 hooks.json

文档:中英文 hooks.md 已补(含与 outbound.send 的对照、覆盖范围、抛错含义与灰度建议)。

现有 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 并告警——不让配置声称发射点做不到的事。
prompt.submit 拦的是进来的消息;这一道拦的是 botmux 要发出去的消息,用于
防止密钥、内部信息、越权内容进入飞书群。

为什么不复用 outbound.send:它的发射点在飞书 API 调用**成功之后**(payload
需要返回的 messageId),那一刻消息已经在群里,deny 至多只能事后撤回——那不
是拦截。所以 outbound.send / outbound.reply 明确不进 GATE_EVENTS,写 sync 会
降级 async 并告警;新增的 outbound.pre_send 跑在 API 调用之前,能真正拦下。

覆盖四个「新消息」发送口:sendMessage / replyMessage / sendUserMessage /
sendEphemeralCard,payload 里的 surface 字段区分来源。updateMessage 不接闸
——它编辑的是已存在的卡片,不是一次新发送。

被拦时抛 OutboundBlockedError,而不是静默返回一个假的 messageId:这些函数
的返回类型是消息 id,编一个假的会让调用方以为发送成功。入站链路已有统一兜底
(失败通知自身也在 try/catch 内,不会因通知同样被拦而循环)。

**未配置该闸时是同步 no-op**。这一点是被回归倒逼出来的:第一版无条件
`await assertOutboundAllowed(...)`,即便闸恒 allow 且不 spawn,那个 await 也
把飞书 API 调用推迟了一个 microtask——而生产里有 `void sendUserMessage(...)`
(notifyVcMeetingInviteFailure)这种 fire-and-forget 调用方,其测试在调用后
同步断言,于是 4 个与本特性无关的 VC 会议用例变红。定位靠打真实调用栈,不是
靠猜(先猜的「时序问题」假设被实验推翻过一次)。改为 `if (outboundGateArmed())
await ...` 同步前置判定后,未配置时一个 await 都不引入,调用时序与本特性存在
之前逐字相同。为此把先前当死代码删掉的 hasSyncGateHooks 重新引入——它的真正
价值就在这里。

测试:新增 11 例(deny 时飞书 API 确实未被调用、reason 透出、四个入口、
suppressHook、以及「未配置时不引入额外 microtask」的时序守卫)。反变异:
裁决被忽略 / 闸恒不触发 / suppressHook 检查移除 / 恢复无条件 await —— 四种
都精确变红。改动面 8 个测试文件 447 例全绿,tsc 0 错。

全量 unit:20609 passed;余下失败为本机既有的 root/bwrap 环境问题(已在未修改
master 上复现过)加两条负载 flake(超时阈值、端口占用),均与本改动无关且单独
运行全绿。
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