From 6145df3114acb36e20ee2237c5b58dc6c67ca324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Wed, 2 Sep 2026 22:13:04 -0700 Subject: [PATCH 1/5] =?UTF-8?q?feat(hooks):=20=E6=96=B0=E5=A2=9E=20prompt.?= =?UTF-8?q?submit=20=E4=BA=8B=E4=BB=B6=E4=B8=8E=E5=90=8C=E6=AD=A5=E5=89=8D?= =?UTF-8?q?=E7=BD=AE=E6=A0=A1=E9=AA=8C=E9=97=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现有 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 环境问题,与本改动无关。 --- docs-site/docs/en/hooks.md | 48 ++++- docs-site/docs/zh/hooks.md | 65 +++++- examples/hooks/README.md | 29 +++ examples/hooks/prompt-gate.sh | 52 +++++ src/daemon.ts | 86 +++++++- src/i18n/en.ts | 2 + src/i18n/zh.ts | 2 + src/services/hook-runner.ts | 252 ++++++++++++++++++++++- test/hook-runner.test.ts | 271 +++++++++++++++++++++++++ test/message-quota-enforcement.test.ts | 154 +++++++++++++- 10 files changed, 948 insertions(+), 13 deletions(-) create mode 100755 examples/hooks/prompt-gate.sh diff --git a/docs-site/docs/en/hooks.md b/docs-site/docs/en/hooks.md index 1dd175e342..c6f04c0e22 100644 --- a/docs-site/docs/en/hooks.md +++ b/docs-site/docs/en/hooks.md @@ -1,6 +1,8 @@ # Lifecycle Hooks -botmux can **asynchronously invoke external commands** when key lifecycle events occur. If a command fails, times out, or doesn't exist, it only writes to the log and never blocks botmux's main flow. +botmux can invoke external commands when key lifecycle events occur. By default they are **asynchronous**: if a command fails, times out, or doesn't exist, it only writes to the log and never blocks botmux's main flow. + +There is also a **synchronous pre-submit gate** (`mode: "sync"`, supported only on the `prompt.submit` event): the daemon waits for it and uses its verdict to decide whether the message reaches the CLI. See [Synchronous pre-submit gate](#synchronous-pre-submit-gate-promptsubmit). ## Configuration Location @@ -52,6 +54,8 @@ After any hook event fires, you'll see the JSON payload in the log. `examples/ho | `event` | string | Required. The event name to subscribe to (see table below) | | `command` | string | Required. The external executable command; supports arguments, but is not run through a shell | | `timeoutMs` | number | Optional. Defaults to 5000; on timeout, sends `SIGTERM` first, then falls back to `SIGKILL` | +| `mode` | `"sync"`|`"async"` | Optional, defaults to `async`. `sync` is supported **only** on `prompt.submit`; declaring it on any other event degrades to `async` with a warning in the log | +| `onError` | `"allow"`|`"deny"` | Optional, meaningful only with `mode:"sync"`. Fallback direction when the hook itself fails (timeout / missing command / crash). Defaults to `allow` (fail-open) | | `filter.chatId` | string|string[] | Optional. Only match the chat of the specified Lark group / topic | | `filter.senderOpenId` | string|string[] | Optional. Only match the specified sender open_id | | `redact.fullContentEvents` | string[] | Optional. Long text is truncated by default; events in this allowlist pass through the full text | @@ -62,6 +66,7 @@ After any hook event fires, you'll see the JSON payload in the log. `examples/ho |------|----------| | `topic.new` | A new topic / @mention is received | | `thread.reply` | A reply to an existing topic is received | +| `prompt.submit` | A message passed the built-in permission checks and is **about to be submitted to the CLI**. The only event that supports `mode:"sync"` blocking | | `outbound.send` | botmux successfully sends a regular message | | `outbound.reply` | botmux successfully replies to a topic message | | `schedule.fired` | A scheduled task finishes running | @@ -79,6 +84,7 @@ Different events carry extra fields: | Event | Extra fields | |------|----------| | `topic.new` | `messageId`, `senderOpenId`, `senderType`, `msgType`, `content` | +| `prompt.submit` | `messageId`, `chatId`, `chatType`, `anchor`, `senderOpenId`, `senderUnionId`, `memberUnionId`, `botSender`, `talkReason`, `content` | | `thread.reply` | `messageId`, `rootId`, `parentId`, `senderOpenId`, `senderType`, `msgType`, `content` | | `outbound.send` | `messageId`, `msgType`, `uuid`, `content` | | `outbound.reply` | `messageId`, `replyId`, `msgType`, `replyInThread`, `uuid`, `content` | @@ -90,6 +96,46 @@ Different events carry extra fields: By default, `content`, `message`, `description`, `finalOutput`, and `lastScreenContent` are truncated to **600 characters**, with `xxxLength` / `xxxTruncated` added; only events in `redact.fullContentEvents` pass through the full text. +## Synchronous pre-submit gate (prompt.submit) + +A regular hook is a *notification* — nothing reads its result. `prompt.submit` with `mode: "sync"` is a *verdict*: the daemon waits for it, reads it, and allows or rejects the message accordingly. Use it to add a custom authorization layer before a message reaches the CLI (an internal permission service, working-hours limits, dangerous-command interception, …). + +```json +[ + { + "event": "prompt.submit", + "mode": "sync", + "command": "/root/bin/prompt-gate.sh", + "timeoutMs": 3000, + "onError": "allow" + } +] +``` + +A ready-to-adapt example ships in the repo: `examples/hooks/prompt-gate.sh`. + +### Expressing a verdict + +Two ways; **JSON on stdout takes precedence over the exit code**: + +| Method | How | Notes | +|------|------|------| +| JSON (recommended) | print `{"decision":"deny","reason":"..."}` on stdout | `reason` is shown to the user; `decision` is `allow`|`deny` | +| Exit code | print no JSON, `exit 0` / non-zero | 0 allows, non-zero denies; stderr is used as the reason | + +Stdout must be a **whole JSON object** to count as a verdict. Printing an ordinary log line will not be mistaken for one — that case falls back to the exit code. + +### Boundaries and guarantees + +- **It can only tighten, never loosen.** The built-in permission model (`allowedUsers` / `grant` / oncall / quota) runs first; the hook is asked only after all of it passes. A hook returning `allow` cannot let in someone the built-in gate rejected. +- **A rejection costs no quota.** The gate sits before the charge, so a denied message does not consume the user's message quota. +- **A rejection tells the user** (with `reason`) instead of dropping silently — an authorized user whose messages vanish is the hardest failure to diagnose. +- **Multiple sync hooks are ANDed.** Any `deny` rejects; hooks after the first `deny` do not run. +- **A broken hook is not a rejection.** Timeout, missing command, and crashes all follow `onError`, which defaults to `allow` — a broken checker should not brick the whole bot. Set `onError: "deny"` explicitly for the opposite. +- **The latency lands directly on the inbound path.** Keep `timeoutMs` small (1–3s). Bot-level admission is concurrent so a slow gate will not stall the whole daemon, but replies **within one topic** hold an ordering lock — a slow gate makes later messages in that topic queue up. Do not lean on a large timeout to paper over a slow service. With no sync hook configured there is zero overhead — no spawn is added per message. +- **Message-listener traffic is adjudicated too.** That content comes from third parties (alert bots and the like) and still reaches a CLI, so it is exactly what a gate should inspect. That path never charges quota; a denial is logged only, with no reply (there is no human sender to answer). +- A given hook entry **runs only once**: after running as the gate, it is not fired again as an async notification. + ## Practical: Auto-Update Skills with session.start botmux natively integrates agentbuddy as a skill source (`botmux skills install ` to install, `botmux skills update ` to update). Combined with the `session.start` hook, you can automatically check for and update installed skills on every new session — equivalent to the SessionStart Hook in Relay / Claude Code's settings.json. diff --git a/docs-site/docs/zh/hooks.md b/docs-site/docs/zh/hooks.md index 760725af74..5aec403bba 100644 --- a/docs-site/docs/zh/hooks.md +++ b/docs-site/docs/zh/hooks.md @@ -1,6 +1,8 @@ # 生命周期 Hooks -botmux 可以在关键生命周期事件发生时**异步调用外部命令**。命令失败、超时或不存在只会写日志,不阻塞 botmux 主流程。 +botmux 可以在关键生命周期事件发生时调用外部命令。默认是**异步**的:命令失败、超时或不存在只会写日志,不阻塞 botmux 主流程。 + +另有一类**同步前置校验闸**(`mode: "sync"`,仅 `prompt.submit` 事件支持):daemon 会等它跑完,并按它的裁决决定这条消息要不要提交给 CLI。见下文[同步前置校验闸](#同步前置校验闸-promptsubmit)。 ## 配置位置 @@ -52,6 +54,8 @@ tail -f /tmp/botmux-hook.log | `event` | string | 必填。订阅的事件名(见下表) | | `command` | string | 必填。外部可执行命令;支持参数,但不经 shell 执行 | | `timeoutMs` | number | 可选。默认 5000;超时先 `SIGTERM`,再兜底 `SIGKILL` | +| `mode` | `"sync"`|`"async"` | 可选。默认 `async`。`sync` **仅** `prompt.submit` 支持,用于前置校验;其它事件写 `sync` 会降级为 `async` 并在日志告警 | +| `onError` | `"allow"`|`"deny"` | 可选,仅 `mode:"sync"` 有意义。hook 自身失败(超时/找不到命令/崩溃)时的兜底方向,默认 `allow`(fail-open) | | `filter.chatId` | string|string[] | 可选。只匹配指定飞书群 / 话题所在 chat | | `filter.senderOpenId` | string|string[] | 可选。只匹配指定发送者 open_id | | `redact.fullContentEvents` | string[] | 可选。默认截断长文本;列入 allowlist 的事件透传全文 | @@ -62,6 +66,7 @@ tail -f /tmp/botmux-hook.log |------|----------| | `topic.new` | 收到新话题 / @mention | | `thread.reply` | 收到已有话题回复 | +| `prompt.submit` | 消息通过内置权限校验、**即将提交给 CLI 之前**。唯一支持 `mode:"sync"` 前置拦截的事件 | | `outbound.send` | botmux 发送普通消息成功 | | `outbound.reply` | botmux 回复话题消息成功 | | `schedule.fired` | 定时任务执行完成 | @@ -79,6 +84,7 @@ tail -f /tmp/botmux-hook.log | 事件 | 额外字段 | |------|----------| | `topic.new` | `messageId`、`senderOpenId`、`senderType`、`msgType`、`content` | +| `prompt.submit` | `messageId`、`chatId`、`chatType`、`anchor`、`senderOpenId`、`senderUnionId`、`memberUnionId`、`botSender`、`talkReason`、`content` | | `thread.reply` | `messageId`、`rootId`、`parentId`、`senderOpenId`、`senderType`、`msgType`、`content` | | `outbound.send` | `messageId`、`msgType`、`uuid`、`content` | | `outbound.reply` | `messageId`、`replyId`、`msgType`、`replyInThread`、`uuid`、`content` | @@ -90,6 +96,63 @@ tail -f /tmp/botmux-hook.log 默认会把 `content`、`message`、`description`、`finalOutput`、`lastScreenContent` 截断到 **600 字符**,并补充 `xxxLength` / `xxxTruncated`;只有 `redact.fullContentEvents` 内的事件透传全文。 +## 同步前置校验闸(prompt.submit) + +普通 hook 是「通知」——跑完没人看结果。`prompt.submit` + `mode: "sync"` 是「裁决」:daemon 等它、读它、按它放行或拒绝。用于在消息进入 CLI 前做一层**自定义权限校验**(内部权限服务、工作时间限制、高危指令拦截等)。 + +```json +[ + { + "event": "prompt.submit", + "mode": "sync", + "command": "/root/bin/prompt-gate.sh", + "timeoutMs": 3000, + "onError": "allow" + } +] +``` + +仓库内置可直接改的示例:`examples/hooks/prompt-gate.sh`。 + +### 怎么表达裁决 + +两种写法,**stdout 的 JSON 优先于退出码**: + +| 方式 | 写法 | 说明 | +|------|------|------| +| JSON(推荐) | stdout 打印 `{"decision":"deny","reason":"原因"}` | `reason` 会回给用户;`decision` 取 `allow`|`deny` | +| 退出码 | 不打印 JSON,`exit 0` / `exit 非0` | 0 放行,非 0 拒绝;stderr 内容当作原因 | + +stdout 必须是**整段 JSON 对象**才会被当作裁决。打印一行普通日志不会被误判成裁决——那种情况回退到看退出码。 + +### 边界与保证 + +- **只能收紧,不能放宽**:内置权限模型(`allowedUsers` / `grant` / oncall / 额度)先跑,全部通过后才会问这个 hook。hook 说 `allow` 不会让内置闸拒掉的人进来。 +- **拒绝不扣额度**:闸排在扣费之前,被拒的消息不消耗用户的消息额度。 +- **拒绝会明确告诉用户**(附 `reason`),不静默丢弃——有权限却消息凭空消失是最难排查的形态。 +- **多个 sync hook 是 AND**:任一 `deny` 即拒绝,第一个 `deny` 之后的 hook 不再执行。 +- **hook 坏了不等于拒绝**:超时、找不到命令、崩溃都走 `onError`,默认 `allow`——校验器挂掉不该让整个 bot 变砖头。要反过来就显式写 `onError: "deny"`。 +- **延迟直接加在收信路径上**:`timeoutMs` 建议设小(1-3s)。bot 级并发不会因此卡死整个 daemon,但**同一话题**的续聊持有顺序锁——慢闸会让该话题的后续消息排队。别指望用大超时兜住一个慢服务。没配 sync hook 时零开销,不会给每条消息加 spawn。 +- **消息监听器(message listener)命中的第三方内容也会过闸**:那类内容来自告警 bot 等外部来源、同样会进 CLI,正是最该校验的。该路径本来就不扣额度,被拒时只记日志、不回消息(没有可回复的真人发送者)。 +- 同一条 hook 配置**只会跑一次**:作为闸执行后,不会再作为异步通知重复触发。 + +### 快速验证 + +```bash +cat > /tmp/gate.sh <<'SH' +#!/bin/bash +cat >/tmp/gate-payload.json +echo '{"decision":"deny","reason":"闸测试:暂时不放行"}' +SH +chmod +x /tmp/gate.sh + +cat > ~/.botmux/data/hooks.json <<'JSON' +[{ "event": "prompt.submit", "mode": "sync", "command": "/tmp/gate.sh", "timeoutMs": 3000 }] +JSON +``` + +在飞书里发一条消息:应当收到「本条消息被前置校验拦截」的回复,且 `/tmp/gate-payload.json` 里能看到本轮 payload。验证完记得清空 `hooks.json`。 + ## 实践:用 session.start hook 自动更新 Skills botmux 原生集成了 agentbuddy 作为 skill 来源(`botmux skills install ` 安装,`botmux skills update ` 更新)。配合 `session.start` hook,可以在每次新会话启动时自动检查并更新已安装的 skills,等效于 Relay / Claude Code settings.json 中的 SessionStart Hook。 diff --git a/examples/hooks/README.md b/examples/hooks/README.md index b4bc3b6f3b..3499558301 100644 --- a/examples/hooks/README.md +++ b/examples/hooks/README.md @@ -30,6 +30,35 @@ Then trigger a matching event and inspect `/tmp/botmux-hook.log`. | `echo-to-log.sh` | Appends every payload to `/tmp/botmux-hook.log` | | `osascript-notify.sh` | Shows a macOS Notification Center alert | | `http-webhook.sh` | POSTs the stdin payload to an HTTP endpoint | +| `prompt-gate.sh` | **Synchronous** pre-submit check: allows or denies a message before it reaches the CLI | + +## Synchronous gate hooks + +The scripts above are asynchronous notifications — nothing reads their result. +`prompt-gate.sh` is different: configured on the `prompt.submit` event with +`"mode": "sync"`, the daemon waits for it and uses its verdict to decide whether +the message is submitted to the CLI at all. + +```json +[ + { + "event": "prompt.submit", + "mode": "sync", + "command": "/absolute/path/to/prompt-gate.sh", + "timeoutMs": 3000, + "onError": "allow" + } +] +``` + +Print `{"decision":"deny","reason":"..."}` on stdout to reject (the reason is +shown to the user), or `{"decision":"allow"}` to permit. A script that prints no +JSON falls back to its exit code: 0 allows, non-zero denies. + +Keep `timeoutMs` small — the wait lands directly on the inbound message path. +A hook that times out or cannot be spawned follows `onError`, which defaults to +`allow` so a broken checker cannot brick the bot. Full contract: +`docs-site/docs/en/hooks.md`. Use absolute paths in `hooks.json`. If the command needs configuration, pass it as command arguments or environment variables inherited by the botmux daemon. diff --git a/examples/hooks/prompt-gate.sh b/examples/hooks/prompt-gate.sh new file mode 100755 index 0000000000..0f32250bcd --- /dev/null +++ b/examples/hooks/prompt-gate.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# 同步前置校验闸示例(event: prompt.submit, mode: sync) +# +# 与其它 hook 不同:daemon 会**等**这个脚本跑完,并按它的裁决决定这条消息 +# 要不要提交给 CLI。裁决两种写法,stdout 的 JSON 优先于退出码: +# 1) stdout 打 {"decision":"allow"} 或 {"decision":"deny","reason":"..."} +# —— 推荐,reason 会回给用户。 +# 2) 什么都不打,只用退出码:0 = 放行,非 0 = 拒绝(stderr 当作原因)。 +# +# 注意:内置权限模型(allowedUsers / grant / oncall / 额度)已经先跑过了。 +# 这个 hook 只能在那之上**再收紧**,不能把内置闸拒掉的人放进来。 +set -uo pipefail + +payload="$(cat)" + +# 没装 jq 就别拦——校验器自身的缺陷不该变成对用户的拒绝。 +if ! command -v jq >/dev/null 2>&1; then + echo '{"decision":"allow"}' + exit 0 +fi + +sender="$(printf '%s' "$payload" | jq -r '.senderOpenId // empty')" +content="$(printf '%s' "$payload" | jq -r '.content // empty')" +chat="$(printf '%s' "$payload" | jq -r '.chatId // empty')" + +# 例一:工作时间外只让值班同学发起 +hour="$(date +%H)" +if [ "$hour" -ge 22 ] || [ "$hour" -lt 7 ]; then + case "$sender" in + ou_oncall_person_1|ou_oncall_person_2) ;; + *) + echo '{"decision":"deny","reason":"夜间(22:00-07:00)仅值班同学可发起,请白天再试"}' + exit 0 + ;; + esac +fi + +# 例二:拦住明显危险的指令 +if printf '%s' "$content" | grep -qiE 'rm +-rf +/|drop +database|:\(\)\{.*\};:'; then + echo '{"decision":"deny","reason":"命中高危指令拦截规则"}' + exit 0 +fi + +# 例三:把决定权交给公司内部权限服务(超时/不可达时按 onError 兜底, +# 默认 fail-open;要「服务挂了就一律拒绝」在 hooks.json 里写 onError:"deny") +# verdict="$(curl -sS --max-time 3 -X POST https://perm.internal/check \ +# -H 'content-type: application/json' \ +# -d "{\"user\":\"$sender\",\"chat\":\"$chat\"}")" || exit 0 +# [ "$(printf '%s' "$verdict" | jq -r .ok)" = "true" ] \ +# || { echo '{"decision":"deny","reason":"未通过内部权限服务校验"}'; exit 0; } + +echo '{"decision":"allow"}' diff --git a/src/daemon.ts b/src/daemon.ts index 432df0b702..ea538594d7 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -98,7 +98,7 @@ import * as scheduleStore from './services/schedule-store.js'; import { migrateSharedSchedulesAtStartup } from './services/schedule-split-migration.js'; import { migrateOverloadAlertAtStartup } from './services/overload-alert-migration.js'; import * as messageQueue from './services/message-queue.js'; -import { emitHookEvent, emitHookEventLocal, HOOK_EVENTS, type HookEvent } from './services/hook-runner.js'; +import { emitHookEvent, emitHookEventLocal, evaluatePromptGate, HOOK_EVENTS, type HookEvent } from './services/hook-runner.js'; import { setSessionLifecycleShutdown } from './services/session-lifecycle-hooks.js'; import { setUsageLedgerPricingResolver, setUsageLedgerRecordSink } from './services/usage-ledger.js'; import { trackBudgetSpend, formatBudgetAlert } from './services/budget-tracker.js'; @@ -3788,9 +3788,41 @@ export async function enforceMessageQuotaForCliInput( memberUnionId?: string, chatType?: 'group' | 'p2p', botSender?: boolean, - opts?: { listenerAuthorized?: boolean; skipCharge?: boolean; alreadyAuthorizedAndCharged?: boolean }, + opts?: { + listenerAuthorized?: boolean; + skipCharge?: boolean; + alreadyAuthorizedAndCharged?: boolean; + /** 本轮要喂给 CLI 的用户文本,交给 prompt.submit 同步校验闸做内容级判断。 + * 不传 = 调用方没有可判定的正文(纯控制路径),闸仍会跑但 content 为空。 */ + promptContent?: string; + }, ): Promise { - if (opts?.listenerAuthorized) return true; + if (opts?.listenerAuthorized) { + // Listener matches skip the talk/quota model (a third-party alert bot is + // not a "sender" with a grant), but they DO reach a CLI — and their content + // is attacker-influenceable third-party text, which is exactly what a + // pre-submit gate exists to inspect. Ask the gate before returning; there + // is no charge on this path, so the charge-order invariant is not in play. + const listenerGate = await evaluatePromptGate('prompt.submit', { + larkAppId, + chatId, + chatType, + anchor, + messageId, + senderOpenId, + botSender: !!botSender, + talkReason: 'messageListener', + content: opts.promptContent, + }); + if (!listenerGate.allowed) { + logger.info( + `[hooks:${larkAppId}] prompt.submit gate denied listener message ${messageId.substring(0, 12)}` + + `${listenerGate.reason ? `: ${listenerGate.reason}` : ''}`, + ); + return false; + } + return true; + } // 会话群出生轮:**同一条消息**的授权判定与扣费,刚刚由本函数在原 DM 上下文里 // 一起做完(handleNewTopicAdmitted 的建群前扣费点:扣费被拒必须零外部副作用, // 所以它必须排在 createGroupWithBots 之前)。这一轮绝不能再复查一次: @@ -3824,6 +3856,50 @@ export async function enforceMessageQuotaForCliInput( // Control/setup messages still need the authorization decision above, but // they never reach a CLI and therefore must not spend or dedupe a quota unit. if (opts?.skipCharge) return true; + + // ─── prompt.submit 同步前置校验闸 ──────────────────────────────────────── + // 位置是被两条既有不变量夹死的,不能随意挪: + // • 必须在 evaluateTalk 之后——内置权限模型仍是第一道;外部 hook 只能 + // 在它放行之后再收紧,永远不能把内置闸拒掉的人放进来。 + // • 必须在 beginCharge 之前——本文件的硬不变量是「扣了费就不能丢任务, + // 丢任务就不能扣费」。放在扣费后被拒 = 用户额度少一格却什么也没发生。 + // 没配 sync hook 时 evaluatePromptGate 直接返回 allow,零 spawn 零开销。 + const gate = await evaluatePromptGate('prompt.submit', { + larkAppId, + chatId, + chatType, + anchor, + messageId, + senderOpenId, + senderUnionId, + memberUnionId, + botSender: !!botSender, + talkReason: ev.reason, + content: opts?.promptContent, + }); + if (!gate.allowed) { + logger.info( + `[hooks:${larkAppId}] prompt.submit gate denied message ${messageId.substring(0, 12)} ` + + `from ${senderOpenId?.substring(0, 12) ?? '?'}${gate.reason ? `: ${gate.reason}` : ''}`, + ); + // 明确告诉用户被拦了。静默丢弃在这里是最坏选项:用户有权限、消息也没超额, + // 却凭空消失,只能反复重发。(内置权限模型的静默丢弃是另一回事——那里 + // 沉默本身是为了不向未授权者泄露 bot 的存在。) + try { + await sessionReply( + anchor, + gate.reason + ? tr('daemon.prompt_gate_denied_reason', { reason: gate.reason }, localeForBot(larkAppId)) + : tr('daemon.prompt_gate_denied', undefined, localeForBot(larkAppId)), + 'text', + larkAppId, + ); + } catch (err) { + logger.warn(`[hooks:${larkAppId}] prompt gate denial notify failed: ${err}`); + } + return false; + } + if (!ev.quotaKey) return true; if (!senderOpenId) return false; // 去重三态:'done' = 同条已成功扣费 → 放行(不重复扣);'pending' = 同条扣费 in-flight 未定论 @@ -17398,7 +17474,7 @@ async function startInitialPassthroughSession(args: { senderIsBot, cardlessForceTopicSeed, onDurablyAdmitted, routeToCanonicalOwner, } = args; - if (!await enforceMessageQuotaForCliInput(larkAppId, chatId, senderOpenId, messageId, anchor, senderUnionId, memberUnionId, chatType, botSender)) { + if (!await enforceMessageQuotaForCliInput(larkAppId, chatId, senderOpenId, messageId, anchor, senderUnionId, memberUnionId, chatType, botSender, { promptContent: commandContent || parsed.content })) { return; } // Reply attribution's is-bot. `resolvedSenderIsBot` is a BOOLEAN for the @@ -18218,6 +18294,7 @@ async function handleNewTopicAdmitted(data: any, ctx: RoutingContext): Promise = { 'daemon.cmd_requires_session': '{cmd} requires an existing session (send a normal message first to start the CLI).', 'daemon.cmd_allowed_users_only': '⚠️ {cmd} is a management command, restricted to `allowedUsers`. To authorize, ask the owner to add you to this bot\'s `allowedUsers` (Dashboard or bots.json).', 'daemon.download_failed_need_login': '⚠️ Some images/files failed to download (missing User Token). Send `/login` in this topic to authorize, then resend.', + 'daemon.prompt_gate_denied': '🚫 This message was blocked by a pre-submit check and was not sent to the CLI.', + 'daemon.prompt_gate_denied_reason': '🚫 This message was blocked by a pre-submit check and was not sent to the CLI: {reason}', 'daemon.foreign_bot_mention_prefix': '[@mention from {botName}]', 'daemon.ordinary_ingress_failed': '⚠️ This message did not reach the CLI. Please resend; if it keeps failing, `/close` and reopen the topic.', 'daemon.ordinary_ingress_admitted_reply_failed': '⚠️ This message was received — do not resend it as-is (a resend would run it twice); the failure happened in a follow-up status reply or post-accept step. If nothing happens in this topic, `/close` and reopen the topic to continue.', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 77eaa2cabb..39726950d7 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -1001,6 +1001,8 @@ export const messages: Record = { 'daemon.cmd_requires_session': '{cmd} 需要在已有会话内使用(先发一条普通消息启动 CLI)。', 'daemon.cmd_allowed_users_only': '⚠️ {cmd} 是管理命令,仅 allowedUsers 可执行。需要授权请让 owner 把你加入该 Bot 的 allowedUsers(Dashboard 或 bots.json)。', 'daemon.download_failed_need_login': '⚠️ 部分图片/文件下载失败(缺少 User Token)。请在话题中发送 /login 授权后重新发送。', + 'daemon.prompt_gate_denied': '🚫 本条消息被前置校验拦截,未提交给 CLI。', + 'daemon.prompt_gate_denied_reason': '🚫 本条消息被前置校验拦截,未提交给 CLI:{reason}', 'daemon.foreign_bot_mention_prefix': '[来自 {botName} 的 @mention]', 'daemon.ordinary_ingress_failed': '⚠️ 这条消息没有送达 CLI,请重发一次;若持续失败,可 /close 后重开话题。', 'daemon.ordinary_ingress_admitted_reply_failed': '⚠️ 这条消息已接收,请勿原样重发(重发会重复执行);失败发生在接收之后的状态回复/收尾步骤。若话题迟迟没有动静,可 /close 后重开话题再继续。', diff --git a/src/services/hook-runner.ts b/src/services/hook-runner.ts index 27169155cb..f18dfe657a 100644 --- a/src/services/hook-runner.ts +++ b/src/services/hook-runner.ts @@ -12,6 +12,7 @@ import { loopbackFetch } from '../core/loopback-fetch.js'; export const HOOK_EVENTS = [ 'topic.new', 'thread.reply', + 'prompt.submit', 'outbound.send', 'outbound.reply', 'schedule.fired', @@ -23,6 +24,21 @@ export const HOOK_EVENTS = [ export type HookEvent = typeof HOOK_EVENTS[number]; +/** + * Events whose emit point can actually WAIT for a verdict and act on it. + * + * `mode: 'sync'` is only honoured here. Every other event is emitted from a + * fire-and-forget site (`emitHookEvent` returns void, callers never await), so + * declaring sync on them would buy nothing but latency while still reading as + * "this hook can block" to the operator. Those degrade to async with a warning + * rather than silently pretending to gate — see `loadHookConfigs`. + */ +export const GATE_EVENTS: readonly HookEvent[] = ['prompt.submit'] as const; + +export function isGateEvent(event: HookEvent): boolean { + return GATE_EVENTS.includes(event); +} + export type HookFilter = { chatId?: string | string[]; senderOpenId?: string | string[]; @@ -34,6 +50,13 @@ export type HookConfig = { command: string; timeoutMs?: number; filter?: HookFilter; + /** 'sync' 只对 GATE_EVENTS 生效:daemon 等它跑完并按裁决放行/拒绝。 + * 其余事件声明 sync 会在加载时降级为 async 并告警(见 normalizeHookConfig)。 */ + mode?: 'sync' | 'async'; + /** sync hook 自身失败(超时 / spawn 不到 / 命令崩溃)时的兜底方向。 + * 默认 'allow'(fail-open):hook 坏掉不该把整个 bot 变成砖头。 + * 要「校验器挂了就一律不放行」的部署显式写 'deny'(fail-closed)。 */ + onError?: 'allow' | 'deny'; redact?: { fullContentEvents?: HookEvent[]; }; @@ -72,13 +95,21 @@ export type HookRunResult = { signal?: NodeJS.Signals | null; timedOut?: boolean; error?: string; + /** Only populated when `captureStdout` was requested (sync gate hooks). + * Async hooks keep stdio[1]='ignore' so a chatty hook cannot fill a pipe + * nobody drains and wedge itself. */ + stdout?: string; }; type RunHookCommandOptions = { fireAndForget?: boolean; + captureStdout?: boolean; }; const DEFAULT_TIMEOUT_MS = 5_000; +/** Sync gate hooks answer with a small JSON verdict; anything past this is + * debug spew we refuse to buffer unboundedly in the long-lived daemon. */ +const STDOUT_CAPTURE_LIMIT = 64_000; const CONTENT_PREVIEW_LIMIT = 600; const CONTENT_FIELDS = ['content', 'message', 'description', 'finalOutput', 'lastScreenContent'] as const; @@ -111,6 +142,26 @@ function normalizeHookConfig(raw: unknown): HookConfig | null { if (typeof rec.timeoutMs === 'number' && Number.isFinite(rec.timeoutMs)) { hook.timeoutMs = rec.timeoutMs; } + // A `sync` declaration on a non-gate event is an operator mistake worth + // saying out loud: the emit site there is fire-and-forget, so the hook would + // run exactly as before while the config claims it gates. Degrade to async + // and warn rather than honour a promise the call site cannot keep. + if (rec.mode === 'sync') { + if (isGateEvent(hook.event)) { + hook.mode = 'sync'; + } else { + hook.mode = 'async'; + logger.warn( + `[hooks] mode:'sync' is not supported for event '${hook.event}' ` + + `(only ${GATE_EVENTS.join(', ')} can block); running it as async.`, + ); + } + } else if (rec.mode === 'async') { + hook.mode = 'async'; + } + if (rec.onError === 'allow' || rec.onError === 'deny') { + hook.onError = rec.onError; + } if (rec.filter && typeof rec.filter === 'object') { const filterRec = rec.filter as Record; const filter: HookFilter = {}; @@ -290,9 +341,13 @@ async function runHookCommand( let settled = false; let timedOut = false; let stderr = ''; + let stdout = ''; const child = spawn(parsed.file, parsed.args, { shell: false, - stdio: ['pipe', 'ignore', 'pipe'], + // stdout stays 'ignore' for async hooks: nothing drains it there, and a + // chatty hook would block on a full pipe. Sync gate hooks need the + // verdict, so they get a pipe (drained below, capped like stderr). + stdio: ['pipe', options.captureStdout ? 'pipe' : 'ignore', 'pipe'], // detached so we can kill the whole process group (grandchildren included) detached: true, env: { @@ -341,7 +396,7 @@ async function runHookCommand( } catch { /* process may already be gone */ } // Actively settle — don't wait for 'close' which may never fire if a // grandchild process holds the stderr pipe open. - settle({ ok: false, timedOut: true, code: null, signal: null, error: 'hook timed out' }); + settle({ ok: false, timedOut: true, code: null, signal: null, error: 'hook timed out', stdout }); }, timeoutFor(hook)); if (options.fireAndForget) timer.unref(); @@ -351,8 +406,16 @@ async function runHookCommand( if (stderr.length > 2_000) stderr = stderr.slice(-2_000); }); + // Keep the HEAD of stdout, not the tail: the verdict JSON is the whole + // document, so a hook that prints its verdict then dumps debug noise must + // still parse. (stderr keeps the tail — there the last error matters most.) + child.stdout?.setEncoding('utf8'); + child.stdout?.on('data', chunk => { + if (stdout.length < STDOUT_CAPTURE_LIMIT) stdout += String(chunk); + }); + child.on('error', (err) => { - settle({ ok: false, timedOut, error: err.message }); + settle({ ok: false, timedOut, error: err.message, stdout }); }); child.on('close', (code, signal) => { @@ -361,6 +424,7 @@ async function runHookCommand( code, signal, timedOut, + stdout, error: code === 0 && !timedOut ? undefined : (stderr.trim() || `hook exited code=${code} signal=${signal ?? 'none'}`), }); }); @@ -439,7 +503,14 @@ export function emitHookEventLocal(event: HookEvent, body: Record hook.event === event && filterMatches(hook.filter, payload)); + const hooks = loadHookConfigs().filter(hook => + hook.event === event + // A sync gate hook already ran (and was awaited) in evaluatePromptGate. + // Without this it would spawn a SECOND time here as a notification — + // double side effects, and a hook that denied would still see its own + // event replayed as if nothing happened. + && hook.mode !== 'sync' + && filterMatches(hook.filter, payload)); if (hooks.length === 0) return; for (const [i, hook] of hooks.entries()) { @@ -457,8 +528,177 @@ function runHooksLocally(payload: HookPayload): void { } } -export function runHookCommandForTest(hook: HookConfig, payload: HookPayload): Promise { - return runHookCommand(hook, payload); +export function runHookCommandForTest( + hook: HookConfig, + payload: HookPayload, + options: RunHookCommandOptions = {}, +): Promise { + return runHookCommand(hook, payload, options); +} + +// ─── 同步前置校验闸(sync gate hooks) ────────────────────────────────────── +// +// 与上面的 async hook 是两条不同的契约:async hook 是「通知」,跑完没人看结果; +// sync gate hook 是「裁决」,daemon 等它、读它、按它放行或拒绝。 +// +// 判据优先级(两者都给以 stdout 为准): +// 1) stdout 是 JSON 且带 `decision` → 按 decision('allow' | 'deny'), +// 可选 `reason` 会回给用户。这是推荐写法:能带拒绝原因。 +// 2) 没有可解析的 JSON verdict → 退回退出码:0 = allow,非 0 = deny。 +// 让「一个只会 exit 1 的老脚本」不用改也能当校验器用。 +// +// 三条铁律: +// • hook 自身失败(超时/spawn 不到/崩溃且没给 verdict)不按 deny 处理, +// 走 `onError`,默认 fail-open——校验器挂掉不该让整个 bot 变砖头。 +// 真要 fail-closed 的部署显式写 onError:'deny'。 +// • 多个 sync hook 是 AND:任一 deny 即拒绝,第一个 deny 短路,其余不再跑。 +// • 无论如何都返回裁决,绝不抛异常——这条链路在 daemon 的收信主路上。 +// +// 延迟的影响面:bot 级 admission 是**并发**的,所以一个慢闸只拖它自己那一轮, +// 不会卡住整个 daemon。但同一话题的续聊持有 per-anchor FIFO 锁(daemon.ts +// `thread-delivery:` 键),慢闸会让同话题的后续消息排队——所以 timeoutMs +// 该设小(1-3s),别指望用大超时兜住一个慢服务。 + +export type PromptGateDecision = { + allowed: boolean; + /** 拒绝原因(回给用户)。allow 时通常为空。 */ + reason?: string; + /** 做出该裁决的 hook 命令(日志用,已截断)。 */ + source?: string; + /** 该裁决是不是 hook 自身失败后的 onError 兜底,而非它真的表了态。 */ + fromError?: boolean; +}; + +const GATE_ALLOW: PromptGateDecision = { allowed: true }; +/** 拒绝原因回显给用户前的长度上限——hook 的 stderr/stdout 不该变成刷屏面。 */ +const GATE_REASON_LIMIT = 300; + +function parseHookVerdict(stdout: string | undefined): { decision: 'allow' | 'deny'; reason?: string } | null { + if (!stdout) return null; + const text = stdout.trim(); + if (!text) return null; + // 只认整段 JSON 对象。不做「从一堆日志里捞 JSON」的模糊匹配:那会让 hook 打印 + // 的一行调试日志意外变成裁决,把安全闸变成猜谜。 + if (!text.startsWith('{')) return null; + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return null; + } + if (!parsed || typeof parsed !== 'object') return null; + const rec = parsed as Record; + const decision = rec.decision; + if (decision !== 'allow' && decision !== 'deny') return null; + const reason = typeof rec.reason === 'string' && rec.reason.trim() + ? rec.reason.trim().slice(0, GATE_REASON_LIMIT) + : undefined; + return { decision, reason }; +} + +/** 单个 sync hook 的裁决。不抛异常。 */ +async function evaluateOneGateHook(hook: HookConfig, payload: HookPayload): Promise { + const source = hook.command.slice(0, 60); + let result: HookRunResult; + try { + result = await runHookCommand(hook, prepareHookPayload(hook, payload), { captureStdout: true }); + } catch (err: any) { + // runHookCommand 本身按契约不 reject,这里只是结构性兜底。 + const failOpen = (hook.onError ?? 'allow') === 'allow'; + logger.warn(`[hooks] gate ${source} crashed: ${err?.message ?? String(err)} → ${failOpen ? 'allow' : 'deny'}`); + return failOpen + ? { allowed: true, source, fromError: true } + : { allowed: false, reason: 'permission check failed', source, fromError: true }; + } + + // stdout 的 verdict 优先于退出码:一个 exit 1 但明说 decision:'allow' 的 + // hook,意图是放行(退出码可能只是它内部某步没成功)。 + const verdict = parseHookVerdict(result.stdout); + if (verdict) { + return verdict.decision === 'allow' + ? { allowed: true, source } + : { allowed: false, reason: verdict.reason, source }; + } + + // 没有 verdict 且 hook 没能正常跑完 → 这是「校验器坏了」,不是「用户没权限」。 + // 关键区分:超时 / spawn 失败(ENOENT)走 onError;命令正常跑完只是 exit 非 0 + // 的,那是它在用退出码表态 deny。 + const brokeDown = result.timedOut || result.code === undefined || result.code === null; + if (brokeDown) { + const failOpen = (hook.onError ?? 'allow') === 'allow'; + logger.warn( + `[hooks] gate ${source} did not return a verdict (${result.error ?? 'unknown failure'}) ` + + `→ onError=${failOpen ? 'allow' : 'deny'}`, + ); + return failOpen + ? { allowed: true, source, fromError: true } + : { allowed: false, reason: 'permission check failed', source, fromError: true }; + } + + if (result.code === 0) return { allowed: true, source }; + return { + allowed: false, + reason: result.error?.trim().slice(0, GATE_REASON_LIMIT) || undefined, + source, + }; +} + +/** + * 跑齐某个 gate 事件上所有 `mode:'sync'` 的 hook,返回合并裁决(AND 语义)。 + * + * 没有配任何 sync hook 时零开销直接放行——绝大多数部署走的就是这条路径, + * 不能因为加了这个能力就给每条消息都加一次 spawn。 + */ +export async function evaluatePromptGate( + event: HookEvent, + body: Record = {}, +): Promise { + try { + if (!isGateEvent(event)) return GATE_ALLOW; + const payload: HookPayload = { + ...body, + event, + emittedAt: new Date().toISOString(), + }; + const hooks = loadHookConfigs().filter(hook => + hook.event === event + && hook.mode === 'sync' + && filterMatches(hook.filter, payload)); + if (hooks.length === 0) return GATE_ALLOW; + + // 记住「有 hook 是坏掉后被兜过去的」。全允许时也要把这一位带出去: + // 「校验器明确放行」和「校验器挂了我们放行」对运维是两件事,后者需要能被 + // 观测到,否则一个一直在超时的闸会安静地等于没装。 + let sawFailOpen = false; + for (const hook of hooks) { + const decision = await evaluateOneGateHook(hook, payload); + // 第一个 deny 短路:后面的 hook 不再跑,省掉一次无意义的 spawn, + // 也让「拒绝原因」有确定的归属(就是这个 hook 说的)。 + if (!decision.allowed) { + logger.info( + `[hooks] gate ${event} DENIED by ${decision.source}` + + `${decision.reason ? `: ${decision.reason}` : ''}`, + ); + return decision; + } + if (decision.fromError) sawFailOpen = true; + } + return sawFailOpen ? { allowed: true, fromError: true } : GATE_ALLOW; + } catch (err: any) { + // 这条链路在收信主路上:任何未预期的异常都必须变成放行,不能把消息吞掉。 + logger.warn(`[hooks] gate ${event} evaluation crashed, allowing: ${err?.message ?? String(err)}`); + return { allowed: true, fromError: true }; + } +} + +/** 该事件当前是否配了 sync hook——调用方用它避开无谓的 payload 组装。 */ +export function hasSyncGateHooks(event: HookEvent): boolean { + try { + if (!isGateEvent(event)) return false; + return loadHookConfigs().some(hook => hook.event === event && hook.mode === 'sync'); + } catch { + return false; + } } const HOOK_FORWARD_FETCH_TIMEOUT_MS = 2_000; diff --git a/test/hook-runner.test.ts b/test/hook-runner.test.ts index a122423f88..41c71063ac 100644 --- a/test/hook-runner.test.ts +++ b/test/hook-runner.test.ts @@ -9,6 +9,8 @@ import { __setLoopbackTransportForTests } from '../src/core/loopback-fetch.js'; import { filterMatches, emitHookEvent, + emitHookEventLocal, + evaluatePromptGate, forwardEmitToDaemon, loadHookConfigs, parseHookCommand, @@ -474,3 +476,272 @@ describe('runHookCommandForTest', () => { expect(existsSync(marker)).toBe(false); }); }); + +// ─── 同步前置校验闸(sync gate hooks) ────────────────────────────────────── + +describe('sync gate hooks (prompt.submit)', () => { + /** 写一个按参数决定行为的 hook 脚本,返回可直接放进 command 的路径。 */ + function writeHook(name: string, body: string): string { + const script = join(tmpDir, name); + writeFileSync(script, body); + return `${process.execPath} ${script}`; + } + + function gateHooks(hooks: HookConfig[]): void { + process.env.BOTMUX_HOOKS_JSON = JSON.stringify(hooks); + } + + afterEach(() => { + delete process.env.BOTMUX_HOOKS_JSON; + }); + + it('allows when no sync hook is configured (zero spawn)', async () => { + gateHooks([{ event: 'prompt.submit', command: '/bin/false', mode: 'async' }]); + const decision = await evaluatePromptGate('prompt.submit', { content: 'hi' }); + expect(decision.allowed).toBe(true); + }); + + it('denies on a JSON verdict and surfaces the reason to the caller', async () => { + gateHooks([{ + event: 'prompt.submit', + mode: 'sync', + command: writeHook('deny.js', ` + console.log(JSON.stringify({ decision: 'deny', reason: 'not on the allowlist' })); + `), + }]); + const decision = await evaluatePromptGate('prompt.submit', { content: 'hi' }); + expect(decision.allowed).toBe(false); + expect(decision.reason).toBe('not on the allowlist'); + }); + + it('allows on a JSON allow verdict', async () => { + gateHooks([{ + event: 'prompt.submit', + mode: 'sync', + command: writeHook('allow.js', `console.log(JSON.stringify({ decision: 'allow' }));`), + }]); + expect((await evaluatePromptGate('prompt.submit', {})).allowed).toBe(true); + }); + + it('receives the prompt payload on stdin', async () => { + const output = join(tmpDir, 'seen-payload.json'); + gateHooks([{ + event: 'prompt.submit', + mode: 'sync', + command: writeHook('capture.js', ` + import { writeFileSync } from 'node:fs'; + let input = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', c => { input += c; }); + process.stdin.on('end', () => { + writeFileSync(${JSON.stringify(output)}, input); + console.log(JSON.stringify({ decision: 'allow' })); + }); + `), + }]); + await evaluatePromptGate('prompt.submit', { content: 'review this', senderOpenId: 'ou_x' }); + expect(JSON.parse(readFileSync(output, 'utf-8'))).toMatchObject({ + event: 'prompt.submit', + content: 'review this', + senderOpenId: 'ou_x', + }); + }); + + it('falls back to the exit code when stdout carries no verdict', async () => { + gateHooks([{ + event: 'prompt.submit', + mode: 'sync', + command: writeHook('exit1.js', `process.exit(1);`), + }]); + expect((await evaluatePromptGate('prompt.submit', {})).allowed).toBe(false); + + gateHooks([{ + event: 'prompt.submit', + mode: 'sync', + command: writeHook('exit0.js', `process.exit(0);`), + }]); + expect((await evaluatePromptGate('prompt.submit', {})).allowed).toBe(true); + }); + + it('lets a stdout verdict win over a conflicting exit code (documented precedence)', async () => { + // A checker that prints "allow" and then dies in its own cleanup still meant + // allow. Documented in hooks.md as "stdout JSON takes precedence". + gateHooks([{ + event: 'prompt.submit', + mode: 'sync', + command: writeHook('allow-then-crash.js', ` + console.log(JSON.stringify({ decision: 'allow' })); + process.exit(3); + `), + }]); + expect((await evaluatePromptGate('prompt.submit', {})).allowed).toBe(true); + + // ...and the reverse: exit 0 but an explicit deny on stdout still denies. + gateHooks([{ + event: 'prompt.submit', + mode: 'sync', + command: writeHook('deny-then-ok.js', ` + console.log(JSON.stringify({ decision: 'deny', reason: 'policy' })); + process.exit(0); + `), + }]); + const denied = await evaluatePromptGate('prompt.submit', {}); + expect(denied.allowed).toBe(false); + expect(denied.reason).toBe('policy'); + }); + + it('ignores non-JSON chatter on stdout and uses the exit code', async () => { + gateHooks([{ + event: 'prompt.submit', + mode: 'sync', + command: writeHook('chatty.js', ` + console.log('checking permissions...'); + process.exit(0); + `), + }]); + expect((await evaluatePromptGate('prompt.submit', {})).allowed).toBe(true); + }); + + it('fails open on timeout by default, and closed when onError is deny', async () => { + const hang = writeHook('hang.js', `setTimeout(() => {}, 60000);`); + + gateHooks([{ event: 'prompt.submit', mode: 'sync', command: hang, timeoutMs: 300 }]); + const openDecision = await evaluatePromptGate('prompt.submit', {}); + expect(openDecision.allowed).toBe(true); + expect(openDecision.fromError).toBe(true); + + gateHooks([{ event: 'prompt.submit', mode: 'sync', command: hang, timeoutMs: 300, onError: 'deny' }]); + const closedDecision = await evaluatePromptGate('prompt.submit', {}); + expect(closedDecision.allowed).toBe(false); + expect(closedDecision.fromError).toBe(true); + }); + + it('fails open when the hook binary does not exist', async () => { + gateHooks([{ + event: 'prompt.submit', + mode: 'sync', + command: join(tmpDir, 'definitely-not-here'), + }]); + const decision = await evaluatePromptGate('prompt.submit', {}); + expect(decision.allowed).toBe(true); + expect(decision.fromError).toBe(true); + }); + + it('ANDs multiple sync hooks and short-circuits after the first deny', async () => { + const secondRan = join(tmpDir, 'second-ran'); + gateHooks([ + { + event: 'prompt.submit', + mode: 'sync', + command: writeHook('first-deny.js', `console.log(JSON.stringify({ decision: 'deny', reason: 'first' }));`), + }, + { + event: 'prompt.submit', + mode: 'sync', + command: writeHook('second.js', ` + import { writeFileSync } from 'node:fs'; + writeFileSync(${JSON.stringify(secondRan)}, 'ran'); + console.log(JSON.stringify({ decision: 'allow' })); + `), + }, + ]); + const decision = await evaluatePromptGate('prompt.submit', {}); + expect(decision.allowed).toBe(false); + expect(decision.reason).toBe('first'); + expect(existsSync(secondRan)).toBe(false); + }); + + it('requires every sync hook to allow', async () => { + gateHooks([ + { + event: 'prompt.submit', + mode: 'sync', + command: writeHook('ok.js', `console.log(JSON.stringify({ decision: 'allow' }));`), + }, + { + event: 'prompt.submit', + mode: 'sync', + command: writeHook('nope.js', `console.log(JSON.stringify({ decision: 'deny', reason: 'second' }));`), + }, + ]); + const decision = await evaluatePromptGate('prompt.submit', {}); + expect(decision.allowed).toBe(false); + expect(decision.reason).toBe('second'); + }); + + it('honours filters, so an unmatched sender is not adjudicated', async () => { + gateHooks([{ + event: 'prompt.submit', + mode: 'sync', + command: writeHook('deny-all.js', `console.log(JSON.stringify({ decision: 'deny' }));`), + filter: { senderOpenId: 'ou_someone_else' }, + }]); + expect((await evaluatePromptGate('prompt.submit', { senderOpenId: 'ou_me' })).allowed).toBe(true); + expect((await evaluatePromptGate('prompt.submit', { senderOpenId: 'ou_someone_else' })).allowed).toBe(false); + }); + + it('degrades mode:sync to async on non-gate events instead of pretending to block', () => { + const hooks = loadHookConfigs({ + env: { + BOTMUX_HOOKS_JSON: JSON.stringify([ + { event: 'session.start', command: '/bin/true', mode: 'sync' }, + { event: 'prompt.submit', command: '/bin/true', mode: 'sync' }, + ]), + }, + }); + expect(hooks.find(h => h.event === 'session.start')?.mode).toBe('async'); + expect(hooks.find(h => h.event === 'prompt.submit')?.mode).toBe('sync'); + }); + + it('never runs a sync gate hook a second time as a fire-and-forget notification', async () => { + // emitHookEventLocal, NOT emitHookEvent: this test process inherits + // BOTMUX_SESSION_ID/BOTMUX_LARK_APP_ID from the botmux session running it, + // so emitHookEvent takes the forward-to-daemon branch and spawns nothing + // locally — the assertion would pass no matter what the dedup filter did. + // (Verified: mutating away `hook.mode !== 'sync'` left the old version green.) + const marker = join(tmpDir, 'notify-ran'); + const dup = `${marker}.dup`; + gateHooks([{ + event: 'prompt.submit', + mode: 'sync', + command: writeHook('gate-once.js', ` + import { writeFileSync, existsSync } from 'node:fs'; + writeFileSync(existsSync(${JSON.stringify(marker)}) ? ${JSON.stringify(dup)} : ${JSON.stringify(marker)}, 'x'); + console.log(JSON.stringify({ decision: 'allow' })); + `), + }]); + await evaluatePromptGate('prompt.submit', {}); + expect(existsSync(marker)).toBe(true); + + emitHookEventLocal('prompt.submit', {}); + await new Promise(resolve => setTimeout(resolve, 600)); + expect(existsSync(dup)).toBe(false); + }); + + it('still runs async hooks on a gate event (the dedup filter is sync-only)', async () => { + const ran = join(tmpDir, 'async-on-gate-event'); + gateHooks([{ + event: 'prompt.submit', + mode: 'async', + command: writeHook('async-notify.js', ` + import { writeFileSync } from 'node:fs'; + writeFileSync(${JSON.stringify(ran)}, 'x'); + `), + }]); + emitHookEventLocal('prompt.submit', {}); + await new Promise(resolve => setTimeout(resolve, 600)); + expect(existsSync(ran)).toBe(true); + }); + + it('captures stdout only for sync hooks', async () => { + const talker: HookConfig = { + event: 'prompt.submit', + command: writeHook('talker.js', `console.log('some output'); process.exit(0);`), + }; + const captured = await runHookCommandForTest(talker, { event: 'prompt.submit' }, { captureStdout: true }); + expect(captured.stdout).toContain('some output'); + + const ignored = await runHookCommandForTest(talker, { event: 'prompt.submit' }); + expect(ignored.stdout ?? '').toBe(''); + }); +}); diff --git a/test/message-quota-enforcement.test.ts b/test/message-quota-enforcement.test.ts index 7b8591387c..18261c6693 100644 --- a/test/message-quota-enforcement.test.ts +++ b/test/message-quota-enforcement.test.ts @@ -2,7 +2,10 @@ * Message quota enforcement wiring. * Run: pnpm vitest run test/message-quota-enforcement.test.ts */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; const mocks = vi.hoisted(() => ({ consumeQuota: vi.fn(), @@ -556,4 +559,153 @@ describe("p2pMode='group' 建群前扣费点:命令判定必须早于扣费", expect(mocks.maybeBirthSessionGroup).not.toHaveBeenCalled(); expect(mocks.buildQuotaExhaustedCard).toHaveBeenCalled(); }); + + // ─── prompt.submit 同步前置校验闸接进这道闸的接线 ───────────────────────── + // + // 这里不 mock hook-runner:跑真的 evaluatePromptGate + 真的子进程, + // 才能证明「daemon 这一侧确实会问闸、并按裁决动作」。 + describe('prompt.submit sync gate wiring', () => { + let gateDir = ''; + + beforeEach(() => { + gateDir = mkdtempSync(join(tmpdir(), 'quota-gate-')); + }); + + afterEach(() => { + delete process.env.BOTMUX_HOOKS_JSON; + if (gateDir) rmSync(gateDir, { recursive: true, force: true }); + gateDir = ''; + }); + + function installGate(body: string, extra: Record = {}): void { + const script = join(gateDir, 'gate.js'); + writeFileSync(script, body); + process.env.BOTMUX_HOOKS_JSON = JSON.stringify([{ + event: 'prompt.submit', + mode: 'sync', + command: `${process.execPath} ${script}`, + timeoutMs: 5000, + ...extra, + }]); + } + + it('denies the message WITHOUT spending a quota unit', async () => { + installGate(`console.log(JSON.stringify({ decision: 'deny', reason: 'nope' }));`); + + await expect(enforceMessageQuotaForCliInput( + 'quota_app', 'oc_1', 'ou_chat', 'om_gate_deny', 'om_anchor', + undefined, undefined, 'group', false, { promptContent: 'hello' }, + )).resolves.toBe(false); + + // 「扣了费就不能丢任务」的另一半:丢了任务就绝不能扣费。 + expect(mocks.beginCharge).not.toHaveBeenCalled(); + expect(mocks.consumeQuota).not.toHaveBeenCalled(); + }); + + it('allows the message and charges normally when the gate allows', async () => { + installGate(`console.log(JSON.stringify({ decision: 'allow' }));`); + + await expect(enforceMessageQuotaForCliInput( + 'quota_app', 'oc_1', 'ou_chat', 'om_gate_allow', 'om_anchor', + undefined, undefined, 'group', false, { promptContent: 'hello' }, + )).resolves.toBe(true); + + expect(mocks.consumeQuota).toHaveBeenCalledTimes(1); + }); + + it('passes the prompt text and sender to the gate', async () => { + const seen = join(gateDir, 'seen.json'); + installGate(` + import { writeFileSync } from 'node:fs'; + let input = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', c => { input += c; }); + process.stdin.on('end', () => { + writeFileSync(${JSON.stringify(seen)}, input); + console.log(JSON.stringify({ decision: 'allow' })); + }); + `); + + await enforceMessageQuotaForCliInput( + 'quota_app', 'oc_1', 'ou_chat', 'om_gate_payload', 'om_anchor', + undefined, undefined, 'group', false, { promptContent: 'deploy to prod' }, + ); + + expect(JSON.parse(readFileSync(seen, 'utf-8'))).toMatchObject({ + event: 'prompt.submit', + content: 'deploy to prod', + senderOpenId: 'ou_chat', + chatId: 'oc_1', + }); + }); + + it('cannot loosen the built-in permission model', async () => { + installGate(`console.log(JSON.stringify({ decision: 'allow' }));`); + + // 内置闸拒掉的陌生人,hook 说 allow 也不能放进来。 + await expect(enforceMessageQuotaForCliInput( + 'quota_app', 'oc_1', 'ou_stranger', 'om_gate_no_widen', 'om_anchor', + undefined, undefined, 'group', false, { promptContent: 'let me in' }, + )).resolves.toBe(false); + }); + + it('is not consulted for skipCharge control paths that never reach a CLI', async () => { + const ran = join(gateDir, 'ran'); + installGate(` + import { writeFileSync } from 'node:fs'; + writeFileSync(${JSON.stringify(ran)}, 'x'); + console.log(JSON.stringify({ decision: 'deny' })); + `); + + await expect(enforceMessageQuotaForCliInput( + 'quota_app', 'oc_1', 'ou_chat', 'om_gate_setup', 'om_anchor', + undefined, undefined, 'group', false, { skipCharge: true }, + )).resolves.toBe(true); + expect(existsSync(ran)).toBe(false); + }); + + it('adjudicates message-listener traffic too (third-party content still reaches a CLI)', async () => { + installGate(`console.log(JSON.stringify({ decision: 'deny', reason: 'listener content rejected' }));`); + + await expect(enforceMessageQuotaForCliInput( + 'quota_app', 'oc_1', 'ou_chat', 'om_listener_deny', 'om_anchor', + undefined, undefined, 'group', false, + { listenerAuthorized: true, promptContent: 'alert: rm -rf /' }, + )).resolves.toBe(false); + + // Listener path never charges, so a denial must not have touched quota. + expect(mocks.beginCharge).not.toHaveBeenCalled(); + expect(mocks.consumeQuota).not.toHaveBeenCalled(); + }); + + it('still lets listener traffic through when the gate allows', async () => { + installGate(`console.log(JSON.stringify({ decision: 'allow' }));`); + + await expect(enforceMessageQuotaForCliInput( + 'quota_app', 'oc_1', 'ou_chat', 'om_listener_allow', 'om_anchor', + undefined, undefined, 'group', false, + { listenerAuthorized: true, promptContent: 'alert: disk 90%' }, + )).resolves.toBe(true); + expect(mocks.consumeQuota).not.toHaveBeenCalled(); + }); + + it('fails open when the gate hook is broken, and closed when onError is deny', async () => { + installGate(`process.exit(0);`, { command: join(gateDir, 'missing-binary') }); + await expect(enforceMessageQuotaForCliInput( + 'quota_app', 'oc_1', 'ou_chat', 'om_gate_broken_open', 'om_anchor', + undefined, undefined, 'group', false, { promptContent: 'x' }, + )).resolves.toBe(true); + + process.env.BOTMUX_HOOKS_JSON = JSON.stringify([{ + event: 'prompt.submit', + mode: 'sync', + command: join(gateDir, 'missing-binary'), + onError: 'deny', + }]); + await expect(enforceMessageQuotaForCliInput( + 'quota_app', 'oc_1', 'ou_chat', 'om_gate_broken_closed', 'om_anchor', + undefined, undefined, 'group', false, { promptContent: 'x' }, + )).resolves.toBe(false); + }); + }); }); From fd01d58d0d3b015d678ee8374db5e590b8c2c4bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Wed, 2 Sep 2026 22:21:50 -0700 Subject: [PATCH 2/5] =?UTF-8?q?fix(hooks):=20=E5=90=8C=E6=AD=A5=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E9=97=B8=E6=81=92=E5=8F=96=E5=AE=8C=E6=95=B4=E6=AD=A3?= =?UTF-8?q?=E6=96=87=EF=BC=8C=E4=BF=AE=20600=20=E5=AD=97=E7=AC=A6=E6=88=AA?= =?UTF-8?q?=E6=96=AD=E5=AF=BC=E8=87=B4=E7=9A=84=E7=BB=95=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 例全绿。 --- docs-site/docs/en/hooks.md | 2 ++ docs-site/docs/zh/hooks.md | 2 ++ src/services/hook-runner.ts | 16 +++++++++- test/hook-runner.test.ts | 58 +++++++++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 1 deletion(-) diff --git a/docs-site/docs/en/hooks.md b/docs-site/docs/en/hooks.md index c6f04c0e22..81b7f94cbe 100644 --- a/docs-site/docs/en/hooks.md +++ b/docs-site/docs/en/hooks.md @@ -134,6 +134,8 @@ Stdout must be a **whole JSON object** to count as a verdict. Printing an ordina - **A broken hook is not a rejection.** Timeout, missing command, and crashes all follow `onError`, which defaults to `allow` — a broken checker should not brick the whole bot. Set `onError: "deny"` explicitly for the opposite. - **The latency lands directly on the inbound path.** Keep `timeoutMs` small (1–3s). Bot-level admission is concurrent so a slow gate will not stall the whole daemon, but replies **within one topic** hold an ordering lock — a slow gate makes later messages in that topic queue up. Do not lean on a large timeout to paper over a slow service. With no sync hook configured there is zero overhead — no spawn is added per message. - **Message-listener traffic is adjudicated too.** That content comes from third parties (alert bots and the like) and still reaches a CLI, so it is exactly what a gate should inspect. That path never charges quota; a denial is logged only, with no reply (there is no human sender to answer). +- **A gate receives the full content, exempt from the 600-character truncation.** That truncation exists for notification hooks; for a gate the content *is* the input to the decision, so truncating it makes the gate structurally blind past the limit (pad 600 characters and hide the payload behind them). + ⚠️ **Privacy implication**: configuring a sync gate hands that command the **full message text**. Async hooks are unaffected and still truncate. - A given hook entry **runs only once**: after running as the gate, it is not fired again as an async notification. ## Practical: Auto-Update Skills with session.start diff --git a/docs-site/docs/zh/hooks.md b/docs-site/docs/zh/hooks.md index 5aec403bba..0118e0115e 100644 --- a/docs-site/docs/zh/hooks.md +++ b/docs-site/docs/zh/hooks.md @@ -134,6 +134,8 @@ stdout 必须是**整段 JSON 对象**才会被当作裁决。打印一行普通 - **hook 坏了不等于拒绝**:超时、找不到命令、崩溃都走 `onError`,默认 `allow`——校验器挂掉不该让整个 bot 变砖头。要反过来就显式写 `onError: "deny"`。 - **延迟直接加在收信路径上**:`timeoutMs` 建议设小(1-3s)。bot 级并发不会因此卡死整个 daemon,但**同一话题**的续聊持有顺序锁——慢闸会让该话题的后续消息排队。别指望用大超时兜住一个慢服务。没配 sync hook 时零开销,不会给每条消息加 spawn。 - **消息监听器(message listener)命中的第三方内容也会过闸**:那类内容来自告警 bot 等外部来源、同样会进 CLI,正是最该校验的。该路径本来就不扣额度,被拒时只记日志、不回消息(没有可回复的真人发送者)。 +- **闸拿到的是完整正文,不受 600 字符截断影响**:截断是为通知类 hook 设计的,而闸的判断依据就是内容本身——截断会让它对超长输入结构性失明(把恶意内容垫到 600 字符之后即可绕过)。 + ⚠️ **隐私含义**:配了 sync 闸就等于把**完整消息正文**交给那个命令。异步 hook 仍按原规则截断,未受影响。 - 同一条 hook 配置**只会跑一次**:作为闸执行后,不会再作为异步通知重复触发。 ### 快速验证 diff --git a/src/services/hook-runner.ts b/src/services/hook-runner.ts index f18dfe657a..9bfe815e7e 100644 --- a/src/services/hook-runner.ts +++ b/src/services/hook-runner.ts @@ -39,6 +39,11 @@ export function isGateEvent(event: HookEvent): boolean { return GATE_EVENTS.includes(event); } +/** A hook that actually adjudicates (blocking) rather than merely observing. */ +function isSyncGateHook(hook: { event: HookEvent; mode?: 'sync' | 'async' }): boolean { + return hook.mode === 'sync' && isGateEvent(hook.event); +} + export type HookFilter = { chatId?: string | string[]; senderOpenId?: string | string[]; @@ -224,7 +229,16 @@ export function loadHookConfigs(opts: { } export function prepareHookPayload(hook: HookConfig, rawPayload: HookPayload): HookPayload { - const allowFullContent = !!hook.redact?.fullContentEvents?.includes(rawPayload.event); + // A sync gate hook DECIDES on this content, so truncating it would make the + // gate structurally blind past CONTENT_PREVIEW_LIMIT: an attacker just pads + // 600 chars and hides the payload behind them. (Verified before the fix: a + // grep-for-`rm -rf /` gate allowed `'A'.repeat(700) + ' rm -rf /'` while + // correctly denying the same string when short.) The preview limit exists to + // keep notification hooks from swallowing huge payloads — that rationale + // does not apply to a verdict input. Privacy note for operators is in + // hooks.md: configuring a sync gate hands it the full message text. + const allowFullContent = isSyncGateHook(hook) + || !!hook.redact?.fullContentEvents?.includes(rawPayload.event); const payload: HookPayload = { ...rawPayload }; for (const field of CONTENT_FIELDS) { diff --git a/test/hook-runner.test.ts b/test/hook-runner.test.ts index 41c71063ac..16b8154a48 100644 --- a/test/hook-runner.test.ts +++ b/test/hook-runner.test.ts @@ -563,6 +563,64 @@ describe('sync gate hooks (prompt.submit)', () => { expect((await evaluatePromptGate('prompt.submit', {})).allowed).toBe(true); }); + it('gives a sync gate the FULL content, so padding cannot hide past the preview limit', async () => { + // Regression: prepareHookPayload truncates content at 600 chars for + // notification hooks. Reusing that for a gate made it structurally blind — + // an attacker pads 600 chars and hides the payload behind them. Verified + // pre-fix: a grep gate allowed 'A'.repeat(700) + ' rm -rf /'. + const seen = join(tmpDir, 'gate-content.json'); + gateHooks([{ + event: 'prompt.submit', + mode: 'sync', + command: writeHook('content-gate.js', ` + import { writeFileSync } from 'node:fs'; + let input = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', c => { input += c; }); + process.stdin.on('end', () => { + const p = JSON.parse(input); + writeFileSync(${JSON.stringify(seen)}, JSON.stringify({ + len: p.content.length, + truncated: p.contentTruncated, + sawTail: p.content.includes('SENTINEL_TAIL'), + })); + console.log(JSON.stringify({ + decision: p.content.includes('SENTINEL_TAIL') ? 'deny' : 'allow', + })); + }); + `), + }]); + + const padded = `${'A'.repeat(700)} SENTINEL_TAIL`; + const decision = await evaluatePromptGate('prompt.submit', { content: padded }); + + expect(JSON.parse(readFileSync(seen, 'utf-8'))).toMatchObject({ + len: padded.length, + truncated: false, + sawTail: true, + }); + expect(decision.allowed).toBe(false); + }); + + it('still truncates content for async hooks, including on the gate event', () => { + const long = 'B'.repeat(900); + const asyncOnGateEvent = prepareHookPayload( + { event: 'prompt.submit', command: '/bin/true', mode: 'async' }, + { event: 'prompt.submit', content: long }, + ); + expect(asyncOnGateEvent.content).toHaveLength(600); + expect(asyncOnGateEvent.contentTruncated).toBe(true); + + // `sync` on a non-gate event is degraded to async at load time, but guard + // the raw shape too: full content must follow the GATE event, not the flag. + const syncOnNonGateEvent = prepareHookPayload( + { event: 'session.idle', command: '/bin/true', mode: 'sync' }, + { event: 'session.idle', content: long }, + ); + expect(syncOnNonGateEvent.content).toHaveLength(600); + expect(syncOnNonGateEvent.contentTruncated).toBe(true); + }); + it('lets a stdout verdict win over a conflicting exit code (documented precedence)', async () => { // A checker that prints "allow" and then dies in its own cleanup still meant // allow. Documented in hooks.md as "stdout JSON takes precedence". From 946ad5c6bf26392be8e9b60c745ba9ec521e9329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Wed, 2 Sep 2026 22:27:51 -0700 Subject: [PATCH 3/5] =?UTF-8?q?feat(hooks):=20=E6=A0=A1=E9=AA=8C=E9=97=B8?= =?UTF-8?q?=E8=A1=A5=E9=99=84=E4=BB=B6=E5=85=83=E4=BF=A1=E6=81=AF=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E5=86=99=E6=98=8E=E3=80=8C=E7=9C=8B=E4=B8=8D=E5=88=B0?= =?UTF-8?q?=E9=99=84=E4=BB=B6=E5=86=85=E5=AE=B9=E3=80=8D=E7=9A=84=E8=BE=B9?= =?UTF-8?q?=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 排查上一条截断绕过时发现同一形状的第二处输入盲区:闸只拿到正文,而本轮 附带的图片/文件对它完全不可见——但那些附件确实会进 CLI。 不把闸挪到下载之后:下载必须排在授权之后(否则未授权发送者也能让 bot 去 拉文件),而闸又必须排在扣费之前,两者不可兼得。因此按能力如实收敛:闸拿 `attachments: [{type, name}]` 元信息(不含内容),足以支撑「禁止上传 .env」 「只许图片」这类策略;文件内容级判断明确不在能力范围内,已写进中英文文档, 避免运维以为配了闸就等于扫了附件。 只传 type + name,不传 key/messageId:那两个是可用于拉取资源的句柄,闸没有 下载能力也不该获得。 新话题与话题续聊两条入口均已接线。反变异:去掉转发 → 新守卫精确变红。 hook-runner 39 例、配额接线 34 例、改动面 7 文件 112 例全绿。 --- docs-site/docs/en/hooks.md | 3 ++- docs-site/docs/zh/hooks.md | 3 ++- src/daemon.ts | 10 +++++++- test/message-quota-enforcement.test.ts | 35 ++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/docs-site/docs/en/hooks.md b/docs-site/docs/en/hooks.md index 81b7f94cbe..714fc28bee 100644 --- a/docs-site/docs/en/hooks.md +++ b/docs-site/docs/en/hooks.md @@ -84,7 +84,7 @@ Different events carry extra fields: | Event | Extra fields | |------|----------| | `topic.new` | `messageId`, `senderOpenId`, `senderType`, `msgType`, `content` | -| `prompt.submit` | `messageId`, `chatId`, `chatType`, `anchor`, `senderOpenId`, `senderUnionId`, `memberUnionId`, `botSender`, `talkReason`, `content` | +| `prompt.submit` | `messageId`, `chatId`, `chatType`, `anchor`, `senderOpenId`, `senderUnionId`, `memberUnionId`, `botSender`, `talkReason`, `content`, `attachments` (`[{type,name}]`, metadata only) | | `thread.reply` | `messageId`, `rootId`, `parentId`, `senderOpenId`, `senderType`, `msgType`, `content` | | `outbound.send` | `messageId`, `msgType`, `uuid`, `content` | | `outbound.reply` | `messageId`, `replyId`, `msgType`, `replyInThread`, `uuid`, `content` | @@ -136,6 +136,7 @@ Stdout must be a **whole JSON object** to count as a verdict. Printing an ordina - **Message-listener traffic is adjudicated too.** That content comes from third parties (alert bots and the like) and still reaches a CLI, so it is exactly what a gate should inspect. That path never charges quota; a denial is logged only, with no reply (there is no human sender to answer). - **A gate receives the full content, exempt from the 600-character truncation.** That truncation exists for notification hooks; for a gate the content *is* the input to the decision, so truncating it makes the gate structurally blind past the limit (pad 600 characters and hide the payload behind them). ⚠️ **Privacy implication**: configuring a sync gate hands that command the **full message text**. Async hooks are unaffected and still truncate. +- **A gate sees attachment metadata, not attachment content.** The `attachments` field carries this turn's `[{type,name}]` (e.g. `[{"type":"file","name":"prod.env"}]`), enough for "no .env uploads" or "images only" policies. But the gate runs *before* the files are downloaded (downloading must stay behind authorization, or an unauthorized sender could make the bot fetch files), so it **cannot decide on file contents**. - A given hook entry **runs only once**: after running as the gate, it is not fired again as an async notification. ## Practical: Auto-Update Skills with session.start diff --git a/docs-site/docs/zh/hooks.md b/docs-site/docs/zh/hooks.md index 0118e0115e..193ca1d614 100644 --- a/docs-site/docs/zh/hooks.md +++ b/docs-site/docs/zh/hooks.md @@ -84,7 +84,7 @@ tail -f /tmp/botmux-hook.log | 事件 | 额外字段 | |------|----------| | `topic.new` | `messageId`、`senderOpenId`、`senderType`、`msgType`、`content` | -| `prompt.submit` | `messageId`、`chatId`、`chatType`、`anchor`、`senderOpenId`、`senderUnionId`、`memberUnionId`、`botSender`、`talkReason`、`content` | +| `prompt.submit` | `messageId`、`chatId`、`chatType`、`anchor`、`senderOpenId`、`senderUnionId`、`memberUnionId`、`botSender`、`talkReason`、`content`、`attachments`(`[{type,name}]`,仅元信息) | | `thread.reply` | `messageId`、`rootId`、`parentId`、`senderOpenId`、`senderType`、`msgType`、`content` | | `outbound.send` | `messageId`、`msgType`、`uuid`、`content` | | `outbound.reply` | `messageId`、`replyId`、`msgType`、`replyInThread`、`uuid`、`content` | @@ -136,6 +136,7 @@ stdout 必须是**整段 JSON 对象**才会被当作裁决。打印一行普通 - **消息监听器(message listener)命中的第三方内容也会过闸**:那类内容来自告警 bot 等外部来源、同样会进 CLI,正是最该校验的。该路径本来就不扣额度,被拒时只记日志、不回消息(没有可回复的真人发送者)。 - **闸拿到的是完整正文,不受 600 字符截断影响**:截断是为通知类 hook 设计的,而闸的判断依据就是内容本身——截断会让它对超长输入结构性失明(把恶意内容垫到 600 字符之后即可绕过)。 ⚠️ **隐私含义**:配了 sync 闸就等于把**完整消息正文**交给那个命令。异步 hook 仍按原规则截断,未受影响。 +- **闸看得到附件的元信息,但看不到附件内容**:`attachments` 字段给出本轮的 `[{type,name}]`(如 `[{"type":"file","name":"prod.env"}]`),足以写「禁止上传 .env」「只许图片」这类策略;但闸跑在**附件下载之前**(下载必须排在授权之后,否则未授权者也能让 bot 去拉文件),所以**无法按文件内容判断**。 - 同一条 hook 配置**只会跑一次**:作为闸执行后,不会再作为异步通知重复触发。 ### 快速验证 diff --git a/src/daemon.ts b/src/daemon.ts index ea538594d7..de62e0fe65 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -3795,6 +3795,11 @@ export async function enforceMessageQuotaForCliInput( /** 本轮要喂给 CLI 的用户文本,交给 prompt.submit 同步校验闸做内容级判断。 * 不传 = 调用方没有可判定的正文(纯控制路径),闸仍会跑但 content 为空。 */ promptContent?: string; + /** 本轮附带的图片/文件**元信息**(type + name,不含内容)。 + * 闸跑在附件下载之前(下载必须排在授权之后,否则未授权者也能让 bot 拉文件), + * 所以闸看不到文件内容——但至少能看到「这轮带了什么附件」,让 + * 「禁止上传 .env / 只许图片」这类策略可写。见 hooks.md 的边界说明。 */ + promptAttachments?: Array<{ type: string; name: string }>; }, ): Promise { if (opts?.listenerAuthorized) { @@ -3813,6 +3818,7 @@ export async function enforceMessageQuotaForCliInput( botSender: !!botSender, talkReason: 'messageListener', content: opts.promptContent, + attachments: opts.promptAttachments, }); if (!listenerGate.allowed) { logger.info( @@ -3876,6 +3882,7 @@ export async function enforceMessageQuotaForCliInput( botSender: !!botSender, talkReason: ev.reason, content: opts?.promptContent, + attachments: opts?.promptAttachments, }); if (!gate.allowed) { logger.info( @@ -18295,6 +18302,7 @@ async function handleNewTopicAdmitted(data: any, ctx: RoutingContext): Promise ({ type: r.type, name: r.name })), })) { return; } @@ -19870,7 +19878,7 @@ async function handleThreadReplyAdmitted( threadSenderUnionId, ctxChatType, isBotSenderType || isForeignBot, - { promptContent: parsed.content }, + { promptContent: parsed.content, promptAttachments: resources.map(r => ({ type: r.type, name: r.name })) }, )) { return; } diff --git a/test/message-quota-enforcement.test.ts b/test/message-quota-enforcement.test.ts index 18261c6693..850c1de2e8 100644 --- a/test/message-quota-enforcement.test.ts +++ b/test/message-quota-enforcement.test.ts @@ -639,6 +639,41 @@ describe("p2pMode='group' 建群前扣费点:命令判定必须早于扣费", }); }); + it('sees attachment metadata even though the files are not downloaded yet', async () => { + // The gate runs BEFORE downloadResources (downloading must stay behind + // authorization, or an unauthorized sender could make the bot fetch + // files). So a gate cannot inspect file CONTENT — but it must at least + // see what is arriving, so "no .env uploads" style policy is writable. + const seen = join(gateDir, 'seen-attachments.json'); + installGate(` + import { writeFileSync } from 'node:fs'; + let input = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', c => { input += c; }); + process.stdin.on('end', () => { + const p = JSON.parse(input); + writeFileSync(${JSON.stringify(seen)}, JSON.stringify(p.attachments ?? null)); + const bad = (p.attachments ?? []).some(a => a.name.endsWith('.env')); + console.log(JSON.stringify(bad + ? { decision: 'deny', reason: 'secrets file rejected' } + : { decision: 'allow' })); + }); + `); + + await expect(enforceMessageQuotaForCliInput( + 'quota_app', 'oc_1', 'ou_chat', 'om_gate_attach', 'om_anchor', + undefined, undefined, 'group', false, + { + promptContent: 'have a look', + promptAttachments: [{ type: 'file', name: 'prod.env' }], + }, + )).resolves.toBe(false); + + expect(JSON.parse(readFileSync(seen, 'utf-8'))).toEqual([ + { type: 'file', name: 'prod.env' }, + ]); + }); + it('cannot loosen the built-in permission model', async () => { installGate(`console.log(JSON.stringify({ decision: 'allow' }));`); From 109e3c3986eec6e7cf29bb567315ece9bfc95553 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Wed, 2 Sep 2026 22:53:12 -0700 Subject: [PATCH 4/5] =?UTF-8?q?fix(hooks):=20=E4=BF=AE=E5=87=BA=E7=94=9F?= =?UTF-8?q?=E8=BD=AE=E9=97=B8=E5=86=85=E5=AE=B9=E5=8F=8C=E7=9B=B2=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E8=A1=A5=20passthrough=20=E9=99=84=E4=BB=B6=E4=B8=8E?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E8=BE=B9=E7=95=8C=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审发现的阻断项: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 错。 --- docs-site/docs/en/hooks.md | 1 + docs-site/docs/zh/hooks.md | 1 + src/daemon.ts | 31 +++++++++++++++-- src/services/hook-runner.ts | 22 +++++------- test/message-quota-enforcement.test.ts | 46 ++++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 15 deletions(-) diff --git a/docs-site/docs/en/hooks.md b/docs-site/docs/en/hooks.md index 714fc28bee..a876d7c16e 100644 --- a/docs-site/docs/en/hooks.md +++ b/docs-site/docs/en/hooks.md @@ -137,6 +137,7 @@ Stdout must be a **whole JSON object** to count as a verdict. Printing an ordina - **A gate receives the full content, exempt from the 600-character truncation.** That truncation exists for notification hooks; for a gate the content *is* the input to the decision, so truncating it makes the gate structurally blind past the limit (pad 600 characters and hide the payload behind them). ⚠️ **Privacy implication**: configuring a sync gate hands that command the **full message text**. Async hooks are unaffected and still truncate. - **A gate sees attachment metadata, not attachment content.** The `attachments` field carries this turn's `[{type,name}]` (e.g. `[{"type":"file","name":"prod.env"}]`), enough for "no .env uploads" or "images only" policies. But the gate runs *before* the files are downloaded (downloading must stay behind authorization, or an unauthorized sender could make the bot fetch files), so it **cannot decide on file contents**. +- **Coverage: the inbound-message entry only.** New topics, thread replies, slash-command cold starts, session-group birth turns, and message-listener matches all pass through the gate. **Scheduled tasks and workflow-generated prompts do not** — those are automation the operator pre-authorized, not external input. Do not read the gate as "everything reaching the CLI was checked". - A given hook entry **runs only once**: after running as the gate, it is not fired again as an async notification. ## Practical: Auto-Update Skills with session.start diff --git a/docs-site/docs/zh/hooks.md b/docs-site/docs/zh/hooks.md index 193ca1d614..5559da9646 100644 --- a/docs-site/docs/zh/hooks.md +++ b/docs-site/docs/zh/hooks.md @@ -137,6 +137,7 @@ stdout 必须是**整段 JSON 对象**才会被当作裁决。打印一行普通 - **闸拿到的是完整正文,不受 600 字符截断影响**:截断是为通知类 hook 设计的,而闸的判断依据就是内容本身——截断会让它对超长输入结构性失明(把恶意内容垫到 600 字符之后即可绕过)。 ⚠️ **隐私含义**:配了 sync 闸就等于把**完整消息正文**交给那个命令。异步 hook 仍按原规则截断,未受影响。 - **闸看得到附件的元信息,但看不到附件内容**:`attachments` 字段给出本轮的 `[{type,name}]`(如 `[{"type":"file","name":"prod.env"}]`),足以写「禁止上传 .env」「只许图片」这类策略;但闸跑在**附件下载之前**(下载必须排在授权之后,否则未授权者也能让 bot 去拉文件),所以**无法按文件内容判断**。 +- **覆盖范围:只管「人/外部消息进 CLI」这条入口**。新话题、话题续聊、斜杠命令冷启动、会话群出生轮、消息监听器命中——都过闸。但**定时任务与 workflow 自动跑出来的 prompt 不过闸**(那是运维自己预先授权的自动化,不是外部输入)。别把它当成「所有进 CLI 的文本都查过了」。 - 同一条 hook 配置**只会跑一次**:作为闸执行后,不会再作为异步通知重复触发。 ### 快速验证 diff --git a/src/daemon.ts b/src/daemon.ts index de62e0fe65..1d0b10b730 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -17442,6 +17442,8 @@ async function startInitialPassthroughSession(args: { messageId: string; replyRootId?: string; parsed: LarkMessage; + /** 本轮附件元信息,转给 prompt.submit 校验闸(内容不在此传)。 */ + promptResources?: MessageResource[]; cmd: string; commandContent: string; senderOpenId?: string; @@ -17476,12 +17478,15 @@ async function startInitialPassthroughSession(args: { }): Promise { const { larkAppId, chatId, chatType, scope, anchor, messageId, replyRootId, - parsed, cmd, commandContent, senderOpenId, substitute, senderUnionId, + parsed, promptResources, cmd, commandContent, senderOpenId, substitute, senderUnionId, memberUnionId, ownerOpenId, ownerUnionId, creatorOpenId, botSender, senderIsBot, cardlessForceTopicSeed, onDurablyAdmitted, routeToCanonicalOwner, } = args; - if (!await enforceMessageQuotaForCliInput(larkAppId, chatId, senderOpenId, messageId, anchor, senderUnionId, memberUnionId, chatType, botSender, { promptContent: commandContent || parsed.content })) { + if (!await enforceMessageQuotaForCliInput(larkAppId, chatId, senderOpenId, messageId, anchor, senderUnionId, memberUnionId, chatType, botSender, { + promptContent: commandContent || parsed.content, + promptAttachments: promptResources?.map(r => ({ type: r.type, name: r.name })), + })) { return; } // Reply attribution's is-bot. `resolvedSenderIsBot` is a BOOLEAN for the @@ -17813,6 +17818,24 @@ async function handleNewTopicAdmitted(data: any, ctx: RoutingContext): Promise | undefined; + try { + const preview = parseEventMessage(data, createImgNumberer()); + birthPromptContent = preview.parsed.content; + birthPromptAttachments = preview.resources.map(r => ({ type: r.type, name: r.name })); + } catch (err) { + logger.debug(`[hooks:${larkAppId}] birth-turn prompt preview failed, gating on metadata only: ${err}`); + } if (!await enforceMessageQuotaForCliInput( larkAppId, chatId, @@ -17822,6 +17845,8 @@ async function handleNewTopicAdmitted(data: any, ctx: RoutingContext): Promise 2_000) stderr = stderr.slice(-2_000); }); - // Keep the HEAD of stdout, not the tail: the verdict JSON is the whole - // document, so a hook that prints its verdict then dumps debug noise must - // still parse. (stderr keeps the tail — there the last error matters most.) + // Keep the HEAD of stdout, not the tail. parseHookVerdict requires the + // WHOLE of stdout to be one JSON object, so verdict-then-debug-noise does + // NOT parse — it falls back to the exit code. Keeping the head means the + // verdict is at least intact for diagnosis in that case, and a hook that + // only prints its verdict is unaffected. (stderr keeps the tail instead: + // there the last error is what matters.) child.stdout?.setEncoding('utf8'); child.stdout?.on('data', chunk => { if (stdout.length < STDOUT_CAPTURE_LIMIT) stdout += String(chunk); @@ -583,7 +586,9 @@ export type PromptGateDecision = { fromError?: boolean; }; -const GATE_ALLOW: PromptGateDecision = { allowed: true }; +// Frozen: this single instance is handed to every allow path, so an in-place +// mutation by a future caller would silently corrupt every later verdict. +const GATE_ALLOW: PromptGateDecision = Object.freeze({ allowed: true }); /** 拒绝原因回显给用户前的长度上限——hook 的 stderr/stdout 不该变成刷屏面。 */ const GATE_REASON_LIMIT = 300; @@ -705,15 +710,6 @@ export async function evaluatePromptGate( } } -/** 该事件当前是否配了 sync hook——调用方用它避开无谓的 payload 组装。 */ -export function hasSyncGateHooks(event: HookEvent): boolean { - try { - if (!isGateEvent(event)) return false; - return loadHookConfigs().some(hook => hook.event === event && hook.mode === 'sync'); - } catch { - return false; - } -} const HOOK_FORWARD_FETCH_TIMEOUT_MS = 2_000; diff --git a/test/message-quota-enforcement.test.ts b/test/message-quota-enforcement.test.ts index 850c1de2e8..f9514974e0 100644 --- a/test/message-quota-enforcement.test.ts +++ b/test/message-quota-enforcement.test.ts @@ -513,6 +513,52 @@ describe("p2pMode='group' 建群前扣费点:命令判定必须早于扣费", )); }); + it('建群前扣费点也把正文交给同步校验闸(出生轮不得内容双盲)', async () => { + // Review 发现的阻断项:建群前扣费点调 enforceMessageQuotaForCliInput 时 + // 不传 opts,而改写后的那一轮带 alreadyAuthorizedAndCharged 提前 return, + // 于是 p2pMode='group' 下**每个会话群的第一条消息**(正是开场 prompt) + // 闸只拿到元信息 —— sender 级规则仍在,内容级规则全盲。 + const dir = mkdtempSync(join(tmpdir(), 'birth-gate-')); + const seen = join(dir, 'seen.json'); + const script = join(dir, 'gate.js'); + writeFileSync(script, ` + import { writeFileSync } from 'node:fs'; + let input = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', c => { input += c; }); + process.stdin.on('end', () => { + const p = JSON.parse(input); + writeFileSync(${JSON.stringify(seen)}, JSON.stringify({ + hasContent: typeof p.content === 'string' && p.content.length > 0, + content: p.content ?? null, + })); + console.log(JSON.stringify({ decision: 'allow' })); + }); + `); + process.env.BOTMUX_HOOKS_JSON = JSON.stringify([{ + event: 'prompt.submit', + mode: 'sync', + command: `${process.execPath} ${script}`, + timeoutMs: 5000, + }]); + + try { + // handleNewTopic 会一路走到 forkWorker(本套件没有初始化 WorkerPool), + // 那发生在闸之后 —— 我们只关心闸看到了什么,所以吞掉这个下游错误。 + await handleNewTopic(dmEvent('帮我 SENTINEL_BIRTH 一下', 'om_birth_gate'), dmCtx('om_birth_gate')) + .catch((err: any) => { + if (!/WorkerPool not initialised/.test(String(err?.message ?? err))) throw err; + }); + expect(JSON.parse(readFileSync(seen, 'utf-8'))).toMatchObject({ + hasContent: true, + content: expect.stringContaining('SENTINEL_BIRTH'), + }); + } finally { + delete process.env.BOTMUX_HOOKS_JSON; + rmSync(dir, { recursive: true, force: true }); + } + }); + it('/help 不扣额度、也不触发建群(命令本来不进 CLI)', async () => { await handleNewTopic(dmEvent('/help', 'om_help'), dmCtx('om_help')); From e45edd5d6e03e3a9d1bc9abe565c279533e64e12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Wed, 2 Sep 2026 23:11:29 -0700 Subject: [PATCH 5/5] =?UTF-8?q?docs(hooks):=20=E5=86=99=E6=98=8E=20prompt.?= =?UTF-8?q?submit=20=E7=9A=84=E7=A1=AE=E5=88=87=E8=A7=A6=E5=8F=91=E6=97=B6?= =?UTF-8?q?=E6=9C=BA=EF=BC=8C=E5=B9=B6=E8=AF=B4=E6=98=8E=20outbound.*=20?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E6=8B=A6=E6=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户提出的疑问:闸是「prompt 送进 CLI 之前」还是「已输入到 CLI、按 Enter 之前」。后者在本架构下不存在——写文本与按 Enter 是同一次适配器调用中的原子 动作(writeInput 逐行打字,末尾 Enter 即提交),中间没有可插入的停顿。补上 完整链路图,并点明闸执行于 daemon 进程、此时 CLI 子进程尚未拿到该轮输入。 同时说明 outbound.send / outbound.reply 为何不能当拦截点:它们的发射位置在 飞书 API 调用成功之后(需先拿到 messageId),那一刻消息已在群里,加 sync 也 只能事后撤回。写 sync 会降级 async 并告警——不让配置声称发射点做不到的事。 --- docs-site/docs/en/hooks.md | 16 ++++++++++++++++ docs-site/docs/zh/hooks.md | 15 +++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/docs-site/docs/en/hooks.md b/docs-site/docs/en/hooks.md index a876d7c16e..e8655bf331 100644 --- a/docs-site/docs/en/hooks.md +++ b/docs-site/docs/en/hooks.md @@ -60,6 +60,8 @@ After any hook event fires, you'll see the JSON payload in the log. `examples/ho | `filter.senderOpenId` | string|string[] | Optional. Only match the specified sender open_id | | `redact.fullContentEvents` | string[] | Optional. Long text is truncated by default; events in this allowlist pass through the full text | +> **`outbound.send` / `outbound.reply` cannot intercept.** They fire *after* the Lark API call succeeds (the `messageId` is required to build the payload), by which point the message is already in the chat; `mode:"sync"` there could only retract after the fact, which is not interception. Declaring `sync` on them degrades to async with a warning. + ## Supported Events | Event | Trigger | @@ -114,6 +116,20 @@ A regular hook is a *notification* — nothing reads its result. `prompt.submit` A ready-to-adapt example ships in the repo: `examples/hooks/prompt-gate.sh`. +### When it fires + +**The gate runs *before* the prompt is handed to the CLI — not "typed into the box, before Enter".** + +That second moment does not exist in botmux: writing the text and pressing Enter are a single atomic adapter call (`writeInput` types line by line and the trailing Enter *is* the submit), with no insertion point between them. + +``` +Lark message → built-in permission checks → 🚦 prompt.submit gate → charge quota + → download attachments → createSession/forkWorker → IPC to worker + → writeInput (type + Enter, atomic) → CLI +``` + +The gate runs in the **daemon** process, before the CLI subprocess has this turn's input at all (for a new topic the CLI has not even been forked). A denied message never existed as far as the CLI is concerned. + ### Expressing a verdict Two ways; **JSON on stdout takes precedence over the exit code**: diff --git a/docs-site/docs/zh/hooks.md b/docs-site/docs/zh/hooks.md index 5559da9646..bbcdc6debe 100644 --- a/docs-site/docs/zh/hooks.md +++ b/docs-site/docs/zh/hooks.md @@ -60,6 +60,8 @@ tail -f /tmp/botmux-hook.log | `filter.senderOpenId` | string|string[] | 可选。只匹配指定发送者 open_id | | `redact.fullContentEvents` | string[] | 可选。默认截断长文本;列入 allowlist 的事件透传全文 | +> **`outbound.send` / `outbound.reply` 不能用来拦截。** 它们的发射点在飞书 API 调用**成功之后**(需先拿到 `messageId`),那一刻消息已经在群里了;加 `mode:"sync"` 也只能事后撤回,不是拦截。这两个事件写 `sync` 会降级为 async 并告警。 + ## 支持事件 | 事件 | 触发时机 | @@ -114,6 +116,19 @@ tail -f /tmp/botmux-hook.log 仓库内置可直接改的示例:`examples/hooks/prompt-gate.sh`。 +### 触发时机 + +**闸跑在「prompt 还没送进 CLI 之前」——不是「已经输进输入框、按 Enter 之前」。** + +后一种时机在 botmux 里**不存在**:写文本与按 Enter 是**同一次适配器调用中的原子动作**(`writeInput` 逐行打字、末尾那个 Enter 即提交),中间没有可插入的停顿。 + +``` +飞书消息 → 内置权限校验 → 🚦 prompt.submit 闸 → 扣额度 → 下载附件 + → createSession/forkWorker → IPC 到 worker → writeInput(打字+Enter,原子)→ CLI +``` + +闸执行于 **daemon 进程**,此时 CLI 子进程尚未拿到这一轮输入(新话题下 CLI 甚至还没 fork)。被拒的消息,CLI 完全不知道它存在过。 + ### 怎么表达裁决 两种写法,**stdout 的 JSON 优先于退出码**: