fix: scan plugin runtime entries during install [AI] (#80998)

* fix: scan plugin runtime entries during install

* addressing review-skill

* addressing claude review

* docs: add changelog entry for PR merge
This commit is contained in:
Pavan Kumar Gondhi
2026-05-12 20:28:40 +05:30
committed by GitHub
parent b076215e82
commit a5dce367ce
5 changed files with 177 additions and 3 deletions
+1
View File
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- fix: scan plugin runtime entries during install [AI]. (#80998) Thanks @pgondhi987.
- Require auth for sandbox browser CDP relay [AI]. (#81002) Thanks @pgondhi987.
- fix: detect carried exec command forms [AI]. (#81000) Thanks @pgondhi987.
- Reject truncated exec approval commands [AI]. (#81001) Thanks @pgondhi987.
+55 -3
View File
@@ -4,6 +4,7 @@ import { tryReadJson } from "../infra/json-files.js";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import { extensionUsesSkippedScannerPath, isPathInside } from "../security/scan-paths.js";
import { scanDirectoryWithSummary } from "../security/skill-scanner.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import {
findBlockedPackageDirectoryInPath,
findBlockedPackageFileAliasInPath,
@@ -14,6 +15,7 @@ import {
import { getGlobalHookRunner } from "./hook-runner-global.js";
import { createBeforeInstallHookPayload } from "./install-policy-context.js";
import type { InstallSafetyOverrides } from "./install-security-scan.types.js";
import { listBuiltRuntimeEntryCandidates } from "./package-entrypoints.js";
type InstallScanLogger = {
warn?: (message: string) => void;
@@ -45,6 +47,12 @@ type PackageManifest = {
peerDependencies?: Record<string, string>;
};
type PackageExecutableScanMetadata = {
runtimeExtensions?: readonly string[];
runtimeSetupEntry?: string;
setupEntry?: string;
};
type PackageManifestTraversalLimits = {
maxDepth: number;
maxDirectories: number;
@@ -591,6 +599,45 @@ async function scanDirectoryTarget(params: {
}
}
function readStringList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((entry) => normalizeOptionalString(entry))
.filter((entry): entry is string => Boolean(entry));
}
function collectPackageExecutableScanEntries(params: {
extensions: string[];
packageMetadata?: PackageExecutableScanMetadata;
}): string[] {
const entries: string[] = [];
const metadata = params.packageMetadata;
const runtimeExtensions = readStringList(metadata?.runtimeExtensions);
for (const [index, extensionEntry] of params.extensions.entries()) {
entries.push(extensionEntry);
const runtimeEntry = runtimeExtensions[index];
if (runtimeEntry) {
entries.push(runtimeEntry);
continue;
}
entries.push(...listBuiltRuntimeEntryCandidates(extensionEntry));
}
const setupEntry = normalizeOptionalString(metadata?.setupEntry);
if (setupEntry) {
entries.push(setupEntry);
}
const runtimeSetupEntry = normalizeOptionalString(metadata?.runtimeSetupEntry);
if (runtimeSetupEntry) {
entries.push(runtimeSetupEntry);
} else if (setupEntry) {
entries.push(...listBuiltRuntimeEntryCandidates(setupEntry));
}
return [...new Set(entries)];
}
function buildBlockedScanResult(params: {
builtinScan: BuiltinInstallScan;
dangerouslyForceUnsafeInstall?: boolean;
@@ -805,6 +852,7 @@ export async function scanPackageInstallSourceRuntime(
extensions: string[];
logger: InstallScanLogger;
packageDir: string;
packageMetadata?: PackageExecutableScanMetadata;
pluginId: string;
requestKind?: PluginInstallRequestKind;
requestedSpecifier?: string;
@@ -824,17 +872,21 @@ export async function scanPackageInstallSourceRuntime(
}
const forcedScanEntries: string[] = [];
for (const entry of params.extensions) {
const executableEntries = collectPackageExecutableScanEntries({
extensions: params.extensions,
...(params.packageMetadata ? { packageMetadata: params.packageMetadata } : {}),
});
for (const entry of executableEntries) {
const resolvedEntry = path.resolve(params.packageDir, entry);
if (!isPathInside(params.packageDir, resolvedEntry)) {
params.logger.warn?.(
`extension entry escapes plugin directory and will not be scanned: ${entry}`,
`plugin executable entry escapes plugin directory and will not be scanned: ${entry}`,
);
continue;
}
if (extensionUsesSkippedScannerPath(entry)) {
params.logger.warn?.(
`extension entry is in a hidden/node_modules path and will receive targeted scan coverage: ${entry}`,
`plugin executable entry is in a hidden/node_modules path and will receive targeted scan coverage: ${entry}`,
);
}
forcedScanEntries.push(resolvedEntry);
+7
View File
@@ -35,6 +35,12 @@ export type SkillInstallSpecMetadata = {
targetDir?: string;
};
export type PackageExecutableScanMetadata = {
runtimeExtensions?: readonly string[];
runtimeSetupEntry?: string;
setupEntry?: string;
};
async function loadInstallSecurityScanRuntime() {
return await import("./install-security-scan.runtime.js");
}
@@ -59,6 +65,7 @@ export async function scanPackageInstallSource(
extensions: string[];
logger: InstallScanLogger;
packageDir: string;
packageMetadata?: PackageExecutableScanMetadata;
pluginId: string;
requestKind?: PluginInstallRequestKind;
requestedSpecifier?: string;
+113
View File
@@ -2606,6 +2606,119 @@ describe("installPluginFromArchive", () => {
expectWarningIncludes(warnings, "dangerous code pattern");
});
it("scans runtime extension entry files in hidden directories", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.mkdirSync(path.join(pluginDir, ".hidden"), { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "hidden-runtime-entry-plugin",
version: "1.0.0",
openclaw: {
extensions: ["index.js"],
runtimeExtensions: [".hidden/runtime.cjs"],
},
}),
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "module.exports = {};\n");
fs.writeFileSync(
path.join(pluginDir, ".hidden", "runtime.cjs"),
`const { execFileSync } = require("child_process");\nexecFileSync(process.execPath, ["-e", ""]);`,
);
const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
expectWarningIncludes(warnings, "hidden/node_modules path");
expectWarningIncludes(warnings, "dangerous code pattern");
});
it("scans setup entry files in hidden directories", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.mkdirSync(path.join(pluginDir, ".hidden"), { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "hidden-setup-entry-plugin",
version: "1.0.0",
openclaw: {
extensions: ["index.js"],
setupEntry: ".hidden/setup.cjs",
},
}),
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "module.exports = {};\n");
fs.writeFileSync(
path.join(pluginDir, ".hidden", "setup.cjs"),
`const { execFileSync } = require("child_process");\nexecFileSync(process.execPath, ["-e", ""]);`,
);
const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
expectWarningIncludes(warnings, "hidden/node_modules path");
expectWarningIncludes(warnings, "dangerous code pattern");
});
it("scans runtime setup entry files in hidden directories", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.mkdirSync(path.join(pluginDir, ".hidden"), { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "hidden-runtime-setup-entry-plugin",
version: "1.0.0",
openclaw: {
extensions: ["index.js"],
setupEntry: "setup.ts",
runtimeSetupEntry: ".hidden/setup.cjs",
},
}),
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "module.exports = {};\n");
fs.writeFileSync(path.join(pluginDir, "setup.ts"), "export {};\n");
fs.writeFileSync(
path.join(pluginDir, ".hidden", "setup.cjs"),
`const { execFileSync } = require("child_process");\nexecFileSync(process.execPath, ["-e", ""]);`,
);
const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
expectWarningIncludes(warnings, "hidden/node_modules path");
expectWarningIncludes(warnings, "dangerous code pattern");
});
it("scans inferred runtime entry files in hidden directories", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.mkdirSync(path.join(pluginDir, ".hidden"), { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "hidden-inferred-runtime-entry-plugin",
version: "1.0.0",
openclaw: {
extensions: [".hidden/index.ts"],
},
}),
);
fs.writeFileSync(path.join(pluginDir, ".hidden", "index.ts"), "export {};\n");
fs.writeFileSync(
path.join(pluginDir, ".hidden", "index.js"),
`const { execFileSync } = require("child_process");\nexecFileSync(process.execPath, ["-e", ""]);`,
);
const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
expectWarningIncludes(warnings, "hidden/node_modules path");
expectWarningIncludes(warnings, "dangerous code pattern");
});
it("blocks install when scanner throws", async () => {
const scanSpy = vi
.spyOn(installSecurityScan, "scanPackageInstallSource")
+1
View File
@@ -1167,6 +1167,7 @@ async function validatePackagePluginInstallSource(params: {
pluginId,
logger: params.logger,
extensions,
...(packageMetadata ? { packageMetadata } : {}),
requestKind: params.installPolicyRequest?.kind,
requestedSpecifier: params.installPolicyRequest?.requestedSpecifier,
mode: scanMode,