Skip to content

feat(plugins): add before_dispatch hook for pre-LLM message interception#43422

Closed
loveyana wants to merge 7 commits intoopenclaw:mainfrom
loveyana:feature/before-dispatch-hook
Closed

feat(plugins): add before_dispatch hook for pre-LLM message interception#43422
loveyana wants to merge 7 commits intoopenclaw:mainfrom
loveyana:feature/before-dispatch-hook

Conversation

@loveyana
Copy link
Copy Markdown
Contributor

@loveyana loveyana commented Mar 11, 2026

Summary

  • Add a new before_dispatch plugin hook that fires in dispatchReplyFromConfig after message_received and before LLM invocation
  • Plugins can return { block: true, replyText: "..." } to abort the dispatch entirely and send a direct reply, preventing any LLM token consumption
  • This enables security plugins (e.g. authentication gates) to universally block unauthenticated sessions across all channels (webchat, Feishu, Telegram, etc.) — something not possible with existing hooks

Changes

File Change
src/plugins/types.ts Add "before_dispatch" to PluginHookName, define PluginHookBeforeDispatchEvent and PluginHookBeforeDispatchResult types, add handler to PluginHookHandlerMap
src/plugins/hooks.ts Add runBeforeDispatch() method — runs handlers sequentially, first { block: true } wins
src/auto-reply/reply/dispatch-from-config.ts Invoke hookRunner.runBeforeDispatch() after message_received hooks and before markProcessing()

Hook Signature

type PluginHookBeforeDispatchEvent = {
  sessionKey: string;
  channelId: string;
  senderId?: string;
  conversationId?: string;
  isGroup: boolean;
  content: string;
  messageId?: string;
};

type PluginHookBeforeDispatchResult = {
  block?: boolean;    // abort dispatch — no LLM invocation
  replyText?: string; // direct reply to user when blocked
};

Why existing hooks are insufficient

Hook Limitation
message_received Fire-and-forget; cannot block dispatch
message_sending Only fires in src/infra/outbound/deliver.ts; channel plugins with custom dispatchers (Feishu, webchat) bypass it
before_message_write Post-LLM; tokens already spent
before_agent_start Can inject context but cannot prevent LLM invocation

Authentication gate flow

1st message (fresh session):
  → before_dispatch: not yet marked → PASS
  → before_agent_start: no credential → mark unauthenticated + inject login prompt
  → LLM generates login URL

2nd+ message (still unauthenticated):
  → before_dispatch: session marked → BLOCK + reply "Please login"
  → LLM NOT invoked (zero token cost)

After login:
  → gate store cleared → before_dispatch passes → normal flow

Closes #43418

AI-Assisted PR

  • This PR was authored with AI assistance (Claude)
  • Fully tested — tsc --noEmit, all existing tests pass (44 + 19 + 48 = 111 tests)
  • Author understands all code changes
  • Bot review conversations addressed and replied to

Test plan

  • npx tsc --noEmit passes (zero type errors)
  • npx vitest run src/auto-reply/reply/dispatch-from-config.test.ts — 44 tests pass
  • npx vitest run src/plugins/hooks.*.test.ts — 19 tests pass
  • npx vitest run src/plugins/loader.test.ts src/plugins/wired-hooks-message.test.ts — 48 tests pass
  • Manual test with agent-identity-plugin: unauthenticated user blocked on 2nd message, no LLM invocation

…ption

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: openclaw#43418
Made-with: Cursor
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 11, 2026

Greptile Summary

This PR adds a new before_dispatch plugin hook that fires synchronously after message_received and before LLM invocation in dispatchReplyFromConfig, enabling plugins to abort dispatch and send a direct reply without consuming any LLM tokens. The implementation is well-structured and follows existing hook patterns.

Key observations:

  • Security bypass via absent sessionKey — The hook is guarded by && sessionKey (line 214 of dispatch-from-config.ts), so it silently skips for any message where ctx.SessionKey is falsy. Auth gate plugins that expect universal coverage cannot intercept those messages. The sessionKey field in the event type is declared as required (string), creating an inconsistency between the type contract and the actual call site behaviour.
  • queuedFinal inferred rather than captured — On the blocked path, queuedFinal is set to Boolean(beforeDispatchResult.replyText) rather than the actual return value of dispatcher.sendFinalReply(...). If sendFinalReply returns false for the given payload (e.g. silent-reply token), the returned queuedFinal would be inaccurate.
  • Fail-open error handling — Hook errors are swallowed and the message is permitted through. This is consistent with other hooks in the codebase but is particularly risky for security hooks; a note in the JSDoc would help plugin authors know to handle errors defensively.
  • No new unit tests — The PR reports existing tests continuing to pass but does not add dedicated unit tests for the new before_dispatch path (no hooks.before-dispatch.test.ts, no new dispatch-from-config.test.ts cases covering the blocked/unblocked/no-reply variants).

Confidence Score: 3/5

  • The feature works for the common case but has a documented bypass condition and no new unit tests, making it risky to rely on for security-critical gates.
  • The core implementation is sound and consistent with the existing hook infrastructure. However, the && sessionKey guard means auth gate plugins cannot universally block all inbound messages — messages without a session key silently bypass the hook entirely. This is a meaningful correctness gap for the primary use-case (authentication gates) described in the PR. Additionally, there are no new unit tests covering the blocked, unblocked, or reply-less variants of the new code path.
  • Pay close attention to src/auto-reply/reply/dispatch-from-config.ts — specifically the sessionKey guard on line 214 and the queuedFinal calculation on line 233.

Last reviewed commit: e84a114

Comment thread src/auto-reply/reply/dispatch-from-config.ts
Comment thread src/auto-reply/reply/dispatch-from-config.ts Outdated
Comment thread src/plugins/hooks.ts
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e84a11411a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/auto-reply/reply/dispatch-from-config.ts Outdated
Comment thread src/auto-reply/reply/dispatch-from-config.ts Outdated
…tics

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
@loveyana
Copy link
Copy Markdown
Contributor Author

All bot review conversations have been addressed — replied inline and pushed a follow-up commit (fafeff9) to capture sendFinalReply return value directly and add fail-open JSDoc documentation.

@vincentkoc @joshavant — would appreciate a review when you have a moment. This adds a before_dispatch plugin hook to enable pre-LLM message interception for security plugins (auth gates, rate limiters, etc.). The motivation and design are detailed in #43418.

The change is small (+104 lines across 3 files), follows existing hook patterns, and all 111 existing tests pass.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fafeff9d19

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/auto-reply/reply/dispatch-from-config.ts
@zeroaltitude
Copy link
Copy Markdown
Contributor

This looks like a solid PR to me -- it extends the hooks consistent with such strategies as #20067 and discussed at #36671! Very supportive! More hook power!

@deanyxu
Copy link
Copy Markdown

deanyxu commented Mar 11, 2026

This is a super useful feature indeed! LGTM

@kongweiguo
Copy link
Copy Markdown

I think this is a very useful patch. The existing hooks does not have similar capabilities, so that we can hook on the message processing link and do more secure authentication and identification.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a8abdf8980

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/auto-reply/reply/dispatch-from-config.ts
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9e76a214d8

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/auto-reply/reply/dispatch-from-config.ts
Comment thread src/auto-reply/reply/dispatch-from-config.ts
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6b91ebf4a1

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +232 to +237
if (beforeDispatchResult?.block) {
const queuedFinal = beforeDispatchResult.replyText
? dispatcher.sendFinalReply({ text: beforeDispatchResult.replyText })
: false;
recordProcessed("skipped", { reason: "before_dispatch_blocked" });
return { queuedFinal, counts: dispatcher.getQueuedCounts() };
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Emit message_received hooks before returning blocked dispatch

The new early return in the before_dispatch block exits dispatchReplyFromConfig before the existing inbound hook fanout runs (runMessageReceived and internal message:received are below this branch), so any turn blocked by before_dispatch is now invisible to plugins and internal consumers that rely on inbound message events for audit/metrics/state updates. This creates a regression specifically when a plugin returns { block: true }: the user gets a reply, but downstream hook-based observers never see the inbound message.

Useful? React with 👍 / 👎.

@odysseus0
Copy link
Copy Markdown
Contributor

Thanks for the contribution @loveyana — the gap you identified was real and the use case is legit.

However, the before_dispatch hook already landed on main via #50444 (commit a10d587). The current implementation uses runClaimingHook (shared hook infrastructure), has a richer event shape (content, body, channel, timestamp), and integrates with routeReplyRuntime for cross-channel delivery.

Closing as superseded — the feature you need is already available. Let us know if the landed version doesn't cover your auth gate use case and we can iterate from there.

@odysseus0 odysseus0 closed this Mar 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Add before_dispatch plugin hook for universal pre-LLM message interception

5 participants