license_policy.py
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 1 | """License and source-path policy for Scooling Lab dependency evidence.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | from dataclasses import dataclass |
| 6 | |
| 7 | |
| 8 | ALLOWED_LICENSES: frozenset[str] = frozenset( |
| 9 | { |
| 10 | "Apache-2.0", |
| 11 | "MIT", |
| 12 | "BSD-2-Clause", |
| 13 | "BSD-3-Clause", |
| 14 | "ISC", |
| 15 | "PSF-2.0", |
| 16 | } |
| 17 | ) |
| 18 | |
| 19 | BLOCKED_LICENSES: frozenset[str] = frozenset( |
| 20 | { |
| 21 | "AGPL-3.0", |
| 22 | "AGPL-3.0-only", |
| 23 | "AGPL-3.0-or-later", |
| 24 | "GPL-2.0", |
| 25 | "GPL-2.0-only", |
| 26 | "GPL-2.0-or-later", |
| 27 | "GPL-3.0", |
| 28 | "GPL-3.0-only", |
| 29 | "GPL-3.0-or-later", |
| 30 | } |
| 31 | ) |
| 32 | |
| 33 | BLOCKED_PATH_SEGMENTS: frozenset[str] = frozenset({"studio", "unsloth_cli"}) |
| 34 | |
| 35 | |
| 36 | class LicensePolicyError(ValueError): |
| 37 | """Raised when a dependency or source path violates Scooling Lab policy.""" |
| 38 | |
| 39 | |
| 40 | @dataclass(frozen=True) |
| 41 | class BomEntry: |
| 42 | """Single dependency or project inventory row used by the BOM generator.""" |
| 43 | |
| 44 | name: str |
| 45 | version: str |
| 46 | license: str |
| 47 | source_path: str |
| 48 | evidence: str |
| 49 | |
| 50 | |
| 51 | def normalize_source_path(source_path: str) -> str: |
| 52 | """Normalize a source path for policy checks without touching the filesystem.""" |
| 53 | |
| 54 | stripped = source_path.strip().replace("\\", "/") |
| 55 | while "//" in stripped: |
| 56 | stripped = stripped.replace("//", "/") |
| 57 | return stripped.strip("/") |
| 58 | |
| 59 | |
| 60 | def validate_source_path(source_path: str) -> None: |
| 61 | """Reject source paths that enter AGPL-covered Unsloth Studio or CLI code.""" |
| 62 | |
| 63 | normalized = normalize_source_path(source_path) |
| 64 | segments = {segment for segment in normalized.split("/") if segment} |
| 65 | blocked = segments.intersection(BLOCKED_PATH_SEGMENTS) |
| 66 | if blocked: |
| 67 | blocked_list = ", ".join(sorted(blocked)) |
| 68 | raise LicensePolicyError(f"blocked source path segment: {blocked_list}") |
| 69 | |
| 70 | |
| 71 | def validate_license(license_id: str) -> None: |
| 72 | """Reject non-allowlisted licenses before code can enter CI or the BOM.""" |
| 73 | |
| 74 | normalized = license_id.strip() |
| 75 | if normalized in BLOCKED_LICENSES: |
| 76 | raise LicensePolicyError(f"blocked license: {normalized}") |
| 77 | if normalized not in ALLOWED_LICENSES: |
| 78 | raise LicensePolicyError(f"non-allowlisted license: {normalized}") |
| 79 | |
| 80 | |
| 81 | def validate_entry(entry: BomEntry) -> None: |
| 82 | """Validate one BOM entry against license and source-path policy.""" |
| 83 | |
| 84 | if not entry.name.strip(): |
| 85 | raise LicensePolicyError("BOM entry name is required") |
| 86 | if not entry.version.strip(): |
| 87 | raise LicensePolicyError("BOM entry version is required") |
| 88 | if not entry.evidence.strip(): |
| 89 | raise LicensePolicyError("BOM entry evidence is required") |
| 90 | validate_license(entry.license) |
| 91 | validate_source_path(entry.source_path) |
| 92 | |
| 93 | |
| 94 | def validate_entries(entries: list[BomEntry]) -> None: |
| 95 | """Validate all BOM entries and fail closed when the BOM is empty.""" |
| 96 | |
| 97 | if not entries: |
| 98 | raise LicensePolicyError("BOM must contain at least one entry") |
| 99 | for entry in entries: |
| 100 | validate_entry(entry) |