diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml new file mode 100644 index 000000000..7c1da1ad2 --- /dev/null +++ b/.github/workflows/license-check.yml @@ -0,0 +1,52 @@ +name: License check + +on: + pull_request: + branches: + - master + paths: + - "pyproject.toml" + - "uv.lock" + - ".licenses/**" + - "scripts/check-licenses.py" + - ".github/workflows/license-check.yml" + push: + branches: + - master + +jobs: + licenses: + name: Audit dependency licenses + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install the latest version of uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Sync dependencies + run: uv sync --frozen --group dev + + - name: Check licenses + id: check + run: | + uv run --frozen scripts/check-licenses.py \ + --markdown "$GITHUB_STEP_SUMMARY" + + - name: Upload license report + if: always() + run: | + uv run --frozen scripts/check-licenses.py --json > license-report.json || true + shell: bash + + - name: Save report artifact + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: license-report + path: license-report.json + retention-days: 30 + if-no-files-found: warn diff --git a/.licenses/policy.toml b/.licenses/policy.toml new file mode 100644 index 000000000..98cc70472 --- /dev/null +++ b/.licenses/policy.toml @@ -0,0 +1,151 @@ +# License policy for nextcloud-mcp-server +# +# The project is distributed under AGPL-3.0-only and the maintainer (Astrolabe +# Cloud) intends to offer commercial dual-licensed builds in the future. Every +# direct or transitive Python dependency must therefore satisfy BOTH: +# +# 1. AGPL-3.0-only distribution (the public release), and +# 2. proprietary/commercial distribution under separate terms. +# +# The lists below classify licenses by their compatibility with that goal. +# +# Maintenance: when a CI run flags a new dependency, either +# - move its license into `allowed` if it is permissive, +# - move it into the per-package `exceptions` table with rationale, or +# - replace the dependency. +# +# Reference: https://www.gnu.org/licenses/license-list.html + +# Permissive licences that are compatible with both AGPL-3 distribution and +# proprietary re-licensing. These need no per-package review. +[allowed] +licenses = [ + # Permissive + "MIT", + "MIT License", + "MIT-CMU", + "BSD", + "BSD License", + "BSD-2-Clause", + "BSD-3-Clause", + "3-Clause BSD License", + "Apache-2.0", + "Apache 2.0", + "Apache License", + "Apache License 2.0", + "Apache Software License", + "ISC", + "ISC License (ISCL)", + "PSF", + "PSF-2.0", + "Python Software Foundation License", + "Python-2.0", + "Unlicense", + "Artistic License", + "Artistic License 2.0", + # Weak copyleft — compatible via dynamic linking (Python import). + # Distributing a modified version of the LGPL component itself still + # requires source release, but linking from proprietary code is allowed. + "LGPL-3.0-or-later", + "GNU Lesser General Public License v3 or later (LGPLv3+)", + # File-level copyleft — only the MPL'd files themselves carry obligations, + # combinations with proprietary code are explicitly allowed (MPL-2.0 §3.3). + "MPL-2.0", + "Mozilla Public License 2.0 (MPL 2.0)", +] + +# Composite SPDX expressions that resolve to an allowed arm. The checker +# splits on " OR " / " AND " and accepts the expression if EVERY AND-arm +# resolves to an allowed license, OR ANY OR-arm does. +# (Handled in the script — listed here for documentation.) +[allowed.composite_examples] +explained = [ + "Apache-2.0 OR BSD-3-Clause", # accept Apache-2.0 arm + "Apache-2.0 OR MIT", # accept either + "Apache-2.0 AND BSD-2-Clause", # both permissive — accept + "Apache-2.0 AND MIT", # both permissive — accept + "MIT AND Python-2.0", # both permissive — accept + "GPL-3.0-or-later OR Apache-2.0", # accept Apache-2.0 arm + "Apache Software License; BSD License",# pip-licenses style — accept + "Apache Software License; MIT License", + "MIT License; Mozilla Public License 2.0 (MPL 2.0)", +] + +# Strong copyleft (GPL/AGPL) and proprietary licenses are NOT allowed by +# default. Each package below has been individually reviewed and accepted +# with a recorded rationale. Re-review whenever the version changes. +[exceptions] + +[exceptions.PyMuPDF] +license = "Dual Licensed - GNU AFFERO GPL 3.0 or Artifex Commercial License" +rationale = """ +Dual-licensed AGPL-3 / Artifex Commercial. Compatible with our AGPL release +out of the box. For any future commercial/proprietary build, Astrolabe Cloud +must obtain an Artifex commercial license — track this requirement before +shipping a non-AGPL artifact. +""" +allowed_for = ["agpl"] +review_required_for = ["commercial"] + +[exceptions.pymupdf4llm] +license = "Dual Licensed - GNU AFFERO GPL 3.0 or Artifex Commercial License" +rationale = "Same Artifex dual-license terms as PyMuPDF — see PyMuPDF entry." +allowed_for = ["agpl"] +review_required_for = ["commercial"] + +[exceptions."pymupdf-layout"] +license = "Other/Proprietary License" +rationale = """ +Artifex proprietary commercial-only package (LICENSE/COPYING contains only +'Commercial license. See artifex.com for details.'). Currently declared in +pyproject.toml but NOT imported anywhere in nextcloud_mcp_server/ — candidate +for removal. If retained, requires an explicit Artifex commercial agreement +even for the AGPL build. +""" +allowed_for = [] +review_required_for = ["agpl", "commercial"] +action = "remove-or-license" + +[exceptions."icalendar-searcher"] +license = "AGPL-3.0-or-later" +rationale = """ +Pure AGPL — fine for our AGPL distribution but blocks commercial dual-licensing. +Either replace with a permissive alternative or negotiate a separate license +with the upstream author before any commercial release. +""" +allowed_for = ["agpl"] +review_required_for = ["commercial"] + +# Packages whose license metadata is wrong/missing in the wheel. The actual +# license file shipped inside the wheel is recorded here. +[overrides] + +[overrides.fastembed] +declared = "Other/Proprietary License" # classifier is wrong +actual = "Apache-2.0" +source = ".dist-info/licenses/LICENSE — full Apache-2.0 text" + +[overrides."matplotlib-inline"] +declared = "UNKNOWN" +actual = "BSD-3-Clause" +source = ".dist-info/licenses/LICENSE — BSD 3-Clause, IPython Development Team" + +[overrides.py_rust_stemmers] +declared = "UNKNOWN" +actual = "MIT" +source = ".dist-info/licenses/LICENSE — MIT, Copyright (c) 2024 qdrant" + +# Packages explicitly NOT allowed under any circumstance. Kept here so the +# checker emits a clear message instead of a generic 'unknown license'. +[denied] +licenses = [ + # Strong copyleft without dual-licensing — would force AGPL on commercial builds + "GPL-2.0-only", + "GPL-3.0-only", + "GPL-3.0-or-later", + "GNU General Public License v3 (GPLv3)", + "GNU General Public License v2 (GPLv2)", + # SSPL is non-OSI and known-incompatible with our distribution model + "SSPL", + "Server Side Public License", +] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d1277d9f4..06778632e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,6 +30,32 @@ that you are permitted to contribute under the CLA. For larger organizations, a separate Corporate CLA may be required — open an issue if this applies to you. +## Adding a Dependency + +The project distributes under AGPL-3.0 today and Astrolabe Cloud intends to +offer commercial dual-licensed builds in the future, so every direct or +transitive dependency must be licensed under terms that allow both. The +allowlist and per-package exceptions live in +[`.licenses/policy.toml`](./.licenses/policy.toml) and are enforced by the +**License check** workflow on every PR. + +Before opening a PR that adds or upgrades a dependency: + +```bash +uv run scripts/check-licenses.py +``` + +If the script flags a new package, either: + +- the package is permissively licensed → add the license string to the + `[allowed]` block in `.licenses/policy.toml`, **or** +- the package is copyleft / dual-licensed and you have a justification → add + it under `[exceptions]` with `rationale`, `allowed_for`, and + `review_required_for` fields, **or** +- the metadata is wrong → add an `[overrides]` entry pointing at the actual + license file shipped in the wheel, **or** +- replace the dep with a permissively-licensed alternative. + ## Version Management This project uses [commitizen](https://commitizen-tools.github.io/commitizen/) for version management following PEP 440 (`major_version_zero = true`, 0.x.x for pre-1.0). diff --git a/pyproject.toml b/pyproject.toml index 28692dd59..e489b1bf4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,6 +122,7 @@ dev = [ "reportlab>=4.0.0", "ty>=0.0.1a25", "pytest-otel>=2.0.1", + "pip-licenses>=5.5.5", ] [project.scripts] diff --git a/scripts/check-licenses.py b/scripts/check-licenses.py new file mode 100755 index 000000000..b85d6ad1d --- /dev/null +++ b/scripts/check-licenses.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""License compliance checker for nextcloud-mcp-server. + +Walks every installed Python package, classifies each license against the +policy in `.licenses/policy.toml`, and exits non-zero if a package falls +outside policy. Designed to run in CI on every PR. + +Usage: + uv run scripts/check-licenses.py # human report + uv run scripts/check-licenses.py --json # machine output + uv run scripts/check-licenses.py --markdown FILE # write GH summary +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +import tomllib +from dataclasses import dataclass, field +from pathlib import Path + +POLICY_PATH = Path(__file__).resolve().parent.parent / ".licenses" / "policy.toml" + + +@dataclass +class Policy: + allowed: set[str] + denied: set[str] + exceptions: dict[str, dict] + overrides: dict[str, dict] + + @classmethod + def load(cls, path: Path) -> Policy: + with path.open("rb") as fh: + raw = tomllib.load(fh) + return cls( + allowed={_norm(s) for s in raw.get("allowed", {}).get("licenses", [])}, + denied={_norm(s) for s in raw.get("denied", {}).get("licenses", [])}, + exceptions={k.lower(): v for k, v in raw.get("exceptions", {}).items()}, + overrides={k.lower(): v for k, v in raw.get("overrides", {}).items()}, + ) + + +@dataclass +class Verdict: + name: str + version: str + declared: str + effective: str + status: str # allowed | denied | exception | override | unknown + reason: str = "" + detail: dict = field(default_factory=dict) + + +def _norm(s: str) -> str: + """Lower-case, strip whitespace — used for license-string comparison.""" + return re.sub(r"\s+", " ", s.strip().lower()) + + +# Use uppercase-only OR/AND to avoid matching "or later" inside legacy +# classifier names like "GNU Lesser General Public License v3 or later". +_OR_RE = re.compile(r"\s+OR\s+|\s*;\s*") +_AND_RE = re.compile(r"\s+AND\s+") + + +def _first_line(license_str: str) -> str: + """Return the first non-empty line. + + Some packages (e.g. pythonvCard4) embed the entire LICENSE file body + into their `License` metadata field. The license *name* is normally on + line 1 (`MIT License`); subsequent lines are copyright/conditions. + """ + for line in license_str.splitlines(): + if line.strip(): + return line.strip() + return license_str.strip() + + +def _arms(license_str: str) -> tuple[list[str], str]: + """Split a composite SPDX/legacy license expression into arms. + + Returns (arms, mode) where mode is "or" or "and". `;` is treated as OR + (this matches pip-licenses' classifier-joining behaviour: a package with + multiple `License ::` classifiers gets them joined by `; `, and the + package is licensed under any of them). + """ + cleaned = _first_line(license_str) + if _AND_RE.search(cleaned) and not _OR_RE.search(cleaned): + return [a.strip() for a in _AND_RE.split(cleaned) if a.strip()], "and" + return [a.strip() for a in _OR_RE.split(cleaned) if a.strip()], "or" + + +def classify(pkg: dict, policy: Policy) -> Verdict: + name = pkg["Name"] + declared = pkg.get("License", "UNKNOWN") or "UNKNOWN" + + # 1. Per-package metadata override (mis-classified upstream). + if (ov := policy.overrides.get(name.lower())) is not None: + effective = ov["actual"] + status, reason = _check_license(effective, policy) + return Verdict( + name, + pkg["Version"], + declared, + effective, + "override" if status == "allowed" else status, + f"override: {ov.get('source', 'metadata corrected')}", + detail=ov, + ) + + # 2. Per-package exception (e.g. AGPL/dual-licensed deps we accept). + if (ex := policy.exceptions.get(name.lower())) is not None: + return Verdict( + name, + pkg["Version"], + declared, + ex.get("license", declared), + "exception", + ex.get("rationale", "explicit exception").strip().splitlines()[0], + detail=ex, + ) + + # 3. Match declared license against policy. + status, reason = _check_license(declared, policy) + return Verdict(name, pkg["Version"], declared, declared, status, reason) + + +def _check_license(license_str: str, policy: Policy) -> tuple[str, str]: + arms, mode = _arms(license_str) + if not arms: + return "unknown", "empty license string" + + norm_arms = [_norm(a) for a in arms] + + if mode == "and": + # Every arm must be allowed and none may be denied. + if any(a in policy.denied for a in norm_arms): + bad = next(a for a in norm_arms if a in policy.denied) + return "denied", f"AND-combined with denied license: {bad!r}" + bad = [a for a, n in zip(arms, norm_arms) if n not in policy.allowed] + if bad: + return "unknown", f"AND-arm not in allowlist: {bad}" + return "allowed", "all AND-arms allowed" + + # OR: any allowed arm satisfies the policy — denied arms are tolerated + # because the user can elect to receive the package under the allowed arm. + good = [a for a, n in zip(arms, norm_arms) if n in policy.allowed] + if good: + return "allowed", f"OR-arm allowed: {good[0]!r}" + if all(n in policy.denied for n in norm_arms): + return "denied", f"all OR-arms denied: {arms}" + return "unknown", f"no OR-arm in allowlist: {arms}" + + +def collect_packages(self_pkg: str | None) -> list[dict]: + out = subprocess.check_output( + ["uv", "run", "--frozen", "pip-licenses", "--format=json", "--with-urls"], + text=True, + ) + packages = json.loads(out) + if self_pkg: + packages = [p for p in packages if p["Name"].lower() != self_pkg.lower()] + return packages + + +def render_markdown(verdicts: list[Verdict]) -> str: + bad = [v for v in verdicts if v.status in ("denied", "unknown")] + review = [v for v in verdicts if v.status == "exception"] + overrides = [v for v in verdicts if v.status == "override"] + ok = [v for v in verdicts if v.status == "allowed"] + + lines: list[str] = [] + lines.append("# License compliance report\n") + lines.append( + f"- Total packages scanned: **{len(verdicts)}**\n" + f"- Allowed: **{len(ok)}**\n" + f"- Allowed via metadata override: **{len(overrides)}**\n" + f"- Allowed via per-package exception: **{len(review)}**\n" + f"- **Failures: {len(bad)}**\n" + ) + + if bad: + lines.append("\n## ❌ Failures (blocking)\n") + lines.append("| Package | Version | License | Reason |") + lines.append("|---|---|---|---|") + for v in bad: + lines.append(f"| `{v.name}` | {v.version} | {v.declared} | {v.reason} |") + + if review: + lines.append("\n## ⚠️ Per-package exceptions (review on version bump)\n") + lines.append("| Package | Version | License | Allowed for | Review for |") + lines.append("|---|---|---|---|---|") + for v in review: + d = v.detail + lines.append( + f"| `{v.name}` | {v.version} | {v.effective} | " + f"{', '.join(d.get('allowed_for', []) or ['—'])} | " + f"{', '.join(d.get('review_required_for', []) or ['—'])} |" + ) + + if overrides: + lines.append("\n## 🔧 Metadata overrides\n") + lines.append("| Package | Version | Declared | Actual | Source |") + lines.append("|---|---|---|---|---|") + for v in overrides: + d = v.detail + lines.append( + f"| `{v.name}` | {v.version} | {v.declared} | " + f"{d.get('actual', v.effective)} | {d.get('source', '')} |" + ) + + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--json", action="store_true", help="Emit JSON to stdout") + parser.add_argument( + "--markdown", + type=Path, + help="Write a Markdown report to the given path (e.g. $GITHUB_STEP_SUMMARY)", + ) + parser.add_argument( + "--self-name", + default="nextcloud-mcp-server", + help="Package name to skip (the project itself)", + ) + args = parser.parse_args() + + policy = Policy.load(POLICY_PATH) + packages = collect_packages(args.self_name) + verdicts = [classify(p, policy) for p in packages] + verdicts.sort(key=lambda v: (v.status, v.name.lower())) + + if args.markdown: + args.markdown.write_text(render_markdown(verdicts)) + + if args.json: + json.dump( + [v.__dict__ for v in verdicts], + sys.stdout, + indent=2, + default=str, + ) + sys.stdout.write("\n") + else: + sys.stdout.write(render_markdown(verdicts)) + + failures = [v for v in verdicts if v.status in ("denied", "unknown")] + if failures: + sys.stderr.write( + f"\n{len(failures)} package(s) violate license policy " + f"(see report above). Update .licenses/policy.toml or replace the dep.\n" + ) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock index 57dabd83e..21fc2bf24 100644 --- a/uv.lock +++ b/uv.lock @@ -2138,6 +2138,7 @@ dev = [ { name = "commitizen" }, { name = "datasets" }, { name = "ipython" }, + { name = "pip-licenses" }, { name = "playwright" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -2192,6 +2193,7 @@ dev = [ { name = "commitizen", specifier = ">=4.8.2" }, { name = "datasets", specifier = ">=3.3.0" }, { name = "ipython", specifier = ">=9.2.0" }, + { name = "pip-licenses", specifier = ">=5.5.5" }, { name = "playwright", specifier = ">=1.49.1" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-cov", specifier = ">=6.1.1" }, @@ -2768,6 +2770,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" }, ] +[[package]] +name = "pip-licenses" +version = "5.5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prettytable" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/18/ddd93af610a04f56a51a27095ddfe55238e1ec236f6758730a0d2c0b49f2/pip_licenses-5.5.5.tar.gz", hash = "sha256:60750c006adf7a0910347b726e8ee9fee3bc8d2e7c8307a5c4ec0776c8e2a276", size = 54955, upload-time = "2026-03-28T22:12:56.48Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/9a/6acfdb8d463eac7cdae7534d35d72237eca63f5fbafe797289d8a5fae447/pip_licenses-5.5.5-py3-none-any.whl", hash = "sha256:f4c4c6d9e6a03612cf59f29f19dc8ab54904d82e055b8e191498f2279a224e14", size = 23247, upload-time = "2026-03-28T22:12:54.89Z" }, +] + [[package]] name = "playwright" version = "1.56.0" @@ -2808,6 +2822,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424, upload-time = "2025-06-14T13:20:38.083Z" }, ] +[[package]] +name = "prettytable" +version = "3.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/45/b0847d88d6cfeb4413566738c8bbf1e1995fad3d42515327ff32cc1eb578/prettytable-3.17.0.tar.gz", hash = "sha256:59f2590776527f3c9e8cf9fe7b66dd215837cca96a9c39567414cbc632e8ddb0", size = 67892, upload-time = "2025-11-14T17:33:20.212Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.0-py3-none-any.whl", hash = "sha256:aad69b294ddbe3e1f95ef8886a060ed1666a0b83018bbf56295f6f226c43d287", size = 34433, upload-time = "2025-11-14T17:33:19.093Z" }, +] + [[package]] name = "prometheus-client" version = "0.23.1"