Files
openclaw/src/commands/doctor-security.ts
2026-04-02 14:52:51 +01:00

355 lines
12 KiB
TypeScript

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 { resolveExecPolicyScopeSnapshot } from "../infra/exec-approvals-effective.js";
import { loadExecApprovals, type ExecAsk, type ExecSecurity } from "../infra/exec-approvals.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;
}
function execSecurityRank(value: ExecSecurity): number {
switch (value) {
case "deny":
return 0;
case "allowlist":
return 1;
case "full":
return 2;
}
}
function execAskRank(value: ExecAsk): number {
switch (value) {
case "off":
return 0;
case "on-miss":
return 1;
case "always":
return 2;
}
}
function collectExecPolicyConflictWarnings(cfg: OpenClawConfig): string[] {
const warnings: string[] = [];
const approvals = loadExecApprovals();
const defaultRequestedSecuritySource = "OpenClaw default (full)";
const defaultRequestedAskSource = "OpenClaw default (off)";
const maybeWarn = (params: {
scopeLabel: string;
scopeExecConfig: { security?: ExecSecurity; ask?: ExecAsk } | undefined;
globalExecConfig?: { security?: ExecSecurity; ask?: ExecAsk } | undefined;
agentId?: string;
}) => {
const scopeExecConfig = params.scopeExecConfig;
const globalExecConfig = params.globalExecConfig;
if (
!scopeExecConfig?.security &&
!scopeExecConfig?.ask &&
!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: string[] = [];
const hostParts: string[] = [];
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,
});
for (const agent of cfg.agents?.list ?? []) {
maybeWarn({
scopeLabel: `agents.list.${agent.id}.tools.exec`,
scopeExecConfig: agent.tools?.exec,
globalExecConfig: cfg.tools?.exec,
agentId: agent.id,
});
}
return warnings;
}
function collectDurableExecApprovalWarnings(cfg: OpenClawConfig): string[] {
void cfg;
return [];
}
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));
warnings.push(...collectExecPolicyConflictWarnings(cfg));
warnings.push(...collectDurableExecApprovalWarnings(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<string | number> | 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, 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);
}
}
}
const lines = warnings.length > 0 ? warnings : ["- No channel security warnings detected."];
lines.push(auditHint);
note(lines.join("\n"), "Security");
}