Files
deer-flow/backend/packages/harness/deerflow/skills/validation.py
T
Ryker_FengandGitHub 41658c5ff4 feat(skills): add skill review quality gate (#4037)
* feat(skills): add skill review quality gate

* fix(skills): skip review eval fixtures in CI

* fix(skills): ignore review eval fixtures in bundled scans

* fix(skill-review): harden review gate boundaries

* fix(skills): address skill review gate feedback
2026-07-11 15:58:07 +08:00

87 lines
3.5 KiB
Python

"""Skill frontmatter validation utilities.
Pure-logic validation of SKILL.md frontmatter — no FastAPI or HTTP dependencies.
"""
import re
from pathlib import Path
from deerflow.skills.frontmatter import ALLOWED_FRONTMATTER_PROPERTIES, split_skill_markdown
from deerflow.skills.parser import parse_allowed_tools
from deerflow.skills.types import SKILL_MD_FILE
def _validate_skill_frontmatter(skill_dir: Path) -> tuple[bool, str, str | None]:
"""Validate a skill directory's SKILL.md frontmatter.
Args:
skill_dir: Path to the skill directory containing SKILL.md.
Returns:
Tuple of (is_valid, message, skill_name).
"""
skill_md = skill_dir / SKILL_MD_FILE
if not skill_md.exists():
return False, f"{SKILL_MD_FILE} not found", None
content = skill_md.read_text(encoding="utf-8")
parts, error = split_skill_markdown(content)
if error:
return False, error, None
if parts is None:
return False, "Invalid frontmatter format", None
frontmatter = parts.metadata
# Check for unexpected properties
unexpected_keys = set(frontmatter.keys()) - ALLOWED_FRONTMATTER_PROPERTIES
if unexpected_keys:
return False, f"Unexpected key(s) in SKILL.md frontmatter: {', '.join(sorted(unexpected_keys))}", None
# Check required fields
if "name" not in frontmatter:
return False, "Missing 'name' in frontmatter", None
if "description" not in frontmatter:
return False, "Missing 'description' in frontmatter", None
# Validate name
name = frontmatter.get("name", "")
if not isinstance(name, str):
return False, f"Name must be a string, got {type(name).__name__}", None
name = name.strip()
if not name:
return False, "Name cannot be empty", None
# Check naming convention (hyphen-case: lowercase with hyphens)
if not re.match(r"^[a-z0-9-]+$", name):
return False, f"Name '{name}' should be hyphen-case (lowercase letters, digits, and hyphens only)", None
if name.startswith("-") or name.endswith("-") or "--" in name:
return False, f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens", None
if len(name) > 64:
return False, f"Name is too long ({len(name)} characters). Maximum is 64 characters.", None
# Validate description
description = frontmatter.get("description", "")
if not isinstance(description, str):
return False, f"Description must be a string, got {type(description).__name__}", None
description = description.strip()
if description:
if "<" in description or ">" in description:
return False, "Description cannot contain angle brackets (< or >)", None
if len(description) > 1024:
return False, f"Description is too long ({len(description)} characters). Maximum is 1024 characters.", None
try:
parse_allowed_tools(frontmatter.get("allowed-tools"), skill_md)
except ValueError as e:
return False, str(e).replace(str(skill_md), SKILL_MD_FILE), None
required_secrets = frontmatter.get("required-secrets")
if required_secrets is not None and not isinstance(required_secrets, list):
return False, f"required-secrets in {SKILL_MD_FILE} must be a list", None
secrets_autonomous = frontmatter.get("secrets-autonomous")
if secrets_autonomous is not None and not isinstance(secrets_autonomous, bool):
return False, f"secrets-autonomous in {SKILL_MD_FILE} must be a boolean", None
return True, "Skill is valid!", name