From c6014c7b0f4755590ee79879a88cede1106cd73c Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Sun, 25 Jan 2026 22:15:03 +1300 Subject: [PATCH] Add per-sender group tool policies --- extensions/msteams/src/policy.ts | 51 +++++++++- src/agents/pi-embedded-runner.test.ts | 1 + src/agents/pi-embedded-runner/run/attempt.ts | 4 + src/agents/pi-embedded-runner/run/params.ts | 4 + src/agents/pi-embedded-runner/run/types.ts | 4 + src/agents/pi-tools-agent-config.test.ts | 70 ++++++++++++++ src/agents/pi-tools.policy.ts | 12 +++ src/agents/pi-tools.ts | 8 ++ .../reply/agent-runner-execution.ts | 4 + src/auto-reply/reply/agent-runner-memory.ts | 4 + src/auto-reply/reply/followup-runner.ts | 4 + src/auto-reply/reply/get-reply-run.ts | 4 + src/auto-reply/reply/queue/types.ts | 4 + src/channels/plugins/group-mentions.ts | 49 ++++++++++ src/channels/plugins/types.core.ts | 4 + src/config/group-policy.ts | 92 +++++++++++++++++-- src/config/types.discord.ts | 4 +- src/config/types.imessage.ts | 3 +- src/config/types.msteams.ts | 4 +- src/config/types.slack.ts | 3 +- src/config/types.telegram.ts | 3 +- src/config/types.tools.ts | 2 + src/config/types.whatsapp.ts | 4 +- src/config/zod-schema.providers-core.ts | 10 ++ src/config/zod-schema.providers-whatsapp.ts | 4 + src/plugin-sdk/index.ts | 2 + 26 files changed, 342 insertions(+), 16 deletions(-) diff --git a/extensions/msteams/src/policy.ts b/extensions/msteams/src/policy.ts index ef84884a7..fee0543a8 100644 --- a/extensions/msteams/src/policy.ts +++ b/extensions/msteams/src/policy.ts @@ -11,6 +11,7 @@ import type { import { buildChannelKeyCandidates, normalizeChannelSlug, + resolveToolsBySender, resolveChannelEntryMatchWithFallback, resolveNestedAllowlistDecision, } from "clawdbot/plugin-sdk"; @@ -106,9 +107,36 @@ export function resolveMSTeamsGroupToolPolicy( }); if (resolved.channelConfig) { - return resolved.channelConfig.tools ?? resolved.teamConfig?.tools; + const senderPolicy = resolveToolsBySender({ + toolsBySender: resolved.channelConfig.toolsBySender, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); + if (senderPolicy) return senderPolicy; + if (resolved.channelConfig.tools) return resolved.channelConfig.tools; + const teamSenderPolicy = resolveToolsBySender({ + toolsBySender: resolved.teamConfig?.toolsBySender, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); + if (teamSenderPolicy) return teamSenderPolicy; + return resolved.teamConfig?.tools; + } + if (resolved.teamConfig) { + const teamSenderPolicy = resolveToolsBySender({ + toolsBySender: resolved.teamConfig.toolsBySender, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); + if (teamSenderPolicy) return teamSenderPolicy; + if (resolved.teamConfig.tools) return resolved.teamConfig.tools; } - if (resolved.teamConfig?.tools) return resolved.teamConfig.tools; if (!groupId) return undefined; @@ -125,7 +153,24 @@ export function resolveMSTeamsGroupToolPolicy( normalizeKey: normalizeChannelSlug, }); if (match.entry) { - return match.entry.tools ?? teamConfig?.tools; + const senderPolicy = resolveToolsBySender({ + toolsBySender: match.entry.toolsBySender, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); + if (senderPolicy) return senderPolicy; + if (match.entry.tools) return match.entry.tools; + const teamSenderPolicy = resolveToolsBySender({ + toolsBySender: teamConfig?.toolsBySender, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); + if (teamSenderPolicy) return teamSenderPolicy; + return teamConfig?.tools; } } diff --git a/src/agents/pi-embedded-runner.test.ts b/src/agents/pi-embedded-runner.test.ts index 03192338b..ec01fdf63 100644 --- a/src/agents/pi-embedded-runner.test.ts +++ b/src/agents/pi-embedded-runner.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import "./test-helpers/fast-coding-tools.js"; import type { ClawdbotConfig } from "../config/config.js"; import { ensureClawdbotModelsJson } from "./models-config.js"; diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index f1c487470..7b5193054 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -215,6 +215,10 @@ export async function runEmbeddedAttempt( groupChannel: params.groupChannel, groupSpace: params.groupSpace, spawnedBy: params.spawnedBy, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, 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 b3b35cbdc..7ca51ab5f 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -35,6 +35,10 @@ export type RunEmbeddedPiAgentParams = { groupSpace?: string | null; /** Parent session key for subagent policy inheritance. */ spawnedBy?: string | null; + senderId?: string | null; + senderName?: string | null; + senderUsername?: string | null; + senderE164?: 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 90bdfb721..0ce68d02a 100644 --- a/src/agents/pi-embedded-runner/run/types.ts +++ b/src/agents/pi-embedded-runner/run/types.ts @@ -31,6 +31,10 @@ export type EmbeddedRunAttemptParams = { groupSpace?: string | null; /** Parent session key for subagent policy inheritance. */ spawnedBy?: string | null; + senderId?: string | null; + senderName?: string | null; + senderUsername?: string | null; + senderE164?: 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 bec7680a5..804786150 100644 --- a/src/agents/pi-tools-agent-config.test.ts +++ b/src/agents/pi-tools-agent-config.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import "./test-helpers/fast-coding-tools.js"; import type { ClawdbotConfig } from "../config/config.js"; import { createClawdbotCodingTools } from "./pi-tools.js"; import type { SandboxDockerConfig } from "./sandbox.js"; @@ -270,6 +271,75 @@ describe("Agent-specific tool filtering", () => { expect(defaultNames).not.toContain("exec"); }); + it("should apply per-sender tool policies for group tools", () => { + const cfg: ClawdbotConfig = { + channels: { + whatsapp: { + groups: { + "*": { + tools: { allow: ["read"] }, + toolsBySender: { + alice: { allow: ["read", "exec"] }, + }, + }, + }, + }, + }, + }; + + const aliceTools = createClawdbotCodingTools({ + config: cfg, + sessionKey: "agent:main:whatsapp:group:family", + senderId: "alice", + workspaceDir: "/tmp/test-group-sender", + agentDir: "/tmp/agent-group-sender", + }); + const aliceNames = aliceTools.map((t) => t.name); + expect(aliceNames).toContain("read"); + expect(aliceNames).toContain("exec"); + + const bobTools = createClawdbotCodingTools({ + config: cfg, + sessionKey: "agent:main:whatsapp:group:family", + senderId: "bob", + workspaceDir: "/tmp/test-group-sender-bob", + agentDir: "/tmp/agent-group-sender", + }); + const bobNames = bobTools.map((t) => t.name); + expect(bobNames).toContain("read"); + expect(bobNames).not.toContain("exec"); + }); + + it("should not let default sender policy override group tools", () => { + const cfg: ClawdbotConfig = { + channels: { + whatsapp: { + groups: { + "*": { + toolsBySender: { + admin: { allow: ["read", "exec"] }, + }, + }, + locked: { + tools: { allow: ["read"] }, + }, + }, + }, + }, + }; + + const adminTools = createClawdbotCodingTools({ + config: cfg, + sessionKey: "agent:main:whatsapp:group:locked", + senderId: "admin", + workspaceDir: "/tmp/test-group-default-override", + agentDir: "/tmp/agent-group-default-override", + }); + const adminNames = adminTools.map((t) => t.name); + expect(adminNames).toContain("read"); + expect(adminNames).not.toContain("exec"); + }); + it("should resolve telegram group tool policy for topic session keys", () => { const cfg: ClawdbotConfig = { channels: { diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts index 98585ca9d..e8ff994e9 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/pi-tools.policy.ts @@ -207,6 +207,10 @@ export function resolveGroupToolPolicy(params: { groupChannel?: string | null; groupSpace?: string | null; accountId?: string | null; + senderId?: string | null; + senderName?: string | null; + senderUsername?: string | null; + senderE164?: string | null; }): SandboxToolPolicy | undefined { if (!params.config) return undefined; const sessionContext = resolveGroupContextFromSessionKey(params.sessionKey); @@ -229,12 +233,20 @@ export function resolveGroupToolPolicy(params: { groupChannel: params.groupChannel, groupSpace: params.groupSpace, accountId: params.accountId, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, }) ?? resolveChannelGroupToolsPolicy({ cfg: params.config, channel, groupId, accountId: params.accountId, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, }); return pickToolPolicy(toolsConfig); } diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index ba0fe13bc..467291bbe 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -139,6 +139,10 @@ export function createClawdbotCodingTools(options?: { groupSpace?: string | null; /** Parent session key for subagent group policy inheritance. */ spawnedBy?: string | null; + senderId?: string | null; + senderName?: string | null; + senderUsername?: string | null; + senderE164?: 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). */ @@ -171,6 +175,10 @@ export function createClawdbotCodingTools(options?: { groupChannel: options?.groupChannel, groupSpace: options?.groupSpace, accountId: options?.agentAccountId, + senderId: options?.senderId, + senderName: options?.senderName, + senderUsername: options?.senderUsername, + senderE164: options?.senderE164, }); const profilePolicy = resolveToolProfilePolicy(profile); const providerProfilePolicy = resolveToolProfilePolicy(providerProfile); diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index a428aa6da..2f80bca5e 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -219,6 +219,10 @@ export async function runAgentTurnWithFallback(params: { groupChannel: params.sessionCtx.GroupChannel?.trim() ?? params.sessionCtx.GroupSubject?.trim(), groupSpace: params.sessionCtx.GroupSpace?.trim() ?? undefined, + senderId: params.sessionCtx.SenderId?.trim() || undefined, + senderName: params.sessionCtx.SenderName?.trim() || undefined, + senderUsername: params.sessionCtx.SenderUsername?.trim() || undefined, + senderE164: params.sessionCtx.SenderE164?.trim() || undefined, // Provider threading context for tool auto-injection ...buildThreadingToolContext({ sessionCtx: params.sessionCtx, diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index a7d590750..f7890cf32 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -114,6 +114,10 @@ export async function runMemoryFlushIfNeeded(params: { config: params.followupRun.run.config, hasRepliedRef: params.opts?.hasRepliedRef, }), + senderId: params.sessionCtx.SenderId?.trim() || undefined, + senderName: params.sessionCtx.SenderName?.trim() || undefined, + senderUsername: params.sessionCtx.SenderUsername?.trim() || undefined, + senderE164: params.sessionCtx.SenderE164?.trim() || undefined, sessionFile: params.followupRun.run.sessionFile, workspaceDir: params.followupRun.run.workspaceDir, agentDir: params.followupRun.run.agentDir, diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index febbc6e6a..5653faa44 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -146,6 +146,10 @@ export function createFollowupRunner(params: { groupId: queued.run.groupId, groupChannel: queued.run.groupChannel, groupSpace: queued.run.groupSpace, + senderId: queued.run.senderId, + senderName: queued.run.senderName, + senderUsername: queued.run.senderUsername, + senderE164: queued.run.senderE164, 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 40802d2b7..895309aa7 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -370,6 +370,10 @@ export async function runPreparedReply( groupId: resolveGroupSessionKey(sessionCtx)?.id ?? undefined, groupChannel: sessionCtx.GroupChannel?.trim() ?? sessionCtx.GroupSubject?.trim(), groupSpace: sessionCtx.GroupSpace?.trim() ?? undefined, + senderId: sessionCtx.SenderId?.trim() || undefined, + senderName: sessionCtx.SenderName?.trim() || undefined, + senderUsername: sessionCtx.SenderUsername?.trim() || undefined, + senderE164: sessionCtx.SenderE164?.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 332e9bae1..31dbd9e7b 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -51,6 +51,10 @@ export type FollowupRun = { groupId?: string; groupChannel?: string; groupSpace?: string; + senderId?: string; + senderName?: string; + senderUsername?: string; + senderE164?: string; sessionFile: string; workspaceDir: string; config: ClawdbotConfig; diff --git a/src/channels/plugins/group-mentions.ts b/src/channels/plugins/group-mentions.ts index 9d254e57a..ce5727cc1 100644 --- a/src/channels/plugins/group-mentions.ts +++ b/src/channels/plugins/group-mentions.ts @@ -2,6 +2,7 @@ import type { ClawdbotConfig } from "../../config/config.js"; import { resolveChannelGroupRequireMention, resolveChannelGroupToolsPolicy, + resolveToolsBySender, } from "../../config/group-policy.js"; import type { DiscordConfig } from "../../config/types.js"; import type { GroupToolPolicyConfig } from "../../config/types.tools.js"; @@ -13,6 +14,10 @@ type GroupMentionParams = { groupChannel?: string | null; groupSpace?: string | null; accountId?: string | null; + senderId?: string | null; + senderName?: string | null; + senderUsername?: string | null; + senderE164?: string | null; }; function normalizeDiscordSlug(value?: string | null) { @@ -172,6 +177,10 @@ export function resolveGoogleChatGroupToolPolicy( channel: "googlechat", groupId: params.groupId, accountId: params.accountId, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, }); } @@ -226,6 +235,10 @@ export function resolveTelegramGroupToolPolicy( channel: "telegram", groupId: chatId ?? params.groupId, accountId: params.accountId, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, }); } @@ -237,6 +250,10 @@ export function resolveWhatsAppGroupToolPolicy( channel: "whatsapp", groupId: params.groupId, accountId: params.accountId, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, }); } @@ -248,6 +265,10 @@ export function resolveIMessageGroupToolPolicy( channel: "imessage", groupId: params.groupId, accountId: params.accountId, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, }); } @@ -268,8 +289,24 @@ export function resolveDiscordGroupToolPolicy( ? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`]) : undefined) ?? (groupChannel ? channelEntries[normalizeDiscordSlug(groupChannel)] : undefined); + const senderPolicy = resolveToolsBySender({ + toolsBySender: entry?.toolsBySender, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); + if (senderPolicy) return senderPolicy; if (entry?.tools) return entry.tools; } + const guildSenderPolicy = resolveToolsBySender({ + toolsBySender: guildEntry?.toolsBySender, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); + if (guildSenderPolicy) return guildSenderPolicy; if (guildEntry?.tools) return guildEntry.tools; return undefined; } @@ -302,6 +339,14 @@ export function resolveSlackGroupToolPolicy( } } const resolved = matched ?? channels["*"]; + const senderPolicy = resolveToolsBySender({ + toolsBySender: resolved?.toolsBySender, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); + if (senderPolicy) return senderPolicy; if (resolved?.tools) return resolved.tools; return undefined; } @@ -314,5 +359,9 @@ export function resolveBlueBubblesGroupToolPolicy( channel: "bluebubbles", groupId: params.groupId, accountId: params.accountId, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, }); } diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 99f847fda..0aaae3bb8 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -155,6 +155,10 @@ export type ChannelGroupContext = { groupChannel?: string | null; groupSpace?: string | null; accountId?: string | null; + senderId?: string | null; + senderName?: string | null; + senderUsername?: string | null; + senderE164?: string | null; }; export type ChannelCapabilities = { diff --git a/src/config/group-policy.ts b/src/config/group-policy.ts index faad3508b..cc999f9c2 100644 --- a/src/config/group-policy.ts +++ b/src/config/group-policy.ts @@ -1,13 +1,14 @@ 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"; +import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js"; export type GroupPolicyChannel = ChannelId; export type ChannelGroupConfig = { requireMention?: boolean; tools?: GroupToolPolicyConfig; + toolsBySender?: GroupToolPolicyBySenderConfig; }; export type ChannelGroupPolicy = { @@ -19,6 +20,65 @@ export type ChannelGroupPolicy = { type ChannelGroups = Record; +export type GroupToolPolicySender = { + senderId?: string | null; + senderName?: string | null; + senderUsername?: string | null; + senderE164?: string | null; +}; + +function normalizeSenderKey(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ""; + const withoutAt = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed; + return withoutAt.toLowerCase(); +} + +export function resolveToolsBySender( + params: { + toolsBySender?: GroupToolPolicyBySenderConfig; + } & GroupToolPolicySender, +): GroupToolPolicyConfig | undefined { + const toolsBySender = params.toolsBySender; + if (!toolsBySender) return undefined; + const entries = Object.entries(toolsBySender); + if (entries.length === 0) return undefined; + + const normalized = new Map(); + let wildcard: GroupToolPolicyConfig | undefined; + for (const [rawKey, policy] of entries) { + if (!policy) continue; + const key = normalizeSenderKey(rawKey); + if (!key) continue; + if (key === "*") { + wildcard = policy; + continue; + } + if (!normalized.has(key)) { + normalized.set(key, policy); + } + } + + const candidates: string[] = []; + const pushCandidate = (value?: string | null) => { + const trimmed = value?.trim(); + if (!trimmed) return; + candidates.push(trimmed); + }; + pushCandidate(params.senderId); + pushCandidate(params.senderE164); + pushCandidate(params.senderUsername); + pushCandidate(params.senderName); + + for (const candidate of candidates) { + const key = normalizeSenderKey(candidate); + if (!key) continue; + const match = normalized.get(key); + if (match) return match; + } + return wildcard; +} + function resolveChannelGroups( cfg: ClawdbotConfig, channel: GroupPolicyChannel, @@ -94,14 +154,32 @@ export function resolveChannelGroupRequireMention(params: { return true; } -export function resolveChannelGroupToolsPolicy(params: { - cfg: ClawdbotConfig; - channel: GroupPolicyChannel; - groupId?: string | null; - accountId?: string | null; -}): GroupToolPolicyConfig | undefined { +export function resolveChannelGroupToolsPolicy( + params: { + cfg: ClawdbotConfig; + channel: GroupPolicyChannel; + groupId?: string | null; + accountId?: string | null; + } & GroupToolPolicySender, +): GroupToolPolicyConfig | undefined { const { groupConfig, defaultConfig } = resolveChannelGroupPolicy(params); + const groupSenderPolicy = resolveToolsBySender({ + toolsBySender: groupConfig?.toolsBySender, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); + if (groupSenderPolicy) return groupSenderPolicy; if (groupConfig?.tools) return groupConfig.tools; + const defaultSenderPolicy = resolveToolsBySender({ + toolsBySender: defaultConfig?.toolsBySender, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); + if (defaultSenderPolicy) return defaultSenderPolicy; if (defaultConfig?.tools) return defaultConfig.tools; return undefined; } diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 071d6e6a7..c51061394 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -8,7 +8,7 @@ import type { } from "./types.base.js"; import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js"; -import type { GroupToolPolicyConfig } from "./types.tools.js"; +import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js"; export type DiscordDmConfig = { /** If false, ignore all incoming Discord DMs. Default: true. */ @@ -28,6 +28,7 @@ export type DiscordGuildChannelConfig = { requireMention?: boolean; /** Optional tool policy overrides for this channel. */ tools?: GroupToolPolicyConfig; + toolsBySender?: GroupToolPolicyBySenderConfig; /** 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. */ @@ -45,6 +46,7 @@ export type DiscordGuildEntry = { requireMention?: boolean; /** Optional tool policy overrides for this guild (used when channel override is missing). */ tools?: GroupToolPolicyConfig; + toolsBySender?: GroupToolPolicyBySenderConfig; /** 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 88ceb02c1..feb52115a 100644 --- a/src/config/types.imessage.ts +++ b/src/config/types.imessage.ts @@ -6,7 +6,7 @@ import type { } from "./types.base.js"; import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; import type { DmConfig } from "./types.messages.js"; -import type { GroupToolPolicyConfig } from "./types.tools.js"; +import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js"; export type IMessageAccountConfig = { /** Optional display name for this account (used in CLI/UI lists). */ @@ -64,6 +64,7 @@ export type IMessageAccountConfig = { { requireMention?: boolean; tools?: GroupToolPolicyConfig; + toolsBySender?: GroupToolPolicyBySenderConfig; } >; /** Heartbeat visibility settings for this channel. */ diff --git a/src/config/types.msteams.ts b/src/config/types.msteams.ts index a8552c6eb..98ae37783 100644 --- a/src/config/types.msteams.ts +++ b/src/config/types.msteams.ts @@ -6,7 +6,7 @@ import type { } from "./types.base.js"; import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; import type { DmConfig } from "./types.messages.js"; -import type { GroupToolPolicyConfig } from "./types.tools.js"; +import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js"; export type MSTeamsWebhookConfig = { /** Port for the webhook server. Default: 3978. */ @@ -24,6 +24,7 @@ export type MSTeamsChannelConfig = { requireMention?: boolean; /** Optional tool policy overrides for this channel. */ tools?: GroupToolPolicyConfig; + toolsBySender?: GroupToolPolicyBySenderConfig; /** Reply style: "thread" replies to the message, "top-level" posts a new message. */ replyStyle?: MSTeamsReplyStyle; }; @@ -34,6 +35,7 @@ export type MSTeamsTeamConfig = { requireMention?: boolean; /** Default tool policy for channels in this team. */ tools?: GroupToolPolicyConfig; + toolsBySender?: GroupToolPolicyBySenderConfig; /** 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 564248503..0f6b9e388 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -7,7 +7,7 @@ import type { } from "./types.base.js"; import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js"; -import type { GroupToolPolicyConfig } from "./types.tools.js"; +import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js"; export type SlackDmConfig = { /** If false, ignore all incoming Slack DMs. Default: true. */ @@ -33,6 +33,7 @@ export type SlackChannelConfig = { requireMention?: boolean; /** Optional tool policy overrides for this channel. */ tools?: GroupToolPolicyConfig; + toolsBySender?: GroupToolPolicyBySenderConfig; /** 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 5d0b80e25..22e50fc79 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -9,7 +9,7 @@ import type { } from "./types.base.js"; import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js"; -import type { GroupToolPolicyConfig } from "./types.tools.js"; +import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js"; export type TelegramActionConfig = { reactions?: boolean; @@ -138,6 +138,7 @@ export type TelegramGroupConfig = { requireMention?: boolean; /** Optional tool policy overrides for this group. */ tools?: GroupToolPolicyConfig; + toolsBySender?: GroupToolPolicyBySenderConfig; /** 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 ad7f69d85..397bf44ce 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -149,6 +149,8 @@ export type GroupToolPolicyConfig = { deny?: string[]; }; +export type GroupToolPolicyBySenderConfig = Record; + 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 84d7379fd..65f527a6a 100644 --- a/src/config/types.whatsapp.ts +++ b/src/config/types.whatsapp.ts @@ -6,7 +6,7 @@ import type { } from "./types.base.js"; import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; import type { DmConfig } from "./types.messages.js"; -import type { GroupToolPolicyConfig } from "./types.tools.js"; +import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js"; export type WhatsAppActionConfig = { reactions?: boolean; @@ -70,6 +70,7 @@ export type WhatsAppConfig = { { requireMention?: boolean; tools?: GroupToolPolicyConfig; + toolsBySender?: GroupToolPolicyBySenderConfig; } >; /** Acknowledgment reaction sent immediately upon message receipt. */ @@ -135,6 +136,7 @@ export type WhatsAppAccountConfig = { { requireMention?: boolean; tools?: GroupToolPolicyConfig; + toolsBySender?: GroupToolPolicyBySenderConfig; } >; /** 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 4b1b9338a..615e9097c 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -22,6 +22,8 @@ import { resolveTelegramCustomCommands, } from "./telegram-custom-commands.js"; +const ToolPolicyBySenderSchema = z.record(z.string(), ToolPolicySchema).optional(); + const TelegramInlineButtonsScopeSchema = z.enum(["off", "dm", "group", "all", "allowlist"]); const TelegramCapabilitiesSchema = z.union([ @@ -47,6 +49,7 @@ export const TelegramGroupSchema = z .object({ requireMention: z.boolean().optional(), tools: ToolPolicySchema, + toolsBySender: ToolPolicyBySenderSchema, skills: z.array(z.string()).optional(), enabled: z.boolean().optional(), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), @@ -180,6 +183,7 @@ export const DiscordGuildChannelSchema = z allow: z.boolean().optional(), requireMention: z.boolean().optional(), tools: ToolPolicySchema, + toolsBySender: ToolPolicyBySenderSchema, skills: z.array(z.string()).optional(), enabled: z.boolean().optional(), users: z.array(z.union([z.string(), z.number()])).optional(), @@ -193,6 +197,7 @@ export const DiscordGuildSchema = z slug: z.string().optional(), requireMention: z.boolean().optional(), tools: ToolPolicySchema, + toolsBySender: ToolPolicyBySenderSchema, 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(), @@ -361,6 +366,7 @@ export const SlackChannelSchema = z allow: z.boolean().optional(), requireMention: z.boolean().optional(), tools: ToolPolicySchema, + toolsBySender: ToolPolicyBySenderSchema, allowBots: z.boolean().optional(), users: z.array(z.union([z.string(), z.number()])).optional(), skills: z.array(z.string()).optional(), @@ -571,6 +577,7 @@ export const IMessageAccountSchemaBase = z .object({ requireMention: z.boolean().optional(), tools: ToolPolicySchema, + toolsBySender: ToolPolicyBySenderSchema, }) .strict() .optional(), @@ -627,6 +634,7 @@ const BlueBubblesGroupConfigSchema = z .object({ requireMention: z.boolean().optional(), tools: ToolPolicySchema, + toolsBySender: ToolPolicyBySenderSchema, }) .strict(); @@ -686,6 +694,7 @@ export const MSTeamsChannelSchema = z .object({ requireMention: z.boolean().optional(), tools: ToolPolicySchema, + toolsBySender: ToolPolicyBySenderSchema, replyStyle: MSTeamsReplyStyleSchema.optional(), }) .strict(); @@ -694,6 +703,7 @@ export const MSTeamsTeamSchema = z .object({ requireMention: z.boolean().optional(), tools: ToolPolicySchema, + toolsBySender: ToolPolicyBySenderSchema, 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 7266f8bf6..f9f6c6d26 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -10,6 +10,8 @@ import { import { ToolPolicySchema } from "./zod-schema.agent-runtime.js"; import { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js"; +const ToolPolicyBySenderSchema = z.record(z.string(), ToolPolicySchema).optional(); + export const WhatsAppAccountSchema = z .object({ name: z.string().optional(), @@ -41,6 +43,7 @@ export const WhatsAppAccountSchema = z .object({ requireMention: z.boolean().optional(), tools: ToolPolicySchema, + toolsBySender: ToolPolicyBySenderSchema, }) .strict() .optional(), @@ -105,6 +108,7 @@ export const WhatsAppConfigSchema = z .object({ requireMention: z.boolean().optional(), tools: ToolPolicySchema, + toolsBySender: ToolPolicyBySenderSchema, }) .strict() .optional(), diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index cb4e95a82..c5c09b20e 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -74,6 +74,7 @@ export type { DmConfig, GroupPolicy, GroupToolPolicyConfig, + GroupToolPolicyBySenderConfig, MarkdownConfig, MarkdownTableMode, GoogleChatAccountConfig, @@ -114,6 +115,7 @@ export { resolveAckReaction } from "../agents/identity.js"; export type { ReplyPayload } from "../auto-reply/types.js"; export type { ChunkMode } from "../auto-reply/chunk.js"; export { SILENT_REPLY_TOKEN, isSilentReplyText } from "../auto-reply/tokens.js"; +export { resolveToolsBySender } from "../config/group-policy.js"; export { buildPendingHistoryContextFromMap, clearHistoryEntries,