From 87eb450047a98e281da55d2555df81dd87ee7f55 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Wed, 13 May 2026 16:31:52 +1000 Subject: [PATCH] Check ClawHub trust before plugin installs (#81307) Merged via squash. Prepared head SHA: 273fd7c20e4fe9fb704c6013139b5b59061439a2 Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> Reviewed-by: @jesse-merhi --- CHANGELOG.md | 1 + docs/cli/plugins.md | 11 ++ docs/cli/update.md | 5 + docs/tools/plugin.md | 10 ++ src/cli/clawhub-risk-acknowledgement.ts | 28 ++++ src/cli/plugins-cli.install.test.ts | 27 +++ src/cli/plugins-cli.ts | 37 +++- src/cli/plugins-cli.update.test.ts | 29 ++++ src/cli/plugins-install-command.ts | 6 + src/cli/plugins-update-command.ts | 12 +- src/cli/update-cli.test.ts | 47 +++++- src/cli/update-cli.ts | 31 +++- src/cli/update-cli/shared.ts | 1 + src/cli/update-cli/update-command.ts | 33 ++++ .../missing-configured-plugin-install.ts | 11 +- .../onboarding-plugin-install.test.ts | 2 + src/commands/onboarding-plugin-install.ts | 8 + src/infra/clawhub.test.ts | 69 ++++++-- src/infra/clawhub.ts | 158 ++++++++++++++++-- src/plugins/clawhub.test.ts | 132 +++++++++++++++ src/plugins/clawhub.ts | 141 ++++++++++++++++ src/plugins/update.test.ts | 53 ++++++ src/plugins/update.ts | 23 ++- 23 files changed, 832 insertions(+), 43 deletions(-) create mode 100644 src/cli/clawhub-risk-acknowledgement.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 380024a0532..0a4791777ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -134,6 +134,7 @@ Docs: https://docs.openclaw.ai - Codex app-server: mirror native Codex subagent spawn lifecycle events into Task Registry so app-server child agents appear in task/status surfaces without relying on transcript text. (#79512) Thanks @mbelinky. - Gateway: expose optional `isHeartbeat` metadata on agent event payloads so clients can distinguish scheduled heartbeat runs from ordinary chat runs. (#80610) Thanks @medns. - Agents: add `agents.defaults.runRetries` and `agents.list[].runRetries` config for embedded Pi runner retry loop limits. (#80661) Thanks @medns. +- Plugins/ClawHub: check exact-release trust before ClawHub plugin install/update downloads, require confirmation for risky releases, and add `--acknowledge-clawhub-risk` for reviewed automation. (#81307) Thanks @jesse-merhi. ### Fixes diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 9106d15c7f8..063fb5363c2 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -83,6 +83,7 @@ openclaw plugins install git:github.com// # git repo openclaw plugins install git:github.com//@ openclaw plugins install --force # overwrite existing install openclaw plugins install --pin # pin version +openclaw plugins install --acknowledge-clawhub-risk openclaw plugins install --dangerously-force-unsafe-install openclaw plugins install # local path openclaw plugins install @ # marketplace @@ -135,6 +136,12 @@ is available, then fall back to `latest`. If a plugin you published on ClawHub is blocked by a registry scan, use the publisher steps in [ClawHub](/clawhub/security). + + + ClawHub installs check the selected release trust record before downloading the package. If ClawHub reports a risky scan status, risky moderation state, download block, or registry reason, OpenClaw shows the trust details and asks for confirmation before continuing. + + Use `--acknowledge-clawhub-risk` only after reviewing the ClawHub warning and deciding to continue without an interactive prompt. Pending or stale clean trust records warn but do not require acknowledgement. + `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. @@ -324,6 +331,7 @@ openclaw plugins update openclaw plugins update --all openclaw plugins update --dry-run openclaw plugins update @openclaw/voice-call +openclaw plugins update openclaw-codex-app-server --acknowledge-clawhub-risk openclaw plugins update openclaw-codex-app-server --dangerously-force-unsafe-install ``` @@ -351,6 +359,9 @@ Updates apply to tracked plugin installs in the managed plugin index and tracked `--dangerously-force-unsafe-install` is also available on `plugins update` as a break-glass override for built-in dangerous-code scan false positives during plugin updates. It still does not bypass plugin `before_install` policy blocks or scan-failure blocking, and it only applies to plugin updates, not hook-pack updates. + + ClawHub-backed plugin updates run the same exact-release trust check as installs before downloading the replacement package. Use `--acknowledge-clawhub-risk` for reviewed automation that should continue when the selected ClawHub release has a risky trust warning. + ### Inspect diff --git a/docs/cli/update.md b/docs/cli/update.md index 34f39604019..5bf35807a58 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -27,6 +27,7 @@ openclaw update --tag main openclaw update --dry-run openclaw update --no-restart openclaw update --yes +openclaw update --acknowledge-clawhub-risk openclaw update --json openclaw --update ``` @@ -44,6 +45,10 @@ openclaw --update when npm plugin artifact drift is detected during post-update plugin sync. - `--timeout `: per-step timeout (default is 1800s). - `--yes`: skip confirmation prompts (for example downgrade confirmation). +- `--acknowledge-clawhub-risk`: continue post-update ClawHub plugin sync when + the selected plugin release has a ClawHub trust warning. Without this flag, + interactive runs ask before downloading risky ClawHub plugin releases and + non-interactive runs fail closed. `openclaw update` does not have a `--verbose` flag. Use `--dry-run` to preview the planned channel/tag/install/restart actions, `--json` for machine-readable diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index b7bc374995a..7fdb758006b 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -543,8 +543,10 @@ openclaw plugins install -l # link (no copy) for dev openclaw plugins install --marketplace openclaw plugins install --marketplace https://github.com// openclaw plugins install --pin # record exact resolved npm spec +openclaw plugins install --acknowledge-clawhub-risk openclaw plugins install --dangerously-force-unsafe-install openclaw plugins update # update one plugin +openclaw plugins update --acknowledge-clawhub-risk openclaw plugins update --dangerously-force-unsafe-install openclaw plugins update --all # update all openclaw plugins uninstall # remove config and plugin index records @@ -603,6 +605,14 @@ beta release exists. Exact versions and explicit tags stay pinned. `--pin` is npm-only. It is not supported with `--marketplace`, because marketplace installs persist marketplace source metadata instead of an npm spec. +ClawHub-backed plugin installs and updates check the exact target release's +ClawHub trust record before download. If ClawHub reports risk for that release, +OpenClaw prints the package, version, scan status, moderation state, and reason +codes, then requires confirmation before continuing. Non-interactive automation +must pass `--acknowledge-clawhub-risk` after reviewing that warning. The flag +acknowledges ClawHub registry risk only; it does not bypass plugin +`before_install` hook policy blocks or the built-in dangerous-code scanner. + `--dangerously-force-unsafe-install` is a break-glass override for false positives from the built-in dangerous-code scanner. It allows plugin installs and plugin updates to continue past built-in `critical` findings, but it still diff --git a/src/cli/clawhub-risk-acknowledgement.ts b/src/cli/clawhub-risk-acknowledgement.ts new file mode 100644 index 00000000000..378cf95c56f --- /dev/null +++ b/src/cli/clawhub-risk-acknowledgement.ts @@ -0,0 +1,28 @@ +import type { ClawHubRiskAcknowledgementRequest } from "../plugins/clawhub.js"; +import { promptYesNo } from "./prompt.js"; + +export type ClawHubRiskAcknowledgementCliOptions = { + acknowledgeClawHubRisk?: boolean; +}; + +function canPromptForClawHubRisk(): boolean { + return process.stdin.isTTY && process.stdout.isTTY; +} + +export function resolveClawHubRiskAcknowledgementCliOptions(params: { + acknowledgeClawHubRisk?: boolean; + action: "installing" | "updating"; +}): ClawHubRiskAcknowledgementCliOptions & { + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => Promise; +} { + return { + acknowledgeClawHubRisk: params.acknowledgeClawHubRisk, + onClawHubRisk: + params.acknowledgeClawHubRisk || !canPromptForClawHubRisk() + ? undefined + : async (request) => + await promptYesNo( + `Continue ${params.action} ClawHub package "${request.packageName}@${request.version}" despite this warning?`, + ), + }; +} diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index 1f3c166b795..de0589daaef 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -620,6 +620,33 @@ describe("plugins cli install", () => { expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); }); + it("passes ClawHub risk acknowledgement to explicit ClawHub installs", async () => { + loadConfig.mockReturnValue(createEmptyPluginConfig()); + parseClawHubPluginSpec.mockReturnValue({ name: "demo" }); + installPluginFromClawHub.mockResolvedValue( + createClawHubInstallResult({ + pluginId: "demo", + packageName: "demo", + version: "1.2.3", + channel: "official", + }), + ); + enablePluginInConfig.mockReturnValue({ config: createEnabledPluginConfig("demo") }); + applyExclusiveSlotSelection.mockReturnValue({ + config: createEnabledPluginConfig("demo"), + warnings: [], + }); + + await runPluginsCommand(["plugins", "install", "clawhub:demo", "--acknowledge-clawhub-risk"]); + + expect(installPluginFromClawHub).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "clawhub:demo", + acknowledgeClawHubRisk: true, + }), + ); + }); + it("passes the active profile extensions dir to ClawHub installs", async () => { const extensionsDir = useProfileExtensionsDir(); const cfg = createEmptyPluginConfig(); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index d635e856caf..b5d31b004c8 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -18,10 +18,19 @@ import { applyParentDefaultHelpAction } from "./program/parent-default-help.js"; export type PluginUpdateOptions = { all?: boolean; + acknowledgeClawhubRisk?: boolean; dryRun?: boolean; dangerouslyForceUnsafeInstall?: boolean; }; +type CommanderClawHubRiskOptions = Record & { + acknowledgeClawhubRisk?: boolean; +}; + +function normalizeCommanderClawHubRiskOption(opts: CommanderClawHubRiskOptions): boolean { + return opts.acknowledgeClawhubRisk === true || opts.acknowledgeClawHubRisk === true; +} + export type PluginMarketplaceListOptions = { json?: boolean; }; @@ -253,6 +262,11 @@ export function registerPluginsCli(program: Command) { "Bypass built-in dangerous-code install blocking (plugin hooks may still block)", false, ) + .option( + "--acknowledge-clawhub-risk", + "Acknowledge ClawHub release trust warnings without prompting", + false, + ) .option( "--marketplace ", "Install a Claude marketplace plugin from a local repo/path or git/GitHub source", @@ -260,7 +274,7 @@ export function registerPluginsCli(program: Command) { .action( async ( raw: string, - opts: { + opts: CommanderClawHubRiskOptions & { dangerouslyForceUnsafeInstall?: boolean; force?: boolean; link?: boolean; @@ -272,7 +286,13 @@ export function registerPluginsCli(program: Command) { "install command", async () => { const { runPluginInstallCommand } = await import("./plugins-install-command.js"); - await runPluginInstallCommand({ raw, opts }); + await runPluginInstallCommand({ + raw, + opts: { + ...opts, + acknowledgeClawHubRisk: normalizeCommanderClawHubRiskOption(opts), + }, + }); }, { command: "install" }, ); @@ -290,9 +310,20 @@ export function registerPluginsCli(program: Command) { "Bypass built-in dangerous-code update blocking for plugins (plugin hooks may still block)", false, ) + .option( + "--acknowledge-clawhub-risk", + "Acknowledge ClawHub release trust warnings without prompting", + false, + ) .action(async (id: string | undefined, opts: PluginUpdateOptions) => { const { runPluginUpdateCommand } = await import("./plugins-update-command.js"); - await runPluginUpdateCommand({ id, opts }); + await runPluginUpdateCommand({ + id, + opts: { + ...opts, + acknowledgeClawHubRisk: normalizeCommanderClawHubRiskOption(opts), + }, + }); }); plugins diff --git a/src/cli/plugins-cli.update.test.ts b/src/cli/plugins-cli.update.test.ts index f358df22101..8ab315c15d3 100644 --- a/src/cli/plugins-cli.update.test.ts +++ b/src/cli/plugins-cli.update.test.ts @@ -210,6 +210,35 @@ describe("plugins cli update", () => { expect(updateParams.dangerouslyForceUnsafeInstall).toBe(true); }); + it("passes ClawHub risk acknowledgement to plugin updates", async () => { + const config = createTrackedPluginConfig({ + pluginId: "openclaw-codex-app-server", + spec: "openclaw-codex-app-server@beta", + }); + loadConfig.mockReturnValue(config); + setInstalledPluginIndexInstallRecords(config.plugins?.installs ?? {}); + updateNpmInstalledPlugins.mockResolvedValue({ + config, + changed: false, + outcomes: [], + }); + + await runPluginsCommand([ + "plugins", + "update", + "openclaw-codex-app-server", + "--acknowledge-clawhub-risk", + ]); + + expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config, + pluginIds: ["openclaw-codex-app-server"], + acknowledgeClawHubRisk: true, + }), + ); + }); + it("writes updated config when updater reports changes", async () => { const cfg = { plugins: { diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index faa4f6afd1e..9fad6d3001b 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -33,6 +33,7 @@ import { validateJsonSchemaValue } from "../plugins/schema-validator.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { theme } from "../terminal/theme.js"; import { shortenHomePath } from "../utils.js"; +import { resolveClawHubRiskAcknowledgementCliOptions } from "./clawhub-risk-acknowledgement.js"; import { formatCliCommand } from "./command-format.js"; import { looksLikeLocalInstallSpec } from "./install-spec.js"; import { resolvePinnedNpmInstallRecordForCli } from "./npm-resolution.js"; @@ -558,6 +559,7 @@ export async function loadConfigForInstall( export async function runPluginInstallCommand(params: { raw: string; opts: InstallSafetyOverrides & { + acknowledgeClawHubRisk?: boolean; force?: boolean; link?: boolean; pin?: boolean; @@ -941,6 +943,10 @@ export async function runPluginInstallCommand(params: { if (clawhubSpec) { const result = await installPluginFromClawHub({ ...safetyOverrides, + ...resolveClawHubRiskAcknowledgementCliOptions({ + acknowledgeClawHubRisk: opts.acknowledgeClawHubRisk, + action: "installing", + }), mode: installMode, spec: raw, extensionsDir, diff --git a/src/cli/plugins-update-command.ts b/src/cli/plugins-update-command.ts index 4e3baae4ed1..0b257d4183b 100644 --- a/src/cli/plugins-update-command.ts +++ b/src/cli/plugins-update-command.ts @@ -13,6 +13,7 @@ import { import { updateNpmInstalledPlugins } from "../plugins/update.js"; import { defaultRuntime } from "../runtime.js"; import { theme } from "../terminal/theme.js"; +import { resolveClawHubRiskAcknowledgementCliOptions } from "./clawhub-risk-acknowledgement.js"; import { commitPluginInstallRecordsWithConfig } from "./plugins-install-record-commit.js"; import { refreshPluginRegistryAfterConfigMutation } from "./plugins-registry-refresh.js"; import { logPluginUpdateOutcomes } from "./plugins-update-outcomes.js"; @@ -24,7 +25,12 @@ import { promptYesNo } from "./prompt.js"; export async function runPluginUpdateCommand(params: { id?: string; - opts: { all?: boolean; dryRun?: boolean; dangerouslyForceUnsafeInstall?: boolean }; + opts: { + all?: boolean; + acknowledgeClawHubRisk?: boolean; + dryRun?: boolean; + dangerouslyForceUnsafeInstall?: boolean; + }; }) { assertConfigWriteAllowedInCurrentMode(); @@ -62,6 +68,10 @@ export async function runPluginUpdateCommand(params: { specOverrides: pluginSelection.specOverrides, dryRun: params.opts.dryRun, dangerouslyForceUnsafeInstall: params.opts.dangerouslyForceUnsafeInstall, + ...resolveClawHubRiskAcknowledgementCliOptions({ + acknowledgeClawHubRisk: params.opts.acknowledgeClawHubRisk, + action: "updating", + }), logger, onIntegrityDrift: async (drift) => { const specLabel = drift.resolvedSpec ?? drift.spec; diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 79735c60d7c..42876e80432 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -405,14 +405,14 @@ describe("update-cli", () => { const syncPluginCall = (index = 0) => { const calls = syncPluginsForUpdateChannel.mock.calls as unknown as Array< - [{ channel?: string; config?: OpenClawConfig }] + [Record & { channel?: string; config?: OpenClawConfig }] >; return calls[index]?.[0]; }; const npmPluginUpdateCall = (index = 0) => { const calls = updateNpmInstalledPlugins.mock.calls as unknown as Array< - [{ config?: OpenClawConfig; timeoutMs?: number }] + [Record & { config?: OpenClawConfig; timeoutMs?: number }] >; return calls[index]?.[0]; }; @@ -930,6 +930,33 @@ describe("update-cli", () => { expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); }); + it("carries ClawHub risk acknowledgement into post-core resume", async () => { + const { entrypoints } = setupUpdatedRootRefresh({ + gatewayUpdateImpl: async (root) => + makeOkUpdateResult({ + mode: "git", + root, + before: { sha: "old-sha", version: "2026.4.26" }, + after: { sha: "new-sha", version: "2026.4.27" }, + }), + }); + + await updateCommand({ + channel: "dev", + yes: true, + restart: false, + acknowledgeClawHubRisk: true, + }); + + expect(spawnCall()?.[1]).toEqual([ + entrypoints[0], + "update", + "--no-restart", + "--yes", + "--acknowledge-clawhub-risk", + ]); + }); + it("keeps downgrade post-update work in the current process", async () => { const downgradedRoot = createCaseDir("openclaw-downgraded-root"); setupUpdatedRootRefresh({ @@ -2892,6 +2919,22 @@ describe("update-cli", () => { expect(updateCall?.syncOfficialPluginInstalls).toBe(true); }); + it("forwards ClawHub risk acknowledgement to post-update plugin work", async () => { + const tempDir = createCaseDir("openclaw-update"); + mockPackageInstallStatus(tempDir); + + await updateCommand({ + channel: "beta", + yes: true, + restart: false, + acknowledgeClawHubRisk: true, + }); + + expect(syncPluginCall()?.acknowledgeClawHubRisk).toBe(true); + expect(npmPluginUpdateCall()?.acknowledgeClawHubRisk).toBe(true); + expect(lastNpmPluginUpdateCall()?.acknowledgeClawHubRisk).toBe(true); + }); + it("persists channel and runs post-update work after switching from package to git", async () => { const tempDir = createCaseDir("openclaw-update"); const gitRoot = path.join(tempDir, "..", "openclaw"); diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 554e7963c11..778d774da8d 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -31,6 +31,21 @@ function inheritedUpdateTimeout( return inheritOptionFromParent(command, "timeout"); } +type CommanderUpdateOptions = Record & { + acknowledgeClawhubRisk?: boolean; + channel?: string; + dryRun?: boolean; + json?: boolean; + restart?: boolean; + tag?: string; + timeout?: string; + yes?: boolean; +}; + +function normalizeCommanderClawHubRiskOption(opts: CommanderUpdateOptions): boolean { + return opts.acknowledgeClawhubRisk === true || opts.acknowledgeClawHubRisk === true; +} + export function registerUpdateCli(program: Command) { program.enablePositionalOptions(); const update = program @@ -46,6 +61,11 @@ export function registerUpdateCli(program: Command) { ) .option("--timeout ", "Timeout for each update step in seconds (default: 1800)") .option("--yes", "Skip confirmation prompts (non-interactive)", false) + .option( + "--acknowledge-clawhub-risk", + "Acknowledge ClawHub release trust warnings during post-update plugin sync", + false, + ) .addHelpText("after", () => { const examples = [ ["openclaw update", "Update a source checkout (git)"], @@ -57,6 +77,7 @@ export function registerUpdateCli(program: Command) { ["openclaw update --no-restart", "Update without restarting the service"], ["openclaw update --json", "Output result as JSON"], ["openclaw update --yes", "Non-interactive (accept downgrade prompts)"], + ["openclaw update --acknowledge-clawhub-risk", "Acknowledge ClawHub plugin trust warnings"], ["openclaw update wizard", "Interactive update wizard"], ["openclaw --update", "Shorthand for openclaw update"], ] as const; @@ -75,6 +96,7 @@ ${theme.heading("Switch channels:")} ${theme.heading("Non-interactive:")} - Use --yes to accept downgrade prompts + - Use --acknowledge-clawhub-risk only after reviewing ClawHub plugin trust warnings - Combine with --channel/--tag/--no-restart/--json/--timeout as needed - Use --dry-run to preview actions without writing config/installing/restarting @@ -89,16 +111,17 @@ ${theme.heading("Notes:")} ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.openclaw.ai/cli/update")}`; }) - .action(async (opts) => { + .action(async (opts: CommanderUpdateOptions) => { try { await updateCommand({ json: Boolean(opts.json), restart: Boolean(opts.restart), dryRun: Boolean(opts.dryRun), - channel: opts.channel as string | undefined, - tag: opts.tag as string | undefined, - timeout: opts.timeout as string | undefined, + channel: opts.channel, + tag: opts.tag, + timeout: opts.timeout, yes: Boolean(opts.yes), + acknowledgeClawHubRisk: normalizeCommanderClawHubRiskOption(opts), }); } catch (err) { defaultRuntime.error(String(err)); diff --git a/src/cli/update-cli/shared.ts b/src/cli/update-cli/shared.ts index 784d114ae36..0e6f8e73ada 100644 --- a/src/cli/update-cli/shared.ts +++ b/src/cli/update-cli/shared.ts @@ -32,6 +32,7 @@ export type UpdateCommandOptions = { tag?: string; timeout?: string; yes?: boolean; + acknowledgeClawHubRisk?: boolean; }; export type UpdateStatusOptions = { diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 73cec7d50cb..13a334b9757 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -55,6 +55,7 @@ import { resolvePnpmGlobalDirFromGlobalRoot, } from "../../infra/update-global.js"; import { runGatewayUpdate, type UpdateRunResult } from "../../infra/update-runner.js"; +import type { ClawHubRiskAcknowledgementRequest } from "../../plugins/clawhub.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js"; import { loadInstalledPluginIndexInstallRecords, @@ -173,6 +174,29 @@ type MissingPluginInstallPayload = { type PostUpdatePluginWarning = NonNullable[number]; +function resolveUpdateClawHubRiskAcknowledgementOptions(opts: UpdateCommandOptions): { + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => Promise; +} { + if (opts.acknowledgeClawHubRisk) { + return { acknowledgeClawHubRisk: true }; + } + if (opts.json || !process.stdin.isTTY) { + return {}; + } + return { + onClawHubRisk: async (request) => { + const ok = await confirm({ + message: stylePromptMessage( + `Continue updating ClawHub package "${request.packageName}@${request.version}" despite this warning?`, + ), + initialValue: false, + }); + return !isCancel(ok) && ok; + }, + }; +} + function pickUpdateQuip(): string { return UPDATE_QUIPS[Math.floor(Math.random() * UPDATE_QUIPS.length)] ?? "Update complete."; } @@ -1185,6 +1209,9 @@ export async function updatePluginsAfterCoreUpdate(params: { } const warnings: PostUpdatePluginWarning[] = []; + const clawHubRiskAcknowledgementOptions = resolveUpdateClawHubRiskAcknowledgementOptions( + params.opts, + ); const pluginInstallRecords = params.pluginInstallRecords ?? (await loadInstalledPluginIndexInstallRecords()); const syncConfig = withPluginInstallRecords( @@ -1198,6 +1225,7 @@ export async function updatePluginsAfterCoreUpdate(params: { externalizedBundledPluginBridges: await listPersistedBundledPluginLocationBridges({ workspaceDir: params.root, }), + ...clawHubRiskAcknowledgementOptions, logger: pluginLogger, }); for (const error of syncResult.summary.errors) { @@ -1271,6 +1299,7 @@ export async function updatePluginsAfterCoreUpdate(params: { disableOnFailure: true, logger: pluginLogger, onIntegrityDrift: onPluginIntegrityDrift, + ...clawHubRiskAcknowledgementOptions, }); pluginConfig = repairResult.config; pluginsChanged ||= repairResult.changed; @@ -1291,6 +1320,7 @@ export async function updatePluginsAfterCoreUpdate(params: { disableOnFailure: true, logger: pluginLogger, onIntegrityDrift: onPluginIntegrityDrift, + ...clawHubRiskAcknowledgementOptions, }); pluginConfig = npmResult.config; pluginsChanged ||= npmResult.changed; @@ -1949,6 +1979,9 @@ async function continuePostCoreUpdateInFreshProcess(params: { if (params.opts.yes) { argv.push("--yes"); } + if (params.opts.acknowledgeClawHubRisk) { + argv.push("--acknowledge-clawhub-risk"); + } if (params.opts.timeout) { argv.push("--timeout", params.opts.timeout); } diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index 32dbfc257f7..d40fcb60a12 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -506,6 +506,7 @@ async function installCandidate(params: { const { candidate } = params; const extensionsDir = resolveDefaultPluginExtensionsDir(); const changes: string[] = []; + const warnings: string[] = []; const clawhubSpecs = candidate.clawhubSpec ? resolveClawHubInstallSpecsForUpdateChannel({ spec: candidate.clawhubSpec, @@ -526,6 +527,9 @@ async function installCandidate(params: { extensionsDir, expectedPluginId: candidate.pluginId, mode: "install", + logger: { + warn: (message) => warnings.push(message), + }, }); if (clawhubResult.ok) { const pluginId = clawhubResult.pluginId; @@ -540,7 +544,7 @@ async function installCandidate(params: { }, }, changes: [`Installed missing configured plugin "${pluginId}" from ${clawhubInstallSpec}.`], - warnings: [], + warnings, }; } if (!npmInstallSpec || !shouldFallbackClawHubToNpm(clawhubResult)) { @@ -548,6 +552,7 @@ async function installCandidate(params: { records: params.records, changes: [], warnings: [ + ...warnings, `Failed to install missing configured plugin "${candidate.pluginId}" from ${clawhubInstallSpec}: ${clawhubResult.error}`, ], }; @@ -561,6 +566,7 @@ async function installCandidate(params: { records: params.records, changes: [], warnings: [ + ...warnings, `Failed to install missing configured plugin "${candidate.pluginId}": missing npm spec.`, ], }; @@ -580,6 +586,7 @@ async function installCandidate(params: { records: params.records, changes: [], warnings: [ + ...warnings, `Failed to install missing configured plugin "${candidate.pluginId}" from ${npmInstallSpec}: ${result.error}`, ], }; @@ -601,7 +608,7 @@ async function installCandidate(params: { ...changes, `Installed missing configured plugin "${pluginId}" from ${npmInstallSpec}.`, ], - warnings: [], + warnings, }; } diff --git a/src/commands/onboarding-plugin-install.test.ts b/src/commands/onboarding-plugin-install.test.ts index 14f5795999e..971d3654def 100644 --- a/src/commands/onboarding-plugin-install.test.ts +++ b/src/commands/onboarding-plugin-install.test.ts @@ -127,6 +127,7 @@ type NpmSpecInstallCall = { type ClawHubInstallCall = { expectedPluginId?: string; mode?: string; + onClawHubRisk?: unknown; spec?: string; timeoutMs?: number; }; @@ -368,6 +369,7 @@ describe("ensureOnboardingPluginInstalled", () => { expect(clawHubCall.expectedPluginId).toBe("demo-plugin"); expect(clawHubCall.mode).toBe("install"); expect(clawHubCall.timeoutMs).toBe(300_000); + expect(typeof clawHubCall.onClawHubRisk).toBe("function"); expect(update).toHaveBeenCalledWith("Downloading"); expect(stop).toHaveBeenCalledWith("Installed Demo Provider plugin"); const [, recordUpdate] = readFirstMockCall(recordPluginInstall, "recordPluginInstall") as [ diff --git a/src/commands/onboarding-plugin-install.ts b/src/commands/onboarding-plugin-install.ts index d63d5f82431..bbc5a09528c 100644 --- a/src/commands/onboarding-plugin-install.ts +++ b/src/commands/onboarding-plugin-install.ts @@ -889,6 +889,14 @@ async function installPluginFromClawHubSpecWithProgress(params: { logInstallWarningWithSpacing(params.runtime, message); }, }, + onClawHubRisk: async (request) => { + animated.stop(); + progress.stop("Review ClawHub warning"); + return await params.prompter.confirm({ + message: `Continue installing ClawHub package "${sanitizeTerminalText(request.packageName)}@${sanitizeTerminalText(request.version)}" despite this warning?`, + initialValue: false, + }); + }, }), ONBOARDING_PLUGIN_INSTALL_WATCHDOG_TIMEOUT_MS, ); diff --git a/src/infra/clawhub.test.ts b/src/infra/clawhub.test.ts index 9906968ce20..c4691afcb11 100644 --- a/src/infra/clawhub.test.ts +++ b/src/infra/clawhub.test.ts @@ -330,30 +330,75 @@ describe("clawhub helpers", () => { requestedUrl = input instanceof Request ? input.url : String(input); return new Response( JSON.stringify({ - releaseId: "rel_demo", - state: "approved", - reasonCode: "clean", - createdAt: 1774256733107, - scanState: "clean", - moderationState: "approved", + package: { + name: "@openclaw/diagnostics-otel", + displayName: "Diagnostics", + family: "code-plugin", + }, + release: { + id: "rel_demo", + version: "2026.3.22", + }, + trust: { + scanStatus: "clean", + moderationState: null, + blockedFromDownload: false, + reasons: [], + pending: false, + stale: true, + }, }), { status: 200, headers: { "content-type": "application/json" } }, ); }, }), ).resolves.toEqual({ - releaseId: "rel_demo", - state: "approved", - reasonCode: "clean", - createdAt: 1774256733107, - scanState: "clean", - moderationState: "approved", + package: { + name: "@openclaw/diagnostics-otel", + displayName: "Diagnostics", + family: "code-plugin", + }, + release: { + id: "rel_demo", + version: "2026.3.22", + }, + trust: { + scanStatus: "clean", + moderationState: null, + blockedFromDownload: false, + reasons: [], + pending: false, + stale: true, + }, }); expect(new URL(requestedUrl).pathname).toBe( "/api/v1/packages/%40openclaw%2Fdiagnostics-otel/versions/2026.3.22/security", ); }); + it("rejects malformed package security reports", async () => { + await expect( + fetchClawHubPackageSecurity({ + name: "@openclaw/diagnostics-otel", + version: "2026.3.22", + fetchImpl: async () => + new Response( + JSON.stringify({ + trust: { + scanStatus: "clean", + moderationState: null, + blockedFromDownload: false, + reasons: "clean", + pending: false, + stale: false, + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + }), + ).rejects.toThrow("expected reasons to be a string array"); + }); + it("downloads package archives to sanitized temp paths and cleans them up", async () => { const archive = await downloadClawHubPackageArchive({ name: "@hyf/zai-external-alpha", diff --git a/src/infra/clawhub.ts b/src/infra/clawhub.ts index d8f4a4df59c..32527cc9e25 100644 --- a/src/infra/clawhub.ts +++ b/src/infra/clawhub.ts @@ -62,14 +62,14 @@ export type ClawHubArtifactScanState = | "not-run" | (string & {}); export type ClawHubArtifactModerationState = "approved" | "quarantined" | "revoked" | (string & {}); -export type ClawHubPackageSecurityState = - | "pending" - | "approved" - | "limited" - | "quarantined" - | "rejected" - | "revoked" - | (string & {}); +export type ClawHubPackageSecurityTrust = { + scanStatus?: ClawHubArtifactScanState | null; + moderationState?: ClawHubArtifactModerationState | null; + blockedFromDownload: boolean; + reasons: string[]; + pending: boolean; + stale: boolean; +}; export type ClawHubResolvedArtifact = | { source: "clawhub"; @@ -116,15 +116,16 @@ export type ClawHubPackageArtifactResolverResponse = { artifact?: ClawHubResolvedArtifact | null; }; export type ClawHubPackageSecurityResponse = { - packageId?: string | null; - releaseId?: string | null; - state: ClawHubPackageSecurityState; - reasonCode?: string | null; - moderatorNote?: string | null; - actorId?: string | null; - createdAt?: number | null; - scanState?: ClawHubArtifactScanState | null; - moderationState?: ClawHubArtifactModerationState | null; + package?: { + name?: string | null; + displayName?: string | null; + family?: ClawHubPackageFamily | (string & {}) | null; + } | null; + release?: { + id?: string | null; + version?: string | null; + } | null; + trust: ClawHubPackageSecurityTrust; }; export type ClawHubPackageClawPackSummary = { available: boolean; @@ -636,6 +637,126 @@ async function fetchJson(params: ClawHubRequestParams): Promise { return (await response.json()) as T; } +function isJsonObject(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function optionalStringField( + source: Record, + field: string, + context: string, +): string | null | undefined { + const value = source[field]; + if (value === undefined || value === null || typeof value === "string") { + return value; + } + throw new Error(`Malformed ClawHub ${context}: expected ${field} to be a string or null.`); +} + +function requiredBooleanField( + source: Record, + field: string, + context: string, +): boolean { + const value = source[field]; + if (typeof value === "boolean") { + return value; + } + throw new Error(`Malformed ClawHub ${context}: expected ${field} to be a boolean.`); +} + +function requiredStringArrayField( + source: Record, + field: string, + context: string, +): string[] { + const value = source[field]; + if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) { + return value; + } + throw new Error(`Malformed ClawHub ${context}: expected ${field} to be a string array.`); +} + +function parseOptionalSecurityPackage(value: unknown): ClawHubPackageSecurityResponse["package"] { + if (value === undefined || value === null) { + return value; + } + if (!isJsonObject(value)) { + throw new Error( + "Malformed ClawHub security response: expected package to be an object or null.", + ); + } + const result: NonNullable = {}; + const name = optionalStringField(value, "name", "security package"); + const displayName = optionalStringField(value, "displayName", "security package"); + const family = optionalStringField(value, "family", "security package"); + if (name !== undefined) { + result.name = name; + } + if (displayName !== undefined) { + result.displayName = displayName; + } + if (family !== undefined) { + result.family = family; + } + return result; +} + +function parseOptionalSecurityRelease(value: unknown): ClawHubPackageSecurityResponse["release"] { + if (value === undefined || value === null) { + return value; + } + if (!isJsonObject(value)) { + throw new Error( + "Malformed ClawHub security response: expected release to be an object or null.", + ); + } + const result: NonNullable = {}; + const id = optionalStringField(value, "id", "security release"); + const version = optionalStringField(value, "version", "security release"); + if (id !== undefined) { + result.id = id; + } + if (version !== undefined) { + result.version = version; + } + return result; +} + +function parseClawHubPackageSecurityResponse(value: unknown): ClawHubPackageSecurityResponse { + if (!isJsonObject(value)) { + throw new Error("Malformed ClawHub security response: expected an object."); + } + const trust = value.trust; + if (!isJsonObject(trust)) { + throw new Error("Malformed ClawHub security response: expected trust to be an object."); + } + const parsedTrust: ClawHubPackageSecurityTrust = { + blockedFromDownload: requiredBooleanField(trust, "blockedFromDownload", "security trust"), + reasons: requiredStringArrayField(trust, "reasons", "security trust"), + pending: requiredBooleanField(trust, "pending", "security trust"), + stale: requiredBooleanField(trust, "stale", "security trust"), + }; + const scanStatus = optionalStringField(trust, "scanStatus", "security trust"); + const moderationState = optionalStringField(trust, "moderationState", "security trust"); + if (scanStatus !== undefined) { + parsedTrust.scanStatus = scanStatus; + } + if (moderationState !== undefined) { + parsedTrust.moderationState = moderationState; + } + const result: ClawHubPackageSecurityResponse = { trust: parsedTrust }; + const parsedPackage = parseOptionalSecurityPackage(value.package); + const parsedRelease = parseOptionalSecurityRelease(value.release); + if (parsedPackage !== undefined) { + result.package = parsedPackage; + } + if (parsedRelease !== undefined) { + result.release = parsedRelease; + } + return result; +} + export function resolveClawHubBaseUrl(baseUrl?: string): string { return normalizeBaseUrl(baseUrl); } @@ -768,7 +889,7 @@ export async function fetchClawHubPackageSecurity(params: { timeoutMs?: number; fetchImpl?: FetchLike; }): Promise { - return await fetchJson({ + const response = await fetchJson({ baseUrl: params.baseUrl, path: `/api/v1/packages/${encodeURIComponent(params.name)}/versions/${encodeURIComponent( params.version, @@ -777,6 +898,7 @@ export async function fetchClawHubPackageSecurity(params: { timeoutMs: params.timeoutMs, fetchImpl: params.fetchImpl, }); + return parseClawHubPackageSecurityResponse(response); } export async function fetchClawHubPackageReadiness(params: { diff --git a/src/plugins/clawhub.test.ts b/src/plugins/clawhub.test.ts index c7f9edc360a..98e17896c1c 100644 --- a/src/plugins/clawhub.test.ts +++ b/src/plugins/clawhub.test.ts @@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const parseClawHubPluginSpecMock = vi.fn(); const fetchClawHubPackageDetailMock = vi.fn(); const fetchClawHubPackageArtifactMock = vi.fn(); +const fetchClawHubPackageSecurityMock = vi.fn(); const fetchClawHubPackageVersionMock = vi.fn(); const downloadClawHubPackageArchiveMock = vi.fn(); const archiveCleanupMock = vi.fn(); @@ -23,6 +24,7 @@ vi.mock("../infra/clawhub.js", async () => { parseClawHubPluginSpec: (...args: unknown[]) => parseClawHubPluginSpecMock(...args), fetchClawHubPackageDetail: (...args: unknown[]) => fetchClawHubPackageDetailMock(...args), fetchClawHubPackageArtifact: (...args: unknown[]) => fetchClawHubPackageArtifactMock(...args), + fetchClawHubPackageSecurity: (...args: unknown[]) => fetchClawHubPackageSecurityMock(...args), fetchClawHubPackageVersion: (...args: unknown[]) => fetchClawHubPackageVersionMock(...args), downloadClawHubPackageArchive: (...args: unknown[]) => downloadClawHubPackageArchiveMock(...args), @@ -142,6 +144,8 @@ function expectClawHubInstallFlow(params: { expect(packageVersionCall().version).toBe(params.version); expect(packageArtifactCall().name).toBe("demo"); expect(packageArtifactCall().version).toBe(params.version); + expect(packageSecurityCall().name).toBe("demo"); + expect(packageSecurityCall().version).toBe(params.version); expect(archiveInstallCall().archivePath).toBe(params.archivePath); } @@ -211,6 +215,10 @@ function packageArtifactCall(callIndex = 0): PackageLookupCall { return mockCallArg(fetchClawHubPackageArtifactMock, callIndex) as PackageLookupCall; } +function packageSecurityCall(callIndex = 0): PackageLookupCall { + return mockCallArg(fetchClawHubPackageSecurityMock, callIndex) as PackageLookupCall; +} + function archiveDownloadCall(callIndex = 0): PackageLookupCall { return mockCallArg(downloadClawHubPackageArchiveMock, callIndex) as PackageLookupCall; } @@ -250,6 +258,7 @@ describe("installPluginFromClawHub", () => { parseClawHubPluginSpecMock.mockReset(); fetchClawHubPackageDetailMock.mockReset(); fetchClawHubPackageArtifactMock.mockReset(); + fetchClawHubPackageSecurityMock.mockReset(); fetchClawHubPackageVersionMock.mockReset(); downloadClawHubPackageArchiveMock.mockReset(); archiveCleanupMock.mockReset(); @@ -289,6 +298,24 @@ describe("installPluginFromClawHub", () => { fetchClawHubPackageArtifactMock.mockImplementation((params) => fetchClawHubPackageVersionMock(params), ); + fetchClawHubPackageSecurityMock.mockResolvedValue({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.22", + }, + trust: { + scanStatus: "clean", + moderationState: null, + blockedFromDownload: false, + reasons: [], + pending: false, + stale: false, + }, + }); downloadClawHubPackageArchiveMock.mockResolvedValue({ archivePath: "/tmp/clawhub-demo/archive.zip", integrity: DEMO_ARCHIVE_INTEGRITY, @@ -331,6 +358,111 @@ describe("installPluginFromClawHub", () => { expect(archiveCleanupMock).toHaveBeenCalledTimes(1); }); + it("requires acknowledgement before downloading risky ClawHub releases", async () => { + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + trust: { + scanStatus: "malicious", + moderationState: "quarantined", + blockedFromDownload: true, + reasons: ["manual_moderation"], + pending: false, + stale: false, + }, + }); + const logger = createLoggerSpies(); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + logger, + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED); + expect(failure.error).toContain("--acknowledge-clawhub-risk"); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('ClawHub trust warning for "demo@2026.3.22"'), + ); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); + }); + + it("continues after a risky ClawHub release is acknowledged", async () => { + const onClawHubRisk = vi.fn(async () => true); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + trust: { + scanStatus: "suspicious", + moderationState: null, + blockedFromDownload: false, + reasons: ["payload_strings"], + pending: false, + stale: false, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + onClawHubRisk, + }); + + expectSuccessfulClawHubInstall(result); + expect(onClawHubRisk).toHaveBeenCalledWith( + expect.objectContaining({ + packageName: "demo", + version: "2026.3.22", + warning: expect.stringContaining("payload_strings"), + }), + ); + expect(downloadClawHubPackageArchiveMock).toHaveBeenCalled(); + }); + + it("warns for stale clean ClawHub trust without requiring acknowledgement", async () => { + const onClawHubRisk = vi.fn(async () => false); + const logger = createLoggerSpies(); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + trust: { + scanStatus: "clean", + moderationState: null, + blockedFromDownload: false, + reasons: [], + pending: false, + stale: true, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + logger, + onClawHubRisk, + }); + + expectSuccessfulClawHubInstall(result); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("stale=true")); + expect(onClawHubRisk).not.toHaveBeenCalled(); + }); + + it("stops when the ClawHub security response is unavailable", async () => { + fetchClawHubPackageSecurityMock.mockRejectedValueOnce( + new ClawHubRequestError({ + path: "/api/v1/packages/demo/versions/2026.3.22/security", + status: 404, + body: "not found", + }), + ); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE); + expect(failure.error).toContain("ClawHub release trust check failed"); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + }); + it("marks official source-linked OpenClaw packages as trusted for install scanning", async () => { fetchClawHubPackageDetailMock.mockResolvedValueOnce({ package: { diff --git a/src/plugins/clawhub.ts b/src/plugins/clawhub.ts index 705feb7e4b1..b77af09a239 100644 --- a/src/plugins/clawhub.ts +++ b/src/plugins/clawhub.ts @@ -15,6 +15,7 @@ import { downloadClawHubPackageArchive, fetchClawHubPackageArtifact, fetchClawHubPackageDetail, + fetchClawHubPackageSecurity, fetchClawHubPackageVersion, normalizeClawHubSha256Integrity, normalizeClawHubSha256Hex, @@ -27,6 +28,7 @@ import { type ClawHubPackageCompatibility, type ClawHubPackageDetail, type ClawHubPackageClawPackSummary, + type ClawHubPackageSecurityTrust, type ClawHubResolvedArtifact, type ClawHubPackageVersion, } from "../infra/clawhub.js"; @@ -49,6 +51,8 @@ export const CLAWHUB_INSTALL_ERROR_CODE = { INCOMPATIBLE_GATEWAY: "incompatible_gateway", MISSING_ARCHIVE_INTEGRITY: "missing_archive_integrity", ARCHIVE_INTEGRITY_MISMATCH: "archive_integrity_mismatch", + CLAWHUB_SECURITY_UNAVAILABLE: "clawhub_security_unavailable", + CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED: "clawhub_risk_acknowledgement_required", } as const; export type ClawHubInstallErrorCode = @@ -59,6 +63,13 @@ type PluginInstallLogger = { warn?: (message: string) => void; }; +export type ClawHubRiskAcknowledgementRequest = { + packageName: string; + version: string; + trust: ClawHubPackageSecurityTrust; + warning: string; +}; + type ClawHubInstallFailure = { ok: false; error: string; @@ -349,6 +360,121 @@ function mapClawHubRequestError( return buildClawHubInstallFailure(formatErrorMessage(error)); } +const CLAWHUB_RISK_SCAN_STATUSES = new Set(["malicious", "suspicious"]); +const CLAWHUB_RISK_MODERATION_STATES = new Set(["blocked", "quarantined", "revoked"]); +const CLAWHUB_NON_RISK_REASONS = new Set([ + "pending", + "pending_scan", + "scan_pending", + "stale", + "stale_scan", +]); + +function normalizeClawHubTrustToken(value: string | null | undefined): string { + return normalizeOptionalString(value)?.toLowerCase() ?? ""; +} + +function resolveClawHubRiskReasons(trust: ClawHubPackageSecurityTrust): string[] { + const reasons: string[] = []; + if (trust.blockedFromDownload) { + reasons.push("blocked from download"); + } + const scanStatus = normalizeClawHubTrustToken(trust.scanStatus); + if (CLAWHUB_RISK_SCAN_STATUSES.has(scanStatus)) { + reasons.push(`scan status ${scanStatus}`); + } + const moderationState = normalizeClawHubTrustToken(trust.moderationState); + if (CLAWHUB_RISK_MODERATION_STATES.has(moderationState)) { + reasons.push(`moderation state ${moderationState}`); + } + for (const reason of trust.reasons) { + const normalized = normalizeClawHubTrustToken(reason); + if (normalized && !CLAWHUB_NON_RISK_REASONS.has(normalized)) { + reasons.push(reason); + } + } + return reasons; +} + +function formatClawHubTrustWarning(params: { + packageName: string; + version: string; + trust: ClawHubPackageSecurityTrust; + riskReasons: readonly string[]; +}): string { + const details = [ + `scan=${params.trust.scanStatus ?? "unknown"}`, + `moderation=${params.trust.moderationState ?? "none"}`, + `blockedFromDownload=${String(params.trust.blockedFromDownload)}`, + `pending=${String(params.trust.pending)}`, + `stale=${String(params.trust.stale)}`, + `reasons=${params.trust.reasons.length ? params.trust.reasons.join(", ") : "none"}`, + ]; + const riskSuffix = + params.riskReasons.length > 0 ? ` Risk signals: ${params.riskReasons.join(", ")}.` : ""; + return `ClawHub trust warning for "${params.packageName}@${params.version}": ${details.join("; ")}.${riskSuffix}`; +} + +async function ensureClawHubPackageTrustAcknowledged(params: { + packageName: string; + version: string; + baseUrl?: string; + token?: string; + timeoutMs?: number; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; + logger?: PluginInstallLogger; +}): Promise { + let trust: ClawHubPackageSecurityTrust; + try { + const security = await fetchClawHubPackageSecurity({ + name: params.packageName, + version: params.version, + baseUrl: params.baseUrl, + token: params.token, + timeoutMs: params.timeoutMs, + }); + trust = security.trust; + } catch (error) { + return buildClawHubInstallFailure( + `ClawHub release trust check failed for "${params.packageName}@${params.version}": ${formatErrorMessage(error)}`, + CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE, + ); + } + + const riskReasons = resolveClawHubRiskReasons(trust); + if (riskReasons.length === 0 && !trust.pending && !trust.stale) { + return null; + } + + const warning = formatClawHubTrustWarning({ + packageName: params.packageName, + version: params.version, + trust, + riskReasons, + }); + params.logger?.warn?.(warning); + if (riskReasons.length === 0 || params.acknowledgeClawHubRisk) { + return null; + } + + const acknowledged = params.onClawHubRisk + ? await params.onClawHubRisk({ + packageName: params.packageName, + version: params.version, + trust, + warning, + }) + : false; + if (acknowledged) { + return null; + } + return buildClawHubInstallFailure( + `ClawHub release "${params.packageName}@${params.version}" has trust warnings. Review the package and rerun with --acknowledge-clawhub-risk to continue.`, + CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED, + ); +} + function isMissingArtifactResolverRoute(error: unknown): boolean { return ( error instanceof ClawHubRequestError && @@ -1055,6 +1181,8 @@ export async function installPluginFromClawHub( timeoutMs?: number; dryRun?: boolean; expectedPluginId?: string; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; }, ): Promise< | ({ @@ -1125,6 +1253,19 @@ export async function installPluginFromClawHub( compatibility: versionState.compatibility, logger: params.logger, }); + const trustFailure = await ensureClawHubPackageTrustAcknowledged({ + packageName: canonicalPackageName, + version: versionState.version, + baseUrl: params.baseUrl, + token: params.token, + timeoutMs: params.timeoutMs, + acknowledgeClawHubRisk: params.acknowledgeClawHubRisk, + onClawHubRisk: params.onClawHubRisk, + logger: params.logger, + }); + if (trustFailure) { + return trustFailure; + } let archive; try { diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index b109a1ad9b1..736a8b1ee73 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -2397,6 +2397,54 @@ describe("updateNpmInstalledPlugins", () => { }); }); + it("forwards ClawHub risk acknowledgement inputs to dry-run and live ClawHub updates", async () => { + const onClawHubRisk = vi.fn(async () => true); + const config = createClawHubInstallConfig({ + pluginId: "demo", + installPath: "/tmp/demo", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + }); + installPluginFromClawHubMock.mockResolvedValue({ + ok: true, + pluginId: "demo", + targetDir: "/tmp/demo", + version: "1.2.4", + clawhub: { + source: "clawhub", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + integrity: "sha256-next", + resolvedAt: "2026-03-22T00:00:00.000Z", + }, + }); + + for (const dryRun of [true, false]) { + installPluginFromClawHubMock.mockClear(); + + await updateNpmInstalledPlugins({ + config, + pluginIds: ["demo"], + acknowledgeClawHubRisk: true, + onClawHubRisk, + ...(dryRun ? { dryRun: true } : {}), + }); + + expect(installPluginFromClawHubMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "clawhub:demo", + acknowledgeClawHubRisk: true, + onClawHubRisk, + ...(dryRun ? { dryRun: true } : {}), + }), + ); + } + }); + it("migrates legacy unscoped install keys when a scoped npm package updates", async () => { installPluginFromNpmSpecMock.mockResolvedValue({ ok: true, @@ -2947,9 +2995,12 @@ describe("syncPluginsForUpdateChannel", () => { clawhubPackage: "legacy-chat", }), ); + const onClawHubRisk = vi.fn(async () => true); const result = await syncPluginsForUpdateChannel({ channel: "stable", + acknowledgeClawHubRisk: true, + onClawHubRisk, externalizedBundledPluginBridges: [ { bundledPluginId: "legacy-chat", @@ -2983,6 +3034,8 @@ describe("syncPluginsForUpdateChannel", () => { expect(clawHubInstallCall()?.baseUrl).toBe("https://clawhub.ai"); expect(clawHubInstallCall()?.mode).toBe("update"); expect(clawHubInstallCall()?.expectedPluginId).toBe("legacy-chat"); + expect(clawHubInstallCall()?.acknowledgeClawHubRisk).toBe(true); + expect(clawHubInstallCall()?.onClawHubRisk).toBe(onClawHubRisk); expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled(); expect(result.changed).toBe(true); expect(result.summary.switchedToClawHub).toEqual(["legacy-chat"]); diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 69cba96c4df..60d157a30a1 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -20,7 +20,11 @@ import type { UpdateChannel } from "../infra/update-channels.js"; import { resolveUserPath } from "../utils.js"; import { resolveBundledPluginSources } from "./bundled-sources.js"; import { buildClawHubPluginInstallRecordFields } from "./clawhub-install-records.js"; -import { CLAWHUB_INSTALL_ERROR_CODE, installPluginFromClawHub } from "./clawhub.js"; +import { + CLAWHUB_INSTALL_ERROR_CODE, + installPluginFromClawHub, + type ClawHubRiskAcknowledgementRequest, +} from "./clawhub.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import { getExternalizedBundledPluginLegacyPathSuffix, @@ -848,6 +852,8 @@ export async function updateNpmInstalledPlugins(params: { dangerouslyForceUnsafeInstall?: boolean; specOverrides?: Record; onIntegrityDrift?: (params: PluginUpdateIntegrityDriftParams) => boolean | Promise; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; }): Promise { const logger = params.logger ?? {}; const installs = params.config.plugins?.installs ?? {}; @@ -866,6 +872,10 @@ export async function updateNpmInstalledPlugins(params: { ranNpmInstaller = true; return await installPluginFromNpmSpec(installParams); }; + const clawHubRiskAcknowledgementOptions = { + ...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), + ...(params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}), + }; const recordFailure = (pluginId: string, message: string) => { if (params.disableOnFailure && !params.dryRun) { @@ -1145,6 +1155,7 @@ export async function updateNpmInstalledPlugins(params: { dryRun: true, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, + ...clawHubRiskAcknowledgementOptions, logger, }) : record.source === "git" @@ -1232,6 +1243,7 @@ export async function updateNpmInstalledPlugins(params: { dryRun: true, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, + ...clawHubRiskAcknowledgementOptions, logger, }); } @@ -1345,6 +1357,7 @@ export async function updateNpmInstalledPlugins(params: { timeoutMs: params.timeoutMs, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, + ...clawHubRiskAcknowledgementOptions, logger, }) : record.source === "git" @@ -1428,6 +1441,7 @@ export async function updateNpmInstalledPlugins(params: { timeoutMs: params.timeoutMs, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, + ...clawHubRiskAcknowledgementOptions, logger, }); } @@ -1569,6 +1583,8 @@ export async function syncPluginsForUpdateChannel(params: { env?: NodeJS.ProcessEnv; logger?: PluginUpdateLogger; externalizedBundledPluginBridges?: readonly ExternalizedBundledPluginBridge[]; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; }): Promise { const env = params.env ?? process.env; const logger = params.logger ?? {}; @@ -1588,6 +1604,10 @@ export async function syncPluginsForUpdateChannel(params: { const loadHelpers = buildLoadPathHelpers(next.plugins?.load?.paths ?? [], env); let installs = next.plugins?.installs ?? {}; let changed = false; + const clawHubRiskAcknowledgementOptions = { + ...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), + ...(params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}), + }; if (params.channel === "dev") { for (const [pluginId, record] of Object.entries(installs)) { @@ -1700,6 +1720,7 @@ export async function syncPluginsForUpdateChannel(params: { ...(bridge.clawhubUrl ? { baseUrl: bridge.clawhubUrl } : {}), mode: "update", expectedPluginId: targetPluginId, + ...clawHubRiskAcknowledgementOptions, logger, }); if (!result.ok && npmSpec && shouldFallbackClawHubBridgeToNpm(result)) {