Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/persist-permission-rules.md
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.
8 changes: 6 additions & 2 deletions docs/en/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -539,14 +539,18 @@ api_key = "sk-xxx"

## `permission`

`permission` sets permission rules that are automatically loaded when a session starts, controlling whether the Agent needs user confirmation before calling a tool. Rules are written as a `[[permission.rules]]` array of tables, matched in order; the first matching rule takes effect.
`permission` sets permission rules that are automatically loaded when a session starts, controlling whether the Agent needs user confirmation before calling a tool. Rules are written as a `[[permission.rules]]` array of tables. Matching `deny` rules take precedence over matching `ask` rules, which take precedence over matching `allow` rules.

User rules are loaded from the user `config.toml` at session startup. For compatibility, legacy `turn-override` and `session-runtime` rules in the user config are silently ignored without a warning.

Project rules are read from `.kimi-code/local.toml` only in the local environment when the workspace is trusted, with an omitted `scope` defaulting to `project`. Remote project rules are not supported. Project rule changes take effect in a new session and are not hot-reloaded.

You can also set `dangerous_command_guard = false` under `[permission]` to turn off every built-in guard confirmation policy entirely: dangerous commands, access to sensitive files such as `.env` or `id_rsa`, and access to git control paths no longer ask for confirmation in Always Ask and Ask When Needed mode (these policies are never active in Never Ask mode); the default is `true`. An environment variable `KIMI_CODE_DANGEROUS_COMMAND_GUARD=false` overrides the file setting. With all built-in guards off, only `ask` rules you configured yourself can still prompt in Ask When Needed mode; in Always Ask mode, tools that are not auto-approved continue to prompt as before. The hard block that stops `Read`/`Write`/`Edit` from touching secret files stays in effect. Use this switch only for environments that already gate commands outside the agent.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `decision` | `string` | Yes | Action on match: `allow` (permit immediately), `deny` (reject immediately), `ask` (prompt each time) |
| `scope` | `string` | No | Rule scope: `turn-override`, `session-runtime`, `project`, `user`; defaults to `user` |
| `scope` | `string` | No | Rule scope: `turn-override`, `session-runtime`, `project`, `user`; defaults to `user` in the user config. Project config accepts only `project` and `user` |
| `pattern` | `string` | Yes | Match pattern in the form `ToolName` or `ToolName(arg-pattern)`, e.g. `Read` or `Bash(rm -rf*)` |
| `reason` | `string` | No | Rule description for debugging and auditing |

Expand Down
8 changes: 6 additions & 2 deletions docs/zh/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -538,14 +538,18 @@ api_key = "sk-xxx"

## `permission`

`permission` 设置会话启动时自动加载的权限规则,控制 Agent 调用工具时是否需要用户确认。规则用 `[[permission.rules]]` 数组表写出,按顺序匹配,第一条命中即生效。
`permission` 设置会话启动时自动加载的权限规则,控制 Agent 调用工具时是否需要用户确认。规则用 `[[permission.rules]]` 数组表写出。命中的 `deny` 规则优先于 `ask` 规则,`ask` 规则优先于 `allow` 规则。

用户规则在会话启动时从用户 `config.toml` 加载。为兼容旧配置,用户 `config.toml` 中的 `turn-override` 和 `session-runtime` 规则会静默忽略,不产生警告。

项目规则仅在本地环境且 workspace 已信任时从 `.kimi-code/local.toml` 读取,省略 `scope` 时默认为 `project`。远程环境不支持项目规则。项目规则修改后需要新建会话生效,不会热重载。

也可以在 `[permission]` 下设置 `dangerous_command_guard = false` 完全关闭所有内置 guard 确认策略:危险命令、敏感文件(如 `.env`、`id_rsa`)访问、git 控制路径访问在 "Always Ask" 和 "Ask When Needed" 模式下不再触发审批("Never Ask" 模式本就不启用这些策略),默认 `true`。环境变量 `KIMI_CODE_DANGEROUS_COMMAND_GUARD=false` 会覆盖文件设置。全部内置 guard 关闭后,"Ask When Needed" 模式下只有你自己配置的 `ask` 规则仍会要求确认;"Always Ask" 模式下未被自动放行的工具照旧逐个询问。`Read`/`Write`/`Edit` 对秘密文件的硬阻断保持不变。此开关只适用于已经在 Agent 之外统一命令限权的环境。

| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `decision` | `string` | 是 | 匹配后的处置:`allow`(直接放行)、`deny`(直接拒绝)、`ask`(每次询问) |
| `scope` | `string` | 否 | 规则有效范围:`turn-override`、`session-runtime`、`project`、`user`,默认 `user` |
| `scope` | `string` | 否 | 规则有效范围:`turn-override`、`session-runtime`、`project`、`user`,在用户配置中默认 `user`;项目配置仅接受 `project` 和 `user` |
| `pattern` | `string` | 是 | 匹配模式,格式为 `工具名` 或 `工具名(参数模式)`,如 `Read`、`Bash(rm -rf*)` |
| `reason` | `string` | 否 | 规则说明,仅用于调试和审计 |

Expand Down
11 changes: 11 additions & 0 deletions packages/agent-core-v2/src/agent/permissionRules/configSection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '#/app/config/toml';

import { parsePermissionPattern } from './matchesRule';
import type { PermissionRule } from './permissionRules';

export const PERMISSION_SECTION = 'permission';

Expand All @@ -35,13 +36,23 @@ export const PermissionRuleSchema = z.object({
reason: z.string().optional(),
});

export const PersistedPermissionRuleSchema = PermissionRuleSchema.extend({
scope: z.enum(['project', 'user']).default('user'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject project-scoped rules in the user config

When ~/.kimi-code/config.toml contains scope = "project", this shared persisted schema accepts it and readUserPermissionRules() subsequently supplies the rule to every agent in every workspace. An allow intended to be project-local can therefore silently relax approvals in unrelated projects; the user-config schema should accept only user, while .kimi-code/local.toml should accept/default only project.

AGENTS.md reference: AGENTS.md:L131-L133

Useful? React with 👍 / 👎.

});

export const PermissionConfigSchema = z.object({
rules: z.array(PermissionRuleSchema).optional(),
dangerousCommandGuard: z.boolean().optional(),
});

export type PermissionConfig = z.infer<typeof PermissionConfigSchema>;

export function readUserPermissionRules(config: IConfigService): readonly PermissionRule[] {
config.assertValid(PERMISSION_SECTION);
const rules = config.get<PermissionConfig | undefined>(PERMISSION_SECTION)?.rules ?? [];
return rules.filter((rule) => rule.scope === 'project' || rule.scope === 'user');
}

export const DANGEROUS_COMMAND_GUARD_ENV = 'KIMI_CODE_DANGEROUS_COMMAND_GUARD';

function parseDangerousCommandGuardEnv(raw: string): boolean | undefined {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
recordApprovalResult(record: PermissionApprovalResultRecord): void;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize user-rule appends across processes

When two CLI or server processes persist user rules concurrently, each process has its own userConfigWriteTails entry and can read the same old permission.rules array before calling replace; the later whole-section write then silently drops the other process's rule. Use a cross-process locked read-modify-write or compare-and-retry mechanism for the shared user config.

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 {
Expand All @@ -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,
Expand Down
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not reuse workspace trust for alternate local directories

When a server or SDK caller creates a session with environment_id: "local" and an environment_cwd outside the registered workspace, this.cwd points at that alternate checkout while this.trust still represents the registered workspace root. Trusting one workspace can therefore load .kimi-code/local.toml from an unrelated, never-trusted local checkout, whose allow rules can silently bypass approvals; derive trust for this.cwd or skip project rules when the local binding directory differs from the trusted root.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not trust project rules from remote targets

When a session uses an SSH, Docker, or command environment, this.cwd and localConfig refer to the remote target, but the injected IWorkspaceTrust still represents the local workspace. Because workspace trust explicitly covers local paths only (docs/en/guides/remote-environment.md:141), trusting the local folder currently causes this code to read the remote checkout's .kimi-code/local.toml without a corresponding trust decision, allowing that checkout to install allow rules and relax approvals. Skip target-side project rules for non-local environments until target-specific trust exists.

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',
);
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProjectAdditionalDirsLoadResult>;
resolveAdditionalDirs(baseDir: string, additionalDirs: readonly string[]): Promise<string[]>;
readPermissionRules(workDir: string): Promise<ProjectPermissionRulesLoadResult>;
appendPermissionRules(
workDir: string,
rules: readonly unknown[],
): Promise<ProjectPermissionRulesLoadResult>;
}

export const IProjectLocalConfigService: ServiceIdentifier<IProjectLocalConfigService> =
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core-v2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading