Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/kimi-code/src/cli/run-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ export async function runShell(
// Experimental agent-core-v2 route (same master switch as `kimi -p`): the
// harness is the SDK's v2-backed client, so the whole TUI runs on the
// agent-core-v2 engine.
const harness = isKimiV2Enabled()
const engineV2 = isKimiV2Enabled();
const harness = engineV2
? createKimiHarnessV2(harnessOptions)
: createKimiHarness(harnessOptions);
log.info('kimi-code starting', {
Expand Down Expand Up @@ -124,6 +125,7 @@ export async function runShell(
startupNotice: configWarning,
migrationPlan,
migrateOnly: runOptions.migrateOnly,
engineV2,
});

initializeCliTelemetry({
Expand Down
107 changes: 107 additions & 0 deletions apps/kimi-code/src/tui/components/dialogs/trust-prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {
Key,
matchesKey,
truncateToWidth,
wrapTextWithAnsi,
type Component,
type Focusable,
} from '@moonshot-ai/pi-tui';

import { SELECT_POINTER } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';

export type TrustPromptChoice = 'trust' | 'distrust';

export interface TrustPromptOptions {
readonly workDir: string;
/** Project-level MCP servers that trusting would enable; may be empty. */
readonly gatedMcpServers: readonly string[];
/** Esc resolves to 'distrust' as well. */
readonly onSelect: (choice: TrustPromptChoice) => void;
}

interface TrustPromptOption {
readonly value: TrustPromptChoice;
readonly label: string;
readonly description: string;
}

const OPTIONS: readonly TrustPromptOption[] = [
{
value: 'trust',
label: 'Trust this folder',
description: 'Enable project MCP servers. Remembered for this folder.',
},
{
value: 'distrust',
label: "Don't trust",
description: 'Exit Kimi Code. Asked again next launch.',
},
];

export class TrustPromptComponent implements Component, Focusable {
focused = false;
private selectedIndex = 0;

constructor(private readonly opts: TrustPromptOptions) {}

invalidate(): void {}

handleInput(data: string): void {
if (matchesKey(data, Key.escape)) {
this.opts.onSelect('distrust');
return;
}
if (matchesKey(data, Key.up)) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
return;
}
if (matchesKey(data, Key.down)) {
this.selectedIndex = Math.min(OPTIONS.length - 1, this.selectedIndex + 1);
return;
}
if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) {
this.opts.onSelect(OPTIONS[this.selectedIndex]!.value);
}
}

render(width: number): string[] {
const rule = currentTheme.fg('primary', '─'.repeat(width));
const lines = [
rule,
currentTheme.boldFg('primary', ' Trust this folder?'),
currentTheme.fg('textMuted', ' ↑↓ navigate · Enter select · Esc exit'),
'',
...wrapTextWithAnsi(this.opts.workDir, Math.max(20, width - 2)).map(
(line) => ` ${currentTheme.fg('textStrong', line)}`,
),
'',
];

const notice =
this.opts.gatedMcpServers.length > 0
? `Kimi Code loads project-level MCP servers (.mcp.json, .kimi-code/mcp.json) only in trusted folders. They run as local processes on your machine. This folder defines: ${this.opts.gatedMcpServers.join(', ')}.`
Comment on lines +81 to +83

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Sanitize MCP names before rendering trust prompt

For an untrusted repo, the server names come directly from project-controlled .mcp.json files and are interpolated into the terminal output before the user has granted trust. wrapTextWithAnsi/theme coloring preserves embedded ESC/OSC bytes, and this codebase already notes that pi-tui renders strings straight to the terminal, so a malicious server name can emit terminal control sequences from the trust dialog itself. Strip control sequences (or otherwise render names as inert text) before joining them into this notice.

AGENTS.md reference: AGENTS.md:L22-L22

Useful? React with 👍 / 👎.

: 'Kimi Code loads project-level MCP servers (.mcp.json, .kimi-code/mcp.json) only in trusted folders. They run as local processes on your machine.';
for (const line of wrapTextWithAnsi(notice, Math.max(20, width - 2))) {
lines.push(` ${currentTheme.fg('textMuted', line)}`);
}
lines.push('');

for (let i = 0; i < OPTIONS.length; i += 1) {
const option = OPTIONS[i]!;
const selected = i === this.selectedIndex;
const pointer = selected ? SELECT_POINTER : ' ';
const label = selected
? currentTheme.boldFg('primary', option.label)
: currentTheme.fg('text', option.label);
lines.push(currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `) + label);
for (const line of wrapTextWithAnsi(option.description, Math.max(20, width - 4))) {
lines.push(` ${currentTheme.fg('textMuted', line)}`);
}
lines.push('');
}

lines.push(rule);
return lines.map((line) => truncateToWidth(line, width));
}
}
64 changes: 63 additions & 1 deletion apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
PermissionMode,
PromptPart,
Session,
WorkspaceTrustInfo,
} from '@moonshot-ai/kimi-code-sdk';
import type { MigrationPlan } from '@moonshot-ai/migration-legacy';
import {
Expand Down Expand Up @@ -64,6 +65,7 @@ import { CompactionComponent } from './components/dialogs/compaction';
import { HelpPanelComponent } from './components/dialogs/help-panel';
import { QuestionDialogComponent } from './components/dialogs/question-dialog';
import { SessionPickerComponent, type SessionRow } from './components/dialogs/session-picker';
import { TrustPromptComponent, type TrustPromptChoice } from './components/dialogs/trust-prompt';
import {
FileMentionProvider,
type SlashAutocompleteCommand,
Expand Down Expand Up @@ -185,6 +187,8 @@ export interface KimiTUIStartupInput {
readonly migrationPlan?: MigrationPlan | null;
/** When true, run only the migration screen, then exit (the `kimi migrate` command). */
readonly migrateOnly?: boolean;
/** agent-core-v2 engine (KIMI_CODE_EXPERIMENTAL_FLAG); enables the startup workspace-trust prompt. */
readonly engineV2?: boolean;
}

type EffectiveActivityPaneMode = ActivityPaneMode | 'idle' | 'session';
Expand Down Expand Up @@ -323,6 +327,7 @@ export class KimiTUI {
private isShuttingDown = false;
private readonly migrationPlan: MigrationPlan | null;
private readonly migrateOnly: boolean;
private readonly engineV2: boolean;
private startupNotice: string | undefined;
private lastActivityMode: string | undefined;
private currentLoadingTip: { kind: LoadingTipKind; tip: string | undefined } | undefined =
Expand Down Expand Up @@ -396,6 +401,7 @@ export class KimiTUI {
this.options = tuiOptions;
this.migrationPlan = startupInput.migrationPlan ?? null;
this.migrateOnly = startupInput.migrateOnly ?? false;
this.engineV2 = startupInput.engineV2 ?? false;
this.startupNotice = startupInput.startupNotice;
this.state = createTUIState(tuiOptions);
this.uninstallRainbowDance = installRainbowDance(() => {
Expand Down Expand Up @@ -555,8 +561,13 @@ export class KimiTUI {
return;
}

const trustPromptStartedLoop = await this.maybeRunWorkspaceTrustPrompt();
const shouldReplayHistory = await this.initMainTui();
this.startEventLoop();
// When the trust prompt already started the event loop, starting it
// again would re-run pi-tui's terminal.start() — stacking a second
// Kitty keyboard-protocol push (leaking CSI-u mode past exit) and
// duplicate stdin listeners.
if (!trustPromptStartedLoop) this.startEventLoop();
try {
this.startBackgroundFdAutocomplete();
await this.finishStartup(shouldReplayHistory);
Expand Down Expand Up @@ -2832,6 +2843,57 @@ export class KimiTUI {
return result;
}

/**
* agent-core-v2 startup gate: before any session is created, ask whether to
* trust this folder when the workspace is not trusted yet (project-level MCP
* servers stay disabled while untrusted). Best-effort throughout — a failed
* check or trust write never blocks startup. Choosing "don't trust" (or Esc)
* exits the program before any session is created; the prompt reappears on
* the next launch: the engine's untrusted state is indistinguishable from
* never-trusted. Returns true when the prompt started the event loop (the
* caller must not start it again).
*/
private async maybeRunWorkspaceTrustPrompt(): Promise<boolean> {
if (!this.engineV2) return false;
const workDir = this.state.appState.workDir;
let info: WorkspaceTrustInfo;
try {
info = await this.harness.getWorkspaceTrustInfo(workDir);
} catch {
return false;
}
if (info.trusted) return false;
this.startEventLoop();
const choice = await new Promise<TrustPromptChoice>((resolve) => {
this.state.activeDialog = 'trust-prompt';
this.mountEditorReplacement(
new TrustPromptComponent({
workDir,
gatedMcpServers: info.gatedMcpServers,
onSelect: (c) => {
resolve(c);
},
}),
);
});
this.state.activeDialog = null;
if (choice !== 'trust') {
// Declining trust exits the program (Claude Code's "No, exit" semantics):
// stop() runs the standard shutdown path and ends in process.exit. The
// editor is NOT restored first — its frame would linger as an orphaned
// input box above the exit message; the prompt stays as the last frame.
await this.stop();
return true;
}
this.restoreEditor();
try {
await this.harness.trustWorkspace(workDir);
} catch {
// A failed write leaves the workspace untrusted (re-asked next launch).
}
return true;
}

showHelpPanel(): void {
this.state.activeDialog = 'help';
this.mountEditorReplacement(
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-code/src/tui/tui-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export interface TUIState {
sessions: SessionRow[];
loadingSessions: boolean;
sessionsScope: 'cwd' | 'all';
activeDialog: 'session-picker' | 'help' | null;
activeDialog: 'session-picker' | 'help' | 'trust-prompt' | null;
tasksBrowser: TasksBrowserState | undefined;
externalEditorRunning: boolean;
queuedMessages: QueuedMessage[];
Expand Down
73 changes: 73 additions & 0 deletions apps/kimi-code/test/tui/components/dialogs/trust-prompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from 'vitest';

import { TrustPromptComponent } from '#/tui/components/dialogs/trust-prompt';

const ANSI_SGR = /\[[0-9;]*m/g;

function strip(text: string): string {
return text.replaceAll(ANSI_SGR, '');
}

function renderLines(gatedMcpServers: readonly string[] = []): string[] {
const prompt = new TrustPromptComponent({
workDir: '/tmp/demo-workspace',
gatedMcpServers,
onSelect: vi.fn(),
});
return prompt.render(100).map(strip);
}

describe('TrustPromptComponent', () => {
it('renders the header vocabulary and the workspace path', () => {
const lines = renderLines();
const titleIdx = lines.findIndex((l) => l.includes('Trust this folder?'));
expect(titleIdx).toBeGreaterThanOrEqual(0);
const hint = lines[titleIdx + 1];
expect(hint).toContain('↑↓ navigate');
expect(hint).toContain('Enter select');
expect(hint).toContain('Esc exit');
expect(lines.some((l) => l.includes('/tmp/demo-workspace'))).toBe(true);
});

it('lists the gated project MCP servers when present', () => {
const lines = renderLines(['nested-server', 'root-server']);
expect(lines.some((l) => l.includes('This folder defines'))).toBe(true);
expect(lines.some((l) => l.includes('nested-server'))).toBe(true);
expect(lines.some((l) => l.includes('root-server'))).toBe(true);
expect(renderLines().some((l) => l.includes('This folder defines'))).toBe(false);
});

it('selects trust on Enter with the default highlight', () => {
const onSelect = vi.fn();
const prompt = new TrustPromptComponent({
workDir: '/tmp/demo-workspace',
gatedMcpServers: [],
onSelect,
});
prompt.handleInput('\r');
expect(onSelect).toHaveBeenCalledWith('trust');
});

it('selects distrust after moving the cursor down', () => {
const onSelect = vi.fn();
const prompt = new TrustPromptComponent({
workDir: '/tmp/demo-workspace',
gatedMcpServers: [],
onSelect,
});
prompt.handleInput('\u001B[B');
prompt.handleInput('\r');
expect(onSelect).toHaveBeenCalledWith('distrust');
});

it('treats Esc as distrust', () => {
const onSelect = vi.fn();
const prompt = new TrustPromptComponent({
workDir: '/tmp/demo-workspace',
gatedMcpServers: [],
onSelect,
});
prompt.handleInput('\u001B');
expect(onSelect).toHaveBeenCalledWith('distrust');
});
});
15 changes: 15 additions & 0 deletions packages/node-sdk/src/kimi-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type {
TelemetryContextPatch,
TelemetryProperties,
TestMcpServerOptions,
WorkspaceTrustInfo,
} from '#/types';

export interface KimiHarnessRuntimeOptions {
Expand Down Expand Up @@ -255,6 +256,20 @@ export class KimiHarness {
return this.rpc.listWorkspaceSkills(workDir);
}

/**
* Trust state of `workDir` (agent-core-v2 only; the v1 engine reports an
* always-trusted workspace). Querying may register the workDir as a
* workspace, which session creation would do anyway.
*/
async getWorkspaceTrustInfo(workDir: string): Promise<WorkspaceTrustInfo> {
return this.rpc.getWorkspaceTrustInfo(workDir);
}

/** Mark `workDir` as trusted; project-level MCP servers connect live afterwards. */
async trustWorkspace(workDir: string): Promise<void> {
return this.rpc.trustWorkspace(workDir);
}

async getConfig(options: GetConfigOptions = {}): Promise<KimiConfig> {
return this.rpc.getConfig(options);
}
Expand Down
15 changes: 15 additions & 0 deletions packages/node-sdk/src/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import type {
SkillSummary,
PluginCommandDef,
Unsubscribe,
WorkspaceTrustInfo,
} from '#/types';

const MAIN_AGENT_ID = 'main';
Expand Down Expand Up @@ -220,6 +221,20 @@ export abstract class SDKRpcClientBase {
return rpc.listWorkspaceSkills({ workDir });
}

/**
* Workspace-trust state for `workDir`. The v1 engine has no trust concept,
* so the base implementation reports an always-trusted workspace and the
* trust write is a no-op; only the v2 client overrides these.
*/
async getWorkspaceTrustInfo(workDir: string): Promise<WorkspaceTrustInfo> {
void workDir;
return { trusted: true, gatedMcpServers: [] };
}

async trustWorkspace(workDir: string): Promise<void> {
void workDir;
}

async renameSession(input: RenameSessionInput): Promise<void> {
const rpc = await this.getRpc();
return rpc.renameSession({
Expand Down
Loading
Loading