mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-21 02:05:45 +00:00
* 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
201 lines
7.8 KiB
Python
201 lines
7.8 KiB
Python
import logging
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
from .types import SKILL_MD_FILE, SecretRequirement, Skill, SkillCategory
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Valid POSIX environment-variable name.
|
|
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
|
|
|
|
def _format_yaml_error(skill_file: Path, exc: yaml.YAMLError, source: str) -> str:
|
|
"""Render a developer-friendly explanation of a YAML front-matter error."""
|
|
|
|
lines = [f"Invalid YAML front-matter in {skill_file}: {exc}"]
|
|
|
|
mark = getattr(exc, "problem_mark", None)
|
|
source_lines = source.splitlines()
|
|
if mark is not None and 0 <= mark.line < len(source_lines):
|
|
offending = source_lines[mark.line]
|
|
|
|
# mark.line is 0-based within the front-matter body; +1 makes it
|
|
# 1-based, +1 more accounts for the leading `---` fence that the
|
|
# front-matter regex strips before yaml.safe_load sees it.
|
|
file_line_number = mark.line + 2
|
|
lines.append(f" line {file_line_number}: {offending}")
|
|
|
|
if getattr(exc, "problem", "") == "mapping values are not allowed here" and ":" in offending:
|
|
key, _, value = offending.partition(":")
|
|
value = value.strip()
|
|
if value and value[0] not in {'"', "'", "|", ">", "[", "{"}:
|
|
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
lines.append(f' hint: values containing ":" must be quoted, e.g. {key}: "{escaped}"')
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def parse_allowed_tools(raw: object, skill_file: Path) -> tuple[str, ...] | None:
|
|
"""Parse the optional allowed-tools frontmatter field.
|
|
|
|
Returns None when the field is omitted. Returns a tuple when the field is a
|
|
YAML sequence of strings, including an empty tuple for explicit no-tool
|
|
skills. Raises ValueError for malformed values.
|
|
"""
|
|
if raw is None:
|
|
return None
|
|
if not isinstance(raw, list):
|
|
raise ValueError(f"allowed-tools in {skill_file} must be a list of strings")
|
|
|
|
allowed_tools: list[str] = []
|
|
for item in raw:
|
|
if not isinstance(item, str):
|
|
raise ValueError(f"allowed-tools in {skill_file} must contain only strings")
|
|
tool_name = item.strip()
|
|
if not tool_name:
|
|
raise ValueError(f"allowed-tools in {skill_file} cannot contain empty tool names")
|
|
allowed_tools.append(tool_name)
|
|
return tuple(allowed_tools)
|
|
|
|
|
|
def parse_required_secrets(raw: object, skill_file: Path) -> tuple[SecretRequirement, ...]:
|
|
"""Parse the optional required-secrets frontmatter field (issue #3861).
|
|
|
|
Accepts a YAML sequence whose items are either a string (the secret / env
|
|
variable name) or a mapping (``{name, optional}``). Returns an empty tuple
|
|
when the field is omitted. Entries whose name is missing or is not a valid
|
|
environment-variable name are dropped with a warning, so one malformed
|
|
declaration does not invalidate the whole skill. Raises ValueError only when
|
|
the field is present but is not a list.
|
|
"""
|
|
if raw is None:
|
|
return ()
|
|
if not isinstance(raw, list):
|
|
raise ValueError(f"required-secrets in {skill_file} must be a list")
|
|
|
|
secrets: list[SecretRequirement] = []
|
|
seen: set[str] = set()
|
|
for item in raw:
|
|
if isinstance(item, str):
|
|
name, optional = item.strip(), False
|
|
elif isinstance(item, dict):
|
|
name = str(item.get("name") or "").strip()
|
|
optional = bool(item.get("optional", False))
|
|
else:
|
|
logger.warning("Ignoring malformed required-secrets entry in %s: %r", skill_file, item)
|
|
continue
|
|
|
|
if not _ENV_VAR_NAME_RE.match(name):
|
|
logger.warning("Ignoring required-secrets entry with invalid env var name in %s: %r", skill_file, name)
|
|
continue
|
|
if name in seen:
|
|
continue
|
|
seen.add(name)
|
|
secrets.append(SecretRequirement(name=name, optional=optional))
|
|
return tuple(secrets)
|
|
|
|
|
|
def parse_secrets_autonomous(raw: object, skill_file: Path) -> bool:
|
|
"""Parse the optional ``secrets-autonomous`` frontmatter field (issue #3914).
|
|
|
|
``True`` (the default) lets declared secrets bind while the skill is
|
|
in-context via an autonomous model load; ``False`` restricts binding to
|
|
explicit ``/slash`` activation. A malformed (non-boolean) value fails
|
|
closed to ``False`` — the safer, less-injection direction.
|
|
"""
|
|
if raw is None:
|
|
return True
|
|
if isinstance(raw, bool):
|
|
return raw
|
|
logger.warning("Ignoring malformed secrets-autonomous value in %s: %r (autonomous binding disabled)", skill_file, raw)
|
|
return False
|
|
|
|
|
|
def parse_skill_file(skill_file: Path, category: SkillCategory, relative_path: Path | None = None) -> Skill | None:
|
|
"""Parse a SKILL.md file and extract metadata.
|
|
|
|
Args:
|
|
skill_file: Path to the SKILL.md file.
|
|
category: Category of the skill.
|
|
relative_path: Relative path from the category root to the skill
|
|
directory. Defaults to the skill directory name when omitted.
|
|
|
|
Returns:
|
|
Skill object if parsing succeeds, None otherwise.
|
|
"""
|
|
if not skill_file.exists() or skill_file.name != SKILL_MD_FILE:
|
|
return None
|
|
|
|
try:
|
|
content = skill_file.read_text(encoding="utf-8")
|
|
|
|
# Keep parser diagnostics richer than the pure helper's host-path-free
|
|
# error string; tests and authoring UX depend on the line-specific hint.
|
|
front_matter_match = re.match(r"^---\s*\n(.*?)\n---\s*\n?", content, re.DOTALL)
|
|
if not front_matter_match:
|
|
return None
|
|
front_matter_text = front_matter_match.group(1)
|
|
try:
|
|
metadata = yaml.safe_load(front_matter_text)
|
|
except yaml.YAMLError as exc:
|
|
logger.error("%s", _format_yaml_error(skill_file, exc, front_matter_text))
|
|
return None
|
|
if not isinstance(metadata, dict):
|
|
logger.error("Invalid SKILL.md front-matter in %s: Frontmatter must be a YAML dictionary", skill_file)
|
|
return None
|
|
|
|
# Extract required fields. Both must be non-empty strings.
|
|
name = metadata.get("name")
|
|
description = metadata.get("description")
|
|
|
|
if not name or not isinstance(name, str):
|
|
return None
|
|
if not description or not isinstance(description, str):
|
|
return None
|
|
|
|
# Normalise: strip surrounding whitespace that YAML may preserve.
|
|
name = name.strip()
|
|
description = description.strip()
|
|
|
|
if not name or not description:
|
|
return None
|
|
|
|
license_text = metadata.get("license")
|
|
if license_text is not None:
|
|
license_text = str(license_text).strip() or None
|
|
|
|
try:
|
|
allowed_tools = parse_allowed_tools(metadata.get("allowed-tools"), skill_file)
|
|
except ValueError as exc:
|
|
logger.error("Invalid allowed-tools in %s: %s", skill_file, exc)
|
|
return None
|
|
|
|
try:
|
|
required_secrets = parse_required_secrets(metadata.get("required-secrets"), skill_file)
|
|
except ValueError as exc:
|
|
logger.error("Invalid required-secrets in %s: %s", skill_file, exc)
|
|
return None
|
|
|
|
secrets_autonomous = parse_secrets_autonomous(metadata.get("secrets-autonomous"), skill_file)
|
|
|
|
return Skill(
|
|
name=name,
|
|
description=description,
|
|
license=license_text,
|
|
skill_dir=skill_file.parent,
|
|
skill_file=skill_file,
|
|
relative_path=relative_path or Path(skill_file.parent.name),
|
|
category=category,
|
|
allowed_tools=allowed_tools,
|
|
enabled=True, # Actual state comes from the extensions config file.
|
|
required_secrets=required_secrets,
|
|
secrets_autonomous=secrets_autonomous,
|
|
)
|
|
|
|
except Exception:
|
|
logger.exception("Unexpected error parsing skill file %s", skill_file)
|
|
return None
|