Files
openclaw/src/security/audit-extra.async.ts
Peter Steinberger 82d1a03f25 refactor(agents): move implicit-main fallback into load-time roster injection (#112678)
* refactor(agents): require explicit roster defaults

* feat(onboard): create named first roster agent

* refactor(agents): remove runtime main fallbacks

* style(agents): apply roster refactor formatting

* refactor(agents): finish roster-only runtime sweep

* fix(doctor): migrate legacy main session sqlite

* fix(doctor): harden roster session migrations

* fix(onboard): commit first agent atomically

* fix(config): support empty-roster analysis

* fix(agents): preserve legacy main state during creation

* fix(setup): materialize baseline agent roster

* fix(agents): harden legacy default transfer recovery

* fix(agents): simplify roster-only legacy compatibility

* fix(agents): preserve staged first-agent entries

* fix(config): migrate persisted implicit-main rosters

* fix(config): preserve staged empty rosters

* fix(agents): finalize roster-only upgrade paths

* fix(sessions): close legacy main migration outcomes

* fix(config): migrate legacy roster markers at load

* fix(sessions): preserve roster upgrade history

* refactor(sessions): restore lean legacy main compatibility

* fix(setup): prepare first-agent credentials before publish

* fix(config): stabilize roster snapshot migration

* refactor(sessions): shrink legacy main compatibility

* fix(agents): restore roster compatibility fidelity

* fix(sessions): preserve divergent legacy history

* refactor(agents): narrow roster-only scope

* fix(config): isolate roster migration

* test(agents): align roster-only fixtures

* fix(agents): keep main agent undeletable

* fix(agents): harden roster migration invariants

* fix(agents): close setup and audit scope gaps

* fix(cron): scope session reaper throttles by agent

* fix(agents): preserve scoped owner precedence

* fix(config): preserve authored config ownership

* fix(setup): keep default workspace and roster in sync

* fix(setup): preserve default entry workspace on bare runs

* fix(agents): adapt roster rebase to keyed entries

* fix(agents): honor both roster representations

* fix(agents): route roster reads through shared helpers

* fix(config): preserve canonical roster writes

* fix(cron): resolve dynamic default for session reaper

* fix(agents): close dynamic default migration gaps

* fix(agents): align scoped session ownership

* fix(sessions): preserve legacy main directory casing

* fix(agents): align cron and legacy auth ownership

* fix(setup): provision the committed default workspace

* fix(cron): align scoped ownership and reaping

* fix(cron): treat blank agent ids as absent

* fix(cron): retain configured session-store owners

* fix(agents): repair roster-aware CI boundaries

* fix(cron): preserve scoped ownership resolution

* fix(agents): preserve rosterless maintenance paths

* fix(agents): propagate roster ownership through runtime boundaries

* fix(agents): preserve roster ownership across runtime paths

* fix(agents): harden roster diagnostics and legacy routing

* fix(agents): remove redundant diagnostic import

* test(agents): type CLI policy fixture explicitly

* fix(config): preserve canonical roster mutation identity

* fix(doctor): read canonical agent rosters consistently

* fix(config): resolve compound roster unsets safely

* fix(config): finalize main-session reconciliation

* fix(doctor): read canonical session state safely

* fix(sessions): preserve current visibility alias

* fix(config): track roster include provenance

* test(config): type roster provenance cases

* fix(config): refine roster include ownership

* fix(agents): preserve staged roster invariants

* test(config): align fixtures with explicit roster ownership

* test(node-host): preserve optional plan typing

* fix(config): preserve authored roster projections

* test(config): keep raw roster fixtures explicit

* test(config): normalize rosters at runtime fixtures

* fix(config): protect authored roster ownership

* fix(agents): require explicit session ownership

* fix(agents): enforce scoped roster ownership

* fix(sessions): merge fixed-store agent partitions

* fix(agents): harden roster ownership boundaries

* fix(config): reject ambiguous roster projections

* fix(sessions): preserve persisted store ownership

* fix(sessions): keep collision diagnostics additive

* fix(security): scan malformed roster workspaces

* test(config): align snapshot fixtures after rebase

* test(agents): use explicit roster fixtures

* fix(config): harden roster diagnostic boundaries

* fix(sessions): isolate fixed-store agent databases

* test(agents): type malformed default markers

* refactor(sessions): extract store collision resolution

* test(system-agent): split oversized setup coverage

* style(system-agent): format split setup suite

* fix(sessions): preserve promoted store ownership

* fix(sessions): derive scoped owner before target

* fix(sessions): preserve explicit sqlite ownership

* fix(agents): restore roster compatibility across CI

* fix(agents): enforce roster-owned runtime boundaries

* fix(agents): satisfy default lookup lint

* test(sessions): split known-owner coverage

* fix(state): satisfy path identity lint

* fix(agents): preserve malformed roster safety boundaries

* fix(agents): restore roster compatibility at runtime boundaries

* fix(config): satisfy roster boundary type checks

* fix(agents): preserve roster ownership across runtime probes

Setup inference probes now execute as the configured roster owner. Malformed agent-prefixed session rows are intentionally omitted by the fail-closed visibility contract rather than normalized by tests.

* fix(agents): satisfy session list owner lint

* fix(agents): preserve roster-owned runtime boundaries

Restore shared logical rows for exact SQLite session locators while keeping their physical database owner separate. The ownership regression test now constructs an explicit sole-owner database directly instead of relying on first-touch capture, matching the intentional shared-store contract.

* fix(sessions): preserve multiply owned exact stores

* fix(sessions): restore runtime owner boundaries

Keep incognito sentinels agent-owned, fold default-agent approvals into the global snapshot, and preserve the configless legacy-main CLI policy fallback. Also repair the existing CLI watchdog test lifecycle so the compact shard observes its timeout without an unawaited assertion or async timer stall; product behavior is unchanged by that test-only fix.

* test(ci): align owner-scoped fixtures

These assertions are unchanged. The fixtures now declare the intended non-default runner, expose the session-key constant imported by production status code, and select the main approvals bucket explicitly on Windows.

* fix(agents): close final roster ownership gaps
2026-07-24 22:38:09 -07:00

993 lines
35 KiB
TypeScript

/**
* Asynchronous security audit collector functions.
*
* These functions perform I/O (filesystem, config reads) to detect security issues.
*/
import path from "node:path";
import { clearTimeout as clearNodeTimeout, setTimeout as setNodeTimeout } from "node:timers";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import {
normalizeStringEntries,
normalizeTrimmedStringList,
uniqueStrings,
} from "@openclaw/normalization-core/string-normalization";
import { resolveAuthProfileDatabaseFilePaths } from "../agents/auth-profiles/sqlite.js";
import { formatCliCommand } from "../cli/command-format.js";
import { MANIFEST_KEY } from "../compat/legacy-names.js";
import type { OpenClawConfig, ConfigFileSnapshot } from "../config/config.js";
import { collectIncludePathsRecursive } from "../config/includes-scan.js";
import { resolveOAuthDir } from "../config/paths.js";
import { readRegularFile, statRegularFile } from "../infra/fs-safe.js";
import { LEGACY_IMPLICIT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
import { getOrCreatePromise } from "../shared/lazy-promise.js";
import { createLazyRuntimeModule, createLazyRuntimeNamedExport } from "../shared/lazy-runtime.js";
import type { SkillScanFinding } from "../skills/security/scanner.js";
import { listInstalledPluginDirs } from "./installed-plugin-dirs.js";
import { extensionUsesSkippedScannerPath, isPathInside } from "./scan-paths.js";
import type { ExecFn } from "./windows-acl.js";
type SecurityAuditFinding = {
checkId: string;
severity: "info" | "warn" | "critical";
title: string;
detail: string;
remediation?: string;
};
type SkillScanSummary = Awaited<
ReturnType<typeof import("../skills/security/scanner.js").scanDirectoryWithSummary>
>;
type ExecDockerRawFn = (
args: string[],
opts?: { allowFailure?: boolean; input?: Buffer | string; signal?: AbortSignal },
) => Promise<import("../agents/sandbox/docker.js").ExecDockerRawResult>;
const DEFAULT_SANDBOX_BROWSER_DOCKER_PROBE_TIMEOUT_MS = 5000;
type CodeSafetySummaryCache = Map<string, Promise<unknown>>;
const loadSkillsModule = createLazyRuntimeModule(() => import("../skills/loading/workspace.js"));
const loadConfigModule = createLazyRuntimeModule(() => import("../config/config.js"));
const loadAuditFsModule = createLazyRuntimeModule(() => import("./audit-fs.js"));
const loadAgentScopeModule = createLazyRuntimeModule(() => import("../agents/agent-scope.js"));
const loadAgentWorkspaceDirsModule = createLazyRuntimeModule(
() => import("../agents/workspace-dirs.js"),
);
const loadSkillSourceModule = createLazyRuntimeModule(() => import("../skills/loading/source.js"));
const loadSkillScannerModule = createLazyRuntimeModule(
() => import("../skills/security/scanner.js"),
);
const loadExecDockerRaw = createLazyRuntimeNamedExport(
() => import("../agents/sandbox/docker.js"),
"execDockerRaw",
) satisfies () => Promise<ExecDockerRawFn>;
const loadSandboxBrowserSecurityHashEpoch = createLazyRuntimeNamedExport(
() => import("../agents/sandbox/constants.js"),
"SANDBOX_BROWSER_SECURITY_HASH_EPOCH",
);
// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------
function expandTilde(p: string, env: NodeJS.ProcessEnv): string | null {
if (!p.startsWith("~")) {
return p;
}
const home = normalizeOptionalString(env.HOME) ?? null;
if (!home) {
return null;
}
if (p === "~") {
return home;
}
if (p.startsWith("~/") || p.startsWith("~\\")) {
return path.join(home, p.slice(2));
}
return null;
}
const MAX_PLUGIN_MANIFEST_BYTES = 1024 * 1024;
// Skill file audit reads are bounded like other audit reads; matches the
// workspace loader's DEFAULT_MAX_SKILL_FILE_BYTES so oversized SKILL.md files
// cannot force an unbounded read during the code-safety scan.
const MAX_SKILL_AUDIT_FILE_BYTES = 256_000;
async function readPluginManifestExtensions(pluginPath: string): Promise<string[]> {
const manifestPath = path.join(pluginPath, "package.json");
const statResult = await statRegularFile(manifestPath);
if (statResult.missing) {
return [];
}
if (statResult.stat.size > MAX_PLUGIN_MANIFEST_BYTES) {
throw new Error(
`Plugin manifest at ${manifestPath} is too large (${statResult.stat.size} bytes, max ${MAX_PLUGIN_MANIFEST_BYTES})`,
);
}
const { buffer } = await readRegularFile({
filePath: manifestPath,
maxBytes: MAX_PLUGIN_MANIFEST_BYTES,
});
const raw = buffer.toString("utf-8");
if (!raw.trim()) {
return [];
}
let parsed: Partial<Record<typeof MANIFEST_KEY, { extensions?: unknown }>> | null;
try {
parsed = JSON.parse(raw) as Partial<
Record<typeof MANIFEST_KEY, { extensions?: unknown }>
> | null;
} catch (err) {
// Re-throw so callers can surface a security finding for malformed manifests.
// A malicious plugin could use a malformed package.json to hide declared
// extension entrypoints from deep scan — callers must not silently drop them.
throw new Error(`Failed to parse plugin manifest at ${manifestPath}: ${String(err)}`, {
cause: err,
});
}
const extensions = parsed?.[MANIFEST_KEY]?.extensions;
if (!Array.isArray(extensions)) {
return [];
}
return normalizeTrimmedStringList(extensions);
}
function formatCodeSafetyDetails(findings: SkillScanFinding[], rootDir: string): string {
return findings
.map((finding) => {
const relPath = path.relative(rootDir, finding.file);
const filePath =
relPath && relPath !== "." && !relPath.startsWith("..")
? relPath
: path.basename(finding.file);
const normalizedPath = filePath.replaceAll("\\", "/");
return ` - [${finding.ruleId}] ${finding.message} (${normalizedPath}:${finding.line})`;
})
.join("\n");
}
function buildCodeSafetySummaryCacheKey(params: {
dirPath: string;
includeFiles?: string[];
}): string {
const includeFiles = normalizeStringEntries(params.includeFiles);
const includeKey = includeFiles.length > 0 ? includeFiles.toSorted().join("\u0000") : "";
return `${params.dirPath}\u0000${includeKey}`;
}
async function getCodeSafetySummary(params: {
dirPath: string;
includeFiles?: string[];
summaryCache?: CodeSafetySummaryCache;
}): Promise<SkillScanSummary> {
const cacheKey = buildCodeSafetySummaryCacheKey({
dirPath: params.dirPath,
includeFiles: params.includeFiles,
});
const scan = async () => {
const skillScanner = await loadSkillScannerModule();
return await skillScanner.scanDirectoryWithSummary(params.dirPath, {
includeFiles: params.includeFiles,
});
};
return params.summaryCache
? ((await getOrCreatePromise(params.summaryCache, cacheKey, scan)) as SkillScanSummary)
: await scan();
}
async function getSkillCodeSafetySummary(params: {
dirPath: string;
skillFilePath: string;
summaryCache?: CodeSafetySummaryCache;
}): Promise<SkillScanSummary> {
const [summary, skillContent, skillScanner] = await Promise.all([
getCodeSafetySummary({
dirPath: params.dirPath,
summaryCache: params.summaryCache,
}),
readRegularFile({
filePath: params.skillFilePath,
maxBytes: MAX_SKILL_AUDIT_FILE_BYTES,
}).then(({ buffer }) => buffer.toString("utf-8")),
loadSkillScannerModule(),
]);
const skillFindings = [
...skillScanner.scanSkillContent(skillContent, params.skillFilePath),
...skillScanner.scanSource(skillContent, params.skillFilePath),
];
return {
...summary,
scannedFiles: summary.scannedFiles + 1,
critical:
summary.critical + skillFindings.filter((finding) => finding.severity === "critical").length,
warn: summary.warn + skillFindings.filter((finding) => finding.severity === "warn").length,
info: summary.info + skillFindings.filter((finding) => finding.severity === "info").length,
findings: [...summary.findings, ...skillFindings],
};
}
// --------------------------------------------------------------------------
// Exported collectors
// --------------------------------------------------------------------------
function normalizeDockerLabelValue(raw: string | undefined): string | null {
const trimmed = normalizeOptionalString(raw) ?? "";
if (!trimmed || trimmed === "<no value>") {
return null;
}
return trimmed;
}
class DockerProbeTimeoutError extends Error {
constructor(timeoutMs: number) {
super(`Docker probe timed out after ${timeoutMs}ms`);
this.name = "DockerProbeTimeoutError";
}
}
function normalizeDockerProbeTimeoutMs(timeoutMs: number | undefined): number {
if (Number.isFinite(timeoutMs) && timeoutMs !== undefined) {
return Math.max(250, Math.floor(timeoutMs));
}
return DEFAULT_SANDBOX_BROWSER_DOCKER_PROBE_TIMEOUT_MS;
}
async function withDockerProbeTimeout<T>(
timeoutMs: number,
run: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
const controller = new AbortController();
let timeout: ReturnType<typeof setNodeTimeout> | undefined;
let timedOut = false;
const timeoutPromise = new Promise<never>((_, reject) => {
timeout = setNodeTimeout(() => {
timedOut = true;
controller.abort();
reject(new DockerProbeTimeoutError(timeoutMs));
}, timeoutMs);
});
try {
return await Promise.race([run(controller.signal), timeoutPromise]);
} catch (err) {
if (timedOut || controller.signal.aborted) {
throw new DockerProbeTimeoutError(timeoutMs);
}
throw err;
} finally {
if (timeout) {
clearNodeTimeout(timeout);
}
}
}
function isDockerProbeTimeoutError(error: unknown): boolean {
return error instanceof DockerProbeTimeoutError;
}
async function listSandboxBrowserContainers(params: {
execDockerRawFn: ExecDockerRawFn;
timeoutMs: number;
onTimeout?: () => void;
}): Promise<string[] | null> {
try {
const result = await withDockerProbeTimeout(params.timeoutMs, (signal) =>
params.execDockerRawFn(
["ps", "-a", "--filter", "label=openclaw.sandboxBrowser=1", "--format", "{{.Names}}"],
{ allowFailure: true, signal },
),
);
if (result.code !== 0) {
return null;
}
return normalizeStringEntries(result.stdout.toString("utf8").split(/\r?\n/));
} catch (err) {
if (isDockerProbeTimeoutError(err)) {
params.onTimeout?.();
}
return null;
}
}
async function readSandboxBrowserHashLabels(params: {
containerName: string;
execDockerRawFn: ExecDockerRawFn;
timeoutMs: number;
onTimeout?: () => void;
}): Promise<{ configHash: string | null; epoch: string | null } | null> {
try {
const result = await withDockerProbeTimeout(params.timeoutMs, (signal) =>
params.execDockerRawFn(
[
"inspect",
"-f",
'{{ index .Config.Labels "openclaw.configHash" }}\t{{ index .Config.Labels "openclaw.browserConfigEpoch" }}',
params.containerName,
],
{ allowFailure: true, signal },
),
);
if (result.code !== 0) {
return null;
}
const [hashRaw, epochRaw] = result.stdout.toString("utf8").split("\t");
return {
configHash: normalizeDockerLabelValue(hashRaw),
epoch: normalizeDockerLabelValue(epochRaw),
};
} catch (err) {
if (isDockerProbeTimeoutError(err)) {
params.onTimeout?.();
}
return null;
}
}
function parsePublishedHostFromDockerPortLine(line: string): string | null {
const trimmed = normalizeOptionalString(line) ?? "";
const rhs = trimmed.includes("->")
? (normalizeOptionalString(trimmed.split("->").at(-1)) ?? "")
: trimmed;
if (!rhs) {
return null;
}
const bracketHost = rhs.match(/^\[([^\]]+)\]:\d+$/);
if (bracketHost?.[1]) {
return bracketHost[1];
}
const hostPort = rhs.match(/^([^:]+):\d+$/);
if (hostPort?.[1]) {
return hostPort[1];
}
return null;
}
function isLoopbackPublishHost(host: string): boolean {
const normalized = normalizeOptionalLowercaseString(host);
return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost";
}
async function readSandboxBrowserPortMappings(params: {
containerName: string;
execDockerRawFn: ExecDockerRawFn;
timeoutMs: number;
onTimeout?: () => void;
}): Promise<string[] | null> {
try {
const result = await withDockerProbeTimeout(params.timeoutMs, (signal) =>
params.execDockerRawFn(["port", params.containerName], {
allowFailure: true,
signal,
}),
);
if (result.code !== 0) {
return null;
}
return normalizeStringEntries(result.stdout.toString("utf8").split(/\r?\n/));
} catch (err) {
if (isDockerProbeTimeoutError(err)) {
params.onTimeout?.();
}
return null;
}
}
export async function collectSandboxBrowserHashLabelFindings(params?: {
execDockerRawFn?: ExecDockerRawFn;
timeoutMs?: number;
}): Promise<SecurityAuditFinding[]> {
const findings: SecurityAuditFinding[] = [];
const timeoutMs = normalizeDockerProbeTimeoutMs(params?.timeoutMs);
let timedOut = false;
const markTimedOut = () => {
timedOut = true;
};
const [execFn, browserHashEpoch] = await Promise.all([
params?.execDockerRawFn ? Promise.resolve(params.execDockerRawFn) : loadExecDockerRaw(),
loadSandboxBrowserSecurityHashEpoch(),
]);
const containers = await listSandboxBrowserContainers({
execDockerRawFn: execFn,
timeoutMs,
onTimeout: markTimedOut,
});
if (!containers || containers.length === 0) {
if (timedOut) {
findings.push(buildSandboxBrowserDockerProbeTimeoutFinding(timeoutMs));
}
return findings;
}
const missingHash: string[] = [];
const staleEpoch: string[] = [];
const nonLoopbackPublished: string[] = [];
for (const containerName of containers) {
const labels = await readSandboxBrowserHashLabels({
containerName,
execDockerRawFn: execFn,
timeoutMs,
onTimeout: markTimedOut,
});
if (timedOut) {
break;
}
if (!labels) {
continue;
}
if (!labels.configHash) {
missingHash.push(containerName);
}
if (labels.epoch !== browserHashEpoch) {
staleEpoch.push(containerName);
}
const portMappings = await readSandboxBrowserPortMappings({
containerName,
execDockerRawFn: execFn,
timeoutMs,
onTimeout: markTimedOut,
});
if (timedOut) {
break;
}
if (!portMappings?.length) {
continue;
}
const exposedMappings = portMappings.filter((line) => {
const host = parsePublishedHostFromDockerPortLine(line);
return Boolean(host && !isLoopbackPublishHost(host));
});
if (exposedMappings.length > 0) {
nonLoopbackPublished.push(`${containerName} (${exposedMappings.join("; ")})`);
}
}
if (missingHash.length > 0) {
findings.push({
checkId: "sandbox.browser_container.hash_label_missing",
severity: "warn",
title: "Sandbox browser container missing config hash label",
detail:
`Containers: ${missingHash.join(", ")}. ` +
"These browser containers predate hash-based drift checks and may miss security remediations until recreated.",
remediation: `${formatCliCommand("openclaw sandbox recreate --browser --all")} (add --force to skip prompt).`,
});
}
if (staleEpoch.length > 0) {
findings.push({
checkId: "sandbox.browser_container.hash_epoch_stale",
severity: "warn",
title: "Sandbox browser container hash epoch is stale",
detail:
`Containers: ${staleEpoch.join(", ")}. ` +
`Expected openclaw.browserConfigEpoch=${browserHashEpoch}.`,
remediation: `${formatCliCommand("openclaw sandbox recreate --browser --all")} (add --force to skip prompt).`,
});
}
if (nonLoopbackPublished.length > 0) {
findings.push({
checkId: "sandbox.browser_container.non_loopback_publish",
severity: "critical",
title: "Sandbox browser container publishes ports on non-loopback interfaces",
detail:
`Containers: ${nonLoopbackPublished.join(", ")}. ` +
"Sandbox browser observer/control ports should stay loopback-only to avoid unintended remote access.",
remediation:
`${formatCliCommand("openclaw sandbox recreate --browser --all")} (add --force to skip prompt), ` +
"then verify published ports are bound to 127.0.0.1.",
});
}
if (timedOut) {
findings.push(buildSandboxBrowserDockerProbeTimeoutFinding(timeoutMs));
}
return findings;
}
function buildSandboxBrowserDockerProbeTimeoutFinding(timeoutMs: number): SecurityAuditFinding {
return {
checkId: "sandbox.browser_container.docker_probe_timeout",
severity: "warn",
title: "Sandbox browser Docker audit probe timed out",
detail:
`Docker did not answer within ${timeoutMs}ms while checking sandbox browser containers. ` +
"OpenClaw skipped any remaining sandbox browser container drift checks for this status run.",
remediation:
"Retry after Docker is responsive, or recreate sandbox browser containers if drift is suspected.",
};
}
export async function collectIncludeFilePermFindings(params: {
configSnapshot: ConfigFileSnapshot;
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
execIcacls?: ExecFn;
}): Promise<SecurityAuditFinding[]> {
const findings: SecurityAuditFinding[] = [];
if (!params.configSnapshot.exists) {
return findings;
}
const configPath = params.configSnapshot.path;
const includePaths = await collectIncludePathsRecursive({
configPath,
parsed: params.configSnapshot.parsed,
env: params.env,
});
if (includePaths.length === 0) {
return findings;
}
const { formatPermissionDetail, formatPermissionRemediation, inspectPathPermissions } =
await loadAuditFsModule();
for (const p of includePaths) {
const perms = await inspectPathPermissions(p, {
env: params.env,
platform: params.platform,
exec: params.execIcacls,
});
if (!perms.ok) {
continue;
}
if (perms.worldWritable || perms.groupWritable) {
findings.push({
checkId: "fs.config_include.perms_writable",
severity: "critical",
title: "Config include file is writable by others",
detail: `${formatPermissionDetail(p, perms)}; another user could influence your effective config.`,
remediation: formatPermissionRemediation({
targetPath: p,
perms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
});
} else if (perms.worldReadable) {
findings.push({
checkId: "fs.config_include.perms_world_readable",
severity: "critical",
title: "Config include file is world-readable",
detail: `${formatPermissionDetail(p, perms)}; include files can contain tokens and private settings.`,
remediation: formatPermissionRemediation({
targetPath: p,
perms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
});
} else if (perms.groupReadable) {
findings.push({
checkId: "fs.config_include.perms_group_readable",
severity: "warn",
title: "Config include file is group-readable",
detail: `${formatPermissionDetail(p, perms)}; include files can contain tokens and private settings.`,
remediation: formatPermissionRemediation({
targetPath: p,
perms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
});
}
}
return findings;
}
export async function collectStateDeepFilesystemFindings(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
stateDir: string;
platform?: NodeJS.Platform;
execIcacls?: ExecFn;
}): Promise<SecurityAuditFinding[]> {
const findings: SecurityAuditFinding[] = [];
const oauthDir = resolveOAuthDir(params.env, params.stateDir);
const { formatPermissionDetail, formatPermissionRemediation, inspectPathPermissions } =
await loadAuditFsModule();
const oauthPerms = await inspectPathPermissions(oauthDir, {
env: params.env,
platform: params.platform,
exec: params.execIcacls,
});
if (oauthPerms.ok && oauthPerms.isDir) {
if (oauthPerms.worldWritable || oauthPerms.groupWritable) {
findings.push({
checkId: "fs.credentials_dir.perms_writable",
severity: "critical",
title: "Credentials dir is writable by others",
detail: `${formatPermissionDetail(oauthDir, oauthPerms)}; another user could drop/modify credential files.`,
remediation: formatPermissionRemediation({
targetPath: oauthDir,
perms: oauthPerms,
isDir: true,
posixMode: 0o700,
env: params.env,
}),
});
} else if (oauthPerms.groupReadable || oauthPerms.worldReadable) {
findings.push({
checkId: "fs.credentials_dir.perms_readable",
severity: "warn",
title: "Credentials dir is readable by others",
detail: `${formatPermissionDetail(oauthDir, oauthPerms)}; credentials and allowlists can be sensitive.`,
remediation: formatPermissionRemediation({
targetPath: oauthDir,
perms: oauthPerms,
isDir: true,
posixMode: 0o700,
env: params.env,
}),
});
}
}
const agentScope = await loadAgentScopeModule();
const agentIds = agentScope.listAgentEntries(params.cfg).map((agent) => agent.id);
let defaultAgentId: string | undefined;
if (agentIds.length > 0) {
try {
defaultAgentId = agentScope.resolveDefaultAgentId(params.cfg);
} catch {
// Security audits must still inspect known agent stores when a malformed
// roster prevents normal default selection; config findings report that defect.
}
}
const ids = uniqueStrings([
LEGACY_IMPLICIT_AGENT_ID,
...(defaultAgentId ? [defaultAgentId] : []),
...agentIds,
]).map((id) => normalizeAgentId(id));
for (const agentId of ids) {
const agentDir = path.join(params.stateDir, "agents", agentId, "agent");
const authTargets = [
{ path: path.join(agentDir, "auth-profiles.json"), label: "legacy auth-profiles.json" },
...resolveAuthProfileDatabaseFilePaths(agentDir).map((targetPath) => ({
path: targetPath,
label: "auth profile SQLite store",
})),
];
for (const authTarget of authTargets) {
const authPerms = await inspectPathPermissions(authTarget.path, {
env: params.env,
platform: params.platform,
exec: params.execIcacls,
});
if (authPerms.ok) {
if (authPerms.worldWritable || authPerms.groupWritable) {
findings.push({
checkId: "fs.auth_profiles.perms_writable",
severity: "critical",
title: `${authTarget.label} is writable by others`,
detail: `${formatPermissionDetail(authTarget.path, authPerms)}; another user could inject credentials.`,
remediation: formatPermissionRemediation({
targetPath: authTarget.path,
perms: authPerms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
});
} else if (authPerms.worldReadable || authPerms.groupReadable) {
findings.push({
checkId: "fs.auth_profiles.perms_readable",
severity: "warn",
title: `${authTarget.label} is readable by others`,
detail: `${formatPermissionDetail(authTarget.path, authPerms)}; auth profile storage contains API keys and OAuth tokens.`,
remediation: formatPermissionRemediation({
targetPath: authTarget.path,
perms: authPerms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
});
}
}
}
const storePath = path.join(params.stateDir, "agents", agentId, "sessions", "sessions.json");
const storePerms = await inspectPathPermissions(storePath, {
env: params.env,
platform: params.platform,
exec: params.execIcacls,
});
if (storePerms.ok) {
if (storePerms.worldReadable || storePerms.groupReadable) {
findings.push({
checkId: "fs.sessions_store.perms_readable",
severity: "warn",
title: "sessions.json is readable by others",
detail: `${formatPermissionDetail(storePath, storePerms)}; routing and transcript metadata can be sensitive.`,
remediation: formatPermissionRemediation({
targetPath: storePath,
perms: storePerms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
});
}
}
}
const logFile = normalizeOptionalString(params.cfg.logging?.file) ?? "";
if (logFile) {
const expanded = logFile.startsWith("~") ? expandTilde(logFile, params.env) : logFile;
if (expanded) {
const logPath = path.resolve(expanded);
const logPerms = await inspectPathPermissions(logPath, {
env: params.env,
platform: params.platform,
exec: params.execIcacls,
});
if (logPerms.ok) {
if (logPerms.worldReadable || logPerms.groupReadable) {
findings.push({
checkId: "fs.log_file.perms_readable",
severity: "warn",
title: "Log file is readable by others",
detail: `${formatPermissionDetail(logPath, logPerms)}; logs can contain private messages and tool output.`,
remediation: formatPermissionRemediation({
targetPath: logPath,
perms: logPerms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
});
}
}
}
}
return findings;
}
export async function readConfigSnapshotForAudit(params: {
env: NodeJS.ProcessEnv;
configPath: string;
}): Promise<ConfigFileSnapshot> {
const { createConfigIO } = await loadConfigModule();
return await createConfigIO({
env: params.env,
configPath: params.configPath,
}).readConfigFileSnapshot();
}
export async function collectPluginsCodeSafetyFindings(params: {
stateDir: string;
summaryCache?: CodeSafetySummaryCache;
}): Promise<SecurityAuditFinding[]> {
const findings: SecurityAuditFinding[] = [];
const { extensionsDir, pluginDirs } = await listInstalledPluginDirs({
stateDir: params.stateDir,
onReadError: (err) => {
findings.push({
checkId: "plugins.code_safety.scan_failed",
severity: "warn",
title: "Plugin extensions directory scan failed",
detail: `Static code scan could not list extensions directory: ${String(err)}`,
remediation:
"Check file permissions and plugin layout, then rerun `openclaw security audit --deep`.",
});
},
});
for (const pluginName of pluginDirs) {
const pluginPath = path.join(extensionsDir, pluginName);
let extensionEntries: string[] = [];
try {
extensionEntries = await readPluginManifestExtensions(pluginPath);
} catch (manifestErr) {
// Malformed package.json — surface a warning so the user investigates.
// A plugin could deliberately corrupt its manifest to hide declared
// extension entrypoints from the deep code scanner.
findings.push({
checkId: "plugins.code_safety.manifest_parse_error",
severity: "warn",
title: `Plugin "${pluginName}" has a malformed package.json`,
detail:
`Could not parse plugin manifest: ${String(manifestErr)}.\n` +
"The extension entrypoint list is unavailable. Deep scan will cover the plugin directory but may miss entries declared via `openclaw.extensions`.",
remediation:
"Inspect the plugin package.json for syntax errors. If the plugin is untrusted, remove it from your OpenClaw extensions state directory.",
});
// Continue — getCodeSafetySummary below still scans the plugin directory
}
const forcedScanEntries: string[] = [];
const escapedEntries: string[] = [];
for (const entry of extensionEntries) {
const resolvedEntry = path.resolve(pluginPath, entry);
if (!isPathInside(pluginPath, resolvedEntry)) {
escapedEntries.push(entry);
continue;
}
if (extensionUsesSkippedScannerPath(entry)) {
findings.push({
checkId: "plugins.code_safety.entry_path",
severity: "warn",
title: `Plugin "${pluginName}" entry path is hidden or node_modules`,
detail: `Extension entry "${entry}" points to a hidden or node_modules path. Deep code scan will cover this entry explicitly, but review this path choice carefully.`,
remediation: "Prefer extension entrypoints under normal source paths like dist/ or src/.",
});
}
forcedScanEntries.push(resolvedEntry);
}
if (escapedEntries.length > 0) {
findings.push({
checkId: "plugins.code_safety.entry_escape",
severity: "critical",
title: `Plugin "${pluginName}" has extension entry path traversal`,
detail: `Found extension entries that escape the plugin directory:\n${escapedEntries.map((entry) => ` - ${entry}`).join("\n")}`,
remediation:
"Update the plugin manifest so all openclaw.extensions entries stay inside the plugin directory.",
});
}
const summary = await getCodeSafetySummary({
dirPath: pluginPath,
includeFiles: forcedScanEntries,
summaryCache: params.summaryCache,
}).catch((err: unknown) => {
findings.push({
checkId: "plugins.code_safety.scan_failed",
severity: "warn",
title: `Plugin "${pluginName}" code scan failed`,
detail: `Static code scan could not complete: ${String(err)}`,
remediation:
"Check file permissions and plugin layout, then rerun `openclaw security audit --deep`.",
});
return null;
});
if (!summary) {
continue;
}
if (summary.critical > 0) {
const criticalFindings = summary.findings.filter((f) => f.severity === "critical");
const details = formatCodeSafetyDetails(criticalFindings, pluginPath);
findings.push({
checkId: "plugins.code_safety",
severity: "critical",
title: `Plugin "${pluginName}" contains dangerous code patterns`,
detail: `Found ${summary.critical} critical issue(s) in ${summary.scannedFiles} scanned file(s):\n${details}`,
remediation:
"Review the plugin source code carefully before use. If untrusted, remove the plugin from your OpenClaw extensions state directory.",
});
} else if (summary.warn > 0) {
const warnFindings = summary.findings.filter((f) => f.severity === "warn");
const details = formatCodeSafetyDetails(warnFindings, pluginPath);
findings.push({
checkId: "plugins.code_safety",
severity: "warn",
title: `Plugin "${pluginName}" contains suspicious code patterns`,
detail: `Found ${summary.warn} warning(s) in ${summary.scannedFiles} scanned file(s):\n${details}`,
remediation: `Review the flagged code to ensure it is intentional and safe.`,
});
}
}
return findings;
}
export async function collectInstalledSkillsCodeSafetyFindings(params: {
cfg: OpenClawConfig;
stateDir: string;
workspaceDir?: string;
summaryCache?: CodeSafetySummaryCache;
}): Promise<SecurityAuditFinding[]> {
const findings: SecurityAuditFinding[] = [];
const pluginExtensionsDir = path.join(params.stateDir, "extensions");
const scannedSkillDirs = new Set<string>();
const [{ listAgentWorkspaceDirs, listExplicitAgentWorkspaceDirs }, { resolveSkillSource }] =
await Promise.all([loadAgentWorkspaceDirsModule(), loadSkillSourceModule()]);
const workspaceDirs = new Set(params.workspaceDir ? [params.workspaceDir] : []);
try {
for (const workspaceDir of listAgentWorkspaceDirs(params.cfg)) {
workspaceDirs.add(workspaceDir);
}
} catch {
// Deep audit accepts raw pre-migration and malformed configs. Continue
// scanning every entry-authored workspace instead of turning a finding into a crash.
for (const workspaceDir of listExplicitAgentWorkspaceDirs(params.cfg)) {
workspaceDirs.add(workspaceDir);
}
}
const { loadWorkspaceSkillEntries } = await loadSkillsModule();
for (const workspaceDir of workspaceDirs) {
const entries = loadWorkspaceSkillEntries(workspaceDir, {
config: params.cfg,
includeArchived: true,
});
for (const entry of entries) {
if (resolveSkillSource(entry.skill) === "openclaw-bundled") {
continue;
}
const skillDir = path.resolve(entry.skill.baseDir);
if (isPathInside(pluginExtensionsDir, skillDir)) {
// Plugin code is already covered by plugins.code_safety checks.
continue;
}
if (scannedSkillDirs.has(skillDir)) {
continue;
}
scannedSkillDirs.add(skillDir);
const skillName = entry.skill.name;
const summary = await getSkillCodeSafetySummary({
dirPath: skillDir,
skillFilePath: entry.skill.filePath,
summaryCache: params.summaryCache,
}).catch((err: unknown) => {
findings.push({
checkId: "skills.code_safety.scan_failed",
severity: "warn",
title: `Skill "${skillName}" code scan failed`,
detail: `Static code scan could not complete for ${skillDir}: ${String(err)}`,
remediation:
"Check file permissions and skill layout, then rerun `openclaw security audit --deep`.",
});
return null;
});
if (!summary) {
continue;
}
if (summary.critical > 0) {
const criticalFindings = summary.findings.filter(
(finding) => finding.severity === "critical",
);
const details = formatCodeSafetyDetails(criticalFindings, skillDir);
findings.push({
checkId: "skills.code_safety",
severity: "critical",
title: `Skill "${skillName}" contains dangerous code patterns`,
detail: `Found ${summary.critical} critical issue(s) in ${summary.scannedFiles} scanned file(s) under ${skillDir}:\n${details}`,
remediation: `Review the skill source code before use. If untrusted, remove "${skillDir}".`,
});
} else if (summary.warn > 0) {
const warnFindings = summary.findings.filter((finding) => finding.severity === "warn");
const details = formatCodeSafetyDetails(warnFindings, skillDir);
findings.push({
checkId: "skills.code_safety",
severity: "warn",
title: `Skill "${skillName}" contains suspicious code patterns`,
detail: `Found ${summary.warn} warning(s) in ${summary.scannedFiles} scanned file(s) under ${skillDir}:\n${details}`,
remediation: "Review flagged lines to ensure the behavior is intentional and safe.",
});
}
}
}
return findings;
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */