UNPKG

@felixgeelhaar/cclint

Version:

Catch CLAUDE.md drift before Claude misbehaves. Lints CLAUDE.md, skills, subagents, and hooks for Claude Code projects.

323 lines 12.6 kB
import { PathValidator } from './security/PathValidator.js'; /** * Handles dynamic loading and management of custom rule plugins */ export class PluginLoader { loadedPlugins = new Map(); registry; pathValidator; trustedPlugins; // SECURITY POSTURE: plugins are loaded with a dynamic `import()` and run // IN-PROCESS with full privileges — there is no sandbox, timeout, or memory // cap. Only load plugins you trust, exactly as with any npm dependency. // (A previous PluginSandbox type suggested isolation that was never wired; // it was removed rather than imply a guarantee it did not provide.) constructor(registry) { this.registry = registry; this.pathValidator = new PathValidator(['.js', '.mjs', '.cjs', '.ts']); this.trustedPlugins = new Set([ '@cclint/core-rules', '@cclint/typescript-rules', '@cclint/python-rules', ]); } /** * Dynamic import wrapper with security checks */ async importPlugin(pluginName) { // Check if plugin is from trusted source const isTrusted = this.isPluginTrusted(pluginName); if (!isTrusted) { // For untrusted plugins, validate the import path if (pluginName.startsWith('.') || pluginName.startsWith('/')) { // Local file import - validate path try { const safePath = this.pathValidator.validatePath(pluginName); if (!this.pathValidator.isValidFile(safePath)) { throw new Error(`Plugin file not found: ${pluginName}`); } } catch (_error) { throw new Error(`Invalid plugin path: ${pluginName}`); } } else if (!pluginName.startsWith('@cclint/')) { // Third-party plugin - require explicit trust console.warn(`⚠️ Loading untrusted plugin: ${pluginName}`); console.warn('Consider adding it to trusted plugins if from a known source'); } } try { return (await import(pluginName)); } catch (error) { throw new Error(`Failed to import plugin ${pluginName}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Check if a plugin is from a trusted source */ isPluginTrusted(pluginName) { // Check explicit trust list if (this.trustedPlugins.has(pluginName)) { return true; } // Check if it's an official @cclint plugin if (pluginName.startsWith('@cclint/') || pluginName.startsWith('@felixgeelhaar/cclint-')) { return true; } return false; } /** * Load a single plugin by name * @param pluginName The name/path of the plugin to load * @param options Optional configuration for the plugin */ async loadPlugin(pluginName, _options) { // Check if plugin is already loaded if (this.isPluginNameLoaded(pluginName)) { console.warn(`Plugin "${pluginName}" is already loaded`); return; } let plugin; try { // Dynamic import of the plugin module with security checks const pluginModule = await this.importPlugin(pluginName); if (!pluginModule || typeof pluginModule !== 'object') { throw new Error(`Invalid plugin module structure`); } plugin = pluginModule.default; if (!plugin) { throw new Error(`Plugin does not export a default plugin object`); } // Validate plugin structure and security this.validatePlugin(plugin, pluginName); // Check for malicious patterns in plugin code this.checkPluginSecurity(plugin); // Register all rules from the plugin with error handling let registeredRules = 0; const failedRules = []; for (const rule of plugin.rules) { try { this.registry.registerRule(rule, plugin.name); registeredRules++; } catch (ruleError) { failedRules.push(rule.id); console.error(`Failed to register rule "${rule.id}":`, ruleError); } } if (registeredRules === 0) { throw new Error(`No rules could be registered from plugin`); } // Store the loaded plugin this.loadedPlugins.set(plugin.name, plugin); // Log success with details if (failedRules.length > 0) { console.warn(`⚠️ Loaded plugin: ${plugin.name} (${registeredRules}/${plugin.rules.length} rules registered)`); console.warn(` Failed rules: ${failedRules.join(', ')}`); } else { console.log(`✅ Loaded plugin: ${plugin.name} (${registeredRules} rules)`); } } catch (error) { // Provide detailed error information const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const detailedError = new Error(`Failed to load plugin "${pluginName}": ${errorMessage}`); // Add plugin name to error for better debugging detailedError.pluginName = pluginName; detailedError.originalError = error; console.error(`❌ ${detailedError.message}`); // Clean up any partial registration if (plugin?.name) { this.registry.unregisterPlugin(plugin.name); } throw detailedError; } } /** * Check if a plugin name is already loaded */ isPluginNameLoaded(pluginName) { // Check if the exact plugin name is loaded for (const [name] of this.loadedPlugins) { if (name === pluginName) { return true; } } return false; } /** * Check plugin for potential security issues */ checkPluginSecurity(plugin) { // Check for suspicious rule patterns for (const rule of plugin.rules) { // Check if rule tries to access dangerous globals const ruleCode = rule.lint.toString(); const dangerousPatterns = [ /eval\s*\(/, /Function\s*\(/, /require\s*\(\s*['"`]child_process/, /require\s*\(\s*['"`]fs/, /process\s*\.\s*exit/, /__dirname/, /__filename/, ]; for (const pattern of dangerousPatterns) { if (pattern.test(ruleCode)) { console.warn(`⚠️ Security Warning: Rule "${rule.id}" contains potentially dangerous pattern: ${pattern}`); } } } } /** * Load multiple plugins from configuration. * * SECURITY: config-declared plugins are executed in-process. They are only * loaded when `options.allowPlugins` is explicitly `true` (an out-of-band * trust gate the linted repo cannot set). Otherwise every enabled plugin is * reported in `result.skipped` and NOT imported. * * @param pluginConfigs Array of plugin configurations * @param options Trust-gate options controlling whether plugins load * @returns Result object with loaded, failed, and skipped plugins */ async loadPluginsFromConfig(pluginConfigs, options = {}) { const result = { loaded: [], failed: [], skipped: [], }; const enabledPlugins = pluginConfigs.filter(config => config.enabled); // Trust gate: without explicit opt-in, do NOT import config-declared // plugins. This prevents a malicious project config from executing // arbitrary code in the cclint process. if (options.allowPlugins !== true) { result.skipped = enabledPlugins.map(config => config.name); return result; } for (const config of enabledPlugins) { try { await this.loadPlugin(config.name, config.options); result.loaded.push(config.name); } catch (error) { result.failed.push({ name: config.name, error: error, }); } } return result; } /** * Unload a plugin and all its rules * @param pluginName The name of the plugin to unload */ unloadPlugin(pluginName) { if (!this.loadedPlugins.has(pluginName)) { console.warn(`Plugin "${pluginName}" is not loaded`); return; } // Unregister all rules from this plugin this.registry.unregisterPlugin(pluginName); // Remove from loaded plugins this.loadedPlugins.delete(pluginName); console.log(`🗑️ Unloaded plugin: ${pluginName}`); } /** * Check if a plugin is currently loaded * @param pluginName The name of the plugin to check * @returns True if the plugin is loaded */ isPluginLoaded(pluginName) { return this.loadedPlugins.has(pluginName); } /** * Get information about a loaded plugin * @param pluginName The name of the plugin * @returns Plugin information or undefined if not loaded */ getPluginInfo(pluginName) { return this.loadedPlugins.get(pluginName); } /** * Get all loaded plugin names * @returns Array of loaded plugin names */ getLoadedPlugins() { return Array.from(this.loadedPlugins.keys()); } /** * Reload a plugin (unload and load again) * @param pluginName The name of the plugin to reload * @param options Optional new configuration */ async reloadPlugin(pluginName, _options) { if (this.isPluginLoaded(pluginName)) { this.unloadPlugin(pluginName); } await this.loadPlugin(pluginName, _options); } /** * Validate plugin structure and requirements * @param plugin The plugin to validate * @param pluginName The name of the plugin for error messages */ validatePlugin(plugin, pluginName) { if (!plugin.name) { throw new Error(`Plugin "${pluginName}" must have a name property`); } if (!plugin.version) { throw new Error(`Plugin "${pluginName}" must have a version property`); } if (!Array.isArray(plugin.rules)) { throw new Error(`Plugin "${pluginName}" must export a rules array`); } if (plugin.rules.length === 0) { throw new Error(`Plugin "${pluginName}" must provide at least one rule`); } // Validate each rule for (const rule of plugin.rules) { if (!rule.id) { throw new Error(`Rule in plugin "${pluginName}" must have an id property`); } if (!rule.description) { throw new Error(`Rule "${rule.id}" in plugin "${pluginName}" must have a description`); } if (typeof rule.lint !== 'function') { throw new Error(`Rule "${rule.id}" in plugin "${pluginName}" must implement lint() method`); } if (typeof rule.generateFixes !== 'function') { throw new Error(`Rule "${rule.id}" in plugin "${pluginName}" must implement generateFixes() method`); } } // Check for duplicate rule IDs within the plugin const ruleIds = new Set(); for (const rule of plugin.rules) { if (ruleIds.has(rule.id)) { throw new Error(`Plugin "${pluginName}" has duplicate rule ID: ${rule.id}`); } ruleIds.add(rule.id); } } /** * Get statistics about loaded plugins */ getStats() { const pluginDetails = Array.from(this.loadedPlugins.entries()).map(([name, plugin]) => ({ name, version: plugin.version, rules: plugin.rules.length, })); return { loadedPlugins: this.loadedPlugins.size, totalRulesFromPlugins: pluginDetails.reduce((sum, plugin) => sum + plugin.rules, 0), pluginDetails, }; } } //# sourceMappingURL=PluginLoader.js.map