From bd4e0e336e4515dbe57d71928e22ebf1d155dac8 Mon Sep 17 00:00:00 2001 From: Atirna <288419661+atirna@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:00:50 +0530 Subject: [PATCH 1/2] fix(workspace): inherit trust in subdirectories --- .changeset/inherit-workspace-trust.md | 5 ++++ docs/en/customization/mcp.md | 2 +- docs/zh/customization/mcp.md | 2 +- .../workspaceTrust/workspaceTrustService.ts | 24 ++++++++++++++--- .../workspaceTrust/workspaceTrust.test.ts | 27 ++++++++++++++++++- 5 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 .changeset/inherit-workspace-trust.md 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 3ebcd4fab6..6a06eac230 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 cb16972132..7336ae3632 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/workspaceTrustService.ts b/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts index 39c04cc7c7..d5efb28121 100644 --- a/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts @@ -4,8 +4,9 @@ * Persists the trust marker through the `persistence` domain's * `IAtomicDocumentStore` under the `workspace-trust` scope, one document per * workspace keyed by `encodeWorkDirKey(root)`, with the raw root kept in the - * value for inspection. The document's presence IS the trusted state: `trust()` - * writes it, `untrust()` deletes it. The record lives under the kimi home, + * value for inspection. A document for this root or an ancestor grants trust: + * `trust()` writes this root's document, `untrust()` deletes it. The record + * lives under the kimi home, * never inside the workspace, so a checked-out tree cannot pre-trust * itself. The flag is read once through `ready` and every later mutation * goes through this service, so the view is in-process: another process @@ -16,6 +17,8 @@ * Bound at Workspace scope. */ +import { dirname, normalize } from 'pathe'; + import { Disposable } from '#/_base/di/lifecycle'; import { Emitter } from '#/_base/event'; import { defineState } from '#/_base/state/stateRegistry'; @@ -96,7 +99,22 @@ export class WorkspaceTrustService extends Disposable implements IWorkspaceTrust private async initialize(): Promise { try { - this.trusted = (await this.docs.get(TRUST_SCOPE, this.storeKey)) !== undefined; + if ((await this.docs.get(TRUST_SCOPE, this.storeKey)) !== undefined) { + this.trusted = true; + return; + } + + let current = dirname(normalize(this.root)); + while (true) { + if ((await this.docs.get(TRUST_SCOPE, encodeWorkDirKey(current))) !== undefined) { + this.trusted = true; + return; + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + this.trusted = false; } catch { this.trusted = false; } 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 dcf874cfc1..7d8eb91af0 100644 --- a/packages/agent-core-v2/test/workspace/workspaceTrust/workspaceTrust.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceTrust/workspaceTrust.test.ts @@ -11,7 +11,7 @@ */ 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'; @@ -123,6 +123,31 @@ 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('tracks different roots independently', async () => { const other = mkdtempSync(join(tmpdir(), 'kimi-workspace-trust-other-')); try { From 287f1868212848903367062a23576f093d1742f5 Mon Sep 17 00:00:00 2001 From: Atirna <288419661+atirna@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:19:07 +0530 Subject: [PATCH 2/2] fix(workspace): preserve inherited trust revocations --- .../workspaceTrust/workspaceTrustService.ts | 91 +++++++++++++------ .../workspaceTrust/workspaceTrust.test.ts | 37 +++++++- 2 files changed, 98 insertions(+), 30 deletions(-) diff --git a/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts b/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts index d5efb28121..db8bc48c7e 100644 --- a/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts @@ -4,14 +4,13 @@ * Persists the trust marker through the `persistence` domain's * `IAtomicDocumentStore` under the `workspace-trust` scope, one document per * workspace keyed by `encodeWorkDirKey(root)`, with the raw root kept in the - * value for inspection. A document for this root or an ancestor grants trust: - * `trust()` writes this root's document, `untrust()` deletes it. The record - * lives under the kimi home, + * value for inspection. The nearest document for this root or an ancestor + * decides trust; legacy documents without `trusted` remain trusted. `trust()` + * writes an allow record and `untrust()` writes a deny record for this root. + * The record lives under the kimi home, * never inside the workspace, so a checked-out tree cannot pre-trust - * itself. The flag is read once through `ready` and every later mutation - * goes through this service, so the view is in-process: another process - * flipping the same record is picked up only on restart (a `docs.watch` - * sync can join when a second writer exists). A read failure resolves to + * itself. The flag is read through `ready`, watches every ancestor record, and + * every later mutation goes through this service. A read failure resolves to * untrusted. The plain-data state (`trusted`) is registered into * `workspaceState` (`IWorkspaceStateService`) and read/written through it. * Bound at Workspace scope. @@ -33,7 +32,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( @@ -60,6 +61,7 @@ export class WorkspaceTrustService extends Disposable implements IWorkspaceTrust this.states.register(workspaceTrustTrustedKey); this.root = workspace.cwd; this.storeKey = encodeWorkDirKey(workspace.cwd); + this.watchTrustRecords(); this.ready = this.initialize(); } @@ -84,40 +86,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 { 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 { try { - if ((await this.docs.get(TRUST_SCOPE, this.storeKey)) !== undefined) { - this.trusted = true; - return; - } - - let current = dirname(normalize(this.root)); - while (true) { - if ((await this.docs.get(TRUST_SCOPE, encodeWorkDirKey(current))) !== undefined) { - this.trusted = true; - return; - } - const parent = dirname(current); - if (parent === current) break; - current = parent; - } - this.trusted = false; + this.trusted = await this.readTrusted(); } catch { this.trusted = false; } } + + private async refresh(): Promise { + try { + this.updateTrusted(await this.readTrusted()); + } catch { + this.updateTrusted(false); + } + } + + private async readTrusted(): Promise { + for (const key of this.trustRecordKeys()) { + const record = await this.docs.get(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 }); + } } 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 7d8eb91af0..480c6700fe 100644 --- a/packages/agent-core-v2/test/workspace/workspaceTrust/workspaceTrust.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceTrust/workspaceTrust.test.ts @@ -15,7 +15,7 @@ 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'; @@ -148,6 +148,41 @@ describe('WorkspaceTrustService', () => { } }); + 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 {