mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 20:04:45 +00:00
fix(plugins): scan installed dependency runtime code [AI] (#81066)
* fix: scan installed plugin dependency code * addressing review-skill * addressing review-skill * addressing codex review * addressing codex review * addressing codex review * addressing codex review * addressing codex review * addressing codex review * addressing codex review * addressing codex review * addressing ci * addressing ci * docs: add changelog entry for PR merge
This commit is contained in:
committed by
GitHub
parent
06c3318bba
commit
39bcd1e088
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- fix(plugins): scan installed dependency runtime code [AI]. (#81066) Thanks @pgondhi987.
|
||||
- Inherit tool restrictions for delegated sessions [AI]. (#80979) Thanks @pgondhi987.
|
||||
- Telegram: discard legacy long-poll update offsets that cannot be tied to the current bot token, so token rotation no longer leaves bots silently skipping new messages. (#80671) Thanks @sxxtony.
|
||||
- browser: enforce navigation checks for act interactions [AI]. (#81070) Thanks @pgondhi987.
|
||||
|
||||
@@ -181,4 +181,4 @@ export function guardSessionManager(
|
||||
(sessionManager as GuardedSessionManager).flushPendingToolResults = guard.flushPendingToolResults;
|
||||
(sessionManager as GuardedSessionManager).clearPendingToolResults = guard.clearPendingToolResults;
|
||||
return sessionManager as GuardedSessionManager;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -821,4 +821,4 @@ describe("before_message_write hook", () => {
|
||||
appendToolCallAndResult(sm);
|
||||
expectPersistedToolResultTextCapped(sm);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -707,4 +707,4 @@ export function installSessionToolResultGuard(
|
||||
clearPendingToolResults,
|
||||
getPendingIds: pendingState.getPendingIds,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,4 +567,4 @@ describe("redactSensitiveLines", () => {
|
||||
expect(joined).toContain("…redacted…");
|
||||
expect(joined).not.toContain("ABCDEF1234567890");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -360,4 +360,4 @@ export function redactSensitiveLines(lines: string[], resolved: ResolvedRedactOp
|
||||
return lines;
|
||||
}
|
||||
return redactText(lines.join("\n"), resolved.patterns).split("\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,11 @@ type PackageManifestTraversalResult = {
|
||||
packageManifestPaths: string[];
|
||||
};
|
||||
|
||||
type InstalledPackageScanRoot = {
|
||||
packageDir: string;
|
||||
realPath: string;
|
||||
};
|
||||
|
||||
type PluginInstallRequestKind =
|
||||
| "skill-install"
|
||||
| "plugin-dir"
|
||||
@@ -321,6 +326,7 @@ function buildBuiltinScanFromSummary(summary: {
|
||||
critical: number;
|
||||
warn: number;
|
||||
info: number;
|
||||
truncated: boolean;
|
||||
findings: InstallScanFinding[];
|
||||
}): BuiltinInstallScan {
|
||||
return {
|
||||
@@ -338,6 +344,7 @@ const DEFAULT_PACKAGE_MANIFEST_TRAVERSAL_LIMITS: PackageManifestTraversalLimits
|
||||
maxDirectories: 10_000,
|
||||
maxManifests: 10_000,
|
||||
};
|
||||
const DEFAULT_INSTALLED_PACKAGE_CODE_SCAN_MAX_FILES = 10_000;
|
||||
|
||||
function readPositiveIntegerEnv(name: string, fallback: number): number {
|
||||
const rawValue = process.env[name];
|
||||
@@ -368,6 +375,171 @@ function resolvePackageManifestTraversalLimits(): PackageManifestTraversalLimits
|
||||
};
|
||||
}
|
||||
|
||||
function resolveInstalledPackageCodeScanMaxFiles(): number {
|
||||
return readPositiveIntegerEnv(
|
||||
"OPENCLAW_INSTALL_SCAN_MAX_CODE_FILES",
|
||||
DEFAULT_INSTALLED_PACKAGE_CODE_SCAN_MAX_FILES,
|
||||
);
|
||||
}
|
||||
|
||||
function isSamePathOrInside(parentPath: string, candidatePath: string): boolean {
|
||||
return parentPath === candidatePath || isPathInside(parentPath, candidatePath);
|
||||
}
|
||||
|
||||
function getErrnoCode(error: unknown): string | undefined {
|
||||
if (typeof error !== "object" || error === null || !("code" in error)) {
|
||||
return undefined;
|
||||
}
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return typeof code === "string" ? code : undefined;
|
||||
}
|
||||
|
||||
function isInstallScannableDependencyName(name: string): boolean {
|
||||
if (name.startsWith("@")) {
|
||||
const parts = name.split("/");
|
||||
return (
|
||||
parts.length === 2 && parts.every((part) => part.length > 0 && part !== "." && part !== "..")
|
||||
);
|
||||
}
|
||||
return (
|
||||
name.length > 0 && !name.includes("/") && !name.includes("\\") && name !== "." && name !== ".."
|
||||
);
|
||||
}
|
||||
|
||||
function collectManifestRuntimeDependencyNames(manifest: PackageManifest): string[] {
|
||||
const dependencyNames = new Set<string>();
|
||||
for (const dependencies of [manifest.dependencies, manifest.optionalDependencies]) {
|
||||
for (const dependencyName of Object.keys(dependencies ?? {})) {
|
||||
if (isInstallScannableDependencyName(dependencyName)) {
|
||||
dependencyNames.add(dependencyName);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const dependencyName of Object.keys(manifest.peerDependencies ?? {})) {
|
||||
if (dependencyName !== "openclaw" && isInstallScannableDependencyName(dependencyName)) {
|
||||
dependencyNames.add(dependencyName);
|
||||
}
|
||||
}
|
||||
return [...dependencyNames].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
async function resolveInstalledPackageScanRoot(params: {
|
||||
boundaryRealPath: string;
|
||||
dependencyName: string;
|
||||
packageDir: string;
|
||||
}): Promise<InstalledPackageScanRoot | undefined> {
|
||||
const packageDir = path.join(params.packageDir, "node_modules", params.dependencyName);
|
||||
let stats: Awaited<ReturnType<typeof fs.stat>>;
|
||||
try {
|
||||
stats = await fs.stat(packageDir);
|
||||
} catch (error) {
|
||||
if (getErrnoCode(error) === "ENOENT") {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!stats.isDirectory()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const realPath = await fs.realpath(packageDir).catch(() => path.resolve(packageDir));
|
||||
if (!isSamePathOrInside(params.boundaryRealPath, realPath)) {
|
||||
throw new Error(
|
||||
`installed dependency scan found package outside install root at ${packageDir}`,
|
||||
);
|
||||
}
|
||||
return { packageDir, realPath };
|
||||
}
|
||||
|
||||
async function collectInstalledPackageScanRoots(params: {
|
||||
additionalPackageDirs?: string[];
|
||||
dependencyScanRootDir?: string;
|
||||
packageDir: string;
|
||||
}): Promise<string[]> {
|
||||
const limits = resolvePackageManifestTraversalLimits();
|
||||
const boundaryDir = params.dependencyScanRootDir ?? params.packageDir;
|
||||
const boundaryRealPath = await fs.realpath(boundaryDir).catch(() => path.resolve(boundaryDir));
|
||||
const packageRealPath = await fs
|
||||
.realpath(params.packageDir)
|
||||
.catch(() => path.resolve(params.packageDir));
|
||||
if (!isSamePathOrInside(boundaryRealPath, packageRealPath)) {
|
||||
throw new Error(
|
||||
`installed dependency scan found package outside install root at ${params.packageDir}`,
|
||||
);
|
||||
}
|
||||
|
||||
const queue: InstalledPackageScanRoot[] = [
|
||||
{ packageDir: params.packageDir, realPath: packageRealPath },
|
||||
];
|
||||
for (const packageDir of params.additionalPackageDirs ?? []) {
|
||||
const realPath = await fs.realpath(packageDir).catch(() => path.resolve(packageDir));
|
||||
if (!isSamePathOrInside(boundaryRealPath, realPath)) {
|
||||
throw new Error(
|
||||
`installed dependency scan found package outside install root at ${packageDir}`,
|
||||
);
|
||||
}
|
||||
queue.push({ packageDir, realPath });
|
||||
}
|
||||
const visitedRealPaths = new Set<string>();
|
||||
const scanRoots: string[] = [];
|
||||
let queueIndex = 0;
|
||||
|
||||
while (queueIndex < queue.length) {
|
||||
const current = queue[queueIndex];
|
||||
queueIndex += 1;
|
||||
if (!current || visitedRealPaths.has(current.realPath)) {
|
||||
continue;
|
||||
}
|
||||
visitedRealPaths.add(current.realPath);
|
||||
if (visitedRealPaths.size > limits.maxDirectories) {
|
||||
throw new Error(
|
||||
`installed dependency scan exceeded max packages (${limits.maxDirectories}) under ${boundaryDir}`,
|
||||
);
|
||||
}
|
||||
scanRoots.push(current.packageDir);
|
||||
|
||||
const manifest = await tryReadJson<PackageManifest>(
|
||||
path.join(current.packageDir, "package.json"),
|
||||
);
|
||||
if (!manifest) {
|
||||
continue;
|
||||
}
|
||||
for (const dependencyName of collectManifestRuntimeDependencyNames(manifest)) {
|
||||
const nestedCandidate = await resolveInstalledPackageScanRoot({
|
||||
boundaryRealPath,
|
||||
dependencyName,
|
||||
packageDir: current.packageDir,
|
||||
});
|
||||
const candidate =
|
||||
nestedCandidate ??
|
||||
(params.dependencyScanRootDir
|
||||
? await resolveInstalledPackageScanRoot({
|
||||
boundaryRealPath,
|
||||
dependencyName,
|
||||
packageDir: params.dependencyScanRootDir,
|
||||
})
|
||||
: undefined);
|
||||
if (candidate && !visitedRealPaths.has(candidate.realPath)) {
|
||||
queue.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scanRoots;
|
||||
}
|
||||
|
||||
async function collectNonOverlappingPackageScanRoots(packageDirs: string[]): Promise<string[]> {
|
||||
const selectedRoots: InstalledPackageScanRoot[] = [];
|
||||
for (const packageDir of packageDirs) {
|
||||
const realPath = await fs.realpath(packageDir).catch(() => path.resolve(packageDir));
|
||||
if (selectedRoots.some((selectedRoot) => isSamePathOrInside(selectedRoot.realPath, realPath))) {
|
||||
continue;
|
||||
}
|
||||
selectedRoots.push({ packageDir, realPath });
|
||||
}
|
||||
return selectedRoots.map((selectedRoot) => selectedRoot.packageDir);
|
||||
}
|
||||
|
||||
async function collectPackageManifestPaths(params: {
|
||||
allowManagedNpmRootPackagePeerSymlinks?: boolean;
|
||||
rootDir: string;
|
||||
@@ -493,10 +665,25 @@ async function collectPackageManifestPaths(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function formatPackageScanRelativePath(params: {
|
||||
packageDir: string;
|
||||
relativePath: string;
|
||||
relativeRootDir?: string;
|
||||
}): string {
|
||||
if (!params.relativeRootDir) {
|
||||
return params.relativePath;
|
||||
}
|
||||
const packageRelativePath = path.relative(params.relativeRootDir, params.packageDir);
|
||||
return packageRelativePath
|
||||
? path.join(packageRelativePath, params.relativePath)
|
||||
: params.relativePath;
|
||||
}
|
||||
|
||||
async function scanManifestDependencyDenylist(params: {
|
||||
allowManagedNpmRootPackagePeerSymlinks?: boolean;
|
||||
logger: InstallScanLogger;
|
||||
packageDir: string;
|
||||
relativeRootDir?: string;
|
||||
targetLabel: string;
|
||||
}): Promise<InstallSecurityScanResult | undefined> {
|
||||
const traversalResult = await collectPackageManifestPaths({
|
||||
@@ -515,7 +702,11 @@ async function scanManifestDependencyDenylist(params: {
|
||||
continue;
|
||||
}
|
||||
|
||||
const manifestRelativePath = path.relative(params.packageDir, manifestPath) || "package.json";
|
||||
const manifestRelativePath = formatPackageScanRelativePath({
|
||||
packageDir: params.packageDir,
|
||||
relativePath: path.relative(params.packageDir, manifestPath) || "package.json",
|
||||
relativeRootDir: params.relativeRootDir,
|
||||
});
|
||||
const reason = buildBlockedDependencyReason({
|
||||
findings: blockedDependencies,
|
||||
manifestPackageName: manifest.name,
|
||||
@@ -536,7 +727,11 @@ async function scanManifestDependencyDenylist(params: {
|
||||
if (traversalResult.blockedDirectoryFinding) {
|
||||
const reason = buildBlockedDependencyDirectoryReason({
|
||||
dependencyName: traversalResult.blockedDirectoryFinding.dependencyName,
|
||||
directoryRelativePath: traversalResult.blockedDirectoryFinding.directoryRelativePath,
|
||||
directoryRelativePath: formatPackageScanRelativePath({
|
||||
packageDir: params.packageDir,
|
||||
relativePath: traversalResult.blockedDirectoryFinding.directoryRelativePath,
|
||||
relativeRootDir: params.relativeRootDir,
|
||||
}),
|
||||
targetLabel: params.targetLabel,
|
||||
});
|
||||
params.logger.warn?.(`WARNING: ${reason}`);
|
||||
@@ -550,7 +745,11 @@ async function scanManifestDependencyDenylist(params: {
|
||||
if (traversalResult.blockedFileFinding) {
|
||||
const reason = buildBlockedDependencyFileReason({
|
||||
dependencyName: traversalResult.blockedFileFinding.dependencyName,
|
||||
fileRelativePath: traversalResult.blockedFileFinding.fileRelativePath,
|
||||
fileRelativePath: formatPackageScanRelativePath({
|
||||
packageDir: params.packageDir,
|
||||
relativePath: traversalResult.blockedFileFinding.fileRelativePath,
|
||||
relativeRootDir: params.relativeRootDir,
|
||||
}),
|
||||
targetLabel: params.targetLabel,
|
||||
});
|
||||
params.logger.warn?.(`WARNING: ${reason}`);
|
||||
@@ -565,8 +764,14 @@ async function scanManifestDependencyDenylist(params: {
|
||||
}
|
||||
|
||||
async function scanDirectoryTarget(params: {
|
||||
excludeTestFiles?: boolean;
|
||||
failOnTruncated?: boolean;
|
||||
includeHiddenDirectories?: boolean;
|
||||
includeNestedNodeModulesTestFiles?: boolean;
|
||||
includeNodeModules?: boolean;
|
||||
includeFiles?: string[];
|
||||
logger: InstallScanLogger;
|
||||
maxFiles?: number;
|
||||
path: string;
|
||||
suppressBuiltinWarnings?: boolean;
|
||||
suspiciousMessage: string;
|
||||
@@ -575,9 +780,18 @@ async function scanDirectoryTarget(params: {
|
||||
}): Promise<BuiltinInstallScan> {
|
||||
try {
|
||||
const scanSummary = await scanDirectoryWithSummary(params.path, {
|
||||
excludeTestFiles: true,
|
||||
excludeTestFiles: params.excludeTestFiles ?? true,
|
||||
includeHiddenDirectories: params.includeHiddenDirectories,
|
||||
includeNestedNodeModulesTestFiles: params.includeNestedNodeModulesTestFiles,
|
||||
includeNodeModules: params.includeNodeModules,
|
||||
includeFiles: params.includeFiles,
|
||||
maxFiles: params.maxFiles,
|
||||
});
|
||||
if (params.failOnTruncated && scanSummary.truncated) {
|
||||
return buildBuiltinScanFromError(
|
||||
`code safety scan reached file limit (${params.maxFiles ?? "configured limit"})`,
|
||||
);
|
||||
}
|
||||
const builtinScan = buildBuiltinScanFromSummary(scanSummary);
|
||||
if (params.suppressBuiltinWarnings) {
|
||||
return builtinScan;
|
||||
@@ -934,17 +1148,81 @@ export async function scanPackageInstallSourceRuntime(
|
||||
}
|
||||
|
||||
export async function scanInstalledPackageDependencyTreeRuntime(params: {
|
||||
additionalPackageDirs?: string[];
|
||||
allowManagedNpmRootPackagePeerSymlinks?: boolean;
|
||||
dangerouslyForceUnsafeInstall?: boolean;
|
||||
dependencyScanRootDir?: string;
|
||||
logger: InstallScanLogger;
|
||||
packageDir: string;
|
||||
pluginId: string;
|
||||
trustedSourceLinkedOfficialInstall?: boolean;
|
||||
}): Promise<InstallSecurityScanResult | undefined> {
|
||||
return await scanManifestDependencyDenylist({
|
||||
logger: params.logger,
|
||||
const scanRoots = await collectInstalledPackageScanRoots({
|
||||
...(params.additionalPackageDirs
|
||||
? { additionalPackageDirs: params.additionalPackageDirs }
|
||||
: {}),
|
||||
dependencyScanRootDir: params.dependencyScanRootDir,
|
||||
packageDir: params.packageDir,
|
||||
allowManagedNpmRootPackagePeerSymlinks: params.allowManagedNpmRootPackagePeerSymlinks,
|
||||
targetLabel: `Plugin "${params.pluginId}" installation`,
|
||||
});
|
||||
const directoryScanRoots = await collectNonOverlappingPackageScanRoots(scanRoots);
|
||||
for (const packageDir of directoryScanRoots) {
|
||||
const dependencyBlocked = await scanManifestDependencyDenylist({
|
||||
logger: params.logger,
|
||||
packageDir,
|
||||
allowManagedNpmRootPackagePeerSymlinks: params.allowManagedNpmRootPackagePeerSymlinks,
|
||||
relativeRootDir: params.dependencyScanRootDir ?? params.packageDir,
|
||||
targetLabel: `Plugin "${params.pluginId}" installation`,
|
||||
});
|
||||
if (dependencyBlocked) {
|
||||
return dependencyBlocked;
|
||||
}
|
||||
}
|
||||
|
||||
let remainingMaxFiles = resolveInstalledPackageCodeScanMaxFiles();
|
||||
const pluginRootRealPath = await fs
|
||||
.realpath(params.packageDir)
|
||||
.catch(() => path.resolve(params.packageDir));
|
||||
for (const packageDir of directoryScanRoots) {
|
||||
if (remainingMaxFiles <= 0) {
|
||||
return resolveBuiltinScanDecision({
|
||||
builtinScan: buildBuiltinScanFromError(
|
||||
"code safety scan reached file limit (configured limit)",
|
||||
),
|
||||
logger: params.logger,
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
|
||||
targetLabel: `Plugin "${params.pluginId}" installation`,
|
||||
});
|
||||
}
|
||||
const packageRealPath = await fs.realpath(packageDir).catch(() => path.resolve(packageDir));
|
||||
const isPluginRoot = packageRealPath === pluginRootRealPath;
|
||||
const builtinScan = await scanDirectoryTarget({
|
||||
excludeTestFiles: isPluginRoot,
|
||||
failOnTruncated: true,
|
||||
includeHiddenDirectories: true,
|
||||
includeNestedNodeModulesTestFiles: isPluginRoot,
|
||||
includeNodeModules: true,
|
||||
logger: params.logger,
|
||||
maxFiles: remainingMaxFiles,
|
||||
path: packageDir,
|
||||
suppressBuiltinWarnings: params.trustedSourceLinkedOfficialInstall === true,
|
||||
suspiciousMessage: `Plugin "{target}" installed tree has {count} suspicious code pattern(s). Run "openclaw security audit --deep" for details.`,
|
||||
targetName: params.pluginId,
|
||||
warningMessage: `WARNING: Plugin "${params.pluginId}" installed tree contains dangerous code patterns`,
|
||||
});
|
||||
const builtinBlocked = resolveBuiltinScanDecision({
|
||||
builtinScan,
|
||||
logger: params.logger,
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
|
||||
targetLabel: `Plugin "${params.pluginId}" installation`,
|
||||
});
|
||||
if (builtinBlocked) {
|
||||
return builtinBlocked;
|
||||
}
|
||||
remainingMaxFiles -= builtinScan.scannedFiles;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function scanFileInstallSourceRuntime(
|
||||
|
||||
@@ -80,10 +80,14 @@ export async function scanPackageInstallSource(
|
||||
}
|
||||
|
||||
export async function scanInstalledPackageDependencyTree(params: {
|
||||
additionalPackageDirs?: string[];
|
||||
allowManagedNpmRootPackagePeerSymlinks?: boolean;
|
||||
dangerouslyForceUnsafeInstall?: boolean;
|
||||
dependencyScanRootDir?: string;
|
||||
logger: InstallScanLogger;
|
||||
packageDir: string;
|
||||
pluginId: string;
|
||||
trustedSourceLinkedOfficialInstall?: boolean;
|
||||
}): Promise<InstallSecurityScanResult | undefined> {
|
||||
const { scanInstalledPackageDependencyTreeRuntime } = await loadInstallSecurityScanRuntime();
|
||||
return await scanInstalledPackageDependencyTreeRuntime(params);
|
||||
|
||||
@@ -11,6 +11,7 @@ import * as installSecurityScan from "./install-security-scan.js";
|
||||
import {
|
||||
installPluginFromArchive,
|
||||
installPluginFromDir,
|
||||
installPluginFromInstalledPackageDir,
|
||||
PLUGIN_INSTALL_ERROR_CODE,
|
||||
resolvePluginInstallDir,
|
||||
} from "./install.js";
|
||||
@@ -811,6 +812,211 @@ describe("installPluginFromArchive", () => {
|
||||
expect(warnings).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("blocks archive installs when dependency install materializes dangerous runtime code", async () => {
|
||||
const stateDir = suiteTempRootTracker.makeTempDir();
|
||||
const extensionsDir = path.join(stateDir, "extensions");
|
||||
fs.mkdirSync(extensionsDir, { recursive: true });
|
||||
|
||||
const archivePath = await ensureDynamicArchiveTemplate({
|
||||
outName: "dependency-runtime-code-plugin.tgz",
|
||||
packageJson: {
|
||||
name: "dependency-runtime-code-plugin",
|
||||
version: "1.0.0",
|
||||
openclaw: { extensions: ["./dist/index.js"] },
|
||||
dependencies: {
|
||||
"telemetry-helper": "1.0.0",
|
||||
},
|
||||
},
|
||||
withDistIndex: true,
|
||||
distIndexJsContent: `const telemetry = require("telemetry-helper");\nmodule.exports = telemetry;\n`,
|
||||
});
|
||||
|
||||
const run = vi.mocked(runCommandWithTimeout);
|
||||
run.mockImplementationOnce(async (_cmd, options) => {
|
||||
if (!options || typeof options === "number" || !options.cwd) {
|
||||
throw new Error("expected npm install cwd");
|
||||
}
|
||||
const dependencyDir = path.join(options.cwd, "node_modules", "telemetry-helper");
|
||||
fs.mkdirSync(dependencyDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dependencyDir, "package.json"),
|
||||
JSON.stringify({ name: "telemetry-helper", version: "1.0.0", main: "index.cjs" }),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dependencyDir, "index.cjs"),
|
||||
`const childProcess = require("node:child_process");\nchildProcess.execSync("node -v", { encoding: "utf8" });\nmodule.exports = {};\n`,
|
||||
"utf-8",
|
||||
);
|
||||
return {
|
||||
code: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
signal: null,
|
||||
killed: false,
|
||||
termination: "exit" as const,
|
||||
};
|
||||
});
|
||||
|
||||
const { result, warnings } = await installFromArchiveWithWarnings({
|
||||
archivePath,
|
||||
extensionsDir,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
|
||||
expect(result.error).toContain(
|
||||
'Plugin "dependency-runtime-code-plugin" installation blocked',
|
||||
);
|
||||
expect(result.error).toContain("dangerous code patterns detected");
|
||||
expect(result.error).toContain("node_modules/telemetry-helper/index.cjs");
|
||||
}
|
||||
expectWarningIncludes(warnings, "installed tree contains dangerous code patterns");
|
||||
});
|
||||
|
||||
it("blocks archive installs when dependency runtime code is loaded from a hidden directory", async () => {
|
||||
const stateDir = suiteTempRootTracker.makeTempDir();
|
||||
const extensionsDir = path.join(stateDir, "extensions");
|
||||
fs.mkdirSync(extensionsDir, { recursive: true });
|
||||
|
||||
const archivePath = await ensureDynamicArchiveTemplate({
|
||||
outName: "hidden-dependency-runtime-code-plugin.tgz",
|
||||
packageJson: {
|
||||
name: "hidden-dependency-runtime-code-plugin",
|
||||
version: "1.0.0",
|
||||
openclaw: { extensions: ["./dist/index.js"] },
|
||||
dependencies: {
|
||||
"hidden-telemetry-helper": "1.0.0",
|
||||
},
|
||||
},
|
||||
withDistIndex: true,
|
||||
distIndexJsContent: `const telemetry = require("hidden-telemetry-helper");\nmodule.exports = telemetry;\n`,
|
||||
});
|
||||
|
||||
const run = vi.mocked(runCommandWithTimeout);
|
||||
run.mockImplementationOnce(async (_cmd, options) => {
|
||||
if (!options || typeof options === "number" || !options.cwd) {
|
||||
throw new Error("expected npm install cwd");
|
||||
}
|
||||
const dependencyDir = path.join(options.cwd, "node_modules", "hidden-telemetry-helper");
|
||||
const hiddenPayloadDir = path.join(dependencyDir, ".payload");
|
||||
fs.mkdirSync(hiddenPayloadDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dependencyDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "hidden-telemetry-helper",
|
||||
version: "1.0.0",
|
||||
main: "index.cjs",
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dependencyDir, "index.cjs"),
|
||||
`module.exports = require("./.payload/runtime.cjs");\n`,
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(hiddenPayloadDir, "runtime.cjs"),
|
||||
`const childProcess = require("node:child_process");\nchildProcess.execSync("node -v", { encoding: "utf8" });\nmodule.exports = {};\n`,
|
||||
"utf-8",
|
||||
);
|
||||
return {
|
||||
code: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
signal: null,
|
||||
killed: false,
|
||||
termination: "exit" as const,
|
||||
};
|
||||
});
|
||||
|
||||
const { result, warnings } = await installFromArchiveWithWarnings({
|
||||
archivePath,
|
||||
extensionsDir,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
|
||||
expect(result.error).toContain(
|
||||
'Plugin "hidden-dependency-runtime-code-plugin" installation blocked',
|
||||
);
|
||||
expect(result.error).toContain("dangerous code patterns detected");
|
||||
expect(result.error).toContain("node_modules/hidden-telemetry-helper/.payload/runtime.cjs");
|
||||
}
|
||||
expectWarningIncludes(warnings, "installed tree contains dangerous code patterns");
|
||||
});
|
||||
|
||||
it("fails archive installs when installed runtime code scan reaches its file cap", async () => {
|
||||
vi.stubEnv("OPENCLAW_INSTALL_SCAN_MAX_CODE_FILES", "1");
|
||||
const stateDir = suiteTempRootTracker.makeTempDir();
|
||||
const extensionsDir = path.join(stateDir, "extensions");
|
||||
fs.mkdirSync(extensionsDir, { recursive: true });
|
||||
|
||||
const archivePath = await ensureDynamicArchiveTemplate({
|
||||
outName: "capped-dependency-runtime-code-plugin.tgz",
|
||||
packageJson: {
|
||||
name: "capped-dependency-runtime-code-plugin",
|
||||
version: "1.0.0",
|
||||
openclaw: { extensions: ["./dist/index.js"] },
|
||||
dependencies: {
|
||||
"capped-telemetry-helper": "1.0.0",
|
||||
},
|
||||
},
|
||||
withDistIndex: true,
|
||||
distIndexJsContent: `const telemetry = require("capped-telemetry-helper");\nmodule.exports = telemetry;\n`,
|
||||
});
|
||||
|
||||
const run = vi.mocked(runCommandWithTimeout);
|
||||
run.mockImplementationOnce(async (_cmd, options) => {
|
||||
if (!options || typeof options === "number" || !options.cwd) {
|
||||
throw new Error("expected npm install cwd");
|
||||
}
|
||||
const dependencyDir = path.join(options.cwd, "node_modules", "capped-telemetry-helper");
|
||||
fs.mkdirSync(dependencyDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dependencyDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "capped-telemetry-helper",
|
||||
version: "1.0.0",
|
||||
main: "index.cjs",
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dependencyDir, "index.cjs"),
|
||||
`module.exports = require("./runtime.cjs");\n`,
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dependencyDir, "runtime.cjs"),
|
||||
`const childProcess = require("node:child_process");\nchildProcess.execSync("node -v", { encoding: "utf8" });\nmodule.exports = {};\n`,
|
||||
"utf-8",
|
||||
);
|
||||
return {
|
||||
code: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
signal: null,
|
||||
killed: false,
|
||||
termination: "exit" as const,
|
||||
};
|
||||
});
|
||||
|
||||
const { result } = await installFromArchiveWithWarnings({
|
||||
archivePath,
|
||||
extensionsDir,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED);
|
||||
expect(result.error).toContain("code safety scan failed");
|
||||
expect(result.error).toContain("code safety scan reached file limit (1)");
|
||||
}
|
||||
});
|
||||
|
||||
it("installs flat-root plugin archives from ClawHub-style downloads", async () => {
|
||||
const result = await installArchivePackageAndReturnResult({
|
||||
packageJson: {
|
||||
@@ -2856,6 +3062,297 @@ describe("installPluginFromDir", () => {
|
||||
expect(vi.mocked(runCommandWithTimeout)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not scan pre-existing sibling packages from a managed npm root", async () => {
|
||||
const caseDir = suiteTempRootTracker.makeTempDir();
|
||||
const npmRoot = path.join(caseDir, "npm-root");
|
||||
const newPluginDir = path.join(npmRoot, "node_modules", "new-managed-plugin");
|
||||
const existingPluginDir = path.join(npmRoot, "node_modules", "existing-official-plugin");
|
||||
fs.mkdirSync(newPluginDir, { recursive: true });
|
||||
fs.mkdirSync(existingPluginDir, { recursive: true });
|
||||
writeMinimalPackagePlugin(newPluginDir, "new-managed-plugin");
|
||||
writeMinimalPackagePlugin(existingPluginDir, "existing-official-plugin");
|
||||
fs.writeFileSync(
|
||||
path.join(existingPluginDir, "index.js"),
|
||||
`const childProcess = require("node:child_process");\nchildProcess.spawn("node", ["-v"]);\nmodule.exports = {};\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const result = await installPluginFromInstalledPackageDir({
|
||||
packageDir: newPluginDir,
|
||||
dependencyScanRootDir: npmRoot,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.pluginId).toBe("new-managed-plugin");
|
||||
}
|
||||
});
|
||||
|
||||
it("scans flattened managed npm dependencies reachable from the installed package", async () => {
|
||||
const caseDir = suiteTempRootTracker.makeTempDir();
|
||||
const npmRoot = path.join(caseDir, "npm-root");
|
||||
const pluginDir = path.join(npmRoot, "node_modules", "managed-plugin-with-dep");
|
||||
const dependencyDir = path.join(npmRoot, "node_modules", "flattened-runtime-helper");
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.mkdirSync(dependencyDir, { recursive: true });
|
||||
writeMinimalPackagePlugin(pluginDir, "managed-plugin-with-dep");
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "managed-plugin-with-dep",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"flattened-runtime-helper": "1.0.0",
|
||||
},
|
||||
openclaw: { extensions: ["index.js"] },
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dependencyDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "flattened-runtime-helper",
|
||||
version: "1.0.0",
|
||||
main: "index.cjs",
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dependencyDir, "index.cjs"),
|
||||
`const childProcess = require("node:child_process");\nchildProcess.execSync("node -v", { encoding: "utf8" });\nmodule.exports = {};\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const result = await installPluginFromInstalledPackageDir({
|
||||
packageDir: pluginDir,
|
||||
dependencyScanRootDir: npmRoot,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
|
||||
expect(result.error).toContain("flattened-runtime-helper/index.cjs");
|
||||
}
|
||||
});
|
||||
|
||||
it("scans installed managed npm peer dependencies reachable from the installed package", async () => {
|
||||
const caseDir = suiteTempRootTracker.makeTempDir();
|
||||
const npmRoot = path.join(caseDir, "npm-root");
|
||||
const pluginDir = path.join(npmRoot, "node_modules", "managed-plugin-with-peer");
|
||||
const peerDependencyDir = path.join(npmRoot, "node_modules", "peer-runtime-helper");
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.mkdirSync(peerDependencyDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "managed-plugin-with-peer",
|
||||
version: "1.0.0",
|
||||
peerDependencies: {
|
||||
"peer-runtime-helper": "^1.0.0",
|
||||
},
|
||||
openclaw: { extensions: ["index.js"] },
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n", "utf-8");
|
||||
fs.writeFileSync(
|
||||
path.join(peerDependencyDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "peer-runtime-helper",
|
||||
version: "1.0.0",
|
||||
main: "index.cjs",
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(peerDependencyDir, "index.cjs"),
|
||||
`const childProcess = require("node:child_process");\nchildProcess.execSync("node -v", { encoding: "utf8" });\nmodule.exports = {};\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const result = await installPluginFromInstalledPackageDir({
|
||||
packageDir: pluginDir,
|
||||
dependencyScanRootDir: npmRoot,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
|
||||
expect(result.error).toContain("peer-runtime-helper/index.cjs");
|
||||
}
|
||||
});
|
||||
|
||||
it("scans installed dependency runtime entrypoints with test-like paths", async () => {
|
||||
const caseDir = suiteTempRootTracker.makeTempDir();
|
||||
const npmRoot = path.join(caseDir, "npm-root");
|
||||
const pluginDir = path.join(npmRoot, "node_modules", "managed-plugin-with-test-entry-dep");
|
||||
const dependencyDir = path.join(npmRoot, "node_modules", "test-entry-helper");
|
||||
const dependencyTestsDir = path.join(dependencyDir, "tests");
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.mkdirSync(dependencyTestsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "managed-plugin-with-test-entry-dep",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"test-entry-helper": "1.0.0",
|
||||
},
|
||||
openclaw: { extensions: ["index.js"] },
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n", "utf-8");
|
||||
fs.writeFileSync(
|
||||
path.join(dependencyDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "test-entry-helper",
|
||||
version: "1.0.0",
|
||||
main: "tests/runtime.test.cjs",
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dependencyTestsDir, "runtime.test.cjs"),
|
||||
`const childProcess = require("node:child_process");\nchildProcess.execSync("node -v", { encoding: "utf8" });\nmodule.exports = {};\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const result = await installPluginFromInstalledPackageDir({
|
||||
packageDir: pluginDir,
|
||||
dependencyScanRootDir: npmRoot,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
|
||||
expect(result.error).toContain("test-entry-helper/tests/runtime.test.cjs");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps plugin-root test files excluded during installed tree scans", async () => {
|
||||
const caseDir = suiteTempRootTracker.makeTempDir();
|
||||
const pluginDir = path.join(caseDir, "plugin-with-test-files");
|
||||
const testsDir = path.join(pluginDir, "tests");
|
||||
fs.mkdirSync(testsDir, { recursive: true });
|
||||
writeMinimalPackagePlugin(pluginDir, "plugin-with-test-files");
|
||||
fs.writeFileSync(
|
||||
path.join(testsDir, "dangerous.test.cjs"),
|
||||
`const childProcess = require("node:child_process");\nchildProcess.execSync("node -v", { encoding: "utf8" });\nmodule.exports = {};\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const result = await installPluginFromInstalledPackageDir({
|
||||
packageDir: pluginDir,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.pluginId).toBe("plugin-with-test-files");
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers nested managed npm dependencies over pre-existing root fallbacks", async () => {
|
||||
const caseDir = suiteTempRootTracker.makeTempDir();
|
||||
const npmRoot = path.join(caseDir, "npm-root");
|
||||
const pluginDir = path.join(npmRoot, "node_modules", "managed-plugin-with-nested-dep");
|
||||
const nestedDependencyDir = path.join(pluginDir, "node_modules", "shared-runtime-helper");
|
||||
const rootFallbackDir = path.join(npmRoot, "node_modules", "shared-runtime-helper");
|
||||
fs.mkdirSync(nestedDependencyDir, { recursive: true });
|
||||
fs.mkdirSync(rootFallbackDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "managed-plugin-with-nested-dep",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"shared-runtime-helper": "2.0.0",
|
||||
},
|
||||
openclaw: { extensions: ["index.js"] },
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n", "utf-8");
|
||||
fs.writeFileSync(
|
||||
path.join(nestedDependencyDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "shared-runtime-helper",
|
||||
version: "2.0.0",
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(nestedDependencyDir, "index.cjs"),
|
||||
"module.exports = {};\n",
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(rootFallbackDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "shared-runtime-helper",
|
||||
version: "1.0.0",
|
||||
main: "index.cjs",
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(rootFallbackDir, "index.cjs"),
|
||||
`const childProcess = require("node:child_process");\nchildProcess.execSync("node -v", { encoding: "utf8" });\nmodule.exports = {};\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const result = await installPluginFromInstalledPackageDir({
|
||||
packageDir: pluginDir,
|
||||
dependencyScanRootDir: npmRoot,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.pluginId).toBe("managed-plugin-with-nested-dep");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not double-count nested dependency files against the installed tree scan cap", async () => {
|
||||
vi.stubEnv("OPENCLAW_INSTALL_SCAN_MAX_CODE_FILES", "4");
|
||||
const caseDir = suiteTempRootTracker.makeTempDir();
|
||||
const pluginDir = path.join(caseDir, "isolated-plugin");
|
||||
const dependencyDir = path.join(pluginDir, "node_modules", "nested-runtime-helper");
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.mkdirSync(dependencyDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "isolated-plugin",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"nested-runtime-helper": "1.0.0",
|
||||
},
|
||||
openclaw: { extensions: ["index.js"] },
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n", "utf-8");
|
||||
fs.writeFileSync(
|
||||
path.join(dependencyDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "nested-runtime-helper",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(path.join(dependencyDir, "first.cjs"), "module.exports = 1;\n", "utf-8");
|
||||
fs.writeFileSync(path.join(dependencyDir, "second.cjs"), "module.exports = 2;\n", "utf-8");
|
||||
|
||||
const result = await installPluginFromInstalledPackageDir({
|
||||
packageDir: pluginDir,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.pluginId).toBe("isolated-plugin");
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "rejects plugins whose minHostVersion is newer than the current host",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { Dirent } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { packageNameMatchesId } from "../infra/install-safe-path.js";
|
||||
@@ -400,6 +401,65 @@ function resolveInstalledNpmResolutionMismatch(params: {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function listManagedNpmRootPackageNames(npmRoot: string): Promise<Set<string>> {
|
||||
const nodeModulesDir = path.join(npmRoot, "node_modules");
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(nodeModulesDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return new Set();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const packageNames = new Set<string>();
|
||||
for (const entry of entries.toSorted((left, right) => left.name.localeCompare(right.name))) {
|
||||
if (entry.name === ".bin" || entry.name === "openclaw") {
|
||||
continue;
|
||||
}
|
||||
if (entry.name.startsWith("@")) {
|
||||
const scopeDir = path.join(nodeModulesDir, entry.name);
|
||||
let scopedEntries: Dirent[];
|
||||
try {
|
||||
scopedEntries = await fs.readdir(scopeDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
for (const scopedEntry of scopedEntries.toSorted((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
)) {
|
||||
if (scopedEntry.isDirectory() || scopedEntry.isSymbolicLink()) {
|
||||
packageNames.add(`${entry.name}/${scopedEntry.name}`);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
packageNames.add(entry.name);
|
||||
}
|
||||
}
|
||||
return packageNames;
|
||||
}
|
||||
|
||||
function resolveManagedNpmRootPackageDir(npmRoot: string, packageName: string): string {
|
||||
return path.join(npmRoot, "node_modules", ...packageName.split("/"));
|
||||
}
|
||||
|
||||
async function listNewManagedNpmRootPackageDirs(params: {
|
||||
beforeInstallPackageNames: Set<string>;
|
||||
npmRoot: string;
|
||||
}): Promise<string[]> {
|
||||
const afterInstallPackageNames = await listManagedNpmRootPackageNames(params.npmRoot);
|
||||
return [...afterInstallPackageNames]
|
||||
.filter((packageName) => !params.beforeInstallPackageNames.has(packageName))
|
||||
.map((packageName) => resolveManagedNpmRootPackageDir(params.npmRoot, packageName))
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function resolveTrustedNpmPackPackageName(packageName: string | undefined):
|
||||
| {
|
||||
ok: true;
|
||||
@@ -489,6 +549,7 @@ async function installPluginFromManagedNpmRoot(
|
||||
logger.info?.(`Repaired stale openclaw peer dependency in ${npmRoot}`);
|
||||
}
|
||||
}
|
||||
const preInstallRootPackageNames = await listManagedNpmRootPackageNames(npmRoot);
|
||||
const managedOverrides = await readOpenClawManagedNpmRootOverrides();
|
||||
await upsertManagedNpmRootDependency({
|
||||
npmRoot,
|
||||
@@ -623,8 +684,13 @@ async function installPluginFromManagedNpmRoot(
|
||||
};
|
||||
}
|
||||
|
||||
const newRootPackageDirs = await listNewManagedNpmRootPackageDirs({
|
||||
beforeInstallPackageNames: preInstallRootPackageNames,
|
||||
npmRoot,
|
||||
});
|
||||
const result = await installPluginFromInstalledPackageDir({
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
additionalDependencyPackageDirs: newRootPackageDirs,
|
||||
packageDir: installRoot,
|
||||
dependencyScanRootDir: npmRoot,
|
||||
logger,
|
||||
@@ -1214,21 +1280,30 @@ async function validatePackagePluginInstallSource(params: {
|
||||
async function scanAndLinkInstalledPackage(params: {
|
||||
runtime: Awaited<ReturnType<typeof loadPluginInstallRuntime>>;
|
||||
installedDir: string;
|
||||
additionalDependencyPackageDirs?: string[];
|
||||
dependencyScanRootDir?: string;
|
||||
pluginId: string;
|
||||
peerDependencies: Record<string, string>;
|
||||
dangerouslyForceUnsafeInstall?: boolean;
|
||||
trustedSourceLinkedOfficialInstall?: boolean;
|
||||
logger: PluginInstallLogger;
|
||||
}): Promise<Extract<InstallPluginResult, { ok: false }> | null> {
|
||||
const scanResult = await runInstallSourceScan({
|
||||
subject: `Plugin "${params.pluginId}"`,
|
||||
scan: async () =>
|
||||
await params.runtime.scanInstalledPackageDependencyTree({
|
||||
...(params.additionalDependencyPackageDirs
|
||||
? { additionalPackageDirs: params.additionalDependencyPackageDirs }
|
||||
: {}),
|
||||
allowManagedNpmRootPackagePeerSymlinks:
|
||||
params.dependencyScanRootDir !== undefined &&
|
||||
path.resolve(params.dependencyScanRootDir) !== path.resolve(params.installedDir),
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
dependencyScanRootDir: params.dependencyScanRootDir,
|
||||
logger: params.logger,
|
||||
packageDir: params.dependencyScanRootDir ?? params.installedDir,
|
||||
packageDir: params.installedDir,
|
||||
pluginId: params.pluginId,
|
||||
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
|
||||
}),
|
||||
});
|
||||
if (scanResult) {
|
||||
@@ -1250,6 +1325,7 @@ async function scanAndLinkInstalledPackage(params: {
|
||||
|
||||
export async function installPluginFromInstalledPackageDir(
|
||||
params: {
|
||||
additionalDependencyPackageDirs?: string[];
|
||||
packageDir: string;
|
||||
dependencyScanRootDir?: string;
|
||||
} & PackageInstallCommonParams,
|
||||
@@ -1273,9 +1349,14 @@ export async function installPluginFromInstalledPackageDir(
|
||||
const postInstallError = await scanAndLinkInstalledPackage({
|
||||
runtime,
|
||||
installedDir: params.packageDir,
|
||||
...(params.additionalDependencyPackageDirs
|
||||
? { additionalDependencyPackageDirs: params.additionalDependencyPackageDirs }
|
||||
: {}),
|
||||
dependencyScanRootDir: params.dependencyScanRootDir,
|
||||
pluginId: validated.plugin.pluginId,
|
||||
peerDependencies: validated.plugin.peerDependencies,
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
|
||||
logger,
|
||||
});
|
||||
if (postInstallError) {
|
||||
@@ -1364,6 +1445,8 @@ async function installPluginFromPackageDir(
|
||||
installedDir,
|
||||
pluginId: plugin.pluginId,
|
||||
peerDependencies: plugin.peerDependencies,
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
|
||||
logger,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -108,6 +108,7 @@ description: test skill
|
||||
critical: 1,
|
||||
warn: 0,
|
||||
info: 0,
|
||||
truncated: false,
|
||||
findings: [
|
||||
{
|
||||
ruleId: "dangerous-exec",
|
||||
@@ -173,6 +174,7 @@ description: test skill
|
||||
critical: dirPath.includes(`${path.sep}demo`) ? 1 : 0,
|
||||
warn: 0,
|
||||
info: 0,
|
||||
truncated: false,
|
||||
findings: dirPath.includes(`${path.sep}demo`)
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -138,6 +138,7 @@ type SummaryCase = {
|
||||
critical?: number;
|
||||
warn?: number;
|
||||
info?: number;
|
||||
truncated?: boolean;
|
||||
findingCount?: number;
|
||||
maxFindings?: number;
|
||||
expectedRuleId?: string;
|
||||
@@ -529,9 +530,23 @@ describe("scanDirectoryWithSummary", () => {
|
||||
options: { maxFiles: 2 },
|
||||
expected: {
|
||||
scannedFiles: 2,
|
||||
truncated: true,
|
||||
maxFindings: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "does not mark scans truncated when file count exactly matches maxFiles",
|
||||
files: {
|
||||
"a.js": `const x = eval("a");`,
|
||||
"b.js": `const x = eval("b");`,
|
||||
},
|
||||
options: { maxFiles: 2 },
|
||||
expected: {
|
||||
scannedFiles: 2,
|
||||
truncated: false,
|
||||
findingCount: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "skips files above maxFileBytes",
|
||||
files: {
|
||||
@@ -591,6 +606,9 @@ describe("scanDirectoryWithSummary", () => {
|
||||
if (testCase.expected.info != null) {
|
||||
expect(summary.info).toBe(testCase.expected.info);
|
||||
}
|
||||
if (testCase.expected.truncated != null) {
|
||||
expect(summary.truncated).toBe(testCase.expected.truncated);
|
||||
}
|
||||
if (testCase.expected.findingCount != null) {
|
||||
expect(summary.findings).toHaveLength(testCase.expected.findingCount);
|
||||
}
|
||||
|
||||
@@ -23,11 +23,15 @@ export type SkillScanSummary = {
|
||||
critical: number;
|
||||
warn: number;
|
||||
info: number;
|
||||
truncated: boolean;
|
||||
findings: SkillScanFinding[];
|
||||
};
|
||||
|
||||
export type SkillScanOptions = {
|
||||
excludeTestFiles?: boolean;
|
||||
includeHiddenDirectories?: boolean;
|
||||
includeNestedNodeModulesTestFiles?: boolean;
|
||||
includeNodeModules?: boolean;
|
||||
includeFiles?: string[];
|
||||
maxFiles?: number;
|
||||
maxFileBytes?: number;
|
||||
@@ -68,6 +72,10 @@ type CachedDirEntry = {
|
||||
name: string;
|
||||
kind: "file" | "dir";
|
||||
};
|
||||
type CollectedScannableFiles = {
|
||||
files: string[];
|
||||
truncated: boolean;
|
||||
};
|
||||
type DirEntryCacheEntry = {
|
||||
mtimeMs: number;
|
||||
entries: CachedDirEntry[];
|
||||
@@ -424,6 +432,9 @@ export function scanSource(source: string, filePath: string): SkillScanFinding[]
|
||||
function normalizeScanOptions(opts?: SkillScanOptions): Required<SkillScanOptions> {
|
||||
return {
|
||||
excludeTestFiles: opts?.excludeTestFiles ?? false,
|
||||
includeHiddenDirectories: opts?.includeHiddenDirectories ?? false,
|
||||
includeNestedNodeModulesTestFiles: opts?.includeNestedNodeModulesTestFiles ?? false,
|
||||
includeNodeModules: opts?.includeNodeModules ?? false,
|
||||
includeFiles: opts?.includeFiles ?? [],
|
||||
maxFiles: Math.max(1, opts?.maxFiles ?? DEFAULT_MAX_SCAN_FILES),
|
||||
maxFileBytes: Math.max(1, opts?.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES),
|
||||
@@ -438,15 +449,23 @@ function isExcludedTestFileName(name: string): boolean {
|
||||
return TEST_FILE_NAME_PATTERN.test(name);
|
||||
}
|
||||
|
||||
function pathContainsNodeModulesSegment(relativePath: string): boolean {
|
||||
return relativePath.split(/[\\/]+/u).includes("node_modules");
|
||||
}
|
||||
|
||||
async function walkDirWithLimit(
|
||||
rootDir: string,
|
||||
dirPath: string,
|
||||
maxFiles: number,
|
||||
candidateLimit: number,
|
||||
excludeTestFiles: boolean,
|
||||
): Promise<string[]> {
|
||||
includeHiddenDirectories: boolean,
|
||||
includeNestedNodeModulesTestFiles: boolean,
|
||||
includeNodeModules: boolean,
|
||||
): Promise<CollectedScannableFiles> {
|
||||
const files: string[] = [];
|
||||
const stack: string[] = [dirPath];
|
||||
|
||||
while (stack.length > 0 && files.length < maxFiles) {
|
||||
while (stack.length > 0 && files.length < candidateLimit) {
|
||||
const currentDir = stack.pop();
|
||||
if (!currentDir) {
|
||||
break;
|
||||
@@ -454,22 +473,30 @@ async function walkDirWithLimit(
|
||||
|
||||
const entries = await readDirEntriesWithCache(currentDir);
|
||||
for (const entry of entries) {
|
||||
if (files.length >= maxFiles) {
|
||||
if (files.length >= candidateLimit) {
|
||||
break;
|
||||
}
|
||||
// Skip hidden dirs and node_modules
|
||||
if (entry.name.startsWith(".") || entry.name === "node_modules") {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
excludeTestFiles &&
|
||||
((entry.kind === "dir" && isExcludedTestDirectoryName(entry.name)) ||
|
||||
(entry.kind === "file" && isExcludedTestFileName(entry.name)))
|
||||
(!includeHiddenDirectories && entry.name.startsWith(".")) ||
|
||||
(!includeNodeModules && entry.name === "node_modules")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fullPath = path.join(currentDir, entry.name);
|
||||
const isExcludedTestPath =
|
||||
entry.kind === "dir"
|
||||
? isExcludedTestDirectoryName(entry.name)
|
||||
: isExcludedTestFileName(entry.name);
|
||||
if (
|
||||
excludeTestFiles &&
|
||||
isExcludedTestPath &&
|
||||
!(
|
||||
includeNestedNodeModulesTestFiles &&
|
||||
pathContainsNodeModulesSegment(path.relative(rootDir, fullPath))
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (entry.kind === "dir") {
|
||||
stack.push(fullPath);
|
||||
} else if (entry.kind === "file" && isScannable(entry.name)) {
|
||||
@@ -478,7 +505,7 @@ async function walkDirWithLimit(
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
return { files, truncated: files.length >= candidateLimit };
|
||||
}
|
||||
|
||||
async function readDirEntriesWithCache(dirPath: string): Promise<CachedDirEntry[]> {
|
||||
@@ -559,30 +586,41 @@ async function resolveForcedFiles(params: {
|
||||
return out;
|
||||
}
|
||||
|
||||
async function collectScannableFiles(dirPath: string, opts: Required<SkillScanOptions>) {
|
||||
async function collectScannableFiles(
|
||||
dirPath: string,
|
||||
opts: Required<SkillScanOptions>,
|
||||
): Promise<CollectedScannableFiles> {
|
||||
const forcedFiles = await resolveForcedFiles({
|
||||
rootDir: dirPath,
|
||||
includeFiles: opts.includeFiles,
|
||||
});
|
||||
if (forcedFiles.length >= opts.maxFiles) {
|
||||
return forcedFiles.slice(0, opts.maxFiles);
|
||||
if (forcedFiles.length > opts.maxFiles) {
|
||||
return { files: forcedFiles.slice(0, opts.maxFiles), truncated: true };
|
||||
}
|
||||
|
||||
const walkedFiles = await walkDirWithLimit(dirPath, opts.maxFiles, opts.excludeTestFiles);
|
||||
const walked = await walkDirWithLimit(
|
||||
dirPath,
|
||||
dirPath,
|
||||
opts.maxFiles + 1,
|
||||
opts.excludeTestFiles,
|
||||
opts.includeHiddenDirectories,
|
||||
opts.includeNestedNodeModulesTestFiles,
|
||||
opts.includeNodeModules,
|
||||
);
|
||||
const seen = new Set(forcedFiles.map((f) => path.resolve(f)));
|
||||
const out = [...forcedFiles];
|
||||
for (const walkedFile of walkedFiles) {
|
||||
if (out.length >= opts.maxFiles) {
|
||||
break;
|
||||
}
|
||||
for (const walkedFile of walked.files) {
|
||||
const resolved = path.resolve(walkedFile);
|
||||
if (seen.has(resolved)) {
|
||||
continue;
|
||||
}
|
||||
if (out.length >= opts.maxFiles) {
|
||||
return { files: out.slice(0, opts.maxFiles), truncated: true };
|
||||
}
|
||||
out.push(walkedFile);
|
||||
seen.add(resolved);
|
||||
}
|
||||
return out;
|
||||
return { files: out, truncated: false };
|
||||
}
|
||||
|
||||
async function scanFileWithCache(params: {
|
||||
@@ -652,7 +690,7 @@ export async function scanDirectory(
|
||||
opts?: SkillScanOptions,
|
||||
): Promise<SkillScanFinding[]> {
|
||||
const scanOptions = normalizeScanOptions(opts);
|
||||
const files = await collectScannableFiles(dirPath, scanOptions);
|
||||
const { files } = await collectScannableFiles(dirPath, scanOptions);
|
||||
const allFindings: SkillScanFinding[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
@@ -674,7 +712,7 @@ export async function scanDirectoryWithSummary(
|
||||
opts?: SkillScanOptions,
|
||||
): Promise<SkillScanSummary> {
|
||||
const scanOptions = normalizeScanOptions(opts);
|
||||
const files = await collectScannableFiles(dirPath, scanOptions);
|
||||
const { files, truncated } = await collectScannableFiles(dirPath, scanOptions);
|
||||
const allFindings: SkillScanFinding[] = [];
|
||||
let scannedFiles = 0;
|
||||
let critical = 0;
|
||||
@@ -707,6 +745,7 @@ export async function scanDirectoryWithSummary(
|
||||
critical,
|
||||
warn,
|
||||
info,
|
||||
truncated,
|
||||
findings: allFindings,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user