From a0fb7fb045474fd41ce5e1474ae57763fb2b2b7e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 10 May 2026 05:06:03 +0100 Subject: [PATCH] refactor: centralize channel ingress access --- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/.i18n/glossary.zh-CN.json | 16 + docs/channels/access-groups.md | 21 +- docs/channels/discord.md | 4 +- docs/channels/imessage.md | 4 +- docs/channels/line.md | 1 + docs/channels/mattermost.md | 2 + docs/channels/msteams.md | 6 +- docs/channels/nextcloud-talk.md | 1 + docs/channels/qa-channel.md | 9 +- docs/channels/zalouser.md | 4 +- docs/plugins/sdk-channel-ingress.md | 137 ++++ docs/plugins/sdk-channel-plugins.md | 10 + docs/plugins/sdk-channel-turn.md | 38 +- docs/plugins/sdk-subpaths.md | 3 + docs/refactor/access.md | 9 + docs/refactor/ingress-core.md | 341 +++++++++ .../discord/src/monitor/access-groups.ts | 55 -- .../src/monitor/agent-components-dm-auth.ts | 152 ++-- .../monitor/agent-components-guild-auth.ts | 4 +- .../agent-components-helpers.runtime.ts | 6 +- .../src/monitor/agent-components.dispatch.ts | 106 ++- extensions/discord/src/monitor/allow-list.ts | 18 +- .../src/monitor/dm-command-auth.test.ts | 138 +++- .../discord/src/monitor/dm-command-auth.ts | 349 ++++++--- .../src/monitor/dm-command-decision.test.ts | 19 +- .../src/monitor/dm-command-decision.ts | 8 +- .../src/monitor/listeners.reactions.ts | 47 +- .../monitor/message-handler.dm-preflight.ts | 18 +- .../monitor/message-handler.preflight.test.ts | 14 +- .../src/monitor/message-handler.preflight.ts | 26 +- .../monitor/message-handler.process.test.ts | 2 +- .../discord/src/monitor/monitor.test.ts | 10 + .../src/monitor/native-command-arg-ui.ts | 2 +- .../src/monitor/native-command-auth.ts | 7 +- .../src/monitor/native-command-context.ts | 2 +- .../src/monitor/native-command-dispatch.ts | 2 +- .../native-command-model-picker-apply.ts | 2 +- ...native-command-model-picker-interaction.ts | 2 +- .../monitor/native-command-model-picker-ui.ts | 2 +- .../discord/src/monitor/native-command.ts | 10 +- .../discord/src/monitor/provider.commands.ts | 2 +- .../src/monitor/provider.interactions.ts | 2 +- extensions/discord/src/monitor/provider.ts | 2 +- .../src/test-support/component-runtime.ts | 10 +- extensions/discord/src/voice/access.ts | 6 +- extensions/discord/src/voice/command.ts | 1 + .../discord/src/voice/manager.e2e.test.ts | 4 + extensions/feishu/src/bot.test.ts | 10 +- extensions/feishu/src/bot.ts | 248 +++--- extensions/feishu/src/comment-handler.ts | 40 +- extensions/feishu/src/policy.test.ts | 193 +---- extensions/feishu/src/policy.ts | 281 ++++--- extensions/googlechat/runtime-api.ts | 5 - .../googlechat/src/monitor-access.test.ts | 178 +++-- extensions/googlechat/src/monitor-access.ts | 319 ++++---- extensions/googlechat/src/sender-allow.ts | 46 -- extensions/googlechat/src/targets.test.ts | 24 - .../imessage/src/monitor.gating.test.ts | 59 +- .../inbound-processing.systemPrompt.test.ts | 28 +- .../src/monitor/inbound-processing.test.ts | 66 +- .../src/monitor/inbound-processing.ts | 178 +++-- .../imessage/src/monitor/monitor-provider.ts | 16 +- .../src/monitor/self-chat-dedupe.test.ts | 96 +-- extensions/irc/src/inbound.behavior.test.ts | 27 + extensions/irc/src/inbound.policy.test.ts | 37 - extensions/irc/src/inbound.ts | 461 ++++++----- extensions/irc/src/policy.test.ts | 96 +-- extensions/irc/src/policy.ts | 101 +-- extensions/irc/src/runtime-api.ts | 6 +- extensions/line/runtime-api.ts | 7 +- extensions/line/src/bot-access.ts | 24 +- extensions/line/src/bot-handlers.test.ts | 169 ++-- extensions/line/src/bot-handlers.ts | 345 ++++----- .../src/matrix/monitor/access-state.test.ts | 174 ++++- .../matrix/src/matrix/monitor/access-state.ts | 201 +++-- .../matrix/src/matrix/monitor/handler.ts | 66 +- .../src/matrix/monitor/verification-events.ts | 5 +- extensions/matrix/src/runtime-api.ts | 4 - extensions/mattermost/runtime-api.ts | 13 +- .../mattermost/src/mattermost/model-picker.ts | 6 +- .../src/mattermost/monitor-auth.test.ts | 66 +- .../mattermost/src/mattermost/monitor-auth.ts | 346 ++++----- .../src/mattermost/monitor.authz.test.ts | 184 ++++- .../monitor.inbound-system-event.test.ts | 1 - .../mattermost/src/mattermost/monitor.ts | 155 +--- .../mattermost/src/mattermost/runtime-api.ts | 11 +- .../mattermost/src/mattermost/slash-http.ts | 2 +- extensions/mattermost/src/runtime-api.ts | 5 - extensions/msteams/runtime-api.ts | 9 +- .../src/monitor-handler.test-helpers.ts | 3 +- extensions/msteams/src/monitor-handler.ts | 4 +- .../msteams/src/monitor-handler/access.ts | 133 ++-- .../message-handler.authz.test.ts | 71 +- .../message-handler.test-support.ts | 4 +- .../src/monitor-handler/message-handler.ts | 111 +-- .../monitor-handler/reaction-handler.test.ts | 33 + .../src/monitor-handler/reaction-handler.ts | 100 +-- extensions/msteams/src/policy.test.ts | 86 +-- extensions/msteams/src/policy.ts | 16 - .../src/reply-stream-controller.test.ts | 4 +- extensions/nextcloud-talk/runtime-api.ts | 4 - extensions/nextcloud-talk/src/core.test.ts | 89 +-- .../nextcloud-talk/src/inbound.authz.test.ts | 5 +- .../src/inbound.behavior.test.ts | 110 ++- extensions/nextcloud-talk/src/inbound.ts | 346 +++++---- extensions/nextcloud-talk/src/policy.ts | 81 +- extensions/nostr/src/channel-api.ts | 4 - extensions/nostr/src/gateway.ts | 135 ++-- extensions/qa-channel/src/inbound.test.ts | 179 ++++- extensions/qa-channel/src/inbound.ts | 139 ++-- extensions/qqbot/src/bridge/gateway.ts | 7 +- extensions/qqbot/src/bridge/sdk-adapter.ts | 192 +++-- extensions/qqbot/src/command-auth.test.ts | 63 +- .../src/engine/access/access-control.test.ts | 198 ----- .../qqbot/src/engine/access/access-control.ts | 226 ------ extensions/qqbot/src/engine/access/index.ts | 16 +- .../qqbot/src/engine/access/resolve-policy.ts | 27 - extensions/qqbot/src/engine/access/types.ts | 51 -- extensions/qqbot/src/engine/adapter/index.ts | 117 +-- .../src/engine/commands/slash-command-auth.ts | 6 +- .../engine/commands/slash-command-handler.ts | 34 +- extensions/qqbot/src/engine/config/group.ts | 72 -- .../src/engine/gateway/gateway-connection.ts | 33 +- .../qqbot/src/engine/gateway/gateway.ts | 36 +- .../src/engine/gateway/inbound-context.ts | 121 +-- .../inbound-pipeline.self-echo.test.ts | 80 ++ .../src/engine/gateway/inbound-pipeline.ts | 74 +- .../qqbot/src/engine/gateway/message-queue.ts | 95 --- .../engine/gateway/outbound-dispatch.test.ts | 21 - .../src/engine/gateway/stages/access-stage.ts | 81 +- .../engine/gateway/stages/group-gate-stage.ts | 85 +-- .../engine/gateway/stages/stub-contexts.ts | 53 +- extensions/qqbot/src/engine/gateway/types.ts | 38 - .../qqbot/src/engine/group/activation.ts | 59 -- extensions/qqbot/src/engine/group/mention.ts | 85 +-- .../qqbot/src/engine/group/message-gating.ts | 108 --- extensions/signal/api.ts | 1 - .../signal/src/dm-policy.contract.test.ts | 77 -- extensions/signal/src/identity.ts | 14 - extensions/signal/src/monitor.test.ts | 67 -- ...-only-senders-uuid-allowlist-entry.test.ts | 5 - ...ends-tool-summaries-responseprefix.test.ts | 3 - .../src/monitor.tool-result.test-harness.ts | 5 - extensions/signal/src/monitor.ts | 24 +- .../signal/src/monitor/access-policy.test.ts | 155 +++- .../signal/src/monitor/access-policy.ts | 167 +++- .../signal/src/monitor/event-handler.ts | 62 +- extensions/slack/src/monitor/auth.test.ts | 111 +-- extensions/slack/src/monitor/auth.ts | 572 ++++++++------ .../slack/src/monitor/events/channels.test.ts | 4 - .../events/interactions.block-actions.ts | 52 +- .../src/monitor/events/interactions.test.ts | 4 - .../slack/src/monitor/events/members.test.ts | 4 - .../slack/src/monitor/events/messages.test.ts | 4 - .../slack/src/monitor/events/pins.test.ts | 4 - .../src/monitor/events/reactions.test.ts | 4 - .../src/monitor/message-handler/prepare.ts | 134 ++-- extensions/slack/src/monitor/policy.ts | 14 +- .../src/monitor/slash-commands.runtime.ts | 12 +- .../monitor/slash-plugin-commands.runtime.ts | 2 +- .../monitor/slash-skill-commands.runtime.ts | 4 +- extensions/slack/src/monitor/slash.ts | 61 +- extensions/synology-chat/src/core.test.ts | 107 ++- extensions/synology-chat/src/security.ts | 65 +- .../synology-chat/src/webhook-handler.ts | 32 +- extensions/telegram/src/bot-access.ts | 21 - .../telegram/src/bot-handlers.runtime.ts | 165 ++-- .../telegram/src/bot-message-context.body.ts | 31 +- .../telegram/src/bot-native-commands.test.ts | 2 +- .../telegram/src/bot-native-commands.ts | 78 +- ...t.media.stickers-and-fragments.e2e.test.ts | 97 ++- extensions/telegram/src/dm-access.ts | 64 +- extensions/telegram/src/group-access.ts | 34 +- extensions/telegram/src/ingress.ts | 126 +++ extensions/telegram/src/sequential-key.ts | 2 +- extensions/tlon/src/monitor/index.ts | 171 ++--- extensions/tlon/src/monitor/utils.ts | 82 +- extensions/tlon/src/security.test.ts | 140 ++-- extensions/twitch/src/access-control.test.ts | 185 +++-- extensions/twitch/src/access-control.ts | 276 ++++--- extensions/twitch/src/monitor.ts | 48 +- .../monitor/process-message.test.ts | 3 - extensions/whatsapp/src/inbound-policy.ts | 166 ++-- .../src/inbound/access-control.test.ts | 36 + .../whatsapp/src/inbound/access-control.ts | 79 +- ...ized-senders-not-allowfrom.test-support.ts | 8 +- extensions/zalo/api.ts | 1 - extensions/zalo/contract-api.ts | 2 +- extensions/zalo/runtime-api.ts | 4 - extensions/zalo/setup-api.ts | 2 +- extensions/zalo/src/group-access.ts | 34 +- .../zalo/src/monitor.group-policy.test.ts | 254 ++++-- extensions/zalo/src/monitor.ts | 226 +++--- extensions/zalo/src/runtime-api.ts | 4 - extensions/zalo/src/runtime-support.ts | 6 - extensions/zalo/test-api.ts | 2 +- extensions/zalouser/runtime-api.ts | 5 - .../zalouser/src/monitor.group-gating.test.ts | 104 ++- extensions/zalouser/src/monitor.ts | 283 +++---- package.json | 13 + scripts/check-no-deprecated-channel-access.ts | 111 +++ scripts/check-no-pairing-store-group-auth.mjs | 2 +- scripts/check.mjs | 4 + scripts/format-docs.mjs | 5 +- scripts/lib/plugin-sdk-doc-metadata.ts | 9 + scripts/lib/plugin-sdk-entrypoints.json | 3 + src/channels/allow-from.ts | 11 + src/channels/message-access/allowlist.ts | 135 ++++ src/channels/message-access/decision.ts | 327 ++++++++ src/channels/message-access/dm-allow-state.ts | 45 ++ src/channels/message-access/index.ts | 31 + .../message-access/message-access.test.ts | 229 ++++++ .../message-access/runtime-access-groups.ts | 95 +++ .../message-access/runtime-identity.ts | 180 +++++ src/channels/message-access/runtime-types.ts | 368 +++++++++ src/channels/message-access/runtime.ts | 722 ++++++++++++++++++ src/channels/message-access/sender-gates.ts | 166 ++++ src/channels/message-access/state.ts | 396 ++++++++++ src/channels/message-access/types.ts | 369 +++++++++ src/channels/turn/context.test.ts | 35 + src/channels/turn/context.ts | 17 +- src/channels/turn/types.ts | 59 +- src/commands/doctor-security.ts | 4 +- .../local/gateway-config.ts | 3 +- src/gateway/server-restart-deferral.test.ts | 4 +- src/plugin-sdk/access-groups.test.ts | 61 ++ src/plugin-sdk/access-groups.ts | 181 +++-- src/plugin-sdk/channel-access-compat.ts | 1 + .../channel-ingress-runtime.test.ts | 146 ++++ src/plugin-sdk/channel-ingress-runtime.ts | 44 ++ src/plugin-sdk/channel-ingress.ts | 620 +++++++++++++++ src/plugin-sdk/channel-policy.ts | 2 +- src/plugin-sdk/command-auth-native.ts | 19 +- src/plugin-sdk/command-auth.ts | 21 +- src/plugin-sdk/conversation-runtime.ts | 2 +- src/plugin-sdk/direct-dm-access.ts | 48 +- src/plugin-sdk/group-access.ts | 90 +-- src/plugin-sdk/inbound-envelope.ts | 2 +- src/plugin-sdk/security-runtime.ts | 5 +- .../test-helpers/plugin-runtime-mock.ts | 6 +- .../plugin-sdk-runtime-api-guardrails.test.ts | 4 +- src/plugins/runtime/runtime-channel.ts | 1 + src/plugins/runtime/types-channel.ts | 3 +- src/security/audit-channel.ts | 4 +- src/security/dm-policy-shared.test.ts | 593 -------------- src/security/dm-policy-shared.ts | 255 +++---- test/vitest/vitest.unit-fast-paths.mjs | 2 +- ui/src/ui/app-tool-stream.node.test.ts | 4 + ui/src/ui/gateway.node.test.ts | 22 +- 250 files changed, 11410 insertions(+), 8161 deletions(-) create mode 100644 docs/plugins/sdk-channel-ingress.md create mode 100644 docs/refactor/access.md create mode 100644 docs/refactor/ingress-core.md delete mode 100644 extensions/discord/src/monitor/access-groups.ts delete mode 100644 extensions/googlechat/src/sender-allow.ts delete mode 100644 extensions/irc/src/inbound.policy.test.ts delete mode 100644 extensions/qqbot/src/engine/access/access-control.test.ts delete mode 100644 extensions/qqbot/src/engine/access/access-control.ts delete mode 100644 extensions/signal/src/dm-policy.contract.test.ts delete mode 100644 extensions/signal/src/monitor.test.ts create mode 100644 extensions/telegram/src/ingress.ts create mode 100644 scripts/check-no-deprecated-channel-access.ts create mode 100644 src/channels/message-access/allowlist.ts create mode 100644 src/channels/message-access/decision.ts create mode 100644 src/channels/message-access/dm-allow-state.ts create mode 100644 src/channels/message-access/index.ts create mode 100644 src/channels/message-access/message-access.test.ts create mode 100644 src/channels/message-access/runtime-access-groups.ts create mode 100644 src/channels/message-access/runtime-identity.ts create mode 100644 src/channels/message-access/runtime-types.ts create mode 100644 src/channels/message-access/runtime.ts create mode 100644 src/channels/message-access/sender-gates.ts create mode 100644 src/channels/message-access/state.ts create mode 100644 src/channels/message-access/types.ts create mode 100644 src/plugin-sdk/access-groups.test.ts create mode 100644 src/plugin-sdk/channel-access-compat.ts create mode 100644 src/plugin-sdk/channel-ingress-runtime.test.ts create mode 100644 src/plugin-sdk/channel-ingress-runtime.ts create mode 100644 src/plugin-sdk/channel-ingress.ts delete mode 100644 src/security/dm-policy-shared.test.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 76220257798..0443e0380f1 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -f303fc2fea17115736f9b7565ab2b6cfa07cfb46e5b3c804c55a945922f749db plugin-sdk-api-baseline.json -90a4a9538691ee1af4d1fcf10b3b0c1d161e835c03e87b09dc0ff0020d1ded7a plugin-sdk-api-baseline.jsonl +d0bbcee28603940444071492f65e293e708d2b437bdc6f0fb492fc41a0920760 plugin-sdk-api-baseline.json +f6a73cf469f279671ab00ff5a0e95eb0ff89d8a8b9652cf1ff9cd983aa69093d plugin-sdk-api-baseline.jsonl diff --git a/docs/.i18n/glossary.zh-CN.json b/docs/.i18n/glossary.zh-CN.json index 0d663766feb..7ebfa3d1c98 100644 --- a/docs/.i18n/glossary.zh-CN.json +++ b/docs/.i18n/glossary.zh-CN.json @@ -59,6 +59,22 @@ "source": "Channel message API", "target": "频道消息 API" }, + { + "source": "Channel ingress API", + "target": "频道入口 API" + }, + { + "source": "Channel access cleanup", + "target": "频道访问清理" + }, + { + "source": "Ingress core shrink plan", + "target": "入口核心精简计划" + }, + { + "source": "Ingress core shrink", + "target": "入口核心精简" + }, { "source": "Talk mode", "target": "Talk 模式" diff --git a/docs/channels/access-groups.md b/docs/channels/access-groups.md index 827d0217a86..babc8d326f4 100644 --- a/docs/channels/access-groups.md +++ b/docs/channels/access-groups.md @@ -125,7 +125,26 @@ Access groups are available in shared message-channel authorization paths, inclu - channel-specific per-room sender allowlists that use the same sender matching rules - command authorization paths that reuse message-channel sender allowlists -Channel support depends on whether that channel is wired through the shared OpenClaw sender-authorization helpers. Current bundled support includes Discord, Google Chat, Nostr, WhatsApp, Zalo, and Zalo Personal. Static `message.senders` groups are designed to be channel-agnostic, so new message channels should support them by using the shared plugin SDK helpers instead of custom allowlist expansion. +Channel support depends on whether that channel is wired through the shared OpenClaw sender-authorization helpers. Current bundled support includes BlueBubbles, Discord, Feishu, Google Chat, iMessage, LINE, Mattermost, Microsoft Teams, Nextcloud Talk, Nostr, QQBot, Signal, WhatsApp, Zalo, and Zalo Personal. Static `message.senders` groups are designed to be channel-agnostic, so new message channels should support them by using the shared plugin SDK helpers instead of custom allowlist expansion. + +## Plugin diagnostics + +Plugin authors can inspect structured access-group state without expanding it back into a flat allowlist: + +```typescript +import { resolveAccessGroupAllowFromState } from "openclaw/plugin-sdk/security-runtime"; + +const state = await resolveAccessGroupAllowFromState({ + accessGroups: cfg.accessGroups, + allowFrom: channelConfig.allowFrom, + channel: "my-channel", + accountId: "default", + senderId, + isSenderAllowed, +}); +``` + +The result reports referenced, matched, missing, unsupported, and failed groups. Use this when you need diagnostics or conformance tests. Use `expandAllowFromWithAccessGroups(...)` only for compatibility paths that still expect a flat `allowFrom` array. ## Discord channel audiences diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 603e3b589f9..c41fe988502 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -451,8 +451,8 @@ Example: - - Discord DMs can use dynamic `accessGroup:` entries in `channels.discord.allowFrom`. + + Discord DMs and text command authorization can use dynamic `accessGroup:` entries in `channels.discord.allowFrom`. Access group names are shared across message channels. Use `type: "message.senders"` for a static group whose members are expressed in each channel's normal `allowFrom` syntax, or `type: "discord.channelAudience"` when a Discord channel's current `ViewChannel` audience should define membership dynamically. Shared access-group behavior is documented here: [Access groups](/channels/access-groups). diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md index 081b906efe4..46adf6e9ef1 100644 --- a/docs/channels/imessage.md +++ b/docs/channels/imessage.md @@ -217,7 +217,7 @@ If SIP-disabled isn't acceptable for your threat model: Allowlist field: `channels.imessage.allowFrom`. - Allowlist entries can be handles or chat targets (`chat_id:*`, `chat_guid:*`, `chat_identifier:*`). + Allowlist entries can be handles, static sender access groups (`accessGroup:`), or chat targets (`chat_id:*`, `chat_guid:*`, `chat_identifier:*`). @@ -230,6 +230,8 @@ If SIP-disabled isn't acceptable for your threat model: Group sender allowlist: `channels.imessage.groupAllowFrom`. + `groupAllowFrom` entries can also reference static sender access groups (`accessGroup:`). + Runtime fallback: if `groupAllowFrom` is unset, iMessage group sender checks fall back to `allowFrom` when available. Runtime note: if `channels.imessage` is completely missing, runtime falls back to `groupPolicy="allowlist"` and logs a warning (even if `channels.defaults.groupPolicy` is set). diff --git a/docs/channels/line.md b/docs/channels/line.md index 74bfc099690..c5cd0b9d2e4 100644 --- a/docs/channels/line.md +++ b/docs/channels/line.md @@ -139,6 +139,7 @@ Allowlists and policies: - `channels.line.groupPolicy`: `allowlist | open | disabled` - `channels.line.groupAllowFrom`: allowlisted LINE user IDs for groups - Per-group overrides: `channels.line.groups..allowFrom` +- Static sender access groups can be referenced from `allowFrom`, `groupAllowFrom`, and per-group `allowFrom` with `accessGroup:`. - Runtime note: if `channels.line` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set). LINE IDs are case-sensitive. Valid IDs look like: diff --git a/docs/channels/mattermost.md b/docs/channels/mattermost.md index ed393f5b483..bb1d6247064 100644 --- a/docs/channels/mattermost.md +++ b/docs/channels/mattermost.md @@ -189,11 +189,13 @@ Notes: - `openclaw pairing list mattermost` - `openclaw pairing approve mattermost ` - Public DMs: `channels.mattermost.dmPolicy="open"` plus `channels.mattermost.allowFrom=["*"]`. +- `channels.mattermost.allowFrom` accepts `accessGroup:` entries. See [Access groups](/channels/access-groups). ## Channels (groups) - Default: `channels.mattermost.groupPolicy = "allowlist"` (mention-gated). - Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs recommended). +- `channels.mattermost.groupAllowFrom` accepts `accessGroup:` entries. See [Access groups](/channels/access-groups). - Per-channel mention overrides live under `channels.mattermost.groups..requireMention` or `channels.mattermost.groups["*"].requireMention` for a default. - `@username` matching is mutable and only enabled when `channels.mattermost.dangerouslyAllowNameMatching: true`. - Open channels: `channels.mattermost.groupPolicy="open"` (mention-gated). diff --git a/docs/channels/msteams.md b/docs/channels/msteams.md index c5c0403b4b1..63e4af75c7c 100644 --- a/docs/channels/msteams.md +++ b/docs/channels/msteams.md @@ -146,14 +146,14 @@ Disable with: **DM access** - Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved. -- `channels.msteams.allowFrom` should use stable AAD object IDs. +- `channels.msteams.allowFrom` should use stable AAD object IDs or static sender access groups such as `accessGroup:core-team`. - Do not rely on UPN/display-name matching for allowlists - they can change. OpenClaw disables direct name matching by default; opt in explicitly with `channels.msteams.dangerouslyAllowNameMatching: true`. - The wizard can resolve names to IDs via Microsoft Graph when credentials allow. **Group access** - Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`). Use `channels.defaults.groupPolicy` to override the default when unset. -- `channels.msteams.groupAllowFrom` controls which senders can trigger in group chats/channels (falls back to `channels.msteams.allowFrom`). +- `channels.msteams.groupAllowFrom` controls which senders or static sender access groups can trigger in group chats/channels (falls back to `channels.msteams.allowFrom`). - Set `groupPolicy: "open"` to allow any member (still mention-gated by default). - To allow **no channels**, set `channels.msteams.groupPolicy: "disabled"`. @@ -164,7 +164,7 @@ Example: channels: { msteams: { groupPolicy: "allowlist", - groupAllowFrom: ["user@org.com"], + groupAllowFrom: ["00000000-0000-0000-0000-000000000000", "accessGroup:core-team"], }, }, } diff --git a/docs/channels/nextcloud-talk.md b/docs/channels/nextcloud-talk.md index b28c0c7f62e..0d20edf85da 100644 --- a/docs/channels/nextcloud-talk.md +++ b/docs/channels/nextcloud-talk.md @@ -157,6 +157,7 @@ Provider options: - `channels.nextcloud-talk.groupPolicy`: `allowlist | open | disabled`. - `channels.nextcloud-talk.groupAllowFrom`: group allowlist (user IDs). - `channels.nextcloud-talk.rooms`: per-room settings and allowlist. +- Static sender access groups can be referenced from `allowFrom` and `groupAllowFrom` with `accessGroup:`. - `channels.nextcloud-talk.historyLimit`: group history limit (0 disables). - `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables). - `channels.nextcloud-talk.dms`: per-DM overrides (historyLimit). diff --git a/docs/channels/qa-channel.md b/docs/channels/qa-channel.md index 4f4f997748f..9439d0275e6 100644 --- a/docs/channels/qa-channel.md +++ b/docs/channels/qa-channel.md @@ -44,7 +44,14 @@ Account keys: - `botUserId` - Matrix-style bot user id used in target grammar. - `botDisplayName` - display name for outbound messages. - `pollTimeoutMs` - long-poll wait window. Integer between 100 and 30000. -- `allowFrom` - sender allowlist (user ids or `"*"`). +- `allowFrom` - sender allowlist (user ids or `"*"`). Direct messages and + allowlisted group policy both use these synthetic sender ids. +- `groupPolicy` - shared-room policy: `"open"` (default), `"allowlist"`, or + `"disabled"`. +- `groupAllowFrom` - optional shared-room sender allowlist. When omitted under + `"allowlist"`, QA Channel falls back to `allowFrom`. +- `groups..requireMention` - require a bot mention before replying in a + specific group/channel room. `groups."*"` sets the default. - `defaultTo` - fallback target when none is supplied. - `actions.messages` / `actions.reactions` / `actions.search` / `actions.threads` - per-action tool gating. diff --git a/docs/channels/zalouser.md b/docs/channels/zalouser.md index 2bd7d095345..1bbcaf1bae4 100644 --- a/docs/channels/zalouser.md +++ b/docs/channels/zalouser.md @@ -81,7 +81,7 @@ openclaw directory groups list --channel zalouser --query "work" `channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`). -`channels.zalouser.allowFrom` should use stable Zalo user IDs. During interactive setup, entered names can be resolved to IDs using the plugin's in-process contact lookup. +`channels.zalouser.allowFrom` should use stable Zalo user IDs. It can also reference static sender access groups (`accessGroup:`). During interactive setup, entered names can be resolved to IDs using the plugin's in-process contact lookup. If a raw name remains in config, startup resolves it only when `channels.zalouser.dangerouslyAllowNameMatching: true` is enabled. Without that opt-in, runtime sender checks are ID-only and raw names are ignored for authorization. @@ -96,7 +96,7 @@ Approve via: - Restrict to an allowlist with: - `channels.zalouser.groupPolicy = "allowlist"` - `channels.zalouser.groups` (keys should be stable group IDs; names are resolved to IDs on startup only when `channels.zalouser.dangerouslyAllowNameMatching: true` is enabled) - - `channels.zalouser.groupAllowFrom` (controls which senders in allowed groups can trigger the bot) + - `channels.zalouser.groupAllowFrom` (controls which senders in allowed groups can trigger the bot; static sender access groups can be referenced with `accessGroup:`) - Block all groups: `channels.zalouser.groupPolicy = "disabled"`. - The configure wizard can prompt for group allowlists. - On startup, OpenClaw resolves group/user names in allowlists to IDs and logs the mapping only when `channels.zalouser.dangerouslyAllowNameMatching: true` is enabled. diff --git a/docs/plugins/sdk-channel-ingress.md b/docs/plugins/sdk-channel-ingress.md new file mode 100644 index 00000000000..a8f31553d42 --- /dev/null +++ b/docs/plugins/sdk-channel-ingress.md @@ -0,0 +1,137 @@ +--- +summary: "Experimental channel ingress API for inbound message authorization" +read_when: + - Building or migrating a messaging channel plugin + - Changing DM or group allowlists, route gates, command auth, event auth, or mention activation + - Reviewing channel ingress redaction or SDK compatibility boundaries +title: "Channel ingress API" +sidebarTitle: "Channel Ingress" +--- + +# Channel ingress API + +Channel ingress is the experimental access-control boundary for inbound channel +events. Use `openclaw/plugin-sdk/channel-ingress-runtime` for receive paths. +The older `openclaw/plugin-sdk/channel-ingress` subpath stays exported as a +deprecated compatibility facade for third-party plugins. + +Plugins own platform facts and side effects. Core owns generic policy: DM/group +allowlists, pairing-store DM entries, route gates, command gates, event auth, +mention activation, redacted diagnostics, and admission. + +## Runtime Resolver + +```ts +import { + defineStableChannelIngressIdentity, + resolveChannelMessageIngress, +} from "openclaw/plugin-sdk/channel-ingress-runtime"; + +const identity = defineStableChannelIngressIdentity({ + key: "platform-user-id", + normalize: normalizePlatformUserId, + sensitivity: "pii", +}); + +const result = await resolveChannelMessageIngress({ + channelId: "my-channel", + accountId, + identity, + subject: { stableId: platformUserId }, + conversation: { kind: isGroup ? "group" : "direct", id: conversationId }, + event: { kind: "message", authMode: "inbound", mayPair: !isGroup }, + policy: { + dmPolicy: config.dmPolicy, + groupPolicy: config.groupPolicy, + groupAllowFromFallbackToAllowFrom: true, + }, + allowFrom: config.allowFrom, + groupAllowFrom: config.groupAllowFrom, + accessGroups: cfg.accessGroups, + route, + readStoreAllowFrom, + command: hasControlCommand ? { allowTextCommands: true, hasControlCommand } : undefined, +}); +``` + +Do not precompute effective allowlists, command owners, or command groups. The +resolver derives them from raw allowlists, store callbacks, route descriptors, +access groups, policy, and conversation kind. + +## Result + +Bundled plugins should consume modern projections directly: + +- `ingress`: ordered gate decision and admission +- `senderAccess`: sender/conversation authorization only +- `routeAccess`: route and route-sender projection +- `commandAccess`: command authorization; false when no command gate ran +- `activationAccess`: mention/activation result + +Event authorization remains available on the ordered `ingress.graph` and the +decisive `ingress.reasonCode`; no separate event projection is emitted. + +Deprecated third-party SDK helpers may rebuild older shapes internally. New +bundled receive paths should not translate modern results back into local DTOs. + +## Access Groups + +`accessGroup:` entries stay redacted. Core resolves static +`message.senders` groups itself and calls `resolveAccessGroupMembership` only +for dynamic groups that require a platform lookup. Missing, unsupported, and +failed groups fail closed. + +## Event Modes + +| `authMode` | Meaning | +| ---------------- | ------------------------------------------------ | +| `inbound` | normal inbound sender gates | +| `command` | command gates for callbacks or scoped buttons | +| `origin-subject` | actor must match the original message subject | +| `route-only` | route gates only for route-scoped trusted events | +| `none` | plugin-owned internal events bypass shared auth | + +Use `mayPair: false` for reactions, buttons, callbacks, and native commands. + +## Routes And Activation + +Use route descriptors for room, topic, guild, thread, or nested route policy: + +```ts +route: { + id: "room", + allowed: roomAllowed, + enabled: roomEnabled, + senderPolicy: "replace", + senderAllowFrom: roomAllowFrom, + blockReason: "room_sender_not_allowlisted", +} +``` + +Use `channelIngressRoutes(...)` when a plugin has several optional route +descriptors; it filters disabled branches while keeping route facts generic and +ordered by each descriptor's `precedence`. + +Mention gating is an activation gate. A mention miss returns +`admission: "skip"` so the turn kernel does not process an observe-only turn. +Most channels should leave activation after sender and command gates. Public +chat surfaces that must quiet non-mentioned traffic before sender allowlist +noise can opt into `activation.order: "before-sender"` when text-command +bypass is disabled. Channels with implicit activation, such as replies in bot +threads, can pass `activation.allowedImplicitMentionKinds`; the projected +`activationAccess.shouldBypassMention` then reports when command or implicit +activation bypassed an explicit mention. + +## Redaction + +Raw sender values and raw allowlist entries are resolver input only. They must +not appear in resolved state, decisions, diagnostics, snapshots, or +compatibility facts. Use opaque subject ids, entry ids, route ids, and +diagnostic ids. + +## Verification + +```bash +pnpm test src/channels/message-access/message-access.test.ts src/plugin-sdk/channel-ingress-runtime.test.ts +pnpm plugin-sdk:api:check +``` diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md index d8a9ed97e12..c77661b085f 100644 --- a/docs/plugins/sdk-channel-plugins.md +++ b/docs/plugins/sdk-channel-plugins.md @@ -74,6 +74,16 @@ remain available for compatibility dispatchers. Do not use those names for new channel code; new plugins should start with the `message` adapter, receipts, and receive/send lifecycle helpers on `openclaw/plugin-sdk/channel-message`. +Channels migrating inbound authorization can use the experimental +`openclaw/plugin-sdk/channel-ingress-runtime` subpath from runtime receive +paths. The subpath keeps platform lookup and side effects in the plugin, while +sharing allowlist state resolution, route/sender/command/event/activation +decisions, redacted diagnostics, and turn-admission mapping. Keep plugin +identity normalization in the descriptor you pass to the resolver; do not +serialize raw match values from the resolved state or decision. See +[Channel ingress API](/plugins/sdk-channel-ingress) for the API design, +ownership boundary, and test expectations. + If your channel supports typing indicators outside inbound replies, expose `heartbeat.sendTyping(...)` on the channel plugin. Core calls it with the resolved heartbeat delivery target before the heartbeat model run starts and diff --git a/docs/plugins/sdk-channel-turn.md b/docs/plugins/sdk-channel-turn.md index c8b32d1cc87..48365be2648 100644 --- a/docs/plugins/sdk-channel-turn.md +++ b/docs/plugins/sdk-channel-turn.md @@ -64,6 +64,7 @@ The runtime exposes three preferred entry points so adapters can opt in at the l ```typescript runtime.channel.turn.run(...) // adapter-driven full pipeline +runtime.channel.turn.runAssembled(...) // already-built context + delivery adapter runtime.channel.turn.runPrepared(...) // channel owns dispatch; kernel runs record + finalize runtime.channel.turn.buildContext(...) // pure facts to FinalizedMsgContext mapping ``` @@ -72,7 +73,7 @@ Two older runtime helpers remain available for Plugin SDK compatibility: ```typescript runtime.channel.turn.runResolved(...) // deprecated compatibility alias; prefer run -runtime.channel.turn.dispatchAssembled(...) // deprecated compatibility alias; prefer run or runPrepared +runtime.channel.turn.dispatchAssembled(...) // deprecated compatibility alias; prefer runAssembled ``` ### run @@ -114,6 +115,41 @@ await runtime.channel.turn.run({ `run` is the right shape when the channel has small adapter logic and benefits from owning the lifecycle through hooks. +### runAssembled + +Use when the channel has already resolved routing, built a `FinalizedMsgContext`, +and only needs the shared record, reply-pipeline, dispatch, and finalize +ordering. This is the preferred shape for simple bundled inbound paths that +would otherwise repeat `createChannelMessageReplyPipeline(...)` and +`runPrepared(...)` boilerplate. + +```typescript +await runtime.channel.turn.runAssembled({ + cfg, + channel: "irc", + accountId, + agentId: route.agentId, + routeSessionKey: route.sessionKey, + storePath, + ctxPayload, + recordInboundSession: runtime.channel.session.recordInboundSession, + dispatchReplyWithBufferedBlockDispatcher: + runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher, + delivery: { + deliver: async (payload) => { + await sendPlatformReply(payload); + }, + onError: (err, info) => { + runtime.error?.(`reply ${info.kind} failed: ${String(err)}`); + }, + }, +}); +``` + +Choose `runAssembled` over `runPrepared` when the only channel-owned dispatch +behavior is final payload delivery plus optional typing, reply options, durable +delivery, or error logging. + ### runPrepared Use when the channel has a complex local dispatcher with previews, retries, edits, or thread bootstrap that must stay channel-owned. The kernel still records the inbound session before dispatch and surfaces a uniform `DispatchedChannelTurnResult`. diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index a370c191df5..4044504cff7 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -56,6 +56,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview) | `plugin-sdk/account-id` | `DEFAULT_ACCOUNT_ID`, account-id normalization helpers | | `plugin-sdk/account-resolution` | Account lookup + default-fallback helpers | | `plugin-sdk/account-helpers` | Narrow account-list/account-action helpers | + | `plugin-sdk/access-groups` | Access-group allowlist parsing and redacted group diagnostics helpers | | `plugin-sdk/channel-pairing` | `createChannelPairingController` | | `plugin-sdk/channel-reply-pipeline` | Legacy reply pipeline helpers. New channel reply pipeline code should use `createChannelMessageReplyPipeline` and `resolveChannelMessageSourceReplyDeliveryMode` from `plugin-sdk/channel-message`. | | `plugin-sdk/channel-config-helpers` | `createHybridChannelConfigAdapter`, `resolveChannelDmAccess`, `resolveChannelDmAllowFrom`, `resolveChannelDmPolicy`, `normalizeChannelDmPolicy`, `normalizeLegacyDmAliases` | @@ -65,6 +66,8 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview) | `plugin-sdk/telegram-command-config` | Telegram custom-command normalization/validation helpers with bundled-contract fallback | | `plugin-sdk/command-gating` | Narrow command authorization gate helpers | | `plugin-sdk/channel-policy` | `resolveChannelGroupRequireMention` | + | `plugin-sdk/channel-ingress` | Deprecated low-level channel ingress compatibility facade. New receive paths should use `plugin-sdk/channel-ingress-runtime`. | + | `plugin-sdk/channel-ingress-runtime` | Experimental high-level channel ingress runtime resolver and route fact builders for migrated channel receive paths. Prefer this over assembling effective allowlists, command allowlists, and legacy projections in each plugin. See [Channel ingress API](/plugins/sdk-channel-ingress). | | `plugin-sdk/channel-lifecycle` | `createAccountStatusSink`, `createChannelRunQueue`, and legacy draft stream lifecycle helpers. New preview finalization code should use `plugin-sdk/channel-message`. | | `plugin-sdk/channel-message` | Cheap message lifecycle contract helpers such as `defineChannelMessageAdapter`, `createChannelMessageAdapterFromOutbound`, `createChannelMessageReplyPipeline`, `createReplyPrefixContext`, `resolveChannelMessageSourceReplyDeliveryMode`, durable-final capability derivation, capability proof helpers for send/receipt/side-effect capabilities, `MessageReceiveContext`, receive ack policy proofs, `defineFinalizableLivePreviewAdapter`, `deliverWithFinalizableLivePreviewAdapter`, live-preview and live-finalizer capability proofs, durable recovery state, `RenderedMessageBatch`, message receipt types, and receipt id helpers. See [Channel message API](/plugins/sdk-channel-message). Legacy reply-dispatch facades are deprecated compatibility only. | | `plugin-sdk/channel-message-runtime` | Runtime delivery helpers that may load outbound delivery, including `deliverInboundReplyWithMessageSendContext`, `sendDurableMessageBatch`, and `withDurableMessageSendContext`. Deprecated reply-dispatch bridges remain importable for compatibility dispatchers only. Use from monitor/send runtime modules, not hot plugin bootstrap files. | diff --git a/docs/refactor/access.md b/docs/refactor/access.md new file mode 100644 index 00000000000..33d65b85926 --- /dev/null +++ b/docs/refactor/access.md @@ -0,0 +1,9 @@ +--- +summary: "Redirect to /refactor/ingress-core" +read_when: + - Changing channel ingress, sender access, command authorization, or access-group handling +title: "Channel access cleanup" +sidebarTitle: "Channel access cleanup" +--- + +The channel access cleanup plan now lives in [Ingress core deletion plan](/refactor/ingress-core). diff --git a/docs/refactor/ingress-core.md b/docs/refactor/ingress-core.md new file mode 100644 index 00000000000..30cdf374a51 --- /dev/null +++ b/docs/refactor/ingress-core.md @@ -0,0 +1,341 @@ +--- +summary: "Deletion-first plan for moving repeated channel ingress glue into core." +read_when: + - Auditing why the channel ingress refactor added too much code + - Moving route, command, event, activation, or access-group policy from bundled plugins into core + - Reviewing whether a channel ingress helper actually deletes bundled plugin code +title: "Ingress core deletion plan" +sidebarTitle: "Ingress core deletion" +--- + +# Ingress core deletion plan + +The ingress refactor is not healthy while it adds thousands of net lines. Core +centralization only counts when bundled plugin production code gets smaller and +old third-party SDK compatibility is quarantined to SDK/core shims. + +Desired runtime shape: + +```text +bundled plugin event + -> extract platform facts locally + -> resolve shared ingress once when facts are available + -> branch on generic ingress projections/outcomes + -> perform platform side effects locally + +old third-party helper + -> SDK compatibility shim + -> shared ingress-compatible projection where possible + -> old return shape preserved +``` + +Bundled plugins should not translate ingress back into local `AccessResult`, +`GroupAccessDecision`, `CommandAuthDecision`, `DmCommandAccess`, or +`{ allowed, reasonCode }` shapes unless that type is public plugin API. + +## Budget + +Measured against the PR merge-base with `origin/main`, including untracked +files. + +```text +merge-base 1671e7532adb + +current: +core production +3,922 / -546 = +3,376 +docs +601 / -17 = +584 +other +145 / -2 = +143 +plugin production +4,148 / -5,388 = -1,240 +tests +2,326 / -2,414 = -88 +total +11,142 / -8,367 = +2,775 + +required: +plugin production <= -1,500 +core production <= +1,500, or paid for by larger plugin deletion +tests <= +1,000 +total <= +2,000 + +stretch: +plugin production <= -2,500 +core production <= +1,200 +total <= 0 +``` + +Minimum remaining cleanup: + +```text +plugin production needs 260 more net deleted lines +total needs 775 more net deleted lines +core production still +1,876 over standalone budget, unless paid down by plugin deletion +``` + +Comment-only deletion does not count as cleanup. The previous budget pass was +too generous because it included restored QQBot explanatory comments; this +document tracks executable/docs/test code movement only. + +Re-measure after each cleanup wave: + +```sh +base=$(git merge-base HEAD origin/main) +git diff --shortstat "$base" +git diff --numstat "$base" -- src/channels/message-access src/plugin-sdk extensions | sort -nr -k1 | head -n 80 +pnpm lint:extensions:no-deprecated-channel-access +``` + +## Diagnosis + +The first pass added the shared ingress kernel, then left too much plugin-local +authorization beside it: + +```text +platform facts + -> shared ingress state and decision + -> plugin-local DTO or legacy projection + -> plugin-local if/else ladder +``` + +That duplicates the model. Core production grew by about 3,376 lines, while +bundled plugin production is 1,240 lines smaller. That is better than the first +pass, but it is not inside the minimum budget. The fix remains deletion-first: + +- delete plugin DTOs that only rename ingress fields +- delete tests that only assert wrapper shape +- add core helpers only when the same patch deletes bundled plugin code +- keep old SDK compatibility in SDK/core shims only +- repack core after wrapper deletion exposes the stable shape + +## Hotspots + +Positive bundled production files that still need to shrink: + +```text +extensions/telegram/src/ingress.ts +126 +extensions/discord/src/monitor/dm-command-auth.ts +101 +extensions/signal/src/monitor/access-policy.ts +92 +extensions/feishu/src/policy.ts +85 +extensions/slack/src/monitor/auth.ts +64 +extensions/googlechat/src/monitor-access.ts +59 +extensions/nextcloud-talk/src/inbound.ts +51 +extensions/matrix/src/matrix/monitor/access-state.ts +49 +extensions/irc/src/inbound.ts +44 +extensions/imessage/src/monitor/inbound-processing.ts +36 +extensions/qa-channel/src/inbound.ts +34 +extensions/qqbot/src/bridge/sdk-adapter.ts +33 +extensions/tlon/src/monitor/utils.ts +30 +extensions/twitch/src/access-control.ts +22 +extensions/qqbot/src/engine/commands/slash-command-handler.ts +20 +extensions/telegram/src/bot-handlers.runtime.ts +19 +``` + +The branch is not inside the minimum budget yet. The remaining review-relevant +work should delete repeated authorization flow, turn scaffolding, or wrapper +tests before adding another core abstraction. + +## Current Code Read + +The healthy core seam already exists in `src/channels/message-access/runtime.ts`: +it owns identity adapters, effective allowlists, pairing-store reads, route +descriptors, command/event presets, access groups, and the final resolved +`ResolvedChannelMessageIngress` projection. + +The remaining growth is mostly plugin glue layered on top of that seam: + +- `extensions/telegram/src/ingress.ts` wraps core decisions in Telegram-specific + command/event helpers, then call sites still pass precomputed normalized + allowlists and owner lists. +- `extensions/discord/src/monitor/dm-command-auth.ts`, + `extensions/feishu/src/policy.ts`, `extensions/googlechat/src/monitor-access.ts`, + and `extensions/matrix/src/matrix/monitor/access-state.ts` still keep + local policy DTOs or legacy decision names beside ingress. +- `extensions/signal/src/monitor/access-policy.ts` correctly keeps Signal + identity normalization and pairing replies local, but still has a wrapper + seam that should collapse into direct ingress consumption. +- `extensions/nextcloud-talk/src/inbound.ts`, `extensions/irc/src/inbound.ts`, + `extensions/qa-channel/src/inbound.ts`, `extensions/zalo/src/monitor.ts`, and + `extensions/zalouser/src/monitor.ts` still repeat route/envelope/turn + assembly that can move to shared turn helpers outside the ingress kernel. + +Conclusion: moving more code into core is only useful if it deletes these +plugin wrapper layers in the same patch. Adding another abstraction while +leaving wrapper returns in place repeats the mistake. + +## Boundary + +Core owns generic policy: + +- allowlist normalization and matching +- access-group expansion and diagnostics +- pairing-store DM allowlist reads +- route, sender, command, event, and activation gates +- admission mapping: dispatch, drop, skip, observe, pairing +- redacted state, decisions, diagnostics, and SDK compatibility projections +- reusable generic descriptors for identity, route, command, event, activation, + and outcomes + +Plugins own transport facts and side effects: + +- webhook/socket/request authenticity +- platform identity extraction and API lookups +- channel-specific policy defaults +- pairing challenge delivery, replies, acks, reactions, typing, media, history, + setup, doctor, status, logs, and user-facing copy + +Core must stay channel-agnostic: no Discord, Slack, Telegram, Matrix, room, +guild, space, API client, or plugin-specific default in +`src/channels/message-access`. + +## Acceptance Rule + +Every new core helper must delete bundled plugin production code immediately. + +```text +one bundled caller reject; keep plugin-local +two bundled callers accept only if plugin production LOC drops +three or more callers plugin deletion must be at least 2x new core LOC +compatibility-only helper SDK/core shim only; never bundled hot paths +``` + +Stop and redesign if: + +- plugin production LOC increases +- tests grow faster than production shrinks +- a bundled hot path returns a DTO that only renames `ResolvedChannelMessageIngress` +- a core helper needs a channel id, platform object, API client, or + channel-specific default + +## Work Packages + +1. Freeze the budget. + Put LOC in the PR, keep deprecated-ingress lint green, and include before/after + LOC in cleanup commits. + +2. Delete thin DTO seams. + Replace plugin-local wrapper returns with `ResolvedChannelMessageIngress`, + `senderAccess`, `commandAccess`, `routeAccess`, or `ingress` directly. Start + with QQBot, Telegram, Slack, Discord, Signal, Feishu, Matrix, iMessage, and + Tlon. Delete wrapper-shape tests; keep behavior tests. + +3. Add outcome classification only with deletions. + A generic classifier may expose `dispatch`, `pairing-required`, + `skip-activation`, `drop-command`, `drop-route`, `drop-sender`, and + `drop-ingress`. It must derive from the decision graph, not reason strings, + and migrate at least three plugins in the same patch. + +4. Add route descriptor builders only with deletions. + Generic route target and route sender helpers are acceptable only if they + immediately shrink route-heavy plugins: Google Chat, IRC, Microsoft Teams, + Nextcloud Talk, Mattermost, Slack, Zalo, and Zalo Personal. + +5. Add command/event presets only with deletions. + Centralize text-command, native-command, callback, and origin-subject shapes. + Command consumers must default to unauthorized when no command gate ran; + events must not start pairing. + +6. Add identity presets only where they remove boilerplate. + Stable-id, stable-id-plus-aliases, phone/e164, and multi-identifier helpers + are allowed when raw values enter only adapter input and redacted state keeps + opaque ids/counts. + +7. Share authorized turn assembly. + Outside the ingress kernel, remove repeated route/envelope/context/reply + scaffolding from QA Channel, IRC, Nextcloud Talk, Zalo, and Zalo Personal. + Core may own route/session/envelope/dispatch sequencing; plugins keep + delivery and channel-specific context. + +8. Quarantine compatibility. + Deprecated SDK helpers stay source-compatible, but bundled hot paths must not + import deprecated ingress or command-auth facades. Compatibility tests should + use fake third-party plugins, not bundled-plugin internals. + +9. Repack core. + After wrapper deletion, collapse one-use modules, remove unused exports, move + compatibility projection out of hot paths, and keep focused tests for identity, + route, command/event, activation, access groups, and compatibility shims. + +## Deletion Waves + +Run these in order. Each wave must lower bundled production LOC. + +1. Wrapper collapse, expected plugin delta: -400 to -600. + Replace plugin-local `resolveXAccess`, `resolveXCommandAccess`, and + `accessFromIngress` result types with direct reads from + `ResolvedChannelMessageIngress`. First targets: Discord DM command auth, + Feishu policy, Matrix access state, Telegram ingress, Signal access policy, + QQBot SDK adapter. + +2. Shared outcome helpers, expected plugin delta: -200 to -350. + Add one generic classifier only if it deletes repeated + `shouldBlockControlCommand`, pairing, activation skip, route block, and sender + block ladders across at least three plugins. + +3. Route descriptor builders, expected plugin delta: -200 to -350. + Move repeated route target and route sender descriptor assembly into core + helpers. First targets: Google Chat, IRC, Microsoft Teams, Nextcloud Talk, + Mattermost, Slack, Zalo, Zalo Personal. + +4. Turn assembly sharing, expected plugin delta: -250 to -450. + Use common route/session/envelope/dispatch sequencing for simple inbound + plugins. First targets: QA Channel, IRC, Nextcloud Talk, Zalo, Zalo Personal. + +5. Core repack, expected core delta: -300 to -700. + After plugins consume runtime projections directly, delete one-use modules, + merge tiny files back into `runtime.ts` or focused siblings, and keep SDK + compatibility files separate from bundled hot paths. + +6. Test pruning, expected test delta: -300 to -600. + Delete tests that only assert removed wrapper shapes. Keep behavior tests for + command denial, group fallback, origin-subject matching, activation skip, + access groups, pairing, and redaction. + +Expected minimum landing shape after these waves: + +```text +plugin production <= -1,500 +core production about +1,800 to +2,200 before final repack +tests <= +500 +total <= +2,000 +``` + +## Do Not Move + +Do not move platform config defaults, setup UX, doctor/fix copy, API lookups, +Slack owner-presence checks, Matrix alias/verification handling, Telegram +callback parsing, command syntax parsing, native command registration, reaction +payload parsing, pairing replies, command replies, acks, typing, media, history, +or logs. + +## Verification + +Targeted local loop: + +```sh +pnpm lint:extensions:no-deprecated-channel-access +pnpm test src/channels/message-access/message-access.test.ts src/plugin-sdk/channel-ingress-runtime.test.ts src/plugin-sdk/access-groups.test.ts +pnpm test extensions//src/... +pnpm plugin-sdk:api:check +pnpm config:docs:check +pnpm check:docs +git diff --check +``` + +Use Testbox for broad changed gates/full-suite proof once the LOC trend is +inside budget. + +Each work package records: + +- before/after LOC by category +- deleted plugin wrappers +- new core helper LOC, if any +- targeted tests run +- remaining hotspot list + +## Exit Criteria + +- bundled production imports no deprecated channel-access or command-auth facades +- compatibility code is isolated to SDK/core seams +- bundled plugins consume ingress projections or generic outcomes directly +- plugin production LOC is at least 1,500 net negative against `origin/main` +- core production LOC is <= +1,500, or any excess is paid for while total stays + <= +2,000 +- representative tests cover redaction, route, command/event, activation, + access-group, and channel-specific fallback behavior diff --git a/extensions/discord/src/monitor/access-groups.ts b/extensions/discord/src/monitor/access-groups.ts deleted file mode 100644 index a21dcb1a3af..00000000000 --- a/extensions/discord/src/monitor/access-groups.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - resolveAccessGroupAllowFromMatches, - type AccessGroupMembershipResolver, -} from "openclaw/plugin-sdk/command-auth"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; -import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import type { RequestClient } from "../internal/discord.js"; -import { canViewDiscordGuildChannel } from "../send.permissions.js"; - -export function createDiscordAccessGroupMembershipResolver(params: { - token?: string; - rest?: RequestClient; -}): AccessGroupMembershipResolver { - return async ({ cfg, name, group, accountId, senderId }) => { - if (group.type !== "discord.channelAudience") { - return false; - } - const membership = group.membership ?? "canViewChannel"; - if (membership !== "canViewChannel") { - return false; - } - return await canViewDiscordGuildChannel(group.guildId, group.channelId, senderId, { - cfg, - accountId, - token: params.token, - rest: params.rest, - }).catch((err) => { - logVerbose(`discord: accessGroup:${name} lookup failed for user ${senderId}: ${String(err)}`); - return false; - }); - }; -} - -export async function resolveDiscordDmAccessGroupEntries(params: { - cfg?: OpenClawConfig; - allowFrom: string[]; - sender: { id: string }; - accountId: string; - token?: string; - rest?: RequestClient; - isSenderAllowed?: (senderId: string, allowFrom: string[]) => boolean; -}): Promise { - return await resolveAccessGroupAllowFromMatches({ - cfg: params.cfg, - allowFrom: params.allowFrom, - channel: "discord", - accountId: params.accountId, - senderId: params.sender.id, - isSenderAllowed: params.isSenderAllowed, - resolveMembership: createDiscordAccessGroupMembershipResolver({ - token: params.token, - rest: params.rest, - }), - }); -} diff --git a/extensions/discord/src/monitor/agent-components-dm-auth.ts b/extensions/discord/src/monitor/agent-components-dm-auth.ts index 2479f94f003..f80da094a03 100644 --- a/extensions/discord/src/monitor/agent-components-dm-auth.ts +++ b/extensions/discord/src/monitor/agent-components-dm-auth.ts @@ -1,13 +1,12 @@ import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { resolveDiscordDmAccessGroupEntries } from "./access-groups.js"; import { resolveComponentInteractionContext, resolveDiscordChannelContext, } from "./agent-components-context.js"; import { - readStoreAllowFromForDmPolicy, + readChannelIngressStoreAllowFromForDmPolicy, upsertChannelPairingRequest, } from "./agent-components-helpers.runtime.js"; import { replySilently } from "./agent-components-reply.js"; @@ -16,11 +15,8 @@ import type { AgentComponentInteraction, DiscordUser, } from "./agent-components.types.js"; -import { - normalizeDiscordAllowList, - resolveDiscordAllowListMatch, - resolveGroupDmAllow, -} from "./allow-list.js"; +import { resolveGroupDmAllow } from "./allow-list.js"; +import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js"; import { formatDiscordUserTag } from "./format.js"; async function ensureDmComponentAuthorized(params: { @@ -31,50 +27,36 @@ async function ensureDmComponentAuthorized(params: { replyOpts: { ephemeral?: boolean }; }) { const { ctx, interaction, user, componentLabel, replyOpts } = params; - const allowFromPrefixes = ["discord:", "user:", "pk:"]; - const resolveAllowMatch = (entries: string[]) => { - const allowList = normalizeDiscordAllowList(entries, allowFromPrefixes); - return allowList - ? resolveDiscordAllowListMatch({ - allowList, - candidate: { - id: user.id, - name: user.username, - tag: formatDiscordUserTag(user), - }, - allowNameMatching: isDangerousNameMatchingEnabled(ctx.discordConfig), - }) - : { allowed: false }; - }; - const resolveAllowMatchWithAccessGroups = async (entries: string[]) => { - const staticMatch = resolveAllowMatch(entries); - if (staticMatch.allowed) { - return staticMatch; - } - const matchedGroups = await resolveDiscordDmAccessGroupEntries({ - cfg: ctx.cfg, - allowFrom: entries, - sender: { id: user.id }, - accountId: ctx.accountId, - token: ctx.token, - isSenderAllowed: (senderId, allowFrom) => - resolveAllowMatch(allowFrom).allowed || allowFrom.includes(senderId), - }); - return matchedGroups.length > 0 - ? resolveAllowMatch([...entries, `discord:${user.id}`]) - : staticMatch; - }; const dmPolicy = ctx.dmPolicy ?? "pairing"; if (dmPolicy === "disabled") { logVerbose(`agent ${componentLabel}: blocked (DM policy disabled)`); await replySilently(interaction, { content: "DM interactions are disabled.", ...replyOpts }); return false; } - if (dmPolicy === "allowlist") { - const allowMatch = await resolveAllowMatchWithAccessGroups(ctx.allowFrom ?? []); - if (allowMatch.allowed) { - return true; - } + const access = await resolveDiscordDmCommandAccess({ + accountId: ctx.accountId, + dmPolicy, + configuredAllowFrom: ctx.allowFrom ?? [], + sender: { + id: user.id, + name: user.username, + tag: formatDiscordUserTag(user), + }, + allowNameMatching: isDangerousNameMatchingEnabled(ctx.discordConfig), + cfg: ctx.cfg, + token: ctx.token, + readStoreAllowFrom: async ({ accountId, dmPolicy }) => + await readChannelIngressStoreAllowFromForDmPolicy({ + provider: "discord", + accountId, + dmPolicy, + }), + eventKind: "button", + }); + if (access.senderAccess.decision === "allow") { + return true; + } + if (access.senderAccess.decision !== "pairing") { logVerbose(`agent ${componentLabel}: blocked DM user ${user.id} (not in allowFrom)`); await replySilently(interaction, { content: `You are not authorized to use this ${componentLabel}.`, @@ -82,62 +64,36 @@ async function ensureDmComponentAuthorized(params: { }); return false; } - - const storeAllowFrom = - dmPolicy === "open" - ? [] - : await readStoreAllowFromForDmPolicy({ - provider: "discord", - accountId: ctx.accountId, - dmPolicy, - }); - const allowMatch = resolveAllowMatch([...(ctx.allowFrom ?? []), ...storeAllowFrom]); - const dynamicAllowMatch = allowMatch.allowed - ? allowMatch - : await resolveAllowMatchWithAccessGroups([...(ctx.allowFrom ?? []), ...storeAllowFrom]); - if (dynamicAllowMatch.allowed) { - return true; - } - - if (dmPolicy === "pairing") { - const pairingResult = await createChannelPairingChallengeIssuer({ - channel: "discord", - upsertPairingRequest: async ({ id, meta }) => { - return await upsertChannelPairingRequest({ - channel: "discord", - id, - accountId: ctx.accountId, - meta, - }); - }, - })({ - senderId: user.id, - senderIdLine: `Your Discord user id: ${user.id}`, - meta: { - tag: formatDiscordUserTag(user), - name: user.username, - }, - sendPairingReply: async (text) => { - await interaction.reply({ - content: text, - ...replyOpts, - }); - }, - }); - if (!pairingResult.created) { - await replySilently(interaction, { - content: "Pairing already requested. Ask the bot owner to approve your code.", + const pairingResult = await createChannelPairingChallengeIssuer({ + channel: "discord", + upsertPairingRequest: async ({ id, meta }) => { + return await upsertChannelPairingRequest({ + channel: "discord", + id, + accountId: ctx.accountId, + meta, + }); + }, + })({ + senderId: user.id, + senderIdLine: `Your Discord user id: ${user.id}`, + meta: { + tag: formatDiscordUserTag(user), + name: user.username, + }, + sendPairingReply: async (text) => { + await interaction.reply({ + content: text, ...replyOpts, }); - } - return false; - } - - logVerbose(`agent ${componentLabel}: blocked DM user ${user.id} (not in allowFrom)`); - await replySilently(interaction, { - content: `You are not authorized to use this ${componentLabel}.`, - ...replyOpts, + }, }); + if (!pairingResult.created) { + await replySilently(interaction, { + content: "Pairing already requested. Ask the bot owner to approve your code.", + ...replyOpts, + }); + } return false; } diff --git a/extensions/discord/src/monitor/agent-components-guild-auth.ts b/extensions/discord/src/monitor/agent-components-guild-auth.ts index ec03ead7216..2a2bbc00167 100644 --- a/extensions/discord/src/monitor/agent-components-guild-auth.ts +++ b/extensions/discord/src/monitor/agent-components-guild-auth.ts @@ -253,7 +253,7 @@ export async function resolveAuthorizedComponentInteraction(params: { return null; } - const commandAuthorized = resolveComponentCommandAuthorized({ + const commandAuthorized = await resolveComponentCommandAuthorized({ ctx: params.ctx, interactionCtx, channelConfig, @@ -273,7 +273,7 @@ export async function resolveAuthorizedComponentInteraction(params: { }; } -export function resolveComponentCommandAuthorized(params: { +export async function resolveComponentCommandAuthorized(params: { ctx: AgentComponentContext; interactionCtx: ComponentInteractionContext; channelConfig: ReturnType; diff --git a/extensions/discord/src/monitor/agent-components-helpers.runtime.ts b/extensions/discord/src/monitor/agent-components-helpers.runtime.ts index 91cc2c26125..c0bb204d942 100644 --- a/extensions/discord/src/monitor/agent-components-helpers.runtime.ts +++ b/extensions/discord/src/monitor/agent-components-helpers.runtime.ts @@ -1,5 +1,3 @@ -export { - readStoreAllowFromForDmPolicy, - resolvePinnedMainDmOwnerFromAllowlist, -} from "openclaw/plugin-sdk/security-runtime"; +export { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime"; +export { readChannelIngressStoreAllowFromForDmPolicy } from "openclaw/plugin-sdk/channel-ingress-runtime"; export { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime"; diff --git a/extensions/discord/src/monitor/agent-components.dispatch.ts b/extensions/discord/src/monitor/agent-components.dispatch.ts index 130869be8ab..03ddf185193 100644 --- a/extensions/discord/src/monitor/agent-components.dispatch.ts +++ b/extensions/discord/src/monitor/agent-components.dispatch.ts @@ -36,9 +36,6 @@ import { buildDirectLabel, buildGuildLabel } from "./reply-context.js"; import { deliverDiscordReply } from "./reply-delivery.js"; let conversationRuntimePromise: Promise | undefined; -let replyPipelineRuntimePromise: - | Promise - | undefined; let typingRuntimePromise: Promise | undefined; async function loadConversationRuntime() { @@ -46,11 +43,6 @@ async function loadConversationRuntime() { return await conversationRuntimePromise; } -async function loadReplyPipelineRuntime() { - replyPipelineRuntimePromise ??= import("openclaw/plugin-sdk/channel-message"); - return await replyPipelineRuntimePromise; -} - async function loadTypingRuntime() { typingRuntimePromise ??= import("./typing.js"); return await typingRuntimePromise; @@ -163,7 +155,7 @@ export async function dispatchDiscordComponentEvent(params: { }, }) : null; - const commandAuthorized = resolveComponentCommandAuthorized({ + const commandAuthorized = await resolveComponentCommandAuthorized({ ctx, interactionCtx, channelConfig, @@ -241,13 +233,6 @@ export async function dispatchDiscordComponentEvent(params: { const deliverTarget = `channel:${interactionCtx.channelId}`; const typingChannelId = interactionCtx.channelId; - const { createChannelMessageReplyPipeline } = await loadReplyPipelineRuntime(); - const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({ - cfg: ctx.cfg, - agentId, - channel: "discord", - accountId, - }); const tableMode = resolveMarkdownTableMode({ cfg: ctx.cfg, channel: "discord", @@ -283,12 +268,15 @@ export async function dispatchDiscordComponentEvent(params: { raw: interaction, }), resolveTurn: () => ({ + cfg: ctx.cfg, channel: "discord", accountId, + agentId, routeSessionKey: sessionKey, storePath, ctxPayload, recordInboundSession, + dispatchReplyWithBufferedBlockDispatcher, record: { updateLastRoute: interactionCtx.isDirectMessage ? { @@ -315,51 +303,47 @@ export async function dispatchDiscordComponentEvent(params: { logVerbose(`discord: failed updating component session meta: ${String(err)}`); }, }, - runDispatch: () => - dispatchReplyWithBufferedBlockDispatcher({ - ctx: ctxPayload, - cfg: ctx.cfg, - replyOptions: { onModelSelected }, - dispatcherOptions: { - ...replyPipeline, - humanDelay: resolveHumanDelayConfig(ctx.cfg, agentId), - deliver: async (payload) => { - const replyToId = replyReference.use(); - await deliverDiscordReply({ - cfg: ctx.cfg, - replies: [payload], - target: deliverTarget, - token, - accountId, - rest: interaction.client.rest, - runtime, - replyToId, - replyToMode, - textLimit, - maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({ - cfg: ctx.cfg, - discordConfig: ctx.discordConfig, - accountId, - }), - tableMode, - chunkMode: resolveChunkMode(ctx.cfg, "discord", accountId), - mediaLocalRoots, - }); - replyReference.markSent(); - }, - onReplyStart: async () => { - try { - const { sendTyping } = await loadTypingRuntime(); - await sendTyping({ rest: feedbackRest, channelId: typingChannelId }); - } catch (err) { - logVerbose(`discord: typing failed for component reply: ${String(err)}`); - } - }, - onError: (err) => { - logError(`discord component dispatch failed: ${String(err)}`); - }, - }, - }), + delivery: { + deliver: async (payload) => { + const replyToId = replyReference.use(); + await deliverDiscordReply({ + cfg: ctx.cfg, + replies: [payload], + target: deliverTarget, + token, + accountId, + rest: interaction.client.rest, + runtime, + replyToId, + replyToMode, + textLimit, + maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({ + cfg: ctx.cfg, + discordConfig: ctx.discordConfig, + accountId, + }), + tableMode, + chunkMode: resolveChunkMode(ctx.cfg, "discord", accountId), + mediaLocalRoots, + }); + replyReference.markSent(); + }, + onError: (err) => { + logError(`discord component dispatch failed: ${String(err)}`); + }, + }, + replyPipeline: {}, + dispatcherOptions: { + humanDelay: resolveHumanDelayConfig(ctx.cfg, agentId), + onReplyStart: async () => { + try { + const { sendTyping } = await loadTypingRuntime(); + await sendTyping({ rest: feedbackRest, channelId: typingChannelId }); + } catch (err) { + logVerbose(`discord: typing failed for component reply: ${String(err)}`); + } + }, + }, }), }, }); diff --git a/extensions/discord/src/monitor/allow-list.ts b/extensions/discord/src/monitor/allow-list.ts index 2541606fdcf..7e6962f160e 100644 --- a/extensions/discord/src/monitor/allow-list.ts +++ b/extensions/discord/src/monitor/allow-list.ts @@ -5,7 +5,6 @@ import { resolveChannelMatchConfig, type ChannelMatchSource, } from "openclaw/plugin-sdk/channel-targets"; -import { evaluateGroupRouteAccessForPolicy } from "openclaw/plugin-sdk/group-access"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -536,15 +535,14 @@ export function isDiscordGroupAllowedByPolicy(params: { if (params.groupPolicy === "allowlist" && !params.guildAllowlisted) { return false; } - - return evaluateGroupRouteAccessForPolicy({ - groupPolicy: - params.groupPolicy === "allowlist" && !params.channelAllowlistConfigured - ? "open" - : params.groupPolicy, - routeAllowlistConfigured: params.channelAllowlistConfigured, - routeMatched: params.channelAllowed, - }).allowed; + if (params.groupPolicy === "disabled") { + return false; + } + return ( + params.groupPolicy !== "allowlist" || + !params.channelAllowlistConfigured || + params.channelAllowed + ); } export function resolveDiscordChannelPolicyCommandAuthorizer(params: { diff --git a/extensions/discord/src/monitor/dm-command-auth.test.ts b/extensions/discord/src/monitor/dm-command-auth.test.ts index cdcbd98a600..8af58af0754 100644 --- a/extensions/discord/src/monitor/dm-command-auth.test.ts +++ b/extensions/discord/src/monitor/dm-command-auth.test.ts @@ -1,7 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js"; +import { + resolveDiscordDmCommandAccess, + resolveDiscordTextCommandAccess, +} from "./dm-command-auth.js"; const canViewDiscordGuildChannelMock = vi.hoisted(() => vi.fn()); +type DiscordDmIngressAccess = Awaited>; vi.mock("../send.permissions.js", async (importOriginal) => { const actual = await importOriginal(); @@ -11,6 +15,91 @@ vi.mock("../send.permissions.js", async (importOriginal) => { }; }); +function dmCommandAuthorized(result: DiscordDmIngressAccess): boolean { + return result.senderAccess.allowed ? result.commandAccess.authorized : false; +} + +describe("resolveDiscordTextCommandAccess", () => { + const sender = { + id: "123", + name: "alice", + tag: "alice#0001", + }; + + it("authorizes guild text commands from owner allowlists", async () => { + await expect( + resolveDiscordTextCommandAccess({ + accountId: "default", + sender, + ownerAllowFrom: ["discord:123"], + memberAccessConfigured: false, + memberAllowed: false, + allowNameMatching: false, + allowTextCommands: true, + hasControlCommand: true, + }), + ).resolves.toMatchObject({ + authorized: true, + shouldBlockControlCommand: false, + }); + }); + + it("authorizes guild text commands from member access facts", async () => { + await expect( + resolveDiscordTextCommandAccess({ + accountId: "default", + sender, + ownerAllowFrom: [], + memberAccessConfigured: true, + memberAllowed: true, + allowNameMatching: false, + allowTextCommands: true, + hasControlCommand: true, + }), + ).resolves.toMatchObject({ + authorized: true, + shouldBlockControlCommand: false, + }); + }); + + it("blocks unauthorized guild text control commands", async () => { + await expect( + resolveDiscordTextCommandAccess({ + accountId: "default", + sender, + ownerAllowFrom: ["discord:999"], + memberAccessConfigured: true, + memberAllowed: false, + allowNameMatching: false, + allowTextCommands: true, + hasControlCommand: true, + }), + ).resolves.toMatchObject({ + authorized: false, + shouldBlockControlCommand: true, + }); + }); + + it("preserves configured mode when access groups are disabled", async () => { + await expect( + resolveDiscordTextCommandAccess({ + accountId: "default", + sender, + ownerAllowFrom: [], + memberAccessConfigured: false, + memberAllowed: false, + allowNameMatching: false, + cfg: { commands: { useAccessGroups: false } }, + allowTextCommands: true, + hasControlCommand: true, + }), + ).resolves.toMatchObject({ + authorized: true, + shouldBlockControlCommand: false, + }); + }); +}); + describe("resolveDiscordDmCommandAccess", () => { const sender = { id: "123", @@ -29,7 +118,6 @@ describe("resolveDiscordDmCommandAccess", () => { configuredAllowFrom, sender, allowNameMatching: false, - useAccessGroups: true, readStoreAllowFrom: async () => [], }); } @@ -37,15 +125,15 @@ describe("resolveDiscordDmCommandAccess", () => { it("blocks open DMs without allowlist wildcard entries", async () => { const result = await resolveOpenDmAccess([]); - expect(result.decision).toBe("block"); - expect(result.commandAuthorized).toBe(false); + expect(result.senderAccess.decision).toBe("block"); + expect(dmCommandAuthorized(result)).toBe(false); }); it("marks command auth true when sender is allowlisted", async () => { const result = await resolveOpenDmAccess(["discord:123"]); - expect(result.decision).toBe("allow"); - expect(result.commandAuthorized).toBe(true); + expect(result.senderAccess.decision).toBe("allow"); + expect(dmCommandAuthorized(result)).toBe(true); }); it("blocks open DMs when configured allowlist does not match", async () => { @@ -55,13 +143,12 @@ describe("resolveDiscordDmCommandAccess", () => { configuredAllowFrom: ["discord:999"], sender, allowNameMatching: false, - useAccessGroups: true, readStoreAllowFrom: async () => [], }); - expect(result.decision).toBe("block"); - expect(result.allowMatch.allowed).toBe(false); - expect(result.commandAuthorized).toBe(false); + expect(result.senderAccess.decision).toBe("block"); + expect(result.senderAccess.reasonCode).toBe("dm_policy_not_allowlisted"); + expect(dmCommandAuthorized(result)).toBe(false); }); it("returns pairing decision and unauthorized command auth for unknown senders", async () => { @@ -71,12 +158,11 @@ describe("resolveDiscordDmCommandAccess", () => { configuredAllowFrom: ["discord:456"], sender, allowNameMatching: false, - useAccessGroups: true, readStoreAllowFrom: async () => [], }); - expect(result.decision).toBe("pairing"); - expect(result.commandAuthorized).toBe(false); + expect(result.senderAccess.decision).toBe("pairing"); + expect(dmCommandAuthorized(result)).toBe(false); }); it("authorizes sender from pairing-store allowlist entries", async () => { @@ -86,12 +172,11 @@ describe("resolveDiscordDmCommandAccess", () => { configuredAllowFrom: [], sender, allowNameMatching: false, - useAccessGroups: true, readStoreAllowFrom: async () => ["discord:123"], }); - expect(result.decision).toBe("allow"); - expect(result.commandAuthorized).toBe(true); + expect(result.senderAccess.decision).toBe("allow"); + expect(dmCommandAuthorized(result)).toBe(true); }); it("authorizes allowlist DMs from a Discord channel audience access group", async () => { @@ -103,7 +188,6 @@ describe("resolveDiscordDmCommandAccess", () => { configuredAllowFrom: ["accessGroup:maintainers"], sender, allowNameMatching: false, - useAccessGroups: true, cfg: { accessGroups: { maintainers: { @@ -123,8 +207,8 @@ describe("resolveDiscordDmCommandAccess", () => { "123", expect.objectContaining({ accountId: "default", token: "token" }), ); - expect(result.decision).toBe("allow"); - expect(result.commandAuthorized).toBe(true); + expect(result.senderAccess.decision).toBe("allow"); + expect(dmCommandAuthorized(result)).toBe(true); }); it("authorizes allowlist DMs from a generic message sender access group", async () => { @@ -134,7 +218,6 @@ describe("resolveDiscordDmCommandAccess", () => { configuredAllowFrom: ["accessGroup:owners"], sender, allowNameMatching: false, - useAccessGroups: true, cfg: { accessGroups: { owners: { @@ -150,8 +233,8 @@ describe("resolveDiscordDmCommandAccess", () => { }); expect(canViewDiscordGuildChannelMock).not.toHaveBeenCalled(); - expect(result.decision).toBe("allow"); - expect(result.commandAuthorized).toBe(true); + expect(result.senderAccess.decision).toBe("allow"); + expect(dmCommandAuthorized(result)).toBe(true); }); it("fails closed when a Discord channel audience access group lookup rejects", async () => { @@ -163,7 +246,6 @@ describe("resolveDiscordDmCommandAccess", () => { configuredAllowFrom: ["accessGroup:maintainers"], sender, allowNameMatching: false, - useAccessGroups: true, cfg: { accessGroups: { maintainers: { @@ -176,8 +258,8 @@ describe("resolveDiscordDmCommandAccess", () => { readStoreAllowFrom: async () => [], }); - expect(result.decision).toBe("block"); - expect(result.commandAuthorized).toBe(false); + expect(result.senderAccess.decision).toBe("block"); + expect(dmCommandAuthorized(result)).toBe(false); }); it("keeps open DM blocked without wildcard even when access groups are disabled", async () => { @@ -187,11 +269,11 @@ describe("resolveDiscordDmCommandAccess", () => { configuredAllowFrom: [], sender, allowNameMatching: false, - useAccessGroups: false, + cfg: { commands: { useAccessGroups: false } }, readStoreAllowFrom: async () => [], }); - expect(result.decision).toBe("block"); - expect(result.commandAuthorized).toBe(false); + expect(result.senderAccess.decision).toBe("block"); + expect(dmCommandAuthorized(result)).toBe(false); }); }); diff --git a/extensions/discord/src/monitor/dm-command-auth.ts b/extensions/discord/src/monitor/dm-command-auth.ts index 16d82879151..0fa19d38c0f 100644 --- a/extensions/discord/src/monitor/dm-command-auth.ts +++ b/extensions/discord/src/monitor/dm-command-auth.ts @@ -1,158 +1,259 @@ -import { expandAllowFromWithAccessGroups } from "openclaw/plugin-sdk/command-auth"; -import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/command-auth-native"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { - readStoreAllowFromForDmPolicy, - resolveDmGroupAccessWithLists, - type DmGroupAccessDecision, -} from "openclaw/plugin-sdk/security-runtime"; + type AccessGroupMembershipFact, + type ChannelIngressEventInput, + type ChannelIngressIdentifierKind, + createChannelIngressResolver, + defineStableChannelIngressIdentity, + type ChannelIngressIdentitySubjectInput, + type ResolveChannelMessageIngressParams, +} from "openclaw/plugin-sdk/channel-ingress-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import type { RequestClient } from "../internal/discord.js"; -import { createDiscordAccessGroupMembershipResolver } from "./access-groups.js"; -import { normalizeDiscordAllowList, resolveDiscordAllowListMatch } from "./allow-list.js"; +import { canViewDiscordGuildChannel } from "../send.permissions.js"; +import { normalizeDiscordAllowList } from "./allow-list.js"; const DISCORD_ALLOW_LIST_PREFIXES = ["discord:", "user:", "pk:"]; +const DISCORD_CHANNEL_ID = "discord"; +const DISCORD_USER_ID_KIND = "stable-id" satisfies ChannelIngressIdentifierKind; +const DISCORD_USER_NAME_KIND = "username" satisfies ChannelIngressIdentifierKind; export type DiscordDmPolicy = "open" | "pairing" | "allowlist" | "disabled"; -export type DiscordDmCommandAccess = { - decision: DmGroupAccessDecision; - reason: string; - commandAuthorized: boolean; - allowMatch: ReturnType | { allowed: false }; -}; - -function resolveSenderAllowMatch(params: { - allowEntries: string[]; - sender: { id: string; name?: string; tag?: string }; - allowNameMatching: boolean; -}) { - const allowList = normalizeDiscordAllowList(params.allowEntries, DISCORD_ALLOW_LIST_PREFIXES); - return allowList - ? resolveDiscordAllowListMatch({ - allowList, - candidate: params.sender, - allowNameMatching: params.allowNameMatching, - }) - : ({ allowed: false } as const); +function normalizeDiscordIdEntry(entry: string): string | null { + const text = entry.trim(); + if (!text) { + return null; + } + const maybeId = text.replace(/^<@!?/, "").replace(/>$/, ""); + if (/^\d+$/.test(maybeId)) { + return maybeId; + } + const prefix = DISCORD_ALLOW_LIST_PREFIXES.find((entryPrefix) => text.startsWith(entryPrefix)); + if (prefix) { + const candidate = text.slice(prefix.length).trim(); + return candidate || null; + } + return null; } -function resolveDmPolicyCommandAuthorization(params: { - decision: DmGroupAccessDecision; - commandAuthorized: boolean; -}) { - return params.commandAuthorized; +function normalizeDiscordNameEntry(entry: string): string | null { + const text = entry.trim(); + if (!text || text === "*" || normalizeDiscordIdEntry(text)) { + return null; + } + const nameSlug = normalizeDiscordAllowList([text], DISCORD_ALLOW_LIST_PREFIXES) + ?.names.values() + .next().value; + return typeof nameSlug === "string" && nameSlug ? nameSlug : null; } -async function expandAllowFromWithDiscordAccessGroups(params: { +function normalizeDiscordNameSubject(value: string): string | null { + const nameSlug = normalizeDiscordAllowList([value], DISCORD_ALLOW_LIST_PREFIXES) + ?.names.values() + .next().value; + return typeof nameSlug === "string" && nameSlug ? nameSlug : null; +} + +const discordIngressIdentity = defineStableChannelIngressIdentity({ + key: "discordUserId", + kind: DISCORD_USER_ID_KIND, + normalizeEntry: normalizeDiscordIdEntry, + normalizeSubject: (value) => value.trim() || null, + sensitivity: "pii", + aliases: ( + [ + ["discordUserName", normalizeDiscordNameEntry], + ["discordUserTag", () => null], + ] as const + ).map(([key, normalizeEntry]) => ({ + key, + kind: DISCORD_USER_NAME_KIND, + normalizeEntry, + normalizeSubject: normalizeDiscordNameSubject, + dangerous: true, + sensitivity: "pii", + })), +}); + +function createDiscordDmIngressSubject(sender: { + id: string; + name?: string; + tag?: string; +}): ChannelIngressIdentitySubjectInput { + return { + stableId: sender.id, + aliases: { + discordUserName: sender.name, + discordUserTag: sender.tag, + }, + }; +} + +function createDiscordDynamicAccessGroupResolver(params: { cfg?: OpenClawConfig; - allowFrom: string[]; - sender: { id: string }; - accountId: string; token?: string; rest?: RequestClient; +}): ResolveChannelMessageIngressParams["resolveAccessGroupMembership"] { + if (!params.cfg) { + return undefined; + } + const cfg = params.cfg; + return async ({ name, group, accountId, subject }) => { + if (group.type !== "discord.channelAudience") { + return false; + } + const senderId = String(subject.stableId ?? "").trim(); + if (!senderId) { + return false; + } + const membership = group.membership ?? "canViewChannel"; + if (membership !== "canViewChannel") { + return false; + } + try { + return await canViewDiscordGuildChannel(group.guildId, group.channelId, senderId, { + cfg, + accountId, + token: params.token, + rest: params.rest, + }); + } catch (err) { + logVerbose(`discord: accessGroup:${name} lookup failed for user ${senderId}: ${String(err)}`); + throw err; + } + }; +} + +function createDiscordIngressResolver(params: { + accountId: string; + cfg?: OpenClawConfig; + token?: string; + rest?: RequestClient; + readStoreAllowFrom?: ResolveChannelMessageIngressParams["readStoreAllowFrom"]; + useDefaultPairingStore?: boolean; }) { - return await expandAllowFromWithAccessGroups({ - cfg: params.cfg, - allowFrom: params.allowFrom, - channel: "discord", + return createChannelIngressResolver({ + channelId: DISCORD_CHANNEL_ID, accountId: params.accountId, - senderId: params.sender.id, - senderAllowEntry: `discord:${params.sender.id}`, - isSenderAllowed: (senderId, allowFrom) => - resolveSenderAllowMatch({ - allowEntries: allowFrom, - sender: { id: senderId }, - allowNameMatching: false, - }).allowed, - resolveMembership: createDiscordAccessGroupMembershipResolver({ + identity: discordIngressIdentity, + cfg: params.cfg, + resolveAccessGroupMembership: createDiscordDynamicAccessGroupResolver({ + cfg: params.cfg, token: params.token, rest: params.rest, }), + ...(params.readStoreAllowFrom ? { readStoreAllowFrom: params.readStoreAllowFrom } : {}), + ...(params.useDefaultPairingStore !== undefined + ? { useDefaultPairingStore: params.useDefaultPairingStore } + : {}), }); } +function syntheticAccessGroupMembership( + groupName: string, + allowed: boolean, +): AccessGroupMembershipFact { + return allowed + ? { + kind: "matched", + groupName, + source: "dynamic", + matchedEntryIds: [groupName], + } + : { + kind: "not-matched", + groupName, + source: "dynamic", + }; +} + export async function resolveDiscordDmCommandAccess(params: { accountId: string; dmPolicy: DiscordDmPolicy; configuredAllowFrom: string[]; sender: { id: string; name?: string; tag?: string }; allowNameMatching: boolean; - useAccessGroups: boolean; cfg?: OpenClawConfig; token?: string; rest?: RequestClient; - readStoreAllowFrom?: () => Promise; -}): Promise { - const storeAllowFrom = params.readStoreAllowFrom - ? params.dmPolicy === "open" - ? [] - : await params.readStoreAllowFrom().catch(() => []) - : await readStoreAllowFromForDmPolicy({ - provider: "discord", - accountId: params.accountId, - dmPolicy: params.dmPolicy, - shouldRead: params.dmPolicy !== "open", - }); - const [configuredAllowFrom, effectiveStoreAllowFrom] = await Promise.all([ - expandAllowFromWithDiscordAccessGroups({ - cfg: params.cfg, - allowFrom: params.configuredAllowFrom, - sender: params.sender, - accountId: params.accountId, - token: params.token, - rest: params.rest, - }), - expandAllowFromWithDiscordAccessGroups({ - cfg: params.cfg, - allowFrom: storeAllowFrom, - sender: params.sender, - accountId: params.accountId, - token: params.token, - rest: params.rest, - }), - ]); - - const access = resolveDmGroupAccessWithLists({ - isGroup: false, + readStoreAllowFrom?: ResolveChannelMessageIngressParams["readStoreAllowFrom"]; + eventKind?: ChannelIngressEventInput["kind"]; +}) { + return await createDiscordIngressResolver({ + accountId: params.accountId, + cfg: params.cfg, + token: params.token, + rest: params.rest, + readStoreAllowFrom: params.readStoreAllowFrom, + useDefaultPairingStore: params.readStoreAllowFrom == null, + }).message({ + subject: createDiscordDmIngressSubject(params.sender), + conversation: { + kind: "direct", + id: params.sender.id, + }, + event: { + kind: params.eventKind ?? "native-command", + authMode: "inbound", + mayPair: true, + }, dmPolicy: params.dmPolicy, - allowFrom: configuredAllowFrom, - groupAllowFrom: [], - storeAllowFrom: effectiveStoreAllowFrom, - isSenderAllowed: (allowEntries) => - resolveSenderAllowMatch({ - allowEntries, - sender: params.sender, - allowNameMatching: params.allowNameMatching, - }).allowed, + groupPolicy: "disabled", + policy: { + mutableIdentifierMatching: params.allowNameMatching ? "enabled" : "disabled", + }, + allowFrom: params.configuredAllowFrom, + command: { + hasControlCommand: false, + modeWhenAccessGroupsOff: "configured", + }, }); - - const allowMatch = resolveSenderAllowMatch({ - allowEntries: access.effectiveAllowFrom, - sender: params.sender, - allowNameMatching: params.allowNameMatching, - }); - - const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({ - useAccessGroups: params.useAccessGroups, - authorizers: [ - { - configured: access.effectiveAllowFrom.length > 0, - allowed: allowMatch.allowed, - }, - ], - modeWhenAccessGroupsOff: "configured", - }); - - return { - decision: access.decision, - reason: access.reason, - commandAuthorized: - access.decision === "allow" - ? resolveDmPolicyCommandAuthorization({ - decision: access.decision, - commandAuthorized, - }) - : false, - allowMatch, - }; +} + +export async function resolveDiscordTextCommandAccess(params: { + accountId: string; + sender: { id: string; name?: string; tag?: string }; + ownerAllowFrom?: string[]; + memberAccessConfigured: boolean; + memberAllowed: boolean; + allowNameMatching: boolean; + allowTextCommands: boolean; + hasControlCommand: boolean; + cfg?: OpenClawConfig; + token?: string; + rest?: RequestClient; +}) { + const ownerAllowFrom = (params.ownerAllowFrom ?? []).filter((entry) => entry.trim() !== "*"); + const memberAccessGroup = "discord-member-access"; + const commandGroup = params.memberAccessConfigured ? [`accessGroup:${memberAccessGroup}`] : []; + const accessGroupMembership = params.memberAccessConfigured + ? [syntheticAccessGroupMembership(memberAccessGroup, params.memberAllowed)] + : []; + const result = await createDiscordIngressResolver({ + accountId: params.accountId, + cfg: params.cfg, + token: params.token, + rest: params.rest, + }).command({ + subject: createDiscordDmIngressSubject(params.sender), + conversation: { + kind: "group", + id: "discord-command", + }, + accessGroupMembership, + dmPolicy: "allowlist", + groupPolicy: "allowlist", + policy: { + mutableIdentifierMatching: params.allowNameMatching ? "enabled" : "disabled", + }, + allowFrom: ownerAllowFrom, + groupAllowFrom: commandGroup, + command: { + allowTextCommands: params.allowTextCommands, + hasControlCommand: params.hasControlCommand, + modeWhenAccessGroupsOff: "configured", + }, + }); + return result.commandAccess; } diff --git a/extensions/discord/src/monitor/dm-command-decision.test.ts b/extensions/discord/src/monitor/dm-command-decision.test.ts index 2f87d8bb30b..199eb3cb928 100644 --- a/extensions/discord/src/monitor/dm-command-decision.test.ts +++ b/extensions/discord/src/monitor/dm-command-decision.test.ts @@ -1,13 +1,12 @@ +import type { ResolvedChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime"; import { describe, expect, it, vi } from "vitest"; -import type { DiscordDmCommandAccess } from "./dm-command-auth.js"; import { handleDiscordDmCommandDecision } from "./dm-command-decision.js"; -function buildDmAccess(overrides: Partial): DiscordDmCommandAccess { +function buildSenderAccess( + overrides: Pick, "decision">, +): Pick { return { decision: "allow", - reason: "ok", - commandAuthorized: true, - allowMatch: { allowed: true, matchKey: "123", matchSource: "id" }, ...overrides, }; } @@ -28,10 +27,8 @@ function createDmDecisionHarness(params?: { pairingCreated?: boolean }) { async function runPairingDecision(params?: { pairingCreated?: boolean }) { const harness = createDmDecisionHarness({ pairingCreated: params?.pairingCreated }); const allowed = await handleDiscordDmCommandDecision({ - dmAccess: buildDmAccess({ + senderAccess: buildSenderAccess({ decision: "pairing", - commandAuthorized: false, - allowMatch: { allowed: false }, }), accountId: TEST_ACCOUNT_ID, sender: TEST_SENDER, @@ -47,7 +44,7 @@ describe("handleDiscordDmCommandDecision", () => { const { onPairingCreated, onUnauthorized, upsertPairingRequest } = createDmDecisionHarness(); const allowed = await handleDiscordDmCommandDecision({ - dmAccess: buildDmAccess({ decision: "allow" }), + senderAccess: buildSenderAccess({ decision: "allow" }), accountId: TEST_ACCOUNT_ID, sender: TEST_SENDER, onPairingCreated, @@ -93,10 +90,8 @@ describe("handleDiscordDmCommandDecision", () => { const { onPairingCreated, onUnauthorized, upsertPairingRequest } = createDmDecisionHarness(); const allowed = await handleDiscordDmCommandDecision({ - dmAccess: buildDmAccess({ + senderAccess: buildSenderAccess({ decision: "block", - commandAuthorized: false, - allowMatch: { allowed: false }, }), accountId: TEST_ACCOUNT_ID, sender: TEST_SENDER, diff --git a/extensions/discord/src/monitor/dm-command-decision.ts b/extensions/discord/src/monitor/dm-command-decision.ts index 22c81040b67..44ad156b989 100644 --- a/extensions/discord/src/monitor/dm-command-decision.ts +++ b/extensions/discord/src/monitor/dm-command-decision.ts @@ -1,9 +1,9 @@ +import type { ResolvedChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime"; import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing"; import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime"; -import type { DiscordDmCommandAccess } from "./dm-command-auth.js"; export async function handleDiscordDmCommandDecision(params: { - dmAccess: DiscordDmCommandAccess; + senderAccess: Pick; accountId: string; sender: { id: string; @@ -14,11 +14,11 @@ export async function handleDiscordDmCommandDecision(params: { onUnauthorized: () => Promise; upsertPairingRequest?: typeof upsertChannelPairingRequest; }): Promise { - if (params.dmAccess.decision === "allow") { + if (params.senderAccess.decision === "allow") { return true; } - if (params.dmAccess.decision === "pairing") { + if (params.senderAccess.decision === "pairing") { const upsertPairingRequest = params.upsertPairingRequest ?? upsertChannelPairingRequest; const result = await createChannelPairingChallengeIssuer({ channel: "discord", diff --git a/extensions/discord/src/monitor/listeners.reactions.ts b/extensions/discord/src/monitor/listeners.reactions.ts index 2577b5d33bd..fdbc831cdb5 100644 --- a/extensions/discord/src/monitor/listeners.reactions.ts +++ b/extensions/discord/src/monitor/listeners.reactions.ts @@ -1,10 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { - readStoreAllowFromForDmPolicy, - resolveDmGroupAccessWithLists, -} from "openclaw/plugin-sdk/security-runtime"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime"; import { ChannelType, @@ -15,15 +11,14 @@ import { } from "../internal/discord.js"; import { isDiscordGroupAllowedByPolicy, - normalizeDiscordAllowList, normalizeDiscordSlug, - resolveDiscordAllowListMatch, resolveDiscordChannelConfigWithFallback, resolveDiscordGuildEntry, resolveDiscordMemberAccessState, resolveGroupDmAllow, shouldEmitDiscordReactionNotification, } from "./allow-list.js"; +import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js"; import { formatDiscordReactionEmoji, formatDiscordUserTag } from "./format.js"; import { runDiscordListenerWithSlowLog, type DiscordListenerLogger } from "./listeners.queue.js"; import { resolveFetchedDiscordThreadLikeChannelContext } from "./thread-channel-context.js"; @@ -128,6 +123,7 @@ async function runDiscordReactionHandler(params: { } type DiscordReactionIngressAuthorizationParams = { + cfg: LoadedConfig; accountId: string; user: User; memberRoleIds: string[]; @@ -158,36 +154,21 @@ async function authorizeDiscordReactionIngress( return { allowed: false, reason: "group-dm-disabled" }; } if (params.isDirectMessage) { - const storeAllowFrom = await readStoreAllowFromForDmPolicy({ - provider: "discord", + const access = await resolveDiscordDmCommandAccess({ + cfg: params.cfg, accountId: params.accountId, dmPolicy: params.dmPolicy, - }); - const access = resolveDmGroupAccessWithLists({ - isGroup: false, - dmPolicy: params.dmPolicy, - groupPolicy: params.groupPolicy, - allowFrom: params.allowFrom, - groupAllowFrom: [], - storeAllowFrom, - isSenderAllowed: (allowEntries) => { - const allowList = normalizeDiscordAllowList(allowEntries, ["discord:", "user:", "pk:"]); - const allowMatch = allowList - ? resolveDiscordAllowListMatch({ - allowList, - candidate: { - id: params.user.id, - name: params.user.username, - tag: formatDiscordUserTag(params.user), - }, - allowNameMatching: params.allowNameMatching, - }) - : { allowed: false }; - return allowMatch.allowed; + configuredAllowFrom: params.allowFrom, + sender: { + id: params.user.id, + name: params.user.username, + tag: formatDiscordUserTag(params.user), }, + allowNameMatching: params.allowNameMatching, + eventKind: "reaction", }); - if (access.decision !== "allow") { - return { allowed: false, reason: access.reason }; + if (access.senderAccess.decision !== "allow") { + return { allowed: false, reason: access.senderAccess.reasonCode }; } } if ( @@ -452,6 +433,7 @@ async function handleDiscordReactionEvent( const isGroupDm = channelType === ChannelType.GroupDM; const isThreadChannel = channelContext.isThreadChannel; const reactionIngressBase: Omit = { + cfg: params.cfg, accountId: params.accountId, user, memberRoleIds, @@ -519,6 +501,7 @@ async function handleDiscordReactionEvent( enqueueSystemEvent(text, { sessionKey: route.sessionKey, contextKey, + trusted: false, }); }; const shouldNotifyReaction = (options: { diff --git a/extensions/discord/src/monitor/message-handler.dm-preflight.ts b/extensions/discord/src/monitor/message-handler.dm-preflight.ts index 39237178aad..9f7a3a6d6f4 100644 --- a/extensions/discord/src/monitor/message-handler.dm-preflight.ts +++ b/extensions/discord/src/monitor/message-handler.dm-preflight.ts @@ -1,4 +1,3 @@ -import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolveDiscordConversationIdentity } from "../conversation-identity.js"; import type { User } from "../internal/discord.js"; @@ -32,7 +31,6 @@ export async function resolveDiscordDmPreflightAccess(params: { dmPolicy: DiscordDmPolicy; resolvedAccountId: string; allowNameMatching: boolean; - useAccessGroups: boolean; }): Promise<{ commandAuthorized: boolean } | null> { if (params.dmPolicy === "disabled") { logVerbose("discord: drop dm (dmPolicy: disabled)"); @@ -61,13 +59,14 @@ export async function resolveDiscordDmPreflightAccess(params: { tag: params.sender.tag, }, allowNameMatching: params.allowNameMatching, - useAccessGroups: params.useAccessGroups, cfg: params.preflight.cfg, token: params.preflight.token, rest: params.preflight.client.rest, }); - const commandAuthorized = dmAccess.commandAuthorized || directBindingRecord != null; - if (dmAccess.decision === "allow") { + const commandAuthorized = + (dmAccess.senderAccess.allowed && dmAccess.commandAccess.authorized) || + directBindingRecord != null; + if (dmAccess.senderAccess.decision === "allow") { return { commandAuthorized }; } if (directBindingRecord) { @@ -77,11 +76,8 @@ export async function resolveDiscordDmPreflightAccess(params: { return { commandAuthorized }; } - const allowMatchMeta = formatAllowlistMatchMeta( - dmAccess.allowMatch.allowed ? dmAccess.allowMatch : undefined, - ); await handleDiscordDmCommandDecision({ - dmAccess, + senderAccess: dmAccess.senderAccess, accountId: params.resolvedAccountId, sender: { id: params.author.id, @@ -90,7 +86,7 @@ export async function resolveDiscordDmPreflightAccess(params: { }, onPairingCreated: async (code) => { logVerbose( - `discord pairing request sender=${params.author.id} tag=${formatDiscordUserTag(params.author)} (${allowMatchMeta})`, + `discord pairing request sender=${params.author.id} tag=${formatDiscordUserTag(params.author)} reason=${dmAccess.senderAccess.reasonCode}`, ); try { const conversationRuntime = await loadConversationRuntime(); @@ -115,7 +111,7 @@ export async function resolveDiscordDmPreflightAccess(params: { }, onUnauthorized: async () => { logVerbose( - `Blocked unauthorized discord sender ${params.sender.id} (dmPolicy=${params.dmPolicy}, ${allowMatchMeta})`, + `Blocked unauthorized discord sender ${params.sender.id} (dmPolicy=${params.dmPolicy}, reason=${dmAccess.senderAccess.reasonCode})`, ); }, }); diff --git a/extensions/discord/src/monitor/message-handler.preflight.test.ts b/extensions/discord/src/monitor/message-handler.preflight.test.ts index c3cdb4ba7ab..1f6b7b84842 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.test.ts @@ -13,7 +13,8 @@ vi.mock("../pluralkit.js", () => ({ vi.mock("./preflight-audio.runtime.js", () => ({ transcribeFirstAudio: transcribeFirstAudioMock, })); -vi.mock("./dm-command-auth.js", () => ({ +vi.mock("./dm-command-auth.js", async (importOriginal) => ({ + ...(await importOriginal()), resolveDiscordDmCommandAccess: resolveDiscordDmCommandAccessMock, })); vi.mock("./dm-command-decision.js", () => ({ @@ -317,9 +318,14 @@ describe("preflightDiscordMessage", () => { transcribeFirstAudioMock.mockReset(); resolveDiscordDmCommandAccessMock.mockReset(); resolveDiscordDmCommandAccessMock.mockResolvedValue({ - commandAuthorized: true, - decision: "allow", - allowMatch: { allowed: true, matchedBy: "allowFrom", value: "123" }, + senderAccess: { + allowed: true, + decision: "allow", + reasonCode: "dm_policy_allowlisted", + }, + commandAccess: { + authorized: true, + }, }); handleDiscordDmCommandDecisionMock.mockReset(); handleDiscordDmCommandDecisionMock.mockResolvedValue(undefined); diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index cb21561a807..199974b3bf8 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -5,7 +5,6 @@ import { logInboundDrop, resolveInboundMentionDecision, } from "openclaw/plugin-sdk/channel-inbound"; -import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth-native"; import { hasControlCommand } from "openclaw/plugin-sdk/command-detection"; import { shouldHandleTextCommands } from "openclaw/plugin-sdk/command-surface"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; @@ -18,10 +17,10 @@ import { ChannelType, MessageType, type User } from "../internal/discord.js"; import { resolveDiscordGuildEntry, resolveDiscordMemberAccessState, - resolveDiscordOwnerAccess, resolveDiscordShouldRequireMention, } from "./allow-list.js"; import { resolveDiscordChannelInfoSafe, resolveDiscordChannelNameSafe } from "./channel-access.js"; +import { resolveDiscordTextCommandAccess } from "./dm-command-auth.js"; import { resolveDiscordSystemLocation } from "./format.js"; import { resolveDiscordDmPreflightAccess } from "./message-handler.dm-preflight.js"; import { hydrateDiscordMessageIfNeeded } from "./message-handler.hydration.js"; @@ -202,7 +201,6 @@ export async function preflightDiscordMessage( } const dmPolicy = params.dmPolicy; - const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; const resolvedAccountId = params.accountId ?? resolveDefaultDiscordAccountId(params.cfg); const allowNameMatching = isDangerousNameMatchingEnabled(params.discordConfig); let commandAuthorized = true; @@ -214,7 +212,6 @@ export async function preflightDiscordMessage( dmPolicy, resolvedAccountId, allowNameMatching, - useAccessGroups, }); if (isPreflightAborted(params.abortSignal)) { return null; @@ -480,28 +477,24 @@ export async function preflightDiscordMessage( const hasControlCommandInMessage = hasControlCommand(baseText, params.cfg); if (!isDirectMessage) { - const { ownerAllowList, ownerAllowed: ownerOk } = resolveDiscordOwnerAccess({ - allowFrom: params.allowFrom, + const commandAccess = await resolveDiscordTextCommandAccess({ + accountId: params.accountId, + cfg: params.cfg, + ownerAllowFrom: params.allowFrom, sender: { id: sender.id, name: sender.name, tag: sender.tag, }, + memberAccessConfigured: hasAccessRestrictions, + memberAllowed, allowNameMatching, - }); - const commandGate = resolveControlCommandGate({ - useAccessGroups, - authorizers: [ - { configured: ownerAllowList != null, allowed: ownerOk }, - { configured: hasAccessRestrictions, allowed: memberAllowed }, - ], - modeWhenAccessGroupsOff: "configured", allowTextCommands, hasControlCommand: hasControlCommandInMessage, }); - commandAuthorized = commandGate.commandAuthorized; + commandAuthorized = commandAccess.authorized; - if (commandGate.shouldBlock) { + if (commandAccess.shouldBlockControlCommand) { logInboundDrop({ log: logVerbose, channel: "discord", @@ -597,6 +590,7 @@ export async function preflightDiscordMessage( enqueueSystemEvent(systemText, { sessionKey: effectiveRoute.sessionKey, contextKey: `discord:system:${messageChannelId}:${message.id}`, + trusted: false, }); return null; } diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index a1779f93f46..2449aac7c34 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -1659,7 +1659,7 @@ describe("processDiscordMessage draft streaming", () => { expect(draftStream.update).toHaveBeenCalledWith("Shelling\n🛠️ Exec\n• done"); }); - it("keeps Discord progress labels as rolling lines", async () => { + it("keeps Discord progress lines below the configured label", async () => { const draftStream = createMockDraftStreamForTest(); dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { diff --git a/extensions/discord/src/monitor/monitor.test.ts b/extensions/discord/src/monitor/monitor.test.ts index bd27e152136..4fd96acf5d7 100644 --- a/extensions/discord/src/monitor/monitor.test.ts +++ b/extensions/discord/src/monitor/monitor.test.ts @@ -308,6 +308,16 @@ describe("discord component interactions", () => { expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true }); expect(lastDispatchCtx?.BodyForAgent).toBe('Clicked "Approve".'); expect(dispatchReplyMock).toHaveBeenCalledTimes(1); + expect(dispatchReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + dispatcherOptions: expect.objectContaining({ + responsePrefixContextProvider: expect.any(Function), + }), + replyOptions: expect.objectContaining({ + onModelSelected: expect.any(Function), + }), + }), + ); expect(resolveDiscordComponentEntry({ id: "btn_1" })).toBeNull(); }); diff --git a/extensions/discord/src/monitor/native-command-arg-ui.ts b/extensions/discord/src/monitor/native-command-arg-ui.ts index 551bb41787c..fa275a2d90c 100644 --- a/extensions/discord/src/monitor/native-command-arg-ui.ts +++ b/extensions/discord/src/monitor/native-command-arg-ui.ts @@ -9,7 +9,7 @@ import { type CommandArgDefinition, type CommandArgValues, type CommandArgs, -} from "openclaw/plugin-sdk/command-auth"; +} from "openclaw/plugin-sdk/command-auth-native"; import { chunkItems } from "openclaw/plugin-sdk/text-runtime"; import { Button, diff --git a/extensions/discord/src/monitor/native-command-auth.ts b/extensions/discord/src/monitor/native-command-auth.ts index 1ed9bd185d1..d24e0c2570b 100644 --- a/extensions/discord/src/monitor/native-command-auth.ts +++ b/extensions/discord/src/monitor/native-command-auth.ts @@ -63,8 +63,9 @@ export function resolveDiscordNativeCommandAllowlistAccess(params: { return { configured: true, allowed: match.allowed } as const; } -export function resolveDiscordGuildNativeCommandAuthorized(params: { +export async function resolveDiscordGuildNativeCommandAuthorized(params: { cfg: OpenClawConfig; + accountId: string; discordConfig: DiscordConfig; useAccessGroups: boolean; commandsAllowFromAccess: ReturnType; @@ -270,11 +271,10 @@ export async function resolveDiscordNativeAutocompleteAuthorized(params: { tag: sender.tag, }, allowNameMatching, - useAccessGroups, cfg, rest: interaction.client.rest, }); - if (dmAccess.decision !== "allow") { + if (dmAccess.senderAccess.decision !== "allow") { return false; } } @@ -292,6 +292,7 @@ export async function resolveDiscordNativeAutocompleteAuthorized(params: { if (!isDirectMessage) { return resolveDiscordGuildNativeCommandAuthorized({ cfg, + accountId, discordConfig, useAccessGroups, commandsAllowFromAccess, diff --git a/extensions/discord/src/monitor/native-command-context.ts b/extensions/discord/src/monitor/native-command-context.ts index f716348e568..0b7e1d3f15f 100644 --- a/extensions/discord/src/monitor/native-command-context.ts +++ b/extensions/discord/src/monitor/native-command-context.ts @@ -1,4 +1,4 @@ -import type { CommandArgs } from "openclaw/plugin-sdk/command-auth"; +import type { CommandArgs } from "openclaw/plugin-sdk/command-auth-native"; import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-dispatch-runtime"; import { resolveDiscordConversationIdentity } from "../conversation-identity.js"; import { type DiscordChannelConfigResolved, type DiscordGuildEntryResolved } from "./allow-list.js"; diff --git a/extensions/discord/src/monitor/native-command-dispatch.ts b/extensions/discord/src/monitor/native-command-dispatch.ts index cd61d397549..6a463441ce1 100644 --- a/extensions/discord/src/monitor/native-command-dispatch.ts +++ b/extensions/discord/src/monitor/native-command-dispatch.ts @@ -1,4 +1,4 @@ -import type { ChatCommandDefinition, CommandArgs } from "openclaw/plugin-sdk/command-auth"; +import type { ChatCommandDefinition, CommandArgs } from "openclaw/plugin-sdk/command-auth-native"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; import type { diff --git a/extensions/discord/src/monitor/native-command-model-picker-apply.ts b/extensions/discord/src/monitor/native-command-model-picker-apply.ts index 0175b9e2625..c7c6e55399a 100644 --- a/extensions/discord/src/monitor/native-command-model-picker-apply.ts +++ b/extensions/discord/src/monitor/native-command-model-picker-apply.ts @@ -1,4 +1,4 @@ -import type { ChatCommandDefinition, CommandArgs } from "openclaw/plugin-sdk/command-auth"; +import type { ChatCommandDefinition, CommandArgs } from "openclaw/plugin-sdk/command-auth-native"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { applyModelOverrideToSessionEntry } from "openclaw/plugin-sdk/model-session-runtime"; import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; diff --git a/extensions/discord/src/monitor/native-command-model-picker-interaction.ts b/extensions/discord/src/monitor/native-command-model-picker-interaction.ts index 196430a8cbd..13a02f516ed 100644 --- a/extensions/discord/src/monitor/native-command-model-picker-interaction.ts +++ b/extensions/discord/src/monitor/native-command-model-picker-interaction.ts @@ -4,7 +4,7 @@ import { listChatCommands, type ChatCommandDefinition, type CommandArgs, -} from "openclaw/plugin-sdk/command-auth"; +} from "openclaw/plugin-sdk/command-auth-native"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { Button, diff --git a/extensions/discord/src/monitor/native-command-model-picker-ui.ts b/extensions/discord/src/monitor/native-command-model-picker-ui.ts index e991dbf60e3..33aef27dc7a 100644 --- a/extensions/discord/src/monitor/native-command-model-picker-ui.ts +++ b/extensions/discord/src/monitor/native-command-model-picker-ui.ts @@ -4,7 +4,7 @@ import { serializeCommandArgs, type ChatCommandDefinition, type CommandArgs, -} from "openclaw/plugin-sdk/command-auth"; +} from "openclaw/plugin-sdk/command-auth-native"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index c0c72097a77..649690f3843 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -435,14 +435,13 @@ async function dispatchDiscordCommandInteraction(params: { tag: sender.tag, }, allowNameMatching, - useAccessGroups, cfg, rest: interaction.client.rest, }); - commandAuthorized = dmAccess.commandAuthorized; - if (dmAccess.decision !== "allow") { + commandAuthorized = dmAccess.senderAccess.allowed ? dmAccess.commandAccess.authorized : false; + if (dmAccess.senderAccess.decision !== "allow") { await handleDiscordDmCommandDecision({ - dmAccess, + senderAccess: dmAccess.senderAccess, accountId, sender: { id: user.id, @@ -483,8 +482,9 @@ async function dispatchDiscordCommandInteraction(params: { return { accepted: false }; } if (!isDirectMessage) { - commandAuthorized = resolveDiscordGuildNativeCommandAuthorized({ + commandAuthorized = await resolveDiscordGuildNativeCommandAuthorized({ cfg, + accountId, discordConfig, useAccessGroups, commandsAllowFromAccess, diff --git a/extensions/discord/src/monitor/provider.commands.ts b/extensions/discord/src/monitor/provider.commands.ts index 7d532cc55bb..433cbd4c62d 100644 --- a/extensions/discord/src/monitor/provider.commands.ts +++ b/extensions/discord/src/monitor/provider.commands.ts @@ -2,7 +2,7 @@ import { listNativeCommandSpecsForConfig, listSkillCommandsForAgents, type NativeCommandSpec, -} from "openclaw/plugin-sdk/command-auth"; +} from "openclaw/plugin-sdk/command-auth-native"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { danger, warn, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; diff --git a/extensions/discord/src/monitor/provider.interactions.ts b/extensions/discord/src/monitor/provider.interactions.ts index c9b92ef320b..c49f42e2827 100644 --- a/extensions/discord/src/monitor/provider.interactions.ts +++ b/extensions/discord/src/monitor/provider.interactions.ts @@ -1,7 +1,7 @@ import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plugin-sdk/approval-handler-adapter-runtime"; import type { ChannelRuntimeSurface } from "openclaw/plugin-sdk/channel-contract"; import { registerChannelRuntimeContext } from "openclaw/plugin-sdk/channel-runtime-context"; -import type { NativeCommandSpec } from "openclaw/plugin-sdk/command-auth"; +import type { NativeCommandSpec } from "openclaw/plugin-sdk/command-auth-native"; import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { isDiscordExecApprovalClientEnabled } from "../exec-approvals.js"; diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index 5ca2c83cce2..30cd0ad7bca 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -2,7 +2,7 @@ import type { ChannelRuntimeSurface } from "openclaw/plugin-sdk/channel-contract import { listNativeCommandSpecsForConfig, listSkillCommandsForAgents, -} from "openclaw/plugin-sdk/command-auth"; +} from "openclaw/plugin-sdk/command-auth-native"; import type { OpenClawConfig, ReplyToMode } from "openclaw/plugin-sdk/config-types"; import { createConnectedChannelStatusPatch } from "openclaw/plugin-sdk/gateway-runtime"; import { diff --git a/extensions/discord/src/test-support/component-runtime.ts b/extensions/discord/src/test-support/component-runtime.ts index 26816f10729..862ab8b926a 100644 --- a/extensions/discord/src/test-support/component-runtime.ts +++ b/extensions/discord/src/test-support/component-runtime.ts @@ -53,13 +53,17 @@ const resolvePluginConversationBindingApprovalMock: AsyncUnknownMock = const buildPluginBindingResolvedTextMock: UnknownMock = runtimeMocks.buildPluginBindingResolvedTextMock; -async function readStoreAllowFromForDmPolicy(params: { +async function readChannelIngressStoreAllowFromForDmPolicy(params: { provider: string; accountId: string; dmPolicy?: string | null; shouldRead?: boolean | null; }) { - if (params.shouldRead === false || params.dmPolicy === "allowlist") { + if ( + params.shouldRead === false || + params.dmPolicy === "allowlist" || + params.dmPolicy === "open" + ) { return []; } return await readAllowFromStoreMock(params.provider, params.accountId); @@ -67,7 +71,7 @@ async function readStoreAllowFromForDmPolicy(params: { vi.mock("../monitor/agent-components-helpers.runtime.js", () => { return { - readStoreAllowFromForDmPolicy, + readChannelIngressStoreAllowFromForDmPolicy, resolvePinnedMainDmOwnerFromAllowlist, upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), }; diff --git a/extensions/discord/src/voice/access.ts b/extensions/discord/src/voice/access.ts index fa895b5685b..4c0164a276c 100644 --- a/extensions/discord/src/voice/access.ts +++ b/extensions/discord/src/voice/access.ts @@ -15,6 +15,7 @@ import { export async function authorizeDiscordVoiceIngress(params: { cfg: OpenClawConfig; discordConfig: DiscordAccountConfig; + accountId?: string; groupPolicy?: "open" | "disabled" | "allowlist"; useAccessGroups?: boolean; guild?: Guild | Guild | null; @@ -114,11 +115,12 @@ export async function authorizeDiscordVoiceIngress(params: { ] : [{ configured: hasAccessRestrictions, allowed: memberAllowed }]; - return resolveCommandAuthorizedFromAuthorizers({ + const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({ useAccessGroups, authorizers, modeWhenAccessGroupsOff: "configured", - }) + }); + return commandAuthorized ? { ok: true, channelConfig } : { ok: false, message: "You are not authorized to use this command." }; } diff --git a/extensions/discord/src/voice/command.ts b/extensions/discord/src/voice/command.ts index 716b7a276ba..58116df6bd7 100644 --- a/extensions/discord/src/voice/command.ts +++ b/extensions/discord/src/voice/command.ts @@ -75,6 +75,7 @@ async function authorizeVoiceCommand( const access = await authorizeDiscordVoiceIngress({ cfg: params.cfg, discordConfig: params.discordConfig, + accountId: params.accountId, groupPolicy: params.groupPolicy, useAccessGroups: params.useAccessGroups, guild: interaction.guild, diff --git a/extensions/discord/src/voice/manager.e2e.test.ts b/extensions/discord/src/voice/manager.e2e.test.ts index dab1d7b2222..8fadc1cb226 100644 --- a/extensions/discord/src/voice/manager.e2e.test.ts +++ b/extensions/discord/src/voice/manager.e2e.test.ts @@ -1178,6 +1178,7 @@ describe("DiscordVoiceManager", () => { const bridgeParams = createRealtimeVoiceBridgeSessionMock.mock.calls.at(-1)?.[0] as | { onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void; + onEvent?: (event: { direction: "server"; type: string }) => void; } | undefined; @@ -1424,6 +1425,7 @@ describe("DiscordVoiceManager", () => { session: typeof realtimeSessionMock, ) => void; onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void; + onEvent?: (event: { direction: "server"; type: string }) => void; } | undefined; @@ -1499,6 +1501,7 @@ describe("DiscordVoiceManager", () => { session: typeof realtimeSessionMock, ) => void; onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void; + onEvent?: (event: { direction: "server"; type: string }) => void; } | undefined; @@ -1573,6 +1576,7 @@ describe("DiscordVoiceManager", () => { session: typeof realtimeSessionMock, ) => void; onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void; + onEvent?: (event: { direction: "server"; type: string }) => void; } | undefined; diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 525c5dc67db..3a9a0508c32 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -1080,10 +1080,7 @@ describe("handleFeishuMessage command authorization", () => { await dispatchMessage({ cfg, event }); - expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({ - useAccessGroups: true, - authorizers: [{ configured: false, allowed: false }], - }); + expect(mockResolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled(); expect(mockFinalizeInboundContext).toHaveBeenCalledWith( expect.objectContaining({ ChatType: "group", @@ -1164,10 +1161,7 @@ describe("handleFeishuMessage command authorization", () => { await dispatchMessage({ cfg, event }); - expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({ - useAccessGroups: true, - authorizers: [{ configured: true, allowed: true }], - }); + expect(mockResolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled(); expect(mockFinalizeInboundContext).toHaveBeenCalledWith( expect.objectContaining({ ChatType: "group", diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index ab3155a8322..b3fd9ab34a5 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -18,7 +18,6 @@ import { resolveOpenProviderRuntimeGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk/runtime-group-policy"; -import { resolveOpenDmAllowlistAccess } from "openclaw/plugin-sdk/security-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { resolveFeishuRuntimeAccount } from "./accounts.js"; import { @@ -33,7 +32,6 @@ import { import { buildAgentMediaPayload, evaluateSupplementalContextVisibility, - filterSupplementalContextItems, normalizeAgentId, resolveChannelContextVisibilityMode, } from "./bot-runtime-api.js"; @@ -47,9 +45,10 @@ import { maybeCreateDynamicAgent } from "./dynamic-agent.js"; import { extractMentionTargets, isMentionForwardRequest } from "./mention.js"; import { hasExplicitFeishuGroupConfig, - isFeishuGroupAllowed, - resolveFeishuAllowlistMatch, + resolveFeishuDmIngressAccess, resolveFeishuGroupConfig, + resolveFeishuGroupConversationIngressAccess, + resolveFeishuGroupSenderActivationIngressAccess, resolveFeishuReplyPolicy, } from "./policy.js"; import { resolveFeishuReasoningPreviewEnabled } from "./reasoning-preview.js"; @@ -353,44 +352,33 @@ export function buildFeishuAgentBody(params: { return messageBody; } -function isFetchedGroupContextSenderAllowed(params: { - isGroup: boolean; - allowFrom: Array; - senderId?: string; - senderType?: string; -}): boolean { - if (!params.isGroup || params.allowFrom.length === 0) { - return true; - } - if (params.senderType === "app") { - return true; - } - const senderId = params.senderId?.trim(); - const senderAllowed = - !!senderId && - isFeishuGroupAllowed({ - groupPolicy: "allowlist", - allowFrom: params.allowFrom, - senderId, - senderName: undefined, - }); - return senderAllowed; -} - -function shouldIncludeFetchedGroupContextMessage(params: { +async function shouldIncludeFetchedGroupContextMessage(params: { + cfg: ClawdbotConfig; + accountId: string; + chatId: string; isGroup: boolean; allowFrom: Array; mode: "all" | "allowlist" | "allowlist_quote"; kind: "quote" | "thread" | "history"; senderId?: string; senderType?: string; -}): boolean { - const senderAllowed = isFetchedGroupContextSenderAllowed({ - isGroup: params.isGroup, - allowFrom: params.allowFrom, - senderId: params.senderId, - senderType: params.senderType, - }); +}): Promise { + let senderAllowed = + !params.isGroup || params.allowFrom.length === 0 || params.senderType === "app"; + const senderId = params.senderId?.trim(); + if (!senderAllowed && senderId) { + const access = await resolveFeishuGroupSenderActivationIngressAccess({ + cfg: params.cfg, + accountId: params.accountId, + chatId: params.chatId, + allowFrom: params.allowFrom, + senderOpenId: senderId, + senderUserId: senderId, + requireMention: false, + mentionedBot: true, + }); + senderAllowed = access.senderAccess.decision === "allow"; + } return evaluateSupplementalContextVisibility({ mode: params.mode, kind: params.kind, @@ -398,29 +386,38 @@ function shouldIncludeFetchedGroupContextMessage(params: { }).include; } -function filterFetchedGroupContextMessages< +async function filterFetchedGroupContextMessages< T extends Pick, >( messages: readonly T[], params: { + cfg: ClawdbotConfig; + accountId: string; + chatId: string; isGroup: boolean; allowFrom: Array; mode: "all" | "allowlist" | "allowlist_quote"; kind: "quote" | "thread" | "history"; }, -): T[] { - return filterSupplementalContextItems({ - items: messages, - mode: params.mode, - kind: params.kind, - isSenderAllowed: (message) => - isFetchedGroupContextSenderAllowed({ +): Promise { + const results: Array = await Promise.all( + messages.map(async (message) => + (await shouldIncludeFetchedGroupContextMessage({ + cfg: params.cfg, + accountId: params.accountId, + chatId: params.chatId, isGroup: params.isGroup, allowFrom: params.allowFrom, + mode: params.mode, + kind: params.kind, senderId: message.senderId, senderType: message.senderType, - }), - }).items; + })) + ? message + : undefined, + ), + ); + return results.filter((message): message is T => message !== undefined); } export async function handleFeishuMessage(params: { @@ -595,7 +592,6 @@ export async function handleFeishuMessage(params: { const groupHistoryKey = isGroup ? (groupSession?.peerId ?? ctx.chatId) : undefined; const dmPolicy = feishuCfg?.dmPolicy ?? "pairing"; const configAllowFrom = feishuCfg?.allowFrom ?? []; - const useAccessGroups = cfg.commands?.useAccessGroups !== false; const rawBroadcastAgents = isGroup ? resolveBroadcastAgents(cfg, ctx.chatId) : null; const broadcastAgents = rawBroadcastAgents ? [...new Set(rawBroadcastAgents.map((id) => normalizeAgentId(id)))] @@ -639,39 +635,22 @@ export async function handleFeishuMessage(params: { groupId: ctx.chatId, }); - // Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs) - const groupAllowed = - groupPolicy !== "disabled" && - (groupExplicitlyConfigured || - isFeishuGroupAllowed({ - groupPolicy, - allowFrom: groupAllowFrom, - senderId: ctx.chatId, // Check group ID, not sender ID - senderName: undefined, - })); + const groupIngress = await resolveFeishuGroupConversationIngressAccess({ + cfg, + accountId: account.accountId, + chatId: ctx.chatId, + groupPolicy, + groupAllowFrom, + groupExplicitlyConfigured, + }); - if (!groupAllowed) { + if (groupIngress.ingress.admission !== "dispatch") { log( `feishu[${account.accountId}]: group ${ctx.chatId} not in groupAllowFrom (groupPolicy=${groupPolicy})`, ); return; } - // Sender-level allowlist: per-group allowFrom takes precedence, then global groupSenderAllowFrom - if (effectiveGroupSenderAllowFrom.length > 0) { - const senderAllowed = isFeishuGroupAllowed({ - groupPolicy: "allowlist", - allowFrom: effectiveGroupSenderAllowFrom, - senderId: ctx.senderOpenId, - senderIds: [senderUserId], - senderName: ctx.senderName, - }); - if (!senderAllowed) { - log(`feishu: sender ${ctx.senderOpenId} not in group ${ctx.chatId} sender allowlist`); - return; - } - } - ({ requireMention } = resolveFeishuReplyPolicy({ isDirectMessage: false, cfg, @@ -680,7 +659,21 @@ export async function handleFeishuMessage(params: { groupPolicy, })); - if (requireMention && !ctx.mentionedBot) { + const groupSenderActivationIngress = await resolveFeishuGroupSenderActivationIngressAccess({ + cfg, + accountId: account.accountId, + chatId: ctx.chatId, + allowFrom: effectiveGroupSenderAllowFrom, + senderOpenId: ctx.senderOpenId, + senderUserId, + requireMention, + mentionedBot: ctx.mentionedBot, + }); + if (groupSenderActivationIngress.senderAccess.decision !== "allow") { + log(`feishu: sender ${ctx.senderOpenId} not in group ${ctx.chatId} sender allowlist`); + return; + } + if (groupSenderActivationIngress.ingress.admission !== "dispatch") { log(`feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot`); // Record to pending history for non-broadcast groups only. For broadcast groups, // the mentioned handler's broadcast dispatch writes the turn directly into all @@ -715,34 +708,22 @@ export async function handleFeishuMessage(params: { commandProbeBody, cfg, ); - const storeAllowFrom = - !isGroup && dmPolicy !== "allowlist" && dmPolicy !== "open" - ? await pairing.readAllowFromStore().catch(() => []) - : []; - const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom]; - const dmAllowed = resolveFeishuAllowlistMatch({ - allowFrom: effectiveDmAllowFrom, - senderId: ctx.senderOpenId, - senderIds: [senderUserId], - senderName: ctx.senderName, - }).allowed; - - const dmAccessAllowed = - dmPolicy === "open" - ? resolveOpenDmAllowlistAccess({ - effectiveAllowFrom: effectiveDmAllowFrom, - isSenderAllowed: (allowFrom) => - resolveFeishuAllowlistMatch({ - allowFrom, - senderId: ctx.senderOpenId, - senderIds: [senderUserId], - senderName: ctx.senderName, - }).allowed, - }).decision === "allow" - : dmAllowed; - - if (isDirect && !dmAccessAllowed) { - if (dmPolicy === "pairing") { + const dmIngress = isDirect + ? await resolveFeishuDmIngressAccess({ + cfg, + accountId: account.accountId, + dmPolicy, + allowFrom: configAllowFrom, + readAllowFromStore: pairing.readAllowFromStore, + senderOpenId: ctx.senderOpenId, + senderUserId, + conversationId: ctx.senderOpenId, + mayPair: true, + ...(shouldComputeCommandAuthorized ? { command: { hasControlCommand: true } } : {}), + }) + : null; + if (isDirect && dmIngress?.ingress.admission !== "dispatch") { + if (dmIngress?.ingress.admission === "pairing-required") { await pairing.issueChallenge({ senderId: ctx.senderOpenId, senderIdLine: `Your Feishu user id: ${ctx.senderOpenId}`, @@ -774,13 +755,7 @@ export async function handleFeishuMessage(params: { const commandAllowFrom = isGroup ? (groupConfig?.allowFrom ?? configAllowFrom) - : effectiveDmAllowFrom; - const senderAllowedForCommands = resolveFeishuAllowlistMatch({ - allowFrom: commandAllowFrom, - senderId: ctx.senderOpenId, - senderIds: [senderUserId], - senderName: ctx.senderName, - }).allowed; + : (dmIngress?.senderAccess.effectiveAllowFrom ?? configAllowFrom); // In group chats, the session is scoped to the group, but the *speaker* is the sender. // Using a group-scoped From causes the agent to treat different users as the same person. @@ -982,12 +957,36 @@ export async function handleFeishuMessage(params: { ? shouldComputeCommandAuthorized : core.channel.commands.shouldComputeCommandAuthorized(effectiveCommandProbeBody, cfg); const commandAuthorized = shouldComputeEffectiveCommandAuthorized - ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ - useAccessGroups, - authorizers: [ - { configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands }, - ], - }) + ? isDirect && audioTranscript === undefined && dmIngress + ? dmIngress.commandAccess.authorized + : isGroup + ? ( + await resolveFeishuGroupSenderActivationIngressAccess({ + cfg, + accountId: account.accountId, + chatId: ctx.chatId, + allowFrom: commandAllowFrom, + senderOpenId: ctx.senderOpenId, + senderUserId, + requireMention: false, + mentionedBot: true, + command: { hasControlCommand: true }, + }) + ).commandAccess.authorized + : ( + await resolveFeishuDmIngressAccess({ + cfg, + accountId: account.accountId, + dmPolicy, + allowFrom: configAllowFrom, + readAllowFromStore: pairing.readAllowFromStore, + senderOpenId: ctx.senderOpenId, + senderUserId, + conversationId: ctx.senderOpenId, + mayPair: false, + command: { hasControlCommand: true }, + }) + ).commandAccess.authorized : undefined; // Fetch quoted/replied message content if parentId exists @@ -1002,14 +1001,17 @@ export async function handleFeishuMessage(params: { }); if ( quotedMessageInfo && - shouldIncludeFetchedGroupContextMessage({ + (await shouldIncludeFetchedGroupContextMessage({ + cfg, + accountId: account.accountId, + chatId: ctx.chatId, isGroup, allowFrom: effectiveGroupSenderAllowFrom, mode: contextVisibilityMode, kind: "quote", senderId: quotedMessageInfo.senderId, senderType: quotedMessageInfo.senderType, - }) + })) ) { quotedContent = quotedMessageInfo.content; log( @@ -1115,14 +1117,17 @@ export async function handleFeishuMessage(params: { rootMessageThreadId = rootMessageInfo?.threadId; if ( rootMessageInfo && - !shouldIncludeFetchedGroupContextMessage({ + !(await shouldIncludeFetchedGroupContextMessage({ + cfg, + accountId: account.accountId, + chatId: ctx.chatId, isGroup, allowFrom: effectiveGroupSenderAllowFrom, mode: contextVisibilityMode, kind: "thread", senderId: rootMessageInfo.senderId, senderType: rootMessageInfo.senderType, - }) + })) ) { log( `feishu[${account.accountId}]: skipped thread starter from sender ${rootMessageInfo.senderId ?? "unknown"} (mode=${contextVisibilityMode})`, @@ -1208,7 +1213,10 @@ export async function handleFeishuMessage(params: { .map((id) => id?.trim()) .filter((id): id is string => id !== undefined && id.length > 0), ); - const allowlistedMessages = filterFetchedGroupContextMessages(threadMessages, { + const allowlistedMessages = await filterFetchedGroupContextMessages(threadMessages, { + cfg, + accountId: account.accountId, + chatId: ctx.chatId, isGroup, allowFrom: effectiveGroupSenderAllowFrom, mode: contextVisibilityMode, diff --git a/extensions/feishu/src/comment-handler.ts b/extensions/feishu/src/comment-handler.ts index ae6e01d2a3b..111c606e4b1 100644 --- a/extensions/feishu/src/comment-handler.ts +++ b/extensions/feishu/src/comment-handler.ts @@ -1,6 +1,5 @@ import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-writes"; import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; -import { resolveOpenDmAllowlistAccess } from "openclaw/plugin-sdk/security-runtime"; import { resolveFeishuRuntimeAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { createFeishuCommentReplyDispatcher } from "./comment-dispatcher.js"; @@ -16,7 +15,7 @@ import { resolveDriveCommentEventTurn, type FeishuDriveCommentNoticeEvent, } from "./monitor.comment.js"; -import { resolveFeishuAllowlistMatch } from "./policy.js"; +import { resolveFeishuDmIngressAccess } from "./policy.js"; import { getFeishuRuntime } from "./runtime.js"; import type { DynamicAgentCreationConfig } from "./types.js"; @@ -88,30 +87,19 @@ export async function handleFeishuCommentEvent( channel: "feishu", accountId: account.accountId, }); - const storeAllowFrom = - dmPolicy !== "allowlist" && dmPolicy !== "open" - ? await pairing.readAllowFromStore().catch(() => []) - : []; - const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom]; - const senderAllowed = resolveFeishuAllowlistMatch({ - allowFrom: effectiveDmAllowFrom, - senderId: turn.senderId, - senderIds: [turn.senderUserId], - }).allowed; - const dmAccessAllowed = - dmPolicy === "open" - ? resolveOpenDmAllowlistAccess({ - effectiveAllowFrom: effectiveDmAllowFrom, - isSenderAllowed: (allowFrom) => - resolveFeishuAllowlistMatch({ - allowFrom, - senderId: turn.senderId, - senderIds: [turn.senderUserId], - }).allowed, - }).decision === "allow" - : senderAllowed; - if (!dmAccessAllowed) { - if (dmPolicy === "pairing") { + const dmIngress = await resolveFeishuDmIngressAccess({ + cfg: params.cfg, + accountId: account.accountId, + dmPolicy, + allowFrom: configAllowFrom, + readAllowFromStore: pairing.readAllowFromStore, + senderOpenId: turn.senderId, + senderUserId: turn.senderUserId, + conversationId: turn.senderId, + mayPair: true, + }); + if (dmIngress.ingress.admission !== "dispatch") { + if (dmIngress.ingress.admission === "pairing-required") { const client = createFeishuClient(account); await pairing.issueChallenge({ senderId: turn.senderId, diff --git a/extensions/feishu/src/policy.test.ts b/extensions/feishu/src/policy.test.ts index c17563518e1..ff4579d0bb9 100644 --- a/extensions/feishu/src/policy.test.ts +++ b/extensions/feishu/src/policy.test.ts @@ -3,9 +3,8 @@ import { describe, expect, it } from "vitest"; import { FeishuConfigSchema } from "./config-schema.js"; import { hasExplicitFeishuGroupConfig, - isFeishuGroupAllowed, - resolveFeishuAllowlistMatch, resolveFeishuGroupConfig, + resolveFeishuGroupSenderActivationIngressAccess, resolveFeishuReplyPolicy, } from "./policy.js"; import type { FeishuConfig } from "./types.js"; @@ -165,170 +164,60 @@ describe("hasExplicitFeishuGroupConfig", () => { }); }); -describe("resolveFeishuAllowlistMatch", () => { - it("allows wildcard", () => { - expect( - resolveFeishuAllowlistMatch({ - allowFrom: ["*"], - senderId: "ou-attacker", - }), - ).toEqual({ allowed: true, matchKey: "*", matchSource: "wildcard" }); - }); +describe("resolveFeishuGroupSenderActivationIngressAccess", () => { + async function senderDecision(params: { + allowFrom: Array; + senderOpenId: string; + senderUserId?: string; + }) { + return ( + await resolveFeishuGroupSenderActivationIngressAccess({ + cfg: createCfg({}), + accountId: "default", + chatId: "oc_group", + allowFrom: params.allowFrom, + senderOpenId: params.senderOpenId, + senderUserId: params.senderUserId, + requireMention: false, + mentionedBot: true, + }) + ).senderAccess.decision; + } - it("allows provider-prefixed wildcard entries", () => { - expect( - resolveFeishuAllowlistMatch({ + it("allows provider-prefixed wildcard entries", async () => { + await expect( + senderDecision({ allowFrom: ["feishu:*", "lark:*"], - senderId: "ou_anyone", + senderOpenId: "ou_anyone", }), - ).toEqual({ allowed: true, matchKey: "*", matchSource: "wildcard" }); + ).resolves.toBe("allow"); }); - it("treats typed wildcard aliases as bare wildcards", () => { - for (const wildcard of [ - "chat:*", - "group:*", - "channel:*", - "user:*", - "dm:*", - "open_id:*", - "feishu:user:*", - ]) { - expect( - resolveFeishuAllowlistMatch({ - allowFrom: [wildcard], - senderId: "ou_anyone", - }), - ).toEqual({ allowed: true, matchKey: "*", matchSource: "wildcard" }); - } - }); - - it("matches normalized ID entries", () => { - expect( - resolveFeishuAllowlistMatch({ - allowFrom: ["feishu:user:ou_ALLOWED"], - senderId: "ou_ALLOWED", - }), - ).toEqual({ allowed: true, matchKey: "user:ou_ALLOWED", matchSource: "id" }); - }); - - it("accepts repeated provider prefixes for legacy allowlist entries", () => { - expect( - resolveFeishuAllowlistMatch({ + it("matches normalized immutable user ID entries", async () => { + await expect( + senderDecision({ allowFrom: ["feishu:feishu:user:ou_ALLOWED"], - senderId: "ou_ALLOWED", + senderOpenId: "ou_ALLOWED", }), - ).toEqual({ allowed: true, matchKey: "user:ou_ALLOWED", matchSource: "id" }); + ).resolves.toBe("allow"); }); - it("does not fold opaque IDs to lowercase", () => { - expect( - resolveFeishuAllowlistMatch({ - allowFrom: ["user:OU_ALLOWED"], - senderId: "ou_ALLOWED", - }), - ).toEqual({ allowed: false }); - }); - - it("keeps user and chat allowlist namespaces distinct", () => { - expect( - resolveFeishuAllowlistMatch({ + it("keeps user and chat allowlist namespaces distinct", async () => { + await expect( + senderDecision({ allowFrom: ["user:oc_group_123"], - senderId: "oc_group_123", + senderOpenId: "oc_group_123", }), - ).toEqual({ allowed: false }); + ).resolves.toBe("block"); }); - it("supports user_id as an additional immutable sender candidate", () => { - expect( - resolveFeishuAllowlistMatch({ + it("supports user_id as an additional immutable sender candidate", async () => { + await expect( + senderDecision({ allowFrom: ["on_user_123"], - senderId: "ou_other", - senderIds: ["on_user_123"], + senderOpenId: "ou_other", + senderUserId: "on_user_123", }), - ).toEqual({ allowed: true, matchKey: "user:on_user_123", matchSource: "id" }); - }); - - it("auto-detects bare open_id entries as user allowlist matches", () => { - expect( - resolveFeishuAllowlistMatch({ - allowFrom: ["ou_BARE"], - senderId: "ou_BARE", - }), - ).toEqual({ allowed: true, matchKey: "user:ou_BARE", matchSource: "id" }); - }); - - it("auto-detects bare chat_id entries as chat allowlist matches", () => { - expect( - resolveFeishuAllowlistMatch({ - allowFrom: ["oc_group_123"], - senderId: "oc_group_123", - }), - ).toEqual({ allowed: true, matchKey: "chat:oc_group_123", matchSource: "id" }); - }); - - it("does not authorize based on display-name collision", () => { - const victimOpenId = "ou_4f4ec5aa111122223333444455556666"; - - expect( - resolveFeishuAllowlistMatch({ - allowFrom: [victimOpenId], - senderId: "ou_attacker_real_open_id", - senderIds: ["on_attacker_user_id"], - senderName: victimOpenId, - }), - ).toEqual({ allowed: false }); - }); -}); - -describe("isFeishuGroupAllowed", () => { - it("matches group IDs with chat: prefix", () => { - expect( - isFeishuGroupAllowed({ - groupPolicy: "allowlist", - allowFrom: ["chat:oc_group_123"], - senderId: "oc_group_123", - }), - ).toBe(true); - }); - - it("allows group when groupPolicy is 'open'", () => { - expect( - isFeishuGroupAllowed({ - groupPolicy: "open", - allowFrom: [], - senderId: "oc_group_999", - }), - ).toBe(true); - }); - - it("treats 'allowall' as equivalent to 'open'", () => { - expect( - isFeishuGroupAllowed({ - groupPolicy: "allowall", - allowFrom: [], - senderId: "oc_group_999", - }), - ).toBe(true); - }); - - it("rejects group when groupPolicy is 'disabled'", () => { - expect( - isFeishuGroupAllowed({ - groupPolicy: "disabled", - allowFrom: ["oc_group_999"], - senderId: "oc_group_999", - }), - ).toBe(false); - }); - - it("rejects group when groupPolicy is 'allowlist' and allowFrom is empty", () => { - expect( - isFeishuGroupAllowed({ - groupPolicy: "allowlist", - allowFrom: [], - senderId: "oc_group_999", - }), - ).toBe(false); + ).resolves.toBe("allow"); }); }); diff --git a/extensions/feishu/src/policy.ts b/extensions/feishu/src/policy.ts index b92e95c2bb4..19ca39893f3 100644 --- a/extensions/feishu/src/policy.ts +++ b/extensions/feishu/src/policy.ts @@ -2,38 +2,42 @@ import { normalizeAccountId, resolveMergedAccountConfig, } from "openclaw/plugin-sdk/account-resolution"; +import { + createChannelIngressResolver, + defineStableChannelIngressIdentity, + type ChannelIngressIdentitySubjectInput, + type ResolveChannelMessageIngressParams, +} from "openclaw/plugin-sdk/channel-ingress-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; -import { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime"; -import type { AllowlistMatch, ChannelGroupContext } from "../runtime-api.js"; +import type { ChannelGroupContext } from "../runtime-api.js"; import { detectIdType } from "./targets.js"; import type { FeishuConfig } from "./types.js"; -type FeishuAllowlistMatch = AllowlistMatch<"wildcard" | "id">; +type FeishuDmPolicy = "open" | "pairing" | "allowlist" | "disabled"; +type FeishuGroupPolicy = "open" | "allowlist" | "disabled" | "allowall"; +type NormalizedFeishuGroupPolicy = Exclude; const FEISHU_PROVIDER_PREFIX_RE = /^(feishu|lark):/i; - -function stripRepeatedFeishuProviderPrefixes(raw: string): string { - let normalized = raw.trim(); - while (FEISHU_PROVIDER_PREFIX_RE.test(normalized)) { - normalized = normalized.replace(FEISHU_PROVIDER_PREFIX_RE, "").trim(); - } - return normalized; -} - -function canonicalizeFeishuAllowlistKey(params: { kind: "chat" | "user"; value: string }): string { - const value = params.value.trim(); - if (!value) { - return ""; - } - // A typed wildcard (`chat:*`, `user:*`, `open_id:*`, `dm:*`, `group:*`, - // `channel:*`) collapses to the bare wildcard so it keeps matching across - // both kinds, preserving the prior `normalizeFeishuTarget`-based behavior. - if (value === "*") { - return "*"; - } - return `${params.kind}:${value}`; -} +const FEISHU_TYPED_PREFIX_RE = /^(chat|group|channel|user|dm|open_id):/i; +const FEISHU_ID_KIND = "plugin:feishu-id" as const; +const feishuIngressIdentity = defineStableChannelIngressIdentity({ + key: "feishu-id", + kind: FEISHU_ID_KIND, + normalize: normalizeFeishuAllowEntry, + sensitivity: "pii", + aliases: [ + { + key: "feishu-alt-id", + kind: FEISHU_ID_KIND, + normalizeEntry: () => null, + normalizeSubject: normalizeFeishuAllowEntry, + sensitivity: "pii", + }, + ], + isWildcardEntry: (entry) => normalizeFeishuAllowEntry(entry) === "*", + resolveEntryId: ({ entryIndex }) => `feishu-entry-${entryIndex + 1}`, +}); function normalizeFeishuAllowEntry(raw: string): string { const trimmed = raw.trim(); @@ -44,7 +48,10 @@ function normalizeFeishuAllowEntry(raw: string): string { return "*"; } - const withoutProviderPrefix = stripRepeatedFeishuProviderPrefixes(trimmed); + let withoutProviderPrefix = trimmed; + while (FEISHU_PROVIDER_PREFIX_RE.test(withoutProviderPrefix)) { + withoutProviderPrefix = withoutProviderPrefix.replace(FEISHU_PROVIDER_PREFIX_RE, "").trim(); + } if (withoutProviderPrefix === "*") { return "*"; } @@ -52,77 +59,170 @@ function normalizeFeishuAllowEntry(raw: string): string { if (!lowered) { return ""; } - // Lowercase for prefix detection only; preserve the original ID casing in the - // canonicalized key. Sender candidates pass through this same path so allowlist - // entries and runtime IDs stay normalized symmetrically. - if ( - lowered.startsWith("chat:") || - lowered.startsWith("group:") || - lowered.startsWith("channel:") - ) { - return canonicalizeFeishuAllowlistKey({ - kind: "chat", - value: withoutProviderPrefix.slice(withoutProviderPrefix.indexOf(":") + 1), - }); - } - if (lowered.startsWith("user:") || lowered.startsWith("dm:")) { - return canonicalizeFeishuAllowlistKey({ - kind: "user", - value: withoutProviderPrefix.slice(withoutProviderPrefix.indexOf(":") + 1), - }); - } - if (lowered.startsWith("open_id:")) { - return canonicalizeFeishuAllowlistKey({ - kind: "user", - value: withoutProviderPrefix.slice(withoutProviderPrefix.indexOf(":") + 1), - }); + const prefixed = lowered.match(FEISHU_TYPED_PREFIX_RE); + if (prefixed?.[1]) { + const kind = ["chat", "group", "channel"].includes(prefixed[1]) ? "chat" : "user"; + const value = withoutProviderPrefix.slice(prefixed[0].length).trim(); + return value === "*" ? "*" : value ? `${kind}:${value}` : ""; } const detectedType = detectIdType(withoutProviderPrefix); if (detectedType === "chat_id") { - return canonicalizeFeishuAllowlistKey({ - kind: "chat", - value: withoutProviderPrefix, - }); + return `chat:${withoutProviderPrefix}`; } if (detectedType === "open_id" || detectedType === "user_id") { - return canonicalizeFeishuAllowlistKey({ - kind: "user", - value: withoutProviderPrefix, - }); + return `user:${withoutProviderPrefix}`; } return ""; } -export function resolveFeishuAllowlistMatch(params: { - allowFrom: Array; - senderId: string; - senderIds?: Array; - senderName?: string | null; -}): FeishuAllowlistMatch { - const allowFrom = params.allowFrom - .map((entry) => normalizeFeishuAllowEntry(String(entry))) - .filter(Boolean); - if (allowFrom.length === 0) { - return { allowed: false }; - } - if (allowFrom.includes("*")) { - return { allowed: true, matchKey: "*", matchSource: "wildcard" }; - } +function normalizeFeishuDmPolicy(policy: string | null | undefined): FeishuDmPolicy { + return policy === "open" || + policy === "pairing" || + policy === "allowlist" || + policy === "disabled" + ? policy + : "pairing"; +} - // Feishu allowlists are ID-based; mutable display names must never grant access. - const senderCandidates = [params.senderId, ...(params.senderIds ?? [])] - .map((entry) => normalizeFeishuAllowEntry(entry ?? "")) - .filter(Boolean); +function normalizeFeishuGroupPolicy(policy: FeishuGroupPolicy): NormalizedFeishuGroupPolicy { + return policy === "allowall" ? "open" : policy; +} - for (const senderId of senderCandidates) { - if (allowFrom.includes(senderId)) { - return { allowed: true, matchKey: senderId, matchSource: "id" }; - } - } +function createFeishuIngressSubject(params: { + primaryId?: string | null; + alternateIds?: Array; +}): ChannelIngressIdentitySubjectInput { + const ids = [params.primaryId, ...(params.alternateIds ?? [])] + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)); + return { + stableId: ids[0], + aliases: { + "feishu-alt-id": ids[1], + }, + }; +} - return { allowed: false }; +function createFeishuIngressResolver(params: { + cfg?: OpenClawConfig; + accountId?: string | null; + readAllowFromStore?: ResolveChannelMessageIngressParams["readStoreAllowFrom"]; +}) { + return createChannelIngressResolver({ + channelId: "feishu", + accountId: normalizeAccountId(params.accountId) ?? "default", + identity: feishuIngressIdentity, + cfg: params.cfg, + ...(params.readAllowFromStore ? { readStoreAllowFrom: params.readAllowFromStore } : {}), + }); +} + +export async function resolveFeishuDmIngressAccess(params: { + cfg: OpenClawConfig; + accountId?: string | null; + dmPolicy?: string | null; + allowFrom?: Array | null; + readAllowFromStore?: () => Promise>; + senderOpenId: string; + senderUserId?: string | null; + conversationId: string; + mayPair: boolean; + command?: { hasControlCommand: boolean }; +}) { + return await createFeishuIngressResolver({ + cfg: params.cfg, + accountId: params.accountId, + readAllowFromStore: params.readAllowFromStore, + }).message({ + subject: createFeishuIngressSubject({ + primaryId: params.senderOpenId, + alternateIds: [params.senderUserId], + }), + conversation: { + kind: "direct", + id: params.conversationId, + }, + event: { + mayPair: params.mayPair, + }, + dmPolicy: normalizeFeishuDmPolicy(params.dmPolicy), + groupPolicy: "disabled", + allowFrom: params.allowFrom ?? [], + ...(params.command ? { command: params.command } : {}), + }); +} + +export async function resolveFeishuGroupConversationIngressAccess(params: { + cfg: OpenClawConfig; + accountId?: string | null; + chatId: string; + groupPolicy: FeishuGroupPolicy; + groupAllowFrom?: Array | null; + groupExplicitlyConfigured?: boolean; +}) { + const groupPolicy = normalizeFeishuGroupPolicy(params.groupPolicy); + const groupAllowFrom = + groupPolicy === "allowlist" && params.groupExplicitlyConfigured + ? [...(params.groupAllowFrom ?? []), params.chatId] + : (params.groupAllowFrom ?? []); + return await createFeishuIngressResolver({ + cfg: params.cfg, + accountId: params.accountId, + }).message({ + subject: createFeishuIngressSubject({ + primaryId: params.chatId, + }), + conversation: { + kind: "group", + id: params.chatId, + }, + dmPolicy: "disabled", + groupPolicy, + groupAllowFrom, + }); +} + +export async function resolveFeishuGroupSenderActivationIngressAccess(params: { + cfg: OpenClawConfig; + accountId?: string | null; + chatId: string; + allowFrom?: Array | null; + senderOpenId: string; + senderUserId?: string | null; + requireMention: boolean; + mentionedBot: boolean; + command?: { hasControlCommand: boolean }; +}) { + const groupAllowFrom = params.allowFrom ?? []; + return await createFeishuIngressResolver({ + cfg: params.cfg, + accountId: params.accountId, + }).message({ + subject: createFeishuIngressSubject({ + primaryId: params.senderOpenId, + alternateIds: [params.senderUserId], + }), + conversation: { + kind: "group", + id: params.chatId, + }, + dmPolicy: "disabled", + groupPolicy: groupAllowFrom.length > 0 ? "allowlist" : "open", + groupAllowFrom, + mentionFacts: { + canDetectMention: true, + wasMentioned: params.mentionedBot, + }, + policy: { + activation: { + requireMention: params.requireMention, + allowTextCommands: false, + }, + }, + ...(params.command ? { command: params.command } : {}), + }); } export function resolveFeishuGroupConfig(params: { cfg?: FeishuConfig; groupId?: string | null }) { @@ -181,21 +281,6 @@ export function resolveFeishuGroupToolPolicy(params: ChannelGroupContext) { return groupConfig?.tools; } -export function isFeishuGroupAllowed(params: { - groupPolicy: "open" | "allowlist" | "disabled" | "allowall"; - allowFrom: Array; - senderId: string; - senderIds?: Array; - senderName?: string | null; -}): boolean { - return evaluateSenderGroupAccessForPolicy({ - groupPolicy: params.groupPolicy === "allowall" ? "open" : params.groupPolicy, - groupAllowFrom: params.allowFrom.map((entry) => String(entry)), - senderId: params.senderId, - isSenderAllowed: () => resolveFeishuAllowlistMatch(params).allowed, - }).allowed; -} - export function resolveFeishuReplyPolicy(params: { isDirectMessage: boolean; cfg: OpenClawConfig; diff --git a/extensions/googlechat/runtime-api.ts b/extensions/googlechat/runtime-api.ts index 66a75940514..a6be7f3128e 100644 --- a/extensions/googlechat/runtime-api.ts +++ b/extensions/googlechat/runtime-api.ts @@ -22,11 +22,6 @@ export { } from "openclaw/plugin-sdk/channel-lifecycle"; export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message"; -export { - evaluateGroupRouteAccessForPolicy, - resolveDmGroupAccessWithLists, - resolveSenderScopedGroupPolicy, -} from "openclaw/plugin-sdk/channel-policy"; export { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status"; export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking"; export type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; diff --git a/extensions/googlechat/src/monitor-access.test.ts b/extensions/googlechat/src/monitor-access.test.ts index 02aff9f0a3a..9af8316d04d 100644 --- a/extensions/googlechat/src/monitor-access.test.ts +++ b/extensions/googlechat/src/monitor-access.test.ts @@ -1,29 +1,18 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; const createChannelPairingController = vi.hoisted(() => vi.fn()); -const evaluateGroupRouteAccessForPolicy = vi.hoisted(() => vi.fn()); const isDangerousNameMatchingEnabled = vi.hoisted(() => vi.fn()); const resolveAllowlistProviderRuntimeGroupPolicy = vi.hoisted(() => vi.fn()); const resolveDefaultGroupPolicy = vi.hoisted(() => vi.fn()); -const resolveDmGroupAccessWithLists = vi.hoisted(() => vi.fn()); -const resolveInboundMentionDecision = vi.hoisted(() => vi.fn()); -const resolveSenderScopedGroupPolicy = vi.hoisted(() => vi.fn()); const warnMissingProviderGroupPolicyFallbackOnce = vi.hoisted(() => vi.fn()); const sendGoogleChatMessage = vi.hoisted(() => vi.fn()); -vi.mock("openclaw/plugin-sdk/channel-inbound", () => ({ - resolveInboundMentionDecision, -})); - vi.mock("../runtime-api.js", () => ({ GROUP_POLICY_BLOCKED_LABEL: { space: "space" }, createChannelPairingController, - evaluateGroupRouteAccessForPolicy, isDangerousNameMatchingEnabled, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, - resolveDmGroupAccessWithLists, - resolveSenderScopedGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, })); @@ -54,10 +43,6 @@ function primeCommonDefaults() { groupPolicy: "allowlist", providerMissingFallbackApplied: false, }); - resolveSenderScopedGroupPolicy.mockImplementation(({ groupPolicy }) => groupPolicy); - evaluateGroupRouteAccessForPolicy.mockReturnValue({ - allowed: true, - }); warnMissingProviderGroupPolicyFallbackOnce.mockReturnValue(undefined); } @@ -74,23 +59,11 @@ const defaultSender = { let applyGoogleChatInboundAccessPolicy: typeof import("./monitor-access.js").applyGoogleChatInboundAccessPolicy; -function allowInboundGroupTraffic(options?: { - effectiveGroupAllowFrom?: string[]; - effectiveWasMentioned?: boolean; -}) { +function allowInboundGroupTraffic() { createChannelPairingController.mockReturnValue({ readAllowFromStore: vi.fn(async () => []), issueChallenge: vi.fn(), }); - resolveDmGroupAccessWithLists.mockReturnValue({ - decision: "allow", - effectiveAllowFrom: [], - effectiveGroupAllowFrom: options?.effectiveGroupAllowFrom ?? ["users/alice"], - }); - resolveInboundMentionDecision.mockReturnValue({ - shouldSkip: false, - effectiveWasMentioned: options?.effectiveWasMentioned ?? true, - }); } async function applyInboundAccessPolicy( @@ -119,12 +92,66 @@ describe("googlechat inbound access policy", () => { }); afterAll(() => { - vi.doUnmock("openclaw/plugin-sdk/channel-inbound"); vi.doUnmock("../runtime-api.js"); vi.doUnmock("./api.js"); vi.resetModules(); }); + it.each([ + { + name: "blocks raw email entries when dangerous name matching is disabled", + allowNameMatching: false, + allowFrom: ["jane@example.com"], + senderId: "users/123", + ok: false, + }, + { + name: "matches raw email entries when dangerous name matching is enabled", + allowNameMatching: true, + allowFrom: ["jane@example.com"], + senderId: "users/123", + ok: true, + }, + { + name: "does not treat users/ entries as email allowlist entries", + allowNameMatching: true, + allowFrom: ["users/jane@example.com"], + senderId: "users/123", + ok: false, + }, + { + name: "matches user id entries", + allowNameMatching: false, + allowFrom: ["users/abc"], + senderId: "users/abc", + ok: true, + }, + ])("$name", async ({ allowNameMatching, allowFrom, senderId, ok }) => { + primeCommonDefaults(); + isDangerousNameMatchingEnabled.mockReturnValue(allowNameMatching); + createChannelPairingController.mockReturnValue({ + readAllowFromStore: vi.fn(async () => []), + issueChallenge: vi.fn(), + }); + + await expect( + applyInboundAccessPolicy({ + isGroup: false, + account: { + accountId: "default", + config: { + dm: { + policy: "allowlist", + allowFrom, + }, + }, + } as never, + senderId, + senderEmail: "Jane@Example.com", + }), + ).resolves.toMatchObject({ ok }); + }); + it("issues a pairing challenge for unauthorized DMs in pairing mode", async () => { primeCommonDefaults(); const now = new Date("2026-05-09T06:35:00.000Z").getTime(); @@ -136,12 +163,6 @@ describe("googlechat inbound access policy", () => { readAllowFromStore: vi.fn(async () => []), issueChallenge, }); - resolveDmGroupAccessWithLists.mockReturnValue({ - decision: "pairing", - reason: "pairing_required", - effectiveAllowFrom: [], - effectiveGroupAllowFrom: [], - }); sendGoogleChatMessage.mockResolvedValue({ ok: true }); const statusSink = vi.fn(); @@ -269,11 +290,6 @@ describe("googlechat inbound access policy", () => { readAllowFromStore, issueChallenge: vi.fn(), }); - resolveDmGroupAccessWithLists.mockReturnValue({ - decision: "allow", - effectiveAllowFrom: ["accessGroup:operators", "users/alice"], - effectiveGroupAllowFrom: [], - }); await expect( applyInboundAccessPolicy({ @@ -303,29 +319,23 @@ describe("googlechat inbound access policy", () => { ok: true, }); - expect(resolveDmGroupAccessWithLists).toHaveBeenCalledWith( - expect.objectContaining({ - allowFrom: ["accessGroup:operators", "users/alice"], - }), - ); expect(readAllowFromStore).not.toHaveBeenCalled(); }); it("preserves allowlist group policy when a routed space has no sender allowlist", async () => { primeCommonDefaults(); - allowInboundGroupTraffic({ - effectiveGroupAllowFrom: [], - effectiveWasMentioned: false, - }); - resolveSenderScopedGroupPolicy.mockReturnValue("open"); - resolveSenderScopedGroupPolicy.mockClear(); - resolveDmGroupAccessWithLists.mockClear(); + allowInboundGroupTraffic(); + const logVerbose = vi.fn(); await expect( applyInboundAccessPolicy({ account: { accountId: "default", config: { + dm: { + policy: "allowlist", + allowFrom: ["users/alice"], + }, groups: { "spaces/AAA": { enabled: true, @@ -333,38 +343,70 @@ describe("googlechat inbound access policy", () => { }, }, } as never, + logVerbose, }), - ).resolves.toEqual({ - ok: true, - commandAuthorized: undefined, - effectiveWasMentioned: false, - groupSystemPrompt: undefined, - }); + ).resolves.toEqual({ ok: false }); - expect(resolveSenderScopedGroupPolicy).not.toHaveBeenCalled(); - expect(resolveDmGroupAccessWithLists).toHaveBeenCalledWith( - expect.objectContaining({ - groupPolicy: "allowlist", - groupAllowFrom: [], - }), + expect(logVerbose).toHaveBeenCalledWith( + "drop group message (sender policy blocked, reason=groupPolicy=allowlist (empty allowlist), space=spaces/AAA)", ); }); + it("keeps configured space users sender-scoped when group policy is open", async () => { + primeCommonDefaults(); + resolveAllowlistProviderRuntimeGroupPolicy.mockReturnValue({ + groupPolicy: "open", + providerMissingFallbackApplied: false, + }); + allowInboundGroupTraffic(); + const logVerbose = vi.fn(); + + await expect( + applyInboundAccessPolicy({ + account: { + accountId: "default", + config: { + groupPolicy: "open", + groups: { + "spaces/AAA": { + users: ["users/bob"], + requireMention: false, + }, + }, + }, + } as never, + logVerbose, + }), + ).resolves.toEqual({ ok: false }); + + expect(logVerbose).toHaveBeenCalledWith("drop group message (sender not allowed, users/alice)"); + }); + it("drops unauthorized group control commands", async () => { primeCommonDefaults(); - allowInboundGroupTraffic({ - effectiveGroupAllowFrom: [], - effectiveWasMentioned: false, + allowInboundGroupTraffic(); + resolveAllowlistProviderRuntimeGroupPolicy.mockReturnValue({ + groupPolicy: "open", + providerMissingFallbackApplied: false, }); const core = createCore(); core.channel.commands.shouldComputeCommandAuthorized.mockReturnValue(true); - core.channel.commands.resolveCommandAuthorizedFromAuthorizers.mockReturnValue(false); core.channel.commands.isControlCommandMessage.mockReturnValue(true); const logVerbose = vi.fn(); await expect( applyInboundAccessPolicy({ core: core as never, + account: { + accountId: "default", + config: { + groups: { + "spaces/AAA": { + requireMention: false, + }, + }, + }, + } as never, rawBody: "/admin", logVerbose, }), diff --git a/extensions/googlechat/src/monitor-access.ts b/extensions/googlechat/src/monitor-access.ts index 79311168707..9d0ab7b87b5 100644 --- a/extensions/googlechat/src/monitor-access.ts +++ b/extensions/googlechat/src/monitor-access.ts @@ -1,25 +1,25 @@ -import { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound"; -import { expandAllowFromWithAccessGroups } from "openclaw/plugin-sdk/security-runtime"; +import { + channelIngressRoutes, + createChannelIngressResolver, + defineStableChannelIngressIdentity, +} from "openclaw/plugin-sdk/channel-ingress-runtime"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, + normalizeStringEntries, } from "openclaw/plugin-sdk/text-runtime"; import { GROUP_POLICY_BLOCKED_LABEL, createChannelPairingController, - evaluateGroupRouteAccessForPolicy, isDangerousNameMatchingEnabled, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, - resolveDmGroupAccessWithLists, - resolveSenderScopedGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, type OpenClawConfig, } from "../runtime-api.js"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; import { sendGoogleChatMessage } from "./api.js"; import type { GoogleChatCoreRuntime } from "./monitor-types.js"; -import { isSenderAllowed } from "./sender-allow.js"; import type { GoogleChatAnnotation, GoogleChatMessage, GoogleChatSpace } from "./types.js"; function normalizeUserId(raw?: string | null): string { @@ -30,7 +30,57 @@ function normalizeUserId(raw?: string | null): string { return normalizeLowercaseStringOrEmpty(trimmed.replace(/^users\//i, "")); } -export { isSenderAllowed } from "./sender-allow.js"; +type GoogleChatDmPolicy = "open" | "pairing" | "allowlist" | "disabled"; +type GoogleChatGroupPolicy = "open" | "allowlist" | "disabled"; + +const GOOGLECHAT_EMAIL_KIND = "plugin:googlechat-email" as const; + +function normalizeEntryValue(raw?: string | null): string { + return normalizeLowercaseStringOrEmpty(raw ?? ""); +} + +function normalizeGoogleChatStableEntry(entry: string): string | null { + const withoutProvider = normalizeEntryValue(entry).replace( + /^(googlechat|google-chat|gchat):/i, + "", + ); + if (!withoutProvider) { + return null; + } + return withoutProvider.startsWith("users/") ? normalizeUserId(withoutProvider) : withoutProvider; +} + +function normalizeGoogleChatEmailEntry(entry: string): string | null { + const withoutProvider = normalizeEntryValue(entry).replace( + /^(googlechat|google-chat|gchat):/i, + "", + ); + if (withoutProvider.startsWith("users/")) { + return null; + } + const stable = normalizeGoogleChatStableEntry(entry); + return stable?.includes("@") ? stable : null; +} + +const googleChatIngressIdentity = defineStableChannelIngressIdentity({ + key: "sender-id", + normalizeEntry: normalizeGoogleChatStableEntry, + normalizeSubject: normalizeUserId, + aliases: [ + { + key: "email", + kind: GOOGLECHAT_EMAIL_KIND, + normalizeEntry: normalizeGoogleChatEmailEntry, + normalizeSubject: normalizeEntryValue, + dangerous: true, + }, + ], + isWildcardEntry: (entry) => normalizeEntryValue(entry) === "*", + resolveEntryId: ({ entryIndex, fieldKey }) => + fieldKey === "stableId" + ? `entry-${entryIndex + 1}:user` + : `entry-${entryIndex + 1}:${fieldKey}`, +}); type GoogleChatGroupEntry = { requireMention?: boolean; @@ -205,151 +255,160 @@ export async function applyGoogleChatInboundAccessPolicy(params: { }); const groupEntry = groupConfigResolved.entry; const groupUsers = groupEntry?.users ?? account.config.groupAllowFrom ?? []; - const isGoogleChatSenderAllowed = (_senderId: string, allowFrom: string[]) => - isSenderAllowed(senderId, senderEmail, allowFrom, allowNameMatching); - const expandedGroupUsers = await expandAllowFromWithAccessGroups({ - cfg: config, - allowFrom: groupUsers, - channel: "googlechat", - accountId: account.accountId, - senderId, - isSenderAllowed: isGoogleChatSenderAllowed, - }); let effectiveWasMentioned: boolean | undefined; + const dmPolicy = account.config.dm?.policy ?? "pairing"; + const rawConfigAllowFrom = normalizeStringEntries(account.config.dm?.allowFrom); + const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config); + const groupActivation = (() => { + if (!isGroup) { + return undefined; + } + const requireMention = groupEntry?.requireMention ?? account.config.requireMention ?? true; + const mentionInfo = extractMentionInfo(message.annotations ?? [], account.config.botUser); + return { + requireMention, + allowTextCommands: core.channel.commands.shouldHandleTextCommands({ + cfg: config, + surface: "googlechat", + }), + hasControlCommand: core.channel.text.hasControlCommand(rawBody, config), + wasMentioned: mentionInfo.wasMentioned, + hasAnyMention: mentionInfo.hasAnyMention, + }; + })(); + const command = { + hasControlCommand: groupActivation?.hasControlCommand ?? shouldComputeAuth, + groupOwnerAllowFrom: "none" as const, + }; + const groupAllowFrom = normalizeStringEntries(groupUsers); + const senderGroupPolicy = + groupConfigResolved.allowlistConfigured && groupAllowFrom.length === 0 + ? groupPolicy + : groupPolicy === "disabled" + ? "disabled" + : groupAllowFrom.length > 0 + ? "allowlist" + : "open"; + const route = channelIngressRoutes( + isGroup && + groupPolicy !== "disabled" && + groupEntry?.enabled === false && { + id: "googlechat:space", + enabled: false, + matched: true, + matchId: "googlechat-space", + blockReason: "route_disabled", + }, + isGroup && + groupPolicy === "allowlist" && + groupEntry?.enabled !== false && + !groupConfigResolved.allowlistConfigured && { + id: "googlechat:space", + allowed: false, + blockReason: "empty_allowlist", + }, + isGroup && + groupPolicy === "allowlist" && + groupEntry?.enabled !== false && + groupConfigResolved.allowlistConfigured && { + id: "googlechat:space", + senderPolicy: "deny-when-empty" as const, + ...(groupEntry ? { senderAllowFromSource: "effective-group" as const } : {}), + allowed: Boolean(groupEntry), + matchId: "googlechat-space", + blockReason: groupEntry ? "sender_empty_allowlist" : "route_not_allowlisted", + }, + ); + const resolvedAccess = await createChannelIngressResolver({ + channelId: "googlechat", + accountId: account.accountId, + identity: googleChatIngressIdentity, + cfg: config, + readStoreAllowFrom: pairing.readAllowFromStore, + }).message({ + subject: { + stableId: senderId, + aliases: { email: senderEmail }, + }, + conversation: { + kind: isGroup ? "group" : "direct", + id: spaceId, + }, + route, + allowFrom: rawConfigAllowFrom, + groupAllowFrom, + dmPolicy, + groupPolicy: senderGroupPolicy, + policy: { + groupAllowFromFallbackToAllowFrom: false, + mutableIdentifierMatching: allowNameMatching ? "enabled" : "disabled", + ...(groupActivation + ? { + activation: { + requireMention: groupActivation.requireMention, + allowTextCommands: groupActivation.allowTextCommands, + }, + } + : {}), + }, + ...(groupActivation == null + ? {} + : { + mentionFacts: { + canDetectMention: true, + wasMentioned: groupActivation.wasMentioned, + hasAnyMention: groupActivation.hasAnyMention, + implicitMentionKinds: [], + }, + }), + command, + }); + const senderAccess = resolvedAccess.senderAccess; + const commandAuthorized = resolvedAccess.commandAccess.requested + ? resolvedAccess.commandAccess.authorized + : undefined; if (isGroup) { if (groupConfigResolved.deprecatedNameMatch) { logVerbose(`drop group message (deprecated mutable group key matched, space=${spaceId})`); return { ok: false }; } - const groupAllowlistConfigured = groupConfigResolved.allowlistConfigured; - const routeAccess = evaluateGroupRouteAccessForPolicy({ - groupPolicy, - routeAllowlistConfigured: groupAllowlistConfigured, - routeMatched: Boolean(groupEntry), - routeEnabled: groupEntry?.enabled !== false, - }); - if (!routeAccess.allowed) { - if (routeAccess.reason === "disabled") { - logVerbose(`drop group message (groupPolicy=disabled, space=${spaceId})`); - } else if (routeAccess.reason === "empty_allowlist") { + const routeBlockReason = resolvedAccess.routeAccess.reason; + if (routeBlockReason && routeBlockReason !== "sender_empty_allowlist") { + if (routeBlockReason === "empty_allowlist") { logVerbose(`drop group message (groupPolicy=allowlist, no allowlist, space=${spaceId})`); - } else if (routeAccess.reason === "route_not_allowlisted") { + } else if (routeBlockReason === "route_not_allowlisted") { logVerbose(`drop group message (not allowlisted, space=${spaceId})`); - } else if (routeAccess.reason === "route_disabled") { + } else if (routeBlockReason === "route_disabled") { logVerbose(`drop group message (space disabled, space=${spaceId})`); } return { ok: false }; } - if (expandedGroupUsers.length > 0) { - warnDeprecatedUsersEmailEntries(logVerbose, expandedGroupUsers); - const ok = isSenderAllowed(senderId, senderEmail, expandedGroupUsers, allowNameMatching); - if (!ok) { - logVerbose(`drop group message (sender not allowed, ${senderId})`); - return { ok: false }; - } + if (senderAccess.effectiveGroupAllowFrom.length > 0 && senderAccess.decision !== "allow") { + warnDeprecatedUsersEmailEntries(logVerbose, senderAccess.effectiveGroupAllowFrom); + logVerbose(`drop group message (sender not allowed, ${senderId})`); + return { ok: false }; } } - const dmPolicy = account.config.dm?.policy ?? "pairing"; - const rawConfigAllowFrom = (account.config.dm?.allowFrom ?? []).map((v) => String(v)); - const normalizedGroupUsers = expandedGroupUsers; - const senderGroupPolicy = - groupConfigResolved.allowlistConfigured && normalizedGroupUsers.length === 0 - ? groupPolicy - : resolveSenderScopedGroupPolicy({ - groupPolicy, - groupAllowFrom: normalizedGroupUsers, - }); - const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config); - const storeAllowFrom = - !isGroup && dmPolicy !== "allowlist" && dmPolicy !== "open" - ? await pairing.readAllowFromStore().catch(() => []) - : []; - const [configAllowFrom, effectiveStoreAllowFrom] = await Promise.all([ - expandAllowFromWithAccessGroups({ - cfg: config, - allowFrom: rawConfigAllowFrom, - channel: "googlechat", - accountId: account.accountId, - senderId, - isSenderAllowed: isGoogleChatSenderAllowed, - }), - expandAllowFromWithAccessGroups({ - cfg: config, - allowFrom: storeAllowFrom, - channel: "googlechat", - accountId: account.accountId, - senderId, - isSenderAllowed: isGoogleChatSenderAllowed, - }), - ]); - const access = resolveDmGroupAccessWithLists({ - isGroup, - dmPolicy, - groupPolicy: senderGroupPolicy, - allowFrom: configAllowFrom, - groupAllowFrom: normalizedGroupUsers, - storeAllowFrom: effectiveStoreAllowFrom, - groupAllowFromFallbackToAllowFrom: false, - isSenderAllowed: (allowFrom) => - isSenderAllowed(senderId, senderEmail, allowFrom, allowNameMatching), - }); - const effectiveAllowFrom = access.effectiveAllowFrom; - const effectiveGroupAllowFrom = access.effectiveGroupAllowFrom; + const effectiveAllowFrom = senderAccess.effectiveAllowFrom; warnDeprecatedUsersEmailEntries(logVerbose, effectiveAllowFrom); - const commandAllowFrom = isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom; - const useAccessGroups = config.commands?.useAccessGroups !== false; - const senderAllowedForCommands = isSenderAllowed( - senderId, - senderEmail, - commandAllowFrom, - allowNameMatching, - ); - const commandAuthorized = shouldComputeAuth - ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ - useAccessGroups, - authorizers: [ - { configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands }, - ], - }) - : undefined; - if (isGroup) { - const requireMention = groupEntry?.requireMention ?? account.config.requireMention ?? true; - const annotations = message.annotations ?? []; - const mentionInfo = extractMentionInfo(annotations, account.config.botUser); - const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ - cfg: config, - surface: "googlechat", - }); - const mentionDecision = resolveInboundMentionDecision({ - facts: { - canDetectMention: true, - wasMentioned: mentionInfo.wasMentioned, - hasAnyMention: mentionInfo.hasAnyMention, - implicitMentionKinds: [], - }, - policy: { - isGroup: true, - requireMention, - allowTextCommands, - hasControlCommand: core.channel.text.hasControlCommand(rawBody, config), - commandAuthorized: commandAuthorized === true, - }, - }); - effectiveWasMentioned = mentionDecision.effectiveWasMentioned; - if (mentionDecision.shouldSkip) { + if (isGroup && resolvedAccess.activationAccess.ran) { + effectiveWasMentioned = resolvedAccess.activationAccess.effectiveWasMentioned; + if (resolvedAccess.activationAccess.shouldSkip) { logVerbose(`drop group message (mention required, space=${spaceId})`); return { ok: false }; } } - if (isGroup && access.decision !== "allow") { - logVerbose( - `drop group message (sender policy blocked, reason=${access.reason}, space=${spaceId})`, - ); + if (isGroup && senderAccess.decision !== "allow") { + const reason = + resolvedAccess.ingress.reasonCode === "route_sender_empty" + ? "groupPolicy=allowlist (empty allowlist)" + : senderAccess.reasonCode; + logVerbose(`drop group message (sender policy blocked, reason=${reason}, space=${spaceId})`); return { ok: false }; } @@ -359,8 +418,8 @@ export async function applyGoogleChatInboundAccessPolicy(params: { return { ok: false }; } - if (access.decision !== "allow") { - if (access.decision === "pairing") { + if (senderAccess.decision !== "allow") { + if (senderAccess.decision === "pairing") { await pairing.issueChallenge({ senderId, senderIdLine: `Your Google Chat user id: ${senderId}`, diff --git a/extensions/googlechat/src/sender-allow.ts b/extensions/googlechat/src/sender-allow.ts deleted file mode 100644 index 5aa09dd6f8d..00000000000 --- a/extensions/googlechat/src/sender-allow.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; - -function normalizeUserId(raw?: string | null): string { - const trimmed = typeof raw === "string" ? raw.trim() : ""; - if (!trimmed) { - return ""; - } - return normalizeLowercaseStringOrEmpty(trimmed.replace(/^users\//i, "")); -} - -function isEmailLike(value: string): boolean { - // Keep this intentionally loose; allowlists are user-provided config. - return value.includes("@"); -} - -export function isSenderAllowed( - senderId: string, - senderEmail: string | undefined, - allowFrom: string[], - allowNameMatching = false, -) { - if (allowFrom.includes("*")) { - return true; - } - const normalizedSenderId = normalizeUserId(senderId); - const normalizedEmail = normalizeLowercaseStringOrEmpty(senderEmail ?? ""); - return allowFrom.some((entry) => { - const normalized = normalizeLowercaseStringOrEmpty(entry); - if (!normalized) { - return false; - } - - // Accept `googlechat:` but treat `users/...` as an *ID* only (deprecated `users/`). - const withoutPrefix = normalized.replace(/^(googlechat|google-chat|gchat):/i, ""); - if (withoutPrefix.startsWith("users/")) { - return normalizeUserId(withoutPrefix) === normalizedSenderId; - } - - // Raw email allowlist entries are a break-glass override. - if (allowNameMatching && normalizedEmail && isEmailLike(withoutPrefix)) { - return withoutPrefix === normalizedEmail; - } - - return withoutPrefix.replace(/^users\//i, "") === normalizedSenderId; - }); -} diff --git a/extensions/googlechat/src/targets.test.ts b/extensions/googlechat/src/targets.test.ts index 82d9457db31..285da66d86c 100644 --- a/extensions/googlechat/src/targets.test.ts +++ b/extensions/googlechat/src/targets.test.ts @@ -2,7 +2,6 @@ import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; import { downloadGoogleChatMedia, sendGoogleChatMessage } from "./api.js"; import { resolveGoogleChatGroupRequireMention } from "./group-policy.js"; -import { isSenderAllowed } from "./sender-allow.js"; import { isGoogleChatSpaceTarget, isGoogleChatUserTarget, @@ -158,29 +157,6 @@ describe("googlechat group policy", () => { }); }); -describe("isSenderAllowed", () => { - it("matches raw email entries only when dangerous name matching is enabled", () => { - expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"])).toBe(false); - expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"], true)).toBe(true); - }); - - it("does not treat users/ entries as email allowlist (deprecated form)", () => { - expect(isSenderAllowed("users/123", "Jane@Example.com", ["users/jane@example.com"])).toBe( - false, - ); - }); - - it("still matches user id entries", () => { - expect(isSenderAllowed("users/abc", "jane@example.com", ["users/abc"])).toBe(true); - }); - - it("rejects non-matching raw email entries", () => { - expect(isSenderAllowed("users/123", "jane@example.com", ["other@example.com"], true)).toBe( - false, - ); - }); -}); - describe("downloadGoogleChatMedia", () => { afterEach(() => { authTesting.resetGoogleChatAuthForTests(); diff --git a/extensions/imessage/src/monitor.gating.test.ts b/extensions/imessage/src/monitor.gating.test.ts index bc3754d50d4..77b816f0501 100644 --- a/extensions/imessage/src/monitor.gating.test.ts +++ b/extensions/imessage/src/monitor.gating.test.ts @@ -29,7 +29,7 @@ function baseCfg(): OpenClawConfig { } as unknown as OpenClawConfig; } -function resolve(params: { +async function resolve(params: { cfg?: OpenClawConfig; message: IMessagePayload; storeAllowFrom?: string[]; @@ -53,7 +53,7 @@ function resolve(params: { }); } -function resolveDispatchDecision(params: { +async function resolveDispatchDecision(params: { cfg: OpenClawConfig; message: IMessagePayload; groupHistories?: Parameters[0]["groupHistories"]; @@ -63,7 +63,7 @@ function resolveDispatchDecision(params: { dmPolicy?: "open" | "pairing" | "allowlist" | "disabled"; }) { const groupHistories = params.groupHistories ?? new Map(); - const decision = resolveIMessageInboundDecision({ + const decision = await resolveIMessageInboundDecision({ cfg: params.cfg, accountId: "default", message: params.message, @@ -85,9 +85,12 @@ function resolveDispatchDecision(params: { return { decision, groupHistories }; } -function buildDispatchContextPayload(params: { cfg: OpenClawConfig; message: IMessagePayload }) { +async function buildDispatchContextPayload(params: { + cfg: OpenClawConfig; + message: IMessagePayload; +}) { const { cfg, message } = params; - const { decision, groupHistories } = resolveDispatchDecision({ cfg, message }); + const { decision, groupHistories } = await resolveDispatchDecision({ cfg, message }); const { ctxPayload } = buildIMessageInboundContext({ cfg, @@ -125,8 +128,8 @@ describe("imessage monitor gating + envelope builders", () => { }); }); - it("drops group messages without mention by default", () => { - const decision = resolve({ + it("drops group messages without mention by default", async () => { + const decision = await resolve({ message: { id: 1, chat_id: 99, @@ -143,7 +146,7 @@ describe("imessage monitor gating + envelope builders", () => { expect(decision.reason).toBe("no mention"); }); - it("dispatches group messages with mention and builds a group envelope", () => { + it("dispatches group messages with mention and builds a group envelope", async () => { const cfg = baseCfg(); const message: IMessagePayload = { id: 3, @@ -155,7 +158,7 @@ describe("imessage monitor gating + envelope builders", () => { chat_name: "Lobster Squad", participants: ["+1555", "+1556"], }; - const ctxPayload = buildDispatchContextPayload({ cfg, message }); + const ctxPayload = await buildDispatchContextPayload({ cfg, message }); expect(ctxPayload.ChatType).toBe("group"); expect(ctxPayload.SessionKey).toBe("agent:main:imessage:group:42"); @@ -164,7 +167,7 @@ describe("imessage monitor gating + envelope builders", () => { expect(ctxPayload.To).toBe("chat_id:42"); }); - it("uses short message ids in context and keeps the full guid for actions", () => { + it("uses short message ids in context and keeps the full guid for actions", async () => { const cfg = baseCfg(); const message: IMessagePayload = { id: 3, @@ -179,13 +182,13 @@ describe("imessage monitor gating + envelope builders", () => { chat_name: "Lobster Squad", participants: ["+1555", "+1556"], }; - const ctxPayload = buildDispatchContextPayload({ cfg, message }); + const ctxPayload = await buildDispatchContextPayload({ cfg, message }); expect(ctxPayload.MessageSid).toBe("1"); expect(ctxPayload.MessageSidFull).toBe("full-message-guid"); }); - it("includes reply-to context fields + suffix", () => { + it("includes reply-to context fields + suffix", async () => { const cfg = baseCfg(); const message: IMessagePayload = { id: 5, @@ -198,7 +201,7 @@ describe("imessage monitor gating + envelope builders", () => { reply_to_text: "original message", reply_to_sender: "+15559998888", }; - const ctxPayload = buildDispatchContextPayload({ cfg, message }); + const ctxPayload = await buildDispatchContextPayload({ cfg, message }); expect(ctxPayload.ReplyToId).toBe("9001"); expect(ctxPayload.ReplyToBody).toBe("original message"); @@ -207,7 +210,7 @@ describe("imessage monitor gating + envelope builders", () => { expect(ctxPayload.Body ?? "").toContain("original message"); }); - it("drops group reply context from non-allowlisted senders in allowlist mode", () => { + it("drops group reply context from non-allowlisted senders in allowlist mode", async () => { const cfg = baseCfg(); cfg.channels ??= {}; cfg.channels.imessage ??= {}; @@ -225,7 +228,7 @@ describe("imessage monitor gating + envelope builders", () => { reply_to_text: "blocked quote", reply_to_sender: "+15559998888", }; - const { decision, groupHistories } = resolveDispatchDecision({ + const { decision, groupHistories } = await resolveDispatchDecision({ cfg, message, allowFrom: ["*"], @@ -246,7 +249,7 @@ describe("imessage monitor gating + envelope builders", () => { expect(ctxPayload.Body ?? "").not.toContain("[Replying to"); }); - it("keeps group reply context in allowlist_quote mode", () => { + it("keeps group reply context in allowlist_quote mode", async () => { const cfg = baseCfg(); cfg.channels ??= {}; cfg.channels.imessage ??= {}; @@ -264,7 +267,7 @@ describe("imessage monitor gating + envelope builders", () => { reply_to_text: "quoted context", reply_to_sender: "+15559998888", }; - const { decision, groupHistories } = resolveDispatchDecision({ + const { decision, groupHistories } = await resolveDispatchDecision({ cfg, message, allowFrom: ["*"], @@ -285,7 +288,7 @@ describe("imessage monitor gating + envelope builders", () => { expect(ctxPayload.Body ?? "").toContain("[Replying to +15559998888 id:9001]"); }); - it("treats configured chat_id as a group session even when is_group is false", () => { + it("treats configured chat_id as a group session even when is_group is false", async () => { const cfg = baseCfg(); cfg.channels ??= {}; cfg.channels.imessage ??= {}; @@ -300,19 +303,19 @@ describe("imessage monitor gating + envelope builders", () => { text: "hello", is_group: false, }; - const { decision } = resolveDispatchDecision({ cfg, message, groupHistories }); + const { decision } = await resolveDispatchDecision({ cfg, message, groupHistories }); expect(decision.isGroup).toBe(true); expect(decision.route.sessionKey).toBe("agent:main:imessage:group:2"); }); - it("allows group messages when requireMention is true but no mentionPatterns exist", () => { + it("allows group messages when requireMention is true but no mentionPatterns exist", async () => { const cfg = baseCfg(); cfg.messages ??= {}; cfg.messages.groupChat ??= {}; cfg.messages.groupChat.mentionPatterns = []; const groupHistories = new Map(); - const decision = resolveIMessageInboundDecision({ + const decision = await resolveIMessageInboundDecision({ cfg, accountId: "default", message: { @@ -337,14 +340,14 @@ describe("imessage monitor gating + envelope builders", () => { expect(decision.kind).toBe("dispatch"); }); - it("blocks group messages when imessage.groups is set without a wildcard", () => { + it("blocks group messages when imessage.groups is set without a wildcard", async () => { const cfg = baseCfg(); cfg.channels ??= {}; cfg.channels.imessage ??= {}; cfg.channels.imessage.groups = { "99": { requireMention: false } }; const groupHistories = new Map(); - const decision = resolveIMessageInboundDecision({ + const decision = await resolveIMessageInboundDecision({ cfg, accountId: "default", message: { @@ -369,14 +372,14 @@ describe("imessage monitor gating + envelope builders", () => { expect(decision.kind).toBe("drop"); }); - it("honors group allowlist and ignores pairing-store senders in groups", () => { + it("honors group allowlist and ignores pairing-store senders in groups", async () => { const cfg = baseCfg(); cfg.channels ??= {}; cfg.channels.imessage ??= {}; cfg.channels.imessage.groupPolicy = "allowlist"; const groupHistories = new Map(); - const denied = resolveIMessageInboundDecision({ + const denied = await resolveIMessageInboundDecision({ cfg, accountId: "default", message: { @@ -400,7 +403,7 @@ describe("imessage monitor gating + envelope builders", () => { }); expect(denied.kind).toBe("drop"); - const allowed = resolveIMessageInboundDecision({ + const allowed = await resolveIMessageInboundDecision({ cfg, accountId: "default", message: { @@ -425,14 +428,14 @@ describe("imessage monitor gating + envelope builders", () => { expect(allowed.kind).toBe("dispatch"); }); - it("blocks group messages when groupPolicy is disabled", () => { + it("blocks group messages when groupPolicy is disabled", async () => { const cfg = baseCfg(); cfg.channels ??= {}; cfg.channels.imessage ??= {}; cfg.channels.imessage.groupPolicy = "disabled"; const groupHistories = new Map(); - const decision = resolveIMessageInboundDecision({ + const decision = await resolveIMessageInboundDecision({ cfg, accountId: "default", message: { diff --git a/extensions/imessage/src/monitor/inbound-processing.systemPrompt.test.ts b/extensions/imessage/src/monitor/inbound-processing.systemPrompt.test.ts index 51d266e898e..9ec6199d326 100644 --- a/extensions/imessage/src/monitor/inbound-processing.systemPrompt.test.ts +++ b/extensions/imessage/src/monitor/inbound-processing.systemPrompt.test.ts @@ -52,8 +52,8 @@ function buildDecisionParams(overrides: Partial = {}): DecisionP } describe("resolveIMessageInboundDecision per-group systemPrompt", () => { - it("captures the per-chat_id systemPrompt on group dispatch decisions", () => { - const decision = resolveIMessageInboundDecision( + it("captures the per-chat_id systemPrompt on group dispatch decisions", async () => { + const decision = await resolveIMessageInboundDecision( buildDecisionParams({ cfg: buildCfgWithGroups({ "7": { systemPrompt: "Keep responses under 3 sentences." }, @@ -67,8 +67,8 @@ describe("resolveIMessageInboundDecision per-group systemPrompt", () => { expect(decision.groupSystemPrompt).toBe("Keep responses under 3 sentences."); }); - it("falls back to the groups['*'] wildcard systemPrompt", () => { - const decision = resolveIMessageInboundDecision( + it("falls back to the groups['*'] wildcard systemPrompt", async () => { + const decision = await resolveIMessageInboundDecision( buildDecisionParams({ cfg: buildCfgWithGroups({ "*": { systemPrompt: "Default group voice." }, @@ -82,8 +82,8 @@ describe("resolveIMessageInboundDecision per-group systemPrompt", () => { expect(decision.groupSystemPrompt).toBe("Default group voice."); }); - it("prefers the per-chat_id systemPrompt over the wildcard when both are set", () => { - const decision = resolveIMessageInboundDecision( + it("prefers the per-chat_id systemPrompt over the wildcard when both are set", async () => { + const decision = await resolveIMessageInboundDecision( buildDecisionParams({ cfg: buildCfgWithGroups({ "*": { systemPrompt: "Default group voice." }, @@ -98,11 +98,11 @@ describe("resolveIMessageInboundDecision per-group systemPrompt", () => { expect(decision.groupSystemPrompt).toBe("Specific group voice."); }); - it("treats whitespace-only per-chat_id systemPrompt as suppression of the wildcard", () => { + it("treats whitespace-only per-chat_id systemPrompt as suppression of the wildcard", async () => { // Mirrors WhatsApp semantic: defining the systemPrompt key on a specific // group entry (even as whitespace) means "this group has no prompt" and // suppresses the groups["*"] fallback. - const decision = resolveIMessageInboundDecision( + const decision = await resolveIMessageInboundDecision( buildDecisionParams({ cfg: buildCfgWithGroups({ "*": { systemPrompt: "Wildcard." }, @@ -117,8 +117,8 @@ describe("resolveIMessageInboundDecision per-group systemPrompt", () => { expect(decision.groupSystemPrompt).toBeUndefined(); }); - it("treats explicit empty-string per-chat_id systemPrompt as suppression of the wildcard", () => { - const decision = resolveIMessageInboundDecision( + it("treats explicit empty-string per-chat_id systemPrompt as suppression of the wildcard", async () => { + const decision = await resolveIMessageInboundDecision( buildDecisionParams({ cfg: buildCfgWithGroups({ "*": { systemPrompt: "Wildcard." }, @@ -133,8 +133,8 @@ describe("resolveIMessageInboundDecision per-group systemPrompt", () => { expect(decision.groupSystemPrompt).toBeUndefined(); }); - it("falls back to the wildcard when the per-chat_id entry has no systemPrompt key at all", () => { - const decision = resolveIMessageInboundDecision( + it("falls back to the wildcard when the per-chat_id entry has no systemPrompt key at all", async () => { + const decision = await resolveIMessageInboundDecision( buildDecisionParams({ cfg: buildCfgWithGroups({ "*": { systemPrompt: "Wildcard." }, @@ -149,12 +149,12 @@ describe("resolveIMessageInboundDecision per-group systemPrompt", () => { expect(decision.groupSystemPrompt).toBe("Wildcard."); }); - it("does not set groupSystemPrompt on true DM decisions", () => { + it("does not set groupSystemPrompt on true DM decisions", async () => { // Use a chat_id that does NOT match any configured group entry, and // route through the DM-shaped message (is_group=false, no chat_id key // in groups). Without a groupConfig match the path stays a DM and the // group prompt must not bleed into the ctx. - const decision = resolveIMessageInboundDecision( + const decision = await resolveIMessageInboundDecision( buildDecisionParams({ cfg: buildCfgWithGroups({ "999": { systemPrompt: "Other group." }, diff --git a/extensions/imessage/src/monitor/inbound-processing.test.ts b/extensions/imessage/src/monitor/inbound-processing.test.ts index 8dc8c132d92..39c69ecfdf4 100644 --- a/extensions/imessage/src/monitor/inbound-processing.test.ts +++ b/extensions/imessage/src/monitor/inbound-processing.test.ts @@ -64,12 +64,12 @@ describe("resolveIMessageInboundDecision echo detection", () => { return resolveIMessageInboundDecision(createInboundDecisionParams(overrides)); } - it("drops inbound messages when outbound message id matches echo cache", () => { + it("drops inbound messages when outbound message id matches echo cache", async () => { const echoHas = vi.fn((_scope: string, lookup: { text?: string; messageId?: string }) => { return lookup.messageId === "42"; }); - const decision = resolveDecision({ + const decision = await resolveDecision({ message: { id: 42, text: "Reasoning:\n_step_", @@ -86,12 +86,12 @@ describe("resolveIMessageInboundDecision echo detection", () => { expect(echoHas).toHaveBeenCalledTimes(1); }); - it("matches attachment-only echoes by bodyText placeholder", () => { + it("matches attachment-only echoes by bodyText placeholder", async () => { const echoHas = vi.fn((_scope: string, lookup: { text?: string; messageId?: string }) => { return lookup.text === "" && lookup.messageId === "42"; }); - const decision = resolveDecision({ + const decision = await resolveDecision({ message: { id: 42, text: "", @@ -116,12 +116,12 @@ describe("resolveIMessageInboundDecision echo detection", () => { ); }); - it("drops reflected self-chat duplicates after seeing the from-me copy", () => { + it("drops reflected self-chat duplicates after seeing the from-me copy", async () => { const selfChatCache = createSelfChatCache(); const createdAt = "2026-03-02T20:58:10.649Z"; expect( - resolveDecision({ + await resolveDecision({ message: { id: 9641, sender: "+15555550123", @@ -138,7 +138,7 @@ describe("resolveIMessageInboundDecision echo detection", () => { ).toMatchObject({ kind: "dispatch" }); expect( - resolveDecision({ + await resolveDecision({ message: { id: 9642, sender: "+15555550123", @@ -153,10 +153,10 @@ describe("resolveIMessageInboundDecision echo detection", () => { ).toEqual({ kind: "drop", reason: "self-chat echo" }); }); - it("does not drop same-text messages when created_at differs", () => { + it("does not drop same-text messages when created_at differs", async () => { const selfChatCache = createSelfChatCache(); - resolveDecision({ + await resolveDecision({ message: { id: 9641, text: "ok", @@ -166,7 +166,7 @@ describe("resolveIMessageInboundDecision echo detection", () => { selfChatCache, }); - const decision = resolveDecision({ + const decision = await resolveDecision({ message: { id: 9642, text: "ok", @@ -178,7 +178,7 @@ describe("resolveIMessageInboundDecision echo detection", () => { expect(decision.kind).toBe("dispatch"); }); - it("keeps self-chat cache scoped to configured group threads", () => { + it("keeps self-chat cache scoped to configured group threads", async () => { const selfChatCache = createSelfChatCache(); const groupedCfg = { channels: { @@ -193,7 +193,7 @@ describe("resolveIMessageInboundDecision echo detection", () => { const createdAt = "2026-03-02T20:58:10.649Z"; expect( - resolveDecision({ + await resolveDecision({ cfg: groupedCfg, message: { id: 9701, @@ -206,7 +206,7 @@ describe("resolveIMessageInboundDecision echo detection", () => { }), ).toEqual({ kind: "drop", reason: "from me" }); - const decision = resolveDecision({ + const decision = await resolveDecision({ cfg: groupedCfg, message: { id: 9702, @@ -220,12 +220,12 @@ describe("resolveIMessageInboundDecision echo detection", () => { expect(decision.kind).toBe("dispatch"); }); - it("does not drop other participants in the same group thread", () => { + it("does not drop other participants in the same group thread", async () => { const selfChatCache = createSelfChatCache(); const createdAt = "2026-03-02T20:58:10.649Z"; expect( - resolveDecision({ + await resolveDecision({ message: { id: 9751, chat_id: 123, @@ -238,7 +238,7 @@ describe("resolveIMessageInboundDecision echo detection", () => { }), ).toEqual({ kind: "drop", reason: "from me" }); - const decision = resolveDecision({ + const decision = await resolveDecision({ message: { id: 9752, chat_id: 123, @@ -253,7 +253,7 @@ describe("resolveIMessageInboundDecision echo detection", () => { expect(decision.kind).toBe("dispatch"); }); - it("drops group echoes persisted under chat_guid scope", () => { + it("drops group echoes persisted under chat_guid scope", async () => { // Outbound `send` to a group keyed by chat_guid persists the echo scope // as `${accountId}:chat_guid:${chatGuid}` (see send.ts:resolveOutboundEchoScope). // The inbound side has chat_id, chat_guid, and chat_identifier all @@ -264,7 +264,7 @@ describe("resolveIMessageInboundDecision echo detection", () => { return scope === "default:chat_guid:iMessage;+;chat0000" && lookup.messageId === "9001"; }); - const decision = resolveDecision({ + const decision = await resolveDecision({ message: { id: 9001, chat_id: 42, @@ -285,12 +285,12 @@ describe("resolveIMessageInboundDecision echo detection", () => { expect(calls).toContain("default:chat_guid:iMessage;+;chat0000"); }); - it("drops group echoes persisted under chat_identifier scope", () => { + it("drops group echoes persisted under chat_identifier scope", async () => { const echoHas = vi.fn((scope: string, lookup: { text?: string; messageId?: string }) => { return scope === "default:chat_identifier:chat0000" && lookup.messageId === "9001"; }); - const decision = resolveDecision({ + const decision = await resolveDecision({ message: { id: 9001, chat_id: 42, @@ -310,12 +310,12 @@ describe("resolveIMessageInboundDecision echo detection", () => { expect(calls).toContain("default:chat_identifier:chat0000"); }); - it("drops group echoes persisted under chat_id scope (baseline)", () => { + it("drops group echoes persisted under chat_id scope (baseline)", async () => { const echoHas = vi.fn((scope: string, lookup: { text?: string; messageId?: string }) => { return scope === "default:chat_id:42" && lookup.messageId === "9001"; }); - const decision = resolveDecision({ + const decision = await resolveDecision({ message: { id: 9001, chat_id: 42, @@ -335,13 +335,13 @@ describe("resolveIMessageInboundDecision echo detection", () => { expect(calls).toContain("default:chat_id:42"); }); - it("does not drop a group inbound when echo cache holds an unrelated chat_guid", () => { + it("does not drop a group inbound when echo cache holds an unrelated chat_guid", async () => { const echoHas = vi.fn( (scope: string, lookup: { text?: string; messageId?: string }) => scope === "default:chat_guid:iMessage;+;OTHER" && lookup.messageId === "9001", ); - const decision = resolveDecision({ + const decision = await resolveDecision({ message: { id: 9001, chat_id: 42, @@ -359,13 +359,13 @@ describe("resolveIMessageInboundDecision echo detection", () => { expect(decision.kind).toBe("dispatch"); }); - it("sanitizes reflected duplicate previews before logging", () => { + it("sanitizes reflected duplicate previews before logging", async () => { const selfChatCache = createSelfChatCache(); const logVerbose = vi.fn(); const createdAt = "2026-03-02T20:58:10.649Z"; const bodyText = "line-1\nline-2\t\u001b[31mred"; - resolveDecision({ + await resolveDecision({ message: { id: 9801, sender: "+15555550123", @@ -381,7 +381,7 @@ describe("resolveIMessageInboundDecision echo detection", () => { logVerbose, }); - resolveDecision({ + await resolveDecision({ message: { id: 9802, sender: "+15555550123", @@ -413,8 +413,8 @@ describe("describeIMessageEchoDropLog", () => { }); describe("buildIMessageInboundContext", () => { - it("keeps numeric row id and provider GUID separately for action tooling", () => { - const decision = resolveIMessageInboundDecision({ + it("keeps numeric row id and provider GUID separately for action tooling", async () => { + const decision = await resolveIMessageInboundDecision({ cfg: {} as OpenClawConfig, accountId: "default", message: { @@ -496,8 +496,8 @@ describe("resolveIMessageInboundDecision command auth", () => { logVerbose: undefined, }); - it("does not auto-authorize DM commands in open mode without allowlists", () => { - const decision = resolveDmCommandDecision({ + it("does not auto-authorize DM commands in open mode without allowlists", async () => { + const decision = await resolveDmCommandDecision({ messageId: 100, storeAllowFrom: [], }); @@ -505,8 +505,8 @@ describe("resolveIMessageInboundDecision command auth", () => { expect(decision).toEqual({ kind: "drop", reason: "dmPolicy blocked" }); }); - it("authorizes DM commands for senders in pairing-mode store allowlist", () => { - const decision = resolveDmCommandDecision({ + it("authorizes DM commands for senders in pairing-mode store allowlist", async () => { + const decision = await resolveDmCommandDecision({ messageId: 101, dmPolicy: "pairing", storeAllowFrom: ["+15555550123"], diff --git a/extensions/imessage/src/monitor/inbound-processing.ts b/extensions/imessage/src/monitor/inbound-processing.ts index 432441c8e07..64f6686bcf4 100644 --- a/extensions/imessage/src/monitor/inbound-processing.ts +++ b/extensions/imessage/src/monitor/inbound-processing.ts @@ -8,13 +8,16 @@ import { resolveEnvelopeFormatOptions, resolveInboundMentionDecision, } from "openclaw/plugin-sdk/channel-inbound"; +import { + createChannelIngressResolver, + defineStableChannelIngressIdentity, +} from "openclaw/plugin-sdk/channel-ingress-runtime"; import { resolveChannelGroupPolicy, resolveChannelGroupRequireMention, } from "openclaw/plugin-sdk/channel-policy"; -import { hasControlCommand } from "openclaw/plugin-sdk/command-auth"; -import { resolveDualTextControlCommandGate } from "openclaw/plugin-sdk/command-auth"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import { hasControlCommand } from "openclaw/plugin-sdk/command-auth-native"; +import type { DmPolicy, GroupPolicy, OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/context-visibility-runtime"; import { buildPendingHistoryContextFromMap, @@ -23,11 +26,7 @@ import { } from "openclaw/plugin-sdk/reply-history"; import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; -import { - DM_GROUP_ACCESS_REASON, - resolveDmGroupAccessWithLists, - evaluateSupplementalContextVisibility, -} from "openclaw/plugin-sdk/security-runtime"; +import { evaluateSupplementalContextVisibility } from "openclaw/plugin-sdk/security-runtime"; import { sanitizeTerminalText } from "openclaw/plugin-sdk/text-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-runtime"; import { resolveIMessageConversationRoute } from "../conversation-route.js"; @@ -36,6 +35,7 @@ import { formatIMessageChatTarget, isAllowedIMessageSender, normalizeIMessageHandle, + parseIMessageAllowTarget, } from "../targets.js"; import { detectReflectedContent } from "./reflection-guard.js"; import type { SelfChatCache } from "./self-chat-cache.js"; @@ -47,6 +47,61 @@ type IMessageReplyContext = { sender?: string; }; +const normalizeNonEmpty = (value: string) => value.trim() || null; + +const imessageIngressIdentity = defineStableChannelIngressIdentity({ + key: "imessage-sender", + normalizeEntry: normalizeIMessageHandleEntry, + normalizeSubject: normalizeIMessageHandle, + sensitivity: "pii", + aliases: ( + [ + ["imessage-chat-id", "plugin:imessage-chat-id", normalizeIMessageChatIdEntry], + ["imessage-chat-guid", "plugin:imessage-chat-guid", normalizeIMessageChatGuidEntry], + [ + "imessage-chat-identifier", + "plugin:imessage-chat-identifier", + normalizeIMessageChatIdentifierEntry, + ], + ] as const + ).map(([key, kind, normalizeEntry]) => ({ + key, + kind, + normalizeEntry, + normalizeSubject: normalizeNonEmpty, + sensitivity: "pii", + })), + resolveEntryId: ({ entryIndex }) => `imessage-entry-${entryIndex + 1}`, +}); + +function normalizeIMessageHandleEntry(entry: string): string | null { + const parsed = parseIMessageAllowTarget(entry.trim()); + return parsed.kind === "handle" ? normalizeIMessageHandle(parsed.handle) : null; +} + +function normalizeIMessageChatIdEntry(entry: string): string | null { + const parsed = parseIMessageAllowTarget(entry.trim()); + return parsed.kind === "chat_id" ? String(parsed.chatId) : null; +} + +function normalizeIMessageChatGuidEntry(entry: string): string | null { + const parsed = parseIMessageAllowTarget(entry.trim()); + return parsed.kind === "chat_guid" ? parsed.chatGuid.trim() || null : null; +} + +function normalizeIMessageChatIdentifierEntry(entry: string): string | null { + const parsed = parseIMessageAllowTarget(entry.trim()); + return parsed.kind === "chat_identifier" ? parsed.chatIdentifier.trim() || null : null; +} + +function normalizeDmPolicy(policy: string): DmPolicy { + return policy === "open" || policy === "allowlist" || policy === "disabled" ? policy : "pairing"; +} + +function normalizeGroupPolicy(policy: string): GroupPolicy { + return policy === "open" || policy === "disabled" ? policy : "allowlist"; +} + function normalizeReplyField(value: unknown): string | undefined { if (typeof value === "string") { const trimmed = value.trim(); @@ -172,9 +227,6 @@ type IMessageInboundDispatchDecision = { replyContext: IMessageReplyContext | null; effectiveWasMentioned: boolean; commandAuthorized: boolean; - // Used for allowlist checks for control commands. - effectiveDmAllowFrom: string[]; - effectiveGroupAllowFrom: string[]; // Forwarded as ctxPayload.GroupSystemPrompt for group messages. Resolved // from `channels.imessage.groups..systemPrompt` (or the `"*"` // wildcard) at gate time. Always undefined for DMs. @@ -186,7 +238,7 @@ type IMessageInboundDecision = | { kind: "pairing"; senderId: string } | IMessageInboundDispatchDecision; -export function resolveIMessageInboundDecision(params: { +export async function resolveIMessageInboundDecision(params: { cfg: OpenClawConfig; accountId: string; message: IMessagePayload; @@ -209,7 +261,7 @@ export function resolveIMessageInboundDecision(params: { }; selfChatCache?: SelfChatCache; logVerbose?: (msg: string) => void; -}): IMessageInboundDecision { +}): Promise { const senderRaw = params.message.sender ?? ""; const sender = senderRaw.trim(); if (!sender) { @@ -313,49 +365,65 @@ export function resolveIMessageInboundDecision(params: { } const groupId = isGroup ? groupIdCandidate : undefined; - const accessDecision = resolveDmGroupAccessWithLists({ - isGroup, - dmPolicy: params.dmPolicy, - groupPolicy: params.groupPolicy, + const hasControlCommandInMessage = hasControlCommand(messageText, params.cfg); + const accessDecision = await createChannelIngressResolver({ + channelId: "imessage", + accountId: params.accountId, + identity: imessageIngressIdentity, + cfg: params.cfg, + readStoreAllowFrom: async () => params.storeAllowFrom, + }).message({ + subject: { + stableId: sender, + aliases: { + ...(chatId != null ? { "imessage-chat-id": String(chatId) } : {}), + ...(chatGuid ? { "imessage-chat-guid": chatGuid } : {}), + ...(chatIdentifier ? { "imessage-chat-identifier": chatIdentifier } : {}), + }, + }, + conversation: { + kind: isGroup ? "group" : "direct", + id: isGroup + ? String(chatId ?? chatGuid ?? chatIdentifier ?? "unknown") + : normalizeIMessageHandle(sender), + }, + dmPolicy: normalizeDmPolicy(params.dmPolicy), + groupPolicy: normalizeGroupPolicy(params.groupPolicy), + policy: { groupAllowFromFallbackToAllowFrom: false }, allowFrom: params.allowFrom, groupAllowFrom: params.groupAllowFrom, - storeAllowFrom: params.storeAllowFrom, - groupAllowFromFallbackToAllowFrom: false, - isSenderAllowed: (allowFrom) => - isAllowedIMessageSender({ - allowFrom, - sender, - chatId, - chatGuid, - chatIdentifier, - }), + command: { + allowTextCommands: isGroup, + hasControlCommand: hasControlCommandInMessage, + directGroupAllowFrom: "effective", + }, }); - const effectiveDmAllowFrom = accessDecision.effectiveAllowFrom; - const effectiveGroupAllowFrom = accessDecision.effectiveGroupAllowFrom; + const { commandAccess, senderAccess } = accessDecision; + const effectiveGroupAllowFrom = senderAccess.effectiveGroupAllowFrom; - if (accessDecision.decision !== "allow") { + if (senderAccess.decision !== "allow") { if (isGroup) { - if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_DISABLED) { + if (senderAccess.reasonCode === "group_policy_disabled") { params.logVerbose?.("Blocked iMessage group message (groupPolicy: disabled)"); return { kind: "drop", reason: "groupPolicy disabled" }; } - if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST) { + if (senderAccess.reasonCode === "group_policy_empty_allowlist") { params.logVerbose?.( "Blocked iMessage group message (groupPolicy: allowlist, no groupAllowFrom)", ); return { kind: "drop", reason: "groupPolicy allowlist (empty groupAllowFrom)" }; } - if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED) { + if (senderAccess.reasonCode === "group_policy_not_allowlisted") { params.logVerbose?.(`Blocked iMessage sender ${sender} (not in groupAllowFrom)`); return { kind: "drop", reason: "not in groupAllowFrom" }; } - params.logVerbose?.(`Blocked iMessage group message (${accessDecision.reason})`); - return { kind: "drop", reason: accessDecision.reason }; + params.logVerbose?.(`Blocked iMessage group message (${senderAccess.reasonCode})`); + return { kind: "drop", reason: senderAccess.reasonCode }; } - if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.DM_POLICY_DISABLED) { + if (senderAccess.reasonCode === "dm_policy_disabled") { return { kind: "drop", reason: "dmPolicy disabled" }; } - if (accessDecision.decision === "pairing") { + if (senderAccess.decision === "pairing") { return { kind: "pairing", senderId: senderNormalized }; } params.logVerbose?.(`Blocked iMessage sender ${sender} (dmPolicy=${params.dmPolicy})`); @@ -478,38 +546,8 @@ export function resolveIMessageInboundDecision(params: { }); const canDetectMention = mentionRegexes.length > 0; - const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; - const commandDmAllowFrom = isGroup ? params.allowFrom : effectiveDmAllowFrom; - const ownerAllowedForCommands = - commandDmAllowFrom.length > 0 - ? isAllowedIMessageSender({ - allowFrom: commandDmAllowFrom, - sender, - chatId, - chatGuid, - chatIdentifier, - }) - : false; - const groupAllowedForCommands = - effectiveGroupAllowFrom.length > 0 - ? isAllowedIMessageSender({ - allowFrom: effectiveGroupAllowFrom, - sender, - chatId, - chatGuid, - chatIdentifier, - }) - : false; - const hasControlCommandInMessage = hasControlCommand(messageText, params.cfg); - const { commandAuthorized, shouldBlock } = resolveDualTextControlCommandGate({ - useAccessGroups, - primaryConfigured: commandDmAllowFrom.length > 0, - primaryAllowed: ownerAllowedForCommands, - secondaryConfigured: effectiveGroupAllowFrom.length > 0, - secondaryAllowed: groupAllowedForCommands, - hasControlCommand: hasControlCommandInMessage, - }); - if (isGroup && shouldBlock) { + const commandAuthorized = commandAccess.authorized; + if (commandAccess.shouldBlockControlCommand) { if (params.logVerbose) { logInboundDrop({ log: params.logVerbose, @@ -583,8 +621,6 @@ export function resolveIMessageInboundDecision(params: { replyContext: filteredReplyContext, effectiveWasMentioned, commandAuthorized, - effectiveDmAllowFrom, - effectiveGroupAllowFrom, groupSystemPrompt, }; } diff --git a/extensions/imessage/src/monitor/monitor-provider.ts b/extensions/imessage/src/monitor/monitor-provider.ts index 1209811edf0..fc11e53dab3 100644 --- a/extensions/imessage/src/monitor/monitor-provider.ts +++ b/extensions/imessage/src/monitor/monitor-provider.ts @@ -73,34 +73,21 @@ import { sanitizeIMessageWatchErrorPayload } from "./watch-error-log.js"; const WATCH_SUBSCRIBE_MAX_ATTEMPTS = 3; const WATCH_SUBSCRIBE_RETRY_DELAY_MS = 1_000; -/** - * Try to detect remote host from an SSH wrapper script like: - * exec ssh -T openclaw@192.168.64.3 /opt/homebrew/bin/imsg "$@" - * exec ssh -T mac-mini imsg "$@" - * Returns the user@host or host portion if found, undefined otherwise. - */ async function detectRemoteHostFromCliPath(cliPath: string): Promise { try { - // Expand ~ to home directory const expanded = cliPath.startsWith("~") ? cliPath.replace(/^~/, process.env.HOME ?? "") : cliPath; const content = await fs.readFile(expanded, "utf8"); - // Match user@host pattern first (e.g., openclaw@192.168.64.3) const userHostMatch = content.match(/\bssh\b[^\n]*?\s+([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+)/); if (userHostMatch) { return userHostMatch[1]; } - // Fallback: match host-only before imsg command (e.g., ssh -T mac-mini imsg) const hostOnlyMatch = content.match(/\bssh\b[^\n]*?\s+([a-zA-Z][a-zA-Z0-9._-]*)\s+\S*\bimsg\b/); return hostOnlyMatch?.[1]; } catch (err) { - // ENOENT / ENOTDIR are expected for non-script cliPaths (just an - // executable on disk). Anything else (EACCES, broken symlink) is - // worth flagging — silent failure means attachments will fail to find - // remote media because remoteHost stays undefined. const code = (err as NodeJS.ErrnoException)?.code; if (code !== "ENOENT" && code !== "ENOTDIR") { logVerbose( @@ -111,7 +98,6 @@ async function detectRemoteHostFromCliPath(cliPath: string): Promise { let fired = false; return { @@ -385,7 +371,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P process.env, accountInfo.accountId, ).catch(() => []); - const decision = resolveIMessageInboundDecision({ + const decision = await resolveIMessageInboundDecision({ cfg, accountId: accountInfo.accountId, message, diff --git a/extensions/imessage/src/monitor/self-chat-dedupe.test.ts b/extensions/imessage/src/monitor/self-chat-dedupe.test.ts index 81293f8dc0a..e0b35511f5f 100644 --- a/extensions/imessage/src/monitor/self-chat-dedupe.test.ts +++ b/extensions/imessage/src/monitor/self-chat-dedupe.test.ts @@ -65,7 +65,7 @@ describe("echo cache — message ID type canary (#47830)", () => { // Tests the implicit contract that outbound GUIDs (e.g. "p:0/abc-def-123") // never match inbound SQLite row IDs (e.g. "200"). If iMessage ever changes // ID schemes, this test should break loudly. - it("outbound GUID format and inbound SQLite row ID format never collide", () => { + it("outbound GUID format and inbound SQLite row ID format never collide", async () => { const echoCache = createSentMessageCache(); const scope = "default:imessage:+15555550123"; @@ -79,7 +79,7 @@ describe("echo cache — message ID type canary (#47830)", () => { expect(echoCache.has(scope, { text: "different", messageId: "p:0/abc-def-123" })).toBe(true); }); - it('falls back to text when outbound messageId was junk ("ok")', () => { + it('falls back to text when outbound messageId was junk ("ok")', async () => { const echoCache = createSentMessageCache(); const scope = "default:imessage:+15555550123"; @@ -91,7 +91,7 @@ describe("echo cache — message ID type canary (#47830)", () => { expect(echoCache.has(scope, { text: "text-only fallback", messageId: "200" })).toBe(true); }); - it("keeps ID short-circuit when scope has real outbound GUID IDs", () => { + it("keeps ID short-circuit when scope has real outbound GUID IDs", async () => { const echoCache = createSentMessageCache(); const scope = "default:imessage:+15555550123"; @@ -110,7 +110,7 @@ describe("echo cache — backward compat for channels without messageId", () => // Proves text-fallback echo detection still works when no messageId is present // on either side. Critical for backward compat with channels that don't // populate messageId. - it("text-only remember/has works within TTL", () => { + it("text-only remember/has works within TTL", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-24T12:00:00Z")); @@ -123,7 +123,7 @@ describe("echo cache — backward compat for channels without messageId", () => expect(echoCache.has(scope, { text: "no id message" })).toBe(true); }); - it("text-only has returns false after TTL expiry", () => { + it("text-only has returns false after TTL expiry", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-24T12:00:00Z")); @@ -136,7 +136,7 @@ describe("echo cache — backward compat for channels without messageId", () => expect(echoCache.has(scope, { text: "no id message" })).toBe(false); }); - it("text-only has returns false for different text", () => { + it("text-only has returns false for different text", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-24T12:00:00Z")); @@ -155,7 +155,7 @@ describe("self-chat dedupe — #47830", () => { vi.useRealTimers(); }); - it("does NOT drop a user message that matches recently-sent agent text (self-chat scope collision)", () => { + it("does NOT drop a user message that matches recently-sent agent text (self-chat scope collision)", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-24T12:00:00Z")); @@ -169,7 +169,7 @@ describe("self-chat dedupe — #47830", () => { // 2 seconds later, user sends "Hello" to themselves (different message id) vi.advanceTimersByTime(2000); - const decision = resolveIMessageInboundDecision( + const decision = await resolveIMessageInboundDecision( createParams({ message: { id: 200, @@ -191,7 +191,7 @@ describe("self-chat dedupe — #47830", () => { expect(decision.kind).toBe("dispatch"); }); - it("DOES drop genuine agent echo (same message id reflected back)", () => { + it("DOES drop genuine agent echo (same message id reflected back)", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-24T12:00:00Z")); @@ -204,7 +204,7 @@ describe("self-chat dedupe — #47830", () => { // 1 second later, iMessage reflects it back with same message id vi.advanceTimersByTime(1000); - const decision = resolveIMessageInboundDecision( + const decision = await resolveIMessageInboundDecision( createParams({ message: { id: "agent-msg-1" as unknown as number, @@ -221,7 +221,7 @@ describe("self-chat dedupe — #47830", () => { expect(decision).toEqual({ kind: "drop", reason: "echo" }); }); - it("does NOT drop different-text messages even within TTL", () => { + it("does NOT drop different-text messages even within TTL", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-24T12:00:00Z")); @@ -233,7 +233,7 @@ describe("self-chat dedupe — #47830", () => { vi.advanceTimersByTime(1000); - const decision = resolveIMessageInboundDecision( + const decision = await resolveIMessageInboundDecision( createParams({ message: { id: 201, @@ -250,7 +250,7 @@ describe("self-chat dedupe — #47830", () => { expect(decision.kind).toBe("dispatch"); }); - it("does NOT drop user messages that match a chunk of a multi-chunk agent reply", () => { + it("does NOT drop user messages that match a chunk of a multi-chunk agent reply", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-24T12:00:00Z")); @@ -265,7 +265,7 @@ describe("self-chat dedupe — #47830", () => { vi.advanceTimersByTime(2000); // User sends "Part two" (matches chunk 2 text, but different message id) - const decision = resolveIMessageInboundDecision( + const decision = await resolveIMessageInboundDecision( createParams({ message: { id: 300, @@ -283,7 +283,7 @@ describe("self-chat dedupe — #47830", () => { expect(decision.kind).toBe("dispatch"); }); - it("drops echo after text TTL expiry (4s TTL: expired at 5s)", () => { + it("drops echo after text TTL expiry (4s TTL: expired at 5s)", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-24T12:00:00Z")); @@ -301,7 +301,7 @@ describe("self-chat dedupe — #47830", () => { }); // Safe failure mode: TTL expiry causes duplicate delivery (noisy), never message loss (lossy) - it("does NOT catch echo after TTL expiry — safe failure mode is duplicate delivery", () => { + it("does NOT catch echo after TTL expiry — safe failure mode is duplicate delivery", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-24T12:00:00Z")); @@ -321,7 +321,7 @@ describe("self-chat dedupe — #47830", () => { expect(result).toBe(false); }); - it("still drops text echo within 4s TTL window", () => { + it("still drops text echo within 4s TTL window", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-24T12:00:00Z")); @@ -343,11 +343,11 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { vi.useRealTimers(); }); - it("processes real user self-chat message (is_from_me=true, no echo cache match)", () => { + it("processes real user self-chat message (is_from_me=true, no echo cache match)", async () => { const echoCache = createSentMessageCache(); const selfChatCache = createSelfChatCache(); - const decision = resolveIMessageInboundDecision( + const decision = await resolveIMessageInboundDecision( createParams({ message: { id: 123703, @@ -368,11 +368,11 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { expect(decision.kind).toBe("dispatch"); }); - it("drops is_from_me outbound when destination_caller_id is blank and sender matches chat_identifier (#63980)", () => { + it("drops is_from_me outbound when destination_caller_id is blank and sender matches chat_identifier (#63980)", async () => { const echoCache = createSentMessageCache(); const selfChatCache = createSelfChatCache(); - const decision = resolveIMessageInboundDecision( + const decision = await resolveIMessageInboundDecision( createParams({ message: { id: 123704, @@ -393,11 +393,11 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { expect(decision).toEqual({ kind: "drop", reason: "from me" }); }); - it("drops DM false positives even when participant lists include the local handle", () => { + it("drops DM false positives even when participant lists include the local handle", async () => { const echoCache = createSentMessageCache(); const selfChatCache = createSelfChatCache(); - const decision = resolveIMessageInboundDecision( + const decision = await resolveIMessageInboundDecision( createParams({ message: { id: 123705, @@ -419,7 +419,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { expect(decision).toEqual({ kind: "drop", reason: "from me" }); }); - it("drops agent reply echo in self-chat (is_from_me=true, echo cache text match)", () => { + it("drops agent reply echo in self-chat (is_from_me=true, echo cache text match)", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-24T12:00:00Z")); @@ -434,7 +434,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { // with a SQLite row ID (never matches the GUID) vi.advanceTimersByTime(1000); - const decision = resolveIMessageInboundDecision( + const decision = await resolveIMessageInboundDecision( createParams({ message: { id: 123706, @@ -457,7 +457,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { expect(decision).toEqual({ kind: "drop", reason: "agent echo in self-chat" }); }); - it("drops attachment-only agent echo in self-chat via bodyText placeholder", () => { + it("drops attachment-only agent echo in self-chat via bodyText placeholder", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-24T12:00:00Z")); @@ -469,7 +469,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { vi.advanceTimersByTime(1000); - const decision = resolveIMessageInboundDecision( + const decision = await resolveIMessageInboundDecision( createParams({ message: { id: 123707, @@ -491,7 +491,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { expect(decision).toEqual({ kind: "drop", reason: "agent echo in self-chat" }); }); - it("drops self-chat echo when outbound cache stored numeric id but inbound also carries a guid", () => { + it("drops self-chat echo when outbound cache stored numeric id but inbound also carries a guid", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-24T12:00:00Z")); @@ -503,7 +503,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { vi.advanceTimersByTime(1000); - const decision = resolveIMessageInboundDecision( + const decision = await resolveIMessageInboundDecision( createParams({ message: { id: 123709, @@ -525,7 +525,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { expect(decision).toEqual({ kind: "drop", reason: "agent echo in self-chat" }); }); - it("does not drop a real self-chat image just because a recent agent image used the same placeholder", () => { + it("does not drop a real self-chat image just because a recent agent image used the same placeholder", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-24T12:00:00Z")); @@ -537,7 +537,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { vi.advanceTimersByTime(1000); - const decision = resolveIMessageInboundDecision( + const decision = await resolveIMessageInboundDecision( createParams({ message: { id: 123708, @@ -559,7 +559,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { expect(decision.kind).toBe("dispatch"); }); - it("drops is_from_me=false reflection via selfChatCache (existing behavior preserved)", () => { + it("drops is_from_me=false reflection via selfChatCache (existing behavior preserved)", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-24T12:00:00Z")); @@ -567,7 +567,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { const createdAt = "2026-03-24T12:00:00.000Z"; // Step 1: is_from_me=true copy arrives (real user message) → processed, selfChatCache populated - const first = resolveIMessageInboundDecision( + const first = await resolveIMessageInboundDecision( createParams({ message: { id: 123703, @@ -588,7 +588,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { // Step 2: is_from_me=false reflection arrives 2s later with same text+createdAt vi.advanceTimersByTime(2200); - const second = resolveIMessageInboundDecision( + const second = await resolveIMessageInboundDecision( createParams({ message: { id: 123704, @@ -608,10 +608,10 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { expect(second).toEqual({ kind: "drop", reason: "self-chat echo" }); }); - it("drops outbound DM when sender matches chat_identifier but destination_caller_id is absent (#63980)", () => { + it("drops outbound DM when sender matches chat_identifier but destination_caller_id is absent (#63980)", async () => { const selfChatCache = createSelfChatCache(); - const decision = resolveIMessageInboundDecision( + const decision = await resolveIMessageInboundDecision( createParams({ message: { id: 10003, @@ -630,14 +630,14 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { expect(decision).toEqual({ kind: "drop", reason: "from me" }); }); - it("drops reflected inbound when destination_caller_id is absent (#63980)", () => { + it("drops reflected inbound when destination_caller_id is absent (#63980)", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-24T12:00:00Z")); const selfChatCache = createSelfChatCache(); const createdAt = "2026-03-24T12:00:00.000Z"; - const outbound = resolveIMessageInboundDecision( + const outbound = await resolveIMessageInboundDecision( createParams({ message: { id: 10003, @@ -657,7 +657,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { vi.advanceTimersByTime(2200); - const reflection = resolveIMessageInboundDecision( + const reflection = await resolveIMessageInboundDecision( createParams({ message: { id: 10004, @@ -677,12 +677,12 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { expect(reflection).toEqual({ kind: "drop", reason: "self-chat echo" }); }); - it("normal DM is_from_me=true is still dropped (regression test)", () => { + it("normal DM is_from_me=true is still dropped (regression test)", async () => { const selfChatCache = createSelfChatCache(); // Normal DM with is_from_me=true: sender may be the local handle and // chat_identifier the other party (they differ), so this is NOT self-chat. - const decision = resolveIMessageInboundDecision( + const decision = await resolveIMessageInboundDecision( createParams({ message: { id: 9999, @@ -701,7 +701,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { expect(decision).toEqual({ kind: "drop", reason: "from me" }); }); - it("uses destination_caller_id to avoid DM self-chat false positives", () => { + it("uses destination_caller_id to avoid DM self-chat false positives", async () => { const echoCache = createSentMessageCache(); const selfChatCache = createSelfChatCache(); @@ -710,7 +710,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { messageId: "p:0/GUID-outbound", }); - const decision = resolveIMessageInboundDecision( + const decision = await resolveIMessageInboundDecision( createParams({ message: { id: 10001, @@ -731,7 +731,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { expect(decision).toEqual({ kind: "drop", reason: "from me" }); }); - it("echo cache text matching works with skipIdShortCircuit=true", () => { + it("echo cache text matching works with skipIdShortCircuit=true", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-24T12:00:00Z")); @@ -751,7 +751,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { }); describe("echo cache — text fallback for null-id inbound messages", () => { - it("still identifies echo via text when inbound message has id: null", () => { + it("still identifies echo via text when inbound message has id: null", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-24T12:00:00Z")); @@ -765,7 +765,7 @@ describe("echo cache — text fallback for null-id inbound messages", () => { // 1 second later, inbound reflection arrives with id: null vi.advanceTimersByTime(1000); - const decision = resolveIMessageInboundDecision( + const decision = await resolveIMessageInboundDecision( createParams({ message: { id: null as unknown as number, @@ -787,7 +787,7 @@ describe("echo cache — text fallback for null-id inbound messages", () => { }); describe("echo cache — mixed GUID and text-only scopes", () => { - it("still falls back to text for the latest text-only send in a scope with older GUID-backed sends", () => { + it("still falls back to text for the latest text-only send in a scope with older GUID-backed sends", async () => { const echoCache = createSentMessageCache(); const scope = "default:imessage:+15555550123"; @@ -797,7 +797,7 @@ describe("echo cache — mixed GUID and text-only scopes", () => { expect(echoCache.has(scope, { text: "latest text-only", messageId: "200" })).toBe(true); }); - it("still short-circuits when the latest copy of a text was GUID-backed", () => { + it("still short-circuits when the latest copy of a text was GUID-backed", async () => { const echoCache = createSentMessageCache(); const scope = "default:imessage:+15555550123"; diff --git a/extensions/irc/src/inbound.behavior.test.ts b/extensions/irc/src/inbound.behavior.test.ts index aa6a82187a3..d1f7edd4417 100644 --- a/extensions/irc/src/inbound.behavior.test.ts +++ b/extensions/irc/src/inbound.behavior.test.ts @@ -1,3 +1,4 @@ +import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helpers"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ResolvedIrcAccount } from "./accounts.js"; import { handleIrcInbound } from "./inbound.js"; @@ -163,4 +164,30 @@ describe("irc inbound behavior", () => { "irc: drop control command (unauthorized) target=alice!ident@example.com", ); }); + + it("passes the shared reply pipeline for dispatched replies", async () => { + const coreRuntime = createPluginRuntimeMock(); + setIrcRuntime(coreRuntime as never); + + await handleIrcInbound({ + message: createMessage(), + account: createAccount({ + config: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "allowlist", + groupAllowFrom: [], + }, + }), + config: { channels: { irc: {} } } as CoreConfig, + runtime: createRuntimeEnv(), + sendReply: vi.fn(async () => {}), + }); + + expect(coreRuntime.channel.turn.runAssembled).toHaveBeenCalledWith( + expect.objectContaining({ + replyPipeline: {}, + }), + ); + }); }); diff --git a/extensions/irc/src/inbound.policy.test.ts b/extensions/irc/src/inbound.policy.test.ts deleted file mode 100644 index a202ffde704..00000000000 --- a/extensions/irc/src/inbound.policy.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { __testing } from "./inbound.js"; - -describe("irc inbound policy", () => { - it("keeps DM allowlist merged with pairing-store entries", () => { - const resolved = __testing.resolveIrcEffectiveAllowlists({ - configAllowFrom: ["owner"], - configGroupAllowFrom: [], - storeAllowList: ["paired-user"], - dmPolicy: "pairing", - }); - - expect(resolved.effectiveAllowFrom).toEqual(["owner", "paired-user"]); - }); - - it("does not grant group access from pairing-store when explicit groupAllowFrom exists", () => { - const resolved = __testing.resolveIrcEffectiveAllowlists({ - configAllowFrom: ["owner"], - configGroupAllowFrom: ["group-owner"], - storeAllowList: ["paired-user"], - dmPolicy: "pairing", - }); - - expect(resolved.effectiveGroupAllowFrom).toEqual(["group-owner"]); - }); - - it("does not grant group access from pairing-store when groupAllowFrom is empty", () => { - const resolved = __testing.resolveIrcEffectiveAllowlists({ - configAllowFrom: ["owner"], - configGroupAllowFrom: [], - storeAllowList: ["paired-user"], - dmPolicy: "pairing", - }); - - expect(resolved.effectiveGroupAllowFrom).toStrictEqual([]); - }); -}); diff --git a/extensions/irc/src/inbound.ts b/extensions/irc/src/inbound.ts index a8e631a404f..656dd2fed26 100644 --- a/extensions/irc/src/inbound.ts +++ b/extensions/irc/src/inbound.ts @@ -1,12 +1,12 @@ -import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message"; -import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; import { - readStoreAllowFromForDmPolicy, - resolveEffectiveAllowFromLists, -} from "openclaw/plugin-sdk/channel-policy"; -import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth"; + channelIngressRoutes, + createChannelIngressResolver, + defineStableChannelIngressIdentity, +} from "openclaw/plugin-sdk/channel-ingress-runtime"; +import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; +import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope"; import { deliverFormattedTextWithAttachments, type OutboundReplyPayload, @@ -21,42 +21,122 @@ import { import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, + normalizeStringEntries, } from "openclaw/plugin-sdk/text-runtime"; import type { ResolvedIrcAccount } from "./accounts.js"; -import { normalizeIrcAllowlist, resolveIrcAllowlistMatch } from "./normalize.js"; -import { - resolveIrcMentionGate, - resolveIrcGroupAccessGate, - resolveIrcGroupMatch, - resolveIrcGroupSenderAllowed, - resolveIrcRequireMention, -} from "./policy.js"; +import { buildIrcAllowlistCandidates, normalizeIrcAllowEntry } from "./normalize.js"; +import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js"; import { getIrcRuntime } from "./runtime.js"; import { sendMessageIrc } from "./send.js"; import type { CoreConfig, IrcInboundMessage } from "./types.js"; const CHANNEL_ID = "irc" as const; +const IRC_NICK_KIND = "plugin:irc-nick" as const; +type IrcGroupPolicy = "open" | "allowlist" | "disabled"; + +const ircIngressIdentity = defineStableChannelIngressIdentity({ + key: "irc-id", + normalizeEntry: normalizeIrcStableEntry, + normalizeSubject: normalizeLowercaseStringOrEmpty, + sensitivity: "pii", + aliases: [ + ...["irc-id-nick-user", "irc-id-nick-host"].map((key) => ({ + key, + kind: "stable-id" as const, + normalizeEntry: () => null, + normalizeSubject: normalizeLowercaseStringOrEmpty, + sensitivity: "pii" as const, + })), + { + key: "irc-nick", + kind: IRC_NICK_KIND, + normalizeEntry: normalizeIrcNickEntry, + normalizeSubject: normalizeLowercaseStringOrEmpty, + dangerous: true, + sensitivity: "pii", + }, + ], + isWildcardEntry: (entry) => normalizeIrcAllowEntry(entry) === "*", + resolveEntryId: ({ entryIndex, fieldKey }) => + `irc-entry-${entryIndex + 1}:${fieldKey === "irc-nick" ? "nick" : "id"}`, +}); const escapeIrcRegexLiteral = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -function resolveIrcEffectiveAllowlists(params: { - configAllowFrom: string[]; - configGroupAllowFrom: string[]; - storeAllowList: string[]; - dmPolicy: string; -}): { - effectiveAllowFrom: string[]; - effectiveGroupAllowFrom: string[]; -} { - const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveEffectiveAllowFromLists({ - allowFrom: params.configAllowFrom, - groupAllowFrom: params.configGroupAllowFrom, - storeAllowFrom: params.storeAllowList, - dmPolicy: params.dmPolicy, - // IRC intentionally requires explicit groupAllowFrom; do not fallback to allowFrom. - groupAllowFromFallbackToAllowFrom: false, - }); - return { effectiveAllowFrom, effectiveGroupAllowFrom }; +function isBareNick(value: string): boolean { + return !value.includes("!") && !value.includes("@"); +} + +function normalizeIrcStableEntry(value: string): string | null { + const normalized = normalizeIrcAllowEntry(value); + if (!normalized || normalized === "*" || isBareNick(normalized)) { + return null; + } + return normalized; +} + +function normalizeIrcNickEntry(value: string): string | null { + const normalized = normalizeIrcAllowEntry(value); + if (!normalized || normalized === "*" || !isBareNick(normalized)) { + return null; + } + return normalized; +} + +function hasEntries(entries: Array | undefined): boolean { + return normalizeStringEntries(entries).some((entry) => normalizeIrcAllowEntry(entry)); +} + +function createIrcIngressSubject(message: IrcInboundMessage) { + const candidates = buildIrcAllowlistCandidates(message, { allowNameMatching: true }); + const stableCandidates = candidates.filter((candidate) => !isBareNick(candidate)); + const nick = normalizeLowercaseStringOrEmpty(message.senderNick); + return { + stableId: stableCandidates[stableCandidates.length - 1] ?? nick, + aliases: { + "irc-id-nick-user": stableCandidates.find( + (candidate) => candidate.includes("!") && !candidate.includes("@"), + ), + "irc-id-nick-host": stableCandidates.find( + (candidate) => !candidate.includes("!") && candidate.includes("@"), + ), + "irc-nick": nick, + }, + }; +} + +function routeDescriptorsForIrcGroup(params: { + isGroup: boolean; + groupPolicy: IrcGroupPolicy; + groupAllowed: boolean; + hasConfiguredGroups: boolean; + groupEnabled: boolean; + routeGroupAllowFrom: string[]; +}) { + if (!params.isGroup) { + return []; + } + return channelIngressRoutes( + params.groupPolicy === "allowlist" && { + id: "irc:channel", + allowed: params.hasConfiguredGroups && params.groupAllowed, + precedence: 0, + matchId: "irc-channel", + blockReason: "channel_not_allowlisted", + }, + !params.groupEnabled && { + id: "irc:channel-enabled", + enabled: false, + precedence: 10, + blockReason: "channel_disabled", + }, + hasEntries(params.routeGroupAllowFrom) && { + id: "irc:channel-sender", + precedence: 20, + senderPolicy: "replace", + senderAllowFrom: params.routeGroupAllowFrom, + }, + ); } async function deliverIrcReply(params: { @@ -67,7 +147,7 @@ async function deliverIrcReply(params: { sendReply?: (target: string, text: string, replyToId?: string) => Promise; statusSink?: (patch: { lastOutboundAt?: number }) => void; }) { - const delivered = await deliverFormattedTextWithAttachments({ + await deliverFormattedTextWithAttachments({ payload: params.payload, send: async ({ text, replyToId }) => { if (params.sendReply) { @@ -82,9 +162,6 @@ async function deliverIrcReply(params: { params.statusSink?.({ lastOutboundAt: Date.now() }); }, }); - if (!delivered) { - return; - } } export async function handleIrcInbound(params: { @@ -132,124 +209,16 @@ export async function handleIrcInbound(params: { log: (message) => runtime.log?.(message), }); - const configAllowFrom = normalizeIrcAllowlist(account.config.allowFrom); - const configGroupAllowFrom = normalizeIrcAllowlist(account.config.groupAllowFrom); - const storeAllowFrom = await readStoreAllowFromForDmPolicy({ - provider: CHANNEL_ID, - accountId: account.accountId, - dmPolicy, - readStore: pairing.readStoreForDmPolicy, - }); - const storeAllowList = normalizeIrcAllowlist(storeAllowFrom); - const groupMatch = resolveIrcGroupMatch({ groups: account.config.groups, target: message.target, }); - if (message.isGroup) { - const groupAccess = resolveIrcGroupAccessGate({ groupPolicy, groupMatch }); - if (!groupAccess.allowed) { - runtime.log?.(`irc: drop channel ${message.target} (${groupAccess.reason})`); - return; - } - } - - const directGroupAllowFrom = normalizeIrcAllowlist(groupMatch.groupConfig?.allowFrom); - const wildcardGroupAllowFrom = normalizeIrcAllowlist(groupMatch.wildcardConfig?.allowFrom); - const groupAllowFrom = - directGroupAllowFrom.length > 0 ? directGroupAllowFrom : wildcardGroupAllowFrom; - - const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveIrcEffectiveAllowlists({ - configAllowFrom, - configGroupAllowFrom, - storeAllowList, - dmPolicy, - }); - const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg: config as OpenClawConfig, surface: CHANNEL_ID, }); - const useAccessGroups = config.commands?.useAccessGroups !== false; - const senderAllowedForCommands = resolveIrcAllowlistMatch({ - allowFrom: message.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom, - message, - allowNameMatching, - }).allowed; const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig); - const commandGate = resolveControlCommandGate({ - useAccessGroups, - authorizers: [ - { - configured: (message.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom).length > 0, - allowed: senderAllowedForCommands, - }, - ], - allowTextCommands, - hasControlCommand, - }); - const commandAuthorized = commandGate.commandAuthorized; - - if (message.isGroup) { - const senderAllowed = resolveIrcGroupSenderAllowed({ - groupPolicy, - message, - outerAllowFrom: effectiveGroupAllowFrom, - innerAllowFrom: groupAllowFrom, - allowNameMatching, - }); - if (!senderAllowed) { - runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`); - return; - } - } else { - if (dmPolicy === "disabled") { - runtime.log?.(`irc: drop DM sender=${senderDisplay} (dmPolicy=disabled)`); - return; - } - const dmAllowed = resolveIrcAllowlistMatch({ - allowFrom: effectiveAllowFrom, - message, - allowNameMatching, - }).allowed; - if (!dmAllowed) { - if (dmPolicy === "pairing") { - await pairing.issueChallenge({ - senderId: normalizeLowercaseStringOrEmpty(senderDisplay), - senderIdLine: `Your IRC id: ${senderDisplay}`, - meta: { name: message.senderNick || undefined }, - sendPairingReply: async (text) => { - await deliverIrcReply({ - payload: { text }, - cfg: config, - target: message.senderNick, - accountId: account.accountId, - sendReply: params.sendReply, - statusSink, - }); - }, - onReplyError: (err) => { - runtime.error?.(`irc: pairing reply failed for ${senderDisplay}: ${String(err)}`); - }, - }); - } - runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`); - return; - } - } - - if (message.isGroup && commandGate.shouldBlock) { - const { logInboundDrop } = await import("openclaw/plugin-sdk/channel-inbound"); - logInboundDrop({ - log: (line) => runtime.log?.(line), - channel: CHANNEL_ID, - reason: "control command (unauthorized)", - target: senderDisplay, - }); - return; - } - const mentionRegexes = core.channel.mentions.buildMentionRegexes(config as OpenClawConfig); const mentionNick = connectedNick?.trim() || account.nick; const explicitMentionRegex = mentionNick @@ -258,29 +227,126 @@ export async function handleIrcInbound(params: { const wasMentioned = core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes) || (explicitMentionRegex ? explicitMentionRegex.test(rawBody) : false); - const requireMention = message.isGroup ? resolveIrcRequireMention({ groupConfig: groupMatch.groupConfig, wildcardConfig: groupMatch.wildcardConfig, }) : false; - - const mentionGate = resolveIrcMentionGate({ - isGroup: message.isGroup, - requireMention, - wasMentioned, - hasControlCommand, - allowTextCommands, - commandAuthorized, + const routeGroupAllowFrom = normalizeStringEntries( + groupMatch.groupConfig?.allowFrom?.length + ? groupMatch.groupConfig.allowFrom + : groupMatch.wildcardConfig?.allowFrom, + ); + const accessGroupPolicy: IrcGroupPolicy = + groupPolicy === "open" && + (hasEntries(account.config.groupAllowFrom) || hasEntries(routeGroupAllowFrom)) + ? "allowlist" + : groupPolicy; + const access = await createChannelIngressResolver({ + channelId: CHANNEL_ID, + accountId: account.accountId, + identity: ircIngressIdentity, + cfg: config as OpenClawConfig, + readStoreAllowFrom: async () => await pairing.readAllowFromStore(), + }).message({ + subject: createIrcIngressSubject(message), + conversation: { + kind: message.isGroup ? "group" : "direct", + id: message.target, + }, + route: routeDescriptorsForIrcGroup({ + isGroup: message.isGroup, + groupPolicy, + groupAllowed: groupMatch.allowed, + hasConfiguredGroups: groupMatch.hasConfiguredGroups, + groupEnabled: + groupMatch.groupConfig?.enabled !== false && groupMatch.wildcardConfig?.enabled !== false, + routeGroupAllowFrom, + }), + mentionFacts: message.isGroup + ? { + canDetectMention: true, + wasMentioned, + hasAnyMention: wasMentioned, + } + : undefined, + dmPolicy, + groupPolicy: accessGroupPolicy, + policy: { + groupAllowFromFallbackToAllowFrom: false, + mutableIdentifierMatching: allowNameMatching ? "enabled" : "disabled", + activation: { + requireMention: message.isGroup && requireMention, + allowTextCommands, + }, + }, + allowFrom: account.config.allowFrom, + groupAllowFrom: account.config.groupAllowFrom, + command: { + allowTextCommands, + hasControlCommand, + }, }); - if (mentionGate.shouldSkip) { - runtime.log?.(`irc: drop channel ${message.target} (${mentionGate.reason})`); + const commandAuthorized = access.commandAccess.authorized; + + if (access.ingress.admission === "pairing-required") { + await pairing.issueChallenge({ + senderId: normalizeLowercaseStringOrEmpty(senderDisplay), + senderIdLine: `Your IRC id: ${senderDisplay}`, + meta: { name: message.senderNick || undefined }, + sendPairingReply: async (text) => { + await deliverIrcReply({ + payload: { text }, + cfg: config, + target: message.senderNick, + accountId: account.accountId, + sendReply: params.sendReply, + statusSink, + }); + }, + onReplyError: (err) => { + runtime.error?.(`irc: pairing reply failed for ${senderDisplay}: ${String(err)}`); + }, + }); + runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`); + return; + } + if (access.ingress.admission === "skip") { + runtime.log?.(`irc: drop channel ${message.target} (missing-mention)`); + return; + } + if (access.ingress.admission !== "dispatch") { + if ( + message.isGroup && + access.ingress.decisiveGateId === "command" && + access.commandAccess.shouldBlockControlCommand + ) { + const { logInboundDrop } = await import("openclaw/plugin-sdk/channel-inbound"); + logInboundDrop({ + log: (line) => runtime.log?.(line), + channel: CHANNEL_ID, + reason: "control command (unauthorized)", + target: senderDisplay, + }); + return; + } + if (message.isGroup) { + if (access.routeAccess.reason === "channel_not_allowlisted") { + runtime.log?.(`irc: drop channel ${message.target} (not allowlisted)`); + } else if (access.routeAccess.reason === "channel_disabled") { + runtime.log?.(`irc: drop channel ${message.target} (disabled)`); + } else { + runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`); + } + } else { + runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`); + } return; } const peerId = message.isGroup ? message.target : message.senderNick; - const route = core.channel.routing.resolveAgentRoute({ + const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({ cfg: config as OpenClawConfig, channel: CHANNEL_ID, accountId: account.accountId, @@ -288,23 +354,15 @@ export async function handleIrcInbound(params: { kind: message.isGroup ? "group" : "direct", id: peerId, }, + runtime: core.channel, + sessionStore: config.session?.store, }); const fromLabel = message.isGroup ? message.target : senderDisplay; - const storePath = core.channel.session.resolveStorePath(config.session?.store, { - agentId: route.agentId, - }); - const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config as OpenClawConfig); - const previousTimestamp = core.channel.session.readSessionUpdatedAt({ - storePath, - sessionKey: route.sessionKey, - }); - const body = core.channel.reply.formatAgentEnvelope({ + const { storePath, body } = buildEnvelope({ channel: "IRC", from: fromLabel, timestamp: message.timestamp, - previousTimestamp, - envelope: envelopeOptions, body: rawBody, }); @@ -334,49 +392,40 @@ export async function handleIrcInbound(params: { CommandAuthorized: commandAuthorized, }); - const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({ + await core.channel.turn.runAssembled({ cfg: config as OpenClawConfig, + channel: CHANNEL_ID, + accountId: account.accountId, agentId: route.agentId, - channel: CHANNEL_ID, - accountId: account.accountId, - }); - - await core.channel.turn.runPrepared({ - channel: CHANNEL_ID, - accountId: account.accountId, routeSessionKey: route.sessionKey, storePath, ctxPayload, recordInboundSession: core.channel.session.recordInboundSession, - runDispatch: async () => - await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ - ctx: ctxPayload, - cfg: config as OpenClawConfig, - dispatcherOptions: { - ...replyPipeline, - deliver: async (payload) => { - await deliverIrcReply({ - payload, - cfg: config, - target: peerId, - accountId: account.accountId, - sendReply: params.sendReply, - statusSink, - }); - }, - onError: (err, info) => { - runtime.error?.(`irc ${info.kind} reply failed: ${String(err)}`); - }, - }, - replyOptions: { - onModelSelected, - skillFilter: groupMatch.groupConfig?.skills, - disableBlockStreaming: - typeof account.config.blockStreaming === "boolean" - ? !account.config.blockStreaming - : undefined, - }, - }), + dispatchReplyWithBufferedBlockDispatcher: + core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, + delivery: { + deliver: async (payload) => { + await deliverIrcReply({ + payload, + cfg: config, + target: peerId, + accountId: account.accountId, + sendReply: params.sendReply, + statusSink, + }); + }, + onError: (err, info) => { + runtime.error?.(`irc ${info.kind} reply failed: ${String(err)}`); + }, + }, + replyPipeline: {}, + replyOptions: { + skillFilter: groupMatch.groupConfig?.skills, + disableBlockStreaming: + typeof account.config.blockStreaming === "boolean" + ? !account.config.blockStreaming + : undefined, + }, record: { onRecordError: (err) => { runtime.error?.(`irc: failed updating session meta: ${String(err)}`); @@ -384,7 +433,3 @@ export async function handleIrcInbound(params: { }, }); } - -export const __testing = { - resolveIrcEffectiveAllowlists, -}; diff --git a/extensions/irc/src/policy.test.ts b/extensions/irc/src/policy.test.ts index 688b96db055..dc2bfc34be0 100644 --- a/extensions/irc/src/policy.test.ts +++ b/extensions/irc/src/policy.test.ts @@ -1,12 +1,6 @@ import { resolveChannelGroupPolicy } from "openclaw/plugin-sdk/channel-policy"; import { describe, expect, it } from "vitest"; -import { - resolveIrcGroupAccessGate, - resolveIrcGroupMatch, - resolveIrcGroupSenderAllowed, - resolveIrcMentionGate, - resolveIrcRequireMention, -} from "./policy.js"; +import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js"; describe("irc policy", () => { it("matches direct and wildcard group entries", () => { @@ -29,94 +23,6 @@ describe("irc policy", () => { expect(resolveIrcRequireMention({ wildcardConfig: wildcard.wildcardConfig })).toBe(true); }); - it("enforces allowlist by default in groups", () => { - const message = { - messageId: "m1", - target: "#ops", - senderNick: "alice", - senderUser: "ident", - senderHost: "example.org", - text: "hi", - timestamp: Date.now(), - isGroup: true, - }; - - expect( - resolveIrcGroupSenderAllowed({ - groupPolicy: "allowlist", - message, - outerAllowFrom: [], - innerAllowFrom: [], - }), - ).toBe(false); - - expect( - resolveIrcGroupSenderAllowed({ - groupPolicy: "allowlist", - message, - outerAllowFrom: ["alice!ident@example.org"], - innerAllowFrom: [], - }), - ).toBe(true); - expect( - resolveIrcGroupSenderAllowed({ - groupPolicy: "allowlist", - message, - outerAllowFrom: ["alice"], - innerAllowFrom: [], - }), - ).toBe(false); - expect( - resolveIrcGroupSenderAllowed({ - groupPolicy: "allowlist", - message, - outerAllowFrom: ["alice"], - innerAllowFrom: [], - allowNameMatching: true, - }), - ).toBe(true); - }); - - it('allows unconfigured channels when groupPolicy is "open"', () => { - const groupMatch = resolveIrcGroupMatch({ - groups: undefined, - target: "#random", - }); - const gate = resolveIrcGroupAccessGate({ - groupPolicy: "open", - groupMatch, - }); - expect(gate.allowed).toBe(true); - expect(gate.reason).toBe("open"); - }); - - it("honors explicit group disable even in open mode", () => { - const groupMatch = resolveIrcGroupMatch({ - groups: { - "#ops": { enabled: false }, - }, - target: "#ops", - }); - const gate = resolveIrcGroupAccessGate({ - groupPolicy: "open", - groupMatch, - }); - expect(gate.allowed).toBe(false); - expect(gate.reason).toBe("disabled"); - }); - - it("allows authorized control commands without mention", () => { - const gate = resolveIrcMentionGate({ - isGroup: true, - requireMention: true, - wasMentioned: false, - hasControlCommand: true, - allowTextCommands: true, - commandAuthorized: true, - }); - expect(gate.shouldSkip).toBe(false); - }); - it("keeps case-insensitive group matching aligned with shared channel policy resolution", () => { const groups = { "#Ops": { requireMention: false }, diff --git a/extensions/irc/src/policy.ts b/extensions/irc/src/policy.ts index 91b621651ce..41fe59c6f19 100644 --- a/extensions/irc/src/policy.ts +++ b/extensions/irc/src/policy.ts @@ -1,20 +1,13 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; -import { normalizeIrcAllowlist, resolveIrcAllowlistMatch } from "./normalize.js"; -import type { IrcAccountConfig, IrcChannelConfig } from "./types.js"; -import type { IrcInboundMessage } from "./types.js"; +import type { IrcChannelConfig } from "./types.js"; -type IrcGroupMatch = { +export type IrcGroupMatch = { allowed: boolean; groupConfig?: IrcChannelConfig; wildcardConfig?: IrcChannelConfig; hasConfiguredGroups: boolean; }; -type IrcGroupAccessGate = { - allowed: boolean; - reason: string; -}; - export function resolveIrcGroupMatch(params: { groups?: Record; target: string; @@ -29,7 +22,7 @@ export function resolveIrcGroupMatch(params: { if (direct) { return { // "allowed" means the target matched an allowlisted key. - // Explicit disables are handled later by resolveIrcGroupAccessGate. + // Explicit disables are represented later as ingress route facts. allowed: true, groupConfig: direct, wildcardConfig: groups["*"], @@ -46,7 +39,7 @@ export function resolveIrcGroupMatch(params: { if (matched) { return { // "allowed" means the target matched an allowlisted key. - // Explicit disables are handled later by resolveIrcGroupAccessGate. + // Explicit disables are represented later as ingress route facts. allowed: true, groupConfig: matched, wildcardConfig: groups["*"], @@ -59,7 +52,7 @@ export function resolveIrcGroupMatch(params: { if (wildcard) { return { // "allowed" means the target matched an allowlisted key. - // Explicit disables are handled later by resolveIrcGroupAccessGate. + // Explicit disables are represented later as ingress route facts. allowed: true, wildcardConfig: wildcard, hasConfiguredGroups, @@ -71,39 +64,6 @@ export function resolveIrcGroupMatch(params: { }; } -export function resolveIrcGroupAccessGate(params: { - groupPolicy: IrcAccountConfig["groupPolicy"]; - groupMatch: IrcGroupMatch; -}): IrcGroupAccessGate { - const policy = params.groupPolicy ?? "allowlist"; - if (policy === "disabled") { - return { allowed: false, reason: "groupPolicy=disabled" }; - } - - // In open mode, unconfigured channels are allowed (mention-gated) but explicit - // per-channel/wildcard disables still apply. - if (policy === "allowlist") { - if (!params.groupMatch.hasConfiguredGroups) { - return { - allowed: false, - reason: "groupPolicy=allowlist and no groups configured", - }; - } - if (!params.groupMatch.allowed) { - return { allowed: false, reason: "not allowlisted" }; - } - } - - if ( - params.groupMatch.groupConfig?.enabled === false || - params.groupMatch.wildcardConfig?.enabled === false - ) { - return { allowed: false, reason: "disabled" }; - } - - return { allowed: true, reason: policy === "open" ? "open" : "allowlisted" }; -} - export function resolveIrcRequireMention(params: { groupConfig?: IrcChannelConfig; wildcardConfig?: IrcChannelConfig; @@ -116,54 +76,3 @@ export function resolveIrcRequireMention(params: { } return true; } - -export function resolveIrcMentionGate(params: { - isGroup: boolean; - requireMention: boolean; - wasMentioned: boolean; - hasControlCommand: boolean; - allowTextCommands: boolean; - commandAuthorized: boolean; -}): { shouldSkip: boolean; reason: string } { - if (!params.isGroup) { - return { shouldSkip: false, reason: "direct" }; - } - if (!params.requireMention) { - return { shouldSkip: false, reason: "mention-not-required" }; - } - if (params.wasMentioned) { - return { shouldSkip: false, reason: "mentioned" }; - } - if (params.hasControlCommand && params.allowTextCommands && params.commandAuthorized) { - return { shouldSkip: false, reason: "authorized-command" }; - } - return { shouldSkip: true, reason: "missing-mention" }; -} - -export function resolveIrcGroupSenderAllowed(params: { - groupPolicy: IrcAccountConfig["groupPolicy"]; - message: IrcInboundMessage; - outerAllowFrom: string[]; - innerAllowFrom: string[]; - allowNameMatching?: boolean; -}): boolean { - const policy = params.groupPolicy ?? "allowlist"; - const inner = normalizeIrcAllowlist(params.innerAllowFrom); - const outer = normalizeIrcAllowlist(params.outerAllowFrom); - - if (inner.length > 0) { - return resolveIrcAllowlistMatch({ - allowFrom: inner, - message: params.message, - allowNameMatching: params.allowNameMatching, - }).allowed; - } - if (outer.length > 0) { - return resolveIrcAllowlistMatch({ - allowFrom: outer, - message: params.message, - allowNameMatching: params.allowNameMatching, - }).allowed; - } - return policy === "open"; -} diff --git a/extensions/irc/src/runtime-api.ts b/extensions/irc/src/runtime-api.ts index 5f67497d5b3..234f2cb8554 100644 --- a/extensions/irc/src/runtime-api.ts +++ b/extensions/irc/src/runtime-api.ts @@ -24,11 +24,7 @@ export { } from "openclaw/plugin-sdk/channel-status"; export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; export { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; -export { - readStoreAllowFromForDmPolicy, - resolveEffectiveAllowFromLists, -} from "openclaw/plugin-sdk/channel-policy"; -export { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth"; +export { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth-native"; export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message"; export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking"; export { diff --git a/extensions/line/runtime-api.ts b/extensions/line/runtime-api.ts index b40883ecbc0..f7c14780560 100644 --- a/extensions/line/runtime-api.ts +++ b/extensions/line/runtime-api.ts @@ -27,12 +27,7 @@ export { splitSetupEntries, } from "openclaw/plugin-sdk/setup"; export { setLineRuntime } from "./src/runtime.js"; -export { - firstDefined, - isSenderAllowed, - normalizeAllowFrom, - normalizeDmAllowFromWithStore, -} from "./src/bot-access.js"; +export { firstDefined, normalizeAllowFrom } from "./src/bot-access.js"; export { downloadLineMedia } from "./src/download.js"; export { probeLineBot } from "./src/probe.js"; export { buildTemplateMessageFromPayload } from "./src/template-messages.js"; diff --git a/extensions/line/src/bot-access.ts b/extensions/line/src/bot-access.ts index 9a4accf61cc..3e78f92a60b 100644 --- a/extensions/line/src/bot-access.ts +++ b/extensions/line/src/bot-access.ts @@ -1,8 +1,4 @@ -import { - firstDefined, - isSenderIdAllowed, - mergeDmAllowFromSources, -} from "openclaw/plugin-sdk/allow-from"; +import { firstDefined } from "openclaw/plugin-sdk/allow-from"; export type NormalizedAllowFrom = { entries: string[]; @@ -10,7 +6,7 @@ export type NormalizedAllowFrom = { hasEntries: boolean; }; -function normalizeAllowEntry(value: string | number): string { +export function normalizeLineAllowEntry(value: string | number): string { const trimmed = String(value).trim(); if (!trimmed) { return ""; @@ -22,7 +18,7 @@ function normalizeAllowEntry(value: string | number): string { } export const normalizeAllowFrom = (list?: Array): NormalizedAllowFrom => { - const entries = (list ?? []).map((value) => normalizeAllowEntry(value)).filter(Boolean); + const entries = (list ?? []).map((value) => normalizeLineAllowEntry(value)).filter(Boolean); const hasWildcard = entries.includes("*"); return { entries, @@ -31,18 +27,4 @@ export const normalizeAllowFrom = (list?: Array): NormalizedAll }; }; -export const normalizeDmAllowFromWithStore = (params: { - allowFrom?: Array; - storeAllowFrom?: string[]; - dmPolicy?: string; -}): NormalizedAllowFrom => normalizeAllowFrom(mergeDmAllowFromSources(params)); - -export const isSenderAllowed = (params: { - allow: NormalizedAllowFrom; - senderId?: string; -}): boolean => { - const { allow, senderId } = params; - return isSenderIdAllowed(allow, senderId, false); -}; - export { firstDefined }; diff --git a/extensions/line/src/bot-handlers.test.ts b/extensions/line/src/bot-handlers.test.ts index e821d792022..fb32852a80e 100644 --- a/extensions/line/src/bot-handlers.test.ts +++ b/extensions/line/src/bot-handlers.test.ts @@ -11,76 +11,6 @@ type PostbackEvent = webhook.PostbackEvent; vi.mock("openclaw/plugin-sdk/channel-inbound", () => ({ buildMentionRegexes: () => [], matchesMentionPatterns: () => false, - resolveInboundMentionDecision: (params: { - facts?: { - canDetectMention: boolean; - wasMentioned: boolean; - hasAnyMention?: boolean; - }; - policy?: { - isGroup: boolean; - requireMention: boolean; - allowTextCommands: boolean; - hasControlCommand: boolean; - commandAuthorized: boolean; - }; - isGroup?: boolean; - requireMention?: boolean; - canDetectMention?: boolean; - wasMentioned?: boolean; - hasAnyMention?: boolean; - allowTextCommands?: boolean; - hasControlCommand?: boolean; - commandAuthorized?: boolean; - }) => { - const facts = - "facts" in params && params.facts - ? params.facts - : { - canDetectMention: Boolean(params.canDetectMention), - wasMentioned: Boolean(params.wasMentioned), - hasAnyMention: params.hasAnyMention, - }; - const policy = - "policy" in params && params.policy - ? params.policy - : { - isGroup: Boolean(params.isGroup), - requireMention: Boolean(params.requireMention), - allowTextCommands: Boolean(params.allowTextCommands), - hasControlCommand: Boolean(params.hasControlCommand), - commandAuthorized: Boolean(params.commandAuthorized), - }; - return { - effectiveWasMentioned: - facts.wasMentioned || - (policy.allowTextCommands && - policy.hasControlCommand && - policy.commandAuthorized && - !facts.hasAnyMention), - shouldSkip: - policy.isGroup && - policy.requireMention && - facts.canDetectMention && - !facts.wasMentioned && - !( - policy.allowTextCommands && - policy.hasControlCommand && - policy.commandAuthorized && - !facts.hasAnyMention - ), - shouldBypassMention: - policy.isGroup && - policy.requireMention && - !facts.wasMentioned && - !facts.hasAnyMention && - policy.allowTextCommands && - policy.hasControlCommand && - policy.commandAuthorized, - implicitMention: false, - matchedImplicitMentionKinds: [], - }; - }, })); vi.mock("openclaw/plugin-sdk/channel-pairing", () => ({ createChannelPairingChallengeIssuer: @@ -122,36 +52,6 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({ danger: (text: string) => text, logVerbose: () => {}, })); -vi.mock("openclaw/plugin-sdk/group-access", () => ({ - evaluateMatchedGroupAccessForPolicy: ({ - groupPolicy, - hasMatchInput, - allowlistConfigured, - allowlistMatched, - }: { - groupPolicy: string; - hasMatchInput: boolean; - allowlistConfigured: boolean; - allowlistMatched: boolean; - }) => { - if (groupPolicy === "disabled") { - return { allowed: false, reason: "disabled" }; - } - if (groupPolicy !== "allowlist") { - return { allowed: true, reason: null }; - } - if (!hasMatchInput) { - return { allowed: false, reason: "missing_match_input" }; - } - if (!allowlistConfigured) { - return { allowed: false, reason: "empty_allowlist" }; - } - if (!allowlistMatched) { - return { allowed: false, reason: "not_allowlisted" }; - } - return { allowed: true, reason: null }; - }, -})); vi.mock("openclaw/plugin-sdk/reply-history", () => ({ DEFAULT_GROUP_HISTORY_LIMIT: 20, clearHistoryEntriesIfEnabled: ({ @@ -285,17 +185,25 @@ function createLineWebhookTestContext(params: { processMessage: LineWebhookContext["processMessage"]; groupPolicy?: LineAccountConfig["groupPolicy"]; dmPolicy?: LineAccountConfig["dmPolicy"]; + allowFrom?: LineAccountConfig["allowFrom"]; + groupAllowFrom?: LineAccountConfig["groupAllowFrom"]; requireMention?: boolean; groupHistories?: Map; replayCache?: ReturnType; + accessGroups?: Record }>; }): Parameters[1] { + const allowFrom = params.allowFrom ?? (params.dmPolicy === "open" ? ["*"] : undefined); const lineConfig = { ...(params.groupPolicy ? { groupPolicy: params.groupPolicy } : {}), ...(params.dmPolicy ? { dmPolicy: params.dmPolicy } : {}), - ...(params.dmPolicy === "open" ? { allowFrom: ["*"] } : {}), + ...(allowFrom ? { allowFrom } : {}), + ...(params.groupAllowFrom ? { groupAllowFrom: params.groupAllowFrom } : {}), }; return { - cfg: { channels: { line: lineConfig } }, + cfg: { + ...(params.accessGroups ? { accessGroups: params.accessGroups } : {}), + channels: { line: lineConfig }, + }, account: { accountId: "default", enabled: true, @@ -379,7 +287,6 @@ describe("handleLineWebhookEvents", () => { vi.doUnmock("openclaw/plugin-sdk/command-auth"); vi.doUnmock("openclaw/plugin-sdk/runtime-group-policy"); vi.doUnmock("openclaw/plugin-sdk/runtime-env"); - vi.doUnmock("openclaw/plugin-sdk/group-access"); vi.doUnmock("openclaw/plugin-sdk/reply-history"); vi.doUnmock("openclaw/plugin-sdk/routing"); vi.doUnmock("openclaw/plugin-sdk/conversation-runtime"); @@ -491,8 +398,56 @@ describe("handleLineWebhookEvents", () => { expect(processMessage).toHaveBeenCalledTimes(1); }); - it("blocks group sender not in groupAllowFrom even when sender is paired in DM store", async () => { - readAllowFromStoreMock.mockResolvedValueOnce(["user-store"]); + it("authorizes group control commands through shared access groups", async () => { + const processMessage = vi.fn(); + await handleLineWebhookEvents( + [ + createTestMessageEvent({ + message: { id: "m3a", type: "text", text: "!status", quoteToken: "quote-token" }, + source: { type: "group", groupId: "group-1", userId: "user-ag" }, + webhookEventId: "evt-3a", + }), + ], + createLineWebhookTestContext({ + processMessage, + groupPolicy: "allowlist", + groupAllowFrom: ["accessGroup:line-operators"], + requireMention: true, + accessGroups: { + "line-operators": { + type: "message.senders", + members: { line: ["user-ag"] }, + }, + }, + }), + ); + + expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1); + expect(processMessage).toHaveBeenCalledTimes(1); + }); + + it("blocks unauthorized group control commands even when an open group sender is allowed", async () => { + const processMessage = vi.fn(); + await handleLineWebhookEvents( + [ + createTestMessageEvent({ + message: { id: "m3b", type: "text", text: "!status", quoteToken: "quote-token" }, + source: { type: "group", groupId: "group-1", userId: "user-open" }, + webhookEventId: "evt-3b", + }), + ], + createLineWebhookTestContext({ + processMessage, + groupPolicy: "open", + requireMention: true, + }), + ); + + expect(buildLineMessageContextMock).not.toHaveBeenCalled(); + expect(processMessage).not.toHaveBeenCalled(); + }); + + it("blocks group sender not in groupAllowFrom without consulting the DM pairing store", async () => { const processMessage = vi.fn(); const event = { type: "message", @@ -524,7 +479,7 @@ describe("handleLineWebhookEvents", () => { expect(processMessage).not.toHaveBeenCalled(); expect(buildLineMessageContextMock).not.toHaveBeenCalled(); - expect(readAllowFromStoreMock).toHaveBeenCalledWith("line", undefined, "default"); + expect(readAllowFromStoreMock).not.toHaveBeenCalled(); }); it("blocks group messages without sender id when groupPolicy is allowlist", async () => { @@ -562,7 +517,6 @@ describe("handleLineWebhookEvents", () => { }); it("does not authorize group messages from DM pairing-store entries when group allowlist is empty", async () => { - readAllowFromStoreMock.mockResolvedValueOnce(["user-5"]); const processMessage = vi.fn(); await expectGroupMessageBlocked({ processMessage, @@ -591,6 +545,7 @@ describe("handleLineWebhookEvents", () => { processMessage, }, }); + expect(readAllowFromStoreMock).not.toHaveBeenCalled(); }); it("blocks group messages when wildcard group config disables groups", async () => { diff --git a/extensions/line/src/bot-handlers.ts b/extensions/line/src/bot-handlers.ts index c59bf177f16..ab27dc77679 100644 --- a/extensions/line/src/bot-handlers.ts +++ b/extensions/line/src/bot-handlers.ts @@ -1,18 +1,14 @@ import type { webhook } from "@line/bot-sdk"; -import { - buildMentionRegexes, - matchesMentionPatterns, - resolveInboundMentionDecision, -} from "openclaw/plugin-sdk/channel-inbound"; +import { buildMentionRegexes, matchesMentionPatterns } from "openclaw/plugin-sdk/channel-inbound"; +import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime"; import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing"; -import { hasControlCommand, resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import { shouldComputeCommandAuthorized } from "openclaw/plugin-sdk/command-auth-native"; +import type { GroupPolicy, OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { readChannelAllowFromStore, resolvePairingIdLabel, upsertChannelPairingRequest, } from "openclaw/plugin-sdk/conversation-runtime"; -import { evaluateMatchedGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access"; import { createClaimableDedupe, type ClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe"; import { DEFAULT_GROUP_HISTORY_LIMIT, @@ -28,13 +24,8 @@ import { resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk/runtime-group-policy"; -import { - firstDefined, - isSenderAllowed, - normalizeAllowFrom, - normalizeDmAllowFromWithStore, - type NormalizedAllowFrom, -} from "./bot-access.js"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/text-runtime"; +import { firstDefined, normalizeLineAllowEntry } from "./bot-access.js"; import { buildLineMessageContext, buildLinePostbackContext, @@ -87,6 +78,10 @@ const LINE_WEBHOOK_REPLAY_WINDOW_MS = 10 * 60 * 1000; const LINE_WEBHOOK_REPLAY_MAX_ENTRIES = 4096; export type LineWebhookReplayCache = ClaimableDedupe; +function normalizeLineIngressEntry(value: string): string | null { + return normalizeLineAllowEntry(value) || null; +} + export class LineRetryableWebhookError extends Error { constructor(message: string, options?: ErrorOptions) { super(message, options); @@ -234,41 +229,100 @@ async function sendLinePairingReply(params: { async function shouldProcessLineEvent( event: MessageEvent | PostbackEvent, context: LineHandlerContext, -): Promise<{ allowed: boolean; commandAuthorized: boolean }> { - const denied = { allowed: false, commandAuthorized: false }; +) { const { cfg, account } = context; const { userId, groupId, roomId, isGroup } = getLineSourceInfo(event.source); const senderId = userId ?? ""; - const dmPolicy = account.config.dmPolicy ?? "pairing"; - - const storeAllowFrom = await readChannelAllowFromStore( - "line", - undefined, - account.accountId, - ).catch(() => []); - const effectiveDmAllow = normalizeDmAllowFromWithStore({ - allowFrom: account.config.allowFrom, - storeAllowFrom, - dmPolicy, - }); const groupConfig = resolveLineGroupConfig({ config: account.config, groupId, roomId }); - const groupAllowOverride = groupConfig?.allowFrom; - const fallbackGroupAllowFrom = account.config.allowFrom?.length - ? account.config.allowFrom - : undefined; - const groupAllowFrom = firstDefined( - groupAllowOverride, - account.config.groupAllowFrom, - fallbackGroupAllowFrom, - ); - const effectiveGroupAllow = normalizeAllowFrom(groupAllowFrom); - const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); - const { groupPolicy, providerMissingFallbackApplied } = + const rawText = resolveEventRawText(event); + const requireMention = isGroup ? groupConfig?.requireMention !== false : false; + const dmPolicy = account.config.dmPolicy ?? "pairing"; + const { groupPolicy: runtimeGroupPolicy, providerMissingFallbackApplied } = resolveAllowlistProviderRuntimeGroupPolicy({ providerConfigPresent: cfg.channels?.line !== undefined, groupPolicy: account.config.groupPolicy, - defaultGroupPolicy, + defaultGroupPolicy: resolveDefaultGroupPolicy(cfg), }); + const groupPolicy: GroupPolicy = + runtimeGroupPolicy === "disabled" + ? "disabled" + : groupConfig?.allowFrom !== undefined + ? "allowlist" + : runtimeGroupPolicy; + const groupAllowFrom = normalizeStringEntries( + firstDefined( + groupConfig?.allowFrom, + account.config.groupAllowFrom, + account.config.allowFrom?.length ? account.config.allowFrom : undefined, + ), + ); + const mentionFacts = (() => { + if (!isGroup || event.type !== "message") { + return { canDetectMention: false, wasMentioned: false, hasAnyMention: false }; + } + const peerId = groupId ?? roomId ?? userId ?? "unknown"; + const { agentId } = resolveAgentRoute({ + cfg, + channel: "line", + accountId: account.accountId, + peer: { kind: "group", id: peerId }, + }); + const mentionRegexes = buildMentionRegexes(cfg, agentId); + const wasMentionedByNative = isLineBotMentioned(event.message); + const wasMentionedByPattern = + event.message.type === "text" ? matchesMentionPatterns(rawText, mentionRegexes) : false; + return { + canDetectMention: event.message.type === "text", + wasMentioned: wasMentionedByNative || wasMentionedByPattern, + hasAnyMention: hasAnyLineMention(event.message), + }; + })(); + const access = await resolveStableChannelMessageIngress({ + channelId: "line", + accountId: account.accountId, + identity: { + key: "line-user-id", + normalize: normalizeLineIngressEntry, + sensitivity: "pii", + entryIdPrefix: "line-entry", + }, + cfg, + readStoreAllowFrom: async () => + await readChannelAllowFromStore("line", undefined, account.accountId), + subject: { stableId: senderId }, + conversation: { + kind: isGroup ? "group" : "direct", + id: (groupId ?? roomId ?? senderId) || "unknown", + }, + ...(isGroup && groupConfig?.enabled === false + ? { route: { id: "line:group-config", enabled: false } } + : {}), + mentionFacts: + isGroup && event.type === "message" + ? { + canDetectMention: mentionFacts.canDetectMention, + wasMentioned: mentionFacts.wasMentioned, + hasAnyMention: mentionFacts.hasAnyMention, + implicitMentionKinds: [], + } + : undefined, + event: { kind: event.type === "postback" ? "postback" : "message" }, + dmPolicy, + groupPolicy, + policy: { + groupAllowFromFallbackToAllowFrom: false, + activation: { + requireMention: isGroup && event.type === "message" && requireMention, + allowTextCommands: true, + }, + }, + allowFrom: normalizeStringEntries(account.config.allowFrom), + groupAllowFrom, + command: { + hasControlCommand: shouldComputeCommandAuthorized(rawText, cfg), + groupOwnerAllowFrom: "none", + }, + }); warnMissingProviderGroupPolicyFallbackOnce({ providerMissingFallbackApplied, providerKey: "line", @@ -276,92 +330,71 @@ async function shouldProcessLineEvent( log: (message) => logVerbose(message), }); + if ( + access.senderAccess.decision === "allow" && + (access.ingress.admission === "dispatch" || + access.ingress.admission === "observe" || + access.ingress.admission === "skip") + ) { + return access; + } + + if (access.senderAccess.decision === "allow") { + logVerbose(`Blocked line event (${access.ingress.reasonCode})`); + return null; + } + if (isGroup) { if (groupConfig?.enabled === false) { logVerbose(`Blocked line group ${groupId ?? roomId ?? "unknown"} (group disabled)`); - return denied; + return null; } - if (groupAllowOverride !== undefined) { + if (groupConfig?.allowFrom !== undefined) { if (!senderId) { logVerbose("Blocked line group message (group allowFrom override, no sender ID)"); - return denied; + return null; } - if (!isSenderAllowed({ allow: effectiveGroupAllow, senderId })) { + if (access.senderAccess.reasonCode !== "group_policy_allowed") { logVerbose(`Blocked line group sender ${senderId} (group allowFrom override)`); - return denied; + return null; } } - const senderGroupAccess = evaluateMatchedGroupAccessForPolicy({ - groupPolicy, - requireMatchInput: true, - hasMatchInput: Boolean(senderId), - allowlistConfigured: effectiveGroupAllow.entries.length > 0, - allowlistMatched: - Boolean(senderId) && - isSenderAllowed({ - allow: effectiveGroupAllow, - senderId, - }), - }); - if (!senderGroupAccess.allowed && senderGroupAccess.reason === "disabled") { + if (access.senderAccess.reasonCode === "group_policy_disabled") { logVerbose("Blocked line group message (groupPolicy: disabled)"); - return denied; - } - if (!senderGroupAccess.allowed && senderGroupAccess.reason === "missing_match_input") { + } else if (!senderId && groupPolicy === "allowlist") { logVerbose("Blocked line group message (no sender ID, groupPolicy: allowlist)"); - return denied; - } - if (!senderGroupAccess.allowed && senderGroupAccess.reason === "empty_allowlist") { + } else if (access.senderAccess.reasonCode === "group_policy_empty_allowlist") { logVerbose("Blocked line group message (groupPolicy: allowlist, no groupAllowFrom)"); - return denied; - } - if (!senderGroupAccess.allowed && senderGroupAccess.reason === "not_allowlisted") { - logVerbose(`Blocked line group message from ${senderId} (groupPolicy: allowlist)`); - return denied; - } - return { - allowed: true, - commandAuthorized: resolveLineCommandAuthorized({ - cfg, - event, - senderId, - allow: effectiveGroupAllow, - }), - }; - } - - if (dmPolicy === "disabled") { - logVerbose("Blocked line sender (dmPolicy: disabled)"); - return denied; - } - - const dmAllowed = isSenderAllowed({ allow: effectiveDmAllow, senderId }); - if (!dmAllowed) { - if (dmPolicy === "pairing") { - if (!senderId) { - logVerbose("Blocked line sender (dmPolicy: pairing, no sender ID)"); - return denied; - } - await sendLinePairingReply({ - senderId, - replyToken: "replyToken" in event ? event.replyToken : undefined, - context, - }); } else { - logVerbose(`Blocked line sender ${senderId || "unknown"} (dmPolicy: ${dmPolicy})`); + logVerbose(`Blocked line group message from ${senderId} (groupPolicy: allowlist)`); } - return denied; + return null; } - return { - allowed: true, - commandAuthorized: resolveLineCommandAuthorized({ - cfg, - event, + if (access.senderAccess.reasonCode === "dm_policy_disabled") { + logVerbose("Blocked line sender (dmPolicy: disabled)"); + return null; + } + + if (access.senderAccess.decision === "pairing") { + if (!senderId) { + logVerbose("Blocked line sender (dmPolicy: pairing, no sender ID)"); + return null; + } + await sendLinePairingReply({ senderId, - allow: effectiveDmAllow, - }), - }; + replyToken: "replyToken" in event ? event.replyToken : undefined, + context, + }); + return null; + } + + logVerbose( + `Blocked line sender ${senderId || "unknown"} (dmPolicy: ${ + account.config.dmPolicy ?? "pairing" + })`, + ); + return null; } function getLineMentionees( @@ -400,87 +433,35 @@ function resolveEventRawText(event: MessageEvent | PostbackEvent): string { return ""; } -function resolveLineCommandAuthorized(params: { - cfg: OpenClawConfig; - event: MessageEvent | PostbackEvent; - senderId?: string; - allow: NormalizedAllowFrom; -}): boolean { - const senderAllowedForCommands = isSenderAllowed({ - allow: params.allow, - senderId: params.senderId, - }); - const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; - const rawText = resolveEventRawText(params.event); - const commandGate = resolveControlCommandGate({ - useAccessGroups, - authorizers: [{ configured: params.allow.hasEntries, allowed: senderAllowedForCommands }], - allowTextCommands: true, - hasControlCommand: hasControlCommand(rawText, params.cfg), - }); - return commandGate.commandAuthorized; -} - async function handleMessageEvent(event: MessageEvent, context: LineHandlerContext): Promise { const { cfg, account, runtime, mediaMaxBytes, processMessage } = context; const message = event.message; const decision = await shouldProcessLineEvent(event, context); - if (!decision.allowed) { + if (!decision) { return; } const { isGroup, groupId, roomId } = getLineSourceInfo(event.source); - if (isGroup) { - const groupConfig = resolveLineGroupConfig({ config: account.config, groupId, roomId }); - const requireMention = groupConfig?.requireMention !== false; + if (isGroup && decision.activationAccess.shouldSkip) { const rawText = message.type === "text" ? message.text : ""; const sourceInfo = getLineSourceInfo(event.source); - const peerId = groupId ?? roomId ?? sourceInfo.userId ?? "unknown"; - const { agentId } = resolveAgentRoute({ - cfg, - channel: "line", - accountId: account.accountId, - peer: { kind: "group", id: peerId }, - }); - const mentionRegexes = buildMentionRegexes(cfg, agentId); - const wasMentionedByNative = isLineBotMentioned(message); - const wasMentionedByPattern = - message.type === "text" ? matchesMentionPatterns(rawText, mentionRegexes) : false; - const wasMentioned = wasMentionedByNative || wasMentionedByPattern; - const mentionDecision = resolveInboundMentionDecision({ - facts: { - canDetectMention: message.type === "text", - wasMentioned, - hasAnyMention: hasAnyLineMention(message), - implicitMentionKinds: [], - }, - policy: { - isGroup: true, - requireMention, - allowTextCommands: true, - hasControlCommand: hasControlCommand(rawText, cfg), - commandAuthorized: decision.commandAuthorized, - }, - }); - if (mentionDecision.shouldSkip) { - logVerbose(`line: skipping group message (requireMention, not mentioned)`); - const historyKey = groupId ?? roomId; - const senderId = sourceInfo.userId ?? "unknown"; - if (historyKey && context.groupHistories) { - recordPendingHistoryEntryIfEnabled({ - historyMap: context.groupHistories, - historyKey, - limit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT, - entry: { - sender: `user:${senderId}`, - body: rawText || `<${message.type}>`, - timestamp: event.timestamp, - }, - }); - } - return; + logVerbose(`line: skipping group message (requireMention, not mentioned)`); + const historyKey = groupId ?? roomId; + const senderId = sourceInfo.userId ?? "unknown"; + if (historyKey && context.groupHistories) { + recordPendingHistoryEntryIfEnabled({ + historyMap: context.groupHistories, + historyKey, + limit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT, + entry: { + sender: `user:${senderId}`, + body: rawText || `<${message.type}>`, + timestamp: event.timestamp, + }, + }); } + return; } const allMedia: MediaRef[] = []; @@ -507,7 +488,7 @@ async function handleMessageEvent(event: MessageEvent, context: LineHandlerConte allMedia, cfg, account, - commandAuthorized: decision.commandAuthorized, + commandAuthorized: decision.commandAccess.authorized, groupHistories: context.groupHistories, historyLimit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT, }); @@ -562,7 +543,7 @@ async function handlePostbackEvent( logVerbose(`line: received postback: ${data}`); const decision = await shouldProcessLineEvent(event, context); - if (!decision.allowed) { + if (!decision) { return; } @@ -570,7 +551,7 @@ async function handlePostbackEvent( event, cfg: context.cfg, account: context.account, - commandAuthorized: decision.commandAuthorized, + commandAuthorized: decision.commandAccess.authorized, }); if (!postbackContext) { return; diff --git a/extensions/matrix/src/matrix/monitor/access-state.test.ts b/extensions/matrix/src/matrix/monitor/access-state.test.ts index 2254e8eaffe..a7c88129a65 100644 --- a/extensions/matrix/src/matrix/monitor/access-state.test.ts +++ b/extensions/matrix/src/matrix/monitor/access-state.test.ts @@ -1,35 +1,28 @@ import { describe, expect, it } from "vitest"; -import { resolveMatrixMonitorAccessState } from "./access-state.js"; +import { + resolveMatrixMonitorAccessState, + resolveMatrixMonitorCommandAccess, +} from "./access-state.js"; describe("resolveMatrixMonitorAccessState", () => { - it("normalizes effective allowlists once and exposes reusable matches", () => { - const state = resolveMatrixMonitorAccessState({ + it("normalizes group allowlists and uses shared ingress matching", async () => { + const state = await resolveMatrixMonitorAccessState({ allowFrom: ["matrix:@Alice:Example.org"], storeAllowFrom: ["user:@bob:example.org"], groupAllowFrom: ["@Carol:Example.org"], roomUsers: ["user:@Dana:Example.org"], senderId: "@dana:example.org", isRoom: true, + groupPolicy: "allowlist", }); - expect(state.effectiveAllowFrom).toEqual([ - "matrix:@alice:example.org", - "user:@bob:example.org", - ]); expect(state.effectiveGroupAllowFrom).toEqual(["@carol:example.org"]); expect(state.effectiveRoomUsers).toEqual(["user:@dana:example.org"]); - expect(state.directAllowMatch.allowed).toBe(false); - expect(state.roomUserMatch?.allowed).toBe(true); - expect(state.groupAllowMatch?.allowed).toBe(false); - expect(state.commandAuthorizers).toEqual([ - { configured: false, allowed: false }, - { configured: true, allowed: true }, - { configured: true, allowed: false }, - ]); + expect(state.messageIngress.ingress.decision).toBe("allow"); }); - it("does not let DM pairing-store entries authorize room control commands", () => { - const state = resolveMatrixMonitorAccessState({ + it("does not let DM pairing-store entries authorize room control commands", async () => { + const state = await resolveMatrixMonitorAccessState({ allowFrom: [], storeAllowFrom: ["@attacker:example.org"], groupAllowFrom: [], @@ -38,17 +31,20 @@ describe("resolveMatrixMonitorAccessState", () => { isRoom: true, }); - expect(state.effectiveAllowFrom).toEqual(["@attacker:example.org"]); - expect(state.directAllowMatch.allowed).toBe(true); - expect(state.commandAuthorizers).toEqual([ - { configured: false, allowed: false }, - { configured: false, allowed: false }, - { configured: false, allowed: false }, - ]); + expect( + await resolveMatrixMonitorCommandAccess(state, { + useAccessGroups: true, + allowTextCommands: true, + hasControlCommand: true, + }), + ).toMatchObject({ + authorized: false, + shouldBlockControlCommand: true, + }); }); - it("does not let pairing-store entries authorize open DMs without wildcard", () => { - const state = resolveMatrixMonitorAccessState({ + it("does not let pairing-store entries authorize open DMs without wildcard", async () => { + const state = await resolveMatrixMonitorAccessState({ allowFrom: [], storeAllowFrom: ["@alice:example.org"], dmPolicy: "open", @@ -58,12 +54,13 @@ describe("resolveMatrixMonitorAccessState", () => { isRoom: false, }); - expect(state.effectiveAllowFrom).toStrictEqual([]); - expect(state.directAllowMatch.allowed).toBe(false); + expect(state.messageIngress.senderAccess.effectiveAllowFrom).toEqual([]); + expect(state.messageIngress.senderAccess.decision).toBe("block"); + expect(state.messageIngress.ingress.reasonCode).toBe("dm_policy_not_allowlisted"); }); - it("does not let configured DM allowFrom authorize room control commands", () => { - const state = resolveMatrixMonitorAccessState({ + it("does not let configured DM allowFrom authorize room control commands", async () => { + const state = await resolveMatrixMonitorAccessState({ allowFrom: ["@owner:example.org"], storeAllowFrom: [], groupAllowFrom: ["@admin:example.org"], @@ -72,16 +69,64 @@ describe("resolveMatrixMonitorAccessState", () => { isRoom: true, }); - expect(state.directAllowMatch.allowed).toBe(true); - expect(state.commandAuthorizers).toEqual([ - { configured: false, allowed: false }, - { configured: false, allowed: false }, - { configured: true, allowed: false }, - ]); + expect( + await resolveMatrixMonitorCommandAccess(state, { + useAccessGroups: true, + allowTextCommands: true, + hasControlCommand: true, + }), + ).toMatchObject({ + authorized: false, + shouldBlockControlCommand: true, + }); }); - it("keeps room-user matching disabled for dm traffic", () => { - const state = resolveMatrixMonitorAccessState({ + it("authorizes room control commands through the shared ingress command gate", async () => { + const state = await resolveMatrixMonitorAccessState({ + allowFrom: [], + storeAllowFrom: [], + groupAllowFrom: ["@admin:example.org"], + roomUsers: [], + senderId: "@admin:example.org", + isRoom: true, + }); + + expect( + await resolveMatrixMonitorCommandAccess(state, { + useAccessGroups: true, + allowTextCommands: true, + hasControlCommand: true, + }), + ).toMatchObject({ + authorized: true, + shouldBlockControlCommand: false, + }); + }); + + it("keeps command allow mode when access groups are disabled", async () => { + const state = await resolveMatrixMonitorAccessState({ + allowFrom: [], + storeAllowFrom: [], + groupAllowFrom: [], + roomUsers: [], + senderId: "@admin:example.org", + isRoom: true, + }); + + expect( + await resolveMatrixMonitorCommandAccess(state, { + useAccessGroups: false, + allowTextCommands: true, + hasControlCommand: true, + }), + ).toMatchObject({ + authorized: true, + shouldBlockControlCommand: false, + }); + }); + + it("keeps room-user allowlists out of dm traffic", async () => { + const state = await resolveMatrixMonitorAccessState({ allowFrom: [], storeAllowFrom: [], groupAllowFrom: ["@carol:example.org"], @@ -90,8 +135,55 @@ describe("resolveMatrixMonitorAccessState", () => { isRoom: false, }); - expect(state.roomUserMatch).toBeNull(); - expect(state.commandAuthorizers[1]).toEqual({ configured: true, allowed: false }); - expect(state.commandAuthorizers[2]).toEqual({ configured: true, allowed: false }); + expect(state.messageIngress.senderAccess.decision).toBe("pairing"); + expect( + await resolveMatrixMonitorCommandAccess(state, { + useAccessGroups: true, + allowTextCommands: true, + hasControlCommand: true, + }), + ).toMatchObject({ + authorized: false, + shouldBlockControlCommand: true, + }); + }); + + it("uses the shared ingress decision for room user sender gates", async () => { + const blocked = await resolveMatrixMonitorAccessState({ + allowFrom: [], + storeAllowFrom: [], + groupAllowFrom: [], + roomUsers: ["@allowed:example.org"], + senderId: "@blocked:example.org", + isRoom: true, + groupPolicy: "open", + }); + const allowed = await resolveMatrixMonitorAccessState({ + allowFrom: [], + storeAllowFrom: [], + groupAllowFrom: [], + roomUsers: ["@allowed:example.org"], + senderId: "@allowed:example.org", + isRoom: true, + groupPolicy: "open", + }); + + expect(blocked.messageIngress.ingress.reasonCode).toBe("group_policy_not_allowlisted"); + expect(allowed.messageIngress.ingress.decision).toBe("allow"); + }); + + it("keeps route-only room allowlists open when no sender allowlist exists", async () => { + const state = await resolveMatrixMonitorAccessState({ + allowFrom: [], + storeAllowFrom: [], + groupAllowFrom: [], + roomUsers: [], + senderId: "@sender:example.org", + isRoom: true, + groupPolicy: "allowlist", + }); + + expect(state.messageIngress.ingress.decision).toBe("allow"); + expect(state.messageIngress.ingress.reasonCode).toBe("activation_allowed"); }); }); diff --git a/extensions/matrix/src/matrix/monitor/access-state.ts b/extensions/matrix/src/matrix/monitor/access-state.ts index ca48f4e10c9..85bd2d364e8 100644 --- a/extensions/matrix/src/matrix/monitor/access-state.ts +++ b/extensions/matrix/src/matrix/monitor/access-state.ts @@ -1,96 +1,145 @@ -import { mergeDmAllowFromSources } from "openclaw/plugin-sdk/allow-from"; +import { + createChannelIngressResolver, + defineStableChannelIngressIdentity, + type ResolvedChannelMessageIngress, +} from "openclaw/plugin-sdk/channel-ingress-runtime"; import { normalizeMatrixAllowList, resolveMatrixAllowListMatch } from "./allowlist.js"; -type MatrixCommandAuthorizer = { - configured: boolean; - allowed: boolean; -}; - -type MatrixMonitorAllowListMatch = { - allowed: boolean; - matchKey?: string; - matchSource?: "wildcard" | "id" | "prefixed-id" | "prefixed-user"; -}; - type MatrixMonitorAccessState = { - effectiveAllowFrom: string[]; effectiveGroupAllowFrom: string[]; effectiveRoomUsers: string[]; - groupAllowConfigured: boolean; - directAllowMatch: MatrixMonitorAllowListMatch; - roomUserMatch: MatrixMonitorAllowListMatch | null; - groupAllowMatch: MatrixMonitorAllowListMatch | null; - commandAuthorizers: [MatrixCommandAuthorizer, MatrixCommandAuthorizer, MatrixCommandAuthorizer]; + messageIngress: ResolvedChannelMessageIngress; + accountId: string; + senderId: string; + isRoom: boolean; }; -export function resolveMatrixMonitorAccessState(params: { +function normalizeMatrixEntry(raw?: string | null): string | null { + return normalizeMatrixAllowList([raw ?? ""])[0] ?? null; +} + +const matrixIngressIdentity = defineStableChannelIngressIdentity({ + key: "sender-id", + normalize: normalizeMatrixEntry, + matchEntry({ subject, entry }) { + const senderId = subject.identifiers[0]?.value; + return ( + entry.value === "*" || + resolveMatrixAllowListMatch({ + allowList: [entry.value], + userId: senderId ?? "", + }).allowed + ); + }, +}); + +function resolveMatrixGroupIngress(params: { + groupPolicy: "open" | "allowlist" | "disabled"; + effectiveGroupAllowFrom: string[]; + effectiveRoomUsers: string[]; +}): { groupPolicy: "open" | "allowlist" | "disabled"; groupAllowFrom: string[] } { + if (params.groupPolicy === "disabled") { + return { groupPolicy: "disabled", groupAllowFrom: [] }; + } + if (params.effectiveRoomUsers.length > 0) { + return { groupPolicy: "allowlist", groupAllowFrom: params.effectiveRoomUsers }; + } + if (params.groupPolicy === "allowlist" && params.effectiveGroupAllowFrom.length > 0) { + return { groupPolicy: "allowlist", groupAllowFrom: params.effectiveGroupAllowFrom }; + } + return { groupPolicy: "open", groupAllowFrom: [] }; +} + +export async function resolveMatrixMonitorAccessState(params: { allowFrom: Array; storeAllowFrom: Array; dmPolicy?: "open" | "pairing" | "allowlist" | "disabled"; + groupPolicy?: "open" | "allowlist" | "disabled"; groupAllowFrom: Array; roomUsers: Array; senderId: string; isRoom: boolean; -}): MatrixMonitorAccessState { - const configuredAllowFrom = normalizeMatrixAllowList(params.allowFrom); - const effectiveAllowFrom = normalizeMatrixAllowList( - mergeDmAllowFromSources({ - allowFrom: configuredAllowFrom, - storeAllowFrom: params.storeAllowFrom, - dmPolicy: params.dmPolicy, - }), - ); + accountId?: string; + eventKind?: "message" | "reaction"; +}): Promise { + const dmPolicy = params.dmPolicy ?? "pairing"; + const groupPolicy = params.groupPolicy ?? "open"; const effectiveGroupAllowFrom = normalizeMatrixAllowList(params.groupAllowFrom); const effectiveRoomUsers = normalizeMatrixAllowList(params.roomUsers); - const commandAllowFrom = params.isRoom ? [] : effectiveAllowFrom; - - const directAllowMatch = resolveMatrixAllowListMatch({ - allowList: effectiveAllowFrom, - userId: params.senderId, - }); - const roomUserMatch = - params.isRoom && effectiveRoomUsers.length > 0 - ? resolveMatrixAllowListMatch({ - allowList: effectiveRoomUsers, - userId: params.senderId, - }) - : null; - const groupAllowMatch = - effectiveGroupAllowFrom.length > 0 - ? resolveMatrixAllowListMatch({ - allowList: effectiveGroupAllowFrom, - userId: params.senderId, - }) - : null; - const commandAllowMatch = - commandAllowFrom.length > 0 - ? resolveMatrixAllowListMatch({ - allowList: commandAllowFrom, - userId: params.senderId, - }) - : null; - - return { - effectiveAllowFrom, + const groupIngress = resolveMatrixGroupIngress({ + groupPolicy, effectiveGroupAllowFrom, effectiveRoomUsers, - groupAllowConfigured: effectiveGroupAllowFrom.length > 0, - directAllowMatch, - roomUserMatch, - groupAllowMatch, - commandAuthorizers: [ - { - configured: commandAllowFrom.length > 0, - allowed: commandAllowMatch?.allowed ?? false, - }, - { - configured: effectiveRoomUsers.length > 0, - allowed: roomUserMatch?.allowed ?? false, - }, - { - configured: effectiveGroupAllowFrom.length > 0, - allowed: groupAllowMatch?.allowed ?? false, - }, - ], + }); + const accountId = params.accountId ?? "default"; + const eventKind = params.eventKind ?? "message"; + const ingress = createChannelIngressResolver({ + channelId: "matrix", + accountId, + identity: matrixIngressIdentity, + readStoreAllowFrom: async () => params.storeAllowFrom, + }); + const resolved = await ingress.message({ + subject: { stableId: params.senderId }, + conversation: { + kind: params.isRoom ? "group" : "direct", + id: params.isRoom ? "matrix-room" : "matrix-dm", + }, + event: { + kind: eventKind, + authMode: "inbound" as const, + mayPair: params.isRoom ? false : eventKind === "message", + }, + dmPolicy, + groupPolicy: params.isRoom ? groupIngress.groupPolicy : "disabled", + policy: { groupAllowFromFallbackToAllowFrom: false }, + allowFrom: params.allowFrom, + ...(params.isRoom ? { groupAllowFrom: groupIngress.groupAllowFrom } : {}), + }); + + return { + effectiveGroupAllowFrom, + effectiveRoomUsers, + messageIngress: resolved, + accountId, + senderId: params.senderId, + isRoom: params.isRoom, }; } + +export async function resolveMatrixMonitorCommandAccess( + state: MatrixMonitorAccessState, + params: { + useAccessGroups: boolean; + allowTextCommands: boolean; + hasControlCommand: boolean; + }, +) { + const commandAllowFrom = state.isRoom ? [] : state.messageIngress.senderAccess.effectiveAllowFrom; + const commandGroupAllowFrom = + state.effectiveRoomUsers.length > 0 ? state.effectiveRoomUsers : state.effectiveGroupAllowFrom; + const resolved = await createChannelIngressResolver({ + channelId: "matrix", + accountId: state.accountId, + identity: matrixIngressIdentity, + }).command({ + subject: { stableId: state.senderId }, + conversation: { + kind: state.isRoom ? "group" : "direct", + id: state.isRoom ? "matrix-room" : "matrix-dm", + }, + dmPolicy: "allowlist", + groupPolicy: "allowlist", + policy: { groupAllowFromFallbackToAllowFrom: false }, + allowFrom: commandAllowFrom, + groupAllowFrom: commandGroupAllowFrom, + command: { + useAccessGroups: params.useAccessGroups, + allowTextCommands: params.allowTextCommands, + hasControlCommand: params.hasControlCommand, + groupOwnerAllowFrom: "none", + commandGroupAllowFromFallbackToAllowFrom: false, + }, + }); + return resolved.commandAccess; +} diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index e7b62f8717f..95df480d042 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -12,7 +12,6 @@ import { isChannelProgressDraftWorkToolName, resolveChannelProgressDraftMaxLines, } from "openclaw/plugin-sdk/channel-streaming"; -import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-gating"; import { evaluateSupplementalContextVisibility, resolveChannelContextVisibilityMode, @@ -52,7 +51,10 @@ import { import type { LocationMessageEventContent, MatrixClient } from "../sdk.js"; import { MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY } from "../send/types.js"; import { resolveMatrixStoredSessionMeta } from "../session-store-metadata.js"; -import { resolveMatrixMonitorAccessState } from "./access-state.js"; +import { + resolveMatrixMonitorAccessState, + resolveMatrixMonitorCommandAccess, +} from "./access-state.js"; import { resolveMatrixAckReactionConfig } from "./ack-config.js"; import { normalizeMatrixUserId, resolveMatrixAllowListMatch } from "./allowlist.js"; import { @@ -72,7 +74,6 @@ import { resolveMatrixInboundRoute } from "./route.js"; import { createReplyPrefixOptions, createTypingCallbacks, - formatAllowlistMatchMeta, getAgentScopedMediaLocalRoots, logInboundDrop, logTypingFailure, @@ -730,34 +731,29 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam liveGroupAllowlistCache = next; }, }); - const accessState = resolveMatrixMonitorAccessState({ + const accessState = await resolveMatrixMonitorAccessState({ allowFrom: liveDmAllowFrom, storeAllowFrom, dmPolicy, + groupPolicy, groupAllowFrom: liveGroupAllowFrom, roomUsers, senderId, isRoom, + accountId, + eventKind: isReactionEvent ? "reaction" : "message", }); - const { - effectiveAllowFrom, - effectiveGroupAllowFrom, - effectiveRoomUsers, - groupAllowConfigured, - directAllowMatch, - roomUserMatch, - groupAllowMatch, - commandAuthorizers, - } = accessState; + const { effectiveGroupAllowFrom, effectiveRoomUsers, messageIngress } = accessState; + const ingressDecision = messageIngress.ingress; if (isDirectMessage) { if (!dmEnabled || dmPolicy === "disabled") { await commitInboundEventIfClaimed(); return undefined; } - const allowMatchMeta = formatAllowlistMatchMeta(directAllowMatch); - if (!directAllowMatch.allowed) { - if (!isReactionEvent && dmPolicy === "pairing") { + const senderReason = messageIngress.senderAccess.reasonCode; + if (ingressDecision.decision !== "allow") { + if (ingressDecision.admission === "pairing-required") { const senderName = await getSenderName(); const { code, created } = await core.channel.pairing.upsertPairingRequest({ channel: "matrix", @@ -773,8 +769,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam }); logVerboseMessage( created - ? `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})` - : `matrix pairing reminder sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`, + ? `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (reason=${senderReason})` + : `matrix pairing reminder sender=${senderId} name=${senderName ?? "unknown"} (reason=${senderReason})`, ); try { const { sendMessageMatrix } = await loadMatrixSendModule(); @@ -803,7 +799,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam } if (isReactionEvent || dmPolicy !== "pairing") { logVerboseMessage( - `matrix: blocked ${isReactionEvent ? "reaction" : "dm"} sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`, + `matrix: blocked ${isReactionEvent ? "reaction" : "dm"} sender ${senderId} (dmPolicy=${dmPolicy}, reason=${senderReason})`, ); await commitInboundEventIfClaimed(); } @@ -811,27 +807,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam } } - if (isRoom && roomUserMatch && !roomUserMatch.allowed) { + if (isRoom && ingressDecision.decision !== "allow") { logVerboseMessage( - `matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta( - roomUserMatch, - )})`, - ); - await commitInboundEventIfClaimed(); - return undefined; - } - if ( - isRoom && - groupPolicy === "allowlist" && - effectiveRoomUsers.length === 0 && - groupAllowConfigured && - groupAllowMatch && - !groupAllowMatch.allowed - ) { - logVerboseMessage( - `matrix: blocked sender ${senderId} (groupAllowFrom, ${roomMatchMeta}, ${formatAllowlistMatchMeta( - groupAllowMatch, - )})`, + `matrix: blocked sender ${senderId} (ingress=${ingressDecision.reasonCode}, ${roomMatchMeta})`, ); await commitInboundEventIfClaimed(); return undefined; @@ -965,14 +943,13 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam commandCheckText, cfg, ); - const commandGate = resolveControlCommandGate({ + const commandAccess = await resolveMatrixMonitorCommandAccess(accessState, { useAccessGroups, - authorizers: commandAuthorizers, allowTextCommands, hasControlCommand: hasControlCommandInMessage, }); - const commandAuthorized = commandGate.commandAuthorized; - if (isRoom && commandGate.shouldBlock) { + const commandAuthorized = commandAccess.authorized; + if (isRoom && commandAccess.shouldBlockControlCommand) { logInboundDrop({ log: logVerboseMessage, channel: "matrix", @@ -1149,7 +1126,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam triggerSnapshot, threadRootId, thread, - effectiveAllowFrom, effectiveGroupAllowFrom, effectiveRoomUsers, }; diff --git a/extensions/matrix/src/matrix/monitor/verification-events.ts b/extensions/matrix/src/matrix/monitor/verification-events.ts index b759a307b2d..1af9b738c42 100644 --- a/extensions/matrix/src/matrix/monitor/verification-events.ts +++ b/extensions/matrix/src/matrix/monitor/verification-events.ts @@ -362,18 +362,19 @@ async function isVerificationNoticeAuthorized(params: { params.dmPolicy !== "allowlist" && params.dmPolicy !== "open" ? await params.readStoreAllowFrom() : []; - const accessState = resolveMatrixMonitorAccessState({ + const accessState = await resolveMatrixMonitorAccessState({ allowFrom: params.allowFrom, storeAllowFrom, dmPolicy: params.dmPolicy, // Verification flows only exist in strict DMs, so room/group allowlists do // not participate in the authorization decision here. + groupPolicy: "open", groupAllowFrom: [], roomUsers: [], senderId: params.senderId, isRoom: false, }); - if (accessState.directAllowMatch.allowed) { + if (accessState.messageIngress.senderAccess.decision === "allow") { return true; } params.logVerboseMessage( diff --git a/extensions/matrix/src/runtime-api.ts b/extensions/matrix/src/runtime-api.ts index 6579f6437b2..ace392bd4db 100644 --- a/extensions/matrix/src/runtime-api.ts +++ b/extensions/matrix/src/runtime-api.ts @@ -98,10 +98,6 @@ export { buildChannelKeyCandidates, resolveChannelEntryMatch, } from "openclaw/plugin-sdk/channel-targets"; -export { - evaluateGroupRouteAccessForPolicy, - resolveSenderScopedGroupPolicy, -} from "openclaw/plugin-sdk/channel-policy"; export { buildTimeoutAbortSignal } from "./matrix/sdk/timeout-abort-signal.js"; export { formatZonedTimestamp } from "openclaw/plugin-sdk/time-runtime"; export type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime"; diff --git a/extensions/mattermost/runtime-api.ts b/extensions/mattermost/runtime-api.ts index d2041ec6740..39a6c43585c 100644 --- a/extensions/mattermost/runtime-api.ts +++ b/extensions/mattermost/runtime-api.ts @@ -16,7 +16,7 @@ export type { } from "openclaw/plugin-sdk/core"; export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; -export type { ModelsProviderData } from "openclaw/plugin-sdk/command-auth"; +export type { ModelsProviderData } from "openclaw/plugin-sdk/models-provider-runtime"; export type { BlockStreamingCoalesceConfig, DmPolicy, @@ -34,11 +34,11 @@ export { buildComputedAccountStatusSnapshot } from "openclaw/plugin-sdk/channel- export { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; export { buildAgentMediaPayload } from "openclaw/plugin-sdk/agent-media-payload"; export { - buildModelsProviderData, listSkillCommandsForAgents, resolveControlCommandGate, resolveStoredModelOverride, -} from "openclaw/plugin-sdk/command-auth"; +} from "openclaw/plugin-sdk/command-auth-native"; +export { buildModelsProviderData } from "openclaw/plugin-sdk/models-provider-runtime"; export { GROUP_POLICY_BLOCKED_LABEL, resolveAllowlistProviderRuntimeGroupPolicy, @@ -50,13 +50,6 @@ export { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/session- export { formatInboundFromLabel } from "openclaw/plugin-sdk/channel-inbound"; export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound"; export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; -export { - DM_GROUP_ACCESS_REASON, - readStoreAllowFromForDmPolicy, - resolveDmGroupAccessWithLists, - resolveEffectiveAllowFromLists, -} from "openclaw/plugin-sdk/channel-policy"; -export { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access"; export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message"; export { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media"; diff --git a/extensions/mattermost/src/mattermost/model-picker.ts b/extensions/mattermost/src/mattermost/model-picker.ts index 20d2206f564..14a90f3868c 100644 --- a/extensions/mattermost/src/mattermost/model-picker.ts +++ b/extensions/mattermost/src/mattermost/model-picker.ts @@ -1,6 +1,8 @@ import { createHash } from "node:crypto"; -import type { ModelsProviderData } from "openclaw/plugin-sdk/command-auth"; -import { resolveStoredModelOverride } from "openclaw/plugin-sdk/command-auth"; +import { + resolveStoredModelOverride, + type ModelsProviderData, +} from "openclaw/plugin-sdk/command-auth-native"; import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; diff --git a/extensions/mattermost/src/mattermost/monitor-auth.test.ts b/extensions/mattermost/src/mattermost/monitor-auth.test.ts index 78175e811a1..359b27fa270 100644 --- a/extensions/mattermost/src/mattermost/monitor-auth.test.ts +++ b/extensions/mattermost/src/mattermost/monitor-auth.test.ts @@ -1,17 +1,11 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -const evaluateSenderGroupAccessForPolicy = vi.hoisted(() => vi.fn()); const isDangerousNameMatchingEnabled = vi.hoisted(() => vi.fn()); const resolveAllowlistMatchSimple = vi.hoisted(() => vi.fn()); -const resolveControlCommandGate = vi.hoisted(() => vi.fn()); -const resolveEffectiveAllowFromLists = vi.hoisted(() => vi.fn()); vi.mock("./runtime-api.js", () => ({ - evaluateSenderGroupAccessForPolicy, isDangerousNameMatchingEnabled, resolveAllowlistMatchSimple, - resolveControlCommandGate, - resolveEffectiveAllowFromLists, })); describe("mattermost monitor auth", () => { @@ -19,7 +13,6 @@ describe("mattermost monitor auth", () => { let isMattermostSenderAllowed: typeof import("./monitor-auth.js").isMattermostSenderAllowed; let normalizeMattermostAllowEntry: typeof import("./monitor-auth.js").normalizeMattermostAllowEntry; let normalizeMattermostAllowList: typeof import("./monitor-auth.js").normalizeMattermostAllowList; - let resolveMattermostEffectiveAllowFromLists: typeof import("./monitor-auth.js").resolveMattermostEffectiveAllowFromLists; beforeAll(async () => { ({ @@ -27,48 +20,23 @@ describe("mattermost monitor auth", () => { isMattermostSenderAllowed, normalizeMattermostAllowEntry, normalizeMattermostAllowList, - resolveMattermostEffectiveAllowFromLists, } = await import("./monitor-auth.js")); }); beforeEach(() => { - evaluateSenderGroupAccessForPolicy.mockReset(); isDangerousNameMatchingEnabled.mockReset(); resolveAllowlistMatchSimple.mockReset(); - resolveControlCommandGate.mockReset(); - resolveEffectiveAllowFromLists.mockReset(); }); - it("normalizes allowlist entries and resolves effective lists", () => { - resolveEffectiveAllowFromLists.mockReturnValue({ - effectiveAllowFrom: ["alice"], - effectiveGroupAllowFrom: ["team"], - }); - + it("normalizes allowlist entries", () => { expect(normalizeMattermostAllowEntry(" @Alice ")).toBe("alice"); expect(normalizeMattermostAllowEntry("mattermost:Bob")).toBe("bob"); + expect(normalizeMattermostAllowEntry("accessGroup:Ops")).toBe("accessGroup:Ops"); expect(normalizeMattermostAllowEntry("*")).toBe("*"); expect(normalizeMattermostAllowList([" Alice ", "user:alice", "ALICE", "*"])).toEqual([ "alice", "*", ]); - expect( - resolveMattermostEffectiveAllowFromLists({ - allowFrom: [" Alice "], - groupAllowFrom: [" Team "], - storeAllowFrom: ["Store"], - dmPolicy: "pairing", - }), - ).toEqual({ - effectiveAllowFrom: ["alice"], - effectiveGroupAllowFrom: ["team"], - }); - expect(resolveEffectiveAllowFromLists).toHaveBeenCalledWith({ - allowFrom: ["alice"], - groupAllowFrom: ["team"], - storeAllowFrom: ["store"], - dmPolicy: "pairing", - }); }); it("checks sender allowlists against normalized ids and names", () => { @@ -89,24 +57,12 @@ describe("mattermost monitor auth", () => { }); }); - it("requires open direct messages to match the effective allowlist", () => { + it("resolves direct command authorization from shared ingress", async () => { isDangerousNameMatchingEnabled.mockReturnValue(false); - resolveEffectiveAllowFromLists.mockReturnValue({ - effectiveAllowFrom: [], - effectiveGroupAllowFrom: [], - }); - resolveControlCommandGate.mockReturnValue({ - commandAuthorized: false, - shouldBlock: false, - }); - evaluateSenderGroupAccessForPolicy.mockReturnValue({ - allowed: false, - reason: "empty_allowlist", - }); resolveAllowlistMatchSimple.mockReturnValue({ allowed: false }); expect( - authorizeMattermostCommandInvocation({ + await authorizeMattermostCommandInvocation({ account: { config: { dmPolicy: "open" }, } as never, @@ -115,8 +71,8 @@ describe("mattermost monitor auth", () => { senderName: "Alice", channelId: "dm-1", channelInfo: { type: "D", name: "alice", display_name: "Alice" } as never, - allowTextCommands: false, - hasControlCommand: false, + allowTextCommands: true, + hasControlCommand: true, }), ).toMatchObject({ ok: false, @@ -124,14 +80,10 @@ describe("mattermost monitor auth", () => { kind: "direct", }); - resolveEffectiveAllowFromLists.mockReturnValue({ - effectiveAllowFrom: ["*"], - effectiveGroupAllowFrom: [], - }); resolveAllowlistMatchSimple.mockReturnValue({ allowed: true }); expect( - authorizeMattermostCommandInvocation({ + await authorizeMattermostCommandInvocation({ account: { config: { dmPolicy: "open", allowFrom: ["*"] }, } as never, @@ -150,7 +102,7 @@ describe("mattermost monitor auth", () => { }); expect( - authorizeMattermostCommandInvocation({ + await authorizeMattermostCommandInvocation({ account: { config: { dmPolicy: "disabled" }, } as never, @@ -168,7 +120,7 @@ describe("mattermost monitor auth", () => { }); expect( - authorizeMattermostCommandInvocation({ + await authorizeMattermostCommandInvocation({ account: { config: { groupPolicy: "allowlist" }, } as never, diff --git a/extensions/mattermost/src/mattermost/monitor-auth.ts b/extensions/mattermost/src/mattermost/monitor-auth.ts index 912d96dbcb0..ab7b48e6195 100644 --- a/extensions/mattermost/src/mattermost/monitor-auth.ts +++ b/extensions/mattermost/src/mattermost/monitor-auth.ts @@ -1,14 +1,35 @@ +import { parseAccessGroupAllowFromEntry } from "openclaw/plugin-sdk/access-groups"; +import { + type ChannelIngressDecision, + type ChannelIngressEventInput, + type ChannelIngressIdentifierKind, + resolveStableChannelMessageIngress, + type StableChannelIngressIdentityParams, +} from "openclaw/plugin-sdk/channel-ingress-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import type { ResolvedMattermostAccount } from "./accounts.js"; import type { MattermostChannel } from "./client.js"; import type { OpenClawConfig } from "./runtime-api.js"; -import { - evaluateSenderGroupAccessForPolicy, - isDangerousNameMatchingEnabled, - resolveAllowlistMatchSimple, - resolveControlCommandGate, - resolveEffectiveAllowFromLists, -} from "./runtime-api.js"; +import { isDangerousNameMatchingEnabled, resolveAllowlistMatchSimple } from "./runtime-api.js"; + +const MATTERMOST_USER_NAME_KIND = + "plugin:mattermost-user-name" as const satisfies ChannelIngressIdentifierKind; +const mattermostIngressIdentity = { + key: "sender-id", + normalize: normalizeMattermostAllowEntry, + aliases: [ + { + key: "sender-name", + kind: MATTERMOST_USER_NAME_KIND, + normalizeEntry: normalizeMattermostAllowEntry, + normalizeSubject: normalizeMattermostAllowEntry, + dangerous: true, + }, + ], + isWildcardEntry: (entry) => normalizeMattermostAllowEntry(entry) === "*", + resolveEntryId: ({ entryIndex, fieldKey }) => + `mattermost-entry-${entryIndex + 1}:${fieldKey === "sender-name" ? "name" : "user"}`, +} satisfies StableChannelIngressIdentityParams; export function normalizeMattermostAllowEntry(entry: string): string { const trimmed = entry.trim(); @@ -18,12 +39,15 @@ export function normalizeMattermostAllowEntry(entry: string): string { if (trimmed === "*") { return "*"; } - return trimmed + const accessGroupName = parseAccessGroupAllowFromEntry(trimmed); + if (accessGroupName) { + return `accessGroup:${accessGroupName}`; + } + const normalized = trimmed .replace(/^(mattermost|user):/i, "") .replace(/^@/, "") - .trim() - ? normalizeLowercaseStringOrEmpty(trimmed.replace(/^(mattermost|user):/i, "").replace(/^@/, "")) - : ""; + .trim(); + return normalized ? normalizeLowercaseStringOrEmpty(normalized) : ""; } export function normalizeMattermostAllowList(entries: Array): string[] { @@ -33,23 +57,6 @@ export function normalizeMattermostAllowList(entries: Array): s return Array.from(new Set(normalized)); } -export function resolveMattermostEffectiveAllowFromLists(params: { - allowFrom?: Array | null; - groupAllowFrom?: Array | null; - storeAllowFrom?: Array | null; - dmPolicy?: string | null; -}): { - effectiveAllowFrom: string[]; - effectiveGroupAllowFrom: string[]; -} { - return resolveEffectiveAllowFromLists({ - allowFrom: normalizeMattermostAllowList(params.allowFrom ?? []), - groupAllowFrom: normalizeMattermostAllowList(params.groupAllowFrom ?? []), - storeAllowFrom: normalizeMattermostAllowList(params.storeAllowFrom ?? []), - dmPolicy: params.dmPolicy, - }); -} - export function isMattermostSenderAllowed(params: { senderId: string; senderName?: string; @@ -109,7 +116,113 @@ export type MattermostCommandAuthDecision = roomLabel: string; }; -export function authorizeMattermostCommandInvocation(params: { +type MattermostCommandDenyReason = Extract< + MattermostCommandAuthDecision, + { ok: false } +>["denyReason"]; + +export async function resolveMattermostMonitorInboundAccess(params: { + account: ResolvedMattermostAccount; + cfg: OpenClawConfig; + senderId: string; + senderName: string; + channelId: string; + kind: "direct" | "group" | "channel"; + groupPolicy: "allowlist" | "open" | "disabled"; + storeAllowFrom?: Array | null; + readStoreAllowFrom?: () => Promise>; + allowTextCommands: boolean; + hasControlCommand: boolean; + eventKind?: ChannelIngressEventInput["kind"]; + mayPair?: boolean; +}) { + const { + account, + cfg, + senderId, + senderName, + channelId, + kind, + groupPolicy, + storeAllowFrom, + allowTextCommands, + hasControlCommand, + } = params; + const dmPolicy = account.config.dmPolicy ?? "pairing"; + const allowNameMatching = isDangerousNameMatchingEnabled(account.config); + const configAllowFrom = account.config.allowFrom ?? []; + const configGroupAllowFrom = account.config.groupAllowFrom ?? []; + const readStoreAllowFrom = + params.readStoreAllowFrom ?? + (storeAllowFrom != null ? async () => [...storeAllowFrom] : undefined); + const ingress = await resolveStableChannelMessageIngress({ + channelId: "mattermost", + accountId: account.accountId, + identity: mattermostIngressIdentity, + cfg, + ...(readStoreAllowFrom ? { readStoreAllowFrom } : {}), + useDefaultPairingStore: params.readStoreAllowFrom === undefined && storeAllowFrom == null, + subject: { + stableId: senderId, + aliases: { "sender-name": senderName }, + }, + conversation: { + kind, + id: channelId, + }, + event: { + kind: params.eventKind ?? "message", + authMode: "inbound", + mayPair: params.mayPair ?? true, + }, + dmPolicy, + groupPolicy, + policy: { + groupAllowFromFallbackToAllowFrom: true, + mutableIdentifierMatching: allowNameMatching ? "enabled" : "disabled", + }, + allowFrom: configAllowFrom, + groupAllowFrom: configGroupAllowFrom, + command: { + allowTextCommands, + hasControlCommand: allowTextCommands && hasControlCommand, + directGroupAllowFrom: kind === "direct" ? "effective" : "none", + }, + }); + return ingress; +} + +function resolveMattermostCommandDenyReason(params: { + decision: ChannelIngressDecision; + kind: "direct" | "group" | "channel"; + dmPolicy: string; +}): MattermostCommandDenyReason | null { + if (params.decision.decision === "allow") { + return null; + } + if (params.kind === "direct") { + if (params.decision.reasonCode === "dm_policy_disabled") { + return "dm-disabled"; + } + if ( + params.dmPolicy === "pairing" && + (params.decision.admission === "pairing-required" || + params.decision.reasonCode === "dm_policy_pairing_required") + ) { + return "dm-pairing"; + } + return "unauthorized"; + } + if (params.decision.reasonCode === "group_policy_disabled") { + return "channels-disabled"; + } + if (params.decision.reasonCode === "group_policy_empty_allowlist") { + return "channel-no-allowlist"; + } + return "unauthorized"; +} + +export async function authorizeMattermostCommandInvocation(params: { account: ResolvedMattermostAccount; cfg: OpenClawConfig; senderId: string; @@ -117,9 +230,10 @@ export function authorizeMattermostCommandInvocation(params: { channelId: string; channelInfo: MattermostChannel | null; storeAllowFrom?: Array | null; + readStoreAllowFrom?: () => Promise>; allowTextCommands: boolean; hasControlCommand: boolean; -}): MattermostCommandAuthDecision { +}): Promise { const { account, cfg, @@ -128,6 +242,7 @@ export function authorizeMattermostCommandInvocation(params: { channelId, channelInfo, storeAllowFrom, + readStoreAllowFrom, allowTextCommands, hasControlCommand, } = params; @@ -152,160 +267,47 @@ export function authorizeMattermostCommandInvocation(params: { const channelDisplay = channelInfo.display_name ?? channelName; const roomLabel = channelName ? `#${channelName}` : channelDisplay || `#${channelId}`; - const dmPolicy = account.config.dmPolicy ?? "pairing"; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; - const allowNameMatching = isDangerousNameMatchingEnabled(account.config); - const configAllowFrom = normalizeMattermostAllowList(account.config.allowFrom ?? []); - const configGroupAllowFrom = normalizeMattermostAllowList(account.config.groupAllowFrom ?? []); - const normalizedStoreAllowFrom = normalizeMattermostAllowList(storeAllowFrom ?? []); - const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveMattermostEffectiveAllowFromLists({ - allowFrom: configAllowFrom, - groupAllowFrom: configGroupAllowFrom, - storeAllowFrom: normalizedStoreAllowFrom, - dmPolicy, - }); - const useAccessGroups = cfg.commands?.useAccessGroups !== false; - const commandDmAllowFrom = kind === "direct" ? effectiveAllowFrom : configAllowFrom; - const commandGroupAllowFrom = - kind === "direct" - ? effectiveGroupAllowFrom - : configGroupAllowFrom.length > 0 - ? configGroupAllowFrom - : configAllowFrom; - - const senderAllowedForCommands = isMattermostSenderAllowed({ + const ingress = await resolveMattermostMonitorInboundAccess({ + account, + cfg, senderId, senderName, - allowFrom: commandDmAllowFrom, - allowNameMatching, - }); - const groupAllowedForCommands = isMattermostSenderAllowed({ - senderId, - senderName, - allowFrom: commandGroupAllowFrom, - allowNameMatching, - }); - - const commandGate = resolveControlCommandGate({ - useAccessGroups, - authorizers: [ - { configured: commandDmAllowFrom.length > 0, allowed: senderAllowedForCommands }, - { - configured: commandGroupAllowFrom.length > 0, - allowed: groupAllowedForCommands, - }, - ], + channelId, + kind, + groupPolicy, + storeAllowFrom, + readStoreAllowFrom, allowTextCommands, - hasControlCommand: allowTextCommands && hasControlCommand, + hasControlCommand, + eventKind: "native-command", + mayPair: true, + }); + const denyReason = resolveMattermostCommandDenyReason({ + decision: ingress.ingress, + kind, + dmPolicy: account.config.dmPolicy ?? "pairing", }); - const commandAuthorized = - kind === "direct" ? senderAllowedForCommands : commandGate.commandAuthorized; - - if (kind === "direct") { - if (dmPolicy === "disabled") { - return { - ok: false, - denyReason: "dm-disabled", - commandAuthorized: false, - channelInfo, - kind, - chatType, - channelName, - channelDisplay, - roomLabel, - }; - } - - if (!senderAllowedForCommands) { - return { - ok: false, - denyReason: dmPolicy === "pairing" ? "dm-pairing" : "unauthorized", - commandAuthorized: false, - channelInfo, - kind, - chatType, - channelName, - channelDisplay, - roomLabel, - }; - } - } else { - const senderGroupAccess = evaluateSenderGroupAccessForPolicy({ - groupPolicy, - groupAllowFrom: effectiveGroupAllowFrom, - senderId, - isSenderAllowed: (_senderId, allowFrom) => - isMattermostSenderAllowed({ - senderId, - senderName, - allowFrom, - allowNameMatching, - }), - }); - - if (!senderGroupAccess.allowed && senderGroupAccess.reason === "disabled") { - return { - ok: false, - denyReason: "channels-disabled", - commandAuthorized: false, - channelInfo, - kind, - chatType, - channelName, - channelDisplay, - roomLabel, - }; - } - - if (!senderGroupAccess.allowed && senderGroupAccess.reason === "empty_allowlist") { - return { - ok: false, - denyReason: "channel-no-allowlist", - commandAuthorized: false, - channelInfo, - kind, - chatType, - channelName, - channelDisplay, - roomLabel, - }; - } - - if (!senderGroupAccess.allowed && senderGroupAccess.reason === "sender_not_allowlisted") { - return { - ok: false, - denyReason: "unauthorized", - commandAuthorized: false, - channelInfo, - kind, - chatType, - channelName, - channelDisplay, - roomLabel, - }; - } - - if (commandGate.shouldBlock) { - return { - ok: false, - denyReason: "unauthorized", - commandAuthorized: false, - channelInfo, - kind, - chatType, - channelName, - channelDisplay, - roomLabel, - }; - } + if (denyReason) { + return { + ok: false, + denyReason, + commandAuthorized: false, + channelInfo, + kind, + chatType, + channelName, + channelDisplay, + roomLabel, + }; } return { ok: true, - commandAuthorized, + commandAuthorized: ingress.commandAccess.authorized, channelInfo, kind, chatType, diff --git a/extensions/mattermost/src/mattermost/monitor.authz.test.ts b/extensions/mattermost/src/mattermost/monitor.authz.test.ts index 0c2aa9f6d2a..a8654299c3b 100644 --- a/extensions/mattermost/src/mattermost/monitor.authz.test.ts +++ b/extensions/mattermost/src/mattermost/monitor.authz.test.ts @@ -1,9 +1,8 @@ import { describe, expect, it } from "vitest"; -import { resolveControlCommandGate } from "../../runtime-api.js"; import type { ResolvedMattermostAccount } from "./accounts.js"; import { authorizeMattermostCommandInvocation, - resolveMattermostEffectiveAllowFromLists, + resolveMattermostMonitorInboundAccess, } from "./monitor-auth.js"; const accountFixture: ResolvedMattermostAccount = { @@ -47,62 +46,103 @@ function authorizeGroupCommand(senderId: string) { } describe("mattermost monitor authz", () => { - it("keeps DM allowlist merged with pairing-store entries", () => { - const resolved = resolveMattermostEffectiveAllowFromLists({ - dmPolicy: "pairing", - allowFrom: ["@trusted-user"], - groupAllowFrom: ["@group-owner"], + it("keeps DM allowlist merged with pairing-store entries", async () => { + const resolved = await resolveMattermostMonitorInboundAccess({ + account: { + ...accountFixture, + config: { + allowFrom: ["@trusted-user"], + groupAllowFrom: ["@group-owner"], + }, + }, + cfg: {}, + senderId: "trusted-user", + senderName: "Trusted User", + channelId: "dm-1", + kind: "direct", + groupPolicy: "allowlist", storeAllowFrom: ["user:attacker"], + allowTextCommands: false, + hasControlCommand: false, }); - expect(resolved.effectiveAllowFrom).toEqual(["trusted-user", "attacker"]); + expect(resolved.senderAccess.effectiveAllowFrom).toEqual(["trusted-user", "attacker"]); }); - it("uses explicit groupAllowFrom without pairing-store inheritance", () => { - const resolved = resolveMattermostEffectiveAllowFromLists({ - dmPolicy: "pairing", - allowFrom: ["@trusted-user"], - groupAllowFrom: ["@group-owner"], + it("uses explicit groupAllowFrom without pairing-store inheritance", async () => { + const resolved = await resolveMattermostMonitorInboundAccess({ + account: { + ...accountFixture, + config: { + allowFrom: ["@trusted-user"], + groupAllowFrom: ["@group-owner"], + }, + }, + cfg: {}, + senderId: "group-owner", + senderName: "Group Owner", + channelId: "chan-1", + kind: "channel", + groupPolicy: "allowlist", storeAllowFrom: ["user:attacker"], + allowTextCommands: false, + hasControlCommand: false, }); - expect(resolved.effectiveGroupAllowFrom).toEqual(["group-owner"]); + expect(resolved.senderAccess.effectiveGroupAllowFrom).toEqual(["group-owner"]); }); - it("does not inherit pairing-store entries into group allowlist", () => { - const resolved = resolveMattermostEffectiveAllowFromLists({ - dmPolicy: "pairing", - allowFrom: ["@trusted-user"], + it("falls group allowlist back to allowFrom without pairing-store entries", async () => { + const resolved = await resolveMattermostMonitorInboundAccess({ + account: { + ...accountFixture, + config: { + allowFrom: ["@trusted-user"], + }, + }, + cfg: {}, + senderId: "trusted-user", + senderName: "Trusted User", + channelId: "chan-1", + kind: "channel", + groupPolicy: "allowlist", storeAllowFrom: ["user:attacker"], + allowTextCommands: false, + hasControlCommand: false, }); - expect(resolved.effectiveAllowFrom).toEqual(["trusted-user", "attacker"]); - expect(resolved.effectiveGroupAllowFrom).toEqual(["trusted-user"]); + expect(resolved.senderAccess.effectiveGroupAllowFrom).toEqual(["trusted-user"]); }); - it("does not auto-authorize DM commands in open mode without allowlists", () => { - const resolved = resolveMattermostEffectiveAllowFromLists({ - dmPolicy: "open", - allowFrom: [], - groupAllowFrom: [], + it("does not auto-authorize DM commands in open mode without allowlists", async () => { + const access = await resolveMattermostMonitorInboundAccess({ + account: { + ...accountFixture, + config: { + dmPolicy: "open", + }, + }, + cfg: { + commands: { + useAccessGroups: true, + }, + }, + senderId: "alice", + senderName: "Alice", + channelId: "dm-1", + kind: "direct", + groupPolicy: "allowlist", storeAllowFrom: [], - }); - - const commandGate = resolveControlCommandGate({ - useAccessGroups: true, - authorizers: [ - { configured: resolved.effectiveAllowFrom.length > 0, allowed: false }, - { configured: resolved.effectiveGroupAllowFrom.length > 0, allowed: false }, - ], allowTextCommands: true, hasControlCommand: true, }); - expect(commandGate.commandAuthorized).toBe(false); + expect(access.ingress.decision).toBe("block"); + expect(access.commandAccess.authorized).toBe(false); }); - it("denies group control commands when the sender is outside the allowlist", () => { - const decision = authorizeGroupCommand("attacker"); + it("denies group control commands when the sender is outside the allowlist", async () => { + const decision = await authorizeGroupCommand("attacker"); expect(decision).toMatchObject({ ok: false, @@ -111,8 +151,8 @@ describe("mattermost monitor authz", () => { }); }); - it("authorizes group control commands for allowlisted senders", () => { - const decision = authorizeGroupCommand("trusted-user"); + it("authorizes group control commands for allowlisted senders", async () => { + const decision = await authorizeGroupCommand("trusted-user"); expect(decision).toMatchObject({ ok: true, @@ -120,4 +160,72 @@ describe("mattermost monitor authz", () => { kind: "channel", }); }); + + it("authorizes group senders through static access groups", async () => { + const decision = await authorizeMattermostCommandInvocation({ + account: { + ...accountFixture, + config: { + groupPolicy: "allowlist", + groupAllowFrom: ["accessGroup:oncall"], + }, + }, + cfg: { + commands: { + useAccessGroups: true, + }, + accessGroups: { + oncall: { + type: "message.senders", + members: { + mattermost: ["mattermost:trusted-user"], + }, + }, + }, + }, + senderId: "trusted-user", + senderName: "Trusted User", + channelId: "chan-1", + channelInfo: { + id: "chan-1", + type: "O", + name: "general", + display_name: "General", + }, + storeAllowFrom: [], + allowTextCommands: true, + hasControlCommand: true, + }); + + expect(decision).toMatchObject({ + ok: true, + commandAuthorized: true, + kind: "channel", + }); + }); + + it("fails direct reaction access without pairing admission", async () => { + const access = await resolveMattermostMonitorInboundAccess({ + account: { + ...accountFixture, + config: { + dmPolicy: "pairing", + }, + }, + cfg: {}, + senderId: "new-user", + senderName: "New User", + channelId: "dm-1", + kind: "direct", + groupPolicy: "allowlist", + storeAllowFrom: [], + allowTextCommands: false, + hasControlCommand: false, + eventKind: "reaction", + mayPair: false, + }); + + expect(access.ingress.decision).toBe("block"); + expect(access.ingress.reasonCode).toBe("event_pairing_not_allowed"); + }); }); diff --git a/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts b/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts index 5a1de8f51f9..d1f280551ca 100644 --- a/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts +++ b/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts @@ -132,7 +132,6 @@ vi.mock("./runtime-api.js", async () => { onModelSelected: vi.fn(), typingCallbacks: {}, })), - readStoreAllowFromForDmPolicy: vi.fn(async () => []), registerPluginHttpRoute: mockState.registerPluginHttpRoute, resolveChannelMediaMaxBytes: vi.fn(() => 8 * 1024 * 1024), warnMissingProviderGroupPolicyFallbackOnce: vi.fn(), diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 61494e8b226..cb13a1f09e6 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -45,9 +45,8 @@ import { } from "./model-picker.js"; import { authorizeMattermostCommandInvocation, - isMattermostSenderAllowed, normalizeMattermostAllowEntry, - normalizeMattermostAllowList, + resolveMattermostMonitorInboundAccess, } from "./monitor-auth.js"; import { evaluateMattermostMentionGate, @@ -83,18 +82,13 @@ import { createChannelPairingController, createChannelMessageReplyPipeline, DEFAULT_GROUP_HISTORY_LIMIT, - DM_GROUP_ACCESS_REASON, - isDangerousNameMatchingEnabled, logInboundDrop, logTypingFailure, - readStoreAllowFromForDmPolicy, recordPendingHistoryEntryIfEnabled, registerPluginHttpRoute, resolveAllowlistProviderRuntimeGroupPolicy, resolveChannelMediaMaxBytes, - resolveControlCommandGate, resolveDefaultGroupPolicy, - resolveDmGroupAccessWithLists, warnMissingProviderGroupPolicyFallbackOnce, type HistoryEntry, } from "./runtime-api.js"; @@ -480,7 +474,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} channel: "mattermost", accountId: account.accountId, }); - const allowNameMatching = isDangerousNameMatchingEnabled(account.config); const botToken = normalizeOptionalString(opts.botToken) ?? normalizeOptionalString(account.botToken); if (!botToken) { @@ -592,26 +585,18 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} handleInteraction: handleModelPickerInteraction, authorizeButtonClick: async ({ payload, post }) => { const channelInfo = await resolveChannelInfo(payload.channel_id); - const isDirect = channelInfo?.type?.trim().toUpperCase() === "D"; const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg, surface: "mattermost", }); - const decision = authorizeMattermostCommandInvocation({ + const decision = await authorizeMattermostCommandInvocation({ account, cfg, senderId: payload.user_id, senderName: payload.user_name ?? "", channelId: payload.channel_id, channelInfo, - storeAllowFrom: isDirect - ? await readStoreAllowFromForDmPolicy({ - provider: "mattermost", - accountId: account.accountId, - dmPolicy: account.config.dmPolicy ?? "pairing", - readStore: pairing.readStoreForDmPolicy, - }) - : undefined, + readStoreAllowFrom: pairing.readAllowFromStore, allowTextCommands, hasControlCommand: false, }); @@ -810,6 +795,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} ); const channelHistories = new Map(); const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const dmPolicy = account.config.dmPolicy ?? "pairing"; const { groupPolicy, providerMissingFallbackApplied } = resolveAllowlistProviderRuntimeGroupPolicy({ providerConfigPresent: cfg.channels?.mattermost !== undefined, @@ -1036,23 +1022,14 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} surface: "mattermost", }); const hasControlCommand = core.channel.text.hasControlCommand(pickerCommandText, cfg); - const dmPolicy = account.config.dmPolicy ?? "pairing"; - const storeAllowFrom = normalizeMattermostAllowList( - await readStoreAllowFromForDmPolicy({ - provider: "mattermost", - accountId: account.accountId, - dmPolicy, - readStore: pairing.readStoreForDmPolicy, - }), - ); - const auth = authorizeMattermostCommandInvocation({ + const auth = await authorizeMattermostCommandInvocation({ account, cfg, senderId: params.payload.user_id, senderName: params.userName, channelId: params.payload.channel_id, channelInfo, - storeAllowFrom, + readStoreAllowFrom: pairing.readAllowFromStore, allowTextCommands, hasControlCommand, }); @@ -1268,77 +1245,35 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} normalizeOptionalString((await resolveUserInfo(senderId))?.username) ?? senderId; const rawText = normalizeOptionalString(post.message) ?? ""; - const dmPolicy = account.config.dmPolicy ?? "pairing"; - const normalizedAllowFrom = normalizeMattermostAllowList(account.config.allowFrom ?? []); - const normalizedGroupAllowFrom = normalizeMattermostAllowList( - account.config.groupAllowFrom ?? [], - ); - const storeAllowFrom = normalizeMattermostAllowList( - await readStoreAllowFromForDmPolicy({ - provider: "mattermost", - accountId: account.accountId, - dmPolicy, - readStore: pairing.readStoreForDmPolicy, - }), - ); - const accessDecision = resolveDmGroupAccessWithLists({ - isGroup: kind !== "direct", - dmPolicy, - groupPolicy, - allowFrom: normalizedAllowFrom, - groupAllowFrom: normalizedGroupAllowFrom, - storeAllowFrom, - isSenderAllowed: (allowFrom) => - isMattermostSenderAllowed({ - senderId, - senderName, - allowFrom, - allowNameMatching, - }), - }); - const effectiveAllowFrom = accessDecision.effectiveAllowFrom; - const effectiveGroupAllowFrom = accessDecision.effectiveGroupAllowFrom; const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg, surface: "mattermost", }); const hasControlCommand = core.channel.text.hasControlCommand(rawText, cfg); const isControlCommand = allowTextCommands && hasControlCommand; - const useAccessGroups = cfg.commands?.useAccessGroups !== false; - const commandDmAllowFrom = kind === "direct" ? effectiveAllowFrom : normalizedAllowFrom; - const senderAllowedForCommands = isMattermostSenderAllowed({ + const accessDecision = await resolveMattermostMonitorInboundAccess({ + account, + cfg, senderId, senderName, - allowFrom: commandDmAllowFrom, - allowNameMatching, - }); - const groupAllowedForCommands = isMattermostSenderAllowed({ - senderId, - senderName, - allowFrom: effectiveGroupAllowFrom, - allowNameMatching, - }); - const commandGate = resolveControlCommandGate({ - useAccessGroups, - authorizers: [ - { configured: commandDmAllowFrom.length > 0, allowed: senderAllowedForCommands }, - { - configured: effectiveGroupAllowFrom.length > 0, - allowed: groupAllowedForCommands, - }, - ], + channelId, + kind, + groupPolicy, + readStoreAllowFrom: pairing.readAllowFromStore, allowTextCommands, hasControlCommand, + eventKind: "message", + mayPair: true, }); - const commandAuthorized = commandGate.commandAuthorized; + const commandAuthorized = accessDecision.commandAccess.authorized; - if (accessDecision.decision !== "allow") { + if (accessDecision.ingress.decision !== "allow") { if (kind === "direct") { - if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.DM_POLICY_DISABLED) { + if (accessDecision.ingress.reasonCode === "dm_policy_disabled") { logVerboseMessage(`mattermost: drop dm (dmPolicy=disabled sender=${senderId})`); return; } - if (accessDecision.decision === "pairing") { + if (accessDecision.ingress.decision === "pairing") { const { code, created } = await pairing.upsertPairingRequest({ id: senderId, meta: { name: senderName }, @@ -1369,25 +1304,25 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} logVerboseMessage(`mattermost: drop dm sender=${senderId} (dmPolicy=${dmPolicy})`); return; } - if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_DISABLED) { + if (accessDecision.ingress.reasonCode === "group_policy_disabled") { logVerboseMessage("mattermost: drop group message (groupPolicy=disabled)"); return; } - if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST) { + if (accessDecision.ingress.reasonCode === "group_policy_empty_allowlist") { logVerboseMessage("mattermost: drop group message (no group allowlist)"); return; } - if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED) { + if (accessDecision.ingress.reasonCode === "group_policy_not_allowlisted") { logVerboseMessage(`mattermost: drop group sender=${senderId} (not in groupAllowFrom)`); return; } logVerboseMessage( - `mattermost: drop group message (groupPolicy=${groupPolicy} reason=${accessDecision.reason})`, + `mattermost: drop group message (groupPolicy=${groupPolicy} reason=${accessDecision.senderAccess.reasonCode})`, ); return; } - if (kind !== "direct" && commandGate.shouldBlock) { + if (kind !== "direct" && accessDecision.commandAccess.shouldBlockControlCommand) { logInboundDrop({ log: logVerboseMessage, channel: "mattermost", @@ -1968,39 +1903,29 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} } const kind = mapMattermostChannelTypeToChatType(channelInfo.type); - // Enforce DM/group policy and allowlist checks (same as normal messages) - const dmPolicy = account.config.dmPolicy ?? "pairing"; - const storeAllowFrom = normalizeMattermostAllowList( - await readStoreAllowFromForDmPolicy({ - provider: "mattermost", - accountId: account.accountId, - dmPolicy, - readStore: pairing.readStoreForDmPolicy, - }), - ); - const reactionAccess = resolveDmGroupAccessWithLists({ - isGroup: kind !== "direct", - dmPolicy, + // Enforce DM/group policy and allowlist checks (same as normal messages). + const reactionAccess = await resolveMattermostMonitorInboundAccess({ + account, + cfg, + senderId: userId, + senderName, + channelId, + kind, groupPolicy, - allowFrom: normalizeMattermostAllowList(account.config.allowFrom ?? []), - groupAllowFrom: normalizeMattermostAllowList(account.config.groupAllowFrom ?? []), - storeAllowFrom, - isSenderAllowed: (allowFrom) => - isMattermostSenderAllowed({ - senderId: userId, - senderName, - allowFrom, - allowNameMatching, - }), + readStoreAllowFrom: pairing.readAllowFromStore, + allowTextCommands: false, + hasControlCommand: false, + eventKind: "reaction", + mayPair: false, }); - if (reactionAccess.decision !== "allow") { + if (reactionAccess.ingress.decision !== "allow") { if (kind === "direct") { logVerboseMessage( - `mattermost: drop reaction (dmPolicy=${dmPolicy} sender=${userId} reason=${reactionAccess.reason})`, + `mattermost: drop reaction (dmPolicy=${dmPolicy} sender=${userId} reason=${reactionAccess.senderAccess.reasonCode})`, ); } else { logVerboseMessage( - `mattermost: drop reaction (groupPolicy=${groupPolicy} sender=${userId} reason=${reactionAccess.reason} channel=${channelId})`, + `mattermost: drop reaction (groupPolicy=${groupPolicy} sender=${userId} reason=${reactionAccess.senderAccess.reasonCode} channel=${channelId})`, ); } return; diff --git a/extensions/mattermost/src/mattermost/runtime-api.ts b/extensions/mattermost/src/mattermost/runtime-api.ts index fd34f53d696..02e05f4b223 100644 --- a/extensions/mattermost/src/mattermost/runtime-api.ts +++ b/extensions/mattermost/src/mattermost/runtime-api.ts @@ -13,26 +13,19 @@ export { buildAgentMediaPayload } from "openclaw/plugin-sdk/agent-media-payload" export { resolveAllowlistMatchSimple } from "openclaw/plugin-sdk/allow-from"; export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound"; export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; -export { - DM_GROUP_ACCESS_REASON, - readStoreAllowFromForDmPolicy, - resolveDmGroupAccessWithLists, - resolveEffectiveAllowFromLists, -} from "openclaw/plugin-sdk/channel-policy"; export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message"; export { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; export { - buildModelsProviderData, listSkillCommandsForAgents, resolveControlCommandGate, -} from "openclaw/plugin-sdk/command-auth"; +} from "openclaw/plugin-sdk/command-auth-native"; +export { buildModelsProviderData } from "openclaw/plugin-sdk/models-provider-runtime"; export { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; export { resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk/runtime-group-policy"; -export { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access"; export { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime"; export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media"; export { diff --git a/extensions/mattermost/src/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts index 7de7410c41a..0979ccf2f69 100644 --- a/extensions/mattermost/src/mattermost/slash-http.ts +++ b/extensions/mattermost/src/mattermost/slash-http.ts @@ -480,7 +480,7 @@ async function authorizeSlashInvocation(params: { }) .catch(() => []), ); - const decision = authorizeMattermostCommandInvocation({ + const decision = await authorizeMattermostCommandInvocation({ account, cfg, senderId, diff --git a/extensions/mattermost/src/runtime-api.ts b/extensions/mattermost/src/runtime-api.ts index ac1aa148736..6d88ab1d968 100644 --- a/extensions/mattermost/src/runtime-api.ts +++ b/extensions/mattermost/src/runtime-api.ts @@ -22,9 +22,7 @@ export { createDedupeCache, DEFAULT_ACCOUNT_ID, DEFAULT_GROUP_HISTORY_LIMIT, - DM_GROUP_ACCESS_REASON, type DmPolicy, - evaluateSenderGroupAccessForPolicy, formatInboundFromLabel, getAgentScopedMediaLocalRoots, GROUP_POLICY_BLOCKED_LABEL, @@ -48,7 +46,6 @@ export { type PluginRuntime, rawDataToString, readRequestBodyWithLimit, - readStoreAllowFromForDmPolicy, recordPendingHistoryEntryIfEnabled, registerPluginHttpRoute, type ReplyPayload, @@ -58,8 +55,6 @@ export { resolveClientIp, resolveControlCommandGate, resolveDefaultGroupPolicy, - resolveDmGroupAccessWithLists, - resolveEffectiveAllowFromLists, resolveStoredModelOverride, resolveStorePath, resolveThreadSessionKeys, diff --git a/extensions/msteams/runtime-api.ts b/extensions/msteams/runtime-api.ts index 0d9e1349569..fefb97332af 100644 --- a/extensions/msteams/runtime-api.ts +++ b/extensions/msteams/runtime-api.ts @@ -18,14 +18,7 @@ export type { export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; export { logTypingFailure } from "openclaw/plugin-sdk/channel-logging"; export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; -export { - evaluateSenderGroupAccessForPolicy, - readStoreAllowFromForDmPolicy, - resolveDmGroupAccessWithLists, - resolveEffectiveAllowFromLists, - resolveSenderScopedGroupPolicy, - resolveToolsBySender, -} from "openclaw/plugin-sdk/channel-policy"; +export { resolveToolsBySender } from "openclaw/plugin-sdk/channel-policy"; export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message"; export { PAIRING_APPROVED_MESSAGE, diff --git a/extensions/msteams/src/monitor-handler.test-helpers.ts b/extensions/msteams/src/monitor-handler.test-helpers.ts index 11a87ef226c..401ec2ba6e0 100644 --- a/extensions/msteams/src/monitor-handler.test-helpers.ts +++ b/extensions/msteams/src/monitor-handler.test-helpers.ts @@ -14,6 +14,7 @@ type MSTeamsTestRuntimeOptions = { upsertPairingRequest?: ReturnType; recordInboundSession?: ReturnType; resolveAgentRoute?: (params: RuntimeRoutePeer) => unknown; + hasControlCommand?: PluginRuntime["channel"]["text"]["hasControlCommand"]; resolveTextChunkLimit?: () => number; resolveStorePath?: () => string; }; @@ -79,7 +80,7 @@ export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = { upsertPairingRequest: options.upsertPairingRequest ?? vi.fn(async () => null), }, text: { - hasControlCommand: () => false, + hasControlCommand: options.hasControlCommand ?? (() => false), resolveChunkMode: () => "length", resolveMarkdownTableMode: () => "code", ...(options.resolveTextChunkLimit diff --git a/extensions/msteams/src/monitor-handler.ts b/extensions/msteams/src/monitor-handler.ts index bc673cb19ea..1ed8180c325 100644 --- a/extensions/msteams/src/monitor-handler.ts +++ b/extensions/msteams/src/monitor-handler.ts @@ -74,7 +74,7 @@ async function isInvokeAuthorized(params: { const maybeInvokeName = includeInvokeName ? { name: context.activity.name } : undefined; - if (isDirectMessage && resolved.access.decision !== "allow") { + if (isDirectMessage && resolved.senderAccess.decision !== "allow") { deps.log.debug?.(deniedLogs.dm, { sender: senderId, conversationId, @@ -97,7 +97,7 @@ async function isInvokeAuthorized(params: { return false; } - if (!isDirectMessage && !resolved.senderGroupAccess.allowed) { + if (!isDirectMessage && !resolved.senderAccess.allowed) { deps.log.debug?.(deniedLogs.group, { sender: senderId, conversationId, diff --git a/extensions/msteams/src/monitor-handler/access.ts b/extensions/msteams/src/monitor-handler/access.ts index 8ce96699774..2dcd3a41d66 100644 --- a/extensions/msteams/src/monitor-handler/access.ts +++ b/extensions/msteams/src/monitor-handler/access.ts @@ -1,24 +1,47 @@ +import { + channelIngressRoutes, + resolveStableChannelMessageIngress, + type StableChannelIngressIdentityParams, +} from "openclaw/plugin-sdk/channel-ingress-runtime"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime"; import { DEFAULT_ACCOUNT_ID, createChannelPairingController, - evaluateSenderGroupAccessForPolicy, isDangerousNameMatchingEnabled, - readStoreAllowFromForDmPolicy, resolveDefaultGroupPolicy, - resolveDmGroupAccessWithLists, - resolveEffectiveAllowFromLists, - resolveSenderScopedGroupPolicy, type OpenClawConfig, } from "../../runtime-api.js"; import { normalizeMSTeamsConversationId } from "../inbound.js"; -import { resolveMSTeamsAllowlistMatch, resolveMSTeamsRouteConfig } from "../policy.js"; +import { resolveMSTeamsRouteConfig } from "../policy.js"; import { getMSTeamsRuntime } from "../runtime.js"; import type { MSTeamsTurnContext } from "../sdk-types.js"; +const MSTEAMS_SENDER_NAME_KIND = "plugin:msteams-sender-name" as const; +const msteamsIngressIdentity = { + key: "sender-id", + normalize: normalizeIngressValue, + aliases: [ + { + key: "sender-name", + kind: MSTEAMS_SENDER_NAME_KIND, + normalizeEntry: normalizeIngressValue, + normalizeSubject: normalizeIngressValue, + dangerous: true, + }, + ], + isWildcardEntry: (entry) => normalizeIngressValue(entry) === "*", + resolveEntryId: ({ entryIndex, fieldKey }) => + `msteams-entry-${entryIndex + 1}:${fieldKey === "sender-name" ? "name" : "id"}`, +} satisfies StableChannelIngressIdentityParams; + +function normalizeIngressValue(value?: string | null): string | null { + return normalizeOptionalLowercaseString(value) ?? null; +} + export async function resolveMSTeamsSenderAccess(params: { cfg: OpenClawConfig; activity: MSTeamsTurnContext["activity"]; + hasControlCommand?: boolean; }) { const activity = params.activity; const msteamsCfg = params.cfg.channels?.msteams; @@ -35,26 +58,13 @@ export async function resolveMSTeamsSenderAccess(params: { accountId: DEFAULT_ACCOUNT_ID, }); const dmPolicy = msteamsCfg?.dmPolicy ?? "pairing"; - const storedAllowFrom = await readStoreAllowFromForDmPolicy({ - provider: "msteams", - accountId: pairing.accountId, - dmPolicy, - readStore: pairing.readStoreForDmPolicy, - }); const configuredDmAllowFrom = msteamsCfg?.allowFrom ?? []; const groupAllowFrom = msteamsCfg?.groupAllowFrom; - const resolvedAllowFromLists = resolveEffectiveAllowFromLists({ - allowFrom: configuredDmAllowFrom, - groupAllowFrom, - storeAllowFrom: storedAllowFrom, - dmPolicy, - }); const defaultGroupPolicy = resolveDefaultGroupPolicy(params.cfg); const groupPolicy = !isDirectMessage && msteamsCfg ? (msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist") : "disabled"; - const effectiveGroupAllowFrom = resolvedAllowFromLists.effectiveGroupAllowFrom; const allowNameMatching = isDangerousNameMatchingEnabled(msteamsCfg); const channelGate = resolveMSTeamsRouteConfig({ cfg: msteamsCfg, @@ -65,60 +75,61 @@ export async function resolveMSTeamsSenderAccess(params: { allowNameMatching, }); - // When a route-level (team/channel) allowlist is configured but the sender allowlist is - // empty, resolveSenderScopedGroupPolicy would otherwise downgrade the policy to "open", - // allowing any sender. To close this bypass (GHSA-g7cr-9h7q-4qxq), treat an empty sender - // allowlist as deny-all whenever the route allowlist is active. - const senderGroupPolicy = - channelGate.allowlistConfigured && effectiveGroupAllowFrom.length === 0 - ? groupPolicy - : resolveSenderScopedGroupPolicy({ - groupPolicy, - groupAllowFrom: effectiveGroupAllowFrom, - }); - const access = resolveDmGroupAccessWithLists({ - isGroup: !isDirectMessage, + const resolved = await resolveStableChannelMessageIngress({ + channelId: "msteams", + accountId: pairing.accountId, + identity: msteamsIngressIdentity, + cfg: params.cfg, + readStoreAllowFrom: pairing.readAllowFromStore, + subject: { + stableId: senderId, + aliases: { "sender-name": senderName }, + }, + conversation: { + kind: isDirectMessage ? "direct" : convType === "channel" ? "channel" : "group", + id: conversationId, + parentId: activity.channelData?.team?.id, + }, + route: channelIngressRoutes( + !isDirectMessage && + channelGate.allowlistConfigured && { + id: "msteams:team-channel", + kind: "nestedAllowlist", + allowed: channelGate.allowed, + precedence: 0, + matchId: "msteams-route", + ...(channelGate.allowed && groupPolicy === "allowlist" + ? { + senderPolicy: "deny-when-empty" as const, + senderAllowFromSource: "effective-group" as const, + } + : {}), + }, + ), dmPolicy, - groupPolicy: senderGroupPolicy, + groupPolicy, + policy: { + groupAllowFromFallbackToAllowFrom: true, + mutableIdentifierMatching: allowNameMatching ? "enabled" : "disabled", + }, allowFrom: configuredDmAllowFrom, groupAllowFrom, - storeAllowFrom: storedAllowFrom, - groupAllowFromFallbackToAllowFrom: false, - isSenderAllowed: (allowFrom) => - resolveMSTeamsAllowlistMatch({ - allowFrom, - senderId, - senderName, - allowNameMatching, - }).allowed, + command: { + allowTextCommands: true, + hasControlCommand: params.hasControlCommand === true, + directGroupAllowFrom: isDirectMessage ? "effective" : "none", + }, }); - const senderGroupAccess = evaluateSenderGroupAccessForPolicy({ - groupPolicy, - groupAllowFrom: effectiveGroupAllowFrom, - senderId, - isSenderAllowed: (_senderId, allowFrom) => - resolveMSTeamsAllowlistMatch({ - allowFrom, - senderId, - senderName, - allowNameMatching, - }).allowed, - }); - return { - msteamsCfg, + ...resolved, pairing, isDirectMessage, conversationId, senderId, senderName, + msteamsCfg, dmPolicy, channelGate, - access, - senderGroupAccess, - configuredDmAllowFrom, - effectiveDmAllowFrom: access.effectiveAllowFrom, - effectiveGroupAllowFrom, allowNameMatching, groupPolicy, }; diff --git a/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts b/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts index 18a5b0f1823..a93dde628c8 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../runtime-api.js"; +import type { OpenClawConfig, PluginRuntime } from "../../runtime-api.js"; import type { GraphThreadMessage } from "../graph-thread.js"; import { _resetThreadParentContextCachesForTest } from "../thread-parent-context.js"; import "./message-handler-mock-support.test-support.js"; @@ -80,7 +80,12 @@ vi.mock("../graph-thread.js", () => { }); describe("msteams monitor handler authz", () => { - function createDeps(cfg: OpenClawConfig) { + function createDeps( + cfg: OpenClawConfig, + options: { + hasControlCommand?: PluginRuntime["channel"]["text"]["hasControlCommand"]; + } = {}, + ) { const readAllowFromStore = vi.fn(async () => ["attacker-aad"]); const upsertPairingRequest = vi.fn(async () => null); const recordInboundSession = vi.fn(async () => undefined); @@ -94,6 +99,7 @@ describe("msteams monitor handler authz", () => { agentId: "default", accountId: "default", })), + hasControlCommand: options.hasControlCommand, }); } @@ -281,10 +287,7 @@ describe("msteams monitor handler authz", () => { const handler = createMSTeamsMessageHandler(deps); await handler(createAttackerGroupActivity({ text: "" })); - expect(readAllowFromStore).toHaveBeenCalledWith({ - channel: "msteams", - accountId: "default", - }); + expect(readAllowFromStore).not.toHaveBeenCalled(); expect(conversationStore.upsert).not.toHaveBeenCalled(); }); @@ -560,6 +563,62 @@ describe("msteams monitor handler authz", () => { ); }); + it("blocks unauthorized text control commands through shared ingress", async () => { + resetThreadMocks(); + const hasControlCommand = vi.fn(() => true); + const { conversationStore, deps } = createDeps( + { + channels: { + msteams: { + groupPolicy: "open", + requireMention: false, + }, + }, + } as OpenClawConfig, + { hasControlCommand }, + ); + + const handler = createMSTeamsMessageHandler(deps); + await handler(createAttackerGroupActivity({ text: "/config set foo bar" })); + + expect(hasControlCommand).toHaveBeenCalledWith("/config set foo bar", deps.cfg); + expect(conversationStore.upsert).not.toHaveBeenCalled(); + expect(runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher).not.toHaveBeenCalled(); + }); + + it("authorizes text control commands from static access groups", async () => { + resetThreadMocks(); + const hasControlCommand = vi.fn(() => true); + const { conversationStore, deps } = createDeps( + { + accessGroups: { + operators: { + type: "message.senders", + members: { msteams: ["attacker-aad"] }, + }, + }, + channels: { + msteams: { + groupPolicy: "allowlist", + groupAllowFrom: ["accessGroup:operators"], + requireMention: false, + }, + }, + } as OpenClawConfig, + { hasControlCommand }, + ); + + const handler = createMSTeamsMessageHandler(deps); + await handler(createAttackerGroupActivity({ text: "/config set foo bar" })); + + expect(conversationStore.upsert).toHaveBeenCalled(); + const dispatched = + runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher.mock.calls[0]?.[0]; + expect(dispatched?.ctxPayload).toMatchObject({ + CommandAuthorized: true, + }); + }); + it("filters non-allowlisted thread messages out of BodyForAgent", async () => { mockThreadContext({ parent: createThreadMessage({ diff --git a/extensions/msteams/src/monitor-handler/message-handler.test-support.ts b/extensions/msteams/src/monitor-handler/message-handler.test-support.ts index 6dec9cb7414..fe13e3f6e22 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.test-support.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.test-support.ts @@ -1,5 +1,5 @@ import { vi } from "vitest"; -import type { OpenClawConfig, RuntimeEnv } from "../../runtime-api.js"; +import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../../runtime-api.js"; import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.js"; import { installMSTeamsTestRuntime } from "../monitor-handler.test-helpers.js"; @@ -11,6 +11,7 @@ type MessageHandlerDepsOptions = { upsertPairingRequest?: ReturnType; recordInboundSession?: ReturnType; resolveAgentRoute?: (params: { peer: { kind: string; id: string } }) => unknown; + hasControlCommand?: PluginRuntime["channel"]["text"]["hasControlCommand"]; }; export function createMessageHandlerDeps( @@ -39,6 +40,7 @@ export function createMessageHandlerDeps( upsertPairingRequest, recordInboundSession, resolveAgentRoute, + hasControlCommand: options.hasControlCommand, resolveTextChunkLimit: () => 4000, resolveStorePath: () => "/tmp/test-store", }); diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index 37a9f5decd6..a62d86611ba 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -4,13 +4,11 @@ import { logInboundDrop, resolveInboundSessionEnvelopeContext, } from "openclaw/plugin-sdk/channel-inbound"; -import { resolveDualTextControlCommandGate } from "openclaw/plugin-sdk/command-gating"; import { filterSupplementalContextItems, resolveChannelContextVisibilityMode, shouldIncludeSupplementalContext, } from "openclaw/plugin-sdk/context-visibility-runtime"; -import { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access"; import { dispatchReplyFromConfigWithSettledDispatcher, hasFinalInboundReplyDispatch, @@ -88,12 +86,9 @@ function extractTextFromHtmlAttachments(attachments: MSTeamsAttachmentLike[]): s } return ""; } + import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.types.js"; -import { - isMSTeamsGroupAllowed, - resolveMSTeamsAllowlistMatch, - resolveMSTeamsReplyPolicy, -} from "../policy.js"; +import { resolveMSTeamsAllowlistMatch, resolveMSTeamsReplyPolicy } from "../policy.js"; import { extractMSTeamsPollVote } from "../polls.js"; import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js"; import { getMSTeamsRuntime } from "../runtime.js"; @@ -106,6 +101,38 @@ import { resolveMSTeamsSenderAccess } from "./access.js"; import { resolveMSTeamsInboundMedia } from "./inbound-media.js"; import { resolveMSTeamsRouteSessionKey } from "./thread-session.js"; +function formatMSTeamsSenderReason(params: { + reasonCode: string; + dmPolicy?: string; + groupPolicy?: string; +}): string { + switch (params.reasonCode) { + case "dm_policy_open": + return "dmPolicy=open"; + case "dm_policy_disabled": + return "dmPolicy=disabled"; + case "dm_policy_pairing_required": + return "dmPolicy=pairing (not allowlisted)"; + case "dm_policy_allowlisted": + return `dmPolicy=${params.dmPolicy ?? "allowlist"} (allowlisted)`; + case "dm_policy_not_allowlisted": + return `dmPolicy=${params.dmPolicy ?? "allowlist"} (not allowlisted)`; + case "group_policy_disabled": + return "groupPolicy=disabled"; + case "group_policy_empty_allowlist": + case "route_sender_empty": + return "groupPolicy=allowlist (empty allowlist)"; + case "group_policy_not_allowlisted": + return "groupPolicy=allowlist (not allowlisted)"; + case "group_policy_open": + return "groupPolicy=open"; + case "group_policy_allowed": + return `groupPolicy=${params.groupPolicy ?? "allowlist"}`; + default: + return params.reasonCode; + } +} + function buildStoredConversationReference(params: { activity: MSTeamsTurnContext["activity"]; conversationId: string; @@ -262,21 +289,22 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { pairing, isDirectMessage, channelGate, - access, - configuredDmAllowFrom, - effectiveDmAllowFrom, - effectiveGroupAllowFrom, + senderAccess, + commandAccess, allowNameMatching, groupPolicy, } = await resolveMSTeamsSenderAccess({ cfg, activity, + hasControlCommand: core.channel.text.hasControlCommand(text, cfg), }); - const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const commandAuthorized = commandAccess.requested ? commandAccess.authorized : undefined; + const effectiveDmAllowFrom = senderAccess.effectiveAllowFrom; + const effectiveGroupAllowFrom = senderAccess.effectiveGroupAllowFrom; const isChannel = conversationType === "channel"; - if (isDirectMessage && msteamsCfg && access.decision !== "allow") { - if (access.reason === "dmPolicy=disabled") { + if (isDirectMessage && msteamsCfg && senderAccess.decision !== "allow") { + if (senderAccess.reasonCode === "dm_policy_disabled") { log.info("dropping dm (dms disabled)", { sender: senderId, label: senderName, @@ -290,7 +318,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { senderName, allowNameMatching, }); - if (access.decision === "pairing") { + if (senderAccess.decision === "pairing") { conversationStore.upsert(conversationId, conversationRef).catch((err) => { log.debug?.("failed to save conversation reference", { error: formatUnknownError(err), @@ -316,7 +344,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { sender: senderId, label: senderName, dmPolicy, - reason: access.reason, + reason: formatMSTeamsSenderReason({ + reasonCode: senderAccess.reasonCode, + dmPolicy, + groupPolicy, + }), allowlistMatch: formatAllowlistMatchMeta(allowMatch), }); return; @@ -340,20 +372,8 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { }); return; } - const senderGroupAccess = evaluateSenderGroupAccessForPolicy({ - groupPolicy, - groupAllowFrom: effectiveGroupAllowFrom, - senderId, - isSenderAllowed: (_senderId, allowFrom) => - resolveMSTeamsAllowlistMatch({ - allowFrom, - senderId, - senderName, - allowNameMatching, - }).allowed, - }); - if (!senderGroupAccess.allowed && senderGroupAccess.reason === "disabled") { + if (!senderAccess.allowed && senderAccess.reasonCode === "group_policy_disabled") { log.info("dropping group message (groupPolicy: disabled)", { conversationId, }); @@ -362,7 +382,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { }); return; } - if (!senderGroupAccess.allowed && senderGroupAccess.reason === "empty_allowlist") { + if ( + !senderAccess.allowed && + (senderAccess.reasonCode === "group_policy_empty_allowlist" || + senderAccess.reasonCode === "route_sender_empty") + ) { log.info("dropping group message (groupPolicy: allowlist, no allowlist)", { conversationId, }); @@ -371,7 +395,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { }); return; } - if (!senderGroupAccess.allowed && senderGroupAccess.reason === "sender_not_allowlisted") { + if (!senderAccess.allowed && senderAccess.reasonCode === "group_policy_not_allowlisted") { const allowMatch = resolveMSTeamsAllowlistMatch({ allowFrom: effectiveGroupAllowFrom, senderId, @@ -392,30 +416,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { } } - const commandDmAllowFrom = isDirectMessage ? effectiveDmAllowFrom : configuredDmAllowFrom; - const ownerAllowedForCommands = isMSTeamsGroupAllowed({ - groupPolicy: "allowlist", - allowFrom: commandDmAllowFrom, - senderId, - senderName, - allowNameMatching, - }); - const groupAllowedForCommands = isMSTeamsGroupAllowed({ - groupPolicy: "allowlist", - allowFrom: effectiveGroupAllowFrom, - senderId, - senderName, - allowNameMatching, - }); - const { commandAuthorized, shouldBlock } = resolveDualTextControlCommandGate({ - useAccessGroups, - primaryConfigured: commandDmAllowFrom.length > 0, - primaryAllowed: ownerAllowedForCommands, - secondaryConfigured: effectiveGroupAllowFrom.length > 0, - secondaryAllowed: groupAllowedForCommands, - hasControlCommand: core.channel.text.hasControlCommand(text, cfg), - }); - if (shouldBlock) { + if (commandAccess.shouldBlockControlCommand) { logInboundDrop({ log: logVerboseMessage, channel: "msteams", diff --git a/extensions/msteams/src/monitor-handler/reaction-handler.test.ts b/extensions/msteams/src/monitor-handler/reaction-handler.test.ts index cfc2fdf8891..25141836fbd 100644 --- a/extensions/msteams/src/monitor-handler/reaction-handler.test.ts +++ b/extensions/msteams/src/monitor-handler/reaction-handler.test.ts @@ -263,5 +263,38 @@ describe("createMSTeamsReactionHandler", () => { expect(enqueue).toHaveBeenCalledOnce(); }); + + it("allows reaction from static access group DM sender", async () => { + const mockRuntime = buildMockRuntime(); + setMSTeamsRuntime(mockRuntime); + const cfg: OpenClawConfig = { + accessGroups: { + operators: { + type: "message.senders", + members: { msteams: ["allowed-aad"] }, + }, + }, + channels: { + msteams: { + dmPolicy: "allowlist", + allowFrom: ["accessGroup:operators"], + }, + }, + } as OpenClawConfig; + const handler = createMSTeamsReactionHandler(buildDeps(cfg, mockRuntime)); + const enqueue = mockRuntime.system.enqueueSystemEvent as ReturnType; + + await invokeReactionEvent( + handler, + { + reactionsAdded: [{ type: "like" }], + from: { id: "good-user", aadObjectId: "allowed-aad", name: "Alice" }, + replyToId: "msg-7", + }, + "added", + ); + + expect(enqueue).toHaveBeenCalledOnce(); + }); }); }); diff --git a/extensions/msteams/src/monitor-handler/reaction-handler.ts b/extensions/msteams/src/monitor-handler/reaction-handler.ts index 75b134acd21..2490c89149c 100644 --- a/extensions/msteams/src/monitor-handler/reaction-handler.ts +++ b/extensions/msteams/src/monitor-handler/reaction-handler.ts @@ -1,21 +1,8 @@ -import { - DEFAULT_ACCOUNT_ID, - isDangerousNameMatchingEnabled, - resolveEffectiveAllowFromLists, - readStoreAllowFromForDmPolicy, - resolveDmGroupAccessWithLists, - resolveDefaultGroupPolicy, - createChannelPairingController, -} from "../../runtime-api.js"; import { normalizeMSTeamsConversationId } from "../inbound.js"; import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.types.js"; -import { - isMSTeamsGroupAllowed, - resolveMSTeamsAllowlistMatch, - resolveMSTeamsRouteConfig, -} from "../policy.js"; import { getMSTeamsRuntime } from "../runtime.js"; import type { MSTeamsTurnContext } from "../sdk-types.js"; +import { resolveMSTeamsSenderAccess } from "./access.js"; /** Teams reaction type names → Unicode emoji. */ const TEAMS_REACTION_EMOJI: Record = { @@ -45,11 +32,6 @@ export function createMSTeamsReactionHandler(deps: MSTeamsMessageHandlerDeps) { const { cfg, log } = deps; const core = getMSTeamsRuntime(); const msteamsCfg = cfg.channels?.msteams; - const pairing = createChannelPairingController({ - core, - channel: "msteams", - accountId: DEFAULT_ACCOUNT_ID, - }); return async function handleReaction( context: MSTeamsTurnContext, @@ -85,88 +67,18 @@ export function createMSTeamsReactionHandler(deps: MSTeamsMessageHandlerDeps) { const senderId = from.aadObjectId ?? from.id; const senderName = from.name ?? from.id; - const dmPolicy = msteamsCfg?.dmPolicy ?? "pairing"; - // Simplified authorization: reuse the same allowlist/policy checks as the message handler. - const storedAllowFrom = await readStoreAllowFromForDmPolicy({ - provider: "msteams", - accountId: pairing.accountId, - dmPolicy, - readStore: pairing.readStoreForDmPolicy, - }); - - const dmAllowFrom = msteamsCfg?.allowFrom ?? []; - const groupAllowFrom = msteamsCfg?.groupAllowFrom; - const resolvedAllowFromLists = resolveEffectiveAllowFromLists({ - allowFrom: dmAllowFrom, - groupAllowFrom, - storeAllowFrom: storedAllowFrom, - dmPolicy, - }); - - // Enforce dmPolicy for DMs (open / disabled / allowlist / pairing). - const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); - if (isDirectMessage && msteamsCfg) { - const access = resolveDmGroupAccessWithLists({ - isGroup: false, - dmPolicy, - groupPolicy: msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist", - allowFrom: dmAllowFrom, - groupAllowFrom, - storeAllowFrom: storedAllowFrom, - groupAllowFromFallbackToAllowFrom: false, - isSenderAllowed: (allowFrom) => - resolveMSTeamsAllowlistMatch({ - allowFrom, - senderId, - senderName, - allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg), - }).allowed, - }); - if (access.decision !== "allow") { - log.debug?.("dropping reaction (dm access denied)", { + if (msteamsCfg) { + const senderAccess = await resolveMSTeamsSenderAccess({ cfg, activity }); + if (senderAccess.senderAccess.decision !== "allow") { + log.debug?.("dropping reaction (access denied)", { sender: senderId, - reason: access.reason, + reason: senderAccess.senderAccess.reasonCode, }); return; } } - // For group/channel messages, check the route allowlist and sender allowlist. - if (!isDirectMessage && msteamsCfg) { - const teamId = (activity as unknown as { channelData?: { team?: { id?: string } } }) - .channelData?.team?.id; - const teamName = (activity as unknown as { channelData?: { team?: { name?: string } } }) - .channelData?.team?.name; - const channelName = (activity as unknown as { channelData?: { channel?: { name?: string } } }) - .channelData?.channel?.name; - const channelGate = resolveMSTeamsRouteConfig({ - cfg: msteamsCfg, - teamId, - teamName, - conversationId, - channelName, - allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg), - }); - if (channelGate.allowlistConfigured && !channelGate.allowed) { - log.debug?.("dropping reaction (not in team/channel allowlist)", { conversationId }); - return; - } - - const effectiveGroupAllowFrom = resolvedAllowFromLists.effectiveGroupAllowFrom; - const groupAllowed = isMSTeamsGroupAllowed({ - groupPolicy: msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist", - allowFrom: effectiveGroupAllowFrom, - senderId, - senderName, - allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg), - }); - if (!groupAllowed) { - log.debug?.("dropping reaction (sender not in group allowlist)", { sender: senderId }); - return; - } - } - // Resolve the agent route for this conversation/sender. // Extract teamId for team-scoped routing bindings (channel/group reactions). const teamId = isDirectMessage diff --git a/extensions/msteams/src/policy.test.ts b/extensions/msteams/src/policy.test.ts index c15ad103549..314a02b731c 100644 --- a/extensions/msteams/src/policy.test.ts +++ b/extensions/msteams/src/policy.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from "vitest"; import type { MSTeamsConfig } from "../runtime-api.js"; -import { - isMSTeamsGroupAllowed, - resolveMSTeamsReplyPolicy, - resolveMSTeamsRouteConfig, -} from "./policy.js"; +import { resolveMSTeamsReplyPolicy, resolveMSTeamsRouteConfig } from "./policy.js"; function resolveNamedTeamRouteConfig(allowNameMatching = false) { const cfg: MSTeamsConfig = { @@ -157,84 +153,4 @@ describe("msteams policy", () => { expect(policy).toEqual({ requireMention: false, replyStyle: "thread" }); }); }); - - describe("isMSTeamsGroupAllowed", () => { - it("allows when policy is open", () => { - expect( - isMSTeamsGroupAllowed({ - groupPolicy: "open", - allowFrom: [], - senderId: "user-id", - senderName: "User", - }), - ).toBe(true); - }); - - it("blocks when policy is disabled", () => { - expect( - isMSTeamsGroupAllowed({ - groupPolicy: "disabled", - allowFrom: ["user-id"], - senderId: "user-id", - senderName: "User", - }), - ).toBe(false); - }); - - it("blocks allowlist when empty", () => { - expect( - isMSTeamsGroupAllowed({ - groupPolicy: "allowlist", - allowFrom: [], - senderId: "user-id", - senderName: "User", - }), - ).toBe(false); - }); - - it("allows allowlist when sender matches", () => { - expect( - isMSTeamsGroupAllowed({ - groupPolicy: "allowlist", - allowFrom: ["User-Id"], - senderId: "user-id", - senderName: "User", - }), - ).toBe(true); - }); - - it("blocks sender-name allowlist matches by default", () => { - expect( - isMSTeamsGroupAllowed({ - groupPolicy: "allowlist", - allowFrom: ["user"], - senderId: "other", - senderName: "User", - }), - ).toBe(false); - }); - - it("allows sender-name allowlist matches when explicitly enabled", () => { - expect( - isMSTeamsGroupAllowed({ - groupPolicy: "allowlist", - allowFrom: ["user"], - senderId: "other", - senderName: "User", - allowNameMatching: true, - }), - ).toBe(true); - }); - - it("allows allowlist wildcard", () => { - expect( - isMSTeamsGroupAllowed({ - groupPolicy: "allowlist", - allowFrom: ["*"], - senderId: "other", - senderName: "User", - }), - ).toBe(true); - }); - }); }); diff --git a/extensions/msteams/src/policy.ts b/extensions/msteams/src/policy.ts index 840313f8458..9796d9075d4 100644 --- a/extensions/msteams/src/policy.ts +++ b/extensions/msteams/src/policy.ts @@ -10,7 +10,6 @@ import type { } from "../runtime-api.js"; import { buildChannelKeyCandidates, - evaluateSenderGroupAccessForPolicy, normalizeChannelSlug, resolveAllowlistMatchSimple, resolveToolsBySender, @@ -245,18 +244,3 @@ export function resolveMSTeamsReplyPolicy(params: { return { requireMention, replyStyle }; } - -export function isMSTeamsGroupAllowed(params: { - groupPolicy: GroupPolicy; - allowFrom: Array; - senderId: string; - senderName?: string | null; - allowNameMatching?: boolean; -}): boolean { - return evaluateSenderGroupAccessForPolicy({ - groupPolicy: params.groupPolicy, - groupAllowFrom: params.allowFrom.map((entry) => String(entry)), - senderId: params.senderId, - isSenderAllowed: () => resolveMSTeamsAllowlistMatch(params).allowed, - }).allowed; -} diff --git a/extensions/msteams/src/reply-stream-controller.test.ts b/extensions/msteams/src/reply-stream-controller.test.ts index 3d4ebe9b4fb..316e0f059ea 100644 --- a/extensions/msteams/src/reply-stream-controller.test.ts +++ b/extensions/msteams/src/reply-stream-controller.test.ts @@ -320,7 +320,9 @@ describe("createTeamsReplyStreamController", () => { expect(ctrl.shouldSuppressDefaultToolProgressMessages()).toBe(true); expect(ctrl.shouldStreamPreviewToolProgress()).toBe(true); - expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenLastCalledWith("- tool: exec"); + expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenLastCalledWith( + "Working\n- tool: exec", + ); }); it("suppresses Teams default progress messages without stream lines when tool progress is disabled", async () => { diff --git a/extensions/nextcloud-talk/runtime-api.ts b/extensions/nextcloud-talk/runtime-api.ts index 60f9e0c0ea8..235bdf8bf23 100644 --- a/extensions/nextcloud-talk/runtime-api.ts +++ b/extensions/nextcloud-talk/runtime-api.ts @@ -5,10 +5,6 @@ export type { AllowlistMatch } from "openclaw/plugin-sdk/allow-from"; export type { ChannelGroupContext } from "openclaw/plugin-sdk/channel-contract"; export { logInboundDrop } from "openclaw/plugin-sdk/channel-logging"; export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; -export { - readStoreAllowFromForDmPolicy, - resolveDmGroupAccessWithCommandGate, -} from "openclaw/plugin-sdk/channel-policy"; export type { BlockStreamingCoalesceConfig, DmConfig, diff --git a/extensions/nextcloud-talk/src/core.test.ts b/extensions/nextcloud-talk/src/core.test.ts index 46254a17f18..84ed5a387cc 100644 --- a/extensions/nextcloud-talk/src/core.test.ts +++ b/extensions/nextcloud-talk/src/core.test.ts @@ -7,7 +7,7 @@ import { normalizeNextcloudTalkMessagingTarget, stripNextcloudTalkTargetPrefix, } from "./normalize.js"; -import { resolveNextcloudTalkAllowlistMatch, resolveNextcloudTalkGroupAllow } from "./policy.js"; +import { resolveNextcloudTalkAllowlistMatch } from "./policy.js"; import { createNextcloudTalkReplayGuard } from "./replay-guard.js"; import { resolveNextcloudTalkOutboundSessionRoute } from "./session-route.js"; import { @@ -291,7 +291,7 @@ describe("nextcloud talk core", () => { expect(retryClaim).toBe("claimed"); }); - it("resolves allowlist matches and group policy decisions", () => { + it("resolves allowlist matches", () => { expect( resolveNextcloudTalkAllowlistMatch({ allowFrom: ["*"], @@ -310,90 +310,5 @@ describe("nextcloud talk core", () => { senderId: "other", }).allowed, ).toBe(false); - - expect( - resolveNextcloudTalkGroupAllow({ - groupPolicy: "disabled", - outerAllowFrom: ["owner"], - innerAllowFrom: ["room-user"], - senderId: "owner", - }), - ).toEqual({ - allowed: false, - outerMatch: { allowed: false }, - innerMatch: { allowed: false }, - }); - expect( - resolveNextcloudTalkGroupAllow({ - groupPolicy: "open", - outerAllowFrom: [], - innerAllowFrom: [], - senderId: "owner", - }), - ).toEqual({ - allowed: true, - outerMatch: { allowed: true }, - innerMatch: { allowed: true }, - }); - expect( - resolveNextcloudTalkGroupAllow({ - groupPolicy: "allowlist", - outerAllowFrom: [], - innerAllowFrom: [], - senderId: "owner", - }), - ).toEqual({ - allowed: false, - outerMatch: { allowed: false }, - innerMatch: { allowed: false }, - }); - expect( - resolveNextcloudTalkGroupAllow({ - groupPolicy: "allowlist", - outerAllowFrom: [], - innerAllowFrom: ["room-user"], - senderId: "room-user", - }), - ).toEqual({ - allowed: true, - outerMatch: { allowed: false }, - innerMatch: { allowed: true, matchKey: "room-user", matchSource: "id" }, - }); - expect( - resolveNextcloudTalkGroupAllow({ - groupPolicy: "allowlist", - outerAllowFrom: ["team-owner"], - innerAllowFrom: ["room-user"], - senderId: "room-user", - }), - ).toEqual({ - allowed: false, - outerMatch: { allowed: false }, - innerMatch: { allowed: true, matchKey: "room-user", matchSource: "id" }, - }); - expect( - resolveNextcloudTalkGroupAllow({ - groupPolicy: "allowlist", - outerAllowFrom: ["team-owner"], - innerAllowFrom: ["room-user"], - senderId: "team-owner", - }), - ).toEqual({ - allowed: false, - outerMatch: { allowed: true, matchKey: "team-owner", matchSource: "id" }, - innerMatch: { allowed: false }, - }); - expect( - resolveNextcloudTalkGroupAllow({ - groupPolicy: "allowlist", - outerAllowFrom: ["shared-user"], - innerAllowFrom: ["shared-user"], - senderId: "shared-user", - }), - ).toEqual({ - allowed: true, - outerMatch: { allowed: true, matchKey: "shared-user", matchSource: "id" }, - innerMatch: { allowed: true, matchKey: "shared-user", matchSource: "id" }, - }); }); }); diff --git a/extensions/nextcloud-talk/src/inbound.authz.test.ts b/extensions/nextcloud-talk/src/inbound.authz.test.ts index 4fc268e5a5e..23511cf1c67 100644 --- a/extensions/nextcloud-talk/src/inbound.authz.test.ts +++ b/extensions/nextcloud-talk/src/inbound.authz.test.ts @@ -86,10 +86,7 @@ describe("nextcloud-talk inbound authz", () => { runtime: createTestRuntimeEnv(), }); - expect(readAllowFromStore).toHaveBeenCalledWith({ - channel: "nextcloud-talk", - accountId: "default", - }); + expect(readAllowFromStore).not.toHaveBeenCalled(); expect(buildMentionRegexes).not.toHaveBeenCalled(); }); diff --git a/extensions/nextcloud-talk/src/inbound.behavior.test.ts b/extensions/nextcloud-talk/src/inbound.behavior.test.ts index f9bb8585c50..d28cbe8b393 100644 --- a/extensions/nextcloud-talk/src/inbound.behavior.test.ts +++ b/extensions/nextcloud-talk/src/inbound.behavior.test.ts @@ -1,3 +1,4 @@ +import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helpers"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { PluginRuntime, RuntimeEnv } from "../runtime-api.js"; import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; @@ -7,16 +8,12 @@ import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js"; const { createChannelPairingControllerMock, - readStoreAllowFromForDmPolicyMock, - resolveDmGroupAccessWithCommandGateMock, resolveAllowlistProviderRuntimeGroupPolicyMock, resolveDefaultGroupPolicyMock, warnMissingProviderGroupPolicyFallbackOnceMock, } = vi.hoisted(() => { return { createChannelPairingControllerMock: vi.fn(), - readStoreAllowFromForDmPolicyMock: vi.fn(), - resolveDmGroupAccessWithCommandGateMock: vi.fn(), resolveAllowlistProviderRuntimeGroupPolicyMock: vi.fn(), resolveDefaultGroupPolicyMock: vi.fn(), warnMissingProviderGroupPolicyFallbackOnceMock: vi.fn(), @@ -31,8 +28,6 @@ vi.mock("../runtime-api.js", async () => { return { ...actual, createChannelPairingController: createChannelPairingControllerMock, - readStoreAllowFromForDmPolicy: readStoreAllowFromForDmPolicyMock, - resolveDmGroupAccessWithCommandGate: resolveDmGroupAccessWithCommandGateMock, resolveAllowlistProviderRuntimeGroupPolicy: resolveAllowlistProviderRuntimeGroupPolicyMock, resolveDefaultGroupPolicy: resolveDefaultGroupPolicyMock, warnMissingProviderGroupPolicyFallbackOnce: warnMissingProviderGroupPolicyFallbackOnceMock, @@ -53,26 +48,33 @@ vi.mock("./room-info.js", async () => { function installRuntime(params?: { buildMentionRegexes?: () => RegExp[]; + hasControlCommand?: (body: string) => boolean; matchesMentionPatterns?: (body: string, regexes: RegExp[]) => boolean; + shouldHandleTextCommands?: () => boolean; }) { - setNextcloudTalkRuntime({ + const runtime = { channel: { + turn: { + runAssembled: vi.fn(async () => undefined), + }, pairing: { readAllowFromStore: vi.fn(async () => []), upsertPairingRequest: vi.fn(async () => ({ code: "123456", created: true })), }, commands: { - shouldHandleTextCommands: vi.fn(() => false), + shouldHandleTextCommands: params?.shouldHandleTextCommands ?? vi.fn(() => false), }, text: { - hasControlCommand: vi.fn(() => false), + hasControlCommand: params?.hasControlCommand ?? vi.fn(() => false), }, mentions: { buildMentionRegexes: params?.buildMentionRegexes ?? vi.fn(() => []), matchesMentionPatterns: params?.matchesMentionPatterns ?? vi.fn(() => false), }, }, - } as unknown as PluginRuntime); + }; + setNextcloudTalkRuntime(runtime as unknown as PluginRuntime); + return runtime; } function createRuntimeEnv() { @@ -129,7 +131,6 @@ describe("nextcloud-talk inbound behavior", () => { providerMissingFallbackApplied: false, }); warnMissingProviderGroupPolicyFallbackOnceMock.mockReturnValue(undefined); - readStoreAllowFromForDmPolicyMock.mockResolvedValue([]); }); it("issues a DM pairing challenge and sends the challenge text", async () => { @@ -142,12 +143,6 @@ describe("nextcloud-talk inbound behavior", () => { readStoreForDmPolicy: vi.fn(), issueChallenge, }); - resolveDmGroupAccessWithCommandGateMock.mockReturnValue({ - decision: "pairing", - reason: "pairing_required", - commandAuthorized: false, - effectiveGroupAllowFrom: [], - }); sendMessageNextcloudTalkMock.mockResolvedValue(undefined); const statusSink = vi.fn(); @@ -193,12 +188,6 @@ describe("nextcloud-talk inbound behavior", () => { issueChallenge: vi.fn(), }); resolveNextcloudTalkRoomKindMock.mockResolvedValue("group"); - resolveDmGroupAccessWithCommandGateMock.mockReturnValue({ - decision: "allow", - reason: "allow", - commandAuthorized: false, - effectiveGroupAllowFrom: ["user-1"], - }); const runtime = createRuntimeEnv(); await handleNextcloudTalkInbound({ @@ -222,4 +211,79 @@ describe("nextcloud-talk inbound behavior", () => { expect(sendMessageNextcloudTalkMock).not.toHaveBeenCalled(); expect(runtime.log).toHaveBeenCalledWith("nextcloud-talk: drop room room-group (no mention)"); }); + + it("blocks unauthorized group text control commands even when room sender access allows chat", async () => { + const buildMentionRegexes = vi.fn(() => [/@openclaw/i]); + const coreRuntime = installRuntime({ + buildMentionRegexes, + hasControlCommand: vi.fn(() => true), + shouldHandleTextCommands: vi.fn(() => true), + }); + createChannelPairingControllerMock.mockReturnValue({ + readStoreForDmPolicy: vi.fn(), + issueChallenge: vi.fn(), + }); + resolveNextcloudTalkRoomKindMock.mockResolvedValue("group"); + const runtime = createRuntimeEnv(); + + await handleNextcloudTalkInbound({ + message: createMessage({ + roomToken: "room-group", + roomName: "Ops", + isGroupChat: true, + text: "/openclaw reload", + }), + account: createAccount({ + config: { + dmPolicy: "pairing", + allowFrom: [], + groupPolicy: "allowlist", + groupAllowFrom: [], + rooms: { + "room-group": { + allowFrom: ["user-1"], + requireMention: false, + }, + }, + }, + }), + config: { channels: { "nextcloud-talk": {} } } as CoreConfig, + runtime, + }); + + expect(coreRuntime.channel.turn.runAssembled).not.toHaveBeenCalled(); + expect(buildMentionRegexes).not.toHaveBeenCalled(); + expect(runtime.log).toHaveBeenCalledWith( + "nextcloud-talk: drop control command (unauthorized) target=user-1", + ); + }); + + it("passes the shared reply pipeline for dispatched replies", async () => { + const coreRuntime = createPluginRuntimeMock(); + setNextcloudTalkRuntime(coreRuntime as unknown as PluginRuntime); + createChannelPairingControllerMock.mockReturnValue({ + readStoreForDmPolicy: vi.fn(async () => []), + issueChallenge: vi.fn(), + }); + + await handleNextcloudTalkInbound({ + message: createMessage(), + account: createAccount({ + config: { + dmPolicy: "allowlist", + allowFrom: ["user-1"], + groupPolicy: "allowlist", + groupAllowFrom: [], + }, + }), + config: { channels: { "nextcloud-talk": {} } } as CoreConfig, + runtime: createRuntimeEnv(), + }); + + expect(coreRuntime.channel.turn.runAssembled).toHaveBeenCalledWith( + expect.objectContaining({ + replyPipeline: {}, + }), + ); + }); }); diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index 2c59c787257..4f9653c68c5 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -1,35 +1,92 @@ -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; +import { + channelIngressRoutes, + resolveStableChannelMessageIngress, +} from "openclaw/plugin-sdk/channel-ingress-runtime"; +import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope"; +import { normalizeOptionalString, normalizeStringEntries } from "openclaw/plugin-sdk/text-runtime"; import { GROUP_POLICY_BLOCKED_LABEL, - createChannelMessageReplyPipeline, + resolveAllowlistProviderRuntimeGroupPolicy, createChannelPairingController, deliverFormattedTextWithAttachments, logInboundDrop, - readStoreAllowFromForDmPolicy, - resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, - resolveDmGroupAccessWithCommandGate, warnMissingProviderGroupPolicyFallbackOnce, + type GroupPolicy, type OpenClawConfig, type OutboundReplyPayload, type RuntimeEnv, } from "../runtime-api.js"; import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; import { + normalizeNextcloudTalkAllowEntry, normalizeNextcloudTalkAllowlist, resolveNextcloudTalkAllowlistMatch, - resolveNextcloudTalkGroupAllow, - resolveNextcloudTalkMentionGate, resolveNextcloudTalkRequireMention, resolveNextcloudTalkRoomMatch, } from "./policy.js"; import { resolveNextcloudTalkRoomKind } from "./room-info.js"; import { getNextcloudTalkRuntime } from "./runtime.js"; import { sendMessageNextcloudTalk } from "./send.js"; -import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js"; +import type { CoreConfig, NextcloudTalkInboundMessage, NextcloudTalkRoomConfig } from "./types.js"; const CHANNEL_ID = "nextcloud-talk" as const; +type NextcloudTalkRoomMatch = ReturnType; + +function hasAllowEntries(entries: string[]): boolean { + return normalizeNextcloudTalkAllowlist(entries).length > 0; +} + +function roomRoutes(params: { + isGroup: boolean; + groupPolicy: GroupPolicy; + roomMatch: NextcloudTalkRoomMatch; + roomConfig?: NextcloudTalkRoomConfig; + senderId: string; + outerGroupAllowFrom: string[]; + roomAllowFrom: string[]; +}) { + if (!params.isGroup) { + return []; + } + const roomSenderConfigured = + params.groupPolicy === "allowlist" && hasAllowEntries(params.roomAllowFrom); + return channelIngressRoutes( + params.roomMatch.allowlistConfigured && { + id: "nextcloud-talk:room", + allowed: params.roomMatch.allowed, + precedence: 0, + matchId: "nextcloud-talk-room", + blockReason: "room_not_allowlisted", + }, + params.roomConfig?.enabled === false && { + id: "nextcloud-talk:room-enabled", + enabled: false, + precedence: 10, + blockReason: "room_disabled", + }, + roomSenderConfigured && { + id: "nextcloud-talk:room-sender", + kind: "nestedAllowlist", + precedence: 20, + blockReason: "room_sender_not_allowlisted", + ...(!hasAllowEntries(params.outerGroupAllowFrom) + ? { + senderPolicy: "replace" as const, + senderAllowFrom: params.roomAllowFrom, + } + : { + allowed: resolveNextcloudTalkAllowlistMatch({ + allowFrom: params.roomAllowFrom, + senderId: params.senderId, + }).allowed, + matchId: "nextcloud-talk-room-sender", + }), + }, + ); +} + async function deliverNextcloudTalkReply(params: { cfg: CoreConfig; payload: OutboundReplyPayload; @@ -84,16 +141,87 @@ export async function handleNextcloudTalkInbound(params: { statusSink?.({ lastInboundAt: message.timestamp }); - const dmPolicy = account.config.dmPolicy ?? "pairing"; - const defaultGroupPolicy = resolveDefaultGroupPolicy(config as OpenClawConfig); + const roomMatch = resolveNextcloudTalkRoomMatch({ + rooms: account.config.rooms, + roomToken, + }); + const roomConfig = roomMatch.roomConfig; + const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ + cfg: config as OpenClawConfig, + surface: CHANNEL_ID, + }); + const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig); + const shouldRequireMention = isGroup + ? resolveNextcloudTalkRequireMention({ + roomConfig, + wildcardConfig: roomMatch.wildcardConfig, + }) + : false; const { groupPolicy, providerMissingFallbackApplied } = resolveAllowlistProviderRuntimeGroupPolicy({ providerConfigPresent: - ((config.channels as Record | undefined)?.["nextcloud-talk"] ?? - undefined) !== undefined, + ((config.channels as Record | undefined)?.[CHANNEL_ID] ?? undefined) !== + undefined, groupPolicy: account.config.groupPolicy, - defaultGroupPolicy, + defaultGroupPolicy: resolveDefaultGroupPolicy(config as OpenClawConfig), }); + const allowFrom = normalizeStringEntries(account.config.allowFrom); + const outerGroupAllowFrom = account.config.groupAllowFrom?.length + ? normalizeStringEntries(account.config.groupAllowFrom) + : allowFrom; + const roomAllowFrom = normalizeStringEntries(roomConfig?.allowFrom); + const resolveAccess = async (wasMentioned?: boolean) => + await resolveStableChannelMessageIngress({ + channelId: CHANNEL_ID, + accountId: account.accountId, + identity: { + key: "nextcloud-talk-user-id", + normalize: (value) => normalizeNextcloudTalkAllowEntry(value) || null, + sensitivity: "pii", + entryIdPrefix: "nextcloud-talk-entry", + }, + cfg: config as OpenClawConfig, + readStoreAllowFrom: async () => + await pairing.readStoreForDmPolicy(CHANNEL_ID, account.accountId), + subject: { stableId: senderId }, + conversation: { + kind: isGroup ? "group" : "direct", + id: isGroup ? roomToken : senderId, + }, + route: roomRoutes({ + isGroup, + groupPolicy, + roomMatch, + roomConfig, + senderId, + outerGroupAllowFrom, + roomAllowFrom, + }), + dmPolicy: account.config.dmPolicy ?? "pairing", + groupPolicy, + policy: { + groupAllowFromFallbackToAllowFrom: true, + activation: { + requireMention: isGroup && shouldRequireMention, + allowTextCommands, + }, + }, + mentionFacts: + isGroup && wasMentioned !== undefined + ? { + canDetectMention: true, + wasMentioned, + hasAnyMention: wasMentioned, + } + : undefined, + allowFrom, + groupAllowFrom: account.config.groupAllowFrom, + command: { + allowTextCommands, + hasControlCommand, + }, + }); + let access = await resolveAccess(); warnMissingProviderGroupPolicyFallbackOnce({ providerMissingFallbackApplied, providerKey: "nextcloud-talk", @@ -101,79 +229,32 @@ export async function handleNextcloudTalkInbound(params: { blockedLabel: GROUP_POLICY_BLOCKED_LABEL.room, log: (message) => runtime.log?.(message), }); - - const configAllowFrom = normalizeNextcloudTalkAllowlist(account.config.allowFrom); - const configGroupAllowFrom = normalizeNextcloudTalkAllowlist(account.config.groupAllowFrom); - const storeAllowFrom = await readStoreAllowFromForDmPolicy({ - provider: CHANNEL_ID, - accountId: account.accountId, - dmPolicy, - readStore: pairing.readStoreForDmPolicy, - }); - const storeAllowList = normalizeNextcloudTalkAllowlist(storeAllowFrom); - - const roomMatch = resolveNextcloudTalkRoomMatch({ - rooms: account.config.rooms, - roomToken, - }); - const roomConfig = roomMatch.roomConfig; - if (isGroup && !roomMatch.allowed) { - runtime.log?.(`nextcloud-talk: drop room ${roomToken} (not allowlisted)`); - return; - } - if (roomConfig?.enabled === false) { - runtime.log?.(`nextcloud-talk: drop room ${roomToken} (disabled)`); - return; - } - - const roomAllowFrom = normalizeNextcloudTalkAllowlist(roomConfig?.allowFrom); - - const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ - cfg: config as OpenClawConfig, - surface: CHANNEL_ID, - }); - const useAccessGroups = - (config.commands as Record | undefined)?.useAccessGroups !== false; - const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig); - const access = resolveDmGroupAccessWithCommandGate({ - isGroup, - dmPolicy, - groupPolicy, - allowFrom: configAllowFrom, - groupAllowFrom: configGroupAllowFrom, - storeAllowFrom: storeAllowList, - isSenderAllowed: (allowFrom) => - resolveNextcloudTalkAllowlistMatch({ - allowFrom, - senderId, - }).allowed, - command: { - useAccessGroups, - allowTextCommands, - hasControlCommand, - }, - }); - const commandAuthorized = access.commandAuthorized; - const effectiveGroupAllowFrom = access.effectiveGroupAllowFrom; + const commandAuthorized = access.commandAccess.authorized; + const accessReason = + access.ingress.reasonCode === "route_blocked" + ? "route blocked" + : access.senderAccess.reasonCode; if (isGroup) { - if (access.decision !== "allow") { - runtime.log?.(`nextcloud-talk: drop group sender ${senderId} (reason=${access.reason})`); + if (access.routeAccess.reason === "room_not_allowlisted") { + runtime.log?.(`nextcloud-talk: drop room ${roomToken} (not allowlisted)`); return; } - const groupAllow = resolveNextcloudTalkGroupAllow({ - groupPolicy, - outerAllowFrom: effectiveGroupAllowFrom, - innerAllowFrom: roomAllowFrom, - senderId, - }); - if (!groupAllow.allowed) { + if (access.routeAccess.reason === "room_disabled") { + runtime.log?.(`nextcloud-talk: drop room ${roomToken} (disabled)`); + return; + } + if (access.routeAccess.reason === "room_sender_not_allowlisted") { runtime.log?.(`nextcloud-talk: drop group sender ${senderId} (policy=${groupPolicy})`); return; } + if (access.senderAccess.decision !== "allow") { + runtime.log?.(`nextcloud-talk: drop group sender ${senderId} (reason=${accessReason})`); + return; + } } else { - if (access.decision !== "allow") { - if (access.decision === "pairing") { + if (access.senderAccess.decision !== "allow") { + if (access.senderAccess.decision === "pairing") { await pairing.issueChallenge({ senderId, senderIdLine: `Your Nextcloud user id: ${senderId}`, @@ -190,12 +271,12 @@ export async function handleNextcloudTalkInbound(params: { }, }); } - runtime.log?.(`nextcloud-talk: drop DM sender ${senderId} (reason=${access.reason})`); + runtime.log?.(`nextcloud-talk: drop DM sender ${senderId} (reason=${accessReason})`); return; } } - if (access.shouldBlockControlCommand) { + if (access.commandAccess.shouldBlockControlCommand) { logInboundDrop({ log: (message) => runtime.log?.(message), channel: CHANNEL_ID, @@ -209,26 +290,15 @@ export async function handleNextcloudTalkInbound(params: { const wasMentioned = mentionRegexes.length ? core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes) : false; - const shouldRequireMention = isGroup - ? resolveNextcloudTalkRequireMention({ - roomConfig, - wildcardConfig: roomMatch.wildcardConfig, - }) - : false; - const mentionGate = resolveNextcloudTalkMentionGate({ - isGroup, - requireMention: shouldRequireMention, - wasMentioned, - allowTextCommands, - hasControlCommand, - commandAuthorized, - }); - if (isGroup && mentionGate.shouldSkip) { + if (isGroup) { + access = await resolveAccess(wasMentioned); + } + + if (isGroup && access.activationAccess.shouldSkip) { runtime.log?.(`nextcloud-talk: drop room ${roomToken} (no mention)`); return; } - - const route = core.channel.routing.resolveAgentRoute({ + const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({ cfg: config as OpenClawConfig, channel: CHANNEL_ID, accountId: account.accountId, @@ -236,26 +306,17 @@ export async function handleNextcloudTalkInbound(params: { kind: isGroup ? "group" : "direct", id: isGroup ? roomToken : senderId, }, + runtime: core.channel, + sessionStore: (config.session as Record | undefined)?.store as + | string + | undefined, }); const fromLabel = isGroup ? `room:${roomName || roomToken}` : senderName || `user:${senderId}`; - const storePath = core.channel.session.resolveStorePath( - (config.session as Record | undefined)?.store as string | undefined, - { - agentId: route.agentId, - }, - ); - const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config as OpenClawConfig); - const previousTimestamp = core.channel.session.readSessionUpdatedAt({ - storePath, - sessionKey: route.sessionKey, - }); - const body = core.channel.reply.formatAgentEnvelope({ + const { storePath, body } = buildEnvelope({ channel: "Nextcloud Talk", from: fromLabel, timestamp: message.timestamp, - previousTimestamp, - envelope: envelopeOptions, body: rawBody, }); @@ -286,48 +347,39 @@ export async function handleNextcloudTalkInbound(params: { CommandAuthorized: commandAuthorized, }); - const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({ + await core.channel.turn.runAssembled({ cfg: config as OpenClawConfig, + channel: CHANNEL_ID, + accountId: account.accountId, agentId: route.agentId, - channel: CHANNEL_ID, - accountId: account.accountId, - }); - - await core.channel.turn.runPrepared({ - channel: CHANNEL_ID, - accountId: account.accountId, routeSessionKey: route.sessionKey, storePath, ctxPayload, recordInboundSession: core.channel.session.recordInboundSession, - runDispatch: async () => - await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ - ctx: ctxPayload, - cfg: config as OpenClawConfig, - dispatcherOptions: { - ...replyPipeline, - deliver: async (payload) => { - await deliverNextcloudTalkReply({ - cfg: config, - payload, - roomToken, - accountId: account.accountId, - statusSink, - }); - }, - onError: (err, info) => { - runtime.error?.(`nextcloud-talk ${info.kind} reply failed: ${String(err)}`); - }, - }, - replyOptions: { - onModelSelected, - skillFilter: roomConfig?.skills, - disableBlockStreaming: - typeof account.config.blockStreaming === "boolean" - ? !account.config.blockStreaming - : undefined, - }, - }), + dispatchReplyWithBufferedBlockDispatcher: + core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, + delivery: { + deliver: async (payload) => { + await deliverNextcloudTalkReply({ + cfg: config, + payload, + roomToken, + accountId: account.accountId, + statusSink, + }); + }, + onError: (err, info) => { + runtime.error?.(`nextcloud-talk ${info.kind} reply failed: ${String(err)}`); + }, + }, + replyPipeline: {}, + replyOptions: { + skillFilter: roomConfig?.skills, + disableBlockStreaming: + typeof account.config.blockStreaming === "boolean" + ? !account.config.blockStreaming + : undefined, + }, record: { onRecordError: (err) => { runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`); diff --git a/extensions/nextcloud-talk/src/policy.ts b/extensions/nextcloud-talk/src/policy.ts index 0869b01f151..c8ad4dbd92b 100644 --- a/extensions/nextcloud-talk/src/policy.ts +++ b/extensions/nextcloud-talk/src/policy.ts @@ -4,16 +4,10 @@ import { resolveChannelEntryMatchWithFallback, resolveNestedAllowlistDecision, } from "openclaw/plugin-sdk/channel-targets"; -import { evaluateMatchedGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access"; -import type { - AllowlistMatch, - ChannelGroupContext, - GroupPolicy, - GroupToolPolicyConfig, -} from "../runtime-api.js"; +import type { AllowlistMatch, ChannelGroupContext, GroupToolPolicyConfig } from "../runtime-api.js"; import type { NextcloudTalkRoomConfig } from "./types.js"; -function normalizeAllowEntry(raw: string): string { +export function normalizeNextcloudTalkAllowEntry(raw: string): string { return raw .trim() .replace(/^(nextcloud-talk|nc-talk|nc):/i, "") @@ -23,7 +17,9 @@ function normalizeAllowEntry(raw: string): string { export function normalizeNextcloudTalkAllowlist( values: Array | undefined, ): string[] { - return (values ?? []).map((value) => normalizeAllowEntry(String(value))).filter(Boolean); + return (values ?? []) + .map((value) => normalizeNextcloudTalkAllowEntry(String(value))) + .filter(Boolean); } export function resolveNextcloudTalkAllowlistMatch(params: { @@ -37,7 +33,7 @@ export function resolveNextcloudTalkAllowlistMatch(params: { if (allowFrom.includes("*")) { return { allowed: true, matchKey: "*", matchSource: "wildcard" }; } - const senderId = normalizeAllowEntry(params.senderId); + const senderId = normalizeNextcloudTalkAllowEntry(params.senderId); if (allowFrom.includes(senderId)) { return { allowed: true, matchKey: senderId, matchSource: "id" }; } @@ -113,68 +109,3 @@ export function resolveNextcloudTalkRequireMention(params: { } return true; } - -export function resolveNextcloudTalkGroupAllow(params: { - groupPolicy: GroupPolicy; - outerAllowFrom: Array | undefined; - innerAllowFrom: Array | undefined; - senderId: string; -}): { allowed: boolean; outerMatch: AllowlistMatch; innerMatch: AllowlistMatch } { - const outerAllow = normalizeNextcloudTalkAllowlist(params.outerAllowFrom); - const innerAllow = normalizeNextcloudTalkAllowlist(params.innerAllowFrom); - const outerMatch = resolveNextcloudTalkAllowlistMatch({ - allowFrom: params.outerAllowFrom, - senderId: params.senderId, - }); - const innerMatch = resolveNextcloudTalkAllowlistMatch({ - allowFrom: params.innerAllowFrom, - senderId: params.senderId, - }); - const access = evaluateMatchedGroupAccessForPolicy({ - groupPolicy: params.groupPolicy, - allowlistConfigured: outerAllow.length > 0 || innerAllow.length > 0, - allowlistMatched: resolveNestedAllowlistDecision({ - outerConfigured: outerAllow.length > 0 || innerAllow.length > 0, - outerMatched: outerAllow.length > 0 ? outerMatch.allowed : true, - innerConfigured: innerAllow.length > 0, - innerMatched: innerMatch.allowed, - }), - }); - - return { - allowed: access.allowed, - outerMatch: - params.groupPolicy === "open" - ? { allowed: true } - : params.groupPolicy === "disabled" - ? { allowed: false } - : outerMatch, - innerMatch: - params.groupPolicy === "open" - ? { allowed: true } - : params.groupPolicy === "disabled" - ? { allowed: false } - : innerMatch, - }; -} - -export function resolveNextcloudTalkMentionGate(params: { - isGroup: boolean; - requireMention: boolean; - wasMentioned: boolean; - allowTextCommands: boolean; - hasControlCommand: boolean; - commandAuthorized: boolean; -}): { shouldSkip: boolean; shouldBypassMention: boolean } { - const shouldBypassMention = - params.isGroup && - params.requireMention && - !params.wasMentioned && - params.allowTextCommands && - params.commandAuthorized && - params.hasControlCommand; - return { - shouldBypassMention, - shouldSkip: params.requireMention && !params.wasMentioned && !shouldBypassMention, - }; -} diff --git a/extensions/nostr/src/channel-api.ts b/extensions/nostr/src/channel-api.ts index b9ff2292339..0ae01c40072 100644 --- a/extensions/nostr/src/channel-api.ts +++ b/extensions/nostr/src/channel-api.ts @@ -9,7 +9,3 @@ export { collectStatusIssuesFromLastError, createDefaultChannelRuntimeState, } from "openclaw/plugin-sdk/status-helpers"; -export { - createPreCryptoDirectDmAuthorizer, - resolveInboundDirectDmAccessWithRuntime, -} from "openclaw/plugin-sdk/direct-dm-access"; diff --git a/extensions/nostr/src/gateway.ts b/extensions/nostr/src/gateway.ts index a36d9df0c8b..51684801f45 100644 --- a/extensions/nostr/src/gateway.ts +++ b/extensions/nostr/src/gateway.ts @@ -1,12 +1,11 @@ +import { + resolveStableChannelMessageIngress, + type StableChannelIngressIdentityParams, +} from "openclaw/plugin-sdk/channel-ingress-runtime"; import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; -import { - createPreCryptoDirectDmAuthorizer, - type ChannelOutboundAdapter, - resolveInboundDirectDmAccessWithRuntime, - type ChannelPlugin, -} from "./channel-api.js"; +import { type ChannelOutboundAdapter, type ChannelPlugin } from "./channel-api.js"; import type { MetricEvent, MetricsSnapshot } from "./metrics.js"; import { startNostrBus, type NostrBusHandle } from "./nostr-bus.js"; import { normalizePubkey } from "./nostr-key-utils.js"; @@ -25,6 +24,16 @@ type NostrOutboundAdapter = Pick< const activeBuses = new Map(); const metricsSnapshots = new Map(); +const ACCESS_GROUP_PREFIX = "accessGroup:"; + +function parseNostrAccessGroupAllowFromEntry(entry: string): string | null { + const trimmed = entry.trim(); + if (!trimmed.startsWith(ACCESS_GROUP_PREFIX)) { + return null; + } + const name = trimmed.slice(ACCESS_GROUP_PREFIX.length).trim(); + return name || null; +} function normalizeNostrAllowEntry(entry: string): string | null { const trimmed = entry.trim(); @@ -34,6 +43,10 @@ function normalizeNostrAllowEntry(entry: string): string | null { if (trimmed === "*") { return "*"; } + const accessGroup = parseNostrAccessGroupAllowFromEntry(trimmed); + if (accessGroup) { + return `accessGroup:${accessGroup}`; + } try { return normalizePubkey(trimmed.replace(/^nostr:/i, "")); } catch { @@ -41,39 +54,21 @@ function normalizeNostrAllowEntry(entry: string): string | null { } } -function isNostrSenderAllowed(senderPubkey: string, allowFrom: string[]): boolean { - const normalizedSender = normalizePubkey(senderPubkey); - for (const entry of allowFrom) { - const normalized = normalizeNostrAllowEntry(entry); - if (normalized === "*" || normalized === normalizedSender) { - return true; - } +function normalizeNostrSenderPubkey(value: string): string | null { + try { + return normalizePubkey(value); + } catch { + return null; } - return false; } -async function resolveNostrDirectAccess(params: { - cfg: OpenClawConfig; - accountId: string; - dmPolicy: "pairing" | "allowlist" | "open" | "disabled"; - allowFrom: Array | undefined; - senderPubkey: string; - rawBody: string; - runtime: Parameters[0]["runtime"]; -}) { - return resolveInboundDirectDmAccessWithRuntime({ - cfg: params.cfg, - channel: "nostr", - accountId: params.accountId, - dmPolicy: params.dmPolicy, - allowFrom: params.allowFrom, - senderId: params.senderPubkey, - rawBody: params.rawBody, - isSenderAllowed: isNostrSenderAllowed, - runtime: params.runtime, - modeWhenAccessGroupsOff: "configured", - }); -} +const nostrIngressIdentity = { + key: "nostr-pubkey", + normalizeEntry: normalizeNostrAllowEntry, + normalizeSubject: normalizeNostrSenderPubkey, + sensitivity: "pii", + entryIdPrefix: "nostr-entry", +} satisfies StableChannelIngressIdentityParams; export const startNostrGatewayAccount: NostrGatewayStart = async (ctx) => { const account = ctx.account; @@ -94,43 +89,59 @@ export const startNostrGatewayAccount: NostrGatewayStart = async (ctx) => { accountId: account.accountId, }); const resolveInboundAccess = async (senderPubkey: string, rawBody: string) => - await resolveNostrDirectAccess({ - cfg: ctx.cfg, + await resolveStableChannelMessageIngress({ + channelId: "nostr", accountId: account.accountId, + identity: nostrIngressIdentity, + cfg: ctx.cfg, + useDefaultPairingStore: true, + subject: { stableId: senderPubkey }, + conversation: { + kind: "direct", + id: senderPubkey, + }, dmPolicy: account.config.dmPolicy ?? "pairing", allowFrom: account.config.allowFrom, - senderPubkey, - rawBody, - runtime: { - shouldComputeCommandAuthorized: runtime.channel.commands.shouldComputeCommandAuthorized, - resolveCommandAuthorizedFromAuthorizers: - runtime.channel.commands.resolveCommandAuthorizedFromAuthorizers, - }, + command: runtime.channel.commands.shouldComputeCommandAuthorized(rawBody, ctx.cfg) + ? { + modeWhenAccessGroupsOff: "configured", + } + : undefined, }); let busHandle: NostrBusHandle | null = null; - const authorizeSender = createPreCryptoDirectDmAuthorizer({ - resolveAccess: async (senderPubkey) => await resolveInboundAccess(senderPubkey, ""), - issuePairingChallenge: async ({ senderId, reply }) => { + const authorizeSender = async (input: { + senderId: string; + reply: (text: string) => Promise; + }): Promise<"allow" | "block" | "pairing"> => { + const resolved = await resolveInboundAccess(input.senderId, ""); + if (resolved.senderAccess.decision === "allow") { + return "allow"; + } + if (resolved.senderAccess.decision === "pairing") { await pairing.issueChallenge({ - senderId, - senderIdLine: `Your Nostr pubkey: ${senderId}`, - sendPairingReply: reply, + senderId: input.senderId, + senderIdLine: `Your Nostr pubkey: ${input.senderId}`, + sendPairingReply: input.reply, onCreated: () => { - ctx.log?.debug?.(`[${account.accountId}] nostr pairing request sender=${senderId}`); + ctx.log?.debug?.(`[${account.accountId}] nostr pairing request sender=${input.senderId}`); }, onReplyError: (err) => { ctx.log?.warn?.( - `[${account.accountId}] nostr pairing reply failed for ${senderId}: ${String(err)}`, + `[${account.accountId}] nostr pairing reply failed for ${input.senderId}: ${String( + err, + )}`, ); }, }); - }, - onBlocked: ({ senderId, reason }) => { - ctx.log?.debug?.(`[${account.accountId}] blocked Nostr sender ${senderId} (${reason})`); - }, - }); + return "pairing"; + } + ctx.log?.debug?.( + `[${account.accountId}] blocked Nostr sender ${input.senderId} (${resolved.senderAccess.reasonCode})`, + ); + return "block"; + }; const bus = await startNostrBus({ accountId: account.accountId, @@ -140,9 +151,9 @@ export const startNostrGatewayAccount: NostrGatewayStart = async (ctx) => { await authorizeSender({ senderId: senderPubkey, reply }), onMessage: async (senderPubkey, text, reply, meta) => { const resolvedAccess = await resolveInboundAccess(senderPubkey, text); - if (resolvedAccess.access.decision !== "allow") { + if (resolvedAccess.senderAccess.decision !== "allow") { ctx.log?.warn?.( - `[${account.accountId}] dropping Nostr DM after preflight drift (${senderPubkey}, ${resolvedAccess.access.reason})`, + `[${account.accountId}] dropping Nostr DM after preflight drift (${senderPubkey}, ${resolvedAccess.senderAccess.reasonCode})`, ); return; } @@ -165,7 +176,9 @@ export const startNostrGatewayAccount: NostrGatewayStart = async (ctx) => { rawBody: text, messageId: meta.eventId, timestamp: meta.createdAt * 1000, - commandAuthorized: resolvedAccess.commandAuthorized, + commandAuthorized: resolvedAccess.commandAccess.requested + ? resolvedAccess.commandAccess.authorized + : undefined, deliver: async (payload) => { const outboundText = payload && typeof payload === "object" && "text" in payload diff --git a/extensions/qa-channel/src/inbound.test.ts b/extensions/qa-channel/src/inbound.test.ts index 5f5a598a89e..42d1bc49d8b 100644 --- a/extensions/qa-channel/src/inbound.test.ts +++ b/extensions/qa-channel/src/inbound.test.ts @@ -3,6 +3,49 @@ import { describe, expect, it, vi } from "vitest"; import { setQaChannelRuntime } from "../api.js"; import { handleQaInbound, isHttpMediaUrl } from "./inbound.js"; +type HandleQaInboundParams = Parameters[0]; + +function createQaInboundParams( + overrides: { + accountConfig?: HandleQaInboundParams["account"]["config"]; + message?: Partial; + } = {}, +): HandleQaInboundParams { + return { + channelId: "qa-channel", + channelLabel: "QA Channel", + account: { + accountId: "default", + enabled: true, + configured: true, + baseUrl: "http://127.0.0.1:43123", + botUserId: "openclaw", + botDisplayName: "OpenClaw QA", + pollTimeoutMs: 250, + config: { + allowFrom: ["*"], + ...overrides.accountConfig, + }, + }, + config: {}, + message: { + id: "msg-1", + accountId: "default", + direction: "inbound", + conversation: { + kind: "direct", + id: "alice", + }, + senderId: "alice", + senderName: "Alice", + text: "ping", + timestamp: 1_777_000_000_000, + reactions: [], + ...overrides.message, + }, + }; +} + describe("isHttpMediaUrl", () => { it("accepts only http and https urls", () => { expect(isHttpMediaUrl("https://example.com/image.png")).toBe(true); @@ -19,40 +62,114 @@ describe("handleQaInbound", () => { vi.mocked(runtime.channel.mentions.buildMentionRegexes).mockReturnValue([/\b@?openclaw\b/i]); setQaChannelRuntime(runtime); - await handleQaInbound({ - channelId: "qa-channel", - channelLabel: "QA Channel", - account: { - accountId: "default", - enabled: true, - configured: true, - baseUrl: "http://127.0.0.1:43123", - botUserId: "openclaw", - botDisplayName: "OpenClaw QA", - pollTimeoutMs: 250, - config: {}, - }, - config: {}, - message: { - id: "msg-1", - accountId: "default", - direction: "inbound", - conversation: { - kind: "channel", - id: "qa-room", - title: "QA Room", + await handleQaInbound( + createQaInboundParams({ + message: { + conversation: { + kind: "channel", + id: "qa-room", + title: "QA Room", + }, + senderId: "alice", + senderName: "Alice", + text: "@openclaw ping", }, - senderId: "alice", - senderName: "Alice", - text: "@openclaw ping", - timestamp: 1_777_000_000_000, - reactions: [], - }, - }); + }), + ); - expect(runtime.channel.turn.runPrepared).toHaveBeenCalledTimes(1); + expect(runtime.channel.turn.runAssembled).toHaveBeenCalledTimes(1); + expect(vi.mocked(runtime.channel.turn.runAssembled).mock.calls[0]?.[0].replyPipeline).toEqual( + {}, + ); expect( - vi.mocked(runtime.channel.turn.runPrepared).mock.calls[0]?.[0].ctxPayload.WasMentioned, + vi.mocked(runtime.channel.turn.runAssembled).mock.calls[0]?.[0].ctxPayload.WasMentioned, ).toBe(true); }); + + it("drops direct messages outside the configured sender allowlist", async () => { + const runtime = createPluginRuntimeMock(); + setQaChannelRuntime(runtime); + + await handleQaInbound( + createQaInboundParams({ + accountConfig: { + allowFrom: ["bob"], + }, + }), + ); + + expect(runtime.channel.turn.runAssembled).not.toHaveBeenCalled(); + }); + + it("allows direct messages from configured senders", async () => { + const runtime = createPluginRuntimeMock(); + setQaChannelRuntime(runtime); + + await handleQaInbound( + createQaInboundParams({ + accountConfig: { + allowFrom: ["alice"], + }, + }), + ); + + expect(runtime.channel.turn.runAssembled).toHaveBeenCalledTimes(1); + expect( + vi.mocked(runtime.channel.turn.runAssembled).mock.calls[0]?.[0].ctxPayload, + ).toMatchObject({ + CommandAuthorized: true, + SenderId: "alice", + }); + }); + + it("uses allowFrom as the group sender fallback for allowlist policy", async () => { + const runtime = createPluginRuntimeMock(); + setQaChannelRuntime(runtime); + + await handleQaInbound( + createQaInboundParams({ + accountConfig: { + allowFrom: ["alice"], + groupPolicy: "allowlist", + }, + message: { + conversation: { + kind: "group", + id: "qa-room", + title: "QA Room", + }, + }, + }), + ); + + expect(runtime.channel.turn.runAssembled).toHaveBeenCalledTimes(1); + }); + + it("skips configured group messages that miss mention activation", async () => { + const runtime = createPluginRuntimeMock(); + vi.mocked(runtime.channel.mentions.buildMentionRegexes).mockReturnValue([/\b@?openclaw\b/i]); + setQaChannelRuntime(runtime); + + await handleQaInbound( + createQaInboundParams({ + accountConfig: { + groups: { + "qa-room": { + requireMention: true, + }, + }, + }, + message: { + conversation: { + kind: "group", + id: "qa-room", + title: "QA Room", + }, + text: "plain group message", + }, + }), + ); + + expect(runtime.channel.turn.runAssembled).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/qa-channel/src/inbound.ts b/extensions/qa-channel/src/inbound.ts index a1500a09b39..f25c3b89850 100644 --- a/extensions/qa-channel/src/inbound.ts +++ b/extensions/qa-channel/src/inbound.ts @@ -1,5 +1,6 @@ -import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message"; +import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope"; import { buildAgentMediaPayload, saveMediaBuffer, @@ -58,6 +59,15 @@ async function resolveQaInboundMediaPayload(attachments: QaBusMessage["attachmen return mediaList.length > 0 ? buildAgentMediaPayload(mediaList) : {}; } +function resolveQaGroupConfig(params: { + account: ResolvedQaChannelAccount; + conversationId: string; + target: string; +}) { + const groups = params.account.config.groups; + return groups?.[params.conversationId] ?? groups?.[params.target] ?? groups?.["*"]; +} + export async function handleQaInbound(params: { channelId: string; channelLabel: string; @@ -72,7 +82,7 @@ export async function handleQaInbound(params: { conversationId: inbound.conversation.id, threadId: inbound.threadId, }); - const route = runtime.channel.routing.resolveAgentRoute({ + const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({ cfg: params.config as OpenClawConfig, channel: params.channelId, accountId: params.account.accountId, @@ -85,6 +95,8 @@ export async function handleQaInbound(params: { : "channel", id: target, }, + runtime: runtime.channel, + sessionStore: params.config.session?.store, }); const isGroup = inbound.conversation.kind !== "direct"; const wasMentioned = isGroup @@ -96,19 +108,51 @@ export async function handleQaInbound(params: { ), ) : undefined; - const storePath = runtime.channel.session.resolveStorePath(params.config.session?.store, { - agentId: route.agentId, + const groupConfig = isGroup + ? resolveQaGroupConfig({ + account: params.account, + conversationId: inbound.conversation.id, + target, + }) + : undefined; + const access = await resolveStableChannelMessageIngress({ + channelId: params.channelId, + accountId: params.account.accountId, + identity: { key: "sender", entryIdPrefix: "qa-entry" }, + groupAllowFromFallbackToAllowFrom: true, + subject: { stableId: inbound.senderId }, + conversation: { + kind: inbound.conversation.kind, + id: inbound.conversation.id, + threadId: inbound.threadId, + title: inbound.conversation.title, + }, + mentionFacts: isGroup + ? { + canDetectMention: true, + wasMentioned: wasMentioned ?? false, + } + : undefined, + dmPolicy: "open", + groupPolicy: params.account.config.groupPolicy ?? "open", + policy: { + activation: isGroup + ? { + requireMention: groupConfig?.requireMention ?? false, + allowTextCommands: true, + } + : undefined, + }, + allowFrom: params.account.config.allowFrom, + groupAllowFrom: params.account.config.groupAllowFrom, }); - const previousTimestamp = runtime.channel.session.readSessionUpdatedAt({ - storePath, - sessionKey: route.sessionKey, - }); - const body = runtime.channel.reply.formatAgentEnvelope({ + if (access.ingress.admission !== "dispatch") { + return; + } + const { storePath, body } = buildEnvelope({ channel: params.channelLabel, from: inbound.senderName || inbound.senderId, timestamp: inbound.timestamp, - previousTimestamp, - envelope: runtime.channel.reply.resolveEnvelopeFormatOptions(params.config as OpenClawConfig), body: inbound.text, }); const mediaPayload = await resolveQaInboundMediaPayload(inbound.attachments); @@ -151,53 +195,44 @@ export async function handleQaInbound(params: { ...mediaPayload, }); - const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({ + await runtime.channel.turn.runAssembled({ cfg: params.config as OpenClawConfig, + channel: params.channelId, + accountId: params.account.accountId, agentId: route.agentId, - channel: params.channelId, - accountId: params.account.accountId, - }); - - await runtime.channel.turn.runPrepared({ - channel: params.channelId, - accountId: params.account.accountId, routeSessionKey: route.sessionKey, storePath, ctxPayload, recordInboundSession: runtime.channel.session.recordInboundSession, - runDispatch: async () => - await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ - ctx: ctxPayload, - cfg: params.config as OpenClawConfig, - dispatcherOptions: { - ...replyPipeline, - deliver: async (payload) => { - const text = - payload && typeof payload === "object" && "text" in payload - ? ((payload as { text?: string }).text ?? "") - : ""; - if (!text.trim()) { - return; - } - await sendQaBusMessage({ - baseUrl: params.account.baseUrl, - accountId: params.account.accountId, - to: target, - text, - senderId: params.account.botUserId, - senderName: params.account.botDisplayName, - threadId: inbound.threadId, - replyToId: inbound.id, - }); - }, - onError: (error) => { - throw error instanceof Error - ? error - : new Error(`qa-channel dispatch failed: ${String(error)}`); - }, - }, - replyOptions: { onModelSelected }, - }), + dispatchReplyWithBufferedBlockDispatcher: + runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher, + delivery: { + deliver: async (payload) => { + const text = + payload && typeof payload === "object" && "text" in payload + ? ((payload as { text?: string }).text ?? "") + : ""; + if (!text.trim()) { + return; + } + await sendQaBusMessage({ + baseUrl: params.account.baseUrl, + accountId: params.account.accountId, + to: target, + text, + senderId: params.account.botUserId, + senderName: params.account.botDisplayName, + threadId: inbound.threadId, + replyToId: inbound.id, + }); + }, + onError: (error) => { + throw error instanceof Error + ? error + : new Error(`qa-channel dispatch failed: ${String(error)}`); + }, + }, + replyPipeline: {}, record: { onRecordError: (error) => { throw error instanceof Error diff --git a/extensions/qqbot/src/bridge/gateway.ts b/extensions/qqbot/src/bridge/gateway.ts index fa80f852628..803d27cbfa5 100644 --- a/extensions/qqbot/src/bridge/gateway.ts +++ b/extensions/qqbot/src/bridge/gateway.ts @@ -25,7 +25,11 @@ import { setBridgeLogger } from "./logger.js"; import { toGatewayAccount } from "./narrowing.js"; import { resolveQQBotPluginVersion } from "./plugin-version.js"; import { getQQBotRuntime, getQQBotRuntimeForEngine } from "./runtime.js"; -import { createSdkHistoryAdapter, createSdkMentionGateAdapter } from "./sdk-adapter.js"; +import { + createSdkAccessAdapter, + createSdkHistoryAdapter, + createSdkMentionGateAdapter, +} from "./sdk-adapter.js"; // ---- One-time startup initialization (module-level) ---- @@ -75,6 +79,7 @@ function createEngineAdapters(_runtime: GatewayPluginRuntime): EngineAdapters { return { history: createSdkHistoryAdapter(), mentionGate: createSdkMentionGateAdapter(), + access: createSdkAccessAdapter(), audioConvert: { convertSilkToWav: _audioModule.convertSilkToWav, isVoiceAttachment: _audioModule.isVoiceAttachment, diff --git a/extensions/qqbot/src/bridge/sdk-adapter.ts b/extensions/qqbot/src/bridge/sdk-adapter.ts index 870649e3c2f..ff802c90865 100644 --- a/extensions/qqbot/src/bridge/sdk-adapter.ts +++ b/extensions/qqbot/src/bridge/sdk-adapter.ts @@ -1,46 +1,31 @@ -/** - * SDK adapter — binds engine port interfaces to the framework's shared - * SDK implementations. - * - * This file lives in bridge/ (not engine/) because it imports from - * `openclaw/plugin-sdk/*`. The engine layer stays zero-SDK-dependency; - * only the bridge layer couples to the framework. - */ - import { - implicitMentionKindWhen, - resolveInboundMentionDecision, -} from "openclaw/plugin-sdk/channel-mention-gating"; + createChannelIngressResolver, + defineStableChannelIngressIdentity, +} from "openclaw/plugin-sdk/channel-ingress-runtime"; +import { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-mention-gating"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { buildPendingHistoryContextFromMap, clearHistoryEntriesIfEnabled, recordPendingHistoryEntryIfEnabled, type HistoryEntry as SdkHistoryEntry, } from "openclaw/plugin-sdk/reply-history"; +import { resolveQQBotEffectivePolicies } from "../engine/access/resolve-policy.js"; +import { normalizeQQBotAllowFrom, normalizeQQBotSenderId } from "../engine/access/sender-match.js"; import type { HistoryPort, HistoryEntryLike } from "../engine/adapter/history.port.js"; -import type { - MentionGatePort, - MentionGateDecision, - MentionFacts, - MentionPolicy, -} from "../engine/adapter/mention-gate.port.js"; +import type { AccessPort } from "../engine/adapter/index.js"; +import type { MentionGatePort } from "../engine/adapter/mention-gate.port.js"; -// ============ History Adapter ============ +const qqbotIngressIdentity = defineStableChannelIngressIdentity({ + key: "sender-id", + normalize: normalizeQQBotSenderId, + isWildcardEntry: (entry) => normalizeQQBotSenderId(entry) === "*", +}); -// Helper: cast engine Map to SDK Map. TypeScript Map is invariant on its -// value type, but the shapes are structurally identical (HistoryEntryLike -// ⊇ SdkHistoryEntry). The `as unknown as` double-cast is safe here. function asSdkMap(map: Map): Map { return map as unknown as Map; } -/** - * History adapter backed by SDK `reply-history`. - * - * Delegates record/build/clear to the SDK's shared implementation so - * the engine benefits from SDK improvements (e.g. future visibility - * filtering) without code duplication. - */ export function createSdkHistoryAdapter(): HistoryPort { return { recordPendingHistoryEntry(params: { @@ -48,7 +33,7 @@ export function createSdkHistoryAdapter(): HistoryPort { historyKey: string; entry?: T | null; limit: number; - }): T[] { + }) { return recordPendingHistoryEntryIfEnabled({ historyMap: asSdkMap(params.historyMap), historyKey: params.historyKey, @@ -57,14 +42,7 @@ export function createSdkHistoryAdapter(): HistoryPort { }) as T[]; }, - buildPendingHistoryContext(params: { - historyMap: Map; - historyKey: string; - limit: number; - currentMessage: string; - formatEntry: (entry: HistoryEntryLike) => string; - lineBreak?: string; - }): string { + buildPendingHistoryContext(params) { return buildPendingHistoryContextFromMap({ historyMap: asSdkMap(params.historyMap), historyKey: params.historyKey, @@ -75,11 +53,7 @@ export function createSdkHistoryAdapter(): HistoryPort { }); }, - clearPendingHistory(params: { - historyMap: Map; - historyKey: string; - limit: number; - }): void { + clearPendingHistory(params) { clearHistoryEntriesIfEnabled({ historyMap: asSdkMap(params.historyMap), historyKey: params.historyKey, @@ -89,43 +63,105 @@ export function createSdkHistoryAdapter(): HistoryPort { }; } -// ============ MentionGate Adapter ============ - -/** - * MentionGate adapter backed by SDK `channel-mention-gating`. - * - * Maps the engine's mention facts/policy to the SDK's - * `resolveInboundMentionDecision` call, normalizing the implicit - * mention boolean into the SDK's typed `ImplicitMentionKind[]`. - */ export function createSdkMentionGateAdapter(): MentionGatePort { return { - resolveInboundMentionDecision(params: { - facts: MentionFacts; - policy: MentionPolicy; - }): MentionGateDecision { - const result = resolveInboundMentionDecision({ - facts: { - canDetectMention: params.facts.canDetectMention, - wasMentioned: params.facts.wasMentioned, - hasAnyMention: params.facts.hasAnyMention, - implicitMentionKinds: - params.facts.implicitMentionKinds ?? implicitMentionKindWhen("reply_to_bot", false), - }, - policy: { - isGroup: params.policy.isGroup, - requireMention: params.policy.requireMention, - allowTextCommands: params.policy.allowTextCommands, - hasControlCommand: params.policy.hasControlCommand, - commandAuthorized: params.policy.commandAuthorized, - }, - }); - return { - effectiveWasMentioned: result.effectiveWasMentioned, - shouldSkip: result.shouldSkip, - shouldBypassMention: result.shouldBypassMention, - implicitMention: result.implicitMention, - }; + resolveInboundMentionDecision(params) { + return resolveInboundMentionDecision(params); }, }; } + +export function createSdkAccessAdapter(): AccessPort { + return { + async resolveInboundAccess(input) { + const { dmPolicy, groupPolicy } = resolveQQBotEffectivePolicies(input); + const rawGroupAllowFrom = + input.groupAllowFrom && input.groupAllowFrom.length > 0 + ? input.groupAllowFrom + : (input.allowFrom ?? []); + const normalizedAllowFrom = normalizeQQBotAllowFrom(input.allowFrom); + const dmAllowFromForIngress = + dmPolicy === "open" && normalizedAllowFrom.length === 0 ? ["*"] : (input.allowFrom ?? []); + + const commandOwnerAllowFrom = input.isGroup + ? [] + : input.allowFrom && input.allowFrom.length > 0 + ? input.allowFrom + : ["*"]; + const resolved = await createChannelIngressResolver({ + channelId: "qqbot", + accountId: input.accountId, + identity: qqbotIngressIdentity, + cfg: input.cfg as OpenClawConfig, + }).message({ + subject: { stableId: input.senderId }, + conversation: { + kind: input.isGroup ? "group" : "direct", + id: input.conversationId, + }, + event: { + mayPair: false, + }, + dmPolicy, + groupPolicy, + policy: { + groupAllowFromFallbackToAllowFrom: false, + }, + allowFrom: dmAllowFromForIngress, + groupAllowFrom: rawGroupAllowFrom, + command: { + commandOwnerAllowFrom, + }, + }); + return resolved; + }, + async resolveSlashCommandAuthorization(input) { + return await resolveQQBotSlashCommandAuthorized(input); + }, + }; +} + +async function resolveQQBotSlashCommandAuthorized(params: { + cfg: unknown; + accountId: string; + isGroup: boolean; + senderId: string; + conversationId: string; + allowFrom?: Array | null; + groupAllowFrom?: Array | null; + commandsAllowFrom?: Array | null; +}): Promise { + const rawAllowFrom = + params.commandsAllowFrom ?? + (params.isGroup && params.groupAllowFrom && params.groupAllowFrom.length > 0 + ? params.groupAllowFrom + : params.allowFrom); + const explicitAllowFrom = normalizeQQBotAllowFrom(rawAllowFrom).filter((entry) => entry !== "*"); + if (explicitAllowFrom.length === 0) { + return false; + } + const resolved = await createChannelIngressResolver({ + channelId: "qqbot", + accountId: params.accountId, + identity: qqbotIngressIdentity, + cfg: params.cfg as OpenClawConfig, + }).message({ + subject: { stableId: params.senderId }, + conversation: { + kind: params.isGroup ? "group" : "direct", + id: params.conversationId, + }, + event: { + kind: "slash-command", + authMode: "none", + mayPair: false, + }, + dmPolicy: "allowlist", + groupPolicy: "open", + allowFrom: explicitAllowFrom, + command: { + modeWhenAccessGroupsOff: "configured", + }, + }); + return resolved.commandAccess.authorized; +} diff --git a/extensions/qqbot/src/command-auth.test.ts b/extensions/qqbot/src/command-auth.test.ts index 41cbb9db559..e74f2f97d8a 100644 --- a/extensions/qqbot/src/command-auth.test.ts +++ b/extensions/qqbot/src/command-auth.test.ts @@ -13,7 +13,7 @@ */ import { describe, expect, it } from "vitest"; -import { qqbotPlugin } from "./channel.js"; +import { createSdkAccessAdapter } from "./bridge/sdk-adapter.js"; // --------------------------------------------------------------------------- // qqbot: prefix normalization for inbound commandAuthorized @@ -24,37 +24,60 @@ import { qqbotPlugin } from "./channel.js"; // --------------------------------------------------------------------------- describe("qqbot: prefix normalization for inbound commandAuthorized", () => { - const formatAllowFrom = qqbotPlugin.config.formatAllowFrom!; + const access = createSdkAccessAdapter(); - /** Mirrors the fixed gateway.ts inbound commandAuthorized computation. */ - function resolveInboundCommandAuthorized(rawAllowFrom: string[], senderId: string): boolean { - const normalizedAllowFrom = formatAllowFrom({ - cfg: {} as never, - accountId: null, + async function resolveInboundCommandAuthorized( + rawAllowFrom: string[], + senderId: string, + options: { + isGroup?: boolean; + groupAllowFrom?: string[]; + } = {}, + ): Promise { + const result = await access.resolveInboundAccess({ + cfg: {}, + accountId: "default", + conversationId: options.isGroup ? "group-openid" : senderId, + isGroup: options.isGroup ?? false, + senderId, allowFrom: rawAllowFrom, + groupAllowFrom: options.groupAllowFrom, }); - const normalizedSenderId = senderId.replace(/^qqbot:/i, "").toUpperCase(); - const allowAll = normalizedAllowFrom.length === 0 || normalizedAllowFrom.some((e) => e === "*"); - return allowAll || normalizedAllowFrom.includes(normalizedSenderId); + return result.commandAccess.authorized; } - it("authorizes when allowFrom uses qqbot: prefix and senderId is the bare id", () => { - expect(resolveInboundCommandAuthorized(["qqbot:USER123"], "USER123")).toBe(true); + it("authorizes when allowFrom uses qqbot: prefix and senderId is the bare id", async () => { + await expect(resolveInboundCommandAuthorized(["qqbot:USER123"], "USER123")).resolves.toBe(true); }); - it("authorizes when qqbot: prefix is mixed case", () => { - expect(resolveInboundCommandAuthorized(["QQBot:user123"], "USER123")).toBe(true); + it("authorizes when qqbot: prefix is mixed case", async () => { + await expect(resolveInboundCommandAuthorized(["QQBot:user123"], "USER123")).resolves.toBe(true); }); - it("denies a sender not in the qqbot:-prefixed allowFrom list", () => { - expect(resolveInboundCommandAuthorized(["qqbot:USER123"], "OTHER")).toBe(false); + it("denies a sender not in the qqbot:-prefixed allowFrom list", async () => { + await expect(resolveInboundCommandAuthorized(["qqbot:USER123"], "OTHER")).resolves.toBe(false); }); - it("authorizes any sender when allowFrom is empty (open)", () => { - expect(resolveInboundCommandAuthorized([], "ANYONE")).toBe(true); + it("authorizes any sender when allowFrom is empty (open)", async () => { + await expect(resolveInboundCommandAuthorized([], "ANYONE")).resolves.toBe(true); }); - it("authorizes any sender when allowFrom contains wildcard *", () => { - expect(resolveInboundCommandAuthorized(["*"], "ANYONE")).toBe(true); + it("authorizes any sender when allowFrom contains wildcard *", async () => { + await expect(resolveInboundCommandAuthorized(["*"], "ANYONE")).resolves.toBe(true); + }); + + it("denies group command auth in an open group without explicit allowlists", async () => { + await expect(resolveInboundCommandAuthorized([], "ANYONE", { isGroup: true })).resolves.toBe( + false, + ); + }); + + it("authorizes group command auth for an explicit group allowlist sender", async () => { + await expect( + resolveInboundCommandAuthorized([], "GROUP_OWNER", { + isGroup: true, + groupAllowFrom: ["qqbot:GROUP_OWNER"], + }), + ).resolves.toBe(true); }); }); diff --git a/extensions/qqbot/src/engine/access/access-control.test.ts b/extensions/qqbot/src/engine/access/access-control.test.ts deleted file mode 100644 index 4b6111bfb11..00000000000 --- a/extensions/qqbot/src/engine/access/access-control.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveQQBotAccess } from "./access-control.js"; -import { QQBOT_ACCESS_REASON } from "./types.js"; - -describe("resolveQQBotAccess", () => { - describe("DM scenarios", () => { - it("allows default-open DMs when allowFrom is omitted", () => { - const result = resolveQQBotAccess({ isGroup: false, senderId: "USER1" }); - expect(result).toMatchObject({ - decision: "allow", - reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_OPEN, - dmPolicy: "open", - effectiveAllowFrom: ["*"], - }); - }); - - it("allows default-open DMs when allowFrom is explicitly empty", () => { - const result = resolveQQBotAccess({ - isGroup: false, - senderId: "USER1", - allowFrom: [], - }); - expect(result).toMatchObject({ - decision: "allow", - reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_OPEN, - dmPolicy: "open", - effectiveAllowFrom: ["*"], - }); - }); - - it("allows everyone with wildcard allowFrom", () => { - const result = resolveQQBotAccess({ - isGroup: false, - senderId: "USER1", - allowFrom: ["*"], - }); - expect(result.decision).toBe("allow"); - expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_OPEN); - }); - - it("allows sender matching the allowlist", () => { - const result = resolveQQBotAccess({ - isGroup: false, - senderId: "USER1", - allowFrom: ["USER1"], - }); - expect(result.decision).toBe("allow"); - expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_ALLOWLISTED); - expect(result.dmPolicy).toBe("allowlist"); - }); - - it("allows open mode when sender matches restrictive allowFrom", () => { - const result = resolveQQBotAccess({ - isGroup: false, - senderId: "USER1", - allowFrom: ["USER1"], - dmPolicy: "open", - }); - expect(result.decision).toBe("allow"); - expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_ALLOWLISTED); - expect(result.reason).toBe("dmPolicy=open (allowlisted)"); - }); - - it("blocks sender not in allowlist", () => { - const result = resolveQQBotAccess({ - isGroup: false, - senderId: "USER2", - allowFrom: ["USER1"], - }); - expect(result.decision).toBe("block"); - expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED); - }); - - it("blocks DM when dmPolicy=disabled (even with wildcard)", () => { - const result = resolveQQBotAccess({ - isGroup: false, - senderId: "USER1", - allowFrom: ["*"], - dmPolicy: "disabled", - }); - expect(result.decision).toBe("block"); - expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_DISABLED); - }); - - it("blocks DM with allowlist policy but empty allowlist", () => { - const result = resolveQQBotAccess({ - isGroup: false, - senderId: "USER1", - dmPolicy: "allowlist", - }); - expect(result.decision).toBe("block"); - expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_EMPTY_ALLOWLIST); - }); - - it("normalizes qqbot: prefix and case when matching", () => { - const result = resolveQQBotAccess({ - isGroup: false, - senderId: "qqbot:user1", - allowFrom: ["QQBot:USER1"], - }); - expect(result.decision).toBe("allow"); - }); - }); - - describe("group scenarios", () => { - it("inherits allowFrom for group access when no groupAllowFrom is set", () => { - const allowed = resolveQQBotAccess({ - isGroup: true, - senderId: "USER1", - allowFrom: ["USER1"], - }); - expect(allowed.decision).toBe("allow"); - expect(allowed.groupPolicy).toBe("allowlist"); - - const blocked = resolveQQBotAccess({ - isGroup: true, - senderId: "USER2", - allowFrom: ["USER1"], - }); - expect(blocked.decision).toBe("block"); - expect(blocked.reasonCode).toBe(QQBOT_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED); - }); - - it("uses groupAllowFrom when explicitly provided", () => { - const result = resolveQQBotAccess({ - isGroup: true, - senderId: "USER2", - allowFrom: ["USER1"], - groupAllowFrom: ["USER2"], - }); - expect(result.decision).toBe("allow"); - }); - - it("blocks when groupPolicy=disabled", () => { - const result = resolveQQBotAccess({ - isGroup: true, - senderId: "USER1", - allowFrom: ["*"], - groupPolicy: "disabled", - }); - expect(result.decision).toBe("block"); - expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.GROUP_POLICY_DISABLED); - }); - - it("allows anyone when groupPolicy=open", () => { - const result = resolveQQBotAccess({ - isGroup: true, - senderId: "RANDOM_USER", - allowFrom: ["USER1"], - groupPolicy: "open", - }); - expect(result.decision).toBe("allow"); - expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.GROUP_POLICY_ALLOWED); - }); - - it("blocks when groupPolicy=allowlist but list is empty", () => { - const result = resolveQQBotAccess({ - isGroup: true, - senderId: "USER1", - groupPolicy: "allowlist", - }); - expect(result.decision).toBe("block"); - expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST); - }); - }); - - describe("backwards compatibility (legacy allowFrom-only configs)", () => { - it("legacy allowFrom=['*'] stays fully open for both DM and group", () => { - const dm = resolveQQBotAccess({ - isGroup: false, - senderId: "RANDOM", - allowFrom: ["*"], - }); - const group = resolveQQBotAccess({ - isGroup: true, - senderId: "RANDOM", - allowFrom: ["*"], - }); - expect(dm.decision).toBe("allow"); - expect(group.decision).toBe("allow"); - }); - - it("legacy allowFrom=['USER1'] locks down both DM and group to USER1", () => { - const allowedDm = resolveQQBotAccess({ - isGroup: false, - senderId: "USER1", - allowFrom: ["USER1"], - }); - const blockedGroup = resolveQQBotAccess({ - isGroup: true, - senderId: "INTRUDER", - allowFrom: ["USER1"], - }); - expect(allowedDm.decision).toBe("allow"); - expect(blockedGroup.decision).toBe("block"); - }); - }); -}); diff --git a/extensions/qqbot/src/engine/access/access-control.ts b/extensions/qqbot/src/engine/access/access-control.ts deleted file mode 100644 index f5dc9d6fce5..00000000000 --- a/extensions/qqbot/src/engine/access/access-control.ts +++ /dev/null @@ -1,226 +0,0 @@ -/** - * QQBot inbound access decision. - * - * This module is the single place where the QQBot engine decides - * whether an inbound message from a given sender is allowed to - * proceed into the outbound pipeline. The implementation mirrors the - * semantics of the framework-wide `resolveDmGroupAccessDecision` - * (`src/security/dm-policy-shared.ts`) but is kept standalone so the - * `engine/` layer does not pull in `openclaw/plugin-sdk/*` modules — - * a hard constraint shared with the standalone `openclaw-qqbot` build. - * - * If in the future we lift the zero-dependency rule in the engine - * layer, this file can be replaced by a thin adapter around the - * framework API with identical semantics. - */ - -import { resolveQQBotEffectivePolicies, type EffectivePolicyInput } from "./resolve-policy.js"; -import { createQQBotSenderMatcher, normalizeQQBotAllowFrom } from "./sender-match.js"; -import { - QQBOT_ACCESS_REASON, - type QQBotAccessResult, - type QQBotDmPolicy, - type QQBotGroupPolicy, -} from "./types.js"; - -interface QQBotAccessInput extends EffectivePolicyInput { - /** Whether the inbound originated in a group (or guild) chat. */ - isGroup: boolean; - /** The raw inbound sender id as provided by the QQ event. */ - senderId: string; -} - -/** - * Evaluate the inbound access policy. - * - * Semantics (aligned with `resolveDmGroupAccessDecision`): - * - Group message: - * - `groupPolicy=disabled` → block - * - `groupPolicy=open` → allow - * - `groupPolicy=allowlist`: - * - empty effectiveGroupAllowFrom → block (empty_allowlist) - * - sender not in list → block (not_allowlisted) - * - otherwise → allow - * - Direct message: - * - `dmPolicy=disabled` → block - * - `dmPolicy=open` → allow wildcard, legacy empty allowFrom, or matching allowFrom - * - `dmPolicy=allowlist`: - * - empty effectiveAllowFrom → block (empty_allowlist) - * - sender not in list → block (not_allowlisted) - * - otherwise → allow - * - * The function never throws; callers can rely on the returned - * `decision`/`reasonCode` pair for branching. - */ -export function resolveQQBotAccess(input: QQBotAccessInput): QQBotAccessResult { - const { dmPolicy, groupPolicy } = resolveQQBotEffectivePolicies(input); - - // Per framework convention: groupAllowFrom falls back to allowFrom - // when not provided. We preserve that behaviour so a single - // `allowFrom` entry locks down both DM and group. - const rawGroupAllowFrom = - input.groupAllowFrom && input.groupAllowFrom.length > 0 - ? input.groupAllowFrom - : (input.allowFrom ?? []); - - const normalizedAllowFrom = normalizeQQBotAllowFrom(input.allowFrom); - const effectiveAllowFrom = - dmPolicy === "open" && normalizedAllowFrom.length === 0 ? ["*"] : normalizedAllowFrom; - const effectiveGroupAllowFrom = normalizeQQBotAllowFrom(rawGroupAllowFrom); - - const isSenderAllowed = createQQBotSenderMatcher(input.senderId); - - if (input.isGroup) { - return evaluateGroupDecision({ - groupPolicy, - dmPolicy, - effectiveAllowFrom, - effectiveGroupAllowFrom, - isSenderAllowed, - }); - } - - return evaluateDmDecision({ - groupPolicy, - dmPolicy, - effectiveAllowFrom, - effectiveGroupAllowFrom, - isSenderAllowed, - }); -} - -// ---- internal helpers ------------------------------------------------ - -interface DecisionContext { - dmPolicy: QQBotDmPolicy; - groupPolicy: QQBotGroupPolicy; - effectiveAllowFrom: string[]; - effectiveGroupAllowFrom: string[]; - isSenderAllowed: (allowFrom: string[]) => boolean; -} - -function evaluateGroupDecision(ctx: DecisionContext): QQBotAccessResult { - const base = buildResultBase(ctx); - - if (ctx.groupPolicy === "disabled") { - return { - ...base, - decision: "block", - reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_DISABLED, - reason: "groupPolicy=disabled", - }; - } - - if (ctx.groupPolicy === "open") { - return { - ...base, - decision: "allow", - reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_ALLOWED, - reason: "groupPolicy=open", - }; - } - - // groupPolicy === "allowlist" - if (ctx.effectiveGroupAllowFrom.length === 0) { - return { - ...base, - decision: "block", - reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST, - reason: "groupPolicy=allowlist (empty allowlist)", - }; - } - - if (!ctx.isSenderAllowed(ctx.effectiveGroupAllowFrom)) { - return { - ...base, - decision: "block", - reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED, - reason: "groupPolicy=allowlist (not allowlisted)", - }; - } - - return { - ...base, - decision: "allow", - reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_ALLOWED, - reason: "groupPolicy=allowlist (allowlisted)", - }; -} - -function evaluateDmDecision(ctx: DecisionContext): QQBotAccessResult { - const base = buildResultBase(ctx); - - if (ctx.dmPolicy === "disabled") { - return { - ...base, - decision: "block", - reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_DISABLED, - reason: "dmPolicy=disabled", - }; - } - - if (ctx.dmPolicy === "open") { - if (ctx.effectiveAllowFrom.includes("*")) { - return { - ...base, - decision: "allow", - reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_OPEN, - reason: "dmPolicy=open", - }; - } - if (ctx.isSenderAllowed(ctx.effectiveAllowFrom)) { - return { - ...base, - decision: "allow", - reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_ALLOWLISTED, - reason: "dmPolicy=open (allowlisted)", - }; - } - return { - ...base, - decision: "block", - reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED, - reason: "dmPolicy=open (not allowlisted)", - }; - } - - // dmPolicy === "allowlist" - if (ctx.effectiveAllowFrom.length === 0) { - return { - ...base, - decision: "block", - reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_EMPTY_ALLOWLIST, - reason: "dmPolicy=allowlist (empty allowlist)", - }; - } - - if (!ctx.isSenderAllowed(ctx.effectiveAllowFrom)) { - return { - ...base, - decision: "block", - reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED, - reason: "dmPolicy=allowlist (not allowlisted)", - }; - } - - return { - ...base, - decision: "allow", - reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_ALLOWLISTED, - reason: "dmPolicy=allowlist (allowlisted)", - }; -} - -function buildResultBase( - ctx: DecisionContext, -): Pick< - QQBotAccessResult, - "effectiveAllowFrom" | "effectiveGroupAllowFrom" | "dmPolicy" | "groupPolicy" -> { - return { - effectiveAllowFrom: ctx.effectiveAllowFrom, - effectiveGroupAllowFrom: ctx.effectiveGroupAllowFrom, - dmPolicy: ctx.dmPolicy, - groupPolicy: ctx.groupPolicy, - }; -} diff --git a/extensions/qqbot/src/engine/access/index.ts b/extensions/qqbot/src/engine/access/index.ts index f26b8bded37..5733988c2ae 100644 --- a/extensions/qqbot/src/engine/access/index.ts +++ b/extensions/qqbot/src/engine/access/index.ts @@ -1,16 +1,2 @@ -/** - * QQBot inbound access control — public entry points. - * - * Consumers (inbound-pipeline and future adapters) should import from - * this barrel to keep the internal module layout opaque. - */ - -export { resolveQQBotAccess } from "./access-control.js"; export { createQQBotSenderMatcher, normalizeQQBotAllowFrom } from "./sender-match.js"; -export { - type QQBotAccessDecision, - type QQBotAccessReasonCode, - type QQBotAccessResult, - type QQBotDmPolicy, - type QQBotGroupPolicy, -} from "./types.js"; +export { type QQBotDmPolicy, type QQBotGroupPolicy } from "./types.js"; diff --git a/extensions/qqbot/src/engine/access/resolve-policy.ts b/extensions/qqbot/src/engine/access/resolve-policy.ts index 5d77ec6947c..761f5281f37 100644 --- a/extensions/qqbot/src/engine/access/resolve-policy.ts +++ b/extensions/qqbot/src/engine/access/resolve-policy.ts @@ -1,21 +1,5 @@ -/** - * Effective-policy resolver. - * - * Maps a raw `QQBotAccountConfig` to the concrete `dmPolicy`/`groupPolicy` - * values that the access engine consumes. Provides backwards-compatible - * defaults for accounts that only have the legacy `allowFrom` field: - * - * - Empty `allowFrom` or containing `"*"` → `"open"` (the historical - * behaviour before P0/P1 landed). - * - Non-empty `allowFrom` without `"*"` → `"allowlist"` (what a - * security-conscious operator almost certainly meant). - * - * An explicit `dmPolicy`/`groupPolicy` always wins over the inference. - */ - import type { QQBotDmPolicy, QQBotGroupPolicy } from "./types.js"; -/** Subset of the account config fields this resolver actually reads. */ export interface EffectivePolicyInput { allowFrom?: Array | null; groupAllowFrom?: Array | null; @@ -27,17 +11,9 @@ function hasRealRestriction(list: Array | null | undefined): bo if (!list || list.length === 0) { return false; } - // A list that only contains `"*"` is logically equivalent to open. return !list.every((entry) => String(entry).trim() === "*"); } -/** - * Derive the effective dmPolicy and groupPolicy applied at runtime. - * - * Caller should pass the raw `QQBotAccountConfig`. The resolver does - * not look at `groups[id]` overrides — per-group overrides are layered - * on top elsewhere (see `inbound-pipeline` mention gating). - */ export function resolveQQBotEffectivePolicies(input: EffectivePolicyInput): { dmPolicy: QQBotDmPolicy; groupPolicy: QQBotGroupPolicy; @@ -47,9 +23,6 @@ export function resolveQQBotEffectivePolicies(input: EffectivePolicyInput): { const dmPolicy: QQBotDmPolicy = input.dmPolicy ?? (allowFromRestricted ? "allowlist" : "open"); - // groupPolicy defaults: if an explicit groupAllowFrom is provided and - // restricts, enforce allowlist. Otherwise fall back to the same rule - // as DM (so a single `allowFrom` entry locks down both DM and group). const groupPolicy: QQBotGroupPolicy = input.groupPolicy ?? (groupAllowFromRestricted || allowFromRestricted ? "allowlist" : "open"); diff --git a/extensions/qqbot/src/engine/access/types.ts b/extensions/qqbot/src/engine/access/types.ts index 249eb207511..fdc5da4d83a 100644 --- a/extensions/qqbot/src/engine/access/types.ts +++ b/extensions/qqbot/src/engine/access/types.ts @@ -1,53 +1,2 @@ -/** - * QQBot access-control primitive types. - * - * Mirrors the semantics of the framework-shared `DmPolicy` and - * `DmGroupAccessDecision` types while staying zero-dependency so the - * engine layer remains portable across the built-in and standalone - * plugin builds. - * - * The reason codes here intentionally match - * `src/security/dm-policy-shared.ts::DM_GROUP_ACCESS_REASON` so metric - * dashboards can treat QQBot decisions identically to WhatsApp / - * Telegram / Discord decisions. - */ - -/** DM-level policy selecting between open / allowlist / disabled gating. */ export type QQBotDmPolicy = "open" | "allowlist" | "disabled"; - -/** Group-level policy selecting between open / allowlist / disabled gating. */ export type QQBotGroupPolicy = "open" | "allowlist" | "disabled"; - -/** High-level outcome returned by the access gate. */ -export type QQBotAccessDecision = "allow" | "block"; - -/** Structured reason codes used in logs and metrics. */ -export const QQBOT_ACCESS_REASON = { - DM_POLICY_OPEN: "dm_policy_open", - DM_POLICY_DISABLED: "dm_policy_disabled", - DM_POLICY_ALLOWLISTED: "dm_policy_allowlisted", - DM_POLICY_NOT_ALLOWLISTED: "dm_policy_not_allowlisted", - DM_POLICY_EMPTY_ALLOWLIST: "dm_policy_empty_allowlist", - GROUP_POLICY_ALLOWED: "group_policy_allowed", - GROUP_POLICY_DISABLED: "group_policy_disabled", - GROUP_POLICY_EMPTY_ALLOWLIST: "group_policy_empty_allowlist", - GROUP_POLICY_NOT_ALLOWLISTED: "group_policy_not_allowlisted", - BOT_SELF_ECHO: "bot_self_echo", -} as const; - -export type QQBotAccessReasonCode = (typeof QQBOT_ACCESS_REASON)[keyof typeof QQBOT_ACCESS_REASON]; - -/** Result of the access gate evaluation. */ -export interface QQBotAccessResult { - decision: QQBotAccessDecision; - reasonCode: QQBotAccessReasonCode; - /** Human-readable reason suitable for logging. */ - reason: string; - /** The allowFrom list that was actually compared against. */ - effectiveAllowFrom: string[]; - /** The groupAllowFrom list that was actually compared against. */ - effectiveGroupAllowFrom: string[]; - /** The dm/group policies that were used (after defaults were applied). */ - dmPolicy: QQBotDmPolicy; - groupPolicy: QQBotGroupPolicy; -} diff --git a/extensions/qqbot/src/engine/adapter/index.ts b/extensions/qqbot/src/engine/adapter/index.ts index 61448f3a8ac..fb4d8e6dc14 100644 --- a/extensions/qqbot/src/engine/adapter/index.ts +++ b/extensions/qqbot/src/engine/adapter/index.ts @@ -1,126 +1,64 @@ -/** - * Engine adapter layer — all external dependency interfaces unified here. - * - * This directory is the **single source of truth** for every interface - * the engine uses to talk to the outside world. - * - * ## Two-layer DI architecture - * - * ### Layer 1: EngineAdapters (构造参数注入 — preferred) - * - * Used for capabilities consumed within the pipeline call stack. - * Injected once via {@link CoreGatewayContext.adapters}, threaded - * through {@link InboundPipelineDeps.adapters}, consumed by stages. - * - * - {@link HistoryPort} — group history record/build/clear - * - {@link MentionGatePort} — mention + command gate evaluation - * - {@link AudioConvertPort} — inbound SILK→WAV conversion - * - {@link OutboundAudioPort} — outbound WAV→SILK conversion - * - {@link CommandsPort} — slash-command version/approve dependencies - * - * ### Layer 2: PlatformAdapter (global singleton — leaf utilities) - * - * Used by leaf utility functions (`file-utils`, `image-size`, - * `platform`, `config/resolve`) that sit outside the pipeline and - * cannot receive a `deps` parameter. Registered once at startup. - * - * - {@link PlatformAdapter} — SSRF, secrets, media fetch, temp dir - */ - +import type { ResolvedChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime"; +import type { EffectivePolicyInput } from "../access/resolve-policy.js"; import type { FetchMediaOptions, FetchMediaResult, SecretInputRef } from "./types.js"; -// ============ EngineAdapters (aggregated port injection) ============ +export type QQBotInboundAccess = ResolvedChannelMessageIngress; + +export interface AccessPort { + resolveInboundAccess( + input: EffectivePolicyInput & { + cfg: unknown; + accountId: string; + isGroup: boolean; + senderId: string; + conversationId: string; + }, + ): QQBotInboundAccess | Promise; + + resolveSlashCommandAuthorization(input: { + cfg: unknown; + accountId: string; + isGroup: boolean; + senderId: string; + conversationId: string; + allowFrom?: Array; + groupAllowFrom?: Array; + commandsAllowFrom?: Array; + }): boolean | Promise; +} -/** - * Aggregated adapter ports injected via `CoreGatewayContext.adapters`. - * - * All fields are required — the bridge layer must provide every adapter. - * The engine no longer falls back to built-in implementations. - */ export interface EngineAdapters { - /** Group history record/build/clear — backed by SDK `reply-history`. */ history: import("./history.port.js").HistoryPort; - /** Mention + command gate evaluation — backed by SDK `channel-mention-gating`. */ mentionGate: import("./mention-gate.port.js").MentionGatePort; - /** Inbound audio conversion (SILK→WAV, voice detection). */ + access: AccessPort; audioConvert: import("./audio.port.js").AudioConvertPort; - /** Outbound audio conversion (WAV→SILK, audio detection). */ outboundAudio: import("./audio.port.js").OutboundAudioPort; - /** Slash-command dependencies (version, approve runtime). */ commands: import("./commands.port.js").CommandsPort; } -// ============ PlatformAdapter (global singleton — leaf utilities) ============ - -/** Platform adapter that leaf utilities use for framework-specific operations. */ export interface PlatformAdapter { - /** Validate that a remote URL is safe to fetch (SSRF protection). */ validateRemoteUrl(url: string, options?: { allowPrivate?: boolean }): Promise; - - /** Resolve a secret value (SecretInput or plain string) to a plain string. */ resolveSecret(value: string | SecretInputRef | undefined): Promise; - - /** Download a remote file to a local directory. Returns the local file path. */ downloadFile(url: string, destDir: string, filename?: string): Promise; - - /** - * Fetch remote media with SSRF protection. - * Replaces direct usage of `fetchRemoteMedia` from `plugin-sdk/media-runtime`. - */ fetchMedia(options: FetchMediaOptions): Promise; - - /** Return the preferred temporary directory for the platform. */ getTempDir(): string; - - /** Check whether a secret input value has been configured (non-empty). */ hasConfiguredSecret(value: unknown): boolean; - - /** - * Normalize a raw SecretInput value into a plain string. - * For unresolved references (e.g. `$secret:xxx`), returns the raw reference string. - */ normalizeSecretInputString(value: unknown): string | undefined; - - /** - * Resolve a SecretInput value into the final plain-text secret. - * For secret references, resolves them to actual values via the platform's secret store. - */ resolveSecretInputString(params: { value: unknown; path: string }): string | undefined; - - /** - * Submit an approval decision to the framework's approval gateway. - * Optional — only available when the framework supports approvals. - * Returns true if the decision was submitted successfully. - */ resolveApproval?(approvalId: string, decision: string): Promise; } let _adapter: PlatformAdapter | null = null; let _adapterFactory: (() => PlatformAdapter) | null = null; -/** Register the platform adapter. Called once during startup. */ export function registerPlatformAdapter(adapter: PlatformAdapter): void { _adapter = adapter; } -/** - * Register a factory that creates the PlatformAdapter on first access. - * - * This decouples adapter availability from side-effect import ordering. - * The factory is invoked at most once — on the first `getPlatformAdapter()` - * call when no adapter has been explicitly registered yet. - */ export function registerPlatformAdapterFactory(factory: () => PlatformAdapter): void { _adapterFactory = factory; } -/** - * Get the registered platform adapter. - * - * If no adapter has been explicitly registered yet but a factory was provided - * via `registerPlatformAdapterFactory()`, the factory is invoked to create - * and register the adapter automatically. - */ export function getPlatformAdapter(): PlatformAdapter { if (!_adapter && _adapterFactory) { _adapter = _adapterFactory(); @@ -133,7 +71,6 @@ export function getPlatformAdapter(): PlatformAdapter { return _adapter; } -/** Check whether a platform adapter has been registered (or can be created from a factory). */ export function hasPlatformAdapter(): boolean { return _adapter !== null || _adapterFactory !== null; } diff --git a/extensions/qqbot/src/engine/commands/slash-command-auth.ts b/extensions/qqbot/src/engine/commands/slash-command-auth.ts index 678fa0187c1..69dfbe63ba7 100644 --- a/extensions/qqbot/src/engine/commands/slash-command-auth.ts +++ b/extensions/qqbot/src/engine/commands/slash-command-auth.ts @@ -1,9 +1,9 @@ /** * Pre-dispatch authorization for requireAuth slash commands. * - * Unlike the access-stage's `resolveCommandAuthorized` (which permits - * `dm_policy_open` senders — i.e. anyone), this function requires the - * sender to appear in an **explicit non-wildcard** allowFrom list. + * Unlike the inbound message ingress command projection (which permits + * open-policy chat senders), this function requires the sender to appear in an + * **explicit non-wildcard** allowFrom list. * * Rationale: sensitive operations (log export, file deletion, approval * config changes) must be gated behind a deliberate operator decision. diff --git a/extensions/qqbot/src/engine/commands/slash-command-handler.ts b/extensions/qqbot/src/engine/commands/slash-command-handler.ts index 3b2c5cd7f72..e1fe706f746 100644 --- a/extensions/qqbot/src/engine/commands/slash-command-handler.ts +++ b/extensions/qqbot/src/engine/commands/slash-command-handler.ts @@ -25,6 +25,14 @@ export interface SlashCommandHandlerContext { log?: EngineLogger; getMessagePeerId: (msg: QueuedMessage) => string; getQueueSnapshot: (peerId: string) => QueueSnapshot; + resolveCommandAuthorized?: (params: { + isGroup: boolean; + senderId: string; + conversationId: string; + allowFrom?: Array; + groupAllowFrom?: Array; + commandsAllowFrom?: Array; + }) => boolean | Promise; } // ============ Constants ============ @@ -63,6 +71,24 @@ export async function trySlashCommand( // Normal slash command — try to match and execute. const receivedAt = Date.now(); const peerId = ctx.getMessagePeerId(msg); + const isGroup = msg.type === "group" || msg.type === "guild"; + const commandsAllowFrom = resolveQQBotCommandsAllowFrom(ctx.cfg); + const commandAuthorized = ctx.resolveCommandAuthorized + ? await ctx.resolveCommandAuthorized({ + isGroup, + senderId: msg.senderId, + conversationId: msg.groupOpenid ?? msg.channelId ?? msg.senderId, + allowFrom: account.config?.allowFrom, + groupAllowFrom: account.config?.groupAllowFrom, + commandsAllowFrom, + }) + : resolveSlashCommandAuth({ + senderId: msg.senderId, + isGroup, + allowFrom: account.config?.allowFrom, + groupAllowFrom: account.config?.groupAllowFrom, + commandsAllowFrom, + }); const cmdCtx: SlashCommandContext = { type: msg.type, senderId: msg.senderId, @@ -77,13 +103,7 @@ export async function trySlashCommand( accountId: account.accountId, appId: account.appId, accountConfig: account.config, - commandAuthorized: resolveSlashCommandAuth({ - senderId: msg.senderId, - isGroup: msg.type === "group" || msg.type === "guild", - allowFrom: account.config?.allowFrom, - groupAllowFrom: account.config?.groupAllowFrom, - commandsAllowFrom: resolveQQBotCommandsAllowFrom(ctx.cfg), - }), + commandAuthorized, queueSnapshot: ctx.getQueueSnapshot(peerId), }; diff --git a/extensions/qqbot/src/engine/config/group.ts b/extensions/qqbot/src/engine/config/group.ts index 5b67515cf4b..3328501ba91 100644 --- a/extensions/qqbot/src/engine/config/group.ts +++ b/extensions/qqbot/src/engine/config/group.ts @@ -1,60 +1,19 @@ -/** - * QQBot group configuration resolution (pure logic). - * QQBot 群配置解析(纯逻辑层)。 - * - * Resolves per-group settings that the inbound pipeline needs to decide how - * to gate and contextualize group messages. Reads from a raw config object - * produced by the framework's config loader, with a `specific > wildcard - * ("*") > default` precedence chain. - * - * All functions are **pure** (no I/O, no external state) — making them - * portable to the standalone plugin build and trivially unit-testable. - */ - import { asOptionalObjectRecord as asRecord } from "../utils/string-normalize.js"; import { resolveAccountBase } from "./resolve.js"; -// ============ Types ============ - -/** - * Tool policy — which tool palette an agent should use in a given group. - * - * - `full`: default allow everything (no engine-side restriction). - * - `restricted`: engine returns an empty allowlist so the framework falls - * back to its built-in restricted palette. - * - `none`: deny all tools. - */ type GroupToolPolicy = "full" | "restricted" | "none"; -/** Per-group configuration — everything that may be overridden per group. */ interface GroupConfig { - /** Whether the bot requires @mention to respond. Defaults to true. */ requireMention: boolean; - /** - * When true, group messages that @other users (but not the bot) are - * dropped silently without reaching the AI pipeline. - */ ignoreOtherMentions: boolean; - /** Tool palette policy. Defaults to "restricted". */ toolPolicy: GroupToolPolicy; - /** Human-readable group name. Empty string if not configured. */ name: string; - /** Per-group behaviour prompt appended to the system prompt. */ prompt?: string; - /** - * Number of non-@ history messages buffered per group. Clamped to 0 when - * disabled. The default matches the standalone build's `50`. - */ historyLimit: number; } -// ============ Defaults ============ - -/** Default history limit — matches the standalone build. */ export const DEFAULT_GROUP_HISTORY_LIMIT = 50; -/** Default group behaviour prompt. Exported so the gating stage can use - * the same fallback when no per-group `prompt` is configured. */ export const DEFAULT_GROUP_PROMPT = "If the sender is a bot, respond only when they explicitly @mention you to ask a question or request assistance with a specific task; keep your replies concise and clear, avoiding the urge to race other bots to answer or engage in lengthy, unproductive exchanges. In group chats, prioritize responding to messages from human users; bots should maintain a collaborative rather than competitive dynamic to ensure the conversation remains orderly and does not result in message flooding."; @@ -66,9 +25,6 @@ const DEFAULT_GROUP_CONFIG: Readonly> = { historyLimit: DEFAULT_GROUP_HISTORY_LIMIT, }; -// ============ Helpers ============ - -/** Read a named account's raw `groups` map from an OpenClawConfig. */ function readGroupsMap( cfg: Record, accountId?: string | null, @@ -78,7 +34,6 @@ function readGroupsMap( if (!groups) { return {}; } - // Only keep sub-objects; skip scalars produced by user mistakes. const normalized: Record> = {}; for (const [key, value] of Object.entries(groups)) { const sub = asRecord(value); @@ -112,14 +67,6 @@ function readHistoryLimit(obj: Record, key: string): number | u return Math.max(0, Math.floor(v)); } -// ============ Public API ============ - -/** - * Resolve per-group configuration with `specific > "*" > default` precedence. - * - * When `groupOpenid` is not provided, only the wildcard/default values are - * returned. This lets callers query the "default" behaviour for new groups. - */ export function resolveGroupConfig( cfg: Record, groupOpenid?: string | null, @@ -151,7 +98,6 @@ export function resolveGroupConfig( }; } -/** Resolve the effective `historyLimit` (>= 0) for a given group. */ export function resolveHistoryLimit( cfg: Record, groupOpenid?: string | null, @@ -160,7 +106,6 @@ export function resolveHistoryLimit( return resolveGroupConfig(cfg, groupOpenid, accountId).historyLimit; } -/** Resolve `requireMention` for a given group. */ export function resolveRequireMention( cfg: Record, groupOpenid?: string | null, @@ -169,7 +114,6 @@ export function resolveRequireMention( return resolveGroupConfig(cfg, groupOpenid, accountId).requireMention; } -/** Resolve `ignoreOtherMentions` for a given group. */ export function resolveIgnoreOtherMentions( cfg: Record, groupOpenid?: string | null, @@ -234,13 +178,6 @@ interface GroupSettings { mentionPatterns: string[]; } -/** - * Resolve all per-inbound group-related settings in one pass. - * - * Prefer this over calling `resolveHistoryLimit` / `resolveRequireMention` - * / etc. individually in hot paths — each of those currently re-walks - * the config tree on its own. - */ export function resolveGroupSettings(params: { cfg: Record; groupOpenid: string; @@ -258,17 +195,10 @@ interface AgentEntry { groupChat?: { mentionPatterns?: unknown }; } -/** - * Resolve mentionPatterns with `agent > global > []` precedence. - * - * Mirrors the framework's `messages.groupChat.mentionPatterns` / per-agent - * `agents.list[].groupChat.mentionPatterns` chain. - */ export function resolveMentionPatterns( cfg: Record, agentId?: string | null, ): string[] { - // ---- 1. Agent-level ---- if (agentId) { const agents = asRecord(cfg.agents); const list = Array.isArray(agents?.list) ? (agents?.list as AgentEntry[]) : []; @@ -284,7 +214,6 @@ export function resolveMentionPatterns( } } - // ---- 2. Global level ---- const messages = asRecord(cfg.messages); const globalGroupChat = asRecord(messages?.groupChat); if (globalGroupChat && Object.hasOwn(globalGroupChat, "mentionPatterns")) { @@ -294,6 +223,5 @@ export function resolveMentionPatterns( : []; } - // ---- 3. Default ---- return []; } diff --git a/extensions/qqbot/src/engine/gateway/gateway-connection.ts b/extensions/qqbot/src/engine/gateway/gateway-connection.ts index 4c2f3275b3b..5bbfedb9328 100644 --- a/extensions/qqbot/src/engine/gateway/gateway-connection.ts +++ b/extensions/qqbot/src/engine/gateway/gateway-connection.ts @@ -1,11 +1,5 @@ -/** - * GatewayConnection — WebSocket lifecycle, heartbeat, reconnect, and session persistence. - * - * Encapsulates all connection state as class fields (replaces 11 closure variables). - * Event handling and message processing are delegated to injected handlers. - */ - import WebSocket from "ws"; +import type { EngineAdapters } from "../adapter/index.js"; import { trySlashCommand, type SlashCommandHandlerContext, @@ -30,28 +24,21 @@ import { ReconnectState } from "./reconnect.js"; import type { GatewayAccount, EngineLogger, GatewayPluginRuntime, WSPayload } from "./types.js"; import { createQQWSClient } from "./ws-client.js"; -// ============ Connection context ============ - interface GatewayConnectionContext { account: GatewayAccount; abortSignal: AbortSignal; cfg: unknown; log?: EngineLogger; runtime: GatewayPluginRuntime; + adapters: EngineAdapters; onReady?: (data: unknown) => void; - /** Called when a RESUMED event is received (reconnect success). */ onResumed?: (data: unknown) => void; onError?: (error: Error) => void; - /** Process a queued message (inbound pipeline → outbound dispatch). */ handleMessage: (event: QueuedMessage) => Promise; - /** Called when an INTERACTION_CREATE event is received (e.g. approval button clicks). */ onInteraction?: (event: InteractionEvent) => void; } -// ============ GatewayConnection ============ - export class GatewayConnection { - // ---- Connection state ---- private isAborted = false; private currentWs: WebSocket | null = null; private heartbeatInterval: ReturnType | null = null; @@ -75,7 +62,6 @@ export class GatewayConnection { }); } - /** Start the connection loop. Resolves when abortSignal fires. */ async start(): Promise { this.restoreSession(); this.registerAbortHandler(); @@ -85,8 +71,6 @@ export class GatewayConnection { }); } - // ============ Session persistence ============ - private restoreSession(): void { const { account, log } = this.ctx; const saved = loadSession(account.accountId, account.appId); @@ -113,8 +97,6 @@ export class GatewayConnection { }); } - // ============ Abort + cleanup ============ - private registerAbortHandler(): void { const { account, abortSignal, log: _log } = this.ctx; abortSignal.addEventListener("abort", () => { @@ -145,8 +127,6 @@ export class GatewayConnection { this.currentWs = null; } - // ============ Reconnect ============ - private scheduleReconnect(customDelay?: number): void { const { account: _account, log } = this.ctx; if (this.isAborted || this.reconnect.isExhausted()) { @@ -166,8 +146,6 @@ export class GatewayConnection { }, delay); } - // ============ Connect ============ - private async connect(): Promise { const { account, log } = this.ctx; @@ -195,13 +173,18 @@ export class GatewayConnection { }); this.currentWs = ws; - // ---- Slash command interception ---- const slashCtx: SlashCommandHandlerContext = { account, cfg: this.ctx.cfg, log, getMessagePeerId: (msg) => this.msgQueue.getMessagePeerId(msg), getQueueSnapshot: (peerId) => this.msgQueue.getSnapshot(peerId), + resolveCommandAuthorized: (params) => + this.ctx.adapters.access.resolveSlashCommandAuthorization({ + cfg: this.ctx.cfg, + accountId: account.accountId, + ...params, + }), }; const trySlashCommandOrEnqueue = async (msg: QueuedMessage): Promise => { diff --git a/extensions/qqbot/src/engine/gateway/gateway.ts b/extensions/qqbot/src/engine/gateway/gateway.ts index c7f8790a070..23b6a1f807a 100644 --- a/extensions/qqbot/src/engine/gateway/gateway.ts +++ b/extensions/qqbot/src/engine/gateway/gateway.ts @@ -1,17 +1,3 @@ -/** - * Core gateway entry point — thin shell that wires together: - * - * - GatewayConnection: WebSocket lifecycle, heartbeat, reconnect - * - buildInboundContext: content building, attachments, quote resolution - * - dispatchOutbound: AI dispatch, deliver callbacks, timeouts - * - * The only responsibilities of this file are: - * 1. Initialize adapters from EngineAdapters - * 2. Initialize API config + refIdx cache hook - * 3. Create the message handler (inbound → outbound pipeline) - * 4. Start GatewayConnection - */ - import path from "node:path"; import { initCommands } from "../commands/slash-commands-impl.js"; import { createNodeSessionStoreReader } from "../group/activation.js"; @@ -42,27 +28,18 @@ import type { } from "./types.js"; import { TypingKeepAlive, TYPING_INPUT_SECOND } from "./typing-keepalive.js"; -// Re-export context type for consumers. export type { CoreGatewayContext } from "./types.js"; -// ============ startGateway ============ - -/** - * Start the Gateway WebSocket connection with automatic reconnect support. - */ export async function startGateway(ctx: CoreGatewayContext): Promise { const { account, log, runtime, adapters } = ctx; - // ---- 1. Initialize adapters ---- setOutboundAudioPort(adapters.outboundAudio); initCommands(adapters.commands); - // ---- 2. Validate ---- if (!account.appId || !account.clientSecret) { throw new Error("QQBot not configured (missing appId or clientSecret)"); } - // ---- 3. Diagnostics ---- const diag = await runDiagnostics(); if (diag.warnings.length > 0) { for (const w of diag.warnings) { @@ -70,11 +47,9 @@ export async function startGateway(ctx: CoreGatewayContext): Promise { } } - // ---- 4. API config ---- initApiConfig(account.appId, { markdownSupport: account.markdownSupport }); log?.debug?.(`API config: markdownSupport=${account.markdownSupport}`); - // ---- 5. Outbound refIdx cache hook ---- onMessageSent(account.appId, (refIdx, meta) => { log?.info( `onMessageSent called: refIdx=${refIdx}, mediaType=${meta.mediaType}, ttsText=${meta.ttsText?.slice(0, 30)}`, @@ -105,7 +80,6 @@ export async function startGateway(ctx: CoreGatewayContext): Promise { }); }); - // ---- 6. Group support (per-connection state) ---- const groupOpts = { enabled: ctx.group?.enabled ?? true, allowTextCommands: ctx.group?.allowTextCommands, @@ -121,7 +95,6 @@ export async function startGateway(ctx: CoreGatewayContext): Promise { ? (groupOpts.sessionStoreReader ?? createNodeSessionStoreReader()) : undefined; - // ---- 7. Message handler ---- const handleMessage = async (event: QueuedMessage): Promise => { log?.info(`Processing message from ${event.senderId}: ${event.content}`, { accountId: account.accountId, @@ -161,9 +134,6 @@ export async function startGateway(ctx: CoreGatewayContext): Promise { return; } - // Group gate decided to stop early (drop_other_mention, block, skip - // no-mention). History has already been recorded inside the - // pipeline; there is no outbound to dispatch. if (inbound.skipped) { log?.info( `Skipped group inbound: reason=${inbound.skipReason ?? "unknown"} group=${event.groupOpenid ?? ""}`, @@ -192,9 +162,6 @@ export async function startGateway(ctx: CoreGatewayContext): Promise { log?.error(`Message processing failed: ${err instanceof Error ? err.message : String(err)}`); } finally { inbound.typing.keepAlive?.stop(); - // Reset the buffered non-@ chatter after every @-activation turn - // (success or failure), matching the standalone build. Guards - // against stale history leaking into the next reply. if (event.type === "group" && event.groupOpenid && inbound.group) { clearGroupPendingHistory({ historyMap: groupHistories, @@ -206,16 +173,15 @@ export async function startGateway(ctx: CoreGatewayContext): Promise { } }; - // ---- 8. Interaction handler ---- const handleInteraction = createInteractionHandler(account, ctx.runtime, log); - // ---- 9. Start connection ---- const connection = new GatewayConnection({ account, abortSignal: ctx.abortSignal, cfg: ctx.cfg, log, runtime, + adapters, onReady: ctx.onReady, onResumed: ctx.onResumed, onError: ctx.onError, diff --git a/extensions/qqbot/src/engine/gateway/inbound-context.ts b/extensions/qqbot/src/engine/gateway/inbound-context.ts index 959bd500d76..8c3d86861a6 100644 --- a/extensions/qqbot/src/engine/gateway/inbound-context.ts +++ b/extensions/qqbot/src/engine/gateway/inbound-context.ts @@ -1,30 +1,12 @@ -/** - * InboundContext — the structured result of the inbound pipeline. - * - * Connects the inbound stage (content building, attachment processing, - * quote resolution) with the outbound stage (AI dispatch, deliver callbacks). - * - * All fields are readonly after construction. The outbound dispatcher - * reads from this object but never mutates it. - */ - -import type { QQBotAccessDecision, QQBotAccessReasonCode } from "../access/index.js"; +import type { ChannelIngressDecision } from "openclaw/plugin-sdk/channel-ingress-runtime"; import type { EngineAdapters } from "../adapter/index.js"; import type { GroupActivationMode, SessionStoreReader } from "../group/activation.js"; import type { HistoryEntry } from "../group/history.js"; import type { GroupMessageGateResult } from "../group/message-gating.js"; import type { QueuedMessage } from "./message-queue.js"; -import type { - GatewayAccount, - EngineLogger, - GatewayPluginRuntime, - ProcessedAttachments, -} from "./types.js"; +import type { GatewayAccount, EngineLogger, GatewayPluginRuntime } from "./types.js"; import type { TypingKeepAlive } from "./typing-keepalive.js"; -// ============ InboundContext ============ - -/** Quote (reply-to) metadata resolved during inbound processing. */ export interface ReplyToInfo { id: string; body?: string; @@ -32,164 +14,69 @@ export interface ReplyToInfo { isQuote: boolean; } -/** - * Group-specific inbound metadata. - * - * Populated for group / guild events; left `undefined` for DMs. Keeping - * the group fields under a nested bag makes it obvious which fields are - * safe to read only when `isGroupChat === true`. - * - * The shape is kept small on purpose: everything derivable from `gate` - * (raw wasMentioned / explicit / implicit / hasAnyMention / bypass) is - * stored once on `gate`, not duplicated on the outer object. - */ export interface InboundGroupInfo { - // ---- Gating decision ---- - /** Full gate evaluation result (source of truth for mention state). */ gate: GroupMessageGateResult; - /** Effective activation mode after session-store / cfg merge. */ activation: GroupActivationMode; - - // ---- Persistence-relevant ---- - /** Per-group history buffer cap. Zero → disabled. */ historyLimit: number; - /** `true` if this message was built by merging several queued entries. */ isMerged: boolean; - /** The unfiltered list of queued messages when `isMerged`, else undefined. */ mergedMessages?: readonly QueuedMessage[]; - - // ---- Presentation / prompt inputs ---- - /** Bundle of display-only strings; assembled by the envelope stage. */ display: { - /** Human-readable group name ("My Group" / first 8 chars of openid). */ groupName: string; - /** Sender label ("Nick (OPENID)" / "OPENID") for the UI. */ senderLabel: string; - /** Channel-level intro hint contributed by the platform adapter. */ introHint?: string; - /** Per-group behaviour prompt appended to the system prompt. */ behaviorPrompt?: string; }; } -/** Fully resolved inbound context passed to the outbound dispatcher. */ export interface InboundContext { - // ---- Original event ---- event: QueuedMessage; - - // ---- Routing ---- route: { sessionKey: string; accountId: string; agentId?: string }; isGroupChat: boolean; peerId: string; - /** Fully qualified target address: "qqbot:c2c:xxx" / "qqbot:group:xxx" etc. */ qualifiedTarget: string; fromAddress: string; - - // ---- Content ---- - /** event.content after parseFaceTags. */ - parsedContent: string; - /** parsedContent + voiceText + attachmentInfo — the user-visible text. */ - userContent: string; - /** "[Quoted message begins]…[ends]" or empty. */ - quotePart: string; - /** Per-message dynamic metadata lines (images, voice, ASR). */ - dynamicCtx: string; - /** quotePart + userContent. */ - userMessage: string; - /** dynamicCtx + userMessage (or raw content for slash commands). */ agentBody: string; - /** Formatted inbound envelope (Web UI body). */ body: string; - - // ---- System prompts ---- - systemPrompts: string[]; groupSystemPrompt?: string; - - // ---- Attachments ---- - attachments: ProcessedAttachments; localMediaPaths: string[]; localMediaTypes: string[]; remoteMediaUrls: string[]; - remoteMediaTypes: string[]; - - // ---- Voice ---- uniqueVoicePaths: string[]; uniqueVoiceUrls: string[]; uniqueVoiceAsrReferTexts: string[]; voiceMediaTypes: string[]; hasAsrReferFallback: boolean; voiceTranscriptSources: string[]; - - // ---- Reply-to / Quote ---- replyTo?: ReplyToInfo; - - // ---- Auth ---- commandAuthorized: boolean; - - // ---- Group ---- - /** Populated only for group / guild messages. */ group?: InboundGroupInfo; - - // ---- Blocking / skipping ---- - /** - * Whether the inbound message should be blocked outright (access policy - * refused the sender). Mutually exclusive with `skipped`. - */ blocked: boolean; - /** Human-readable reason for `blocked`, for logging only. */ blockReason?: string; - /** Structured reason code for `blocked`. */ - blockReasonCode?: QQBotAccessReasonCode; - /** The raw access decision produced by the policy engine. */ - accessDecision?: QQBotAccessDecision; - /** - * Whether the inbound was accepted by access control but stopped before - * AI dispatch by the group gate (e.g. "skip_no_mention"). The caller - * should NOT forward `skipped` messages to the outbound dispatcher, but - * history / activity side-effects may already have been applied. - */ + blockReasonCode?: string; + accessDecision?: ChannelIngressDecision["decision"]; skipped: boolean; - /** Structured reason code for `skipped`. */ skipReason?: "drop_other_mention" | "block_unauthorized_command" | "skip_no_mention"; - - // ---- Typing ---- typing: { keepAlive: TypingKeepAlive | null }; - /** refIdx returned by the initial InputNotify call. */ inputNotifyRefIdx?: string; } -// ============ Pipeline dependencies ============ - -/** Dependencies injected into the inbound pipeline. */ export interface InboundPipelineDeps { account: GatewayAccount; cfg: unknown; log?: EngineLogger; runtime: GatewayPluginRuntime; - /** Start typing indicator and return the refIdx from InputNotify. */ startTyping: (event: QueuedMessage) => Promise<{ refIdx?: string; keepAlive: TypingKeepAlive | null; }>; - // ---- Group dependencies (optional — omit when the caller doesn't need - // group support, e.g. a DM-only test harness). ---- - /** Shared per-connection history buffer, created by the gateway. */ groupHistories?: Map; - /** Session-store reader for activation-mode overrides. */ sessionStoreReader?: SessionStoreReader; - /** Whether text-based control commands are enabled globally. */ allowTextCommands?: boolean; - /** - * Framework probe that returns true when `content` is a known control - * command. Injected to avoid hard-coding a list of commands in engine. - */ isControlCommand?: (content: string) => boolean; - /** Optional platform hook that contributes a channel-level intro hint. */ resolveGroupIntroHint?: (params: { cfg: unknown; accountId: string; groupId: string; }) => string | undefined; - /** SDK adapter ports for delegating to shared implementations. */ adapters: EngineAdapters; } diff --git a/extensions/qqbot/src/engine/gateway/inbound-pipeline.self-echo.test.ts b/extensions/qqbot/src/engine/gateway/inbound-pipeline.self-echo.test.ts index dfb8d38443a..874e4cacf8f 100644 --- a/extensions/qqbot/src/engine/gateway/inbound-pipeline.self-echo.test.ts +++ b/extensions/qqbot/src/engine/gateway/inbound-pipeline.self-echo.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { QQBotInboundAccess } from "../adapter/index.js"; import type { RefIndexEntry } from "../ref/types.js"; import type { InboundPipelineDeps } from "./inbound-context.js"; import { buildInboundContext } from "./inbound-pipeline.js"; @@ -47,6 +48,28 @@ const account: GatewayAccount = { config: {}, }; +const emptyAllowlist: QQBotInboundAccess["state"]["allowlists"]["dm"] = { + rawEntryCount: 0, + normalizedEntries: [], + invalidEntries: [], + disabledEntries: [], + matchedEntryIds: [], + hasConfiguredEntries: false, + hasMatchableEntries: false, + hasWildcard: false, + accessGroups: { + referenced: [], + matched: [], + missing: [], + unsupported: [], + failed: [], + }, + match: { + matched: false, + matchedEntryIds: [], + }, +}; + function makeRuntime(): GatewayPluginRuntime { return { channel: { @@ -131,6 +154,63 @@ function makeDeps(overrides: Partial = {}): InboundPipeline implicitMention: false, })), }, + access: { + resolveInboundAccess: vi.fn( + (input): QQBotInboundAccess => ({ + state: { + channelId: "qqbot", + accountId: "qq-main", + conversationKind: input.isGroup ? "group" : "direct", + event: { + kind: "message", + authMode: "inbound", + mayPair: true, + hasOriginSubject: false, + originSubjectMatched: false, + }, + routeFacts: [], + allowlists: { + dm: emptyAllowlist, + pairingStore: emptyAllowlist, + group: emptyAllowlist, + commandOwner: emptyAllowlist, + commandGroup: emptyAllowlist, + }, + }, + ingress: { + admission: "dispatch", + decision: "allow", + decisiveGateId: "activation", + reasonCode: "activation_allowed", + graph: { gates: [] }, + }, + senderAccess: { + allowed: true, + decision: "allow", + reasonCode: input.isGroup ? "group_policy_allowed" : "dm_policy_open", + effectiveAllowFrom: [], + effectiveGroupAllowFrom: [], + providerMissingFallbackApplied: false, + }, + commandAccess: { + requested: true, + authorized: true, + shouldBlockControlCommand: false, + reasonCode: "command_authorized", + }, + routeAccess: { + allowed: true, + }, + activationAccess: { + ran: false, + allowed: true, + shouldSkip: false, + reasonCode: "activation_allowed", + }, + }), + ), + resolveSlashCommandAuthorization: vi.fn(() => true), + }, audioConvert: { convertSilkToWav: vi.fn(async () => null), isVoiceAttachment: vi.fn(() => false), diff --git a/extensions/qqbot/src/engine/gateway/inbound-pipeline.ts b/extensions/qqbot/src/engine/gateway/inbound-pipeline.ts index 2f0ebd91ac3..cb57b6f9d57 100644 --- a/extensions/qqbot/src/engine/gateway/inbound-pipeline.ts +++ b/extensions/qqbot/src/engine/gateway/inbound-pipeline.ts @@ -1,30 +1,3 @@ -/** - * Inbound pipeline — compose stages into a single - * {@link buildInboundContext} call. - * - * The pipeline stays intentionally thin: all real logic lives in - * `./stages/*`. Reading this file top-to-bottom should be enough to - * understand the full inbound path. - * - * Stage order: - * 1. access — route + access control (early return on block) - * 2. attachments — download + STT + image metadata - * 3. typing — start the typing indicator (awaited before refIdx write) - * 4. content — parseFaceTags + voice text + attachment info + mention cleanup - * 5. quote — resolve `refMsgIdx` three ways - * 6. refIdx — cache the current message so future quotes work - * 7. group gate — @mention / ignoreOther / activation / command bypass - * (early return on skip, history already recorded) - * 8. envelope — body / quotePart / dynamicCtx - * 9. assembly — userMessage + agentBody (with pending-history prefix) - * 10. system — final group system prompt composition - * 11. classify — media classification (local vs remote; dedup voice) - * - * Returns a fully populated {@link InboundContext}. The gateway handler - * then branches on `blocked` / `skipped` to decide whether to dispatch - * outbound. - */ - import type { HistoryPort } from "../adapter/history.port.js"; import type { HistoryEntry } from "../group/history.js"; import { processAttachments } from "./inbound-attachments.js"; @@ -40,36 +13,26 @@ import { buildUserContent, buildUserMessage, classifyMedia, - resolveCommandAuthorized, resolveQuote, runAccessStage, runGroupGateStage, writeRefIndex, } from "./stages/index.js"; -/** - * Process a raw queued message through the full inbound pipeline. - * - * Returns an {@link InboundContext} with `blocked` / `skipped` set when - * the message should not reach the AI dispatcher. - */ export async function buildInboundContext( event: QueuedMessage, deps: InboundPipelineDeps, ): Promise { const { account, log } = deps; - // ---- 1. Access ---- - const accessResult = runAccessStage(event, deps); + const accessResult = await runAccessStage(event, deps); if (accessResult.kind === "block") { return accessResult.context; } const { isGroupChat, peerId, qualifiedTarget, fromAddress, route, access } = accessResult; - // ---- 2. Typing indicator (async; awaited before refIdx write) ---- const typingPromise = deps.startTyping(event); - // ---- 3. Attachments ---- const processed = await processAttachments(event.attachments, { accountId: account.accountId, cfg: deps.cfg, @@ -77,17 +40,14 @@ export async function buildInboundContext( log, }); - // ---- 4. Content ---- const { parsedContent, userContent } = buildUserContent({ event, attachmentInfo: processed.attachmentInfo, voiceTranscripts: processed.voiceTranscripts, }); - // ---- 5. Quote ---- const replyTo = await resolveQuote(event, deps); - // ---- 6. RefIdx ---- const typingResult = await typingPromise; writeRefIndex({ event, @@ -96,7 +56,6 @@ export async function buildInboundContext( inputNotifyRefIdx: typingResult.refIdx, }); - // ---- 7. Group gate ---- let groupInfo: InboundContext["group"]; if (event.type === "group" && event.groupOpenid) { const gateOutcome = runGroupGateStage({ @@ -107,6 +66,7 @@ export async function buildInboundContext( sessionKey: route.sessionKey, userContent, processedAttachments: processed, + access, }); if (gateOutcome.kind === "skip") { @@ -128,7 +88,6 @@ export async function buildInboundContext( groupInfo = gateOutcome.groupInfo; } - // ---- 8. Envelope ---- const body = buildBody({ event, deps, @@ -145,7 +104,6 @@ export async function buildInboundContext( uniqueVoiceAsrReferTexts: media.uniqueVoiceAsrReferTexts, }); - // ---- 9. Assembly ---- const userMessage = buildUserMessage({ event, userContent, @@ -163,17 +121,9 @@ export async function buildInboundContext( deps, }); - // ---- 10. System prompt ---- - const systemPrompts: string[] = []; - if (account.systemPrompt) { - systemPrompts.push(account.systemPrompt); - } - const accountSystemInstruction = systemPrompts.length > 0 ? systemPrompts.join("\n") : ""; + const accountSystemInstruction = account.systemPrompt ?? ""; const groupSystemPrompt = buildGroupSystemPrompt(accountSystemInstruction, groupInfo); - // ---- 11. Authorization ---- - const commandAuthorized = resolveCommandAuthorized(access); - return { event, route, @@ -181,20 +131,12 @@ export async function buildInboundContext( peerId, qualifiedTarget, fromAddress, - parsedContent, - userContent, - quotePart, - dynamicCtx, - userMessage, agentBody, body, - systemPrompts, groupSystemPrompt, - attachments: processed, localMediaPaths: media.localMediaPaths, localMediaTypes: media.localMediaTypes, remoteMediaUrls: media.remoteMediaUrls, - remoteMediaTypes: media.remoteMediaTypes, uniqueVoicePaths: media.uniqueVoicePaths, uniqueVoiceUrls: media.uniqueVoiceUrls, uniqueVoiceAsrReferTexts: media.uniqueVoiceAsrReferTexts, @@ -202,22 +144,16 @@ export async function buildInboundContext( hasAsrReferFallback: media.hasAsrReferFallback, voiceTranscriptSources: media.voiceTranscriptSources, replyTo, - commandAuthorized, + commandAuthorized: access.commandAccess.authorized, group: groupInfo, blocked: false, skipped: false, - accessDecision: access.decision, + accessDecision: access.senderAccess.decision, typing: { keepAlive: typingResult.keepAlive }, inputNotifyRefIdx: typingResult.refIdx, }; } -// ============ Public history-clear helper ============ - -/** - * Clear a group's pending history buffer. Exposed so the gateway can - * call it in its `finally` block after a reply attempt. - */ export function clearGroupPendingHistory(params: { historyMap: Map | undefined; groupOpenid: string | undefined; diff --git a/extensions/qqbot/src/engine/gateway/message-queue.ts b/extensions/qqbot/src/engine/gateway/message-queue.ts index 928700d0c00..846b67eaf37 100644 --- a/extensions/qqbot/src/engine/gateway/message-queue.ts +++ b/extensions/qqbot/src/engine/gateway/message-queue.ts @@ -1,40 +1,10 @@ -/** - * Per-user concurrent message queue. - * - * Messages are serialized per **peer** (one DM user, one group, one guild - * channel) and processed in parallel across peers up to - * {@link DEFAULT_MAX_CONCURRENT_USERS}. - * - * Group-specific enhancements (added when merging from the standalone build): - * - Group peers have a larger queue cap ({@link DEFAULT_GROUP_QUEUE_SIZE}) - * because groups can burst more chatter than a single DM. - * - When a group's queue overflows, bot-authored messages are evicted - * preferentially so human messages don't get dropped. - * - When draining a group peer with more than one queued message, the - * non-command messages are **merged** into one logical turn (see - * {@link mergeGroupMessages}). Slash commands are always processed - * individually to avoid conflating a "/stop" with surrounding chatter. - * - * The module is self-contained: the only injected dependency is the - * logger / abort probe supplied via {@link MessageQueueContext}. - */ - import { formatErrorMessage } from "../utils/format.js"; -// ============ Queue limits ============ - -/** Global cap across all peers. */ const DEFAULT_GLOBAL_QUEUE_SIZE = 1000; -/** Per-DM / per-channel cap. */ const DEFAULT_PER_PEER_QUEUE_SIZE = 20; -/** Per-group cap — larger because groups burst more. */ const DEFAULT_GROUP_QUEUE_SIZE = 50; -/** Parallel fanout across peers. */ const DEFAULT_MAX_CONCURRENT_USERS = 10; -// ============ Types ============ - -/** Mention entry carried on group messages (subset of QQ's shape). */ export interface QueuedMention { scope?: "all" | "single"; id?: string; @@ -46,28 +16,15 @@ export interface QueuedMention { is_you?: boolean; } -/** - * Metadata attached to a merged group turn. - * - * When the drainer folds multiple non-command messages into one - * representative turn, the merge information lands here instead of - * being scattered across `_` -prefixed fields on {@link QueuedMessage}. - */ interface QueuedMergeInfo { - /** Number of original messages folded in. Always >= 2. */ count: number; - /** Original messages in insertion order — `messages.at(-1)` is "current". */ messages: readonly QueuedMessage[]; } -/** - * Queue item used for asynchronous message handling without blocking heartbeats. - */ export interface QueuedMessage { type: "c2c" | "guild" | "dm" | "group"; senderId: string; senderName?: string; - /** Whether the sender is another bot. Used by the eviction policy. */ senderIsBot?: boolean; content: string; messageId: string; @@ -82,13 +39,9 @@ export interface QueuedMessage { voice_wav_url?: string; asr_refer_text?: string; }>; - /** refIdx of the quoted message. */ refMsgIdx?: string; - /** refIdx assigned to this message for future quoting. */ msgIdx?: string; - /** QQ message type (103 = quote). */ msgType?: number; - /** Referenced message elements (for quote messages). */ msgElements?: Array<{ msg_idx?: string; content?: string; @@ -103,26 +56,12 @@ export interface QueuedMessage { asr_refer_text?: string; }>; }>; - /** - * Raw event type (e.g. `GROUP_AT_MESSAGE_CREATE`). Used by the gate to - * detect explicit @bot without parsing `mentions` ourselves, and by - * the group merger to decide whether the merged result represents an - * @bot turn. - */ eventType?: string; - /** @mentions list from the raw event. */ mentions?: QueuedMention[]; - /** Scene info (source channel + ext bag). */ messageScene?: { source?: string; ext?: string[] }; - - /** - * Set only on merged group turns; absent on single-message turns. - * See {@link mergeGroupMessages} for merge semantics. - */ merge?: QueuedMergeInfo; } -/** Convenience predicate: is this a merged multi-message turn? */ export function isMergedTurn(msg: QueuedMessage): msg is QueuedMessage & { merge: QueuedMergeInfo; } { @@ -136,19 +75,13 @@ interface MessageQueueContext { error: (msg: string, meta?: Record) => void; debug?: (msg: string, meta?: Record) => void; }; - /** Abort-state probe supplied by the caller. */ isAborted: () => boolean; - /** Per-group queue cap. Defaults to {@link DEFAULT_GROUP_QUEUE_SIZE}. */ groupQueueSize?: number; - /** Per-DM / per-channel queue cap. Defaults to {@link DEFAULT_PER_PEER_QUEUE_SIZE}. */ peerQueueSize?: number; - /** Global queue cap. Defaults to {@link DEFAULT_GLOBAL_QUEUE_SIZE}. */ globalQueueSize?: number; - /** Max concurrent peers. Defaults to {@link DEFAULT_MAX_CONCURRENT_USERS}. */ maxConcurrentUsers?: number; } -/** Snapshot of the queue state for diagnostics. */ interface QueueSnapshot { totalPending: number; activeUsers: number; @@ -161,20 +94,14 @@ interface MessageQueue { startProcessor: (handleMessageFn: (msg: QueuedMessage) => Promise) => void; getSnapshot: (senderPeerId: string) => QueueSnapshot; getMessagePeerId: (msg: QueuedMessage) => string; - /** Clear a user's queued messages and return how many were dropped. */ clearUserQueue: (peerId: string) => number; - /** Execute one message immediately, bypassing the queue for urgent commands. */ executeImmediate: (msg: QueuedMessage) => void; } -// ============ Group merging ============ - -/** Return true when the peer id refers to a group-like conversation. */ function isGroupPeer(peerId: string): boolean { return peerId.startsWith("group:") || peerId.startsWith("guild:"); } -/** Slash-command test used by {@link drainGroupBatch}. */ function isSlashCommand(msg: QueuedMessage): boolean { return (msg.content ?? "").trim().startsWith("/"); } @@ -264,11 +191,6 @@ export function mergeGroupMessages(batch: QueuedMessage[]): QueuedMessage { }; } -// ============ Queue factory ============ - -/** - * Create a per-user concurrent queue with built-in group enhancements. - */ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue { const { accountId: _accountId, log } = ctx; const globalQueueSize = ctx.globalQueueSize ?? DEFAULT_GLOBAL_QUEUE_SIZE; @@ -291,13 +213,6 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue { return `dm:${msg.senderId}`; }; - /** - * Evict one message from an over-full queue. - * - * For group peers we prefer to drop a bot-authored message so human - * input never gets lost. Falling back to dropping the oldest keeps the - * queue bounded when all members are bots. - */ const evictOne = (queue: QueuedMessage[], isGroup: boolean): QueuedMessage | undefined => { if (isGroup) { const botIdx = queue.findIndex((m) => m.senderIsBot); @@ -308,7 +223,6 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue { return queue.shift(); }; - /** Run a single message, capturing errors in the log. */ const processOne = async (msg: QueuedMessage, peerId: string, label: string): Promise => { try { await handleMessageFnRef!(msg); @@ -317,11 +231,6 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue { } }; - /** - * Drain a group's batch: - * - slash commands are processed one by one (order preserved); - * - the remaining messages are merged into a single turn. - */ const drainGroupBatch = async (batch: QueuedMessage[], peerId: string): Promise => { const commands: QueuedMessage[] = []; const normal: QueuedMessage[] = []; @@ -349,7 +258,6 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue { } }; - /** Process one peer's queue serially. */ const drainUserQueue = async (peerId: string): Promise => { if (activeUsers.has(peerId)) { return; @@ -370,7 +278,6 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue { try { while (queue.length > 0 && !ctx.isAborted()) { - // Group peers with more than one queued message: batch-merge. if (isGroup && queue.length > 1 && handleMessageFnRef) { const batch = queue.splice(0); totalEnqueued = Math.max(0, totalEnqueued - batch.length); @@ -378,7 +285,6 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue { continue; } - // Single-message (or non-group) path. const msg = queue.shift()!; totalEnqueued = Math.max(0, totalEnqueued - 1); if (handleMessageFnRef) { @@ -389,7 +295,6 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue { activeUsers.delete(peerId); userQueues.delete(peerId); - // Fill any freed concurrency slots. for (const [waitingPeerId, waitingQueue] of userQueues) { if (activeUsers.size >= maxConcurrentUsers) { break; diff --git a/extensions/qqbot/src/engine/gateway/outbound-dispatch.test.ts b/extensions/qqbot/src/engine/gateway/outbound-dispatch.test.ts index 46841edb8d4..bd11f7f3c24 100644 --- a/extensions/qqbot/src/engine/gateway/outbound-dispatch.test.ts +++ b/extensions/qqbot/src/engine/gateway/outbound-dispatch.test.ts @@ -59,29 +59,11 @@ function makeInbound(overrides: Partial = {}): InboundContext { peerId: "user-openid", qualifiedTarget: "qqbot:c2c:user-openid", fromAddress: "qqbot:c2c:user-openid", - parsedContent: "voice", - userContent: "voice", - quotePart: "", - dynamicCtx: "", - userMessage: "voice", agentBody: "voice", body: "voice", - systemPrompts: [], - attachments: { - attachmentInfo: "", - imageUrls: [], - imageMediaTypes: [], - voiceAttachmentPaths: [], - voiceAttachmentUrls: [], - voiceAsrReferTexts: [], - voiceTranscripts: [], - voiceTranscriptSources: [], - attachmentLocalPaths: [], - }, localMediaPaths: [], localMediaTypes: [], remoteMediaUrls: [], - remoteMediaTypes: [], uniqueVoicePaths: [], uniqueVoiceUrls: [], uniqueVoiceAsrReferTexts: [], @@ -249,9 +231,6 @@ describe("dispatchOutbound", () => { content: "/models", timestamp: "2026-04-25T00:00:00.000Z", }, - parsedContent: "/models", - userContent: "/models", - userMessage: "/models", agentBody: "/models", body: "/models", commandAuthorized: true, diff --git a/extensions/qqbot/src/engine/gateway/stages/access-stage.ts b/extensions/qqbot/src/engine/gateway/stages/access-stage.ts index c0bde54f26e..fa6f6fcf95b 100644 --- a/extensions/qqbot/src/engine/gateway/stages/access-stage.ts +++ b/extensions/qqbot/src/engine/gateway/stages/access-stage.ts @@ -1,44 +1,24 @@ -/** - * Access stage — resolves routing target + runs access control. - * - * Split from the pipeline so it is trivially unit-testable: given a raw - * event and the runtime's routing info, the stage returns either: - * - `{ kind: "allow", ... }` — proceed through the rest of the pipeline - * - `{ kind: "block", context }` — short-circuit; the caller returns - * `context` directly to its own caller. - */ - -import { resolveQQBotAccess, type QQBotAccessResult } from "../../access/index.js"; +import type { QQBotInboundAccess } from "../../adapter/index.js"; import type { InboundContext, InboundPipelineDeps } from "../inbound-context.js"; import type { QueuedMessage } from "../message-queue.js"; import { buildBlockedInboundContext } from "./stub-contexts.js"; -// ─────────────────────────── Types ─────────────────────────── +type AccessStageResult = + | { + kind: "allow"; + isGroupChat: boolean; + peerId: string; + qualifiedTarget: string; + fromAddress: string; + route: { sessionKey: string; accountId: string; agentId?: string }; + access: QQBotInboundAccess; + } + | { kind: "block"; context: InboundContext }; -interface AccessStageAllow { - kind: "allow"; - isGroupChat: boolean; - peerId: string; - qualifiedTarget: string; - fromAddress: string; - route: { sessionKey: string; accountId: string; agentId?: string }; - access: QQBotAccessResult; -} - -interface AccessStageBlock { - kind: "block"; - context: InboundContext; -} - -type AccessStageResult = AccessStageAllow | AccessStageBlock; - -// ─────────────────────────── Stage ─────────────────────────── - -/** - * Resolve the routing target, walk the access policy, and decide whether - * the inbound message should proceed to the rest of the pipeline. - */ -export function runAccessStage(event: QueuedMessage, deps: InboundPipelineDeps): AccessStageResult { +export async function runAccessStage( + event: QueuedMessage, + deps: InboundPipelineDeps, +): Promise { const { account, cfg, runtime, log } = deps; const isGroupChat = event.type === "guild" || event.type === "group"; @@ -52,20 +32,22 @@ export function runAccessStage(event: QueuedMessage, deps: InboundPipelineDeps): peer: { kind: isGroupChat ? "group" : "direct", id: peerId }, }); - const access = resolveQQBotAccess({ + const access = await deps.adapters.access.resolveInboundAccess({ + cfg, + accountId: account.accountId, isGroup: isGroupChat, senderId: event.senderId, + conversationId: peerId, allowFrom: account.config?.allowFrom, groupAllowFrom: account.config?.groupAllowFrom, dmPolicy: account.config?.dmPolicy, groupPolicy: account.config?.groupPolicy, }); - if (access.decision !== "allow") { + if (access.senderAccess.decision !== "allow") { log?.info( - `Blocked qqbot inbound: decision=${access.decision} reasonCode=${access.reasonCode} ` + - `reason=${access.reason} senderId=${event.senderId} ` + - `accountId=${account.accountId} isGroup=${isGroupChat}`, + `Blocked qqbot inbound: decision=${access.senderAccess.decision} reasonCode=${access.senderAccess.reasonCode} ` + + `senderId=${event.senderId} accountId=${account.accountId} isGroup=${isGroupChat}`, ); return { kind: "block", @@ -103,7 +85,7 @@ function resolvePeerId(event: QueuedMessage, isGroupChat: boolean): string { } if (isGroupChat) { return "unknown"; - } // defensive, should never hit + } return event.senderId; } @@ -115,18 +97,3 @@ function buildQualifiedTarget(event: QueuedMessage, isGroupChat: boolean): strin } return event.type === "dm" ? `qqbot:dm:${event.guildId}` : `qqbot:c2c:${event.senderId}`; } - -/** - * Decide whether the access decision permits running text-based control - * commands. Placed in the access stage because the rule is an - * access-policy derivative, not a gate derivative. - */ -export function resolveCommandAuthorized(access: QQBotAccessResult): boolean { - return ( - access.reasonCode === "dm_policy_open" || - access.reasonCode === "dm_policy_allowlisted" || - (access.reasonCode === "group_policy_allowed" && - access.effectiveGroupAllowFrom.length > 0 && - access.groupPolicy === "allowlist") - ); -} diff --git a/extensions/qqbot/src/engine/gateway/stages/group-gate-stage.ts b/extensions/qqbot/src/engine/gateway/stages/group-gate-stage.ts index f22f65b283d..18eacd7fe13 100644 --- a/extensions/qqbot/src/engine/gateway/stages/group-gate-stage.ts +++ b/extensions/qqbot/src/engine/gateway/stages/group-gate-stage.ts @@ -1,32 +1,15 @@ -/** - * Group-gate stage — for `type === "group"` inbound events, decide - * whether the message should pass to AI dispatch or be intercepted. - * - * Three possible outcomes: - * - `{ kind: "pass", groupInfo }` — continue the pipeline - * - `{ kind: "skip", groupInfo, skipReason }` — buffered to history - * (if applicable) and short-circuit - * - No group info at all — returned when the event isn't a group event - * (caller should treat as a straight pass-through) - * - * Consolidates the control-command auth check, session-store - * activation override, mention detection, and the unified - * {@link resolveGroupMessageGate} call. Delegates all pure logic to - * existing `engine/group/*` modules so this stage remains a thin - * orchestrator. - */ - -import { createQQBotSenderMatcher, normalizeQQBotAllowFrom } from "../../access/index.js"; +import type { HistoryPort } from "../../adapter/history.port.js"; +import type { QQBotInboundAccess } from "../../adapter/index.js"; +import type { MentionGatePort } from "../../adapter/mention-gate.port.js"; import { DEFAULT_GROUP_PROMPT, resolveGroupSettings } from "../../config/group.js"; import { resolveGroupActivation } from "../../group/activation.js"; import { toAttachmentSummaries, type HistoryEntry } from "../../group/history.js"; import { detectWasMentioned, hasAnyMention, resolveImplicitMention } from "../../group/mention.js"; +import type { GroupMessageGateResult } from "../../group/message-gating.js"; import { getRefIndex } from "../../ref/store.js"; -import type { InboundGroupInfo, InboundPipelineDeps } from "../inbound-context.js"; +import type { InboundContext, InboundGroupInfo, InboundPipelineDeps } from "../inbound-context.js"; import { isMergedTurn, type QueuedMessage } from "../message-queue.js"; -// ─────────────────────────── Types ─────────────────────────── - interface GroupGatePass { kind: "pass"; groupInfo: InboundGroupInfo; @@ -35,7 +18,7 @@ interface GroupGatePass { interface GroupGateSkip { kind: "skip"; groupInfo: InboundGroupInfo; - skipReason: NonNullable; + skipReason: NonNullable; } type GroupGateStageResult = GroupGatePass | GroupGateSkip; @@ -46,37 +29,21 @@ interface GroupGateStageInput { accountId: string; agentId?: string; sessionKey: string; - /** User-visible content (post-emoji-parse, post-mention-strip). */ userContent: string; - /** Already-processed attachments (downloaded). Available for history recording. */ processedAttachments?: import("../inbound-attachments.js").ProcessedAttachments; + access: QQBotInboundAccess; } -// ─────────────────────────── Stage ─────────────────────────── - -/** - * Run the group-gate stage. - * - * Precondition: `event.type === "group"` && `event.groupOpenid` is set. - * The caller (pipeline) enforces this; the stage doesn't re-check. - * - * On `skip` outcomes the stage records the message into the group's - * history buffer when the skip reason is one that should preserve - * context (drop / skip_no_mention), then returns. `block` skip - * reasons do NOT write history — they are silent rejects. - */ export function runGroupGateStage(input: GroupGateStageInput): GroupGateStageResult { const { event, deps, accountId, agentId, sessionKey, userContent, processedAttachments } = input; const groupOpenid = event.groupOpenid!; const cfg = (deps.cfg ?? {}) as Record; - // ---- 1. One-pass config resolution ---- const settings = resolveGroupSettings({ cfg, groupOpenid, accountId, agentId }); const { historyLimit, requireMention, ignoreOtherMentions } = settings.config; const behaviorPrompt = settings.config.prompt ?? DEFAULT_GROUP_PROMPT; const groupName = settings.name; - // ---- 2. Mention detection (QQ-specific) ---- const explicitWasMentioned = detectWasMentioned({ eventType: event.eventType, mentions: event.mentions as never, @@ -92,7 +59,6 @@ export function runGroupGateStage(input: GroupGateStageInput): GroupGateStageRes getRefEntry: (idx) => getRefIndex(idx) ?? null, }); - // ---- 3. Activation mode (session store > cfg) ---- const activation = resolveGroupActivation({ cfg, agentId: agentId ?? "default", @@ -101,15 +67,11 @@ export function runGroupGateStage(input: GroupGateStageInput): GroupGateStageRes sessionStoreReader: deps.sessionStoreReader, }); - // ---- 4. Command authorization (for bypass) ---- const content = (event.content ?? "").trim(); const isControlCommand = Boolean(deps.isControlCommand?.(content)); const commandAuthorized = - deps.allowTextCommands !== false && isSenderAllowedForCommands(event.senderId, deps); + deps.allowTextCommands !== false && input.access.commandAccess.authorized; - // ---- 5. Gate evaluation ---- - // Layer 1 (ignoreOtherMentions) is QQ-specific and handled by - // resolveGateWithPort. Layers 2+3 delegate to the SDK adapter. const gate = resolveGateWithPort({ mentionGatePort: deps.adapters.mentionGate, ignoreOtherMentions, @@ -122,7 +84,6 @@ export function runGroupGateStage(input: GroupGateStageInput): GroupGateStageRes requireMention: activation === "mention", }); - // ---- 6. Build InboundGroupInfo (shared by pass / skip paths) ---- const introHint = deps.resolveGroupIntroHint?.({ cfg, accountId, @@ -144,12 +105,10 @@ export function runGroupGateStage(input: GroupGateStageInput): GroupGateStageRes }, }; - // ---- 7. Decide pass vs skip ---- if (gate.action === "pass") { return { kind: "pass", groupInfo }; } - // Skip path: record history for drop / skip_no_mention, silent for block. if (gate.action === "drop_other_mention" || gate.action === "skip_no_mention") { recordGroupHistory({ historyMap: deps.groupHistories, @@ -165,18 +124,6 @@ export function runGroupGateStage(input: GroupGateStageInput): GroupGateStageRes return { kind: "skip", groupInfo, skipReason: gate.action }; } -// ─────────────────────────── Internal helpers ─────────────────────────── - -import type { HistoryPort } from "../../adapter/history.port.js"; -import type { MentionGatePort } from "../../adapter/mention-gate.port.js"; -import type { GroupMessageGateResult } from "../../group/message-gating.js"; - -/** - * Resolve the gate using the SDK MentionGatePort adapter. - * - * Layer 1 (ignoreOtherMentions) is QQ-specific and handled here. - * Layers 2+3 delegate to the SDK's `resolveInboundMentionDecision`. - */ function resolveGateWithPort(params: { mentionGatePort: MentionGatePort; ignoreOtherMentions: boolean; @@ -188,7 +135,6 @@ function resolveGateWithPort(params: { commandAuthorized: boolean; requireMention: boolean; }): GroupMessageGateResult { - // Layer 1: QQ-specific ignoreOtherMentions if ( params.ignoreOtherMentions && params.hasAnyMention && @@ -202,7 +148,6 @@ function resolveGateWithPort(params: { }; } - // Layer 2+3: delegate to SDK mention gate (includes command bypass) const decision = params.mentionGatePort.resolveInboundMentionDecision({ facts: { canDetectMention: true, @@ -219,7 +164,6 @@ function resolveGateWithPort(params: { }, }); - // Map SDK's shouldBlock (unauthorized command) to our action if (params.allowTextCommands && params.isControlCommand && !params.commandAuthorized) { return { action: "block_unauthorized_command", @@ -243,18 +187,6 @@ function resolveGateWithPort(params: { }; } -/** - * Test whether the sender is on the DM `allowFrom` list. - */ -function isSenderAllowedForCommands(senderId: string, deps: InboundPipelineDeps): boolean { - const raw = deps.account.config?.allowFrom; - if (!Array.isArray(raw) || raw.length === 0) { - return true; - } - const normalized = normalizeQQBotAllowFrom(raw); - return createQQBotSenderMatcher(senderId)(normalized); -} - function recordGroupHistory(params: { historyMap: Map | undefined; groupOpenid: string; @@ -262,7 +194,6 @@ function recordGroupHistory(params: { event: QueuedMessage; userContent: string; historyPort: HistoryPort; - /** Local paths from processAttachments — enriches history with downloaded file paths. */ localPaths?: Array; }): void { const { historyMap, groupOpenid, historyLimit, event, userContent, historyPort, localPaths } = diff --git a/extensions/qqbot/src/engine/gateway/stages/stub-contexts.ts b/extensions/qqbot/src/engine/gateway/stages/stub-contexts.ts index fa95fb755f7..8ceade2ad21 100644 --- a/extensions/qqbot/src/engine/gateway/stages/stub-contexts.ts +++ b/extensions/qqbot/src/engine/gateway/stages/stub-contexts.ts @@ -1,18 +1,8 @@ -/** - * Shared `InboundContext` stub builders for early-return paths. - * - * Both the access-control "blocked" path and the group-gate "skipped" - * path need to return a fully populated {@link InboundContext} that the - * upstream handler can inspect without crashing on undefined fields. - * Centralising the stubs here prevents the two paths from drifting. - */ - -import type { QQBotAccessResult } from "../../access/index.js"; +import type { QQBotInboundAccess } from "../../adapter/index.js"; import type { InboundContext, InboundGroupInfo } from "../inbound-context.js"; import type { QueuedMessage } from "../message-queue.js"; import type { TypingKeepAlive } from "../typing-keepalive.js"; -/** Shared fields every stub context needs. */ interface BaseStubFields { event: QueuedMessage; route: { sessionKey: string; accountId: string; agentId?: string }; @@ -22,7 +12,6 @@ interface BaseStubFields { fromAddress: string; } -/** Build an {@link InboundContext} with all non-routing fields cleared. */ function emptyInboundContext(fields: BaseStubFields): InboundContext { return { event: fields.event, @@ -31,30 +20,12 @@ function emptyInboundContext(fields: BaseStubFields): InboundContext { peerId: fields.peerId, qualifiedTarget: fields.qualifiedTarget, fromAddress: fields.fromAddress, - parsedContent: "", - userContent: "", - quotePart: "", - dynamicCtx: "", - userMessage: "", agentBody: "", body: "", - systemPrompts: [], groupSystemPrompt: undefined, - attachments: { - attachmentInfo: "", - imageUrls: [], - imageMediaTypes: [], - voiceAttachmentPaths: [], - voiceAttachmentUrls: [], - voiceAsrReferTexts: [], - voiceTranscripts: [], - voiceTranscriptSources: [], - attachmentLocalPaths: [], - }, localMediaPaths: [], localMediaTypes: [], remoteMediaUrls: [], - remoteMediaTypes: [], uniqueVoicePaths: [], uniqueVoiceUrls: [], uniqueVoiceAsrReferTexts: [], @@ -71,35 +42,25 @@ function emptyInboundContext(fields: BaseStubFields): InboundContext { }; } -/** - * Build an {@link InboundContext} that represents a message blocked by - * access control (policy denial, allowlist mismatch, etc.). - */ export function buildBlockedInboundContext( params: BaseStubFields & { - access: QQBotAccessResult; + access: QQBotInboundAccess; }, ): InboundContext { return { ...emptyInboundContext(params), blocked: true, - blockReason: params.access.reason, - blockReasonCode: params.access.reasonCode, - accessDecision: params.access.decision, + blockReason: params.access.senderAccess.reasonCode, + blockReasonCode: params.access.senderAccess.reasonCode, + accessDecision: params.access.senderAccess.decision, }; } -/** - * Build an {@link InboundContext} that represents a message stopped by - * the group gate (drop_other_mention, block_unauthorized_command, - * skip_no_mention). Any history side-effects have already been applied - * by the gate stage. - */ export function buildSkippedInboundContext( params: BaseStubFields & { group: InboundGroupInfo; skipReason: NonNullable; - access: QQBotAccessResult; + access: QQBotInboundAccess; typing: { keepAlive: TypingKeepAlive | null }; inputNotifyRefIdx?: string; }, @@ -109,7 +70,7 @@ export function buildSkippedInboundContext( group: params.group, skipped: true, skipReason: params.skipReason, - accessDecision: params.access.decision, + accessDecision: params.access.senderAccess.decision, typing: params.typing, inputNotifyRefIdx: params.inputNotifyRefIdx, }; diff --git a/extensions/qqbot/src/engine/gateway/types.ts b/extensions/qqbot/src/engine/gateway/types.ts index 68b9aff90e0..ef26ede3761 100644 --- a/extensions/qqbot/src/engine/gateway/types.ts +++ b/extensions/qqbot/src/engine/gateway/types.ts @@ -1,30 +1,9 @@ -/** - * Gateway types. - * - * core/gateway/gateway.ts now imports all dependencies directly (both - * core/ modules and upper-layer files). The only injected dependency - * is `runtime` (PluginRuntime), which is a framework-provided object. - */ - -// ============ Logger ============ import type { EngineLogger } from "../types.js"; export type { EngineLogger }; -// ============ Account ============ - -/** Re-export GatewayAccount from engine/types.ts (single source of truth). */ import type { GatewayAccount as _GatewayAccount } from "../types.js"; export type GatewayAccount = _GatewayAccount; -// ============ PluginRuntime subset ============ - -/** - * Subset of PluginRuntime used by the gateway. - * - * This is NOT a custom adapter — it's the exact same object shape that - * the framework injects. We define it here so core/ doesn't need to - * depend on the plugin-sdk root barrel. - */ export interface GatewayPluginRuntime { channel: { activity: { @@ -80,13 +59,6 @@ export interface GatewayPluginRuntime { error?: string; }>; }; - /** - * Config API for reading/writing the framework configuration. - * - * Used by the interaction handler (config query/update) directly - * within the engine layer. Optional because not all runtime - * environments provide config write capability. - */ config?: { current: () => Record; replaceConfigFile: (params: { @@ -96,12 +68,8 @@ export interface GatewayPluginRuntime { }; } -// ============ Shared result types ============ - -/** Re-export ProcessedAttachments from inbound-attachments (single source of truth). */ export type { ProcessedAttachments } from "./inbound-attachments.js"; -/** Outbound result from media sends. */ export interface OutboundResult { channel: string; messageId?: string; @@ -109,12 +77,8 @@ export interface OutboundResult { error?: string; } -/** Re-export RefAttachmentSummary for convenience. */ export type { RefAttachmentSummary } from "../ref/types.js"; -// ============ WebSocket Event Types ============ - -/** Raw WebSocket payload structure. */ export interface WSPayload { op: number; d: unknown; @@ -122,7 +86,6 @@ export interface WSPayload { t?: string; } -/** Attachment shape shared by all message event types. */ interface RawMessageAttachment { content_type: string; url: string; @@ -131,7 +94,6 @@ interface RawMessageAttachment { asr_refer_text?: string; } -/** Referenced message element (used for quote messages). */ interface RawMsgElement { msg_idx?: string; content?: string; diff --git a/extensions/qqbot/src/engine/group/activation.ts b/extensions/qqbot/src/engine/group/activation.ts index 6e011cbfd77..9ae94622614 100644 --- a/extensions/qqbot/src/engine/group/activation.ts +++ b/extensions/qqbot/src/engine/group/activation.ts @@ -1,36 +1,8 @@ -/** - * Group activation mode — how the bot decides whether to respond in a group. - * - * Resolution chain: - * 1. session store override (`/activation` command writes per-session - * `groupActivation` value) — highest priority - * 2. per-group `requireMention` config - * 3. `"mention"` default (require @-bot to respond) - * - * File I/O is isolated in the default node-based reader so the gating - * logic itself stays a pure function, testable without touching disk. - * - * Note: the implicit-mention predicate (quoting a bot message counts as - * @-ing the bot) lives in `./mention.ts` alongside the other mention - * helpers — see `resolveImplicitMention` there. - */ - import fs from "node:fs"; import path from "node:path"; -// ────────────────────────── Types ────────────────────────── - -/** High-level activation outcome. */ export type GroupActivationMode = "mention" | "always"; -/** - * Pluggable reader that returns parsed session-store contents. - * - * A return value of `null` means "no override available" (file missing, - * parse error, or reader disabled). Implementations must **not** throw — - * the gating pipeline treats any failure as "fall back to the config - * default". - */ export interface SessionStoreReader { read(params: { cfg: Record; @@ -38,22 +10,11 @@ export interface SessionStoreReader { }): Record | null; } -// ────────────────────────── groupActivation ────────────────────────── - -/** - * Resolve the effective activation mode for one inbound message. - * - * Order of precedence: - * 1. `store[sessionKey].groupActivation` (read via the injected reader) - * 2. config-level `requireMention` (maps to `"mention"` / `"always"`) - * 3. `"mention"` (safe default) - */ export function resolveGroupActivation(params: { cfg: Record; agentId: string; sessionKey: string; configRequireMention: boolean; - /** Pluggable reader; omit to disable the session-store override. */ sessionStoreReader?: SessionStoreReader; }): GroupActivationMode { const fallback: GroupActivationMode = params.configRequireMention ? "mention" : "always"; @@ -78,16 +39,6 @@ export function resolveGroupActivation(params: { return fallback; } -// ────────────────────────── Default node reader ────────────────────────── - -/** - * Resolve the on-disk path to the agent-sessions file. - * - * Priority: - * 1. `cfg.session.store` (supports `{agentId}` placeholder and `~` expansion) - * 2. `$OPENCLAW_STATE_DIR` / `$CLAWDBOT_STATE_DIR` - * 3. `~/.openclaw/agents/{agentId}/sessions/sessions.json` - */ function resolveSessionStorePath( cfg: Record, agentId: string | undefined, @@ -119,16 +70,6 @@ function resolveSessionStorePath( return path.join(stateDir, "agents", resolvedAgentId, "sessions", "sessions.json"); } -/** - * Create the default, production-ready session-store reader. - * - * Reads the file synchronously on every call. The overhead is acceptable - * because activation mode is only resolved once per group message and - * the sessions file is typically a handful of kilobytes. - * - * Any I/O or JSON error is swallowed and returned as `null` so the - * gating pipeline falls back to the config default. - */ export function createNodeSessionStoreReader(): SessionStoreReader { return { read: ({ cfg, agentId }) => { diff --git a/extensions/qqbot/src/engine/group/mention.ts b/extensions/qqbot/src/engine/group/mention.ts index f1ef462c34e..5bd56fc94cf 100644 --- a/extensions/qqbot/src/engine/group/mention.ts +++ b/extensions/qqbot/src/engine/group/mention.ts @@ -1,86 +1,28 @@ -/** - * QQBot group @mention detection and text normalization. - * - * Pure functions extracted from the standalone build (`openclaw-qqbot/src/ - * channel.ts::detectWasMentioned` / `stripMentionText`) plus the helper - * `hasAnyMention` that previously lived inline in `gateway.ts` and the - * `resolveImplicitMention` predicate that decides whether a quoted-reply - * should count as an implicit @bot. - * - * Keeping these helpers together makes it easier to test the group gating - * pipeline and lets both the built-in and standalone builds share a - * single mention-detection implementation. - */ - -// ============ Types ============ - -/** - * Raw mention entry shape used across QQ Bot group events. - * - * QQ's `mentions` array uses slightly different field names on different - * event types (the bot's self-mention comes as `is_you: true`; user IDs - * can appear in any of `member_openid` / `id` / `user_openid`). This type - * captures the union so callers don't have to worry about which variant. - */ export interface RawMention { - /** Whether this mention targets the bot itself. */ is_you?: boolean; - /** Whether the mention target is another bot. */ bot?: boolean; - /** Member openid in group chats. */ member_openid?: string; - /** Event-level id (guild context). */ id?: string; - /** User openid (C2C context). */ user_openid?: string; - /** Display name. */ nickname?: string; - /** Alternative display name. */ username?: string; - /** @all / @single scope (QQ guild events). */ scope?: "all" | "single"; } -/** Input for {@link detectWasMentioned}. */ interface DetectWasMentionedInput { - /** - * Raw event type. `"GROUP_AT_MESSAGE_CREATE"` unambiguously identifies - * that the bot was @-ed, even when the mentions array is empty. - */ eventType?: string; mentions?: RawMention[]; - /** Raw message content — used as a regex fallback via `mentionPatterns`. */ content?: string; - /** - * Regex patterns matched against `content` when neither `mentions.is_you` - * nor `eventType` prove a bot mention. Invalid patterns are ignored. - */ mentionPatterns?: string[]; } -/** Input for {@link hasAnyMention}. */ interface HasAnyMentionInput { mentions?: RawMention[]; content?: string; } -// ============ Constants ============ - -/** Regex detecting `<@openid>` / `<@!openid>` mention tags in raw content. */ const MENTION_TAG_RE = /<@!?\w+>/; -// ============ Public API ============ - -/** - * Detect whether the inbound message explicitly targets the bot. - * - * Priority order: - * 1. `mentions[].is_you === true` (most reliable) - * 2. `eventType === "GROUP_AT_MESSAGE_CREATE"` (QQ-level @bot event) - * 3. regex match on any of `mentionPatterns` (fallback, e.g. "@bot-name") - * - * Returns `false` for direct messages or when no signal is found. - */ export function detectWasMentioned(input: DetectWasMentionedInput): boolean { const { eventType, mentions, content, mentionPatterns } = input; @@ -101,23 +43,13 @@ export function detectWasMentioned(input: DetectWasMentionedInput): boolean { if (new RegExp(pattern, "i").test(content)) { return true; } - } catch { - // Invalid regex — skip silently; bad patterns must not crash the pipeline. - } + } catch {} } } return false; } -/** - * Report whether the message contains **any** @mention (not necessarily @bot). - * - * Used by the gating layer to decide whether to bypass mention requirements - * for control commands. A control command like `/stop` that also @-s another - * user should NOT bypass the mention gate — the `@other-user` prefix is a - * strong signal that the command wasn't addressed to the bot. - */ export function hasAnyMention(input: HasAnyMentionInput): boolean { if (input.mentions && input.mentions.length > 0) { return true; @@ -128,17 +60,6 @@ export function hasAnyMention(input: HasAnyMentionInput): boolean { return false; } -/** - * Clean up `<@openid>` mention tags in raw QQ group content. - * - * - For the bot's own mention (`is_you === true`): the tag is removed - * outright so prompts don't contain visible `<@BOTID>` garbage. - * - For other mentioned users: the tag is replaced with `@nickname` (or - * `@username`) for readability. Entries without a display name are left - * as-is (rare in practice). - * - * Returns the original text unchanged when `text` or `mentions` is empty. - */ export function stripMentionText(text: string, mentions?: RawMention[]): string { if (!text || !mentions?.length) { return text; @@ -149,7 +70,6 @@ export function stripMentionText(text: string, mentions?: RawMention[]): string if (!openid) { continue; } - // RegExp: match both `<@openid>` and `<@!openid>` variants. const tagRe = new RegExp(`<@!?${escapeRegex(openid)}>`, "g"); if (m.is_you) { cleaned = cleaned.replace(tagRe, "").trim(); @@ -163,9 +83,6 @@ export function stripMentionText(text: string, mentions?: RawMention[]): string return cleaned; } -// ============ Internal helpers ============ - -/** Escape characters that carry regex meaning. */ function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } diff --git a/extensions/qqbot/src/engine/group/message-gating.ts b/extensions/qqbot/src/engine/group/message-gating.ts index 1a31147540a..c6eb99ca117 100644 --- a/extensions/qqbot/src/engine/group/message-gating.ts +++ b/extensions/qqbot/src/engine/group/message-gating.ts @@ -1,113 +1,27 @@ -/** - * Group message gate — unified entry point for group inbound gating. - * - * Collapses three orthogonal rules that previously lived in ad-hoc spots - * of the standalone gateway into a single pure function. Callers pass in - * the message's mention state plus the resolved configuration, and get - * back a structured action telling them how to handle the message. - * - * Evaluation order (short-circuit at the first match): - * 1. `ignoreOtherMentions` — message @-s someone else but not the bot - * → `drop_other_mention` (record to history, - * then drop). Implicit mentions (e.g. quoting - * a bot reply) still count as @bot. - * 2. `block_unauthorized_command` — sender is not allowed to run control - * commands (text starts with `/xxx`) - * → silently drop. - * 3. `mention gating` — when `requireMention` is on, non-@bot messages - * are `skip_no_mention`'d (still buffered to - * history). Authorized control commands can - * **bypass** the gate as long as the message does - * not @anyone else at the same time. - * 4. Otherwise → `pass` (the message will reach the AI pipeline). - * - * All inputs are plain data; there is no I/O and no mutation, so the - * function is safe to share between the built-in and standalone builds. - */ - -// ────────────────────── Types ────────────────────── - -/** - * Structured action returned by {@link resolveGroupMessageGate}. - * - * - `drop_other_mention` — message @-s another user but not the bot; - * record to the group history cache and - * drop without hitting the AI. - * - `block_unauthorized_command` — silently refuse a control command from - * an unauthorized sender (no history - * write, no AI call). - * - `skip_no_mention` — `requireMention` is on and the message - * does not @bot; record to history but - * skip AI dispatch. - * - `pass` — forward the message to the AI pipeline. - */ type GroupMessageGateAction = | "drop_other_mention" | "block_unauthorized_command" | "skip_no_mention" | "pass"; -/** Gate evaluation result. */ export interface GroupMessageGateResult { - /** The action the caller should take. */ action: GroupMessageGateAction; - /** - * Effective mention state after combining raw mention detection with - * implicit / bypass signals. Only meaningful when `action === "pass"`. - */ effectiveWasMentioned: boolean; - /** - * Whether the control-command bypass was applied to flip a missing - * mention into `pass`. Only meaningful when `action === "pass"`. - */ shouldBypassMention: boolean; } -/** Input for {@link resolveGroupMessageGate}. */ export interface GroupMessageGateInput { - // ---- ignoreOtherMentions layer ---- - /** Per-group config: drop messages that @someone other than the bot. */ ignoreOtherMentions: boolean; - /** Whether the message contains *any* @mention (including @other-user). */ hasAnyMention: boolean; - /** - * Whether the QQ event explicitly @-s the bot (via `mentions[].is_you` - * or `GROUP_AT_MESSAGE_CREATE`). - */ wasMentioned: boolean; - /** - * Implicit mention — e.g. the message quotes an earlier bot reply. - * Treated as equivalent to an explicit @bot for gating purposes. - */ implicitMention: boolean; - - // ---- Control-command layer ---- - /** Whether text-based control commands are enabled globally. */ allowTextCommands: boolean; - /** Whether the current message is recognised as a control command. */ isControlCommand: boolean; - /** Whether the sender is authorised to run control commands. */ commandAuthorized: boolean; - - // ---- Mention gating layer ---- - /** Per-group config: `requireMention` — bot only replies when @-ed. */ requireMention: boolean; - /** - * Whether the channel can reliably detect @-mentions at all. In C2C chat - * this should be `false` (DMs don't have mentions); in group chat it - * should be `true`. - */ canDetectMention: boolean; } -// ────────────────────── Core logic ────────────────────── - -/** - * Base mention-gate evaluation. - * - * `effectiveWasMentioned = wasMentioned || implicitMention || bypass`. - * `shouldSkip = requireMention && canDetectMention && !effectiveWasMentioned`. - */ function resolveMentionGating(input: { requireMention: boolean; canDetectMention: boolean; @@ -121,18 +35,6 @@ function resolveMentionGating(input: { return { effectiveWasMentioned, shouldSkip }; } -/** - * Decide whether an authorized control command may bypass the mention gate. - * - * All of the following must hold: - * 1. `requireMention` is on (gate is active) - * 2. The bot was NOT directly @-ed (otherwise no bypass is needed) - * 3. The message does NOT @anyone (a `@other-user /stop` should NOT pass - * — the command wasn't aimed at us) - * 4. Text commands are enabled - * 5. Sender is authorised - * 6. The content is a valid control command - */ function resolveCommandBypass(input: { requireMention: boolean; wasMentioned: boolean; @@ -151,15 +53,7 @@ function resolveCommandBypass(input: { ); } -// ────────────────────── Unified gate ────────────────────── - -/** - * Evaluate the group-message gate. - * - * See the module-level docs for the ordering and semantics. - */ export function resolveGroupMessageGate(input: GroupMessageGateInput): GroupMessageGateResult { - // ---- Layer 1: ignoreOtherMentions ---- if ( input.ignoreOtherMentions && input.hasAnyMention && @@ -173,7 +67,6 @@ export function resolveGroupMessageGate(input: GroupMessageGateInput): GroupMess }; } - // ---- Layer 2: unauthorized control command ---- if (input.allowTextCommands && input.isControlCommand && !input.commandAuthorized) { return { action: "block_unauthorized_command", @@ -182,7 +75,6 @@ export function resolveGroupMessageGate(input: GroupMessageGateInput): GroupMess }; } - // ---- Layer 3: mention gate + command bypass ---- const shouldBypassMention = resolveCommandBypass({ requireMention: input.requireMention, wasMentioned: input.wasMentioned, diff --git a/extensions/signal/api.ts b/extensions/signal/api.ts index 1e04f4885b7..46d9e4c1d77 100644 --- a/extensions/signal/api.ts +++ b/extensions/signal/api.ts @@ -17,7 +17,6 @@ export { formatSignalPairingIdLine, formatSignalSenderDisplay, formatSignalSenderId, - isSignalGroupAllowed, isSignalSenderAllowed, looksLikeUuid, normalizeSignalAllowRecipient, diff --git a/extensions/signal/src/dm-policy.contract.test.ts b/extensions/signal/src/dm-policy.contract.test.ts deleted file mode 100644 index 6a0f6c232d6..00000000000 --- a/extensions/signal/src/dm-policy.contract.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { - DM_GROUP_ACCESS_REASON, - resolveDmGroupAccessWithLists, -} from "openclaw/plugin-sdk/channel-policy"; -import { describe, expect, it } from "vitest"; -import { isSignalSenderAllowed, type SignalSender } from "../contract-api.js"; - -type ChannelSmokeCase = { - name: string; - storeAllowFrom: string[]; - isSenderAllowed: (allowFrom: string[]) => boolean; -}; - -const signalSender: SignalSender = { - kind: "phone", - raw: "+15550001111", - e164: "+15550001111", -}; -const signalSenderE164 = "+15550001111"; - -function createChannelSmokeCases(): ChannelSmokeCase[] { - return [ - { - name: "generic-chat", - storeAllowFrom: ["attacker-user"], - isSenderAllowed: (allowFrom) => allowFrom.includes("attacker-user"), - }, - { - name: "signal", - storeAllowFrom: [signalSenderE164], - isSenderAllowed: (allowFrom) => isSignalSenderAllowed(signalSender, allowFrom), - }, - { - name: "mattermost", - storeAllowFrom: ["user:attacker-user"], - isSenderAllowed: (allowFrom) => allowFrom.includes("user:attacker-user"), - }, - ]; -} - -function expandChannelIngressCases(cases: readonly ChannelSmokeCase[]) { - return cases.flatMap((testCase) => - (["message", "reaction"] as const).map((ingress) => ({ - testCase, - ingress, - })), - ); -} - -describe("Signal dm-policy shared contract", () => { - function expectBlockedGroupAccess(params: { - storeAllowFrom: string[]; - isSenderAllowed: (allowFrom: string[]) => boolean; - }) { - const access = resolveDmGroupAccessWithLists({ - isGroup: true, - dmPolicy: "pairing", - groupPolicy: "allowlist", - allowFrom: ["owner-user"], - groupAllowFrom: ["group-owner"], - storeAllowFrom: params.storeAllowFrom, - isSenderAllowed: params.isSenderAllowed, - }); - expect(access.decision).toBe("block"); - expect(access.reasonCode).toBe(DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED); - expect(access.reason).toBe("groupPolicy=allowlist (not allowlisted)"); - } - - it("blocks group ingress when sender is only in pairing store", () => { - for (const { testCase } of expandChannelIngressCases(createChannelSmokeCases())) { - expectBlockedGroupAccess({ - storeAllowFrom: testCase.storeAllowFrom, - isSenderAllowed: testCase.isSenderAllowed, - }); - } - }); -}); diff --git a/extensions/signal/src/identity.ts b/extensions/signal/src/identity.ts index 987c19bf118..7c856856922 100644 --- a/extensions/signal/src/identity.ts +++ b/extensions/signal/src/identity.ts @@ -1,4 +1,3 @@ -import { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access"; import { normalizeE164, normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { looksLikeUuid } from "./uuid.js"; @@ -113,16 +112,3 @@ export function isSignalSenderAllowed(sender: SignalSender, allowFrom: string[]) return false; }); } - -export function isSignalGroupAllowed(params: { - groupPolicy: "open" | "disabled" | "allowlist"; - allowFrom: string[]; - sender: SignalSender; -}): boolean { - return evaluateSenderGroupAccessForPolicy({ - groupPolicy: params.groupPolicy, - groupAllowFrom: params.allowFrom, - senderId: params.sender.raw, - isSenderAllowed: () => isSignalSenderAllowed(params.sender, params.allowFrom), - }).allowed; -} diff --git a/extensions/signal/src/monitor.test.ts b/extensions/signal/src/monitor.test.ts deleted file mode 100644 index a15956ce119..00000000000 --- a/extensions/signal/src/monitor.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isSignalGroupAllowed } from "./identity.js"; - -describe("signal groupPolicy gating", () => { - it("allows when policy is open", () => { - expect( - isSignalGroupAllowed({ - groupPolicy: "open", - allowFrom: [], - sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" }, - }), - ).toBe(true); - }); - - it("blocks when policy is disabled", () => { - expect( - isSignalGroupAllowed({ - groupPolicy: "disabled", - allowFrom: ["+15550001111"], - sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" }, - }), - ).toBe(false); - }); - - it("blocks allowlist when empty", () => { - expect( - isSignalGroupAllowed({ - groupPolicy: "allowlist", - allowFrom: [], - sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" }, - }), - ).toBe(false); - }); - - it("allows allowlist when sender matches", () => { - expect( - isSignalGroupAllowed({ - groupPolicy: "allowlist", - allowFrom: ["+15550001111"], - sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" }, - }), - ).toBe(true); - }); - - it("allows allowlist wildcard", () => { - expect( - isSignalGroupAllowed({ - groupPolicy: "allowlist", - allowFrom: ["*"], - sender: { kind: "phone", raw: "+15550002222", e164: "+15550002222" }, - }), - ).toBe(true); - }); - - it("allows allowlist when uuid sender matches", () => { - expect( - isSignalGroupAllowed({ - groupPolicy: "allowlist", - allowFrom: ["uuid:123e4567-e89b-12d3-a456-426614174000"], - sender: { - kind: "uuid", - raw: "123e4567-e89b-12d3-a456-426614174000", - }, - }), - ).toBe(true); - }); -}); diff --git a/extensions/signal/src/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.test.ts b/extensions/signal/src/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.test.ts index 07d3df3a924..21b94599077 100644 --- a/extensions/signal/src/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.test.ts +++ b/extensions/signal/src/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.test.ts @@ -2,7 +2,6 @@ import { Buffer } from "node:buffer"; import { describe, expect, it, vi } from "vitest"; import { config, - flush, getSignalToolResultTestMocks, installSignalToolResultTestHooks, setSignalToolResultTestConfig, @@ -62,8 +61,6 @@ describe("monitorSignalProvider tool results", () => { abortSignal: abortController.signal, }); - await flush(); - expect(replyMock).not.toHaveBeenCalled(); expect(upsertPairingRequestMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -150,8 +147,6 @@ describe("monitorSignalProvider tool results", () => { abortSignal: abortController.signal, }); - await flush(); - expect(signalRpcRequestMock).toHaveBeenCalledWith( "getAttachment", expect.objectContaining({ id: "attachment-1", recipient: "+15550001111" }), diff --git a/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts b/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts index a7b0fae55c2..f61718aa10f 100644 --- a/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts +++ b/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts @@ -6,7 +6,6 @@ import { describe, expect, it, vi } from "vitest"; import { createSignalToolResultConfig, config, - flush, getSignalToolResultTestMocks, installSignalToolResultTestHooks, setSignalToolResultTestConfig, @@ -60,8 +59,6 @@ async function receiveSignalPayloads(params: { abortSignal: abortController.signal, ...params.opts, }); - - await flush(); } function hasQueuedReactionEventFor(sender: string) { diff --git a/extensions/signal/src/monitor.tool-result.test-harness.ts b/extensions/signal/src/monitor.tool-result.test-harness.ts index a6a9d647b93..45a82d6545c 100644 --- a/extensions/signal/src/monitor.tool-result.test-harness.ts +++ b/extensions/signal/src/monitor.tool-result.test-harness.ts @@ -74,11 +74,6 @@ export function createSignalToolResultConfig( }; } -export async function flush() { - await Promise.resolve(); - await Promise.resolve(); -} - export function createMockSignalDaemonHandle( overrides: { stop?: MockFn; diff --git a/extensions/signal/src/monitor.ts b/extensions/signal/src/monitor.ts index 1465703ea12..05fba56e7de 100644 --- a/extensions/signal/src/monitor.ts +++ b/extensions/signal/src/monitor.ts @@ -73,6 +73,24 @@ function resolveRuntime(opts: MonitorSignalOpts): RuntimeEnv { return opts.runtime ?? createNonExitingRuntime(); } +function createSignalMonitorTaskRunner(runtime: RuntimeEnv) { + const inFlight = new Set>(); + return { + runEventTask(task: () => Promise): void { + const trackedTask = Promise.resolve() + .then(task) + .catch((err) => runtime.error?.(`event handler failed: ${String(err)}`)) + .finally(() => inFlight.delete(trackedTask)); + inFlight.add(trackedTask); + }, + async waitForIdle(): Promise { + while (inFlight.size > 0) { + await Promise.allSettled(Array.from(inFlight)); + } + }, + }; +} + function mergeAbortSignals( a?: AbortSignal, b?: AbortSignal, @@ -431,6 +449,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi ); const readReceiptsViaDaemon = autoStart && sendReadReceipts; const daemonLifecycle = createSignalDaemonLifecycle({ abortSignal: opts.abortSignal }); + const monitorTaskRunner = createSignalMonitorTaskRunner(runtime); let daemonHandle: SignalDaemonHandle | null = null; if (autoStart && configuredApiMode === "container") { @@ -518,9 +537,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi apiMode: configuredApiMode, policy: opts.reconnectPolicy, onEvent: (event) => { - void handleEvent(event).catch((err) => { - runtime.error?.(`event handler failed: ${String(err)}`); - }); + monitorTaskRunner.runEventTask(() => handleEvent(event)); }, }); const daemonExitError = daemonLifecycle.getExitError(); @@ -534,6 +551,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi } throw err; } finally { + await monitorTaskRunner.waitForIdle(); daemonLifecycle.dispose(); opts.abortSignal?.removeEventListener("abort", onAbort); daemonLifecycle.stop(); diff --git a/extensions/signal/src/monitor/access-policy.test.ts b/extensions/signal/src/monitor/access-policy.test.ts index fc7a8ad1555..f8831696dfe 100644 --- a/extensions/signal/src/monitor/access-policy.test.ts +++ b/extensions/signal/src/monitor/access-policy.test.ts @@ -1,11 +1,7 @@ +import type { AccessGroupsConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { describe, expect, it, vi } from "vitest"; import { handleSignalDirectMessageAccess, resolveSignalAccessState } from "./access-policy.js"; -vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => ({ - ...(await importOriginal()), - readStoreAllowFromForDmPolicy: vi.fn(async () => []), -})); - const SIGNAL_GROUP_ID = "signal-group-id"; const OTHER_SIGNAL_GROUP_ID = "other-signal-group-id"; const SIGNAL_SENDER = { @@ -18,6 +14,8 @@ async function resolveGroupAccess(params: { allowFrom?: string[]; groupAllowFrom?: string[]; groupId?: string; + accessGroups?: AccessGroupsConfig; + storeAllowFrom?: string[]; }) { const access = await resolveSignalAccessState({ accountId: "default", @@ -27,13 +25,22 @@ async function resolveGroupAccess(params: { groupAllowFrom: params.groupAllowFrom ?? [], sender: SIGNAL_SENDER, groupId: params.groupId, + isGroup: true, + cfg: accessGroupsConfig(params.accessGroups), + readStoreAllowFrom: async () => params.storeAllowFrom ?? [], }); return { ...access, - groupDecision: access.resolveAccessDecision(true), + groupDecision: access.senderAccess, }; } +function accessGroupsConfig( + accessGroups: AccessGroupsConfig | undefined, +): Pick | undefined { + return accessGroups ? { accessGroups } : undefined; +} + describe("resolveSignalAccessState", () => { it("allows group messages when groupAllowFrom contains the inbound Signal group id", async () => { const { groupDecision } = await resolveGroupAccess({ @@ -76,8 +83,17 @@ describe("resolveSignalAccessState", () => { expect(groupDecision.decision).toBe("allow"); }); + it("falls back to allowFrom for group sender access when groupAllowFrom is unset", async () => { + const { groupDecision } = await resolveGroupAccess({ + allowFrom: [SIGNAL_SENDER.e164], + groupId: SIGNAL_GROUP_ID, + }); + + expect(groupDecision.decision).toBe("allow"); + }); + it("does not match group ids against direct-message allowFrom entries", async () => { - const { dmAccess } = await resolveSignalAccessState({ + const { senderAccess } = await resolveSignalAccessState({ accountId: "default", dmPolicy: "allowlist", groupPolicy: "allowlist", @@ -85,9 +101,93 @@ describe("resolveSignalAccessState", () => { groupAllowFrom: [], sender: SIGNAL_SENDER, groupId: SIGNAL_GROUP_ID, + isGroup: false, }); - expect(dmAccess.decision).toBe("block"); + expect(senderAccess.decision).toBe("block"); + }); + + it("allows direct messages through static message sender access groups", async () => { + const { senderAccess } = await resolveSignalAccessState({ + accountId: "default", + dmPolicy: "allowlist", + groupPolicy: "allowlist", + allowFrom: ["accessGroup:operators"], + groupAllowFrom: [], + sender: SIGNAL_SENDER, + isGroup: false, + cfg: accessGroupsConfig({ + operators: { + type: "message.senders", + members: { + signal: [SIGNAL_SENDER.e164], + }, + }, + }), + }); + + expect(senderAccess.decision).toBe("allow"); + }); + + it("allows group messages through static message sender access groups", async () => { + const { groupDecision } = await resolveGroupAccess({ + groupAllowFrom: ["accessGroup:operators"], + groupId: SIGNAL_GROUP_ID, + accessGroups: { + operators: { + type: "message.senders", + members: { + signal: [SIGNAL_SENDER.e164], + }, + }, + }, + }); + + expect(groupDecision.decision).toBe("allow"); + }); + + it("preserves matched Signal senders in effective group allowlists", async () => { + const { groupDecision } = await resolveGroupAccess({ + groupAllowFrom: ["accessGroup:operators"], + groupId: SIGNAL_GROUP_ID, + accessGroups: { + operators: { + type: "message.senders", + members: { + signal: [SIGNAL_SENDER.e164], + }, + }, + }, + }); + + expect(groupDecision.decision).toBe("allow"); + expect(groupDecision.effectiveGroupAllowFrom).toContain(SIGNAL_SENDER.e164); + }); + + it("allows paired direct senders from the pairing store", async () => { + const { senderAccess } = await resolveSignalAccessState({ + accountId: "default", + dmPolicy: "pairing", + groupPolicy: "allowlist", + allowFrom: [], + groupAllowFrom: [], + sender: SIGNAL_SENDER, + isGroup: false, + readStoreAllowFrom: async () => [SIGNAL_SENDER.e164], + }); + + expect(senderAccess.decision).toBe("allow"); + expect(senderAccess.effectiveAllowFrom).toEqual([SIGNAL_SENDER.e164]); + }); + + it("does not let pairing-store senders satisfy group access", async () => { + const { groupDecision } = await resolveGroupAccess({ + groupAllowFrom: [], + groupId: SIGNAL_GROUP_ID, + storeAllowFrom: [SIGNAL_SENDER.e164], + }); + + expect(groupDecision.decision).toBe("block"); }); it("does not let group ids in allowFrom satisfy an explicit groupAllowFrom mismatch", async () => { @@ -99,6 +199,45 @@ describe("resolveSignalAccessState", () => { expect(groupDecision.decision).toBe("block"); }); + + it("keeps sender access allowed while blocking unauthorized group control commands", async () => { + const access = await resolveSignalAccessState({ + accountId: "default", + dmPolicy: "allowlist", + groupPolicy: "open", + allowFrom: [], + groupAllowFrom: [], + sender: SIGNAL_SENDER, + groupId: SIGNAL_GROUP_ID, + isGroup: true, + hasControlCommand: true, + }); + + expect(access.senderAccess.decision).toBe("allow"); + expect(access.commandAccess).toMatchObject({ + authorized: false, + shouldBlockControlCommand: true, + }); + }); + + it("authorizes group control commands from the shared ingress command gate", async () => { + const access = await resolveSignalAccessState({ + accountId: "default", + dmPolicy: "allowlist", + groupPolicy: "allowlist", + allowFrom: [], + groupAllowFrom: [SIGNAL_SENDER.e164], + sender: SIGNAL_SENDER, + groupId: SIGNAL_GROUP_ID, + isGroup: true, + hasControlCommand: true, + }); + + expect(access.commandAccess).toMatchObject({ + authorized: true, + shouldBlockControlCommand: false, + }); + }); }); describe("handleSignalDirectMessageAccess", () => { diff --git a/extensions/signal/src/monitor/access-policy.ts b/extensions/signal/src/monitor/access-policy.ts index 5fdfdba0787..1f4448a5b09 100644 --- a/extensions/signal/src/monitor/access-policy.ts +++ b/extensions/signal/src/monitor/access-policy.ts @@ -1,20 +1,109 @@ +import { + createChannelIngressResolver, + defineStableChannelIngressIdentity, +} from "openclaw/plugin-sdk/channel-ingress-runtime"; import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime"; import { - readStoreAllowFromForDmPolicy, - resolveDmGroupAccessWithLists, -} from "openclaw/plugin-sdk/security-runtime"; -import { isSignalSenderAllowed, type SignalSender } from "../identity.js"; + formatSignalSenderId, + looksLikeUuid, + normalizeSignalAllowRecipient, + type SignalSender, +} from "../identity.js"; type SignalDmPolicy = "open" | "pairing" | "allowlist" | "disabled"; type SignalGroupPolicy = "open" | "allowlist" | "disabled"; -function isSignalGroupAllowed(groupId: string | undefined, allowEntries: string[]): boolean { - if (!groupId) { - return false; +const SIGNAL_UUID_KIND = "plugin:signal-uuid" as const; +const SIGNAL_GROUP_KIND = "plugin:signal-group" as const; + +function strippedSignalEntry( + entry: string, +): { trimmed: string; signalStripped: string; lower: string } | null { + const trimmed = entry.trim(); + if (!trimmed) { + return null; } - const candidates = new Set([groupId, `group:${groupId}`, `signal:group:${groupId}`]); - return allowEntries.some((entry) => candidates.has(entry)); + const signalStripped = trimmed.replace(/^signal:/i, "").trim(); + const lower = signalStripped.toLowerCase(); + return { trimmed, signalStripped, lower }; +} + +function normalizeSignalGroupEntry(entry: string): string | null { + const parsed = strippedSignalEntry(entry); + if (!parsed) { + return null; + } + const { trimmed, signalStripped, lower } = parsed; + if (lower.startsWith("group:")) { + const groupId = signalStripped.slice("group:".length).trim(); + return groupId || null; + } + return trimmed; +} + +function normalizeSignalUuidEntry(entry: string): string | null { + const parsed = strippedSignalEntry(entry); + if (!parsed) { + return null; + } + const { signalStripped, lower } = parsed; + if (lower.startsWith("uuid:")) { + const raw = signalStripped.slice("uuid:".length).trim(); + return raw || null; + } + return looksLikeUuid(signalStripped) ? signalStripped : null; +} + +function normalizeSignalPhoneEntry(entry: string): string | null { + const parsed = strippedSignalEntry(entry); + if (!parsed) { + return null; + } + return normalizeSignalAllowRecipient(parsed.trimmed) ?? null; +} + +const signalIngressIdentity = defineStableChannelIngressIdentity({ + key: "stable", + normalizeEntry: () => null, + aliases: [ + { + key: "phone", + kind: "phone", + normalizeEntry: normalizeSignalPhoneEntry, + normalizeSubject: (value: string) => value, + sensitivity: "pii", + }, + { + key: "uuid", + kind: SIGNAL_UUID_KIND, + normalizeEntry: normalizeSignalUuidEntry, + normalizeSubject: (value: string) => value, + sensitivity: "pii", + }, + { + key: "group", + kind: SIGNAL_GROUP_KIND, + normalizeEntry: normalizeSignalGroupEntry, + normalizeSubject: (value: string) => value, + }, + ], + isWildcardEntry: (entry) => entry.trim() === "*", + resolveEntryId({ entryIndex, fieldKey }) { + return `entry-${entryIndex + 1}:${fieldKey}`; + }, +}); + +function signalSubjectInput(params: { sender: SignalSender; groupId?: string }) { + return { + stableId: formatSignalSenderId(params.sender), + aliases: { + phone: params.sender.kind === "phone" ? params.sender.e164 : undefined, + uuid: params.sender.kind === "uuid" ? params.sender.raw : undefined, + group: params.groupId, + }, + }; } export async function resolveSignalAccessState(params: { @@ -25,34 +114,44 @@ export async function resolveSignalAccessState(params: { groupAllowFrom: string[]; sender: SignalSender; groupId?: string; + isGroup?: boolean; + cfg?: Pick; + hasControlCommand?: boolean; + readStoreAllowFrom?: () => Promise; }) { - const storeAllowFrom = await readStoreAllowFromForDmPolicy({ - provider: "signal", + const isGroup = params.isGroup ?? params.groupId != null; + const command = + params.hasControlCommand === true + ? { + allowTextCommands: true, + directGroupAllowFrom: "effective" as const, + } + : undefined; + const ingress = createChannelIngressResolver({ + channelId: "signal", accountId: params.accountId, - dmPolicy: params.dmPolicy, + identity: signalIngressIdentity, + cfg: params.cfg, + ...(params.readStoreAllowFrom ? { readStoreAllowFrom: params.readStoreAllowFrom } : {}), + useDefaultPairingStore: params.readStoreAllowFrom == null, + }); + return await ingress.message({ + subject: signalSubjectInput({ + sender: params.sender, + groupId: isGroup ? params.groupId : undefined, + }), + conversation: { + kind: isGroup ? "group" : "direct", + id: isGroup ? (params.groupId ?? "unknown") : params.sender.raw, + }, + ...(isGroup ? { event: { mayPair: false } } : {}), + dmPolicy: params.dmPolicy, + groupPolicy: params.groupPolicy, + policy: { groupAllowFromFallbackToAllowFrom: true }, + allowFrom: params.allowFrom, + groupAllowFrom: params.groupAllowFrom, + command, }); - const isSenderAllowed = (allowEntries: string[]) => - isSignalSenderAllowed(params.sender, allowEntries); - const isSenderOrGroupAllowed = (allowEntries: string[]) => - isSenderAllowed(allowEntries) || isSignalGroupAllowed(params.groupId, allowEntries); - const resolveAccessDecision = (isGroup: boolean) => - resolveDmGroupAccessWithLists({ - isGroup, - dmPolicy: params.dmPolicy, - groupPolicy: params.groupPolicy, - allowFrom: params.allowFrom, - groupAllowFrom: params.groupAllowFrom, - storeAllowFrom, - isSenderAllowed: isGroup ? isSenderOrGroupAllowed : isSenderAllowed, - }); - const dmAccess = resolveAccessDecision(false); - return { - resolveAccessDecision, - isGroupAllowed: isSenderOrGroupAllowed, - dmAccess, - effectiveDmAllow: dmAccess.effectiveAllowFrom, - effectiveGroupAllow: dmAccess.effectiveGroupAllowFrom, - }; } export async function handleSignalDirectMessageAccess(params: { diff --git a/extensions/signal/src/monitor/event-handler.ts b/extensions/signal/src/monitor/event-handler.ts index f9f5264dac8..5c08acad5d5 100644 --- a/extensions/signal/src/monitor/event-handler.ts +++ b/extensions/signal/src/monitor/event-handler.ts @@ -16,8 +16,7 @@ import { resolveChannelGroupPolicy, resolveChannelGroupRequireMention, } from "openclaw/plugin-sdk/channel-policy"; -import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth"; -import { hasControlCommand } from "openclaw/plugin-sdk/command-auth"; +import { hasControlCommand } from "openclaw/plugin-sdk/command-auth-native"; import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; import { createInternalHookEvent, @@ -37,10 +36,7 @@ import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runti import { settleReplyDispatcher } from "openclaw/plugin-sdk/reply-runtime"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { - DM_GROUP_ACCESS_REASON, - resolvePinnedMainDmOwnerFromAllowlist, -} from "openclaw/plugin-sdk/security-runtime"; +import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime"; import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime"; import { normalizeE164, normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; @@ -48,7 +44,6 @@ import { formatSignalPairingIdLine, formatSignalSenderDisplay, formatSignalSenderId, - isSignalSenderAllowed, normalizeSignalAllowRecipient, resolveSignalPeerId, resolveSignalRecipient, @@ -425,10 +420,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { senderDisplay: string; reaction: SignalReactionMessage; hasBodyContent: boolean; - resolveAccessDecision: (isGroup: boolean) => { - decision: "allow" | "block" | "pairing"; - reason: string; - }; + accessDecision: { decision: "allow" | "block" | "pairing"; reasonCode: string }; }): boolean { if (params.hasBodyContent) { return false; @@ -442,10 +434,9 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { const groupId = params.reaction.groupInfo?.groupId ?? undefined; const groupName = params.reaction.groupInfo?.groupName ?? undefined; const isGroup = Boolean(groupId); - const reactionAccess = params.resolveAccessDecision(isGroup); - if (reactionAccess.decision !== "allow") { + if (params.accessDecision.decision !== "allow") { logVerbose( - `Blocked signal reaction sender ${params.senderDisplay} (${reactionAccess.reason})`, + `Blocked signal reaction sender ${params.senderDisplay} (${params.accessDecision.reasonCode})`, ); return true; } @@ -555,15 +546,10 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { const messageText = normalizedMessage.trim(); const groupId = dataMessage?.groupInfo?.groupId ?? reaction?.groupInfo?.groupId ?? undefined; const isGroup = Boolean(groupId); + const hasControlCommandInMessage = hasControlCommand(messageText, deps.cfg); const senderDisplay = formatSignalSenderDisplay(sender); - const { - resolveAccessDecision, - isGroupAllowed, - dmAccess, - effectiveDmAllow, - effectiveGroupAllow, - } = await resolveSignalAccessState({ + const { senderAccess, commandAccess } = await resolveSignalAccessState({ accountId: deps.accountId, dmPolicy: deps.dmPolicy, groupPolicy: deps.groupPolicy, @@ -571,6 +557,9 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { groupAllowFrom: deps.groupAllowFrom, sender, groupId, + isGroup, + cfg: deps.cfg, + hasControlCommand: hasControlCommandInMessage, }); const quoteText = normalizeOptionalString(dataMessage?.quote?.text) ?? ""; const { contextVisibilityMode, quoteSenderAllowed, visibleQuoteText, visibleQuoteSender } = @@ -579,7 +568,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { accountId: deps.accountId, isGroup, dataMessage, - effectiveGroupAllow, + effectiveGroupAllow: senderAccess.effectiveGroupAllowFrom, }); if (quoteText && !visibleQuoteText && isGroup) { logVerbose( @@ -598,7 +587,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { senderDisplay, reaction, hasBodyContent, - resolveAccessDecision, + accessDecision: senderAccess, }) ) { return; @@ -619,7 +608,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { if (!isGroup) { const allowedDirectMessage = await handleSignalDirectMessageAccess({ dmPolicy: deps.dmPolicy, - dmAccessDecision: dmAccess.decision, + dmAccessDecision: senderAccess.decision, senderId: senderAllowId, senderIdLine, senderDisplay, @@ -641,11 +630,10 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { } } if (isGroup) { - const groupAccess = resolveAccessDecision(true); - if (groupAccess.decision !== "allow") { - if (groupAccess.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_DISABLED) { + if (senderAccess.decision !== "allow") { + if (senderAccess.reasonCode === "group_policy_disabled") { logVerbose("Blocked signal group message (groupPolicy: disabled)"); - } else if (groupAccess.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST) { + } else if (senderAccess.reasonCode === "group_policy_empty_allowlist") { logVerbose("Blocked signal group message (groupPolicy: allowlist, no groupAllowFrom)"); } else { logVerbose(`Blocked signal group sender ${senderDisplay} (not in groupAllowFrom)`); @@ -654,22 +642,8 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { } } - const useAccessGroups = deps.cfg.commands?.useAccessGroups !== false; - const commandDmAllow = isGroup ? deps.allowFrom : effectiveDmAllow; - const ownerAllowedForCommands = isSignalSenderAllowed(sender, commandDmAllow); - const groupAllowedForCommands = isGroupAllowed(effectiveGroupAllow); - const hasControlCommandInMessage = hasControlCommand(messageText, deps.cfg); - const commandGate = resolveControlCommandGate({ - useAccessGroups, - authorizers: [ - { configured: commandDmAllow.length > 0, allowed: ownerAllowedForCommands }, - { configured: effectiveGroupAllow.length > 0, allowed: groupAllowedForCommands }, - ], - allowTextCommands: true, - hasControlCommand: hasControlCommandInMessage, - }); - const commandAuthorized = commandGate.commandAuthorized; - if (isGroup && commandGate.shouldBlock) { + const commandAuthorized = commandAccess.authorized; + if (isGroup && commandAccess.shouldBlockControlCommand) { logInboundDrop({ log: logVerbose, channel: "signal", diff --git a/extensions/slack/src/monitor/auth.test.ts b/extensions/slack/src/monitor/auth.test.ts index db554cb401b..2e0de1e4329 100644 --- a/extensions/slack/src/monitor/auth.test.ts +++ b/extensions/slack/src/monitor/auth.test.ts @@ -1,19 +1,20 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { SlackMonitorContext } from "./context.js"; -const readStoreAllowFromForDmPolicyMock = vi.hoisted(() => vi.fn()); +const readChannelIngressStoreAllowFromForDmPolicyMock = vi.hoisted(() => vi.fn()); let authorizeSlackSystemEventSender: typeof import("./auth.js").authorizeSlackSystemEventSender; let clearSlackAllowFromCacheForTest: typeof import("./auth.js").clearSlackAllowFromCacheForTest; let resolveSlackEffectiveAllowFrom: typeof import("./auth.js").resolveSlackEffectiveAllowFrom; +let resolveSlackCommandIngress: typeof import("./auth.js").resolveSlackCommandIngress; -vi.mock("openclaw/plugin-sdk/security-runtime", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/security-runtime", - ); +vi.mock("openclaw/plugin-sdk/channel-ingress-runtime", async () => { + const actual = await vi.importActual< + typeof import("openclaw/plugin-sdk/channel-ingress-runtime") + >("openclaw/plugin-sdk/channel-ingress-runtime"); return { ...actual, - readStoreAllowFromForDmPolicy: (...args: unknown[]) => - readStoreAllowFromForDmPolicyMock(...args), + readChannelIngressStoreAllowFromForDmPolicy: (...args: unknown[]) => + readChannelIngressStoreAllowFromForDmPolicyMock(...args), }; }); @@ -53,8 +54,6 @@ function makeAuthorizeCtx(params?: { } describe("resolveSlackEffectiveAllowFrom", () => { - const prevTtl = process.env.OPENCLAW_SLACK_PAIRING_ALLOWFROM_CACHE_TTL_MS; - beforeAll(async () => { ({ authorizeSlackSystemEventSender, @@ -64,54 +63,47 @@ describe("resolveSlackEffectiveAllowFrom", () => { }); beforeEach(() => { - readStoreAllowFromForDmPolicyMock.mockReset(); + readChannelIngressStoreAllowFromForDmPolicyMock.mockReset(); clearSlackAllowFromCacheForTest(); - if (prevTtl === undefined) { - delete process.env.OPENCLAW_SLACK_PAIRING_ALLOWFROM_CACHE_TTL_MS; - } else { - process.env.OPENCLAW_SLACK_PAIRING_ALLOWFROM_CACHE_TTL_MS = prevTtl; - } }); it("falls back to channel config allowFrom when pairing store throws", async () => { - readStoreAllowFromForDmPolicyMock.mockRejectedValueOnce(new Error("boom")); + readChannelIngressStoreAllowFromForDmPolicyMock.mockRejectedValueOnce(new Error("boom")); - const effective = await resolveSlackEffectiveAllowFrom(makeSlackCtx(["u1"])); + const effective = await resolveSlackEffectiveAllowFrom(makeSlackCtx(["u1"]), { + includePairingStore: true, + }); - expect(effective.allowFrom).toEqual(["u1"]); - expect(effective.allowFromLower).toEqual(["u1"]); + expect(effective).toEqual(["u1"]); }); it("treats malformed non-array pairing-store responses as empty", async () => { - readStoreAllowFromForDmPolicyMock.mockReturnValueOnce(undefined); + readChannelIngressStoreAllowFromForDmPolicyMock.mockReturnValueOnce(undefined); + + const effective = await resolveSlackEffectiveAllowFrom(makeSlackCtx(["u1"]), { + includePairingStore: true, + }); + + expect(effective).toEqual(["u1"]); + }); + + it("reads pairing-store allowFrom when requested", async () => { + readChannelIngressStoreAllowFromForDmPolicyMock.mockResolvedValue(["u2"]); + const ctx = makeSlackCtx(["u1"]); + + const effective = await resolveSlackEffectiveAllowFrom(ctx, { includePairingStore: true }); + + expect(effective).toEqual(["u1", "u2"]); + expect(readChannelIngressStoreAllowFromForDmPolicyMock).toHaveBeenCalledTimes(1); + }); + + it("does not read pairing-store allowFrom unless requested", async () => { + readChannelIngressStoreAllowFromForDmPolicyMock.mockResolvedValue(["u2"]); const effective = await resolveSlackEffectiveAllowFrom(makeSlackCtx(["u1"])); - expect(effective.allowFrom).toEqual(["u1"]); - expect(effective.allowFromLower).toEqual(["u1"]); - }); - - it("memoizes pairing-store allowFrom reads within TTL", async () => { - readStoreAllowFromForDmPolicyMock.mockResolvedValue(["u2"]); - const ctx = makeSlackCtx(["u1"]); - - const first = await resolveSlackEffectiveAllowFrom(ctx, { includePairingStore: true }); - const second = await resolveSlackEffectiveAllowFrom(ctx, { includePairingStore: true }); - - expect(first.allowFrom).toEqual(["u1", "u2"]); - expect(second.allowFrom).toEqual(["u1", "u2"]); - expect(readStoreAllowFromForDmPolicyMock).toHaveBeenCalledTimes(1); - }); - - it("refreshes pairing-store allowFrom when cache TTL is zero", async () => { - process.env.OPENCLAW_SLACK_PAIRING_ALLOWFROM_CACHE_TTL_MS = "0"; - readStoreAllowFromForDmPolicyMock.mockResolvedValue(["u2"]); - const ctx = makeSlackCtx(["u1"]); - - await resolveSlackEffectiveAllowFrom(ctx, { includePairingStore: true }); - await resolveSlackEffectiveAllowFrom(ctx, { includePairingStore: true }); - - expect(readStoreAllowFromForDmPolicyMock).toHaveBeenCalledTimes(2); + expect(effective).toEqual(["u1"]); + expect(readChannelIngressStoreAllowFromForDmPolicyMock).not.toHaveBeenCalled(); }); }); @@ -305,6 +297,35 @@ describe("authorizeSlackSystemEventSender", () => { }); }); +describe("resolveSlackCommandIngress", () => { + beforeAll(async () => { + ({ resolveSlackCommandIngress, clearSlackAllowFromCacheForTest } = await import("./auth.js")); + }); + + beforeEach(() => { + clearSlackAllowFromCacheForTest(); + }); + + it("does not authorize commands when sender denial stops before the command gate", async () => { + const result = await resolveSlackCommandIngress({ + ctx: makeAuthorizeCtx(), + senderId: "U_DENIED", + channelType: "channel", + channelId: "C1", + ownerAllowFromLower: ["u_owner"], + channelUsers: ["U_ALLOWED"], + allowTextCommands: false, + hasControlCommand: true, + eventKind: "button", + modeWhenAccessGroupsOff: "configured", + }); + + expect(result.ingress.decision).toBe("block"); + expect(result.commandAccess.authorized).toBe(false); + expect(result.commandAccess.shouldBlockControlCommand).toBe(false); + }); +}); + describe("authorizeSlackSystemEventSender interactiveEvent", () => { beforeAll(async () => { ({ authorizeSlackSystemEventSender, clearSlackAllowFromCacheForTest } = diff --git a/extensions/slack/src/monitor/auth.ts b/extensions/slack/src/monitor/auth.ts index 6e102615885..91c24d0dcbb 100644 --- a/extensions/slack/src/monitor/auth.ts +++ b/extensions/slack/src/monitor/auth.ts @@ -1,79 +1,152 @@ +import { + type ChannelIngressEventInput, + type ChannelIngressIdentifierKind, + type ChannelIngressPolicyInput, + type ChannelIngressStateInput, + type ChannelIngressDecision, + createChannelIngressResolver, + defineStableChannelIngressIdentity, + readChannelIngressStoreAllowFromForDmPolicy, +} from "openclaw/plugin-sdk/channel-ingress-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { readStoreAllowFromForDmPolicy } from "openclaw/plugin-sdk/security-runtime"; import { allowListMatches, normalizeAllowList, normalizeAllowListLower, normalizeSlackAllowOwnerEntry, - resolveSlackAllowListMatch, - resolveSlackUserAllowed, + normalizeSlackSlug, } from "./allow-list.js"; import { resolveSlackChannelConfig } from "./channel-config.js"; import { inferSlackChannelType } from "./channel-type.js"; import { normalizeSlackChannelType, type SlackMonitorContext } from "./context.js"; -type ResolvedAllowFromLists = { - allowFrom: string[]; - allowFromLower: string[]; -}; - -type SlackAllowFromCacheState = { - baseSignature?: string; - base?: ResolvedAllowFromLists; - pairingKey?: string; - pairing?: ResolvedAllowFromLists; - pairingExpiresAtMs?: number; - pairingPending?: Promise; -}; - type SlackChannelMembersCacheEntry = { expiresAtMs: number; members?: Set; pending?: Promise>; }; -let slackAllowFromCache = new WeakMap(); +type SlackIngressChannelType = "im" | "mpim" | "channel" | "group"; +type SlackSystemEventAuthorization = + | { + allowed: true; + channelType?: SlackIngressChannelType; + channelName?: string; + } + | { + allowed: false; + reason: string; + channelType?: SlackIngressChannelType; + channelName?: string; + }; + let slackChannelMembersCache = new WeakMap< SlackMonitorContext, Map >(); -const DEFAULT_PAIRING_ALLOW_FROM_CACHE_TTL_MS = 5000; const DEFAULT_CHANNEL_MEMBERS_CACHE_TTL_MS = 60_000; const CHANNEL_MEMBERS_CACHE_MAX = 512; +const SLACK_CHANNEL_ID = "slack"; +const SLACK_USER_NAME_KIND = + "plugin:slack-user-name" as const satisfies ChannelIngressIdentifierKind; -function getPairingAllowFromCacheTtlMs(): number { - const raw = process.env.OPENCLAW_SLACK_PAIRING_ALLOWFROM_CACHE_TTL_MS?.trim(); - if (!raw) { - return DEFAULT_PAIRING_ALLOW_FROM_CACHE_TTL_MS; +function normalizeSlackUserId(raw?: string | null): string { + const value = (raw ?? "").trim().toLowerCase(); + if (!value) { + return ""; } - const parsed = Number(raw); - if (!Number.isFinite(parsed)) { - return DEFAULT_PAIRING_ALLOW_FROM_CACHE_TTL_MS; + const mention = value.match(/^<@([a-z0-9_]+)>$/i); + if (mention?.[1]) { + return mention[1]; } - return Math.max(0, Math.floor(parsed)); + return value.replace(/^(slack:|user:)/, ""); } -function getChannelMembersCacheTtlMs(): number { - const raw = process.env.OPENCLAW_SLACK_CHANNEL_MEMBERS_CACHE_TTL_MS?.trim(); - if (!raw) { - return DEFAULT_CHANNEL_MEMBERS_CACHE_TTL_MS; - } - const parsed = Number(raw); - if (!Number.isFinite(parsed)) { - return DEFAULT_CHANNEL_MEMBERS_CACHE_TTL_MS; - } - return Math.max(0, Math.floor(parsed)); +function isSlackStableUserId(value: string): boolean { + return /^[ubw][a-z0-9_]+$/i.test(value); } -function getAllowFromCacheState(ctx: SlackMonitorContext): SlackAllowFromCacheState { - const existing = slackAllowFromCache.get(ctx); - if (existing) { - return existing; +function normalizeSlackStableEntry(entry: string): string | null { + const normalized = entry.trim().toLowerCase(); + if (!normalized) { + return null; } - const next: SlackAllowFromCacheState = {}; - slackAllowFromCache.set(ctx, next); - return next; + const userId = normalizeSlackUserId(normalized); + return isSlackStableUserId(userId) ? userId : null; +} + +function normalizeSlackNameEntry(entry: string): string | null { + const normalized = entry.trim().toLowerCase(); + if (!normalized || normalizeSlackStableEntry(normalized)) { + return null; + } + return normalized.replace(/^slack:/, "") || null; +} + +function normalizeSlackNameSubject(value: string): string | null { + return value.trim().toLowerCase() || null; +} + +function normalizeSlackNameSlugEntry(entry: string): string | null { + const name = normalizeSlackNameEntry(entry); + if (!name) { + return null; + } + const slug = normalizeSlackSlug(name); + return slug && slug !== name ? slug : null; +} + +const slackIngressIdentity = defineStableChannelIngressIdentity({ + key: "senderId", + kind: "stable-id", + normalizeEntry: normalizeSlackStableEntry, + normalizeSubject: normalizeSlackUserId, + sensitivity: "pii", + aliases: ( + [ + ["senderName", normalizeSlackNameEntry], + ["senderNameSlug", normalizeSlackNameSlugEntry], + ] as const + ).map(([key, normalizeEntry]) => ({ + key, + kind: SLACK_USER_NAME_KIND, + normalizeEntry, + normalizeSubject: normalizeSlackNameSubject, + dangerous: true, + sensitivity: "pii" as const, + })), +}); + +function createSlackIngressSubject(params: { senderId: string; senderName?: string }) { + const senderId = normalizeSlackUserId(params.senderId); + const senderName = params.senderName?.trim().toLowerCase(); + const senderNameSlug = senderName ? normalizeSlackSlug(senderName) : undefined; + return { + stableId: senderId, + aliases: { + senderName, + senderNameSlug, + }, + }; +} + +function createSlackIngressResolver(ctx: SlackMonitorContext) { + return createChannelIngressResolver({ + channelId: SLACK_CHANNEL_ID, + accountId: ctx.accountId, + identity: slackIngressIdentity, + cfg: ctx.cfg, + }); +} + +function readSlackCacheTtlMs(envName: string, fallback: number): number { + const raw = process.env[envName]?.trim(); + if (!raw) { + return fallback; + } + const parsed = Number(raw); + return Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : fallback; } function getChannelMembersCache( @@ -98,112 +171,39 @@ function pruneChannelMembersCache(cache: Map 0 && - cache.pairing && - cache.pairingKey === pairingKey && - (cache.pairingExpiresAtMs ?? 0) >= nowMs - ) { - return cache.pairing; - } - if (cache.pairingPending && cache.pairingKey === pairingKey) { - return await cache.pairingPending; - } - - const pairingPending = (async (): Promise => { - let storeAllowFrom: string[] = []; - try { - const resolved = await readStoreAllowFromForDmPolicy({ - provider: "slack", - accountId: ctx.accountId, - dmPolicy: ctx.dmPolicy, - }); - storeAllowFrom = Array.isArray(resolved) ? resolved : []; - } catch { - storeAllowFrom = []; - } - const allowFrom = normalizeAllowList([...(cache.base?.allowFrom ?? []), ...storeAllowFrom]); - return { - allowFrom, - allowFromLower: normalizeAllowListLower(allowFrom), - }; - })(); - - cache.pairingKey = pairingKey; - cache.pairingPending = pairingPending; + let storeAllowFrom: string[] = []; try { - const resolved = await pairingPending; - if (ttlMs > 0) { - cache.pairing = resolved; - cache.pairingExpiresAtMs = nowMs + ttlMs; - } else { - cache.pairing = undefined; - cache.pairingExpiresAtMs = undefined; - } - return resolved; - } finally { - if (cache.pairingPending === pairingPending) { - cache.pairingPending = undefined; - } + const resolved = await readChannelIngressStoreAllowFromForDmPolicy({ + provider: "slack", + accountId: ctx.accountId, + dmPolicy: ctx.dmPolicy, + }); + storeAllowFrom = Array.isArray(resolved) ? resolved : []; + } catch { + storeAllowFrom = []; } + return normalizeAllowListLower([...base, ...storeAllowFrom]); } export function clearSlackAllowFromCacheForTest(): void { - slackAllowFromCache = new WeakMap(); slackChannelMembersCache = new WeakMap< SlackMonitorContext, Map >(); } -export function isSlackSenderAllowListed(params: { - allowListLower: string[]; - senderId: string; - senderName?: string; - allowNameMatching?: boolean; -}) { - const { allowListLower, senderId, senderName, allowNameMatching } = params; - return ( - allowListLower.length === 0 || - allowListMatches({ - allowList: allowListLower, - id: senderId, - name: senderName, - allowNameMatching, - }) - ); -} - async function fetchSlackChannelMemberIds( ctx: SlackMonitorContext, channelId: string, @@ -232,7 +232,10 @@ async function resolveSlackChannelMemberIds( ): Promise> { const cache = getChannelMembersCache(ctx); const key = `${ctx.accountId}:${channelId}`; - const ttlMs = getChannelMembersCacheTtlMs(); + const ttlMs = readSlackCacheTtlMs( + "OPENCLAW_SLACK_CHANNEL_MEMBERS_CACHE_TTL_MS", + DEFAULT_CHANNEL_MEMBERS_CACHE_TTL_MS, + ); const nowMs = Date.now(); const cached = cache.get(key); if (ttlMs > 0 && cached?.members && cached.expiresAtMs >= nowMs) { @@ -326,21 +329,139 @@ export async function authorizeSlackBotRoomMessage(params: { return false; } -export type SlackSystemEventAuthResult = { - allowed: boolean; - reason?: - | "missing-sender" - | "missing-expected-sender" - | "sender-mismatch" - | "channel-not-allowed" - | "ambiguous-channel-type" - | "dm-disabled" - | "sender-not-allowlisted" - | "sender-not-channel-allowed" - | "sender-not-authorized"; - channelType?: "im" | "mpim" | "channel" | "group"; - channelName?: string; -}; +function wildcardWhenOpen(entries: readonly string[]): string[] { + return entries.length > 0 ? [...entries] : ["*"]; +} + +function slackIngressConversationKind( + channelType: SlackIngressChannelType, +): "direct" | "group" | "channel" { + return channelType === "im" ? "direct" : channelType === "mpim" ? "group" : "channel"; +} + +export async function resolveSlackCommandIngress(params: { + ctx: SlackMonitorContext; + senderId: string; + senderName?: string; + channelType: SlackIngressChannelType; + channelId: string; + ownerAllowFromLower: string[]; + channelUsers?: Array; + allowTextCommands: boolean; + hasControlCommand: boolean; + mentionFacts?: ChannelIngressStateInput["mentionFacts"]; + activation?: NonNullable; + eventKind?: ChannelIngressEventInput["kind"]; + modeWhenAccessGroupsOff?: NonNullable< + ChannelIngressPolicyInput["command"] + >["modeWhenAccessGroupsOff"]; +}) { + const isDirectMessage = params.channelType === "im"; + const channelUsers = normalizeAllowListLower(params.channelUsers); + const channelUsersConfigured = !isDirectMessage && channelUsers.length > 0; + const result = await createSlackIngressResolver(params.ctx).message({ + subject: createSlackIngressSubject({ + senderId: params.senderId, + senderName: params.senderName, + }), + conversation: { + kind: slackIngressConversationKind(params.channelType), + id: params.channelId, + }, + event: { + kind: params.eventKind ?? "message", + authMode: "inbound", + mayPair: false, + }, + dmPolicy: isDirectMessage ? "open" : "disabled", + groupPolicy: channelUsersConfigured ? "allowlist" : "open", + policy: { + groupAllowFromFallbackToAllowFrom: false, + mutableIdentifierMatching: params.ctx.allowNameMatching ? "enabled" : "disabled", + ...(params.activation ? { activation: params.activation } : {}), + }, + mentionFacts: params.mentionFacts, + allowFrom: isDirectMessage ? ["*"] : params.ownerAllowFromLower, + groupAllowFrom: channelUsersConfigured ? channelUsers : [], + command: { + allowTextCommands: params.allowTextCommands, + hasControlCommand: params.hasControlCommand, + modeWhenAccessGroupsOff: params.modeWhenAccessGroupsOff, + ...(isDirectMessage ? { commandOwnerAllowFrom: params.ownerAllowFromLower } : {}), + }, + }); + return result; +} + +async function decideSlackSystemIngress(params: { + ctx: SlackMonitorContext; + senderId: string; + senderName?: string; + channelType: SlackIngressChannelType; + channelId?: string; + ownerAllowFromLower: string[]; + channelUsers?: Array; + interactiveEvent: boolean; +}): Promise { + const isDirectMessage = params.channelType === "im"; + const channelUsers = normalizeAllowListLower(params.channelUsers); + const channelUsersConfigured = !isDirectMessage && channelUsers.length > 0; + const ownerAllowFrom = + params.interactiveEvent && channelUsersConfigured + ? params.ownerAllowFromLower.filter((entry) => entry !== "*") + : params.ownerAllowFromLower; + const hasAnyCommandAllowlist = ownerAllowFrom.length > 0 || channelUsersConfigured; + const groupAllowFrom = (() => { + if (isDirectMessage) { + return []; + } + if (params.interactiveEvent && hasAnyCommandAllowlist) { + return channelUsersConfigured ? channelUsers : []; + } + if (channelUsersConfigured) { + return channelUsers; + } + return params.channelId ? ["*"] : wildcardWhenOpen(params.ownerAllowFromLower); + })(); + const result = await createSlackIngressResolver(params.ctx).message({ + subject: createSlackIngressSubject({ + senderId: params.senderId, + senderName: params.senderName, + }), + conversation: { + kind: slackIngressConversationKind(params.channelType), + id: params.channelId ?? "slack-system", + }, + event: { + kind: params.interactiveEvent ? "button" : "system", + authMode: params.interactiveEvent && hasAnyCommandAllowlist ? "command" : "inbound", + mayPair: false, + }, + dmPolicy: isDirectMessage ? "open" : "disabled", + groupPolicy: + params.interactiveEvent && hasAnyCommandAllowlist + ? "open" + : channelUsersConfigured || (!params.channelId && params.ownerAllowFromLower.length > 0) + ? "allowlist" + : "open", + policy: { + groupAllowFromFallbackToAllowFrom: false, + mutableIdentifierMatching: params.ctx.allowNameMatching ? "enabled" : "disabled", + }, + allowFrom: isDirectMessage ? wildcardWhenOpen(params.ownerAllowFromLower) : ownerAllowFrom, + groupAllowFrom, + command: + params.interactiveEvent && hasAnyCommandAllowlist + ? { + useAccessGroups: true, + allowTextCommands: true, + modeWhenAccessGroupsOff: "configured", + commandOwnerAllowFrom: ownerAllowFrom, + } + : undefined, + }); + return result.ingress; +} export async function authorizeSlackSystemEventSender(params: { ctx: SlackMonitorContext; @@ -352,7 +473,7 @@ export async function authorizeSlackSystemEventSender(params: { * and applies interactive-only owner allowFrom checks without changing the * open-by-default channel behavior when no allowlists are configured. */ interactiveEvent?: boolean; -}): Promise { +}): Promise { const senderId = params.senderId?.trim(); if (!senderId) { return { allowed: false, reason: "missing-sender" }; @@ -424,118 +545,61 @@ export async function authorizeSlackSystemEventSender(params: { .resolveUserName(senderId) .catch(() => ({})); const senderName = senderInfo.name; + const ingressChannelType = channelType ?? "channel"; - const resolveAllowFromLower = async (includePairingStore = false) => - (await resolveSlackEffectiveAllowFrom(params.ctx, { includePairingStore })).allowFromLower; - - if (channelType === "im") { + if (ingressChannelType === "im") { if (!params.ctx.dmEnabled || params.ctx.dmPolicy === "disabled") { return { allowed: false, reason: "dm-disabled", channelType, channelName }; } - const allowFromLower = await resolveAllowFromLower(true); - const senderAllowListed = isSlackSenderAllowListed({ - allowListLower: allowFromLower, - senderId, - senderName, - allowNameMatching: params.ctx.allowNameMatching, - }); - if (!senderAllowListed) { - return { - allowed: false, - reason: "sender-not-allowlisted", - channelType, - channelName, - }; - } - } else if (!channelId) { - // No channel context. Preserve the existing open default unless a global - // allowFrom list is configured. - const allowFromLower = await resolveAllowFromLower(false); - if (allowFromLower.length > 0) { - const senderAllowListed = isSlackSenderAllowListed({ - allowListLower: allowFromLower, - senderId, - senderName, - allowNameMatching: params.ctx.allowNameMatching, - }); - if (!senderAllowListed) { - return { allowed: false, reason: "sender-not-allowlisted" }; - } - } - } else { - const allowFromLower = await resolveAllowFromLower(false); - const ownerAllowlistConfigured = allowFromLower.length > 0; - const allowFromLowerWithoutWildcard = allowFromLower.filter((entry) => entry !== "*"); - const channelConfig = resolveSlackChannelConfig({ - channelId, - channelName, - channels: params.ctx.channelsConfig, - channelKeys: params.ctx.channelsConfigKeys, - defaultRequireMention: params.ctx.defaultRequireMention, - allowNameMatching: params.ctx.allowNameMatching, - }); - const channelUsersAllowlistConfigured = - Array.isArray(channelConfig?.users) && channelConfig.users.length > 0; - const ownerMatch = ownerAllowlistConfigured - ? resolveSlackAllowListMatch({ - allowList: allowFromLower, - id: senderId, - name: senderName, - allowNameMatching: params.ctx.allowNameMatching, - }) - : { allowed: false }; - const ownerAllowed = ownerMatch.allowed; - const ownerExplicitlyAllowed = - allowFromLowerWithoutWildcard.length > 0 && - resolveSlackAllowListMatch({ - allowList: allowFromLowerWithoutWildcard, - id: senderId, - name: senderName, - allowNameMatching: params.ctx.allowNameMatching, - }).allowed; - if (channelUsersAllowlistConfigured) { - const channelUserAllowed = resolveSlackUserAllowed({ - allowList: channelConfig?.users, - userId: senderId, - userName: senderName, - allowNameMatching: params.ctx.allowNameMatching, - }); - if (channelUserAllowed || (params.interactiveEvent && ownerExplicitlyAllowed)) { - return { - allowed: true, - channelType, - channelName, - }; - } - return { - allowed: false, - reason: - params.interactiveEvent && ownerAllowlistConfigured - ? "sender-not-authorized" - : "sender-not-channel-allowed", - channelType, - channelName, - }; - } - if (params.interactiveEvent && ownerAllowed) { - return { - allowed: true, - channelType, - channelName, - }; - } - if (params.interactiveEvent && ownerAllowlistConfigured) { - return { - allowed: false, - reason: "sender-not-allowlisted", - channelType, - channelName, - }; - } } + const allowFromLower = await resolveSlackEffectiveAllowFrom(params.ctx, { + includePairingStore: ingressChannelType === "im", + }); + const channelConfig = channelId + ? resolveSlackChannelConfig({ + channelId, + channelName, + channels: params.ctx.channelsConfig, + channelKeys: params.ctx.channelsConfigKeys, + defaultRequireMention: params.ctx.defaultRequireMention, + allowNameMatching: params.ctx.allowNameMatching, + }) + : null; + const channelUsersAllowlistConfigured = + Array.isArray(channelConfig?.users) && channelConfig.users.length > 0; + const decision = await decideSlackSystemIngress({ + ctx: params.ctx, + senderId, + senderName, + channelType: ingressChannelType, + channelId, + ownerAllowFromLower: allowFromLower, + channelUsers: channelConfig?.users, + interactiveEvent: params.interactiveEvent === true, + }); + if (decision.decision === "allow") { + return { + allowed: true, + channelType, + channelName, + }; + } + if (channelType === "im" || !channelId) { + return { + allowed: false, + reason: "sender-not-allowlisted", + ...(channelId ? { channelType, channelName } : {}), + }; + } return { - allowed: true, + allowed: false, + reason: + params.interactiveEvent && channelUsersAllowlistConfigured && allowFromLower.length > 0 + ? "sender-not-authorized" + : channelUsersAllowlistConfigured + ? "sender-not-channel-allowed" + : "sender-not-allowlisted", channelType, channelName, }; diff --git a/extensions/slack/src/monitor/events/channels.test.ts b/extensions/slack/src/monitor/events/channels.test.ts index beede618cf4..22f428e7137 100644 --- a/extensions/slack/src/monitor/events/channels.test.ts +++ b/extensions/slack/src/monitor/events/channels.test.ts @@ -10,10 +10,6 @@ vi.mock("openclaw/plugin-sdk/system-event-runtime", () => ({ vi.mock("openclaw/plugin-sdk/system-event-runtime.js", () => ({ enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args), })); -vi.mock("openclaw/plugin-sdk/security-runtime", () => ({ - readStoreAllowFromForDmPolicy: async () => [], -})); - type SlackChannelHandler = (args: { event: Record; body: unknown; diff --git a/extensions/slack/src/monitor/events/interactions.block-actions.ts b/extensions/slack/src/monitor/events/interactions.block-actions.ts index 9b8730efc41..b3cd9dba733 100644 --- a/extensions/slack/src/monitor/events/interactions.block-actions.ts +++ b/extensions/slack/src/monitor/events/interactions.block-actions.ts @@ -2,10 +2,7 @@ import type { SlackActionMiddlewareArgs } from "@slack/bolt"; import type { Block, KnownBlock } from "@slack/web-api"; import { resolveApprovalOverGateway } from "openclaw/plugin-sdk/approval-gateway-runtime"; import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime"; -import { - resolveCommandAuthorization, - resolveCommandAuthorizedFromAuthorizers, -} from "openclaw/plugin-sdk/command-auth-native"; +import { resolveCommandAuthorization } from "openclaw/plugin-sdk/command-auth-native"; import { requestHeartbeat } from "openclaw/plugin-sdk/heartbeat-runtime"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; @@ -18,8 +15,11 @@ import { SLACK_REPLY_BUTTON_ACTION_ID, SLACK_REPLY_SELECT_ACTION_ID, } from "../../reply-action-ids.js"; -import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "../allow-list.js"; -import { authorizeSlackSystemEventSender, resolveSlackEffectiveAllowFrom } from "../auth.js"; +import { + authorizeSlackSystemEventSender, + resolveSlackCommandIngress, + resolveSlackEffectiveAllowFrom, +} from "../auth.js"; import { resolveSlackChannelConfig } from "../channel-config.js"; import type { SlackMonitorContext } from "../context.js"; import { @@ -708,20 +708,13 @@ async function resolveSlackBlockActionCommandAuthorized(params: { const isDirectMessage = params.auth.channelType === "im"; const isRoom = params.auth.channelType === "channel" || params.auth.channelType === "group"; - const { allowFromLower } = await resolveSlackEffectiveAllowFrom(params.ctx, { + const allowFromLower = await resolveSlackEffectiveAllowFrom(params.ctx, { includePairingStore: isDirectMessage, }); const sender = await params.ctx.resolveUserName(params.parsed.userId).catch(() => undefined); const senderName = sender?.name; - const ownerAllowed = resolveSlackAllowListMatch({ - allowList: allowFromLower, - id: params.parsed.userId, - name: senderName, - allowNameMatching: params.ctx.allowNameMatching, - }).allowed; - let channelUsersAllowlistConfigured = false; - let channelUserAllowed = false; + let channelUsers: Array = []; if (isRoom && params.parsed.channelId) { const channelConfig = resolveSlackChannelConfig({ channelId: params.parsed.channelId, @@ -731,26 +724,23 @@ async function resolveSlackBlockActionCommandAuthorized(params: { defaultRequireMention: params.ctx.defaultRequireMention, allowNameMatching: params.ctx.allowNameMatching, }); - channelUsersAllowlistConfigured = - Array.isArray(channelConfig?.users) && channelConfig.users.length > 0; - channelUserAllowed = channelUsersAllowlistConfigured - ? resolveSlackUserAllowed({ - allowList: channelConfig?.users, - userId: params.parsed.userId, - userName: senderName, - allowNameMatching: params.ctx.allowNameMatching, - }) - : false; + channelUsers = Array.isArray(channelConfig?.users) ? channelConfig.users : []; } - return resolveCommandAuthorizedFromAuthorizers({ - useAccessGroups: params.ctx.useAccessGroups, - authorizers: [ - { configured: allowFromLower.length > 0, allowed: ownerAllowed }, - { configured: channelUsersAllowlistConfigured, allowed: channelUserAllowed }, - ], + const commandIngress = await resolveSlackCommandIngress({ + ctx: params.ctx, + senderId: params.parsed.userId, + senderName, + channelType: params.auth.channelType ?? "channel", + channelId: params.parsed.channelId ?? "slack-interaction", + ownerAllowFromLower: allowFromLower, + channelUsers, + allowTextCommands: false, + hasControlCommand: true, + eventKind: "button", modeWhenAccessGroupsOff: "configured", }); + return commandIngress.commandAccess.authorized; } function enqueueSlackBlockActionEvent(params: { diff --git a/extensions/slack/src/monitor/events/interactions.test.ts b/extensions/slack/src/monitor/events/interactions.test.ts index 13688f3f095..949db9bfb7c 100644 --- a/extensions/slack/src/monitor/events/interactions.test.ts +++ b/extensions/slack/src/monitor/events/interactions.test.ts @@ -42,10 +42,6 @@ vi.mock("openclaw/plugin-sdk/approval-gateway-runtime", () => ({ resolveApprovalOverGateway: (arg: unknown) => resolveApprovalOverGatewayMock(arg), })); -vi.mock("openclaw/plugin-sdk/security-runtime", () => ({ - readStoreAllowFromForDmPolicy: async () => [], -})); - vi.mock("../../interactive-dispatch.js", () => ({ dispatchSlackPluginInteractiveHandler: (params: { data: string; diff --git a/extensions/slack/src/monitor/events/members.test.ts b/extensions/slack/src/monitor/events/members.test.ts index a9ffe7d4fcf..d623b7b1b11 100644 --- a/extensions/slack/src/monitor/events/members.test.ts +++ b/extensions/slack/src/monitor/events/members.test.ts @@ -13,10 +13,6 @@ vi.mock("openclaw/plugin-sdk/system-event-runtime", () => ({ vi.mock("openclaw/plugin-sdk/system-event-runtime.js", () => ({ enqueueSystemEvent: (...args: unknown[]) => memberMocks.enqueue(...args), })); -vi.mock("openclaw/plugin-sdk/security-runtime", () => ({ - readStoreAllowFromForDmPolicy: async () => [], -})); - type MemberHandler = (args: { event: Record; body: unknown }) => Promise; type MemberCaseArgs = { diff --git a/extensions/slack/src/monitor/events/messages.test.ts b/extensions/slack/src/monitor/events/messages.test.ts index 487383a7044..d6e0d5969cd 100644 --- a/extensions/slack/src/monitor/events/messages.test.ts +++ b/extensions/slack/src/monitor/events/messages.test.ts @@ -15,10 +15,6 @@ vi.mock("openclaw/plugin-sdk/system-event-runtime", () => ({ vi.mock("openclaw/plugin-sdk/system-event-runtime.js", () => ({ enqueueSystemEvent: (...args: unknown[]) => messageQueueMock(...args), })); -vi.mock("openclaw/plugin-sdk/security-runtime", () => ({ - readStoreAllowFromForDmPolicy: async () => [], -})); - vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ readChannelAllowFromStore: (...args: unknown[]) => messageAllowMock(...args), })); diff --git a/extensions/slack/src/monitor/events/pins.test.ts b/extensions/slack/src/monitor/events/pins.test.ts index 601f0f39f64..2fd44558fa2 100644 --- a/extensions/slack/src/monitor/events/pins.test.ts +++ b/extensions/slack/src/monitor/events/pins.test.ts @@ -11,10 +11,6 @@ vi.mock("openclaw/plugin-sdk/system-event-runtime", () => ({ vi.mock("openclaw/plugin-sdk/system-event-runtime.js", () => ({ enqueueSystemEvent: (...args: unknown[]) => pinEnqueueMock(...args), })); -vi.mock("openclaw/plugin-sdk/security-runtime", () => ({ - readStoreAllowFromForDmPolicy: async () => [], -})); - type PinHandler = (args: { event: Record; body: unknown }) => Promise; type PinCase = { diff --git a/extensions/slack/src/monitor/events/reactions.test.ts b/extensions/slack/src/monitor/events/reactions.test.ts index 19dec9ffecc..0ff87726e86 100644 --- a/extensions/slack/src/monitor/events/reactions.test.ts +++ b/extensions/slack/src/monitor/events/reactions.test.ts @@ -12,10 +12,6 @@ vi.mock("openclaw/plugin-sdk/system-event-runtime", () => ({ vi.mock("openclaw/plugin-sdk/system-event-runtime.js", () => ({ enqueueSystemEvent: (...args: unknown[]) => reactionQueueMock(...args), })); -vi.mock("openclaw/plugin-sdk/security-runtime", () => ({ - readStoreAllowFromForDmPolicy: async () => [], -})); - type ReactionHandler = (args: { event: Record; body: unknown }) => Promise; type ReactionRunInput = { diff --git a/extensions/slack/src/monitor/message-handler/prepare.ts b/extensions/slack/src/monitor/message-handler/prepare.ts index 0a58e1b7efc..22e447d4345 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.ts @@ -10,11 +10,9 @@ import { logInboundDrop, matchesMentionWithExplicit, resolveEnvelopeFormatOptions, - resolveInboundMentionDecision, } from "openclaw/plugin-sdk/channel-inbound"; import { resolveChannelMessageSourceReplyDeliveryMode } from "openclaw/plugin-sdk/channel-message"; import { hasControlCommand } from "openclaw/plugin-sdk/command-detection"; -import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-gating"; import { shouldHandleTextCommands } from "openclaw/plugin-sdk/command-surface"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-dispatch-runtime"; @@ -36,13 +34,12 @@ import { reactSlackMessage } from "../../actions.js"; import { formatSlackFileReference } from "../../file-reference.js"; import { hasSlackThreadParticipationWithPersistence } from "../../sent-thread-cache.js"; import type { SlackMessageEvent } from "../../types.js"; +import { normalizeAllowListLower, normalizeSlackAllowOwnerEntry } from "../allow-list.js"; import { - normalizeAllowListLower, - normalizeSlackAllowOwnerEntry, - resolveSlackAllowListMatch, - resolveSlackUserAllowed, -} from "../allow-list.js"; -import { authorizeSlackBotRoomMessage, resolveSlackEffectiveAllowFrom } from "../auth.js"; + authorizeSlackBotRoomMessage, + resolveSlackCommandIngress, + resolveSlackEffectiveAllowFrom, +} from "../auth.js"; import { resolveSlackChannelConfig } from "../channel-config.js"; import { stripSlackMentionsForCommandDetection } from "../commands.js"; import { @@ -214,7 +211,7 @@ async function authorizeSlackInboundMessage(params: { return null; } - const { allowFromLower } = await resolveSlackEffectiveAllowFrom(ctx, { + const allowFromLower = await resolveSlackEffectiveAllowFrom(ctx, { includePairingStore: isDirectMessage, }); @@ -431,15 +428,43 @@ export async function prepareSlackMessage(params: { }; const senderNameForAuth = ctx.allowNameMatching ? await resolveSenderName() : undefined; - const channelUserAuthorized = isRoom - ? resolveSlackUserAllowed({ - allowList: channelConfig?.users, - userId: senderId, - userName: senderNameForAuth, - allowNameMatching: ctx.allowNameMatching, - }) - : true; - if (isRoom && !channelUserAuthorized) { + const allowTextCommands = shouldHandleTextCommands({ + cfg, + surface: "slack", + }); + const shouldRequireMention = isRoom + ? (channelConfig?.requireMention ?? ctx.defaultRequireMention) + : false; + const canDetectMention = Boolean(ctx.botUserId) || mentionRegexes.length > 0; + // Strip Slack mentions (<@U123>) before command detection so "@Labrador /new" is recognized + const textForCommandDetection = stripSlackMentionsForCommandDetection(message.text ?? ""); + const hasControlCommandInMessage = hasControlCommand(textForCommandDetection, cfg); + const channelUsersAllowlistConfigured = + isRoom && Array.isArray(channelConfig?.users) && channelConfig.users.length > 0; + const messageIngress = await resolveSlackCommandIngress({ + ctx, + senderId, + senderName: senderNameForAuth, + channelType: conversation.resolvedChannelType ?? "channel", + channelId: message.channel, + ownerAllowFromLower: allowFromLower, + channelUsers: isRoom ? channelConfig?.users : undefined, + allowTextCommands, + hasControlCommand: hasControlCommandInMessage, + mentionFacts: { + canDetectMention, + wasMentioned, + hasAnyMention, + implicitMentionKinds, + }, + activation: { + requireMention: shouldRequireMention, + allowTextCommands, + ...(ctx.threadRequireExplicitMention ? { allowedImplicitMentionKinds: [] } : {}), + }, + }); + const senderGate = messageIngress.senderAccess.gate; + if (isRoom && senderGate?.allowed === false) { logVerbose(`Blocked unauthorized slack sender ${senderId} (not in channel users)`); return null; } @@ -459,22 +484,6 @@ export async function prepareSlackMessage(params: { return null; } - const allowTextCommands = shouldHandleTextCommands({ - cfg, - surface: "slack", - }); - // Strip Slack mentions (<@U123>) before command detection so "@Labrador /new" is recognized - const textForCommandDetection = stripSlackMentionsForCommandDetection(message.text ?? ""); - const hasControlCommandInMessage = hasControlCommand(textForCommandDetection, cfg); - - const ownerAuthorized = resolveSlackAllowListMatch({ - allowList: allowFromLower, - id: senderId, - name: senderNameForAuth, - allowNameMatching: ctx.allowNameMatching, - }).allowed; - const channelUsersAllowlistConfigured = - isRoom && Array.isArray(channelConfig?.users) && channelConfig.users.length > 0; const threadContextAllowFromLower = isRoom ? channelUsersAllowlistConfigured ? normalizeAllowListLower(channelConfig?.users) @@ -487,30 +496,9 @@ export async function prepareSlackMessage(params: { channel: "slack", accountId: account.accountId, }); - const channelCommandAuthorized = - isRoom && channelUsersAllowlistConfigured - ? resolveSlackUserAllowed({ - allowList: channelConfig?.users, - userId: senderId, - userName: senderNameForAuth, - allowNameMatching: ctx.allowNameMatching, - }) - : false; - const commandGate = resolveControlCommandGate({ - useAccessGroups: ctx.useAccessGroups, - authorizers: [ - { configured: allowFromLower.length > 0, allowed: ownerAuthorized }, - { - configured: channelUsersAllowlistConfigured, - allowed: channelCommandAuthorized, - }, - ], - allowTextCommands, - hasControlCommand: hasControlCommandInMessage, - }); - const commandAuthorized = commandGate.commandAuthorized; + const commandAuthorized = messageIngress.commandAccess.authorized; - if (isRoomish && commandGate.shouldBlock) { + if (isRoomish && messageIngress.commandAccess.shouldBlockControlCommand) { logInboundDrop({ log: logVerbose, channel: "slack", @@ -520,30 +508,9 @@ export async function prepareSlackMessage(params: { return null; } - const shouldRequireMention = isRoom - ? (channelConfig?.requireMention ?? ctx.defaultRequireMention) - : false; - - // Allow "control commands" to bypass mention gating if sender is authorized. - const canDetectMention = Boolean(ctx.botUserId) || mentionRegexes.length > 0; - const mentionDecision = resolveInboundMentionDecision({ - facts: { - canDetectMention, - wasMentioned, - hasAnyMention, - implicitMentionKinds, - }, - policy: { - isGroup: isRoom, - requireMention: shouldRequireMention, - allowedImplicitMentionKinds: ctx.threadRequireExplicitMention ? [] : undefined, - allowTextCommands, - hasControlCommand: hasControlCommandInMessage, - commandAuthorized, - }, - }); - const effectiveWasMentioned = mentionDecision.effectiveWasMentioned; - if (isRoom && shouldRequireMention && mentionDecision.shouldSkip) { + const effectiveWasMentioned = messageIngress.activationAccess.effectiveWasMentioned ?? false; + const shouldBypassMention = messageIngress.activationAccess.shouldBypassMention ?? false; + if (isRoom && shouldRequireMention && messageIngress.activationAccess.shouldSkip) { ctx.logger.info({ channel: message.channel, reason: "no-mention" }, "skipping channel message"); const pendingText = (message.text ?? "").trim(); const fallbackFile = message.files?.length @@ -609,14 +576,13 @@ export async function prepareSlackMessage(params: { requireMention: shouldRequireMention, canDetectMention, effectiveWasMentioned, - shouldBypassMention: mentionDecision.shouldBypassMention, + shouldBypassMention, }), ); const ackReactionMessageTs = message.ts; const allowToolOnlyStatusReaction = - statusReactionsExplicitlyEnabled && - (effectiveWasMentioned || mentionDecision.shouldBypassMention); + statusReactionsExplicitlyEnabled && (effectiveWasMentioned || shouldBypassMention); const shouldSendAckReaction = shouldAckReaction() && (!sourceRepliesAreToolOnly || allowToolOnlyStatusReaction); const statusReactionsWillHandle = diff --git a/extensions/slack/src/monitor/policy.ts b/extensions/slack/src/monitor/policy.ts index 9f58c758c51..7db20143682 100644 --- a/extensions/slack/src/monitor/policy.ts +++ b/extensions/slack/src/monitor/policy.ts @@ -1,13 +1,13 @@ -import { evaluateGroupRouteAccessForPolicy } from "openclaw/plugin-sdk/group-access"; - export function isSlackChannelAllowedByPolicy(params: { groupPolicy: "open" | "disabled" | "allowlist"; channelAllowlistConfigured: boolean; channelAllowed: boolean; }): boolean { - return evaluateGroupRouteAccessForPolicy({ - groupPolicy: params.groupPolicy, - routeAllowlistConfigured: params.channelAllowlistConfigured, - routeMatched: params.channelAllowed, - }).allowed; + if (params.groupPolicy === "disabled") { + return false; + } + return ( + params.groupPolicy !== "allowlist" || + (params.channelAllowlistConfigured && params.channelAllowed) + ); } diff --git a/extensions/slack/src/monitor/slash-commands.runtime.ts b/extensions/slack/src/monitor/slash-commands.runtime.ts index 6659ae61031..35dc26e88e4 100644 --- a/extensions/slack/src/monitor/slash-commands.runtime.ts +++ b/extensions/slack/src/monitor/slash-commands.runtime.ts @@ -4,17 +4,17 @@ import { listNativeCommandSpecsForConfig as listNativeCommandSpecsForConfigImpl, parseCommandArgs as parseCommandArgsImpl, resolveCommandArgMenu as resolveCommandArgMenuImpl, -} from "openclaw/plugin-sdk/command-auth"; +} from "openclaw/plugin-sdk/command-auth-native"; type BuildCommandTextFromArgs = - typeof import("openclaw/plugin-sdk/command-auth").buildCommandTextFromArgs; + typeof import("openclaw/plugin-sdk/command-auth-native").buildCommandTextFromArgs; type FindCommandByNativeName = - typeof import("openclaw/plugin-sdk/command-auth").findCommandByNativeName; + typeof import("openclaw/plugin-sdk/command-auth-native").findCommandByNativeName; type ListNativeCommandSpecsForConfig = - typeof import("openclaw/plugin-sdk/command-auth").listNativeCommandSpecsForConfig; -type ParseCommandArgs = typeof import("openclaw/plugin-sdk/command-auth").parseCommandArgs; + typeof import("openclaw/plugin-sdk/command-auth-native").listNativeCommandSpecsForConfig; +type ParseCommandArgs = typeof import("openclaw/plugin-sdk/command-auth-native").parseCommandArgs; type ResolveCommandArgMenu = - typeof import("openclaw/plugin-sdk/command-auth").resolveCommandArgMenu; + typeof import("openclaw/plugin-sdk/command-auth-native").resolveCommandArgMenu; export function buildCommandTextFromArgs( ...args: Parameters diff --git a/extensions/slack/src/monitor/slash-plugin-commands.runtime.ts b/extensions/slack/src/monitor/slash-plugin-commands.runtime.ts index 6c89e635aac..a80bfdd1180 100644 --- a/extensions/slack/src/monitor/slash-plugin-commands.runtime.ts +++ b/extensions/slack/src/monitor/slash-plugin-commands.runtime.ts @@ -1 +1 @@ -export { listProviderPluginCommandSpecs } from "openclaw/plugin-sdk/command-auth"; +export { listProviderPluginCommandSpecs } from "openclaw/plugin-sdk/command-auth-native"; diff --git a/extensions/slack/src/monitor/slash-skill-commands.runtime.ts b/extensions/slack/src/monitor/slash-skill-commands.runtime.ts index 926eb5a3932..e59eb00f80c 100644 --- a/extensions/slack/src/monitor/slash-skill-commands.runtime.ts +++ b/extensions/slack/src/monitor/slash-skill-commands.runtime.ts @@ -1,7 +1,7 @@ -import { listSkillCommandsForAgents as listSkillCommandsForAgentsImpl } from "openclaw/plugin-sdk/command-auth"; +import { listSkillCommandsForAgents as listSkillCommandsForAgentsImpl } from "openclaw/plugin-sdk/command-auth-native"; type ListSkillCommandsForAgents = - typeof import("openclaw/plugin-sdk/command-auth").listSkillCommandsForAgents; + typeof import("openclaw/plugin-sdk/command-auth-native").listSkillCommandsForAgents; export function listSkillCommandsForAgents( ...args: Parameters diff --git a/extensions/slack/src/monitor/slash.ts b/extensions/slack/src/monitor/slash.ts index 4b757883491..23aa1551de6 100644 --- a/extensions/slack/src/monitor/slash.ts +++ b/extensions/slack/src/monitor/slash.ts @@ -5,10 +5,9 @@ import { formatCommandArgMenuTitle, resolveStoredModelOverride, type ChatCommandDefinition, -} from "openclaw/plugin-sdk/command-auth"; +} from "openclaw/plugin-sdk/command-auth-native"; import { type CommandArgs, - resolveCommandAuthorizedFromAuthorizers, resolveNativeCommandSessionTargets, } from "openclaw/plugin-sdk/command-auth-native"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; @@ -28,8 +27,7 @@ import { import type { ResolvedSlackAccount } from "../accounts.js"; import { SLACK_MAX_BLOCKS } from "../blocks-input.js"; import { truncateSlackText } from "../truncate.js"; -import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "./allow-list.js"; -import { resolveSlackEffectiveAllowFrom } from "./auth.js"; +import { resolveSlackCommandIngress, resolveSlackEffectiveAllowFrom } from "./auth.js"; import { resolveSlackChannelConfig, type SlackChannelConfigResolved } from "./channel-config.js"; import { buildSlackSlashCommandMatcher, resolveSlackSlashCommandConfig } from "./commands.js"; import type { SlackMonitorContext } from "./context.js"; @@ -436,12 +434,9 @@ export async function registerSlackMonitorSlashCommands(params: { return; } - const { allowFromLower: effectiveAllowFromLower } = await resolveSlackEffectiveAllowFrom( - ctx, - { - includePairingStore: isDirectMessage, - }, - ); + const effectiveAllowFromLower = await resolveSlackEffectiveAllowFrom(ctx, { + includePairingStore: isDirectMessage, + }); // Privileged command surface: compute CommandAuthorized, don't assume true. // Keep this aligned with the Slack message path (message-handler/prepare.ts). @@ -523,17 +518,21 @@ export async function registerSlackMonitorSlashCommands(params: { const sender = await ctx.resolveUserName(command.user_id); const senderName = sender?.name ?? command.user_name ?? command.user_id; - const channelUsersAllowlistConfigured = - isRoom && Array.isArray(channelConfig?.users) && channelConfig.users.length > 0; - const channelUserAllowed = channelUsersAllowlistConfigured - ? resolveSlackUserAllowed({ - allowList: channelConfig?.users, - userId: command.user_id, - userName: senderName, - allowNameMatching: ctx.allowNameMatching, - }) - : false; - if (channelUsersAllowlistConfigured && !channelUserAllowed) { + const slashIngress = await resolveSlackCommandIngress({ + ctx, + senderId: command.user_id, + senderName, + channelType: channelType ?? "channel", + channelId: command.channel_id, + ownerAllowFromLower: effectiveAllowFromLower, + channelUsers: isRoom ? channelConfig?.users : undefined, + allowTextCommands: false, + hasControlCommand: false, + eventKind: "slash-command", + modeWhenAccessGroupsOff: "configured", + }); + const senderGate = slashIngress.senderAccess.gate; + if (isRoom && senderGate?.allowed === false) { await respond({ text: "You are not authorized to use this command here.", response_type: "ephemeral", @@ -541,28 +540,10 @@ export async function registerSlackMonitorSlashCommands(params: { return; } - const ownerAllowed = resolveSlackAllowListMatch({ - allowList: effectiveAllowFromLower, - id: command.user_id, - name: senderName, - allowNameMatching: ctx.allowNameMatching, - }).allowed; // DMs: allow chatting in dmPolicy=open, but keep privileged command gating intact by setting // CommandAuthorized based on allowlists/access-groups (downstream decides which commands need it). - commandAuthorized = resolveCommandAuthorizedFromAuthorizers({ - useAccessGroups: ctx.useAccessGroups, - authorizers: [{ configured: effectiveAllowFromLower.length > 0, allowed: ownerAllowed }], - modeWhenAccessGroupsOff: "configured", - }); + commandAuthorized = slashIngress.commandAccess.authorized; if (isRoomish) { - commandAuthorized = resolveCommandAuthorizedFromAuthorizers({ - useAccessGroups: ctx.useAccessGroups, - authorizers: [ - { configured: effectiveAllowFromLower.length > 0, allowed: ownerAllowed }, - { configured: channelUsersAllowlistConfigured, allowed: channelUserAllowed }, - ], - modeWhenAccessGroupsOff: "configured", - }); if (ctx.useAccessGroups && !commandAuthorized) { await respond({ text: "You are not authorized to use this command.", diff --git a/extensions/synology-chat/src/core.test.ts b/extensions/synology-chat/src/core.test.ts index 9987b9f23b3..c509232b099 100644 --- a/extensions/synology-chat/src/core.test.ts +++ b/extensions/synology-chat/src/core.test.ts @@ -9,8 +9,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import { listAccountIds, resolveAccount } from "./accounts.js"; import { SynologyChatChannelConfigSchema } from "./config-schema.js"; import { - authorizeUserForDm, - checkUserAllowed, + authorizeUserForDmWithIngress, RateLimiter, sanitizeInput, validateToken, @@ -317,32 +316,90 @@ describe("synology-chat security helpers", () => { expect(validateToken("short", "muchlongertoken")).toBe(false); }); - it("enforces allowlists and DM policy decisions", () => { - expect(checkUserAllowed("user1", [])).toBe(false); - expect(checkUserAllowed("user1", ["user1", "user2"])).toBe(true); - expect(checkUserAllowed("user3", ["user1", "user2"])).toBe(false); + it("matches DM policy decisions through channel ingress", async () => { + await expect( + authorizeUserForDmWithIngress({ + accountId: "default", + userId: "user1", + dmPolicy: "open", + allowedUserIds: [], + }), + ).resolves.toMatchObject({ + senderAccess: { + allowed: false, + reasonCode: "dm_policy_not_allowlisted", + }, + }); + await expect( + authorizeUserForDmWithIngress({ + accountId: "default", + userId: "user1", + dmPolicy: "open", + allowedUserIds: ["*"], + }), + ).resolves.toMatchObject({ senderAccess: { allowed: true } }); + await expect( + authorizeUserForDmWithIngress({ + accountId: "default", + userId: "user1", + dmPolicy: "disabled", + allowedUserIds: ["user1"], + }), + ).resolves.toMatchObject({ + senderAccess: { + allowed: false, + reasonCode: "dm_policy_disabled", + }, + }); + await expect( + authorizeUserForDmWithIngress({ + accountId: "default", + userId: "user1", + dmPolicy: "allowlist", + allowedUserIds: [], + }), + ).resolves.toMatchObject({ + senderAccess: { + allowed: false, + reasonCode: "dm_policy_not_allowlisted", + }, + }); + await expect( + authorizeUserForDmWithIngress({ + accountId: "default", + userId: "user9", + dmPolicy: "allowlist", + allowedUserIds: ["user1"], + }), + ).resolves.toMatchObject({ + senderAccess: { + allowed: false, + reasonCode: "dm_policy_not_allowlisted", + }, + }); + await expect( + authorizeUserForDmWithIngress({ + accountId: "default", + userId: "user1", + dmPolicy: "allowlist", + allowedUserIds: ["user1", "user2"], + }), + ).resolves.toMatchObject({ senderAccess: { allowed: true } }); + }); - expect(authorizeUserForDm("user1", "open", [])).toEqual({ - allowed: false, - reason: "not-allowlisted", + it("redacts Synology user IDs and allowlist entries from ingress state/decision", async () => { + const auth = await authorizeUserForDmWithIngress({ + accountId: "default", + userId: "raw-sensitive-user-id", + dmPolicy: "allowlist", + allowedUserIds: ["raw-sensitive-user-id"], }); - expect(authorizeUserForDm("user1", "open", ["*"])).toEqual({ allowed: true }); - expect(authorizeUserForDm("user1", "open", ["user1"])).toEqual({ allowed: true }); - expect(authorizeUserForDm("user1", "disabled", ["user1"])).toEqual({ - allowed: false, - reason: "disabled", - }); - expect(authorizeUserForDm("user1", "allowlist", [])).toEqual({ - allowed: false, - reason: "allowlist-empty", - }); - expect(authorizeUserForDm("user9", "allowlist", ["user1"])).toEqual({ - allowed: false, - reason: "not-allowlisted", - }); - expect(authorizeUserForDm("user1", "allowlist", ["user1", "user2"])).toEqual({ - allowed: true, + + const serialized = JSON.stringify({ + state: auth.state, + decision: auth.ingress, }); + expect(serialized).not.toContain("raw-sensitive-user-id"); }); it("sanitizes prompt injection markers and long inputs", () => { diff --git a/extensions/synology-chat/src/security.ts b/extensions/synology-chat/src/security.ts index 62fb02b908a..2f8a440e7bf 100644 --- a/extensions/synology-chat/src/security.ts +++ b/extensions/synology-chat/src/security.ts @@ -2,16 +2,13 @@ * Security module: token validation, rate limiting, input sanitization, user allowlist. */ +import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime"; import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime"; import { createFixedWindowRateLimiter, type FixedWindowRateLimiter, } from "openclaw/plugin-sdk/webhook-ingress"; -type DmAuthorizationResult = - | { allowed: true } - | { allowed: false; reason: "disabled" | "allowlist-empty" | "not-allowlisted" }; - /** * Validate webhook token using constant-time comparison. * Reject empty tokens explicitly; use shared constant-time comparison otherwise. @@ -23,44 +20,28 @@ export function validateToken(received: string, expected: string): boolean { return safeEqualSecret(received, expected); } -/** - * Check if a user ID is in the allowed list. - * Allowlist mode must be explicit; empty lists should not match any user. - */ -export function checkUserAllowed(userId: string, allowedUserIds: string[]): boolean { - if (allowedUserIds.length === 0) { - return false; - } - if (allowedUserIds.includes("*")) { - return true; - } - return allowedUserIds.includes(userId); -} - -/** - * Resolve DM authorization for a sender across all DM policy modes. - * Keeps policy semantics in one place so webhook/startup behavior stays consistent. - */ -export function authorizeUserForDm( - userId: string, - dmPolicy: "open" | "allowlist" | "disabled", - allowedUserIds: string[], -): DmAuthorizationResult { - if (dmPolicy === "disabled") { - return { allowed: false, reason: "disabled" }; - } - if (dmPolicy === "open") { - return checkUserAllowed(userId, allowedUserIds) - ? { allowed: true } - : { allowed: false, reason: "not-allowlisted" }; - } - if (allowedUserIds.length === 0) { - return { allowed: false, reason: "allowlist-empty" }; - } - if (!checkUserAllowed(userId, allowedUserIds)) { - return { allowed: false, reason: "not-allowlisted" }; - } - return { allowed: true }; +export async function authorizeUserForDmWithIngress(params: { + accountId: string; + userId: string; + dmPolicy: "open" | "allowlist" | "disabled"; + allowedUserIds: string[]; +}) { + return await resolveStableChannelMessageIngress({ + channelId: "synology-chat", + accountId: params.accountId, + identity: { + key: "sender-id", + entryIdPrefix: "synology-chat-entry", + }, + subject: { stableId: params.userId }, + conversation: { + kind: "direct", + id: "direct", + }, + event: { mayPair: false }, + dmPolicy: params.dmPolicy, + allowFrom: params.allowedUserIds, + }); } /** diff --git a/extensions/synology-chat/src/webhook-handler.ts b/extensions/synology-chat/src/webhook-handler.ts index 38132cd6812..6a36ecef7ba 100644 --- a/extensions/synology-chat/src/webhook-handler.ts +++ b/extensions/synology-chat/src/webhook-handler.ts @@ -14,7 +14,12 @@ import { requestBodyErrorToText, } from "openclaw/plugin-sdk/webhook-ingress"; import * as synologyClient from "./client.js"; -import { validateToken, authorizeUserForDm, sanitizeInput, RateLimiter } from "./security.js"; +import { + validateToken, + authorizeUserForDmWithIngress, + sanitizeInput, + RateLimiter, +} from "./security.js"; import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./types.js"; // One rate limiter per account, created lazily @@ -395,14 +400,14 @@ async function parseWebhookPayloadRequest(params: { return { ok: true, payload }; } -function authorizeSynologyWebhook(params: { +async function authorizeSynologyWebhook(params: { req: IncomingMessage; account: ResolvedSynologyChatAccount; payload: SynologyWebhookPayload; invalidTokenRateLimiter: InvalidTokenRateLimiter; rateLimiter: RateLimiter; log?: WebhookHandlerDeps["log"]; -}): SynologyWebhookAuthorization { +}): Promise { const invalidTokenRateLimitKey = getSynologyWebhookInvalidTokenRateLimitKey(params.req); // Once a source has exhausted its invalid-token budget, reject all requests in the window. if (params.invalidTokenRateLimiter.isLocked(invalidTokenRateLimitKey)) { @@ -419,16 +424,17 @@ function authorizeSynologyWebhook(params: { return { ok: false, statusCode: 401, error: "Invalid token" }; } - const auth = authorizeUserForDm( - params.payload.user_id, - params.account.dmPolicy, - params.account.allowedUserIds, - ); - if (!auth.allowed) { - if (auth.reason === "disabled") { + const auth = await authorizeUserForDmWithIngress({ + accountId: params.account.accountId, + userId: params.payload.user_id, + dmPolicy: params.account.dmPolicy, + allowedUserIds: params.account.allowedUserIds, + }); + if (!auth.senderAccess.allowed) { + if (auth.senderAccess.reasonCode === "dm_policy_disabled") { return { ok: false, statusCode: 403, error: "DMs are disabled" }; } - if (auth.reason === "allowlist-empty") { + if (params.account.dmPolicy === "allowlist" && params.account.allowedUserIds.length === 0) { params.log?.warn( "Synology Chat allowlist is empty while dmPolicy=allowlist; rejecting message", ); @@ -449,7 +455,7 @@ function authorizeSynologyWebhook(params: { return { ok: false, statusCode: 429, error: "Rate limit exceeded" }; } - return { ok: true, commandAuthorized: auth.allowed }; + return { ok: true, commandAuthorized: auth.senderAccess.allowed }; } function sanitizeSynologyWebhookText(payload: SynologyWebhookPayload): string { @@ -474,7 +480,7 @@ async function parseAndAuthorizeSynologyWebhook(params: { return { ok: false }; } - const authorized = authorizeSynologyWebhook({ + const authorized = await authorizeSynologyWebhook({ req: params.req, account: params.account, payload: parsed.payload, diff --git a/extensions/telegram/src/bot-access.ts b/extensions/telegram/src/bot-access.ts index 99ca43502cd..2664a1a304a 100644 --- a/extensions/telegram/src/bot-access.ts +++ b/extensions/telegram/src/bot-access.ts @@ -2,7 +2,6 @@ import { firstDefined, isSenderIdAllowed, mergeDmAllowFromSources, - type AllowlistMatch, } from "openclaw/plugin-sdk/allow-from"; import type { DmPolicy, @@ -19,8 +18,6 @@ export type NormalizedAllowFrom = { invalidEntries: string[]; }; -type AllowFromMatch = AllowlistMatch<"wildcard" | "id">; - const warnedInvalidEntries = new Set(); const log = createSubsystemLogger("telegram/bot-access"); @@ -93,21 +90,3 @@ export const isSenderAllowed = (params: { }; export { firstDefined }; - -export const resolveSenderAllowMatch = (params: { - allow: NormalizedAllowFrom; - senderId?: string; - senderUsername?: string; -}): AllowFromMatch => { - const { allow, senderId } = params; - if (allow.hasWildcard) { - return { allowed: true, matchKey: "*", matchSource: "wildcard" }; - } - if (!allow.hasEntries) { - return { allowed: false }; - } - if (senderId && allow.entries.includes(senderId)) { - return { allowed: true, matchKey: senderId, matchSource: "id" }; - } - return { allowed: false }; -}; diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index fb94fe4f81e..47719ada804 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -6,8 +6,7 @@ import { createInboundDebouncer, resolveInboundDebounceMs, } from "openclaw/plugin-sdk/channel-inbound-debounce"; -import { resolveStoredModelOverride } from "openclaw/plugin-sdk/command-auth"; -import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/command-auth-native"; +import { resolveStoredModelOverride } from "openclaw/plugin-sdk/command-auth-native"; import { buildCommandsMessagePaginated } from "openclaw/plugin-sdk/command-status"; import { replaceConfigFile } from "openclaw/plugin-sdk/config-mutation"; import type { DmPolicy, OpenClawConfig } from "openclaw/plugin-sdk/config-types"; @@ -31,14 +30,10 @@ import { resolveSessionStoreEntry, updateSessionStore, } from "openclaw/plugin-sdk/session-store-runtime"; -import { - expandTelegramAllowFromWithAccessGroups, - resolveTelegramDmAllow, -} from "./access-groups.js"; +import { expandTelegramAllowFromWithAccessGroups } from "./access-groups.js"; import { resolveTelegramAccount, resolveTelegramMediaRuntimeOptions } from "./accounts.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { - isSenderAllowed, normalizeDmAllowFromWithStore, resolveTelegramEffectiveDmPolicy, type NormalizedAllowFrom, @@ -104,6 +99,10 @@ import { evaluateTelegramGroupPolicyAccess, } from "./group-access.js"; import { migrateTelegramGroupConfig } from "./group-migration.js"; +import { + resolveTelegramCommandIngressAuthorization, + resolveTelegramEventIngressAuthorization, +} from "./ingress.js"; import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js"; import { dispatchTelegramPluginInteractiveHandler } from "./interactive-dispatch.js"; import { @@ -815,19 +814,6 @@ export const registerTelegramHandlers = ({ ); }; - const isAllowlistAuthorized = ( - allow: NormalizedAllowFrom, - senderId: string, - senderUsername: string, - ) => - allow.hasWildcard || - (allow.hasEntries && - isSenderAllowed({ - allow, - senderId, - senderUsername, - })); - const shouldSkipGroupMessage = (params: { isGroup: boolean; chatId: string | number; @@ -927,7 +913,6 @@ export const registerTelegramHandlers = ({ type TelegramGroupAllowContext = Awaited>; type TelegramEventAuthorizationMode = "reaction" | "callback-scope" | "callback-allowlist"; - type TelegramEventAuthorizationResult = { allowed: true } | { allowed: false; reason: string }; type TelegramEventAuthorizationContext = TelegramGroupAllowContext & { dmPolicy: DmPolicy }; const getChat = typeof (bot.api as { getChat?: unknown }).getChat === "function" @@ -1016,7 +1001,7 @@ export const registerTelegramHandlers = ({ senderUsername: string; mode: TelegramEventAuthorizationMode; context: TelegramEventAuthorizationContext; - }): Promise => { + }): Promise => { const { chatId, chatTitle, isGroup, senderId, senderUsername, mode, context } = params; const { dmPolicy, @@ -1049,16 +1034,10 @@ export const registerTelegramHandlers = ({ topicConfig, }) ) { - return { allowed: false, reason: "group-policy" }; + return false; } if (!isGroup && enforceDirectAuthorization) { - if (dmPolicy === "disabled") { - logVerbose( - `Blocked telegram direct event from ${senderId || "unknown"} (${deniedDmReason})`, - ); - return { allowed: false, reason: "direct-disabled" }; - } // For DMs, prefer per-DM/topic allowFrom (groupAllowOverride) over account-level allowFrom. const dmAllowFrom = groupAllowOverride ?? allowFrom; const expandedDmAllowFrom = await expandTelegramAllowFromWithAccessGroups({ @@ -1072,22 +1051,48 @@ export const registerTelegramHandlers = ({ storeAllowFrom, dmPolicy, }); - const hasPublicDmAccess = dmPolicy === "open" && effectiveDmAllow.hasWildcard; - if ( - !hasPublicDmAccess && - !isAllowlistAuthorized(effectiveDmAllow, senderId, senderUsername) - ) { + const eventAccess = await resolveTelegramEventIngressAuthorization({ + accountId, + dmPolicy, + isGroup, + chatId, + resolvedThreadId, + senderId, + effectiveDmAllow, + effectiveGroupAllow, + enforceGroupAuthorization: false, + eventKind: mode === "reaction" ? "reaction" : "button", + }); + if (eventAccess.decision !== "allow") { + if (eventAccess.reasonCode === "dm_policy_disabled") { + logVerbose( + `Blocked telegram direct event from ${senderId || "unknown"} (${deniedDmReason})`, + ); + return false; + } logVerbose(`Blocked telegram direct sender ${senderId || "unknown"} (${deniedDmReason})`); - return { allowed: false, reason: "direct-unauthorized" }; + return false; } } if (isGroup && enforceGroupAllowlistAuthorization) { - if (!isAllowlistAuthorized(effectiveGroupAllow, senderId, senderUsername)) { + const eventAccess = await resolveTelegramEventIngressAuthorization({ + accountId, + dmPolicy, + isGroup, + chatId, + resolvedThreadId, + senderId, + effectiveDmAllow: normalizeDmAllowFromWithStore({ allowFrom: [], dmPolicy }), + effectiveGroupAllow, + enforceGroupAuthorization: true, + eventKind: mode === "reaction" ? "reaction" : "button", + }); + if (eventAccess.decision !== "allow") { logVerbose(`Blocked telegram group sender ${senderId || "unknown"} (${deniedGroupReason})`); - return { allowed: false, reason: "group-unauthorized" }; + return false; } } - return { allowed: true }; + return true; }; const isTelegramModelCallbackAuthorized = async (params: { @@ -1099,7 +1104,6 @@ export const registerTelegramHandlers = ({ cfg: OpenClawConfig; }): Promise => { const { chatId, isGroup, senderId, senderUsername, context, cfg } = params; - const useAccessGroups = cfg.commands?.useAccessGroups !== false; const dmAllowFrom = context.groupAllowOverride ?? allowFrom; if (isTelegramCommandsAllowFromConfigured(cfg)) { return resolveTelegramCommandAuthorization({ @@ -1124,36 +1128,24 @@ export const registerTelegramHandlers = ({ storeAllowFrom: isGroup ? [] : context.storeAllowFrom, dmPolicy: context.dmPolicy, }); - const senderAllowed = isSenderAllowed({ - allow: dmAllow, - senderId, - senderUsername, - }); - const groupSenderAllowed = isGroup - ? isSenderAllowed({ - allow: context.effectiveGroupAllow, - senderId, - senderUsername, - }) - : false; - - return resolveCommandAuthorizedFromAuthorizers({ - useAccessGroups, - authorizers: [ - { configured: dmAllow.hasEntries, allowed: senderAllowed }, - ...(isGroup - ? [ - { - configured: context.effectiveGroupAllow.hasEntries, - allowed: groupSenderAllowed, - }, - ] - : []), - ], - modeWhenAccessGroupsOff: "configured", - }); + return ( + await resolveTelegramCommandIngressAuthorization({ + accountId, + cfg, + dmPolicy: context.dmPolicy, + isGroup, + chatId, + resolvedThreadId: context.resolvedThreadId, + senderId, + effectiveDmAllow: dmAllow, + effectiveGroupAllow: context.effectiveGroupAllow, + ownerAccess: { ownerList: [], senderIsOwner: false }, + eventKind: "button", + }) + ).authorized; }; + // Handle emoji reactions to messages. bot.on("message_reaction", async (ctx) => { try { const reaction = ctx.messageReaction; @@ -1172,6 +1164,7 @@ export const registerTelegramHandlers = ({ const isGroup = reaction.chat.type === "group" || reaction.chat.type === "supergroup"; const isForum = reaction.chat.is_forum === true; + // Resolve reaction notification mode (default: "own"). const reactionMode = telegramCfg.reactionNotifications ?? "own"; if (reactionMode === "off") { return; @@ -1200,7 +1193,7 @@ export const registerTelegramHandlers = ({ mode: "reaction", context: eventAuthContext, }); - if (!senderAuthorization.allowed) { + if (!senderAuthorization) { return; } @@ -1219,6 +1212,7 @@ export const registerTelegramHandlers = ({ } } + // Detect added reactions. const oldEmojis = new Set( reaction.old_reaction .filter((r): r is ReactionTypeEmoji => r.type === "emoji") @@ -1232,6 +1226,7 @@ export const registerTelegramHandlers = ({ return; } + // Build sender label. const senderName = user ? [user.first_name, user.last_name].filter(Boolean).join(" ").trim() || user.username : undefined; @@ -1255,6 +1250,7 @@ export const registerTelegramHandlers = ({ : undefined; const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId); const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId }); + // Fresh config for bindings lookup; other routing inputs are payload-derived. const route = resolveAgentRoute({ cfg: telegramDeps.getRuntimeConfig(), channel: "telegram", @@ -1264,6 +1260,7 @@ export const registerTelegramHandlers = ({ }); const sessionKey = route.sessionKey; + // Enqueue system event for each added reaction. for (const r of addedReactions) { const emoji = r.emoji; const text = `Telegram reaction added: ${emoji} by ${senderLabel} on msg ${messageId}`; @@ -1299,11 +1296,14 @@ export const registerTelegramHandlers = ({ oversizeLogMessage, } = params; + // Text fragment handling - Telegram splits long pastes into multiple inbound messages (~4096 chars). + // We buffer “near-limit” messages and append immediately-following parts. const text = typeof msg.text === "string" ? msg.text : undefined; const isCommandLike = (text ?? "").trim().startsWith("/"); if (text && !isCommandLike) { const nowMs = Date.now(); const senderId = msg.from?.id != null ? String(msg.from.id) : "unknown"; + // Use resolvedThreadId for forum groups, dmThreadId for DM topics const threadId = resolvedThreadId ?? dmThreadId; const key = `text:${chatId}:${threadId ?? "main"}:${senderId}`; const existing = textFragmentBuffer.get(key); @@ -1336,6 +1336,7 @@ export const registerTelegramHandlers = ({ } } + // Not appendable (or limits exceeded): flush buffered entry first, then continue normally. clearTimeout(existing.timer); textFragmentBuffer.delete(key); textFragmentProcessing = textFragmentProcessing @@ -1359,6 +1360,7 @@ export const registerTelegramHandlers = ({ } } + // Media group handling - buffer multi-image messages const mediaGroupId = msg.media_group_id; if (mediaGroupId) { const existing = mediaGroupBuffer.get(mediaGroupId); @@ -1433,6 +1435,8 @@ export const registerTelegramHandlers = ({ return; } + // Skip sticker-only messages where the sticker was skipped (animated/video) + // These have no media and no text content to process. const hasText = Boolean(getTelegramTextParts(msg).text.trim()); if (msg.sticker && !media && !hasText) { logVerbose("telegram: skipping sticker-only message (unsupported sticker type)"); @@ -1485,6 +1489,7 @@ export const registerTelegramHandlers = ({ typeof (ctx as { answerCallbackQuery?: unknown }).answerCallbackQuery === "function" ? () => ctx.answerCallbackQuery() : () => bot.api.answerCallbackQuery(callback.id); + // Answer immediately to prevent Telegram from retrying while we process await withTelegramApiErrorLogging({ operation: "answerCallbackQuery", runtime, @@ -1637,7 +1642,7 @@ export const registerTelegramHandlers = ({ mode: authorizationMode, context: eventAuthContext, }); - if (!senderAuthorization.allowed) { + if (!senderAuthorization) { return; } @@ -1881,6 +1886,7 @@ export const registerTelegramHandlers = ({ return; } + // Model selection callback handler (mdl_prov, mdl_list_*, mdl_sel_*, mdl_back) const modelCallback = parseModelCallbackData(data); if (modelCallback) { if ( @@ -1967,6 +1973,7 @@ export const registerTelegramHandlers = ({ const { provider, page } = modelCallback; const modelSet = byProvider.get(provider); if (!modelSet || modelSet.size === 0) { + // Provider not found or no models - show providers list const providerInfos: ProviderInfo[] = providers.map((p) => ({ id: p, count: byProvider.get(p)?.size ?? 0, @@ -1987,6 +1994,7 @@ export const registerTelegramHandlers = ({ const totalPages = calculateTotalPages(models.length, pageSize); const safePage = Math.max(1, Math.min(page, totalPages)); + // Resolve current model from session (prefer overrides) const currentModel = sessionState.model; const buttons = buildModelsKeyboard({ @@ -2049,6 +2057,7 @@ export const registerTelegramHandlers = ({ return; } + // Directly set model override in session try { // Use the fresh runtimeCfg (loaded at callback entry) so store path // and default-model resolution stay consistent with the next @@ -2086,6 +2095,7 @@ export const registerTelegramHandlers = ({ throw new TelegramRetryableCallbackError(err); } + // Update message to show success with visual feedback const escapeHtml = (text: string) => text.replace(/&/g, "&").replace(//g, ">"); const actionText = isDefaultSelection @@ -2136,6 +2146,7 @@ export const registerTelegramHandlers = ({ } }); + // Handle group migration to supergroup (chat ID changes) bot.on("message:migrate_to_chat_id", async (ctx) => { try { const msg = ctx.message; @@ -2157,6 +2168,7 @@ export const registerTelegramHandlers = ({ return; } + // Check if old chat ID has config and migrate it const currentConfig = telegramDeps.getRuntimeConfig(); const migration = migrateTelegramGroupConfig({ cfg: currentConfig, @@ -2229,12 +2241,16 @@ export const registerTelegramHandlers = ({ effectiveGroupAllow, hasGroupAllowOverride, } = eventAuthContext; - const dmAllow = await resolveTelegramDmAllow({ + // For DMs, prefer per-DM/topic allowFrom (groupAllowOverride) over account-level allowFrom + const dmAllowFrom = groupAllowOverride ?? allowFrom; + const expandedDmAllowFrom = await expandTelegramAllowFromWithAccessGroups({ cfg, - groupAllowOverride, - allowFrom, + allowFrom: dmAllowFrom, accountId, senderId: event.senderId, + }); + const effectiveDmAllow = normalizeDmAllowFromWithStore({ + allowFrom: expandedDmAllowFrom, storeAllowFrom, dmPolicy, }); @@ -2267,7 +2283,7 @@ export const registerTelegramHandlers = ({ dmPolicy, msg: event.msg, chatId: event.chatId, - effectiveDmAllow: dmAllow.effectiveAllow, + effectiveDmAllow, accountId, bot, logger, @@ -2330,6 +2346,9 @@ export const registerTelegramHandlers = ({ }); }); + // Handle channel posts — enables bot-to-bot communication via Telegram channels. + // Telegram bots cannot see other bot messages in groups, but CAN in channels. + // This handler normalizes channel_post updates into the standard message pipeline. bot.on("channel_post", async (ctx) => { const post = ctx.channelPost; if (!post) { diff --git a/extensions/telegram/src/bot-message-context.body.ts b/extensions/telegram/src/bot-message-context.body.ts index a2e425ab740..33ab62e5cd1 100644 --- a/extensions/telegram/src/bot-message-context.body.ts +++ b/extensions/telegram/src/bot-message-context.body.ts @@ -8,7 +8,6 @@ import { type NormalizedLocation, } from "openclaw/plugin-sdk/channel-inbound"; import { resolveChannelGroupPolicy } from "openclaw/plugin-sdk/channel-policy"; -import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth-native"; import { hasControlCommand } from "openclaw/plugin-sdk/command-detection"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import type { @@ -30,7 +29,6 @@ import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime"; import type { NormalizedAllowFrom } from "./bot-access.js"; -import { isSenderAllowed } from "./bot-access.js"; import type { TelegramLogger, TelegramMediaRef, @@ -48,6 +46,7 @@ import { import { buildTelegramGroupPeerId } from "./bot/helpers.js"; import type { TelegramContext } from "./bot/types.js"; import { isTelegramForumServiceMessage } from "./forum-service-message.js"; +import { resolveTelegramCommandIngressAuthorization } from "./ingress.js"; type StickerVisionRuntime = typeof import("./sticker-vision.runtime.js"); type MediaUnderstandingRuntime = typeof import("./media-understanding.runtime.js"); @@ -184,22 +183,28 @@ export async function resolveTelegramInboundBody(params: { const mentionRegexes = buildMentionRegexes(cfg, routeAgentId); const messageTextParts = getTelegramTextParts(msg); const allowForCommands = isGroup ? effectiveGroupAllow : effectiveDmAllow; - const senderAllowedForCommands = isSenderAllowed({ - allow: allowForCommands, - senderId, - senderUsername, - }); const useAccessGroups = cfg.commands?.useAccessGroups !== false; const hasControlCommandInMessage = hasControlCommand(messageTextParts.text, cfg, { botUsername, }); - const commandGate = resolveControlCommandGate({ - useAccessGroups, - authorizers: [{ configured: allowForCommands.hasEntries, allowed: senderAllowedForCommands }], + const commandGate = await resolveTelegramCommandIngressAuthorization({ + accountId: accountId ?? "default", + cfg, + dmPolicy: "pairing", + isGroup, + chatId, + resolvedThreadId, + senderId, + effectiveDmAllow, + effectiveGroupAllow, + ownerAccess: { ownerList: [], senderIsOwner: false }, + eventKind: "message", allowTextCommands: true, hasControlCommand: hasControlCommandInMessage, + modeWhenAccessGroupsOff: "allow", + includeDmAllowForGroupCommands: false, }); - const commandAuthorized = commandGate.commandAuthorized; + const commandAuthorized = commandGate.authorized; const historyKey = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : undefined; const primaryMedia = resolveTelegramPrimaryMedia(msg); @@ -240,7 +245,7 @@ export async function resolveTelegramInboundBody(params: { (topicConfig?.disableAudioPreflight ?? (groupConfig as TelegramGroupConfig | undefined)?.disableAudioPreflight) === true; const senderAllowedForAudioPreflight = - !useAccessGroups || !allowForCommands.hasEntries || senderAllowedForCommands; + !useAccessGroups || !allowForCommands.hasEntries || commandAuthorized; let preflightTranscript: string | undefined; const needsPreflightTranscription = @@ -314,7 +319,7 @@ export async function resolveTelegramInboundBody(params: { }); const wasMentioned = options?.forceWasMentioned === true ? true : computedWasMentioned; - if (isGroup && commandGate.shouldBlock) { + if (isGroup && commandGate.shouldBlockControlCommand) { logInboundDrop({ log: logVerbose, channel: "telegram", diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index 73e99152c72..c0a062144a5 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -263,7 +263,7 @@ describe("registerTelegramNativeCommands", () => { const { bot, commandHandlers, sendMessage } = createCommandBot(); registerTelegramNativeCommands({ - ...createNativeCommandTestParams({}, { bot }), + ...createNativeCommandTestParams({}, { bot, allowFrom: [200] }), }); const handler = commandHandlers.get("fast"); diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index effc022b03f..e60000b4ca4 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -8,10 +8,7 @@ import { resolveThinkingDefault, } from "openclaw/plugin-sdk/agent-runtime"; import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-streaming"; -import { - resolveCommandAuthorizedFromAuthorizers, - resolveNativeCommandSessionTargets, -} from "openclaw/plugin-sdk/command-auth-native"; +import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native"; import { buildCommandTextFromArgs, findCommandByNativeName, @@ -50,10 +47,10 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "openclaw/plugin-sdk/text-runtime"; -import { resolveTelegramDmAllow } from "./access-groups.js"; +import { expandTelegramAllowFromWithAccessGroups } from "./access-groups.js"; import { resolveTelegramAccount } from "./accounts.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; -import { isSenderAllowed, resolveTelegramEffectiveDmPolicy } from "./bot-access.js"; +import { normalizeDmAllowFromWithStore, resolveTelegramEffectiveDmPolicy } from "./bot-access.js"; import type { TelegramBotDeps } from "./bot-deps.js"; import type { TelegramMediaRef } from "./bot-message-context.js"; import type { TelegramMessageContextOptions } from "./bot-message-context.types.js"; @@ -99,6 +96,7 @@ import { evaluateTelegramGroupPolicyAccess, } from "./group-access.js"; import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js"; +import { resolveTelegramCommandIngressAuthorization } from "./ingress.js"; import { buildInlineKeyboard } from "./inline-keyboard.js"; import { recordSentMessage } from "./sent-message-cache.js"; @@ -344,7 +342,9 @@ async function cleanupTelegramProgressPlaceholder(params: { runtime: params.runtime, fn: () => params.bot.api.deleteMessage(params.chatId, progressMessageId), }); - } catch {} + } catch { + // Best-effort cleanup before fallback or suppression exits. + } } async function resolveTelegramNativeCommandThreadContext(params: { @@ -526,15 +526,7 @@ async function resolveTelegramCommandAuth(params: { logVerbose(`Blocked telegram command in DM ${chatId}: requireTopic=true but no topic present`); return null; } - const dmAllow = await resolveTelegramDmAllow({ - cfg, - groupAllowOverride, - allowFrom, - accountId, - senderId, - storeAllowFrom: isGroup ? [] : storeAllowFrom, - dmPolicy: effectiveDmPolicy, - }); + const dmAllowFrom = groupAllowOverride ?? allowFrom; const commandsAllowFromConfigured = isTelegramCommandsAllowFromConfigured(cfg); const commandsAllowFromAccess = commandsAllowFromConfigured ? resolveTelegramCommandAuthorization({ @@ -622,31 +614,34 @@ async function resolveTelegramCommandAuth(params: { } } - const senderAllowed = isSenderAllowed({ - allow: dmAllow.effectiveAllow, + const expandedDmAllowFrom = await expandTelegramAllowFromWithAccessGroups({ + cfg, + allowFrom: dmAllowFrom, + accountId, senderId, - senderUsername, }); - const groupSenderAllowed = isGroup - ? isSenderAllowed({ allow: effectiveGroupAllow, senderId, senderUsername }) - : false; - const ownerAuthorizerConfigured = ownerAccess.senderIsOwner || ownerAccess.ownerList.length > 0; + const dmAllow = normalizeDmAllowFromWithStore({ + allowFrom: expandedDmAllowFrom, + storeAllowFrom: isGroup ? [] : storeAllowFrom, + dmPolicy: effectiveDmPolicy, + }); const commandAuthorized = commandsAllowFromConfigured ? Boolean(commandsAllowFromAccess?.isAuthorizedSender) - : resolveCommandAuthorizedFromAuthorizers({ - useAccessGroups, - authorizers: [ - { configured: dmAllow.effectiveAllow.hasEntries, allowed: senderAllowed }, - ...(isGroup - ? [{ configured: effectiveGroupAllow.hasEntries, allowed: groupSenderAllowed }] - : []), - { - configured: ownerAuthorizerConfigured, - allowed: ownerAccess.senderIsOwner, - }, - ], - modeWhenAccessGroupsOff: "configured", - }); + : ( + await resolveTelegramCommandIngressAuthorization({ + accountId, + cfg, + dmPolicy: effectiveDmPolicy, + isGroup, + chatId, + resolvedThreadId, + senderId, + effectiveDmAllow: dmAllow, + effectiveGroupAllow, + ownerAccess, + eventKind: "native-command", + }) + ).authorized; if (requireAuth && !commandAuthorized) { return await rejectNotAuthorized(); } @@ -1150,6 +1145,7 @@ export const registerTelegramNativeCommands = ({ CommandTargetSessionKey: commandTargetSessionKey, MessageThreadId: threadSpec.id, IsForum: isForum, + // Originating context for sub-agent announce routing OriginatingChannel: "telegram" as const, OriginatingTo: originatingTo, }); @@ -1329,7 +1325,9 @@ export const registerTelegramNativeCommands = ({ if (typeof maybeMessageId === "number") { progressMessageId = maybeMessageId; } - } catch {} + } catch { + // Fall back to the normal final reply path if the placeholder send fails. + } } const sessionFileContext = await resolveTelegramCommandSessionFile({ @@ -1409,7 +1407,9 @@ export const registerTelegramNativeCommands = ({ groupId: isGroup ? String(chatId) : undefined, }); return; - } catch {} + } catch { + // Fall through to cleanup + normal delivered reply if editing fails. + } } await cleanupTelegramProgressPlaceholder({ bot, diff --git a/extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts b/extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts index a9dd5a1b3f5..1d536c9bd34 100644 --- a/extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts +++ b/extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts @@ -10,8 +10,37 @@ import { resolveMedia } from "./bot/delivery.resolve-media.js"; import type { TelegramContext } from "./bot/types.js"; import type { TelegramTransport } from "./fetch.js"; +function resolveScheduledTimerForDelay( + setTimeoutSpy: ReturnType, + delayMs: number, +) { + const timerCallIndex = setTimeoutSpy.mock.calls.findLastIndex( + (call: Parameters) => call[1] === delayMs, + ); + const flushTimer = + timerCallIndex >= 0 + ? (setTimeoutSpy.mock.calls[timerCallIndex]?.[0] as (() => unknown) | undefined) + : undefined; + if (timerCallIndex >= 0) { + clearTimeout( + setTimeoutSpy.mock.results[timerCallIndex]?.value as ReturnType, + ); + } + return flushTimer; +} + +async function flushScheduledTimerForDelay( + setTimeoutSpy: ReturnType, + delayMs: number, +) { + const flushTimer = resolveScheduledTimerForDelay(setTimeoutSpy, delayMs); + expect(flushTimer).toBeTypeOf("function"); + await flushTimer?.(); +} + describe("telegram stickers", () => { - const STICKER_TEST_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 20_000; + // Parallel Testbox shards can make these media-path e2e tests slower than standalone local runs. + const STICKER_TEST_TIMEOUT_MS = process.platform === "win32" ? 120_000 : 90_000; async function createStaticStickerHarness() { const proxyFetch = vi.fn().mockResolvedValue( @@ -172,7 +201,6 @@ describe("telegram text fragments", () => { }); const TEXT_FRAGMENT_TEST_TIMEOUT_MS = process.platform === "win32" ? 45_000 : 20_000; - const TEXT_FRAGMENT_FLUSH_MS = TELEGRAM_TEST_TIMINGS.textFragmentGapMs + 80; it( "buffers near-limit text and processes sequential parts as one message", @@ -180,42 +208,43 @@ describe("telegram text fragments", () => { const { handler, replySpy } = await createBotHandlerWithOptions({}); const part1 = "A".repeat(4050); const part2 = "B".repeat(50); + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); - await handler({ - message: { - chat: { id: 42, type: "private" }, - from: { id: 777, is_bot: false, first_name: "Ada" }, - message_id: 10, - date: 1736380800, - text: part1, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({}), - }); + try { + await handler({ + message: { + chat: { id: 42, type: "private" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, + message_id: 10, + date: 1736380800, + text: part1, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({}), + }); - await handler({ - message: { - chat: { id: 42, type: "private" }, - from: { id: 777, is_bot: false, first_name: "Ada" }, - message_id: 11, - date: 1736380801, - text: part2, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({}), - }); + await handler({ + message: { + chat: { id: 42, type: "private" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, + message_id: 11, + date: 1736380801, + text: part2, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({}), + }); - expect(replySpy).not.toHaveBeenCalled(); - await vi.waitFor( - () => { - expect(replySpy).toHaveBeenCalledTimes(1); - }, - { timeout: TEXT_FRAGMENT_FLUSH_MS * 6, interval: 5 }, - ); + expect(replySpy).not.toHaveBeenCalled(); + await flushScheduledTimerForDelay(setTimeoutSpy, TELEGRAM_TEST_TIMINGS.textFragmentGapMs); - const payload = replySpy.mock.calls[0][0] as { RawBody?: string }; - expect(payload.RawBody).toContain(part1.slice(0, 32)); - expect(payload.RawBody).toContain(part2.slice(0, 32)); + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0] as { RawBody?: string }; + expect(payload.RawBody).toContain(part1.slice(0, 32)); + expect(payload.RawBody).toContain(part2.slice(0, 32)); + } finally { + setTimeoutSpy.mockRestore(); + } }, TEXT_FRAGMENT_TEST_TIMEOUT_MS, ); diff --git a/extensions/telegram/src/dm-access.ts b/extensions/telegram/src/dm-access.ts index 95095716208..4442d4715f9 100644 --- a/extensions/telegram/src/dm-access.ts +++ b/extensions/telegram/src/dm-access.ts @@ -5,8 +5,13 @@ import type { DmPolicy } from "openclaw/plugin-sdk/config-types"; import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { withTelegramApiErrorLogging } from "./api-logging.js"; -import { resolveSenderAllowMatch, type NormalizedAllowFrom } from "./bot-access.js"; +import type { NormalizedAllowFrom } from "./bot-access.js"; import { renderTelegramHtmlText } from "./format.js"; +import { + createTelegramIngressSubject, + createTelegramIngressResolver, + telegramAllowEntries, +} from "./ingress.js"; type TelegramDmAccessLogger = { info: (obj: Record, msg: string) => void; @@ -32,6 +37,25 @@ function resolveTelegramSenderIdentity(msg: Message, chatId: number): TelegramSe }; } +async function decideTelegramDmAccess(params: { + accountId: string; + dmPolicy: DmPolicy; + sender: TelegramSenderIdentity; + effectiveDmAllow: NormalizedAllowFrom; +}) { + const result = await createTelegramIngressResolver({ accountId: params.accountId }).message({ + subject: createTelegramIngressSubject(params.sender.candidateId), + conversation: { + kind: "direct", + id: params.sender.candidateId, + }, + dmPolicy: params.dmPolicy, + groupPolicy: "disabled", + allowFrom: telegramAllowEntries(params.effectiveDmAllow), + }); + return result.ingress; +} + export async function enforceTelegramDmAccess(params: { isGroup: boolean; dmPolicy: DmPolicy; @@ -62,30 +86,22 @@ export async function enforceTelegramDmAccess(params: { } const sender = resolveTelegramSenderIdentity(msg, chatId); - const allowMatch = resolveSenderAllowMatch({ - allow: effectiveDmAllow, - senderId: sender.candidateId, - senderUsername: sender.username, + const access = await decideTelegramDmAccess({ + accountId, + dmPolicy, + sender, + effectiveDmAllow, }); - const allowMatchMeta = `matchKey=${allowMatch.matchKey ?? "none"} matchSource=${ - allowMatch.matchSource ?? "none" - }`; - const allowed = - effectiveDmAllow.hasWildcard || (effectiveDmAllow.hasEntries && allowMatch.allowed); - if (dmPolicy === "open") { - if (allowed) { - return true; - } - logVerbose( - `Blocked unauthorized telegram sender ${sender.candidateId} (dmPolicy=open, ${allowMatchMeta})`, - ); - return false; - } - if (allowed) { + if (access.decision === "allow") { return true; } - if (dmPolicy === "pairing") { + if (dmPolicy === "open") { + logVerbose(`Blocked unauthorized telegram sender ${sender.candidateId} (dmPolicy=open)`); + return false; + } + + if (access.decision === "pairing") { try { const telegramUserId = sender.userId ?? sender.candidateId; await createChannelPairingChallengeIssuer({ @@ -113,8 +129,6 @@ export async function enforceTelegramDmAccess(params: { username: sender.username || undefined, firstName: sender.firstName, lastName: sender.lastName, - matchKey: allowMatch.matchKey ?? "none", - matchSource: allowMatch.matchSource ?? "none", }, "telegram pairing request", ); @@ -136,8 +150,6 @@ export async function enforceTelegramDmAccess(params: { return false; } - logVerbose( - `Blocked unauthorized telegram sender ${sender.candidateId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`, - ); + logVerbose(`Blocked unauthorized telegram sender ${sender.candidateId} (dmPolicy=${dmPolicy})`); return false; } diff --git a/extensions/telegram/src/group-access.ts b/extensions/telegram/src/group-access.ts index 80b06ddea21..aa73157572d 100644 --- a/extensions/telegram/src/group-access.ts +++ b/extensions/telegram/src/group-access.ts @@ -6,7 +6,6 @@ import type { TelegramGroupConfig, TelegramTopicConfig, } from "openclaw/plugin-sdk/config-types"; -import { evaluateMatchedGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access"; import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy"; import { isSenderAllowed, type NormalizedAllowFrom } from "./bot-access.js"; import { firstDefined } from "./bot-access.js"; @@ -175,29 +174,24 @@ export const evaluateTelegramGroupPolicyAccess = (params: { } if (groupPolicy === "allowlist" && params.enforceAllowlistAuthorization) { const senderId = params.senderId ?? ""; - const senderAuthorization = evaluateMatchedGroupAccessForPolicy({ - groupPolicy, - requireMatchInput: params.requireSenderForAllowlistAuthorization, - hasMatchInput: Boolean(senderId), - allowlistConfigured: - chatExplicitlyAllowed || - params.allowEmptyAllowlistEntries || - params.effectiveGroupAllow.hasEntries, - allowlistMatched: - (chatExplicitlyAllowed && !params.effectiveGroupAllow.hasEntries) || - isSenderAllowed({ - allow: params.effectiveGroupAllow, - senderId, - senderUsername: params.senderUsername ?? "", - }), - }); - if (!senderAuthorization.allowed && senderAuthorization.reason === "missing_match_input") { + const allowlistConfigured = + chatExplicitlyAllowed || + params.allowEmptyAllowlistEntries || + params.effectiveGroupAllow.hasEntries; + const allowlistMatched = + (chatExplicitlyAllowed && !params.effectiveGroupAllow.hasEntries) || + isSenderAllowed({ + allow: params.effectiveGroupAllow, + senderId, + senderUsername: params.senderUsername ?? "", + }); + if (params.requireSenderForAllowlistAuthorization && !senderId) { return { allowed: false, reason: "group-policy-allowlist-no-sender", groupPolicy }; } - if (!senderAuthorization.allowed && senderAuthorization.reason === "empty_allowlist") { + if (!allowlistConfigured) { return { allowed: false, reason: "group-policy-allowlist-empty", groupPolicy }; } - if (!senderAuthorization.allowed && senderAuthorization.reason === "not_allowlisted") { + if (!allowlistMatched) { return { allowed: false, reason: "group-policy-allowlist-unauthorized", groupPolicy }; } } diff --git a/extensions/telegram/src/ingress.ts b/extensions/telegram/src/ingress.ts new file mode 100644 index 00000000000..4a1f9378fb1 --- /dev/null +++ b/extensions/telegram/src/ingress.ts @@ -0,0 +1,126 @@ +import { + createChannelIngressResolver, + defineStableChannelIngressIdentity, + type ChannelIngressEventInput, +} from "openclaw/plugin-sdk/channel-ingress-runtime"; +import type { DmPolicy, OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import { normalizeAllowFrom, type NormalizedAllowFrom } from "./bot-access.js"; + +const TELEGRAM_CHANNEL_ID = "telegram"; + +const telegramIngressIdentity = defineStableChannelIngressIdentity({ + key: "telegram-user-id", + normalize: (value) => { + const normalized = normalizeAllowFrom([value]); + return normalized.entries[0] ?? (normalized.hasWildcard ? "*" : null); + }, + sensitivity: "pii", +}); + +export function createTelegramIngressSubject(senderId: string) { + return { stableId: senderId }; +} + +export function createTelegramIngressResolver(params: { + accountId?: string; + cfg?: Pick; +}) { + return createChannelIngressResolver({ + channelId: TELEGRAM_CHANNEL_ID, + accountId: params.accountId ?? "default", + identity: telegramIngressIdentity, + cfg: params.cfg, + }); +} + +export function telegramAllowEntries(allow: NormalizedAllowFrom): string[] { + return [...(allow.hasWildcard ? ["*"] : []), ...allow.entries]; +} + +type TelegramOwnerCommandAccess = { ownerList: string[]; senderIsOwner: boolean }; + +function telegramConversation(params: { + isGroup: boolean; + chatId: string | number; + resolvedThreadId?: number; +}) { + return { + kind: params.isGroup ? ("group" as const) : ("direct" as const), + id: String(params.chatId), + ...(params.resolvedThreadId != null ? { threadId: String(params.resolvedThreadId) } : {}), + }; +} + +export async function resolveTelegramCommandIngressAuthorization(params: { + accountId: string; + cfg: OpenClawConfig; + dmPolicy: DmPolicy; + isGroup: boolean; + chatId: string | number; + resolvedThreadId?: number; + senderId: string; + effectiveDmAllow: NormalizedAllowFrom; + effectiveGroupAllow: NormalizedAllowFrom; + ownerAccess: TelegramOwnerCommandAccess; + eventKind?: ChannelIngressEventInput["kind"]; + allowTextCommands?: boolean; + hasControlCommand?: boolean; + modeWhenAccessGroupsOff?: "allow" | "deny" | "configured"; + includeDmAllowForGroupCommands?: boolean; +}) { + const commandOwner = [ + ...(params.isGroup && params.includeDmAllowForGroupCommands === false + ? [] + : telegramAllowEntries(params.effectiveDmAllow)), + ...(params.ownerAccess.senderIsOwner ? [params.senderId || "*"] : params.ownerAccess.ownerList), + ]; + const result = await createTelegramIngressResolver({ + accountId: params.accountId, + cfg: params.cfg, + }).command({ + subject: createTelegramIngressSubject(params.senderId), + conversation: telegramConversation(params), + event: { + kind: params.eventKind ?? "native-command", + }, + dmPolicy: params.dmPolicy, + groupPolicy: "allowlist", + allowFrom: commandOwner, + groupAllowFrom: params.isGroup ? telegramAllowEntries(params.effectiveGroupAllow) : [], + command: { + allowTextCommands: params.allowTextCommands ?? false, + hasControlCommand: params.hasControlCommand ?? false, + modeWhenAccessGroupsOff: params.modeWhenAccessGroupsOff ?? "configured", + }, + }); + return result.commandAccess; +} + +export async function resolveTelegramEventIngressAuthorization(params: { + accountId: string; + dmPolicy: DmPolicy; + isGroup: boolean; + chatId: number; + resolvedThreadId?: number; + senderId: string; + effectiveDmAllow: NormalizedAllowFrom; + effectiveGroupAllow: NormalizedAllowFrom; + enforceGroupAuthorization: boolean; + eventKind: Extract; +}) { + const result = await createTelegramIngressResolver({ accountId: params.accountId }).event({ + subject: createTelegramIngressSubject(params.senderId), + conversation: telegramConversation(params), + event: { + kind: params.eventKind, + authMode: "inbound", + }, + dmPolicy: params.dmPolicy, + groupPolicy: params.enforceGroupAuthorization ? "allowlist" : "open", + allowFrom: telegramAllowEntries(params.effectiveDmAllow), + groupAllowFrom: params.enforceGroupAuthorization + ? telegramAllowEntries(params.effectiveGroupAllow) + : [], + }); + return result.ingress; +} diff --git a/extensions/telegram/src/sequential-key.ts b/extensions/telegram/src/sequential-key.ts index 4bf52ce8cd1..c51672f3cdd 100644 --- a/extensions/telegram/src/sequential-key.ts +++ b/extensions/telegram/src/sequential-key.ts @@ -4,7 +4,7 @@ import { listChatCommands, maybeResolveTextAlias, normalizeCommandBody, -} from "openclaw/plugin-sdk/command-auth"; +} from "openclaw/plugin-sdk/command-auth-native"; import { isAbortRequestText, isBtwRequestText, diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index 84943b99734..370e5524b4e 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -38,10 +38,11 @@ import { extractMessageText, formatModelName, isBotMentioned, - isDmAllowed, + isDmAllowedWithIngress, isGroupInviteAllowed, isSummarizationRequest, resolveAuthorizedMessageText, + resolveTlonCommandAuthorizationWithIngress, stripBotMention, } from "./utils.js"; @@ -431,7 +432,6 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise 0 && !senders.has(senderShip)) { - // Log warning runtime.log?.( `[tlon] ⚠️ SECURITY: Multiple users sharing DM session. ` + `Configure "session.dmScope: per-channel-peer" in OpenClaw config.`, ); - // Notify owner via DM (once per monitor session) if (!sharedSessionWarningSent && effectiveOwnerShip) { sharedSessionWarningSent = true; const warningMsg = @@ -455,7 +453,6 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise 0) { const mediaLines = attachments @@ -514,7 +508,6 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise ({ - id: raw.messageId, - timestamp: raw.timestamp, - rawText: raw.messageText, - textForAgent: commandBody, - textForCommands: commandBody, - raw, - }), - resolveTurn: () => ({ - cfg, - channel: "tlon", - accountId: route.accountId, - agentId: route.agentId, - routeSessionKey: route.sessionKey, - storePath, - ctxPayload, - recordInboundSession: core.channel.session.recordInboundSession, - dispatchReplyWithBufferedBlockDispatcher: - core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, - delivery: { - preparePayload: prepareReplyPayload, - durable: deliveryTarget - ? () => ({ - to: deliveryTarget, - replyToId: parentId ?? undefined, - threadId: parentId ?? undefined, - }) - : false, - deliver: async (payload: ReplyPayload) => { - const replyText = payload.text; - if (!replyText) { - return { visibleReplySent: false }; - } + cfg, + agentId: route.agentId, + routeSessionKey: route.sessionKey, + storePath, + ctxPayload, + recordInboundSession: core.channel.session.recordInboundSession, + dispatchReplyWithBufferedBlockDispatcher: + core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, + delivery: { + preparePayload: prepareReplyPayload, + durable: deliveryTarget + ? () => ({ + to: deliveryTarget, + replyToId: parentId ?? undefined, + threadId: parentId ?? undefined, + }) + : false, + deliver: async (payload: ReplyPayload) => { + const replyText = payload.text; + if (!replyText) { + return { visibleReplySent: false }; + } - if (isGroup && groupChannel) { - const parsed = parseChannelNest(groupChannel); - if (!parsed) { - return { visibleReplySent: false }; - } - await sendGroupMessage({ - api: api, - fromShip: botShipName, - hostShip: parsed.hostShip, - channelName: parsed.channelName, - text: replyText, - replyToId: parentId ?? undefined, - }); - return { visibleReplySent: true, replyToId: parentId ?? undefined }; - } + if (isGroup && groupChannel) { + const parsed = parseChannelNest(groupChannel); + if (!parsed) { + return { visibleReplySent: false }; + } + await sendGroupMessage({ + api: api, + fromShip: botShipName, + hostShip: parsed.hostShip, + channelName: parsed.channelName, + text: replyText, + replyToId: parentId ?? undefined, + }); + return { visibleReplySent: true, replyToId: parentId ?? undefined }; + } - await sendDm({ - api: api, - fromShip: botShipName, - toShip: senderShip, - text: replyText, - }); - return { visibleReplySent: true }; - }, - onDelivered: (_payload, _info, result) => { - rememberThreadParticipation(result); - }, - onError: (err, info) => { - const dispatchDuration = Date.now() - dispatchStartTime; - runtime.error?.( - `[tlon] ${info.kind} reply failed after ${dispatchDuration}ms: ${String(err)}`, - ); - }, - }, - dispatcherOptions: { - responsePrefix, - humanDelay, - }, - record: { - onRecordError: (err) => { - runtime.error?.(`[tlon] failed updating session meta: ${String(err)}`); - }, - }, - }), + await sendDm({ + api: api, + fromShip: botShipName, + toShip: senderShip, + text: replyText, + }); + return { visibleReplySent: true }; + }, + onDelivered: (_payload, _info, result) => { + rememberThreadParticipation(result); + }, + onError: (err, info) => { + const dispatchDuration = Date.now() - dispatchStartTime; + runtime.error?.( + `[tlon] ${info.kind} reply failed after ${dispatchDuration}ms: ${String(err)}`, + ); + }, + }, + dispatcherOptions: { + responsePrefix, + humanDelay, + }, + record: { + onRecordError: (err) => { + runtime.error?.(`[tlon] failed updating session meta: ${String(err)}`); + }, }, }); }; @@ -961,7 +939,10 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise normalizeShip(ship)).some((ship) => ship === normalizedSender); +const tlonIngressIdentity = { + key: "sender-ship", + normalize: normalizeShip, + sensitivity: "pii", + isWildcardEntry: () => false, + entryIdPrefix: "tlon-entry", +} satisfies StableChannelIngressIdentityParams; + +export async function isDmAllowedWithIngress( + senderShip: string, + allowlist: string[] | undefined, +): Promise { + const access = await resolveStableChannelMessageIngress({ + channelId: "tlon", + accountId: "default", + identity: tlonIngressIdentity, + subject: { stableId: senderShip }, + conversation: { + kind: "direct", + id: "direct", + }, + dmPolicy: "allowlist", + allowFrom: allowlist ?? [], + }); + return access.senderAccess.allowed; +} + +export async function resolveTlonCommandAuthorizationWithIngress(params: { + senderShip: string; + ownerShip: string | null | undefined; + useAccessGroups: boolean; +}) { + const normalizedOwner = params.ownerShip ? normalizeShip(params.ownerShip) : null; + return await resolveStableChannelMessageIngress({ + channelId: "tlon", + accountId: "default", + identity: tlonIngressIdentity, + useAccessGroups: params.useAccessGroups, + subject: { stableId: params.senderShip }, + conversation: { + kind: "direct", + id: "command", + }, + event: { + authMode: "none", + mayPair: false, + }, + dmPolicy: "allowlist", + groupPolicy: "open", + allowFrom: normalizedOwner ? [normalizedOwner] : [], + command: {}, + }); } -/** - * Check if a group invite from a ship should be auto-accepted. - * - * SECURITY: Fail-safe to deny. If allowlist is empty or undefined, - * ALL invites are rejected - even if autoAcceptGroupInvites is enabled. - * This prevents misconfigured bots from accepting malicious invites. - */ export function isGroupInviteAllowed( inviterShip: string, allowlist: string[] | undefined, ): boolean { - // SECURITY: Fail-safe to deny when no allowlist configured if (!allowlist || allowlist.length === 0) { return false; } @@ -149,10 +183,6 @@ export function isGroupInviteAllowed( return allowlist.map((ship) => normalizeShip(ship)).some((ship) => ship === normalizedInviter); } -/** - * Resolve quoted/cited content only after the caller has passed authorization. - * Unauthorized paths must keep raw text and must not trigger cross-channel cite fetches. - */ export async function resolveAuthorizedMessageText(params: { rawText: string; content: unknown; diff --git a/extensions/tlon/src/security.test.ts b/extensions/tlon/src/security.test.ts index 01560f23dab..6ccabf6a9fc 100644 --- a/extensions/tlon/src/security.test.ts +++ b/extensions/tlon/src/security.test.ts @@ -11,7 +11,8 @@ import { describe, expect, it, vi } from "vitest"; import { extractCites, - isDmAllowed, + resolveTlonCommandAuthorizationWithIngress, + isDmAllowedWithIngress, isGroupInviteAllowed, isBotMentioned, extractMessageText, @@ -20,59 +21,75 @@ import { import { normalizeShip } from "./targets.js"; const allowlistShipMatchingCases = [ - { label: "DM allowlist", isAllowed: isDmAllowed }, + { label: "DM allowlist", isAllowed: isDmAllowedWithIngress }, { label: "group invite allowlist", isAllowed: isGroupInviteAllowed }, ] satisfies Array<{ label: string; - isAllowed: (ship: string, allowlist: string[] | undefined) => boolean; + isAllowed: (ship: string, allowlist: string[] | undefined) => boolean | Promise; }>; +async function expectAllowed( + isAllowed: (ship: string, allowlist: string[] | undefined) => boolean | Promise, + ship: string, + allowlist: string[] | undefined, + expected: boolean, +) { + await expect(Promise.resolve(isAllowed(ship, allowlist))).resolves.toBe(expected); +} + +async function expectDmAllowed(ship: string, allowlist: string[] | undefined, expected: boolean) { + await expect(isDmAllowedWithIngress(ship, allowlist)).resolves.toBe(expected); +} + describe("Security: allowlist ship matching", () => { it.each(allowlistShipMatchingCases)( "$label normalizes ship names with and without ~ prefix", - ({ isAllowed }) => { + async ({ isAllowed }) => { const allowlist = ["~zod"]; - expect(isAllowed("zod", allowlist)).toBe(true); - expect(isAllowed("~zod", allowlist)).toBe(true); + await expectAllowed(isAllowed, "zod", allowlist, true); + await expectAllowed(isAllowed, "~zod", allowlist, true); const allowlistWithoutTilde = ["zod"]; - expect(isAllowed("~zod", allowlistWithoutTilde)).toBe(true); - expect(isAllowed("zod", allowlistWithoutTilde)).toBe(true); + await expectAllowed(isAllowed, "~zod", allowlistWithoutTilde, true); + await expectAllowed(isAllowed, "zod", allowlistWithoutTilde, true); }, ); - it.each(allowlistShipMatchingCases)("$label rejects partial ship matches", ({ isAllowed }) => { - const allowlist = ["~zod"]; - expect(isAllowed("~zod-extra", allowlist)).toBe(false); - expect(isAllowed("~extra-zod", allowlist)).toBe(false); - }); + it.each(allowlistShipMatchingCases)( + "$label rejects partial ship matches", + async ({ isAllowed }) => { + const allowlist = ["~zod"]; + await expectAllowed(isAllowed, "~zod-extra", allowlist, false); + await expectAllowed(isAllowed, "~extra-zod", allowlist, false); + }, + ); }); describe("Security: DM Allowlist", () => { - describe("isDmAllowed", () => { - it("rejects DMs when allowlist is empty", () => { - expect(isDmAllowed("~zod", [])).toBe(false); - expect(isDmAllowed("~sampel-palnet", [])).toBe(false); + describe("DM ingress allowlist", () => { + it("rejects DMs when allowlist is empty", async () => { + await expectDmAllowed("~zod", [], false); + await expectDmAllowed("~sampel-palnet", [], false); }); - it("rejects DMs when allowlist is undefined", () => { - expect(isDmAllowed("~zod", undefined)).toBe(false); + it("rejects DMs when allowlist is undefined", async () => { + await expectDmAllowed("~zod", undefined, false); }); - it("allows DMs from ships on the allowlist", () => { + it("allows DMs from ships on the allowlist", async () => { const allowlist = ["~zod", "~bus"]; - expect(isDmAllowed("~zod", allowlist)).toBe(true); - expect(isDmAllowed("~bus", allowlist)).toBe(true); + await expectDmAllowed("~zod", allowlist, true); + await expectDmAllowed("~bus", allowlist, true); }); - it("rejects DMs from ships NOT on the allowlist", () => { + it("rejects DMs from ships NOT on the allowlist", async () => { const allowlist = ["~zod", "~bus"]; - expect(isDmAllowed("~nec", allowlist)).toBe(false); - expect(isDmAllowed("~sampel-palnet", allowlist)).toBe(false); - expect(isDmAllowed("~random-ship", allowlist)).toBe(false); + await expectDmAllowed("~nec", allowlist, false); + await expectDmAllowed("~sampel-palnet", allowlist, false); + await expectDmAllowed("~random-ship", allowlist, false); }); - it("handles galaxy, star, planet, and moon names", () => { + it("handles galaxy, star, planet, and moon names", async () => { const allowlist = [ "~zod", // galaxy "~marzod", // star @@ -80,32 +97,53 @@ describe("Security: DM Allowlist", () => { "~dozzod-dozzod-dozzod-dozzod", // moon ]; - expect(isDmAllowed("~zod", allowlist)).toBe(true); - expect(isDmAllowed("~marzod", allowlist)).toBe(true); - expect(isDmAllowed("~sampel-palnet", allowlist)).toBe(true); - expect(isDmAllowed("~dozzod-dozzod-dozzod-dozzod", allowlist)).toBe(true); + await expectDmAllowed("~zod", allowlist, true); + await expectDmAllowed("~marzod", allowlist, true); + await expectDmAllowed("~sampel-palnet", allowlist, true); + await expectDmAllowed("~dozzod-dozzod-dozzod-dozzod", allowlist, true); // Similar but different ships should be rejected - expect(isDmAllowed("~nec", allowlist)).toBe(false); - expect(isDmAllowed("~wanzod", allowlist)).toBe(false); - expect(isDmAllowed("~sampel-palned", allowlist)).toBe(false); + await expectDmAllowed("~nec", allowlist, false); + await expectDmAllowed("~wanzod", allowlist, false); + await expectDmAllowed("~sampel-palned", allowlist, false); }); // NOTE: Ship names in Urbit are always lowercase by convention. // This test documents current behavior - strict equality after normalization. // If case-insensitivity is desired, normalizeShip should lowercase. - it("uses strict equality after normalization (case-sensitive)", () => { + it("uses strict equality after normalization (case-sensitive)", async () => { const allowlist = ["~zod"]; - expect(isDmAllowed("~zod", allowlist)).toBe(true); + await expectDmAllowed("~zod", allowlist, true); // Different case would NOT match with current implementation - expect(isDmAllowed("~Zod", ["~Zod"])).toBe(true); // exact match works + await expectDmAllowed("~Zod", ["~Zod"], true); // exact match works }); - it("handles whitespace in ship names (normalized)", () => { + it("handles whitespace in ship names (normalized)", async () => { // Ships with leading/trailing whitespace are normalized by normalizeShip const allowlist = [" ~zod ", "~bus"]; - expect(isDmAllowed("~zod", allowlist)).toBe(true); - expect(isDmAllowed(" ~zod ", allowlist)).toBe(true); + await expectDmAllowed("~zod", allowlist, true); + await expectDmAllowed(" ~zod ", allowlist, true); + }); + + it("uses the ingress command gate for owner-only command authorization", async () => { + await expect( + resolveTlonCommandAuthorizationWithIngress({ + senderShip: "~zod", + ownerShip: "zod", + useAccessGroups: true, + }), + ).resolves.toMatchObject({ + commandAccess: { authorized: true }, + }); + await expect( + resolveTlonCommandAuthorizationWithIngress({ + senderShip: "~nec", + ownerShip: "~zod", + useAccessGroups: true, + }), + ).resolves.toMatchObject({ + commandAccess: { authorized: false }, + }); }); }); }); @@ -314,29 +352,29 @@ describe("Security: Channel Authorization Logic", () => { }); describe("Security: Authorization Edge Cases", () => { - it("empty strings are not valid ships", () => { - expect(isDmAllowed("", ["~zod"])).toBe(false); - expect(isDmAllowed("~zod", [""])).toBe(false); + it("empty strings are not valid ships", async () => { + await expectDmAllowed("", ["~zod"], false); + await expectDmAllowed("~zod", [""], false); }); - it("handles very long ship-like strings", () => { + it("handles very long ship-like strings", async () => { const longName = "~" + "a".repeat(1000); - expect(isDmAllowed(longName, ["~zod"])).toBe(false); + await expectDmAllowed(longName, ["~zod"], false); }); - it("handles special characters that could break regex", () => { + it("handles special characters that could break regex", async () => { // These should not cause regex injection const maliciousShip = "~zod.*"; - expect(isDmAllowed("~zodabc", [maliciousShip])).toBe(false); + await expectDmAllowed("~zodabc", [maliciousShip], false); const allowlist = ["~zod"]; - expect(isDmAllowed("~zod.*", allowlist)).toBe(false); + await expectDmAllowed("~zod.*", allowlist, false); }); - it("protects against prototype pollution-style keys", () => { + it("protects against prototype pollution-style keys", async () => { const suspiciousShip = "__proto__"; - expect(isDmAllowed(suspiciousShip, ["~zod"])).toBe(false); - expect(isDmAllowed("~zod", [suspiciousShip])).toBe(false); + await expectDmAllowed(suspiciousShip, ["~zod"], false); + await expectDmAllowed("~zod", [suspiciousShip], false); }); }); diff --git a/extensions/twitch/src/access-control.test.ts b/extensions/twitch/src/access-control.test.ts index 1313021cb8c..34ecd029304 100644 --- a/extensions/twitch/src/access-control.test.ts +++ b/extensions/twitch/src/access-control.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { checkTwitchAccessControl, extractMentions } from "./access-control.js"; +import { checkTwitchAccessControl } from "./access-control.js"; import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js"; describe("checkTwitchAccessControl", () => { @@ -34,11 +34,11 @@ describe("checkTwitchAccessControl", () => { }); } - function expectSingleRoleAllowed(params: { + async function expectSingleRoleAllowed(params: { role: NonNullable[number]; message: Partial; }) { - const result = runAccessCheck({ + const result = await runAccessCheck({ account: { allowedRoles: [params.role] }, message: { message: "@testbot hello", @@ -49,11 +49,11 @@ describe("checkTwitchAccessControl", () => { return result; } - function expectAllowedAccessCheck(params: { + async function expectAllowedAccessCheck(params: { account?: Partial; message?: Partial; }) { - const result = runAccessCheck({ + const result = await runAccessCheck({ account: params.account, message: { message: "@testbot hello", @@ -64,13 +64,13 @@ describe("checkTwitchAccessControl", () => { return result; } - function expectAllowFromBlocked(params: { + async function expectAllowFromBlocked(params: { allowFrom: string[]; allowedRoles?: NonNullable; message?: Partial; reason: string; }) { - const result = runAccessCheck({ + const result = await runAccessCheck({ account: { allowFrom: params.allowFrom, allowedRoles: params.allowedRoles, @@ -85,8 +85,8 @@ describe("checkTwitchAccessControl", () => { } describe("when no restrictions are configured", () => { - it("allows messages that mention the bot (default requireMention)", () => { - const result = runAccessCheck({ + it("allows messages that mention the bot (default requireMention)", async () => { + const result = await runAccessCheck({ message: { message: "@testbot hello", }, @@ -96,8 +96,8 @@ describe("checkTwitchAccessControl", () => { }); describe("requireMention default", () => { - it("defaults to true when undefined", () => { - const result = runAccessCheck({ + it("defaults to true when undefined", async () => { + const result = await runAccessCheck({ message: { message: "hello bot", }, @@ -106,8 +106,8 @@ describe("checkTwitchAccessControl", () => { expect(result.reason).toContain("does not mention the bot"); }); - it("allows mention when requireMention is undefined", () => { - const result = runAccessCheck({ + it("allows mention when requireMention is undefined", async () => { + const result = await runAccessCheck({ message: { message: "@testbot hello", }, @@ -117,24 +117,24 @@ describe("checkTwitchAccessControl", () => { }); describe("requireMention", () => { - it("allows messages that mention the bot", () => { - const result = runAccessCheck({ + it("allows messages that mention the bot", async () => { + const result = await runAccessCheck({ account: { requireMention: true }, message: { message: "@testbot hello" }, }); expect(result.allowed).toBe(true); }); - it("blocks messages that don't mention the bot", () => { - const result = runAccessCheck({ + it("blocks messages that don't mention the bot", async () => { + const result = await runAccessCheck({ account: { requireMention: true }, }); expect(result.allowed).toBe(false); expect(result.reason).toContain("does not mention the bot"); }); - it("is case-insensitive for bot username", () => { - const result = runAccessCheck({ + it("is case-insensitive for bot username", async () => { + const result = await runAccessCheck({ account: { requireMention: true }, message: { message: "@TestBot hello" }, }); @@ -143,8 +143,8 @@ describe("checkTwitchAccessControl", () => { }); describe("allowFrom allowlist", () => { - it("allows users in the allowlist", () => { - const result = expectAllowedAccessCheck({ + it("allows users in the allowlist", async () => { + const result = await expectAllowedAccessCheck({ account: { allowFrom: ["123456", "789012"], }, @@ -153,29 +153,29 @@ describe("checkTwitchAccessControl", () => { expect(result.matchSource).toBe("allowlist"); }); - it("blocks users not in allowlist when allowFrom is set", () => { - expectAllowFromBlocked({ + it("blocks users not in allowlist when allowFrom is set", async () => { + await expectAllowFromBlocked({ allowFrom: ["789012"], reason: "allowFrom", }); }); - it("blocks everyone when allowFrom is explicitly empty", () => { - expectAllowFromBlocked({ + it("blocks everyone when allowFrom is explicitly empty", async () => { + await expectAllowFromBlocked({ allowFrom: [], reason: "allowFrom", }); }); - it("blocks messages without userId", () => { - expectAllowFromBlocked({ + it("blocks messages without userId", async () => { + await expectAllowFromBlocked({ allowFrom: ["123456"], message: { userId: undefined }, reason: "user ID not available", }); }); - it("bypasses role checks when user is in allowlist", () => { + it("bypasses role checks when user is in allowlist", async () => { const account: TwitchAccountConfig = { ...mockAccount, allowFrom: ["123456"], @@ -187,7 +187,7 @@ describe("checkTwitchAccessControl", () => { isOwner: false, }; - const result = checkTwitchAccessControl({ + const result = await checkTwitchAccessControl({ message, account, botUsername: "testbot", @@ -195,8 +195,8 @@ describe("checkTwitchAccessControl", () => { expect(result.allowed).toBe(true); }); - it("blocks user with role when not in allowlist", () => { - expectAllowFromBlocked({ + it("blocks user with role when not in allowlist", async () => { + await expectAllowFromBlocked({ allowFrom: ["789012"], allowedRoles: ["moderator"], message: { userId: "123456", isMod: true }, @@ -204,8 +204,8 @@ describe("checkTwitchAccessControl", () => { }); }); - it("blocks user not in allowlist even when roles configured", () => { - expectAllowFromBlocked({ + it("blocks user not in allowlist even when roles configured", async () => { + await expectAllowFromBlocked({ allowFrom: ["789012"], allowedRoles: ["moderator"], message: { userId: "123456", isMod: false }, @@ -215,15 +215,15 @@ describe("checkTwitchAccessControl", () => { }); describe("allowedRoles", () => { - it("allows users with matching role", () => { - const result = expectSingleRoleAllowed({ + it("allows users with matching role", async () => { + const result = await expectSingleRoleAllowed({ role: "moderator", message: { isMod: true }, }); expect(result.matchSource).toBe("role"); }); - it("allows users with any of multiple roles", () => { + it("allows users with any of multiple roles", async () => { const account: TwitchAccountConfig = { ...mockAccount, allowedRoles: ["moderator", "vip", "subscriber"], @@ -236,7 +236,7 @@ describe("checkTwitchAccessControl", () => { isSub: false, }; - const result = checkTwitchAccessControl({ + const result = await checkTwitchAccessControl({ message, account, botUsername: "testbot", @@ -244,7 +244,7 @@ describe("checkTwitchAccessControl", () => { expect(result.allowed).toBe(true); }); - it("blocks users without matching role", () => { + it("blocks users without matching role", async () => { const account: TwitchAccountConfig = { ...mockAccount, allowedRoles: ["moderator"], @@ -255,7 +255,7 @@ describe("checkTwitchAccessControl", () => { isMod: false, }; - const result = checkTwitchAccessControl({ + const result = await checkTwitchAccessControl({ message, account, botUsername: "testbot", @@ -264,8 +264,8 @@ describe("checkTwitchAccessControl", () => { expect(result.reason).toContain("does not have any of the required roles"); }); - it("allows all users when role is 'all'", () => { - const result = expectAllowedAccessCheck({ + it("allows all users when role is 'all'", async () => { + const result = await expectAllowedAccessCheck({ account: { allowedRoles: ["all"], }, @@ -273,29 +273,29 @@ describe("checkTwitchAccessControl", () => { expect(result.matchKey).toBe("all"); }); - it("handles moderator role", () => { - expectSingleRoleAllowed({ + it("handles moderator role", async () => { + await expectSingleRoleAllowed({ role: "moderator", message: { isMod: true }, }); }); - it("handles subscriber role", () => { - expectSingleRoleAllowed({ + it("handles subscriber role", async () => { + await expectSingleRoleAllowed({ role: "subscriber", message: { isSub: true }, }); }); - it("handles owner role", () => { - expectSingleRoleAllowed({ + it("handles owner role", async () => { + await expectSingleRoleAllowed({ role: "owner", message: { isOwner: true }, }); }); - it("handles vip role", () => { - expectSingleRoleAllowed({ + it("handles vip role", async () => { + await expectSingleRoleAllowed({ role: "vip", message: { isVip: true }, }); @@ -303,7 +303,7 @@ describe("checkTwitchAccessControl", () => { }); describe("combined restrictions", () => { - it("checks requireMention before allowlist", () => { + it("checks requireMention before allowlist", async () => { const account: TwitchAccountConfig = { ...mockAccount, requireMention: true, @@ -314,7 +314,7 @@ describe("checkTwitchAccessControl", () => { message: "hello", // No mention }; - const result = checkTwitchAccessControl({ + const result = await checkTwitchAccessControl({ message, account, botUsername: "testbot", @@ -323,8 +323,40 @@ describe("checkTwitchAccessControl", () => { expect(result.reason).toContain("does not mention the bot"); }); - it("checks allowlist before allowedRoles", () => { - const result = runAccessCheck({ + it("checks requireMention before sender allowlists for unauthorized chat", async () => { + const result = await runAccessCheck({ + account: { + requireMention: true, + allowFrom: ["789012"], + }, + message: { + message: "ordinary chat", + userId: "123456", + }, + }); + + expect(result.allowed).toBe(false); + expect(result.reason).toContain("does not mention the bot"); + }); + + it("checks requireMention before role gates for unauthorized chat", async () => { + const result = await runAccessCheck({ + account: { + requireMention: true, + allowedRoles: ["moderator"], + }, + message: { + message: "ordinary chat", + isMod: false, + }, + }); + + expect(result.allowed).toBe(false); + expect(result.reason).toContain("does not mention the bot"); + }); + + it("checks allowlist before allowedRoles", async () => { + const result = await runAccessCheck({ account: { allowFrom: ["123456"], allowedRoles: ["owner"], @@ -339,50 +371,3 @@ describe("checkTwitchAccessControl", () => { }); }); }); - -describe("extractMentions", () => { - it("extracts single mention", () => { - const mentions = extractMentions("hello @testbot"); - expect(mentions).toEqual(["testbot"]); - }); - - it("extracts multiple mentions", () => { - const mentions = extractMentions("hello @testbot and @otheruser"); - expect(mentions).toEqual(["testbot", "otheruser"]); - }); - - it("returns empty array when no mentions", () => { - const mentions = extractMentions("hello everyone"); - expect(mentions).toStrictEqual([]); - }); - - it("handles mentions at start of message", () => { - const mentions = extractMentions("@testbot hello"); - expect(mentions).toEqual(["testbot"]); - }); - - it("handles mentions at end of message", () => { - const mentions = extractMentions("hello @testbot"); - expect(mentions).toEqual(["testbot"]); - }); - - it("converts mentions to lowercase", () => { - const mentions = extractMentions("hello @TestBot"); - expect(mentions).toEqual(["testbot"]); - }); - - it("extracts alphanumeric usernames", () => { - const mentions = extractMentions("hello @user123"); - expect(mentions).toEqual(["user123"]); - }); - - it("handles underscores in usernames", () => { - const mentions = extractMentions("hello @test_user"); - expect(mentions).toEqual(["test_user"]); - }); - - it("handles empty string", () => { - const mentions = extractMentions(""); - expect(mentions).toStrictEqual([]); - }); -}); diff --git a/extensions/twitch/src/access-control.ts b/extensions/twitch/src/access-control.ts index 4444461d366..decc0c63f5e 100644 --- a/extensions/twitch/src/access-control.ts +++ b/extensions/twitch/src/access-control.ts @@ -1,9 +1,12 @@ +import { + createChannelIngressResolver, + defineStableChannelIngressIdentity, + type ChannelIngressIdentitySubjectInput, + type IngressReasonCode, +} from "openclaw/plugin-sdk/channel-ingress-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js"; -/** - * Result of checking access control for a Twitch message - */ type TwitchAccessControlResult = { allowed: boolean; reason?: string; @@ -11,163 +14,182 @@ type TwitchAccessControlResult = { matchSource?: string; }; -/** - * Check if a Twitch message should be allowed based on account configuration - * - * This function implements the access control logic for incoming Twitch messages, - * checking allowlists, role-based restrictions, and mention requirements. - * - * Priority order: - * 1. If `requireMention` is true, message must mention the bot - * 2. If `allowFrom` is set, sender must be in the allowlist (by user ID) - * 3. If `allowedRoles` is set (and `allowFrom` is not), sender must have at least one role - * - * Note: `allowFrom` is a hard allowlist. When set, only those user IDs are allowed. - * Use `allowedRoles` as an alternative when you don't want to maintain an allowlist. - * - * Available roles: - * - "moderator": Moderators - * - "owner": Channel owner/broadcaster - * - "vip": VIPs - * - "subscriber": Subscribers - * - "all": Anyone in the chat - */ -export function checkTwitchAccessControl(params: { +type TwitchPolicyKind = "open" | "allowFrom" | "role"; + +const twitchUserIdentity = defineStableChannelIngressIdentity({ + key: "sender-id", + entryIdPrefix: "twitch-user-entry", +}); + +const twitchRoleIdentity = defineStableChannelIngressIdentity({ + key: "role-moderator", + kind: "role", + normalizeEntry: normalizeTwitchRole, + normalizeSubject: normalizeTwitchRole, + aliases: ["owner", "vip", "subscriber"].map((role) => ({ + key: `role-${role}`, + kind: "role", + normalizeEntry: () => null, + normalizeSubject: normalizeTwitchRole, + })), + isWildcardEntry: (entry) => normalizeTwitchRole(entry) === "all", + resolveEntryId: ({ entryIndex }) => `twitch-role-entry-${entryIndex + 1}`, +}); + +export async function checkTwitchAccessControl(params: { message: TwitchChatMessage; account: TwitchAccountConfig; botUsername: string; -}): TwitchAccessControlResult { +}): Promise { const { message, account, botUsername } = params; + const policyKind = resolveTwitchPolicyKind(account); + const resolved = await createChannelIngressResolver({ + channelId: "twitch", + accountId: "default", + identity: policyKind === "role" ? twitchRoleIdentity : twitchUserIdentity, + }).message({ + subject: + policyKind === "role" + ? twitchRoleSubject(message) + : ({ stableId: message.userId } satisfies ChannelIngressIdentitySubjectInput), + conversation: { + kind: "group", + id: message.channel, + }, + event: { mayPair: false }, + mentionFacts: { + canDetectMention: true, + wasMentioned: mentionsBot(message.message, botUsername), + }, + dmPolicy: "open", + groupPolicy: policyKind === "open" ? "open" : "allowlist", + policy: { + activation: { + requireMention: account.requireMention ?? true, + allowTextCommands: false, + order: "before-sender", + }, + }, + groupAllowFrom: + policyKind === "allowFrom" + ? account.allowFrom + : policyKind === "role" + ? account.allowedRoles + : undefined, + }); + const decision = resolved.ingress; - if (account.requireMention ?? true) { - const mentions = extractMentions(message.message); - if (!mentions.includes(normalizeLowercaseStringOrEmpty(botUsername))) { - return { - allowed: false, - reason: "message does not mention the bot (requireMention is enabled)", - }; - } + if (decision.decisiveGateId === "activation" && decision.admission !== "dispatch") { + return { + allowed: false, + reason: "message does not mention the bot (requireMention is enabled)", + }; } - if (account.allowFrom !== undefined) { - const allowFrom = account.allowFrom; - if (allowFrom.length === 0) { + if (decision.admission === "dispatch") { + if (policyKind === "allowFrom") { return { - allowed: false, - reason: "sender is not in allowFrom allowlist", + allowed: true, + matchKey: params.message.userId, + matchSource: "allowlist", }; } - const senderId = message.userId; + if (policyKind === "role") { + return { + allowed: true, + matchKey: params.account.allowedRoles?.join(","), + matchSource: "role", + }; + } + return { + allowed: true, + }; + } - if (!senderId) { + if (policyKind === "allowFrom") { + if (!params.message.userId) { return { allowed: false, reason: "sender user ID not available for allowlist check", }; } - - if (allowFrom.includes(senderId)) { - return { - allowed: true, - matchKey: senderId, - matchSource: "allowlist", - }; - } - return { allowed: false, reason: "sender is not in allowFrom allowlist", }; } - if (account.allowedRoles && account.allowedRoles.length > 0) { - const allowedRoles = account.allowedRoles; - - // "all" grants access to everyone - if (allowedRoles.includes("all")) { - return { - allowed: true, - matchKey: "all", - matchSource: "role", - }; - } - - const hasAllowedRole = checkSenderRoles({ - message, - allowedRoles, - }); - - if (!hasAllowedRole) { - return { - allowed: false, - reason: `sender does not have any of the required roles: ${allowedRoles.join(", ")}`, - }; - } - + if (policyKind === "role") { return { - allowed: true, - matchKey: allowedRoles.join(","), - matchSource: "role", + allowed: false, + reason: `sender does not have any of the required roles: ${params.account.allowedRoles?.join(", ") ?? ""}`, }; } return { - allowed: true, + allowed: false, + reason: reasonForTwitchIngressDecision(decision), }; } -/** - * Check if the sender has any of the allowed roles - */ -function checkSenderRoles(params: { message: TwitchChatMessage; allowedRoles: string[] }): boolean { - const { message, allowedRoles } = params; - const { isMod, isOwner, isVip, isSub } = message; +function resolveTwitchPolicyKind(account: TwitchAccountConfig): TwitchPolicyKind { + if (account.allowFrom !== undefined) { + return "allowFrom"; + } + if (account.allowedRoles && account.allowedRoles.length > 0) { + return "role"; + } + return "open"; +} - for (const role of allowedRoles) { - switch (role) { - case "moderator": - if (isMod) { - return true; - } - break; - case "owner": - if (isOwner) { - return true; - } - break; - case "vip": - if (isVip) { - return true; - } - break; - case "subscriber": - if (isSub) { - return true; - } - break; +function twitchRoleSubject(message: TwitchChatMessage): ChannelIngressIdentitySubjectInput { + return { + stableId: message.isMod ? "moderator" : undefined, + aliases: { + "role-owner": message.isOwner ? "owner" : undefined, + "role-vip": message.isVip ? "vip" : undefined, + "role-subscriber": message.isSub ? "subscriber" : undefined, + }, + }; +} + +function normalizeTwitchRole(value: string): string | null { + const role = normalizeLowercaseStringOrEmpty(value); + if (role === "*") { + return "all"; + } + return role === "moderator" || + role === "owner" || + role === "vip" || + role === "subscriber" || + role === "all" + ? role + : null; +} + +function reasonForTwitchIngressDecision(decision: { reasonCode: IngressReasonCode }): string { + switch (decision.reasonCode) { + case "activation_skipped": + return "message does not mention the bot (requireMention is enabled)"; + case "group_policy_empty_allowlist": + case "group_policy_not_allowlisted": + return "sender is not in allowFrom allowlist"; + default: + return decision.reasonCode; + } +} + +function mentionsBot(message: string, botUsername: string): boolean { + const expected = normalizeLowercaseStringOrEmpty(botUsername); + const mentionRegex = /@(\w+)/g; + let match: RegExpExecArray | null; + + while ((match = mentionRegex.exec(message)) !== null) { + const username = match[1] ? normalizeLowercaseStringOrEmpty(match[1]) : ""; + if (username === expected) { + return true; } } return false; } - -/** - * Extract @mentions from a Twitch chat message - * - * Returns a list of lowercase usernames that were mentioned in the message. - * Twitch mentions are in the format @username. - */ -export function extractMentions(message: string): string[] { - const mentionRegex = /@(\w+)/g; - const mentions: string[] = []; - let match: RegExpExecArray | null; - - while ((match = mentionRegex.exec(message)) !== null) { - const username = match[1]; - if (username) { - mentions.push(normalizeLowercaseStringOrEmpty(username)); - } - } - - return mentions; -} diff --git a/extensions/twitch/src/monitor.ts b/extensions/twitch/src/monitor.ts index 683e24a8e24..9925b8883b4 100644 --- a/extensions/twitch/src/monitor.ts +++ b/extensions/twitch/src/monitor.ts @@ -268,34 +268,34 @@ export async function monitorTwitchProvider( return; } - // Access control check - const botUsername = normalizeLowercaseStringOrEmpty(account.username); - if (normalizeLowercaseStringOrEmpty(message.username) === botUsername) { - return; // Ignore own messages - } + void (async () => { + const botUsername = normalizeLowercaseStringOrEmpty(account.username); + if (normalizeLowercaseStringOrEmpty(message.username) === botUsername) { + return; + } - const access = checkTwitchAccessControl({ - message, - account, - botUsername, - }); + const access = await checkTwitchAccessControl({ + message, + account, + botUsername, + }); - if (!access.allowed) { - return; - } + if (stopped || !access.allowed) { + return; + } - statusSink?.({ lastInboundAt: Date.now() }); + statusSink?.({ lastInboundAt: Date.now() }); - // Fire-and-forget: process message without blocking - void processTwitchMessage({ - message, - account, - accountId, - config, - runtime, - core, - statusSink, - }).catch((err) => { + await processTwitchMessage({ + message, + account, + accountId, + config, + runtime, + core, + statusSink, + }); + })().catch((err) => { runtime.error?.(`Message processing failed: ${String(err)}`); }); }); diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts index 50065f53925..eeb7995af6e 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts @@ -161,10 +161,7 @@ function makePolicy(account: ReturnType) { groupAllowFrom: [], isSelfChat: false, providerMissingFallbackApplied: false, - shouldReadStorePairingApprovals: true, isSamePhone: () => false, - isDmSenderAllowed: () => false, - isGroupSenderAllowed: () => false, resolveConversationGroupPolicy: () => "allowlist", resolveConversationRequireMention: () => false, }; diff --git a/extensions/whatsapp/src/inbound-policy.ts b/extensions/whatsapp/src/inbound-policy.ts index acc166e4d94..eb308006df1 100644 --- a/extensions/whatsapp/src/inbound-policy.ts +++ b/extensions/whatsapp/src/inbound-policy.ts @@ -1,3 +1,4 @@ +import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime"; import { resolveChannelGroupPolicy, resolveChannelGroupRequireMention, @@ -9,12 +10,6 @@ import type { OpenClawConfig, } from "openclaw/plugin-sdk/config-types"; import { resolveDefaultGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy"; -import { - expandAllowFromWithAccessGroups, - readStoreAllowFromForDmPolicy, - resolveEffectiveAllowFromLists, - resolveDmGroupAccessWithCommandGate, -} from "openclaw/plugin-sdk/security-runtime"; import { resolveGroupSessionKey } from "openclaw/plugin-sdk/session-store-runtime"; import { resolveWhatsAppAccount, type ResolvedWhatsAppAccount } from "./accounts.js"; import { getSelfIdentity, getSenderIdentity } from "./identity.js"; @@ -31,14 +26,19 @@ export type ResolvedWhatsAppInboundPolicy = { groupAllowFrom: string[]; isSelfChat: boolean; providerMissingFallbackApplied: boolean; - shouldReadStorePairingApprovals: boolean; isSamePhone: (value?: string | null) => boolean; - isDmSenderAllowed: (allowEntries: string[], sender?: string | null) => boolean; - isGroupSenderAllowed: (allowEntries: string[], sender?: string | null) => boolean; resolveConversationGroupPolicy: (conversationId: string) => ChannelGroupPolicy; resolveConversationRequireMention: (conversationId: string) => boolean; }; +function normalizeWhatsAppIngressPhone(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + return normalizeE164(trimmed); +} + function resolveGroupConversationId(conversationId: string): string { return ( resolveGroupSessionKey({ @@ -49,20 +49,15 @@ function resolveGroupConversationId(conversationId: string): string { ); } -function isNormalizedSenderAllowed(allowEntries: string[], sender?: string | null): boolean { - if (allowEntries.includes("*")) { - return true; +function maybeSamePhoneDmAllowFrom(params: { + isGroup: boolean; + policy: ResolvedWhatsAppInboundPolicy; + dmSenderId?: string | null; +}): string[] { + if (params.isGroup || !params.dmSenderId || !params.policy.isSamePhone(params.dmSenderId)) { + return []; } - const normalizedSender = normalizeE164(sender ?? ""); - if (!normalizedSender) { - return false; - } - const normalizedEntrySet = new Set( - allowEntries - .map((entry) => normalizeE164(entry)) - .filter((entry): entry is string => Boolean(entry)), - ); - return normalizedEntrySet.has(normalizedSender); + return [params.dmSenderId]; } function buildResolvedWhatsAppGroupConfig(params: { @@ -92,14 +87,14 @@ export function resolveWhatsAppInboundPolicy(params: { const dmPolicy = account.dmPolicy ?? "pairing"; const dmAllowFrom = configuredAllowFrom.length > 0 ? configuredAllowFrom : params.selfE164 ? [params.selfE164] : []; + const configuredGroupAllowFrom = + Array.isArray(account.groupAllowFrom) && account.groupAllowFrom.length > 0 + ? account.groupAllowFrom + : undefined; const groupAllowFrom = - account.groupAllowFrom ?? + configuredGroupAllowFrom ?? (configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined) ?? []; - const { effectiveGroupAllowFrom } = resolveEffectiveAllowFromLists({ - allowFrom: configuredAllowFrom, - groupAllowFrom, - }); const defaultGroupPolicy = resolveDefaultGroupPolicy(params.cfg); const { groupPolicy, providerMissingFallbackApplied } = resolveWhatsAppRuntimeGroupPolicy({ providerConfigPresent: params.cfg.channels?.whatsapp !== undefined, @@ -121,17 +116,13 @@ export function resolveWhatsAppInboundPolicy(params: { groupAllowFrom, isSelfChat: account.selfChatMode ?? isSelfChatMode(params.selfE164, configuredAllowFrom), providerMissingFallbackApplied, - shouldReadStorePairingApprovals: dmPolicy !== "allowlist", isSamePhone, - isDmSenderAllowed: (allowEntries, sender) => - isSamePhone(sender) || isNormalizedSenderAllowed(allowEntries, sender), - isGroupSenderAllowed: (allowEntries, sender) => isNormalizedSenderAllowed(allowEntries, sender), resolveConversationGroupPolicy: (conversationId) => resolveChannelGroupPolicy({ cfg: resolvedGroupCfg, channel: "whatsapp", groupId: resolveGroupConversationId(conversationId), - hasGroupAllowFrom: effectiveGroupAllowFrom.length > 0, + hasGroupAllowFrom: groupAllowFrom.length > 0, }), resolveConversationRequireMention: (conversationId) => resolveChannelGroupRequireMention({ @@ -142,6 +133,49 @@ export function resolveWhatsAppInboundPolicy(params: { }; } +export async function resolveWhatsAppIngressAccess(params: { + cfg: OpenClawConfig; + policy: ResolvedWhatsAppInboundPolicy; + isGroup: boolean; + conversationId: string; + senderId?: string | null; + dmSenderId?: string | null; + includeCommand?: boolean; +}) { + const samePhoneDmAllowFrom = maybeSamePhoneDmAllowFrom({ + isGroup: params.isGroup, + policy: params.policy, + dmSenderId: params.dmSenderId, + }); + const dmAllowFrom = [...params.policy.dmAllowFrom, ...samePhoneDmAllowFrom]; + return await resolveStableChannelMessageIngress({ + channelId: "whatsapp", + accountId: params.policy.account.accountId, + identity: { + key: "whatsapp-sender-phone", + kind: "phone", + normalize: normalizeWhatsAppIngressPhone, + sensitivity: "pii", + entryIdPrefix: "whatsapp-entry", + }, + cfg: params.cfg, + useDefaultPairingStore: true, + subject: { stableId: params.senderId ?? "" }, + conversation: { + kind: params.isGroup ? "group" : "direct", + id: params.conversationId, + }, + dmPolicy: params.policy.dmPolicy, + groupPolicy: params.policy.groupPolicy, + policy: { + groupAllowFromFallbackToAllowFrom: false, + }, + allowFrom: dmAllowFrom, + groupAllowFrom: params.policy.groupAllowFrom, + command: params.includeCommand === true ? {} : undefined, + }); +} + export async function resolveWhatsAppCommandAuthorized(params: { cfg: OpenClawConfig; msg: WebInboundMessage; @@ -164,68 +198,18 @@ export async function resolveWhatsAppCommandAuthorized(params: { const sender = getSenderIdentity(params.msg); const dmSender = sender.e164 ?? params.msg.from ?? ""; const groupSender = sender.e164 ?? ""; - const normalizedSender = normalizeE164(isGroup ? groupSender : dmSender); - if (!normalizedSender) { + if (!normalizeE164(isGroup ? groupSender : dmSender)) { return false; } - const storeAllowFrom = - isGroup || !policy.shouldReadStorePairingApprovals - ? [] - : await readStoreAllowFromForDmPolicy({ - provider: "whatsapp", - accountId: policy.account.accountId, - dmPolicy: policy.dmPolicy, - shouldRead: policy.shouldReadStorePairingApprovals, - }); - const isSenderAllowed = (senderId: string, allowEntries: string[]) => - isGroup - ? policy.isGroupSenderAllowed(allowEntries, senderId) - : policy.isDmSenderAllowed(allowEntries, senderId); - const [allowFrom, groupAllowFrom] = await Promise.all([ - expandAllowFromWithAccessGroups({ - cfg: params.cfg, - allowFrom: policy.dmAllowFrom, - channel: "whatsapp", - accountId: policy.account.accountId, - senderId: normalizedSender, - isSenderAllowed, - }), - expandAllowFromWithAccessGroups({ - cfg: params.cfg, - allowFrom: policy.groupAllowFrom, - channel: "whatsapp", - accountId: policy.account.accountId, - senderId: normalizedSender, - isSenderAllowed, - }), - ]); - const dmStoreAllowFrom = isGroup - ? [] - : await expandAllowFromWithAccessGroups({ - cfg: params.cfg, - allowFrom: storeAllowFrom, - channel: "whatsapp", - accountId: policy.account.accountId, - senderId: normalizedSender, - isSenderAllowed, - }); - const access = resolveDmGroupAccessWithCommandGate({ + const access = await resolveWhatsAppIngressAccess({ + cfg: params.cfg, + policy, isGroup, - dmPolicy: policy.dmPolicy, - groupPolicy: policy.groupPolicy, - allowFrom, - groupAllowFrom, - storeAllowFrom: dmStoreAllowFrom, - isSenderAllowed: (allowEntries) => - isGroup - ? policy.isGroupSenderAllowed(allowEntries, groupSender) - : policy.isDmSenderAllowed(allowEntries, dmSender), - command: { - useAccessGroups, - allowTextCommands: true, - hasControlCommand: true, - }, + conversationId: params.msg.conversationId ?? params.msg.chatId ?? params.msg.from, + senderId: isGroup ? groupSender : dmSender, + dmSenderId: dmSender, + includeCommand: true, }); - return access.commandAuthorized; + return access.commandAccess.authorized; } diff --git a/extensions/whatsapp/src/inbound/access-control.test.ts b/extensions/whatsapp/src/inbound/access-control.test.ts index d3a9ed7cbab..6abd6771b94 100644 --- a/extensions/whatsapp/src/inbound/access-control.test.ts +++ b/extensions/whatsapp/src/inbound/access-control.test.ts @@ -317,6 +317,42 @@ describe("WhatsApp dmPolicy precedence", () => { expect(sendMessageMock).not.toHaveBeenCalled(); }); + it("falls back from empty groupAllowFrom to allowFrom for group allowlists", async () => { + const cfg = { + channels: { + whatsapp: { + dmPolicy: "allowlist", + groupPolicy: "allowlist", + allowFrom: ["+15550001111"], + groupAllowFrom: [], + }, + }, + }; + setAccessControlTestConfig(cfg); + + const result = await checkInboundAccessControl({ + cfg: getAccessControlTestConfig() as never, + accountId: "default", + from: "120363401234567890@g.us", + selfE164: "+15550009999", + senderE164: "+15550001111", + group: true, + pushName: "Sam", + isFromMe: false, + sock: { sendMessage: sendMessageMock }, + remoteJid: "120363401234567890@g.us", + }); + const commandAuthorized = await checkCommandAuthorizedForGroup({ + cfg, + accountId: "default", + }); + + expect(result.allowed).toBe(true); + expect(commandAuthorized).toBe(true); + expect(upsertPairingRequestMock).not.toHaveBeenCalled(); + expect(sendMessageMock).not.toHaveBeenCalled(); + }); + it("does not broaden self-chat mode to every paired DM when allowFrom is empty", async () => { const cfg = { channels: { diff --git a/extensions/whatsapp/src/inbound/access-control.ts b/extensions/whatsapp/src/inbound/access-control.ts index 1a06341c34e..f2c1ca33504 100644 --- a/extensions/whatsapp/src/inbound/access-control.ts +++ b/extensions/whatsapp/src/inbound/access-control.ts @@ -3,12 +3,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime"; import { defaultRuntime } from "openclaw/plugin-sdk/runtime-env"; import { warnMissingProviderGroupPolicyFallbackOnce } from "openclaw/plugin-sdk/runtime-group-policy"; -import { - expandAllowFromWithAccessGroups, - readStoreAllowFromForDmPolicy, - resolveDmGroupAccessWithLists, -} from "openclaw/plugin-sdk/security-runtime"; -import { resolveWhatsAppInboundPolicy } from "../inbound-policy.js"; +import { resolveWhatsAppInboundPolicy, resolveWhatsAppIngressAccess } from "../inbound-policy.js"; export type InboundAccessControlResult = { allowed: boolean; @@ -49,14 +44,6 @@ export async function checkInboundAccessControl(params: { accountId: params.accountId, selfE164: params.selfE164, }); - const storeAllowFrom = params.group - ? [] - : await readStoreAllowFromForDmPolicy({ - provider: "whatsapp", - accountId: policy.account.accountId, - dmPolicy: policy.dmPolicy, - shouldRead: policy.shouldReadStorePairingApprovals, - }); const pairingGraceMs = typeof params.pairingGraceMs === "number" && params.pairingGraceMs > 0 ? params.pairingGraceMs @@ -76,57 +63,19 @@ export async function checkInboundAccessControl(params: { accountId: policy.account.accountId, log: (message) => logWhatsAppVerbose(params.verbose, message), }); - const accessGroupSenderId = params.group ? (params.senderE164 ?? params.from) : params.from; - const isAccessGroupSenderAllowed = (senderId: string, allowEntries: string[]) => { - return params.group - ? policy.isGroupSenderAllowed(allowEntries, senderId) - : policy.isDmSenderAllowed(allowEntries, senderId); - }; - const [allowFrom, groupAllowFrom] = await Promise.all([ - expandAllowFromWithAccessGroups({ - cfg: params.cfg, - allowFrom: params.group ? policy.configuredAllowFrom : policy.dmAllowFrom, - channel: "whatsapp", - accountId: policy.account.accountId, - senderId: accessGroupSenderId, - isSenderAllowed: isAccessGroupSenderAllowed, - }), - expandAllowFromWithAccessGroups({ - cfg: params.cfg, - allowFrom: policy.groupAllowFrom, - channel: "whatsapp", - accountId: policy.account.accountId, - senderId: accessGroupSenderId, - isSenderAllowed: isAccessGroupSenderAllowed, - }), - ]); - const dmStoreAllowFrom = params.group - ? [] - : await expandAllowFromWithAccessGroups({ - cfg: params.cfg, - allowFrom: storeAllowFrom, - channel: "whatsapp", - accountId: policy.account.accountId, - senderId: accessGroupSenderId, - isSenderAllowed: isAccessGroupSenderAllowed, - }); - const access = resolveDmGroupAccessWithLists({ + const access = await resolveWhatsAppIngressAccess({ + cfg: params.cfg, + policy, isGroup: params.group, - dmPolicy: policy.dmPolicy, - groupPolicy: policy.groupPolicy, - allowFrom, - groupAllowFrom, - storeAllowFrom: dmStoreAllowFrom, - isSenderAllowed: (allowEntries) => { - return params.group - ? policy.isGroupSenderAllowed(allowEntries, params.senderE164) - : policy.isDmSenderAllowed(allowEntries, params.from); - }, + conversationId: params.remoteJid, + senderId: params.group ? params.senderE164 : params.from, + dmSenderId: params.from, }); - if (params.group && access.decision !== "allow") { - if (access.reason === "groupPolicy=disabled") { + const { senderAccess } = access; + if (params.group && senderAccess.decision !== "allow") { + if (senderAccess.reasonCode === "group_policy_disabled") { logWhatsAppVerbose(params.verbose, "Blocked group message (groupPolicy: disabled)"); - } else if (access.reason === "groupPolicy=allowlist (empty allowlist)") { + } else if (senderAccess.reasonCode === "group_policy_empty_allowlist") { logWhatsAppVerbose( params.verbose, "Blocked group message (groupPolicy: allowlist, no groupAllowFrom)", @@ -156,7 +105,7 @@ export async function checkInboundAccessControl(params: { resolvedAccountId: policy.account.accountId, }; } - if (access.decision === "block" && access.reason === "dmPolicy=disabled") { + if (senderAccess.decision === "block" && senderAccess.reasonCode === "dm_policy_disabled") { logWhatsAppVerbose(params.verbose, "Blocked dm (dmPolicy: disabled)"); return { allowed: false, @@ -165,7 +114,7 @@ export async function checkInboundAccessControl(params: { resolvedAccountId: policy.account.accountId, }; } - if (access.decision === "pairing" && !policy.isSamePhone(params.from)) { + if (senderAccess.decision === "pairing" && !policy.isSamePhone(params.from)) { const candidate = params.from; if (suppressPairingReply) { logWhatsAppVerbose( @@ -210,7 +159,7 @@ export async function checkInboundAccessControl(params: { resolvedAccountId: policy.account.accountId, }; } - if (access.decision !== "allow") { + if (senderAccess.decision !== "allow") { logWhatsAppVerbose( params.verbose, `Blocked unauthorized sender ${params.from} (dmPolicy=${policy.dmPolicy})`, diff --git a/extensions/whatsapp/src/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test-support.ts b/extensions/whatsapp/src/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test-support.ts index 16cf52798f3..d279ddbb7ae 100644 --- a/extensions/whatsapp/src/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test-support.ts +++ b/extensions/whatsapp/src/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test-support.ts @@ -9,6 +9,7 @@ import { installWebMonitorInboxUnitTestHooks, mockLoadConfig, settleInboundWork, + waitForMessageCalls, } from "./monitor-inbox.test-harness.js"; const nowSeconds = (offsetMs = 0) => Math.floor((Date.now() + offsetMs) / 1000); @@ -110,13 +111,12 @@ describe("web monitor inbox", () => { }), ), ); - await settleInboundWork(); + await vi.waitFor(() => expectPairingPromptSent(sock, "999@s.whatsapp.net", "+999")); // Should NOT call onMessage for unauthorized senders expect(onMessage).not.toHaveBeenCalled(); // Should NOT send read receipts for blocked senders (privacy + avoids Baileys Bad MAC churn). expect(sock.readMessages).not.toHaveBeenCalled(); - expectPairingPromptSent(sock, "999@s.whatsapp.net", "+999"); await listener.close(); }); @@ -146,7 +146,7 @@ describe("web monitor inbox", () => { }), ), ); - await settleInboundWork(); + await waitForMessageCalls(onMessage, 1); expect(onMessage).toHaveBeenCalledTimes(1); expect(onMessage).toHaveBeenCalledWith( @@ -176,7 +176,7 @@ describe("web monitor inbox", () => { }), ), ); - await settleInboundWork(); + await waitForMessageCalls(onMessage, 1); expect(onMessage).toHaveBeenCalledTimes(1); expect(sock.readMessages).not.toHaveBeenCalled(); diff --git a/extensions/zalo/api.ts b/extensions/zalo/api.ts index e1110f928ff..de22c3b17a2 100644 --- a/extensions/zalo/api.ts +++ b/extensions/zalo/api.ts @@ -1,7 +1,6 @@ export { zaloPlugin } from "./src/channel.js"; export { createZaloSetupWizardProxy, - evaluateZaloGroupAccess, resolveZaloRuntimeGroupPolicy, zaloDmPolicy, zaloSetupAdapter, diff --git a/extensions/zalo/contract-api.ts b/extensions/zalo/contract-api.ts index c2b3f52e5e4..0dc98ccd3df 100644 --- a/extensions/zalo/contract-api.ts +++ b/extensions/zalo/contract-api.ts @@ -1,4 +1,4 @@ -export { evaluateZaloGroupAccess, resolveZaloRuntimeGroupPolicy } from "./src/group-access.js"; +export { resolveZaloRuntimeGroupPolicy } from "./src/group-access.js"; export { collectRuntimeConfigAssignments, secretTargetRegistryEntries, diff --git a/extensions/zalo/runtime-api.ts b/extensions/zalo/runtime-api.ts index 0e574838b63..c9278509b7f 100644 --- a/extensions/zalo/runtime-api.ts +++ b/extensions/zalo/runtime-api.ts @@ -23,7 +23,6 @@ export { createWebhookAnomalyTracker, DEFAULT_ACCOUNT_ID, deliverTextOrMediaReply, - evaluateSenderGroupAccess, formatAllowFromLowercase, formatPairingApproveHint, type GroupPolicy, @@ -53,16 +52,13 @@ export { type ReplyPayload, resolveClientIp, resolveDefaultGroupPolicy, - resolveDirectDmAuthorizationOutcome, resolveInboundRouteEnvelopeBuilderWithRuntime, resolveOpenProviderRuntimeGroupPolicy, - resolveSenderCommandAuthorizationWithRuntime, resolveWebhookPath, resolveWebhookTargetWithAuthOrRejectSync, runSingleChannelSecretStep, type RuntimeEnv, type SecretInput, - type SenderGroupAccessDecision, sendPayloadWithChunkedTextAndMedia, setTopLevelChannelDmPolicyWithAllowFrom, setZaloRuntime, diff --git a/extensions/zalo/setup-api.ts b/extensions/zalo/setup-api.ts index 4291676bb6d..7f1804ad2fe 100644 --- a/extensions/zalo/setup-api.ts +++ b/extensions/zalo/setup-api.ts @@ -27,7 +27,7 @@ function loadSetupSurfaceModule(): SetupSurfaceModule { } export { zaloDmPolicy, zaloSetupAdapter, createZaloSetupWizardProxy } from "./src/setup-core.js"; -export { evaluateZaloGroupAccess, resolveZaloRuntimeGroupPolicy } from "./src/group-access.js"; +export { resolveZaloRuntimeGroupPolicy } from "./src/group-access.js"; export const zaloSetupWizard: SetupSurfaceModule["zaloSetupWizard"] = createLazyObjectValue( () => loadSetupSurfaceModule().zaloSetupWizard as object, diff --git a/extensions/zalo/src/group-access.ts b/extensions/zalo/src/group-access.ts index 5b317ad70de..b8a8e8794c8 100644 --- a/extensions/zalo/src/group-access.ts +++ b/extensions/zalo/src/group-access.ts @@ -1,19 +1,10 @@ -import { isNormalizedSenderAllowed } from "openclaw/plugin-sdk/allow-from"; -import { - evaluateSenderGroupAccess, - resolveOpenProviderRuntimeGroupPolicy, - type GroupPolicy, - type SenderGroupAccessDecision, -} from "openclaw/plugin-sdk/group-access"; +import type { GroupPolicy } from "openclaw/plugin-sdk/config-types"; +import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy"; const ZALO_ALLOW_FROM_PREFIX_RE = /^(zalo|zl):/i; -export function isZaloSenderAllowed(senderId: string, allowFrom: string[]): boolean { - return isNormalizedSenderAllowed({ - senderId, - allowFrom, - stripPrefixRe: ZALO_ALLOW_FROM_PREFIX_RE, - }); +export function normalizeZaloAllowEntry(value: string): string { + return value.trim().replace(ZALO_ALLOW_FROM_PREFIX_RE, "").trim().toLowerCase(); } export function resolveZaloRuntimeGroupPolicy(params: { @@ -30,20 +21,3 @@ export function resolveZaloRuntimeGroupPolicy(params: { defaultGroupPolicy: params.defaultGroupPolicy, }); } - -export function evaluateZaloGroupAccess(params: { - providerConfigPresent: boolean; - configuredGroupPolicy?: GroupPolicy; - defaultGroupPolicy?: GroupPolicy; - groupAllowFrom: string[]; - senderId: string; -}): SenderGroupAccessDecision { - return evaluateSenderGroupAccess({ - providerConfigPresent: params.providerConfigPresent, - configuredGroupPolicy: params.configuredGroupPolicy, - defaultGroupPolicy: params.defaultGroupPolicy, - groupAllowFrom: params.groupAllowFrom, - senderId: params.senderId, - isSenderAllowed: isZaloSenderAllowed, - }); -} diff --git a/extensions/zalo/src/monitor.group-policy.test.ts b/extensions/zalo/src/monitor.group-policy.test.ts index 7a44caab83d..32de6055ca2 100644 --- a/extensions/zalo/src/monitor.group-policy.test.ts +++ b/extensions/zalo/src/monitor.group-policy.test.ts @@ -1,94 +1,198 @@ -import { describe, expect, it } from "vitest"; -import { __testing } from "./monitor.js"; +import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime"; +import type { GroupPolicy, OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import { describe, expect, it, vi } from "vitest"; +import { normalizeZaloAllowEntry, resolveZaloRuntimeGroupPolicy } from "./group-access.js"; +import type { ZaloAccountConfig } from "./types.js"; -describe("zalo group policy access", () => { - it("blocks all group messages when policy is disabled", () => { - const decision = __testing.evaluateZaloGroupAccess({ - providerConfigPresent: true, - configuredGroupPolicy: "disabled", - defaultGroupPolicy: "open", - groupAllowFrom: ["zalo:123"], - senderId: "123", - }); - expect(decision).toMatchObject({ - allowed: false, - groupPolicy: "disabled", - reason: "disabled", +function stringEntries(entries: Array | undefined): string[] { + return (entries ?? []).map((entry) => String(entry)); +} + +const groupPolicyCases: Array<[string, ZaloAccountConfig, string, boolean, string]> = [ + [ + "disabled policy", + { groupPolicy: "disabled", groupAllowFrom: ["zalo:123"] }, + "123", + false, + "group_policy_disabled", + ], + [ + "empty allowlist", + { groupPolicy: "allowlist", groupAllowFrom: [] }, + "attacker", + false, + "group_policy_empty_allowlist", + ], + [ + "allowlist mismatch", + { groupPolicy: "allowlist", groupAllowFrom: ["zalo:victim-user-001"] }, + "attacker-user-999", + false, + "group_policy_not_allowlisted", + ], + [ + "Zalo prefix match", + { groupPolicy: "allowlist", groupAllowFrom: ["zl:12345"] }, + "12345", + true, + "group_policy_allowed", + ], + [ + "allowFrom fallback", + { groupPolicy: "allowlist", allowFrom: ["zl:12345"], groupAllowFrom: [] }, + "12345", + true, + "group_policy_allowed", + ], + [ + "open policy", + { groupPolicy: "open", groupAllowFrom: [] }, + "attacker-user-999", + true, + "group_policy_open", + ], +]; + +async function resolveAccess( + params: { + cfg?: OpenClawConfig; + accountConfig?: ZaloAccountConfig; + providerConfigPresent?: boolean; + defaultGroupPolicy?: GroupPolicy; + isGroup?: boolean; + senderId?: string; + rawBody?: string; + storeAllowFrom?: string[]; + shouldComputeCommandAuthorized?: boolean; + } = {}, +) { + const readAllowFromStore = vi.fn(async () => params.storeAllowFrom ?? []); + const accountConfig = { + dmPolicy: "pairing", + groupPolicy: "allowlist", + allowFrom: [], + groupAllowFrom: [], + ...params.accountConfig, + } satisfies ZaloAccountConfig; + const { groupPolicy, providerMissingFallbackApplied } = resolveZaloRuntimeGroupPolicy({ + providerConfigPresent: params.providerConfigPresent ?? true, + groupPolicy: accountConfig.groupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy ?? "open", + }); + const shouldComputeAuth = params.shouldComputeCommandAuthorized ?? false; + const isGroup = params.isGroup ?? true; + const result = await resolveStableChannelMessageIngress({ + channelId: "zalo", + accountId: "default", + identity: { + key: "zalo-user-id", + normalize: normalizeZaloAllowEntry, + sensitivity: "pii", + entryIdPrefix: "zalo-entry", + }, + accessGroups: params.cfg?.accessGroups, + readStoreAllowFrom: async () => await readAllowFromStore(), + useAccessGroups: params.cfg?.commands?.useAccessGroups !== false, + subject: { stableId: params.senderId ?? "123" }, + conversation: { + kind: isGroup ? "group" : "direct", + id: "chat-1", + }, + providerMissingFallbackApplied, + dmPolicy: accountConfig.dmPolicy ?? "pairing", + groupPolicy, + policy: { groupAllowFromFallbackToAllowFrom: true }, + allowFrom: stringEntries(accountConfig.allowFrom), + groupAllowFrom: stringEntries(accountConfig.groupAllowFrom), + command: shouldComputeAuth ? {} : undefined, + }); + return { result, readAllowFromStore }; +} + +describe("zalo shared ingress access policy", () => { + it.each(groupPolicyCases)( + "maps %s through shared ingress", + async (_name, accountConfig, senderId, allowed, reasonCode) => { + const { result } = await resolveAccess({ accountConfig, senderId }); + expect(result.senderAccess).toMatchObject({ allowed, reasonCode }); + }, + ); + + it("keeps group control-command authorization separate from group sender access", async () => { + const { result } = await resolveAccess({ + accountConfig: { + groupPolicy: "open", + allowFrom: [], + groupAllowFrom: [], + }, + rawBody: "/reset", + shouldComputeCommandAuthorized: true, }); + + expect(result.senderAccess.decision).toBe("allow"); + expect(result.commandAccess.authorized).toBe(false); }); - it("blocks group messages on allowlist policy with empty allowlist", () => { - const decision = __testing.evaluateZaloGroupAccess({ - providerConfigPresent: true, - configuredGroupPolicy: "allowlist", - defaultGroupPolicy: "open", - groupAllowFrom: [], - senderId: "attacker", + it("authorizes direct commands from the pairing store", async () => { + const { result, readAllowFromStore } = await resolveAccess({ + isGroup: false, + accountConfig: { + dmPolicy: "pairing", + allowFrom: [], + }, + senderId: "12345", + storeAllowFrom: ["zl:12345"], + rawBody: "/status", + shouldComputeCommandAuthorized: true, }); - expect(decision).toMatchObject({ - allowed: false, - groupPolicy: "allowlist", - reason: "empty_allowlist", + + expect(readAllowFromStore).toHaveBeenCalledTimes(1); + expect(result.senderAccess).toMatchObject({ + decision: "allow", + reasonCode: "dm_policy_allowlisted", }); + expect(result.commandAccess.authorized).toBe(true); }); - it("blocks sender not in group allowlist", () => { - const decision = __testing.evaluateZaloGroupAccess({ - providerConfigPresent: true, - configuredGroupPolicy: "allowlist", - defaultGroupPolicy: "open", - groupAllowFrom: ["zalo:victim-user-001"], - senderId: "attacker-user-999", - }); - expect(decision).toMatchObject({ - allowed: false, - groupPolicy: "allowlist", - reason: "sender_not_allowlisted", - }); - }); - - it("allows sender in group allowlist", () => { - const decision = __testing.evaluateZaloGroupAccess({ - providerConfigPresent: true, - configuredGroupPolicy: "allowlist", - defaultGroupPolicy: "open", - groupAllowFrom: ["zl:12345"], + it("requires an explicit wildcard or allowlist match for open DMs", async () => { + const { result, readAllowFromStore } = await resolveAccess({ + isGroup: false, + accountConfig: { + dmPolicy: "open", + allowFrom: [], + }, senderId: "12345", }); - expect(decision).toMatchObject({ - allowed: true, - groupPolicy: "allowlist", - reason: "allowed", + + expect(readAllowFromStore).not.toHaveBeenCalled(); + expect(result.senderAccess).toMatchObject({ + decision: "block", + reasonCode: "dm_policy_not_allowlisted", }); }); - it("allows any sender with wildcard allowlist", () => { - const decision = __testing.evaluateZaloGroupAccess({ - providerConfigPresent: true, - configuredGroupPolicy: "allowlist", - defaultGroupPolicy: "open", - groupAllowFrom: ["*"], - senderId: "random-user", + it("matches static access-group entries through the shared ingress resolver", async () => { + const { result } = await resolveAccess({ + cfg: { + accessGroups: { + operators: { + type: "message.senders", + members: { + zalo: ["zl:12345"], + }, + }, + }, + }, + accountConfig: { + groupPolicy: "allowlist", + groupAllowFrom: ["accessGroup:operators"], + }, + senderId: "12345", }); - expect(decision).toMatchObject({ - allowed: true, - groupPolicy: "allowlist", - reason: "allowed", - }); - }); - it("allows all group senders on open policy", () => { - const decision = __testing.evaluateZaloGroupAccess({ - providerConfigPresent: true, - configuredGroupPolicy: "open", - defaultGroupPolicy: "allowlist", - groupAllowFrom: [], - senderId: "attacker-user-999", - }); - expect(decision).toMatchObject({ + expect(result.senderAccess).toMatchObject({ allowed: true, - groupPolicy: "open", - reason: "allowed", + reasonCode: "group_policy_allowed", }); }); }); diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 4bd00ec4ae4..67de993ac8d 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -1,10 +1,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; +import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime"; import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; -import { - resolveDirectDmAuthorizationOutcome, - resolveSenderCommandAuthorizationWithRuntime, -} from "openclaw/plugin-sdk/command-auth"; import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope"; import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; @@ -17,6 +14,7 @@ import { resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk/runtime-group-policy"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/text-runtime"; import { registerPluginHttpRoute, resolveWebhookPath } from "openclaw/plugin-sdk/webhook-ingress"; import type { ResolvedZaloAccount } from "./accounts.js"; import { @@ -32,11 +30,7 @@ import { type ZaloMessage, type ZaloUpdate, } from "./api.js"; -import { - evaluateZaloGroupAccess, - isZaloSenderAllowed, - resolveZaloRuntimeGroupPolicy, -} from "./group-access.js"; +import { normalizeZaloAllowEntry, resolveZaloRuntimeGroupPolicy } from "./group-access.js"; import { resolveZaloProxyFetch } from "./proxy.js"; import { getZaloRuntime } from "./runtime.js"; export type { ZaloRuntimeEnv } from "./monitor.types.js"; @@ -86,6 +80,7 @@ type ZaloProcessingContext = { statusSink?: ZaloStatusSink; fetcher?: ZaloFetch; }; + type ZaloPollingLoopParams = ZaloProcessingContext & { abortSignal: AbortSignal; isStopped: () => boolean; @@ -424,70 +419,71 @@ async function authorizeZaloMessage( const senderName = from.display_name ?? from.name; const dmPolicy = account.config.dmPolicy ?? "pairing"; - const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v)); - const configuredGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((v) => String(v)); - const groupAllowFrom = - configuredGroupAllowFrom.length > 0 ? configuredGroupAllowFrom : configAllowFrom; const defaultGroupPolicy = resolveDefaultGroupPolicy(config); - const groupAccess = isGroup - ? evaluateZaloGroupAccess({ - providerConfigPresent: config.channels?.zalo !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - defaultGroupPolicy, - groupAllowFrom, - senderId, - }) - : undefined; - if (groupAccess) { + const rawBody = text?.trim() || (mediaPath ? "" : ""); + const { groupPolicy, providerMissingFallbackApplied } = resolveZaloRuntimeGroupPolicy({ + providerConfigPresent: config.channels?.zalo !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); + const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config); + const access = await resolveStableChannelMessageIngress({ + channelId: "zalo", + accountId: account.accountId, + identity: { + key: "zalo-user-id", + normalize: normalizeZaloAllowEntry, + sensitivity: "pii", + entryIdPrefix: "zalo-entry", + }, + cfg: config, + readStoreAllowFrom: async () => await pairing.readAllowFromStore(), + subject: { stableId: senderId }, + conversation: { + kind: isGroup ? "group" : "direct", + id: chatId, + }, + providerMissingFallbackApplied, + dmPolicy, + groupPolicy, + policy: { groupAllowFromFallbackToAllowFrom: true }, + allowFrom: normalizeStringEntries(account.config.allowFrom), + groupAllowFrom: normalizeStringEntries(account.config.groupAllowFrom), + command: shouldComputeAuth ? {} : undefined, + }); + const senderAccess = access.senderAccess; + if (isGroup) { warnMissingProviderGroupPolicyFallbackOnce({ - providerMissingFallbackApplied: groupAccess.providerMissingFallbackApplied, + providerMissingFallbackApplied: senderAccess.providerMissingFallbackApplied, providerKey: "zalo", accountId: account.accountId, log: (message) => logVerbose(core, runtime, message), }); - if (!groupAccess.allowed) { - if (groupAccess.reason === "disabled") { + if (!senderAccess.allowed) { + if (senderAccess.reasonCode === "group_policy_disabled") { logVerbose(core, runtime, `zalo: drop group ${chatId} (groupPolicy=disabled)`); - } else if (groupAccess.reason === "empty_allowlist") { + } else if (senderAccess.reasonCode === "group_policy_empty_allowlist") { logVerbose( core, runtime, `zalo: drop group ${chatId} (groupPolicy=allowlist, no groupAllowFrom)`, ); - } else if (groupAccess.reason === "sender_not_allowlisted") { + } else if (senderAccess.reasonCode === "group_policy_not_allowlisted") { logVerbose(core, runtime, `zalo: drop group sender ${senderId} (groupPolicy=allowlist)`); } return undefined; } } - const rawBody = text?.trim() || (mediaPath ? "" : ""); - const { senderAllowedForCommands, commandAuthorized } = - await resolveSenderCommandAuthorizationWithRuntime({ - cfg: config, - rawBody, - isGroup, - dmPolicy, - configuredAllowFrom: configAllowFrom, - configuredGroupAllowFrom: groupAllowFrom, - senderId, - isSenderAllowed: isZaloSenderAllowed, - channel: "zalo", - accountId: account.accountId, - readAllowFromStore: pairing.readAllowFromStore, - runtime: core.channel.commands, - }); - - const directDmOutcome = resolveDirectDmAuthorizationOutcome({ - isGroup, - dmPolicy, - senderAllowedForCommands, - }); - if (directDmOutcome === "disabled") { + if ( + !isGroup && + senderAccess.decision === "block" && + senderAccess.reasonCode === "dm_policy_disabled" + ) { logVerbose(core, runtime, `Blocked zalo DM from ${senderId} (dmPolicy=disabled)`); return undefined; } - if (directDmOutcome === "unauthorized") { + if (!isGroup && senderAccess.decision !== "allow") { if (dmPolicy === "pairing") { await pairing.issueChallenge({ senderId, @@ -523,7 +519,7 @@ async function authorizeZaloMessage( return { chatId, - commandAuthorized, + commandAuthorized: access.commandAccess.requested ? access.commandAccess.authorized : undefined, isGroup, rawBody, senderId, @@ -668,78 +664,61 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr }, }; - await core.channel.turn.run({ + await core.channel.turn.runAssembled({ + cfg: config, channel: "zalo", accountId: account.accountId, - raw: message, - adapter: { - ingest: () => ({ - id: message_id, - timestamp: date ? date * 1000 : undefined, - rawText: rawBody, - textForAgent: rawBody, - textForCommands: rawBody, - raw: message, - }), - resolveTurn: () => ({ - cfg: config, - channel: "zalo", - accountId: account.accountId, - agentId: route.agentId, - routeSessionKey: route.sessionKey, - storePath, - ctxPayload, - recordInboundSession: core.channel.session.recordInboundSession, - dispatchReplyWithBufferedBlockDispatcher: - core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, - delivery: { - preparePayload: (payload) => - prepareZaloDurableReplyPayload({ - payload, - tableMode, - convertMarkdownTables: core.channel.text.convertMarkdownTables, - }), - durable: (payload, info) => - resolveZaloDurableReplyOptions({ - payload, - infoKind: info.kind, - chatId, - }), - deliver: async (payload) => { - await deliverZaloReply({ - payload, - token, - chatId, - runtime, - core, - config, - webhookUrl: params.webhookUrl, - webhookPath: params.webhookPath, - proxyUrl: account.config.proxy, - mediaMaxBytes: params.mediaMaxMb * 1024 * 1024, - canHostMedia: params.canHostMedia, - accountId: account.accountId, - statusSink, - fetcher, - tableMode: "off", - }); - }, - onDelivered: () => { - statusSink?.({ lastOutboundAt: Date.now() }); - }, - onError: (err, info) => { - runtime.error?.( - `[${account.accountId}] Zalo ${info.kind} reply failed: ${String(err)}`, - ); - }, - }, - replyPipeline, - record: { - onRecordError: (err) => { - runtime.error?.(`zalo: failed updating session meta: ${String(err)}`); - }, - }, - }), + agentId: route.agentId, + routeSessionKey: route.sessionKey, + storePath, + ctxPayload, + recordInboundSession: core.channel.session.recordInboundSession, + dispatchReplyWithBufferedBlockDispatcher: + core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, + delivery: { + preparePayload: (payload) => + prepareZaloDurableReplyPayload({ + payload, + tableMode, + convertMarkdownTables: core.channel.text.convertMarkdownTables, + }), + durable: (payload, info) => + resolveZaloDurableReplyOptions({ + payload, + infoKind: info.kind, + chatId, + }), + deliver: async (payload) => { + await deliverZaloReply({ + payload, + token, + chatId, + runtime, + core, + config, + webhookUrl: params.webhookUrl, + webhookPath: params.webhookPath, + proxyUrl: account.config.proxy, + mediaMaxBytes: params.mediaMaxMb * 1024 * 1024, + canHostMedia: params.canHostMedia, + accountId: account.accountId, + statusSink, + fetcher, + tableMode: "off", + }); + }, + onDelivered: () => { + statusSink?.({ lastOutboundAt: Date.now() }); + }, + onError: (err, info) => { + runtime.error?.(`[${account.accountId}] Zalo ${info.kind} reply failed: ${String(err)}`); + }, + }, + replyPipeline, + record: { + onRecordError: (err) => { + runtime.error?.(`zalo: failed updating session meta: ${String(err)}`); + }, }, }); } @@ -1033,7 +1012,6 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise< } export const __testing = { - evaluateZaloGroupAccess, resolveZaloRuntimeGroupPolicy, clearHostedMediaRouteRefsForTest: () => hostedMediaRouteRefs.clear(), }; diff --git a/extensions/zalo/src/runtime-api.ts b/extensions/zalo/src/runtime-api.ts index 8869e76640c..421aa85121d 100644 --- a/extensions/zalo/src/runtime-api.ts +++ b/extensions/zalo/src/runtime-api.ts @@ -23,7 +23,6 @@ export { createWebhookAnomalyTracker, DEFAULT_ACCOUNT_ID, deliverTextOrMediaReply, - evaluateSenderGroupAccess, formatAllowFromLowercase, formatPairingApproveHint, type GroupPolicy, @@ -53,16 +52,13 @@ export { type ReplyPayload, resolveClientIp, resolveDefaultGroupPolicy, - resolveDirectDmAuthorizationOutcome, resolveInboundRouteEnvelopeBuilderWithRuntime, resolveOpenProviderRuntimeGroupPolicy, - resolveSenderCommandAuthorizationWithRuntime, resolveWebhookPath, resolveWebhookTargetWithAuthOrRejectSync, runSingleChannelSecretStep, type RuntimeEnv, type SecretInput, - type SenderGroupAccessDecision, sendPayloadWithChunkedTextAndMedia, setTopLevelChannelDmPolicyWithAllowFrom, waitForAbortSignal, diff --git a/extensions/zalo/src/runtime-support.ts b/extensions/zalo/src/runtime-support.ts index 33bfb0cbb63..3588f7cd13a 100644 --- a/extensions/zalo/src/runtime-support.ts +++ b/extensions/zalo/src/runtime-support.ts @@ -10,7 +10,6 @@ export type { ChannelStatusIssue, } from "openclaw/plugin-sdk/channel-contract"; export type { SecretInput } from "openclaw/plugin-sdk/secret-input"; -export type { SenderGroupAccessDecision } from "openclaw/plugin-sdk/group-access"; export type { ChannelPlugin, PluginRuntime, WizardPrompter } from "openclaw/plugin-sdk/core"; export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; export type { OutboundReplyPayload } from "openclaw/plugin-sdk/reply-payload"; @@ -51,7 +50,6 @@ export { isNormalizedSenderAllowed, } from "openclaw/plugin-sdk/allow-from"; export { addWildcardAllowFrom } from "openclaw/plugin-sdk/setup"; -export { evaluateSenderGroupAccess } from "openclaw/plugin-sdk/group-access"; export { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy"; export { warnMissingProviderGroupPolicyFallbackOnce, @@ -65,10 +63,6 @@ export { isNumericTargetId, sendPayloadWithChunkedTextAndMedia, } from "openclaw/plugin-sdk/reply-payload"; -export { - resolveDirectDmAuthorizationOutcome, - resolveSenderCommandAuthorizationWithRuntime, -} from "openclaw/plugin-sdk/command-auth"; export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope"; export { waitForAbortSignal } from "openclaw/plugin-sdk/runtime"; export { diff --git a/extensions/zalo/test-api.ts b/extensions/zalo/test-api.ts index e8f6e742b11..698683bc1d0 100644 --- a/extensions/zalo/test-api.ts +++ b/extensions/zalo/test-api.ts @@ -1 +1 @@ -export { evaluateZaloGroupAccess, resolveZaloRuntimeGroupPolicy } from "./src/group-access.js"; +export { resolveZaloRuntimeGroupPolicy } from "./src/group-access.js"; diff --git a/extensions/zalouser/runtime-api.ts b/extensions/zalouser/runtime-api.ts index fb776173e0a..e29b1842108 100644 --- a/extensions/zalouser/runtime-api.ts +++ b/extensions/zalouser/runtime-api.ts @@ -51,11 +51,6 @@ export { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbou export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message"; export { buildBaseAccountStatusSnapshot } from "openclaw/plugin-sdk/status-helpers"; -export { resolveSenderCommandAuthorization } from "openclaw/plugin-sdk/command-auth"; -export { - evaluateGroupRouteAccessForPolicy, - resolveSenderScopedGroupPolicy, -} from "openclaw/plugin-sdk/group-access"; export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media"; export { deliverTextOrMediaReply, diff --git a/extensions/zalouser/src/monitor.group-gating.test.ts b/extensions/zalouser/src/monitor.group-gating.test.ts index b8e2a54afe1..8c79c8fb4c0 100644 --- a/extensions/zalouser/src/monitor.group-gating.test.ts +++ b/extensions/zalouser/src/monitor.group-gating.test.ts @@ -95,9 +95,9 @@ function installRuntime(params: { const readSessionUpdatedAt = vi.fn( (_params?: { storePath: string; sessionKey: string }): number | undefined => undefined, ); - type ResolvedTurn = Awaited< - ReturnType[0]["adapter"]["resolveTurn"]> - >; + type ResolvedTurn = + | Parameters[0] + | Parameters[0]; const dispatchAssembled = vi.fn(async (turn: ResolvedTurn) => { await turn.recordInboundSession({ storePath: turn.storePath, @@ -150,21 +150,6 @@ function installRuntime(params: { dispatchResult, }; }); - const runTurn = vi.fn(async (params: Parameters[0]) => { - const input = await params.adapter.ingest(params.raw); - if (!input) { - return { admission: { kind: "drop" as const, reason: "ingest-null" }, dispatched: false }; - } - const resolved = await params.adapter.resolveTurn( - input, - { - kind: "message", - canStartAgentTurn: true, - }, - {}, - ); - return await dispatchAssembled(resolved); - }); const buildContext = vi.fn( (params: Parameters[0]) => ({ @@ -270,7 +255,8 @@ function installRuntime(params: { dispatchReplyWithBufferedBlockDispatcher, }, turn: { - run: runTurn as unknown as PluginRuntime["channel"]["turn"]["run"], + runAssembled: + dispatchAssembled as unknown as PluginRuntime["channel"]["turn"]["runAssembled"], buildContext: buildContext as unknown as PluginRuntime["channel"]["turn"]["buildContext"], }, text: { @@ -416,7 +402,7 @@ describe("zalouser monitor group mention gating", () => { async function expectGroupCommandAuthorizers(params: { accountConfig: ResolvedZalouserAccount["config"]; - expectedAuthorizers: Array<{ configured: boolean; allowed: boolean }>; + expectedCommandAuthorized: boolean; }) { const { dispatchReplyWithBufferedBlockDispatcher, resolveCommandAuthorizedFromAuthorizers } = installGroupCommandAuthRuntime(); @@ -427,8 +413,9 @@ describe("zalouser monitor group mention gating", () => { }, }); expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); - const authCall = resolveCommandAuthorizedFromAuthorizers.mock.calls[0]?.[0]; - expect(authCall?.authorizers).toEqual(params.expectedAuthorizers); + expect(resolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled(); + const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0]; + expect(callArg?.ctx?.CommandAuthorized).toBe(params.expectedCommandAuthorized); } async function processOpenDmMessage(params?: { @@ -614,6 +601,35 @@ describe("zalouser monitor group mention gating", () => { ); }); + it("allows DM senders from static access groups", async () => { + const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({ + commandAuthorized: false, + }); + await __testing.processMessage({ + message: createDmMessage({ senderId: "321" }), + account: { + ...createAccount(), + config: { + ...createAccount().config, + dmPolicy: "allowlist", + allowFrom: ["accessGroup:operators"], + }, + }, + config: { + ...createConfig(), + accessGroups: { + operators: { + type: "message.senders", + members: { zalouser: ["321"] }, + }, + }, + }, + runtime: createRuntimeEnv(), + }); + + expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); + }); + it("uses commandContent for mention-prefixed control commands", async () => { const callArg = await dispatchGroupMessage({ commandAuthorized: true, @@ -634,10 +650,7 @@ describe("zalouser monitor group mention gating", () => { ...createAccount().config, allowFrom: ["123"], }, - expectedAuthorizers: [ - { configured: true, allowed: true }, - { configured: true, allowed: true }, - ], + expectedCommandAuthorized: true, }); }); @@ -670,6 +683,40 @@ describe("zalouser monitor group mention gating", () => { expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); + it("allows group senders from static access groups", async () => { + const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({ + commandAuthorized: false, + }); + await __testing.processMessage({ + message: createGroupMessage({ + content: "ping @bot", + hasAnyMention: true, + wasExplicitlyMentioned: true, + senderId: "123", + }), + account: { + ...createAccount(), + config: { + ...createAccount().config, + groupPolicy: "allowlist", + groupAllowFrom: ["accessGroup:operators"], + }, + }, + config: { + ...createConfig(), + accessGroups: { + operators: { + type: "message.senders", + members: { zalouser: ["123"] }, + }, + }, + }, + runtime: createRuntimeEnv(), + }); + + expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); + }); + it("blocks group messages when sender is not in groupAllowFrom", async () => { const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({ commandAuthorized: false, @@ -755,10 +802,7 @@ describe("zalouser monitor group mention gating", () => { allowFrom: ["999"], groupAllowFrom: ["123"], }, - expectedAuthorizers: [ - { configured: true, allowed: false }, - { configured: true, allowed: true }, - ], + expectedCommandAuthorized: true, }); }); diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index 6eb37c470bf..4bd00c6517d 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -3,20 +3,12 @@ import { implicitMentionKindWhen, resolveInboundMentionDecision, } from "openclaw/plugin-sdk/channel-inbound"; +import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime"; import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; -import { - DM_GROUP_ACCESS_REASON, - resolveDmGroupAccessWithLists, -} from "openclaw/plugin-sdk/channel-policy"; -import { resolveSenderCommandAuthorization } from "openclaw/plugin-sdk/command-auth"; import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { KeyedAsyncQueue } from "openclaw/plugin-sdk/core"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; import { createDeferred } from "openclaw/plugin-sdk/extension-shared"; -import { - evaluateGroupRouteAccessForPolicy, - resolveSenderScopedGroupPolicy, -} from "openclaw/plugin-sdk/group-access"; import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry, @@ -38,6 +30,7 @@ import { import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, + normalizeStringEntries, } from "openclaw/plugin-sdk/text-runtime"; import { buildZalouserGroupCandidates, @@ -74,10 +67,6 @@ export type ZalouserMonitorResult = { const ZALOUSER_TEXT_LIMIT = 2000; -function normalizeZalouserEntry(entry: string): string { - return entry.replace(/^(zalouser|zlu):/i, "").trim(); -} - function buildNameIndex(items: T[], nameFn: (item: T) => string | undefined): Map { const index = new Map(); for (const item of items) { @@ -128,6 +117,14 @@ type ZalouserGroupHistoryState = { groupHistories: Map; }; +function normalizeZalouserAllowEntry(entry: string): string { + return entry.replace(/^(zalouser|zlu):/i, "").trim(); +} + +function normalizeZalouserSender(value: string): string | null { + return normalizeOptionalLowercaseString(normalizeZalouserAllowEntry(value)) || null; +} + function resolveInboundQueueKey(message: ZaloInboundMessage): string { const threadId = message.threadId?.trim() || "unknown"; if (message.isGroup) { @@ -142,6 +139,40 @@ function resolveZalouserDmSessionScope(config: OpenClawConfig) { return configured === "main" || !configured ? "per-channel-peer" : configured; } +function resolveZalouserRouteAccess(params: { + groupPolicy: "open" | "disabled" | "allowlist"; + configured: boolean; + matched: boolean; + enabled?: boolean; +}): { + allowed: boolean; + reason?: "disabled" | "empty_allowlist" | "route_not_allowlisted" | "route_disabled"; +} { + if (params.groupPolicy === "disabled") { + return { allowed: false, reason: "disabled" }; + } + if (params.matched && params.enabled === false) { + return { allowed: false, reason: "route_disabled" }; + } + if (params.groupPolicy !== "allowlist") { + return { allowed: true }; + } + if (!params.configured) { + return { allowed: false, reason: "empty_allowlist" }; + } + return params.matched ? { allowed: true } : { allowed: false, reason: "route_not_allowlisted" }; +} + +function senderScopedZalouserGroupPolicy(params: { + groupPolicy: "open" | "disabled" | "allowlist"; + groupAllowFrom: readonly string[]; +}) { + if (params.groupPolicy === "disabled") { + return "disabled"; + } + return params.groupAllowFrom.length > 0 ? "allowlist" : "open"; +} + function resolveZalouserInboundSessionKey(params: { core: ZalouserCoreRuntime; config: OpenClawConfig; @@ -193,20 +224,6 @@ function logVerbose(core: ZalouserCoreRuntime, runtime: RuntimeEnv, message: str } } -function isSenderAllowed(senderId: string | undefined, allowFrom: string[]): boolean { - if (allowFrom.includes("*")) { - return true; - } - const normalizedSenderId = normalizeOptionalLowercaseString(senderId); - if (!normalizedSenderId) { - return false; - } - return allowFrom.some((entry) => { - const normalized = normalizeLowercaseStringOrEmpty(entry).replace(/^(zalouser|zlu):/i, ""); - return normalized === normalizedSenderId; - }); -} - function resolveGroupRequireMention(params: { groupId: string; groupName?: string | null; @@ -330,11 +347,11 @@ async function processMessage( allowNameMatching, }), ); - const routeAccess = evaluateGroupRouteAccessForPolicy({ + const routeAccess = resolveZalouserRouteAccess({ groupPolicy, - routeAllowlistConfigured, - routeMatched: Boolean(groupEntry), - routeEnabled: isZalouserGroupEntryAllowed(groupEntry), + configured: routeAllowlistConfigured, + matched: Boolean(groupEntry), + enabled: isZalouserGroupEntryAllowed(groupEntry), }); if (!routeAccess.allowed) { if (routeAccess.reason === "disabled") { @@ -355,33 +372,50 @@ async function processMessage( } const dmPolicy = account.config.dmPolicy ?? "pairing"; - const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v)); - const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((v) => String(v)); + const configAllowFrom = normalizeStringEntries(account.config.allowFrom); + const configGroupAllowFrom = normalizeStringEntries(account.config.groupAllowFrom); const senderGroupPolicy = routeAllowlistConfigured && configGroupAllowFrom.length === 0 ? groupPolicy - : resolveSenderScopedGroupPolicy({ + : senderScopedZalouserGroupPolicy({ groupPolicy, groupAllowFrom: configGroupAllowFrom, }); - const storeAllowFrom = - !isGroup && dmPolicy !== "allowlist" && dmPolicy !== "open" - ? await pairing.readAllowFromStore().catch(() => []) - : []; - const accessDecision = resolveDmGroupAccessWithLists({ - isGroup, + const shouldComputeCommandAuth = core.channel.commands.shouldComputeCommandAuthorized( + commandBody, + config, + ); + const accessDecision = await resolveStableChannelMessageIngress({ + channelId: "zalouser", + accountId: account.accountId, + identity: { + normalize: normalizeZalouserSender, + sensitivity: "pii", + entryIdPrefix: "zalouser-entry", + }, + cfg: config, + readStoreAllowFrom: async () => await pairing.readAllowFromStore(), + subject: { stableId: senderId }, + conversation: { + kind: isGroup ? "group" : "direct", + id: isGroup ? "group" : senderId, + }, dmPolicy, groupPolicy: senderGroupPolicy, + policy: { groupAllowFromFallbackToAllowFrom: false }, allowFrom: configAllowFrom, groupAllowFrom: configGroupAllowFrom, - storeAllowFrom, - groupAllowFromFallbackToAllowFrom: false, - isSenderAllowed: (allowFrom) => isSenderAllowed(senderId, allowFrom), + command: shouldComputeCommandAuth + ? { + directGroupAllowFrom: "effective", + commandGroupAllowFromFallbackToAllowFrom: true, + } + : undefined, }); - if (isGroup && accessDecision.decision !== "allow") { - if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST) { + if (isGroup && accessDecision.senderAccess.decision !== "allow") { + if (accessDecision.senderAccess.reasonCode === "group_policy_empty_allowlist") { logVerbose(core, runtime, "Blocked zalouser group message (no group allowlist)"); - } else if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED) { + } else if (accessDecision.senderAccess.reasonCode === "group_policy_not_allowlisted") { logVerbose( core, runtime, @@ -391,8 +425,8 @@ async function processMessage( return; } - if (!isGroup && accessDecision.decision !== "allow") { - if (accessDecision.decision === "pairing") { + if (!isGroup && accessDecision.senderAccess.decision !== "allow") { + if (accessDecision.senderAccess.decision === "pairing") { await pairing.issueChallenge({ senderId, senderIdLine: `Your Zalo user id: ${senderId}`, @@ -414,7 +448,7 @@ async function processMessage( }); return; } - if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.DM_POLICY_DISABLED) { + if (accessDecision.senderAccess.reasonCode === "dm_policy_disabled") { logVerbose(core, runtime, `Blocked zalouser DM from ${senderId} (dmPolicy=disabled)`); } else { logVerbose( @@ -426,23 +460,9 @@ async function processMessage( return; } - const { commandAuthorized } = await resolveSenderCommandAuthorization({ - cfg: config, - rawBody: commandBody, - isGroup, - dmPolicy, - configuredAllowFrom: configAllowFrom, - configuredGroupAllowFrom: configGroupAllowFrom, - senderId, - isSenderAllowed, - channel: "zalouser", - accountId: account.accountId, - readAllowFromStore: async () => storeAllowFrom, - shouldComputeCommandAuthorized: (body, cfg) => - core.channel.commands.shouldComputeCommandAuthorized(body, cfg), - resolveCommandAuthorizedFromAuthorizers: (params) => - core.channel.commands.resolveCommandAuthorizedFromAuthorizers(params), - }); + const commandAuthorized = accessDecision.commandAccess.requested + ? accessDecision.commandAccess.authorized + : undefined; const hasControlCommand = core.channel.commands.isControlCommandMessage(commandBody, config); if (isGroup && hasControlCommand && commandAuthorized !== true) { logVerbose( @@ -668,81 +688,64 @@ async function processMessage( }, }; - await core.channel.turn.run({ + await core.channel.turn.runAssembled({ channel: "zalouser", accountId: account.accountId, - raw: message, - adapter: { - ingest: () => ({ - id: messageSid ?? `${message.timestampMs}`, - timestamp: message.timestampMs, - rawText: rawBody, - textForAgent: rawBody, - textForCommands: commandBody, - raw: message, - }), - resolveTurn: () => ({ - cfg: config, - channel: "zalouser", - accountId: account.accountId, - agentId: route.agentId, - routeSessionKey: route.sessionKey, - storePath, - ctxPayload, - recordInboundSession: core.channel.session.recordInboundSession, - dispatchReplyWithBufferedBlockDispatcher: - core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, - delivery: { - preparePayload: (payload) => { - if (payload.text === undefined) { - return payload; - } - return { - ...payload, - text: core.channel.text.convertMarkdownTables( - payload.text, - core.channel.text.resolveMarkdownTableMode({ - cfg: config, - channel: "zalouser", - accountId: account.accountId, - }), - ), - }; - }, - durable: () => ({ - to: normalizedTo, - }), - deliver: async (payload) => { - return await deliverZalouserReply({ - payload: payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string }, - profile: account.profile, - chatId, - isGroup, - runtime, - core, - config, + cfg: config, + agentId: route.agentId, + routeSessionKey: route.sessionKey, + storePath, + ctxPayload, + recordInboundSession: core.channel.session.recordInboundSession, + dispatchReplyWithBufferedBlockDispatcher: + core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, + delivery: { + preparePayload: (payload) => { + if (payload.text === undefined) { + return payload; + } + return { + ...payload, + text: core.channel.text.convertMarkdownTables( + payload.text, + core.channel.text.resolveMarkdownTableMode({ + cfg: config, + channel: "zalouser", accountId: account.accountId, - tableMode: "off", - }); - }, - onDelivered: (_payload, _info, result) => { - if (result?.visibleReplySent !== false) { - statusSink?.({ lastOutboundAt: Date.now() }); - } - }, - onError: (err, info) => { - runtime.error( - `[${account.accountId}] Zalouser ${info.kind} reply failed: ${String(err)}`, - ); - }, - }, - replyPipeline, - record: { - onRecordError: (err) => { - runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`); - }, - }, + }), + ), + }; + }, + durable: () => ({ + to: normalizedTo, }), + deliver: async (payload) => { + return await deliverZalouserReply({ + payload: payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string }, + profile: account.profile, + chatId, + isGroup, + runtime, + core, + config, + accountId: account.accountId, + tableMode: "off", + }); + }, + onDelivered: (_payload, _info, result) => { + if (result?.visibleReplySent !== false) { + statusSink?.({ lastOutboundAt: Date.now() }); + } + }, + onError: (err, info) => { + runtime.error(`[${account.accountId}] Zalouser ${info.kind} reply failed: ${String(err)}`); + }, + }, + replyPipeline, + record: { + onRecordError: (err) => { + runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`); + }, }, }); if (isGroup && historyKey) { @@ -834,10 +837,10 @@ export async function monitorZalouserProvider( try { const profile = account.profile; const allowFromEntries = (account.config.allowFrom ?? []) - .map((entry) => normalizeZalouserEntry(String(entry))) + .map((entry) => normalizeZalouserAllowEntry(String(entry))) .filter((entry) => entry && entry !== "*"); const groupAllowFromEntries = (account.config.groupAllowFrom ?? []) - .map((entry) => normalizeZalouserEntry(String(entry))) + .map((entry) => normalizeZalouserAllowEntry(String(entry))) .filter((entry) => entry && entry !== "*"); const allowNameMatching = isDangerousNameMatchingEnabled(account.config); @@ -888,7 +891,7 @@ export async function monitorZalouserProvider( const unresolved: string[] = []; const nextGroups = { ...groupsConfig }; for (const entry of groupKeys) { - const cleaned = normalizeZalouserEntry(entry); + const cleaned = normalizeZalouserAllowEntry(entry); if (/^\d+$/.test(cleaned)) { if (!nextGroups[cleaned]) { nextGroups[cleaned] = groupsConfig[entry]; diff --git a/package.json b/package.json index cd23af09f3d..6e9f9ca9cbe 100644 --- a/package.json +++ b/package.json @@ -619,6 +619,10 @@ "types": "./dist/plugin-sdk/agent-config-primitives.d.ts", "default": "./dist/plugin-sdk/agent-config-primitives.js" }, + "./plugin-sdk/access-groups": { + "types": "./dist/plugin-sdk/access-groups.d.ts", + "default": "./dist/plugin-sdk/access-groups.js" + }, "./plugin-sdk/allow-from": { "types": "./dist/plugin-sdk/allow-from.d.ts", "default": "./dist/plugin-sdk/allow-from.js" @@ -803,6 +807,14 @@ "types": "./dist/plugin-sdk/channel-lifecycle.d.ts", "default": "./dist/plugin-sdk/channel-lifecycle.js" }, + "./plugin-sdk/channel-ingress": { + "types": "./dist/plugin-sdk/channel-ingress.d.ts", + "default": "./dist/plugin-sdk/channel-ingress.js" + }, + "./plugin-sdk/channel-ingress-runtime": { + "types": "./dist/plugin-sdk/channel-ingress-runtime.d.ts", + "default": "./dist/plugin-sdk/channel-ingress-runtime.js" + }, "./plugin-sdk/channel-message": { "types": "./dist/plugin-sdk/channel-message.d.ts", "default": "./dist/plugin-sdk/channel-message.js" @@ -1419,6 +1431,7 @@ "lint:docker-e2e": "node scripts/check-docker-e2e-boundaries.mjs", "lint:docs": "pnpm dlx --config.resolution-mode=highest markdownlint-cli2 --config config/markdownlint-cli2.jsonc", "lint:docs:fix": "pnpm dlx --config.resolution-mode=highest markdownlint-cli2 --config config/markdownlint-cli2.jsonc --fix", + "lint:extensions:no-deprecated-channel-access": "node --import tsx scripts/check-no-deprecated-channel-access.ts", "lint:extensions": "node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.extensions.json extensions", "lint:extensions:bundled": "node scripts/run-bundled-extension-oxlint.mjs", "lint:extensions:channels": "node scripts/run-extension-channel-oxlint.mjs", diff --git a/scripts/check-no-deprecated-channel-access.ts b/scripts/check-no-deprecated-channel-access.ts new file mode 100644 index 00000000000..984f24007e0 --- /dev/null +++ b/scripts/check-no-deprecated-channel-access.ts @@ -0,0 +1,111 @@ +import fs from "node:fs"; +import path from "node:path"; +import { collectFilesSync, isCodeFile, relativeToCwd } from "./check-file-utils.js"; +import { classifyBundledExtensionSourcePath } from "./lib/extension-source-classifier.mjs"; + +type Rule = { + label: string; + pattern: RegExp; +}; + +const RULES: Rule[] = [ + { + label: "deprecated channel ingress resolver aliases", + pattern: + /\b(?:resolved|result|directResolved|groupResolved)\.(?:legacyAccess|senderReasonCode|commandAuthorized|shouldBlockControlCommand)\b/u, + }, + { + label: "inline deprecated channel ingress legacyAccess projection", + pattern: /\)\.legacyAccess\b/u, + }, + { + label: "low-level compatibility ingress resolver", + pattern: /\bresolveChannelIngressAccess\b/u, + }, + { + label: "deprecated channel ingress compatibility projection", + pattern: + /\b(?:findChannelIngressSenderReasonCode|formatChannelIngressPolicyReason|mapChannelIngressReasonCodeToDmGroupAccessReason|projectChannelIngressDmGroupAccess|projectChannelIngressSenderGroupAccess)\b/u, + }, + { + label: "deprecated pairing-store access helper", + pattern: /\breadStoreAllowFromForDmPolicy\b/u, + }, + { + label: "deprecated DM/group access helper", + pattern: /\bresolveDmGroupAccessWith(?:Lists|CommandGate)\b/u, + }, + { + label: "deprecated DM/group access reason constants", + pattern: /\bDM_GROUP_ACCESS_REASON\b/u, + }, + { + label: "deprecated group policy access helper", + pattern: + /\b(?:resolveSenderScopedGroupPolicy|evaluateSenderGroupAccess(?:ForPolicy)?|evaluateGroupRouteAccessForPolicy|evaluateMatchedGroupAccessForPolicy)\b/u, + }, + { + label: "deprecated group access compatibility module", + pattern: /from\s+["']openclaw\/plugin-sdk\/group-access["']/u, + }, + { + label: "deprecated command authorization helper", + pattern: /\bresolveSenderCommandAuthorization(?:WithRuntime)?\b/u, + }, + { + label: "deprecated command auth SDK facade", + pattern: /from\s+["']openclaw\/plugin-sdk\/command-auth["']/u, + }, + { + label: "deprecated AccessFacts command authorizers", + pattern: /\bcommands\.authorizers\b/u, + }, +]; + +function collectBundledPluginProductionFiles(): string[] { + const extensionsDir = path.join(process.cwd(), "extensions"); + return collectFilesSync(extensionsDir, { + includeFile(filePath) { + if (!isCodeFile(filePath)) { + return false; + } + const repoPath = relativeToCwd(filePath); + const classified = classifyBundledExtensionSourcePath(repoPath); + return classified.isProductionSource; + }, + }).toSorted((left, right) => relativeToCwd(left).localeCompare(relativeToCwd(right))); +} + +function main() { + const offenders: Array<{ file: string; line: number; label: string; text: string }> = []; + for (const file of collectBundledPluginProductionFiles()) { + const content = fs.readFileSync(file, "utf8"); + const lines = content.split(/\r?\n/u); + for (const [index, line] of lines.entries()) { + for (const rule of RULES) { + if (rule.pattern.test(line)) { + offenders.push({ + file: relativeToCwd(file), + line: index + 1, + label: rule.label, + text: line.trim(), + }); + } + } + } + } + + if (offenders.length > 0) { + console.error( + "Bundled plugin production code must use modern channel access results, not deprecated compatibility seams.", + ); + for (const offender of offenders) { + console.error(`- ${offender.file}:${offender.line}: ${offender.label}: ${offender.text}`); + } + process.exit(1); + } + + console.log("OK: bundled plugin production code avoids deprecated channel access seams."); +} + +main(); diff --git a/scripts/check-no-pairing-store-group-auth.mjs b/scripts/check-no-pairing-store-group-auth.mjs index 83b3535abb3..3a4f925d64b 100644 --- a/scripts/check-no-pairing-store-group-auth.mjs +++ b/scripts/check-no-pairing-store-group-auth.mjs @@ -12,7 +12,7 @@ import { const { repoRoot, sourceRoots, resolveFromRepo } = createPairingGuardContext(import.meta.url); const allowedFiles = new Set([ - resolveFromRepo("src/security/dm-policy-shared.ts"), + resolveFromRepo("src/channels/message-access/legacy-policy.ts"), resolveFromRepo("src/channels/allow-from.ts"), // Config migration/audit logic may intentionally reference store + group fields. resolveFromRepo("src/security/fix.ts"), diff --git a/scripts/check.mjs b/scripts/check.mjs index 651a687a6fe..35743425ff4 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -39,6 +39,10 @@ export async function main(argv = process.argv.slice(2)) { name: "plugin-sdk wildcard re-exports", args: ["lint:extensions:no-plugin-sdk-wildcard-reexports"], }, + { + name: "deprecated channel access seams", + args: ["lint:extensions:no-deprecated-channel-access"], + }, { name: "runtime sidecar loader guard", args: ["check:runtime-sidecar-loaders"] }, { name: "tool display", args: ["tool-display:check"] }, { name: "host env policy", args: ["check:host-env-policy:swift"] }, diff --git a/scripts/format-docs.mjs b/scripts/format-docs.mjs index 92992a84821..05fdbe9c589 100644 --- a/scripts/format-docs.mjs +++ b/scripts/format-docs.mjs @@ -16,7 +16,10 @@ function docsFiles() { cwd: ROOT, encoding: "utf8", }); - return output.split("\n").filter(Boolean); + return output + .split("\n") + .filter(Boolean) + .filter((relativePath) => fs.existsSync(path.join(ROOT, relativePath))); } function runOxfmt(files) { diff --git a/scripts/lib/plugin-sdk-doc-metadata.ts b/scripts/lib/plugin-sdk-doc-metadata.ts index 6e57d5f9ed0..7116476c235 100644 --- a/scripts/lib/plugin-sdk-doc-metadata.ts +++ b/scripts/lib/plugin-sdk-doc-metadata.ts @@ -50,6 +50,9 @@ export const pluginSdkDocMetadata = { "plugin-test-runtime": { category: "utilities", }, + "access-groups": { + category: "channel", + }, "channel-actions": { category: "channel", }, @@ -68,6 +71,12 @@ export const pluginSdkDocMetadata = { "channel-pairing": { category: "channel", }, + "channel-ingress": { + category: "channel", + }, + "channel-ingress-runtime": { + category: "channel", + }, "channel-reply-pipeline": { category: "channel", }, diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index dba769f808c..668c7174c8a 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -131,6 +131,7 @@ "account-resolution", "account-resolution-runtime", "agent-config-primitives", + "access-groups", "allow-from", "allowlist-config-edit", "browser-config", @@ -177,6 +178,8 @@ "channel-location", "channel-mention-gating", "channel-lifecycle", + "channel-ingress", + "channel-ingress-runtime", "channel-message", "channel-message-runtime", "channel-pairing", diff --git a/src/channels/allow-from.ts b/src/channels/allow-from.ts index ca9c5cddfc0..83d7836b0ec 100644 --- a/src/channels/allow-from.ts +++ b/src/channels/allow-from.ts @@ -1,5 +1,16 @@ import { normalizeStringEntries } from "../shared/string-normalization.js"; +export const ACCESS_GROUP_ALLOW_FROM_PREFIX = "accessGroup:"; + +export function parseAccessGroupAllowFromEntry(entry: string): string | null { + const trimmed = entry.trim(); + if (!trimmed.startsWith(ACCESS_GROUP_ALLOW_FROM_PREFIX)) { + return null; + } + const name = trimmed.slice(ACCESS_GROUP_ALLOW_FROM_PREFIX.length).trim(); + return name.length > 0 ? name : null; +} + export function mergeDmAllowFromSources(params: { allowFrom?: Array; storeAllowFrom?: Array; diff --git a/src/channels/message-access/allowlist.ts b/src/channels/message-access/allowlist.ts new file mode 100644 index 00000000000..c2bca2ae665 --- /dev/null +++ b/src/channels/message-access/allowlist.ts @@ -0,0 +1,135 @@ +import type { + ChannelIngressPolicyInput, + ChannelIngressState, + IngressReasonCode, + RedactedIngressAllowlistFacts, + RedactedIngressEntryDiagnostic, + ResolvedIngressAllowlist, +} from "./types.js"; + +export function allowlistFailureReason( + allowlist: ResolvedIngressAllowlist, +): IngressReasonCode | null { + if (allowlist.accessGroups.failed.length > 0) { + return "access_group_failed"; + } + if (allowlist.accessGroups.unsupported.length > 0) { + return "access_group_unsupported"; + } + if (allowlist.accessGroups.missing.length > 0) { + return "access_group_missing"; + } + return null; +} + +export function redactedAllowlistDiagnostics( + allowlist: ResolvedIngressAllowlist, + reasonCode: IngressReasonCode, +): RedactedIngressAllowlistFacts { + return { + configured: allowlist.hasConfiguredEntries, + matched: allowlist.match.matched, + reasonCode, + matchedEntryIds: allowlist.matchedEntryIds, + invalidEntryCount: allowlist.invalidEntries.length, + disabledEntryCount: allowlist.disabledEntries.length, + accessGroups: allowlist.accessGroups, + }; +} + +function uniqueStrings(values: readonly string[]): string[] { + return Array.from(new Set(values)); +} + +function mergeResolvedAllowlists( + allowlists: readonly ResolvedIngressAllowlist[], +): ResolvedIngressAllowlist { + const matches = allowlists.map((allowlist) => allowlist.match); + const matchedEntryIds = uniqueStrings( + allowlists.flatMap((allowlist) => allowlist.matchedEntryIds), + ); + return { + rawEntryCount: allowlists.reduce((sum, allowlist) => sum + allowlist.rawEntryCount, 0), + normalizedEntries: allowlists.flatMap((allowlist) => allowlist.normalizedEntries), + invalidEntries: allowlists.flatMap((allowlist) => allowlist.invalidEntries), + disabledEntries: allowlists.flatMap((allowlist) => allowlist.disabledEntries), + matchedEntryIds, + hasConfiguredEntries: allowlists.some((allowlist) => allowlist.hasConfiguredEntries), + hasMatchableEntries: allowlists.some((allowlist) => allowlist.hasMatchableEntries), + hasWildcard: allowlists.some((allowlist) => allowlist.hasWildcard), + accessGroups: { + referenced: uniqueStrings( + allowlists.flatMap((allowlist) => allowlist.accessGroups.referenced), + ), + matched: uniqueStrings(allowlists.flatMap((allowlist) => allowlist.accessGroups.matched)), + missing: uniqueStrings(allowlists.flatMap((allowlist) => allowlist.accessGroups.missing)), + unsupported: uniqueStrings( + allowlists.flatMap((allowlist) => allowlist.accessGroups.unsupported), + ), + failed: uniqueStrings(allowlists.flatMap((allowlist) => allowlist.accessGroups.failed)), + }, + match: { + matched: matches.some((match) => match.matched) || matchedEntryIds.length > 0, + matchedEntryIds, + }, + }; +} + +export function applyMutableIdentifierPolicy( + allowlist: ResolvedIngressAllowlist, + policy: ChannelIngressPolicyInput, +): ResolvedIngressAllowlist { + if (policy.mutableIdentifierMatching === "enabled") { + return allowlist; + } + const dangerousEntryIds = new Set( + allowlist.normalizedEntries + .filter((entry) => entry.dangerous) + .map((entry) => entry.opaqueEntryId), + ); + if (dangerousEntryIds.size === 0) { + return allowlist; + } + const matchedEntryIds = allowlist.matchedEntryIds.filter((id) => !dangerousEntryIds.has(id)); + const disabledEntries: RedactedIngressEntryDiagnostic[] = [ + ...allowlist.disabledEntries, + ...allowlist.normalizedEntries + .filter((entry) => entry.dangerous) + .map((entry) => ({ + opaqueEntryId: entry.opaqueEntryId, + reasonCode: "mutable_identifier_disabled" as const, + })), + ]; + return { + ...allowlist, + disabledEntries, + matchedEntryIds, + hasMatchableEntries: allowlist.normalizedEntries.some((entry) => !entry.dangerous), + match: { + matched: matchedEntryIds.length > 0, + matchedEntryIds, + }, + }; +} + +export function effectiveGroupSenderAllowlist(params: { + state: ChannelIngressState; + policy: ChannelIngressPolicyInput; +}): ResolvedIngressAllowlist { + let effective = + params.policy.groupAllowFromFallbackToAllowFrom && + !params.state.allowlists.group.hasConfiguredEntries + ? params.state.allowlists.dm + : params.state.allowlists.group; + for (const route of params.state.routeFacts) { + if (route.gate !== "matched" || !route.senderAllowlist) { + continue; + } + if (route.senderPolicy === "inherit") { + effective = mergeResolvedAllowlists([effective, route.senderAllowlist]); + continue; + } + effective = route.senderAllowlist; + } + return applyMutableIdentifierPolicy(effective, params.policy); +} diff --git a/src/channels/message-access/decision.ts b/src/channels/message-access/decision.ts new file mode 100644 index 00000000000..599309e3023 --- /dev/null +++ b/src/channels/message-access/decision.ts @@ -0,0 +1,327 @@ +import { resolveCommandAuthorizedFromAuthorizers } from "../command-gating.js"; +import { resolveInboundMentionDecision } from "../mention-gating.js"; +import { applyMutableIdentifierPolicy, redactedAllowlistDiagnostics } from "./allowlist.js"; +import { + applyEventAuthModeToSenderGate, + senderGateForDirect, + senderGateForGroup, +} from "./sender-gates.js"; +import type { + AccessGraphGate, + ChannelIngressDecision, + ChannelIngressPolicyInput, + ChannelIngressState, + RedactedIngressMatch, +} from "./types.js"; + +function decisiveDecision(params: { + admission: ChannelIngressDecision["admission"]; + decision: ChannelIngressDecision["decision"]; + gate: AccessGraphGate; + gates: AccessGraphGate[]; +}): ChannelIngressDecision { + return { + admission: params.admission, + decision: params.decision, + decisiveGateId: params.gate.id, + reasonCode: params.gate.reasonCode, + graph: { gates: params.gates }, + }; +} + +function routeGates(state: ChannelIngressState): AccessGraphGate[] { + return state.routeFacts.map((route) => ({ + id: route.id, + phase: "route", + kind: route.kind, + effect: route.effect, + allowed: route.effect !== "block-dispatch", + reasonCode: route.effect === "block-dispatch" ? "route_blocked" : "allowed", + match: route.match, + })); +} + +function routeSenderEmptyGate(state: ChannelIngressState): AccessGraphGate | null { + const route = state.routeFacts.find( + (fact) => + fact.senderPolicy === "deny-when-empty" && + fact.gate === "matched" && + fact.senderAllowlist?.hasConfiguredEntries !== true, + ); + if (!route) { + return null; + } + const reasonCode = "route_sender_empty"; + return { + id: `${route.id}:sender`, + phase: "route", + kind: "routeSender", + effect: "block-dispatch", + allowed: false, + reasonCode, + match: route.match, + allowlist: route.senderAllowlist + ? redactedAllowlistDiagnostics(route.senderAllowlist, reasonCode) + : undefined, + }; +} + +function commandGate(params: { + state: ChannelIngressState; + policy: ChannelIngressPolicyInput; +}): AccessGraphGate { + const command = params.policy.command; + if (!command) { + return { + id: "command", + phase: "command", + kind: "command", + effect: "allow", + allowed: true, + reasonCode: "command_authorized", + }; + } + const useAccessGroups = command.useAccessGroups ?? true; + const owner = applyMutableIdentifierPolicy(params.state.allowlists.commandOwner, params.policy); + const group = applyMutableIdentifierPolicy(params.state.allowlists.commandGroup, params.policy); + const authorized = resolveCommandAuthorizedFromAuthorizers({ + useAccessGroups, + modeWhenAccessGroupsOff: command.modeWhenAccessGroupsOff, + authorizers: [ + { configured: owner.hasConfiguredEntries, allowed: owner.match.matched }, + { configured: group.hasConfiguredEntries, allowed: group.match.matched }, + ], + }); + const shouldBlock = command.allowTextCommands && command.hasControlCommand && !authorized; + return { + id: "command", + phase: "command", + kind: "command", + effect: shouldBlock ? "block-command" : "allow", + allowed: authorized, + reasonCode: shouldBlock ? "control_command_unauthorized" : "command_authorized", + match: mergeCommandMatch(owner.match, group.match), + command: { + useAccessGroups, + allowTextCommands: command.allowTextCommands, + modeWhenAccessGroupsOff: command.modeWhenAccessGroupsOff, + shouldBlockControlCommand: shouldBlock, + }, + }; +} + +function mergeCommandMatch( + owner: RedactedIngressMatch, + group: RedactedIngressMatch, +): RedactedIngressMatch { + const matchedEntryIds = Array.from(new Set([...owner.matchedEntryIds, ...group.matchedEntryIds])); + return { + matched: owner.matched || group.matched || matchedEntryIds.length > 0, + matchedEntryIds, + }; +} + +function eventGate(params: { + state: ChannelIngressState; + senderGate: AccessGraphGate; + commandGate: AccessGraphGate; +}): AccessGraphGate { + const authMode = params.state.event.authMode; + const event = params.state.event; + const eventResult = ( + allowed: boolean, + reasonCode: AccessGraphGate["reasonCode"], + ): AccessGraphGate => ({ + id: "event", + phase: "event", + kind: "event", + effect: allowed ? "allow" : "block-dispatch", + allowed, + reasonCode, + event, + }); + if (authMode === "none" || authMode === "route-only") { + return eventResult(true, "event_authorized"); + } + if (authMode === "command") { + return eventResult( + params.commandGate.allowed, + params.commandGate.allowed ? "event_authorized" : "event_unauthorized", + ); + } + if (authMode === "origin-subject") { + if (!params.state.event.hasOriginSubject) { + return eventResult(false, "origin_subject_missing"); + } + const matched = params.state.event.originSubjectMatched; + return eventResult(matched, matched ? "event_authorized" : "origin_subject_not_matched"); + } + return eventResult( + params.senderGate.allowed, + params.senderGate.allowed ? "event_authorized" : "event_unauthorized", + ); +} + +function activationMetadata(params: { + activation?: ChannelIngressPolicyInput["activation"]; + mentionFacts: ChannelIngressState["mentionFacts"]; + shouldSkip: boolean; + effectiveWasMentioned?: boolean; + shouldBypassMention?: boolean; +}) { + const mentionFacts = params.mentionFacts; + return { + hasMentionFacts: mentionFacts != null, + requireMention: params.activation?.requireMention ?? false, + allowTextCommands: params.activation?.allowTextCommands ?? false, + ...(params.activation?.allowedImplicitMentionKinds !== undefined + ? { allowedImplicitMentionKinds: params.activation.allowedImplicitMentionKinds } + : {}), + ...(params.activation?.order ? { order: params.activation.order } : {}), + shouldSkip: params.shouldSkip, + ...(mentionFacts?.canDetectMention !== undefined + ? { canDetectMention: mentionFacts.canDetectMention } + : {}), + ...(mentionFacts?.wasMentioned !== undefined + ? { wasMentioned: mentionFacts.wasMentioned } + : {}), + ...(mentionFacts?.hasAnyMention !== undefined + ? { hasAnyMention: mentionFacts.hasAnyMention } + : {}), + ...(mentionFacts?.implicitMentionKinds !== undefined + ? { implicitMentionKinds: mentionFacts.implicitMentionKinds } + : {}), + ...(params.effectiveWasMentioned !== undefined + ? { effectiveWasMentioned: params.effectiveWasMentioned } + : {}), + ...(params.shouldBypassMention !== undefined + ? { shouldBypassMention: params.shouldBypassMention } + : {}), + }; +} + +function activationGate(params: { + state: ChannelIngressState; + policy: ChannelIngressPolicyInput; + commandGate: AccessGraphGate; +}): AccessGraphGate { + const activation = params.policy.activation; + const mentionFacts = params.state.mentionFacts; + const activationResult = (input: { + shouldSkip: boolean; + effectiveWasMentioned?: boolean; + shouldBypassMention?: boolean; + }): AccessGraphGate => ({ + id: "activation", + phase: "activation", + kind: "mention", + effect: input.shouldSkip ? "skip" : "allow", + allowed: !input.shouldSkip, + reasonCode: input.shouldSkip ? "activation_skipped" : "activation_allowed", + activation: activationMetadata({ + activation, + mentionFacts, + shouldSkip: input.shouldSkip, + effectiveWasMentioned: input.effectiveWasMentioned, + shouldBypassMention: input.shouldBypassMention, + }), + }); + if (!activation || !mentionFacts) { + return activationResult({ + shouldSkip: false, + effectiveWasMentioned: + mentionFacts && + (mentionFacts.wasMentioned || Boolean(mentionFacts.implicitMentionKinds?.length)), + }); + } + const result = resolveInboundMentionDecision({ + facts: mentionFacts, + policy: { + isGroup: params.state.conversationKind !== "direct", + requireMention: activation.requireMention, + allowedImplicitMentionKinds: activation.allowedImplicitMentionKinds, + allowTextCommands: activation.allowTextCommands, + hasControlCommand: params.policy.command?.hasControlCommand ?? false, + commandAuthorized: params.commandGate.allowed, + }, + }); + return activationResult({ + shouldSkip: result.shouldSkip, + effectiveWasMentioned: result.effectiveWasMentioned, + shouldBypassMention: result.shouldBypassMention, + }); +} + +export function decideChannelIngress( + state: ChannelIngressState, + policy: ChannelIngressPolicyInput, +): ChannelIngressDecision { + const gates: AccessGraphGate[] = routeGates(state); + const emptyRouteSenderGate = routeSenderEmptyGate(state); + if (emptyRouteSenderGate) { + gates.push(emptyRouteSenderGate); + } + const routeBlock = gates.find((entry) => entry.effect === "block-dispatch"); + if (routeBlock) { + return decisiveDecision({ admission: "drop", decision: "block", gate: routeBlock, gates }); + } + + const activationBeforeSender = + policy.activation?.order === "before-sender" && !policy.activation.allowTextCommands + ? activationGate({ + state, + policy, + commandGate: commandGate({ state, policy: { ...policy, command: undefined } }), + }) + : null; + if (activationBeforeSender) { + gates.push(activationBeforeSender); + if (activationBeforeSender.effect === "skip") { + return decisiveDecision({ + admission: "skip", + decision: "allow", + gate: activationBeforeSender, + gates, + }); + } + } + + const sender = + state.conversationKind === "direct" + ? senderGateForDirect({ state, policy }) + : senderGateForGroup({ state, policy }); + const eventModeSender = applyEventAuthModeToSenderGate({ state, senderGate: sender }); + gates.push(eventModeSender); + if (!eventModeSender.allowed) { + const admission = + eventModeSender.reasonCode === "dm_policy_pairing_required" ? "pairing-required" : "drop"; + const decision = + eventModeSender.reasonCode === "dm_policy_pairing_required" ? "pairing" : "block"; + return decisiveDecision({ admission, decision, gate: eventModeSender, gates }); + } + + const command = commandGate({ state, policy }); + gates.push(command); + if (command.effect === "block-command") { + return decisiveDecision({ admission: "drop", decision: "block", gate: command, gates }); + } + + const event = eventGate({ state, senderGate: eventModeSender, commandGate: command }); + gates.push(event); + if (!event.allowed) { + return decisiveDecision({ admission: "drop", decision: "block", gate: event, gates }); + } + + const activation = + activationBeforeSender ?? activationGate({ state, policy, commandGate: command }); + if (!activationBeforeSender) { + gates.push(activation); + } + if (activation.effect === "skip") { + return decisiveDecision({ admission: "skip", decision: "allow", gate: activation, gates }); + } + if (activation.effect === "observe") { + return decisiveDecision({ admission: "observe", decision: "allow", gate: activation, gates }); + } + return decisiveDecision({ admission: "dispatch", decision: "allow", gate: activation, gates }); +} diff --git a/src/channels/message-access/dm-allow-state.ts b/src/channels/message-access/dm-allow-state.ts new file mode 100644 index 00000000000..42cfb627058 --- /dev/null +++ b/src/channels/message-access/dm-allow-state.ts @@ -0,0 +1,45 @@ +import { normalizeStringEntries } from "../../shared/string-normalization.js"; +import type { ChannelId } from "../plugins/types.public.js"; +import { readChannelIngressStoreAllowFromForDmPolicy } from "./runtime.js"; + +export async function resolveDmAllowAuditState(params: { + provider: ChannelId; + accountId: string; + allowFrom?: Array | null; + dmPolicy?: string | null; + normalizeEntry?: (raw: string) => string; + readStore?: (provider: ChannelId, accountId: string) => Promise; +}): Promise<{ + configAllowFrom: string[]; + hasWildcard: boolean; + allowCount: number; + isMultiUserDm: boolean; +}> { + const configAllowFrom = normalizeStringEntries( + Array.isArray(params.allowFrom) ? params.allowFrom : undefined, + ); + const hasWildcard = configAllowFrom.includes("*"); + const storeAllowFrom = await readChannelIngressStoreAllowFromForDmPolicy({ + provider: params.provider, + accountId: params.accountId, + dmPolicy: params.dmPolicy, + readStore: params.readStore, + }); + const normalizeEntry = params.normalizeEntry ?? ((value: string) => value); + const normalizedCfg = configAllowFrom + .filter((value) => value !== "*") + .map((value) => normalizeEntry(value)) + .map((value) => value.trim()) + .filter(Boolean); + const normalizedStore = storeAllowFrom + .map((value) => normalizeEntry(value)) + .map((value) => value.trim()) + .filter(Boolean); + const allowCount = new Set([...normalizedCfg, ...normalizedStore]).size; + return { + configAllowFrom, + hasWildcard, + allowCount, + isMultiUserDm: hasWildcard || allowCount > 1, + }; +} diff --git a/src/channels/message-access/index.ts b/src/channels/message-access/index.ts new file mode 100644 index 00000000000..81de575841e --- /dev/null +++ b/src/channels/message-access/index.ts @@ -0,0 +1,31 @@ +export { decideChannelIngress } from "./decision.js"; +export { defineStableChannelIngressIdentity } from "./runtime-identity.js"; +export { + channelIngressRoutes, + createChannelIngressResolver, + readChannelIngressStoreAllowFromForDmPolicy, + resolveChannelMessageIngress, + resolveStableChannelMessageIngress, +} from "./runtime.js"; +export { resolveChannelIngressState } from "./state.js"; +export type { + ChannelIngressAccessGroupMembershipResolver, + ChannelIngressCommandPresetInput, + ChannelIngressConfigInput, + ChannelIngressEventPresetInput, + ChannelIngressIdentityAlias, + ChannelIngressIdentityDescriptor, + ChannelIngressIdentityField, + ChannelIngressIdentitySubjectInput, + ChannelIngressRouteAccess, + ChannelIngressRouteDescriptor, + ChannelIngressResolver, + ChannelIngressResolverMessageParams, + ChannelMessageIngressCommandInput, + CreateChannelIngressResolverParams, + ResolvedChannelMessageIngress, + ResolveChannelMessageIngressParams, + ResolveStableChannelMessageIngressParams, + StableChannelIngressIdentityParams, +} from "./runtime-types.js"; +export type * from "./types.js"; diff --git a/src/channels/message-access/message-access.test.ts b/src/channels/message-access/message-access.test.ts new file mode 100644 index 00000000000..de0a3c2e838 --- /dev/null +++ b/src/channels/message-access/message-access.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from "vitest"; +import { + decideChannelIngress, + resolveChannelIngressState, + type ChannelIngressPolicyInput, + type ChannelIngressStateInput, + type InternalChannelIngressAdapter, + type InternalChannelIngressSubject, +} from "./index.js"; + +const subject = (value: string): InternalChannelIngressSubject => ({ + identifiers: [{ opaqueId: "subject-1", kind: "stable-id", value }], +}); + +const adapter: InternalChannelIngressAdapter = { + normalizeEntries({ entries }) { + return { + matchable: entries.map((entry, index) => ({ + opaqueEntryId: `entry-${index + 1}`, + kind: "stable-id", + value: entry, + dangerous: entry.startsWith("display:"), + })), + invalid: [], + disabled: [], + }; + }, + matchSubject({ subject, entries }) { + const values = new Set(subject.identifiers.map((identifier) => identifier.value)); + const matchedEntryIds = entries + .filter((entry) => entry.value === "*" || values.has(entry.value)) + .map((entry) => entry.opaqueEntryId); + return { matched: matchedEntryIds.length > 0, matchedEntryIds }; + }, +}; + +const lowerCaseAdapter: InternalChannelIngressAdapter = { + normalizeEntries({ entries }) { + return { + matchable: entries.map((entry, index) => ({ + opaqueEntryId: `entry-${index + 1}`, + kind: "stable-id", + value: entry.toLowerCase(), + })), + invalid: [], + disabled: [], + }; + }, + matchSubject({ subject, entries }) { + const values = new Set(subject.identifiers.map((identifier) => identifier.value.toLowerCase())); + const matchedEntryIds = entries + .filter((entry) => entry.kind === "stable-id" && values.has(entry.value)) + .map((entry) => entry.opaqueEntryId); + return { matched: matchedEntryIds.length > 0, matchedEntryIds }; + }, +}; + +function baseInput(overrides: Partial = {}): ChannelIngressStateInput { + return { + channelId: "test", + accountId: "default", + subject: subject("sender-1"), + conversation: { kind: "direct", id: "dm-1" }, + adapter, + event: { kind: "message", authMode: "inbound", mayPair: true }, + allowlists: {}, + ...overrides, + }; +} + +const policy: ChannelIngressPolicyInput = { + dmPolicy: "pairing", + groupPolicy: "allowlist", +}; + +describe("channel message access ingress", () => { + it.each([ + { + name: "keeps pairing-store entries DM-policy scoped", + input: baseInput({ + subject: subject("paired-sender"), + allowlists: { pairingStore: ["paired-sender"] }, + }), + policy: { ...policy, dmPolicy: "open" as const }, + expected: { admission: "drop", reasonCode: "dm_policy_not_allowlisted" }, + secondPolicy: { ...policy, dmPolicy: "pairing" as const }, + secondExpected: { admission: "dispatch", decision: "allow" }, + }, + { + name: "requires explicit group fallback to DM allowlists", + input: baseInput({ + conversation: { kind: "group", id: "room-1" }, + allowlists: { dm: ["sender-1"] }, + }), + policy, + expected: { admission: "drop", reasonCode: "group_policy_empty_allowlist" }, + secondPolicy: { ...policy, groupAllowFromFallbackToAllowFrom: true }, + secondExpected: { admission: "dispatch", decision: "allow" }, + }, + { + name: "requires explicit dangerous identifier matching", + input: baseInput({ + subject: subject("display:sender-1"), + allowlists: { dm: ["display:sender-1"] }, + }), + policy: { ...policy, dmPolicy: "allowlist" as const }, + expected: { admission: "drop", reasonCode: "dm_policy_not_allowlisted" }, + secondPolicy: { + ...policy, + dmPolicy: "allowlist" as const, + mutableIdentifierMatching: "enabled" as const, + }, + secondExpected: { admission: "dispatch", decision: "allow" }, + }, + ])("$name", async ({ input, policy, expected, secondPolicy, secondExpected }) => { + const state = await resolveChannelIngressState(input); + expect(decideChannelIngress(state, policy)).toMatchObject(expected); + expect(decideChannelIngress(state, secondPolicy)).toMatchObject(secondExpected); + }); + + it("applies route sender allowlists without retaining raw sender values", async () => { + const rawSender = "route-sender@example.test"; + const state = await resolveChannelIngressState( + baseInput({ + subject: subject(rawSender), + conversation: { kind: "group", id: "room-1" }, + routeFacts: [ + { + id: "space-1", + kind: "route", + gate: "matched", + effect: "allow", + precedence: 0, + senderPolicy: "replace", + senderAllowFrom: [rawSender], + }, + ], + allowlists: { group: ["group-sender"] }, + }), + ); + + const decision = decideChannelIngress(state, policy); + + expect(state.routeFacts[0]?.senderAllowlist).toMatchObject({ + hasConfiguredEntries: true, + match: { matched: true }, + }); + expect(decision).toMatchObject({ admission: "dispatch", decision: "allow" }); + expect(JSON.stringify(state)).not.toContain(rawSender); + expect(JSON.stringify(decision)).not.toContain(rawSender); + }); + + it("blocks matched routes with deny-when-empty sender policy", async () => { + const state = await resolveChannelIngressState( + baseInput({ + routeFacts: [ + { + id: "space-1", + kind: "route", + gate: "matched", + effect: "allow", + precedence: 0, + senderPolicy: "deny-when-empty", + senderAllowFrom: [], + }, + ], + allowlists: { dm: ["sender-1"] }, + }), + ); + + expect(decideChannelIngress(state, policy)).toMatchObject({ + admission: "drop", + reasonCode: "route_sender_empty", + }); + expect(state.routeFacts[0]).not.toHaveProperty("senderAllowFrom"); + }); + + it.each([ + { + name: "allows origin-subject events for the same normalized actor", + adapter, + current: "sender-1", + origin: "sender-1", + matched: true, + expected: { admission: "dispatch", decision: "allow" }, + }, + { + name: "does not authorize by default opaque identifier slots", + adapter, + current: "sender-1", + origin: "different-sender", + matched: false, + expected: { admission: "drop", decision: "block", reasonCode: "origin_subject_not_matched" }, + }, + { + name: "uses adapter-normalized identity values", + adapter: lowerCaseAdapter, + current: "Sender-1", + origin: "sender-1", + matched: true, + expected: { admission: "dispatch", decision: "allow" }, + }, + ])("$name", async (entry) => { + const state = await resolveChannelIngressState( + baseInput({ + adapter: entry.adapter, + subject: subject(entry.current), + event: { + kind: "reaction", + authMode: "origin-subject", + mayPair: false, + originSubject: subject(entry.origin), + }, + }), + ); + const decision = decideChannelIngress(state, policy); + + expect(state.event.originSubjectMatched).toBe(entry.matched); + expect(decision).toMatchObject(entry.expected); + if (entry.matched) { + expect( + decision.graph.gates.find((gate) => gate.phase === "sender" && gate.kind === "dmSender"), + ).toMatchObject({ + effect: "ignore", + reasonCode: "sender_not_required", + }); + } + }); +}); diff --git a/src/channels/message-access/runtime-access-groups.ts b/src/channels/message-access/runtime-access-groups.ts new file mode 100644 index 00000000000..3ecd6bb72df --- /dev/null +++ b/src/channels/message-access/runtime-access-groups.ts @@ -0,0 +1,95 @@ +import { normalizeStringEntries } from "../../shared/string-normalization.js"; +import { parseAccessGroupAllowFromEntry } from "../allow-from.js"; +import type { ChannelIngressAdapter, ResolveChannelMessageIngressParams } from "./runtime-types.js"; +import type { AccessGroupMembershipFact, ChannelIngressChannelId } from "./types.js"; + +function uniqueValues(values: readonly T[]): T[] { + return Array.from(new Set(values)); +} + +function accessGroupNames(entries: readonly (string | number)[]): string[] { + return Array.from( + new Set( + entries + .map((entry) => parseAccessGroupAllowFromEntry(String(entry))) + .filter((entry): entry is string => entry != null), + ), + ); +} + +export function allReferencedAccessGroupNames( + entries: Array, +): string[] { + return Array.from(new Set(entries.flatMap((entryGroup) => accessGroupNames(entryGroup)))); +} + +export async function normalizeEffectiveEntries(params: { + adapter: ChannelIngressAdapter; + accountId: string; + entries: readonly (string | number)[]; + context: "dm" | "group" | "route" | "command"; +}): Promise { + const rawEntries = normalizeStringEntries(params.entries); + const accessGroupEntries = rawEntries.filter( + (entry) => parseAccessGroupAllowFromEntry(entry) != null, + ); + const directEntries = rawEntries.filter((entry) => parseAccessGroupAllowFromEntry(entry) == null); + if (directEntries.length === 0) { + return accessGroupEntries; + } + const normalized = await params.adapter.normalizeEntries({ + entries: directEntries, + context: params.context, + accountId: params.accountId, + }); + return uniqueValues([...accessGroupEntries, ...normalized.matchable.map((entry) => entry.value)]); +} + +export async function resolveRuntimeAccessGroupMembershipFacts(params: { + input: ResolveChannelMessageIngressParams; + channelId: ChannelIngressChannelId; + names: readonly string[]; +}): Promise { + if (!params.input.resolveAccessGroupMembership || params.names.length === 0) { + return []; + } + const facts: AccessGroupMembershipFact[] = []; + for (const name of params.names) { + const group = params.input.accessGroups?.[name]; + if (!group || group.type === "message.senders") { + continue; + } + try { + const matched = await params.input.resolveAccessGroupMembership({ + name, + group, + channelId: params.channelId, + accountId: params.input.accountId, + subject: params.input.subject, + }); + facts.push( + matched + ? { + kind: "matched", + groupName: name, + source: "dynamic", + matchedEntryIds: [`access-group:${name}`], + } + : { + kind: "not-matched", + groupName: name, + source: "dynamic", + }, + ); + } catch { + facts.push({ + kind: "failed", + groupName: name, + source: "dynamic", + reasonCode: "access_group_failed", + diagnosticId: `access-group:${name}`, + }); + } + } + return facts; +} diff --git a/src/channels/message-access/runtime-identity.ts b/src/channels/message-access/runtime-identity.ts new file mode 100644 index 00000000000..3650d656a96 --- /dev/null +++ b/src/channels/message-access/runtime-identity.ts @@ -0,0 +1,180 @@ +import type { + ChannelIngressAdapter, + ChannelIngressAdapterEntry, + ChannelIngressIdentityDescriptor, + ChannelIngressIdentityField, + ChannelIngressIdentitySubjectInput, + ChannelIngressSubject, + StableChannelIngressIdentityParams, +} from "./runtime-types.js"; +import type { InternalMatchMaterial } from "./types.js"; + +type ResolvedIdentityField = Required> & + Omit; + +/** Build an identity descriptor for channels with one stable id and optional aliases. */ +export function defineStableChannelIngressIdentity( + params: StableChannelIngressIdentityParams = {}, +): ChannelIngressIdentityDescriptor { + const { entryIdPrefix, resolveEntryId, aliases, isWildcardEntry, matchEntry, ...primary } = + params; + return { + primary, + aliases, + isWildcardEntry, + matchEntry, + resolveEntryId: + resolveEntryId ?? + (entryIdPrefix ? ({ entryIndex }) => `${entryIdPrefix}-${entryIndex + 1}` : undefined), + }; +} + +function defaultNormalize(value: string): string { + return value; +} + +function normalizeFieldValue( + field: ResolvedIdentityField, + value: string, + mode: "entry" | "subject", +): string | null { + const normalize = + mode === "entry" + ? (field.normalizeEntry ?? field.normalize ?? defaultNormalize) + : (field.normalizeSubject ?? field.normalize ?? defaultNormalize); + const normalized = normalize(value); + return normalized == null ? null : normalized.trim() || null; +} + +function fieldDangerous(field: ResolvedIdentityField, value: string): boolean | undefined { + return typeof field.dangerous === "function" ? field.dangerous(value) : field.dangerous; +} + +function identityFields(identity: ChannelIngressIdentityDescriptor): ResolvedIdentityField[] { + const fields: ResolvedIdentityField[] = [ + { + ...identity.primary, + key: identity.primary.key ?? "stableId", + kind: identity.primary.kind ?? "stable-id", + }, + ]; + for (const alias of identity.aliases ?? []) { + fields.push({ + ...alias, + kind: alias.kind ?? (`plugin:${alias.key}` as const), + }); + } + return fields; +} + +function identityMatchKey(entry: Pick): string { + return `${entry.kind}:${entry.value}`; +} + +function adapterEntry(params: { + identity: ChannelIngressIdentityDescriptor; + field: ResolvedIdentityField; + fieldIndex: number; + entry: string; + entryIndex: number; + value: string; + fallbackSuffix?: string; +}): ChannelIngressAdapterEntry { + return { + opaqueEntryId: + params.identity.resolveEntryId?.({ + entry: params.entry, + entryIndex: params.entryIndex, + fieldKey: params.field.key, + fieldIndex: params.fieldIndex, + }) ?? `entry-${params.entryIndex + 1}:${params.fallbackSuffix ?? params.field.key}`, + kind: params.field.kind, + value: params.value, + dangerous: fieldDangerous(params.field, params.entry), + sensitivity: params.field.sensitivity, + }; +} + +export function createIdentityAdapter( + identity: ChannelIngressIdentityDescriptor, +): ChannelIngressAdapter { + const fields = identityFields(identity); + const isWildcardEntry = identity.isWildcardEntry ?? ((value: string) => value === "*"); + return { + normalizeEntries({ entries }) { + const matchable = entries.flatMap((entry, entryIndex) => { + if (isWildcardEntry(entry)) { + return [ + adapterEntry({ + identity, + field: fields[0], + fieldIndex: 0, + entry, + entryIndex, + value: "*", + fallbackSuffix: "wildcard", + }), + ]; + } + return fields.flatMap((field, fieldIndex) => { + const value = normalizeFieldValue(field, entry, "entry"); + if (!value) { + return []; + } + return [adapterEntry({ identity, field, fieldIndex, entry, entryIndex, value })]; + }); + }); + return { + matchable, + invalid: [], + disabled: [], + }; + }, + matchSubject({ subject, entries, context }) { + const subjectKeys = new Set( + subject.identifiers.flatMap((identifier) => { + const field = fields.find((candidate) => candidate.kind === identifier.kind); + if (!field) { + return []; + } + const value = normalizeFieldValue(field, identifier.value, "subject"); + return value ? [identityMatchKey({ kind: identifier.kind, value })] : []; + }), + ); + const matchedEntryIds = entries + .filter((entry) => { + const fallback = entry.value === "*" || subjectKeys.has(identityMatchKey(entry)); + return identity.matchEntry?.({ subject, entry, context }) ?? fallback; + }) + .map((entry) => entry.opaqueEntryId); + return { + matched: matchedEntryIds.length > 0, + matchedEntryIds, + }; + }, + }; +} + +export function createIdentitySubject( + identity: ChannelIngressIdentityDescriptor, + input: ChannelIngressIdentitySubjectInput, +): ChannelIngressSubject { + const fields = identityFields(identity); + const identifiers: InternalMatchMaterial[] = fields.flatMap((field, index) => { + const rawValue = index === 0 ? input.stableId : input.aliases?.[field.key]; + if (rawValue == null) { + return []; + } + const value = String(rawValue); + return [ + { + opaqueId: field.key, + kind: field.kind, + value, + dangerous: fieldDangerous(field, value), + sensitivity: field.sensitivity, + }, + ]; + }); + return { identifiers }; +} diff --git a/src/channels/message-access/runtime-types.ts b/src/channels/message-access/runtime-types.ts new file mode 100644 index 00000000000..61bf303e632 --- /dev/null +++ b/src/channels/message-access/runtime-types.ts @@ -0,0 +1,368 @@ +import type { AccessGroupConfig } from "../../config/types.access-groups.js"; +import type { + AccessGroupMembershipFact, + AccessGraphGate, + ChannelIngressChannelId, + ChannelIngressDecision, + ChannelIngressEventInput, + ChannelIngressIdentifierKind, + ChannelIngressPolicyInput, + ChannelIngressState, + ChannelIngressStateInput, + IngressReasonCode, + InternalChannelIngressAdapter, + InternalChannelIngressSubject, + InternalMatchMaterial, + InternalNormalizedEntry, + RouteGateFacts, +} from "./types.js"; + +/** Normalized identifier material used to match an inbound sender against allowlist entries. */ +export type ChannelIngressSubjectIdentifier = InternalMatchMaterial; + +/** Redacted subject identity assembled from a stable id plus optional platform aliases. */ +export type ChannelIngressSubject = InternalChannelIngressSubject; + +/** Normalized allowlist entry material produced by a channel identity adapter. */ +export type ChannelIngressAdapterEntry = InternalNormalizedEntry; + +/** Adapter used by the ingress resolver to normalize entries and match subjects. */ +export type ChannelIngressAdapter = InternalChannelIngressAdapter; + +/** Describes one identity field used for stable ids or platform-specific aliases. */ +export type ChannelIngressIdentityField = { + /** Unique field key used in subject alias maps and diagnostics. */ + key?: string; + /** Redacted identifier kind written into the access graph. */ + kind?: ChannelIngressIdentifierKind; + /** Shared normalizer used for both entries and subjects when no side-specific normalizer exists. */ + normalize?: (value: string) => string | null | undefined; + /** Normalizes configured allowlist entries for this identity field. */ + normalizeEntry?: (value: string) => string | null | undefined; + /** Normalizes inbound subject values for this identity field. */ + normalizeSubject?: (value: string) => string | null | undefined; + /** Marks identifiers as dangerous in diagnostics, for example mutable display names. */ + dangerous?: boolean | ((value: string) => boolean | undefined); + /** Redaction hint for diagnostics and access graph consumers. */ + sensitivity?: "normal" | "pii"; +}; + +/** Named alias field such as email, phone, UUID, room id, or platform user id. */ +export type ChannelIngressIdentityAlias = ChannelIngressIdentityField & { + key: string; +}; + +/** Identity contract for a channel resolver. Plugins provide platform normalization here. */ +export type ChannelIngressIdentityDescriptor = { + /** Primary stable identity field. Prefer immutable sender ids when the platform has one. */ + primary: ChannelIngressIdentityField; + /** Additional identifiers that can match legacy or platform-specific allowlist entries. */ + aliases?: readonly ChannelIngressIdentityAlias[]; + /** Returns true when a raw allowlist entry should authorize every sender. */ + isWildcardEntry?: (value: string) => boolean; + /** Optional custom match hook for platform-specific identity equivalence. */ + matchEntry?: (params: { + subject: ChannelIngressSubject; + entry: ChannelIngressAdapterEntry; + context: "dm" | "group" | "route" | "command"; + }) => boolean | undefined; + /** Generates stable redacted entry ids for diagnostics. */ + resolveEntryId?: (params: { + entry: string; + entryIndex: number; + fieldKey: string; + fieldIndex: number; + }) => string; +}; + +/** Convenience input for defining a stable identity descriptor with optional aliases. */ +export type StableChannelIngressIdentityParams = ChannelIngressIdentityField & + Pick & { + /** Prefix used for generated entry ids when `resolveEntryId` is omitted. */ + entryIdPrefix?: string; + /** Custom entry-id generator used in redacted diagnostics. */ + resolveEntryId?: ChannelIngressIdentityDescriptor["resolveEntryId"]; + }; + +/** Raw sender identity passed by a plugin for one inbound event. */ +export type ChannelIngressIdentitySubjectInput = { + /** Stable sender id appended to effective allowlists when access groups matched. */ + stableId?: string | number | null; + /** Optional identity aliases keyed by `ChannelIngressIdentityAlias.key`. */ + aliases?: Record; +}; + +/** Minimal config subset consumed by the ingress resolver. */ +export type ChannelIngressConfigInput = { + /** Static or dynamic access group definitions referenced by allowlist entries. */ + accessGroups?: ChannelIngressStateInput["accessGroups"]; + /** Command config used for access-group command behavior. */ + commands?: { useAccessGroups?: boolean } | null; +} | null; + +/** Command gate input for control-command authorization. */ +export type ChannelMessageIngressCommandInput = NonNullable< + ChannelIngressPolicyInput["command"] +> & { + /** Explicit command-owner allowlist; defaults to effective DM allowlist. */ + commandOwnerAllowFrom?: Array | null; + /** Controls whether group command owners inherit configured DM owners. */ + groupOwnerAllowFrom?: "configured" | "none"; + /** Allows direct-message command checks to reuse effective group allowlists. */ + directGroupAllowFrom?: "effective" | "none"; + /** Group command allowFrom fallback, separate from normal group sender policy. */ + commandGroupAllowFromFallbackToAllowFrom?: boolean; +}; + +/** Preset form for command gates accepted by `createChannelIngressResolver`. */ +export type ChannelIngressCommandPresetInput = Omit< + Partial, + "useAccessGroups" +> & { + /** Set false to omit the command gate entirely. */ + requested?: boolean; + /** Overrides `cfg.commands.useAccessGroups` for this command decision. */ + useAccessGroups?: boolean | null; + /** Config subset used to derive command access-group behavior. */ + cfg?: ChannelIngressConfigInput; +}; + +/** Preset form for event gates accepted by `createChannelIngressResolver`. */ +export type ChannelIngressEventPresetInput = Partial & { + /** Convenience flag used to derive pairing defaults for group events. */ + isGroup?: boolean; +}; + +/** Optional route gate, such as a room, thread, topic, guild, or group route. */ +export type ChannelIngressRouteDescriptor = { + /** Stable route id used in diagnostics. */ + id: string; + /** Route kind for diagnostics and graph consumers. */ + kind?: RouteGateFacts["kind"]; + /** Whether this route policy is configured. */ + configured?: boolean; + /** Whether the inbound event matched this route. */ + matched?: boolean; + /** Whether this route admits the inbound event. */ + allowed?: boolean; + /** Whether to include this route descriptor in the graph. */ + enabled?: boolean; + /** Ordering hint when multiple route descriptors are supplied. */ + precedence?: number; + /** How route sender allowlists combine with effective channel allowlists. */ + senderPolicy?: RouteGateFacts["senderPolicy"]; + /** Route-specific sender allowlist entries. */ + senderAllowFrom?: Array | null; + /** Indicates whether route sender entries came from effective DM or group policy. */ + senderAllowFromSource?: RouteGateFacts["senderAllowFromSource"]; + /** Optional redacted match id for the route. */ + matchId?: string; + /** Reason used when this route blocks the event. */ + blockReason?: string; +}; + +/** Dynamic access-group resolver invoked for groups that need platform lookups. */ +export type ChannelIngressAccessGroupMembershipResolver = (params: { + name: string; + group: AccessGroupConfig; + channelId: ChannelIngressChannelId; + accountId: string; + subject: ChannelIngressIdentitySubjectInput; +}) => boolean | Promise; + +/** Complete input for resolving one inbound channel message or event. */ +export type ResolveChannelMessageIngressParams = { + /** Channel id used for config, diagnostics, access groups, and pairing-store reads. */ + channelId: ChannelIngressChannelId; + /** Account id scoped to this channel instance. */ + accountId: string; + /** Identity descriptor that normalizes sender and allowlist material. */ + identity: ChannelIngressIdentityDescriptor; + /** Inbound sender identity for this event. */ + subject: ChannelIngressIdentitySubjectInput; + /** Conversation classification and id. */ + conversation: ChannelIngressStateInput["conversation"]; + /** Event auth mode and pairing/origin-subject facts. */ + event: ChannelIngressEventInput; + /** Sender, command, event, route, and activation policy. */ + policy: ChannelIngressPolicyInput; + /** Raw direct-message allowlist entries. */ + allowFrom?: Array | null; + /** Raw group sender allowlist entries. */ + groupAllowFrom?: Array | null; + /** Route descriptors used to build route gates. */ + route?: ChannelIngressRouteDescriptor | readonly ChannelIngressRouteDescriptor[]; + /** Prebuilt route facts for lower-level callers. */ + routeFacts?: RouteGateFacts[]; + /** Access group config referenced by allowlist entries. */ + accessGroups?: ChannelIngressStateInput["accessGroups"]; + /** Precomputed access-group memberships for this subject. */ + accessGroupMembership?: readonly AccessGroupMembershipFact[]; + /** Resolver for dynamic access groups. */ + resolveAccessGroupMembership?: ChannelIngressAccessGroupMembershipResolver; + /** Concrete sender entry appended to effective allowlists when an access group matched. */ + accessGroupMatchedAllowFromEntry?: string | number | null; + /** Records whether a provider-specific missing-config fallback was applied. */ + providerMissingFallbackApplied?: boolean; + /** Mention or activation facts for activation gates. */ + mentionFacts?: ChannelIngressStateInput["mentionFacts"]; + /** Optional pairing-store reader for direct-message allowlist material. */ + readStoreAllowFrom?: (params: { + channelId: ChannelIngressChannelId; + accountId: string; + dmPolicy: ChannelIngressPolicyInput["dmPolicy"]; + }) => Promise; + /** Reads the default pairing store when no explicit reader is supplied. */ + useDefaultPairingStore?: boolean; + /** Command gate input; omit when no command policy is requested. */ + command?: ChannelMessageIngressCommandInput; +}; + +/** Shared resolver defaults for repeated events from the same channel account. */ +export type CreateChannelIngressResolverParams = Pick< + ResolveChannelMessageIngressParams, + | "channelId" + | "accountId" + | "identity" + | "accessGroups" + | "accessGroupMembership" + | "resolveAccessGroupMembership" + | "accessGroupMatchedAllowFromEntry" + | "readStoreAllowFrom" + | "useDefaultPairingStore" +> & { + /** Config subset used for access groups and command behavior. */ + cfg?: ChannelIngressConfigInput; + /** Global override for access-group expansion in this resolver. */ + useAccessGroups?: boolean | null; + /** Default DM policy for message calls that omit it. */ + defaultDmPolicy?: ChannelIngressPolicyInput["dmPolicy"]; + /** Default group policy for message calls that omit it. */ + defaultGroupPolicy?: ChannelIngressPolicyInput["groupPolicy"]; + /** Default group allowlist fallback behavior. */ + groupAllowFromFallbackToAllowFrom?: boolean; + /** Mutable identifier matching policy for this resolver. */ + mutableIdentifierMatching?: ChannelIngressPolicyInput["mutableIdentifierMatching"]; +}; + +/** Per-message input for a resolver created by `createChannelIngressResolver`. */ +export type ChannelIngressResolverMessageParams = Omit< + ResolveChannelMessageIngressParams, + | "channelId" + | "accountId" + | "identity" + | "accessGroups" + | "resolveAccessGroupMembership" + | "accessGroupMatchedAllowFromEntry" + | "readStoreAllowFrom" + | "useDefaultPairingStore" + | "event" + | "policy" + | "command" +> & { + /** Event facts or presets; defaults to a normal inbound message event. */ + event?: ChannelIngressEventInput | ChannelIngressEventPresetInput; + /** DM policy override for this event. */ + dmPolicy?: ChannelIngressPolicyInput["dmPolicy"]; + /** Group policy override for this event. */ + groupPolicy?: ChannelIngressPolicyInput["groupPolicy"]; + /** Additional policy fields merged with resolver defaults. */ + policy?: Partial>; + /** Command gate input, preset, or false to suppress command checks. */ + command?: ChannelMessageIngressCommandInput | ChannelIngressCommandPresetInput | false; +}; + +/** Reusable high-level ingress resolver for message, command, and event surfaces. */ +export type ChannelIngressResolver = { + /** Resolve a normal inbound message with sender, route, command, event, and activation gates. */ + message(params: ChannelIngressResolverMessageParams): Promise; + /** Resolve a command-oriented event with command auth defaults enabled. */ + command(params: ChannelIngressResolverMessageParams): Promise; + /** Resolve a non-message event with event-gate defaults enabled. */ + event(params: ChannelIngressResolverMessageParams): Promise; +}; + +/** One-shot helper input using a simple stable identity descriptor. */ +export type ResolveStableChannelMessageIngressParams = Omit< + CreateChannelIngressResolverParams, + "identity" +> & + ChannelIngressResolverMessageParams & { identity?: StableChannelIngressIdentityParams }; + +/** Sender/conversation projection consumed by channel handlers. */ +export type ChannelIngressSenderAccess = { + /** True when the sender gate admits the event. */ + allowed: boolean; + /** Final ingress decision after all gates, not just the sender gate. */ + decision: ChannelIngressDecision["decision"]; + /** Sender gate reason when present, otherwise decisive ingress reason. */ + reasonCode: IngressReasonCode; + /** Sender gate from the access graph, when one ran. */ + gate?: AccessGraphGate; + /** Effective DM allowlist entries after store and access-group processing. */ + effectiveAllowFrom: string[]; + /** Effective group allowlist entries after fallback and access-group processing. */ + effectiveGroupAllowFrom: string[]; + /** Whether provider-specific fallback behavior was applied. */ + providerMissingFallbackApplied: boolean; +}; + +/** Command projection consumed by channel command/control handlers. */ +export type ChannelIngressCommandAccess = { + /** True when a command gate was requested for this event. */ + requested: boolean; + /** True when the command gate authorizes this sender. */ + authorized: boolean; + /** True when an unauthorized control command should be blocked. */ + shouldBlockControlCommand: boolean; + /** Command gate reason when present, otherwise decisive ingress reason. */ + reasonCode: IngressReasonCode; + /** Command gate from the access graph, when one ran. */ + gate?: AccessGraphGate; +}; + +/** Route projection consumed by room/thread/topic handlers. */ +export type ChannelIngressRouteAccess = { + /** True when all configured route gates admit the event. */ + allowed: boolean; + /** Route gate reason when a route gate decided. */ + reasonCode?: IngressReasonCode; + /** Optional route-specific reason text. */ + reason?: string; + /** Route gate from the access graph, when one ran. */ + gate?: AccessGraphGate; +}; + +/** Activation/mention projection consumed by group handlers. */ +export type ChannelIngressActivationAccess = { + /** True when an activation gate ran. */ + ran: boolean; + /** True when activation admits the event. */ + allowed: boolean; + /** True when the event should be skipped instead of dispatched. */ + shouldSkip: boolean; + /** Activation gate reason when present, otherwise decisive ingress reason. */ + reasonCode: IngressReasonCode; + /** Effective mention match after command bypass and activation policy. */ + effectiveWasMentioned?: boolean; + /** True when mention gating was bypassed by policy or command facts. */ + shouldBypassMention?: boolean; + /** Activation gate from the access graph, when one ran. */ + gate?: AccessGraphGate; +}; + +/** Full ingress result returned by runtime resolvers. */ +export type ResolvedChannelMessageIngress = { + /** Redacted normalized state used as input to the decision engine. */ + state: ChannelIngressState; + /** Ordered access graph plus final admission decision. */ + ingress: ChannelIngressDecision; + /** Sender/conversation projection. */ + senderAccess: ChannelIngressSenderAccess; + /** Route projection. */ + routeAccess: ChannelIngressRouteAccess; + /** Command projection. */ + commandAccess: ChannelIngressCommandAccess; + /** Activation/mention projection. */ + activationAccess: ChannelIngressActivationAccess; +}; diff --git a/src/channels/message-access/runtime.ts b/src/channels/message-access/runtime.ts new file mode 100644 index 00000000000..598aaedb9a2 --- /dev/null +++ b/src/channels/message-access/runtime.ts @@ -0,0 +1,722 @@ +import { readChannelAllowFromStore } from "../../pairing/pairing-store.js"; +import type { PairingChannel } from "../../pairing/pairing-store.types.js"; +import { normalizeStringEntries } from "../../shared/string-normalization.js"; +import { mergeDmAllowFromSources, resolveGroupAllowFromSources } from "../allow-from.js"; +import { decideChannelIngress } from "./decision.js"; +import { + allReferencedAccessGroupNames, + normalizeEffectiveEntries, + resolveRuntimeAccessGroupMembershipFacts, +} from "./runtime-access-groups.js"; +import { + createIdentityAdapter, + createIdentitySubject, + defineStableChannelIngressIdentity, +} from "./runtime-identity.js"; +import type { + ChannelMessageIngressCommandInput, + ChannelIngressCommandPresetInput, + ChannelIngressEventPresetInput, + ChannelIngressActivationAccess, + ChannelIngressCommandAccess, + ChannelIngressRouteAccess, + ChannelIngressRouteDescriptor, + ChannelIngressResolver, + ChannelIngressResolverMessageParams, + ChannelIngressSenderAccess, + CreateChannelIngressResolverParams, + ResolveChannelMessageIngressParams, + ResolveStableChannelMessageIngressParams, + ResolvedChannelMessageIngress, +} from "./runtime-types.js"; +import { resolveChannelIngressState } from "./state.js"; +import type { + AccessGraphGate, + ChannelIngressChannelId, + ChannelIngressEventInput, + ChannelIngressPolicyInput, + ChannelIngressStateInput, + RedactedIngressMatch, + ResolvedIngressAllowlist, + RouteGateFacts, + RouteSenderPolicy, +} from "./types.js"; + +type RouteFactDefaults = { + id: string; + kind?: RouteGateFacts["kind"]; + precedence?: number; + senderPolicy?: RouteSenderPolicy; + senderAllowFrom?: Array; + senderAllowFromSource?: RouteGateFacts["senderAllowFromSource"]; + match?: RedactedIngressMatch; +}; + +function shouldReadStore(params: { + conversationKind: ChannelIngressStateInput["conversation"]["kind"]; + dmPolicy: ChannelIngressPolicyInput["dmPolicy"]; +}): boolean { + return ( + params.conversationKind === "direct" && + params.dmPolicy !== "allowlist" && + params.dmPolicy !== "open" + ); +} + +/** + * Merge configured direct, group, and pairing-store allowlists into the + * effective lists consumed by sender and context-visibility checks. + */ +export function resolveChannelIngressEffectiveAllowFromLists(params: { + allowFrom?: Array | null; + groupAllowFrom?: Array | null; + storeAllowFrom?: Array | null; + dmPolicy?: string | null; + groupAllowFromFallbackToAllowFrom?: boolean | null; +}): { + effectiveAllowFrom: string[]; + effectiveGroupAllowFrom: string[]; +} { + const allowFrom = Array.isArray(params.allowFrom) ? params.allowFrom : undefined; + const groupAllowFrom = Array.isArray(params.groupAllowFrom) ? params.groupAllowFrom : undefined; + const storeAllowFrom = Array.isArray(params.storeAllowFrom) ? params.storeAllowFrom : undefined; + const effectiveAllowFrom = normalizeStringEntries( + mergeDmAllowFromSources({ + allowFrom, + storeAllowFrom, + dmPolicy: params.dmPolicy ?? undefined, + }), + ); + const effectiveGroupAllowFrom = normalizeStringEntries( + resolveGroupAllowFromSources({ + allowFrom, + groupAllowFrom, + fallbackToAllowFrom: params.groupAllowFromFallbackToAllowFrom ?? undefined, + }), + ); + return { effectiveAllowFrom, effectiveGroupAllowFrom }; +} + +/** + * Read pairing-store allowlist entries when a direct-message policy permits + * store fallback. + */ +export async function readChannelIngressStoreAllowFromForDmPolicy(params: { + provider: PairingChannel; + accountId: string; + dmPolicy?: string | null; + shouldRead?: boolean | null; + readStore?: (provider: PairingChannel, accountId: string) => Promise; +}): Promise { + if ( + params.shouldRead === false || + params.dmPolicy === "allowlist" || + params.dmPolicy === "open" + ) { + return []; + } + const readStore = + params.readStore ?? + ((provider: PairingChannel, accountId: string) => + readChannelAllowFromStore(provider, process.env, accountId)); + return await readStore(params.provider, params.accountId).catch(() => []); +} + +async function readStoreAllowFrom( + params: ResolveChannelMessageIngressParams & { channelId: ChannelIngressChannelId }, +): Promise> { + if ( + !shouldReadStore({ + conversationKind: params.conversation.kind, + dmPolicy: params.policy.dmPolicy, + }) + ) { + return []; + } + const entries = params.readStoreAllowFrom + ? await params + .readStoreAllowFrom({ + channelId: params.channelId, + accountId: params.accountId, + dmPolicy: params.policy.dmPolicy, + }) + .catch(() => []) + : params.useDefaultPairingStore + ? await readChannelIngressStoreAllowFromForDmPolicy({ + provider: params.channelId as PairingChannel, + accountId: params.accountId, + dmPolicy: params.policy.dmPolicy, + }) + : []; + return [...(entries ?? [])]; +} + +function commandRequested(policy: ChannelIngressPolicyInput): boolean { + return policy.command != null; +} + +function normalizeChannelId(id: string): ChannelIngressChannelId { + const trimmed = id.trim(); + if (!trimmed) { + throw new Error("Channel ingress channel id must be non-empty."); + } + return trimmed; +} + +function findIngressGate(params: { + ingress: ResolvedChannelMessageIngress["ingress"]; + phase: AccessGraphGate["phase"]; + kind: AccessGraphGate["kind"]; +}): AccessGraphGate | undefined { + return params.ingress.graph.gates.find( + (gate) => gate.phase === params.phase && gate.kind === params.kind, + ); +} + +function findSenderGate( + ingress: ResolvedChannelMessageIngress["ingress"], + isGroup: boolean, +): AccessGraphGate | undefined { + return findIngressGate({ + ingress, + phase: "sender", + kind: isGroup ? "groupSender" : "dmSender", + }); +} + +function useAccessGroupsFromConfig(params: { + useAccessGroups?: boolean | null; + cfg?: ChannelIngressCommandPresetInput["cfg"]; +}): boolean { + return params.useAccessGroups ?? params.cfg?.commands?.useAccessGroups !== false; +} + +function channelIngressCommand( + params: ChannelIngressCommandPresetInput = {}, +): ChannelMessageIngressCommandInput | undefined { + if (params.requested === false) { + return undefined; + } + const { requested: _requested, cfg, ...command } = params; + return { + ...command, + useAccessGroups: useAccessGroupsFromConfig({ + useAccessGroups: params.useAccessGroups, + cfg, + }), + allowTextCommands: params.allowTextCommands ?? false, + hasControlCommand: params.hasControlCommand ?? true, + }; +} + +function channelIngressEvent( + params: ChannelIngressEventPresetInput = {}, +): ChannelIngressEventInput { + const isGroup = params.isGroup ?? false; + return { + kind: params.kind ?? "message", + authMode: params.authMode ?? "inbound", + mayPair: params.mayPair ?? !isGroup, + ...(params.originSubject ? { originSubject: params.originSubject } : {}), + }; +} + +function resolveCommandInput(params: { + command?: ChannelIngressResolverMessageParams["command"]; + useAccessGroups?: boolean | null; +}): ChannelMessageIngressCommandInput | undefined { + if (params.command === false || params.command == null) { + return undefined; + } + return channelIngressCommand({ + ...params.command, + useAccessGroups: params.command.useAccessGroups ?? params.useAccessGroups, + }); +} + +function resolveResolverPolicy(params: { + base: CreateChannelIngressResolverParams; + input: ChannelIngressResolverMessageParams; +}): ChannelIngressPolicyInput { + return { + dmPolicy: params.input.dmPolicy ?? params.base.defaultDmPolicy ?? "pairing", + groupPolicy: params.input.groupPolicy ?? params.base.defaultGroupPolicy ?? "disabled", + groupAllowFromFallbackToAllowFrom: + params.input.policy?.groupAllowFromFallbackToAllowFrom ?? + params.base.groupAllowFromFallbackToAllowFrom, + mutableIdentifierMatching: + params.input.policy?.mutableIdentifierMatching ?? params.base.mutableIdentifierMatching, + ...(params.input.policy?.activation ? { activation: params.input.policy.activation } : {}), + }; +} + +/** + * Create a reusable ingress resolver for one channel account and identity + * descriptor. + */ +export function createChannelIngressResolver( + base: CreateChannelIngressResolverParams, +): ChannelIngressResolver { + const resolve = async ( + input: ChannelIngressResolverMessageParams, + eventDefaults?: ChannelIngressEventPresetInput, + ) => { + const isGroup = input.conversation.kind !== "direct"; + const useAccessGroups = useAccessGroupsFromConfig({ + useAccessGroups: base.useAccessGroups, + cfg: base.cfg, + }); + return await resolveChannelMessageIngress({ + channelId: base.channelId, + accountId: base.accountId, + identity: base.identity, + subject: input.subject, + conversation: input.conversation, + event: channelIngressEvent({ + isGroup, + ...eventDefaults, + ...input.event, + }), + policy: resolveResolverPolicy({ base, input }), + allowFrom: input.allowFrom, + groupAllowFrom: input.groupAllowFrom, + route: input.route, + routeFacts: input.routeFacts, + accessGroups: base.accessGroups ?? base.cfg?.accessGroups, + accessGroupMembership: [ + ...(base.accessGroupMembership ?? []), + ...(input.accessGroupMembership ?? []), + ], + resolveAccessGroupMembership: base.resolveAccessGroupMembership, + accessGroupMatchedAllowFromEntry: base.accessGroupMatchedAllowFromEntry, + providerMissingFallbackApplied: input.providerMissingFallbackApplied, + mentionFacts: input.mentionFacts, + readStoreAllowFrom: base.readStoreAllowFrom, + useDefaultPairingStore: base.useDefaultPairingStore, + command: resolveCommandInput({ + command: input.command, + useAccessGroups, + }), + }); + }; + return { + message: async (input) => await resolve(input), + command: async (input) => + await resolve(input, { + authMode: "command", + mayPair: false, + }), + event: async (input) => await resolve(input, { mayPair: false }), + }; +} + +/** + * Resolve one inbound event using a simple stable subject identity descriptor. + */ +export async function resolveStableChannelMessageIngress( + params: ResolveStableChannelMessageIngressParams, +): Promise { + return await createChannelIngressResolver({ + ...params, + identity: defineStableChannelIngressIdentity(params.identity), + }).message(params); +} + +function routeDescriptors( + route: ResolveChannelMessageIngressParams["route"], +): ChannelIngressRouteDescriptor[] { + if (!route) { + return []; + } + if (Array.isArray(route)) { + return [...route]; + } + return [route as ChannelIngressRouteDescriptor]; +} + +/** + * Collect optional route descriptors while dropping false, null, and undefined + * entries. + */ +export function channelIngressRoutes( + ...routes: Array +): ChannelIngressRouteDescriptor[] { + return routes.filter((route): route is ChannelIngressRouteDescriptor => Boolean(route)); +} + +function routeDescriptorMatch(descriptor: ChannelIngressRouteDescriptor) { + const matched = descriptor.matched ?? descriptor.allowed ?? descriptor.enabled !== false; + return { + matched, + matchedEntryIds: matched && descriptor.matchId ? [descriptor.matchId] : [], + }; +} + +function routeFact( + params: RouteFactDefaults & Pick, +): RouteGateFacts { + return { + id: params.id, + kind: params.kind ?? "route", + gate: params.gate, + effect: params.effect, + precedence: params.precedence ?? 0, + senderPolicy: params.senderPolicy ?? "inherit", + senderAllowFrom: params.senderAllowFrom, + senderAllowFromSource: params.senderAllowFromSource, + match: params.match, + }; +} + +function routeFactDefaults(descriptor: ChannelIngressRouteDescriptor) { + return { + id: descriptor.id, + ...(descriptor.kind ? { kind: descriptor.kind } : {}), + ...(descriptor.precedence !== undefined ? { precedence: descriptor.precedence } : {}), + ...(descriptor.senderPolicy ? { senderPolicy: descriptor.senderPolicy } : {}), + ...(descriptor.senderAllowFrom != null + ? { senderAllowFrom: [...descriptor.senderAllowFrom] } + : {}), + ...(descriptor.senderAllowFromSource + ? { senderAllowFromSource: descriptor.senderAllowFromSource } + : {}), + match: routeDescriptorMatch(descriptor), + }; +} + +function routeFactsFromDescriptors( + route: ResolveChannelMessageIngressParams["route"], +): RouteGateFacts[] { + return routeDescriptors(route).flatMap((descriptor) => { + if (descriptor.configured === false) { + return []; + } + const defaults = routeFactDefaults(descriptor); + if (descriptor.enabled === false) { + return [routeFact({ ...defaults, gate: "disabled", effect: "block-dispatch" })]; + } + if (descriptor.allowed !== undefined) { + return [ + routeFact({ + ...defaults, + gate: descriptor.allowed ? "matched" : "not-matched", + effect: descriptor.allowed ? "allow" : "block-dispatch", + }), + ]; + } + if ( + descriptor.senderPolicy !== "deny-when-empty" && + descriptor.senderAllowFrom == null && + descriptor.senderAllowFromSource == null + ) { + return []; + } + return [ + routeFact({ + ...defaults, + kind: descriptor.senderPolicy === "deny-when-empty" ? defaults.kind : "routeSender", + gate: "matched", + effect: "allow", + senderPolicy: + descriptor.senderPolicy === "deny-when-empty" ? "deny-when-empty" : defaults.senderPolicy, + }), + ]; + }); +} + +function routeDescriptorForGate(params: { + descriptors: readonly ChannelIngressRouteDescriptor[]; + gate: AccessGraphGate; +}): ChannelIngressRouteDescriptor | undefined { + const senderSuffix = ":sender"; + const baseGateId = params.gate.id.endsWith(senderSuffix) + ? params.gate.id.slice(0, -senderSuffix.length) + : params.gate.id; + return params.descriptors.find( + (descriptor) => descriptor.id === params.gate.id || descriptor.id === baseGateId, + ); +} + +function projectRouteAccess(params: { + ingress: ResolvedChannelMessageIngress["ingress"]; + route: ResolveChannelMessageIngressParams["route"]; +}): ChannelIngressRouteAccess { + const descriptors = routeDescriptors(params.route); + const routeBlock = params.ingress.graph.gates.find( + (entry) => entry.phase === "route" && entry.effect === "block-dispatch", + ); + if (routeBlock) { + const descriptor = routeDescriptorForGate({ descriptors, gate: routeBlock }); + return { + allowed: routeBlock.allowed, + reasonCode: routeBlock.reasonCode, + ...(descriptor?.blockReason ? { reason: descriptor.blockReason } : {}), + gate: routeBlock, + }; + } + const routeSenderReplacement = descriptors.find( + (descriptor) => descriptor.senderPolicy === "replace" && descriptor.blockReason, + ); + const senderBlock = params.ingress.graph.gates.find( + (entry) => entry.phase === "sender" && entry.effect === "block-dispatch", + ); + if (routeSenderReplacement && senderBlock) { + return { + allowed: false, + reasonCode: senderBlock.reasonCode, + reason: routeSenderReplacement.blockReason, + gate: senderBlock, + }; + } + const gate = params.ingress.graph.gates.find((entry) => entry.phase === "route"); + if (gate) { + return { + allowed: gate.allowed, + reasonCode: gate.reasonCode, + gate, + }; + } + return { allowed: true }; +} + +function projectSenderAccess(params: { + ingress: ResolvedChannelMessageIngress["ingress"]; + isGroup: boolean; + effectiveAllowFrom: string[]; + effectiveGroupAllowFrom: string[]; + providerMissingFallbackApplied?: boolean; +}): ChannelIngressSenderAccess { + const gate = findSenderGate(params.ingress, params.isGroup); + const reasonCode = + !gate && + params.isGroup && + params.ingress.reasonCode === "route_sender_empty" && + params.effectiveGroupAllowFrom.length === 0 + ? "group_policy_empty_allowlist" + : (gate?.reasonCode ?? params.ingress.reasonCode); + const decision = + reasonCode === "dm_policy_pairing_required" + ? "pairing" + : gate?.allowed === true + ? "allow" + : "block"; + return { + allowed: decision === "allow", + decision, + reasonCode, + ...(gate ? { gate } : {}), + effectiveAllowFrom: params.effectiveAllowFrom, + effectiveGroupAllowFrom: params.effectiveGroupAllowFrom, + providerMissingFallbackApplied: params.providerMissingFallbackApplied ?? false, + }; +} + +function projectCommandAccess(params: { + ingress: ResolvedChannelMessageIngress["ingress"]; + policy: ChannelIngressPolicyInput; +}): ChannelIngressCommandAccess { + const gate = findIngressGate({ + ingress: params.ingress, + phase: "command", + kind: "command", + }); + return { + requested: commandRequested(params.policy), + authorized: commandRequested(params.policy) ? gate?.allowed === true : false, + shouldBlockControlCommand: gate?.command?.shouldBlockControlCommand === true, + reasonCode: gate?.reasonCode ?? params.ingress.reasonCode, + ...(gate ? { gate } : {}), + }; +} + +function projectActivationAccess(params: { + ingress: ResolvedChannelMessageIngress["ingress"]; +}): ChannelIngressActivationAccess { + const gate = findIngressGate({ + ingress: params.ingress, + phase: "activation", + kind: "mention", + }); + return { + ran: gate != null, + allowed: gate?.allowed === true, + shouldSkip: gate?.activation?.shouldSkip === true, + reasonCode: gate?.reasonCode ?? params.ingress.reasonCode, + ...(gate?.activation?.effectiveWasMentioned !== undefined + ? { effectiveWasMentioned: gate.activation.effectiveWasMentioned } + : {}), + ...(gate?.activation?.shouldBypassMention !== undefined + ? { shouldBypassMention: gate.activation.shouldBypassMention } + : {}), + ...(gate ? { gate } : {}), + }; +} + +function commandOwnerAllowFrom(params: { + command?: ChannelMessageIngressCommandInput; + isGroup: boolean; + configuredAllowFrom: Array; + effectiveAllowFrom: string[]; +}): Array { + if (params.command?.commandOwnerAllowFrom != null) { + return params.command.commandOwnerAllowFrom; + } + if (!params.isGroup) { + return params.effectiveAllowFrom; + } + return params.command?.groupOwnerAllowFrom === "none" ? [] : params.configuredAllowFrom; +} + +function commandGroupAllowFrom(params: { + command?: ChannelMessageIngressCommandInput; + isGroup: boolean; + effectiveCommandGroupAllowFrom: string[]; +}): Array { + if (params.isGroup) { + return params.effectiveCommandGroupAllowFrom; + } + return params.command?.directGroupAllowFrom === "effective" + ? params.effectiveCommandGroupAllowFrom + : []; +} + +function accessGroupMatchedEntry(params: ResolveChannelMessageIngressParams): string | null { + const entry = params.accessGroupMatchedAllowFromEntry ?? params.subject.stableId; + return entry == null ? null : String(entry); +} + +function appendAccessGroupMatchedEntry(params: { + entries: string[]; + allowlist: ResolvedIngressAllowlist; + matchedEntry: string | null; +}): string[] { + return params.matchedEntry && params.allowlist.accessGroups.matched.length > 0 + ? Array.from(new Set([...params.entries, params.matchedEntry])) + : params.entries; +} + +/** + * Resolve sender, route, command, event, and activation gates for one inbound + * channel event. + */ +export async function resolveChannelMessageIngress( + params: ResolveChannelMessageIngressParams, +): Promise { + const channelId = normalizeChannelId(params.channelId); + const adapter = createIdentityAdapter(params.identity); + const subject = createIdentitySubject(params.identity, params.subject); + const routeFacts = [...routeFactsFromDescriptors(params.route), ...(params.routeFacts ?? [])]; + const storeAllowFrom = await readStoreAllowFrom({ ...params, channelId }); + const rawAllowFrom = normalizeStringEntries(params.allowFrom ?? []); + const rawStoreAllowFrom = normalizeStringEntries(storeAllowFrom); + const rawGroupAllowFrom = normalizeStringEntries(params.groupAllowFrom ?? []); + const normalizeEffective = (entries: readonly (string | number)[], context: "dm" | "group") => + normalizeEffectiveEntries({ adapter, accountId: params.accountId, entries, context }); + const [normalizedAllowFrom, normalizedStoreAllowFrom, normalizedGroupAllowFrom] = + await Promise.all([ + normalizeEffective(rawAllowFrom, "dm"), + normalizeEffective(rawStoreAllowFrom, "dm"), + normalizeEffective(rawGroupAllowFrom, "group"), + ]); + const referencedAccessGroups = allReferencedAccessGroupNames([ + rawAllowFrom, + rawGroupAllowFrom, + rawStoreAllowFrom, + params.command?.commandOwnerAllowFrom ?? [], + ...routeFacts.map((route) => route.senderAllowFrom ?? []), + ]); + const runtimeAccessGroupMembership = await resolveRuntimeAccessGroupMembershipFacts({ + input: params, + channelId, + names: referencedAccessGroups, + }); + const accessGroupMembership = [ + ...runtimeAccessGroupMembership, + ...(params.accessGroupMembership ?? []), + ]; + const baseEffective = resolveChannelIngressEffectiveAllowFromLists({ + allowFrom: normalizedAllowFrom, + groupAllowFrom: normalizedGroupAllowFrom, + storeAllowFrom: normalizedStoreAllowFrom, + dmPolicy: params.policy.dmPolicy, + groupAllowFromFallbackToAllowFrom: params.policy.groupAllowFromFallbackToAllowFrom, + }); + const rawEffective = resolveChannelIngressEffectiveAllowFromLists({ + allowFrom: rawAllowFrom, + groupAllowFrom: rawGroupAllowFrom, + storeAllowFrom: rawStoreAllowFrom, + dmPolicy: params.policy.dmPolicy, + groupAllowFromFallbackToAllowFrom: params.policy.groupAllowFromFallbackToAllowFrom, + }); + const rawCommandGroup = resolveChannelIngressEffectiveAllowFromLists({ + allowFrom: rawAllowFrom, + groupAllowFrom: rawGroupAllowFrom, + dmPolicy: params.policy.dmPolicy, + groupAllowFromFallbackToAllowFrom: + params.command?.commandGroupAllowFromFallbackToAllowFrom ?? + params.policy.groupAllowFromFallbackToAllowFrom, + }); + const isGroup = params.conversation.kind !== "direct"; + const policy: ChannelIngressPolicyInput = { + ...params.policy, + ...(params.command !== undefined ? { command: params.command } : {}), + }; + const state = await resolveChannelIngressState({ + channelId, + accountId: params.accountId, + subject, + conversation: params.conversation, + adapter, + accessGroups: params.accessGroups, + accessGroupMembership, + routeFacts, + mentionFacts: params.mentionFacts, + event: params.event, + allowlists: { + dm: rawAllowFrom, + group: rawEffective.effectiveGroupAllowFrom, + pairingStore: rawStoreAllowFrom, + commandOwner: commandOwnerAllowFrom({ + command: params.command, + isGroup, + configuredAllowFrom: rawAllowFrom, + effectiveAllowFrom: rawEffective.effectiveAllowFrom, + }), + commandGroup: commandGroupAllowFrom({ + command: params.command, + isGroup, + effectiveCommandGroupAllowFrom: rawCommandGroup.effectiveGroupAllowFrom, + }), + }, + }); + const ingress = decideChannelIngress(state, policy); + const matchedAccessGroupEntry = accessGroupMatchedEntry(params); + const effectiveAllowFrom = appendAccessGroupMatchedEntry({ + entries: baseEffective.effectiveAllowFrom, + allowlist: state.allowlists.dm, + matchedEntry: matchedAccessGroupEntry, + }); + const effectiveGroupAllowFrom = appendAccessGroupMatchedEntry({ + entries: baseEffective.effectiveGroupAllowFrom, + allowlist: state.allowlists.group, + matchedEntry: matchedAccessGroupEntry, + }); + const senderAccess = projectSenderAccess({ + ingress, + isGroup, + effectiveAllowFrom, + effectiveGroupAllowFrom, + providerMissingFallbackApplied: params.providerMissingFallbackApplied, + }); + const routeAccess = projectRouteAccess({ ingress, route: params.route }); + const commandAccess = projectCommandAccess({ ingress, policy }); + const activationAccess = projectActivationAccess({ ingress }); + return { + state, + ingress, + senderAccess, + routeAccess, + commandAccess, + activationAccess, + }; +} diff --git a/src/channels/message-access/sender-gates.ts b/src/channels/message-access/sender-gates.ts new file mode 100644 index 00000000000..e47f55c244b --- /dev/null +++ b/src/channels/message-access/sender-gates.ts @@ -0,0 +1,166 @@ +import { + allowlistFailureReason, + applyMutableIdentifierPolicy, + effectiveGroupSenderAllowlist, + redactedAllowlistDiagnostics, +} from "./allowlist.js"; +import type { + AccessGraphGate, + ChannelIngressPolicyInput, + ChannelIngressState, + ResolvedIngressAllowlist, +} from "./types.js"; + +function senderGate(params: { + id: "sender:dm" | "sender:group"; + kind: "dmSender" | "groupSender"; + effect: AccessGraphGate["effect"]; + allowed: boolean; + reasonCode: AccessGraphGate["reasonCode"]; + match: AccessGraphGate["match"]; + policy: ChannelIngressPolicyInput["dmPolicy"] | ChannelIngressPolicyInput["groupPolicy"]; + allowlistSource: ResolvedIngressAllowlist; +}): AccessGraphGate { + return { + id: params.id, + phase: "sender", + kind: params.kind, + effect: params.effect, + allowed: params.allowed, + reasonCode: params.reasonCode, + match: params.match, + sender: { policy: params.policy }, + allowlist: redactedAllowlistDiagnostics(params.allowlistSource, params.reasonCode), + }; +} + +export function senderGateForDirect(params: { + state: ChannelIngressState; + policy: ChannelIngressPolicyInput; +}): AccessGraphGate { + const dm = applyMutableIdentifierPolicy(params.state.allowlists.dm, params.policy); + const pairingStore = applyMutableIdentifierPolicy( + params.state.allowlists.pairingStore, + params.policy, + ); + const base = { + policy: params.policy.dmPolicy, + allowlistSource: dm, + match: dm.match, + }; + const allow = (reasonCode: AccessGraphGate["reasonCode"]) => + senderGate({ + id: "sender:dm", + kind: "dmSender", + ...base, + effect: "allow", + allowed: true, + reasonCode, + }); + const block = (reasonCode: AccessGraphGate["reasonCode"]) => + senderGate({ + id: "sender:dm", + kind: "dmSender", + ...base, + effect: "block-dispatch", + allowed: false, + reasonCode, + }); + if (params.policy.dmPolicy === "disabled") { + return block("dm_policy_disabled"); + } + if (params.policy.dmPolicy === "open") { + if (dm.hasWildcard) { + return allow("dm_policy_open"); + } + if (dm.match.matched) { + return allow("dm_policy_allowlisted"); + } + return block("dm_policy_not_allowlisted"); + } + if (dm.match.matched) { + return allow("dm_policy_allowlisted"); + } + if (params.policy.dmPolicy === "pairing" && pairingStore.match.matched) { + return senderGate({ + id: "sender:dm", + kind: "dmSender", + effect: "allow", + allowed: true, + reasonCode: "dm_policy_allowlisted", + match: pairingStore.match, + policy: params.policy.dmPolicy, + allowlistSource: pairingStore, + }); + } + if (params.policy.dmPolicy === "pairing" && params.state.event.mayPair) { + return block("dm_policy_pairing_required"); + } + const reasonCode = + params.policy.dmPolicy === "pairing" + ? "event_pairing_not_allowed" + : (allowlistFailureReason(dm) ?? "dm_policy_not_allowlisted"); + return block(reasonCode); +} + +export function senderGateForGroup(params: { + state: ChannelIngressState; + policy: ChannelIngressPolicyInput; +}): AccessGraphGate { + const group = effectiveGroupSenderAllowlist(params); + const base = { + policy: params.policy.groupPolicy, + allowlistSource: group, + match: group.match, + }; + const allow = (reasonCode: AccessGraphGate["reasonCode"]) => + senderGate({ + id: "sender:group", + kind: "groupSender", + ...base, + effect: "allow", + allowed: true, + reasonCode, + }); + const block = (reasonCode: AccessGraphGate["reasonCode"]) => + senderGate({ + id: "sender:group", + kind: "groupSender", + ...base, + effect: "block-dispatch", + allowed: false, + reasonCode, + }); + if (params.policy.groupPolicy === "disabled") { + return block("group_policy_disabled"); + } + if (params.policy.groupPolicy === "open") { + return allow("group_policy_open"); + } + if (!group.hasConfiguredEntries) { + return block("group_policy_empty_allowlist"); + } + if (group.match.matched) { + return allow("group_policy_allowed"); + } + return block(allowlistFailureReason(group) ?? "group_policy_not_allowlisted"); +} + +export function applyEventAuthModeToSenderGate(params: { + state: ChannelIngressState; + senderGate: AccessGraphGate; +}): AccessGraphGate { + if (params.state.event.authMode === "inbound" || params.senderGate.allowed) { + return params.senderGate; + } + const reasonCode = "sender_not_required"; + return { + ...params.senderGate, + effect: "ignore", + allowed: true, + reasonCode, + allowlist: params.senderGate.allowlist + ? { ...params.senderGate.allowlist, reasonCode } + : undefined, + }; +} diff --git a/src/channels/message-access/state.ts b/src/channels/message-access/state.ts new file mode 100644 index 00000000000..104771b669b --- /dev/null +++ b/src/channels/message-access/state.ts @@ -0,0 +1,396 @@ +import { normalizeStringEntries } from "../../shared/string-normalization.js"; +import { parseAccessGroupAllowFromEntry } from "../allow-from.js"; +import type { + AccessGroupMembershipFact, + ChannelIngressState, + ChannelIngressStateInput, + InternalChannelIngressAdapter, + InternalChannelIngressSubject, + InternalNormalizedEntry, + RedactedIngressEntryDiagnostic, + RedactedIngressMatch, + ResolvedRouteGateFacts, + ResolvedIngressAllowlist, +} from "./types.js"; + +function redactedEntries(entries: readonly InternalNormalizedEntry[]) { + return entries.map(({ value: _value, ...entry }) => entry); +} + +function emptyMatch(): RedactedIngressMatch { + return { matched: false, matchedEntryIds: [] }; +} + +function mergeMatches(matches: readonly RedactedIngressMatch[]): RedactedIngressMatch { + const matchedEntryIds = Array.from(new Set(matches.flatMap((match) => match.matchedEntryIds))); + return { + matched: matches.some((match) => match.matched) || matchedEntryIds.length > 0, + matchedEntryIds, + }; +} + +function mergeDiagnostics( + ...groups: Array +): RedactedIngressEntryDiagnostic[] { + const diagnostics: RedactedIngressEntryDiagnostic[] = []; + for (const group of groups) { + if (group) { + diagnostics.push(...group); + } + } + return diagnostics; +} + +function accessGroupFactByName( + facts: readonly AccessGroupMembershipFact[] | undefined, +): Map { + return new Map((facts ?? []).map((fact) => [fact.groupName, fact] as const)); +} + +async function normalizeAndMatch(params: { + adapter: InternalChannelIngressAdapter; + subject: InternalChannelIngressSubject; + accountId: string; + entries: readonly string[]; + context: "dm" | "group" | "route" | "command"; +}): Promise<{ + normalizedEntries: ReturnType; + invalidEntries: RedactedIngressEntryDiagnostic[]; + disabledEntries: RedactedIngressEntryDiagnostic[]; + match: RedactedIngressMatch; +}> { + if (params.entries.length === 0) { + return { + normalizedEntries: [], + invalidEntries: [], + disabledEntries: [], + match: emptyMatch(), + }; + } + const normalized = await params.adapter.normalizeEntries({ + entries: params.entries, + context: params.context, + accountId: params.accountId, + }); + const match = + normalized.matchable.length > 0 + ? await params.adapter.matchSubject({ + subject: params.subject, + entries: normalized.matchable, + context: params.context, + }) + : emptyMatch(); + return { + normalizedEntries: redactedEntries(normalized.matchable), + invalidEntries: normalized.invalid, + disabledEntries: normalized.disabled, + match, + }; +} + +function referencedAccessGroups(entries: readonly string[]): string[] { + return Array.from( + new Set( + entries + .map((entry) => parseAccessGroupAllowFromEntry(entry)) + .filter((entry): entry is string => entry != null), + ), + ); +} + +function directAllowlistEntries(entries: readonly string[]): string[] { + return entries.filter((entry) => parseAccessGroupAllowFromEntry(entry) == null); +} + +function groupSenderEntries(params: { + groupName: string; + input: ChannelIngressStateInput; +}): string[] { + const group = params.input.accessGroups?.[params.groupName]; + if (!group || group.type !== "message.senders") { + return []; + } + return normalizeStringEntries([ + ...(group.members["*"] ?? []), + ...(group.members[params.input.channelId] ?? []), + ]); +} + +function eventSubjectMatchContext(input: ChannelIngressStateInput): "dm" | "group" { + return input.conversation.kind === "direct" ? "dm" : "group"; +} + +async function normalizeSubjectIdentifiersForMatch(params: { + input: ChannelIngressStateInput; + subject: InternalChannelIngressSubject; + context: "dm" | "group"; + opaquePrefix: string; +}): Promise { + const normalized = await Promise.all( + params.subject.identifiers.map(async (identifier, identifierIndex) => { + const entries = await params.input.adapter.normalizeEntries({ + entries: [identifier.value], + context: params.context, + accountId: params.input.accountId, + }); + return ( + entries.matchable + // Origin subjects are identity material, not configured allowlists. + // Do not let a subject value normalize into adapter wildcard semantics. + .filter((entry) => entry.kind === identifier.kind && entry.value !== "*") + .map((entry, entryIndex) => ({ + opaqueEntryId: `${params.opaquePrefix}-${identifierIndex + 1}:${entryIndex + 1}`, + kind: entry.kind, + value: entry.value, + dangerous: entry.dangerous, + sensitivity: entry.sensitivity, + })) + ); + }), + ); + return normalized.flat(); +} + +async function originSubjectMatched(input: ChannelIngressStateInput): Promise { + const origin = input.event.originSubject; + if (!origin) { + return false; + } + if ( + origin.identifiers.some((identifier) => + input.subject.identifiers.some( + (current) => current.kind === identifier.kind && current.value === identifier.value, + ), + ) + ) { + return true; + } + + const context = eventSubjectMatchContext(input); + const originEntries = await normalizeSubjectIdentifiersForMatch({ + input, + subject: origin, + context, + opaquePrefix: "origin", + }); + if (originEntries.length > 0) { + const currentMatch = await input.adapter.matchSubject({ + subject: input.subject, + entries: originEntries, + context, + }); + if (currentMatch.matched) { + return true; + } + } + + const currentEntries = await normalizeSubjectIdentifiersForMatch({ + input, + subject: input.subject, + context, + opaquePrefix: "current", + }); + if (currentEntries.length === 0) { + return false; + } + const originMatch = await input.adapter.matchSubject({ + subject: origin, + entries: currentEntries, + context, + }); + return originMatch.matched; +} + +async function resolveAccessGroupEntries(params: { + input: ChannelIngressStateInput; + context: "dm" | "group" | "route" | "command"; + referenced: readonly string[]; +}): Promise<{ + normalizedEntries: ReturnType; + invalidEntries: RedactedIngressEntryDiagnostic[]; + disabledEntries: RedactedIngressEntryDiagnostic[]; + matches: RedactedIngressMatch[]; + accessGroups: ResolvedIngressAllowlist["accessGroups"]; +}> { + const factByName = accessGroupFactByName(params.input.accessGroupMembership); + const accessGroups: ResolvedIngressAllowlist["accessGroups"] = { + referenced: [...params.referenced], + matched: [], + missing: [], + unsupported: [], + failed: [], + }; + const normalizedEntries: ReturnType = []; + const invalidEntries: RedactedIngressEntryDiagnostic[] = []; + const disabledEntries: RedactedIngressEntryDiagnostic[] = []; + const matches: RedactedIngressMatch[] = []; + + for (const groupName of params.referenced) { + const fact = factByName.get(groupName); + if (fact?.kind === "matched") { + accessGroups.matched.push(groupName); + matches.push({ matched: true, matchedEntryIds: fact.matchedEntryIds }); + continue; + } + if (fact?.kind === "missing" || fact?.kind === "unsupported" || fact?.kind === "failed") { + accessGroups[fact.kind].push(groupName); + continue; + } + if (fact?.kind === "not-matched") { + continue; + } + + const group = params.input.accessGroups?.[groupName]; + if (!group) { + accessGroups.missing.push(groupName); + continue; + } + if (group.type !== "message.senders") { + accessGroups.unsupported.push(groupName); + continue; + } + + const groupEntries = groupSenderEntries({ groupName, input: params.input }); + const resolved = await normalizeAndMatch({ + adapter: params.input.adapter, + subject: params.input.subject, + accountId: params.input.accountId, + entries: groupEntries, + context: params.context, + }); + normalizedEntries.push(...resolved.normalizedEntries); + invalidEntries.push(...resolved.invalidEntries); + disabledEntries.push(...resolved.disabledEntries); + if (resolved.match.matched) { + accessGroups.matched.push(groupName); + matches.push(resolved.match); + } + } + + return { + normalizedEntries, + invalidEntries, + disabledEntries, + matches, + accessGroups, + }; +} + +async function resolveIngressAllowlist(params: { + input: ChannelIngressStateInput; + rawEntries: Array | undefined; + context: "dm" | "group" | "route" | "command"; +}): Promise { + const entries = normalizeStringEntries(params.rawEntries ?? []); + const referenced = referencedAccessGroups(entries); + const directEntries = directAllowlistEntries(entries); + const direct = await normalizeAndMatch({ + adapter: params.input.adapter, + subject: params.input.subject, + accountId: params.input.accountId, + entries: directEntries, + context: params.context, + }); + const groups = await resolveAccessGroupEntries({ + input: params.input, + context: params.context, + referenced, + }); + const match = mergeMatches([direct.match, ...groups.matches]); + return { + rawEntryCount: entries.length, + normalizedEntries: [...direct.normalizedEntries, ...groups.normalizedEntries], + invalidEntries: mergeDiagnostics(direct.invalidEntries, groups.invalidEntries), + disabledEntries: mergeDiagnostics(direct.disabledEntries, groups.disabledEntries), + matchedEntryIds: match.matchedEntryIds, + hasConfiguredEntries: entries.length > 0, + hasMatchableEntries: direct.normalizedEntries.length > 0 || groups.normalizedEntries.length > 0, + hasWildcard: directEntries.includes("*"), + accessGroups: groups.accessGroups, + match, + }; +} + +async function resolveRouteFacts( + input: ChannelIngressStateInput, +): Promise { + const routeFacts = [...(input.routeFacts ?? [])].toSorted( + (left, right) => left.precedence - right.precedence || left.id.localeCompare(right.id), + ); + const resolved: ResolvedRouteGateFacts[] = []; + for (const route of routeFacts) { + const senderAllowFrom = + route.senderAllowFrom ?? + (route.senderAllowFromSource === "effective-dm" + ? input.allowlists.dm + : route.senderAllowFromSource === "effective-group" + ? input.allowlists.group + : undefined); + resolved.push({ + id: route.id, + kind: route.kind, + gate: route.gate, + effect: route.effect, + precedence: route.precedence, + senderPolicy: route.senderPolicy, + match: route.match, + senderAllowlist: + senderAllowFrom != null + ? await resolveIngressAllowlist({ + input, + rawEntries: senderAllowFrom, + context: "route", + }) + : undefined, + }); + } + return resolved; +} + +export async function resolveChannelIngressState( + input: ChannelIngressStateInput, +): Promise { + const [dm, pairingStore, group, commandOwner, commandGroup, routeFacts, eventOriginMatched] = + await Promise.all([ + resolveIngressAllowlist({ input, rawEntries: input.allowlists.dm, context: "dm" }), + resolveIngressAllowlist({ + input, + rawEntries: input.allowlists.pairingStore, + context: "dm", + }), + resolveIngressAllowlist({ input, rawEntries: input.allowlists.group, context: "group" }), + resolveIngressAllowlist({ + input, + rawEntries: input.allowlists.commandOwner, + context: "command", + }), + resolveIngressAllowlist({ + input, + rawEntries: input.allowlists.commandGroup, + context: "command", + }), + resolveRouteFacts(input), + originSubjectMatched(input), + ]); + return { + channelId: input.channelId, + accountId: input.accountId, + conversationKind: input.conversation.kind, + event: { + kind: input.event.kind, + authMode: input.event.authMode, + mayPair: input.event.mayPair, + hasOriginSubject: input.event.originSubject != null, + originSubjectMatched: eventOriginMatched, + }, + mentionFacts: input.mentionFacts, + routeFacts, + allowlists: { + dm, + pairingStore, + group, + commandOwner, + commandGroup, + }, + }; +} diff --git a/src/channels/message-access/types.ts b/src/channels/message-access/types.ts new file mode 100644 index 00000000000..adad5f88f11 --- /dev/null +++ b/src/channels/message-access/types.ts @@ -0,0 +1,369 @@ +import type { AccessGroupConfig } from "../../config/types.access-groups.js"; +import type { ChatChannelId } from "../ids.js"; +import type { InboundImplicitMentionKind, InboundMentionFacts } from "../mention-gating.js"; + +/** Channel identifier used in ingress diagnostics and config lookups. */ +export type ChannelIngressChannelId = ChatChannelId; + +/** Redacted identifier category used by allowlist normalization and matching. */ +export type ChannelIngressIdentifierKind = + | "stable-id" + | "username" + | "email" + | "phone" + | "role" + | `plugin:${string}`; + +/** Public, redacted identifier material that can participate in allowlist matching. */ +export type MatchableIdentifier = { + opaqueId: string; + kind: ChannelIngressIdentifierKind; + dangerous?: boolean; + sensitivity?: "normal" | "pii"; +}; + +/** Internal identifier material with the raw comparable value retained. */ +export type InternalMatchMaterial = MatchableIdentifier & { + value: string; +}; + +/** Internal subject representation used by the shared ingress kernel. */ +export type InternalChannelIngressSubject = { + identifiers: InternalMatchMaterial[]; +}; + +/** Public, redacted form of a normalized allowlist entry. */ +export type ChannelIngressNormalizedEntry = { + opaqueEntryId: string; + kind: ChannelIngressIdentifierKind; + dangerous?: boolean; + sensitivity?: "normal" | "pii"; +}; + +/** Internal normalized allowlist entry with its raw comparable value retained. */ +export type InternalNormalizedEntry = ChannelIngressNormalizedEntry & { + value: string; +}; + +/** Redacted diagnostic for an invalid, disabled, or unsupported allowlist entry. */ +export type RedactedIngressEntryDiagnostic = { + opaqueEntryId?: string; + reasonCode: IngressReasonCode; +}; + +/** Redacted allowlist match result exposed to callers and access facts. */ +export type RedactedIngressMatch = { + matched: boolean; + matchedEntryIds: string[]; +}; + +/** Public normalization result for a set of allowlist entries. */ +export type ChannelIngressNormalizeResult = { + matchable: ChannelIngressNormalizedEntry[]; + invalid: RedactedIngressEntryDiagnostic[]; + disabled: RedactedIngressEntryDiagnostic[]; +}; + +/** Internal normalization result with raw comparable entry values retained. */ +export type InternalChannelIngressNormalizeResult = Omit< + ChannelIngressNormalizeResult, + "matchable" +> & { + matchable: InternalNormalizedEntry[]; +}; + +/** Adapter that gives the shared ingress kernel channel-specific identity matching. */ +export type InternalChannelIngressAdapter = { + normalizeEntries(params: { + entries: readonly string[]; + context: "dm" | "group" | "route" | "command"; + accountId: string; + }): InternalChannelIngressNormalizeResult | Promise; + + matchSubject(params: { + subject: InternalChannelIngressSubject; + entries: readonly InternalNormalizedEntry[]; + context: "dm" | "group" | "route" | "command"; + }): RedactedIngressMatch | Promise; +}; + +/** Resolved access-group membership fact used by allowlist entries. */ +export type AccessGroupMembershipFact = + | { + kind: "matched"; + groupName: string; + source: "static" | "dynamic"; + matchedEntryIds: string[]; + } + | { + kind: "not-matched"; + groupName: string; + source: "static" | "dynamic"; + } + | { + kind: "missing" | "unsupported" | "failed"; + groupName: string; + source: "static" | "dynamic"; + reasonCode: IngressReasonCode; + diagnosticId?: string; + }; + +/** Fully normalized allowlist facts for one ingress gate. */ +export type ResolvedIngressAllowlist = { + rawEntryCount: number; + normalizedEntries: ChannelIngressNormalizedEntry[]; + invalidEntries: RedactedIngressEntryDiagnostic[]; + disabledEntries: RedactedIngressEntryDiagnostic[]; + matchedEntryIds: string[]; + hasConfiguredEntries: boolean; + hasMatchableEntries: boolean; + hasWildcard: boolean; + accessGroups: { + referenced: string[]; + matched: string[]; + missing: string[]; + unsupported: string[]; + failed: string[]; + }; + match: RedactedIngressMatch; +}; + +/** Redacted allowlist facts safe to expose in the access graph. */ +export type RedactedIngressAllowlistFacts = { + configured: boolean; + matched: boolean; + reasonCode: IngressReasonCode; + matchedEntryIds: string[]; + invalidEntryCount: number; + disabledEntryCount: number; + accessGroups: ResolvedIngressAllowlist["accessGroups"]; +}; + +/** Route lookup state projected into the ingress access graph. */ +export type RouteGateState = + | "not-configured" + | "matched" + | "not-matched" + | "disabled" + | "lookup-failed"; + +/** How a matched route affects sender allowlist evaluation. */ +export type RouteSenderPolicy = "inherit" | "replace" | "deny-when-empty"; + +/** Source list used when a route sender policy contributes sender entries. */ +export type RouteSenderAllowlistSource = "effective-dm" | "effective-group"; + +/** Raw route gate facts supplied by a channel-specific router. */ +export type RouteGateFacts = { + id: string; + kind: "route" | "routeSender" | "membership" | "ownerAllowlist" | "nestedAllowlist"; + gate: RouteGateState; + effect: "allow" | "block-dispatch" | "ignore"; + precedence: number; + senderPolicy: RouteSenderPolicy; + senderAllowFrom?: Array; + senderAllowFromSource?: RouteSenderAllowlistSource; + match?: RedactedIngressMatch; +}; + +/** Route gate facts after any route-specific sender allowlist is normalized. */ +export type ResolvedRouteGateFacts = Omit< + RouteGateFacts, + "senderAllowFrom" | "senderAllowFromSource" +> & { + senderAllowlist?: ResolvedIngressAllowlist; +}; + +/** Inbound event facts used to choose command, pairing, and origin-subject rules. */ +export type ChannelIngressEventInput = { + kind: + | "message" + | "reaction" + | "button" + | "postback" + | "native-command" + | "slash-command" + | "system"; + authMode: "inbound" | "command" | "origin-subject" | "route-only" | "none"; + mayPair: boolean; + originSubject?: InternalChannelIngressSubject; +}; + +/** Redacted event facts exposed in decisions and access facts. */ +export type RedactedChannelIngressEvent = Omit & { + hasOriginSubject: boolean; + originSubjectMatched: boolean; +}; + +/** Complete raw input to the shared ingress state resolver. */ +export type ChannelIngressStateInput = { + channelId: ChannelIngressChannelId; + accountId: string; + subject: InternalChannelIngressSubject; + conversation: { + kind: "direct" | "group" | "channel"; + id: string; + parentId?: string; + threadId?: string; + title?: string; + }; + adapter: InternalChannelIngressAdapter; + accessGroups?: Record; + accessGroupMembership?: readonly AccessGroupMembershipFact[]; + routeFacts?: RouteGateFacts[]; + mentionFacts?: InboundMentionFacts; + event: ChannelIngressEventInput; + allowlists: { + dm?: Array; + group?: Array; + commandOwner?: Array; + commandGroup?: Array; + pairingStore?: Array; + }; +}; + +/** Policy knobs that decide how the ingress graph is evaluated. */ +export type ChannelIngressPolicyInput = { + dmPolicy: "pairing" | "allowlist" | "open" | "disabled"; + groupPolicy: "allowlist" | "open" | "disabled"; + groupAllowFromFallbackToAllowFrom?: boolean; + mutableIdentifierMatching?: "disabled" | "enabled"; + activation?: { + requireMention: boolean; + allowTextCommands: boolean; + allowedImplicitMentionKinds?: readonly InboundImplicitMentionKind[]; + order?: "before-sender" | "after-command"; + }; + command?: { + useAccessGroups?: boolean; + allowTextCommands: boolean; + hasControlCommand: boolean; + modeWhenAccessGroupsOff?: "allow" | "deny" | "configured"; + }; +}; + +/** Ordered phase for a gate in the ingress graph. */ +export type IngressGatePhase = "route" | "sender" | "command" | "event" | "activation"; + +/** Gate kind used in the ingress graph and projected access facts. */ +export type IngressGateKind = + | "route" + | "routeSender" + | "dmSender" + | "groupSender" + | "membership" + | "ownerAllowlist" + | "nestedAllowlist" + | "command" + | "event" + | "mention"; + +/** Effect produced by a gate when computing final ingress admission. */ +export type IngressGateEffect = + | "allow" + | "block-dispatch" + | "block-command" + | "skip" + | "observe" + | "ignore"; + +/** Stable machine-readable reason code for ingress diagnostics. */ +export type IngressReasonCode = + | "allowed" + | "route_blocked" + | "route_sender_empty" + | "dm_policy_disabled" + | "dm_policy_open" + | "dm_policy_allowlisted" + | "dm_policy_pairing_required" + | "dm_policy_not_allowlisted" + | "group_policy_disabled" + | "group_policy_open" + | "group_policy_allowed" + | "group_policy_empty_allowlist" + | "group_policy_not_allowlisted" + | "command_authorized" + | "control_command_unauthorized" + | "event_authorized" + | "event_unauthorized" + | "event_pairing_not_allowed" + | "sender_not_required" + | "origin_subject_missing" + | "origin_subject_not_matched" + | "activation_allowed" + | "activation_skipped" + | "access_group_missing" + | "access_group_unsupported" + | "access_group_failed" + | "mutable_identifier_disabled" + | "no_policy_match"; + +/** One evaluated gate in the ordered ingress access graph. */ +export type AccessGraphGate = { + id: string; + phase: IngressGatePhase; + kind: IngressGateKind; + effect: IngressGateEffect; + allowed: boolean; + reasonCode: IngressReasonCode; + match?: RedactedIngressMatch; + allowlist?: RedactedIngressAllowlistFacts; + sender?: { + policy: ChannelIngressPolicyInput["dmPolicy"] | ChannelIngressPolicyInput["groupPolicy"]; + }; + command?: { + useAccessGroups: boolean; + allowTextCommands: boolean; + modeWhenAccessGroupsOff?: "allow" | "deny" | "configured"; + shouldBlockControlCommand: boolean; + }; + event?: RedactedChannelIngressEvent; + activation?: { + hasMentionFacts: boolean; + requireMention: boolean; + allowTextCommands: boolean; + allowedImplicitMentionKinds?: readonly InboundImplicitMentionKind[]; + order?: "before-sender" | "after-command"; + shouldSkip: boolean; + canDetectMention?: boolean; + wasMentioned?: boolean; + hasAnyMention?: boolean; + implicitMentionKinds?: readonly InboundImplicitMentionKind[]; + effectiveWasMentioned?: boolean; + shouldBypassMention?: boolean; + }; +}; + +/** Ordered graph of all evaluated ingress gates. */ +export type AccessGraph = { + gates: AccessGraphGate[]; +}; + +/** Normalized ingress state before policy gates are reduced into a decision. */ +export type ChannelIngressState = { + channelId: ChannelIngressChannelId; + accountId: string; + conversationKind: "direct" | "group" | "channel"; + event: RedactedChannelIngressEvent; + mentionFacts?: InboundMentionFacts; + routeFacts: ResolvedRouteGateFacts[]; + allowlists: { + dm: ResolvedIngressAllowlist; + pairingStore: ResolvedIngressAllowlist; + group: ResolvedIngressAllowlist; + commandOwner: ResolvedIngressAllowlist; + commandGroup: ResolvedIngressAllowlist; + }; +}; + +/** Final runtime admission action for the inbound event. */ +export type ChannelIngressAdmission = "dispatch" | "observe" | "skip" | "drop" | "pairing-required"; + +/** Final decision and graph for a resolved channel ingress event. */ +export type ChannelIngressDecision = { + admission: ChannelIngressAdmission; + decision: "allow" | "block" | "pairing"; + decisiveGateId: string; + reasonCode: IngressReasonCode; + graph: AccessGraph; +}; diff --git a/src/channels/turn/context.test.ts b/src/channels/turn/context.test.ts index e7a66b35dcc..c1fcab74668 100644 --- a/src/channels/turn/context.test.ts +++ b/src/channels/turn/context.test.ts @@ -177,6 +177,41 @@ describe("buildChannelTurnContext", () => { } }); + it("uses resolved command authorization instead of recomputing authorizers", () => { + const ctx = buildChannelTurnContext( + createBaseContextParams({ + access: { + commands: { + authorized: false, + shouldBlockControlCommand: true, + reasonCode: "control_command_unauthorized", + allowTextCommands: true, + useAccessGroups: true, + authorizers: [{ configured: true, allowed: true }], + }, + }, + }), + ); + + expect(ctx.CommandAuthorized).toBe(false); + }); + + it("keeps legacy command authorization fallback for authorizer arrays", () => { + const ctx = buildChannelTurnContext( + createBaseContextParams({ + access: { + commands: { + allowTextCommands: true, + useAccessGroups: true, + authorizers: [{ configured: true, allowed: true }], + }, + }, + }), + ); + + expect(ctx.CommandAuthorized).toBe(true); + }); + it("filters supplemental context with channel visibility policy", () => { const ctx = buildChannelTurnContext( createBaseContextParams({ diff --git a/src/channels/turn/context.ts b/src/channels/turn/context.ts index 8a29db43eb9..976a116fb05 100644 --- a/src/channels/turn/context.ts +++ b/src/channels/turn/context.ts @@ -46,14 +46,6 @@ function mediaTranscribedIndexes(media: InboundMediaFacts[]): number[] | undefin return indexes.length > 0 ? indexes : undefined; } -function commandAuthorized(access: AccessFacts | undefined): boolean | undefined { - const commands = access?.commands; - if (!commands) { - return undefined; - } - return commands.authorizers.some((entry) => entry.allowed); -} - function keepSupplementalContext(params: { mode?: ContextVisibilityMode; kind: "quote" | "forwarded" | "thread"; @@ -110,6 +102,13 @@ export function filterChannelTurnSupplementalContext(params: { }; } +function resolveAccessFactsCommandAuthorized(access: AccessFacts | undefined): boolean | undefined { + const commands = access?.commands; + return typeof commands?.authorized === "boolean" + ? commands.authorized + : commands?.authorizers?.some((entry) => entry.allowed); +} + export function buildChannelTurnContext( params: BuildChannelTurnContextParams, ): FinalizedMsgContext { @@ -174,7 +173,7 @@ export function buildChannelTurnContext( Provider: params.provider ?? params.channel, Surface: params.surface ?? params.provider ?? params.channel, WasMentioned: params.access?.mentions?.wasMentioned, - CommandAuthorized: commandAuthorized(params.access), + CommandAuthorized: resolveAccessFactsCommandAuthorized(params.access), MessageThreadId: params.reply.messageThreadId ?? params.conversation.threadId, NativeChannelId: params.reply.nativeChannelId ?? params.conversation.nativeChannelId, OriginatingChannel: params.channel, diff --git a/src/channels/turn/types.ts b/src/channels/turn/types.ts index 3b3135c3a6d..17f388de00b 100644 --- a/src/channels/turn/types.ts +++ b/src/channels/turn/types.ts @@ -89,29 +89,86 @@ export type ReplyPlanFacts = { sourceReplyDeliveryMode?: "thread" | "reply" | "channel" | "direct" | "none"; }; +export type ProjectedAllowlistAccessFacts = { + configured: boolean; + matched: boolean; + reasonCode?: string; + matchedEntryIds: string[]; + invalidEntryCount: number; + disabledEntryCount: number; + accessGroups: { + referenced: string[]; + matched: string[]; + missing: string[]; + unsupported: string[]; + failed: string[]; + }; +}; + +export type ProjectedEventAccessFacts = { + kind: + | "message" + | "reaction" + | "button" + | "postback" + | "native-command" + | "slash-command" + | "system"; + authMode: "inbound" | "command" | "origin-subject" | "route-only" | "none"; + mayPair: boolean; + authorized: boolean; + reasonCode?: string; + hasOriginSubject: boolean; + originSubjectMatched: boolean; +}; + export type AccessFacts = { dm?: { decision: "allow" | "pairing" | "deny"; reason?: string; + /** + * @deprecated Shared ingress projections redact allowlist entries and return an empty compat list. + * Use allowlist diagnostics instead. + */ allowFrom: string[]; + allowlist?: ProjectedAllowlistAccessFacts; }; group?: { policy: "open" | "allowlist" | "disabled"; routeAllowed: boolean; senderAllowed: boolean; + /** + * @deprecated Shared ingress projections redact allowlist entries and return an empty compat list. + * Use allowlist diagnostics instead. + */ allowFrom: string[]; requireMention: boolean; + allowlist?: ProjectedAllowlistAccessFacts; }; commands?: { + authorized?: boolean; + shouldBlockControlCommand?: boolean; + reasonCode?: string; useAccessGroups: boolean; allowTextCommands: boolean; + modeWhenAccessGroupsOff?: "allow" | "deny" | "configured"; + /** + * @deprecated Shared ingress projections do not expose raw authorizer lists. + * Use authorized and reasonCode instead. + */ authorizers: Array<{ configured: boolean; allowed: boolean }>; }; + event?: ProjectedEventAccessFacts; mentions?: { canDetectMention: boolean; wasMentioned: boolean; hasAnyMention?: boolean; - implicitMentionKinds?: Array<"reply_to_bot" | "bot_thread_participant" | "native">; + implicitMentionKinds?: Array< + "reply_to_bot" | "quoted_bot" | "bot_thread_participant" | "native" + >; + requireMention?: boolean; + effectiveWasMentioned?: boolean; + shouldSkip?: boolean; }; }; diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index 7d186e56080..17361c494f8 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -1,3 +1,4 @@ +import { resolveDmAllowAuditState } from "../channels/message-access/dm-allow-state.js"; import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js"; import type { ChannelId } from "../channels/plugins/types.public.js"; import { formatCliCommand } from "../cli/command-format.js"; @@ -9,7 +10,6 @@ import { resolveGatewayAuth } from "../gateway/auth.js"; import { isLoopbackHost, resolveGatewayBindHost } from "../gateway/net.js"; import { resolveExecPolicyScopeSnapshot } from "../infra/exec-approvals-effective.js"; import { loadExecApprovals, type ExecAsk, type ExecSecurity } from "../infra/exec-approvals.js"; -import { resolveDmAllowState } from "../security/dm-policy-shared.js"; import { collectExecFilesystemPolicyDriftHits } from "../security/exec-filesystem-policy.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { note } from "../terminal/note.js"; @@ -285,7 +285,7 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) { }) => { const dmPolicy = params.dmPolicy; const policyPath = params.policyPath ?? `${params.allowFromPath}policy`; - const { hasWildcard, allowCount, isMultiUserDm } = await resolveDmAllowState({ + const { hasWildcard, allowCount, isMultiUserDm } = await resolveDmAllowAuditState({ provider: params.provider, accountId: params.accountId, allowFrom: params.allowFrom, diff --git a/src/commands/onboard-non-interactive/local/gateway-config.ts b/src/commands/onboard-non-interactive/local/gateway-config.ts index d511066e151..6d4f9d2fbdb 100644 --- a/src/commands/onboard-non-interactive/local/gateway-config.ts +++ b/src/commands/onboard-non-interactive/local/gateway-config.ts @@ -24,9 +24,8 @@ export function applyNonInteractiveGatewayConfig(params: { const { opts, runtime } = params; const gatewayPort = opts.gatewayPort; - const hasGatewayPort = gatewayPort !== undefined; if ( - hasGatewayPort && + gatewayPort !== undefined && (!Number.isFinite(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65_535) ) { runtime.error(formatInvalidPortOption("--gateway-port")); diff --git a/src/gateway/server-restart-deferral.test.ts b/src/gateway/server-restart-deferral.test.ts index cad20629369..c541e156d2c 100644 --- a/src/gateway/server-restart-deferral.test.ts +++ b/src/gateway/server-restart-deferral.test.ts @@ -4,7 +4,7 @@ import { getTotalPendingReplies, } from "../auto-reply/reply/dispatcher-registry.js"; import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; -import { getTotalQueueSize } from "../process/command-queue.js"; +import { getTotalQueueSize, resetCommandQueueStateForTest } from "../process/command-queue.js"; async function flushMicrotasks(count = 10): Promise { for (let i = 0; i < count; i += 1) { @@ -29,6 +29,7 @@ describe("gateway restart deferral", () => { let replyErrors: string[] = []; beforeEach(() => { + resetCommandQueueStateForTest(); vi.clearAllMocks(); replyErrors = []; }); @@ -37,6 +38,7 @@ describe("gateway restart deferral", () => { vi.restoreAllMocks(); await flushMicrotasks(); clearAllDispatchers(); + resetCommandQueueStateForTest(); }); it("defers restart while reply delivery is in flight", async () => { diff --git a/src/plugin-sdk/access-groups.test.ts b/src/plugin-sdk/access-groups.test.ts new file mode 100644 index 00000000000..e930d23f42b --- /dev/null +++ b/src/plugin-sdk/access-groups.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + expandAllowFromWithAccessGroups, + resolveAccessGroupAllowFromState, +} from "./access-groups.js"; + +describe("access group allowlists", () => { + it("reports static, missing, unsupported, failed, and compatibility expansion states", async () => { + const cfg = { + accessGroups: { + admins: { type: "message.senders", members: { "*": ["global"], test: ["local"] } }, + audience: { type: "discord.channelAudience", guildId: "guild-1", channelId: "channel-1" }, + }, + } as OpenClawConfig; + + await expect( + resolveAccessGroupAllowFromState({ + accessGroups: cfg.accessGroups, + allowFrom: ["accessGroup:admins", "accessGroup:missing", "accessGroup:audience"], + channel: "test", + accountId: "default", + senderId: "local", + isSenderAllowed: (senderId, allowFrom) => allowFrom.includes(senderId), + }), + ).resolves.toMatchObject({ + referenced: ["admins", "missing", "audience"], + matched: ["admins"], + missing: ["missing"], + unsupported: ["audience"], + failed: [], + matchedAllowFromEntries: ["accessGroup:admins"], + hasReferences: true, + hasMatch: true, + }); + + await expect( + resolveAccessGroupAllowFromState({ + accessGroups: cfg.accessGroups, + allowFrom: ["accessGroup:audience"], + channel: "discord", + accountId: "default", + senderId: "discord:123", + resolveMembership: async () => { + throw new Error("discord lookup failed"); + }, + }), + ).resolves.toMatchObject({ referenced: ["audience"], failed: ["audience"], hasMatch: false }); + + await expect( + expandAllowFromWithAccessGroups({ + cfg, + allowFrom: ["accessGroup:admins"], + channel: "test", + accountId: "default", + senderId: "local", + isSenderAllowed: (senderId, allowFrom) => allowFrom.includes(senderId), + }), + ).resolves.toEqual(["accessGroup:admins", "local"]); + }); +}); diff --git a/src/plugin-sdk/access-groups.ts b/src/plugin-sdk/access-groups.ts index 984251f0c0f..5bd05911d25 100644 --- a/src/plugin-sdk/access-groups.ts +++ b/src/plugin-sdk/access-groups.ts @@ -1,8 +1,12 @@ +import { + ACCESS_GROUP_ALLOW_FROM_PREFIX, + parseAccessGroupAllowFromEntry, +} from "../channels/allow-from.js"; import type { ChannelId } from "../channels/plugins/types.public.js"; import type { AccessGroupConfig } from "../config/types.access-groups.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -export const ACCESS_GROUP_ALLOW_FROM_PREFIX = "accessGroup:"; +export { ACCESS_GROUP_ALLOW_FROM_PREFIX, parseAccessGroupAllowFromEntry }; export type AccessGroupMembershipResolver = (params: { cfg: OpenClawConfig; @@ -13,14 +17,24 @@ export type AccessGroupMembershipResolver = (params: { senderId: string; }) => boolean | Promise; -export function parseAccessGroupAllowFromEntry(entry: string): string | null { - const trimmed = entry.trim(); - if (!trimmed.startsWith(ACCESS_GROUP_ALLOW_FROM_PREFIX)) { - return null; - } - const name = trimmed.slice(ACCESS_GROUP_ALLOW_FROM_PREFIX.length).trim(); - return name.length > 0 ? name : null; -} +export type AccessGroupMembershipLookup = (params: { + name: string; + group: AccessGroupConfig; + channel: ChannelId; + accountId: string; + senderId: string; +}) => boolean | Promise; + +export type ResolvedAccessGroupAllowFromState = { + referenced: string[]; + matched: string[]; + missing: string[]; + unsupported: string[]; + failed: string[]; + matchedAllowFromEntries: string[]; + hasReferences: boolean; + hasMatch: boolean; +}; function resolveMessageSenderGroupEntries(params: { group: AccessGroupConfig; @@ -32,6 +46,83 @@ function resolveMessageSenderGroupEntries(params: { return [...(params.group.members["*"] ?? []), ...(params.group.members[params.channel] ?? [])]; } +export async function resolveAccessGroupAllowFromState(params: { + accessGroups?: Record; + allowFrom: Array | null | undefined; + channel: ChannelId; + accountId: string; + senderId: string; + isSenderAllowed?: (senderId: string, allowFrom: string[]) => boolean; + resolveMembership?: AccessGroupMembershipLookup; +}): Promise { + const names = Array.from( + new Set( + (params.allowFrom ?? []) + .map((entry) => parseAccessGroupAllowFromEntry(String(entry))) + .filter((entry): entry is string => entry != null), + ), + ); + const state: ResolvedAccessGroupAllowFromState = { + referenced: names, + matched: [], + missing: [], + unsupported: [], + failed: [], + matchedAllowFromEntries: [], + hasReferences: names.length > 0, + hasMatch: false, + }; + const groups = params.accessGroups; + for (const name of names) { + const group = groups?.[name]; + if (!group) { + state.missing.push(name); + continue; + } + + const senderEntries = resolveMessageSenderGroupEntries({ + group, + channel: params.channel, + }); + if ( + senderEntries.length > 0 && + params.isSenderAllowed?.(params.senderId, senderEntries) === true + ) { + state.matched.push(name); + continue; + } + + if (!params.resolveMembership) { + if (group.type !== "message.senders") { + state.unsupported.push(name); + } + continue; + } + + let allowed = false; + try { + allowed = await params.resolveMembership({ + name, + group, + channel: params.channel, + accountId: params.accountId, + senderId: params.senderId, + }); + } catch { + state.failed.push(name); + continue; + } + if (allowed) { + state.matched.push(name); + } + } + state.matchedAllowFromEntries = state.matched.map( + (name) => `${ACCESS_GROUP_ALLOW_FROM_PREFIX}${name}`, + ); + state.hasMatch = state.matchedAllowFromEntries.length > 0; + return state; +} + export async function resolveAccessGroupAllowFromMatches(params: { cfg?: OpenClawConfig; allowFrom: Array | null | undefined; @@ -42,60 +133,24 @@ export async function resolveAccessGroupAllowFromMatches(params: { resolveMembership?: AccessGroupMembershipResolver; }): Promise { const cfg = params.cfg; - const groups = cfg?.accessGroups; - if (!groups) { - return []; - } - - const names = Array.from( - new Set( - (params.allowFrom ?? []) - .map((entry) => parseAccessGroupAllowFromEntry(String(entry))) - .filter((entry): entry is string => entry != null), - ), - ); - if (names.length === 0) { - return []; - } - - const matched: string[] = []; - for (const name of names) { - const group = groups[name]; - if (!group) { - continue; - } - - const senderEntries = resolveMessageSenderGroupEntries({ - group, - channel: params.channel, - }); - if ( - senderEntries.length > 0 && - params.isSenderAllowed?.(params.senderId, senderEntries) === true - ) { - matched.push(`${ACCESS_GROUP_ALLOW_FROM_PREFIX}${name}`); - continue; - } - - let allowed = false; - try { - allowed = - (await params.resolveMembership?.({ - cfg, - name, - group, - channel: params.channel, - accountId: params.accountId, - senderId: params.senderId, - })) === true; - } catch { - allowed = false; - } - if (allowed) { - matched.push(`${ACCESS_GROUP_ALLOW_FROM_PREFIX}${name}`); - } - } - return matched; + const resolveMembership = params.resolveMembership; + const state = await resolveAccessGroupAllowFromState({ + accessGroups: cfg?.accessGroups, + allowFrom: params.allowFrom, + channel: params.channel, + accountId: params.accountId, + senderId: params.senderId, + isSenderAllowed: params.isSenderAllowed, + resolveMembership: + resolveMembership && cfg + ? async (lookupParams) => + await resolveMembership({ + cfg, + ...lookupParams, + }) + : undefined, + }); + return state.matchedAllowFromEntries; } export async function expandAllowFromWithAccessGroups(params: { diff --git a/src/plugin-sdk/channel-access-compat.ts b/src/plugin-sdk/channel-access-compat.ts new file mode 100644 index 00000000000..1afc289c03f --- /dev/null +++ b/src/plugin-sdk/channel-access-compat.ts @@ -0,0 +1 @@ +export * from "../security/dm-policy-shared.js"; diff --git a/src/plugin-sdk/channel-ingress-runtime.test.ts b/src/plugin-sdk/channel-ingress-runtime.test.ts new file mode 100644 index 00000000000..c1bc4ffad83 --- /dev/null +++ b/src/plugin-sdk/channel-ingress-runtime.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, expectTypeOf, it, vi } from "vitest"; +import type { AccessFacts } from "../channels/turn/types.js"; +import { + resolveChannelMessageIngress, + type ChannelIngressIdentityDescriptor, + type ResolveChannelMessageIngressParams, +} from "./channel-ingress-runtime.js"; +import { projectIngressAccessFacts } from "./channel-ingress.js"; + +const identity = { + primary: { normalize: (value) => value.trim().toLowerCase(), sensitivity: "pii" }, +} satisfies ChannelIngressIdentityDescriptor; + +async function resolve(input: Partial = {}) { + return await resolveChannelMessageIngress({ + channelId: "runtime-test", + accountId: "default", + identity, + subject: { stableId: "owner" }, + conversation: { kind: "direct", id: "dm-1" }, + event: { kind: "message", authMode: "inbound", mayPair: true }, + policy: { dmPolicy: "allowlist", groupPolicy: "disabled", ...input.policy }, + allowFrom: ["owner"], + ...input, + }); +} + +describe("plugin-sdk/channel-ingress-runtime", () => { + it("omits projected command facts unless command policy was requested", async () => { + const normalMessage = await resolve(); + + expect(projectIngressAccessFacts(normalMessage.ingress).commands).toBeUndefined(); + + const commandMessage = await resolve({ + command: { useAccessGroups: true, allowTextCommands: true, hasControlCommand: true }, + }); + + expect(projectIngressAccessFacts(commandMessage.ingress).commands).toMatchObject({ + authorized: true, + authorizers: [], + useAccessGroups: true, + allowTextCommands: true, + }); + }); + + it("keeps command authorizers required on public AccessFacts", () => { + expectTypeOf["authorizers"]>().toEqualTypeOf< + Array<{ configured: boolean; allowed: boolean }> + >(); + }); + + it("derives store allowlists, command auth, sender separation, and redaction", async () => { + const sender = "Secret-Sender@example.test"; + const readStoreAllowFrom = vi.fn(async () => ["secret-sender@example.test"]); + const allowed = await resolve({ + subject: { stableId: sender }, + policy: { dmPolicy: "pairing", groupPolicy: "disabled" }, + allowFrom: [], + readStoreAllowFrom, + command: { useAccessGroups: true, allowTextCommands: true, hasControlCommand: true }, + }); + expect(readStoreAllowFrom).toHaveBeenCalledOnce(); + expect(allowed.ingress).toMatchObject({ admission: "dispatch", decision: "allow" }); + expect(allowed.commandAccess.authorized).toBe(true); + expect(JSON.stringify(allowed.state)).not.toContain(sender); + expect(JSON.stringify(allowed.ingress)).not.toContain(sender); + + const blockedBeforeCommand = await resolve({ + route: { id: "route:disabled", enabled: false }, + command: { useAccessGroups: true, allowTextCommands: true, hasControlCommand: true }, + }); + expect(blockedBeforeCommand.ingress.reasonCode).toBe("route_blocked"); + expect(blockedBeforeCommand.commandAccess.authorized).toBe(false); + + const unauthorizedCommand = await resolve({ + conversation: { kind: "group", id: "room-1" }, + event: { kind: "message", authMode: "inbound", mayPair: false }, + policy: { + dmPolicy: "pairing", + groupPolicy: "open", + groupAllowFromFallbackToAllowFrom: false, + }, + command: { + useAccessGroups: true, + allowTextCommands: true, + hasControlCommand: true, + groupOwnerAllowFrom: "none", + commandGroupAllowFromFallbackToAllowFrom: false, + }, + }); + expect(unauthorizedCommand.ingress.reasonCode).toBe("control_command_unauthorized"); + expect(unauthorizedCommand.senderAccess).toMatchObject({ + decision: "allow", + reasonCode: "group_policy_open", + }); + expect(unauthorizedCommand.commandAccess.shouldBlockControlCommand).toBe(true); + }); + + it("keeps normalized compatibility entries scoped to the intended identifier kind", async () => { + const prefixedIdentity = { + primary: { + key: "user-id", + normalizeEntry: (value) => + value + .trim() + .toLowerCase() + .replace(/^users\//, "") || null, + normalizeSubject: (value) => + value + .trim() + .toLowerCase() + .replace(/^users\//, ""), + }, + aliases: [ + { + key: "email", + kind: "plugin:test-email", + normalizeEntry(value) { + const normalized = value.trim().toLowerCase(); + return normalized.startsWith("users/") || !normalized.includes("@") ? null : normalized; + }, + normalizeSubject: (value) => value.trim().toLowerCase(), + dangerous: true, + }, + ], + } satisfies ChannelIngressIdentityDescriptor; + + const result = await resolveChannelMessageIngress({ + channelId: "runtime-test", + accountId: "default", + identity: prefixedIdentity, + subject: { stableId: "users/123", aliases: { email: "jane@example.test" } }, + conversation: { kind: "direct", id: "dm-1" }, + event: { kind: "message", authMode: "inbound", mayPair: false }, + policy: { + dmPolicy: "allowlist", + groupPolicy: "disabled", + mutableIdentifierMatching: "enabled", + }, + allowFrom: ["users/jane@example.test"], + }); + + expect(result.senderAccess.effectiveAllowFrom).toEqual(["jane@example.test"]); + expect(result.senderAccess.decision).toBe("block"); + }); +}); diff --git a/src/plugin-sdk/channel-ingress-runtime.ts b/src/plugin-sdk/channel-ingress-runtime.ts new file mode 100644 index 00000000000..fa45d0687ff --- /dev/null +++ b/src/plugin-sdk/channel-ingress-runtime.ts @@ -0,0 +1,44 @@ +/** + * High-level runtime resolver for inbound channel access decisions. + * + * Channel plugins should use this subpath for new receive paths. It accepts + * platform facts, raw allowlists, route descriptors, command facts, and access + * group config, then returns sender/route/command/activation projections plus + * the ordered ingress graph. + */ +export { + channelIngressRoutes, + createChannelIngressResolver, + defineStableChannelIngressIdentity, + readChannelIngressStoreAllowFromForDmPolicy, + resolveChannelMessageIngress, + resolveStableChannelMessageIngress, +} from "../channels/message-access/index.js"; +export type { + AccessGroupMembershipFact, + ChannelIngressDecision, + ChannelIngressAccessGroupMembershipResolver, + ChannelIngressCommandPresetInput, + ChannelIngressConfigInput, + ChannelIngressEventInput, + ChannelIngressEventPresetInput, + ChannelIngressIdentityDescriptor, + ChannelIngressIdentityAlias, + ChannelIngressIdentityField, + ChannelIngressIdentitySubjectInput, + ChannelIngressIdentifierKind, + ChannelIngressPolicyInput, + ChannelIngressRouteAccess, + ChannelIngressRouteDescriptor, + ChannelIngressResolver, + ChannelIngressResolverMessageParams, + ChannelIngressStateInput, + ChannelIngressState, + ChannelMessageIngressCommandInput, + CreateChannelIngressResolverParams, + IngressReasonCode, + ResolvedChannelMessageIngress, + ResolveChannelMessageIngressParams, + ResolveStableChannelMessageIngressParams, + StableChannelIngressIdentityParams, +} from "../channels/message-access/index.js"; diff --git a/src/plugin-sdk/channel-ingress.ts b/src/plugin-sdk/channel-ingress.ts new file mode 100644 index 00000000000..ad0daa2d85c --- /dev/null +++ b/src/plugin-sdk/channel-ingress.ts @@ -0,0 +1,620 @@ +import { + decideChannelIngress, + resolveChannelIngressState as resolveChannelIngressStateInternal, +} from "../channels/message-access/index.js"; +import type { + AccessGraphGate, + ChannelIngressDecision, + ChannelIngressIdentifierKind, + ChannelIngressPolicyInput, + ChannelIngressState, + ChannelIngressStateInput as MessageAccessChannelIngressStateInput, + IngressGateKind, + IngressGatePhase, + InternalChannelIngressAdapter, + InternalChannelIngressNormalizeResult, + InternalChannelIngressSubject, + InternalMatchMaterial, + InternalNormalizedEntry, + IngressReasonCode, +} from "../channels/message-access/index.js"; +import type { AccessFacts, ChannelTurnAdmission } from "../channels/turn/types.js"; +import type { + DmGroupAccessDecision, + DmGroupAccessReasonCode, +} from "../security/dm-policy-shared.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; + +export { decideChannelIngress }; +export type { + AccessGraph, + AccessGraphGate, + AccessGroupMembershipFact, + ChannelIngressAdmission, + ChannelIngressChannelId, + ChannelIngressDecision, + ChannelIngressEventInput, + ChannelIngressIdentifierKind, + ChannelIngressNormalizedEntry, + ChannelIngressPolicyInput, + ChannelIngressState, + IngressGateEffect, + IngressGateKind, + IngressGatePhase, + IngressReasonCode, + MatchableIdentifier, + RedactedChannelIngressEvent, + RedactedIngressAllowlistFacts, + RedactedIngressEntryDiagnostic, + RedactedIngressMatch, + ResolvedIngressAllowlist, + ResolvedRouteGateFacts, + RouteGateFacts, + RouteGateState, + RouteSenderAllowlistSource, + RouteSenderPolicy, +} from "../channels/message-access/index.js"; + +export type ChannelIngressSubjectIdentifier = InternalMatchMaterial; +export type ChannelIngressSubject = InternalChannelIngressSubject; +export type ChannelIngressAdapterEntry = InternalNormalizedEntry; +export type ChannelIngressAdapterNormalizeResult = InternalChannelIngressNormalizeResult; +export type ChannelIngressAdapter = InternalChannelIngressAdapter; +export type ChannelIngressStateInput = MessageAccessChannelIngressStateInput; + +declare const CHANNEL_INGRESS_PLUGIN_ID: unique symbol; + +export type ChannelIngressPluginId = string & { + readonly [CHANNEL_INGRESS_PLUGIN_ID]: true; +}; + +export type ChannelIngressGateSelector = { + phase: IngressGatePhase; + kind: IngressGateKind; +}; + +export type ChannelIngressDecisionBundle = { + dm: ChannelIngressDecision; + group: ChannelIngressDecision; + dmCommand: ChannelIngressDecision; + groupCommand: ChannelIngressDecision; +}; + +export type ChannelIngressSideEffectResult = + | { kind: "none" } + | { kind: "pairing-reply-sent" } + | { kind: "pairing-reply-failed"; errorCode?: string } + | { kind: "command-reply-sent" } + | { kind: "command-reply-failed"; errorCode?: string } + | { kind: "pending-history-recorded" } + | { kind: "local-event-handled" }; + +export type RedactedIngressDiagnostics = { + decisiveGateId?: string; + reasonCode: IngressReasonCode; +}; + +export const CHANNEL_INGRESS_GATE_SELECTORS = { + command: { phase: "command", kind: "command" }, + activation: { phase: "activation", kind: "mention" }, + dmSender: { phase: "sender", kind: "dmSender" }, + groupSender: { phase: "sender", kind: "groupSender" }, + event: { phase: "event", kind: "event" }, +} as const satisfies Record; + +export type ChannelIngressSubjectIdentifierInput = { + value: string; + opaqueId?: string; + kind?: ChannelIngressIdentifierKind; + dangerous?: boolean; + sensitivity?: "normal" | "pii"; +}; + +export type CreateChannelIngressStringAdapterParams = { + kind?: ChannelIngressIdentifierKind; + normalizeEntry?: (value: string) => string | null | undefined; + normalizeSubject?: (value: string) => string | null | undefined; + isWildcardEntry?: (value: string) => boolean; + resolveEntryId?: (params: { entry: string; index: number }) => string; + dangerous?: boolean | ((entry: string) => boolean); + sensitivity?: "normal" | "pii"; +}; + +export type CreateChannelIngressMultiIdentifierAdapterParams = { + normalizeEntry: (entry: string, index: number) => readonly ChannelIngressAdapterEntry[]; + getEntryMatchKey?: (entry: ChannelIngressAdapterEntry) => string | null | undefined; + getSubjectMatchKeys?: ( + identifier: ChannelIngressSubjectIdentifier, + ) => readonly (string | null | undefined)[]; + isWildcardEntry?: (entry: ChannelIngressAdapterEntry) => boolean; +}; + +export type ChannelIngressDmGroupAccessProjection = { + decision: DmGroupAccessDecision; + reasonCode: DmGroupAccessReasonCode; + reason: string; +}; + +export type ChannelIngressSenderGroupAccessProjection = { + allowed: boolean; + groupPolicy: ChannelIngressPolicyInput["groupPolicy"]; + providerMissingFallbackApplied: boolean; + reason: "allowed" | "disabled" | "empty_allowlist" | "sender_not_allowlisted"; +}; + +/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ +export type ResolveChannelIngressAccessParams = ChannelIngressStateInput & { + policy: ChannelIngressPolicyInput; + effectiveAllowFrom?: readonly string[]; + effectiveGroupAllowFrom?: readonly string[]; +}; + +/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ +export type ResolvedChannelIngressAccess = { + state: ChannelIngressState; + ingress: ChannelIngressDecision; + isGroup: boolean; + senderReasonCode: IngressReasonCode; + access: ChannelIngressDmGroupAccessProjection & { + effectiveAllowFrom: string[]; + effectiveGroupAllowFrom: string[]; + }; + commandAuthorized: boolean; + shouldBlockControlCommand: boolean; +}; + +function defaultNormalize(value: string): string { + return value; +} + +function normalizeMatchValue( + value: string, + normalize: (value: string) => string | null | undefined, +): string | null { + const normalized = normalize(value); + return normalized == null ? null : normalized.trim() || null; +} + +function resolveDangerous( + dangerous: CreateChannelIngressStringAdapterParams["dangerous"], + entry: string, +): boolean | undefined { + return typeof dangerous === "function" ? dangerous(entry) : dangerous; +} + +function defaultIngressMatchKey(params: { + kind: ChannelIngressIdentifierKind; + value: string; +}): string { + return `${params.kind}:${params.value}`; +} + +export function findChannelIngressGate( + decision: ChannelIngressDecision, + selector: ChannelIngressGateSelector, +): AccessGraphGate | undefined { + return decision.graph.gates.find( + (gate) => gate.phase === selector.phase && gate.kind === selector.kind, + ); +} + +export function findChannelIngressSenderGate( + decision: ChannelIngressDecision, + params: { isGroup: boolean }, +): AccessGraphGate | undefined { + return findChannelIngressGate( + decision, + params.isGroup + ? CHANNEL_INGRESS_GATE_SELECTORS.groupSender + : CHANNEL_INGRESS_GATE_SELECTORS.dmSender, + ); +} + +export function findChannelIngressCommandGate( + decision: ChannelIngressDecision, +): AccessGraphGate | undefined { + return findChannelIngressGate(decision, CHANNEL_INGRESS_GATE_SELECTORS.command); +} + +export function decideChannelIngressBundle(params: { + directState: ChannelIngressState; + groupState: ChannelIngressState; + basePolicy: ChannelIngressPolicyInput; + commandPolicy: ChannelIngressPolicyInput; +}): ChannelIngressDecisionBundle { + return { + dm: decideChannelIngress(params.directState, params.basePolicy), + group: decideChannelIngress(params.groupState, params.basePolicy), + dmCommand: decideChannelIngress(params.directState, params.commandPolicy), + groupCommand: decideChannelIngress(params.groupState, params.commandPolicy), + }; +} + +function projectGroupPolicy( + gate: AccessGraphGate | undefined, +): NonNullable["policy"] { + const policy = gate?.sender?.policy; + return policy === "open" || policy === "disabled" ? policy : "allowlist"; +} + +function projectMentionFacts(gate: AccessGraphGate | undefined): AccessFacts["mentions"] { + const activation = gate?.activation; + if (!activation?.hasMentionFacts) { + return undefined; + } + return { + canDetectMention: activation.canDetectMention ?? false, + wasMentioned: activation.wasMentioned ?? false, + hasAnyMention: activation.hasAnyMention, + implicitMentionKinds: activation.implicitMentionKinds + ? [...activation.implicitMentionKinds] + : undefined, + requireMention: activation.requireMention, + effectiveWasMentioned: activation.effectiveWasMentioned, + shouldSkip: activation.shouldSkip, + }; +} + +function projectDmDecision( + decision: ChannelIngressDecision, + dmSender: AccessGraphGate | undefined, +): NonNullable["decision"] { + if (decision.decision === "pairing") { + return "pairing"; + } + if (dmSender) { + return dmSender.allowed ? "allow" : "deny"; + } + return decision.admission === "drop" ? "deny" : "allow"; +} + +export function projectIngressAccessFacts(decision: ChannelIngressDecision): AccessFacts { + const command = findChannelIngressGate(decision, CHANNEL_INGRESS_GATE_SELECTORS.command); + const activation = findChannelIngressGate(decision, CHANNEL_INGRESS_GATE_SELECTORS.activation); + const dmSender = findChannelIngressGate(decision, CHANNEL_INGRESS_GATE_SELECTORS.dmSender); + const groupSender = findChannelIngressGate(decision, CHANNEL_INGRESS_GATE_SELECTORS.groupSender); + const event = findChannelIngressGate(decision, CHANNEL_INGRESS_GATE_SELECTORS.event); + return { + dm: { + decision: projectDmDecision(decision, dmSender), + reason: dmSender?.reasonCode ?? decision.reasonCode, + allowFrom: [], + allowlist: dmSender?.allowlist, + }, + group: { + policy: projectGroupPolicy(groupSender), + routeAllowed: !decision.graph.gates.some( + (gate) => gate.phase === "route" && gate.effect === "block-dispatch", + ), + senderAllowed: groupSender?.allowed ?? dmSender?.allowed ?? false, + allowFrom: [], + requireMention: activation?.activation?.requireMention ?? false, + allowlist: groupSender?.allowlist, + }, + commands: command?.command + ? { + authorized: command.allowed, + shouldBlockControlCommand: command.command.shouldBlockControlCommand, + reasonCode: command.reasonCode, + useAccessGroups: command.command.useAccessGroups, + allowTextCommands: command.command.allowTextCommands, + modeWhenAccessGroupsOff: command.command.modeWhenAccessGroupsOff, + authorizers: [], + } + : undefined, + event: event?.event + ? { + ...event.event, + authorized: event.allowed, + reasonCode: event.reasonCode, + } + : undefined, + mentions: projectMentionFacts(activation), + }; +} + +export function mapChannelIngressDecisionToTurnAdmission( + decision: ChannelIngressDecision, + sideEffect: ChannelIngressSideEffectResult, +): ChannelTurnAdmission { + if (decision.admission === "dispatch") { + return { kind: "dispatch", reason: decision.reasonCode }; + } + if (decision.admission === "observe") { + return { kind: "observeOnly", reason: decision.reasonCode }; + } + if (decision.admission === "pairing-required") { + return sideEffect.kind === "pairing-reply-sent" + ? { kind: "handled", reason: decision.reasonCode } + : { kind: "drop", reason: decision.reasonCode }; + } + if (decision.admission === "skip") { + return sideEffect.kind === "pending-history-recorded" || + sideEffect.kind === "local-event-handled" || + sideEffect.kind === "command-reply-sent" + ? { kind: "handled", reason: decision.reasonCode } + : { kind: "drop", reason: decision.reasonCode, recordHistory: false }; + } + return sideEffect.kind === "local-event-handled" || sideEffect.kind === "command-reply-sent" + ? { kind: "handled", reason: decision.reasonCode } + : { kind: "drop", reason: decision.reasonCode }; +} + +export function createChannelIngressPluginId(id: string): ChannelIngressPluginId { + const trimmed = id.trim(); + if (!trimmed) { + throw new Error("Channel ingress plugin id must be non-empty."); + } + return trimmed as ChannelIngressPluginId; +} + +export function createChannelIngressSubject( + input: + | ChannelIngressSubjectIdentifierInput + | { identifiers: readonly ChannelIngressSubjectIdentifierInput[] }, +): ChannelIngressSubject { + const identifiers = "identifiers" in input ? input.identifiers : [input]; + return { + identifiers: identifiers.map((identifier, index) => ({ + opaqueId: identifier.opaqueId ?? `subject-${index + 1}`, + kind: identifier.kind ?? "stable-id", + value: identifier.value, + dangerous: identifier.dangerous, + sensitivity: identifier.sensitivity, + })), + }; +} + +export function createChannelIngressStringAdapter( + params: CreateChannelIngressStringAdapterParams = {}, +): ChannelIngressAdapter { + const kind = params.kind ?? "stable-id"; + const normalizeEntry = params.normalizeEntry ?? defaultNormalize; + const normalizeSubject = params.normalizeSubject ?? normalizeEntry; + const isWildcardEntry = params.isWildcardEntry ?? ((entry: string) => entry === "*"); + return { + normalizeEntries({ entries }) { + const matchable = normalizeStringEntries(entries).flatMap((entry, index) => { + const value = isWildcardEntry(entry) ? "*" : normalizeMatchValue(entry, normalizeEntry); + if (!value) { + return []; + } + return [ + { + opaqueEntryId: params.resolveEntryId?.({ entry, index }) ?? `entry-${index + 1}`, + kind, + value, + dangerous: resolveDangerous(params.dangerous, entry), + sensitivity: params.sensitivity, + }, + ]; + }); + return { + matchable, + invalid: [], + disabled: [], + }; + }, + matchSubject({ subject, entries }) { + const values = new Set( + subject.identifiers.flatMap((identifier) => { + if (identifier.kind !== kind) { + return []; + } + const value = normalizeMatchValue(identifier.value, normalizeSubject); + return value ? [value] : []; + }), + ); + const matchedEntryIds = entries + .filter((entry) => entry.kind === kind && (entry.value === "*" || values.has(entry.value))) + .map((entry) => entry.opaqueEntryId); + return { + matched: matchedEntryIds.length > 0, + matchedEntryIds, + }; + }, + }; +} + +export function createChannelIngressMultiIdentifierAdapter( + params: CreateChannelIngressMultiIdentifierAdapterParams, +): ChannelIngressAdapter { + const getEntryMatchKey = params.getEntryMatchKey ?? defaultIngressMatchKey; + const getSubjectMatchKeys = + params.getSubjectMatchKeys ?? + ((identifier: ChannelIngressSubjectIdentifier) => [defaultIngressMatchKey(identifier)]); + const isWildcardEntry = params.isWildcardEntry ?? ((entry) => entry.value === "*"); + return { + normalizeEntries({ entries }) { + return { + matchable: entries.flatMap((entry, index) => params.normalizeEntry(entry, index)), + invalid: [], + disabled: [], + }; + }, + matchSubject({ subject, entries }) { + const subjectKeys = new Set( + subject.identifiers.flatMap((identifier) => + getSubjectMatchKeys(identifier).filter((key): key is string => Boolean(key)), + ), + ); + const matchedEntryIds = entries + .filter((entry) => { + if (isWildcardEntry(entry)) { + return true; + } + const key = getEntryMatchKey(entry); + return key ? subjectKeys.has(key) : false; + }) + .map((entry) => entry.opaqueEntryId); + return { + matched: matchedEntryIds.length > 0, + matchedEntryIds, + }; + }, + }; +} + +export function assertNeverChannelIngressReason(reasonCode: never): never { + throw new Error(`Unhandled channel ingress reason code: ${String(reasonCode)}`); +} + +/** @deprecated Use `senderAccess.reasonCode` from `resolveChannelMessageIngress(...)` or typed gate selectors. */ +export function findChannelIngressSenderReasonCode( + decision: ChannelIngressDecision, + params: { isGroup: boolean }, +): IngressReasonCode { + return findChannelIngressSenderGate(decision, params)?.reasonCode ?? decision.reasonCode; +} + +/** @deprecated Use `senderAccess.reasonCode` from `resolveChannelMessageIngress(...)`. */ +export function mapChannelIngressReasonCodeToDmGroupAccessReason(params: { + reasonCode: IngressReasonCode; + isGroup: boolean; +}): DmGroupAccessReasonCode { + switch (params.reasonCode) { + case "group_policy_open": + case "group_policy_allowed": + return "group_policy_allowed"; + case "group_policy_disabled": + return "group_policy_disabled"; + case "route_sender_empty": + case "group_policy_empty_allowlist": + return "group_policy_empty_allowlist"; + case "group_policy_not_allowlisted": + return "group_policy_not_allowlisted"; + case "dm_policy_open": + return "dm_policy_open"; + case "dm_policy_disabled": + return "dm_policy_disabled"; + case "dm_policy_allowlisted": + return "dm_policy_allowlisted"; + case "dm_policy_pairing_required": + return "dm_policy_pairing_required"; + default: + return params.isGroup ? "group_policy_not_allowlisted" : "dm_policy_not_allowlisted"; + } +} + +/** @deprecated Use `senderAccess.reason` from `resolveChannelMessageIngress(...)`. */ +export function formatChannelIngressPolicyReason(params: { + reasonCode: DmGroupAccessReasonCode; + dmPolicy: string; + groupPolicy: string; +}): string { + switch (params.reasonCode) { + case "group_policy_allowed": + return `groupPolicy=${params.groupPolicy}`; + case "group_policy_disabled": + return "groupPolicy=disabled"; + case "group_policy_empty_allowlist": + return "groupPolicy=allowlist (empty allowlist)"; + case "group_policy_not_allowlisted": + return "groupPolicy=allowlist (not allowlisted)"; + case "dm_policy_open": + return "dmPolicy=open"; + case "dm_policy_disabled": + return "dmPolicy=disabled"; + case "dm_policy_allowlisted": + return `dmPolicy=${params.dmPolicy} (allowlisted)`; + case "dm_policy_pairing_required": + return "dmPolicy=pairing (not allowlisted)"; + case "dm_policy_not_allowlisted": + return `dmPolicy=${params.dmPolicy} (not allowlisted)`; + } + const exhaustive: never = params.reasonCode; + return exhaustive; +} + +/** @deprecated Use `senderAccess.groupAccess` from `resolveChannelMessageIngress(...)`. */ +export function projectChannelIngressSenderGroupAccess(params: { + reasonCode: IngressReasonCode; + decisionAllowed: boolean; + groupPolicy: ChannelIngressPolicyInput["groupPolicy"]; + providerMissingFallbackApplied?: boolean; +}): ChannelIngressSenderGroupAccessProjection { + const reasonCode = mapChannelIngressReasonCodeToDmGroupAccessReason({ + reasonCode: params.reasonCode, + isGroup: true, + }); + const reason = + params.groupPolicy === "disabled" || reasonCode === "group_policy_disabled" + ? "disabled" + : reasonCode === "group_policy_empty_allowlist" + ? "empty_allowlist" + : reasonCode === "group_policy_not_allowlisted" + ? "sender_not_allowlisted" + : "allowed"; + return { + allowed: reason === "allowed" && params.decisionAllowed, + groupPolicy: params.groupPolicy, + providerMissingFallbackApplied: params.providerMissingFallbackApplied ?? false, + reason, + }; +} + +/** @deprecated Use `senderAccess` from `resolveChannelMessageIngress(...)`. */ +export function projectChannelIngressDmGroupAccess(params: { + ingress: ChannelIngressDecision; + isGroup: boolean; + dmPolicy: string; + groupPolicy: string; +}): ChannelIngressDmGroupAccessProjection { + const reasonCode = mapChannelIngressReasonCodeToDmGroupAccessReason({ + reasonCode: findChannelIngressSenderReasonCode(params.ingress, { isGroup: params.isGroup }), + isGroup: params.isGroup, + }); + const decision: DmGroupAccessDecision = + reasonCode === "dm_policy_pairing_required" + ? "pairing" + : params.ingress.decision === "allow" + ? "allow" + : "block"; + const reason = formatChannelIngressPolicyReason({ + reasonCode, + dmPolicy: params.dmPolicy, + groupPolicy: params.groupPolicy, + }); + return { + decision, + reasonCode, + reason, + }; +} + +export async function resolveChannelIngressState( + input: ChannelIngressStateInput, +): Promise { + return await resolveChannelIngressStateInternal(input); +} + +/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ +export async function resolveChannelIngressAccess( + params: ResolveChannelIngressAccessParams, +): Promise { + const { policy, effectiveAllowFrom, effectiveGroupAllowFrom, ...stateInput } = params; + const state = await resolveChannelIngressState(stateInput); + const ingress = decideChannelIngress(state, policy); + const isGroup = params.conversation.kind !== "direct"; + const senderReasonCode = findChannelIngressSenderReasonCode(ingress, { isGroup }); + const access = projectChannelIngressDmGroupAccess({ + ingress, + isGroup, + dmPolicy: policy.dmPolicy, + groupPolicy: policy.groupPolicy, + }); + const commandGate = findChannelIngressCommandGate(ingress); + return { + state, + ingress, + isGroup, + senderReasonCode, + access: { + ...access, + effectiveAllowFrom: [...(effectiveAllowFrom ?? [])], + effectiveGroupAllowFrom: [...(effectiveGroupAllowFrom ?? [])], + }, + commandAuthorized: commandGate?.allowed === true, + shouldBlockControlCommand: commandGate?.command?.shouldBlockControlCommand === true, + }; +} diff --git a/src/plugin-sdk/channel-policy.ts b/src/plugin-sdk/channel-policy.ts index 533e7fc2fd2..230cd6ccb0c 100644 --- a/src/plugin-sdk/channel-policy.ts +++ b/src/plugin-sdk/channel-policy.ts @@ -50,7 +50,7 @@ export { resolveDmGroupAccessWithLists, resolveEffectiveAllowFromLists, resolveOpenDmAllowlistAccess, -} from "../security/dm-policy-shared.js"; +} from "./channel-access-compat.js"; export { evaluateGroupRouteAccessForPolicy, evaluateSenderGroupAccessForPolicy, diff --git a/src/plugin-sdk/command-auth-native.ts b/src/plugin-sdk/command-auth-native.ts index c032cd85b1e..b3e6e98d9ce 100644 --- a/src/plugin-sdk/command-auth-native.ts +++ b/src/plugin-sdk/command-auth-native.ts @@ -2,12 +2,26 @@ export { buildCommandTextFromArgs, findCommandByNativeName, formatCommandArgMenuTitle, + listChatCommands, listNativeCommandSpecs, listNativeCommandSpecsForConfig, + maybeResolveTextAlias, + normalizeCommandBody, parseCommandArgs, + serializeCommandArgs, resolveCommandArgMenu, } from "../auto-reply/commands-registry.js"; -export type { CommandArgs } from "../auto-reply/commands-registry.js"; +export type { + ChatCommandDefinition, + CommandArgDefinition, + CommandArgValues, + CommandArgs, + NativeCommandSpec, +} from "../auto-reply/commands-registry.js"; +export { + hasControlCommand, + shouldComputeCommandAuthorized, +} from "../auto-reply/command-detection.js"; export { resolveCommandAuthorizedFromAuthorizers, resolveControlCommandGate, @@ -18,3 +32,6 @@ export { type CommandAuthorization, } from "../auto-reply/command-auth.js"; export { resolveStoredModelOverride } from "../auto-reply/reply/stored-model-override.js"; +export type { ModelsProviderData } from "../auto-reply/reply/commands-models.js"; +export { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js"; +export { listProviderPluginCommandSpecs } from "../plugins/command-specs.js"; diff --git a/src/plugin-sdk/command-auth.ts b/src/plugin-sdk/command-auth.ts index 626f101aae6..326f87b40ff 100644 --- a/src/plugin-sdk/command-auth.ts +++ b/src/plugin-sdk/command-auth.ts @@ -5,17 +5,20 @@ import { } from "../auto-reply/command-status-builders.js"; import type { ChannelId } from "../channels/plugins/types.public.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { resolveDmGroupAccessWithLists } from "../security/dm-policy-shared.js"; import { expandAllowFromWithAccessGroups, type AccessGroupMembershipResolver, } from "./access-groups.js"; +import { resolveDmGroupAccessWithLists } from "./channel-access-compat.js"; export { ACCESS_GROUP_ALLOW_FROM_PREFIX, expandAllowFromWithAccessGroups, parseAccessGroupAllowFromEntry, resolveAccessGroupAllowFromMatches, + resolveAccessGroupAllowFromState, type AccessGroupMembershipResolver, + type AccessGroupMembershipLookup, + type ResolvedAccessGroupAllowFromState, } from "./access-groups.js"; export { buildCommandsPaginationKeyboard } from "./telegram-command-ui.js"; export { @@ -100,6 +103,7 @@ export type { ModelsProviderData } from "../auto-reply/reply/commands-models.js" export { resolveStoredModelOverride } from "../auto-reply/reply/stored-model-override.js"; export type { StoredModelOverride } from "../auto-reply/reply/stored-model-override.js"; +/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ export type ResolveSenderCommandAuthorizationParams = { cfg: OpenClawConfig; rawBody: string; @@ -114,12 +118,14 @@ export type ResolveSenderCommandAuthorizationParams = { resolveAccessGroupMembership?: AccessGroupMembershipResolver; readAllowFromStore: () => Promise; shouldComputeCommandAuthorized: (rawBody: string, cfg: OpenClawConfig) => boolean; - resolveCommandAuthorizedFromAuthorizers: (params: { + /** @deprecated Command authorization is resolved by channel ingress. Kept for runtime injection compatibility. */ + resolveCommandAuthorizedFromAuthorizers?: (params: { useAccessGroups: boolean; authorizers: Array<{ configured: boolean; allowed: boolean }>; }) => boolean; }; +/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ export type CommandAuthorizationRuntime = { shouldComputeCommandAuthorized: (rawBody: string, cfg: OpenClawConfig) => boolean; resolveCommandAuthorizedFromAuthorizers: (params: { @@ -128,6 +134,7 @@ export type CommandAuthorizationRuntime = { }) => boolean; }; +/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ export type ResolveSenderCommandAuthorizationWithRuntimeParams = Omit< ResolveSenderCommandAuthorizationParams, "shouldComputeCommandAuthorized" | "resolveCommandAuthorizedFromAuthorizers" @@ -135,7 +142,7 @@ export type ResolveSenderCommandAuthorizationWithRuntimeParams = Omit< runtime: CommandAuthorizationRuntime; }; -/** Fast-path DM command authorization when only policy and sender allowlist state matter. */ +/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ export function resolveDirectDmAuthorizationOutcome(params: { isGroup: boolean; dmPolicy: string; @@ -153,7 +160,7 @@ export function resolveDirectDmAuthorizationOutcome(params: { return "allowed"; } -/** Runtime-backed wrapper around sender command authorization for grouped helper surfaces. */ +/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ export async function resolveSenderCommandAuthorizationWithRuntime( params: ResolveSenderCommandAuthorizationWithRuntimeParams, ): ReturnType { @@ -164,7 +171,7 @@ export async function resolveSenderCommandAuthorizationWithRuntime( }); } -/** Compute effective allowlists and command authorization for one inbound sender. */ +/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ export async function resolveSenderCommandAuthorization( params: ResolveSenderCommandAuthorizationParams, ): Promise<{ @@ -236,13 +243,13 @@ export async function resolveSenderCommandAuthorization( const ownerAllowedForCommands = params.isSenderAllowed(params.senderId, effectiveAllowFrom); const groupAllowedForCommands = params.isSenderAllowed(params.senderId, effectiveGroupAllowFrom); const commandAuthorized = shouldComputeAuth - ? params.resolveCommandAuthorizedFromAuthorizers({ + ? (params.resolveCommandAuthorizedFromAuthorizers?.({ useAccessGroups, authorizers: [ { configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands }, { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, ], - }) + }) ?? senderAllowedForCommands) : undefined; return { diff --git a/src/plugin-sdk/conversation-runtime.ts b/src/plugin-sdk/conversation-runtime.ts index 1f67f25268b..aa59ca1f292 100644 --- a/src/plugin-sdk/conversation-runtime.ts +++ b/src/plugin-sdk/conversation-runtime.ts @@ -109,4 +109,4 @@ export { resolvePluginConversationBindingApproval, toPluginConversationBinding, } from "../plugins/conversation-binding.js"; -export { resolvePinnedMainDmOwnerFromAllowlist } from "../security/dm-policy-shared.js"; +export { resolvePinnedMainDmOwnerFromAllowlist } from "./channel-access-compat.js"; diff --git a/src/plugin-sdk/direct-dm-access.ts b/src/plugin-sdk/direct-dm-access.ts index 1495e26f836..b715b10d1ba 100644 --- a/src/plugin-sdk/direct-dm-access.ts +++ b/src/plugin-sdk/direct-dm-access.ts @@ -1,25 +1,27 @@ import type { ChannelId } from "../channels/plugins/types.public.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { - readStoreAllowFromForDmPolicy, - resolveDmGroupAccessWithLists, - type DmGroupAccessReasonCode, -} from "../security/dm-policy-shared.js"; import { expandAllowFromWithAccessGroups, type AccessGroupMembershipResolver, } from "./access-groups.js"; +import { DM_GROUP_ACCESS_REASON, type DmGroupAccessReasonCode } from "./channel-access-compat.js"; +import { + readStoreAllowFromForDmPolicy, + resolveDmGroupAccessWithLists, +} from "./channel-access-compat.js"; export type { AccessGroupMembershipResolver } from "./access-groups.js"; export type DirectDmCommandAuthorizationRuntime = { shouldComputeCommandAuthorized: (rawBody: string, cfg: OpenClawConfig) => boolean; - resolveCommandAuthorizedFromAuthorizers: (params: { + /** @deprecated Command authorization is resolved by channel ingress. Kept for runtime injection compatibility. */ + resolveCommandAuthorizedFromAuthorizers?: (params: { useAccessGroups: boolean; authorizers: Array<{ configured: boolean; allowed: boolean }>; modeWhenAccessGroupsOff?: "allow" | "deny" | "configured"; }) => boolean; }; +/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ export type ResolvedInboundDirectDmAccess = { access: { decision: "allow" | "block" | "pairing"; @@ -32,7 +34,20 @@ export type ResolvedInboundDirectDmAccess = { commandAuthorized: boolean | undefined; }; -/** Resolve direct-DM policy, effective allowlists, and optional command auth in one place. */ +function toLegacyDmReasonCode(reasonCode: string): DmGroupAccessReasonCode { + switch (reasonCode) { + case DM_GROUP_ACCESS_REASON.DM_POLICY_OPEN: + case DM_GROUP_ACCESS_REASON.DM_POLICY_DISABLED: + case DM_GROUP_ACCESS_REASON.DM_POLICY_ALLOWLISTED: + case DM_GROUP_ACCESS_REASON.DM_POLICY_PAIRING_REQUIRED: + case DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED: + return reasonCode; + default: + return DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED; + } +} + +/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ export async function resolveInboundDirectDmAccessWithRuntime(params: { cfg: OpenClawConfig; channel: ChannelId; @@ -48,6 +63,10 @@ export async function resolveInboundDirectDmAccessWithRuntime(params: { readStoreAllowFrom?: (provider: ChannelId, accountId: string) => Promise; }): Promise { const dmPolicy = params.dmPolicy ?? "pairing"; + const shouldComputeAuth = params.runtime.shouldComputeCommandAuthorized( + params.rawBody, + params.cfg, + ); const storeAllowFrom = dmPolicy === "pairing" ? await readStoreAllowFromForDmPolicy({ @@ -77,7 +96,6 @@ export async function resolveInboundDirectDmAccessWithRuntime(params: { resolveMembership: params.resolveAccessGroupMembership, }), ]); - const access = resolveDmGroupAccessWithLists({ isGroup: false, dmPolicy, @@ -86,17 +104,13 @@ export async function resolveInboundDirectDmAccessWithRuntime(params: { groupAllowFromFallbackToAllowFrom: false, isSenderAllowed: (allowEntries) => params.isSenderAllowed(params.senderId, allowEntries), }); - - const shouldComputeAuth = params.runtime.shouldComputeCommandAuthorized( - params.rawBody, - params.cfg, - ); + const reasonCode = toLegacyDmReasonCode(access.reasonCode); const senderAllowedForCommands = params.isSenderAllowed( params.senderId, access.effectiveAllowFrom, ); const commandAuthorized = shouldComputeAuth - ? params.runtime.resolveCommandAuthorizedFromAuthorizers({ + ? (params.runtime.resolveCommandAuthorizedFromAuthorizers?.({ useAccessGroups: params.cfg.commands?.useAccessGroups !== false, authorizers: [ { @@ -105,13 +119,13 @@ export async function resolveInboundDirectDmAccessWithRuntime(params: { }, ], modeWhenAccessGroupsOff: params.modeWhenAccessGroupsOff, - }) + }) ?? senderAllowedForCommands) : undefined; return { access: { decision: access.decision, - reasonCode: access.reasonCode, + reasonCode, reason: access.reason, effectiveAllowFrom: access.effectiveAllowFrom, }, @@ -121,7 +135,7 @@ export async function resolveInboundDirectDmAccessWithRuntime(params: { }; } -/** Convert resolved DM policy into a pre-crypto allow/block/pairing callback. */ +/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ export function createPreCryptoDirectDmAuthorizer(params: { resolveAccess: ( senderId: string, diff --git a/src/plugin-sdk/group-access.ts b/src/plugin-sdk/group-access.ts index 494a78c7b6c..9783ed79929 100644 --- a/src/plugin-sdk/group-access.ts +++ b/src/plugin-sdk/group-access.ts @@ -9,41 +9,36 @@ export type SenderGroupAccessReason = | "disabled" | "empty_allowlist" | "sender_not_allowlisted"; - export type SenderGroupAccessDecision = { allowed: boolean; groupPolicy: GroupPolicy; providerMissingFallbackApplied: boolean; reason: SenderGroupAccessReason; }; - export type GroupRouteAccessReason = | "allowed" | "disabled" | "empty_allowlist" | "route_not_allowlisted" | "route_disabled"; - export type GroupRouteAccessDecision = { allowed: boolean; groupPolicy: GroupPolicy; reason: GroupRouteAccessReason; }; - export type MatchedGroupAccessReason = | "allowed" | "disabled" | "missing_match_input" | "empty_allowlist" | "not_allowlisted"; - export type MatchedGroupAccessDecision = { allowed: boolean; groupPolicy: GroupPolicy; reason: MatchedGroupAccessReason; }; -/** Downgrade sender-scoped group policy to open mode when no allowlist is configured. */ +/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ export function resolveSenderScopedGroupPolicy(params: { groupPolicy: GroupPolicy; groupAllowFrom: string[]; @@ -54,7 +49,7 @@ export function resolveSenderScopedGroupPolicy(params: { return params.groupAllowFrom.length > 0 ? "allowlist" : "open"; } -/** Evaluate route-level group access after policy, route match, and enablement checks. */ +/** @deprecated Use route descriptors with `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ export function evaluateGroupRouteAccessForPolicy(params: { groupPolicy: GroupPolicy; routeAllowlistConfigured: boolean; @@ -62,46 +57,23 @@ export function evaluateGroupRouteAccessForPolicy(params: { routeEnabled?: boolean; }): GroupRouteAccessDecision { if (params.groupPolicy === "disabled") { - return { - allowed: false, - groupPolicy: params.groupPolicy, - reason: "disabled", - }; + return { allowed: false, groupPolicy: params.groupPolicy, reason: "disabled" }; } - if (params.routeMatched && params.routeEnabled === false) { - return { - allowed: false, - groupPolicy: params.groupPolicy, - reason: "route_disabled", - }; + return { allowed: false, groupPolicy: params.groupPolicy, reason: "route_disabled" }; } - if (params.groupPolicy === "allowlist") { if (!params.routeAllowlistConfigured) { - return { - allowed: false, - groupPolicy: params.groupPolicy, - reason: "empty_allowlist", - }; + return { allowed: false, groupPolicy: params.groupPolicy, reason: "empty_allowlist" }; } if (!params.routeMatched) { - return { - allowed: false, - groupPolicy: params.groupPolicy, - reason: "route_not_allowlisted", - }; + return { allowed: false, groupPolicy: params.groupPolicy, reason: "route_not_allowlisted" }; } } - - return { - allowed: true, - groupPolicy: params.groupPolicy, - reason: "allowed", - }; + return { allowed: true, groupPolicy: params.groupPolicy, reason: "allowed" }; } -/** Evaluate generic allowlist match state for channels that compare derived group identifiers. */ +/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ export function evaluateMatchedGroupAccessForPolicy(params: { groupPolicy: GroupPolicy; allowlistConfigured: boolean; @@ -110,45 +82,23 @@ export function evaluateMatchedGroupAccessForPolicy(params: { hasMatchInput?: boolean; }): MatchedGroupAccessDecision { if (params.groupPolicy === "disabled") { - return { - allowed: false, - groupPolicy: params.groupPolicy, - reason: "disabled", - }; + return { allowed: false, groupPolicy: params.groupPolicy, reason: "disabled" }; } - if (params.groupPolicy === "allowlist") { if (params.requireMatchInput && !params.hasMatchInput) { - return { - allowed: false, - groupPolicy: params.groupPolicy, - reason: "missing_match_input", - }; + return { allowed: false, groupPolicy: params.groupPolicy, reason: "missing_match_input" }; } if (!params.allowlistConfigured) { - return { - allowed: false, - groupPolicy: params.groupPolicy, - reason: "empty_allowlist", - }; + return { allowed: false, groupPolicy: params.groupPolicy, reason: "empty_allowlist" }; } if (!params.allowlistMatched) { - return { - allowed: false, - groupPolicy: params.groupPolicy, - reason: "not_allowlisted", - }; + return { allowed: false, groupPolicy: params.groupPolicy, reason: "not_allowlisted" }; } } - - return { - allowed: true, - groupPolicy: params.groupPolicy, - reason: "allowed", - }; + return { allowed: true, groupPolicy: params.groupPolicy, reason: "allowed" }; } -/** Evaluate sender access for an already-resolved group policy and allowlist. */ +/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ export function evaluateSenderGroupAccessForPolicy(params: { groupPolicy: GroupPolicy; providerMissingFallbackApplied?: boolean; @@ -156,11 +106,12 @@ export function evaluateSenderGroupAccessForPolicy(params: { senderId: string; isSenderAllowed: (senderId: string, allowFrom: string[]) => boolean; }): SenderGroupAccessDecision { + const providerMissingFallbackApplied = Boolean(params.providerMissingFallbackApplied); if (params.groupPolicy === "disabled") { return { allowed: false, groupPolicy: params.groupPolicy, - providerMissingFallbackApplied: Boolean(params.providerMissingFallbackApplied), + providerMissingFallbackApplied, reason: "disabled", }; } @@ -169,7 +120,7 @@ export function evaluateSenderGroupAccessForPolicy(params: { return { allowed: false, groupPolicy: params.groupPolicy, - providerMissingFallbackApplied: Boolean(params.providerMissingFallbackApplied), + providerMissingFallbackApplied, reason: "empty_allowlist", }; } @@ -177,21 +128,20 @@ export function evaluateSenderGroupAccessForPolicy(params: { return { allowed: false, groupPolicy: params.groupPolicy, - providerMissingFallbackApplied: Boolean(params.providerMissingFallbackApplied), + providerMissingFallbackApplied, reason: "sender_not_allowlisted", }; } } - return { allowed: true, groupPolicy: params.groupPolicy, - providerMissingFallbackApplied: Boolean(params.providerMissingFallbackApplied), + providerMissingFallbackApplied, reason: "allowed", }; } -/** Resolve provider fallback policy first, then evaluate sender access against that result. */ +/** @deprecated Use `resolveOpenProviderRuntimeGroupPolicy` plus `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ export function evaluateSenderGroupAccess(params: { providerConfigPresent: boolean; configuredGroupPolicy?: GroupPolicy; diff --git a/src/plugin-sdk/inbound-envelope.ts b/src/plugin-sdk/inbound-envelope.ts index f3662b725c3..25b45e18564 100644 --- a/src/plugin-sdk/inbound-envelope.ts +++ b/src/plugin-sdk/inbound-envelope.ts @@ -4,7 +4,7 @@ type RouteLike = { }; type RoutePeerLike = { - kind: string; + kind: "direct" | "group" | "channel"; id: string | number; }; diff --git a/src/plugin-sdk/security-runtime.ts b/src/plugin-sdk/security-runtime.ts index dbb5955c777..c3a693e8f10 100644 --- a/src/plugin-sdk/security-runtime.ts +++ b/src/plugin-sdk/security-runtime.ts @@ -8,13 +8,16 @@ export * from "../secrets/shared.js"; export type * from "../secrets/target-registry-types.js"; export * from "../security/channel-metadata.js"; export * from "../security/context-visibility.js"; -export * from "../security/dm-policy-shared.js"; +export * from "./channel-access-compat.js"; export { ACCESS_GROUP_ALLOW_FROM_PREFIX, expandAllowFromWithAccessGroups, parseAccessGroupAllowFromEntry, resolveAccessGroupAllowFromMatches, + resolveAccessGroupAllowFromState, type AccessGroupMembershipResolver, + type AccessGroupMembershipLookup, + type ResolvedAccessGroupAllowFromState, } from "./access-groups.js"; export * from "../security/external-content.js"; export * from "../security/safe-regex.js"; diff --git a/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts b/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts index 6610c3b5195..3e328b456fb 100644 --- a/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts +++ b/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts @@ -265,7 +265,9 @@ export function createPluginRuntimeMock(overrides: DeepPartial = OriginatingChannel: params.channel, OriginatingTo: params.reply.originatingTo, CommandAuthorized: params.access?.commands - ? params.access.commands.authorizers.some((entry) => entry.allowed) + ? (params.access.commands.authorized ?? + params.access.commands.authorizers?.some((entry) => entry.allowed) ?? + false) : false, ...params.extra, }) as ReturnType, @@ -636,6 +638,8 @@ export function createPluginRuntimeMock(overrides: DeepPartial = }, turn: { run: runChannelTurnMock, + runAssembled: + dispatchAssembledChannelTurnMock as unknown as PluginRuntime["channel"]["turn"]["runAssembled"], runResolved: vi.fn( async (params: Parameters[0]) => await runChannelTurnMock({ diff --git a/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts b/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts index 229f996c340..acb155293e4 100644 --- a/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts +++ b/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts @@ -49,7 +49,6 @@ const RUNTIME_API_EXPORT_GUARDS: Record = { 'export { createAccountStatusSink, runPassiveAccountLifecycle } from "openclaw/plugin-sdk/channel-lifecycle";', 'export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";', 'export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message";', - 'export { evaluateGroupRouteAccessForPolicy, resolveDmGroupAccessWithLists, resolveSenderScopedGroupPolicy } from "openclaw/plugin-sdk/channel-policy";', 'export { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";', 'export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";', 'export type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";', @@ -77,7 +76,7 @@ const RUNTIME_API_EXPORT_GUARDS: Record = { 'export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";', 'export { logTypingFailure } from "openclaw/plugin-sdk/channel-logging";', 'export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";', - 'export { evaluateSenderGroupAccessForPolicy, readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithLists, resolveEffectiveAllowFromLists, resolveSenderScopedGroupPolicy, resolveToolsBySender } from "openclaw/plugin-sdk/channel-policy";', + 'export { resolveToolsBySender } from "openclaw/plugin-sdk/channel-policy";', 'export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message";', 'export { PAIRING_APPROVED_MESSAGE, buildProbeChannelStatusSummary, createDefaultChannelRuntimeState } from "openclaw/plugin-sdk/channel-status";', 'export { buildChannelKeyCandidates, normalizeChannelSlug, resolveChannelEntryMatchWithFallback, resolveNestedAllowlistDecision } from "openclaw/plugin-sdk/channel-targets";', @@ -131,7 +130,6 @@ const RUNTIME_API_EXPORT_GUARDS: Record = { 'export type { ChannelGroupContext } from "openclaw/plugin-sdk/channel-contract";', 'export { logInboundDrop } from "openclaw/plugin-sdk/channel-logging";', 'export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";', - 'export { readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithCommandGate } from "openclaw/plugin-sdk/channel-policy";', 'export type { BlockStreamingCoalesceConfig, DmConfig, DmPolicy, GroupPolicy, GroupToolPolicyConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-types";', 'export { GROUP_POLICY_BLOCKED_LABEL, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce } from "openclaw/plugin-sdk/runtime-group-policy";', 'export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message";', diff --git a/src/plugins/runtime/runtime-channel.ts b/src/plugins/runtime/runtime-channel.ts index e77d9571078..cb401762265 100644 --- a/src/plugins/runtime/runtime-channel.ts +++ b/src/plugins/runtime/runtime-channel.ts @@ -174,6 +174,7 @@ export function createRuntimeChannel(): PluginRuntime["channel"] { }, turn: { run: runChannelTurn, + runAssembled: dispatchAssembledChannelTurn, runResolved: runResolvedChannelTurn, buildContext: buildChannelTurnContext, runPrepared: runPreparedChannelTurn, diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index dd1a9b4f439..822225533c8 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -153,11 +153,12 @@ export type PluginRuntimeChannel = { }; turn: { run: typeof import("../../channels/turn/kernel.js").runChannelTurn; + runAssembled: typeof import("../../channels/turn/kernel.js").dispatchAssembledChannelTurn; /** @deprecated Prefer `run(...)`. */ runResolved: typeof import("../../channels/turn/kernel.js").runResolvedChannelTurn; buildContext: typeof import("../../channels/turn/kernel.js").buildChannelTurnContext; runPrepared: typeof import("../../channels/turn/kernel.js").runPreparedChannelTurn; - /** @deprecated Prefer `run(...)` or `runPrepared(...)`. */ + /** @deprecated Prefer `runAssembled(...)`. */ dispatchAssembled: typeof import("../../channels/turn/kernel.js").dispatchAssembledChannelTurn; }; threadBindings: { diff --git a/src/security/audit-channel.ts b/src/security/audit-channel.ts index 4e585bce8fc..9c66a6f1a29 100644 --- a/src/security/audit-channel.ts +++ b/src/security/audit-channel.ts @@ -2,6 +2,7 @@ import { hasConfiguredUnavailableCredentialStatus, hasResolvedCredentialValue, } from "../channels/account-snapshot-fields.js"; +import { resolveDmAllowAuditState } from "../channels/message-access/dm-allow-state.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; import type { ChannelId } from "../channels/plugins/types.public.js"; @@ -11,7 +12,6 @@ import { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matchin import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; import type { SecurityAuditFinding, SecurityAuditSeverity } from "./audit.types.js"; -import { resolveDmAllowState } from "./dm-policy-shared.js"; function classifyChannelWarningSeverity(message: string): SecurityAuditSeverity { const s = message.toLowerCase(); @@ -206,7 +206,7 @@ export async function collectChannelSecurityFindings(params: { normalizeEntry?: (raw: string) => string; }) => { const policyPath = input.policyPath ?? `${input.allowFromPath}policy`; - const { hasWildcard, isMultiUserDm } = await resolveDmAllowState({ + const { hasWildcard, isMultiUserDm } = await resolveDmAllowAuditState({ provider: input.provider, accountId: input.accountId, allowFrom: input.allowFrom, diff --git a/src/security/dm-policy-shared.test.ts b/src/security/dm-policy-shared.test.ts deleted file mode 100644 index bbd6b322fe4..00000000000 --- a/src/security/dm-policy-shared.test.ts +++ /dev/null @@ -1,593 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - DM_GROUP_ACCESS_REASON, - readStoreAllowFromForDmPolicy, - resolveDmAllowState, - resolveDmGroupAccessWithCommandGate, - resolveDmGroupAccessDecision, - resolveDmGroupAccessWithLists, - resolveEffectiveAllowFromLists, - resolvePinnedMainDmOwnerFromAllowlist, -} from "./dm-policy-shared.js"; - -describe("security/dm-policy-shared", () => { - const controlCommand = { - useAccessGroups: true, - allowTextCommands: true, - hasControlCommand: true, - } as const; - - async function expectStoreReadSkipped(params: { - provider: string; - accountId: string; - dmPolicy?: "open" | "allowlist" | "pairing" | "disabled"; - shouldRead?: boolean; - }) { - let called = false; - const storeAllowFrom = await readStoreAllowFromForDmPolicy({ - provider: params.provider, - accountId: params.accountId, - ...(params.dmPolicy ? { dmPolicy: params.dmPolicy } : {}), - ...(params.shouldRead !== undefined ? { shouldRead: params.shouldRead } : {}), - readStore: async (_provider, _accountId) => { - called = true; - return ["should-not-be-read"]; - }, - }); - expect(called).toBe(false); - expect(storeAllowFrom).toStrictEqual([]); - } - - function resolveCommandGate(overrides: { - isGroup: boolean; - isSenderAllowed: (allowFrom: string[]) => boolean; - groupPolicy?: "open" | "allowlist" | "disabled"; - }) { - return resolveDmGroupAccessWithCommandGate({ - dmPolicy: "pairing", - groupPolicy: overrides.groupPolicy ?? "allowlist", - allowFrom: ["owner"], - groupAllowFrom: ["group-owner"], - storeAllowFrom: ["paired-user"], - command: controlCommand, - ...overrides, - }); - } - - it("normalizes config + store allow entries and counts distinct senders", async () => { - const state = await resolveDmAllowState({ - provider: "demo-channel-a" as never, - accountId: "default", - allowFrom: [" * ", " alice ", "ALICE", "bob"], - normalizeEntry: (value) => value.toLowerCase(), - readStore: async (_provider, _accountId) => [" Bob ", "carol", ""], - }); - expect(state.configAllowFrom).toEqual(["*", "alice", "ALICE", "bob"]); - expect(state.hasWildcard).toBe(true); - expect(state.allowCount).toBe(3); - expect(state.isMultiUserDm).toBe(true); - }); - - it("handles empty allowlists and store failures", async () => { - const state = await resolveDmAllowState({ - provider: "demo-channel-b" as never, - accountId: "default", - allowFrom: undefined, - readStore: async (_provider, _accountId) => { - throw new Error("offline"); - }, - }); - expect(state.configAllowFrom).toStrictEqual([]); - expect(state.hasWildcard).toBe(false); - expect(state.allowCount).toBe(0); - expect(state.isMultiUserDm).toBe(false); - }); - - it("does not count pairing-store senders for allowlist DM policy", async () => { - let called = false; - const state = await resolveDmAllowState({ - provider: "demo-channel-c" as never, - accountId: "default", - dmPolicy: "allowlist", - allowFrom: ["owner"], - readStore: async (_provider, _accountId) => { - called = true; - return ["paired-user"]; - }, - }); - - expect(called).toBe(false); - expect(state.allowCount).toBe(1); - expect(state.isMultiUserDm).toBe(false); - }); - - it.each([ - { - name: "dmPolicy is allowlist", - params: { - provider: "demo-channel-a", - accountId: "default", - dmPolicy: "allowlist" as const, - }, - }, - { - name: "dmPolicy is open", - params: { - provider: "demo-channel-open", - accountId: "default", - dmPolicy: "open" as const, - }, - }, - { - name: "shouldRead=false", - params: { - provider: "demo-channel-b", - accountId: "default", - shouldRead: false, - }, - }, - ] as const)("skips pairing-store reads when $name", async ({ params }) => { - await expectStoreReadSkipped(params); - }); - - it("builds effective DM/group allowlists from config + pairing store", () => { - const lists = resolveEffectiveAllowFromLists({ - allowFrom: [" owner ", "", "owner2"], - groupAllowFrom: ["group:abc"], - storeAllowFrom: [" owner3 ", ""], - }); - expect(lists.effectiveAllowFrom).toEqual(["owner", "owner2", "owner3"]); - expect(lists.effectiveGroupAllowFrom).toEqual(["group:abc"]); - }); - - it("falls back to DM allowlist for groups when groupAllowFrom is empty", () => { - const lists = resolveEffectiveAllowFromLists({ - allowFrom: [" owner "], - groupAllowFrom: [], - storeAllowFrom: [" owner2 "], - }); - expect(lists.effectiveAllowFrom).toEqual(["owner", "owner2"]); - expect(lists.effectiveGroupAllowFrom).toEqual(["owner"]); - }); - - it("can keep group allowlist empty when fallback is disabled", () => { - const lists = resolveEffectiveAllowFromLists({ - allowFrom: ["owner"], - groupAllowFrom: [], - storeAllowFrom: ["paired-user"], - groupAllowFromFallbackToAllowFrom: false, - }); - expect(lists.effectiveAllowFrom).toEqual(["owner", "paired-user"]); - expect(lists.effectiveGroupAllowFrom).toStrictEqual([]); - }); - - it("infers pinned main DM owner from a single configured allowlist entry", () => { - const pinnedOwner = resolvePinnedMainDmOwnerFromAllowlist({ - dmScope: "main", - allowFrom: [" line:user:U123 "], - normalizeEntry: (entry) => - entry - .trim() - .toLowerCase() - .replace(/^line:(?:user:)?/, ""), - }); - expect(pinnedOwner).toBe("u123"); - }); - - it.each([ - { - name: "wildcard allowlist", - dmScope: "main" as const, - allowFrom: ["*"], - }, - { - name: "multi-owner allowlist", - dmScope: "main" as const, - allowFrom: ["u123", "u456"], - }, - { - name: "non-main scope", - dmScope: "per-channel-peer" as const, - allowFrom: ["u123"], - }, - ] as const)("does not infer pinned owner for $name", ({ dmScope, allowFrom }) => { - expect( - resolvePinnedMainDmOwnerFromAllowlist({ - dmScope, - allowFrom: [...allowFrom], - normalizeEntry: (entry) => entry.trim(), - }), - ).toBeNull(); - }); - - it("excludes storeAllowFrom when dmPolicy is allowlist", () => { - const lists = resolveEffectiveAllowFromLists({ - allowFrom: ["+1111"], - groupAllowFrom: ["group:abc"], - storeAllowFrom: ["+2222", "+3333"], - dmPolicy: "allowlist", - }); - expect(lists.effectiveAllowFrom).toEqual(["+1111"]); - expect(lists.effectiveGroupAllowFrom).toEqual(["group:abc"]); - }); - - it("excludes pairing-store entries when dmPolicy is open", () => { - const lists = resolveEffectiveAllowFromLists({ - allowFrom: ["owner"], - groupAllowFrom: ["group:abc"], - storeAllowFrom: ["paired-user"], - dmPolicy: "open", - }); - expect(lists.effectiveAllowFrom).toEqual(["owner"]); - expect(lists.effectiveGroupAllowFrom).toEqual(["group:abc"]); - }); - - it("keeps group allowlist explicit when dmPolicy is pairing", () => { - const lists = resolveEffectiveAllowFromLists({ - allowFrom: ["+1111"], - groupAllowFrom: [], - storeAllowFrom: ["+2222"], - dmPolicy: "pairing", - }); - expect(lists.effectiveAllowFrom).toEqual(["+1111", "+2222"]); - expect(lists.effectiveGroupAllowFrom).toEqual(["+1111"]); - }); - - it("resolves access + effective allowlists in one shared call", () => { - const resolved = resolveDmGroupAccessWithLists({ - isGroup: false, - dmPolicy: "pairing", - groupPolicy: "allowlist", - allowFrom: ["owner"], - groupAllowFrom: ["group:room"], - storeAllowFrom: ["paired-user"], - isSenderAllowed: (allowFrom) => allowFrom.includes("paired-user"), - }); - expect(resolved.decision).toBe("allow"); - expect(resolved.reasonCode).toBe(DM_GROUP_ACCESS_REASON.DM_POLICY_ALLOWLISTED); - expect(resolved.reason).toBe("dmPolicy=pairing (allowlisted)"); - expect(resolved.effectiveAllowFrom).toEqual(["owner", "paired-user"]); - expect(resolved.effectiveGroupAllowFrom).toEqual(["group:room"]); - }); - - it("resolves command gate with dm/group parity for groups", () => { - const resolved = resolveCommandGate({ - isGroup: true, - isSenderAllowed: (allowFrom) => allowFrom.includes("paired-user"), - }); - expect(resolved.decision).toBe("block"); - expect(resolved.reason).toBe("groupPolicy=allowlist (not allowlisted)"); - expect(resolved.commandAuthorized).toBe(false); - expect(resolved.shouldBlockControlCommand).toBe(true); - }); - - it("keeps configured dm allowlist usable for group command auth", () => { - const resolved = resolveDmGroupAccessWithCommandGate({ - isGroup: true, - dmPolicy: "pairing", - groupPolicy: "open", - allowFrom: ["owner"], - groupAllowFrom: [], - storeAllowFrom: ["paired-user"], - isSenderAllowed: (allowFrom) => allowFrom.includes("owner"), - command: controlCommand, - }); - expect(resolved.commandAuthorized).toBe(true); - expect(resolved.shouldBlockControlCommand).toBe(false); - }); - - it("treats dm command authorization as dm access result", () => { - const resolved = resolveCommandGate({ - isGroup: false, - isSenderAllowed: (allowFrom) => allowFrom.includes("paired-user"), - }); - expect(resolved.decision).toBe("allow"); - expect(resolved.commandAuthorized).toBe(true); - expect(resolved.shouldBlockControlCommand).toBe(false); - }); - - it("does not auto-authorize dm commands in open mode without explicit allowlists", () => { - const resolved = resolveDmGroupAccessWithCommandGate({ - isGroup: false, - dmPolicy: "open", - groupPolicy: "allowlist", - allowFrom: [], - groupAllowFrom: [], - storeAllowFrom: [], - isSenderAllowed: () => false, - command: controlCommand, - }); - expect(resolved.decision).toBe("block"); - expect(resolved.reasonCode).toBe(DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED); - expect(resolved.reason).toBe("dmPolicy=open (not allowlisted)"); - expect(resolved.commandAuthorized).toBe(false); - expect(resolved.shouldBlockControlCommand).toBe(false); - }); - - it("allows open-mode DMs only for wildcard or matching allowlist entries", () => { - const publicAccess = resolveDmGroupAccessWithLists({ - isGroup: false, - dmPolicy: "open", - allowFrom: ["*"], - isSenderAllowed: () => true, - }); - expect(publicAccess.decision).toBe("allow"); - expect(publicAccess.reasonCode).toBe(DM_GROUP_ACCESS_REASON.DM_POLICY_OPEN); - - const constrainedAccess = resolveDmGroupAccessWithLists({ - isGroup: false, - dmPolicy: "open", - allowFrom: ["owner"], - isSenderAllowed: (allowFrom) => allowFrom.includes("owner"), - }); - expect(constrainedAccess.decision).toBe("allow"); - expect(constrainedAccess.reasonCode).toBe(DM_GROUP_ACCESS_REASON.DM_POLICY_ALLOWLISTED); - expect(constrainedAccess.reason).toBe("dmPolicy=open (allowlisted)"); - }); - - it("keeps allowlist mode strict in shared resolver (no pairing-store fallback)", () => { - const resolved = resolveDmGroupAccessWithLists({ - isGroup: false, - dmPolicy: "allowlist", - groupPolicy: "allowlist", - allowFrom: ["owner"], - groupAllowFrom: [], - storeAllowFrom: ["paired-user"], - isSenderAllowed: () => false, - }); - expect(resolved.decision).toBe("block"); - expect(resolved.reasonCode).toBe(DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED); - expect(resolved.reason).toBe("dmPolicy=allowlist (not allowlisted)"); - expect(resolved.effectiveAllowFrom).toEqual(["owner"]); - }); - - const channels = [ - "imessage", - "imessage", - "signal", - "telegram", - "whatsapp", - "msteams", - "matrix", - "zalo", - ] as const; - - type ParityCase = { - name: string; - isGroup: boolean; - dmPolicy: "open" | "allowlist" | "pairing" | "disabled"; - groupPolicy: "open" | "allowlist" | "disabled"; - allowFrom: string[]; - groupAllowFrom: string[]; - storeAllowFrom: string[]; - isSenderAllowed: (allowFrom: string[]) => boolean; - expectedDecision: "allow" | "block" | "pairing"; - expectedReactionAllowed: boolean; - }; - - type DecisionCase = { - name: string; - input: Parameters[0]; - expected: - | ReturnType - | Pick, "decision">; - }; - - function createParityCase({ - name, - ...overrides - }: Partial & Pick): ParityCase { - return { - name, - isGroup: false, - dmPolicy: "open", - groupPolicy: "allowlist", - allowFrom: [], - groupAllowFrom: [], - storeAllowFrom: [], - isSenderAllowed: () => false, - expectedDecision: "allow", - expectedReactionAllowed: true, - ...overrides, - }; - } - - function expectParityCase(channel: (typeof channels)[number], testCase: ParityCase) { - const access = resolveDmGroupAccessWithLists({ - isGroup: testCase.isGroup, - dmPolicy: testCase.dmPolicy, - groupPolicy: testCase.groupPolicy, - allowFrom: testCase.allowFrom, - groupAllowFrom: testCase.groupAllowFrom, - storeAllowFrom: testCase.storeAllowFrom, - isSenderAllowed: testCase.isSenderAllowed, - }); - const reactionAllowed = access.decision === "allow"; - expect(access.decision, `[${channel}] ${testCase.name}`).toBe(testCase.expectedDecision); - expect(reactionAllowed, `[${channel}] ${testCase.name} reaction`).toBe( - testCase.expectedReactionAllowed, - ); - } - - it.each( - channels.flatMap((channel) => - [ - createParityCase({ - name: "dmPolicy=open without wildcard", - dmPolicy: "open", - expectedDecision: "block", - expectedReactionAllowed: false, - }), - createParityCase({ - name: "dmPolicy=open with wildcard", - dmPolicy: "open", - allowFrom: ["*"], - isSenderAllowed: (allowFrom: string[]) => allowFrom.includes("*"), - expectedDecision: "allow", - expectedReactionAllowed: true, - }), - createParityCase({ - name: "dmPolicy=disabled", - dmPolicy: "disabled", - expectedDecision: "block", - expectedReactionAllowed: false, - }), - createParityCase({ - name: "dmPolicy=allowlist unauthorized", - dmPolicy: "allowlist", - allowFrom: ["owner"], - isSenderAllowed: () => false, - expectedDecision: "block", - expectedReactionAllowed: false, - }), - createParityCase({ - name: "dmPolicy=allowlist authorized", - dmPolicy: "allowlist", - allowFrom: ["owner"], - isSenderAllowed: () => true, - expectedDecision: "allow", - expectedReactionAllowed: true, - }), - createParityCase({ - name: "dmPolicy=pairing unauthorized", - dmPolicy: "pairing", - isSenderAllowed: () => false, - expectedDecision: "pairing", - expectedReactionAllowed: false, - }), - createParityCase({ - name: "groupPolicy=allowlist rejects DM-paired sender not in explicit group list", - isGroup: true, - dmPolicy: "pairing", - allowFrom: ["owner"], - groupAllowFrom: ["group-owner"], - storeAllowFrom: ["paired-user"], - isSenderAllowed: (allowFrom: string[]) => allowFrom.includes("paired-user"), - expectedDecision: "block", - expectedReactionAllowed: false, - }), - ].map((testCase) => ({ - channel, - testCase, - })), - ), - )( - "keeps message/reaction policy parity table across channels: [$channel] $testCase.name", - ({ channel, testCase }) => { - expectParityCase(channel, testCase); - }, - ); - - const decisionCases: DecisionCase[] = [ - { - name: "blocks groups when group allowlist is empty", - input: { - isGroup: true, - dmPolicy: "pairing", - groupPolicy: "allowlist", - effectiveAllowFrom: ["owner"], - effectiveGroupAllowFrom: [], - isSenderAllowed: () => false, - }, - expected: { - decision: "block", - reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST, - reason: "groupPolicy=allowlist (empty allowlist)", - }, - }, - { - name: "allows groups when group policy is open", - input: { - isGroup: true, - dmPolicy: "pairing", - groupPolicy: "open", - effectiveAllowFrom: ["owner"], - effectiveGroupAllowFrom: [], - isSenderAllowed: () => false, - }, - expected: { - decision: "allow", - reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_ALLOWED, - reason: "groupPolicy=open", - }, - }, - { - name: "blocks DM allowlist mode when allowlist is empty", - input: { - isGroup: false, - dmPolicy: "allowlist", - groupPolicy: "allowlist", - effectiveAllowFrom: [], - effectiveGroupAllowFrom: [], - isSenderAllowed: () => false, - }, - expected: { - decision: "block", - reasonCode: DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED, - reason: "dmPolicy=allowlist (not allowlisted)", - }, - }, - { - name: "uses pairing flow when DM sender is not allowlisted", - input: { - isGroup: false, - dmPolicy: "pairing", - groupPolicy: "allowlist", - effectiveAllowFrom: [], - effectiveGroupAllowFrom: [], - isSenderAllowed: () => false, - }, - expected: { - decision: "pairing", - reasonCode: DM_GROUP_ACCESS_REASON.DM_POLICY_PAIRING_REQUIRED, - reason: "dmPolicy=pairing (not allowlisted)", - }, - }, - { - name: "allows DM sender when allowlisted", - input: { - isGroup: false, - dmPolicy: "allowlist", - groupPolicy: "allowlist", - effectiveAllowFrom: ["owner"], - effectiveGroupAllowFrom: [], - isSenderAllowed: () => true, - }, - expected: { - decision: "allow", - }, - }, - { - name: "blocks group allowlist mode when sender/group is not allowlisted", - input: { - isGroup: true, - dmPolicy: "pairing", - groupPolicy: "allowlist", - effectiveAllowFrom: ["owner"], - effectiveGroupAllowFrom: ["group:abc"], - isSenderAllowed: () => false, - }, - expected: { - decision: "block", - reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED, - reason: "groupPolicy=allowlist (not allowlisted)", - }, - }, - ]; - - it.each( - channels.flatMap((channel) => - decisionCases.map((testCase) => ({ - channel, - testCase, - })), - ), - )("[$channel] $testCase.name", ({ testCase }) => { - const decision = resolveDmGroupAccessDecision(testCase.input); - if ("reasonCode" in testCase.expected && "reason" in testCase.expected) { - expect(decision).toEqual(testCase.expected); - return; - } - expect(decision).toMatchObject(testCase.expected); - }); -}); diff --git a/src/security/dm-policy-shared.ts b/src/security/dm-policy-shared.ts index 9fc9f40e5e7..cd2c27d3ba7 100644 --- a/src/security/dm-policy-shared.ts +++ b/src/security/dm-policy-shared.ts @@ -1,9 +1,13 @@ -import { evaluateMatchedGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access"; -import { mergeDmAllowFromSources, resolveGroupAllowFromSources } from "../channels/allow-from.js"; +import { resolveGroupAllowFromSources } from "../channels/allow-from.js"; import { resolveControlCommandGate } from "../channels/command-gating.js"; +import { resolveDmAllowAuditState } from "../channels/message-access/dm-allow-state.js"; +import { + readChannelIngressStoreAllowFromForDmPolicy, + resolveChannelIngressEffectiveAllowFromLists, +} from "../channels/message-access/runtime.js"; import type { ChannelId } from "../channels/plugins/types.public.js"; import type { GroupPolicy } from "../config/types.base.js"; -import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; +import { evaluateMatchedGroupAccessForPolicy } from "../plugin-sdk/group-access.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; export function resolvePinnedMainDmOwnerFromAllowlist(params: { @@ -28,6 +32,7 @@ export function resolvePinnedMainDmOwnerFromAllowlist(params: { return normalizedOwners.length === 1 ? normalizedOwners[0] : null; } +/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ export function resolveEffectiveAllowFromLists(params: { allowFrom?: Array | null; groupAllowFrom?: Array | null; @@ -38,25 +43,7 @@ export function resolveEffectiveAllowFromLists(params: { effectiveAllowFrom: string[]; effectiveGroupAllowFrom: string[]; } { - const allowFrom = Array.isArray(params.allowFrom) ? params.allowFrom : undefined; - const groupAllowFrom = Array.isArray(params.groupAllowFrom) ? params.groupAllowFrom : undefined; - const storeAllowFrom = Array.isArray(params.storeAllowFrom) ? params.storeAllowFrom : undefined; - const effectiveAllowFrom = normalizeStringEntries( - mergeDmAllowFromSources({ - allowFrom, - storeAllowFrom, - dmPolicy: params.dmPolicy ?? undefined, - }), - ); - // Group auth is explicit (groupAllowFrom fallback allowFrom). Pairing store is DM-only. - const effectiveGroupAllowFrom = normalizeStringEntries( - resolveGroupAllowFromSources({ - allowFrom, - groupAllowFrom, - fallbackToAllowFrom: params.groupAllowFromFallbackToAllowFrom ?? undefined, - }), - ); - return { effectiveAllowFrom, effectiveGroupAllowFrom }; + return resolveChannelIngressEffectiveAllowFromLists(params); } export type DmGroupAccessDecision = "allow" | "block" | "pairing"; @@ -73,35 +60,37 @@ export const DM_GROUP_ACCESS_REASON = { } as const; export type DmGroupAccessReasonCode = (typeof DM_GROUP_ACCESS_REASON)[keyof typeof DM_GROUP_ACCESS_REASON]; +type DmGroupAccessResult = { + decision: DmGroupAccessDecision; + reasonCode: DmGroupAccessReasonCode; + reason: string; +}; +const dmGroupAccess = ( + decision: DmGroupAccessDecision, + reasonCode: DmGroupAccessReasonCode, + reason: string, +): DmGroupAccessResult => ({ decision, reasonCode, reason }); + +/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ export function resolveOpenDmAllowlistAccess(params: { effectiveAllowFrom: Array; isSenderAllowed: (allowFrom: string[]) => boolean; -}): { - decision: Extract; - reasonCode: DmGroupAccessReasonCode; - reason: string; -} { +}): DmGroupAccessResult { const effectiveAllowFrom = normalizeStringEntries(params.effectiveAllowFrom); - if (effectiveAllowFrom.includes("*")) { - return { - decision: "allow", - reasonCode: DM_GROUP_ACCESS_REASON.DM_POLICY_OPEN, - reason: "dmPolicy=open", - }; - } - if (params.isSenderAllowed(effectiveAllowFrom)) { - return { - decision: "allow", - reasonCode: DM_GROUP_ACCESS_REASON.DM_POLICY_ALLOWLISTED, - reason: "dmPolicy=open (allowlisted)", - }; - } - return { - decision: "block", - reasonCode: DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED, - reason: "dmPolicy=open (not allowlisted)", - }; + return effectiveAllowFrom.includes("*") + ? dmGroupAccess("allow", DM_GROUP_ACCESS_REASON.DM_POLICY_OPEN, "dmPolicy=open") + : params.isSenderAllowed(effectiveAllowFrom) + ? dmGroupAccess( + "allow", + DM_GROUP_ACCESS_REASON.DM_POLICY_ALLOWLISTED, + "dmPolicy=open (allowlisted)", + ) + : dmGroupAccess( + "block", + DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED, + "dmPolicy=open (not allowlisted)", + ); } type DmGroupAccessInputParams = { @@ -115,6 +104,33 @@ type DmGroupAccessInputParams = { isSenderAllowed: (allowFrom: string[]) => boolean; }; +const GROUP_ACCESS_RESULT: Record< + Exclude["reason"], "allowed">, + DmGroupAccessResult +> = { + disabled: dmGroupAccess( + "block", + DM_GROUP_ACCESS_REASON.GROUP_POLICY_DISABLED, + "groupPolicy=disabled", + ), + empty_allowlist: dmGroupAccess( + "block", + DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST, + "groupPolicy=allowlist (empty allowlist)", + ), + missing_match_input: dmGroupAccess( + "block", + DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED, + "groupPolicy=allowlist (not allowlisted)", + ), + not_allowlisted: dmGroupAccess( + "block", + DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED, + "groupPolicy=allowlist (not allowlisted)", + ), +}; + +/** @deprecated Use `resolveChannelMessageIngress` or `readChannelIngressStoreAllowFromForDmPolicy` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ export async function readStoreAllowFromForDmPolicy(params: { provider: ChannelId; accountId: string; @@ -122,20 +138,10 @@ export async function readStoreAllowFromForDmPolicy(params: { shouldRead?: boolean | null; readStore?: (provider: ChannelId, accountId: string) => Promise; }): Promise { - if ( - params.shouldRead === false || - params.dmPolicy === "allowlist" || - params.dmPolicy === "open" - ) { - return []; - } - const readStore = - params.readStore ?? - ((provider: ChannelId, accountId: string) => - readChannelAllowFromStore(provider, process.env, accountId)); - return await readStore(params.provider, params.accountId).catch(() => []); + return await readChannelIngressStoreAllowFromForDmPolicy(params); } +/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ export function resolveDmGroupAccessDecision(params: { isGroup: boolean; dmPolicy?: string | null; @@ -143,11 +149,7 @@ export function resolveDmGroupAccessDecision(params: { effectiveAllowFrom: Array; effectiveGroupAllowFrom: Array; isSenderAllowed: (allowFrom: string[]) => boolean; -}): { - decision: DmGroupAccessDecision; - reasonCode: DmGroupAccessReasonCode; - reason: string; -} { +}): DmGroupAccessResult { const dmPolicy = params.dmPolicy ?? "pairing"; const groupPolicy: GroupPolicy = params.groupPolicy === "open" || params.groupPolicy === "disabled" @@ -162,44 +164,30 @@ export function resolveDmGroupAccessDecision(params: { allowlistConfigured: effectiveGroupAllowFrom.length > 0, allowlistMatched: params.isSenderAllowed(effectiveGroupAllowFrom), }); - - if (!groupAccess.allowed) { - if (groupAccess.reason === "disabled") { - return { - decision: "block", - reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_DISABLED, - reason: "groupPolicy=disabled", - }; - } - if (groupAccess.reason === "empty_allowlist") { - return { - decision: "block", - reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST, - reason: "groupPolicy=allowlist (empty allowlist)", - }; - } - if (groupAccess.reason === "not_allowlisted") { - return { - decision: "block", - reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED, - reason: "groupPolicy=allowlist (not allowlisted)", - }; - } + if (groupAccess.allowed) { + return dmGroupAccess( + "allow", + DM_GROUP_ACCESS_REASON.GROUP_POLICY_ALLOWED, + `groupPolicy=${groupPolicy}`, + ); + } + switch (groupAccess.reason) { + case "disabled": + case "empty_allowlist": + case "missing_match_input": + case "not_allowlisted": + return GROUP_ACCESS_RESULT[groupAccess.reason]; + case "allowed": + return dmGroupAccess( + "allow", + DM_GROUP_ACCESS_REASON.GROUP_POLICY_ALLOWED, + `groupPolicy=${groupPolicy}`, + ); } - - return { - decision: "allow", - reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_ALLOWED, - reason: `groupPolicy=${groupPolicy}`, - }; } if (dmPolicy === "disabled") { - return { - decision: "block", - reasonCode: DM_GROUP_ACCESS_REASON.DM_POLICY_DISABLED, - reason: "dmPolicy=disabled", - }; + return dmGroupAccess("block", DM_GROUP_ACCESS_REASON.DM_POLICY_DISABLED, "dmPolicy=disabled"); } if (dmPolicy === "open") { return resolveOpenDmAllowlistAccess({ @@ -207,27 +195,26 @@ export function resolveDmGroupAccessDecision(params: { isSenderAllowed: params.isSenderAllowed, }); } - if (params.isSenderAllowed(effectiveAllowFrom)) { - return { - decision: "allow", - reasonCode: DM_GROUP_ACCESS_REASON.DM_POLICY_ALLOWLISTED, - reason: `dmPolicy=${dmPolicy} (allowlisted)`, - }; - } - if (dmPolicy === "pairing") { - return { - decision: "pairing", - reasonCode: DM_GROUP_ACCESS_REASON.DM_POLICY_PAIRING_REQUIRED, - reason: "dmPolicy=pairing (not allowlisted)", - }; - } - return { - decision: "block", - reasonCode: DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED, - reason: `dmPolicy=${dmPolicy} (not allowlisted)`, - }; + return params.isSenderAllowed(effectiveAllowFrom) + ? dmGroupAccess( + "allow", + DM_GROUP_ACCESS_REASON.DM_POLICY_ALLOWLISTED, + `dmPolicy=${dmPolicy} (allowlisted)`, + ) + : dmPolicy === "pairing" + ? dmGroupAccess( + "pairing", + DM_GROUP_ACCESS_REASON.DM_POLICY_PAIRING_REQUIRED, + "dmPolicy=pairing (not allowlisted)", + ) + : dmGroupAccess( + "block", + DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED, + `dmPolicy=${dmPolicy} (not allowlisted)`, + ); } +/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ export function resolveDmGroupAccessWithLists(params: DmGroupAccessInputParams): { decision: DmGroupAccessDecision; reasonCode: DmGroupAccessReasonCode; @@ -257,6 +244,7 @@ export function resolveDmGroupAccessWithLists(params: DmGroupAccessInputParams): }; } +/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ export function resolveDmGroupAccessWithCommandGate( params: DmGroupAccessInputParams & { command?: { @@ -298,19 +286,17 @@ export function resolveDmGroupAccessWithCommandGate( const commandGroupAllowFrom = params.isGroup ? configuredGroupAllowFrom : access.effectiveGroupAllowFrom; - const ownerAllowedForCommands = params.isSenderAllowed(commandDmAllowFrom); - const groupAllowedForCommands = params.isSenderAllowed(commandGroupAllowFrom); const commandGate = params.command ? resolveControlCommandGate({ useAccessGroups: params.command.useAccessGroups, authorizers: [ { configured: commandDmAllowFrom.length > 0, - allowed: ownerAllowedForCommands, + allowed: params.isSenderAllowed(commandDmAllowFrom), }, { configured: commandGroupAllowFrom.length > 0, - allowed: groupAllowedForCommands, + allowed: params.isSenderAllowed(commandGroupAllowFrom), }, ], allowTextCommands: params.command.allowTextCommands, @@ -325,6 +311,7 @@ export function resolveDmGroupAccessWithCommandGate( }; } +/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ export async function resolveDmAllowState(params: { provider: ChannelId; accountId: string; @@ -338,31 +325,5 @@ export async function resolveDmAllowState(params: { allowCount: number; isMultiUserDm: boolean; }> { - const configAllowFrom = normalizeStringEntries( - Array.isArray(params.allowFrom) ? params.allowFrom : undefined, - ); - const hasWildcard = configAllowFrom.includes("*"); - const storeAllowFrom = await readStoreAllowFromForDmPolicy({ - provider: params.provider, - accountId: params.accountId, - dmPolicy: params.dmPolicy, - readStore: params.readStore, - }); - const normalizeEntry = params.normalizeEntry ?? ((value: string) => value); - const normalizedCfg = configAllowFrom - .filter((value) => value !== "*") - .map((value) => normalizeEntry(value)) - .map((value) => value.trim()) - .filter(Boolean); - const normalizedStore = storeAllowFrom - .map((value) => normalizeEntry(value)) - .map((value) => value.trim()) - .filter(Boolean); - const allowCount = new Set([...normalizedCfg, ...normalizedStore]).size; - return { - configAllowFrom, - hasWildcard, - allowCount, - isMultiUserDm: hasWildcard || allowCount > 1, - }; + return await resolveDmAllowAuditState(params); } diff --git a/test/vitest/vitest.unit-fast-paths.mjs b/test/vitest/vitest.unit-fast-paths.mjs index 871d9a088da..973cd4d72ae 100644 --- a/test/vitest/vitest.unit-fast-paths.mjs +++ b/test/vitest/vitest.unit-fast-paths.mjs @@ -162,7 +162,7 @@ export const forcedUnitFastTestFiles = [ "src/security/audit-summary.test.ts", "src/security/audit-synced-folder.test.ts", "src/security/audit-trust-model.test.ts", - "src/security/dm-policy-shared.test.ts", + "src/channels/message-access/message-access.test.ts", "src/security/audit-plugins-trust.test.ts", "src/security/audit-plugin-readonly-scope.test.ts", "src/security/audit-loopback-logging.test.ts", diff --git a/ui/src/ui/app-tool-stream.node.test.ts b/ui/src/ui/app-tool-stream.node.test.ts index a38a0afd453..cac317b7e1a 100644 --- a/ui/src/ui/app-tool-stream.node.test.ts +++ b/ui/src/ui/app-tool-stream.node.test.ts @@ -83,6 +83,10 @@ function useToolStreamFakeTimers(): void { } describe("app-tool-stream fallback lifecycle handling", () => { + beforeEach(() => { + vi.useRealTimers(); + }); + beforeAll(() => { const globalWithWindow = globalThis as typeof globalThis & { window?: Window & typeof globalThis; diff --git a/ui/src/ui/gateway.node.test.ts b/ui/src/ui/gateway.node.test.ts index e052e7f48b6..053bcab9455 100644 --- a/ui/src/ui/gateway.node.test.ts +++ b/ui/src/ui/gateway.node.test.ts @@ -161,6 +161,12 @@ function stubInsecureCrypto() { }); } +function useNodeFakeTimers() { + vi.useFakeTimers({ + toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"], + }); +} + function parseLatestConnectFrame(ws: MockWebSocket): ConnectFrame { return JSON.parse(ws.sent.at(-1) ?? "{}") as ConnectFrame; } @@ -461,7 +467,7 @@ describe("GatewayBrowserClient", () => { }); it("retries once with device token after token mismatch when shared token is explicit", async () => { - vi.useFakeTimers(); + useNodeFakeTimers(); const { secondWs, secondConnect } = await expectRetriedDeviceTokenConnect({ url: "ws://127.0.0.1:18789", token: "shared-auth-token", @@ -489,7 +495,7 @@ describe("GatewayBrowserClient", () => { }); it("retries startup-unavailable connect responses without terminal callbacks", async () => { - vi.useFakeTimers(); + useNodeFakeTimers(); const onClose = vi.fn(); const client = new GatewayBrowserClient({ url: "ws://127.0.0.1:18789", @@ -530,7 +536,7 @@ describe("GatewayBrowserClient", () => { }); it("treats IPv6 loopback as trusted for bounded device-token retry", async () => { - vi.useFakeTimers(); + useNodeFakeTimers(); const { client } = await expectRetriedDeviceTokenConnect({ url: "ws://[::1]:18789", token: "shared-auth-token", @@ -541,7 +547,7 @@ describe("GatewayBrowserClient", () => { }); it("continues reconnecting on first token mismatch when no retry was attempted", async () => { - vi.useFakeTimers(); + useNodeFakeTimers(); localStorage.clear(); const client = new GatewayBrowserClient({ @@ -572,7 +578,7 @@ describe("GatewayBrowserClient", () => { }); it("cancels a queued connect send when stopped before the timeout fires", async () => { - vi.useFakeTimers(); + useNodeFakeTimers(); const client = new GatewayBrowserClient({ url: "ws://127.0.0.1:18789", @@ -637,7 +643,7 @@ describe("GatewayBrowserClient", () => { }); it("cancels a scheduled reconnect when stopped before the retry fires", async () => { - vi.useFakeTimers(); + useNodeFakeTimers(); const client = new GatewayBrowserClient({ url: "ws://127.0.0.1:18789", @@ -657,7 +663,7 @@ describe("GatewayBrowserClient", () => { }); it("does not auto-reconnect on AUTH_TOKEN_MISSING", async () => { - vi.useFakeTimers(); + useNodeFakeTimers(); localStorage.clear(); const client = new GatewayBrowserClient({ @@ -686,7 +692,7 @@ describe("GatewayBrowserClient", () => { }); it("clears stale stored device tokens and does not reconnect on AUTH_DEVICE_TOKEN_MISMATCH", async () => { - vi.useFakeTimers(); + useNodeFakeTimers(); const client = new GatewayBrowserClient({ url: "ws://127.0.0.1:18789",