fix: widen UTF-8 BOM tolerance to all sibling frontmatter parsers

The previous commit fixes the canonical agent/skill_utils.parse_frontmatter.
Six more modules reimplement the '---' fence check locally and had the
same bug:

- tools/skill_manager_tool.py _validate_frontmatter — rejected BOM'd
  skill_manage create/edit content outright
- tools/skills_hub.py GitHubSource._parse_frontmatter_quick and
  OptionalSkillSource._parse_frontmatter — hub browse/install metadata
- hermes_cli/skills_hub.py — local skill install validation
- gateway/run.py — skill slug discovery for disabled-skill hints
- agent/prompt_builder.py _strip_yaml_frontmatter — BOM'd context files
  (AGENTS.md) leaked raw frontmatter into the system prompt
- tools/blueprints.py _split_frontmatter — str.lstrip() does not strip
  U+FEFF (not whitespace), so the existing lstrip never covered it

Sibling-surface regression tests added.
Bug class also fixed upstream in cline/cline#12218 (found by the weekly
Cline PR scout).
This commit is contained in:
Teknium 2026-07-15 17:16:00 -07:00
parent a4ecb3da9a
commit 780e098077
7 changed files with 49 additions and 1 deletions

View File

@ -114,6 +114,7 @@ def _strip_yaml_frontmatter(content: str) -> str:
strip it so only the human-readable markdown body is injected into the
system prompt.
"""
content = content.lstrip("\ufeff") # tolerate UTF-8 BOM (Windows editors)
if content.startswith("---"):
end = content.find("\n---", 3)
if end != -1:

View File

@ -2344,6 +2344,7 @@ def _skill_slug_from_frontmatter(skill_md: Path) -> tuple[str | None, str | None
content = skill_md.read_text(encoding="utf-8", errors="replace")
except Exception:
return None, None
content = content.lstrip("\ufeff") # tolerate UTF-8 BOM (Windows editors)
if not content.startswith("---"):
return None, None
end = content.find("\n---", 3)

View File

@ -1461,6 +1461,7 @@ def do_publish(skill_path: str, target: str = "github", repo: str = "",
# Validate the skill
import yaml
skill_md = (path / "SKILL.md").read_text(encoding="utf-8")
skill_md = skill_md.lstrip("\ufeff") # tolerate UTF-8 BOM (Windows editors)
fm = {}
if skill_md.startswith("---"):
import re

View File

@ -474,3 +474,43 @@ class TestParseFrontmatterBOM:
fm, _ = parse_frontmatter(raw)
assert fm["name"] == "my-skill"
assert fm["platforms"] == ["macos"]
class TestBOMToleranceSiblingSites:
"""The BOM fix must cover every independent frontmatter parser, not just
the canonical ``parse_frontmatter`` several modules reimplement the
``---`` fence check locally."""
SKILL = "---\nname: bom-skill\ndescription: Saved by Notepad\n---\n\n# Body\n"
def test_skill_manager_validate_accepts_bom(self):
from tools.skill_manager_tool import _validate_frontmatter
assert _validate_frontmatter("\ufeff" + self.SKILL) is None
def test_prompt_builder_strips_bom_frontmatter(self):
# A BOM'd context file (AGENTS.md etc.) must not leak raw
# frontmatter into the system prompt.
from agent.prompt_builder import _strip_yaml_frontmatter
out = _strip_yaml_frontmatter("\ufeff---\nfoo: bar\n---\nBody text\n")
assert out.strip() == "Body text"
def test_blueprints_split_frontmatter_bom(self):
# str.lstrip() does NOT strip U+FEFF (it is not whitespace), so the
# pre-existing lstrip() in _split_frontmatter never covered it.
from tools.blueprints import _split_frontmatter
fm = _split_frontmatter("\ufeff---\nname: bp\n---\nbody")
assert fm is not None
assert fm.get("name") == "bp"
def test_skills_hub_parsers_accept_bom(self):
from tools.skills_hub import GitHubSource, OptionalSkillSource
for parser in (
GitHubSource._parse_frontmatter_quick,
OptionalSkillSource._parse_frontmatter,
):
fm = parser("\ufeff" + self.SKILL)
assert fm.get("name") == "bom-skill", parser.__qualname__

View File

@ -73,7 +73,7 @@ def _split_frontmatter(text: str) -> Optional[Dict[str, Any]]:
"""Return the parsed YAML frontmatter mapping, or None if absent/invalid."""
if not isinstance(text, str):
return None
stripped = text.lstrip()
stripped = text.lstrip("\ufeff").lstrip() # BOM is not whitespace; strip explicitly
if not stripped.startswith("---"):
return None
# Find the closing fence after the opening one.

View File

@ -529,6 +529,9 @@ def _validate_frontmatter(content: str) -> Optional[str]:
if not content.strip():
return "Content cannot be empty."
# Tolerate a leading UTF-8 BOM (Windows editors) before the fence.
content = content.lstrip("\ufeff")
if not content.startswith("---"):
return "SKILL.md must start with YAML frontmatter (---). See existing skills for format."

View File

@ -1165,6 +1165,7 @@ class GitHubSource(SkillSource):
@staticmethod
def _parse_frontmatter_quick(content: str) -> dict:
"""Parse YAML frontmatter from SKILL.md content."""
content = content.lstrip("\ufeff") # tolerate UTF-8 BOM (Windows editors)
if not content.startswith("---"):
return {}
match = re.search(r'\n---\s*\n', content[3:])
@ -3317,6 +3318,7 @@ class OptionalSkillSource(SkillSource):
@staticmethod
def _parse_frontmatter(content: str) -> dict:
"""Parse YAML frontmatter from SKILL.md content."""
content = content.lstrip("\ufeff") # tolerate UTF-8 BOM (Windows editors)
if not content.startswith("---"):
return {}
match = re.search(r'\n---\s*\n', content[3:])