openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
298 lines (297 loc) • 14.5 kB
JavaScript
import { l as normalizeOptionalString } from "./string-coerce-CIXf7egm.js";
import { t as formatCliCommand } from "./command-format-C7YfyMTd.js";
import { l as hasConfiguredSecretInput, v as resolveSecretInputRef } from "./types.secrets-kC0nOetj.js";
import { p as hasConfiguredPlaintextSecretValue } from "./runtime-shared-CqbaocQu.js";
import { n as discoverConfigSecretTargets } from "./target-registry-query-qVhyD-Hq.js";
import "./target-registry-BHDUOLUU.js";
import { g as resolveGatewayBindHost, s as isLoopbackHost } from "./net-DbNPs6Xm.js";
import { n as resolveGatewayAuth } from "./auth-resolve-O5AKX-sb.js";
import "./auth-CyN_wFeb.js";
import { c as resolveExecApprovalsDisplayPath } from "./exec-approvals-config-D1lCGl0_.js";
import "./exec-approvals-BSZ-fPIY.js";
import { t as ExecApprovalsMigrationRequiredError } from "./exec-approvals-migration-gate-B8vlIh80.js";
import { a as loadExecApprovalsReadOnly } from "./exec-approvals-store-DBR0neS0.js";
import { t as countObsoleteGeneratedExecApprovals } from "./exec-approvals-generated-migration-ByaAcCAT.js";
import { t as note } from "./note-DqHk3fA1.js";
import { t as listReadOnlyChannelPluginsForConfig } from "./read-only-0zbnWxaR.js";
import { r as resolveExecPolicyScopeSnapshot } from "./exec-approvals-effective-DGNgYRIx.js";
import { t as resolveGatewayAuthTokenSourceConflict } from "./auth-token-source-conflict-C5ureq4M.js";
import { t as collectExecFilesystemPolicyDriftHits } from "./exec-filesystem-policy-B4K_yFM5.js";
import { t as isLikelySensitiveModelProviderHeaderName } from "./model-provider-header-policy-DaO5ykFx.js";
import { t as collectChannelSecurityFindingsCore } from "./audit-channel-CddRCCZl.js";
//#region src/commands/doctor-security.ts
/** Security warnings for gateway exposure, exec policy drift, channel DMs, and plaintext secrets. */
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"
});
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 findings;
}
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, 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 && 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 = [];
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 = [];
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 [{
checkId: "config.plaintext_secrets",
severity: "warn",
title: "WARNING",
detail: "openclaw.json contains plaintext secret-bearing config fields.",
remediation: [
`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")}.`
].join("\n")
}];
}
/** Collects doctor security findings without emitting terminal notes. */
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));
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) {
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;
}
/** Emits security warnings plus the deep audit follow-up command. */
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");
}
}
//#endregion
export { collectSecurityWarnings, noteSecurityWarnings };