mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-29 05:02:04 +03:00
fix(allowlist): canonicalize Slack/Discord allowFrom
This commit is contained in:
@@ -254,7 +254,8 @@ function resolveChannelAllowFromPaths(
|
|||||||
}
|
}
|
||||||
if (scope === "dm") {
|
if (scope === "dm") {
|
||||||
if (channelId === "slack" || channelId === "discord") {
|
if (channelId === "slack" || channelId === "discord") {
|
||||||
return ["dm", "allowFrom"];
|
// Canonical DM allowlist location for Slack/Discord. Legacy: dm.allowFrom.
|
||||||
|
return ["allowFrom"];
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
channelId === "telegram" ||
|
channelId === "telegram" ||
|
||||||
@@ -404,7 +405,7 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
|
|||||||
groupPolicy = account.config.groupPolicy;
|
groupPolicy = account.config.groupPolicy;
|
||||||
} else if (channelId === "slack") {
|
} else if (channelId === "slack") {
|
||||||
const account = resolveSlackAccount({ cfg: params.cfg, accountId });
|
const account = resolveSlackAccount({ cfg: params.cfg, accountId });
|
||||||
dmAllowFrom = (account.dm?.allowFrom ?? []).map(String);
|
dmAllowFrom = (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map(String);
|
||||||
groupPolicy = account.groupPolicy;
|
groupPolicy = account.groupPolicy;
|
||||||
const channels = account.channels ?? {};
|
const channels = account.channels ?? {};
|
||||||
groupOverrides = Object.entries(channels)
|
groupOverrides = Object.entries(channels)
|
||||||
@@ -415,7 +416,7 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
|
|||||||
.filter(Boolean) as Array<{ label: string; entries: string[] }>;
|
.filter(Boolean) as Array<{ label: string; entries: string[] }>;
|
||||||
} else if (channelId === "discord") {
|
} else if (channelId === "discord") {
|
||||||
const account = resolveDiscordAccount({ cfg: params.cfg, accountId });
|
const account = resolveDiscordAccount({ cfg: params.cfg, accountId });
|
||||||
dmAllowFrom = (account.config.dm?.allowFrom ?? []).map(String);
|
dmAllowFrom = (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map(String);
|
||||||
groupPolicy = account.config.groupPolicy;
|
groupPolicy = account.config.groupPolicy;
|
||||||
const guilds = account.config.guilds ?? {};
|
const guilds = account.config.guilds ?? {};
|
||||||
for (const [guildKey, guildCfg] of Object.entries(guilds)) {
|
for (const [guildKey, guildCfg] of Object.entries(guilds)) {
|
||||||
@@ -567,10 +568,25 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
|
|||||||
pathPrefix,
|
pathPrefix,
|
||||||
accountId: normalizedAccountId,
|
accountId: normalizedAccountId,
|
||||||
} = resolveAccountTarget(parsedConfig, channelId, accountId);
|
} = resolveAccountTarget(parsedConfig, channelId, accountId);
|
||||||
const existingRaw = getNestedValue(target, allowlistPath);
|
const existing: string[] = [];
|
||||||
const existing = Array.isArray(existingRaw)
|
const existingPaths =
|
||||||
? existingRaw.map((entry) => String(entry).trim()).filter(Boolean)
|
scope === "dm" && (channelId === "slack" || channelId === "discord")
|
||||||
: [];
|
? // Read both while legacy alias may still exist; write canonical below.
|
||||||
|
[allowlistPath, ["dm", "allowFrom"]]
|
||||||
|
: [allowlistPath];
|
||||||
|
for (const path of existingPaths) {
|
||||||
|
const existingRaw = getNestedValue(target, path);
|
||||||
|
if (!Array.isArray(existingRaw)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const entry of existingRaw) {
|
||||||
|
const value = String(entry).trim();
|
||||||
|
if (!value || existing.includes(value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
existing.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedEntry = normalizeAllowFrom({
|
const normalizedEntry = normalizeAllowFrom({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
@@ -628,6 +644,10 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
|
|||||||
} else {
|
} else {
|
||||||
setNestedValue(target, allowlistPath, next);
|
setNestedValue(target, allowlistPath, next);
|
||||||
}
|
}
|
||||||
|
if (scope === "dm" && (channelId === "slack" || channelId === "discord")) {
|
||||||
|
// Remove legacy DM allowlist alias to prevent drift.
|
||||||
|
deleteNestedValue(target, ["dm", "allowFrom"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (configChanged) {
|
if (configChanged) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
import type { MsgContext } from "../templating.js";
|
import type { MsgContext } from "../templating.js";
|
||||||
import { buildCommandContext, handleCommands } from "./commands.js";
|
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||||
@@ -94,6 +94,10 @@ function buildParams(commandBody: string, cfg: OpenClawConfig, ctxOverrides?: Pa
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("handleCommands /allowlist", () => {
|
describe("handleCommands /allowlist", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it("lists config + store allowFrom entries", async () => {
|
it("lists config + store allowFrom entries", async () => {
|
||||||
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
|
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
|
||||||
|
|
||||||
@@ -145,6 +149,92 @@ describe("handleCommands /allowlist", () => {
|
|||||||
});
|
});
|
||||||
expect(result.reply?.text).toContain("DM allowlist added");
|
expect(result.reply?.text).toContain("DM allowlist added");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("removes Slack DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => {
|
||||||
|
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
||||||
|
valid: true,
|
||||||
|
parsed: {
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
allowFrom: ["U111", "U222"],
|
||||||
|
dm: { allowFrom: ["U111", "U222"] },
|
||||||
|
configWrites: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
||||||
|
ok: true,
|
||||||
|
config,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true, config: true },
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
allowFrom: ["U111", "U222"],
|
||||||
|
dm: { allowFrom: ["U111", "U222"] },
|
||||||
|
configWrites: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
|
||||||
|
const params = buildParams("/allowlist remove dm U111", cfg, {
|
||||||
|
Provider: "slack",
|
||||||
|
Surface: "slack",
|
||||||
|
});
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(writeConfigFileMock).toHaveBeenCalledTimes(1);
|
||||||
|
const written = writeConfigFileMock.mock.calls[0]?.[0] as OpenClawConfig;
|
||||||
|
expect(written.channels?.slack?.allowFrom).toEqual(["U222"]);
|
||||||
|
expect(written.channels?.slack?.dm?.allowFrom).toBeUndefined();
|
||||||
|
expect(result.reply?.text).toContain("channels.slack.allowFrom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes Discord DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => {
|
||||||
|
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
||||||
|
valid: true,
|
||||||
|
parsed: {
|
||||||
|
channels: {
|
||||||
|
discord: {
|
||||||
|
allowFrom: ["111", "222"],
|
||||||
|
dm: { allowFrom: ["111", "222"] },
|
||||||
|
configWrites: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
||||||
|
ok: true,
|
||||||
|
config,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true, config: true },
|
||||||
|
channels: {
|
||||||
|
discord: {
|
||||||
|
allowFrom: ["111", "222"],
|
||||||
|
dm: { allowFrom: ["111", "222"] },
|
||||||
|
configWrites: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
|
||||||
|
const params = buildParams("/allowlist remove dm 111", cfg, {
|
||||||
|
Provider: "discord",
|
||||||
|
Surface: "discord",
|
||||||
|
});
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(writeConfigFileMock).toHaveBeenCalledTimes(1);
|
||||||
|
const written = writeConfigFileMock.mock.calls[0]?.[0] as OpenClawConfig;
|
||||||
|
expect(written.channels?.discord?.allowFrom).toEqual(["222"]);
|
||||||
|
expect(written.channels?.discord?.dm?.allowFrom).toBeUndefined();
|
||||||
|
expect(result.reply?.text).toContain("channels.discord.allowFrom");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("/models command", () => {
|
describe("/models command", () => {
|
||||||
|
|||||||
+13
-6
@@ -191,13 +191,16 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
|||||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||||
},
|
},
|
||||||
elevated: {
|
elevated: {
|
||||||
allowFromFallback: ({ cfg }) => cfg.channels?.discord?.dm?.allowFrom,
|
allowFromFallback: ({ cfg }) =>
|
||||||
|
cfg.channels?.discord?.allowFrom ?? cfg.channels?.discord?.dm?.allowFrom,
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
resolveAllowFrom: ({ cfg, accountId }) => {
|
||||||
(resolveDiscordAccount({ cfg, accountId }).config.dm?.allowFrom ?? []).map((entry) =>
|
const account = resolveDiscordAccount({ cfg, accountId });
|
||||||
|
return (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map((entry) =>
|
||||||
String(entry),
|
String(entry),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom),
|
formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom),
|
||||||
},
|
},
|
||||||
groups: {
|
groups: {
|
||||||
@@ -355,8 +358,12 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
|||||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
resolveAllowFrom: ({ cfg, accountId }) => {
|
||||||
(resolveSlackAccount({ cfg, accountId }).dm?.allowFrom ?? []).map((entry) => String(entry)),
|
const account = resolveSlackAccount({ cfg, accountId });
|
||||||
|
return (account.config.allowFrom ?? account.dm?.allowFrom ?? []).map((entry) =>
|
||||||
|
String(entry),
|
||||||
|
);
|
||||||
|
},
|
||||||
formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom),
|
formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom),
|
||||||
},
|
},
|
||||||
groups: {
|
groups: {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export async function listSlackDirectoryPeersFromConfig(
|
|||||||
const q = params.query?.trim().toLowerCase() || "";
|
const q = params.query?.trim().toLowerCase() || "";
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
|
|
||||||
for (const entry of account.dm?.allowFrom ?? []) {
|
for (const entry of account.config.allowFrom ?? account.dm?.allowFrom ?? []) {
|
||||||
const raw = String(entry).trim();
|
const raw = String(entry).trim();
|
||||||
if (!raw || raw === "*") {
|
if (!raw || raw === "*") {
|
||||||
continue;
|
continue;
|
||||||
@@ -84,7 +84,7 @@ export async function listDiscordDirectoryPeersFromConfig(
|
|||||||
const q = params.query?.trim().toLowerCase() || "";
|
const q = params.query?.trim().toLowerCase() || "";
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
|
|
||||||
for (const entry of account.config.dm?.allowFrom ?? []) {
|
for (const entry of account.config.allowFrom ?? account.config.dm?.allowFrom ?? []) {
|
||||||
const raw = String(entry).trim();
|
const raw = String(entry).trim();
|
||||||
if (!raw || raw === "*") {
|
if (!raw || raw === "*") {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -364,7 +364,7 @@ export const FIELD_HELP: Record<string, string> = {
|
|||||||
"channels.discord.dmPolicy":
|
"channels.discord.dmPolicy":
|
||||||
'Direct message access control ("pairing" recommended). "open" requires channels.discord.allowFrom=["*"].',
|
'Direct message access control ("pairing" recommended). "open" requires channels.discord.allowFrom=["*"].',
|
||||||
"channels.discord.dm.policy":
|
"channels.discord.dm.policy":
|
||||||
'Direct message access control ("pairing" recommended). "open" requires channels.discord.dm.allowFrom=["*"].',
|
'Direct message access control ("pairing" recommended). "open" requires channels.discord.allowFrom=["*"] (legacy: channels.discord.dm.allowFrom).',
|
||||||
"channels.discord.retry.attempts":
|
"channels.discord.retry.attempts":
|
||||||
"Max retry attempts for outbound Discord API calls (default: 3).",
|
"Max retry attempts for outbound Discord API calls (default: 3).",
|
||||||
"channels.discord.retry.minDelayMs": "Minimum retry delay in ms for Discord outbound calls.",
|
"channels.discord.retry.minDelayMs": "Minimum retry delay in ms for Discord outbound calls.",
|
||||||
@@ -385,7 +385,7 @@ export const FIELD_HELP: Record<string, string> = {
|
|||||||
"Discord presence activity type (0=Playing,1=Streaming,2=Listening,3=Watching,4=Custom,5=Competing).",
|
"Discord presence activity type (0=Playing,1=Streaming,2=Listening,3=Watching,4=Custom,5=Competing).",
|
||||||
"channels.discord.activityUrl": "Discord presence streaming URL (required for activityType=1).",
|
"channels.discord.activityUrl": "Discord presence streaming URL (required for activityType=1).",
|
||||||
"channels.slack.dm.policy":
|
"channels.slack.dm.policy":
|
||||||
'Direct message access control ("pairing" recommended). "open" requires channels.slack.dm.allowFrom=["*"].',
|
'Direct message access control ("pairing" recommended). "open" requires channels.slack.allowFrom=["*"] (legacy: channels.slack.dm.allowFrom).',
|
||||||
"channels.slack.dmPolicy":
|
"channels.slack.dmPolicy":
|
||||||
'Direct message access control ("pairing" recommended). "open" requires channels.slack.allowFrom=["*"].',
|
'Direct message access control ("pairing" recommended). "open" requires channels.slack.allowFrom=["*"].',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export type AgentComponentContext = {
|
|||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
guildEntries?: Record<string, DiscordGuildEntryResolved>;
|
guildEntries?: Record<string, DiscordGuildEntryResolved>;
|
||||||
/** DM allowlist (from dm.allowFrom config) */
|
/** DM allowlist (from allowFrom config; legacy: dm.allowFrom) */
|
||||||
allowFrom?: Array<string | number>;
|
allowFrom?: Array<string | number>;
|
||||||
/** DM policy (default: "pairing") */
|
/** DM policy (default: "pairing") */
|
||||||
dmPolicy?: "open" | "pairing" | "allowlist" | "disabled";
|
dmPolicy?: "open" | "pairing" | "allowlist" | "disabled";
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ export async function collectChannelSecurityFindings(params: {
|
|||||||
detail:
|
detail:
|
||||||
"Discord slash commands are enabled, but neither an owner allowFrom list nor any per-guild/channel users allowlist is configured; /… commands will be rejected for everyone.",
|
"Discord slash commands are enabled, but neither an owner allowFrom list nor any per-guild/channel users allowlist is configured; /… commands will be rejected for everyone.",
|
||||||
remediation:
|
remediation:
|
||||||
"Add your user id to channels.discord.dm.allowFrom (or approve yourself via pairing), or configure channels.discord.guilds.<id>.users.",
|
"Add your user id to channels.discord.allowFrom (or approve yourself via pairing), or configure channels.discord.guilds.<id>.users.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,12 +277,23 @@ export async function collectChannelSecurityFindings(params: {
|
|||||||
remediation: "Set commands.useAccessGroups=true (recommended).",
|
remediation: "Set commands.useAccessGroups=true (recommended).",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const dmAllowFromRaw = (account as { dm?: { allowFrom?: unknown } } | null)?.dm
|
const allowFromRaw = (
|
||||||
?.allowFrom;
|
account as
|
||||||
const dmAllowFrom = Array.isArray(dmAllowFromRaw) ? dmAllowFromRaw : [];
|
| { config?: { allowFrom?: unknown }; dm?: { allowFrom?: unknown } }
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
)?.config?.allowFrom;
|
||||||
|
const legacyAllowFromRaw = (
|
||||||
|
account as { dm?: { allowFrom?: unknown } } | null | undefined
|
||||||
|
)?.dm?.allowFrom;
|
||||||
|
const allowFrom = Array.isArray(allowFromRaw)
|
||||||
|
? allowFromRaw
|
||||||
|
: Array.isArray(legacyAllowFromRaw)
|
||||||
|
? legacyAllowFromRaw
|
||||||
|
: [];
|
||||||
const storeAllowFrom = await readChannelAllowFromStore("slack").catch(() => []);
|
const storeAllowFrom = await readChannelAllowFromStore("slack").catch(() => []);
|
||||||
const ownerAllowFromConfigured =
|
const ownerAllowFromConfigured =
|
||||||
normalizeAllowFromList([...dmAllowFrom, ...storeAllowFrom]).length > 0;
|
normalizeAllowFromList([...allowFrom, ...storeAllowFrom]).length > 0;
|
||||||
const channels = (slackCfg.channels as Record<string, unknown> | undefined) ?? {};
|
const channels = (slackCfg.channels as Record<string, unknown> | undefined) ?? {};
|
||||||
const hasAnyChannelUsersAllowlist = Object.values(channels).some((value) => {
|
const hasAnyChannelUsersAllowlist = Object.values(channels).some((value) => {
|
||||||
if (!value || typeof value !== "object") {
|
if (!value || typeof value !== "object") {
|
||||||
@@ -299,7 +310,7 @@ export async function collectChannelSecurityFindings(params: {
|
|||||||
detail:
|
detail:
|
||||||
"Slack slash/native commands are enabled, but neither an owner allowFrom list nor any channels.<id>.users allowlist is configured; /… commands will be rejected for everyone.",
|
"Slack slash/native commands are enabled, but neither an owner allowFrom list nor any channels.<id>.users allowlist is configured; /… commands will be rejected for everyone.",
|
||||||
remediation:
|
remediation:
|
||||||
"Approve yourself via pairing (recommended), or set channels.slack.dm.allowFrom and/or channels.slack.channels.<id>.users.",
|
"Approve yourself via pairing (recommended), or set channels.slack.allowFrom and/or channels.slack.channels.<id>.users.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user