Skip to content
This repository was archived by the owner on Aug 10, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 29 additions & 7 deletions scripts/validate_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
"profile",
}
LINK_RE = re.compile(r"\[[^\]]+\]\(([^)]+)\)")
EXTERNAL_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*:")
WINDOWS_DRIVE_RE = re.compile(r"^[a-zA-Z]:[/\\]")
NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$")

Expand Down Expand Up @@ -106,17 +108,37 @@ def parse_frontmatter(path: Path) -> tuple[dict[str, str], str]:
return metadata, text[end + 5 :]


def validate_links() -> None:
for path in SKILL.rglob("*.md"):
def _is_external_link(target: str) -> bool:
if target.startswith("#"):
return True
if "://" in target:
return True
if not EXTERNAL_SCHEME_RE.match(target):
return False
return not WINDOWS_DRIVE_RE.match(target)


def validate_links(skill_root: Path = SKILL) -> None:
skill_canonical = skill_root.resolve()
for path in skill_root.rglob("*.md"):
path_canonical = path.resolve()
if path_canonical != skill_canonical and skill_canonical not in path_canonical.parents:
continue
for target in LINK_RE.findall(path.read_text(encoding="utf-8")):
if "://" in target or target.startswith("#"):
if _is_external_link(target):
continue
clean = target.split("#", 1)[0]
if clean:
require(
(path.parent / clean).resolve().exists(),
f"broken link in {path.relative_to(ROOT)}: {target}",
if not clean:
continue
resolved = (path.parent / clean).resolve()
if resolved == skill_canonical or skill_canonical not in resolved.parents:
raise ValidationError(
f"link escapes skill boundary in {path.relative_to(skill_root)}: {target}"
)
require(
resolved.exists(),
f"broken link in {path.relative_to(skill_root)}: {target}",
)


def require_graded_cases_match(
Expand Down
92 changes: 92 additions & 0 deletions tests/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,98 @@ def test_project_archive_contains_source_without_git_metadata(self) -> None:
self.assertFalse(any("/.git/" in name or "/dist/" in name for name in names))


class SkillLinkBoundaryTests(unittest.TestCase):
"""Issue #15: local links must stay inside the packaged design-workflow/ tree."""

def _make_skill(self, temp_dir: str) -> Path:
skill = Path(temp_dir) / "design-workflow"
skill.mkdir(parents=True)
return skill

def _write_doc(self, skill: Path, rel_path: str, content: str) -> Path:
doc = skill / rel_path
doc.parent.mkdir(parents=True, exist_ok=True)
doc.write_text(content, encoding="utf-8")
return doc

def test_rejects_grandparent_escape_from_nested_document(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
skill = self._make_skill(temp_dir)
self._write_doc(skill, "references/routing.md", "[CHANGE](../../CHANGELOG.md)")
with self.assertRaisesRegex(validate_project.ValidationError, "references/routing.md.*\.\./\.\./CHANGELOG\.md"):
validate_project.validate_links(skill)

def test_rejects_deeper_traversal_escape(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
skill = self._make_skill(temp_dir)
self._write_doc(skill, "references/deep/doc.md", "[ROOT](../../../README.md)")
with self.assertRaisesRegex(validate_project.ValidationError, "doc\.md.*\.\./\.\./\.\./README\.md"):
validate_project.validate_links(skill)

def test_rejects_parent_escape_from_skill_root(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
skill = self._make_skill(temp_dir)
self._write_doc(skill, "SKILL.md", "[CHANGE](../CHANGELOG.md)")
with self.assertRaisesRegex(validate_project.ValidationError, "SKILL\.md.*\.\./CHANGELOG\.md"):
validate_project.validate_links(skill)

def test_rejects_absolute_filesystem_path(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
skill = self._make_skill(temp_dir)
self._write_doc(skill, "SKILL.md", "[ABS](/etc/passwd)")
with self.assertRaisesRegex(validate_project.ValidationError, "SKILL\.md"):
validate_project.validate_links(skill)

def test_accepts_valid_sibling_file(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
skill = self._make_skill(temp_dir)
self._write_doc(skill, "routing.md", "routing")
self._write_doc(skill, "SKILL.md", "[routing](routing.md)")
validate_project.validate_links(skill)

def test_accepts_valid_nested_file(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
skill = self._make_skill(temp_dir)
self._write_doc(skill, "references/routing.md", "routing")
self._write_doc(skill, "SKILL.md", "[routing](references/routing.md)")
validate_project.validate_links(skill)

def test_accepts_valid_parent_relative_link_inside_skill(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
skill = self._make_skill(temp_dir)
self._write_doc(skill, "assets/DESIGN.template.md", "template")
self._write_doc(skill, "references/design-profile.md", "[template](../assets/DESIGN.template.md)")
validate_project.validate_links(skill)

def test_accepts_valid_link_with_fragment(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
skill = self._make_skill(temp_dir)
self._write_doc(skill, "routing.md", "routing")
self._write_doc(skill, "SKILL.md", "[routing](routing.md#section)")
validate_project.validate_links(skill)

def test_accepts_fragment_only_link(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
skill = self._make_skill(temp_dir)
self._write_doc(skill, "SKILL.md", "[section](#section)")
validate_project.validate_links(skill)

def test_skips_external_urls_and_mailto(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
skill = self._make_skill(temp_dir)
self._write_doc(
skill,
"SKILL.md",
"[external](https://example.com)\n"
"[mailto](mailto:foo@example.com)\n"
"[fragment](#section)\n",
)
validate_project.validate_links(skill)

def test_committed_skill_documents_continue_to_pass(self) -> None:
validate_project.validate_links(validate_project.SKILL)


class GradedResultIntegrityTests(unittest.TestCase):
"""Issue #13: committed graded result cases must exactly match recomputed grading."""

Expand Down
Loading