import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import type { InstalledPluginIndex } from "../plugins/installed-plugin-index.js"; import { createPathResolutionEnv, withEnvAsync } from "../test-utils/env.js"; type CollectPluginsTrustFindings = typeof import("./audit-plugins-trust.js").collectPluginsTrustFindings; async function collectPluginsTrustFindingsForTest( ...args: Parameters ): Promise>> { vi.resetModules(); const { collectPluginsTrustFindings } = await import("./audit-plugins-trust.js"); return await collectPluginsTrustFindings(...args); } const mockChannelPlugins = vi.hoisted(() => [ { id: "discord", capabilities: {}, commands: {}, config: { listAccountIds: () => [], resolveAccount: () => null, }, }, ]); const mockPluginRegistryIds = vi.hoisted(() => [ "active-memory", "anthropic", "brave", "discord", "google", "lmstudio", "memory-core", "ollama", ]); const readInstalledPackageVersionMock = vi.hoisted(() => vi.fn(async (dir: string) => { if (dir.includes("/extensions/voice-call") || dir.includes("\\extensions\\voice-call")) { return "9.9.9"; } if (dir.includes("/hooks/test-hooks") || dir.includes("\\hooks\\test-hooks")) { return "8.8.8"; } return undefined; }), ); vi.mock("../infra/package-update-utils.js", () => ({ readInstalledPackageVersion: readInstalledPackageVersionMock, })); vi.mock("../plugins/config-state.js", () => ({ normalizePluginId: (id: string) => id, resolveEffectiveEnableState: (params: { config?: { enabled?: boolean; deny?: string[]; allow?: string[]; entries?: Record; }; id: string; enabledByDefault?: boolean; }) => { const entry = params.config?.entries?.[params.id]; const denied = params.config?.deny?.includes(params.id) === true; const allowed = !params.config?.allow?.length || params.config.allow.includes(params.id) || params.config.allow.includes("group:plugins"); const enabled = params.config?.enabled !== false && !denied && allowed && entry?.enabled !== false && (entry?.enabled === true || params.enabledByDefault === true); return { enabled, activated: enabled, reason: enabled ? "enabled" : "disabled", }; }, normalizePluginsConfig: ( config: | { allow?: string[]; deny?: string[]; enabled?: boolean; entries?: Record; } | undefined, ) => ({ allow: config?.allow ?? [], deny: config?.deny ?? [], enabled: config?.enabled !== false, entries: config?.entries ?? {}, }), })); vi.mock("../plugins/plugin-registry.js", () => ({ createPluginRegistryIdNormalizer: () => (id: string) => id, loadPluginRegistrySnapshot: () => ({ diagnostics: [], plugins: mockPluginRegistryIds.map((pluginId) => ({ pluginId })), }), })); vi.mock("../config/commands.js", () => ({ resolveNativeSkillsEnabled: ({ globalSetting, providerSetting, }: { globalSetting?: boolean | "auto"; providerSetting?: boolean | "auto"; }) => providerSetting === true || (providerSetting === undefined && globalSetting === true), })); vi.mock("../channels/plugins/read-only.js", () => ({ listReadOnlyChannelPluginsForConfig: () => mockChannelPlugins, })); vi.mock("../channels/read-only-account-inspect.js", () => ({ inspectReadOnlyChannelAccount: () => null, })); vi.mock("../agents/sandbox/config.js", () => ({ resolveSandboxConfigForAgent: () => ({ mode: "off" }), })); vi.mock("../agents/sandbox/tool-policy.js", () => ({ resolveSandboxToolPolicyForAgent: () => undefined, })); vi.mock("../agents/tool-policy-match.js", () => ({ isToolAllowedByPolicies: (_tool: string, policies: unknown[]) => policies.every((policy) => policy == null), })); vi.mock("../agents/tool-policy.js", () => ({ resolveToolProfilePolicy: (profile: unknown) => profile === "coding" || profile === "minimal" ? {} : undefined, })); vi.mock("./audit-tool-policy.js", () => ({ pickSandboxToolPolicy: () => undefined, })); describe("security audit install metadata findings", () => { let fixtureRoot = ""; let caseId = 0; const makeTmpDir = async (label: string) => { const dir = path.join(fixtureRoot, `case-${caseId++}-${label}`); await fs.mkdir(dir, { recursive: true }); return dir; }; const runInstallMetadataAudit = async (cfg: OpenClawConfig, stateDir: string) => { return await collectPluginsTrustFindingsForTest({ cfg, stateDir }); }; const writePluginIndexInstallRecords = async ( stateDir: string, records: Record, ) => { const index: InstalledPluginIndex = { version: 1, hostContractVersion: "2026.4.25", compatRegistryVersion: "compat", migrationVersion: 1, policyHash: "policy", generatedAtMs: Date.now(), installRecords: records, plugins: Object.keys(records).map((pluginId) => ({ pluginId, manifestPath: path.join(stateDir, "extensions", pluginId, "openclaw.plugin.json"), manifestHash: "manifest", rootDir: path.join(stateDir, "extensions", pluginId), origin: "global" as const, enabled: true, startup: { sidecar: true, memory: false, deferConfiguredChannelFullLoadUntilAfterListen: false, agentHarnesses: [], }, compat: [], })), diagnostics: [], }; const filePath = path.join(stateDir, "plugins", "installs.json"); await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 }); await fs.writeFile(filePath, `${JSON.stringify(index, null, 2)}\n`, { mode: 0o600 }); }; beforeAll(async () => { fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-install-")); }); afterAll(async () => { if (fixtureRoot) { await fs.rm(fixtureRoot, { recursive: true, force: true }).catch(() => undefined); } }); it("evaluates install metadata findings", async () => { const cases: Array<{ name: string; run: () => Promise>>; expectedPresent?: readonly string[]; expectedAbsent?: readonly string[]; }> = [ { name: "warns on unpinned npm install specs and missing integrity metadata", run: async () => { const stateDir = await makeTmpDir("unpinned-plugin-index"); await writePluginIndexInstallRecords(stateDir, { "voice-call": { source: "npm", spec: "@openclaw/voice-call", }, }); return runInstallMetadataAudit( { hooks: { internal: { installs: { "test-hooks": { source: "npm", spec: "@openclaw/test-hooks", }, }, }, }, }, stateDir, ); }, expectedPresent: [ "plugins.installs_unpinned_npm_specs", "plugins.installs_missing_integrity", "hooks.installs_unpinned_npm_specs", "hooks.installs_missing_integrity", ], }, { name: "does not warn on pinned npm install specs with integrity metadata", run: async () => { const stateDir = await makeTmpDir("pinned-plugin-index"); await writePluginIndexInstallRecords(stateDir, { "voice-call": { source: "npm", spec: "@openclaw/voice-call@1.2.3", integrity: "sha512-plugin", }, }); return runInstallMetadataAudit( { hooks: { internal: { installs: { "test-hooks": { source: "npm", spec: "@openclaw/test-hooks@1.2.3", integrity: "sha512-hook", }, }, }, }, }, stateDir, ); }, expectedAbsent: [ "plugins.installs_unpinned_npm_specs", "plugins.installs_missing_integrity", "hooks.installs_unpinned_npm_specs", "hooks.installs_missing_integrity", ], }, { name: "warns when install records drift from installed package versions", run: async () => { const stateDir = await makeTmpDir("drift-plugin-index"); await writePluginIndexInstallRecords(stateDir, { "voice-call": { source: "npm", spec: "@openclaw/voice-call@1.2.3", integrity: "sha512-plugin", resolvedVersion: "1.2.3", }, }); return runInstallMetadataAudit( { hooks: { internal: { installs: { "test-hooks": { source: "npm", spec: "@openclaw/test-hooks@1.2.3", integrity: "sha512-hook", resolvedVersion: "1.2.3", }, }, }, }, }, stateDir, ); }, expectedPresent: ["plugins.installs_version_drift", "hooks.installs_version_drift"], }, ]; for (const testCase of cases) { const findings = await testCase.run(); for (const checkId of testCase.expectedPresent ?? []) { expect( findings.some((finding) => finding.checkId === checkId && finding.severity === "warn"), testCase.name, ).toBe(true); } for (const checkId of testCase.expectedAbsent ?? []) { expect( findings.some((finding) => finding.checkId === checkId), testCase.name, ).toBe(false); } } }); it("evaluates phantom allowlist findings", async () => { const bundledStateDir = await makeTmpDir("phantom-bundled-excluded"); await fs.mkdir(path.join(bundledStateDir, "extensions", "some-installed-plugin"), { recursive: true, }); const bundledFindings = await runInstallMetadataAudit( { plugins: { allow: ["discord", "some-installed-plugin"] }, }, bundledStateDir, ); expect( bundledFindings.find((finding) => finding.checkId === "plugins.allow_phantom_entries"), ).toBeUndefined(); const reportedStateDir = await makeTmpDir("phantom-reported"); await fs.mkdir(path.join(reportedStateDir, "extensions", "installed-plugin"), { recursive: true, }); const reportedFindings = await runInstallMetadataAudit( { plugins: { allow: ["installed-plugin", "ghost-plugin-xyz"] }, }, reportedStateDir, ); const phantomFinding = reportedFindings.find( (finding) => finding.checkId === "plugins.allow_phantom_entries", ); expect(phantomFinding?.severity).toBe("warn"); expect(phantomFinding?.detail).toContain("ghost-plugin-xyz"); expect(phantomFinding?.detail).not.toContain("installed-plugin"); }); it("ignores install backup and debris dirs when auditing installed plugin roots", async () => { const stateDir = await makeTmpDir("installed-plugin-debris"); for (const name of [ "live-plugin", ".openclaw-install-backups", "node_modules", "old-plugin.backup-20260502", "old-plugin.disabled.20260502", "old-plugin.bak", ]) { await fs.mkdir(path.join(stateDir, "extensions", name), { recursive: true, }); } const findings = await runInstallMetadataAudit({}, stateDir); const noAllowlist = findings.find( (finding) => finding.checkId === "plugins.extensions_no_allowlist", ); expect(noAllowlist?.detail).toContain("Found 1 extension(s)"); const toolsReachable = findings.find( (finding) => finding.checkId === "plugins.tools_reachable_permissive_policy", ); expect(toolsReachable?.detail).toContain("Enabled extension plugins: live-plugin."); expect(findings.map((finding) => finding.detail).join("\n")).not.toContain( ".openclaw-install-backups", ); }); it("does not report bundled provider and utility plugins as phantom allowlist entries", async () => { const stateDir = await makeTmpDir("phantom-bundled-providers"); await fs.mkdir(path.join(stateDir, "extensions", "installed-plugin"), { recursive: true, }); const findings = await runInstallMetadataAudit( { plugins: { allow: [ "active-memory", "anthropic", "brave", "google", "lmstudio", "memory-core", "ollama", "installed-plugin", ], }, }, stateDir, ); expect( findings.find((finding) => finding.checkId === "plugins.allow_phantom_entries"), ).toBeUndefined(); }); }); describe("security audit extension tool reachability findings", () => { let fixtureRoot = ""; let sharedExtensionsStateDir = ""; let isolatedHome = ""; let homedirSpy: { mockRestore(): void } | undefined; const pathResolutionEnvKeys = [ "HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR", "OPENCLAW_BUNDLED_PLUGINS_DIR", ] as const; const previousPathResolutionEnv: Partial> = {}; const runSharedExtensionsAudit = async (config: OpenClawConfig) => { return await collectPluginsTrustFindingsForTest({ cfg: config, stateDir: sharedExtensionsStateDir, }); }; beforeAll(async () => { const osModule = await import("node:os"); const vitestModule = await import("vitest"); fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-extensions-")); isolatedHome = path.join(fixtureRoot, "home"); const isolatedEnv = createPathResolutionEnv(isolatedHome, { OPENCLAW_HOME: isolatedHome }); for (const key of pathResolutionEnvKeys) { previousPathResolutionEnv[key] = process.env[key]; const value = isolatedEnv[key]; if (value === undefined) { delete process.env[key]; } else { process.env[key] = value; } } homedirSpy = vitestModule.vi .spyOn(osModule.default ?? osModule, "homedir") .mockReturnValue(isolatedHome); await fs.mkdir(isolatedHome, { recursive: true, mode: 0o700 }); sharedExtensionsStateDir = path.join(fixtureRoot, "shared-extensions-state"); await fs.mkdir(path.join(sharedExtensionsStateDir, "extensions", "some-plugin"), { recursive: true, mode: 0o700, }); }); afterAll(async () => { homedirSpy?.mockRestore(); for (const key of pathResolutionEnvKeys) { const value = previousPathResolutionEnv[key]; if (value === undefined) { delete process.env[key]; } else { process.env[key] = value; } } if (fixtureRoot) { await fs.rm(fixtureRoot, { recursive: true, force: true }).catch(() => undefined); } }); it("evaluates extension tool reachability findings", async () => { const cases = [ { name: "flags extensions without plugins.allow", cfg: {} satisfies OpenClawConfig, assert: (findings: Awaited>) => { expect( findings.some( (finding) => finding.checkId === "plugins.extensions_no_allowlist" && finding.severity === "warn", ), ).toBe(true); }, }, { name: "flags enabled extensions when tool policy can expose plugin tools", cfg: { plugins: { allow: ["some-plugin"] }, } satisfies OpenClawConfig, assert: (findings: Awaited>) => { expect( findings.some( (finding) => finding.checkId === "plugins.tools_reachable_permissive_policy" && finding.severity === "warn", ), ).toBe(true); }, }, { name: "does not flag plugin tool reachability when profile is restrictive", cfg: { plugins: { allow: ["some-plugin"] }, tools: { profile: "coding" }, } satisfies OpenClawConfig, assert: (findings: Awaited>) => { expect( findings.some( (finding) => finding.checkId === "plugins.tools_reachable_permissive_policy", ), ).toBe(false); }, }, { name: "flags unallowlisted extensions as warn-level findings when extension inventory exists", cfg: { channels: { discord: { enabled: true, token: "t" }, }, } satisfies OpenClawConfig, assert: (findings: Awaited>) => { expect( findings.some( (finding) => finding.checkId === "plugins.extensions_no_allowlist" && finding.severity === "warn", ), ).toBe(true); }, }, { name: "treats SecretRef channel credentials as configured for extension allowlist severity", cfg: { channels: { discord: { enabled: true, token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN", } as unknown as string, }, }, } satisfies OpenClawConfig, assert: (findings: Awaited>) => { expect( findings.some( (finding) => finding.checkId === "plugins.extensions_no_allowlist" && finding.severity === "warn", ), ).toBe(true); }, }, ] as const; await withEnvAsync( { DISCORD_BOT_TOKEN: undefined, TELEGRAM_BOT_TOKEN: undefined, SLACK_BOT_TOKEN: undefined, SLACK_APP_TOKEN: undefined, }, async () => { for (const testCase of cases) { testCase.assert(await runSharedExtensionsAudit(testCase.cfg)); } }, ); }); });