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..fe404994d0 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. 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) | @@ -292,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" @@ -310,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 70b36e1e8d..a2a6e781b8 100644 --- a/docs/en/customization/skills.md +++ b/docs/en/customization/skills.md @@ -81,6 +81,32 @@ 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). + +`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 496c9c571d..47e452d8d5 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 工具、斜杠菜单与用户激活)。大小写不敏感。磁盘上的文件保留。不拦截用 `Bash` 等工具「复现」Skill 工作流的行为——需要时配合 [`permission`](#permission) deny 规则。详见 [Agent Skills](../customization/skills.md#skill-存放位置) | | `telemetry` | `boolean` | `true` | 是否启用匿名遥测;显式设为 `false` 时关闭 | | `providers` | `table` | `{}` | API 供应商表 → [`providers`](#providers) | | `models` | `table` | — | 模型别名表 → [`models`](#models) | @@ -292,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" @@ -310,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 0ee0ce4449..079c33dacc 100644 --- a/docs/zh/customization/skills.md +++ b/docs/zh/customization/skills.md @@ -81,6 +81,32 @@ 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 仍会出现在模型列表中)。 + +`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 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/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'); 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/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', 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(),