UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

269 lines (268 loc) 14 kB
import { c as normalizeOptionalString } from "./string-coerce-mnp54Vah.js"; import { t as formatCliCommand } from "./command-format-CKGmlpAQ.js"; import { m as resolveSecretInputRef, s as hasConfiguredSecretInput } from "./types.secrets-_0JOMGE5.js"; import { n as hasConfiguredPlaintextSecretValue } from "./secret-value-DmVDGy1Y.js"; import { n as discoverConfigSecretTargets } from "./target-registry-0k0oK6Ia.js"; import { i as isLoopbackHost, p as resolveGatewayBindHost } from "./net-DTe7AQiu.js"; import "./auth-DXSWhsnk.js"; import { n as resolveGatewayAuth } from "./auth-resolve-BSKl_nHz.js"; import { N as resolveExecApprovalsDisplayPath, f as loadExecApprovals } from "./exec-approvals-C6M6SGY3.js"; import { t as resolveDmAllowAuditState } from "./dm-allow-state-DgWvIrZa.js"; import { n as listReadOnlyChannelPluginsForConfig } from "./read-only-7UA2_pQ4.js"; import { t as note } from "./note-BRSJp0UF.js"; import { n as resolveExecPolicyScopeSnapshot } from "./exec-approvals-effective-Buc95lwG.js"; import { t as resolveGatewayAuthTokenSourceConflict } from "./auth-token-source-conflict-DjnAqb2k.js"; import { t as collectExecFilesystemPolicyDriftHits } from "./exec-filesystem-policy-D7p63mZA.js"; import { t as isLikelySensitiveModelProviderHeaderName } from "./model-provider-header-policy-DHa8ymgK.js"; import { t as resolveDefaultChannelAccountContext } from "./channel-account-context-Bhs-k9uX.js"; //#region src/commands/doctor-security.ts /** Security warnings for gateway exposure, exec policy drift, channel DMs, and plaintext secrets. */ function collectImplicitHeartbeatDirectPolicyWarnings(cfg) { const warnings = []; const maybeWarn = (params) => { const heartbeat = params.heartbeat; if (!heartbeat || heartbeat.target === void 0 || heartbeat.target === "none") return; if (heartbeat.directPolicy !== void 0) 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" }); const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; for (const agent of agents) maybeWarn({ label: `Heartbeat agent "${agent.id}"`, heartbeat: agent.heartbeat, pathHint: `heartbeat.directPolicy for agent "${agent.id}"` }); return warnings; } function execSecurityRank(value) { switch (value) { case "deny": return 0; case "allowlist": return 1; case "full": return 2; } throw new Error("Unsupported exec security value"); } function execAskRank(value) { switch (value) { case "off": return 0; case "on-miss": return 1; case "always": return 2; } throw new Error("Unsupported exec ask value"); } function collectExecPolicyConflictWarnings(cfg) { const warnings = []; const approvals = loadExecApprovals(); const defaultRequestedSecuritySource = "OpenClaw default (full)"; const defaultRequestedAskSource = "OpenClaw default (off)"; const maybeWarn = (params) => { const scopeExecConfig = params.scopeExecConfig; const globalExecConfig = params.globalExecConfig; if (!scopeExecConfig?.mode && !scopeExecConfig?.security && !scopeExecConfig?.ask && !globalExecConfig?.mode && !globalExecConfig?.security && !globalExecConfig?.ask) return; const snapshot = resolveExecPolicyScopeSnapshot({ approvals, scopeExecConfig, globalExecConfig, configPath: params.scopeLabel === "tools.exec" ? "tools.exec" : `agents.list.${params.agentId}.tools.exec`, scopeLabel: params.scopeLabel, agentId: params.agentId }); const securityConfigured = snapshot.security.requestedSource !== defaultRequestedSecuritySource; const askConfigured = snapshot.ask.requestedSource !== defaultRequestedAskSource; const securityConflict = securityConfigured && execSecurityRank(snapshot.security.requested) > execSecurityRank(snapshot.security.effective); const askConflict = askConfigured && execAskRank(snapshot.ask.requested) < execAskRank(snapshot.ask.effective); if (!securityConflict && !askConflict) return; const configParts = []; const hostParts = []; if (securityConflict) { configParts.push(`${snapshot.security.requestedSource}="${snapshot.security.requested}"`); hostParts.push(`${snapshot.security.hostSource}="${snapshot.security.host}"`); } if (askConflict) { configParts.push(`${snapshot.ask.requestedSource}="${snapshot.ask.requested}"`); hostParts.push(`${snapshot.ask.hostSource}="${snapshot.ask.host}"`); } warnings.push([ `- ${params.scopeLabel} is broader than the host exec policy.`, ` Config: ${configParts.join(", ")}`, ` Host: ${hostParts.join(", ")}`, ` Effective host exec stays security="${snapshot.security.effective}" ask="${snapshot.ask.effective}" because the stricter side wins.`, " Headless runs like isolated cron cannot answer approval prompts; align both files or enable Web UI, terminal UI, or chat exec approvals.", ` Inspect with: ${formatCliCommand("openclaw approvals get --gateway")}` ].join("\n")); }; maybeWarn({ scopeLabel: "tools.exec", scopeExecConfig: cfg.tools?.exec }); const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; for (const agent of agents) maybeWarn({ scopeLabel: `agents.list.${agent.id}.tools.exec`, scopeExecConfig: agent.tools?.exec, globalExecConfig: cfg.tools?.exec, agentId: agent.id }); return warnings; } function collectDurableExecApprovalWarnings(cfg) { return []; } function collectExecFilesystemPolicyWarnings(cfg) { return collectExecFilesystemPolicyDriftHits(cfg).map((hit) => [ `- ${hit.scopeLabel}: filesystem write tools are disabled, but exec is still available.`, ` Runtime tools: ${hit.runtimeTools.join(", ")}; disabled filesystem tools: ${hit.disabledFilesystemTools.join(", ")}.`, ` Effective exec host is "${hit.execHost}" with sandbox.mode="${hit.sandboxMode}" and workspaceAccess="${hit.sandboxWorkspaceAccess}".`, " The exec shell can still write wherever that host or sandbox filesystem permits.", " For read-only agents, also deny exec/process; otherwise use sandbox mode \"all\" with workspaceAccess \"ro\" or \"none\"." ].join("\n")); } function collectPlaintextConfigSecretWarnings(cfg) { const plaintextPaths = []; const defaults = cfg.secrets?.defaults; for (const target of discoverConfigSecretTargets(cfg)) { if (!target.entry.includeInAudit) continue; if (target.entry.id === "models.providers.*.headers.*" && !isLikelySensitiveModelProviderHeaderName(target.pathSegments.at(-1) ?? "")) continue; const { ref } = resolveSecretInputRef({ value: target.value, refValue: target.refValue, defaults }); if (ref) continue; if (!hasConfiguredPlaintextSecretValue(target.value, target.entry.expectedResolvedValue)) continue; plaintextPaths.push(target.path); } if (plaintextPaths.length === 0) return []; const samplePaths = plaintextPaths.slice(0, 5); const extraCount = plaintextPaths.length - samplePaths.length; return [ "- WARNING: openclaw.json contains plaintext secret-bearing config fields.", ` Paths: ${extraCount > 0 ? `${samplePaths.join(", ")} (+${extraCount} more)` : samplePaths.join(", ")}`, " Agents or workspace tools that can read config files may see these API keys/tokens.", ` Migrate them to SecretRefs with ${formatCliCommand("openclaw secrets configure")} or ${formatCliCommand("openclaw secrets apply")}, then verify with ${formatCliCommand("openclaw secrets audit --check")}.` ]; } /** Collects doctor security warnings without emitting terminal notes. */ async function collectSecurityWarnings(cfg, env = process.env) { const warnings = []; if (cfg.approvals?.exec?.enabled === false) warnings.push("- Note: approvals.exec.enabled=false disables approval forwarding only.", ` Host exec gating still comes from ${resolveExecApprovalsDisplayPath()}.`, ` Check local policy with: ${formatCliCommand("openclaw approvals get --gateway")}`); warnings.push(...collectImplicitHeartbeatDirectPolicyWarnings(cfg)); warnings.push(...collectExecPolicyConflictWarnings(cfg)); warnings.push(...collectExecFilesystemPolicyWarnings(cfg)); warnings.push(...collectPlaintextConfigSecretWarnings(cfg)); warnings.push(...collectDurableExecApprovalWarnings(cfg)); const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; const gatewayBind = cfg.gateway?.bind ?? "loopback"; const customBindHost = cfg.gateway?.customBindHost?.trim(); const bindMode = [ "auto", "lan", "loopback", "custom", "tailnet" ].includes(gatewayBind) ? gatewayBind : void 0; const resolvedBindHost = bindMode ? await resolveGatewayBindHost(bindMode, customBindHost) : "0.0.0.0"; const isExposed = !isLoopbackHost(resolvedBindHost); const resolvedAuth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env, tailscaleMode }); const authToken = normalizeOptionalString(resolvedAuth.token) ?? ""; const authPassword = normalizeOptionalString(resolvedAuth.password) ?? ""; 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 warnings.push(`- WARNING: Gateway bound to ${bindDescriptor} (network-accessible).`, ` Ensure your auth credentials are strong and not exposed.`, ...saferRemoteAccessLines); const tokenConflict = resolveGatewayAuthTokenSourceConflict({ cfg, env }); if (tokenConflict) warnings.push(...tokenConflict.warningLines); const warnDmPolicy = async (params) => { const dmPolicy = params.dmPolicy; const policyPath = params.policyPath ?? `${params.allowFromPath}policy`; const { hasWildcard, allowCount, isMultiUserDm } = await resolveDmAllowAuditState({ provider: params.provider, accountId: params.accountId, allowFrom: params.allowFrom, dmPolicy, 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 listReadOnlyChannelPluginsForConfig(cfg, { includePersistedAuthState: true, includeSetupFallbackPlugins: true })) { if (!plugin.security) continue; const { defaultAccountId, account, enabled, configured, diagnostics } = await resolveDefaultChannelAccountContext(plugin, cfg, { mode: "read_only", commandName: "doctor" }); for (const diagnostic of diagnostics) warnings.push(`- [secrets] ${diagnostic}`); 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); } } return warnings; } /** Emits security warnings plus the deep audit follow-up command. */ async function noteSecurityWarnings(cfg) { const warnings = await collectSecurityWarnings(cfg); const auditHint = `- Run: ${formatCliCommand("openclaw security audit --deep")}`; const lines = warnings.length > 0 ? warnings : ["- No channel security warnings detected."]; lines.push(auditHint); note(lines.join("\n"), "Security"); } //#endregion export { collectSecurityWarnings, noteSecurityWarnings };