mirror of https://github.com/openclaw/openclaw.git
fix(outbound): restore generic delivery and security seams
This commit is contained in:
parent
ab96520bba
commit
856592cf00
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -12,6 +12,7 @@ export * from "./src/probe.js";
|
|||
export * from "./src/session-key-normalization.js";
|
||||
export * from "./src/status-issues.js";
|
||||
export * from "./src/targets.js";
|
||||
export * from "./src/security-audit.js";
|
||||
export { resolveDiscordRuntimeGroupPolicy } from "./src/runtime-group-policy.js";
|
||||
export {
|
||||
DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,235 @@
|
|||
import {
|
||||
isDangerousNameMatchingEnabled,
|
||||
resolveNativeCommandsEnabled,
|
||||
resolveNativeSkillsEnabled,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import type { ResolvedDiscordAccount } from "./accounts.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
|
||||
function normalizeAllowFromList(list: Array<string | number> | undefined | null): string[] {
|
||||
if (!Array.isArray(list)) {
|
||||
return [];
|
||||
}
|
||||
return list.map((value) => String(value).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function coerceNativeSetting(value: unknown): boolean | "auto" | undefined {
|
||||
if (value === true || value === false || value === "auto") {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isDiscordMutableAllowEntry(raw: string): boolean {
|
||||
const text = raw.trim();
|
||||
if (!text || text === "*") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const maybeMentionId = text.replace(/^<@!?/, "").replace(/>$/, "");
|
||||
if (/^\d+$/.test(maybeMentionId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const prefix of ["discord:", "user:", "pk:"]) {
|
||||
if (!text.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
return text.slice(prefix.length).trim().length === 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function addDiscordNameBasedEntries(params: {
|
||||
target: Set<string>;
|
||||
values: unknown;
|
||||
source: string;
|
||||
}) {
|
||||
if (!Array.isArray(params.values)) {
|
||||
return;
|
||||
}
|
||||
for (const value of params.values) {
|
||||
if (!isDiscordMutableAllowEntry(String(value))) {
|
||||
continue;
|
||||
}
|
||||
const text = String(value).trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
params.target.add(`${params.source}:${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectDiscordSecurityAuditFindings(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
account: ResolvedDiscordAccount;
|
||||
orderedAccountIds: string[];
|
||||
hasExplicitAccountPath: boolean;
|
||||
}) {
|
||||
const findings: Array<{
|
||||
checkId: string;
|
||||
severity: "info" | "warn" | "critical";
|
||||
title: string;
|
||||
detail: string;
|
||||
remediation?: string;
|
||||
}> = [];
|
||||
const discordCfg = params.account.config ?? {};
|
||||
const accountId = params.accountId?.trim() || params.account.accountId || "default";
|
||||
const dangerousNameMatchingEnabled = isDangerousNameMatchingEnabled(discordCfg);
|
||||
const storeAllowFrom = await readChannelAllowFromStore("discord", process.env, accountId).catch(
|
||||
() => [],
|
||||
);
|
||||
const discordNameBasedAllowEntries = new Set<string>();
|
||||
const discordPathPrefix =
|
||||
params.orderedAccountIds.length > 1 || params.hasExplicitAccountPath
|
||||
? `channels.discord.accounts.${accountId}`
|
||||
: "channels.discord";
|
||||
|
||||
addDiscordNameBasedEntries({
|
||||
target: discordNameBasedAllowEntries,
|
||||
values: discordCfg.allowFrom,
|
||||
source: `${discordPathPrefix}.allowFrom`,
|
||||
});
|
||||
addDiscordNameBasedEntries({
|
||||
target: discordNameBasedAllowEntries,
|
||||
values: (discordCfg.dm as { allowFrom?: unknown } | undefined)?.allowFrom,
|
||||
source: `${discordPathPrefix}.dm.allowFrom`,
|
||||
});
|
||||
addDiscordNameBasedEntries({
|
||||
target: discordNameBasedAllowEntries,
|
||||
values: storeAllowFrom,
|
||||
source: "~/.openclaw/credentials/discord-allowFrom.json",
|
||||
});
|
||||
|
||||
const guildEntries = (discordCfg.guilds as Record<string, unknown> | undefined) ?? {};
|
||||
for (const [guildKey, guildValue] of Object.entries(guildEntries)) {
|
||||
if (!guildValue || typeof guildValue !== "object") {
|
||||
continue;
|
||||
}
|
||||
const guild = guildValue as Record<string, unknown>;
|
||||
addDiscordNameBasedEntries({
|
||||
target: discordNameBasedAllowEntries,
|
||||
values: guild.users,
|
||||
source: `${discordPathPrefix}.guilds.${guildKey}.users`,
|
||||
});
|
||||
const channels = guild.channels;
|
||||
if (!channels || typeof channels !== "object") {
|
||||
continue;
|
||||
}
|
||||
for (const [channelKey, channelValue] of Object.entries(channels as Record<string, unknown>)) {
|
||||
if (!channelValue || typeof channelValue !== "object") {
|
||||
continue;
|
||||
}
|
||||
const channel = channelValue as Record<string, unknown>;
|
||||
addDiscordNameBasedEntries({
|
||||
target: discordNameBasedAllowEntries,
|
||||
values: channel.users,
|
||||
source: `${discordPathPrefix}.guilds.${guildKey}.channels.${channelKey}.users`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (discordNameBasedAllowEntries.size > 0) {
|
||||
const examples = Array.from(discordNameBasedAllowEntries).slice(0, 5);
|
||||
const more =
|
||||
discordNameBasedAllowEntries.size > examples.length
|
||||
? ` (+${discordNameBasedAllowEntries.size - examples.length} more)`
|
||||
: "";
|
||||
findings.push({
|
||||
checkId: "channels.discord.allowFrom.name_based_entries",
|
||||
severity: dangerousNameMatchingEnabled ? "info" : "warn",
|
||||
title: dangerousNameMatchingEnabled
|
||||
? "Discord allowlist uses break-glass name/tag matching"
|
||||
: "Discord allowlist contains name or tag entries",
|
||||
detail: dangerousNameMatchingEnabled
|
||||
? "Discord name/tag allowlist matching is explicitly enabled via dangerouslyAllowNameMatching. This mutable-identity mode is operator-selected break-glass behavior and out-of-scope for vulnerability reports by itself. " +
|
||||
`Found: ${examples.join(", ")}${more}.`
|
||||
: "Discord name/tag allowlist matching uses normalized slugs and can collide across users. " +
|
||||
`Found: ${examples.join(", ")}${more}.`,
|
||||
remediation: dangerousNameMatchingEnabled
|
||||
? "Prefer stable Discord IDs (or <@id>/user:<id>/pk:<id>), then disable dangerouslyAllowNameMatching."
|
||||
: "Prefer stable Discord IDs (or <@id>/user:<id>/pk:<id>) in channels.discord.allowFrom and channels.discord.guilds.*.users, or explicitly opt in with dangerouslyAllowNameMatching=true if you accept the risk.",
|
||||
});
|
||||
}
|
||||
|
||||
const nativeEnabled = resolveNativeCommandsEnabled({
|
||||
providerId: "discord",
|
||||
providerSetting: coerceNativeSetting(
|
||||
(discordCfg.commands as { native?: unknown } | undefined)?.native,
|
||||
),
|
||||
globalSetting: params.cfg.commands?.native,
|
||||
});
|
||||
const nativeSkillsEnabled = resolveNativeSkillsEnabled({
|
||||
providerId: "discord",
|
||||
providerSetting: coerceNativeSetting(
|
||||
(discordCfg.commands as { nativeSkills?: unknown } | undefined)?.nativeSkills,
|
||||
),
|
||||
globalSetting: params.cfg.commands?.nativeSkills,
|
||||
});
|
||||
if (!nativeEnabled && !nativeSkillsEnabled) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
const defaultGroupPolicy = params.cfg.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy =
|
||||
(discordCfg.groupPolicy as string | undefined) ?? defaultGroupPolicy ?? "allowlist";
|
||||
const guildsConfigured = Object.keys(guildEntries).length > 0;
|
||||
const hasAnyUserAllowlist = Object.values(guildEntries).some((guild) => {
|
||||
if (!guild || typeof guild !== "object") {
|
||||
return false;
|
||||
}
|
||||
const record = guild as Record<string, unknown>;
|
||||
if (Array.isArray(record.users) && record.users.length > 0) {
|
||||
return true;
|
||||
}
|
||||
const channels = record.channels;
|
||||
if (!channels || typeof channels !== "object") {
|
||||
return false;
|
||||
}
|
||||
return Object.values(channels as Record<string, unknown>).some((channel) => {
|
||||
if (!channel || typeof channel !== "object") {
|
||||
return false;
|
||||
}
|
||||
const channelRecord = channel as Record<string, unknown>;
|
||||
return Array.isArray(channelRecord.users) && channelRecord.users.length > 0;
|
||||
});
|
||||
});
|
||||
const dmAllowFromRaw = (discordCfg.dm as { allowFrom?: unknown } | undefined)?.allowFrom;
|
||||
const dmAllowFrom = Array.isArray(dmAllowFromRaw) ? dmAllowFromRaw : [];
|
||||
const ownerAllowFromConfigured =
|
||||
normalizeAllowFromList([...dmAllowFrom, ...storeAllowFrom]).length > 0;
|
||||
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
|
||||
|
||||
if (!useAccessGroups && groupPolicy !== "disabled" && guildsConfigured && !hasAnyUserAllowlist) {
|
||||
findings.push({
|
||||
checkId: "channels.discord.commands.native.unrestricted",
|
||||
severity: "critical",
|
||||
title: "Discord slash commands are unrestricted",
|
||||
detail:
|
||||
"commands.useAccessGroups=false disables sender allowlists for Discord slash commands unless a per-guild/channel users allowlist is configured; with no users allowlist, any user in allowed guild channels can invoke /… commands.",
|
||||
remediation:
|
||||
"Set commands.useAccessGroups=true (recommended), or configure channels.discord.guilds.<id>.users (or channels.discord.guilds.<id>.channels.<channel>.users).",
|
||||
});
|
||||
} else if (
|
||||
useAccessGroups &&
|
||||
groupPolicy !== "disabled" &&
|
||||
guildsConfigured &&
|
||||
!ownerAllowFromConfigured &&
|
||||
!hasAnyUserAllowlist
|
||||
) {
|
||||
findings.push({
|
||||
checkId: "channels.discord.commands.native.no_allowlists",
|
||||
severity: "warn",
|
||||
title: "Discord slash commands have no allowlists",
|
||||
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.",
|
||||
remediation:
|
||||
"Add your user id to channels.discord.allowFrom (or approve yourself via pairing), or configure channels.discord.guilds.<id>.users.",
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
|||
import { createScopedChannelMediaMaxBytesResolver } from "openclaw/plugin-sdk/media-runtime";
|
||||
import {
|
||||
resolveOutboundSendDep,
|
||||
sanitizeForPlainText,
|
||||
type OutboundSendDeps,
|
||||
} from "openclaw/plugin-sdk/outbound-runtime";
|
||||
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
|
||||
|
|
@ -34,6 +35,7 @@ export const signalOutbound: ChannelOutboundAdapter = {
|
|||
chunker: (text, _limit) => text.split(/\n{2,}/).flatMap((chunk) => (chunk ? [chunk] : [])),
|
||||
chunkerMode: "text",
|
||||
textChunkLimit: 4000,
|
||||
sanitizeText: ({ text }) => sanitizeForPlainText(text),
|
||||
sendFormattedText: async ({ cfg, to, text, accountId, deps, abortSignal }) => {
|
||||
const send = resolveSignalSender(deps);
|
||||
const maxBytes = resolveSignalMaxBytes({
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export * from "./src/message-actions.js";
|
|||
export * from "./src/group-policy.js";
|
||||
export * from "./src/monitor/allow-list.js";
|
||||
export * from "./src/probe.js";
|
||||
export * from "./src/security-audit.js";
|
||||
export * from "./src/sent-thread-cache.js";
|
||||
export * from "./src/targets.js";
|
||||
export * from "./src/threading-tool-context.js";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
import {
|
||||
resolveNativeCommandsEnabled,
|
||||
resolveNativeSkillsEnabled,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import type { ResolvedSlackAccount } from "./accounts.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
|
||||
function normalizeAllowFromList(list: Array<string | number> | undefined | null): string[] {
|
||||
if (!Array.isArray(list)) {
|
||||
return [];
|
||||
}
|
||||
return list.map((value) => String(value).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function coerceNativeSetting(value: unknown): boolean | "auto" | undefined {
|
||||
if (value === true || value === false || value === "auto") {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function collectSlackSecurityAuditFindings(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
account: ResolvedSlackAccount;
|
||||
}) {
|
||||
const findings: Array<{
|
||||
checkId: string;
|
||||
severity: "info" | "warn" | "critical";
|
||||
title: string;
|
||||
detail: string;
|
||||
remediation?: string;
|
||||
}> = [];
|
||||
const slackCfg = params.account.config ?? {};
|
||||
const accountId = params.accountId?.trim() || params.account.accountId || "default";
|
||||
const nativeEnabled = resolveNativeCommandsEnabled({
|
||||
providerId: "slack",
|
||||
providerSetting: coerceNativeSetting(
|
||||
(slackCfg.commands as { native?: unknown } | undefined)?.native,
|
||||
),
|
||||
globalSetting: params.cfg.commands?.native,
|
||||
});
|
||||
const nativeSkillsEnabled = resolveNativeSkillsEnabled({
|
||||
providerId: "slack",
|
||||
providerSetting: coerceNativeSetting(
|
||||
(slackCfg.commands as { nativeSkills?: unknown } | undefined)?.nativeSkills,
|
||||
),
|
||||
globalSetting: params.cfg.commands?.nativeSkills,
|
||||
});
|
||||
const slashCommandEnabled =
|
||||
nativeEnabled ||
|
||||
nativeSkillsEnabled ||
|
||||
(slackCfg.slashCommand as { enabled?: unknown } | undefined)?.enabled === true;
|
||||
if (!slashCommandEnabled) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
|
||||
if (!useAccessGroups) {
|
||||
findings.push({
|
||||
checkId: "channels.slack.commands.slash.useAccessGroups_off",
|
||||
severity: "critical",
|
||||
title: "Slack slash commands bypass access groups",
|
||||
detail:
|
||||
"Slack slash/native commands are enabled while commands.useAccessGroups=false; this can allow unrestricted /… command execution from channels/users you didn't explicitly authorize.",
|
||||
remediation: "Set commands.useAccessGroups=true (recommended).",
|
||||
});
|
||||
return findings;
|
||||
}
|
||||
|
||||
const allowFromRaw = slackCfg.allowFrom;
|
||||
const legacyAllowFromRaw = (params.account as { dm?: { allowFrom?: unknown } }).dm?.allowFrom;
|
||||
const allowFrom = Array.isArray(allowFromRaw)
|
||||
? allowFromRaw
|
||||
: Array.isArray(legacyAllowFromRaw)
|
||||
? legacyAllowFromRaw
|
||||
: [];
|
||||
const storeAllowFrom = await readChannelAllowFromStore("slack", process.env, accountId).catch(
|
||||
() => [],
|
||||
);
|
||||
const ownerAllowFromConfigured =
|
||||
normalizeAllowFromList([...allowFrom, ...storeAllowFrom]).length > 0;
|
||||
const channels = (slackCfg.channels as Record<string, unknown> | undefined) ?? {};
|
||||
const hasAnyChannelUsersAllowlist = Object.values(channels).some((value) => {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const channel = value as Record<string, unknown>;
|
||||
return Array.isArray(channel.users) && channel.users.length > 0;
|
||||
});
|
||||
if (!ownerAllowFromConfigured && !hasAnyChannelUsersAllowlist) {
|
||||
findings.push({
|
||||
checkId: "channels.slack.commands.slash.no_allowlists",
|
||||
severity: "warn",
|
||||
title: "Slack slash commands have no allowlists",
|
||||
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.",
|
||||
remediation:
|
||||
"Approve yourself via pairing (recommended), or set channels.slack.allowFrom and/or channels.slack.channels.<id>.users.",
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from "./src/security-audit.js";
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import type { ResolvedSynologyChatAccount } from "./types.js";
|
||||
|
||||
export function collectSynologyChatSecurityAuditFindings(params: {
|
||||
accountId?: string | null;
|
||||
account: ResolvedSynologyChatAccount;
|
||||
orderedAccountIds: string[];
|
||||
hasExplicitAccountPath: boolean;
|
||||
}) {
|
||||
if (!params.account.dangerouslyAllowNameMatching) {
|
||||
return [];
|
||||
}
|
||||
const accountId = params.accountId?.trim() || params.account.accountId || "default";
|
||||
const accountNote =
|
||||
params.orderedAccountIds.length > 1 || params.hasExplicitAccountPath
|
||||
? ` (account: ${accountId})`
|
||||
: "";
|
||||
return [
|
||||
{
|
||||
checkId: "channels.synology-chat.reply.dangerous_name_matching_enabled",
|
||||
severity: "info" as const,
|
||||
title: `Synology Chat dangerous name matching is enabled${accountNote}`,
|
||||
detail:
|
||||
"dangerouslyAllowNameMatching=true re-enables mutable username/nickname matching for reply delivery. This is a break-glass compatibility mode, not a hardened default.",
|
||||
remediation:
|
||||
"Prefer stable numeric Synology Chat user IDs for reply delivery, then disable dangerouslyAllowNameMatching.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ export * from "./src/outbound-adapter.js";
|
|||
export * from "./src/outbound-params.js";
|
||||
export * from "./src/probe.js";
|
||||
export * from "./src/reaction-level.js";
|
||||
export * from "./src/security-audit.js";
|
||||
export * from "./src/sticker-cache.js";
|
||||
export * from "./src/status-issues.js";
|
||||
export * from "./src/targets.js";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
import { resolveNativeSkillsEnabled } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import type { ResolvedTelegramAccount } from "./accounts.js";
|
||||
import { isNumericTelegramUserId, normalizeTelegramAllowFromEntry } from "./allow-from.js";
|
||||
|
||||
function collectInvalidTelegramAllowFromEntries(params: { entries: unknown; target: Set<string> }) {
|
||||
if (!Array.isArray(params.entries)) {
|
||||
return;
|
||||
}
|
||||
for (const entry of params.entries) {
|
||||
const normalized = normalizeTelegramAllowFromEntry(entry);
|
||||
if (!normalized || normalized === "*") {
|
||||
continue;
|
||||
}
|
||||
if (!isNumericTelegramUserId(normalized)) {
|
||||
params.target.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectTelegramSecurityAuditFindings(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
account: ResolvedTelegramAccount;
|
||||
}) {
|
||||
const findings: Array<{
|
||||
checkId: string;
|
||||
severity: "info" | "warn" | "critical";
|
||||
title: string;
|
||||
detail: string;
|
||||
remediation?: string;
|
||||
}> = [];
|
||||
if (params.cfg.commands?.text === false) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
const telegramCfg = params.account.config ?? {};
|
||||
const accountId = params.accountId?.trim() || params.account.accountId || "default";
|
||||
const defaultGroupPolicy = params.cfg.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy =
|
||||
(telegramCfg.groupPolicy as string | undefined) ?? defaultGroupPolicy ?? "allowlist";
|
||||
const groups = telegramCfg.groups as Record<string, unknown> | undefined;
|
||||
const groupsConfigured = Boolean(groups) && Object.keys(groups ?? {}).length > 0;
|
||||
const groupAccessPossible =
|
||||
groupPolicy === "open" || (groupPolicy === "allowlist" && groupsConfigured);
|
||||
if (!groupAccessPossible) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
const storeAllowFrom = await readChannelAllowFromStore("telegram", process.env, accountId).catch(
|
||||
() => [],
|
||||
);
|
||||
const storeHasWildcard = storeAllowFrom.some((value) => String(value).trim() === "*");
|
||||
const invalidTelegramAllowFromEntries = new Set<string>();
|
||||
collectInvalidTelegramAllowFromEntries({
|
||||
entries: storeAllowFrom,
|
||||
target: invalidTelegramAllowFromEntries,
|
||||
});
|
||||
const groupAllowFrom = Array.isArray(telegramCfg.groupAllowFrom)
|
||||
? telegramCfg.groupAllowFrom
|
||||
: [];
|
||||
const groupAllowFromHasWildcard = groupAllowFrom.some((value) => String(value).trim() === "*");
|
||||
collectInvalidTelegramAllowFromEntries({
|
||||
entries: groupAllowFrom,
|
||||
target: invalidTelegramAllowFromEntries,
|
||||
});
|
||||
collectInvalidTelegramAllowFromEntries({
|
||||
entries: Array.isArray(telegramCfg.allowFrom) ? telegramCfg.allowFrom : [],
|
||||
target: invalidTelegramAllowFromEntries,
|
||||
});
|
||||
|
||||
let anyGroupOverride = false;
|
||||
if (groups) {
|
||||
for (const value of Object.values(groups)) {
|
||||
if (!value || typeof value !== "object") {
|
||||
continue;
|
||||
}
|
||||
const group = value as Record<string, unknown>;
|
||||
const allowFrom = Array.isArray(group.allowFrom) ? group.allowFrom : [];
|
||||
if (allowFrom.length > 0) {
|
||||
anyGroupOverride = true;
|
||||
collectInvalidTelegramAllowFromEntries({
|
||||
entries: allowFrom,
|
||||
target: invalidTelegramAllowFromEntries,
|
||||
});
|
||||
}
|
||||
const topics = group.topics;
|
||||
if (!topics || typeof topics !== "object") {
|
||||
continue;
|
||||
}
|
||||
for (const topicValue of Object.values(topics as Record<string, unknown>)) {
|
||||
if (!topicValue || typeof topicValue !== "object") {
|
||||
continue;
|
||||
}
|
||||
const topic = topicValue as Record<string, unknown>;
|
||||
const topicAllow = Array.isArray(topic.allowFrom) ? topic.allowFrom : [];
|
||||
if (topicAllow.length > 0) {
|
||||
anyGroupOverride = true;
|
||||
}
|
||||
collectInvalidTelegramAllowFromEntries({
|
||||
entries: topicAllow,
|
||||
target: invalidTelegramAllowFromEntries,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasAnySenderAllowlist =
|
||||
storeAllowFrom.length > 0 || groupAllowFrom.length > 0 || anyGroupOverride;
|
||||
|
||||
if (invalidTelegramAllowFromEntries.size > 0) {
|
||||
const examples = Array.from(invalidTelegramAllowFromEntries).slice(0, 5);
|
||||
const more =
|
||||
invalidTelegramAllowFromEntries.size > examples.length
|
||||
? ` (+${invalidTelegramAllowFromEntries.size - examples.length} more)`
|
||||
: "";
|
||||
findings.push({
|
||||
checkId: "channels.telegram.allowFrom.invalid_entries",
|
||||
severity: "warn",
|
||||
title: "Telegram allowlist contains non-numeric entries",
|
||||
detail:
|
||||
"Telegram sender authorization requires numeric Telegram user IDs. " +
|
||||
`Found non-numeric allowFrom entries: ${examples.join(", ")}${more}.`,
|
||||
remediation:
|
||||
"Replace @username entries with numeric Telegram user IDs (use setup to resolve), then re-run the audit.",
|
||||
});
|
||||
}
|
||||
|
||||
if (storeHasWildcard || groupAllowFromHasWildcard) {
|
||||
findings.push({
|
||||
checkId: "channels.telegram.groups.allowFrom.wildcard",
|
||||
severity: "critical",
|
||||
title: "Telegram group allowlist contains wildcard",
|
||||
detail:
|
||||
'Telegram group sender allowlist contains "*", which allows any group member to run /… commands and control directives.',
|
||||
remediation:
|
||||
'Remove "*" from channels.telegram.groupAllowFrom and pairing store; prefer explicit numeric Telegram user IDs.',
|
||||
});
|
||||
return findings;
|
||||
}
|
||||
|
||||
if (!hasAnySenderAllowlist) {
|
||||
const skillsEnabled = resolveNativeSkillsEnabled({
|
||||
providerId: "telegram",
|
||||
providerSetting: (telegramCfg.commands as { nativeSkills?: unknown } | undefined)
|
||||
?.nativeSkills as boolean | "auto" | undefined,
|
||||
globalSetting: params.cfg.commands?.nativeSkills,
|
||||
});
|
||||
findings.push({
|
||||
checkId: "channels.telegram.groups.allowFrom.missing",
|
||||
severity: "critical",
|
||||
title: "Telegram group commands have no sender allowlist",
|
||||
detail:
|
||||
`Telegram group access is enabled but no sender allowlist is configured; this allows any group member to invoke /… commands` +
|
||||
(skillsEnabled ? " (including skill commands)." : "."),
|
||||
remediation:
|
||||
"Approve yourself via pairing (recommended), or set channels.telegram.groupAllowFrom (or per-group groups.<id>.allowFrom).",
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
export * from "./src/setup-core.js";
|
||||
export * from "./src/setup-surface.js";
|
||||
export * from "./src/security-audit.js";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
import { isDangerousNameMatchingEnabled } from "../runtime-api.js";
|
||||
import type { ResolvedZalouserAccount } from "./accounts.js";
|
||||
|
||||
function isZalouserMutableGroupEntry(raw: string): boolean {
|
||||
const text = raw.trim();
|
||||
if (!text || text === "*") {
|
||||
return false;
|
||||
}
|
||||
const normalized = text
|
||||
.replace(/^(zalouser|zlu):/i, "")
|
||||
.replace(/^group:/i, "")
|
||||
.trim();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (/^\d+$/.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return !/^g-\S+$/i.test(normalized);
|
||||
}
|
||||
|
||||
export function collectZalouserSecurityAuditFindings(params: {
|
||||
accountId?: string | null;
|
||||
account: ResolvedZalouserAccount;
|
||||
orderedAccountIds: string[];
|
||||
hasExplicitAccountPath: boolean;
|
||||
}) {
|
||||
const zalouserCfg = params.account.config ?? {};
|
||||
const accountId = params.accountId?.trim() || params.account.accountId || "default";
|
||||
const dangerousNameMatchingEnabled = isDangerousNameMatchingEnabled(zalouserCfg);
|
||||
const zalouserPathPrefix =
|
||||
params.orderedAccountIds.length > 1 || params.hasExplicitAccountPath
|
||||
? `channels.zalouser.accounts.${accountId}`
|
||||
: "channels.zalouser";
|
||||
const mutableGroupEntries = new Set<string>();
|
||||
const groups = zalouserCfg.groups;
|
||||
if (groups && typeof groups === "object" && !Array.isArray(groups)) {
|
||||
for (const key of Object.keys(groups as Record<string, unknown>)) {
|
||||
if (!isZalouserMutableGroupEntry(key)) {
|
||||
continue;
|
||||
}
|
||||
mutableGroupEntries.add(`${zalouserPathPrefix}.groups:${key}`);
|
||||
}
|
||||
}
|
||||
if (mutableGroupEntries.size === 0) {
|
||||
return [];
|
||||
}
|
||||
const examples = Array.from(mutableGroupEntries).slice(0, 5);
|
||||
const more =
|
||||
mutableGroupEntries.size > examples.length
|
||||
? ` (+${mutableGroupEntries.size - examples.length} more)`
|
||||
: "";
|
||||
const severity: "info" | "warn" = dangerousNameMatchingEnabled ? "info" : "warn";
|
||||
return [
|
||||
{
|
||||
checkId: "channels.zalouser.groups.mutable_entries",
|
||||
severity,
|
||||
title: dangerousNameMatchingEnabled
|
||||
? "Zalouser group routing uses break-glass name matching"
|
||||
: "Zalouser group routing contains mutable group entries",
|
||||
detail: dangerousNameMatchingEnabled
|
||||
? "Zalouser group-name routing is explicitly enabled via dangerouslyAllowNameMatching. This mutable-identity mode is operator-selected break-glass behavior and out-of-scope for vulnerability reports by itself. " +
|
||||
`Found: ${examples.join(", ")}${more}.`
|
||||
: "Zalouser group auth is ID-only by default, so unresolved group-name or slug entries are ignored for auth and can drift from the intended trusted group. " +
|
||||
`Found: ${examples.join(", ")}${more}.`,
|
||||
remediation: dangerousNameMatchingEnabled
|
||||
? "Prefer stable Zalo group IDs (for example group:<id> or provider-native g- ids), then disable dangerouslyAllowNameMatching."
|
||||
: "Prefer stable Zalo group IDs in channels.zalouser.groups, or explicitly opt in with dangerouslyAllowNameMatching=true if you accept mutable group-name matching.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -4,9 +4,13 @@ const { callGatewayMock } = vi.hoisted(() => ({
|
|||
callGatewayMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../agent-scope.js", () => ({
|
||||
resolveSessionAgentId: () => "agent-123",
|
||||
}));
|
||||
vi.mock("../agent-scope.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../agent-scope.js")>("../agent-scope.js");
|
||||
return {
|
||||
...actual,
|
||||
resolveSessionAgentId: () => "agent-123",
|
||||
};
|
||||
});
|
||||
|
||||
import { createCronTool } from "./cron-tool.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-runtime";
|
||||
import {
|
||||
resolvePayloadMediaUrls,
|
||||
sendPayloadMediaSequence,
|
||||
|
|
@ -112,6 +113,7 @@ export function createDirectTextMediaOutbound<
|
|||
chunker: chunkText,
|
||||
chunkerMode: "text",
|
||||
textChunkLimit: 4000,
|
||||
sanitizeText: ({ text }) => sanitizeForPlainText(text),
|
||||
sendPayload: async (ctx) =>
|
||||
await sendTextMediaPayload({ channel: params.channel, ctx, adapter: outbound }),
|
||||
sendText: async ({ cfg, to, text, accountId, deps, replyToId }) => {
|
||||
|
|
|
|||
|
|
@ -178,7 +178,10 @@ export type ChannelOutboundAdapter = {
|
|||
chunker?: ((text: string, limit: number) => string[]) | null;
|
||||
chunkerMode?: "text" | "markdown";
|
||||
textChunkLimit?: number;
|
||||
sanitizeText?: (params: { text: string; payload: ReplyPayload }) => string;
|
||||
pollMaxOptions?: number;
|
||||
supportsPollDurationSeconds?: boolean;
|
||||
supportsAnonymousPolls?: boolean;
|
||||
normalizePayload?: (params: { payload: ReplyPayload }) => ReplyPayload | null;
|
||||
shouldSkipPlainTextSanitization?: (params: { payload: ReplyPayload }) => boolean;
|
||||
resolveEffectiveTextChunkLimit?: (params: {
|
||||
|
|
@ -198,6 +201,15 @@ export type ChannelOutboundAdapter = {
|
|||
payload: ReplyPayload;
|
||||
hint?: ChannelOutboundPayloadHint;
|
||||
}) => Promise<void> | void;
|
||||
shouldTreatRoutedTextAsVisible?: (params: {
|
||||
kind: "tool" | "block" | "final";
|
||||
text?: string;
|
||||
}) => boolean;
|
||||
targetsMatchForReplySuppression?: (params: {
|
||||
originTarget: string;
|
||||
targetKey: string;
|
||||
targetThreadId?: string;
|
||||
}) => boolean;
|
||||
resolveTarget?: (params: {
|
||||
cfg?: OpenClawConfig;
|
||||
to?: string;
|
||||
|
|
@ -217,6 +229,7 @@ export type ChannelOutboundAdapter = {
|
|||
|
||||
export type ChannelStatusAdapter<ResolvedAccount, Probe = unknown, Audit = unknown> = {
|
||||
defaultRuntime?: ChannelAccountSnapshot;
|
||||
skipStaleSocketHealthCheck?: boolean;
|
||||
buildChannelSummary?: (params: {
|
||||
account: ResolvedAccount;
|
||||
cfg: OpenClawConfig;
|
||||
|
|
@ -497,6 +510,84 @@ export type ChannelElevatedAdapter = {
|
|||
export type ChannelCommandAdapter = {
|
||||
enforceOwnerForCommands?: boolean;
|
||||
skipWhenConfigEmpty?: boolean;
|
||||
nativeCommandsAutoEnabled?: boolean;
|
||||
nativeSkillsAutoEnabled?: boolean;
|
||||
preferSenderE164ForCommands?: boolean;
|
||||
resolveNativeCommandName?: (params: {
|
||||
commandKey: string;
|
||||
defaultName: string;
|
||||
}) => string | undefined;
|
||||
buildCommandsListChannelData?: (params: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
agentId?: string;
|
||||
}) => ReplyPayload["channelData"] | null;
|
||||
buildModelsProviderChannelData?: (params: {
|
||||
providers: Array<{ id: string; count: number }>;
|
||||
}) => ReplyPayload["channelData"] | null;
|
||||
buildModelsListChannelData?: (params: {
|
||||
provider: string;
|
||||
models: readonly string[];
|
||||
currentModel?: string;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
pageSize?: number;
|
||||
modelNames?: ReadonlyMap<string, string>;
|
||||
}) => ReplyPayload["channelData"] | null;
|
||||
buildModelBrowseChannelData?: () => ReplyPayload["channelData"] | null;
|
||||
};
|
||||
|
||||
export type ChannelDoctorConfigMutation = {
|
||||
config: OpenClawConfig;
|
||||
changes: string[];
|
||||
warnings?: string[];
|
||||
};
|
||||
|
||||
export type ChannelDoctorSequenceResult = {
|
||||
changeNotes: string[];
|
||||
warningNotes: string[];
|
||||
};
|
||||
|
||||
export type ChannelDoctorEmptyAllowlistAccountContext = {
|
||||
account: Record<string, unknown>;
|
||||
channelName: string;
|
||||
dmPolicy?: string;
|
||||
effectiveAllowFrom?: Array<string | number>;
|
||||
parent?: Record<string, unknown>;
|
||||
prefix: string;
|
||||
};
|
||||
|
||||
export type ChannelDoctorAdapter = {
|
||||
dmAllowFromMode?: "topOnly" | "topOrNested" | "nestedOnly";
|
||||
groupModel?: "sender" | "route" | "hybrid";
|
||||
groupAllowFromFallbackToAllowFrom?: boolean;
|
||||
warnOnEmptyGroupSenderAllowlist?: boolean;
|
||||
normalizeCompatibilityConfig?: (params: { cfg: OpenClawConfig }) => ChannelDoctorConfigMutation;
|
||||
collectPreviewWarnings?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
doctorFixCommand: string;
|
||||
}) => string[] | Promise<string[]>;
|
||||
collectMutableAllowlistWarnings?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
}) => string[] | Promise<string[]>;
|
||||
repairConfig?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
doctorFixCommand: string;
|
||||
}) => ChannelDoctorConfigMutation | Promise<ChannelDoctorConfigMutation>;
|
||||
runConfigSequence?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
shouldRepair: boolean;
|
||||
}) => ChannelDoctorSequenceResult | Promise<ChannelDoctorSequenceResult>;
|
||||
cleanStaleConfig?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
}) => ChannelDoctorConfigMutation | Promise<ChannelDoctorConfigMutation>;
|
||||
collectEmptyAllowlistExtraWarnings?: (
|
||||
params: ChannelDoctorEmptyAllowlistAccountContext,
|
||||
) => string[];
|
||||
shouldSkipDefaultEmptyGroupAllowlistWarning?: (
|
||||
params: ChannelDoctorEmptyAllowlistAccountContext,
|
||||
) => boolean;
|
||||
};
|
||||
|
||||
export type ChannelLifecycleAdapter = {
|
||||
|
|
@ -511,6 +602,16 @@ export type ChannelLifecycleAdapter = {
|
|||
accountId: string;
|
||||
runtime: RuntimeEnv;
|
||||
}) => Promise<void> | void;
|
||||
runStartupMaintenance?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
log: {
|
||||
info?: (message: string) => void;
|
||||
warn?: (message: string) => void;
|
||||
};
|
||||
trigger?: string;
|
||||
logPrefix?: string;
|
||||
}) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export type ChannelApprovalDeliveryAdapter = {
|
||||
|
|
@ -694,6 +795,7 @@ export type ChannelCommandConversationContext = {
|
|||
};
|
||||
|
||||
export type ChannelConfiguredBindingProvider = {
|
||||
selfParentConversationByDefault?: boolean;
|
||||
compileConfiguredBinding: (params: {
|
||||
binding: ConfiguredBindingRule;
|
||||
conversationId: string;
|
||||
|
|
@ -711,6 +813,45 @@ export type ChannelConfiguredBindingProvider = {
|
|||
|
||||
export type ChannelConversationBindingSupport = {
|
||||
supportsCurrentConversationBinding?: boolean;
|
||||
/**
|
||||
* Preferred placement when a command is started from a top-level conversation
|
||||
* without an existing native thread id.
|
||||
*
|
||||
* - `current`: bind/spawn in the current conversation
|
||||
* - `child`: create a child thread/conversation first
|
||||
*/
|
||||
defaultTopLevelPlacement?: "current" | "child";
|
||||
resolveConversationRef?: (params: {
|
||||
accountId?: string | null;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
threadId?: string | number | null;
|
||||
}) => {
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
} | null;
|
||||
buildBoundReplyChannelData?: (params: {
|
||||
operation: "acp-spawn";
|
||||
placement: "current" | "child";
|
||||
conversation: {
|
||||
channel: string;
|
||||
accountId?: string | null;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
};
|
||||
}) => ReplyPayload["channelData"] | null | Promise<ReplyPayload["channelData"] | null>;
|
||||
shouldStripThreadFromAnnounceOrigin?: (params: {
|
||||
requester: {
|
||||
channel?: string;
|
||||
to?: string;
|
||||
threadId?: string | number;
|
||||
};
|
||||
entry: {
|
||||
channel?: string;
|
||||
to?: string;
|
||||
threadId?: string | number;
|
||||
};
|
||||
}) => boolean;
|
||||
setIdleTimeoutBySessionKey?: (params: {
|
||||
targetSessionKey: string;
|
||||
accountId?: string | null;
|
||||
|
|
@ -745,4 +886,27 @@ export type ChannelSecurityAdapter<ResolvedAccount = unknown> = {
|
|||
ctx: ChannelSecurityContext<ResolvedAccount>,
|
||||
) => ChannelSecurityDmPolicy | null;
|
||||
collectWarnings?: (ctx: ChannelSecurityContext<ResolvedAccount>) => Promise<string[]> | string[];
|
||||
collectAuditFindings?: (
|
||||
ctx: ChannelSecurityContext<ResolvedAccount> & {
|
||||
sourceConfig: OpenClawConfig;
|
||||
orderedAccountIds: string[];
|
||||
hasExplicitAccountPath: boolean;
|
||||
},
|
||||
) =>
|
||||
| Promise<
|
||||
Array<{
|
||||
checkId: string;
|
||||
severity: "info" | "warn" | "critical";
|
||||
title: string;
|
||||
detail: string;
|
||||
remediation?: string;
|
||||
}>
|
||||
>
|
||||
| Array<{
|
||||
checkId: string;
|
||||
severity: "info" | "warn" | "critical";
|
||||
title: string;
|
||||
detail: string;
|
||||
remediation?: string;
|
||||
}>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -401,6 +401,20 @@ export type ChannelMessagingAdapter = {
|
|||
sessionKey: string;
|
||||
ctx: MsgContext;
|
||||
}) => string | undefined;
|
||||
resolveInboundConversation?: (params: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
conversationId?: string;
|
||||
threadId?: string | number;
|
||||
isGroup: boolean;
|
||||
}) => {
|
||||
conversationId?: string;
|
||||
parentConversationId?: string;
|
||||
} | null;
|
||||
resolveDeliveryTarget?: (params: { conversationId: string; parentConversationId?: string }) => {
|
||||
to?: string;
|
||||
threadId?: string;
|
||||
} | null;
|
||||
/**
|
||||
* Canonical plugin-owned session conversation grammar.
|
||||
* Use this when the provider encodes thread or scoped-conversation semantics
|
||||
|
|
@ -500,6 +514,12 @@ export type ChannelAgentPromptAdapter = {
|
|||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}) => string[] | undefined;
|
||||
inboundFormattingHints?: (params: { accountId?: string | null }) =>
|
||||
| {
|
||||
text_markup: string;
|
||||
rules: string[];
|
||||
}
|
||||
| undefined;
|
||||
reactionGuidance?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
|
|
@ -567,6 +587,13 @@ export type ChannelMessageActionAdapter = {
|
|||
params: ChannelMessageActionDiscoveryContext,
|
||||
) => ChannelMessageToolDiscovery | null | undefined;
|
||||
supportsAction?: (params: { action: ChannelMessageActionName }) => boolean;
|
||||
resolveCliActionRequest?: (params: {
|
||||
action: ChannelMessageActionName;
|
||||
args: Record<string, unknown>;
|
||||
}) => {
|
||||
action: ChannelMessageActionName;
|
||||
args: Record<string, unknown>;
|
||||
};
|
||||
requiresTrustedRequesterSender?: (params: {
|
||||
action: ChannelMessageActionName;
|
||||
toolContext?: ChannelThreadingToolContext;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type {
|
|||
ChannelCommandAdapter,
|
||||
ChannelConfigAdapter,
|
||||
ChannelConversationBindingSupport,
|
||||
ChannelDoctorAdapter,
|
||||
ChannelDirectoryAdapter,
|
||||
ChannelResolverAdapter,
|
||||
ChannelElevatedAdapter,
|
||||
|
|
@ -107,6 +108,7 @@ export type ChannelPlugin<ResolvedAccount = any, Probe = unknown, Audit = unknow
|
|||
lifecycle?: ChannelLifecycleAdapter;
|
||||
approvals?: ChannelApprovalAdapter;
|
||||
allowlist?: ChannelAllowlistAdapter;
|
||||
doctor?: ChannelDoctorAdapter;
|
||||
bindings?: ChannelConfiguredBindingProvider;
|
||||
conversationBindings?: ChannelConversationBindingSupport;
|
||||
streaming?: ChannelStreamingAdapter;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { normalizeAnyChannelId } from "../channels/registry.js";
|
||||
import type { OutboundSendDeps } from "../infra/outbound/deliver.js";
|
||||
|
||||
/**
|
||||
|
|
@ -6,23 +7,39 @@ import type { OutboundSendDeps } from "../infra/outbound/deliver.js";
|
|||
*/
|
||||
export type CliOutboundSendSource = { [channelId: string]: unknown };
|
||||
|
||||
const LEGACY_SOURCE_TO_CHANNEL = {
|
||||
sendMessageWhatsApp: "whatsapp",
|
||||
sendMessageTelegram: "telegram",
|
||||
sendMessageDiscord: "discord",
|
||||
sendMessageSlack: "slack",
|
||||
sendMessageSignal: "signal",
|
||||
sendMessageIMessage: "imessage",
|
||||
} as const;
|
||||
function normalizeLegacyChannelStem(raw: string): string {
|
||||
return raw
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
||||
.replace(/_/g, "-")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/-/g, "");
|
||||
}
|
||||
|
||||
const CHANNEL_TO_LEGACY_DEP_KEY = {
|
||||
whatsapp: "sendWhatsApp",
|
||||
telegram: "sendTelegram",
|
||||
discord: "sendDiscord",
|
||||
slack: "sendSlack",
|
||||
signal: "sendSignal",
|
||||
imessage: "sendIMessage",
|
||||
} as const;
|
||||
function resolveChannelIdFromLegacySourceKey(key: string): string | undefined {
|
||||
const match = key.match(/^sendMessage(.+)$/);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const normalizedStem = normalizeLegacyChannelStem(match[1] ?? "");
|
||||
return normalizeAnyChannelId(normalizedStem) ?? (normalizedStem || undefined);
|
||||
}
|
||||
|
||||
function resolveLegacyDepKeysForChannel(channelId: string): string[] {
|
||||
const compact = channelId.replace(/[^a-z0-9]+/gi, "");
|
||||
if (!compact) {
|
||||
return [];
|
||||
}
|
||||
const pascal = compact.charAt(0).toUpperCase() + compact.slice(1);
|
||||
const keys = new Set<string>([`send${pascal}`]);
|
||||
if (pascal.startsWith("I") && pascal.length > 1) {
|
||||
keys.add(`sendI${pascal.slice(1)}`);
|
||||
}
|
||||
if (pascal.startsWith("Ms") && pascal.length > 2) {
|
||||
keys.add(`sendMS${pascal.slice(2)}`);
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass CLI send sources through as-is — both CliOutboundSendSource and
|
||||
|
|
@ -31,17 +48,26 @@ const CHANNEL_TO_LEGACY_DEP_KEY = {
|
|||
export function createOutboundSendDepsFromCliSource(deps: CliOutboundSendSource): OutboundSendDeps {
|
||||
const outbound: OutboundSendDeps = { ...deps };
|
||||
|
||||
for (const [legacySourceKey, channelId] of Object.entries(LEGACY_SOURCE_TO_CHANNEL)) {
|
||||
for (const legacySourceKey of Object.keys(deps)) {
|
||||
const channelId = resolveChannelIdFromLegacySourceKey(legacySourceKey);
|
||||
if (!channelId) {
|
||||
continue;
|
||||
}
|
||||
const sourceValue = deps[legacySourceKey];
|
||||
if (sourceValue !== undefined && outbound[channelId] === undefined) {
|
||||
outbound[channelId] = sourceValue;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [channelId, legacyDepKey] of Object.entries(CHANNEL_TO_LEGACY_DEP_KEY)) {
|
||||
for (const channelId of Object.keys(outbound)) {
|
||||
const sourceValue = outbound[channelId];
|
||||
if (sourceValue !== undefined && outbound[legacyDepKey] === undefined) {
|
||||
outbound[legacyDepKey] = sourceValue;
|
||||
if (sourceValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
for (const legacyDepKey of resolveLegacyDepKeysForChannel(channelId)) {
|
||||
if (outbound[legacyDepKey] === undefined) {
|
||||
outbound[legacyDepKey] = sourceValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { createPluginBoundaryRuntimeSend } from "./plugin-boundary-send.js";
|
||||
import { createChannelOutboundRuntimeSend } from "./channel-outbound-send.js";
|
||||
|
||||
export const runtimeSend = createPluginBoundaryRuntimeSend({
|
||||
pluginId: "discord",
|
||||
exportName: "sendMessageDiscord",
|
||||
missingLabel: "Discord plugin runtime",
|
||||
export const runtimeSend = createChannelOutboundRuntimeSend({
|
||||
channelId: "discord",
|
||||
unavailableMessage: "Discord outbound adapter is unavailable.",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
import { createCachedPluginBoundaryModuleLoader } from "../../plugins/runtime/runtime-plugin-boundary.js";
|
||||
|
||||
type RuntimeSendModule = Record<string, unknown>;
|
||||
|
||||
export type RuntimeSend = {
|
||||
sendMessage: (...args: unknown[]) => Promise<unknown>;
|
||||
};
|
||||
|
||||
function resolveRuntimeExport(
|
||||
module: RuntimeSendModule | null,
|
||||
pluginId: string,
|
||||
exportName: string,
|
||||
): (...args: unknown[]) => Promise<unknown> {
|
||||
const candidate = module?.[exportName];
|
||||
if (typeof candidate !== "function") {
|
||||
throw new Error(`${pluginId} plugin runtime is unavailable: missing export '${exportName}'`);
|
||||
}
|
||||
return candidate as (...args: unknown[]) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export function createPluginBoundaryRuntimeSend(params: {
|
||||
pluginId: string;
|
||||
exportName: string;
|
||||
missingLabel: string;
|
||||
}): RuntimeSend {
|
||||
const loadRuntimeModuleSync = createCachedPluginBoundaryModuleLoader<RuntimeSendModule>({
|
||||
pluginId: params.pluginId,
|
||||
entryBaseName: "runtime-api",
|
||||
required: true,
|
||||
missingLabel: params.missingLabel,
|
||||
});
|
||||
|
||||
return {
|
||||
sendMessage: (...args) =>
|
||||
resolveRuntimeExport(loadRuntimeModuleSync(), params.pluginId, params.exportName)(...args),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { createPluginBoundaryRuntimeSend } from "./plugin-boundary-send.js";
|
||||
import { createChannelOutboundRuntimeSend } from "./channel-outbound-send.js";
|
||||
|
||||
export const runtimeSend = createPluginBoundaryRuntimeSend({
|
||||
pluginId: "signal",
|
||||
exportName: "sendMessageSignal",
|
||||
missingLabel: "Signal plugin runtime",
|
||||
export const runtimeSend = createChannelOutboundRuntimeSend({
|
||||
channelId: "signal",
|
||||
unavailableMessage: "Signal outbound adapter is unavailable.",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { createPluginBoundaryRuntimeSend } from "./plugin-boundary-send.js";
|
||||
import { createChannelOutboundRuntimeSend } from "./channel-outbound-send.js";
|
||||
|
||||
export const runtimeSend = createPluginBoundaryRuntimeSend({
|
||||
pluginId: "slack",
|
||||
exportName: "sendMessageSlack",
|
||||
missingLabel: "Slack plugin runtime",
|
||||
export const runtimeSend = createChannelOutboundRuntimeSend({
|
||||
channelId: "slack",
|
||||
unavailableMessage: "Slack outbound adapter is unavailable.",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,39 +1,6 @@
|
|||
import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { createChannelOutboundRuntimeSend } from "./channel-outbound-send.js";
|
||||
|
||||
type TelegramRuntimeSendOpts = {
|
||||
cfg?: ReturnType<typeof loadConfig>;
|
||||
mediaUrl?: string;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
accountId?: string;
|
||||
messageThreadId?: string | number;
|
||||
replyToMessageId?: string | number;
|
||||
silent?: boolean;
|
||||
forceDocument?: boolean;
|
||||
gatewayClientScopes?: readonly string[];
|
||||
};
|
||||
|
||||
export const runtimeSend = {
|
||||
sendMessage: async (to: string, text: string, opts: TelegramRuntimeSendOpts = {}) => {
|
||||
const outbound = await loadChannelOutboundAdapter("telegram");
|
||||
if (!outbound?.sendText) {
|
||||
throw new Error("Telegram outbound adapter is unavailable.");
|
||||
}
|
||||
return await outbound.sendText({
|
||||
cfg: opts.cfg ?? loadConfig(),
|
||||
to,
|
||||
text,
|
||||
mediaUrl: opts.mediaUrl,
|
||||
mediaLocalRoots: opts.mediaLocalRoots,
|
||||
accountId: opts.accountId,
|
||||
threadId: opts.messageThreadId,
|
||||
replyToId:
|
||||
opts.replyToMessageId == null
|
||||
? undefined
|
||||
: String(opts.replyToMessageId).trim() || undefined,
|
||||
silent: opts.silent,
|
||||
forceDocument: opts.forceDocument,
|
||||
gatewayClientScopes: opts.gatewayClientScopes,
|
||||
});
|
||||
},
|
||||
};
|
||||
export const runtimeSend = createChannelOutboundRuntimeSend({
|
||||
channelId: "telegram",
|
||||
unavailableMessage: "Telegram outbound adapter is unavailable.",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { createPluginBoundaryRuntimeSend } from "./plugin-boundary-send.js";
|
||||
import { createChannelOutboundRuntimeSend } from "./channel-outbound-send.js";
|
||||
|
||||
export const runtimeSend = createPluginBoundaryRuntimeSend({
|
||||
pluginId: "whatsapp",
|
||||
exportName: "sendMessageWhatsApp",
|
||||
missingLabel: "WhatsApp plugin runtime",
|
||||
export const runtimeSend = createChannelOutboundRuntimeSend({
|
||||
channelId: "whatsapp",
|
||||
unavailableMessage: "WhatsApp outbound adapter is unavailable.",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ describe("generic current-conversation bindings", () => {
|
|||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps Slack current-conversation binding support when the runtime registry is empty", () => {
|
||||
it("requires an active channel plugin registration", () => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
|
||||
expect(
|
||||
|
|
@ -84,12 +84,7 @@ describe("generic current-conversation bindings", () => {
|
|||
channel: "slack",
|
||||
accountId: "default",
|
||||
}),
|
||||
).toEqual({
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current"],
|
||||
});
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("reloads persisted bindings after the in-memory cache is cleared", async () => {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ type PersistedCurrentConversationBindingsFile = {
|
|||
|
||||
const CURRENT_BINDINGS_FILE_VERSION = 1;
|
||||
const CURRENT_BINDINGS_ID_PREFIX = "generic:";
|
||||
const FALLBACK_CURRENT_CONVERSATION_BINDING_CHANNELS = new Set(["slack"]);
|
||||
|
||||
let bindingsLoaded = false;
|
||||
let persistPromise: Promise<void> = Promise.resolve();
|
||||
|
|
@ -136,10 +135,7 @@ function resolveChannelSupportsCurrentConversationBinding(channel: string): bool
|
|||
if (plugin?.conversationBindings?.supportsCurrentConversationBinding === true) {
|
||||
return true;
|
||||
}
|
||||
// Slack live/gateway tests intentionally skip channel startup, so there is no
|
||||
// active runtime plugin snapshot even though the generic current-conversation
|
||||
// path is still expected to work.
|
||||
return FALLBACK_CURRENT_CONVERSATION_BINDING_CHANNELS.has(normalized);
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getGenericCurrentConversationBindingCapabilities(params: {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import {
|
||||
releasePinnedPluginChannelRegistry,
|
||||
setActivePluginRegistry,
|
||||
} from "../../plugins/runtime.js";
|
||||
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
|
||||
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
|
||||
|
|
@ -174,6 +177,7 @@ export const defaultRegistry = createTestRegistry([
|
|||
export const emptyRegistry = createTestRegistry([]);
|
||||
|
||||
export function resetDeliverTestState() {
|
||||
releasePinnedPluginChannelRegistry();
|
||||
setActivePluginRegistry(defaultRegistry);
|
||||
deliverMocks.hooks.runner.hasHooks = () => false;
|
||||
deliverMocks.hooks.runner.runMessageSent = async () => {};
|
||||
|
|
@ -190,6 +194,7 @@ export function resetDeliverTestState() {
|
|||
}
|
||||
|
||||
export function clearDeliverTestRegistry() {
|
||||
releasePinnedPluginChannelRegistry();
|
||||
setActivePluginRegistry(emptyRegistry);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import type { OpenClawConfig } from "../../config/config.js";
|
|||
import { createHookRunner } from "../../plugins/hooks.js";
|
||||
import { addTestHook } from "../../plugins/hooks.test-helpers.js";
|
||||
import { createEmptyPluginRegistry } from "../../plugins/registry.js";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import {
|
||||
releasePinnedPluginChannelRegistry,
|
||||
setActivePluginRegistry,
|
||||
} from "../../plugins/runtime.js";
|
||||
import type { PluginHookRegistration } from "../../plugins/types.js";
|
||||
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
|
||||
|
|
@ -189,6 +192,7 @@ describe("deliverOutboundPayloads", () => {
|
|||
});
|
||||
|
||||
beforeEach(() => {
|
||||
releasePinnedPluginChannelRegistry();
|
||||
setActivePluginRegistry(defaultRegistry);
|
||||
mocks.appendAssistantMessageToSessionTranscript.mockClear();
|
||||
hookMocks.runner.hasHooks.mockClear();
|
||||
|
|
@ -210,6 +214,7 @@ describe("deliverOutboundPayloads", () => {
|
|||
});
|
||||
|
||||
afterEach(() => {
|
||||
releasePinnedPluginChannelRegistry();
|
||||
setActivePluginRegistry(emptyRegistry);
|
||||
});
|
||||
it("chunks direct adapter text and preserves delivery overrides across sends", async () => {
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ import type { OutboundIdentity } from "./identity.js";
|
|||
import type { DeliveryMirror } from "./mirror.js";
|
||||
import type { NormalizedOutboundPayload } from "./payloads.js";
|
||||
import { normalizeReplyPayloadsForDelivery } from "./payloads.js";
|
||||
import { isPlainTextSurface, sanitizeForPlainText } from "./sanitize-text.js";
|
||||
import { resolveOutboundSendDep, type OutboundSendDeps } from "./send-deps.js";
|
||||
import type { OutboundSessionContext } from "./session-context.js";
|
||||
import type { OutboundChannel } from "./targets.js";
|
||||
|
|
@ -84,6 +83,7 @@ type ChannelHandler = {
|
|||
chunkerMode?: "text" | "markdown";
|
||||
textChunkLimit?: number;
|
||||
supportsMedia: boolean;
|
||||
sanitizeText?: (payload: ReplyPayload) => string;
|
||||
normalizePayload?: (payload: ReplyPayload) => ReplyPayload | null;
|
||||
shouldSkipPlainTextSanitization?: (payload: ReplyPayload) => boolean;
|
||||
resolveEffectiveTextChunkLimit?: (fallbackLimit?: number) => number | undefined;
|
||||
|
|
@ -192,6 +192,9 @@ function createPluginHandler(
|
|||
chunkerMode,
|
||||
textChunkLimit: outbound.textChunkLimit,
|
||||
supportsMedia: Boolean(sendMedia),
|
||||
sanitizeText: outbound.sanitizeText
|
||||
? (payload) => outbound.sanitizeText!({ text: payload.text ?? "", payload })
|
||||
: undefined,
|
||||
normalizePayload: outbound.normalizePayload
|
||||
? (payload) => outbound.normalizePayload!({ payload })
|
||||
: undefined,
|
||||
|
|
@ -335,20 +338,16 @@ function normalizeEmptyPayloadForDelivery(payload: ReplyPayload): ReplyPayload |
|
|||
|
||||
function normalizePayloadsForChannelDelivery(
|
||||
payloads: ReplyPayload[],
|
||||
channel: Exclude<OutboundChannel, "none">,
|
||||
handler: ChannelHandler,
|
||||
): ReplyPayload[] {
|
||||
const normalizedPayloads: ReplyPayload[] = [];
|
||||
for (const payload of normalizeReplyPayloadsForDelivery(payloads)) {
|
||||
let sanitizedPayload = payload;
|
||||
// Strip HTML tags for plain-text surfaces (WhatsApp, Signal, etc.)
|
||||
// Models occasionally produce <br>, <b>, etc. that render as literal text.
|
||||
// See https://github.com/openclaw/openclaw/issues/31884
|
||||
if (isPlainTextSurface(channel) && sanitizedPayload.text) {
|
||||
if (handler.sanitizeText && sanitizedPayload.text) {
|
||||
if (!handler.shouldSkipPlainTextSanitization?.(sanitizedPayload)) {
|
||||
sanitizedPayload = {
|
||||
...sanitizedPayload,
|
||||
text: sanitizeForPlainText(sanitizedPayload.text),
|
||||
text: handler.sanitizeText(sanitizedPayload),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -650,7 +649,7 @@ async function deliverOutboundPayloadsCore(
|
|||
results.push(await handler.sendText(chunk, overrides));
|
||||
}
|
||||
};
|
||||
const normalizedPayloads = normalizePayloadsForChannelDelivery(payloads, channel, handler);
|
||||
const normalizedPayloads = normalizePayloadsForChannelDelivery(payloads, handler);
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
const sessionKeyForInternalHooks = params.mirror?.sessionKey ?? params.session?.key;
|
||||
const mirrorIsGroup = params.mirror?.isGroup;
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ const PERMANENT_ERROR_PATTERNS: readonly RegExp[] = [
|
|||
/chat_id is empty/i,
|
||||
/recipient is not a valid/i,
|
||||
/outbound not configured for channel/i,
|
||||
/ambiguous discord recipient/i,
|
||||
/ambiguous .* recipient/i,
|
||||
/User .* not in room/i,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -369,7 +369,7 @@ async function handleBroadcastAction(
|
|||
}
|
||||
return {
|
||||
kind: "broadcast",
|
||||
channel: targetChannels[0] ?? "discord",
|
||||
channel: targetChannels[0] ?? channelHint?.trim().toLowerCase() ?? "unknown",
|
||||
action: "broadcast",
|
||||
handledBy: input.dryRun ? "dry-run" : "core",
|
||||
payload: { results },
|
||||
|
|
|
|||
|
|
@ -1,27 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { isPlainTextSurface, sanitizeForPlainText } from "./sanitize-text.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isPlainTextSurface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("isPlainTextSurface", () => {
|
||||
it.each(["whatsapp", "signal", "sms", "irc", "telegram", "imessage", "googlechat"])(
|
||||
"returns true for %s",
|
||||
(channel) => {
|
||||
expect(isPlainTextSurface(channel)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["discord", "slack", "web", "matrix"])("returns false for %s", (channel) => {
|
||||
expect(isPlainTextSurface(channel)).toBe(false);
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(isPlainTextSurface("WhatsApp")).toBe(true);
|
||||
expect(isPlainTextSurface("SIGNAL")).toBe(true);
|
||||
});
|
||||
});
|
||||
import { sanitizeForPlainText } from "./sanitize-text.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sanitizeForPlainText
|
||||
|
|
|
|||
|
|
@ -11,22 +11,6 @@
|
|||
* @see https://github.com/openclaw/openclaw/issues/18558
|
||||
*/
|
||||
|
||||
/** Channels where HTML tags should be converted/stripped. */
|
||||
const PLAIN_TEXT_SURFACES = new Set([
|
||||
"whatsapp",
|
||||
"signal",
|
||||
"sms",
|
||||
"irc",
|
||||
"telegram",
|
||||
"imessage",
|
||||
"googlechat",
|
||||
]);
|
||||
|
||||
/** Returns `true` when the channel cannot render raw HTML. */
|
||||
export function isPlainTextSurface(channelId: string): boolean {
|
||||
return PLAIN_TEXT_SURFACES.has(channelId.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert common HTML tags to their plain-text/lightweight-markup equivalents
|
||||
* and strip anything that remains.
|
||||
|
|
|
|||
|
|
@ -1,41 +1,42 @@
|
|||
type LegacyOutboundSendDeps = {
|
||||
sendWhatsApp?: unknown;
|
||||
sendTelegram?: unknown;
|
||||
sendDiscord?: unknown;
|
||||
sendSlack?: unknown;
|
||||
sendSignal?: unknown;
|
||||
sendIMessage?: unknown;
|
||||
sendMatrix?: unknown;
|
||||
sendMSTeams?: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Dynamic bag of per-channel send functions, keyed by channel ID.
|
||||
* Each outbound adapter resolves its own function from this record and
|
||||
* falls back to a direct import when the key is absent.
|
||||
*/
|
||||
export type OutboundSendDeps = LegacyOutboundSendDeps & { [channelId: string]: unknown };
|
||||
export type OutboundSendDeps = { [channelId: string]: unknown };
|
||||
|
||||
const LEGACY_SEND_DEP_KEYS = {
|
||||
whatsapp: "sendWhatsApp",
|
||||
telegram: "sendTelegram",
|
||||
discord: "sendDiscord",
|
||||
slack: "sendSlack",
|
||||
signal: "sendSignal",
|
||||
imessage: "sendIMessage",
|
||||
matrix: "sendMatrix",
|
||||
msteams: "sendMSTeams",
|
||||
} as const satisfies Record<string, keyof LegacyOutboundSendDeps>;
|
||||
function resolveLegacyDepKeysForChannel(channelId: string): string[] {
|
||||
const compact = channelId.replace(/[^a-z0-9]+/gi, "");
|
||||
if (!compact) {
|
||||
return [];
|
||||
}
|
||||
const pascal = compact.charAt(0).toUpperCase() + compact.slice(1);
|
||||
const keys = new Set<string>([`send${pascal}`]);
|
||||
if (compact === "whatsapp") {
|
||||
keys.add("sendWhatsApp");
|
||||
}
|
||||
if (pascal.startsWith("I") && pascal.length > 1) {
|
||||
keys.add(`sendI${pascal.slice(1)}`);
|
||||
}
|
||||
if (pascal.startsWith("Ms") && pascal.length > 2) {
|
||||
keys.add(`sendMS${pascal.slice(2)}`);
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
export function resolveOutboundSendDep<T>(
|
||||
deps: OutboundSendDeps | null | undefined,
|
||||
channelId: keyof typeof LEGACY_SEND_DEP_KEYS,
|
||||
channelId: string,
|
||||
): T | undefined {
|
||||
const dynamic = deps?.[channelId];
|
||||
if (dynamic !== undefined) {
|
||||
return dynamic as T;
|
||||
}
|
||||
const legacyKey = LEGACY_SEND_DEP_KEYS[channelId];
|
||||
const legacy = deps?.[legacyKey];
|
||||
return legacy as T | undefined;
|
||||
for (const legacyKey of resolveLegacyDepKeysForChannel(channelId)) {
|
||||
const legacy = deps?.[legacyKey];
|
||||
if (legacy !== undefined) {
|
||||
return legacy as T;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,9 +14,16 @@ export type {
|
|||
ChannelMessageActionName,
|
||||
ChannelMessageToolDiscovery,
|
||||
ChannelMessageToolSchemaContribution,
|
||||
ChannelStructuredComponents,
|
||||
ChannelStatusIssue,
|
||||
ChannelThreadingContext,
|
||||
ChannelThreadingToolContext,
|
||||
} from "../channels/plugins/types.js";
|
||||
|
||||
export type { ChannelDirectoryAdapter } from "../channels/plugins/types.adapters.js";
|
||||
export type {
|
||||
ChannelDirectoryAdapter,
|
||||
ChannelDoctorAdapter,
|
||||
ChannelDoctorConfigMutation,
|
||||
ChannelDoctorEmptyAllowlistAccountContext,
|
||||
ChannelDoctorSequenceResult,
|
||||
} from "../channels/plugins/types.adapters.js";
|
||||
|
|
|
|||
|
|
@ -71,7 +71,6 @@ export {
|
|||
resolveSkillCommandInvocation,
|
||||
} from "../auto-reply/skill-commands.js";
|
||||
export type { SkillCommandSpec } from "../agents/skills.js";
|
||||
export { buildCommandsPaginationKeyboard } from "../auto-reply/reply/commands-info.js";
|
||||
export {
|
||||
buildModelsProviderData,
|
||||
formatModelsAvailableHeader,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ export {
|
|||
resolveTelegramCustomCommands,
|
||||
} from "../config/telegram-custom-commands.js";
|
||||
export {
|
||||
formatSlackStreamingBooleanMigrationMessage,
|
||||
formatSlackStreamModeMigrationMessage,
|
||||
mapStreamingModeToSlackLegacyDraftStreamMode,
|
||||
resolveDiscordPreviewStreamMode,
|
||||
resolveSlackNativeStreaming,
|
||||
|
|
|
|||
|
|
@ -42,8 +42,6 @@ export {
|
|||
resolveThreadBindingThreadName,
|
||||
} from "../channels/thread-bindings-messages.js";
|
||||
export {
|
||||
DISCORD_THREAD_BINDING_CHANNEL,
|
||||
MATRIX_THREAD_BINDING_CHANNEL,
|
||||
formatThreadBindingDisabledError,
|
||||
resolveThreadBindingEffectiveExpiresAt,
|
||||
resolveThreadBindingIdleTimeoutMs,
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ export type {
|
|||
OpenClawPluginService,
|
||||
OpenClawPluginServiceContext,
|
||||
PluginCommandContext,
|
||||
PluginInteractiveTelegramHandlerContext,
|
||||
PluginLogger,
|
||||
ProviderAuthContext,
|
||||
ProviderAuthDoctorHintContext,
|
||||
|
|
@ -247,6 +246,8 @@ type CreateChannelPluginBaseOptions<TResolvedAccount> = {
|
|||
meta?: Partial<NonNullable<ChannelPlugin<TResolvedAccount>["meta"]>>;
|
||||
setupWizard?: NonNullable<ChannelPlugin<TResolvedAccount>["setupWizard"]>;
|
||||
capabilities?: ChannelPlugin<TResolvedAccount>["capabilities"];
|
||||
commands?: ChannelPlugin<TResolvedAccount>["commands"];
|
||||
doctor?: ChannelPlugin<TResolvedAccount>["doctor"];
|
||||
agentPrompt?: ChannelPlugin<TResolvedAccount>["agentPrompt"];
|
||||
streaming?: ChannelPlugin<TResolvedAccount>["streaming"];
|
||||
reload?: ChannelPlugin<TResolvedAccount>["reload"];
|
||||
|
|
@ -267,6 +268,8 @@ type CreatedChannelPluginBase<TResolvedAccount> = Pick<
|
|||
ChannelPlugin<TResolvedAccount>,
|
||||
| "setupWizard"
|
||||
| "capabilities"
|
||||
| "commands"
|
||||
| "doctor"
|
||||
| "agentPrompt"
|
||||
| "streaming"
|
||||
| "reload"
|
||||
|
|
@ -357,6 +360,7 @@ type ChatChannelSecurityOptions<TResolvedAccount extends { accountId?: string |
|
|||
normalizeEntry?: (raw: string) => string;
|
||||
};
|
||||
collectWarnings?: ChannelSecurityAdapter<TResolvedAccount>["collectWarnings"];
|
||||
collectAuditFindings?: ChannelSecurityAdapter<TResolvedAccount>["collectAuditFindings"];
|
||||
};
|
||||
|
||||
type ChatChannelPairingOptions = {
|
||||
|
|
@ -464,6 +468,9 @@ function resolveChatChannelSecurity<TResolvedAccount extends { accountId?: strin
|
|||
normalizeEntry: security.dm.normalizeEntry,
|
||||
}),
|
||||
...(security.collectWarnings ? { collectWarnings: security.collectWarnings } : {}),
|
||||
...(security.collectAuditFindings
|
||||
? { collectAuditFindings: security.collectAuditFindings }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -559,6 +566,8 @@ export function createChannelPluginBase<TResolvedAccount>(
|
|||
},
|
||||
...(params.setupWizard ? { setupWizard: params.setupWizard } : {}),
|
||||
...(params.capabilities ? { capabilities: params.capabilities } : {}),
|
||||
...(params.commands ? { commands: params.commands } : {}),
|
||||
...(params.doctor ? { doctor: params.doctor } : {}),
|
||||
...(params.agentPrompt ? { agentPrompt: params.agentPrompt } : {}),
|
||||
...(params.streaming ? { streaming: params.streaming } : {}),
|
||||
...(params.reload ? { reload: params.reload } : {}),
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export * from "../infra/net/proxy-fetch.js";
|
|||
export * from "../infra/net/undici-global-dispatcher.js";
|
||||
export * from "../infra/net/ssrf.js";
|
||||
export * from "../infra/outbound/identity.js";
|
||||
export * from "../infra/outbound/sanitize-text.js";
|
||||
export * from "../infra/parse-finite-number.js";
|
||||
export * from "../infra/outbound/send-deps.js";
|
||||
export * from "../infra/retry.js";
|
||||
|
|
|
|||
|
|
@ -3,5 +3,17 @@
|
|||
|
||||
export { ensureConfiguredAcpBindingReady } from "../acp/persistent-bindings.lifecycle.js";
|
||||
export { resolveConfiguredAcpBindingRecord } from "../acp/persistent-bindings.resolve.js";
|
||||
export { maybeCreateMatrixMigrationSnapshot } from "../infra/matrix-migration-snapshot.js";
|
||||
export {
|
||||
autoPrepareLegacyMatrixCrypto,
|
||||
detectLegacyMatrixCrypto,
|
||||
} from "../infra/matrix-legacy-crypto.js";
|
||||
export {
|
||||
autoMigrateLegacyMatrixState,
|
||||
detectLegacyMatrixState,
|
||||
} from "../infra/matrix-legacy-state.js";
|
||||
export {
|
||||
hasActionableMatrixMigration,
|
||||
hasPendingMatrixMigration,
|
||||
maybeCreateMatrixMigrationSnapshot,
|
||||
} from "../infra/matrix-migration-snapshot.js";
|
||||
export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js";
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export { createRuntimeOutboundDelegates } from "../channels/plugins/runtime-forwarders.js";
|
||||
export { resolveOutboundSendDep, type OutboundSendDeps } from "../infra/outbound/send-deps.js";
|
||||
export { resolveAgentOutboundIdentity, type OutboundIdentity } from "../infra/outbound/identity.js";
|
||||
export { sanitizeForPlainText } from "../infra/outbound/sanitize-text.js";
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import type {
|
|||
OpenClawPluginServiceContext,
|
||||
OpenClawPluginToolContext,
|
||||
OpenClawPluginToolFactory,
|
||||
PluginInteractiveTelegramHandlerContext,
|
||||
PluginLogger,
|
||||
ProviderAugmentModelCatalogContext,
|
||||
ProviderAuthContext,
|
||||
|
|
@ -117,7 +116,6 @@ export type {
|
|||
OpenClawPluginCommandDefinition,
|
||||
OpenClawPluginDefinition,
|
||||
PluginLogger,
|
||||
PluginInteractiveTelegramHandlerContext,
|
||||
};
|
||||
export type { OpenClawConfig };
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export * from "../plugins/commands.js";
|
|||
export * from "../plugins/hook-runner-global.js";
|
||||
export * from "../plugins/http-path.js";
|
||||
export * from "../plugins/http-registry.js";
|
||||
export * from "../plugins/interactive-binding-helpers.js";
|
||||
export * from "../plugins/interactive.js";
|
||||
export * from "../plugins/lazy-service-module.js";
|
||||
export * from "../plugins/types.js";
|
||||
|
|
|
|||
|
|
@ -52,8 +52,5 @@ export type {
|
|||
export { createReplyReferencePlanner } from "../auto-reply/reply/reply-reference.js";
|
||||
export type { GetReplyOptions, ReplyPayload } from "../auto-reply/types.js";
|
||||
export type { FinalizedMsgContext, MsgContext } from "../auto-reply/templating.js";
|
||||
export {
|
||||
resolveAutoTopicLabelConfig,
|
||||
generateTopicLabel,
|
||||
} from "../auto-reply/reply/auto-topic-label.js";
|
||||
export type { AutoTopicLabelParams } from "../auto-reply/reply/auto-topic-label.js";
|
||||
export { generateConversationLabel } from "../auto-reply/reply/conversation-label-generator.js";
|
||||
export type { ConversationLabelParams } from "../auto-reply/reply/conversation-label-generator.js";
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { format } from "node:util";
|
|||
import type { OutputRuntimeEnv, RuntimeEnv } from "../runtime.js";
|
||||
export type { OutputRuntimeEnv, RuntimeEnv } from "../runtime.js";
|
||||
export { createNonExitingRuntime, defaultRuntime } from "../runtime.js";
|
||||
export { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js";
|
||||
export { getChannelsCommandSecretTargetIds } from "../cli/command-secret-targets.js";
|
||||
export {
|
||||
danger,
|
||||
info,
|
||||
|
|
@ -17,7 +19,18 @@ export {
|
|||
} from "../globals.js";
|
||||
export * from "../logging.js";
|
||||
export { waitForAbortSignal } from "../infra/abort-signal.js";
|
||||
export {
|
||||
detectPluginInstallPathIssue,
|
||||
formatPluginInstallPathIssue,
|
||||
} from "../infra/plugin-install-path-warnings.js";
|
||||
export { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js";
|
||||
export { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
|
||||
export { removePluginFromConfig } from "../plugins/uninstall.js";
|
||||
export {
|
||||
isDiscordMutableAllowEntry,
|
||||
isSlackMutableAllowEntry,
|
||||
isZalouserMutableGroupEntry,
|
||||
} from "../security/mutable-allowlist-detectors.js";
|
||||
|
||||
/** Minimal logger contract accepted by runtime-adapter helpers. */
|
||||
type LoggerLike = {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
|
||||
import { listBundledPluginManifestSnapshots } from "./bundled-manifest-snapshots.js";
|
||||
import { listBundledPluginMetadata } from "./bundled-plugin-metadata.js";
|
||||
|
||||
export type BundledPluginContractSnapshot = {
|
||||
pluginId: string;
|
||||
|
|
@ -15,27 +12,6 @@ export type BundledPluginContractSnapshot = {
|
|||
toolNames: string[];
|
||||
};
|
||||
|
||||
function resolveBundledManifestSnapshotDir(): string | undefined {
|
||||
const packageRoot = resolveOpenClawPackageRootSync({ moduleUrl: import.meta.url });
|
||||
if (!packageRoot) {
|
||||
return undefined;
|
||||
}
|
||||
for (const candidate of [
|
||||
path.join(packageRoot, "extensions"),
|
||||
path.join(packageRoot, "dist", "extensions"),
|
||||
path.join(packageRoot, "dist-runtime", "extensions"),
|
||||
]) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const BUNDLED_PLUGIN_MANIFEST_SNAPSHOTS = listBundledPluginManifestSnapshots({
|
||||
bundledDir: resolveBundledManifestSnapshotDir(),
|
||||
});
|
||||
|
||||
function uniqueStrings(values: readonly string[] | undefined): string[] {
|
||||
const result: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
|
@ -50,8 +26,13 @@ function uniqueStrings(values: readonly string[] | undefined): string[] {
|
|||
return result;
|
||||
}
|
||||
|
||||
const BUNDLED_PLUGIN_METADATA_FOR_CAPABILITIES = listBundledPluginMetadata({
|
||||
includeChannelConfigs: false,
|
||||
includeSyntheticChannelConfigs: false,
|
||||
});
|
||||
|
||||
export const BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS: readonly BundledPluginContractSnapshot[] =
|
||||
BUNDLED_PLUGIN_MANIFEST_SNAPSHOTS.map(({ manifest }) => ({
|
||||
BUNDLED_PLUGIN_METADATA_FOR_CAPABILITIES.map(({ manifest }) => ({
|
||||
pluginId: manifest.id,
|
||||
cliBackendIds: uniqueStrings(manifest.cliBackends),
|
||||
providerIds: uniqueStrings(manifest.providers),
|
||||
|
|
@ -130,7 +111,7 @@ export const BUNDLED_PROVIDER_PLUGIN_ID_ALIASES = Object.fromEntries(
|
|||
) as Readonly<Record<string, string>>;
|
||||
|
||||
export const BUNDLED_LEGACY_PLUGIN_ID_ALIASES = Object.fromEntries(
|
||||
BUNDLED_PLUGIN_MANIFEST_SNAPSHOTS.flatMap(({ manifest }) =>
|
||||
BUNDLED_PLUGIN_METADATA_FOR_CAPABILITIES.flatMap(({ manifest }) =>
|
||||
(manifest.legacyPluginIds ?? []).map(
|
||||
(legacyPluginId) => [legacyPluginId, manifest.id] as const,
|
||||
),
|
||||
|
|
@ -138,7 +119,7 @@ export const BUNDLED_LEGACY_PLUGIN_ID_ALIASES = Object.fromEntries(
|
|||
) as Readonly<Record<string, string>>;
|
||||
|
||||
export const BUNDLED_AUTO_ENABLE_PROVIDER_PLUGIN_IDS = Object.fromEntries(
|
||||
BUNDLED_PLUGIN_MANIFEST_SNAPSHOTS.flatMap(({ manifest }) =>
|
||||
BUNDLED_PLUGIN_METADATA_FOR_CAPABILITIES.flatMap(({ manifest }) =>
|
||||
(manifest.autoEnableWhenConfiguredProviders ?? []).map((providerId) => [
|
||||
providerId,
|
||||
manifest.id,
|
||||
|
|
|
|||
|
|
@ -432,8 +432,6 @@ describe("plugin-sdk subpath exports", () => {
|
|||
"resolveThreadBindingThreadName",
|
||||
"resolveThreadBindingsEnabled",
|
||||
"formatThreadBindingDisabledError",
|
||||
"DISCORD_THREAD_BINDING_CHANNEL",
|
||||
"MATRIX_THREAD_BINDING_CHANNEL",
|
||||
"resolveControlCommandGate",
|
||||
"resolveCommandAuthorizedFromAuthorizers",
|
||||
"resolveDualTextControlCommandGate",
|
||||
|
|
@ -621,8 +619,6 @@ describe("plugin-sdk subpath exports", () => {
|
|||
]);
|
||||
|
||||
expectSourceMentions("conversation-runtime", [
|
||||
"DISCORD_THREAD_BINDING_CHANNEL",
|
||||
"MATRIX_THREAD_BINDING_CHANNEL",
|
||||
"formatThreadBindingDisabledError",
|
||||
"resolveThreadBindingFarewellText",
|
||||
"resolveThreadBindingConversationIdFromBindingId",
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createDiscordTypingLease,
|
||||
type CreateDiscordTypingLeaseParams,
|
||||
} from "./runtime-discord-typing.js";
|
||||
|
||||
describe("createDiscordTypingLease", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("uses the Discord default interval and forwards pulse params", async () => {
|
||||
vi.useFakeTimers();
|
||||
const pulse: CreateDiscordTypingLeaseParams["pulse"] = vi.fn(async () => undefined);
|
||||
const cfg = { channels: { discord: { token: "x" } } };
|
||||
|
||||
const lease = await createDiscordTypingLease({
|
||||
channelId: "123",
|
||||
accountId: "work",
|
||||
cfg,
|
||||
intervalMs: Number.NaN,
|
||||
pulse,
|
||||
});
|
||||
|
||||
expect(pulse).toHaveBeenCalledTimes(1);
|
||||
expect(pulse).toHaveBeenCalledWith({
|
||||
channelId: "123",
|
||||
accountId: "work",
|
||||
cfg,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(7_999);
|
||||
expect(pulse).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(pulse).toHaveBeenCalledTimes(2);
|
||||
|
||||
lease.stop();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import { createTypingLease } from "./typing-lease.js";
|
||||
|
||||
export type CreateDiscordTypingLeaseParams = {
|
||||
channelId: string;
|
||||
accountId?: string;
|
||||
cfg?: ReturnType<typeof import("../../config/config.js").loadConfig>;
|
||||
intervalMs?: number;
|
||||
pulse: (params: {
|
||||
channelId: string;
|
||||
accountId?: string;
|
||||
cfg?: ReturnType<typeof import("../../config/config.js").loadConfig>;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
|
||||
const DEFAULT_DISCORD_TYPING_INTERVAL_MS = 8_000;
|
||||
|
||||
export async function createDiscordTypingLease(params: CreateDiscordTypingLeaseParams): Promise<{
|
||||
refresh: () => Promise<void>;
|
||||
stop: () => void;
|
||||
}> {
|
||||
return await createTypingLease({
|
||||
defaultIntervalMs: DEFAULT_DISCORD_TYPING_INTERVAL_MS,
|
||||
errorLabel: "discord",
|
||||
intervalMs: params.intervalMs,
|
||||
pulse: params.pulse,
|
||||
pulseArgs: {
|
||||
channelId: params.channelId,
|
||||
accountId: params.accountId,
|
||||
cfg: params.cfg,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -53,6 +53,46 @@ export function resolvePluginRuntimeRecord(
|
|||
};
|
||||
}
|
||||
|
||||
export function resolvePluginRuntimeRecordByEntryBaseNames(
|
||||
entryBaseNames: string[],
|
||||
onMissing?: () => never,
|
||||
): PluginRuntimeRecord | null {
|
||||
const manifestRegistry = loadPluginManifestRegistry({
|
||||
config: readPluginBoundaryConfigSafely(),
|
||||
cache: true,
|
||||
});
|
||||
const matches = manifestRegistry.plugins.filter((plugin) => {
|
||||
if (!plugin?.source) {
|
||||
return false;
|
||||
}
|
||||
const record = {
|
||||
rootDir: plugin.rootDir,
|
||||
source: plugin.source,
|
||||
};
|
||||
return entryBaseNames.every(
|
||||
(entryBaseName) => resolvePluginRuntimeModulePath(record, entryBaseName) !== null,
|
||||
);
|
||||
});
|
||||
if (matches.length === 0) {
|
||||
if (onMissing) {
|
||||
onMissing();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
const pluginIds = matches.map((plugin) => plugin.id).join(", ");
|
||||
throw new Error(
|
||||
`plugin runtime boundary is ambiguous for entries [${entryBaseNames.join(", ")}]: ${pluginIds}`,
|
||||
);
|
||||
}
|
||||
const record = matches[0];
|
||||
return {
|
||||
...(record.origin ? { origin: record.origin } : {}),
|
||||
rootDir: record.rootDir,
|
||||
source: record.source,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePluginRuntimeModulePath(
|
||||
record: Pick<PluginRuntimeRecord, "rootDir" | "source">,
|
||||
entryBaseName: string,
|
||||
|
|
|
|||
|
|
@ -1,26 +1,108 @@
|
|||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { createJiti } from "jiti";
|
||||
type WebChannelHeavyRuntimeModule = typeof import("@openclaw/whatsapp/runtime-api.js");
|
||||
type WebChannelLightRuntimeModule = typeof import("@openclaw/whatsapp/light-runtime-api.js");
|
||||
import type { ChannelAgentTool } from "../../channels/plugins/types.core.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import {
|
||||
getDefaultLocalRoots as getDefaultLocalRootsImpl,
|
||||
loadWebMedia as loadWebMediaImpl,
|
||||
loadWebMediaRaw as loadWebMediaRawImpl,
|
||||
optimizeImageToJpeg as optimizeImageToJpegImpl,
|
||||
} from "../../media/web-media.js";
|
||||
import type { PollInput } from "../../polls.js";
|
||||
import {
|
||||
loadPluginBoundaryModuleWithJiti,
|
||||
resolvePluginRuntimeRecordByEntryBaseNames,
|
||||
resolvePluginRuntimeModulePath,
|
||||
resolvePluginRuntimeRecord,
|
||||
} from "./runtime-plugin-boundary.js";
|
||||
|
||||
const WEB_CHANNEL_PLUGIN_ID = "whatsapp";
|
||||
|
||||
type WebChannelPluginRecord = {
|
||||
origin: string;
|
||||
origin?: string;
|
||||
rootDir?: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
type WebChannelLightRuntimeModule = {
|
||||
getActiveWebListener: (accountId?: string | null) => unknown;
|
||||
getWebAuthAgeMs: (authDir?: string) => number | null;
|
||||
logWebSelfId: (authDir?: string, runtime?: unknown, includeChannelPrefix?: boolean) => void;
|
||||
logoutWeb: (params: {
|
||||
authDir?: string;
|
||||
isLegacyAuthDir?: boolean;
|
||||
runtime?: unknown;
|
||||
}) => Promise<boolean>;
|
||||
readWebSelfId: (authDir?: string) => {
|
||||
e164: string | null;
|
||||
jid: string | null;
|
||||
lid: string | null;
|
||||
};
|
||||
webAuthExists: (authDir?: string) => Promise<boolean>;
|
||||
createWhatsAppLoginTool: () => ChannelAgentTool;
|
||||
formatError: (error: unknown) => string;
|
||||
getStatusCode: (error: unknown) => number | undefined;
|
||||
pickWebChannel: (pref: string, authDir?: string) => Promise<string>;
|
||||
WA_WEB_AUTH_DIR: string;
|
||||
};
|
||||
|
||||
type WebChannelHeavyRuntimeModule = {
|
||||
loginWeb: (
|
||||
verbose: boolean,
|
||||
waitForConnection?: (sock: unknown) => Promise<void>,
|
||||
runtime?: unknown,
|
||||
accountId?: string,
|
||||
) => Promise<void>;
|
||||
sendMessageWhatsApp: (
|
||||
to: string,
|
||||
body: string,
|
||||
options: {
|
||||
verbose: boolean;
|
||||
cfg?: OpenClawConfig;
|
||||
mediaUrl?: string;
|
||||
mediaAccess?: {
|
||||
localRoots?: readonly string[];
|
||||
readFile?: (filePath: string) => Promise<Buffer>;
|
||||
};
|
||||
mediaLocalRoots?: readonly string[];
|
||||
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
||||
gifPlayback?: boolean;
|
||||
accountId?: string;
|
||||
},
|
||||
) => Promise<{ messageId: string; toJid: string }>;
|
||||
sendPollWhatsApp: (
|
||||
to: string,
|
||||
poll: PollInput,
|
||||
options: { verbose: boolean; accountId?: string; cfg?: OpenClawConfig },
|
||||
) => Promise<{ messageId: string; toJid: string }>;
|
||||
sendReactionWhatsApp: (
|
||||
chatJid: string,
|
||||
messageId: string,
|
||||
emoji: string,
|
||||
options: {
|
||||
verbose: boolean;
|
||||
fromMe?: boolean;
|
||||
participant?: string;
|
||||
accountId?: string;
|
||||
},
|
||||
) => Promise<void>;
|
||||
createWaSocket: (
|
||||
printQr: boolean,
|
||||
verbose: boolean,
|
||||
opts?: { authDir?: string; onQr?: (qr: string) => void },
|
||||
) => Promise<unknown>;
|
||||
handleWhatsAppAction: (
|
||||
params: Record<string, unknown>,
|
||||
cfg: OpenClawConfig,
|
||||
) => Promise<AgentToolResult<unknown>>;
|
||||
monitorWebChannel: (...args: unknown[]) => Promise<unknown>;
|
||||
monitorWebInbox: (...args: unknown[]) => Promise<unknown>;
|
||||
runWebHeartbeatOnce: (...args: unknown[]) => Promise<unknown>;
|
||||
startWebLoginWithQr: (...args: unknown[]) => Promise<unknown>;
|
||||
waitForWaConnection: (sock: unknown) => Promise<void>;
|
||||
waitForWebLogin: (...args: unknown[]) => Promise<unknown>;
|
||||
extractMediaPlaceholder: (...args: unknown[]) => unknown;
|
||||
extractText: (...args: unknown[]) => unknown;
|
||||
resolveHeartbeatRecipients: (...args: unknown[]) => unknown;
|
||||
};
|
||||
|
||||
let cachedHeavyModulePath: string | null = null;
|
||||
let cachedHeavyModule: WebChannelHeavyRuntimeModule | null = null;
|
||||
let cachedLightModulePath: string | null = null;
|
||||
|
|
@ -29,9 +111,9 @@ let cachedLightModule: WebChannelLightRuntimeModule | null = null;
|
|||
const jitiLoaders = new Map<boolean, ReturnType<typeof createJiti>>();
|
||||
|
||||
function resolveWebChannelPluginRecord(): WebChannelPluginRecord {
|
||||
return resolvePluginRuntimeRecord(WEB_CHANNEL_PLUGIN_ID, () => {
|
||||
return resolvePluginRuntimeRecordByEntryBaseNames(["light-runtime-api", "runtime-api"], () => {
|
||||
throw new Error(
|
||||
`web channel plugin runtime is unavailable: missing plugin '${WEB_CHANNEL_PLUGIN_ID}'`,
|
||||
"web channel plugin runtime is unavailable: missing plugin that provides light-runtime-api and runtime-api",
|
||||
);
|
||||
}) as WebChannelPluginRecord;
|
||||
}
|
||||
|
|
@ -41,14 +123,10 @@ function resolveWebChannelRuntimeModulePath(
|
|||
entryBaseName: "light-runtime-api" | "runtime-api",
|
||||
): string {
|
||||
const modulePath = resolvePluginRuntimeModulePath(record, entryBaseName, () => {
|
||||
throw new Error(
|
||||
`web channel plugin runtime is unavailable: missing ${entryBaseName} for plugin '${WEB_CHANNEL_PLUGIN_ID}'`,
|
||||
);
|
||||
throw new Error(`web channel plugin runtime is unavailable: missing ${entryBaseName}`);
|
||||
});
|
||||
if (!modulePath) {
|
||||
throw new Error(
|
||||
`web channel plugin runtime is unavailable: missing ${entryBaseName} for plugin '${WEB_CHANNEL_PLUGIN_ID}'`,
|
||||
);
|
||||
throw new Error(`web channel plugin runtime is unavailable: missing ${entryBaseName}`);
|
||||
}
|
||||
return modulePath;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,97 +7,12 @@ import type { listChannelPlugins } from "../channels/plugins/index.js";
|
|||
import type { ChannelId } from "../channels/plugins/types.js";
|
||||
import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../config/commands.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matching.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js";
|
||||
import { normalizeStringEntries } from "../shared/string-normalization.js";
|
||||
import type { SecurityAuditFinding, SecurityAuditSeverity } from "./audit.js";
|
||||
import { resolveDmAllowState } from "./dm-policy-shared.js";
|
||||
|
||||
const loadAuditChannelDiscordRuntimeModule = createLazyRuntimeSurface(
|
||||
() => import("./audit-channel.discord.runtime.js"),
|
||||
({ auditChannelDiscordRuntime }) => auditChannelDiscordRuntime,
|
||||
);
|
||||
|
||||
const loadAuditChannelAllowFromRuntimeModule = createLazyRuntimeSurface(
|
||||
() => import("./audit-channel.allow-from.runtime.js"),
|
||||
({ auditChannelAllowFromRuntime }) => auditChannelAllowFromRuntime,
|
||||
);
|
||||
|
||||
const loadAuditChannelTelegramRuntimeModule = createLazyRuntimeSurface(
|
||||
() => import("./audit-channel.telegram.runtime.js"),
|
||||
({ auditChannelTelegramRuntime }) => auditChannelTelegramRuntime,
|
||||
);
|
||||
|
||||
const loadAuditChannelZalouserRuntimeModule = createLazyRuntimeSurface(
|
||||
() => import("./audit-channel.zalouser.runtime.js"),
|
||||
({ auditChannelZalouserRuntime }) => auditChannelZalouserRuntime,
|
||||
);
|
||||
|
||||
function normalizeAllowFromList(list: Array<string | number> | undefined | null): string[] {
|
||||
return normalizeStringEntries(Array.isArray(list) ? list : undefined);
|
||||
}
|
||||
|
||||
function addDiscordNameBasedEntries(params: {
|
||||
target: Set<string>;
|
||||
values: unknown;
|
||||
source: string;
|
||||
isDiscordMutableAllowEntry: (value: string) => boolean;
|
||||
}): void {
|
||||
if (!Array.isArray(params.values)) {
|
||||
return;
|
||||
}
|
||||
for (const value of params.values) {
|
||||
if (!params.isDiscordMutableAllowEntry(String(value))) {
|
||||
continue;
|
||||
}
|
||||
const text = String(value).trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
params.target.add(`${params.source}:${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
function addZalouserMutableGroupEntries(params: {
|
||||
target: Set<string>;
|
||||
groups: unknown;
|
||||
source: string;
|
||||
isZalouserMutableGroupEntry: (value: string) => boolean;
|
||||
}): void {
|
||||
if (!params.groups || typeof params.groups !== "object" || Array.isArray(params.groups)) {
|
||||
return;
|
||||
}
|
||||
for (const key of Object.keys(params.groups as Record<string, unknown>)) {
|
||||
if (!params.isZalouserMutableGroupEntry(key)) {
|
||||
continue;
|
||||
}
|
||||
params.target.add(`${params.source}:${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function collectInvalidTelegramAllowFromEntries(params: {
|
||||
entries: unknown;
|
||||
target: Set<string>;
|
||||
}): Promise<void> {
|
||||
if (!Array.isArray(params.entries)) {
|
||||
return;
|
||||
}
|
||||
const { isNumericTelegramUserId, normalizeTelegramAllowFromEntry } =
|
||||
await loadAuditChannelTelegramRuntimeModule();
|
||||
for (const entry of params.entries) {
|
||||
const normalized = normalizeTelegramAllowFromEntry(entry);
|
||||
if (!normalized || normalized === "*") {
|
||||
continue;
|
||||
}
|
||||
if (!isNumericTelegramUserId(normalized)) {
|
||||
params.target.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function classifyChannelWarningSeverity(message: string): SecurityAuditSeverity {
|
||||
const s = message.toLowerCase();
|
||||
if (
|
||||
|
|
@ -277,19 +192,6 @@ export async function collectChannelSecurityFindings(params: {
|
|||
return { account, enabled, configured, diagnostics };
|
||||
};
|
||||
|
||||
const coerceNativeSetting = (value: unknown): boolean | "auto" | undefined => {
|
||||
if (value === true) {
|
||||
return true;
|
||||
}
|
||||
if (value === false) {
|
||||
return false;
|
||||
}
|
||||
if (value === "auto") {
|
||||
return "auto";
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const warnDmPolicy = async (input: {
|
||||
label: string;
|
||||
provider: ChannelId;
|
||||
|
|
@ -411,318 +313,6 @@ export async function collectChannelSecurityFindings(params: {
|
|||
});
|
||||
}
|
||||
|
||||
if (
|
||||
plugin.id === "synology-chat" &&
|
||||
(account as { dangerouslyAllowNameMatching?: unknown } | null)
|
||||
?.dangerouslyAllowNameMatching === true
|
||||
) {
|
||||
const accountNote = formatChannelAccountNote({
|
||||
orderedAccountIds,
|
||||
hasExplicitAccountPath,
|
||||
accountId,
|
||||
});
|
||||
findings.push({
|
||||
checkId: "channels.synology-chat.reply.dangerous_name_matching_enabled",
|
||||
severity: "info",
|
||||
title: `Synology Chat dangerous name matching is enabled${accountNote}`,
|
||||
detail:
|
||||
"dangerouslyAllowNameMatching=true re-enables mutable username/nickname matching for reply delivery. This is a break-glass compatibility mode, not a hardened default.",
|
||||
remediation:
|
||||
"Prefer stable numeric Synology Chat user IDs for reply delivery, then disable dangerouslyAllowNameMatching.",
|
||||
});
|
||||
}
|
||||
|
||||
if (plugin.id === "discord") {
|
||||
const { isDiscordMutableAllowEntry } = await loadAuditChannelDiscordRuntimeModule();
|
||||
const { readChannelAllowFromStore } = await loadAuditChannelAllowFromRuntimeModule();
|
||||
const discordCfg =
|
||||
(account as { config?: Record<string, unknown> } | null)?.config ??
|
||||
({} as Record<string, unknown>);
|
||||
const dangerousNameMatchingEnabled = isDangerousNameMatchingEnabled(discordCfg);
|
||||
const storeAllowFrom = await readChannelAllowFromStore(
|
||||
"discord",
|
||||
process.env,
|
||||
accountId,
|
||||
).catch(() => []);
|
||||
const discordNameBasedAllowEntries = new Set<string>();
|
||||
const discordPathPrefix =
|
||||
orderedAccountIds.length > 1 || hasExplicitAccountPath
|
||||
? `channels.discord.accounts.${accountId}`
|
||||
: "channels.discord";
|
||||
addDiscordNameBasedEntries({
|
||||
target: discordNameBasedAllowEntries,
|
||||
values: discordCfg.allowFrom,
|
||||
source: `${discordPathPrefix}.allowFrom`,
|
||||
isDiscordMutableAllowEntry,
|
||||
});
|
||||
addDiscordNameBasedEntries({
|
||||
target: discordNameBasedAllowEntries,
|
||||
values: (discordCfg.dm as { allowFrom?: unknown } | undefined)?.allowFrom,
|
||||
source: `${discordPathPrefix}.dm.allowFrom`,
|
||||
isDiscordMutableAllowEntry,
|
||||
});
|
||||
addDiscordNameBasedEntries({
|
||||
target: discordNameBasedAllowEntries,
|
||||
values: storeAllowFrom,
|
||||
source: "~/.openclaw/credentials/discord-allowFrom.json",
|
||||
isDiscordMutableAllowEntry,
|
||||
});
|
||||
const discordGuildEntries =
|
||||
(discordCfg.guilds as Record<string, unknown> | undefined) ?? {};
|
||||
for (const [guildKey, guildValue] of Object.entries(discordGuildEntries)) {
|
||||
if (!guildValue || typeof guildValue !== "object") {
|
||||
continue;
|
||||
}
|
||||
const guild = guildValue as Record<string, unknown>;
|
||||
addDiscordNameBasedEntries({
|
||||
target: discordNameBasedAllowEntries,
|
||||
values: guild.users,
|
||||
source: `${discordPathPrefix}.guilds.${guildKey}.users`,
|
||||
isDiscordMutableAllowEntry,
|
||||
});
|
||||
const channels = guild.channels;
|
||||
if (!channels || typeof channels !== "object") {
|
||||
continue;
|
||||
}
|
||||
for (const [channelKey, channelValue] of Object.entries(
|
||||
channels as Record<string, unknown>,
|
||||
)) {
|
||||
if (!channelValue || typeof channelValue !== "object") {
|
||||
continue;
|
||||
}
|
||||
const channel = channelValue as Record<string, unknown>;
|
||||
addDiscordNameBasedEntries({
|
||||
target: discordNameBasedAllowEntries,
|
||||
values: channel.users,
|
||||
source: `${discordPathPrefix}.guilds.${guildKey}.channels.${channelKey}.users`,
|
||||
isDiscordMutableAllowEntry,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (discordNameBasedAllowEntries.size > 0) {
|
||||
const examples = Array.from(discordNameBasedAllowEntries).slice(0, 5);
|
||||
const more =
|
||||
discordNameBasedAllowEntries.size > examples.length
|
||||
? ` (+${discordNameBasedAllowEntries.size - examples.length} more)`
|
||||
: "";
|
||||
findings.push({
|
||||
checkId: "channels.discord.allowFrom.name_based_entries",
|
||||
severity: dangerousNameMatchingEnabled ? "info" : "warn",
|
||||
title: dangerousNameMatchingEnabled
|
||||
? "Discord allowlist uses break-glass name/tag matching"
|
||||
: "Discord allowlist contains name or tag entries",
|
||||
detail: dangerousNameMatchingEnabled
|
||||
? "Discord name/tag allowlist matching is explicitly enabled via dangerouslyAllowNameMatching. This mutable-identity mode is operator-selected break-glass behavior and out-of-scope for vulnerability reports by itself. " +
|
||||
`Found: ${examples.join(", ")}${more}.`
|
||||
: "Discord name/tag allowlist matching uses normalized slugs and can collide across users. " +
|
||||
`Found: ${examples.join(", ")}${more}.`,
|
||||
remediation: dangerousNameMatchingEnabled
|
||||
? "Prefer stable Discord IDs (or <@id>/user:<id>/pk:<id>), then disable dangerouslyAllowNameMatching."
|
||||
: "Prefer stable Discord IDs (or <@id>/user:<id>/pk:<id>) in channels.discord.allowFrom and channels.discord.guilds.*.users, or explicitly opt in with dangerouslyAllowNameMatching=true if you accept the risk.",
|
||||
});
|
||||
}
|
||||
const nativeEnabled = resolveNativeCommandsEnabled({
|
||||
providerId: "discord",
|
||||
providerSetting: coerceNativeSetting(
|
||||
(discordCfg.commands as { native?: unknown } | undefined)?.native,
|
||||
),
|
||||
globalSetting: params.cfg.commands?.native,
|
||||
});
|
||||
const nativeSkillsEnabled = resolveNativeSkillsEnabled({
|
||||
providerId: "discord",
|
||||
providerSetting: coerceNativeSetting(
|
||||
(discordCfg.commands as { nativeSkills?: unknown } | undefined)?.nativeSkills,
|
||||
),
|
||||
globalSetting: params.cfg.commands?.nativeSkills,
|
||||
});
|
||||
const slashEnabled = nativeEnabled || nativeSkillsEnabled;
|
||||
if (slashEnabled) {
|
||||
const defaultGroupPolicy = params.cfg.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy =
|
||||
(discordCfg.groupPolicy as string | undefined) ?? defaultGroupPolicy ?? "allowlist";
|
||||
const guildEntries = discordGuildEntries;
|
||||
const guildsConfigured = Object.keys(guildEntries).length > 0;
|
||||
const hasAnyUserAllowlist = Object.values(guildEntries).some((guild) => {
|
||||
if (!guild || typeof guild !== "object") {
|
||||
return false;
|
||||
}
|
||||
const g = guild as Record<string, unknown>;
|
||||
if (Array.isArray(g.users) && g.users.length > 0) {
|
||||
return true;
|
||||
}
|
||||
const channels = g.channels;
|
||||
if (!channels || typeof channels !== "object") {
|
||||
return false;
|
||||
}
|
||||
return Object.values(channels as Record<string, unknown>).some((channel) => {
|
||||
if (!channel || typeof channel !== "object") {
|
||||
return false;
|
||||
}
|
||||
const c = channel as Record<string, unknown>;
|
||||
return Array.isArray(c.users) && c.users.length > 0;
|
||||
});
|
||||
});
|
||||
const dmAllowFromRaw = (discordCfg.dm as { allowFrom?: unknown } | undefined)?.allowFrom;
|
||||
const dmAllowFrom = Array.isArray(dmAllowFromRaw) ? dmAllowFromRaw : [];
|
||||
const ownerAllowFromConfigured =
|
||||
normalizeAllowFromList([...dmAllowFrom, ...storeAllowFrom]).length > 0;
|
||||
|
||||
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
|
||||
if (
|
||||
!useAccessGroups &&
|
||||
groupPolicy !== "disabled" &&
|
||||
guildsConfigured &&
|
||||
!hasAnyUserAllowlist
|
||||
) {
|
||||
findings.push({
|
||||
checkId: "channels.discord.commands.native.unrestricted",
|
||||
severity: "critical",
|
||||
title: "Discord slash commands are unrestricted",
|
||||
detail:
|
||||
"commands.useAccessGroups=false disables sender allowlists for Discord slash commands unless a per-guild/channel users allowlist is configured; with no users allowlist, any user in allowed guild channels can invoke /… commands.",
|
||||
remediation:
|
||||
"Set commands.useAccessGroups=true (recommended), or configure channels.discord.guilds.<id>.users (or channels.discord.guilds.<id>.channels.<channel>.users).",
|
||||
});
|
||||
} else if (
|
||||
useAccessGroups &&
|
||||
groupPolicy !== "disabled" &&
|
||||
guildsConfigured &&
|
||||
!ownerAllowFromConfigured &&
|
||||
!hasAnyUserAllowlist
|
||||
) {
|
||||
findings.push({
|
||||
checkId: "channels.discord.commands.native.no_allowlists",
|
||||
severity: "warn",
|
||||
title: "Discord slash commands have no allowlists",
|
||||
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.",
|
||||
remediation:
|
||||
"Add your user id to channels.discord.allowFrom (or approve yourself via pairing), or configure channels.discord.guilds.<id>.users.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (plugin.id === "zalouser") {
|
||||
const { isZalouserMutableGroupEntry } = await loadAuditChannelZalouserRuntimeModule();
|
||||
const zalouserCfg =
|
||||
(account as { config?: Record<string, unknown> } | null)?.config ??
|
||||
({} as Record<string, unknown>);
|
||||
const dangerousNameMatchingEnabled = isDangerousNameMatchingEnabled(zalouserCfg);
|
||||
const zalouserPathPrefix =
|
||||
orderedAccountIds.length > 1 || hasExplicitAccountPath
|
||||
? `channels.zalouser.accounts.${accountId}`
|
||||
: "channels.zalouser";
|
||||
const mutableGroupEntries = new Set<string>();
|
||||
addZalouserMutableGroupEntries({
|
||||
target: mutableGroupEntries,
|
||||
groups: zalouserCfg.groups,
|
||||
source: `${zalouserPathPrefix}.groups`,
|
||||
isZalouserMutableGroupEntry,
|
||||
});
|
||||
if (mutableGroupEntries.size > 0) {
|
||||
const examples = Array.from(mutableGroupEntries).slice(0, 5);
|
||||
const more =
|
||||
mutableGroupEntries.size > examples.length
|
||||
? ` (+${mutableGroupEntries.size - examples.length} more)`
|
||||
: "";
|
||||
findings.push({
|
||||
checkId: "channels.zalouser.groups.mutable_entries",
|
||||
severity: dangerousNameMatchingEnabled ? "info" : "warn",
|
||||
title: dangerousNameMatchingEnabled
|
||||
? "Zalouser group routing uses break-glass name matching"
|
||||
: "Zalouser group routing contains mutable group entries",
|
||||
detail: dangerousNameMatchingEnabled
|
||||
? "Zalouser group-name routing is explicitly enabled via dangerouslyAllowNameMatching. This mutable-identity mode is operator-selected break-glass behavior and out-of-scope for vulnerability reports by itself. " +
|
||||
`Found: ${examples.join(", ")}${more}.`
|
||||
: "Zalouser group auth is ID-only by default, so unresolved group-name or slug entries are ignored for auth and can drift from the intended trusted group. " +
|
||||
`Found: ${examples.join(", ")}${more}.`,
|
||||
remediation: dangerousNameMatchingEnabled
|
||||
? "Prefer stable Zalo group IDs (for example group:<id> or provider-native g- ids), then disable dangerouslyAllowNameMatching."
|
||||
: "Prefer stable Zalo group IDs in channels.zalouser.groups, or explicitly opt in with dangerouslyAllowNameMatching=true if you accept mutable group-name matching.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (plugin.id === "slack") {
|
||||
const { readChannelAllowFromStore } = await loadAuditChannelAllowFromRuntimeModule();
|
||||
const slackCfg =
|
||||
(account as { config?: Record<string, unknown>; dm?: Record<string, unknown> } | null)
|
||||
?.config ?? ({} as Record<string, unknown>);
|
||||
const nativeEnabled = resolveNativeCommandsEnabled({
|
||||
providerId: "slack",
|
||||
providerSetting: coerceNativeSetting(
|
||||
(slackCfg.commands as { native?: unknown } | undefined)?.native,
|
||||
),
|
||||
globalSetting: params.cfg.commands?.native,
|
||||
});
|
||||
const nativeSkillsEnabled = resolveNativeSkillsEnabled({
|
||||
providerId: "slack",
|
||||
providerSetting: coerceNativeSetting(
|
||||
(slackCfg.commands as { nativeSkills?: unknown } | undefined)?.nativeSkills,
|
||||
),
|
||||
globalSetting: params.cfg.commands?.nativeSkills,
|
||||
});
|
||||
const slashCommandEnabled =
|
||||
nativeEnabled ||
|
||||
nativeSkillsEnabled ||
|
||||
(slackCfg.slashCommand as { enabled?: unknown } | undefined)?.enabled === true;
|
||||
if (slashCommandEnabled) {
|
||||
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
|
||||
if (!useAccessGroups) {
|
||||
findings.push({
|
||||
checkId: "channels.slack.commands.slash.useAccessGroups_off",
|
||||
severity: "critical",
|
||||
title: "Slack slash commands bypass access groups",
|
||||
detail:
|
||||
"Slack slash/native commands are enabled while commands.useAccessGroups=false; this can allow unrestricted /… command execution from channels/users you didn't explicitly authorize.",
|
||||
remediation: "Set commands.useAccessGroups=true (recommended).",
|
||||
});
|
||||
} else {
|
||||
const allowFromRaw = (
|
||||
account as
|
||||
| { 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",
|
||||
process.env,
|
||||
accountId,
|
||||
).catch(() => []);
|
||||
const ownerAllowFromConfigured =
|
||||
normalizeAllowFromList([...allowFrom, ...storeAllowFrom]).length > 0;
|
||||
const channels = (slackCfg.channels as Record<string, unknown> | undefined) ?? {};
|
||||
const hasAnyChannelUsersAllowlist = Object.values(channels).some((value) => {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const channel = value as Record<string, unknown>;
|
||||
return Array.isArray(channel.users) && channel.users.length > 0;
|
||||
});
|
||||
if (!ownerAllowFromConfigured && !hasAnyChannelUsersAllowlist) {
|
||||
findings.push({
|
||||
checkId: "channels.slack.commands.slash.no_allowlists",
|
||||
severity: "warn",
|
||||
title: "Slack slash commands have no allowlists",
|
||||
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.",
|
||||
remediation:
|
||||
"Approve yourself via pairing (recommended), or set channels.slack.allowFrom and/or channels.slack.channels.<id>.users.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dmPolicy = plugin.security.resolveDmPolicy?.({
|
||||
cfg: params.cfg,
|
||||
accountId,
|
||||
|
|
@ -760,145 +350,19 @@ export async function collectChannelSecurityFindings(params: {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (plugin.id !== "telegram") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const allowTextCommands = params.cfg.commands?.text !== false;
|
||||
if (!allowTextCommands) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const telegramCfg =
|
||||
(account as { config?: Record<string, unknown> } | null)?.config ??
|
||||
({} as Record<string, unknown>);
|
||||
const defaultGroupPolicy = params.cfg.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy =
|
||||
(telegramCfg.groupPolicy as string | undefined) ?? defaultGroupPolicy ?? "allowlist";
|
||||
const groups = telegramCfg.groups as Record<string, unknown> | undefined;
|
||||
const groupsConfigured = Boolean(groups) && Object.keys(groups ?? {}).length > 0;
|
||||
const groupAccessPossible =
|
||||
groupPolicy === "open" || (groupPolicy === "allowlist" && groupsConfigured);
|
||||
if (!groupAccessPossible) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { readChannelAllowFromStore } = await loadAuditChannelAllowFromRuntimeModule();
|
||||
const storeAllowFrom = await readChannelAllowFromStore(
|
||||
"telegram",
|
||||
process.env,
|
||||
accountId,
|
||||
).catch(() => []);
|
||||
const storeHasWildcard = storeAllowFrom.some((value) => String(value).trim() === "*");
|
||||
const invalidTelegramAllowFromEntries = new Set<string>();
|
||||
await collectInvalidTelegramAllowFromEntries({
|
||||
entries: storeAllowFrom,
|
||||
target: invalidTelegramAllowFromEntries,
|
||||
});
|
||||
const groupAllowFrom = Array.isArray(telegramCfg.groupAllowFrom)
|
||||
? telegramCfg.groupAllowFrom
|
||||
: [];
|
||||
const groupAllowFromHasWildcard = groupAllowFrom.some((v) => String(v).trim() === "*");
|
||||
await collectInvalidTelegramAllowFromEntries({
|
||||
entries: groupAllowFrom,
|
||||
target: invalidTelegramAllowFromEntries,
|
||||
});
|
||||
const dmAllowFrom = Array.isArray(telegramCfg.allowFrom) ? telegramCfg.allowFrom : [];
|
||||
await collectInvalidTelegramAllowFromEntries({
|
||||
entries: dmAllowFrom,
|
||||
target: invalidTelegramAllowFromEntries,
|
||||
});
|
||||
let anyGroupOverride = false;
|
||||
if (groups) {
|
||||
for (const value of Object.values(groups)) {
|
||||
if (!value || typeof value !== "object") {
|
||||
continue;
|
||||
}
|
||||
const group = value as Record<string, unknown>;
|
||||
const allowFrom = Array.isArray(group.allowFrom) ? group.allowFrom : [];
|
||||
if (allowFrom.length > 0) {
|
||||
anyGroupOverride = true;
|
||||
await collectInvalidTelegramAllowFromEntries({
|
||||
entries: allowFrom,
|
||||
target: invalidTelegramAllowFromEntries,
|
||||
});
|
||||
}
|
||||
const topics = group.topics;
|
||||
if (!topics || typeof topics !== "object") {
|
||||
continue;
|
||||
}
|
||||
for (const topicValue of Object.values(topics as Record<string, unknown>)) {
|
||||
if (!topicValue || typeof topicValue !== "object") {
|
||||
continue;
|
||||
}
|
||||
const topic = topicValue as Record<string, unknown>;
|
||||
const topicAllow = Array.isArray(topic.allowFrom) ? topic.allowFrom : [];
|
||||
if (topicAllow.length > 0) {
|
||||
anyGroupOverride = true;
|
||||
}
|
||||
await collectInvalidTelegramAllowFromEntries({
|
||||
entries: topicAllow,
|
||||
target: invalidTelegramAllowFromEntries,
|
||||
});
|
||||
}
|
||||
if (plugin.security.collectAuditFindings) {
|
||||
const auditFindings = await plugin.security.collectAuditFindings({
|
||||
cfg: params.cfg,
|
||||
sourceConfig,
|
||||
accountId,
|
||||
account,
|
||||
orderedAccountIds,
|
||||
hasExplicitAccountPath,
|
||||
});
|
||||
for (const finding of auditFindings ?? []) {
|
||||
findings.push(finding);
|
||||
}
|
||||
}
|
||||
|
||||
const hasAnySenderAllowlist =
|
||||
storeAllowFrom.length > 0 || groupAllowFrom.length > 0 || anyGroupOverride;
|
||||
|
||||
if (invalidTelegramAllowFromEntries.size > 0) {
|
||||
const examples = Array.from(invalidTelegramAllowFromEntries).slice(0, 5);
|
||||
const more =
|
||||
invalidTelegramAllowFromEntries.size > examples.length
|
||||
? ` (+${invalidTelegramAllowFromEntries.size - examples.length} more)`
|
||||
: "";
|
||||
findings.push({
|
||||
checkId: "channels.telegram.allowFrom.invalid_entries",
|
||||
severity: "warn",
|
||||
title: "Telegram allowlist contains non-numeric entries",
|
||||
detail:
|
||||
"Telegram sender authorization requires numeric Telegram user IDs. " +
|
||||
`Found non-numeric allowFrom entries: ${examples.join(", ")}${more}.`,
|
||||
remediation:
|
||||
"Replace @username entries with numeric Telegram user IDs (use setup to resolve), then re-run the audit.",
|
||||
});
|
||||
}
|
||||
|
||||
if (storeHasWildcard || groupAllowFromHasWildcard) {
|
||||
findings.push({
|
||||
checkId: "channels.telegram.groups.allowFrom.wildcard",
|
||||
severity: "critical",
|
||||
title: "Telegram group allowlist contains wildcard",
|
||||
detail:
|
||||
'Telegram group sender allowlist contains "*", which allows any group member to run /… commands and control directives.',
|
||||
remediation:
|
||||
'Remove "*" from channels.telegram.groupAllowFrom and pairing store; prefer explicit numeric Telegram user IDs.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hasAnySenderAllowlist) {
|
||||
const providerSetting = (telegramCfg.commands as { nativeSkills?: unknown } | undefined)
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
?.nativeSkills as any;
|
||||
const skillsEnabled = resolveNativeSkillsEnabled({
|
||||
providerId: "telegram",
|
||||
providerSetting,
|
||||
globalSetting: params.cfg.commands?.nativeSkills,
|
||||
});
|
||||
findings.push({
|
||||
checkId: "channels.telegram.groups.allowFrom.missing",
|
||||
severity: "critical",
|
||||
title: "Telegram group commands have no sender allowlist",
|
||||
detail:
|
||||
`Telegram group access is enabled but no sender allowlist is configured; this allows any group member to invoke /… commands` +
|
||||
(skillsEnabled ? " (including skill commands)." : "."),
|
||||
remediation:
|
||||
"Approve yourself via pairing (recommended), or set channels.telegram.groupAllowFrom (or per-group groups.<id>.allowFrom).",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,13 +15,14 @@ import { resolveSkillSource } from "../agents/skills/source.js";
|
|||
import { isToolAllowedByPolicies } from "../agents/tool-policy-match.js";
|
||||
import { resolveToolProfilePolicy } from "../agents/tool-policy.js";
|
||||
import { listAgentWorkspaceDirs } from "../agents/workspace-dirs.js";
|
||||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { MANIFEST_KEY } from "../compat/legacy-names.js";
|
||||
import { resolveNativeSkillsEnabled } from "../config/commands.js";
|
||||
import type { OpenClawConfig, ConfigFileSnapshot } from "../config/config.js";
|
||||
import { collectIncludePathsRecursive } from "../config/includes-scan.js";
|
||||
import { resolveOAuthDir } from "../config/paths.js";
|
||||
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
|
||||
import type { AgentToolsConfig } from "../config/types.tools.js";
|
||||
import { normalizePluginsConfig } from "../plugins/config-state.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
|
|
@ -118,6 +119,85 @@ function formatCodeSafetyDetails(findings: SkillScanFinding[], rootDir: string):
|
|||
.join("\n");
|
||||
}
|
||||
|
||||
function readChannelCommandSetting(
|
||||
cfg: OpenClawConfig,
|
||||
channelId: string,
|
||||
key: "native" | "nativeSkills",
|
||||
): unknown {
|
||||
const channelCfg = cfg.channels?.[channelId as keyof NonNullable<OpenClawConfig["channels"]>];
|
||||
if (!channelCfg || typeof channelCfg !== "object" || Array.isArray(channelCfg)) {
|
||||
return undefined;
|
||||
}
|
||||
const commands = (channelCfg as { commands?: unknown }).commands;
|
||||
if (!commands || typeof commands !== "object" || Array.isArray(commands)) {
|
||||
return undefined;
|
||||
}
|
||||
return (commands as Record<string, unknown>)[key];
|
||||
}
|
||||
|
||||
async function isChannelPluginConfigured(
|
||||
cfg: OpenClawConfig,
|
||||
plugin: ReturnType<typeof listChannelPlugins>[number],
|
||||
): Promise<boolean> {
|
||||
const accountIds = plugin.config.listAccountIds(cfg);
|
||||
const candidates = accountIds.length > 0 ? accountIds : [undefined];
|
||||
for (const accountId of candidates) {
|
||||
const inspected =
|
||||
plugin.config.inspectAccount?.(cfg, accountId) ??
|
||||
(await inspectReadOnlyChannelAccount({
|
||||
channelId: plugin.id,
|
||||
cfg,
|
||||
accountId,
|
||||
}));
|
||||
const inspectedRecord =
|
||||
inspected && typeof inspected === "object" && !Array.isArray(inspected)
|
||||
? (inspected as Record<string, unknown>)
|
||||
: null;
|
||||
let resolvedAccount: unknown = inspected;
|
||||
if (!resolvedAccount) {
|
||||
try {
|
||||
resolvedAccount = plugin.config.resolveAccount(cfg, accountId);
|
||||
} catch {
|
||||
resolvedAccount = null;
|
||||
}
|
||||
}
|
||||
let enabled =
|
||||
typeof inspectedRecord?.enabled === "boolean"
|
||||
? inspectedRecord.enabled
|
||||
: resolvedAccount != null;
|
||||
if (
|
||||
typeof inspectedRecord?.enabled !== "boolean" &&
|
||||
resolvedAccount != null &&
|
||||
plugin.config.isEnabled
|
||||
) {
|
||||
try {
|
||||
enabled = plugin.config.isEnabled(resolvedAccount, cfg);
|
||||
} catch {
|
||||
enabled = false;
|
||||
}
|
||||
}
|
||||
let configured =
|
||||
typeof inspectedRecord?.configured === "boolean"
|
||||
? inspectedRecord.configured
|
||||
: resolvedAccount != null;
|
||||
if (
|
||||
typeof inspectedRecord?.configured !== "boolean" &&
|
||||
resolvedAccount != null &&
|
||||
plugin.config.isConfigured
|
||||
) {
|
||||
try {
|
||||
configured = await plugin.config.isConfigured(resolvedAccount, cfg);
|
||||
} catch {
|
||||
configured = false;
|
||||
}
|
||||
}
|
||||
if (enabled && configured) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function listInstalledPluginDirs(params: {
|
||||
stateDir: string;
|
||||
onReadError?: (error: unknown) => void;
|
||||
|
|
@ -544,75 +624,29 @@ export async function collectPluginsTrustFindings(params: {
|
|||
const allow = params.cfg.plugins?.allow;
|
||||
const allowConfigured = Array.isArray(allow) && allow.length > 0;
|
||||
if (!allowConfigured) {
|
||||
const hasString = (value: unknown) => typeof value === "string" && value.trim().length > 0;
|
||||
const hasSecretInput = (value: unknown) =>
|
||||
hasConfiguredSecretInput(value, params.cfg.secrets?.defaults);
|
||||
const hasAccountStringKey = (account: unknown, key: string) =>
|
||||
Boolean(
|
||||
account &&
|
||||
typeof account === "object" &&
|
||||
hasString((account as Record<string, unknown>)[key]),
|
||||
);
|
||||
const hasAccountSecretInputKey = (account: unknown, key: string) =>
|
||||
Boolean(
|
||||
account &&
|
||||
typeof account === "object" &&
|
||||
hasSecretInput((account as Record<string, unknown>)[key]),
|
||||
);
|
||||
|
||||
const discordConfigured =
|
||||
hasSecretInput(params.cfg.channels?.discord?.token) ||
|
||||
Boolean(
|
||||
params.cfg.channels?.discord?.accounts &&
|
||||
Object.values(params.cfg.channels.discord.accounts).some((a) =>
|
||||
hasAccountSecretInputKey(a, "token"),
|
||||
),
|
||||
) ||
|
||||
hasString(process.env.DISCORD_BOT_TOKEN);
|
||||
|
||||
const telegramConfigured =
|
||||
hasSecretInput(params.cfg.channels?.telegram?.botToken) ||
|
||||
hasString(params.cfg.channels?.telegram?.tokenFile) ||
|
||||
Boolean(
|
||||
params.cfg.channels?.telegram?.accounts &&
|
||||
Object.values(params.cfg.channels.telegram.accounts).some(
|
||||
(a) => hasAccountSecretInputKey(a, "botToken") || hasAccountStringKey(a, "tokenFile"),
|
||||
),
|
||||
) ||
|
||||
hasString(process.env.TELEGRAM_BOT_TOKEN);
|
||||
|
||||
const slackConfigured =
|
||||
hasSecretInput(params.cfg.channels?.slack?.botToken) ||
|
||||
hasSecretInput(params.cfg.channels?.slack?.appToken) ||
|
||||
Boolean(
|
||||
params.cfg.channels?.slack?.accounts &&
|
||||
Object.values(params.cfg.channels.slack.accounts).some(
|
||||
(a) =>
|
||||
hasAccountSecretInputKey(a, "botToken") || hasAccountSecretInputKey(a, "appToken"),
|
||||
),
|
||||
) ||
|
||||
hasString(process.env.SLACK_BOT_TOKEN) ||
|
||||
hasString(process.env.SLACK_APP_TOKEN);
|
||||
|
||||
const skillCommandsLikelyExposed =
|
||||
(discordConfigured &&
|
||||
resolveNativeSkillsEnabled({
|
||||
providerId: "discord",
|
||||
providerSetting: params.cfg.channels?.discord?.commands?.nativeSkills,
|
||||
globalSetting: params.cfg.commands?.nativeSkills,
|
||||
})) ||
|
||||
(telegramConfigured &&
|
||||
resolveNativeSkillsEnabled({
|
||||
providerId: "telegram",
|
||||
providerSetting: params.cfg.channels?.telegram?.commands?.nativeSkills,
|
||||
globalSetting: params.cfg.commands?.nativeSkills,
|
||||
})) ||
|
||||
(slackConfigured &&
|
||||
resolveNativeSkillsEnabled({
|
||||
providerId: "slack",
|
||||
providerSetting: params.cfg.channels?.slack?.commands?.nativeSkills,
|
||||
globalSetting: params.cfg.commands?.nativeSkills,
|
||||
}));
|
||||
const skillCommandsLikelyExposed = (
|
||||
await Promise.all(
|
||||
listChannelPlugins().map(async (plugin) => {
|
||||
if (
|
||||
plugin.capabilities.nativeCommands !== true &&
|
||||
plugin.commands?.nativeSkillsAutoEnabled !== true
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!(await isChannelPluginConfigured(params.cfg, plugin))) {
|
||||
return false;
|
||||
}
|
||||
return resolveNativeSkillsEnabled({
|
||||
providerId: plugin.id,
|
||||
providerSetting: readChannelCommandSetting(params.cfg, plugin.id, "nativeSkills") as
|
||||
| "auto"
|
||||
| boolean
|
||||
| undefined,
|
||||
globalSetting: params.cfg.commands?.nativeSkills,
|
||||
});
|
||||
}),
|
||||
)
|
||||
).some(Boolean);
|
||||
|
||||
findings.push({
|
||||
checkId: "plugins.extensions_no_allowlist",
|
||||
|
|
|
|||
|
|
@ -2,9 +2,16 @@ import fs from "node:fs/promises";
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { collectDiscordSecurityAuditFindings } from "../../extensions/discord/api.js";
|
||||
import { collectSlackSecurityAuditFindings } from "../../extensions/slack/api.js";
|
||||
import { collectSynologyChatSecurityAuditFindings } from "../../extensions/synology-chat/api.js";
|
||||
import { collectTelegramSecurityAuditFindings } from "../../extensions/telegram/api.js";
|
||||
import { collectZalouserSecurityAuditFindings } from "../../extensions/zalouser/api.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { saveExecApprovals } from "../infra/exec-approvals.js";
|
||||
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
|
||||
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createPathResolutionEnv, withEnvAsync } from "../test-utils/env.js";
|
||||
import type { SecurityAuditOptions, SecurityAuditReport } from "./audit.js";
|
||||
import { runSecurityAudit } from "./audit.js";
|
||||
|
|
@ -39,7 +46,47 @@ function stubChannelPlugin(params: {
|
|||
listAccountIds?: (cfg: OpenClawConfig) => string[];
|
||||
isConfigured?: (account: unknown, cfg: OpenClawConfig) => boolean;
|
||||
isEnabled?: (account: unknown, cfg: OpenClawConfig) => boolean;
|
||||
collectAuditFindings?: NonNullable<ChannelPlugin["security"]>["collectAuditFindings"];
|
||||
commands?: ChannelPlugin["commands"];
|
||||
}): ChannelPlugin {
|
||||
const channelConfigured = (cfg: OpenClawConfig) =>
|
||||
Boolean((cfg.channels as Record<string, unknown> | undefined)?.[params.id]);
|
||||
const defaultCollectAuditFindings =
|
||||
params.collectAuditFindings ??
|
||||
(params.id === "discord"
|
||||
? (collectDiscordSecurityAuditFindings as NonNullable<
|
||||
ChannelPlugin["security"]
|
||||
>["collectAuditFindings"])
|
||||
: params.id === "slack"
|
||||
? (collectSlackSecurityAuditFindings as NonNullable<
|
||||
ChannelPlugin["security"]
|
||||
>["collectAuditFindings"])
|
||||
: params.id === "synology-chat"
|
||||
? (collectSynologyChatSecurityAuditFindings as NonNullable<
|
||||
ChannelPlugin["security"]
|
||||
>["collectAuditFindings"])
|
||||
: params.id === "telegram"
|
||||
? (collectTelegramSecurityAuditFindings as NonNullable<
|
||||
ChannelPlugin["security"]
|
||||
>["collectAuditFindings"])
|
||||
: params.id === "zalouser"
|
||||
? (collectZalouserSecurityAuditFindings as NonNullable<
|
||||
ChannelPlugin["security"]
|
||||
>["collectAuditFindings"])
|
||||
: undefined);
|
||||
const defaultCommands =
|
||||
params.commands ??
|
||||
(params.id === "discord" || params.id === "telegram"
|
||||
? {
|
||||
nativeCommandsAutoEnabled: true,
|
||||
nativeSkillsAutoEnabled: true,
|
||||
}
|
||||
: params.id === "slack"
|
||||
? {
|
||||
nativeCommandsAutoEnabled: false,
|
||||
nativeSkillsAutoEnabled: false,
|
||||
}
|
||||
: undefined);
|
||||
return {
|
||||
id: params.id,
|
||||
meta: {
|
||||
|
|
@ -52,7 +99,12 @@ function stubChannelPlugin(params: {
|
|||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
},
|
||||
security: {},
|
||||
...(defaultCommands ? { commands: defaultCommands } : {}),
|
||||
security: defaultCollectAuditFindings
|
||||
? {
|
||||
collectAuditFindings: defaultCollectAuditFindings,
|
||||
}
|
||||
: {},
|
||||
config: {
|
||||
listAccountIds:
|
||||
params.listAccountIds ??
|
||||
|
|
@ -78,14 +130,14 @@ function stubChannelPlugin(params: {
|
|||
const config = account?.config ?? {};
|
||||
return {
|
||||
accountId: resolvedAccountId,
|
||||
enabled: params.isEnabled?.(account, cfg) ?? true,
|
||||
configured: params.isConfigured?.(account, cfg) ?? true,
|
||||
enabled: params.isEnabled?.(account, cfg) ?? channelConfigured(cfg),
|
||||
configured: params.isConfigured?.(account, cfg) ?? channelConfigured(cfg),
|
||||
config,
|
||||
};
|
||||
}),
|
||||
resolveAccount: (cfg, accountId) => params.resolveAccount(cfg, accountId),
|
||||
isEnabled: (account, cfg) => params.isEnabled?.(account, cfg) ?? true,
|
||||
isConfigured: (account, cfg) => params.isConfigured?.(account, cfg) ?? true,
|
||||
isEnabled: (account, cfg) => params.isEnabled?.(account, cfg) ?? channelConfigured(cfg),
|
||||
isConfigured: (account, cfg) => params.isConfigured?.(account, cfg) ?? channelConfigured(cfg),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -180,6 +232,14 @@ const synologyChatPlugin = stubChannelPlugin({
|
|||
},
|
||||
});
|
||||
|
||||
const BASE_AUDIT_CHANNEL_PLUGINS = [
|
||||
discordPlugin,
|
||||
slackPlugin,
|
||||
telegramPlugin,
|
||||
zalouserPlugin,
|
||||
synologyChatPlugin,
|
||||
] satisfies ChannelPlugin[];
|
||||
|
||||
function successfulProbeResult(url: string) {
|
||||
return {
|
||||
ok: true,
|
||||
|
|
@ -202,12 +262,14 @@ async function audit(
|
|||
saveExecApprovals({ version: 1, agents: {} });
|
||||
}
|
||||
const { preserveExecApprovals: _preserveExecApprovals, ...options } = extra ?? {};
|
||||
return runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
...options,
|
||||
});
|
||||
return withActiveAuditChannelPlugins(options.plugins ?? BASE_AUDIT_CHANNEL_PLUGINS, () =>
|
||||
runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
...options,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function runAuditCases<T>(
|
||||
|
|
@ -299,12 +361,33 @@ async function runChannelSecurityAudit(
|
|||
cfg: OpenClawConfig,
|
||||
plugins: ChannelPlugin[],
|
||||
): Promise<SecurityAuditReport> {
|
||||
return runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: true,
|
||||
plugins,
|
||||
});
|
||||
return withActiveAuditChannelPlugins(plugins, () =>
|
||||
runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: true,
|
||||
plugins,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function withActiveAuditChannelPlugins<T>(
|
||||
plugins: ChannelPlugin[],
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const previousRegistry = getActivePluginRegistry();
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.channels = plugins.map((plugin) => ({
|
||||
pluginId: plugin.id,
|
||||
plugin,
|
||||
source: "test",
|
||||
}));
|
||||
setActivePluginRegistry(registry);
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
setActivePluginRegistry(previousRegistry ?? createEmptyPluginRegistry());
|
||||
}
|
||||
}
|
||||
|
||||
async function runInstallMetadataAudit(
|
||||
|
|
@ -2191,12 +2274,14 @@ describe("security audit", () => {
|
|||
},
|
||||
];
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: true,
|
||||
plugins,
|
||||
});
|
||||
const res = await withActiveAuditChannelPlugins(plugins, () =>
|
||||
runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: true,
|
||||
plugins,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
|
|
@ -2255,12 +2340,14 @@ describe("security audit", () => {
|
|||
] as const;
|
||||
|
||||
await runChannelSecurityStateCases(cases, async (testCase) => {
|
||||
const res = await runSecurityAudit({
|
||||
config: testCase.cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: true,
|
||||
plugins: [discordPlugin],
|
||||
});
|
||||
const res = await withActiveAuditChannelPlugins([discordPlugin], () =>
|
||||
runSecurityAudit({
|
||||
config: testCase.cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: true,
|
||||
plugins: [discordPlugin],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
res.findings.some(
|
||||
|
|
@ -2463,13 +2550,16 @@ describe("security audit", () => {
|
|||
] as const;
|
||||
|
||||
await runChannelSecurityStateCases(cases, async (testCase) => {
|
||||
const res = await runSecurityAudit({
|
||||
config: testCase.resolvedConfig,
|
||||
sourceConfig: testCase.sourceConfig,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: true,
|
||||
plugins: [testCase.plugin(testCase.sourceConfig)],
|
||||
});
|
||||
const plugins = [testCase.plugin(testCase.sourceConfig)];
|
||||
const res = await withActiveAuditChannelPlugins(plugins, () =>
|
||||
runSecurityAudit({
|
||||
config: testCase.resolvedConfig,
|
||||
sourceConfig: testCase.sourceConfig,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: true,
|
||||
plugins,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.findings, testCase.name).toEqual(
|
||||
expect.arrayContaining([
|
||||
|
|
@ -2500,12 +2590,14 @@ describe("security audit", () => {
|
|||
},
|
||||
};
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: true,
|
||||
plugins: [plugin],
|
||||
});
|
||||
const res = await withActiveAuditChannelPlugins([plugin], () =>
|
||||
runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: true,
|
||||
plugins: [plugin],
|
||||
}),
|
||||
);
|
||||
|
||||
const finding = res.findings.find(
|
||||
(entry) => entry.checkId === "channels.zalouser.account.read_only_resolution",
|
||||
|
|
@ -2765,12 +2857,14 @@ describe("security audit", () => {
|
|||
},
|
||||
};
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: true,
|
||||
plugins: [pluginWithProtoDefaultAccount],
|
||||
});
|
||||
const res = await withActiveAuditChannelPlugins([pluginWithProtoDefaultAccount], () =>
|
||||
runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: true,
|
||||
plugins: [pluginWithProtoDefaultAccount],
|
||||
}),
|
||||
);
|
||||
|
||||
const dangerousMatchingFinding = res.findings.find(
|
||||
(entry) => entry.checkId === "channels.discord.allowFrom.dangerous_name_matching_enabled",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,9 @@ let auditDeepModulePromise: Promise<typeof import("./audit.deep.runtime.js")> |
|
|||
let auditChannelModulePromise:
|
||||
| Promise<typeof import("./audit-channel.collect.runtime.js")>
|
||||
| undefined;
|
||||
let pluginRegistryLoaderModulePromise:
|
||||
| Promise<typeof import("../plugins/runtime/runtime-registry-loader.js")>
|
||||
| undefined;
|
||||
let gatewayProbeDepsPromise:
|
||||
| Promise<{
|
||||
buildGatewayConnectionDetails: typeof import("../gateway/call.js").buildGatewayConnectionDetails;
|
||||
|
|
@ -148,6 +151,11 @@ async function loadAuditChannelModule() {
|
|||
return await auditChannelModulePromise;
|
||||
}
|
||||
|
||||
async function loadPluginRegistryLoaderModule() {
|
||||
pluginRegistryLoaderModulePromise ??= import("../plugins/runtime/runtime-registry-loader.js");
|
||||
return await pluginRegistryLoaderModulePromise;
|
||||
}
|
||||
|
||||
async function loadGatewayProbeDeps() {
|
||||
gatewayProbeDepsPromise ??= Promise.all([
|
||||
import("../gateway/call.js"),
|
||||
|
|
@ -1455,6 +1463,14 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
|
|||
context.includeChannelSecurity &&
|
||||
(context.plugins !== undefined || hasPotentialConfiguredChannels(cfg, env));
|
||||
if (shouldAuditChannelSecurity) {
|
||||
if (context.plugins === undefined) {
|
||||
(await loadPluginRegistryLoaderModule()).ensurePluginRegistryLoaded({
|
||||
scope: "configured-channels",
|
||||
config: cfg,
|
||||
activationSourceConfig: context.sourceConfig,
|
||||
env,
|
||||
});
|
||||
}
|
||||
const channelPlugins = context.plugins ?? (await loadChannelPlugins()).listChannelPlugins();
|
||||
const { collectChannelSecurityFindings } = await loadAuditChannelModule();
|
||||
findings.push(
|
||||
|
|
|
|||
Loading…
Reference in New Issue