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/docs/gateway/configuration.md b/docs/gateway/configuration.md index 3473c1ade..cea4d786d 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2768,6 +2768,7 @@ scheme/host for profiles that only set `cdpPort`. Defaults: - enabled: `true` +- evaluateEnabled: `true` (set `false` to disable `act:evaluate` and `wait --fn`) - control service: loopback only (port derived from `gateway.port`, default `18791`) - CDP URL: `http://127.0.0.1:18792` (control service + 1, legacy single-profile) - profile color: `#FF4500` (lobster-orange) @@ -2778,6 +2779,7 @@ Defaults: { browser: { enabled: true, + evaluateEnabled: true, // cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override defaultProfile: "chrome", profiles: { diff --git a/docs/gateway/security.md b/docs/gateway/security.md index f83ea3eb6..5f5990b9e 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -572,6 +572,9 @@ If that browser profile already contains logged-in sessions, the model can access those accounts and data. Treat browser profiles as **sensitive state**: - Prefer a dedicated profile for the agent (the default `clawd` profile). - Avoid pointing the agent at your personal daily-driver profile. +- `act:evaluate` and `wait --fn` run arbitrary JavaScript in the page context. + Prompt injection can steer the model into calling them. If you do not need + them, set `browser.evaluateEnabled=false` (see [Configuration](/gateway/configuration#browser-clawd-managed-browser)). - Keep host browser control disabled for sandboxed agents unless you trust them. - Treat browser downloads as untrusted input; prefer an isolated downloads directory. - Disable browser sync/password managers in the agent profile if possible (reduces blast radius). diff --git a/docs/security/formal-verification.md b/docs/security/formal-verification.md index 6028b8291..4e2c13771 100644 --- a/docs/security/formal-verification.md +++ b/docs/security/formal-verification.md @@ -20,7 +20,7 @@ misconfiguration safety), under explicit assumptions. ## Where the models live -Models are maintained in a separate repo: . +Models are maintained in a separate repo: [vignesh07/clawdbot-formal-models](https://github.com/vignesh07/clawdbot-formal-models). ## Important caveats diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 4b8b7eb00..db63ab93c 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -505,6 +505,9 @@ These are useful for “make the site behave like X” workflows: ## Security & privacy - The clawd browser profile may contain logged-in sessions; treat it as sensitive. +- `browser act kind=evaluate` / `clawdbot browser evaluate` and `wait --fn` + execute arbitrary JavaScript in the page context. Prompt injection can steer + this. Disable it with `browser.evaluateEnabled=false` if you do not need it. - For logins and anti-bot notes (X/Twitter, etc.), see [Browser login + X/Twitter posting](/tools/browser-login). - Keep the Gateway/node host private (loopback or tailnet-only). - Remote CDP endpoints are powerful; tunnel and protect them. 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/clawdbot-gateway-tool.test.ts b/src/agents/clawdbot-gateway-tool.test.ts index b452e9379..76d8ff8e4 100644 --- a/src/agents/clawdbot-gateway-tool.test.ts +++ b/src/agents/clawdbot-gateway-tool.test.ts @@ -20,8 +20,10 @@ describe("gateway tool", () => { vi.useFakeTimers(); const kill = vi.spyOn(process, "kill").mockImplementation(() => true); const previousStateDir = process.env.CLAWDBOT_STATE_DIR; + const previousProfile = process.env.CLAWDBOT_PROFILE; const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-test-")); process.env.CLAWDBOT_STATE_DIR = stateDir; + process.env.CLAWDBOT_PROFILE = "isolated"; try { const tool = createClawdbotTools({ @@ -62,6 +64,11 @@ describe("gateway tool", () => { } else { process.env.CLAWDBOT_STATE_DIR = previousStateDir; } + if (previousProfile === undefined) { + delete process.env.CLAWDBOT_PROFILE; + } else { + process.env.CLAWDBOT_PROFILE = previousProfile; + } } }); 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/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index cf552e157..4951d9ce5 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -1,6 +1,9 @@ import { startBrowserBridgeServer, stopBrowserBridgeServer } from "../../browser/bridge-server.js"; import { type ResolvedBrowserConfig, resolveProfile } from "../../browser/config.js"; -import { DEFAULT_CLAWD_BROWSER_COLOR } from "../../browser/constants.js"; +import { + DEFAULT_BROWSER_EVALUATE_ENABLED, + DEFAULT_CLAWD_BROWSER_COLOR, +} from "../../browser/constants.js"; import { BROWSER_BRIDGES } from "./browser-bridges.js"; import { DEFAULT_SANDBOX_BROWSER_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js"; import { @@ -39,10 +42,12 @@ function buildSandboxBrowserResolvedConfig(params: { controlPort: number; cdpPort: number; headless: boolean; + evaluateEnabled: boolean; }): ResolvedBrowserConfig { const cdpHost = "127.0.0.1"; return { enabled: true, + evaluateEnabled: params.evaluateEnabled, controlPort: params.controlPort, cdpProtocol: "http", cdpHost, @@ -76,6 +81,7 @@ export async function ensureSandboxBrowser(params: { workspaceDir: string; agentWorkspaceDir: string; cfg: SandboxConfig; + evaluateEnabled?: boolean; }): Promise { if (!params.cfg.browser.enabled) return null; if (!isToolAllowed(params.cfg.tools, "browser")) return null; @@ -170,6 +176,7 @@ export async function ensureSandboxBrowser(params: { controlPort: 0, cdpPort: mappedCdp, headless: params.cfg.browser.headless, + evaluateEnabled: params.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED, }), onEnsureAttachTarget, }); diff --git a/src/agents/sandbox/context.ts b/src/agents/sandbox/context.ts index 8e0c004b5..c983ca6df 100644 --- a/src/agents/sandbox/context.ts +++ b/src/agents/sandbox/context.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import type { ClawdbotConfig } from "../../config/config.js"; import { defaultRuntime } from "../../runtime.js"; import { resolveUserPath } from "../../utils.js"; +import { DEFAULT_BROWSER_EVALUATE_ENABLED } from "../../browser/constants.js"; import { syncSkillsToWorkspace } from "../skills.js"; import { DEFAULT_AGENT_WORKSPACE_DIR } from "../workspace.js"; import { ensureSandboxBrowser } from "./browser.js"; @@ -69,11 +70,14 @@ export async function resolveSandboxContext(params: { cfg, }); + const evaluateEnabled = + params.config?.browser?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED; const browser = await ensureSandboxBrowser({ scopeKey, workspaceDir, agentWorkspaceDir, cfg, + evaluateEnabled, }); return { diff --git a/src/agents/skills/config.ts b/src/agents/skills/config.ts index a0dab4125..71b3130a4 100644 --- a/src/agents/skills/config.ts +++ b/src/agents/skills/config.ts @@ -6,6 +6,7 @@ import type { SkillEligibilityContext, SkillEntry } from "./types.js"; const DEFAULT_CONFIG_VALUES: Record = { "browser.enabled": true, + "browser.evaluateEnabled": true, }; function isTruthy(value: unknown): boolean { 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 06facc416..63b71d733 100644 --- a/src/browser/client-fetch.ts +++ b/src/browser/client-fetch.ts @@ -14,7 +14,14 @@ function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number): ? "If this is a sandboxed session, ensure the sandbox browser is running and try again." : `Start (or restart) the Clawdbot gateway (Clawdbot.app menubar, or \`${formatCliCommand("clawdbot gateway")}\`) and try again.`; const msg = String(err); - if (msg.toLowerCase().includes("timed out") || msg.toLowerCase().includes("timeout")) { + const msgLower = msg.toLowerCase(); + const looksLikeTimeout = + msgLower.includes("timed out") || + msgLower.includes("timeout") || + msgLower.includes("aborted") || + 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}`, ); @@ -48,7 +55,7 @@ export async function fetchBrowserJson( const timeoutMs = init?.timeoutMs ?? 5000; try { if (isAbsoluteHttp(url)) { - return await fetchHttpJson(url, init ? { ...init, timeoutMs } : { timeoutMs }); + return await fetchHttpJson(url, { ...init, timeoutMs }); } const started = await startBrowserControlServiceFromConfig(); if (!started) { diff --git a/src/browser/config.ts b/src/browser/config.ts index 4f862db51..02ab0e0a1 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -8,6 +8,7 @@ import { resolveGatewayPort } from "../config/paths.js"; import { DEFAULT_CLAWD_BROWSER_COLOR, DEFAULT_CLAWD_BROWSER_ENABLED, + DEFAULT_BROWSER_EVALUATE_ENABLED, DEFAULT_BROWSER_DEFAULT_PROFILE_NAME, DEFAULT_CLAWD_BROWSER_PROFILE_NAME, } from "./constants.js"; @@ -15,6 +16,7 @@ import { CDP_PORT_RANGE_START, getUsedPorts } from "./profiles.js"; export type ResolvedBrowserConfig = { enabled: boolean; + evaluateEnabled: boolean; controlPort: number; cdpProtocol: "http" | "https"; cdpHost: string; @@ -140,6 +142,7 @@ export function resolveBrowserConfig( rootConfig?: ClawdbotConfig, ): ResolvedBrowserConfig { const enabled = cfg?.enabled ?? DEFAULT_CLAWD_BROWSER_ENABLED; + const evaluateEnabled = cfg?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED; const gatewayPort = resolveGatewayPort(rootConfig); const controlPort = deriveDefaultBrowserControlPort(gatewayPort ?? DEFAULT_BROWSER_CONTROL_PORT); const defaultColor = normalizeHexColor(cfg?.color); @@ -197,6 +200,7 @@ export function resolveBrowserConfig( return { enabled, + evaluateEnabled, controlPort, cdpProtocol, cdpHost: cdpInfo.parsed.hostname, diff --git a/src/browser/constants.ts b/src/browser/constants.ts index 88642a6c5..e06a7dff8 100644 --- a/src/browser/constants.ts +++ b/src/browser/constants.ts @@ -1,4 +1,5 @@ export const DEFAULT_CLAWD_BROWSER_ENABLED = true; +export const DEFAULT_BROWSER_EVALUATE_ENABLED = true; export const DEFAULT_CLAWD_BROWSER_COLOR = "#FF4500"; export const DEFAULT_CLAWD_BROWSER_PROFILE_NAME = "clawd"; export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "chrome"; diff --git a/src/browser/routes/agent.act.ts b/src/browser/routes/agent.act.ts index ae8d274b7..ee1242a4a 100644 --- a/src/browser/routes/agent.act.ts +++ b/src/browser/routes/agent.act.ts @@ -39,6 +39,7 @@ export function registerBrowserAgentActRoutes( const cdpUrl = profileCtx.profile.cdpUrl; const pw = await requirePwAi(res, `act:${kind}`); if (!pw) return; + const evaluateEnabled = ctx.state().resolved.evaluateEnabled; switch (kind) { case "click": { @@ -210,6 +211,16 @@ export function registerBrowserAgentActRoutes( : undefined; const fn = toStringOrEmpty(body.fn) || undefined; const timeoutMs = toNumber(body.timeoutMs) ?? undefined; + if (fn && !evaluateEnabled) { + return jsonError( + res, + 403, + [ + "wait --fn is disabled by config (browser.evaluateEnabled=false).", + "Docs: /gateway/configuration#browser-clawd-managed-browser", + ].join("\n"), + ); + } if ( timeMs === undefined && !text && @@ -240,6 +251,16 @@ export function registerBrowserAgentActRoutes( return res.json({ ok: true, targetId: tab.targetId }); } case "evaluate": { + if (!evaluateEnabled) { + return jsonError( + res, + 403, + [ + "act:evaluate is disabled by config (browser.evaluateEnabled=false).", + "Docs: /gateway/configuration#browser-clawd-managed-browser", + ].join("\n"), + ); + } const fn = toStringOrEmpty(body.fn); if (!fn) return jsonError(res, 400, "fn is required"); const ref = toStringOrEmpty(body.ref) || undefined; diff --git a/src/browser/server.agent-contract-form-layout-act-commands.test.ts b/src/browser/server.agent-contract-form-layout-act-commands.test.ts index 87293a9a3..9276a5280 100644 --- a/src/browser/server.agent-contract-form-layout-act-commands.test.ts +++ b/src/browser/server.agent-contract-form-layout-act-commands.test.ts @@ -7,6 +7,7 @@ let testPort = 0; let cdpBaseUrl = ""; let reachable = false; let cfgAttachOnly = false; +let cfgEvaluateEnabled = true; let createTargetId: string | null = null; let prevGatewayPort: string | undefined; @@ -89,6 +90,7 @@ vi.mock("../config/config.js", async (importOriginal) => { loadConfig: () => ({ browser: { enabled: true, + evaluateEnabled: cfgEvaluateEnabled, color: "#FF4500", attachOnly: cfgAttachOnly, headless: true, @@ -185,6 +187,7 @@ describe("browser control server", () => { beforeEach(async () => { reachable = false; cfgAttachOnly = false; + cfgEvaluateEnabled = true; createTargetId = null; cdpMocks.createTargetViaCdp.mockImplementation(async () => { @@ -349,6 +352,30 @@ describe("browser control server", () => { slowTimeoutMs, ); + it( + "blocks act:evaluate when browser.evaluateEnabled=false", + async () => { + cfgEvaluateEnabled = false; + const base = await startServerAndBase(); + + const waitRes = (await postJson(`${base}/act`, { + kind: "wait", + fn: "() => window.ready === true", + })) as { error?: string }; + expect(waitRes.error).toContain("browser.evaluateEnabled=false"); + expect(pwMocks.waitForViaPlaywright).not.toHaveBeenCalled(); + + const res = (await postJson(`${base}/act`, { + kind: "evaluate", + fn: "() => 1", + })) as { error?: string }; + + expect(res.error).toContain("browser.evaluateEnabled=false"); + expect(pwMocks.evaluateViaPlaywright).not.toHaveBeenCalled(); + }, + slowTimeoutMs, + ); + it("agent contract: hooks + response + downloads + screenshot", async () => { const base = await startServerAndBase(); diff --git a/src/browser/server.covers-additional-endpoint-branches.test.ts b/src/browser/server.covers-additional-endpoint-branches.test.ts index ee5463ab5..3710d8ed6 100644 --- a/src/browser/server.covers-additional-endpoint-branches.test.ts +++ b/src/browser/server.covers-additional-endpoint-branches.test.ts @@ -308,6 +308,11 @@ describe("backward compatibility (profile parameter)", () => { testPort = await getFreePort(); _cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`; + 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", @@ -344,6 +349,11 @@ describe("backward compatibility (profile parameter)", () => { afterEach(async () => { vi.unstubAllGlobals(); vi.restoreAllMocks(); + if (prevGatewayPort === undefined) { + delete process.env.CLAWDBOT_GATEWAY_PORT; + } else { + process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort; + } const { stopBrowserControlServer } = await import("./server.js"); await stopBrowserControlServer(); }); 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 f178858a7..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 @@ -285,6 +285,11 @@ describe("profile CRUD endpoints", () => { testPort = await getFreePort(); _cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`; + 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", @@ -299,6 +304,11 @@ describe("profile CRUD endpoints", () => { afterEach(async () => { vi.unstubAllGlobals(); vi.restoreAllMocks(); + if (prevGatewayPort === undefined) { + delete process.env.CLAWDBOT_GATEWAY_PORT; + } else { + process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort; + } const { stopBrowserControlServer } = await import("./server.js"); await stopBrowserControlServer(); }); 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 30111e9e5..8b398e510 100644 --- a/src/cli/browser-cli-inspect.test.ts +++ b/src/cli/browser-cli-inspect.test.ts @@ -1,23 +1,52 @@ 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: {} })), })); vi.mock("../config/config.js", () => configMocks); +const sharedMocks = vi.hoisted(() => ({ + callBrowserRequest: vi.fn( + async (_opts: unknown, params: { path?: string; query?: Record }) => { + const format = params.query?.format === "aria" ? "aria" : "ai"; + if (format === "aria") { + return { + ok: true, + format: "aria", + targetId: "t1", + url: "https://example.com", + nodes: [], + }; + } + return { + ok: true, + format: "ai", + targetId: "t1", + url: "https://example.com", + snapshot: "ok", + }; + }, + ), +})); +vi.mock("./browser-cli-shared.js", () => ({ + callBrowserRequest: sharedMocks.callBrowserRequest, +})); + const runtime = { log: vi.fn(), error: vi.fn(), @@ -37,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); @@ -44,26 +74,28 @@ describe("browser cli snapshot defaults", () => { await program.parseAsync(["browser", "snapshot"], { from: "user" }); - expect(clientMocks.browserSnapshot).toHaveBeenCalledWith( - "http://127.0.0.1:18791", - expect.objectContaining({ - format: "ai", - mode: "efficient", - }), - ); + expect(sharedMocks.callBrowserRequest).toHaveBeenCalled(); + const [, params] = sharedMocks.callBrowserRequest.mock.calls.at(-1) ?? []; + expect(params?.path).toBe("/snapshot"); + expect(params?.query).toMatchObject({ + format: "ai", + mode: "efficient", + }); }); it("does not apply config snapshot defaults to aria snapshots", async () => { 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); @@ -71,8 +103,9 @@ describe("browser cli snapshot defaults", () => { await program.parseAsync(["browser", "snapshot", "--format", "aria"], { from: "user" }); - expect(clientMocks.browserSnapshot).toHaveBeenCalled(); - const [, opts] = clientMocks.browserSnapshot.mock.calls.at(-1) ?? []; - expect(opts?.mode).toBeUndefined(); + expect(sharedMocks.callBrowserRequest).toHaveBeenCalled(); + const [, params] = sharedMocks.callBrowserRequest.mock.calls.at(-1) ?? []; + expect(params?.path).toBe("/snapshot"); + expect((params?.query as { mode?: unknown } | undefined)?.mode).toBeUndefined(); }); }); 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/schema.ts b/src/config/schema.ts index f05ac277c..69846236e 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -279,6 +279,7 @@ const FIELD_LABELS: Record = { "ui.seamColor": "Accent Color", "ui.assistant.name": "Assistant Name", "ui.assistant.avatar": "Assistant Avatar", + "browser.evaluateEnabled": "Browser Evaluate Enabled", "browser.snapshotDefaults": "Browser Snapshot Defaults", "browser.snapshotDefaults.mode": "Browser Snapshot Mode", "browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)", diff --git a/src/config/types.browser.ts b/src/config/types.browser.ts index cbd006589..d8678b80b 100644 --- a/src/config/types.browser.ts +++ b/src/config/types.browser.ts @@ -14,6 +14,8 @@ export type BrowserSnapshotDefaults = { }; export type BrowserConfig = { enabled?: boolean; + /** If false, disable browser act:evaluate (arbitrary JS). Default: true */ + evaluateEnabled?: boolean; /** Base URL of the CDP endpoint (for remote browsers). Default: loopback CDP on the derived port. */ cdpUrl?: string; /** Remote CDP HTTP timeout (ms). Default: 1500. */ 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/config/zod-schema.ts b/src/config/zod-schema.ts index 75f438c1e..3c83d2c09 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -134,6 +134,7 @@ export const ClawdbotSchema = z browser: z .object({ enabled: z.boolean().optional(), + evaluateEnabled: z.boolean().optional(), cdpUrl: z.string().optional(), remoteCdpTimeoutMs: z.number().int().nonnegative().optional(), remoteCdpHandshakeTimeoutMs: z.number().int().nonnegative().optional(), diff --git a/src/hooks/config.ts b/src/hooks/config.ts index e290e6b5d..ee2acc07e 100644 --- a/src/hooks/config.ts +++ b/src/hooks/config.ts @@ -6,6 +6,7 @@ import type { HookEligibilityContext, HookEntry } from "./types.js"; const DEFAULT_CONFIG_VALUES: Record = { "browser.enabled": true, + "browser.evaluateEnabled": true, "workspace.dir": true, }; diff --git a/src/pairing/pairing-messages.test.ts b/src/pairing/pairing-messages.test.ts index 581d405d3..416a6ab19 100644 --- a/src/pairing/pairing-messages.test.ts +++ b/src/pairing/pairing-messages.test.ts @@ -1,8 +1,23 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { buildPairingReply } from "./pairing-messages.js"; describe("buildPairingReply", () => { + let previousProfile: string | undefined; + + beforeEach(() => { + previousProfile = process.env.CLAWDBOT_PROFILE; + process.env.CLAWDBOT_PROFILE = "isolated"; + }); + + afterEach(() => { + if (previousProfile === undefined) { + delete process.env.CLAWDBOT_PROFILE; + return; + } + process.env.CLAWDBOT_PROFILE = previousProfile; + }); + const cases = [ { channel: "discord", 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,