mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-16 09:38:41 +00:00
* 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.
90 lines
3.7 KiB
Python
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
|