From 47f6bb41468c9c6a2370154e635fd7627e435566 Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 9 Feb 2026 23:57:17 -0600 Subject: [PATCH] Commands: add commands.allowFrom config --- CHANGELOG.md | 1 + docs/gateway/configuration.md | 15 ++- docs/tools/slash-commands.md | 12 +- src/auto-reply/command-auth.ts | 60 ++++++++- src/auto-reply/command-control.test.ts | 176 +++++++++++++++++++++++++ src/config/schema.ts | 3 + src/config/types.messages.ts | 14 ++ src/config/zod-schema.session.ts | 2 + 8 files changed, 277 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 197271feaa9..6e26753ed99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Added +- Commands: add `commands.allowFrom` config for separate command authorization, allowing operators to restrict slash commands to specific users while keeping chat open to others. (#12430) Thanks @thewilloftheshadow. - iOS: alpha node app + setup-code onboarding. (#11756) Thanks @mbelinky. - Channels: comprehensive BlueBubbles and channel cleanup. (#11093) Thanks @tyler6204. - Plugins: device pairing + phone control plugins (Telegram `/pair`, iOS/Android node controls). (#11755) Thanks @mbelinky. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 31c115039b6..c333525a5e4 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -990,6 +990,10 @@ Controls how chat commands are enabled across connectors. config: false, // allow /config (writes to disk) debug: false, // allow /debug (runtime-only overrides) restart: false, // allow /restart + gateway restart tool + allowFrom: { + "*": ["user1"], // optional per-provider command allowlist + discord: ["user:123"], + }, useAccessGroups: true, // enforce access-group allowlists/policies for commands }, } @@ -1008,9 +1012,14 @@ Notes: - `channels..configWrites` gates config mutations initiated by that channel (default: true). This applies to `/config set|unset` plus provider-specific auto-migrations (Telegram supergroup ID changes, Slack channel ID changes). - `commands.debug: true` enables `/debug` (runtime-only overrides). - `commands.restart: true` enables `/restart` and the gateway tool restart action. -- `commands.useAccessGroups: false` allows commands to bypass access-group allowlists/policies. -- Slash commands and directives are only honored for **authorized senders**. Authorization is derived from - channel allowlists/pairing plus `commands.useAccessGroups`. +- `commands.allowFrom` sets a per-provider allowlist for command execution. When configured, it is the **only** + authorization source for commands and directives (channel allowlists/pairing and `commands.useAccessGroups` are ignored). + Use `"*"` for a global default; provider-specific keys (for example `discord`) override it. +- `commands.useAccessGroups: false` allows commands to bypass access-group allowlists/policies when `commands.allowFrom` + is not set. +- Slash commands and directives are only honored for **authorized senders**. If `commands.allowFrom` is set, + authorization comes solely from that list; otherwise it is derived from channel allowlists/pairing plus + `commands.useAccessGroups`. ### `web` (WhatsApp web channel runtime) diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 24684c72bc5..bb254d8e8e8 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -18,7 +18,8 @@ There are two related systems: - Directives are stripped from the message before the model sees it. - In normal chat messages (not directive-only), they are treated as “inline hints” and do **not** persist session settings. - In directive-only messages (the message contains only directives), they persist to the session and reply with an acknowledgement. - - Directives are only applied for **authorized senders** (channel allowlists/pairing plus `commands.useAccessGroups`). + - Directives are only applied for **authorized senders**. If `commands.allowFrom` is set, it is the only + allowlist used; otherwise authorization comes from channel allowlists/pairing plus `commands.useAccessGroups`. Unauthorized senders see directives treated as plain text. There are also a few **inline shortcuts** (allowlisted/authorized senders only): `/help`, `/commands`, `/status`, `/whoami` (`/id`). @@ -37,6 +38,10 @@ They run immediately, are stripped before the model sees the message, and the re config: false, debug: false, restart: false, + allowFrom: { + "*": ["user1"], + discord: ["user:123"], + }, useAccessGroups: true, }, } @@ -55,7 +60,10 @@ They run immediately, are stripped before the model sees the message, and the re - `commands.bashForegroundMs` (default `2000`) controls how long bash waits before switching to background mode (`0` backgrounds immediately). - `commands.config` (default `false`) enables `/config` (reads/writes `openclaw.json`). - `commands.debug` (default `false`) enables `/debug` (runtime-only overrides). -- `commands.useAccessGroups` (default `true`) enforces allowlists/policies for commands. +- `commands.allowFrom` (optional) sets a per-provider allowlist for command authorization. When configured, it is the + only authorization source for commands and directives (channel allowlists/pairing and `commands.useAccessGroups` + are ignored). Use `"*"` for a global default; provider-specific keys override it. +- `commands.useAccessGroups` (default `true`) enforces allowlists/policies for commands when `commands.allowFrom` is not set. ## Command list diff --git a/src/auto-reply/command-auth.ts b/src/auto-reply/command-auth.ts index c751fddf9bc..f2d8f64d8c0 100644 --- a/src/auto-reply/command-auth.ts +++ b/src/auto-reply/command-auth.ts @@ -126,6 +126,41 @@ function resolveOwnerAllowFromList(params: { }); } +/** + * Resolves the commands.allowFrom list for a given provider. + * Returns the provider-specific list if defined, otherwise the "*" global list. + * Returns null if commands.allowFrom is not configured at all (fall back to channel allowFrom). + */ +function resolveCommandsAllowFromList(params: { + dock?: ChannelDock; + cfg: OpenClawConfig; + accountId?: string | null; + providerId?: ChannelId; +}): string[] | null { + const { dock, cfg, accountId, providerId } = params; + const commandsAllowFrom = cfg.commands?.allowFrom; + if (!commandsAllowFrom || typeof commandsAllowFrom !== "object") { + return null; // Not configured, fall back to channel allowFrom + } + + // Check provider-specific list first, then fall back to global "*" + const providerKey = providerId ?? ""; + const providerList = commandsAllowFrom[providerKey]; + const globalList = commandsAllowFrom["*"]; + + const rawList = Array.isArray(providerList) ? providerList : globalList; + if (!Array.isArray(rawList)) { + return null; // No applicable list found + } + + return formatAllowFromList({ + dock, + cfg, + accountId, + allowFrom: rawList, + }); +} + function resolveSenderCandidates(params: { dock?: ChannelDock; providerId?: ChannelId; @@ -175,6 +210,15 @@ export function resolveCommandAuthorization(params: { const dock = providerId ? getChannelDock(providerId) : undefined; const from = (ctx.From ?? "").trim(); const to = (ctx.To ?? "").trim(); + + // Check if commands.allowFrom is configured (separate command authorization) + const commandsAllowFromList = resolveCommandsAllowFromList({ + dock, + cfg, + accountId: ctx.AccountId, + providerId, + }); + const allowFromRaw = dock?.config?.resolveAllowFrom ? dock.config.resolveAllowFrom({ cfg, accountId: ctx.AccountId }) : []; @@ -256,7 +300,21 @@ export function resolveCommandAuthorization(params: { : ownerAllowlistConfigured ? senderIsOwner : allowAll || ownerCandidatesForCommands.length === 0 || Boolean(matchedCommandOwner); - const isAuthorizedSender = commandAuthorized && isOwnerForCommands; + + // If commands.allowFrom is configured, use it for command authorization + // Otherwise, fall back to existing behavior (channel allowFrom + owner checks) + let isAuthorizedSender: boolean; + if (commandsAllowFromList !== null) { + // commands.allowFrom is configured - use it for authorization + const commandsAllowAll = commandsAllowFromList.some((entry) => entry.trim() === "*"); + const matchedCommandsAllowFrom = commandsAllowFromList.length + ? senderCandidates.find((candidate) => commandsAllowFromList.includes(candidate)) + : undefined; + isAuthorizedSender = commandsAllowAll || Boolean(matchedCommandsAllowFrom); + } else { + // Fall back to existing behavior + isAuthorizedSender = commandAuthorized && isOwnerForCommands; + } return { providerId, diff --git a/src/auto-reply/command-control.test.ts b/src/auto-reply/command-control.test.ts index f96f10bf272..c1145be3447 100644 --- a/src/auto-reply/command-control.test.ts +++ b/src/auto-reply/command-control.test.ts @@ -211,6 +211,182 @@ describe("resolveCommandAuthorization", () => { expect(auth.senderIsOwner).toBe(true); expect(auth.ownerList).toEqual(["123"]); }); + + describe("commands.allowFrom", () => { + it("uses commands.allowFrom global list when configured", () => { + const cfg = { + commands: { + allowFrom: { + "*": ["user123"], + }, + }, + channels: { whatsapp: { allowFrom: ["+different"] } }, + } as OpenClawConfig; + + const authorizedCtx = { + Provider: "whatsapp", + Surface: "whatsapp", + From: "whatsapp:user123", + SenderId: "user123", + } as MsgContext; + + const authorizedAuth = resolveCommandAuthorization({ + ctx: authorizedCtx, + cfg, + commandAuthorized: true, + }); + + expect(authorizedAuth.isAuthorizedSender).toBe(true); + + const unauthorizedCtx = { + Provider: "whatsapp", + Surface: "whatsapp", + From: "whatsapp:otheruser", + SenderId: "otheruser", + } as MsgContext; + + const unauthorizedAuth = resolveCommandAuthorization({ + ctx: unauthorizedCtx, + cfg, + commandAuthorized: true, + }); + + expect(unauthorizedAuth.isAuthorizedSender).toBe(false); + }); + + it("ignores commandAuthorized when commands.allowFrom is configured", () => { + const cfg = { + commands: { + allowFrom: { + "*": ["user123"], + }, + }, + channels: { whatsapp: { allowFrom: ["+different"] } }, + } as OpenClawConfig; + + const authorizedCtx = { + Provider: "whatsapp", + Surface: "whatsapp", + From: "whatsapp:user123", + SenderId: "user123", + } as MsgContext; + + const authorizedAuth = resolveCommandAuthorization({ + ctx: authorizedCtx, + cfg, + commandAuthorized: false, + }); + + expect(authorizedAuth.isAuthorizedSender).toBe(true); + + const unauthorizedCtx = { + Provider: "whatsapp", + Surface: "whatsapp", + From: "whatsapp:otheruser", + SenderId: "otheruser", + } as MsgContext; + + const unauthorizedAuth = resolveCommandAuthorization({ + ctx: unauthorizedCtx, + cfg, + commandAuthorized: false, + }); + + expect(unauthorizedAuth.isAuthorizedSender).toBe(false); + }); + + it("uses commands.allowFrom provider-specific list over global", () => { + const cfg = { + commands: { + allowFrom: { + "*": ["globaluser"], + whatsapp: ["+15551234567"], + }, + }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + + // User in global list but not in whatsapp-specific list + const globalUserCtx = { + Provider: "whatsapp", + Surface: "whatsapp", + From: "whatsapp:globaluser", + SenderId: "globaluser", + } as MsgContext; + + const globalAuth = resolveCommandAuthorization({ + ctx: globalUserCtx, + cfg, + commandAuthorized: true, + }); + + // Provider-specific list overrides global, so globaluser is not authorized + expect(globalAuth.isAuthorizedSender).toBe(false); + + // User in whatsapp-specific list + const whatsappUserCtx = { + Provider: "whatsapp", + Surface: "whatsapp", + From: "whatsapp:+15551234567", + SenderE164: "+15551234567", + } as MsgContext; + + const whatsappAuth = resolveCommandAuthorization({ + ctx: whatsappUserCtx, + cfg, + commandAuthorized: true, + }); + + expect(whatsappAuth.isAuthorizedSender).toBe(true); + }); + + it("falls back to channel allowFrom when commands.allowFrom not set", () => { + const cfg = { + channels: { whatsapp: { allowFrom: ["+15551234567"] } }, + } as OpenClawConfig; + + const authorizedCtx = { + Provider: "whatsapp", + Surface: "whatsapp", + From: "whatsapp:+15551234567", + SenderE164: "+15551234567", + } as MsgContext; + + const auth = resolveCommandAuthorization({ + ctx: authorizedCtx, + cfg, + commandAuthorized: true, + }); + + expect(auth.isAuthorizedSender).toBe(true); + }); + + it("allows all senders when commands.allowFrom includes wildcard", () => { + const cfg = { + commands: { + allowFrom: { + "*": ["*"], + }, + }, + channels: { whatsapp: { allowFrom: ["+specific"] } }, + } as OpenClawConfig; + + const anyUserCtx = { + Provider: "whatsapp", + Surface: "whatsapp", + From: "whatsapp:anyuser", + SenderId: "anyuser", + } as MsgContext; + + const auth = resolveCommandAuthorization({ + ctx: anyUserCtx, + cfg, + commandAuthorized: true, + }); + + expect(auth.isAuthorizedSender).toBe(true); + }); + }); }); describe("control command parsing", () => { diff --git a/src/config/schema.ts b/src/config/schema.ts index 91a143ba01a..0fd9909faf7 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -306,6 +306,7 @@ const FIELD_LABELS: Record = { "commands.restart": "Allow Restart", "commands.useAccessGroups": "Use Access Groups", "commands.ownerAllowFrom": "Command Owners", + "commands.allowFrom": "Command Access Allowlist", "ui.seamColor": "Accent Color", "ui.assistant.name": "Assistant Name", "ui.assistant.avatar": "Assistant Avatar", @@ -675,6 +676,8 @@ const FIELD_HELP: Record = { "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.", + "commands.allowFrom": + 'Per-provider allowlist restricting who can use slash commands. If set, overrides the channel\'s allowFrom for command authorization. Use \'*\' key for global default; provider-specific keys (e.g. \'discord\') override the global. Example: { "*": ["user1"], "discord": ["user:123"] }.', "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 7619666143c..0f197c98e6d 100644 --- a/src/config/types.messages.ts +++ b/src/config/types.messages.ts @@ -88,6 +88,13 @@ export type MessagesConfig = { export type NativeCommandsSetting = boolean | "auto"; +/** + * Per-provider allowlist for command authorization. + * Keys are channel IDs (e.g., "discord", "whatsapp") or "*" for global default. + * Values are arrays of sender IDs allowed to use commands on that channel. + */ +export type CommandAllowFrom = Record>; + export type CommandsConfig = { /** Enable native command registration when supported (default: "auto"). */ native?: NativeCommandsSetting; @@ -109,6 +116,13 @@ export type CommandsConfig = { useAccessGroups?: boolean; /** Explicit owner allowlist for owner-only tools/commands (channel-native IDs). */ ownerAllowFrom?: Array; + /** + * Per-provider allowlist restricting who can use slash commands. + * If set, overrides the channel's allowFrom for command authorization. + * Use "*" key for global default, provider-specific keys override the global. + * Example: { "*": ["user1"], discord: ["user:123"] } + */ + allowFrom?: CommandAllowFrom; }; export type ProviderCommandsConfig = { diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index ce30509fd92..a574733cc98 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { parseByteSize } from "../cli/parse-bytes.js"; import { parseDurationMs } from "../cli/parse-duration.js"; +import { ElevatedAllowFromSchema } from "./zod-schema.agent-runtime.js"; import { GroupChatSchema, InboundDebounceSchema, @@ -158,6 +159,7 @@ export const CommandsSchema = z restart: z.boolean().optional(), useAccessGroups: z.boolean().optional(), ownerAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + allowFrom: ElevatedAllowFromSchema.optional(), }) .strict() .optional()