Files
openclaw/src/plugins/config-policy.ts
Vincent Koc 50e1eb56d7 fix(security): harden discord proxy and bundled channel activation (#60455)
* fix(security): tighten discord proxy and mobile tls guards

* fix(plugins): enforce allowlists for bundled channels

* fix(types): align callers with removed legacy config aliases

* fix(security): preserve bundled channel opt-in and ipv6 proxies
2026-04-04 02:48:52 +09:00

436 lines
13 KiB
TypeScript

import { normalizeChatChannelId } from "../channels/ids.js";
import type { OpenClawConfig } from "../config/config.js";
import { defaultSlotIdForKey, hasKind } from "./slots.js";
import type { PluginKind, PluginOrigin } from "./types.js";
export type PluginActivationSource = "disabled" | "explicit" | "auto" | "default";
export type PluginActivationState = {
enabled: boolean;
activated: boolean;
explicitlyEnabled: boolean;
source: PluginActivationSource;
reason?: string;
};
export type NormalizedPluginsConfig = {
enabled: boolean;
allow: string[];
deny: string[];
loadPaths: string[];
slots: {
memory?: string | null;
};
entries: Record<
string,
{
enabled?: boolean;
hooks?: {
allowPromptInjection?: boolean;
};
subagent?: {
allowModelOverride?: boolean;
allowedModels?: string[];
hasAllowedModelsConfig?: boolean;
};
config?: unknown;
}
>;
};
type NormalizePluginId = (id: string) => string;
const identityNormalizePluginId: NormalizePluginId = (id) => id.trim();
const normalizeList = (value: unknown, normalizePluginId: NormalizePluginId): string[] => {
if (!Array.isArray(value)) {
return [];
}
return value
.map((entry) => (typeof entry === "string" ? normalizePluginId(entry) : ""))
.filter(Boolean);
};
const normalizeSlotValue = (value: unknown): string | null | undefined => {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
if (trimmed.toLowerCase() === "none") {
return null;
}
return trimmed;
};
const normalizePluginEntries = (
entries: unknown,
normalizePluginId: NormalizePluginId,
): NormalizedPluginsConfig["entries"] => {
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
return {};
}
const normalized: NormalizedPluginsConfig["entries"] = {};
for (const [key, value] of Object.entries(entries)) {
const normalizedKey = normalizePluginId(key);
if (!normalizedKey) {
continue;
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
normalized[normalizedKey] = {};
continue;
}
const entry = value as Record<string, unknown>;
const hooksRaw = entry.hooks;
const hooks =
hooksRaw && typeof hooksRaw === "object" && !Array.isArray(hooksRaw)
? {
allowPromptInjection: (hooksRaw as { allowPromptInjection?: unknown })
.allowPromptInjection,
}
: undefined;
const normalizedHooks =
hooks && typeof hooks.allowPromptInjection === "boolean"
? {
allowPromptInjection: hooks.allowPromptInjection,
}
: undefined;
const subagentRaw = entry.subagent;
const subagent =
subagentRaw && typeof subagentRaw === "object" && !Array.isArray(subagentRaw)
? {
allowModelOverride: (subagentRaw as { allowModelOverride?: unknown })
.allowModelOverride,
hasAllowedModelsConfig: Array.isArray(
(subagentRaw as { allowedModels?: unknown }).allowedModels,
),
allowedModels: Array.isArray((subagentRaw as { allowedModels?: unknown }).allowedModels)
? ((subagentRaw as { allowedModels?: unknown }).allowedModels as unknown[])
.map((model) => (typeof model === "string" ? model.trim() : ""))
.filter(Boolean)
: undefined,
}
: undefined;
const normalizedSubagent =
subagent &&
(typeof subagent.allowModelOverride === "boolean" ||
subagent.hasAllowedModelsConfig ||
(Array.isArray(subagent.allowedModels) && subagent.allowedModels.length > 0))
? {
...(typeof subagent.allowModelOverride === "boolean"
? { allowModelOverride: subagent.allowModelOverride }
: {}),
...(subagent.hasAllowedModelsConfig ? { hasAllowedModelsConfig: true } : {}),
...(Array.isArray(subagent.allowedModels) && subagent.allowedModels.length > 0
? { allowedModels: subagent.allowedModels }
: {}),
}
: undefined;
normalized[normalizedKey] = {
...normalized[normalizedKey],
enabled:
typeof entry.enabled === "boolean" ? entry.enabled : normalized[normalizedKey]?.enabled,
hooks: normalizedHooks ?? normalized[normalizedKey]?.hooks,
subagent: normalizedSubagent ?? normalized[normalizedKey]?.subagent,
config: "config" in entry ? entry.config : normalized[normalizedKey]?.config,
};
}
return normalized;
};
export function normalizePluginsConfigWithResolver(
config?: OpenClawConfig["plugins"],
normalizePluginId: NormalizePluginId = identityNormalizePluginId,
): NormalizedPluginsConfig {
const memorySlot = normalizeSlotValue(config?.slots?.memory);
return {
enabled: config?.enabled !== false,
allow: normalizeList(config?.allow, normalizePluginId),
deny: normalizeList(config?.deny, normalizePluginId),
loadPaths: normalizeList(config?.load?.paths, identityNormalizePluginId),
slots: {
memory: memorySlot === undefined ? defaultSlotIdForKey("memory") : memorySlot,
},
entries: normalizePluginEntries(config?.entries, normalizePluginId),
};
}
function resolveExplicitPluginSelection(params: {
id: string;
origin: PluginOrigin;
config: NormalizedPluginsConfig;
rootConfig?: OpenClawConfig;
}): { explicitlyEnabled: boolean; reason?: string } {
if (params.config.entries[params.id]?.enabled === true) {
return { explicitlyEnabled: true, reason: "enabled in config" };
}
if (
params.origin === "bundled" &&
isBundledChannelEnabledByChannelConfig(params.rootConfig, params.id)
) {
return { explicitlyEnabled: true, reason: "channel enabled in config" };
}
if (params.config.slots.memory === params.id) {
return { explicitlyEnabled: true, reason: "selected memory slot" };
}
if (params.origin !== "bundled" && params.config.allow.includes(params.id)) {
return { explicitlyEnabled: true, reason: "selected in allowlist" };
}
return { explicitlyEnabled: false };
}
export function resolvePluginActivationState(params: {
id: string;
origin: PluginOrigin;
config: NormalizedPluginsConfig;
rootConfig?: OpenClawConfig;
enabledByDefault?: boolean;
sourceConfig?: NormalizedPluginsConfig;
sourceRootConfig?: OpenClawConfig;
autoEnabledReason?: string;
}): PluginActivationState {
const explicitSelection = resolveExplicitPluginSelection({
id: params.id,
origin: params.origin,
config: params.sourceConfig ?? params.config,
rootConfig: params.sourceRootConfig ?? params.rootConfig,
});
if (!params.config.enabled) {
return {
enabled: false,
activated: false,
explicitlyEnabled: explicitSelection.explicitlyEnabled,
source: "disabled",
reason: "plugins disabled",
};
}
if (params.config.deny.includes(params.id)) {
return {
enabled: false,
activated: false,
explicitlyEnabled: explicitSelection.explicitlyEnabled,
source: "disabled",
reason: "blocked by denylist",
};
}
const entry = params.config.entries[params.id];
if (entry?.enabled === false) {
return {
enabled: false,
activated: false,
explicitlyEnabled: explicitSelection.explicitlyEnabled,
source: "disabled",
reason: "disabled in config",
};
}
const explicitlyAllowed = params.config.allow.includes(params.id);
if (params.origin === "workspace" && !explicitlyAllowed && entry?.enabled !== true) {
return {
enabled: false,
activated: false,
explicitlyEnabled: explicitSelection.explicitlyEnabled,
source: "disabled",
reason: "workspace plugin (disabled by default)",
};
}
if (params.config.slots.memory === params.id) {
return {
enabled: true,
activated: true,
explicitlyEnabled: true,
source: "explicit",
reason: "selected memory slot",
};
}
if (params.config.allow.length > 0 && !explicitlyAllowed) {
return {
enabled: false,
activated: false,
explicitlyEnabled: explicitSelection.explicitlyEnabled,
source: "disabled",
reason: "not in allowlist",
};
}
if (explicitSelection.explicitlyEnabled) {
return {
enabled: true,
activated: true,
explicitlyEnabled: true,
source: "explicit",
reason: explicitSelection.reason,
};
}
if (params.autoEnabledReason) {
return {
enabled: true,
activated: true,
explicitlyEnabled: false,
source: "auto",
reason: params.autoEnabledReason,
};
}
if (entry?.enabled === true) {
return {
enabled: true,
activated: true,
explicitlyEnabled: false,
source: "auto",
reason: "enabled by effective config",
};
}
if (
params.origin === "bundled" &&
isBundledChannelEnabledByChannelConfig(params.rootConfig, params.id)
) {
return {
enabled: true,
activated: true,
explicitlyEnabled: false,
source: "auto",
reason: "channel configured",
};
}
if (params.origin === "bundled" && params.enabledByDefault === true) {
return {
enabled: true,
activated: true,
explicitlyEnabled: false,
source: "default",
reason: "bundled default enablement",
};
}
if (params.origin === "bundled") {
return {
enabled: false,
activated: false,
explicitlyEnabled: false,
source: "disabled",
reason: "bundled (disabled by default)",
};
}
return {
enabled: true,
activated: true,
explicitlyEnabled: explicitSelection.explicitlyEnabled,
source: "default",
};
}
export function hasExplicitPluginConfig(plugins?: OpenClawConfig["plugins"]): boolean {
if (!plugins) {
return false;
}
if (typeof plugins.enabled === "boolean") {
return true;
}
if (Array.isArray(plugins.allow) && plugins.allow.length > 0) {
return true;
}
if (Array.isArray(plugins.deny) && plugins.deny.length > 0) {
return true;
}
if (plugins.load?.paths && Array.isArray(plugins.load.paths) && plugins.load.paths.length > 0) {
return true;
}
if (plugins.slots && Object.keys(plugins.slots).length > 0) {
return true;
}
if (plugins.entries && Object.keys(plugins.entries).length > 0) {
return true;
}
return false;
}
export function resolveEnableState(
id: string,
origin: PluginOrigin,
config: NormalizedPluginsConfig,
enabledByDefault?: boolean,
): { enabled: boolean; reason?: string } {
const state = resolvePluginActivationState({
id,
origin,
config,
enabledByDefault,
});
return state.enabled ? { enabled: true } : { enabled: false, reason: state.reason };
}
export function isBundledChannelEnabledByChannelConfig(
cfg: OpenClawConfig | undefined,
pluginId: string,
): boolean {
if (!cfg) {
return false;
}
const channelId = normalizeChatChannelId(pluginId);
if (!channelId) {
return false;
}
const channels = cfg.channels as Record<string, unknown> | undefined;
const entry = channels?.[channelId];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return false;
}
return (entry as Record<string, unknown>).enabled === true;
}
export function resolveEffectiveEnableState(params: {
id: string;
origin: PluginOrigin;
config: NormalizedPluginsConfig;
rootConfig?: OpenClawConfig;
enabledByDefault?: boolean;
sourceConfig?: NormalizedPluginsConfig;
sourceRootConfig?: OpenClawConfig;
autoEnabledReason?: string;
}): { enabled: boolean; reason?: string } {
const state = resolveEffectivePluginActivationState(params);
return state.enabled ? { enabled: true } : { enabled: false, reason: state.reason };
}
export function resolveEffectivePluginActivationState(params: {
id: string;
origin: PluginOrigin;
config: NormalizedPluginsConfig;
rootConfig?: OpenClawConfig;
enabledByDefault?: boolean;
sourceConfig?: NormalizedPluginsConfig;
sourceRootConfig?: OpenClawConfig;
autoEnabledReason?: string;
}): PluginActivationState {
return resolvePluginActivationState(params);
}
export function resolveMemorySlotDecision(params: {
id: string;
kind?: PluginKind | PluginKind[];
slot: string | null | undefined;
selectedId: string | null;
}): { enabled: boolean; reason?: string; selected?: boolean } {
if (!hasKind(params.kind, "memory")) {
return { enabled: true };
}
// A dual-kind plugin (e.g. ["memory", "context-engine"]) that lost the
// memory slot must stay enabled so its other slot role can still load.
const isMultiKind = Array.isArray(params.kind) && params.kind.length > 1;
if (params.slot === null) {
return isMultiKind ? { enabled: true } : { enabled: false, reason: "memory slot disabled" };
}
if (typeof params.slot === "string") {
if (params.slot === params.id) {
return { enabled: true, selected: true };
}
return isMultiKind
? { enabled: true }
: { enabled: false, reason: `memory slot set to "${params.slot}"` };
}
if (params.selectedId && params.selectedId !== params.id) {
return isMultiKind
? { enabled: true }
: { enabled: false, reason: `memory slot already filled by "${params.selectedId}"` };
}
return { enabled: true, selected: true };
}