UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

279 lines (278 loc) • 14.3 kB
import { l as normalizeOptionalString } from "./string-coerce-1VuiLaaR.mjs"; import { g as resolveSecretInputRef, o as hasConfiguredSecretInput } from "./types.secrets-DsPRn9Dz.mjs"; import { t as formatCliCommand } from "./command-format-DwEdrDKL.mjs"; import "./agent-scope-config-CDYpzA0r.mjs"; import { r as listAgentEntriesWithSource } from "./agent-roster-BXD1uMJD.mjs"; import { n as discoverConfigSecretTargets } from "./target-registry-query-djbCpoM3.mjs"; import "./target-registry-C9njyDoW.mjs"; import { p as resolveExecApprovalsDisplayPath, t as ExecApprovalsMigrationRequiredError } from "./exec-approvals-migration-gate-4cEEb67t.mjs"; import { i as loadExecApprovalsReadOnly } from "./exec-approvals-store-DGp2moY9.mjs"; import { t as countObsoleteGeneratedExecApprovals } from "./exec-approvals-generated-migration-CnSZXIu2.mjs"; import "./exec-approvals-B1rZTeax.mjs"; import { g as resolveGatewayBindHost, s as isLoopbackHost } from "./net-C6WEu3pd.mjs"; import { t as note } from "./note-Byxgt4MB.mjs"; import { r as resolveGatewayAuth } from "./auth-resolve-BmUDP1m_.mjs"; import { t as listReadOnlyChannelPluginsForConfig } from "./read-only-BUz6WPkw.mjs"; import "./auth-BfNXsaOg.mjs"; import { r as resolveExecPolicyScopeSnapshot } from "./exec-approvals-effective-Cqfx27kD.mjs"; import { t as resolveGatewayAuthTokenSourceConflict } from "./auth-token-source-conflict-C__DDKAa.mjs"; import { t as collectExecFilesystemPolicyDriftHits } from "./exec-filesystem-policy-CmbF81kP.mjs"; import { r as findSecretStoreRedactedValueFindings, t as classifyConfigSecretTarget } from "./config-secret-target-iGc3GXCQ.mjs"; import { t as collectChannelSecurityFindingsCore } from "./audit-channel-BsBNTNw1.mjs"; //#region src/commands/doctor-security.ts function collectImplicitHeartbeatDirectPolicyWarnings(cfg) { const findings = []; const maybeWarn = (params) => { const heartbeat = params.heartbeat; if (!heartbeat || heartbeat.target === void 0 || heartbeat.target === "none") return; if (heartbeat.directPolicy !== void 0) return; findings.push({ checkId: "doctor.heartbeat_direct_policy_unset", severity: "warn", title: params.label, detail: `heartbeat delivery is configured while ${params.pathHint} is unset.`, remediation: "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 { entry: agent, source } of listAgentEntriesWithSource(cfg)) maybeWarn({ label: `Heartbeat agent "${agent.id}"`, heartbeat: agent.heartbeat, pathHint: source.kind === "entries" ? `agents.entries.${source.key}.heartbeat.directPolicy` : `heartbeat.directPolicy for agent "${agent.id}"` }); return findings; } function collectExecPolicyConflictWarnings(cfg, approvals) { const findings = []; 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.entries.${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 && snapshot.security.requested !== snapshot.security.effective; const askConflict = askConfigured && snapshot.ask.requested !== snapshot.ask.effective; if (!securityConflict && !askConflict) return; const configParts = []; const hostParts = []; const canonicalModeSource = snapshot.security.requestedSource === snapshot.ask.requestedSource && snapshot.security.requestedSource.endsWith(".mode") ? snapshot.security.requestedSource : void 0; if (canonicalModeSource) configParts.push(`${canonicalModeSource}="${snapshot.mode.requested}"`); if (securityConflict) { if (!canonicalModeSource) configParts.push(`${snapshot.security.requestedSource}="${snapshot.security.requested}"`); hostParts.push(`${snapshot.security.hostSource}="${snapshot.security.host}"`); } if (askConflict) { if (!canonicalModeSource) configParts.push(`${snapshot.ask.requestedSource}="${snapshot.ask.requested}"`); hostParts.push(`${snapshot.ask.hostSource}="${snapshot.ask.host}"`); } findings.push({ checkId: "doctor.exec_policy_conflict", severity: "warn", title: `${params.scopeLabel} is broader than the host exec policy.`, detail: "", remediation: [ `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 keep the Control UI or a macOS/iOS/Android app connected so gateway automation runs can raise approval cards.", `Inspect with: ${formatCliCommand("openclaw approvals get --gateway")}` ].join("\n") }); }; maybeWarn({ scopeLabel: "tools.exec", scopeExecConfig: cfg.tools?.exec }); const agents = cfg.agents?.entries ?? {}; for (const [agentId, agent] of Object.entries(agents)) maybeWarn({ scopeLabel: `agents.entries.${agentId}.tools.exec`, scopeExecConfig: agent.tools?.exec, globalExecConfig: cfg.tools?.exec, agentId }); return findings; } function collectDurableExecApprovalWarnings(approvals) { const count = countObsoleteGeneratedExecApprovals(approvals); if (count === 0) return []; return [{ checkId: "doctor.exec_approvals_require_cwd_renewal", severity: "warn", title: "Exec approvals need renewal", detail: `${count} older generated ${count === 1 ? "approval is" : "approvals are"} inactive because they are not tied to a working directory.`, remediation: [ `Run ${formatCliCommand("openclaw doctor --fix")} to remove the inactive entries.`, "Then rerun affected workflows and choose \"Always allow here\" when prompted.", "Manual allowlist rules are unchanged." ].join("\n") }]; } function collectExecFilesystemPolicyWarnings(cfg) { return collectExecFilesystemPolicyDriftHits(cfg).map((hit) => ({ checkId: "doctor.exec_filesystem_policy", severity: "warn", title: hit.scopeLabel, detail: "filesystem write tools are disabled, but exec is still available.", remediation: [ `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 = []; for (const target of discoverConfigSecretTargets(cfg)) if (classifyConfigSecretTarget(cfg, target).plaintext) plaintextPaths.push(target.path); if (plaintextPaths.length === 0) return []; const samplePaths = plaintextPaths.slice(0, 5); const extraCount = plaintextPaths.length - samplePaths.length; const pathLine = extraCount > 0 ? `${samplePaths.join(", ")} (+${extraCount} more)` : samplePaths.join(", "); return [{ checkId: "config.plaintext_secrets", severity: "warn", title: "WARNING", detail: "openclaw.json contains plaintext secret-bearing config fields.", remediation: [ `Migrate them to SecretRefs with ${formatCliCommand("openclaw secrets configure")} or ${formatCliCommand("openclaw secrets apply")}, then verify with ${formatCliCommand("openclaw secrets audit --check")}.`, `Paths: ${pathLine}`, "Agents or workspace tools that can read config files may see these API keys/tokens." ].join("\n") }]; } async function collectSecurityWarnings(cfg, env = process.env) { const findings = []; if (cfg.approvals?.exec?.enabled === false) findings.push({ checkId: "doctor.approval_forwarding_disabled", severity: "warn", title: "Note", detail: "approvals.exec.enabled=false disables approval forwarding only.", remediation: [`Host exec gating still comes from ${resolveExecApprovalsDisplayPath()}.`, `Check local policy with: ${formatCliCommand("openclaw approvals get --gateway")}`].join("\n") }); findings.push(...collectImplicitHeartbeatDirectPolicyWarnings(cfg)); let approvals; try { approvals = loadExecApprovalsReadOnly(); } catch (error) { if (!(error instanceof ExecApprovalsMigrationRequiredError)) throw error; } if (approvals) findings.push(...collectExecPolicyConflictWarnings(cfg, approvals)); findings.push(...collectExecFilesystemPolicyWarnings(cfg)); findings.push(...collectPlaintextConfigSecretWarnings(cfg)); const gatewayTokenRef = resolveSecretInputRef({ value: cfg.gateway?.auth?.token, defaults: cfg.secrets?.defaults }).ref; findings.push(...(await findSecretStoreRedactedValueFindings({ database: { env } })).map((finding) => ({ checkId: "doctor.secret_store_redacted_value", severity: "warn", title: "Unavailable credential", detail: finding.message, remediation: gatewayTokenRef?.source === "store" && gatewayTokenRef.id === finding.name ? "Run `openclaw doctor --fix` to regenerate the Gateway token, then restart and reconnect or re-pair devices." : `This credential remains unavailable until replaced. Run \`openclaw secrets store set ${finding.name}\` with the real credential, then \`openclaw secrets reload\`.` }))); if (approvals) findings.push(...collectDurableExecApprovalWarnings(approvals)); 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 && resolvedAuth.mode !== "trusted-proxy") { 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")}`]; findings.push({ checkId: "gateway.bind_no_auth", severity: "critical", title: "CRITICAL", detail: [`Gateway bound to ${bindDescriptor} without authentication.`, "Anyone on your network (or internet if port-forwarded) can fully control your agent."].join("\n"), remediation: [ `Fix: ${formatCliCommand("openclaw config set gateway.bind loopback")}`, ...saferRemoteAccessLines, ...authFixLines ].join("\n") }); } else findings.push({ checkId: "gateway.bind_network_accessible", severity: "warn", title: "WARNING", detail: [`Gateway bound to ${bindDescriptor} (network-accessible).`, "Ensure your auth credentials are strong and not exposed."].join("\n"), remediation: saferRemoteAccessLines.join("\n") }); } const tokenConflict = resolveGatewayAuthTokenSourceConflict({ cfg, env }); if (tokenConflict) findings.push({ checkId: tokenConflict.checkId, severity: tokenConflict.severity, title: "WARNING", detail: `${tokenConflict.title}.\n${tokenConflict.detail}`, remediation: `Fix: ${tokenConflict.remediation}` }); const channelFindings = await collectChannelSecurityFindingsCore({ cfg, mode: "doctor", plugins: listReadOnlyChannelPluginsForConfig(cfg, { includePersistedAuthState: true, includeSetupFallbackPlugins: true }) }); findings.push(...channelFindings); return findings; } function renderSecurityFindingLines(finding) { const detailLines = finding.detail.split("\n"); const firstDetail = detailLines.shift() ?? ""; const lines = [`- ${finding.title}${firstDetail ? `: ${firstDetail}` : ""}`]; lines.push(...detailLines.map((line) => ` ${line}`)); if (finding.remediation) lines.push(...finding.remediation.split("\n").map((line) => ` ${line}`)); return lines; } async function noteSecurityWarnings(cfg) { const findings = await collectSecurityWarnings(cfg); if (findings.length > 0) { const lines = findings.flatMap(renderSecurityFindingLines); lines.push(`- Run: ${formatCliCommand("openclaw security audit --deep")}`); note(lines.join("\n"), "Security"); } return findings; } //#endregion export { collectSecurityWarnings, noteSecurityWarnings };