From 6039ed9253f1547c6f4b179fbbe9afc34e404c04 Mon Sep 17 00:00:00 2001 From: Sebastian B Date: Sat, 18 Jul 2026 21:05:43 +0100 Subject: [PATCH 1/4] feat(agent-core): add disabled_skills config denylist Allow config.toml to fully hide selected skill names from Kimi model listings, the Skill tool, slash menus, and user activation while leaving skill files on disk for shared directories such as ~/.agents/skills. --- .changeset/disabled-skills-config.md | 5 +++ apps/kimi-web/src/api/daemon/client.ts | 1 + apps/kimi-web/src/api/daemon/mappers.ts | 1 + apps/kimi-web/src/api/daemon/wire.ts | 1 + apps/kimi-web/src/api/types.ts | 1 + docs/en/configuration/config-files.md | 1 + docs/en/customization/skills.md | 10 +++++ docs/zh/configuration/config-files.md | 1 + docs/zh/customization/skills.md | 10 +++++ .../src/agent/skill/skillService.ts | 6 +++ .../src/agent/skill/tools/skill.ts | 5 +++ .../src/app/skillCatalog/configSection.ts | 15 +++++-- .../src/app/skillCatalog/errors.ts | 1 + .../src/app/skillCatalog/registry.ts | 25 ++++++++++- .../src/app/skillCatalog/types.ts | 23 ++++++++++ .../skillCatalogService.ts | 18 +++++++- .../test/agent/skill/skill.test.ts | 42 +++++++++++++++++++ .../test/app/config/config.test.ts | 4 +- .../sessionLifecycle/sessionLifecycle.test.ts | 1 + .../test/app/skillCatalog/registry.test.ts | 18 ++++++++ .../skillCatalog/skill-tool-manager.test.ts | 1 + packages/agent-core/src/agent/skill/index.ts | 6 +++ packages/agent-core/src/agent/skill/types.ts | 2 + packages/agent-core/src/config/schema.ts | 2 + packages/agent-core/src/config/toml.ts | 1 + packages/agent-core/src/errors/codes.ts | 7 ++++ packages/agent-core/src/rpc/core-impl.ts | 5 ++- .../src/services/config/configService.ts | 1 + packages/agent-core/src/session/index.ts | 3 ++ packages/agent-core/src/skill/registry.ts | 19 ++++++++- packages/agent-core/src/skill/types.ts | 21 ++++++++++ .../tools/builtin/collaboration/skill-tool.ts | 5 +++ .../test/agent/skill-tool-manager.test.ts | 1 + .../agent-core/test/config/configs.test.ts | 3 ++ .../agent-core/test/skill/registry.test.ts | 23 ++++++++++ .../agent-core/test/tools/skill-tool.test.ts | 13 +++++- .../kap-server/src/protocol/rest-config.ts | 2 + packages/node-sdk/test/config.test.ts | 3 ++ packages/protocol/src/rest/config.ts | 2 + 39 files changed, 298 insertions(+), 11 deletions(-) create mode 100644 .changeset/disabled-skills-config.md diff --git a/.changeset/disabled-skills-config.md b/.changeset/disabled-skills-config.md new file mode 100644 index 0000000000..ae61b931dc --- /dev/null +++ b/.changeset/disabled-skills-config.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add `disabled_skills` config to fully hide selected skill names from Kimi (model listing, Skill tool, slash menu, and activation). Set `disabled_skills = ["name"]` in `config.toml`, then run `/reload`. diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts index d8b1833edf..12d08d6732 100644 --- a/apps/kimi-web/src/api/daemon/client.ts +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -1275,6 +1275,7 @@ export class DaemonKimiWebApi implements KimiWebApi { services: 'services', mergeAllAvailableSkills: 'merge_all_available_skills', extraSkillDirs: 'extra_skill_dirs', + disabledSkills: 'disabled_skills', loopControl: 'loop_control', background: 'background', experimental: 'experimental', diff --git a/apps/kimi-web/src/api/daemon/mappers.ts b/apps/kimi-web/src/api/daemon/mappers.ts index 5eeda29e08..a65285b536 100644 --- a/apps/kimi-web/src/api/daemon/mappers.ts +++ b/apps/kimi-web/src/api/daemon/mappers.ts @@ -775,6 +775,7 @@ export function toAppConfig(wire: WireConfig): AppConfig { services: wire.services, mergeAllAvailableSkills: wire.merge_all_available_skills, extraSkillDirs: wire.extra_skill_dirs, + disabledSkills: wire.disabled_skills, loopControl: wire.loop_control, background: wire.background, experimental: wire.experimental, diff --git a/apps/kimi-web/src/api/daemon/wire.ts b/apps/kimi-web/src/api/daemon/wire.ts index 900d98bcf0..ada2076d22 100644 --- a/apps/kimi-web/src/api/daemon/wire.ts +++ b/apps/kimi-web/src/api/daemon/wire.ts @@ -415,6 +415,7 @@ export interface WireConfig { services?: unknown; merge_all_available_skills?: boolean; extra_skill_dirs?: string[]; + disabled_skills?: string[]; loop_control?: unknown; background?: unknown; experimental?: Record; diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index 5c34d26ccb..58d1d5b316 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -672,6 +672,7 @@ export interface AppConfig { services?: unknown; mergeAllAvailableSkills?: boolean; extraSkillDirs?: string[]; + disabledSkills?: string[]; loopControl?: unknown; background?: unknown; experimental?: Record; diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 141d021fa0..68c34fe3ca 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -102,6 +102,7 @@ Fields in the config file fall into two categories: **top-level scalars** that d | `default_plan_mode` | `boolean` | `false` | Whether new sessions start in Plan mode (produce a plan before executing) by default | | `merge_all_available_skills` | `boolean` | `true` | Whether to merge Agent Skills from all available directories | | `extra_skill_dirs` | `array` | — | Extra skill search directories, layered on top of the default directories | +| `disabled_skills` | `array` | `[]` | Skill names to fully disable in Kimi (model listing, Skill tool, slash menu, and user activation). Case-insensitive. Files stay on disk | | `telemetry` | `boolean` | `true` | Whether anonymous telemetry is enabled; disabled only when explicitly set to `false` | | `providers` | `table` | `{}` | API provider table → [`providers`](#providers) | | `models` | `table` | — | Model alias table → [`models`](#models) | diff --git a/docs/en/customization/skills.md b/docs/en/customization/skills.md index 70b36e1e8d..1197cea988 100644 --- a/docs/en/customization/skills.md +++ b/docs/en/customization/skills.md @@ -81,6 +81,16 @@ The Kimi-specific user Skill directory moves with `KIMI_CODE_HOME`, so isolated extra_skill_dirs = ["~/team-skills", ".agents/team-skills"] ``` +**Disabling skills by name**: Use top-level `disabled_skills` when a Skill directory is shared with other tools (for example `~/.agents/skills/`) but you do not want selected Skills to appear in Kimi at all: + +```toml +disabled_skills = ["grok-delegation", "pi-delegation"] +``` + +Disabled names are case-insensitive. Matching Skills are removed from the model listing, rejected by the `Skill` tool, hidden from the slash menu, and blocked from user activation. Files remain on disk for other tools. After editing `config.toml`, run `/reload` or start a new session. + +This is stronger than frontmatter `disableModelInvocation: true` (which only blocks automatic model invocation while still allowing slash use) and stronger than a permission deny rule for `Skill(...)` (which can block the tool call but still leaves the Skill in the model listing). + **Built-in Skills** are distributed with the CLI and have the lowest priority. They provide out-of-the-box workflows for common tasks — for example, configuring MCP servers, customizing the TUI theme, and editing config files. See [Built-in skill commands](../reference/slash-commands.md#built-in-skill-commands) for the full list. ## Invoking a Skill diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 496c9c571d..30d9178cad 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -102,6 +102,7 @@ timeout = 5 | `default_plan_mode` | `boolean` | `false` | 新会话是否默认以 Plan 模式(先出计划再执行)启动 | | `merge_all_available_skills` | `boolean` | `true` | 是否合并所有目录中的 Agent Skills | | `extra_skill_dirs` | `array` | — | 额外 Skill 搜索目录,叠加到默认目录之上 | +| `disabled_skills` | `array` | `[]` | 在 Kimi 中完全禁用的 Skill 名称(模型列表、Skill 工具、斜杠菜单与用户激活)。大小写不敏感。磁盘上的文件保留 | | `telemetry` | `boolean` | `true` | 是否启用匿名遥测;显式设为 `false` 时关闭 | | `providers` | `table` | `{}` | API 供应商表 → [`providers`](#providers) | | `models` | `table` | — | 模型别名表 → [`models`](#models) | diff --git a/docs/zh/customization/skills.md b/docs/zh/customization/skills.md index 0ee0ce4449..1faf1d6be2 100644 --- a/docs/zh/customization/skills.md +++ b/docs/zh/customization/skills.md @@ -81,6 +81,16 @@ Kimi 专属用户级 Skill 目录会随 `KIMI_CODE_HOME` 移动,因此隔离 extra_skill_dirs = ["~/team-skills", ".agents/team-skills"] ``` +**按名称禁用 Skill**:当 Skill 目录与其他工具共享(例如 `~/.agents/skills/`),但又不希望某些 Skill 出现在 Kimi 中时,使用顶层 `disabled_skills`: + +```toml +disabled_skills = ["grok-delegation", "pi-delegation"] +``` + +名称匹配不区分大小写。被禁用的 Skill 会从模型列表中移除,被 `Skill` 工具拒绝,不出现在斜杠菜单中,也无法由用户激活。磁盘上的文件会保留,供其他工具使用。修改 `config.toml` 后执行 `/reload` 或开启新会话。 + +这比 frontmatter 的 `disableModelInvocation: true` 更强(后者只阻止模型自动调用,仍允许斜杠调用),也比针对 `Skill(...)` 的 permission deny 规则更强(后者可能拦截工具调用,但 Skill 仍会出现在模型列表中)。 + **内置 Skills** 随 CLI 一起分发,优先级最低。它们为常见任务提供开箱即用的工作流,例如配置 MCP server、定制 TUI 主题和编辑配置文件。完整列表详见[内置 Skill 命令](../reference/slash-commands.md#内置-skill-命令)。 ## 调用 Skill diff --git a/packages/agent-core-v2/src/agent/skill/skillService.ts b/packages/agent-core-v2/src/agent/skill/skillService.ts index 3f787ffd3b..c044c1fe01 100644 --- a/packages/agent-core-v2/src/agent/skill/skillService.ts +++ b/packages/agent-core-v2/src/agent/skill/skillService.ts @@ -50,6 +50,12 @@ export class AgentSkillService extends Disposable implements IAgentSkillService if (skill === undefined) { throw new Error2(ErrorCodes.SKILL_NOT_FOUND, `Skill "${input.name}" was not found`); } + if (this.skillCatalog.catalog.isSkillDisabled(input.name)) { + throw new Error2( + ErrorCodes.SKILL_DISABLED, + `Skill "${skill.name}" is disabled in configuration (disabled_skills).`, + ); + } if (!isUserActivatableSkillType(skill.metadata.type)) { throw new Error2( ErrorCodes.SKILL_TYPE_UNSUPPORTED, diff --git a/packages/agent-core-v2/src/agent/skill/tools/skill.ts b/packages/agent-core-v2/src/agent/skill/tools/skill.ts index 8c01a0bc90..2ee8c69a2e 100644 --- a/packages/agent-core-v2/src/agent/skill/tools/skill.ts +++ b/packages/agent-core-v2/src/agent/skill/tools/skill.ts @@ -135,6 +135,11 @@ export async function executeModelSkill( if (skill === undefined) { return errorResult(`Skill "${args.skill}" not found in the current skill listing.`); } + if (catalog.catalog.isSkillDisabled(args.skill)) { + return errorResult( + `Skill "${skill.name}" is disabled in configuration (disabled_skills).`, + ); + } if (skill.metadata.disableModelInvocation === true) { return errorResult( `Skill "${args.skill}" can only be triggered by the user (model invocation is disabled).`, diff --git a/packages/agent-core-v2/src/app/skillCatalog/configSection.ts b/packages/agent-core-v2/src/app/skillCatalog/configSection.ts index bfc68dd7d9..413cc6b950 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/configSection.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/configSection.ts @@ -1,9 +1,10 @@ /** * `skillCatalog` domain (L3) — skill config sections. * - * Registers the v1-compatible top-level config domains `extraSkillDirs` and - * `mergeAllAvailableSkills`. Values stay camelCase in memory; TOML uses the - * snake_case keys `extra_skill_dirs` and `merge_all_available_skills`. + * Registers the v1-compatible top-level config domains `extraSkillDirs`, + * `mergeAllAvailableSkills`, and `disabledSkills`. Values stay camelCase in + * memory; TOML uses the snake_case keys `extra_skill_dirs`, + * `merge_all_available_skills`, and `disabled_skills`. */ import { z } from 'zod'; @@ -25,3 +26,11 @@ export type MergeAllAvailableSkillsConfig = z.infer; + +registerConfigSection(DISABLED_SKILLS_SECTION, DisabledSkillsConfigSchema, { + defaultValue: [], +}); diff --git a/packages/agent-core-v2/src/app/skillCatalog/errors.ts b/packages/agent-core-v2/src/app/skillCatalog/errors.ts index 601cf2177c..45665a4fc8 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/errors.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/errors.ts @@ -9,6 +9,7 @@ export const SkillErrors = { SKILL_NOT_FOUND: 'skill.not_found', SKILL_TYPE_UNSUPPORTED: 'skill.type_unsupported', SKILL_NAME_EMPTY: 'skill.name_empty', + SKILL_DISABLED: 'skill.disabled', }, } as const satisfies ErrorDomain; diff --git a/packages/agent-core-v2/src/app/skillCatalog/registry.ts b/packages/agent-core-v2/src/app/skillCatalog/registry.ts index 22d18b1ba3..0b2114efa1 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/registry.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/registry.ts @@ -18,7 +18,12 @@ import type { SkillSource, SkippedSkill, } from './types'; -import { isInlineSkillType, normalizeSkillName } from './types'; +import { + createDisabledSkillNameSet, + isDisabledSkillName, + isInlineSkillType, + normalizeSkillName, +} from './types'; const LISTING_DESC_MAX = 250; @@ -32,11 +37,21 @@ export class SkillNotFoundError extends Error { } } +export interface InMemorySkillCatalogOptions { + /** Skill names from config `disabled_skills` (case-insensitive). */ + readonly disabledSkills?: readonly string[]; +} + export class InMemorySkillCatalog implements SkillCatalog { private readonly byName = new Map(); private readonly byPluginAndName = new Map(); private readonly roots: string[] = []; private readonly skipped: SkippedSkill[] = []; + private readonly disabledSkills: ReadonlySet; + + constructor(options: InMemorySkillCatalogOptions = {}) { + this.disabledSkills = createDisabledSkillNameSet(options.disabledSkills); + } registerBuiltinSkill(skill: SkillDefinition): void { this.register(skill.source === 'builtin' ? skill : { ...skill, source: 'builtin' }); @@ -90,8 +105,14 @@ export class InMemorySkillCatalog implements SkillCatalog { ); } + isSkillDisabled(name: string): boolean { + return isDisabledSkillName(name, this.disabledSkills); + } + listSkills(): readonly SkillDefinition[] { - return [...this.byName.values()].toSorted((a, b) => a.name.localeCompare(b.name)); + return [...this.byName.values()] + .filter((skill) => !this.isSkillDisabled(skill.name)) + .toSorted((a, b) => a.name.localeCompare(b.name)); } listInvocableSkills(): readonly SkillDefinition[] { diff --git a/packages/agent-core-v2/src/app/skillCatalog/types.ts b/packages/agent-core-v2/src/app/skillCatalog/types.ts index 210cfe62d0..ab6599ebc3 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/types.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/types.ts @@ -65,12 +65,35 @@ export interface SkillCatalog { getSkillRoots(): readonly string[]; getSkippedByPolicy(): readonly SkippedSkill[]; getModelSkillListing(): string; + /** True when the skill name is listed in config `disabled_skills`. */ + isSkillDisabled(name: string): boolean; } export function normalizeSkillName(name: string): string { return name.toLowerCase(); } +/** Build a case-insensitive set of skill names from config `disabled_skills`. */ +export function createDisabledSkillNameSet( + names: readonly string[] | undefined, +): ReadonlySet { + const set = new Set(); + if (names === undefined) return set; + for (const name of names) { + const normalized = normalizeSkillName(name.trim()); + if (normalized.length > 0) set.add(normalized); + } + return set; +} + +export function isDisabledSkillName(name: string, disabled: ReadonlySet): boolean { + return disabled.has(normalizeSkillName(name)); +} + +export function disabledSkillConfigMessage(skillName: string): string { + return `Skill "${skillName}" is disabled in configuration (disabled_skills).`; +} + export function isInlineSkillType(type: string | undefined): boolean { return type === undefined || type === 'prompt' || type === 'inline'; } diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts index 5054c8b6dd..d7bc7baa29 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts @@ -11,7 +11,12 @@ import { Disposable } from '#/_base/di/lifecycle'; import { InstantiationType } from '#/_base/di/extensions'; import { Emitter, type Event } from '#/_base/event'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IConfigService } from '#/app/config/config'; import { IBuiltinSkillSource } from '#/app/skillCatalog/builtinSkillSource'; +import { + DISABLED_SKILLS_SECTION, + type DisabledSkillsConfig, +} from '#/app/skillCatalog/configSection'; import { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; import type { ISkillSource, SkillContribution } from '#/app/skillCatalog/skillSource'; import type { SkillCatalog } from '#/app/skillCatalog/types'; @@ -47,12 +52,21 @@ export class SessionSkillCatalogService @IExtraFileSkillSource extra: IExtraFileSkillSource, @IWorkspaceFileSkillSource workspace: IWorkspaceFileSkillSource, @IPluginSkillSource plugin: IPluginSkillSource, + @IConfigService private readonly config: IConfigService, ) { super(); this.sources = [builtin, user, explicit, extra, workspace, plugin].toSorted((a, b) => a.priority - b.priority); for (const s of this.sources) { if (s.onDidChange) this._register(s.onDidChange(() => { void this.reloadSource(s.id); })); } + this._register( + this.config.onDidSectionChange((event) => { + if (event.domain === DISABLED_SKILLS_SECTION) { + this.remerge(); + this.onDidChangeEmitter.fire('disabledSkills'); + } + }), + ); this.ready = this.loadAll(); } @@ -115,7 +129,9 @@ export class SessionSkillCatalogService } private remerge(): void { - const m = new InMemorySkillCatalog(); + const disabledSkills = + this.config.get(DISABLED_SKILLS_SECTION) ?? []; + const m = new InMemorySkillCatalog({ disabledSkills }); const ordered = [...this.contributions.values()].toSorted((a, b) => a.priority - b.priority); for (const { c } of ordered) { for (const skill of c.skills) m.register(skill, { replace: true }); diff --git a/packages/agent-core-v2/test/agent/skill/skill.test.ts b/packages/agent-core-v2/test/agent/skill/skill.test.ts index c4b07fdaa0..97610e4eff 100644 --- a/packages/agent-core-v2/test/agent/skill/skill.test.ts +++ b/packages/agent-core-v2/test/agent/skill/skill.test.ts @@ -112,6 +112,25 @@ describe('AgentSkillService', () => { await expect(svc.activate({ name: 'missing' })).rejects.toThrow(/not found/i); }); + it('activate throws for skills listed in disabled_skills config', async () => { + const disabledCatalog = new InMemorySkillCatalog({ disabledSkills: ['grok-delegation'] }); + disabledCatalog.register(stubSkill('grok-delegation')); + ix.set(ISessionSkillCatalog, { + _serviceBrand: undefined, + catalog: disabledCatalog, + ready: Promise.resolve(), + onDidChange: () => ({ dispose: () => {} }), + load: async () => {}, + reload: async () => {}, + } satisfies ISessionSkillCatalog); + ix.set(IAgentSkillService, new SyncDescriptor(AgentSkillService)); + + const svc = ix.get(IAgentSkillService); + await expect(svc.activate({ name: 'grok-delegation' })).rejects.toThrow( + /disabled in configuration \(disabled_skills\)/i, + ); + }); + it('activate waits for the catalog to be ready before resolving', async () => { let resolveReady!: () => void; const ready = new Promise((resolve) => { @@ -264,6 +283,29 @@ describe('SkillTool', () => { }); }); + it('rejects skills listed in disabled_skills config', async () => { + const disabledCatalog = new InMemorySkillCatalog({ disabledSkills: ['grok-delegation'] }); + disabledCatalog.register(stubSkill('grok-delegation')); + ix.set(ISessionSkillCatalog, { + _serviceBrand: undefined, + catalog: disabledCatalog, + ready: Promise.resolve(), + onDidChange: () => ({ dispose: () => {} }), + load: async () => {}, + reload: async () => {}, + } satisfies ISessionSkillCatalog); + + const result = await executeTool( + makeTool(ix), + toolContext({ skill: 'grok-delegation' }), + ); + + expect(result).toMatchObject({ + isError: true, + output: 'Skill "grok-delegation" is disabled in configuration (disabled_skills).', + }); + }); + it('rejects non-inline skill types in the current v1 runtime', async () => { skills.register(stubSkill('flow-only', { metadata: { type: 'flow' } })); diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index d0592270c8..8352462a91 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -29,6 +29,7 @@ import '#/app/cron/configSection'; import type { CronConfig } from '#/app/cron/configSection'; import '#/app/skillCatalog/configSection'; import { + DISABLED_SKILLS_SECTION, EXTRA_SKILL_DIRS_SECTION, MERGE_ALL_AVAILABLE_SKILLS_SECTION, } from '#/app/skillCatalog/configSection'; @@ -456,11 +457,12 @@ describe('ConfigService env overlay (live)', () => { }); describe('skill config sections', () => { - it('registers defaults for extraSkillDirs and mergeAllAvailableSkills', () => { + it('registers defaults for extraSkillDirs, mergeAllAvailableSkills, and disabledSkills', () => { const registry = new ConfigRegistry(); expect(registry.getSection(EXTRA_SKILL_DIRS_SECTION)?.defaultValue).toEqual([]); expect(registry.getSection(MERGE_ALL_AVAILABLE_SKILLS_SECTION)?.defaultValue).toBe(true); + expect(registry.getSection(DISABLED_SKILLS_SECTION)?.defaultValue).toEqual([]); }); }); diff --git a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts index a39586c91f..a19e0bae23 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts @@ -142,6 +142,7 @@ function skillCatalogStub(): ISessionSkillCatalog { getSkillRoots: () => [], getSkippedByPolicy: () => [], getModelSkillListing: () => '', + isSkillDisabled: () => false, }, ready: Promise.resolve(), onDidChange: () => ({ dispose: () => {} }), diff --git a/packages/agent-core-v2/test/app/skillCatalog/registry.test.ts b/packages/agent-core-v2/test/app/skillCatalog/registry.test.ts index f7cde21a14..911e092b74 100644 --- a/packages/agent-core-v2/test/app/skillCatalog/registry.test.ts +++ b/packages/agent-core-v2/test/app/skillCatalog/registry.test.ts @@ -104,6 +104,24 @@ describe('InMemorySkillCatalog skill listing', () => { }); }); +describe('InMemorySkillCatalog disabled_skills filter', () => { + it('hides disabled skill names from public lists while keeping getSkill', () => { + const registry = new InMemorySkillCatalog({ + disabledSkills: ['Grok-Delegation', ' pi-delegation '], + }); + registry.register(makeSkill('grok-delegation', 'user', 'Grok helper')); + registry.register(makeSkill('pi-delegation', 'user', 'Pi helper')); + registry.register(makeSkill('keep-me', 'user', 'Still available')); + + expect(registry.isSkillDisabled('grok-delegation')).toBe(true); + expect(registry.listSkills().map((skill) => skill.name)).toEqual(['keep-me']); + expect(registry.listInvocableSkills().map((skill) => skill.name)).toEqual(['keep-me']); + expect(registry.getModelSkillListing()).toContain('keep-me'); + expect(registry.getModelSkillListing()).not.toContain('grok-delegation'); + expect(registry.getSkill('grok-delegation')?.name).toBe('grok-delegation'); + }); +}); + describe('InMemorySkillCatalog model skill listing', () => { it('lists only model-invocable inline skills', () => { const registry = makeRegistry([ diff --git a/packages/agent-core-v2/test/app/skillCatalog/skill-tool-manager.test.ts b/packages/agent-core-v2/test/app/skillCatalog/skill-tool-manager.test.ts index 362519657c..45145f481b 100644 --- a/packages/agent-core-v2/test/app/skillCatalog/skill-tool-manager.test.ts +++ b/packages/agent-core-v2/test/app/skillCatalog/skill-tool-manager.test.ts @@ -171,6 +171,7 @@ describe('ToolManager SkillTool registration with a structural catalog', () => { getSkillRoots: () => ['/skills/review'], getSkippedByPolicy: () => [], getModelSkillListing: () => '- review: desc for review', + isSkillDisabled: () => false, }; ctx = createTestAgent(skillServices(skills)); profile = ctx.get(IAgentProfileService); diff --git a/packages/agent-core/src/agent/skill/index.ts b/packages/agent-core/src/agent/skill/index.ts index 684122fb14..fb67789831 100644 --- a/packages/agent-core/src/agent/skill/index.ts +++ b/packages/agent-core/src/agent/skill/index.ts @@ -23,6 +23,12 @@ export class SkillManager { if (skill === undefined) { throw new KimiError(ErrorCodes.SKILL_NOT_FOUND, `Skill "${input.name}" was not found`); } + if (this.registry.isSkillDisabled(input.name)) { + throw new KimiError( + ErrorCodes.SKILL_DISABLED, + `Skill "${skill.name}" is disabled in configuration (disabled_skills).`, + ); + } if (!isUserActivatableSkillType(skill.metadata.type)) { throw new KimiError(ErrorCodes.SKILL_TYPE_UNSUPPORTED, `Skill "${skill.name}" cannot be activated by the user`); } diff --git a/packages/agent-core/src/agent/skill/types.ts b/packages/agent-core/src/agent/skill/types.ts index 19413b8a2c..c56387a6e0 100644 --- a/packages/agent-core/src/agent/skill/types.ts +++ b/packages/agent-core/src/agent/skill/types.ts @@ -7,4 +7,6 @@ export interface SkillRegistry { listInvocableSkills(): readonly SkillDefinition[]; getSkillRoots(): readonly string[]; getModelSkillListing(): string; + /** True when the skill name is listed in config `disabled_skills`. */ + isSkillDisabled(name: string): boolean; } diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 06e7c9d307..d8d82b1559 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -302,6 +302,7 @@ export const KimiConfigSchema = z.object({ services: ServicesConfigSchema.optional(), mergeAllAvailableSkills: z.boolean().optional(), extraSkillDirs: z.array(z.string()).optional(), + disabledSkills: z.array(z.string()).optional(), loopControl: LoopControlSchema.optional(), background: BackgroundConfigSchema.optional(), subagent: SubagentConfigSchema.optional(), @@ -346,6 +347,7 @@ export const KimiConfigPatchSchema = z services: ServicesConfigPatchSchema.optional(), mergeAllAvailableSkills: z.boolean().optional(), extraSkillDirs: z.array(z.string()).optional(), + disabledSkills: z.array(z.string()).optional(), loopControl: LoopControlPatchSchema.optional(), background: BackgroundConfigPatchSchema.optional(), subagent: SubagentConfigPatchSchema.optional(), diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index edee21a041..7c1fe098ef 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -483,6 +483,7 @@ export function configToTomlData(config: KimiConfig): Record { 'defaultPlanMode', 'mergeAllAvailableSkills', 'extraSkillDirs', + 'disabledSkills', 'telemetry', ]; for (const key of scalarFields) { diff --git a/packages/agent-core/src/errors/codes.ts b/packages/agent-core/src/errors/codes.ts index 3032f3b0ac..6546c88a50 100644 --- a/packages/agent-core/src/errors/codes.ts +++ b/packages/agent-core/src/errors/codes.ts @@ -57,6 +57,7 @@ export const ErrorCodes = { SKILL_NOT_FOUND: 'skill.not_found', SKILL_TYPE_UNSUPPORTED: 'skill.type_unsupported', SKILL_NAME_EMPTY: 'skill.name_empty', + SKILL_DISABLED: 'skill.disabled', RECORDS_WRITE_FAILED: 'records.write_failed', COMPACTION_FAILED: 'compaction.failed', @@ -353,6 +354,12 @@ export const KIMI_ERROR_INFO = { public: true, action: 'Provide a non-empty skill name.', }, + 'skill.disabled': { + title: 'Skill disabled', + retryable: false, + public: true, + action: 'Remove the skill from disabled_skills in config.toml, then reload.', + }, 'records.write_failed': { title: 'Failed to write records', diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 09cf2965ca..7481b6c37e 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -974,7 +974,9 @@ export class KimiCore implements PromisableMethods { mergeAllAvailableSkills: skills.mergeAllAvailableSkills, builtinDir: skills.builtinDir, }); - const registry = new SessionSkillRegistry({}); + const registry = new SessionSkillRegistry({ + disabledSkills: skills.disabledSkills, + }); await registry.loadRoots(roots); registerBuiltinSkills(registry); return registry.listSkills().map(summarizeSkill); @@ -1178,6 +1180,7 @@ export class KimiCore implements PromisableMethods { extraDirs: config.extraSkillDirs, pluginSkillRoots: this.plugins.pluginSkillRoots(), mergeAllAvailableSkills: config.mergeAllAvailableSkills, + disabledSkills: config.disabledSkills, }; } diff --git a/packages/agent-core/src/services/config/configService.ts b/packages/agent-core/src/services/config/configService.ts index 79a08e1af1..c8a3710129 100644 --- a/packages/agent-core/src/services/config/configService.ts +++ b/packages/agent-core/src/services/config/configService.ts @@ -64,6 +64,7 @@ function toConfigResponse(config: KimiConfig): ConfigResponse { services: config.services, merge_all_available_skills: config.mergeAllAvailableSkills, extra_skill_dirs: config.extraSkillDirs, + disabled_skills: config.disabledSkills, loop_control: config.loopControl, background: config.background, experimental: config.experimental, diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 0f7ec8d235..dc5033ed3e 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -98,6 +98,8 @@ export interface SessionSkillConfig { readonly pluginSkillRoots?: readonly SkillRoot[]; readonly mergeAllAvailableSkills?: boolean; readonly builtinDir?: string; + /** Skill names from config `disabled_skills` (case-insensitive). */ + readonly disabledSkills?: readonly string[]; } export interface AgentMeta { @@ -227,6 +229,7 @@ export class Session { this.pluginCommands = options.pluginCommands ?? []; this.skills = new SessionSkillRegistry({ sessionId: options.id, + disabledSkills: options.skills?.disabledSkills, }); this.mcp = new McpConnectionManager({ oauthService: new McpOAuthService({ kimiHomeDir: options.kimiHomeDir }), diff --git a/packages/agent-core/src/skill/registry.ts b/packages/agent-core/src/skill/registry.ts index 65b207e274..f0e89f12fd 100644 --- a/packages/agent-core/src/skill/registry.ts +++ b/packages/agent-core/src/skill/registry.ts @@ -1,7 +1,12 @@ import { expandSkillParameters, skillArgumentNames } from './parser'; import { discoverSkills, type DiscoverSkillsOptions } from './scanner'; import type { SkillDefinition, SkillRoot, SkillSource, SkippedSkill } from './types'; -import { isInlineSkillType, normalizeSkillName } from './types'; +import { + createDisabledSkillNameSet, + isDisabledSkillName, + isInlineSkillType, + normalizeSkillName, +} from './types'; import type { SkillRegistry as AgentSkillRegistry } from '../agent/skill/types'; import { escapeXmlAttr } from '../utils/xml-escape'; @@ -21,6 +26,8 @@ export interface SkillRegistryOptions { readonly discover?: typeof discoverSkills; readonly onWarning?: (message: string, cause?: unknown) => void; readonly sessionId?: string; + /** Skill names from config `disabled_skills` (case-insensitive). */ + readonly disabledSkills?: readonly string[]; } export class SessionSkillRegistry implements AgentSkillRegistry { @@ -30,12 +37,14 @@ export class SessionSkillRegistry implements AgentSkillRegistry { private readonly skipped: SkippedSkill[] = []; private readonly discoverImpl: typeof discoverSkills; private readonly onWarning: (message: string, cause?: unknown) => void; + private readonly disabledSkills: ReadonlySet; readonly sessionId?: string; constructor(options: SkillRegistryOptions = {}) { this.discoverImpl = options.discover ?? discoverSkills; this.onWarning = options.onWarning ?? (() => {}); this.sessionId = options.sessionId; + this.disabledSkills = createDisabledSkillNameSet(options.disabledSkills); } async loadRoots(roots: readonly SkillRoot[]): Promise { @@ -106,8 +115,14 @@ export class SessionSkillRegistry implements AgentSkillRegistry { ); } + isSkillDisabled(name: string): boolean { + return isDisabledSkillName(name, this.disabledSkills); + } + listSkills(): readonly SkillDefinition[] { - return [...this.byName.values()].toSorted((a, b) => a.name.localeCompare(b.name)); + return [...this.byName.values()] + .filter((skill) => !this.isSkillDisabled(skill.name)) + .toSorted((a, b) => a.name.localeCompare(b.name)); } listInvocableSkills(): readonly SkillDefinition[] { diff --git a/packages/agent-core/src/skill/types.ts b/packages/agent-core/src/skill/types.ts index 7e030adf95..8bc212882e 100644 --- a/packages/agent-core/src/skill/types.ts +++ b/packages/agent-core/src/skill/types.ts @@ -62,6 +62,27 @@ export function normalizeSkillName(name: string): string { return name.toLowerCase(); } +/** Build a case-insensitive set of skill names from config `disabled_skills`. */ +export function createDisabledSkillNameSet( + names: readonly string[] | undefined, +): ReadonlySet { + const set = new Set(); + if (names === undefined) return set; + for (const name of names) { + const normalized = normalizeSkillName(name.trim()); + if (normalized.length > 0) set.add(normalized); + } + return set; +} + +export function isDisabledSkillName(name: string, disabled: ReadonlySet): boolean { + return disabled.has(normalizeSkillName(name)); +} + +export function disabledSkillConfigMessage(skillName: string): string { + return `Skill "${skillName}" is disabled in configuration (disabled_skills).`; +} + export function isInlineSkillType(type: string | undefined): boolean { return type === undefined || type === 'prompt' || type === 'inline'; } diff --git a/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts b/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts index 12f10cb56c..4901db6583 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts @@ -120,6 +120,11 @@ export class SkillTool implements BuiltinTool { if (skill === undefined) { return errorResult(`Skill "${args.skill}" not found in the current skill listing.`); } + if (skills.registry.isSkillDisabled(args.skill)) { + return errorResult( + `Skill "${skill.name}" is disabled in configuration (disabled_skills).`, + ); + } if (skill.metadata.disableModelInvocation === true) { // Keep the exact wording "can only be triggered by the user" so // contract audits and integration tests stay deterministic. diff --git a/packages/agent-core/test/agent/skill-tool-manager.test.ts b/packages/agent-core/test/agent/skill-tool-manager.test.ts index 51b42a74e0..e3bc95fa38 100644 --- a/packages/agent-core/test/agent/skill-tool-manager.test.ts +++ b/packages/agent-core/test/agent/skill-tool-manager.test.ts @@ -136,6 +136,7 @@ describe('ToolManager SkillTool registration', () => { listInvocableSkills: () => [skill], getSkillRoots: () => ['/skills/review'], getModelSkillListing: () => '- review: desc for review', + isSkillDisabled: () => false, }; const agent = makeAgent(skills); diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index d626f7d5fb..773a6023f6 100644 --- a/packages/agent-core/test/config/configs.test.ts +++ b/packages/agent-core/test/config/configs.test.ts @@ -57,6 +57,7 @@ default_permission_mode = "auto" default_plan_mode = false merge_all_available_skills = true extra_skill_dirs = ["~/team-skills", ".agents/team-skills"] +disabled_skills = ["grok-delegation", "pi-delegation"] telemetry = false theme = "dark" @@ -147,6 +148,7 @@ describe('harness config TOML loader', () => { expect(config.defaultPlanMode).toBe(false); expect(config.mergeAllAvailableSkills).toBe(true); expect(config.extraSkillDirs).toEqual(['~/team-skills', '.agents/team-skills']); + expect(config.disabledSkills).toEqual(['grok-delegation', 'pi-delegation']); expect(config.telemetry).toBe(false); expect(config.providers['managed:kimi-code']).toMatchObject({ type: 'kimi', @@ -357,6 +359,7 @@ removed_flag = true expect(text).toContain('default_model = "kimi-code/kimi-for-coding"'); expect(text).toContain('default_permission_mode = "auto"'); expect(text).toContain('extra_skill_dirs = [ "~/team-skills", ".agents/team-skills" ]'); + expect(text).toContain('disabled_skills = [ "grok-delegation", "pi-delegation" ]'); expect(text).toContain('telemetry = false'); expect(text).not.toContain('default_yolo'); expect(text).toContain('[[permission.rules]]'); diff --git a/packages/agent-core/test/skill/registry.test.ts b/packages/agent-core/test/skill/registry.test.ts index 7e9e26056b..ed4962e7d2 100644 --- a/packages/agent-core/test/skill/registry.test.ts +++ b/packages/agent-core/test/skill/registry.test.ts @@ -98,6 +98,29 @@ describe('skill registry prompt rendering', () => { }); }); +describe('disabled_skills config filter', () => { + it('hides disabled skill names from listSkills, listInvocableSkills, and model listing', () => { + const registry = new SessionSkillRegistry({ + disabledSkills: ['Grok-Delegation', ' pi-delegation '], + }); + registry.register(makeSkill('grok-delegation', 'user', 'Grok helper')); + registry.register(makeSkill('pi-delegation', 'user', 'Pi helper')); + registry.register(makeSkill('keep-me', 'user', 'Still available')); + + expect(registry.isSkillDisabled('grok-delegation')).toBe(true); + expect(registry.isSkillDisabled('PI-DELEGATION')).toBe(true); + expect(registry.isSkillDisabled('keep-me')).toBe(false); + + expect(registry.listSkills().map((skill) => skill.name)).toEqual(['keep-me']); + expect(registry.listInvocableSkills().map((skill) => skill.name)).toEqual(['keep-me']); + expect(registry.getModelSkillListing()).toContain('keep-me'); + expect(registry.getModelSkillListing()).not.toContain('grok-delegation'); + expect(registry.getModelSkillListing()).not.toContain('pi-delegation'); + // Still resolvable for diagnostics / hard-deny paths. + expect(registry.getSkill('grok-delegation')?.name).toBe('grok-delegation'); + }); +}); + describe('getModelSkillListing description truncation', () => { it('keeps descriptions at or below the 250-char limit unchanged', () => { const description = 'a'.repeat(250); diff --git a/packages/agent-core/test/tools/skill-tool.test.ts b/packages/agent-core/test/tools/skill-tool.test.ts index cde4a6de98..3ac55cb96d 100644 --- a/packages/agent-core/test/tools/skill-tool.test.ts +++ b/packages/agent-core/test/tools/skill-tool.test.ts @@ -32,7 +32,7 @@ function skill( function registry( skills: readonly SkillDefinition[] = [], - options: { readonly sessionId?: string } = {}, + options: { readonly sessionId?: string; readonly disabledSkills?: readonly string[] } = {}, ): AgentSkillRegistry { const registry = new SessionSkillRegistry(options); for (const item of skills) { @@ -144,6 +144,17 @@ describe('SkillTool execution', () => { expect(result.output).toContain('can only be triggered by the user'); }); + it('rejects skills listed in disabled_skills config', async () => { + const tool = skillTool( + registry([skill('grok-delegation')], { disabledSkills: ['grok-delegation'] }), + ); + + const result = await execute(tool, { skill: 'grok-delegation' }); + + expect(result).toMatchObject({ isError: true }); + expect(result.output).toContain('disabled in configuration (disabled_skills)'); + }); + it('rejects non-inline skill types in the current v1 runtime', async () => { const methods = skillToolMethods(); const tool = skillTool(registry([skill('review', { type: 'fork' })]), methods); diff --git a/packages/kap-server/src/protocol/rest-config.ts b/packages/kap-server/src/protocol/rest-config.ts index 5dcb7d45af..1564dfbe6f 100644 --- a/packages/kap-server/src/protocol/rest-config.ts +++ b/packages/kap-server/src/protocol/rest-config.ts @@ -23,6 +23,7 @@ export const configResponseSchema = z.object({ services: z.unknown().optional(), merge_all_available_skills: z.boolean().optional(), extra_skill_dirs: z.array(z.string()).optional(), + disabled_skills: z.array(z.string()).optional(), loop_control: z.unknown().optional(), background: z.unknown().optional(), experimental: z.record(z.string(), z.boolean()).optional(), @@ -46,6 +47,7 @@ export const patchConfigRequestSchema = z.object({ services: z.unknown().optional(), merge_all_available_skills: z.boolean().optional(), extra_skill_dirs: z.array(z.string()).optional(), + disabled_skills: z.array(z.string()).optional(), loop_control: z.unknown().optional(), background: z.unknown().optional(), experimental: z.record(z.string(), z.boolean()).optional(), diff --git a/packages/node-sdk/test/config.test.ts b/packages/node-sdk/test/config.test.ts index 9484facf1f..0de0da7631 100644 --- a/packages/node-sdk/test/config.test.ts +++ b/packages/node-sdk/test/config.test.ts @@ -43,6 +43,7 @@ theme = "dark" show_thinking_stream = true merge_all_available_skills = true extra_skill_dirs = ["~/team-skills", ".agents/team-skills"] +disabled_skills = ["grok-delegation", "pi-delegation"] [providers.kimi-for-coding] type = "kimi" @@ -134,6 +135,7 @@ max_context_size = "large" expect(config.defaultPlanMode).toBe(false); expect(config.mergeAllAvailableSkills).toBe(true); expect(config.extraSkillDirs).toEqual(['~/team-skills', '.agents/team-skills']); + expect(config.disabledSkills).toEqual(['grok-delegation', 'pi-delegation']); const provider = config.providers['kimi-for-coding']; expect(provider).toMatchObject({ @@ -192,6 +194,7 @@ max_context_size = "large" expect(text).toContain('default_model = "kimi-for-coding"'); expect(text).toContain('default_permission_mode = "auto"'); expect(text).toContain('extra_skill_dirs = [ "~/team-skills", ".agents/team-skills" ]'); + expect(text).toContain('disabled_skills = [ "grok-delegation", "pi-delegation" ]'); expect(text).not.toContain('default_yolo'); expect(text).toContain('max_steps_per_turn = 42'); expect(text).toContain('display_name = "Kimi for Coding"'); diff --git a/packages/protocol/src/rest/config.ts b/packages/protocol/src/rest/config.ts index 5dcb7d45af..1564dfbe6f 100644 --- a/packages/protocol/src/rest/config.ts +++ b/packages/protocol/src/rest/config.ts @@ -23,6 +23,7 @@ export const configResponseSchema = z.object({ services: z.unknown().optional(), merge_all_available_skills: z.boolean().optional(), extra_skill_dirs: z.array(z.string()).optional(), + disabled_skills: z.array(z.string()).optional(), loop_control: z.unknown().optional(), background: z.unknown().optional(), experimental: z.record(z.string(), z.boolean()).optional(), @@ -46,6 +47,7 @@ export const patchConfigRequestSchema = z.object({ services: z.unknown().optional(), merge_all_available_skills: z.boolean().optional(), extra_skill_dirs: z.array(z.string()).optional(), + disabled_skills: z.array(z.string()).optional(), loop_control: z.unknown().optional(), background: z.unknown().optional(), experimental: z.record(z.string(), z.boolean()).optional(), From 255070e289fba64039ee1a329e252a000ab7d514 Mon Sep 17 00:00:00 2001 From: Sebastian B Date: Sat, 18 Jul 2026 21:08:34 +0100 Subject: [PATCH 2/4] fix(protocol): add skill.disabled to wire KimiErrorCode union Keep protocol error codes in sync with agent-core so node-sdk dts build accepts the disabled_skills hard-deny path. --- packages/protocol/src/events.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index 042ee35325..e9732ce5ac 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -266,6 +266,7 @@ export type KimiErrorCode = | 'skill.not_found' | 'skill.type_unsupported' | 'skill.name_empty' + | 'skill.disabled' | 'records.write_failed' | 'compaction.failed' | 'compaction.unable' @@ -1197,6 +1198,7 @@ export const kimiErrorCodeSchema = z.enum([ 'skill.not_found', 'skill.type_unsupported', 'skill.name_empty', + 'skill.disabled', 'records.write_failed', 'compaction.failed', 'compaction.unable', From df3ca14c9cdd41514fbfd9ada1e1a6740e10b950 Mon Sep 17 00:00:00 2001 From: Sebastian B Date: Sun, 19 Jul 2026 04:15:37 +0100 Subject: [PATCH 3/4] docs: document disabled_skills limits and Bash deny pairing Clarify that disabled_skills only covers the Skill surface, deny rules still apply in YOLO mode, and show Bash deny patterns for skill helper binaries. --- docs/en/configuration/config-files.md | 18 +++++++++++++++++- docs/en/customization/skills.md | 16 ++++++++++++++++ docs/zh/configuration/config-files.md | 18 +++++++++++++++++- docs/zh/customization/skills.md | 16 ++++++++++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 68c34fe3ca..fe404994d0 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -102,7 +102,7 @@ Fields in the config file fall into two categories: **top-level scalars** that d | `default_plan_mode` | `boolean` | `false` | Whether new sessions start in Plan mode (produce a plan before executing) by default | | `merge_all_available_skills` | `boolean` | `true` | Whether to merge Agent Skills from all available directories | | `extra_skill_dirs` | `array` | — | Extra skill search directories, layered on top of the default directories | -| `disabled_skills` | `array` | `[]` | Skill names to fully disable in Kimi (model listing, Skill tool, slash menu, and user activation). Case-insensitive. Files stay on disk | +| `disabled_skills` | `array` | `[]` | Skill names to fully disable in Kimi (model listing, Skill tool, slash menu, and user activation). Case-insensitive. Files stay on disk. Does not block `Bash` or other tools that reimplement a skill's workflow — pair with [`permission`](#permission) deny rules when needed. See [Agent Skills](../customization/skills.md#skill-locations) | | `telemetry` | `boolean` | `true` | Whether anonymous telemetry is enabled; disabled only when explicitly set to `false` | | `providers` | `table` | `{}` | API provider table → [`providers`](#providers) | | `models` | `table` | — | Model alias table → [`models`](#models) | @@ -293,6 +293,8 @@ api_key = "sk-xxx" Built-in tool names are listed in [Built-in tools](../reference/tools.md). Most built-in tools that accept rule arguments define their own matching subject, such as `Bash(command-pattern)` or `Read(path-pattern)`. `AgentSwarm`, MCP tools, and custom tools can only be matched by tool name — argument patterns are not supported for them. +**Permission mode vs deny:** `default_permission_mode` (`manual` / `yolo` / `auto`) only changes what happens when no deny rule matches. A `decision = "deny"` rule always blocks the matching tool call, including in YOLO mode. + ```toml [[permission.rules]] decision = "allow" @@ -311,6 +313,20 @@ decision = "ask" pattern = "Bash" ``` +To hide a skill from the model **and** block shell helpers that reimplement it (for example after `disabled_skills`): + +```toml +disabled_skills = ["grok-delegation", "pi-delegation"] + +[[permission.rules]] +decision = "deny" +pattern = "Bash(*grok-delegate*)" + +[[permission.rules]] +decision = "deny" +pattern = "Bash(*pi-delegate*)" +``` + ::: tip MCP server declarations are configured in `~/.kimi-code/mcp.json` or the project-local `.kimi-code/mcp.json`, not in `config.toml`. The interactive configuration entry point is `/mcp-config`; see [Model Context Protocol](../customization/mcp.md). ::: diff --git a/docs/en/customization/skills.md b/docs/en/customization/skills.md index 1197cea988..a2a6e781b8 100644 --- a/docs/en/customization/skills.md +++ b/docs/en/customization/skills.md @@ -91,6 +91,22 @@ Disabled names are case-insensitive. Matching Skills are removed from the model This is stronger than frontmatter `disableModelInvocation: true` (which only blocks automatic model invocation while still allowing slash use) and stronger than a permission deny rule for `Skill(...)` (which can block the tool call but still leaves the Skill in the model listing). +`disabled_skills` only covers the Skill surface. The agent may still reimplement a skill's workflow with other tools — for example running a helper binary via `Bash` because a handoff document or earlier conversation showed that command. To block those shell paths as well, add deny rules (deny still applies in YOLO mode): + +```toml +disabled_skills = ["grok-delegation", "pi-delegation"] + +[[permission.rules]] +decision = "deny" +pattern = "Bash(*grok-delegate*)" + +[[permission.rules]] +decision = "deny" +pattern = "Bash(*pi-delegate*)" +``` + +See [Permission rules](../configuration/config-files.md#permission) for the full rule format. + **Built-in Skills** are distributed with the CLI and have the lowest priority. They provide out-of-the-box workflows for common tasks — for example, configuring MCP servers, customizing the TUI theme, and editing config files. See [Built-in skill commands](../reference/slash-commands.md#built-in-skill-commands) for the full list. ## Invoking a Skill diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 30d9178cad..47e452d8d5 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -102,7 +102,7 @@ timeout = 5 | `default_plan_mode` | `boolean` | `false` | 新会话是否默认以 Plan 模式(先出计划再执行)启动 | | `merge_all_available_skills` | `boolean` | `true` | 是否合并所有目录中的 Agent Skills | | `extra_skill_dirs` | `array` | — | 额外 Skill 搜索目录,叠加到默认目录之上 | -| `disabled_skills` | `array` | `[]` | 在 Kimi 中完全禁用的 Skill 名称(模型列表、Skill 工具、斜杠菜单与用户激活)。大小写不敏感。磁盘上的文件保留 | +| `disabled_skills` | `array` | `[]` | 在 Kimi 中完全禁用的 Skill 名称(模型列表、Skill 工具、斜杠菜单与用户激活)。大小写不敏感。磁盘上的文件保留。不拦截用 `Bash` 等工具「复现」Skill 工作流的行为——需要时配合 [`permission`](#permission) deny 规则。详见 [Agent Skills](../customization/skills.md#skill-存放位置) | | `telemetry` | `boolean` | `true` | 是否启用匿名遥测;显式设为 `false` 时关闭 | | `providers` | `table` | `{}` | API 供应商表 → [`providers`](#providers) | | `models` | `table` | — | 模型别名表 → [`models`](#models) | @@ -293,6 +293,8 @@ api_key = "sk-xxx" 内置工具名见[内置工具](../reference/tools.md)。大多数支持规则参数的内置工具会定义自己的匹配对象,例如 `Bash(command-pattern)` 或 `Read(path-pattern)`。`AgentSwarm`、MCP 工具和自定义工具只能按工具名匹配,不支持参数模式。 +**权限模式与 deny:** `default_permission_mode`(`manual` / `yolo` / `auto`)只影响「没有 deny 命中时」的行为。`decision = "deny"` 的规则始终拦截匹配的工具调用,**包括 YOLO 模式**。 + ```toml [[permission.rules]] decision = "allow" @@ -311,6 +313,20 @@ decision = "ask" pattern = "Bash" ``` +若要在隐藏 Skill 的同时拦住「用 shell 复现其工作流」的辅助命令(例如配合 `disabled_skills`): + +```toml +disabled_skills = ["grok-delegation", "pi-delegation"] + +[[permission.rules]] +decision = "deny" +pattern = "Bash(*grok-delegate*)" + +[[permission.rules]] +decision = "deny" +pattern = "Bash(*pi-delegate*)" +``` + ::: tip MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-code/mcp.json` 中,不在 `config.toml` 里。交互式配置入口是 `/mcp-config`,详见 [Model Context Protocol](../customization/mcp.md)。 ::: diff --git a/docs/zh/customization/skills.md b/docs/zh/customization/skills.md index 1faf1d6be2..079c33dacc 100644 --- a/docs/zh/customization/skills.md +++ b/docs/zh/customization/skills.md @@ -91,6 +91,22 @@ disabled_skills = ["grok-delegation", "pi-delegation"] 这比 frontmatter 的 `disableModelInvocation: true` 更强(后者只阻止模型自动调用,仍允许斜杠调用),也比针对 `Skill(...)` 的 permission deny 规则更强(后者可能拦截工具调用,但 Skill 仍会出现在模型列表中)。 +`disabled_skills` 只覆盖 Skill 表面。Agent 仍可能用其他工具「复现」Skill 的工作流——例如根据 handoff 文档或先前对话,用 `Bash` 直接跑辅助二进制。若要一并拦住这类 shell 路径,再加 deny 规则(YOLO 模式下 deny 仍然生效): + +```toml +disabled_skills = ["grok-delegation", "pi-delegation"] + +[[permission.rules]] +decision = "deny" +pattern = "Bash(*grok-delegate*)" + +[[permission.rules]] +decision = "deny" +pattern = "Bash(*pi-delegate*)" +``` + +完整规则格式见 [Permission 规则](../configuration/config-files.md#permission)。 + **内置 Skills** 随 CLI 一起分发,优先级最低。它们为常见任务提供开箱即用的工作流,例如配置 MCP server、定制 TUI 主题和编辑配置文件。完整列表详见[内置 Skill 命令](../reference/slash-commands.md#内置-skill-命令)。 ## 调用 Skill From d8e68644c0d0689fc060642e0eee3c80b7106645 Mon Sep 17 00:00:00 2001 From: Sebastian B Date: Mon, 20 Jul 2026 22:51:02 +0100 Subject: [PATCH 4/4] fix(kap-server): honor disabled_skills in workspace list and activate errors Map skill.disabled to wire 40912 on activation and pass disabledSkills into the session-less workspace catalog so web previews match session listings. --- packages/kap-server/src/routes/skills.ts | 10 ++++- packages/kap-server/test/skills.test.ts | 56 ++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/packages/kap-server/src/routes/skills.ts b/packages/kap-server/src/routes/skills.ts index 861056a38d..fca4eb02d3 100644 --- a/packages/kap-server/src/routes/skills.ts +++ b/packages/kap-server/src/routes/skills.ts @@ -57,7 +57,7 @@ * - unknown workspace id → envelope `code: 40410 workspace.not_found`. * - not live / unknown session → envelope `code: 40401 session.not_found` (see gate above). * - `skill.not_found` / `skill.name_empty` → envelope `code: 40415 skill.not_found`. - * - `skill.type_unsupported` → envelope `code: 40912 skill.not_activatable`. + * - `skill.type_unsupported` / `skill.disabled` → envelope `code: 40912 skill.not_activatable`. * - malformed `{tail}` (bad action, bare) → envelope `code: 40001 validation.failed`. * - other errors → 50001 via the global `installErrorHandler`. * @@ -70,6 +70,7 @@ import { BUILTIN_SKILLS, + DISABLED_SKILLS_SECTION, ErrorCodes, EXTRA_SKILL_DIRS_SECTION, IAgentSkillService, @@ -93,6 +94,7 @@ import { projectRoots, promptMetadataTextFromSkill, userRoots, + type DisabledSkillsConfig, type ISessionScopeHandle, type Scope, type SkillDefinition, @@ -343,6 +345,7 @@ async function listWorkspaceSkillsForRoot( await config.ready; const runtimeOptions = core.accessor.get(ISkillCatalogRuntimeOptions); const extraSkillDirs = config.get(EXTRA_SKILL_DIRS_SECTION) ?? []; + const disabledSkills = config.get(DISABLED_SKILLS_SECTION) ?? []; const mergeAllAvailableSkills = config.get(MERGE_ALL_AVAILABLE_SKILLS_SECTION) ?? true; const explicitDirs = runtimeOptions.explicitDirs ?? []; @@ -366,7 +369,9 @@ async function listWorkspaceSkillsForRoot( discovery.discover(pluginRootList), ]); - const catalog = new InMemorySkillCatalog(); + // Match the session catalog: honor top-level `disabled_skills` so workspace + // previews (e.g. web onboarding slash menu) do not surface denylisted names. + const catalog = new InMemorySkillCatalog({ disabledSkills }); const ordered = [ { skills: BUILTIN_SKILLS, priority: SKILL_SOURCE_PRIORITY.builtin }, { skills: plugin.skills, priority: SKILL_SOURCE_PRIORITY.plugin }, @@ -421,6 +426,7 @@ function sendMappedError( reply.send(errEnvelope(ErrorCode.SKILL_NOT_FOUND, err.message, requestId, err.stack)); return; case ErrorCodes.SKILL_TYPE_UNSUPPORTED: + case ErrorCodes.SKILL_DISABLED: reply.send(errEnvelope(ErrorCode.SKILL_NOT_ACTIVATABLE, err.message, requestId, err.stack)); return; } diff --git a/packages/kap-server/test/skills.test.ts b/packages/kap-server/test/skills.test.ts index 6a0286297d..a1b690f776 100644 --- a/packages/kap-server/test/skills.test.ts +++ b/packages/kap-server/test/skills.test.ts @@ -10,6 +10,8 @@ * - GET on an unknown workspace → 40410 * - POST /api/v1/sessions/{sid}/skills/{name}:activate → {activated:true, skill_name} * - POST :activate an unknown skill → 40415 + * - POST :activate a disabled skill → 40912 + * - GET workspace/session listings honor disabled_skills * - POST bare `{name}` / bogus action → 40001 * * Session skills are resolved from the per-session `ISessionSkillCatalog` (list) @@ -149,6 +151,24 @@ describe('server-v2 /api/v1 skills', () => { ); } + /** Restart the in-process server with top-level disabled_skills in config.toml. */ + async function restartWithDisabledSkills(names: readonly string[]): Promise { + await writeFile( + join(home as string, 'config.toml'), + `disabled_skills = ${JSON.stringify([...names])}\n`, + 'utf-8', + ); + await server!.close(); + server = undefined; + server = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + } + describe('GET /api/v1/sessions/{sid}/skills', () => { it('returns 40401 for an unknown session', async () => { const { body } = await getJson('/api/v1/sessions/nope/skills'); @@ -242,6 +262,21 @@ describe('server-v2 /api/v1 skills', () => { expect(body.code).toBe(40415); }); + it('returns 40912 when activating a skill listed in disabled_skills', async () => { + const workspaceDir = await makeWorkspaceDir(); + await seedProjectSkill(workspaceDir, 'e2e-disabled'); + await restartWithDisabledSkills(['e2e-disabled']); + + const id = await createSession(workspaceDir); + await createMainAgent(id); + + const { body } = await postJson( + `/api/v1/sessions/${id}/skills/e2e-disabled:activate`, + ); + expect(body.code).toBe(40912); + expect(body.msg).toMatch(/disabled/i); + }); + it('returns 40401 for an unknown session', async () => { const { body } = await postJson('/api/v1/sessions/nope/skills/update-config:activate'); expect(body.code).toBe(40401); @@ -298,6 +333,27 @@ describe('server-v2 /api/v1 skills', () => { expect(names(wsSkills)).toEqual(names(sessSkills)); }); + it('omits disabled_skills from both workspace and session listings', async () => { + const workspaceDir = await makeWorkspaceDir(); + await seedProjectSkill(workspaceDir, 'e2e-keep'); + await seedProjectSkill(workspaceDir, 'e2e-hidden'); + await restartWithDisabledSkills(['e2e-hidden']); + + const wid = await registerWorkspace(workspaceDir); + const sid = await createSession(workspaceDir); + + const [wsRes, sessRes] = await Promise.all([ + getJson<{ skills: SkillWire[] }>(`/api/v1/workspaces/${wid}/skills`), + getJson<{ skills: SkillWire[] }>(`/api/v1/sessions/${sid}/skills`), + ]); + const wsSkills = listSkillsResponseSchema.parse(wsRes.body.data).skills; + const sessSkills = listSkillsResponseSchema.parse(sessRes.body.data).skills; + expect(wsSkills.some((s) => s.name === 'e2e-keep')).toBe(true); + expect(wsSkills.some((s) => s.name === 'e2e-hidden')).toBe(false); + expect(sessSkills.some((s) => s.name === 'e2e-keep')).toBe(true); + expect(sessSkills.some((s) => s.name === 'e2e-hidden')).toBe(false); + }); + it('honors explicit skill dirs in workspace preview', async () => { const workspaceDir = await makeWorkspaceDir(); await seedProjectSkill(workspaceDir, 'e2e-explicit');