Private
Public Access
archive completed or outdated tracks.
This commit is contained in:
@@ -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/<date>/. 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", "<unknown>")
|
||||
for vuln in dep.get("vulns", []):
|
||||
cve_id = vuln.get("id", "<unknown>")
|
||||
fix = ", ".join(vuln.get("fix_versions", []) or ["<unknown>"])
|
||||
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: <last_commit_sha>]`
|
||||
*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 `<last_commit_sha>` 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 <commit-hash>` 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).
|
||||
@@ -0,0 +1,286 @@
|
||||
# Track: License & CVE Audit (Dependency Compliance)
|
||||
|
||||
**Status:** Spec approved 2026-06-07
|
||||
**Initialized:** 2026-06-07
|
||||
**Owner:** Tier 2 Tech Lead
|
||||
**Priority:** High (compliance + security; CI gate)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Build `scripts/audit_license_cve.py` — a single audit script that checks third-party dependencies (in `pyproject.toml` + `uv.lock` transitive tree) for: (1) license compliance against the project's policy, (2) known CVEs (via `pip-audit` subprocess), and (3) version-pinning (every direct dep must have a `~X.Y.Z` bound). The script also scans source-file license headers (`SPDX-License-Identifier`) in `src/**/*.py` and `scripts/**/*.py`. Then apply the fixes: tilde-pin all direct deps, delete `requirements.txt` (redundant with `uv.lock`), regenerate `uv.lock`, add `--strict` mode + baseline file (CI gate). One script, one CI gate, one report.
|
||||
|
||||
The track is **scope-limited to third-party dependencies**. The project's own LICENSE file and SPDX/Copyright headers are explicitly OUT OF SCOPE — the user reserves all rights to the repo and has not picked a project license yet. The audit reports third-party state only; it does not assert or imply a project license, and it does not create a `LICENSE` file.
|
||||
|
||||
## Current State Audit (as of `9796fe27`)
|
||||
|
||||
- `pyproject.toml` has 14 direct deps with **mixed pinning**:
|
||||
- 7 unconstrained: `"imgui-bundle"`, `"anthropic"`, `"google-genai"`, `"openai"`, `"fastapi"`, `"mcp"`, `"uvicorn"`
|
||||
- 6 with `>=X.Y.Z`: `"pyopengl>=3.1.10"`, `"tree-sitter>=0.25.2"`, `"tree-sitter-python>=0.25.0"`, `"tree-sitter-c>=0.23.2"`, `"tree-sitter-cpp>=0.23.2"`, `"psutil>=7.2.2"`, `"chromadb>=1.5.8"`
|
||||
- `"tomli-w"`, `"pytest-timeout>=2.4.0"`
|
||||
- `uv.lock` exists; `requirements.txt` exists (duplicates lock — will be removed)
|
||||
- No `LICENSE` file in repo root (user's chosen posture: all rights reserved; the audit reports this as informational, not a violation)
|
||||
- No source-file `SPDX-License-Identifier` headers in `src/**/*.py` or `scripts/**/*.py` (informational note; not a violation — the user hasn't picked a project license yet)
|
||||
- No `vendor/`, `third_party/`, or vendored C/C++ in the repo tree (the scan is defensive for the future)
|
||||
- 0 existing license/CVE audit tools in `scripts/`
|
||||
- The 3 existing audit scripts (`audit_main_thread_imports.py`, `audit_weak_types.py`, `check_test_toml_paths.py`) follow the project pattern of `scripts/audit_<name>.py` + `scripts/audit_<name>.baseline.json` + `--strict` mode for CI gates (per `conductor/workflow.md` "Audit Script Policy"). The new track follows the same pattern.
|
||||
|
||||
### Already Implemented (DO NOT re-implement; KEEP / build on)
|
||||
|
||||
1. **The 3 existing audit scripts** in `scripts/`. They define the project pattern for audit + CI gate. The new `scripts/audit_license_cve.py` follows the same shape.
|
||||
2. **`uv.lock`** — the canonical lock file for the project. The audit reads it for transitive resolution.
|
||||
3. **`importlib.metadata`** (Python 3.11+ stdlib) — gives `License` and `License-Expression` per installed distribution. No new pip dep needed for the license check.
|
||||
4. **`tomllib`** (Python 3.11+ stdlib) — parses `pyproject.toml`. No new pip dep needed for the pin check.
|
||||
5. **`pip-audit`** (PyPA tool) — invoked as a subprocess for the CVE check. `pip-audit` itself is NOT a project dep; it's installed via `uv tool install pip-audit` or `uvx pip-audit` if the user wants the CVE check. The script detects missing `pip-audit` and logs a warning; license + pin checks still run.
|
||||
|
||||
### Gaps to Fill (this track's scope)
|
||||
|
||||
- `scripts/audit_license_cve.py` (~300 lines, 3 internal checks + `--strict` + `--dump-baseline`)
|
||||
- `scripts/audit_license_cve.baseline.json` (zero-violation post-cleanup state for `--strict` mode)
|
||||
- `docs/reports/license_cve_audit/2026-06-07/initial.md` and `final.md` (the human-readable reports)
|
||||
- Updates to `pyproject.toml` (tilde-pin every direct dep)
|
||||
- Updated `uv.lock` (regenerated)
|
||||
- Deletion of `requirements.txt`
|
||||
- `tests/test_audit_license_cve.py` (TDD unit tests)
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Single audit script** that runs all four checks (license + CVE + pin + source-header) and emits a unified report.
|
||||
2. **CI gate** via `--strict` mode + baseline file. Mirrors the 3 existing audit scripts. Fails on any new violation OR any new CVE.
|
||||
3. **Tilde-pin every direct dep** in `pyproject.toml` (`~X.Y.Z` = `>=X.Y.Z,<X.(Y+1).0`).
|
||||
4. **Delete `requirements.txt`** (duplicates `uv.lock`; redundant in a `uv` project).
|
||||
5. **Re-run `uv lock`** to refresh the lock file with the new bounds.
|
||||
6. **Document the non-OSI / restricted-source category** in the policy table of the script (so future contributors understand why these licenses are blocked).
|
||||
7. **Preserve the user's "all rights reserved" posture** — no `LICENSE` file is created; no project-level SPDX headers are added.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- The project's own `LICENSE` file (user's decision; not creating one).
|
||||
- The project's own `SPDX-License-Identifier` / `Copyright` headers (user's decision; not adding or modifying).
|
||||
- Any recommendation on what license the user should pick for the project.
|
||||
- Patching CVEs in transitive deps (the track REPORTS; the user decides whether to wait for upstream or replace).
|
||||
- Auto-bumping versions to address CVEs (manual decision; the track reports, the user acts).
|
||||
- Modifying any third-party code already in the repo (none currently; the scan is defensive for the future).
|
||||
- License/header updates to vendored C/C++ (none currently vendored; the scan is defensive).
|
||||
- The local-rag optional dependency group (`sentence-transformers`); covered by the same audit but pinning happens in the same `pyproject.toml` edit.
|
||||
|
||||
## Architecture
|
||||
|
||||
**`scripts/audit_license_cve.py`** — single audit script, ~300 lines. No new pip dep required (stdlib + subprocess to `pip-audit`).
|
||||
|
||||
### Public API (CLI)
|
||||
|
||||
```bash
|
||||
uv run python scripts/audit_license_cve.py [--src src] [--scripts scripts] \
|
||||
[--report-dir docs/reports/license_cve_audit] [--date YYYY-MM-DD] \
|
||||
[--strict] [--dump-baseline]
|
||||
```
|
||||
|
||||
- **Default mode:** informational. Prints violations to stdout (line-per-violation format). Writes markdown report to `<report-dir>/<date>/initial.md` or `final.md`.
|
||||
- **`--strict` mode:** exits non-zero if violations > baseline. For CI.
|
||||
- **`--dump-baseline`:** writes the current violation set as the new baseline. For intentional changes (e.g., a new dep is added; the user accepts its license).
|
||||
|
||||
### Internal structure (3 checks + 1 scan)
|
||||
|
||||
```python
|
||||
def check_licenses() -> list[Violation]: ... # iterates dist.metadata; classifies
|
||||
def check_cves() -> list[Violation]: ... # subprocess pip-audit; parses JSON
|
||||
def check_pins() -> list[Violation]: ... # tomllib parse; flag missing/loose pins
|
||||
def check_source_headers() -> list[Violation]: ... # pathlib rglob; SPDX regex
|
||||
|
||||
def main():
|
||||
violations = []
|
||||
for check in (check_licenses, check_cves, check_pins, check_source_headers):
|
||||
violations.extend(check())
|
||||
for v in violations:
|
||||
print(v.format_stdout()) # parseable line-per-violation
|
||||
write_markdown_report(violations)
|
||||
if args.strict and len(violations) > len(load_baseline()):
|
||||
sys.exit(1)
|
||||
if args.dump_baseline:
|
||||
dump_baseline(violations)
|
||||
```
|
||||
|
||||
### Cost model (the 4 checks)
|
||||
|
||||
| Check | Mechanism | New deps? |
|
||||
|-------|-----------|-----------|
|
||||
| **License** | `importlib.metadata.distribution(name).metadata.get("License")` + `License-Expression` (Python 3.11+ stdlib). For each direct + transitive dep, classify the license string against the policy table. Unknown / unparseable / missing → violation. | None (stdlib) |
|
||||
| **CVE** | Subprocess call to `pip-audit --format=json --strict` (a `uv tool install pip-audit` dev tool; the project itself doesn't depend on it). If `pip-audit` isn't installed, log a warning + skip the CVE check; license + pin still run. Air-gapped CI: CVE check returns no results (not a failure). | None in `pyproject.toml`; `pip-audit` is an optional dev tool. |
|
||||
| **Version pin** | `tomllib.load(pyproject.toml)` (stdlib). For each entry in `[project].dependencies`, check the version specifier. Flags: (a) no specifier at all, (b) no lower bound. Accepts any lower bound as a soft check (the user's choice is tilde, but the script doesn't enforce tilde specifically — it enforces "has a lower bound"). | None (stdlib) |
|
||||
| **Source header** | `pathlib.Path(src_dir).rglob("*.py")`, read first 20 lines of each, regex-look for `SPDX-License-Identifier:` (case-insensitive). If present and in the blocklist → violation. If no SPDX → no violation (informational note). | None (stdlib) |
|
||||
|
||||
## License Policy (encoded in the script)
|
||||
|
||||
### Allowlist (permissive or weak copyleft, import-safe in Python)
|
||||
|
||||
- **Permissive:** MIT, BSD (2-clause + 3-clause), Apache 2.0, ISC, Unlicense, Zlib, Python-2.0, 0BSD, PSF-2.0
|
||||
- **Weak copyleft (import-safe in Python):** LGPL (2.1, 3.0), MPL-2.0
|
||||
- **Public domain:** CC0, Unlicense, WTFPL
|
||||
|
||||
(The script's allowlist is the canonical source of truth for the per-license table; see `scripts/audit_license_cve.py` for the current list. New licenses can be added by editing that table; no spec change needed.)
|
||||
|
||||
### Blocklist (non-permissive / restricted-source)
|
||||
|
||||
The blocklist is for licenses that are **non-OSI** or that impose **restrictions beyond standard copyleft terms** (permissive or copyleft). The unifying technical property: the license restricts how downstream users can use the software in ways that standard open-source licenses do not.
|
||||
|
||||
| License | Specific restriction |
|
||||
|---------|---------------------|
|
||||
| **GPL** (any version) | Strong copyleft; viral licensing; downstream users must release derivative works under GPL |
|
||||
| **AGPL** (any version) | Network copyleft; downstream SaaS users must release source under AGPL |
|
||||
| **SSPL** (MongoDB, 2018) | "If you offer the software as a service, you must release the entire stack under SSPL" — broad service-provider trigger |
|
||||
| **BSL / BUSL** (Business Source License) | Source-available with a delayed open-source conversion; competitive-use restriction during the delay |
|
||||
| **Commons Clause** | Addendum to an open-source license; adds "you may not sell the software" — targets SaaS reselling |
|
||||
| **Elastic License v2** (Elastic NV, 2021) | "You may not offer the software as a managed service that competes with Elastic" |
|
||||
| **Unknown / unparseable** (e.g., `UNKNOWN`, `Custom`, `see AUTHORS`) | Not classifiable; flagged for manual review; never auto-pass |
|
||||
| **Missing license metadata** | Catches packaging bugs |
|
||||
|
||||
### Decision rule (in the script)
|
||||
|
||||
```
|
||||
if license in BLOCKLIST: violation
|
||||
elif license in ALLOWLIST: pass
|
||||
else: # unknown / unparseable / unclassified
|
||||
violation (flag for manual review; never auto-pass)
|
||||
```
|
||||
|
||||
The two lists are explicit, not heuristic. Adding a new license to either list is a one-line code change. The script's `--help` references the policy table for transparency.
|
||||
|
||||
## Output Format
|
||||
|
||||
### Stdout (line-per-violation, parseable)
|
||||
|
||||
```
|
||||
LICENSE_VIOLATION pkg=foo license="GPL-3.0" via=bar==2.0
|
||||
CVE_FOUND pkg=baz cve_id=CVE-2024-12345 severity=high fix_versions=">=1.2.3"
|
||||
PIN_MISSING pkg=qux (no version specifier in pyproject.toml)
|
||||
SPDX_VIOLATION file=src/some_module.py license="GPL-3.0"
|
||||
```
|
||||
|
||||
Each line is a stable parseable format; CI can grep for `VIOLATION|FOUND|MISSING` and `exit 1` on any match.
|
||||
|
||||
### Markdown report (in `docs/reports/license_cve_audit/<YYYY-MM-DD>/`)
|
||||
|
||||
- `initial.md` — the discovered violations (committed in Phase 1)
|
||||
- `final.md` — the post-cleanup state (committed in Phase 2, after tilde-pinning + lock regen)
|
||||
|
||||
Structure:
|
||||
|
||||
```markdown
|
||||
# License & CVE Audit — 2026-06-07
|
||||
|
||||
## Top-level summary
|
||||
|
||||
- License violations: 0
|
||||
- CVEs found: 0
|
||||
- Pinning issues: 0
|
||||
- SPDX violations in src/ or scripts/: 0
|
||||
|
||||
## 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.
|
||||
- pip-audit not installed → CVE check skipped. Install via `uv tool install pip-audit` to enable.
|
||||
|
||||
## Per-violation table
|
||||
|
||||
| Type | Package | License / CVE / Pin | Via |
|
||||
|------|---------|---------------------|-----|
|
||||
| ... | ... | ... | ... |
|
||||
```
|
||||
|
||||
### Baseline file (`scripts/audit_license_cve.baseline.json`)
|
||||
|
||||
Internal state for `--strict` mode. JSON because it matches the existing convention (`scripts/audit_weak_types.baseline.json`). Not the user-facing report; not in the output surface. Format:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"baseline_violations": [],
|
||||
"baseline_date": "2026-06-07",
|
||||
"notes": "Zero-violation state after the tilde-pinning + lock regen in this track."
|
||||
}
|
||||
```
|
||||
|
||||
`--strict` mode loads this file and fails CI if `len(current_violations) > len(baseline_violations)`. The user's intentional changes (e.g., adding a new dep with an acceptable license) are recorded by re-running with `--dump-baseline`.
|
||||
|
||||
## Commit Structure (4 atomic commits, in order)
|
||||
|
||||
```
|
||||
1. chore(audit): add license_cve audit script + initial report
|
||||
- scripts/audit_license_cve.py (initial version, informational mode)
|
||||
- docs/reports/license_cve_audit/2026-06-07/initial.md (the discovered violations)
|
||||
2. chore(deps): tilde-pin all deps; delete requirements.txt
|
||||
- pyproject.toml (every direct dep gets ~X.Y.Z or stays as >=X.Y.Z)
|
||||
- uv.lock (regenerated)
|
||||
- requirements.txt (deleted; was redundant with lock)
|
||||
3. chore(audit): add --strict mode + baseline file (CI gate)
|
||||
- scripts/audit_license_cve.py (extends with --strict + baseline diff)
|
||||
- scripts/audit_license_cve.baseline.json (zero-violation post-cleanup state)
|
||||
4. conductor(tracks): mark License CVE Audit track complete
|
||||
- tracks.md update
|
||||
```
|
||||
|
||||
Each commit message includes a `git notes add -m "..."` summary per `conductor/workflow.md`.
|
||||
|
||||
## Verification (TDD per `conductor/workflow.md`)
|
||||
|
||||
Unit tests in `tests/test_audit_license_cve.py`:
|
||||
|
||||
- License classifier: a known fixture package list with various licenses → correct classification (blocklist + allowlist + unknown).
|
||||
- Blocklist enforcement: each entry (GPL, AGPL, SSPL, BSL, BUSL, Commons Clause, Elastic v2, unknown, missing) → correctly flagged.
|
||||
- Allowlist enforcement: each entry (MIT, BSD, Apache 2.0, ISC, Unlicense, Zlib, Python-2.0, LGPL, MPL-2.0, CC0, WTFPL) → correctly passes.
|
||||
- Pin check: synthetic `pyproject.toml` with mixed pinning (no bound, `>=X.Y`, `~X.Y.Z`, exact) → correct flags.
|
||||
- Source header check: synthetic `.py` with `SPDX-License-Identifier: GPL-3.0` → flagged; with no SPDX → no violation.
|
||||
- `--strict` mode: violations > baseline → exit 1; violations == baseline → exit 0; new violation (delta > 0) → exit 1.
|
||||
- `--dump-baseline`: writes a baseline file matching the current violation set.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|-----------|--------|------------|
|
||||
| Some packages' license metadata is missing or unparseable in `importlib.metadata` | High | Medium (false positives on unknown) | The policy treats `UNKNOWN` as violation → manual review catches the right answer; the report's notes section lists the unknowns explicitly |
|
||||
| `pip-audit` not installed in CI | Medium | Low (CVE check is a no-op) | Script detects missing `pip-audit` and logs a warning; license + pin checks still run |
|
||||
| Air-gapped CI can't reach OSV / PyPI advisory DBs | Medium | Low (CVE check returns no results) | Document; a follow-up could add offline CVE support, not in this track |
|
||||
| Pinning decisions are subjective (some deps deserve looser bounds than others) | Medium | Low (initial pass is conservative) | The pin check accepts any lower bound as a soft check; the user can loosen specific deps via the baseline file |
|
||||
| The baseline file becomes a "shadow ledger" — needs maintenance when intentional changes are made | Medium | Low (intentional) | Document the update workflow in the script's `--help`; `--dump-baseline` regenerates the baseline after an intentional change |
|
||||
| The project's own LICENSE absence might confuse a future contributor who doesn't know the user's posture | Low | Low | The report's notes section explicitly calls this out: "no LICENSE in repo root — informational, not a violation; project's own license is the user's call (currently all rights reserved)" |
|
||||
| A dep is added with a license that doesn't match the script's allowlist/blocklist (e.g., a new "BSL 2.0" variant) | Low | Low | The script's default rule (unknown = violation) catches it; the report's notes section surfaces it for review; one-line add to the appropriate list |
|
||||
|
||||
## Follow-up
|
||||
|
||||
- `air_gapped_cve_check_20260607` (NOT in this track): add offline CVE support for air-gapped CI environments that can't reach OSV / PyPI. The CVE check would ship a snapshot of the advisory DBs (or use a local mirror).
|
||||
- `cve_auto_remediation_20260607` (NOT in this track): when a CVE is found, auto-bump the dep to the fix version (within the pin range) and re-run the audit. Out of scope here; this track REPORTS, the user DECIDES.
|
||||
|
||||
## Coordination with Pending Tracks
|
||||
|
||||
This track has **no blockers** and **no conflicts** with the 5 active planned tracks. It modifies:
|
||||
|
||||
- `pyproject.toml` (version pins; could affect resolution for any future track that depends on something)
|
||||
- `uv.lock` (regenerated; the lock file changes)
|
||||
- `requirements.txt` (deleted; was redundant with lock)
|
||||
- New: `scripts/audit_license_cve.py`, `scripts/audit_license_cve.baseline.json`, `docs/reports/license_cve_audit/2026-06-07/`
|
||||
|
||||
It does NOT modify `src/`, `tests/`, or any of the 5 planned tracks' files. The deleted `requirements.txt` is a separate file from the 5 planned tracks' scope. Can ship independently and in parallel with the 5 planned tracks.
|
||||
|
||||
The tilde-pinning in this track is a STRENGTHENING of the dep contract, not a loosening — it doesn't break any existing test or any other track's plan.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- The project's own `LICENSE` file (user's decision; the track will not create one).
|
||||
- The project's own `SPDX-License-Identifier` / `Copyright` headers in `src/` (user's decision; the track will not add or modify).
|
||||
- Any recommendation on what license the user should pick for the project.
|
||||
- Patching CVEs in transitive deps (the track REPORTS; the user decides whether to wait for upstream or replace).
|
||||
- Auto-bumping versions to address CVEs (manual decision; the track reports, the user acts).
|
||||
- Modifying any third-party code already in the repo (none currently; the scan is defensive for the future).
|
||||
- License/header updates to vendored C/C++ (none currently vendored; the scan is defensive).
|
||||
- The local-rag optional dependency group (`sentence-transformers`); covered by the same audit but pinning happens in the same `pyproject.toml` edit.
|
||||
|
||||
## See Also
|
||||
|
||||
- `conductor/workflow.md` "Audit Script Policy" — the convention this track follows.
|
||||
- `scripts/audit_main_thread_imports.py`, `scripts/audit_weak_types.py`, `scripts/check_test_toml_paths.py` — the 3 existing audit scripts; the new track follows the same shape.
|
||||
- `scripts/audit_weak_types.baseline.json` — the baseline file pattern (the new `scripts/audit_license_cve.baseline.json` mirrors this).
|
||||
- [OSI Approved Licenses](https://opensource.org/licenses/) — the de facto list of "open source" licenses; the script's policy is consistent with this list (with the addition of LGPL / MPL-2.0 in transitive deps for Python import-safety).
|
||||
- `pip-audit` (PyPA) — the CVE-checking tool invoked as a subprocess. Optional; the script handles its absence gracefully.
|
||||
@@ -0,0 +1,48 @@
|
||||
# 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 = "completed"
|
||||
current_phase = "complete"
|
||||
last_updated = "2026-06-07"
|
||||
|
||||
[phases]
|
||||
phase_1 = { status = "completed", checkpointsha = "a8ae11d3", name = "Audit script + initial report" }
|
||||
phase_2 = { status = "completed", checkpointsha = "20fa3558", name = "Tilde-pin + lock regen + delete requirements.txt" }
|
||||
phase_3 = { status = "completed", checkpointsha = "a7ab994f", name = "CI gate (--strict + baseline)" }
|
||||
phase_4 = { status = "completed", checkpointsha = "TBD", name = "tracks.md update" }
|
||||
|
||||
[verification]
|
||||
audit_script_exists = true
|
||||
license_check_passes = true
|
||||
cve_check_optional_passes = true
|
||||
pin_check_passes = true
|
||||
source_header_check_passes = true
|
||||
pyproject_tilde_pinned = true
|
||||
requirements_txt_deleted = true
|
||||
uv_lock_regenerated = true
|
||||
strict_mode_implemented = true
|
||||
baseline_file_committed = true
|
||||
unit_tests_passing = true
|
||||
|
||||
[tasks]
|
||||
t0_1 = { status = "completed", commit_sha = "a8ae11d3", description = "Create state.toml" }
|
||||
t0_2 = { status = "completed", commit_sha = "a8ae11d3", description = "Create empty scripts/audit_license_cve.py" }
|
||||
t0_3 = { status = "completed", commit_sha = "a8ae11d3", description = "Create empty tests/test_audit_license_cve.py" }
|
||||
t1_1 = { status = "completed", commit_sha = "a8ae11d3", description = "TDD: license classifier + ALLOW/BLOCK tables" }
|
||||
t1_2 = { status = "completed", commit_sha = "a8ae11d3", description = "TDD: pin check" }
|
||||
t1_3 = { status = "completed", commit_sha = "a8ae11d3", description = "TDD: source-header check" }
|
||||
t1_4 = { status = "completed", commit_sha = "a8ae11d3", description = "TDD: license check via importlib.metadata" }
|
||||
t1_5 = { status = "completed", commit_sha = "a8ae11d3", description = "TDD: CVE check via subprocess pip-audit" }
|
||||
t1_6 = { status = "completed", commit_sha = "a8ae11d3", description = "Main loop + smoke test + initial report" }
|
||||
t2_1 = { status = "completed", commit_sha = "20fa3558", description = "Tilde-pin all deps in pyproject.toml" }
|
||||
t2_2 = { status = "completed", commit_sha = "20fa3558", description = "Regenerate uv.lock (gitignored)" }
|
||||
t2_3 = { status = "completed", commit_sha = "20fa3558", description = "Delete requirements.txt" }
|
||||
t2_4 = { status = "completed", commit_sha = "20fa3558", description = "Re-run audit + final.md report" }
|
||||
t3_1 = { status = "completed", commit_sha = "a7ab994f", description = "Generate baseline file via --dump-baseline" }
|
||||
t3_2 = { status = "completed", commit_sha = "a7ab994f", description = "Add --strict mode tests" }
|
||||
t3_3 = { status = "completed", commit_sha = "a7ab994f", description = "Verify gate end-to-end (--strict exit 0)" }
|
||||
t4_1 = { status = "completed", commit_sha = "TBD", description = "Add track entry to conductor/tracks.md" }
|
||||
t4_2 = { status = "completed", commit_sha = "TBD", description = "Update state.toml to completed" }
|
||||
Reference in New Issue
Block a user