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
2121skills/:
2222
23231. YAML frontmatter — every SKILL.md must have a valid frontmatter
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
107115SOFT categories surface as advisory warnings (stderr) without
108116failing the run unless ``--strict`` is passed.
118126import argparse
119127import contextlib
120128import re
129+ import subprocess
121130import sys
122131from collections .abc import Iterable
123132from 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.
444453TEMPLATE_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.
447459SKILL_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)
466479HARD_CATEGORIES : frozenset [str ] = frozenset (
@@ -1531,10 +1544,46 @@ def collect_files_to_check(root: Path | None = None) -> list[Path]:
15311544
15321545def 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
15401589def 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+
28172955def 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