harden frontmatter verification

3clyp50 committed Feb 2, 2026 at 12:25 UTC 55ab5478d3de36e0901936c70c0c88bb48c075d5
1 file changed +65 -35
python/helpers/skills.py
+65 -35
@@ -124,29 +124,28 @@ def _read_text(path: Path) -> str:
124 return path.read_text(encoding="utf-8", errors="replace")
125
126
127 -def split_frontmatter(markdown: str) -> Tuple[Dict[str, Any], str]:
127 +def split_frontmatter(markdown: str) -> Tuple[Dict[str, Any], str, List[str]]:
128 """
129 - Splits a SKILL.md into (frontmatter_dict, body_text).
130 -
131 - If no YAML frontmatter is present, returns ({}, full_text).
129 + Splits a SKILL.md into (frontmatter_dict, body_text, errors).
130 + Enforces YAML frontmatter at the top for spec compatibility.
131 """
132 + errors: List[str] = []
133 text = markdown or ""
134 - if not text.lstrip().startswith("---"):
135 - return {}, text.strip()
136 -
137 - # We require frontmatter fence at the start (allow leading whitespace/newlines).
134 lines = text.splitlines()
139 - # find first '---' line
135 +
136 + # Require frontmatter fence at the start (allow leading whitespace/newlines).
137 start_idx = None
138 for i, line in enumerate(lines):
139 if line.strip() == "---":
140 start_idx = i
141 break
145 - if line.strip(): # non-empty before fence => not frontmatter
146 - return {}, text.strip()
142 + if line.strip(): # non-empty before fence => invalid
143 + errors.append("Frontmatter must start at the top of the file")
144 + return {}, text.strip(), errors
145
146 if start_idx is None:
149 - return {}, text.strip()
147 + errors.append("Missing YAML frontmatter")
148 + return {}, text.strip(), errors
149
150 end_idx = None
151 for j in range(start_idx + 1, len(lines)):
@@ -155,29 +154,18 @@ def split_frontmatter(markdown: str) -> Tuple[Dict[str, Any], str]:
154 break
155
156 if end_idx is None:
158 - return {}, text.strip()
157 + errors.append("Unterminated YAML frontmatter")
158 + return {}, text.strip(), errors
159
160 fm_text = "\n".join(lines[start_idx + 1 : end_idx]).strip()
161 body = "\n".join(lines[end_idx + 1 :]).strip()
162 - fm = parse_frontmatter(fm_text)
163 - return fm, body
164 -
165 -
166 -def parse_frontmatter(frontmatter_text: str) -> Dict[str, Any]:
167 - """
168 - Parse YAML frontmatter. Uses PyYAML if available, otherwise a minimal fallback parser.
169 - """
170 - if not frontmatter_text.strip():
171 - return {}
162 + fm, fm_errors = parse_frontmatter(fm_text)
163 + errors.extend(fm_errors)
164 + return fm, body, errors
165
173 - if yaml is not None:
174 - try:
175 - parsed = yaml.safe_load(frontmatter_text) # type: ignore[attr-defined]
176 - return parsed if isinstance(parsed, dict) else {}
177 - except Exception:
178 - return {}
166
180 - # Fallback: very small YAML subset (key: value, lists with '- item')
167 +def _parse_frontmatter_fallback(frontmatter_text: str) -> Dict[str, Any]:
168 + # Minimal YAML subset: key: value, lists with "- item"
169 data: Dict[str, Any] = {}
170 current_key: Optional[str] = None
171 for raw in frontmatter_text.splitlines():
@@ -193,7 +181,6 @@ def parse_frontmatter(frontmatter_text: str) -> Dict[str, Any]:
181 if val == "":
182 data[key] = []
183 else:
196 - # strip surrounding quotes
184 if (val.startswith('"') and val.endswith('"')) or (
185 val.startswith("'") and val.endswith("'")
186 ):
@@ -212,25 +199,53 @@ def parse_frontmatter(frontmatter_text: str) -> Dict[str, Any]:
199 data[current_key] = []
200 data[current_key].append(item)
201 continue
215 -
202 return data
203
204
205 +def parse_frontmatter(frontmatter_text: str) -> Tuple[Dict[str, Any], List[str]]:
206 + """
207 + Parse YAML frontmatter with PyYAML when available,
208 + falling back to a minimal subset parser.
209 + """
210 + errors: List[str] = []
211 + if not frontmatter_text.strip():
212 + return {}, errors
213 +
214 + if yaml is not None:
215 + try:
216 + parsed = yaml.safe_load(frontmatter_text) # type: ignore[attr-defined]
217 + except Exception:
218 + parsed = None
219 + if parsed is not None:
220 + if not isinstance(parsed, dict):
221 + errors.append("Frontmatter must be a mapping")
222 + return {}, errors
223 + return parsed, errors
224 +
225 + parsed = _parse_frontmatter_fallback(frontmatter_text)
226 + if not parsed:
227 + errors.append("Invalid YAML frontmatter")
228 + return parsed, errors
229 +
230 +
231 def skill_from_markdown(
232 skill_md_path: Path,
233 source: SkillSource,
234 *,
235 include_content: bool = False,
236 + validate: bool = True,
237 ) -> Optional[Skill]:
238 try:
239 text = _read_text(skill_md_path)
240 except Exception:
241 return None
242
230 - fm, body = split_frontmatter(text)
243 + fm, body, fm_errors = split_frontmatter(text)
244 + if fm_errors:
245 + return None
246 skill_dir = skill_md_path.parent
247
233 - name = str(fm.get("name") or fm.get("skill") or skill_dir.name).strip()
248 + name = str(fm.get("name") or fm.get("skill") or "").strip()
249 description = str(
250 fm.get("description") or fm.get("when_to_use") or fm.get("summary") or ""
251 ).strip()
@@ -276,6 +291,10 @@ def skill_from_markdown(
291 raw_frontmatter=fm if include_content else {},
292 content=body if include_content else "",
293 )
294 + if validate:
295 + issues = validate_skill(skill)
296 + if issues:
297 + return None
298 return skill
299
300
@@ -414,7 +433,18 @@ def validate_skill(skill: Skill) -> List[str]:
433
434
435 def validate_skill_md(skill_md_path: Path, source: SkillSource) -> List[str]:
417 - skill = skill_from_markdown(skill_md_path, source, include_content=False)
436 + try:
437 + text = _read_text(skill_md_path)
438 + except Exception:
439 + return ["Unable to read SKILL.md"]
440 +
441 + _fm, _body, fm_errors = split_frontmatter(text)
442 + if fm_errors:
443 + return fm_errors
444 +
445 + skill = skill_from_markdown(
446 + skill_md_path, source, include_content=False, validate=False
447 + )
448 if not skill:
449 return ["Unable to parse SKILL.md frontmatter"]
450 return validate_skill(skill)