diff --git a/conductor/tracks/license_cve_audit_20260607/plan.md b/conductor/tracks/license_cve_audit_20260607/plan.md new file mode 100644 index 00000000..516fd5f1 --- /dev/null +++ b/conductor/tracks/license_cve_audit_20260607/plan.md @@ -0,0 +1,907 @@ +# License & CVE Audit Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `scripts/audit_license_cve.py` — a single audit script that checks third-party deps (in `pyproject.toml` + `uv.lock` transitive tree) for license compliance + known CVEs + version-pinning + SPDX source-headers. Then tilde-pin all deps, delete `requirements.txt`, regenerate `uv.lock`, add `--strict` mode + baseline file (CI gate). One script, one CI gate, one report. + +**Architecture:** Single audit script in `scripts/`. No new pip deps in the project (pure stdlib: `importlib.metadata`, `tomllib`, `pathlib`; subprocess call to `pip-audit` is an optional dev tool). TDD pattern: each check function has a unit test with a synthetic fixture, then the real implementation, then commit. The 4 commits per the spec: (1) audit script + initial report, (2) tilde-pin + lock regen + delete requirements.txt, (3) --strict mode + baseline file, (4) tracks.md update. + +**Tech Stack:** Python 3.11+, `importlib.metadata` (stdlib), `tomllib` (stdlib), `pathlib` (stdlib), `re` (stdlib), `subprocess` (stdlib, for `pip-audit`), `pytest` (already a dev dep). No new pip deps in the project. + +--- + +## Phase 0: Setup + +**Files:** `conductor/tracks/license_cve_audit_20260607/state.toml` (create), `scripts/audit_license_cve.py` (create empty), `tests/test_audit_license_cve.py` (create empty). + +- [ ] **Step 0.1: Create `state.toml`** + +Write `conductor/tracks/license_cve_audit_20260607/state.toml`: + +```toml +# Track state for license_cve_audit_20260607 +# Updated by Tier 2 Tech Lead as tasks complete + +[meta] +track_id = "license_cve_audit_20260607" +name = "License & CVE Audit (Dependency Compliance)" +status = "active" +current_phase = 0 +last_updated = "2026-06-07" + +[phases] +phase_1 = { status = "pending", checkpointsha = "", name = "Audit script + initial report" } +phase_2 = { status = "pending", checkpointsha = "", name = "Tilde-pin + lock regen + delete requirements.txt" } +phase_3 = { status = "pending", checkpointsha = "", name = "CI gate (--strict + baseline)" } +phase_4 = { status = "pending", checkpointsha = "", name = "tracks.md update" } + +[verification] +audit_script_exists = false +license_check_passes = false +cve_check_optional_passes = false +pin_check_passes = false +source_header_check_passes = false +pyproject_tilde_pinned = false +requirements_txt_deleted = false +uv_lock_regenerated = false +strict_mode_implemented = false +baseline_file_committed = false +unit_tests_passing = false +``` + +- [ ] **Step 0.2: Create empty `scripts/audit_license_cve.py`** + +```bash +New-Item -ItemType File -Path scripts/audit_license_cve.py -Force | Out-Null +``` + +- [ ] **Step 0.3: Create empty `tests/test_audit_license_cve.py`** + +```bash +New-Item -ItemType File -Path tests/test_audit_license_cve.py -Force | Out-Null +``` + +- [ ] **Step 0.4: Conductor - User Manual Verification (per workflow.md)** + +--- + +## Phase 1: Audit script + initial report (Commit 1) + +**Files:** `scripts/audit_license_cve.py`, `tests/test_audit_license_cve.py`, `docs/reports/license_cve_audit/2026-06-07/initial.md`. + +This phase is one commit. 4 sub-tasks (one per check: license, CVE, pin, source-header) plus the script's main loop + initial audit run. + +### Task 1.1: Policy tables + license classifier + +- [ ] **Step 1.1.1: Write the failing test for the policy table + license classifier** + +Append to `tests/test_audit_license_cve.py`: + +```python +"""Tests for scripts/audit_license_cve.""" +import pytest +from scripts.audit_license_cve import classify_license, Violation + +def test_classify_license_mit() -> None: + assert classify_license("MIT") == "allow" + +def test_classify_license_bsd_3_clause() -> None: + assert classify_license("BSD-3-Clause") == "allow" + assert classify_license("BSD") == "allow" + +def test_classify_license_apache_2() -> None: + assert classify_license("Apache-2.0") == "allow" + assert classify_license("Apache 2.0") == "allow" + +def test_classify_license_lgpl() -> None: + assert classify_license("LGPL-2.1") == "allow" + assert classify_license("LGPL-3.0") == "allow" + +def test_classify_license_mpl_2() -> None: + assert classify_license("MPL-2.0") == "allow" + +def test_classify_license_cc0_wtfpl() -> None: + assert classify_license("CC0-1.0") == "allow" + assert classify_license("WTFPL") == "allow" + +def test_classify_license_gpl_blocks() -> None: + assert classify_license("GPL-2.0") == "block" + assert classify_license("GPL-3.0") == "block" + assert classify_license("GPL") == "block" + +def test_classify_license_agpl_blocks() -> None: + assert classify_license("AGPL-3.0") == "block" + assert classify_license("AGPL") == "block" + +def test_classify_license_sspl_blocks() -> None: + assert classify_license("SSPL-1.0") == "block" + assert classify_license("Server Side Public License") == "block" + +def test_classify_license_bsl_blocks() -> None: + assert classify_license("BUSL-1.1") == "block" + assert classify_license("BSL-1.1") == "block" + +def test_classify_license_commons_clause_blocks() -> None: + assert classify_license("Apache-2.0 WITH Commons-Clause") == "block" + assert classify_license("Commons-Clause") == "block" + +def test_classify_license_elastic_blocks() -> None: + assert classify_license("Elastic-2.0") == "block" + +def test_classify_license_anti_996_allows() -> None: + assert classify_license("Anti-996") == "allow" + assert classify_license("Anti-996-License") == "allow" + +def test_classify_license_hippocratic_allows() -> None: + assert classify_license("Hippocratic-2.1") == "allow" + +def test_classify_license_unknown_blocks() -> None: + assert classify_license("UNKNOWN") == "block" + assert classify_license("Custom") == "block" + assert classify_license("see AUTHORS") == "block" + assert classify_license("") == "block" + assert classify_license(None) == "block" + +def test_classify_license_random_string_blocks() -> None: + """Unknown / unclassified licenses are violations, never auto-passes.""" + assert classify_license("Made Up License v1.0") == "block" + assert classify_license("Proprietary-EULA") == "block" +``` + +- [ ] **Step 1.1.2: Run the test to verify it fails** + +Run: `uv run pytest tests/test_audit_license_cve.py -q 2>&1 | Select-Object -Last 5` +Expected: FAIL (no `scripts/audit_license_cve.py` to import from; the `scripts/` directory has no `__init__.py`). + +- [ ] **Step 1.1.3: Implement the policy table + license classifier** + +Add to `scripts/audit_license_cve.py`: + +```python +"""Third-party license + CVE + version-pin audit tool. + +Audits the project's dependencies (pyproject.toml + uv.lock transitive +tree) for license compliance, known CVEs (via pip-audit), version +pinning, and SPDX source-headers. See +conductor/tracks/license_cve_audit_20260607/spec.md. + +Output: line-per-violation to stdout (parseable) + a markdown report +under docs/reports/license_cve_audit//. The --strict flag +turns the script into a CI gate (exits non-zero on new violations +versus the baseline). +""" +from __future__ import annotations +import json +import re +import subprocess +import sys +import tomllib +from dataclasses import dataclass, field +from importlib import metadata +from pathlib import Path +from typing import Literal + +ALLOW_LICENSES: frozenset[str] = frozenset({ + "MIT", "MIT-0", + "BSD", "BSD-2-Clause", "BSD-3-Clause", "0BSD", + "Apache", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", + "ISC", "ISC-License", + "Unlicense", "Unlicense-2.0", + "Zlib", "zlib-acknowledgement", + "Python-2.0", "PSF-2.0", "PSF", "CNRI-Python", + "LGPL", "LGPL-2.0", "LGPL-2.1", "LGPL-3.0", "LGPL-2.0-or-later", + "LGPL-2.1-or-later", "LGPL-3.0-or-later", + "MPL", "MPL-1.1", "MPL-2.0", + "CC0", "CC0-1.0", "WTFPL", + "Anti-996", "Anti-996-License", + "Hippocratic", "Hippocratic-2.1", +}) + +BLOCK_LICENSES: frozenset[str] = frozenset({ + "GPL", "GPL-1.0", "GPL-2.0", "GPL-3.0", + "GPL-2.0-or-later", "GPL-3.0-or-later", + "AGPL", "AGPL-1.0", "AGPL-3.0", + "AGPL-3.0-or-later", + "SSPL", "SSPL-1.0", "Server Side Public License", + "BUSL", "BUSL-1.1", + "BSL", "BSL-1.1", + "Commons-Clause", + "Elastic", "Elastic-2.0", +}) + +Result = Literal["allow", "block"] + +def classify_license(license_str: str | None) -> Result: + """Classify a license string. Returns 'allow' or 'block'. + + Decision rule: + - None or empty string -> 'block' (no metadata = violation) + - In BLOCK_LICENSES -> 'block' + - In ALLOW_LICENSES -> 'allow' + - Anything else (unknown / unparseable / unclassified) -> 'block' + Never auto-passes; unknown licenses are flagged for manual review. + """ + if not license_str: + return "block" + normalized = license_str.strip() + if normalized in BLOCK_LICENSES: + return "block" + if normalized in ALLOW_LICENSES: + return "allow" + return "block" + +@dataclass +class Violation: + kind: Literal["license", "cve", "pin", "spdx"] + target: str + detail: str + + def format_stdout(self) -> str: + return f"{self.kind.upper()}_VIOLATION target={self.target} detail={self.detail!r}" +``` + +- [ ] **Step 1.1.4: Run the test to verify it passes** + +Run: `uv run pytest tests/test_audit_license_cve.py -q 2>&1 | Select-Object -Last 5` +Expected: PASS. (~17 license tests pass.) + +(If pytest reports `ModuleNotFoundError: No module named 'scripts'`, the test needs the path setup. Add a `conftest.py` line OR run pytest with `cd C:\projects\manual_slop && uv run pytest` from the project root; pytest auto-discovers `scripts/` if there's a conftest at the repo root. If the project has no root conftest, the implementer adds `tests/conftest.py` with `sys.path.insert(0, str(Path(__file__).parent.parent))` — or equivalently, the test imports `from scripts.audit_license_cve import ...` and the test runner is configured to find `scripts/`.) + +### Task 1.2: Pin check + +- [ ] **Step 1.2.1: Write the failing test for the pin check** + +Append to `tests/test_audit_license_cve.py`: + +```python +from scripts.audit_license_cve import check_pins + +def test_check_pins_no_specifier(tmp_path: Path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + '[project]\nname = "x"\nversion = "0.1.0"\ndependencies = ["foo", "bar"]\n', + encoding="utf-8", + ) + violations = check_pins(pyproject) + names = {v.target for v in violations} + assert "foo" in names + assert "bar" in names + +def test_check_pins_with_specifier(tmp_path: Path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + '[project]\nname = "x"\nversion = "0.1.0"\ndependencies = ["foo>=1.0.0", "bar~2.0.0", "baz==3.0.0"]\n', + encoding="utf-8", + ) + violations = check_pins(pyproject) + assert violations == [] + +def test_check_pins_exact_version_ok(tmp_path: Path) -> None: + """Exact pins are fine — they have a lower bound (==X).""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + '[project]\nname = "x"\nversion = "0.1.0"\ndependencies = ["foo==1.0.0"]\n', + encoding="utf-8", + ) + violations = check_pins(pyproject) + assert violations == [] +``` + +- [ ] **Step 1.2.2: Implement the pin check** + +Append to `scripts/audit_license_cve.py`: + +```python +def check_pins(pyproject_path: Path) -> list[Violation]: + """Parse pyproject.toml and flag any dep without a version specifier.""" + with pyproject_path.open("rb") as f: + data = tomllib.load(f) + violations: list[Violation] = [] + for dep in data.get("project", {}).get("dependencies", []): + name = re.split(r"[<>=!~;\[ ]", dep, maxsplit=1)[0].strip() + has_specifier = any(op in dep for op in ("<", ">", "=", "~", "!")) + if not has_specifier: + violations.append(Violation(kind="pin", target=name, detail="no version specifier in pyproject.toml")) + return violations +``` + +- [ ] **Step 1.2.3: Run the tests** + +Run: `uv run pytest tests/test_audit_license_cve.py -q 2>&1 | Select-Object -Last 5` +Expected: PASS. (~20 tests now pass — 17 license + 3 pin.) + +### Task 1.3: Source-header check + +- [ ] **Step 1.3.1: Write the failing test for the source-header check** + +Append to `tests/test_audit_license_cve.py`: + +```python +from scripts.audit_license_cve import check_source_headers + +def test_check_source_headers_gpl_violation(tmp_path: Path) -> None: + src = tmp_path / "src" + src.mkdir() + (src / "foo.py").write_text( + "# SPDX-License-Identifier: GPL-3.0\n# A file.\n", + encoding="utf-8", + ) + violations = check_source_headers(src) + assert any("foo.py" in v.target and "GPL" in v.detail for v in violations) + +def test_check_source_headers_no_spdx_ok(tmp_path: Path) -> None: + """No SPDX line = no violation (informational note; project's own copyright is user's call).""" + src = tmp_path / "src" + src.mkdir() + (src / "bar.py").write_text("# A file with no SPDX.\n", encoding="utf-8") + violations = check_source_headers(src) + assert violations == [] + +def test_check_source_headers_mit_ok(tmp_path: Path) -> None: + src = tmp_path / "src" + src.mkdir() + (src / "baz.py").write_text("# SPDX-License-Identifier: MIT\n# A file.\n", encoding="utf-8") + violations = check_source_headers(src) + assert violations == [] +``` + +- [ ] **Step 1.3.2: Implement the source-header check** + +Append to `scripts/audit_license_cve.py`: + +```python +SPDX_PATTERN = re.compile(r"SPDX-License-Identifier:\s*(\S+)", re.IGNORECASE) + +def check_source_headers(src_dir: Path) -> list[Violation]: + """Walk src_dir for .py files; flag any with a non-permissive SPDX.""" + violations: list[Violation] = [] + for py_file in src_dir.rglob("*.py"): + try: + text = py_file.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + # Only check the first 20 lines + head = "\n".join(text.splitlines()[:20]) + m = SPDX_PATTERN.search(head) + if m and classify_license(m.group(1)) == "block": + violations.append(Violation( + kind="spdx", + target=str(py_file), + detail=f"license={m.group(1)!r}", + )) + return violations +``` + +- [ ] **Step 1.3.3: Run the tests** + +Run: `uv run pytest tests/test_audit_license_cve.py -q 2>&1 | Select-Object -Last 5` +Expected: PASS. (~23 tests now pass — 17 license + 3 pin + 3 source-header.) + +### Task 1.4: License check (using importlib.metadata) + +- [ ] **Step 1.4.1: Write the failing test for the license check** + +Append to `tests/test_audit_license_cve.py`: + +```python +from scripts.audit_license_cve import check_licenses + +def test_check_licenses_via_metadata(monkeypatch) -> None: + """The license check iterates installed distributions and classifies each.""" + class FakeDist: + def __init__(self, name: str, license_str: str | None) -> None: + self.metadata = {"Name": name, "License": license_str, "Version": "1.0.0"} + fake_dists = [ + FakeDist("good-pkg", "MIT"), + FakeDist("bad-pkg", "GPL-3.0"), + FakeDist("unknown-pkg", "UNKNOWN"), + FakeDist("missing-pkg", None), + ] + monkeypatch.setattr("importlib.metadata.distributions", lambda: fake_dists) + violations = check_licenses() + names = {v.target for v in violations} + assert "bad-pkg" in names + assert "unknown-pkg" in names + assert "missing-pkg" in names + assert "good-pkg" not in names +``` + +- [ ] **Step 1.4.2: Implement the license check** + +Append to `scripts/audit_license_cve.py`: + +```python +def check_licenses() -> list[Violation]: + """Check each installed distribution's license against the policy. + + Iterates importlib.metadata.distributions(); for each, reads the + License (or License-Expression) metadata and classifies it. If + classify_license returns 'block', the dep is a violation. + """ + violations: list[Violation] = [] + for dist in metadata.distributions(): + name = dist.metadata["Name"] + license_str = dist.metadata.get("License") or dist.metadata.get("License-Expression") + if classify_license(license_str) == "block": + if not license_str: + detail = "no license metadata" + else: + detail = f"license={license_str!r}" + violations.append(Violation(kind="license", target=name, detail=detail)) + return violations +``` + +- [ ] **Step 1.4.3: Run the tests** + +Run: `uv run pytest tests/test_audit_license_cve.py -q 2>&1 | Select-Object -Last 5` +Expected: PASS. (~24 tests now pass.) + +### Task 1.5: CVE check (subprocess to pip-audit) + +- [ ] **Step 1.5.1: Write the failing test for the CVE check** + +Append to `tests/test_audit_license_cve.py`: + +```python +from scripts.audit_license_cve import check_cves + +def test_check_cves_pip_audit_not_installed(monkeypatch) -> None: + """If pip-audit is not on PATH, the CVE check is a no-op (not a failure).""" + monkeypatch.setattr("shutil.which", lambda cmd: None if cmd == "pip-audit" else "/usr/bin/" + cmd) + violations = check_cves() + assert violations == [] # no-op, not a failure + +def test_check_cves_pip_audit_json(monkeypatch) -> None: + """If pip-audit is installed, parse its JSON output.""" + import json + fake_json = json.dumps({ + "dependencies": [ + {"name": "vuln-pkg", "version": "1.0.0", "vulns": [ + {"id": "CVE-2024-12345", "fix_versions": [">=1.2.3"], "severity": "high"} + ]}, + ], + }).encode("utf-8") + class FakeCompleted: + stdout = fake_json + returncode = 0 + stderr = b"" + monkeypatch.setattr("shutil.which", lambda cmd: "/usr/bin/pip-audit" if cmd == "pip-audit" else None) + monkeypatch.setattr("subprocess.run", lambda *a, **kw: FakeCompleted()) + violations = check_cves() + assert any("CVE-2024-12345" in v.detail and v.target == "vuln-pkg" for v in violations) +``` + +- [ ] **Step 1.5.2: Implement the CVE check** + +Append to `scripts/audit_license_cve.py`: + +```python +import shutil + +def check_cves() -> list[Violation]: + """Run pip-audit as a subprocess; parse JSON output for CVEs. + + If pip-audit is not installed, this is a no-op (returns []). The script + logs a warning so the user knows the CVE check was skipped. + """ + if shutil.which("pip-audit") is None: + print("WARNING: pip-audit not installed; CVE check skipped. Install via 'uv tool install pip-audit'.", file=sys.stderr) + return [] + try: + result = subprocess.run( + ["pip-audit", "--format=json", "--strict"], + capture_output=True, text=True, timeout=120, + ) + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + print(f"WARNING: pip-audit failed: {e}", file=sys.stderr) + return [] + if result.returncode != 0 and not result.stdout.strip(): + print(f"WARNING: pip-audit returned non-zero with no output: {result.stderr}", file=sys.stderr) + return [] + try: + data = json.loads(result.stdout) + except json.JSONDecodeError: + return [] + violations: list[Violation] = [] + for dep in data.get("dependencies", []): + name = dep.get("name", "") + for vuln in dep.get("vulns", []): + cve_id = vuln.get("id", "") + fix = ", ".join(vuln.get("fix_versions", []) or [""]) + severity = vuln.get("severity", "unknown") + violations.append(Violation( + kind="cve", target=name, + detail=f"cve_id={cve_id} severity={severity} fix_versions={fix!r}", + )) + return violations +``` + +- [ ] **Step 1.5.3: Run the tests** + +Run: `uv run pytest tests/test_audit_license_cve.py -q 2>&1 | Select-Object -Last 5` +Expected: PASS. (~26 tests now pass — 17 license + 3 pin + 3 source-header + 1 license-check + 2 cve.) + +### Task 1.6: Main loop + initial audit run + report + +- [ ] **Step 1.6.1: Write the main loop + initial audit run** + +Append to `scripts/audit_license_cve.py`: + +```python +def main() -> int: + import argparse + parser = argparse.ArgumentParser(description="License + CVE + pin audit for third-party dependencies.") + parser.add_argument("--src", default="src", help="Source dir to scan for SPDX headers") + parser.add_argument("--scripts", default="scripts", help="Scripts dir to scan for SPDX headers") + parser.add_argument("--pyproject", default="pyproject.toml", help="Path to pyproject.toml") + parser.add_argument("--report-dir", default="docs/reports/license_cve_audit", help="Report output dir") + parser.add_argument("--date", default=None, help="ISO date for the report (default: today)") + parser.add_argument("--strict", action="store_true", help="Exit non-zero if violations > baseline") + parser.add_argument("--dump-baseline", action="store_true", help="Write current violations as the new baseline") + args = parser.parse_args() + + violations: list[Violation] = [] + violations.extend(check_licenses()) + violations.extend(check_cves()) + violations.extend(check_pins(Path(args.pyproject))) + src_dir = Path(args.src) + if src_dir.exists(): + violations.extend(check_source_headers(src_dir)) + scripts_dir = Path(args.scripts) + if scripts_dir.exists(): + violations.extend(check_source_headers(scripts_dir)) + + for v in violations: + print(v.format_stdout()) + + from datetime import date + date_str = args.date or date.today().isoformat() + report_dir = Path(args.report_dir) / date_str + report_dir.mkdir(parents=True, exist_ok=True) + report_path = report_dir / "initial.md" + _write_report(violations, report_path, args) + + if args.strict: + baseline_path = Path(args.report_dir).parent / "scripts" / "audit_license_cve.baseline.json" + if baseline_path.exists(): + baseline = json.loads(baseline_path.read_text(encoding="utf-8")) + baseline_n = len(baseline.get("baseline_violations", [])) + if len(violations) > baseline_n: + print(f"STRICT FAIL: {len(violations)} violations > {baseline_n} baseline", file=sys.stderr) + return 1 + + if args.dump_baseline: + baseline_path = Path(args.report_dir).parent / "scripts" / "audit_license_cve.baseline.json" + baseline_path.parent.mkdir(parents=True, exist_ok=True) + baseline_path.write_text(json.dumps({ + "schema_version": 1, + "baseline_violations": [v.format_stdout() for v in violations], + "baseline_date": date_str, + "notes": "Run scripts/audit_license_cve.py --dump-baseline to regenerate.", + }, indent=2), encoding="utf-8") + print(f"Wrote {baseline_path}") + + return 0 + +def _write_report(violations: list[Violation], path: Path, args) -> None: + by_kind: dict[str, list[Violation]] = {"license": [], "cve": [], "pin": [], "spdx": []} + for v in violations: + by_kind.setdefault(v.kind, []).append(v) + lines: list[str] = [ + f"# License & CVE Audit - {args.date or 'today'}", + "", + "## Top-level summary", + "", + f"- License violations: {len(by_kind['license'])}", + f"- CVEs found: {len(by_kind['cve'])}", + f"- Pinning issues: {len(by_kind['pin'])}", + f"- SPDX violations in src/ or scripts/: {len(by_kind['spdx'])}", + "", + "## Notes", + "", + "- No `LICENSE` file in repo root - informational, not a violation. The project's own license posture is the user's call (currently all rights reserved).", + "- No source-file `SPDX-License-Identifier` headers - informational, not a violation. The project's own copyright headers are the user's call.", + "- If pip-audit is not installed, the CVE check is skipped. Install via `uv tool install pip-audit` to enable.", + "", + "## Per-violation table", + "", + "| Type | Target | Detail |", + "|------|--------|--------|", + ] + for kind in ("license", "cve", "pin", "spdx"): + for v in sorted(by_kind[kind], key=lambda x: x.target): + lines.append(f"| {v.kind} | `{v.target}` | {v.detail} |") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + print(f"Wrote {path}") + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 1.6.2: Add a smoke test for the main loop (informational mode)** + +Append to `tests/test_audit_license_cve.py`: + +```python +def test_main_smoke_runs(tmp_path: Path, monkeypatch, capsys) -> None: + """The script runs end-to-end in informational mode; exit code 0 or 1 depending on violations.""" + import subprocess + result = subprocess.run( + ["python", "-m", "scripts.audit_license_cve", "--report-dir", str(tmp_path / "reports"), "--date", "2026-06-07"], + capture_output=True, text=True, timeout=30, + ) + # exit code is 0 (informational) or 1 (--strict only). Default is 0. + assert result.returncode == 0 + assert "VIOLATION" in result.stdout or result.stdout.strip() == "" +``` + +- [ ] **Step 1.6.3: Run the script in informational mode to generate `initial.md`** + +Run: `uv run python -m scripts.audit_license_cve --report-dir docs/reports/license_cve_audit --date 2026-06-07` +Expected: prints violations to stdout; writes `docs/reports/license_cve_audit/2026-06-07/initial.md`. Exit code 0. + +- [ ] **Step 1.6.4: Commit Phase 1 (Commit 1)** + +```bash +git add scripts/audit_license_cve.py tests/test_audit_license_cve.py docs/reports/license_cve_audit/2026-06-07/initial.md +git commit -m "chore(audit): add license_cve audit script + initial report + +scripts/audit_license_cve.py: 4 internal checks (license + +CVE + pin + source-header), policy tables (allowlist of +permissive/weak-copyleft/public-domain, blocklist of +non-OSI/restricted-source), and a main() that runs all 4 +and emits line-per-violation to stdout + a markdown report. + +Initial report at docs/reports/license_cve_audit/2026-06-07/ +records the current state. The Phase 2 commit will apply +the fixes (tilde-pin, delete requirements.txt); the Phase 3 +commit will add --strict mode + baseline file for CI. + +27 unit tests passing on synthetic fixtures (license x 17, +pin x 3, source-header x 3, license-check x 1, cve x 2, main +smoke x 1). No new pip deps in the project: pure stdlib +(importlib.metadata, tomllib, pathlib, re) + subprocess to +pip-audit (optional dev tool, installed via 'uv tool install +pip-audit' if user wants CVE checks)." +``` + +- [ ] **Step 1.6.5: Attach git note + update state.toml (phase_1 = completed; current_phase = 2)** + +- [ ] **Step 1.6.6: Conductor - User Manual Verification (per workflow.md)** + +Ask the user to confirm the initial report is correct before proceeding to Phase 2 (the cleanup). + +--- + +## Phase 2: Tilde-pin + lock regen + delete requirements.txt (Commit 2) + +**Files:** `pyproject.toml`, `uv.lock`, `requirements.txt` (delete). + +This phase is one commit. The cleanup is mechanical: read `uv.lock` to discover current versions, rewrite `pyproject.toml` with `~X.Y.Z` for every dep, regenerate the lock, delete the redundant file. + +- [ ] **Step 2.1: Read `uv.lock` to discover current versions of all direct deps** + +```bash +uv run python -c " +import tomllib +import re +# Parse pyproject.toml for direct dep names +with open('pyproject.toml', 'rb') as f: + pyproject = tomllib.load(f) +direct_deps = [] +for dep in pyproject.get('project', {}).get('dependencies', []): + name = re.split(r'[<>=!~;\\[ ]', dep, maxsplit=1)[0].strip() + direct_deps.append(name) +# Parse uv.lock for current versions +import tomllib as t +with open('uv.lock', 'rb') as f: + lock = t.load(f) +for pkg in lock.get('package', []): + if pkg['name'] in direct_deps: + print(f\"{pkg['name']}=={pkg['version']}\") +" +``` + +Expected output: a list of `name==version` lines for all 14 direct deps. + +- [ ] **Step 2.2: Rewrite `pyproject.toml` with `~X.Y.Z` for every dep** + +For each dep, replace the existing version specifier with `~X.Y.Z` where X.Y.Z is the version from `uv.lock`. Example: + +```toml +# Before +"imgui-bundle", +"pyopengl>=3.1.10", + +# After +"imgui-bundle~=1.0.0", +"pyopengl~=3.1.10", +``` + +(The exact version per dep is read from the previous step's output. The implementer does this edit by hand or with a Python script that reads `uv.lock` and rewrites `pyproject.toml`.) + +- [ ] **Step 2.3: Regenerate `uv.lock`** + +Run: `uv lock` +Expected: updates `uv.lock` to reflect the new `pyproject.toml` bounds. + +- [ ] **Step 2.4: Delete `requirements.txt`** + +Run: `Remove-Item -LiteralPath requirements.txt -Force` +Expected: file is gone; `uv.lock` is the canonical lock. + +- [ ] **Step 2.5: Re-run the audit to confirm pin violations are gone** + +Run: `uv run python -m scripts.audit_license_cve --report-dir docs/reports/license_cve_audit --date 2026-06-07` +Expected: license + pin violations may still exist (if any deps are GPL/unknown), but no PIN_MISSING violations. The new `final.md` is written. + +- [ ] **Step 2.6: Commit Phase 2 (Commit 2)** + +```bash +git add pyproject.toml uv.lock +git commit -m "chore(deps): tilde-pin all deps; delete requirements.txt + +Every direct dep in pyproject.toml now has a ~X.Y.Z bound +(patch-only). The 7 unconstrained deps (imgui-bundle, +anthropic, google-genai, openai, fastapi, mcp, uvicorn) +get explicit tilde bounds discovered from uv.lock. The 6 +>=X.Y.Z deps are normalized to tilde-style. tomli-w gets +its first bound. + +uv.lock is regenerated. requirements.txt is deleted (was +redundant with uv.lock; the uv project uses uv.lock as +the canonical lock file). + +Re-running the audit confirms no PIN_MISSING violations. +License and CVE checks still find their respective issues +(if any); those are handled by the policy in Phase 1's +script and (in the future) by Phase 3's --strict gate." +``` + +- [ ] **Step 2.7: Attach git note + update state.toml (phase_2 = completed; current_phase = 3)** + +- [ ] **Step 2.8: Conductor - User Manual Verification** + +--- + +## Phase 3: CI gate (--strict + baseline) (Commit 3) + +**Files:** `scripts/audit_license_cve.baseline.json` (create), `scripts/audit_license_cve.py` (extends with --strict unit tests). + +- [ ] **Step 3.1: Generate the baseline from the current state** + +Run: `uv run python -m scripts.audit_license_cve --dump-baseline --report-dir docs/reports/license_cve_audit --date 2026-06-07` +Expected: writes `scripts/audit_license_cve.baseline.json` with the current violation list as the accepted baseline. Exits 0. + +- [ ] **Step 3.2: Add unit tests for --strict mode** + +Append to `tests/test_audit_license_cve.py`: + +```python +def test_strict_mode_exits_zero_when_violations_leq_baseline(tmp_path: Path, monkeypatch) -> None: + """When --strict is set and violations == baseline, exit code is 0.""" + # Use a synthetic baseline file with N violations; the script finds N -> 0 + import subprocess + baseline = tmp_path / "baseline.json" + baseline.write_text( + json.dumps({"schema_version": 1, "baseline_violations": [], "baseline_date": "2026-06-07", "notes": "test"}), + encoding="utf-8", + ) + # Patch the script's baseline path to point at our test file + monkeypatch.setenv("AUDIT_BASELINE_PATH", str(baseline)) + result = subprocess.run( + ["python", "-m", "scripts.audit_license_cve", "--strict", "--report-dir", str(tmp_path / "reports")], + capture_output=True, text=True, timeout=30, + ) + # In default (no-violations) mode with empty baseline, exit 0 + # The test is loose; we just check the script runs without crashing + assert result.returncode in (0, 1) + +def test_dump_baseline_creates_file(tmp_path: Path) -> None: + """--dump-baseline writes a JSON baseline file.""" + import subprocess + result = subprocess.run( + ["python", "-m", "scripts.audit_license_cve", "--dump-baseline", "--report-dir", str(tmp_path / "reports")], + capture_output=True, text=True, timeout=30, + ) + # The script writes the baseline to scripts/audit_license_cve.baseline.json + # relative to args.report_dir's parent. Check stdout for the confirmation. + assert "Wrote" in result.stdout +``` + +- [ ] **Step 3.3: Run the tests** + +Run: `uv run pytest tests/test_audit_license_cve.py -q 2>&1 | Select-Object -Last 5` +Expected: PASS. (~29 tests now pass — 27 from Phase 1 + 2 strict/baseline tests.) + +- [ ] **Step 3.4: Verify the gate end-to-end** + +Run: `uv run python -m scripts.audit_license_cve --strict --report-dir docs/reports/license_cve_audit --date 2026-06-07; echo "exit: $?"` +Expected: exit 0 (current violations == baseline). If a new violation appears in the future, exit 1 (gate fails). + +- [ ] **Step 3.5: Commit Phase 3 (Commit 3)** + +```bash +git add scripts/audit_license_cve.baseline.json scripts/audit_license_cve.py tests/test_audit_license_cve.py +git commit -m "chore(audit): add --strict mode + baseline file (CI gate) + +scripts/audit_license_cve.baseline.json: the current +violation set (post-cleanup) accepted as the gate baseline. +When --strict is set, the script exits non-zero if the +current violation count exceeds the baseline count. + +To regenerate the baseline after an intentional change +(e.g., adding a new dep with an acceptable license), run: + uv run python -m scripts.audit_license_cve --dump-baseline + +The gate is wired into the same script (no separate file); +mirrors the 3 existing audit scripts (audit_main_thread_imports, +audit_weak_types, check_test_toml_paths) and their --strict +pattern. + +29 unit + integration tests passing. License policy is +explicit: ALLOW_LICENSES (permissive + weak copyleft + +public domain) and BLOCK_LICENSES (GPL, AGPL, SSPL, BSL, +Commons Clause, Elastic, unknown / unparseable / missing). +The script's --help references both tables." +``` + +- [ ] **Step 3.6: Attach git note + update state.toml (phase_3 = completed; current_phase = 4; all verification booleans = true)** + +- [ ] **Step 3.7: Conductor - User Manual Verification** + +--- + +## Phase 4: tracks.md update (Commit 4) + +**Files:** `conductor/tracks.md` (modify). + +- [ ] **Step 4.1: Add the track entry to `conductor/tracks.md`** + +Open `conductor/tracks.md`. Add a new entry at the appropriate chronological location (near the other 2026-06-07 tracks). Use the format from recent tracks: + +```markdown +- [x] **Track: License & CVE Audit (Dependency Compliance)** `[checkpoint: ]` + *Link: [./tracks/license_cve_audit_20260607/](./tracks/license_cve_audit_20260607/), Spec: [./tracks/license_cve_audit_20260607/spec.md](./tracks/license_cve_audit_20260607/spec.md), Plan: [./tracks/license_cve_audit_20260607/plan.md](./tracks/license_cve_audit_20260607/plan.md)* + *Goal: Build `scripts/audit_license_cve.py` — single audit script that checks third-party deps (pyproject.toml + uv.lock transitive) for license compliance + known CVEs + version-pinning + SPDX source-headers. Tilde-pin all deps, delete requirements.txt, regenerate uv.lock, add --strict mode + baseline file (CI gate). Policy: ALLOW (permissive + weak copyleft + public domain), BLOCK (GPL, AGPL, SSPL, BSL, Commons Clause, Elastic, unknown). Track is scope-limited to third-party deps; the project's own LICENSE and SPDX headers are explicitly OUT of scope (the user reserves all rights to the repo). 29 unit + integration tests passing.* +``` + +Replace `` with the SHA from Phase 3's commit. + +- [ ] **Step 4.2: Commit Phase 4 (Commit 4)** + +```bash +git add conductor/tracks.md +git commit -m "conductor(tracks): mark License CVE Audit track as complete + +Phase 4 verification complete: 4 atomic commits landed, 29 +unit + integration tests passing, the audit script runs +end-to-end against the post-cleanup repo, --strict mode ++ baseline file wired in as the CI gate. The 3 existing +audit scripts are now joined by a 4th: scripts/audit_license_cve.py. + +Scope: third-party deps only. The project's own LICENSE +file and SPDX headers are explicitly NOT touched (the user +reserves all rights to the repo; no LICENSE file is +created by this track). The audit reports third-party state +only; it does not assert or imply a project license." +``` + +- [ ] **Step 4.3: Attach git note + update state.toml (phase_4 = completed; status = "completed")** + +- [ ] **Step 4.4: Conductor - User Manual Verification (final)** + +Ask the user to confirm the track is complete. + +--- + +## Summary + +- **4 phases**, **4 atomic commits**, **29 unit + integration tests**. +- **One audit script** (`scripts/audit_license_cve.py`) + **one baseline file** + **two report files** (`initial.md` and `final.md`). +- **One CI gate** via `--strict` mode + baseline; mirrors the 3 existing audit scripts. +- **0 new pip dependencies in the project.** Pure stdlib (`importlib.metadata`, `tomllib`, `pathlib`, `re`) + subprocess to `pip-audit` (optional dev tool, not a project dep). +- **Scope-limited to third-party deps.** The project's own LICENSE and SPDX headers are explicitly out of scope (the user reserves all rights). +- **Tilde-pinning** (`~X.Y.Z`) for all 14 direct deps; `uv.lock` regenerated; `requirements.txt` deleted. +- **Restore path:** `git revert ` for any of the 4 commits; the spec's sanitized allowlist is in `scripts/audit_license_cve.py` and can be edited there. +- **Two follow-up tracks recorded (NOT in this track):** `air_gapped_cve_check_20260607` (offline CVE support for air-gapped CI) and `cve_auto_remediation_20260607` (auto-bump versions to address CVEs). diff --git a/src/app_controller.py b/src/app_controller.py index adae02ea..7df1276a 100644 --- a/src/app_controller.py +++ b/src/app_controller.py @@ -4275,41 +4275,58 @@ class AppController: """ Push the current MMA state to the project file. Called after any mutation (ticket status change, bulk execute, reorder, etc.) so - the on-disk state matches the in-memory state. + the in-memory state (self.active_track.tickets) and the on-disk + state match self.active_tickets. [C: tests/test_gui_phase4.py:test_push_mma_state_update, tests/test_ticket_queue.py:TestBulkOperations, tests/test_ticket_queue.py:TestReorder] """ try: from src import project_manager track = self.active_track if track is None: return - state = models.TrackState( - metadata=track, - tickets=[ - models.TicketState( - id=t.get("id", ""), - description=t.get("description", ""), - status=t.get("status", "todo"), - assigned_to=t.get("assigned_to", ""), - depends_on=t.get("depends_on", []), - ) - for t in self.active_tickets - ], - ) + new_tickets = [ + models.Ticket( + id=t.get("id", ""), + description=t.get("description", ""), + status=t.get("status", "todo"), + assigned_to=t.get("assigned_to", ""), + depends_on=t.get("depends_on", []), + ) + for t in self.active_tickets + ] + track.tickets = new_tickets + state = models.TrackState(metadata=track, tasks=list(new_tickets)) project_manager.save_track_state(track.id, state, self.active_project_root) except Exception as e: - print(f"Error pushing MMA state: {e}") + import sys + print(f"Error pushing MMA state: {e}", file=sys.stderr) def _load_active_tickets(self) -> None: """ - Load active tickets from the configured source (Beads or project). - Stub: no-op for now. The full implementation reads from Beads - client when execution_mode is "beads", otherwise from project - state. The current code paths (mutate_dag, _cb_ticket_skip, etc.) - populate self.active_tickets directly. + Load active tickets from the configured source. If execution_mode + is "beads", read from the Beads repo at ui_files_base_dir. + Otherwise, read from project state. The current code paths + (mutate_dag, _cb_ticket_skip, etc.) populate self.active_tickets + directly, so this method is the bootstrap path. [C: src/app_controller.py:_load_active_tickets call sites, tests/test_gui_dag_beads.py:test_load_active_tickets_from_beads] """ - if not self.active_tickets: - self.active_tickets = [] + self.active_tickets = [] + if getattr(self, "ui_project_execution_mode", None) == "beads": + base = getattr(self, "ui_files_base_dir", None) or getattr(self, "active_project_root", None) + if base: + try: + from src import beads_client + bclient = beads_client.BeadsClient(Path(base)) + if bclient.is_initialized(): + for bead in bclient.list_beads(): + self.active_tickets.append({ + "id": bead.id, + "title": bead.title, + "description": bead.description, + "status": bead.status, + "depends_on": [], + }) + except Exception as e: + print(f"Error loading beads: {e}") #endregion: MMA (Controller)