From e84a11411a00f0799336268d102a956c4df929d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=8E=89=E6=B6=B5?= Date: Thu, 12 Mar 2026 03:22:09 +0800 Subject: [PATCH 1/2] feat(plugins): add `before_dispatch` hook for pre-LLM message interception Add a new plugin hook `before_dispatch` that fires in `dispatchReplyFromConfig` after `message_received` hooks and before LLM invocation. This allows plugins to block the entire dispatch and optionally send a direct reply to the user. Use case: security plugins (e.g. authentication gates) can prevent unauthenticated sessions from consuming LLM tokens by blocking the dispatch early and returning a "please login" message. Refs: #43418 Made-with: Cursor --- src/auto-reply/reply/dispatch-from-config.ts | 28 +++++++++++ src/plugins/hooks.ts | 50 ++++++++++++++++++++ src/plugins/types.ts | 26 ++++++++++ 3 files changed, 104 insertions(+) diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 5b250b03362..70616302259 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -208,6 +208,34 @@ export async function dispatchReplyFromConfig(params: { ); } + // Run before_dispatch plugin hooks (blocking — can abort dispatch before LLM). + // This lets security plugins (e.g. authentication gates) prevent LLM invocation + // entirely for unauthenticated sessions, saving tokens and enforcing access. + if (hookRunner?.hasHooks("before_dispatch") && sessionKey) { + const beforeDispatchResult = await hookRunner.runBeforeDispatch( + { + sessionKey, + channelId: channel, + senderId: ctx.SenderId, + conversationId: hookContext.conversationId, + isGroup, + content: hookContext.content, + messageId: messageIdForHook, + }, + toPluginMessageContext(hookContext), + ); + if (beforeDispatchResult?.block) { + if (beforeDispatchResult.replyText) { + dispatcher.sendFinalReply({ text: beforeDispatchResult.replyText }); + } + recordProcessed("skipped", { reason: "before_dispatch_blocked" }); + return { + queuedFinal: Boolean(beforeDispatchResult.replyText), + counts: dispatcher.getQueuedCounts(), + }; + } + } + // Check if we should route replies to originating channel instead of dispatcher. // Only route when the originating channel is DIFFERENT from the current surface. // This handles cross-provider routing (e.g., message from Telegram being processed diff --git a/src/plugins/hooks.ts b/src/plugins/hooks.ts index 4d74267d4ca..027b027ff4d 100644 --- a/src/plugins/hooks.ts +++ b/src/plugins/hooks.ts @@ -50,6 +50,8 @@ import type { PluginHookToolResultPersistResult, PluginHookBeforeMessageWriteEvent, PluginHookBeforeMessageWriteResult, + PluginHookBeforeDispatchEvent, + PluginHookBeforeDispatchResult, } from "./types.js"; // Re-export types for consumers @@ -81,6 +83,8 @@ export type { PluginHookToolResultPersistResult, PluginHookBeforeMessageWriteEvent, PluginHookBeforeMessageWriteResult, + PluginHookBeforeDispatchEvent, + PluginHookBeforeDispatchResult, PluginHookSessionContext, PluginHookSessionStartEvent, PluginHookSessionEndEvent, @@ -426,6 +430,50 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp return runVoidHook("message_sent", event, ctx); } + // ========================================================================= + // Dispatch Hooks + // ========================================================================= + + /** + * Run before_dispatch hook. + * Allows plugins to block the entire message dispatch before LLM invocation + * and optionally send a reply text directly to the user. + * Runs sequentially — first handler that returns { block: true } wins. + */ + async function runBeforeDispatch( + event: PluginHookBeforeDispatchEvent, + ctx: PluginHookMessageContext, + ): Promise { + const hooks = getHooksForName(registry, "before_dispatch"); + if (hooks.length === 0) { + return undefined; + } + + logger?.debug?.(`[hooks] running before_dispatch (${hooks.length} handlers, sequential)`); + + for (const hook of hooks) { + try { + const result = await ( + hook.handler as ( + event: PluginHookBeforeDispatchEvent, + ctx: PluginHookMessageContext, + ) => + | Promise + | PluginHookBeforeDispatchResult + | void + )(event, ctx); + + if (result?.block) { + return result; + } + } catch (err) { + handleHookError({ hookName: "before_dispatch", pluginId: hook.pluginId, error: err }); + } + } + + return undefined; + } + // ========================================================================= // Tool Hooks // ========================================================================= @@ -737,6 +785,8 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp runMessageReceived, runMessageSending, runMessageSent, + // Dispatch hooks + runBeforeDispatch, // Tool hooks runBeforeToolCall, runAfterToolCall, diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 4c5894ddda1..c718f9a8763 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -341,6 +341,7 @@ export type PluginHookName = | "subagent_delivery_target" | "subagent_spawned" | "subagent_ended" + | "before_dispatch" | "gateway_start" | "gateway_stop"; @@ -367,6 +368,7 @@ export const PLUGIN_HOOK_NAMES = [ "subagent_delivery_target", "subagent_spawned", "subagent_ended", + "before_dispatch", "gateway_start", "gateway_stop", ] as const satisfies readonly PluginHookName[]; @@ -668,6 +670,26 @@ export type PluginHookBeforeMessageWriteResult = { message?: AgentMessage; // Optional: modified message to write instead }; +// before_dispatch hook — fired in dispatchReplyFromConfig before LLM invocation. +// Allows plugins to block the entire dispatch (preventing LLM processing) and +// optionally send a direct reply to the user. +export type PluginHookBeforeDispatchEvent = { + sessionKey: string; + channelId: string; + senderId?: string; + conversationId?: string; + isGroup: boolean; + content: string; + messageId?: string; +}; + +export type PluginHookBeforeDispatchResult = { + /** If true, the dispatch is aborted — no LLM invocation occurs. */ + block?: boolean; + /** If block is true, this text is sent as a direct reply to the user. */ + replyText?: string; +}; + // Session context export type PluginHookSessionContext = { agentId?: string; @@ -873,6 +895,10 @@ export type PluginHookHandlerMap = { event: PluginHookSubagentEndedEvent, ctx: PluginHookSubagentContext, ) => Promise | void; + before_dispatch: ( + event: PluginHookBeforeDispatchEvent, + ctx: PluginHookMessageContext, + ) => Promise | PluginHookBeforeDispatchResult | void; gateway_start: ( event: PluginHookGatewayStartEvent, ctx: PluginHookGatewayContext, From fafeff9d19bfbbd78ae74476bb845ba97de22fbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=8E=89=E6=B6=B5?= Date: Thu, 12 Mar 2026 03:40:30 +0800 Subject: [PATCH 2/2] fix: capture sendFinalReply return value and document fail-open semantics Address bot review feedback: - Use actual sendFinalReply() return for queuedFinal instead of Boolean proxy - Add JSDoc note about fail-open error semantics for security plugins Made-with: Cursor --- src/auto-reply/reply/dispatch-from-config.ts | 11 ++++------- src/plugins/hooks.ts | 5 +++++ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 70616302259..7da65b23654 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -225,14 +225,11 @@ export async function dispatchReplyFromConfig(params: { toPluginMessageContext(hookContext), ); if (beforeDispatchResult?.block) { - if (beforeDispatchResult.replyText) { - dispatcher.sendFinalReply({ text: beforeDispatchResult.replyText }); - } + const queuedFinal = beforeDispatchResult.replyText + ? dispatcher.sendFinalReply({ text: beforeDispatchResult.replyText }) + : false; recordProcessed("skipped", { reason: "before_dispatch_blocked" }); - return { - queuedFinal: Boolean(beforeDispatchResult.replyText), - counts: dispatcher.getQueuedCounts(), - }; + return { queuedFinal, counts: dispatcher.getQueuedCounts() }; } } diff --git a/src/plugins/hooks.ts b/src/plugins/hooks.ts index 027b027ff4d..896f2e532fc 100644 --- a/src/plugins/hooks.ts +++ b/src/plugins/hooks.ts @@ -439,6 +439,11 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp * Allows plugins to block the entire message dispatch before LLM invocation * and optionally send a reply text directly to the user. * Runs sequentially — first handler that returns { block: true } wins. + * + * **Error semantics:** Like all hooks, errors are caught and logged when + * `catchErrors` is true (the default). This means a throwing handler + * results in permit-by-default (fail-open). Security-critical plugins + * should handle errors internally to implement fail-closed behavior. */ async function runBeforeDispatch( event: PluginHookBeforeDispatchEvent,