From 7096ad9be5ad37ef58b75dcd3a0cdcb41e4b4ca2 Mon Sep 17 00:00:00 2001 From: abrichr Date: Sun, 26 Jul 2026 15:22:48 -0400 Subject: [PATCH] feat: platform release manifest v1 + open-core licensing boundary Roadmap Sections 13 and 22. Section 13 (platform release manifest, pragmatic v1): - platform-manifest.json: one authoritative machine-readable manifest of launcher/flow/capture/desktop versions, PyPI artifact URLs + sha256, supported OSes, substrate drivers, compatibility ranges, qualification evidence IDs, and release channel. - scripts/generate_platform_manifest.py: generates the manifest from the real published sources (PyPI JSON API, openadapt.ai/status.json, pyproject.toml); fails loudly instead of inventing numbers. - scripts/validate_platform_manifest.py: CI drift detector; fails when the manifest disagrees with published artifacts or pyproject pins. - signature block is honestly null with status 'unsigned (signing infrastructure pending)'; the Authenticode / Developer ID / sigstore plan is documented, and the validator rejects any claimed signature until verification exists. - docs/platform-manifest.md: schema, generation, validation, signing plan. - .github/workflows/platform-manifest.yml: PR + weekly scheduled runs. Section 22 (licensing and contribution boundary): - TRADEMARKS.md: name/logo not covered by MIT; nominative use OK; no confusingly similar names; contact for permission. - scripts/check_source_boundary.py: denylist path/content check keeping application-specific recipes, customer fixtures, and proprietary system identifiers out of public core repos; wired into CI. - CONTRIBUTING.md: open-core boundary for contributors + DCO note (CLA under consideration, not adopted). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM --- .github/workflows/platform-manifest.yml | 50 ++++ CONTRIBUTING.md | 34 +++ TRADEMARKS.md | 41 +++ docs/platform-manifest.md | 125 ++++++++++ platform-manifest.json | 184 ++++++++++++++ scripts/check_source_boundary.py | 211 ++++++++++++++++ scripts/generate_platform_manifest.py | 297 ++++++++++++++++++++++ scripts/validate_platform_manifest.py | 318 ++++++++++++++++++++++++ 8 files changed, 1260 insertions(+) create mode 100644 .github/workflows/platform-manifest.yml create mode 100644 TRADEMARKS.md create mode 100644 docs/platform-manifest.md create mode 100644 platform-manifest.json create mode 100644 scripts/check_source_boundary.py create mode 100644 scripts/generate_platform_manifest.py create mode 100644 scripts/validate_platform_manifest.py diff --git a/.github/workflows/platform-manifest.yml b/.github/workflows/platform-manifest.yml new file mode 100644 index 000000000..43e064387 --- /dev/null +++ b/.github/workflows/platform-manifest.yml @@ -0,0 +1,50 @@ +name: Platform manifest and source boundary + +# Validates platform-manifest.json against the real published artifacts +# (PyPI, status.json) and enforces the open-core source boundary. +# See docs/platform-manifest.md and scripts/check_source_boundary.py. + +on: + pull_request: + branches: + - '**' + push: + branches: + - main + schedule: + # Weekly drift check: a component release without a manifest regeneration + # should fail here, loudly, even if no PR touches this repo. + - cron: '17 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate-platform-manifest: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: '3.12' + + - name: Validate platform manifest against published artifacts + run: python scripts/validate_platform_manifest.py + + check-source-boundary: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: '3.12' + + - name: Check open-core source boundary + run: python scripts/check_source_boundary.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9bcd4fcf6..d83af5eb8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,6 +43,40 @@ replay, repair, policy, and backend work belongs there. - Update documentation as needed - Keep PRs focused and small +## Licensing and the open-core boundary + +OpenAdapt is open-core. The public repositories (this launcher, +`openadapt-flow`, `openadapt-capture`, `openadapt-desktop`, and the other +`openadapt-*` engine repos) are MIT licensed: the mechanism and the public +interfaces are open. The hosted control plane (OpenAdapt Cloud) is a +proprietary commercial service, and certain data and empirical tuning stay +private by policy. + +What this means for contributions to the public repos: + +- Do not contribute application-specific recipes, customer fixtures, or + proprietary system identifiers derived from real deployments (for example, + automation content tied to a specific customer's EHR configuration). CI + runs `scripts/check_source_boundary.py` to reject that class of content. +- Do not contribute deployment-derived corpora, tuned adversary parameters, + thresholds, oracle or connector recipes, or datasets tied to real systems + of record. Synthetic, reproducible fixtures are welcome. +- Do not copy or vendor GPL/AGPL/SSPL or otherwise non-MIT-compatible + material into these repositories or their built packages. +- The OpenAdapt name and logo are trademarks and are not covered by the MIT + License; see [TRADEMARKS.md](TRADEMARKS.md). + +## Developer Certificate of Origin + +By submitting a contribution you certify the +[Developer Certificate of Origin (DCO) 1.1](https://developercertificate.org/): +that you wrote the contribution or otherwise have the right to submit it +under the MIT License. Signing off your commits (`git commit -s`) is +appreciated but the certification applies to every contribution regardless. + +A Contributor License Agreement (CLA) is under consideration but has not +been adopted; today the DCO plus the MIT License govern contributions. + ## Questions? - [Discord](https://discord.gg/yF527cQbDG) diff --git a/TRADEMARKS.md b/TRADEMARKS.md new file mode 100644 index 000000000..146386361 --- /dev/null +++ b/TRADEMARKS.md @@ -0,0 +1,41 @@ +# OpenAdapt Trademark Policy + +The OpenAdapt name, the OpenAdapt logo, and the openadapt.ai domain are +trademarks of MLDSAI Inc. (operating as OpenAdapt.AI). + +The MIT License that covers the source code in this repository and the other +public OpenAdapt repositories covers the code only. It does not grant any +rights to use the OpenAdapt name or logo. + +## What you may do without asking + +- Nominative use: accurately referring to OpenAdapt to describe, review, + document, teach, or compare it ("built on OpenAdapt", "compatible with + OpenAdapt", "a plugin for OpenAdapt"), as long as the use is truthful and + does not suggest sponsorship or endorsement by MLDSAI Inc. +- Unmodified redistribution: distributing unmodified official OpenAdapt + packages under their original names, as package managers do. +- Forks for development: keeping the repository name when forking on GitHub + for the purpose of contributing back. + +## What requires permission + +- Using "OpenAdapt" or a confusingly similar name (for example + "OpenAdaptPro", "OpenAdapt2", "OpenAdaption") in the name of your own + product, service, company, domain name, or package. +- Publishing a modified fork under the OpenAdapt name in a way that users + could mistake for an official release. If you ship a modified version, + give it your own name and state that it is derived from OpenAdapt. +- Using the OpenAdapt logo, or a modified version of it, as your own mark or + in a way that implies affiliation, sponsorship, or endorsement. +- Merchandise bearing the name or logo. + +## Contact + +For permission requests or questions about this policy, email +hello@openadapt.ai. + +This policy is intended to protect users from confusion about what is and is +not official OpenAdapt software. It follows common open source trademark +practice and does not restrict any rights granted by the MIT License to the +code itself. diff --git a/docs/platform-manifest.md b/docs/platform-manifest.md new file mode 100644 index 000000000..688ae83b2 --- /dev/null +++ b/docs/platform-manifest.md @@ -0,0 +1,125 @@ +# OpenAdapt platform release manifest + +`platform-manifest.json` at the repository root is the single authoritative, +machine-readable statement of what constitutes an OpenAdapt platform release: +which launcher, flow, capture, and desktop versions belong together, where +their published artifacts live (with sha256 digests), which operating systems +and substrate drivers are supported, what qualification evidence backs the +release, and what release channel it is on. + +## Why it lives here + +This repository (`OpenAdaptAI/OpenAdapt`) is the launcher/meta-package: it is +the one place that already pins `openadapt-flow` and the other components via +`pyproject.toml`, and it is the integration surface users install. The +platform manifest is therefore generated and versioned here, next to the pins +it must agree with. Other repositories contribute source data only: + +- PyPI is the authority for published versions, artifact URLs, and digests. +- `https://openadapt.ai/status.json` (maintained in `openadapt-web`, + `public/status.json`) is the authority for substrate availability, release + channel, and the public qualification summary. +- `openadapt-flow` holds the versioned qualification evidence packs the + manifest points at (`public-demo/evidence-packs/*/manifest.json` and the + effectbench task pack manifest). + +## How it is generated + +```bash +python scripts/generate_platform_manifest.py +``` + +The generator reads the real published state (PyPI JSON API, the live +status.json) plus this repository's `pyproject.toml`. It never invents +numbers: if a source is unreachable, empty, or disagrees with the repository, +it fails loudly. If a launcher release train is in flight (pyproject.toml +ahead of PyPI), pass `--allow-unreleased-launcher`; the manifest still records +the published version. + +Regenerate the manifest after each component release and commit the result. + +## How it is validated + +```bash +python scripts/validate_platform_manifest.py +``` + +CI runs this on every pull request and on a schedule +(`.github/workflows/platform-manifest.yml`), so drift between the committed +manifest and the actually published artifacts fails loudly. + +Validated today: + +- Structure: manifest kind, schema major version, required fields, artifacts + with sha256 digests per component. +- Signature honesty: while `signature.value` is null, `signature.status` must + read `unsigned (signing infrastructure pending)`. A non-null signature + value fails validation because no verification path exists yet. +- Repo agreement: launcher version and openadapt-* compatibility ranges match + `pyproject.toml`. +- Published-artifact agreement: component versions, artifact filenames, URLs, + and sha256 digests match PyPI exactly (`--offline` skips this class). +- Status skew: disagreement with `status.json` versions is a warning by + default (status.json regenerates on its own cadence in `openadapt-web`); + `--strict-status` escalates it to a failure. + +Not validated yet (planned): + +- Cryptographic signature verification (see the signing plan below). +- Desktop OS installer artifacts (MSI/DMG); today only PyPI artifacts exist. + +## Schema (v1) + +Top-level fields: + +| Field | Meaning | +|-------|---------| +| `manifest_kind` | Always `openadapt-platform-release-manifest`. | +| `schema_version` | Semver of this schema; validators reject unknown majors. | +| `generated_at` | UTC timestamp of generation. | +| `release_channel` | Lowercased product lifecycle from status.json (currently `beta`). | +| `components` | `launcher`, `flow`, `capture`, `desktop`: package name, published version, `requires_python`, and artifacts (`type`, `filename`, `url`, `sha256`). | +| `compatibility` | The launcher's supported Python range and its real openadapt-* dependency specifiers, extracted from `pyproject.toml`. | +| `supported_os` | Operating systems the launcher supports. | +| `substrate_drivers` | Substrate table (name, public label, delivery) read from status.json. | +| `qualification_evidence` | Stable evidence IDs pointing at the public status document and the flow evidence-pack manifests. | +| `signature` | See below. | + +## Signing plan + +The `signature` block is present in the schema from day one, but it is +honestly null: + +```json +{ + "algorithm": null, + "value": null, + "status": "unsigned (signing infrastructure pending)", + "plan": "docs/platform-manifest.md#signing-plan" +} +``` + +We deliberately do not fake a signature or stand up theater around one. The +intended rollout, in order: + +1. Sigstore (cosign, keyless via the GitHub Actions OIDC identity) signing of + `platform-manifest.json` itself at release time, with the verification + step added to `validate_platform_manifest.py` before any signature is + ever emitted. Note that PyPI publish attestations (PEP 740, also + sigstore-backed) already exist for recent `openadapt-desktop` uploads and + provide per-artifact provenance independent of this manifest. +2. Windows Authenticode signing for desktop installers (MSI/EXE) once those + installers are produced. +3. Apple Developer ID signing plus notarization for macOS app and DMG + artifacts. + +Until step 1 lands, the validator enforces that the manifest claims nothing: +a manifest with a non-null `signature.value` fails validation. + +## Consumers + +Anything that needs "what is the current OpenAdapt platform release" as data +should read this manifest rather than scraping PyPI or hardcoding versions: +release notes tooling, the website, the desktop updater, and support +tooling. Consumers must check `schema_version` and must treat +`signature.status` as informational until signing ships. diff --git a/platform-manifest.json b/platform-manifest.json new file mode 100644 index 000000000..6cc7e209d --- /dev/null +++ b/platform-manifest.json @@ -0,0 +1,184 @@ +{ + "manifest_kind": "openadapt-platform-release-manifest", + "schema_version": "1.0.0", + "generated_at": "2026-07-26T19:20:22+00:00", + "release_channel": "beta", + "components": { + "launcher": { + "package": "openadapt", + "version": "1.7.3", + "source": "pypi", + "requires_python": "<3.13,>=3.10", + "artifacts": [ + { + "type": "bdist_wheel", + "filename": "openadapt-1.7.3-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/08/58/990f7f31360b0be723e004271b1e7016708c21627b1910f3bef751e11f45/openadapt-1.7.3-py3-none-any.whl", + "sha256": "f173f1454802bc01c966f973755d6657af9b968cbc0318483d75751adc79b9d0" + }, + { + "type": "sdist", + "filename": "openadapt-1.7.3.tar.gz", + "url": "https://files.pythonhosted.org/packages/d1/f8/fed8b98bdc39376e7bd1aa9cccba22a415f54eb225b1f262d8126d9353d7/openadapt-1.7.3.tar.gz", + "sha256": "5a7d63bfacea0b579d06d48886348ce0bce38db7c55e04839ea9d860c36e6d79" + } + ] + }, + "flow": { + "package": "openadapt-flow", + "version": "1.23.0", + "source": "pypi", + "requires_python": "<3.13,>=3.10", + "artifacts": [ + { + "type": "bdist_wheel", + "filename": "openadapt_flow-1.23.0-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/54/f7/632b13c60ba805cfd22614dc4c73285c06c2b7d698c124d9c45104623c18/openadapt_flow-1.23.0-py3-none-any.whl", + "sha256": "b5d10dfc294479866d6dddd4c1a6afc9164414f0634703173b7e440dc0133bec" + }, + { + "type": "sdist", + "filename": "openadapt_flow-1.23.0.tar.gz", + "url": "https://files.pythonhosted.org/packages/f6/e5/8957790965a0fc5cde4e8f8dc8664c3db682d1b09881abf05f013fd3f495/openadapt_flow-1.23.0.tar.gz", + "sha256": "b16e181ac1f8b84f825b418ca9c6608cd408bde9ccd94d0a7e62db515d2145be" + } + ] + }, + "capture": { + "package": "openadapt-capture", + "version": "1.1.1", + "source": "pypi", + "requires_python": ">=3.10", + "artifacts": [ + { + "type": "bdist_wheel", + "filename": "openadapt_capture-1.1.1-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/3b/66/f6ea4cb9a5e9515370e59574c7caf8f827807af877d251db17047010c871/openadapt_capture-1.1.1-py3-none-any.whl", + "sha256": "76f6fdc9bd81dd3a72445fd8ebd918654a732765d5aae848d5f56dc62d1bf97d" + }, + { + "type": "sdist", + "filename": "openadapt_capture-1.1.1.tar.gz", + "url": "https://files.pythonhosted.org/packages/6f/65/11c5c49eeee83b10595c87269f83d1d1dbc6f4e2e04f51885441d064e295/openadapt_capture-1.1.1.tar.gz", + "sha256": "7022d41b37f3a8fbf30f5e89df2373d3bfacc0e13ab8c9b6c1770f76126fbd6b" + } + ] + }, + "desktop": { + "package": "openadapt-desktop", + "version": "0.14.0", + "source": "pypi", + "requires_python": ">=3.11", + "artifacts": [ + { + "type": "bdist_wheel", + "filename": "openadapt_desktop-0.14.0-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/dc/5f/f2cf2d6fdaab8a400e95065d2f4e1810665b3a9352879967fab5c9e9f957/openadapt_desktop-0.14.0-py3-none-any.whl", + "sha256": "7ee5a1d2e00f5cbd887edd070916e53530c9548b04713a1aa7d9960f2d313082" + }, + { + "type": "sdist", + "filename": "openadapt_desktop-0.14.0.tar.gz", + "url": "https://files.pythonhosted.org/packages/d9/47/64ceeecd28345145ee3ee5cd796806f14fc5c339c52778ae41507661ec76/openadapt_desktop-0.14.0.tar.gz", + "sha256": "44a50bd2c126524a76b4d0c0eb0bed9f24667940f735eed1dfed03dce7e9862b" + } + ] + } + }, + "compatibility": { + "python": ">=3.10,<3.13", + "launcher_requires": { + "openadapt-capture": [ + ">=1.0.4,<2.0.0" + ], + "openadapt-evals": [ + ">=0.1.0" + ], + "openadapt-flow": [ + ">=1.20.1,<2.0.0" + ], + "openadapt-grounding": [ + ">=0.1.0" + ], + "openadapt-ml": [ + ">=0.2.0" + ], + "openadapt-retrieval": [ + ">=0.1.0" + ], + "openadapt-viewer": [ + ">=0.1.0" + ] + } + }, + "supported_os": [ + "windows", + "macos", + "linux" + ], + "substrate_drivers": [ + { + "name": "Browser", + "public_label": "Available", + "delivery": "Local, managed OpenAdapt Cloud, or customer-controlled" + }, + { + "name": "Windows", + "public_label": "Available", + "delivery": "Local or customer-controlled" + }, + { + "name": "macOS", + "public_label": "Available", + "delivery": "Local or customer-controlled" + }, + { + "name": "Linux", + "public_label": "Available", + "delivery": "Local or customer-controlled" + }, + { + "name": "RDP", + "public_label": "Available", + "delivery": "Local or customer-controlled" + }, + { + "name": "Citrix / VDI", + "public_label": "Available", + "delivery": "Local or customer-controlled" + }, + { + "name": "Hosted Cloud", + "public_label": "Beta", + "delivery": "Managed browser runner and control plane" + } + ], + "qualification_evidence": [ + { + "id": "openadapt-public-status", + "kind": "status-document", + "url": "https://openadapt.ai/status.json", + "description": "Canonical machine-readable release, capability, and substrate qualification status, maintained in openadapt-web (public/status.json)." + }, + { + "id": "flow-public-demo-evidence-packs", + "kind": "evidence-pack-collection", + "repository": "OpenAdaptAI/openadapt-flow", + "path": "public-demo/evidence-packs", + "description": "Versioned public evidence packs (for example mockmed-triage-v3) with per-pack manifest.json files: synthetic-patient replay evidence including fault-injection trials." + }, + { + "id": "flow-effectbench-task-pack", + "kind": "benchmark-manifest", + "repository": "OpenAdaptAI/openadapt-flow", + "path": "benchmark/effectbench/task_pack/manifest.json", + "description": "Effect-verification benchmark task pack manifest used by the flow qualification harness." + } + ], + "signature": { + "algorithm": null, + "value": null, + "status": "unsigned (signing infrastructure pending)", + "plan": "docs/platform-manifest.md#signing-plan" + } +} diff --git a/scripts/check_source_boundary.py b/scripts/check_source_boundary.py new file mode 100644 index 000000000..39f516357 --- /dev/null +++ b/scripts/check_source_boundary.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +"""Fail CI when deployment-specific or private-boundary content enters a +public core repository. + +Motivating case: the PowerChart incident. Application-specific workflow +content tied to a real customer deployment of an EHR (Cerner PowerChart) +reached a public core surface and had to be excised by hand after review +caught it. Application-specific recipes, customer fixtures, and proprietary +system identifiers derived from real deployments are exactly the class of +content the open-core boundary keeps private (see the workspace +source-policy.yaml: grown corpora, tuned adversary parameters, +deployment-derived thresholds, oracle/connector recipes, and real-EMR-tied +datasets are crown jewels). This check makes that class of leak a CI failure +instead of a code-review coin flip. + +It complements, and deliberately overlaps with, the packaging-time guard in +openadapt-flow's scripts/check_release_consistency.py (which inspects built +wheels/sdists). This script inspects the REPOSITORY TREE, so the leak is +caught at PR time in any public core repo, not only when an artifact is +built. + +Two kinds of matching, so a rename cannot dodge the check: + +* Path tokens: any tracked file whose path contains a denylisted token fails. +* Content signatures: any tracked text file whose contents match a + denylisted pattern fails. + +Files that legitimately DISCUSS the boundary (this script, policy docs, +contributor docs) are covered by the allowlist below. + +Usage: + python scripts/check_source_boundary.py [--root PATH] + +--root defaults to this repository. Point it at another checkout to run the +same gate in that repo's CI without vendoring a divergent copy. +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +DEFAULT_ROOT = Path(__file__).resolve().parents[1] + +# Path fragments that indicate private-boundary or deployment-specific +# content. Lowercase; matched case-insensitively against the full repo- +# relative path. +DENYLISTED_PATH_TOKENS = ( + # Proprietary systems of record: per-system recipes/fixtures stay private. + "powerchart", + "cerner", + "millennium_recipe", + "epic_hyperspace", + "meditech", + # Customer/deployment-derived material. + "customer_fixture", + "customer_recipe", + "client_fixture", + "deployment_corpus", + "deployment_thresholds", + "real_emr", + # Crown-jewel categories from source-policy.yaml. + "grown_corpus", + "tuned_adversary", + "adversary_corpus", + "identity_roc", + "held_out_corpus", + "oracle_recipe", + "effect_oracle_recipe", + "pixel_verify_cert", + "enterprise_productionized", + "paid_agent_evidence", + # The private repos themselves must never be vendored into a public one. + "openadapt-corpus", + "openadapt-cloud", + "openadapt-internal", +) + +# Content signatures for text files. Case-insensitive regular expressions. +DENYLISTED_CONTENT_PATTERNS = ( + # Proprietary EHR surfaces showing up inside code, fixtures, or recipes. + r"powerchart", + r"cerner\s+millennium", + r"epic\s+hyperspace", + # Markers our private artifacts carry. + r"openadapt[_-]corpus[/:]", + r"deployment[_-]derived\s+threshold\s*=", + r"oracle[_-]recipe[_-]id\s*[:=]", +) + +# Repo-relative paths (exact file or directory prefix) that may mention the +# denylisted terms because they document or enforce the boundary itself. +ALLOWLISTED_PATHS = ( + "scripts/check_source_boundary.py", + "docs/platform-manifest.md", + "CONTRIBUTING.md", + "TRADEMARKS.md", +) + +# Binary-ish extensions we skip for content scanning (path scan still applies). +SKIP_CONTENT_SUFFIXES = { + ".png", + ".jpg", + ".jpeg", + ".gif", + ".ico", + ".icns", + ".pdf", + ".zip", + ".gz", + ".whl", + ".mp4", + ".webm", + ".woff", + ".woff2", + ".ttf", + ".db", + ".sqlite", +} + +MAX_CONTENT_BYTES = 5 * 1024 * 1024 + + +def _tracked_files(root: Path) -> list[str]: + result = subprocess.run( + ["git", "ls-files", "-z"], + cwd=root, + capture_output=True, + check=True, + ) + return [p for p in result.stdout.decode().split("\0") if p] + + +def _is_allowlisted(path: str) -> bool: + return any( + path == allowed or path.startswith(allowed.rstrip("/") + "/") + for allowed in ALLOWLISTED_PATHS + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--root", + type=Path, + default=DEFAULT_ROOT, + help="Repository checkout to scan (default: this repository).", + ) + args = parser.parse_args() + root = args.root.resolve() + + try: + tracked = _tracked_files(root) + except subprocess.CalledProcessError as exc: + print(f"FATAL: git ls-files failed in {root}: {exc}", file=sys.stderr) + return 2 + + content_regex = re.compile( + "|".join(f"(?:{p})" for p in DENYLISTED_CONTENT_PATTERNS), re.IGNORECASE + ) + + violations: list[str] = [] + for rel_path in tracked: + if _is_allowlisted(rel_path): + continue + lower = rel_path.lower() + for token in DENYLISTED_PATH_TOKENS: + if token in lower: + violations.append( + f"{rel_path}: path contains denylisted token {token!r}" + ) + break + + full_path = root / rel_path + if full_path.suffix.lower() in SKIP_CONTENT_SUFFIXES or not full_path.is_file(): + continue + try: + if full_path.stat().st_size > MAX_CONTENT_BYTES: + continue + text = full_path.read_text(errors="ignore") + except OSError: + continue + match = content_regex.search(text) + if match: + line = text.count("\n", 0, match.start()) + 1 + violations.append( + f"{rel_path}:{line}: content matches denylisted pattern " + f"{match.group(0)!r}" + ) + + if violations: + print( + "Source-availability boundary violations found. Deployment-" + "specific recipes, customer fixtures, and proprietary system " + "identifiers must not enter public core repos (see docstring; " + "motivating case: the PowerChart incident).", + file=sys.stderr, + ) + for violation in violations: + print(f"ERROR: {violation}", file=sys.stderr) + return 1 + + print(f"OK: no boundary violations across {len(tracked)} tracked files.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/generate_platform_manifest.py b/scripts/generate_platform_manifest.py new file mode 100644 index 000000000..1ce294316 --- /dev/null +++ b/scripts/generate_platform_manifest.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""Generate the authoritative OpenAdapt platform release manifest. + +This script produces ``platform-manifest.json`` at the repository root: one +machine-readable statement of which component versions constitute the current +OpenAdapt platform release, where their published artifacts live, and which +qualification evidence backs them. + +Design rules (see docs/platform-manifest.md): + +* Versions, artifact URLs, and sha256 digests are read from the REAL published + sources (the PyPI JSON API). Nothing is invented or hand-typed. If a source + is unreachable or disagrees with this repository's ``pyproject.toml``, the + script fails loudly instead of guessing. +* Substrate/driver availability and the release channel are read from the + canonical public status document, https://openadapt.ai/status.json, which is + maintained in openadapt-web and already drives status-aware public surfaces. +* The ``signature`` block is intentionally null. OpenAdapt does not yet run + manifest-signing infrastructure and this manifest refuses to pretend + otherwise. The signing plan is documented in docs/platform-manifest.md. + +Usage: + python scripts/generate_platform_manifest.py [--output PATH] + [--allow-unreleased-launcher] + +``--allow-unreleased-launcher`` permits the in-repo launcher version to be +ahead of the latest PyPI release (the normal state mid-release-train). The +manifest always records the PUBLISHED version, never the unreleased one. +""" + +from __future__ import annotations + +import argparse +import datetime as _dt +import json +import sys +import urllib.error +import urllib.request +from pathlib import Path + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - Python < 3.11 + import tomli as tomllib # type: ignore[no-redef] + +ROOT = Path(__file__).resolve().parents[1] + +SCHEMA_VERSION = "1.0.0" +MANIFEST_KIND = "openadapt-platform-release-manifest" +STATUS_URL = "https://openadapt.ai/status.json" +PYPI_URL_TEMPLATE = "https://pypi.org/pypi/{package}/json" +HTTP_TIMEOUT_SECONDS = 30 + +# The platform components this manifest pins. The launcher (this repository) +# is the integration point; the others are the runtime, the native recorder, +# and the desktop shell. All four publish to PyPI. +COMPONENTS: dict[str, str] = { + "launcher": "openadapt", + "flow": "openadapt-flow", + "capture": "openadapt-capture", + "desktop": "openadapt-desktop", +} + +# Operating systems the launcher supports, mirroring pyproject extras +# (windows/macos/linux) and the substrate table in status.json. +SUPPORTED_OS = ["windows", "macos", "linux"] + +# Qualification evidence pointers. IDs are stable; consumers resolve them to +# the referenced documents. The public status document is the live summary; +# the flow evidence packs are versioned, replayable evidence bundles. +QUALIFICATION_EVIDENCE = [ + { + "id": "openadapt-public-status", + "kind": "status-document", + "url": STATUS_URL, + "description": ( + "Canonical machine-readable release, capability, and substrate " + "qualification status, maintained in openadapt-web " + "(public/status.json)." + ), + }, + { + "id": "flow-public-demo-evidence-packs", + "kind": "evidence-pack-collection", + "repository": "OpenAdaptAI/openadapt-flow", + "path": "public-demo/evidence-packs", + "description": ( + "Versioned public evidence packs (for example mockmed-triage-v3) " + "with per-pack manifest.json files: synthetic-patient replay " + "evidence including fault-injection trials." + ), + }, + { + "id": "flow-effectbench-task-pack", + "kind": "benchmark-manifest", + "repository": "OpenAdaptAI/openadapt-flow", + "path": "benchmark/effectbench/task_pack/manifest.json", + "description": ( + "Effect-verification benchmark task pack manifest used by the " + "flow qualification harness." + ), + }, +] + +# Honest signing placeholder. Validation enforces that this exact structure +# is present and that no signature value is claimed until real signing +# infrastructure exists. Plan: docs/platform-manifest.md ("Signing plan"). +UNSIGNED_SIGNATURE = { + "algorithm": None, + "value": None, + "status": "unsigned (signing infrastructure pending)", + "plan": "docs/platform-manifest.md#signing-plan", +} + + +class DriftError(RuntimeError): + """Raised when live sources disagree with each other or the repo.""" + + +def _fetch_json(url: str) -> dict: + request = urllib.request.Request( + url, headers={"User-Agent": "openadapt-platform-manifest-generator"} + ) + try: + with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_SECONDS) as resp: + return json.load(resp) + except (urllib.error.URLError, TimeoutError) as exc: + raise DriftError( + f"FATAL: could not fetch {url}: {exc}. " + "The manifest is generated only from live published sources; " + "refusing to invent values." + ) from exc + + +def _pypi_component(package: str) -> dict: + doc = _fetch_json(PYPI_URL_TEMPLATE.format(package=package)) + version = doc["info"]["version"] + artifacts = [] + for url_entry in doc.get("urls", []): + artifacts.append( + { + "type": url_entry["packagetype"], + "filename": url_entry["filename"], + "url": url_entry["url"], + "sha256": url_entry["digests"]["sha256"], + } + ) + if not artifacts: + raise DriftError( + f"FATAL: PyPI reports no release artifacts for {package}=={version}." + ) + return { + "package": package, + "version": version, + "source": "pypi", + "requires_python": doc["info"].get("requires_python"), + "artifacts": sorted(artifacts, key=lambda a: a["filename"]), + } + + +def _compatibility_from_pyproject() -> dict: + """Extract the launcher's real dependency pins for openadapt-* packages.""" + pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text()) + project = pyproject["project"] + requirement_strings: list[str] = list(project.get("dependencies", [])) + for extra_requirements in project.get("optional-dependencies", {}).values(): + requirement_strings.extend(extra_requirements) + + pins: dict[str, set[str]] = {} + for requirement in requirement_strings: + # Requirement grammar subset: name[extras]specifier[; marker] + bare = requirement.split(";")[0].strip() + for index, char in enumerate(bare): + if char in "><=!~ [": + name = bare[:index] + rest = bare[index:] + break + else: + name, rest = bare, "" + if not name.startswith("openadapt"): + continue + specifier = rest + if specifier.startswith("["): + close = specifier.index("]") + specifier = specifier[close + 1 :] + specifier = specifier.strip() + if specifier: + pins.setdefault(name, set()).add(specifier) + + return { + "python": project["requires-python"], + "launcher_requires": { + name: sorted(specs) for name, specs in sorted(pins.items()) + }, + } + + +def _substrates_from_status(status: dict) -> list[dict]: + substrates = [] + for entry in status.get("substrates", []): + substrates.append( + { + "name": entry["name"], + "public_label": entry["public_label"], + "delivery": entry.get("delivery"), + } + ) + if not substrates: + raise DriftError( + f"FATAL: {STATUS_URL} contains no substrates; refusing to emit an " + "empty driver table." + ) + return substrates + + +def generate(allow_unreleased_launcher: bool) -> dict: + status = _fetch_json(STATUS_URL) + + components = { + role: _pypi_component(package) for role, package in COMPONENTS.items() + } + + # Drift guard: the in-repo launcher version must match the published one, + # unless the caller explicitly acknowledges an in-flight release. + pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text()) + repo_launcher_version = pyproject["project"]["version"] + published_launcher_version = components["launcher"]["version"] + if repo_launcher_version != published_launcher_version: + message = ( + f"launcher version drift: pyproject.toml has " + f"{repo_launcher_version} but PyPI's latest is " + f"{published_launcher_version}." + ) + if not allow_unreleased_launcher: + raise DriftError( + "FATAL: " + message + " Pass --allow-unreleased-launcher only " + "if a release train is in flight; the manifest records the " + "published version either way." + ) + print(f"WARNING: {message} Recording the published version.") + + release_channel = status.get("product_lifecycle", "").lower() or None + if release_channel is None: + raise DriftError( + f"FATAL: {STATUS_URL} has no product_lifecycle; cannot determine " + "the release channel." + ) + + return { + "manifest_kind": MANIFEST_KIND, + "schema_version": SCHEMA_VERSION, + "generated_at": _dt.datetime.now(_dt.timezone.utc) + .replace(microsecond=0) + .isoformat(), + "release_channel": release_channel, + "components": components, + "compatibility": _compatibility_from_pyproject(), + "supported_os": SUPPORTED_OS, + "substrate_drivers": _substrates_from_status(status), + "qualification_evidence": QUALIFICATION_EVIDENCE, + "signature": UNSIGNED_SIGNATURE, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--output", + type=Path, + default=ROOT / "platform-manifest.json", + help="Where to write the manifest (default: repo root).", + ) + parser.add_argument( + "--allow-unreleased-launcher", + action="store_true", + help=( + "Permit pyproject.toml to be ahead of the latest PyPI launcher " + "release. The published version is recorded regardless." + ), + ) + args = parser.parse_args() + + try: + manifest = generate(args.allow_unreleased_launcher) + except DriftError as exc: + print(str(exc), file=sys.stderr) + return 1 + + args.output.write_text(json.dumps(manifest, indent=2) + "\n") + print(f"Wrote {args.output}") + for role, component in manifest["components"].items(): + print(f" {role}: {component['package']}=={component['version']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_platform_manifest.py b/scripts/validate_platform_manifest.py new file mode 100644 index 000000000..a7eb912d1 --- /dev/null +++ b/scripts/validate_platform_manifest.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +"""Validate platform-manifest.json against the real published artifacts. + +CI-runnable drift detector for the OpenAdapt platform release manifest +(see docs/platform-manifest.md). It fails loudly when the committed manifest +disagrees with reality instead of letting a stale or invented manifest ship. + +What it validates TODAY: + +* Structure: manifest kind, schema major version, required fields, and at + least one artifact with a sha256 digest per component. +* Signature honesty: while the signature value is null the status must say + "unsigned (signing infrastructure pending)". A non-null signature value + FAILS validation, because no verification infrastructure exists yet and a + claimed-but-unverifiable signature is worse than an honest null. +* Repo agreement (offline): the launcher version and the openadapt-* + compatibility ranges in the manifest match this checkout's pyproject.toml. +* Published-artifact agreement (network): for every component, the manifest + version equals the latest version on PyPI, and every artifact filename, + URL, and sha256 digest matches what PyPI reports. Run with --offline to + skip only this class of check (for example in an airgapped environment). +* Status-document agreement (network): version skew against + https://openadapt.ai/status.json is reported as a WARNING by default, + because status.json is regenerated on its own cadence in openadapt-web. + Pass --strict-status to escalate the skew to a failure. + +What it does NOT validate yet (planned, see docs/platform-manifest.md): + +* Cryptographic signature verification (sigstore for the manifest itself, + Authenticode / Apple Developer ID for OS installers). +* Desktop OS installer artifacts (MSI/DMG); only PyPI artifacts exist today. + +Usage: + python scripts/validate_platform_manifest.py [--manifest PATH] + [--offline] [--strict-status] +""" + +from __future__ import annotations + +import argparse +import json +import sys +import urllib.error +import urllib.request +from pathlib import Path + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - Python < 3.11 + import tomli as tomllib # type: ignore[no-redef] + +ROOT = Path(__file__).resolve().parents[1] + +EXPECTED_KIND = "openadapt-platform-release-manifest" +EXPECTED_SCHEMA_MAJOR = 1 +EXPECTED_UNSIGNED_STATUS = "unsigned (signing infrastructure pending)" +STATUS_URL = "https://openadapt.ai/status.json" +PYPI_URL_TEMPLATE = "https://pypi.org/pypi/{package}/json" +HTTP_TIMEOUT_SECONDS = 30 + +REQUIRED_TOP_LEVEL_FIELDS = ( + "manifest_kind", + "schema_version", + "generated_at", + "release_channel", + "components", + "compatibility", + "supported_os", + "substrate_drivers", + "qualification_evidence", + "signature", +) + + +def _fetch_json(url: str) -> dict: + request = urllib.request.Request( + url, headers={"User-Agent": "openadapt-platform-manifest-validator"} + ) + with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_SECONDS) as resp: + return json.load(resp) + + +class Report: + def __init__(self) -> None: + self.errors: list[str] = [] + self.warnings: list[str] = [] + + def error(self, message: str) -> None: + self.errors.append(message) + + def warning(self, message: str) -> None: + self.warnings.append(message) + + +def check_structure(manifest: dict, report: Report) -> None: + for field in REQUIRED_TOP_LEVEL_FIELDS: + if field not in manifest: + report.error(f"missing required top-level field: {field}") + if manifest.get("manifest_kind") != EXPECTED_KIND: + report.error( + f"manifest_kind is {manifest.get('manifest_kind')!r}, expected " + f"{EXPECTED_KIND!r}" + ) + schema_version = str(manifest.get("schema_version", "")) + major = schema_version.split(".", 1)[0] + if not major.isdigit() or int(major) != EXPECTED_SCHEMA_MAJOR: + report.error( + f"schema_version {schema_version!r} is not major version " + f"{EXPECTED_SCHEMA_MAJOR}; this validator cannot interpret it" + ) + for role, component in manifest.get("components", {}).items(): + artifacts = component.get("artifacts") or [] + if not artifacts: + report.error(f"component {role} lists no artifacts") + for artifact in artifacts: + for key in ("type", "filename", "url", "sha256"): + if not artifact.get(key): + report.error( + f"component {role} artifact " + f"{artifact.get('filename', '')} missing {key}" + ) + if not manifest.get("qualification_evidence"): + report.error("qualification_evidence is empty") + + +def check_signature_honesty(manifest: dict, report: Report) -> None: + signature = manifest.get("signature") or {} + if signature.get("value") is None: + if signature.get("status") != EXPECTED_UNSIGNED_STATUS: + report.error( + "signature.value is null but signature.status is " + f"{signature.get('status')!r}; while unsigned it must be " + f"{EXPECTED_UNSIGNED_STATUS!r}" + ) + else: + report.error( + "signature.value is set, but no signature verification " + "infrastructure exists yet. Refusing to accept an unverifiable " + "signature claim. Implement verification before signing " + "(docs/platform-manifest.md, Signing plan)." + ) + + +def check_against_pyproject(manifest: dict, report: Report) -> None: + pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text()) + project = pyproject["project"] + + launcher = manifest.get("components", {}).get("launcher", {}) + if launcher.get("version") != project["version"]: + report.error( + f"launcher version drift: manifest has {launcher.get('version')!r} " + f"but pyproject.toml has {project['version']!r}. Regenerate with " + "scripts/generate_platform_manifest.py after releasing." + ) + + compatibility = manifest.get("compatibility", {}) + if compatibility.get("python") != project["requires-python"]: + report.error( + f"python compatibility drift: manifest has " + f"{compatibility.get('python')!r} but pyproject.toml has " + f"{project['requires-python']!r}" + ) + + # Recompute the openadapt-* pins exactly as the generator does. + requirement_strings: list[str] = list(project.get("dependencies", [])) + for extra_requirements in project.get("optional-dependencies", {}).values(): + requirement_strings.extend(extra_requirements) + pins: dict[str, set[str]] = {} + for requirement in requirement_strings: + bare = requirement.split(";")[0].strip() + for index, char in enumerate(bare): + if char in "><=!~ [": + name = bare[:index] + rest = bare[index:] + break + else: + name, rest = bare, "" + if not name.startswith("openadapt"): + continue + specifier = rest + if specifier.startswith("["): + specifier = specifier[specifier.index("]") + 1 :] + specifier = specifier.strip() + if specifier: + pins.setdefault(name, set()).add(specifier) + expected = {name: sorted(specs) for name, specs in sorted(pins.items())} + actual = compatibility.get("launcher_requires") + if actual != expected: + report.error( + "compatibility.launcher_requires drift: manifest has " + f"{actual!r} but pyproject.toml implies {expected!r}" + ) + + +def check_against_pypi(manifest: dict, report: Report) -> None: + for role, component in manifest.get("components", {}).items(): + package = component.get("package") + try: + doc = _fetch_json(PYPI_URL_TEMPLATE.format(package=package)) + except (urllib.error.URLError, TimeoutError) as exc: + report.error(f"could not fetch PyPI metadata for {package}: {exc}") + continue + published_version = doc["info"]["version"] + if component.get("version") != published_version: + report.error( + f"{role} version drift: manifest has " + f"{component.get('version')!r} but PyPI's latest {package} is " + f"{published_version!r}. Regenerate the manifest." + ) + continue + published = { + entry["filename"]: (entry["url"], entry["digests"]["sha256"]) + for entry in doc.get("urls", []) + } + for artifact in component.get("artifacts", []): + filename = artifact.get("filename") + if filename not in published: + report.error( + f"{role} artifact {filename} is not among PyPI's files " + f"for {package}=={published_version}" + ) + continue + url, sha256 = published[filename] + if artifact.get("url") != url: + report.error( + f"{role} artifact {filename} URL drift: manifest has " + f"{artifact.get('url')} but PyPI has {url}" + ) + if artifact.get("sha256") != sha256: + report.error( + f"{role} artifact {filename} sha256 drift: manifest has " + f"{artifact.get('sha256')} but PyPI has {sha256}" + ) + + +def check_against_status(manifest: dict, report: Report, strict: bool) -> None: + try: + status = _fetch_json(STATUS_URL) + except (urllib.error.URLError, TimeoutError) as exc: + report.warning(f"could not fetch {STATUS_URL}: {exc}") + return + status_versions = status.get("versions", {}) + emit = report.error if strict else report.warning + for role, component in manifest.get("components", {}).items(): + status_version = status_versions.get(role) + if status_version is None: + continue + if status_version != component.get("version"): + emit( + f"{role} skew vs {STATUS_URL}: manifest has " + f"{component.get('version')!r}, status.json has " + f"{status_version!r} (status.json regenerates on its own " + "cadence in openadapt-web)" + ) + lifecycle = (status.get("product_lifecycle") or "").lower() + if lifecycle and lifecycle != manifest.get("release_channel"): + emit( + f"release_channel skew: manifest has " + f"{manifest.get('release_channel')!r}, status.json lifecycle is " + f"{lifecycle!r}" + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--manifest", type=Path, default=ROOT / "platform-manifest.json" + ) + parser.add_argument( + "--offline", + action="store_true", + help="Skip network checks against PyPI and status.json.", + ) + parser.add_argument( + "--strict-status", + action="store_true", + help="Treat status.json skew as a failure instead of a warning.", + ) + args = parser.parse_args() + + if not args.manifest.exists(): + print(f"FATAL: {args.manifest} does not exist", file=sys.stderr) + return 1 + try: + manifest = json.loads(args.manifest.read_text()) + except json.JSONDecodeError as exc: + print(f"FATAL: {args.manifest} is not valid JSON: {exc}", file=sys.stderr) + return 1 + + report = Report() + check_structure(manifest, report) + check_signature_honesty(manifest, report) + check_against_pyproject(manifest, report) + if not args.offline: + check_against_pypi(manifest, report) + check_against_status(manifest, report, strict=args.strict_status) + + for warning in report.warnings: + print(f"WARNING: {warning}") + for error in report.errors: + print(f"ERROR: {error}", file=sys.stderr) + if report.errors: + print( + f"\nFAILED: {len(report.errors)} error(s), " + f"{len(report.warnings)} warning(s).", + file=sys.stderr, + ) + return 1 + print( + f"OK: platform manifest validated " + f"({'offline checks only' if args.offline else 'including published artifacts'}); " + f"{len(report.warnings)} warning(s)." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())