Skip to content

Commit 100b85f

Browse files
authored
feat(validator): add branch-name confidentiality check (#692)
* feat(validator): add branch-name confidentiality check (#18, SOFT advisory) Adds check #17 to skill-and-tool-validator: scans git checkout -b and git switch -c examples inside fenced code blocks (across skills/ and docs/) and flags any concrete branch name that contains an embargo-breaking term — CVE IDs (CVE-YYYY-NNNNN), security, vulnerability/vuln, or advisory. Pre-disclosure public branch names must not reveal embargo context; neutral descriptive slugs are the safe alternative. Lines explicitly marked as bad examples (**bad**, bad:) are exempt, and placeholder branch names (<fix-slug>, $VAR) are silently skipped. The check is SOFT-advisory only (never blocks the run). 14 unit tests cover CVE IDs, security framing, vuln/advisory terms, placeholder exemptions, neutral names, and bad-example exemptions. The full codebase currently produces zero new violations. Generated-by: Claude (Opus 4.7) * fix for tool directories * change regular expression
1 parent 3c020b5 commit 100b85f

2 files changed

Lines changed: 299 additions & 3 deletions

File tree

tools/skill-and-tool-validator/src/skill_and_tool_validator/__init__.py

Lines changed: 150 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
"""Validate framework skill definitions.
1919
20-
This module validates twelve aspects of every skill under
20+
This module validates seventeen aspects of every skill under
2121
skills/:
2222
2323
1. YAML frontmatter — every SKILL.md must have a valid frontmatter
@@ -103,6 +103,14 @@
103103
headings (``project.md`` and ``README.md`` are excluded from the
104104
h2 comparison because their structures intentionally differ by
105105
organization profile). Advisory only.
106+
17. Branch-name confidentiality (SOFT) — scans ``git checkout -b`` and
107+
``git switch -c`` examples in fenced code blocks across skills and
108+
docs and flags any concrete branch name that contains an
109+
embargo-breaking term: a CVE ID (``CVE-YYYY-NNNNN``), ``security``,
110+
``vulnerability`` / ``vuln``, or ``advisory``. Pre-disclosure
111+
public branch names must not reveal embargo context. Lines in
112+
explicit "bad example" contexts (containing ``**bad**`` or
113+
``bad:``) are exempt. Advisory only.
106114
107115
SOFT categories surface as advisory warnings (stderr) without
108116
failing the run unless ``--strict`` is passed.
@@ -118,6 +126,7 @@
118126
import argparse
119127
import contextlib
120128
import re
129+
import subprocess
121130
import sys
122131
from collections.abc import Iterable
123132
from pathlib import Path
@@ -442,6 +451,9 @@ def _read_mode_table() -> dict[str, str]:
442451
# SOFT advisory: structural drift between projects/_template/ and
443452
# projects/non-asf-example/ — missing files, undocumented files, or h2 mismatches.
444453
TEMPLATE_DRIFT_CATEGORY = "template-drift"
454+
# SOFT advisory: branch name examples in code blocks that contain embargo-breaking
455+
# terms (CVE IDs, security, vulnerability, advisory) before public disclosure.
456+
BRANCH_CONFIDENTIALITY_CATEGORY = "branch-name-confidentiality"
445457

446458
# The `magpie-` namespace prefix every installed framework skill carries.
447459
SKILL_NAME_PREFIX = "magpie-"
@@ -461,6 +473,7 @@ def _read_mode_table() -> dict[str, str]:
461473
MULTI_CAPABILITY_CATEGORY,
462474
OVERRIDE_CONTRACT_CATEGORY,
463475
TEMPLATE_DRIFT_CATEGORY,
476+
BRANCH_CONFIDENTIALITY_CATEGORY,
464477
}
465478
)
466479
HARD_CATEGORIES: frozenset[str] = frozenset(
@@ -1531,10 +1544,46 @@ def collect_files_to_check(root: Path | None = None) -> list[Path]:
15311544

15321545
def collect_tool_dirs(root: Path | None = None) -> list[Path]:
15331546
"""Return every immediate sub-directory under tools/ that should be checked."""
1534-
base = (root or find_repo_root()) / TOOLS_DIR
1547+
repo_root = root or find_repo_root()
1548+
base = repo_root / TOOLS_DIR
15351549
if not base.exists():
15361550
return []
1537-
return sorted(d for d in base.iterdir() if d.is_dir() and not d.name.startswith("."))
1551+
1552+
dirs = sorted(d for d in base.iterdir() if d.is_dir() and not d.name.startswith("."))
1553+
tracked_names = _git_tracked_tool_names(repo_root)
1554+
if tracked_names is None:
1555+
return dirs
1556+
return [d for d in dirs if d.name in tracked_names]
1557+
1558+
1559+
def _git_tracked_tool_names(root: Path) -> set[str] | None:
1560+
"""Return top-level ``tools/<name>`` entries tracked by git, if available."""
1561+
try:
1562+
result = subprocess.run(
1563+
["git", "-C", str(root), "ls-files", "-z", "--", str(TOOLS_DIR)],
1564+
check=False,
1565+
stdout=subprocess.PIPE,
1566+
stderr=subprocess.DEVNULL,
1567+
text=False,
1568+
)
1569+
except OSError:
1570+
return None
1571+
if result.returncode != 0:
1572+
return None
1573+
1574+
names: set[str] = set()
1575+
prefix = f"{TOOLS_DIR.as_posix()}/"
1576+
for raw_path in result.stdout.split(b"\0"):
1577+
if not raw_path:
1578+
continue
1579+
path = raw_path.decode("utf-8", errors="surrogateescape")
1580+
if not path.startswith(prefix):
1581+
continue
1582+
remainder = path[len(prefix) :]
1583+
name = remainder.split("/", 1)[0]
1584+
if name and not name.startswith("."):
1585+
names.add(name)
1586+
return names
15381587

15391588

15401589
def validate_tools(root: Path | None = None) -> Iterable[Violation]:
@@ -2814,6 +2863,95 @@ def validate_project_template_drift(root: Path | None = None) -> Iterable[Violat
28142863
)
28152864

28162865

2866+
# ---------------------------------------------------------------------------
2867+
# Branch-name confidentiality check (check #17, SOFT)
2868+
# ---------------------------------------------------------------------------
2869+
2870+
# Matches `git checkout -b <branch-name>` in fenced code blocks.
2871+
_BRANCH_CHECKOUT_RE = re.compile(r"git\s+checkout\s+-b\s+([^\s#\\]+)")
2872+
# Matches `git switch -c <branch-name>` and `git switch --create <branch-name>`.
2873+
_BRANCH_SWITCH_RE = re.compile(r"git\s+switch\s+(?:--create|-c)\s+([^\s#\\]+)")
2874+
2875+
# Embargo-breaking terms in branch names:
2876+
# - CVE IDs: CVE-YYYY-NNNNN
2877+
# - security, vulnerability (or vuln), advisory as a word component
2878+
_EMBARGO_BRANCH_RE = re.compile(
2879+
r"CVE-\d{4}-\d{4,}"
2880+
r"|(?<![^-_/])(?:security|vuln(?:erability|erable)?|advisory)(?![^-_/])",
2881+
re.IGNORECASE,
2882+
)
2883+
2884+
# Markers that indicate a line is an intentional "bad example" demonstration.
2885+
_BRANCH_BAD_EXAMPLE_MARKERS: tuple[str, ...] = (
2886+
"**bad**",
2887+
"bad:",
2888+
"# bad",
2889+
"don't:",
2890+
"not:",
2891+
"invalid:",
2892+
"forbidden:",
2893+
)
2894+
2895+
2896+
def validate_branch_name_confidentiality(path: Path, text: str) -> Iterable[Violation]:
2897+
"""Flag embargo-breaking terms in branch name examples inside fenced code blocks.
2898+
2899+
SOFT advisory — scans ``git checkout -b`` and ``git switch -c`` commands in
2900+
fenced code blocks across skills and docs and flags any concrete branch name
2901+
that contains a CVE ID, ``security``, ``vulnerability`` / ``vuln``, or
2902+
``advisory``. Pre-disclosure public branch names must not reveal embargo
2903+
context; use a neutral descriptive slug instead.
2904+
2905+
Lines in explicit "bad example" contexts (containing ``**bad**`` or ``bad:``)
2906+
are exempt. Placeholder branch names (containing ``<...>`` or starting with
2907+
``$``) are silently skipped.
2908+
"""
2909+
if is_path_allowlisted(path):
2910+
return
2911+
2912+
for block_match in _FENCED_CODE_RE.finditer(text):
2913+
block_body = block_match.group()
2914+
block_start_line = text[: block_match.start()].count("\n")
2915+
block_lines = block_body.splitlines()
2916+
2917+
for cmd_re in (_BRANCH_CHECKOUT_RE, _BRANCH_SWITCH_RE):
2918+
for cmd_match in cmd_re.finditer(block_body):
2919+
branch_name = cmd_match.group(1)
2920+
2921+
# Skip placeholder branch names.
2922+
if "<" in branch_name or branch_name.startswith("$"):
2923+
continue
2924+
2925+
embargo_match = _EMBARGO_BRANCH_RE.search(branch_name)
2926+
if not embargo_match:
2927+
continue
2928+
2929+
# Determine which line within the block carries this match.
2930+
line_in_block = block_body[: cmd_match.start()].count("\n")
2931+
if 0 <= line_in_block < len(block_lines):
2932+
line_text = block_lines[line_in_block]
2933+
if any(marker in line_text for marker in _BRANCH_BAD_EXAMPLE_MARKERS):
2934+
continue
2935+
# Also skip if the line itself is a comment (# bad example…).
2936+
stripped = line_text.strip()
2937+
if stripped.startswith("#") and any(
2938+
m in stripped.lower() for m in ("bad", "don't", "invalid")
2939+
):
2940+
continue
2941+
2942+
absolute_line_no = block_start_line + line_in_block + 1
2943+
yield Violation(
2944+
path,
2945+
absolute_line_no,
2946+
f"branch-name-confidentiality: branch name example `{branch_name}` "
2947+
f"contains embargo-breaking term {embargo_match.group()!r} — "
2948+
f"pre-disclosure public branch names must not reveal CVE IDs or "
2949+
f"security framing; use a neutral descriptive slug instead "
2950+
f"(e.g. 'fix-input-validation')",
2951+
category=BRANCH_CONFIDENTIALITY_CATEGORY,
2952+
)
2953+
2954+
28172955
def run_validation(root: Path | None = None) -> list[Violation]:
28182956
"""Run the full validation suite and return all violations."""
28192957
repo_root = root or find_repo_root()
@@ -2845,6 +2983,7 @@ def run_validation(root: Path | None = None) -> list[Violation]:
28452983
violations.extend(validate_security_patterns(path, text))
28462984
violations.extend(validate_gh_list_limit(path, text))
28472985
violations.extend(validate_lowercase_f_field(path, text))
2986+
violations.extend(validate_branch_name_confidentiality(path, text))
28482987

28492988
# License-header check for tool Python source files.
28502989
for py_path in collect_tool_python_files(repo_root):
@@ -2877,6 +3016,14 @@ def run_validation(root: Path | None = None) -> list[Violation]:
28773016
# Project-template drift check: _template/ and non-asf-example/ stay comparable.
28783017
violations.extend(validate_project_template_drift(repo_root))
28793018

3019+
# Branch-name confidentiality check on docs/ (skills/ is already covered above).
3020+
for doc_path in sorted(doc_files):
3021+
try:
3022+
doc_text = doc_path.read_text(encoding="utf-8")
3023+
except OSError:
3024+
continue
3025+
violations.extend(validate_branch_name_confidentiality(doc_path, doc_text))
3026+
28803027
return violations
28813028

28823029

0 commit comments

Comments
 (0)