From 006e1352d8859e91fe436a95f7708be99579c162 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 12 Jan 2026 08:31:59 +0000 Subject: [PATCH] fix: harden msteams group access --- CHANGELOG.md | 1 + docs/concepts/groups.md | 12 ++++-- docs/gateway/configuration.md | 6 ++- docs/gateway/security.md | 2 +- docs/providers/msteams.md | 22 ++++++++++ src/config/config.test.ts | 10 +++++ src/config/types.ts | 9 +++++ src/config/zod-schema.ts | 2 + src/msteams/monitor-handler.ts | 48 +++++++++++++++++++++- src/msteams/policy.test.ts | 69 ++++++++++++++++++++++++++++++++ src/msteams/policy.ts | 23 +++++++++++ src/providers/plugins/msteams.ts | 9 +++++ 12 files changed, 206 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6176411e4..37078f584 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ - Models: normalize Gemini 3 Pro/Flash IDs to preview names for live model lookups. (#769) — thanks @steipete. - CLI: fix guardCancel typing for configure prompts. (#769) — thanks @steipete. - Providers: default groupPolicy to allowlist across providers and warn in doctor when groups are open. +- MS Teams: add groupPolicy/groupAllowFrom gating for group chats and warn when groups are open. - Gateway/WebChat: include handshake validation details in the WebSocket close reason for easier debugging; preserve close codes. - Gateway/Auth: send invalid connect responses before closing the handshake; stabilize invalid-connect auth test. - Gateway: tighten gateway listener detection. diff --git a/docs/concepts/groups.md b/docs/concepts/groups.md index 80d71b0de..b02e050c1 100644 --- a/docs/concepts/groups.md +++ b/docs/concepts/groups.md @@ -1,11 +1,11 @@ --- -summary: "Group chat behavior across surfaces (WhatsApp/Telegram/Discord/Slack/Signal/iMessage)" +summary: "Group chat behavior across surfaces (WhatsApp/Telegram/Discord/Slack/Signal/iMessage/Microsoft Teams)" read_when: - Changing group chat behavior or mention gating --- # Groups -Clawdbot treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, Slack, Signal, iMessage. +Clawdbot treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, Slack, Signal, iMessage, Microsoft Teams. ## Beginner intro (2 minutes) Clawdbot “lives” on your own messaging accounts. There is no separate WhatsApp bot user. @@ -15,7 +15,7 @@ Default behavior: - Groups are restricted (`groupPolicy: "allowlist"`). - Replies require a mention unless you explicitly disable mention gating. -Translation: anyone in the group can trigger Clawdbot by mentioning it. +Translation: allowlisted senders can trigger Clawdbot by mentioning it. > TL;DR > - **DM access** is controlled by `*.allowFrom`. @@ -71,6 +71,10 @@ Control how group/room messages are handled per provider: groupPolicy: "disabled", groupAllowFrom: ["chat_id:123"] }, + msteams: { + groupPolicy: "disabled", + groupAllowFrom: ["user@org.com"] + }, discord: { groupPolicy: "allowlist", guilds: { @@ -92,7 +96,7 @@ Control how group/room messages are handled per provider: Notes: - `groupPolicy` is separate from mention-gating (which requires @mentions). -- WhatsApp/Telegram/Signal/iMessage: use `groupAllowFrom` (fallback: explicit `allowFrom`). +- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams: use `groupAllowFrom` (fallback: explicit `allowFrom`). - Discord: allowlist uses `discord.guilds..channels`. - Slack: allowlist uses `slack.channels`. - Group DMs are controlled separately (`discord.dm.*`, `slack.dm.*`). diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 21046af13..3d82f475a 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -529,6 +529,10 @@ Use `*.groupPolicy` to control whether group/room messages are accepted at all: groupPolicy: "allowlist", groupAllowFrom: ["chat_id:123"] }, + msteams: { + groupPolicy: "allowlist", + groupAllowFrom: ["user@org.com"] + }, discord: { groupPolicy: "allowlist", guilds: { @@ -548,7 +552,7 @@ Notes: - `"open"`: groups bypass allowlists; mention-gating still applies. - `"disabled"`: block all group/room messages. - `"allowlist"`: only allow groups/rooms that match the configured allowlist. -- WhatsApp/Telegram/Signal/iMessage use `groupAllowFrom` (fallback: explicit `allowFrom`). +- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams use `groupAllowFrom` (fallback: explicit `allowFrom`). - Discord/Slack use channel allowlists (`discord.guilds.*.channels`, `slack.channels`). - Group DMs (Discord/Slack) are still controlled by `dm.groupEnabled` + `dm.groupChannels`. - Default is `groupPolicy: "allowlist"`; if no allowlist is configured, group messages are blocked. diff --git a/docs/gateway/security.md b/docs/gateway/security.md index a32899afc..84558571b 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -70,7 +70,7 @@ Clawdbot has two separate “who can trigger me?” layers: - **Group allowlist** (provider-specific): which groups/channels/guilds the bot will accept messages from at all. - Common patterns: - `whatsapp.groups`, `telegram.groups`, `imessage.groups`: per-group defaults like `requireMention`; when set, it also acts as a group allowlist (include `"*"` to keep allow-all behavior). - - `groupPolicy="allowlist"` + `groupAllowFrom`: restrict who can trigger the bot *inside* a group session (WhatsApp/Telegram/Signal/iMessage). + - `groupPolicy="allowlist"` + `groupAllowFrom`: restrict who can trigger the bot *inside* a group session (WhatsApp/Telegram/Signal/iMessage/Microsoft Teams). - `discord.guilds` / `slack.channels`: per-surface allowlists + mention defaults. Details: [Configuration](/gateway/configuration) and [Groups](/concepts/groups) diff --git a/docs/providers/msteams.md b/docs/providers/msteams.md index 0c08ffa01..416021220 100644 --- a/docs/providers/msteams.md +++ b/docs/providers/msteams.md @@ -30,12 +30,34 @@ Minimal config: } } ``` +Note: group chats are blocked by default (`msteams.groupPolicy: "allowlist"`). To allow group replies, set `msteams.groupAllowFrom` (or use `groupPolicy: "open"` to allow any member, mention-gated). ## Goals - Talk to Clawdbot via Teams DMs, group chats, or channels. - Keep routing deterministic: replies always go back to the provider they arrived on. - Default to safe channel behavior (mentions required unless configured otherwise). +## Access control (DMs + groups) + +**DM access** +- Default: `msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved. +- `msteams.allowFrom` accepts AAD object IDs or UPNs. + +**Group access** +- Default: `msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`). +- `msteams.groupAllowFrom` controls which senders can trigger in group chats/channels (falls back to `msteams.allowFrom`). +- Set `groupPolicy: "open"` to allow any member (still mention‑gated by default). + +Example: +```json5 +{ + msteams: { + groupPolicy: "allowlist", + groupAllowFrom: ["user@org.com"] + } +} +``` + ## How it works 1. Create an **Azure Bot** (App ID + secret + tenant ID). 2. Build a **Teams app package** that references the bot and includes the RSC permissions below. diff --git a/src/config/config.test.ts b/src/config/config.test.ts index e8aa90079..f271fd211 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -1481,6 +1481,16 @@ describe("legacy config detection", () => { } }); + it("defaults msteams.groupPolicy to allowlist when msteams section exists", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ msteams: {} }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.msteams?.groupPolicy).toBe("allowlist"); + } + }); + it("rejects unsafe executable config values", async () => { vi.resetModules(); const { validateConfigObject } = await import("./config.js"); diff --git a/src/config/types.ts b/src/config/types.ts index 89275345a..200e14b45 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -763,6 +763,15 @@ export type MSTeamsConfig = { dmPolicy?: DmPolicy; /** Allowlist for DM senders (AAD object IDs or UPNs). */ allowFrom?: Array; + /** Optional allowlist for group/channel senders (AAD object IDs or UPNs). */ + groupAllowFrom?: Array; + /** + * Controls how group/channel messages are handled: + * - "open": groups bypass allowFrom; mention-gating applies + * - "disabled": block all group messages + * - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom + */ + groupPolicy?: GroupPolicy; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; /** Merge streamed block replies before sending. */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 1733c6128..dd8405684 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -619,6 +619,8 @@ const MSTeamsConfigSchema = z .optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), allowFrom: z.array(z.string()).optional(), + groupAllowFrom: z.array(z.string()).optional(), + groupPolicy: GroupPolicySchema.optional().default("allowlist"), textChunkLimit: z.number().int().positive().optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), mediaAllowHosts: z.array(z.string()).optional(), diff --git a/src/msteams/monitor-handler.ts b/src/msteams/monitor-handler.ts index ced3ae986..129a6c77d 100644 --- a/src/msteams/monitor-handler.ts +++ b/src/msteams/monitor-handler.ts @@ -39,6 +39,7 @@ import { import type { MSTeamsAdapter } from "./messenger.js"; import type { MSTeamsMonitorLogger } from "./monitor-types.js"; import { + isMSTeamsGroupAllowed, resolveMSTeamsReplyPolicy, resolveMSTeamsRouteConfig, } from "./policy.js"; @@ -176,6 +177,9 @@ function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { const senderName = from.name ?? from.id; const senderId = from.aadObjectId ?? from.id; + const storedAllowFrom = await readProviderAllowFromStore("msteams").catch( + () => [], + ); // Check DM policy for direct messages if (isDirectMessage && msteamsCfg) { @@ -189,7 +193,6 @@ function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { if (dmPolicy !== "open") { // Check allowlist - look up from config and pairing store - const storedAllowFrom = await readProviderAllowFromStore("msteams"); const effectiveAllowFrom = [ ...allowFrom.map((v) => String(v).toLowerCase()), ...storedAllowFrom, @@ -225,6 +228,49 @@ function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { } } + if (!isDirectMessage && msteamsCfg) { + const groupPolicy = msteamsCfg.groupPolicy ?? "allowlist"; + const groupAllowFrom = + msteamsCfg.groupAllowFrom ?? + (msteamsCfg.allowFrom && msteamsCfg.allowFrom.length > 0 + ? msteamsCfg.allowFrom + : []); + const effectiveGroupAllowFrom = [ + ...groupAllowFrom.map((v) => String(v)), + ...storedAllowFrom, + ]; + + if (groupPolicy === "disabled") { + log.debug("dropping group message (groupPolicy: disabled)", { + conversationId, + }); + return; + } + + if (groupPolicy === "allowlist") { + if (effectiveGroupAllowFrom.length === 0) { + log.debug( + "dropping group message (groupPolicy: allowlist, no groupAllowFrom)", + { conversationId }, + ); + return; + } + const allowed = isMSTeamsGroupAllowed({ + groupPolicy, + allowFrom: effectiveGroupAllowFrom, + senderId, + senderName, + }); + if (!allowed) { + log.debug("dropping group message (not in groupAllowFrom)", { + sender: senderId, + label: senderName, + }); + return; + } + } + } + // Build conversation reference for proactive replies const agent = activity.recipient; const teamId = activity.channelData?.team?.id; diff --git a/src/msteams/policy.test.ts b/src/msteams/policy.test.ts index c0900ceb2..c36823062 100644 --- a/src/msteams/policy.test.ts +++ b/src/msteams/policy.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { MSTeamsConfig } from "../config/types.js"; import { + isMSTeamsGroupAllowed, resolveMSTeamsReplyPolicy, resolveMSTeamsRouteConfig, } from "./policy.js"; @@ -96,4 +97,72 @@ describe("msteams policy", () => { expect(policy).toEqual({ requireMention: false, replyStyle: "thread" }); }); }); + + describe("isMSTeamsGroupAllowed", () => { + it("allows when policy is open", () => { + expect( + isMSTeamsGroupAllowed({ + groupPolicy: "open", + allowFrom: [], + senderId: "user-id", + senderName: "User", + }), + ).toBe(true); + }); + + it("blocks when policy is disabled", () => { + expect( + isMSTeamsGroupAllowed({ + groupPolicy: "disabled", + allowFrom: ["user-id"], + senderId: "user-id", + senderName: "User", + }), + ).toBe(false); + }); + + it("blocks allowlist when empty", () => { + expect( + isMSTeamsGroupAllowed({ + groupPolicy: "allowlist", + allowFrom: [], + senderId: "user-id", + senderName: "User", + }), + ).toBe(false); + }); + + it("allows allowlist when sender matches", () => { + expect( + isMSTeamsGroupAllowed({ + groupPolicy: "allowlist", + allowFrom: ["User-Id"], + senderId: "user-id", + senderName: "User", + }), + ).toBe(true); + }); + + it("allows allowlist when sender name matches", () => { + expect( + isMSTeamsGroupAllowed({ + groupPolicy: "allowlist", + allowFrom: ["user"], + senderId: "other", + senderName: "User", + }), + ).toBe(true); + }); + + it("allows allowlist wildcard", () => { + expect( + isMSTeamsGroupAllowed({ + groupPolicy: "allowlist", + allowFrom: ["*"], + senderId: "other", + senderName: "User", + }), + ).toBe(true); + }); + }); }); diff --git a/src/msteams/policy.ts b/src/msteams/policy.ts index b96a83205..961f61f3f 100644 --- a/src/msteams/policy.ts +++ b/src/msteams/policy.ts @@ -1,4 +1,5 @@ import type { + GroupPolicy, MSTeamsChannelConfig, MSTeamsConfig, MSTeamsReplyStyle, @@ -56,3 +57,25 @@ export function resolveMSTeamsReplyPolicy(params: { return { requireMention, replyStyle }; } + +export function isMSTeamsGroupAllowed(params: { + groupPolicy: GroupPolicy; + allowFrom: Array; + senderId: string; + senderName?: string | null; +}): boolean { + const { groupPolicy } = params; + if (groupPolicy === "disabled") return false; + if (groupPolicy === "open") return true; + const allowFrom = params.allowFrom + .map((entry) => String(entry).trim().toLowerCase()) + .filter(Boolean); + if (allowFrom.length === 0) return false; + if (allowFrom.includes("*")) return true; + const senderId = params.senderId.toLowerCase(); + const senderName = params.senderName?.toLowerCase(); + return ( + allowFrom.includes(senderId) || + (senderName ? allowFrom.includes(senderName) : false) + ); +} diff --git a/src/providers/plugins/msteams.ts b/src/providers/plugins/msteams.ts index 56e74eb8e..5fc4b8641 100644 --- a/src/providers/plugins/msteams.ts +++ b/src/providers/plugins/msteams.ts @@ -80,6 +80,15 @@ export const msteamsPlugin: ProviderPlugin = { .filter(Boolean) .map((entry) => entry.toLowerCase()), }, + security: { + collectWarnings: ({ cfg }) => { + const groupPolicy = cfg.msteams?.groupPolicy ?? "allowlist"; + if (groupPolicy !== "open") return []; + return [ + `- MS Teams groups: groupPolicy="open" allows any member to trigger (mention-gated). Set msteams.groupPolicy="allowlist" + msteams.groupAllowFrom to restrict senders.`, + ]; + }, + }, setup: { resolveAccountId: () => DEFAULT_ACCOUNT_ID, applyAccountConfig: ({ cfg }) => ({