diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 220ab876657e..0508cda22e7c 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -44,6 +44,7 @@ import { } from "./pi-tools.read.js"; import { cleanToolSchemaForGemini, normalizeToolParameters } from "./pi-tools.schema.js"; import { + applyOwnerOnlyToolPolicy, buildPluginToolGroups, collectExplicitAllowlist, expandPolicyWithPluginGroups, @@ -361,23 +362,7 @@ export function createOpenClawCodingTools(options?: { ]; // Security: treat unknown/undefined as unauthorized (opt-in, not opt-out) const senderIsOwner = options?.senderIsOwner === true; - const toolsWithOwnerGuard = tools.map((tool) => { - if (normalizeToolName(tool.name) !== "whatsapp_login") { - return tool; - } - if (senderIsOwner || !tool.execute) { - return tool; - } - return { - ...tool, - execute: async () => { - throw new Error("whatsapp_login is restricted to owner senders."); - }, - }; - }); - const toolsByAuthorization = senderIsOwner - ? toolsWithOwnerGuard - : toolsWithOwnerGuard.filter((tool) => normalizeToolName(tool.name) !== "whatsapp_login"); + const toolsByAuthorization = applyOwnerOnlyToolPolicy(tools, senderIsOwner); const coreToolNames = new Set( toolsByAuthorization .filter((tool) => !getPluginToolMeta(tool)) diff --git a/src/agents/tool-policy.ts b/src/agents/tool-policy.ts index a14cb73ac080..e318f9ee1917 100644 --- a/src/agents/tool-policy.ts +++ b/src/agents/tool-policy.ts @@ -1,3 +1,5 @@ +import type { AnyAgentTool } from "./tools/common.js"; + export type ToolProfileId = "minimal" | "coding" | "messaging" | "full"; type ToolProfilePolicy = { @@ -56,6 +58,8 @@ export const TOOL_GROUPS: Record = { ], }; +const OWNER_ONLY_TOOL_NAMES = new Set(["whatsapp_login"]); + const TOOL_PROFILES: Record = { minimal: { allow: ["session_status"], @@ -80,6 +84,31 @@ export function normalizeToolName(name: string) { return TOOL_NAME_ALIASES[normalized] ?? normalized; } +export function isOwnerOnlyToolName(name: string) { + return OWNER_ONLY_TOOL_NAMES.has(normalizeToolName(name)); +} + +export function applyOwnerOnlyToolPolicy(tools: AnyAgentTool[], senderIsOwner: boolean) { + const withGuard = tools.map((tool) => { + if (!isOwnerOnlyToolName(tool.name)) { + return tool; + } + if (senderIsOwner || !tool.execute) { + return tool; + } + return { + ...tool, + execute: async () => { + throw new Error("Tool restricted to owner senders."); + }, + }; + }); + if (senderIsOwner) { + return withGuard; + } + return withGuard.filter((tool) => !isOwnerOnlyToolName(tool.name)); +} + export function normalizeToolList(list?: string[]) { if (!list) { return []; diff --git a/src/auto-reply/command-auth.ts b/src/auto-reply/command-auth.ts index 209a746aac4f..187fa6261cd8 100644 --- a/src/auto-reply/command-auth.ts +++ b/src/auto-reply/command-auth.ts @@ -84,6 +84,47 @@ function normalizeAllowFromEntry(params: { return normalized.filter((entry) => entry.trim().length > 0); } +function resolveOwnerAllowFromList(params: { + dock?: ChannelDock; + cfg: OpenClawConfig; + accountId?: string | null; + providerId?: ChannelId; +}): string[] { + const raw = params.cfg.commands?.ownerAllowFrom; + if (!Array.isArray(raw) || raw.length === 0) { + return []; + } + const filtered: string[] = []; + for (const entry of raw) { + const trimmed = String(entry ?? "").trim(); + if (!trimmed) { + continue; + } + const separatorIndex = trimmed.indexOf(":"); + if (separatorIndex > 0) { + const prefix = trimmed.slice(0, separatorIndex); + const channel = normalizeAnyChannelId(prefix); + if (channel) { + if (params.providerId && channel !== params.providerId) { + continue; + } + const remainder = trimmed.slice(separatorIndex + 1).trim(); + if (remainder) { + filtered.push(remainder); + } + continue; + } + } + filtered.push(trimmed); + } + return formatAllowFromList({ + dock: params.dock, + cfg: params.cfg, + accountId: params.accountId, + allowFrom: filtered, + }); +} + function resolveSenderCandidates(params: { dock?: ChannelDock; providerId?: ChannelId; @@ -142,11 +183,17 @@ export function resolveCommandAuthorization(params: { accountId: ctx.AccountId, allowFrom: Array.isArray(allowFromRaw) ? allowFromRaw : [], }); + const ownerAllowFromList = resolveOwnerAllowFromList({ + dock, + cfg, + accountId: ctx.AccountId, + providerId, + }); const allowAll = allowFromList.length === 0 || allowFromList.some((entry) => entry.trim() === "*"); - const ownerCandidates = allowAll ? [] : allowFromList.filter((entry) => entry !== "*"); - if (!allowAll && ownerCandidates.length === 0 && to) { + const ownerCandidatesForCommands = allowAll ? [] : allowFromList.filter((entry) => entry !== "*"); + if (!allowAll && ownerCandidatesForCommands.length === 0 && to) { const normalizedTo = normalizeAllowFromEntry({ dock, cfg, @@ -154,10 +201,13 @@ export function resolveCommandAuthorization(params: { value: to, }); if (normalizedTo.length > 0) { - ownerCandidates.push(...normalizedTo); + ownerCandidatesForCommands.push(...normalizedTo); } } - const ownerList = Array.from(new Set(ownerCandidates)); + const explicitOwners = ownerAllowFromList.filter((entry) => entry !== "*"); + const ownerList = Array.from( + new Set(explicitOwners.length > 0 ? explicitOwners : ownerCandidatesForCommands), + ); const senderCandidates = resolveSenderCandidates({ dock, @@ -171,11 +221,18 @@ export function resolveCommandAuthorization(params: { const matchedSender = ownerList.length ? senderCandidates.find((candidate) => ownerList.includes(candidate)) : undefined; + const matchedCommandOwner = ownerCandidatesForCommands.length + ? senderCandidates.find((candidate) => ownerCandidatesForCommands.includes(candidate)) + : undefined; const senderId = matchedSender ?? senderCandidates[0]; const enforceOwner = Boolean(dock?.commands?.enforceOwnerForCommands); const senderIsOwner = Boolean(matchedSender); - const isOwnerForCommands = !enforceOwner || allowAll || ownerList.length === 0 || senderIsOwner; + const isOwnerForCommands = + !enforceOwner || + allowAll || + ownerCandidatesForCommands.length === 0 || + Boolean(matchedCommandOwner); const isAuthorizedSender = commandAuthorized && isOwnerForCommands; return { diff --git a/src/auto-reply/command-control.test.ts b/src/auto-reply/command-control.test.ts index c06d81aa73c2..860e7550bf8a 100644 --- a/src/auto-reply/command-control.test.ts +++ b/src/auto-reply/command-control.test.ts @@ -132,6 +132,41 @@ describe("resolveCommandAuthorization", () => { expect(auth.senderId).toBe("+41796666864"); expect(auth.isAuthorizedSender).toBe(true); }); + + it("uses explicit owner allowlist when allowFrom is wildcard", () => { + const cfg = { + commands: { ownerAllowFrom: ["whatsapp:+15551234567"] }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + + const ownerCtx = { + Provider: "whatsapp", + Surface: "whatsapp", + From: "whatsapp:+15551234567", + SenderE164: "+15551234567", + } as MsgContext; + const ownerAuth = resolveCommandAuthorization({ + ctx: ownerCtx, + cfg, + commandAuthorized: true, + }); + expect(ownerAuth.senderIsOwner).toBe(true); + expect(ownerAuth.isAuthorizedSender).toBe(true); + + const otherCtx = { + Provider: "whatsapp", + Surface: "whatsapp", + From: "whatsapp:+19995551234", + SenderE164: "+19995551234", + } as MsgContext; + const otherAuth = resolveCommandAuthorization({ + ctx: otherCtx, + cfg, + commandAuthorized: true, + }); + expect(otherAuth.senderIsOwner).toBe(false); + expect(otherAuth.isAuthorizedSender).toBe(true); + }); }); describe("control command parsing", () => { diff --git a/src/config/schema.ts b/src/config/schema.ts index c918d38f0b88..175265ac167a 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -301,6 +301,7 @@ const FIELD_LABELS: Record = { "commands.debug": "Allow /debug", "commands.restart": "Allow Restart", "commands.useAccessGroups": "Use Access Groups", + "commands.ownerAllowFrom": "Command Owners", "ui.seamColor": "Accent Color", "ui.assistant.name": "Assistant Name", "ui.assistant.avatar": "Assistant Avatar", @@ -661,6 +662,8 @@ const FIELD_HELP: Record = { "commands.debug": "Allow /debug chat command for runtime-only overrides (default: false).", "commands.restart": "Allow /restart and gateway restart tool actions (default: false).", "commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.", + "commands.ownerAllowFrom": + "Explicit owner allowlist for owner-only tools/commands. Use channel-native IDs (optionally prefixed like \"whatsapp:+15551234567\"). '*' is ignored.", "session.dmScope": 'DM session scoping: "main" keeps continuity; "per-peer", "per-channel-peer", or "per-account-channel-peer" isolates DM history (recommended for shared inboxes/multi-account).', "session.identityLinks": diff --git a/src/config/types.messages.ts b/src/config/types.messages.ts index 37ef4e942445..97de53417f8e 100644 --- a/src/config/types.messages.ts +++ b/src/config/types.messages.ts @@ -107,6 +107,8 @@ export type CommandsConfig = { restart?: boolean; /** Enforce access-group allowlists/policies for commands (default: true). */ useAccessGroups?: boolean; + /** Explicit owner allowlist for owner-only tools/commands (channel-native IDs). */ + ownerAllowFrom?: Array; }; export type ProviderCommandsConfig = { diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index e7a7245822bf..136c82d04c94 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -112,6 +112,7 @@ export const CommandsSchema = z debug: z.boolean().optional(), restart: z.boolean().optional(), useAccessGroups: z.boolean().optional(), + ownerAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), }) .strict() .optional()