feat(tui): confirm before installing third-party plugins (#1088)

* feat(tui): confirm before installing third-party plugins

* chore: add changeset for third-party plugin install confirmation

* docs: note third-party plugin install confirmation prompt

* fix: harden third-party plugin install confirmation
This commit is contained in:
qer 2026-06-25 13:48:23 +08:00 committed by GitHub
parent f1fad7222c
commit 0030f76c5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 373 additions and 40 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---
Add a confirmation prompt before installing third-party plugins.

View file

@ -4,9 +4,11 @@ import { isAbsolute, join, resolve } from 'node:path';
import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk';
import {
PluginInstallTrustConfirmComponent,
PluginMcpSelectorComponent,
PluginRemoveConfirmComponent,
PluginsPanelComponent,
type PluginInstallTrustConfirmResult,
type PluginMcpSelection,
type PluginRemoveConfirmResult,
type PluginsPanelSelection,
@ -18,7 +20,7 @@ import {
} from '../components/messages/plugins-status-panel';
import { UsagePanelComponent } from '../components/messages/usage-panel';
import { formatErrorMessage } from '../utils/event-payload';
import { formatPluginSourceLabel } from '../utils/plugin-source-label';
import { formatPluginSourceLabel, isOfficialPluginSource } from '../utils/plugin-source-label';
import { loadPluginMarketplace } from '#/utils/plugin-marketplace';
import type { SlashCommandHost } from './dispatch';
@ -63,6 +65,10 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri
host.showError('Usage: /plugins install <local-path-or-zip-url>');
return;
}
if (!(await confirmInstallTrust(host, source, isOfficialPluginSource(source)))) {
host.showStatus('Install cancelled.');
return;
}
const spinner = host.showProgressSpinner(`Installing plugin from ${truncateForStatus(source)}`);
try {
await installPluginFromSource(host, source);
@ -266,6 +272,67 @@ async function confirmRemovePlugin(host: SlashCommandHost, id: string): Promise<
});
}
async function confirmInstallTrust(
host: SlashCommandHost,
label: string,
official: boolean,
): Promise<boolean> {
// Kimi-built official plugins are trusted implicitly; anything else requires
// the user to explicitly opt in via the trust prompt.
if (official) return true;
return new Promise((resolveConfirmed) => {
host.mountEditorReplacement(
new PluginInstallTrustConfirmComponent({
label,
onDone: (result: PluginInstallTrustConfirmResult) => {
host.restoreEditor();
resolveConfirmed(result.kind === 'confirm');
},
}),
);
});
}
async function installFromPanel(
host: SlashCommandHost,
panel: PluginsPanelComponent,
source: string,
label: string,
official: boolean,
): Promise<void> {
if (!(await confirmInstallTrust(host, label, official))) {
host.showStatus(`Install cancelled: ${label}.`);
host.restoreEditor();
return;
}
// Official installs keep the panel mounted and show the inline installing
// state; third-party installs pass through a trust prompt that replaces the
// panel, so fall back to a transcript status for those.
if (official) {
panel.setInstalling(truncateForStatus(label));
} else {
host.showStatus(`Installing or updating ${label} from marketplace...`);
}
host.state.ui.requestRender();
try {
await installPluginFromSource(host, source);
} catch (error) {
if (official) {
panel.clearInstalling();
host.state.ui.requestRender();
} else {
// The trust prompt replaced the panel; re-mount it so the user can retry
// instead of being dropped back at the editor.
host.mountEditorReplacement(panel);
}
host.showError(`Failed to install ${label}: ${formatErrorMessage(error)}`);
return;
}
// Close the panel after installing so the result status and the
// "/reload or /new" tip are visible in the transcript.
host.restoreEditor();
}
async function applyPluginEnabled(
host: SlashCommandHost,
id: string,
@ -326,36 +393,24 @@ async function handlePluginsPanelSelection(
await reloadPlugins(host);
await showPluginsPicker(host, { initialTab: 'installed' });
return;
case 'install': {
panel.setInstalling(selection.entry.displayName);
host.state.ui.requestRender();
try {
await installPluginFromSource(host, selection.entry.source);
} catch (error) {
panel.clearInstalling();
host.state.ui.requestRender();
host.showError(`Failed to install ${selection.entry.displayName}: ${formatErrorMessage(error)}`);
return;
}
// Close the panel after installing so the success notice and the
// "/reload or /new" / post-install tip are visible in the transcript.
host.restoreEditor();
case 'install':
await installFromPanel(
host,
panel,
selection.entry.source,
selection.entry.displayName,
isOfficialPluginSource(selection.entry.source),
);
return;
}
case 'install-source': {
panel.setInstalling(truncateForStatus(selection.source));
host.state.ui.requestRender();
try {
await installPluginFromSource(host, selection.source);
} catch (error) {
panel.clearInstalling();
host.state.ui.requestRender();
host.showError(`Failed to install from ${truncateForStatus(selection.source)}: ${formatErrorMessage(error)}`);
return;
}
host.restoreEditor();
case 'install-source':
await installFromPanel(
host,
panel,
selection.source,
selection.source,
isOfficialPluginSource(selection.source),
);
return;
}
}
}

View file

@ -37,6 +37,8 @@ export interface ChoicePickerOptions {
readonly hint?: string;
readonly formatHint?: (text: string) => string;
readonly notice?: string;
/** Color tone for the notice line. Defaults to 'success'. */
readonly noticeTone?: 'success' | 'warning';
readonly options: readonly ChoiceOption[];
readonly currentValue?: string;
/** When true, typed characters filter the list (fuzzy) and a search line is shown. */
@ -142,8 +144,12 @@ export class ChoicePickerComponent extends Container implements Focusable {
);
}
if (this.opts.notice !== undefined) {
const tone = this.opts.noticeTone ?? 'success';
const noticeWidth = Math.max(1, width - 1);
for (const noticeLine of this.opts.notice.split(/\r?\n/)) {
lines.push(currentTheme.fg('success', ` ${noticeLine}`));
for (const wrapped of wrapDescription(noticeLine, noticeWidth)) {
lines.push(currentTheme.fg(tone, ` ${wrapped}`));
}
}
}
lines.push('');

View file

@ -24,6 +24,8 @@ const MCP_SERVER_PREFIX = 'mcp:';
const REMOVE_CONFIRM_CANCEL = 'cancel';
const REMOVE_CONFIRM_REMOVE = 'remove';
const INSTALL_TRUST_EXIT = 'exit';
const INSTALL_TRUST_TRUST = 'trust';
const ELLIPSIS = '…';
interface PluginsOverviewItem {
@ -193,6 +195,55 @@ export class PluginRemoveConfirmComponent extends ChoicePickerComponent {
}
}
export type PluginInstallTrustConfirmResult =
| { readonly kind: 'confirm' }
| { readonly kind: 'cancel' };
export interface PluginInstallTrustConfirmOptions {
/** Plugin display name or source, shown in the title for identification. */
readonly label: string;
readonly onDone: (result: PluginInstallTrustConfirmResult) => void;
}
/**
* Confirmation shown before installing a third-party (unofficial) plugin.
* Defaults to "Exit" so the user must explicitly switch to "Trust and install"
* to proceed with a plugin that Kimi has not reviewed.
*/
export class PluginInstallTrustConfirmComponent extends ChoicePickerComponent {
constructor(opts: PluginInstallTrustConfirmOptions) {
super({
title: `Install third-party plugin ${opts.label}?`,
hint: '↑↓ navigate · Enter/Space select · ←/Esc cancel',
formatHint: mutedHintLine,
notice:
'⚠️ This is a third-party plugin that Kimi has not reviewed. It can bundle MCP servers, ' +
'skills, or files that run code and access your workspace. Install it only if you ' +
'trust the source.',
noticeTone: 'warning',
options: [
{
value: INSTALL_TRUST_EXIT,
label: 'Exit',
description: 'Cancel the installation.',
},
{
value: INSTALL_TRUST_TRUST,
label: 'Trust and install',
tone: 'danger',
description: 'Install this third-party plugin anyway.',
},
],
onSelect: (value) => {
opts.onDone(value === INSTALL_TRUST_TRUST ? { kind: 'confirm' } : { kind: 'cancel' });
},
onCancel: () => {
opts.onDone({ kind: 'cancel' });
},
});
}
}
function overviewPluginDescription(plugin: PluginSummary): string {
const state = plugin.state === 'ok' ? '' : ` · state ${plugin.state}`;
const skills = `${plugin.skillCount} skill${plugin.skillCount === 1 ? '' : 's'}`;

View file

@ -50,6 +50,26 @@ export function pluginTrustLabel(plugin: PluginSummary): PluginTrustLabel {
}
}
/**
* Returns true only for install sources that are unambiguously Kimi-built
* official plugins an https URL under the official Kimi CDN plugin path.
* Everything else (local paths, GitHub repos, curated or third-party URLs)
* is treated as unofficial and should be confirmed before install.
*/
export function isOfficialPluginSource(source: string): boolean {
const trimmed = source.trim();
if (!trimmed.startsWith('https://')) return false;
try {
const url = new URL(trimmed);
return (
url.hostname === 'code.kimi.com' &&
url.pathname.startsWith('/kimi-code/plugins/official/')
);
} catch {
return false;
}
}
function hostFromUrl(raw: string): string | undefined {
try {
const url = new URL(raw);

View file

@ -2,18 +2,20 @@ import { describe, expect, it, vi } from 'vitest';
import chalk from 'chalk';
import {
PluginInstallTrustConfirmComponent,
PluginMcpSelectorComponent,
PluginRemoveConfirmComponent,
PluginsPanelComponent,
type PluginInstallTrustConfirmResult,
type PluginMcpSelection,
type PluginRemoveConfirmResult,
type PluginsPanelSelection,
} from '#/tui/components/dialogs/plugins-selector';
import { currentTheme } from '#/tui/theme';
import { darkColors, lightColors } from '#/tui/theme/colors';
import { pluginTrustLabel } from '#/tui/utils/plugin-source-label';
import { isOfficialPluginSource, pluginTrustLabel } from '#/tui/utils/plugin-source-label';
const ANSI_SGR = /\u001b\[[0-9;]*m/g;
const ANSI_SGR = /\u001B\[[0-9;]*m/g;
function strip(text: string): string {
return text.replaceAll(ANSI_SGR, '').replaceAll('\u276F', '?');
@ -37,6 +39,12 @@ function dangerShortcut(text: string): string {
return withAnsiColors(() => chalk.hex(darkColors.error).bold(text));
}
function warningMark(): string {
// Opening ANSI escape for the warning color; the install-trust notice is the
// only element in that dialog using it, so its presence confirms the tone.
return withAnsiColors(() => chalk.hex(darkColors.warning)('\u0001').split('\u0001')[0]!);
}
const superpowers = {
id: 'superpowers',
displayName: 'Superpowers',
@ -132,6 +140,20 @@ describe('plugins selector dialogs', () => {
})).toBe('third-party');
});
it('treats only the official Kimi CDN path as a trusted install source', () => {
expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip')).toBe(true);
// Curated and other Kimi CDN paths are not "official" for the install gate.
expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/curated/superpowers.zip')).toBe(false);
expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/foo.zip')).toBe(false);
// Non-Kimi hosts, non-https schemes, local paths, and GitHub sources are unofficial.
expect(isOfficialPluginSource('https://example.test/kimi-code/plugins/official/x.zip')).toBe(false);
expect(isOfficialPluginSource('http://code.kimi.com/kimi-code/plugins/official/x.zip')).toBe(false);
expect(isOfficialPluginSource('./plugins/kimi-datasource')).toBe(false);
expect(isOfficialPluginSource('/abs/path/to/plugin')).toBe(false);
expect(isOfficialPluginSource('github.com/owner/repo')).toBe(false);
expect(isOfficialPluginSource('not a url')).toBe(false);
});
it('opens on the Installed tab with the four panel tabs', () => {
const { panel } = makePanel({ installed: [superpowers] });
const out = strip(renderRaw(panel));
@ -278,7 +300,7 @@ describe('plugins selector dialogs', () => {
const { panel, onSelect } = makePanel({ initialTab: 'third-party' });
// Catalog still loading (entries empty); pressing ↓ must not drive the
// selection negative, or the later Enter would read entries[-1].
panel.handleInput('\u001b[B'); // ↓
panel.handleInput('\u001B[B'); // ↓
panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json');
panel.handleInput('\r');
expect(onSelect).toHaveBeenCalledWith({
@ -461,7 +483,7 @@ describe('plugins selector dialogs', () => {
},
});
picker.handleInput('\u001b[B');
picker.handleInput('\u001B[B');
const raw = renderRaw(picker);
expect(strip(raw)).toContain('Enter/Space select');
// The destructive option label keeps its danger styling (error + bold).
@ -471,4 +493,49 @@ describe('plugins selector dialogs', () => {
expect(results).toEqual([{ kind: 'confirm' }]);
});
it('defaults the third-party install trust prompt to exit', () => {
const results: PluginInstallTrustConfirmResult[] = [];
const picker = new PluginInstallTrustConfirmComponent({
label: 'Superpowers',
onDone: (result) => {
results.push(result);
},
});
const raw = renderRaw(picker);
const out = raw.split('\n').map(strip);
expect(out).toContain(' Install third-party plugin Superpowers?');
expect(out).toContain(' ? Exit');
expect(out).toContain(' Cancel the installation.');
expect(out).toContain(' Install this third-party plugin anyway.');
// The warning explains why confirmation is required and uses the
// design-system warning color rather than muted/default text.
expect(out.some((line) => line.includes('Kimi has not reviewed'))).toBe(true);
expect(out.some((line) => line.includes('trust the source'))).toBe(true);
expect(raw).toContain(warningMark());
picker.handleInput('\r');
expect(results).toEqual([{ kind: 'cancel' }]);
});
it('installs a third-party plugin only after switching to trust', () => {
const results: PluginInstallTrustConfirmResult[] = [];
const picker = new PluginInstallTrustConfirmComponent({
label: 'Superpowers',
onDone: (result) => {
results.push(result);
},
});
picker.handleInput('\u001B[B');
const raw = renderRaw(picker);
expect(strip(raw)).toContain('Enter/Space select');
// The opt-in option keeps its danger styling (error + bold).
expect(raw).toContain(dangerShortcut('Trust and install'));
picker.handleInput('\r');
expect(results).toEqual([{ kind: 'confirm' }]);
});
});

View file

@ -23,6 +23,7 @@ import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'
import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-model-selector';
import { UndoSelectorComponent } from '#/tui/components/dialogs/undo-selector';
import {
PluginInstallTrustConfirmComponent,
PluginMcpSelectorComponent,
PluginRemoveConfirmComponent,
PluginsPanelComponent,
@ -3092,17 +3093,46 @@ command = "vim"
expect(session.installPlugin).not.toHaveBeenCalled();
});
it('installs from a positional source on /plugins install', async () => {
it('installs from a positional source on /plugins install after trusting it', async () => {
const session = makeSession();
const { driver } = await makeDriver(session);
driver.handleUserInput('/plugins install ./plugins/kimi-datasource');
await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(
PluginInstallTrustConfirmComponent,
);
});
const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent;
confirm.handleInput('\u001B[B'); // switch from "Exit" to "Trust and install"
confirm.handleInput('\r');
await vi.waitFor(() => {
expect(session.installPlugin).toHaveBeenCalledWith('/tmp/proj-a/plugins/kimi-datasource');
});
});
it('does not install when the third-party trust prompt is dismissed', async () => {
const session = makeSession();
const { driver } = await makeDriver(session);
driver.handleUserInput('/plugins install ./plugins/kimi-datasource');
await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(
PluginInstallTrustConfirmComponent,
);
});
const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent;
confirm.handleInput('\r'); // default option is "Exit"
await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBe(driver.state.editor);
});
expect(session.installPlugin).not.toHaveBeenCalled();
});
it('loads a local plugin marketplace file and installs from it', async () => {
const marketplaceDir = await makeTempHome();
const marketplacePath = join(marketplaceDir, 'marketplace.json');
@ -3115,7 +3145,7 @@ command = "vim"
tier: 'official',
displayName: 'Kimi Datasource',
description: 'Datasource plugin',
source: './kimi-datasource',
source: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip',
},
],
}),
@ -3138,7 +3168,9 @@ command = "vim"
panel.handleInput('\r');
await vi.waitFor(() => {
expect(session.installPlugin).toHaveBeenCalledWith(join(marketplaceDir, 'kimi-datasource'));
expect(session.installPlugin).toHaveBeenCalledWith(
'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip',
);
});
await vi.waitFor(() => {
const transcript = stripSgr(renderTranscript(driver));
@ -3162,7 +3194,7 @@ command = "vim"
id: 'kimi-datasource',
tier: 'official',
displayName: 'Kimi Datasource',
source: './kimi-datasource',
source: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip',
},
],
}),
@ -3195,6 +3227,103 @@ command = "vim"
});
});
it('prompts for trust before installing a third-party marketplace entry', async () => {
const marketplaceDir = await makeTempHome();
const marketplacePath = join(marketplaceDir, 'marketplace.json');
await writeFile(
marketplacePath,
JSON.stringify({
plugins: [
{
id: 'superpowers',
tier: 'curated',
displayName: 'Superpowers',
description: 'Curated plugin',
source: './superpowers',
},
],
}),
'utf8',
);
const session = makeSession();
const { driver } = await makeDriver(session);
// Passing the marketplace path opens the panel directly on the Third-party tab.
driver.handleUserInput(`/plugins marketplace ${marketplacePath}`);
await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent);
});
const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent;
await vi.waitFor(() => {
expect(stripSgr(panel.render(120).join('\n'))).toContain('Superpowers');
});
panel.handleInput('\r');
await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(
PluginInstallTrustConfirmComponent,
);
});
const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent;
confirm.handleInput('\u001B[B'); // switch from "Exit" to "Trust and install"
confirm.handleInput('\r');
await vi.waitFor(() => {
expect(session.installPlugin).toHaveBeenCalledWith(join(marketplaceDir, 'superpowers'));
});
});
it('restores the panel when a third-party marketplace install fails', async () => {
const marketplaceDir = await makeTempHome();
const marketplacePath = join(marketplaceDir, 'marketplace.json');
await writeFile(
marketplacePath,
JSON.stringify({
plugins: [
{
id: 'superpowers',
tier: 'curated',
displayName: 'Superpowers',
source: './superpowers',
},
],
}),
'utf8',
);
const installPlugin = vi.fn(async () => {
throw new Error('install failed');
});
const session = makeSession({ installPlugin });
const { driver } = await makeDriver(session);
driver.handleUserInput(`/plugins marketplace ${marketplacePath}`);
await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent);
});
const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent;
await vi.waitFor(() => {
expect(stripSgr(panel.render(120).join('\n'))).toContain('Superpowers');
});
panel.handleInput('\r');
await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(
PluginInstallTrustConfirmComponent,
);
});
const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent;
confirm.handleInput('\u001B[B'); // switch from "Exit" to "Trust and install"
confirm.handleInput('\r');
// The failed install must return the user to the marketplace panel so they
// can retry, rather than dropping them back at the editor.
await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBe(panel);
});
});
it('removes a plugin record without auto-running any cleanup skill', async () => {
const session = makeSession();
const { driver } = await makeDriver(session);

View file

@ -35,7 +35,7 @@ You can also use slash commands directly:
| `/plugins mcp enable <id> <server>` | Enable an MCP server declared by a plugin |
| `/plugins mcp disable <id> <server>` | Disable an MCP server declared by a plugin |
The **Installed** tab lists your installed plugins and shows an update badge when a newer version is available in the marketplace. The **Official** and **Third-party** tabs list marketplace plugins by tier; the **Custom** tab installs from a URL. Marketplace catalogs load automatically when needed. Each install shows a trust badge: `kimi-official` (from an official address), `curated` (from a curated address), or `third-party` (everything else).
The **Installed** tab lists your installed plugins and shows an update badge when a newer version is available in the marketplace. The **Official** and **Third-party** tabs list marketplace plugins by tier; the **Custom** tab installs from a URL. Marketplace catalogs load automatically when needed. Each install shows a trust badge: `kimi-official` (from an official address), `curated` (from a curated address), or `third-party` (everything else). Installing a third-party plugin (anything not from the official address, including Custom installs) first shows a confirmation prompt that defaults to cancelling, so it is only installed if you choose to trust the source.
### Installing from GitHub

View file

@ -35,7 +35,7 @@ Kimi Code CLI 对 plugin 采用保守的加载策略:安装 plugin 时不会
| `/plugins mcp enable <id> <server>` | 启用 plugin 声明的 MCP server |
| `/plugins mcp disable <id> <server>` | 禁用 plugin 声明的 MCP server |
**Installed** tab 列出已安装的 plugin并在 marketplace 有更新版本时显示更新徽章。**Official** 和 **Third-party** tab 按 tier 列出 marketplace plugin**Custom** tab 从 URL 安装。marketplace 目录会在需要时自动加载。每个安装会显示信任徽章:`kimi-official`(来自官方地址)、`curated`(来自精选地址)、`third-party`(其他所有情况)。
**Installed** tab 列出已安装的 plugin并在 marketplace 有更新版本时显示更新徽章。**Official** 和 **Third-party** tab 按 tier 列出 marketplace plugin**Custom** tab 从 URL 安装。marketplace 目录会在需要时自动加载。每个安装会显示信任徽章:`kimi-official`(来自官方地址)、`curated`(来自精选地址)、`third-party`(其他所有情况)。安装第三方 plugin任何非官方地址的 plugin包括 Custom 安装)会先显示一个默认「取消」的确认提示,只有在你选择信任该来源后才会继续安装。
### 从 GitHub 安装