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)