diff --git a/src/plugins/clawhub.test.ts b/src/plugins/clawhub.test.ts index 9d7b2aecf93..d2f7c7a9c39 100644 --- a/src/plugins/clawhub.test.ts +++ b/src/plugins/clawhub.test.ts @@ -255,6 +255,35 @@ describe("installPluginFromClawHub", () => { expect(archiveCleanupMock).toHaveBeenCalledTimes(1); }); + it("marks official source-linked OpenClaw packages as trusted for install scanning", async () => { + fetchClawHubPackageDetailMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + channel: "official", + isOfficial: true, + createdAt: 0, + updatedAt: 0, + verification: { + tier: "source-linked", + sourceRepo: "openclaw/openclaw", + }, + }, + }); + + await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + expect(installPluginFromArchiveMock).toHaveBeenCalledWith( + expect.objectContaining({ + trustedSourceLinkedOfficialInstall: true, + }), + ); + }); + it("resolves explicit ClawHub dist tags before fetching version metadata", async () => { parseClawHubPluginSpecMock.mockReturnValueOnce({ name: "demo", version: "latest" }); fetchClawHubPackageDetailMock.mockResolvedValueOnce({ diff --git a/src/plugins/clawhub.ts b/src/plugins/clawhub.ts index 7b6d8da36c8..05cd687358c 100644 --- a/src/plugins/clawhub.ts +++ b/src/plugins/clawhub.ts @@ -136,6 +136,7 @@ function normalizeClawHubClawPackInstallFields( if (clawpack?.available !== true) { return {}; } + const clawpackSha256 = typeof clawpack.sha256 === "string" ? normalizeClawHubSha256Hex(clawpack.sha256) : null; const clawpackManifestSha256 = @@ -160,6 +161,18 @@ function normalizeClawHubClawPackInstallFields( }; } +function isTrustedSourceLinkedOfficialPackage(pkg: NonNullable) { + const sourceRepo = normalizeOptionalString(pkg.verification?.sourceRepo); + return ( + pkg.channel === "official" && + pkg.isOfficial === true && + pkg.verification?.tier === "source-linked" && + (sourceRepo === "openclaw/openclaw" || + sourceRepo === "github.com/openclaw/openclaw" || + sourceRepo === "https://github.com/openclaw/openclaw") + ); +} + function resolveClawHubClawPackArtifactSha256( clawpack: ClawHubPackageClawPackSummary | null | undefined, ): string | null { @@ -943,6 +956,7 @@ export async function installPluginFromClawHub( const installResult = await installPluginFromArchive({ archivePath: archive.archivePath, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + trustedSourceLinkedOfficialInstall: isTrustedSourceLinkedOfficialPackage(detail.package!), logger: params.logger, mode: params.mode, extensionsDir: params.extensionsDir, diff --git a/src/plugins/install-security-scan.runtime.ts b/src/plugins/install-security-scan.runtime.ts index 30056a429b2..fa46b314249 100644 --- a/src/plugins/install-security-scan.runtime.ts +++ b/src/plugins/install-security-scan.runtime.ts @@ -554,6 +554,7 @@ async function scanDirectoryTarget(params: { function buildBlockedScanResult(params: { builtinScan: BuiltinInstallScan; dangerouslyForceUnsafeInstall?: boolean; + trustedSourceLinkedOfficialInstall?: boolean; targetLabel: string; }): InstallSecurityScanResult | undefined { if (params.builtinScan.status === "error") { @@ -568,7 +569,7 @@ function buildBlockedScanResult(params: { }; } if (params.builtinScan.critical > 0) { - if (params.dangerouslyForceUnsafeInstall) { + if (params.dangerouslyForceUnsafeInstall || params.trustedSourceLinkedOfficialInstall) { return undefined; } return { @@ -594,6 +595,16 @@ function logDangerousForceUnsafeInstall(params: { ); } +function logTrustedSourceLinkedOfficialInstall(params: { + findings: Array<{ file: string; line: number; message: string; severity: string }>; + logger: InstallScanLogger; + targetLabel: string; +}) { + params.logger.warn?.( + `WARNING: ${params.targetLabel} allowed because it is an official source-linked ClawHub package: ${buildCriticalDetails({ findings: params.findings })}`, + ); +} + function resolveBuiltinScanDecision( params: InstallSafetyOverrides & { builtinScan: BuiltinInstallScan; @@ -604,6 +615,7 @@ function resolveBuiltinScanDecision( const builtinBlocked = buildBlockedScanResult({ builtinScan: params.builtinScan, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, targetLabel: params.targetLabel, }); if (params.dangerouslyForceUnsafeInstall && params.builtinScan.critical > 0) { @@ -612,6 +624,12 @@ function resolveBuiltinScanDecision( logger: params.logger, targetLabel: params.targetLabel, }); + } else if (params.trustedSourceLinkedOfficialInstall && params.builtinScan.critical > 0) { + logTrustedSourceLinkedOfficialInstall({ + findings: params.builtinScan.findings, + logger: params.logger, + targetLabel: params.targetLabel, + }); } return builtinBlocked; } @@ -810,6 +828,7 @@ export async function scanPackageInstallSourceRuntime( builtinScan, logger: params.logger, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, targetLabel: `Plugin "${params.pluginId}" installation`, }); @@ -913,6 +932,7 @@ export async function scanSkillInstallSourceRuntime(params: { const builtinBlocked = buildBlockedScanResult({ builtinScan, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + trustedSourceLinkedOfficialInstall: false, targetLabel: `Skill "${params.skillName}" installation`, }); if (params.dangerouslyForceUnsafeInstall && builtinScan.critical > 0) { diff --git a/src/plugins/install-security-scan.types.ts b/src/plugins/install-security-scan.types.ts index 4b727611a5d..a4a4146441d 100644 --- a/src/plugins/install-security-scan.types.ts +++ b/src/plugins/install-security-scan.types.ts @@ -1,3 +1,4 @@ export type InstallSafetyOverrides = { dangerouslyForceUnsafeInstall?: boolean; + trustedSourceLinkedOfficialInstall?: boolean; }; diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 1c6dd221b37..9e01283e38c 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -213,11 +213,13 @@ async function installFromDirWithWarnings(params: { pluginDir: string; extensionsDir: string; dangerouslyForceUnsafeInstall?: boolean; + trustedSourceLinkedOfficialInstall?: boolean; mode?: "install" | "update"; }) { const warnings: string[] = []; const result = await installPluginFromDir({ dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, dirPath: params.pluginDir, extensionsDir: params.extensionsDir, mode: params.mode, @@ -1871,6 +1873,36 @@ describe("installPluginFromArchive", () => { ).toBe(true); }); + it("allows package installs with dangerous code patterns for trusted source-linked official installs", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "official-dangerous-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + }), + ); + fs.writeFileSync( + path.join(pluginDir, "index.js"), + `const { spawn } = require("child_process");\nspawn("google-chrome", []);`, + ); + + const { result, warnings } = await installFromDirWithWarnings({ + pluginDir, + extensionsDir, + trustedSourceLinkedOfficialInstall: true, + }); + + expect(result.ok).toBe(true); + expect( + warnings.some((warning) => + warning.includes("allowed because it is an official source-linked ClawHub package"), + ), + ).toBe(true); + }); + it("does not flag the real qa-matrix plugin as dangerous install code", async () => { const sourcePluginDir = path.resolve(process.cwd(), "extensions", "qa-matrix"); const pluginDir = path.join(suiteTempRootTracker.makeTempDir(), "qa-matrix"); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 46c6f877366..2d6821082aa 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -207,6 +207,7 @@ type PackageInstallCommonParams = InstallSafetyOverrides & { type FileInstallCommonParams = Pick< PackageInstallCommonParams, | "dangerouslyForceUnsafeInstall" + | "trustedSourceLinkedOfficialInstall" | "extensionsDir" | "logger" | "mode" @@ -219,6 +220,7 @@ function pickPackageInstallCommonParams( ): PackageInstallCommonParams { return { dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, extensionsDir: params.extensionsDir, npmDir: params.npmDir, timeoutMs: params.timeoutMs, @@ -567,6 +569,7 @@ async function validatePackagePluginInstallSource(params: { expectedPluginId?: string; requirePluginManifest?: boolean; dangerouslyForceUnsafeInstall?: boolean; + trustedSourceLinkedOfficialInstall?: boolean; installPolicyRequest?: PluginInstallPolicyRequest; logger: PluginInstallLogger; mode: "install" | "update"; @@ -691,6 +694,7 @@ async function validatePackagePluginInstallSource(params: { scan: async () => await params.runtime.scanPackageInstallSource({ dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, packageDir: params.packageDir, pluginId, logger: params.logger, @@ -762,6 +766,7 @@ export async function installPluginFromInstalledPackageDir( expectedPluginId: params.expectedPluginId, requirePluginManifest: params.requirePluginManifest, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, installPolicyRequest: params.installPolicyRequest, logger, mode: params.mode ?? "install", @@ -823,6 +828,7 @@ async function installPluginFromPackageDir( expectedPluginId: params.expectedPluginId, requirePluginManifest: params.requirePluginManifest, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, installPolicyRequest: params.installPolicyRequest, logger, mode,