feat(config): gate channel config writes

This commit is contained in:
Peter Steinberger
2026-01-15 01:41:11 +00:00
parent f65668cb5f
commit ad8799522c
28 changed files with 576 additions and 2 deletions

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } 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";
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
const ctx = {
Body: commandBody,
CommandBody: commandBody,
CommandSource: "text",
CommandAuthorized: true,
Provider: "whatsapp",
Surface: "whatsapp",
...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: "whatsapp",
model: "test-model",
contextTokens: 0,
isGroup: false,
};
}
describe("handleCommands /config configWrites gating", () => {
it("blocks /config set when channel config writes are disabled", async () => {
const cfg = {
commands: { config: true, text: true },
channels: { whatsapp: { allowFrom: ["*"], configWrites: false } },
} as ClawdbotConfig;
const params = buildParams("/config set messages.ackReaction=\":)\"", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Config writes are disabled");
});
});

View File

@@ -15,6 +15,8 @@ import {
setConfigOverride,
unsetConfigOverride,
} from "../../config/runtime-overrides.js";
import { resolveChannelConfigWrites } from "../../channels/plugins/config-writes.js";
import { normalizeChannelId } from "../../channels/registry.js";
import { logVerbose } from "../../globals.js";
import type { CommandHandler } from "./commands-types.js";
import { parseConfigCommand } from "./config-commands.js";
@@ -44,6 +46,28 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
reply: { text: `⚠️ ${configCommand.message}` },
};
}
if (configCommand.action === "set" || configCommand.action === "unset") {
const channelId = params.command.channelId ?? normalizeChannelId(params.command.channel);
const allowWrites = resolveChannelConfigWrites({
cfg: params.cfg,
channelId,
accountId: params.ctx.AccountId,
});
if (!allowWrites) {
const channelLabel = channelId ?? "this channel";
const hint = channelId
? `channels.${channelId}.configWrites=true`
: "channels.<channel>.configWrites=true";
return {
shouldContinue: false,
reply: {
text: `⚠️ Config writes are disabled for ${channelLabel}. Set ${hint} to enable.`,
},
};
}
}
const snapshot = await readConfigFileSnapshot();
if (!snapshot.valid || !snapshot.parsed || typeof snapshot.parsed !== "object") {
return {

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { resolveChannelConfigWrites } from "./config-writes.js";
describe("resolveChannelConfigWrites", () => {
it("defaults to allow when unset", () => {
const cfg = {};
expect(resolveChannelConfigWrites({ cfg, channelId: "slack" })).toBe(true);
});
it("blocks when channel config disables writes", () => {
const cfg = { channels: { slack: { configWrites: false } } };
expect(resolveChannelConfigWrites({ cfg, channelId: "slack" })).toBe(false);
});
it("account override wins over channel default", () => {
const cfg = {
channels: {
slack: {
configWrites: true,
accounts: {
work: { configWrites: false },
},
},
},
};
expect(
resolveChannelConfigWrites({ cfg, channelId: "slack", accountId: "work" }),
).toBe(false);
});
it("matches account ids case-insensitively", () => {
const cfg = {
channels: {
slack: {
configWrites: true,
accounts: {
Work: { configWrites: false },
},
},
},
};
expect(
resolveChannelConfigWrites({ cfg, channelId: "slack", accountId: "work" }),
).toBe(false);
});
});

View File

@@ -0,0 +1,35 @@
import type { ClawdbotConfig } from "../../config/config.js";
import type { ChannelId } from "./types.js";
import { normalizeAccountId } from "../../routing/session-key.js";
type ChannelConfigWithAccounts = {
configWrites?: boolean;
accounts?: Record<string, { configWrites?: boolean }>;
};
function resolveAccountConfig(
accounts: ChannelConfigWithAccounts["accounts"],
accountId: string,
) {
if (!accounts || typeof accounts !== "object") return undefined;
if (accountId in accounts) return accounts[accountId];
const matchKey = Object.keys(accounts).find(
(key) => key.toLowerCase() === accountId.toLowerCase(),
);
return matchKey ? accounts[matchKey] : undefined;
}
export function resolveChannelConfigWrites(params: {
cfg: ClawdbotConfig;
channelId?: ChannelId | null;
accountId?: string | null;
}): boolean {
if (!params.channelId) return true;
const channels = params.cfg.channels as Record<string, ChannelConfigWithAccounts> | undefined;
const channelConfig = channels?.[params.channelId];
if (!channelConfig) return true;
const accountId = normalizeAccountId(params.accountId);
const accountConfig = resolveAccountConfig(channelConfig.accounts, accountId);
const value = accountConfig?.configWrites ?? channelConfig.configWrites;
return value !== false;
}

View File

@@ -269,6 +269,20 @@ const FIELD_HELP: Record<string, string> = {
"commands.debug": "Allow /debug chat command for runtime-only overrides (default: false).",
"commands.restart": "Allow /restart and gateway restart tool actions (default: false).",
"commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.",
"channels.telegram.configWrites":
"Allow Telegram to write config in response to channel events/commands (default: true).",
"channels.slack.configWrites":
"Allow Slack to write config in response to channel events/commands (default: true).",
"channels.discord.configWrites":
"Allow Discord to write config in response to channel events/commands (default: true).",
"channels.whatsapp.configWrites":
"Allow WhatsApp to write config in response to channel events/commands (default: true).",
"channels.signal.configWrites":
"Allow Signal to write config in response to channel events/commands (default: true).",
"channels.imessage.configWrites":
"Allow iMessage to write config in response to channel events/commands (default: true).",
"channels.msteams.configWrites":
"Allow Microsoft Teams to write config in response to channel events/commands (default: true).",
"channels.discord.commands.native": 'Override native commands for Discord (bool or "auto").',
"channels.telegram.commands.native": 'Override native commands for Telegram (bool or "auto").',
"channels.slack.commands.native": 'Override native commands for Slack (bool or "auto").',

View File

@@ -72,6 +72,8 @@ export type DiscordAccountConfig = {
capabilities?: string[];
/** Override native command registration for Discord (bool or "auto"). */
commands?: ProviderCommandsConfig;
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/** If false, do not start this Discord account. Default: true. */
enabled?: boolean;
token?: string;

View File

@@ -6,6 +6,8 @@ export type IMessageAccountConfig = {
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/** If false, do not start this iMessage account. Default: true. */
enabled?: boolean;
/** imsg CLI binary path (default: imsg). */

View File

@@ -34,6 +34,8 @@ export type MSTeamsConfig = {
enabled?: boolean;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/** Azure Bot App ID (from Azure Bot registration). */
appId?: string;
/** Azure Bot App Password / Client Secret. */

View File

@@ -8,6 +8,8 @@ export type SignalAccountConfig = {
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/** If false, do not start this Signal account. Default: true. */
enabled?: boolean;
/** Optional explicit E.164 account for signal-cli. */

View File

@@ -67,6 +67,8 @@ export type SlackAccountConfig = {
capabilities?: string[];
/** Override native command registration for Slack (bool or "auto"). */
commands?: ProviderCommandsConfig;
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/** If false, do not start this Slack account. Default: true. */
enabled?: boolean;
botToken?: string;

View File

@@ -21,6 +21,8 @@ export type TelegramAccountConfig = {
capabilities?: string[];
/** Override native command registration for Telegram (bool or "auto"). */
commands?: ProviderCommandsConfig;
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/**
* Controls how Telegram direct chats (DMs) are handled:
* - "pairing" (default): unknown senders get a pairing code; owner must approve

View File

@@ -12,6 +12,8 @@ export type WhatsAppConfig = {
accounts?: Record<string, WhatsAppAccountConfig>;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/**
* Inbound message prefix (WhatsApp only).
* Default: `[{agents.list[].identity.name}]` (or `[clawdbot]`) when allowFrom is empty, else `""`.
@@ -78,6 +80,8 @@ export type WhatsAppAccountConfig = {
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/** If false, do not start this WhatsApp account provider. Default: true. */
enabled?: boolean;
/** Inbound message prefix override for this account (WhatsApp only). */

View File

@@ -36,6 +36,7 @@ export const TelegramAccountSchemaBase = z.object({
capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
commands: ProviderCommandsSchema,
configWrites: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
botToken: z.string().optional(),
tokenFile: z.string().optional(),
@@ -132,6 +133,7 @@ export const DiscordAccountSchema = z.object({
capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
commands: ProviderCommandsSchema,
configWrites: z.boolean().optional(),
token: z.string().optional(),
allowBots: z.boolean().optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
@@ -206,6 +208,7 @@ export const SlackAccountSchema = z.object({
capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
commands: ProviderCommandsSchema,
configWrites: z.boolean().optional(),
botToken: z.string().optional(),
appToken: z.string().optional(),
allowBots: z.boolean().optional(),
@@ -252,6 +255,7 @@ export const SignalAccountSchemaBase = z.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
configWrites: z.boolean().optional(),
account: z.string().optional(),
httpUrl: z.string().optional(),
httpHost: z.string().optional(),
@@ -303,6 +307,7 @@ export const IMessageAccountSchemaBase = z.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
configWrites: z.boolean().optional(),
cliPath: ExecutableTokenSchema.optional(),
dbPath: z.string().optional(),
service: z.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")]).optional(),
@@ -370,6 +375,7 @@ export const MSTeamsConfigSchema = z
.object({
enabled: z.boolean().optional(),
capabilities: z.array(z.string()).optional(),
configWrites: z.boolean().optional(),
appId: z.string().optional(),
appPassword: z.string().optional(),
tenantId: z.string().optional(),

View File

@@ -11,6 +11,7 @@ export const WhatsAppAccountSchema = z
.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
configWrites: z.boolean().optional(),
enabled: z.boolean().optional(),
messagePrefix: z.string().optional(),
/** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */
@@ -60,6 +61,7 @@ export const WhatsAppConfigSchema = z
.object({
accounts: z.record(z.string(), WhatsAppAccountSchema.optional()).optional(),
capabilities: z.array(z.string()).optional(),
configWrites: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
messagePrefix: z.string().optional(),
selfChatMode: z.boolean().optional(),

View File

@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { migrateSlackChannelConfig } from "./channel-migration.js";
describe("migrateSlackChannelConfig", () => {
it("migrates global channel ids", () => {
const cfg = {
channels: {
slack: {
channels: {
C123: { requireMention: false },
},
},
},
};
const result = migrateSlackChannelConfig({
cfg,
accountId: "default",
oldChannelId: "C123",
newChannelId: "C999",
});
expect(result.migrated).toBe(true);
expect(cfg.channels.slack.channels).toEqual({
C999: { requireMention: false },
});
});
it("migrates account-scoped channels", () => {
const cfg = {
channels: {
slack: {
accounts: {
primary: {
channels: {
C123: { requireMention: true },
},
},
},
},
},
};
const result = migrateSlackChannelConfig({
cfg,
accountId: "primary",
oldChannelId: "C123",
newChannelId: "C999",
});
expect(result.migrated).toBe(true);
expect(result.scopes).toEqual(["account"]);
expect(cfg.channels.slack.accounts.primary.channels).toEqual({
C999: { requireMention: true },
});
});
it("matches account ids case-insensitively", () => {
const cfg = {
channels: {
slack: {
accounts: {
Primary: {
channels: {
C123: {},
},
},
},
},
},
};
const result = migrateSlackChannelConfig({
cfg,
accountId: "primary",
oldChannelId: "C123",
newChannelId: "C999",
});
expect(result.migrated).toBe(true);
expect(cfg.channels.slack.accounts.Primary.channels).toEqual({
C999: {},
});
});
it("skips migration when new id already exists", () => {
const cfg = {
channels: {
slack: {
channels: {
C123: { requireMention: true },
C999: { requireMention: false },
},
},
},
};
const result = migrateSlackChannelConfig({
cfg,
accountId: "default",
oldChannelId: "C123",
newChannelId: "C999",
});
expect(result.migrated).toBe(false);
expect(result.skippedExisting).toBe(true);
expect(cfg.channels.slack.channels).toEqual({
C123: { requireMention: true },
C999: { requireMention: false },
});
});
});

View File

@@ -0,0 +1,84 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { SlackChannelConfig } from "../config/types.slack.js";
import { normalizeAccountId } from "../routing/session-key.js";
type SlackChannels = Record<string, SlackChannelConfig>;
type MigrationScope = "account" | "global";
export type SlackChannelMigrationResult = {
migrated: boolean;
skippedExisting: boolean;
scopes: MigrationScope[];
};
function resolveAccountChannels(
cfg: ClawdbotConfig,
accountId?: string | null,
): { channels?: SlackChannels } {
if (!accountId) return {};
const normalized = normalizeAccountId(accountId);
const accounts = cfg.channels?.slack?.accounts;
if (!accounts || typeof accounts !== "object") return {};
const exact = accounts[normalized];
if (exact?.channels) return { channels: exact.channels };
const matchKey = Object.keys(accounts).find(
(key) => key.toLowerCase() === normalized.toLowerCase(),
);
return { channels: matchKey ? accounts[matchKey]?.channels : undefined };
}
export function migrateSlackChannelsInPlace(
channels: SlackChannels | undefined,
oldChannelId: string,
newChannelId: string,
): { migrated: boolean; skippedExisting: boolean } {
if (!channels) return { migrated: false, skippedExisting: false };
if (oldChannelId === newChannelId) return { migrated: false, skippedExisting: false };
if (!Object.hasOwn(channels, oldChannelId)) return { migrated: false, skippedExisting: false };
if (Object.hasOwn(channels, newChannelId)) return { migrated: false, skippedExisting: true };
channels[newChannelId] = channels[oldChannelId];
delete channels[oldChannelId];
return { migrated: true, skippedExisting: false };
}
export function migrateSlackChannelConfig(params: {
cfg: ClawdbotConfig;
accountId?: string | null;
oldChannelId: string;
newChannelId: string;
}): SlackChannelMigrationResult {
const scopes: MigrationScope[] = [];
let migrated = false;
let skippedExisting = false;
const accountChannels = resolveAccountChannels(params.cfg, params.accountId).channels;
if (accountChannels) {
const result = migrateSlackChannelsInPlace(
accountChannels,
params.oldChannelId,
params.newChannelId,
);
if (result.migrated) {
migrated = true;
scopes.push("account");
}
if (result.skippedExisting) skippedExisting = true;
}
const globalChannels = params.cfg.channels?.slack?.channels;
if (globalChannels) {
const result = migrateSlackChannelsInPlace(
globalChannels,
params.oldChannelId,
params.newChannelId,
);
if (result.migrated) {
migrated = true;
scopes.push("global");
}
if (result.skippedExisting) skippedExisting = true;
}
return { migrated, skippedExisting, scopes };
}

View File

@@ -1,11 +1,18 @@
import type { SlackEventMiddlewareArgs } from "@slack/bolt";
import { danger } from "../../../globals.js";
import { loadConfig, writeConfigFile } from "../../../config/config.js";
import { resolveChannelConfigWrites } from "../../../channels/plugins/config-writes.js";
import { danger, warn } from "../../../globals.js";
import { enqueueSystemEvent } from "../../../infra/system-events.js";
import { resolveSlackChannelLabel } from "../channel-config.js";
import type { SlackMonitorContext } from "../context.js";
import type { SlackChannelCreatedEvent, SlackChannelRenamedEvent } from "../types.js";
import { migrateSlackChannelConfig } from "../../channel-migration.js";
import type {
SlackChannelCreatedEvent,
SlackChannelIdChangedEvent,
SlackChannelRenamedEvent,
} from "../types.js";
export function registerSlackChannelEvents(params: { ctx: SlackMonitorContext }) {
const { ctx } = params;
@@ -75,4 +82,74 @@ export function registerSlackChannelEvents(params: { ctx: SlackMonitorContext })
}
},
);
ctx.app.event(
"channel_id_changed",
async ({ event, body }: SlackEventMiddlewareArgs<"channel_id_changed">) => {
try {
if (ctx.shouldDropMismatchedSlackEvent(body)) return;
const payload = event as SlackChannelIdChangedEvent;
const oldChannelId = payload.old_channel_id;
const newChannelId = payload.new_channel_id;
if (!oldChannelId || !newChannelId) return;
const channelInfo = await ctx.resolveChannelName(newChannelId);
const label = resolveSlackChannelLabel({
channelId: newChannelId,
channelName: channelInfo?.name,
});
ctx.runtime.log?.(
warn(`[slack] Channel ID changed: ${oldChannelId}${newChannelId} (${label})`),
);
if (
!resolveChannelConfigWrites({
cfg: ctx.cfg,
channelId: "slack",
accountId: ctx.accountId,
})
) {
ctx.runtime.log?.(
warn("[slack] Config writes disabled; skipping channel config migration."),
);
return;
}
const currentConfig = loadConfig();
const migration = migrateSlackChannelConfig({
cfg: currentConfig,
accountId: ctx.accountId,
oldChannelId,
newChannelId,
});
if (migration.migrated) {
migrateSlackChannelConfig({
cfg: ctx.cfg,
accountId: ctx.accountId,
oldChannelId,
newChannelId,
});
await writeConfigFile(currentConfig);
ctx.runtime.log?.(warn("[slack] Channel config migrated and saved successfully."));
} else if (migration.skippedExisting) {
ctx.runtime.log?.(
warn(
`[slack] Channel config already exists for ${newChannelId}; leaving ${oldChannelId} unchanged`,
),
);
} else {
ctx.runtime.log?.(
warn(
`[slack] No config found for old channel ID ${oldChannelId}; migration logged only`,
),
);
}
} catch (err) {
ctx.runtime.error?.(danger(`slack channel_id_changed handler failed: ${String(err)}`));
}
},
);
}

View File

@@ -46,6 +46,13 @@ export type SlackChannelRenamedEvent = {
event_ts?: string;
};
export type SlackChannelIdChangedEvent = {
type: "channel_id_changed";
old_channel_id?: string;
new_channel_id?: string;
event_ts?: string;
};
export type SlackPinEvent = {
type: "pin_added" | "pin_removed";
channel_id?: string;

View File

@@ -9,6 +9,7 @@ import { firstDefined, isSenderAllowed, normalizeAllowFrom } from "./bot-access.
import { MEDIA_GROUP_TIMEOUT_MS, type MediaGroupEntry } from "./bot-updates.js";
import { migrateTelegramGroupConfig } from "./group-migration.js";
import { readTelegramAllowFromStore } from "./pairing-store.js";
import { resolveChannelConfigWrites } from "../channels/plugins/config-writes.js";
export const registerTelegramHandlers = ({
cfg,
@@ -93,6 +94,13 @@ export const registerTelegramHandlers = ({
runtime.log?.(warn(`[telegram] Group migrated: "${chatTitle}" ${oldChatId}${newChatId}`));
if (!resolveChannelConfigWrites({ cfg, channelId: "telegram", accountId })) {
runtime.log?.(
warn("[telegram] Config writes disabled; skipping group config migration."),
);
return;
}
// Check if old chat ID has config and migrate it
const currentConfig = loadConfig();
const migration = migrateTelegramGroupConfig({