diff --git a/.changeset/persist-permission-rules.md b/.changeset/persist-permission-rules.md new file mode 100644 index 00000000000..93015c3ecd8 --- /dev/null +++ b/.changeset/persist-permission-rules.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Persist user and project permission rules and apply them when sessions start. diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 592634deca3..0770ec78fe2 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -539,14 +539,18 @@ api_key = "sk-xxx" ## `permission` -`permission` sets permission rules that are automatically loaded when a session starts, controlling whether the Agent needs user confirmation before calling a tool. Rules are written as a `[[permission.rules]]` array of tables, matched in order; the first matching rule takes effect. +`permission` sets permission rules that are automatically loaded when a session starts, controlling whether the Agent needs user confirmation before calling a tool. Rules are written as a `[[permission.rules]]` array of tables. Matching `deny` rules take precedence over matching `ask` rules, which take precedence over matching `allow` rules. + +User rules are loaded from the user `config.toml` at session startup. For compatibility, legacy `turn-override` and `session-runtime` rules in the user config are silently ignored without a warning. + +Project rules are read from `.kimi-code/local.toml` only in the local environment when the workspace is trusted, with an omitted `scope` defaulting to `project`. Remote project rules are not supported. Project rule changes take effect in a new session and are not hot-reloaded. You can also set `dangerous_command_guard = false` under `[permission]` to turn off every built-in guard confirmation policy entirely: dangerous commands, access to sensitive files such as `.env` or `id_rsa`, and access to git control paths no longer ask for confirmation in Always Ask and Ask When Needed mode (these policies are never active in Never Ask mode); the default is `true`. An environment variable `KIMI_CODE_DANGEROUS_COMMAND_GUARD=false` overrides the file setting. With all built-in guards off, only `ask` rules you configured yourself can still prompt in Ask When Needed mode; in Always Ask mode, tools that are not auto-approved continue to prompt as before. The hard block that stops `Read`/`Write`/`Edit` from touching secret files stays in effect. Use this switch only for environments that already gate commands outside the agent. | Field | Type | Required | Description | | --- | --- | --- | --- | | `decision` | `string` | Yes | Action on match: `allow` (permit immediately), `deny` (reject immediately), `ask` (prompt each time) | -| `scope` | `string` | No | Rule scope: `turn-override`, `session-runtime`, `project`, `user`; defaults to `user` | +| `scope` | `string` | No | Rule scope: `turn-override`, `session-runtime`, `project`, `user`; defaults to `user` in the user config. Project config accepts only `project` and `user` | | `pattern` | `string` | Yes | Match pattern in the form `ToolName` or `ToolName(arg-pattern)`, e.g. `Read` or `Bash(rm -rf*)` | | `reason` | `string` | No | Rule description for debugging and auditing | diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 4ae16709f96..dba64eb8403 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -538,14 +538,18 @@ api_key = "sk-xxx" ## `permission` -`permission` 设置会话启动时自动加载的权限规则,控制 Agent 调用工具时是否需要用户确认。规则用 `[[permission.rules]]` 数组表写出,按顺序匹配,第一条命中即生效。 +`permission` 设置会话启动时自动加载的权限规则,控制 Agent 调用工具时是否需要用户确认。规则用 `[[permission.rules]]` 数组表写出。命中的 `deny` 规则优先于 `ask` 规则,`ask` 规则优先于 `allow` 规则。 + +用户规则在会话启动时从用户 `config.toml` 加载。为兼容旧配置,用户 `config.toml` 中的 `turn-override` 和 `session-runtime` 规则会静默忽略,不产生警告。 + +项目规则仅在本地环境且 workspace 已信任时从 `.kimi-code/local.toml` 读取,省略 `scope` 时默认为 `project`。远程环境不支持项目规则。项目规则修改后需要新建会话生效,不会热重载。 也可以在 `[permission]` 下设置 `dangerous_command_guard = false` 完全关闭所有内置 guard 确认策略:危险命令、敏感文件(如 `.env`、`id_rsa`)访问、git 控制路径访问在 "Always Ask" 和 "Ask When Needed" 模式下不再触发审批("Never Ask" 模式本就不启用这些策略),默认 `true`。环境变量 `KIMI_CODE_DANGEROUS_COMMAND_GUARD=false` 会覆盖文件设置。全部内置 guard 关闭后,"Ask When Needed" 模式下只有你自己配置的 `ask` 规则仍会要求确认;"Always Ask" 模式下未被自动放行的工具照旧逐个询问。`Read`/`Write`/`Edit` 对秘密文件的硬阻断保持不变。此开关只适用于已经在 Agent 之外统一命令限权的环境。 | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | `decision` | `string` | 是 | 匹配后的处置:`allow`(直接放行)、`deny`(直接拒绝)、`ask`(每次询问) | -| `scope` | `string` | 否 | 规则有效范围:`turn-override`、`session-runtime`、`project`、`user`,默认 `user` | +| `scope` | `string` | 否 | 规则有效范围:`turn-override`、`session-runtime`、`project`、`user`,在用户配置中默认 `user`;项目配置仅接受 `project` 和 `user` | | `pattern` | `string` | 是 | 匹配模式,格式为 `工具名` 或 `工具名(参数模式)`,如 `Read`、`Bash(rm -rf*)` | | `reason` | `string` | 否 | 规则说明,仅用于调试和审计 | diff --git a/packages/agent-core-v2/src/agent/permissionRules/configSection.ts b/packages/agent-core-v2/src/agent/permissionRules/configSection.ts index f37a0311068..b1e0b6a83d3 100644 --- a/packages/agent-core-v2/src/agent/permissionRules/configSection.ts +++ b/packages/agent-core-v2/src/agent/permissionRules/configSection.ts @@ -15,6 +15,7 @@ import { } from '#/app/config/toml'; import { parsePermissionPattern } from './matchesRule'; +import type { PermissionRule } from './permissionRules'; export const PERMISSION_SECTION = 'permission'; @@ -35,6 +36,10 @@ export const PermissionRuleSchema = z.object({ reason: z.string().optional(), }); +export const PersistedPermissionRuleSchema = PermissionRuleSchema.extend({ + scope: z.enum(['project', 'user']).default('user'), +}); + export const PermissionConfigSchema = z.object({ rules: z.array(PermissionRuleSchema).optional(), dangerousCommandGuard: z.boolean().optional(), @@ -42,6 +47,12 @@ export const PermissionConfigSchema = z.object({ export type PermissionConfig = z.infer; +export function readUserPermissionRules(config: IConfigService): readonly PermissionRule[] { + config.assertValid(PERMISSION_SECTION); + const rules = config.get(PERMISSION_SECTION)?.rules ?? []; + return rules.filter((rule) => rule.scope === 'project' || rule.scope === 'user'); +} + export const DANGEROUS_COMMAND_GUARD_ENV = 'KIMI_CODE_DANGEROUS_COMMAND_GUARD'; function parseDangerousCommandGuardEnv(raw: string): boolean | undefined { diff --git a/packages/agent-core-v2/src/agent/permissionRules/permissionRules.ts b/packages/agent-core-v2/src/agent/permissionRules/permissionRules.ts index f524b5b4166..74bb2d57e60 100644 --- a/packages/agent-core-v2/src/agent/permissionRules/permissionRules.ts +++ b/packages/agent-core-v2/src/agent/permissionRules/permissionRules.ts @@ -26,7 +26,7 @@ export interface IAgentPermissionRulesService { readonly rules: readonly PermissionRule[]; readonly sessionApprovalRulePatterns: readonly string[]; - addRules(rules: readonly PermissionRule[]): void; + addRules(rules: readonly PermissionRule[]): Promise; recordApprovalResult(record: PermissionApprovalResultRecord): void; } diff --git a/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts index 046ed8c9674..351ebdb7c1b 100644 --- a/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts +++ b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts @@ -4,12 +4,19 @@ import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; +import { IConfigService } from '#/app/config/config'; import { IEventDispatcher } from '#/state/eventDispatcher'; import { IAgentPermissionRulesService, type PermissionApprovalResultRecord, type PermissionRule, } from './permissionRules'; +import { + PERMISSION_SECTION, + readUserPermissionRules, + type PermissionConfig, +} from './configSection'; +import { IProjectPermissionRulesService } from './projectPermissionRules'; import { PermissionRecordApprovalResult, PermissionRulesAdd, @@ -23,23 +30,55 @@ export class AgentPermissionRulesService implements IAgentPermissionRulesService @IEventDispatcher private readonly dispatcher: IEventDispatcher, @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @IAgentStateService private readonly agentState: IAgentStateService, + @IConfigService private readonly config: IConfigService, + @IProjectPermissionRulesService private readonly projectRules: IProjectPermissionRulesService, ) { this.agentState.contributeState(permissionRulesKey); } get rules(): readonly PermissionRule[] { - return [...this.agentState.get(permissionRulesKey).rules]; + return dedupePermissionRules([ + ...this.agentState.get(permissionRulesKey).rules, + ...this.projectRules.rules, + ...readUserPermissionRules(this.config), + ]); } get sessionApprovalRulePatterns(): readonly string[] { return [...this.agentState.get(permissionRulesKey).sessionApprovalRulePatterns]; } - addRules(rules: readonly PermissionRule[]): void { + async addRules(rules: readonly PermissionRule[]): Promise { if (rules.length === 0) return; - void this.dispatcher.dispatch( - new PermissionRulesAdd({ agentId: this.scopeContext.agentId, rules: [...rules] }), + const runtimeRules = rules.filter( + (rule) => rule.scope === 'turn-override' || rule.scope === 'session-runtime', ); + const userRules = rules.filter((rule) => rule.scope === 'user'); + const projectRules = missingPermissionRules( + this.projectRules.rules, + rules.filter((rule) => rule.scope === 'project'), + ); + const writes: Promise[] = []; + if (runtimeRules.length > 0) { + writes.push(this.dispatcher.dispatch( + new PermissionRulesAdd({ agentId: this.scopeContext.agentId, rules: runtimeRules }), + )); + } + if (userRules.length > 0) { + writes.push(enqueueUserConfigWrite(this.config, async () => { + const current = this.config.get(PERMISSION_SECTION); + const existing = current?.rules ?? []; + const appended = missingPermissionRules(existing, userRules); + if (appended.length > 0) { + await this.config.replace(PERMISSION_SECTION, { + ...current, + rules: [...existing, ...appended], + }); + } + })); + } + if (projectRules.length > 0) writes.push(this.projectRules.appendRules(projectRules)); + await Promise.all(writes); } recordApprovalResult(record: PermissionApprovalResultRecord): void { @@ -52,6 +91,41 @@ export class AgentPermissionRulesService implements IAgentPermissionRulesService } } +const userConfigWriteTails = new WeakMap>(); + +function enqueueUserConfigWrite(config: IConfigService, work: () => Promise): Promise { + const previous = userConfigWriteTails.get(config) ?? Promise.resolve(); + const run = previous.then(work, work); + userConfigWriteTails.set(config, run.then( + () => undefined, + () => undefined, + )); + return run; +} + +function permissionRuleKey(rule: PermissionRule): string { + return `${rule.decision}\u0000${rule.scope}\u0000${rule.pattern}\u0000${rule.reason ?? ''}`; +} + +function missingPermissionRules( + existing: readonly PermissionRule[], + rules: readonly PermissionRule[], +): PermissionRule[] { + const seen = new Set(existing.map(permissionRuleKey)); + const out: PermissionRule[] = []; + for (const rule of rules) { + const key = permissionRuleKey(rule); + if (seen.has(key)) continue; + seen.add(key); + out.push(rule); + } + return out; +} + +function dedupePermissionRules(rules: readonly PermissionRule[]): PermissionRule[] { + return missingPermissionRules([], rules); +} + registerScopedService( LifecycleScope.Agent, IAgentPermissionRulesService, diff --git a/packages/agent-core-v2/src/agent/permissionRules/projectPermissionRules.ts b/packages/agent-core-v2/src/agent/permissionRules/projectPermissionRules.ts new file mode 100644 index 00000000000..d7d15ced350 --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionRules/projectPermissionRules.ts @@ -0,0 +1,14 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +import type { PermissionRule } from './permissionRules'; + +export interface IProjectPermissionRulesService { + readonly _serviceBrand: undefined; + + readonly ready: Promise; + readonly rules: readonly PermissionRule[]; + appendRules(rules: readonly PermissionRule[]): Promise; +} + +export const IProjectPermissionRulesService: ServiceIdentifier = + createDecorator('projectPermissionRulesService'); diff --git a/packages/agent-core-v2/src/agent/permissionRules/projectPermissionRulesService.ts b/packages/agent-core-v2/src/agent/permissionRules/projectPermissionRulesService.ts new file mode 100644 index 00000000000..964668b8af9 --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionRules/projectPermissionRulesService.ts @@ -0,0 +1,84 @@ +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; +import { ErrorCodes, Error2 } from '#/errors'; +import { LOCAL_ENVIRONMENT_ID } from '#/environment/environment'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionWorkdir } from '#/session/workdir/workdir'; +import { IWorkspaceTrust } from '#/workspace/workspaceTrust/workspaceTrust'; + +import { PersistedPermissionRuleSchema } from './configSection'; +import type { PermissionRule } from './permissionRules'; +import { IProjectPermissionRulesService } from './projectPermissionRules'; + +export class ProjectPermissionRulesService implements IProjectPermissionRulesService { + declare readonly _serviceBrand: undefined; + + private readonly cwd: string; + private readonly local: boolean; + private currentRules: readonly PermissionRule[] = []; + private trusted = false; + readonly ready: Promise; + private mutationTail: Promise = Promise.resolve(); + + constructor( + @ISessionContext session: ISessionContext, + @ISessionWorkdir workdir: ISessionWorkdir, + @IProjectLocalConfigService private readonly localConfig: IProjectLocalConfigService, + @IWorkspaceTrust private readonly trust: IWorkspaceTrust, + ) { + this.cwd = workdir.cwd ?? session.cwd; + this.local = workdir.environmentId === LOCAL_ENVIRONMENT_ID; + this.ready = this.local ? this.enqueue(() => this.loadFromDisk()) : Promise.resolve(); + } + + get rules(): readonly PermissionRule[] { + return this.currentRules; + } + + appendRules(rules: readonly PermissionRule[]): Promise { + if (!this.local) { + return Promise.reject(new Error('Project permission rules are not supported in remote environments')); + } + return this.enqueue(async () => { + const appended = await this.localConfig.appendPermissionRules(this.cwd, rules); + if (this.trusted) this.currentRules = parseProjectPermissionRules(appended.rules); + }); + } + + private async loadFromDisk(): Promise { + this.trusted = await this.trust.get(); + if (!this.trusted) return; + const loaded = await this.localConfig.readPermissionRules(this.cwd); + this.currentRules = parseProjectPermissionRules(loaded.rules); + } + + private enqueue(work: () => Promise): Promise { + const run = this.mutationTail.then(work, work); + this.mutationTail = run.then( + () => undefined, + () => undefined, + ); + return run; + } +} + +function parseProjectPermissionRules(raw: readonly unknown[]): readonly PermissionRule[] { + try { + return raw.map((entry) => PersistedPermissionRuleSchema.parse( + typeof entry === 'object' && entry !== null && !Array.isArray(entry) + ? { scope: 'project', ...entry } + : entry, + )); + } catch (error) { + throw new Error2(ErrorCodes.CONFIG_INVALID, 'Invalid project permission rule', { cause: error }); + } +} + +registerScopedService( + LifecycleScope.Session, + IProjectPermissionRulesService, + ProjectPermissionRulesService, + ScopeActivation.OnScopeCreated, + 'projectPermissionRules', +); diff --git a/packages/agent-core-v2/src/app/projectLocalConfig/projectLocalConfig.ts b/packages/agent-core-v2/src/app/projectLocalConfig/projectLocalConfig.ts index 04473e32819..9e6b629a60b 100644 --- a/packages/agent-core-v2/src/app/projectLocalConfig/projectLocalConfig.ts +++ b/packages/agent-core-v2/src/app/projectLocalConfig/projectLocalConfig.ts @@ -6,11 +6,22 @@ export interface ProjectAdditionalDirsLoadResult { readonly additionalDirs: readonly string[]; } +export interface ProjectPermissionRulesLoadResult { + readonly projectRoot: string; + readonly configPath: string; + readonly rules: readonly unknown[]; +} + export interface IProjectLocalConfigService { readonly _serviceBrand: undefined; readAdditionalDirs(workDir: string): Promise; resolveAdditionalDirs(baseDir: string, additionalDirs: readonly string[]): Promise; + readPermissionRules(workDir: string): Promise; + appendPermissionRules( + workDir: string, + rules: readonly unknown[], + ): Promise; } export const IProjectLocalConfigService: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 850a94325a4..61899438490 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -722,6 +722,8 @@ import '#/agent/permissionRules/configSection'; export * from '#/agent/permissionRules/permissionRules'; export * from '#/agent/permissionRules/matchesRule'; export * from '#/agent/permissionRules/permissionRulesService'; +export * from '#/agent/permissionRules/projectPermissionRules'; +export * from '#/agent/permissionRules/projectPermissionRulesService'; export * from '#/agent/pluginCommand/pluginCommand'; export * from '#/agent/pluginCommand/pluginCommandService'; export * from '#/agent/profile/profile'; diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/projectLocalConfigService.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/projectLocalConfigService.ts index a3c10cd4703..b196be606dc 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/projectLocalConfigService.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/projectLocalConfigService.ts @@ -1,5 +1,7 @@ +import { randomUUID } from 'node:crypto'; + import { dirname, isAbsolute, join, normalize, resolve } from 'pathe'; -import { parse as parseToml } from 'smol-toml'; +import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'; import { z } from 'zod'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; @@ -7,6 +9,7 @@ import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IProjectLocalConfigService, type ProjectAdditionalDirsLoadResult, + type ProjectPermissionRulesLoadResult, } from '#/app/projectLocalConfig/projectLocalConfig'; import { ErrorCodes, Error2, unwrapErrorCause } from '#/errors'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; @@ -23,6 +26,8 @@ const ProjectLocalTomlSchema = z.object({ type ProjectLocalToml = z.infer; +const permissionWriteTails = new Map>(); + export class FileProjectLocalConfigService implements IProjectLocalConfigService { declare readonly _serviceBrand: undefined; @@ -35,9 +40,9 @@ export class FileProjectLocalConfigService implements IProjectLocalConfigService async readAdditionalDirs(workDir: string): Promise { const projectRoot = await this.findProjectRoot(workDir); const configPath = this.getProjectLocalConfigPath(projectRoot); - const parsed = await this.readProjectLocalToml(configPath); + const file = await this.readProjectLocalToml(configPath); - const additionalDirs = parsed?.workspace?.additional_dir; + const additionalDirs = file?.parsed.workspace?.additional_dir; if (additionalDirs === undefined) { return { projectRoot, configPath, additionalDirs: [] }; } @@ -53,6 +58,68 @@ export class FileProjectLocalConfigService implements IProjectLocalConfigService return this.resolveAdditionalDirsInternal(baseDir, additionalDirs); } + async readPermissionRules(workDir: string): Promise { + const projectRoot = await this.findProjectRoot(workDir); + const configPath = this.getProjectLocalConfigPath(projectRoot); + const file = await this.readProjectLocalToml(configPath); + return { projectRoot, configPath, rules: readRawPermissionRules(file?.raw) }; + } + + async appendPermissionRules( + workDir: string, + rules: readonly unknown[], + ): Promise { + const projectRoot = await this.findProjectRoot(workDir); + const configPath = this.getProjectLocalConfigPath(projectRoot); + return enqueuePermissionWrite(configPath, async () => { + await this.assertNoSymlinks(projectRoot, configPath); + const file = (await this.readProjectLocalToml(configPath)) ?? { raw: {}, parsed: {} }; + const existing = readRawPermissionRules(file.raw); + const permission = cloneRecord(file.raw['permission']); + permission['rules'] = [...existing, ...rules]; + file.raw['permission'] = permission; + + if (this.fs.rename === undefined) { + throw new Error2( + ErrorCodes.CONFIG_INVALID, + 'Project local config filesystem does not support atomic replacement', + ); + } + const tempPath = `${configPath}.tmp.${process.pid}.${randomUUID()}`; + let renamed = false; + try { + await this.fs.mkdir(dirname(configPath), { recursive: true }); + await this.fs.writeText(tempPath, `${stringifyToml(file.raw)}\n`); + await this.fs.rename(tempPath, configPath); + renamed = true; + } catch (error: unknown) { + throw toStorageIoError(error, { path: configPath, op: 'write' }); + } finally { + if (!renamed) await this.fs.remove(tempPath).catch(() => undefined); + } + + return { projectRoot, configPath, rules: permission['rules'] as unknown[] }; + }); + } + + private async assertNoSymlinks(projectRoot: string, configPath: string): Promise { + for (const path of [projectRoot, dirname(configPath), configPath]) { + let stat: Awaited>; + try { + stat = await this.fs.lstat(path); + } catch (error: unknown) { + if (isPathMissing(error)) continue; + throw toStorageIoError(error, { path, op: 'stat' }); + } + if (stat.isSymbolicLink === true) { + throw new Error2( + ErrorCodes.CONFIG_INVALID, + `Refusing to write project permission rules through symlink ${path}`, + ); + } + } + } + private getProjectLocalConfigPath(projectRoot: string): string { return join(projectRoot, '.kimi-code', 'local.toml'); } @@ -69,7 +136,9 @@ export class FileProjectLocalConfigService implements IProjectLocalConfigService } } - private async readProjectLocalToml(configPath: string): Promise { + private async readProjectLocalToml( + configPath: string, + ): Promise<{ raw: Record; parsed: ProjectLocalToml } | undefined> { let text: string; try { text = await this.fs.readText(configPath); @@ -78,7 +147,7 @@ export class FileProjectLocalConfigService implements IProjectLocalConfigService throw toStorageIoError(error, { path: configPath, op: 'read' }); } - if (text.trim().length === 0) return {}; + if (text.trim().length === 0) return { raw: {}, parsed: {} }; let raw: unknown; try { @@ -101,7 +170,7 @@ export class FileProjectLocalConfigService implements IProjectLocalConfigService ); } - return parseProjectLocalToml(raw); + return { raw, parsed: parseProjectLocalToml(raw) }; } private async resolveAdditionalDirsInternal( @@ -230,6 +299,35 @@ function describeProjectLocalValidationError(error: z.ZodError): string { return `Invalid project local config: ${error.message}`; } +function enqueuePermissionWrite(configPath: string, work: () => Promise): Promise { + const previous = permissionWriteTails.get(configPath) ?? Promise.resolve(); + const run = previous.then(work, work); + permissionWriteTails.set(configPath, run.then( + () => undefined, + () => undefined, + )); + return run; +} + +function cloneRecord(value: unknown): Record { + if (!isPlainObject(value)) return {}; + return JSON.parse(JSON.stringify(value)) as Record; +} + +function readRawPermissionRules(raw: Record | undefined): unknown[] { + const permission = raw?.['permission']; + if (permission === undefined) return []; + if (!isPlainObject(permission)) { + throw new Error2(ErrorCodes.CONFIG_INVALID, 'permission must be a table'); + } + const rules = permission['rules']; + if (rules === undefined) return []; + if (!Array.isArray(rules)) { + throw new Error2(ErrorCodes.CONFIG_INVALID, 'permission.rules must be an array'); + } + return [...rules]; +} + function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } diff --git a/packages/agent-core-v2/src/program/program.ts b/packages/agent-core-v2/src/program/program.ts index 68b5a9fc7e1..28102374fbe 100644 --- a/packages/agent-core-v2/src/program/program.ts +++ b/packages/agent-core-v2/src/program/program.ts @@ -153,6 +153,7 @@ export class Program { userAgentProfiles: generation.userAgentProfiles, pluginAgentProfiles: generation.pluginAgentProfiles, mcp: generation.mcp, + trust: generation.trust, acquireWorkdir: (environmentId, cwd) => generation.workdirs.acquire(environmentId, cwd), onDispose: release, }); diff --git a/packages/agent-core-v2/src/program/programDependencies.ts b/packages/agent-core-v2/src/program/programDependencies.ts index 53c502c85b2..fd7799a023c 100644 --- a/packages/agent-core-v2/src/program/programDependencies.ts +++ b/packages/agent-core-v2/src/program/programDependencies.ts @@ -24,6 +24,7 @@ import type { IExplicitAgentProfileLoader } from '#/workspace/workspaceAgentProf import type { IPluginAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; import type { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; import type { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; +import type { IWorkspaceTrust } from '#/workspace/workspaceTrust/workspaceTrust'; import type { IWorkspaceTrustStore } from '#/workspace/workspaceTrust/workspaceTrustStore'; import type { ProgramWorkdir } from './programWorkdirs'; @@ -34,6 +35,7 @@ export interface ProgramSessionControllerInput { readonly userAgentProfiles: IUserAgentProfileLoader; readonly pluginAgentProfiles: IPluginAgentProfileLoader; readonly mcp: IWorkspaceMcpService; + readonly trust: IWorkspaceTrust; readonly acquireWorkdir: (environmentId: string, cwd: string) => Promise; readonly onDispose: () => void; } diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index ea211dd154e..2885aa5f812 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -24,10 +24,13 @@ import { touchForkedFileHistory, } from '#/features/fileHistory/fileHistoryRetention'; import { IAgentLoopService } from '#/agent/loop/loop'; +import { readUserPermissionRules } from '#/agent/permissionRules/configSection'; +import { IProjectPermissionRulesService } from '#/agent/permissionRules/projectPermissionRules'; import { IAgentPlanService } from '#/features/plan/plan'; import { LifecycleScope } from '#/app/scopes'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; +import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; import { IEventService } from '#/app/event/event'; import { IFlagService } from '#/app/flag/flag'; import { CHILD_SESSION_KIND, @@ -87,6 +90,7 @@ import { flattenChain } from '#/wire/tree/index'; import { IModelService } from '#/llm-adapter/model/model'; import { IProviderService } from '#/llm-adapter/provider/provider'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { IWorkspaceTrust } from '#/workspace/workspaceTrust/workspaceTrust'; import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; import { IPluginAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; import { @@ -202,6 +206,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec @IModelService private readonly models: IModelService, @IProviderService private readonly providers: IProviderService, private readonly environments: IEnvironmentService, + private readonly trust: IWorkspaceTrust, private readonly acquireWorkdir: (environmentId: string, cwd: string) => Promise, onDispose?: () => void, ) { @@ -281,6 +286,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec const dirs = workdir.dirs; try { await Promise.all([this.config.ready, this.models.ready, this.providers.ready]); + readUserPermissionRules(this.config); await dirs.ready; await dirs.mergeAdditionalDirs(environment?.cwd ?? opts.workDir, opts.additionalDirs ?? []); } catch (error) { @@ -310,6 +316,8 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec { seeds: [ ...sessionContextSeed(ctx), + [IProjectLocalConfigService, workdir.localConfig], + [IWorkspaceTrust, this.trust], [ITelemetryService, telemetryBinding.telemetry], ...sessionAgentProfileCatalogSeed({ _serviceBrand: undefined, @@ -360,6 +368,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec await handle.accessor.get(ISessionMetadata).ready; await handle.accessor.get(ISessionNotify).ready; await handle.accessor.get(ISessionToolPolicy).ready; + await handle.accessor.get(IProjectPermissionRulesService).ready; await Promise.all([ workdir.agentProfiles.ready, this.extraAgentProfileLoader.ready, diff --git a/packages/agent-core-v2/src/workspace/workspaceInstance/workspaceInstanceManagerService.ts b/packages/agent-core-v2/src/workspace/workspaceInstance/workspaceInstanceManagerService.ts index cf03121a644..271edd41c26 100644 --- a/packages/agent-core-v2/src/workspace/workspaceInstance/workspaceInstanceManagerService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceInstance/workspaceInstanceManagerService.ts @@ -287,6 +287,7 @@ export class WorkspaceInstanceManager implements IWorkspaceInstanceManager { this.models, this.modelProviders, this.environments, + input.trust, input.acquireWorkdir, input.onDispose, ), diff --git a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts index 1c0e85e4ffd..1e862f1c410 100644 --- a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts +++ b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts @@ -735,7 +735,7 @@ function permissionRulesStub( get sessionApprovalRulePatterns() { return sessionApprovalRulePatterns(); }, - addRules: () => {}, + addRules: () => Promise.resolve(), recordApprovalResult: () => {}, }; } diff --git a/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts b/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts index 7e1d2f6b3c8..29529459190 100644 --- a/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts +++ b/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts @@ -3,17 +3,30 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; +import { ILogService } from '#/_base/log/log'; +import { IConfigService } from '#/app/config/config'; import { IAgentPermissionRulesService, type PermissionApprovalResultRecord, type PermissionRule } from '#/agent/permissionRules/permissionRules'; import { AgentPermissionRulesService } from '#/agent/permissionRules/permissionRulesService'; +import { IProjectPermissionRulesService } from '#/agent/permissionRules/projectPermissionRules'; +import { ProjectPermissionRulesService } from '#/agent/permissionRules/projectPermissionRulesService'; +import { + PERMISSION_SECTION, + PermissionConfigSchema, + readUserPermissionRules, + type PermissionConfig, +} from '#/agent/permissionRules/configSection'; import { permissionRulesKey } from '#/agent/permissionRules/permissionRulesOps'; +import { UserConfiguredDenyPermissionPolicyService } from '#/agent/permissionPolicy/policies/user-configured-deny'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { IAgentStateService } from '#/agent/state/agentState'; +import { AgentStateService } from '#/agent/state/agentStateService'; import { IEventDispatcher } from '#/state/eventDispatcher'; import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record'; +import { stubLog } from '../../_base/log/stubs'; import { registerTestAgentWire, registerTestEventDispatcher, @@ -38,17 +51,79 @@ function sessionApproval(pattern: string): PermissionApprovalResultRecord { }; } +class InMemoryConfigStub { + permission: PermissionConfig | undefined; + replaceError: Error | undefined; + readonly replaced: PermissionConfig[] = []; + private instance: IConfigService | undefined; + + asService(): IConfigService { + this.instance ??= { + _serviceBrand: undefined, + get: (domain: string): T => + (domain === PERMISSION_SECTION ? this.permission : undefined) as T, + assertValid: (domain: string): void => { + if (domain === PERMISSION_SECTION && this.permission !== undefined) { + PermissionConfigSchema.parse(this.permission); + } + }, + replace: async (domain: string, value: unknown): Promise => { + await Promise.resolve(); + if (this.replaceError !== undefined) throw this.replaceError; + if (domain === PERMISSION_SECTION) { + this.permission = value as PermissionConfig; + this.replaced.push(this.permission); + } + }, + } as unknown as IConfigService; + return this.instance; + } +} + +interface ProjectRulesStub { + readonly service: IProjectPermissionRulesService; + rules: PermissionRule[]; + readonly appended: PermissionRule[]; +} + +function stubProjectRules(): ProjectRulesStub { + const stub: ProjectRulesStub = { + rules: [], + appended: [], + service: { + _serviceBrand: undefined, + ready: Promise.resolve(), + get rules() { + return stub.rules; + }, + appendRules: (rules: readonly PermissionRule[]) => { + stub.appended.push(...rules); + stub.rules = [...stub.rules, ...rules]; + return Promise.resolve(); + }, + }, + }; + return stub; +} + let disposables: DisposableStore; let ix: TestInstantiationService; let log: IAppendLogStore; let dispatcher: IEventDispatcher; let svc: IAgentPermissionRulesService; +let config: InMemoryConfigStub; +let project: ProjectRulesStub; beforeEach(() => { disposables = new DisposableStore(); ix = disposables.add(new TestInstantiationService()); ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + config = new InMemoryConfigStub(); + project = stubProjectRules(); + ix.stub(IConfigService, config.asService()); + ix.stub(IProjectPermissionRulesService, project.service); + ix.stub(ILogService, stubLog()); ix.set(IAgentPermissionRulesService, new SyncDescriptor(AgentPermissionRulesService)); log = ix.get(IAppendLogStore); registerTestAgentWire(ix, testWireScope(SCOPE, KEY), { log }); @@ -68,42 +143,175 @@ async function readRecords(): Promise { } describe('AgentPermissionRulesService (wire-backed)', () => { - it('addRules appends rules and exposes the accumulated rules', () => { - expect(svc.rules).toEqual([]); + it('loads startup deny rules and awaits durable writeback without copying it into state', async () => { + const configRule = { decision: 'deny', scope: 'user', pattern: 'Bash' } as const; + const projectRule: PermissionRule = { decision: 'allow', scope: 'project', pattern: 'Read(**)' }; + config.permission = { rules: [configRule] }; + project.rules = [projectRule]; + + const startup = new AgentPermissionRulesService( + { dispatch: async () => undefined } as never, + { agentId: 'startup-agent' } as never, + new AgentStateService(), + config.asService(), + project.service, + ); + expect(startup.rules).toEqual([projectRule, configRule]); + expect(new UserConfiguredDenyPermissionPolicyService(startup).evaluate({ + toolCall: { name: 'Bash' }, + execution: {}, + } as never)).toMatchObject({ kind: 'deny' }); - svc.addRules([allowRule]); - expect(svc.rules).toEqual([allowRule]); - svc.addRules([denyRule]); - expect(svc.rules).toEqual([allowRule, denyRule]); + const userRule: PermissionRule = { decision: 'allow', scope: 'user', pattern: 'Write(**)' }; + const otherUserRule: PermissionRule = { decision: 'ask', scope: 'user', pattern: 'Glob(**)' }; + const newProjectRule: PermissionRule = { decision: 'deny', scope: 'project', pattern: 'Bash(rm -rf *)' }; + await svc.addRules([allowRule, userRule, newProjectRule, configRule]); - svc.addRules([]); - expect(svc.rules).toEqual([allowRule, denyRule]); + expect(svc.rules).toEqual([allowRule, projectRule, newProjectRule, configRule, userRule]); + expect(ix.get(IAgentStateService).get(permissionRulesKey).rules).toEqual([allowRule]); + expect(project.appended).toEqual([newProjectRule]); + + const other = new AgentPermissionRulesService( + { dispatch: async () => undefined } as never, + { agentId: 'other-agent' } as never, + new AgentStateService(), + config.asService(), + project.service, + ); + await other.addRules([otherUserRule]); + await svc.addRules([]); + expect(config.permission?.rules).toEqual([configRule, userRule, otherUserRule]); + + config.replaceError = new Error('write failed'); + await expect(svc.addRules([ + { decision: 'deny', scope: 'user', pattern: 'Edit(**)' }, + ])).rejects.toThrow('write failed'); + expect(config.permission?.rules).toEqual([configRule, userRule, otherUserRule]); + + const restarted = new AgentPermissionRulesService( + { dispatch: async () => undefined } as never, + { agentId: 'restarted-agent' } as never, + new AgentStateService(), + config.asService(), + project.service, + ); + expect(restarted.rules).toEqual([ + projectRule, + newProjectRule, + configRule, + userRule, + otherUserRule, + ]); }); - it('records a session approval pattern', () => { + it('records session approval patterns only for session approvals', () => { const approval = sessionApproval('Bash(rm *)'); svc.recordApprovalResult(approval); expect(svc.sessionApprovalRulePatterns).toEqual(['Bash(rm *)']); svc.recordApprovalResult(approval); - expect(svc.sessionApprovalRulePatterns).toEqual(['Bash(rm *)']); - }); - - it('ignores non-session approvals for the pattern set', () => { - const oneTime: PermissionApprovalResultRecord = { + svc.recordApprovalResult({ turnId: 2, toolCallId: 'call-2', toolName: 'Write', action: 'Write(/tmp/x)', result: { decision: 'approved' }, + }); + expect(svc.sessionApprovalRulePatterns).toEqual(['Bash(rm *)']); + }); + + it('loads local trusted rules, ignores legacy user runtime scopes, and rejects remote project IO', async () => { + const projectRule: PermissionRule = { decision: 'deny', scope: 'project', pattern: 'Bash' }; + const appendedRule: PermissionRule = { decision: 'allow', scope: 'project', pattern: 'Write' }; + const runtimeRule: PermissionRule = { decision: 'deny', scope: 'session-runtime', pattern: 'Bash' }; + const calls: string[] = []; + let rawRules: unknown[] = [runtimeRule]; + let trusted = false; + let failAppend = false; + const localConfig = { + readPermissionRules: async (cwd: string) => { + calls.push(cwd); + return { projectRoot: cwd, configPath: `${cwd}/.kimi-code/local.toml`, rules: rawRules }; + }, + appendPermissionRules: async (cwd: string, rules: readonly unknown[]) => { + calls.push(cwd); + if (failAppend) throw new Error('append failed'); + rawRules = [...rawRules, ...rules]; + return { projectRoot: cwd, configPath: `${cwd}/.kimi-code/local.toml`, rules: rawRules }; + }, + } as never; + const trust = { get: async () => trusted } as never; + const untrusted = new ProjectPermissionRulesService( + { cwd: '/local-association' } as never, + { environmentId: 'local', cwd: '/local/project' } as never, + localConfig, + trust, + ); + await untrusted.ready; + expect(untrusted.rules).toEqual([]); + expect(calls).toEqual([]); + + trusted = true; + rawRules = [projectRule]; + const local = new ProjectPermissionRulesService( + { cwd: '/local-association' } as never, + { environmentId: 'local', cwd: '/local/project' } as never, + localConfig, + trust, + ); + await local.ready; + expect(local.rules).toEqual([projectRule]); + + rawRules = [projectRule, appendedRule]; + expect(local.rules).toEqual([projectRule]); + rawRules = [projectRule]; + failAppend = true; + await expect(local.appendRules([appendedRule])).rejects.toThrow('append failed'); + expect(local.rules).toEqual([projectRule]); + + failAppend = false; + await local.appendRules([appendedRule]); + expect(local.rules).toEqual([projectRule, appendedRule]); + + rawRules = [runtimeRule]; + const invalid = new ProjectPermissionRulesService( + { cwd: '/local-association' } as never, + { environmentId: 'local', cwd: '/local/project' } as never, + localConfig, + trust, + ); + await expect(invalid.ready).rejects.toThrow('Invalid project permission rule'); + + const remote = new ProjectPermissionRulesService( + { cwd: '/local-association' } as never, + { environmentId: 'ssh', cwd: '/remote/project' } as never, + localConfig, + { get: async () => { throw new Error('remote trust must not be read'); } } as never, + ); + await remote.ready; + expect(remote.rules).toEqual([]); + await expect(remote.appendRules([appendedRule])).rejects.toThrow('not supported'); + expect(calls).toEqual([ + '/local/project', + '/local/project', + '/local/project', + '/local/project', + ]); + + const userRule: PermissionRule = { ...runtimeRule, scope: 'user' }; + config.permission = { + rules: [runtimeRule, { ...runtimeRule, scope: 'turn-override' }, userRule], }; - svc.recordApprovalResult(oneTime); - expect(svc.sessionApprovalRulePatterns).toEqual([]); + expect(readUserPermissionRules(config.asService())).toEqual([userRule]); + config.permission = { + rules: [{ ...runtimeRule, scope: 'invalid' }], + } as unknown as PermissionConfig; + expect(() => readUserPermissionRules(config.asService())).toThrow(); }); it('only persists approval records (permission.rules.add is live-only)', async () => { - svc.addRules([allowRule]); + await svc.addRules([allowRule]); svc.recordApprovalResult(sessionApproval('Bash(rm *)')); const records = await readRecords(); @@ -121,10 +329,12 @@ describe('AgentPermissionRulesService (wire-backed)', () => { }, ]); expect(records.every((record) => 'payload' in record === false)).toBe(true); + expect(config.replaced).toEqual([]); + expect(project.appended).toEqual([]); }); it('replay rebuilds session approval patterns only (rules are not persisted)', async () => { - svc.addRules([allowRule, denyRule]); + await svc.addRules([allowRule, denyRule]); svc.recordApprovalResult(sessionApproval('Bash(rm *)')); const records = await readRecords(); diff --git a/packages/agent-core-v2/test/agent/permissionRules/stubs.ts b/packages/agent-core-v2/test/agent/permissionRules/stubs.ts index 075c771ed18..befc7b96b4e 100644 --- a/packages/agent-core-v2/test/agent/permissionRules/stubs.ts +++ b/packages/agent-core-v2/test/agent/permissionRules/stubs.ts @@ -12,7 +12,7 @@ export function stubPermissionRulesService( return rules(); }, sessionApprovalRulePatterns: [], - addRules: () => {}, + addRules: () => Promise.resolve(), recordApprovalResult: () => {}, }; } diff --git a/packages/agent-core-v2/test/agent/toolApproval/toolApproval.test.ts b/packages/agent-core-v2/test/agent/toolApproval/toolApproval.test.ts index db0484e3095..7c0cea74fa9 100644 --- a/packages/agent-core-v2/test/agent/toolApproval/toolApproval.test.ts +++ b/packages/agent-core-v2/test/agent/toolApproval/toolApproval.test.ts @@ -117,7 +117,7 @@ describe('AgentToolApprovalService', () => { _serviceBrand: undefined, rules: [], sessionApprovalRulePatterns: [], - addRules: () => {}, + addRules: () => Promise.resolve(), recordApprovalResult: (record) => { recorded.push(record); }, diff --git a/packages/agent-core-v2/test/features/plan/plan.test.ts b/packages/agent-core-v2/test/features/plan/plan.test.ts index 3ed6d2a4068..9c7631432ff 100644 --- a/packages/agent-core-v2/test/features/plan/plan.test.ts +++ b/packages/agent-core-v2/test/features/plan/plan.test.ts @@ -548,7 +548,7 @@ describe('Plan service', () => { useFakes(fakes); useTools(['ExitPlanMode']); if (allowRule) { - permissionRules.addRules([{ decision: 'allow', scope: 'user', pattern: 'ExitPlanMode' }]); + await permissionRules.addRules([{ decision: 'allow', scope: 'user', pattern: 'ExitPlanMode' }]); } await ctx.rpc.setPermission({ mode }); await plan.enter('test-plan', false); @@ -798,7 +798,7 @@ describe('Plan service', () => { }); useFakes(createPlanFakes({ writeText })); useTools(['Write']); - permissionRules.addRules([ + await permissionRules.addRules([ { decision: 'deny', scope: 'user', diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 399a6bb3cab..8cd2c039f48 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -157,6 +157,7 @@ import { IAgentPermissionGate, IAgentPermissionModeService, IAgentPermissionRulesService, + IProjectPermissionRulesService, IHostFileSystem, IHostProcessService, ISessionBtwService, @@ -176,6 +177,7 @@ import { ISessionUsageService, ISessionWorkspaceContext, IWorkspaceStateService, + IWorkspaceTrust, LifecycleScope, AgentMcpService, AgentPermissionGate, @@ -1322,6 +1324,9 @@ export class AgentTestContext { cwd: this.cwd, fs: undefined as never, }); + reg.definePartialInstance(IWorkspaceTrust, { + get: async () => false, + }); reg.defineInstance( IWorkspaceStateService, new WorkspaceStateService(this.root.accessor.get(IAppStateService)), @@ -1426,6 +1431,12 @@ export class AgentTestContext { IAgentPermissionRulesService, new SyncDescriptor(AgentPermissionRulesService), ); + reg.defineInstance(IProjectPermissionRulesService, { + _serviceBrand: undefined, + ready: Promise.resolve(), + rules: [], + appendRules: () => Promise.resolve(), + }); reg.defineDescriptor( IAgentPermissionGate, new SyncDescriptor(AgentPermissionGate), @@ -2408,6 +2419,7 @@ function createPermissionRulesStub( }, addRules: (nextRules) => { rules = [...rules, ...nextRules]; + return Promise.resolve(); }, recordApprovalResult: () => { }, }; @@ -2601,6 +2613,7 @@ function configService(readConfig: () => KimiConfig): IConfigService { onDidChangeConfiguration: () => ({ dispose: () => { } }), onDidSectionChange: sectionEmitter.event, get: (domain: string) => valueFor(domain) as T, + assertValid: () => {}, inspect: (domain: string) => { const value = (effectiveConfig() as Record)[domain]; return { diff --git a/packages/agent-core-v2/test/persistence/backends/node-fs/projectLocalConfigService.test.ts b/packages/agent-core-v2/test/persistence/backends/node-fs/projectLocalConfigService.test.ts index a973c19ca75..472ee59927b 100644 --- a/packages/agent-core-v2/test/persistence/backends/node-fs/projectLocalConfigService.test.ts +++ b/packages/agent-core-v2/test/persistence/backends/node-fs/projectLocalConfigService.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { parse as parseToml } from 'smol-toml'; import { HostFsError, OsFsErrors } from '#/os/interface/hostFsErrors'; import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; @@ -26,6 +27,91 @@ describe('FileProjectLocalConfigService.readAdditionalDirs', () => { }); }); +describe('FileProjectLocalConfigService permissionRules', () => { + it('serializes atomic appends and rejects unsafe or invalid config without losing the last valid file', async () => { + const existing = { decision: 'deny', scope: 'project', pattern: 'Bash(git push *)' }; + const added = { decision: 'allow', scope: 'project', pattern: 'Read(**)' }; + const concurrentAdded = { decision: 'ask', scope: 'project', pattern: 'Edit(**)' }; + const configPath = '/repo/.kimi-code/local.toml'; + let text = `[workspace]\nadditional_dir = []\n\n[[permission.rules]]\ndecision = "deny"\nscope = "project"\npattern = "Bash(git push *)"\n`; + const staged = new Map(); + const writes: string[] = []; + const renames: string[] = []; + const symlinks = new Set(); + let failRename = false; + const missing = () => new HostFsError(OsFsErrors.codes.OS_FS_NOT_FOUND, 'missing'); + const fs = { + _serviceBrand: undefined, + readText: async (path: string) => { + if (path === configPath) return text; + const value = staged.get(path); + if (value === undefined) throw missing(); + return value; + }, + lstat: async (path: string) => { + if (['/repo', '/repo/.git', '/repo/.kimi-code', configPath].includes(path)) { + return { isSymbolicLink: symlinks.has(path) }; + } + throw missing(); + }, + mkdir: async (_path: string, _options: unknown) => undefined, + writeText: async (path: string, value: string) => { + await Promise.resolve(); + writes.push(path); + staged.set(path, value); + }, + rename: async (from: string, to: string) => { + if (failRename) throw new Error('rename failed'); + const value = staged.get(from); + if (value === undefined) throw missing(); + text = value; + staged.delete(from); + renames.push(to); + }, + remove: async (path: string) => { + staged.delete(path); + }, + } as unknown as IHostFileSystem; + const service = new FileProjectLocalConfigService( + { _serviceBrand: undefined, osHomeDir: '' } as never, + fs, + ); + + const [appended] = await Promise.all([ + service.appendPermissionRules('/repo', [added]), + service.appendPermissionRules('/repo', [concurrentAdded]), + ]); + + expect(appended.rules).toEqual([existing, added]); + expect(parseToml(text)).toMatchObject({ + workspace: { additional_dir: [] }, + permission: { rules: [existing, added, concurrentAdded] }, + }); + await expect(service.readPermissionRules('/repo')).resolves.toMatchObject({ + rules: [existing, added, concurrentAdded], + }); + expect(writes.every((path) => path !== configPath)).toBe(true); + expect(renames).toEqual([configPath, configPath]); + + const validText = text; + failRename = true; + await expect(service.appendPermissionRules('/repo', [added])).rejects.toThrow(); + expect(text).toBe(validText); + failRename = false; + for (const path of [configPath, '/repo/.kimi-code']) { + symlinks.add(path); + await expect(service.appendPermissionRules('/repo', [added])).rejects.toThrow('symlink'); + symlinks.clear(); + } + expect(text).toBe(validText); + + text = '[permission]\nrules = "invalid"\n'; + await expect(service.readPermissionRules('/repo')).rejects.toThrow('permission.rules must be an array'); + text = '[permission\nrules = []\n'; + await expect(service.readPermissionRules('/repo')).rejects.toThrow('Invalid TOML'); + }); +}); + describe('FileProjectLocalConfigService.resolveAdditionalDirs', () => { function service(osHomeDir: string, homeDir?: string): { service: FileProjectLocalConfigService; stated: string[] } { const stated: string[] = []; @@ -42,19 +128,17 @@ describe('FileProjectLocalConfigService.resolveAdditionalDirs', () => { }; } - it('expands ~ with the client home when no target home is given', async () => { + it('expands ~ with the target home when given and otherwise the client home', async () => { const { service: local, stated } = service('/client/home'); await expect(local.resolveAdditionalDirs('/work', ['~/shared'])).resolves.toEqual(['/client/home/shared']); expect(stated).toEqual(['/client/home/shared']); - }); - it('expands ~ with the target home instead of the client home', async () => { - const { service: remote, stated } = service('/client/home', '/remote/home'); + const { service: remote, stated: remoteStated } = service('/client/home', '/remote/home'); await expect(remote.resolveAdditionalDirs('/work', ['~/shared', '~'])).resolves.toEqual([ '/remote/home/shared', '/remote/home', ]); - expect(stated).toEqual(['/remote/home/shared', '/remote/home']); + expect(remoteStated).toEqual(['/remote/home/shared', '/remote/home']); }); it('leaves ~ unexpanded when the target home is unknown', async () => { diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index 3ee939dba50..9202576fe1a 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -95,6 +95,7 @@ import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionWorkdir } from '#/session/workdir/workdir'; +import { IWorkspaceTrust } from '#/workspace/workspaceTrust/workspaceTrust'; import { ISessionMetadata, type AgentMeta } from '#/session/sessionMetadata/sessionMetadata'; import { createWireMetadataRecord, type WireRecord } from '#/wire/record'; import { IWireService } from '#/wire/wire'; @@ -319,6 +320,9 @@ describe('AgentLifecycleService', () => { environmentId: 'local', fs: undefined, } as unknown as ISessionWorkdir); + ix.stub(IWorkspaceTrust, { + get: async () => false, + } as unknown as IWorkspaceTrust); ix.stub(IWorkspaceInstanceManager, { _serviceBrand: undefined, onDidChange: () => ({ dispose: () => {} }), diff --git a/packages/agent-core-v2/test/session/subagent/subagentScopeCache.test.ts b/packages/agent-core-v2/test/session/subagent/subagentScopeCache.test.ts index 73f50127de8..2503728c5a5 100644 --- a/packages/agent-core-v2/test/session/subagent/subagentScopeCache.test.ts +++ b/packages/agent-core-v2/test/session/subagent/subagentScopeCache.test.ts @@ -73,6 +73,7 @@ import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionWorkdir } from '#/session/workdir/workdir'; +import { IWorkspaceTrust } from '#/workspace/workspaceTrust/workspaceTrust'; import { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; @@ -380,6 +381,9 @@ describe('SessionSubagentScopeCacheService', () => { environmentId: 'local', fs: undefined, } as unknown as ISessionWorkdir); + ix.stub(IWorkspaceTrust, { + get: async () => false, + } as unknown as IWorkspaceTrust); ix.stub(IWorkspaceInstanceManager, { _serviceBrand: undefined, onDidChange: () => ({ dispose: () => {} }), diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycleClose.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycleClose.test.ts index 6bb1ea5edf7..edfe611804a 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycleClose.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycleClose.test.ts @@ -34,6 +34,7 @@ function closeHarness(environments: EnvironmentRegistry): SessionLifecycleServic undefined as never, environments as unknown as IEnvironmentService, undefined as never, + undefined as never, ); } diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycleService.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycleService.test.ts index 5344092e3c7..7a4eaed17b6 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycleService.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycleService.test.ts @@ -53,7 +53,7 @@ describe('SessionLifecycleService', () => { undefined, { workspaceId: 'wm_test', persistenceScope: 'sessions/wm_test' }, { homeDir: '/home' }, - { ready }, + { ready, assertValid: () => {}, get: () => undefined }, undefined, undefined, undefined,