import { listChannelPlugins } from "../channels/plugins/index.js"; import type { ChannelId } from "../channels/plugins/types.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig, GatewayBindMode } from "../config/config.js"; import type { AgentConfig } from "../config/types.agents.js"; import { hasConfiguredSecretInput } from "../config/types.secrets.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; import { isLoopbackHost, resolveGatewayBindHost } from "../gateway/net.js"; import { resolveDmAllowState } from "../security/dm-policy-shared.js"; import { note } from "../terminal/note.js"; import { resolveDefaultChannelAccountContext } from "./channel-account-context.js"; function collectImplicitHeartbeatDirectPolicyWarnings(cfg: OpenClawConfig): string[] { const warnings: string[] = []; const maybeWarn = (params: { label: string; heartbeat: AgentConfig["heartbeat"] | undefined; pathHint: string; }) => { const heartbeat = params.heartbeat; if (!heartbeat || heartbeat.target === undefined || heartbeat.target === "none") { return; } if (heartbeat.directPolicy !== undefined) { return; } warnings.push( `- ${params.label}: heartbeat delivery is configured while ${params.pathHint} is unset.`, ' Heartbeat now allows direct/DM targets by default. Set it explicitly to "allow" or "block" to pin upgrade behavior.', ); }; maybeWarn({ label: "Heartbeat defaults", heartbeat: cfg.agents?.defaults?.heartbeat, pathHint: "agents.defaults.heartbeat.directPolicy", }); for (const agent of cfg.agents?.list ?? []) { maybeWarn({ label: `Heartbeat agent "${agent.id}"`, heartbeat: agent.heartbeat, pathHint: `heartbeat.directPolicy for agent "${agent.id}"`, }); } return warnings; } export async function noteSecurityWarnings(cfg: OpenClawConfig) { const warnings: string[] = []; const auditHint = `- Run: ${formatCliCommand("openclaw security audit --deep")}`; if (cfg.approvals?.exec?.enabled === false) { warnings.push( "- Note: approvals.exec.enabled=false disables approval forwarding only.", " Host exec gating still comes from ~/.openclaw/exec-approvals.json.", ` Check local policy with: ${formatCliCommand("openclaw approvals get --gateway")}`, ); } warnings.push(...collectImplicitHeartbeatDirectPolicyWarnings(cfg)); // =========================================== // GATEWAY NETWORK EXPOSURE CHECK // =========================================== // Check for dangerous gateway binding configurations // that expose the gateway to network without proper auth const gatewayBind = (cfg.gateway?.bind ?? "loopback") as string; const customBindHost = cfg.gateway?.customBindHost?.trim(); const bindModes: GatewayBindMode[] = ["auto", "lan", "loopback", "custom", "tailnet"]; const bindMode = bindModes.includes(gatewayBind as GatewayBindMode) ? (gatewayBind as GatewayBindMode) : undefined; const resolvedBindHost = bindMode ? await resolveGatewayBindHost(bindMode, customBindHost) : "0.0.0.0"; const isExposed = !isLoopbackHost(resolvedBindHost); const resolvedAuth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env, tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off", }); const authToken = resolvedAuth.token?.trim() ?? ""; const authPassword = resolvedAuth.password?.trim() ?? ""; const hasToken = authToken.length > 0 || hasConfiguredSecretInput(cfg.gateway?.auth?.token, cfg.secrets?.defaults); const hasPassword = authPassword.length > 0 || hasConfiguredSecretInput(cfg.gateway?.auth?.password, cfg.secrets?.defaults); const hasSharedSecret = (resolvedAuth.mode === "token" && hasToken) || (resolvedAuth.mode === "password" && hasPassword); const bindDescriptor = `"${gatewayBind}" (${resolvedBindHost})`; const saferRemoteAccessLines = [ " Safer remote access: keep bind loopback and use Tailscale Serve/Funnel or an SSH tunnel.", " Example tunnel: ssh -N -L 18789:127.0.0.1:18789 user@gateway-host", " Docs: https://docs.openclaw.ai/gateway/remote", ]; if (isExposed) { if (!hasSharedSecret) { const authFixLines = resolvedAuth.mode === "password" ? [ ` Fix: ${formatCliCommand("openclaw configure")} to set a password`, ` Or switch to token: ${formatCliCommand("openclaw config set gateway.auth.mode token")}`, ] : [ ` Fix: ${formatCliCommand("openclaw doctor --fix")} to generate a token`, ` Or set token directly: ${formatCliCommand( "openclaw config set gateway.auth.mode token", )}`, ]; warnings.push( `- CRITICAL: Gateway bound to ${bindDescriptor} without authentication.`, ` Anyone on your network (or internet if port-forwarded) can fully control your agent.`, ` Fix: ${formatCliCommand("openclaw config set gateway.bind loopback")}`, ...saferRemoteAccessLines, ...authFixLines, ); } else { // Auth is configured, but still warn about network exposure warnings.push( `- WARNING: Gateway bound to ${bindDescriptor} (network-accessible).`, ` Ensure your auth credentials are strong and not exposed.`, ...saferRemoteAccessLines, ); } } const warnDmPolicy = async (params: { label: string; provider: ChannelId; accountId: string; dmPolicy: string; allowFrom?: Array | null; policyPath?: string; allowFromPath: string; approveHint: string; normalizeEntry?: (raw: string) => string; }) => { const dmPolicy = params.dmPolicy; const policyPath = params.policyPath ?? `${params.allowFromPath}policy`; const { hasWildcard, allowCount, isMultiUserDm } = await resolveDmAllowState({ provider: params.provider, accountId: params.accountId, allowFrom: params.allowFrom, normalizeEntry: params.normalizeEntry, }); const dmScope = cfg.session?.dmScope ?? "main"; if (dmPolicy === "open") { const allowFromPath = `${params.allowFromPath}allowFrom`; warnings.push(`- ${params.label} DMs: OPEN (${policyPath}="open"). Anyone can DM it.`); if (!hasWildcard) { warnings.push( `- ${params.label} DMs: config invalid — "open" requires ${allowFromPath} to include "*".`, ); } } if (dmPolicy === "disabled") { warnings.push(`- ${params.label} DMs: disabled (${policyPath}="disabled").`); return; } if (dmPolicy !== "open" && allowCount === 0) { warnings.push( `- ${params.label} DMs: locked (${policyPath}="${dmPolicy}") with no allowlist; unknown senders will be blocked / get a pairing code.`, ); warnings.push(` ${params.approveHint}`); } if (dmScope === "main" && isMultiUserDm) { warnings.push( `- ${params.label} DMs: multiple senders share the main session; run: ` + formatCliCommand('openclaw config set session.dmScope "per-channel-peer"') + ' (or "per-account-channel-peer" for multi-account channels) to isolate sessions.', ); } }; for (const plugin of listChannelPlugins()) { if (!plugin.security) { continue; } const { defaultAccountId, account, enabled, configured } = await resolveDefaultChannelAccountContext(plugin, cfg); if (!enabled) { continue; } if (!configured) { continue; } const dmPolicy = plugin.security.resolveDmPolicy?.({ cfg, accountId: defaultAccountId, account, }); if (dmPolicy) { await warnDmPolicy({ label: plugin.meta.label ?? plugin.id, provider: plugin.id, accountId: defaultAccountId, dmPolicy: dmPolicy.policy, allowFrom: dmPolicy.allowFrom, policyPath: dmPolicy.policyPath, allowFromPath: dmPolicy.allowFromPath, approveHint: dmPolicy.approveHint, normalizeEntry: dmPolicy.normalizeEntry, }); } if (plugin.security.collectWarnings) { const extra = await plugin.security.collectWarnings({ cfg, accountId: defaultAccountId, account, }); if (extra?.length) { warnings.push(...extra); } } } const lines = warnings.length > 0 ? warnings : ["- No channel security warnings detected."]; lines.push(auditHint); note(lines.join("\n"), "Security"); }