CLI/plugins: stop forced-unsafe installs from falling back to hook packs (#58909)

Merged via squash.

Prepared head SHA: 7cf146efb6
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
Reviewed-by: @hxy91819
This commit is contained in:
Mason Huang
2026-04-15 13:23:17 +08:00
committed by GitHub
parent 7fc5a18d89
commit 3d2f51c0a4
4 changed files with 320 additions and 2 deletions

View File

@@ -43,6 +43,7 @@ Docs: https://docs.openclaw.ai
- Telegram/native commands: keep Telegram command-sync cache process-local so gateway restarts re-register the menu instead of trusting stale on-disk sync state after Telegram cleared commands out-of-band. (#66730) Thanks @nightq.
- Audio/self-hosted STT: restore `models.providers.*.request.allowPrivateNetwork` for audio transcription so private or LAN speech-to-text endpoints stop tripping SSRF blocks after the v2026.4.14 regression. (#66692) Thanks @jhsmith409.
- QQBot/cron: guard against undefined `event.content` in `parseFaceTags` and `filterInternalMarkers` so cron-triggered agent turns with no content payload no longer crash with `TypeError: Cannot read properties of undefined (reading 'startsWith')`. (#66302) Thanks @xinmotlanthua.
- CLI/plugins: stop `--dangerously-force-unsafe-install` plugin installs from falling back to hook-pack installs after security scan failures, while still preserving non-security fallback behavior for real hook packs. (#58909) Thanks @hxy91819.
## 2026.4.14
@@ -1041,6 +1042,8 @@ Docs: https://docs.openclaw.ai
- ACPX/runtime: repair `queue owner unavailable` session recovery by replacing dead named sessions and resuming the backend session when ACPX exposes a stable session id, so the first ACP prompt no longer inherits a dead handle. (#58669) Thanks @neeravmakwana
- ACPX/runtime: retry dead-session queue-owner repair without `--resume-session` when the reported ACPX session id is stale, so recovery still creates a fresh named session instead of failing session init. Thanks @obviyus.
- Tools/web_search (Kimi): replay native Moonshot `$web_search` arguments verbatim, disable thinking for `kimi-k2.5`, and add Moonshot region/model setup prompts so bundled Kimi web search works again. (#59356) Thanks @Innocent-children.
- Auth/OpenAI Codex: persist plugin-refreshed OAuth credentials to `auth-profiles.json` before returning them, so rotated Codex refresh tokens survive restart and stop falling into `refresh_token_reused` loops. (#53082)
- Discord/gateway: hand reconnect ownership back to Carbon, keep runtime status aligned with close/reconnect state, and force-stop sockets that open without reaching READY so Discord monitors recover promptly instead of waiting on stale health timeouts. (#59019) Thanks @obviyus
## 2026.3.31

View File

@@ -252,6 +252,8 @@ vi.mock("./prompt.js", () => ({
vi.mock("../plugins/install.js", () => ({
PLUGIN_INSTALL_ERROR_CODE: {
NPM_PACKAGE_NOT_FOUND: "npm_package_not_found",
SECURITY_SCAN_BLOCKED: "security_scan_blocked",
SECURITY_SCAN_FAILED: "security_scan_failed",
},
installPluginFromNpmSpec: ((
...args: Parameters<(typeof import("../plugins/install.js"))["installPluginFromNpmSpec"]>

View File

@@ -9,8 +9,8 @@ import {
buildPluginDiagnosticsReport,
clearPluginManifestRegistryCache,
enablePluginInConfig,
installHooksFromPath,
installHooksFromNpmSpec,
installHooksFromPath,
installPluginFromClawHub,
installPluginFromMarketplace,
installPluginFromNpmSpec,
@@ -678,6 +678,289 @@ describe("plugins cli install", () => {
);
});
it("passes the install logger to the --link dry-run probe", async () => {
const localPluginDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-link-plugin-"));
const cfg = {
plugins: {
entries: {},
load: {
paths: [],
},
},
} as OpenClawConfig;
const enabledCfg = createEnabledPluginConfig("demo");
loadConfig.mockReturnValue(cfg);
installPluginFromPath.mockImplementation(async (...args: unknown[]) => {
const [params] = args as [
{
logger?: { warn?: (message: string) => void };
path: string;
dryRun?: boolean;
dangerouslyForceUnsafeInstall?: boolean;
},
];
params.logger?.warn?.(
'WARNING: Plugin "demo" forced despite dangerous code patterns via --dangerously-force-unsafe-install: index.js:1',
);
return {
ok: true,
pluginId: "demo",
targetDir: localPluginDir,
version: "1.0.0",
extensions: [],
};
});
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
recordPluginInstall.mockReturnValue(enabledCfg);
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
try {
await runPluginsCommand([
"plugins",
"install",
localPluginDir,
"--link",
"--dangerously-force-unsafe-install",
]);
} finally {
fs.rmSync(localPluginDir, { recursive: true, force: true });
}
expect(installPluginFromPath).toHaveBeenCalledWith(
expect.objectContaining({
path: localPluginDir,
dryRun: true,
dangerouslyForceUnsafeInstall: true,
logger: expect.objectContaining({
info: expect.any(Function),
warn: expect.any(Function),
}),
}),
);
expect(
runtimeLogs.some((line) =>
line.includes(
"forced despite dangerous code patterns via --dangerously-force-unsafe-install",
),
),
).toBe(true);
});
it("does not fall back to hook pack for local path when dangerous force unsafe install is set", async () => {
const localPluginDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-local-plugin-"));
const cfg = {} as OpenClawConfig;
const pluginInstallError = "plugin blocked by security scan";
loadConfig.mockReturnValue(cfg);
installPluginFromPath.mockResolvedValue({
ok: false,
error: pluginInstallError,
code: "security_scan_blocked",
});
try {
await expect(
runPluginsCommand([
"plugins",
"install",
localPluginDir,
"--dangerously-force-unsafe-install",
]),
).rejects.toThrow("__exit__:1");
} finally {
fs.rmSync(localPluginDir, { recursive: true, force: true });
}
expect(installHooksFromPath).not.toHaveBeenCalled();
expect(runtimeErrors.at(-1)).toContain(pluginInstallError);
});
it("does not fall back to hook pack for local path when security scan fails under dangerous force unsafe install", async () => {
const localPluginDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-local-plugin-"));
const cfg = {} as OpenClawConfig;
const pluginInstallError = "plugin security scan failed";
loadConfig.mockReturnValue(cfg);
installPluginFromPath.mockResolvedValue({
ok: false,
error: pluginInstallError,
code: "security_scan_failed",
});
try {
await expect(
runPluginsCommand([
"plugins",
"install",
localPluginDir,
"--dangerously-force-unsafe-install",
]),
).rejects.toThrow("__exit__:1");
} finally {
fs.rmSync(localPluginDir, { recursive: true, force: true });
}
expect(installHooksFromPath).not.toHaveBeenCalled();
expect(runtimeErrors.at(-1)).toContain(pluginInstallError);
});
it("does not fall back to hook pack for npm installs when dangerous force unsafe install is set", async () => {
const cfg = {} as OpenClawConfig;
const pluginInstallError = "plugin blocked by security scan";
loadConfig.mockReturnValue(cfg);
installPluginFromClawHub.mockResolvedValue({
ok: false,
error: "ClawHub /api/v1/packages/demo failed (404): Package not found",
code: "package_not_found",
});
installPluginFromNpmSpec.mockResolvedValue({
ok: false,
error: pluginInstallError,
code: "security_scan_blocked",
});
await expect(
runPluginsCommand(["plugins", "install", "demo", "--dangerously-force-unsafe-install"]),
).rejects.toThrow("__exit__:1");
expect(installHooksFromNpmSpec).not.toHaveBeenCalled();
expect(runtimeErrors.at(-1)).toContain(pluginInstallError);
});
it("does not fall back to hook pack for npm installs when security scan fails under dangerous force unsafe install", async () => {
const cfg = {} as OpenClawConfig;
const pluginInstallError = "plugin security scan failed";
loadConfig.mockReturnValue(cfg);
installPluginFromClawHub.mockResolvedValue({
ok: false,
error: "ClawHub /api/v1/packages/demo failed (404): Package not found",
code: "package_not_found",
});
installPluginFromNpmSpec.mockResolvedValue({
ok: false,
error: pluginInstallError,
code: "security_scan_failed",
});
await expect(
runPluginsCommand(["plugins", "install", "demo", "--dangerously-force-unsafe-install"]),
).rejects.toThrow("__exit__:1");
expect(installHooksFromNpmSpec).not.toHaveBeenCalled();
expect(runtimeErrors.at(-1)).toContain(pluginInstallError);
});
it("still falls back to local hook pack when dangerous force unsafe install is set for non-security errors", async () => {
const localHookDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-local-hook-pack-"));
const cfg = {} as OpenClawConfig;
const installedCfg = {
hooks: {
internal: {
installs: {
"demo-hooks": {
source: "path",
sourcePath: localHookDir,
},
},
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(cfg);
installPluginFromPath.mockResolvedValue({
ok: false,
error: "package.json missing openclaw.plugin.json",
code: "missing_openclaw_extensions",
});
installHooksFromPath.mockResolvedValue({
ok: true,
hookPackId: "demo-hooks",
hooks: ["command-audit"],
targetDir: "/tmp/hooks/demo-hooks",
version: "1.2.3",
});
recordHookInstall.mockReturnValue(installedCfg);
try {
await runPluginsCommand([
"plugins",
"install",
localHookDir,
"--dangerously-force-unsafe-install",
]);
} finally {
fs.rmSync(localHookDir, { recursive: true, force: true });
}
expect(installHooksFromPath).toHaveBeenCalledWith(
expect.objectContaining({
path: localHookDir,
}),
);
expect(runtimeLogs.some((line) => line.includes("Installed hook pack: demo-hooks"))).toBe(true);
});
it("still falls back to npm hook pack when dangerous force unsafe install is set for non-security errors", async () => {
const cfg = {} as OpenClawConfig;
const installedCfg = {
hooks: {
internal: {
installs: {
"demo-hooks": {
source: "npm",
spec: "@acme/demo-hooks@1.2.3",
},
},
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(cfg);
installPluginFromClawHub.mockResolvedValue({
ok: false,
error: "ClawHub /api/v1/packages/@acme/demo-hooks failed (404): Package not found",
code: "package_not_found",
});
installPluginFromNpmSpec.mockResolvedValue({
ok: false,
error: "package.json missing openclaw.plugin.json",
code: "missing_openclaw_extensions",
});
installHooksFromNpmSpec.mockResolvedValue({
ok: true,
hookPackId: "demo-hooks",
hooks: ["command-audit"],
targetDir: "/tmp/hooks/demo-hooks",
version: "1.2.3",
npmResolution: {
name: "@acme/demo-hooks",
spec: "@acme/demo-hooks@1.2.3",
integrity: "sha256-demo",
},
});
recordHookInstall.mockReturnValue(installedCfg);
await runPluginsCommand([
"plugins",
"install",
"@acme/demo-hooks",
"--dangerously-force-unsafe-install",
]);
expect(installHooksFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@acme/demo-hooks",
}),
);
expect(runtimeLogs.some((line) => line.includes("Installed hook pack: demo-hooks"))).toBe(true);
});
it("does not fall back to npm when ClawHub rejects a real package", async () => {
installPluginFromClawHub.mockResolvedValue({
ok: false,

View File

@@ -9,7 +9,11 @@ import { extractErrorCode, formatErrorMessage } from "../infra/errors.js";
import { type BundledPluginSource, findBundledPluginSource } from "../plugins/bundled-sources.js";
import { formatClawHubSpecifier, installPluginFromClawHub } from "../plugins/clawhub.js";
import type { InstallSafetyOverrides } from "../plugins/install-security-scan.js";
import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js";
import {
PLUGIN_INSTALL_ERROR_CODE,
installPluginFromNpmSpec,
installPluginFromPath,
} from "../plugins/install.js";
import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js";
import {
installPluginFromMarketplace,
@@ -191,6 +195,17 @@ async function tryInstallHookPackFromNpmSpec(params: {
return { ok: true };
}
function shouldExitOnForcedUnsafeInstall(params: {
forceUnsafeInstall: boolean;
code?: string;
}): boolean {
return (
params.forceUnsafeInstall &&
(params.code === PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED ||
params.code === PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED)
);
}
function isAllowedBundledRecoveryIssue(
issue: { path?: string; message?: string },
request: PluginInstallRequestContext,
@@ -345,6 +360,7 @@ export async function runPluginInstallCommand(params: {
}
const resolved = request.resolvedPath ?? request.normalizedSpec;
const forceUnsafeInstall = opts.dangerouslyForceUnsafeInstall === true;
if (fs.existsSync(resolved)) {
if (opts.link) {
@@ -352,10 +368,16 @@ export async function runPluginInstallCommand(params: {
const merged = Array.from(new Set([...existing, resolved]));
const probe = await installPluginFromPath({
...safetyOverrides,
mode: installMode,
path: resolved,
dryRun: true,
logger: createPluginInstallLogger(),
});
if (!probe.ok) {
if (shouldExitOnForcedUnsafeInstall({ forceUnsafeInstall, code: probe.code })) {
defaultRuntime.error(probe.error);
return defaultRuntime.exit(1);
}
const hookFallback = await tryInstallHookPackFromLocalPath({
config: cfg,
installMode,
@@ -402,6 +424,10 @@ export async function runPluginInstallCommand(params: {
logger: createPluginInstallLogger(),
});
if (!result.ok) {
if (shouldExitOnForcedUnsafeInstall({ forceUnsafeInstall, code: result.code })) {
defaultRuntime.error(result.error);
return defaultRuntime.exit(1);
}
const hookFallback = await tryInstallHookPackFromLocalPath({
config: cfg,
installMode,
@@ -547,6 +573,10 @@ export async function runPluginInstallCommand(params: {
logger: createPluginInstallLogger(),
});
if (!result.ok) {
if (shouldExitOnForcedUnsafeInstall({ forceUnsafeInstall, code: result.code })) {
defaultRuntime.error(result.error);
return defaultRuntime.exit(1);
}
const bundledFallbackPlan = resolveBundledInstallPlanForNpmFailure({
rawSpec: raw,
code: result.code,