diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ad5626f7..f1355204d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 77a17fb61..a0dfbf8c7 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -60,6 +60,7 @@ Text + native (when enabled): - `/commands` - `/skill [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 ` 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. diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 5e912d1ec..691bdd36e 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -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", diff --git a/src/auto-reply/reply/commands-allowlist.test.ts b/src/auto-reply/reply/commands-allowlist.test.ts new file mode 100644 index 000000000..60c6fdecd --- /dev/null +++ b/src/auto-reply/reply/commands-allowlist.test.ts @@ -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("../../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( + "../../pairing/pairing-store.js", + ); + return { + ...actual, + readChannelAllowFromStore: readChannelAllowFromStoreMock, + addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock, + removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock, + }; +}); + +vi.mock("../../channels/plugins/pairing.js", async () => { + const actual = await vi.importActual( + "../../channels/plugins/pairing.js", + ); + return { + ...actual, + listPairingChannels: () => ["telegram"], + }; +}); + +function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial) { + 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"); + }); +}); diff --git a/src/auto-reply/reply/commands-allowlist.ts b/src/auto-reply/reply/commands-allowlist.ts new file mode 100644 index 000000000..d3326f31f --- /dev/null +++ b/src/auto-reply/reply/commands-allowlist.ts @@ -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(["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 " }; + } + 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[] { + 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 { + 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, + channelId: ChannelId, + accountId?: string | null, +) { + const channels = (parsed.channels ??= {}) as Record; + const channel = (channels[channelId] ??= {}) as Record; + 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; + const account = (accounts[normalizedAccountId] ??= {}) as Record; + return { + target: account, + pathPrefix: `channels.${channelId}.accounts.${normalizedAccountId}`, + accountId: normalizedAccountId, + }; +} + +function getNestedValue(root: Record, path: string[]): unknown { + let current: unknown = root; + for (const key of path) { + if (!current || typeof current !== "object") return undefined; + current = (current as Record)[key]; + } + return current; +} + +function ensureNestedObject( + root: Record, + path: string[], +): Record { + let current = root; + for (const key of path) { + const existing = current[key]; + if (!existing || typeof existing !== "object") { + current[key] = {}; + } + current = current[key] as Record; + } + return current; +} + +function setNestedValue(root: Record, 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, 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)[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(); + const resolved = await resolveSlackUserAllowlist({ token, entries: params.entries }); + const map = new Map(); + 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(); + const resolved = await resolveDiscordUserAllowlist({ token, entries: params.entries }); + const map = new Map(); + 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 || ""}`, + ); + 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= 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); + 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.` }, + }; +}; diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index 887c45568..9abe5e677 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -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, diff --git a/src/pairing/pairing-store.ts b/src/pairing/pairing-store.ts index 26c0ae910..5ae89dbd9 100644 --- a/src/pairing/pairing-store.ts +++ b/src/pairing/pairing-store.ts @@ -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(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,