From c07949a99c1245b32280b04207d7673343a6a65b Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Sat, 24 Jan 2026 15:35:05 +1300 Subject: [PATCH] Channels: add per-group tool policies --- CHANGELOG.md | 1 + extensions/bluebubbles/src/channel.ts | 2 + extensions/bluebubbles/src/config-schema.ts | 3 +- extensions/bluebubbles/src/types.ts | 2 + extensions/discord/src/channel.ts | 2 + extensions/imessage/src/channel.ts | 2 + extensions/matrix/src/channel.ts | 3 +- extensions/matrix/src/config-schema.ts | 3 +- extensions/matrix/src/group-mentions.ts | 29 ++++- extensions/matrix/src/types.ts | 2 + extensions/msteams/src/channel.ts | 4 + extensions/msteams/src/policy.ts | 46 ++++++++ extensions/nextcloud-talk/src/channel.ts | 2 + .../nextcloud-talk/src/config-schema.ts | 2 + extensions/nextcloud-talk/src/policy.ts | 17 ++- extensions/nextcloud-talk/src/types.ts | 2 + extensions/slack/src/channel.ts | 2 + extensions/telegram/src/channel.ts | 2 + extensions/whatsapp/src/channel.ts | 2 + extensions/zalouser/src/channel.ts | 24 ++++ extensions/zalouser/src/config-schema.ts | 3 +- extensions/zalouser/src/types.ts | 4 +- src/agents/pi-embedded-runner/run.ts | 3 + src/agents/pi-embedded-runner/run/attempt.ts | 3 + src/agents/pi-embedded-runner/run/params.ts | 6 + src/agents/pi-embedded-runner/run/types.ts | 6 + src/agents/pi-tools-agent-config.test.ts | 64 +++++++++++ src/agents/pi-tools.policy.ts | 60 ++++++++++ src/agents/pi-tools.ts | 29 ++++- .../reply/agent-runner-execution.ts | 5 + src/auto-reply/reply/followup-runner.ts | 3 + src/auto-reply/reply/get-reply-run.ts | 4 + src/auto-reply/reply/queue/types.ts | 3 + src/channels/dock.ts | 10 ++ src/channels/plugins/group-mentions.ts | 106 +++++++++++++++++- src/channels/plugins/types.adapters.ts | 2 + src/config/group-policy.ts | 14 +++ src/config/types.discord.ts | 5 + src/config/types.imessage.ts | 2 + src/config/types.msteams.ts | 5 + src/config/types.slack.ts | 3 + src/config/types.telegram.ts | 3 + src/config/types.tools.ts | 5 + src/config/types.whatsapp.ts | 3 + src/config/zod-schema.providers-core.ts | 9 ++ src/config/zod-schema.providers-whatsapp.ts | 3 + src/plugin-sdk/index.ts | 8 ++ 47 files changed, 512 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04144cf55..f0ff4f6d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.clawd.bot - Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc. - Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0. - Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a. +- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt. ### Fixes - Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556) diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 5fcb75794..126a73131 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -10,6 +10,7 @@ import { normalizeAccountId, PAIRING_APPROVED_MESSAGE, resolveBlueBubblesGroupRequireMention, + resolveBlueBubblesGroupToolPolicy, setAccountEnabledInConfigSection, } from "clawdbot/plugin-sdk"; @@ -62,6 +63,7 @@ export const bluebubblesPlugin: ChannelPlugin = { }, groups: { resolveRequireMention: resolveBlueBubblesGroupRequireMention, + resolveToolPolicy: resolveBlueBubblesGroupToolPolicy, }, threading: { buildToolContext: ({ context, hasRepliedRef }) => ({ diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts index 9e2f6e50f..844641b94 100644 --- a/extensions/bluebubbles/src/config-schema.ts +++ b/extensions/bluebubbles/src/config-schema.ts @@ -1,4 +1,4 @@ -import { MarkdownConfigSchema } from "clawdbot/plugin-sdk"; +import { MarkdownConfigSchema, ToolPolicySchema } from "clawdbot/plugin-sdk"; import { z } from "zod"; const allowFromEntry = z.union([z.string(), z.number()]); @@ -21,6 +21,7 @@ const bluebubblesActionSchema = z const bluebubblesGroupConfigSchema = z.object({ requireMention: z.boolean().optional(), + tools: ToolPolicySchema, }); const bluebubblesAccountSchema = z.object({ diff --git a/extensions/bluebubbles/src/types.ts b/extensions/bluebubbles/src/types.ts index a4c975359..6b1da775b 100644 --- a/extensions/bluebubbles/src/types.ts +++ b/extensions/bluebubbles/src/types.ts @@ -4,6 +4,8 @@ export type GroupPolicy = "open" | "disabled" | "allowlist"; export type BlueBubblesGroupConfig = { /** If true, only respond in this group when mentioned. */ requireMention?: boolean; + /** Optional tool policy overrides for this group. */ + tools?: { allow?: string[]; deny?: string[] }; }; export type BlueBubblesAccountConfig = { diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 5775cea61..f2dea61d4 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -20,6 +20,7 @@ import { resolveDiscordAccount, resolveDefaultDiscordAccountId, resolveDiscordGroupRequireMention, + resolveDiscordGroupToolPolicy, setAccountEnabledInConfigSection, type ChannelMessageActionAdapter, type ChannelPlugin, @@ -144,6 +145,7 @@ export const discordPlugin: ChannelPlugin = { }, groups: { resolveRequireMention: resolveDiscordGroupRequireMention, + resolveToolPolicy: resolveDiscordGroupToolPolicy, }, mentions: { stripPatterns: () => ["<@!?\\d+>"], diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 30a82d612..d13341706 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -15,6 +15,7 @@ import { resolveDefaultIMessageAccountId, resolveIMessageAccount, resolveIMessageGroupRequireMention, + resolveIMessageGroupToolPolicy, setAccountEnabledInConfigSection, type ChannelPlugin, type ResolvedIMessageAccount, @@ -106,6 +107,7 @@ export const imessagePlugin: ChannelPlugin = { }, groups: { resolveRequireMention: resolveIMessageGroupRequireMention, + resolveToolPolicy: resolveIMessageGroupToolPolicy, }, messaging: { targetResolver: { diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 3e17f009b..909f3fac3 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -12,7 +12,7 @@ import { import { matrixMessageActions } from "./actions.js"; import { MatrixConfigSchema } from "./config-schema.js"; -import { resolveMatrixGroupRequireMention } from "./group-mentions.js"; +import { resolveMatrixGroupRequireMention, resolveMatrixGroupToolPolicy } from "./group-mentions.js"; import type { CoreConfig } from "./types.js"; import { listMatrixAccountIds, @@ -167,6 +167,7 @@ export const matrixPlugin: ChannelPlugin = { }, groups: { resolveRequireMention: resolveMatrixGroupRequireMention, + resolveToolPolicy: resolveMatrixGroupToolPolicy, }, threading: { resolveReplyToMode: ({ cfg }) => diff --git a/extensions/matrix/src/config-schema.ts b/extensions/matrix/src/config-schema.ts index 2d035dc43..b153ae40f 100644 --- a/extensions/matrix/src/config-schema.ts +++ b/extensions/matrix/src/config-schema.ts @@ -1,4 +1,4 @@ -import { MarkdownConfigSchema } from "clawdbot/plugin-sdk"; +import { MarkdownConfigSchema, ToolPolicySchema } from "clawdbot/plugin-sdk"; import { z } from "zod"; const allowFromEntry = z.union([z.string(), z.number()]); @@ -26,6 +26,7 @@ const matrixRoomSchema = z enabled: z.boolean().optional(), allow: z.boolean().optional(), requireMention: z.boolean().optional(), + tools: ToolPolicySchema, autoReply: z.boolean().optional(), users: z.array(allowFromEntry).optional(), skills: z.array(z.string()).optional(), diff --git a/extensions/matrix/src/group-mentions.ts b/extensions/matrix/src/group-mentions.ts index 5c6aecb5b..084479160 100644 --- a/extensions/matrix/src/group-mentions.ts +++ b/extensions/matrix/src/group-mentions.ts @@ -1,4 +1,4 @@ -import type { ChannelGroupContext } from "clawdbot/plugin-sdk"; +import type { ChannelGroupContext, GroupToolPolicyConfig } from "clawdbot/plugin-sdk"; import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js"; import type { CoreConfig } from "./types.js"; @@ -32,3 +32,30 @@ export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): b } return true; } + +export function resolveMatrixGroupToolPolicy( + params: ChannelGroupContext, +): GroupToolPolicyConfig | undefined { + const rawGroupId = params.groupId?.trim() ?? ""; + let roomId = rawGroupId; + const lower = roomId.toLowerCase(); + if (lower.startsWith("matrix:")) { + roomId = roomId.slice("matrix:".length).trim(); + } + if (roomId.toLowerCase().startsWith("channel:")) { + roomId = roomId.slice("channel:".length).trim(); + } + if (roomId.toLowerCase().startsWith("room:")) { + roomId = roomId.slice("room:".length).trim(); + } + const groupChannel = params.groupChannel?.trim() ?? ""; + const aliases = groupChannel ? [groupChannel] : []; + const cfg = params.cfg as CoreConfig; + const resolved = resolveMatrixRoomConfig({ + rooms: cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms, + roomId, + aliases, + name: groupChannel || undefined, + }).config; + return resolved?.tools; +} diff --git a/extensions/matrix/src/types.ts b/extensions/matrix/src/types.ts index 8b2e96d34..b7ff7facd 100644 --- a/extensions/matrix/src/types.ts +++ b/extensions/matrix/src/types.ts @@ -18,6 +18,8 @@ export type MatrixRoomConfig = { allow?: boolean; /** Require mentioning the bot to trigger replies. */ requireMention?: boolean; + /** Optional tool policy overrides for this room. */ + tools?: { allow?: string[]; deny?: string[] }; /** If true, reply without mention requirements. */ autoReply?: boolean; /** Optional allowlist for room senders (user IDs or localparts). */ diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 521916e34..c586c0346 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -9,6 +9,7 @@ import { import { msteamsOnboardingAdapter } from "./onboarding.js"; import { msteamsOutbound } from "./outbound.js"; import { probeMSTeams } from "./probe.js"; +import { resolveMSTeamsGroupToolPolicy } from "./policy.js"; import { normalizeMSTeamsMessagingTarget, normalizeMSTeamsUserInput, @@ -77,6 +78,9 @@ export const msteamsPlugin: ChannelPlugin = { hasRepliedRef, }), }, + groups: { + resolveToolPolicy: resolveMSTeamsGroupToolPolicy, + }, reload: { configPrefixes: ["channels.msteams"] }, configSchema: buildChannelConfigSchema(MSTeamsConfigSchema), config: { diff --git a/extensions/msteams/src/policy.ts b/extensions/msteams/src/policy.ts index b68174711..ef84884a7 100644 --- a/extensions/msteams/src/policy.ts +++ b/extensions/msteams/src/policy.ts @@ -1,6 +1,8 @@ import type { AllowlistMatch, + ChannelGroupContext, GroupPolicy, + GroupToolPolicyConfig, MSTeamsChannelConfig, MSTeamsConfig, MSTeamsReplyStyle, @@ -86,6 +88,50 @@ export function resolveMSTeamsRouteConfig(params: { }; } +export function resolveMSTeamsGroupToolPolicy( + params: ChannelGroupContext, +): GroupToolPolicyConfig | undefined { + const cfg = params.cfg.channels?.msteams; + if (!cfg) return undefined; + const groupId = params.groupId?.trim(); + const groupChannel = params.groupChannel?.trim(); + const groupSpace = params.groupSpace?.trim(); + + const resolved = resolveMSTeamsRouteConfig({ + cfg, + teamId: groupSpace, + teamName: groupSpace, + conversationId: groupId, + channelName: groupChannel, + }); + + if (resolved.channelConfig) { + return resolved.channelConfig.tools ?? resolved.teamConfig?.tools; + } + if (resolved.teamConfig?.tools) return resolved.teamConfig.tools; + + if (!groupId) return undefined; + + const channelCandidates = buildChannelKeyCandidates( + groupId, + groupChannel, + groupChannel ? normalizeChannelSlug(groupChannel) : undefined, + ); + for (const teamConfig of Object.values(cfg.teams ?? {})) { + const match = resolveChannelEntryMatchWithFallback({ + entries: teamConfig?.channels ?? {}, + keys: channelCandidates, + wildcardKey: "*", + normalizeKey: normalizeChannelSlug, + }); + if (match.entry) { + return match.entry.tools ?? teamConfig?.tools; + } + } + + return undefined; +} + export type MSTeamsReplyPolicy = { requireMention: boolean; replyStyle: MSTeamsReplyStyle; diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index 23858ebc1..a41b2a16f 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -24,6 +24,7 @@ import { nextcloudTalkOnboardingAdapter } from "./onboarding.js"; import { getNextcloudTalkRuntime } from "./runtime.js"; import { sendMessageNextcloudTalk } from "./send.js"; import type { CoreConfig } from "./types.js"; +import { resolveNextcloudTalkGroupToolPolicy } from "./policy.js"; const meta = { id: "nextcloud-talk", @@ -159,6 +160,7 @@ export const nextcloudTalkPlugin: ChannelPlugin = return true; }, + resolveToolPolicy: resolveNextcloudTalkGroupToolPolicy, }, messaging: { normalizeTarget: normalizeNextcloudTalkMessagingTarget, diff --git a/extensions/nextcloud-talk/src/config-schema.ts b/extensions/nextcloud-talk/src/config-schema.ts index 085319d1c..b047c7903 100644 --- a/extensions/nextcloud-talk/src/config-schema.ts +++ b/extensions/nextcloud-talk/src/config-schema.ts @@ -4,6 +4,7 @@ import { DmPolicySchema, GroupPolicySchema, MarkdownConfigSchema, + ToolPolicySchema, requireOpenAllowFrom, } from "clawdbot/plugin-sdk"; import { z } from "zod"; @@ -11,6 +12,7 @@ import { z } from "zod"; export const NextcloudTalkRoomSchema = z .object({ requireMention: z.boolean().optional(), + tools: ToolPolicySchema, skills: z.array(z.string()).optional(), enabled: z.boolean().optional(), allowFrom: z.array(z.string()).optional(), diff --git a/extensions/nextcloud-talk/src/policy.ts b/extensions/nextcloud-talk/src/policy.ts index 6c9599d44..56e094a99 100644 --- a/extensions/nextcloud-talk/src/policy.ts +++ b/extensions/nextcloud-talk/src/policy.ts @@ -1,4 +1,4 @@ -import type { AllowlistMatch, GroupPolicy } from "clawdbot/plugin-sdk"; +import type { AllowlistMatch, ChannelGroupContext, GroupPolicy, GroupToolPolicyConfig } from "clawdbot/plugin-sdk"; import { buildChannelKeyCandidates, normalizeChannelSlug, @@ -86,6 +86,21 @@ export function resolveNextcloudTalkRoomMatch(params: { }; } +export function resolveNextcloudTalkGroupToolPolicy( + params: ChannelGroupContext, +): GroupToolPolicyConfig | undefined { + const cfg = params.cfg as { channels?: { "nextcloud-talk"?: { rooms?: Record } } }; + const roomToken = params.groupId?.trim(); + if (!roomToken) return undefined; + const roomName = params.groupChannel?.trim() || undefined; + const match = resolveNextcloudTalkRoomMatch({ + rooms: cfg.channels?.["nextcloud-talk"]?.rooms, + roomToken, + roomName, + }); + return match.roomConfig?.tools ?? match.wildcardConfig?.tools; +} + export function resolveNextcloudTalkRequireMention(params: { roomConfig?: NextcloudTalkRoomConfig; wildcardConfig?: NextcloudTalkRoomConfig; diff --git a/extensions/nextcloud-talk/src/types.ts b/extensions/nextcloud-talk/src/types.ts index 97d11c4ab..18525ccab 100644 --- a/extensions/nextcloud-talk/src/types.ts +++ b/extensions/nextcloud-talk/src/types.ts @@ -7,6 +7,8 @@ import type { export type NextcloudTalkRoomConfig = { requireMention?: boolean; + /** Optional tool policy overrides for this room. */ + tools?: { allow?: string[]; deny?: string[] }; /** If specified, only load these skills for this room. Omit = all skills; empty = no skills. */ skills?: string[]; /** If false, disable the bot for this room. */ diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 851b9ebb2..8323a9ce0 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -21,6 +21,7 @@ import { resolveSlackAccount, resolveSlackReplyToMode, resolveSlackGroupRequireMention, + resolveSlackGroupToolPolicy, buildSlackThreadingToolContext, setAccountEnabledInConfigSection, slackOnboardingAdapter, @@ -161,6 +162,7 @@ export const slackPlugin: ChannelPlugin = { }, groups: { resolveRequireMention: resolveSlackGroupRequireMention, + resolveToolPolicy: resolveSlackGroupToolPolicy, }, threading: { resolveReplyToMode: ({ cfg, accountId, chatType }) => diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index ac2958850..c0d018c83 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -17,6 +17,7 @@ import { resolveDefaultTelegramAccountId, resolveTelegramAccount, resolveTelegramGroupRequireMention, + resolveTelegramGroupToolPolicy, setAccountEnabledInConfigSection, telegramOnboardingAdapter, TelegramConfigSchema, @@ -154,6 +155,7 @@ export const telegramPlugin: ChannelPlugin = { }, groups: { resolveRequireMention: resolveTelegramGroupRequireMention, + resolveToolPolicy: resolveTelegramGroupToolPolicy, }, threading: { resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "first", diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 5e6ff23b2..c9dd9da28 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -21,6 +21,7 @@ import { resolveDefaultWhatsAppAccountId, resolveWhatsAppAccount, resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupToolPolicy, resolveWhatsAppHeartbeatRecipients, whatsappOnboardingAdapter, WhatsAppConfigSchema, @@ -198,6 +199,7 @@ export const whatsappPlugin: ChannelPlugin = { }, groups: { resolveRequireMention: resolveWhatsAppGroupRequireMention, + resolveToolPolicy: resolveWhatsAppGroupToolPolicy, resolveGroupIntroHint: () => "WhatsApp IDs: SenderId is the participant JID; [message_id: ...] is the message id for reactions (use SenderId as participant).", }, diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index 8e87591b0..eeb7b0299 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -2,8 +2,10 @@ import type { ChannelAccountSnapshot, ChannelDirectoryEntry, ChannelDock, + ChannelGroupContext, ChannelPlugin, ClawdbotConfig, + GroupToolPolicyConfig, } from "clawdbot/plugin-sdk"; import { applyAccountNameToChannelSection, @@ -79,6 +81,26 @@ function mapGroup(params: { }; } +function resolveZalouserGroupToolPolicy( + params: ChannelGroupContext, +): GroupToolPolicyConfig | undefined { + const account = resolveZalouserAccountSync({ + cfg: params.cfg as ClawdbotConfig, + accountId: params.accountId ?? undefined, + }); + const groups = account.config.groups ?? {}; + const groupId = params.groupId?.trim(); + const groupChannel = params.groupChannel?.trim(); + const candidates = [groupId, groupChannel, "*"].filter( + (value): value is string => Boolean(value), + ); + for (const key of candidates) { + const entry = groups[key]; + if (entry?.tools) return entry.tools; + } + return undefined; +} + export const zalouserDock: ChannelDock = { id: "zalouser", capabilities: { @@ -101,6 +123,7 @@ export const zalouserDock: ChannelDock = { }, groups: { resolveRequireMention: () => true, + resolveToolPolicy: resolveZalouserGroupToolPolicy, }, threading: { resolveReplyToMode: () => "off", @@ -188,6 +211,7 @@ export const zalouserPlugin: ChannelPlugin = { }, groups: { resolveRequireMention: () => true, + resolveToolPolicy: resolveZalouserGroupToolPolicy, }, threading: { resolveReplyToMode: () => "off", diff --git a/extensions/zalouser/src/config-schema.ts b/extensions/zalouser/src/config-schema.ts index bf80d28c0..8fac18805 100644 --- a/extensions/zalouser/src/config-schema.ts +++ b/extensions/zalouser/src/config-schema.ts @@ -1,4 +1,4 @@ -import { MarkdownConfigSchema } from "clawdbot/plugin-sdk"; +import { MarkdownConfigSchema, ToolPolicySchema } from "clawdbot/plugin-sdk"; import { z } from "zod"; const allowFromEntry = z.union([z.string(), z.number()]); @@ -6,6 +6,7 @@ const allowFromEntry = z.union([z.string(), z.number()]); const groupConfigSchema = z.object({ allow: z.boolean().optional(), enabled: z.boolean().optional(), + tools: ToolPolicySchema, }); const zalouserAccountSchema = z.object({ diff --git a/extensions/zalouser/src/types.ts b/extensions/zalouser/src/types.ts index fcdb81dcc..be521b754 100644 --- a/extensions/zalouser/src/types.ts +++ b/extensions/zalouser/src/types.ts @@ -75,7 +75,7 @@ export type ZalouserAccountConfig = { dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; allowFrom?: Array; groupPolicy?: "open" | "allowlist" | "disabled"; - groups?: Record; + groups?: Record; messagePrefix?: string; }; @@ -87,7 +87,7 @@ export type ZalouserConfig = { dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; allowFrom?: Array; groupPolicy?: "open" | "allowlist" | "disabled"; - groups?: Record; + groups?: Record; messagePrefix?: string; accounts?: Record; }; diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 49c5dc6e0..3e4c0926b 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -264,6 +264,9 @@ export async function runEmbeddedPiAgent( agentAccountId: params.agentAccountId, messageTo: params.messageTo, messageThreadId: params.messageThreadId, + groupId: params.groupId, + groupChannel: params.groupChannel, + groupSpace: params.groupSpace, currentChannelId: params.currentChannelId, currentThreadTs: params.currentThreadTs, replyToMode: params.replyToMode, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 655ab6ba3..aa392710b 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -208,6 +208,9 @@ export async function runEmbeddedAttempt( agentAccountId: params.agentAccountId, messageTo: params.messageTo, messageThreadId: params.messageThreadId, + groupId: params.groupId, + groupChannel: params.groupChannel, + groupSpace: params.groupSpace, sessionKey: params.sessionKey ?? params.sessionId, agentDir, workspaceDir: effectiveWorkspace, diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index 38fa3fcc3..596e35a21 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -27,6 +27,12 @@ export type RunEmbeddedPiAgentParams = { messageTo?: string; /** Thread/topic identifier for routing replies to the originating thread. */ messageThreadId?: string | number; + /** Group id for channel-level tool policy resolution. */ + groupId?: string | null; + /** Group channel label (e.g. #general) for channel-level tool policy resolution. */ + groupChannel?: string | null; + /** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */ + groupSpace?: string | null; /** Current channel ID for auto-threading (Slack). */ currentChannelId?: string; /** Current thread timestamp for auto-threading (Slack). */ diff --git a/src/agents/pi-embedded-runner/run/types.ts b/src/agents/pi-embedded-runner/run/types.ts index c67e96ca0..c7ddc2627 100644 --- a/src/agents/pi-embedded-runner/run/types.ts +++ b/src/agents/pi-embedded-runner/run/types.ts @@ -23,6 +23,12 @@ export type EmbeddedRunAttemptParams = { agentAccountId?: string; messageTo?: string; messageThreadId?: string | number; + /** Group id for channel-level tool policy resolution. */ + groupId?: string | null; + /** Group channel label (e.g. #general) for channel-level tool policy resolution. */ + groupChannel?: string | null; + /** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */ + groupSpace?: string | null; currentChannelId?: string; currentThreadTs?: string; replyToMode?: "off" | "first" | "all"; diff --git a/src/agents/pi-tools-agent-config.test.ts b/src/agents/pi-tools-agent-config.test.ts index dbb3f46b6..488051b9a 100644 --- a/src/agents/pi-tools-agent-config.test.ts +++ b/src/agents/pi-tools-agent-config.test.ts @@ -231,6 +231,70 @@ describe("Agent-specific tool filtering", () => { expect(familyToolNames).not.toContain("apply_patch"); }); + it("should apply group tool policy overrides (group-specific beats wildcard)", () => { + const cfg: ClawdbotConfig = { + channels: { + whatsapp: { + groups: { + "*": { + tools: { allow: ["read"] }, + }, + trusted: { + tools: { allow: ["read", "exec"] }, + }, + }, + }, + }, + }; + + const trustedTools = createClawdbotCodingTools({ + config: cfg, + sessionKey: "agent:main:whatsapp:group:trusted", + messageProvider: "whatsapp", + workspaceDir: "/tmp/test-group-trusted", + agentDir: "/tmp/agent-group", + }); + const trustedNames = trustedTools.map((t) => t.name); + expect(trustedNames).toContain("read"); + expect(trustedNames).toContain("exec"); + + const defaultTools = createClawdbotCodingTools({ + config: cfg, + sessionKey: "agent:main:whatsapp:group:unknown", + messageProvider: "whatsapp", + workspaceDir: "/tmp/test-group-default", + agentDir: "/tmp/agent-group", + }); + const defaultNames = defaultTools.map((t) => t.name); + expect(defaultNames).toContain("read"); + expect(defaultNames).not.toContain("exec"); + }); + + it("should resolve telegram group tool policy for topic session keys", () => { + const cfg: ClawdbotConfig = { + channels: { + telegram: { + groups: { + "123": { + tools: { allow: ["read"] }, + }, + }, + }, + }, + }; + + const tools = createClawdbotCodingTools({ + config: cfg, + sessionKey: "agent:main:telegram:group:123:topic:456", + messageProvider: "telegram", + workspaceDir: "/tmp/test-telegram-topic", + agentDir: "/tmp/agent-telegram", + }); + const names = tools.map((t) => t.name); + expect(names).toContain("read"); + expect(names).not.toContain("exec"); + }); + it("should apply global tool policy before agent-specific policy", () => { const cfg: ClawdbotConfig = { tools: { diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts index ea4004ec9..360f4e0e1 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/pi-tools.policy.ts @@ -1,8 +1,12 @@ import type { ClawdbotConfig } from "../config/config.js"; +import { getChannelDock } from "../channels/dock.js"; +import { resolveChannelGroupToolsPolicy } from "../config/group-policy.js"; import { resolveAgentConfig, resolveAgentIdFromSessionKey } from "./agent-scope.js"; import type { AnyAgentTool } from "./pi-tools.types.js"; import type { SandboxToolPolicy } from "./sandbox.js"; import { expandToolGroups, normalizeToolName } from "./tool-policy.js"; +import { normalizeMessageChannel } from "../utils/message-channel.js"; +import { resolveThreadParentSessionKey } from "../sessions/session-key-utils.js"; type CompiledPattern = | { kind: "all" } @@ -108,6 +112,23 @@ function normalizeProviderKey(value: string): string { return value.trim().toLowerCase(); } +function resolveGroupContextFromSessionKey(sessionKey?: string | null): { + channel?: string; + groupId?: string; +} { + const raw = (sessionKey ?? "").trim(); + if (!raw) return {}; + const base = resolveThreadParentSessionKey(raw) ?? raw; + const parts = base.split(":").filter(Boolean); + const body = parts[0] === "agent" ? parts.slice(2) : parts; + if (body.length < 3) return {}; + const [channel, kind, ...rest] = body; + if (kind !== "group" && kind !== "channel") return {}; + const groupId = rest.join(":").trim(); + if (!groupId) return {}; + return { channel: channel.trim().toLowerCase(), groupId }; +} + function resolveProviderToolPolicy(params: { byProvider?: Record; modelProvider?: string; @@ -174,6 +195,45 @@ export function resolveEffectiveToolPolicy(params: { }; } +export function resolveGroupToolPolicy(params: { + config?: ClawdbotConfig; + sessionKey?: string; + messageProvider?: string; + groupId?: string | null; + groupChannel?: string | null; + groupSpace?: string | null; + accountId?: string | null; +}): SandboxToolPolicy | undefined { + if (!params.config) return undefined; + const sessionContext = resolveGroupContextFromSessionKey(params.sessionKey); + const groupId = params.groupId ?? sessionContext.groupId; + if (!groupId) return undefined; + const channelRaw = params.messageProvider ?? sessionContext.channel; + const channel = normalizeMessageChannel(channelRaw); + if (!channel) return undefined; + let dock; + try { + dock = getChannelDock(channel); + } catch { + dock = undefined; + } + const toolsConfig = + dock?.groups?.resolveToolPolicy?.({ + cfg: params.config, + groupId, + groupChannel: params.groupChannel, + groupSpace: params.groupSpace, + accountId: params.accountId, + }) ?? + resolveChannelGroupToolsPolicy({ + cfg: params.config, + channel, + groupId, + accountId: params.accountId, + }); + return pickToolPolicy(toolsConfig); +} + export function isToolAllowedByPolicies( name: string, policies: Array, diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 2831aec99..7b3caf591 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -23,6 +23,7 @@ import { filterToolsByPolicy, isToolAllowedByPolicies, resolveEffectiveToolPolicy, + resolveGroupToolPolicy, resolveSubagentToolPolicy, } from "./pi-tools.policy.js"; import { @@ -128,6 +129,12 @@ export function createClawdbotCodingTools(options?: { currentChannelId?: string; /** Current thread timestamp for auto-threading (Slack). */ currentThreadTs?: string; + /** Group id for channel-level tool policy resolution. */ + groupId?: string | null; + /** Group channel label (e.g. #general) for channel-level tool policy resolution. */ + groupChannel?: string | null; + /** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */ + groupSpace?: string | null; /** Reply-to mode for Slack auto-threading. */ replyToMode?: "off" | "first" | "all"; /** Mutable ref to track if a reply was sent (for "first" mode). */ @@ -151,6 +158,15 @@ export function createClawdbotCodingTools(options?: { modelProvider: options?.modelProvider, modelId: options?.modelId, }); + const groupPolicy = resolveGroupToolPolicy({ + config: options?.config, + sessionKey: options?.sessionKey, + messageProvider: options?.messageProvider, + groupId: options?.groupId, + groupChannel: options?.groupChannel, + groupSpace: options?.groupSpace, + accountId: options?.agentAccountId, + }); const profilePolicy = resolveToolProfilePolicy(profile); const providerProfilePolicy = resolveToolProfilePolicy(providerProfile); const scopeKey = options?.exec?.scopeKey ?? (agentId ? `agent:${agentId}` : undefined); @@ -165,6 +181,7 @@ export function createClawdbotCodingTools(options?: { globalProviderPolicy, agentPolicy, agentProviderPolicy, + groupPolicy, sandbox?.tools, subagentPolicy, ]); @@ -285,6 +302,7 @@ export function createClawdbotCodingTools(options?: { globalProviderPolicy, agentPolicy, agentProviderPolicy, + groupPolicy, sandbox?.tools, subagentPolicy, ]), @@ -323,6 +341,10 @@ export function createClawdbotCodingTools(options?: { stripPluginOnlyAllowlist(agentProviderPolicy, pluginGroups), pluginGroups, ); + const groupPolicyExpanded = expandPolicyWithPluginGroups( + stripPluginOnlyAllowlist(groupPolicy, pluginGroups), + pluginGroups, + ); const sandboxPolicyExpanded = expandPolicyWithPluginGroups(sandbox?.tools, pluginGroups); const subagentPolicyExpanded = expandPolicyWithPluginGroups(subagentPolicy, pluginGroups); @@ -344,9 +366,12 @@ export function createClawdbotCodingTools(options?: { const agentProviderFiltered = agentProviderExpanded ? filterToolsByPolicy(agentFiltered, agentProviderExpanded) : agentFiltered; - const sandboxed = sandboxPolicyExpanded - ? filterToolsByPolicy(agentProviderFiltered, sandboxPolicyExpanded) + const groupFiltered = groupPolicyExpanded + ? filterToolsByPolicy(agentProviderFiltered, groupPolicyExpanded) : agentProviderFiltered; + const sandboxed = sandboxPolicyExpanded + ? filterToolsByPolicy(groupFiltered, sandboxPolicyExpanded) + : groupFiltered; const subagentFiltered = subagentPolicyExpanded ? filterToolsByPolicy(sandboxed, subagentPolicyExpanded) : sandboxed; diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index ce653926d..a428aa6da 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -14,6 +14,7 @@ import { } from "../../agents/pi-embedded-helpers.js"; import { resolveAgentIdFromSessionKey, + resolveGroupSessionKey, resolveSessionTranscriptPath, type SessionEntry, updateSessionStore, @@ -214,6 +215,10 @@ export async function runAgentTurnWithFallback(params: { agentAccountId: params.sessionCtx.AccountId, messageTo: params.sessionCtx.OriginatingTo ?? params.sessionCtx.To, messageThreadId: params.sessionCtx.MessageThreadId ?? undefined, + groupId: resolveGroupSessionKey(params.sessionCtx)?.id, + groupChannel: + params.sessionCtx.GroupChannel?.trim() ?? params.sessionCtx.GroupSubject?.trim(), + groupSpace: params.sessionCtx.GroupSpace?.trim() ?? undefined, // Provider threading context for tool auto-injection ...buildThreadingToolContext({ sessionCtx: params.sessionCtx, diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 76b2c4c2a..dfda65897 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -147,6 +147,9 @@ export function createFollowupRunner(params: { agentAccountId: queued.run.agentAccountId, messageTo: queued.originatingTo, messageThreadId: queued.originatingThreadId, + groupId: queued.run.groupId, + groupChannel: queued.run.groupChannel, + groupSpace: queued.run.groupSpace, sessionFile: queued.run.sessionFile, workspaceDir: queued.run.workspaceDir, config: queued.run.config, diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index a5db8a73c..40802d2b7 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -9,6 +9,7 @@ import { resolveSessionAuthProfileOverride } from "../../agents/auth-profiles/se import type { ExecToolDefaults } from "../../agents/bash-tools.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { + resolveGroupSessionKey, resolveSessionFilePath, type SessionEntry, updateSessionStore, @@ -366,6 +367,9 @@ export async function runPreparedReply( sessionKey, messageProvider: sessionCtx.Provider?.trim().toLowerCase() || undefined, agentAccountId: sessionCtx.AccountId, + groupId: resolveGroupSessionKey(sessionCtx)?.id ?? undefined, + groupChannel: sessionCtx.GroupChannel?.trim() ?? sessionCtx.GroupSubject?.trim(), + groupSpace: sessionCtx.GroupSpace?.trim() ?? undefined, sessionFile, workspaceDir, config: cfg, diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index a57b65813..332e9bae1 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -48,6 +48,9 @@ export type FollowupRun = { sessionKey?: string; messageProvider?: string; agentAccountId?: string; + groupId?: string; + groupChannel?: string; + groupSpace?: string; sessionFile: string; workspaceDir: string; config: ClawdbotConfig; diff --git a/src/channels/dock.ts b/src/channels/dock.ts index 43fa07b6b..b2e985688 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -11,10 +11,15 @@ import { normalizeWhatsAppTarget } from "../whatsapp/normalize.js"; import { requireActivePluginRegistry } from "../plugins/runtime.js"; import { resolveDiscordGroupRequireMention, + resolveDiscordGroupToolPolicy, resolveIMessageGroupRequireMention, + resolveIMessageGroupToolPolicy, resolveSlackGroupRequireMention, + resolveSlackGroupToolPolicy, resolveTelegramGroupRequireMention, + resolveTelegramGroupToolPolicy, resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupToolPolicy, } from "./plugins/group-mentions.js"; import type { ChannelCapabilities, @@ -103,6 +108,7 @@ const DOCKS: Record = { }, groups: { resolveRequireMention: resolveTelegramGroupRequireMention, + resolveToolPolicy: resolveTelegramGroupToolPolicy, }, threading: { resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "first", @@ -141,6 +147,7 @@ const DOCKS: Record = { }, groups: { resolveRequireMention: resolveWhatsAppGroupRequireMention, + resolveToolPolicy: resolveWhatsAppGroupToolPolicy, resolveGroupIntroHint: () => "WhatsApp IDs: SenderId is the participant JID; [message_id: ...] is the message id for reactions (use SenderId as participant).", }, @@ -189,6 +196,7 @@ const DOCKS: Record = { }, groups: { resolveRequireMention: resolveDiscordGroupRequireMention, + resolveToolPolicy: resolveDiscordGroupToolPolicy, }, mentions: { stripPatterns: () => ["<@!?\\d+>"], @@ -222,6 +230,7 @@ const DOCKS: Record = { }, groups: { resolveRequireMention: resolveSlackGroupRequireMention, + resolveToolPolicy: resolveSlackGroupToolPolicy, }, threading: { resolveReplyToMode: ({ cfg, accountId, chatType }) => @@ -284,6 +293,7 @@ const DOCKS: Record = { }, groups: { resolveRequireMention: resolveIMessageGroupRequireMention, + resolveToolPolicy: resolveIMessageGroupToolPolicy, }, threading: { buildToolContext: ({ context, hasRepliedRef }) => { diff --git a/src/channels/plugins/group-mentions.ts b/src/channels/plugins/group-mentions.ts index 79dfa0320..c26b95915 100644 --- a/src/channels/plugins/group-mentions.ts +++ b/src/channels/plugins/group-mentions.ts @@ -1,6 +1,10 @@ import type { ClawdbotConfig } from "../../config/config.js"; -import { resolveChannelGroupRequireMention } from "../../config/group-policy.js"; +import { + resolveChannelGroupRequireMention, + resolveChannelGroupToolsPolicy, +} from "../../config/group-policy.js"; import type { DiscordConfig } from "../../config/types.js"; +import type { GroupToolPolicyConfig } from "../../config/types.tools.js"; import { resolveSlackAccount } from "../../slack/accounts.js"; type GroupMentionParams = { @@ -192,3 +196,103 @@ export function resolveBlueBubblesGroupRequireMention(params: GroupMentionParams accountId: params.accountId, }); } + +export function resolveTelegramGroupToolPolicy( + params: GroupMentionParams, +): GroupToolPolicyConfig | undefined { + const { chatId } = parseTelegramGroupId(params.groupId); + return resolveChannelGroupToolsPolicy({ + cfg: params.cfg, + channel: "telegram", + groupId: chatId ?? params.groupId, + accountId: params.accountId, + }); +} + +export function resolveWhatsAppGroupToolPolicy( + params: GroupMentionParams, +): GroupToolPolicyConfig | undefined { + return resolveChannelGroupToolsPolicy({ + cfg: params.cfg, + channel: "whatsapp", + groupId: params.groupId, + accountId: params.accountId, + }); +} + +export function resolveIMessageGroupToolPolicy( + params: GroupMentionParams, +): GroupToolPolicyConfig | undefined { + return resolveChannelGroupToolsPolicy({ + cfg: params.cfg, + channel: "imessage", + groupId: params.groupId, + accountId: params.accountId, + }); +} + +export function resolveDiscordGroupToolPolicy( + params: GroupMentionParams, +): GroupToolPolicyConfig | undefined { + const guildEntry = resolveDiscordGuildEntry( + params.cfg.channels?.discord?.guilds, + params.groupSpace, + ); + const channelEntries = guildEntry?.channels; + if (channelEntries && Object.keys(channelEntries).length > 0) { + const groupChannel = params.groupChannel; + const channelSlug = normalizeDiscordSlug(groupChannel); + const entry = + (params.groupId ? channelEntries[params.groupId] : undefined) ?? + (channelSlug + ? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`]) + : undefined) ?? + (groupChannel ? channelEntries[normalizeDiscordSlug(groupChannel)] : undefined); + if (entry?.tools) return entry.tools; + } + if (guildEntry?.tools) return guildEntry.tools; + return undefined; +} + +export function resolveSlackGroupToolPolicy( + params: GroupMentionParams, +): GroupToolPolicyConfig | undefined { + const account = resolveSlackAccount({ + cfg: params.cfg, + accountId: params.accountId, + }); + const channels = account.channels ?? {}; + const keys = Object.keys(channels); + if (keys.length === 0) return undefined; + const channelId = params.groupId?.trim(); + const groupChannel = params.groupChannel; + const channelName = groupChannel?.replace(/^#/, ""); + const normalizedName = normalizeSlackSlug(channelName); + const candidates = [ + channelId ?? "", + channelName ? `#${channelName}` : "", + channelName ?? "", + normalizedName, + ].filter(Boolean); + let matched: { tools?: GroupToolPolicyConfig } | undefined; + for (const candidate of candidates) { + if (candidate && channels[candidate]) { + matched = channels[candidate]; + break; + } + } + const resolved = matched ?? channels["*"]; + if (resolved?.tools) return resolved.tools; + return undefined; +} + +export function resolveBlueBubblesGroupToolPolicy( + params: GroupMentionParams, +): GroupToolPolicyConfig | undefined { + return resolveChannelGroupToolsPolicy({ + cfg: params.cfg, + channel: "bluebubbles", + groupId: params.groupId, + accountId: params.accountId, + }); +} diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index 832f59e58..ccd7009c5 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -1,4 +1,5 @@ import type { ClawdbotConfig } from "../../config/config.js"; +import type { GroupToolPolicyConfig } from "../../config/types.tools.js"; import type { OutboundDeliveryResult, OutboundSendDeps } from "../../infra/outbound/deliver.js"; import type { RuntimeEnv } from "../../runtime.js"; import type { @@ -65,6 +66,7 @@ export type ChannelConfigAdapter = { export type ChannelGroupAdapter = { resolveRequireMention?: (params: ChannelGroupContext) => boolean | undefined; resolveGroupIntroHint?: (params: ChannelGroupContext) => string | undefined; + resolveToolPolicy?: (params: ChannelGroupContext) => GroupToolPolicyConfig | undefined; }; export type ChannelOutboundContext = { diff --git a/src/config/group-policy.ts b/src/config/group-policy.ts index d52daa4c5..faad3508b 100644 --- a/src/config/group-policy.ts +++ b/src/config/group-policy.ts @@ -1,11 +1,13 @@ import type { ChannelId } from "../channels/plugins/types.js"; import { normalizeAccountId } from "../routing/session-key.js"; import type { ClawdbotConfig } from "./config.js"; +import type { GroupToolPolicyConfig } from "./types.tools.js"; export type GroupPolicyChannel = ChannelId; export type ChannelGroupConfig = { requireMention?: boolean; + tools?: GroupToolPolicyConfig; }; export type ChannelGroupPolicy = { @@ -91,3 +93,15 @@ export function resolveChannelGroupRequireMention(params: { } return true; } + +export function resolveChannelGroupToolsPolicy(params: { + cfg: ClawdbotConfig; + channel: GroupPolicyChannel; + groupId?: string | null; + accountId?: string | null; +}): GroupToolPolicyConfig | undefined { + const { groupConfig, defaultConfig } = resolveChannelGroupPolicy(params); + if (groupConfig?.tools) return groupConfig.tools; + if (defaultConfig?.tools) return defaultConfig.tools; + return undefined; +} diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index cdedcb0d7..42e1d49f2 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -7,6 +7,7 @@ import type { ReplyToMode, } from "./types.base.js"; import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js"; +import type { GroupToolPolicyConfig } from "./types.tools.js"; export type DiscordDmConfig = { /** If false, ignore all incoming Discord DMs. Default: true. */ @@ -24,6 +25,8 @@ export type DiscordDmConfig = { export type DiscordGuildChannelConfig = { allow?: boolean; requireMention?: boolean; + /** Optional tool policy overrides for this channel. */ + tools?: GroupToolPolicyConfig; /** If specified, only load these skills for this channel. Omit = all skills; empty = no skills. */ skills?: string[]; /** If false, disable the bot for this channel. */ @@ -39,6 +42,8 @@ export type DiscordReactionNotificationMode = "off" | "own" | "all" | "allowlist export type DiscordGuildEntry = { slug?: string; requireMention?: boolean; + /** Optional tool policy overrides for this guild (used when channel override is missing). */ + tools?: GroupToolPolicyConfig; /** Reaction notification mode (off|own|all|allowlist). Default: own. */ reactionNotifications?: DiscordReactionNotificationMode; users?: Array; diff --git a/src/config/types.imessage.ts b/src/config/types.imessage.ts index c166fee54..72e298378 100644 --- a/src/config/types.imessage.ts +++ b/src/config/types.imessage.ts @@ -5,6 +5,7 @@ import type { MarkdownConfig, } from "./types.base.js"; import type { DmConfig } from "./types.messages.js"; +import type { GroupToolPolicyConfig } from "./types.tools.js"; export type IMessageAccountConfig = { /** Optional display name for this account (used in CLI/UI lists). */ @@ -59,6 +60,7 @@ export type IMessageAccountConfig = { string, { requireMention?: boolean; + tools?: GroupToolPolicyConfig; } >; }; diff --git a/src/config/types.msteams.ts b/src/config/types.msteams.ts index 170c64e47..98b707500 100644 --- a/src/config/types.msteams.ts +++ b/src/config/types.msteams.ts @@ -5,6 +5,7 @@ import type { MarkdownConfig, } from "./types.base.js"; import type { DmConfig } from "./types.messages.js"; +import type { GroupToolPolicyConfig } from "./types.tools.js"; export type MSTeamsWebhookConfig = { /** Port for the webhook server. Default: 3978. */ @@ -20,6 +21,8 @@ export type MSTeamsReplyStyle = "thread" | "top-level"; export type MSTeamsChannelConfig = { /** Require @mention to respond. Default: true. */ requireMention?: boolean; + /** Optional tool policy overrides for this channel. */ + tools?: GroupToolPolicyConfig; /** Reply style: "thread" replies to the message, "top-level" posts a new message. */ replyStyle?: MSTeamsReplyStyle; }; @@ -28,6 +31,8 @@ export type MSTeamsChannelConfig = { export type MSTeamsTeamConfig = { /** Default requireMention for channels in this team. */ requireMention?: boolean; + /** Default tool policy for channels in this team. */ + tools?: GroupToolPolicyConfig; /** Default reply style for channels in this team. */ replyStyle?: MSTeamsReplyStyle; /** Per-channel overrides. Key is conversation ID (e.g., "19:...@thread.tacv2"). */ diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index e2ca63b3c..e71b2e423 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -6,6 +6,7 @@ import type { ReplyToMode, } from "./types.base.js"; import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js"; +import type { GroupToolPolicyConfig } from "./types.tools.js"; export type SlackDmConfig = { /** If false, ignore all incoming Slack DMs. Default: true. */ @@ -29,6 +30,8 @@ export type SlackChannelConfig = { allow?: boolean; /** Require mentioning the bot to trigger replies. */ requireMention?: boolean; + /** Optional tool policy overrides for this channel. */ + tools?: GroupToolPolicyConfig; /** Allow bot-authored messages to trigger replies (default: false). */ allowBots?: boolean; /** Allowlist of users that can invoke the bot in this channel. */ diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 02a822c13..12894bcb7 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -8,6 +8,7 @@ import type { ReplyToMode, } from "./types.base.js"; import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js"; +import type { GroupToolPolicyConfig } from "./types.tools.js"; export type TelegramActionConfig = { reactions?: boolean; @@ -128,6 +129,8 @@ export type TelegramTopicConfig = { export type TelegramGroupConfig = { requireMention?: boolean; + /** Optional tool policy overrides for this group. */ + tools?: GroupToolPolicyConfig; /** If specified, only load these skills for this group (when no topic). Omit = all skills; empty = no skills. */ skills?: string[]; /** Per-topic configuration (key is message_thread_id as string) */ diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index cb0d28f4b..fab0cca47 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -120,6 +120,11 @@ export type ToolPolicyConfig = { profile?: ToolProfileId; }; +export type GroupToolPolicyConfig = { + allow?: string[]; + deny?: string[]; +}; + export type ExecToolConfig = { /** Exec host routing (default: sandbox). */ host?: "sandbox" | "gateway" | "node"; diff --git a/src/config/types.whatsapp.ts b/src/config/types.whatsapp.ts index 90b5497d4..41e6e36d9 100644 --- a/src/config/types.whatsapp.ts +++ b/src/config/types.whatsapp.ts @@ -5,6 +5,7 @@ import type { MarkdownConfig, } from "./types.base.js"; import type { DmConfig } from "./types.messages.js"; +import type { GroupToolPolicyConfig } from "./types.tools.js"; export type WhatsAppActionConfig = { reactions?: boolean; @@ -65,6 +66,7 @@ export type WhatsAppConfig = { string, { requireMention?: boolean; + tools?: GroupToolPolicyConfig; } >; /** Acknowledgment reaction sent immediately upon message receipt. */ @@ -125,6 +127,7 @@ export type WhatsAppAccountConfig = { string, { requireMention?: boolean; + tools?: GroupToolPolicyConfig; } >; /** Acknowledgment reaction sent immediately upon message receipt. */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 12f6cbb3d..fc2c4480e 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -14,6 +14,7 @@ import { RetryConfigSchema, requireOpenAllowFrom, } from "./zod-schema.core.js"; +import { ToolPolicySchema } from "./zod-schema.agent-runtime.js"; import { normalizeTelegramCommandDescription, normalizeTelegramCommandName, @@ -44,6 +45,7 @@ export const TelegramTopicSchema = z export const TelegramGroupSchema = z .object({ requireMention: z.boolean().optional(), + tools: ToolPolicySchema, skills: z.array(z.string()).optional(), enabled: z.boolean().optional(), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), @@ -173,6 +175,7 @@ export const DiscordGuildChannelSchema = z .object({ allow: z.boolean().optional(), requireMention: z.boolean().optional(), + tools: ToolPolicySchema, skills: z.array(z.string()).optional(), enabled: z.boolean().optional(), users: z.array(z.union([z.string(), z.number()])).optional(), @@ -185,6 +188,7 @@ export const DiscordGuildSchema = z .object({ slug: z.string().optional(), requireMention: z.boolean().optional(), + tools: ToolPolicySchema, reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(), users: z.array(z.union([z.string(), z.number()])).optional(), channels: z.record(z.string(), DiscordGuildChannelSchema.optional()).optional(), @@ -270,6 +274,7 @@ export const SlackChannelSchema = z enabled: z.boolean().optional(), allow: z.boolean().optional(), requireMention: z.boolean().optional(), + tools: ToolPolicySchema, allowBots: z.boolean().optional(), users: z.array(z.union([z.string(), z.number()])).optional(), skills: z.array(z.string()).optional(), @@ -466,6 +471,7 @@ export const IMessageAccountSchemaBase = z z .object({ requireMention: z.boolean().optional(), + tools: ToolPolicySchema, }) .strict() .optional(), @@ -520,6 +526,7 @@ const BlueBubblesActionSchema = z const BlueBubblesGroupConfigSchema = z .object({ requireMention: z.boolean().optional(), + tools: ToolPolicySchema, }) .strict(); @@ -576,6 +583,7 @@ export const BlueBubblesConfigSchema = BlueBubblesAccountSchemaBase.extend({ export const MSTeamsChannelSchema = z .object({ requireMention: z.boolean().optional(), + tools: ToolPolicySchema, replyStyle: MSTeamsReplyStyleSchema.optional(), }) .strict(); @@ -583,6 +591,7 @@ export const MSTeamsChannelSchema = z export const MSTeamsTeamSchema = z .object({ requireMention: z.boolean().optional(), + tools: ToolPolicySchema, replyStyle: MSTeamsReplyStyleSchema.optional(), channels: z.record(z.string(), MSTeamsChannelSchema.optional()).optional(), }) diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index de6cda2f8..b33e7f5d1 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -7,6 +7,7 @@ import { GroupPolicySchema, MarkdownConfigSchema, } from "./zod-schema.core.js"; +import { ToolPolicySchema } from "./zod-schema.agent-runtime.js"; export const WhatsAppAccountSchema = z .object({ @@ -37,6 +38,7 @@ export const WhatsAppAccountSchema = z z .object({ requireMention: z.boolean().optional(), + tools: ToolPolicySchema, }) .strict() .optional(), @@ -98,6 +100,7 @@ export const WhatsAppConfigSchema = z z .object({ requireMention: z.boolean().optional(), + tools: ToolPolicySchema, }) .strict() .optional(), diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 167838b52..61dd1bc0b 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -73,6 +73,7 @@ export type { DmPolicy, DmConfig, GroupPolicy, + GroupToolPolicyConfig, MarkdownConfig, MarkdownTableMode, MSTeamsChannelConfig, @@ -99,6 +100,7 @@ export { normalizeAllowFrom, requireOpenAllowFrom, } from "../config/zod-schema.core.js"; +export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js"; export type { RuntimeEnv } from "../runtime.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; @@ -143,6 +145,12 @@ export { resolveSlackGroupRequireMention, resolveTelegramGroupRequireMention, resolveWhatsAppGroupRequireMention, + resolveBlueBubblesGroupToolPolicy, + resolveDiscordGroupToolPolicy, + resolveIMessageGroupToolPolicy, + resolveSlackGroupToolPolicy, + resolveTelegramGroupToolPolicy, + resolveWhatsAppGroupToolPolicy, } from "../channels/plugins/group-mentions.js"; export { recordInboundSession } from "../channels/session.js"; export {