diff --git a/CHANGELOG.md b/CHANGELOG.md index c4073193b..7ab80bdde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Status: unreleased. ### Changes - macOS: limit project-local `node_modules/.bin` PATH preference to debug builds (reduce PATH hijacking risk). +- Tools: add per-sender group tool policies and fix precedence. (#1757) Thanks @adam91holt. - Agents: summarize dropped messages during compaction safeguard pruning. (#2509) Thanks @jogi47. - Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204. - Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281) diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 395f13c6a..8aba9d336 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -298,8 +298,12 @@ ack reaction after the bot replies. - `guilds."*"`: default per-guild settings applied when no explicit entry exists. - `guilds..slug`: optional friendly slug used for display names. - `guilds..users`: optional per-guild user allowlist (ids or names). +- `guilds..tools`: optional per-guild tool policy overrides (`allow`/`deny`/`alsoAllow`) used when the channel override is missing. +- `guilds..toolsBySender`: optional per-sender tool policy overrides at the guild level (applies when the channel override is missing; `"*"` wildcard supported). - `guilds..channels..allow`: allow/deny the channel when `groupPolicy="allowlist"`. - `guilds..channels..requireMention`: mention gating for the channel. +- `guilds..channels..tools`: optional per-channel tool policy overrides (`allow`/`deny`/`alsoAllow`). +- `guilds..channels..toolsBySender`: optional per-sender tool policy overrides within the channel (`"*"` wildcard supported). - `guilds..channels..users`: optional per-channel user allowlist. - `guilds..channels..skills`: skill filter (omit = all skills, empty = none). - `guilds..channels..systemPrompt`: extra system prompt for the channel (combined with channel topic). diff --git a/docs/channels/msteams.md b/docs/channels/msteams.md index 2f6ed5f83..e0a36ad48 100644 --- a/docs/channels/msteams.md +++ b/docs/channels/msteams.md @@ -421,8 +421,12 @@ Key settings (see `/gateway/configuration` for shared channel patterns): - `channels.msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)). - `channels.msteams.teams..replyStyle`: per-team override. - `channels.msteams.teams..requireMention`: per-team override. +- `channels.msteams.teams..tools`: default per-team tool policy overrides (`allow`/`deny`/`alsoAllow`) used when a channel override is missing. +- `channels.msteams.teams..toolsBySender`: default per-team per-sender tool policy overrides (`"*"` wildcard supported). - `channels.msteams.teams..channels..replyStyle`: per-channel override. - `channels.msteams.teams..channels..requireMention`: per-channel override. +- `channels.msteams.teams..channels..tools`: per-channel tool policy overrides (`allow`/`deny`/`alsoAllow`). +- `channels.msteams.teams..channels..toolsBySender`: per-channel per-sender tool policy overrides (`"*"` wildcard supported). - `channels.msteams.sharePointSiteId`: SharePoint site ID for file uploads in group chats/channels (see [Sending files in group chats](#sending-files-in-group-chats)). ## Routing & Sessions diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 5f768db0e..8ab5846b7 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -464,6 +464,8 @@ For fine-grained control, use these tags in agent responses: Channel options (`channels.slack.channels.` or `channels.slack.channels.`): - `allow`: allow/deny the channel when `groupPolicy="allowlist"`. - `requireMention`: mention gating for the channel. +- `tools`: optional per-channel tool policy overrides (`allow`/`deny`/`alsoAllow`). +- `toolsBySender`: optional per-sender tool policy overrides within the channel (keys are sender ids/@handles/emails; `"*"` wildcard supported). - `allowBots`: allow bot-authored messages in this channel (default: false). - `users`: optional per-channel user allowlist. - `skills`: skill filter (omit = all skills, empty = none). diff --git a/docs/concepts/groups.md b/docs/concepts/groups.md index d6e72aac8..0e5ad399c 100644 --- a/docs/concepts/groups.md +++ b/docs/concepts/groups.md @@ -232,6 +232,42 @@ Notes: - Discord defaults live in `channels.discord.guilds."*"` (overridable per guild/channel). - Group history context is wrapped uniformly across channels and is **pending-only** (messages skipped due to mention gating); use `messages.groupChat.historyLimit` for the global default and `channels..historyLimit` (or `channels..accounts.*.historyLimit`) for overrides. Set `0` to disable. +## Group/channel tool restrictions (optional) +Some channel configs support restricting which tools are available **inside a specific group/room/channel**. + +- `tools`: allow/deny tools for the whole group. +- `toolsBySender`: per-sender overrides within the group (keys are sender IDs/usernames/emails/phone numbers depending on the channel). Use `"*"` as a wildcard. + +Resolution order (most specific wins): +1) group/channel `toolsBySender` match +2) group/channel `tools` +3) default (`"*"`) `toolsBySender` match +4) default (`"*"`) `tools` + +Example (Telegram): + +```json5 +{ + channels: { + telegram: { + groups: { + "*": { tools: { deny: ["exec"] } }, + "-1001234567890": { + tools: { deny: ["exec", "read", "write"] }, + toolsBySender: { + "123456789": { alsoAllow: ["exec"] } + } + } + } + } + } +} +``` + +Notes: +- Group/channel tool restrictions are applied in addition to global/agent tool policy (deny still wins). +- Some channels use different nesting for rooms/channels (e.g., Discord `guilds.*.channels.*`, Slack `channels.*`, MS Teams `teams.*.channels.*`). + ## Group allowlists When `channels.whatsapp.groups`, `channels.telegram.groups`, or `channels.imessage.groups` is configured, the keys act as a group allowlist. Use `"*"` to allow all groups while still setting default mention behavior. 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 d6e125e33..4445775d3 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/pi-tools.policy.ts @@ -233,6 +233,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); @@ -255,12 +259,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 87dd0919d..3d2f46ff1 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -140,6 +140,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). */ @@ -174,6 +178,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 7aae24e6a..5aff68639 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -232,6 +232,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 2b2e26b0c..ae6d244a8 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -115,6 +115,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 7f5bdde21..77edf66e5 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -147,6 +147,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/browser/client-fetch.ts b/src/browser/client-fetch.ts index a1efb8f86..63b71d733 100644 --- a/src/browser/client-fetch.ts +++ b/src/browser/client-fetch.ts @@ -19,7 +19,8 @@ function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number): msgLower.includes("timed out") || msgLower.includes("timeout") || msgLower.includes("aborted") || - msgLower.includes("abort"); + msgLower.includes("abort") || + msgLower.includes("aborterror"); if (looksLikeTimeout) { return new Error( `Can't reach the clawd browser control service (timed out after ${timeoutMs}ms). ${hint}`, diff --git a/src/browser/server.covers-additional-endpoint-branches.test.ts b/src/browser/server.covers-additional-endpoint-branches.test.ts index 948b5984f..3710d8ed6 100644 --- a/src/browser/server.covers-additional-endpoint-branches.test.ts +++ b/src/browser/server.covers-additional-endpoint-branches.test.ts @@ -311,6 +311,9 @@ describe("backward compatibility (profile parameter)", () => { prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT; process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2); + prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT; + process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2); + vi.stubGlobal( "fetch", vi.fn(async (url: string) => { diff --git a/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts b/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts index 1ba1de28c..3b55339b5 100644 --- a/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts +++ b/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts @@ -288,6 +288,9 @@ describe("profile CRUD endpoints", () => { prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT; process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2); + prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT; + process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2); + vi.stubGlobal( "fetch", vi.fn(async (url: string) => { diff --git a/src/canvas-host/a2ui/.bundle.hash b/src/canvas-host/a2ui/.bundle.hash index 8b654f12f..19a232f5c 100644 --- a/src/canvas-host/a2ui/.bundle.hash +++ b/src/canvas-host/a2ui/.bundle.hash @@ -1 +1 @@ -6d63b866aa0e917b278c6bef42229e8cd1f43c8ba31c845a96b4d9d5ce780265 +2567ca5bbc065b922d96717a488d5db3120b5b033c5d0508682d1aa8fbba470a diff --git a/src/channels/plugins/group-mentions.ts b/src/channels/plugins/group-mentions.ts index 9d254e57a..48d640dfc 100644 --- a/src/channels/plugins/group-mentions.ts +++ b/src/channels/plugins/group-mentions.ts @@ -2,9 +2,13 @@ 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"; +import type { + GroupToolPolicyBySenderConfig, + GroupToolPolicyConfig, +} from "../../config/types.tools.js"; import { resolveSlackAccount } from "../../slack/accounts.js"; type GroupMentionParams = { @@ -13,6 +17,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 +180,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 +238,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 +253,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 +268,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 +292,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; } @@ -294,7 +334,9 @@ export function resolveSlackGroupToolPolicy( channelName ?? "", normalizedName, ].filter(Boolean); - let matched: { tools?: GroupToolPolicyConfig } | undefined; + let matched: + | { tools?: GroupToolPolicyConfig; toolsBySender?: GroupToolPolicyBySenderConfig } + | undefined; for (const candidate of candidates) { if (candidate && channels[candidate]) { matched = channels[candidate]; @@ -302,6 +344,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 +364,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 6a76743f2..7ec464d48 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/cli/browser-cli-inspect.test.ts b/src/cli/browser-cli-inspect.test.ts index 3e68de49f..8b398e510 100644 --- a/src/cli/browser-cli-inspect.test.ts +++ b/src/cli/browser-cli-inspect.test.ts @@ -1,17 +1,19 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { Command } from "commander"; -const clientMocks = vi.hoisted(() => ({ - browserSnapshot: vi.fn(async () => ({ +const gatewayMocks = vi.hoisted(() => ({ + callGatewayFromCli: vi.fn(async () => ({ ok: true, format: "ai", targetId: "t1", url: "https://example.com", snapshot: "ok", })), - resolveBrowserControlUrl: vi.fn(() => "http://127.0.0.1:18791"), })); -vi.mock("../browser/client.js", () => clientMocks); + +vi.mock("./gateway-rpc.js", () => ({ + callGatewayFromCli: gatewayMocks.callGatewayFromCli, +})); const configMocks = vi.hoisted(() => ({ loadConfig: vi.fn(() => ({ browser: {} })), @@ -64,6 +66,7 @@ describe("browser cli snapshot defaults", () => { configMocks.loadConfig.mockReturnValue({ browser: { snapshotDefaults: { mode: "efficient" } }, }); + const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js"); const program = new Command(); const browser = program.command("browser").option("--json", false); @@ -84,13 +87,15 @@ describe("browser cli snapshot defaults", () => { configMocks.loadConfig.mockReturnValue({ browser: { snapshotDefaults: { mode: "efficient" } }, }); - clientMocks.browserSnapshot.mockResolvedValueOnce({ + + gatewayMocks.callGatewayFromCli.mockResolvedValueOnce({ ok: true, format: "aria", targetId: "t1", url: "https://example.com", nodes: [], }); + const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js"); const program = new Command(); const browser = program.command("browser").option("--json", false); diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 2cba37b49..ca9d8ae96 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -1,4 +1,19 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +let previousProfile: string | undefined; + +beforeAll(() => { + previousProfile = process.env.CLAWDBOT_PROFILE; + process.env.CLAWDBOT_PROFILE = "isolated"; +}); + +afterAll(() => { + if (previousProfile === undefined) { + delete process.env.CLAWDBOT_PROFILE; + } else { + process.env.CLAWDBOT_PROFILE = previousProfile; + } +}); const mocks = vi.hoisted(() => ({ loadSessionStore: vi.fn().mockReturnValue({ 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 70ea5f1fb..07d4e658f 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 fa9e2890a..4d476f88e 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; @@ -146,6 +146,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 d84dd1aa7..bb1d45bf0 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -158,6 +158,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 26e279faf..fbf6a2173 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(), @@ -186,6 +189,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(), @@ -199,6 +203,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(), @@ -374,6 +379,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(), @@ -584,6 +590,7 @@ export const IMessageAccountSchemaBase = z .object({ requireMention: z.boolean().optional(), tools: ToolPolicySchema, + toolsBySender: ToolPolicyBySenderSchema, }) .strict() .optional(), @@ -640,6 +647,7 @@ const BlueBubblesGroupConfigSchema = z .object({ requireMention: z.boolean().optional(), tools: ToolPolicySchema, + toolsBySender: ToolPolicyBySenderSchema, }) .strict(); @@ -699,6 +707,7 @@ export const MSTeamsChannelSchema = z .object({ requireMention: z.boolean().optional(), tools: ToolPolicySchema, + toolsBySender: ToolPolicyBySenderSchema, replyStyle: MSTeamsReplyStyleSchema.optional(), }) .strict(); @@ -707,6 +716,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 c0c201ff0..b7f44a76c 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -81,6 +81,7 @@ export type { DmConfig, GroupPolicy, GroupToolPolicyConfig, + GroupToolPolicyBySenderConfig, MarkdownConfig, MarkdownTableMode, GoogleChatAccountConfig, @@ -121,6 +122,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,