diff --git a/openkb/skill/validator.py b/openkb/skill/validator.py index f40014d6..37ac7186 100644 --- a/openkb/skill/validator.py +++ b/openkb/skill/validator.py @@ -207,10 +207,20 @@ def validate_skill(skill_dir: Path, *, strict: bool = False) -> ValidationResult # references/ wikilink resolution wikilinks = WIKILINK_RE.findall(text) for link in wikilinks: - # link may or may not include .md suffix - target = refs_dir / link - if not target.suffix: - target = target.with_suffix(".md") + # The link may already include the .md suffix; append it otherwise. + # Test the literal ".md" rather than Path.suffix — a dotted stem like + # "api.v2" has a truthy suffix (".v2"), so Path.suffix would skip the + # ".md" and then look for a non-existent extension-less file. + target = refs_dir / (link if link.lower().endswith(".md") else f"{link}.md") + # A reference must resolve to a file *under* references/ — reject any + # that escape it (e.g. "[[references/../SKILL]]"), which would also + # break the relative_to(skill_dir) call in the not-found message below. + if not target.resolve().is_relative_to(refs_dir.resolve()): + result.errors.append( + f"SKILL.md references [[references/{link}]] which resolves " + f"outside the references/ directory." + ) + continue if not target.exists(): result.errors.append( f"SKILL.md references [[references/{link}]] but " diff --git a/tests/test_skill_validator.py b/tests/test_skill_validator.py index 653cb64c..6010ebac 100644 --- a/tests/test_skill_validator.py +++ b/tests/test_skill_validator.py @@ -259,6 +259,32 @@ def test_wikilink_without_md_suffix_resolves(tmp_path): assert result.passed, result.errors +def test_wikilink_dotted_stem_without_md_suffix_resolves(tmp_path): + # A reference name whose stem contains a dot (e.g. "api.v2") must still get + # the implicit ".md". Path.suffix would treat ".v2" as the suffix and skip + # appending ".md", falsely reporting the existing api.v2.md as missing. + sd = _write_skill( + tmp_path, "ref-dotted-stem", + body="See [[references/api.v2]] for details.\n", + refs={"api.v2.md": "# api v2\n"}, + ) + result = validate_skill(sd) + assert result.passed, result.errors + + +def test_wikilink_escaping_references_dir_is_rejected(tmp_path): + # A link that resolves outside references/ (e.g. "../SKILL") must error — + # not be accepted just because the resolved target (here SKILL.md) exists. + sd = _write_skill( + tmp_path, "ref-escape", + body="See [[references/../SKILL]] for details.\n", + refs={"topic.md": "# topic\n"}, + ) + result = validate_skill(sd) + assert not result.passed + assert any("outside the references/" in e for e in result.errors) + + # --------------------------------------------------------------------------- # scripts/ imports — strict mode only # ---------------------------------------------------------------------------