Files
Anthropic-Cybersecurity-Skills/tools/lint-descriptions.py
T
Mahipal a81b233649 ci: add description-quality and collision gates
The description is the only signal an agent sees at discovery time, so
overlapping descriptions cause misrouting. Nothing in CI checked for that.

- tools/lint-descriptions.py enforces name==folder, description <=1024
  chars, terminal punctuation (a truncation canary), a trigger clause, a
  negative trigger, and a 500-line body cap. Pre-existing failures are
  grandfathered in tools/lint-baseline.json so this blocks new debt only;
  the baseline may shrink and never grow.
- tools/detect-collisions.py scores every description pair by TF-IDF
  cosine and ratchets the count of unreviewed near-duplicates. It strips
  negative-trigger clauses before vectorizing: those name the sibling
  skill on purpose, so scoring them would make correct disambiguation
  raise a pair's similarity.
- wire both into validate-skills.yml, along with agentskills conformance,
  an index.json freshness check, and a guard that fails the build if a
  regex frontmatter parser is reintroduced.
- broaden the path filters from tools/validate-skill.py to tools/**, as
  noted when #105 merged.

All five gates verified to fail on deliberately broken input.
2026-08-23 17:15:12 +02:00

220 lines
8.2 KiB
Python

#!/usr/bin/env python3
"""Lint SKILL.md descriptions for the qualities that drive agent routing.
The description is the ONLY signal an agent sees at discovery time, so it must
say what the skill does, when to fire, and -- critically -- when NOT to fire.
Skills whose descriptions overlap without a distinguishing negative trigger get
misrouted ("skill collision").
Rules
-----
name-matches-folder frontmatter name == directory name
desc-max-length description <= 1024 chars (agentskills.io limit)
desc-ends-punctuation ends in . ! ? ) " ' -- a truncation canary
desc-has-use-when carries an explicit trigger clause
desc-has-negative-trigger says what it is NOT for
body-max-lines SKILL.md <= 500 lines (Anthropic guidance)
Grandfathering
--------------
Known pre-existing failures live in tools/lint-baseline.json so CI can go green
today while the debt is paid down. A baselined skill that starts passing is
reported as a stale entry -- run --update-baseline to shrink the file. The
baseline can only ever shrink in review, never silently grow: any NEW failure
is a hard error.
Usage:
python tools/lint-descriptions.py --all
python tools/lint-descriptions.py --all --stats
python tools/lint-descriptions.py --update-baseline
python tools/lint-descriptions.py skills/<slug>
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from skill_frontmatter import description_of, iter_skill_dirs, load_frontmatter, FrontmatterError
BASELINE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lint-baseline.json")
DESCRIPTION_MAX_CHARS = 1024
BODY_MAX_LINES = 500
SENTENCE_ENDINGS = ".!?)\"'"
# "Use when ...", "Use this skill when ...", "Use for ...", "Use during ..."
USE_WHEN_RE = re.compile(
r"\buse\s+(?:this\s+)?(?:skill\s+)?(?:when|whenever|for|during|after|before)\b",
re.IGNORECASE,
)
# "Do not use for X", "Don't use when Y", "Not for Z"
NEGATIVE_TRIGGER_RE = re.compile(
r"\b(?:do\s+not\s+use|don'?t\s+use|not\s+for\b|avoid\s+(?:this\s+)?(?:skill\s+)?for)\b",
re.IGNORECASE,
)
RULES = (
"name-matches-folder",
"desc-max-length",
"desc-ends-punctuation",
"desc-has-use-when",
"desc-has-negative-trigger",
"body-max-lines",
)
RED, GREEN, YELLOW, DIM, RESET = "\033[91m", "\033[92m", "\033[93m", "\033[2m", "\033[0m"
def check_skill(slug: str, skill_dir: str) -> dict[str, str]:
"""Return {rule_id: human message} for every rule this skill violates."""
skill_md = os.path.join(skill_dir, "SKILL.md")
violations: dict[str, str] = {}
try:
frontmatter = load_frontmatter(skill_md)
except FrontmatterError as exc:
return {"name-matches-folder": f"unparseable frontmatter: {exc}"}
name = str(frontmatter.get("name", "") or "")
if name != slug:
violations["name-matches-folder"] = f"name {name!r} != folder {slug!r}"
description = description_of(frontmatter)
if len(description) > DESCRIPTION_MAX_CHARS:
violations["desc-max-length"] = (
f"{len(description)} chars, max {DESCRIPTION_MAX_CHARS}")
if description and description[-1] not in SENTENCE_ENDINGS:
violations["desc-ends-punctuation"] = f"ends with {description[-40:]!r}"
if not USE_WHEN_RE.search(description):
violations["desc-has-use-when"] = "no trigger clause (add 'Use when ...')"
if not NEGATIVE_TRIGGER_RE.search(description):
violations["desc-has-negative-trigger"] = (
"no negative trigger (add 'Do not use for X - use <other-skill>.')")
with open(skill_md, encoding="utf-8") as handle:
line_count = sum(1 for _ in handle)
if line_count > BODY_MAX_LINES:
violations["body-max-lines"] = f"{line_count} lines, max {BODY_MAX_LINES}"
return violations
def collect(skills_dir: str = "skills") -> dict[str, dict[str, str]]:
return {slug: v for slug, d in iter_skill_dirs(skills_dir)
if (v := check_skill(slug, d))}
def load_baseline() -> dict[str, list[str]]:
if not os.path.isfile(BASELINE_PATH):
return {}
with open(BASELINE_PATH, encoding="utf-8") as handle:
return {k: v for k, v in json.load(handle).items() if not k.startswith("_")}
def write_baseline(violations: dict[str, dict[str, str]]) -> dict[str, list[str]]:
baseline = {rule: sorted(s for s, v in violations.items() if rule in v)
for rule in RULES}
baseline = {rule: slugs for rule, slugs in baseline.items() if slugs}
payload = {
"_comment": (
"Pre-existing lint failures, grandfathered so CI can gate new work today. "
"This file may only shrink. Never add a slug by hand -- fix the skill, then "
"run: python tools/lint-descriptions.py --update-baseline"
),
"_total_grandfathered": sum(len(s) for s in baseline.values()),
**baseline,
}
with open(BASELINE_PATH, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2)
handle.write("\n")
return baseline
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("target", nargs="?", help="a single skills/<slug> directory")
parser.add_argument("--all", action="store_true", help="lint every skill")
parser.add_argument("--stats", action="store_true", help="show per-rule totals")
parser.add_argument("--update-baseline", action="store_true",
help="rewrite lint-baseline.json from current state")
parser.add_argument("--skills-dir", default="skills")
args = parser.parse_args()
if not os.path.isdir(args.skills_dir):
print(f"ERROR: '{args.skills_dir}' not found. Run from the repository root.")
return 1
single_skill = bool(args.target) and not (args.all or args.update_baseline)
if single_skill:
slug = os.path.basename(args.target.rstrip("/\\"))
violations = {slug: v} if (v := check_skill(slug, args.target.rstrip("/\\"))) else {}
else:
violations = collect(args.skills_dir)
if args.update_baseline:
baseline = write_baseline(violations)
total = sum(len(s) for s in baseline.values())
print(f"Baseline written: {total} grandfathered violation(s) across "
f"{len(baseline)} rule(s) -> {os.path.relpath(BASELINE_PATH)}")
for rule in RULES:
if baseline.get(rule):
print(f" {rule:26s} {len(baseline[rule])}")
return 0
baseline = load_baseline()
new_failures: list[tuple[str, str, str]] = []
grandfathered = 0
for slug, rule_map in sorted(violations.items()):
for rule, message in sorted(rule_map.items()):
if slug in baseline.get(rule, []):
grandfathered += 1
else:
new_failures.append((slug, rule, message))
# Only meaningful over the whole tree: linting one skill says nothing about
# whether the other 816 baselined violations still stand.
stale = [] if single_skill else [
(rule, slug) for rule, slugs in baseline.items() for slug in slugs
if rule not in violations.get(slug, {})
]
if args.stats:
print("Violations by rule (grandfathered + new):")
for rule in RULES:
count = sum(1 for v in violations.values() if rule in v)
remaining = len(baseline.get(rule, []))
print(f" {rule:26s} {count:4d} baselined {remaining:4d}")
print()
for slug, rule, message in new_failures:
print(f"{RED}FAIL{RESET} {slug}: {YELLOW}{rule}{RESET} - {message}")
if stale:
print(f"\n{GREEN}{len(stale)} baselined violation(s) now pass.{RESET} "
f"Shrink the baseline: python tools/lint-descriptions.py --update-baseline")
for rule, slug in stale[:10]:
print(f" {DIM}fixed{RESET} {slug}: {rule}")
print(f"\n{'=' * 60}")
checked = 1 if single_skill else sum(1 for _ in iter_skill_dirs(args.skills_dir))
print(f"Skills: {checked} "
f"{RED}New failures: {len(new_failures)}{RESET} "
f"{YELLOW}Grandfathered: {grandfathered}{RESET}")
return 1 if new_failures else 0
if __name__ == "__main__":
sys.exit(main())