Skip to content

Commit a5dc347

Browse files
committed
fix(integration): align Kimi dispatch and harden legacy migration
- Override build_command_invocation to emit /skill:speckit-<stem> so dispatched commands match Kimi Code CLI's native slash syntax. - Skip symlinked .kimi/skills directories during legacy migration and teardown to avoid operating on files outside the project. - Remove kimi from the multi-install-safe integrations table. - Add tests for command invocation and symlink safety.
1 parent 4697252 commit a5dc347

3 files changed

Lines changed: 114 additions & 5 deletions

File tree

docs/reference/integrations.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,6 @@ The currently declared multi-install safe integrations are:
187187
| `iflow` | `.iflow/commands`, `IFLOW.md` |
188188
| `junie` | `.junie/commands`, `.junie/AGENTS.md` |
189189
| `kilocode` | `.kilocode/workflows`, `.kilocode/rules/specify-rules.md` |
190-
| `kimi` | `.kimi-code/skills`, `AGENTS.md` |
191190
| `qodercli` | `.qoder/commands`, `QODER.md` |
192191
| `qwen` | `.qwen/commands`, `QWEN.md` |
193192
| `roo` | `.roo/commands`, `.roo/rules/specify-rules.md` |

src/specify_cli/integrations/kimi/__init__.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,25 @@ class KimiIntegration(SkillsIntegration):
3838
context_file = "AGENTS.md"
3939
multi_install_safe = False
4040

41+
def build_command_invocation(self, command_name: str, args: str = "") -> str:
42+
"""Build Kimi's native skill invocation: ``/skill:speckit-<stem>``.
43+
44+
Kimi Code CLI invokes installed skills with a ``/skill:<name>``
45+
slash command (e.g. ``/skill:speckit-plan``), not the bare
46+
``/speckit-<name>`` form produced by the generic skills base
47+
class. Overriding here keeps ``dispatch_command()`` and workflow
48+
command steps aligned with the ``/skill:`` guidance shown at init
49+
time and in rendered hook invocations.
50+
"""
51+
stem = command_name
52+
if stem.startswith("speckit."):
53+
stem = stem[len("speckit."):]
54+
55+
invocation = "/skill:speckit-" + stem.replace(".", "-")
56+
if args:
57+
invocation = f"{invocation} {args}"
58+
return invocation
59+
4160
@classmethod
4261
def options(cls) -> list[IntegrationOption]:
4362
return [
@@ -77,7 +96,7 @@ def setup(
7796
if parsed_options.get("migrate_legacy", False):
7897
new_skills_dir = self.skills_dest(project_root)
7998
old_skills_dir = project_root / ".kimi" / "skills"
80-
if old_skills_dir.is_dir():
99+
if _is_safe_legacy_dir(old_skills_dir, project_root):
81100
_migrate_legacy_kimi_skills_dir(old_skills_dir, new_skills_dir)
82101
_migrate_legacy_kimi_context_file(project_root)
83102

@@ -94,12 +113,12 @@ def teardown(
94113
removed, skipped = super().teardown(project_root, manifest, force=force)
95114

96115
old_skills_dir = project_root / ".kimi" / "skills"
97-
if old_skills_dir.is_dir():
116+
if _is_safe_legacy_dir(old_skills_dir, project_root):
98117
legacy_dirs = sorted(
99118
[*old_skills_dir.glob("speckit-*"), *old_skills_dir.glob("speckit.*")]
100119
)
101120
for legacy_dir in legacy_dirs:
102-
if not legacy_dir.is_dir():
121+
if legacy_dir.is_symlink() or not legacy_dir.is_dir():
103122
continue
104123
if _is_speckit_generated_skill(legacy_dir):
105124
try:
@@ -116,6 +135,26 @@ def teardown(
116135
return removed, skipped
117136

118137

138+
def _is_safe_legacy_dir(path: Path, project_root: Path) -> bool:
139+
"""Return ``True`` when *path* is a real directory safely inside *project_root*.
140+
141+
Legacy migration and cleanup ``shutil.move()`` and ``shutil.rmtree()``
142+
directories, so a symlinked ``.kimi``/``.kimi/skills`` (or one reached
143+
through a symlinked parent) must never be followed: doing so could
144+
relocate or delete content living outside the project tree. We reject
145+
the path when it is itself a symlink, when it is not a directory, or
146+
when resolving every symlink lands outside *project_root*.
147+
"""
148+
if path.is_symlink() or not path.is_dir():
149+
return False
150+
try:
151+
resolved = path.resolve()
152+
root = project_root.resolve()
153+
except OSError:
154+
return False
155+
return resolved == root or root in resolved.parents
156+
157+
119158
def _migrate_legacy_kimi_skills_dir(
120159
old_skills_dir: Path, new_skills_dir: Path
121160
) -> tuple[int, int]:
@@ -140,7 +179,7 @@ def _migrate_legacy_kimi_skills_dir(
140179
)
141180

142181
for legacy_dir in legacy_dirs:
143-
if not legacy_dir.is_dir():
182+
if legacy_dir.is_symlink() or not legacy_dir.is_dir():
144183
continue
145184
if not (legacy_dir / "SKILL.md").exists():
146185
continue

tests/integrations/test_integration_kimi.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,77 @@ def test_teardown_preserves_user_skills_in_legacy_dir(self, tmp_path):
239239
assert user_skill.exists()
240240

241241

242+
class TestKimiCommandInvocation:
243+
"""Kimi dispatch must use the native ``/skill:`` slash command."""
244+
245+
def test_build_command_invocation_uses_skill_prefix(self):
246+
i = get_integration("kimi")
247+
assert i.build_command_invocation("specify") == "/skill:speckit-specify"
248+
assert i.build_command_invocation("speckit.plan") == "/skill:speckit-plan"
249+
250+
def test_build_command_invocation_dotted_extension(self):
251+
i = get_integration("kimi")
252+
assert (
253+
i.build_command_invocation("speckit.git.commit")
254+
== "/skill:speckit-git-commit"
255+
)
256+
257+
def test_build_command_invocation_appends_args(self):
258+
i = get_integration("kimi")
259+
assert (
260+
i.build_command_invocation("specify", "my feature")
261+
== "/skill:speckit-specify my feature"
262+
)
263+
264+
265+
class TestKimiLegacySymlinkSafety:
266+
"""Legacy migration/cleanup must not follow symlinks out of the project."""
267+
268+
def test_migrate_skips_symlinked_legacy_skills_dir(self, tmp_path):
269+
# An attacker-controlled directory outside the project root. Use a
270+
# non-template skill name so a successful migration would be visible
271+
# (the bundled templates never create "speckit-evillegacy").
272+
outside = tmp_path / "outside"
273+
(outside / "speckit-evillegacy").mkdir(parents=True)
274+
(outside / "speckit-evillegacy" / "SKILL.md").write_text("# evil\n")
275+
276+
project = tmp_path / "project"
277+
(project / ".kimi").mkdir(parents=True)
278+
# .kimi/skills is a symlink to the outside directory.
279+
(project / ".kimi" / "skills").symlink_to(
280+
outside, target_is_directory=True
281+
)
282+
283+
i = get_integration("kimi")
284+
m = IntegrationManifest("kimi", project)
285+
i.setup(project, m, parsed_options={"migrate_legacy": True})
286+
287+
# Outside content must be untouched (not moved into .kimi-code).
288+
assert (outside / "speckit-evillegacy" / "SKILL.md").exists()
289+
assert not (
290+
project / ".kimi-code" / "skills" / "speckit-evillegacy"
291+
).exists()
292+
293+
def test_teardown_skips_symlinked_legacy_skills_dir(self, tmp_path):
294+
outside = tmp_path / "outside"
295+
outside.mkdir()
296+
keep = outside / "keep.txt"
297+
keep.write_text("important\n")
298+
299+
project = tmp_path / "project"
300+
(project / ".kimi").mkdir(parents=True)
301+
(project / ".kimi" / "skills").symlink_to(
302+
outside, target_is_directory=True
303+
)
304+
305+
i = get_integration("kimi")
306+
m = IntegrationManifest("kimi", project)
307+
i.teardown(project, m)
308+
309+
# The symlink target and its contents must survive teardown.
310+
assert keep.exists()
311+
312+
242313
class TestKimiNextSteps:
243314
"""CLI output tests for kimi next-steps display."""
244315

0 commit comments

Comments
 (0)