Files
openclaw/src/security/audit.ts
Peter Steinberger fd6e042d87 fix(gateway): keep configured local auth authoritative (#114462)
* fix(gateway): align configured local credentials

* test(gateway): align local credential coverage

* chore(gateway): leave release note to release process
2026-07-27 05:43:31 -04:00

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. */