feat: add /allowlist command

This commit is contained in:
Peter Steinberger
2026-01-21 05:31:07 +00:00
parent 0229b8bbd8
commit 555b2578a8
7 changed files with 839 additions and 0 deletions

View File

@@ -13,6 +13,7 @@ Docs: https://docs.clawd.bot
- Memory: add native Gemini embeddings provider for memory search. (#1151) https://docs.clawd.bot/concepts/memory
### Changes
- Commands: add `/allowlist` slash command for listing and editing channel allowlists.
- Control UI: add copy-as-markdown with error feedback. (#1345) https://docs.clawd.bot/web/control-ui
- Control UI: drop the legacy list view. (#1345) https://docs.clawd.bot/web/control-ui
- TUI: add syntax highlighting for code blocks. (#1200) https://docs.clawd.bot/tui

View File

@@ -60,6 +60,7 @@ Text + native (when enabled):
- `/commands`
- `/skill <name> [input]` (run a skill by name)
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
- `/allowlist` (list/add/remove allowlist entries)
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
- `/whoami` (show your sender id; alias: `/id`)
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
@@ -93,6 +94,7 @@ Notes:
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
- `/new <model>` accepts a model alias, `provider/model`, or a provider name (fuzzy match); if no match, the text is treated as the message body.
- For full provider usage breakdown, use `clawdbot status --usage`.
- `/allowlist add|remove` requires `commands.config=true` and honors channel `configWrites`.
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from Clawdbot session logs.
- `/restart` is disabled by default; set `commands.restart: true` to enable it.
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.

View File

@@ -157,6 +157,13 @@ function buildChatCommands(): ChatCommandDefinition[] {
description: "Show current status.",
textAlias: "/status",
}),
defineChatCommand({
key: "allowlist",
description: "List/add/remove allowlist entries.",
textAlias: "/allowlist",
acceptsArgs: true,
scope: "text",
}),
defineChatCommand({
key: "context",
nativeName: "context",

View File

@@ -0,0 +1,139 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
import { buildCommandContext, handleCommands } from "./commands.js";
import { parseInlineDirectives } from "./directive-handling.js";
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn());
const writeConfigFileMock = vi.hoisted(() => vi.fn());
vi.mock("../../config/config.js", async () => {
const actual =
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
return {
...actual,
readConfigFileSnapshot: readConfigFileSnapshotMock,
validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock,
writeConfigFile: writeConfigFileMock,
};
});
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn());
const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
vi.mock("../../pairing/pairing-store.js", async () => {
const actual = await vi.importActual<typeof import("../../pairing/pairing-store.js")>(
"../../pairing/pairing-store.js",
);
return {
...actual,
readChannelAllowFromStore: readChannelAllowFromStoreMock,
addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock,
removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock,
};
});
vi.mock("../../channels/plugins/pairing.js", async () => {
const actual = await vi.importActual<typeof import("../../channels/plugins/pairing.js")>(
"../../channels/plugins/pairing.js",
);
return {
...actual,
listPairingChannels: () => ["telegram"],
};
});
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
const ctx = {
Body: commandBody,
CommandBody: commandBody,
CommandSource: "text",
CommandAuthorized: true,
Provider: "telegram",
Surface: "telegram",
...ctxOverrides,
} as MsgContext;
const command = buildCommandContext({
ctx,
cfg,
isGroup: false,
triggerBodyNormalized: commandBody.trim().toLowerCase(),
commandAuthorized: true,
});
return {
ctx,
cfg,
command,
directives: parseInlineDirectives(commandBody),
elevated: { enabled: true, allowed: true, failures: [] },
sessionKey: "agent:main:main",
workspaceDir: "/tmp",
defaultGroupActivation: () => "mention",
resolvedVerboseLevel: "off" as const,
resolvedReasoningLevel: "off" as const,
resolveDefaultThinkingLevel: async () => undefined,
provider: "telegram",
model: "test-model",
contextTokens: 0,
isGroup: false,
};
}
describe("handleCommands /allowlist", () => {
it("lists config + store allowFrom entries", async () => {
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
const cfg = {
commands: { text: true },
channels: { telegram: { allowFrom: ["123", "@Alice"] } },
} as ClawdbotConfig;
const params = buildParams("/allowlist list dm", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Channel: telegram");
expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice");
expect(result.reply?.text).toContain("Paired allowFrom (store): 456");
});
it("adds entries to config and pairing store", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
valid: true,
parsed: {
channels: { telegram: { allowFrom: ["123"] } },
},
});
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
ok: true,
config,
}));
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
changed: true,
allowFrom: ["123", "789"],
});
const cfg = {
commands: { text: true, config: true },
channels: { telegram: { allowFrom: ["123"] } },
} as ClawdbotConfig;
const params = buildParams("/allowlist add dm 789", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
channels: { telegram: { allowFrom: ["123", "789"] } },
}),
);
expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({
channel: "telegram",
entry: "789",
});
expect(result.reply?.text).toContain("DM allowlist added");
});
});

View File

@@ -0,0 +1,657 @@
import {
readConfigFileSnapshot,
validateConfigObjectWithPlugins,
writeConfigFile,
} from "../../config/config.js";
import { resolveChannelConfigWrites } from "../../channels/plugins/config-writes.js";
import { getChannelDock } from "../../channels/dock.js";
import { normalizeChannelId } from "../../channels/registry.js";
import { listPairingChannels } from "../../channels/plugins/pairing.js";
import { logVerbose } from "../../globals.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { resolveDiscordAccount } from "../../discord/accounts.js";
import { resolveIMessageAccount } from "../../imessage/accounts.js";
import { resolveSignalAccount } from "../../signal/accounts.js";
import { resolveSlackAccount } from "../../slack/accounts.js";
import { resolveTelegramAccount } from "../../telegram/accounts.js";
import { resolveWhatsAppAccount } from "../../web/accounts.js";
import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js";
import { resolveDiscordUserAllowlist } from "../../discord/resolve-users.js";
import {
addChannelAllowFromStoreEntry,
readChannelAllowFromStore,
removeChannelAllowFromStoreEntry,
} from "../../pairing/pairing-store.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import type { CommandHandler } from "./commands-types.js";
type AllowlistScope = "dm" | "group" | "all";
type AllowlistAction = "list" | "add" | "remove";
type AllowlistTarget = "both" | "config" | "store";
type AllowlistCommand =
| {
action: "list";
scope: AllowlistScope;
channel?: string;
account?: string;
resolve?: boolean;
}
| {
action: "add" | "remove";
scope: AllowlistScope;
channel?: string;
account?: string;
entry: string;
resolve?: boolean;
target: AllowlistTarget;
}
| { action: "error"; message: string };
const ACTIONS = new Set(["list", "add", "remove"]);
const SCOPES = new Set<AllowlistScope>(["dm", "group", "all"]);
function parseAllowlistCommand(raw: string): AllowlistCommand | null {
const trimmed = raw.trim();
if (!trimmed.toLowerCase().startsWith("/allowlist")) return null;
const rest = trimmed.slice("/allowlist".length).trim();
if (!rest) return { action: "list", scope: "dm" };
const tokens = rest.split(/\s+/);
let action: AllowlistAction = "list";
let scope: AllowlistScope = "dm";
let resolve = false;
let target: AllowlistTarget = "both";
let channel: string | undefined;
let account: string | undefined;
const entryTokens: string[] = [];
let i = 0;
if (tokens[i] && ACTIONS.has(tokens[i].toLowerCase())) {
action = tokens[i].toLowerCase() as AllowlistAction;
i += 1;
}
if (tokens[i] && SCOPES.has(tokens[i].toLowerCase() as AllowlistScope)) {
scope = tokens[i].toLowerCase() as AllowlistScope;
i += 1;
}
for (; i < tokens.length; i += 1) {
const token = tokens[i];
const lowered = token.toLowerCase();
if (lowered === "--resolve" || lowered === "resolve") {
resolve = true;
continue;
}
if (lowered === "--config" || lowered === "config") {
target = "config";
continue;
}
if (lowered === "--store" || lowered === "store") {
target = "store";
continue;
}
if (lowered === "--channel" && tokens[i + 1]) {
channel = tokens[i + 1];
i += 1;
continue;
}
if (lowered === "--account" && tokens[i + 1]) {
account = tokens[i + 1];
i += 1;
continue;
}
const kv = token.split("=");
if (kv.length === 2) {
const key = kv[0]?.trim().toLowerCase();
const value = kv[1]?.trim();
if (key === "channel") {
if (value) channel = value;
continue;
}
if (key === "account") {
if (value) account = value;
continue;
}
if (key === "scope" && value && SCOPES.has(value.toLowerCase() as AllowlistScope)) {
scope = value.toLowerCase() as AllowlistScope;
continue;
}
}
entryTokens.push(token);
}
if (action === "add" || action === "remove") {
const entry = entryTokens.join(" ").trim();
if (!entry) {
return { action: "error", message: "Usage: /allowlist add|remove <entry>" };
}
return { action, scope, entry, channel, account, resolve, target };
}
return { action: "list", scope, channel, account, resolve };
}
function normalizeAllowFrom(params: {
cfg: ClawdbotConfig;
channelId: ChannelId;
accountId?: string | null;
values: Array<string | number>;
}): string[] {
const dock = getChannelDock(params.channelId);
if (dock?.config?.formatAllowFrom) {
return dock.config.formatAllowFrom({
cfg: params.cfg,
accountId: params.accountId,
allowFrom: params.values,
});
}
return params.values.map((entry) => String(entry).trim()).filter(Boolean);
}
function formatEntryList(entries: string[], resolved?: Map<string, string>): string {
if (entries.length === 0) return "(none)";
return entries
.map((entry) => {
const name = resolved?.get(entry);
return name ? `${entry} (${name})` : entry;
})
.join(", ");
}
function resolveAccountTarget(
parsed: Record<string, unknown>,
channelId: ChannelId,
accountId?: string | null,
) {
const channels = (parsed.channels ??= {}) as Record<string, unknown>;
const channel = (channels[channelId] ??= {}) as Record<string, unknown>;
const normalizedAccountId = normalizeAccountId(accountId);
const hasAccounts = Boolean(channel.accounts && typeof channel.accounts === "object");
const useAccount = normalizedAccountId !== DEFAULT_ACCOUNT_ID || hasAccounts;
if (!useAccount) {
return { target: channel, pathPrefix: `channels.${channelId}`, accountId: normalizedAccountId };
}
const accounts = (channel.accounts ??= {}) as Record<string, unknown>;
const account = (accounts[normalizedAccountId] ??= {}) as Record<string, unknown>;
return {
target: account,
pathPrefix: `channels.${channelId}.accounts.${normalizedAccountId}`,
accountId: normalizedAccountId,
};
}
function getNestedValue(root: Record<string, unknown>, path: string[]): unknown {
let current: unknown = root;
for (const key of path) {
if (!current || typeof current !== "object") return undefined;
current = (current as Record<string, unknown>)[key];
}
return current;
}
function ensureNestedObject(
root: Record<string, unknown>,
path: string[],
): Record<string, unknown> {
let current = root;
for (const key of path) {
const existing = current[key];
if (!existing || typeof existing !== "object") {
current[key] = {};
}
current = current[key] as Record<string, unknown>;
}
return current;
}
function setNestedValue(root: Record<string, unknown>, path: string[], value: unknown) {
if (path.length === 0) return;
if (path.length === 1) {
root[path[0]] = value;
return;
}
const parent = ensureNestedObject(root, path.slice(0, -1));
parent[path[path.length - 1]] = value;
}
function deleteNestedValue(root: Record<string, unknown>, path: string[]) {
if (path.length === 0) return;
if (path.length === 1) {
delete root[path[0]];
return;
}
const parent = getNestedValue(root, path.slice(0, -1));
if (!parent || typeof parent !== "object") return;
delete (parent as Record<string, unknown>)[path[path.length - 1]];
}
function resolveChannelAllowFromPaths(
channelId: ChannelId,
scope: AllowlistScope,
): string[] | null {
if (scope === "all") return null;
if (scope === "dm") {
if (channelId === "slack" || channelId === "discord") return ["dm", "allowFrom"];
if (
channelId === "telegram" ||
channelId === "whatsapp" ||
channelId === "signal" ||
channelId === "imessage"
) {
return ["allowFrom"];
}
return null;
}
if (scope === "group") {
if (
channelId === "telegram" ||
channelId === "whatsapp" ||
channelId === "signal" ||
channelId === "imessage"
) {
return ["groupAllowFrom"];
}
return null;
}
return null;
}
async function resolveSlackNames(params: {
cfg: ClawdbotConfig;
accountId?: string | null;
entries: string[];
}) {
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
const token = account.config.userToken?.trim() || account.botToken?.trim();
if (!token) return new Map<string, string>();
const resolved = await resolveSlackUserAllowlist({ token, entries: params.entries });
const map = new Map<string, string>();
for (const entry of resolved) {
if (entry.resolved && entry.name) map.set(entry.input, entry.name);
}
return map;
}
async function resolveDiscordNames(params: {
cfg: ClawdbotConfig;
accountId?: string | null;
entries: string[];
}) {
const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
const token = account.token?.trim();
if (!token) return new Map<string, string>();
const resolved = await resolveDiscordUserAllowlist({ token, entries: params.entries });
const map = new Map<string, string>();
for (const entry of resolved) {
if (entry.resolved && entry.name) map.set(entry.input, entry.name);
}
return map;
}
export const handleAllowlistCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
const parsed = parseAllowlistCommand(params.command.commandBodyNormalized);
if (!parsed) return null;
if (parsed.action === "error") {
return { shouldContinue: false, reply: { text: `⚠️ ${parsed.message}` } };
}
if (!params.command.isAuthorizedSender) {
logVerbose(
`Ignoring /allowlist from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
const channelId =
normalizeChannelId(parsed.channel) ??
params.command.channelId ??
normalizeChannelId(params.command.channel);
if (!channelId) {
return {
shouldContinue: false,
reply: { text: "⚠️ Unknown channel. Add channel=<id> to the command." },
};
}
const accountId = normalizeAccountId(parsed.account ?? params.ctx.AccountId);
const scope = parsed.scope;
if (parsed.action === "list") {
const pairingChannels = listPairingChannels();
const supportsStore = pairingChannels.includes(channelId);
const storeAllowFrom = supportsStore
? await readChannelAllowFromStore(channelId).catch(() => [])
: [];
let dmAllowFrom: string[] = [];
let groupAllowFrom: string[] = [];
let groupOverrides: Array<{ label: string; entries: string[] }> = [];
let dmPolicy: string | undefined;
let groupPolicy: string | undefined;
if (channelId === "telegram") {
const account = resolveTelegramAccount({ cfg: params.cfg, accountId });
dmAllowFrom = (account.config.allowFrom ?? []).map(String);
groupAllowFrom = (account.config.groupAllowFrom ?? []).map(String);
dmPolicy = account.config.dmPolicy;
groupPolicy = account.config.groupPolicy;
const groups = account.config.groups ?? {};
for (const [groupId, groupCfg] of Object.entries(groups)) {
const entries = (groupCfg?.allowFrom ?? []).map(String).filter(Boolean);
if (entries.length > 0) {
groupOverrides.push({ label: groupId, entries });
}
const topics = groupCfg?.topics ?? {};
for (const [topicId, topicCfg] of Object.entries(topics)) {
const topicEntries = (topicCfg?.allowFrom ?? []).map(String).filter(Boolean);
if (topicEntries.length > 0) {
groupOverrides.push({ label: `${groupId} topic ${topicId}`, entries: topicEntries });
}
}
}
} else if (channelId === "whatsapp") {
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId });
dmAllowFrom = (account.allowFrom ?? []).map(String);
groupAllowFrom = (account.groupAllowFrom ?? []).map(String);
dmPolicy = account.dmPolicy;
groupPolicy = account.groupPolicy;
} else if (channelId === "signal") {
const account = resolveSignalAccount({ cfg: params.cfg, accountId });
dmAllowFrom = (account.config.allowFrom ?? []).map(String);
groupAllowFrom = (account.config.groupAllowFrom ?? []).map(String);
dmPolicy = account.config.dmPolicy;
groupPolicy = account.config.groupPolicy;
} else if (channelId === "imessage") {
const account = resolveIMessageAccount({ cfg: params.cfg, accountId });
dmAllowFrom = (account.config.allowFrom ?? []).map(String);
groupAllowFrom = (account.config.groupAllowFrom ?? []).map(String);
dmPolicy = account.config.dmPolicy;
groupPolicy = account.config.groupPolicy;
} else if (channelId === "slack") {
const account = resolveSlackAccount({ cfg: params.cfg, accountId });
dmAllowFrom = (account.dm?.allowFrom ?? []).map(String);
groupPolicy = account.groupPolicy;
const channels = account.channels ?? {};
groupOverrides = Object.entries(channels)
.map(([key, value]) => {
const entries = (value?.users ?? []).map(String).filter(Boolean);
return entries.length > 0 ? { label: key, entries } : null;
})
.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);
groupPolicy = account.config.groupPolicy;
const guilds = account.config.guilds ?? {};
for (const [guildKey, guildCfg] of Object.entries(guilds)) {
const entries = (guildCfg?.users ?? []).map(String).filter(Boolean);
if (entries.length > 0) {
groupOverrides.push({ label: `guild ${guildKey}`, entries });
}
const channels = guildCfg?.channels ?? {};
for (const [channelKey, channelCfg] of Object.entries(channels)) {
const channelEntries = (channelCfg?.users ?? []).map(String).filter(Boolean);
if (channelEntries.length > 0) {
groupOverrides.push({
label: `guild ${guildKey} / channel ${channelKey}`,
entries: channelEntries,
});
}
}
}
}
const dmDisplay = normalizeAllowFrom({
cfg: params.cfg,
channelId,
accountId,
values: dmAllowFrom,
});
const groupDisplay = normalizeAllowFrom({
cfg: params.cfg,
channelId,
accountId,
values: groupAllowFrom,
});
const groupOverrideEntries = groupOverrides.flatMap((entry) => entry.entries);
const groupOverrideDisplay = normalizeAllowFrom({
cfg: params.cfg,
channelId,
accountId,
values: groupOverrideEntries,
});
const resolvedDm =
parsed.resolve && dmDisplay.length > 0 && channelId === "slack"
? await resolveSlackNames({ cfg: params.cfg, accountId, entries: dmDisplay })
: parsed.resolve && dmDisplay.length > 0 && channelId === "discord"
? await resolveDiscordNames({ cfg: params.cfg, accountId, entries: dmDisplay })
: undefined;
const resolvedGroup =
parsed.resolve && groupOverrideDisplay.length > 0 && channelId === "slack"
? await resolveSlackNames({
cfg: params.cfg,
accountId,
entries: groupOverrideDisplay,
})
: parsed.resolve && groupOverrideDisplay.length > 0 && channelId === "discord"
? await resolveDiscordNames({
cfg: params.cfg,
accountId,
entries: groupOverrideDisplay,
})
: undefined;
const lines: string[] = ["🧾 Allowlist"];
lines.push(`Channel: ${channelId}${accountId ? ` (account ${accountId})` : ""}`);
if (dmPolicy) lines.push(`DM policy: ${dmPolicy}`);
if (groupPolicy) lines.push(`Group policy: ${groupPolicy}`);
const showDm = scope === "dm" || scope === "all";
const showGroup = scope === "group" || scope === "all";
if (showDm) {
lines.push(`DM allowFrom (config): ${formatEntryList(dmDisplay, resolvedDm)}`);
}
if (supportsStore && storeAllowFrom.length > 0) {
const storeLabel = normalizeAllowFrom({
cfg: params.cfg,
channelId,
accountId,
values: storeAllowFrom,
});
lines.push(`Paired allowFrom (store): ${formatEntryList(storeLabel)}`);
}
if (showGroup) {
if (groupAllowFrom.length > 0) {
lines.push(`Group allowFrom (config): ${formatEntryList(groupDisplay)}`);
}
if (groupOverrides.length > 0) {
lines.push("Group overrides:");
for (const entry of groupOverrides) {
const normalized = normalizeAllowFrom({
cfg: params.cfg,
channelId,
accountId,
values: entry.entries,
});
lines.push(`- ${entry.label}: ${formatEntryList(normalized, resolvedGroup)}`);
}
}
}
return { shouldContinue: false, reply: { text: lines.join("\n") } };
}
if (params.cfg.commands?.config !== true) {
return {
shouldContinue: false,
reply: { text: "⚠️ /allowlist edits are disabled. Set commands.config=true to enable." },
};
}
const shouldUpdateConfig = parsed.target !== "store";
const shouldTouchStore = parsed.target !== "config" && listPairingChannels().includes(channelId);
if (shouldUpdateConfig) {
const allowWrites = resolveChannelConfigWrites({
cfg: params.cfg,
channelId,
accountId: params.ctx.AccountId,
});
if (!allowWrites) {
const hint = `channels.${channelId}.configWrites=true`;
return {
shouldContinue: false,
reply: { text: `⚠️ Config writes are disabled for ${channelId}. Set ${hint} to enable.` },
};
}
const allowlistPath = resolveChannelAllowFromPaths(channelId, scope);
if (!allowlistPath) {
return {
shouldContinue: false,
reply: {
text: `⚠️ ${channelId} does not support ${scope} allowlist edits via /allowlist.`,
},
};
}
const snapshot = await readConfigFileSnapshot();
if (!snapshot.valid || !snapshot.parsed || typeof snapshot.parsed !== "object") {
return {
shouldContinue: false,
reply: { text: "⚠️ Config file is invalid; fix it before using /allowlist." },
};
}
const parsedConfig = structuredClone(snapshot.parsed as Record<string, unknown>);
const {
target,
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 normalizedEntry = normalizeAllowFrom({
cfg: params.cfg,
channelId,
accountId: normalizedAccountId,
values: [parsed.entry],
});
if (normalizedEntry.length === 0) {
return {
shouldContinue: false,
reply: { text: "⚠️ Invalid allowlist entry." },
};
}
const existingNormalized = normalizeAllowFrom({
cfg: params.cfg,
channelId,
accountId: normalizedAccountId,
values: existing,
});
const shouldMatch = (value: string) => normalizedEntry.includes(value);
let configChanged = false;
let next = existing;
const configHasEntry = existingNormalized.some((value) => shouldMatch(value));
if (parsed.action === "add") {
if (!configHasEntry) {
next = [...existing, parsed.entry.trim()];
configChanged = true;
}
}
if (parsed.action === "remove") {
const keep: string[] = [];
for (const entry of existing) {
const normalized = normalizeAllowFrom({
cfg: params.cfg,
channelId,
accountId: normalizedAccountId,
values: [entry],
});
if (normalized.some((value) => shouldMatch(value))) {
configChanged = true;
continue;
}
keep.push(entry);
}
next = keep;
}
if (configChanged) {
if (next.length === 0) {
deleteNestedValue(target, allowlistPath);
} else {
setNestedValue(target, allowlistPath, next);
}
}
if (configChanged) {
const validated = validateConfigObjectWithPlugins(parsedConfig);
if (!validated.ok) {
const issue = validated.issues[0];
return {
shouldContinue: false,
reply: { text: `⚠️ Config invalid after update (${issue.path}: ${issue.message}).` },
};
}
await writeConfigFile(validated.config);
}
if (!configChanged && !shouldTouchStore) {
const message = parsed.action === "add" ? "✅ Already allowlisted." : "⚠️ Entry not found.";
return { shouldContinue: false, reply: { text: message } };
}
if (shouldTouchStore) {
if (parsed.action === "add") {
await addChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
} else if (parsed.action === "remove") {
await removeChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
}
}
const actionLabel = parsed.action === "add" ? "added" : "removed";
const scopeLabel = scope === "dm" ? "DM" : "group";
const locations: string[] = [];
if (configChanged) {
locations.push(`${pathPrefix}.${allowlistPath.join(".")}`);
}
if (shouldTouchStore) {
locations.push("pairing store");
}
const targetLabel = locations.length > 0 ? locations.join(" + ") : "no-op";
return {
shouldContinue: false,
reply: {
text: `${scopeLabel} allowlist ${actionLabel}: ${targetLabel}.`,
},
};
}
if (!shouldTouchStore) {
return {
shouldContinue: false,
reply: { text: "⚠️ This channel does not support allowlist storage." },
};
}
if (parsed.action === "add") {
await addChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
} else if (parsed.action === "remove") {
await removeChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
}
const actionLabel = parsed.action === "add" ? "added" : "removed";
const scopeLabel = scope === "dm" ? "DM" : "group";
return {
shouldContinue: false,
reply: { text: `${scopeLabel} allowlist ${actionLabel} in pairing store.` },
};
};

View File

@@ -13,6 +13,7 @@ import {
handleStatusCommand,
handleWhoamiCommand,
} from "./commands-info.js";
import { handleAllowlistCommand } from "./commands-allowlist.js";
import { handleSubagentsCommand } from "./commands-subagents.js";
import {
handleAbortTrigger,
@@ -37,6 +38,7 @@ const HANDLERS: CommandHandler[] = [
handleHelpCommand,
handleCommandsListCommand,
handleStatusCommand,
handleAllowlistCommand,
handleContextCommand,
handleWhoamiCommand,
handleSubagentsCommand,

View File

@@ -245,6 +245,37 @@ export async function addChannelAllowFromStoreEntry(params: {
);
}
export async function removeChannelAllowFromStoreEntry(params: {
channel: PairingChannel;
entry: string | number;
env?: NodeJS.ProcessEnv;
}): Promise<{ changed: boolean; allowFrom: string[] }> {
const env = params.env ?? process.env;
const filePath = resolveAllowFromPath(params.channel, env);
return await withFileLock(
filePath,
{ version: 1, allowFrom: [] } satisfies AllowFromStore,
async () => {
const { value } = await readJsonFile<AllowFromStore>(filePath, {
version: 1,
allowFrom: [],
});
const current = (Array.isArray(value.allowFrom) ? value.allowFrom : [])
.map((v) => normalizeAllowEntry(params.channel, String(v)))
.filter(Boolean);
const normalized = normalizeAllowEntry(params.channel, normalizeId(params.entry));
if (!normalized) return { changed: false, allowFrom: current };
const next = current.filter((entry) => entry !== normalized);
if (next.length === current.length) return { changed: false, allowFrom: current };
await writeJsonFile(filePath, {
version: 1,
allowFrom: next,
} satisfies AllowFromStore);
return { changed: true, allowFrom: next };
},
);
}
export async function listChannelPairingRequests(
channel: PairingChannel,
env: NodeJS.ProcessEnv = process.env,