refactor: extract CII helper into standalone masdevid/cii-best-practices skill

scripts/cii_criteria.py and scripts/cii_justifications.json moved to the
new dedicated repo, generalized for any project:

  https://github.com/masdevid/cii-best-practices

Install as an agent skill with:
  npx skills add masdevid/cii-best-practices
This commit is contained in:
Devid HW
2026-08-26 06:28:49 +07:00
parent 302a0c5624
commit 7eace36bb6
2 changed files with 0 additions and 526 deletions
-477
View File
@@ -1,477 +0,0 @@
#!/usr/bin/env python3
"""List incomplete OpenSSF Best Practices criteria and generate a pre-filled edit URL.
Usage:
cii_criteria.py <project_id> [section]
[--schema {osps,metal}] [--exclude n1,n2,n3]
[--na n1,n2,n3] [--no-open] [--open]
project_id: bestpractices.dev project numeric id (default: 14227)
section: passing | silver | gold | baseline-1 | baseline-2 | baseline-3
(default: passing for --schema metal; baseline-1 for --schema osps)
--schema: osps (DEFAULT) -> OSPS Baseline schema (baseline_criteria.yml;
project JSON uses `OSPS-*` criterion ids)
metal (legacy) -> "Metal" schema (criteria.yml; short criterion
names like `static_analysis`). Keeps the old behavior.
--exclude: comma-separated criterion ids to SKIP from the pre-filled URL
(they stay at their current Unmet/?/N/A in the form).
--na: comma-separated criterion ids to mark as N/A in the pre-filled URL
(emits <id>_status=N/A with the justification from the JSON).
--no-open: never open the generated URL in a browser (useful for CI/piping).
--open: force-open the URL even when stdout is not a TTY (e.g. when piped).
When run interactively (stdout is a TTY) the script auto-opens the generated
edit URL in the default browser. Piping to a file or another command prints the
URL only (no browser spawn). Use --no-open to suppress auto-open, --open to
force it regardless of TTY.
SCHEMA DIFFERENCES
------------------
* metal — fetches criteria.yml, maps tiers via its nested structure, and
pre-fills every Unmet/? criterion in the section as `Met` (placeholder
justification when none is known). This is the legacy behavior.
* osps — fetches baseline_criteria.yml, maps `original_id` -> tier, and only
pre-fills a criterion as `Met` when a truthful justification exists in
cii_justifications.json (keyed by the `OSPS-*` id). Criteria without a
justification are left at their current Unmet/? state so the URL never claims
more than is documented. `overrides=*,osps_*` is always appended.
"""
import sys, os, json, subprocess, urllib.request, urllib.parse, argparse
import ssl
try:
import certifi
_SSL_CTX = ssl.create_default_context(cafile=certifi.where())
except Exception:
_SSL_CTX = ssl.create_default_context()
BASE = "https://www.bestpractices.dev"
# Legacy "Metal" schema definitions.
METAL_CRITERIA_URL = ("https://raw.githubusercontent.com/ossf/best-practices-badge/"
"main/criteria/criteria.yml")
# OSPS Baseline schema definitions.
OSPS_CRITERIA_URL = ("https://raw.githubusercontent.com/ossf/best-practices-badge/"
"main/criteria/baseline_criteria.yml")
OSPS_TIERS = ("baseline-1", "baseline-2", "baseline-3")
# CLI section argument -> tier key inside the legacy criteria.yml.
METAL_SECTION_TO_TIER = {
"passing": "0",
"silver": "1",
"gold": "2",
"baseline-1": "baseline-1",
"baseline-2": "baseline-2",
"baseline-3": "baseline-3",
}
# Default section per schema (used only when no section argument is given).
SCHEMA_DEFAULT_SECTION = {
"metal": "passing",
"osps": "baseline-1",
}
# Criterion ids whose status is pre-fillable as "Met".
DOABLE_STATUSES = ("Unmet", "?")
def load_justifications():
"""Load real justifications from the sibling cii_justifications.json.
Returns a dict id -> justification string. Falls back to {} if the file
is missing or invalid, so callers should still default to a placeholder.
"""
path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"cii_justifications.json")
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
return data
except Exception:
pass
return {}
def fetch(url):
req = urllib.request.Request(url, headers={"User-Agent": "cii-skill"})
with urllib.request.urlopen(req, timeout=20, context=_SSL_CTX) as r:
return r.read().decode()
def open_url(url):
"""Open `url` in the default browser for the current platform.
Fails silently with a clear fallback message if the opener is unavailable
or errors — never raises, so the script always exits cleanly.
"""
if sys.platform == "darwin":
cmd = ["open", url]
elif sys.platform.startswith("win"):
# Windows: `start` needs a dummy title before the URL argument.
cmd = ["cmd", "/c", "start", "", url]
else:
cmd = ["xdg-open", url]
try:
subprocess.run(cmd, check=True)
except Exception as e:
print(f"\nCould not auto-open the browser ({e}).\n"
f"Open this URL manually:\n{url}", file=sys.stderr)
def load_project_criteria(project_id):
"""Fetch the project JSON and return {criterion_id: {status, justification}}."""
data = json.loads(fetch(f"{BASE}/projects/{project_id}.json"))
criteria = {}
for k, v in data.items():
if k.endswith("_status") and isinstance(v, str):
name = k[: -len("_status")]
criteria[name] = {
"status": v,
"justification": data.get(name + "_justification", "") or "",
}
return data, criteria
# --------------------------------------------------------------------------- #
# Metal (legacy) schema #
# --------------------------------------------------------------------------- #
def _collect_metal_names(node, out):
"""Recursively walk the criteria.yml nested structure.
The file is a list of (tier_key, [sections]); each section is
(section_name, [ (group, [...]) | (criterion_name, {attrs}) ]). A leaf
criterion is a 2-tuple whose second element is a dict (it has 'category').
"""
if isinstance(node, tuple) and len(node) == 2:
key, val = node
if isinstance(val, dict):
out.append(key) # leaf criterion
elif isinstance(val, list):
for child in val:
_collect_metal_names(child, out)
elif isinstance(node, list):
for child in node:
_collect_metal_names(child, out)
def fetch_metal_section_map():
"""Return {section_arg: set(criterion_names)} for every known Metal tier.
Returns None if criteria.yml could not be fetched/parsed.
"""
try:
raw = fetch(METAL_CRITERIA_URL)
except Exception as e:
print(f"WARNING: could not fetch criteria definitions ({e}); "
f"falling back to all-criteria behavior.", file=sys.stderr)
return None
try:
doc = __import__("yaml").safe_load(raw)
except Exception as e:
print(f"WARNING: could not parse criteria definitions ({e}); "
f"falling back to all-criteria behavior.", file=sys.stderr)
return None
# doc is a list of (tier_key, sections)
tier_names = {}
for item in doc:
if isinstance(item, tuple) and len(item) == 2:
tier_key, sections = item
names = []
_collect_metal_names(sections, names)
tier_names[tier_key] = set(names)
section_map = {}
for section_arg, tier_key in METAL_SECTION_TO_TIER.items():
section_map[section_arg] = tier_names.get(tier_key, set())
return section_map
def run_metal(project_id, section, exclude, na_set, justifications, args):
data, criteria = load_project_criteria(project_id)
section_map = fetch_metal_section_map()
target_names = section_map.get(section) if section_map else None
if target_names is not None and not target_names:
print(f"WARNING: no criteria found for section '{section}' in "
f"criteria.yml; falling back to all-criteria behavior.",
file=sys.stderr)
target_names = None # fall back: no section restriction
if target_names is not None:
scoped = {n: c for n, c in criteria.items() if n in target_names}
else:
scoped = criteria
incomplete = {n: c for n, c in scoped.items() if c["status"] != "Met"}
doable = {n: c for n, c in incomplete.items() if c["status"] in DOABLE_STATUSES}
na = {n: c for n, c in incomplete.items() if c["status"] == "N/A"}
print(f"Project {project_id}: badge_level={data.get('badge_level')} "
f"(passing%={data.get('badge_percentage_0')} "
f"silver%={data.get('badge_percentage_1')} "
f"gold%={data.get('badge_percentage_2')})")
scope_note = (f"section '{section}'" if target_names is not None
else "ALL sections (fallback)")
print(f"Schema: metal | Scope: {scope_note} | "
f"criteria in scope: {len(scoped)} | "
f"Incomplete: {len(incomplete)} "
f"(Unmet/? : {len(doable)} | N/A: {len(na)})\n")
print("Incomplete (Unmet/? — candidate to mark Met):")
for n, c in sorted(doable.items()):
print(f" - {n}: {c['status']} | current note: {c['justification'][:70]}")
print("\nN/A (review separately — usually leave as N/A):")
for n, c in sorted(na.items()):
print(f" - {n}: N/A | note: {c['justification'][:70]}")
params = []
for n in sorted(doable):
if n in exclude:
continue # leave at current Unmet/?/N/A in the form
if n in na_set:
continue # emitted as N/A below instead of Met
params.append((f"{n}_status", "Met"))
params.append((f"{n}_justification",
justifications.get(n, "TODO: justify")))
for n in sorted(na_set):
if n in exclude:
continue
if n not in scoped:
continue
params.append((f"{n}_status", "N/A"))
params.append((f"{n}_justification",
justifications.get(n, "TODO: justify N/A")))
params.append(("overrides", "*,osps_*"))
edit_url = (f"{BASE}/projects/{project_id}/{section}/edit?"
f"{urllib.parse.urlencode(params)}")
print(f"\n=== Edit URL ({section}"
+ (f", excluding {sorted(exclude)}" if exclude else "")
+ (f", N/A {sorted(na_set)}" if na_set else "")
+ f") ===\n{edit_url}")
print(f"\nURL param count for _status/_justification pairs: "
f"{sum(1 for k, _ in params if k.endswith('_status'))}")
print("WARNING: pre-fills Unmet/? criteria as 'Met' (and --na as 'N/A') using "
"justifications from cii_justifications.json.")
print("Review EVERY field before Save; only mark Met what you genuinely satisfy.")
return edit_url
# --------------------------------------------------------------------------- #
# OSPS Baseline schema #
# --------------------------------------------------------------------------- #
def _collect_osps_controls(node, out):
"""Recursively collect every control definition under a node.
A control is any dict containing an `original_id` key. We map that id to its
{category, na_allowed, na_justification_required} so callers can scope by
tier and validate N/A eligibility.
"""
if isinstance(node, dict):
if "original_id" in node:
out[node["original_id"]] = {
"category": node.get("category"),
"na_allowed": bool(node.get("na_allowed")),
"na_justification_required": bool(
node.get("na_justification_required")),
}
else:
for v in node.values():
_collect_osps_controls(v, out)
elif isinstance(node, list):
for v in node:
_collect_osps_controls(v, out)
def fetch_osps_tier_map():
"""Return {tier: {original_id: control_meta}} from baseline_criteria.yml.
Returns {} if the file could not be fetched/parsed (caller falls back to
all-criteria behavior).
"""
try:
raw = fetch(OSPS_CRITERIA_URL)
except Exception as e:
print(f"WARNING: could not fetch OSPS criteria definitions ({e}); "
f"falling back to all-criteria behavior.", file=sys.stderr)
return {}
try:
doc = __import__("yaml").safe_load(raw)
except Exception as e:
print(f"WARNING: could not parse OSPS criteria definitions ({e}); "
f"falling back to all-criteria behavior.", file=sys.stderr)
return {}
if not isinstance(doc, dict):
return {}
tier_map = {}
for tier in OSPS_TIERS:
controls = {}
if tier in doc:
_collect_osps_controls(doc[tier], controls)
tier_map[tier] = controls
return tier_map
def _osps_field_name(original_id):
"""Translate a JSON criterion key into the edit-form field name.
The project JSON API uses dotted names WITH the `OSPS-` prefix
(e.g. `OSPS-AC-03.01`), but the bestpractices.dev edit form expects
lowercase underscore names retaining the `osps` prefix
(e.g. `osps_ac_03_01`). The controller silently ignores unrecognized
params, so we must translate dashes and dots to underscores.
"""
return original_id.lower().replace("-", "_").replace(".", "_")
def run_osps(project_id, section, exclude, na_set, justifications, args):
data, criteria = load_project_criteria(project_id)
tier_map = fetch_osps_tier_map()
controls = tier_map.get(section)
if controls is None:
print(f"WARNING: section '{section}' is not an OSPS Baseline tier; "
f"falling back to all-criteria behavior.", file=sys.stderr)
target_ids = None
elif not controls:
print(f"WARNING: no OSPS criteria found for tier '{section}'; "
f"falling back to all-criteria behavior.", file=sys.stderr)
target_ids = None
else:
target_ids = set(controls.keys())
if target_ids is not None:
scoped = {n: c for n, c in criteria.items() if n in target_ids}
else:
scoped = criteria
incomplete = {n: c for n, c in scoped.items() if c["status"] != "Met"}
doable = {n: c for n, c in incomplete.items() if c["status"] in DOABLE_STATUSES}
na = {n: c for n, c in incomplete.items() if c["status"] == "N/A"}
print(f"Project {project_id}: badge_level={data.get('badge_level')}")
scope_note = (f"tier '{section}'" if target_ids is not None
else "ALL criteria (fallback)")
print(f"Schema: osps | Scope: {scope_note} | "
f"criteria in scope: {len(scoped)} | "
f"Incomplete: {len(incomplete)} "
f"(Unmet/? : {len(doable)} | N/A: {len(na)})\n")
print("Incomplete (Unmet/? — only those with a justification are pre-filled "
"as Met):")
for n, c in sorted(doable.items()):
have = "J" if n in justifications else " "
print(f" [{have}] {n}: {c['status']} | current note: "
f"{c['justification'][:70]}")
print("\nN/A (review separately — usually leave as N/A):")
for n, c in sorted(na.items()):
print(f" - {n}: N/A | note: {c['justification'][:70]}")
# Pre-fill Met ONLY for doable criteria that have a truthful justification
# recorded locally. Criteria without one are left at their current state so
# the URL never over-claims.
params = []
prefilled_met = []
for n in sorted(doable):
if n in exclude:
continue
if n in na_set:
continue # handled below as N/A
justification = justifications.get(n)
if not justification:
continue
params.append((f"{_osps_field_name(n)}_status", "Met"))
params.append((f"{_osps_field_name(n)}_justification", justification))
prefilled_met.append(n)
# Pre-fill N/A for --na criteria that are in scope and allow N/A.
for n in sorted(na_set):
if n in exclude:
continue
if n not in scoped:
continue
ctrl = controls.get(n) if controls else None
if ctrl is not None and not ctrl["na_allowed"]:
print(f"WARNING: {n} does not allow N/A (na_allowed=false); "
f"skipping --na for it.", file=sys.stderr)
continue
justification = justifications.get(
n, criteria.get(n, {}).get("justification") or "TODO: justify N/A")
params.append((f"{_osps_field_name(n)}_status", "N/A"))
params.append((f"{_osps_field_name(n)}_justification", justification))
params.append(("overrides", "*,osps_*"))
edit_url = (f"{BASE}/projects/{project_id}/{section}/edit?"
f"{urllib.parse.urlencode(params)}")
print(f"\n=== Edit URL ({section}"
+ (f", excluding {sorted(exclude)}" if exclude else "")
+ (f", N/A {sorted(na_set)}" if na_set else "")
+ f") ===\n{edit_url}")
print(f"\nPre-filled Met ({len(prefilled_met)}): {sorted(prefilled_met)}")
print(f"URL param count for _status/_justification pairs: "
f"{sum(1 for k, _ in params if k.endswith('_status'))}")
print("WARNING: pre-fills only criteria with a recorded justification as "
"'Met' (and --na as 'N/A').")
print("Review EVERY field before Save; only mark Met what you genuinely satisfy.")
return edit_url
# --------------------------------------------------------------------------- #
# Entry point #
# --------------------------------------------------------------------------- #
def main():
parser = argparse.ArgumentParser(
description="List incomplete OpenSSF Best Practices (CII) criteria and "
"generate a pre-filled edit URL. Defaults to the OSPS "
"Baseline schema.")
parser.add_argument("project_id", nargs="?", default="14227")
parser.add_argument("section", nargs="?", default=None,
choices=list(METAL_SECTION_TO_TIER.keys()))
parser.add_argument("--schema", default="osps", choices=["osps", "metal"],
help="Criteria schema: 'osps' (DEFAULT, OSPS Baseline) "
"or 'metal' (legacy).")
parser.add_argument("--exclude", default="",
help="Comma-separated criterion ids to skip in the URL.")
parser.add_argument("--na", default="",
help="Comma-separated criterion ids to mark as N/A "
"with the justification from cii_justifications.json.")
parser.add_argument("--no-open", action="store_true",
help="Never open the generated URL in a browser "
"(useful for CI/piping).")
parser.add_argument("--open", action="store_true",
help="Force-open the URL even when stdout is not a TTY.")
args = parser.parse_args()
project_id = args.project_id
# Resolve the section: explicit arg wins; otherwise use the schema default.
section = args.section or SCHEMA_DEFAULT_SECTION[args.schema]
exclude = {x.strip() for x in args.exclude.split(",") if x.strip()}
na_set = {x.strip() for x in args.na.split(",") if x.strip()}
justifications = load_justifications()
if args.schema == "metal":
edit_url = run_metal(project_id, section, exclude, na_set,
justifications, args)
else:
edit_url = run_osps(project_id, section, exclude, na_set,
justifications, args)
# Auto-open the URL in a browser. Default: only when run interactively
# (stdout is a TTY) and --no-open was not given. --open forces it even when
# piped; --no-open always disables it.
if args.open or (sys.stdout.isatty() and not args.no_open):
open_url(edit_url)
if __name__ == "__main__":
main()
-49
View File
@@ -1,49 +0,0 @@
{
"build": "The project builds with Cargo; CI (.github/workflows/rust.yml) runs `cargo build --verbose` on every push and pull request.",
"build_common_tools": "Builds use Cargo, the standard and widely-adopted Rust build toolchain.",
"build_floss_tools": "All build and CI tooling is FLOSS: Cargo/rustc, GitHub Actions, clippy, and cargo-deny.",
"contribution_requirements": "CONTRIBUTING.md documents forking, PRs against `master`, the PR template, dev setup, the testing requirement, and Conventional Commits. Contribution requirements are documented in CONTRIBUTING.md: https://github.com/masdevid/mt5-quant/blob/master/CONTRIBUTING.md",
"crypto_call": "Outbound internet calls (GitHub API for version checks/downloads) use HTTPS via the system `curl`, which negotiates TLS — no plaintext internet communication.",
"crypto_floss": "TLS is provided by the system `curl`/OS libraries (FLOSS); the project bundles no proprietary cryptography.",
"crypto_keylength": "Relies on `curl`/OS TLS defaults (modern ciphers with >=128-bit security and standard RSA/ECDSA key sizes).",
"crypto_pfs": "Modern TLS via `curl` negotiates forward secrecy (ECDHE) with current GitHub endpoints.",
"crypto_published": "Uses the widely-published TLS protocol through the standard `curl` toolchain rather than any custom or unpublished algorithm.",
"crypto_random": "The project generates no security-sensitive random values (only UUIDs for internal job IDs), so no cryptographic RNG is required for its functionality.",
"crypto_working": "TLS HTTPS calls to GitHub succeed in practice; the version-check and self-update features work as designed.",
"enhancement_responses": "Enhancement requests are tracked on the public GitHub issue tracker (with a feature-request template) and reviewed through PRs.",
"no_leaked_credentials": "Sensitive local config (config/mt5-quant.yaml, which can hold passwords/tokens) is gitignored, and no secrets are committed to the repository.",
"repo_interim": "The repository at github.com/masdevid/mt5-quant is the canonical project home, not a temporary or interim placeholder.",
"report_archive": "GitHub automatically archives the source; tagged releases additionally publish downloadable source and binary archives. Releases are published and archived on GitHub: https://github.com/masdevid/mt5-quant/releases",
"report_responses": "The project uses GitHub Issues (with bug/feature templates) as its public tracker, and maintainers triage and respond via issues and PR reviews.",
"static_analysis": "CI runs Rust Clippy and GitHub CodeQL (Rust) as static analysis on every push and pull request.",
"static_analysis_fixed": "Clippy runs with `-D warnings` so defects fail the build, and CodeQL findings are reviewed and remediated.",
"static_analysis_often": "Static analysis runs automatically on every push to and pull request against `master` (clippy in rust.yml, CodeQL in codeql.yml).",
"test": "Unit and integration tests exist in `tests/` (Rust integration_tests.rs, test_mcp.py, shell tests) and `cargo test` runs in CI.",
"test_most": "Unit tests cover core modules (analytics extraction/analysis, storage, report parsing); `cargo test` is part of CI.",
"test_policy": "AGENTS.md and CONTRIBUTING.md require that non-trivial changes include tests, and CI enforces `cargo test`.",
"tests_are_added": "The `tests/` directory contains Rust, Python, and shell tests, and new non-trivial functionality is expected to add corresponding tests.",
"tests_documented_added": "Testing expectations and the `cargo test` requirement are documented in AGENTS.md and CONTRIBUTING.md.",
"version_semver": "The `version` field in Cargo.toml uses Semantic Versioning (currently 1.34.3).",
"version_tags": "Releases are tagged with `vX.Y.Z` semver tags (e.g., v1.34.1), confirmed via `git tag`.",
"version_unique": "Each release carries a unique `version` in Cargo.toml and a corresponding immutable `vX.Y.Z` git tag.",
"vulnerabilities_critical_fixed": "No known unpatched critical vulnerabilities; `cargo-deny` advisories and dependency-review run in CI and dependency updates are monitored.",
"vulnerabilities_fixed_60_days": "Dependency vulnerabilities are surfaced by `cargo-deny` (advisories) and the dependency-review action in CI, and are updated promptly.",
"warnings": "CI enforces a warning-free build via `cargo clippy --all-targets -- -D warnings` plus `cargo fmt --check`.",
"warnings_fixed": "Because Clippy uses `-D warnings`, any warning fails CI and must be fixed before a change can merge.",
"dynamic_analysis": "Dynamic analysis is performed via cargo-fuzz (libFuzzer) fuzz targets in fuzz/ that exercise the report/metrics parsers with arbitrary input.",
"dynamic_analysis_enable_assertions": "The cargo-fuzz build enables debug/overflow assertions: fuzz/Cargo.toml sets `[profile.release] debug-assertions = true` (and `[profile.dev] debug-assertions = true`), so debug_assert! checks and integer-overflow traps remain active while libFuzzer feeds arbitrary input; crashes that violate internal invariants are caught as bugs.",
"dynamic_analysis_fixed": "Fuzzing findings are triaged and fixed; crashes found by the harness are treated as bugs.",
"delivery_unsigned": "Release artifacts are GPG-signed in CI (.github/workflows/release.yml) with a dedicated RSA-4096 project key; each GitHub Release attaches the macOS and Linux binaries, their detached ASCII-armored signatures (*.tar.gz.asc), and the public key at docs/signing-key.asc. Verification steps are documented in SECURITY.md.",
"vulnerability_report_process": "SECURITY.md defines the vulnerability reporting process: reports accepted via GitHub Security Advisories (preferred) or public GitHub Issues, what to include, expected acknowledgement/triage timelines, and a coordinated-disclosure policy. Vulnerability reporting process is documented in SECURITY.md: https://github.com/masdevid/mt5-quant/blob/master/SECURITY.md",
"know_secure_design": "SECURITY.md documents secure-development practices: input validation at all 89 tool boundaries, no hardcoded secrets, safe bounded parsing of untrusted MT5 HTML/XML reports, single-MT5-instance least-privilege, cargo-deny/cargo-audit dependency auditing in CI, parser fuzzing, and Wine/MT5 isolation.",
"know_common_errors": "SECURITY.md lists common implementation errors avoided: integer-overflow (checked arithmetic + fuzz overflow traps), path traversal in .set file handling, UTF-16LE encoding pitfalls, avoiding unsafe, and proper error propagation via Result/?.",
"crypto_password_storage": "No passwords are stored by the application; the only secrets are a user-provided MT5/Wine path config (gitignored) and an optional release PAT — neither is a password persisted by the project.",
"vulnerability_report_private": "No private vulnerability reporting channel is offered; reports are accepted via public GitHub Security Advisories/issues.",
"vulnerability_report_response": "No formal fixed-response SLA; handled case-by-case via the public reporting channel.",
"release_notes_vulns": "Release notes do not currently track security vulnerabilities separately.",
"OSPS-AC-03.01": "The `master` primary branch is protected on GitHub: direct pushes are blocked and changes require a pull request with at least one approving review before merge. This enforcement mechanism prevents direct commits to the primary branch, satisfying OSPS-AC-03.01.",
"OSPS-AC-03.02": "The `master` primary branch is protected against deletion on GitHub: branch protection disables branch deletion, and a separate deletion-protecting ruleset requires explicit confirmation of intent before any destructive action. This satisfies OSPS-AC-03.02."
}