Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/skillspector/multi_skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,11 @@ def _extract_skill_name(skill_dir: Path, *, budget: _DetectionBudget) -> str:
raise _read_error("multi_skill_manifest_content") from exc

prefix = observed[:MAX_MULTI_SKILL_MANIFEST_FRONTMATTER_BYTES]
while prefix.startswith(b"\xef\xbb\xbf"):
# Byte-level counterpart to the BOM strip in build_context._parse_manifest:
# this sniff runs on raw bytes before decode, so a BOM-prefixed SKILL.md
# would otherwise fall through to `fallback` (the directory name) below.
prefix = prefix[3:]
if not prefix.startswith(b"---"):
return fallback
content = prefix.decode("utf-8", errors="replace")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,10 @@ def _frontmatter_bounds(content: str, file_path: str) -> tuple[int, int] | None:
"""Return the YAML-frontmatter byte offsets for a SKILL.md file."""
if file_path.rsplit("/", 1)[-1].lower() != "skill.md":
return None
opening = re.match(r"\A---[ \t]*\r?\n", content)
# A leading BOM is matched here rather than stripped: the offsets this
# function returns index the caller's original `content` (line numbers and
# context are derived from it), so removing characters would shift them.
opening = re.match(r"\A\ufeff*---[ \t]*\r?\n", content)
if opening is None:
return None
closing = re.search(r"^---[ \t]*$", content[opening.end() :], re.MULTILINE)
Expand Down
9 changes: 9 additions & 0 deletions src/skillspector/nodes/build_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -1575,6 +1575,15 @@ def _check_runtime() -> None:
runtime_limit=runtime_limit,
)
return {}
while content.startswith("\ufeff"):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description says "both places that sniff SKILL.md frontmatter", but I found a third: _frontmatter_bounds in static_patterns_excessive_agency.py (around line 189) does re.match(r"\A---[ \t]*\r?\n", content) on decode_text() output, which keeps the BOM.

I ran it: with plain frontmatter it returns bounds (4, 17); add a leading BOM and it returns None. So EA5's frontmatter-key detection (model-switch keys, selection_surface: "frontmatter") still silently loses findings on a BOM-prefixed SKILL.md — the same silent-miss class this PR fixes.

It's the same one-line class of fix, so worth either adding it here or filing a named follow-up, so the "both places" claim doesn't get taken at face value.

# decode_text() decodes with plain "utf-8", which never strips a
# leading byte-order mark (only "utf-8-sig" does), so a BOM-prefixed
# SKILL.md leaves content[0] == "\ufeff" and the delimiter check below
# silently sees {} instead of the frontmatter. Strip leading BOMs here,
# scoped to delimiter detection — decode_text() itself stays untouched
# because P2/TP1/P9 treat U+FEFF as a hidden-character injection signal
# in file bodies.
content = content[1:]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny simplification: for this string case, content = content.lstrip("\ufeff") does the same thing in one pass instead of the char-by-char loop. Each iteration here copies the whole remaining buffer — I measured the worst case (256 KiB frontmatter window filled with stacked BOMs, ~87k iterations) at ~0.15s, so it stays under the 1.0s MAX_MANIFEST_PARSE_SECONDS budget; it's just untidy, not a real problem.

One caution though: do NOT make the equivalent change to the bytes loop in multi_skill.py. prefix.lstrip(b"\xef\xbb\xbf") strips individual bytes from that set, so a malformed partial BOM like EF BB followed by --- would wrongly pass the sniff. The loop there is correct as written — I tested the partial-BOM case and it properly rejects.

if not content.startswith("---"):
return {}
end_match = re.search(r"\n---\s*\n", content[3:])
Expand Down
49 changes: 49 additions & 0 deletions tests/nodes/test_build_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,55 @@ def test_build_context_skill_md_lowercase(tmp_path: Path) -> None:
assert "references/guide.md" in result["components"]


def test_build_context_parses_manifest_with_utf8_bom(tmp_path: Path) -> None:
"""A leading UTF-8 BOM before the frontmatter delimiter must not blank the manifest.

Regression guard: ``decode_text()`` decodes with plain "utf-8", which does not
strip a leading byte-order mark (only "utf-8-sig" does), so
``content[0] == "\\ufeff"`` and the naive ``content.startswith("---")`` check in
``_parse_manifest`` used to fail silently, returning ``{}`` with no ledger record.
"""
(tmp_path / "SKILL.md").write_bytes(
b"\xef\xbb\xbf---\nname: bom-skill\ndescription: has a leading BOM\n---\n# Skill\n"
)
state: SkillspectorState = {"skill_path": str(tmp_path)}
result = build_context(state)

assert result["manifest"]["name"] == "bom-skill"
assert result["manifest"]["description"] == "has a leading BOM"


def test_build_context_parses_manifest_without_bom_identically(tmp_path: Path) -> None:
"""Regression guard: the BOM-free sibling of the fixture above parses identically."""
(tmp_path / "SKILL.md").write_bytes(
b"---\nname: bom-skill\ndescription: has a leading BOM\n---\n# Skill\n"
)
state: SkillspectorState = {"skill_path": str(tmp_path)}
result = build_context(state)

assert result["manifest"]["name"] == "bom-skill"
assert result["manifest"]["description"] == "has a leading BOM"


def test_build_context_parses_manifest_with_stacked_utf8_bom(tmp_path: Path) -> None:
"""A doubled leading BOM must not reproduce the original silent ``{}`` manifest.

Regression guard: the single-strip fix (``if content.startswith("\\ufeff")``)
only removes one BOM, so ``content[0] == "\\ufeff"`` again after the strip and
``content.startswith("---")`` still fails on a stacked BOM. ``_parse_manifest``
strips leading BOMs in a loop so repeated markers are all removed.
"""
(tmp_path / "SKILL.md").write_bytes(
b"\xef\xbb\xbf\xef\xbb\xbf---\nname: stacked-bom-skill\ndescription: doubled BOM\n"
b"---\n# Skill\n"
)
state: SkillspectorState = {"skill_path": str(tmp_path)}
result = build_context(state)

assert result["manifest"]["name"] == "stacked-bom-skill"
assert result["manifest"]["description"] == "doubled BOM"


def test_build_context_parses_manifest_from_cached_snapshot_after_file_disappears(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
Expand Down
26 changes: 26 additions & 0 deletions tests/test_multi_skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,32 @@ def test_skill_names_extracted_from_frontmatter(self, multi_skill_dir: Path) ->
names = {s.name for s in result.skills}
assert names == {"weather-lookup", "email-sender", "file-manager"}

def test_skill_name_extracted_from_bom_prefixed_frontmatter(self, tmp_path: Path) -> None:
"""A BOM-prefixed sub-skill resolves to its declared `name:`, not the directory name.

Regression guard: `_extract_skill_name` sniffs raw bytes for a leading
`b"---"` before decoding. A UTF-8 BOM (`b"\\xef\\xbb\\xbf"`) in front of that
delimiter used to defeat the sniff and silently fall back to `skill_dir.name`
instead of the frontmatter's declared name.
"""
clean_dir = tmp_path / "clean-skill"
clean_dir.mkdir()
(clean_dir / "SKILL.md").write_bytes(
b"---\nname: clean-declared-name\ndescription: no BOM\n---\n# Clean\n"
)

bom_dir = tmp_path / "bom-dir-name"
bom_dir.mkdir()
(bom_dir / "SKILL.md").write_bytes(
b"\xef\xbb\xbf---\nname: bom-declared-name\ndescription: has a BOM\n---\n# BOM\n"
)

result = detect_skills(tmp_path)

names = {s.relative_path: s.name for s in result.skills}
assert names["clean-skill"] == "clean-declared-name"
assert names["bom-dir-name"] == "bom-declared-name"

def test_structured_skill_subdir_detected(self, tmp_path: Path) -> None:
"""An immediate subdirectory with a valid AISOP/AISP bundle is detected."""
sub = tmp_path / "workflow-bundle"
Expand Down
15 changes: 15 additions & 0 deletions tests/unit/test_patterns_new.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,21 @@ def test_ea5_frontmatter_pin_is_medium(self, key: str) -> None:
"selection_key": key,
}

def test_ea5_frontmatter_pin_detected_through_leading_bom(self) -> None:
"""A BOM-prefixed SKILL.md must not hide the frontmatter model pin."""
content = "\ufeff---\nname: example\nmodel: claude-sonnet-4-6\n---\n\n# Example\n"
findings = ea_mod.analyze(content, "SKILL.md", "markdown")
ea5 = [finding for finding in findings if finding.rule_id == "EA5"]
assert len(ea5) == 1
assert ea5[0].severity == Severity.MEDIUM
# The BOM is matched, not stripped, so offsets still index the original
# content and the reported line matches the no-BOM case exactly.
assert ea5[0].location.start_line == 3
assert ea5[0].evidence == {
"selection_surface": "frontmatter",
"selection_key": "model",
}

def test_ea5_only_matches_top_level_skill_frontmatter(self) -> None:
content = (
"---\n"
Expand Down
Loading