refactor: centralize channel ingress access

This commit is contained in:
Peter Steinberger
2026-05-10 05:06:03 +01:00
parent 1725eebe62
commit a0fb7fb045
250 changed files with 11410 additions and 8161 deletions

View File

@@ -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

View File

@@ -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 模式"

View File

@@ -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

View File

@@ -451,8 +451,8 @@ Example:
</Tab>
<Tab title="DM access groups">
Discord DMs can use dynamic `accessGroup:<name>` entries in `channels.discord.allowFrom`.
<Tab title="Access groups">
Discord DMs and text command authorization can use dynamic `accessGroup:<name>` 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).

View File

@@ -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:<name>`), or chat targets (`chat_id:*`, `chat_guid:*`, `chat_identifier:*`).
</Tab>
@@ -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:<name>`).
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).

View File

@@ -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.<groupId>.allowFrom`
- Static sender access groups can be referenced from `allowFrom`, `groupAllowFrom`, and per-group `allowFrom` with `accessGroup:<name>`.
- 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:

View File

@@ -189,11 +189,13 @@ Notes:
- `openclaw pairing list mattermost`
- `openclaw pairing approve mattermost <CODE>`
- Public DMs: `channels.mattermost.dmPolicy="open"` plus `channels.mattermost.allowFrom=["*"]`.
- `channels.mattermost.allowFrom` accepts `accessGroup:<name>` 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:<name>` entries. See [Access groups](/channels/access-groups).
- Per-channel mention overrides live under `channels.mattermost.groups.<channelId>.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).

View File

@@ -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"],
},
},
}

View File

@@ -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:<name>`.
- `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).

View File

@@ -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.<room>.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.

View File

@@ -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:<name>`). 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:<name>`)
- 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.

View File

@@ -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:<name>` 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
```

View File

@@ -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

View File

@@ -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`.

View File

@@ -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. |

9
docs/refactor/access.md Normal file
View File

@@ -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).

View File

@@ -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/<changed-plugin>/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

View File

@@ -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<string[]> {
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,
}),
});
}

View File

@@ -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;
}

View File

@@ -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<typeof resolveDiscordChannelConfigWithFallback>;

View File

@@ -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";

View File

@@ -36,9 +36,6 @@ import { buildDirectLabel, buildGuildLabel } from "./reply-context.js";
import { deliverDiscordReply } from "./reply-delivery.js";
let conversationRuntimePromise: Promise<typeof import("./agent-components.runtime.js")> | undefined;
let replyPipelineRuntimePromise:
| Promise<typeof import("openclaw/plugin-sdk/channel-message")>
| undefined;
let typingRuntimePromise: Promise<typeof import("./typing.js")> | 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)}`);
}
},
},
}),
},
});

View File

@@ -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: {

View File

@@ -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<ReturnType<typeof resolveDiscordDmCommandAccess>>;
vi.mock("../send.permissions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../send.permissions.js")>();
@@ -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);
});
});

View File

@@ -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<typeof resolveDiscordAllowListMatch> | { 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<string[]>;
}): Promise<DiscordDmCommandAccess> {
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;
}

View File

@@ -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>): DiscordDmCommandAccess {
function buildSenderAccess(
overrides: Pick<Partial<ResolvedChannelMessageIngress["senderAccess"]>, "decision">,
): Pick<ResolvedChannelMessageIngress["senderAccess"], "decision"> {
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,

View File

@@ -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<ResolvedChannelMessageIngress["senderAccess"], "decision">;
accountId: string;
sender: {
id: string;
@@ -14,11 +14,11 @@ export async function handleDiscordDmCommandDecision(params: {
onUnauthorized: () => Promise<void>;
upsertPairingRequest?: typeof upsertChannelPairingRequest;
}): Promise<boolean> {
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",

View File

@@ -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<DiscordReactionIngressAuthorizationParams, "channelConfig"> = {
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: {

View File

@@ -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})`,
);
},
});

View File

@@ -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<typeof import("./dm-command-auth.js")>()),
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);

View File

@@ -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;
}

View File

@@ -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) => {

View File

@@ -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();
});

View File

@@ -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,

View File

@@ -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<typeof resolveDiscordNativeCommandAllowlistAccess>;
@@ -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,

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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";

View File

@@ -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,

View File

@@ -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";

View File

@@ -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,

View File

@@ -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";

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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),
};

View File

@@ -15,6 +15,7 @@ import {
export async function authorizeDiscordVoiceIngress(params: {
cfg: OpenClawConfig;
discordConfig: DiscordAccountConfig;
accountId?: string;
groupPolicy?: "open" | "disabled" | "allowlist";
useAccessGroups?: boolean;
guild?: Guild<true> | 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." };
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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",

View File

@@ -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<string | number>;
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<string | number>;
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<boolean> {
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<FeishuMessageInfo, "senderId" | "senderType">,
>(
messages: readonly T[],
params: {
cfg: ClawdbotConfig;
accountId: string;
chatId: string;
isGroup: boolean;
allowFrom: Array<string | number>;
mode: "all" | "allowlist" | "allowlist_quote";
kind: "quote" | "thread" | "history";
},
): T[] {
return filterSupplementalContextItems({
items: messages,
mode: params.mode,
kind: params.kind,
isSenderAllowed: (message) =>
isFetchedGroupContextSenderAllowed({
): Promise<T[]> {
const results: Array<T | undefined> = 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,

View File

@@ -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,

View File

@@ -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<string | number>;
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");
});
});

View File

@@ -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<FeishuGroupPolicy, "allowall">;
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<string | number>;
senderId: string;
senderIds?: Array<string | null | undefined>;
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<string | null | undefined>;
}): 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<string | number> | null;
readAllowFromStore?: () => Promise<Array<string | number>>;
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<string | number> | 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<string | number> | 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<string | number>;
senderId: string;
senderIds?: Array<string | null | undefined>;
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;

View File

@@ -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";

View File

@@ -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/<email> 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,
}),

View File

@@ -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}`,

View File

@@ -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:<id>` but treat `users/...` as an *ID* only (deprecated `users/<email>`).
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;
});
}

View File

@@ -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/<email> 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();

View File

@@ -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<typeof resolveIMessageInboundDecision>[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: {

View File

@@ -52,8 +52,8 @@ function buildDecisionParams(overrides: Partial<DecisionParams> = {}): 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." },

View File

@@ -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 === "<media:image>" && 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"],

View File

@@ -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.<chat_id>.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<IMessageInboundDecision> {
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,
};
}

View File

@@ -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<string | undefined> {
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<string | un
}
}
/** One-shot warning when typing/read are gated off due to old imsg build. */
const warnIfImsgUpgradeNeeded = (() => {
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,

View File

@@ -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";

View File

@@ -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: {},
}),
);
});
});

View File

@@ -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([]);
});
});

View File

@@ -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<string | number> | 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<void>;
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,
};

View File

@@ -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 },

View File

@@ -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<string, IrcChannelConfig>;
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";
}

View File

@@ -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 {

View File

@@ -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";

View File

@@ -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<string | number>): 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<string | number>): NormalizedAll
};
};
export const normalizeDmAllowFromWithStore = (params: {
allowFrom?: Array<string | number>;
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 };

View File

@@ -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<string, HistoryEntry[]>;
replayCache?: ReturnType<typeof createLineWebhookReplayCache>;
accessGroups?: Record<string, { type: "message.senders"; members: Record<string, string[]> }>;
}): Parameters<typeof handleLineWebhookEvents>[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 () => {

View File

@@ -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<void> {
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;

View File

@@ -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");
});
});

View File

@@ -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<string | number>;
storeAllowFrom: Array<string | number>;
dmPolicy?: "open" | "pairing" | "allowlist" | "disabled";
groupPolicy?: "open" | "allowlist" | "disabled";
groupAllowFrom: Array<string | number>;
roomUsers: Array<string | number>;
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<MatrixMonitorAccessState> {
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;
}

View File

@@ -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,
};

View File

@@ -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(

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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,

View File

@@ -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 | number>): string[] {
@@ -33,23 +57,6 @@ export function normalizeMattermostAllowList(entries: Array<string | number>): s
return Array.from(new Set(normalized));
}
export function resolveMattermostEffectiveAllowFromLists(params: {
allowFrom?: Array<string | number> | null;
groupAllowFrom?: Array<string | number> | null;
storeAllowFrom?: Array<string | number> | 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<string | number> | null;
readStoreAllowFrom?: () => Promise<Array<string | number>>;
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<string | number> | null;
readStoreAllowFrom?: () => Promise<Array<string | number>>;
allowTextCommands: boolean;
hasControlCommand: boolean;
}): MattermostCommandAuthDecision {
}): Promise<MattermostCommandAuthDecision> {
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,

View File

@@ -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");
});
});

View File

@@ -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(),

View File

@@ -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<string, HistoryEntry[]>();
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;

View File

@@ -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 {

View File

@@ -480,7 +480,7 @@ async function authorizeSlashInvocation(params: {
})
.catch(() => []),
);
const decision = authorizeMattermostCommandInvocation({
const decision = await authorizeMattermostCommandInvocation({
account,
cfg,
senderId,

View File

@@ -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,

View File

@@ -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,

View File

@@ -14,6 +14,7 @@ type MSTeamsTestRuntimeOptions = {
upsertPairingRequest?: ReturnType<typeof vi.fn>;
recordInboundSession?: ReturnType<typeof vi.fn>;
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

View File

@@ -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,

View File

@@ -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,
};

View File

@@ -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({

View File

@@ -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<typeof vi.fn>;
recordInboundSession?: ReturnType<typeof vi.fn>;
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",
});

View File

@@ -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",

View File

@@ -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<typeof vi.fn>;
await invokeReactionEvent(
handler,
{
reactionsAdded: [{ type: "like" }],
from: { id: "good-user", aadObjectId: "allowed-aad", name: "Alice" },
replyToId: "msg-7",
},
"added",
);
expect(enqueue).toHaveBeenCalledOnce();
});
});
});

View File

@@ -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<string, string> = {
@@ -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

View File

@@ -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);
});
});
});

View File

@@ -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<string | number>;
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;
}

Some files were not shown because too many files have changed in this diff Show More