Repository navigation
feat(agent-core-v2): persist user and project permission rules #4550
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@moonshot-ai/kimi-code": patch | ||
| --- | ||
|
|
||
| Persist user and project permission rules and apply them when sessions start. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,12 +4,19 @@ import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; | |
|
|
||
| import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; | ||
| import { IAgentStateService } from '#/agent/state/agentState'; | ||
| import { IConfigService } from '#/app/config/config'; | ||
| import { IEventDispatcher } from '#/state/eventDispatcher'; | ||
| import { | ||
| IAgentPermissionRulesService, | ||
| type PermissionApprovalResultRecord, | ||
| type PermissionRule, | ||
| } from './permissionRules'; | ||
| import { | ||
| PERMISSION_SECTION, | ||
| readUserPermissionRules, | ||
| type PermissionConfig, | ||
| } from './configSection'; | ||
| import { IProjectPermissionRulesService } from './projectPermissionRules'; | ||
| import { | ||
| PermissionRecordApprovalResult, | ||
| PermissionRulesAdd, | ||
|
|
@@ -23,23 +30,55 @@ export class AgentPermissionRulesService implements IAgentPermissionRulesService | |
| @IEventDispatcher private readonly dispatcher: IEventDispatcher, | ||
| @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, | ||
| @IAgentStateService private readonly agentState: IAgentStateService, | ||
| @IConfigService private readonly config: IConfigService, | ||
| @IProjectPermissionRulesService private readonly projectRules: IProjectPermissionRulesService, | ||
| ) { | ||
| this.agentState.contributeState(permissionRulesKey); | ||
| } | ||
|
|
||
| get rules(): readonly PermissionRule[] { | ||
| return [...this.agentState.get(permissionRulesKey).rules]; | ||
| return dedupePermissionRules([ | ||
| ...this.agentState.get(permissionRulesKey).rules, | ||
| ...this.projectRules.rules, | ||
| ...readUserPermissionRules(this.config), | ||
| ]); | ||
| } | ||
|
|
||
| get sessionApprovalRulePatterns(): readonly string[] { | ||
| return [...this.agentState.get(permissionRulesKey).sessionApprovalRulePatterns]; | ||
| } | ||
|
|
||
| addRules(rules: readonly PermissionRule[]): void { | ||
| async addRules(rules: readonly PermissionRule[]): Promise<void> { | ||
| if (rules.length === 0) return; | ||
| void this.dispatcher.dispatch( | ||
| new PermissionRulesAdd({ agentId: this.scopeContext.agentId, rules: [...rules] }), | ||
| const runtimeRules = rules.filter( | ||
| (rule) => rule.scope === 'turn-override' || rule.scope === 'session-runtime', | ||
| ); | ||
| const userRules = rules.filter((rule) => rule.scope === 'user'); | ||
| const projectRules = missingPermissionRules( | ||
| this.projectRules.rules, | ||
| rules.filter((rule) => rule.scope === 'project'), | ||
| ); | ||
| const writes: Promise<void>[] = []; | ||
| 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<PermissionConfig | undefined>(PERMISSION_SECTION); | ||
|
Comment on lines
+68
to
+69
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When two CLI or server processes persist user rules concurrently, each process has its own AGENTS.md reference: AGENTS.md:L131-L133 Useful? React with 👍 / 👎. |
||
| const existing = current?.rules ?? []; | ||
| const appended = missingPermissionRules(existing, userRules); | ||
| if (appended.length > 0) { | ||
| await this.config.replace(PERMISSION_SECTION, { | ||
| ...current, | ||
| rules: [...existing, ...appended], | ||
| }); | ||
| } | ||
| })); | ||
| } | ||
| if (projectRules.length > 0) writes.push(this.projectRules.appendRules(projectRules)); | ||
| await Promise.all(writes); | ||
| } | ||
|
|
||
| recordApprovalResult(record: PermissionApprovalResultRecord): void { | ||
|
|
@@ -52,6 +91,41 @@ export class AgentPermissionRulesService implements IAgentPermissionRulesService | |
| } | ||
| } | ||
|
|
||
| const userConfigWriteTails = new WeakMap<IConfigService, Promise<void>>(); | ||
|
|
||
| function enqueueUserConfigWrite(config: IConfigService, work: () => Promise<void>): Promise<void> { | ||
| const previous = userConfigWriteTails.get(config) ?? Promise.resolve(); | ||
| const run = previous.then(work, work); | ||
| userConfigWriteTails.set(config, run.then( | ||
| () => undefined, | ||
| () => undefined, | ||
| )); | ||
| return run; | ||
| } | ||
|
|
||
| function permissionRuleKey(rule: PermissionRule): string { | ||
| return `${rule.decision}\u0000${rule.scope}\u0000${rule.pattern}\u0000${rule.reason ?? ''}`; | ||
| } | ||
|
|
||
| function missingPermissionRules( | ||
| existing: readonly PermissionRule[], | ||
| rules: readonly PermissionRule[], | ||
| ): PermissionRule[] { | ||
| const seen = new Set(existing.map(permissionRuleKey)); | ||
| const out: PermissionRule[] = []; | ||
| for (const rule of rules) { | ||
| const key = permissionRuleKey(rule); | ||
| if (seen.has(key)) continue; | ||
| seen.add(key); | ||
| out.push(rule); | ||
| } | ||
| return out; | ||
| } | ||
|
|
||
| function dedupePermissionRules(rules: readonly PermissionRule[]): PermissionRule[] { | ||
| return missingPermissionRules([], rules); | ||
| } | ||
|
|
||
| registerScopedService( | ||
| LifecycleScope.Agent, | ||
| IAgentPermissionRulesService, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<void>; | ||
| readonly rules: readonly PermissionRule[]; | ||
| appendRules(rules: readonly PermissionRule[]): Promise<void>; | ||
| } | ||
|
|
||
| export const IProjectPermissionRulesService: ServiceIdentifier<IProjectPermissionRulesService> = | ||
| createDecorator<IProjectPermissionRulesService>('projectPermissionRulesService'); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; | ||
| import { LifecycleScope } from '#/app/scopes'; | ||
| import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; | ||
| import { ErrorCodes, Error2 } from '#/errors'; | ||
| import { LOCAL_ENVIRONMENT_ID } from '#/environment/environment'; | ||
| import { ISessionContext } from '#/session/sessionContext/sessionContext'; | ||
| import { ISessionWorkdir } from '#/session/workdir/workdir'; | ||
| import { IWorkspaceTrust } from '#/workspace/workspaceTrust/workspaceTrust'; | ||
|
|
||
| import { PersistedPermissionRuleSchema } from './configSection'; | ||
| import type { PermissionRule } from './permissionRules'; | ||
| import { IProjectPermissionRulesService } from './projectPermissionRules'; | ||
|
|
||
| export class ProjectPermissionRulesService implements IProjectPermissionRulesService { | ||
| declare readonly _serviceBrand: undefined; | ||
|
|
||
| private readonly cwd: string; | ||
| private readonly local: boolean; | ||
| private currentRules: readonly PermissionRule[] = []; | ||
| private trusted = false; | ||
| readonly ready: Promise<void>; | ||
| private mutationTail: Promise<void> = Promise.resolve(); | ||
|
|
||
| constructor( | ||
| @ISessionContext session: ISessionContext, | ||
| @ISessionWorkdir workdir: ISessionWorkdir, | ||
| @IProjectLocalConfigService private readonly localConfig: IProjectLocalConfigService, | ||
| @IWorkspaceTrust private readonly trust: IWorkspaceTrust, | ||
| ) { | ||
| this.cwd = workdir.cwd ?? session.cwd; | ||
| this.local = workdir.environmentId === LOCAL_ENVIRONMENT_ID; | ||
| this.ready = this.local ? this.enqueue(() => this.loadFromDisk()) : Promise.resolve(); | ||
|
Comment on lines
+30
to
+32
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a server or SDK caller creates a session with AGENTS.md reference: AGENTS.md:L131-L133 Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| get rules(): readonly PermissionRule[] { | ||
| return this.currentRules; | ||
| } | ||
|
|
||
| appendRules(rules: readonly PermissionRule[]): Promise<void> { | ||
| if (!this.local) { | ||
| return Promise.reject(new Error('Project permission rules are not supported in remote environments')); | ||
| } | ||
| return this.enqueue(async () => { | ||
| const appended = await this.localConfig.appendPermissionRules(this.cwd, rules); | ||
| if (this.trusted) this.currentRules = parseProjectPermissionRules(appended.rules); | ||
| }); | ||
| } | ||
|
|
||
| private async loadFromDisk(): Promise<void> { | ||
| this.trusted = await this.trust.get(); | ||
| if (!this.trusted) return; | ||
| const loaded = await this.localConfig.readPermissionRules(this.cwd); | ||
|
Comment on lines
+50
to
+52
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a session uses an SSH, Docker, or command environment, Useful? React with 👍 / 👎. |
||
| this.currentRules = parseProjectPermissionRules(loaded.rules); | ||
| } | ||
|
|
||
| private enqueue<T>(work: () => Promise<T>): Promise<T> { | ||
| const run = this.mutationTail.then(work, work); | ||
| this.mutationTail = run.then( | ||
| () => undefined, | ||
| () => undefined, | ||
| ); | ||
| return run; | ||
| } | ||
| } | ||
|
|
||
| function parseProjectPermissionRules(raw: readonly unknown[]): readonly PermissionRule[] { | ||
| try { | ||
| return raw.map((entry) => PersistedPermissionRuleSchema.parse( | ||
| typeof entry === 'object' && entry !== null && !Array.isArray(entry) | ||
| ? { scope: 'project', ...entry } | ||
| : entry, | ||
| )); | ||
| } catch (error) { | ||
| throw new Error2(ErrorCodes.CONFIG_INVALID, 'Invalid project permission rule', { cause: error }); | ||
| } | ||
| } | ||
|
|
||
| registerScopedService( | ||
| LifecycleScope.Session, | ||
| IProjectPermissionRulesService, | ||
| ProjectPermissionRulesService, | ||
| ScopeActivation.OnScopeCreated, | ||
| 'projectPermissionRules', | ||
| ); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
~/.kimi-code/config.tomlcontainsscope = "project", this shared persisted schema accepts it andreadUserPermissionRules()subsequently supplies the rule to every agent in every workspace. Anallowintended to be project-local can therefore silently relax approvals in unrelated projects; the user-config schema should accept onlyuser, while.kimi-code/local.tomlshould accept/default onlyproject.AGENTS.md reference: AGENTS.md:L131-L133
Useful? React with 👍 / 👎.