From 150faba8d18d3242c36643c18a9966fc0429a0bd Mon Sep 17 00:00:00 2001 From: George Zhang Date: Sun, 29 Mar 2026 10:50:12 -0700 Subject: [PATCH] plugins: enrich before_install policy context --- src/agents/skills-install.ts | 84 +++++- src/plugins/install-security-scan.runtime.ts | 281 +++++++++++++++---- src/plugins/install-security-scan.ts | 27 ++ src/plugins/install.runtime.ts | 7 +- src/plugins/install.ts | 77 ++++- src/plugins/types.ts | 101 +++++-- 6 files changed, 495 insertions(+), 82 deletions(-) diff --git a/src/agents/skills-install.ts b/src/agents/skills-install.ts index 9fcd24d0a9f..fcf02c342cf 100644 --- a/src/agents/skills-install.ts +++ b/src/agents/skills-install.ts @@ -56,26 +56,44 @@ function formatScanFindingDetail( return `${finding.message} (${filePath}:${finding.line})`; } +type SkillScanFinding = { + ruleId: string; + severity: "info" | "warn" | "critical"; + file: string; + line: number; + message: string; +}; + +type SkillBuiltinScan = { + status: "ok" | "error"; + scannedFiles: number; + critical: number; + warn: number; + info: number; + findings: SkillScanFinding[]; + error?: string; +}; + type SkillScanResult = { warnings: string[]; - findings: Array<{ - ruleId: string; - severity: "info" | "warn" | "critical"; - file: string; - line: number; - message: string; - }>; + builtinScan: SkillBuiltinScan; }; async function collectSkillInstallScanWarnings(entry: SkillEntry): Promise { const warnings: string[] = []; - const findings: SkillScanResult["findings"] = []; const skillName = entry.skill.name; const skillDir = path.resolve(entry.skill.baseDir); try { const summary = await scanDirectoryWithSummary(skillDir); - findings.push(...summary.findings); + const builtinScan: SkillBuiltinScan = { + status: "ok", + scannedFiles: summary.scannedFiles, + critical: summary.critical, + warn: summary.warn, + info: summary.info, + findings: summary.findings, + }; if (summary.critical > 0) { const criticalDetails = summary.findings .filter((finding) => finding.severity === "critical") @@ -89,13 +107,24 @@ async function collectSkillInstallScanWarnings(entry: SkillEntry): Promise { + try { + const scanSummary = await scanDirectoryWithSummary(params.path, { + includeFiles: params.includeFiles, + }); + const builtinScan = buildBuiltinScanFromSummary(scanSummary); + if (scanSummary.critical > 0) { + params.logger.warn?.( + `${params.warningMessage}: ${buildCriticalDetails({ findings: scanSummary.findings })}`, + ); + } else if (scanSummary.warn > 0) { + params.logger.warn?.( + params.suspiciousMessage + .replace("{count}", String(scanSummary.warn)) + .replace("{target}", params.targetName), + ); + } + return builtinScan; + } catch (err) { + params.logger.warn?.(params.scanFailureMessage.replace("{error}", String(err))); + return buildBuiltinScanFromError(err); + } +} + +async function scanFileTarget(params: { + logger: InstallScanLogger; + path: string; + scanFailureMessage: string; + suspiciousMessage: string; + targetName: string; + warningMessage: string; +}): Promise { + const directory = path.dirname(params.path); + return await scanDirectoryTarget({ + includeFiles: [params.path], + logger: params.logger, + path: directory, + scanFailureMessage: params.scanFailureMessage, + suspiciousMessage: params.suspiciousMessage, + targetName: params.targetName, + warningMessage: params.warningMessage, + }); +} + async function runBeforeInstallHook(params: { logger: InstallScanLogger; installLabel: string; source: string; - sourceDir: string; + sourcePath: string; + sourcePathKind: "file" | "directory"; targetName: string; targetType: "skill" | "plugin"; - builtinFindings: InstallScanFinding[]; + requestKind: PluginInstallRequestKind; + requestMode: "install" | "update"; + requestedSpecifier?: string; + builtinScan: BuiltinInstallScan; + skill?: { + installId: string; + installSpec?: { + id?: string; + kind: "brew" | "node" | "go" | "uv" | "download"; + label?: string; + bins?: string[]; + os?: string[]; + formula?: string; + package?: string; + module?: string; + url?: string; + archive?: string; + extract?: boolean; + stripComponents?: number; + targetDir?: string; + }; + }; + plugin?: { + contentType: "bundle" | "package" | "file"; + pluginId: string; + packageName?: string; + manifestId?: string; + version?: string; + extensions?: string[]; + }; }): Promise { const hookRunner = getGlobalHookRunner(); if (!hookRunner?.hasHooks("before_install")) { @@ -50,10 +178,22 @@ async function runBeforeInstallHook(params: { targetName: params.targetName, targetType: params.targetType, source: params.source, - sourceDir: params.sourceDir, - builtinFindings: params.builtinFindings, + sourcePath: params.sourcePath, + sourcePathKind: params.sourcePathKind, + request: { + kind: params.requestKind, + mode: params.requestMode, + ...(params.requestedSpecifier ? { requestedSpecifier: params.requestedSpecifier } : {}), + }, + builtinScan: params.builtinScan, + ...(params.skill ? { skill: params.skill } : {}), + ...(params.plugin ? { plugin: params.plugin } : {}), + }, + { + source: params.source, + targetType: params.targetType, + requestKind: params.requestKind, }, - { source: params.source, targetType: params.targetType }, ); if (hookResult?.block) { const reason = hookResult.blockReason || "Installation blocked by plugin hook"; @@ -80,34 +220,38 @@ export async function scanBundleInstallSourceRuntime(params: { logger: InstallScanLogger; pluginId: string; sourceDir: string; + requestKind?: PluginInstallRequestKind; + requestedSpecifier?: string; + mode?: "install" | "update"; + version?: string; }): Promise { - let builtinFindings: InstallScanFinding[] = []; - try { - const scanSummary = await scanDirectoryWithSummary(params.sourceDir); - builtinFindings = scanSummary.findings; - if (scanSummary.critical > 0) { - params.logger.warn?.( - `WARNING: Bundle "${params.pluginId}" contains dangerous code patterns: ${buildCriticalDetails({ findings: scanSummary.findings })}`, - ); - } else if (scanSummary.warn > 0) { - params.logger.warn?.( - `Bundle "${params.pluginId}" has ${scanSummary.warn} suspicious code pattern(s). Run "openclaw security audit --deep" for details.`, - ); - } - } catch (err) { - params.logger.warn?.( - `Bundle "${params.pluginId}" code safety scan failed (${String(err)}). Installation continues; run "openclaw security audit --deep" after install.`, - ); - } + const builtinScan = await scanDirectoryTarget({ + logger: params.logger, + path: params.sourceDir, + scanFailureMessage: `Bundle "${params.pluginId}" code safety scan failed ({error}). Installation continues; run "openclaw security audit --deep" after install.`, + suspiciousMessage: `Bundle "{target}" has {count} suspicious code pattern(s). Run "openclaw security audit --deep" for details.`, + targetName: params.pluginId, + warningMessage: `WARNING: Bundle "${params.pluginId}" contains dangerous code patterns`, + }); return await runBeforeInstallHook({ logger: params.logger, installLabel: `Bundle "${params.pluginId}" installation`, source: "plugin-bundle", - sourceDir: params.sourceDir, + sourcePath: params.sourceDir, + sourcePathKind: "directory", targetName: params.pluginId, targetType: "plugin", - builtinFindings, + requestKind: params.requestKind ?? "plugin-dir", + requestMode: params.mode ?? "install", + requestedSpecifier: params.requestedSpecifier, + builtinScan, + plugin: { + contentType: "bundle", + pluginId: params.pluginId, + manifestId: params.pluginId, + ...(params.version ? { version: params.version } : {}), + }, }); } @@ -116,6 +260,12 @@ export async function scanPackageInstallSourceRuntime(params: { logger: InstallScanLogger; packageDir: string; pluginId: string; + requestKind?: PluginInstallRequestKind; + requestedSpecifier?: string; + mode?: "install" | "update"; + packageName?: string; + manifestId?: string; + version?: string; }): Promise { const forcedScanEntries: string[] = []; for (const entry of params.extensions) { @@ -134,34 +284,71 @@ export async function scanPackageInstallSourceRuntime(params: { forcedScanEntries.push(resolvedEntry); } - let builtinFindings: InstallScanFinding[] = []; - try { - const scanSummary = await scanDirectoryWithSummary(params.packageDir, { - includeFiles: forcedScanEntries, - }); - builtinFindings = scanSummary.findings; - if (scanSummary.critical > 0) { - params.logger.warn?.( - `WARNING: Plugin "${params.pluginId}" contains dangerous code patterns: ${buildCriticalDetails({ findings: scanSummary.findings })}`, - ); - } else if (scanSummary.warn > 0) { - params.logger.warn?.( - `Plugin "${params.pluginId}" has ${scanSummary.warn} suspicious code pattern(s). Run "openclaw security audit --deep" for details.`, - ); - } - } catch (err) { - params.logger.warn?.( - `Plugin "${params.pluginId}" code safety scan failed (${String(err)}). Installation continues; run "openclaw security audit --deep" after install.`, - ); - } + const builtinScan = await scanDirectoryTarget({ + includeFiles: forcedScanEntries, + logger: params.logger, + path: params.packageDir, + scanFailureMessage: `Plugin "${params.pluginId}" code safety scan failed ({error}). Installation continues; run "openclaw security audit --deep" after install.`, + suspiciousMessage: `Plugin "{target}" has {count} suspicious code pattern(s). Run "openclaw security audit --deep" for details.`, + targetName: params.pluginId, + warningMessage: `WARNING: Plugin "${params.pluginId}" contains dangerous code patterns`, + }); return await runBeforeInstallHook({ logger: params.logger, installLabel: `Plugin "${params.pluginId}" installation`, source: "plugin-package", - sourceDir: params.packageDir, + sourcePath: params.packageDir, + sourcePathKind: "directory", targetName: params.pluginId, targetType: "plugin", - builtinFindings, + requestKind: params.requestKind ?? "plugin-dir", + requestMode: params.mode ?? "install", + requestedSpecifier: params.requestedSpecifier, + builtinScan, + plugin: { + contentType: "package", + pluginId: params.pluginId, + ...(params.packageName ? { packageName: params.packageName } : {}), + ...(params.manifestId ? { manifestId: params.manifestId } : {}), + ...(params.version ? { version: params.version } : {}), + extensions: params.extensions.slice(), + }, + }); +} + +export async function scanFileInstallSourceRuntime(params: { + filePath: string; + logger: InstallScanLogger; + mode?: "install" | "update"; + pluginId: string; + requestedSpecifier?: string; +}): Promise { + const builtinScan = await scanFileTarget({ + logger: params.logger, + path: params.filePath, + scanFailureMessage: `Plugin file "${params.pluginId}" code safety scan failed ({error}). Installation continues; run "openclaw security audit --deep" after install.`, + suspiciousMessage: `Plugin file "{target}" has {count} suspicious code pattern(s). Run "openclaw security audit --deep" for details.`, + targetName: params.pluginId, + warningMessage: `WARNING: Plugin file "${params.pluginId}" contains dangerous code patterns`, + }); + + return await runBeforeInstallHook({ + logger: params.logger, + installLabel: `Plugin file "${params.pluginId}" installation`, + source: "plugin-file", + sourcePath: params.filePath, + sourcePathKind: "file", + targetName: params.pluginId, + targetType: "plugin", + requestKind: "plugin-file", + requestMode: params.mode ?? "install", + requestedSpecifier: params.requestedSpecifier, + builtinScan, + plugin: { + contentType: "file", + pluginId: params.pluginId, + extensions: [path.basename(params.filePath)], + }, }); } diff --git a/src/plugins/install-security-scan.ts b/src/plugins/install-security-scan.ts index 3ae9f3b21c8..1cd6dce6e3e 100644 --- a/src/plugins/install-security-scan.ts +++ b/src/plugins/install-security-scan.ts @@ -8,6 +8,12 @@ export type InstallSecurityScanResult = { }; }; +export type PluginInstallRequestKind = + | "plugin-dir" + | "plugin-archive" + | "plugin-file" + | "plugin-npm"; + async function loadInstallSecurityScanRuntime() { return await import("./install-security-scan.runtime.js"); } @@ -16,6 +22,10 @@ export async function scanBundleInstallSource(params: { logger: InstallScanLogger; pluginId: string; sourceDir: string; + requestKind?: PluginInstallRequestKind; + requestedSpecifier?: string; + mode?: "install" | "update"; + version?: string; }): Promise { const { scanBundleInstallSourceRuntime } = await loadInstallSecurityScanRuntime(); return await scanBundleInstallSourceRuntime(params); @@ -26,7 +36,24 @@ export async function scanPackageInstallSource(params: { logger: InstallScanLogger; packageDir: string; pluginId: string; + requestKind?: PluginInstallRequestKind; + requestedSpecifier?: string; + mode?: "install" | "update"; + packageName?: string; + manifestId?: string; + version?: string; }): Promise { const { scanPackageInstallSourceRuntime } = await loadInstallSecurityScanRuntime(); return await scanPackageInstallSourceRuntime(params); } + +export async function scanFileInstallSource(params: { + filePath: string; + logger: InstallScanLogger; + mode?: "install" | "update"; + pluginId: string; + requestedSpecifier?: string; +}): Promise { + const { scanFileInstallSourceRuntime } = await loadInstallSecurityScanRuntime(); + return await scanFileInstallSourceRuntime(params); +} diff --git a/src/plugins/install.runtime.ts b/src/plugins/install.runtime.ts index 81ba710f943..b63a250388a 100644 --- a/src/plugins/install.runtime.ts +++ b/src/plugins/install.runtime.ts @@ -22,7 +22,11 @@ import { import { validateRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { resolveCompatibilityHostVersion, resolveRuntimeServiceVersion } from "../version.js"; import { detectBundleManifestFormat, loadBundleManifest } from "./bundle-manifest.js"; -import { scanBundleInstallSource, scanPackageInstallSource } from "./install-security-scan.js"; +import { + scanBundleInstallSource, + scanFileInstallSource, + scanPackageInstallSource, +} from "./install-security-scan.js"; import { getPackageManifestMetadata, loadPluginManifest, @@ -56,6 +60,7 @@ export { resolveRuntimeServiceVersion, resolveTimedInstallModeOptions, scanBundleInstallSource, + scanFileInstallSource, scanPackageInstallSource, validateRegistryNpmSpec, withExtractedArchiveRoot, diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 9ae1db5094c..66ba0196e6a 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -73,6 +73,11 @@ export type PluginNpmIntegrityDriftParams = { resolution: NpmSpecResolution; }; +type PluginInstallPolicyRequest = { + kind: "plugin-dir" | "plugin-archive" | "plugin-file" | "plugin-npm"; + requestedSpecifier?: string; +}; + const defaultLogger: PluginInstallLogger = {}; function safeFileName(input: string): string { return safeDirName(input); @@ -214,11 +219,12 @@ type PackageInstallCommonParams = { mode?: "install" | "update"; dryRun?: boolean; expectedPluginId?: string; + installPolicyRequest?: PluginInstallPolicyRequest; }; type FileInstallCommonParams = Pick< PackageInstallCommonParams, - "extensionsDir" | "logger" | "mode" | "dryRun" + "extensionsDir" | "logger" | "mode" | "dryRun" | "installPolicyRequest" >; function pickPackageInstallCommonParams( @@ -231,6 +237,7 @@ function pickPackageInstallCommonParams( mode: params.mode, dryRun: params.dryRun, expectedPluginId: params.expectedPluginId, + installPolicyRequest: params.installPolicyRequest, }; } @@ -240,6 +247,7 @@ function pickFileInstallCommonParams(params: FileInstallCommonParams): FileInsta logger: params.logger, mode: params.mode, dryRun: params.dryRun, + installPolicyRequest: params.installPolicyRequest, }; } @@ -380,6 +388,10 @@ async function installBundleFromSourceDir( sourceDir: params.sourceDir, pluginId, logger, + requestKind: params.installPolicyRequest?.kind, + requestedSpecifier: params.installPolicyRequest?.requestedSpecifier, + mode, + version: manifestRes.manifest.version, }); if (scanResult?.blocked) { return { ok: false, error: scanResult.blocked.reason }; @@ -553,6 +565,12 @@ async function installPluginFromPackageDir( pluginId, logger, extensions, + requestKind: params.installPolicyRequest?.kind, + requestedSpecifier: params.installPolicyRequest?.requestedSpecifier, + mode, + packageName: pkgName || undefined, + manifestId: manifestPluginId, + version: typeof manifest.version === "string" ? manifest.version : undefined, }); if (scanResult?.blocked) { return { ok: false, error: scanResult.blocked.reason }; @@ -603,6 +621,10 @@ export async function installPluginFromArchive( const logger = params.logger ?? defaultLogger; const timeoutMs = params.timeoutMs ?? 120_000; const mode = params.mode ?? "install"; + const installPolicyRequest = params.installPolicyRequest ?? { + kind: "plugin-archive", + requestedSpecifier: params.archivePath, + }; const archivePathResult = await runtime.resolveArchiveSourcePath(params.archivePath); if (!archivePathResult.ok) { return archivePathResult; @@ -625,6 +647,7 @@ export async function installPluginFromArchive( mode, dryRun: params.dryRun, expectedPluginId: params.expectedPluginId, + installPolicyRequest, }), }), }); @@ -637,6 +660,10 @@ export async function installPluginFromDir( ): Promise { const runtime = await loadPluginInstallRuntime(); const dirPath = resolveUserPath(params.dirPath); + const installPolicyRequest = params.installPolicyRequest ?? { + kind: "plugin-dir", + requestedSpecifier: params.dirPath, + }; if (!(await runtime.fileExists(dirPath))) { return { ok: false, error: `directory not found: ${dirPath}` }; } @@ -647,7 +674,10 @@ export async function installPluginFromDir( return await installPluginFromSourceDir({ sourceDir: dirPath, - ...pickPackageInstallCommonParams(params), + ...pickPackageInstallCommonParams({ + ...params, + installPolicyRequest, + }), }); } @@ -657,11 +687,16 @@ export async function installPluginFromFile(params: { logger?: PluginInstallLogger; mode?: "install" | "update"; dryRun?: boolean; + installPolicyRequest?: PluginInstallPolicyRequest; }): Promise { const runtime = await loadPluginInstallRuntime(); const { logger, mode, dryRun } = runtime.resolveInstallModeOptions(params, defaultLogger); const filePath = resolveUserPath(params.filePath); + const installPolicyRequest = params.installPolicyRequest ?? { + kind: "plugin-file", + requestedSpecifier: params.filePath, + }; if (!(await runtime.fileExists(filePath))) { return { ok: false, error: `file not found: ${filePath}` }; } @@ -692,6 +727,23 @@ export async function installPluginFromFile(params: { return buildFileInstallResult(pluginId, targetFile); } + try { + const scanResult = await runtime.scanFileInstallSource({ + filePath, + logger, + mode, + pluginId, + requestedSpecifier: installPolicyRequest.requestedSpecifier, + }); + if (scanResult?.blocked) { + return { ok: false, error: scanResult.blocked.reason }; + } + } catch (err) { + logger.warn?.( + `Plugin file "${pluginId}" code safety scan failed (${String(err)}). Installation continues; run "openclaw security audit --deep" after install.`, + ); + } + logger.info?.(`Installing to ${targetFile}…`); try { await runtime.writeFileFromPathWithinRoot({ @@ -734,6 +786,10 @@ export async function installPluginFromNpmSpec(params: { } logger.info?.(`Downloading ${spec}…`); + const installPolicyRequest: PluginInstallPolicyRequest = { + kind: "plugin-npm", + requestedSpecifier: spec, + }; const flowResult = await runtime.installFromNpmSpecArchiveWithInstaller({ tempDirPrefix: "openclaw-npm-pack-", spec, @@ -751,6 +807,7 @@ export async function installPluginFromNpmSpec(params: { mode, dryRun, expectedPluginId, + installPolicyRequest, }, }); const finalized = runtime.finalizeNpmSpecArchiveInstall(flowResult); @@ -781,6 +838,10 @@ export async function installPluginFromPath( return await installPluginFromDir({ dirPath: resolved, ...packageInstallOptions, + installPolicyRequest: { + kind: "plugin-dir", + requestedSpecifier: params.path, + }, }); } @@ -789,11 +850,21 @@ export async function installPluginFromPath( return await installPluginFromArchive({ archivePath: resolved, ...packageInstallOptions, + installPolicyRequest: { + kind: "plugin-archive", + requestedSpecifier: params.path, + }, }); } return await installPluginFromFile({ filePath: resolved, - ...pickFileInstallCommonParams(params), + ...pickFileInstallCommonParams({ + ...params, + installPolicyRequest: { + kind: "plugin-file", + requestedSpecifier: params.path, + }, + }), }); } diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 8b1f58ccfd0..065c0dc4ac5 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -2340,11 +2340,82 @@ export type PluginHookGatewayStopEvent = { }; export type PluginInstallTargetType = "skill" | "plugin"; +export type PluginInstallRequestKind = + | "skill-install" + | "plugin-dir" + | "plugin-archive" + | "plugin-file" + | "plugin-npm"; +export type PluginInstallSourcePathKind = "file" | "directory"; + +export type PluginInstallFinding = { + ruleId: string; + severity: "info" | "warn" | "critical"; + file: string; + line: number; + message: string; +}; + +export type PluginHookBeforeInstallRequest = { + /** Original install entrypoint/provenance. */ + kind: PluginInstallRequestKind; + /** Install mode requested by the caller. */ + mode: "install" | "update"; + /** Raw user-facing specifier or path when available. */ + requestedSpecifier?: string; +}; + +export type PluginHookBeforeInstallBuiltinScan = { + /** Whether the built-in scan completed successfully. */ + status: "ok" | "error"; + /** Number of files the built-in scanner actually inspected. */ + scannedFiles: number; + critical: number; + warn: number; + info: number; + findings: PluginInstallFinding[]; + /** Scanner failure reason when status=`error`. */ + error?: string; +}; + +export type PluginHookBeforeInstallSkillInstallSpec = { + id?: string; + kind: "brew" | "node" | "go" | "uv" | "download"; + label?: string; + bins?: string[]; + os?: string[]; + formula?: string; + package?: string; + module?: string; + url?: string; + archive?: string; + extract?: boolean; + stripComponents?: number; + targetDir?: string; +}; + +export type PluginHookBeforeInstallSkill = { + installId: string; + installSpec?: PluginHookBeforeInstallSkillInstallSpec; +}; + +export type PluginHookBeforeInstallPlugin = { + /** Canonical plugin id OpenClaw will install under. */ + pluginId: string; + /** Normalized installable content shape after source resolution. */ + contentType: "bundle" | "package" | "file"; + packageName?: string; + manifestId?: string; + version?: string; + extensions?: string[]; +}; // before_install hook export type PluginHookBeforeInstallContext = { /** Category of install target being checked. */ targetType: PluginInstallTargetType; + /** Original install entrypoint/provenance. */ + requestKind: PluginInstallRequestKind; /** Origin of the install target (e.g. "openclaw-bundled", "plugin-package"). */ source?: string; }; @@ -2354,29 +2425,25 @@ export type PluginHookBeforeInstallEvent = { targetType: PluginInstallTargetType; /** Human-readable skill or plugin name. */ targetName: string; - /** Absolute path to the install target source directory being scanned. */ - sourceDir: string; + /** Absolute path to the install target content being scanned. */ + sourcePath: string; + /** Whether the install target content is a file or directory. */ + sourcePathKind: PluginInstallSourcePathKind; /** Origin of the install target (e.g. "openclaw-bundled", "plugin-package"). */ source?: string; - /** Findings from the built-in scanner, provided for augmentation. */ - builtinFindings: Array<{ - ruleId: string; - severity: "info" | "warn" | "critical"; - file: string; - line: number; - message: string; - }>; + /** Install request provenance and caller mode. */ + request: PluginHookBeforeInstallRequest; + /** Structured result of the built-in scanner. */ + builtinScan: PluginHookBeforeInstallBuiltinScan; + /** Present when targetType=`skill`. */ + skill?: PluginHookBeforeInstallSkill; + /** Present when targetType=`plugin`. */ + plugin?: PluginHookBeforeInstallPlugin; }; export type PluginHookBeforeInstallResult = { /** Additional findings to merge with built-in scanner results. */ - findings?: Array<{ - ruleId: string; - severity: "info" | "warn" | "critical"; - file: string; - line: number; - message: string; - }>; + findings?: PluginInstallFinding[]; /** If true, block the installation entirely. */ block?: boolean; /** Human-readable reason for blocking. */