From d84eb46467d74e12e7fad63ee2a257ad1473fcd8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 4 Feb 2026 23:34:08 -0800 Subject: [PATCH] fix: restore discord owner hint from allowlists --- CHANGELOG.md | 1 + docs/channels/discord.md | 3 +- src/auto-reply/command-auth.ts | 28 +++++++++++++++---- src/auto-reply/command-control.test.ts | 23 +++++++++++++++ src/auto-reply/templating.ts | 2 ++ src/discord/monitor/allow-list.ts | 24 ++++++++++++++++ .../monitor/message-handler.process.ts | 8 +++++- src/discord/monitor/native-command.ts | 7 +++++ 8 files changed, 89 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8311accaea..95d0c64800 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - Web UI: apply button styling to the new-messages indicator. - Onboarding: infer auth choice from non-interactive API key flags. (#8484) Thanks @f-trycua. - Security: keep untrusted channel metadata out of system prompts (Slack/Discord). Thanks @KonstantinMirin. +- Discord: treat allowlisted senders as owner for system-prompt identity hints while keeping channel topics untrusted. - Security: enforce sandboxed media paths for message tool attachments. (#9182) Thanks @victormier. - Security: require explicit credentials for gateway URL overrides to prevent credential leakage. (#8113) Thanks @victormier. - Security: gate `whatsapp_login` tool to owner senders and default-deny non-owner contexts. (#8768) Thanks @victormier. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index dcabf1da76..c520c16fdd 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -196,6 +196,7 @@ Notes: - If `channels` is present, any channel not listed is denied by default. - Use a `"*"` channel entry to apply defaults across all channels; explicit channel entries override the wildcard. - Threads inherit parent channel config (allowlist, `requireMention`, skills, prompts, etc.) unless you add the thread channel id explicitly. +- Owner hint: when a per-guild or per-channel `users` allowlist matches the sender, OpenClaw treats that sender as the owner in the system prompt. For a global owner across channels, set `commands.ownerAllowFrom`. - Bot-authored messages are ignored by default; set `channels.discord.allowBots=true` to allow them (own messages remain filtered). - Warning: If you allow replies to other bots (`channels.discord.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.discord.guilds.*.channels..users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`. @@ -334,7 +335,7 @@ ack reaction after the bot replies. - `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). +- `guilds..channels..systemPrompt`: extra system prompt for the channel. Discord channel topics are injected as **untrusted** context (not system prompt). - `guilds..channels..enabled`: set `false` to disable the channel. - `guilds..channels`: channel rules (keys are channel slugs or ids). - `guilds..requireMention`: per-guild mention requirement (overridable per channel). diff --git a/src/auto-reply/command-auth.ts b/src/auto-reply/command-auth.ts index 7db36d36a7..c751fddf9b 100644 --- a/src/auto-reply/command-auth.ts +++ b/src/auto-reply/command-auth.ts @@ -89,8 +89,9 @@ function resolveOwnerAllowFromList(params: { cfg: OpenClawConfig; accountId?: string | null; providerId?: ChannelId; + allowFrom?: Array; }): string[] { - const raw = params.cfg.commands?.ownerAllowFrom; + const raw = params.allowFrom ?? params.cfg.commands?.ownerAllowFrom; if (!Array.isArray(raw) || raw.length === 0) { return []; } @@ -183,11 +184,19 @@ export function resolveCommandAuthorization(params: { accountId: ctx.AccountId, allowFrom: Array.isArray(allowFromRaw) ? allowFromRaw : [], }); - const ownerAllowFromList = resolveOwnerAllowFromList({ + const configOwnerAllowFromList = resolveOwnerAllowFromList({ dock, cfg, accountId: ctx.AccountId, providerId, + allowFrom: cfg.commands?.ownerAllowFrom, + }); + const contextOwnerAllowFromList = resolveOwnerAllowFromList({ + dock, + cfg, + accountId: ctx.AccountId, + providerId, + allowFrom: ctx.OwnerAllowFrom, }); const allowAll = allowFromList.length === 0 || allowFromList.some((entry) => entry.trim() === "*"); @@ -204,10 +213,19 @@ export function resolveCommandAuthorization(params: { ownerCandidatesForCommands.push(...normalizedTo); } } - const ownerAllowAll = ownerAllowFromList.some((entry) => entry.trim() === "*"); - const explicitOwners = ownerAllowFromList.filter((entry) => entry !== "*"); + const ownerAllowAll = configOwnerAllowFromList.some((entry) => entry.trim() === "*"); + const explicitOwners = configOwnerAllowFromList.filter((entry) => entry !== "*"); + const explicitOverrides = contextOwnerAllowFromList.filter((entry) => entry !== "*"); const ownerList = Array.from( - new Set(explicitOwners.length > 0 ? explicitOwners : ownerCandidatesForCommands), + new Set( + explicitOwners.length > 0 + ? explicitOwners + : ownerAllowAll + ? [] + : explicitOverrides.length > 0 + ? explicitOverrides + : ownerCandidatesForCommands, + ), ); const senderCandidates = resolveSenderCandidates({ diff --git a/src/auto-reply/command-control.test.ts b/src/auto-reply/command-control.test.ts index 4ef4ff7f47..b2fcc3d51d 100644 --- a/src/auto-reply/command-control.test.ts +++ b/src/auto-reply/command-control.test.ts @@ -167,6 +167,29 @@ describe("resolveCommandAuthorization", () => { expect(otherAuth.senderIsOwner).toBe(false); expect(otherAuth.isAuthorizedSender).toBe(false); }); + + it("uses owner allowlist override from context when configured", () => { + const cfg = { + channels: { discord: {} }, + } as OpenClawConfig; + + const ctx = { + Provider: "discord", + Surface: "discord", + From: "discord:123", + SenderId: "123", + OwnerAllowFrom: ["discord:123"], + } as MsgContext; + + const auth = resolveCommandAuthorization({ + ctx, + cfg, + commandAuthorized: true, + }); + + expect(auth.senderIsOwner).toBe(true); + expect(auth.ownerList).toEqual(["123"]); + }); }); describe("control command parsing", () => { diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 7b0f8ed1e1..725012d611 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -91,6 +91,8 @@ export type MsgContext = { GroupSystemPrompt?: string; /** Untrusted metadata that must not be treated as system instructions. */ UntrustedContext?: string[]; + /** Explicit owner allowlist overrides (trusted, configuration-derived). */ + OwnerAllowFrom?: Array; SenderName?: string; SenderId?: string; SenderUsername?: string; diff --git a/src/discord/monitor/allow-list.ts b/src/discord/monitor/allow-list.ts index 0254c21a06..7ff53b49bb 100644 --- a/src/discord/monitor/allow-list.ts +++ b/src/discord/monitor/allow-list.ts @@ -154,6 +154,30 @@ export function resolveDiscordUserAllowed(params: { }); } +export function resolveDiscordOwnerAllowFrom(params: { + channelConfig?: DiscordChannelConfigResolved | null; + guildInfo?: DiscordGuildEntryResolved | null; + sender: { id: string; name?: string; tag?: string }; +}): string[] | undefined { + const rawAllowList = params.channelConfig?.users ?? params.guildInfo?.users; + if (!Array.isArray(rawAllowList) || rawAllowList.length === 0) { + return undefined; + } + const allowList = normalizeDiscordAllowList(rawAllowList, ["discord:", "user:", "pk:"]); + if (!allowList) { + return undefined; + } + const match = allowListMatches(allowList, { + id: params.sender.id, + name: params.sender.name, + tag: params.sender.tag, + }); + if (!match.allowed || !match.matchKey || match.matchKey === "*") { + return undefined; + } + return [match.matchKey]; +} + export function resolveDiscordCommandAuthorized(params: { isDirectMessage: boolean; allowFrom?: Array; diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 927e9621a0..eac94ed3ca 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -31,7 +31,7 @@ import { resolveThreadSessionKeys } from "../../routing/session-key.js"; import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js"; import { truncateUtf16Safe } from "../../utils.js"; import { reactMessageDiscord, removeReactionDiscord } from "../send.js"; -import { normalizeDiscordSlug } from "./allow-list.js"; +import { normalizeDiscordSlug, resolveDiscordOwnerAllowFrom } from "./allow-list.js"; import { resolveTimestampMs } from "./format.js"; import { buildDiscordMediaPayload, @@ -157,6 +157,11 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) ); const groupSystemPrompt = systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined; + const ownerAllowFrom = resolveDiscordOwnerAllowFrom({ + channelConfig, + guildInfo, + sender: { id: sender.id, name: sender.name, tag: sender.tag }, + }); const storePath = resolveStorePath(cfg.session?.store, { agentId: route.agentId, }); @@ -293,6 +298,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) UntrustedContext: untrustedChannelMetadata ? [untrustedChannelMetadata] : undefined, GroupSystemPrompt: isGuildMessage ? groupSystemPrompt : undefined, GroupSpace: isGuildMessage ? (guildInfo?.id ?? guildSlug) || undefined : undefined, + OwnerAllowFrom: ownerAllowFrom, Provider: "discord" as const, Surface: "discord" as const, WasMentioned: effectiveWasMentioned, diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index 79246921ea..092f4ee06b 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -50,6 +50,7 @@ import { normalizeDiscordSlug, resolveDiscordChannelConfigWithFallback, resolveDiscordGuildEntry, + resolveDiscordOwnerAllowFrom, resolveDiscordUserAllowed, } from "./allow-list.js"; import { resolveDiscordChannelInfo } from "./message-utils.js"; @@ -741,6 +742,11 @@ async function dispatchDiscordCommandInteraction(params: { parentPeer: threadParentId ? { kind: "channel", id: threadParentId } : undefined, }); const conversationLabel = isDirectMessage ? (user.globalName ?? user.username) : channelId; + const ownerAllowFrom = resolveDiscordOwnerAllowFrom({ + channelConfig, + guildInfo, + sender: { id: sender.id, name: sender.name, tag: sender.tag }, + }); const ctxPayload = finalizeInboundContext({ Body: prompt, RawBody: prompt, @@ -778,6 +784,7 @@ async function dispatchDiscordCommandInteraction(params: { return untrustedChannelMetadata ? [untrustedChannelMetadata] : undefined; })() : undefined, + OwnerAllowFrom: ownerAllowFrom, SenderName: user.globalName ?? user.username, SenderId: user.id, SenderUsername: user.username,