Skip to content

Commit 3fea8cf

Browse files
fix(skill): resolve references/ wikilinks whose stem contains a dot (#120)
* fix(skill): resolve references/ wikilinks whose stem contains a dot `validate_skill` appended the implicit `.md` to a `[[references/...]]` link only when `Path(link).suffix` was empty. `Path.suffix` returns everything after the last dot, so a reference whose stem contains a dot — e.g. `[[references/api.v2]]` or `[[references/v1.2-guide]]` — has a truthy suffix (`.v2` / `.2-guide`). The `.md` was therefore never appended and the validator looked for an extension-less `references/api.v2`, emitting a false "doesn't exist" error even though `references/api.v2.md` is present and correctly linked. (`WIKILINK_RE` allows `.` in the target, so such links are legal and reach this branch.) Append `.md` based on a literal `.md` check instead of `Path.suffix`, so a dotted stem is suffixed correctly while an explicit `...md` link is left alone. Genuinely missing references still error. Adds a regression test for the dotted-stem case (the existing `test_wikilink_without_md_suffix_resolves` only covered a dot-free stem). * skill: reject references/ wikilinks that escape the references directory Review feedback: WIKILINK_RE allows '.' and '/', so '[[references/../SKILL]]' resolved outside references/ and was accepted whenever the resolved file existed (and would crash the not-found message's relative_to(skill_dir)). Reject any link whose resolved target is not under references/.
1 parent 840f2b7 commit 3fea8cf

2 files changed

Lines changed: 40 additions & 4 deletions

File tree

openkb/skill/validator.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,10 +207,20 @@ def validate_skill(skill_dir: Path, *, strict: bool = False) -> ValidationResult
207207
# references/ wikilink resolution
208208
wikilinks = WIKILINK_RE.findall(text)
209209
for link in wikilinks:
210-
# link may or may not include .md suffix
211-
target = refs_dir / link
212-
if not target.suffix:
213-
target = target.with_suffix(".md")
210+
# The link may already include the .md suffix; append it otherwise.
211+
# Test the literal ".md" rather than Path.suffix — a dotted stem like
212+
# "api.v2" has a truthy suffix (".v2"), so Path.suffix would skip the
213+
# ".md" and then look for a non-existent extension-less file.
214+
target = refs_dir / (link if link.lower().endswith(".md") else f"{link}.md")
215+
# A reference must resolve to a file *under* references/ — reject any
216+
# that escape it (e.g. "[[references/../SKILL]]"), which would also
217+
# break the relative_to(skill_dir) call in the not-found message below.
218+
if not target.resolve().is_relative_to(refs_dir.resolve()):
219+
result.errors.append(
220+
f"SKILL.md references [[references/{link}]] which resolves "
221+
f"outside the references/ directory."
222+
)
223+
continue
214224
if not target.exists():
215225
result.errors.append(
216226
f"SKILL.md references [[references/{link}]] but "

tests/test_skill_validator.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,32 @@ def test_wikilink_without_md_suffix_resolves(tmp_path):
259259
assert result.passed, result.errors
260260

261261

262+
def test_wikilink_dotted_stem_without_md_suffix_resolves(tmp_path):
263+
# A reference name whose stem contains a dot (e.g. "api.v2") must still get
264+
# the implicit ".md". Path.suffix would treat ".v2" as the suffix and skip
265+
# appending ".md", falsely reporting the existing api.v2.md as missing.
266+
sd = _write_skill(
267+
tmp_path, "ref-dotted-stem",
268+
body="See [[references/api.v2]] for details.\n",
269+
refs={"api.v2.md": "# api v2\n"},
270+
)
271+
result = validate_skill(sd)
272+
assert result.passed, result.errors
273+
274+
275+
def test_wikilink_escaping_references_dir_is_rejected(tmp_path):
276+
# A link that resolves outside references/ (e.g. "../SKILL") must error —
277+
# not be accepted just because the resolved target (here SKILL.md) exists.
278+
sd = _write_skill(
279+
tmp_path, "ref-escape",
280+
body="See [[references/../SKILL]] for details.\n",
281+
refs={"topic.md": "# topic\n"},
282+
)
283+
result = validate_skill(sd)
284+
assert not result.passed
285+
assert any("outside the references/" in e for e in result.errors)
286+
287+
262288
# ---------------------------------------------------------------------------
263289
# scripts/ imports — strict mode only
264290
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)