Skip to content
5 changes: 5 additions & 0 deletions .changeset/inherit-workspace-trust.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Allow a trusted workspace folder to cover its subdirectories.
2 changes: 1 addition & 1 deletion docs/en/customization/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ 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.
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 a folder also trusts its subfolders, enabling their project-level MCP servers without another prompt.

Structure of `mcp.json`:

Expand Down
2 changes: 1 addition & 1 deletion docs/zh/customization/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ 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 才会启用
当 Kimi Code 在不受信任的文件夹中发现项目级 MCP server 时,工作区信任提示会显示每个 server 的传输方式和启动目标。提示默认选中 `Don't trust`;请先移动到 `Trust this folder`,核对列出的命令与参数或远程 URL 后,再确认信任。信任一个文件夹也会信任其子文件夹,因此这些子文件夹中的项目级 MCP server 无需再次提示即可启用

`mcp.json` 的结构:

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { dirname, normalize } from 'pathe';

import { Disposable } from '#/_base/di/lifecycle';
import { Emitter } from '#/_base/event';
import { defineState } from '#/state/state';
Expand All @@ -12,7 +14,9 @@ const TRUST_SCOPE = 'workspace-trust';

interface TrustRecord {
readonly root: string;
readonly trustedAt: number;
readonly trusted?: boolean;
readonly trustedAt?: number;
readonly untrustedAt?: number;
}

export const workspaceTrustTrustedKey = defineState<boolean>(
Expand All @@ -38,6 +42,7 @@ export class WorkspaceTrustService extends Disposable implements IWorkspaceTrust
this.states.contributeState(workspaceTrustTrustedKey);
this.root = workspace.cwd;
this.storeKey = encodeWorkDirKey(workspace.cwd);
this.watchTrustRecords();
this.ready = this.initialize();
}

Expand All @@ -62,25 +67,71 @@ export class WorkspaceTrustService extends Disposable implements IWorkspaceTrust
if (this.trusted) return;
await this.docs.set(TRUST_SCOPE, this.storeKey, {
root: this.root,
trusted: true,
trustedAt: Date.now(),
});
this.trusted = true;
this.changeEmitter.fire({ trusted: true });
this.updateTrusted(true);
}

async untrust(): Promise<void> {
if (!this.trusted) return;
await this.docs.delete(TRUST_SCOPE, this.storeKey);
this.trusted = false;
this.changeEmitter.fire({ trusted: false });
await this.docs.set(TRUST_SCOPE, this.storeKey, {
root: this.root,
trusted: false,
untrustedAt: Date.now(),
});
this.updateTrusted(false);
}

private async initialize(): Promise<void> {
try {
this.trusted = (await this.docs.get<TrustRecord>(TRUST_SCOPE, this.storeKey)) !== undefined;
this.trusted = await this.readTrusted();
} catch {
this.trusted = false;
}
}

private async refresh(): Promise<void> {
try {
this.updateTrusted(await this.readTrusted());
} catch {
this.updateTrusted(false);
}
}

private async readTrusted(): Promise<boolean> {
for (const key of this.trustRecordKeys()) {
const record = await this.docs.get<TrustRecord>(TRUST_SCOPE, key);
if (record !== undefined) return record.trusted !== false;
}
return false;
}

private watchTrustRecords(): void {
for (const key of this.trustRecordKeys()) {
this._register(
this.docs.watch(TRUST_SCOPE, key)(() => {
void this.refresh();
}),
);
}
}

private trustRecordKeys(): readonly string[] {
const keys = [this.storeKey];
let current = dirname(normalize(this.root));
while (true) {
keys.push(encodeWorkDirKey(current));
const parent = dirname(current);
if (parent === current) return keys;
current = parent;
}
}

private updateTrusted(value: boolean): void {
if (this.trusted === value) return;
this.trusted = value;
this.changeEmitter.fire({ trusted: value });
}
}

Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { mkdtempSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { mkdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'pathe';

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { DisposableStore } from '#/_base/di/lifecycle';
import { createServices } from '#/_base/di/test';
Expand Down Expand Up @@ -111,6 +111,66 @@ describe('WorkspaceTrustService', () => {
expect(second.isTrusted()).toBe(true);
});

it('inherits trust from an ancestor workspace', async () => {
const nested = join(cwd, 'test', 'workspace');
const prefixSibling = `${cwd}-other`;
const siblingNested = join(prefixSibling, 'test', 'workspace');
await mkdir(nested, { recursive: true });
await mkdir(siblingNested, { recursive: true });

try {
const { service: parent } = createService(cwd);
await parent.ready;
await parent.trust();

const { service: child } = createService(nested);
await child.ready;

const { service: sibling } = createService(siblingNested);
await sibling.ready;

expect(child.isTrusted()).toBe(true);
expect(sibling.isTrusted()).toBe(false);
} finally {
await rm(prefixSibling, { recursive: true, force: true });
}
});

it('keeps a child handler in sync with ancestor trust changes', async () => {
const nested = join(cwd, 'test', 'workspace');
await mkdir(nested, { recursive: true });

const { service: parent } = createService(cwd);
const { service: child } = createService(nested);
await Promise.all([parent.ready, child.ready]);

expect(child.isTrusted()).toBe(false);

await parent.trust();
await vi.waitFor(() => expect(child.isTrusted()).toBe(true));

await parent.untrust();
await vi.waitFor(() => expect(child.isTrusted()).toBe(false));
});

it('keeps an inherited child untrusted after rematerialization', async () => {
const nested = join(cwd, 'test', 'workspace');
await mkdir(nested, { recursive: true });

const { service: parent } = createService(cwd);
await parent.ready;
await parent.trust();

const { service: child } = createService(nested);
await child.ready;
await child.untrust();

const { service: rematerialized } = createService(nested);
await rematerialized.ready;

expect(rematerialized.isTrusted()).toBe(false);
});

it('tracks different roots independently', async () => {
const other = mkdtempSync(join(tmpdir(), 'kimi-workspace-trust-other-'));
try {
Expand Down