diff --git a/.github/workflows/release-registry.yml b/.github/workflows/release-registry.yml new file mode 100644 index 0000000000..0421300836 --- /dev/null +++ b/.github/workflows/release-registry.yml @@ -0,0 +1,300 @@ +# Publish the skills registry: attest the index and the bundle archives. +# +# 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. +# +# - 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: + workflow_dispatch: + inputs: + suffix: + 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-${{ github.ref }} + cancel-in-progress: false + +jobs: + tag: + name: tag registry-${{ inputs.suffix }} + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: release-identity + permissions: + contents: read + steps: + - 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" + - uses: astral-sh/setup-uv@18d369b23da25a81e9458561248e1aafff6a70fa # v10.3.1 + - name: Install the engine + run: | + python scripts/ci/locked_install.py --lock 'packages/flow[dev]' \ + --editable packages/types --editable packages/flow + - name: Check the bundles + run: | + set -euo pipefail + 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 + + attest: + name: build and attest ${{ github.ref_name }} + needs: check + runs-on: ubuntu-latest + 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, 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" ] \ + || [ "$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 }} + 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 verifier wheels + run: | + python -m pip install --require-hashes --no-deps --only-binary=:all: \ + -r packages/flow/registry/verify-requirements.txt + - name: Build the archives and the stamped index + 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 \ + --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 pathlib import Path + + 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 + (cd dist-registry && sha256sum -- *.tar.gz index.json index.sigstore.json > SHA256SUMS) + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: registry-attested + path: dist-registry/ + if-no-files-found: error + + publish: + name: release ${{ github.ref_name }} + needs: attest + 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 }} + run: | + 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: main + token: ${{ steps.release-app.outputs.token }} + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: registry-attested + path: dist-registry/ + - name: Release, and propose the attested index + env: + GH_TOKEN: ${{ steps.release-app.outputs.token }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + (cd dist-registry && sha256sum --check --strict SHA256SUMS) + gh release create "$TAG" dist-registry/*.tar.gz dist-registry/index.json \ + dist-registry/index.sigstore.json dist-registry/SHA256SUMS \ + --verify-tag \ + --title "Skills registry $TAG" \ + --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.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: attested index for $TAG" + git push origin "refs/heads/$branch" + gh pr create --base main --head "$branch" \ + --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 fb3db03c26..e0c554df0a 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -264,6 +264,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 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 @@ -311,6 +312,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 843adf6ac7..d89e34a526 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -661,6 +661,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 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 @@ -708,6 +709,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 0000000000..b8d6be2392 --- /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 0000000000..26b2d95313 --- /dev/null +++ b/packages/flow/docs/design/SKILLS_REGISTRY.md @@ -0,0 +1,213 @@ +# Verified skills registry + +Status: reviewed (coordinator, 2026-10-28). The first stage is implemented: +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 + +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 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 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; + - 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) + +- **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 + 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, 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` (never a variable the entry + names), only from an https host the package names + (`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 + 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 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 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 + 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 attest job checks its own + attestation using only hash-pinned wheels. diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index 768898fc86..ff896b7e27 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -2569,6 +2569,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 @@ -2695,6 +2698,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; " @@ -3621,6 +3625,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 @@ -3720,6 +3727,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) @@ -6518,7 +6531,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 @@ -6526,7 +6541,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 @@ -9806,8 +9823,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/emit/mcp_tool.py b/packages/flow/openadapt_flow/emit/mcp_tool.py index 802e433a8a..9d09c134ab 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/ir.py b/packages/flow/openadapt_flow/ir.py index df5b0001db..f50cc8f8e8 100644 --- a/packages/flow/openadapt_flow/ir.py +++ b/packages/flow/openadapt_flow/ir.py @@ -2780,6 +2780,31 @@ 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}$") + #: 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 + + class BundleManifest(BaseModel): """Integrity + provenance manifest for a compiled bundle (schema v2). @@ -3063,6 +3088,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 @@ -3117,6 +3144,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 dfbd115715..1f18c9a4b6 100644 --- a/packages/flow/openadapt_flow/registry.py +++ b/packages/flow/openadapt_flow/registry.py @@ -13,18 +13,37 @@ ``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`). + +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 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. """ from __future__ import annotations +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/" @@ -32,12 +51,186 @@ ) 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 +#: 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 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) class RegistryError(RuntimeError): """The index or a bundle failed a check. Nothing was written to ``out``.""" +#: Domain separation for an entry's digest (``RegistryOrigin.entry_sha256``). +_ENTRY_DOMAIN = b"openadapt.registry-entry/v1\n" +#: 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") + +#: 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 looks like a private-tier file, in any spelling.""" + + 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: + """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( + 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 _stamp(moment: datetime) -> str: + return moment.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def stamp_index( + index: dict[str, Any], + *, + now: Optional[datetime] = None, + lifetime: timedelta = INDEX_LIFETIME, +) -> dict[str, Any]: + """Stamp ``issued_at``, ``expires_at`` and ``serial``, before attesting.""" + + now = now or datetime.now(timezone.utc) + index.update( + issued_at=_stamp(now), + expires_at=_stamp(now + lifetime), + # Seconds since the epoch: it only ever grows from one release to the + # next. + serial=int(now.timestamp()), + ) + 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_freshness_problem( + index: dict[str, Any], *, now: Optional[datetime] = None +) -> Optional[str]: + """Why an attested index is stale or unstamped, or None.""" + + 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 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(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 its publisher at any address: a stale or rolled-back copy" + ) + return None + + +def record_serial(key: str, serial: int) -> None: + """Keep the highest serial seen for this attesting identity.""" + + state = _read_state() + serials = state.setdefault("serials", {}) + 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) + + @dataclass(frozen=True) class Entry: id: str @@ -49,35 +242,116 @@ 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 _read(location: str) -> bytes: - if location.startswith(("http://", "https://")): + +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()) + 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, *, 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 = [] 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}") @@ -85,13 +359,84 @@ 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 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") + 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 +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)) @@ -101,34 +446,144 @@ 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})") +def _read_attestation(index_url: str) -> Optional[Any]: + """The index's attestation; None only when a local index has none.""" + + 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, limit=MAX_ATTESTATION_BYTES)) + except (RegistryError, OSError, json.JSONDecodeError) as exc: + raise RegistryError( + f"the index's attestation ({ATTESTATION_NAME}) can't be read: {exc}" + ) from exc + + def pull( workflow_id: str, out: Optional[Path | str] = None, *, index_url: str = DEFAULT_INDEX_URL, overwrite: bool = False, + allow_unsigned: bool = False, + 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 ``./``).""" - entry = find(load_index(index_url), workflow_id) + """Download and verify a bundle into ``out`` (default ``./``). + + Before anything is downloaded: + + - 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 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 + 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") + 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(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") - dest = Path(out) if out else Path(workflow_id.split("/", 1)[1]) + 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=pinned, + 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) + serial_key = identity_key(pinned, attested_by) + if problem is None: + 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]) 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" + 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 + 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 remote else str(Path(base) / rel), + local_root=local_root, + ) actual = hashlib.sha256(data).hexdigest() if actual != expected: raise RegistryError(f"{rel}: sha256 {actual} does not match the index") @@ -141,13 +596,136 @@ 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, + 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 serial_key is not None: + record_serial(serial_key, index_data["serial"]) return dest +#: 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. 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 + 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") + 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 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}") + if member.isdir(): + continue + if not member.isfile(): + 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(member.size) + return out + + +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 + import io + import tarfile + + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w", format=tarfile.PAX_FORMAT) as tar: + 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) + 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 { @@ -163,27 +741,61 @@ 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: 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)) 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), + allow_test_key=getattr(args, "allow_test_key", 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 +820,22 @@ 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 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 0000000000..d34c4802b3 --- /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, on Python " + "3.11 or later: 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_trusted_root.json b/packages/flow/openadapt_flow/registry_trusted_root.json new file mode 100644 index 0000000000..effb0a19e6 --- /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/skills.py b/packages/flow/openadapt_flow/skills.py new file mode 100644 index 0000000000..5371985fd4 --- /dev/null +++ b/packages/flow/openadapt_flow/skills.py @@ -0,0 +1,806 @@ +"""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. + +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 +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}$") + +#: 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.""" + + +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, + 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.""" + + 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 + # 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=attested_by, + published_digest=published, + pulled_at=_now(), + ) + workflow.save(bundle_dir) + _record_pull(workflow, workflow.registry) + _write_json( + bundle_dir / LOCK, + { + "schema": LOCK_SCHEMA, + "id": entry.id, + "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, + "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, + }, + ) + + +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) + 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 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) + 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}" + 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" + ) + 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}" + ) + if not _qualified_origin(workflow): + return ( + f"pulled skill {provenance.id} was not qualified on a named instance; " + f"run {hint}" + ) + 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 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 + + +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 fetch_full_contract( + 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. 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. + """ + + 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(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 + + 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(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, + *, + certify: str, + 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. + + 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 ( + 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})") + signer = str(lock.get("signed_by") or "") + if not signer or signer.startswith("test:"): + log( + 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: + raise SkillError( + f"{lock.get('id')} ships no record check; pass --verify with your own" + ) + 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)" + ) + 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"): + log( + f"The full record check is in the private tier (set {TOKEN_ENV}); " + "qualifying with the public minimal check." + ) + 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 = 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, + 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, + "contract": "full" + if full_path is not None + 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) + 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, + replace_contract=args.replace_contract, + ) + 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") + ) + signer = lock.get("signed_by") + 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}") + 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)) + 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", + ) + 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): + parser.set_defaults(func=cmd_skill) diff --git a/packages/flow/public-artifacts.json b/packages/flow/public-artifacts.json index e4b21a985b..b6f9516428 100644 --- a/packages/flow/public-artifacts.json +++ b/packages/flow/public-artifacts.json @@ -359,6 +359,7 @@ "openadapt_flow/mockmed/static/widgets.js", "openadapt_flow/policies/clinical-write.yaml", "openadapt_flow/policies/permissive.yaml", + "openadapt_flow/registry_trusted_root.json", "openadapt_flow/visualize/static/program_graph.css", "openadapt_flow/visualize/static/program_graph.js", "public-demo/mockmed-verify.yaml", diff --git a/packages/flow/pyproject.toml b/packages/flow/pyproject.toml index 500724ca5d..e09cc3c297 100644 --- a/packages/flow/pyproject.toml +++ b/packages/flow/pyproject.toml @@ -130,6 +130,9 @@ dev = [ # The Windows real-app row census drives a WPF list through UI Automation # (tests/test_native_census_real_apps.py). "uiautomation>=2.0.20; sys_platform == 'win32'", + # The registry's Sigstore attestation tests; see the `registry` extra. + # 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 @@ -207,6 +210,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; 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 fb3fb7603d..37e95a4b38 100644 --- a/packages/flow/registry/README.md +++ b/packages/flow/registry/README.md @@ -5,13 +5,81 @@ 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. +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]'`, 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`. +- 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 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 + (`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 +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. 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 +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`; 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 | |---|---|---| | `openemr/new-referral` | OpenEMR 8.0.0.3 | example, not certified | @@ -26,6 +94,25 @@ checked as well. It doesn't run anything. 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 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 1. Put the bundle under `bundles///`. Use synthetic data only. diff --git a/packages/flow/registry/index.json b/packages/flow/registry/index.json index 13035bb03b..4809d6320f 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/sources.json b/packages/flow/registry/sources.json index 85e3b7dc79..ed4004c703 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/registry/verify-requirements.txt b/packages/flow/registry/verify-requirements.txt new file mode 100644 index 0000000000..257b907161 --- /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 e55c3579f3..b80520f93b 100644 --- a/packages/flow/scripts/build_registry_index.py +++ b/packages/flow/scripts/build_registry_index.py @@ -1,34 +1,90 @@ -"""Write registry/index.json from registry/sources.json and the bundle files.""" +"""Write registry/index.json from registry/sources.json and the bundle files. + + 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 --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. ``--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 sys from pathlib import Path +from typing import Optional 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 build_entry_files, validate_index # noqa: E402 +from openadapt_flow.registry import ( # noqa: E402 + INDEX_FRESHNESS_FIELDS, + build_archive, + build_entry_files, + license_notice, + stamp_index, + 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, notice=license_notice(item)), + } sources["schema_version"] = 1 validate_index(sources) return sources +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("--stamp", action="store_true") + args = parser.parse_args() + 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 freshness fields and each entry's + # archive. + for field in INDEX_FRESHNESS_FIELDS: + committed.pop(field, None) + for item in committed["workflows"]: + 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/fixtures/registry/desktop-0.24.1-SHA256SUMS b/packages/flow/tests/fixtures/registry/desktop-0.24.1-SHA256SUMS new file mode 100644 index 0000000000..7f733bdf8a --- /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 0000000000..0e9bbcd3d5 --- /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_registry.py b/packages/flow/tests/test_registry.py index 81264fceff..de583793e6 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,6 +31,13 @@ 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 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) assert committed == build() @@ -34,19 +48,34 @@ 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"}, + } + ] + } ) 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 +83,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 +124,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 0000000000..12c507f747 --- /dev/null +++ b/packages/flow/tests/test_skills_registry.py @@ -0,0 +1,637 @@ +"""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] + + +@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: + 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": {"workflow.json": "0"}, + }, + { + "id": "a/b", + "version": "1.10.0", + "status": "example", + "files": {"workflow.json": "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": {"workflow.json": "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", "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"), + ("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 _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 + + 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 "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") + 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") + + +# -- the index's attestation ------------------------------------------------------------- + + +def _attest(index: Path, *, now=None) -> Path: + """Stamp the local index and attest it, and its archives, with the test signer.""" + + import hashlib + + from openadapt_flow import registry_attest + + data = json.loads(index.read_text()) + registry.stamp_index(data, now=now) + index.write_text(json.dumps(data)) + 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_an_attested_index_pulls_and_records_who_attested_it(tmp_path): + from openadapt_flow.ir import Workflow + + index = _attest(_registry(tmp_path)) + dest = registry.pull( + "openemr/new-referral", + tmp_path / "s", + index_url=str(index), + allow_test_key=True, + ) + origin = Workflow.load(dest).registry + 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_index_without_an_attestation_needs_allow_unsigned(tmp_path): + index = _registry(tmp_path) + 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), + allow_unsigned=True, + ) + + +@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 +): + 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": + 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: + attestation.write_text( + json.dumps(registry_attest.sign_test({"index.json": "0" * 64})) + ) + with pytest.raises( + registry.RegistryError, match="does not name this index|does not hold" + ): + registry.pull( + "openemr/new-referral", + tmp_path / "t", + index_url=str(index), + allow_test_key=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" + + +# -- 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 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 + + 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://registry.openadapt.ai/private/openemr/verify.full.yaml", + "sha256": hashlib.sha256(body).hexdigest(), + } + 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(body: bytes, seen: dict, status: int = 200): + """An httpx client whose transport answers every request with ``body``.""" + + import httpx + + def handle(request): + seen.update(url=str(request.url), auth=request.headers.get("Authorization")) + return httpx.Response(status, content=body) + + 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(skills.TOKEN_ENV, 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(skills.TOKEN_ENV, "t0k3n") + served, seen = {}, {} + 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" + 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(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, + http_client=_serve(b"something else", {}), + ) + + +def test_a_full_contract_needs_a_url_and_a_digest(): + item = { + "id": "a/b", + "status": "example", + "files": {"workflow.json": "0"}, + "full_contract": {"url": "u"}, + } + with pytest.raises(registry.RegistryError, match="full_contract takes exactly"): + registry.validate_index({"workflows": [item]}) + + +# -- bundles as release assets -------------------------------------------------------------- + + +def _with_archive(tmp_path, *, tamper=None): + index = _registry(tmp_path) + bundle = index.parent / "bundles/openemr/new-referral" + 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": asset.name, "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() + assert (dest / registry.NOTICE_NAME).read_text() == "GPL-3.0 notice\n" + + +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 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 0000000000..abc014fdfe --- /dev/null +++ b/packages/flow/tests/test_skills_registry_review.py @@ -0,0 +1,981 @@ +"""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 pathlib import Path +from types import SimpleNamespace + +import pytest + +from openadapt_flow import qualification, registry, skills +from tests.test_skills_registry import ( + CONTRACT, + ROOT, + VARS, + _attest, + _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 _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", "value-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", "value-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 attestation checks can't be switched off for a remote index --------------------- + + +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) + 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", + tmp_path / "s", + index_url="http://evil.example/i.json", + ) + + +def _mirror(monkeypatch, index): + """Serve a local index directory as if from an https mirror.""" + + 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_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 --------------------------------------------------------------- + + +def test_an_expired_index_is_refused(tmp_path): + 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), + allow_test_key=True, + ) + + +def test_an_index_issued_in_the_future_is_refused(tmp_path): + 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), + 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 = _attest(_index(tmp_path), now=now) + registry.pull( + "openemr/new-referral", + tmp_path / "a", + index_url=str(index), + allow_test_key=True, + ) + # 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), + allow_test_key=True, + ) + 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()) + 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), + allow_test_key=True, + ) + + +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=str(index), + allow_test_key=True, + ) + assert digest + + +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.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" + # 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 ------------------ + + +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 + import sys + import types + + from openadapt_flow.emit.mcp_tool import emit_mcp_server + + # The emitted server's own guard is under test, not the mcp package, whose + # 2.x line renamed FastMCP; stand in for it as test_emit does. + fastmcp = types.ModuleType("mcp.server.fastmcp") + + class _FakeFastMCP: + def __init__(self, name: str) -> None: + self.name = name + + def tool(self): # noqa: ANN202 - mirrors FastMCP's decorator factory + return lambda func: func + + fastmcp.FastMCP = _FakeFastMCP # type: ignore[attr-defined] + for name in ("mcp", "mcp.server"): + monkeypatch.setitem(sys.modules, name, types.ModuleType(name)) + monkeypatch.setitem(sys.modules, "mcp.server.fastmcp", fastmcp) + + 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_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() + 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 dbb3960834..b28e9d7eaa 100644 --- a/packages/flow/uv.lock +++ b/packages/flow/uv.lock @@ -684,6 +684,15 @@ 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.9.0" +source = { registry = "https://pypi.org/simple" } +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 +702,19 @@ 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" }, + { 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 +1097,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" @@ -2019,6 +2053,7 @@ dev = [ { name = "requests" }, { name = "ruff" }, { name = "scikit-image" }, + { name = "sigstore", marker = "python_full_version >= '3.11'" }, { name = "uiautomation", marker = "sys_platform == 'win32'" }, { name = "uvicorn" }, ] @@ -2073,6 +2108,9 @@ privacy = [ rdp = [ { name = "aardwolf" }, ] +registry = [ + { name = "sigstore", marker = "python_full_version >= '3.11'" }, +] reward = [ { name = "fastapi" }, { name = "uvicorn" }, @@ -2152,6 +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 = "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" }, @@ -2169,7 +2209,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", "local-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", "local-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" @@ -2356,6 +2396,15 @@ 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.14.0" +source = { registry = "https://pypi.org/simple" } +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" @@ -2582,6 +2631,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" @@ -3192,6 +3246,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" @@ -3402,6 +3488,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" @@ -3484,6 +3579,54 @@ 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 = "platformdirs" }, + { 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" @@ -3913,6 +4056,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"