diff --git a/.changeset/inherit-workspace-trust.md b/.changeset/inherit-workspace-trust.md new file mode 100644 index 0000000000..376e3ad0c5 --- /dev/null +++ b/.changeset/inherit-workspace-trust.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Allow a trusted workspace folder to cover its subdirectories. diff --git a/docs/en/customization/mcp.md b/docs/en/customization/mcp.md index 019576d976..659e57cace 100644 --- a/docs/en/customization/mcp.md +++ b/docs/en/customization/mcp.md @@ -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`: diff --git a/docs/zh/customization/mcp.md b/docs/zh/customization/mcp.md index 0bd78a2bfc..823357c311 100644 --- a/docs/zh/customization/mcp.md +++ b/docs/zh/customization/mcp.md @@ -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` 的结构: diff --git a/packages/agent-core-v2/src/workspace/workspaceTrust/trustRecord.ts b/packages/agent-core-v2/src/workspace/workspaceTrust/trustRecord.ts index be6622f95b..bb3225166d 100644 --- a/packages/agent-core-v2/src/workspace/workspaceTrust/trustRecord.ts +++ b/packages/agent-core-v2/src/workspace/workspaceTrust/trustRecord.ts @@ -1,3 +1,5 @@ +import { dirname } from 'pathe'; + import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; import { canonicalWorkspaceRoot } from '#/_base/utils/paths'; import type { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; @@ -6,7 +8,9 @@ const TRUST_SCOPE = 'workspace-trust'; interface TrustRecord { readonly root: string; - readonly trustedAt: number; + readonly trustedAt?: number; + readonly trusted?: boolean; + readonly untrustedAt?: number; } export async function readWorkspaceTrust( @@ -14,18 +18,17 @@ export async function readWorkspaceTrust( root: string, ): Promise { try { - const canonicalKey = trustKey(root); - if ((await docs.get(TRUST_SCOPE, canonicalKey)) !== undefined) return true; - - const legacyKey = encodeWorkDirKey(root); - if (legacyKey === canonicalKey) return false; - const legacy = await docs.get(TRUST_SCOPE, legacyKey); - if (legacy === undefined) return false; - try { - await docs.set(TRUST_SCOPE, canonicalKey, legacy); - await docs.delete(TRUST_SCOPE, legacyKey); - } catch {} - return true; + const own = await readOwnRecord(docs, root); + if (own !== undefined) return own.trusted !== false; + let current = canonicalWorkspaceRoot(root); + while (true) { + const parent = dirname(current); + const next = canonicalWorkspaceRoot(parent); + if (next === current) return false; + const record = await readOwnRecord(docs, next); + if (record !== undefined) return record.trusted !== false; + current = next; + } } catch { return false; } @@ -39,6 +42,14 @@ export function writeWorkspaceTrust( return docs.set(TRUST_SCOPE, trustKey(root), { root, trustedAt }); } +export function writeUntrustedWorkspaceTrust( + docs: IAtomicDocumentStore, + root: string, + untrustedAt: number, +): Promise { + return docs.set(TRUST_SCOPE, trustKey(root), { root, trusted: false, untrustedAt }); +} + export function deleteWorkspaceTrust( docs: IAtomicDocumentStore, root: string, @@ -51,6 +62,41 @@ export function deleteWorkspaceTrust( })(); } +export function workspaceTrustWatchKeys(root: string): readonly string[] { + return ancestorRoots(root).map(trustKey); +} + +async function readOwnRecord( + docs: IAtomicDocumentStore, + root: string, +): Promise { + const canonicalKey = trustKey(root); + const canonical = await docs.get(TRUST_SCOPE, canonicalKey); + if (canonical !== undefined) return canonical; + + const legacyKey = encodeWorkDirKey(root); + if (legacyKey === canonicalKey) return undefined; + const legacy = await docs.get(TRUST_SCOPE, legacyKey); + if (legacy === undefined) return undefined; + try { + await docs.set(TRUST_SCOPE, canonicalKey, legacy); + await docs.delete(TRUST_SCOPE, legacyKey); + } catch {} + return legacy; +} + +function ancestorRoots(root: string): string[] { + const roots: string[] = []; + let current = canonicalWorkspaceRoot(root); + while (true) { + roots.push(current); + const parent = dirname(current); + const next = canonicalWorkspaceRoot(parent); + if (next === current) return roots; + current = next; + } +} + function trustKey(root: string): string { return encodeWorkDirKey(canonicalWorkspaceRoot(root)); } diff --git a/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts b/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts index ed489d0c77..33a8c2099e 100644 --- a/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts @@ -6,7 +6,15 @@ import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; import { IWorkspaceTrust, type WorkspaceTrustChange } from './workspaceTrust'; -import { deleteWorkspaceTrust, readWorkspaceTrust, writeWorkspaceTrust } from './trustRecord'; +import { + deleteWorkspaceTrust, + readWorkspaceTrust, + workspaceTrustWatchKeys, + writeUntrustedWorkspaceTrust, + writeWorkspaceTrust, +} from './trustRecord'; + +const TRUST_SCOPE = 'workspace-trust'; export const workspaceTrustTrustedKey = defineState( 'workspaceTrust.trusted', @@ -29,6 +37,7 @@ export class WorkspaceTrustService extends Disposable implements IWorkspaceTrust super(); this.states.contributeState(workspaceTrustTrustedKey); this.root = workspace.cwd; + this.watchTrustRecords(); this.ready = this.initialize(); } @@ -52,18 +61,39 @@ export class WorkspaceTrustService extends Disposable implements IWorkspaceTrust async trust(): Promise { if (this.trusted) return; await writeWorkspaceTrust(this.docs, this.root, Date.now()); - this.trusted = true; - this.changeEmitter.fire({ trusted: true }); + this.updateTrusted(true); } async untrust(): Promise { if (!this.trusted) return; await deleteWorkspaceTrust(this.docs, this.root); - this.trusted = false; - this.changeEmitter.fire({ trusted: false }); + if (await readWorkspaceTrust(this.docs, this.root)) { + await writeUntrustedWorkspaceTrust(this.docs, this.root, Date.now()); + } + this.updateTrusted(false); } private async initialize(): Promise { this.trusted = await readWorkspaceTrust(this.docs, this.root); } + + private async refresh(): Promise { + this.updateTrusted(await readWorkspaceTrust(this.docs, this.root)); + } + + private watchTrustRecords(): void { + for (const key of workspaceTrustWatchKeys(this.root)) { + this._register( + this.docs.watch(TRUST_SCOPE, key)(() => { + void this.refresh(); + }), + ); + } + } + + private updateTrusted(value: boolean): void { + if (this.trusted === value) return; + this.trusted = value; + this.changeEmitter.fire({ trusted: value }); + } } diff --git a/packages/agent-core-v2/test/workspace/workspaceTrust/workspaceTrust.test.ts b/packages/agent-core-v2/test/workspace/workspaceTrust/workspaceTrust.test.ts index 7604e1550b..e6601792f6 100644 --- a/packages/agent-core-v2/test/workspace/workspaceTrust/workspaceTrust.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceTrust/workspaceTrust.test.ts @@ -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 { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; import { DisposableStore } from '#/_base/di/lifecycle'; @@ -117,6 +117,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('migrates a legacy Windows trust marker to the canonical key', async () => { const docs = new JsonAtomicDocumentStore(new FileStorageService(homeDir)); const root = 'C:\\Users\\Foo\\Repo';