From 7c3a7dc407107964eabe80d9bae935bcbd5fc88f Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 28 Oct 2026 20:44:09 -0400 Subject: [PATCH 1/4] Skills registry: pull, bind and qualify attested skills A registry of compiled skills. pull verifies a keyless Sigstore attestation from the registry release workflow, naming the exact index and archives; refuses an expired index and one older than the newest seen from the same attesting identity at any address; and marks the pulled bundle so run, replay and emit refuse it until skill qualify passes on the user's own instance. --- .github/workflows/release-registry.yml | 297 ++++++ CAPABILITIES.md | 2 + llms-full.txt | 2 + .../commands/01-record-and-build/skill.md | 1 + packages/flow/docs/design/SKILLS_REGISTRY.md | 213 ++++ packages/flow/openadapt_flow/__main__.py | 19 + packages/flow/openadapt_flow/emit/mcp_tool.py | 13 + packages/flow/openadapt_flow/ir.py | 29 + packages/flow/openadapt_flow/registry.py | 684 ++++++++++++- .../flow/openadapt_flow/registry_attest.py | 305 ++++++ .../openadapt_flow/registry_trusted_root.json | 126 +++ packages/flow/openadapt_flow/skills.py | 806 +++++++++++++++ packages/flow/pyproject.toml | 7 + packages/flow/registry/README.md | 89 +- packages/flow/registry/index.json | 5 + packages/flow/registry/sources.json | 20 +- .../flow/registry/verify-requirements.txt | 533 ++++++++++ packages/flow/scripts/build_registry_index.py | 80 +- .../registry/desktop-0.24.1-SHA256SUMS | 16 + .../desktop-0.24.1-SHA256SUMS.sigstore.json | 1 + packages/flow/tests/test_registry.py | 52 +- packages/flow/tests/test_skills_registry.py | 637 ++++++++++++ .../flow/tests/test_skills_registry_review.py | 963 ++++++++++++++++++ packages/flow/uv.lock | 158 ++- 24 files changed, 5010 insertions(+), 48 deletions(-) create mode 100644 .github/workflows/release-registry.yml create mode 100644 packages/flow/docs/commands/01-record-and-build/skill.md create mode 100644 packages/flow/docs/design/SKILLS_REGISTRY.md create mode 100644 packages/flow/openadapt_flow/registry_attest.py create mode 100644 packages/flow/openadapt_flow/registry_trusted_root.json create mode 100644 packages/flow/openadapt_flow/skills.py create mode 100644 packages/flow/registry/verify-requirements.txt create mode 100644 packages/flow/tests/fixtures/registry/desktop-0.24.1-SHA256SUMS create mode 100644 packages/flow/tests/fixtures/registry/desktop-0.24.1-SHA256SUMS.sigstore.json create mode 100644 packages/flow/tests/test_skills_registry.py create mode 100644 packages/flow/tests/test_skills_registry_review.py diff --git a/.github/workflows/release-registry.yml b/.github/workflows/release-registry.yml new file mode 100644 index 000000000..d3e67c6ef --- /dev/null +++ b/.github/workflows/release-registry.yml @@ -0,0 +1,297 @@ +# 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" + - name: Install the engine + run: python -m pip install -e packages/types -e 'packages/flow[dev]' + - name: Check the bundles + run: | + set -euo pipefail + python packages/flow/scripts/build_registry_index.py --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 fb3db03c2..e0c554df0 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 843adf6ac..d89e34a52 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 000000000..b8d6be239 --- /dev/null +++ b/packages/flow/docs/commands/01-record-and-build/skill.md @@ -0,0 +1 @@ +Bind a pulled skill to your instance, qualify it there, and check whether it may run. diff --git a/packages/flow/docs/design/SKILLS_REGISTRY.md b/packages/flow/docs/design/SKILLS_REGISTRY.md new file mode 100644 index 000000000..26b2d9531 --- /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 768898fc8..ff896b7e2 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 802e433a8..9d09c134a 100644 --- a/packages/flow/openadapt_flow/emit/mcp_tool.py +++ b/packages/flow/openadapt_flow/emit/mcp_tool.py @@ -60,6 +60,19 @@ def {func_name}(url: str{param_sig}) -> dict: record before you call this tool again. """ params = {params_dict} + from openadapt_flow.skills import pulled_skill_refusal + + # A skill pulled from the registry runs only on the instance it was + # qualified on; the bundle copy carries that qualification. + refusal = pulled_skill_refusal(BUNDLE_DIR, target_url=url) + if refusal is not None: + return {{ + "success": False, + "outcome": "REFUSED", + "transaction_outcome": None, + "production_eligible": False, + "detail": refusal, + }} from playwright.sync_api import sync_playwright from openadapt_flow._browser_setup import launch_chromium diff --git a/packages/flow/openadapt_flow/ir.py b/packages/flow/openadapt_flow/ir.py index df5b0001d..f50cc8f8e 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 dfbd11571..1f18c9a4b 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 000000000..d34c4802b --- /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 000000000..effb0a19e --- /dev/null +++ b/packages/flow/openadapt_flow/registry_trusted_root.json @@ -0,0 +1,126 @@ +{ + "mediaType": "application/vnd.dev.sigstore.trustedroot+json;version=0.1", + "tlogs": [ + { + "baseUrl": "https://rekor.sigstore.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2021-01-12T11:53:27Z" + } + }, + "logId": { + "keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=" + } + }, + { + "baseUrl": "https://log2025-1.rekor.sigstore.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MCowBQYDK2VwAyEAt8rlp1knGwjfbcXAYPYAkn0XiLz1x8O4t0YkEhie244=", + "keyDetails": "PKIX_ED25519", + "validFor": { + "start": "2025-09-23T00:00:00Z" + } + }, + "logId": { + "keyId": "zxGZFVvd0FEmjR8WrFwMdcAJ9vtaY/QXf44Y1wUeP6A=" + } + } + ], + "certificateAuthorities": [ + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore" + }, + "uri": "https://fulcio.sigstore.dev", + "certChain": { + "certificates": [ + { + "rawBytes": "MIIB+DCCAX6gAwIBAgITNVkDZoCiofPDsy7dfm6geLbuhzAKBggqhkjOPQQDAzAqMRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIxMDMwNzAzMjAyOVoXDTMxMDIyMzAzMjAyOVowKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLSyA7Ii5k+pNO8ZEWY0ylemWDowOkNa3kL+GZE5Z5GWehL9/A9bRNA3RbrsZ5i0JcastaRL7Sp5fp/jD5dxqc/UdTVnlvS16an+2Yfswe/QuLolRUCrcOE2+2iA5+tzd6NmMGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFMjFHQBBmiQpMlEk6w2uSu1KBtPsMB8GA1UdIwQYMBaAFMjFHQBBmiQpMlEk6w2uSu1KBtPsMAoGCCqGSM49BAMDA2gAMGUCMH8liWJfMui6vXXBhjDgY4MwslmN/TJxVe/83WrFomwmNf056y1X48F9c4m3a3ozXAIxAKjRay5/aj/jsKKGIkmQatjI8uupHr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ==" + } + ] + }, + "validFor": { + "start": "2021-03-07T03:20:29Z", + "end": "2022-12-31T23:59:59.999Z" + } + }, + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore" + }, + "uri": "https://fulcio.sigstore.dev", + "certChain": { + "certificates": [ + { + "rawBytes": "MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV77LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjpKFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZIzj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJRnZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsPmygUY7Ii2zbdCdliiow=" + }, + { + "rawBytes": "MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxexX69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92jYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRYwB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQKsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCMWP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ" + } + ] + }, + "validFor": { + "start": "2022-04-13T20:06:15Z" + } + } + ], + "ctlogs": [ + { + "baseUrl": "https://ctfe.sigstore.dev/test", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbfwR+RJudXscgRBRpKX1XFDy3PyudDxz/SfnRi1fT8ekpfBd2O1uoz7jr3Z8nKzxA69EUQ+eFCFI3zeubPWU7w==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2021-03-14T00:00:00Z", + "end": "2022-10-31T23:59:59.999Z" + } + }, + "logId": { + "keyId": "CGCS8ChS/2hF0dFrJ4ScRWcYrBY9wzjSbea8IgY2b3I=" + } + }, + { + "baseUrl": "https://ctfe.sigstore.dev/2022", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiPSlFi0CmFTfEjCUqF9HuCEcYXNKAaYalIJmBZ8yyezPjTqhxrKBpMnaocVtLJBI1eM3uXnQzQGAJdJ4gs9Fyw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2022-10-20T00:00:00Z" + } + }, + "logId": { + "keyId": "3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4=" + } + } + ], + "timestampAuthorities": [ + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore-tsa-selfsigned" + }, + "uri": "https://timestamp.sigstore.dev/api/v1/timestamp", + "certChain": { + "certificates": [ + { + "rawBytes": "MIICEDCCAZagAwIBAgIUOhNULwyQYe68wUMvy4qOiyojiwwwCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTA0MDgwNjU5NDNaFw0zNTA0MDYwNjU5NDNaMC4xFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEVMBMGA1UEAxMMc2lnc3RvcmUtdHNhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE4ra2Z8hKNig2T9kFjCAToGG30jky+WQv3BzL+mKvh1SKNR/UwuwsfNCg4sryoYAd8E6isovVA3M4aoNdm9QDi50Z8nTEyvqgfDPtTIwXItfiW/AFf1V7uwkbkAoj0xxco2owaDAOBgNVHQ8BAf8EBAMCB4AwHQYDVR0OBBYEFIn9eUOHz9BlRsMCRscsc1t9tOsDMB8GA1UdIwQYMBaAFJjsAe9/u1H/1JUeb4qImFMHic6/MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMIMAoGCCqGSM49BAMDA2gAMGUCMDtpsV/6KaO0qyF/UMsX2aSUXKQFdoGTptQGc0ftq1csulHPGG6dsmyMNd3JB+G3EQIxAOajvBcjpJmKb4Nv+2Taoj8Uc5+b6ih6FXCCKraSqupe07zqswMcXJTe1cExvHvvlw==" + }, + { + "rawBytes": "MIIB9zCCAXygAwIBAgIUV7f0GLDOoEzIh8LXSW80OJiUp14wCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTA0MDgwNjU5NDNaFw0zNTA0MDYwNjU5NDNaMDkxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEgMB4GA1UEAxMXc2lnc3RvcmUtdHNhLXNlbGZzaWduZWQwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQUQNtfRT/ou3YATa6wB/kKTe70cfJwyRIBovMnt8RcJph/COE82uyS6FmppLLL1VBPGcPfpQPYJNXzWwi8icwhKQ6W/Qe2h3oebBb2FHpwNJDqo+TMaC/tdfkv/ElJB72jRTBDMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSY7AHvf7tR/9SVHm+KiJhTB4nOvzAKBggqhkjOPQQDAwNpADBmAjEAwGEGrfGZR1cen1R8/DTVMI943LssZmJRtDp/i7SfGHmGRP6gRbuj9vOK3b67Z0QQAjEAuT2H673LQEaHTcyQSZrkp4mX7WwkmF+sVbkYY5mXN+RMH13KUEHHOqASaemYWK/E" + } + ] + }, + "validFor": { + "start": "2025-07-04T00:00:00Z" + } + } + ] +} diff --git a/packages/flow/openadapt_flow/skills.py b/packages/flow/openadapt_flow/skills.py new file mode 100644 index 000000000..5371985fd --- /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/pyproject.toml b/packages/flow/pyproject.toml index 500724ca5..e09cc3c29 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 fb3fb7603..37e95a4b3 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 13035bb03..4809d6320 100644 --- a/packages/flow/registry/index.json +++ b/packages/flow/registry/index.json @@ -3,6 +3,7 @@ "workflows": [ { "app": "OpenEMR 8.0.0.3 (GPL-3.0), local test copy with made-up patients", + "app_license": "GPL-3.0", "certification": null, "evidence": "On 2026-10-11 this bundle ran over 8 made-up referrals on a local OpenEMR test copy. 7 saved and a separate REST and SQL read matched 12 of 12 fields; the duplicate-name case stopped at OpenEMR's duplicate check. Engine certification was off for those runs.", "files": { @@ -68,6 +69,10 @@ "country_code" ], "path": "bundles/openemr/new-referral", + "screens": { + "data": "synthetic", + "source": "benchmark/openemr_local (made-up patients)" + }, "status": "example", "title": "Create the patient chart from a new referral in OpenEMR", "usage": "Start signed in to OpenEMR on Patient > New (/interface/new/new_comprehensive.php) at 1280x800. benchmark/openemr_local in this repository brings up a matching synthetic copy." diff --git a/packages/flow/registry/sources.json b/packages/flow/registry/sources.json index 85e3b7dc7..ed4004c70 100644 --- a/packages/flow/registry/sources.json +++ b/packages/flow/registry/sources.json @@ -9,7 +9,25 @@ "certification": null, "evidence": "On 2026-10-11 this bundle ran over 8 made-up referrals on a local OpenEMR test copy. 7 saved and a separate REST and SQL read matched 12 of 12 fields; the duplicate-name case stopped at OpenEMR's duplicate check. Engine certification was off for those runs.", "usage": "Start signed in to OpenEMR on Patient > New (/interface/new/new_comprehensive.php) at 1280x800. benchmark/openemr_local in this repository brings up a matching synthetic copy.", - "params": ["title", "fname", "lname", "DOB", "sex", "street", "city", "state_label", "postal_code", "phone_home", "email", "country_code"] + "params": [ + "title", + "fname", + "lname", + "DOB", + "sex", + "street", + "city", + "state_label", + "postal_code", + "phone_home", + "email", + "country_code" + ], + "app_license": "GPL-3.0", + "screens": { + "data": "synthetic", + "source": "benchmark/openemr_local (made-up patients)" + } }, { "id": "openimis/claim-intake", diff --git a/packages/flow/registry/verify-requirements.txt b/packages/flow/registry/verify-requirements.txt new file mode 100644 index 000000000..257b90716 --- /dev/null +++ b/packages/flow/registry/verify-requirements.txt @@ -0,0 +1,533 @@ +# Hash-pinned wheels for the registry release workflow, which checks its own +# attestation with openadapt_flow.registry_attest before publishing. Regenerate: +# uv pip compile <(printf "sigstore==4.5.1\npydantic==2.12.5\ncryptography==50.0.0\n") \ +# --generate-hashes --python-version 3.12 --python-platform x86_64-manylinux_2_28 +annotated-types==0.8.0 \ + --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \ + --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 +certifi==2026.10.23 \ + --hash=sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd \ + --hash=sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1 +cffi==2.1.1 \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ + --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ + --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \ + --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \ + --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ + --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ + --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ + --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 +charset-normalizer==3.5.2 \ + --hash=sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e \ + --hash=sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf \ + --hash=sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5 \ + --hash=sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56 \ + --hash=sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26 \ + --hash=sha256:0891b9d3903c5571c03771ca669a4b0ec5618ca722a5c957d3d29cd4e5062848 \ + --hash=sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718 \ + --hash=sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93 \ + --hash=sha256:114e4d0c92d618409ed82a99e22b5c5e768fe995f2973f78265f4524f49d4640 \ + --hash=sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3 \ + --hash=sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875 \ + --hash=sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e \ + --hash=sha256:1461ac396c4fdb983a675f20aa555624f0ee18ac83d832b9244ffff3d8055275 \ + --hash=sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204 \ + --hash=sha256:15bb4005af6320d259dc7593ca84a38d7fe06a421dbcf7b910ae23979101e787 \ + --hash=sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234 \ + --hash=sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3 \ + --hash=sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98 \ + --hash=sha256:195c26fb65950f8fce54e26349852b7bdd7c5f120aeefbcc440b8a20faaed4a3 \ + --hash=sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187 \ + --hash=sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d \ + --hash=sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f \ + --hash=sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7 \ + --hash=sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011 \ + --hash=sha256:211d5a3eb6af8f513b8d4ca19a8c1b7accab1b5f0d3175f9826b03c1a920dc1f \ + --hash=sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869 \ + --hash=sha256:254eb48b9fa5ee9898a3c445825a1f340fe53712a098904b39b0bddba8ea3cb1 \ + --hash=sha256:2625388c6c754520c37abaf3b41eb34d1cc4a373f457898f08606c8e362b891d \ + --hash=sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847 \ + --hash=sha256:28a15fdad492a99b6eccfaaed66ef3f74050680545ea61ec8b2f4c538f1f1320 \ + --hash=sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9 \ + --hash=sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93 \ + --hash=sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd \ + --hash=sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00 \ + --hash=sha256:2cc961b171b3f3440f410489ab3573e86aea8736134ebbb40ea1338b7f0831bc \ + --hash=sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0 \ + --hash=sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09 \ + --hash=sha256:304d5463e65a35d7bb0850550e0780395395f6fcf452f04db7d5ca7cecc425ac \ + --hash=sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621 \ + --hash=sha256:30fcd120b732aa79317f08dee04d7de0847822e4cf7ee0e9f445bb958832252c \ + --hash=sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8 \ + --hash=sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a \ + --hash=sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51 \ + --hash=sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0 \ + --hash=sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef \ + --hash=sha256:3d14b50de6bf4d0edf857a9386836846f982b8f524e188e2e68b96d702bcf4aa \ + --hash=sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6 \ + --hash=sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649 \ + --hash=sha256:3ddacd27458c45bdacd6bd6db644bfb730efbf9e830310186e3045c9c5be8fb2 \ + --hash=sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229 \ + --hash=sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e \ + --hash=sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd \ + --hash=sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115 \ + --hash=sha256:447441e76ec720b15e64418d32e092297340387053047c7c694f579efb0ee1d9 \ + --hash=sha256:4495c5002a7b28557e7e222e77e0b661183e432b7d6d2e788101e3f240e05b8c \ + --hash=sha256:44bd4fbb29dfbeba60e7d2bd000c59e4b21ddb3cc53912b14048d37092706d7c \ + --hash=sha256:4685902cf26edf013ed7a3da0f426ebba7a00ebb9541386d835afbf002c11cab \ + --hash=sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253 \ + --hash=sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995 \ + --hash=sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438 \ + --hash=sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0 \ + --hash=sha256:50e3adfb96fc189eb27b1cf62d3b598b89b4bb0420d93a3d3e42e137409011be \ + --hash=sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b \ + --hash=sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7 \ + --hash=sha256:55ea99acb17b9325618de155a0cd6a2e8f5d10be008113e1d433bbb58db543b2 \ + --hash=sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a \ + --hash=sha256:588461c2e8384d309bd63e5826019b6977bc66d629b99ac8737bb795d7b2cb5a \ + --hash=sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a \ + --hash=sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c \ + --hash=sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5 \ + --hash=sha256:59f63901b0031c3136cf64704dcb21de0bbae62ce2c9529bc39d27665463de37 \ + --hash=sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e \ + --hash=sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4 \ + --hash=sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800 \ + --hash=sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055 \ + --hash=sha256:619799369eeef6366ed3e8755a5670f4f2f0fb6b30a0fd7264dc0fdc2357058e \ + --hash=sha256:62588a277bfb59def052abd940703fa35107152bf479781a878617d60faf8fb5 \ + --hash=sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c \ + --hash=sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b \ + --hash=sha256:68eb192d85ab8e5f6ec69c2bc6ac0179fbf04a5ac1569d12fbef74883fe102d0 \ + --hash=sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80 \ + --hash=sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a \ + --hash=sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4 \ + --hash=sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2 \ + --hash=sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58 \ + --hash=sha256:75a3ceed0724d625d64b86ca20aba182e4df462e04c2414fc941c0f523f06aac \ + --hash=sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc \ + --hash=sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639 \ + --hash=sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf \ + --hash=sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d \ + --hash=sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f \ + --hash=sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c \ + --hash=sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc \ + --hash=sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4 \ + --hash=sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253 \ + --hash=sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade \ + --hash=sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858 \ + --hash=sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26 \ + --hash=sha256:87e50a3e7cb90af586b6c5faf23e302a970415ac73bd7bd90a515a04b427ef96 \ + --hash=sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8 \ + --hash=sha256:8a893cc101149f80a653f82062ebc95b34525a2614382e1da5458fe7c6997249 \ + --hash=sha256:8b2bfab86aa71ae13aa41a6a26aab338e0db2b8bc75434b05aea89e011ff35a4 \ + --hash=sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13 \ + --hash=sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1 \ + --hash=sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03 \ + --hash=sha256:93223adc95033dd47133a46ccfc316a0139176fd79085762e27202ec56018f03 \ + --hash=sha256:9373ad13ef0d2c0fb761e04e55bfdee5a08b52cef2c882c8fbe9935b1517152e \ + --hash=sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364 \ + --hash=sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4 \ + --hash=sha256:9bde855991b7e362c146535e3136a50bfaffc0487d38b33ca7e5edefc6e23849 \ + --hash=sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0 \ + --hash=sha256:9cf9b1a857e25c4baceeb3624e92a56df3668f398c4acba74e174d81fb4d1d3a \ + --hash=sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036 \ + --hash=sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3 \ + --hash=sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21 \ + --hash=sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3 \ + --hash=sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e \ + --hash=sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413 \ + --hash=sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21 \ + --hash=sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346 \ + --hash=sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429 \ + --hash=sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685 \ + --hash=sha256:b736353c0a625bbd5fcec108576e2385db3496f4f771f785ff32e108d3c3bc45 \ + --hash=sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f \ + --hash=sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c \ + --hash=sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d \ + --hash=sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad \ + --hash=sha256:bd16aabe4a02a297c23417aa17ac6299dbd8c49f673bcd645b4929b11f5a4400 \ + --hash=sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb \ + --hash=sha256:c6708715abcf3c73b99508253e961a9967f02fe536532834149574eda6de0d1c \ + --hash=sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc \ + --hash=sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c \ + --hash=sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74 \ + --hash=sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf \ + --hash=sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604 \ + --hash=sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f \ + --hash=sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105 \ + --hash=sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a \ + --hash=sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d \ + --hash=sha256:d4a7319f304a774bed22115bc891618e45f85065ab44ea6acd07d274e750519a \ + --hash=sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1 \ + --hash=sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5 \ + --hash=sha256:d913de495d90407cd859d263bee2e5d1a4ed3eb6573c04e70d9ec619a7cbed7f \ + --hash=sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e \ + --hash=sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709 \ + --hash=sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874 \ + --hash=sha256:ddf19c062bea7a0cc80f519243d2c01dd091be0cf952a0750d4ad576709559f5 \ + --hash=sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc \ + --hash=sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95 \ + --hash=sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd \ + --hash=sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0 \ + --hash=sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d \ + --hash=sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3 \ + --hash=sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c \ + --hash=sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3 \ + --hash=sha256:e80e6c2f55656b4824d72065abb4ddd6a525c74bd78a0aab5d9fc2cf4fb5af50 \ + --hash=sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491 \ + --hash=sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5 \ + --hash=sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5 \ + --hash=sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655 \ + --hash=sha256:ef4fcbf3327382cd4c9f540babd61248208af7b93eec4de397b4d5f58a09e288 \ + --hash=sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd \ + --hash=sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084 \ + --hash=sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d \ + --hash=sha256:f5833ad231be5eb6553de524a70f48d71b2c8563101750531e0b80184e175cd4 \ + --hash=sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915 \ + --hash=sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1 \ + --hash=sha256:fb9e68df06293761f9fe66ade60a9bc6d0f5e42b8acf2939a9158af86ab0e5bd \ + --hash=sha256:fc14a032f813bf5fe624d991960ea83e9715adc27e4c1830a2361eb1d02ac341 \ + --hash=sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424 \ + --hash=sha256:fd1fbe0f116b6e55da77aca2c6ddcddcfac2186cbf78bdebf40fc156efca389d \ + --hash=sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f +cryptography==50.0.0 \ + --hash=sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03 \ + --hash=sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7 \ + --hash=sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437 \ + --hash=sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 \ + --hash=sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025 \ + --hash=sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037 \ + --hash=sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269 \ + --hash=sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105 \ + --hash=sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc \ + --hash=sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95 \ + --hash=sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b \ + --hash=sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47 \ + --hash=sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c \ + --hash=sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41 \ + --hash=sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c \ + --hash=sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d \ + --hash=sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7 \ + --hash=sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c \ + --hash=sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708 \ + --hash=sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef \ + --hash=sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f \ + --hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f \ + --hash=sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a \ + --hash=sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f \ + --hash=sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a \ + --hash=sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a \ + --hash=sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e \ + --hash=sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3 \ + --hash=sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d \ + --hash=sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3 \ + --hash=sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f \ + --hash=sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae \ + --hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \ + --hash=sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9 \ + --hash=sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9 \ + --hash=sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07 \ + --hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba \ + --hash=sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3 \ + --hash=sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f \ + --hash=sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533 \ + --hash=sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5 \ + --hash=sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11 \ + --hash=sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9 \ + --hash=sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f \ + --hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \ + --hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645 +dnspython==2.9.0 \ + --hash=sha256:9a4aedb833c3c1b49214d04d44d3032ab7a9135f7c1d29a549b4ff78fd82fda9 \ + --hash=sha256:b44dc6b18f07a8b1c56676a19fbfdb5209415b046a9cece286baafa87ff3f7f1 +email-validator==2.3.0 \ + --hash=sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4 \ + --hash=sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426 +id==1.6.1 \ + --hash=sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069 \ + --hash=sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca +idna==3.20 \ + --hash=sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44 \ + --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c +markdown-it-py==4.2.0 \ + --hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \ + --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a +mdurl==0.1.2 \ + --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ + --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba +platformdirs==4.14.0 \ + --hash=sha256:1dadfc59a8a5d7565b3d9270ae8fa71c065595352c4277b41c11ee6c222c4b91 \ + --hash=sha256:83f009c1e8c5fc36869b5171755b291887bd2cade7965233cdfabd23d754720b +pycparser==3.12 \ + --hash=sha256:a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e \ + --hash=sha256:f03387877eab82ce01bbdfd0ba7252ee55a90b1c708d5129e2ce915475cf1db6 +pydantic==2.12.5 \ + --hash=sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49 \ + --hash=sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d +pydantic-core==2.41.5 \ + --hash=sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90 \ + --hash=sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740 \ + --hash=sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504 \ + --hash=sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84 \ + --hash=sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33 \ + --hash=sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c \ + --hash=sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0 \ + --hash=sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e \ + --hash=sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0 \ + --hash=sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a \ + --hash=sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34 \ + --hash=sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2 \ + --hash=sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3 \ + --hash=sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815 \ + --hash=sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14 \ + --hash=sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba \ + --hash=sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375 \ + --hash=sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf \ + --hash=sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963 \ + --hash=sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1 \ + --hash=sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808 \ + --hash=sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553 \ + --hash=sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1 \ + --hash=sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2 \ + --hash=sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5 \ + --hash=sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470 \ + --hash=sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2 \ + --hash=sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b \ + --hash=sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660 \ + --hash=sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c \ + --hash=sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093 \ + --hash=sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5 \ + --hash=sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594 \ + --hash=sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008 \ + --hash=sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a \ + --hash=sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a \ + --hash=sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd \ + --hash=sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284 \ + --hash=sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586 \ + --hash=sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869 \ + --hash=sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294 \ + --hash=sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f \ + --hash=sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66 \ + --hash=sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51 \ + --hash=sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc \ + --hash=sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97 \ + --hash=sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a \ + --hash=sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d \ + --hash=sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9 \ + --hash=sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c \ + --hash=sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07 \ + --hash=sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36 \ + --hash=sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e \ + --hash=sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05 \ + --hash=sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e \ + --hash=sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941 \ + --hash=sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3 \ + --hash=sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612 \ + --hash=sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3 \ + --hash=sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b \ + --hash=sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe \ + --hash=sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146 \ + --hash=sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11 \ + --hash=sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60 \ + --hash=sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd \ + --hash=sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b \ + --hash=sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c \ + --hash=sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a \ + --hash=sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460 \ + --hash=sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1 \ + --hash=sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf \ + --hash=sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf \ + --hash=sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858 \ + --hash=sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2 \ + --hash=sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9 \ + --hash=sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2 \ + --hash=sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3 \ + --hash=sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6 \ + --hash=sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770 \ + --hash=sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d \ + --hash=sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc \ + --hash=sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23 \ + --hash=sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26 \ + --hash=sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa \ + --hash=sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8 \ + --hash=sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d \ + --hash=sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3 \ + --hash=sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d \ + --hash=sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034 \ + --hash=sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9 \ + --hash=sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1 \ + --hash=sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56 \ + --hash=sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b \ + --hash=sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c \ + --hash=sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a \ + --hash=sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e \ + --hash=sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9 \ + --hash=sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5 \ + --hash=sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a \ + --hash=sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556 \ + --hash=sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e \ + --hash=sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49 \ + --hash=sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2 \ + --hash=sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9 \ + --hash=sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b \ + --hash=sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc \ + --hash=sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb \ + --hash=sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0 \ + --hash=sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8 \ + --hash=sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82 \ + --hash=sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69 \ + --hash=sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b \ + --hash=sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c \ + --hash=sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75 \ + --hash=sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5 \ + --hash=sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f \ + --hash=sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad \ + --hash=sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b \ + --hash=sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7 \ + --hash=sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425 \ + --hash=sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52 +pygments==2.21.0 \ + --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 \ + --hash=sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c +pyjwt==2.15.1 \ + --hash=sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193 \ + --hash=sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8 +requests==2.34.2 \ + --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ + --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed +rfc3161-client==1.0.9 \ + --hash=sha256:03be7ac7919c7f4c566076b07773b81b1f1b28d447fa91c6960fb5f82fac5bc1 \ + --hash=sha256:0ac247d5aa22f3514d1e284bdc547d2ad5f101e1e5d7f6db25db2ab3a0c59f06 \ + --hash=sha256:4d33f5aaec2974239e1c62a79a6fcf32506b09095482377a8b5be3a00fa67dfb \ + --hash=sha256:5e619d7e28ffa3a46685923e6d6ba4ce2e74cb09a41c77995a0702cc1a533bc4 \ + --hash=sha256:614da494c6f8850c5d6d98c23bc291a4e4dbf2c3c9082d9907fa156f9e4a75c6 \ + --hash=sha256:66997415086d2c6e5d57a2d315157b32a9c3f4f1312877ca8eb55e2e0ddf8ce4 \ + --hash=sha256:7b703b233dbee7228d117d5f1c118363c61d523fa6f018c9217c79e3c6684f86 \ + --hash=sha256:8208999e94e1252e3f5f748f5efe59d3b9c308d04c7626be1becfbf3236c9de4 \ + --hash=sha256:908f6a775da4bdce1d39f825a4431f389542696078ef3e4ed911a068bcaf1792 \ + --hash=sha256:a7004edfbf1bb7ec7801978a39fa7a7fe385ca59673df90d7578079b6eba3b24 \ + --hash=sha256:d3cbf1991b6f3457efcb25e1d31179cac556112e6d3e65ec9e0fd70c6e9af90d \ + --hash=sha256:d4cf92f0e784ff02ff5ea1c574f8521f30953dc178092424126dbd8348034f93 \ + --hash=sha256:fac3f440a507555e684dc5daba75e33dc08f0f45fdefa48448f19001233a6b21 +rfc8785==0.1.4 \ + --hash=sha256:520d690b448ecf0703691c76e1a34a24ddcd4fc5bc41d589cb7c58ec651bcd48 \ + --hash=sha256:e545841329fe0eee4f6a3b44e7034343100c12b4ec566dc06ca9735681deb4da +rich==15.0.0 \ + --hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \ + --hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36 +securesystemslib==1.6.0 \ + --hash=sha256:1c1aa00f058f4851f038d409c8ca4a727db854a91f07ec3a0fa047bb93e43e0e \ + --hash=sha256:b16e07c32bb41174c2ee2fb18f478ebe0d52e0e7c1fb085ce89c990452a35977 +sigstore==4.5.1 \ + --hash=sha256:02e000197b34980f1d382741d6f83c2fecbcef7debf7304f36a5253f3c892cd9 \ + --hash=sha256:14accda8a7d4ef47cb25b4903829fb10f011048c261a5937724a43d073c4f34d +sigstore-models==0.0.6 \ + --hash=sha256:5201a68f4d7d0f8bec1e2f4378eb646b084c52609a4e31db8c385095fff68b2e \ + --hash=sha256:c766c09470c2a7e8a4a333c893f07e2001c56a3ff1757b1a246119f53169a849 +sigstore-rekor-types==0.0.18 \ + --hash=sha256:19aef25433218ebf9975a1e8b523cc84aaf3cd395ad39a30523b083ea7917ec5 \ + --hash=sha256:b62bf38c5b1a62bc0d7fe0ee51a0709e49311d137c7880c329882a8f4b2d1d78 +tuf==7.0.1 \ + --hash=sha256:5ada1db78da3518fa851e28588f4b2249b8662a28ae528f54afa2a1de541fb31 \ + --hash=sha256:d30434bda6e079ab303fb30d1b3006d939a10ca34783b1573d61cd9b802fa45c +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 +typing-inspection==0.4.4 \ + --hash=sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47 \ + --hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147 +urllib3==2.8.0 \ + --hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3 \ + --hash=sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63 diff --git a/packages/flow/scripts/build_registry_index.py b/packages/flow/scripts/build_registry_index.py index e55c3579f..b80520f93 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 000000000..7f733bdf8 --- /dev/null +++ b/packages/flow/tests/fixtures/registry/desktop-0.24.1-SHA256SUMS @@ -0,0 +1,16 @@ +c1136bfffb634f71a51e3cbfd080fca12e0bef0fa8a7549d9a3919e8560d5939 OpenAdapt-Desktop-Candidate-v0.24.1-linux-x86_64-github-attested-metadata.json +daa967d02559ad3624bda0ae923b291442485ab06a1e555c31d3f49bb814cea0 OpenAdapt-Desktop-Candidate-v0.24.1-linux-x86_64-github-attested.AppImage +75b942cabf04f7e6216ef73273f92a021232ff7ed7ead4301fd940220e87df16 OpenAdapt-Desktop-Candidate-v0.24.1-linux-x86_64-github-attested.deb +6132fdab542f7907275906ac8c0d4be8b8b01daf8f9d1f188af058b74e350a85 OpenAdapt-Desktop-Candidate-v0.24.1-macos-arm64-adhoc-metadata.json +6f8a4f05df5ca5cb495ebe3e7deede5190d1da6fed6476284c689952d08e32f7 OpenAdapt-Desktop-Candidate-v0.24.1-macos-arm64-adhoc.dmg +9725459d89b241340fa59c43b3885015f89db17423819882089d480a5af4345d OpenAdapt-Desktop-Candidate-v0.24.1-macos-x86_64-adhoc-metadata.json +48ba58909b9a736abd109c38310ce9bb03b48a46bd1f7ae19221626c50f3a7f8 OpenAdapt-Desktop-Candidate-v0.24.1-macos-x86_64-adhoc.dmg +ed86395490fe7151ec5338d38a29640145e23dcdfad3d8f9f20919436b61667b OpenAdapt-Desktop-Candidate-v0.24.1-windows-x86_64-unsigned-metadata.json +89937222c1591907caf91c3409d979a4b3f304a413ca870aad98eb82de9a3faa OpenAdapt-Desktop-Candidate-v0.24.1-windows-x86_64-unsigned-nsis-setup.exe +ae3990223ec7a41233fffc76027391d15423080ff238a52e717a2cf7460b458e OpenAdapt-Desktop-Candidate-v0.24.1-windows-x86_64-unsigned.msi +7d3ac8cc13364ab79fc0c5eb001b5b8524f2fdf567aaf8aacb65eddf1047158e OpenAdapt-Desktop-v0.24.1.cyclonedx.json +55a2424c4c2488a1d9a1da8da47fe108c29a903fe9acda5e8fa62533e1198724 openadapt-desktop-native-release-provenance.json +41f59b25834151a9e62263f01e18ddb3fbd0b0abd1d6758e9b22b786078247c3 openadapt-desktop-release-manifest.json +aedbb04dbbad4ad683dbe9dc2f517762aa2c1df3f63d5dd2c0819c50d7502e01 openadapt_desktop-0.24.1-py3-none-any.whl +c91f576980201853574d8166faa5bb7f91b78b30a25097a95807bd020b0cba4c openadapt_desktop-0.24.1.tar.gz +e476f72e7f01bac3e553269af4ebdd9dc73207da84e6ce5ca050cbd76c75cb44 verify-openadapt-native-release.py diff --git a/packages/flow/tests/fixtures/registry/desktop-0.24.1-SHA256SUMS.sigstore.json b/packages/flow/tests/fixtures/registry/desktop-0.24.1-SHA256SUMS.sigstore.json new file mode 100644 index 000000000..0e9bbcd3d --- /dev/null +++ b/packages/flow/tests/fixtures/registry/desktop-0.24.1-SHA256SUMS.sigstore.json @@ -0,0 +1 @@ +{"mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", "verificationMaterial": {"tlogEntries": [{"logIndex": "3546917922", "logId": {"keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="}, "kindVersion": {"kind": "dsse", "version": "0.0.1"}, "integratedTime": "1793215525", "inclusionPromise": {"signedEntryTimestamp": "MEYCIQCwyxvQgF2/MZN7rpHhDL0xX57M+uqxNwDXQwgE1lDhcQIhALbGqGHNdhY2jSqAYEdmqog/Vn/SeyhndmXpLoPYeHbc"}, "inclusionProof": {"logIndex": "3425013660", "rootHash": "OzqivWPn9RR99yLrwYOuyMC7MdxU24YGEFckFY3Mulk=", "treeSize": "3425013746", "hashes": ["6AjdAxwG0UaZg0YKcRNRzAyuogTC9x+Y4WvoChwZIvo=", "fw1GEQrx4qc2eHbYH5GWrZT0W5v2QWe+Htwx6sFE2rU=", "0Su24esG8DId8Vy0TwqJNJ0reUfM780aDxi8b9HyKpk=", "WNUq58Dx8SdiP1Nm33n1FBOD3CLBXPIPJmtUANqVZUc=", "1Vpl93vnOGLzr2l9MLhbT8k2JMgD1EbYssTJv1BHJ9Q=", "CVSccRh2NjFYVoKTvb3ancqfpdtyy7PiA34BDZBmcaE=", "krLnGvq9Eym5ijB6Bgf05ZtG27Fh9h41IQAX42pb3/E=", "TpqZwHXoAIMsEIMph8IV5dqwH2/8AUhPB5mk2KQc98I=", "ArWwZhu5V0OBZNWs0LNaYzmpi5t8KG8Ifo3HULhv5Xo=", "p2HbYh4kdkgAhA+jel+UvkmMhguS6mpWqH0EEdwyviM=", "uVp3NoM5VUQwDBKDgdG64plYowmBkR7uFcYDcLbN/9c=", "8OUlapyOPzDuv/SUm0rXM9SyE1LKT/fWuUIZ+/Jkvro=", "+3K2T7Xiz84RUaRe7bCelFG+4QBXjWxO7rUsmqOM8Og=", "Fokxoptdag8QDkqEXYi72uTk96TrSMcID7M21cld6jQ=", "p6/M9se/opYmIU3ek7rIz+SXrKEji7klZao7o2204UE=", "JJo2FoaRBqsvaKxihSg8WSCm/8QaCgvBr0LKZ78ukt0=", "kQz/4ND7LdhfAQn+idNsO+ZFC32sfkYRlhjWF5W+sPc=", "zGfeoWKmrZlXMOvyi3R2GDbGpmI1skJW/wLTXCCgF0o=", "1DaDKzrCNNBhmSdY5vFjz+NpAmxmYfYEgBCdPLrMP1A=", "xH/DCseLHr9eKoYT8qsORZK7zVdEGYWHuVtsVrD95wY="], "checkpoint": {"envelope": "rekor.sigstore.dev - 1193050959916656506\n3425013746\nOzqivWPn9RR99yLrwYOuyMC7MdxU24YGEFckFY3Mulk=\n\n\u2014 rekor.sigstore.dev wNI9ajBFAiByAAxXUhnM2HSVzYte+M2b8dZvHztt1cbi6dZzjkLj2QIhAIbKHfXEdleNusk/DOzS8ePQ247iiB7j/tN5cCRcqUL3\n"}}, "canonicalizedBody": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiZHNzZSIsInNwZWMiOnsiZW52ZWxvcGVIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiZjgwNGFjMWMyNWJkNmY4M2M0ZmNkOTY4YmI4ZmE3ZjNhN2FmNDM0MjU0MjkzYTAyMGU1NjY5MDU1NzMxMzI2NSJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6IjExY2M5ZGM5NDczNjM0M2NkZTZjODZmYWUyOWJhMTk1ODgzYjE0ZmEwNWYyMWQ3NTM5ODRmMDY0M2JjZGIyNTAifSwic2lnbmF0dXJlcyI6W3sic2lnbmF0dXJlIjoiTUVVQ0lRRDBJV3FoK3d0ZTZEVldDM1lRWDF0ejdVd0RFclVJQVdRMmZxWnpCSEpHVndJZ1pJWHVyM3JNalF4RkZpcTVjdFlCUlFmLzI2anpWNUZkUDhxVGJSZ3FFWjA9IiwidmVyaWZpZXIiOiJMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VoRlZFTkRRbkJoWjBGM1NVSkJaMGxWV0hOTlJITk1TelJpZDBoRlJETTBZU3Q1WTIxVVRXVlRNRU5yZDBObldVbExiMXBKZW1vd1JVRjNUWGNLVG5wRlZrMUNUVWRCTVZWRlEyaE5UV015Ykc1ak0xSjJZMjFWZFZwSFZqSk5ValIzU0VGWlJGWlJVVVJGZUZaNllWZGtlbVJIT1hsYVV6RndZbTVTYkFwamJURnNXa2RzYUdSSFZYZElhR05PVFdwWmVFMUVTVFJOVkd0NVRsUkpNRmRvWTA1TmFsbDRUVVJKTkUxVWEzcE9WRWt3VjJwQlFVMUdhM2RGZDFsSUNrdHZXa2w2YWpCRFFWRlpTVXR2V2tsNmFqQkVRVkZqUkZGblFVVkZXVmRzV1RCWmJUTXpURFZQVHk5T1lrYzNkV3MzV0Rrck1uZG9ZV3g1Y1dveGVFSUtibFJHVG10aGRtMUZOM1J4YVhOTGNtSTRhbE40VUhSVGFtVjRLMk5UYm10M1lUUmlOR1Z2U1VGeVREWjFRV1JOVkdGUFEwSmlWWGRuWjFkNFRVRTBSd3BCTVZWa1JIZEZRaTkzVVVWQmQwbElaMFJCVkVKblRsWklVMVZGUkVSQlMwSm5aM0pDWjBWR1FsRmpSRUY2UVdaQ1owNVdTRk5OUlVkRVFWZG5RbFJtQ2pBcmJsQldhVkZTYkhadGJ6SlBhMjlXWVV4SFRHaG9hMUI2UW5OQ1owNVdTRkpGUWtGbU9FVlpha0puYUd3MWIyUklVbmRqZW05MlRESmtjR1JIYURFS1dXazFhbUl5TUhaVU0wSnNZbXRHYTFsWVFqQlJWV3QyVkROQ2JHSnJSbXRaV0VJd1RIazFibUZZVW05a1YwbDJaREk1ZVdFeVduTmlNMlI2VERKU2JBcGpNblF3WWpOQmRHTnRWbk5hVjBaNldsTTFOV0pYZUVGamJWWnRZM2s1YjFwWFJtdGplVGwwV1Zkc2RVMUVhMGREYVhOSFFWRlJRbWMzT0hkQlVVVkZDa3N5YURCa1NFSjZUMms0ZG1SSE9YSmFWelIxV1ZkT01HRlhPWFZqZVRWdVlWaFNiMlJYU2pGak1sWjVXVEk1ZFdSSFZuVmtRelZxWWpJd2QwZG5XVXNLUzNkWlFrSkJSMFIyZWtGQ1FXZFJUV1F5T1hsaE1scHpZak5rWm1OdVZuVk5SRmxIUTJselIwRlJVVUpuTnpoM1FWRk5SVXRFWnpWWmVrbDNUVWRLYWdwUFJFWm9XVlJrYlU1VVNUSk5iVTVwVDFSU2FVOVVUVFZaVjA1b1drUnJNMWxxUm0xYVJFVXdUa1JSZDBoUldVdExkMWxDUWtGSFJIWjZRVUpDUVZGUUNsSkhWbnBoTTFKMlkwTkNlVnBYZUd4WldFNXNUVU5OUjBOcGMwZEJVVkZDWnpjNGQwRlJWVVZHVlRsM1dsYzFRbHBIUm5ka1JVWktUREE1ZDFwWE5VSUtXa2RHZDJSRVFXUkNaMjl5UW1kRlJVRlpUeTlOUVVWSFFrRTVlVnBYV25wTU1taHNXVmRTZWt3eU1XaGhWelIzVDNkWlMwdDNXVUpDUVVkRWRucEJRZ3BEUVZGMFJFTjBiMlJJVW5kamVtOTJURE5TZG1FeVZuVk1iVVpxWkVkc2RtSnVUWFZhTW13d1lVaFdhV1JZVG14amJVNTJZbTVTYkdKdVVYVlpNamwwQ2sxSE5FZERhWE5IUVZGUlFtYzNPSGRCVVd0RldVRjRaV0ZJVWpCalNFMDJUSGs1Ym1GWVVtOWtWMGwxV1RJNWRFd3dPWGRhVnpWQ1drZEdkMlJGUmtvS1REQTVkMXBYTlVKYVIwWjNaRU00ZFZveWJEQmhTRlpwVEROa2RtTnRkRzFpUnprelkzazVhMXBZVG5Ka1J6bDNURmhLYkdKSFZtaGpNbFYxWlZjeGN3cFJTRXBzV201TmRtRkhWbWhhU0UxMllsZEdjR0pxUVRSQ1oyOXlRbWRGUlVGWlR5OU5RVVZMUWtOdlRVdEVaelZaZWtsM1RVZEthazlFUm1oWlZHUnRDazVVU1RKTmJVNXBUMVJTYVU5VVRUVlpWMDVvV2tSck0xbHFSbTFhUkVVd1RrUlJkMGhSV1V0TGQxbENRa0ZIUkhaNlFVSkRkMUZRUkVFeGJtRllVbThLWkZkSmRHRkhPWHBrUjFaclRVUm5SME5wYzBkQlVWRkNaemM0ZDBGUmQwVkxaM2R2WVVoU01HTklUVFpNZVRsdVlWaFNiMlJYU1hWWk1qbDBUREE1ZHdwYVZ6VkNXa2RHZDJSRlJrcE1NRGwzV2xjMVFscEhSbmRrUkVFMFFtZHZja0puUlVWQldVOHZUVUZGVGtKRGIwMUxSR2MxV1hwSmQwMUhTbXBQUkVab0NsbFVaRzFPVkVreVRXMU9hVTlVVW1sUFZFMDFXVmRPYUZwRWF6Tlpha1p0V2tSRk1FNUVVWGRJZDFsTFMzZFpRa0pCUjBSMmVrRkNSR2RSVWtSQk9Ya0tXbGRhZWt3eWFHeFpWMUo2VERJeGFHRlhOSGRIVVZsTFMzZFpRa0pCUjBSMmVrRkNSSGRSVEVSQmF6Sk5hbU4zVFdwUk5FNVVRWGRNWjFsTFMzZFpRZ3BDUVVkRWRucEJRa1ZCVVdkRVFqVnZaRWhTZDJONmIzWk1NbVJ3WkVkb01WbHBOV3BpTWpCMlZETkNiR0pyUm10WldFSXdVVlZyZDBkUldVdExkMWxDQ2tKQlIwUjJla0ZDUlZGUlRFUkJhM2hOZWtreVQwUkZlVTFVWTNkaVoxbExTM2RaUWtKQlIwUjJla0ZDUldkU1owUkdOVzlrU0ZKM1kzcHZka3d5WkhBS1pFZG9NVmxwTldwaU1qQjJWRE5DYkdKclJtdFpXRUl3VVZWcmRsUXpRbXhpYTBacldWaENNRXg1Tlc1aFdGSnZaRmRKZG1ReU9YbGhNbHB6WWpOa2VncE1NbEpzWXpKME1HSXpRWFJqYlZaeldsZEdlbHBUTlRWaVYzaEJZMjFXYldONU9XOWFWMFpyWTNrNWRGbFhiSFZOUkdkSFEybHpSMEZSVVVKbk56aDNDa0ZTVFVWTFozZHZUMFJzYWsxcVFYZFpiVTAwVFZkR2FFNHlXVEZOYWxsNVdUSkpOVTVIU1RWTmVteG9XVEpHYTA5VVpHbE5WMXByVFZSUk1FNUVRV01LUW1kdmNrSm5SVVZCV1U4dlRVRkZWVUpCTkUxRVNHUjJZMjEwYldKSE9UTllNMG94WW1wQ1kwSm5iM0pDWjBWRlFWbFBMMDFCUlZaQ1JUUk5WRWRvTUFwa1NFSjZUMms0ZGxveWJEQmhTRlpwVEcxT2RtSlRPVkJqUjFaMVVWZFNhR05JVWtKVFV6bFFZMGRXZFZGWFVtaGpTRkYyV1ZkT01HRlhPWFZqZVRsNUNtUlhOWHBNZWxGM1RucFZlRTFFYXpCTlZFa3pUREpHTUdSSFZuUmpTRko2VEhwRmQwWm5XVXRMZDFsQ1FrRkhSSFo2UVVKR1oxRkpSRUZhZDJSWFNuTUtZVmROZDFCbldVdExkMWxDUWtGSFJIWjZRVUpIUVZGM1JFTTFlVnBZUW5aUGF6bDNXbGMxUWxwSFJuZGtSVVpLVERBNWQxcFhOVUphUjBaM1pFUndlUXBhVjFrMlkyMVdiV041T1c5YVYwWnJZM2s1ZEZsWGJIVk5TVWRLUW1kdmNrSm5SVVZCWkZvMVFXZFJRMEpJYzBWbFVVSXpRVWhWUVROVU1IZGhjMkpJQ2tWVVNtcEhValJqYlZkak0wRnhTa3RZY21wbFVFc3pMMmcwY0hsblF6aHdOMjgwUVVGQlIyaG5MMngyZDNkQlFVSkJUVUZTYWtKRlFXbENURVl4VDNJS1oxaE1ZWGxwT0hjelJVcHdUbmRPUjAxUmVHRTFiRkZFYXpWQ01EVklTa3BsY1U1V2FYZEpaMHhXZEdwTFNXVjJXVTB2V1ZnckswaEhVVkZOU25oQkt3cHlWbnBoU0dOUFoyUnZPVzQxVkhkTmFIRk5kME5uV1VsTGIxcEplbW93UlVGM1RVUmhVVUYzV21kSmVFRlBTa1pEU1VWcWFGZDBaa1JNWkZCV1IyWXdDbHBXVGxVd1QyUmlkMU56UlVZM1QyRTFka05TYzFZcmIyRlVWbXRRV1ZaMGMwbFdRMlozWmtkRVdreEhSMEZKZUVGTVVqUXZUbU41VlVKVFNIWlRVRVVLZUdsTVZYQkJSbEpqZURRMU9UaEVjVm81UjJWaVlYaEtiMWhWVUUxR1RVdEJaREZYY2podlQyOTRSMDlsYm5CbGFIYzlQUW90TFMwdExVVk9SQ0JEUlZKVVNVWkpRMEZVUlMwdExTMHRDZz09In1dfX0="}], "timestampVerificationData": {}, "certificate": {"rawBytes": "MIIHETCCBpagAwIBAgIUXsMDsLK4bwHED34a+ycmTMeS0CkwCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjYxMDI4MTkyNTI0WhcNMjYxMDI4MTkzNTI0WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEYWlY0Ym33L5OO/NbG7uk7X9+2whalyqj1xBnTFNkavmE7tqisKrb8jSxPtSjex+cSnkwa4b4eoIArL6uAdMTaOCBbUwggWxMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAfBgNVHSMEGDAWgBTf0+nPViQRlvmo2OkoVaLGLhhkPzBsBgNVHREBAf8EYjBghl5odHRwczovL2dpdGh1Yi5jb20vT3BlbkFkYXB0QUkvT3BlbkFkYXB0Ly5naXRodWIvd29ya2Zsb3dzL2Rlc2t0b3AtcmVsZWFzZS55bWxAcmVmcy9oZWFkcy9tYWluMDkGCisGAQQBg78wAQEEK2h0dHBzOi8vdG9rZW4uYWN0aW9ucy5naXRodWJ1c2VyY29udGVudC5jb20wGgYKKwYBBAGDvzABAgQMd29ya2Zsb3dfcnVuMDYGCisGAQQBg78wAQMEKDg5YzIwMGJjODFhYTdmNTI2MmNiOTRiOTM5YWNhZDk3YjFmZDE0NDQwHQYKKwYBBAGDvzABBAQPRGVza3RvcCByZWxlYXNlMCMGCisGAQQBg78wAQUEFU9wZW5BZGFwdEFJL09wZW5BZGFwdDAdBgorBgEEAYO/MAEGBA9yZWZzL2hlYWRzL21haW4wOwYKKwYBBAGDvzABCAQtDCtodHRwczovL3Rva2VuLmFjdGlvbnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tMG4GCisGAQQBg78wAQkEYAxeaHR0cHM6Ly9naXRodWIuY29tL09wZW5BZGFwdEFJL09wZW5BZGFwdC8uZ2l0aHViL3dvcmtmbG93cy9kZXNrdG9wLXJlbGVhc2UueW1sQHJlZnMvaGVhZHMvbWFpbjA4BgorBgEEAYO/MAEKBCoMKDg5YzIwMGJjODFhYTdmNTI2MmNiOTRiOTM5YWNhZDk3YjFmZDE0NDQwHQYKKwYBBAGDvzABCwQPDA1naXRodWItaG9zdGVkMDgGCisGAQQBg78wAQwEKgwoaHR0cHM6Ly9naXRodWIuY29tL09wZW5BZGFwdEFJL09wZW5BZGFwdDA4BgorBgEEAYO/MAENBCoMKDg5YzIwMGJjODFhYTdmNTI2MmNiOTRiOTM5YWNhZDk3YjFmZDE0NDQwHwYKKwYBBAGDvzABDgQRDA9yZWZzL2hlYWRzL21haW4wGQYKKwYBBAGDvzABDwQLDAk2MjcwMjQ4NTAwLgYKKwYBBAGDvzABEAQgDB5odHRwczovL2dpdGh1Yi5jb20vT3BlbkFkYXB0QUkwGQYKKwYBBAGDvzABEQQLDAkxMzI2ODEyMTcwbgYKKwYBBAGDvzABEgRgDF5odHRwczovL2dpdGh1Yi5jb20vT3BlbkFkYXB0QUkvT3BlbkFkYXB0Ly5naXRodWIvd29ya2Zsb3dzL2Rlc2t0b3AtcmVsZWFzZS55bWxAcmVmcy9oZWFkcy9tYWluMDgGCisGAQQBg78wARMEKgwoODljMjAwYmM4MWFhN2Y1MjYyY2I5NGI5MzlhY2FkOTdiMWZkMTQ0NDAcBgorBgEEAYO/MAEUBA4MDHdvcmtmbG93X3J1bjBcBgorBgEEAYO/MAEVBE4MTGh0dHBzOi8vZ2l0aHViLmNvbS9PcGVuQWRhcHRBSS9PcGVuQWRhcHQvYWN0aW9ucy9ydW5zLzQwNzUxMDk0MTI3L2F0dGVtcHRzLzEwFgYKKwYBBAGDvzABFgQIDAZwdWJsaWMwPgYKKwYBBAGDvzABGAQwDC5yZXBvOk9wZW5BZGFwdEFJL09wZW5BZGFwdDpyZWY6cmVmcy9oZWFkcy9tYWluMIGJBgorBgEEAdZ5AgQCBHsEeQB3AHUA3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4AAAGhg/lvwwAABAMARjBEAiBLF1OrgXLayi8w3EJpNwNGMQxa5lQDk5B05HJJeqNViwIgLVtjKIevYM/YX++HGQQMJxA+rVzaHcOgdo9n5TwMhqMwCgYIKoZIzj0EAwMDaQAwZgIxAOJFCIEjhWtfDLdPVGf0ZVNU0OdbwSsEF7Oa5vCRsV+oaTVkPYVtsIVCfwfGDZLGGAIxALR4/NcyUBSHvSPExiLUpAFRcx4598DqZ9GebaxJoXUPMFMKAd1Wr8oOoxGOenpehw=="}}, "dsseEnvelope": {"payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoiU0hBMjU2U1VNUyIsImRpZ2VzdCI6eyJzaGEyNTYiOiJkYjdiMWI1M2UxMDdiZGQwNmJlYTdiMDk2OWY1MmM1MmNmY2ZmMTcwNjhiOWFjNTQzZTQ2M2Y4YTdlYWJkOTIwIn19XSwicHJlZGljYXRlVHlwZSI6Imh0dHBzOi8vc2xzYS5kZXYvcHJvdmVuYW5jZS92MSIsInByZWRpY2F0ZSI6eyJidWlsZERlZmluaXRpb24iOnsiYnVpbGRUeXBlIjoiaHR0cHM6Ly9hY3Rpb25zLmdpdGh1Yi5pby9idWlsZHR5cGVzL3dvcmtmbG93L3YxIiwiZXh0ZXJuYWxQYXJhbWV0ZXJzIjp7IndvcmtmbG93Ijp7InJlZiI6InJlZnMvaGVhZHMvbWFpbiIsInJlcG9zaXRvcnkiOiJodHRwczovL2dpdGh1Yi5jb20vT3BlbkFkYXB0QUkvT3BlbkFkYXB0IiwicGF0aCI6Ii5naXRodWIvd29ya2Zsb3dzL2Rlc2t0b3AtcmVsZWFzZS55bWwifX0sImludGVybmFsUGFyYW1ldGVycyI6eyJnaXRodWIiOnsiZXZlbnRfbmFtZSI6IndvcmtmbG93X3J1biIsInJlcG9zaXRvcnlfaWQiOiI2MjcwMjQ4NTAiLCJyZXBvc2l0b3J5X293bmVyX2lkIjoiMTMyNjgxMjE3IiwicnVubmVyX2Vudmlyb25tZW50IjoiZ2l0aHViLWhvc3RlZCJ9fSwicmVzb2x2ZWREZXBlbmRlbmNpZXMiOlt7InVyaSI6ImdpdCtodHRwczovL2dpdGh1Yi5jb20vT3BlbkFkYXB0QUkvT3BlbkFkYXB0QHJlZnMvaGVhZHMvbWFpbiIsImRpZ2VzdCI6eyJnaXRDb21taXQiOiI4OWMyMDBiYzgxYWE3ZjUyNjJjYjk0YjkzOWFjYWQ5N2IxZmQxNDQ0In19XX0sInJ1bkRldGFpbHMiOnsiYnVpbGRlciI6eyJpZCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9PcGVuQWRhcHRBSS9PcGVuQWRhcHQvLmdpdGh1Yi93b3JrZmxvd3MvZGVza3RvcC1yZWxlYXNlLnltbEByZWZzL2hlYWRzL21haW4ifSwibWV0YWRhdGEiOnsiaW52b2NhdGlvbklkIjoiaHR0cHM6Ly9naXRodWIuY29tL09wZW5BZGFwdEFJL09wZW5BZGFwdC9hY3Rpb25zL3J1bnMvNDA3NTEwOTQxMjcvYXR0ZW1wdHMvMSJ9fX19", "payloadType": "application/vnd.in-toto+json", "signatures": [{"sig": "MEUCIQD0IWqh+wte6DVWC3YQX1tz7UwDErUIAWQ2fqZzBHJGVwIgZIXur3rMjQxFFiq5ctYBRQf/26jzV5FdP8qTbRgqEZ0="}]}} \ No newline at end of file diff --git a/packages/flow/tests/test_registry.py b/packages/flow/tests/test_registry.py index 81264fcef..de583793e 100644 --- a/packages/flow/tests/test_registry.py +++ b/packages/flow/tests/test_registry.py @@ -13,6 +13,13 @@ ROOT = Path(__file__).resolve().parents[1] +@pytest.fixture(autouse=True) +def _own_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Each test's pull ledger and serials live in its own OPENADAPT_HOME.""" + + monkeypatch.setenv("OPENADAPT_HOME", str(tmp_path / "openadapt-home")) + + def _local_registry(tmp_path: Path) -> Path: dest = tmp_path / "registry" shutil.copytree(ROOT / "registry", dest) @@ -24,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 000000000..12c507f74 --- /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 000000000..241876978 --- /dev/null +++ b/packages/flow/tests/test_skills_registry_review.py @@ -0,0 +1,963 @@ +"""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 + + from openadapt_flow.emit.mcp_tool import emit_mcp_server + + dest = _qualified(tmp_path, monkeypatch) + server = emit_mcp_server(dest, tmp_path / "mcp" / "server.py") + spec = importlib.util.spec_from_file_location("emitted_server", server) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + tool = next( + fn + for name, fn in vars(module).items() + if inspect.isfunction(fn) + and fn.__module__ == "emitted_server" + and "url" in inspect.signature(fn).parameters + ) + params = {name: "x" for name in inspect.signature(tool).parameters if name != "url"} + out = tool(url="https://production.example/", **params) + assert out["outcome"] == "REFUSED" + assert "not https://production.example" in out["detail"] + + +# -- 8. a pulled skill qualifies with the contract it ships ------------------------------------ + + +def test_replacing_a_shipped_contract_takes_an_explicit_opt_in(tmp_path): + dest = _pull(tmp_path) + own = tmp_path / "mine.yaml" + own.write_text( + CONTRACT.replace("${APP_URL}", "https://emr.example.org") + .replace("${SIGNED_IN_STATE}", str(own)) + .replace("${RESET_COMMAND}", "/usr/bin/true") + .replace("${RECORD_URL}", "https://emr.example.org") + .replace("${RECORD_TOKEN_VAR}", "MY_EMR_TOKEN") + ) + with pytest.raises(skills.SkillError, match="--replace-contract"): + skills.qualify_skill(dest, certify="p", contract=own, run=_resetting_run({})) + seen = {} + skills.qualify_skill( + dest, + certify="p", + contract=own, + replace_contract=True, + run=lambda bundle, config, **kw: ( + seen.update(origin=config.app.origin), + SimpleNamespace(certified=True), + )[1], + ) + lock = skills.read_lock(dest) + assert lock["qualified"]["contract"] == "yours" + assert lock["qualified"]["contract_opt_in"] is True + + +def test_a_qualification_with_an_unchosen_contract_is_refused(tmp_path, monkeypatch): + dest = _qualified(tmp_path, monkeypatch) + _record_qualified(dest, contract="yours", opt_in=False) + assert "a contract it doesn't ship" in skills.pulled_skill_refusal(dest) + + +# -- 9. archives: sizes before reads, https, and the index's own directory ----------------------- + + +def _archive(members): + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + for name, data in members: + info = tarfile.TarInfo(name) + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + blob = buf.getvalue() + return blob, {"sha256": hashlib.sha256(blob).hexdigest()} + + +def test_an_oversized_member_is_refused_before_it_is_read(tmp_path, monkeypatch): + blob, archive = _archive([("manifest.json", b"\0" * (registry.MAX_FILE_BYTES + 1))]) + (tmp_path / "a.tar.gz").write_bytes(blob) + + def no_extract(*a, **k): + raise AssertionError("a member was read before its size was checked") + + monkeypatch.setattr(tarfile.TarFile, "extractfile", no_extract) + with pytest.raises(registry.RegistryError, match="larger than"): + registry._unpack_archive( + {**archive, "url": "a.tar.gz"}, + expected={"manifest.json"}, + local_root=tmp_path, + ) + + +def test_an_archive_that_unpacks_too_large_is_refused(tmp_path, monkeypatch): + blob, archive = _archive( + [("workflow.json", b"1" * 600), ("manifest.json", b"2" * 600)] + ) + (tmp_path / "a.tar.gz").write_bytes(blob) + monkeypatch.setattr(registry, "MAX_ARCHIVE_BYTES", 1000) + with pytest.raises(registry.RegistryError, match="unpacks to more than"): + registry._unpack_archive( + {**archive, "url": "a.tar.gz"}, + expected={"workflow.json", "manifest.json"}, + local_root=tmp_path, + ) + + +def test_an_archive_member_the_entry_does_not_list_is_refused(tmp_path): + blob, archive = _archive([("evil.py", b"print(1)")]) + (tmp_path / "a.tar.gz").write_bytes(blob) + with pytest.raises(registry.RegistryError, match="not in the entry"): + registry._unpack_archive( + {**archive, "url": "a.tar.gz"}, + expected={"workflow.json"}, + local_root=tmp_path, + ) + + +def test_an_archive_or_file_must_come_from_https_or_the_index_directory(tmp_path): + outside = tmp_path / "outside.tar.gz" + blob, archive = _archive([("workflow.json", b"{}")]) + outside.write_bytes(blob) + with pytest.raises(registry.RegistryError, match="https address"): + registry._unpack_archive( + {**archive, "url": str(outside)}, expected={"workflow.json"} + ) + (tmp_path / "index").mkdir() + with pytest.raises(registry.RegistryError, match="outside the index's directory"): + registry._unpack_archive( + {**archive, "url": str(outside)}, + expected={"workflow.json"}, + local_root=tmp_path / "index", + ) + with pytest.raises(registry.RegistryError, match="plain http"): + registry._read("http://example.org/x") + item = { + "id": "a/b", + "status": "example", + "path": "bundles/../../etc", + "files": {"workflow.json": "0"}, + } + with pytest.raises(registry.RegistryError, match="unsafe path"): + registry.validate_index({"workflows": [item]}) + + +# -- lower: an allowlist of public files, screens, versions, the signing environment ----- + + +@pytest.mark.parametrize( + "name", + [ + "verify.full.yml", + "Faults.yaml", + "Rows.csv", + "notes.txt", + "hooks.py", + "templates/x.jpg", + ], +) +def test_a_public_bundle_holds_only_allowlisted_files(name): + item = {"id": "a/b", "status": "example", "files": {name: "0" * 64}} + with pytest.raises(registry.RegistryError, match="private tier|may hold"): + registry.validate_index({"workflows": [item]}) + + +def test_screen_templates_need_the_synthetic_data_attestation(): + item = { + "id": "a/b", + "status": "example", + "files": {"workflow.json": "0", "templates/t.png": "0"}, + } + with pytest.raises(registry.RegistryError, match="synthetic"): + registry.validate_index({"workflows": [item]}) + item["screens"] = {"data": "synthetic"} + registry.validate_index({"workflows": [item]}) + + +def test_a_release_outranks_its_pre_release(): + entries = registry.validate_index( + { + "workflows": [ + { + "id": "a/b", + "version": v, + "status": "example", + "files": {"workflow.json": "0"}, + } + for v in ("1.2.0", "1.2.0-rc.1", "1.10.0-rc.2", "1.9.0") + ] + } + ) + assert registry.find(entries, "a/b").version == "1.10.0-rc.2" + ordered = sorted( + ("1.2.0", "1.2.0-rc.1", "1.2.0-rc.10", "1.2.0-rc.2"), key=registry._version_key + ) + assert ordered == ["1.2.0-rc.1", "1.2.0-rc.2", "1.2.0-rc.10", "1.2.0"] + + +def test_the_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 dbb396083..b28e9d7ea 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" From 4ba570b96cb991aa11974e213544631ed78b6b91 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 28 Oct 2026 20:45:12 -0400 Subject: [PATCH 2/4] Odoo 18 new contact: a refused qualification, and a shipped SQL check judged by its effect The registry's first non-healthcare candidate, on a synthetic Odoo 18 fixture. Qualification under clinical-write refuses, as it should, on short-label clicks and an icon-only save button, so no entry ships. A shipped SQL read-back now runs on a driver-level read-only session, and must be SELECT FROM with inputs bound only after WHERE, so a select list that echoes the inputs can't make a wrong save verify. --- .../flow/benchmark/odoo_contacts/README.md | 59 ++++ .../flow/benchmark/odoo_contacts/compose.yml | 41 +++ packages/flow/benchmark/odoo_contacts/demo.py | 121 +++++++ .../odoo_contacts/environment.lock.json | 12 + .../flow/benchmark/odoo_contacts/fixture.py | 304 ++++++++++++++++++ .../benchmark/odoo_contacts/qualify_hooks.py | 142 ++++++++ .../qualification/qualify-notes.json | 7 + .../qualification/qualify.log | 13 + .../qualification/verify.resolved.json | 132 ++++++++ .../flow/benchmark/odoo_contacts/verify.yaml | 58 ++++ packages/flow/openadapt_flow/deployment.py | 20 +- .../openadapt_flow/runtime/effects/sql.py | 36 ++- packages/flow/openadapt_flow/skills.py | 101 +++++- .../flow/registry/verify-requirements.txt | 76 ++++- packages/flow/scripts/build_registry_index.py | 27 ++ packages/flow/tests/test_shipped_sql.py | 245 ++++++++++++++ 16 files changed, 1387 insertions(+), 7 deletions(-) create mode 100644 packages/flow/benchmark/odoo_contacts/README.md create mode 100644 packages/flow/benchmark/odoo_contacts/compose.yml create mode 100644 packages/flow/benchmark/odoo_contacts/demo.py create mode 100644 packages/flow/benchmark/odoo_contacts/environment.lock.json create mode 100644 packages/flow/benchmark/odoo_contacts/fixture.py create mode 100644 packages/flow/benchmark/odoo_contacts/qualify_hooks.py create mode 100644 packages/flow/benchmark/odoo_contacts/results/2026-10-28-click-teach/qualification/qualify-notes.json create mode 100644 packages/flow/benchmark/odoo_contacts/results/2026-10-28-click-teach/qualification/qualify.log create mode 100644 packages/flow/benchmark/odoo_contacts/results/2026-10-28-click-teach/qualification/verify.resolved.json create mode 100644 packages/flow/benchmark/odoo_contacts/verify.yaml create mode 100644 packages/flow/tests/test_shipped_sql.py diff --git a/packages/flow/benchmark/odoo_contacts/README.md b/packages/flow/benchmark/odoo_contacts/README.md new file mode 100644 index 000000000..fbaebec75 --- /dev/null +++ b/packages/flow/benchmark/odoo_contacts/README.md @@ -0,0 +1,59 @@ +# Odoo 18 Community: create a contact + +The skills registry's first non-healthcare candidate. Odoo Community is +LGPL-3.0. Its images are pulled by digest at run time +(`environment.lock.json`), and nothing of Odoo is stored here. All data is +synthetic. + +| File | What it is | +| --- | --- | +| `compose.yml`, `fixture.py` | The test copy: Odoo 18.0 and Postgres 16 on loopback ports 9412 and 9413, the `oa` database with Contacts and no demo data. A dedicated user (`openadapt.actor@example.invalid`, with Contact Creation) and a read-only role for the record check (`oa_record_check`). | +| `qualify_hooks.py` | Signs the dedicated user in, opens a new contact, archives contacts between cases, and writes a signed-in browser state (what a registry user binds as `SIGNED_IN_STATE`). | +| `demo.py` | The scripted demonstration (choose Individual, fill name, street, city, ZIP, phone and email, save), then compile. | +| `verify.yaml` | The record check: one parameterized, read-only SELECT of the typed fields from `res_partner`, keyed by email. | +| `results/2026-10-28-click-teach/qualification/` | The qualification evidence. The compiled bundle isn't committed; `demo.py compile` rebuilds it. | + +```bash +cd packages/flow +export ODOO_RECORD_PASSWORD=... # from the fixture's secrets.json +PYTHONPATH=.:benchmark/odoo_contacts python benchmark/odoo_contacts/demo.py record --out REC +PYTHONPATH=.:benchmark/odoo_contacts python benchmark/odoo_contacts/demo.py compile --recording REC --bundle B +PYTHONPATH=.:benchmark/odoo_contacts openadapt-flow qualify B \ + --verify benchmark/odoo_contacts/verify.yaml --out Q --certify clinical-write +``` + +`PYTHONPATH` includes this folder because an installed `benchmark` package +can shadow this repository's namespace package. + +## Result (2026-10-28) + +The demonstration saved the contact, and the record check's SELECT read all +six fields back. The compiled program has 14 steps and 6 parameters. +Qualification under `clinical-write` refused, as it should, before any case +ran: + +- **Short labels.** The clicks into City and Phone are unarmed: the only + text beside each is a one-word label, too little for an identity check. + Reaching them by keyboard from a checked click (Street, Email) works. + Those keystrokes need the focus check (#1579) to carry a postcondition. +- **An icon-only save button.** Odoo's save button is a cloud icon with no + text, so its click can't carry an identity check. This is the same class + as Medplum's Create button (#1581, form identity). Odoo's own save + shortcut is Control+S on macOS and Alt+S elsewhere, so a taught shortcut + would replay on one platform only. + +So this skill isn't in the registry yet. Under the threat model (#1598) the +record check is what proves the save, and identity checks are fail-closed +pre-checks only, so the next step is the icon-button identity in #1581, not +a page heuristic here. + +## Setting the fixture up + +- Odoo keeps a long-polling connection open, so a page never goes network + idle. The hooks wait for the form instead. +- A plain internal user can't create contacts, and Odoo then shows no save + button. The dedicated user needs Contact Creation + (`base.group_partner_manager`). + +Effort: about 0.8 hours of lease time, from bring-up to the refused +qualification. diff --git a/packages/flow/benchmark/odoo_contacts/compose.yml b/packages/flow/benchmark/odoo_contacts/compose.yml new file mode 100644 index 000000000..53fd023d7 --- /dev/null +++ b/packages/flow/benchmark/odoo_contacts/compose.yml @@ -0,0 +1,41 @@ +# Odoo 18 Community (LGPL-3.0), self-hosted for the skills registry's first +# non-healthcare entry: create a contact. Synthetic data only. Images are +# pinned by digest; the web and database ports are published on loopback only; +# secrets come +# from the fixture's generated state (fixture.py). +services: + db: + image: ${ODOO_PG_IMAGE} + environment: + POSTGRES_USER: odoo + POSTGRES_PASSWORD: ${ODOO_DB_PASSWORD} + POSTGRES_DB: postgres + # Loopback only: the record check reads res_partner through a read-only role. + ports: + - "127.0.0.1:${ODOO_DB_PORT}:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U odoo"] + interval: 5s + timeout: 5s + retries: 30 + + odoo: + image: ${ODOO_IMAGE} + depends_on: + db: + condition: service_healthy + environment: + HOST: db + USER: odoo + PASSWORD: ${ODOO_DB_PASSWORD} + command: ["odoo", "--db-filter=^oa$", "--without-demo=all", "--proxy-mode"] + ports: + - "127.0.0.1:${ODOO_PORT}:8069" + volumes: + - odoodata:/var/lib/odoo + +volumes: + pgdata: + odoodata: diff --git a/packages/flow/benchmark/odoo_contacts/demo.py b/packages/flow/benchmark/odoo_contacts/demo.py new file mode 100644 index 000000000..0c0df37c1 --- /dev/null +++ b/packages/flow/benchmark/odoo_contacts/demo.py @@ -0,0 +1,121 @@ +"""Teach Odoo's new-contact form: a scripted demonstration, then compile. + + PYTHONPATH=.:benchmark/odoo_contacts python benchmark/odoo_contacts/demo.py \ + record --out DIR [--values FILE] + PYTHONPATH=.:benchmark/odoo_contacts python benchmark/odoo_contacts/demo.py \ + compile --recording DIR --bundle DIR + +Signing in and opening a new contact are setup, not part of the +demonstration (``qualify_hooks.open_session``). The demonstration marks the +contact an Individual (the form starts on Company), fills it, and saves it +with the form's save button. Every action goes through the ``Recorder``, so +the frames and events are what a person's demonstration would leave; record +time uses Playwright locators only to find where to click. + +Synthetic values only. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +#: The demonstration's contact (synthetic). Replays bring their own values. +DEFAULT_VALUES = { + "name": "Rowan Achterberg", + "street": "41 Quarry Lane", + "city": "Worcester", + "zip": "01609", + "phone": "508-555-0147", + "email": "rowan.achterberg@example.invalid", +} + +SETTLE = { + "settle_timeout_s": 10.0, + "settle_stable_frames": 3, + "settle_interval_s": 0.3, +} + + +def _click(recorder: Any, page: Any, locator: Any) -> None: + box = locator.bounding_box() + if box is None: + raise RuntimeError("target is not on the page") + recorder.click(int(box["x"] + box["width"] / 2), int(box["y"] + box["height"] / 2)) + page.wait_for_timeout(300) + + +def demonstrate(recorder: Any, page: Any, values: dict[str, str]) -> None: + """Fill a new person contact once and save it.""" + + _click(recorder, page, page.get_by_label("Individual")) + _click(recorder, page, page.locator("div[name=name] input")) + recorder.type_text(values["name"], param="name") + for placeholder, param in (("Street...", "street"), ("City", "city"), ("ZIP", "zip")): + _click(recorder, page, page.get_by_placeholder(placeholder, exact=True)) + recorder.type_text(values[param], param=param) + # Phone and Email have a label beside them, not a placeholder. + for param in ("phone", "email"): + _click(recorder, page, page.locator(f"#{param}_0")) + recorder.type_text(values[param], param=param) + _click(recorder, page, page.get_by_role("button", name="Save manually")) + page.wait_for_url(lambda url: not url.rstrip("/").endswith("/new"), timeout=30_000) + page.wait_for_timeout(1500) + + +def cmd_record(args: argparse.Namespace) -> int: + from types import SimpleNamespace + + import qualify_hooks + + from openadapt_flow.recorder import Recorder + + out = Path(args.out) + if out.exists(): + raise SystemExit(f"recording path already exists: {out}") + values = dict(DEFAULT_VALUES) + if args.values: + values.update(json.loads(Path(args.values).read_text())) + config = SimpleNamespace(app=SimpleNamespace(viewport=(1280, 900))) + backend, close = qualify_hooks.open_session(config) + try: + recorder = Recorder(backend, out, app_url=backend.page.url, **SETTLE) + demonstrate(recorder, backend.page, values) + print("recording:", recorder.finish()) + print("saved:", backend.page.url) + finally: + close() + return 0 + + +def cmd_compile(args: argparse.Namespace) -> int: + from openadapt_flow.compiler import compile_recording + + bundle = Path(args.bundle) + if bundle.exists(): + raise SystemExit(f"bundle path already exists: {bundle}") + workflow = compile_recording(Path(args.recording), bundle, name="odoo-new-contact") + print("bundle:", bundle, "steps:", len(workflow.steps)) + print("params:", sorted(workflow.params) if workflow.params else []) + return 0 + + +def main(argv: Any = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + sub = parser.add_subparsers(dest="cmd", required=True) + p = sub.add_parser("record") + p.add_argument("--out", required=True) + p.add_argument("--values", default=None, help="JSON file of field values") + p.set_defaults(func=cmd_record) + p = sub.add_parser("compile") + p.add_argument("--recording", required=True) + p.add_argument("--bundle", required=True) + p.set_defaults(func=cmd_compile) + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/flow/benchmark/odoo_contacts/environment.lock.json b/packages/flow/benchmark/odoo_contacts/environment.lock.json new file mode 100644 index 000000000..089a73634 --- /dev/null +++ b/packages/flow/benchmark/odoo_contacts/environment.lock.json @@ -0,0 +1,12 @@ +{ + "schema_version": 1, + "generated_for": "OpenAdapt skills registry: Odoo 18 Community, create a contact (synthetic data)", + "license": "LGPL-3.0 (Odoo Community); images pulled at run time, never stored in this repository", + "services": { + "odoo": "odoo:18.0@sha256:9668abacd894276ed009e8db7db1d1ffb8068af6982f76a3400ed0d68e40432d", + "postgres": "postgres:16@sha256:1beaa3084eee2676f20a9cd9928f0bf2c5bf674f8e49f81017912183d377c61c" + }, + "platforms": "both images publish linux/arm64 and linux/amd64", + "project": "openadapt-si-odoo", + "database": "oa" +} diff --git a/packages/flow/benchmark/odoo_contacts/fixture.py b/packages/flow/benchmark/odoo_contacts/fixture.py new file mode 100644 index 000000000..04a0ccdbc --- /dev/null +++ b/packages/flow/benchmark/odoo_contacts/fixture.py @@ -0,0 +1,304 @@ +"""A local Odoo 18 Community (LGPL-3.0) with synthetic contacts. + +The skills registry's first non-healthcare entry: create a contact in the +Contacts app. Odoo's images are pulled by digest at run time +(environment.lock.json); nothing of Odoo is stored here. + +- The web app (``ui_url``) is what a clerk uses. +- The record check reads ``res_partner`` through a read-only database role + (``oa_record_check``) on the loopback database port. +- The oracle reads ``res_partner`` as the database owner from inside the + database container, a channel the product never uses. + +Generated secrets live in ``state_dir``, outside the repository. All data is +synthetic. +""" + +from __future__ import annotations + +import json +import os +import secrets +import subprocess +import time +import urllib.request +import xmlrpc.client +from pathlib import Path +from typing import Any, Optional +from urllib.error import HTTPError, URLError + +HERE = Path(__file__).resolve().parent +COMPOSE = HERE / "compose.yml" +PROJECT = "openadapt-si-odoo" +WEB_PORT = 9412 +DB_PORT = 9413 +DATABASE = "oa" +DEFAULT_STATE = Path.home() / ".openadapt" / "fixtures" / "odoo-state" +ACTOR_LOGIN = "openadapt.actor@example.invalid" +RECORD_ROLE = "oa_record_check" + + +class FixtureError(RuntimeError): + pass + + +class OdooFixture: + def __init__(self, state_dir: Optional[Path] = None) -> None: + self.state_dir = Path( + state_dir or os.environ.get("ODOO_STATE_DIR") or DEFAULT_STATE + ) + self.lock = json.loads((HERE / "environment.lock.json").read_text()) + for name, image in self.lock["services"].items(): + if "@sha256:" not in image: + raise FixtureError(f"{name} image is not pinned by digest: {image}") + + @property + def ui_url(self) -> str: + return f"http://localhost:{WEB_PORT}/" + + # -- secrets and compose --------------------------------------------------- + + def secrets(self) -> dict[str, str]: + path = self.state_dir / "secrets.json" + if path.exists(): + return json.loads(path.read_text()) + self.state_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + data = { + "db_password": secrets.token_urlsafe(24), + "admin_password": secrets.token_urlsafe(18), + "actor_password": secrets.token_urlsafe(18), + "record_check_password": secrets.token_urlsafe(24), + } + path.touch(mode=0o600) + path.write_text(json.dumps(data, indent=2)) + return data + + def _env(self) -> dict[str, str]: + s = self.secrets() + images = self.lock["services"] + return { + "ODOO_IMAGE": images["odoo"], + "ODOO_PG_IMAGE": images["postgres"], + "ODOO_DB_PASSWORD": s["db_password"], + "ODOO_PORT": str(WEB_PORT), + "ODOO_DB_PORT": str(DB_PORT), + } + + def compose(self, *args: str, timeout: float = 1800) -> str: + proc = subprocess.run( + ["docker", "compose", "-p", PROJECT, "-f", str(COMPOSE), *args], + capture_output=True, + text=True, + env={**os.environ, **self._env()}, + timeout=timeout, + ) + if proc.returncode != 0: + raise FixtureError( + f"compose {' '.join(args[:2])} failed: {proc.stderr[-1500:]}" + ) + return proc.stdout + + def psql(self, sql: str) -> str: + return self.compose( + "exec", + "-T", + "db", + "psql", + "-U", + "odoo", + "-d", + DATABASE, + "-At", + "-v", + "ON_ERROR_STOP=1", + "-c", + sql, + ) + + # -- lifecycle ------------------------------------------------------------- + + def up(self, *, wait_s: float = 900.0) -> None: + """Start the database, create ``oa`` with Contacts once, start Odoo.""" + + self.compose("up", "-d", "db") + # Postgres answers (with 0 or 1) once it is up; 1 means ``oa`` exists. + self._wait(lambda: self._db_exists().strip() in ("0", "1"), wait_s, "database") + if self._db_exists().strip() != "1": + self.compose( + "run", + "--rm", + "odoo", + "odoo", + "-d", + DATABASE, + "-i", + "base,contacts", + "--without-demo=all", + "--stop-after-init", + ) + self.compose("up", "-d", "odoo") + self._wait(self._web_ready, wait_s, "Odoo web") + self.bootstrap() + + def down(self, *, volumes: bool = True) -> None: + self.compose("down", *(["-v"] if volumes else [])) + + def _db_exists(self) -> str: + try: + return self.compose( + "exec", + "-T", + "db", + "psql", + "-U", + "odoo", + "-d", + "postgres", + "-At", + "-c", + f"SELECT count(*) FROM pg_database WHERE datname = '{DATABASE}';", + ) + except FixtureError: + return "" + + def _web_ready(self) -> bool: + try: + with urllib.request.urlopen(f"{self.ui_url}web/login", timeout=5) as r: # noqa: S310 + return r.status == 200 + except (URLError, HTTPError, OSError): + return False + + @staticmethod + def _wait(check: Any, wait_s: float, what: str) -> None: + deadline = time.monotonic() + wait_s + while time.monotonic() < deadline: + if check(): + return + time.sleep(5) + raise FixtureError(f"{what} not ready after {wait_s}s") + + # -- accounts -------------------------------------------------------------- + + def _rpc(self, login: str, password: str) -> tuple[int, Any]: + common = xmlrpc.client.ServerProxy(f"{self.ui_url}xmlrpc/2/common") + uid = common.authenticate(DATABASE, login, password, {}) + if not uid: + raise FixtureError(f"Odoo refused the sign-in for {login}") + return uid, xmlrpc.client.ServerProxy(f"{self.ui_url}xmlrpc/2/object") + + def admin(self) -> tuple[int, Any, str]: + """The administrator's XML-RPC session (its default password replaced once).""" + + s = self.secrets() + try: + uid, models = self._rpc("admin", s["admin_password"]) + return uid, models, s["admin_password"] + except FixtureError: + uid, models = self._rpc("admin", "admin") + models.execute_kw( + DATABASE, + uid, + "admin", + "res.users", + "write", + [[uid], {"password": s["admin_password"]}], + ) + uid, models = self._rpc("admin", s["admin_password"]) + return uid, models, s["admin_password"] + + def bootstrap(self) -> None: + """The dedicated user and the record check's read-only role, once.""" + + s = self.secrets() + uid, models, pw = self.admin() + # An internal user who may create and edit contacts ("Contact + # Creation"); without it Odoo shows no save button. + groups = [ + models.execute_kw( + DATABASE, + uid, + pw, + "ir.model.data", + "check_object_reference", + ["base", xmlid], + )[1] + for xmlid in ("group_user", "group_partner_manager") + ] + found = models.execute_kw( + DATABASE, uid, pw, "res.users", "search", [[["login", "=", ACTOR_LOGIN]]] + ) + if found: + models.execute_kw( + DATABASE, + uid, + pw, + "res.users", + "write", + [found, {"groups_id": [[4, g] for g in groups]}], + ) + else: + models.execute_kw( + DATABASE, + uid, + pw, + "res.users", + "create", + [ + { + "name": "OpenAdapt Actor", + "login": ACTOR_LOGIN, + "password": s["actor_password"], + "groups_id": [[6, 0, groups]], + } + ], + ) + exists = self.psql( + f"SELECT count(*) FROM pg_roles WHERE rolname = '{RECORD_ROLE}';" + ) + verb = "ALTER" if exists.strip() == "1" else "CREATE" + self.psql( + f"{verb} ROLE {RECORD_ROLE} WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE " + f"PASSWORD '{s['record_check_password']}';" + ) + self.psql( + f"ALTER ROLE {RECORD_ROLE} SET default_transaction_read_only = on; " + f"GRANT SELECT ON res_partner TO {RECORD_ROLE};" + ) + + # -- the oracle and the harness's own writes --------------------------------- + + CONTACT_SQL = ( + "SELECT json_build_object('id', id::text, 'name', coalesce(name, ''), " + "'email', coalesce(email, ''), 'phone', coalesce(phone, ''), " + "'street', coalesce(street, ''), 'city', coalesce(city, ''), " + "'zip', coalesce(zip, '')) FROM res_partner " + "WHERE active AND NOT is_company AND type = 'contact' " + "AND id NOT IN (SELECT partner_id FROM res_users) " + "AND id NOT IN (SELECT partner_id FROM res_company) ORDER BY id;" + ) + + def contacts(self) -> list[dict[str, str]]: + """Every live person contact (not a user, not a company): the oracle.""" + + return [ + json.loads(line) + for line in self.psql(self.CONTACT_SQL).splitlines() + if line + ] + + def create_contact(self, fields: dict[str, str]) -> str: + uid, models, pw = self.admin() + return str( + models.execute_kw(DATABASE, uid, pw, "res.partner", "create", [fields]) + ) + + def archive_all_contacts(self) -> int: + """Reset: archive every person contact the benchmark made.""" + + ids = [int(c["id"]) for c in self.contacts()] + if ids: + uid, models, pw = self.admin() + models.execute_kw( + DATABASE, uid, pw, "res.partner", "write", [ids, {"active": False}] + ) + return len(ids) diff --git a/packages/flow/benchmark/odoo_contacts/qualify_hooks.py b/packages/flow/benchmark/odoo_contacts/qualify_hooks.py new file mode 100644 index 000000000..d939944e4 --- /dev/null +++ b/packages/flow/benchmark/odoo_contacts/qualify_hooks.py @@ -0,0 +1,142 @@ +"""Sign-in and reset for the local Odoo fixture. + +``benchmark/odoo_contacts/verify.yaml`` names these functions. They are +environment setup for a synthetic test copy, not workflow logic: + +- ``open_session`` signs the dedicated user into Odoo and opens a new + contact; +- ``reset`` archives every person contact (the cases create their own); +- ``save_signed_in_state`` writes a Playwright storage state for the + dedicated user: what a user of the registry skill binds as + ``SIGNED_IN_STATE``, since a shipped contract names no Python hooks. + +Environment (optional): ``ODOO_STATE_DIR``. +""" + +from __future__ import annotations + +import atexit +import importlib.util +import os +import sys +from pathlib import Path +from typing import Any, Callable + +_HERE = Path(__file__).resolve().parent + + +def _fixture_module() -> Any: + # Loaded by path: another installed ``benchmark`` package can shadow + # this repository's namespace package. + name = "openadapt_benchmark_odoo_fixture" + if name not in sys.modules: + spec = importlib.util.spec_from_file_location(name, _HERE / "fixture.py") + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return sys.modules[name] + + +_fixture: Any = None +_browser: Any = None + + +def fixture() -> Any: + global _fixture + if _fixture is None: + _fixture = _fixture_module().OdooFixture() + return _fixture + + +def record_password() -> str: + """The record check's read-only database password, exported for the verifier.""" + + password = fixture().secrets()["record_check_password"] + os.environ["ODOO_RECORD_PASSWORD"] = password + return password + + +def _shared_browser() -> Any: + global _browser + if _browser is None: + from playwright.sync_api import sync_playwright + + from openadapt_flow._browser_setup import ensure_chromium_installed + + ensure_chromium_installed() + pw = sync_playwright().start() + browser = pw.chromium.launch(headless=True) + _browser = (pw, browser) + + def _stop() -> None: + try: + browser.close() + finally: + pw.stop() + + atexit.register(_stop) + return _browser[1] + + +def sign_in(page: Any) -> None: + fx = fixture() + login = _fixture_module().ACTOR_LOGIN + page.goto(f"{fx.ui_url}web/login", wait_until="load", timeout=60_000) + page.locator("input[name=login]").fill(login) + page.locator("input[name=password]").fill(fx.secrets()["actor_password"]) + page.get_by_role("button", name="Log in").click() + page.wait_for_url(lambda url: "/web/login" not in url, timeout=60_000) + + +def open_form(page: Any) -> None: + fx = fixture() + page.goto(f"{fx.ui_url}odoo/contacts/new", wait_until="load", timeout=60_000) + page.locator(".o_form_view").wait_for(state="visible", timeout=30_000) + + +def open_session(config: Any) -> tuple[Any, Callable[[], None]]: + """A fresh signed-in context at a new contact.""" + + from openadapt_flow.backends.playwright_backend import PlaywrightBackend + + record_password() + width, height = config.app.viewport + context = _shared_browser().new_context( + viewport={"width": width, "height": height}, device_scale_factor=1 + ) + page = context.new_page() + sign_in(page) + open_form(page) + return PlaywrightBackend(page), context.close + + +def save_signed_in_state(path: str) -> str: + """Write the dedicated user's signed-in browser state to ``path``.""" + + context = _shared_browser().new_context() + try: + page = context.new_page() + sign_in(page) + context.storage_state(path=path) + finally: + context.close() + os.chmod(path, 0o600) + return path + + +def reset() -> None: + """Archive every person contact on the test copy.""" + + fixture().archive_all_contacts() + record_password() + + +if __name__ == "__main__": + # python benchmark/odoo_contacts/qualify_hooks.py state PATH | reset + if sys.argv[1:2] == ["state"]: + print(save_signed_in_state(sys.argv[2])) + elif sys.argv[1:2] == ["reset"]: + reset() + else: + raise SystemExit("usage: qualify_hooks.py state PATH | reset") diff --git a/packages/flow/benchmark/odoo_contacts/results/2026-10-28-click-teach/qualification/qualify-notes.json b/packages/flow/benchmark/odoo_contacts/results/2026-10-28-click-teach/qualification/qualify-notes.json new file mode 100644 index 000000000..6dff7c625 --- /dev/null +++ b/packages/flow/benchmark/odoo_contacts/results/2026-10-28-click-teach/qualification/qualify-notes.json @@ -0,0 +1,7 @@ +[ + { + "change": "record_checks_from_verify_yaml", + "effects": 6, + "step": "step_013" + } +] diff --git a/packages/flow/benchmark/odoo_contacts/results/2026-10-28-click-teach/qualification/qualify.log b/packages/flow/benchmark/odoo_contacts/results/2026-10-28-click-teach/qualification/qualify.log new file mode 100644 index 000000000..bc781d9de --- /dev/null +++ b/packages/flow/benchmark/odoo_contacts/results/2026-10-28-click-teach/qualification/qualify.log @@ -0,0 +1,13 @@ +qualify REFUSED: REFUSE: workflow 'odoo-new-contact' under 'standard' profile vs policy 'clinical-write' + [PASS] Execution profile: standard (production); durability required and enabled; settled-state detection required and enabled + [REFUSE] Certification: bundle is NOT certified under 'clinical-write': 5 policy violation(s) (e.g. prohibit_unarmed_clicks: identity-applicable step is UNARMED — would act with no identity verification (row text outside the target's label is too generic (< 12 squashed chars after volatile-line filtering))) + steps: step_005, step_009, step_013 + [REFUSE] Identity coverage: 2/8 entity-sensitive/consequential action(s) are UNARMED -- would act with no verified target identity + steps: step_005, step_009 + [PASS] Effect coverage: 1/1 consequential write(s) declare the required postcondition and confirmed system-of-record effect contracts + [PASS] Approval fallback: a verifier satisfying the required evidence tier is configured; 1 declared write(s) are covered + [PASS] Interstitial admission: no automatic interstitial actions declared + [WARN] Encrypted bundle: workflow.json plaintext (not required); WARNING: 18 template/screenshot asset(s) are unsealed (plaintext at rest) -- protect via disk encryption or run with --strict-templates to refuse + steps: templates/identifiers/step_003.png, templates/identifiers/step_007.png, templates/identifiers/step_011.png, templates/layout_fingerprint.json, templates/step_000.png, templates/step_000_expect.png, templates/step_001.png, templates/step_001_expect.png, ... (+10 more) + [PASS] Sealed manifest + version pin: integrity-sealed manifest re-verified (digest 3750eaac9ddb9659..., compiler 2.4.0) + -> RUN REFUSED (fail-closed): Certification, Identity coverage. Nothing was executed. diff --git a/packages/flow/benchmark/odoo_contacts/results/2026-10-28-click-teach/qualification/verify.resolved.json b/packages/flow/benchmark/odoo_contacts/results/2026-10-28-click-teach/qualification/verify.resolved.json new file mode 100644 index 000000000..27cb460fc --- /dev/null +++ b/packages/flow/benchmark/odoo_contacts/results/2026-10-28-click-teach/qualification/verify.resolved.json @@ -0,0 +1,132 @@ +{ + "app": { + "entry_url": null, + "headless": true, + "name": "Odoo 18 Community (local fixture, synthetic contacts)", + "reset": { + "command": null, + "http": null, + "python": "qualify_hooks:reset", + "timeout_s": 600.0 + }, + "session": "qualify_hooks:open_session", + "storage_state": null, + "surface": null, + "target": "web", + "url": "http://localhost:9412/", + "version": "odoo:18.0 (benchmark/odoo_contacts lock)", + "viewport": [ + 1280, + 800 + ] + }, + "campaign": { + "cases": [ + "representative", + "fault-ambiguity", + "fault-wrong-identity", + "fault-stale-identity", + "fault-weak-effect", + "fault-missing-effect" + ], + "id": "odoo-local-new-contact-v1", + "policy": "clinical-write", + "trials": 1 + }, + "effects": null, + "environment_id": "odoo-local-fixture-18.0", + "inputs": { + "file": null, + "params": {}, + "source": "recorded", + "unique": [] + }, + "minimum_effect_strength": "independent_system_of_record", + "minimum_readback": null, + "qualification_id": null, + "record": { + "duplicate_key": null, + "exact_new_set": false, + "expected_count": 1, + "fields": { + "city": "city", + "email": "email", + "name": "name", + "phone": "phone", + "street": "street", + "zip": "zip" + }, + "identity_field": "id", + "match": [ + "email" + ], + "name": "contact", + "timeout_s": 5.0 + }, + "save_step": "step_013", + "schema_version": "openadapt.verify/v1", + "verifier": { + "session": null, + "verifier": { + "access_token": null, + "access_token_env": null, + "auth": null, + "base_url": null, + "candidates": [], + "document_field_paths": {}, + "document_format": "json", + "document_text_pattern": null, + "evidence_keep_fields": null, + "evidence_redact_fields": [], + "field_paths": null, + "file_content_probe": null, + "file_min_size": 1, + "file_mtime_window_s": null, + "file_pattern": "*", + "fresh_session": null, + "glob": "*", + "graphql_freshness_field": null, + "graphql_freshness_window_s": null, + "graphql_query": null, + "graphql_records_path": "data", + "graphql_variables": {}, + "kind": "sql", + "mail_pattern": "*.eml", + "option_params": {}, + "options": {}, + "path_params": {}, + "poll_interval_s": 0.2, + "readback_min_ratio": 0.9, + "readback_region": null, + "records_drop_fields": [], + "records_key": "records", + "records_path": "/api/db", + "resource_type": "Observation", + "root": null, + "search_param_exprs": {}, + "search_params": {}, + "secret_env": {}, + "smart_backend": null, + "sql_connect_args": { + "dbname": "oa", + "host": "127.0.0.1", + "port": 9413, + "user": "oa_record_check" + }, + "sql_driver": "psycopg2", + "sql_password_env": "ODOO_RECORD_PASSWORD", + "sql_query": "SELECT id::text AS id, name, email, phone, street, city, zip FROM res_partner WHERE email = %(email)s AND active", + "sql_query_params": { + "email": { + "literal": null, + "param": "email" + } + }, + "sqlite_database": null, + "surfaces": {}, + "timeout_s": 5.0, + "ui_readback": null, + "verify_tls": true + } + } +} diff --git a/packages/flow/benchmark/odoo_contacts/verify.yaml b/packages/flow/benchmark/odoo_contacts/verify.yaml new file mode 100644 index 000000000..817c844f5 --- /dev/null +++ b/packages/flow/benchmark/odoo_contacts/verify.yaml @@ -0,0 +1,58 @@ +# Qualify the Odoo new-contact workflow on the local fixture. +# +# PYTHONPATH=.:benchmark/odoo_contacts openadapt-flow qualify path/to/bundle \ +# --verify benchmark/odoo_contacts/verify.yaml --out runs/odoo-qual \ +# --certify clinical-write +# +# Synthetic contacts only. The record check reads the contact back from +# Odoo's database through a read-only role (oa_record_check), never the +# screen: one parameterized SELECT of the typed fields, keyed by email. +schema_version: openadapt.verify/v1 +environment_id: odoo-local-fixture-18.0 +minimum_effect_strength: independent_system_of_record + +app: + target: web + name: Odoo 18 Community (local fixture, synthetic contacts) + url: http://localhost:9412/ + version: odoo:18.0 (benchmark/odoo_contacts lock) + # Signs the dedicated user in and opens a new contact; the reset archives + # the contacts the cases created. + session: qualify_hooks:open_session + reset: + python: qualify_hooks:reset + +verifier: + kind: sql + sql_query: >- + SELECT id::text AS id, name, email, phone, street, city, zip + FROM res_partner WHERE email = %(email)s AND active + sql_query_params: + email: {param: email} + sql_driver: psycopg2 + sql_connect_args: {host: 127.0.0.1, port: 9413, user: oa_record_check, dbname: oa} + # Set by the hooks module (qualify_hooks.record_password) from the fixture. + sql_password_env: ODOO_RECORD_PASSWORD + +record: + name: contact + match: [email] + identity_field: id + fields: + name: name + email: email + phone: phone + street: street + city: city + zip: zip + +# The form's save button. +save_step: step_013 + +inputs: + source: recorded + +campaign: + id: odoo-local-new-contact-v1 + trials: 1 + policy: clinical-write diff --git a/packages/flow/openadapt_flow/deployment.py b/packages/flow/openadapt_flow/deployment.py index d82e1d38e..460ba29e4 100644 --- a/packages/flow/openadapt_flow/deployment.py +++ b/packages/flow/openadapt_flow/deployment.py @@ -2202,23 +2202,37 @@ def _build_effect_verifier_unredacted( if not cfg.sql_query: raise ValueError("effects.kind 'sql' requires effects.sql_query") from openadapt_flow.runtime.effects import SqlRecordVerifier + from openadapt_flow.runtime.effects.sql import ( + READ_ONLY_DRIVERS, + read_only_connection, + ) connect: Callable[[], Any] if cfg.sqlite_database: import sqlite3 database = cfg.sqlite_database - connect = lambda: sqlite3.connect(database) # noqa: E731 + connect = lambda: read_only_connection( # noqa: E731 + sqlite3.connect(database), "sqlite3" + ) elif cfg.sql_driver: import importlib - module = importlib.import_module(cfg.sql_driver) + driver = cfg.sql_driver + module = importlib.import_module(driver) connect_args = dict(cfg.sql_connect_args) if cfg.sql_password_env: connect_args["password"] = _require_env( cfg.sql_password_env, "effects.sql_password_env" ) - connect = lambda: module.connect(**connect_args) # noqa: E731 + # The read-back never writes: where the driver can, its session + # is read-only, so a query that would write is refused there. + if driver in READ_ONLY_DRIVERS: + connect = lambda: read_only_connection( # noqa: E731 + module.connect(**connect_args), driver + ) + else: + connect = lambda: module.connect(**connect_args) # noqa: E731 else: raise ValueError( "effects.kind 'sql' requires effects.sqlite_database or " diff --git a/packages/flow/openadapt_flow/runtime/effects/sql.py b/packages/flow/openadapt_flow/runtime/effects/sql.py index 7d89d263d..e4ac99866 100644 --- a/packages/flow/openadapt_flow/runtime/effects/sql.py +++ b/packages/flow/openadapt_flow/runtime/effects/sql.py @@ -27,7 +27,10 @@ verifier under a dedicated READ-ONLY account (no INSERT/UPDATE/DELETE, no EXECUTE on writing functions, no sequence privileges), as ``docs/EFFECT_KIT.md`` instructs. The filter then guards against config -mistakes; the role guards against everything. +mistakes; the role guards against everything. In between, +:func:`read_only_connection` asks the driver itself for a read-only session +(``PRAGMA query_only``, a ``READ ONLY`` transaction), so a write is refused +even when the account could make one. Driver-neutral: the caller supplies a zero-arg ``connect`` callable returning a DB-API 2.0 connection (``sqlite3.connect`` in tests and CI; ``pymysql`` / @@ -88,6 +91,37 @@ _TABLE_NAME = re.compile(r"^[A-Za-z0-9_ ]+$") +#: Drivers :func:`read_only_connection` can make read-only, and how. +READ_ONLY_DRIVERS = frozenset( + {"sqlite3", "psycopg", "psycopg2", "pymysql", "MySQLdb", "mysql.connector"} +) + + +def read_only_connection(conn: Any, driver: str) -> Any: + """``conn`` with the driver's own read-only session switched on. + + Every transaction the read-back opens is then read-only on the server, + whatever the query says. Raises ``ValueError`` for a driver this can't + do for, so a caller that needs it fails closed. + """ + + if driver == "sqlite3": + conn.execute("PRAGMA query_only = ON") + elif driver == "psycopg2": + conn.set_session(readonly=True) + elif driver == "psycopg": + conn.read_only = True + elif driver in ("pymysql", "MySQLdb", "mysql.connector"): + cursor = conn.cursor() + try: + cursor.execute("SET SESSION TRANSACTION READ ONLY") + finally: + cursor.close() + else: + raise ValueError(f"no driver-level read-only session for {driver!r}") + return conn + + def assert_read_only_sql(query: str) -> str: """Validate that ``query`` is a single read-only SELECT; return it. diff --git a/packages/flow/openadapt_flow/skills.py b/packages/flow/openadapt_flow/skills.py index 5371985fd..f31ad7205 100644 --- a/packages/flow/openadapt_flow/skills.py +++ b/packages/flow/openadapt_flow/skills.py @@ -466,12 +466,103 @@ def fetch_full_contract( #: 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). +#: DB-API drivers a shipped SQL check may name (``sql_driver`` imports it): +#: those whose session the read-back can make read-only at the driver. SHIPPED_SQL_DRIVERS = frozenset( - {"psycopg", "psycopg2", "pymysql", "MySQLdb", "mysql.connector", "pymssql"} + {"psycopg", "psycopg2", "pymysql", "MySQLdb", "mysql.connector"} ) +#: Words a shipped read-back query may not contain: anything that writes, +#: changes the schema or session, calls out, or reads the server's files. +_SQL_FORBIDDEN = re.compile( + r"\b(insert|update|delete|merge|upsert|drop|alter|create|grant|revoke|" + r"truncate|copy|call|do|execute|exec|prepare|set|reset|lock|vacuum|analyze|" + r"into|notify|listen|load|import|attach|pragma|replace|dblink\w*|lo_\w+|" + r"pg_\w+|sleep|benchmark|outfile|dumpfile)\b", + re.IGNORECASE, +) +_SQL_PLACEHOLDER = re.compile(r"%\(([A-Za-z_][A-Za-z0-9_]*)\)s") +#: One select-list item: a plain column, optionally qualified, cast to text +#: and renamed. No function call, expression, literal or placeholder. +_SQL_COLUMN = re.compile( + r"(?i)^(?:[a-z_][a-z0-9_]*\.)?[a-z_][a-z0-9_]*" + r"(?:::(?:text|varchar))?(?:\s+as\s+[a-z_][a-z0-9_]*)?$" +) +_SQL_SHAPE = re.compile(r"(?is)^select\s+(.*?)(?:\s+from\s+(.*))?$") +_SQL_CALL = re.compile(r"[A-Za-z0-9_\"]\s*\(") + + +def _sql_shape_problems(text: str) -> list[str]: + """The query's shape: plain columns, from tables, inputs only after WHERE. + + This judges what the query reads, not its words: a select list that + echoes the inputs, or calls a function, would make any save match. + """ + + shape = _SQL_SHAPE.match(text) + if shape is None: + return ["verifier.sql_query must be SELECT FROM "] + columns, rest = shape.groups() + problems = [] + if rest is None: + problems.append("verifier.sql_query must be SELECT FROM
") + rest = "" + for item in columns.split(","): + if not _SQL_COLUMN.match(item.strip()): + problems.append( + f"verifier.sql_query selects {item.strip()!r}: select only plain " + "column names (optionally ::text and AS)" + ) + where = re.search(r"(?i)\bwhere\b", rest) + source = rest[: where.start()] if where else rest + if _SQL_PLACEHOLDER.search(source): + problems.append("verifier.sql_query binds inputs only after WHERE") + if _SQL_CALL.search(source) or "(" in source: + problems.append("verifier.sql_query reads tables, not functions or subqueries") + return problems + + +def shipped_sql_problems(query: Any, params: Any) -> list[str]: + """Why a shipped SQL read-back isn't one parameterized, read-only SELECT.""" + + if not isinstance(query, str) or not query.strip(): + return ["verifier.sql_query must be a SELECT"] + text = query.strip() + if text.endswith(";"): + text = text[:-1].rstrip() + problems = [] + if ";" in text: + problems.append("verifier.sql_query must be a single statement") + if not re.match(r"(?is)^select\s", text): + problems.append("verifier.sql_query must start with SELECT") + if "--" in text or "/*" in text or "#" in text: + problems.append("verifier.sql_query may not carry comments") + if "'" in text or "$$" in text or "${" in text: + problems.append( + "verifier.sql_query may not carry literals or ${} text: bind every " + "value with %(name)s" + ) + forbidden = sorted({m.group(1).lower() for m in _SQL_FORBIDDEN.finditer(text)}) + if forbidden: + problems.append( + f"verifier.sql_query is read-only, and may not use {', '.join(forbidden)}" + ) + problems.extend(_sql_shape_problems(text)) + bound = params if isinstance(params, dict) else {} + for name in _SQL_PLACEHOLDER.findall(text): + if name not in bound: + problems.append(f"verifier.sql_query binds %({name})s, which has no value") + if "%" in _SQL_PLACEHOLDER.sub("", text): + problems.append("verifier.sql_query binds values only as %(name)s") + for name, value in bound.items(): + if not (isinstance(value, dict) and set(value) == {"param"}): + problems.append( + f"verifier.sql_query_params.{name} must be {{param: ...}}, a typed input" + ) + return problems + + 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. @@ -568,6 +659,12 @@ def walk(node: Any, path: str, keyed: bool = True) -> None: for section in ("verifier", "system_of_record"): node = data.get(section) while isinstance(node, dict): + if "sql_query" in node or node.get("kind") == "sql": + problems.extend( + shipped_sql_problems( + node.get("sql_query"), node.get("sql_query_params") + ) + ) kind = node.get("kind") if kind is not None and kind not in SHIPPED_VERIFIER_KINDS: problems.append( diff --git a/packages/flow/registry/verify-requirements.txt b/packages/flow/registry/verify-requirements.txt index 257b90716..a91dfa973 100644 --- a/packages/flow/registry/verify-requirements.txt +++ b/packages/flow/registry/verify-requirements.txt @@ -1,6 +1,6 @@ # 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") \ +# uv pip compile <(printf "sigstore==4.5.1\npydantic==2.12.5\ncryptography==50.0.0\npyyaml==6.0.3\n") \ # --generate-hashes --python-version 3.12 --python-platform x86_64-manylinux_2_28 annotated-types==0.8.0 \ --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \ @@ -484,6 +484,80 @@ pygments==2.21.0 \ pyjwt==2.15.1 \ --hash=sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193 \ --hash=sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8 +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 requests==2.34.2 \ --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed diff --git a/packages/flow/scripts/build_registry_index.py b/packages/flow/scripts/build_registry_index.py index b80520f93..7d3e47fd2 100644 --- a/packages/flow/scripts/build_registry_index.py +++ b/packages/flow/scripts/build_registry_index.py @@ -52,9 +52,36 @@ def build(archives: Optional[Path] = None, asset_base: Optional[str] = None) -> } sources["schema_version"] = 1 validate_index(sources) + for item in sources["workflows"]: + check_shipped_contract(item) return sources +def check_shipped_contract(item: dict) -> None: + """Refuse a bundle whose shipped record check could run code or write. + + The same rules ``skill qualify`` applies on the user's machine + (``skills.shipped_contract_problems``): every address, credential name, + path and command is a declared variable, and a SQL read-back is one + parameterized, read-only SELECT. + """ + + if not item.get("contract"): + return + import yaml + + from openadapt_flow.skills import shipped_contract_problems + + path = ROOT / "registry" / item["path"] / item["contract"] + kinds = {v["name"]: v["kind"] for v in item.get("contract_vars") or []} + problems = shipped_contract_problems(yaml.safe_load(path.read_text()), kinds) + if problems: + raise SystemExit( + f"{item['id']}: its shipped record check may not ship:\n " + + "\n ".join(problems) + ) + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--check", action="store_true") diff --git a/packages/flow/tests/test_shipped_sql.py b/packages/flow/tests/test_shipped_sql.py new file mode 100644 index 000000000..57d6cda84 --- /dev/null +++ b/packages/flow/tests/test_shipped_sql.py @@ -0,0 +1,245 @@ +"""A skill's shipped SQL read-back is one parameterized, read-only SELECT. + +A public registry bundle may ship a minimal SQL record check (the typed +fields, keyed by the identity field, on the app's documented schema). The +rule (``skills.shipped_sql_problems``) holds at ``skill qualify`` and when the +index is built, so a bundle whose query could write, call out or splice text +never ships. +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest +import yaml + +from openadapt_flow import skills + +ROOT = Path(__file__).resolve().parents[1] + +ODOO_SELECT = ( + "SELECT id::text AS id, name, email, phone, street, city, zip " + "FROM res_partner WHERE email = %(email)s AND active" +) +PARAMS = {"email": {"param": "email"}} + + +def test_the_minimal_select_passes(): + assert skills.shipped_sql_problems(ODOO_SELECT, PARAMS) == [] + assert skills.shipped_sql_problems(ODOO_SELECT + ";", PARAMS) == [] + + +@pytest.mark.parametrize( + "query, message", + [ + (ODOO_SELECT + "; DELETE FROM res_partner", "single statement"), + ( + "UPDATE res_partner SET active = false WHERE email = %(email)s", + "start with SELECT", + ), + ("SELECT * INTO stolen FROM res_partner WHERE email = %(email)s", "into"), + ("SELECT pg_read_file('/etc/passwd')", "literals"), + ("SELECT pg_sleep(9) FROM res_partner WHERE email = %(email)s", "pg_sleep"), + ("SELECT name FROM res_partner WHERE email = '" + "x@y.z'", "literals"), + ( + "SELECT name FROM res_partner WHERE email = ${EMAIL}", + "literals or \\$\\{\\}", + ), + ("SELECT name FROM res_partner -- WHERE email = %(email)s", "comments"), + ("SELECT name FROM res_partner WHERE email = %(other)s", "has no value"), + ("SELECT name FROM res_partner WHERE email = %s", r"only as %\(name\)s"), + ("SELECT name FROM res_partner FOR UPDATE", "update"), + ], +) +def test_a_query_that_is_not_one_read_only_select_is_refused(query, message): + problems = skills.shipped_sql_problems(query, PARAMS) + assert any(__import__("re").search(message, p) for p in problems), problems + + +ECHO_PARAMS = {"email": {"param": "email"}, "name": {"param": "name"}} + + +@pytest.mark.parametrize( + "query, message", + [ + # Writes a sequence: setval and nextval are not the word "set". + ( + "SELECT setval(%(name)s::regclass, 1) AS id, " + "nextval(%(email)s::regclass) AS email", + "plain column names", + ), + # Changes a session setting. + ("SELECT set_config(%(name)s, %(email)s, false) AS id", "plain column names"), + # Runs SQL taken from a typed input. + ( + "SELECT query_to_xml(%(name)s, true, true, %(email)s)::text AS id", + "plain column names", + ), + # Echoes the inputs as the stored fields: any write would match. + ( + "SELECT id::text AS id, %(name)s AS name, %(email)s AS email " + "FROM res_partner WHERE id = (SELECT max(id) FROM res_partner)", + "plain column names", + ), + ( + "SELECT name FROM res_partner, query_to_xml(%(name)s, true, true, " + "%(email)s) WHERE email = %(email)s", + "only after WHERE", + ), + ( + "SELECT name FROM lower(%(name)s) WHERE email = %(email)s", + "not functions", + ), + ("SELECT name, email", "SELECT FROM
"), + ], +) +def test_a_query_whose_effect_is_not_a_plain_read_is_refused(query, message): + problems = skills.shipped_sql_problems(query, ECHO_PARAMS) + assert any(__import__("re").search(message, p) for p in problems), problems + + +def test_the_read_back_runs_on_a_read_only_connection(tmp_path): + """Whatever the query says, the driver refuses a write from the read-back.""" + + import sqlite3 + + from openadapt_flow.deployment import EffectsConfig, build_effect_verifier + + db = tmp_path / "sor.db" + conn = sqlite3.connect(db) + conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, who TEXT)") + conn.commit() + conn.close() + verifier = build_effect_verifier( + EffectsConfig( + kind="sql", sql_query="SELECT id, who FROM t", sqlite_database=str(db) + ) + ) + read_back = verifier._connect() + with pytest.raises(sqlite3.OperationalError, match="readonly"): + read_back.execute("INSERT INTO t (who) VALUES ('x')") + read_back.close() + + +def test_every_shipped_driver_has_a_read_only_session(): + from openadapt_flow.runtime.effects.sql import ( + READ_ONLY_DRIVERS, + read_only_connection, + ) + + assert skills.SHIPPED_SQL_DRIVERS <= READ_ONLY_DRIVERS + with pytest.raises(ValueError, match="no driver-level read-only"): + read_only_connection(object(), "pymssql") + + +@pytest.mark.parametrize( + "driver, expected", + [ + ("psycopg2", [("set_session", {"readonly": True})]), + ("pymysql", [("execute", "SET SESSION TRANSACTION READ ONLY")]), + ], +) +def test_the_driver_is_asked_for_a_read_only_session(driver, expected): + from openadapt_flow.runtime.effects.sql import read_only_connection + + calls = [] + + class Cursor: + def execute(self, sql): + calls.append(("execute", sql)) + + def close(self): + pass + + class Conn: + def set_session(self, **kw): + calls.append(("set_session", kw)) + + def cursor(self): + return Cursor() + + read_only_connection(Conn(), driver) + assert calls == expected + + +def test_a_bound_value_must_be_a_typed_input(): + problems = skills.shipped_sql_problems(ODOO_SELECT, {"email": "x@y.z"}) + assert any("must be {param: ...}" in p for p in problems) + + +def _odoo_contract(query: str = ODOO_SELECT) -> dict: + return { + "schema_version": "openadapt.verify/v1", + "app": { + "target": "web", + "name": "Odoo", + "version": "18.0", + "url": "${APP_URL}", + "storage_state": "${SIGNED_IN_STATE}", + "reset": {"command": "${RESET_COMMAND}"}, + }, + "verifier": { + "kind": "sql", + "sql_query": query, + "sql_query_params": PARAMS, + "sql_driver": "psycopg2", + "sql_connect_args": { + "host": "${DB_HOST}", + "port": "${DB_PORT}", + "user": "${DB_USER}", + "dbname": "${DB_NAME}", + }, + "sql_password_env": "${DB_PASSWORD_VAR}", + }, + "record": {"name": "contact", "match": ["email"], "fields": {"email": "email"}}, + } + + +KINDS = { + "APP_URL": "url", + "SIGNED_IN_STATE": "path", + "RESET_COMMAND": "command", + "DB_HOST": "url", + "DB_PORT": "command", + "DB_USER": "command", + "DB_NAME": "command", + "DB_PASSWORD_VAR": "env_var_name", +} + + +def test_a_shipped_sql_contract_is_checked_as_a_whole(): + assert skills.shipped_contract_problems(_odoo_contract(), KINDS) == [] + bad = _odoo_contract(ODOO_SELECT + "; DROP TABLE res_partner") + assert any( + "single statement" in p for p in skills.shipped_contract_problems(bad, KINDS) + ) + + +def test_the_index_build_refuses_a_bundle_whose_sql_could_write(tmp_path, monkeypatch): + import runpy + + root = tmp_path / "flow" + shutil.copytree(ROOT / "registry", root / "registry") + bundle = root / "registry/bundles/openemr/new-referral" + (bundle / "verify.yaml").write_text( + yaml.safe_dump( + _odoo_contract("DELETE FROM res_partner WHERE email = %(email)s") + ) + ) + sources = json.loads((root / "registry/sources.json").read_text()) + for item in sources["workflows"]: + if item["id"] == "openemr/new-referral": + item.update( + contract="verify.yaml", + contract_vars=[ + {"name": name, "kind": kind} for name, kind in KINDS.items() + ], + ) + (root / "registry/sources.json").write_text(json.dumps(sources)) + script = runpy.run_path(str(ROOT / "scripts/build_registry_index.py")) + monkeypatch.setitem(script["build"].__globals__, "ROOT", root) + with pytest.raises(SystemExit, match="start with SELECT"): + script["build"]() From d3cb0d140e4dcbd31c9bd86bf9aca16335362718 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 28 Oct 2026 21:16:36 -0400 Subject: [PATCH 3/4] registry: hashed install in the check job; list the trusted root; mcp 2.x in the test The check job installed the engine without hashes; it now uses locked_install.py like every other job. The shipped Sigstore trusted root is added to the reviewed public artifact inventory. The emitted MCP server test stands in for FastMCP, which mcp 2.x renamed, as test_emit does. --- .github/workflows/release-registry.yml | 5 ++++- packages/flow/public-artifacts.json | 1 + .../flow/tests/test_skills_registry_review.py | 18 ++++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-registry.yml b/.github/workflows/release-registry.yml index d3e67c6ef..042130083 100644 --- a/.github/workflows/release-registry.yml +++ b/.github/workflows/release-registry.yml @@ -116,8 +116,11 @@ jobs: - 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 -m pip install -e packages/types -e 'packages/flow[dev]' + 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 diff --git a/packages/flow/public-artifacts.json b/packages/flow/public-artifacts.json index e4b21a985..b6f951642 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/tests/test_skills_registry_review.py b/packages/flow/tests/test_skills_registry_review.py index 241876978..abc014fdf 100644 --- a/packages/flow/tests/test_skills_registry_review.py +++ b/packages/flow/tests/test_skills_registry_review.py @@ -640,9 +640,27 @@ def test_run_checks_the_deployments_app_address(tmp_path, monkeypatch): 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) From 6f665c66bac69f0a4a96bd9af3abf6151786b390 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 28 Oct 2026 21:53:07 -0400 Subject: [PATCH 4/4] public-artifacts: list the Odoo benchmark's fixture and evidence files --- packages/flow/public-artifacts.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/flow/public-artifacts.json b/packages/flow/public-artifacts.json index b6f951642..c332497a5 100644 --- a/packages/flow/public-artifacts.json +++ b/packages/flow/public-artifacts.json @@ -232,6 +232,11 @@ "benchmark/macos_native/textedit_counted_3plus1_b1b61a5_20260717.json", "benchmark/multiapp-standard.yaml", "benchmark/o2c_recon/results.json", + "benchmark/odoo_contacts/compose.yml", + "benchmark/odoo_contacts/environment.lock.json", + "benchmark/odoo_contacts/results/2026-10-28-click-teach/qualification/qualify-notes.json", + "benchmark/odoo_contacts/results/2026-10-28-click-teach/qualification/verify.resolved.json", + "benchmark/odoo_contacts/verify.yaml", "benchmark/openemr/latency_cost.png", "benchmark/openemr/results.json", "benchmark/openemr_live/docker-compose.yml",