From bc74b75d939b9bfd9ba83f62c9e232bc0cab57d5 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 29 Oct 2026 21:00:12 +0800 Subject: [PATCH 1/3] feat(agent-core-v2): persist user and project permission rules - load and watch user and project permission rules - persist scoped rule additions without losing concurrent writes - use the active environment's workdir and filesystem for project rules --- .changeset/persist-permission-rules.md | 5 + .../permissionRules/permissionRulesService.ts | 91 ++++++++++- .../permissionRules/projectPermissionRules.ts | 14 ++ .../projectPermissionRulesService.ts | 128 ++++++++++++++++ .../projectLocalConfig/projectLocalConfig.ts | 11 ++ packages/agent-core-v2/src/index.ts | 2 + .../node-fs/projectLocalConfigService.ts | 70 ++++++++- .../sessionLifecycleService.ts | 4 + .../permissionRules/permissionRules.test.ts | 141 +++++++++++++++--- packages/agent-core-v2/test/harness/agent.ts | 7 + .../node-fs/projectLocalConfigService.test.ts | 46 +++++- 11 files changed, 490 insertions(+), 29 deletions(-) create mode 100644 .changeset/persist-permission-rules.md create mode 100644 packages/agent-core-v2/src/agent/permissionRules/projectPermissionRules.ts create mode 100644 packages/agent-core-v2/src/agent/permissionRules/projectPermissionRulesService.ts 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/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts index 046ed8c9674..2e8934b73a3 100644 --- a/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts +++ b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts @@ -2,14 +2,18 @@ import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; 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, type PermissionConfig } from './configSection'; +import { IProjectPermissionRulesService } from './projectPermissionRules'; import { PermissionRecordApprovalResult, PermissionRulesAdd, @@ -19,16 +23,26 @@ import { export class AgentPermissionRulesService implements IAgentPermissionRulesService { declare readonly _serviceBrand: undefined; + private persistTail: Promise = Promise.resolve(); + constructor( @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, + @ILogService private readonly log: ILogService, ) { this.agentState.contributeState(permissionRulesKey); } get rules(): readonly PermissionRule[] { - return [...this.agentState.get(permissionRulesKey).rules]; + const configRules = this.config.get(PERMISSION_SECTION)?.rules ?? []; + return dedupePermissionRules([ + ...this.agentState.get(permissionRulesKey).rules, + ...this.projectRules.rules, + ...configRules, + ]); } get sessionApprovalRulePatterns(): readonly string[] { @@ -40,6 +54,13 @@ export class AgentPermissionRulesService implements IAgentPermissionRulesService void this.dispatcher.dispatch( new PermissionRulesAdd({ agentId: this.scopeContext.agentId, rules: [...rules] }), ); + const userRules = rules.filter((rule) => rule.scope === 'user'); + const projectScopedRules = rules.filter((rule) => rule.scope === 'project'); + if (userRules.length === 0 && projectScopedRules.length === 0) return; + this.persistTail = this.persistTail.then( + () => this.persistRules(userRules, projectScopedRules), + () => this.persistRules(userRules, projectScopedRules), + ); } recordApprovalResult(record: PermissionApprovalResultRecord): void { @@ -50,6 +71,74 @@ export class AgentPermissionRulesService implements IAgentPermissionRulesService }), ); } + + private async persistRules( + userRules: readonly PermissionRule[], + projectRules: readonly PermissionRule[], + ): Promise { + if (userRules.length > 0) { + try { + await 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], + }); + } + }); + } catch (error) { + this.log.warn(`failed to persist user permission rules: ${String(error)}`); + } + } + if (projectRules.length > 0) { + try { + const appended = missingPermissionRules(this.projectRules.rules, projectRules); + if (appended.length > 0) { + await this.projectRules.appendRules(appended); + } + } catch (error) { + this.log.warn(`failed to persist project permission rules: ${String(error)}`); + } + } + } +} + +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( 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..e59acc5c0b1 --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionRules/projectPermissionRulesService.ts @@ -0,0 +1,128 @@ +import { Disposable } from '#/_base/di/lifecycle'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { TimeoutTimer } from '#/_base/utils/timer'; +import { subtreeWatchFilter } from '#/_base/utils/paths'; +import { LifecycleScope } from '#/app/scopes'; +import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; +import { LOCAL_ENVIRONMENT_ID } from '#/environment/environment'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionWorkdir } from '#/session/workdir/workdir'; +import { watchCandidates } from '#human/utils/watch'; + +import { PermissionRuleSchema } from './configSection'; +import type { PermissionRule } from './permissionRules'; +import { IProjectPermissionRulesService } from './projectPermissionRules'; + +const WATCH_DEBOUNCE_MS = 200; + +export class ProjectPermissionRulesService extends Disposable implements IProjectPermissionRulesService { + declare readonly _serviceBrand: undefined; + + private readonly cwd: string; + private projectRoot: string; + private configPath: string; + private currentRules: readonly PermissionRule[] = []; + readonly ready: Promise; + private readonly watchDebounce = this._register(new TimeoutTimer()); + private mutationTail: Promise = Promise.resolve(); + + constructor( + @ISessionContext session: ISessionContext, + @ISessionWorkdir private readonly workdir: ISessionWorkdir, + @IProjectLocalConfigService private readonly localConfig: IProjectLocalConfigService, + @ILogService private readonly log: ILogService, + ) { + super(); + this.cwd = workdir.cwd ?? session.cwd; + this.projectRoot = this.cwd; + this.configPath = ''; + this.ready = this.enqueue(() => this.reloadFromDisk()); + void this.ready.then(() => { + this.watchLocalToml(); + }); + } + + get rules(): readonly PermissionRule[] { + return this.currentRules; + } + + appendRules(rules: readonly PermissionRule[]): Promise { + return this.enqueue(async () => { + const appended = await this.localConfig.appendPermissionRules(this.cwd, rules); + this.projectRoot = appended.projectRoot; + this.configPath = appended.configPath; + this.currentRules = parseProjectPermissionRules(appended.rules, this.log); + }); + } + + private async reloadFromDisk(): Promise { + try { + const loaded = await this.localConfig.readPermissionRules(this.cwd); + this.projectRoot = loaded.projectRoot; + this.configPath = loaded.configPath; + this.currentRules = parseProjectPermissionRules(loaded.rules, this.log); + } catch (error) { + this.log.warn(`project permission rules load failed: ${String(error)}`); + this.currentRules = []; + } + } + + private watchLocalToml(): void { + if (this.workdir.environmentId !== LOCAL_ENVIRONMENT_ID || this.configPath === '') return; + try { + const handle = watchCandidates(this.projectRoot, [this.configPath], { + ignored: subtreeWatchFilter(this.projectRoot, [this.configPath]), + }); + this._register(handle); + this._register( + handle.onDidChange(() => { + this.watchDebounce.cancelAndSet(() => { + void this.enqueue(() => this.reloadFromDisk()).catch((error) => { + this.log.warn(`local.toml permission rules reload failed: ${String(error)}`); + }); + }, WATCH_DEBOUNCE_MS); + }), + ); + } catch (error) { + this.log.warn(`cannot watch project-local config ${this.configPath}: ${String(error)}`); + } + } + + 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[], + log: ILogService, +): readonly PermissionRule[] { + const rules: PermissionRule[] = []; + for (const entry of raw) { + const withDefault = + typeof entry === 'object' && entry !== null && !Array.isArray(entry) + ? { scope: 'project', ...entry } + : entry; + const parsed = PermissionRuleSchema.safeParse(withDefault); + if (parsed.success) { + rules.push(parsed.data); + } else { + log.warn(`ignoring invalid project permission rule: ${JSON.stringify(entry)}`); + } + } + return rules; +} + +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..1a9e463b8bd 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,5 @@ 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 +7,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 +24,8 @@ const ProjectLocalTomlSchema = z.object({ type ProjectLocalToml = z.infer; +const permissionWriteTails = new Map>(); + export class FileProjectLocalConfigService implements IProjectLocalConfigService { declare readonly _serviceBrand: undefined; @@ -35,9 +38,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 +56,37 @@ 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 () => { + 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; + + try { + await this.fs.mkdir(dirname(configPath), { recursive: true }); + await this.fs.writeText(configPath, `${stringifyToml(file.raw)}\n`); + } catch (error: unknown) { + throw toStorageIoError(error, { path: configPath, op: 'write' }); + } + + return { projectRoot, configPath, rules: permission['rules'] as unknown[] }; + }); + } + private getProjectLocalConfigPath(projectRoot: string): string { return join(projectRoot, '.kimi-code', 'local.toml'); } @@ -69,7 +103,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 +114,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 +137,7 @@ export class FileProjectLocalConfigService implements IProjectLocalConfigService ); } - return parseProjectLocalToml(raw); + return { raw, parsed: parseProjectLocalToml(raw) }; } private async resolveAdditionalDirsInternal( @@ -230,6 +266,28 @@ 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 (!isPlainObject(permission)) return []; + const rules = permission['rules']; + return Array.isArray(rules) ? [...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/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index ea211dd154e..81b5a9e6a3e 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -24,10 +24,12 @@ import { touchForkedFileHistory, } from '#/features/fileHistory/fileHistoryRetention'; import { IAgentLoopService } from '#/agent/loop/loop'; +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, @@ -310,6 +312,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec { seeds: [ ...sessionContextSeed(ctx), + [IProjectLocalConfigService, workdir.localConfig], [ITelemetryService, telemetryBinding.telemetry], ...sessionAgentProfileCatalogSeed({ _serviceBrand: undefined, @@ -360,6 +363,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/test/agent/permissionRules/permissionRules.test.ts b/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts index 7e1d2f6b3c8..f35c7808274 100644 --- a/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts +++ b/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts @@ -1,19 +1,26 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } 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, type PermissionConfig } from '#/agent/permissionRules/configSection'; import { permissionRulesKey } from '#/agent/permissionRules/permissionRulesOps'; 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 +45,72 @@ function sessionApproval(pattern: string): PermissionApprovalResultRecord { }; } +class InMemoryConfigStub { + permission: PermissionConfig | 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, + replace: async (domain: string, value: unknown): Promise => { + await Promise.resolve(); + 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,38 +130,81 @@ async function readRecords(): Promise { } describe('AgentPermissionRulesService (wire-backed)', () => { - it('addRules appends rules and exposes the accumulated rules', () => { - expect(svc.rules).toEqual([]); + it('merges state, project, and config rules and persists durable scopes', async () => { + const configRule: PermissionRule = { decision: 'deny', scope: 'user', pattern: 'Bash(git push *)' }; + const projectRule: PermissionRule = { decision: 'allow', scope: 'project', pattern: 'Read(**)' }; + config.permission = { rules: [configRule] }; + project.rules = [projectRule]; - svc.addRules([allowRule]); - expect(svc.rules).toEqual([allowRule]); - svc.addRules([denyRule]); - expect(svc.rules).toEqual([allowRule, denyRule]); + expect(svc.rules).toEqual([projectRule, configRule]); + + 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 *)' }; + const other = new AgentPermissionRulesService( + { dispatch: async () => undefined } as never, + { agentId: 'other-agent' } as never, + new AgentStateService(), + config.asService(), + project.service, + stubLog(), + ); + svc.addRules([allowRule, userRule, newProjectRule, configRule]); + + expect(svc.rules).toEqual([allowRule, userRule, newProjectRule, configRule, projectRule]); + + other.addRules([otherUserRule]); + await vi.waitFor(() => { + expect(config.permission?.rules).toEqual([configRule, userRule, otherUserRule]); + expect(project.appended).toEqual([newProjectRule]); + }); svc.addRules([]); - expect(svc.rules).toEqual([allowRule, denyRule]); + expect(svc.rules).toEqual([allowRule, userRule, newProjectRule, configRule, projectRule, 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' }, - }; - svc.recordApprovalResult(oneTime); - expect(svc.sessionApprovalRulePatterns).toEqual([]); + }); + expect(svc.sessionApprovalRulePatterns).toEqual(['Bash(rm *)']); + }); + + it('uses the actual session workdir for project rule IO', async () => { + const remoteRule: PermissionRule = { decision: 'deny', scope: 'project', pattern: 'Bash' }; + const calls: string[] = []; + const localConfig = { + readPermissionRules: async (cwd: string) => { + calls.push(cwd); + return { projectRoot: cwd, configPath: `${cwd}/.kimi-code/local.toml`, rules: [remoteRule] }; + }, + appendPermissionRules: async (cwd: string, rules: readonly unknown[]) => { + calls.push(cwd); + return { projectRoot: cwd, configPath: `${cwd}/.kimi-code/local.toml`, rules }; + }, + } as never; + const remote = new ProjectPermissionRulesService( + { cwd: '/local-association' } as never, + { environmentId: 'ssh', cwd: '/remote/project' } as never, + localConfig, + stubLog(), + ); + + await remote.ready; + expect(remote.rules).toEqual([remoteRule]); + await remote.appendRules([remoteRule]); + expect(calls).toEqual(['/remote/project', '/remote/project']); + remote.dispose(); }); it('only persists approval records (permission.rules.add is live-only)', async () => { @@ -121,6 +226,8 @@ 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 () => { diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 399a6bb3cab..a10a8097677 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, @@ -1426,6 +1427,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), 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..de806f4040b 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,43 @@ describe('FileProjectLocalConfigService.readAdditionalDirs', () => { }); }); +describe('FileProjectLocalConfigService permissionRules', () => { + it('appends rules without dropping other config and reads them back', 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(**)' }; + let text = `[workspace]\nadditional_dir = []\n\n[[permission.rules]]\ndecision = "deny"\nscope = "project"\npattern = "Bash(git push *)"\n`; + const fs = { + _serviceBrand: undefined, + readText: async () => text, + lstat: async (_path: string) => ({}), + mkdir: async (_path: string, _options: unknown) => undefined, + writeText: async (_path: string, value: string) => { + await Promise.resolve(); + text = value; + }, + } 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], + }); + }); +}); + describe('FileProjectLocalConfigService.resolveAdditionalDirs', () => { function service(osHomeDir: string, homeDir?: string): { service: FileProjectLocalConfigService; stated: string[] } { const stated: string[] = []; @@ -42,19 +80,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 () => { From 2d2e4c04ca0147e327ad5cb3554fbe239ffb74bd Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Fri, 30 Oct 2026 15:29:29 +0800 Subject: [PATCH 2/3] refactor(agent-core-v2): simplify permission rule persistence - keep durable rules out of live agent state and await writes - load trusted project rules at session startup and fail closed - use atomic project writes and update config documentation --- docs/en/configuration/config-files.md | 6 +- docs/zh/configuration/config-files.md | 6 +- .../agent-core-v2/docs/config-manifest.toml | 2 +- .../agent/permissionRules/configSection.ts | 12 +- .../agent/permissionRules/permissionRules.ts | 2 +- .../permissionRules/permissionRulesService.ts | 81 +++++----- .../projectPermissionRulesService.ts | 92 +++--------- .../node-fs/projectLocalConfigService.ts | 46 +++++- packages/agent-core-v2/src/program/program.ts | 1 + .../src/program/programDependencies.ts | 2 + .../sessionLifecycleService.ts | 5 + .../workspaceInstanceManagerService.ts | 1 + .../permissionPolicyService.test.ts | 2 +- .../permissionRules/permissionRules.test.ts | 140 ++++++++++++++---- .../test/agent/permissionRules/stubs.ts | 2 +- .../agent/toolApproval/toolApproval.test.ts | 2 +- .../test/features/plan/plan.test.ts | 4 +- packages/agent-core-v2/test/harness/agent.ts | 6 + .../node-fs/projectLocalConfigService.test.ts | 56 ++++++- .../agentLifecycle/agentLifecycle.test.ts | 4 + .../subagent/subagentScopeCache.test.ts | 4 + .../sessionLifecycleClose.test.ts | 1 + 22 files changed, 313 insertions(+), 164 deletions(-) diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 592634deca3..7272f068817 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -539,14 +539,16 @@ 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. Project rules are read from `.kimi-code/local.toml` only when the workspace is trusted, with an omitted `scope` defaulting to `project`; 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 | Persistent rule scope: `project` or `user`; defaults to `user` in the user config. Runtime-only scopes are invalid in config files | | `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..c9fbb05068c 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -538,14 +538,16 @@ api_key = "sk-xxx" ## `permission` -`permission` 设置会话启动时自动加载的权限规则,控制 Agent 调用工具时是否需要用户确认。规则用 `[[permission.rules]]` 数组表写出,按顺序匹配,第一条命中即生效。 +`permission` 设置会话启动时自动加载的权限规则,控制 Agent 调用工具时是否需要用户确认。规则用 `[[permission.rules]]` 数组表写出。命中的 `deny` 规则优先于 `ask` 规则,`ask` 规则优先于 `allow` 规则。 + +用户规则在会话启动时从用户 `config.toml` 加载。项目规则仅在 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` | 否 | 持久化规则范围:`project` 或 `user`,在用户配置中默认 `user`;配置文件不接受仅运行时有效的 scope | | `pattern` | `string` | 是 | 匹配模式,格式为 `工具名` 或 `工具名(参数模式)`,如 `Read`、`Bash(rm -rf*)` | | `reason` | `string` | 否 | 规则说明,仅用于调试和审计 | diff --git a/packages/agent-core-v2/docs/config-manifest.toml b/packages/agent-core-v2/docs/config-manifest.toml index f6761869eb4..730ea0a37f4 100644 --- a/packages/agent-core-v2/docs/config-manifest.toml +++ b/packages/agent-core-v2/docs/config-manifest.toml @@ -334,7 +334,7 @@ merge_all_available_skills = true [permission] # rules: object[] — one entry per item: # decision: "allow" | "deny" | "ask" - # scope: "turn-override" | "session-runtime" | "project" | "user" (default: "user") + # scope: "project" | "user" (default: "user") # pattern: string # reason: string # dangerous_command_guard: boolean diff --git a/packages/agent-core-v2/src/agent/permissionRules/configSection.ts b/packages/agent-core-v2/src/agent/permissionRules/configSection.ts index f37a0311068..e6e24bd69b2 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,13 +36,22 @@ 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(), + rules: z.array(PersistedPermissionRuleSchema).optional(), dangerousCommandGuard: z.boolean().optional(), }); export type PermissionConfig = z.infer; +export function readUserPermissionRules(config: IConfigService): readonly PermissionRule[] { + config.assertValid(PERMISSION_SECTION); + return config.get(PERMISSION_SECTION)?.rules ?? []; +} + 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 2e8934b73a3..351ebdb7c1b 100644 --- a/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts +++ b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts @@ -2,7 +2,6 @@ import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { ILogService } from '#/_base/log/log'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import { IConfigService } from '#/app/config/config'; @@ -12,7 +11,11 @@ import { type PermissionApprovalResultRecord, type PermissionRule, } from './permissionRules'; -import { PERMISSION_SECTION, type PermissionConfig } from './configSection'; +import { + PERMISSION_SECTION, + readUserPermissionRules, + type PermissionConfig, +} from './configSection'; import { IProjectPermissionRulesService } from './projectPermissionRules'; import { PermissionRecordApprovalResult, @@ -23,25 +26,21 @@ import { export class AgentPermissionRulesService implements IAgentPermissionRulesService { declare readonly _serviceBrand: undefined; - private persistTail: Promise = Promise.resolve(); - constructor( @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, - @ILogService private readonly log: ILogService, ) { this.agentState.contributeState(permissionRulesKey); } get rules(): readonly PermissionRule[] { - const configRules = this.config.get(PERMISSION_SECTION)?.rules ?? []; return dedupePermissionRules([ ...this.agentState.get(permissionRulesKey).rules, ...this.projectRules.rules, - ...configRules, + ...readUserPermissionRules(this.config), ]); } @@ -49,18 +48,37 @@ export class AgentPermissionRulesService implements IAgentPermissionRulesService 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 projectScopedRules = rules.filter((rule) => rule.scope === 'project'); - if (userRules.length === 0 && projectScopedRules.length === 0) return; - this.persistTail = this.persistTail.then( - () => this.persistRules(userRules, projectScopedRules), - () => this.persistRules(userRules, projectScopedRules), + 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 { @@ -71,39 +89,6 @@ export class AgentPermissionRulesService implements IAgentPermissionRulesService }), ); } - - private async persistRules( - userRules: readonly PermissionRule[], - projectRules: readonly PermissionRule[], - ): Promise { - if (userRules.length > 0) { - try { - await 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], - }); - } - }); - } catch (error) { - this.log.warn(`failed to persist user permission rules: ${String(error)}`); - } - } - if (projectRules.length > 0) { - try { - const appended = missingPermissionRules(this.projectRules.rules, projectRules); - if (appended.length > 0) { - await this.projectRules.appendRules(appended); - } - } catch (error) { - this.log.warn(`failed to persist project permission rules: ${String(error)}`); - } - } - } } const userConfigWriteTails = new WeakMap>(); diff --git a/packages/agent-core-v2/src/agent/permissionRules/projectPermissionRulesService.ts b/packages/agent-core-v2/src/agent/permissionRules/projectPermissionRulesService.ts index e59acc5c0b1..6a18da9d711 100644 --- a/packages/agent-core-v2/src/agent/permissionRules/projectPermissionRulesService.ts +++ b/packages/agent-core-v2/src/agent/permissionRules/projectPermissionRulesService.ts @@ -1,46 +1,32 @@ -import { Disposable } from '#/_base/di/lifecycle'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { ILogService } from '#/_base/log/log'; -import { TimeoutTimer } from '#/_base/utils/timer'; -import { subtreeWatchFilter } from '#/_base/utils/paths'; import { LifecycleScope } from '#/app/scopes'; import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; -import { LOCAL_ENVIRONMENT_ID } from '#/environment/environment'; +import { ErrorCodes, Error2 } from '#/errors'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionWorkdir } from '#/session/workdir/workdir'; -import { watchCandidates } from '#human/utils/watch'; +import { IWorkspaceTrust } from '#/workspace/workspaceTrust/workspaceTrust'; -import { PermissionRuleSchema } from './configSection'; +import { PersistedPermissionRuleSchema } from './configSection'; import type { PermissionRule } from './permissionRules'; import { IProjectPermissionRulesService } from './projectPermissionRules'; -const WATCH_DEBOUNCE_MS = 200; - -export class ProjectPermissionRulesService extends Disposable implements IProjectPermissionRulesService { +export class ProjectPermissionRulesService implements IProjectPermissionRulesService { declare readonly _serviceBrand: undefined; private readonly cwd: string; - private projectRoot: string; - private configPath: string; private currentRules: readonly PermissionRule[] = []; + private trusted = false; readonly ready: Promise; - private readonly watchDebounce = this._register(new TimeoutTimer()); private mutationTail: Promise = Promise.resolve(); constructor( @ISessionContext session: ISessionContext, - @ISessionWorkdir private readonly workdir: ISessionWorkdir, + @ISessionWorkdir workdir: ISessionWorkdir, @IProjectLocalConfigService private readonly localConfig: IProjectLocalConfigService, - @ILogService private readonly log: ILogService, + @IWorkspaceTrust private readonly trust: IWorkspaceTrust, ) { - super(); this.cwd = workdir.cwd ?? session.cwd; - this.projectRoot = this.cwd; - this.configPath = ''; - this.ready = this.enqueue(() => this.reloadFromDisk()); - void this.ready.then(() => { - this.watchLocalToml(); - }); + this.ready = this.enqueue(() => this.loadFromDisk()); } get rules(): readonly PermissionRule[] { @@ -50,43 +36,15 @@ export class ProjectPermissionRulesService extends Disposable implements IProjec appendRules(rules: readonly PermissionRule[]): Promise { return this.enqueue(async () => { const appended = await this.localConfig.appendPermissionRules(this.cwd, rules); - this.projectRoot = appended.projectRoot; - this.configPath = appended.configPath; - this.currentRules = parseProjectPermissionRules(appended.rules, this.log); + if (this.trusted) this.currentRules = parseProjectPermissionRules(appended.rules); }); } - private async reloadFromDisk(): Promise { - try { - const loaded = await this.localConfig.readPermissionRules(this.cwd); - this.projectRoot = loaded.projectRoot; - this.configPath = loaded.configPath; - this.currentRules = parseProjectPermissionRules(loaded.rules, this.log); - } catch (error) { - this.log.warn(`project permission rules load failed: ${String(error)}`); - this.currentRules = []; - } - } - - private watchLocalToml(): void { - if (this.workdir.environmentId !== LOCAL_ENVIRONMENT_ID || this.configPath === '') return; - try { - const handle = watchCandidates(this.projectRoot, [this.configPath], { - ignored: subtreeWatchFilter(this.projectRoot, [this.configPath]), - }); - this._register(handle); - this._register( - handle.onDidChange(() => { - this.watchDebounce.cancelAndSet(() => { - void this.enqueue(() => this.reloadFromDisk()).catch((error) => { - this.log.warn(`local.toml permission rules reload failed: ${String(error)}`); - }); - }, WATCH_DEBOUNCE_MS); - }), - ); - } catch (error) { - this.log.warn(`cannot watch project-local config ${this.configPath}: ${String(error)}`); - } + 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 { @@ -99,24 +57,16 @@ export class ProjectPermissionRulesService extends Disposable implements IProjec } } -function parseProjectPermissionRules( - raw: readonly unknown[], - log: ILogService, -): readonly PermissionRule[] { - const rules: PermissionRule[] = []; - for (const entry of raw) { - const withDefault = +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; - const parsed = PermissionRuleSchema.safeParse(withDefault); - if (parsed.success) { - rules.push(parsed.data); - } else { - log.warn(`ignoring invalid project permission rule: ${JSON.stringify(entry)}`); - } + : entry, + )); + } catch (error) { + throw new Error2(ErrorCodes.CONFIG_INVALID, 'Invalid project permission rule', { cause: error }); } - return rules; } registerScopedService( 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 1a9e463b8bd..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,3 +1,5 @@ +import { randomUUID } from 'node:crypto'; + import { dirname, isAbsolute, join, normalize, resolve } from 'pathe'; import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'; import { z } from 'zod'; @@ -70,23 +72,54 @@ export class FileProjectLocalConfigService implements IProjectLocalConfigService 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(configPath, `${stringifyToml(file.raw)}\n`); + 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'); } @@ -283,9 +316,16 @@ function cloneRecord(value: unknown): Record { function readRawPermissionRules(raw: Record | undefined): unknown[] { const permission = raw?.['permission']; - if (!isPlainObject(permission)) return []; + if (permission === undefined) return []; + if (!isPlainObject(permission)) { + throw new Error2(ErrorCodes.CONFIG_INVALID, 'permission must be a table'); + } const rules = permission['rules']; - return Array.isArray(rules) ? [...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 { 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 81b5a9e6a3e..2885aa5f812 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -24,6 +24,7 @@ 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'; @@ -89,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 { @@ -204,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, ) { @@ -283,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) { @@ -313,6 +317,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec seeds: [ ...sessionContextSeed(ctx), [IProjectLocalConfigService, workdir.localConfig], + [IWorkspaceTrust, this.trust], [ITelemetryService, telemetryBinding.telemetry], ...sessionAgentProfileCatalogSeed({ _serviceBrand: undefined, 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 f35c7808274..91b86c910e1 100644 --- a/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts +++ b/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; @@ -9,8 +9,14 @@ import { IAgentPermissionRulesService, type PermissionApprovalResultRecord, type import { AgentPermissionRulesService } from '#/agent/permissionRules/permissionRulesService'; import { IProjectPermissionRulesService } from '#/agent/permissionRules/projectPermissionRules'; import { ProjectPermissionRulesService } from '#/agent/permissionRules/projectPermissionRulesService'; -import { PERMISSION_SECTION, type PermissionConfig } from '#/agent/permissionRules/configSection'; +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'; @@ -47,6 +53,7 @@ function sessionApproval(pattern: string): PermissionApprovalResultRecord { class InMemoryConfigStub { permission: PermissionConfig | undefined; + replaceError: Error | undefined; readonly replaced: PermissionConfig[] = []; private instance: IConfigService | undefined; @@ -55,8 +62,14 @@ class InMemoryConfigStub { _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); @@ -130,37 +143,65 @@ async function readRecords(): Promise { } describe('AgentPermissionRulesService (wire-backed)', () => { - it('merges state, project, and config rules and persists durable scopes', async () => { - const configRule: PermissionRule = { decision: 'deny', scope: 'user', pattern: 'Bash(git push *)' }; + 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]; - expect(svc.rules).toEqual([projectRule, configRule]); + 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' }); 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]); + + 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, - stubLog(), ); - svc.addRules([allowRule, userRule, newProjectRule, configRule]); - - expect(svc.rules).toEqual([allowRule, userRule, newProjectRule, configRule, projectRule]); + await other.addRules([otherUserRule]); + await svc.addRules([]); + expect(config.permission?.rules).toEqual([configRule, userRule, otherUserRule]); - other.addRules([otherUserRule]); - await vi.waitFor(() => { - expect(config.permission?.rules).toEqual([configRule, userRule, otherUserRule]); - expect(project.appended).toEqual([newProjectRule]); - }); + 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]); - svc.addRules([]); - expect(svc.rules).toEqual([allowRule, userRule, newProjectRule, configRule, projectRule, 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 session approval patterns only for session approvals', () => { @@ -180,35 +221,82 @@ describe('AgentPermissionRulesService (wire-backed)', () => { expect(svc.sessionApprovalRulePatterns).toEqual(['Bash(rm *)']); }); - it('uses the actual session workdir for project rule IO', async () => { + it('loads trusted project rules once from the actual workdir and fails closed', async () => { const remoteRule: PermissionRule = { decision: 'deny', scope: 'project', pattern: 'Bash' }; + const appendedRule: PermissionRule = { decision: 'allow', scope: 'project', pattern: 'Write' }; + const invalidRule = { decision: 'deny', scope: 'session-runtime', pattern: 'Bash' }; const calls: string[] = []; + let rawRules: unknown[] = [invalidRule]; + 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: [remoteRule] }; + return { projectRoot: cwd, configPath: `${cwd}/.kimi-code/local.toml`, rules: rawRules }; }, appendPermissionRules: async (cwd: string, rules: readonly unknown[]) => { calls.push(cwd); - return { projectRoot: cwd, configPath: `${cwd}/.kimi-code/local.toml`, rules }; + if (failAppend) throw new Error('append failed'); + rawRules = [...rawRules, ...rules]; + return { projectRoot: cwd, configPath: `${cwd}/.kimi-code/local.toml`, rules: rawRules }; }, } as never; - const remote = new ProjectPermissionRulesService( + const trust = { get: async () => trusted } as never; + const untrusted = new ProjectPermissionRulesService( { cwd: '/local-association' } as never, { environmentId: 'ssh', cwd: '/remote/project' } as never, localConfig, - stubLog(), + trust, ); + await untrusted.ready; + expect(untrusted.rules).toEqual([]); + expect(calls).toEqual([]); + trusted = true; + rawRules = [remoteRule]; + const remote = new ProjectPermissionRulesService( + { cwd: '/local-association' } as never, + { environmentId: 'ssh', cwd: '/remote/project' } as never, + localConfig, + trust, + ); await remote.ready; expect(remote.rules).toEqual([remoteRule]); - await remote.appendRules([remoteRule]); - expect(calls).toEqual(['/remote/project', '/remote/project']); - remote.dispose(); + + rawRules = [remoteRule, appendedRule]; + expect(remote.rules).toEqual([remoteRule]); + rawRules = [remoteRule]; + failAppend = true; + await expect(remote.appendRules([appendedRule])).rejects.toThrow('append failed'); + expect(remote.rules).toEqual([remoteRule]); + + failAppend = false; + await remote.appendRules([appendedRule]); + expect(remote.rules).toEqual([remoteRule, appendedRule]); + expect(calls).toHaveLength(3); + expect(calls.every((cwd) => cwd === '/remote/project')).toBe(true); + + rawRules = [invalidRule]; + const invalid = new ProjectPermissionRulesService( + { cwd: '/local-association' } as never, + { environmentId: 'ssh', cwd: '/remote/project' } as never, + localConfig, + trust, + ); + await expect(invalid.ready).rejects.toThrow('Invalid project permission rule'); + expect(calls).toEqual([ + '/remote/project', + '/remote/project', + '/remote/project', + '/remote/project', + ]); + + config.permission = { rules: [invalidRule] } 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(); @@ -231,7 +319,7 @@ describe('AgentPermissionRulesService (wire-backed)', () => { }); 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 a10a8097677..8cd2c039f48 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -177,6 +177,7 @@ import { ISessionUsageService, ISessionWorkspaceContext, IWorkspaceStateService, + IWorkspaceTrust, LifecycleScope, AgentMcpService, AgentPermissionGate, @@ -1323,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)), @@ -2415,6 +2419,7 @@ function createPermissionRulesStub( }, addRules: (nextRules) => { rules = [...rules, ...nextRules]; + return Promise.resolve(); }, recordApprovalResult: () => { }, }; @@ -2608,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 de806f4040b..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 @@ -28,19 +28,48 @@ describe('FileProjectLocalConfigService.readAdditionalDirs', () => { }); describe('FileProjectLocalConfigService permissionRules', () => { - it('appends rules without dropping other config and reads them back', async () => { + 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 () => text, - lstat: async (_path: string) => ({}), + 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) => { + 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( @@ -61,6 +90,25 @@ describe('FileProjectLocalConfigService permissionRules', () => { 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'); }); }); 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, ); } From 8a0808fc89de6b428c6d9918c59b90d82ca749f8 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Fri, 30 Oct 2026 16:34:53 +0800 Subject: [PATCH 3/3] fix(agent-core-v2): limit project rules to local workspaces - ignore legacy runtime scopes in user config without warning - reject project rule persistence in remote environments - update configuration docs and the generated manifest --- docs/en/configuration/config-files.md | 6 +- docs/zh/configuration/config-files.md | 6 +- .../agent-core-v2/docs/config-manifest.toml | 2 +- .../agent/permissionRules/configSection.ts | 5 +- .../projectPermissionRulesService.ts | 8 ++- .../permissionRules/permissionRules.test.ts | 67 ++++++++++++------- .../sessionLifecycleService.test.ts | 2 +- 7 files changed, 61 insertions(+), 35 deletions(-) diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 7272f068817..0770ec78fe2 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -541,14 +541,16 @@ api_key = "sk-xxx" `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. Project rules are read from `.kimi-code/local.toml` only when the workspace is trusted, with an omitted `scope` defaulting to `project`; project rule changes take effect in a new session and are not hot-reloaded. +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 | Persistent rule scope: `project` or `user`; defaults to `user` in the user config. Runtime-only scopes are invalid in config files | +| `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 c9fbb05068c..dba64eb8403 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -540,14 +540,16 @@ api_key = "sk-xxx" `permission` 设置会话启动时自动加载的权限规则,控制 Agent 调用工具时是否需要用户确认。规则用 `[[permission.rules]]` 数组表写出。命中的 `deny` 规则优先于 `ask` 规则,`ask` 规则优先于 `allow` 规则。 -用户规则在会话启动时从用户 `config.toml` 加载。项目规则仅在 workspace 已信任时从 `.kimi-code/local.toml` 读取,省略 `scope` 时默认为 `project`;项目规则修改后需要新建会话生效,不会热重载。 +用户规则在会话启动时从用户 `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` | 否 | 持久化规则范围:`project` 或 `user`,在用户配置中默认 `user`;配置文件不接受仅运行时有效的 scope | +| `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/docs/config-manifest.toml b/packages/agent-core-v2/docs/config-manifest.toml index 730ea0a37f4..f6761869eb4 100644 --- a/packages/agent-core-v2/docs/config-manifest.toml +++ b/packages/agent-core-v2/docs/config-manifest.toml @@ -334,7 +334,7 @@ merge_all_available_skills = true [permission] # rules: object[] — one entry per item: # decision: "allow" | "deny" | "ask" - # scope: "project" | "user" (default: "user") + # scope: "turn-override" | "session-runtime" | "project" | "user" (default: "user") # pattern: string # reason: string # dangerous_command_guard: boolean diff --git a/packages/agent-core-v2/src/agent/permissionRules/configSection.ts b/packages/agent-core-v2/src/agent/permissionRules/configSection.ts index e6e24bd69b2..b1e0b6a83d3 100644 --- a/packages/agent-core-v2/src/agent/permissionRules/configSection.ts +++ b/packages/agent-core-v2/src/agent/permissionRules/configSection.ts @@ -41,7 +41,7 @@ export const PersistedPermissionRuleSchema = PermissionRuleSchema.extend({ }); export const PermissionConfigSchema = z.object({ - rules: z.array(PersistedPermissionRuleSchema).optional(), + rules: z.array(PermissionRuleSchema).optional(), dangerousCommandGuard: z.boolean().optional(), }); @@ -49,7 +49,8 @@ export type PermissionConfig = z.infer; export function readUserPermissionRules(config: IConfigService): readonly PermissionRule[] { config.assertValid(PERMISSION_SECTION); - return config.get(PERMISSION_SECTION)?.rules ?? []; + 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'; diff --git a/packages/agent-core-v2/src/agent/permissionRules/projectPermissionRulesService.ts b/packages/agent-core-v2/src/agent/permissionRules/projectPermissionRulesService.ts index 6a18da9d711..964668b8af9 100644 --- a/packages/agent-core-v2/src/agent/permissionRules/projectPermissionRulesService.ts +++ b/packages/agent-core-v2/src/agent/permissionRules/projectPermissionRulesService.ts @@ -2,6 +2,7 @@ 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'; @@ -14,6 +15,7 @@ export class ProjectPermissionRulesService implements IProjectPermissionRulesSer declare readonly _serviceBrand: undefined; private readonly cwd: string; + private readonly local: boolean; private currentRules: readonly PermissionRule[] = []; private trusted = false; readonly ready: Promise; @@ -26,7 +28,8 @@ export class ProjectPermissionRulesService implements IProjectPermissionRulesSer @IWorkspaceTrust private readonly trust: IWorkspaceTrust, ) { this.cwd = workdir.cwd ?? session.cwd; - this.ready = this.enqueue(() => this.loadFromDisk()); + this.local = workdir.environmentId === LOCAL_ENVIRONMENT_ID; + this.ready = this.local ? this.enqueue(() => this.loadFromDisk()) : Promise.resolve(); } get rules(): readonly PermissionRule[] { @@ -34,6 +37,9 @@ export class ProjectPermissionRulesService implements IProjectPermissionRulesSer } 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); 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 91b86c910e1..29529459190 100644 --- a/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts +++ b/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts @@ -221,12 +221,12 @@ describe('AgentPermissionRulesService (wire-backed)', () => { expect(svc.sessionApprovalRulePatterns).toEqual(['Bash(rm *)']); }); - it('loads trusted project rules once from the actual workdir and fails closed', async () => { - const remoteRule: PermissionRule = { decision: 'deny', scope: 'project', pattern: 'Bash' }; + 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 invalidRule = { decision: 'deny', scope: 'session-runtime', pattern: 'Bash' }; + const runtimeRule: PermissionRule = { decision: 'deny', scope: 'session-runtime', pattern: 'Bash' }; const calls: string[] = []; - let rawRules: unknown[] = [invalidRule]; + let rawRules: unknown[] = [runtimeRule]; let trusted = false; let failAppend = false; const localConfig = { @@ -244,7 +244,7 @@ describe('AgentPermissionRulesService (wire-backed)', () => { const trust = { get: async () => trusted } as never; const untrusted = new ProjectPermissionRulesService( { cwd: '/local-association' } as never, - { environmentId: 'ssh', cwd: '/remote/project' } as never, + { environmentId: 'local', cwd: '/local/project' } as never, localConfig, trust, ); @@ -253,45 +253,60 @@ describe('AgentPermissionRulesService (wire-backed)', () => { expect(calls).toEqual([]); trusted = true; - rawRules = [remoteRule]; - const remote = new ProjectPermissionRulesService( + rawRules = [projectRule]; + const local = new ProjectPermissionRulesService( { cwd: '/local-association' } as never, - { environmentId: 'ssh', cwd: '/remote/project' } as never, + { environmentId: 'local', cwd: '/local/project' } as never, localConfig, trust, ); - await remote.ready; - expect(remote.rules).toEqual([remoteRule]); + await local.ready; + expect(local.rules).toEqual([projectRule]); - rawRules = [remoteRule, appendedRule]; - expect(remote.rules).toEqual([remoteRule]); - rawRules = [remoteRule]; + rawRules = [projectRule, appendedRule]; + expect(local.rules).toEqual([projectRule]); + rawRules = [projectRule]; failAppend = true; - await expect(remote.appendRules([appendedRule])).rejects.toThrow('append failed'); - expect(remote.rules).toEqual([remoteRule]); + await expect(local.appendRules([appendedRule])).rejects.toThrow('append failed'); + expect(local.rules).toEqual([projectRule]); failAppend = false; - await remote.appendRules([appendedRule]); - expect(remote.rules).toEqual([remoteRule, appendedRule]); - expect(calls).toHaveLength(3); - expect(calls.every((cwd) => cwd === '/remote/project')).toBe(true); + await local.appendRules([appendedRule]); + expect(local.rules).toEqual([projectRule, appendedRule]); - rawRules = [invalidRule]; + rawRules = [runtimeRule]; const invalid = new ProjectPermissionRulesService( { cwd: '/local-association' } as never, - { environmentId: 'ssh', cwd: '/remote/project' } 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([ - '/remote/project', - '/remote/project', - '/remote/project', - '/remote/project', + '/local/project', + '/local/project', + '/local/project', + '/local/project', ]); - config.permission = { rules: [invalidRule] } as unknown as PermissionConfig; + const userRule: PermissionRule = { ...runtimeRule, scope: 'user' }; + config.permission = { + rules: [runtimeRule, { ...runtimeRule, scope: 'turn-override' }, userRule], + }; + expect(readUserPermissionRules(config.asService())).toEqual([userRule]); + config.permission = { + rules: [{ ...runtimeRule, scope: 'invalid' }], + } as unknown as PermissionConfig; expect(() => readUserPermissionRules(config.asService())).toThrow(); }); 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,