From b80b9a84cc12e6080ca1166fa4af118f067a4a79 Mon Sep 17 00:00:00 2001 From: tangyun Date: Thu, 28 May 2026 12:05:48 +0800 Subject: [PATCH 1/8] fix(skill): wrap slash-activated skill content in system-reminder tags SkillManager.activate() was sending raw skill content as a user message without or wrapping, unlike SkillTool which wraps content properly. This caused the model to not recognize slash-activated skills as skill invocations, leading to non-compliant responses. Now the content is wrapped consistently with the SkillTool path: ... --- packages/agent-core/src/agent/skill/index.ts | 30 +++++++++++++++---- .../test/harness/skill-session.test.ts | 10 +++++-- packages/node-sdk/test/session-skills.test.ts | 2 +- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/packages/agent-core/src/agent/skill/index.ts b/packages/agent-core/src/agent/skill/index.ts index 7d698b7e..a0d8ad1d 100644 --- a/packages/agent-core/src/agent/skill/index.ts +++ b/packages/agent-core/src/agent/skill/index.ts @@ -8,6 +8,14 @@ import { ErrorCodes, KimiError } from '#/errors'; import { isUserActivatableSkillType, type SkillRegistry } from '../../skill'; import type { SkillActivationOrigin } from '../context'; +function escapeXml(input: string): string { + return input + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + export class SkillManager { constructor( protected readonly agent: Agent, @@ -23,6 +31,21 @@ export class SkillManager { throw new KimiError(ErrorCodes.SKILL_TYPE_UNSUPPORTED, `Skill "${skill.name}" cannot be activated by the user`); } + const skillContent = this.registry.renderSkillPrompt(skill, input.args ?? ''); + const args = input.args; + const argsAttr = args ? ` args="${escapeXml(args)}"` : ''; + const wrapped = [ + { + type: 'text' as const, + text: + `\n` + + `\n` + + `${skillContent}\n` + + `\n` + + ``, + }, + ]; + this.recordActivation( { kind: 'skill_activation', @@ -34,12 +57,7 @@ export class SkillManager { skillSource: skill.source, skillArgs: input.args, }, - [ - { - type: 'text', - text: this.registry.renderSkillPrompt(skill, input.args ?? ''), - }, - ], + wrapped, ); } diff --git a/packages/agent-core/test/harness/skill-session.test.ts b/packages/agent-core/test/harness/skill-session.test.ts index ac6be2b4..76366049 100644 --- a/packages/agent-core/test/harness/skill-session.test.ts +++ b/packages/agent-core/test/harness/skill-session.test.ts @@ -184,7 +184,9 @@ describe('HarnessAPI session skills', () => { const records = await readMainWire(created.sessionDir); const prompt = records.find((record) => record['type'] === 'turn.prompt'); const userMessage = records.find((record) => record['type'] === 'context.append_message'); - const expectedPrompt = 'Review the requested file.\n\nARGUMENTS: src/app.ts'; + const expectedPrompt = + '\n\n' + + 'Review the requested file.\n\nARGUMENTS: src/app.ts\n\n'; expect(prompt).toMatchObject({ type: 'turn.prompt', input: [{ type: 'text', text: expectedPrompt }], @@ -264,11 +266,15 @@ describe('HarnessAPI session skills', () => { const prompt = records.find((record) => record['type'] === 'turn.prompt'); const skillDir = await realpath(join(workDir, '.kimi-code', 'skills', 'templated-review')); const expectedPrompt = [ + '', + '', 'Target: src/app.ts', 'Mode: careful', 'Raw: "src/app.ts" careful', `Dir: ${skillDir}`, 'Session: ses_skill_template', + '', + '', ].join('\n'); expect(prompt).toMatchObject({ type: 'turn.prompt', @@ -354,7 +360,7 @@ describe('HarnessAPI session skills', () => { content: [ { type: 'text', - text: 'Review the requested file.\n\nARGUMENTS: src/app.ts', + text: '\n\nReview the requested file.\n\nARGUMENTS: src/app.ts\n\n', }, ], origin: { diff --git a/packages/node-sdk/test/session-skills.test.ts b/packages/node-sdk/test/session-skills.test.ts index 6f785bb4..235bc756 100644 --- a/packages/node-sdk/test/session-skills.test.ts +++ b/packages/node-sdk/test/session-skills.test.ts @@ -178,7 +178,7 @@ describe('Session skills', () => { input: [ { type: 'text', - text: 'Review the requested file.\n\nARGUMENTS: src/app.ts', + text: '\n\nReview the requested file.\n\nARGUMENTS: src/app.ts\n\n', }, ], origin: { From b70a774fe8cbf35794db8c955486710ca4b306b3 Mon Sep 17 00:00:00 2001 From: tangyun Date: Thu, 28 May 2026 12:11:41 +0800 Subject: [PATCH 2/8] chore: add changeset for slash skill system reminder fix --- .changeset/slash-skill-system-reminder.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/slash-skill-system-reminder.md diff --git a/.changeset/slash-skill-system-reminder.md b/.changeset/slash-skill-system-reminder.md new file mode 100644 index 00000000..3db89a2c --- /dev/null +++ b/.changeset/slash-skill-system-reminder.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Fix slash-activated skills not being recognized by the model due to missing system reminder wrapper. From cc2ed2cbfdd7ecf933c0f1e9419060eaef6271c5 Mon Sep 17 00:00:00 2001 From: tangyun Date: Thu, 28 May 2026 16:12:15 +0800 Subject: [PATCH 3/8] refactor(agent-core): extract escapeXml/escapeXmlAttr to shared utility Consolidate duplicate XML escaping implementations scattered across 4 files into a single utils/xml-escape.ts module. Removes 2 identical escapeXml definitions and unifies 3 attribute-escaping variants. --- .../src/agent/context/notification-xml.ts | 7 +++---- .../src/agent/injection/plugin-session-start.ts | 9 +++------ packages/agent-core/src/agent/skill/index.ts | 9 +-------- packages/agent-core/src/skill/registry.ts | 7 ++----- .../src/tools/builtin/collaboration/skill-tool.ts | 8 +------- packages/agent-core/src/utils/xml-escape.ts | 13 +++++++++++++ 6 files changed, 23 insertions(+), 30 deletions(-) create mode 100644 packages/agent-core/src/utils/xml-escape.ts diff --git a/packages/agent-core/src/agent/context/notification-xml.ts b/packages/agent-core/src/agent/context/notification-xml.ts index 72e584b4..3005907f 100644 --- a/packages/agent-core/src/agent/context/notification-xml.ts +++ b/packages/agent-core/src/agent/context/notification-xml.ts @@ -17,6 +17,8 @@ * — rename requires updating the detector too. */ +import { escapeXmlAttr } from '#/utils/xml-escape'; + export function renderNotificationXml(data: Record): string { const id = stringAttr(data['id'], 'unknown'); const category = stringAttr(data['category'], 'unknown'); @@ -65,8 +67,5 @@ function truncateTailOutput(raw: string, maxLines: number, maxChars: number): st function stringAttr(value: unknown, fallback: string): string { if (typeof value !== 'string' || value.length === 0) return fallback; - // Attribute boundary safety: escape `&` and `"`. Body-text `<` / `>` - // stay untouched — the injection target is an LLM-visible transcript - // where double-escaping would be noisier than literal punctuation. - return value.replaceAll('&', '&').replaceAll('"', '"'); + return escapeXmlAttr(value); } diff --git a/packages/agent-core/src/agent/injection/plugin-session-start.ts b/packages/agent-core/src/agent/injection/plugin-session-start.ts index 1f1705a4..16d41489 100644 --- a/packages/agent-core/src/agent/injection/plugin-session-start.ts +++ b/packages/agent-core/src/agent/injection/plugin-session-start.ts @@ -1,5 +1,6 @@ import type { EnabledPluginSessionStart } from '../../plugin/types'; import type { SkillDefinition } from '../../skill'; +import { escapeXmlAttr } from '../../utils/xml-escape'; import { DynamicInjector } from './injector'; export class PluginSessionStartInjector extends DynamicInjector { @@ -43,11 +44,7 @@ function renderSessionStartBlock( skillContent: string, ): string { return ( - `\n${skillContent}\n` + `\n${skillContent}\n` ); } - -function escapeAttr(value: string): string { - return value.replaceAll('"', '"'); -} diff --git a/packages/agent-core/src/agent/skill/index.ts b/packages/agent-core/src/agent/skill/index.ts index a0d8ad1d..19d08af7 100644 --- a/packages/agent-core/src/agent/skill/index.ts +++ b/packages/agent-core/src/agent/skill/index.ts @@ -6,16 +6,9 @@ import type { ContentPart } from '@moonshot-ai/kosong'; import type { Agent } from '..'; import { ErrorCodes, KimiError } from '#/errors'; import { isUserActivatableSkillType, type SkillRegistry } from '../../skill'; +import { escapeXml } from '#/utils/xml-escape'; import type { SkillActivationOrigin } from '../context'; -function escapeXml(input: string): string { - return input - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"'); -} - export class SkillManager { constructor( protected readonly agent: Agent, diff --git a/packages/agent-core/src/skill/registry.ts b/packages/agent-core/src/skill/registry.ts index a03d36c4..44f37d84 100644 --- a/packages/agent-core/src/skill/registry.ts +++ b/packages/agent-core/src/skill/registry.ts @@ -2,6 +2,7 @@ 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 { escapeXmlAttr } from '../utils/xml-escape'; const LISTING_DESC_MAX = 250; @@ -98,7 +99,7 @@ export class SkillRegistry { const instructions = plugin.instructions; if (instructions === undefined || instructions.trim().length === 0) return content; return ( - `\n` + + `\n` + `${instructions}\n` + `\n\n${content}` ); @@ -181,7 +182,3 @@ function formatModelSkill(skill: SkillDefinition): readonly string[] { function truncate(value: string, max: number): string { return value.length > max ? value.slice(0, max) : value; } - -function escapeAttr(value: string): string { - return value.replaceAll('&', '&').replaceAll('"', '"'); -} 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 b7350e3c..0bc8f699 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts @@ -21,6 +21,7 @@ import type { BuiltinTool } from '../../../agent/tool'; import type { ExecutableToolResult, ToolExecution } from '../../../loop/types'; import { isInlineSkillType, type SkillDefinition } from '../../../skill'; import { renderPrompt } from '../../../utils/render-prompt'; +import { escapeXml } from '../../../utils/xml-escape'; import { toInputJsonSchema } from '../../support/input-schema'; import { matchesGlobRuleSubject } from '../../support/rule-match'; import skillDescriptionTemplate from './skill-tool.md'; @@ -163,10 +164,3 @@ function skillOrigin( }; } -function escapeXml(input: string): string { - return input - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"'); -} diff --git a/packages/agent-core/src/utils/xml-escape.ts b/packages/agent-core/src/utils/xml-escape.ts new file mode 100644 index 00000000..dd02ccff --- /dev/null +++ b/packages/agent-core/src/utils/xml-escape.ts @@ -0,0 +1,13 @@ +/** Escape XML content — escapes both tag and attribute boundary chars (& < > ") */ +export function escapeXml(input: string): string { + return input + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +/** Escape XML attribute value — only escapes attribute boundary chars (& "), not tag chars */ +export function escapeXmlAttr(input: string): string { + return input.replaceAll('&', '&').replaceAll('"', '"'); +} From 900026cde5d0a7b1a9efd157353fbae7f847f111 Mon Sep 17 00:00:00 2001 From: tangyun Date: Thu, 28 May 2026 16:26:30 +0800 Subject: [PATCH 4/8] fix(agent-core): escape user-provided args in skill parameter expansion Apply escapeXml() to all user-controlled args values inside expandSkillParameters() so that malicious inputs containing cannot break out of the system-reminder wrapper boundary. --- packages/agent-core/src/skill/parser.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/agent-core/src/skill/parser.ts b/packages/agent-core/src/skill/parser.ts index cee0b953..66651e44 100644 --- a/packages/agent-core/src/skill/parser.ts +++ b/packages/agent-core/src/skill/parser.ts @@ -6,6 +6,7 @@ import regexpEscape from 'regexp.escape'; import type { SkillDefinition, SkillMetadata, SkillSource } from './types'; import { isSupportedSkillType } from './types'; +import { escapeXml } from '../utils/xml-escape'; export class FrontmatterError extends Error { constructor(message: string, cause?: unknown) { @@ -186,20 +187,20 @@ export function expandSkillParameters( const escaped = regexpEscape(name); content = content.replaceAll( new RegExp(`\\$${escaped}(?![\\[\\w])`, 'g'), - tokens[index] ?? '', + escapeXml(tokens[index] ?? ''), ); } content = content .replaceAll(/\$ARGUMENTS\[(\d+)\]/g, (_match, indexText: string) => { const index = Number.parseInt(indexText, 10); - return tokens[index] ?? ''; + return escapeXml(tokens[index] ?? ''); }) .replaceAll(/\$(\d+)(?!\w)/g, (_match, indexText: string) => { const index = Number.parseInt(indexText, 10); - return tokens[index] ?? ''; + return escapeXml(tokens[index] ?? ''); }) - .replaceAll('$ARGUMENTS', rawArgs); + .replaceAll('$ARGUMENTS', escapeXml(rawArgs)); const hasArgumentPlaceholder = content !== body; content = content @@ -207,7 +208,7 @@ export function expandSkillParameters( .replaceAll('${KIMI_SESSION_ID}', context.sessionId ?? ''); if (!hasArgumentPlaceholder && rawArgs.length > 0) { - return `${content}\n\nARGUMENTS: ${rawArgs}`; + return `${content}\n\nARGUMENTS: ${escapeXml(rawArgs)}`; } return content; } From 93634b9600815f229dc01b506a860e76d0c69756 Mon Sep 17 00:00:00 2001 From: tangyun Date: Thu, 28 May 2026 16:32:01 +0800 Subject: [PATCH 5/8] test(agent-core): update expectations for XML-escaped skill args --- packages/agent-core/test/harness/skill-session.test.ts | 2 +- packages/agent-core/test/skill/parser.test.ts | 4 ++-- packages/agent-core/test/tools/skill-tool.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/agent-core/test/harness/skill-session.test.ts b/packages/agent-core/test/harness/skill-session.test.ts index 76366049..1af1feba 100644 --- a/packages/agent-core/test/harness/skill-session.test.ts +++ b/packages/agent-core/test/harness/skill-session.test.ts @@ -270,7 +270,7 @@ describe('HarnessAPI session skills', () => { '', 'Target: src/app.ts', 'Mode: careful', - 'Raw: "src/app.ts" careful', + 'Raw: "src/app.ts" careful', `Dir: ${skillDir}`, 'Session: ses_skill_template', '', diff --git a/packages/agent-core/test/skill/parser.test.ts b/packages/agent-core/test/skill/parser.test.ts index 27a8c6b0..fd33022b 100644 --- a/packages/agent-core/test/skill/parser.test.ts +++ b/packages/agent-core/test/skill/parser.test.ts @@ -150,7 +150,7 @@ describe('skill parameter expansion', () => { ); expect(out).toBe( - 'raw=-m "fix login" zero=-m one=fix login second=fix login flag=-m message=fix login dir=/tmp/skills/commit session=ses_1', + 'raw=-m "fix login" zero=-m one=fix login second=fix login flag=-m message=fix login dir=/tmp/skills/commit session=ses_1', ); }); @@ -189,7 +189,7 @@ describe('SkillRegistry.renderSkillPrompt', () => { '"src/app.ts" carefully', ); - expect(rendered).toBe('Review src/app.ts from "src/app.ts" carefully.'); + expect(rendered).toBe('Review src/app.ts from "src/app.ts" carefully.'); expect(rendered).not.toContain('ARGUMENTS:'); }); diff --git a/packages/agent-core/test/tools/skill-tool.test.ts b/packages/agent-core/test/tools/skill-tool.test.ts index aa8a0338..1e855311 100644 --- a/packages/agent-core/test/tools/skill-tool.test.ts +++ b/packages/agent-core/test/tools/skill-tool.test.ts @@ -186,7 +186,7 @@ describe('SkillTool execution', () => { await execute(tool, { skill: 'commit', args: '-m "fix login"' }); expect(methods.recordSystemReminder.mock.calls[0]?.[0]).toContain( - '\nFlag: -m\nCommit message: fix login\nRaw: -m "fix login"\n', + '\nFlag: -m\nCommit message: fix login\nRaw: -m "fix login"\n', ); expect(methods.recordSystemReminder.mock.calls[0]?.[0]).not.toContain('ARGUMENTS:'); }); @@ -236,7 +236,7 @@ describe('SkillTool execution', () => { await execute(tool, { skill: 'a&b', args: '' }); expect(methods.recordSystemReminder.mock.calls[0]?.[0]).toContain( - '\nbody of a&b\n\nARGUMENTS: \n', + '\nbody of a&b\n\nARGUMENTS: <raw "value">\n', ); expect(methods.recordSkillActivation).toHaveBeenCalledTimes(1); }); From 2b92b66516ad161b3d670d81d61de1306d908e4d Mon Sep 17 00:00:00 2001 From: tangyun Date: Thu, 28 May 2026 16:36:38 +0800 Subject: [PATCH 6/8] fix(agent-core): limit XML escaping to fallback ARGUMENTS line only Revert escapeXml() on body-internal placeholder substitutions ($ARGUMENTS, $name, $N) since injecting XML entities into skill Markdown instructions corrupts literal values like docs/R&D.md. Only the system-appended fallback "ARGUMENTS:" line is escaped. --- packages/agent-core/src/skill/parser.ts | 8 ++++---- packages/agent-core/test/harness/skill-session.test.ts | 2 +- packages/agent-core/test/skill/parser.test.ts | 4 ++-- packages/agent-core/test/tools/skill-tool.test.ts | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/agent-core/src/skill/parser.ts b/packages/agent-core/src/skill/parser.ts index 66651e44..35eb0ed6 100644 --- a/packages/agent-core/src/skill/parser.ts +++ b/packages/agent-core/src/skill/parser.ts @@ -187,20 +187,20 @@ export function expandSkillParameters( const escaped = regexpEscape(name); content = content.replaceAll( new RegExp(`\\$${escaped}(?![\\[\\w])`, 'g'), - escapeXml(tokens[index] ?? ''), + tokens[index] ?? '', ); } content = content .replaceAll(/\$ARGUMENTS\[(\d+)\]/g, (_match, indexText: string) => { const index = Number.parseInt(indexText, 10); - return escapeXml(tokens[index] ?? ''); + return tokens[index] ?? ''; }) .replaceAll(/\$(\d+)(?!\w)/g, (_match, indexText: string) => { const index = Number.parseInt(indexText, 10); - return escapeXml(tokens[index] ?? ''); + return tokens[index] ?? ''; }) - .replaceAll('$ARGUMENTS', escapeXml(rawArgs)); + .replaceAll('$ARGUMENTS', rawArgs); const hasArgumentPlaceholder = content !== body; content = content diff --git a/packages/agent-core/test/harness/skill-session.test.ts b/packages/agent-core/test/harness/skill-session.test.ts index 1af1feba..76366049 100644 --- a/packages/agent-core/test/harness/skill-session.test.ts +++ b/packages/agent-core/test/harness/skill-session.test.ts @@ -270,7 +270,7 @@ describe('HarnessAPI session skills', () => { '', 'Target: src/app.ts', 'Mode: careful', - 'Raw: "src/app.ts" careful', + 'Raw: "src/app.ts" careful', `Dir: ${skillDir}`, 'Session: ses_skill_template', '', diff --git a/packages/agent-core/test/skill/parser.test.ts b/packages/agent-core/test/skill/parser.test.ts index fd33022b..27a8c6b0 100644 --- a/packages/agent-core/test/skill/parser.test.ts +++ b/packages/agent-core/test/skill/parser.test.ts @@ -150,7 +150,7 @@ describe('skill parameter expansion', () => { ); expect(out).toBe( - 'raw=-m "fix login" zero=-m one=fix login second=fix login flag=-m message=fix login dir=/tmp/skills/commit session=ses_1', + 'raw=-m "fix login" zero=-m one=fix login second=fix login flag=-m message=fix login dir=/tmp/skills/commit session=ses_1', ); }); @@ -189,7 +189,7 @@ describe('SkillRegistry.renderSkillPrompt', () => { '"src/app.ts" carefully', ); - expect(rendered).toBe('Review src/app.ts from "src/app.ts" carefully.'); + expect(rendered).toBe('Review src/app.ts from "src/app.ts" carefully.'); expect(rendered).not.toContain('ARGUMENTS:'); }); diff --git a/packages/agent-core/test/tools/skill-tool.test.ts b/packages/agent-core/test/tools/skill-tool.test.ts index 1e855311..1342d61d 100644 --- a/packages/agent-core/test/tools/skill-tool.test.ts +++ b/packages/agent-core/test/tools/skill-tool.test.ts @@ -186,7 +186,7 @@ describe('SkillTool execution', () => { await execute(tool, { skill: 'commit', args: '-m "fix login"' }); expect(methods.recordSystemReminder.mock.calls[0]?.[0]).toContain( - '\nFlag: -m\nCommit message: fix login\nRaw: -m "fix login"\n', + '\nFlag: -m\nCommit message: fix login\nRaw: -m "fix login"\n', ); expect(methods.recordSystemReminder.mock.calls[0]?.[0]).not.toContain('ARGUMENTS:'); }); From 12caa72c38dfd49617f5ce59fa3daf9da9d2cfe4 Mon Sep 17 00:00:00 2001 From: tangyun Date: Thu, 28 May 2026 16:54:56 +0800 Subject: [PATCH 7/8] fix(agent-core): escape tag delimiters in body-internal placeholder args Use escapeXmlTags() (only < >) for placeholder substitutions inside skill body to prevent XML tag injection while preserving Markdown literal values like docs/R&D.md and "src/app.ts". --- packages/agent-core/src/skill/parser.ts | 10 +++++----- packages/agent-core/src/utils/xml-escape.ts | 5 +++++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/agent-core/src/skill/parser.ts b/packages/agent-core/src/skill/parser.ts index 35eb0ed6..132eb89d 100644 --- a/packages/agent-core/src/skill/parser.ts +++ b/packages/agent-core/src/skill/parser.ts @@ -6,7 +6,7 @@ import regexpEscape from 'regexp.escape'; import type { SkillDefinition, SkillMetadata, SkillSource } from './types'; import { isSupportedSkillType } from './types'; -import { escapeXml } from '../utils/xml-escape'; +import { escapeXml, escapeXmlTags } from '../utils/xml-escape'; export class FrontmatterError extends Error { constructor(message: string, cause?: unknown) { @@ -187,20 +187,20 @@ export function expandSkillParameters( const escaped = regexpEscape(name); content = content.replaceAll( new RegExp(`\\$${escaped}(?![\\[\\w])`, 'g'), - tokens[index] ?? '', + escapeXmlTags(tokens[index] ?? ''), ); } content = content .replaceAll(/\$ARGUMENTS\[(\d+)\]/g, (_match, indexText: string) => { const index = Number.parseInt(indexText, 10); - return tokens[index] ?? ''; + return escapeXmlTags(tokens[index] ?? ''); }) .replaceAll(/\$(\d+)(?!\w)/g, (_match, indexText: string) => { const index = Number.parseInt(indexText, 10); - return tokens[index] ?? ''; + return escapeXmlTags(tokens[index] ?? ''); }) - .replaceAll('$ARGUMENTS', rawArgs); + .replaceAll('$ARGUMENTS', escapeXmlTags(rawArgs)); const hasArgumentPlaceholder = content !== body; content = content diff --git a/packages/agent-core/src/utils/xml-escape.ts b/packages/agent-core/src/utils/xml-escape.ts index dd02ccff..8d803ca6 100644 --- a/packages/agent-core/src/utils/xml-escape.ts +++ b/packages/agent-core/src/utils/xml-escape.ts @@ -11,3 +11,8 @@ export function escapeXml(input: string): string { export function escapeXmlAttr(input: string): string { return input.replaceAll('&', '&').replaceAll('"', '"'); } + +/** Escape tag delimiters only — prevents XML tag injection without corrupting Markdown (& " stay literal) */ +export function escapeXmlTags(input: string): string { + return input.replaceAll('<', '<').replaceAll('>', '>'); +} From 4a311c974e6d2454c95e0fa2e89e67d72d8fa5fa Mon Sep 17 00:00:00 2001 From: tangyun Date: Thu, 28 May 2026 17:07:54 +0800 Subject: [PATCH 8/8] fix(agent-core): use escapeXmlTags for fallback ARGUMENTS line too The fallback line is inside the wrapper body, not an attribute, so only < > need escaping. Using full escapeXml() here would corrupt literal values like docs/R&D.md. --- packages/agent-core/src/skill/parser.ts | 4 ++-- packages/agent-core/test/tools/skill-tool.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/agent-core/src/skill/parser.ts b/packages/agent-core/src/skill/parser.ts index 132eb89d..23d281c9 100644 --- a/packages/agent-core/src/skill/parser.ts +++ b/packages/agent-core/src/skill/parser.ts @@ -6,7 +6,7 @@ import regexpEscape from 'regexp.escape'; import type { SkillDefinition, SkillMetadata, SkillSource } from './types'; import { isSupportedSkillType } from './types'; -import { escapeXml, escapeXmlTags } from '../utils/xml-escape'; +import { escapeXmlTags } from '../utils/xml-escape'; export class FrontmatterError extends Error { constructor(message: string, cause?: unknown) { @@ -208,7 +208,7 @@ export function expandSkillParameters( .replaceAll('${KIMI_SESSION_ID}', context.sessionId ?? ''); if (!hasArgumentPlaceholder && rawArgs.length > 0) { - return `${content}\n\nARGUMENTS: ${escapeXml(rawArgs)}`; + return `${content}\n\nARGUMENTS: ${escapeXmlTags(rawArgs)}`; } return content; } diff --git a/packages/agent-core/test/tools/skill-tool.test.ts b/packages/agent-core/test/tools/skill-tool.test.ts index 1342d61d..99ef0440 100644 --- a/packages/agent-core/test/tools/skill-tool.test.ts +++ b/packages/agent-core/test/tools/skill-tool.test.ts @@ -236,7 +236,7 @@ describe('SkillTool execution', () => { await execute(tool, { skill: 'a&b', args: '' }); expect(methods.recordSystemReminder.mock.calls[0]?.[0]).toContain( - '\nbody of a&b\n\nARGUMENTS: <raw "value">\n', + '\nbody of a&b\n\nARGUMENTS: <raw "value">\n', ); expect(methods.recordSkillActivation).toHaveBeenCalledTimes(1); });