ci(coverage): enforce the critical core contract

This commit is contained in:
2026-07-26 07:50:44 +00:00
parent f9ad6c85aa
commit fafb828bb7
6 changed files with 226 additions and 29 deletions
+15 -6
View File
@@ -33,19 +33,28 @@ jobs:
- name: Run coverage and extract percentage - name: Run coverage and extract percentage
id: coverage id: coverage
run: | run: |
python3 -m coverage run -m unittest discover -t . -s tests/unit > /dev/null 2>&1 || true set -euo pipefail
PERCENT=$(python3 -m coverage report 2>/dev/null | grep '^TOTAL' | grep -oP '\d+(?=%)' | tail -1) # Never publish a badge from a failed or partial test run.
python3 -m coverage run -m unittest discover -t . -s tests/unit
REPORT=$(python3 -m coverage report)
printf '%s\n' "$REPORT"
PERCENT=$(printf '%s\n' "$REPORT" | awk '$1 == "TOTAL" {gsub("%", "", $NF); print $NF}')
test -n "$PERCENT"
echo "percent=$PERCENT" >> $GITHUB_OUTPUT echo "percent=$PERCENT" >> $GITHUB_OUTPUT
echo "Coverage: $PERCENT%" echo "Coverage: $PERCENT%"
- name: Extract core (critical-module) coverage percentage - name: Extract core (critical-module) coverage percentage
id: core_coverage id: core_coverage
run: | run: |
set -euo pipefail
# Reuses the .coverage data from the previous step. The core list is # Reuses the .coverage data from the previous step. The core list is
# the single source of truth in scripts/critical-modules.txt; every # validated single source of truth. Fail if a listed path disappeared
# core module is unit-tested, so the unit-only run is accurate for it. # or if the measured core falls below ADR 0004's 90% minimum.
INCLUDE=$(grep -vE '^[[:space:]]*(#|$)' scripts/critical-modules.txt | paste -sd, -) INCLUDE=$(python3 scripts/critical_modules.py)
PERCENT=$(python3 -m coverage report --include="$INCLUDE" 2>/dev/null | grep '^TOTAL' | grep -oP '\d+(?=%)' | tail -1) REPORT=$(python3 -m coverage report --include="$INCLUDE" --fail-under=90)
printf '%s\n' "$REPORT"
PERCENT=$(printf '%s\n' "$REPORT" | awk '$1 == "TOTAL" {gsub("%", "", $NF); print $NF}')
test -n "$PERCENT"
echo "percent=$PERCENT" >> $GITHUB_OUTPUT echo "percent=$PERCENT" >> $GITHUB_OUTPUT
echo "Core coverage: $PERCENT%" echo "Core coverage: $PERCENT%"
+10 -6
View File
@@ -34,12 +34,13 @@ a regression (Goodhart's law).
Coverage is **risk-weighted**, measured over the **combined unit + Coverage is **risk-weighted**, measured over the **combined unit +
integration** suites, with three rules: integration** suites, with three rules:
1. **Critical modules target ≥ 90%.** The security/logic core 1. **Critical modules must remain ≥ 90%.** The curated security/logic core
`egress_addon{,_core}.py`, `dlp_detectors.py`, `egress.py`, covers the host and gateway egress policy, manifest trust boundary,
`manifest*.py`, `git_gate.py`, `git_http_backend.py`, `supervise.py`, git-gate enforcement, supervise protocol/server, YAML parser, and bottle
`yaml_subset.py`, `bottle_state.py` — is Docker-independent and state. The concrete module list lives in `scripts/critical-modules.txt`;
unit-testable, so it carries the high bar. We ratchet toward 90% as `scripts/critical_modules.py` rejects stale or ambiguous entries before
these modules are touched; new gaps in them are not acceptable. Coverage.py can silently ignore them. These modules are unit-testable, so
CI enforces the aggregate minimum independently of diff coverage.
2. **Subprocess/backend orchestration is covered by the integration 2. **Subprocess/backend orchestration is covered by the integration
suite, not omitted.** `scripts/coverage.sh` runs unit + integration suite, not omitted.** `scripts/coverage.sh` runs unit + integration
@@ -82,6 +83,9 @@ omit list.
(critical-module standard + diff coverage) are Docker-independent. (critical-module standard + diff coverage) are Docker-independent.
- "We're at N%" is now a curated figure; outsiders should read the - "We're at N%" is now a curated figure; outsiders should read the
policy, not just the badge. policy, not just the badge.
- A rename or removal in the curated list fails CI. Updating the list is an
explicit review of where the security-critical behavior moved, not a way to
improve the percentage by omission.
## Links ## Links
+8 -8
View File
@@ -20,10 +20,10 @@ cd "$(dirname "$0")/.."
PY="${PYTHON:-python3}" PY="${PYTHON:-python3}"
# Critical security/logic core held to the high bar by ADR 0004. The list # Critical security/logic core held to the high bar by ADR 0004. The helper
# lives in one place (scripts/critical-modules.txt) so this report and the # fails before coverage when a curated path was renamed or removed; Coverage.py
# README "core coverage" badge can't drift; comma-join it for --include. # itself would silently ignore that stale include and inflate the score.
CRITICAL=$(grep -vE '^[[:space:]]*(#|$)' scripts/critical-modules.txt | paste -sd, -) CRITICAL=$("$PY" scripts/critical_modules.py)
if [ "${1:-}" = "aggregate" ]; then if [ "${1:-}" = "aggregate" ]; then
# Aggregate mode: combine .coverage.* artifacts already in the workspace. # Aggregate mode: combine .coverage.* artifacts already in the workspace.
@@ -34,8 +34,8 @@ if [ "${1:-}" = "aggregate" ]; then
"$PY" -m coverage report -m "$PY" -m coverage report -m
if [ "${2:-}" = "critical" ]; then if [ "${2:-}" = "critical" ]; then
echo "== critical modules (ADR 0004 target: 90%) ==" >&2 echo "== critical modules (ADR 0004 minimum: 90%) ==" >&2
"$PY" -m coverage report --include="$CRITICAL" "$PY" -m coverage report --include="$CRITICAL" --fail-under=90
fi fi
exit 0 exit 0
fi fi
@@ -55,6 +55,6 @@ echo "== combined report ==" >&2
"$PY" -m coverage report -m "$PY" -m coverage report -m
if [ "${1:-}" = "critical" ]; then if [ "${1:-}" = "critical" ]; then
echo "== critical modules (ADR 0004 target: 90%) ==" >&2 echo "== critical modules (ADR 0004 minimum: 90%) ==" >&2
"$PY" -m coverage report --include="$CRITICAL" "$PY" -m coverage report --include="$CRITICAL" --fail-under=90
fi fi
+38 -9
View File
@@ -7,19 +7,48 @@
# number that silently stops measuring a module is worse than no badge. # number that silently stops measuring a module is worse than no badge.
# #
# One module path per line, relative to the repo root. Blank lines and # One module path per line, relative to the repo root. Blank lines and
# `#` comments are ignored. # `#` comments are ignored. scripts/critical_modules.py rejects missing,
# duplicate, non-Python, and out-of-repository entries before coverage runs.
# Host-side egress planning and secret preparation.
bot_bottle/egress/plan.py
bot_bottle/egress/service.py
# Gateway egress policy, matching, and DLP enforcement.
bot_bottle/gateway/egress/addon.py bot_bottle/gateway/egress/addon.py
bot_bottle/gateway/egress/addon_core.py bot_bottle/gateway/egress/addon_core.py
bot_bottle/gateway/egress/context.py
bot_bottle/gateway/egress/dlp.py
bot_bottle/gateway/egress/dlp_config.py
bot_bottle/gateway/egress/dlp_detectors.py bot_bottle/gateway/egress/dlp_detectors.py
bot_bottle/egress.py bot_bottle/gateway/egress/matching.py
bot_bottle/manifest.py bot_bottle/gateway/egress/schema.py
bot_bottle/manifest_egress.py bot_bottle/gateway/egress/types.py
bot_bottle/manifest_agent.py
bot_bottle/manifest_schema.py # Manifest trust boundary and schema.
bot_bottle/git_gate.py bot_bottle/manifest/agent.py
bot_bottle/manifest/bottle.py
bot_bottle/manifest/egress.py
bot_bottle/manifest/extends.py
bot_bottle/manifest/git.py
bot_bottle/manifest/index.py
bot_bottle/manifest/loader.py
bot_bottle/manifest/schema.py
bot_bottle/manifest/util.py
# Host-side and gateway-side git policy enforcement.
bot_bottle/git_gate/host_key.py
bot_bottle/git_gate/plan.py
bot_bottle/git_gate/provision.py
bot_bottle/git_gate/service.py
bot_bottle/gateway/git_gate/render.py bot_bottle/gateway/git_gate/render.py
bot_bottle/git_gate_provision.py
bot_bottle/gateway/git_gate/http_backend.py bot_bottle/gateway/git_gate/http_backend.py
bot_bottle/supervise.py
# Supervise proposal protocol and data plane.
bot_bottle/supervisor/plan.py
bot_bottle/supervisor/types.py
bot_bottle/gateway/supervisor/server.py
# Shared parsers and state validation.
bot_bottle/yaml_subset.py bot_bottle/yaml_subset.py
bot_bottle/bottle_state.py bot_bottle/bottle_state.py
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Validate and render the critical-module coverage manifest.
Coverage.py silently ignores an ``--include`` path that does not exist. That
is useful for broad globs, but dangerous for bot-bottle's curated security
core: a rename could otherwise improve the reported percentage by removing a
module from the measurement. Keep the validation in one small stdlib helper
and make every coverage consumer call it.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_MANIFEST = REPO_ROOT / "scripts" / "critical-modules.txt"
class CriticalModulesError(ValueError):
"""The critical-module manifest is empty, ambiguous, or stale."""
def load_critical_modules(manifest: Path, *, root: Path) -> list[str]:
"""Return validated module paths relative to *root*.
Entries must be unique, concrete Python files inside the repository.
Globs are deliberately rejected by the file check: each rename must update
this explicit security review surface.
"""
root = root.resolve()
try:
lines = manifest.read_text(encoding="utf-8").splitlines()
except OSError as exc:
raise CriticalModulesError(
f"cannot read critical-module manifest {manifest}: {exc}"
) from exc
modules: list[str] = []
seen: set[str] = set()
errors: list[str] = []
for line_number, raw in enumerate(lines, start=1):
entry = raw.strip()
if not entry or entry.startswith("#"):
continue
path = Path(entry)
prefix = f"{manifest}:{line_number}: {entry!r}"
if path.is_absolute():
errors.append(f"{prefix} must be relative to the repository root")
continue
try:
resolved = (root / path).resolve()
resolved.relative_to(root)
except ValueError:
errors.append(f"{prefix} escapes the repository root")
continue
if entry in seen:
errors.append(f"{prefix} is duplicated")
continue
seen.add(entry)
if path.suffix != ".py":
errors.append(f"{prefix} is not a Python module")
continue
if not resolved.is_file():
errors.append(f"{prefix} does not exist")
continue
modules.append(path.as_posix())
if not modules and not errors:
errors.append(f"{manifest}: contains no critical modules")
if errors:
raise CriticalModulesError("\n".join(errors))
return modules
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="validate and print the critical coverage include list"
)
parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
parser.add_argument("--root", type=Path, default=REPO_ROOT)
parser.add_argument(
"--check", action="store_true",
help="validate only; do not print the comma-separated include list",
)
args = parser.parse_args(argv)
try:
modules = load_critical_modules(args.manifest, root=args.root)
except CriticalModulesError as exc:
print(f"critical-modules: {exc}", file=sys.stderr)
return 1
if not args.check:
print(",".join(modules))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+54
View File
@@ -0,0 +1,54 @@
"""Tests for the fail-closed critical coverage manifest."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from scripts.critical_modules import (
DEFAULT_MANIFEST,
REPO_ROOT,
CriticalModulesError,
load_critical_modules,
)
class TestCriticalModules(unittest.TestCase):
def test_repository_manifest_is_valid(self) -> None:
modules = load_critical_modules(DEFAULT_MANIFEST, root=REPO_ROOT)
self.assertGreater(len(modules), 20)
self.assertEqual(len(modules), len(set(modules)))
def test_missing_module_fails(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
manifest = root / "critical-modules.txt"
manifest.write_text("bot_bottle/renamed.py\n", encoding="utf-8")
with self.assertRaisesRegex(CriticalModulesError, "does not exist"):
load_critical_modules(manifest, root=root)
def test_duplicate_module_fails(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
module = root / "bot_bottle" / "core.py"
module.parent.mkdir()
module.write_text("", encoding="utf-8")
manifest = root / "critical-modules.txt"
manifest.write_text(
"bot_bottle/core.py\nbot_bottle/core.py\n", encoding="utf-8"
)
with self.assertRaisesRegex(CriticalModulesError, "duplicated"):
load_critical_modules(manifest, root=root)
def test_entry_cannot_escape_repository(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
manifest = root / "critical-modules.txt"
manifest.write_text("../outside.py\n", encoding="utf-8")
with self.assertRaisesRegex(CriticalModulesError, "escapes"):
load_critical_modules(manifest, root=root)
if __name__ == "__main__":
unittest.main()