fix(allowlist): canonicalize Slack/Discord allowFrom

This commit is contained in:
Peter Steinberger
2026-02-15 03:46:11 +01:00
parent 3c3695d7c2
commit cf04208cb9
7 changed files with 153 additions and 25 deletions

View File

@@ -254,7 +254,8 @@ function resolveChannelAllowFromPaths(
}
if (scope === "dm") {
if (channelId === "slack" || channelId === "discord") {
return ["dm", "allowFrom"];
// Canonical DM allowlist location for Slack/Discord. Legacy: dm.allowFrom.
return ["allowFrom"];
}
if (
channelId === "telegram" ||
@@ -404,7 +405,7 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
groupPolicy = account.config.groupPolicy;
} else if (channelId === "slack") {
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;
const channels = account.channels ?? {};
groupOverrides = Object.entries(channels)
@@ -415,7 +416,7 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
.filter(Boolean) as Array<{ label: string; entries: string[] }>;
} else if (channelId === "discord") {
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;
const guilds = account.config.guilds ?? {};
for (const [guildKey, guildCfg] of Object.entries(guilds)) {
@@ -567,10 +568,25 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
pathPrefix,
accountId: normalizedAccountId,
} = resolveAccountTarget(parsedConfig, channelId, accountId);
const existingRaw = getNestedValue(target, allowlistPath);
const existing = Array.isArray(existingRaw)
? existingRaw.map((entry) => String(entry).trim()).filter(Boolean)
: [];
const existing: string[] = [];
const existingPaths =
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({
cfg: params.cfg,
@@ -628,6 +644,10 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
} else {
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) {

View File

@@ -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 { MsgContext } from "../templating.js";
import { buildCommandContext, handleCommands } from "./commands.js";
@@ -94,6 +94,10 @@ function buildParams(commandBody: string, cfg: OpenClawConfig, ctxOverrides?: Pa
}
describe("handleCommands /allowlist", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("lists config + store allowFrom entries", async () => {
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
@@ -145,6 +149,92 @@ describe("handleCommands /allowlist", () => {
});
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", () => {