mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-06 05:51:36 +00:00
* fix(gateway): align configured local credentials * test(gateway): align local credential coverage * chore(gateway): leave release note to release process
1521 lines
55 KiB
TypeScript
1521 lines
55 KiB
TypeScript
// Orchestrates security audit collection and report formatting.
|
|
import path from "node:path";
|
|
import { asNullableRecord } from "@openclaw/normalization-core/record-coerce";
|
|
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
|
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
|
|
import { hasAgentRosterProperty, listAgentEntries } from "../agents/agent-scope-config.js";
|
|
import { resolveAgentWorkspaceDir, tryResolveDefaultAgentId } from "../agents/agent-scope.js";
|
|
import { resolveExecDefaults } from "../agents/exec-defaults.js";
|
|
import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js";
|
|
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace-default.js";
|
|
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
|
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/config.js";
|
|
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
|
|
import type { GatewayAuthConfig } from "../config/types.gateway.js";
|
|
import type { SecurityAuditSuppression } from "../config/types.openclaw.js";
|
|
import {
|
|
canMaterializeGatewayAuthSecretRefsWithoutExec,
|
|
materializeGatewayAuthSecretRefs,
|
|
} from "../gateway/auth-config-utils.js";
|
|
import { isInterpreterLikeAllowlistPattern } from "../infra/command-analysis/inline-eval.js";
|
|
import { emitTrustedSecurityEvent } from "../infra/diagnostic-events.js";
|
|
import {
|
|
type ExecApprovalsFile,
|
|
loadExecApprovals,
|
|
resolveExecModePolicy,
|
|
} from "../infra/exec-approvals.js";
|
|
import {
|
|
normalizeConfiguredSafeBins,
|
|
normalizeConfiguredTrustedSafeBinDirs,
|
|
} from "../infra/exec-safe-bin-config.js";
|
|
import {
|
|
listInterpreterLikeSafeBins,
|
|
resolveMergedSafeBinProfileFixtures,
|
|
} from "../infra/exec-safe-bin-runtime-policy.js";
|
|
import { listRiskyConfiguredSafeBins } from "../infra/exec-safe-bin-semantics.js";
|
|
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
|
|
import { readControlUiDeviceAuthMigrationState } from "../state/control-ui-device-auth-migration.js";
|
|
import { resolveUserPath } from "../utils.js";
|
|
import { collectDeepCodeSafetyFindings } from "./audit-deep-code-safety.js";
|
|
import { collectDeepProbeFindings } from "./audit-deep-probe-findings.js";
|
|
import {
|
|
formatPermissionDetail,
|
|
formatPermissionRemediation,
|
|
inspectPathPermissions,
|
|
} from "./audit-fs.js";
|
|
import { collectGatewayConfigFindings as collectGatewayConfigFindingsBase } from "./audit-gateway-config.js";
|
|
import {
|
|
readBoundedMcporterRegistry,
|
|
type McporterRegistryReadOutcome,
|
|
type McporterRegistryRejectReason,
|
|
} from "./audit-mcporter-registry.js";
|
|
import type {
|
|
SecurityAuditFinding,
|
|
SecurityAuditReport,
|
|
SecurityAuditSummary,
|
|
SecurityAuditSuppressedFinding,
|
|
} from "./audit.types.js";
|
|
import { collectEnabledInsecureOrDangerousFlags } from "./dangerous-config-flags.js";
|
|
import { collectExecFilesystemPolicyDriftHits } from "./exec-filesystem-policy.js";
|
|
import type { ExecFn } from "./windows-acl.js";
|
|
|
|
type ExecDockerRawFn = typeof import("../agents/sandbox/docker.js").execDockerRaw;
|
|
type ProbeGatewayFn = typeof import("../gateway/probe.js").probeGateway;
|
|
type SecurityAuditExplicitGatewayAuth = {
|
|
token?: string;
|
|
password?: string;
|
|
};
|
|
type SecurityAuditGatewayAuthOverride = Pick<GatewayAuthConfig, "mode" | "token" | "password">;
|
|
type McpServerSourceSummary = {
|
|
label: string;
|
|
names: string[];
|
|
};
|
|
type AgentSkillMcpBoundaryScope = {
|
|
id: string;
|
|
skillSource: string;
|
|
execHost: string;
|
|
execSecurity: string;
|
|
execAsk: string;
|
|
};
|
|
type AgentSkillMcpBoundaryCandidate =
|
|
| { kind: "defaults"; id: "agents.defaults"; skillSource: string }
|
|
| { kind: "agent"; id: string; skillSource: string; agentId: string };
|
|
|
|
export type { SecurityAuditReport } from "./audit.types.js";
|
|
|
|
type SecurityAuditOptions = {
|
|
config: OpenClawConfig;
|
|
sourceConfig?: OpenClawConfig;
|
|
env?: NodeJS.ProcessEnv;
|
|
platform?: NodeJS.Platform;
|
|
deep?: boolean;
|
|
includeFilesystem?: boolean;
|
|
includeChannelSecurity?: boolean;
|
|
/** Override where to check state (default: resolveStateDir()). */
|
|
stateDir?: string;
|
|
/** Override config path check (default: resolveConfigPath()). */
|
|
configPath?: string;
|
|
/** Time limit for deep gateway probe. */
|
|
deepTimeoutMs?: number;
|
|
/** Dependency injection for tests. */
|
|
plugins?: ChannelPlugin[];
|
|
/** Whether to import plugin modules to discover plugin security audit collectors. */
|
|
loadPluginSecurityCollectors?: boolean;
|
|
/** Dependency injection for tests (Windows ACL checks). */
|
|
execIcacls?: ExecFn;
|
|
/** Dependency injection for tests (Docker label checks). */
|
|
execDockerRawFn?: ExecDockerRawFn;
|
|
/** Optional preloaded config snapshot to skip audit-time config file reads. */
|
|
configSnapshot?: ConfigFileSnapshot | null;
|
|
/** Optional cache for code-safety summaries across repeated deep audits. */
|
|
codeSafetySummaryCache?: Map<string, Promise<unknown>>;
|
|
/** Optional explicit auth for deep gateway probe. */
|
|
deepProbeAuth?: SecurityAuditExplicitGatewayAuth;
|
|
/** Optional explicit Gateway auth mode/secret for config-only audit checks. */
|
|
auditGatewayAuthOverride?: SecurityAuditGatewayAuthOverride;
|
|
/** Override workspace used for workspace plugin discovery. */
|
|
workspaceDir?: string;
|
|
/** Dependency injection for tests. */
|
|
probeGatewayFn?: ProbeGatewayFn;
|
|
};
|
|
|
|
type AuditExecutionContext = {
|
|
cfg: OpenClawConfig;
|
|
sourceConfig: OpenClawConfig;
|
|
env: NodeJS.ProcessEnv;
|
|
platform: NodeJS.Platform;
|
|
includeFilesystem: boolean;
|
|
includeChannelSecurity: boolean;
|
|
deep: boolean;
|
|
deepTimeoutMs: number;
|
|
stateDir: string;
|
|
configPath: string;
|
|
execIcacls?: ExecFn;
|
|
execDockerRawFn?: ExecDockerRawFn;
|
|
probeGatewayFn?: ProbeGatewayFn;
|
|
plugins?: ChannelPlugin[];
|
|
loadPluginSecurityCollectors: boolean;
|
|
configSnapshot: ConfigFileSnapshot | null;
|
|
codeSafetySummaryCache: Map<string, Promise<unknown>>;
|
|
deepProbeAuth?: SecurityAuditExplicitGatewayAuth;
|
|
auditGatewayAuthOverride?: SecurityAuditGatewayAuthOverride;
|
|
workspaceDir?: string;
|
|
};
|
|
|
|
const loadReadOnlyChannelPlugins = createLazyRuntimeModule(
|
|
() => import("../channels/plugins/read-only.js"),
|
|
);
|
|
|
|
const loadAuditNonDeepModule = createLazyRuntimeModule(() => import("./audit.nondeep.runtime.js"));
|
|
|
|
const loadAuditChannelModule = createLazyRuntimeModule(
|
|
() => import("./audit-channel.collect.runtime.js"),
|
|
);
|
|
|
|
const loadPluginMetadataRegistryLoaderModule = createLazyRuntimeModule(
|
|
() => import("../plugins/runtime/metadata-registry-loader.js"),
|
|
);
|
|
|
|
const loadPluginAutoEnableModule = createLazyRuntimeModule(
|
|
() => import("../config/plugin-auto-enable.js"),
|
|
);
|
|
|
|
const loadChannelPluginIdsModule = createLazyRuntimeModule(
|
|
() => import("../plugins/channel-plugin-ids.js"),
|
|
);
|
|
|
|
const loadPluginRuntimeModule = createLazyRuntimeModule(() => import("../plugins/runtime.js"));
|
|
|
|
const loadGatewayProbeDeps = createLazyRuntimeModule(() =>
|
|
Promise.all([
|
|
import("../gateway/call.js"),
|
|
import("../gateway/probe-auth.js"),
|
|
import("../gateway/probe.js"),
|
|
]).then(([callModule, probeAuthModule, probeModule]) => ({
|
|
buildGatewayConnectionDetails: callModule.buildGatewayConnectionDetails,
|
|
resolveGatewayProbeAuthSafe: probeAuthModule.resolveGatewayProbeAuthSafe,
|
|
resolveGatewayProbeTarget: probeAuthModule.resolveGatewayProbeTarget,
|
|
probeGateway: probeModule.probeGateway,
|
|
})),
|
|
);
|
|
|
|
function countBySeverity(findings: SecurityAuditFinding[]): SecurityAuditSummary {
|
|
let critical = 0;
|
|
let warn = 0;
|
|
let info = 0;
|
|
for (const f of findings) {
|
|
if (f.severity === "critical") {
|
|
critical += 1;
|
|
} else if (f.severity === "warn") {
|
|
warn += 1;
|
|
} else {
|
|
info += 1;
|
|
}
|
|
}
|
|
return { critical, warn, info };
|
|
}
|
|
|
|
function emitSecurityAuditReportEvent(params: {
|
|
summary: SecurityAuditSummary;
|
|
deep: boolean;
|
|
includeFilesystem: boolean;
|
|
includeChannelSecurity: boolean;
|
|
suppressedCount: number;
|
|
}) {
|
|
const hasCritical = params.summary.critical > 0;
|
|
const hasWarnings = params.summary.warn > 0;
|
|
emitTrustedSecurityEvent({
|
|
category: "audit",
|
|
action: "security.audit.completed",
|
|
outcome: hasCritical || hasWarnings ? "failure" : "success",
|
|
severity: hasCritical ? "critical" : hasWarnings ? "medium" : "info",
|
|
actor: {
|
|
kind: "operator",
|
|
},
|
|
target: {
|
|
kind: "config",
|
|
name: "security.audit",
|
|
},
|
|
policy: {
|
|
id: "security.audit",
|
|
decision: "not_applicable",
|
|
},
|
|
control: {
|
|
id: "security.audit",
|
|
family: "authorization",
|
|
},
|
|
attributes: {
|
|
critical_count: params.summary.critical,
|
|
warn_count: params.summary.warn,
|
|
info_count: params.summary.info,
|
|
suppressed_count: params.suppressedCount,
|
|
deep: params.deep,
|
|
include_filesystem: params.includeFilesystem,
|
|
include_channel_security: params.includeChannelSecurity,
|
|
},
|
|
});
|
|
}
|
|
|
|
function normalizeSuppressionText(value: string | undefined): string {
|
|
return (value ?? "").trim().toLowerCase();
|
|
}
|
|
|
|
async function materializeAuditGatewayAuthRefs(params: {
|
|
cfg: OpenClawConfig;
|
|
env: NodeJS.ProcessEnv;
|
|
}): Promise<OpenClawConfig> {
|
|
const materializeParams = {
|
|
cfg: params.cfg,
|
|
env: params.env,
|
|
mode: params.cfg.gateway?.auth?.mode,
|
|
hasTokenOverride: false,
|
|
hasPasswordOverride: false,
|
|
hasTokenFallback: Boolean(normalizeOptionalString(params.env.OPENCLAW_GATEWAY_TOKEN)),
|
|
hasPasswordFallback: Boolean(normalizeOptionalString(params.env.OPENCLAW_GATEWAY_PASSWORD)),
|
|
};
|
|
if (!canMaterializeGatewayAuthSecretRefsWithoutExec(materializeParams)) {
|
|
return params.cfg;
|
|
}
|
|
try {
|
|
return await materializeGatewayAuthSecretRefs(materializeParams);
|
|
} catch {
|
|
return params.cfg;
|
|
}
|
|
}
|
|
|
|
function shouldMaterializeHooksGatewayAuthRefs(cfg: OpenClawConfig): boolean {
|
|
return cfg.hooks?.enabled === true && Boolean(normalizeOptionalString(cfg.hooks.token));
|
|
}
|
|
|
|
function findingMatchesSuppression(
|
|
finding: SecurityAuditFinding,
|
|
suppression: SecurityAuditSuppression,
|
|
): boolean {
|
|
const checkId = suppression.checkId.trim();
|
|
if (!checkId || finding.checkId !== checkId) {
|
|
return false;
|
|
}
|
|
const titleNeedle = normalizeSuppressionText(suppression.titleIncludes);
|
|
if (titleNeedle && !finding.title.toLowerCase().includes(titleNeedle)) {
|
|
return false;
|
|
}
|
|
const detailNeedle = normalizeSuppressionText(suppression.detailIncludes);
|
|
if (detailNeedle && !finding.detail.toLowerCase().includes(detailNeedle)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function buildSecurityAuditSuppressionsActiveFinding(params: {
|
|
configuredCount: number;
|
|
suppressedCount: number;
|
|
}): SecurityAuditFinding {
|
|
return {
|
|
checkId: "security.audit.suppressions.active",
|
|
severity: "info",
|
|
title: "Security audit suppressions configured",
|
|
detail:
|
|
`security.audit.suppressions has ${params.configuredCount} configured suppression(s); ` +
|
|
`${params.suppressedCount} finding(s) moved to suppressedFindings.`,
|
|
remediation:
|
|
"Review suppressedFindings and remove suppressions when the accepted risk no longer applies.",
|
|
};
|
|
}
|
|
|
|
function applySecurityAuditSuppressions(
|
|
findings: SecurityAuditFinding[],
|
|
suppressions: SecurityAuditSuppression[] | undefined,
|
|
): { findings: SecurityAuditFinding[]; suppressedFindings: SecurityAuditSuppressedFinding[] } {
|
|
if (!Array.isArray(suppressions) || suppressions.length === 0) {
|
|
return { findings, suppressedFindings: [] };
|
|
}
|
|
const active: SecurityAuditFinding[] = [];
|
|
const suppressedFindings: SecurityAuditSuppressedFinding[] = [];
|
|
for (const finding of findings) {
|
|
const suppression = suppressions.find((candidate) =>
|
|
findingMatchesSuppression(finding, candidate),
|
|
);
|
|
if (!suppression) {
|
|
active.push(finding);
|
|
continue;
|
|
}
|
|
const reason = suppression.reason?.trim();
|
|
suppressedFindings.push({
|
|
...finding,
|
|
suppression: reason ? { reason } : {},
|
|
});
|
|
}
|
|
return { findings: active, suppressedFindings };
|
|
}
|
|
|
|
function normalizeAllowFromList(list: Array<string | number> | undefined | null): string[] {
|
|
if (!Array.isArray(list)) {
|
|
return [];
|
|
}
|
|
return normalizeStringEntries(list);
|
|
}
|
|
|
|
async function collectFilesystemFindings(params: {
|
|
stateDir: string;
|
|
configPath: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
platform?: NodeJS.Platform;
|
|
execIcacls?: ExecFn;
|
|
}): Promise<SecurityAuditFinding[]> {
|
|
const findings: SecurityAuditFinding[] = [];
|
|
|
|
const stateDirPerms = await inspectPathPermissions(params.stateDir, {
|
|
env: params.env,
|
|
platform: params.platform,
|
|
exec: params.execIcacls,
|
|
});
|
|
if (stateDirPerms.ok) {
|
|
if (stateDirPerms.isSymlink) {
|
|
findings.push({
|
|
checkId: "fs.state_dir.symlink",
|
|
severity: "warn",
|
|
title: "State dir is a symlink",
|
|
detail: `${params.stateDir} is a symlink; treat this as an extra trust boundary.`,
|
|
});
|
|
}
|
|
if (stateDirPerms.worldWritable) {
|
|
findings.push({
|
|
checkId: "fs.state_dir.perms_world_writable",
|
|
severity: "critical",
|
|
title: "State dir is world-writable",
|
|
detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; other users can write into your OpenClaw state.`,
|
|
remediation: formatPermissionRemediation({
|
|
targetPath: params.stateDir,
|
|
perms: stateDirPerms,
|
|
isDir: true,
|
|
posixMode: 0o700,
|
|
env: params.env,
|
|
}),
|
|
});
|
|
} else if (stateDirPerms.groupWritable) {
|
|
findings.push({
|
|
checkId: "fs.state_dir.perms_group_writable",
|
|
severity: "warn",
|
|
title: "State dir is group-writable",
|
|
detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; group users can write into your OpenClaw state.`,
|
|
remediation: formatPermissionRemediation({
|
|
targetPath: params.stateDir,
|
|
perms: stateDirPerms,
|
|
isDir: true,
|
|
posixMode: 0o700,
|
|
env: params.env,
|
|
}),
|
|
});
|
|
} else if (stateDirPerms.groupReadable || stateDirPerms.worldReadable) {
|
|
findings.push({
|
|
checkId: "fs.state_dir.perms_readable",
|
|
severity: "warn",
|
|
title: "State dir is readable by others",
|
|
detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; consider restricting to 700.`,
|
|
remediation: formatPermissionRemediation({
|
|
targetPath: params.stateDir,
|
|
perms: stateDirPerms,
|
|
isDir: true,
|
|
posixMode: 0o700,
|
|
env: params.env,
|
|
}),
|
|
});
|
|
}
|
|
}
|
|
|
|
const configPerms = await inspectPathPermissions(params.configPath, {
|
|
env: params.env,
|
|
platform: params.platform,
|
|
exec: params.execIcacls,
|
|
});
|
|
if (configPerms.ok) {
|
|
const skipReadablePermWarnings = configPerms.isSymlink;
|
|
if (configPerms.isSymlink) {
|
|
findings.push({
|
|
checkId: "fs.config.symlink",
|
|
severity: "warn",
|
|
title: "Config file is a symlink",
|
|
detail: `${params.configPath} is a symlink; make sure you trust its target.`,
|
|
});
|
|
}
|
|
if (configPerms.worldWritable || configPerms.groupWritable) {
|
|
findings.push({
|
|
checkId: "fs.config.perms_writable",
|
|
severity: "critical",
|
|
title: "Config file is writable by others",
|
|
detail: `${formatPermissionDetail(params.configPath, configPerms)}; another user could change gateway/auth/tool policies.`,
|
|
remediation: formatPermissionRemediation({
|
|
targetPath: params.configPath,
|
|
perms: configPerms,
|
|
isDir: false,
|
|
posixMode: 0o600,
|
|
env: params.env,
|
|
}),
|
|
});
|
|
} else if (!skipReadablePermWarnings && configPerms.worldReadable) {
|
|
findings.push({
|
|
checkId: "fs.config.perms_world_readable",
|
|
severity: "critical",
|
|
title: "Config file is world-readable",
|
|
detail: `${formatPermissionDetail(params.configPath, configPerms)}; config can contain tokens and private settings.`,
|
|
remediation: formatPermissionRemediation({
|
|
targetPath: params.configPath,
|
|
perms: configPerms,
|
|
isDir: false,
|
|
posixMode: 0o600,
|
|
env: params.env,
|
|
}),
|
|
});
|
|
} else if (!skipReadablePermWarnings && configPerms.groupReadable) {
|
|
findings.push({
|
|
checkId: "fs.config.perms_group_readable",
|
|
severity: "warn",
|
|
title: "Config file is group-readable",
|
|
detail: `${formatPermissionDetail(params.configPath, configPerms)}; config can contain tokens and private settings.`,
|
|
remediation: formatPermissionRemediation({
|
|
targetPath: params.configPath,
|
|
perms: configPerms,
|
|
isDir: false,
|
|
posixMode: 0o600,
|
|
env: params.env,
|
|
}),
|
|
});
|
|
}
|
|
}
|
|
|
|
return findings;
|
|
}
|
|
|
|
function collectGatewayConfigFindings(
|
|
cfg: OpenClawConfig,
|
|
sourceConfig: OpenClawConfig,
|
|
env: NodeJS.ProcessEnv,
|
|
options: { gatewayAuthOverride?: SecurityAuditGatewayAuthOverride } = {},
|
|
): SecurityAuditFinding[] {
|
|
return collectGatewayConfigFindingsBase(cfg, sourceConfig, env, {
|
|
collectDangerousConfigFlags: collectEnabledInsecureOrDangerousFlags,
|
|
gatewayAuthOverride: options.gatewayAuthOverride,
|
|
});
|
|
}
|
|
|
|
function collectControlUiDeviceAuthMigrationFindings(params: {
|
|
env: NodeJS.ProcessEnv;
|
|
stateDir: string;
|
|
}): SecurityAuditFinding[] {
|
|
const migration = readControlUiDeviceAuthMigrationState({
|
|
env: { ...params.env, OPENCLAW_STATE_DIR: params.stateDir },
|
|
});
|
|
if (migration?.status !== "pending") {
|
|
return [];
|
|
}
|
|
return [
|
|
{
|
|
checkId: "gateway.control_ui.device_auth_disabled",
|
|
severity: "critical",
|
|
title: "Control UI device-auth migration is pending",
|
|
detail:
|
|
"The retired device-auth bypass was imported into pending migration state. " +
|
|
"Device-less Control UI sessions with valid shared auth can still connect for remediation until an operator browser completes pairing.",
|
|
remediation:
|
|
"Reopen the Control UI over HTTPS or localhost and click Secure this browser to complete pairing and end the compatibility window.",
|
|
},
|
|
];
|
|
}
|
|
|
|
async function collectPluginSecurityAuditFindings(
|
|
context: AuditExecutionContext,
|
|
): Promise<SecurityAuditFinding[]> {
|
|
if (!context.loadPluginSecurityCollectors) {
|
|
return [];
|
|
}
|
|
const { getActivePluginRegistry } = await loadPluginRuntimeModule();
|
|
let collectors = getActivePluginRegistry()?.securityAuditCollectors ?? [];
|
|
if (collectors.length === 0) {
|
|
const { applyPluginAutoEnable } = await loadPluginAutoEnableModule();
|
|
const autoEnabled = applyPluginAutoEnable({
|
|
config: context.sourceConfig,
|
|
env: context.env,
|
|
});
|
|
const requestedPluginIds = new Set<string>();
|
|
for (const pluginId of Object.keys(autoEnabled.autoEnabledReasons)) {
|
|
const normalized = pluginId.trim();
|
|
if (normalized) {
|
|
requestedPluginIds.add(normalized);
|
|
}
|
|
}
|
|
for (const pluginId of autoEnabled.config.plugins?.allow ?? []) {
|
|
if (typeof pluginId !== "string") {
|
|
continue;
|
|
}
|
|
const normalized = pluginId.trim();
|
|
if (normalized) {
|
|
requestedPluginIds.add(normalized);
|
|
}
|
|
}
|
|
for (const [pluginId, entry] of Object.entries(autoEnabled.config.plugins?.entries ?? {})) {
|
|
if (entry?.enabled === false) {
|
|
continue;
|
|
}
|
|
const normalized = pluginId.trim();
|
|
if (normalized) {
|
|
requestedPluginIds.add(normalized);
|
|
}
|
|
}
|
|
if (context.includeChannelSecurity && context.plugins !== undefined) {
|
|
const { resolveConfiguredChannelPluginIds } = await loadChannelPluginIdsModule();
|
|
const auditedChannelPluginIds = new Set(context.plugins.map((plugin) => plugin.id));
|
|
for (const pluginId of resolveConfiguredChannelPluginIds({
|
|
config: autoEnabled.config,
|
|
activationSourceConfig: context.sourceConfig,
|
|
workspaceDir: context.workspaceDir,
|
|
env: context.env,
|
|
})) {
|
|
if (auditedChannelPluginIds.has(pluginId)) {
|
|
requestedPluginIds.delete(pluginId);
|
|
}
|
|
}
|
|
}
|
|
if (requestedPluginIds.size === 0) {
|
|
return [];
|
|
}
|
|
const snapshot = (
|
|
await loadPluginMetadataRegistryLoaderModule()
|
|
).loadPluginMetadataRegistrySnapshot({
|
|
config: autoEnabled.config,
|
|
activationSourceConfig: context.sourceConfig,
|
|
env: context.env,
|
|
workspaceDir: context.workspaceDir,
|
|
onlyPluginIds: [...requestedPluginIds],
|
|
});
|
|
collectors = snapshot.securityAuditCollectors ?? [];
|
|
}
|
|
const collectorResults = await Promise.all(
|
|
collectors.map(async (entry) => {
|
|
try {
|
|
return await entry.collector({
|
|
config: context.cfg,
|
|
sourceConfig: context.sourceConfig,
|
|
env: context.env,
|
|
stateDir: context.stateDir,
|
|
configPath: context.configPath,
|
|
});
|
|
} catch (err) {
|
|
return [
|
|
{
|
|
checkId: `plugins.${entry.pluginId}.security_audit_failed`,
|
|
severity: "warn" as const,
|
|
title: "Plugin security audit collector failed",
|
|
detail: `${entry.pluginId}: ${String(err)}`,
|
|
},
|
|
];
|
|
}
|
|
}),
|
|
);
|
|
return collectorResults.flat();
|
|
}
|
|
|
|
function collectElevatedFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
|
|
const findings: SecurityAuditFinding[] = [];
|
|
const enabled = cfg.tools?.elevated?.enabled;
|
|
const allowFrom = cfg.tools?.elevated?.allowFrom ?? {};
|
|
const anyAllowFromKeys = Object.keys(allowFrom).length > 0;
|
|
|
|
if (enabled === false) {
|
|
return findings;
|
|
}
|
|
if (!anyAllowFromKeys) {
|
|
return findings;
|
|
}
|
|
|
|
for (const [provider, list] of Object.entries(allowFrom)) {
|
|
const normalized = normalizeAllowFromList(list);
|
|
if (normalized.includes("*")) {
|
|
findings.push({
|
|
checkId: `tools.elevated.allowFrom.${provider}.wildcard`,
|
|
severity: "critical",
|
|
title: "Elevated exec allowlist contains wildcard",
|
|
detail: `tools.elevated.allowFrom.${provider} includes "*" which effectively approves everyone on that channel for elevated mode.`,
|
|
});
|
|
} else if (normalized.length > 25) {
|
|
findings.push({
|
|
checkId: `tools.elevated.allowFrom.${provider}.large`,
|
|
severity: "warn",
|
|
title: "Elevated exec allowlist is large",
|
|
detail: `tools.elevated.allowFrom.${provider} has ${normalized.length} entries; consider tightening elevated access.`,
|
|
});
|
|
}
|
|
}
|
|
|
|
return findings;
|
|
}
|
|
|
|
function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
|
|
const findings: SecurityAuditFinding[] = [];
|
|
const globalExecHost = cfg.tools?.exec?.host;
|
|
const globalStrictInlineEval = cfg.tools?.exec?.strictInlineEval === true;
|
|
const defaultSandboxMode = resolveSandboxConfigForAgent(cfg).mode;
|
|
const defaultHostIsExplicitSandbox = globalExecHost === "sandbox";
|
|
const approvals = loadExecApprovals();
|
|
|
|
if (defaultHostIsExplicitSandbox && defaultSandboxMode === "off") {
|
|
findings.push({
|
|
checkId: "tools.exec.host_sandbox_no_sandbox_defaults",
|
|
severity: "warn",
|
|
title: "Exec host is sandbox but sandbox mode is off",
|
|
detail:
|
|
"tools.exec.host is explicitly set to sandbox while agents.defaults.sandbox.mode=off. " +
|
|
"In this mode, exec fails closed because no sandbox runtime is available.",
|
|
remediation:
|
|
'Enable sandbox mode (`agents.defaults.sandbox.mode="non-main"` or `"all"`) or set tools.exec.host to "gateway" with approvals.',
|
|
});
|
|
}
|
|
|
|
const agents = listAgentEntries(cfg);
|
|
const defaultAgentId = tryResolveDefaultAgentId(cfg);
|
|
const riskyAgents = agents
|
|
.filter(
|
|
(entry) =>
|
|
entry &&
|
|
typeof entry === "object" &&
|
|
typeof entry.id === "string" &&
|
|
entry.tools?.exec?.host === "sandbox" &&
|
|
resolveSandboxConfigForAgent(cfg, entry.id).mode === "off",
|
|
)
|
|
.map((entry) => entry.id)
|
|
.slice(0, 5);
|
|
|
|
if (riskyAgents.length > 0) {
|
|
findings.push({
|
|
checkId: "tools.exec.host_sandbox_no_sandbox_agents",
|
|
severity: "warn",
|
|
title: "Agent exec host uses sandbox while sandbox mode is off",
|
|
detail:
|
|
`agents.entries.*.tools.exec.host is set to sandbox for: ${riskyAgents.join(", ")}. ` +
|
|
"With sandbox mode off, exec fails closed for those agents.",
|
|
remediation:
|
|
'Enable sandbox mode for these agents (`agents.entries.*.sandbox.mode`) or set their tools.exec.host to "gateway".',
|
|
});
|
|
}
|
|
|
|
const effectiveExecScopes = Array.from(
|
|
new Map(
|
|
[
|
|
{
|
|
id: defaultAgentId ?? "global",
|
|
security: resolveExecModePolicy({
|
|
mode: cfg.tools?.exec?.mode,
|
|
security: cfg.tools?.exec?.security ?? "deny",
|
|
ask: cfg.tools?.exec?.ask ?? "off",
|
|
}).security,
|
|
host: cfg.tools?.exec?.host ?? "auto",
|
|
},
|
|
...agents
|
|
.filter(
|
|
(entry): entry is NonNullable<(typeof agents)[number]> =>
|
|
Boolean(entry) && typeof entry === "object" && typeof entry.id === "string",
|
|
)
|
|
.map((entry) => {
|
|
const inherited = resolveExecModePolicy({
|
|
mode: cfg.tools?.exec?.mode,
|
|
security: cfg.tools?.exec?.security ?? "deny",
|
|
ask: cfg.tools?.exec?.ask ?? "off",
|
|
});
|
|
return {
|
|
id: entry.id,
|
|
security: resolveExecModePolicy({
|
|
mode: entry.tools?.exec?.mode,
|
|
security: entry.tools?.exec?.security ?? inherited.security,
|
|
ask: entry.tools?.exec?.ask ?? inherited.ask,
|
|
}).security,
|
|
host: entry.tools?.exec?.host ?? cfg.tools?.exec?.host ?? "auto",
|
|
};
|
|
}),
|
|
].map((entry) => [entry.id, entry] as const),
|
|
).values(),
|
|
);
|
|
const fullExecScopes = effectiveExecScopes.filter((entry) => entry.security === "full");
|
|
const execEnabledScopes = effectiveExecScopes.filter((entry) => entry.security !== "deny");
|
|
const openExecSurfacePaths = collectOpenExecSurfacePaths(cfg);
|
|
|
|
if (fullExecScopes.length > 0) {
|
|
findings.push({
|
|
checkId: "tools.exec.security_full_configured",
|
|
severity: openExecSurfacePaths.length > 0 ? "critical" : "warn",
|
|
title: "Exec security=full is configured",
|
|
detail:
|
|
`Full exec trust is enabled for: ${fullExecScopes.map((entry) => entry.id).join(", ")}.` +
|
|
(openExecSurfacePaths.length > 0
|
|
? ` Open channel access was also detected at:\n${openExecSurfacePaths.map((entry) => `- ${entry}`).join("\n")}`
|
|
: ""),
|
|
remediation:
|
|
'Prefer tools.exec.mode="ask" or "allowlist", and reserve "full" for tightly scoped break-glass agents only.',
|
|
});
|
|
}
|
|
|
|
if (openExecSurfacePaths.length > 0 && execEnabledScopes.length > 0) {
|
|
findings.push({
|
|
checkId: "security.exposure.open_channels_with_exec",
|
|
severity: fullExecScopes.length > 0 ? "critical" : "warn",
|
|
title: "Open channels can reach exec-enabled agents",
|
|
detail:
|
|
`Open DM/group access detected at:\n${openExecSurfacePaths.map((entry) => `- ${entry}`).join("\n")}\n` +
|
|
`Exec-enabled scopes:\n${execEnabledScopes.map((entry) => `- ${entry.id}: security=${entry.security}, host=${entry.host}`).join("\n")}`,
|
|
remediation:
|
|
"Tighten dmPolicy/groupPolicy to pairing or allowlist, or disable exec for agents reachable from shared/public channels.",
|
|
});
|
|
}
|
|
|
|
const execFilesystemPolicyHits = collectExecFilesystemPolicyDriftHits(cfg);
|
|
if (execFilesystemPolicyHits.length > 0) {
|
|
findings.push({
|
|
checkId: "tools.exec.fs_tools_disabled_but_exec_enabled",
|
|
severity: "warn",
|
|
title: "Filesystem tool policy does not make exec read-only",
|
|
detail:
|
|
`Found scopes where write/edit/apply_patch are unavailable but exec remains available:\n${execFilesystemPolicyHits.map((hit) => `- ${hit.scopeLabel}: runtime=[${hit.runtimeTools.join(", ")}], disabledFs=[${hit.disabledFilesystemTools.join(", ")}], exec.host=${hit.execHost}, sandbox=${hit.sandboxMode}, workspaceAccess=${hit.sandboxWorkspaceAccess}`).join("\n")}\n` +
|
|
"The exec tool is a shell and can still write files wherever the selected host or sandbox filesystem permits it.",
|
|
remediation:
|
|
'For read-only agents, deny exec and process too. If shell access is intentional, constrain the filesystem boundary with sandbox mode "all" and workspaceAccess "ro" or "none".',
|
|
});
|
|
}
|
|
|
|
const autoAllowSkillsHits = collectAutoAllowSkillsHits(approvals);
|
|
if (autoAllowSkillsHits.length > 0) {
|
|
findings.push({
|
|
checkId: "tools.exec.auto_allow_skills_enabled",
|
|
severity: "warn",
|
|
title: "autoAllowSkills is enabled for exec approvals",
|
|
detail:
|
|
`Implicit skill-bin allowlisting is enabled at:\n${autoAllowSkillsHits.map((entry) => `- ${entry}`).join("\n")}\n` +
|
|
"This widens host exec trust beyond explicit manual allowlist entries.",
|
|
remediation:
|
|
"Disable autoAllowSkills in exec approvals and keep manual allowlists tight when you need explicit host-exec trust.",
|
|
});
|
|
}
|
|
|
|
const interpreterAllowlistHits = collectInterpreterAllowlistHits({
|
|
approvals,
|
|
strictInlineEvalForAgentId: (agentId) => {
|
|
if (!agentId || agentId === "*") {
|
|
return globalStrictInlineEval;
|
|
}
|
|
const agent = agents.find((entry) => entry?.id === agentId);
|
|
return agent?.tools?.exec?.strictInlineEval ?? globalStrictInlineEval;
|
|
},
|
|
});
|
|
if (interpreterAllowlistHits.length > 0) {
|
|
findings.push({
|
|
checkId: "tools.exec.allowlist_interpreter_without_strict_inline_eval",
|
|
severity: "warn",
|
|
title: "Interpreter allowlist entries are missing strictInlineEval hardening",
|
|
detail: `Interpreter/runtime allowlist entries were found without strictInlineEval enabled:\n${interpreterAllowlistHits.map((entry) => `- ${entry}`).join("\n")}`,
|
|
remediation:
|
|
"Set tools.exec.strictInlineEval=true (or per-agent tools.exec.strictInlineEval=true) when allowlisting interpreters like python, node, ruby, perl, php, lua, or osascript.",
|
|
});
|
|
}
|
|
|
|
const classifyRiskySafeBinTrustedDir = (entry: string): string | null => {
|
|
const raw = entry.trim();
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
if (!path.isAbsolute(raw)) {
|
|
return "relative path (trust boundary depends on process cwd)";
|
|
}
|
|
const normalized = path.resolve(raw).replace(/\\/g, "/").toLowerCase();
|
|
if (
|
|
normalized === "/tmp" ||
|
|
normalized.startsWith("/tmp/") ||
|
|
normalized === "/var/tmp" ||
|
|
normalized.startsWith("/var/tmp/") ||
|
|
normalized === "/private/tmp" ||
|
|
normalized.startsWith("/private/tmp/")
|
|
) {
|
|
return "temporary directory is mutable and easy to poison";
|
|
}
|
|
if (
|
|
normalized === "/usr/local/bin" ||
|
|
normalized === "/opt/homebrew/bin" ||
|
|
normalized === "/opt/local/bin" ||
|
|
normalized === "/home/linuxbrew/.linuxbrew/bin"
|
|
) {
|
|
return "package-manager bin directory (often user-writable)";
|
|
}
|
|
if (
|
|
normalized.startsWith("/users/") ||
|
|
normalized.startsWith("/home/") ||
|
|
normalized.includes("/.local/bin")
|
|
) {
|
|
return "home-scoped bin directory (typically user-writable)";
|
|
}
|
|
if (/^[a-z]:\/users\//.test(normalized)) {
|
|
return "home-scoped bin directory (typically user-writable)";
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const globalExec = cfg.tools?.exec;
|
|
const riskyTrustedDirHits: string[] = [];
|
|
const collectRiskyTrustedDirHits = (scopePath: string, entries: unknown): void => {
|
|
for (const entry of normalizeConfiguredTrustedSafeBinDirs(entries)) {
|
|
const reason = classifyRiskySafeBinTrustedDir(entry);
|
|
if (!reason) {
|
|
continue;
|
|
}
|
|
riskyTrustedDirHits.push(`- ${scopePath}.safeBinTrustedDirs: ${entry} (${reason})`);
|
|
}
|
|
};
|
|
collectRiskyTrustedDirHits("tools.exec", globalExec?.safeBinTrustedDirs);
|
|
for (const entry of agents) {
|
|
if (!entry || typeof entry !== "object" || typeof entry.id !== "string") {
|
|
continue;
|
|
}
|
|
collectRiskyTrustedDirHits(
|
|
`agents.entries.${entry.id}.tools.exec`,
|
|
entry.tools?.exec?.safeBinTrustedDirs,
|
|
);
|
|
}
|
|
|
|
const interpreterHits: string[] = [];
|
|
const riskySemanticSafeBinHits: string[] = [];
|
|
const globalSafeBins = normalizeConfiguredSafeBins(globalExec?.safeBins);
|
|
if (globalSafeBins.length > 0) {
|
|
const merged = resolveMergedSafeBinProfileFixtures({ global: globalExec }) ?? {};
|
|
const interpreters = listInterpreterLikeSafeBins(globalSafeBins).filter((bin) => !merged[bin]);
|
|
if (interpreters.length > 0) {
|
|
interpreterHits.push(`- tools.exec.safeBins: ${interpreters.join(", ")}`);
|
|
}
|
|
for (const hit of listRiskyConfiguredSafeBins(globalSafeBins)) {
|
|
riskySemanticSafeBinHits.push(`- tools.exec.safeBins: ${hit.bin} (${hit.warning})`);
|
|
}
|
|
}
|
|
|
|
for (const entry of agents) {
|
|
if (!entry || typeof entry !== "object" || typeof entry.id !== "string") {
|
|
continue;
|
|
}
|
|
const agentExec = entry.tools?.exec;
|
|
const agentSafeBins = normalizeConfiguredSafeBins(agentExec?.safeBins);
|
|
if (agentSafeBins.length === 0) {
|
|
continue;
|
|
}
|
|
const merged =
|
|
resolveMergedSafeBinProfileFixtures({
|
|
global: globalExec,
|
|
local: agentExec,
|
|
}) ?? {};
|
|
const interpreters = listInterpreterLikeSafeBins(agentSafeBins).filter((bin) => !merged[bin]);
|
|
if (interpreters.length === 0) {
|
|
for (const hit of listRiskyConfiguredSafeBins(agentSafeBins)) {
|
|
riskySemanticSafeBinHits.push(
|
|
`- agents.entries.${entry.id}.tools.exec.safeBins: ${hit.bin} (${hit.warning})`,
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
interpreterHits.push(
|
|
`- agents.entries.${entry.id}.tools.exec.safeBins: ${interpreters.join(", ")}`,
|
|
);
|
|
for (const hit of listRiskyConfiguredSafeBins(agentSafeBins)) {
|
|
riskySemanticSafeBinHits.push(
|
|
`- agents.entries.${entry.id}.tools.exec.safeBins: ${hit.bin} (${hit.warning})`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (interpreterHits.length > 0) {
|
|
findings.push({
|
|
checkId: "tools.exec.safe_bins_interpreter_unprofiled",
|
|
severity: "warn",
|
|
title: "safeBins includes interpreter/runtime binaries without explicit profiles",
|
|
detail:
|
|
`Detected interpreter-like safeBins entries missing explicit profiles:\n${interpreterHits.join("\n")}\n` +
|
|
"These entries can turn safeBins into a broad execution surface when used with permissive argv profiles.",
|
|
remediation:
|
|
"Remove interpreter/runtime bins from safeBins (prefer allowlist entries) or define hardened tools.exec.safeBinProfiles.<bin> rules.",
|
|
});
|
|
}
|
|
|
|
if (riskySemanticSafeBinHits.length > 0) {
|
|
findings.push({
|
|
checkId: "tools.exec.safe_bins_broad_behavior",
|
|
severity: "warn",
|
|
title: "safeBins includes binaries with broader semantics than low-risk stream filters",
|
|
detail:
|
|
`Detected risky safeBins entries:\n${riskySemanticSafeBinHits.join("\n")}\n` +
|
|
"These tools expose semantics that do not fit the low-risk stdin-filter fast path.",
|
|
remediation:
|
|
"Remove these binaries from safeBins and prefer explicit allowlist entries or approval-gated execution.",
|
|
});
|
|
}
|
|
|
|
if (riskyTrustedDirHits.length > 0) {
|
|
findings.push({
|
|
checkId: "tools.exec.safe_bin_trusted_dirs_risky",
|
|
severity: "warn",
|
|
title: "safeBinTrustedDirs includes risky mutable directories",
|
|
detail:
|
|
`Detected risky safeBinTrustedDirs entries:\n${riskyTrustedDirHits.slice(0, 10).join("\n")}` +
|
|
(riskyTrustedDirHits.length > 10
|
|
? `\n- +${riskyTrustedDirHits.length - 10} more entries.`
|
|
: ""),
|
|
remediation:
|
|
"Prefer root-owned immutable bins, keep default trust dirs (/bin, /usr/bin), and avoid trusting temporary/home/package-manager paths unless tightly controlled.",
|
|
});
|
|
}
|
|
|
|
return findings;
|
|
}
|
|
|
|
function collectAgentRosterFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
|
|
const agents = listAgentEntries(cfg);
|
|
// A missing roster is the supported pre-roster compatibility state and is
|
|
// materialized by config loading. An explicitly authored empty roster is invalid.
|
|
if (agents.length === 0 && !hasAgentRosterProperty(cfg)) {
|
|
return [];
|
|
}
|
|
const defaultCount = agents.filter((agent) => agent?.default === true).length;
|
|
if (defaultCount === 1) {
|
|
return [];
|
|
}
|
|
return [
|
|
{
|
|
checkId: "config.agent_roster.invalid_default_count",
|
|
severity: "warn",
|
|
title: "Agent roster has an invalid default selection",
|
|
detail: `Expected exactly one agents.entries default=true entry, found ${defaultCount}.`,
|
|
remediation: "Run `openclaw doctor --fix` to repair the authored agent roster.",
|
|
},
|
|
];
|
|
}
|
|
|
|
function formatNamesPreview(names: readonly string[]): string {
|
|
const visible = names.slice(0, 6);
|
|
const suffix = names.length > visible.length ? `, +${names.length - visible.length} more` : "";
|
|
return `${visible.join(", ")}${suffix}`;
|
|
}
|
|
|
|
function listConfiguredMcpServerNames(cfg: OpenClawConfig): string[] {
|
|
return Object.entries(cfg.mcp?.servers ?? {})
|
|
.filter(([, server]) => server?.enabled !== false)
|
|
.map(([name]) => name)
|
|
.toSorted();
|
|
}
|
|
|
|
type GlobalMcporterRegistrySummary =
|
|
| { status: "source"; summary: McpServerSourceSummary }
|
|
| { status: "absent" }
|
|
| Extract<McporterRegistryReadOutcome, { status: "rejected" }>;
|
|
|
|
async function readGlobalMcporterRegistrySummary(
|
|
stateDir: string,
|
|
): Promise<GlobalMcporterRegistrySummary> {
|
|
const outcome = await readBoundedMcporterRegistry(stateDir);
|
|
if (outcome.status === "missing") {
|
|
return { status: "absent" };
|
|
}
|
|
if (outcome.status === "rejected") {
|
|
return outcome;
|
|
}
|
|
const mcpServers = asNullableRecord(asNullableRecord(outcome.value)?.mcpServers);
|
|
if (!mcpServers) {
|
|
return { status: "absent" };
|
|
}
|
|
const names = Object.entries(mcpServers)
|
|
.filter(([, value]) => asNullableRecord(value)?.enabled !== false)
|
|
.map(([name]) => name)
|
|
.toSorted();
|
|
return names.length > 0
|
|
? { status: "source", summary: { label: "skills/config/mcporter.json", names } }
|
|
: { status: "absent" };
|
|
}
|
|
|
|
function describeMcporterRegistryRejection(reason: McporterRegistryRejectReason): string {
|
|
switch (reason) {
|
|
case "oversized":
|
|
return "larger than the 16 MiB audit cap";
|
|
case "unreadable":
|
|
return "unreadable";
|
|
case "non-regular":
|
|
return "not a regular file";
|
|
case "malformed":
|
|
return "not valid JSON";
|
|
default: {
|
|
const exhaustive: never = reason;
|
|
return exhaustive;
|
|
}
|
|
}
|
|
}
|
|
|
|
function hasOwnSkillsAllowlist(entry: object | undefined): boolean {
|
|
return Boolean(entry && Object.hasOwn(entry, "skills"));
|
|
}
|
|
|
|
function collectAgentSkillMcpBoundaryScopes(cfg: OpenClawConfig): AgentSkillMcpBoundaryScope[] {
|
|
const agents = listAgentEntries(cfg);
|
|
const defaultsHaveSkillAllowlist = hasOwnSkillsAllowlist(cfg.agents?.defaults);
|
|
const candidates: AgentSkillMcpBoundaryCandidate[] = [
|
|
...(defaultsHaveSkillAllowlist
|
|
? [
|
|
{
|
|
kind: "defaults" as const,
|
|
id: "agents.defaults" as const,
|
|
skillSource: "agents.defaults.skills",
|
|
},
|
|
]
|
|
: []),
|
|
...agents
|
|
.filter(
|
|
(entry): entry is NonNullable<(typeof agents)[number]> =>
|
|
Boolean(entry) && typeof entry === "object" && typeof entry.id === "string",
|
|
)
|
|
.flatMap((entry) => {
|
|
if (hasOwnSkillsAllowlist(entry)) {
|
|
return [
|
|
{
|
|
kind: "agent" as const,
|
|
id: entry.id,
|
|
skillSource: "agents.entries.*.skills",
|
|
agentId: entry.id,
|
|
},
|
|
];
|
|
}
|
|
if (defaultsHaveSkillAllowlist) {
|
|
return [
|
|
{
|
|
kind: "agent" as const,
|
|
id: entry.id,
|
|
skillSource: "agents.defaults.skills (inherited)",
|
|
agentId: entry.id,
|
|
},
|
|
];
|
|
}
|
|
return [];
|
|
}),
|
|
];
|
|
|
|
return candidates.flatMap((candidate) => {
|
|
const agentId = candidate.kind === "agent" ? candidate.agentId : undefined;
|
|
const sandboxMode = resolveSandboxConfigForAgent(cfg, agentId).mode;
|
|
const exec = resolveExecDefaults({
|
|
cfg,
|
|
...(candidate.kind === "defaults" ? { scope: { kind: "defaults" as const } } : { agentId }),
|
|
sandboxAvailable: sandboxMode !== "off",
|
|
});
|
|
if (exec.security === "deny" || exec.effectiveHost === "sandbox") {
|
|
return [];
|
|
}
|
|
return [
|
|
{
|
|
id: candidate.id,
|
|
skillSource: candidate.skillSource,
|
|
execHost: exec.effectiveHost,
|
|
execSecurity: exec.security,
|
|
execAsk: exec.ask,
|
|
},
|
|
];
|
|
});
|
|
}
|
|
|
|
async function collectAgentSkillMcpBoundaryFindings(params: {
|
|
cfg: OpenClawConfig;
|
|
stateDir: string;
|
|
}): Promise<SecurityAuditFinding[]> {
|
|
const scopes = collectAgentSkillMcpBoundaryScopes(params.cfg);
|
|
if (scopes.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const findings: SecurityAuditFinding[] = [];
|
|
const sources: McpServerSourceSummary[] = [];
|
|
const configServerNames = listConfiguredMcpServerNames(params.cfg);
|
|
if (configServerNames.length > 0) {
|
|
sources.push({ label: "mcp.servers", names: configServerNames });
|
|
}
|
|
const globalMcporterRegistry = await readGlobalMcporterRegistrySummary(params.stateDir);
|
|
if (globalMcporterRegistry.status === "rejected") {
|
|
// An existing registry that cannot be inspected must not silently vanish
|
|
// from the audit; tell the operator the MCP boundary check is incomplete.
|
|
findings.push({
|
|
checkId: "tools.exec.mcporter_registry_inspection_incomplete",
|
|
severity: "warn",
|
|
title: "Global mcporter registry could not be inspected",
|
|
detail:
|
|
`skills/config/mcporter.json exists but could not be safely inspected (${describeMcporterRegistryRejection(globalMcporterRegistry.reason)}). ` +
|
|
"The MCP boundary inspection is incomplete: the audit could not verify which MCP servers a host exec process can reach.",
|
|
remediation:
|
|
"Repair or remove skills/config/mcporter.json so the audit can inspect it: keep it a regular file readable by the gateway user, valid JSON, and below the 16 MiB audit cap.",
|
|
});
|
|
} else if (globalMcporterRegistry.status === "source") {
|
|
sources.push(globalMcporterRegistry.summary);
|
|
}
|
|
if (sources.length === 0) {
|
|
return findings;
|
|
}
|
|
|
|
findings.push({
|
|
checkId: "tools.exec.agent_skill_mcp_boundary_drift",
|
|
severity: "warn",
|
|
title: "Agent skill allowlists do not constrain host exec MCP clients",
|
|
detail:
|
|
`Detected agent skill allowlists on host-exec-capable scopes:\n${scopes
|
|
.slice(0, 8)
|
|
.map(
|
|
(scope) =>
|
|
`- ${scope.id}: ${scope.skillSource}, exec.host=${scope.execHost}, security=${scope.execSecurity}, ask=${scope.execAsk}`,
|
|
)
|
|
.join("\n")}` +
|
|
(scopes.length > 8 ? `\n- +${scopes.length - 8} more scopes.` : "") +
|
|
`\nMCP server registries visible to the gateway configuration/state:\n${sources
|
|
.map((source) => `- ${source.label}: ${formatNamesPreview(source.names)}`)
|
|
.join("\n")}\n` +
|
|
"agents.*.skills filters OpenClaw skill visibility and snapshots; it is not a shell-time authorization boundary. " +
|
|
"A host exec process can run external MCP clients or read a global mcporter registry unless sandbox, filesystem, network, or MCP credential boundaries block it.",
|
|
remediation:
|
|
'For agents that need per-agent MCP isolation, set their exec policy to security="deny" or a tight allowlist, run them in sandbox/container/OS-user isolation where the global MCP registry is not readable, split sensitive MCP servers into a separate gateway/trust boundary, or require per-agent MCP credentials at the server layer.',
|
|
});
|
|
return findings;
|
|
}
|
|
|
|
function collectOpenExecSurfacePaths(cfg: OpenClawConfig): string[] {
|
|
const channels = asNullableRecord(cfg.channels);
|
|
if (!channels) {
|
|
return [];
|
|
}
|
|
const hits = new Set<string>();
|
|
const seen = new WeakSet<object>();
|
|
const visit = (value: unknown, scope: string) => {
|
|
const record = asNullableRecord(value);
|
|
if (!record || seen.has(record)) {
|
|
return;
|
|
}
|
|
seen.add(record);
|
|
if (record.groupPolicy === "open") {
|
|
hits.add(`${scope}.groupPolicy`);
|
|
}
|
|
if (record.dmPolicy === "open") {
|
|
hits.add(`${scope}.dmPolicy`);
|
|
}
|
|
for (const [key, nested] of Object.entries(record)) {
|
|
if (key === "groups" || key === "accounts" || key === "dms") {
|
|
visit(nested, `${scope}.${key}`);
|
|
continue;
|
|
}
|
|
if (asNullableRecord(nested)) {
|
|
visit(nested, `${scope}.${key}`);
|
|
}
|
|
}
|
|
};
|
|
for (const [channelId, channelValue] of Object.entries(channels)) {
|
|
visit(channelValue, `channels.${channelId}`);
|
|
}
|
|
return Array.from(hits).toSorted();
|
|
}
|
|
|
|
function collectAutoAllowSkillsHits(approvals: ExecApprovalsFile): string[] {
|
|
const hits: string[] = [];
|
|
if (approvals.defaults?.autoAllowSkills === true) {
|
|
hits.push("defaults.autoAllowSkills");
|
|
}
|
|
for (const [agentId, agent] of Object.entries(approvals.agents ?? {})) {
|
|
if (agent?.autoAllowSkills === true) {
|
|
hits.push(`agents.${agentId}.autoAllowSkills`);
|
|
}
|
|
}
|
|
return hits;
|
|
}
|
|
|
|
function collectInterpreterAllowlistHits(params: {
|
|
approvals: ExecApprovalsFile;
|
|
strictInlineEvalForAgentId: (agentId: string | undefined) => boolean;
|
|
}): string[] {
|
|
const hits: string[] = [];
|
|
for (const [agentId, agent] of Object.entries(params.approvals.agents ?? {})) {
|
|
if (!agent || params.strictInlineEvalForAgentId(agentId)) {
|
|
continue;
|
|
}
|
|
for (const entry of agent.allowlist ?? []) {
|
|
if (!isInterpreterLikeAllowlistPattern(entry.pattern)) {
|
|
continue;
|
|
}
|
|
hits.push(`agents.${agentId}.allowlist: ${entry.pattern}`);
|
|
}
|
|
}
|
|
return hits;
|
|
}
|
|
|
|
async function maybeProbeGateway(params: {
|
|
cfg: OpenClawConfig;
|
|
env: NodeJS.ProcessEnv;
|
|
timeoutMs: number;
|
|
probe: ProbeGatewayFn;
|
|
explicitAuth?: { token?: string; password?: string };
|
|
}): Promise<{
|
|
deep: SecurityAuditReport["deep"];
|
|
authWarning?: string;
|
|
}> {
|
|
const { buildGatewayConnectionDetails, resolveGatewayProbeAuthSafe, resolveGatewayProbeTarget } =
|
|
await loadGatewayProbeDeps();
|
|
const connection = buildGatewayConnectionDetails({ config: params.cfg });
|
|
const url = connection.url;
|
|
const probeTarget = resolveGatewayProbeTarget(params.cfg);
|
|
|
|
const authResolution = resolveGatewayProbeAuthSafe({
|
|
cfg: params.cfg,
|
|
env: params.env,
|
|
mode: probeTarget.mode,
|
|
explicitAuth: params.explicitAuth,
|
|
});
|
|
const res = await params
|
|
.probe({ url, auth: authResolution.auth, timeoutMs: params.timeoutMs })
|
|
.catch((err: unknown) => ({
|
|
ok: false,
|
|
url,
|
|
connectLatencyMs: null,
|
|
error: String(err),
|
|
close: null,
|
|
health: null,
|
|
status: null,
|
|
presence: null,
|
|
configSnapshot: null,
|
|
}));
|
|
|
|
if (authResolution.warning && !res.ok) {
|
|
res.error = res.error ? `${res.error}; ${authResolution.warning}` : authResolution.warning;
|
|
}
|
|
|
|
return {
|
|
deep: {
|
|
gateway: {
|
|
attempted: true,
|
|
url,
|
|
ok: res.ok,
|
|
error: res.ok ? null : res.error,
|
|
close: res.close ? { code: res.close.code, reason: res.close.reason } : null,
|
|
},
|
|
},
|
|
authWarning: authResolution.warning,
|
|
};
|
|
}
|
|
|
|
async function createAuditExecutionContext(
|
|
opts: SecurityAuditOptions,
|
|
): Promise<AuditExecutionContext> {
|
|
const cfg = opts.config;
|
|
const sourceConfig = opts.sourceConfig ?? opts.config;
|
|
const env = opts.env ?? process.env;
|
|
const platform = opts.platform ?? process.platform;
|
|
const includeFilesystem = opts.includeFilesystem !== false;
|
|
const includeChannelSecurity = opts.includeChannelSecurity !== false;
|
|
const deep = opts.deep === true;
|
|
const deepTimeoutMs = Math.max(250, opts.deepTimeoutMs ?? 5000);
|
|
const stateDir = opts.stateDir ?? resolveStateDir(env);
|
|
const configPath = opts.configPath ?? resolveConfigPath(env, stateDir);
|
|
const defaultAgentId = tryResolveDefaultAgentId(cfg);
|
|
const configuredDefaultWorkspace = cfg.agents?.defaults?.workspace?.trim();
|
|
const workspaceDir =
|
|
opts.workspaceDir ??
|
|
(defaultAgentId
|
|
? resolveAgentWorkspaceDir(cfg, defaultAgentId)
|
|
: configuredDefaultWorkspace
|
|
? resolveUserPath(configuredDefaultWorkspace, env)
|
|
: resolveDefaultAgentWorkspaceDir(env));
|
|
const { readConfigSnapshotForAudit } = await loadAuditNonDeepModule();
|
|
const configSnapshot = includeFilesystem
|
|
? opts.configSnapshot !== undefined
|
|
? opts.configSnapshot
|
|
: await readConfigSnapshotForAudit({ env, configPath }).catch(() => null)
|
|
: null;
|
|
return {
|
|
cfg,
|
|
sourceConfig,
|
|
env,
|
|
platform,
|
|
includeFilesystem,
|
|
includeChannelSecurity,
|
|
deep,
|
|
deepTimeoutMs,
|
|
stateDir,
|
|
configPath,
|
|
execIcacls: opts.execIcacls,
|
|
execDockerRawFn: opts.execDockerRawFn,
|
|
probeGatewayFn: opts.probeGatewayFn,
|
|
plugins: opts.plugins,
|
|
loadPluginSecurityCollectors: opts.loadPluginSecurityCollectors ?? deep,
|
|
workspaceDir,
|
|
configSnapshot,
|
|
codeSafetySummaryCache: opts.codeSafetySummaryCache ?? new Map<string, Promise<unknown>>(),
|
|
deepProbeAuth: opts.deepProbeAuth,
|
|
auditGatewayAuthOverride: opts.auditGatewayAuthOverride,
|
|
};
|
|
}
|
|
|
|
export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<SecurityAuditReport> {
|
|
const findings: SecurityAuditFinding[] = [];
|
|
const context = await createAuditExecutionContext(opts);
|
|
const { cfg, env, platform, stateDir, configPath } = context;
|
|
const auditNonDeep = await loadAuditNonDeepModule();
|
|
|
|
findings.push(...auditNonDeep.collectAttackSurfaceSummaryFindings(cfg));
|
|
findings.push(...collectAgentRosterFindings(context.sourceConfig));
|
|
findings.push(...auditNonDeep.collectSyncedFolderFindings({ stateDir, configPath }));
|
|
|
|
findings.push(
|
|
...collectGatewayConfigFindings(cfg, context.sourceConfig, env, {
|
|
gatewayAuthOverride: context.auditGatewayAuthOverride,
|
|
}),
|
|
);
|
|
findings.push(...collectControlUiDeviceAuthMigrationFindings({ env, stateDir }));
|
|
findings.push(...(await collectPluginSecurityAuditFindings(context)));
|
|
findings.push(...collectElevatedFindings(cfg));
|
|
findings.push(...collectExecRuntimeFindings(cfg));
|
|
findings.push(...(await collectAgentSkillMcpBoundaryFindings({ cfg, stateDir })));
|
|
const hooksGatewayAuthCfg = shouldMaterializeHooksGatewayAuthRefs(cfg)
|
|
? await materializeAuditGatewayAuthRefs({
|
|
cfg,
|
|
env,
|
|
})
|
|
: cfg;
|
|
findings.push(
|
|
...auditNonDeep.collectHooksHardeningFindings(hooksGatewayAuthCfg, env, {
|
|
gatewayAuthOverride: context.auditGatewayAuthOverride,
|
|
}),
|
|
);
|
|
findings.push(
|
|
...auditNonDeep.collectGatewayHttpNoAuthFindings(cfg, env, {
|
|
gatewayAuthOverride: context.auditGatewayAuthOverride,
|
|
}),
|
|
);
|
|
findings.push(...auditNonDeep.collectGatewayHttpSessionKeyOverrideFindings(cfg));
|
|
findings.push(...auditNonDeep.collectSandboxDockerNoopFindings(cfg));
|
|
findings.push(...auditNonDeep.collectSandboxDangerousConfigFindings(cfg));
|
|
findings.push(...auditNonDeep.collectNodeDenyCommandPatternFindings(cfg));
|
|
findings.push(...auditNonDeep.collectNodeDangerousAllowCommandFindings(cfg));
|
|
findings.push(...auditNonDeep.collectMinimalProfileOverrideFindings(cfg));
|
|
findings.push(...auditNonDeep.collectSecretsInConfigFindings(cfg));
|
|
findings.push(...auditNonDeep.collectModelHygieneFindings(cfg));
|
|
findings.push(...auditNonDeep.collectSmallModelRiskFindings({ cfg, env }));
|
|
findings.push(...auditNonDeep.collectExposureMatrixFindings(cfg));
|
|
findings.push(...auditNonDeep.collectLikelyMultiUserSetupFindings(cfg));
|
|
|
|
if (context.includeFilesystem) {
|
|
findings.push(
|
|
...(await collectFilesystemFindings({
|
|
stateDir,
|
|
configPath,
|
|
env,
|
|
platform,
|
|
execIcacls: context.execIcacls,
|
|
})),
|
|
);
|
|
if (context.configSnapshot) {
|
|
findings.push(
|
|
...(await auditNonDeep.collectIncludeFilePermFindings({
|
|
configSnapshot: context.configSnapshot,
|
|
env,
|
|
platform,
|
|
execIcacls: context.execIcacls,
|
|
})),
|
|
);
|
|
}
|
|
findings.push(
|
|
...(await auditNonDeep.collectStateDeepFilesystemFindings({
|
|
cfg,
|
|
env,
|
|
stateDir,
|
|
platform,
|
|
execIcacls: context.execIcacls,
|
|
})),
|
|
);
|
|
findings.push(
|
|
...(await auditNonDeep.collectWorkspaceSkillSymlinkEscapeFindings({
|
|
cfg,
|
|
workspaceDir: context.workspaceDir,
|
|
})),
|
|
);
|
|
findings.push(
|
|
...(await auditNonDeep.collectSandboxBrowserHashLabelFindings({
|
|
execDockerRawFn: context.execDockerRawFn,
|
|
timeoutMs: context.deepTimeoutMs,
|
|
})),
|
|
);
|
|
findings.push(...(await auditNonDeep.collectPluginsTrustFindings({ cfg, stateDir })));
|
|
findings.push(
|
|
...(await collectDeepCodeSafetyFindings({
|
|
cfg,
|
|
stateDir,
|
|
deep: context.deep,
|
|
workspaceDir: context.workspaceDir,
|
|
summaryCache: context.codeSafetySummaryCache,
|
|
})),
|
|
);
|
|
}
|
|
|
|
let shouldAuditChannelSecurity = false;
|
|
if (context.includeChannelSecurity) {
|
|
if (context.plugins !== undefined) {
|
|
shouldAuditChannelSecurity = true;
|
|
} else {
|
|
const { hasConfiguredChannelsForReadOnlyScope, resolveConfiguredChannelPluginIds } =
|
|
await loadChannelPluginIdsModule();
|
|
shouldAuditChannelSecurity =
|
|
hasConfiguredChannelsForReadOnlyScope({
|
|
config: cfg,
|
|
activationSourceConfig: context.sourceConfig,
|
|
workspaceDir: context.workspaceDir,
|
|
env,
|
|
}) ||
|
|
resolveConfiguredChannelPluginIds({
|
|
config: cfg,
|
|
activationSourceConfig: context.sourceConfig,
|
|
workspaceDir: context.workspaceDir,
|
|
env,
|
|
}).length > 0;
|
|
}
|
|
}
|
|
if (shouldAuditChannelSecurity) {
|
|
const channelPlugins =
|
|
context.plugins ??
|
|
(await loadReadOnlyChannelPlugins()).listReadOnlyChannelPluginsForConfig(cfg, {
|
|
activationSourceConfig: context.sourceConfig,
|
|
workspaceDir: context.workspaceDir,
|
|
env,
|
|
stateDir,
|
|
includePersistedAuthState: true,
|
|
includeSetupFallbackPlugins: true,
|
|
});
|
|
const { collectChannelSecurityFindings } = await loadAuditChannelModule();
|
|
findings.push(
|
|
...(await collectChannelSecurityFindings({
|
|
cfg,
|
|
sourceConfig: context.sourceConfig,
|
|
plugins: channelPlugins,
|
|
})),
|
|
);
|
|
}
|
|
|
|
const deepProbeResult = context.deep
|
|
? await maybeProbeGateway({
|
|
cfg,
|
|
env,
|
|
timeoutMs: context.deepTimeoutMs,
|
|
probe: context.probeGatewayFn ?? (await loadGatewayProbeDeps()).probeGateway,
|
|
explicitAuth: context.deepProbeAuth,
|
|
})
|
|
: undefined;
|
|
const deep = deepProbeResult?.deep;
|
|
findings.push(...collectDeepProbeFindings({ deep, authWarning: deepProbeResult?.authWarning }));
|
|
|
|
const configuredSuppressions = cfg.security?.audit?.suppressions;
|
|
const filtered = applySecurityAuditSuppressions(findings, configuredSuppressions);
|
|
const configuredSuppressionCount = configuredSuppressions?.length ?? 0;
|
|
const activeFindings =
|
|
configuredSuppressionCount > 0
|
|
? [
|
|
...filtered.findings,
|
|
buildSecurityAuditSuppressionsActiveFinding({
|
|
configuredCount: configuredSuppressionCount,
|
|
suppressedCount: filtered.suppressedFindings.length,
|
|
}),
|
|
]
|
|
: filtered.findings;
|
|
const summary = countBySeverity(activeFindings);
|
|
emitSecurityAuditReportEvent({
|
|
summary,
|
|
deep: context.deep,
|
|
includeFilesystem: context.includeFilesystem,
|
|
includeChannelSecurity: context.includeChannelSecurity,
|
|
suppressedCount: filtered.suppressedFindings.length,
|
|
});
|
|
return {
|
|
ts: Date.now(),
|
|
summary,
|
|
findings: activeFindings,
|
|
...(filtered.suppressedFindings.length > 0
|
|
? { suppressedFindings: filtered.suppressedFindings }
|
|
: {}),
|
|
deep,
|
|
};
|
|
}
|
|
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|