Madan kumar df68b59149
fix(skills): reject a blank SKILL.md description at the write gate (#4867)
* fix(skills): reject a blank SKILL.md description at the write gate

_validate_skill_frontmatter only applied its description rules when the
value was truthy, so a blank or whitespace-only description passed the
gate. The loader rejects it, so the file was written to disk and then
disappeared from every consumer.

On the PUT edit endpoint that meant the write committed first and the
response was a 404 -- the previously working skill was gone with no
rollback. Same shape via the .skill install path and the agent-facing
skill_manage tool, which reported success for a skill that never loads.

Empty names were already rejected; description was the only field where
the write gate and the loader disagreed.

* test(skills): pin rollback rejection of a blank-description history entry

Add a regression test for the rollback path: restoring a history entry
whose stored content has an empty description must return 400
("Description cannot be empty") and leave the on-disk SKILL.md untouched,
rather than the previous destructive path that wrote the unloadable
content and then 404'd. On main this returns 404, so the test also pins
the intended status-code change on this path.
2026-09-02 00:33:21 +08:00

90 lines
3.7 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()
# The loader (parse_skill_file) drops a skill whose description is blank, so
# accepting one here would write a SKILL.md that never loads again.
if not description:
return False, "Description cannot be empty", None
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