mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-12 18:27:39 +00:00
fix(kimi-code): show MCP launch targets in the workspace trust prompt (#2843)
* fix(kimi-code): show MCP launch targets in the workspace trust prompt Render each gated project MCP server's launch target (transport, command, args, cwd, or url) in the workspace trust prompt without leaking env or header secrets, stripping terminal control characters from the workspace-supplied text, default the prompt to "Don't trust", and resolve fd binaries to absolute paths so untrusted workspaces cannot plant a bare-name fd executable that runs before trust confirmation. * fix(kimi-code): resolve stty to an absolute path before the trust gate
This commit is contained in:
parent
26a37f30a3
commit
c212ae9715
13 changed files with 222 additions and 41 deletions
8
.changeset/secure-workspace-trust-prompt.md
Normal file
8
.changeset/secure-workspace-trust-prompt.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
"@moonshot-ai/kimi-code-sdk": patch
|
||||
---
|
||||
|
||||
Show project MCP launch targets in the workspace trust prompt, default to declining trust, and resolve fd and stty binaries to absolute paths so untrusted workspaces cannot plant bare-name executables before confirmation.
|
||||
|
||||
`@moonshot-ai/kimi-code-sdk` contract change: `WorkspaceTrustInfo.gatedMcpServers` now carries structured `WorkspaceTrustMcpServerInfo` records (`name`, `transport`, and `command`/`args`/`cwd` or `url`) instead of plain strings, so SDK consumers rendering a trust prompt can show the full launch target.
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { execSync, spawnSync } from 'node:child_process';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
|
|
@ -29,6 +29,7 @@ import { startupTrace } from '#/utils/startup-trace';
|
|||
import { currentTheme, getColorPalette } from '#/tui/theme';
|
||||
import { toTerminalHyperlink } from '#/utils/terminal-hyperlink';
|
||||
import { restoreTerminalModes } from '#/utils/terminal-restore';
|
||||
import { resolveCommandPath } from '#/utils/process/resolve-command';
|
||||
|
||||
import type { CLIOptions } from './options';
|
||||
import { resolveAgentProfileSelection } from './agent-selection';
|
||||
|
|
@ -155,28 +156,34 @@ export async function runShell(
|
|||
};
|
||||
|
||||
let savedStty: string | undefined;
|
||||
// stty is a POSIX command and never works on Windows; skip it there instead
|
||||
// of relying on the catch — a bare command name would resolve a planted
|
||||
// `stty.exe` from the current directory before the workspace trust gate.
|
||||
if (process.platform !== 'win32') {
|
||||
// stty runs before tui.start() reaches the workspace trust gate, so it must
|
||||
// never be resolved by name through PATH: a `.` or empty PATH segment would
|
||||
// let an untrusted checkout plant an `stty` executable and run it pre-trust.
|
||||
// resolveCommandPath returns an absolute path and refuses hits inside the
|
||||
// cwd; when it cannot resolve stty, skip the save/restore entirely — it is
|
||||
// best-effort terminal hygiene, not required for startup.
|
||||
// stty is also POSIX-only, so skip it on Windows instead of relying on the
|
||||
// catch below.
|
||||
const sttyPath = process.platform === 'win32' ? undefined : resolveCommandPath('stty');
|
||||
if (sttyPath !== undefined) {
|
||||
try {
|
||||
// stty operates on the terminal behind stdin, so stdin must be the TTY —
|
||||
// piping /dev/null (ignore) makes stty fail with "not a tty".
|
||||
const saved = execSync('stty -g', {
|
||||
const saved = execFileSync(sttyPath, ['-g'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['inherit', 'pipe', 'ignore'],
|
||||
});
|
||||
savedStty = typeof saved === 'string' ? saved.trim() : undefined;
|
||||
execSync('stty -ixon', { stdio: ['inherit', 'ignore', 'ignore'] });
|
||||
savedStty = saved.trim();
|
||||
execFileSync(sttyPath, ['-ixon'], { stdio: ['inherit', 'ignore', 'ignore'] });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const restoreStty = (): void => {
|
||||
if (savedStty === undefined) return;
|
||||
if (sttyPath === undefined || savedStty === undefined) return;
|
||||
const args = savedStty.split(/\s+/).filter((arg) => arg.length > 0);
|
||||
if (args.length === 0) return;
|
||||
spawnSync('stty', args, { stdio: ['inherit', 'ignore', 'ignore'] });
|
||||
spawnSync(sttyPath, args, { stdio: ['inherit', 'ignore', 'ignore'] });
|
||||
};
|
||||
|
||||
// If we crash without going through KimiTUI.stop(), the terminal is left in
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import {
|
|||
type Focusable,
|
||||
} from '@moonshot-ai/pi-tui';
|
||||
|
||||
import type { WorkspaceTrustMcpServerInfo } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import { SELECT_POINTER } from '#/tui/constant/symbols';
|
||||
import { currentTheme } from '#/tui/theme';
|
||||
|
||||
|
|
@ -15,7 +17,7 @@ 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[];
|
||||
readonly gatedMcpServers: readonly WorkspaceTrustMcpServerInfo[];
|
||||
/** Esc resolves to 'distrust' as well. */
|
||||
readonly onSelect: (choice: TrustPromptChoice) => void;
|
||||
}
|
||||
|
|
@ -41,7 +43,7 @@ const OPTIONS: readonly TrustPromptOption[] = [
|
|||
|
||||
export class TrustPromptComponent implements Component, Focusable {
|
||||
focused = false;
|
||||
private selectedIndex = 0;
|
||||
private selectedIndex = 1;
|
||||
|
||||
constructor(private readonly opts: TrustPromptOptions) {}
|
||||
|
||||
|
|
@ -79,12 +81,19 @@ export class TrustPromptComponent implements Component, Focusable {
|
|||
];
|
||||
|
||||
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(', ')}.`
|
||||
: '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.';
|
||||
'Project-level MCP servers are disabled until you explicitly choose Trust. Trust starts the listed project MCP targets and remembers this folder.';
|
||||
for (const line of wrapTextWithAnsi(notice, Math.max(20, width - 2))) {
|
||||
lines.push(` ${currentTheme.fg('textMuted', line)}`);
|
||||
}
|
||||
if (this.opts.gatedMcpServers.length > 0) {
|
||||
lines.push(` ${currentTheme.fg('warning', 'Project MCP targets:')}`);
|
||||
for (const server of this.opts.gatedMcpServers) {
|
||||
const details = formatMcpTarget(server);
|
||||
for (const line of wrapTextWithAnsi(details, Math.max(20, width - 4))) {
|
||||
lines.push(` ${currentTheme.fg('warning', line)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
for (let i = 0; i < OPTIONS.length; i += 1) {
|
||||
|
|
@ -105,3 +114,27 @@ export class TrustPromptComponent implements Component, Focusable {
|
|||
return lines.map((line) => truncateToWidth(line, width));
|
||||
}
|
||||
}
|
||||
|
||||
function formatMcpTarget(server: WorkspaceTrustMcpServerInfo): string {
|
||||
if (server.transport === 'stdio') {
|
||||
const args = server.args === undefined ? '' : ` args=${JSON.stringify(server.args)}`;
|
||||
const cwd = server.cwd === undefined ? '' : ` cwd=${server.cwd}`;
|
||||
return sanitizeForDisplay(`${server.name} (stdio): command=${server.command ?? ''}${args}${cwd}`);
|
||||
}
|
||||
return sanitizeForDisplay(`${server.name} (${server.transport}): url=${server.url ?? ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops C0/C1 control characters (including ESC) from workspace-supplied text:
|
||||
* the trust prompt renders before the workspace is trusted, so a planted
|
||||
* `.mcp.json` must not inject terminal control sequences into it.
|
||||
*/
|
||||
function sanitizeForDisplay(value: string): string {
|
||||
let result = '';
|
||||
for (const char of value) {
|
||||
const code = char.codePointAt(0) ?? 0;
|
||||
if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) continue;
|
||||
result += char;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { pipeline } from 'node:stream/promises';
|
|||
|
||||
import { KIMI_CODE_CDN_BASE } from '#/constant/app';
|
||||
import { getBinDir } from '#/utils/paths';
|
||||
import { resolveCommandPath } from '#/utils/process/resolve-command';
|
||||
|
||||
const CANDIDATES = ['fd', 'fdfind'];
|
||||
const FD_BASE_URL = `${KIMI_CODE_CDN_BASE}/fd`;
|
||||
|
|
@ -56,9 +57,11 @@ export async function ensureFdPath(): Promise<string | null> {
|
|||
|
||||
function detectSystemFdPath(): string | null {
|
||||
for (const name of CANDIDATES) {
|
||||
const commandPath = resolveCommandPath(name);
|
||||
if (commandPath === undefined) continue;
|
||||
try {
|
||||
const result = spawnSync(name, ['--version'], { stdio: 'ignore' });
|
||||
if (result.status === 0) return name;
|
||||
const result = spawnSync(commandPath, ['--version'], { stdio: 'ignore' });
|
||||
if (result.status === 0) return commandPath;
|
||||
} catch {
|
||||
// ENOENT, EACCES, etc. — try next candidate.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { execSync } from 'node:child_process';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
import type { createKimiDeviceId as createKimiDeviceIdFn } from '@moonshot-ai/kimi-code-oauth';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
|
@ -61,7 +61,9 @@ const mocks = vi.hoisted(() => {
|
|||
resolveKimiHome: vi.fn((homeDir?: string) => homeDir ?? '/tmp/kimi-code-test-home'),
|
||||
flushDiagnosticLogsSync: vi.fn(),
|
||||
harnessCreatesDeviceIdOnConstruction: false,
|
||||
execSync: vi.fn(),
|
||||
execFileSync: vi.fn(() => ''),
|
||||
spawnSync: vi.fn(),
|
||||
resolveCommandPath: vi.fn(() => '/bin/stty' as string | undefined),
|
||||
TuiConfigParseError,
|
||||
};
|
||||
});
|
||||
|
|
@ -152,7 +154,12 @@ vi.mock('../../src/migration/index', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execSync: mocks.execSync,
|
||||
execFileSync: mocks.execFileSync,
|
||||
spawnSync: mocks.spawnSync,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/utils/process/resolve-command', () => ({
|
||||
resolveCommandPath: mocks.resolveCommandPath,
|
||||
}));
|
||||
|
||||
describe('runShell', () => {
|
||||
|
|
@ -175,6 +182,7 @@ describe('runShell', () => {
|
|||
mocks.resolveKimiHome.mockImplementation(
|
||||
(homeDir?: string) => homeDir ?? '/tmp/kimi-code-test-home',
|
||||
);
|
||||
mocks.resolveCommandPath.mockImplementation(() => '/bin/stty');
|
||||
mocks.harnessCreatesDeviceIdOnConstruction = false;
|
||||
});
|
||||
|
||||
|
|
@ -297,12 +305,15 @@ describe('runShell', () => {
|
|||
expect(mocks.harnessEnsureConfigFile.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.harnessGetConfig.mock.invocationCallOrder[0]!,
|
||||
);
|
||||
// stty is POSIX-only; on Windows the save/restore block is skipped
|
||||
// entirely (a bare `stty` name would resolve into the untrusted cwd).
|
||||
// stty is resolved to an absolute path before the trust gate and skipped
|
||||
// entirely on Windows (a bare `stty` name would resolve into the
|
||||
// untrusted cwd).
|
||||
if (process.platform !== 'win32') {
|
||||
expect(execSync).toHaveBeenCalledWith('stty -ixon', { stdio: ['inherit', 'ignore', 'ignore'] });
|
||||
expect(execFileSync).toHaveBeenCalledWith('/bin/stty', ['-ixon'], {
|
||||
stdio: ['inherit', 'ignore', 'ignore'],
|
||||
});
|
||||
} else {
|
||||
expect(execSync).not.toHaveBeenCalled();
|
||||
expect(execFileSync).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(mocks.kimiTuiConstructor).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.createKimiDeviceId).toHaveBeenCalledWith(
|
||||
|
|
@ -351,12 +362,21 @@ describe('runShell', () => {
|
|||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
try {
|
||||
await runShell(minimalCliOptions, '1.2.3-test');
|
||||
expect(execSync).not.toHaveBeenCalled();
|
||||
expect(execFileSync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
}
|
||||
});
|
||||
|
||||
it('skips stty when it cannot be resolved outside the untrusted cwd', async () => {
|
||||
stubTuiStartup();
|
||||
if (process.platform === 'win32') return;
|
||||
mocks.resolveCommandPath.mockReturnValue(undefined);
|
||||
await runShell(minimalCliOptions, '1.2.3-test');
|
||||
expect(mocks.resolveCommandPath).toHaveBeenCalledWith('stty');
|
||||
expect(execFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves the --agent profile into the TUI startup input', async () => {
|
||||
mocks.loadTuiConfig.mockResolvedValue({
|
||||
theme: 'dark',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { WorkspaceTrustMcpServerInfo } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import { TrustPromptComponent } from '#/tui/components/dialogs/trust-prompt';
|
||||
|
||||
const ANSI_SGR = /\[[0-9;]*m/g;
|
||||
|
|
@ -8,7 +10,7 @@ function strip(text: string): string {
|
|||
return text.replaceAll(ANSI_SGR, '');
|
||||
}
|
||||
|
||||
function renderLines(gatedMcpServers: readonly string[] = []): string[] {
|
||||
function renderLines(gatedMcpServers: readonly WorkspaceTrustMcpServerInfo[] = []): string[] {
|
||||
const prompt = new TrustPromptComponent({
|
||||
workDir: '/tmp/demo-workspace',
|
||||
gatedMcpServers,
|
||||
|
|
@ -30,14 +32,30 @@ describe('TrustPromptComponent', () => {
|
|||
});
|
||||
|
||||
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);
|
||||
const lines = renderLines([
|
||||
{ name: 'nested-server', transport: 'stdio', command: 'nested-cmd', args: ['--safe'], cwd: '/tmp' },
|
||||
{ name: 'root-server', transport: 'http', url: 'https://example.test/mcp' },
|
||||
]);
|
||||
expect(lines.some((l) => l.includes('Project MCP targets'))).toBe(true);
|
||||
expect(lines.some((l) => l.includes('nested-server (stdio): command=nested-cmd'))).toBe(true);
|
||||
expect(lines.some((l) => l.includes('args=["--safe"] cwd=/tmp'))).toBe(true);
|
||||
expect(lines.some((l) => l.includes('root-server (http): url=https://example.test/mcp'))).toBe(true);
|
||||
expect(renderLines().some((l) => l.includes('This folder defines'))).toBe(false);
|
||||
});
|
||||
|
||||
it('selects trust on Enter with the default highlight', () => {
|
||||
it('strips terminal control characters from workspace-supplied MCP targets', () => {
|
||||
const lines = renderLines([
|
||||
{ name: 'evil', transport: 'stdio', command: 'cmd\u001B[2J\u0007evil' },
|
||||
{ name: 'multi\nline', transport: 'http', url: 'https://example.test/\u001B]8;;https://evil.test\u0007' },
|
||||
]);
|
||||
const text = lines.join('\n');
|
||||
// ESC and BEL are dropped, defusing the sequences into harmless literal text.
|
||||
expect(text).toContain('evil (stdio): command=cmd[2Jevil');
|
||||
expect(text).toContain('multiline (http): url=https://example.test/]8;;https://evil.test');
|
||||
expect(text).not.toContain('\u001B]8;;https://evil.test');
|
||||
});
|
||||
|
||||
it("defaults to Don't trust", () => {
|
||||
const onSelect = vi.fn();
|
||||
const prompt = new TrustPromptComponent({
|
||||
workDir: '/tmp/demo-workspace',
|
||||
|
|
@ -45,6 +63,18 @@ describe('TrustPromptComponent', () => {
|
|||
onSelect,
|
||||
});
|
||||
prompt.handleInput('\r');
|
||||
expect(onSelect).toHaveBeenCalledWith('distrust');
|
||||
});
|
||||
|
||||
it('selects trust only after moving to it explicitly', () => {
|
||||
const onSelect = vi.fn();
|
||||
const prompt = new TrustPromptComponent({
|
||||
workDir: '/tmp/demo-workspace',
|
||||
gatedMcpServers: [],
|
||||
onSelect,
|
||||
});
|
||||
prompt.handleInput('\u001B[A');
|
||||
prompt.handleInput('\r');
|
||||
expect(onSelect).toHaveBeenCalledWith('trust');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2085,7 +2085,7 @@ describe('KimiTUI startup', () => {
|
|||
// later startup steps spawned child processes in an untrusted directory.
|
||||
const getWorkspaceTrustInfo = vi.fn(async () => ({
|
||||
trusted: true,
|
||||
gatedMcpServers: [] as string[],
|
||||
gatedMcpServers: [],
|
||||
}));
|
||||
const harness = makeHarness(makeSession(), { getWorkspaceTrustInfo });
|
||||
const driver = makeDriver(harness, {
|
||||
|
|
@ -2115,7 +2115,7 @@ describe('KimiTUI startup', () => {
|
|||
it('prompts for workspace trust before migrating an untrusted workspace', async () => {
|
||||
const getWorkspaceTrustInfo = vi.fn(async () => ({
|
||||
trusted: false,
|
||||
gatedMcpServers: [] as string[],
|
||||
gatedMcpServers: [],
|
||||
}));
|
||||
const trustWorkspace = vi.fn(async () => {});
|
||||
const harness = makeHarness(makeSession(), { getWorkspaceTrustInfo, trustWorkspace });
|
||||
|
|
@ -2141,7 +2141,8 @@ describe('KimiTUI startup', () => {
|
|||
await vi.waitFor(() => {
|
||||
expect(mountSpy).toHaveBeenCalled();
|
||||
});
|
||||
// Choose the default "Trust this folder" option with Enter.
|
||||
// Move from the safe default to the explicit trust choice, then confirm.
|
||||
mountSpy.mock.calls[0]![0].handleInput('\u001B[A');
|
||||
mountSpy.mock.calls[0]![0].handleInput('\r');
|
||||
await startPromise;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||
import { detectFdPath, getFdAssetName } from '#/utils/process/fd-detect';
|
||||
import { getBinDir } from '#/utils/paths';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveCommandPath: vi.fn(),
|
||||
spawnSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('#/utils/process/resolve-command', () => ({
|
||||
resolveCommandPath: mocks.resolveCommandPath,
|
||||
}));
|
||||
vi.mock('node:child_process', () => ({ spawnSync: mocks.spawnSync }));
|
||||
|
||||
const originalEnv = { ...process.env };
|
||||
let tempHome: string | undefined;
|
||||
|
||||
|
|
@ -16,6 +26,7 @@ afterEach(() => {
|
|||
tempHome = undefined;
|
||||
}
|
||||
process.env = { ...originalEnv };
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
|
|
@ -43,6 +54,20 @@ describe('getFdAssetName', () => {
|
|||
});
|
||||
|
||||
describe('detectFdPath', () => {
|
||||
it('returns the absolute resolved path for a system fd binary', () => {
|
||||
tempHome = mkdtempSync(join(tmpdir(), 'kimi-fd-home-'));
|
||||
process.env['KIMI_CODE_HOME'] = tempHome;
|
||||
mocks.resolveCommandPath.mockImplementation((name: string) =>
|
||||
name === 'fd' ? '/usr/local/bin/fd' : undefined,
|
||||
);
|
||||
mocks.spawnSync.mockReturnValue({ status: 0 });
|
||||
|
||||
expect(detectFdPath()).toBe('/usr/local/bin/fd');
|
||||
expect(mocks.spawnSync).toHaveBeenCalledWith('/usr/local/bin/fd', ['--version'], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the managed fd binary under KIMI_CODE_HOME', () => {
|
||||
tempHome = mkdtempSync(join(tmpdir(), 'kimi-fd-home-'));
|
||||
process.env['KIMI_CODE_HOME'] = tempHome;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ Run `/mcp-config` in the TUI to interactively add, edit, or delete servers witho
|
|||
|
||||
Deleting a server from the configuration does not interrupt open sessions: the server stays listed in `/mcp` as `removed`, its tools remain visible there, and calls to them fail with a removal notice, while new sessions do not register the tools at all. Conversely, a server added mid-session — by editing `mcp.json` or installing a plugin — is not registered in already-open sessions; it only joins sessions created later.
|
||||
|
||||
When Kimi Code finds project-level MCP servers in an untrusted folder, it shows each server's transport and launch target in the workspace trust prompt. The prompt defaults to `Don't trust`; move to `Trust this folder` and confirm only after reviewing the listed command and arguments or remote URL. Trusting the folder enables the project-level MCP servers for that workspace.
|
||||
|
||||
Structure of `mcp.json`:
|
||||
|
||||
```json
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ MCP server 配置写在 `mcp.json` 中,分两层:
|
|||
|
||||
从配置中删除某个 server 不会打断进行中的会话:该 server 在 `/mcp` 中仍显示为 `removed`,其工具在这些会话中保持可见,但调用会失败并返回移除提示;新会话则完全不会注册这些工具。反过来,会话进行中新增的 server——无论是编辑 `mcp.json` 还是安装 plugin——都不会注册到已打开的会话中,只会加入之后创建的会话。
|
||||
|
||||
当 Kimi Code 在不受信任的文件夹中发现项目级 MCP server 时,工作区信任提示会显示每个 server 的传输方式和启动目标。提示默认选中 `Don't trust`;请先移动到 `Trust this folder`,核对列出的命令与参数或远程 URL 后,再确认信任。信任文件夹后,该工作区的项目级 MCP server 才会启用。
|
||||
|
||||
`mcp.json` 的结构:
|
||||
|
||||
```json
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ import { SECONDARY_MODEL_SECTION } from '@moonshot-ai/agent-core-v2/app/kosongCo
|
|||
import { IAtomicDocumentStore } from '@moonshot-ai/agent-core-v2/persistence/interface/atomicDocumentStore';
|
||||
import { wrapSubagentModelError } from '@moonshot-ai/agent-core-v2/session/subagent/configSection';
|
||||
import { loadMcpServers } from '@moonshot-ai/agent-core-v2/workspace/workspaceMcpConfig/internal/config-loader';
|
||||
import type { McpServerConfig as WorkspaceMcpServerConfig } from '@moonshot-ai/agent-core-v2/mcpCore/config-schema';
|
||||
import {
|
||||
applyPromptMetadataUpdate,
|
||||
bootstrap,
|
||||
|
|
@ -597,9 +598,10 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
|
|||
loadMcpServers({ fs, cwd: workDir, homeDir: this.homeDir, includeProject: true }),
|
||||
loadMcpServers({ fs, cwd: workDir, homeDir: this.homeDir, includeProject: false }),
|
||||
]);
|
||||
const gatedMcpServers = Object.keys(withProject)
|
||||
.filter((name) => !(name in userOnly))
|
||||
.toSorted();
|
||||
const gatedMcpServers = Object.entries(withProject)
|
||||
.filter(([name]) => !(name in userOnly))
|
||||
.map(([name, config]) => describeWorkspaceMcpServer(name, config))
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
return { trusted: false, gatedMcpServers };
|
||||
} catch {
|
||||
return { trusted: false, gatedMcpServers: [] };
|
||||
|
|
@ -2378,3 +2380,19 @@ function normalizeRequiredWorkDir(operation: string, workDir: string): string {
|
|||
}
|
||||
return normalizeWorkDir(workDir);
|
||||
}
|
||||
|
||||
function describeWorkspaceMcpServer(
|
||||
name: string,
|
||||
config: WorkspaceMcpServerConfig,
|
||||
): WorkspaceTrustInfo['gatedMcpServers'][number] {
|
||||
if (config.transport === 'stdio') {
|
||||
return {
|
||||
name,
|
||||
transport: config.transport,
|
||||
command: config.command,
|
||||
args: config.args,
|
||||
cwd: config.cwd,
|
||||
};
|
||||
}
|
||||
return { name, transport: config.transport, url: config.url };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,10 +86,19 @@ export type PermissionMode = 'yolo' | 'manual' | 'auto';
|
|||
* engine; the v1 engine has no workspace-trust concept and reports
|
||||
* `{ trusted: true, gatedMcpServers: [] }`.
|
||||
*/
|
||||
export interface WorkspaceTrustMcpServerInfo {
|
||||
readonly name: string;
|
||||
readonly transport: 'stdio' | 'http' | 'sse';
|
||||
readonly command?: string;
|
||||
readonly args?: readonly string[];
|
||||
readonly cwd?: string;
|
||||
readonly url?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceTrustInfo {
|
||||
readonly trusted: boolean;
|
||||
/** Names of project-level MCP servers that trusting the workspace would enable. */
|
||||
readonly gatedMcpServers: readonly string[];
|
||||
/** Safe descriptions of project-level MCP servers that trusting would enable. */
|
||||
readonly gatedMcpServers: readonly WorkspaceTrustMcpServerInfo[];
|
||||
}
|
||||
|
||||
export interface CreateGoalInput {
|
||||
|
|
|
|||
|
|
@ -386,7 +386,22 @@ describe('SDKRpcClientV2 workspace trust', () => {
|
|||
tempDirs.push(workDir);
|
||||
await writeFile(
|
||||
join(workDir, '.mcp.json'),
|
||||
JSON.stringify({ mcpServers: { 'root-server': { command: 'root-cmd' } } }),
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
'root-server': {
|
||||
command: 'root-cmd',
|
||||
args: ['--safe'],
|
||||
cwd: '/tmp/root',
|
||||
env: { SECRET: 'hidden' },
|
||||
},
|
||||
'http-server': {
|
||||
transport: 'http',
|
||||
url: 'https://example.test/mcp',
|
||||
headers: { Authorization: 'Bearer hidden' },
|
||||
bearerTokenEnvVar: 'TOKEN',
|
||||
},
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
await mkdir(join(workDir, '.kimi-code'), { recursive: true });
|
||||
|
|
@ -398,7 +413,15 @@ describe('SDKRpcClientV2 workspace trust', () => {
|
|||
try {
|
||||
const info = await harness.getWorkspaceTrustInfo(workDir);
|
||||
expect(info.trusted).toBe(false);
|
||||
expect(info.gatedMcpServers).toEqual(['nested-server', 'root-server']);
|
||||
expect(info.gatedMcpServers).toEqual([
|
||||
{ name: 'http-server', transport: 'http', url: 'https://example.test/mcp' },
|
||||
{ name: 'nested-server', transport: 'stdio', command: 'nested-cmd' },
|
||||
{ name: 'root-server', transport: 'stdio', command: 'root-cmd', args: ['--safe'], cwd: '/tmp/root' },
|
||||
]);
|
||||
const serialized = JSON.stringify(info);
|
||||
expect(serialized).not.toContain('hidden');
|
||||
expect(serialized).not.toContain('SECRET');
|
||||
expect(serialized).not.toContain('TOKEN');
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue