fix(plugins): require provenance for official npm trust

Require OpenClaw-owned install provenance before granting official npm plugin scanner trust. Direct npm package names now scan normally; catalog, onboarding, and doctor paths pass explicit provenance.\n\nValidation:\n- pnpm test:serial src/plugins/install.npm-spec.test.ts src/cli/plugins-cli.install.test.ts src/commands/onboarding-plugin-install.test.ts src/commands/doctor/shared/missing-configured-plugin-install.test.ts src/channels/plugins/contracts/channel-catalog.contract.test.ts src/commands/auth-choice.apply.plugin-provider.test.ts\n- pnpm test:serial src/plugins/install.test.ts src/plugins/provider-auth-choices.test.ts src/plugins/provider-install-catalog.test.ts src/commands/channel-setup/plugin-install.test.ts\n- pnpm exec oxfmt --check --threads=1 ...\n- node scripts/run-oxlint.mjs ...\n- Crabbox cbx_6157440c9bbe / run_cbd813956eed: pnpm check:changed passed\n\nThanks @fede-kamel and @vincentkoc.
This commit is contained in:
Vincent Koc
2026-05-02 23:30:45 -07:00
committed by GitHub
parent f249b1c6df
commit 2a22eb68aa
13 changed files with 122 additions and 39 deletions

View File

@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
- Channels/WhatsApp: attach native outbound mention metadata for group text and media captions by resolving `@+<digits>` and `@<digits>` tokens against WhatsApp participant data, including LID groups. Fixes #39879; carries forward #56863. Thanks @kengi1437, @joe2643, and @fridayck.
- Plugins/uninstall: remove empty managed git install parent directories after deleting cloned plugin repos and cover npm/git uninstall residue in Docker plugin lifecycle tests. Thanks @vincentkoc.
- Plugins/install: resolve bare official external plugin IDs such as `brave` through the official catalog when no bundled source is available, so packaged installs fetch the intended scoped npm package instead of an unrelated unscoped package. Fixes #76373. Thanks @bek91 and @vincentkoc.
- Plugins/install: require OpenClaw-owned install provenance before granting official npm plugin scanner trust, so direct npm package names no longer bypass launch-code scanning while catalog, onboarding, and doctor installs stay trusted. Thanks @fede-kamel and @vincentkoc.
- Gateway/sessions: keep async `sessions.list` title and preview hydration bounded to transcript head/tail reads so Control UI polling cannot full-scan large session transcripts every refresh. Thanks @vincentkoc.
- Gateway/sessions: keep agent runtime metadata on lightweight `sessions.list` rows so model-only session patches do not make Control UI lose runtime identity. Thanks @vincentkoc.
- Gateway/sessions: keep bulk `sessions.list` rows lightweight by skipping per-row transcript usage fallback, display model inference, and plugin projection, avoiding event-loop stalls in large session stores. Thanks @Marvinthebored and @vincentkoc.

View File

@@ -39,6 +39,7 @@ export type ChannelPluginCatalogEntry = {
id: string;
pluginId?: string;
origin?: PluginOrigin;
trustedSourceLinkedOfficialInstall?: boolean;
meta: ChannelMeta;
install: ChannelPluginCatalogInstall;
installSource?: PluginInstallSourceInfo;
@@ -203,7 +204,7 @@ function loadOfficialCatalogEntries(options: CatalogOptions): ChannelPluginCatal
? loadCatalogEntriesFromPaths(officialPaths)
: loadOfficialCatalogEntriesFromPaths(officialPaths);
return [...builtInEntries, ...fileEntries]
.map((entry) => buildExternalCatalogEntry(entry))
.map((entry) => buildExternalCatalogEntry(entry, { trustedSourceLinkedOfficialInstall: true }))
.filter((entry): entry is ChannelPluginCatalogEntry => Boolean(entry));
}
@@ -297,6 +298,7 @@ function buildCatalogEntryFromManifest(params: {
packageName?: string;
packageDir?: string;
origin?: PluginOrigin;
trustedSourceLinkedOfficialInstall?: boolean;
workspaceDir?: string;
channel?: PluginPackageChannel;
install?: PluginPackageInstall;
@@ -326,6 +328,9 @@ function buildCatalogEntryFromManifest(params: {
id,
...(pluginId ? { pluginId } : {}),
...(params.origin ? { origin: params.origin } : {}),
...(params.trustedSourceLinkedOfficialInstall
? { trustedSourceLinkedOfficialInstall: true }
: {}),
meta,
install,
installSource: describePluginInstallSource(install, {
@@ -334,10 +339,16 @@ function buildCatalogEntryFromManifest(params: {
};
}
function buildExternalCatalogEntry(entry: ExternalCatalogEntry): ChannelPluginCatalogEntry | null {
function buildExternalCatalogEntry(
entry: ExternalCatalogEntry,
options?: {
trustedSourceLinkedOfficialInstall?: boolean;
},
): ChannelPluginCatalogEntry | null {
const manifest = entry[MANIFEST_KEY];
return buildCatalogEntryFromManifest({
packageName: entry.name,
trustedSourceLinkedOfficialInstall: options?.trustedSourceLinkedOfficialInstall,
channel: manifest?.channel,
install: manifest?.install,
});

View File

@@ -141,6 +141,7 @@ export function describeOfficialFallbackChannelCatalogContract(params: {
expect(entry?.install.npmSpec).toBe(params.npmSpec);
expect(entry?.pluginId).toBeUndefined();
expect(entry?.trustedSourceLinkedOfficialInstall).toBe(true);
});
it("lets external catalogs override shipped fallback channel metadata", () => {
@@ -217,6 +218,7 @@ export function describeOfficialFallbackChannelCatalogContract(params: {
expect(entry?.install.npmSpec).toBe(params.externalNpmSpec);
expect(entry?.meta.label).toBe(params.externalLabel);
expect(entry?.pluginId).toBeUndefined();
expect(entry?.trustedSourceLinkedOfficialInstall).toBeUndefined();
});
it("surfaces package-name drift in external channel catalog install metadata", () => {

View File

@@ -675,6 +675,7 @@ describe("plugins cli install", () => {
expect.objectContaining({
spec: "@openclaw/brave-plugin",
expectedPluginId: "brave",
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
@@ -708,6 +709,7 @@ describe("plugins cli install", () => {
expectedPluginId: "wecom",
expectedIntegrity:
"sha512-bnzfdIEEu1/LFvcdyjaTkyxt27w6c7dqhkPezU62OWaqmcdFsUGR3T55USK/O9pIKsNcnL1Tnu1pqKYCWHFgWQ==",
trustedSourceLinkedOfficialInstall: true,
}),
);
});
@@ -728,6 +730,11 @@ describe("plugins cli install", () => {
await expect(runPluginsCommand(["plugins", "install", "wecom"])).rejects.toThrow("__exit__:1");
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(installHooksFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@wecom/wecom-openclaw-plugin@2026.4.23",
@@ -845,6 +852,11 @@ describe("plugins cli install", () => {
expectedPluginId: "brave",
}),
);
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.not.objectContaining({
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(installPluginFromClawHub).not.toHaveBeenCalled();
});

View File

@@ -279,6 +279,7 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: {
extensionsDir: string;
expectedPluginId?: string;
expectedIntegrity?: string;
trustedSourceLinkedOfficialInstall?: boolean;
runtime?: RuntimeEnv;
}): Promise<{ ok: true } | { ok: false }> {
const result = await installPluginFromNpmSpec({
@@ -287,6 +288,9 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: {
spec: params.spec,
...(params.expectedPluginId ? { expectedPluginId: params.expectedPluginId } : {}),
...(params.expectedIntegrity ? { expectedIntegrity: params.expectedIntegrity } : {}),
...(params.trustedSourceLinkedOfficialInstall
? { trustedSourceLinkedOfficialInstall: true }
: {}),
extensionsDir: params.extensionsDir,
logger: createPluginInstallLogger(params.runtime),
});
@@ -787,6 +791,7 @@ export async function runPluginInstallCommand(params: {
extensionsDir,
expectedPluginId: officialExternalPlan.pluginId,
expectedIntegrity: officialExternalPlan.expectedIntegrity,
trustedSourceLinkedOfficialInstall: true,
runtime,
});
if (!npmResult.ok) {

View File

@@ -33,6 +33,9 @@ function toOnboardingPluginInstallEntry(
pluginId: entry.pluginId ?? entry.id,
label: entry.meta.label,
install: entry.install,
...(entry.trustedSourceLinkedOfficialInstall
? { trustedSourceLinkedOfficialInstall: true }
: {}),
};
}

View File

@@ -125,6 +125,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
npmSpec: "@openclaw/plugin-matrix@1.2.3",
expectedIntegrity: "sha512-test",
},
trustedSourceLinkedOfficialInstall: true,
},
]);
@@ -146,6 +147,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
extensionsDir: "/tmp/openclaw-plugins",
expectedPluginId: "matrix",
expectedIntegrity: "sha512-test",
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
@@ -224,6 +226,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
install: {
npmSpec: "@openclaw/plugin-matrix@1.2.3",
},
trustedSourceLinkedOfficialInstall: true,
},
]);
@@ -240,6 +243,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
spec: "@openclaw/plugin-matrix@1.2.3",
extensionsDir: "/tmp/openclaw-plugins",
expectedPluginId: "matrix",
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
@@ -273,6 +277,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
clawhubSpec: "clawhub:@openclaw/plugin-matrix@stable",
npmSpec: "@openclaw/plugin-matrix@1.2.3",
},
trustedSourceLinkedOfficialInstall: true,
},
]);
@@ -289,6 +294,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect.objectContaining({
spec: "@openclaw/plugin-matrix@1.2.3",
expectedPluginId: "matrix",
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(result.changes).toEqual([
@@ -321,6 +327,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
npmSpec: "@openclaw/twitch",
defaultChoice: "npm",
},
trustedSourceLinkedOfficialInstall: true,
},
]);
@@ -338,6 +345,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect.objectContaining({
spec: "@openclaw/twitch",
expectedPluginId: "twitch",
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(result.changes).toEqual([
@@ -813,6 +821,11 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expectedPluginId: "wecom",
}),
);
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.not.objectContaining({
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(result.changes).toEqual([
'Installed missing configured plugin "wecom" from @wecom/wecom-openclaw-plugin@2026.4.23.',
]);
@@ -863,6 +876,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect.objectContaining({
spec: "@openclaw/codex",
expectedPluginId: "codex",
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
@@ -940,6 +954,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect.objectContaining({
spec: "@openclaw/codex",
expectedPluginId: "codex",
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
@@ -1073,6 +1088,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
install: {
npmSpec: "@openclaw/discord",
},
trustedSourceLinkedOfficialInstall: true,
},
]);
mocks.installPluginFromNpmSpec.mockResolvedValueOnce({
@@ -1132,6 +1148,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect.objectContaining({
spec: "@openclaw/discord",
expectedPluginId: "discord",
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
@@ -1476,6 +1493,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect.objectContaining({
spec: "@openclaw/brave-plugin",
expectedPluginId: "brave",
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(result.changes).toEqual([

View File

@@ -36,6 +36,7 @@ type DownloadableInstallCandidate = {
npmSpec?: string;
clawhubSpec?: string;
expectedIntegrity?: string;
trustedSourceLinkedOfficialInstall?: boolean;
defaultChoice?: PluginPackageInstall["defaultChoice"];
};
@@ -44,12 +45,14 @@ const RUNTIME_PLUGIN_INSTALL_CANDIDATES: readonly DownloadableInstallCandidate[]
pluginId: "acpx",
label: "ACPX Runtime",
npmSpec: "@openclaw/acpx",
trustedSourceLinkedOfficialInstall: true,
},
// Runtime-only configs do not have a provider/channel integration catalog entry.
{
pluginId: "codex",
label: "Codex",
npmSpec: "@openclaw/codex",
trustedSourceLinkedOfficialInstall: true,
},
];
@@ -201,6 +204,9 @@ function collectDownloadableInstallCandidates(params: {
...(entry.install.expectedIntegrity
? { expectedIntegrity: entry.install.expectedIntegrity }
: {}),
...(entry.trustedSourceLinkedOfficialInstall
? { trustedSourceLinkedOfficialInstall: true }
: {}),
...(entry.install.defaultChoice ? { defaultChoice: entry.install.defaultChoice } : {}),
});
}
@@ -229,6 +235,7 @@ function collectDownloadableInstallCandidates(params: {
...(entry.install.expectedIntegrity
? { expectedIntegrity: entry.install.expectedIntegrity }
: {}),
...(entry.origin === "bundled" ? { trustedSourceLinkedOfficialInstall: true } : {}),
...(entry.install.defaultChoice ? { defaultChoice: entry.install.defaultChoice } : {}),
});
}
@@ -256,6 +263,7 @@ function collectDownloadableInstallCandidates(params: {
...(npmSpec ? { npmSpec } : {}),
...(clawhubSpec ? { clawhubSpec } : {}),
...(install.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}),
trustedSourceLinkedOfficialInstall: true,
...(install.defaultChoice ? { defaultChoice: install.defaultChoice } : {}),
});
}
@@ -419,6 +427,9 @@ async function installCandidate(params: {
extensionsDir,
expectedPluginId: candidate.pluginId,
expectedIntegrity: candidate.expectedIntegrity,
...(candidate.trustedSourceLinkedOfficialInstall
? { trustedSourceLinkedOfficialInstall: true }
: {}),
mode: "install",
});
if (!result.ok) {

View File

@@ -200,6 +200,7 @@ describe("ensureOnboardingPluginInstalled", () => {
npmSpec: "@wecom/wecom-openclaw-plugin@1.2.3",
expectedIntegrity: "sha512-wecom",
},
trustedSourceLinkedOfficialInstall: true,
},
prompter: {
select: vi.fn(async () => "npm"),
@@ -211,7 +212,9 @@ describe("ensureOnboardingPluginInstalled", () => {
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@wecom/wecom-openclaw-plugin@1.2.3",
expectedPluginId: "demo-plugin",
expectedIntegrity: "sha512-wecom",
trustedSourceLinkedOfficialInstall: true,
timeoutMs: 300_000,
}),
);

View File

@@ -30,6 +30,7 @@ export type OnboardingPluginInstallEntry = {
pluginId: string;
label: string;
install: PluginPackageInstall;
trustedSourceLinkedOfficialInstall?: boolean;
};
export type OnboardingPluginInstallStatus = "installed" | "skipped" | "failed" | "timed_out";
@@ -585,7 +586,11 @@ async function installPluginFromNpmSpecWithProgress(params: {
installPluginFromNpmSpec({
spec: params.npmSpec,
timeoutMs: ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS,
expectedPluginId: params.entry.pluginId,
expectedIntegrity: params.entry.install.expectedIntegrity,
...(params.entry.trustedSourceLinkedOfficialInstall
? { trustedSourceLinkedOfficialInstall: true }
: {}),
extensionsDir: resolveDefaultPluginExtensionsDir(),
logger: {
info: updateProgress,

View File

@@ -342,7 +342,7 @@ describe("installPluginFromNpmSpec", () => {
});
});
it.each([
const officialLaunchPluginCases = [
{
spec: "@openclaw/acpx",
pluginId: "acpx",
@@ -363,8 +363,10 @@ describe("installPluginFromNpmSpec", () => {
pluginId: "voice-call",
indexJs: `import { spawn } from "node:child_process";\nspawn("ngrok", ["http", "3000"]);`,
},
])(
"allows official npm plugin $spec with reviewed launch code",
];
it.each(officialLaunchPluginCases)(
"blocks direct official npm plugin $spec with launch code without source provenance",
async ({ spec, pluginId, indexJs }) => {
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
const warnings: string[] = [];
@@ -386,6 +388,45 @@ describe("installPluginFromNpmSpec", () => {
},
});
expect(result.ok).toBe(false);
if (result.ok) {
return;
}
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(fs.existsSync(path.join(npmRoot, "node_modules", spec))).toBe(false);
expect(
warnings.some((warning) =>
warning.includes("allowed because it is an official OpenClaw package"),
),
).toBe(false);
},
);
it.each(officialLaunchPluginCases)(
"allows source-linked official npm plugin $spec with reviewed launch code",
async ({ spec, pluginId, indexJs }) => {
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
const warnings: string[] = [];
mockNpmViewAndInstall({
spec,
packageName: spec,
version: "2026.5.2",
pluginId,
npmRoot,
indexJs,
});
const result = await installPluginFromNpmSpec({
spec,
npmDir: npmRoot,
expectedPluginId: pluginId,
trustedSourceLinkedOfficialInstall: true,
logger: {
info: () => {},
warn: (msg: string) => warnings.push(msg),
},
});
expect(result.ok).toBe(true);
if (!result.ok) {
return;

View File

@@ -115,12 +115,6 @@ type PluginInstallPolicyRequest = {
};
const defaultLogger: PluginInstallLogger = {};
const TRUSTED_OFFICIAL_NPM_PLUGIN_PACKAGES = new Map([
["@openclaw/acpx", "acpx"],
["@openclaw/codex", "codex"],
["@openclaw/google-meet", "google-meet"],
["@openclaw/voice-call", "voice-call"],
]);
function ensureOpenClawExtensions(params: { manifest: PackageManifest }):
| {
@@ -196,26 +190,6 @@ function hasPackageRuntimeDependencies(manifest: PackageManifest): boolean {
);
}
function isTrustedOfficialNpmPluginInstall(params: {
installPolicyRequest?: PluginInstallPolicyRequest;
packageName: string;
pluginId: string;
}): boolean {
if (params.installPolicyRequest?.kind !== "plugin-npm") {
return false;
}
const requested = parseRegistryNpmSpec(params.installPolicyRequest.requestedSpecifier ?? "");
if (!requested) {
return false;
}
const expectedPluginId = TRUSTED_OFFICIAL_NPM_PLUGIN_PACKAGES.get(requested.name);
return (
expectedPluginId !== undefined &&
params.packageName === requested.name &&
params.pluginId === expectedPluginId
);
}
function buildBlockedInstallResult(params: {
blocked: NonNullable<NonNullable<InstallSecurityScanResult>["blocked"]>;
}): Extract<InstallPluginResult, { ok: false }> {
@@ -777,19 +751,12 @@ async function validatePackagePluginInstallSource(params: {
const scanMode = params.resolveEffectiveMode
? await params.resolveEffectiveMode(pluginId)
: params.mode;
const trustedOfficialInstall =
params.trustedSourceLinkedOfficialInstall ||
isTrustedOfficialNpmPluginInstall({
installPolicyRequest: params.installPolicyRequest,
packageName: pkgName,
pluginId,
});
const scanResult = await runInstallSourceScan({
subject: `Plugin "${pluginId}"`,
scan: async () =>
await params.runtime.scanPackageInstallSource({
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
trustedSourceLinkedOfficialInstall: trustedOfficialInstall,
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
packageDir: params.packageDir,
pluginId,
logger: params.logger,
@@ -1279,6 +1246,7 @@ export async function installPluginFromNpmSpec(
dependencyScanRootDir: npmRoot,
logger,
expectedPluginId,
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
mode: effectiveMode,
installPolicyRequest: {
kind: "plugin-npm",

View File

@@ -378,6 +378,9 @@ export async function applyAuthChoiceLoadedPluginProvider(
pluginId: installCatalogEntry.pluginId,
label: installCatalogEntry.label,
install: installCatalogEntry.install,
...(installCatalogEntry.origin === "bundled"
? { trustedSourceLinkedOfficialInstall: true }
: {}),
},
prompter: params.prompter,
runtime: params.runtime,