/ utils / plugins / pluginBlocklist.ts
pluginBlocklist.ts
  1  /**
  2   * Plugin delisting detection.
  3   *
  4   * Compares installed plugins against marketplace manifests to find plugins
  5   * that have been removed, and auto-uninstalls them.
  6   *
  7   * The security.json fetch was removed (see #25447) — ~29.5M/week GitHub hits
  8   * for UI reason/text only. If re-introduced, serve from downloads.claude.ai.
  9   */
 10  
 11  import { uninstallPluginOp } from '../../services/plugins/pluginOperations.js'
 12  import { logForDebugging } from '../debug.js'
 13  import { errorMessage } from '../errors.js'
 14  import { loadInstalledPluginsV2 } from './installedPluginsManager.js'
 15  import {
 16    getMarketplace,
 17    loadKnownMarketplacesConfigSafe,
 18  } from './marketplaceManager.js'
 19  import {
 20    addFlaggedPlugin,
 21    getFlaggedPlugins,
 22    loadFlaggedPlugins,
 23  } from './pluginFlagging.js'
 24  import type { InstalledPluginsFileV2, PluginMarketplace } from './schemas.js'
 25  
 26  /**
 27   * Detect plugins installed from a marketplace that are no longer listed there.
 28   *
 29   * @param installedPlugins All installed plugins
 30   * @param marketplace The marketplace to check against
 31   * @param marketplaceName The marketplace name suffix (e.g. "claude-plugins-official")
 32   * @returns List of delisted plugin IDs in "name@marketplace" format
 33   */
 34  export function detectDelistedPlugins(
 35    installedPlugins: InstalledPluginsFileV2,
 36    marketplace: PluginMarketplace,
 37    marketplaceName: string,
 38  ): string[] {
 39    const marketplacePluginNames = new Set(marketplace.plugins.map(p => p.name))
 40    const suffix = `@${marketplaceName}`
 41  
 42    const delisted: string[] = []
 43    for (const pluginId of Object.keys(installedPlugins.plugins)) {
 44      if (!pluginId.endsWith(suffix)) continue
 45  
 46      const pluginName = pluginId.slice(0, -suffix.length)
 47      if (!marketplacePluginNames.has(pluginName)) {
 48        delisted.push(pluginId)
 49      }
 50    }
 51  
 52    return delisted
 53  }
 54  
 55  /**
 56   * Detect delisted plugins across all marketplaces, auto-uninstall them,
 57   * and record them as flagged.
 58   *
 59   * This is the core delisting enforcement logic, shared between interactive
 60   * mode (useManagePlugins) and headless mode (main.tsx print path).
 61   *
 62   * @returns List of newly flagged plugin IDs
 63   */
 64  export async function detectAndUninstallDelistedPlugins(): Promise<string[]> {
 65    await loadFlaggedPlugins()
 66  
 67    const installedPlugins = loadInstalledPluginsV2()
 68    const alreadyFlagged = getFlaggedPlugins()
 69    // Read-only iteration — Safe variant so a corrupted config doesn't throw
 70    // out of this function (it's called in the same try-block as loadAllPlugins
 71    // in useManagePlugins, so a throw here would void loadAllPlugins' resilience).
 72    const knownMarketplaces = await loadKnownMarketplacesConfigSafe()
 73    const newlyFlagged: string[] = []
 74  
 75    for (const marketplaceName of Object.keys(knownMarketplaces)) {
 76      try {
 77        const marketplace = await getMarketplace(marketplaceName)
 78  
 79        if (!marketplace.forceRemoveDeletedPlugins) continue
 80  
 81        const delisted = detectDelistedPlugins(
 82          installedPlugins,
 83          marketplace,
 84          marketplaceName,
 85        )
 86  
 87        for (const pluginId of delisted) {
 88          if (pluginId in alreadyFlagged) continue
 89  
 90          // Skip managed-only plugins — enterprise admin should handle those
 91          const installations = installedPlugins.plugins[pluginId] ?? []
 92          const hasUserInstall = installations.some(
 93            i =>
 94              i.scope === 'user' || i.scope === 'project' || i.scope === 'local',
 95          )
 96          if (!hasUserInstall) continue
 97  
 98          // Auto-uninstall the delisted plugin from all user-controllable scopes
 99          for (const installation of installations) {
100            const { scope } = installation
101            if (scope !== 'user' && scope !== 'project' && scope !== 'local') {
102              continue
103            }
104            try {
105              await uninstallPluginOp(pluginId, scope)
106            } catch (error) {
107              logForDebugging(
108                `Failed to auto-uninstall delisted plugin ${pluginId} from ${scope}: ${errorMessage(error)}`,
109                { level: 'error' },
110              )
111            }
112          }
113  
114          await addFlaggedPlugin(pluginId)
115          newlyFlagged.push(pluginId)
116        }
117      } catch (error) {
118        // Marketplace may not be available yet — log and continue
119        logForDebugging(
120          `Failed to check for delisted plugins in "${marketplaceName}": ${errorMessage(error)}`,
121          { level: 'warn' },
122        )
123      }
124    }
125  
126    return newlyFlagged
127  }