Files
openclaw/src/security/audit-plugins-trust.test.ts
pashpashpash 563dca82f4 Add Codex happy path prompt snapshots (#75807)
* Add Codex prompt snapshots

* Fix prompt snapshot scenario catalogs

* Harden prompt snapshot drift check

* Fix CLI compat build export

* fix: keep codex snapshots out of core plugin surface

* fix: harden prompt snapshot ci checks

* fix: accept readonly web search onboarding scopes

* fix: repair plugin sdk package boundary types

* fix: clear prompt snapshot ci regressions

* fix: clear latest main ci checks

* fix: resolve latest main discord helper overlap

* fix: refresh codex dynamic tool snapshots

* fix: align prompt snapshot branch with latest ci

* fix: isolate plugin auto enable tests

* test: refresh prompt dynamic tool snapshots

* fix: stabilize bundled channel auto enable

* fix: clean stale prompt snapshots
2026-05-03 00:59:55 +09:00

601 lines
19 KiB
TypeScript

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<CollectPluginsTrustFindings>
): Promise<Awaited<ReturnType<CollectPluginsTrustFindings>>> {
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<string, { enabled?: boolean }>;
};
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<string, { enabled?: boolean }>;
}
| 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<string, PluginInstallRecord>,
) => {
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<Awaited<ReturnType<typeof runInstallMetadataAudit>>>;
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<Record<(typeof pathResolutionEnvKeys)[number], string>> =
{};
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<ReturnType<typeof runSharedExtensionsAudit>>) => {
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<ReturnType<typeof runSharedExtensionsAudit>>) => {
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<ReturnType<typeof runSharedExtensionsAudit>>) => {
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<ReturnType<typeof runSharedExtensionsAudit>>) => {
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<ReturnType<typeof runSharedExtensionsAudit>>) => {
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));
}
},
);
});
});