fix(security): pin axios to 1.15.0 and add dependency denylist for plugin installs [AI-assisted] (#63891)

* fix: address issue

* fix: address review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* Plugins: fix install security CI regressions

* Plugins: make manifest traversal linear

* Plugins: bound manifest security traversal

* Plugins: block denied node_modules package dirs

* Plugins: match node_modules case-insensitively

* Plugins: block denied package symlink paths

* Tests: normalize blocked symlink assertion

* Plugins: fail closed on unreadable denied paths

* Plugins: block denied node_modules file aliases

* Plugins: inspect node_modules symlink targets

* Plugins: preserve symlink target package paths

* fix: address PR review feedback

* chore(changelog): add axios pin and dependency denylist entry

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
This commit is contained in:
Michael Appel
2026-04-10 13:20:05 -04:00
committed by GitHub
parent 9b44929f28
commit 9f97ad857a
11 changed files with 1959 additions and 2 deletions

View File

@@ -0,0 +1,209 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
blockedInstallDependencyPackageNames,
findBlockedPackageDirectoryInPath,
findBlockedPackageFileAliasInPath,
findBlockedManifestDependencies,
findBlockedNodeModulesDirectory,
findBlockedNodeModulesFileAlias,
} from "./dependency-denylist.js";
type RootPackageManifest = {
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
overrides?: Record<string, string | Record<string, string>>;
peerDependencies?: Record<string, string>;
pnpm?: {
overrides?: Record<string, string>;
};
};
function readRootManifest(): RootPackageManifest {
return JSON.parse(
fs.readFileSync(path.resolve(process.cwd(), "package.json"), "utf8"),
) as RootPackageManifest;
}
function readRootLockfile(): string {
return fs.readFileSync(path.resolve(process.cwd(), "pnpm-lock.yaml"), "utf8");
}
describe("dependency denylist guardrails", () => {
it("finds blocked package names on vendored manifests", () => {
expect(
findBlockedManifestDependencies({
name: "plain-crypto-js",
}),
).toEqual([
{
dependencyName: "plain-crypto-js",
field: "name",
},
]);
});
it("finds blocked packages declared through npm alias specs", () => {
expect(
findBlockedManifestDependencies({
dependencies: {
"safe-name": "npm:plain-crypto-js@^4.2.1",
},
peerDependencies: {
"@alias/safe": "npm:@scope/ok@^1.0.0",
},
}),
).toEqual([
{
dependencyName: "plain-crypto-js",
declaredAs: "safe-name",
field: "dependencies",
},
]);
});
it("finds blocked packages declared through nested override alias specs", () => {
expect(
findBlockedManifestDependencies({
overrides: {
axios: "1.15.0",
"@scope/parent": {
"safe-name": "npm:plain-crypto-js@^4.2.1",
},
},
}),
).toEqual([
{
dependencyName: "plain-crypto-js",
declaredAs: "@scope/parent > safe-name",
field: "overrides",
},
]);
});
it("pins the axios override to an exact version", () => {
const manifest = readRootManifest();
expect(manifest.overrides?.axios).toMatch(/^\d+\.\d+\.\d+$/);
expect(manifest.pnpm?.overrides?.axios).toMatch(/^\d+\.\d+\.\d+$/);
});
it("finds blocked package directories under node_modules regardless of node_modules casing", () => {
expect(
findBlockedNodeModulesDirectory({
directoryRelativePath: "vendor/Node_Modules/plain-crypto-js",
}),
).toEqual({
dependencyName: "plain-crypto-js",
directoryRelativePath: "vendor/Node_Modules/plain-crypto-js",
});
});
it("finds blocked package directories regardless of blocked package segment casing", () => {
expect(
findBlockedNodeModulesDirectory({
directoryRelativePath: "vendor/node_modules/Plain-Crypto-Js",
}),
).toEqual({
dependencyName: "Plain-Crypto-Js",
directoryRelativePath: "vendor/node_modules/Plain-Crypto-Js",
});
});
it("finds blocked package file aliases under node_modules regardless of casing", () => {
expect(
findBlockedNodeModulesFileAlias({
fileRelativePath: "vendor/Node_Modules/Plain-Crypto-Js.Js",
}),
).toEqual({
dependencyName: "Plain-Crypto-Js",
fileRelativePath: "vendor/Node_Modules/Plain-Crypto-Js.Js",
});
});
it("finds blocked extensionless package file aliases under node_modules", () => {
expect(
findBlockedNodeModulesFileAlias({
fileRelativePath: "vendor/Node_Modules/Plain-Crypto-Js",
}),
).toEqual({
dependencyName: "Plain-Crypto-Js",
fileRelativePath: "vendor/Node_Modules/Plain-Crypto-Js",
});
});
it("finds blocked package directories anywhere in a resolved path", () => {
expect(
findBlockedPackageDirectoryInPath({
pathRelativeToRoot: "vendor/Plain-Crypto-Js/dist/index.js",
}),
).toEqual({
dependencyName: "Plain-Crypto-Js",
directoryRelativePath: "vendor/Plain-Crypto-Js/dist/index.js",
});
});
it("finds blocked package file aliases anywhere in a resolved path", () => {
expect(
findBlockedPackageFileAliasInPath({
pathRelativeToRoot: "vendor/Plain-Crypto-Js.Js",
}),
).toEqual({
dependencyName: "Plain-Crypto-Js",
fileRelativePath: "vendor/Plain-Crypto-Js.Js",
});
});
it("does not treat similarly named non-node_modules segments as package-resolution paths", () => {
expect(
findBlockedNodeModulesDirectory({
directoryRelativePath: "vendor/node_modules_backup/plain-crypto-js",
}),
).toBeUndefined();
});
it("does not treat similarly named non-node_modules file aliases as package-resolution paths", () => {
expect(
findBlockedNodeModulesFileAlias({
fileRelativePath: "vendor/plain-crypto-js.js",
}),
).toBeUndefined();
});
it("does not treat dotted non-loadable file aliases as blocked package paths", () => {
expect(
findBlockedNodeModulesFileAlias({
fileRelativePath: "vendor/node_modules/plain-crypto-js.txt",
}),
).toBeUndefined();
});
it("does not treat similarly named non-package paths as blocked package directories", () => {
expect(
findBlockedPackageDirectoryInPath({
pathRelativeToRoot: "vendor/safe-plain-crypto-js-notes/index.js",
}),
).toBeUndefined();
});
it("does not flag the unscoped name segment from an allowed scoped package path", () => {
expect(
findBlockedPackageDirectoryInPath({
pathRelativeToRoot: "vendor/@scope/plain-crypto-js/dist/index.js",
}),
).toBeUndefined();
});
it("keeps blocked packages out of the root manifest", () => {
const manifest = readRootManifest();
expect(findBlockedManifestDependencies(manifest)).toEqual([]);
});
it("keeps blocked packages out of the lockfile graph", () => {
const lockfile = readRootLockfile();
for (const packageName of blockedInstallDependencyPackageNames) {
expect(lockfile).not.toContain(`\n ${packageName}@`);
expect(lockfile).not.toContain(`\n ${packageName}: `);
}
});
});

View File

@@ -0,0 +1,326 @@
const BLOCKED_INSTALL_DEPENDENCY_PACKAGE_NAMES = ["plain-crypto-js"] as const;
export const blockedInstallDependencyPackageNames = [
...BLOCKED_INSTALL_DEPENDENCY_PACKAGE_NAMES,
] as const;
export type BlockedManifestDependencyFinding = {
dependencyName: string;
declaredAs?: string;
field: "dependencies" | "name" | "optionalDependencies" | "overrides" | "peerDependencies";
};
export type BlockedPackageDirectoryFinding = {
dependencyName: string;
directoryRelativePath: string;
};
export type BlockedPackageFileFinding = {
dependencyName: string;
fileRelativePath: string;
};
type PackageDependencyMapFields = Partial<
Record<
Exclude<BlockedManifestDependencyFinding["field"], "name" | "overrides">,
Record<string, string>
>
>;
type PackageDependencyFields = {
name?: string;
} & PackageDependencyMapFields;
interface PackageOverrideObject {
[key: string]: PackageOverrideValue;
}
type PackageOverrideValue = string | PackageOverrideObject;
type PackageOverrideFields = {
overrides?: unknown;
};
const BLOCKED_INSTALL_DEPENDENCY_PACKAGE_NAME_SET = new Set<string>(
blockedInstallDependencyPackageNames,
);
const BLOCKED_INSTALL_DEPENDENCY_PACKAGE_NAME_LOWER_SET = new Set<string>(
blockedInstallDependencyPackageNames.map((packageName) => packageName.toLowerCase()),
);
function isBlockedInstallDependencyPackageName(packageName: string): boolean {
return BLOCKED_INSTALL_DEPENDENCY_PACKAGE_NAME_SET.has(packageName);
}
function isBlockedInstallDependencyPackagePathName(packageName: string): boolean {
return BLOCKED_INSTALL_DEPENDENCY_PACKAGE_NAME_LOWER_SET.has(packageName.toLowerCase());
}
function normalizePathSegments(relativePath: string): string[] {
return relativePath
.split(/[\\/]+/)
.map((segment) => segment.trim())
.filter(Boolean);
}
function parseBlockedNodeModulesPackageId(
segments: string[],
packageNameSegmentTransform: (packageNameSegment: string) => string | undefined,
): string | undefined {
for (let index = 0; index < segments.length; index += 1) {
if (segments[index]?.toLowerCase() !== "node_modules") {
continue;
}
const packageScopeOrName = segments[index + 1];
if (!packageScopeOrName) {
continue;
}
if (packageScopeOrName.startsWith("@")) {
const packageNameSegment = segments[index + 2];
if (!packageNameSegment) {
continue;
}
const packageName = packageNameSegmentTransform(packageNameSegment);
if (!packageName) {
continue;
}
const scopedPackageId = `${packageScopeOrName}/${packageName}`;
if (!isBlockedInstallDependencyPackagePathName(scopedPackageId)) {
continue;
}
return scopedPackageId;
}
const packageName = packageNameSegmentTransform(packageScopeOrName);
if (!packageName || !isBlockedInstallDependencyPackagePathName(packageName)) {
continue;
}
return packageName;
}
return undefined;
}
function parseNpmAliasTargetPackageName(spec: string): string | undefined {
const normalized = spec.trim();
if (!normalized.startsWith("npm:")) {
return undefined;
}
const aliasTarget = normalized.slice("npm:".length).trim();
if (!aliasTarget) {
return undefined;
}
if (aliasTarget.startsWith("@")) {
const slashIndex = aliasTarget.indexOf("/");
if (slashIndex < 0) {
return undefined;
}
const versionSeparatorIndex = aliasTarget.indexOf("@", slashIndex + 1);
return versionSeparatorIndex < 0 ? aliasTarget : aliasTarget.slice(0, versionSeparatorIndex);
}
const versionSeparatorIndex = aliasTarget.indexOf("@");
return versionSeparatorIndex < 0 ? aliasTarget : aliasTarget.slice(0, versionSeparatorIndex);
}
function parsePackageNameFromOverrideSelector(selector: string): string | undefined {
const normalized = selector.trim();
if (!normalized || normalized === ".") {
return undefined;
}
if (normalized.startsWith("@")) {
const slashIndex = normalized.indexOf("/");
if (slashIndex < 0) {
return undefined;
}
const versionSeparatorIndex = normalized.indexOf("@", slashIndex + 1);
return versionSeparatorIndex < 0 ? normalized : normalized.slice(0, versionSeparatorIndex);
}
const versionSeparatorIndex = normalized.indexOf("@");
return versionSeparatorIndex < 0 ? normalized : normalized.slice(0, versionSeparatorIndex);
}
function collectBlockedOverrideFindings(
value: PackageOverrideValue,
path: string[] = [],
): BlockedManifestDependencyFinding[] {
if (typeof value === "string") {
const aliasTargetPackageName = parseNpmAliasTargetPackageName(value);
if (!aliasTargetPackageName) {
return [];
}
if (!BLOCKED_INSTALL_DEPENDENCY_PACKAGE_NAME_SET.has(aliasTargetPackageName)) {
return [];
}
return [
{
dependencyName: aliasTargetPackageName,
declaredAs: path.join(" > "),
field: "overrides",
},
];
}
const findings: BlockedManifestDependencyFinding[] = [];
for (const overrideKey of Object.keys(value).toSorted()) {
const overrideSelectorPackageName = parsePackageNameFromOverrideSelector(overrideKey);
if (
overrideSelectorPackageName &&
BLOCKED_INSTALL_DEPENDENCY_PACKAGE_NAME_SET.has(overrideSelectorPackageName)
) {
findings.push({
dependencyName: overrideSelectorPackageName,
declaredAs: [...path, overrideKey].join(" > "),
field: "overrides",
});
}
findings.push(...collectBlockedOverrideFindings(value[overrideKey], [...path, overrideKey]));
}
return findings;
}
function isPackageOverrideObject(value: unknown): value is PackageOverrideObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function findBlockedManifestDependencies(
manifest: PackageDependencyFields & PackageOverrideFields,
): BlockedManifestDependencyFinding[] {
const findings: BlockedManifestDependencyFinding[] = [];
if (manifest.name && isBlockedInstallDependencyPackageName(manifest.name)) {
findings.push({ dependencyName: manifest.name, field: "name" });
}
if (isPackageOverrideObject(manifest.overrides)) {
findings.push(...collectBlockedOverrideFindings(manifest.overrides));
}
for (const field of ["dependencies", "optionalDependencies", "peerDependencies"] as const) {
const dependencyMap = manifest[field];
if (!dependencyMap) {
continue;
}
for (const dependencyName of Object.keys(dependencyMap).toSorted()) {
if (isBlockedInstallDependencyPackageName(dependencyName)) {
findings.push({ dependencyName, field });
continue;
}
const aliasTargetPackageName = parseNpmAliasTargetPackageName(dependencyMap[dependencyName]);
if (!aliasTargetPackageName) {
continue;
}
if (!isBlockedInstallDependencyPackageName(aliasTargetPackageName)) {
continue;
}
findings.push({
dependencyName: aliasTargetPackageName,
declaredAs: dependencyName,
field,
});
}
}
return findings;
}
export function findBlockedNodeModulesDirectory(params: {
directoryRelativePath: string;
}): BlockedPackageDirectoryFinding | undefined {
const dependencyName = parseBlockedNodeModulesPackageId(
normalizePathSegments(params.directoryRelativePath),
(packageNameSegment) => packageNameSegment,
);
return dependencyName
? {
dependencyName,
directoryRelativePath: params.directoryRelativePath,
}
: undefined;
}
function parseBlockedPackageFileAliasName(fileName: string): string | undefined {
const extensionMatch = /^(.+)\.(js|json|node)$/i.exec(fileName);
if (extensionMatch) {
return extensionMatch[1];
}
if (fileName.includes(".")) {
return undefined;
}
return fileName;
}
export function findBlockedNodeModulesFileAlias(params: {
fileRelativePath: string;
}): BlockedPackageFileFinding | undefined {
const dependencyName = parseBlockedNodeModulesPackageId(
normalizePathSegments(params.fileRelativePath),
parseBlockedPackageFileAliasName,
);
return dependencyName
? {
dependencyName,
fileRelativePath: params.fileRelativePath,
}
: undefined;
}
export function findBlockedPackageDirectoryInPath(params: {
pathRelativeToRoot: string;
}): BlockedPackageDirectoryFinding | undefined {
const segments = normalizePathSegments(params.pathRelativeToRoot);
for (let index = 0; index < segments.length; index += 1) {
const packageScopeOrName = segments[index];
if (!packageScopeOrName) {
continue;
}
if (packageScopeOrName.startsWith("@")) {
const packageName = segments[index + 1];
if (!packageName) {
continue;
}
const scopedPackageId = `${packageScopeOrName}/${packageName}`;
if (!isBlockedInstallDependencyPackagePathName(scopedPackageId)) {
index += 1;
continue;
}
return {
dependencyName: scopedPackageId,
directoryRelativePath: params.pathRelativeToRoot,
};
}
if (!isBlockedInstallDependencyPackagePathName(packageScopeOrName)) {
continue;
}
return {
dependencyName: packageScopeOrName,
directoryRelativePath: params.pathRelativeToRoot,
};
}
return undefined;
}
export function findBlockedPackageFileAliasInPath(params: {
pathRelativeToRoot: string;
}): BlockedPackageFileFinding | undefined {
const segments = normalizePathSegments(params.pathRelativeToRoot);
const fileName = segments.at(-1);
if (!fileName) {
return undefined;
}
const dependencyName = parseBlockedPackageFileAliasName(fileName);
if (!dependencyName || !isBlockedInstallDependencyPackagePathName(dependencyName)) {
return undefined;
}
return {
dependencyName,
fileRelativePath: params.pathRelativeToRoot,
};
}

View File

@@ -1,6 +1,14 @@
import fs from "node:fs/promises";
import path from "node:path";
import { extensionUsesSkippedScannerPath, isPathInside } from "../security/scan-paths.js";
import { scanDirectoryWithSummary } from "../security/skill-scanner.js";
import {
findBlockedPackageDirectoryInPath,
findBlockedPackageFileAliasInPath,
findBlockedManifestDependencies,
findBlockedNodeModulesDirectory,
findBlockedNodeModulesFileAlias,
} from "./dependency-denylist.js";
import { getGlobalHookRunner } from "./hook-runner-global.js";
import { createBeforeInstallHookPayload } from "./install-policy-context.js";
import type { InstallSafetyOverrides } from "./install-security-scan.js";
@@ -27,6 +35,36 @@ type BuiltinInstallScan = {
error?: string;
};
type PackageManifest = {
name?: string;
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
overrides?: unknown;
peerDependencies?: Record<string, string>;
};
type PackageManifestTraversalLimits = {
maxDepth: number;
maxDirectories: number;
maxManifests: number;
};
type BlockedPackageDirectoryFinding = {
dependencyName: string;
directoryRelativePath: string;
};
type BlockedPackageFileFinding = {
dependencyName: string;
fileRelativePath: string;
};
type PackageManifestTraversalResult = {
blockedDirectoryFinding?: BlockedPackageDirectoryFinding;
blockedFileFinding?: BlockedPackageFileFinding;
packageManifestPaths: string[];
};
type PluginInstallRequestKind =
| "skill-install"
| "plugin-dir"
@@ -61,6 +99,102 @@ function buildScanFailureBlockReason(params: { error: string; targetLabel: strin
return `${params.targetLabel} blocked: code safety scan failed (${params.error}). Run "openclaw security audit --deep" for details.`;
}
function buildBlockedDependencyManifestLabel(params: {
manifestPackageName?: string;
manifestRelativePath: string;
}) {
const manifestLabel =
typeof params.manifestPackageName === "string" && params.manifestPackageName.trim()
? `${params.manifestPackageName.trim()} (${params.manifestRelativePath})`
: params.manifestRelativePath;
return manifestLabel;
}
function buildBlockedDependencyReason(params: {
findings: Array<{
dependencyName: string;
declaredAs?: string;
field: "dependencies" | "name" | "optionalDependencies" | "overrides" | "peerDependencies";
}>;
manifestPackageName?: string;
manifestRelativePath: string;
targetLabel: string;
}) {
const manifestLabel = buildBlockedDependencyManifestLabel({
manifestPackageName: params.manifestPackageName,
manifestRelativePath: params.manifestRelativePath,
});
const findingSummary = params.findings
.map((finding) =>
finding.field === "name"
? `"${finding.dependencyName}" as package name`
: finding.declaredAs
? `"${finding.dependencyName}" via alias "${finding.declaredAs}" in ${finding.field}`
: `"${finding.dependencyName}" in ${finding.field}`,
)
.join(", ");
return `${params.targetLabel} blocked: blocked dependencies ${findingSummary} declared in ${manifestLabel}.`;
}
function buildBlockedDependencyDirectoryReason(params: {
dependencyName: string;
directoryRelativePath: string;
targetLabel: string;
}) {
return `${params.targetLabel} blocked: blocked dependency directory "${params.dependencyName}" declared at ${params.directoryRelativePath}.`;
}
function buildBlockedDependencyFileReason(params: {
dependencyName: string;
fileRelativePath: string;
targetLabel: string;
}) {
return `${params.targetLabel} blocked: blocked dependency file alias "${params.dependencyName}" declared at ${params.fileRelativePath}.`;
}
function pathContainsNodeModulesSegment(relativePath: string): boolean {
return relativePath
.split(/[\\/]+/)
.map((segment) => segment.trim().toLowerCase())
.includes("node_modules");
}
async function inspectNodeModulesSymlinkTarget(params: {
rootRealPath: string;
symlinkPath: string;
symlinkRelativePath: string;
}): Promise<
Pick<PackageManifestTraversalResult, "blockedDirectoryFinding" | "blockedFileFinding">
> {
let resolvedTargetPath: string;
try {
resolvedTargetPath = await fs.realpath(params.symlinkPath);
} catch (error) {
throw new Error(
`manifest dependency scan could not resolve symlink target ${params.symlinkRelativePath}: ${String(error)}`,
{
cause: error,
},
);
}
if (!isPathInside(params.rootRealPath, resolvedTargetPath)) {
throw new Error(
`manifest dependency scan found node_modules symlink target outside install root at ${params.symlinkRelativePath}`,
);
}
const resolvedTargetRelativePath = path.relative(params.rootRealPath, resolvedTargetPath);
return {
blockedDirectoryFinding: findBlockedPackageDirectoryInPath({
pathRelativeToRoot: resolvedTargetRelativePath,
}),
blockedFileFinding: findBlockedPackageFileAliasInPath({
pathRelativeToRoot: resolvedTargetRelativePath,
}),
};
}
function buildBuiltinScanFromError(error: unknown): BuiltinInstallScan {
return {
status: "error",
@@ -90,6 +224,240 @@ function buildBuiltinScanFromSummary(summary: {
};
}
const DEFAULT_PACKAGE_MANIFEST_TRAVERSAL_LIMITS: PackageManifestTraversalLimits = {
maxDepth: 64,
maxDirectories: 10_000,
maxManifests: 10_000,
};
function readPositiveIntegerEnv(name: string, fallback: number): number {
const rawValue = process.env[name];
if (!rawValue) {
return fallback;
}
const parsedValue = Number.parseInt(rawValue, 10);
if (!Number.isFinite(parsedValue) || parsedValue < 1) {
return fallback;
}
return parsedValue;
}
function resolvePackageManifestTraversalLimits(): PackageManifestTraversalLimits {
return {
maxDepth: readPositiveIntegerEnv(
"OPENCLAW_INSTALL_SCAN_MAX_DEPTH",
DEFAULT_PACKAGE_MANIFEST_TRAVERSAL_LIMITS.maxDepth,
),
maxDirectories: readPositiveIntegerEnv(
"OPENCLAW_INSTALL_SCAN_MAX_DIRECTORIES",
DEFAULT_PACKAGE_MANIFEST_TRAVERSAL_LIMITS.maxDirectories,
),
maxManifests: readPositiveIntegerEnv(
"OPENCLAW_INSTALL_SCAN_MAX_MANIFESTS",
DEFAULT_PACKAGE_MANIFEST_TRAVERSAL_LIMITS.maxManifests,
),
};
}
async function collectPackageManifestPaths(
rootDir: string,
): Promise<PackageManifestTraversalResult> {
const limits = resolvePackageManifestTraversalLimits();
const rootRealPath = await fs.realpath(rootDir).catch(() => rootDir);
const queue: Array<{ depth: number; dir: string }> = [{ depth: 0, dir: rootDir }];
const packageManifestPaths: string[] = [];
const visitedDirectories = new Set<string>();
let queueIndex = 0;
while (queueIndex < queue.length) {
const current = queue[queueIndex];
queueIndex += 1;
if (!current) {
continue;
}
if (current.depth > limits.maxDepth) {
throw new Error(
`manifest dependency scan exceeded max depth (${limits.maxDepth}) at ${current.dir}`,
);
}
const currentDir = current.dir;
const currentRealPath = await fs.realpath(currentDir).catch(() => currentDir);
if (visitedDirectories.has(currentRealPath)) {
continue;
}
visitedDirectories.add(currentRealPath);
if (visitedDirectories.size > limits.maxDirectories) {
throw new Error(
`manifest dependency scan exceeded max directories (${limits.maxDirectories}) under ${rootDir}`,
);
}
let entries: Array<{
name: string;
isDirectory(): boolean;
isFile(): boolean;
isSymbolicLink(): boolean;
}>;
try {
entries = await fs.readdir(currentDir, { encoding: "utf8", withFileTypes: true });
} catch (error) {
throw new Error(`manifest dependency scan could not read ${currentDir}: ${String(error)}`, {
cause: error,
});
}
// Intentionally walk vendored/node_modules trees so bundled transitive
// manifests cannot hide blocked packages from install-time policy checks.
for (const entry of entries.toSorted((left, right) => left.name.localeCompare(right.name))) {
const nextPath = path.join(currentDir, entry.name);
const relativeNextPath = path.relative(rootDir, nextPath) || entry.name;
if (entry.isSymbolicLink()) {
const blockedDirectoryFinding = findBlockedNodeModulesDirectory({
directoryRelativePath: relativeNextPath,
});
if (blockedDirectoryFinding) {
return {
blockedDirectoryFinding,
packageManifestPaths,
};
}
const blockedFileFinding = findBlockedNodeModulesFileAlias({
fileRelativePath: relativeNextPath,
});
if (blockedFileFinding) {
return {
blockedFileFinding,
packageManifestPaths,
};
}
if (pathContainsNodeModulesSegment(relativeNextPath)) {
const symlinkTargetInspection = await inspectNodeModulesSymlinkTarget({
rootRealPath,
symlinkPath: nextPath,
symlinkRelativePath: relativeNextPath,
});
if (symlinkTargetInspection.blockedDirectoryFinding) {
return {
blockedDirectoryFinding: symlinkTargetInspection.blockedDirectoryFinding,
packageManifestPaths,
};
}
if (symlinkTargetInspection.blockedFileFinding) {
return {
blockedFileFinding: symlinkTargetInspection.blockedFileFinding,
packageManifestPaths,
};
}
}
continue;
}
if (entry.isDirectory()) {
const blockedDirectoryFinding = findBlockedNodeModulesDirectory({
directoryRelativePath: relativeNextPath,
});
if (blockedDirectoryFinding) {
return {
blockedDirectoryFinding,
packageManifestPaths,
};
}
queue.push({ depth: current.depth + 1, dir: nextPath });
continue;
}
if (entry.isFile()) {
const blockedFileFinding = findBlockedNodeModulesFileAlias({
fileRelativePath: relativeNextPath,
});
if (blockedFileFinding) {
return {
blockedFileFinding,
packageManifestPaths,
};
}
}
if (entry.isFile() && entry.name === "package.json") {
packageManifestPaths.push(nextPath);
if (packageManifestPaths.length > limits.maxManifests) {
throw new Error(
`manifest dependency scan exceeded max manifests (${limits.maxManifests}) under ${rootDir}`,
);
}
}
}
}
return { packageManifestPaths };
}
async function scanManifestDependencyDenylist(params: {
logger: InstallScanLogger;
packageDir: string;
targetLabel: string;
}): Promise<InstallSecurityScanResult | undefined> {
const traversalResult = await collectPackageManifestPaths(params.packageDir);
if (traversalResult.blockedDirectoryFinding) {
const reason = buildBlockedDependencyDirectoryReason({
dependencyName: traversalResult.blockedDirectoryFinding.dependencyName,
directoryRelativePath: traversalResult.blockedDirectoryFinding.directoryRelativePath,
targetLabel: params.targetLabel,
});
params.logger.warn?.(`WARNING: ${reason}`);
return {
blocked: {
code: "security_scan_blocked",
reason,
},
};
}
if (traversalResult.blockedFileFinding) {
const reason = buildBlockedDependencyFileReason({
dependencyName: traversalResult.blockedFileFinding.dependencyName,
fileRelativePath: traversalResult.blockedFileFinding.fileRelativePath,
targetLabel: params.targetLabel,
});
params.logger.warn?.(`WARNING: ${reason}`);
return {
blocked: {
code: "security_scan_blocked",
reason,
},
};
}
const packageManifestPaths = traversalResult.packageManifestPaths;
for (const manifestPath of packageManifestPaths) {
let manifest: PackageManifest;
try {
manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")) as PackageManifest;
} catch {
continue;
}
const blockedDependencies = findBlockedManifestDependencies(manifest);
if (blockedDependencies.length === 0) {
continue;
}
const manifestRelativePath = path.relative(params.packageDir, manifestPath) || "package.json";
const reason = buildBlockedDependencyReason({
findings: blockedDependencies,
manifestPackageName: manifest.name,
manifestRelativePath,
targetLabel: params.targetLabel,
});
params.logger.warn?.(`WARNING: ${reason}`);
return {
blocked: {
code: "security_scan_blocked",
reason,
},
};
}
return undefined;
}
async function scanDirectoryTarget(params: {
includeFiles?: string[];
logger: InstallScanLogger;
@@ -296,6 +664,15 @@ export async function scanBundleInstallSourceRuntime(
version?: string;
},
): Promise<InstallSecurityScanResult | undefined> {
const dependencyBlocked = await scanManifestDependencyDenylist({
logger: params.logger,
packageDir: params.sourceDir,
targetLabel: `Bundle "${params.pluginId}" installation`,
});
if (dependencyBlocked) {
return dependencyBlocked;
}
const builtinScan = await scanDirectoryTarget({
logger: params.logger,
path: params.sourceDir,
@@ -346,6 +723,15 @@ export async function scanPackageInstallSourceRuntime(
version?: string;
},
): Promise<InstallSecurityScanResult | undefined> {
const dependencyBlocked = await scanManifestDependencyDenylist({
logger: params.logger,
packageDir: params.packageDir,
targetLabel: `Plugin "${params.pluginId}" installation`,
});
if (dependencyBlocked) {
return dependencyBlocked;
}
const forcedScanEntries: string[] = [];
for (const entry of params.extensions) {
const resolvedEntry = path.resolve(params.packageDir, entry);
@@ -402,6 +788,18 @@ export async function scanPackageInstallSourceRuntime(
return hookResult?.blocked ? hookResult : builtinBlocked;
}
export async function scanInstalledPackageDependencyTreeRuntime(params: {
logger: InstallScanLogger;
packageDir: string;
pluginId: string;
}): Promise<InstallSecurityScanResult | undefined> {
return await scanManifestDependencyDenylist({
logger: params.logger,
packageDir: params.packageDir,
targetLabel: `Plugin "${params.pluginId}" installation`,
});
}
export async function scanFileInstallSourceRuntime(
params: InstallSafetyOverrides & {
filePath: string;

View File

@@ -72,6 +72,15 @@ export async function scanPackageInstallSource(
return await scanPackageInstallSourceRuntime(params);
}
export async function scanInstalledPackageDependencyTree(params: {
logger: InstallScanLogger;
packageDir: string;
pluginId: string;
}): Promise<InstallSecurityScanResult | undefined> {
const { scanInstalledPackageDependencyTreeRuntime } = await loadInstallSecurityScanRuntime();
return await scanInstalledPackageDependencyTreeRuntime(params);
}
export async function scanFileInstallSource(
params: InstallSafetyOverrides & {
filePath: string;

View File

@@ -23,6 +23,7 @@ import { validateRegistryNpmSpec } from "../infra/npm-registry-spec.js";
import { resolveCompatibilityHostVersion, resolveRuntimeServiceVersion } from "../version.js";
import { detectBundleManifestFormat, loadBundleManifest } from "./bundle-manifest.js";
import {
scanInstalledPackageDependencyTree,
scanBundleInstallSource,
scanFileInstallSource,
scanPackageInstallSource,
@@ -59,6 +60,7 @@ export {
resolveCompatibilityHostVersion,
resolveRuntimeServiceVersion,
resolveTimedInstallModeOptions,
scanInstalledPackageDependencyTree,
scanBundleInstallSource,
scanFileInstallSource,
scanPackageInstallSource,

View File

@@ -563,6 +563,9 @@ beforeAll(async () => {
beforeEach(() => {
resetGlobalHookRunner();
vi.clearAllMocks();
const run = vi.mocked(runCommandWithTimeout);
run.mockReset();
mockSuccessfulCommandRun(run);
vi.unstubAllEnvs();
resolveCompatibilityHostVersionMock.mockReturnValue("2026.3.28-beta.1");
});
@@ -764,6 +767,634 @@ describe("installPluginFromArchive", () => {
expect(warnings.some((w) => w.includes("dangerous code pattern"))).toBe(true);
});
it("blocks package installs when a package manifest declares a blocked dependency", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "blocked-dependency-plugin",
version: "1.0.0",
openclaw: { extensions: ["index.js"] },
dependencies: {
"plain-crypto-js": "^4.2.1",
},
}),
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n");
const { result, warnings } = await installFromDirWithWarnings({ pluginDir, 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 "blocked-dependency-plugin" installation blocked');
expect(result.error).toContain('blocked dependencies "plain-crypto-js" in dependencies');
expect(result.error).toContain("declared in blocked-dependency-plugin (package.json)");
}
expect(warnings).toContain(
'WARNING: Plugin "blocked-dependency-plugin" installation blocked: blocked dependencies "plain-crypto-js" in dependencies declared in blocked-dependency-plugin (package.json).',
);
});
it("blocks package installs when a dependency aliases to a blocked package", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "aliased-blocked-dependency-plugin",
version: "1.0.0",
openclaw: { extensions: ["index.js"] },
dependencies: {
"safe-name": "npm:plain-crypto-js@^4.2.1",
},
}),
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n");
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain('"plain-crypto-js" via alias "safe-name" in dependencies');
expect(result.error).toContain(
"declared in aliased-blocked-dependency-plugin (package.json)",
);
}
});
it("blocks package installs when overrides alias to a blocked package", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "override-aliased-blocked-dependency-plugin",
version: "1.0.0",
openclaw: { extensions: ["index.js"] },
overrides: {
"@scope/parent": {
"safe-name": "npm:plain-crypto-js@^4.2.1",
},
},
}),
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n");
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain(
'"plain-crypto-js" via alias "@scope/parent > safe-name" in overrides',
);
expect(result.error).toContain(
"declared in override-aliased-blocked-dependency-plugin (package.json)",
);
}
});
it("blocks package installs when a nested vendored package manifest declares a blocked dependency", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "vendored-blocked-dependency-plugin",
version: "1.0.0",
openclaw: { extensions: ["index.js"] },
}),
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n");
fs.mkdirSync(path.join(pluginDir, "vendor", "axios"), { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "vendor", "axios", "package.json"),
JSON.stringify({
name: "axios",
version: "1.14.1",
dependencies: {
"plain-crypto-js": "^4.2.1",
},
}),
);
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain('blocked dependencies "plain-crypto-js" in dependencies');
expect(result.error).toContain("declared in axios (vendor/axios/package.json)");
}
});
it("blocks package installs when node_modules contains a blocked package directory without package.json", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "blocked-package-dir-plugin",
version: "1.0.0",
openclaw: { extensions: ["index.js"] },
}),
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n");
const blockedPackageDir = path.join(pluginDir, "vendor", "node_modules", "plain-crypto-js");
fs.mkdirSync(blockedPackageDir, { recursive: true });
fs.writeFileSync(path.join(blockedPackageDir, "index.js"), "module.exports = {};\n");
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain('blocked dependency directory "plain-crypto-js"');
expect(result.error).toContain("vendor/node_modules/plain-crypto-js");
}
});
it("blocks package installs when node_modules contains a blocked package file alias", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "blocked-package-file-plugin",
version: "1.0.0",
openclaw: { extensions: ["index.js"] },
}),
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n");
const nodeModulesDir = path.join(pluginDir, "vendor", "Node_Modules");
fs.mkdirSync(nodeModulesDir, { recursive: true });
fs.writeFileSync(path.join(nodeModulesDir, "Plain-Crypto-Js.Js"), "module.exports = {};\n");
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain('blocked dependency file alias "Plain-Crypto-Js"');
expect(result.error).toContain("vendor/Node_Modules/Plain-Crypto-Js.Js");
}
});
it("blocks package installs when node_modules contains a blocked extensionless package file alias", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "blocked-package-extensionless-file-plugin",
version: "1.0.0",
openclaw: { extensions: ["index.js"] },
}),
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n");
const nodeModulesDir = path.join(pluginDir, "vendor", "Node_Modules");
fs.mkdirSync(nodeModulesDir, { recursive: true });
fs.writeFileSync(path.join(nodeModulesDir, "Plain-Crypto-Js"), "module.exports = {};\n");
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain('blocked dependency file alias "Plain-Crypto-Js"');
expect(result.error).toContain("vendor/Node_Modules/Plain-Crypto-Js");
}
});
it.runIf(process.platform !== "win32")(
"blocks package installs when node_modules contains a blocked package symlink",
async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "blocked-package-symlink-plugin",
version: "1.0.0",
openclaw: { extensions: ["index.js"] },
}),
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n");
const actualDir = path.join(pluginDir, "vendor", "actual-package");
fs.mkdirSync(actualDir, { recursive: true });
fs.writeFileSync(path.join(actualDir, "index.js"), "module.exports = {};\n");
const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules");
fs.mkdirSync(nodeModulesDir, { recursive: true });
fs.symlinkSync("../actual-package", path.join(nodeModulesDir, "plain-crypto-js"), "dir");
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain('blocked dependency directory "plain-crypto-js"');
expect(result.error).toContain("vendor/node_modules/plain-crypto-js");
}
},
);
it.runIf(process.platform !== "win32")(
"blocks package installs when node_modules safe-name symlink targets a blocked package directory",
async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "blocked-package-symlink-target-plugin",
version: "1.0.0",
openclaw: { extensions: ["index.js"] },
}),
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n");
const targetDir = path.join(pluginDir, "vendor", "plain-crypto-js");
fs.mkdirSync(targetDir, { recursive: true });
fs.writeFileSync(path.join(targetDir, "index.js"), "module.exports = {};\n");
const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules");
fs.mkdirSync(nodeModulesDir, { recursive: true });
fs.symlinkSync("../plain-crypto-js", path.join(nodeModulesDir, "safe-name"), "dir");
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain('blocked dependency directory "plain-crypto-js"');
expect(result.error).toContain("vendor/plain-crypto-js");
}
},
);
it.runIf(process.platform !== "win32")(
"blocks package installs when node_modules safe-name symlink targets a blocked package file alias",
async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "blocked-package-file-symlink-target-plugin",
version: "1.0.0",
openclaw: { extensions: ["index.js"] },
}),
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n");
fs.mkdirSync(path.join(pluginDir, "vendor"), { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "vendor", "plain-crypto-js.js"),
"module.exports = {};\n",
);
const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules");
fs.mkdirSync(nodeModulesDir, { recursive: true });
fs.symlinkSync("../plain-crypto-js.js", path.join(nodeModulesDir, "safe-name"), "file");
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain('blocked dependency file alias "plain-crypto-js"');
expect(result.error).toContain("vendor/plain-crypto-js.js");
}
},
);
it.runIf(process.platform !== "win32")(
"blocks package installs when node_modules safe-name symlink targets a file under a blocked package directory",
async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "blocked-package-nested-file-symlink-target-plugin",
version: "1.0.0",
openclaw: { extensions: ["index.js"] },
}),
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n");
const blockedPackageDir = path.join(pluginDir, "vendor", "plain-crypto-js", "dist");
fs.mkdirSync(blockedPackageDir, { recursive: true });
fs.writeFileSync(path.join(blockedPackageDir, "index.js"), "module.exports = {};\n");
const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules");
fs.mkdirSync(nodeModulesDir, { recursive: true });
fs.symlinkSync(
"../plain-crypto-js/dist/index.js",
path.join(nodeModulesDir, "safe-name"),
"file",
);
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain('blocked dependency directory "plain-crypto-js"');
expect(result.error).toContain("vendor/plain-crypto-js/dist/index.js");
}
},
);
it.runIf(process.platform !== "win32")(
"does not block package installs when node_modules symlink targets an allowed scoped package path",
async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "allowed-scoped-symlink-target-plugin",
version: "1.0.0",
openclaw: { extensions: ["index.js"] },
}),
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n");
const scopedTargetDir = path.join(pluginDir, "vendor", "@scope", "plain-crypto-js");
fs.mkdirSync(scopedTargetDir, { recursive: true });
fs.writeFileSync(path.join(scopedTargetDir, "index.js"), "module.exports = {};\n");
const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules");
fs.mkdirSync(nodeModulesDir, { recursive: true });
fs.symlinkSync("../@scope/plain-crypto-js", path.join(nodeModulesDir, "safe-name"), "dir");
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(true);
},
);
it.runIf(process.platform !== "win32")(
"fails package installs when node_modules symlink target escapes the install root",
async () => {
const { pluginDir, extensionsDir, tmpDir } = setupPluginInstallDirs();
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "outside-root-symlink-plugin",
version: "1.0.0",
openclaw: { extensions: ["index.js"] },
}),
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n");
const externalDir = path.join(tmpDir, "external-package");
fs.mkdirSync(externalDir, { recursive: true });
fs.writeFileSync(path.join(externalDir, "index.js"), "module.exports = {};\n");
const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules");
fs.mkdirSync(nodeModulesDir, { recursive: true });
fs.symlinkSync(externalDir, path.join(nodeModulesDir, "safe-name"), "dir");
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED);
expect(result.error).toContain("symlink target outside install root");
}
},
);
it("does not block package installs for blocked-looking names outside node_modules", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "non-node-modules-path-plugin",
version: "1.0.0",
openclaw: { extensions: ["index.js"] },
}),
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n");
const innocuousDir = path.join(pluginDir, "assets", "plain-crypto-js");
fs.mkdirSync(innocuousDir, { recursive: true });
fs.writeFileSync(path.join(innocuousDir, "index.js"), "export {};\n");
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(true);
});
it("does not block package installs for blocked package file aliases outside node_modules", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "non-node-modules-file-alias-plugin",
version: "1.0.0",
openclaw: { extensions: ["index.js"] },
}),
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n");
fs.mkdirSync(path.join(pluginDir, "assets"), { recursive: true });
fs.writeFileSync(path.join(pluginDir, "assets", "plain-crypto-js.js"), "export {};\n");
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(true);
});
it("blocks package installs when a broad vendored tree contains a deeply nested blocked manifest", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "wide-vendored-tree-plugin",
version: "1.0.0",
openclaw: { extensions: ["index.js"] },
}),
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n");
const vendorRoot = path.join(pluginDir, "vendor");
for (let index = 0; index < 128; index += 1) {
fs.mkdirSync(path.join(vendorRoot, `pkg-${String(index).padStart(3, "0")}`), {
recursive: true,
});
}
const blockedManifestDir = path.join(
vendorRoot,
"pkg-127",
"node_modules",
"nested-safe",
"node_modules",
"plain-crypto-js",
);
fs.mkdirSync(blockedManifestDir, { recursive: true });
fs.writeFileSync(
path.join(blockedManifestDir, "package.json"),
JSON.stringify({
name: "plain-crypto-js",
version: "4.2.1",
}),
);
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain('"plain-crypto-js" as package name');
expect(result.error).toContain(
"declared in plain-crypto-js (vendor/pkg-127/node_modules/nested-safe/node_modules/plain-crypto-js/package.json)",
);
}
});
it("fails package installs when manifest traversal exceeds the directory cap", async () => {
vi.stubEnv("OPENCLAW_INSTALL_SCAN_MAX_DIRECTORIES", "4");
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "directory-cap-plugin",
version: "1.0.0",
openclaw: { extensions: ["index.js"] },
}),
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n");
const vendorRoot = path.join(pluginDir, "vendor");
for (let index = 0; index < 8; index += 1) {
fs.mkdirSync(path.join(vendorRoot, `pkg-${index}`), { recursive: true });
}
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED);
expect(result.error).toContain("manifest dependency scan exceeded max directories (4)");
}
});
it("fails package installs when manifest traversal exceeds the depth cap", async () => {
vi.stubEnv("OPENCLAW_INSTALL_SCAN_MAX_DEPTH", "2");
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "depth-cap-plugin",
version: "1.0.0",
openclaw: { extensions: ["index.js"] },
}),
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n");
const nestedDir = path.join(pluginDir, "vendor", "a", "b", "c");
fs.mkdirSync(nestedDir, { recursive: true });
fs.writeFileSync(
path.join(nestedDir, "package.json"),
JSON.stringify({
name: "plain-crypto-js",
version: "4.2.1",
}),
);
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED);
expect(result.error).toContain("manifest dependency scan exceeded max depth (2)");
}
});
it.runIf(process.platform !== "win32")(
"fails package installs when manifest traversal cannot read a directory",
async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "unreadable-dir-plugin",
version: "1.0.0",
openclaw: { extensions: ["index.js"] },
}),
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n");
const blockedDir = path.join(pluginDir, "vendor", "sealed");
fs.mkdirSync(blockedDir, { recursive: true });
fs.writeFileSync(
path.join(blockedDir, "package.json"),
JSON.stringify({ name: "plain-crypto-js" }),
);
fs.chmodSync(blockedDir, 0o000);
try {
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED);
expect(result.error).toContain("manifest dependency scan could not read");
expect(result.error).toContain("vendor/sealed");
}
} finally {
fs.chmodSync(blockedDir, 0o755);
}
},
);
it("reports all blocked dependencies from the same manifest", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "multiple-blocked-dependencies-plugin",
version: "1.0.0",
openclaw: { extensions: ["index.js"] },
dependencies: {
"plain-crypto-js": "^4.2.1",
},
peerDependencies: {
"plain-crypto-js": "^4.2.1",
},
}),
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n");
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain('"plain-crypto-js" in dependencies');
expect(result.error).toContain('"plain-crypto-js" in peerDependencies');
expect(result.error).toContain("multiple-blocked-dependencies-plugin (package.json)");
}
});
it("allows package installs with dangerous code patterns when forced unsafe install is set", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
@@ -796,6 +1427,47 @@ describe("installPluginFromArchive", () => {
).toBe(true);
});
it("keeps blocked dependency package checks active when forced unsafe install is set", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "forced-blocked-dependency-plugin",
version: "1.0.0",
openclaw: { extensions: ["index.js"] },
dependencies: {
"plain-crypto-js": "^4.2.1",
},
}),
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n");
const { result, warnings } = await installFromDirWithWarnings({
pluginDir,
extensionsDir,
dangerouslyForceUnsafeInstall: true,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain('blocked dependencies "plain-crypto-js" in dependencies');
}
expect(
warnings.some((warning) =>
warning.includes('blocked dependencies "plain-crypto-js" in dependencies'),
),
).toBe(true);
expect(
warnings.some((warning) =>
warning.includes(
"forced despite dangerous code patterns via --dangerously-force-unsafe-install",
),
),
).toBe(false);
});
it("blocks bundle installs when bundle contains dangerous code patterns", async () => {
const { pluginDir, extensionsDir } = setupBundleInstallFixture({
bundleFormat: "codex",
@@ -813,6 +1485,252 @@ describe("installPluginFromArchive", () => {
expect(warnings.some((w) => w.includes("dangerous code pattern"))).toBe(true);
});
it("blocks bundle installs when a vendored manifest declares a blocked dependency", async () => {
const { pluginDir, extensionsDir } = setupBundleInstallFixture({
bundleFormat: "codex",
name: "Blocked Dependency Bundle",
});
fs.mkdirSync(path.join(pluginDir, "vendor", "axios"), { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "vendor", "axios", "package.json"),
JSON.stringify({
name: "axios",
version: "1.14.1",
dependencies: {
"plain-crypto-js": "^4.2.1",
},
}),
);
const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain('Bundle "blocked-dependency-bundle" installation blocked');
expect(result.error).toContain('blocked dependencies "plain-crypto-js" in dependencies');
expect(result.error).toContain("declared in axios (vendor/axios/package.json)");
}
expect(
warnings.some((warning) =>
warning.includes('blocked dependencies "plain-crypto-js" in dependencies'),
),
).toBe(true);
});
it("blocks bundle installs when a vendored manifest uses a blocked package name", async () => {
const { pluginDir, extensionsDir } = setupBundleInstallFixture({
bundleFormat: "codex",
name: "Blocked Vendored Package Name Bundle",
});
fs.mkdirSync(path.join(pluginDir, "vendor", "plain-crypto-js"), { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "vendor", "plain-crypto-js", "package.json"),
JSON.stringify({
name: "plain-crypto-js",
version: "4.2.1",
}),
);
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain(
'Bundle "blocked-vendored-package-name-bundle" installation blocked',
);
expect(result.error).toContain('"plain-crypto-js" as package name');
expect(result.error).toContain(
"declared in plain-crypto-js (vendor/plain-crypto-js/package.json)",
);
}
});
it("blocks bundle installs when node_modules contains a blocked package directory without package.json", async () => {
const { pluginDir, extensionsDir } = setupBundleInstallFixture({
bundleFormat: "codex",
name: "Blocked Package Dir Bundle",
});
const blockedPackageDir = path.join(pluginDir, "vendor", "node_modules", "plain-crypto-js");
fs.mkdirSync(blockedPackageDir, { recursive: true });
fs.writeFileSync(path.join(blockedPackageDir, "index.js"), "module.exports = {};\n");
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain('Bundle "blocked-package-dir-bundle" installation blocked');
expect(result.error).toContain('blocked dependency directory "plain-crypto-js"');
expect(result.error).toContain("vendor/node_modules/plain-crypto-js");
}
});
it("blocks bundle installs when node_modules contains a blocked package file alias", async () => {
const { pluginDir, extensionsDir } = setupBundleInstallFixture({
bundleFormat: "codex",
name: "Blocked Package File Bundle",
});
const nodeModulesDir = path.join(pluginDir, "vendor", "Node_Modules");
fs.mkdirSync(nodeModulesDir, { recursive: true });
fs.writeFileSync(path.join(nodeModulesDir, "Plain-Crypto-Js.Js"), "module.exports = {};\n");
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain('Bundle "blocked-package-file-bundle" installation blocked');
expect(result.error).toContain('blocked dependency file alias "Plain-Crypto-Js"');
expect(result.error).toContain("vendor/Node_Modules/Plain-Crypto-Js.Js");
}
});
it("blocks bundle installs when node_modules contains a blocked extensionless package file alias", async () => {
const { pluginDir, extensionsDir } = setupBundleInstallFixture({
bundleFormat: "codex",
name: "Blocked Package Extensionless File Bundle",
});
const nodeModulesDir = path.join(pluginDir, "vendor", "Node_Modules");
fs.mkdirSync(nodeModulesDir, { recursive: true });
fs.writeFileSync(path.join(nodeModulesDir, "Plain-Crypto-Js"), "module.exports = {};\n");
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain(
'Bundle "blocked-package-extensionless-file-bundle" installation blocked',
);
expect(result.error).toContain('blocked dependency file alias "Plain-Crypto-Js"');
expect(result.error).toContain("vendor/Node_Modules/Plain-Crypto-Js");
}
});
it.runIf(process.platform !== "win32")(
"blocks bundle installs when node_modules contains a blocked package symlink",
async () => {
const { pluginDir, extensionsDir } = setupBundleInstallFixture({
bundleFormat: "codex",
name: "Blocked Package Symlink Bundle",
});
const actualDir = path.join(pluginDir, "vendor", "actual-package");
fs.mkdirSync(actualDir, { recursive: true });
fs.writeFileSync(path.join(actualDir, "index.js"), "module.exports = {};\n");
const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules");
fs.mkdirSync(nodeModulesDir, { recursive: true });
fs.symlinkSync("../actual-package", path.join(nodeModulesDir, "plain-crypto-js"), "dir");
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain(
'Bundle "blocked-package-symlink-bundle" installation blocked',
);
expect(result.error).toContain('blocked dependency directory "plain-crypto-js"');
expect(result.error).toContain("vendor/node_modules/plain-crypto-js");
}
},
);
it.runIf(process.platform !== "win32")(
"blocks bundle installs when node_modules safe-name symlink targets a blocked package directory",
async () => {
const { pluginDir, extensionsDir } = setupBundleInstallFixture({
bundleFormat: "codex",
name: "Blocked Package Symlink Target Bundle",
});
const targetDir = path.join(pluginDir, "vendor", "plain-crypto-js");
fs.mkdirSync(targetDir, { recursive: true });
fs.writeFileSync(path.join(targetDir, "index.js"), "module.exports = {};\n");
const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules");
fs.mkdirSync(nodeModulesDir, { recursive: true });
fs.symlinkSync("../plain-crypto-js", path.join(nodeModulesDir, "safe-name"), "dir");
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain(
'Bundle "blocked-package-symlink-target-bundle" installation blocked',
);
expect(result.error).toContain('blocked dependency directory "plain-crypto-js"');
expect(result.error).toContain("vendor/plain-crypto-js");
}
},
);
it.runIf(process.platform !== "win32")(
"blocks bundle installs when node_modules safe-name symlink targets a blocked package file alias",
async () => {
const { pluginDir, extensionsDir } = setupBundleInstallFixture({
bundleFormat: "codex",
name: "Blocked Package File Symlink Target Bundle",
});
fs.mkdirSync(path.join(pluginDir, "vendor"), { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "vendor", "plain-crypto-js.js"),
"module.exports = {};\n",
);
const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules");
fs.mkdirSync(nodeModulesDir, { recursive: true });
fs.symlinkSync("../plain-crypto-js.js", path.join(nodeModulesDir, "safe-name"), "file");
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain(
'Bundle "blocked-package-file-symlink-target-bundle" installation blocked',
);
expect(result.error).toContain('blocked dependency file alias "plain-crypto-js"');
expect(result.error).toContain("vendor/plain-crypto-js.js");
}
},
);
it.runIf(process.platform !== "win32")(
"blocks bundle installs when node_modules safe-name symlink targets a file under a blocked package directory",
async () => {
const { pluginDir, extensionsDir } = setupBundleInstallFixture({
bundleFormat: "codex",
name: "Blocked Package Nested File Symlink Target Bundle",
});
const blockedPackageDir = path.join(pluginDir, "vendor", "plain-crypto-js", "dist");
fs.mkdirSync(blockedPackageDir, { recursive: true });
fs.writeFileSync(path.join(blockedPackageDir, "index.js"), "module.exports = {};\n");
const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules");
fs.mkdirSync(nodeModulesDir, { recursive: true });
fs.symlinkSync(
"../plain-crypto-js/dist/index.js",
path.join(nodeModulesDir, "safe-name"),
"file",
);
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain(
'Bundle "blocked-package-nested-file-symlink-target-bundle" installation blocked',
);
expect(result.error).toContain('blocked dependency directory "plain-crypto-js"');
expect(result.error).toContain("vendor/plain-crypto-js/dist/index.js");
}
},
);
it("surfaces plugin scanner findings from before_install", async () => {
const handler = vi.fn().mockReturnValue({
findings: [
@@ -1163,6 +2081,50 @@ describe("installPluginFromDir", () => {
expect(manifest.devDependencies?.vitest).toBe("^3.0.0");
});
it("blocks install when resolved dependencies introduce a denied package", async () => {
const { pluginDir, extensionsDir } = setupInstallPluginFromDirFixture();
const run = vi.mocked(runCommandWithTimeout);
run.mockImplementation(async (_command, opts) => {
const cwd = typeof opts === "number" ? undefined : opts?.cwd;
if (!cwd) {
throw new Error("expected cwd for npm install");
}
const blockedPkgDir = path.join(cwd, "node_modules", "plain-crypto-js");
fs.mkdirSync(blockedPkgDir, { recursive: true });
fs.writeFileSync(
path.join(blockedPkgDir, "package.json"),
JSON.stringify({
name: "plain-crypto-js",
version: "4.2.1",
}),
"utf-8",
);
return {
code: 0,
stdout: "",
stderr: "",
signal: null,
killed: false,
termination: "exit" as const,
};
});
const result = await installPluginFromDir({
dirPath: pluginDir,
extensionsDir,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain('"plain-crypto-js" as package name');
expect(result.error).toContain(
"declared in plain-crypto-js (node_modules/plain-crypto-js/package.json)",
);
}
});
it.each([
{
name: "rejects plugins whose minHostVersion is newer than the current host",

View File

@@ -359,6 +359,9 @@ async function installPluginDirectoryIntoExtensions(params: {
hasDeps: boolean;
depsLogMessage: string;
afterCopy?: (installedDir: string) => Promise<void>;
afterInstall?: (
installedDir: string,
) => Promise<Extract<InstallPluginResult, { ok: false }> | null>;
nameEncoder?: (pluginId: string) => string;
}): Promise<InstallPluginResult> {
const runtime = await loadPluginInstallRuntime();
@@ -404,9 +407,24 @@ async function installPluginDirectoryIntoExtensions(params: {
hasDeps: params.hasDeps,
depsLogMessage: params.depsLogMessage,
afterCopy: params.afterCopy,
afterInstall: async (installedDir) => {
const postInstallResult = await params.afterInstall?.(installedDir);
if (!postInstallResult) {
return { ok: true as const };
}
return {
ok: false as const,
error: postInstallResult.error,
...(postInstallResult.code ? { code: postInstallResult.code } : {}),
};
},
});
if (!installRes.ok) {
return installRes;
return {
ok: false,
error: installRes.error,
...(installRes.code ? { code: installRes.code as PluginInstallErrorCode } : {}),
};
}
return buildDirectoryInstallResult({
@@ -752,6 +770,16 @@ async function installPluginFromPackageDir(
}
}
},
afterInstall: async (installedDir) =>
await runInstallSourceScan({
subject: `Plugin "${pluginId}"`,
scan: async () =>
await runtime.scanInstalledPackageDependencyTree({
logger,
packageDir: installedDir,
pluginId,
}),
}),
});
}