diff --git a/.changeset/secure-workspace-trust-prompt.md b/.changeset/secure-workspace-trust-prompt.md new file mode 100644 index 000000000..77549f475 --- /dev/null +++ b/.changeset/secure-workspace-trust-prompt.md @@ -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. diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index d7a13cb75..0dcbaed5a 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -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 diff --git a/apps/kimi-code/src/tui/components/dialogs/trust-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/trust-prompt.ts index 0ecca3732..811ad888e 100644 --- a/apps/kimi-code/src/tui/components/dialogs/trust-prompt.ts +++ b/apps/kimi-code/src/tui/components/dialogs/trust-prompt.ts @@ -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; +} diff --git a/apps/kimi-code/src/utils/process/fd-detect.ts b/apps/kimi-code/src/utils/process/fd-detect.ts index ed97a0000..f41c5d0a6 100644 --- a/apps/kimi-code/src/utils/process/fd-detect.ts +++ b/apps/kimi-code/src/utils/process/fd-detect.ts @@ -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 { 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. } diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index 35a096659..00631a42a 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -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', diff --git a/apps/kimi-code/test/tui/components/dialogs/trust-prompt.test.ts b/apps/kimi-code/test/tui/components/dialogs/trust-prompt.test.ts index 389b21149..4fe7d5361 100644 --- a/apps/kimi-code/test/tui/components/dialogs/trust-prompt.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/trust-prompt.test.ts @@ -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'); }); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index a621fdaba..7f463e9a3 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -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; diff --git a/apps/kimi-code/test/utils/process/fd-detect.test.ts b/apps/kimi-code/test/utils/process/fd-detect.test.ts index cd6fd249c..76e48b71a 100644 --- a/apps/kimi-code/test/utils/process/fd-detect.test.ts +++ b/apps/kimi-code/test/utils/process/fd-detect.test.ts @@ -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; diff --git a/docs/en/customization/mcp.md b/docs/en/customization/mcp.md index a6533c38f..3ebcd4fab 100644 --- a/docs/en/customization/mcp.md +++ b/docs/en/customization/mcp.md @@ -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 diff --git a/docs/zh/customization/mcp.md b/docs/zh/customization/mcp.md index bfc6fd4bb..cb1697213 100644 --- a/docs/zh/customization/mcp.md +++ b/docs/zh/customization/mcp.md @@ -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 diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 75d99ad8c..eea5f7b35 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -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 }; +} diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 8e89c4246..d87143c82 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -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 { diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts index f99266eb0..95e004e32 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -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(); }