fix(security): make allowFrom id-only by default with dangerous name opt-in (#24907)

* fix(channels): default allowFrom to id-only; add dangerous name opt-in

* docs(security): align channel allowFrom docs with id-only default
This commit is contained in:
Peter Steinberger
2026-02-24 01:01:51 +00:00
committed by GitHub
parent 41b0568b35
commit cfa44ea6b4
53 changed files with 852 additions and 100 deletions

View File

@@ -95,6 +95,7 @@ OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boun
- Deployments where mutually untrusted/adversarial operators share one gateway host and config (for example, reports expecting per-operator isolation for `sessions.list`, `sessions.preview`, `chat.history`, or similar control-plane reads) - Deployments where mutually untrusted/adversarial operators share one gateway host and config (for example, reports expecting per-operator isolation for `sessions.list`, `sessions.preview`, `chat.history`, or similar control-plane reads)
- Prompt-injection-only attacks (without a policy/auth/sandbox boundary bypass) - Prompt-injection-only attacks (without a policy/auth/sandbox boundary bypass)
- Reports that require write access to trusted local state (`~/.openclaw`, workspace files like `MEMORY.md` / `memory/*.md`) - Reports that require write access to trusted local state (`~/.openclaw`, workspace files like `MEMORY.md` / `memory/*.md`)
- Any report whose only claim is that an operator-enabled `dangerous*`/`dangerously*` config option weakens defaults (these are explicit break-glass tradeoffs by design)
- Reports that depend on trusted operator-supplied configuration values to trigger availability impact (for example custom regex patterns). These may still be fixed as defense-in-depth hardening, but are not security-boundary bypasses. - Reports that depend on trusted operator-supplied configuration values to trigger availability impact (for example custom regex patterns). These may still be fixed as defense-in-depth hardening, but are not security-boundary bypasses.
- Exposed secrets that are third-party/user-controlled credentials (not OpenClaw-owned and not granting access to OpenClaw-operated infrastructure/services) without demonstrated OpenClaw impact - Exposed secrets that are third-party/user-controlled credentials (not OpenClaw-owned and not granting access to OpenClaw-operated infrastructure/services) without demonstrated OpenClaw impact

View File

@@ -397,7 +397,8 @@ Example:
`allowlist` behavior: `allowlist` behavior:
- guild must match `channels.discord.guilds` (`id` preferred, slug accepted) - guild must match `channels.discord.guilds` (`id` preferred, slug accepted)
- optional sender allowlists: `users` (IDs or names) and `roles` (role IDs only); if either is configured, senders are allowed when they match `users` OR `roles` - optional sender allowlists: `users` (stable IDs recommended) and `roles` (role IDs only); if either is configured, senders are allowed when they match `users` OR `roles`
- direct name/tag matching is disabled by default; enable `channels.discord.dangerouslyAllowNameMatching: true` only as break-glass compatibility mode
- names/tags are supported for `users`, but IDs are safer; `openclaw security audit` warns when name/tag entries are used - names/tags are supported for `users`, but IDs are safer; `openclaw security audit` warns when name/tag entries are used
- if a guild has `channels` configured, non-listed channels are denied - if a guild has `channels` configured, non-listed channels are denied
- if a guild has no `channels` block, all channels in that allowlisted guild are allowed - if a guild has no `channels` block, all channels in that allowlisted guild are allowed
@@ -768,7 +769,7 @@ Default slash command settings:
Notes: Notes:
- allowlists can use `pk:<memberId>` - allowlists can use `pk:<memberId>`
- member display names are matched by name/slug - member display names are matched by name/slug only when `channels.discord.dangerouslyAllowNameMatching: true`
- lookups use original message ID and are time-window constrained - lookups use original message ID and are time-window constrained
- if lookup fails, proxied messages are treated as bot messages and dropped unless `allowBots=true` - if lookup fails, proxied messages are treated as bot messages and dropped unless `allowBots=true`

View File

@@ -153,7 +153,8 @@ Configure your tunnel's ingress rules to only route the webhook path:
Use these identifiers for delivery and allowlists: Use these identifiers for delivery and allowlists:
- Direct messages: `users/<userId>` (recommended) or raw email `name@example.com` (mutable principal). - Direct messages: `users/<userId>` (recommended).
- Raw email `name@example.com` is mutable and only used for direct allowlist matching when `channels.googlechat.dangerouslyAllowNameMatching: true`.
- Deprecated: `users/<email>` is treated as a user id, not an email allowlist. - Deprecated: `users/<email>` is treated as a user id, not an email allowlist.
- Spaces: `spaces/<spaceId>`. - Spaces: `spaces/<spaceId>`.
@@ -171,7 +172,7 @@ Use these identifiers for delivery and allowlists:
botUser: "users/1234567890", // optional; helps mention detection botUser: "users/1234567890", // optional; helps mention detection
dm: { dm: {
policy: "pairing", policy: "pairing",
allowFrom: ["users/1234567890", "name@example.com"], allowFrom: ["users/1234567890"],
}, },
groupPolicy: "allowlist", groupPolicy: "allowlist",
groups: { groups: {
@@ -194,6 +195,7 @@ Notes:
- Service account credentials can also be passed inline with `serviceAccount` (JSON string). - Service account credentials can also be passed inline with `serviceAccount` (JSON string).
- Default webhook path is `/googlechat` if `webhookPath` isnt set. - Default webhook path is `/googlechat` if `webhookPath` isnt set.
- `dangerouslyAllowNameMatching` re-enables mutable email principal matching for allowlists (break-glass compatibility mode).
- Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled. - Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled.
- `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth). - `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth).
- Attachments are downloaded through the Chat API and stored in the media pipeline (size capped by `mediaMaxMb`). - Attachments are downloaded through the Chat API and stored in the media pipeline (size capped by `mediaMaxMb`).

View File

@@ -57,7 +57,8 @@ Config keys:
- Per-channel controls (channel + sender + mention rules): `channels.irc.groups["#channel"]` - Per-channel controls (channel + sender + mention rules): `channels.irc.groups["#channel"]`
- `channels.irc.groupPolicy="open"` allows unconfigured channels (**still mention-gated by default**) - `channels.irc.groupPolicy="open"` allows unconfigured channels (**still mention-gated by default**)
Allowlist entries can use nick or `nick!user@host` forms. Allowlist entries should use stable sender identities (`nick!user@host`).
Bare nick matching is mutable and only enabled when `channels.irc.dangerouslyAllowNameMatching: true`.
### Common gotcha: `allowFrom` is for DMs, not channels ### Common gotcha: `allowFrom` is for DMs, not channels

View File

@@ -101,7 +101,8 @@ Notes:
## Channels (groups) ## Channels (groups)
- Default: `channels.mattermost.groupPolicy = "allowlist"` (mention-gated). - Default: `channels.mattermost.groupPolicy = "allowlist"` (mention-gated).
- Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs or `@username`). - Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs recommended).
- `@username` matching is mutable and only enabled when `channels.mattermost.dangerouslyAllowNameMatching: true`.
- Open channels: `channels.mattermost.groupPolicy="open"` (mention-gated). - Open channels: `channels.mattermost.groupPolicy="open"` (mention-gated).
- Runtime note: if `channels.mattermost` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set). - Runtime note: if `channels.mattermost` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set).

View File

@@ -87,7 +87,9 @@ Disable with:
**DM access** **DM access**
- Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved. - Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved.
- `channels.msteams.allowFrom` accepts AAD object IDs, UPNs, or display names. The wizard resolves names to IDs via Microsoft Graph when credentials allow. - `channels.msteams.allowFrom` should use stable AAD object IDs.
- UPNs/display names are mutable; direct matching is disabled by default and only enabled with `channels.msteams.dangerouslyAllowNameMatching: true`.
- The wizard can resolve names to IDs via Microsoft Graph when credentials allow.
**Group access** **Group access**
@@ -454,7 +456,8 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
- `channels.msteams.webhook.port` (default `3978`) - `channels.msteams.webhook.port` (default `3978`)
- `channels.msteams.webhook.path` (default `/api/messages`) - `channels.msteams.webhook.path` (default `/api/messages`)
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing) - `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs, UPNs, or display names). The wizard resolves names to IDs during setup when Graph access is available. - `channels.msteams.allowFrom`: DM allowlist (AAD object IDs recommended). The wizard resolves names to IDs during setup when Graph access is available.
- `channels.msteams.dangerouslyAllowNameMatching`: break-glass toggle to re-enable mutable UPN/display-name matching.
- `channels.msteams.textChunkLimit`: outbound text chunk size. - `channels.msteams.textChunkLimit`: outbound text chunk size.
- `channels.msteams.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. - `channels.msteams.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains). - `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains).

View File

@@ -171,6 +171,7 @@ For actions/directory reads, user token can be preferred when configured. For wr
- channel allowlist entries and DM allowlist entries are resolved at startup when token access allows - channel allowlist entries and DM allowlist entries are resolved at startup when token access allows
- unresolved entries are kept as configured - unresolved entries are kept as configured
- inbound authorization matching is ID-first by default; direct username/slug matching requires `channels.slack.dangerouslyAllowNameMatching: true`
</Tab> </Tab>
@@ -513,6 +514,7 @@ Primary reference:
High-signal Slack fields: High-signal Slack fields:
- mode/auth: `mode`, `botToken`, `appToken`, `signingSecret`, `webhookPath`, `accounts.*` - mode/auth: `mode`, `botToken`, `appToken`, `signingSecret`, `webhookPath`, `accounts.*`
- DM access: `dm.enabled`, `dmPolicy`, `allowFrom` (legacy: `dm.policy`, `dm.allowFrom`), `dm.groupEnabled`, `dm.groupChannels` - DM access: `dm.enabled`, `dmPolicy`, `allowFrom` (legacy: `dm.policy`, `dm.allowFrom`), `dm.groupEnabled`, `dm.groupChannels`
- compatibility toggle: `dangerouslyAllowNameMatching` (break-glass; keep off unless needed)
- channel access: `groupPolicy`, `channels.*`, `channels.*.users`, `channels.*.requireMention` - channel access: `groupPolicy`, `channels.*`, `channels.*.users`, `channels.*.requireMention`
- threading/history: `replyToMode`, `replyToModeByChatType`, `thread.*`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit` - threading/history: `replyToMode`, `replyToModeByChatType`, `thread.*`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit`
- delivery: `textChunkLimit`, `chunkMode`, `mediaMaxMb`, `streaming`, `nativeStreaming` - delivery: `textChunkLimit`, `chunkMode`, `mediaMaxMb`, `streaming`, `nativeStreaming`

View File

@@ -32,8 +32,9 @@ It also flags `gateway.allowRealIpFallback=true` (header-spoofing risk if proxie
It also warns when sandbox browser uses Docker `bridge` network without `sandbox.browser.cdpSourceRange`. It also warns when sandbox browser uses Docker `bridge` network without `sandbox.browser.cdpSourceRange`.
It also warns when existing sandbox browser Docker containers have missing/stale hash labels (for example pre-migration containers missing `openclaw.browserConfigEpoch`) and recommends `openclaw sandbox recreate --browser --all`. It also warns when existing sandbox browser Docker containers have missing/stale hash labels (for example pre-migration containers missing `openclaw.browserConfigEpoch`) and recommends `openclaw sandbox recreate --browser --all`.
It also warns when npm-based plugin/hook install records are unpinned, missing integrity metadata, or drift from currently installed package versions. It also warns when npm-based plugin/hook install records are unpinned, missing integrity metadata, or drift from currently installed package versions.
It warns when Discord allowlists (`channels.discord.allowFrom`, `channels.discord.guilds.*.users`, pairing store) use name or tag entries instead of stable IDs. It warns when channel allowlists rely on mutable names/emails/tags instead of stable IDs (Discord, Slack, Google Chat, MS Teams, Mattermost, IRC scopes where applicable).
It warns when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable without a shared secret (`/tools/invoke` plus any enabled `/v1/*` endpoint). It warns when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable without a shared secret (`/tools/invoke` plus any enabled `/v1/*` endpoint).
Settings prefixed with `dangerous`/`dangerously` are explicit break-glass operator overrides; enabling one is not, by itself, a security vulnerability report.
## JSON output ## JSON output

View File

@@ -202,7 +202,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
discord: { discord: {
enabled: true, enabled: true,
token: "YOUR_DISCORD_BOT_TOKEN", token: "YOUR_DISCORD_BOT_TOKEN",
dm: { enabled: true, allowFrom: ["steipete"] }, dm: { enabled: true, allowFrom: ["123456789012345678"] },
guilds: { guilds: {
"123456789012345678": { "123456789012345678": {
slug: "friends-of-openclaw", slug: "friends-of-openclaw",
@@ -317,7 +317,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
allowFrom: { allowFrom: {
whatsapp: ["+15555550123"], whatsapp: ["+15555550123"],
telegram: ["123456789"], telegram: ["123456789"],
discord: ["steipete"], discord: ["123456789012345678"],
slack: ["U123"], slack: ["U123"],
signal: ["+15555550123"], signal: ["+15555550123"],
imessage: ["user@example.com"], imessage: ["user@example.com"],
@@ -461,7 +461,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
discord: { discord: {
enabled: true, enabled: true,
token: "YOUR_TOKEN", token: "YOUR_TOKEN",
dm: { allowFrom: ["yourname"] }, dm: { allowFrom: ["123456789012345678"] },
}, },
}, },
} }
@@ -487,12 +487,15 @@ If more than one person can DM your bot (multiple entries in `allowFrom`, pairin
discord: { discord: {
enabled: true, enabled: true,
token: "YOUR_DISCORD_BOT_TOKEN", token: "YOUR_DISCORD_BOT_TOKEN",
dm: { enabled: true, allowFrom: ["alice", "bob"] }, dm: { enabled: true, allowFrom: ["123456789012345678", "987654321098765432"] },
}, },
}, },
} }
``` ```
For Discord/Slack/Google Chat/MS Teams/Mattermost/IRC, sender authorization is ID-first by default.
Only enable direct mutable name/email/nick matching with each channel's `dangerouslyAllowNameMatching: true` if you explicitly accept that risk.
### OAuth with API key failover ### OAuth with API key failover
```json5 ```json5

View File

@@ -212,7 +212,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
}, },
replyToMode: "off", // off | first | all replyToMode: "off", // off | first | all
dmPolicy: "pairing", dmPolicy: "pairing",
allowFrom: ["1234567890", "steipete"], allowFrom: ["1234567890", "123456789012345678"],
dm: { enabled: true, groupEnabled: false, groupChannels: ["openclaw-dm"] }, dm: { enabled: true, groupEnabled: false, groupChannels: ["openclaw-dm"] },
guilds: { guilds: {
"123456789012345678": { "123456789012345678": {
@@ -283,6 +283,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
- `channels.discord.ui.components.accentColor` sets the accent color for Discord components v2 containers. - `channels.discord.ui.components.accentColor` sets the accent color for Discord components v2 containers.
- `channels.discord.voice` enables Discord voice channel conversations and optional auto-join + TTS overrides. - `channels.discord.voice` enables Discord voice channel conversations and optional auto-join + TTS overrides.
- `channels.discord.streaming` is the canonical stream mode key. Legacy `streamMode` and boolean `streaming` values are auto-migrated. - `channels.discord.streaming` is the canonical stream mode key. Legacy `streamMode` and boolean `streaming` values are auto-migrated.
- `channels.discord.dangerouslyAllowNameMatching` re-enables mutable name/tag matching (break-glass compatibility mode).
**Reaction notification modes:** `off` (none), `own` (bot's messages, default), `all` (all messages), `allowlist` (from `guilds.<id>.users` on all messages). **Reaction notification modes:** `off` (none), `own` (bot's messages, default), `all` (all messages), `allowlist` (from `guilds.<id>.users` on all messages).
@@ -317,7 +318,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
- Service account JSON: inline (`serviceAccount`) or file-based (`serviceAccountFile`). - Service account JSON: inline (`serviceAccount`) or file-based (`serviceAccountFile`).
- Env fallbacks: `GOOGLE_CHAT_SERVICE_ACCOUNT` or `GOOGLE_CHAT_SERVICE_ACCOUNT_FILE`. - Env fallbacks: `GOOGLE_CHAT_SERVICE_ACCOUNT` or `GOOGLE_CHAT_SERVICE_ACCOUNT_FILE`.
- Use `spaces/<spaceId>` or `users/<userId|email>` for delivery targets. - Use `spaces/<spaceId>` or `users/<userId>` for delivery targets.
- `channels.googlechat.dangerouslyAllowNameMatching` re-enables mutable email principal matching (break-glass compatibility mode).
### Slack ### Slack
@@ -1490,7 +1492,7 @@ Controls elevated (host) exec access:
enabled: true, enabled: true,
allowFrom: { allowFrom: {
whatsapp: ["+15555550123"], whatsapp: ["+15555550123"],
discord: ["steipete", "1234567890123"], discord: ["1234567890123", "987654321098765432"],
}, },
}, },
}, },

View File

@@ -2,8 +2,9 @@ import { describe, expect, it } from "vitest";
import { isSenderAllowed } from "./monitor.js"; import { isSenderAllowed } from "./monitor.js";
describe("isSenderAllowed", () => { describe("isSenderAllowed", () => {
it("matches allowlist entries with raw email", () => { it("matches raw email entries only when dangerous name matching is enabled", () => {
expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"])).toBe(true); 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)", () => { it("does not treat users/<email> entries as email allowlist (deprecated form)", () => {
@@ -17,6 +18,8 @@ describe("isSenderAllowed", () => {
}); });
it("rejects non-matching raw email entries", () => { it("rejects non-matching raw email entries", () => {
expect(isSenderAllowed("users/123", "jane@example.com", ["other@example.com"])).toBe(false); expect(isSenderAllowed("users/123", "jane@example.com", ["other@example.com"], true)).toBe(
false,
);
}); });
}); });

View File

@@ -287,6 +287,7 @@ export function isSenderAllowed(
senderId: string, senderId: string,
senderEmail: string | undefined, senderEmail: string | undefined,
allowFrom: string[], allowFrom: string[],
allowNameMatching = false,
) { ) {
if (allowFrom.includes("*")) { if (allowFrom.includes("*")) {
return true; return true;
@@ -305,8 +306,8 @@ export function isSenderAllowed(
return normalizeUserId(withoutPrefix) === normalizedSenderId; return normalizeUserId(withoutPrefix) === normalizedSenderId;
} }
// Raw email allowlist entries remain supported for usability. // Raw email allowlist entries are a break-glass override.
if (normalizedEmail && isEmailLike(withoutPrefix)) { if (allowNameMatching && normalizedEmail && isEmailLike(withoutPrefix)) {
return withoutPrefix === normalizedEmail; return withoutPrefix === normalizedEmail;
} }
@@ -409,6 +410,7 @@ async function processMessageWithPipeline(params: {
const senderId = sender?.name ?? ""; const senderId = sender?.name ?? "";
const senderName = sender?.displayName ?? ""; const senderName = sender?.displayName ?? "";
const senderEmail = sender?.email ?? undefined; const senderEmail = sender?.email ?? undefined;
const allowNameMatching = account.config.dangerouslyAllowNameMatching === true;
const allowBots = account.config.allowBots === true; const allowBots = account.config.allowBots === true;
if (!allowBots) { if (!allowBots) {
@@ -489,6 +491,7 @@ async function processMessageWithPipeline(params: {
senderId, senderId,
senderEmail, senderEmail,
groupUsers.map((v) => String(v)), groupUsers.map((v) => String(v)),
allowNameMatching,
); );
if (!ok) { if (!ok) {
logVerbose(core, runtime, `drop group message (sender not allowed, ${senderId})`); logVerbose(core, runtime, `drop group message (sender not allowed, ${senderId})`);
@@ -508,7 +511,12 @@ async function processMessageWithPipeline(params: {
warnDeprecatedUsersEmailEntries(core, runtime, effectiveAllowFrom); warnDeprecatedUsersEmailEntries(core, runtime, effectiveAllowFrom);
const commandAllowFrom = isGroup ? groupUsers.map((v) => String(v)) : effectiveAllowFrom; const commandAllowFrom = isGroup ? groupUsers.map((v) => String(v)) : effectiveAllowFrom;
const useAccessGroups = config.commands?.useAccessGroups !== false; const useAccessGroups = config.commands?.useAccessGroups !== false;
const senderAllowedForCommands = isSenderAllowed(senderId, senderEmail, commandAllowFrom); const senderAllowedForCommands = isSenderAllowed(
senderId,
senderEmail,
commandAllowFrom,
allowNameMatching,
);
const commandAuthorized = shouldComputeAuth const commandAuthorized = shouldComputeAuth
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups, useAccessGroups,

View File

@@ -46,6 +46,7 @@ export const IrcAccountSchemaBase = z
.object({ .object({
name: z.string().optional(), name: z.string().optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
dangerouslyAllowNameMatching: z.boolean().optional(),
host: z.string().optional(), host: z.string().optional(),
port: z.number().int().min(1).max(65535).optional(), port: z.number().int().min(1).max(65535).optional(),
tls: z.boolean().optional(), tls: z.boolean().optional(),

View File

@@ -78,6 +78,7 @@ export async function handleIrcInbound(params: {
const senderDisplay = message.senderHost const senderDisplay = message.senderHost
? `${message.senderNick}!${message.senderUser ?? "?"}@${message.senderHost}` ? `${message.senderNick}!${message.senderUser ?? "?"}@${message.senderHost}`
: message.senderNick; : message.senderNick;
const allowNameMatching = account.config.dangerouslyAllowNameMatching === true;
const dmPolicy = account.config.dmPolicy ?? "pairing"; const dmPolicy = account.config.dmPolicy ?? "pairing";
const defaultGroupPolicy = resolveDefaultGroupPolicy(config); const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
@@ -132,6 +133,7 @@ export async function handleIrcInbound(params: {
const senderAllowedForCommands = resolveIrcAllowlistMatch({ const senderAllowedForCommands = resolveIrcAllowlistMatch({
allowFrom: message.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom, allowFrom: message.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom,
message, message,
allowNameMatching,
}).allowed; }).allowed;
const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig); const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig);
const commandGate = resolveControlCommandGate({ const commandGate = resolveControlCommandGate({
@@ -153,6 +155,7 @@ export async function handleIrcInbound(params: {
message, message,
outerAllowFrom: effectiveGroupAllowFrom, outerAllowFrom: effectiveGroupAllowFrom,
innerAllowFrom: groupAllowFrom, innerAllowFrom: groupAllowFrom,
allowNameMatching,
}); });
if (!senderAllowed) { if (!senderAllowed) {
runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`); runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`);
@@ -167,6 +170,7 @@ export async function handleIrcInbound(params: {
const dmAllowed = resolveIrcAllowlistMatch({ const dmAllowed = resolveIrcAllowlistMatch({
allowFrom: effectiveAllowFrom, allowFrom: effectiveAllowFrom,
message, message,
allowNameMatching,
}).allowed; }).allowed;
if (!dmAllowed) { if (!dmAllowed) {
if (dmPolicy === "pairing") { if (dmPolicy === "pairing") {

View File

@@ -30,6 +30,8 @@ describe("irc normalize", () => {
}; };
expect(buildIrcAllowlistCandidates(message)).toContain("alice!ident@example.org"); expect(buildIrcAllowlistCandidates(message)).toContain("alice!ident@example.org");
expect(buildIrcAllowlistCandidates(message)).not.toContain("alice");
expect(buildIrcAllowlistCandidates(message, { allowNameMatching: true })).toContain("alice");
expect( expect(
resolveIrcAllowlistMatch({ resolveIrcAllowlistMatch({
allowFrom: ["alice!ident@example.org"], allowFrom: ["alice!ident@example.org"],
@@ -38,9 +40,16 @@ describe("irc normalize", () => {
).toBe(true); ).toBe(true);
expect( expect(
resolveIrcAllowlistMatch({ resolveIrcAllowlistMatch({
allowFrom: ["bob"], allowFrom: ["alice"],
message, message,
}).allowed, }).allowed,
).toBe(false); ).toBe(false);
expect(
resolveIrcAllowlistMatch({
allowFrom: ["alice"],
message,
allowNameMatching: true,
}).allowed,
).toBe(true);
}); });
}); });

View File

@@ -77,12 +77,15 @@ export function formatIrcSenderId(message: IrcInboundMessage): string {
return base; return base;
} }
export function buildIrcAllowlistCandidates(message: IrcInboundMessage): string[] { export function buildIrcAllowlistCandidates(
message: IrcInboundMessage,
params?: { allowNameMatching?: boolean },
): string[] {
const nick = message.senderNick.trim().toLowerCase(); const nick = message.senderNick.trim().toLowerCase();
const user = message.senderUser?.trim().toLowerCase(); const user = message.senderUser?.trim().toLowerCase();
const host = message.senderHost?.trim().toLowerCase(); const host = message.senderHost?.trim().toLowerCase();
const candidates = new Set<string>(); const candidates = new Set<string>();
if (nick) { if (nick && params?.allowNameMatching === true) {
candidates.add(nick); candidates.add(nick);
} }
if (nick && user) { if (nick && user) {
@@ -100,6 +103,7 @@ export function buildIrcAllowlistCandidates(message: IrcInboundMessage): string[
export function resolveIrcAllowlistMatch(params: { export function resolveIrcAllowlistMatch(params: {
allowFrom: string[]; allowFrom: string[];
message: IrcInboundMessage; message: IrcInboundMessage;
allowNameMatching?: boolean;
}): { allowed: boolean; source?: string } { }): { allowed: boolean; source?: string } {
const allowFrom = new Set( const allowFrom = new Set(
params.allowFrom.map((entry) => entry.trim().toLowerCase()).filter(Boolean), params.allowFrom.map((entry) => entry.trim().toLowerCase()).filter(Boolean),
@@ -107,7 +111,9 @@ export function resolveIrcAllowlistMatch(params: {
if (allowFrom.has("*")) { if (allowFrom.has("*")) {
return { allowed: true, source: "wildcard" }; return { allowed: true, source: "wildcard" };
} }
const candidates = buildIrcAllowlistCandidates(params.message); const candidates = buildIrcAllowlistCandidates(params.message, {
allowNameMatching: params.allowNameMatching,
});
for (const candidate of candidates) { for (const candidate of candidates) {
if (allowFrom.has(candidate)) { if (allowFrom.has(candidate)) {
return { allowed: true, source: candidate }; return { allowed: true, source: candidate };

View File

@@ -50,6 +50,14 @@ describe("irc policy", () => {
}), }),
).toBe(false); ).toBe(false);
expect(
resolveIrcGroupSenderAllowed({
groupPolicy: "allowlist",
message,
outerAllowFrom: ["alice!ident@example.org"],
innerAllowFrom: [],
}),
).toBe(true);
expect( expect(
resolveIrcGroupSenderAllowed({ resolveIrcGroupSenderAllowed({
groupPolicy: "allowlist", groupPolicy: "allowlist",
@@ -57,6 +65,15 @@ describe("irc policy", () => {
outerAllowFrom: ["alice"], outerAllowFrom: ["alice"],
innerAllowFrom: [], innerAllowFrom: [],
}), }),
).toBe(false);
expect(
resolveIrcGroupSenderAllowed({
groupPolicy: "allowlist",
message,
outerAllowFrom: ["alice"],
innerAllowFrom: [],
allowNameMatching: true,
}),
).toBe(true); ).toBe(true);
}); });

View File

@@ -142,16 +142,25 @@ export function resolveIrcGroupSenderAllowed(params: {
message: IrcInboundMessage; message: IrcInboundMessage;
outerAllowFrom: string[]; outerAllowFrom: string[];
innerAllowFrom: string[]; innerAllowFrom: string[];
allowNameMatching?: boolean;
}): boolean { }): boolean {
const policy = params.groupPolicy ?? "allowlist"; const policy = params.groupPolicy ?? "allowlist";
const inner = normalizeIrcAllowlist(params.innerAllowFrom); const inner = normalizeIrcAllowlist(params.innerAllowFrom);
const outer = normalizeIrcAllowlist(params.outerAllowFrom); const outer = normalizeIrcAllowlist(params.outerAllowFrom);
if (inner.length > 0) { if (inner.length > 0) {
return resolveIrcAllowlistMatch({ allowFrom: inner, message: params.message }).allowed; return resolveIrcAllowlistMatch({
allowFrom: inner,
message: params.message,
allowNameMatching: params.allowNameMatching,
}).allowed;
} }
if (outer.length > 0) { if (outer.length > 0) {
return resolveIrcAllowlistMatch({ allowFrom: outer, message: params.message }).allowed; return resolveIrcAllowlistMatch({
allowFrom: outer,
message: params.message,
allowNameMatching: params.allowNameMatching,
}).allowed;
} }
return policy === "open"; return policy === "open";
} }

View File

@@ -32,6 +32,11 @@ export type IrcNickServConfig = {
export type IrcAccountConfig = { export type IrcAccountConfig = {
name?: string; name?: string;
enabled?: boolean; enabled?: boolean;
/**
* Break-glass override: allow nick-only allowlist matching.
* Default behavior requires host/user-qualified identities.
*/
dangerouslyAllowNameMatching?: boolean;
host?: string; host?: string;
port?: number; port?: number;
tls?: boolean; tls?: boolean;

View File

@@ -11,6 +11,7 @@ const MattermostAccountSchemaBase = z
.object({ .object({
name: z.string().optional(), name: z.string().optional(),
capabilities: z.array(z.string()).optional(), capabilities: z.array(z.string()).optional(),
dangerouslyAllowNameMatching: z.boolean().optional(),
markdown: MarkdownConfigSchema, markdown: MarkdownConfigSchema,
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
configWrites: z.boolean().optional(), configWrites: z.boolean().optional(),

View File

@@ -152,6 +152,7 @@ function isSenderAllowed(params: {
senderId: string; senderId: string;
senderName?: string; senderName?: string;
allowFrom: string[]; allowFrom: string[];
allowNameMatching?: boolean;
}): boolean { }): boolean {
const allowFrom = params.allowFrom; const allowFrom = params.allowFrom;
if (allowFrom.length === 0) { if (allowFrom.length === 0) {
@@ -162,10 +163,15 @@ function isSenderAllowed(params: {
} }
const normalizedSenderId = normalizeAllowEntry(params.senderId); const normalizedSenderId = normalizeAllowEntry(params.senderId);
const normalizedSenderName = params.senderName ? normalizeAllowEntry(params.senderName) : ""; const normalizedSenderName = params.senderName ? normalizeAllowEntry(params.senderName) : "";
return allowFrom.some( return allowFrom.some((entry) => {
(entry) => if (entry === normalizedSenderId) {
entry === normalizedSenderId || (normalizedSenderName && entry === normalizedSenderName), return true;
); }
if (params.allowNameMatching !== true) {
return false;
}
return normalizedSenderName ? entry === normalizedSenderName : false;
});
} }
type MattermostMediaInfo = { type MattermostMediaInfo = {
@@ -206,6 +212,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
cfg, cfg,
accountId: opts.accountId, accountId: opts.accountId,
}); });
const allowNameMatching = account.config.dangerouslyAllowNameMatching === true;
const botToken = opts.botToken?.trim() || account.botToken?.trim(); const botToken = opts.botToken?.trim() || account.botToken?.trim();
if (!botToken) { if (!botToken) {
throw new Error( throw new Error(
@@ -416,11 +423,13 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
senderId, senderId,
senderName, senderName,
allowFrom: effectiveAllowFrom, allowFrom: effectiveAllowFrom,
allowNameMatching,
}); });
const groupAllowedForCommands = isSenderAllowed({ const groupAllowedForCommands = isSenderAllowed({
senderId, senderId,
senderName, senderName,
allowFrom: effectiveGroupAllowFrom, allowFrom: effectiveGroupAllowFrom,
allowNameMatching,
}); });
const commandGate = resolveControlCommandGate({ const commandGate = resolveControlCommandGate({
useAccessGroups, useAccessGroups,
@@ -892,6 +901,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
senderId: userId, senderId: userId,
senderName, senderName,
allowFrom: effectiveAllowFrom, allowFrom: effectiveAllowFrom,
allowNameMatching,
}); });
if (!allowed) { if (!allowed) {
logVerboseMessage( logVerboseMessage(
@@ -927,6 +937,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
senderId: userId, senderId: userId,
senderName, senderName,
allowFrom: effectiveGroupAllowFrom, allowFrom: effectiveGroupAllowFrom,
allowNameMatching,
}); });
if (!allowed) { if (!allowed) {
logVerboseMessage(`mattermost: drop reaction (groupPolicy=allowlist sender=${userId})`); logVerboseMessage(`mattermost: drop reaction (groupPolicy=allowlist sender=${userId})`);

View File

@@ -7,6 +7,11 @@ export type MattermostAccountConfig = {
name?: string; name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */ /** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[]; capabilities?: string[];
/**
* Break-glass override: allow mutable identity matching (@username/display name) in allowlists.
* Default behavior is ID-only matching.
*/
dangerouslyAllowNameMatching?: boolean;
/** Allow channel-initiated config writes (default: true). */ /** Allow channel-initiated config writes (default: true). */
configWrites?: boolean; configWrites?: boolean;
/** If false, do not start this Mattermost account. Default: true. */ /** If false, do not start this Mattermost account. Default: true. */

View File

@@ -145,10 +145,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
if (dmPolicy !== "open") { if (dmPolicy !== "open") {
const effectiveAllowFrom = [...allowFrom.map((v) => String(v)), ...storedAllowFrom]; const effectiveAllowFrom = [...allowFrom.map((v) => String(v)), ...storedAllowFrom];
const allowNameMatching = msteamsCfg.dangerouslyAllowNameMatching === true;
const allowMatch = resolveMSTeamsAllowlistMatch({ const allowMatch = resolveMSTeamsAllowlistMatch({
allowFrom: effectiveAllowFrom, allowFrom: effectiveAllowFrom,
senderId, senderId,
senderName, senderName,
allowNameMatching,
}); });
if (!allowMatch.allowed) { if (!allowMatch.allowed) {
@@ -226,10 +228,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
return; return;
} }
if (effectiveGroupAllowFrom.length > 0) { if (effectiveGroupAllowFrom.length > 0) {
const allowNameMatching = msteamsCfg.dangerouslyAllowNameMatching === true;
const allowMatch = resolveMSTeamsAllowlistMatch({ const allowMatch = resolveMSTeamsAllowlistMatch({
allowFrom: effectiveGroupAllowFrom, allowFrom: effectiveGroupAllowFrom,
senderId, senderId,
senderName, senderName,
allowNameMatching,
}); });
if (!allowMatch.allowed) { if (!allowMatch.allowed) {
log.debug?.("dropping group message (not in groupAllowFrom)", { log.debug?.("dropping group message (not in groupAllowFrom)", {
@@ -248,12 +252,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
allowFrom: effectiveDmAllowFrom, allowFrom: effectiveDmAllowFrom,
senderId, senderId,
senderName, senderName,
allowNameMatching: msteamsCfg?.dangerouslyAllowNameMatching === true,
}); });
const groupAllowedForCommands = isMSTeamsGroupAllowed({ const groupAllowedForCommands = isMSTeamsGroupAllowed({
groupPolicy: "allowlist", groupPolicy: "allowlist",
allowFrom: effectiveGroupAllowFrom, allowFrom: effectiveGroupAllowFrom,
senderId, senderId,
senderName, senderName,
allowNameMatching: msteamsCfg?.dangerouslyAllowNameMatching === true,
}); });
const hasControlCommandInMessage = core.channel.text.hasControlCommand(text, cfg); const hasControlCommandInMessage = core.channel.text.hasControlCommand(text, cfg);
const commandGate = resolveControlCommandGate({ const commandGate = resolveControlCommandGate({

View File

@@ -209,6 +209,7 @@ export function resolveMSTeamsAllowlistMatch(params: {
allowFrom: Array<string | number>; allowFrom: Array<string | number>;
senderId: string; senderId: string;
senderName?: string | null; senderName?: string | null;
allowNameMatching?: boolean;
}): MSTeamsAllowlistMatch { }): MSTeamsAllowlistMatch {
return resolveAllowlistMatchSimple(params); return resolveAllowlistMatchSimple(params);
} }
@@ -245,6 +246,7 @@ export function isMSTeamsGroupAllowed(params: {
allowFrom: Array<string | number>; allowFrom: Array<string | number>;
senderId: string; senderId: string;
senderName?: string | null; senderName?: string | null;
allowNameMatching?: boolean;
}): boolean { }): boolean {
const { groupPolicy } = params; const { groupPolicy } = params;
if (groupPolicy === "disabled") { if (groupPolicy === "disabled") {

View File

@@ -26,6 +26,7 @@ export function resolveAllowlistMatchSimple(params: {
allowFrom: Array<string | number>; allowFrom: Array<string | number>;
senderId: string; senderId: string;
senderName?: string | null; senderName?: string | null;
allowNameMatching?: boolean;
}): AllowlistMatch<"wildcard" | "id" | "name"> { }): AllowlistMatch<"wildcard" | "id" | "name"> {
const allowFrom = params.allowFrom const allowFrom = params.allowFrom
.map((entry) => String(entry).trim().toLowerCase()) .map((entry) => String(entry).trim().toLowerCase())
@@ -44,7 +45,7 @@ export function resolveAllowlistMatchSimple(params: {
} }
const senderName = params.senderName?.toLowerCase(); const senderName = params.senderName?.toLowerCase();
if (senderName && allowFrom.includes(senderName)) { if (params.allowNameMatching === true && senderName && allowFrom.includes(senderName)) {
return { allowed: true, matchKey: senderName, matchSource: "name" }; return { allowed: true, matchKey: senderName, matchSource: "name" };
} }

View File

@@ -578,6 +578,400 @@ function maybeRepairDiscordNumericIds(cfg: OpenClawConfig): {
return { config: next, changes }; return { config: next, changes };
} }
type MutableAllowlistHit = {
channel: string;
path: string;
entry: string;
dangerousFlagPath: string;
};
function collectProviderAccountScopes(
cfg: OpenClawConfig,
provider: string,
): Array<{ prefix: string; account: Record<string, unknown> }> {
const scopes: Array<{ prefix: string; account: Record<string, unknown> }> = [];
const channels = asObjectRecord(cfg.channels);
if (!channels) {
return scopes;
}
const providerCfg = asObjectRecord(channels[provider]);
if (!providerCfg) {
return scopes;
}
scopes.push({ prefix: `channels.${provider}`, account: providerCfg });
const accounts = asObjectRecord(providerCfg.accounts);
if (!accounts) {
return scopes;
}
for (const key of Object.keys(accounts)) {
const account = asObjectRecord(accounts[key]);
if (!account) {
continue;
}
scopes.push({ prefix: `channels.${provider}.accounts.${key}`, account });
}
return scopes;
}
function isDiscordMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const maybeMentionId = text.replace(/^<@!?/, "").replace(/>$/, "");
if (/^\d+$/.test(maybeMentionId)) {
return false;
}
for (const prefix of ["discord:", "user:", "pk:"]) {
if (!text.startsWith(prefix)) {
continue;
}
return text.slice(prefix.length).trim().length === 0;
}
return true;
}
function isSlackMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const mentionMatch = text.match(/^<@([A-Z0-9]+)>$/i);
if (mentionMatch && /^[A-Z0-9]{8,}$/i.test(mentionMatch[1] ?? "")) {
return false;
}
const withoutPrefix = text.replace(/^(slack|user):/i, "").trim();
if (/^[UWBCGDT][A-Z0-9]{2,}$/.test(withoutPrefix)) {
return false;
}
if (/^[A-Z0-9]{8,}$/i.test(withoutPrefix)) {
return false;
}
return true;
}
function isGoogleChatMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const withoutPrefix = text.replace(/^(googlechat|google-chat|gchat):/i, "").trim();
if (!withoutPrefix) {
return false;
}
const withoutUsers = withoutPrefix.replace(/^users\//i, "");
return withoutUsers.includes("@");
}
function isMSTeamsMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const withoutPrefix = text.replace(/^(msteams|user):/i, "").trim();
return /\s/.test(withoutPrefix) || withoutPrefix.includes("@");
}
function isMattermostMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const normalized = text
.replace(/^(mattermost|user):/i, "")
.replace(/^@/, "")
.trim()
.toLowerCase();
// Mattermost user IDs are stable 26-char lowercase/number tokens.
if (/^[a-z0-9]{26}$/.test(normalized)) {
return false;
}
return true;
}
function isIrcMutableAllowEntry(raw: string): boolean {
const text = raw.trim().toLowerCase();
if (!text || text === "*") {
return false;
}
const normalized = text
.replace(/^irc:/, "")
.replace(/^user:/, "")
.trim();
return !normalized.includes("!") && !normalized.includes("@");
}
function addMutableAllowlistHits(params: {
hits: MutableAllowlistHit[];
pathLabel: string;
list: unknown;
detector: (entry: string) => boolean;
channel: string;
dangerousFlagPath: string;
}) {
if (!Array.isArray(params.list)) {
return;
}
for (const entry of params.list) {
const text = String(entry).trim();
if (!text || text === "*") {
continue;
}
if (!params.detector(text)) {
continue;
}
params.hits.push({
channel: params.channel,
path: params.pathLabel,
entry: text,
dangerousFlagPath: params.dangerousFlagPath,
});
}
}
function scanMutableAllowlistEntries(cfg: OpenClawConfig): MutableAllowlistHit[] {
const hits: MutableAllowlistHit[] = [];
for (const scope of collectProviderAccountScopes(cfg, "discord")) {
const dangerousFlagPath = `${scope.prefix}.dangerouslyAllowNameMatching`;
if (scope.account.dangerouslyAllowNameMatching === true) {
continue;
}
addMutableAllowlistHits({
hits,
pathLabel: `${scope.prefix}.allowFrom`,
list: scope.account.allowFrom,
detector: isDiscordMutableAllowEntry,
channel: "discord",
dangerousFlagPath,
});
const dm = asObjectRecord(scope.account.dm);
if (dm) {
addMutableAllowlistHits({
hits,
pathLabel: `${scope.prefix}.dm.allowFrom`,
list: dm.allowFrom,
detector: isDiscordMutableAllowEntry,
channel: "discord",
dangerousFlagPath,
});
}
const guilds = asObjectRecord(scope.account.guilds);
if (!guilds) {
continue;
}
for (const [guildId, guildRaw] of Object.entries(guilds)) {
const guild = asObjectRecord(guildRaw);
if (!guild) {
continue;
}
addMutableAllowlistHits({
hits,
pathLabel: `${scope.prefix}.guilds.${guildId}.users`,
list: guild.users,
detector: isDiscordMutableAllowEntry,
channel: "discord",
dangerousFlagPath,
});
const channels = asObjectRecord(guild.channels);
if (!channels) {
continue;
}
for (const [channelId, channelRaw] of Object.entries(channels)) {
const channel = asObjectRecord(channelRaw);
if (!channel) {
continue;
}
addMutableAllowlistHits({
hits,
pathLabel: `${scope.prefix}.guilds.${guildId}.channels.${channelId}.users`,
list: channel.users,
detector: isDiscordMutableAllowEntry,
channel: "discord",
dangerousFlagPath,
});
}
}
}
for (const scope of collectProviderAccountScopes(cfg, "slack")) {
const dangerousFlagPath = `${scope.prefix}.dangerouslyAllowNameMatching`;
if (scope.account.dangerouslyAllowNameMatching === true) {
continue;
}
addMutableAllowlistHits({
hits,
pathLabel: `${scope.prefix}.allowFrom`,
list: scope.account.allowFrom,
detector: isSlackMutableAllowEntry,
channel: "slack",
dangerousFlagPath,
});
const dm = asObjectRecord(scope.account.dm);
if (dm) {
addMutableAllowlistHits({
hits,
pathLabel: `${scope.prefix}.dm.allowFrom`,
list: dm.allowFrom,
detector: isSlackMutableAllowEntry,
channel: "slack",
dangerousFlagPath,
});
}
const channels = asObjectRecord(scope.account.channels);
if (!channels) {
continue;
}
for (const [channelKey, channelRaw] of Object.entries(channels)) {
const channel = asObjectRecord(channelRaw);
if (!channel) {
continue;
}
addMutableAllowlistHits({
hits,
pathLabel: `${scope.prefix}.channels.${channelKey}.users`,
list: channel.users,
detector: isSlackMutableAllowEntry,
channel: "slack",
dangerousFlagPath,
});
}
}
for (const scope of collectProviderAccountScopes(cfg, "googlechat")) {
const dangerousFlagPath = `${scope.prefix}.dangerouslyAllowNameMatching`;
if (scope.account.dangerouslyAllowNameMatching === true) {
continue;
}
addMutableAllowlistHits({
hits,
pathLabel: `${scope.prefix}.groupAllowFrom`,
list: scope.account.groupAllowFrom,
detector: isGoogleChatMutableAllowEntry,
channel: "googlechat",
dangerousFlagPath,
});
const dm = asObjectRecord(scope.account.dm);
if (dm) {
addMutableAllowlistHits({
hits,
pathLabel: `${scope.prefix}.dm.allowFrom`,
list: dm.allowFrom,
detector: isGoogleChatMutableAllowEntry,
channel: "googlechat",
dangerousFlagPath,
});
}
const groups = asObjectRecord(scope.account.groups);
if (!groups) {
continue;
}
for (const [groupKey, groupRaw] of Object.entries(groups)) {
const group = asObjectRecord(groupRaw);
if (!group) {
continue;
}
addMutableAllowlistHits({
hits,
pathLabel: `${scope.prefix}.groups.${groupKey}.users`,
list: group.users,
detector: isGoogleChatMutableAllowEntry,
channel: "googlechat",
dangerousFlagPath,
});
}
}
for (const scope of collectProviderAccountScopes(cfg, "msteams")) {
const dangerousFlagPath = `${scope.prefix}.dangerouslyAllowNameMatching`;
if (scope.account.dangerouslyAllowNameMatching === true) {
continue;
}
addMutableAllowlistHits({
hits,
pathLabel: `${scope.prefix}.allowFrom`,
list: scope.account.allowFrom,
detector: isMSTeamsMutableAllowEntry,
channel: "msteams",
dangerousFlagPath,
});
addMutableAllowlistHits({
hits,
pathLabel: `${scope.prefix}.groupAllowFrom`,
list: scope.account.groupAllowFrom,
detector: isMSTeamsMutableAllowEntry,
channel: "msteams",
dangerousFlagPath,
});
}
for (const scope of collectProviderAccountScopes(cfg, "mattermost")) {
const dangerousFlagPath = `${scope.prefix}.dangerouslyAllowNameMatching`;
if (scope.account.dangerouslyAllowNameMatching === true) {
continue;
}
addMutableAllowlistHits({
hits,
pathLabel: `${scope.prefix}.allowFrom`,
list: scope.account.allowFrom,
detector: isMattermostMutableAllowEntry,
channel: "mattermost",
dangerousFlagPath,
});
addMutableAllowlistHits({
hits,
pathLabel: `${scope.prefix}.groupAllowFrom`,
list: scope.account.groupAllowFrom,
detector: isMattermostMutableAllowEntry,
channel: "mattermost",
dangerousFlagPath,
});
}
for (const scope of collectProviderAccountScopes(cfg, "irc")) {
const dangerousFlagPath = `${scope.prefix}.dangerouslyAllowNameMatching`;
if (scope.account.dangerouslyAllowNameMatching === true) {
continue;
}
addMutableAllowlistHits({
hits,
pathLabel: `${scope.prefix}.allowFrom`,
list: scope.account.allowFrom,
detector: isIrcMutableAllowEntry,
channel: "irc",
dangerousFlagPath,
});
addMutableAllowlistHits({
hits,
pathLabel: `${scope.prefix}.groupAllowFrom`,
list: scope.account.groupAllowFrom,
detector: isIrcMutableAllowEntry,
channel: "irc",
dangerousFlagPath,
});
const groups = asObjectRecord(scope.account.groups);
if (!groups) {
continue;
}
for (const [groupKey, groupRaw] of Object.entries(groups)) {
const group = asObjectRecord(groupRaw);
if (!group) {
continue;
}
addMutableAllowlistHits({
hits,
pathLabel: `${scope.prefix}.groups.${groupKey}.allowFrom`,
list: group.allowFrom,
detector: isIrcMutableAllowEntry,
channel: "irc",
dangerousFlagPath,
});
}
}
return hits;
}
/** /**
* Scan all channel configs for dmPolicy="open" without allowFrom including "*". * Scan all channel configs for dmPolicy="open" without allowFrom including "*".
* This configuration is rejected by the schema validator but can easily occur when * This configuration is rejected by the schema validator but can easily occur when
@@ -1209,6 +1603,34 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
} }
} }
const mutableAllowlistHits = scanMutableAllowlistEntries(candidate);
if (mutableAllowlistHits.length > 0) {
const channels = Array.from(new Set(mutableAllowlistHits.map((hit) => hit.channel))).toSorted();
const exampleLines = mutableAllowlistHits
.slice(0, 8)
.map((hit) => `- ${hit.path}: ${hit.entry}`)
.join("\n");
const remaining =
mutableAllowlistHits.length > 8
? `- +${mutableAllowlistHits.length - 8} more mutable allowlist entries.`
: null;
const flagPaths = Array.from(new Set(mutableAllowlistHits.map((hit) => hit.dangerousFlagPath)));
const flagHint =
flagPaths.length === 1
? flagPaths[0]
: `${flagPaths[0]} (and ${flagPaths.length - 1} other scope flags)`;
note(
[
`- Found ${mutableAllowlistHits.length} mutable allowlist ${mutableAllowlistHits.length === 1 ? "entry" : "entries"} across ${channels.join(", ")} while name matching is disabled by default.`,
exampleLines,
...(remaining ? [remaining] : []),
`- Option A (break-glass): enable ${flagHint}=true to keep name/email/nick matching.`,
"- Option B (recommended): resolve names/emails/nicks to stable sender IDs and rewrite the allowlist entries.",
].join("\n"),
"Doctor warnings",
);
}
const unknown = stripUnknownConfigKeys(candidate); const unknown = stripUnknownConfigKeys(candidate);
if (unknown.removed.length > 0) { if (unknown.removed.length > 0) {
const lines = unknown.removed.map((path) => `- ${path}`).join("\n"); const lines = unknown.removed.map((path) => `- ${path}`).join("\n");

View File

@@ -184,6 +184,11 @@ export type DiscordAccountConfig = {
proxy?: string; proxy?: string;
/** Allow bot-authored messages to trigger replies (default: false). */ /** Allow bot-authored messages to trigger replies (default: false). */
allowBots?: boolean; allowBots?: boolean;
/**
* Break-glass override: allow mutable identity matching (names/tags/slugs) in allowlists.
* Default behavior is ID-only matching.
*/
dangerouslyAllowNameMatching?: boolean;
/** /**
* Controls how guild channel messages are handled: * Controls how guild channel messages are handled:
* - "open": guild channels bypass allowlists; mention-gating applies * - "open": guild channels bypass allowlists; mention-gating applies

View File

@@ -43,6 +43,11 @@ export type GoogleChatAccountConfig = {
enabled?: boolean; enabled?: boolean;
/** Allow bot-authored messages to trigger replies (default: false). */ /** Allow bot-authored messages to trigger replies (default: false). */
allowBots?: boolean; allowBots?: boolean;
/**
* Break-glass override: allow mutable principal matching (raw email entries) in allowlists.
* Default behavior is ID-only matching.
*/
dangerouslyAllowNameMatching?: boolean;
/** Default mention requirement for space messages (default: true). */ /** Default mention requirement for space messages (default: true). */
requireMention?: boolean; requireMention?: boolean;
/** /**

View File

@@ -47,6 +47,11 @@ export type MSTeamsConfig = {
enabled?: boolean; enabled?: boolean;
/** Optional provider capability tags used for agent/runtime guidance. */ /** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[]; capabilities?: string[];
/**
* Break-glass override: allow mutable identity matching (display names/UPNs) in allowlists.
* Default behavior is ID-only matching.
*/
dangerouslyAllowNameMatching?: boolean;
/** Markdown formatting overrides (tables). */ /** Markdown formatting overrides (tables). */
markdown?: MarkdownConfig; markdown?: MarkdownConfig;
/** Allow channel-initiated config writes (default: true). */ /** Allow channel-initiated config writes (default: true). */

View File

@@ -105,6 +105,11 @@ export type SlackAccountConfig = {
userTokenReadOnly?: boolean; userTokenReadOnly?: boolean;
/** Allow bot-authored messages to trigger replies (default: false). */ /** Allow bot-authored messages to trigger replies (default: false). */
allowBots?: boolean; allowBots?: boolean;
/**
* Break-glass override: allow mutable identity matching (name/slug) in allowlists.
* Default behavior is ID-only matching.
*/
dangerouslyAllowNameMatching?: boolean;
/** Default mention requirement for channel messages (default: true). */ /** Default mention requirement for channel messages (default: true). */
requireMention?: boolean; requireMention?: boolean;
/** /**

View File

@@ -331,6 +331,7 @@ export const DiscordAccountSchema = z
token: z.string().optional().register(sensitive), token: z.string().optional().register(sensitive),
proxy: z.string().optional(), proxy: z.string().optional(),
allowBots: z.boolean().optional(), allowBots: z.boolean().optional(),
dangerouslyAllowNameMatching: z.boolean().optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"), groupPolicy: GroupPolicySchema.optional().default("allowlist"),
historyLimit: z.number().int().min(0).optional(), historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(), dmHistoryLimit: z.number().int().min(0).optional(),
@@ -516,6 +517,7 @@ export const GoogleChatAccountSchema = z
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
configWrites: z.boolean().optional(), configWrites: z.boolean().optional(),
allowBots: z.boolean().optional(), allowBots: z.boolean().optional(),
dangerouslyAllowNameMatching: z.boolean().optional(),
requireMention: z.boolean().optional(), requireMention: z.boolean().optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"), groupPolicy: GroupPolicySchema.optional().default("allowlist"),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
@@ -612,6 +614,7 @@ export const SlackAccountSchema = z
userToken: z.string().optional().register(sensitive), userToken: z.string().optional().register(sensitive),
userTokenReadOnly: z.boolean().optional().default(true), userTokenReadOnly: z.boolean().optional().default(true),
allowBots: z.boolean().optional(), allowBots: z.boolean().optional(),
dangerouslyAllowNameMatching: z.boolean().optional(),
requireMention: z.boolean().optional(), requireMention: z.boolean().optional(),
groupPolicy: GroupPolicySchema.optional(), groupPolicy: GroupPolicySchema.optional(),
historyLimit: z.number().int().min(0).optional(), historyLimit: z.number().int().min(0).optional(),
@@ -1059,6 +1062,7 @@ export const MSTeamsConfigSchema = z
.object({ .object({
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
capabilities: z.array(z.string()).optional(), capabilities: z.array(z.string()).optional(),
dangerouslyAllowNameMatching: z.boolean().optional(),
markdown: MarkdownConfigSchema, markdown: MarkdownConfigSchema,
configWrites: z.boolean().optional(), configWrites: z.boolean().optional(),
appId: z.string().optional(), appId: z.string().optional(),

View File

@@ -184,7 +184,7 @@ describe("discord allowlist helpers", () => {
expect(normalizeDiscordSlug("Dev__Chat")).toBe("dev-chat"); expect(normalizeDiscordSlug("Dev__Chat")).toBe("dev-chat");
}); });
it("matches ids or names", () => { it("matches ids by default and names only when enabled", () => {
const allow = normalizeDiscordAllowList( const allow = normalizeDiscordAllowList(
["123", "steipete", "Friends of OpenClaw"], ["123", "steipete", "Friends of OpenClaw"],
["discord:", "user:", "guild:", "channel:"], ["discord:", "user:", "guild:", "channel:"],
@@ -194,8 +194,12 @@ describe("discord allowlist helpers", () => {
throw new Error("Expected allow list to be normalized"); throw new Error("Expected allow list to be normalized");
} }
expect(allowListMatches(allow, { id: "123" })).toBe(true); expect(allowListMatches(allow, { id: "123" })).toBe(true);
expect(allowListMatches(allow, { name: "steipete" })).toBe(true); expect(allowListMatches(allow, { name: "steipete" })).toBe(false);
expect(allowListMatches(allow, { name: "friends-of-openclaw" })).toBe(true); expect(allowListMatches(allow, { name: "friends-of-openclaw" })).toBe(false);
expect(allowListMatches(allow, { name: "steipete" }, { allowNameMatching: true })).toBe(true);
expect(
allowListMatches(allow, { name: "friends-of-openclaw" }, { allowNameMatching: true }),
).toBe(true);
expect(allowListMatches(allow, { name: "other" })).toBe(false); expect(allowListMatches(allow, { name: "other" })).toBe(false);
}); });
@@ -750,6 +754,31 @@ describe("discord reaction notification gating", () => {
}, },
expected: true, expected: true,
}, },
{
name: "allowlist mode does not match usernames by default",
input: {
mode: "allowlist" as const,
botId: "bot-1",
messageAuthorId: "user-1",
userId: "999",
userName: "trusted-user",
allowlist: ["trusted-user"] as string[],
},
expected: false,
},
{
name: "allowlist mode matches usernames when explicitly enabled",
input: {
mode: "allowlist" as const,
botId: "bot-1",
messageAuthorId: "user-1",
userId: "999",
userName: "trusted-user",
allowlist: ["trusted-user"] as string[],
allowNameMatching: true,
},
expected: true,
},
]); ]);
for (const testCase of cases) { for (const testCase of cases) {
@@ -870,6 +899,7 @@ function makeReactionClient(options?: {
function makeReactionListenerParams(overrides?: { function makeReactionListenerParams(overrides?: {
botUserId?: string; botUserId?: string;
allowNameMatching?: boolean;
guildEntries?: Record<string, DiscordGuildEntryResolved>; guildEntries?: Record<string, DiscordGuildEntryResolved>;
}) { }) {
return { return {
@@ -877,6 +907,7 @@ function makeReactionListenerParams(overrides?: {
accountId: "acc-1", accountId: "acc-1",
runtime: {} as import("../runtime.js").RuntimeEnv, runtime: {} as import("../runtime.js").RuntimeEnv,
botUserId: overrides?.botUserId ?? "bot-1", botUserId: overrides?.botUserId ?? "bot-1",
allowNameMatching: overrides?.allowNameMatching ?? false,
guildEntries: overrides?.guildEntries, guildEntries: overrides?.guildEntries,
logger: { logger: {
info: vi.fn(), info: vi.fn(),

View File

@@ -237,6 +237,7 @@ async function ensureGuildComponentMemberAllowed(params: {
replyOpts: { ephemeral?: boolean }; replyOpts: { ephemeral?: boolean };
componentLabel: string; componentLabel: string;
unauthorizedReply: string; unauthorizedReply: string;
allowNameMatching: boolean;
}): Promise<boolean> { }): Promise<boolean> {
const { const {
interaction, interaction,
@@ -275,6 +276,7 @@ async function ensureGuildComponentMemberAllowed(params: {
name: user.username, name: user.username,
tag: user.discriminator ? `${user.username}#${user.discriminator}` : undefined, tag: user.discriminator ? `${user.username}#${user.discriminator}` : undefined,
}, },
allowNameMatching: params.allowNameMatching,
}); });
if (memberAllowed) { if (memberAllowed) {
return true; return true;
@@ -299,6 +301,7 @@ async function ensureComponentUserAllowed(params: {
replyOpts: { ephemeral?: boolean }; replyOpts: { ephemeral?: boolean };
componentLabel: string; componentLabel: string;
unauthorizedReply: string; unauthorizedReply: string;
allowNameMatching: boolean;
}): Promise<boolean> { }): Promise<boolean> {
const allowList = normalizeDiscordAllowList(params.entry.allowedUsers, [ const allowList = normalizeDiscordAllowList(params.entry.allowedUsers, [
"discord:", "discord:",
@@ -315,6 +318,7 @@ async function ensureComponentUserAllowed(params: {
name: params.user.username, name: params.user.username,
tag: formatDiscordUserTag(params.user), tag: formatDiscordUserTag(params.user),
}, },
allowNameMatching: params.allowNameMatching,
}); });
if (match.allowed) { if (match.allowed) {
return true; return true;
@@ -361,6 +365,7 @@ async function ensureAgentComponentInteractionAllowed(params: {
replyOpts: params.replyOpts, replyOpts: params.replyOpts,
componentLabel: params.componentLabel, componentLabel: params.componentLabel,
unauthorizedReply: params.unauthorizedReply, unauthorizedReply: params.unauthorizedReply,
allowNameMatching: params.ctx.discordConfig?.dangerouslyAllowNameMatching === true,
}); });
if (!memberAllowed) { if (!memberAllowed) {
return null; return null;
@@ -476,6 +481,7 @@ async function ensureDmComponentAuthorized(params: {
name: user.username, name: user.username,
tag: formatDiscordUserTag(user), tag: formatDiscordUserTag(user),
}, },
allowNameMatching: ctx.discordConfig?.dangerouslyAllowNameMatching === true,
}) })
: { allowed: false }; : { allowed: false };
if (allowMatch.allowed) { if (allowMatch.allowed) {
@@ -778,6 +784,7 @@ async function dispatchDiscordComponentEvent(params: {
channelConfig, channelConfig,
guildInfo, guildInfo,
sender: { id: interactionCtx.user.id, name: interactionCtx.user.username, tag: senderTag }, sender: { id: interactionCtx.user.id, name: interactionCtx.user.username, tag: senderTag },
allowNameMatching: ctx.discordConfig?.dangerouslyAllowNameMatching === true,
}); });
const storePath = resolveStorePath(ctx.cfg.session?.store, { agentId }); const storePath = resolveStorePath(ctx.cfg.session?.store, { agentId });
const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg); const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg);
@@ -975,6 +982,7 @@ async function handleDiscordComponentEvent(params: {
replyOpts, replyOpts,
componentLabel: params.componentLabel, componentLabel: params.componentLabel,
unauthorizedReply, unauthorizedReply,
allowNameMatching: params.ctx.discordConfig?.dangerouslyAllowNameMatching === true,
}); });
if (!memberAllowed) { if (!memberAllowed) {
return; return;
@@ -987,6 +995,7 @@ async function handleDiscordComponentEvent(params: {
replyOpts, replyOpts,
componentLabel: params.componentLabel, componentLabel: params.componentLabel,
unauthorizedReply, unauthorizedReply,
allowNameMatching: params.ctx.discordConfig?.dangerouslyAllowNameMatching === true,
}); });
if (!componentAllowed) { if (!componentAllowed) {
return; return;
@@ -1125,6 +1134,7 @@ async function handleDiscordModalTrigger(params: {
replyOpts, replyOpts,
componentLabel: "form", componentLabel: "form",
unauthorizedReply, unauthorizedReply,
allowNameMatching: params.ctx.discordConfig?.dangerouslyAllowNameMatching === true,
}); });
if (!memberAllowed) { if (!memberAllowed) {
return; return;
@@ -1137,6 +1147,7 @@ async function handleDiscordModalTrigger(params: {
replyOpts, replyOpts,
componentLabel: "form", componentLabel: "form",
unauthorizedReply, unauthorizedReply,
allowNameMatching: params.ctx.discordConfig?.dangerouslyAllowNameMatching === true,
}); });
if (!componentAllowed) { if (!componentAllowed) {
return; return;
@@ -1572,6 +1583,7 @@ class DiscordComponentModal extends Modal {
replyOpts, replyOpts,
componentLabel: "form", componentLabel: "form",
unauthorizedReply: "You are not authorized to use this form.", unauthorizedReply: "You are not authorized to use this form.",
allowNameMatching: this.ctx.discordConfig?.dangerouslyAllowNameMatching === true,
}); });
if (!memberAllowed) { if (!memberAllowed) {
return; return;

View File

@@ -98,6 +98,7 @@ export function normalizeDiscordSlug(value: string) {
export function allowListMatches( export function allowListMatches(
list: DiscordAllowList, list: DiscordAllowList,
candidate: { id?: string; name?: string; tag?: string }, candidate: { id?: string; name?: string; tag?: string },
params?: { allowNameMatching?: boolean },
) { ) {
if (list.allowAll) { if (list.allowAll) {
return true; return true;
@@ -105,12 +106,14 @@ export function allowListMatches(
if (candidate.id && list.ids.has(candidate.id)) { if (candidate.id && list.ids.has(candidate.id)) {
return true; return true;
} }
const slug = candidate.name ? normalizeDiscordSlug(candidate.name) : ""; if (params?.allowNameMatching === true) {
if (slug && list.names.has(slug)) { const slug = candidate.name ? normalizeDiscordSlug(candidate.name) : "";
return true; if (slug && list.names.has(slug)) {
} return true;
if (candidate.tag && list.names.has(normalizeDiscordSlug(candidate.tag))) { }
return true; if (candidate.tag && list.names.has(normalizeDiscordSlug(candidate.tag))) {
return true;
}
} }
return false; return false;
} }
@@ -118,6 +121,7 @@ export function allowListMatches(
export function resolveDiscordAllowListMatch(params: { export function resolveDiscordAllowListMatch(params: {
allowList: DiscordAllowList; allowList: DiscordAllowList;
candidate: { id?: string; name?: string; tag?: string }; candidate: { id?: string; name?: string; tag?: string };
allowNameMatching?: boolean;
}): DiscordAllowListMatch { }): DiscordAllowListMatch {
const { allowList, candidate } = params; const { allowList, candidate } = params;
if (allowList.allowAll) { if (allowList.allowAll) {
@@ -126,13 +130,15 @@ export function resolveDiscordAllowListMatch(params: {
if (candidate.id && allowList.ids.has(candidate.id)) { if (candidate.id && allowList.ids.has(candidate.id)) {
return { allowed: true, matchKey: candidate.id, matchSource: "id" }; return { allowed: true, matchKey: candidate.id, matchSource: "id" };
} }
const nameSlug = candidate.name ? normalizeDiscordSlug(candidate.name) : ""; if (params.allowNameMatching === true) {
if (nameSlug && allowList.names.has(nameSlug)) { const nameSlug = candidate.name ? normalizeDiscordSlug(candidate.name) : "";
return { allowed: true, matchKey: nameSlug, matchSource: "name" }; if (nameSlug && allowList.names.has(nameSlug)) {
} return { allowed: true, matchKey: nameSlug, matchSource: "name" };
const tagSlug = candidate.tag ? normalizeDiscordSlug(candidate.tag) : ""; }
if (tagSlug && allowList.names.has(tagSlug)) { const tagSlug = candidate.tag ? normalizeDiscordSlug(candidate.tag) : "";
return { allowed: true, matchKey: tagSlug, matchSource: "tag" }; if (tagSlug && allowList.names.has(tagSlug)) {
return { allowed: true, matchKey: tagSlug, matchSource: "tag" };
}
} }
return { allowed: false }; return { allowed: false };
} }
@@ -142,16 +148,21 @@ export function resolveDiscordUserAllowed(params: {
userId: string; userId: string;
userName?: string; userName?: string;
userTag?: string; userTag?: string;
allowNameMatching?: boolean;
}) { }) {
const allowList = normalizeDiscordAllowList(params.allowList, ["discord:", "user:", "pk:"]); const allowList = normalizeDiscordAllowList(params.allowList, ["discord:", "user:", "pk:"]);
if (!allowList) { if (!allowList) {
return true; return true;
} }
return allowListMatches(allowList, { return allowListMatches(
id: params.userId, allowList,
name: params.userName, {
tag: params.userTag, id: params.userId,
}); name: params.userName,
tag: params.userTag,
},
{ allowNameMatching: params.allowNameMatching },
);
} }
export function resolveDiscordRoleAllowed(params: { export function resolveDiscordRoleAllowed(params: {
@@ -176,6 +187,7 @@ export function resolveDiscordMemberAllowed(params: {
userId: string; userId: string;
userName?: string; userName?: string;
userTag?: string; userTag?: string;
allowNameMatching?: boolean;
}) { }) {
const hasUserRestriction = Array.isArray(params.userAllowList) && params.userAllowList.length > 0; const hasUserRestriction = Array.isArray(params.userAllowList) && params.userAllowList.length > 0;
const hasRoleRestriction = Array.isArray(params.roleAllowList) && params.roleAllowList.length > 0; const hasRoleRestriction = Array.isArray(params.roleAllowList) && params.roleAllowList.length > 0;
@@ -188,6 +200,7 @@ export function resolveDiscordMemberAllowed(params: {
userId: params.userId, userId: params.userId,
userName: params.userName, userName: params.userName,
userTag: params.userTag, userTag: params.userTag,
allowNameMatching: params.allowNameMatching,
}) })
: false; : false;
const roleOk = hasRoleRestriction const roleOk = hasRoleRestriction
@@ -204,6 +217,7 @@ export function resolveDiscordMemberAccessState(params: {
guildInfo?: DiscordGuildEntryResolved | null; guildInfo?: DiscordGuildEntryResolved | null;
memberRoleIds: string[]; memberRoleIds: string[];
sender: { id: string; name?: string; tag?: string }; sender: { id: string; name?: string; tag?: string };
allowNameMatching?: boolean;
}) { }) {
const channelUsers = params.channelConfig?.users ?? params.guildInfo?.users; const channelUsers = params.channelConfig?.users ?? params.guildInfo?.users;
const channelRoles = params.channelConfig?.roles ?? params.guildInfo?.roles; const channelRoles = params.channelConfig?.roles ?? params.guildInfo?.roles;
@@ -217,6 +231,7 @@ export function resolveDiscordMemberAccessState(params: {
userId: params.sender.id, userId: params.sender.id,
userName: params.sender.name, userName: params.sender.name,
userTag: params.sender.tag, userTag: params.sender.tag,
allowNameMatching: params.allowNameMatching,
}); });
return { channelUsers, channelRoles, hasAccessRestrictions, memberAllowed } as const; return { channelUsers, channelRoles, hasAccessRestrictions, memberAllowed } as const;
} }
@@ -225,6 +240,7 @@ export function resolveDiscordOwnerAllowFrom(params: {
channelConfig?: DiscordChannelConfigResolved | null; channelConfig?: DiscordChannelConfigResolved | null;
guildInfo?: DiscordGuildEntryResolved | null; guildInfo?: DiscordGuildEntryResolved | null;
sender: { id: string; name?: string; tag?: string }; sender: { id: string; name?: string; tag?: string };
allowNameMatching?: boolean;
}): string[] | undefined { }): string[] | undefined {
const rawAllowList = params.channelConfig?.users ?? params.guildInfo?.users; const rawAllowList = params.channelConfig?.users ?? params.guildInfo?.users;
if (!Array.isArray(rawAllowList) || rawAllowList.length === 0) { if (!Array.isArray(rawAllowList) || rawAllowList.length === 0) {
@@ -241,6 +257,7 @@ export function resolveDiscordOwnerAllowFrom(params: {
name: params.sender.name, name: params.sender.name,
tag: params.sender.tag, tag: params.sender.tag,
}, },
allowNameMatching: params.allowNameMatching,
}); });
if (!match.allowed || !match.matchKey || match.matchKey === "*") { if (!match.allowed || !match.matchKey || match.matchKey === "*") {
return undefined; return undefined;
@@ -253,6 +270,7 @@ export function resolveDiscordCommandAuthorized(params: {
allowFrom?: string[]; allowFrom?: string[];
guildInfo?: DiscordGuildEntryResolved | null; guildInfo?: DiscordGuildEntryResolved | null;
author: User; author: User;
allowNameMatching?: boolean;
}) { }) {
if (!params.isDirectMessage) { if (!params.isDirectMessage) {
return true; return true;
@@ -261,11 +279,15 @@ export function resolveDiscordCommandAuthorized(params: {
if (!allowList) { if (!allowList) {
return true; return true;
} }
return allowListMatches(allowList, { return allowListMatches(
id: params.author.id, allowList,
name: params.author.username, {
tag: formatDiscordUserTag(params.author), id: params.author.id,
}); name: params.author.username,
tag: formatDiscordUserTag(params.author),
},
{ allowNameMatching: params.allowNameMatching },
);
} }
export function resolveDiscordGuildEntry(params: { export function resolveDiscordGuildEntry(params: {
@@ -501,6 +523,7 @@ export function shouldEmitDiscordReactionNotification(params: {
userName?: string; userName?: string;
userTag?: string; userTag?: string;
allowlist?: string[]; allowlist?: string[];
allowNameMatching?: boolean;
}) { }) {
const mode = params.mode ?? "own"; const mode = params.mode ?? "own";
if (mode === "off") { if (mode === "off") {
@@ -517,11 +540,15 @@ export function shouldEmitDiscordReactionNotification(params: {
if (!list) { if (!list) {
return false; return false;
} }
return allowListMatches(list, { return allowListMatches(
id: params.userId, list,
name: params.userName, {
tag: params.userTag, id: params.userId,
}); name: params.userName,
tag: params.userTag,
},
{ allowNameMatching: params.allowNameMatching },
);
} }
return false; return false;
} }

View File

@@ -37,6 +37,7 @@ type DiscordReactionListenerParams = {
accountId: string; accountId: string;
runtime: RuntimeEnv; runtime: RuntimeEnv;
botUserId?: string; botUserId?: string;
allowNameMatching: boolean;
guildEntries?: Record<string, import("./allow-list.js").DiscordGuildEntryResolved>; guildEntries?: Record<string, import("./allow-list.js").DiscordGuildEntryResolved>;
logger: Logger; logger: Logger;
}; };
@@ -178,6 +179,7 @@ async function runDiscordReactionHandler(params: {
cfg: params.handlerParams.cfg, cfg: params.handlerParams.cfg,
accountId: params.handlerParams.accountId, accountId: params.handlerParams.accountId,
botUserId: params.handlerParams.botUserId, botUserId: params.handlerParams.botUserId,
allowNameMatching: params.handlerParams.allowNameMatching,
guildEntries: params.handlerParams.guildEntries, guildEntries: params.handlerParams.guildEntries,
logger: params.handlerParams.logger, logger: params.handlerParams.logger,
}), }),
@@ -191,6 +193,7 @@ async function handleDiscordReactionEvent(params: {
cfg: LoadedConfig; cfg: LoadedConfig;
accountId: string; accountId: string;
botUserId?: string; botUserId?: string;
allowNameMatching: boolean;
guildEntries?: Record<string, import("./allow-list.js").DiscordGuildEntryResolved>; guildEntries?: Record<string, import("./allow-list.js").DiscordGuildEntryResolved>;
logger: Logger; logger: Logger;
}) { }) {
@@ -292,6 +295,7 @@ async function handleDiscordReactionEvent(params: {
userName: user.username, userName: user.username,
userTag: formatDiscordUserTag(user), userTag: formatDiscordUserTag(user),
allowlist: guildInfo?.users, allowlist: guildInfo?.users,
allowNameMatching: params.allowNameMatching,
}); });
const emitReactionWithAuthor = (message: { author?: User } | null) => { const emitReactionWithAuthor = (message: { author?: User } | null) => {
const { baseText } = resolveReactionBase(); const { baseText } = resolveReactionBase();

View File

@@ -190,6 +190,7 @@ export async function preflightDiscordMessage(
name: sender.name, name: sender.name,
tag: sender.tag, tag: sender.tag,
}, },
allowNameMatching: params.discordConfig?.dangerouslyAllowNameMatching === true,
}) })
: { allowed: false }; : { allowed: false };
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
@@ -563,6 +564,7 @@ export async function preflightDiscordMessage(
guildInfo, guildInfo,
memberRoleIds, memberRoleIds,
sender, sender,
allowNameMatching: params.discordConfig?.dangerouslyAllowNameMatching === true,
}); });
if (!isDirectMessage) { if (!isDirectMessage) {
@@ -572,11 +574,15 @@ export async function preflightDiscordMessage(
"pk:", "pk:",
]); ]);
const ownerOk = ownerAllowList const ownerOk = ownerAllowList
? allowListMatches(ownerAllowList, { ? allowListMatches(
id: sender.id, ownerAllowList,
name: sender.name, {
tag: sender.tag, id: sender.id,
}) name: sender.name,
tag: sender.tag,
},
{ allowNameMatching: params.discordConfig?.dangerouslyAllowNameMatching === true },
)
: false; : false;
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
const commandGate = resolveControlCommandGate({ const commandGate = resolveControlCommandGate({

View File

@@ -199,6 +199,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
channelConfig, channelConfig,
guildInfo, guildInfo,
sender: { id: sender.id, name: sender.name, tag: sender.tag }, sender: { id: sender.id, name: sender.name, tag: sender.tag },
allowNameMatching: discordConfig?.dangerouslyAllowNameMatching === true,
}); });
const storePath = resolveStorePath(cfg.session?.store, { const storePath = resolveStorePath(cfg.session?.store, {
agentId: route.agentId, agentId: route.agentId,

View File

@@ -170,6 +170,7 @@ describe("agent components", () => {
const select = createAgentSelectMenu({ const select = createAgentSelectMenu({
cfg: createCfg(), cfg: createCfg(),
accountId: "default", accountId: "default",
discordConfig: { dangerouslyAllowNameMatching: true } as DiscordAccountConfig,
dmPolicy: "allowlist", dmPolicy: "allowlist",
allowFrom: ["Alice#1234"], allowFrom: ["Alice#1234"],
}); });
@@ -426,13 +427,20 @@ describe("resolveDiscordOwnerAllowFrom", () => {
expect(result).toEqual(["123"]); expect(result).toEqual(["123"]);
}); });
it("returns the normalized name slug for name matches", () => { it("returns the normalized name slug for name matches only when enabled", () => {
const result = resolveDiscordOwnerAllowFrom({ const defaultResult = resolveDiscordOwnerAllowFrom({
channelConfig: { allowed: true, users: ["Some User"] } as DiscordChannelConfigResolved, channelConfig: { allowed: true, users: ["Some User"] } as DiscordChannelConfigResolved,
sender: { id: "999", name: "Some User" }, sender: { id: "999", name: "Some User" },
}); });
expect(defaultResult).toBeUndefined();
expect(result).toEqual(["some-user"]); const enabledResult = resolveDiscordOwnerAllowFrom({
channelConfig: { allowed: true, users: ["Some User"] } as DiscordChannelConfigResolved,
sender: { id: "999", name: "Some User" },
allowNameMatching: true,
});
expect(enabledResult).toEqual(["some-user"]);
}); });
}); });

View File

@@ -1276,11 +1276,15 @@ async function dispatchDiscordCommandInteraction(params: {
); );
const ownerOk = const ownerOk =
ownerAllowList && user ownerAllowList && user
? allowListMatches(ownerAllowList, { ? allowListMatches(
id: sender.id, ownerAllowList,
name: sender.name, {
tag: sender.tag, id: sender.id,
}) name: sender.name,
tag: sender.tag,
},
{ allowNameMatching: discordConfig?.dangerouslyAllowNameMatching === true },
)
: false; : false;
const guildInfo = resolveDiscordGuildEntry({ const guildInfo = resolveDiscordGuildEntry({
guild: interaction.guild ?? undefined, guild: interaction.guild ?? undefined,
@@ -1363,11 +1367,15 @@ async function dispatchDiscordCommandInteraction(params: {
]; ];
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]); const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]);
const permitted = allowList const permitted = allowList
? allowListMatches(allowList, { ? allowListMatches(
id: sender.id, allowList,
name: sender.name, {
tag: sender.tag, id: sender.id,
}) name: sender.name,
tag: sender.tag,
},
{ allowNameMatching: discordConfig?.dangerouslyAllowNameMatching === true },
)
: false; : false;
if (!permitted) { if (!permitted) {
commandAuthorized = false; commandAuthorized = false;
@@ -1404,6 +1412,7 @@ async function dispatchDiscordCommandInteraction(params: {
guildInfo, guildInfo,
memberRoleIds, memberRoleIds,
sender, sender,
allowNameMatching: discordConfig?.dangerouslyAllowNameMatching === true,
}); });
const authorizers = useAccessGroups const authorizers = useAccessGroups
? [ ? [
@@ -1509,6 +1518,7 @@ async function dispatchDiscordCommandInteraction(params: {
channelConfig, channelConfig,
guildInfo, guildInfo,
sender: { id: sender.id, name: sender.name, tag: sender.tag }, sender: { id: sender.id, name: sender.name, tag: sender.tag },
allowNameMatching: discordConfig?.dangerouslyAllowNameMatching === true,
}); });
const ctxPayload = finalizeInboundContext({ const ctxPayload = finalizeInboundContext({
Body: prompt, Body: prompt,

View File

@@ -559,6 +559,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
accountId: account.accountId, accountId: account.accountId,
runtime, runtime,
botUserId, botUserId,
allowNameMatching: discordCfg.dangerouslyAllowNameMatching === true,
guildEntries, guildEntries,
logger, logger,
}), }),
@@ -570,6 +571,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
accountId: account.accountId, accountId: account.accountId,
runtime, runtime,
botUserId, botUserId,
allowNameMatching: discordCfg.dangerouslyAllowNameMatching === true,
guildEntries, guildEntries,
logger, logger,
}), }),

View File

@@ -156,6 +156,7 @@ async function authorizeVoiceCommand(
guildInfo, guildInfo,
memberRoleIds, memberRoleIds,
sender, sender,
allowNameMatching: params.discordConfig?.dangerouslyAllowNameMatching === true,
}); });
const ownerAllowList = normalizeDiscordAllowList( const ownerAllowList = normalizeDiscordAllowList(
@@ -163,11 +164,15 @@ async function authorizeVoiceCommand(
["discord:", "user:", "pk:"], ["discord:", "user:", "pk:"],
); );
const ownerOk = ownerAllowList const ownerOk = ownerAllowList
? allowListMatches(ownerAllowList, { ? allowListMatches(
id: sender.id, ownerAllowList,
name: sender.name, {
tag: sender.tag, id: sender.id,
}) name: sender.name,
tag: sender.tag,
},
{ allowNameMatching: params.discordConfig?.dangerouslyAllowNameMatching === true },
)
: false; : false;
const authorizers = params.useAccessGroups const authorizers = params.useAccessGroups

View File

@@ -178,10 +178,25 @@ export async function collectChannelSecurityFindings(params: {
continue; continue;
} }
const accountConfig = (account as { config?: Record<string, unknown> } | null | undefined)
?.config;
if (accountConfig?.dangerouslyAllowNameMatching === true) {
findings.push({
checkId: `channels.${plugin.id}.allowFrom.dangerous_name_matching_enabled`,
severity: "info",
title: `${plugin.meta.label ?? plugin.id} dangerous name matching is enabled`,
detail:
"dangerouslyAllowNameMatching=true re-enables mutable name/email/tag matching for sender authorization. This is a break-glass compatibility mode, not a hardened default.",
remediation:
"Prefer stable sender IDs in allowlists, then disable dangerouslyAllowNameMatching.",
});
}
if (plugin.id === "discord") { if (plugin.id === "discord") {
const discordCfg = const discordCfg =
(account as { config?: Record<string, unknown> } | null)?.config ?? (account as { config?: Record<string, unknown> } | null)?.config ??
({} as Record<string, unknown>); ({} as Record<string, unknown>);
const dangerousNameMatchingEnabled = discordCfg.dangerouslyAllowNameMatching === true;
const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []); const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []);
const discordNameBasedAllowEntries = new Set<string>(); const discordNameBasedAllowEntries = new Set<string>();
addDiscordNameBasedEntries({ addDiscordNameBasedEntries({
@@ -236,13 +251,18 @@ export async function collectChannelSecurityFindings(params: {
: ""; : "";
findings.push({ findings.push({
checkId: "channels.discord.allowFrom.name_based_entries", checkId: "channels.discord.allowFrom.name_based_entries",
severity: "warn", severity: dangerousNameMatchingEnabled ? "info" : "warn",
title: "Discord allowlist contains name or tag entries", title: dangerousNameMatchingEnabled
detail: ? "Discord allowlist uses break-glass name/tag matching"
"Discord name/tag allowlist matching uses normalized slugs and can collide across users. " + : "Discord allowlist contains name or tag entries",
`Found: ${examples.join(", ")}${more}.`, detail: dangerousNameMatchingEnabled
remediation: ? "Discord name/tag allowlist matching is explicitly enabled via dangerouslyAllowNameMatching. This mutable-identity mode is operator-selected break-glass behavior and out-of-scope for vulnerability reports by itself. " +
"Prefer stable Discord IDs (or <@id>/user:<id>/pk:<id>) in channels.discord.allowFrom and channels.discord.guilds.*.users.", `Found: ${examples.join(", ")}${more}.`
: "Discord name/tag allowlist matching uses normalized slugs and can collide across users. " +
`Found: ${examples.join(", ")}${more}.`,
remediation: dangerousNameMatchingEnabled
? "Prefer stable Discord IDs (or <@id>/user:<id>/pk:<id>), then disable dangerouslyAllowNameMatching."
: "Prefer stable Discord IDs (or <@id>/user:<id>/pk:<id>) in channels.discord.allowFrom and channels.discord.guilds.*.users, or explicitly opt in with dangerouslyAllowNameMatching=true if you accept the risk.",
}); });
} }
const nativeEnabled = resolveNativeCommandsEnabled({ const nativeEnabled = resolveNativeCommandsEnabled({

View File

@@ -1500,6 +1500,43 @@ describe("security audit", () => {
}); });
}); });
it("marks Discord name-based allowlists as break-glass when dangerous matching is enabled", async () => {
await withChannelSecurityStateDir(async () => {
const cfg: OpenClawConfig = {
channels: {
discord: {
enabled: true,
token: "t",
dangerouslyAllowNameMatching: true,
allowFrom: ["Alice#1234"],
},
},
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: true,
plugins: [discordPlugin],
});
const finding = res.findings.find(
(entry) => entry.checkId === "channels.discord.allowFrom.name_based_entries",
);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("info");
expect(finding?.detail).toContain("out-of-scope");
expect(res.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "channels.discord.allowFrom.dangerous_name_matching_enabled",
severity: "info",
}),
]),
);
});
});
it("does not warn when Discord allowlists use ID-style entries only", async () => { it("does not warn when Discord allowlists use ID-style entries only", async () => {
await withChannelSecurityStateDir(async () => { await withChannelSecurityStateDir(async () => {
const cfg: OpenClawConfig = { const cfg: OpenClawConfig = {

View File

@@ -15,7 +15,7 @@ describe("slack/allow-list", () => {
expect(normalizeSlackSlug(" #Ops.Room ")).toBe("#ops.room"); expect(normalizeSlackSlug(" #Ops.Room ")).toBe("#ops.room");
}); });
it("matches wildcard, id, and prefixed name candidates", () => { it("matches wildcard and id candidates by default", () => {
expect(resolveSlackAllowListMatch({ allowList: ["*"], id: "u1", name: "alice" })).toEqual({ expect(resolveSlackAllowListMatch({ allowList: ["*"], id: "u1", name: "alice" })).toEqual({
allowed: true, allowed: true,
matchKey: "*", matchKey: "*",
@@ -40,6 +40,15 @@ describe("slack/allow-list", () => {
id: "u2", id: "u2",
name: "alice", name: "alice",
}), }),
).toEqual({ allowed: false });
expect(
resolveSlackAllowListMatch({
allowList: ["slack:alice"],
id: "u2",
name: "alice",
allowNameMatching: true,
}),
).toEqual({ ).toEqual({
allowed: true, allowed: true,
matchKey: "slack:alice", matchKey: "slack:alice",

View File

@@ -25,6 +25,7 @@ export function resolveSlackAllowListMatch(params: {
allowList: string[]; allowList: string[];
id?: string; id?: string;
name?: string; name?: string;
allowNameMatching?: boolean;
}): SlackAllowListMatch { }): SlackAllowListMatch {
const allowList = params.allowList; const allowList = params.allowList;
if (allowList.length === 0) { if (allowList.length === 0) {
@@ -40,9 +41,13 @@ export function resolveSlackAllowListMatch(params: {
{ value: id, source: "id" }, { value: id, source: "id" },
{ value: id ? `slack:${id}` : undefined, source: "prefixed-id" }, { value: id ? `slack:${id}` : undefined, source: "prefixed-id" },
{ value: id ? `user:${id}` : undefined, source: "prefixed-user" }, { value: id ? `user:${id}` : undefined, source: "prefixed-user" },
{ value: name, source: "name" }, ...(params.allowNameMatching === true
{ value: name ? `slack:${name}` : undefined, source: "prefixed-name" }, ? ([
{ value: slug, source: "slug" }, { value: name, source: "name" as const },
{ value: name ? `slack:${name}` : undefined, source: "prefixed-name" as const },
{ value: slug, source: "slug" as const },
] satisfies Array<{ value?: string; source: SlackAllowListMatch["matchSource"] }>)
: []),
]; ];
for (const candidate of candidates) { for (const candidate of candidates) {
if (!candidate.value) { if (!candidate.value) {
@@ -59,7 +64,12 @@ export function resolveSlackAllowListMatch(params: {
return { allowed: false }; return { allowed: false };
} }
export function allowListMatches(params: { allowList: string[]; id?: string; name?: string }) { export function allowListMatches(params: {
allowList: string[];
id?: string;
name?: string;
allowNameMatching?: boolean;
}) {
return resolveSlackAllowListMatch(params).allowed; return resolveSlackAllowListMatch(params).allowed;
} }
@@ -67,6 +77,7 @@ export function resolveSlackUserAllowed(params: {
allowList?: Array<string | number>; allowList?: Array<string | number>;
userId?: string; userId?: string;
userName?: string; userName?: string;
allowNameMatching?: boolean;
}) { }) {
const allowList = normalizeAllowListLower(params.allowList); const allowList = normalizeAllowListLower(params.allowList);
if (allowList.length === 0) { if (allowList.length === 0) {
@@ -76,5 +87,6 @@ export function resolveSlackUserAllowed(params: {
allowList, allowList,
id: params.userId, id: params.userId,
name: params.userName, name: params.userName,
allowNameMatching: params.allowNameMatching,
}); });
} }

View File

@@ -14,14 +14,16 @@ export function isSlackSenderAllowListed(params: {
allowListLower: string[]; allowListLower: string[];
senderId: string; senderId: string;
senderName?: string; senderName?: string;
allowNameMatching?: boolean;
}) { }) {
const { allowListLower, senderId, senderName } = params; const { allowListLower, senderId, senderName, allowNameMatching } = params;
return ( return (
allowListLower.length === 0 || allowListLower.length === 0 ||
allowListMatches({ allowListMatches({
allowList: allowListLower, allowList: allowListLower,
id: senderId, id: senderId,
name: senderName, name: senderName,
allowNameMatching,
}) })
); );
} }

View File

@@ -47,6 +47,7 @@ export function shouldEmitSlackReactionNotification(params: {
userId: string; userId: string;
userName?: string | null; userName?: string | null;
allowlist?: Array<string | number> | null; allowlist?: Array<string | number> | null;
allowNameMatching?: boolean;
}) { }) {
const { mode, botId, messageAuthorId, userId, userName, allowlist } = params; const { mode, botId, messageAuthorId, userId, userName, allowlist } = params;
const effectiveMode = mode ?? "own"; const effectiveMode = mode ?? "own";
@@ -68,6 +69,7 @@ export function shouldEmitSlackReactionNotification(params: {
allowList: users, allowList: users,
id: userId, id: userId,
name: userName ?? undefined, name: userName ?? undefined,
allowNameMatching: params.allowNameMatching,
}); });
} }
return true; return true;

View File

@@ -68,6 +68,7 @@ export type SlackMonitorContext = {
dmEnabled: boolean; dmEnabled: boolean;
dmPolicy: DmPolicy; dmPolicy: DmPolicy;
allowFrom: string[]; allowFrom: string[];
allowNameMatching: boolean;
groupDmEnabled: boolean; groupDmEnabled: boolean;
groupDmChannels: string[]; groupDmChannels: string[];
defaultRequireMention: boolean; defaultRequireMention: boolean;
@@ -129,6 +130,7 @@ export function createSlackMonitorContext(params: {
dmEnabled: boolean; dmEnabled: boolean;
dmPolicy: DmPolicy; dmPolicy: DmPolicy;
allowFrom: Array<string | number> | undefined; allowFrom: Array<string | number> | undefined;
allowNameMatching: boolean;
groupDmEnabled: boolean; groupDmEnabled: boolean;
groupDmChannels: Array<string | number> | undefined; groupDmChannels: Array<string | number> | undefined;
defaultRequireMention?: boolean; defaultRequireMention?: boolean;
@@ -391,6 +393,7 @@ export function createSlackMonitorContext(params: {
dmEnabled: params.dmEnabled, dmEnabled: params.dmEnabled,
dmPolicy: params.dmPolicy, dmPolicy: params.dmPolicy,
allowFrom, allowFrom,
allowNameMatching: params.allowNameMatching,
groupDmEnabled: params.groupDmEnabled, groupDmEnabled: params.groupDmEnabled,
groupDmChannels, groupDmChannels,
defaultRequireMention, defaultRequireMention,

View File

@@ -60,6 +60,7 @@ describe("slack prepareSlackMessage inbound contract", () => {
dmEnabled: true, dmEnabled: true,
dmPolicy: "open", dmPolicy: "open",
allowFrom: [], allowFrom: [],
allowNameMatching: false,
groupDmEnabled: true, groupDmEnabled: true,
groupDmChannels: [], groupDmChannels: [],
defaultRequireMention: params.defaultRequireMention ?? true, defaultRequireMention: params.defaultRequireMention ?? true,

View File

@@ -142,6 +142,7 @@ export async function prepareSlackMessage(params: {
const allowMatch = resolveSlackAllowListMatch({ const allowMatch = resolveSlackAllowListMatch({
allowList: allowFromLower, allowList: allowFromLower,
id: directUserId, id: directUserId,
allowNameMatching: ctx.allowNameMatching,
}); });
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
if (!allowMatch.allowed) { if (!allowMatch.allowed) {
@@ -244,6 +245,7 @@ export async function prepareSlackMessage(params: {
allowList: channelConfig?.users, allowList: channelConfig?.users,
userId: senderId, userId: senderId,
userName: senderName, userName: senderName,
allowNameMatching: ctx.allowNameMatching,
}) })
: true; : true;
if (isRoom && !channelUserAuthorized) { if (isRoom && !channelUserAuthorized) {
@@ -263,6 +265,7 @@ export async function prepareSlackMessage(params: {
allowList: allowFromLower, allowList: allowFromLower,
id: senderId, id: senderId,
name: senderName, name: senderName,
allowNameMatching: ctx.allowNameMatching,
}).allowed; }).allowed;
const channelUsersAllowlistConfigured = const channelUsersAllowlistConfigured =
isRoom && Array.isArray(channelConfig?.users) && channelConfig.users.length > 0; isRoom && Array.isArray(channelConfig?.users) && channelConfig.users.length > 0;
@@ -272,6 +275,7 @@ export async function prepareSlackMessage(params: {
allowList: channelConfig?.users, allowList: channelConfig?.users,
userId: senderId, userId: senderId,
userName: senderName, userName: senderName,
allowNameMatching: ctx.allowNameMatching,
}) })
: false; : false;
const commandGate = resolveControlCommandGate({ const commandGate = resolveControlCommandGate({

View File

@@ -77,6 +77,7 @@ const baseParams = () => ({
dmEnabled: true, dmEnabled: true,
dmPolicy: "open" as const, dmPolicy: "open" as const,
allowFrom: [], allowFrom: [],
allowNameMatching: false,
groupDmEnabled: true, groupDmEnabled: true,
groupDmChannels: [], groupDmChannels: [],
defaultRequireMention: true, defaultRequireMention: true,

View File

@@ -210,6 +210,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
dmEnabled, dmEnabled,
dmPolicy, dmPolicy,
allowFrom, allowFrom,
allowNameMatching: slackCfg.dangerouslyAllowNameMatching === true,
groupDmEnabled, groupDmEnabled,
groupDmChannels, groupDmChannels,
defaultRequireMention: slackCfg.requireMention, defaultRequireMention: slackCfg.requireMention,

View File

@@ -361,6 +361,7 @@ export async function registerSlackMonitorSlashCommands(params: {
allowList: effectiveAllowFromLower, allowList: effectiveAllowFromLower,
id: command.user_id, id: command.user_id,
name: senderName, name: senderName,
allowNameMatching: ctx.allowNameMatching,
}); });
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
if (!allowMatch.allowed) { if (!allowMatch.allowed) {
@@ -446,6 +447,7 @@ export async function registerSlackMonitorSlashCommands(params: {
allowList: channelConfig?.users, allowList: channelConfig?.users,
userId: command.user_id, userId: command.user_id,
userName: senderName, userName: senderName,
allowNameMatching: ctx.allowNameMatching,
}) })
: false; : false;
if (channelUsersAllowlistConfigured && !channelUserAllowed) { if (channelUsersAllowlistConfigured && !channelUserAllowed) {
@@ -460,6 +462,7 @@ export async function registerSlackMonitorSlashCommands(params: {
allowList: effectiveAllowFromLower, allowList: effectiveAllowFromLower,
id: command.user_id, id: command.user_id,
name: senderName, name: senderName,
allowNameMatching: ctx.allowNameMatching,
}).allowed; }).allowed;
// DMs: allow chatting in dmPolicy=open, but keep privileged command gating intact by setting // DMs: allow chatting in dmPolicy=open, but keep privileged command gating intact by setting
// CommandAuthorized based on allowlists/access-groups (downstream decides which commands need it). // CommandAuthorized based on allowlists/access-groups (downstream decides which commands need it).