From f33a812c0740526f1fd0329e2414ed118387c0c5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:01:01 +0100 Subject: [PATCH] fix: validate plugin package extension entries --- src/plugins/discovery.ts | 27 +---- src/plugins/install.test.ts | 75 ++++++++++++++ src/plugins/install.ts | 160 ++++++++++++++++++++++++++--- src/plugins/package-entrypoints.ts | 27 +++++ 4 files changed, 251 insertions(+), 38 deletions(-) create mode 100644 src/plugins/package-entrypoints.ts diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index f1406445fb8..970460c49e1 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -19,6 +19,7 @@ import { type OpenClawPackageManifest, type PackageManifest, } from "./manifest.js"; +import { listBuiltRuntimeEntryCandidates } from "./package-entrypoints.js"; import { formatPosixMode, isPathInside, safeRealpathSync, safeStatSync } from "./path-safety.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; import { resolvePluginCacheInputs, resolvePluginSourceRoots } from "./roots.js"; @@ -613,10 +614,6 @@ function resolvePackageEntrySource(params: { return openCandidate(source); } -function isTypeScriptPackageEntry(entryPath: string): boolean { - return [".ts", ".mts", ".cts"].includes(normalizeLowercaseStringOrEmpty(path.extname(entryPath))); -} - function shouldInferBuiltRuntimeEntry(origin: PluginOrigin): boolean { return origin === "config" || origin === "global"; } @@ -663,28 +660,6 @@ function resolveSafePackageEntry(params: { return { relativePath: path.relative(params.packageDir, absolutePath).replace(/\\/g, "/") }; } -function listBuiltRuntimeEntryCandidates(entryPath: string): string[] { - if (!isTypeScriptPackageEntry(entryPath)) { - return []; - } - const normalized = entryPath.replace(/\\/g, "/"); - const withoutExtension = normalized.replace(/\.[^.]+$/u, ""); - const normalizedRelative = normalized.replace(/^\.\//u, ""); - const distWithoutExtension = normalizedRelative.startsWith("src/") - ? `./dist/${normalizedRelative.slice("src/".length).replace(/\.[^.]+$/u, "")}` - : `./dist/${withoutExtension.replace(/^\.\//u, "")}`; - const withJavaScriptExtensions = (basePath: string) => [ - `${basePath}.js`, - `${basePath}.mjs`, - `${basePath}.cjs`, - ]; - const candidates = [ - ...withJavaScriptExtensions(distWithoutExtension), - ...withJavaScriptExtensions(withoutExtension), - ]; - return [...new Set(candidates)].filter((candidate) => candidate !== normalized); -} - function resolveExistingPackageEntrySource(params: { packageDir: string; entryPath: string; diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index a0f81ae98b8..f2c0abea3d8 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -790,6 +790,81 @@ describe("installPluginFromArchive", () => { expect.unreachable("expected install to fail without openclaw.extensions"); }); + it("rejects package installs when openclaw.extensions entries escape the package", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "escaping-entry-plugin", + version: "1.0.0", + openclaw: { + extensions: ["../src/index.ts"], + runtimeExtensions: ["./dist/index.js"], + }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};\n"); + + const result = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS); + expect(result.error).toContain("extension entry escapes plugin directory"); + } + }); + + it("rejects package installs when no extension runtime entry exists", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "missing-entry-plugin", + version: "1.0.0", + openclaw: { extensions: ["./dist/index.js"] }, + }), + ); + + const result = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS); + expect(result.error).toContain("extension entry not found"); + } + }); + + it("allows missing TypeScript source entries when an inferred built runtime entry exists", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "inferred-runtime-plugin", + version: "1.0.0", + openclaw: { extensions: ["./src/index.ts"] }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};\n"); + + const result = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.pluginId).toBe("inferred-runtime-plugin"); + } + }); + it("blocks package installs when plugin contains dangerous code patterns", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs(); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 0d23636a27b..591aeca3461 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -1,5 +1,8 @@ +import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; +import { matchBoundaryFileOpenFailure, openBoundaryFile } from "../infra/boundary-file-read.js"; +import { resolveBoundaryPath } from "../infra/boundary-path.js"; import { packageNameMatchesId, resolveSafeInstallDir, @@ -14,9 +17,11 @@ import { CONFIG_DIR, resolveUserPath } from "../utils.js"; import type { InstallSecurityScanResult } from "./install-security-scan.js"; import type { InstallSafetyOverrides } from "./install-security-scan.js"; import { + getPackageManifestMetadata, resolvePackageExtensionEntries, type PackageManifest as PluginPackageManifest, } from "./manifest.js"; +import { listBuiltRuntimeEntryCandidates } from "./package-entrypoints.js"; let pluginInstallRuntimePromise: Promise | undefined; @@ -54,6 +59,7 @@ export const PLUGIN_INSTALL_ERROR_CODE = { MISSING_OPENCLAW_EXTENSIONS: "missing_openclaw_extensions", MISSING_PLUGIN_MANIFEST: "missing_plugin_manifest", EMPTY_OPENCLAW_EXTENSIONS: "empty_openclaw_extensions", + INVALID_OPENCLAW_EXTENSIONS: "invalid_openclaw_extensions", NPM_PACKAGE_NOT_FOUND: "npm_package_not_found", PLUGIN_ID_MISMATCH: "plugin_id_mismatch", SECURITY_SCAN_BLOCKED: "security_scan_blocked", @@ -186,6 +192,139 @@ function ensureOpenClawExtensions(params: { manifest: PackageManifest }): }; } +type ExtensionEntryValidation = { ok: true; exists: boolean } | { ok: false; error: string }; + +async function validatePackageExtensionEntry(params: { + packageDir: string; + entry: string; + label: string; + requireExisting: boolean; +}): Promise { + const absolutePath = path.resolve(params.packageDir, params.entry); + try { + const resolved = await resolveBoundaryPath({ + absolutePath, + rootPath: params.packageDir, + boundaryLabel: "plugin package directory", + }); + if (!resolved.exists) { + return params.requireExisting + ? { ok: false, error: `${params.label} not found: ${params.entry}` } + : { ok: true, exists: false }; + } + } catch { + return { + ok: false, + error: `${params.label} escapes plugin directory: ${params.entry}`, + }; + } + + const opened = await openBoundaryFile({ + absolutePath, + rootPath: params.packageDir, + boundaryLabel: "plugin package directory", + }); + if (!opened.ok) { + return matchBoundaryFileOpenFailure(opened, { + path: () => ({ ok: false, error: `${params.label} not found: ${params.entry}` }), + io: () => ({ ok: false, error: `${params.label} unreadable: ${params.entry}` }), + validation: () => ({ + ok: false, + error: `${params.label} failed plugin directory boundary checks: ${params.entry}`, + }), + fallback: () => ({ + ok: false, + error: `${params.label} failed plugin directory boundary checks: ${params.entry}`, + }), + }); + } + fsSync.closeSync(opened.fd); + return { ok: true, exists: true }; +} + +async function validatePackageExtensionEntries(params: { + packageDir: string; + extensions: string[]; + manifest: PackageManifest; +}): Promise<{ ok: true } | { ok: false; error: string; code: PluginInstallErrorCode }> { + const packageMetadata = getPackageManifestMetadata(params.manifest); + const runtimeExtensions = Array.isArray(packageMetadata?.runtimeExtensions) + ? packageMetadata.runtimeExtensions + .map((entry) => normalizeOptionalString(entry) ?? "") + .filter(Boolean) + : []; + const useRuntimeExtensions = runtimeExtensions.length === params.extensions.length; + + for (const [index, entry] of params.extensions.entries()) { + const sourceEntry = await validatePackageExtensionEntry({ + packageDir: params.packageDir, + entry, + label: "extension entry", + requireExisting: false, + }); + if (!sourceEntry.ok) { + return { + ok: false, + error: sourceEntry.error, + code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS, + }; + } + + const runtimeEntry = useRuntimeExtensions ? runtimeExtensions[index] : undefined; + if (runtimeEntry) { + const runtimeResult = await validatePackageExtensionEntry({ + packageDir: params.packageDir, + entry: runtimeEntry, + label: "runtime extension entry", + requireExisting: true, + }); + if (!runtimeResult.ok) { + return { + ok: false, + error: runtimeResult.error, + code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS, + }; + } + continue; + } + + if (sourceEntry.exists) { + continue; + } + + let foundBuiltEntry = false; + for (const builtEntry of listBuiltRuntimeEntryCandidates(entry)) { + const builtResult = await validatePackageExtensionEntry({ + packageDir: params.packageDir, + entry: builtEntry, + label: "inferred runtime extension entry", + requireExisting: false, + }); + if (!builtResult.ok) { + return { + ok: false, + error: builtResult.error, + code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS, + }; + } + if (builtResult.exists) { + foundBuiltEntry = true; + break; + } + } + + if (!foundBuiltEntry) { + return { + ok: false, + error: `extension entry not found: ${entry}`, + code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS, + }; + } + } + + return { ok: true }; +} + function isNpmPackageNotFoundMessage(error: string): boolean { const normalized = error.trim(); if (normalized.startsWith("Package not found on npm:")) { @@ -766,6 +905,15 @@ async function installPluginFromPackageDir( }; } + const extensionValidation = await validatePackageExtensionEntries({ + packageDir: params.packageDir, + extensions, + manifest, + }); + if (!extensionValidation.ok) { + return extensionValidation; + } + const targetResult = await resolvePreparedDirectoryInstallTarget({ runtime, pluginId, @@ -819,18 +967,6 @@ async function installPluginFromPackageDir( hasDeps: Object.keys(deps).length > 0, depsLogMessage: "Installing plugin dependencies…", nameEncoder: encodePluginInstallDirName, - afterCopy: async (installedDir) => { - for (const entry of extensions) { - const resolvedEntry = path.resolve(installedDir, entry); - if (!runtime.isPathInside(installedDir, resolvedEntry)) { - logger.warn?.(`extension entry escapes plugin directory: ${entry}`); - continue; - } - if (!(await runtime.fileExists(resolvedEntry))) { - logger.warn?.(`extension entry not found: ${entry}`); - } - } - }, afterInstall: async (installedDir) => { // Run the dependency-tree security scan BEFORE linking peer deps. // The scan rejects any node_modules/ symlink whose target resolves diff --git a/src/plugins/package-entrypoints.ts b/src/plugins/package-entrypoints.ts new file mode 100644 index 00000000000..ccfe55ebdba --- /dev/null +++ b/src/plugins/package-entrypoints.ts @@ -0,0 +1,27 @@ +import path from "node:path"; + +export function isTypeScriptPackageEntry(entryPath: string): boolean { + return [".ts", ".mts", ".cts"].includes(path.extname(entryPath).toLowerCase()); +} + +export function listBuiltRuntimeEntryCandidates(entryPath: string): string[] { + if (!isTypeScriptPackageEntry(entryPath)) { + return []; + } + const normalized = entryPath.replace(/\\/g, "/"); + const withoutExtension = normalized.replace(/\.[^.]+$/u, ""); + const normalizedRelative = normalized.replace(/^\.\//u, ""); + const distWithoutExtension = normalizedRelative.startsWith("src/") + ? `./dist/${normalizedRelative.slice("src/".length).replace(/\.[^.]+$/u, "")}` + : `./dist/${withoutExtension.replace(/^\.\//u, "")}`; + const withJavaScriptExtensions = (basePath: string) => [ + `${basePath}.js`, + `${basePath}.mjs`, + `${basePath}.cjs`, + ]; + const candidates = [ + ...withJavaScriptExtensions(distWithoutExtension), + ...withJavaScriptExtensions(withoutExtension), + ]; + return [...new Set(candidates)].filter((candidate) => candidate !== normalized); +}